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.
@@ -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()