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/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()
|