summaryrefslogtreecommitdiff
path: root/plugins/motion_capture/plugin.py
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/motion_capture/plugin.py')
-rw-r--r--plugins/motion_capture/plugin.py177
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)