""" motion_capture/window.py 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] """ from __future__ import annotations from PyQt6.QtCore import Qt, QTimer, pyqtSignal from PyQt6.QtGui import QCloseEvent, QImage, QMouseEvent, QPixmap from PyQt6.QtWidgets import ( QComboBox, QDoubleSpinBox, QFrame, QHBoxLayout, QLabel, QPushButton, QSizePolicy, QVBoxLayout, QWidget, ) import numpy as np class MotionCaptureWindow(QWidget): closed = pyqtSignal() def __init__(self, tracker, parent=None): 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.setWindowTitle("Motion Capture") self.setMinimumSize(680, 560) self.resize(720, 580) self._build() self._refresh = QTimer(self) self._refresh.timeout.connect(self._update) self._refresh.start(33) # ~30 fps display # ── UI ──────────────────────────────────────────────────────────────── def _build(self): root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) # Title bar hdr = QWidget(); hdr.setObjectName("devWindowTitleBar") hdr.setFixedHeight(44) hl = QHBoxLayout(hdr); hl.setContentsMargins(14, 0, 14, 0) self._title_lbl = QLabel("MOTION CAPTURE") self._title_lbl.setObjectName("devWindowTitle") hl.addWidget(self._title_lbl) hl.addStretch() self._sim_badge = QLabel("SIMULATION") self._sim_badge.setObjectName("traceSource") self._sim_badge.setVisible(False) hl.addWidget(self._sim_badge) root.addWidget(hdr) div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) div.setObjectName("devWindowDivider") 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 root.addWidget(self._feed, 1) # Status bar self._status = QLabel("Not connected") self._status.setObjectName("traceSource") self._status.setAlignment(Qt.AlignmentFlag.AlignCenter) self._status.setFixedHeight(24) root.addWidget(self._status) # Controls bar ctrl_frame = QWidget(); ctrl_frame.setObjectName("cfgBottomBar") ctrl = QHBoxLayout(ctrl_frame) ctrl.setContentsMargins(10, 6, 10, 6); ctrl.setSpacing(6) ctrl.addWidget(QLabel("Camera:")) self._cam_combo = QComboBox() self._cam_combo.setObjectName("channelPickerCb") self._cam_combo.setMinimumWidth(200) 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._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) 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) 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) ctrl.addWidget(self._clear_btn) sep2 = QFrame(); sep2.setFrameShape(QFrame.Shape.VLine) sep2.setObjectName("devWindowDivider"); ctrl.addWidget(sep2) ctrl.addWidget(QLabel("px/mm:")) self._pxmm_spin = QDoubleSpinBox() self._pxmm_spin.setRange(0.01, 100_000) self._pxmm_spin.setValue(10.0) self._pxmm_spin.setDecimals(2) self._pxmm_spin.setSuffix(" px/mm") self._pxmm_spin.setObjectName("cfgGlobalSpin") self._pxmm_spin.setFixedWidth(130) ctrl.addWidget(self._pxmm_spin) ctrl.addStretch() root.addWidget(ctrl_frame) # ── Actions ─────────────────────────────────────────────────────────── def _scan_cameras(self): from tracker import list_cameras current_idx = self.get_camera_index() self._cam_combo.blockSignals(True) self._cam_combo.clear() self._cameras = list_cameras() # [(index, label), ...] 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) break self._cam_combo.blockSignals(False) def _toggle_connect(self): if self._connected: 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") 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 ──────────────────────────────────────────────────── def _update(self): if not self._connected: return frame = self._tracker.get_frame() if frame is not None: self._show_frame(frame) pos = self._tracker.get_position() fps = self._tracker.fps px_per_mm = self._pxmm_spin.value() if self._tracker.is_lost(): self._status.setText( f"Tracking lost — click 'Click to Track' to reselect | {fps:.0f} FPS") 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) ───────────────── def get_camera_index(self) -> int: i = self._cam_combo.currentIndex() cameras = getattr(self, "_cameras", []) if 0 <= i < len(cameras): return cameras[i][0] return 0 def set_camera_index(self, v: int): cameras = getattr(self, "_cameras", []) for i, (idx, _) in enumerate(cameras): if idx == v: self._cam_combo.setCurrentIndex(i) return def get_px_per_mm(self) -> float: return self._pxmm_spin.value() def set_px_per_mm(self, v: float): self._pxmm_spin.setValue(v) # ── Close ───────────────────────────────────────────────────────────── def closeEvent(self, e: QCloseEvent): self._refresh.stop() self.closed.emit() e.accept()