summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Kolset <ckolset@colostate.edu>2026-08-17 10:02:16 -0600
committerChristian Kolset <ckolset@colostate.edu>2026-08-17 10:02:16 -0600
commitabab8f7938dada9a2abff31e719186b99934601a (patch)
tree6f013cb54256205b3eadf467001e86a882a7f57b
parente749cca30da2d02839aca736873e7e5ad45e191c (diff)
Fix motion capture UI freezes: throttling, async camera open/scan, stale poll
Several compounding freeze sources in plugins/motion_capture/: - tracker.py: _run_real had no frame-rate cap independent of the camera's configured fps (up to 120fps/2560x1440 was possible), and no bound on consecutive failed reads — a camera that "opens" (cv2 isOpened() can lie, seen with Windows MSMF/obsensor) but never actually delivers frames would get hammered forever. Added a 20Hz processing cap, a real test-read before committing to a camera, and a give-up-after-30-failures fallback to simulation. - tracker.py/device.py: cv2.VideoCapture() opening a real camera is a slow, blocking call — CameraTracker.start() used to do this synchronously on the caller's thread, which froze the whole app when ProfileManager.apply() called it during profile load. Now runs on the background thread; status reporting (CameraDevice) moved to an on_result callback since the caller can no longer learn the outcome synchronously. - window.py: the Camera View preview ran a 30Hz GUI-thread timer doing a full-frame copy + QImage/QPixmap rebuild every tick unconditionally (no visibility check) — dropped to 10Hz and skips entirely when hidden. Camera enumeration (list_cameras(), up to 8 sequential cv2.VideoCapture probes) also ran inline in the window constructor, blocking it open the same way camera_panel.py's CameraScanThread already avoided for the Add Device dialog — moved to the same async pattern here. config_window.py: separately, ConfigWindow._dev_poll (device status label refresh) was stopped in closeEvent and never restarted, since MainWindow reuses the same ConfigWindow instance across opens — invisible before because connect() was synchronous, but with the above change a device can sit in CONNECTING for a while, and a stale poll made that look stuck forever after reopening the window. Restart it in showEvent. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
-rw-r--r--plugins/motion_capture/device.py12
-rw-r--r--plugins/motion_capture/tracker.py104
-rw-r--r--plugins/motion_capture/window.py47
-rw-r--r--ui/windows/config_window.py13
4 files changed, 152 insertions, 24 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:
diff --git a/ui/windows/config_window.py b/ui/windows/config_window.py
index 34cc789..604b6ed 100644
--- a/ui/windows/config_window.py
+++ b/ui/windows/config_window.py
@@ -24,7 +24,7 @@ from PyQt6.QtWidgets import (
QDoubleSpinBox,
)
from PyQt6.QtCore import Qt, pyqtSignal, QTimer
-from PyQt6.QtGui import QCloseEvent, QColor
+from PyQt6.QtGui import QCloseEvent, QColor, QShowEvent
from devices.device_registry import DeviceRegistry
from core.acquisition import AcquisitionEngine
@@ -535,7 +535,16 @@ class ConfigWindow(QWidget):
if idx == 2:
self._plot_tab.refresh_channels()
- # ── Close ────────────────────────────────────────────────────────────────
+ # ── Show / Close ─────────────────────────────────────────────────────────
+
+ def showEvent(self, e: QShowEvent):
+ # closeEvent stops this poll; MainWindow reuses this same ConfigWindow
+ # instance across opens (doesn't recreate it), so without restarting
+ # here, reopening after a prior close leaves device status frozen at
+ # whatever it was at close time — e.g. stuck showing "Connecting…"
+ # for an async-connecting device like the motion capture camera.
+ self._dev_poll.start()
+ super().showEvent(e)
def closeEvent(self, e: QCloseEvent):
self._dev_poll.stop()