diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-06-09 19:39:35 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-06-09 19:39:35 -0600 |
| commit | b776e70b4cd94c5d1f53dde39b2e46f6447cac3e (patch) | |
| tree | fade07a8ecd7a771c095a4d636fa5570e1f8ee7c | |
| parent | 51f04ada503b02c598c9940c96fdc444cf5da52a (diff) | |
Motion capture: profile support, FPS config, persistent tracking, window state
Profile integration:
- CameraDevice.get_save_config() serialises camera_index, simulate,
resolution, fps — cameras now persist in .labdaq profiles
- ProfileManager.register/unregister_device_factory() lets plugins
register their device classes for profile restoration without
core → plugin dependency
- MotionCapturePlugin registers CameraDevice factory on load/unload
Camera configuration:
- Add Frame Rate combo to CameraPanel (Default/15/24/30/60/120 fps)
defaults to 30 fps; flows through to CAP_PROP_FPS on connect
- tracker.start() accepts fps param alongside existing resolution
Tracking persistence:
- CameraTracker.is_running() — checks thread alive state
- MotionCaptureWindow initialises _connected from tracker.is_running()
so reopening the window resumes the live feed without restarting
the camera or losing the active tracking state
Window state:
- Plugin saves px_per_mm + camera_idx when window closes
- Reopened window restores saved values and syncs mode/shape UI
from tracker state via restore_ui_state() (signals blocked to
avoid resetting tracker)
- Window type changed to Qt.WindowType.Window for independent
close button on all Linux WMs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | core/profile.py | 14 | ||||
| -rw-r--r-- | plugins/motion_capture/camera_panel.py | 18 | ||||
| -rw-r--r-- | plugins/motion_capture/device.py | 17 | ||||
| -rw-r--r-- | plugins/motion_capture/plugin.py | 33 | ||||
| -rw-r--r-- | plugins/motion_capture/tracker.py | 8 | ||||
| -rw-r--r-- | plugins/motion_capture/window.py | 23 |
6 files changed, 102 insertions, 11 deletions
diff --git a/core/profile.py b/core/profile.py index fdd3a48..39cb3ca 100644 --- a/core/profile.py +++ b/core/profile.py @@ -131,6 +131,18 @@ class ProfileManager: ProfileManager.apply(profile, registry, processor, ...) """ + # Plugin device factories registered at runtime (device_type → class) + _extra_factories: dict = {} + + @staticmethod + def register_device_factory(device_type: str, factory): + """Register a plugin device class so it can be restored from profiles.""" + ProfileManager._extra_factories[device_type] = factory + + @staticmethod + def unregister_device_factory(device_type: str): + ProfileManager._extra_factories.pop(device_type, None) + # ── Capture ────────────────────────────────────────────────────────────── @staticmethod @@ -234,7 +246,7 @@ class ProfileManager: ) # ── Devices ────────────────────────────────────────────────────── - _DEVICE_FACTORIES = {} + _DEVICE_FACTORIES = dict(ProfileManager._extra_factories) try: from devices.arduino_device import ArduinoDevice _DEVICE_FACTORIES["arduino"] = ArduinoDevice diff --git a/plugins/motion_capture/camera_panel.py b/plugins/motion_capture/camera_panel.py index 5e25f7f..279945e 100644 --- a/plugins/motion_capture/camera_panel.py +++ b/plugins/motion_capture/camera_panel.py @@ -21,6 +21,15 @@ _RESOLUTIONS = { "2560 × 1440": (2560, 1440), } +_FPS = { + "Default": None, + "15 fps": 15, + "24 fps": 24, + "30 fps": 30, + "60 fps": 60, + "120 fps": 120, +} + class CameraScanThread(QThread): cameras_found = pyqtSignal(list) # list of (index: int, label: str) @@ -64,7 +73,14 @@ class CameraPanel(QWidget): form.setContentsMargins(0, 0, 0, 0) self.res_cb = QComboBox() self.res_cb.addItems(list(_RESOLUTIONS.keys())) + self.res_cb.setCurrentText("1280 × 720") form.addRow("Resolution:", self.res_cb) + + self.fps_cb = QComboBox() + self.fps_cb.addItems(list(_FPS.keys())) + self.fps_cb.setCurrentText("30 fps") + form.addRow("Frame Rate:", self.fps_cb) + lay.addLayout(form) note = QLabel( @@ -91,9 +107,11 @@ class CameraPanel(QWidget): resolution: Optional[Tuple[int, int]] = _RESOLUTIONS.get( self.res_cb.currentText() ) + fps: Optional[int] = _FPS.get(self.fps_cb.currentText()) return CameraDevice( device_id=device_id, camera_index=self._selected_index, simulate=self._simulate, resolution=resolution, + fps=fps, ) diff --git a/plugins/motion_capture/device.py b/plugins/motion_capture/device.py index 8eb1591..58f89ce 100644 --- a/plugins/motion_capture/device.py +++ b/plugins/motion_capture/device.py @@ -24,10 +24,13 @@ from devices.base_device import ( class CameraDevice(BaseDevice): + DEVICE_TYPE = "camera" + def __init__(self, device_id: str = "cam_0", camera_index: int = 0, simulate: bool = False, - resolution=None): + resolution=None, + fps=None): name = "Camera (Sim)" if simulate else f"Camera {camera_index}" super().__init__(DeviceInfo( device_id = device_id, @@ -57,6 +60,7 @@ class CameraDevice(BaseDevice): self._camera_index = camera_index self._simulate = simulate self._resolution = resolution + self._fps = fps self._tracker: Optional[Any] = None self._tracking_win = None @@ -68,6 +72,7 @@ class CameraDevice(BaseDevice): self._tracker.start( -1 if self._simulate else self._camera_index, resolution=self._resolution, + fps=self._fps, ) if self._tracker.simulated: self.status = DeviceStatus.SIMULATED @@ -93,6 +98,16 @@ class CameraDevice(BaseDevice): x, y = pos return {"x_pos": float(x), "y_pos": float(y)} + def get_save_config(self) -> dict: + return { + "device_type": self.DEVICE_TYPE, + "device_id": self.info.device_id, + "camera_index": self._camera_index, + "simulate": self._simulate, + "resolution": list(self._resolution) if self._resolution else None, + "fps": self._fps, + } + def write_channel(self, channel_id: str, value: Any) -> bool: return False diff --git a/plugins/motion_capture/plugin.py b/plugins/motion_capture/plugin.py index 0b44cb4..9cf9557 100644 --- a/plugins/motion_capture/plugin.py +++ b/plugins/motion_capture/plugin.py @@ -49,12 +49,16 @@ class MotionCapturePlugin(LabPlugin): # ── Lifecycle ───────────────────────────────────────────────────────── def on_load(self, context: PluginContext) -> None: - self._ctx = context - self._btn = None # toolbar button reference - self._windows: dict = {} # device_id → MotionCaptureWindow + 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()): @@ -62,6 +66,9 @@ class MotionCapturePlugin(LabPlugin): 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 ───────────────────────────────────────────────── @@ -133,11 +140,27 @@ class MotionCapturePlugin(LabPlugin): self._btn.setChecked(False) return win = MotionCaptureWindow(device._tracker) - win.closed.connect(lambda: self._close_window(dev_id)) + + # 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 _close_window(self, dev_id: str) -> None: + 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) diff --git a/plugins/motion_capture/tracker.py b/plugins/motion_capture/tracker.py index ffc9f25..8dc89f1 100644 --- a/plugins/motion_capture/tracker.py +++ b/plugins/motion_capture/tracker.py @@ -128,7 +128,8 @@ class CameraTracker: # ── Start / stop ────────────────────────────────────────────────────── def start(self, camera_index: int = 0, - resolution: Optional[Tuple[int, int]] = None) -> bool: + resolution: Optional[Tuple[int, int]] = None, + fps: Optional[int] = None) -> bool: self.stop() self._running = True @@ -145,6 +146,8 @@ class CameraTracker: if resolution: cap.set(cv2.CAP_PROP_FRAME_WIDTH, resolution[0]) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, resolution[1]) + if fps: + cap.set(cv2.CAP_PROP_FPS, fps) if cap.isOpened(): with self._lock: self._cap = cap @@ -271,6 +274,9 @@ class CameraTracker: with self._lock: return self._lost + def is_running(self) -> bool: + return self._running and self._thread is not None and self._thread.is_alive() + @property def fps(self) -> float: return self._fps diff --git a/plugins/motion_capture/window.py b/plugins/motion_capture/window.py index bc9a0de..46b8850 100644 --- a/plugins/motion_capture/window.py +++ b/plugins/motion_capture/window.py @@ -245,10 +245,9 @@ class MotionCaptureWindow(QWidget): closed = pyqtSignal() def __init__(self, tracker, parent=None): - super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + super().__init__(parent, Qt.WindowType.Window) self._tracker = tracker - self._connected = False - self._selecting = False # True while frame is frozen for selection + self._selecting = False self.setWindowTitle("Motion Capture") self.setMinimumSize(680, 560) @@ -256,6 +255,11 @@ class MotionCaptureWindow(QWidget): self._build() + # Reflect tracker's actual state — it may already be running + self._connected = tracker.is_running() + self._sim_badge.setVisible(self._connected and tracker.simulated) + self._sync_controls() + self._refresh = QTimer(self) self._refresh.timeout.connect(self._update) self._refresh.start(33) @@ -500,6 +504,19 @@ class MotionCaptureWindow(QWidget): # ── State accessors ──────────────────────────────────────────────────── + def restore_ui_state(self, mode: str, shape: str): + """Sync mode/shape UI to tracker state without triggering tracker resets.""" + self._mode_cb.blockSignals(True) + self._shape_cb.blockSignals(True) + self._mode_cb.setCurrentIndex(1 if mode == "csrt" else 0) + self._shape_cb.setCurrentIndex(1 if shape == "circle" else 0) + is_template = mode == "template" + self._shape_lbl.setVisible(is_template) + self._shape_cb.setVisible(is_template) + self._mode_cb.blockSignals(False) + self._shape_cb.blockSignals(False) + self._feed.set_shape(shape) + def get_camera_index(self) -> int: i = self._cam_combo.currentIndex() cameras = getattr(self, "_cameras", []) |
