diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-05-07 13:42:30 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-06-03 09:50:17 -0600 |
| commit | 8e5d0aec942fb9c49040fc525f0d697fd24d7c2f (patch) | |
| tree | a9636db29be900698541441cb93b486b9a3c0acb /plugins/motion_capture/plugin.py | |
| parent | deb3ad65d25d4c3167a3cbeca2ac19b5c45b0627 (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/motion_capture/plugin.py')
| -rw-r--r-- | plugins/motion_capture/plugin.py | 177 |
1 files changed, 177 insertions, 0 deletions
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) |
