""" motion_capture/plugin.py LabUI Motion Capture plugin. When enabled, adds a "Camera" device type to the Add Device dialog. Each camera added by the user becomes a first-class device in the acquisition pipeline — data flows through the standard Device → Signal → Channel → Plot stream. Custom filter registered globally: pixel_to_mm — convert pixel coords to mm using a px_per_mm value. Add it in Signals → Channels on x_pos or y_pos. Install OpenCV for real camera support: pip install opencv-python """ from __future__ import annotations from typing import List, Optional 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 ( "Adds a Camera device type to the Add Device dialog. " "Stream x/y tracking coordinates directly into the signal pipeline." ) @property def author(self) -> str: return "LabUI" # ── Lifecycle ───────────────────────────────────────────────────────── def on_load(self, context: PluginContext) -> None: self._ctx = context self._btn = None # toolbar button reference self._windows: dict = {} # device_id → MotionCaptureWindow self._window_state: dict = {} # device_id → {px_per_mm, camera_idx} from ui.add_device_dialog import _PANELS from camera_panel import CameraPanel _PANELS["Camera"] = (CameraPanel, "cam") from core.profile import ProfileManager from device import CameraDevice ProfileManager.register_device_factory(CameraDevice.DEVICE_TYPE, CameraDevice) def on_unload(self) -> None: for win in list(self._windows.values()): win.close() self._windows.clear() from ui.add_device_dialog import _PANELS _PANELS.pop("Camera", None) from core.profile import ProfileManager from device import CameraDevice ProfileManager.unregister_device_factory(CameraDevice.DEVICE_TYPE) # ── Integration hooks ───────────────────────────────────────────────── def get_toolbar_actions(self) -> list: return [PluginAction( label="Motion Capture", icon="🎥", tooltip="Open camera tracking view", checkable=True, callback=self._on_btn_toggled, button_ref_callback=lambda btn: setattr(self, "_btn", btn), )] def get_filter_classes(self) -> dict: from filter import PixelToMMFilter return {"pixel_to_mm": PixelToMMFilter} # ── Internal ────────────────────────────────────────────────────────── def _camera_devices(self) -> list: from device import CameraDevice return [d for d in self._ctx.registry.all_instances() if isinstance(d, CameraDevice)] def _on_btn_toggled(self, checked: bool) -> None: from PyQt6.QtWidgets import QInputDialog, QMessageBox if not checked: return cameras = self._camera_devices() if not cameras: QMessageBox.information( None, "No Camera Devices", "Add a Camera device first via Devices → Add Device.", ) if self._btn is not None: self._btn.setChecked(False) return if len(cameras) == 1: self._open_window(cameras[0]) else: names = [f"{d.info.name} ({d.info.device_id})" for d in cameras] choice, ok = QInputDialog.getItem( None, "Select Camera", "Open tracking view for:", names, 0, False, ) if not ok: if self._btn is not None: self._btn.setChecked(False) return idx = names.index(choice) self._open_window(cameras[idx]) def _open_window(self, device) -> None: from window import MotionCaptureWindow dev_id = device.info.device_id if dev_id in self._windows: win = self._windows[dev_id] win.show(); win.raise_() return if device._tracker is None: from PyQt6.QtWidgets import QMessageBox QMessageBox.warning( None, "Camera Not Connected", f"{device.info.name} is not connected. Connect it first.", ) if self._btn is not None: self._btn.setChecked(False) return win = MotionCaptureWindow(device._tracker) # Restore mode/shape from live tracker state win.restore_ui_state( mode=device._tracker.track_mode, shape=device._tracker.template_shape, ) # Restore px/mm and camera index from last session if dev_id in self._window_state: state = self._window_state[dev_id] win.set_px_per_mm(state["px_per_mm"]) win.set_camera_index(state["camera_idx"]) win.closed.connect(lambda d=dev_id, w=win: self._on_window_closed(d, w)) self._windows[dev_id] = win win.show() def _on_window_closed(self, dev_id: str, win) -> None: self._window_state[dev_id] = { "px_per_mm": win.get_px_per_mm(), "camera_idx": win.get_camera_index(), } self._windows.pop(dev_id, None) if not self._windows and self._btn is not None: self._btn.setChecked(False)