""" 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 ( QComboBox, QDoubleSpinBox, QFormLayout, QHBoxLayout, QLabel, QPushButton, 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]: from tracker import list_cameras w = QWidget() lay = QFormLayout(w) lay.setContentsMargins(0, 4, 0, 4) cam_row = QHBoxLayout() self._s_cam = QComboBox() self._s_cam.setObjectName("channelPickerCb") self._s_cameras: list = [] self._s_refresh_btn = QPushButton("⟳") self._s_refresh_btn.setObjectName("configButton") self._s_refresh_btn.setFixedWidth(28) self._s_refresh_btn.setToolTip("Rescan cameras") self._s_refresh_btn.clicked.connect(lambda: self._populate_settings_cam(list_cameras)) cam_row.addWidget(self._s_cam, 1) cam_row.addWidget(self._s_refresh_btn) lay.addRow("Camera:", cam_row) self._populate_settings_cam(list_cameras) self._s_cam.currentIndexChanged.connect(self._sync_cam_to_window) 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 _populate_settings_cam(self, list_cameras_fn) -> None: current = self._win.get_camera_index() if self._win else 0 self._s_cam.blockSignals(True) self._s_cam.clear() self._s_cameras = list_cameras_fn() for _, label in self._s_cameras: self._s_cam.addItem(label) for i, (idx, _) in enumerate(self._s_cameras): if idx == current: self._s_cam.setCurrentIndex(i) break self._s_cam.blockSignals(False) def _sync_cam_to_window(self, combo_i: int) -> None: if not self._s_cameras: return idx = self._s_cameras[combo_i][0] if self._win: self._win.set_camera_index(idx) def _sync_pxmm_to_window(self, v: float) -> None: if self._win: self._win.set_px_per_mm(v)