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/session.py ADDED
@@ -0,0 +1,328 @@
1
+ """Recorded gesture sessions.
2
+
3
+ A session is a labelled capture of real hands: for every frame, the raw landmarks
4
+ plus the pose the user was *asked* to hold. That pairing is what makes the file
5
+ useful twice over.
6
+
7
+ Raw landmarks rather than finished measurements, because a recording of derived
8
+ numbers could only ever validate the thresholds it was captured with. Storing the
9
+ landmarks means any threshold can be re-evaluated against the same hands later --
10
+ which is exactly what `autotune` and the replay tests do.
11
+
12
+ The prompted label is the supervision signal. Knowing that these 150 frames were
13
+ meant to be a fist is what turns "here is a distribution of finger curl" into
14
+ "here is where the boundary between curled and extended belongs".
15
+
16
+ Format is JSON Lines: one self-describing header, then one object per frame, so a
17
+ session can be appended to as it is captured and read back in a streaming pass.
18
+
19
+ A frame holds one *view* per camera rather than a flat list of hands, mirroring
20
+ the `Observation` list the live pipeline fuses. Recording each camera separately
21
+ is what lets a replay exercise the fusion path; flattening them here would throw
22
+ away the disagreement between viewpoints that fusion exists to resolve.
23
+
24
+ Older single-camera recordings (format 1) are read as one view, because a
25
+ recording costs somebody a minute of holding poses and should not be invalidated
26
+ by a change to how the file is arranged.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import time
33
+ from collections.abc import Iterator
34
+ from dataclasses import dataclass, field
35
+ from pathlib import Path
36
+
37
+ import numpy as np
38
+
39
+ from .config import STATE_DIR
40
+ from .geometry import HandFeatures, measure
41
+
42
+ SESSIONS_DIR = STATE_DIR / "sessions"
43
+ FORMAT_VERSION = 2
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class RecordedHand:
48
+ """One hand in one frame, as landmarks."""
49
+
50
+ handedness: str
51
+ seen_handedness: str
52
+ score: float
53
+ world: np.ndarray
54
+ image: np.ndarray
55
+
56
+ def to_json(self) -> dict:
57
+ return {
58
+ "handedness": self.handedness,
59
+ "seen": self.seen_handedness,
60
+ "score": round(self.score, 4),
61
+ # Rounded to keep sessions readable and small; far finer than the
62
+ # tracker's own noise floor, so nothing measurable is lost.
63
+ "world": np.round(self.world, 5).tolist(),
64
+ "image": np.round(self.image, 5).tolist(),
65
+ }
66
+
67
+ @classmethod
68
+ def from_json(cls, data: dict) -> RecordedHand:
69
+ return cls(
70
+ handedness=data["handedness"],
71
+ seen_handedness=data.get("seen", data["handedness"]),
72
+ score=float(data["score"]),
73
+ world=np.array(data["world"], dtype=np.float32),
74
+ image=np.array(data["image"], dtype=np.float32),
75
+ )
76
+
77
+ def remeasure(self, thresholds) -> HandFeatures:
78
+ """Re-derive features under a given `GestureConfig`."""
79
+ return measure(
80
+ world=self.world,
81
+ image_points=self.image,
82
+ handedness=self.handedness,
83
+ seen_handedness=self.seen_handedness,
84
+ score=self.score,
85
+ thresholds=thresholds,
86
+ )
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class RecordedView:
91
+ """What one camera saw at one instant.
92
+
93
+ ``age_ms`` is how far behind the newest camera this view was, which is what
94
+ fusion uses to decide how much to trust it.
95
+ """
96
+
97
+ camera_id: int
98
+ hands: list[RecordedHand] = field(default_factory=list)
99
+ age_ms: float = 0.0
100
+
101
+ def to_json(self) -> dict:
102
+ return {
103
+ "camera": self.camera_id,
104
+ "age": round(self.age_ms, 2),
105
+ "hands": [hand.to_json() for hand in self.hands],
106
+ }
107
+
108
+ @classmethod
109
+ def from_json(cls, data: dict) -> RecordedView:
110
+ return cls(
111
+ camera_id=int(data["camera"]),
112
+ hands=[RecordedHand.from_json(h) for h in data.get("hands", [])],
113
+ age_ms=float(data.get("age", 0.0)),
114
+ )
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class RecordedFrame:
119
+ """One instant, across every camera, with the pose that was being asked for."""
120
+
121
+ time: float
122
+ label: str
123
+ views: list[RecordedView] = field(default_factory=list)
124
+
125
+ def to_json(self) -> dict:
126
+ return {
127
+ "t": round(self.time, 4),
128
+ "label": self.label,
129
+ "views": [view.to_json() for view in self.views],
130
+ }
131
+
132
+ @classmethod
133
+ def from_json(cls, data: dict) -> RecordedFrame:
134
+ if "views" in data:
135
+ views = [RecordedView.from_json(v) for v in data["views"]]
136
+ else:
137
+ # Format 1 stored a single camera's hands directly on the frame.
138
+ # Reading it as one view keeps existing recordings usable, which
139
+ # matters because a recording is a minute of somebody's time.
140
+ views = [
141
+ RecordedView(
142
+ camera_id=int(data.get("camera", 0)),
143
+ hands=[RecordedHand.from_json(h) for h in data.get("hands", [])],
144
+ )
145
+ ]
146
+ return cls(time=float(data["t"]), label=data["label"], views=views)
147
+
148
+ @property
149
+ def hands(self) -> list[RecordedHand]:
150
+ """Every hand seen by every camera, for analysis that ignores viewpoint."""
151
+ return [hand for view in self.views for hand in view.hands]
152
+
153
+ @property
154
+ def cameras(self) -> tuple[int, ...]:
155
+ return tuple(view.camera_id for view in self.views)
156
+
157
+
158
+ class SessionWriter:
159
+ """Appends frames to a session file as they are captured."""
160
+
161
+ def __init__(self, path: Path, note: str = "") -> None:
162
+ self.path = path
163
+ path.parent.mkdir(parents=True, exist_ok=True)
164
+ self._handle = path.open("w")
165
+ self._count = 0
166
+ self._write(
167
+ {
168
+ "format": FORMAT_VERSION,
169
+ "kind": "mindcontrol-session",
170
+ "created": time.strftime("%Y-%m-%dT%H:%M:%S"),
171
+ "note": note,
172
+ }
173
+ )
174
+
175
+ def _write(self, payload: dict) -> None:
176
+ self._handle.write(json.dumps(payload) + "\n")
177
+
178
+ def add(self, frame: RecordedFrame) -> None:
179
+ self._write(frame.to_json())
180
+ self._count += 1
181
+
182
+ @property
183
+ def frames(self) -> int:
184
+ return self._count
185
+
186
+ def close(self) -> None:
187
+ self._handle.flush()
188
+ self._handle.close()
189
+
190
+ def __enter__(self) -> SessionWriter:
191
+ return self
192
+
193
+ def __exit__(self, *_exc) -> None:
194
+ self.close()
195
+
196
+
197
+ @dataclass
198
+ class Session:
199
+ """A whole recording, in memory."""
200
+
201
+ frames: list[RecordedFrame]
202
+ header: dict = field(default_factory=dict)
203
+ path: Path | None = None
204
+
205
+ @classmethod
206
+ def load(cls, path: Path) -> Session:
207
+ header: dict = {}
208
+ frames: list[RecordedFrame] = []
209
+ with path.open() as handle:
210
+ for line in handle:
211
+ line = line.strip()
212
+ if not line:
213
+ continue
214
+ data = json.loads(line)
215
+ if data.get("kind") == "mindcontrol-session":
216
+ header = data
217
+ continue
218
+ frames.append(RecordedFrame.from_json(data))
219
+ version = int(header.get("format", FORMAT_VERSION))
220
+ if version > FORMAT_VERSION:
221
+ raise ValueError(
222
+ f"{path} is format {version}, newer than this build understands "
223
+ f"({FORMAT_VERSION}); upgrade mindcontrol"
224
+ )
225
+ return cls(frames=frames, header=header, path=path)
226
+
227
+ def labels(self) -> list[str]:
228
+ """Distinct labels, in the order they were recorded."""
229
+ seen: dict[str, None] = {}
230
+ for frame in self.frames:
231
+ seen.setdefault(frame.label, None)
232
+ return list(seen)
233
+
234
+ def segment(self, *labels: str) -> Iterator[RecordedFrame]:
235
+ """Frames recorded under any of the given labels."""
236
+ wanted = set(labels)
237
+ for frame in self.frames:
238
+ if frame.label in wanted:
239
+ yield frame
240
+
241
+ def hands(self, *labels: str) -> Iterator[RecordedHand]:
242
+ """Every hand recorded under the given labels, ignoring frame grouping."""
243
+ for frame in self.segment(*labels):
244
+ yield from frame.hands
245
+
246
+ @property
247
+ def cameras(self) -> tuple[int, ...]:
248
+ """Every camera that contributed to this recording."""
249
+ seen: dict[int, None] = {}
250
+ for frame in self.frames:
251
+ for camera_id in frame.cameras:
252
+ seen.setdefault(camera_id, None)
253
+ return tuple(sorted(seen))
254
+
255
+ def problems(self) -> list[str]:
256
+ """Ways this recording cannot answer the questions asked of it.
257
+
258
+ Worth reporting loudly. A threshold fitted from a prompt where the other
259
+ hand was also in shot is not a measurement of anything, but it looks
260
+ exactly like one -- so the recording has to be able to say when it is not
261
+ fit to be fitted.
262
+ """
263
+ found: list[str] = []
264
+ cameras = max(len(self.cameras), 1)
265
+
266
+ for label in self.labels():
267
+ frames = list(self.segment(label))
268
+ if not frames:
269
+ continue
270
+
271
+ with_hands = [f for f in frames if f.hands]
272
+ if label == "none":
273
+ share = len(with_hands) / len(frames)
274
+ if share > 0.1:
275
+ found.append(
276
+ f"'{label}': a hand was visible in {share:.0%} of frames; "
277
+ "the baseline needs both hands right out of shot"
278
+ )
279
+ continue
280
+
281
+ if len(with_hands) < len(frames) * 0.5:
282
+ found.append(
283
+ f"'{label}': a hand was found in only "
284
+ f"{len(with_hands) / len(frames):.0%} of frames"
285
+ )
286
+ continue
287
+
288
+ # More hands than cameras means a second hand was in shot, and its
289
+ # shape is being recorded under this prompt's label.
290
+ crowded = [f for f in with_hands if len(f.hands) > cameras]
291
+ if len(crowded) > len(with_hands) * 0.25:
292
+ found.append(
293
+ f"'{label}': two hands were in shot for "
294
+ f"{len(crowded) / len(with_hands):.0%} of frames; only the "
295
+ "performing hand should be visible"
296
+ )
297
+
298
+ return found
299
+
300
+ def summary(self) -> str:
301
+ counts: dict[str, int] = {}
302
+ hands = 0
303
+ for frame in self.frames:
304
+ counts[frame.label] = counts.get(frame.label, 0) + 1
305
+ hands += len(frame.hands)
306
+ parts = ", ".join(f"{label} {count}" for label, count in counts.items())
307
+ rig = f"{len(self.cameras)} camera(s) {list(self.cameras)}"
308
+ return f"{len(self.frames)} frames ({hands} hand samples, {rig}): {parts}"
309
+
310
+
311
+ def latest_session() -> Path | None:
312
+ """Most recently recorded session, if any."""
313
+ if not SESSIONS_DIR.is_dir():
314
+ return None
315
+ files = sorted(SESSIONS_DIR.glob("*.jsonl"), key=lambda p: p.stat().st_mtime)
316
+ return files[-1] if files else None
317
+
318
+
319
+ def resolve(path: Path | None) -> Path:
320
+ """Pick an explicit session, or fall back to the newest one."""
321
+ if path is not None:
322
+ return path
323
+ found = latest_session()
324
+ if found is None:
325
+ raise FileNotFoundError(
326
+ f"no recordings in {SESSIONS_DIR}; run 'mindcontrol record' first"
327
+ )
328
+ return found
@@ -0,0 +1 @@
1
+ """Perception wrappers around the MediaPipe task models."""
@@ -0,0 +1,272 @@
1
+ """Eye tracking and the gaze-to-screen model.
2
+
3
+ Where you are looking is not directly observable from a webcam, so it is
4
+ *learned*: calibration shows you nine dots, records what your eyes and head look
5
+ like for each, and fits a small regression from those features to screen
6
+ coordinates. Inference then runs that regression per frame.
7
+
8
+ The feature vector is built in one place, `feature_vector`, and used by both
9
+ calibration and inference. If the two ever built it differently the model would
10
+ silently predict nonsense, so there is deliberately only one definition.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import math
17
+ import time
18
+ from collections import deque
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ import cv2
23
+ import mediapipe as mp
24
+ import numpy as np
25
+ from mediapipe.tasks import python as mp_tasks
26
+ from mediapipe.tasks.python import vision
27
+
28
+ from .. import models
29
+ from ..capture import Frame
30
+ from ..config import TrackingConfig
31
+
32
+ # Indices into MediaPipe's 478-point face mesh (the last ten are the irises).
33
+ RIGHT_EYE_OUTER, RIGHT_EYE_INNER = 33, 133
34
+ LEFT_EYE_INNER, LEFT_EYE_OUTER = 362, 263
35
+ RIGHT_IRIS, LEFT_IRIS = 468, 473
36
+ RIGHT_LID_TOP, RIGHT_LID_BOTTOM = 159, 145
37
+ LEFT_LID_TOP, LEFT_LID_BOTTOM = 386, 374
38
+ IRIS_MESH_POINTS = 478
39
+
40
+ FEATURE_NAMES = (
41
+ "bias",
42
+ "iris_x",
43
+ "iris_y",
44
+ "head_x",
45
+ "head_y",
46
+ "iris_x^2",
47
+ "iris_y^2",
48
+ "iris_x*iris_y",
49
+ "iris_x*head_x",
50
+ "iris_y*head_y",
51
+ )
52
+ FEATURE_COUNT = len(FEATURE_NAMES)
53
+
54
+
55
+ def feature_vector(iris: tuple[float, float], head: tuple[float, float]) -> np.ndarray:
56
+ """Build the regression input from eye offset and head rotation.
57
+
58
+ Quadratic and cross terms are included because the mapping is not linear:
59
+ the same eye offset lands somewhere different depending on how your head is
60
+ turned, and screen edges compress relative to the centre.
61
+ """
62
+ ix, iy = iris
63
+ hx, hy = head
64
+ return np.array(
65
+ [1.0, ix, iy, hx, hy, ix * ix, iy * iy, ix * iy, ix * hx, iy * hy],
66
+ dtype=np.float64,
67
+ )
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class GazeObservation:
72
+ """What one frame tells us about the eyes."""
73
+
74
+ present: bool
75
+ features: np.ndarray | None = None
76
+ openness: float = 0.0
77
+ iris_points: tuple[tuple[float, float], ...] = ()
78
+
79
+ @property
80
+ def usable(self) -> bool:
81
+ return self.present and self.features is not None
82
+
83
+
84
+ class GazeTracker:
85
+ """Extracts gaze features from the camera trusted for gaze."""
86
+
87
+ def __init__(self, cfg: TrackingConfig) -> None:
88
+ self._last_timestamp = -1
89
+ self._warned_no_iris = False
90
+ options = vision.FaceLandmarkerOptions(
91
+ base_options=mp_tasks.BaseOptions(
92
+ model_asset_path=str(models.ensure("face_landmarker.task"))
93
+ ),
94
+ running_mode=vision.RunningMode.VIDEO,
95
+ num_faces=1,
96
+ min_face_detection_confidence=cfg.face_detection_confidence,
97
+ min_tracking_confidence=cfg.hand_tracking_confidence,
98
+ # The head's own rotation is half of where you are looking.
99
+ output_facial_transformation_matrixes=True,
100
+ )
101
+ self._landmarker = vision.FaceLandmarker.create_from_options(options)
102
+
103
+ def process(self, frame: Frame) -> GazeObservation:
104
+ image = mp.Image(
105
+ image_format=mp.ImageFormat.SRGB,
106
+ data=cv2.cvtColor(frame.image, cv2.COLOR_BGR2RGB),
107
+ )
108
+ timestamp = max(frame.timestamp_ms, self._last_timestamp + 1)
109
+ self._last_timestamp = timestamp
110
+ result = self._landmarker.detect_for_video(image, timestamp)
111
+ if not result.face_landmarks:
112
+ return GazeObservation(present=False)
113
+
114
+ landmarks = result.face_landmarks[0]
115
+ points = np.array([[p.x, p.y] for p in landmarks], dtype=np.float64)
116
+ has_iris = len(landmarks) >= IRIS_MESH_POINTS
117
+ if not has_iris and not self._warned_no_iris:
118
+ print("[gaze] face model returned no iris points; falling back to head-only gaze")
119
+ self._warned_no_iris = True
120
+
121
+ right = _eye_offset(points, RIGHT_EYE_OUTER, RIGHT_EYE_INNER, RIGHT_IRIS, has_iris)
122
+ left = _eye_offset(points, LEFT_EYE_OUTER, LEFT_EYE_INNER, LEFT_IRIS, has_iris)
123
+ iris = ((right[0] + left[0]) / 2.0, (right[1] + left[1]) / 2.0)
124
+
125
+ head = _head_rotation(result.facial_transformation_matrixes)
126
+ openness = min(
127
+ _aspect(points, RIGHT_LID_TOP, RIGHT_LID_BOTTOM, RIGHT_EYE_OUTER, RIGHT_EYE_INNER),
128
+ _aspect(points, LEFT_LID_TOP, LEFT_LID_BOTTOM, LEFT_EYE_OUTER, LEFT_EYE_INNER),
129
+ )
130
+ iris_points = (tuple(points[RIGHT_IRIS]), tuple(points[LEFT_IRIS])) if has_iris else ()
131
+ return GazeObservation(
132
+ present=True,
133
+ features=feature_vector(iris, head),
134
+ openness=openness,
135
+ iris_points=iris_points, # type: ignore[arg-type]
136
+ )
137
+
138
+ def close(self) -> None:
139
+ self._landmarker.close()
140
+
141
+
142
+ def _eye_offset(
143
+ points: np.ndarray, outer: int, inner: int, iris: int, has_iris: bool
144
+ ) -> tuple[float, float]:
145
+ """Iris displacement from the eye's centre, in units of eye width.
146
+
147
+ Dividing by eye width is what makes this survive leaning toward or away from
148
+ the camera: the eye shrinks in the image but the ratio holds.
149
+ """
150
+ corner_a, corner_b = points[outer], points[inner]
151
+ width = max(float(np.linalg.norm(corner_a - corner_b)), 1e-6)
152
+ centre = (corner_a + corner_b) / 2.0
153
+ if not has_iris:
154
+ return 0.0, 0.0
155
+ offset = (points[iris] - centre) / width
156
+ return float(offset[0]), float(offset[1])
157
+
158
+
159
+ def _head_rotation(matrices: list) -> tuple[float, float]:
160
+ """Head yaw and pitch in radians, from the face transformation matrix."""
161
+ if not matrices:
162
+ return 0.0, 0.0
163
+ rotation = np.asarray(matrices[0])[:3, :3]
164
+ magnitude = math.hypot(rotation[0, 0], rotation[1, 0])
165
+ if magnitude < 1e-6:
166
+ return 0.0, 0.0
167
+ yaw = math.atan2(-rotation[2, 0], magnitude)
168
+ pitch = math.atan2(rotation[2, 1], rotation[2, 2])
169
+ return yaw, pitch
170
+
171
+
172
+ def _aspect(points: np.ndarray, top: int, bottom: int, outer: int, inner: int) -> float:
173
+ """Eye aspect ratio: lid separation over eye width. Collapses toward 0 on a blink."""
174
+ width = max(float(np.linalg.norm(points[outer] - points[inner])), 1e-6)
175
+ return float(np.linalg.norm(points[top] - points[bottom])) / width
176
+
177
+
178
+ class GazeModel:
179
+ """Ridge regression from gaze features to a point on screen."""
180
+
181
+ def __init__(self, weights: np.ndarray | None = None, quality: float = 0.0) -> None:
182
+ self.weights = weights
183
+ self.quality = quality
184
+
185
+ @property
186
+ def ready(self) -> bool:
187
+ return self.weights is not None
188
+
189
+ @classmethod
190
+ def fit(cls, features: np.ndarray, targets: np.ndarray, ridge: float = 1e-3) -> GazeModel:
191
+ """Solve for the feature-to-screen mapping.
192
+
193
+ Ridge rather than plain least squares: nine calibration points give
194
+ nearly collinear features, and unregularised weights would blow up and
195
+ fling the cursor off screen.
196
+ """
197
+ gram = features.T @ features + ridge * np.eye(features.shape[1])
198
+ weights = np.linalg.solve(gram, features.T @ targets)
199
+ residual = features @ weights - targets
200
+ error = float(np.sqrt(np.mean(np.sum(residual**2, axis=1))))
201
+ return cls(weights=weights, quality=error)
202
+
203
+ def predict(self, features: np.ndarray) -> tuple[float, float]:
204
+ """Screen position as fractions of width and height, clamped on screen."""
205
+ if self.weights is None:
206
+ raise RuntimeError("gaze model is not calibrated")
207
+ point = features @ self.weights
208
+ return float(np.clip(point[0], 0.0, 1.0)), float(np.clip(point[1], 0.0, 1.0))
209
+
210
+ def save(self, path: Path) -> None:
211
+ path.parent.mkdir(parents=True, exist_ok=True)
212
+ assert self.weights is not None
213
+ path.write_text(
214
+ json.dumps(
215
+ {
216
+ "version": 1,
217
+ "features": list(FEATURE_NAMES),
218
+ "weights": self.weights.tolist(),
219
+ "rms_error": self.quality,
220
+ "created": time.strftime("%Y-%m-%dT%H:%M:%S"),
221
+ },
222
+ indent=2,
223
+ )
224
+ )
225
+
226
+ @classmethod
227
+ def load(cls, path: Path) -> GazeModel:
228
+ """Load a saved model, or an uncalibrated one if it is missing or stale."""
229
+ if not path.is_file():
230
+ return cls()
231
+ try:
232
+ data = json.loads(path.read_text())
233
+ weights = np.array(data["weights"], dtype=np.float64)
234
+ except (json.JSONDecodeError, KeyError, ValueError) as exc:
235
+ print(f"[gaze] ignoring unreadable calibration at {path}: {exc}")
236
+ return cls()
237
+ if weights.shape != (FEATURE_COUNT, 2):
238
+ print("[gaze] calibration was built for a different feature set; recalibrate")
239
+ return cls()
240
+ return cls(weights=weights, quality=float(data.get("rms_error", 0.0)))
241
+
242
+
243
+ class FixationDetector:
244
+ """Reports where gaze has settled, ignoring the constant flicker of saccades.
245
+
246
+ A cursor that chased raw gaze would be unusable, since the eye never truly
247
+ holds still. Only once the recent samples all fall inside a small radius is
248
+ the gaze treated as a deliberate target.
249
+ """
250
+
251
+ def __init__(self, window_ms: float, radius: float) -> None:
252
+ self._window_s = window_ms / 1000.0
253
+ self._radius = radius
254
+ self._samples: deque[tuple[float, float, float]] = deque()
255
+
256
+ def update(self, x: float, y: float, now: float | None = None) -> tuple[float, float] | None:
257
+ now = time.monotonic() if now is None else now
258
+ self._samples.append((now, x, y))
259
+ cutoff = now - self._window_s
260
+ while self._samples and self._samples[0][0] < cutoff:
261
+ self._samples.popleft()
262
+ if len(self._samples) < 3 or self._samples[0][0] > cutoff + self._window_s:
263
+ return None
264
+
265
+ points = np.array([[sx, sy] for _, sx, sy in self._samples])
266
+ centre = points.mean(axis=0)
267
+ if float(np.max(np.linalg.norm(points - centre, axis=1))) > self._radius:
268
+ return None
269
+ return float(centre[0]), float(centre[1])
270
+
271
+ def reset(self) -> None:
272
+ self._samples.clear()