From 8e5d0aec942fb9c49040fc525f0d697fd24d7c2f Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Thu, 7 May 2026 13:42:30 -0600 Subject: Added Motion Capture plugin Tracks a user-selected point via webcam (OpenCV CSRT tracker) and streams x_pos / y_pos pixel coordinates as live channels into the acquisition pipeline. Falls back to a Lissajous figure simulation when opencv-python is not installed, so the plugin works immediately without hardware. - tracker.py: background thread, real camera + sim modes, thread-safe API - device.py: BaseDevice reading x/y from tracker, wires into engine - filter.py: custom pixel_to_mm filter (registered in FILTER_CLASSES) - window.py: floating tool window, live feed, click-to-track ROI selection - plugin.py: LabPlugin wiring all pieces together, toolbar button, settings widget, profile save/restore Also: PluginAction.button_ref_callback lets plugins store a ref to their toolbar button so they can uncheck it when their window closes. opencv-python install note added to requirements.txt. Co-Authored-By: Claude Sonnet 4.6 --- plugins/motion_capture/window.py | 262 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 plugins/motion_capture/window.py (limited to 'plugins/motion_capture/window.py') diff --git a/plugins/motion_capture/window.py b/plugins/motion_capture/window.py new file mode 100644 index 0000000..082a8f6 --- /dev/null +++ b/plugins/motion_capture/window.py @@ -0,0 +1,262 @@ +""" +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 ( + QDoubleSpinBox, QFrame, QHBoxLayout, QLabel, QPushButton, + QSizePolicy, QSpinBox, 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_spin = QSpinBox() + self._cam_spin.setRange(0, 9); self._cam_spin.setValue(0) + self._cam_spin.setObjectName("traceWidthSpin") + self._cam_spin.setFixedWidth(52) + ctrl.addWidget(self._cam_spin) + + 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 _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._cam_spin.value()) + 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: + return self._cam_spin.value() + + def set_camera_index(self, v: int): + self._cam_spin.setValue(v) + + 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() -- cgit v1.2.3