added dd06 test support, organized 2d viewer and 3d viewer code organization. #10
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
Two apps live under ``examples/gui``:
|
||||||
from .layout import (
|
|
||||||
LOG_CONFIG,
|
- Finger 3D dashboard: ``from gui import run_dashboard`` (also ``gui.viewer3d``)
|
||||||
LOG_PANEL,
|
- 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,
|
LAYOUT,
|
||||||
|
LOG_PANEL,
|
||||||
MESH_CONTACT_ARROW,
|
MESH_CONTACT_ARROW,
|
||||||
MESH_LIGHTING,
|
MESH_LIGHTING,
|
||||||
PANEL_SETUP,
|
PANEL_SETUP,
|
||||||
TOP_ROW,
|
TOP_ROW,
|
||||||
DashboardLayout,
|
DashboardLayout,
|
||||||
LogConfig,
|
|
||||||
MeshLighting,
|
MeshLighting,
|
||||||
PanelRow,
|
PanelRow,
|
||||||
PanelSetup,
|
PanelSetup,
|
||||||
PanelSlot,
|
PanelSlot,
|
||||||
|
run_dashboard,
|
||||||
top_row_splitter_sizes,
|
top_row_splitter_sizes,
|
||||||
top_row_slot_index,
|
top_row_slot_index,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"video_source": "0",
|
"video_source": "D:/repo/orisys-sdk/data/dd02-0703/1.mp4",
|
||||||
"config_path": "./config/dd02-ov.json",
|
"config_path": "./config/dd02-ov.json",
|
||||||
"finger_assets_dir": "D:/repo/orisys-sdk/assets/finger/30fps-0703",
|
"finger_assets_dir": "D:/repo/orisys-sdk/assets/finger/30fps-0703",
|
||||||
"csv_save_dir": "./outputs/csv",
|
"csv_save_dir": "./outputs/csv",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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'"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
"""
|
||||||
@@ -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_()
|
||||||
@@ -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.csv_recorder import CsvRecorder
|
||||||
from pipeline.service import FingerPipeline
|
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 .camera_discovery import list_cameras, pick_default_camera
|
||||||
from .preferences import get_preferred_video_source, load_preferences, save_preferences
|
from .preferences import get_preferred_video_source, load_preferences, save_preferences
|
||||||
from .layout import (
|
from .layout import (
|
||||||
@@ -33,14 +33,12 @@ from .layout import (
|
|||||||
)
|
)
|
||||||
from .mesh_view_state import MeshViewState
|
from .mesh_view_state import MeshViewState
|
||||||
from .styles import STYLE
|
from .styles import STYLE
|
||||||
|
from gui.widgets import TeeStream, WidgetLogHandler, make_log_widget
|
||||||
from .widgets import (
|
from .widgets import (
|
||||||
ArrowPanelWidget,
|
ArrowPanelWidget,
|
||||||
ForceChartWidget,
|
ForceChartWidget,
|
||||||
MeshViewportWidget,
|
MeshViewportWidget,
|
||||||
SettingsPanelWidget,
|
SettingsPanelWidget,
|
||||||
TeeStream,
|
|
||||||
WidgetLogHandler,
|
|
||||||
make_log_widget,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -689,7 +687,7 @@ class FingerDashboardWindow(QMainWindow):
|
|||||||
|
|
||||||
def run_dashboard(config: PipelineConfig, *, layout: DashboardLayout = LAYOUT) -> int:
|
def run_dashboard(config: PipelineConfig, *, layout: DashboardLayout = LAYOUT) -> int:
|
||||||
"""Launch the unified finger dashboard and block until the window closes."""
|
"""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("\n按键说明:Q 退出 | R 重置追踪器 | S 开始录像 | E 结束录像 | ESC 全屏")
|
||||||
print("GUI:Record CSV / Stop CSV 写入接触点与力到所选目录。")
|
print("GUI:Record CSV / Stop CSV 写入接触点与力到所选目录。")
|
||||||
@@ -47,6 +47,7 @@ import math
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
from gui.log_config import LOG_CONFIG, LogConfig
|
||||||
from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
|
from viz.contact_arrow import ContactArrowStyle, DEFAULT_CONTACT_ARROW_STYLE
|
||||||
from viz.flow_arrow import FlowArrowStyle
|
from viz.flow_arrow import FlowArrowStyle
|
||||||
from viz.render_2d import ArrowBackground, ArrowCanvasColors
|
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)
|
@dataclass(frozen=True)
|
||||||
class DashboardLayout:
|
class DashboardLayout:
|
||||||
"""Window chrome, chart styling, and panel hierarchy."""
|
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,15 +6,16 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .app_paths import resource_path
|
from gui.app_paths import resource_path
|
||||||
|
|
||||||
PREFERENCE_FILE = "preference.json"
|
PREFERENCE_FILE = "preference.json"
|
||||||
LEGACY_CAMERA_SELECTION_FILE = "camera_selection.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:
|
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
|
return _GUI_DIR / PREFERENCE_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 ..layout import DashboardLayout, LAYOUT
|
||||||
from .image_panel import ImagePanel
|
from .image_panel import ImagePanel
|
||||||
from .qt_pixmap import ndarray_to_pixmap
|
from gui.widgets.qt_pixmap import ndarray_to_pixmap
|
||||||
|
|
||||||
|
|
||||||
class ArrowPanelWidget(ImagePanel):
|
class ArrowPanelWidget(ImagePanel):
|
||||||
+2
-2
@@ -6,8 +6,8 @@ import numpy as np
|
|||||||
from PyQt5.QtGui import QFont
|
from PyQt5.QtGui import QFont
|
||||||
from PyQt5.QtWidgets import QSizePolicy
|
from PyQt5.QtWidgets import QSizePolicy
|
||||||
|
|
||||||
from gui.layout import DashboardLayout, LAYOUT
|
from ..layout import DashboardLayout, LAYOUT
|
||||||
from gui.styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
|
from ..styles import LOG_FONT_PRIMARY, UI_FONT_PRIMARY
|
||||||
from pipeline.frame import FingerFrameView
|
from pipeline.frame import FingerFrameView
|
||||||
from viz.signal_filters import lowpass_filter
|
from viz.signal_filters import lowpass_filter
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ from PyQt5.QtCore import Qt
|
|||||||
from PyQt5.QtGui import QPixmap
|
from PyQt5.QtGui import QPixmap
|
||||||
from PyQt5.QtWidgets import QLabel, QSizePolicy, QVBoxLayout, QWidget
|
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):
|
class ImagePanel(QWidget):
|
||||||
+1
-1
@@ -11,7 +11,7 @@ from viz.offscreen_mesh import OffscreenFingerMeshRenderer
|
|||||||
from ..layout import DashboardLayout, LAYOUT
|
from ..layout import DashboardLayout, LAYOUT
|
||||||
from ..mesh_view_state import MeshViewState
|
from ..mesh_view_state import MeshViewState
|
||||||
from ..mesh_render_worker import MeshRenderWorker
|
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
|
from .image_panel import ImagePanel
|
||||||
|
|
||||||
_VIEW_ROTATE_SENSITIVITY = 0.35
|
_VIEW_ROTATE_SENSITIVITY = 0.35
|
||||||
@@ -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 .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 .qt_pixmap import ndarray_to_pixmap, pixmap_keep_aspect
|
||||||
from .image_panel import ImagePanel
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ArrowPanelWidget",
|
|
||||||
"SettingsPanelWidget",
|
|
||||||
"ForceChartWidget",
|
|
||||||
"MeshViewportWidget",
|
|
||||||
"ImagePanel",
|
|
||||||
"TeeStream",
|
"TeeStream",
|
||||||
"WidgetLogHandler",
|
"WidgetLogHandler",
|
||||||
"make_log_widget",
|
"make_log_widget",
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class WidgetLogHandler:
|
|||||||
|
|
||||||
def make_log_widget(config=None) -> QPlainTextEdit:
|
def make_log_widget(config=None) -> QPlainTextEdit:
|
||||||
if config is None:
|
if config is None:
|
||||||
from ..layout import LOG_CONFIG
|
from ..log_config import LOG_CONFIG
|
||||||
|
|
||||||
config = LOG_CONFIG
|
config = LOG_CONFIG
|
||||||
widget = QPlainTextEdit()
|
widget = QPlainTextEdit()
|
||||||
|
|||||||
+26
-792
@@ -1,794 +1,31 @@
|
|||||||
"""
|
#!/usr/bin/env python3
|
||||||
Orisys SDK 实时监控 — Qt 统一界面
|
"""Shim for the Orisys 2D Qt sensor monitor (see examples/gui/viewer2d/)."""
|
||||||
|
|
||||||
将所有可视化和曲线整合到一个窗口中,适合销售演示使用。
|
|
||||||
SDK version: 0.3.1
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
|
from pathlib import Path
|
||||||
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
|
|
||||||
|
|
||||||
# Force curve sampling rate (Hz), matches typical sensor loop ~30 FPS.
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||||
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
|
|
||||||
|
|
||||||
from PyQt5.QtWidgets import (
|
_EXAMPLES_DIR = Path(__file__).resolve().parents[1] / "examples"
|
||||||
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
|
if str(_EXAMPLES_DIR) not in sys.path:
|
||||||
QLabel, QApplication, QSplitter, QPlainTextEdit,
|
sys.path.insert(0, str(_EXAMPLES_DIR))
|
||||||
QPushButton, QFileDialog, QLineEdit,
|
|
||||||
)
|
|
||||||
from PyQt5.QtCore import Qt, QTimer
|
|
||||||
from PyQt5 import QtGui
|
|
||||||
from PyQt5.QtGui import QImage, QPixmap, QFont, QIcon
|
|
||||||
|
|
||||||
try:
|
from gui.viewer2d import run_viewer
|
||||||
import pyqtgraph as pg
|
|
||||||
except Exception:
|
|
||||||
print("PyQtGraph is not installed. Please install it using 'pip install pyqt5 pyqtgraph'.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 工具
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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():
|
def main():
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
import argparse
|
||||||
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
|
|
||||||
|
|
||||||
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("--video", "-v", type=str, default="0")
|
||||||
parser.add_argument("--config", "-c", type=str, default="./config/ddjx01.json")
|
parser.add_argument("--config", "-c", type=str, default="./config/ddjx01.json")
|
||||||
parser.add_argument("--cal", "-cal", type=str, default="./config/ddjx01.npy")
|
parser.add_argument("--cal", "-cal", type=str, default="./config/ddjx01.npy")
|
||||||
@@ -801,18 +38,15 @@ def main():
|
|||||||
)
|
)
|
||||||
args = parser.parse_args()
|
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 适配(笔记本高分屏)
|
log_dir = Path(__file__).resolve().parent / "logs"
|
||||||
QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
sys.exit(run_viewer(args, log_dir=log_dir))
|
||||||
QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
window = MainWindow(args, log_file)
|
|
||||||
window.showFullScreen()
|
|
||||||
QApplication.processEvents()
|
|
||||||
window.adjust_square_images()
|
|
||||||
|
|
||||||
sys.exit(app.exec_())
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
+175
@@ -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()
|
||||||
@@ -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))
|
||||||
+283
-63
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -8,7 +10,8 @@ import cv2
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
WINDOW_NAME = "ROI Polygon Editor"
|
WINDOW_NAME = "ROI Editor"
|
||||||
|
POLYGON_WINDOW_NAME = "ROI Polygon Editor"
|
||||||
|
|
||||||
|
|
||||||
def to_uint8_image(image):
|
def to_uint8_image(image):
|
||||||
@@ -49,33 +52,6 @@ def polygon_to_mask(points, image_size):
|
|||||||
return mask
|
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):
|
def scale_points(points, source_size, target_size):
|
||||||
source_w, source_h = int(source_size[0]), int(source_size[1])
|
source_w, source_h = int(source_size[0]), int(source_size[1])
|
||||||
target_w, target_h = int(target_size[0]), int(target_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)
|
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):
|
def load_roi_mask(config_path, frame_shape):
|
||||||
config_path = Path(config_path)
|
config_path = Path(config_path)
|
||||||
data = load_roi_config(config_path)
|
data = load_roi_config(config_path)
|
||||||
|
roi = extract_roi_section(data)
|
||||||
|
|
||||||
height, width = frame_shape[:2]
|
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:
|
if points:
|
||||||
source_size = data.get("image_size", [width, height])
|
polygon = scale_points(points, source_size, target_size).reshape(-1, 1, 2)
|
||||||
polygon = scale_points(points, source_size, (width, height)).reshape(-1, 1, 2)
|
|
||||||
output = np.zeros((height, width), dtype=np.uint8)
|
output = np.zeros((height, width), dtype=np.uint8)
|
||||||
cv2.fillPoly(output, [polygon], 255)
|
cv2.fillPoly(output, [polygon], 255)
|
||||||
return output
|
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:
|
if mask_path:
|
||||||
mask_path = Path(mask_path)
|
mask_path = Path(mask_path)
|
||||||
if not mask_path.is_absolute():
|
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}")
|
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:
|
class RoiPolygonEditor:
|
||||||
def __init__(self, frame):
|
def __init__(self, frame):
|
||||||
self.frame = normalize_editor_frame(frame)
|
self.frame = normalize_editor_frame(frame)
|
||||||
@@ -187,25 +287,85 @@ class RoiPolygonEditor:
|
|||||||
return view
|
return view
|
||||||
|
|
||||||
|
|
||||||
def build_roi_result(points, image_size, mask, config=None, config_path=None, mask_path=None):
|
def build_roi_result(
|
||||||
return {
|
points,
|
||||||
"points": [[int(x), int(y)] for x, y in 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])],
|
"image_size": [int(image_size[0]), int(image_size[1])],
|
||||||
"mask": mask,
|
"mask": mask,
|
||||||
"config": config,
|
"config": config,
|
||||||
"config_path": str(config_path) if config_path is not None else None,
|
"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,
|
"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(
|
def select_polygon_roi(
|
||||||
frame,
|
frame,
|
||||||
output=None,
|
output=None,
|
||||||
window_name=WINDOW_NAME,
|
window_name=POLYGON_WINDOW_NAME,
|
||||||
metadata=None,
|
metadata=None,
|
||||||
save_mask=True,
|
save_mask=True,
|
||||||
destroy_window=True,
|
destroy_window=True,
|
||||||
recapture_callback=None,
|
recapture_callback=None,
|
||||||
|
nest_in_config=False,
|
||||||
):
|
):
|
||||||
"""Open an OpenCV window, draw a polygon ROI, and return points/mask/config info."""
|
"""Open an OpenCV window, draw a polygon ROI, and return points/mask/config info."""
|
||||||
editor = RoiPolygonEditor(frame)
|
editor = RoiPolygonEditor(frame)
|
||||||
@@ -230,35 +390,95 @@ def select_polygon_roi(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
image_size = (editor.frame.shape[1], editor.frame.shape[0])
|
image_size = (editor.frame.shape[1], editor.frame.shape[0])
|
||||||
mask = polygon_to_mask(editor.points, image_size)
|
return _save_roi_outputs(
|
||||||
config = None
|
image_size=image_size,
|
||||||
config_path = None
|
output=output,
|
||||||
mask_path = None
|
points=editor.points,
|
||||||
|
|
||||||
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,
|
metadata=metadata,
|
||||||
)
|
save_mask=save_mask,
|
||||||
|
nest_in_config=nest_in_config,
|
||||||
return build_roi_result(
|
|
||||||
editor.points,
|
|
||||||
image_size,
|
|
||||||
mask,
|
|
||||||
config=config,
|
|
||||||
config_path=config_path,
|
|
||||||
mask_path=mask_path,
|
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
if destroy_window:
|
if destroy_window:
|
||||||
cv2.destroyWindow(window_name)
|
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())
|
||||||
|
|||||||
Reference in New Issue
Block a user