1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
|
"""
motion_capture/plugin.py
LabDAQ Motion Capture plugin.
Tracks a user-selected point via webcam (OpenCV CSRT tracker) and
streams x/y pixel coordinates as live channels into the acquisition
pipeline. Falls back to a Lissajous simulation when OpenCV is absent.
Custom filter:
pixel_to_mm — convert pixel coords to millimetres using a
px_per_mm calibration value. Add it in Signals → Channels on
the x_pos or y_pos channel.
Install OpenCV to use a real camera:
Arch Linux: sudo pacman -S python-opencv
Other: pip install opencv-python
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from PyQt6.QtWidgets import (
QDoubleSpinBox, QFormLayout, QLabel, QSpinBox, QWidget,
)
from plugins.base_plugin import LabPlugin, PluginAction, PluginContext
class MotionCapturePlugin(LabPlugin):
# ── Metadata ──────────────────────────────────────────────────────────
@property
def plugin_id(self) -> str: return "motion_capture"
@property
def name(self) -> str: return "Motion Capture"
@property
def version(self) -> str: return "1.0.0"
@property
def description(self) -> str:
return ("Track a point via webcam and stream x/y position as live signals. "
"Apply pixel_to_mm filter to convert to real-world units.")
@property
def author(self) -> str: return "LabDAQ"
# ── Lifecycle ─────────────────────────────────────────────────────────
def on_load(self, context: PluginContext) -> None:
self._ctx = context
self._win: Optional[object] = None
self._toolbar_btn = None
from tracker import CameraTracker
from device import MotionCaptureDevice
self._tracker = CameraTracker()
self._device = MotionCaptureDevice(self._tracker)
def on_unload(self) -> None:
self._tracker.stop()
if self._win is not None:
self._win.close()
self._win = None
# ── Integration hooks ─────────────────────────────────────────────────
def get_devices(self) -> list:
return [self._device]
def get_filter_classes(self) -> dict:
from filter import PixelToMMFilter
return {"pixel_to_mm": PixelToMMFilter}
def get_toolbar_actions(self) -> List[PluginAction]:
return [
PluginAction(
label = "Motion Capture",
icon = "🎥",
tooltip = "Open motion capture window",
checkable = True,
callback = self._toggle_window,
button_ref_callback = self._store_btn,
)
]
def get_settings_widget(self) -> Optional[QWidget]:
w = QWidget()
lay = QFormLayout(w)
lay.setContentsMargins(0, 4, 0, 4)
self._s_cam = QSpinBox()
self._s_cam.setRange(0, 9)
self._s_cam.setValue(
self._win.get_camera_index() if self._win else 0)
self._s_cam.setObjectName("traceWidthSpin")
self._s_cam.valueChanged.connect(self._sync_cam_to_window)
lay.addRow("Camera index:", self._s_cam)
self._s_pxmm = QDoubleSpinBox()
self._s_pxmm.setRange(0.01, 100_000)
self._s_pxmm.setDecimals(2)
self._s_pxmm.setSuffix(" px/mm")
self._s_pxmm.setValue(
self._win.get_px_per_mm() if self._win else 10.0)
self._s_pxmm.setObjectName("cfgGlobalSpin")
self._s_pxmm.valueChanged.connect(self._sync_pxmm_to_window)
lay.addRow("Calibration:", self._s_pxmm)
hint = QLabel(
"Calibration: count how many pixels span a known real distance\n"
"in the camera frame, then divide pixels ÷ mm."
)
hint.setObjectName("traceSource")
hint.setWordWrap(True)
lay.addRow(hint)
return w
# ── Profile state ─────────────────────────────────────────────────────
def get_save_state(self) -> Dict[str, Any]:
return {
"camera_index": self._win.get_camera_index() if self._win else 0,
"px_per_mm": self._win.get_px_per_mm() if self._win else 10.0,
}
def apply_save_state(self, state: Dict[str, Any]) -> None:
if self._win is not None:
self._win.set_camera_index(state.get("camera_index", 0))
self._win.set_px_per_mm(state.get("px_per_mm", 10.0))
# Store for when window is opened later
self._pending_state = state
# ── Internal ──────────────────────────────────────────────────────────
def _store_btn(self, btn) -> None:
self._toolbar_btn = btn
def _toggle_window(self, checked: bool) -> None:
if self._win is None:
from window import MotionCaptureWindow
self._win = MotionCaptureWindow(
self._tracker, self._ctx.main_window)
self._win.closed.connect(self._on_win_closed)
# Apply any state that arrived before window was created
state = getattr(self, "_pending_state", None)
if state:
self._win.set_camera_index(state.get("camera_index", 0))
self._win.set_px_per_mm(state.get("px_per_mm", 10.0))
if checked:
geo = self._ctx.main_window.geometry()
if not self._win.isVisible():
self._win.move(geo.right() + 8, geo.top() + 40)
self._win.show()
self._win.raise_()
else:
self._win.hide()
def _on_win_closed(self) -> None:
if self._toolbar_btn is not None:
self._toolbar_btn.setChecked(False)
def _sync_cam_to_window(self, v: int) -> None:
if self._win:
self._win.set_camera_index(v)
def _sync_pxmm_to_window(self, v: float) -> None:
if self._win:
self._win.set_px_per_mm(v)
|