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/record.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Guided capture of a labelled gesture session.
|
|
2
|
+
|
|
3
|
+
Walks you through each pose in turn and records what your hands actually measure
|
|
4
|
+
while you hold it. The prompt is the label, so the resulting file is supervised
|
|
5
|
+
data: not just "here are some hand shapes" but "here are 150 frames that were
|
|
6
|
+
definitely meant to be a fist".
|
|
7
|
+
|
|
8
|
+
Two prompts are deliberately about *transitions* rather than poses -- pinching
|
|
9
|
+
repeatedly, and swiping -- because clicks and swipes are events in time, and a
|
|
10
|
+
threshold fitted only to held poses would have nothing to say about them.
|
|
11
|
+
|
|
12
|
+
Every configured camera is recorded, not just the primary, so a replay can drive
|
|
13
|
+
the fusion path with genuine cross-viewpoint disagreement rather than the same
|
|
14
|
+
image twice.
|
|
15
|
+
|
|
16
|
+
Runs in the foreground: it owns the cameras and a preview window, and it wants
|
|
17
|
+
your attention for about a minute.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import cv2
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
from .capture import CameraBank, Frame
|
|
31
|
+
from .config import Config, load
|
|
32
|
+
from .geometry import SKELETON, HandFeatures
|
|
33
|
+
from .logs import muffled
|
|
34
|
+
from .session import SESSIONS_DIR, RecordedFrame, RecordedHand, RecordedView, SessionWriter
|
|
35
|
+
from .tracking.hands import HandTracker
|
|
36
|
+
|
|
37
|
+
# How long to wait for every camera to produce its first frame before starting.
|
|
38
|
+
# Generous because a phone joined over Continuity has been measured taking most of
|
|
39
|
+
# five seconds, and timing it out would silently record without it.
|
|
40
|
+
WARM_TIMEOUT_S = 12.0
|
|
41
|
+
|
|
42
|
+
WINDOW = "mindcontrol recorder"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Prompt:
|
|
47
|
+
label: str
|
|
48
|
+
seconds: float
|
|
49
|
+
instruction: str
|
|
50
|
+
detail: str = ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Order matters: the baseline comes first so that "no hand" is on record, and the
|
|
54
|
+
# unambiguous held poses come before the fiddly transitions.
|
|
55
|
+
#
|
|
56
|
+
# ONE HAND ONLY, throughout. A second hand resting in frame is not idle data --
|
|
57
|
+
# it is a different pose wearing the same label, and it corrupts every threshold
|
|
58
|
+
# fitted from that prompt. The instructions say so repeatedly on purpose.
|
|
59
|
+
#
|
|
60
|
+
# Prompts that drive a gesture the app reads from *motion* -- scrolling with a
|
|
61
|
+
# fist, swiping with a palm -- ask for that motion. A held-still fist records the
|
|
62
|
+
# shape but says nothing about whether scrolling works.
|
|
63
|
+
SCRIPT: tuple[Prompt, ...] = (
|
|
64
|
+
Prompt(
|
|
65
|
+
"none",
|
|
66
|
+
3.0,
|
|
67
|
+
"Both hands right out of frame",
|
|
68
|
+
"Drop them in your lap. Nothing visible at all.",
|
|
69
|
+
),
|
|
70
|
+
Prompt(
|
|
71
|
+
"ready",
|
|
72
|
+
5.0,
|
|
73
|
+
"ONE hand: the READY pose",
|
|
74
|
+
"Index and thumb out, relaxed. Drift around slowly. Other hand away.",
|
|
75
|
+
),
|
|
76
|
+
Prompt(
|
|
77
|
+
"pinch_closed",
|
|
78
|
+
5.0,
|
|
79
|
+
"ONE hand: thumb to INDEX, other THREE fingers STAYING OUT",
|
|
80
|
+
"Do not ball your hand up -- a curled pinch is a fist and scrolls instead.",
|
|
81
|
+
),
|
|
82
|
+
Prompt(
|
|
83
|
+
"pinch_cycle",
|
|
84
|
+
9.0,
|
|
85
|
+
"ONE hand: QUICK taps, thumb to index, three fingers OUT",
|
|
86
|
+
"Shut and open immediately. A pinch held over ~1/4 second is a drag.",
|
|
87
|
+
),
|
|
88
|
+
Prompt(
|
|
89
|
+
"pinch_middle_closed",
|
|
90
|
+
5.0,
|
|
91
|
+
"ONE hand: pinch thumb to MIDDLE finger",
|
|
92
|
+
"Curl ring and little finger in too. Other hand away.",
|
|
93
|
+
),
|
|
94
|
+
Prompt(
|
|
95
|
+
"fist",
|
|
96
|
+
6.0,
|
|
97
|
+
"ONE hand: fist, and scroll it up and down",
|
|
98
|
+
"Thumb wrapped in. Move as if dragging a page. Other hand away.",
|
|
99
|
+
),
|
|
100
|
+
Prompt(
|
|
101
|
+
"open_palm",
|
|
102
|
+
5.0,
|
|
103
|
+
"ONE hand: open palm at the camera, still",
|
|
104
|
+
"All five fingers out, facing the lens. Hold it. Other hand away.",
|
|
105
|
+
),
|
|
106
|
+
Prompt(
|
|
107
|
+
"telephone",
|
|
108
|
+
4.0,
|
|
109
|
+
"ONE hand: thumb and little finger out",
|
|
110
|
+
"The 'call me' hand. Other hand away.",
|
|
111
|
+
),
|
|
112
|
+
Prompt(
|
|
113
|
+
"swipe",
|
|
114
|
+
8.0,
|
|
115
|
+
"ONE hand: open palm, sweep left and right, palm FLAT to the lens",
|
|
116
|
+
"Brisk sweeps, fingers spread the whole way. Do not let the palm rotate.",
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
COUNTDOWN_S = 2.0
|
|
121
|
+
|
|
122
|
+
# Subsets worth recording on their own, when the full script already worked for
|
|
123
|
+
# everything else and one gesture needs another attempt.
|
|
124
|
+
#
|
|
125
|
+
# Each group carries the prompts its fit *depends on*, not just the failing one.
|
|
126
|
+
# A threshold is a boundary between two clusters, so re-recording only the low
|
|
127
|
+
# side leaves nothing to separate it from: fitting `pinch_close` needs open hands
|
|
128
|
+
# to contrast against, and a pinch alone would simply be declined.
|
|
129
|
+
FOCUS: dict[str, tuple[str, ...]] = {
|
|
130
|
+
# A fist is in the pinch group because `pinch_close` has to sit below it, not
|
|
131
|
+
# just below an open hand: a fist measures as pinched, and a pinch that fires
|
|
132
|
+
# on the way into one suppresses scrolling for the rest of the gesture.
|
|
133
|
+
"pinch": ("none", "ready", "open_palm", "fist", "pinch_closed", "pinch_cycle"),
|
|
134
|
+
"swipe": ("none", "open_palm", "swipe"),
|
|
135
|
+
"poses": ("none", "ready", "fist", "open_palm", "telephone"),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def select(focus: tuple[str, ...] | None) -> tuple[Prompt, ...]:
|
|
140
|
+
"""The prompts to run, in script order, for the named focus groups."""
|
|
141
|
+
if not focus:
|
|
142
|
+
return SCRIPT
|
|
143
|
+
unknown = set(focus) - set(FOCUS)
|
|
144
|
+
if unknown:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"no such focus: {', '.join(sorted(unknown))}; try {', '.join(sorted(FOCUS))}"
|
|
147
|
+
)
|
|
148
|
+
wanted = {label for name in focus for label in FOCUS[name]}
|
|
149
|
+
return tuple(prompt for prompt in SCRIPT if prompt.label in wanted)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Rig:
|
|
153
|
+
"""Every configured camera, each with its own tracker."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, cfg: Config) -> None:
|
|
156
|
+
self.bank = CameraBank(cfg.cameras)
|
|
157
|
+
self.problems = self.bank.start()
|
|
158
|
+
# Muffled because this is a guided session: the user has to read the
|
|
159
|
+
# prompts, and MediaPipe prints six lines of startup trivia per camera.
|
|
160
|
+
with muffled():
|
|
161
|
+
self.trackers = {
|
|
162
|
+
camera_id: HandTracker(cfg.tracking, cfg.gestures, cfg.cameras.mirror)
|
|
163
|
+
for camera_id in self.bank.workers
|
|
164
|
+
}
|
|
165
|
+
self._seen: dict[int, int] = {}
|
|
166
|
+
|
|
167
|
+
def __len__(self) -> int:
|
|
168
|
+
return len(self.bank)
|
|
169
|
+
|
|
170
|
+
def warm(self, timeout_s: float = WARM_TIMEOUT_S) -> list[int]:
|
|
171
|
+
"""Wait for every camera to deliver a frame, priming the trackers.
|
|
172
|
+
|
|
173
|
+
A camera that is still waking contributes no views, and frames recorded
|
|
174
|
+
during that window are indistinguishable, later, from a camera that saw
|
|
175
|
+
nothing -- so the baseline prompt would inherit a fault that was really
|
|
176
|
+
just a cold start. USB and Continuity cameras are the slow ones.
|
|
177
|
+
|
|
178
|
+
Priming here also absorbs MediaPipe's remaining log line, which comes from
|
|
179
|
+
its first inference rather than from construction and would otherwise land
|
|
180
|
+
on top of the first prompt.
|
|
181
|
+
|
|
182
|
+
Returns the cameras that never woke.
|
|
183
|
+
"""
|
|
184
|
+
deadline = time.monotonic() + timeout_s
|
|
185
|
+
awake: set[int] = set()
|
|
186
|
+
with muffled():
|
|
187
|
+
while time.monotonic() < deadline and len(awake) < len(self.trackers):
|
|
188
|
+
frames, _, _ = self.read()
|
|
189
|
+
awake |= frames.keys()
|
|
190
|
+
time.sleep(0.02)
|
|
191
|
+
return sorted(set(self.trackers) - awake)
|
|
192
|
+
|
|
193
|
+
def read(self) -> tuple[dict[int, Frame], dict[int, list[HandFeatures]], bool]:
|
|
194
|
+
"""Latest frame and measured hands per camera.
|
|
195
|
+
|
|
196
|
+
``fresh`` reports whether any camera produced a new image, so the caller
|
|
197
|
+
can redraw the preview continuously while only recording real frames.
|
|
198
|
+
"""
|
|
199
|
+
frames = self.bank.latest()
|
|
200
|
+
fresh = False
|
|
201
|
+
for camera_id, frame in frames.items():
|
|
202
|
+
if self._seen.get(camera_id) != frame.sequence:
|
|
203
|
+
self._seen[camera_id] = frame.sequence
|
|
204
|
+
fresh = True
|
|
205
|
+
hands = {
|
|
206
|
+
camera_id: self.trackers[camera_id].process(frame)
|
|
207
|
+
for camera_id, frame in frames.items()
|
|
208
|
+
if camera_id in self.trackers
|
|
209
|
+
}
|
|
210
|
+
return frames, hands, fresh
|
|
211
|
+
|
|
212
|
+
def views(self, frames: dict[int, Frame], hands: dict[int, list[HandFeatures]]):
|
|
213
|
+
"""Turn one instant into recordable per-camera views."""
|
|
214
|
+
if not frames:
|
|
215
|
+
return []
|
|
216
|
+
newest = max(frame.timestamp_ms for frame in frames.values())
|
|
217
|
+
return [
|
|
218
|
+
RecordedView(
|
|
219
|
+
camera_id=camera_id,
|
|
220
|
+
age_ms=float(newest - frames[camera_id].timestamp_ms),
|
|
221
|
+
hands=[
|
|
222
|
+
RecordedHand(
|
|
223
|
+
handedness=hand.handedness,
|
|
224
|
+
seen_handedness=hand.seen_handedness,
|
|
225
|
+
score=hand.score,
|
|
226
|
+
world=hand.world,
|
|
227
|
+
image=hand.landmarks,
|
|
228
|
+
)
|
|
229
|
+
for hand in found
|
|
230
|
+
],
|
|
231
|
+
)
|
|
232
|
+
for camera_id, found in hands.items()
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
def close(self) -> None:
|
|
236
|
+
# Muffled too: closing a graph is when MediaPipe's telemetry uploader
|
|
237
|
+
# gives up trying to reach Google, once per camera, at error level. Those
|
|
238
|
+
# lines land on top of the "wrote N frames" line that matters.
|
|
239
|
+
with muffled():
|
|
240
|
+
for tracker in self.trackers.values():
|
|
241
|
+
tracker.close()
|
|
242
|
+
self.bank.stop()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def run(
|
|
246
|
+
cfg: Config | None = None,
|
|
247
|
+
out: Path | None = None,
|
|
248
|
+
note: str = "",
|
|
249
|
+
focus: tuple[str, ...] | None = None,
|
|
250
|
+
) -> int:
|
|
251
|
+
"""Record a session. Returns a process exit code."""
|
|
252
|
+
cfg = cfg or load()
|
|
253
|
+
try:
|
|
254
|
+
script = select(focus)
|
|
255
|
+
except ValueError as bad:
|
|
256
|
+
print(f"[record] {bad}", file=sys.stderr)
|
|
257
|
+
return 2
|
|
258
|
+
rig = Rig(cfg)
|
|
259
|
+
for problem in rig.problems:
|
|
260
|
+
print(f"[record] {problem}", file=sys.stderr)
|
|
261
|
+
if not len(rig):
|
|
262
|
+
print("[record] no cameras available", file=sys.stderr)
|
|
263
|
+
rig.close()
|
|
264
|
+
return 2
|
|
265
|
+
|
|
266
|
+
print(f"[record] waking {len(rig)} camera(s)...", flush=True)
|
|
267
|
+
for camera_id in rig.warm():
|
|
268
|
+
print(
|
|
269
|
+
f"[record] camera {camera_id} never woke; recording without it",
|
|
270
|
+
file=sys.stderr,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
print(f"[record] recording from camera(s) {sorted(rig.trackers)}")
|
|
274
|
+
path = out or SESSIONS_DIR / f"session-{time.strftime('%Y%m%d-%H%M%S')}.jsonl"
|
|
275
|
+
cv2.namedWindow(WINDOW, cv2.WINDOW_AUTOSIZE)
|
|
276
|
+
|
|
277
|
+
aborted = False
|
|
278
|
+
started = time.monotonic()
|
|
279
|
+
frames = 0
|
|
280
|
+
try:
|
|
281
|
+
with SessionWriter(path, note=note) as writer:
|
|
282
|
+
for index, prompt in enumerate(script):
|
|
283
|
+
if not _countdown(rig, prompt, index, len(script)) or not _capture(
|
|
284
|
+
rig, prompt, index, len(script), writer, started
|
|
285
|
+
):
|
|
286
|
+
aborted = True
|
|
287
|
+
break
|
|
288
|
+
frames = writer.frames
|
|
289
|
+
finally:
|
|
290
|
+
cv2.destroyAllWindows()
|
|
291
|
+
for _ in range(4):
|
|
292
|
+
cv2.waitKey(1)
|
|
293
|
+
rig.close()
|
|
294
|
+
|
|
295
|
+
if aborted:
|
|
296
|
+
print(f"[record] cancelled; partial session kept at {path}")
|
|
297
|
+
return 1
|
|
298
|
+
print(f"[record] wrote {frames} frames to {path}")
|
|
299
|
+
print("[record] next: mindcontrol autotune")
|
|
300
|
+
return 0
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
TILE_WIDTH = 640
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _tile(frame: Frame, hands: list[HandFeatures], colour, camera_id: int):
|
|
307
|
+
"""One camera's frame, annotated with its own skeletons and readout."""
|
|
308
|
+
image = frame.image
|
|
309
|
+
canvas = cv2.resize(image, (TILE_WIDTH, int(image.shape[0] * TILE_WIDTH / image.shape[1])))
|
|
310
|
+
height, width = canvas.shape[:2]
|
|
311
|
+
|
|
312
|
+
for hand in hands:
|
|
313
|
+
points = [(int(p[0] * width), int(p[1] * height)) for p in hand.landmarks[:, :2]]
|
|
314
|
+
for a, b in SKELETON:
|
|
315
|
+
cv2.line(canvas, points[a], points[b], colour, 1, cv2.LINE_AA)
|
|
316
|
+
|
|
317
|
+
label = f"cam {camera_id}"
|
|
318
|
+
if hands:
|
|
319
|
+
first = hands[0]
|
|
320
|
+
label += (
|
|
321
|
+
f" {first.handedness} {first.pose.value}"
|
|
322
|
+
f" pinch {first.pinch_index:.2f}/{first.pinch_middle:.2f}"
|
|
323
|
+
)
|
|
324
|
+
else:
|
|
325
|
+
label += " no hand"
|
|
326
|
+
cv2.rectangle(canvas, (0, height - 26), (width, height), (18, 18, 18), -1)
|
|
327
|
+
cv2.putText(
|
|
328
|
+
canvas, label, (8, height - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.48, colour, 1, cv2.LINE_AA
|
|
329
|
+
)
|
|
330
|
+
return canvas
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _draw(
|
|
334
|
+
rig: Rig,
|
|
335
|
+
prompt: Prompt,
|
|
336
|
+
index: int,
|
|
337
|
+
total: int,
|
|
338
|
+
banner: str,
|
|
339
|
+
progress: float,
|
|
340
|
+
recording: bool,
|
|
341
|
+
):
|
|
342
|
+
"""Render every camera side by side. Returns what was seen this instant."""
|
|
343
|
+
frames, hands, fresh = rig.read()
|
|
344
|
+
if not frames:
|
|
345
|
+
return frames, hands, fresh
|
|
346
|
+
|
|
347
|
+
colour = (90, 220, 120) if recording else (200, 200, 200)
|
|
348
|
+
tiles = [
|
|
349
|
+
_tile(frames[camera_id], hands.get(camera_id, []), colour, camera_id)
|
|
350
|
+
for camera_id in sorted(frames)
|
|
351
|
+
]
|
|
352
|
+
# Cameras can differ in aspect ratio, so pad to the tallest before stacking.
|
|
353
|
+
tallest = max(tile.shape[0] for tile in tiles)
|
|
354
|
+
padded = [
|
|
355
|
+
tile
|
|
356
|
+
if tile.shape[0] == tallest
|
|
357
|
+
else np.vstack(
|
|
358
|
+
[tile, np.zeros((tallest - tile.shape[0], tile.shape[1], 3), dtype=tile.dtype)]
|
|
359
|
+
)
|
|
360
|
+
for tile in tiles
|
|
361
|
+
]
|
|
362
|
+
body = np.hstack(padded)
|
|
363
|
+
|
|
364
|
+
width = body.shape[1]
|
|
365
|
+
header = np.zeros((86, width, 3), dtype=body.dtype)
|
|
366
|
+
cv2.putText(
|
|
367
|
+
header,
|
|
368
|
+
f"{index + 1}/{total} {prompt.instruction}",
|
|
369
|
+
(12, 30),
|
|
370
|
+
cv2.FONT_HERSHEY_SIMPLEX,
|
|
371
|
+
0.72,
|
|
372
|
+
(245, 245, 245),
|
|
373
|
+
2,
|
|
374
|
+
cv2.LINE_AA,
|
|
375
|
+
)
|
|
376
|
+
cv2.putText(
|
|
377
|
+
header, prompt.detail, (12, 54), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (165, 165, 165), 1
|
|
378
|
+
)
|
|
379
|
+
cv2.putText(header, banner, (12, 76), cv2.FONT_HERSHEY_SIMPLEX, 0.5, colour, 1)
|
|
380
|
+
cv2.rectangle(header, (0, 82), (int(width * progress), 86), colour, -1)
|
|
381
|
+
|
|
382
|
+
cv2.imshow(WINDOW, np.vstack([header, body]))
|
|
383
|
+
return frames, hands, fresh
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _countdown(rig: Rig, prompt: Prompt, index: int, total: int) -> bool:
|
|
387
|
+
"""Give the user time to get into position. False if they pressed Escape."""
|
|
388
|
+
started = time.monotonic()
|
|
389
|
+
while True:
|
|
390
|
+
elapsed = time.monotonic() - started
|
|
391
|
+
if elapsed >= COUNTDOWN_S:
|
|
392
|
+
return True
|
|
393
|
+
_draw(
|
|
394
|
+
rig,
|
|
395
|
+
prompt,
|
|
396
|
+
index,
|
|
397
|
+
total,
|
|
398
|
+
f"get ready... {COUNTDOWN_S - elapsed:.1f}s (Esc cancels)",
|
|
399
|
+
elapsed / COUNTDOWN_S,
|
|
400
|
+
recording=False,
|
|
401
|
+
)
|
|
402
|
+
if cv2.waitKey(15) & 0xFF == 27:
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _capture(
|
|
407
|
+
rig: Rig,
|
|
408
|
+
prompt: Prompt,
|
|
409
|
+
index: int,
|
|
410
|
+
total: int,
|
|
411
|
+
writer: SessionWriter,
|
|
412
|
+
origin: float,
|
|
413
|
+
) -> bool:
|
|
414
|
+
"""Record one prompt's worth of frames. False if the user pressed Escape."""
|
|
415
|
+
started = time.monotonic()
|
|
416
|
+
kept = 0
|
|
417
|
+
while True:
|
|
418
|
+
elapsed = time.monotonic() - started
|
|
419
|
+
if elapsed >= prompt.seconds:
|
|
420
|
+
print(f"[record] {prompt.label}: {kept} frames")
|
|
421
|
+
return True
|
|
422
|
+
|
|
423
|
+
frames, hands, fresh = _draw(
|
|
424
|
+
rig,
|
|
425
|
+
prompt,
|
|
426
|
+
index,
|
|
427
|
+
total,
|
|
428
|
+
f"RECORDING {prompt.label} {prompt.seconds - elapsed:.1f}s left",
|
|
429
|
+
elapsed / prompt.seconds,
|
|
430
|
+
recording=True,
|
|
431
|
+
)
|
|
432
|
+
# Only store genuinely new images; the preview redraws faster than the
|
|
433
|
+
# cameras deliver, and duplicates would weight the fit toward whichever
|
|
434
|
+
# moments happened to be shown twice.
|
|
435
|
+
if fresh and frames:
|
|
436
|
+
writer.add(
|
|
437
|
+
RecordedFrame(
|
|
438
|
+
time=time.monotonic() - origin,
|
|
439
|
+
label=prompt.label,
|
|
440
|
+
views=rig.views(frames, hands),
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
kept += 1
|
|
444
|
+
|
|
445
|
+
if cv2.waitKey(5) & 0xFF == 27:
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def main() -> int:
|
|
450
|
+
return run()
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
if __name__ == "__main__":
|
|
454
|
+
raise SystemExit(main())
|
mindcontrol/replay.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Replaying a recorded session through the gesture engine.
|
|
2
|
+
|
|
3
|
+
This is the part that makes the system testable. A recording of real hands can be
|
|
4
|
+
pushed through the state machine offline, deterministically, as many times as we
|
|
5
|
+
like -- so "does a pinch still produce exactly one click" becomes a question that
|
|
6
|
+
can be answered without a human, a camera, or good lighting.
|
|
7
|
+
|
|
8
|
+
It also closes the loop on tuning. Change a threshold, replay, and see whether
|
|
9
|
+
the number of clicks went up or down. That is a measurement of the change, not an
|
|
10
|
+
opinion about it.
|
|
11
|
+
|
|
12
|
+
Replay uses the recorded timestamps rather than wall-clock time, so every run is
|
|
13
|
+
identical and the durations the state machine cares about -- tap length, hold
|
|
14
|
+
length, cooldowns -- stay faithful to what actually happened.
|
|
15
|
+
|
|
16
|
+
Multi-camera recordings go through the same `HandFusion` the live pipeline uses,
|
|
17
|
+
so a replay tests the merge as well as the state machine. That path is otherwise
|
|
18
|
+
very hard to test: it needs two cameras genuinely disagreeing about one hand, and
|
|
19
|
+
feeding it the same image twice proves almost nothing.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections import Counter
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
from .config import Config
|
|
28
|
+
from .fusion import HandFusion, Observation, fuse_session
|
|
29
|
+
from .gestures.engine import Action, GestureEngine, GestureEvent
|
|
30
|
+
from .session import Session
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ReplayResult:
|
|
35
|
+
"""Everything the engine did during a replay."""
|
|
36
|
+
|
|
37
|
+
events: list[tuple[float, str, GestureEvent]] = field(default_factory=list)
|
|
38
|
+
merged_frames: int = 0
|
|
39
|
+
rebases: int = 0
|
|
40
|
+
|
|
41
|
+
def counts(self, label: str | None = None) -> Counter[Action]:
|
|
42
|
+
"""How many of each action fired, optionally within one prompt's frames."""
|
|
43
|
+
return Counter(
|
|
44
|
+
event.action
|
|
45
|
+
for _, event_label, event in self.events
|
|
46
|
+
if label is None or event_label == label
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def during(self, label: str) -> list[GestureEvent]:
|
|
50
|
+
return [event for _, event_label, event in self.events if event_label == label]
|
|
51
|
+
|
|
52
|
+
def total(self, action: Action, label: str | None = None) -> int:
|
|
53
|
+
return self.counts(label)[action]
|
|
54
|
+
|
|
55
|
+
def summary(self) -> str:
|
|
56
|
+
counts = self.counts()
|
|
57
|
+
if not counts:
|
|
58
|
+
return "no events"
|
|
59
|
+
return ", ".join(
|
|
60
|
+
f"{action.value} x{count}" for action, count in sorted(counts.items(), key=str)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def replay(session: Session, cfg: Config, engaged: bool = True) -> ReplayResult:
|
|
65
|
+
"""Push a recording through a fresh engine and collect what it emits.
|
|
66
|
+
|
|
67
|
+
``engaged`` mirrors the live app's mode gate: replaying disengaged is how the
|
|
68
|
+
engage gesture itself gets tested.
|
|
69
|
+
"""
|
|
70
|
+
engine = GestureEngine(cfg.pointer, cfg.gestures, cfg.tracking)
|
|
71
|
+
fusion = HandFusion(cfg.tracking, cfg.gestures)
|
|
72
|
+
result = ReplayResult()
|
|
73
|
+
previous_time: float | None = None
|
|
74
|
+
|
|
75
|
+
for frame in session.frames:
|
|
76
|
+
dt = 1 / 30.0 if previous_time is None else max(frame.time - previous_time, 1e-4)
|
|
77
|
+
previous_time = frame.time
|
|
78
|
+
|
|
79
|
+
fused = fusion.fuse(
|
|
80
|
+
[
|
|
81
|
+
Observation(
|
|
82
|
+
camera_id=view.camera_id,
|
|
83
|
+
hands=[hand.remeasure(cfg.gestures) for hand in view.hands],
|
|
84
|
+
age_ms=view.age_ms,
|
|
85
|
+
)
|
|
86
|
+
for view in frame.views
|
|
87
|
+
]
|
|
88
|
+
)
|
|
89
|
+
# The live pipeline rebases the pointer when the leading camera changes,
|
|
90
|
+
# so that a viewpoint switch does not fling the cursor. Replaying without
|
|
91
|
+
# it would report jumps the real app never makes.
|
|
92
|
+
if any(hand.rebased for hand in fused):
|
|
93
|
+
engine.rebase()
|
|
94
|
+
result.rebases += 1
|
|
95
|
+
if any(hand.merged for hand in fused):
|
|
96
|
+
result.merged_frames += 1
|
|
97
|
+
|
|
98
|
+
for event in engine.update([hand.features for hand in fused], frame.time, dt, engaged):
|
|
99
|
+
result.events.append((frame.time, frame.label, event))
|
|
100
|
+
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def pose_report(session: Session, cfg: Config) -> dict[str, Counter[str]]:
|
|
105
|
+
"""Which poses each prompt actually classified as.
|
|
106
|
+
|
|
107
|
+
The diagonal is the interesting part: if the frames labelled ``fist`` mostly
|
|
108
|
+
classify as something else, the thresholds are wrong for this user and every
|
|
109
|
+
downstream gesture built on that pose will misbehave.
|
|
110
|
+
|
|
111
|
+
Scored on the fused hand rather than each camera's view of it, so the figure
|
|
112
|
+
answers "would the engine have recognised this", not "did some camera see it".
|
|
113
|
+
Counting views instead lets a three-camera recording report a pose as 33%
|
|
114
|
+
recognised when the engine recognised it every single frame.
|
|
115
|
+
"""
|
|
116
|
+
report: dict[str, Counter[str]] = {}
|
|
117
|
+
for frame, hands in fuse_session(session, cfg.gestures, cfg.tracking):
|
|
118
|
+
bucket = report.setdefault(frame.label, Counter())
|
|
119
|
+
if not hands:
|
|
120
|
+
bucket["<no hand>"] += 1
|
|
121
|
+
for hand in hands:
|
|
122
|
+
bucket[hand.features.pose.value] += 1
|
|
123
|
+
return report
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# What each prompt should predominantly classify as. Transition prompts are left
|
|
127
|
+
# out: they legitimately span several poses.
|
|
128
|
+
EXPECTED_POSE = {
|
|
129
|
+
"ready": "ready",
|
|
130
|
+
"fist": "fist",
|
|
131
|
+
"open_palm": "open_palm",
|
|
132
|
+
"telephone": "telephone",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def accuracy(session: Session, cfg: Config) -> dict[str, float]:
|
|
137
|
+
"""Fraction of each held prompt's frames that classified as intended."""
|
|
138
|
+
scores: dict[str, float] = {}
|
|
139
|
+
for label, expected in EXPECTED_POSE.items():
|
|
140
|
+
counts = pose_report(session, cfg).get(label)
|
|
141
|
+
if not counts:
|
|
142
|
+
continue
|
|
143
|
+
total = sum(counts.values())
|
|
144
|
+
scores[label] = counts.get(expected, 0) / total if total else 0.0
|
|
145
|
+
return scores
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run(session: Session, cfg: Config) -> int:
|
|
149
|
+
"""Print a readable replay report."""
|
|
150
|
+
print(f"[replay] {session.path.name if session.path else 'session'}: {session.summary()}\n")
|
|
151
|
+
|
|
152
|
+
problems = session.problems()
|
|
153
|
+
if problems:
|
|
154
|
+
print("recording quality — read the results below with these in mind:")
|
|
155
|
+
for problem in problems:
|
|
156
|
+
print(f" ! {problem}")
|
|
157
|
+
print()
|
|
158
|
+
|
|
159
|
+
print("pose classification, by prompt")
|
|
160
|
+
report = pose_report(session, cfg)
|
|
161
|
+
for label, counts in report.items():
|
|
162
|
+
total = sum(counts.values())
|
|
163
|
+
top = ", ".join(
|
|
164
|
+
f"{pose} {count / total:.0%}" for pose, count in counts.most_common(3)
|
|
165
|
+
)
|
|
166
|
+
expected = EXPECTED_POSE.get(label)
|
|
167
|
+
mark = ""
|
|
168
|
+
if expected:
|
|
169
|
+
share = counts.get(expected, 0) / total if total else 0.0
|
|
170
|
+
mark = " OK" if share >= 0.7 else f" <-- wanted {expected}"
|
|
171
|
+
print(f" {label:<22} {top}{mark}")
|
|
172
|
+
|
|
173
|
+
result = replay(session, cfg)
|
|
174
|
+
if len(session.cameras) > 1:
|
|
175
|
+
print(
|
|
176
|
+
f"\nfusion: {result.merged_frames} frame(s) merged across cameras, "
|
|
177
|
+
f"{result.rebases} pointer rebase(s) on a viewpoint change"
|
|
178
|
+
)
|
|
179
|
+
print(f"\nevents emitted while engaged: {result.summary()}")
|
|
180
|
+
print("\nby prompt")
|
|
181
|
+
for label in session.labels():
|
|
182
|
+
counts = result.counts(label)
|
|
183
|
+
if counts:
|
|
184
|
+
detail = ", ".join(f"{a.value} x{c}" for a, c in sorted(counts.items(), key=str))
|
|
185
|
+
print(f" {label:<22} {detail}")
|
|
186
|
+
|
|
187
|
+
disengaged = replay(session, cfg, engaged=False)
|
|
188
|
+
engages = disengaged.total(Action.ENGAGE_TOGGLE)
|
|
189
|
+
others = sum(v for k, v in disengaged.counts().items() if k is not Action.ENGAGE_TOGGLE)
|
|
190
|
+
print(f"\nwhile disengaged: {engages} engage toggle(s), {others} other event(s)")
|
|
191
|
+
if others:
|
|
192
|
+
print(" WARNING: gestures leaked through while control was off")
|
|
193
|
+
return 0
|