From 8e5d0aec942fb9c49040fc525f0d697fd24d7c2f Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Thu, 7 May 2026 13:42:30 -0600 Subject: Added Motion Capture plugin Tracks a user-selected point via webcam (OpenCV CSRT tracker) and streams x_pos / y_pos pixel coordinates as live channels into the acquisition pipeline. Falls back to a Lissajous figure simulation when opencv-python is not installed, so the plugin works immediately without hardware. - tracker.py: background thread, real camera + sim modes, thread-safe API - device.py: BaseDevice reading x/y from tracker, wires into engine - filter.py: custom pixel_to_mm filter (registered in FILTER_CLASSES) - window.py: floating tool window, live feed, click-to-track ROI selection - plugin.py: LabPlugin wiring all pieces together, toolbar button, settings widget, profile save/restore Also: PluginAction.button_ref_callback lets plugins store a ref to their toolbar button so they can uncheck it when their window closes. opencv-python install note added to requirements.txt. Co-Authored-By: Claude Sonnet 4.6 --- plugins/base_plugin.py | 3 + .../__pycache__/device.cpython-314.pyc | Bin 0 -> 4440 bytes .../__pycache__/filter.cpython-314.pyc | Bin 0 -> 2178 bytes .../__pycache__/tracker.cpython-314.pyc | Bin 0 -> 16806 bytes plugins/motion_capture/device.py | 85 ++++++ plugins/motion_capture/filter.py | 33 +++ plugins/motion_capture/manifest.json | 9 + plugins/motion_capture/plugin.py | 177 ++++++++++++ plugins/motion_capture/tracker.py | 307 +++++++++++++++++++++ plugins/motion_capture/window.py | 262 ++++++++++++++++++ 10 files changed, 876 insertions(+) create mode 100644 plugins/motion_capture/__pycache__/device.cpython-314.pyc create mode 100644 plugins/motion_capture/__pycache__/filter.cpython-314.pyc create mode 100644 plugins/motion_capture/__pycache__/tracker.cpython-314.pyc create mode 100644 plugins/motion_capture/device.py create mode 100644 plugins/motion_capture/filter.py create mode 100644 plugins/motion_capture/manifest.json create mode 100644 plugins/motion_capture/plugin.py create mode 100644 plugins/motion_capture/tracker.py create mode 100644 plugins/motion_capture/window.py (limited to 'plugins') diff --git a/plugins/base_plugin.py b/plugins/base_plugin.py index 0eeb25f..2dbb56d 100644 --- a/plugins/base_plugin.py +++ b/plugins/base_plugin.py @@ -59,6 +59,9 @@ class PluginAction: icon: str = "" tooltip: str = "" checkable: bool = False + # Optional: main window calls this with the QPushButton after creation. + # Lets the plugin store a reference to uncheck it when its window closes. + button_ref_callback: Optional[Callable] = None # ── Base class ──────────────────────────────────────────────────────────────── diff --git a/plugins/motion_capture/__pycache__/device.cpython-314.pyc b/plugins/motion_capture/__pycache__/device.cpython-314.pyc new file mode 100644 index 0000000..34b95e1 Binary files /dev/null and b/plugins/motion_capture/__pycache__/device.cpython-314.pyc differ diff --git a/plugins/motion_capture/__pycache__/filter.cpython-314.pyc b/plugins/motion_capture/__pycache__/filter.cpython-314.pyc new file mode 100644 index 0000000..bc08a16 Binary files /dev/null and b/plugins/motion_capture/__pycache__/filter.cpython-314.pyc differ diff --git a/plugins/motion_capture/__pycache__/tracker.cpython-314.pyc b/plugins/motion_capture/__pycache__/tracker.cpython-314.pyc new file mode 100644 index 0000000..064af3c Binary files /dev/null and b/plugins/motion_capture/__pycache__/tracker.cpython-314.pyc differ diff --git a/plugins/motion_capture/device.py b/plugins/motion_capture/device.py new file mode 100644 index 0000000..f0d082c --- /dev/null +++ b/plugins/motion_capture/device.py @@ -0,0 +1,85 @@ +""" +motion_capture/device.py + +BaseDevice that reads x/y position from CameraTracker. + +Channels: + x_pos — horizontal position [px] (left=0, right=frame_width) + y_pos — vertical position [px] (top=0, bottom=frame_height) + +Apply the pixel_to_mm filter in Signals → Channels to convert to mm. +""" + +from __future__ import annotations + +import threading +from typing import Any, Dict + +from devices.base_device import ( + BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus, +) + + +class MotionCaptureDevice(BaseDevice): + + def __init__(self, tracker, device_id: str = "motion_capture"): + super().__init__(DeviceInfo( + device_id = device_id, + name = "Motion Capture", + device_type = "virtual", + description = "Camera point-tracking — x/y position in pixels.", + icon = "🎥", + channels = [ + ChannelConfig( + channel_id = "x_pos", + name = "X Position", + unit = "px", + min_value = 0.0, + max_value = 1920.0, + color = "#00d4ff", + ), + ChannelConfig( + channel_id = "y_pos", + name = "Y Position", + unit = "px", + min_value = 0.0, + max_value = 1080.0, + color = "#f72585", + ), + ], + )) + self._tracker = tracker + self._lock = threading.Lock() + self._last = (0.0, 0.0) + + # ── BaseDevice interface ────────────────────────────────────────────── + + def connect(self) -> bool: + self.status = DeviceStatus.SIMULATED + return True + + def disconnect(self) -> None: + self.status = DeviceStatus.DISCONNECTED + + def read_channels(self) -> Dict[str, float]: + pos = self._tracker.get_position() + if pos is not None: + with self._lock: + self._last = pos + with self._lock: + x, y = self._last + return {"x_pos": x, "y_pos": y} + + def write_channel(self, channel_id: str, value: Any) -> bool: + return False + + def get_config_widget(self): + from PyQt6.QtWidgets import QLabel + lbl = QLabel( + "Configure via the Motion Capture toolbar window.\n\n" + "Apply 'pixel_to_mm' filter in Signals → Channels\n" + "to convert pixel coordinates to millimetres." + ) + lbl.setObjectName("traceSource") + lbl.setWordWrap(True) + return lbl diff --git a/plugins/motion_capture/filter.py b/plugins/motion_capture/filter.py new file mode 100644 index 0000000..f624873 --- /dev/null +++ b/plugins/motion_capture/filter.py @@ -0,0 +1,33 @@ +""" +motion_capture/filter.py + +Custom signal filter: convert pixel coordinates to millimetres. + +Usage in Signals → Channels pipeline: + type: pixel_to_mm + px_per_mm: pixels per millimetre + +Calibration: measure a known distance on-screen (e.g. a ruler in frame) +and count how many pixels it spans. Divide pixels by mm → px_per_mm. +""" + +from core.signal_processor import FilterBase + + +class PixelToMMFilter(FilterBase): + """ + y = (x - zero_px) / px_per_mm + + Converts pixel position to millimetres. Optionally zero at a reference + point (useful when origin matters more than absolute position). + """ + name = "pixel_to_mm" + + def __init__(self, px_per_mm: float = 10.0, zero_px: float = 0.0): + self.params = {"px_per_mm": px_per_mm, "zero_px": zero_px} + + def __call__(self, value: float) -> float: + return (value - self.params["zero_px"]) / max(self.params["px_per_mm"], 1e-9) + + def reset(self): + pass diff --git a/plugins/motion_capture/manifest.json b/plugins/motion_capture/manifest.json new file mode 100644 index 0000000..f1f10ce --- /dev/null +++ b/plugins/motion_capture/manifest.json @@ -0,0 +1,9 @@ +{ + "plugin_id": "motion_capture", + "name": "Motion Capture", + "version": "1.0.0", + "description": "Track a point via webcam and stream x/y position as live signals. Apply pixel_to_mm filter to convert to real-world units.", + "author": "LabDAQ", + "entry_point": "plugin.MotionCapturePlugin", + "requires": ["opencv-python>=4.8.0"] +} diff --git a/plugins/motion_capture/plugin.py b/plugins/motion_capture/plugin.py new file mode 100644 index 0000000..d4bf497 --- /dev/null +++ b/plugins/motion_capture/plugin.py @@ -0,0 +1,177 @@ +""" +motion_capture/plugin.py + +LabDAQ Motion Capture plugin. + +Tracks a user-selected point via webcam (OpenCV CSRT tracker) and +streams x/y pixel coordinates as live channels into the acquisition +pipeline. Falls back to a Lissajous simulation when OpenCV is absent. + +Custom filter: + pixel_to_mm — convert pixel coords to millimetres using a + px_per_mm calibration value. Add it in Signals → Channels on + the x_pos or y_pos channel. + +Install OpenCV to use a real camera: + Arch Linux: sudo pacman -S python-opencv + Other: pip install opencv-python +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from PyQt6.QtWidgets import ( + QDoubleSpinBox, QFormLayout, QLabel, QSpinBox, QWidget, +) + +from plugins.base_plugin import LabPlugin, PluginAction, PluginContext + + +class MotionCapturePlugin(LabPlugin): + + # ── Metadata ────────────────────────────────────────────────────────── + + @property + def plugin_id(self) -> str: return "motion_capture" + + @property + def name(self) -> str: return "Motion Capture" + + @property + def version(self) -> str: return "1.0.0" + + @property + def description(self) -> str: + return ("Track a point via webcam and stream x/y position as live signals. " + "Apply pixel_to_mm filter to convert to real-world units.") + + @property + def author(self) -> str: return "LabDAQ" + + # ── Lifecycle ───────────────────────────────────────────────────────── + + def on_load(self, context: PluginContext) -> None: + self._ctx = context + self._win: Optional[object] = None + self._toolbar_btn = None + + from tracker import CameraTracker + from device import MotionCaptureDevice + + self._tracker = CameraTracker() + self._device = MotionCaptureDevice(self._tracker) + + def on_unload(self) -> None: + self._tracker.stop() + if self._win is not None: + self._win.close() + self._win = None + + # ── Integration hooks ───────────────────────────────────────────────── + + def get_devices(self) -> list: + return [self._device] + + def get_filter_classes(self) -> dict: + from filter import PixelToMMFilter + return {"pixel_to_mm": PixelToMMFilter} + + def get_toolbar_actions(self) -> List[PluginAction]: + return [ + PluginAction( + label = "Motion Capture", + icon = "🎥", + tooltip = "Open motion capture window", + checkable = True, + callback = self._toggle_window, + button_ref_callback = self._store_btn, + ) + ] + + def get_settings_widget(self) -> Optional[QWidget]: + w = QWidget() + lay = QFormLayout(w) + lay.setContentsMargins(0, 4, 0, 4) + + self._s_cam = QSpinBox() + self._s_cam.setRange(0, 9) + self._s_cam.setValue( + self._win.get_camera_index() if self._win else 0) + self._s_cam.setObjectName("traceWidthSpin") + self._s_cam.valueChanged.connect(self._sync_cam_to_window) + lay.addRow("Camera index:", self._s_cam) + + self._s_pxmm = QDoubleSpinBox() + self._s_pxmm.setRange(0.01, 100_000) + self._s_pxmm.setDecimals(2) + self._s_pxmm.setSuffix(" px/mm") + self._s_pxmm.setValue( + self._win.get_px_per_mm() if self._win else 10.0) + self._s_pxmm.setObjectName("cfgGlobalSpin") + self._s_pxmm.valueChanged.connect(self._sync_pxmm_to_window) + lay.addRow("Calibration:", self._s_pxmm) + + hint = QLabel( + "Calibration: count how many pixels span a known real distance\n" + "in the camera frame, then divide pixels ÷ mm." + ) + hint.setObjectName("traceSource") + hint.setWordWrap(True) + lay.addRow(hint) + + return w + + # ── Profile state ───────────────────────────────────────────────────── + + def get_save_state(self) -> Dict[str, Any]: + return { + "camera_index": self._win.get_camera_index() if self._win else 0, + "px_per_mm": self._win.get_px_per_mm() if self._win else 10.0, + } + + def apply_save_state(self, state: Dict[str, Any]) -> None: + if self._win is not None: + self._win.set_camera_index(state.get("camera_index", 0)) + self._win.set_px_per_mm(state.get("px_per_mm", 10.0)) + # Store for when window is opened later + self._pending_state = state + + # ── Internal ────────────────────────────────────────────────────────── + + def _store_btn(self, btn) -> None: + self._toolbar_btn = btn + + def _toggle_window(self, checked: bool) -> None: + if self._win is None: + from window import MotionCaptureWindow + self._win = MotionCaptureWindow( + self._tracker, self._ctx.main_window) + self._win.closed.connect(self._on_win_closed) + + # Apply any state that arrived before window was created + state = getattr(self, "_pending_state", None) + if state: + self._win.set_camera_index(state.get("camera_index", 0)) + self._win.set_px_per_mm(state.get("px_per_mm", 10.0)) + + if checked: + geo = self._ctx.main_window.geometry() + if not self._win.isVisible(): + self._win.move(geo.right() + 8, geo.top() + 40) + self._win.show() + self._win.raise_() + else: + self._win.hide() + + def _on_win_closed(self) -> None: + if self._toolbar_btn is not None: + self._toolbar_btn.setChecked(False) + + def _sync_cam_to_window(self, v: int) -> None: + if self._win: + self._win.set_camera_index(v) + + def _sync_pxmm_to_window(self, v: float) -> None: + if self._win: + self._win.set_px_per_mm(v) diff --git a/plugins/motion_capture/tracker.py b/plugins/motion_capture/tracker.py new file mode 100644 index 0000000..847a170 --- /dev/null +++ b/plugins/motion_capture/tracker.py @@ -0,0 +1,307 @@ +""" +motion_capture/tracker.py + +Camera capture + OpenCV CSRT point tracking. + +Falls back to simulation mode (Lissajous figure) when OpenCV is not +installed or the requested camera index cannot be opened. + +Thread model: + _thread runs continuously, updating _frame (RGB numpy array) and + _position (x, y floats). All public methods are thread-safe. + get_frame() / get_position() are safe to call from the Qt main thread. +""" + +from __future__ import annotations + +import math +import threading +import time +from typing import Optional, Tuple + +import numpy as np + + +# ── Simulation constants ────────────────────────────────────────────────────── +_SIM_W, _SIM_H = 640, 480 +_BG_COLOR = (17, 22, 40) # match app dark theme +_GRID_COLOR = (42, 53, 88) +_DOT_COLOR = (0, 212, 255) # cyan accent + + +def _make_sim_frame(cx: int, cy: int) -> np.ndarray: + frame = np.full((_SIM_H, _SIM_W, 3), _BG_COLOR, dtype=np.uint8) + # Grid + frame[::60, :] = _GRID_COLOR + frame[:, ::80] = _GRID_COLOR + # Crosshair + cx = max(20, min(_SIM_W - 21, cx)) + cy = max(20, min(_SIM_H - 21, cy)) + frame[cy - 12:cy + 13, cx - 1:cx + 2] = _DOT_COLOR + frame[cy - 1:cy + 2, cx - 12:cx + 13] = _DOT_COLOR + # Dot + frame[cy - 5:cy + 6, cx - 5:cx + 6] = _DOT_COLOR + # Label + frame[10:18, 10:260] = (30, 38, 65) + return frame + + +def _draw_tracking(frame: np.ndarray, cx: int, cy: int) -> np.ndarray: + """Draw crosshair + circle on an RGB frame (modifies in-place).""" + h, w = frame.shape[:2] + cx = max(20, min(w - 21, cx)) + cy = max(20, min(h - 21, cy)) + frame[cy - 16:cy + 17, cx - 1:cx + 2] = (0, 255, 0) + frame[cy - 1:cy + 2, cx - 16:cx + 17] = (0, 255, 0) + frame[cy - 6:cy + 7, cx - 6:cx + 7] = (0, 255, 0) + return frame + + +# ── Tracker ─────────────────────────────────────────────────────────────────── + +class CameraTracker: + """ + Manages camera capture and CSRT tracking in a background thread. + + Public API (all thread-safe): + start(camera_index) → bool open camera / start sim + stop() close camera / stop thread + set_roi(cx, cy, size) begin tracking at pixel (cx, cy) + clear_roi() stop tracking (keep camera running) + get_frame() → ndarray | None latest RGB frame with overlay + get_position() → (x, y) | None tracked point, or None + is_tracking() → bool + is_lost() → bool + fps → float + simulated → bool + """ + + def __init__(self): + self._lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None + + self._frame: Optional[np.ndarray] = None + self._position: Tuple[float, float] = (0.0, 0.0) + self._tracking = False + self._lost = False + self._fps = 0.0 + self._simulated = True + + # OpenCV objects — only set when cv2 available + self._cap = None + self._ot = None # OpenCV tracker + self._roi: Optional[Tuple] = None # pending ROI to init + + # ── Start / stop ────────────────────────────────────────────────────── + + def start(self, camera_index: int = 0) -> bool: + self.stop() + self._running = True + + try: + import cv2 + cap = cv2.VideoCapture(camera_index) + if cap.isOpened(): + with self._lock: + self._cap = cap + self._simulated = False + self._tracking = False + self._lost = False + self._ot = None + self._roi = None + self._thread = threading.Thread( + target=self._run_real, daemon=True) + self._thread.start() + return True + except ImportError: + pass # cv2 not installed — fall through to simulation + + with self._lock: + self._simulated = True + self._thread = threading.Thread(target=self._run_sim, daemon=True) + self._thread.start() + return True # simulation always succeeds + + def stop(self): + self._running = False + if self._thread: + self._thread.join(timeout=2) + self._thread = None + with self._lock: + if self._cap is not None: + self._cap.release() + self._cap = None + self._ot = None + self._tracking = False + self._lost = False + self._frame = None + + # ── ROI control ─────────────────────────────────────────────────────── + + def set_roi(self, cx: int, cy: int, size: int = 70): + """Begin tracking at pixel coordinate (cx, cy) with box of `size`.""" + with self._lock: + frame = self._frame + simulated = self._simulated + + if simulated: + with self._lock: + self._position = (float(cx), float(cy)) + self._tracking = True + self._lost = False + return + + if frame is None: + return + + try: + import cv2 + bgr = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR) + except Exception: + return + + h, w = bgr.shape[:2] + half = size // 2 + x = max(0, cx - half) + y = max(0, cy - half) + bw = min(w - x, size) + bh = min(h - y, size) + + ot = _make_cv_tracker() + if ot is None: + return + ot.init(bgr, (x, y, bw, bh)) + + with self._lock: + self._ot = ot + self._tracking = True + self._lost = False + + def clear_roi(self): + with self._lock: + self._ot = None + self._tracking = False + self._lost = False + + # ── Data access ─────────────────────────────────────────────────────── + + def get_frame(self) -> Optional[np.ndarray]: + with self._lock: + return self._frame.copy() if self._frame is not None else None + + def get_position(self) -> Optional[Tuple[float, float]]: + with self._lock: + return self._position if self._tracking else None + + def is_tracking(self) -> bool: + with self._lock: + return self._tracking + + def is_lost(self) -> bool: + with self._lock: + return self._lost + + @property + def fps(self) -> float: + return self._fps + + @property + def simulated(self) -> bool: + with self._lock: + return self._simulated + + # ── Background loops ────────────────────────────────────────────────── + + def _run_real(self): + import cv2 + t_prev = time.monotonic() + while self._running: + with self._lock: + cap = self._cap + if cap is None: + break + + ret, bgr = cap.read() + if not ret: + time.sleep(0.05) + continue + + rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) + + with self._lock: + ot = self._ot + + if ot is not None: + ok, bbox = ot.update(bgr) + if ok: + cx = bbox[0] + bbox[2] / 2 + cy = bbox[1] + bbox[3] / 2 + with self._lock: + self._position = (float(cx), float(cy)) + self._lost = False + _draw_tracking(rgb, int(cx), int(cy)) + else: + with self._lock: + self._lost = True + self._tracking = False + self._ot = None + + # FPS + now = time.monotonic() + dt = now - t_prev + if dt > 0: + self._fps = 0.9 * self._fps + 0.1 * (1.0 / dt) + t_prev = now + + with self._lock: + self._frame = rgb + + def _run_sim(self): + """Lissajous simulation — no camera hardware needed.""" + t0 = time.monotonic() + t_prev = t0 + cx, cy = float(_SIM_W // 2), float(_SIM_H // 2) + + while self._running: + t = time.monotonic() - t0 + cx = _SIM_W / 2 + (_SIM_W / 2 - 60) * 0.9 * math.sin(t * 0.31) + cy = _SIM_H / 2 + (_SIM_H / 2 - 50) * 0.9 * math.sin(t * 0.47 + 0.5) + + with self._lock: + tracking = self._tracking + + frame = _make_sim_frame(int(cx), int(cy)) + + with self._lock: + if tracking: + # Sim tracking: dot follows Lissajous + self._position = (cx, cy) + self._frame = frame + + now = time.monotonic() + dt = now - t_prev + if dt > 0: + self._fps = 0.9 * self._fps + 0.1 * (1.0 / dt) + t_prev = now + time.sleep(1 / 30) + + +# ── Helper ──────────────────────────────────────────────────────────────────── + +def _make_cv_tracker(): + try: + import cv2 + for factory in ( + lambda: cv2.TrackerCSRT_create(), + lambda: cv2.legacy.TrackerCSRT_create(), + lambda: cv2.TrackerKCF_create(), + lambda: cv2.legacy.TrackerKCF_create(), + ): + try: + return factory() + except AttributeError: + continue + except ImportError: + pass + return None diff --git a/plugins/motion_capture/window.py b/plugins/motion_capture/window.py new file mode 100644 index 0000000..082a8f6 --- /dev/null +++ b/plugins/motion_capture/window.py @@ -0,0 +1,262 @@ +""" +motion_capture/window.py + +Floating tool window for the Motion Capture plugin. + +Layout: + [Title bar] + [Video feed — live camera or simulation, click to select tracking point] + [Status bar — position, fps, tracking state] + [Controls — camera index, connect, click-to-track, clear, calibration] +""" + +from __future__ import annotations + +from PyQt6.QtCore import Qt, QTimer, pyqtSignal +from PyQt6.QtGui import QCloseEvent, QImage, QMouseEvent, QPixmap +from PyQt6.QtWidgets import ( + QDoubleSpinBox, QFrame, QHBoxLayout, QLabel, QPushButton, + QSizePolicy, QSpinBox, QVBoxLayout, QWidget, +) + +import numpy as np + + +class MotionCaptureWindow(QWidget): + + closed = pyqtSignal() + + def __init__(self, tracker, parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self._tracker = tracker + self._connected = False + self._arming = False # waiting for click-to-set-ROI + + self.setWindowTitle("Motion Capture") + self.setMinimumSize(680, 560) + self.resize(720, 580) + + self._build() + + self._refresh = QTimer(self) + self._refresh.timeout.connect(self._update) + self._refresh.start(33) # ~30 fps display + + # ── UI ──────────────────────────────────────────────────────────────── + + def _build(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # Title bar + hdr = QWidget(); hdr.setObjectName("devWindowTitleBar") + hdr.setFixedHeight(44) + hl = QHBoxLayout(hdr); hl.setContentsMargins(14, 0, 14, 0) + self._title_lbl = QLabel("MOTION CAPTURE") + self._title_lbl.setObjectName("devWindowTitle") + hl.addWidget(self._title_lbl) + hl.addStretch() + self._sim_badge = QLabel("SIMULATION") + self._sim_badge.setObjectName("traceSource") + self._sim_badge.setVisible(False) + hl.addWidget(self._sim_badge) + root.addWidget(hdr) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider") + root.addWidget(div) + + # Feed + self._feed = QLabel() + self._feed.setObjectName("motionFeed") + self._feed.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._feed.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self._feed.setMinimumSize(480, 320) + self._feed.setStyleSheet("background: #0b0e13;") + self._feed.setText("Connect a camera to begin.") + self._feed.mousePressEvent = self._on_feed_click + root.addWidget(self._feed, 1) + + # Status bar + self._status = QLabel("Not connected") + self._status.setObjectName("traceSource") + self._status.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._status.setFixedHeight(24) + root.addWidget(self._status) + + # Controls bar + ctrl_frame = QWidget(); ctrl_frame.setObjectName("cfgBottomBar") + ctrl = QHBoxLayout(ctrl_frame) + ctrl.setContentsMargins(10, 6, 10, 6); ctrl.setSpacing(6) + + ctrl.addWidget(QLabel("Camera:")) + self._cam_spin = QSpinBox() + self._cam_spin.setRange(0, 9); self._cam_spin.setValue(0) + self._cam_spin.setObjectName("traceWidthSpin") + self._cam_spin.setFixedWidth(52) + ctrl.addWidget(self._cam_spin) + + self._conn_btn = QPushButton("Connect") + self._conn_btn.setObjectName("applyButton") + self._conn_btn.clicked.connect(self._toggle_connect) + ctrl.addWidget(self._conn_btn) + + sep = QFrame(); sep.setFrameShape(QFrame.Shape.VLine) + sep.setObjectName("devWindowDivider"); ctrl.addWidget(sep) + + self._track_btn = QPushButton("🎯 Click to Track") + self._track_btn.setObjectName("toolbarSectionBtn") + self._track_btn.setEnabled(False) + self._track_btn.clicked.connect(self._arm_tracking) + ctrl.addWidget(self._track_btn) + + self._clear_btn = QPushButton("✕ Clear") + self._clear_btn.setObjectName("configButton") + self._clear_btn.setEnabled(False) + self._clear_btn.clicked.connect(self._clear_tracking) + ctrl.addWidget(self._clear_btn) + + sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.VLine) + sep2.setObjectName("devWindowDivider"); ctrl.addWidget(sep2) + + ctrl.addWidget(QLabel("px/mm:")) + self._pxmm_spin = QDoubleSpinBox() + self._pxmm_spin.setRange(0.01, 100_000) + self._pxmm_spin.setValue(10.0) + self._pxmm_spin.setDecimals(2) + self._pxmm_spin.setSuffix(" px/mm") + self._pxmm_spin.setObjectName("cfgGlobalSpin") + self._pxmm_spin.setFixedWidth(130) + ctrl.addWidget(self._pxmm_spin) + + ctrl.addStretch() + root.addWidget(ctrl_frame) + + # ── Actions ─────────────────────────────────────────────────────────── + + def _toggle_connect(self): + if self._connected: + self._tracker.stop() + self._connected = False + self._arming = False + self._conn_btn.setText("Connect") + self._track_btn.setEnabled(False) + self._track_btn.setText("🎯 Click to Track") + self._clear_btn.setEnabled(False) + self._feed.setPixmap(QPixmap()) + self._feed.setText("Connect a camera to begin.") + self._sim_badge.setVisible(False) + self._status.setText("Disconnected") + else: + ok = self._tracker.start(self._cam_spin.value()) + if ok: + self._connected = True + self._conn_btn.setText("Disconnect") + self._track_btn.setEnabled(True) + self._clear_btn.setEnabled(True) + self._sim_badge.setVisible(self._tracker.simulated) + else: + self._status.setText("Could not open camera") + + def _arm_tracking(self): + self._arming = True + self._track_btn.setText("⊹ Click on target…") + self._feed.setCursor(Qt.CursorShape.CrossCursor) + + def _clear_tracking(self): + self._arming = False + self._tracker.clear_roi() + self._track_btn.setText("🎯 Click to Track") + self._feed.unsetCursor() + + def _on_feed_click(self, event: QMouseEvent): + if not self._arming or not self._connected: + return + + frame = self._tracker.get_frame() + if frame is None: + return + + lw = self._feed.width() + lh = self._feed.height() + fh, fw = frame.shape[:2] + scale = min(lw / fw, lh / fh) + off_x = (lw - int(fw * scale)) // 2 + off_y = (lh - int(fh * scale)) // 2 + + fx = int((event.position().x() - off_x) / scale) + fy = int((event.position().y() - off_y) / scale) + + if 0 <= fx < fw and 0 <= fy < fh: + self._tracker.set_roi(fx, fy) + + self._arming = False + self._track_btn.setText("🎯 Click to Track") + self._feed.unsetCursor() + + # ── Display update ──────────────────────────────────────────────────── + + def _update(self): + if not self._connected: + return + + frame = self._tracker.get_frame() + if frame is not None: + self._show_frame(frame) + + pos = self._tracker.get_position() + fps = self._tracker.fps + px_per_mm = self._pxmm_spin.value() + + if self._tracker.is_lost(): + self._status.setText( + f"Tracking lost — click 'Click to Track' to reselect | {fps:.0f} FPS") + elif pos is not None: + xmm = pos[0] / px_per_mm + ymm = pos[1] / px_per_mm + self._status.setText( + f"X: {pos[0]:.1f} px ({xmm:.2f} mm) " + f"Y: {pos[1]:.1f} px ({ymm:.2f} mm) | {fps:.0f} FPS") + elif self._arming: + self._status.setText("Click on the object you want to track") + else: + sim = " [SIMULATION]" if self._tracker.simulated else "" + self._status.setText( + f"Live{sim} | {fps:.0f} FPS | Click 'Click to Track' to begin") + + def _show_frame(self, rgb: np.ndarray): + h, w = rgb.shape[:2] + bytes_per_line = 3 * w + qi = QImage( + rgb.data, w, h, bytes_per_line, + QImage.Format.Format_RGB888, + ) + pix = QPixmap.fromImage(qi).scaled( + self._feed.width(), self._feed.height(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.FastTransformation, + ) + self._feed.setPixmap(pix) + + # ── State accessors (used by plugin for save/restore) ───────────────── + + def get_camera_index(self) -> int: + return self._cam_spin.value() + + def set_camera_index(self, v: int): + self._cam_spin.setValue(v) + + def get_px_per_mm(self) -> float: + return self._pxmm_spin.value() + + def set_px_per_mm(self, v: float): + self._pxmm_spin.setValue(v) + + # ── Close ───────────────────────────────────────────────────────────── + + def closeEvent(self, e: QCloseEvent): + self._refresh.stop() + self.closed.emit() + e.accept() -- cgit v1.2.3