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