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 | |
| 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')
| -rw-r--r-- | plugins/motion_capture/camera_panel.py | 78 | ||||
| -rw-r--r-- | plugins/motion_capture/device.py | 124 | ||||
| -rw-r--r-- | plugins/motion_capture/plugin.py | 249 |
3 files changed, 265 insertions, 186 deletions
diff --git a/plugins/motion_capture/camera_panel.py b/plugins/motion_capture/camera_panel.py new file mode 100644 index 0000000..3fdd286 --- /dev/null +++ b/plugins/motion_capture/camera_panel.py @@ -0,0 +1,78 @@ +""" +motion_capture/camera_panel.py + +CameraPanel for AddDeviceDialog — shows selected camera info and builds +a CameraDevice when the user clicks "Add Device". +Camera discovery and simulation toggle are handled by AddDeviceDialog. +""" + +from __future__ import annotations + +from PyQt6.QtCore import QThread, pyqtSignal +from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget + + +class CameraScanThread(QThread): + cameras_found = pyqtSignal(list) # list of (index: int, label: str) + + def run(self): + results = [] + try: + import cv2 + for i in range(6): + cap = cv2.VideoCapture(i) + if cap.isOpened(): + ret, _ = cap.read() + if ret: + w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + results.append((i, f"Camera {i} ({w}×{h})")) + cap.release() + except ImportError: + pass + self.cameras_found.emit(results) + + +class CameraPanel(QWidget): + """Config panel for Camera device in AddDeviceDialog.""" + + def __init__(self): + super().__init__() + self._selected_index: int = 0 + self._simulate: bool = False + + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 4, 0, 4) + lay.setSpacing(8) + + self._selected_lbl = QLabel("No camera selected — scan above and click a camera") + self._selected_lbl.setObjectName("traceSource") + self._selected_lbl.setWordWrap(True) + lay.addWidget(self._selected_lbl) + + note = QLabel( + "Requires OpenCV: pip install opencv-python\n" + "Use Simulation mode if no camera is available." + ) + note.setObjectName("traceSource") + note.setWordWrap(True) + lay.addWidget(note) + lay.addStretch() + + def is_valid(self) -> bool: + return True + + def set_simulate(self, simulate: bool): + self._simulate = simulate + + def set_camera_index(self, index: int): + self._selected_index = index + self._selected_lbl.setText(f"Selected: Camera {index}") + + def build_device(self, device_id: str): + from device import CameraDevice + return CameraDevice( + device_id=device_id, + camera_index=self._selected_index, + simulate=self._simulate, + ) diff --git a/plugins/motion_capture/device.py b/plugins/motion_capture/device.py index f0d082c..9806030 100644 --- a/plugins/motion_capture/device.py +++ b/plugins/motion_capture/device.py @@ -1,34 +1,39 @@ """ motion_capture/device.py -BaseDevice that reads x/y position from CameraTracker. +BaseDevice implementation for a camera-based motion tracker. + +Each CameraDevice owns a CameraTracker that runs its own background +capture thread. connect() opens the camera; disconnect() releases it. Channels: x_pos — horizontal position [px] (left=0, right=frame_width) y_pos — vertical position [px] (top=0, bottom=frame_height) -Apply the pixel_to_mm filter in Signals → Channels to convert to mm. +Apply the pixel_to_mm filter in Signals → Channels to convert to mm. """ from __future__ import annotations -import threading -from typing import Any, Dict +from typing import Any, Dict, Optional from devices.base_device import ( BaseDevice, ChannelConfig, DeviceInfo, DeviceStatus, ) -class MotionCaptureDevice(BaseDevice): +class CameraDevice(BaseDevice): - def __init__(self, tracker, device_id: str = "motion_capture"): + def __init__(self, device_id: str = "cam_0", + camera_index: int = 0, + simulate: bool = False): + name = "Camera (Sim)" if simulate else f"Camera {camera_index}" super().__init__(DeviceInfo( device_id = device_id, - name = "Motion Capture", - device_type = "virtual", + name = name, + device_type = "camera", description = "Camera point-tracking — x/y position in pixels.", - icon = "🎥", + icon = "📷", channels = [ ChannelConfig( channel_id = "x_pos", @@ -48,38 +53,101 @@ class MotionCaptureDevice(BaseDevice): ), ], )) - self._tracker = tracker - self._lock = threading.Lock() - self._last = (0.0, 0.0) + self._camera_index = camera_index + self._simulate = simulate + self._tracker: Optional[Any] = None + self._tracking_win = None - # ── BaseDevice interface ────────────────────────────────────────────── + # ── BaseDevice ──────────────────────────────────────────────────────── def connect(self) -> bool: - self.status = DeviceStatus.SIMULATED + from tracker import CameraTracker + self._tracker = CameraTracker() + self._tracker.start(-1 if self._simulate else self._camera_index) + if self._tracker.simulated: + self.status = DeviceStatus.SIMULATED + else: + self.status = DeviceStatus.CONNECTED return True def disconnect(self) -> None: + if self._tracking_win is not None: + self._tracking_win.close() + self._tracking_win = None + if self._tracker is not None: + self._tracker.stop() + self._tracker = None self.status = DeviceStatus.DISCONNECTED def read_channels(self) -> Dict[str, float]: + if self._tracker is None: + return {"x_pos": 0.0, "y_pos": 0.0} pos = self._tracker.get_position() - if pos is not None: - with self._lock: - self._last = pos - with self._lock: - x, y = self._last - return {"x_pos": x, "y_pos": y} + if pos is None: + return {"x_pos": 0.0, "y_pos": 0.0} + x, y = pos + return {"x_pos": float(x), "y_pos": float(y)} def write_channel(self, channel_id: str, value: Any) -> bool: return False def get_config_widget(self): - from PyQt6.QtWidgets import QLabel - lbl = QLabel( - "Configure via the Motion Capture toolbar window.\n\n" - "Apply 'pixel_to_mm' filter in Signals → Channels\n" - "to convert pixel coordinates to millimetres." + from PyQt6.QtWidgets import ( + QFormLayout, QLabel, QPushButton, QVBoxLayout, QWidget, + ) + + w = QWidget() + lay = QVBoxLayout(w) + lay.setContentsMargins(8, 8, 8, 8) + lay.setSpacing(10) + + form = QFormLayout() + form.setContentsMargins(0, 0, 0, 0) + + src_lbl = QLabel( + "Simulation" if self._simulate else f"Camera {self._camera_index}" + ) + src_lbl.setObjectName("traceSource") + form.addRow("Source:", src_lbl) + + self._status_lbl = QLabel(self.status.name.title()) + self._status_lbl.setObjectName("traceSource") + form.addRow("Status:", self._status_lbl) + + lay.addLayout(form) + + open_btn = QPushButton("🎥 Open Camera View") + open_btn.setObjectName("addTraceBtn") + open_btn.clicked.connect(self._open_tracking_window) + lay.addWidget(open_btn) + + hint = QLabel( + "In the Camera View: click the frame to set the tracking\n" + "point. Apply the 'pixel_to_mm' filter in Signals → Channels\n" + "to convert pixel coordinates to real-world units." ) - lbl.setObjectName("traceSource") - lbl.setWordWrap(True) - return lbl + hint.setObjectName("traceSource") + hint.setWordWrap(True) + lay.addWidget(hint) + lay.addStretch() + + return w + + # ── Internal ────────────────────────────────────────────────────────── + + def _open_tracking_window(self): + if self._tracker is None: + return + from window import MotionCaptureWindow + if self._tracking_win is None: + self._tracking_win = MotionCaptureWindow(self._tracker) + self._tracking_win.closed.connect(self._on_tracking_win_closed) + self._tracking_win.show() + self._tracking_win.raise_() + + def _on_tracking_win_closed(self): + self._tracking_win = None + + +# Backward-compat alias for any profiles that reference the old class name +MotionCaptureDevice = CameraDevice 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) |
