diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-06-09 18:49:05 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-06-09 18:49:05 -0600 |
| commit | 51f04ada503b02c598c9940c96fdc444cf5da52a (patch) | |
| tree | 7fd4db831b96323dc5451e11b63ec329a320df97 | |
| parent | c16b8546c72673534965da5590a62ba3ee7635d3 (diff) | |
| parent | 898fde4dc286517cb27590b2f9cd7406d9922bd4 (diff) | |
Merge fix/motion-capture: template matching + frame-freeze selection UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | plugins/motion_capture/camera_panel.py | 31 | ||||
| -rw-r--r-- | plugins/motion_capture/device.py | 9 | ||||
| -rw-r--r-- | plugins/motion_capture/tracker.py | 306 | ||||
| -rw-r--r-- | plugins/motion_capture/window.py | 444 |
4 files changed, 578 insertions, 212 deletions
diff --git a/plugins/motion_capture/camera_panel.py b/plugins/motion_capture/camera_panel.py index 3fdd286..5e25f7f 100644 --- a/plugins/motion_capture/camera_panel.py +++ b/plugins/motion_capture/camera_panel.py @@ -1,15 +1,25 @@ """ motion_capture/camera_panel.py -CameraPanel for AddDeviceDialog — shows selected camera info 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 QThread, pyqtSignal -from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget +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): @@ -38,8 +48,8 @@ class CameraPanel(QWidget): def __init__(self): super().__init__() - self._selected_index: int = 0 - self._simulate: bool = False + self._selected_index: int = 0 + self._simulate: bool = False lay = QVBoxLayout(self) lay.setContentsMargins(0, 4, 0, 4) @@ -50,6 +60,13 @@ class CameraPanel(QWidget): 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" "Use Simulation mode if no camera is available." @@ -71,8 +88,12 @@ class CameraPanel(QWidget): 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._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 |
