From 0a8d42501c1bf168bd852537bb3cd18490fac7ee Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Thu, 7 May 2026 14:46:20 -0600 Subject: Motion capture: camera selection dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace camera index spinbox with a combo listing detected cameras by name. On Linux enumerates /dev/video* and reads device names from sysfs (fast, no probing). Fallback probes cv2.VideoCapture indices 0-7 on other platforms. Simulation entry always appended as last option. Also adds explicit simulation mode (index -1) to tracker.start() and a rescan button (⟳) in both the main window and the Settings > Plugins widget. Co-Authored-By: Claude Sonnet 4.6 --- plugins/motion_capture/plugin.py | 46 +++++++++++++++++++++++++++-------- plugins/motion_capture/tracker.py | 51 +++++++++++++++++++++++++++++++++++++++ plugins/motion_capture/window.py | 51 +++++++++++++++++++++++++++++++-------- 3 files changed, 128 insertions(+), 20 deletions(-) diff --git a/plugins/motion_capture/plugin.py b/plugins/motion_capture/plugin.py index d4bf497..e5d37c7 100644 --- a/plugins/motion_capture/plugin.py +++ b/plugins/motion_capture/plugin.py @@ -22,7 +22,8 @@ from __future__ import annotations from typing import Any, Dict, List, Optional from PyQt6.QtWidgets import ( - QDoubleSpinBox, QFormLayout, QLabel, QSpinBox, QWidget, + QComboBox, QDoubleSpinBox, QFormLayout, QHBoxLayout, + QLabel, QPushButton, QWidget, ) from plugins.base_plugin import LabPlugin, PluginAction, PluginContext @@ -90,17 +91,26 @@ class MotionCapturePlugin(LabPlugin): ] def get_settings_widget(self) -> Optional[QWidget]: + from tracker import list_cameras 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) + 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) @@ -168,9 +178,25 @@ class MotionCapturePlugin(LabPlugin): if self._toolbar_btn is not None: self._toolbar_btn.setChecked(False) - def _sync_cam_to_window(self, v: int) -> None: + 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: + return + idx = self._s_cameras[combo_i][0] if self._win: - self._win.set_camera_index(v) + self._win.set_camera_index(idx) def _sync_pxmm_to_window(self, v: float) -> None: if self._win: diff --git a/plugins/motion_capture/tracker.py b/plugins/motion_capture/tracker.py index 847a170..d65aca1 100644 --- a/plugins/motion_capture/tracker.py +++ b/plugins/motion_capture/tracker.py @@ -99,6 +99,13 @@ class CameraTracker: self.stop() self._running = True + if camera_index == -1: + with self._lock: + self._simulated = True + self._thread = threading.Thread(target=self._run_sim, daemon=True) + self._thread.start() + return True + try: import cv2 cap = cv2.VideoCapture(camera_index) @@ -287,6 +294,50 @@ class CameraTracker: time.sleep(1 / 30) +# ── Camera enumeration ──────────────────────────────────────────────────────── + +def list_cameras(): + """ + Return list of (index, label) for all detected camera devices. + + On Linux: reads /dev/video* and pulls human-readable names from sysfs. + Falls back to probing cv2.VideoCapture indices 0-7 on other platforms. + Always appends a Simulation entry. + """ + cameras = [] + + import os, glob + + video_nodes = sorted(glob.glob("/dev/video*")) + if video_nodes: + for path in video_nodes: + try: + idx = int(path.replace("/dev/video", "")) + except ValueError: + continue + name_path = f"/sys/class/video4linux/video{idx}/name" + try: + with open(name_path) as f: + name = f.read().strip() + except OSError: + name = path + cameras.append((idx, f"{name} [/dev/video{idx}]")) + else: + # Non-Linux fallback: probe indices + try: + import cv2 + for idx in range(8): + cap = cv2.VideoCapture(idx) + if cap.isOpened(): + cameras.append((idx, f"Camera {idx}")) + cap.release() + except ImportError: + pass + + cameras.append((-1, "Simulation (no camera)")) + return cameras + + # ── Helper ──────────────────────────────────────────────────────────────────── def _make_cv_tracker(): diff --git a/plugins/motion_capture/window.py b/plugins/motion_capture/window.py index 082a8f6..40b436f 100644 --- a/plugins/motion_capture/window.py +++ b/plugins/motion_capture/window.py @@ -15,8 +15,8 @@ from __future__ import annotations from PyQt6.QtCore import Qt, QTimer, pyqtSignal from PyQt6.QtGui import QCloseEvent, QImage, QMouseEvent, QPixmap from PyQt6.QtWidgets import ( - QDoubleSpinBox, QFrame, QHBoxLayout, QLabel, QPushButton, - QSizePolicy, QSpinBox, QVBoxLayout, QWidget, + QComboBox, QDoubleSpinBox, QFrame, QHBoxLayout, QLabel, QPushButton, + QSizePolicy, QVBoxLayout, QWidget, ) import numpy as np @@ -92,11 +92,19 @@ class MotionCaptureWindow(QWidget): ctrl.setContentsMargins(10, 6, 10, 6); ctrl.setSpacing(6) ctrl.addWidget(QLabel("Camera:")) - self._cam_spin = QSpinBox() - self._cam_spin.setRange(0, 9); self._cam_spin.setValue(0) - self._cam_spin.setObjectName("traceWidthSpin") - self._cam_spin.setFixedWidth(52) - ctrl.addWidget(self._cam_spin) + self._cam_combo = QComboBox() + self._cam_combo.setObjectName("channelPickerCb") + self._cam_combo.setMinimumWidth(200) + ctrl.addWidget(self._cam_combo) + + self._refresh_btn = QPushButton("⟳") + self._refresh_btn.setObjectName("configButton") + self._refresh_btn.setFixedWidth(28) + self._refresh_btn.setToolTip("Rescan cameras") + self._refresh_btn.clicked.connect(self._scan_cameras) + ctrl.addWidget(self._refresh_btn) + + self._scan_cameras() # populate on open self._conn_btn = QPushButton("Connect") self._conn_btn.setObjectName("applyButton") @@ -136,6 +144,21 @@ class MotionCaptureWindow(QWidget): # ── Actions ─────────────────────────────────────────────────────────── + def _scan_cameras(self): + from tracker import list_cameras + current_idx = self.get_camera_index() + self._cam_combo.blockSignals(True) + self._cam_combo.clear() + self._cameras = list_cameras() # [(index, label), ...] + for _, label in self._cameras: + self._cam_combo.addItem(label) + # Restore selection by index if still present + for i, (idx, _) in enumerate(self._cameras): + if idx == current_idx: + self._cam_combo.setCurrentIndex(i) + break + self._cam_combo.blockSignals(False) + def _toggle_connect(self): if self._connected: self._tracker.stop() @@ -150,7 +173,7 @@ class MotionCaptureWindow(QWidget): self._sim_badge.setVisible(False) self._status.setText("Disconnected") else: - ok = self._tracker.start(self._cam_spin.value()) + ok = self._tracker.start(self.get_camera_index()) if ok: self._connected = True self._conn_btn.setText("Disconnect") @@ -243,10 +266,18 @@ class MotionCaptureWindow(QWidget): # ── State accessors (used by plugin for save/restore) ───────────────── def get_camera_index(self) -> int: - return self._cam_spin.value() + i = self._cam_combo.currentIndex() + cameras = getattr(self, "_cameras", []) + if 0 <= i < len(cameras): + return cameras[i][0] + return 0 def set_camera_index(self, v: int): - self._cam_spin.setValue(v) + cameras = getattr(self, "_cameras", []) + for i, (idx, _) in enumerate(cameras): + if idx == v: + self._cam_combo.setCurrentIndex(i) + return def get_px_per_mm(self) -> float: return self._pxmm_spin.value() -- cgit v1.2.3