summaryrefslogtreecommitdiff
path: root/plugins
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-05-07 13:42:30 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-06-03 09:50:17 -0600
commit8e5d0aec942fb9c49040fc525f0d697fd24d7c2f (patch)
treea9636db29be900698541441cb93b486b9a3c0acb /plugins
parentdeb3ad65d25d4c3167a3cbeca2ac19b5c45b0627 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'plugins')
-rw-r--r--plugins/base_plugin.py3
-rw-r--r--plugins/motion_capture/__pycache__/device.cpython-314.pycbin0 -> 4440 bytes
-rw-r--r--plugins/motion_capture/__pycache__/filter.cpython-314.pycbin0 -> 2178 bytes
-rw-r--r--plugins/motion_capture/__pycache__/tracker.cpython-314.pycbin0 -> 16806 bytes
-rw-r--r--plugins/motion_capture/device.py85
-rw-r--r--plugins/motion_capture/filter.py33
-rw-r--r--plugins/motion_capture/manifest.json9
-rw-r--r--plugins/motion_capture/plugin.py177
-rw-r--r--plugins/motion_capture/tracker.py307
-rw-r--r--plugins/motion_capture/window.py262
10 files changed, 876 insertions, 0 deletions
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
--- /dev/null
+++ b/plugins/motion_capture/__pycache__/device.cpython-314.pyc
Binary files 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
--- /dev/null
+++ b/plugins/motion_capture/__pycache__/filter.cpython-314.pyc
Binary files 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
--- /dev/null
+++ b/plugins/motion_capture/__pycache__/tracker.cpython-314.pyc
Binary files 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: <calibration value> 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()