added dd06 test support, organized 2d viewer and 3d viewer code organization. #10

Merged
zhangyijian merged 1 commits from test/dd06 into dev 2026-07-26 17:05:12 +08:00
32 changed files with 2326 additions and 936 deletions
+101
View File
@@ -0,0 +1,101 @@
{
"name": "dd06-ov",
"type": "single_camera",
"version": "1.0",
"camera": {
"resolution_raw": [
1280,
720
],
"fps": 30,
"resolution_stitch": [
390,
550
],
"source_scale": 0.4,
"old": [
672,
728,
1,
0.5
],
"work": [
504,
546,
1
],
"work2": [
420,
450,
0.8
],
"work3": [
350,
375,
4,
0.7
],
"work4": [
280,
300,
4,
0.4,
0.2
]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": [
"raft",
"dis"
],
"hhd_downsample": 1
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 0.2,
"div_threshold": 0.022,
"expansion_threshold": 0.01,
"kernel_size": [
5,
5
],
"denoise_sigma": 12
},
"roi": {
"type": "polygon",
"image_size": [
1280,
720
],
"created_at": "2026-07-26T11:56:15",
"points": [
[
242,
1
],
[
402,
597
],
[
799,
606
],
[
976,
2
]
],
"mask_path": "dd06-ov_mask.png",
"metadata": {
"source": "./data/dd06/0726.mp4"
}
}
}
+15 -6
View File
@@ -1,20 +1,29 @@
"""Public GUI entry points for finger examples."""
"""Public GUI entry points for Orisys example apps.
from .dashboard import FingerDashboardWindow, run_dashboard
from .layout import (
LOG_CONFIG,
LOG_PANEL,
Two apps live under ``examples/gui``:
- Finger 3D dashboard: ``from gui import run_dashboard`` (also ``gui.viewer3d``)
- 2D sensor monitor: ``from gui.viewer2d import run_viewer``
Acquisition adapters (no widgets) live in ``gui.sensors``.
Shared bootstrap: ``gui.app``, ``gui.app_paths``, ``gui.log_config``, ``gui.widgets``.
"""
from .log_config import LOG_CONFIG, LogConfig
from .viewer3d import (
FingerDashboardWindow,
LAYOUT,
LOG_PANEL,
MESH_CONTACT_ARROW,
MESH_LIGHTING,
PANEL_SETUP,
TOP_ROW,
DashboardLayout,
LogConfig,
MeshLighting,
PanelRow,
PanelSetup,
PanelSlot,
run_dashboard,
top_row_splitter_sizes,
top_row_slot_index,
)
+52
View File
@@ -0,0 +1,52 @@
"""Shared log-panel configuration for Qt viewers (2D and 3D)."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class LogConfig:
"""Log panel — edit LOG_CONFIG or DashboardLayout.log_config."""
status_enabled: bool = True
max_lines: int = 2000
font_family: str = "Cascadia Mono"
font_pt: int = 12
show_flow: bool = True
show_mask: bool = True
def format_status(
self,
*,
fps: float,
fnormal: float,
fshearx: float,
fsheary: float,
flow_rms: float = 0.0,
mask_coverage: float = 0.0,
wall_fps: float | None = None,
contact_xyz_mm: tuple[float, float, float] | None = None,
) -> str:
shear_mag = float((fshearx**2 + fsheary**2) ** 0.5)
if wall_fps is not None:
parts = [f"FPS={wall_fps:.1f}", f"sdk={fps:.1f}"]
else:
parts = [f"FPS={fps:.1f}"]
parts.extend(
[
f"Fn={fnormal:.1f}",
f"|(Fx,Fy)|={shear_mag:.1f}",
f"Fx, Fy=({fshearx:.1f}, {fsheary:.1f})",
]
)
force_line = " ".join(parts)
if contact_xyz_mm is None:
contact_line = "contact=---"
else:
cx, cy, cz = contact_xyz_mm
contact_line = f"contact=({cx:.1f}, {cy:.1f}, {cz:.1f}) mm"
return f"{force_line}\n{contact_line}"
LOG_CONFIG = LogConfig()
+1 -1
View File
@@ -1,5 +1,5 @@
{
"video_source": "0",
"video_source": "D:/repo/orisys-sdk/data/dd02-0703/1.mp4",
"config_path": "./config/dd02-ov.json",
"finger_assets_dir": "D:/repo/orisys-sdk/assets/finger/30fps-0703",
"csv_save_dir": "./outputs/csv",
+23
View File
@@ -0,0 +1,23 @@
"""Sensor acquisition adapters for Qt / multi-sensor viewers (no Qt widgets)."""
from .adapter import (
Dd06RoiAdapter,
FrameSnapshot,
OrisysSensorAdapter,
SensorAdapter,
calibrate_normal_force,
create_sensor_adapter,
_snapshot_from_sensor,
)
from . import roi_geometry
__all__ = [
"Dd06RoiAdapter",
"FrameSnapshot",
"OrisysSensorAdapter",
"SensorAdapter",
"calibrate_normal_force",
"create_sensor_adapter",
"roi_geometry",
"_snapshot_from_sensor",
]
+330
View File
@@ -0,0 +1,330 @@
"""Sensor adapters that normalize frames for Qt / multi-sensor viewers."""
from __future__ import annotations
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
import cv2
import numpy as np
import orisys
from .roi_geometry import (
build_roi_warp,
load_json_config,
load_roi_polygon,
open_video_source,
rectified_output_size,
warp_roi_to_rectangle,
)
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
@dataclass
class FrameSnapshot:
"""Normalized per-frame payload consumed by the Qt viewer."""
image: Optional[np.ndarray] = None
flow: Optional[np.ndarray] = None
depth: Optional[np.ndarray] = None
contour: Optional[np.ndarray] = None
centroid: Any = None
# Raw SDK force channels used for curves (same units for FN/FX/FY).
normal_force: float = 0.0
shear_x: float = 0.0
shear_y: float = 0.0
# Optional calibrated FN in Newtons for export/display overlays.
normal_force_calibrated: float = 0.0
fps: float = 0.0
timestamp: float = 0.0
raw_image: Optional[np.ndarray] = None
processed_image: Optional[np.ndarray] = None
flow_overlay: Optional[np.ndarray] = None
record_frame: Optional[np.ndarray] = None
def calibrate_normal_force(raw_value: float) -> float:
"""Convert raw normal-force reading to Newtons using y = mx + c."""
return NORMAL_FORCE_CAL_SLOPE * float(raw_value) + NORMAL_FORCE_CAL_OFFSET
class SensorAdapter(ABC):
"""Common interface between Qt and concrete sensor backends."""
@abstractmethod
def start(self) -> None:
raise NotImplementedError
@abstractmethod
def read(self) -> Optional[FrameSnapshot]:
raise NotImplementedError
@abstractmethod
def reset(self) -> None:
raise NotImplementedError
@abstractmethod
def close(self) -> None:
raise NotImplementedError
def _bgr_from_gray_or_bgr(img: np.ndarray) -> np.ndarray:
if img is None:
return None
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 1:
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
return img
def _snapshot_from_sensor(
sensor,
*,
raw_image=None,
processed_image=None,
) -> Optional[FrameSnapshot]:
"""Build a FrameSnapshot with HHD/forces refreshed every frame."""
if sensor is None or sensor.img is None:
return None
# Always decompose so FN/FX/FY update every frame (motion gating freezes forces).
sensor.compute_deformation(decompose=True, check_motion=False)
sensor.compute_contact()
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = (
sensor.read_info(
sensor.info.FPS,
sensor.info.VRAW,
sensor.info.VNORMAL,
sensor.info.IMG,
sensor.info.CONTOUR,
sensor.info.CENTROID,
sensor.info.DEPTH,
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY,
)
)
image_bgr = _bgr_from_gray_or_bgr(img)
processed = processed_image if processed_image is not None else image_bgr
record = None
if getattr(sensor, "frame", None) is not None:
record = _bgr_from_gray_or_bgr(sensor.frame)
elif processed is not None:
record = processed
flow_overlay = None
if image_bgr is not None and flow is not None:
flow_overlay = orisys.util.draw_arrows(
image_bgr,
flow,
threshold=2,
grid_spacing=20,
arrow_scale=0.5,
below_threshold_color=(160, 160, 160),
)
fn_raw = float(fnormal)
return FrameSnapshot(
image=image_bgr,
flow=flow,
depth=depth_map,
contour=contour,
centroid=centroid,
normal_force=fn_raw,
shear_x=float(fshearx),
shear_y=float(fsheary),
normal_force_calibrated=calibrate_normal_force(fn_raw),
fps=float(fps),
timestamp=time.time(),
raw_image=raw_image,
processed_image=processed,
flow_overlay=flow_overlay,
record_frame=record,
)
class OrisysSensorAdapter(SensorAdapter):
"""Direct orisys.Sensor capture path (compound-eye / single-camera)."""
def __init__(
self,
video,
config_name,
cal_path=None,
verbose=True,
*,
rotate_ccw90=False,
motion_threshold=1,
):
self.video = video
self.config_name = config_name
self.cal_path = cal_path
self.verbose = verbose
self.rotate_ccw90 = bool(rotate_ccw90)
self.motion_threshold = motion_threshold
self.sensor = None
def start(self) -> None:
src = self.video
try:
src = int(str(src).strip())
except (TypeError, ValueError):
pass
self.sensor = orisys.Sensor(
src,
config_name=self.config_name,
cal_path=self.cal_path,
verbose=self.verbose,
)
def read(self) -> Optional[FrameSnapshot]:
if self.sensor is None:
return None
img = self.sensor.get_img()
if img is None:
return None
if self.rotate_ccw90:
self.sensor.img = cv2.rotate(self.sensor.img, cv2.ROTATE_90_COUNTERCLOCKWISE)
if self.sensor.frame is not None:
self.sensor.frame = cv2.rotate(
self.sensor.frame, cv2.ROTATE_90_COUNTERCLOCKWISE
)
return _snapshot_from_sensor(
self.sensor,
raw_image=_bgr_from_gray_or_bgr(self.sensor.frame)
if self.sensor.frame is not None
else None,
processed_image=_bgr_from_gray_or_bgr(self.sensor.img),
)
def reset(self) -> None:
if self.sensor is not None:
self.sensor.reset()
def close(self) -> None:
if self.sensor is not None:
self.sensor.disconnect()
self.sensor = None
class Dd06RoiAdapter(SensorAdapter):
"""DD06 path: external capture + polygon ROI rectify + already_processed Sensor."""
def __init__(self, video, config_name, verbose=True, *, motion_threshold=0):
self.video = video
self.config_name = config_name
self.verbose = verbose
self.motion_threshold = motion_threshold
self.sensor = None
self.cap = None
self.roi_points = None
self.roi_image_size = None
self.out_size = None
self.warp_matrix = None
self.warp_src = None
self.last_frame_size = None
self._source = None
def start(self) -> None:
config = load_json_config(self.config_name)
self.roi_points, self.roi_image_size = load_roi_polygon(config)
self.out_size = rectified_output_size(config, self.roi_points)
self.sensor = orisys.Sensor(
"external",
config_name=self.config_name,
verbose=self.verbose,
)
self.cap, self._source = open_video_source(self.video)
self.warp_matrix = None
self.last_frame_size = None
if self.verbose:
print(
f"DD06 adapter: ROI points={len(self.roi_points)} "
f"source_size={self.roi_image_size} output={self.out_size} "
f"video={self._source}"
)
def read(self) -> Optional[FrameSnapshot]:
if self.sensor is None or self.cap is None:
return None
ok, frame = self.cap.read()
if not ok or frame is None:
return None
frame_h, frame_w = frame.shape[:2]
frame_size = (frame_w, frame_h)
if self.warp_matrix is None or self.last_frame_size != frame_size:
self.warp_matrix, self.warp_src, _ = build_roi_warp(
self.roi_points,
self.roi_image_size,
frame_size,
self.out_size,
)
self.last_frame_size = frame_size
if self.verbose:
print(f"Built ROI warp for frame {frame_w}x{frame_h}")
rectified = warp_roi_to_rectangle(frame, self.warp_matrix, self.out_size)
self.sensor.get_img(frame=rectified, already_processed=True)
return _snapshot_from_sensor(
self.sensor,
raw_image=frame,
processed_image=rectified,
)
def reset(self) -> None:
if self.sensor is not None:
self.sensor.reset()
def close(self) -> None:
if self.cap is not None:
self.cap.release()
self.cap = None
if self.sensor is not None:
self.sensor.disconnect()
self.sensor = None
def create_sensor_adapter(
sensor_type: str,
*,
video,
config_name,
cal_path=None,
verbose=True,
) -> SensorAdapter:
"""Factory for Qt / demo viewers."""
kind = str(sensor_type or "orisys").strip().lower()
if kind in ("orisys", "direct", "default", "compound_eye", "single_camera"):
# Preserve historical Wuxi viewer rotation for compound-eye demos.
rotate = kind in ("orisys", "direct", "default", "compound_eye")
return OrisysSensorAdapter(
video,
config_name,
cal_path=cal_path,
verbose=verbose,
rotate_ccw90=rotate,
motion_threshold=1,
)
if kind in ("dd06", "dd06-ov", "roi", "polygon_roi"):
return Dd06RoiAdapter(
video,
config_name,
verbose=verbose,
motion_threshold=0,
)
raise ValueError(
f"Unknown sensor_type={sensor_type!r}; expected 'orisys' or 'dd06'"
)
+140
View File
@@ -0,0 +1,140 @@
"""Polygon ROI geometry helpers for rectifying tactile sensor frames."""
from __future__ import annotations
import json
from pathlib import Path
import cv2
import numpy as np
def load_json_config(path):
path = Path(path)
with path.open("r", encoding="utf-8") as f:
return json.load(f)
def load_roi_polygon(config):
"""Return (points Nx2 float32, image_size [w,h]) from nested or legacy ROI fields."""
roi = config.get("roi") if isinstance(config.get("roi"), dict) else config
if not isinstance(roi, dict):
raise ValueError("Config has no usable ROI section")
image_size = roi.get("image_size") or config.get("camera", {}).get("resolution_raw")
if image_size is None:
raise ValueError("ROI image_size is missing")
points = roi.get("points") or roi.get("polygon") or roi.get("roi_polygon")
if points is None and roi.get("xyxy") is not None:
x1, y1, x2, y2 = [int(v) for v in roi["xyxy"]]
points = [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]
if not points:
raise ValueError("ROI points are missing")
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
if pts.shape[0] < 4:
raise ValueError(f"ROI needs at least 4 points, got {pts.shape[0]}")
return pts, [int(image_size[0]), int(image_size[1])]
def scale_points(points, source_size, target_size):
source_w, source_h = float(source_size[0]), float(source_size[1])
target_w, target_h = float(target_size[0]), float(target_size[1])
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2).copy()
if source_w > 0 and source_h > 0 and (source_w, source_h) != (target_w, target_h):
pts[:, 0] *= target_w / source_w
pts[:, 1] *= target_h / source_h
return pts
def order_quad_points(points):
"""Order 4 points as TL, TR, BR, BL for perspective transforms."""
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
if pts.shape[0] != 4:
hull = cv2.convexHull(pts.astype(np.float32))
rect = cv2.minAreaRect(hull)
pts = cv2.boxPoints(rect).astype(np.float32)
s = pts.sum(axis=1)
diff = np.diff(pts, axis=1).reshape(-1)
ordered = np.zeros((4, 2), dtype=np.float32)
ordered[0] = pts[np.argmin(s)] # top-left
ordered[2] = pts[np.argmax(s)] # bottom-right
ordered[1] = pts[np.argmin(diff)] # top-right
ordered[3] = pts[np.argmax(diff)] # bottom-left
return ordered
def rectified_output_size(config, points=None):
"""Prefer camera.resolution_stitch; fall back to ROI bounding box size."""
stitch = config.get("camera", {}).get("resolution_stitch")
if stitch is not None and len(stitch) >= 2:
return int(stitch[0]), int(stitch[1])
if points is not None:
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2)
width = max(
int(np.linalg.norm(pts[1] - pts[0])),
int(np.linalg.norm(pts[2] - pts[3])),
1,
)
height = max(
int(np.linalg.norm(pts[3] - pts[0])),
int(np.linalg.norm(pts[2] - pts[1])),
1,
)
return width, height
raise ValueError("Cannot determine rectified output size")
def build_roi_warp(points, source_image_size, frame_size, output_size):
"""
Build a perspective transform that maps the ROI polygon in frame coords
onto a rectangle of ``output_size`` (width, height).
"""
frame_w, frame_h = int(frame_size[0]), int(frame_size[1])
scaled = scale_points(points, source_image_size, (frame_w, frame_h))
src = order_quad_points(scaled)
out_w, out_h = int(output_size[0]), int(output_size[1])
dst = np.array(
[
[0.0, 0.0],
[out_w - 1.0, 0.0],
[out_w - 1.0, out_h - 1.0],
[0.0, out_h - 1.0],
],
dtype=np.float32,
)
matrix = cv2.getPerspectiveTransform(src, dst)
return matrix, src, (out_w, out_h)
def warp_roi_to_rectangle(frame, matrix, output_size):
out_w, out_h = int(output_size[0]), int(output_size[1])
return cv2.warpPerspective(
frame,
matrix,
(out_w, out_h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE,
)
def open_video_source(video):
"""Open a camera index or video file. Returns (cap, resolved_source)."""
text = str(video).strip()
if text.isdigit():
source = int(text)
cap = cv2.VideoCapture(source, cv2.CAP_DSHOW)
if not cap.isOpened():
cap.release()
cap = cv2.VideoCapture(source)
else:
path = Path(text)
if not path.exists():
raise FileNotFoundError(f"Video not found: {path}")
source = str(path)
cap = cv2.VideoCapture(source)
if not cap.isOpened():
raise RuntimeError(f"Failed to open video source: {video}")
return cap, source
+24
View File
@@ -0,0 +1,24 @@
"""2D Qt sensor monitor (image + force curves; no finger 3D mesh)."""
from .backend import (
ForceHistory,
ForcePlotState,
QtViewerBackend,
ViewerFrame,
image_shape_meta,
panel_width_for_aspect,
select_display_image,
)
from .window import QtViewerWindow, run_viewer
__all__ = [
"ForceHistory",
"ForcePlotState",
"QtViewerBackend",
"QtViewerWindow",
"ViewerFrame",
"image_shape_meta",
"panel_width_for_aspect",
"run_viewer",
"select_display_image",
]
+312
View File
@@ -0,0 +1,312 @@
"""Qt-facing display backend for sensor viewer snapshots.
Separates acquisition (`gui.sensors.FrameSnapshot`) from Qt display state:
normalized images, force history / filtering, and aspect-ratio metadata.
Does not import camera, ROI, or `orisys.Sensor`.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Sequence, Tuple
import cv2
import numpy as np
from scipy.signal import butter, filtfilt
FORCE_SAMPLE_RATE_HZ = 30.0
PLOT_BUFFER_POINTS = 120
PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ
Y_AXIS_MIN_SPAN = 20000.0
@dataclass
class ViewerFrame:
"""Normalized display payload for one UI tick."""
display_image: np.ndarray
image_height: int
image_width: int
aspect_ratio: float # width / height
depth: Optional[np.ndarray]
normal_force: float
shear_x: float
shear_y: float
normal_force_calibrated: float
fps: float
timestamp: float
record_frame: Optional[np.ndarray]
@dataclass
class ForcePlotState:
"""Filtered force series ready for pyqtgraph curves."""
times: np.ndarray # shape (n,)
values: np.ndarray # shape (n, 3) FN, FX, FY
sample_count: int
x_range: Tuple[float, float]
y_ranges: Tuple[Tuple[float, float], Tuple[float, float], Tuple[float, float]]
def lowpass_filter(
data,
cutoff_hz: float,
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
order: int = 2,
) -> np.ndarray:
"""Zero-phase Butterworth low-pass for uniformly sampled 1-D data."""
y = np.asarray(data, dtype=np.float64)
if cutoff_hz == 0:
return y.copy()
if y.size == 0:
return y
nyquist = 0.5 * sample_rate_hz
if cutoff_hz < 0 or cutoff_hz >= nyquist:
raise ValueError(
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
)
b, a = butter(order, cutoff_hz / nyquist, btype="low")
padlen = 3 * (max(len(b), len(a)) - 1)
if y.size <= padlen:
return y.copy()
return filtfilt(b, a, y)
def ensure_bgr(img: Optional[np.ndarray]) -> Optional[np.ndarray]:
if img is None:
return None
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 1:
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
return img
def image_shape_meta(img: np.ndarray) -> Tuple[int, int, float]:
"""Return (height, width, width/height) from a display image."""
h, w = int(img.shape[0]), int(img.shape[1])
aspect = float(w) / float(h) if h > 0 else 1.0
return h, w, aspect
def select_display_image(snapshot) -> np.ndarray:
"""Pick flow overlay / processed / raw image for the left panel."""
arrows = getattr(snapshot, "flow_overlay", None)
if arrows is not None:
out = ensure_bgr(arrows)
if out is not None:
return out
for attr in ("processed_image", "image", "raw_image"):
base = getattr(snapshot, attr, None)
out = ensure_bgr(base)
if out is not None:
return out
return np.zeros((400, 400, 3), dtype=np.uint8)
def bgr_to_rgb_bytes(img_bgr: np.ndarray) -> Tuple[np.ndarray, int, int]:
"""Convert BGR uint8 image to contiguous RGB for QImage."""
rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
if not rgb.flags["C_CONTIGUOUS"]:
rgb = np.ascontiguousarray(rgb)
h, w = rgb.shape[:2]
return rgb, h, w
def scale_bgr_to_fit(
img_bgr: np.ndarray,
max_w: int,
max_h: int,
) -> np.ndarray:
"""Resize preserving aspect ratio to fit inside (max_w, max_h)."""
if max_w <= 0 or max_h <= 0:
return img_bgr
h, w = img_bgr.shape[:2]
if h <= 0 or w <= 0:
return img_bgr
scale = min(max_w / float(w), max_h / float(h))
if abs(scale - 1.0) < 1e-6:
return img_bgr
nw = max(1, int(round(w * scale)))
nh = max(1, int(round(h * scale)))
return cv2.resize(img_bgr, (nw, nh), interpolation=cv2.INTER_AREA)
def panel_width_for_aspect(
available_height: int,
aspect_ratio: float,
*,
min_size: int = 160,
) -> int:
"""Preferred left-panel width given available height and image aspect."""
h = max(int(available_height), min_size)
w = int(round(h * float(aspect_ratio)))
return max(w, min_size)
class ForceHistory:
"""Ring-style force history buffer with filtering and y-range smoothing."""
def __init__(
self,
npoints: int = PLOT_BUFFER_POINTS,
*,
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
lowpass_cutoff_hz: float = 10.0,
y_axis_min_span: float = Y_AXIS_MIN_SPAN,
y_range_alpha: float = 0.5,
):
self.npoints = int(npoints)
self.sample_rate_hz = float(sample_rate_hz)
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
self.y_axis_min_span = float(y_axis_min_span)
self.y_range_alpha = float(y_range_alpha)
self.window_sec = self.npoints / self.sample_rate_hz
self.reset()
def reset(self) -> None:
self.data = np.full((self.npoints, 2, 3), np.nan, dtype=np.float64)
self.sample_count = 0
self._y_range_smooth = [None, None, None]
def push(self, t: float, fn: float, fx: float, fy: float) -> None:
values = (float(fn), float(fx), float(fy))
if self.sample_count < self.npoints:
idx = self.sample_count
self.data[idx, 0, :] = t
self.data[idx, 1, :] = values
self.sample_count += 1
return
self.data[:-1, 0, :] = self.data[1:, 0, :]
self.data[:-1, 1, :] = self.data[1:, 1, :]
self.data[-1, 0, :] = t
self.data[-1, 1, :] = values
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> Tuple[float, float]:
if self._y_range_smooth[channel] is None:
smoothed = (ymin, ymax)
else:
prev_min, prev_max = self._y_range_smooth[channel]
alpha = self.y_range_alpha
ymin = alpha * ymin + (1.0 - alpha) * prev_min
ymax = alpha * ymax + (1.0 - alpha) * prev_max
smoothed = (ymin, ymax)
span = smoothed[1] - smoothed[0]
if span < self.y_axis_min_span:
mid = 0.5 * (smoothed[0] + smoothed[1])
half = self.y_axis_min_span / 2.0
smoothed = (mid - half, mid + half)
self._y_range_smooth[channel] = smoothed
pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01)
return smoothed[0] - pad, smoothed[1] + pad
def plot_state(self) -> Optional[ForcePlotState]:
n = self.sample_count
if n == 0:
return None
data_plot = self.data[:n].copy()
for i in range(3):
data_plot[:, 1, i] = lowpass_filter(
data_plot[:, 1, i],
self.lowpass_cutoff_hz,
sample_rate_hz=self.sample_rate_hz,
)
times = data_plot[:, 0, 0].copy()
values = data_plot[:, 1, :].copy()
y_ranges = tuple(
self._smooth_y_range(i, float(values[:, i].min()), float(values[:, i].max()))
for i in range(3)
)
if n < self.npoints:
x_range = (0.0, self.window_sec)
else:
x0 = float(times[0])
x1 = float(times[-1])
if x1 <= x0:
x1 = x0 + self.window_sec
x_range = (x0, x1)
return ForcePlotState(
times=times,
values=values,
sample_count=n,
x_range=x_range,
y_ranges=y_ranges, # type: ignore[arg-type]
)
class QtViewerBackend:
"""Normalize FrameSnapshot into display images + force history."""
def __init__(
self,
*,
npoints: int = PLOT_BUFFER_POINTS,
lowpass_cutoff_hz: float = 10.0,
y_axis_min_span: float = Y_AXIS_MIN_SPAN,
):
self.force_history = ForceHistory(
npoints=npoints,
lowpass_cutoff_hz=lowpass_cutoff_hz,
y_axis_min_span=y_axis_min_span,
)
self._start_time: Optional[float] = None
self.last_viewer_frame: Optional[ViewerFrame] = None
def reset(self) -> None:
self.force_history.reset()
self._start_time = None
self.last_viewer_frame = None
def ingest(self, snapshot, *, now: Optional[float] = None) -> ViewerFrame:
"""Convert a FrameSnapshot into ViewerFrame and update force history."""
display = select_display_image(snapshot)
h, w, aspect = image_shape_meta(display)
fn = float(getattr(snapshot, "normal_force", 0.0))
fx = float(getattr(snapshot, "shear_x", 0.0))
fy = float(getattr(snapshot, "shear_y", 0.0))
fn_cal = float(getattr(snapshot, "normal_force_calibrated", 0.0))
fps = float(getattr(snapshot, "fps", 0.0))
ts = float(getattr(snapshot, "timestamp", 0.0) or 0.0)
if now is None:
now = ts if ts > 0 else 0.0
if self._start_time is None:
self._start_time = now
t = now - self._start_time
self.force_history.push(t, fn, fx, fy)
record = ensure_bgr(getattr(snapshot, "record_frame", None))
depth = getattr(snapshot, "depth", None)
frame = ViewerFrame(
display_image=display,
image_height=h,
image_width=w,
aspect_ratio=aspect,
depth=depth,
normal_force=fn,
shear_x=fx,
shear_y=fy,
normal_force_calibrated=fn_cal,
fps=fps,
timestamp=ts,
record_frame=record,
)
self.last_viewer_frame = frame
return frame
def plot_state(self) -> Optional[ForcePlotState]:
return self.force_history.plot_state()
+120
View File
@@ -0,0 +1,120 @@
"""Styles for the 2D Qt sensor monitor."""
CURVE_THEME_COLOR = "#00E5FF"
STYLE = """
QMainWindow {
background-color: #000000;
}
/* ---- 图像面板 ---- */
QLabel#arrowsLabel {
border: 1px solid #262626;
border-radius: 4px;
background-color: #0b0b0b;
}
/* ---- 顶栏按钮 & 输入 ---- */
QPushButton {
background-color: #1a1a1a;
color: #999999;
border: 1px solid #333333;
border-radius: 4px;
padding: 6px 16px;
font-family: "SimHei";
font-size: 13px;
}
QPushButton:hover {
background-color: #262626;
color: #cccccc;
border: 1px solid #555555;
}
QPushButton#btnStart {
background-color: #0d2818;
color: #4caf84;
border: 1px solid #1a4d30;
}
QPushButton#btnStart:hover {
background-color: #143d24;
color: #6fd4a0;
}
QPushButton#btnStart:disabled {
background-color: #0f0f0f;
color: #333333;
border: 1px solid #222222;
}
QPushButton#btnStop {
background-color: #2b1010;
color: #e05555;
border: 1px solid #4d1a1a;
}
QPushButton#btnStop:hover {
background-color: #3d1818;
color: #f07070;
}
QPushButton#btnStop:disabled {
background-color: #0f0f0f;
color: #333333;
border: 1px solid #222222;
}
QLineEdit {
background-color: #0d0d0d;
color: #999999;
border: 1px solid #333333;
border-radius: 4px;
padding: 5px 10px;
font-size: 13px;
}
/* ---- 力曲线容器 ---- */
GraphicsLayoutWidget {
border: 1px solid #262626;
border-radius: 4px;
}
/* ---- 日志面板 ---- */
QPlainTextEdit {
background-color: #0b0b0b;
color: #777777;
border: 1px solid #262626;
border-radius: 4px;
font-family: "Cascadia Code", "Consolas", "Courier New", monospace;
font-size: 13px;
padding: 8px;
selection-background-color: #1a3a4a;
selection-color: #cccccc;
}
QPlainTextEdit QScrollBar:vertical {
background-color: #0b0b0b;
width: 6px;
margin: 1px;
border-radius: 3px;
}
QPlainTextEdit QScrollBar::handle:vertical {
background-color: #262626;
border-radius: 3px;
min-height: 30px;
}
QPlainTextEdit QScrollBar::handle:vertical:hover {
background-color: #3a3a3a;
}
QPlainTextEdit QScrollBar::add-line:vertical,
QPlainTextEdit QScrollBar::sub-line:vertical {
height: 0px;
}
QPlainTextEdit QScrollBar::add-page:vertical,
QPlainTextEdit QScrollBar::sub-page:vertical {
background: none;
}
QSplitter::handle {
background-color: #1a1a1a;
}
QSplitter::handle:vertical {
height: 1px;
}
QSplitter::handle:horizontal {
width: 1px;
}
"""
+491
View File
@@ -0,0 +1,491 @@
"""2D Qt sensor monitor window (image + force curves)."""
from __future__ import annotations
import os
import sys
import time
from datetime import datetime
from pathlib import Path
import cv2
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QFont, QIcon
from PyQt5.QtWidgets import (
QApplication,
QFileDialog,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QPushButton,
QSplitter,
QVBoxLayout,
QWidget,
)
try:
import pyqtgraph as pg
except Exception as exc: # pragma: no cover
raise ImportError(
"PyQtGraph is not installed. Please install it using 'pip install pyqt5 pyqtgraph'."
) from exc
from gui.sensors import create_sensor_adapter
from gui.widgets.log_panel import TeeStream, WidgetLogHandler, make_log_widget
from gui.widgets.qt_pixmap import ndarray_to_pixmap, pixmap_keep_aspect
from .backend import PLOT_BUFFER_POINTS, PLOT_WINDOW_SEC, QtViewerBackend, panel_width_for_aspect
from .styles import CURVE_THEME_COLOR, STYLE
class QtViewerWindow(QMainWindow):
"""2D monitor: flow/image panel + FN/FX/FY curves driven by QtViewerBackend."""
def __init__(self, args, log_file):
super().__init__()
self.setWindowTitle("Orisys SDK")
self.setStyleSheet(STYLE)
logo_path = Path(__file__).resolve().parents[3] / "logo.png"
if logo_path.exists():
self.setWindowIcon(QIcon(str(logo_path)))
self.sensor_adapter = None
self._last_snapshot = None
self._last_viewer_frame = None
self._display_aspect = 1.0
self._source_pixmap = None
self.args = args
self.is_save = False
self.frame_count = 0
self.writer = None
self.save_path = None
self.viewer_backend = QtViewerBackend(
npoints=PLOT_BUFFER_POINTS,
lowpass_cutoff_hz=getattr(args, "cutoff", 10.0),
)
self._setup_ui()
self._create_plots()
self.camera_edit.setText(args.video)
self.config_edit.setText(args.config)
self.cal_edit.setText(args.cal)
self.log_handler = WidgetLogHandler(self.log_widget)
sys.stdout = TeeStream(log_file, self.log_handler)
sys.stderr = sys.stdout
self.timer = QTimer()
self.timer.timeout.connect(self._on_timer)
self._running = False
def _setup_ui(self):
central = QWidget()
self.setCentralWidget(central)
root_layout = QVBoxLayout(central)
root_layout.setContentsMargins(6, 6, 6, 6)
root_layout.setSpacing(6)
bar = QHBoxLayout()
bar.setSpacing(8)
self.btn_camera = QPushButton("摄像机")
self.btn_camera.setObjectName("btnCamera")
self.btn_camera.clicked.connect(self._select_camera)
bar.addWidget(self.btn_camera)
self.camera_edit = QLineEdit("1")
self.camera_edit.setPlaceholderText("摄像头编号 或 视频路径")
bar.addWidget(self.camera_edit, 1)
self.btn_config = QPushButton("配置")
self.btn_config.setObjectName("btnConfig")
self.btn_config.clicked.connect(self._select_config)
bar.addWidget(self.btn_config)
self.config_edit = QLineEdit("./config/ddjx01.json")
bar.addWidget(self.config_edit, 1)
self.btn_reset = QPushButton("重置")
self.btn_reset.setObjectName("btnReset")
self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self._on_reset)
bar.addWidget(self.btn_reset)
self.cal_edit = QLineEdit("./config/ddjx01.npy")
bar.addWidget(self.cal_edit, 1)
self.btn_start = QPushButton("启动")
self.btn_start.setObjectName("btnStart")
self.btn_start.clicked.connect(self._on_start)
bar.addWidget(self.btn_start)
self.btn_stop = QPushButton("暂停")
self.btn_stop.setObjectName("btnStop")
self.btn_stop.setEnabled(False)
self.btn_stop.clicked.connect(self._on_stop)
bar.addWidget(self.btn_stop)
root_layout.addLayout(bar)
v_splitter = QSplitter(Qt.Vertical)
h_splitter = QSplitter(Qt.Horizontal)
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(6)
self.arrows_label = QLabel()
self.arrows_label.setObjectName("arrowsLabel")
self.arrows_label.setMinimumSize(160, 160)
self.arrows_label.setScaledContents(False)
self.arrows_label.setAlignment(Qt.AlignCenter)
left_layout.addWidget(self.arrows_label)
h_splitter.addWidget(left)
pg.setConfigOption("background", "#000000")
pg.setConfigOption("foreground", "#777777")
pg.setConfigOptions(antialias=True)
self.graph_widget = pg.GraphicsLayoutWidget()
self.graph_widget.setMinimumWidth(300)
h_splitter.addWidget(self.graph_widget)
self.h_splitter = h_splitter
v_splitter.addWidget(h_splitter)
from gui.log_config import LOG_CONFIG
self.log_widget = make_log_widget(LOG_CONFIG)
v_splitter.addWidget(self.log_widget)
v_splitter.setStretchFactor(0, 4)
v_splitter.setStretchFactor(1, 1)
root_layout.addWidget(v_splitter)
def _create_plots(self):
titles = ["法向力 (raw)", "切向力 X", "切向力 Y"]
self.plots = []
self.curves = []
self.dot_glows = []
self.dot_cores = []
axis_pen = pg.mkPen(color=(38, 38, 38), width=1)
label_font = QFont("SimHei", 9)
for i, title in enumerate(titles):
p = self.graph_widget.addPlot(title=None)
p.setTitle(title, color="#999999", size="14pt")
p.titleLabel.setFont(QFont("SimHei", 14))
p.setLabel("bottom", "时间", units="s")
p.getAxis("bottom").label.setFont(label_font)
p.showAxis("top", False)
p.showAxis("right", False)
p.getAxis("left").setPen(axis_pen)
p.getAxis("bottom").setPen(axis_pen)
p.showGrid(x=False, y=True, alpha=0.12)
tick_font = QFont("Consolas", 11)
tick_font.setStyleHint(QFont.Monospace)
p.getAxis("left").setTickFont(tick_font)
p.getAxis("bottom").setTickFont(tick_font)
line_pen = pg.mkPen(color=CURVE_THEME_COLOR, width=2)
curve = p.plot(pen=line_pen, fillLevel=0)
dot_glow = p.plot(
[],
[],
pen=None,
symbol="o",
symbolSize=8,
symbolPen=None,
symbolBrush=(0, 229, 255, 76),
)
dot_core = p.plot(
[],
[],
pen=None,
symbol="o",
symbolSize=4,
symbolPen=None,
symbolBrush=(255, 255, 255, 255),
)
p.setXRange(0, PLOT_WINDOW_SEC, padding=0)
p.enableAutoRange(x=False, y=False)
self.plots.append(p)
self.curves.append(curve)
self.dot_glows.append(dot_glow)
self.dot_cores.append(dot_core)
if i < 2:
self.graph_widget.nextRow()
def adjust_image_panel(self):
"""Size the left panel from the current image aspect ratio."""
top_bar_h = 48
log_h = self.log_widget.height()
spacing = 20
available = max(self.height() - top_bar_h - log_h - spacing, 160)
image_w = panel_width_for_aspect(available, self._display_aspect, min_size=160)
max_w = max(self.width() // 2, 160)
image_w = min(image_w, max_w)
self.h_splitter.setSizes([image_w, max(self.width() - image_w, 200)])
self._render_display_pixmap()
def resizeEvent(self, event):
super().resizeEvent(event)
self._render_display_pixmap()
def _render_display_pixmap(self):
if self._source_pixmap is None or self._source_pixmap.isNull():
return
target = self.arrows_label.size()
if target.width() < 2 or target.height() < 2:
return
self.arrows_label.setPixmap(pixmap_keep_aspect(self._source_pixmap, target))
def _on_timer(self):
if self.sensor_adapter is None:
return
snapshot = self.sensor_adapter.read()
if snapshot is None:
print("Failed to get image")
return
self._last_snapshot = snapshot
viewer_frame = self.viewer_backend.ingest(snapshot, now=time.time())
self._last_viewer_frame = viewer_frame
first_image = self._source_pixmap is None
aspect_changed = abs(viewer_frame.aspect_ratio - self._display_aspect) > 1e-3
self._display_aspect = viewer_frame.aspect_ratio
if self.is_save and self.writer is not None and snapshot.record_frame is not None:
self.frame_count += 1
save_img = snapshot.record_frame
if len(save_img.shape) == 2 or (
len(save_img.shape) == 3 and save_img.shape[2] == 1
):
if len(save_img.shape) == 3:
save_img = save_img.squeeze(2)
save_img = cv2.cvtColor(save_img, cv2.COLOR_GRAY2BGR)
self.writer.write(save_img)
print(
f"FPS={viewer_frame.fps:.2f}, "
f"法向力(raw)={viewer_frame.normal_force:.4f}, "
f"切向力X={viewer_frame.shear_x:.4f}, "
f"切向力Y={viewer_frame.shear_y:.4f}, "
f"法向力(N)={viewer_frame.normal_force_calibrated:.6f}"
)
self._source_pixmap = ndarray_to_pixmap(viewer_frame.display_image, bgr=True)
if first_image or aspect_changed:
self.adjust_image_panel()
else:
self._render_display_pixmap()
plot_state = self.viewer_backend.plot_state()
if plot_state is None:
return
for i, curve in enumerate(self.curves):
x = plot_state.times
y = plot_state.values[:, i]
curve.setData(x, y)
ymin, ymax = plot_state.y_ranges[i]
self.plots[i].setYRange(ymin, ymax)
self.dot_glows[i].setData(x[-1:], y[-1:])
self.dot_cores[i].setData(x[-1:], y[-1:])
x0, x1 = plot_state.x_range
for p in self.plots:
p.setXRange(x0, x1, padding=0)
def _on_start(self):
if self._running:
return
self.btn_start.setText("启动中...")
self.btn_start.setEnabled(False)
QApplication.processEvents()
try:
src = self.camera_edit.text()
cfg = self.config_edit.text()
cal = self.cal_edit.text()
verbose = getattr(self.args, "verbose", True)
sensor_type = getattr(self.args, "sensor_type", "orisys")
self.sensor_adapter = create_sensor_adapter(
sensor_type,
video=src,
config_name=cfg,
cal_path=cal,
verbose=verbose,
)
self.sensor_adapter.start()
except Exception as e:
self.btn_start.setText("启动")
self.btn_start.setEnabled(True)
self.sensor_adapter = None
print(f"启动失败: {e}")
return
self.viewer_backend.reset()
self._running = True
self.timer.start(10)
self.btn_start.setText("启动")
self.btn_stop.setEnabled(True)
self.btn_reset.setEnabled(True)
print(
f"▶ 启动 — sensor_type={getattr(self.args, 'sensor_type', 'orisys')}, "
f"摄像机={src}, 配置={cfg}, 标定={cal}"
)
def _on_stop(self):
if not self._running:
return
self._running = False
self.timer.stop()
self.btn_stop.setText("停止中...")
self.btn_stop.setEnabled(False)
QApplication.processEvents()
if self.sensor_adapter is not None:
self.sensor_adapter.close()
self.sensor_adapter = None
self._last_snapshot = None
self.btn_start.setEnabled(True)
self.btn_stop.setText("暂停")
self.btn_reset.setEnabled(False)
print("⏸ 已暂停")
def _select_camera(self):
path, _ = QFileDialog.getOpenFileName(
self,
"选择视频文件",
"data",
"视频 (*.mp4 *.avi *.mov);;所有文件 (*)",
)
if path:
self.camera_edit.setText(path)
def _select_config(self):
path, _ = QFileDialog.getOpenFileName(
self,
"选择配置文件",
"config",
"JSON (*.json);;所有文件 (*)",
)
if path:
self.config_edit.setText(path)
def _on_reset(self):
if self.sensor_adapter is None or not self._running:
print("请先启动传感器后再重置追踪器。")
return
self.sensor_adapter.reset()
print("追踪器已重置。")
def keyPressEvent(self, event):
key = event.key()
if key == Qt.Key_Escape:
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
elif key == Qt.Key_Q:
print("\n正在退出示例程序...")
self.close()
elif key == Qt.Key_S:
self._start_recording()
elif key == Qt.Key_E:
self._stop_recording()
elif key == Qt.Key_R:
self._on_reset()
else:
super().keyPressEvent(event)
def _start_recording(self):
if self.sensor_adapter is None:
print("请先启动采集")
return
self.save_path = "./data/pulse_test.mp4"
os.makedirs(os.path.dirname(self.save_path), exist_ok=True)
save_img = None
if self._last_snapshot is not None:
save_img = self._last_snapshot.record_frame
if save_img is None:
print("当前没有可用帧,无法确定录像尺寸。")
return
temp_img = save_img.copy()
if len(temp_img.shape) == 2 or (
len(temp_img.shape) == 3 and temp_img.shape[2] == 1
):
if len(temp_img.shape) == 3:
temp_img = temp_img.squeeze(2)
temp_img = cv2.cvtColor(temp_img, cv2.COLOR_GRAY2BGR)
fh, fw = temp_img.shape[:2]
self.writer = cv2.VideoWriter(
self.save_path,
cv2.VideoWriter_fourcc(*"mp4v"),
30,
(fw, fh),
)
if self.writer.isOpened():
self.is_save = True
print(f"开始录像:{self.save_path}")
else:
print(f"无法创建录像文件:{self.save_path}")
self.writer = None
def _stop_recording(self):
self.is_save = False
if self.writer is not None:
self.writer.release()
self.writer = None
if self.save_path is not None:
print(f"录像结束:{self.save_path},总帧数:{self.frame_count}")
else:
print(f"录像结束,总帧数:{self.frame_count}")
self.frame_count = 0
def closeEvent(self, event):
self.timer.stop()
if self.writer is not None:
self.writer.release()
if self.sensor_adapter is not None:
self.sensor_adapter.close()
self.sensor_adapter = None
event.accept()
def run_viewer(args, *, log_dir: str | Path | None = None) -> int:
"""Launch the 2D Qt viewer and block until the window closes."""
from gui.app import get_or_create_qapp
if log_dir is None:
log_dir = Path(__file__).resolve().parents[3] / "scripts" / "logs"
else:
log_dir = Path(log_dir)
log_dir.mkdir(parents=True, exist_ok=True)
log_path = log_dir / f"qt_viewer_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
log_file = open(log_path, "w", encoding="utf-8")
sys.stdout = log_file
sys.stderr = sys.stdout
print("\n按键说明:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏")
app = get_or_create_qapp("Orisys SDK")
window = QtViewerWindow(args, log_file)
window.showFullScreen()
QApplication.processEvents()
window.adjust_image_panel()
return app.exec_()
+36
View File
@@ -0,0 +1,36 @@
"""Finger 3D Qt dashboard (mesh + UV arrows + force charts)."""
from .dashboard import FingerDashboardWindow, run_dashboard
from .layout import (
LOG_PANEL,
LAYOUT,
MESH_CONTACT_ARROW,
MESH_LIGHTING,
PANEL_SETUP,
TOP_ROW,
DashboardLayout,
MeshLighting,
PanelRow,
PanelSetup,
PanelSlot,
top_row_splitter_sizes,
top_row_slot_index,
)
__all__ = [
"DashboardLayout",
"FingerDashboardWindow",
"LAYOUT",
"LOG_PANEL",
"MESH_CONTACT_ARROW",
"MESH_LIGHTING",
"MeshLighting",
"PANEL_SETUP",
"TOP_ROW",
"PanelRow",
"PanelSetup",
"PanelSlot",
"run_dashboard",
"top_row_splitter_sizes",
"top_row_slot_index",
]
@@ -21,7 +21,7 @@ from pipeline.config import PipelineConfig
from pipeline.csv_recorder import CsvRecorder
from pipeline.service import FingerPipeline
from .app_paths import resource_path
from gui.app_paths import resource_path
from .camera_discovery import list_cameras, pick_default_camera
from .preferences import get_preferred_video_source, load_preferences, save_preferences
from .layout import (
@@ -33,14 +33,12 @@ from .layout import (
)
from .mesh_view_state import MeshViewState
from .styles import STYLE
from gui.widgets import TeeStream, WidgetLogHandler, make_log_widget
from .widgets import (
ArrowPanelWidget,
ForceChartWidget,
MeshViewportWidget,
SettingsPanelWidget,
TeeStream,
WidgetLogHandler,
make_log_widget,
)
@@ -689,7 +687,7 @@ class FingerDashboardWindow(QMainWindow):
def run_dashboard(config: PipelineConfig, *, layout: DashboardLayout = LAYOUT) -> int:
"""Launch the unified finger dashboard and block until the window closes."""
from .app import get_or_create_qapp
from gui.app import get_or_create_qapp
print("\n按键说明:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏")
print("GUI:Record CSV / Stop CSV 写入接触点与力到所选目录。")
@@ -47,6 +47,7 @@ import math
from dataclasses import dataclass
from typing import Literal
from gui.log_config import LOG_CONFIG, LogConfig
from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
from viz.flow_arrow import FlowArrowStyle
from viz.render_2d import ArrowBackground, ArrowCanvasColors
@@ -193,53 +194,6 @@ ARROW_CANVAS_COLORS = ArrowCanvasColors(
)
@dataclass(frozen=True)
class LogConfig:
"""Log panel — edit LOG_CONFIG or DashboardLayout.log_config."""
status_enabled: bool = True
max_lines: int = 2000
font_family: str = "Cascadia Mono"
font_pt: int = 12
show_flow: bool = True
show_mask: bool = True
def format_status(
self,
*,
fps: float,
fnormal: float,
fshearx: float,
fsheary: float,
flow_rms: float = 0.0,
mask_coverage: float = 0.0,
wall_fps: float | None = None,
contact_xyz_mm: tuple[float, float, float] | None = None,
) -> str:
shear_mag = float((fshearx**2 + fsheary**2) ** 0.5)
if wall_fps is not None:
parts = [f"FPS={wall_fps:.1f}", f"sdk={fps:.1f}"]
else:
parts = [f"FPS={fps:.1f}"]
parts.extend(
[
f"Fn={fnormal:.1f}",
f"|(Fx,Fy)|={shear_mag:.1f}",
f"Fx, Fy=({fshearx:.1f}, {fsheary:.1f})",
]
)
force_line = " ".join(parts)
if contact_xyz_mm is None:
contact_line = "contact=---"
else:
cx, cy, cz = contact_xyz_mm
contact_line = f"contact=({cx:.1f}, {cy:.1f}, {cz:.1f}) mm"
return f"{force_line}\n{contact_line}"
LOG_CONFIG = LogConfig()
@dataclass(frozen=True)
class DashboardLayout:
"""Window chrome, chart styling, and panel hierarchy."""
@@ -1,4 +1,4 @@
"""Load/save dashboard user preferences near this module."""
"""Load/save dashboard user preferences for the finger 3D viewer."""
from __future__ import annotations
@@ -6,15 +6,16 @@ import json
from pathlib import Path
from typing import Any
from .app_paths import resource_path
from gui.app_paths import resource_path
PREFERENCE_FILE = "preference.json"
LEGACY_CAMERA_SELECTION_FILE = "camera_selection.json"
_GUI_DIR = Path(__file__).resolve().parent
# Keep preference.json at examples/gui/ for backward compatibility.
_GUI_DIR = Path(__file__).resolve().parents[1]
def preference_path() -> Path:
# Keep preference file beside this module: examples/gui/preference.json
# Keep preference file at examples/gui/preference.json
return _GUI_DIR / PREFERENCE_FILE
+15
View File
@@ -0,0 +1,15 @@
"""Finger-specific Qt widgets for the 3D dashboard."""
from .arrow_image import ArrowPanelWidget
from .force_charts import ForceChartWidget
from .image_panel import ImagePanel
from .mesh_viewport import MeshViewportWidget
from .settings_panel import SettingsPanelWidget
__all__ = [
"ArrowPanelWidget",
"ForceChartWidget",
"ImagePanel",
"MeshViewportWidget",
"SettingsPanelWidget",
]
@@ -9,7 +9,7 @@ from viz.render_2d import ArrowBackground, ArrowCanvasColors, render_uv_arrows
from ..layout import DashboardLayout, LAYOUT
from .image_panel import ImagePanel
from .qt_pixmap import ndarray_to_pixmap
from gui.widgets.qt_pixmap import ndarray_to_pixmap
class ArrowPanelWidget(ImagePanel):
@@ -6,8 +6,8 @@ import numpy as np
from PyQt5.QtGui import QFont
from PyQt5.QtWidgets import QSizePolicy
from gui.layout import DashboardLayout, LAYOUT
from gui.styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
from ..layout import DashboardLayout, LAYOUT
from ..styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
from pipeline.frame import FingerFrameView
from viz.signal_filters import lowpass_filter
@@ -6,7 +6,7 @@ from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
from .qt_pixmap import pixmap_keep_aspect
from gui.widgets.qt_pixmap import pixmap_keep_aspect
class ImagePanel(QWidget):
@@ -11,7 +11,7 @@ from viz.offscreen_mesh import OffscreenFingerMeshRenderer
from ..layout import DashboardLayout, LAYOUT
from ..mesh_view_state import MeshViewState
from ..mesh_render_worker import MeshRenderWorker
from .qt_pixmap import ndarray_to_pixmap
from gui.widgets.qt_pixmap import ndarray_to_pixmap
from .image_panel import ImagePanel
_VIEW_ROTATE_SENSITIVITY = 0.35
+1 -11
View File
@@ -1,19 +1,9 @@
"""Qt widgets for the finger dashboard."""
"""Shared Qt widgets used by 2D and 3D viewers."""
from .settings_panel import SettingsPanelWidget
from .force_charts import ForceChartWidget
from .log_panel import TeeStream, WidgetLogHandler, make_log_widget
from .mesh_viewport import MeshViewportWidget
from .arrow_image import ArrowPanelWidget
from .qt_pixmap import ndarray_to_pixmap, pixmap_keep_aspect
from .image_panel import ImagePanel
__all__ = [
"ArrowPanelWidget",
"SettingsPanelWidget",
"ForceChartWidget",
"MeshViewportWidget",
"ImagePanel",
"TeeStream",
"WidgetLogHandler",
"make_log_widget",
+1 -1
View File
@@ -86,7 +86,7 @@ class WidgetLogHandler:
def make_log_widget(config=None) -> QPlainTextEdit:
if config is None:
from ..layout import LOG_CONFIG
from ..log_config import LOG_CONFIG
config = LOG_CONFIG
widget = QPlainTextEdit()
+26 -792
View File
@@ -1,794 +1,31 @@
"""
Orisys SDK 实时监控 — Qt 统一界面
将所有可视化和曲线整合到一个窗口中,适合销售演示使用。
SDK version: 0.3.1
"""
#!/usr/bin/env python3
"""Shim for the Orisys 2D Qt sensor monitor (see examples/gui/viewer2d/)."""
import os
import sys
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
def _fix_qt_plugin_path():
"""OpenCV redirects Qt to cv2/qt/plugins; use PyQt5's plugins instead."""
try:
from PyQt5.QtCore import QLibraryInfo
os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = QLibraryInfo.location(
QLibraryInfo.PluginsPath
)
except Exception:
path = os.environ.get("QT_QPA_PLATFORM_PLUGIN_PATH", "")
if "cv2" in path.replace("\\", "/"):
os.environ.pop("QT_QPA_PLATFORM_PLUGIN_PATH", None)
_fix_qt_plugin_path()
import orisys
import numpy as np
import time
import argparse
from datetime import datetime
from scipy.signal import butter, filtfilt
from pathlib import Path
# Force curve sampling rate (Hz), matches typical sensor loop ~30 FPS.
FORCE_SAMPLE_RATE_HZ = 30.0
PLOT_BUFFER_POINTS = 120
PLOT_WINDOW_SEC = PLOT_BUFFER_POINTS / FORCE_SAMPLE_RATE_HZ # 4 s window
# Minimum y-axis span so autoscale does not over-zoom on small fluctuations.
Y_AXIS_MIN_SPAN = 20000
CURVE_THEME_COLOR = '#00E5FF'
NORMAL_FORCE_CAL_SLOPE = 3.92685554668795e-05
NORMAL_FORCE_CAL_OFFSET = 0.0217596967278535
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
from PyQt5.QtWidgets import (
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
QLabel, QApplication, QSplitter, QPlainTextEdit,
QPushButton, QFileDialog, QLineEdit,
)
from PyQt5.QtCore import Qt, QTimer
from PyQt5 import QtGui
from PyQt5.QtGui import QImage, QPixmap, QFont, QIcon
_EXAMPLES_DIR = Path(__file__).resolve().parents[1] / "examples"
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
try:
import pyqtgraph as pg
except Exception:
print("PyQtGraph is not installed. Please install it using 'pip install pyqt5 pyqtgraph'.")
sys.exit(1)
from gui.viewer2d import run_viewer
# ---------------------------------------------------------------------------
# 工具
# ---------------------------------------------------------------------------
def cv2_to_pixmap(img_bgr: np.ndarray) -> QPixmap:
rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
h, w, ch = rgb.shape
qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888)
return QPixmap.fromImage(qimg)
def draw_magnitude_map(vfield, threshold=3, colormap=cv2.COLORMAP_JET) -> np.ndarray:
flow_field = vfield
magnitude = np.sqrt(flow_field[:, :, 0] ** 2 + flow_field[:, :, 1] ** 2)
downsample_factor = 10
h, w = magnitude.shape
magnitude_downsampled = magnitude[::downsample_factor, ::downsample_factor]
magnitude_smoothed = cv2.resize(magnitude_downsampled, (w, h), interpolation=cv2.INTER_CUBIC)
magnitude_thresholded = magnitude_smoothed.copy()
magnitude_thresholded[magnitude_smoothed < threshold] = 0
mag_min, mag_max = magnitude_thresholded.min(), magnitude_thresholded.max()
if mag_max > mag_min:
magnitude_norm = ((magnitude_thresholded - mag_min) / (mag_max - mag_min) * 255).astype(np.uint8)
else:
magnitude_norm = np.zeros_like(magnitude_thresholded, dtype=np.uint8)
magnitude_colored = cv2.applyColorMap(magnitude_norm, colormap)
out_img = cv2.resize(magnitude_colored, (400, 400), interpolation=cv2.INTER_CUBIC)
return out_img
def lowpass_filter(data, cutoff_hz, sample_rate_hz=FORCE_SAMPLE_RATE_HZ, order=2):
"""
Zero-phase Butterworth low-pass filter for uniformly sampled 1-D data.
Parameters
----------
data : array-like
Input samples acquired at `sample_rate_hz` (default 30 Hz).
cutoff_hz : float
-3 dB cutoff frequency in Hz; use 0 to skip filtering (return raw data).
Otherwise must satisfy 0 < cutoff_hz < sample_rate_hz / 2.
sample_rate_hz : float
Sampling rate of `data` in Hz.
order : int
Butterworth filter order.
Returns
-------
np.ndarray
Filtered 1-D array with the same length as `data`.
"""
y = np.asarray(data, dtype=np.float64)
if cutoff_hz == 0:
return y.copy()
if y.size == 0:
return y
nyquist = 0.5 * sample_rate_hz
if cutoff_hz < 0 or cutoff_hz >= nyquist:
raise ValueError(
f"cutoff_hz must be in (0, {nyquist}) for sample_rate_hz={sample_rate_hz}"
)
b, a = butter(order, cutoff_hz / nyquist, btype="low")
padlen = 3 * (max(len(b), len(a)) - 1)
if y.size <= padlen:
return y.copy()
return filtfilt(b, a, y)
def calibrate_normal_force(raw_value):
"""Convert raw normal-force reading to Newtons using y = mx + c."""
return NORMAL_FORCE_CAL_SLOPE * raw_value + NORMAL_FORCE_CAL_OFFSET
class TeeStream:
"""同时写入多个流"""
def __init__(self, *streams):
self.streams = streams
def write(self, text):
for s in self.streams:
s.write(text)
def flush(self):
for s in self.streams:
s.flush()
class WidgetLogHandler:
"""将 print 输出实时显示到 QPlainTextEdit"""
def __init__(self, widget: QPlainTextEdit):
self.widget = widget
self.buf = ""
def write(self, text: str):
self.buf += text
while "\n" in self.buf:
line, self.buf = self.buf.split("\n", 1)
if line:
self.widget.appendPlainText(line)
def flush(self):
if self.buf:
self.widget.appendPlainText(self.buf)
self.buf = ""
# ---------------------------------------------------------------------------
# 样式表
# ---------------------------------------------------------------------------
STYLE = """
QMainWindow {
background-color: #000000;
}
/* ---- 图像面板 ---- */
QLabel#arrowsLabel {
border: 1px solid #262626;
border-radius: 4px;
background-color: #0b0b0b;
}
/* ---- 顶栏按钮 & 输入 - — */
QPushButton {
background-color: #1a1a1a;
color: #999999;
border: 1px solid #333333;
border-radius: 4px;
padding: 6px 16px;
font-family: "SimHei";
font-size: 13px;
}
QPushButton:hover {
background-color: #262626;
color: #cccccc;
border: 1px solid #555555;
}
QPushButton#btnStart {
background-color: #0d2818;
color: #4caf84;
border: 1px solid #1a4d30;
}
QPushButton#btnStart:hover {
background-color: #143d24;
color: #6fd4a0;
}
QPushButton#btnStart:disabled {
background-color: #0f0f0f;
color: #333333;
border: 1px solid #222222;
}
QPushButton#btnStop {
background-color: #2b1010;
color: #e05555;
border: 1px solid #4d1a1a;
}
QPushButton#btnStop:hover {
background-color: #3d1818;
color: #f07070;
}
QPushButton#btnStop:disabled {
background-color: #0f0f0f;
color: #333333;
border: 1px solid #222222;
}
QLineEdit {
background-color: #0d0d0d;
color: #999999;
border: 1px solid #333333;
border-radius: 4px;
padding: 5px 10px;
font-size: 13px;
}
/* ---- 力曲线容器 ---- */
GraphicsLayoutWidget {
border: 1px solid #262626;
border-radius: 4px;
}
/* ---- 日志面板 ---- */
QPlainTextEdit {
background-color: #0b0b0b;
color: #777777;
border: 1px solid #262626;
border-radius: 4px;
font-family: "Cascadia Code", "Consolas", "Courier New", monospace;
font-size: 13px;
padding: 8px;
selection-background-color: #1a3a4a;
selection-color: #cccccc;
}
QPlainTextEdit QScrollBar:vertical {
background-color: #0b0b0b;
width: 6px;
margin: 1px;
border-radius: 3px;
}
QPlainTextEdit QScrollBar::handle:vertical {
background-color: #262626;
border-radius: 3px;
min-height: 30px;
}
QPlainTextEdit QScrollBar::handle:vertical:hover {
background-color: #3a3a3a;
}
QPlainTextEdit QScrollBar::add-line:vertical,
QPlainTextEdit QScrollBar::sub-line:vertical {
height: 0px;
}
QPlainTextEdit QScrollBar::add-page:vertical,
QPlainTextEdit QScrollBar::sub-page:vertical {
background: none;
}
QSplitter::handle {
background-color: #1a1a1a;
}
QSplitter::handle:vertical {
height: 1px;
}
QSplitter::handle:horizontal {
width: 1px;
}
"""
# ---------------------------------------------------------------------------
# 主窗口
# ---------------------------------------------------------------------------
class MainWindow(QMainWindow):
def __init__(self, args, log_file):
super().__init__()
self.setWindowTitle("Orisys SDK")
self.setStyleSheet(STYLE)
logo_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logo.png")
if os.path.exists(logo_path):
self.setWindowIcon(QIcon(logo_path))
self.sensor = None
self.start_time = None
self.args = args
# 录像状态
self.is_save = False
self.frame_count = 0
self.writer = None
self.save_path = None
# 曲线数据缓冲(先填满 npoints 再滚动;120 点 @ 30 Hz = 4 s)
self.npoints = PLOT_BUFFER_POINTS
self.lowpass_cutoff_hz = getattr(args, "cutoff", 5.0)
self._plot_sample_count = 0
self._init_plot_buffer()
self._setup_ui()
self._create_plots()
# 同步参数到 UI
self.camera_edit.setText(args.video)
self.config_edit.setText(args.config)
self.cal_edit.setText(args.cal)
# stdout → 日志文件 + 界面日志面板
self.log_handler = WidgetLogHandler(self.log_widget)
sys.stdout = TeeStream(log_file, self.log_handler)
sys.stderr = sys.stdout
self._y_range_alpha = 0.5
self.timer = QTimer()
self.timer.timeout.connect(self._on_timer)
self._running = False
def _init_plot_buffer(self):
"""Empty buffer; x-axis grows until npoints real samples, then scrolls."""
self.data = np.full((self.npoints, 2, 3), np.nan, dtype=np.float64)
self._plot_sample_count = 0
self._y_range_smooth = [None, None, None]
# ---- UI 构建 ----
def _setup_ui(self):
central = QWidget()
self.setCentralWidget(central)
root_layout = QVBoxLayout(central)
root_layout.setContentsMargins(6, 6, 6, 6)
root_layout.setSpacing(6)
# ---- 顶栏 ----
bar = QHBoxLayout()
bar.setSpacing(8)
self.btn_camera = QPushButton("摄像机")
self.btn_camera.setObjectName("btnCamera")
self.btn_camera.clicked.connect(self._select_camera)
bar.addWidget(self.btn_camera)
self.camera_edit = QLineEdit("1")
self.camera_edit.setPlaceholderText("摄像头编号 或 视频路径")
bar.addWidget(self.camera_edit, 1)
self.btn_config = QPushButton("配置")
self.btn_config.setObjectName("btnConfig")
self.btn_config.clicked.connect(self._select_config)
bar.addWidget(self.btn_config)
self.config_edit = QLineEdit("./config/ddjx01.json")
bar.addWidget(self.config_edit, 1)
self.btn_reset = QPushButton("重置")
self.btn_reset.setObjectName("btnReset")
self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self._on_reset)
bar.addWidget(self.btn_reset)
self.cal_edit = QLineEdit("./config/ddjx01.npy")
bar.addWidget(self.cal_edit, 1)
self.btn_start = QPushButton("启动")
self.btn_start.setObjectName("btnStart")
self.btn_start.clicked.connect(self._on_start)
bar.addWidget(self.btn_start)
self.btn_stop = QPushButton("暂停")
self.btn_stop.setObjectName("btnStop")
self.btn_stop.setEnabled(False)
self.btn_stop.clicked.connect(self._on_stop)
bar.addWidget(self.btn_stop)
root_layout.addLayout(bar)
# ---- 外层纵向 splitter:主区域 / 日志 ----
v_splitter = QSplitter(Qt.Vertical)
# ---- 主区域:图像 (左) | 曲线 (右) ----
h_splitter = QSplitter(Qt.Horizontal)
# 左侧图像面板
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(6)
self.arrows_label = QLabel()
self.arrows_label.setObjectName("arrowsLabel")
self.arrows_label.setMinimumSize(160, 160)
self.arrows_label.setScaledContents(True)
self.arrows_label.setAlignment(Qt.AlignCenter)
left_layout.addWidget(self.arrows_label)
h_splitter.addWidget(left)
# 右侧力曲线
pg.setConfigOption('background', '#000000')
pg.setConfigOption('foreground', '#777777')
pg.setConfigOptions(antialias=True)
self.graph_widget = pg.GraphicsLayoutWidget()
self.graph_widget.setMinimumWidth(300)
h_splitter.addWidget(self.graph_widget)
self.h_splitter = h_splitter
v_splitter.addWidget(h_splitter)
# ---- 底部日志面板 ----
self.log_widget = QPlainTextEdit()
self.log_widget.setReadOnly(True)
self.log_widget.setMaximumBlockCount(2000)
font = QFont("Consolas", 12)
font.setStyleHint(QFont.Monospace)
self.log_widget.setFont(font)
v_splitter.addWidget(self.log_widget)
v_splitter.setStretchFactor(0, 4)
v_splitter.setStretchFactor(1, 1)
root_layout.addWidget(v_splitter)
def _create_plots(self):
titles = ["法向力 (N)", "切向力 X", "切向力 Y"]
self.plots = []
self.curves = []
self.dot_glows = []
self.dot_cores = []
axis_pen = pg.mkPen(color=(38, 38, 38), width=1) # #262626
label_font = QFont("SimHei", 9)
for i, title in enumerate(titles):
p = self.graph_widget.addPlot(title=None)
# 标题:黑体
p.setTitle(title, color='#999999', size='14pt')
p.titleLabel.setFont(QFont("SimHei", 14))
# X 轴标签
p.setLabel('bottom', '时间', units='s')
p.getAxis('bottom').label.setFont(label_font)
# 仅保留左 + 下轴线,隐藏右 + 上边框
p.showAxis('top', False)
p.showAxis('right', False)
p.getAxis('left').setPen(axis_pen)
p.getAxis('bottom').setPen(axis_pen)
# 仅横向网格,极淡
p.showGrid(x=False, y=True, alpha=0.12)
# 等宽刻度字体
tick_font = QFont("Consolas", 11)
tick_font.setStyleHint(QFont.Monospace)
p.getAxis('left').setTickFont(tick_font)
p.getAxis('bottom').setTickFont(tick_font)
# 曲线: 青色描边 + 向下渐变填充(与 demo_wuxi 一致)
line_pen = pg.mkPen(color=CURVE_THEME_COLOR, width=2)
# grad_brush = make_gradient_brush(CURVE_THEME_COLOR, max_alpha=120)
curve = p.plot(pen=line_pen, fillLevel=0)
# 终点光晕: #00e5ff 30% 透明,半径 8
dot_glow = p.plot([], [], pen=None, symbol='o', symbolSize=8,
symbolPen=None, symbolBrush=(0, 229, 255, 76))
# 终点核心: 纯白,半径 4
dot_core = p.plot([], [], pen=None, symbol='o', symbolSize=4,
symbolPen=None, symbolBrush=(255, 255, 255, 255))
p.setXRange(0, PLOT_WINDOW_SEC, padding=0)
p.enableAutoRange(x=False, y=False)
self.plots.append(p)
self.curves.append(curve)
self.dot_glows.append(dot_glow)
self.dot_cores.append(dot_core)
if i < 2:
self.graph_widget.nextRow()
def adjust_square_images(self):
"""根据窗口高度调整左侧面板宽度,使左侧图像保持正方形"""
top_bar_h = 48
log_h = self.log_widget.height()
spacing = 20
available = self.height() - top_bar_h - log_h - spacing
image_w = max(available, 160)
self.h_splitter.setSizes([image_w, self.width() - image_w])
# ---- 帧处理 ----
def _on_timer(self):
if self.sensor is None:
return
sensor = self.sensor
isimg = sensor.get_img()
if isimg is None:
print("Failed to get image")
return
# 旋转原始图像,使力方向与实际物理方向对齐
sensor.img = cv2.rotate(sensor.img, cv2.ROTATE_90_COUNTERCLOCKWISE)
if sensor.frame is not None:
sensor.frame = cv2.rotate(sensor.frame, cv2.ROTATE_90_COUNTERCLOCKWISE)
# 录像写入
if self.is_save and self.writer is not None and sensor.frame is not None:
self.frame_count += 1
save_img = sensor.frame
if len(save_img.shape) == 2 or (len(save_img.shape) == 3 and save_img.shape[2] == 1):
if len(save_img.shape) == 3:
save_img = save_img.squeeze(2)
save_img = cv2.cvtColor(save_img, cv2.COLOR_GRAY2BGR)
self.writer.write(save_img)
is_touched = sensor.compute_deformation(check_motion=True, threshold=1)
sensor.compute_contact()
fps, fn_raw, fx, fy, flow, vnormal, vshear, _ = sensor.read_info(
sensor.info.FPS, sensor.info.FNORMAL,
sensor.info.FSHEARX, sensor.info.FSHEARY,
sensor.info.VRAW, sensor.info.VNORMAL,
sensor.info.VSHEAR,
sensor.info.DEPTH
)
fn = calibrate_normal_force(fn_raw)
print(f"FPS={fps:.2f}, 法向力={fn:.4f}, 切向力X={fx:.4f}, 切向力Y={fy:.4f}")
# print(f"CUPY Available: {sensor.CUPY_AVAILABLE}")
# 图像
_bg_bgr = (22, 16, 11)
_img = sensor.img
_h, _w = _img.shape[:2]
black_img = np.empty((_h, _w, 3), dtype=_img.dtype)
black_img[:, :, 0] = _bg_bgr[0]
black_img[:, :, 1] = _bg_bgr[1]
black_img[:, :, 2] = _bg_bgr[2]
arrows = orisys.util.draw_arrows(
black_img, flow,
threshold=2, grid_spacing=20,
arrow_scale=0.5,
below_threshold_color=(160, 160, 160),
)
arrows = cv2.resize(arrows, (400, 400), interpolation=cv2.INTER_CUBIC)
self.arrows_label.setPixmap(cv2_to_pixmap(arrows))
# 曲线:先顺序写入 npoints 个真实样本,满后再左移滚动
current_time = time.time() - self.start_time
if self._plot_sample_count < self.npoints:
idx = self._plot_sample_count
self.data[idx, 0, :] = current_time
self.data[idx, 1, 0] = fn
self.data[idx, 1, 1] = fx
self.data[idx, 1, 2] = fy
self._plot_sample_count += 1
else:
for i in range(3):
self.data[:-1, 0, i] = self.data[1:, 0, i]
self.data[:-1, 1, i] = self.data[1:, 1, i]
self.data[-1, 0, :] = current_time
self.data[-1, 1, 0] = fn
self.data[-1, 1, 1] = fx
self.data[-1, 1, 2] = fy
n = self._plot_sample_count
if n == 0:
return
data_plot = self.data[:n].copy()
for i in range(3):
data_plot[:, 1, i] = lowpass_filter(
data_plot[:, 1, i],
self.lowpass_cutoff_hz,
sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
)
for i, curve in enumerate(self.curves):
x = data_plot[:, 0, i]
y = data_plot[:, 1, i]
curve.setData(x, y)
ymin, ymax = y.min(), y.max()
if self._y_range_smooth[i] is None:
self._y_range_smooth[i] = (ymin, ymax)
else:
prev_min, prev_max = self._y_range_smooth[i]
alpha = self._y_range_alpha
ymin = alpha * ymin + (1 - alpha) * prev_min
ymax = alpha * ymax + (1 - alpha) * prev_max
span = ymax - ymin
if span < Y_AXIS_MIN_SPAN:
mid = (ymin + ymax) / 2
half = Y_AXIS_MIN_SPAN / 2
ymin = mid - half
ymax = mid + half
self._y_range_smooth[i] = (ymin, ymax)
pad = max((ymax - ymin) * 0.12, 0.01)
self.plots[i].setYRange(ymin - pad, ymax + pad)
last_x = x[-1:]
last_y = y[-1:]
self.dot_glows[i].setData(last_x, last_y)
self.dot_cores[i].setData(last_x, last_y)
if n < self.npoints:
for p in self.plots:
p.setXRange(0, PLOT_WINDOW_SEC, padding=0)
else:
x0 = float(data_plot[0, 0, 0])
x1 = float(data_plot[-1, 0, 0])
if x1 <= x0:
x1 = x0 + PLOT_WINDOW_SEC
for p in self.plots:
p.setXRange(x0, x1, padding=0)
# ---- 键盘 & 录像 ----
def _on_start(self):
if self._running:
return
self.btn_start.setText("启动中...")
self.btn_start.setEnabled(False)
QApplication.processEvents()
try:
src = self.camera_edit.text()
cfg = self.config_edit.text()
cal = self.cal_edit.text()
verbose = getattr(self.args, 'verbose', True)
try:
src = int(src)
except ValueError:
pass
self.sensor = orisys.Sensor(src, config_name=cfg, cal_path=cal, verbose=verbose)
except Exception as e:
self.btn_start.setText("启动")
self.btn_start.setEnabled(True)
print(f"启动失败: {e}")
return
self.start_time = time.time()
self._init_plot_buffer()
self._running = True
self.timer.start(10)
self.btn_start.setText("启动")
self.btn_stop.setEnabled(True)
self.btn_reset.setEnabled(True)
print(f"▶ 启动 — 摄像机={src}, 配置={cfg}, 标定={cal}")
def _on_stop(self):
if not self._running:
return
self._running = False
self.timer.stop()
self.btn_stop.setText("停止中...")
self.btn_stop.setEnabled(False)
QApplication.processEvents()
if self.sensor is not None:
self.sensor.disconnect()
self.sensor = None
self.btn_start.setEnabled(True)
self.btn_stop.setText("暂停")
self.btn_reset.setEnabled(False)
print("⏸ 已暂停")
def _select_camera(self):
text = self.camera_edit.text()
path, _ = QFileDialog.getOpenFileName(
self, "选择视频文件", "data", "视频 (*.mp4 *.avi *.mov);;所有文件 (*)",
)
if path:
self.camera_edit.setText(path)
def _select_config(self):
path, _ = QFileDialog.getOpenFileName(
self, "选择配置文件", "config", "JSON (*.json);;所有文件 (*)",
)
if path:
self.config_edit.setText(path)
def _on_reset(self):
"""Reset optical flow tracker when drift occurs (same as basic_pipeline 'r' key)."""
if self.sensor is None or not self._running:
print("请先启动传感器后再重置追踪器。")
return
self.sensor.reset()
print("追踪器已重置。")
def keyPressEvent(self, event):
key = event.key()
if key == Qt.Key_Escape:
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
elif key == Qt.Key_Q:
print("\n正在退出示例程序...")
self.close()
elif key == Qt.Key_S:
self._start_recording()
elif key == Qt.Key_E:
self._stop_recording()
elif key == Qt.Key_R:
self._on_reset()
else:
super().keyPressEvent(event)
def _start_recording(self):
if self.sensor is None:
print("请先启动采集")
return
self.save_path = "./data/pulse_test.mp4"
os.makedirs(os.path.dirname(self.save_path), exist_ok=True)
save_img = self.sensor.frame
if save_img is None:
print("当前没有可用帧,无法确定录像尺寸。")
return
temp_img = save_img.copy()
if len(temp_img.shape) == 2 or (len(temp_img.shape) == 3 and temp_img.shape[2] == 1):
if len(temp_img.shape) == 3:
temp_img = temp_img.squeeze(2)
temp_img = cv2.cvtColor(temp_img, cv2.COLOR_GRAY2BGR)
fh, fw = temp_img.shape[:2]
self.writer = cv2.VideoWriter(
self.save_path, cv2.VideoWriter_fourcc(*'mp4v'), 30, (fw, fh),
)
if self.writer.isOpened():
self.is_save = True
print(f"开始录像:{self.save_path}")
else:
print(f"无法创建录像文件:{self.save_path}")
self.writer = None
def _stop_recording(self):
self.is_save = False
if self.writer is not None:
self.writer.release()
self.writer = None
if self.save_path is not None:
print(f"录像结束:{self.save_path},总帧数:{self.frame_count}")
else:
print(f"录像结束,总帧数:{self.frame_count}")
self.frame_count = 0
def closeEvent(self, event):
self.timer.stop()
if self.writer is not None:
self.writer.release()
if self.sensor is not None:
self.sensor.disconnect()
event.accept()
# ---------------------------------------------------------------------------
# 入口
# ---------------------------------------------------------------------------
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
log_dir = os.path.join(script_dir, "logs")
os.makedirs(log_dir, exist_ok=True)
log_path = os.path.join(log_dir, f"qt_viewer_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log")
log_file = open(log_path, 'w', encoding='utf-8')
# stdout 到文件的临时重定向(创建 MainWindow 后会加上日志面板)
sys.stdout = log_file
sys.stderr = sys.stdout
import argparse
parser = argparse.ArgumentParser()
parser = argparse.ArgumentParser(description="Orisys 2D Qt sensor monitor")
parser.add_argument(
"--sensor-type",
"-s",
type=str,
default="orisys",
choices=("orisys", "dd06"),
help="Sensor adapter: orisys (direct Sensor) or dd06 (polygon ROI rectify)",
)
parser.add_argument("--video", "-v", type=str, default="0")
parser.add_argument("--config", "-c", type=str, default="./config/ddjx01.json")
parser.add_argument("--cal", "-cal", type=str, default="./config/ddjx01.npy")
@@ -801,18 +38,15 @@ def main():
)
args = parser.parse_args()
print("\n按键说明:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏")
# Sensible DD06 defaults when user only switches sensor type.
if args.sensor_type == "dd06":
if args.video in ("0", "1") and args.config.endswith("ddjx01.json"):
args.video = "./data/dd06/0726.mp4"
args.config = "./config/dd06-ov.json"
# 高 DPI 适配(笔记本高分屏)
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
app = QApplication(sys.argv)
window = MainWindow(args, log_file)
window.showFullScreen()
QApplication.processEvents()
window.adjust_square_images()
log_dir = Path(__file__).resolve().parent / "logs"
sys.exit(run_viewer(args, log_dir=log_dir))
sys.exit(app.exec_())
if __name__ == '__main__':
if __name__ == "__main__":
main()
+175
View File
@@ -0,0 +1,175 @@
"""
DD06 single-camera pipeline with polygon ROI rectification.
Flow:
1. Load sensor config + nested polygon ROI
2. Capture raw frames from the video/camera source
3. Perspective-warp the ROI into a rectangle sized to camera.resolution_stitch
4. Feed the rectified frame into Sensor.get_img(..., already_processed=True)
5. Compute deformation/contact and visualize
SDK version: 0.3.1
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2
import numpy as np
import orisys
_REPO_ROOT = Path(__file__).resolve().parents[1]
_EXAMPLES_DIR = _REPO_ROOT / "examples"
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
from gui.sensors.roi_geometry import ( # noqa: E402
build_roi_warp,
load_json_config,
load_roi_polygon,
open_video_source,
rectified_output_size,
warp_roi_to_rectangle,
)
def main():
parser = argparse.ArgumentParser(
description="DD06 pipeline: polygon-ROI rectify -> Sensor.get_img(already_processed=True)"
)
parser.add_argument(
"--video",
"-v",
type=str,
default="./data/dd06/0726.mp4",
help="视频源:摄像头编号或视频文件路径",
)
parser.add_argument(
"--config",
"-c",
type=str,
default="./config/dd06-ov.json",
help="配置文件名称或路径",
)
parser.add_argument(
"--verbose",
"-verbose",
action="store_true",
default=True,
help="是否输出详细日志",
)
args = parser.parse_args()
config = load_json_config(args.config)
roi_points, roi_image_size = load_roi_polygon(config)
out_w, out_h = rectified_output_size(config, roi_points)
sensor = orisys.Sensor("external", config_name=args.config, verbose=args.verbose)
cap, source = open_video_source(args.video)
warp_matrix = None
warp_src = None
last_frame_size = None
print(
f"ROI points={len(roi_points)} source_size={roi_image_size} "
f"output={out_w}x{out_h} video={source}"
)
print("按键说明:'q' 退出程序,'r' 重置追踪器。")
try:
while True:
ok, frame = cap.read()
if not ok or frame is None:
print("Failed to read frame from video source.")
break
frame_h, frame_w = frame.shape[:2]
frame_size = (frame_w, frame_h)
if warp_matrix is None or last_frame_size != frame_size:
warp_matrix, warp_src, _ = build_roi_warp(
roi_points,
roi_image_size,
frame_size,
(out_w, out_h),
)
last_frame_size = frame_size
if args.verbose:
print(f"Built ROI warp for frame {frame_w}x{frame_h}:\n{warp_src}")
rectified = warp_roi_to_rectangle(frame, warp_matrix, (out_w, out_h))
sensor.get_img(frame=rectified, already_processed=True)
is_contact_now = sensor.compute_deformation(check_motion=True, threshold=0)
_ = is_contact_now
sensor.compute_contact()
fps, flow, vnormal, img, contour, centroid, depth_map, fnormal, fshearx, fsheary = (
sensor.read_info(
sensor.info.FPS,
sensor.info.VRAW,
sensor.info.VNORMAL,
sensor.info.IMG,
sensor.info.CONTOUR,
sensor.info.CENTROID,
sensor.info.DEPTH,
sensor.info.FNORMAL,
sensor.info.FSHEARX,
sensor.info.FSHEARY,
)
)
preview = frame.copy()
cv2.polylines(
preview,
[np.rint(warp_src).astype(np.int32).reshape(-1, 1, 2)],
True,
(0, 255, 255),
2,
cv2.LINE_AA,
)
arrows = orisys.util.draw_arrows(
img,
flow,
threshold=1,
grid_spacing=20,
arrow_scale=1.0,
)
cv2.imshow("形变矢量场", arrows)
div_abs = depth_map
if div_abs.max() > div_abs.min():
div_normalized = (
(div_abs - div_abs.min()) / (div_abs.max() - div_abs.min()) * 255
).astype(np.uint8)
else:
div_normalized = np.zeros_like(div_abs, dtype=np.uint8)
cv2.imshow("depth", cv2.applyColorMap(div_normalized, cv2.COLORMAP_JET))
print(
f"FPS={fps:.2f}, 法向力={fnormal:.4f}, 切向力X={fshearx:.4f}, "
f"切向力Y={fsheary:.4f}, 深度图尺寸={depth_map.shape}, "
f"原始分辨率={sensor.img_size_raw}, 矫正={rectified.shape[1]}x{rectified.shape[0]}"
)
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
print("\n正在退出示例程序...")
break
if key == ord("r"):
sensor.reset()
print("追踪器已重置。")
finally:
cap.release()
sensor.disconnect()
cv2.destroyAllWindows()
if __name__ == "__main__":
main()
+165
View File
@@ -0,0 +1,165 @@
"""Tests for Qt viewer backend force history and aspect-aware display helpers."""
from __future__ import annotations
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock
import numpy as np
import pytest
_REPO_ROOT = Path(__file__).resolve().parents[1]
_EXAMPLES_DIR = _REPO_ROOT / "examples"
if str(_EXAMPLES_DIR) not in sys.path:
sys.path.insert(0, str(_EXAMPLES_DIR))
from gui.viewer2d.backend import ( # noqa: E402
QtViewerBackend,
image_shape_meta,
panel_width_for_aspect,
scale_bgr_to_fit,
select_display_image,
)
from gui.sensors import ( # noqa: E402
FrameSnapshot,
_snapshot_from_sensor,
calibrate_normal_force,
)
class FakeForceAdapter:
"""Minimal adapter that emits changing FN/FX/FY without a real sensor."""
def __init__(self):
self._i = 0
def read(self):
i = self._i
self._i += 1
return FrameSnapshot(
image=np.zeros((100, 200, 3), dtype=np.uint8),
normal_force=1000.0 + 50.0 * i,
shear_x=-200.0 + 10.0 * i,
shear_y=300.0 - 5.0 * i,
normal_force_calibrated=calibrate_normal_force(1000.0 + 50.0 * i),
fps=30.0,
timestamp=float(i) / 30.0,
flow_overlay=np.zeros((100, 200, 3), dtype=np.uint8),
record_frame=np.zeros((100, 200, 3), dtype=np.uint8),
)
def test_backend_force_history_tracks_all_channels():
adapter = FakeForceAdapter()
backend = QtViewerBackend(npoints=32, lowpass_cutoff_hz=0.0)
for _ in range(8):
snap = adapter.read()
backend.ingest(snap, now=snap.timestamp)
state = backend.plot_state()
assert state is not None
assert state.sample_count == 8
assert state.values.shape == (8, 3)
# All three channels must change across frames (not stay flat).
fn_span = float(state.values[:, 0].max() - state.values[:, 0].min())
fx_span = float(state.values[:, 1].max() - state.values[:, 1].min())
fy_span = float(state.values[:, 2].max() - state.values[:, 2].min())
assert fn_span > 100.0
assert fx_span > 20.0
assert fy_span > 10.0
# Raw FN is used for curves, not the tiny calibrated Newton scale.
assert float(state.values[-1, 0]) == pytest.approx(1000.0 + 50.0 * 7)
@pytest.mark.parametrize(
"shape,expected_aspect",
[
((100, 200, 3), 2.0), # wide
((120, 120, 3), 1.0), # square
((200, 100, 3), 0.5), # portrait
],
)
def test_backend_image_aspect_metadata(shape, expected_aspect):
img = np.zeros(shape, dtype=np.uint8)
h, w, aspect = image_shape_meta(img)
assert (h, w) == (shape[0], shape[1])
assert aspect == pytest.approx(expected_aspect)
snap = FrameSnapshot(flow_overlay=img, normal_force=1.0, shear_x=2.0, shear_y=3.0)
backend = QtViewerBackend(lowpass_cutoff_hz=0.0)
frame = backend.ingest(snap, now=0.0)
assert frame.image_height == shape[0]
assert frame.image_width == shape[1]
assert frame.aspect_ratio == pytest.approx(expected_aspect)
fitted = scale_bgr_to_fit(img, max_w=80, max_h=80)
fh, fw = fitted.shape[:2]
assert fw <= 80 and fh <= 80
assert abs((fw / fh) - expected_aspect) < 0.05
panel_w = panel_width_for_aspect(200, expected_aspect, min_size=160)
assert panel_w >= 160
assert panel_w == pytest.approx(max(160, int(round(200 * expected_aspect))))
def test_select_display_image_prefers_flow_overlay():
overlay = np.full((10, 20, 3), 7, dtype=np.uint8)
processed = np.full((10, 20, 3), 3, dtype=np.uint8)
snap = FrameSnapshot(flow_overlay=overlay, processed_image=processed, image=processed)
out = select_display_image(snap)
assert np.array_equal(out, overlay)
def test_snapshot_requests_hhd_every_frame_without_motion_gate():
"""Regression: force refresh must not use check_motion=True gating."""
sensor = MagicMock()
sensor.img = np.zeros((32, 32), dtype=np.uint8)
sensor.frame = np.zeros((32, 32), dtype=np.uint8)
sensor.info = types.SimpleNamespace(
FPS="FPS",
VRAW="VRAW",
VNORMAL="VNORMAL",
IMG="IMG",
CONTOUR="CONTOUR",
CENTROID="CENTROID",
DEPTH="DEPTH",
FNORMAL="FNORMAL",
FSHEARX="FSHEARX",
FSHEARY="FSHEARY",
)
sensor.read_info.return_value = (
30.0,
np.zeros((32, 32, 2), dtype=np.float32),
np.zeros((32, 32), dtype=np.float32),
sensor.img,
None,
None,
np.zeros((32, 32), dtype=np.float32),
12345.0,
-100.0,
50.0,
)
# Avoid depending on orisys.util.draw_arrows implementation details.
import gui.sensors.adapter as sa
original_util = sa.orisys.util
sa.orisys.util = types.SimpleNamespace(
draw_arrows=lambda *args, **kwargs: np.zeros((32, 32, 3), dtype=np.uint8)
)
try:
snap = _snapshot_from_sensor(sensor, raw_image=sensor.frame, processed_image=sensor.img)
finally:
sa.orisys.util = original_util
sensor.compute_deformation.assert_called_once_with(decompose=True, check_motion=False)
assert snap is not None
assert snap.normal_force == pytest.approx(12345.0)
assert snap.shear_x == pytest.approx(-100.0)
assert snap.shear_y == pytest.approx(50.0)
assert snap.normal_force_calibrated == pytest.approx(calibrate_normal_force(12345.0))
+284 -64
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import argparse
import json
import os
from datetime import datetime
from pathlib import Path
@@ -8,7 +10,8 @@ import cv2
import numpy as np
WINDOW_NAME = "ROI Polygon Editor"
WINDOW_NAME = "ROI Editor"
POLYGON_WINDOW_NAME = "ROI Polygon Editor"
def to_uint8_image(image):
@@ -49,33 +52,6 @@ def polygon_to_mask(points, image_size):
return mask
def save_polygon_roi_config(path, points, image_size, mask_path=None, metadata=None):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
data = {
"version": "1.0",
"type": "polygon",
"image_size": [int(image_size[0]), int(image_size[1])],
"points": [[int(x), int(y)] for x, y in points],
"created_at": datetime.now().isoformat(timespec="seconds"),
}
if mask_path is not None:
data["mask_path"] = str(mask_path)
if metadata:
data["metadata"] = metadata
with path.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return data
def load_roi_config(path):
path = Path(path)
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
data["_config_path"] = str(path)
return data
def scale_points(points, source_size, target_size):
source_w, source_h = int(source_size[0]), int(source_size[1])
target_w, target_h = int(target_size[0]), int(target_size[1])
@@ -97,20 +73,96 @@ def normalize_mask(mask, shape):
return np.where(mask > 0, 255, 0).astype(np.uint8)
def build_roi_payload(image_size, points, mask_path=None, metadata=None):
"""Build the shared ``roi`` object written into sensor / FFC JSON configs."""
points = np.asarray(points, dtype=np.int32).reshape(-1, 2)
if len(points) < 3:
raise ValueError("polygon ROI requires at least 3 points")
payload = {
"type": "polygon",
"image_size": [int(image_size[0]), int(image_size[1])],
"points": [[int(x), int(y)] for x, y in points],
"created_at": datetime.now().isoformat(timespec="seconds"),
}
if mask_path is not None:
payload["mask_path"] = str(mask_path)
if metadata:
payload["metadata"] = metadata
return payload
def extract_roi_section(data):
"""Return the ROI dict from either a nested ``roi`` field or a legacy top-level ROI file."""
if not isinstance(data, dict):
raise TypeError("ROI config must be a JSON object")
if isinstance(data.get("roi"), dict):
return data["roi"]
return data
def load_roi_config(path):
path = Path(path)
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
data["_config_path"] = str(path)
return data
def save_polygon_roi_config(path, points, image_size, mask_path=None, metadata=None):
"""Backward-compatible standalone polygon ROI JSON writer."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
data = build_roi_payload(
image_size,
points,
mask_path=mask_path,
metadata=metadata,
)
data["version"] = "1.0"
with path.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return data
def upsert_roi_in_config(path, roi_payload, *, create_if_missing=True):
"""Read-modify-write ``roi`` into an existing sensor config, preserving other fields."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError(f"Config must be a JSON object: {path}")
elif create_if_missing:
data = {"version": "1.0"}
else:
raise FileNotFoundError(f"Config not found: {path}")
data["roi"] = roi_payload
with path.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
return data
def load_roi_mask(config_path, frame_shape):
config_path = Path(config_path)
data = load_roi_config(config_path)
roi = extract_roi_section(data)
height, width = frame_shape[:2]
points = data.get("points") or data.get("polygon") or data.get("roi_polygon")
target_size = (width, height)
source_size = roi.get("image_size") or data.get("image_size") or [width, height]
points = roi.get("points")
points = roi.get("points") or roi.get("polygon") or roi.get("roi_polygon")
if points:
source_size = data.get("image_size", [width, height])
polygon = scale_points(points, source_size, (width, height)).reshape(-1, 1, 2)
polygon = scale_points(points, source_size, target_size).reshape(-1, 1, 2)
output = np.zeros((height, width), dtype=np.uint8)
cv2.fillPoly(output, [polygon], 255)
return output
mask_path = data.get("mask_path") or data.get("roi_mask")
mask_path = roi.get("mask_path") or data.get("mask_path") or data.get("roi_mask")
if mask_path:
mask_path = Path(mask_path)
if not mask_path.is_absolute():
@@ -123,6 +175,54 @@ def load_roi_mask(config_path, frame_shape):
raise ValueError(f"Unsupported ROI config: {config_path}")
def resolve_video_source(video):
"""Accept camera index (int/str digit) or filesystem path."""
if isinstance(video, int):
return video
text = str(video).strip()
if text.isdigit():
return int(text)
return text
def open_video_capture(video):
source = resolve_video_source(video)
# Avoid MSMF HW transform stalls on Windows camera capture.
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
if isinstance(source, int):
cap = cv2.VideoCapture(source, cv2.CAP_DSHOW)
if not cap.isOpened():
cap.release()
cap = cv2.VideoCapture(source)
else:
path = Path(source)
if not path.exists():
raise FileNotFoundError(f"Video file not found: {path}")
cap = cv2.VideoCapture(str(path))
if not cap.isOpened():
raise RuntimeError(f"Failed to open video source: {video}")
return cap
def capture_frame(cap, *, max_attempts=60):
"""Grab the next readable frame from an opened capture."""
last_error = None
for _ in range(max_attempts):
ok, frame = cap.read()
if ok and frame is not None and getattr(frame, "size", 0) > 0:
return normalize_editor_frame(frame)
last_error = "empty frame" if ok else "read failed"
raise RuntimeError(f"Failed to capture frame from video source ({last_error})")
def capture_frame_from_source(video):
cap = open_video_capture(video)
try:
return capture_frame(cap)
finally:
cap.release()
class RoiPolygonEditor:
def __init__(self, frame):
self.frame = normalize_editor_frame(frame)
@@ -187,25 +287,85 @@ class RoiPolygonEditor:
return view
def build_roi_result(points, image_size, mask, config=None, config_path=None, mask_path=None):
return {
"points": [[int(x), int(y)] for x, y in points],
def build_roi_result(
points,
image_size,
mask,
config=None,
config_path=None,
mask_path=None,
):
result = {
"type": "polygon",
"points": [[int(x), int(y)] for x, y in points] if points is not None else None,
"image_size": [int(image_size[0]), int(image_size[1])],
"mask": mask,
"config": config,
"config_path": str(config_path) if config_path is not None else None,
"mask_path": str(mask_path) if mask_path is not None else None,
}
return result
def _save_roi_outputs(
*,
image_size,
output,
points,
metadata=None,
save_mask=True,
nest_in_config=True,
):
mask = polygon_to_mask(points, image_size)
config = None
config_path = None
mask_path = None
if output is not None:
config_path = Path(output)
config_path.parent.mkdir(parents=True, exist_ok=True)
saved_mask = None
if save_mask:
mask_path = config_path.with_name(config_path.stem + "_mask.png")
cv2.imwrite(str(mask_path), mask)
saved_mask = mask_path.name
roi_payload = build_roi_payload(
image_size,
points,
mask_path=saved_mask,
metadata=metadata,
)
if nest_in_config:
config = upsert_roi_in_config(config_path, roi_payload)
else:
# Standalone ROI file (legacy FFC polygon style).
standalone = dict(roi_payload)
standalone["version"] = "1.0"
with config_path.open("w", encoding="utf-8") as f:
json.dump(standalone, f, indent=2, ensure_ascii=False)
f.write("\n")
config = standalone
return build_roi_result(
points,
image_size,
mask,
config=config,
config_path=config_path,
mask_path=mask_path,
)
def select_polygon_roi(
frame,
output=None,
window_name=WINDOW_NAME,
window_name=POLYGON_WINDOW_NAME,
metadata=None,
save_mask=True,
destroy_window=True,
recapture_callback=None,
nest_in_config=False,
):
"""Open an OpenCV window, draw a polygon ROI, and return points/mask/config info."""
editor = RoiPolygonEditor(frame)
@@ -230,35 +390,95 @@ def select_polygon_roi(
continue
image_size = (editor.frame.shape[1], editor.frame.shape[0])
mask = polygon_to_mask(editor.points, image_size)
config = None
config_path = None
mask_path = None
if output is not None:
config_path = Path(output)
config_path.parent.mkdir(parents=True, exist_ok=True)
saved_mask = None
if save_mask:
mask_path = config_path.with_name(config_path.stem + "_mask.png")
cv2.imwrite(str(mask_path), mask)
saved_mask = mask_path.name
config = save_polygon_roi_config(
config_path,
editor.points,
image_size,
mask_path=saved_mask,
metadata=metadata,
)
return build_roi_result(
editor.points,
image_size,
mask,
config=config,
config_path=config_path,
mask_path=mask_path,
return _save_roi_outputs(
image_size=image_size,
output=output,
points=editor.points,
metadata=metadata,
save_mask=save_mask,
nest_in_config=nest_in_config,
)
finally:
if destroy_window:
cv2.destroyWindow(window_name)
select_roi = select_polygon_roi
def parse_args(argv=None):
parser = argparse.ArgumentParser(
description="Capture a frame from a camera/video and save a polygon ROI to JSON."
)
parser.add_argument(
"--video",
"-v",
required=True,
help="Camera index (e.g. 0) or path to a video file",
)
parser.add_argument(
"--config",
"-c",
required=True,
help="Output JSON config path (existing sensor configs are updated in place)",
)
parser.add_argument(
"--frame-output",
default=None,
help="Optional path to save the captured reference frame (PNG/JPG)",
)
parser.add_argument(
"--no-mask",
action="store_true",
help="Do not write a companion *_mask.png next to the config",
)
return parser.parse_args(argv)
def main(argv=None):
args = parse_args(argv)
video = resolve_video_source(args.video)
cap = open_video_capture(video)
def recapture():
# For file sources, rewind so R always gets a usable frame.
if not isinstance(video, int):
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
return capture_frame(cap)
try:
frame = capture_frame(cap)
if args.frame_output:
frame_path = Path(args.frame_output)
frame_path.parent.mkdir(parents=True, exist_ok=True)
cv2.imwrite(str(frame_path), frame)
print(f"Saved reference frame: {frame_path}")
result = select_polygon_roi(
frame,
output=args.config,
metadata={"source": str(args.video)},
save_mask=not args.no_mask,
recapture_callback=recapture,
nest_in_config=True,
)
finally:
cap.release()
cv2.destroyAllWindows()
if result is None:
print("ROI capture cancelled.")
return 1
print(f"Saved ROI type={result['type']} image_size={result['image_size']}")
if result.get("points") is not None:
print(f"points={result['points']}")
if result.get("config_path"):
print(f"config={result['config_path']}")
if result.get("mask_path"):
print(f"mask={result['mask_path']}")
return 0
if __name__ == "__main__":
raise SystemExit(main())