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,465 @@
1
+ """The gesture state machine.
2
+
3
+ Turns a stream of measured hands into discrete intents. Three ideas do most of
4
+ the work:
5
+
6
+ *Hysteresis* -- a pinch closes at one distance and opens at a wider one, so a
7
+ hand hovering near the threshold does not machine-gun clicks.
8
+
9
+ *Latching* -- a held pose fires once and then refuses to fire again until you
10
+ change pose. Without it, holding an open palm for three seconds would toggle
11
+ control three times.
12
+
13
+ *Fail-safe release* -- if the hand vanishes mid-drag the button is released.
14
+ Losing tracking should never leave the mouse stuck down.
15
+
16
+ The machine emits pixel deltas rather than raw landmark deltas: pointer feel
17
+ (sensitivity, acceleration, smoothing) is a property of the gesture layer, and
18
+ the control layer below should stay a dumb, testable event emitter.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ from dataclasses import dataclass
25
+ from enum import Enum
26
+
27
+ from ..config import GestureConfig, PointerConfig, TrackingConfig
28
+ from ..filters import OneEuroFilter2D
29
+ from ..geometry import HandFeatures, Pose
30
+
31
+
32
+ class Action(Enum):
33
+ POINTER_MOVE = "pointer_move"
34
+ CLICK = "click"
35
+ DRAG_START = "drag_start"
36
+ DRAG_MOVE = "drag_move"
37
+ DRAG_END = "drag_end"
38
+ SCROLL = "scroll"
39
+ ENGAGE_TOGGLE = "engage_toggle"
40
+ TELEPHONE = "telephone"
41
+ SWIPE_LEFT = "swipe_left"
42
+ SWIPE_RIGHT = "swipe_right"
43
+ PALM_PUSH_UP = "palm_push_up"
44
+
45
+
46
+ class State(Enum):
47
+ IDLE = "idle"
48
+ POINTING = "pointing"
49
+ PINCHED = "pinched"
50
+ DRAGGING = "dragging"
51
+ SCROLLING = "scrolling"
52
+ HOLDING_PALM = "holding_palm"
53
+ HOLDING_PHONE = "holding_phone"
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class GestureEvent:
58
+ action: Action
59
+ dx: float = 0.0
60
+ dy: float = 0.0
61
+ button: str = "left"
62
+
63
+
64
+ # Poses that mean "this hand is talking to the computer". A hand that is merely
65
+ # visible -- resting on the desk, holding a mug -- is deliberately ignored.
66
+ ACTIONABLE = (Pose.READY, Pose.FIST, Pose.OPEN_PALM, Pose.TELEPHONE)
67
+
68
+
69
+ class GestureEngine:
70
+ """Interprets hands frame by frame."""
71
+
72
+ def __init__(
73
+ self, pointer: PointerConfig, gestures: GestureConfig, tracking: TrackingConfig
74
+ ) -> None:
75
+ self._pointer = pointer
76
+ self._cfg = gestures
77
+ self._tracking = tracking
78
+
79
+ self.state = State.IDLE
80
+ self._hand_label: str | None = None
81
+ self._pose = Pose.NONE
82
+
83
+ self._anchor = OneEuroFilter2D(pointer.filter_fc_min, pointer.filter_beta)
84
+ self._last_anchor: tuple[float, float] | None = None
85
+ self._lost_since: float | None = None
86
+
87
+ self._pinch_closed = False
88
+ self._pinch_started_at = 0.0
89
+ self._pinch_origin = (0.0, 0.0)
90
+ self._pinch_button = "left"
91
+
92
+ self._pose_since = 0.0
93
+ self._pose_origin = (0.0, 0.0)
94
+ self._pose_consumed = False
95
+ self._cooldown_until = 0.0
96
+ self._clear_sweep()
97
+ # Negative infinity so nothing is latched until a palm is actually seen.
98
+ self._palm_seen_at = float("-inf")
99
+
100
+ # ---------------------------------------------------------------- properties
101
+
102
+ @property
103
+ def hand_speed(self) -> float:
104
+ """Smoothed hand speed in normalised units per second."""
105
+ return self._anchor.speed
106
+
107
+ @property
108
+ def pointer_active(self) -> bool:
109
+ return self.state in (State.POINTING, State.PINCHED, State.DRAGGING)
110
+
111
+ @property
112
+ def sweeping(self) -> bool:
113
+ """True while the hand is scrolling or sweeping rather than aiming.
114
+
115
+ A scroll or a swipe is not pointing at anything, so the snapping in the
116
+ native helper has to stand down for the duration -- a magnetic pull during
117
+ a sweep fights the hand instead of helping it.
118
+ """
119
+ return self.state in (State.SCROLLING, State.HOLDING_PALM)
120
+
121
+ def status(self) -> str:
122
+ hand = self._hand_label or "-"
123
+ return f"{self.state.value} [{self._pose.value}] {hand}"
124
+
125
+ # -------------------------------------------------------------------- update
126
+
127
+ def update(
128
+ self, hands: list[HandFeatures], now: float, dt: float, engaged: bool
129
+ ) -> list[GestureEvent]:
130
+ """Advance the machine one frame and return everything it wants done."""
131
+ hand = self._select(hands)
132
+
133
+ if hand is None:
134
+ return self._handle_missing_hand(now)
135
+ self._lost_since = None
136
+
137
+ if not engaged:
138
+ # Disengaged, exactly one gesture is listened for: the palm hold that
139
+ # turns control back on. Anything else must be inert, or you could
140
+ # never safely put your hands down.
141
+ return self._watch_for_engage(hand, now)
142
+
143
+ return self._handle_engaged(hand, now, dt)
144
+
145
+ # ------------------------------------------------------------------ internals
146
+
147
+ def _select(self, hands: list[HandFeatures]) -> HandFeatures | None:
148
+ """Pick the hand in charge, preferring the one already driving.
149
+
150
+ Sticking with the current hand matters: mid-drag, a second hand entering
151
+ frame must not steal the cursor.
152
+ """
153
+ usable = [h for h in hands if h.pose in ACTIONABLE or self._is_pinching(h)]
154
+ if not usable:
155
+ return None
156
+ if self._hand_label is not None:
157
+ for hand in usable:
158
+ if hand.handedness == self._hand_label:
159
+ return hand
160
+ return max(usable, key=lambda h: h.score)
161
+
162
+ def _is_pinching(self, hand: HandFeatures) -> bool:
163
+ """True while a hand is pinching, whether it just started or is mid-pinch.
164
+
165
+ A pinching hand reads as no recognisable pose, since the index curls in to
166
+ meet the thumb. Both halves matter, and only the second was here at first:
167
+ a hand already pinching keeps control of the pointer down to `pinch_open`,
168
+ and a hand measurably shut past `pinch_close` may *take* control.
169
+
170
+ Without that first half a well-formed pinch was unreachable. Every hand is
171
+ filtered on pose before the pinch machinery runs, so a pinch that never
172
+ passed through READY on its way shut -- exactly what holding one looks like
173
+ -- was discarded before anything could measure it. Only pinches that
174
+ happened to stay READY while closing worked, which is why quick taps
175
+ clicked and a deliberate held pinch did nothing at all.
176
+ """
177
+ if self._pinch_closed:
178
+ return hand.pinch < self._cfg.pinch_open
179
+ return hand.pinch < self._cfg.pinch_close
180
+
181
+ def _handle_missing_hand(self, now: float) -> list[GestureEvent]:
182
+ if self._lost_since is None:
183
+ self._lost_since = now
184
+ if (now - self._lost_since) * 1000.0 < self._tracking.stale_after_ms:
185
+ return []
186
+ events: list[GestureEvent] = []
187
+ if self.state is State.DRAGGING:
188
+ events.append(GestureEvent(Action.DRAG_END, button=self._pinch_button))
189
+ self._reset()
190
+ return events
191
+
192
+ def _watch_for_engage(self, hand: HandFeatures, now: float) -> list[GestureEvent]:
193
+ self._track_pose(hand, now)
194
+ if hand.pose is not Pose.OPEN_PALM:
195
+ return []
196
+ if self._hold_satisfied(hand, now, self._cfg.engage_hold_ms):
197
+ self._consume(now)
198
+ return [GestureEvent(Action.ENGAGE_TOGGLE)]
199
+ return []
200
+
201
+ def _handle_engaged(self, hand: HandFeatures, now: float, dt: float) -> list[GestureEvent]:
202
+ events: list[GestureEvent] = []
203
+ self._hand_label = hand.handedness
204
+
205
+ # A sweeping palm is a moving, rotating, motion-blurred palm, and the pose
206
+ # drops out partway through: one recording held OPEN_PALM for 100% of a
207
+ # still palm but only 25% of the sweep. Demanding the pose every frame means
208
+ # the travel is wiped mid-gesture and the swipe never completes, so an
209
+ # established palm keeps its status through a brief flicker. Some of those
210
+ # flickers read as FIST, which is why the latch outranks the scroll branch --
211
+ # otherwise a sweep turns into a scroll halfway across.
212
+ if hand.pose is Pose.OPEN_PALM:
213
+ self._palm_seen_at = now
214
+ latched = (now - self._palm_seen_at) * 1000.0 <= self._cfg.swipe_grace_ms
215
+
216
+ self._track_pose(hand, now, keep_travel=latched)
217
+
218
+ smoothed = self._anchor(hand.anchor[0], hand.anchor[1], dt)
219
+ delta = self._delta(smoothed)
220
+
221
+ events.extend(self._update_pinch(hand, now))
222
+
223
+ if self.state is State.DRAGGING:
224
+ events.extend(self._emit_motion(delta, Action.DRAG_MOVE))
225
+ return events
226
+
227
+ if self.state is State.PINCHED:
228
+ events.extend(self._emit_motion(delta, Action.POINTER_MOVE))
229
+ return events
230
+
231
+ if hand.pose is Pose.FIST and not latched:
232
+ self.state = State.SCROLLING
233
+ events.extend(self._emit_scroll(delta))
234
+ return events
235
+
236
+ if hand.pose is Pose.OPEN_PALM or latched:
237
+ self.state = State.HOLDING_PALM
238
+ events.extend(self._update_palm(hand, now, delta, dt))
239
+ return events
240
+
241
+ if hand.pose is Pose.TELEPHONE:
242
+ self.state = State.HOLDING_PHONE
243
+ if self._hold_satisfied(hand, now, self._cfg.dictation_hold_ms):
244
+ self._consume(now)
245
+ events.append(GestureEvent(Action.TELEPHONE))
246
+ return events
247
+
248
+ if hand.pose is Pose.READY:
249
+ self.state = State.POINTING
250
+ events.extend(self._emit_motion(delta, Action.POINTER_MOVE))
251
+ return events
252
+
253
+ self.state = State.IDLE
254
+ return events
255
+
256
+ def _clear_sweep(self) -> None:
257
+ """Discard the accumulated sweep, peak included.
258
+
259
+ Both together, always: a peak surviving its travel would arm the next
260
+ sweep with speed the hand never reached during it.
261
+ """
262
+ self._swipe_travel = (0.0, 0.0)
263
+ self._swipe_peak = 0.0
264
+
265
+ def _track_pose(self, hand: HandFeatures, now: float, keep_travel: bool = False) -> None:
266
+ """Restart the hold timer whenever the pose changes or the hand drifts.
267
+
268
+ ``keep_travel`` spares the accumulated sweep. Hold timers still restart --
269
+ a flicker genuinely is not a held pose -- but zeroing the travel would
270
+ discard the first half of a swipe every time the palm blurred.
271
+ """
272
+ if hand.pose is not self._pose:
273
+ self._pose = hand.pose
274
+ self._pose_since = now
275
+ self._pose_origin = hand.anchor
276
+ self._pose_consumed = False
277
+ if not keep_travel:
278
+ self._clear_sweep()
279
+ elif _distance(hand.anchor, self._pose_origin) > self._cfg.hold_max_travel:
280
+ self._pose_since = now
281
+ self._pose_origin = hand.anchor
282
+
283
+ def _hold_satisfied(self, hand: HandFeatures, now: float, duration_ms: float) -> bool:
284
+ if self._pose_consumed or now < self._cooldown_until:
285
+ return False
286
+ if _distance(hand.anchor, self._pose_origin) > self._cfg.hold_max_travel:
287
+ return False
288
+ return (now - self._pose_since) * 1000.0 >= duration_ms
289
+
290
+ def _consume(self, now: float) -> None:
291
+ """Latch the current pose and start the refractory window."""
292
+ self._pose_consumed = True
293
+ self._cooldown_until = now + self._cfg.gesture_cooldown_ms / 1000.0
294
+
295
+ def _delta(self, smoothed: tuple[float, float]) -> tuple[float, float]:
296
+ """Movement since the last frame, suppressing the jump on first sight."""
297
+ if self._last_anchor is None:
298
+ self._last_anchor = smoothed
299
+ return 0.0, 0.0
300
+ delta = (smoothed[0] - self._last_anchor[0], smoothed[1] - self._last_anchor[1])
301
+ self._last_anchor = smoothed
302
+ return delta
303
+
304
+ def _update_pinch(self, hand: HandFeatures, now: float) -> list[GestureEvent]:
305
+ """Drive the pinch sub-machine: closing, promotion to drag, and release."""
306
+ events: list[GestureEvent] = []
307
+ distance = hand.pinch
308
+
309
+ # A fist folds the thumb alongside the curled fingers, so its thumb-to-index
310
+ # distance can read exactly like a pinch -- on some hands the two ranges
311
+ # overlap almost entirely. A fist means scroll, so it must never open a
312
+ # pinch: otherwise it silently starts a drag and then never scrolls, because
313
+ # dragging outranks every pose. An in-flight pinch is left alone, so curling
314
+ # the rest of the hand mid-drag does not drop what you are holding.
315
+ #
316
+ # Exempting a tightly-closed fist was tried and reverted. Both candidate
317
+ # discriminators fail on real recordings: thumb-to-index distance overlaps a
318
+ # genuine pinch almost entirely, and index reach -- which does separate the
319
+ # two by median -- still leaves 5% of fist frames in pinch territory. A
320
+ # single such frame latches PINCHED, and PINCHED outranks the scroll branch
321
+ # below, so one misread costs the remainder of the scroll. Blunt, but it
322
+ # fails in the direction that keeps scrolling working.
323
+ if not self._pinch_closed and hand.pose is Pose.FIST:
324
+ return events
325
+
326
+ if not self._pinch_closed and distance < self._cfg.pinch_close:
327
+ self._pinch_closed = True
328
+ self._pinch_started_at = now
329
+ self._pinch_origin = hand.anchor
330
+ self._pinch_button = "right" if hand.pinch_is_middle else "left"
331
+ if self.state is not State.DRAGGING:
332
+ self.state = State.PINCHED
333
+ return events
334
+
335
+ if not self._pinch_closed:
336
+ return events
337
+
338
+ held_ms = (now - self._pinch_started_at) * 1000.0
339
+ travel = _distance(hand.anchor, self._pinch_origin)
340
+
341
+ if distance > self._cfg.pinch_open:
342
+ self._pinch_closed = False
343
+ if self.state is State.DRAGGING:
344
+ self.state = State.POINTING
345
+ events.append(GestureEvent(Action.DRAG_END, button=self._pinch_button))
346
+ elif self._is_tap(held_ms, travel):
347
+ self.state = State.POINTING
348
+ events.append(GestureEvent(Action.CLICK, button=self._pinch_button))
349
+ else:
350
+ self.state = State.POINTING
351
+ return events
352
+
353
+ # Still pinched. A left pinch held past the tap window becomes a drag;
354
+ # a right pinch has no drag equivalent, so it just waits for release.
355
+ if (
356
+ self.state is State.PINCHED
357
+ and self._pinch_button == "left"
358
+ and held_ms >= self._cfg.tap_max_ms
359
+ ):
360
+ self.state = State.DRAGGING
361
+ events.append(GestureEvent(Action.DRAG_START, button="left"))
362
+ return events
363
+
364
+ def _is_tap(self, held_ms: float, travel: float) -> bool:
365
+ if travel > self._cfg.tap_max_travel:
366
+ return False
367
+ # A right pinch is always a deliberate act, so it is not time limited;
368
+ # a left pinch has to be quick or it would have become a drag.
369
+ return self._pinch_button == "right" or held_ms < self._cfg.tap_max_ms
370
+
371
+ def _emit_motion(self, delta: tuple[float, float], action: Action) -> list[GestureEvent]:
372
+ dx, dy = delta
373
+ if math.hypot(dx, dy) < self._pointer.deadzone:
374
+ return []
375
+ gain = self._gain()
376
+ scale = self._pointer.sensitivity * gain
377
+ return [GestureEvent(action, dx=dx * scale, dy=dy * scale)]
378
+
379
+ def _gain(self) -> float:
380
+ """Pointer acceleration: precise when slow, sweeping when fast."""
381
+ cfg = self._pointer
382
+ ratio = self._anchor.speed / max(cfg.gain_speed_reference, 1e-6)
383
+ return min(cfg.gain_min + (cfg.gain_max - cfg.gain_min) * ratio, cfg.gain_max)
384
+
385
+ def _emit_scroll(self, delta: tuple[float, float]) -> list[GestureEvent]:
386
+ dx, dy = delta
387
+ if math.hypot(dx, dy) < self._cfg.scroll_deadzone:
388
+ return []
389
+ scale = self._cfg.scroll_sensitivity
390
+ return [GestureEvent(Action.SCROLL, dx=dx * scale, dy=dy * scale)]
391
+
392
+ def _update_palm(
393
+ self, hand: HandFeatures, now: float, delta: tuple[float, float], dt: float
394
+ ) -> list[GestureEvent]:
395
+ """An open palm either sweeps (swipe) or sits still (engage toggle).
396
+
397
+ The two never collide, because a swipe is defined by speed and the toggle
398
+ by the absence of it.
399
+ """
400
+ self._swipe_travel = (
401
+ self._swipe_travel[0] + delta[0],
402
+ self._swipe_travel[1] + delta[1],
403
+ )
404
+ # Peak speed over the sweep, not the speed on the frame that happens to
405
+ # complete the travel. The two conditions are anti-correlated in real
406
+ # motion -- speed peaks early, while travel only accumulates later, by
407
+ # which time the hand is decelerating into the end of its arc -- and a
408
+ # multi-camera rig makes it worse: every change of leading camera drops
409
+ # that frame's delta to zero to stop the cursor flinging, so a fast sweep
410
+ # crossing between views reports standstill for a third of its frames. On
411
+ # one recording 39 frames were fast enough and 21 had travelled far
412
+ # enough, but only 2 managed both at once, and the sweep read as nothing.
413
+ speed = math.hypot(*delta) / max(dt, 1e-6)
414
+ self._swipe_peak = max(self._swipe_peak, speed)
415
+
416
+ if self._swipe_peak >= self._cfg.swipe_min_speed and now >= self._cooldown_until:
417
+ travel_x, travel_y = self._swipe_travel
418
+ if abs(travel_x) >= self._cfg.swipe_min_travel and abs(travel_x) >= abs(travel_y):
419
+ self._consume(now)
420
+ self._clear_sweep()
421
+ action = Action.SWIPE_RIGHT if travel_x > 0 else Action.SWIPE_LEFT
422
+ return [GestureEvent(action)]
423
+ if -travel_y >= self._cfg.swipe_min_travel and abs(travel_y) > abs(travel_x):
424
+ self._consume(now)
425
+ self._clear_sweep()
426
+ return [GestureEvent(Action.PALM_PUSH_UP)]
427
+ return []
428
+
429
+ if self._hold_satisfied(hand, now, self._cfg.engage_hold_ms):
430
+ self._consume(now)
431
+ return [GestureEvent(Action.ENGAGE_TOGGLE)]
432
+ return []
433
+
434
+ def _reset(self) -> None:
435
+ self.state = State.IDLE
436
+ self._pose = Pose.NONE
437
+ self._hand_label = None
438
+ self._pinch_closed = False
439
+ self._last_anchor = None
440
+ self._pose_consumed = False
441
+ self._clear_sweep()
442
+ # Losing the hand ends any sweep; the latch must not span that gap.
443
+ self._palm_seen_at = float("-inf")
444
+ self._anchor.reset()
445
+
446
+ def rebase(self) -> None:
447
+ """Forget the motion baseline, without disturbing gesture state.
448
+
449
+ Called when the camera leading for position changes: the anchor jumps to
450
+ a new viewpoint, and differencing across that jump would fling the cursor.
451
+ """
452
+ self._last_anchor = None
453
+ self._anchor.reset()
454
+
455
+ def release(self) -> list[GestureEvent]:
456
+ """Drop everything held, for suspend or shutdown. Never leaves a button down."""
457
+ events: list[GestureEvent] = []
458
+ if self.state is State.DRAGGING:
459
+ events.append(GestureEvent(Action.DRAG_END, button=self._pinch_button))
460
+ self._reset()
461
+ return events
462
+
463
+
464
+ def _distance(a: tuple[float, float], b: tuple[float, float]) -> float:
465
+ return math.hypot(a[0] - b[0], a[1] - b[1])
mindcontrol/logs.py ADDED
@@ -0,0 +1,87 @@
1
+ """Quieting the tracking stack's startup chatter.
2
+
3
+ MediaPipe, TensorFlow Lite and glog all write to stderr from C++, before any
4
+ Python logging configuration can reach them. Between them they announce the GL
5
+ version, the XNNPACK delegate and two feedback managers on every single start.
6
+
7
+ That matters most during a guided recording. The output the user is meant to read
8
+ is the prompt and the per-prompt frame counts, and six lines of library trivia
9
+ bury them.
10
+
11
+ Two mechanisms, because one is not enough:
12
+
13
+ `quiet()` sets the documented logging environment variables. On MediaPipe 0.10
14
+ these turn out to change nothing -- its logs come from absl in C++ and honour
15
+ neither the glog variables nor any Python-side logging call -- but they are the
16
+ supported interface, they are harmless, and they cover the other components and
17
+ whatever MediaPipe does next.
18
+
19
+ `muffled()` is what actually works: it redirects file descriptor 2 for the
20
+ duration of a noisy call. That is a blunt instrument, so it is aimed narrowly at
21
+ model construction and it keeps what it swallowed: on failure the captured output
22
+ is written out, because the one time this logging matters is the time something
23
+ broke.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import contextlib
29
+ import os
30
+ import sys
31
+ import tempfile
32
+ from collections.abc import Iterator
33
+
34
+ # glog levels: 0=INFO, 1=WARNING, 2=ERROR, 3=FATAL. The value is the floor for
35
+ # what still prints, so 2 keeps errors and drops the rest.
36
+ QUIET_ENV = {
37
+ "GLOG_minloglevel": "2",
38
+ "GLOG_stderrthreshold": "2",
39
+ "TF_CPP_MIN_LOG_LEVEL": "3",
40
+ "ABSL_MIN_LOG_LEVEL": "2",
41
+ }
42
+
43
+ VERBOSE_VAR = "MINDCONTROL_VERBOSE"
44
+
45
+
46
+ def quiet() -> None:
47
+ """Silence library startup logging, unless the environment says otherwise.
48
+
49
+ Set ``MINDCONTROL_VERBOSE=1`` to see everything. Any level already exported
50
+ is left untouched, so an explicit choice always wins over this default.
51
+ """
52
+ if os.environ.get(VERBOSE_VAR):
53
+ return
54
+ for name, level in QUIET_ENV.items():
55
+ os.environ.setdefault(name, level)
56
+
57
+
58
+ @contextlib.contextmanager
59
+ def muffled() -> Iterator[None]:
60
+ """Swallow C++ stderr for the duration, and give it back if anything fails.
61
+
62
+ Only file-descriptor redirection reaches logging emitted from C++, so that is
63
+ what this does. The descriptor is restored in a ``finally`` whatever happens,
64
+ since leaving stderr pointing at a deleted temporary file would silence the
65
+ whole process.
66
+ """
67
+ if os.environ.get(VERBOSE_VAR):
68
+ yield
69
+ return
70
+
71
+ sys.stderr.flush()
72
+ saved = os.dup(2)
73
+ try:
74
+ with tempfile.TemporaryFile() as sink:
75
+ os.dup2(sink.fileno(), 2)
76
+ try:
77
+ yield
78
+ finally:
79
+ os.dup2(saved, 2)
80
+ if sys.exc_info()[0] is not None:
81
+ sink.seek(0)
82
+ captured = sink.read().decode("utf-8", "replace")
83
+ if captured:
84
+ sys.stderr.write(captured)
85
+ sys.stderr.flush()
86
+ finally:
87
+ os.close(saved)
mindcontrol/models.py ADDED
@@ -0,0 +1,59 @@
1
+ """MediaPipe task-model bundles.
2
+
3
+ MediaPipe 1.x ships no bundled models, so the ``.task`` files are fetched once
4
+ into the user cache on first run and reused from then on.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import urllib.error
11
+ import urllib.request
12
+ from pathlib import Path
13
+
14
+ from .config import CACHE_DIR
15
+
16
+ MODELS_DIR = CACHE_DIR / "models"
17
+
18
+ _BASE = "https://storage.googleapis.com/mediapipe-models"
19
+ SOURCES: dict[str, str] = {
20
+ name: f"{_BASE}/{stem}/{stem}/float16/latest/{name}"
21
+ for stem, name in (
22
+ ("hand_landmarker", "hand_landmarker.task"),
23
+ ("face_landmarker", "face_landmarker.task"),
24
+ )
25
+ }
26
+
27
+
28
+ class ModelUnavailableError(RuntimeError):
29
+ """A required model is neither cached nor downloadable."""
30
+
31
+
32
+ def ensure(name: str) -> Path:
33
+ """Return the local path to a task bundle, downloading it if needed."""
34
+ if name not in SOURCES:
35
+ raise KeyError(f"unknown model {name!r}")
36
+ target = MODELS_DIR / name
37
+ if target.is_file() and target.stat().st_size > 0:
38
+ return target
39
+
40
+ MODELS_DIR.mkdir(parents=True, exist_ok=True)
41
+ url = SOURCES[name]
42
+ print(f"[models] downloading {name} ...")
43
+ staging = target.with_suffix(target.suffix + ".part")
44
+ try:
45
+ with urllib.request.urlopen(url, timeout=60) as response, staging.open("wb") as out:
46
+ shutil.copyfileobj(response, out)
47
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
48
+ staging.unlink(missing_ok=True)
49
+ raise ModelUnavailableError(
50
+ f"could not download {name} from {url}: {exc}. "
51
+ f"Download it manually and place it at {target}."
52
+ ) from exc
53
+ staging.replace(target)
54
+ print(f"[models] cached {name} ({target.stat().st_size // 1024} KiB)")
55
+ return target
56
+
57
+
58
+ def ensure_all() -> dict[str, Path]:
59
+ return {name: ensure(name) for name in SOURCES}