summaryrefslogtreecommitdiff
path: root/plugins/motion_capture/plugin.py
blob: 9cf955780edc465c476eb65ca6ef512c2d34e064 (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
"""
motion_capture/plugin.py

LabDAQ Motion Capture plugin.

When enabled, adds a "Camera" device type to the Add Device dialog.
Each camera added by the user becomes a first-class device in the
acquisition pipeline — data flows through the standard
Device → Signal → Channel → Plot stream.

Custom filter registered globally:
    pixel_to_mm — convert pixel coords to mm using a px_per_mm value.
    Add it in Signals → Channels on x_pos or y_pos.

Install OpenCV for real camera support:
    pip install opencv-python
"""

from __future__ import annotations

from typing import List, Optional

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 (
            "Adds a Camera device type to the Add Device dialog. "
            "Stream x/y tracking coordinates directly into the signal pipeline."
        )

    @property
    def author(self) -> str: return "LabDAQ"

    # ── Lifecycle ─────────────────────────────────────────────────────────

    def on_load(self, context: PluginContext) -> None:
        self._ctx          = context
        self._btn          = None     # toolbar button reference
        self._windows: dict      = {}  # device_id → MotionCaptureWindow
        self._window_state: dict = {}  # device_id → {px_per_mm, camera_idx}
        from ui.add_device_dialog import _PANELS
        from camera_panel import CameraPanel
        _PANELS["Camera"] = (CameraPanel, "cam")
        from core.profile import ProfileManager
        from device import CameraDevice
        ProfileManager.register_device_factory(CameraDevice.DEVICE_TYPE, CameraDevice)

    def on_unload(self) -> None:
        for win in list(self._windows.values()):
            win.close()
        self._windows.clear()
        from ui.add_device_dialog import _PANELS
        _PANELS.pop("Camera", None)
        from core.profile import ProfileManager
        from device import CameraDevice
        ProfileManager.unregister_device_factory(CameraDevice.DEVICE_TYPE)

    # ── Integration hooks ─────────────────────────────────────────────────

    def get_toolbar_actions(self) -> list:
        return [PluginAction(
            label="Motion Capture",
            icon="🎥",
            tooltip="Open camera tracking view",
            checkable=True,
            callback=self._on_btn_toggled,
            button_ref_callback=lambda btn: setattr(self, "_btn", btn),
        )]

    def get_filter_classes(self) -> dict:
        from filter import PixelToMMFilter
        return {"pixel_to_mm": PixelToMMFilter}

    # ── Internal ──────────────────────────────────────────────────────────

    def _camera_devices(self) -> list:
        from device import CameraDevice
        return [d for d in self._ctx.registry.all_instances()
                if isinstance(d, CameraDevice)]

    def _on_btn_toggled(self, checked: bool) -> None:
        from PyQt6.QtWidgets import QInputDialog, QMessageBox
        if not checked:
            return

        cameras = self._camera_devices()

        if not cameras:
            QMessageBox.information(
                None, "No Camera Devices",
                "Add a Camera device first via Devices → Add Device.",
            )
            if self._btn is not None:
                self._btn.setChecked(False)
            return

        if len(cameras) == 1:
            self._open_window(cameras[0])
        else:
            names = [f"{d.info.name}  ({d.info.device_id})" for d in cameras]
            choice, ok = QInputDialog.getItem(
                None, "Select Camera", "Open tracking view for:", names, 0, False,
            )
            if not ok:
                if self._btn is not None:
                    self._btn.setChecked(False)
                return
            idx = names.index(choice)
            self._open_window(cameras[idx])

    def _open_window(self, device) -> None:
        from window import MotionCaptureWindow
        dev_id = device.info.device_id
        if dev_id in self._windows:
            win = self._windows[dev_id]
            win.show(); win.raise_()
            return
        if device._tracker is None:
            from PyQt6.QtWidgets import QMessageBox
            QMessageBox.warning(
                None, "Camera Not Connected",
                f"{device.info.name} is not connected. Connect it first.",
            )
            if self._btn is not None:
                self._btn.setChecked(False)
            return
        win = MotionCaptureWindow(device._tracker)

        # Restore mode/shape from live tracker state
        win.restore_ui_state(
            mode=device._tracker.track_mode,
            shape=device._tracker.template_shape,
        )
        # Restore px/mm and camera index from last session
        if dev_id in self._window_state:
            state = self._window_state[dev_id]
            win.set_px_per_mm(state["px_per_mm"])
            win.set_camera_index(state["camera_idx"])

        win.closed.connect(lambda d=dev_id, w=win: self._on_window_closed(d, w))
        self._windows[dev_id] = win
        win.show()

    def _on_window_closed(self, dev_id: str, win) -> None:
        self._window_state[dev_id] = {
            "px_per_mm":  win.get_px_per_mm(),
            "camera_idx": win.get_camera_index(),
        }
        self._windows.pop(dev_id, None)
        if not self._windows and self._btn is not None:
            self._btn.setChecked(False)