diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-06-09 15:40:41 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-06-09 15:40:41 -0600 |
| commit | c16b8546c72673534965da5590a62ba3ee7635d3 (patch) | |
| tree | a429f65f6dbe62c1253c8c31730b8420153678be /plugins/motion_capture/plugin.py | |
| parent | 254df94e71586216991486242ab4064706177465 (diff) | |
| parent | a35b9b121218625445bda40729a8222243199c77 (diff) | |
Merge fix/add-devices-steamline: streamlined Add Device dialog
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 | 249 |
1 files changed, 91 insertions, 158 deletions
diff --git a/plugins/motion_capture/plugin.py b/plugins/motion_capture/plugin.py index 1c655c5..0b44cb4 100644 --- a/plugins/motion_capture/plugin.py +++ b/plugins/motion_capture/plugin.py @@ -3,28 +3,22 @@ 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 +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 Any, Dict, List, Optional - -from PyQt6.QtWidgets import ( - QComboBox, QDoubleSpinBox, QFormLayout, QHBoxLayout, - QLabel, QPushButton, QWidget, -) +from typing import List, Optional from plugins.base_plugin import LabPlugin, PluginAction, PluginContext @@ -44,8 +38,10 @@ class MotionCapturePlugin(LabPlugin): @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.") + 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 "LabDAQ" @@ -53,158 +49,95 @@ class MotionCapturePlugin(LabPlugin): # ── 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) + self._ctx = context + self._btn = None # toolbar button reference + self._windows: dict = {} # device_id → MotionCaptureWindow + from ui.add_device_dialog import _PANELS + from camera_panel import CameraPanel + _PANELS["Camera"] = (CameraPanel, "cam") def on_unload(self) -> None: - self._tracker.stop() - if self._win is not None: - self._win.close() - self._win = None + for win in list(self._windows.values()): + win.close() + self._windows.clear() + from ui.add_device_dialog import _PANELS + _PANELS.pop("Camera", None) # ── Integration hooks ───────────────────────────────────────────────── - def get_devices(self) -> list: - return [self._device] + 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} - 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 + # ── Internal ────────────────────────────────────────────────────────── - # ── Profile state ───────────────────────────────────────────────────── + def _camera_devices(self) -> list: + from device import CameraDevice + return [d for d in self._ctx.registry.all_instances() + if isinstance(d, CameraDevice)] - 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 _on_btn_toggled(self, checked: bool) -> None: + from PyQt6.QtWidgets import QInputDialog, QMessageBox + if not checked: + return - 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 + cameras = self._camera_devices() - # ── Internal ────────────────────────────────────────────────────────── + 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 - 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: - if not self._win.isVisible(): - from PyQt6.QtWidgets import QApplication - mw = self._ctx.main_window - screen = QApplication.screenAt(mw.geometry().center()) or QApplication.primaryScreen() - avail = screen.availableGeometry() - geo = mw.normalGeometry() - self._win.adjustSize() - x = max(avail.left(), min(geo.right() + 8, avail.right() - self._win.width())) - y = max(avail.top(), min(geo.top() + 40, avail.bottom() - self._win.height())) - self._win.move(x, y) - self._win.show() - self._win.raise_() + if len(cameras) == 1: + self._open_window(cameras[0]) 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: + 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 - 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) + 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) + win.closed.connect(lambda: self._close_window(dev_id)) + self._windows[dev_id] = win + win.show() + + def _close_window(self, dev_id: str) -> None: + self._windows.pop(dev_id, None) + if not self._windows and self._btn is not None: + self._btn.setChecked(False) |
