diff options
Diffstat (limited to 'plugins')
| -rw-r--r-- | plugins/motion_capture/device.py | 12 | ||||
| -rw-r--r-- | plugins/motion_capture/tracker.py | 104 | ||||
| -rw-r--r-- | plugins/motion_capture/window.py | 47 |
3 files changed, 141 insertions, 22 deletions
diff --git a/plugins/motion_capture/device.py b/plugins/motion_capture/device.py index 3369962..fc1c588 100644 --- a/plugins/motion_capture/device.py +++ b/plugins/motion_capture/device.py @@ -69,17 +69,21 @@ class CameraDevice(BaseDevice): def connect(self) -> bool: from tracker import CameraTracker self._tracker = CameraTracker() + self.status = DeviceStatus.CONNECTING + # Camera opens on the tracker's background thread (cv2.VideoCapture + # can block for seconds) — status flips to CONNECTED/SIMULATED once + # the outcome is known, via this callback, instead of blocking here. self._tracker.start( -1 if self._simulate else self._camera_index, resolution=self._resolution, fps=self._fps, + on_result=self._on_tracker_result, ) - if self._tracker.simulated: - self.status = DeviceStatus.SIMULATED - else: - self.status = DeviceStatus.CONNECTED return True + def _on_tracker_result(self, simulated: bool) -> None: + self.status = DeviceStatus.SIMULATED if simulated else DeviceStatus.CONNECTED + def disconnect(self) -> None: if self._tracking_win is not None: self._tracking_win.close() diff --git a/plugins/motion_capture/tracker.py b/plugins/motion_capture/tracker.py index 8dc89f1..2e189ad 100644 --- a/plugins/motion_capture/tracker.py +++ b/plugins/motion_capture/tracker.py @@ -17,13 +17,27 @@ from __future__ import annotations import math import threading import time -from typing import Optional, Tuple +from typing import Callable, Optional, Tuple import numpy as np # ── Simulation constants ────────────────────────────────────────────────────── _SIM_W, _SIM_H = 640, 480 + +# Caps how often _run_real processes a captured frame, independent of the +# camera's configured capture fps (camera_panel.py allows up to 120 fps at +# 2560x1440 with no validation) — otherwise cvtColor + matchTemplate/CSRT +# update on every frame at high settings saturates a CPU core and starves +# the GUI thread's ability to service the Qt event loop (sluggish/unresponsive +# controls app-wide, not just in the camera window). +_MAX_TRACK_HZ = 20 + +# Consecutive failed cap.read() calls before giving up on a real camera and +# falling back to simulation — ~1.5s at the 50ms failed-read retry spacing +# below. Some backends (Windows MSMF/obsensor observed) report a camera as +# opened even when the stream never actually delivers frames. +_MAX_READ_FAILURES = 30 _BG_COLOR = (17, 22, 40) _GRID_COLOR = (42, 53, 88) _DOT_COLOR = (0, 212, 255) @@ -129,7 +143,17 @@ class CameraTracker: def start(self, camera_index: int = 0, resolution: Optional[Tuple[int, int]] = None, - fps: Optional[int] = None) -> bool: + fps: Optional[int] = None, + on_result: Optional[Callable[[bool], None]] = None) -> bool: + """Start capture. Returns True immediately — always non-blocking. + + cv2.VideoCapture(camera_index) can block for seconds (driver/backend + enumeration, especially on Windows), so opening the camera happens + on the background thread, not here — calling this synchronously + during e.g. profile load used to freeze the whole app. `on_result`, + if given, is called from that thread once the outcome is known: + on_result(True) for simulated/fallback, on_result(False) for a real + camera that opened successfully.""" self.stop() self._running = True @@ -138,8 +162,24 @@ class CameraTracker: self._simulated = True self._thread = threading.Thread(target=self._run_sim, daemon=True) self._thread.start() + if on_result: + on_result(True) return True + self._thread = threading.Thread( + target=self._open_and_run, + args=(camera_index, resolution, fps, on_result), + daemon=True, + ) + self._thread.start() + return True + + def _open_and_run(self, camera_index: int, + resolution: Optional[Tuple[int, int]], + fps: Optional[int], + on_result: Optional[Callable[[bool], None]]): + """Runs on the background thread: opens the camera (the slow part), + then falls into the real or simulated read loop in place.""" try: import cv2 cap = cv2.VideoCapture(camera_index) @@ -148,7 +188,23 @@ class CameraTracker: cap.set(cv2.CAP_PROP_FRAME_HEIGHT, resolution[1]) if fps: cap.set(cv2.CAP_PROP_FPS, fps) + + # isOpened() alone isn't enough — some backends (seen with + # Windows MSMF/obsensor) report a device as open even when it + # can't actually deliver frames, e.g. a stale camera_index from + # a profile saved on a different machine. Confirm with a real + # read before committing, or _run_real would otherwise loop + # forever hammering a broken handle. + opened = False if cap.isOpened(): + for _ in range(5): + ret, _ = cap.read() + if ret: + opened = True + break + time.sleep(0.05) + + if opened: with self._lock: self._cap = cap self._simulated = False @@ -157,18 +213,19 @@ class CameraTracker: self._ot = None self._template_bgr = None self._template_mask = None - self._thread = threading.Thread( - target=self._run_real, daemon=True) - self._thread.start() - return True - except ImportError: + if on_result: + on_result(False) + self._run_real(on_result) + return + cap.release() + except Exception: pass with self._lock: self._simulated = True - self._thread = threading.Thread(target=self._run_sim, daemon=True) - self._thread.start() - return True + if on_result: + on_result(True) + self._run_sim() def stop(self): self._running = False @@ -329,10 +386,13 @@ class CameraTracker: # ── Background loops ────────────────────────────────────────────────── - def _run_real(self): + def _run_real(self, on_result: Optional[Callable[[bool], None]] = None): import cv2 t_prev = time.monotonic() + fail_count = 0 while self._running: + t_iter_start = time.monotonic() + with self._lock: cap = self._cap if cap is None: @@ -340,8 +400,25 @@ class CameraTracker: ret, bgr = cap.read() if not ret: + fail_count += 1 + # The initial test-read in _open_and_run already screens out + # cameras that never work, but a device can also die mid- + # session (unplugged, driver fault) — without this, a broken + # handle gets hammered forever, spamming OpenCV errors and + # burning CPU/GIL time the whole app needs. + if fail_count >= _MAX_READ_FAILURES: + with self._lock: + if self._cap is not None: + self._cap.release() + self._cap = None + self._simulated = True + if on_result: + on_result(True) + self._run_sim() + return time.sleep(0.05) continue + fail_count = 0 rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) @@ -383,6 +460,11 @@ class CameraTracker: with self._lock: self._frame = rgb + elapsed = time.monotonic() - t_iter_start + remaining = (1.0 / _MAX_TRACK_HZ) - elapsed + if remaining > 0: + time.sleep(remaining) + def _run_sim(self): t0 = time.monotonic() t_prev = t0 diff --git a/plugins/motion_capture/window.py b/plugins/motion_capture/window.py index 46b8850..328c61b 100644 --- a/plugins/motion_capture/window.py +++ b/plugins/motion_capture/window.py @@ -16,7 +16,7 @@ from typing import Optional, Tuple import numpy as np -from PyQt6.QtCore import Qt, QPointF, QTimer, pyqtSignal +from PyQt6.QtCore import Qt, QPointF, QTimer, QThread, pyqtSignal from PyQt6.QtGui import ( QCloseEvent, QColor, QFont, QImage, QMouseEvent, QPainter, QPen, QPixmap, @@ -27,6 +27,17 @@ from PyQt6.QtWidgets import ( ) +# ── Background camera enumeration ─────────────────────────────────────────── + +class _CameraListThread(QThread): + """Runs tracker.list_cameras() off the GUI thread — see _scan_cameras.""" + done = pyqtSignal(list) + + def run(self): + from tracker import list_cameras + self.done.emit(list_cameras()) + + # ── Frame display + rubber-band selection widget ────────────────────────────── class FrameSelector(QWidget): @@ -262,7 +273,8 @@ class MotionCaptureWindow(QWidget): self._refresh = QTimer(self) self._refresh.timeout.connect(self._update) - self._refresh.start(33) + self._refresh.start(100) # 10 Hz — a position readout doesn't need 30fps, + # and this is a full-frame copy + repaint on the GUI thread # ── UI ──────────────────────────────────────────────────────────────── @@ -377,15 +389,31 @@ class MotionCaptureWindow(QWidget): # ── Actions ─────────────────────────────────────────────────────────── def _scan_cameras(self): - from tracker import list_cameras - current_idx = self.get_camera_index() + # list_cameras() tries cv2.VideoCapture() on several indices + # sequentially — each one can be slow to fail (backend/driver + # probing), so this used to run inline here and block the whole + # app while the window opened. Run it on a background thread instead + # (same pattern camera_panel.py's CameraScanThread already uses for + # the Add Device dialog's scan). + self._pending_cam_idx = self.get_camera_index() + self._refresh_btn.setEnabled(False) self._cam_combo.blockSignals(True) self._cam_combo.clear() - self._cameras = list_cameras() + self._cam_combo.addItem("Scanning…") + self._cam_combo.blockSignals(False) + self._scan_thread = _CameraListThread(self) + self._scan_thread.done.connect(self._on_cameras_scanned) + self._scan_thread.start() + + def _on_cameras_scanned(self, cameras: list): + self._refresh_btn.setEnabled(True) + self._cam_combo.blockSignals(True) + self._cam_combo.clear() + self._cameras = cameras for _, label in self._cameras: self._cam_combo.addItem(label) for i, (idx, _) in enumerate(self._cameras): - if idx == current_idx: + if idx == self._pending_cam_idx: self._cam_combo.setCurrentIndex(i) break self._cam_combo.blockSignals(False) @@ -466,9 +494,14 @@ class MotionCaptureWindow(QWidget): # ── Display update ───────────────────────────────────────────────────── def _update(self): - if not self._connected: + if not self._connected or not self.isVisible(): return + # simulated flips async now (camera opens on the tracker's background + # thread, see CameraTracker._open_and_run) — keep the badge in sync + # instead of reading a value that was still stale at Connect-time. + self._sim_badge.setVisible(self._tracker.simulated) + if not self._selecting: frame = self._tracker.get_frame() if frame is not None: |
