summaryrefslogtreecommitdiff
path: root/plugins/motion_capture
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/motion_capture')
-rw-r--r--plugins/motion_capture/camera_panel.py117
-rw-r--r--plugins/motion_capture/device.py9
-rw-r--r--plugins/motion_capture/tracker.py306
-rw-r--r--plugins/motion_capture/window.py444
4 files changed, 594 insertions, 282 deletions
diff --git a/plugins/motion_capture/camera_panel.py b/plugins/motion_capture/camera_panel.py
index 611c942..5e25f7f 100644
--- a/plugins/motion_capture/camera_panel.py
+++ b/plugins/motion_capture/camera_panel.py
@@ -1,17 +1,25 @@
"""
motion_capture/camera_panel.py
-CameraPanel for AddDeviceDialog — scans for OpenCV cameras and builds
-a CameraDevice when the user clicks "Add Device".
+CameraPanel for AddDeviceDialog.
+Camera discovery and simulation toggle are handled by AddDeviceDialog.
+CameraScanThread is kept here and used by the unified scanner.
"""
from __future__ import annotations
+from typing import Optional, Tuple
-from PyQt6.QtCore import Qt, QThread, pyqtSignal
-from PyQt6.QtWidgets import (
- QCheckBox, QGroupBox, QHBoxLayout, QLabel, QListWidget,
- QListWidgetItem, QPushButton, QVBoxLayout, QWidget,
-)
+from PyQt6.QtCore import QThread, pyqtSignal
+from PyQt6.QtWidgets import QComboBox, QFormLayout, QLabel, QVBoxLayout, QWidget
+
+
+_RESOLUTIONS = {
+ "Default": None,
+ "640 × 480": (640, 480),
+ "1280 × 720": (1280, 720),
+ "1920 × 1080": (1920, 1080),
+ "2560 × 1440": (2560, 1440),
+}
class CameraScanThread(QThread):
@@ -40,44 +48,24 @@ class CameraPanel(QWidget):
def __init__(self):
super().__init__()
- self._cameras: list = []
- self._selected_index: int = 0
- self._scanner = None
+ self._selected_index: int = 0
+ self._simulate: bool = False
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 4, 0, 4)
lay.setSpacing(8)
- self.sim_chk = QCheckBox("Simulation mode (Lissajous pattern, no camera needed)")
- self.sim_chk.setChecked(False)
- lay.addWidget(self.sim_chk)
-
- scan_grp = QGroupBox("Available Cameras")
- sg = QVBoxLayout(scan_grp)
- sg.setSpacing(4)
-
- top = QHBoxLayout()
- self._scan_btn = QPushButton("🔍 Scan Cameras")
- self._scan_btn.setObjectName("addTraceBtn")
- self._scan_btn.clicked.connect(self._scan)
- self._status = QLabel("Click Scan to detect cameras")
- self._status.setObjectName("traceSource")
- top.addWidget(self._scan_btn)
- top.addWidget(self._status, 1)
- sg.addLayout(top)
-
- self._list = QListWidget()
- self._list.setMaximumHeight(120)
- self._list.setObjectName("portList")
- self._list.itemClicked.connect(self._on_select)
- self._list.setToolTip("Click a camera to select it")
- sg.addWidget(self._list)
-
- hint = QLabel("↑ Click a camera above to select it")
- hint.setObjectName("traceSource")
- sg.addWidget(hint)
-
- lay.addWidget(scan_grp)
+ self._selected_lbl = QLabel("No camera selected — scan above and click a camera")
+ self._selected_lbl.setObjectName("traceSource")
+ self._selected_lbl.setWordWrap(True)
+ lay.addWidget(self._selected_lbl)
+
+ form = QFormLayout()
+ form.setContentsMargins(0, 0, 0, 0)
+ self.res_cb = QComboBox()
+ self.res_cb.addItems(list(_RESOLUTIONS.keys()))
+ form.addRow("Resolution:", self.res_cb)
+ lay.addLayout(form)
note = QLabel(
"Requires OpenCV: pip install opencv-python\n"
@@ -88,45 +76,24 @@ class CameraPanel(QWidget):
lay.addWidget(note)
lay.addStretch()
- # ── Scan ─────────────────────────────────────────────────────────────
-
- def _scan(self):
- self._scan_btn.setEnabled(False)
- self._status.setText("Scanning…")
- self._list.clear()
- self._scanner = CameraScanThread()
- self._scanner.cameras_found.connect(self._on_found)
- self._scanner.start()
-
- def _on_found(self, cameras):
- self._scan_btn.setEnabled(True)
- self._cameras = cameras
- self._list.clear()
- if not cameras:
- self._status.setText("No cameras found")
- item = QListWidgetItem(" No cameras detected")
- item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
- self._list.addItem(item)
- else:
- self._status.setText(f"{len(cameras)} camera(s) found")
- for idx, label in cameras:
- item = QListWidgetItem(f" {label}")
- item.setData(Qt.ItemDataRole.UserRole, idx)
- self._list.addItem(item)
- self._list.setCurrentRow(0)
- self._selected_index = cameras[0][0]
-
- def _on_select(self, item: QListWidgetItem):
- idx = item.data(Qt.ItemDataRole.UserRole)
- if idx is not None:
- self._selected_index = idx
-
- # ── Build ─────────────────────────────────────────────────────────────
+ def is_valid(self) -> bool:
+ return True
+
+ def set_simulate(self, simulate: bool):
+ self._simulate = simulate
+
+ def set_camera_index(self, index: int):
+ self._selected_index = index
+ self._selected_lbl.setText(f"Selected: Camera {index}")
def build_device(self, device_id: str):
from device import CameraDevice
+ resolution: Optional[Tuple[int, int]] = _RESOLUTIONS.get(
+ self.res_cb.currentText()
+ )
return CameraDevice(
device_id=device_id,
camera_index=self._selected_index,
- simulate=self.sim_chk.isChecked(),
+ simulate=self._simulate,
+ resolution=resolution,
)
diff --git a/plugins/motion_capture/device.py b/plugins/motion_capture/device.py
index 9806030..8eb1591 100644
--- a/plugins/motion_capture/device.py
+++ b/plugins/motion_capture/device.py
@@ -26,7 +26,8 @@ class CameraDevice(BaseDevice):
def __init__(self, device_id: str = "cam_0",
camera_index: int = 0,
- simulate: bool = False):
+ simulate: bool = False,
+ resolution=None):
name = "Camera (Sim)" if simulate else f"Camera {camera_index}"
super().__init__(DeviceInfo(
device_id = device_id,
@@ -55,6 +56,7 @@ class CameraDevice(BaseDevice):
))
self._camera_index = camera_index
self._simulate = simulate
+ self._resolution = resolution
self._tracker: Optional[Any] = None
self._tracking_win = None
@@ -63,7 +65,10 @@ class CameraDevice(BaseDevice):
def connect(self) -> bool:
from tracker import CameraTracker
self._tracker = CameraTracker()
- self._tracker.start(-1 if self._simulate else self._camera_index)
+ self._tracker.start(
+ -1 if self._simulate else self._camera_index,
+ resolution=self._resolution,
+ )
if self._tracker.simulated:
self.status = DeviceStatus.SIMULATED
else:
diff --git a/plugins/motion_capture/tracker.py b/plugins/motion_capture/tracker.py
index d65aca1..ffc9f25 100644
--- a/plugins/motion_capture/tracker.py
+++ b/plugins/motion_capture/tracker.py
@@ -1,15 +1,15 @@
"""
motion_capture/tracker.py
-Camera capture + OpenCV CSRT point tracking.
+Camera capture + two tracking modes:
+ "template" — snapshot + shape-masked template matching (default, good for markers)
+ "csrt" — OpenCV CSRT object tracker (texture-based, good for complex targets)
-Falls back to simulation mode (Lissajous figure) when OpenCV is not
-installed or the requested camera index cannot be opened.
+Falls back to simulation (Lissajous) when OpenCV is absent or camera fails.
Thread model:
- _thread runs continuously, updating _frame (RGB numpy array) and
- _position (x, y floats). All public methods are thread-safe.
- get_frame() / get_position() are safe to call from the Qt main thread.
+ _thread runs continuously, updating _frame and _position.
+ All public methods are thread-safe via _lock.
"""
from __future__ import annotations
@@ -24,30 +24,25 @@ import numpy as np
# ── Simulation constants ──────────────────────────────────────────────────────
_SIM_W, _SIM_H = 640, 480
-_BG_COLOR = (17, 22, 40) # match app dark theme
+_BG_COLOR = (17, 22, 40)
_GRID_COLOR = (42, 53, 88)
-_DOT_COLOR = (0, 212, 255) # cyan accent
+_DOT_COLOR = (0, 212, 255)
def _make_sim_frame(cx: int, cy: int) -> np.ndarray:
frame = np.full((_SIM_H, _SIM_W, 3), _BG_COLOR, dtype=np.uint8)
- # Grid
frame[::60, :] = _GRID_COLOR
frame[:, ::80] = _GRID_COLOR
- # Crosshair
cx = max(20, min(_SIM_W - 21, cx))
cy = max(20, min(_SIM_H - 21, cy))
frame[cy - 12:cy + 13, cx - 1:cx + 2] = _DOT_COLOR
frame[cy - 1:cy + 2, cx - 12:cx + 13] = _DOT_COLOR
- # Dot
frame[cy - 5:cy + 6, cx - 5:cx + 6] = _DOT_COLOR
- # Label
frame[10:18, 10:260] = (30, 38, 65)
return frame
def _draw_tracking(frame: np.ndarray, cx: int, cy: int) -> np.ndarray:
- """Draw crosshair + circle on an RGB frame (modifies in-place)."""
h, w = frame.shape[:2]
cx = max(20, min(w - 21, cx))
cy = max(20, min(h - 21, cy))
@@ -61,41 +56,79 @@ def _draw_tracking(frame: np.ndarray, cx: int, cy: int) -> np.ndarray:
class CameraTracker:
"""
- Manages camera capture and CSRT tracking in a background thread.
+ Manages camera capture and tracking in a background thread.
Public API (all thread-safe):
- start(camera_index) → bool open camera / start sim
- stop() close camera / stop thread
- set_roi(cx, cy, size) begin tracking at pixel (cx, cy)
- clear_roi() stop tracking (keep camera running)
- get_frame() → ndarray | None latest RGB frame with overlay
- get_position() → (x, y) | None tracked point, or None
- is_tracking() → bool
- is_lost() → bool
- fps → float
- simulated → bool
+ start(camera_index, resolution) open camera / start sim
+ stop() close camera / stop thread
+ set_track_mode(mode) "template" or "csrt"
+ set_template_shape(shape) "rect" or "circle"
+ set_roi(cx, cy, size) point-click target init (legacy)
+ set_roi_rect(x, y, w, h, frame) exact-rect target init
+ clear_roi() stop tracking
+ get_frame() → ndarray | None
+ get_position() → (x, y) | None
+ is_tracking() → bool
+ is_lost() → bool
+ fps → float
+ simulated → bool
+ track_mode → str
+ template_shape → str
"""
def __init__(self):
- self._lock = threading.Lock()
- self._running = False
+ self._lock = threading.Lock()
+ self._running = False
self._thread: Optional[threading.Thread] = None
- self._frame: Optional[np.ndarray] = None
- self._position: Tuple[float, float] = (0.0, 0.0)
+ self._frame: Optional[np.ndarray] = None
+ self._position: Tuple[float, float] = (0.0, 0.0)
self._tracking = False
self._lost = False
self._fps = 0.0
self._simulated = True
- # OpenCV objects — only set when cv2 available
- self._cap = None
- self._ot = None # OpenCV tracker
- self._roi: Optional[Tuple] = None # pending ROI to init
+ self._track_mode = "template"
+ self._template_shape = "rect"
+
+ self._cap = None
+ self._ot = None
+
+ self._template_bgr: Optional[np.ndarray] = None
+ self._template_mask: Optional[np.ndarray] = None
+
+ # ── Configuration ─────────────────────────────────────────────────────
+
+ def set_track_mode(self, mode: str):
+ with self._lock:
+ self._track_mode = mode
+ self._ot = None
+ self._template_bgr = None
+ self._template_mask = None
+ self._tracking = False
+ self._lost = False
+
+ def set_template_shape(self, shape: str):
+ with self._lock:
+ self._template_shape = shape
+ self._template_bgr = None
+ self._template_mask = None
+ self._tracking = False
+
+ @property
+ def track_mode(self) -> str:
+ with self._lock:
+ return self._track_mode
+
+ @property
+ def template_shape(self) -> str:
+ with self._lock:
+ return self._template_shape
# ── Start / stop ──────────────────────────────────────────────────────
- def start(self, camera_index: int = 0) -> bool:
+ def start(self, camera_index: int = 0,
+ resolution: Optional[Tuple[int, int]] = None) -> bool:
self.stop()
self._running = True
@@ -109,26 +142,30 @@ class CameraTracker:
try:
import cv2
cap = cv2.VideoCapture(camera_index)
+ if resolution:
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, resolution[0])
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, resolution[1])
if cap.isOpened():
with self._lock:
- self._cap = cap
- self._simulated = False
- self._tracking = False
- self._lost = False
- self._ot = None
- self._roi = None
+ self._cap = cap
+ self._simulated = False
+ self._tracking = False
+ self._lost = False
+ 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:
- pass # cv2 not installed — fall through to simulation
+ pass
with self._lock:
self._simulated = True
self._thread = threading.Thread(target=self._run_sim, daemon=True)
self._thread.start()
- return True # simulation always succeeds
+ return True
def stop(self):
self._running = False
@@ -139,18 +176,32 @@ class CameraTracker:
if self._cap is not None:
self._cap.release()
self._cap = None
- self._ot = None
- self._tracking = False
- self._lost = False
- self._frame = None
+ self._ot = None
+ self._template_bgr = None
+ self._template_mask = None
+ self._tracking = False
+ self._lost = False
+ self._frame = None
# ── ROI control ───────────────────────────────────────────────────────
def set_roi(self, cx: int, cy: int, size: int = 70):
- """Begin tracking at pixel coordinate (cx, cy) with box of `size`."""
+ """Point-click init — delegates to set_roi_rect with a centred square."""
+ half = size // 2
+ self.set_roi_rect(cx - half, cy - half, size, size)
+
+ def set_roi_rect(self, x: int, y: int, w: int, h: int,
+ frame_rgb: Optional[np.ndarray] = None):
+ """
+ Initialise tracking from an exact rectangle.
+ frame_rgb: frozen RGB frame to extract template from; falls back to
+ the latest live frame if None.
+ """
with self._lock:
- frame = self._frame
simulated = self._simulated
+ mode = self._track_mode
+
+ cx, cy = x + w / 2, y + h / 2
if simulated:
with self._lock:
@@ -159,37 +210,48 @@ class CameraTracker:
self._lost = False
return
- if frame is None:
+ src = frame_rgb
+ if src is None:
+ with self._lock:
+ src = self._frame
+ if src is None:
return
try:
import cv2
- bgr = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
+ bgr = cv2.cvtColor(src, cv2.COLOR_RGB2BGR)
except Exception:
return
- h, w = bgr.shape[:2]
- half = size // 2
- x = max(0, cx - half)
- y = max(0, cy - half)
- bw = min(w - x, size)
- bh = min(h - y, size)
-
- ot = _make_cv_tracker()
- if ot is None:
+ fh, fw = bgr.shape[:2]
+ x1 = max(0, x); y1 = max(0, y)
+ x2 = min(fw, x + w); y2 = min(fh, y + h)
+ rw, rh = x2 - x1, y2 - y1
+ if rw < 4 or rh < 4:
return
- ot.init(bgr, (x, y, bw, bh))
- with self._lock:
- self._ot = ot
- self._tracking = True
- self._lost = False
+ if mode == "template":
+ self._build_template(bgr, x1, y1, rw, rh)
+ with self._lock:
+ self._tracking = True
+ self._lost = False
+ else:
+ ot = _make_cv_tracker()
+ if ot is None:
+ return
+ ot.init(bgr, (x1, y1, rw, rh))
+ with self._lock:
+ self._ot = ot
+ self._tracking = True
+ self._lost = False
def clear_roi(self):
with self._lock:
- self._ot = None
- self._tracking = False
- self._lost = False
+ self._ot = None
+ self._template_bgr = None
+ self._template_mask = None
+ self._tracking = False
+ self._lost = False
# ── Data access ───────────────────────────────────────────────────────
@@ -218,6 +280,47 @@ class CameraTracker:
with self._lock:
return self._simulated
+ # ── Template helpers ──────────────────────────────────────────────────
+
+ def _build_template(self, bgr: np.ndarray, x1: int, y1: int, rw: int, rh: int):
+ """Extract ROI from bgr and build the shape-masked template (under lock)."""
+ import cv2
+ template = bgr[y1:y1 + rh, x1:x1 + rw].copy()
+ th, tw = template.shape[:2]
+
+ with self._lock:
+ shape = self._template_shape
+
+ if shape == "circle":
+ mask = np.zeros((th, tw), dtype=np.uint8)
+ cv2.circle(mask, (tw // 2, th // 2), min(tw, th) // 2, 255, -1)
+ else:
+ mask = None
+
+ with self._lock:
+ self._template_bgr = template
+ self._template_mask = mask
+
+ def _match_template(self, bgr: np.ndarray) -> Optional[Tuple[float, float]]:
+ import cv2
+ with self._lock:
+ tmpl = self._template_bgr
+ mask = self._template_mask
+
+ if tmpl is None:
+ return None
+ th, tw = tmpl.shape[:2]
+ if bgr.shape[0] < th or bgr.shape[1] < tw:
+ return None
+
+ try:
+ method = cv2.TM_CCORR_NORMED if mask is not None else cv2.TM_CCOEFF_NORMED
+ result = cv2.matchTemplate(bgr, tmpl, method, mask=mask)
+ _, _, _, max_loc = cv2.minMaxLoc(result)
+ return float(max_loc[0] + tw / 2), float(max_loc[1] + th / 2)
+ except Exception:
+ return None
+
# ── Background loops ──────────────────────────────────────────────────
def _run_real(self):
@@ -237,26 +340,36 @@ class CameraTracker:
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
with self._lock:
- ot = self._ot
-
- if ot is not None:
- ok, bbox = ot.update(bgr)
- if ok:
- cx = bbox[0] + bbox[2] / 2
- cy = bbox[1] + bbox[3] / 2
- with self._lock:
- self._position = (float(cx), float(cy))
- self._lost = False
- _draw_tracking(rgb, int(cx), int(cy))
- else:
- with self._lock:
- self._lost = True
- self._tracking = False
- self._ot = None
-
- # FPS
+ mode = self._track_mode
+ ot = self._ot
+ has_tmpl = self._template_bgr is not None
+ tracking = self._tracking
+
+ if tracking:
+ if mode == "template" and has_tmpl:
+ pos = self._match_template(bgr)
+ if pos is not None:
+ with self._lock:
+ self._position = pos
+ self._lost = False
+ _draw_tracking(rgb, int(pos[0]), int(pos[1]))
+ elif mode == "csrt" and ot is not None:
+ ok, bbox = ot.update(bgr)
+ if ok:
+ cx = bbox[0] + bbox[2] / 2
+ cy = bbox[1] + bbox[3] / 2
+ with self._lock:
+ self._position = (float(cx), float(cy))
+ self._lost = False
+ _draw_tracking(rgb, int(cx), int(cy))
+ else:
+ with self._lock:
+ self._lost = True
+ self._tracking = False
+ self._ot = None
+
now = time.monotonic()
- dt = now - t_prev
+ dt = now - t_prev
if dt > 0:
self._fps = 0.9 * self._fps + 0.1 * (1.0 / dt)
t_prev = now
@@ -265,29 +378,21 @@ class CameraTracker:
self._frame = rgb
def _run_sim(self):
- """Lissajous simulation — no camera hardware needed."""
t0 = time.monotonic()
t_prev = t0
- cx, cy = float(_SIM_W // 2), float(_SIM_H // 2)
-
while self._running:
t = time.monotonic() - t0
cx = _SIM_W / 2 + (_SIM_W / 2 - 60) * 0.9 * math.sin(t * 0.31)
cy = _SIM_H / 2 + (_SIM_H / 2 - 50) * 0.9 * math.sin(t * 0.47 + 0.5)
-
with self._lock:
tracking = self._tracking
-
frame = _make_sim_frame(int(cx), int(cy))
-
with self._lock:
if tracking:
- # Sim tracking: dot follows Lissajous
self._position = (cx, cy)
self._frame = frame
-
- now = time.monotonic()
- dt = now - t_prev
+ now = time.monotonic()
+ dt = now - t_prev
if dt > 0:
self._fps = 0.9 * self._fps + 0.1 * (1.0 / dt)
t_prev = now
@@ -297,16 +402,8 @@ class CameraTracker:
# ── 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
+ import glob
video_nodes = sorted(glob.glob("/dev/video*"))
if video_nodes:
@@ -323,7 +420,6 @@ def list_cameras():
name = path
cameras.append((idx, f"{name} [/dev/video{idx}]"))
else:
- # Non-Linux fallback: probe indices
try:
import cv2
for idx in range(8):
diff --git a/plugins/motion_capture/window.py b/plugins/motion_capture/window.py
index 40b436f..bc9a0de 100644
--- a/plugins/motion_capture/window.py
+++ b/plugins/motion_capture/window.py
@@ -5,22 +5,240 @@ Floating tool window for the Motion Capture plugin.
Layout:
[Title bar]
- [Video feed — live camera or simulation, click to select tracking point]
- [Status bar — position, fps, tracking state]
- [Controls — camera index, connect, click-to-track, clear, calibration]
+ [FrameSelector — live / frozen feed with zoom + rubber-band target selection]
+ [Status bar]
+ [Controls — camera, connect, mode, shape, track/confirm, clear, px/mm]
"""
from __future__ import annotations
-from PyQt6.QtCore import Qt, QTimer, pyqtSignal
-from PyQt6.QtGui import QCloseEvent, QImage, QMouseEvent, QPixmap
+from typing import Optional, Tuple
+
+import numpy as np
+
+from PyQt6.QtCore import Qt, QPointF, QTimer, pyqtSignal
+from PyQt6.QtGui import (
+ QCloseEvent, QColor, QFont, QImage, QMouseEvent,
+ QPainter, QPen, QPixmap,
+)
from PyQt6.QtWidgets import (
QComboBox, QDoubleSpinBox, QFrame, QHBoxLayout, QLabel, QPushButton,
QSizePolicy, QVBoxLayout, QWidget,
)
-import numpy as np
+# ── Frame display + rubber-band selection widget ──────────────────────────────
+
+class FrameSelector(QWidget):
+ """
+ Live or frozen camera frame display with zoom, pan, and rubber-band
+ target selection.
+
+ Live mode — set_frame() updates the display in real time.
+ Frozen mode — freeze() locks the frame; user scrolls to zoom (around
+ cursor), right-drags to pan, left-drags to draw a
+ rubber-band selection. get_frame_selection() returns the
+ selected rect in frame pixel coordinates.
+ """
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._frame_rgb: Optional[np.ndarray] = None
+ self._frozen_frame: Optional[np.ndarray] = None
+ self._frozen = False
+ self._zoom = 1.0
+ self._pan = QPointF(0.0, 0.0)
+ self._shape = "rect"
+
+ self._rb_start: Optional[QPointF] = None
+ self._rb_end: Optional[QPointF] = None
+
+ self._pan_anchor: Optional[QPointF] = None
+ self._pan_origin: Optional[QPointF] = None
+
+ self.setMouseTracking(True)
+ self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+ self.setMinimumSize(480, 320)
+ self.setStyleSheet("background: #0b0e13;")
+
+ # ── Public API ────────────────────────────────────────────────────────
+
+ def set_frame(self, rgb: np.ndarray):
+ if not self._frozen:
+ self._frame_rgb = rgb
+ self.update()
+
+ def set_shape(self, shape: str):
+ self._shape = shape
+ self.update()
+
+ def freeze(self):
+ """Lock display on current frame and enter selection mode."""
+ self._frozen = True
+ self._frozen_frame = (
+ self._frame_rgb.copy() if self._frame_rgb is not None else None
+ )
+ self._rb_start = None
+ self._rb_end = None
+ self.update()
+
+ def unfreeze(self):
+ """Resume live display; reset zoom/pan/selection."""
+ self._frozen = False
+ self._frozen_frame = None
+ self._zoom = 1.0
+ self._pan = QPointF(0.0, 0.0)
+ self._rb_start = None
+ self._rb_end = None
+ self.update()
+
+ def get_frozen_frame(self) -> Optional[np.ndarray]:
+ return self._frozen_frame
+
+ def get_frame_selection(self) -> Optional[Tuple[int, int, int, int]]:
+ """Return (x, y, w, h) of selection in frame pixel coords, or None."""
+ if self._rb_start is None or self._rb_end is None:
+ return None
+ frame = self._frozen_frame
+ if frame is None:
+ return None
+ fh, fw = frame.shape[:2]
+ p1 = self._screen_to_frame(self._rb_start)
+ p2 = self._screen_to_frame(self._rb_end)
+ x = int(max(0, min(p1[0], p2[0])))
+ y = int(max(0, min(p1[1], p2[1])))
+ x2 = int(min(fw, max(p1[0], p2[0])))
+ y2 = int(min(fh, max(p1[1], p2[1])))
+ w, h = x2 - x, y2 - y
+ return (x, y, w, h) if w >= 8 and h >= 8 else None
+
+ # ── Coordinate transforms ─────────────────────────────────────────────
+
+ def _get_transform(self) -> Tuple[float, float, float]:
+ frame = self._frozen_frame if self._frozen else self._frame_rgb
+ if frame is None:
+ return 1.0, 0.0, 0.0
+ fh, fw = frame.shape[:2]
+ W, H = self.width(), self.height()
+ ds = min(W / fw, H / fh)
+ ts = ds * self._zoom
+ ox = (W - fw * ts) / 2 + self._pan.x()
+ oy = (H - fh * ts) / 2 + self._pan.y()
+ return ts, ox, oy
+
+ def _screen_to_frame(self, pt: QPointF) -> Tuple[float, float]:
+ ts, ox, oy = self._get_transform()
+ return (pt.x() - ox) / ts, (pt.y() - oy) / ts
+
+ # ── Events ────────────────────────────────────────────────────────────
+
+ def wheelEvent(self, e):
+ frame = self._frozen_frame if self._frozen else self._frame_rgb
+ if frame is None:
+ e.ignore()
+ return
+ fh, fw = frame.shape[:2]
+ W, H = self.width(), self.height()
+ ds = min(W / fw, H / fh)
+
+ factor = 1.15 if e.angleDelta().y() > 0 else 1 / 1.15
+ new_zoom = max(1.0, min(12.0, self._zoom * factor))
+ cursor = QPointF(e.position())
+
+ old_ts = ds * self._zoom
+ new_ts = ds * new_zoom
+ old_ox = (W - fw * old_ts) / 2 + self._pan.x()
+ old_oy = (H - fh * old_ts) / 2 + self._pan.y()
+ fx = (cursor.x() - old_ox) / old_ts
+ fy = (cursor.y() - old_oy) / old_ts
+ self._pan = QPointF(
+ cursor.x() - fx * new_ts - (W - fw * new_ts) / 2,
+ cursor.y() - fy * new_ts - (H - fh * new_ts) / 2,
+ )
+ self._zoom = new_zoom
+ e.accept()
+ self.update()
+
+ def mousePressEvent(self, e: QMouseEvent):
+ if e.button() == Qt.MouseButton.RightButton:
+ self._pan_anchor = QPointF(e.position())
+ self._pan_origin = QPointF(self._pan)
+ elif e.button() == Qt.MouseButton.LeftButton and self._frozen:
+ self._rb_start = QPointF(e.position())
+ self._rb_end = None
+ self.update()
+
+ def mouseMoveEvent(self, e: QMouseEvent):
+ if e.buttons() & Qt.MouseButton.RightButton and self._pan_anchor is not None:
+ self._pan = self._pan_origin + (QPointF(e.position()) - self._pan_anchor)
+ self.update()
+ elif (e.buttons() & Qt.MouseButton.LeftButton
+ and self._frozen and self._rb_start is not None):
+ self._rb_end = QPointF(e.position())
+ self.update()
+
+ def mouseReleaseEvent(self, e: QMouseEvent):
+ if e.button() == Qt.MouseButton.RightButton:
+ self._pan_anchor = None
+ self._pan_origin = None
+ elif e.button() == Qt.MouseButton.LeftButton and self._frozen:
+ if self._rb_start is not None:
+ self._rb_end = QPointF(e.position())
+ self.update()
+
+ def paintEvent(self, _):
+ p = QPainter(self)
+ p.fillRect(self.rect(), QColor(11, 14, 19))
+
+ frame = self._frozen_frame if self._frozen else self._frame_rgb
+
+ if frame is None:
+ p.setPen(QColor(120, 130, 150))
+ p.setFont(QFont("sans-serif", 11))
+ p.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter,
+ "Connect a camera to begin.")
+ return
+
+ # Draw frame with zoom/pan applied
+ ts, ox, oy = self._get_transform()
+ fh, fw = frame.shape[:2]
+ arr = np.ascontiguousarray(frame)
+ qi = QImage(arr.data, fw, fh, 3 * fw, QImage.Format.Format_RGB888)
+ pix = QPixmap.fromImage(qi).scaled(
+ max(1, int(fw * ts)), max(1, int(fh * ts)),
+ Qt.AspectRatioMode.IgnoreAspectRatio,
+ Qt.TransformationMode.FastTransformation,
+ )
+ p.drawPixmap(int(ox), int(oy), pix)
+
+ # Rubber-band selection overlay
+ if self._frozen and self._rb_start is not None and self._rb_end is not None:
+ pen = QPen(QColor(0, 220, 100))
+ pen.setWidth(2)
+ pen.setStyle(Qt.PenStyle.DashLine)
+ p.setPen(pen)
+ sx = min(self._rb_start.x(), self._rb_end.x())
+ sy = min(self._rb_start.y(), self._rb_end.y())
+ rw = abs(self._rb_end.x() - self._rb_start.x())
+ rh = abs(self._rb_end.y() - self._rb_start.y())
+ if self._shape == "circle":
+ p.drawEllipse(int(sx), int(sy), int(rw), int(rh))
+ else:
+ p.drawRect(int(sx), int(sy), int(rw), int(rh))
+
+ # Instruction banner (frozen mode)
+ if self._frozen:
+ p.fillRect(0, 0, self.width(), 26, QColor(0, 0, 0, 170))
+ p.setPen(QColor(0, 212, 255))
+ p.setFont(QFont("sans-serif", 9))
+ p.drawText(
+ 0, 0, self.width(), 26,
+ Qt.AlignmentFlag.AlignCenter,
+ "Scroll = zoom · Right-drag = pan · Left-drag = select target",
+ )
+
+
+# ── Main window ───────────────────────────────────────────────────────────────
class MotionCaptureWindow(QWidget):
@@ -30,17 +248,17 @@ class MotionCaptureWindow(QWidget):
super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool)
self._tracker = tracker
self._connected = False
- self._arming = False # waiting for click-to-set-ROI
+ self._selecting = False # True while frame is frozen for selection
self.setWindowTitle("Motion Capture")
self.setMinimumSize(680, 560)
- self.resize(720, 580)
+ self.resize(760, 600)
self._build()
self._refresh = QTimer(self)
self._refresh.timeout.connect(self._update)
- self._refresh.start(33) # ~30 fps display
+ self._refresh.start(33)
# ── UI ────────────────────────────────────────────────────────────────
@@ -68,15 +286,7 @@ class MotionCaptureWindow(QWidget):
root.addWidget(div)
# Feed
- self._feed = QLabel()
- self._feed.setObjectName("motionFeed")
- self._feed.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self._feed.setSizePolicy(
- QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
- self._feed.setMinimumSize(480, 320)
- self._feed.setStyleSheet("background: #0b0e13;")
- self._feed.setText("Connect a camera to begin.")
- self._feed.mousePressEvent = self._on_feed_click
+ self._feed = FrameSelector()
root.addWidget(self._feed, 1)
# Status bar
@@ -91,44 +301,62 @@ class MotionCaptureWindow(QWidget):
ctrl = QHBoxLayout(ctrl_frame)
ctrl.setContentsMargins(10, 6, 10, 6); ctrl.setSpacing(6)
+ # Camera selector
ctrl.addWidget(QLabel("Camera:"))
self._cam_combo = QComboBox()
self._cam_combo.setObjectName("channelPickerCb")
- self._cam_combo.setMinimumWidth(200)
+ self._cam_combo.setMinimumWidth(180)
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._scan_cameras()
self._conn_btn = QPushButton("Connect")
self._conn_btn.setObjectName("applyButton")
self._conn_btn.clicked.connect(self._toggle_connect)
ctrl.addWidget(self._conn_btn)
- sep = QFrame(); sep.setFrameShape(QFrame.Shape.VLine)
- sep.setObjectName("devWindowDivider"); ctrl.addWidget(sep)
+ ctrl.addWidget(_vsep())
+
+ # Tracking mode
+ ctrl.addWidget(QLabel("Mode:"))
+ self._mode_cb = QComboBox()
+ self._mode_cb.setObjectName("channelPickerCb")
+ self._mode_cb.addItems(["Template Match", "CSRT"])
+ self._mode_cb.currentIndexChanged.connect(self._on_mode_changed)
+ ctrl.addWidget(self._mode_cb)
+
+ # Shape (template mode only)
+ self._shape_lbl = QLabel("Shape:")
+ ctrl.addWidget(self._shape_lbl)
+ self._shape_cb = QComboBox()
+ self._shape_cb.setObjectName("channelPickerCb")
+ self._shape_cb.addItems(["Rectangle", "Circle"])
+ self._shape_cb.currentIndexChanged.connect(self._on_shape_changed)
+ ctrl.addWidget(self._shape_cb)
+
+ ctrl.addWidget(_vsep())
+ # Track / confirm + clear
self._track_btn = QPushButton("🎯 Click to Track")
self._track_btn.setObjectName("toolbarSectionBtn")
self._track_btn.setEnabled(False)
- self._track_btn.clicked.connect(self._arm_tracking)
+ self._track_btn.clicked.connect(self._on_track_btn)
ctrl.addWidget(self._track_btn)
self._clear_btn = QPushButton("✕ Clear")
self._clear_btn.setObjectName("configButton")
self._clear_btn.setEnabled(False)
- self._clear_btn.clicked.connect(self._clear_tracking)
+ self._clear_btn.clicked.connect(self._on_clear)
ctrl.addWidget(self._clear_btn)
- sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.VLine)
- sep2.setObjectName("devWindowDivider"); ctrl.addWidget(sep2)
+ ctrl.addWidget(_vsep())
+ # px/mm calibration
ctrl.addWidget(QLabel("px/mm:"))
self._pxmm_spin = QDoubleSpinBox()
self._pxmm_spin.setRange(0.01, 100_000)
@@ -149,10 +377,9 @@ class MotionCaptureWindow(QWidget):
current_idx = self.get_camera_index()
self._cam_combo.blockSignals(True)
self._cam_combo.clear()
- self._cameras = list_cameras() # [(index, label), ...]
+ self._cameras = list_cameras()
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)
@@ -161,109 +388,117 @@ class MotionCaptureWindow(QWidget):
def _toggle_connect(self):
if self._connected:
+ if self._selecting:
+ self._feed.unfreeze()
+ self._selecting = False
self._tracker.stop()
self._connected = False
- self._arming = False
- self._conn_btn.setText("Connect")
- self._track_btn.setEnabled(False)
- self._track_btn.setText("🎯 Click to Track")
- self._clear_btn.setEnabled(False)
- self._feed.setPixmap(QPixmap())
- self._feed.setText("Connect a camera to begin.")
self._sim_badge.setVisible(False)
self._status.setText("Disconnected")
else:
ok = self._tracker.start(self.get_camera_index())
if ok:
self._connected = True
- self._conn_btn.setText("Disconnect")
- self._track_btn.setEnabled(True)
- self._clear_btn.setEnabled(True)
self._sim_badge.setVisible(self._tracker.simulated)
else:
self._status.setText("Could not open camera")
+ self._sync_controls()
+
+ def _on_track_btn(self):
+ if self._selecting:
+ sel = self._feed.get_frame_selection()
+ if sel is None:
+ self._status.setText(
+ "Draw a selection box first, then click Confirm")
+ return
+ x, y, w, h = sel
+ self._tracker.set_roi_rect(x, y, w, h,
+ frame_rgb=self._feed.get_frozen_frame())
+ self._feed.unfreeze()
+ self._selecting = False
+ else:
+ self._feed.freeze()
+ self._selecting = True
+ self._sync_controls()
+
+ def _on_clear(self):
+ if self._selecting:
+ self._feed.unfreeze()
+ self._selecting = False
+ else:
+ self._tracker.clear_roi()
+ self._sync_controls()
+
+ def _on_mode_changed(self, idx: int):
+ mode = "csrt" if idx == 1 else "template"
+ self._tracker.set_track_mode(mode)
+ is_template = mode == "template"
+ self._shape_lbl.setVisible(is_template)
+ self._shape_cb.setVisible(is_template)
+ if self._selecting:
+ self._feed.unfreeze()
+ self._selecting = False
+ self._sync_controls()
+
+ def _on_shape_changed(self, idx: int):
+ shape = "circle" if idx == 1 else "rect"
+ self._tracker.set_template_shape(shape)
+ self._feed.set_shape(shape)
+
+ def _sync_controls(self):
+ connected = self._connected
+ selecting = self._selecting
+ tracking = self._tracker.is_tracking() if self._tracker else False
+
+ self._conn_btn.setText("Disconnect" if connected else "Connect")
+ self._track_btn.setEnabled(connected)
+ self._clear_btn.setEnabled(connected and (selecting or tracking))
+
+ if selecting:
+ self._track_btn.setText("✓ Confirm Selection")
+ else:
+ self._track_btn.setText("🎯 Click to Track")
- def _arm_tracking(self):
- self._arming = True
- self._track_btn.setText("⊹ Click on target…")
- self._feed.setCursor(Qt.CursorShape.CrossCursor)
-
- def _clear_tracking(self):
- self._arming = False
- self._tracker.clear_roi()
- self._track_btn.setText("🎯 Click to Track")
- self._feed.unsetCursor()
-
- def _on_feed_click(self, event: QMouseEvent):
- if not self._arming or not self._connected:
- return
-
- frame = self._tracker.get_frame()
- if frame is None:
- return
-
- lw = self._feed.width()
- lh = self._feed.height()
- fh, fw = frame.shape[:2]
- scale = min(lw / fw, lh / fh)
- off_x = (lw - int(fw * scale)) // 2
- off_y = (lh - int(fh * scale)) // 2
-
- fx = int((event.position().x() - off_x) / scale)
- fy = int((event.position().y() - off_y) / scale)
-
- if 0 <= fx < fw and 0 <= fy < fh:
- self._tracker.set_roi(fx, fy)
-
- self._arming = False
- self._track_btn.setText("🎯 Click to Track")
- self._feed.unsetCursor()
-
- # ── Display update ────────────────────────────────────────────────────
+ # ── Display update ─────────────────────────────────────────────────────
def _update(self):
if not self._connected:
return
- frame = self._tracker.get_frame()
- if frame is not None:
- self._show_frame(frame)
+ if not self._selecting:
+ frame = self._tracker.get_frame()
+ if frame is not None:
+ self._feed.set_frame(frame)
- pos = self._tracker.get_position()
- fps = self._tracker.fps
+ pos = self._tracker.get_position()
+ fps = self._tracker.fps
px_per_mm = self._pxmm_spin.value()
- if self._tracker.is_lost():
+ if self._selecting:
+ sel = self._feed.get_frame_selection()
+ if sel:
+ x, y, w, h = sel
+ self._status.setText(
+ f"Selected: {w} × {h} px at ({x}, {y}) · Click Confirm to track")
+ else:
+ self._status.setText(
+ "Left-drag to select target · Scroll to zoom · Right-drag to pan")
+ elif self._tracker.is_lost():
self._status.setText(
f"Tracking lost — click 'Click to Track' to reselect | {fps:.0f} FPS")
+ self._sync_controls()
elif pos is not None:
xmm = pos[0] / px_per_mm
ymm = pos[1] / px_per_mm
self._status.setText(
f"X: {pos[0]:.1f} px ({xmm:.2f} mm) "
f"Y: {pos[1]:.1f} px ({ymm:.2f} mm) | {fps:.0f} FPS")
- elif self._arming:
- self._status.setText("Click on the object you want to track")
else:
sim = " [SIMULATION]" if self._tracker.simulated else ""
self._status.setText(
f"Live{sim} | {fps:.0f} FPS | Click 'Click to Track' to begin")
- def _show_frame(self, rgb: np.ndarray):
- h, w = rgb.shape[:2]
- bytes_per_line = 3 * w
- qi = QImage(
- rgb.data, w, h, bytes_per_line,
- QImage.Format.Format_RGB888,
- )
- pix = QPixmap.fromImage(qi).scaled(
- self._feed.width(), self._feed.height(),
- Qt.AspectRatioMode.KeepAspectRatio,
- Qt.TransformationMode.FastTransformation,
- )
- self._feed.setPixmap(pix)
-
- # ── State accessors (used by plugin for save/restore) ─────────────────
+ # ── State accessors ────────────────────────────────────────────────────
def get_camera_index(self) -> int:
i = self._cam_combo.currentIndex()
@@ -291,3 +526,12 @@ class MotionCaptureWindow(QWidget):
self._refresh.stop()
self.closed.emit()
e.accept()
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def _vsep() -> QFrame:
+ sep = QFrame()
+ sep.setFrameShape(QFrame.Shape.VLine)
+ sep.setObjectName("devWindowDivider")
+ return sep