summaryrefslogtreecommitdiff
path: root/plugins/motion_capture/plugin.py
blob: 1c655c5946d9ffbbf9ce463ae30945171f19b7ce (plain)
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)