summaryrefslogtreecommitdiff
path: root/plugins/motion_capture/window.py
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-05-07 13:42:30 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-06-03 09:50:17 -0600
commit8e5d0aec942fb9c49040fc525f0d697fd24d7c2f (patch)
treea9636db29be900698541441cb93b486b9a3c0acb /plugins/motion_capture/window.py
parentdeb3ad65d25d4c3167a3cbeca2ac19b5c45b0627 (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'plugins/motion_capture/window.py')
-rw-r--r--plugins/motion_capture/window.py262
1 files changed, 262 insertions, 0 deletions
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()