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/filters.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Signal smoothing.
|
|
2
|
+
|
|
3
|
+
Landmark streams are noisy at a level you can see as cursor jitter, but a plain
|
|
4
|
+
low-pass trades that jitter for lag you can feel. The one-euro filter adapts:
|
|
5
|
+
heavy smoothing while the hand is still, light smoothing while it moves, so the
|
|
6
|
+
pointer is both steady when parked and responsive when thrown.
|
|
7
|
+
|
|
8
|
+
Reference: Casiez, Roussel & Vogel, "1 e filter" (CHI 2012).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _alpha(cutoff: float, dt: float) -> float:
|
|
17
|
+
tau = 1.0 / (2.0 * math.pi * max(cutoff, 1e-6))
|
|
18
|
+
return 1.0 / (1.0 + tau / max(dt, 1e-6))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _LowPass:
|
|
22
|
+
__slots__ = ("_value",)
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self._value: float | None = None
|
|
26
|
+
|
|
27
|
+
def __call__(self, sample: float, alpha: float) -> float:
|
|
28
|
+
self._value = (
|
|
29
|
+
sample if self._value is None else alpha * sample + (1.0 - alpha) * self._value
|
|
30
|
+
)
|
|
31
|
+
return self._value
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def value(self) -> float | None:
|
|
35
|
+
return self._value
|
|
36
|
+
|
|
37
|
+
def reset(self) -> None:
|
|
38
|
+
self._value = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class OneEuroFilter:
|
|
42
|
+
"""Adaptive low-pass filter for a scalar stream."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, fc_min: float = 1.0, beta: float = 0.01, dc_cutoff: float = 1.0) -> None:
|
|
45
|
+
self.fc_min = fc_min
|
|
46
|
+
self.beta = beta
|
|
47
|
+
self.dc_cutoff = dc_cutoff
|
|
48
|
+
self._x = _LowPass()
|
|
49
|
+
self._dx = _LowPass()
|
|
50
|
+
|
|
51
|
+
def __call__(self, sample: float, dt: float) -> float:
|
|
52
|
+
previous = self._x.value
|
|
53
|
+
rate = 0.0 if previous is None else (sample - previous) / max(dt, 1e-6)
|
|
54
|
+
edge = self._dx(rate, _alpha(self.dc_cutoff, dt))
|
|
55
|
+
cutoff = self.fc_min + self.beta * abs(edge)
|
|
56
|
+
return self._x(sample, _alpha(cutoff, dt))
|
|
57
|
+
|
|
58
|
+
def reset(self) -> None:
|
|
59
|
+
self._x.reset()
|
|
60
|
+
self._dx.reset()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class OneEuroFilter2D:
|
|
64
|
+
"""Two-axis one-euro filter that shares one adaptive cutoff.
|
|
65
|
+
|
|
66
|
+
Both axes are driven by the *combined* speed rather than their own, so a
|
|
67
|
+
diagonal move is smoothed evenly instead of bending toward whichever axis
|
|
68
|
+
happened to be quieter.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
def __init__(self, fc_min: float = 1.0, beta: float = 0.01, dc_cutoff: float = 1.0) -> None:
|
|
72
|
+
self.fc_min = fc_min
|
|
73
|
+
self.beta = beta
|
|
74
|
+
self.dc_cutoff = dc_cutoff
|
|
75
|
+
self._x = _LowPass()
|
|
76
|
+
self._y = _LowPass()
|
|
77
|
+
self._speed = _LowPass()
|
|
78
|
+
|
|
79
|
+
def __call__(self, x: float, y: float, dt: float) -> tuple[float, float]:
|
|
80
|
+
px, py = self._x.value, self._y.value
|
|
81
|
+
stale = px is None or py is None
|
|
82
|
+
rate = 0.0 if stale else math.hypot(x - px, y - py) / max(dt, 1e-6)
|
|
83
|
+
edge = self._speed(rate, _alpha(self.dc_cutoff, dt))
|
|
84
|
+
alpha = _alpha(self.fc_min + self.beta * abs(edge), dt)
|
|
85
|
+
return self._x(x, alpha), self._y(y, alpha)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def speed(self) -> float:
|
|
89
|
+
"""Smoothed magnitude of recent motion, in input units per second."""
|
|
90
|
+
return abs(self._speed.value or 0.0)
|
|
91
|
+
|
|
92
|
+
def reset(self) -> None:
|
|
93
|
+
self._x.reset()
|
|
94
|
+
self._y.reset()
|
|
95
|
+
self._speed.reset()
|
mindcontrol/fusion.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Meshing several cameras into one view of your hands.
|
|
2
|
+
|
|
3
|
+
The two halves of a hand observation are merged differently, because they mean
|
|
4
|
+
different things across viewpoints.
|
|
5
|
+
|
|
6
|
+
*Shape* -- pinch distances, which fingers are out -- is scale invariant and
|
|
7
|
+
comparable between cameras, so it is combined by confidence-weighted vote. This
|
|
8
|
+
is the real payoff of a second camera: a pinch hidden behind your palm from the
|
|
9
|
+
laptop is plainly visible from the side, and either camera can carry the gesture.
|
|
10
|
+
|
|
11
|
+
*Position* is not comparable. Each camera has its own viewpoint, so the same hand
|
|
12
|
+
sits at different normalised coordinates in each. Averaging them would invent a
|
|
13
|
+
location belonging to no camera and lurch whenever one dropped out. Instead one
|
|
14
|
+
camera *leads* for position, chosen by confidence and held onto until it is
|
|
15
|
+
clearly beaten, and a change of leader is reported so the pointer can rebase
|
|
16
|
+
instead of jumping.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from dataclasses import dataclass, replace
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from .config import GestureConfig, TrackingConfig
|
|
26
|
+
from .geometry import HandFeatures, classify
|
|
27
|
+
from .tracking.gaze import GazeObservation
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .session import RecordedFrame, Session
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Observation:
|
|
36
|
+
"""One camera's hands, with the age of the frame they came from."""
|
|
37
|
+
|
|
38
|
+
camera_id: int
|
|
39
|
+
hands: list[HandFeatures]
|
|
40
|
+
age_ms: float
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class FusedHand:
|
|
45
|
+
features: HandFeatures
|
|
46
|
+
camera_id: int
|
|
47
|
+
cameras: tuple[int, ...]
|
|
48
|
+
rebased: bool
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def merged(self) -> bool:
|
|
52
|
+
return len(self.cameras) > 1
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class HandFusion:
|
|
56
|
+
"""Combines per-camera hand observations into one hand per side."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, tracking: TrackingConfig, gestures: GestureConfig) -> None:
|
|
59
|
+
self._tracking = tracking
|
|
60
|
+
self._gestures = gestures
|
|
61
|
+
self._leader: dict[str, int] = {}
|
|
62
|
+
# State for stitching position across a change of leading camera. Per side:
|
|
63
|
+
# the offset currently mapping the leader's coordinates into the continuous
|
|
64
|
+
# track, the last anchor emitted, and the last anchor each camera reported.
|
|
65
|
+
self._offset: dict[str, tuple[float, float]] = {}
|
|
66
|
+
self._emitted: dict[str, tuple[float, float]] = {}
|
|
67
|
+
self._seen: dict[tuple[str, int], tuple[float, float]] = {}
|
|
68
|
+
|
|
69
|
+
def fuse(self, observations: list[Observation]) -> list[FusedHand]:
|
|
70
|
+
"""Merge observations, newest-first, dropping stale frames."""
|
|
71
|
+
by_side: dict[str, list[tuple[int, HandFeatures]]] = {}
|
|
72
|
+
for observation in observations:
|
|
73
|
+
if observation.age_ms > self._tracking.stale_after_ms:
|
|
74
|
+
continue
|
|
75
|
+
for hand in observation.hands:
|
|
76
|
+
by_side.setdefault(hand.handedness, []).append((observation.camera_id, hand))
|
|
77
|
+
|
|
78
|
+
return [self._fuse_side(side, entries) for side, entries in by_side.items()]
|
|
79
|
+
|
|
80
|
+
def _fuse_side(self, side: str, entries: list[tuple[int, HandFeatures]]) -> FusedHand:
|
|
81
|
+
cameras = tuple(sorted(camera_id for camera_id, _ in entries))
|
|
82
|
+
leader_id, leader = self._pick_leader(side, entries)
|
|
83
|
+
rebased = self._stitch(side, leader_id, leader.anchor)
|
|
84
|
+
self._leader[side] = leader_id
|
|
85
|
+
|
|
86
|
+
features = leader if len(entries) == 1 else self._blend(entries, leader)
|
|
87
|
+
anchor, offset = features.anchor, self._offset[side]
|
|
88
|
+
if offset != (0.0, 0.0):
|
|
89
|
+
features = replace(features, anchor=(anchor[0] + offset[0], anchor[1] + offset[1]))
|
|
90
|
+
|
|
91
|
+
self._emitted[side] = features.anchor
|
|
92
|
+
for camera_id, hand in entries:
|
|
93
|
+
self._seen[side, camera_id] = hand.anchor
|
|
94
|
+
return FusedHand(features, leader_id, cameras, rebased)
|
|
95
|
+
|
|
96
|
+
def _stitch(self, side: str, leader_id: int, anchor: tuple[float, float]) -> bool:
|
|
97
|
+
"""Absorb a change of leader into an offset, and say whether that failed.
|
|
98
|
+
|
|
99
|
+
Two cameras looking at one hand disagree about where it is, so handing the
|
|
100
|
+
lead over moves the anchor by the parallax between them. That used to be
|
|
101
|
+
dealt with by telling the pointer to forget its baseline, which stops the
|
|
102
|
+
cursor flinging but throws away the frame -- and a fast gesture crossing
|
|
103
|
+
between views spends a third of its frames doing exactly that, which is how
|
|
104
|
+
a real sweep changed leader 27 times and registered one swipe out of four.
|
|
105
|
+
|
|
106
|
+
The new leader has usually been watching all along, so its *own* movement
|
|
107
|
+
since the previous frame is known and is a faithful measure of the hand's.
|
|
108
|
+
Choosing an offset that continues the track from there keeps position
|
|
109
|
+
continuous while spending none of the motion:
|
|
110
|
+
|
|
111
|
+
emitted = leader.anchor + offset, offset = last_emitted - leader.previous
|
|
112
|
+
|
|
113
|
+
The offset then stays put until the next handover, so between them the
|
|
114
|
+
motion is exactly the leader's own. Only a leader that was not in the
|
|
115
|
+
previous frame -- one that just appeared -- has nothing to continue from,
|
|
116
|
+
and that alone still needs a rebase.
|
|
117
|
+
"""
|
|
118
|
+
previous = self._leader.get(side)
|
|
119
|
+
if previous == leader_id:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
here = self._seen.get((side, leader_id))
|
|
123
|
+
emitted = self._emitted.get(side)
|
|
124
|
+
if previous is None or here is None or emitted is None:
|
|
125
|
+
self._offset[side] = (0.0, 0.0)
|
|
126
|
+
# A first sighting is not a jump; there is no baseline to invalidate.
|
|
127
|
+
return previous is not None
|
|
128
|
+
|
|
129
|
+
self._offset[side] = (emitted[0] - here[0], emitted[1] - here[1])
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
def _pick_leader(
|
|
133
|
+
self, side: str, entries: list[tuple[int, HandFeatures]]
|
|
134
|
+
) -> tuple[int, HandFeatures]:
|
|
135
|
+
"""Best camera for position, with hysteresis so it does not flip-flop.
|
|
136
|
+
|
|
137
|
+
The margin used to carry more weight than it should have, because every
|
|
138
|
+
handover discarded a frame of motion; `_stitch` now absorbs them, so this
|
|
139
|
+
only keeps the lead from flitting between views of near-equal confidence.
|
|
140
|
+
"""
|
|
141
|
+
best_id, best = max(entries, key=lambda item: item[1].score)
|
|
142
|
+
held = self._leader.get(side)
|
|
143
|
+
if held is None:
|
|
144
|
+
return best_id, best
|
|
145
|
+
margin = self._tracking.leader_margin
|
|
146
|
+
for camera_id, hand in entries:
|
|
147
|
+
if camera_id == held and hand.score + margin >= best.score:
|
|
148
|
+
return camera_id, hand
|
|
149
|
+
return best_id, best
|
|
150
|
+
|
|
151
|
+
def _blend(self, entries: list[tuple[int, HandFeatures]], leader: HandFeatures) -> HandFeatures:
|
|
152
|
+
"""Average shape across cameras, keeping the leader's position."""
|
|
153
|
+
weights = [max(hand.score, 1e-3) for _, hand in entries]
|
|
154
|
+
total = sum(weights)
|
|
155
|
+
hands = [hand for _, hand in entries]
|
|
156
|
+
|
|
157
|
+
def weighted(pick) -> float:
|
|
158
|
+
pairs = zip(weights, hands, strict=True)
|
|
159
|
+
return sum(weight * pick(hand) for weight, hand in pairs) / total
|
|
160
|
+
|
|
161
|
+
# A finger counts as extended when the cameras that can see it agree by
|
|
162
|
+
# weight; occlusion in one view is outvoted rather than trusted.
|
|
163
|
+
flags = tuple(
|
|
164
|
+
sum(weight for weight, hand in zip(weights, hands, strict=True) if hand.extended[index])
|
|
165
|
+
> total / 2.0
|
|
166
|
+
for index in range(5)
|
|
167
|
+
)
|
|
168
|
+
spread = weighted(lambda h: h.spread)
|
|
169
|
+
facing = weighted(lambda h: h.facing)
|
|
170
|
+
|
|
171
|
+
return replace(
|
|
172
|
+
leader,
|
|
173
|
+
pinch_index=weighted(lambda h: h.pinch_index),
|
|
174
|
+
pinch_middle=weighted(lambda h: h.pinch_middle),
|
|
175
|
+
extended=flags, # type: ignore[arg-type]
|
|
176
|
+
spread=spread,
|
|
177
|
+
facing=facing,
|
|
178
|
+
score=max(hand.score for hand in hands),
|
|
179
|
+
pose=classify(flags, spread, facing, self._gestures),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def reset(self) -> None:
|
|
183
|
+
"""Forget everything, including the stitched track.
|
|
184
|
+
|
|
185
|
+
Clearing the offset here is what bounds its drift: it only accumulates
|
|
186
|
+
while one hand stays continuously in view, and each camera measures motion
|
|
187
|
+
in its own field of view, so the scales are not identical.
|
|
188
|
+
"""
|
|
189
|
+
self._leader.clear()
|
|
190
|
+
self._offset.clear()
|
|
191
|
+
self._emitted.clear()
|
|
192
|
+
self._seen.clear()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def fuse_session(
|
|
196
|
+
session: Session, gestures: GestureConfig, tracking: TrackingConfig | None = None
|
|
197
|
+
) -> Iterator[tuple[RecordedFrame, list[FusedHand]]]:
|
|
198
|
+
"""Push a recording through fusion, yielding each frame's merged hands.
|
|
199
|
+
|
|
200
|
+
Tuning and reporting both want the hand the *engine* sees. Reading the raw
|
|
201
|
+
per-camera views instead skews everything downstream, and not by a little:
|
|
202
|
+
taking the lowest value across three viewpoints for a cluster meant to be low,
|
|
203
|
+
and the highest for one meant to be high, manufactures a separation that no
|
|
204
|
+
single camera ever saw. A threshold fitted to that invented gap lands in the
|
|
205
|
+
space between the cameras, where no real measurement falls.
|
|
206
|
+
|
|
207
|
+
Frames are walked in order because fusion carries state -- which camera
|
|
208
|
+
currently leads position, and the hysteresis holding it there.
|
|
209
|
+
"""
|
|
210
|
+
fusion = HandFusion(tracking or TrackingConfig(), gestures)
|
|
211
|
+
for frame in session.frames:
|
|
212
|
+
yield (
|
|
213
|
+
frame,
|
|
214
|
+
fusion.fuse(
|
|
215
|
+
[
|
|
216
|
+
Observation(
|
|
217
|
+
camera_id=view.camera_id,
|
|
218
|
+
hands=[hand.remeasure(gestures) for hand in view.hands],
|
|
219
|
+
age_ms=view.age_ms,
|
|
220
|
+
)
|
|
221
|
+
for view in frame.views
|
|
222
|
+
]
|
|
223
|
+
),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def fuse_gaze(observations: dict[int, GazeObservation], primary: int) -> GazeObservation:
|
|
228
|
+
"""Prefer the camera designated for gaze; fall back to any that sees a face.
|
|
229
|
+
|
|
230
|
+
Gaze normally runs on one camera only -- it is the expensive model, and only
|
|
231
|
+
a camera near the screen you look at can produce a useful answer -- but the
|
|
232
|
+
fallback keeps gaze alive if that camera is unplugged.
|
|
233
|
+
"""
|
|
234
|
+
preferred = observations.get(primary)
|
|
235
|
+
if preferred is not None and preferred.usable:
|
|
236
|
+
return preferred
|
|
237
|
+
for observation in observations.values():
|
|
238
|
+
if observation.usable:
|
|
239
|
+
return observation
|
|
240
|
+
return preferred or GazeObservation(present=False)
|
mindcontrol/geometry.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Hand-shape features and pose classification.
|
|
2
|
+
|
|
3
|
+
Everything here is scale invariant: raw landmark distances shrink as you lean
|
|
4
|
+
back from the camera, so each measurement is divided by the palm span
|
|
5
|
+
(wrist to middle knuckle). A pinch is then "0.3 palms" whether you are at the
|
|
6
|
+
keyboard or across the room.
|
|
7
|
+
|
|
8
|
+
Landmark ordering is MediaPipe's 21-point hand model.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from itertools import pairwise
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
WRIST = 0
|
|
20
|
+
THUMB_MCP, THUMB_IP, THUMB_TIP = 2, 3, 4
|
|
21
|
+
INDEX_MCP, INDEX_PIP, INDEX_TIP = 5, 6, 8
|
|
22
|
+
MIDDLE_MCP, MIDDLE_PIP, MIDDLE_TIP = 9, 10, 12
|
|
23
|
+
RING_MCP, RING_PIP, RING_TIP = 13, 14, 16
|
|
24
|
+
PINKY_MCP, PINKY_PIP, PINKY_TIP = 17, 18, 20
|
|
25
|
+
|
|
26
|
+
PALM_POINTS = (WRIST, INDEX_MCP, MIDDLE_MCP, RING_MCP, PINKY_MCP)
|
|
27
|
+
# (tip, pip) pairs for the four non-thumb fingers, in finger order.
|
|
28
|
+
FINGERS = (
|
|
29
|
+
(INDEX_TIP, INDEX_PIP),
|
|
30
|
+
(MIDDLE_TIP, MIDDLE_PIP),
|
|
31
|
+
(RING_TIP, RING_PIP),
|
|
32
|
+
(PINKY_TIP, PINKY_PIP),
|
|
33
|
+
)
|
|
34
|
+
TIPS = (INDEX_TIP, MIDDLE_TIP, RING_TIP, PINKY_TIP)
|
|
35
|
+
|
|
36
|
+
# Drawing skeleton: pairs of landmark indices connected by a bone, one row per
|
|
37
|
+
# finger, then the strap across the base of the palm.
|
|
38
|
+
# fmt: off
|
|
39
|
+
SKELETON = (
|
|
40
|
+
(0, 1), (1, 2), (2, 3), (3, 4),
|
|
41
|
+
(0, 5), (5, 6), (6, 7), (7, 8),
|
|
42
|
+
(5, 9), (9, 10), (10, 11), (11, 12),
|
|
43
|
+
(9, 13), (13, 14), (14, 15), (15, 16),
|
|
44
|
+
(13, 17), (17, 18), (18, 19), (19, 20),
|
|
45
|
+
(0, 17),
|
|
46
|
+
)
|
|
47
|
+
# fmt: on
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Pose(Enum):
|
|
51
|
+
"""Coarse hand shape, evaluated fresh on every frame."""
|
|
52
|
+
|
|
53
|
+
NONE = "none"
|
|
54
|
+
OTHER = "other"
|
|
55
|
+
READY = "ready"
|
|
56
|
+
FIST = "fist"
|
|
57
|
+
OPEN_PALM = "open_palm"
|
|
58
|
+
TELEPHONE = "telephone"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class HandFeatures:
|
|
63
|
+
"""Scale-invariant measurements of one hand, plus its classified pose."""
|
|
64
|
+
|
|
65
|
+
handedness: str
|
|
66
|
+
score: float
|
|
67
|
+
pose: Pose
|
|
68
|
+
anchor: tuple[float, float]
|
|
69
|
+
palm_size: float
|
|
70
|
+
pinch_index: float
|
|
71
|
+
pinch_middle: float
|
|
72
|
+
extended: tuple[bool, bool, bool, bool, bool]
|
|
73
|
+
spread: float
|
|
74
|
+
facing: float
|
|
75
|
+
landmarks: np.ndarray
|
|
76
|
+
# The metric landmarks every measurement above was derived from. Kept so a
|
|
77
|
+
# recorded session can be re-measured under different thresholds; without it
|
|
78
|
+
# a recording would only ever prove the thresholds it was captured with.
|
|
79
|
+
world: np.ndarray
|
|
80
|
+
seen_handedness: str = ""
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def pinch(self) -> float:
|
|
84
|
+
"""Distance of whichever pinch is closest to closing."""
|
|
85
|
+
return min(self.pinch_index, self.pinch_middle)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def pinch_is_middle(self) -> bool:
|
|
89
|
+
"""True when the thumb is meeting the middle finger, not the index."""
|
|
90
|
+
return self.pinch_middle < self.pinch_index
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _norm(vector: np.ndarray) -> float:
|
|
94
|
+
return float(np.linalg.norm(vector))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def palm_span(points: np.ndarray) -> float:
|
|
98
|
+
"""Wrist-to-middle-knuckle distance: the unit every other measure divides by."""
|
|
99
|
+
return max(_norm(points[MIDDLE_MCP] - points[WRIST]), 1e-6)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def palm_normal(points: np.ndarray, handedness: str) -> float:
|
|
103
|
+
"""Signed z of the palm normal; positive means the palm faces the camera.
|
|
104
|
+
|
|
105
|
+
MediaPipe's z axis grows away from the camera, and the cross product's sign
|
|
106
|
+
flips between hands, so both are corrected here.
|
|
107
|
+
"""
|
|
108
|
+
edge_a = points[INDEX_MCP] - points[WRIST]
|
|
109
|
+
edge_b = points[PINKY_MCP] - points[WRIST]
|
|
110
|
+
normal = np.cross(edge_a, edge_b)
|
|
111
|
+
magnitude = _norm(normal)
|
|
112
|
+
if magnitude < 1e-9:
|
|
113
|
+
return 0.0
|
|
114
|
+
chirality = 1.0 if handedness.lower().startswith("r") else -1.0
|
|
115
|
+
return float(-normal[2] / magnitude) * chirality
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def measure(
|
|
119
|
+
world: np.ndarray,
|
|
120
|
+
image_points: np.ndarray,
|
|
121
|
+
handedness: str,
|
|
122
|
+
seen_handedness: str,
|
|
123
|
+
score: float,
|
|
124
|
+
thresholds: object,
|
|
125
|
+
) -> HandFeatures:
|
|
126
|
+
"""Turn 21 landmarks into features and a pose label.
|
|
127
|
+
|
|
128
|
+
Shape is measured from ``world`` (MediaPipe's metric landmarks) because those
|
|
129
|
+
axes share one scale; normalised image coordinates do not, and a pinch judged
|
|
130
|
+
in them would drift with the frame's aspect ratio. Position comes from
|
|
131
|
+
``image_points``, which is what the pointer actually needs.
|
|
132
|
+
|
|
133
|
+
``thresholds`` is a ``GestureConfig``, passed in rather than imported so the
|
|
134
|
+
classifier stays tunable from ``config.toml`` at runtime.
|
|
135
|
+
"""
|
|
136
|
+
points = world
|
|
137
|
+
span = palm_span(points)
|
|
138
|
+
wrist = points[WRIST]
|
|
139
|
+
|
|
140
|
+
# A finger is extended when its tip sits farther from the wrist than its
|
|
141
|
+
# middle joint. Comparing against the joint instead of a fixed length keeps
|
|
142
|
+
# the test honest for the short pinky and the long middle finger alike.
|
|
143
|
+
extended: list[bool] = []
|
|
144
|
+
for tip, pip in FINGERS:
|
|
145
|
+
pip_reach = max(_norm(points[pip] - wrist), 1e-6)
|
|
146
|
+
extended.append(_norm(points[tip] - wrist) / pip_reach > thresholds.finger_extended)
|
|
147
|
+
|
|
148
|
+
# The thumb rotates rather than curls, so it is measured by how far its tip
|
|
149
|
+
# has swung away from the far edge of the palm.
|
|
150
|
+
thumb_out = _norm(points[THUMB_TIP] - points[PINKY_MCP]) / span > thresholds.thumb_extended
|
|
151
|
+
flags = (thumb_out, *extended)
|
|
152
|
+
|
|
153
|
+
spread = float(np.mean([_norm(points[a] - points[b]) for a, b in pairwise(TIPS)]) / span)
|
|
154
|
+
# Chirality follows the hand as the camera sees it, which is what the cross
|
|
155
|
+
# product is computed from; a mirrored frame flips that but not the physical
|
|
156
|
+
# label carried in `handedness`.
|
|
157
|
+
facing = palm_normal(points, seen_handedness)
|
|
158
|
+
# The palm centre is the anchor rather than a fingertip: it barely moves when
|
|
159
|
+
# you pinch, so clicking does not shove the cursor off target.
|
|
160
|
+
anchor_point = image_points[list(PALM_POINTS)].mean(axis=0)
|
|
161
|
+
|
|
162
|
+
return HandFeatures(
|
|
163
|
+
handedness=handedness,
|
|
164
|
+
score=score,
|
|
165
|
+
pose=classify(flags, spread, facing, thresholds),
|
|
166
|
+
anchor=(float(anchor_point[0]), float(anchor_point[1])),
|
|
167
|
+
palm_size=span,
|
|
168
|
+
pinch_index=_norm(points[THUMB_TIP] - points[INDEX_TIP]) / span,
|
|
169
|
+
pinch_middle=_norm(points[THUMB_TIP] - points[MIDDLE_TIP]) / span,
|
|
170
|
+
extended=flags,
|
|
171
|
+
spread=spread,
|
|
172
|
+
facing=facing,
|
|
173
|
+
landmarks=image_points,
|
|
174
|
+
world=world,
|
|
175
|
+
seen_handedness=seen_handedness,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def classify(flags: tuple[bool, ...], spread: float, facing: float, thresholds: object) -> Pose:
|
|
180
|
+
"""Label a hand shape. Order matters: the specific poses are tested first."""
|
|
181
|
+
thumb, index, middle, ring, pinky = flags
|
|
182
|
+
|
|
183
|
+
if index and middle and ring and pinky:
|
|
184
|
+
# An open hand only means "open palm" when it is shown to the camera;
|
|
185
|
+
# otherwise it is just a hand that happens to be relaxed.
|
|
186
|
+
if spread >= thresholds.palm_spread and facing >= thresholds.palm_facing:
|
|
187
|
+
return Pose.OPEN_PALM
|
|
188
|
+
return Pose.OTHER
|
|
189
|
+
|
|
190
|
+
if thumb and pinky and not index and not middle and not ring:
|
|
191
|
+
return Pose.TELEPHONE
|
|
192
|
+
|
|
193
|
+
if not any((thumb, index, middle, ring, pinky)):
|
|
194
|
+
return Pose.FIST
|
|
195
|
+
|
|
196
|
+
# Thumb and index available to pinch, with the hand otherwise relaxed.
|
|
197
|
+
if index and thumb and not (ring and pinky):
|
|
198
|
+
return Pose.READY
|
|
199
|
+
|
|
200
|
+
return Pose.OTHER
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Gesture recognition: hand shapes and motion turned into intents."""
|