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
|
@@ -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}
|