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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
|
"""
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 (
QComboBox, QDoubleSpinBox, QFormLayout, QHBoxLayout,
QLabel, QPushButton, 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]:
from tracker import list_cameras
w = QWidget()
lay = QFormLayout(w)
lay.setContentsMargins(0, 4, 0, 4)
cam_row = QHBoxLayout()
self._s_cam = QComboBox()
self._s_cam.setObjectName("channelPickerCb")
self._s_cameras: list = []
self._s_refresh_btn = QPushButton("⟳")
self._s_refresh_btn.setObjectName("configButton")
self._s_refresh_btn.setFixedWidth(28)
self._s_refresh_btn.setToolTip("Rescan cameras")
self._s_refresh_btn.clicked.connect(lambda: self._populate_settings_cam(list_cameras))
cam_row.addWidget(self._s_cam, 1)
cam_row.addWidget(self._s_refresh_btn)
lay.addRow("Camera:", cam_row)
self._populate_settings_cam(list_cameras)
self._s_cam.currentIndexChanged.connect(self._sync_cam_to_window)
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:
if not self._win.isVisible():
from PyQt6.QtWidgets import QApplication
mw = self._ctx.main_window
screen = QApplication.screenAt(mw.geometry().center()) or QApplication.primaryScreen()
avail = screen.availableGeometry()
geo = mw.normalGeometry()
self._win.adjustSize()
x = max(avail.left(), min(geo.right() + 8, avail.right() - self._win.width()))
y = max(avail.top(), min(geo.top() + 40, avail.bottom() - self._win.height()))
self._win.move(x, y)
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 _populate_settings_cam(self, list_cameras_fn) -> None:
current = self._win.get_camera_index() if self._win else 0
self._s_cam.blockSignals(True)
self._s_cam.clear()
self._s_cameras = list_cameras_fn()
for _, label in self._s_cameras:
self._s_cam.addItem(label)
for i, (idx, _) in enumerate(self._s_cameras):
if idx == current:
self._s_cam.setCurrentIndex(i)
break
self._s_cam.blockSignals(False)
def _sync_cam_to_window(self, combo_i: int) -> None:
if not self._s_cameras:
return
idx = self._s_cameras[combo_i][0]
if self._win:
self._win.set_camera_index(idx)
def _sync_pxmm_to_window(self, v: float) -> None:
if self._win:
self._win.set_px_per_mm(v)
|