mctrl 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mctrl-0.1.0.dist-info/METADATA +692 -0
- mctrl-0.1.0.dist-info/RECORD +33 -0
- mctrl-0.1.0.dist-info/WHEEL +4 -0
- mctrl-0.1.0.dist-info/entry_points.txt +3 -0
- mindcontrol/__init__.py +10 -0
- mindcontrol/__main__.py +4 -0
- mindcontrol/app.py +432 -0
- mindcontrol/autotune.py +493 -0
- mindcontrol/calibrate.py +199 -0
- mindcontrol/capture.py +159 -0
- mindcontrol/config.py +243 -0
- mindcontrol/control/__init__.py +1 -0
- mindcontrol/control/bridge.py +360 -0
- mindcontrol/control/events.py +34 -0
- mindcontrol/control/keyboard.py +101 -0
- mindcontrol/control/modes.py +194 -0
- mindcontrol/control/mouse.py +256 -0
- mindcontrol/debug_view.py +188 -0
- mindcontrol/devices.py +222 -0
- mindcontrol/filters.py +95 -0
- mindcontrol/fusion.py +240 -0
- mindcontrol/geometry.py +200 -0
- mindcontrol/gestures/__init__.py +1 -0
- mindcontrol/gestures/engine.py +465 -0
- mindcontrol/logs.py +87 -0
- mindcontrol/models.py +59 -0
- mindcontrol/pipeline.py +376 -0
- mindcontrol/record.py +454 -0
- mindcontrol/replay.py +193 -0
- mindcontrol/session.py +328 -0
- mindcontrol/tracking/__init__.py +1 -0
- mindcontrol/tracking/gaze.py +272 -0
- mindcontrol/tracking/hands.py +102 -0
mindcontrol/pipeline.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""The processing loop.
|
|
2
|
+
|
|
3
|
+
Runs on a worker thread so the menu bar keeps its own main thread, and does the
|
|
4
|
+
same five things every frame: read cameras, find hands and eyes, merge cameras,
|
|
5
|
+
ask the gesture engine what that means, and post the resulting events.
|
|
6
|
+
|
|
7
|
+
Gaze and hands are combined here rather than in either tracker, because the
|
|
8
|
+
useful rule is a relationship between them: gaze may only move the cursor while
|
|
9
|
+
the hand is holding still. Eyes are good at crossing a screen and bad at holding
|
|
10
|
+
a target; hands are the reverse. So gaze throws the cursor into the right region
|
|
11
|
+
and the hand does the last inch, and gaze stops interfering the moment the hand
|
|
12
|
+
starts working.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from .capture import CameraBank, Frame
|
|
23
|
+
from .config import GAZE_MODEL_PATH, Config
|
|
24
|
+
from .control.bridge import Bridge
|
|
25
|
+
from .control.keyboard import Keyboard
|
|
26
|
+
from .control.modes import Mode, ModeManager
|
|
27
|
+
from .control.mouse import Mouse
|
|
28
|
+
from .filters import OneEuroFilter2D
|
|
29
|
+
from .fusion import FusedHand, HandFusion, Observation, fuse_gaze
|
|
30
|
+
from .gestures.engine import Action, GestureEngine, GestureEvent
|
|
31
|
+
from .logs import muffled
|
|
32
|
+
from .tracking.gaze import FixationDetector, GazeModel, GazeObservation, GazeTracker
|
|
33
|
+
from .tracking.hands import HandTracker
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class PipelineStatus:
|
|
38
|
+
"""Snapshot for the menu bar and the debug overlay."""
|
|
39
|
+
|
|
40
|
+
fps: float = 0.0
|
|
41
|
+
mode: str = "off"
|
|
42
|
+
gesture: str = "idle"
|
|
43
|
+
hands: int = 0
|
|
44
|
+
cameras: tuple[int, ...] = ()
|
|
45
|
+
merged: bool = False
|
|
46
|
+
gaze_ready: bool = False
|
|
47
|
+
gaze_point: tuple[float, float] | None = None
|
|
48
|
+
warps: int = 0
|
|
49
|
+
# Whether the native helper is driving. False means the fallback path is,
|
|
50
|
+
# which works but is neither smoothed nor snapped.
|
|
51
|
+
native: bool = False
|
|
52
|
+
problems: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Pipeline:
|
|
56
|
+
"""Owns the cameras, the models, and the frame loop."""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
cfg: Config,
|
|
61
|
+
on_status: Callable[[PipelineStatus], None] | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.cfg = cfg
|
|
64
|
+
self._on_status = on_status
|
|
65
|
+
|
|
66
|
+
self.modes = ModeManager(cfg.modes)
|
|
67
|
+
self.bridge = Bridge(cfg.native, cfg.gestures.double_click_ms)
|
|
68
|
+
self.mouse = Mouse(double_click_ms=cfg.gestures.double_click_ms, bridge=self.bridge)
|
|
69
|
+
self.keyboard = Keyboard(cfg.keys)
|
|
70
|
+
self.engine = GestureEngine(cfg.pointer, cfg.gestures, cfg.tracking)
|
|
71
|
+
self.fusion = HandFusion(cfg.tracking, cfg.gestures)
|
|
72
|
+
|
|
73
|
+
self._bank: CameraBank | None = None
|
|
74
|
+
self._hand_trackers: dict[int, HandTracker] = {}
|
|
75
|
+
self._gaze_tracker: GazeTracker | None = None
|
|
76
|
+
self.gaze_model = GazeModel.load(GAZE_MODEL_PATH)
|
|
77
|
+
self._gaze_filter = OneEuroFilter2D(cfg.gaze.filter_fc_min, cfg.gaze.filter_beta)
|
|
78
|
+
self._fixation = FixationDetector(cfg.gaze.fixation_ms, cfg.gaze.fixation_radius)
|
|
79
|
+
self._last_warp: tuple[float, float] | None = None
|
|
80
|
+
|
|
81
|
+
self._thread: threading.Thread | None = None
|
|
82
|
+
self._stop = threading.Event()
|
|
83
|
+
self._paused = threading.Event()
|
|
84
|
+
self._seen: dict[int, int] = {}
|
|
85
|
+
self.status = PipelineStatus(mode=self.modes.describe())
|
|
86
|
+
self.frame_hook: Callable[[Frame, list[FusedHand], PipelineStatus], None] | None = None
|
|
87
|
+
|
|
88
|
+
# ----------------------------------------------------------------- lifecycle
|
|
89
|
+
|
|
90
|
+
def start(self) -> None:
|
|
91
|
+
self.modes.start()
|
|
92
|
+
if self.modes.watcher_error:
|
|
93
|
+
self.status.problems.append(self.modes.watcher_error)
|
|
94
|
+
# A missing helper is a downgrade, not a failure: the fallback path in
|
|
95
|
+
# `Mouse` still drives the cursor, just without smoothing or snapping.
|
|
96
|
+
if not self.bridge.start() and self.bridge.error:
|
|
97
|
+
self.status.problems.append(self.bridge.error)
|
|
98
|
+
print(f"[bridge] {self.bridge.error}")
|
|
99
|
+
self.status.native = self.bridge.connected
|
|
100
|
+
self._open_cameras()
|
|
101
|
+
self._stop.clear()
|
|
102
|
+
self._thread = threading.Thread(target=self._run, name="pipeline", daemon=True)
|
|
103
|
+
self._thread.start()
|
|
104
|
+
|
|
105
|
+
def stop(self) -> None:
|
|
106
|
+
self._stop.set()
|
|
107
|
+
if self._thread is not None:
|
|
108
|
+
self._thread.join(timeout=2.0)
|
|
109
|
+
self._thread = None
|
|
110
|
+
self._release_control()
|
|
111
|
+
self._close_cameras()
|
|
112
|
+
self.modes.stop()
|
|
113
|
+
self.bridge.stop()
|
|
114
|
+
|
|
115
|
+
def pause(self) -> None:
|
|
116
|
+
"""Release the cameras so another process can use them, e.g. calibration."""
|
|
117
|
+
self._paused.set()
|
|
118
|
+
self._release_control()
|
|
119
|
+
self._close_cameras()
|
|
120
|
+
|
|
121
|
+
def resume(self) -> None:
|
|
122
|
+
"""Reopen cameras and pick up a calibration written while paused."""
|
|
123
|
+
self.gaze_model = GazeModel.load(GAZE_MODEL_PATH)
|
|
124
|
+
self._open_cameras()
|
|
125
|
+
self.engine.rebase()
|
|
126
|
+
self.fusion.reset()
|
|
127
|
+
self._paused.clear()
|
|
128
|
+
|
|
129
|
+
def _open_cameras(self) -> None:
|
|
130
|
+
self._bank = CameraBank(self.cfg.cameras)
|
|
131
|
+
problems = self._bank.start()
|
|
132
|
+
self.status.problems = list(problems)
|
|
133
|
+
if not len(self._bank):
|
|
134
|
+
self.status.problems.append("no cameras available")
|
|
135
|
+
return
|
|
136
|
+
mirrored = self.cfg.cameras.mirror
|
|
137
|
+
# Model construction is where MediaPipe does its logging, and it happens
|
|
138
|
+
# again on every resume after a calibration, so it is worth muffling.
|
|
139
|
+
with muffled():
|
|
140
|
+
self._hand_trackers = {
|
|
141
|
+
camera_id: HandTracker(self.cfg.tracking, self.cfg.gestures, mirrored)
|
|
142
|
+
for camera_id in self._bank.workers
|
|
143
|
+
}
|
|
144
|
+
if self.cfg.tracking.face_enabled:
|
|
145
|
+
self._gaze_tracker = GazeTracker(self.cfg.tracking)
|
|
146
|
+
self._seen.clear()
|
|
147
|
+
|
|
148
|
+
def _close_cameras(self) -> None:
|
|
149
|
+
for tracker in self._hand_trackers.values():
|
|
150
|
+
tracker.close()
|
|
151
|
+
self._hand_trackers.clear()
|
|
152
|
+
if self._gaze_tracker is not None:
|
|
153
|
+
self._gaze_tracker.close()
|
|
154
|
+
self._gaze_tracker = None
|
|
155
|
+
if self._bank is not None:
|
|
156
|
+
self._bank.stop()
|
|
157
|
+
self._bank = None
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------- loop
|
|
160
|
+
|
|
161
|
+
def _run(self) -> None:
|
|
162
|
+
previous = time.monotonic()
|
|
163
|
+
smoothed_fps = 0.0
|
|
164
|
+
while not self._stop.is_set():
|
|
165
|
+
if self._paused.is_set() or self._bank is None:
|
|
166
|
+
time.sleep(0.05)
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
frames = self._fresh_frames()
|
|
170
|
+
if not frames:
|
|
171
|
+
time.sleep(0.005)
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
now = time.monotonic()
|
|
175
|
+
dt = max(now - previous, 1e-4)
|
|
176
|
+
previous = now
|
|
177
|
+
smoothed_fps = 0.9 * smoothed_fps + 0.1 * (1.0 / dt) if smoothed_fps else 1.0 / dt
|
|
178
|
+
|
|
179
|
+
self._process(frames, now, dt)
|
|
180
|
+
self.status.fps = smoothed_fps
|
|
181
|
+
self.status.mode = self.modes.describe()
|
|
182
|
+
self.status.gesture = self.engine.status()
|
|
183
|
+
if self._on_status is not None:
|
|
184
|
+
self._on_status(self.status)
|
|
185
|
+
|
|
186
|
+
def _fresh_frames(self) -> dict[int, Frame]:
|
|
187
|
+
"""Newest unprocessed frame per camera.
|
|
188
|
+
|
|
189
|
+
A camera that has not produced a new image is skipped rather than
|
|
190
|
+
reprocessed, which keeps a slow camera from throttling a fast one.
|
|
191
|
+
"""
|
|
192
|
+
assert self._bank is not None
|
|
193
|
+
fresh: dict[int, Frame] = {}
|
|
194
|
+
for camera_id, frame in self._bank.latest().items():
|
|
195
|
+
if self._seen.get(camera_id) == frame.sequence:
|
|
196
|
+
continue
|
|
197
|
+
self._seen[camera_id] = frame.sequence
|
|
198
|
+
fresh[camera_id] = frame
|
|
199
|
+
return fresh
|
|
200
|
+
|
|
201
|
+
def _process(self, frames: dict[int, Frame], now: float, dt: float) -> None:
|
|
202
|
+
assert self._bank is not None
|
|
203
|
+
newest_ms = max(frame.timestamp_ms for frame in frames.values())
|
|
204
|
+
|
|
205
|
+
observations = [
|
|
206
|
+
Observation(
|
|
207
|
+
camera_id=camera_id,
|
|
208
|
+
hands=self._hand_trackers[camera_id].process(frame),
|
|
209
|
+
age_ms=float(newest_ms - frame.timestamp_ms),
|
|
210
|
+
)
|
|
211
|
+
for camera_id, frame in frames.items()
|
|
212
|
+
if camera_id in self._hand_trackers
|
|
213
|
+
]
|
|
214
|
+
fused = self.fusion.fuse(observations)
|
|
215
|
+
if any(hand.rebased for hand in fused):
|
|
216
|
+
self.engine.rebase()
|
|
217
|
+
|
|
218
|
+
gaze = self._read_gaze(frames)
|
|
219
|
+
engaged = self.modes.engaged
|
|
220
|
+
|
|
221
|
+
events = self.engine.update([hand.features for hand in fused], now, dt, engaged)
|
|
222
|
+
# Before dispatching, not after: the helper decides whether to look for a
|
|
223
|
+
# target from this, and a click arriving first would resolve against a
|
|
224
|
+
# stale mode.
|
|
225
|
+
self._signal_mode(engaged)
|
|
226
|
+
if engaged:
|
|
227
|
+
self._apply_gaze(gaze, dt)
|
|
228
|
+
self._dispatch(events)
|
|
229
|
+
|
|
230
|
+
self.status.hands = len(fused)
|
|
231
|
+
self.status.cameras = tuple(sorted(frames))
|
|
232
|
+
self.status.merged = any(hand.merged for hand in fused)
|
|
233
|
+
self.status.gaze_ready = self.gaze_model.ready
|
|
234
|
+
|
|
235
|
+
if self.frame_hook is not None:
|
|
236
|
+
primary = frames.get(self._bank.primary_id) or next(iter(frames.values()))
|
|
237
|
+
self.frame_hook(primary, fused, self.status)
|
|
238
|
+
|
|
239
|
+
def _read_gaze(self, frames: dict[int, Frame]) -> GazeObservation:
|
|
240
|
+
"""Run the face model on the gaze camera only; it is the expensive one."""
|
|
241
|
+
assert self._bank is not None
|
|
242
|
+
if self._gaze_tracker is None:
|
|
243
|
+
return GazeObservation(present=False)
|
|
244
|
+
primary_id = self._bank.primary_id
|
|
245
|
+
frame = frames.get(primary_id)
|
|
246
|
+
if frame is None:
|
|
247
|
+
return GazeObservation(present=False)
|
|
248
|
+
return fuse_gaze({primary_id: self._gaze_tracker.process(frame)}, primary_id)
|
|
249
|
+
|
|
250
|
+
# ------------------------------------------------------------------- gaze arm
|
|
251
|
+
|
|
252
|
+
def _apply_gaze(self, gaze: GazeObservation, dt: float) -> None:
|
|
253
|
+
"""Warp the cursor to a settled gaze target, when the hand is not busy."""
|
|
254
|
+
cfg = self.cfg.gaze
|
|
255
|
+
if self.cfg.pointer.mode == "hands" or not self.gaze_model.ready or not gaze.usable:
|
|
256
|
+
return
|
|
257
|
+
if gaze.openness < cfg.blink_ear:
|
|
258
|
+
self._fixation.reset()
|
|
259
|
+
return
|
|
260
|
+
# The hand always outranks the eyes. Mid-drag, mid-scroll, or simply while
|
|
261
|
+
# the hand is moving, a warp would fight the user.
|
|
262
|
+
if self.mouse.dragging or self.engine.hand_speed > cfg.hand_quiet_speed:
|
|
263
|
+
self._fixation.reset()
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
assert gaze.features is not None
|
|
267
|
+
raw_x, raw_y = self.gaze_model.predict(gaze.features)
|
|
268
|
+
point = self._gaze_filter(raw_x, raw_y, dt)
|
|
269
|
+
self.status.gaze_point = point
|
|
270
|
+
|
|
271
|
+
settled = self._fixation.update(*point)
|
|
272
|
+
if settled is None:
|
|
273
|
+
return
|
|
274
|
+
# Only make the big jumps. Small corrections belong to the hand, and
|
|
275
|
+
# warping for them would feel like the cursor twitching under you.
|
|
276
|
+
if self._last_warp is not None:
|
|
277
|
+
moved = max(abs(settled[0] - self._last_warp[0]), abs(settled[1] - self._last_warp[1]))
|
|
278
|
+
if moved < cfg.warp_min_distance:
|
|
279
|
+
return
|
|
280
|
+
self._last_warp = settled
|
|
281
|
+
self.mouse.move_to_fraction(*settled)
|
|
282
|
+
self.status.warps += 1
|
|
283
|
+
self._fixation.reset()
|
|
284
|
+
|
|
285
|
+
# --------------------------------------------------------------- dispatching
|
|
286
|
+
|
|
287
|
+
def _dispatch(self, events: list[GestureEvent]) -> None:
|
|
288
|
+
for event in events:
|
|
289
|
+
action = event.action
|
|
290
|
+
if action is Action.ENGAGE_TOGGLE:
|
|
291
|
+
self._toggle_engage()
|
|
292
|
+
elif action in (Action.POINTER_MOVE, Action.DRAG_MOVE):
|
|
293
|
+
self.mouse.move_by(event.dx, event.dy)
|
|
294
|
+
elif action is Action.CLICK:
|
|
295
|
+
self.mouse.click(event.button)
|
|
296
|
+
elif action is Action.DRAG_START:
|
|
297
|
+
self.mouse.press(event.button)
|
|
298
|
+
elif action is Action.DRAG_END:
|
|
299
|
+
self.mouse.release(event.button)
|
|
300
|
+
elif action is Action.SCROLL:
|
|
301
|
+
self.mouse.scroll(event.dx, event.dy)
|
|
302
|
+
else:
|
|
303
|
+
self._run_binding(action.value)
|
|
304
|
+
|
|
305
|
+
def _signal_mode(self, engaged: bool) -> None:
|
|
306
|
+
"""Keep the helper's idea of the current gesture current.
|
|
307
|
+
|
|
308
|
+
Sent every frame but transmitted only on change. Also where a helper that
|
|
309
|
+
died is picked back up, since this runs unconditionally and cheaply.
|
|
310
|
+
"""
|
|
311
|
+
if not self.bridge.connected:
|
|
312
|
+
if self.bridge.reconnect():
|
|
313
|
+
self.status.native = True
|
|
314
|
+
print("[bridge] native helper reconnected")
|
|
315
|
+
else:
|
|
316
|
+
self.status.native = False
|
|
317
|
+
return
|
|
318
|
+
self.bridge.set_mode(
|
|
319
|
+
engaged=engaged,
|
|
320
|
+
pointing=self.engine.pointer_active,
|
|
321
|
+
sweeping=self.engine.sweeping,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def _run_binding(self, gesture: str) -> None:
|
|
325
|
+
action = self.cfg.bindings.get(gesture)
|
|
326
|
+
if action is None:
|
|
327
|
+
return
|
|
328
|
+
self.keyboard.run_action(action)
|
|
329
|
+
|
|
330
|
+
def _toggle_engage(self) -> None:
|
|
331
|
+
mode = self.modes.toggle()
|
|
332
|
+
if mode is Mode.OFF:
|
|
333
|
+
self._release_control()
|
|
334
|
+
else:
|
|
335
|
+
self.engine.rebase()
|
|
336
|
+
self.mouse.refresh_bounds()
|
|
337
|
+
|
|
338
|
+
def _release_control(self) -> None:
|
|
339
|
+
"""Drop any held button and clear motion state."""
|
|
340
|
+
for event in self.engine.release():
|
|
341
|
+
if event.action is Action.DRAG_END:
|
|
342
|
+
self.mouse.release(event.button)
|
|
343
|
+
self.mouse.release()
|
|
344
|
+
# Belt and braces: the helper drops anything held on its own side too, so a
|
|
345
|
+
# button cannot survive a suspend even if Python's idea of what is held has
|
|
346
|
+
# drifted. A stuck mouse button is the one failure that makes the machine
|
|
347
|
+
# unusable, so it is worth saying twice.
|
|
348
|
+
if self.bridge.connected:
|
|
349
|
+
self.bridge.release_all()
|
|
350
|
+
self.bridge.set_mode(engaged=False, pointing=False, sweeping=False)
|
|
351
|
+
self._fixation.reset()
|
|
352
|
+
self._last_warp = None
|
|
353
|
+
|
|
354
|
+
# -------------------------------------------------------------------- config
|
|
355
|
+
|
|
356
|
+
def apply_config(self, cfg: Config) -> None:
|
|
357
|
+
"""Adopt an edited config without dropping the current session.
|
|
358
|
+
|
|
359
|
+
Camera changes need a restart, since a device list change means opening
|
|
360
|
+
different hardware; everything else is re-read in place.
|
|
361
|
+
"""
|
|
362
|
+
cameras_changed = (
|
|
363
|
+
cfg.cameras.devices != self.cfg.cameras.devices
|
|
364
|
+
or cfg.cameras.mirror != self.cfg.cameras.mirror
|
|
365
|
+
or cfg.cameras.primary_gaze != self.cfg.cameras.primary_gaze
|
|
366
|
+
)
|
|
367
|
+
self.cfg = cfg
|
|
368
|
+
self.engine = GestureEngine(cfg.pointer, cfg.gestures, cfg.tracking)
|
|
369
|
+
self.fusion = HandFusion(cfg.tracking, cfg.gestures)
|
|
370
|
+
self.keyboard.update_bindings(cfg.keys)
|
|
371
|
+
self.bridge.apply_config(cfg.native, cfg.gestures.double_click_ms)
|
|
372
|
+
self._gaze_filter = OneEuroFilter2D(cfg.gaze.filter_fc_min, cfg.gaze.filter_beta)
|
|
373
|
+
self._fixation = FixationDetector(cfg.gaze.fixation_ms, cfg.gaze.fixation_radius)
|
|
374
|
+
if cameras_changed and not self._paused.is_set():
|
|
375
|
+
self._close_cameras()
|
|
376
|
+
self._open_cameras()
|