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