summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-05-07 14:46:20 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-06-03 09:50:17 -0600
commit0a8d42501c1bf168bd852537bb3cd18490fac7ee (patch)
treee6e604cb7fc026fed8ffa65ea14929dfabdfbab9
parent8e5d0aec942fb9c49040fc525f0d697fd24d7c2f (diff)
Motion capture: camera selection dropdown
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 <noreply@anthropic.com>
-rw-r--r--plugins/motion_capture/plugin.py46
-rw-r--r--plugins/motion_capture/tracker.py51
-rw-r--r--plugins/motion_capture/window.py51
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()