islkit 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.
islkit/infer.py ADDED
@@ -0,0 +1,911 @@
1
+ """Live inference: camera frames in, a gloss or an honest silence out.
2
+
3
+ This is the first code in the repo that feeds the model something MediaPipe
4
+ produced here rather than something the INCLUDE dump produced elsewhere, so it
5
+ is also the first place the train/serve gap can bite. Two corrections live in
6
+ `to_include_convention` and both are about making live input look like what the
7
+ model was actually trained on; see the tests for why each one matters.
8
+
9
+ `mediapipe` is imported inside `HolisticExtractor.__init__` rather than at module
10
+ scope, so `SignRecogniser` and `ClipStore` can be imported — and tested — without
11
+ paying for it or requiring it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import time
19
+ from dataclasses import dataclass
20
+ from datetime import datetime
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+
26
+ from islkit.features import RawFrame, from_holistic
27
+
28
+ log = logging.getLogger("islkit.infer")
29
+
30
+ INCLUDE_FACE_POINTS = 468 # what load_include truncates to, and FACE_IDX indexes
31
+ HOLISTIC_FACE_POINTS = 478 # 468 mesh + 10 iris, with refine_face_landmarks=True
32
+
33
+
34
+ def to_include_convention(raw: RawFrame) -> RawFrame:
35
+ """Make a live frame look like one that came out of the INCLUDE dump.
36
+
37
+ Two differences, both silent if left alone:
38
+
39
+ **Visibility.** The Kaggle dump has no visibility column, so `load_include`
40
+ writes 1.0 for all 33 pose landmarks whenever the block is present. That
41
+ value lands in 11 of the 14 validity-mask dims, and the mask is
42
+ multiplied through the network — so the model has never seen a pose mask
43
+ that was not exactly 1.0. Live Holistic returns real values across [0, 1],
44
+ which would attenuate geometry the model expects at full strength.
45
+
46
+ **Face length.** `refine_face_landmarks=True` appends 10 iris points.
47
+ `load_include` truncates to 468. Indices 0..467 are identical so this is
48
+ cosmetic, but the length check underneath it is not: it turns a version
49
+ that quietly drops iris refinement into a loud failure instead of four
50
+ slightly-wrong non-manual scalars.
51
+ """
52
+ pose = raw.pose
53
+ if pose is not None:
54
+ pose = pose.copy()
55
+ # 1.0 for what the camera actually saw. MediaPipe reports a position for
56
+ # every landmark whether or not it is in shot, extrapolating the ones
57
+ # that are not — and those guesses are bad: hips invented below the frame
58
+ # land ~2.4 shoulder-widths down against INCLUDE's 1.53, which costs
59
+ # 100% -> 62.5% top-1. Marking them absent instead restores 100.0%, so
60
+ # the validity mask is what makes a head-and-torso framing work
61
+ # at all. This matters for the device, which will not see anyone's hips.
62
+ xy = pose[:, :2]
63
+ seen = np.all((xy >= 0.0) & (xy <= 1.0), axis=1)
64
+ pose[:, 3] = seen.astype(np.float32)
65
+
66
+ face = raw.face
67
+ if face is not None:
68
+ if face.shape[0] not in (INCLUDE_FACE_POINTS, HOLISTIC_FACE_POINTS):
69
+ raise ValueError(
70
+ f"face has {face.shape[0]} landmarks; expected 468 or 478. "
71
+ "The encoder's FACE_IDX is calibrated against the canonical mesh."
72
+ )
73
+ # .copy(), like the pose branch: a slice is a view onto the caller's
74
+ # array, and the two branches should not differ in whether the returned
75
+ # frame still aliases the recorded one.
76
+ face = face[:INCLUDE_FACE_POINTS].copy()
77
+
78
+ return RawFrame(pose=pose, face=face, hand_left=raw.hand_left, hand_right=raw.hand_right)
79
+
80
+
81
+ # --------------------------------------------------------------------------
82
+ # XNNPACK threading
83
+ # --------------------------------------------------------------------------
84
+ #
85
+ # MediaPipe compiles the Holistic graph with every InferenceCalculator set to
86
+ # `delegate { xnnpack {} }` — an empty block, which XNNPACK reads as ONE
87
+ # thread. That default is invisible and it is expensive: measured on the UNO Q
88
+ # (4 x Kryo-V2 @ 2.0 GHz) stock Holistic uses 0.99 of four cores and delivers
89
+ # ~2.2 fps with a signer in shot, leaving three cores idle.
90
+ #
91
+ # That number decides whether the device works at all. Top-1 against effective
92
+ # frame rate, measured on the pretrained checkpoint:
93
+ #
94
+ # fps 27 13 9 5 3 2
95
+ # top-1 100% 100% 100% 100% 52.5% 2.5%
96
+ #
97
+ # encode_clip resamples a take to T frames rather than padding it, so a sign
98
+ # sampled five times is fed to the model as a piecewise-linear trajectory with
99
+ # five knots where training had forty. The TCN keys on exactly the temporal
100
+ # texture that removes, so it degrades to noise — and
101
+ # the model is overconfident out of distribution (0.9978 on random landmarks),
102
+ # so noise arrives as a confident wrong gloss rather than as a decline.
103
+ #
104
+ # Raising the thread count is therefore not tuning. On the board it measured
105
+ # 2.32 -> 3.95 fps at num_threads=2, which is the difference between sitting
106
+ # under the cliff and sitting on it.
107
+
108
+ _HOLISTIC_BINARYPB = "modules/holistic_landmark/holistic_landmark_cpu.binarypb"
109
+
110
+ # The two confidence knobs `Holistic.__init__` sets through calculator_params,
111
+ # by their canonical node names once the graph is expanded. Named here because
112
+ # they are long enough to wrap, and a typo in one is silent — SolutionBase
113
+ # raises on an unknown node, but a valid node with the wrong field is not
114
+ # something it can catch.
115
+ _DETECTION_THRESHOLD_PARAM = (
116
+ "poselandmarkcpu__posedetectioncpu__TensorsToDetectionsCalculator.min_score_thresh"
117
+ )
118
+ _TRACKING_THRESHOLD_PARAM = (
119
+ "poselandmarkcpu__poselandmarkbyroicpu__tensorstoposelandmarksandsegmentation"
120
+ "__ThresholdingCalculator.threshold"
121
+ )
122
+
123
+
124
+ def _expanded_holistic_config():
125
+ """The Holistic graph with its subgraphs expanded into concrete nodes.
126
+
127
+ The shipped .binarypb holds four subgraph references, not the inference
128
+ nodes themselves, so the thread setting has nowhere to land until MediaPipe
129
+ expands it. `CalculatorGraph.binary_config` is that expansion — the only
130
+ accessor the Python package exposes for it.
131
+ """
132
+ import mediapipe as mp
133
+ from mediapipe.framework import calculator_pb2
134
+ from mediapipe.python import CalculatorGraph
135
+
136
+ shipped = calculator_pb2.CalculatorGraphConfig()
137
+ shipped.ParseFromString((Path(mp.__file__).parent / _HOLISTIC_BINARYPB).read_bytes())
138
+
139
+ expanded = calculator_pb2.CalculatorGraphConfig()
140
+ expanded.ParseFromString(CalculatorGraph(graph_config=shipped).binary_config)
141
+ return expanded
142
+
143
+
144
+ def xnnpack_threaded_config(num_threads: int):
145
+ """The Holistic graph with XNNPACK told how many threads it may use.
146
+
147
+ Returns `(config, n_patched)`. `n_patched` is reported rather than assumed
148
+ because the failure this guards against is silent: a patch that matches no
149
+ node yields a graph that validates, runs, and is still single-threaded.
150
+
151
+ Nodes with no options block are skipped deliberately. One inference node in
152
+ this graph takes its model as a side packet, and materialising an empty
153
+ options block there gives it an empty `model_path`, which trips
154
+ InferenceCalculatorCpu's "either model as side packet or model path in
155
+ options" check and the graph then fails to validate at all.
156
+ """
157
+ if num_threads < 1:
158
+ raise ValueError(f"num_threads must be at least 1, got {num_threads}")
159
+
160
+ from mediapipe.calculators.tensor import inference_calculator_pb2
161
+
162
+ ext = inference_calculator_pb2.InferenceCalculatorOptions.ext
163
+ config = _expanded_holistic_config()
164
+
165
+ patched = 0
166
+ for node in config.node:
167
+ if "Inference" not in node.calculator or not node.options.HasExtension(ext):
168
+ continue
169
+ node.options.Extensions[ext].delegate.xnnpack.num_threads = num_threads
170
+ patched += 1
171
+
172
+ if not patched:
173
+ raise RuntimeError(
174
+ "found no InferenceCalculator to thread in the Holistic graph. "
175
+ "mediapipe is pinned to 0.10.18 for the board's sake; if that pin "
176
+ "has moved, the node layout has moved with it and this needs "
177
+ "re-deriving rather than silently running at one thread."
178
+ )
179
+ return config, patched
180
+
181
+
182
+ def _threaded_holistic(
183
+ num_threads: int,
184
+ model_complexity: int,
185
+ refine_face: bool,
186
+ min_detection_confidence: float,
187
+ min_tracking_confidence: float,
188
+ ):
189
+ """`mp.solutions.Holistic` rebuilt on a thread-patched graph.
190
+
191
+ Holistic itself takes a `binary_graph_path` and offers no way in, but the
192
+ SolutionBase underneath it accepts a whole `graph_config`. Its
193
+ `calculator_params` hook cannot reach this setting: it splits a key on '.'
194
+ and requires exactly two parts, so the three-level
195
+ `delegate.xnnpack.num_threads` is not expressible there.
196
+
197
+ Everything except the graph mirrors `Holistic.__init__` exactly. Diverging
198
+ would change what the encoder sees, which is the one thing threading must
199
+ not do.
200
+ """
201
+ from mediapipe.python import solution_base
202
+ from mediapipe.python.solutions.holistic import _download_oss_pose_landmark_model
203
+
204
+ # Holistic does this first: complexity 0 and 2 use lite/heavy weights that
205
+ # are fetched on demand rather than shipped in the wheel.
206
+ _download_oss_pose_landmark_model(model_complexity)
207
+
208
+ config, patched = xnnpack_threaded_config(num_threads)
209
+
210
+ class _ThreadedHolistic(solution_base.SolutionBase):
211
+ def process(self, image):
212
+ results = super().process(input_data={"image": image})
213
+ # Holistic.process strips `presence` from both pose lists. Nothing
214
+ # downstream reads it, but a divergence here is exactly the kind of
215
+ # quiet difference this whole class exists to avoid.
216
+ for landmarks in (results.pose_landmarks, results.pose_world_landmarks):
217
+ if landmarks:
218
+ for landmark in landmarks.landmark:
219
+ landmark.ClearField("presence")
220
+ return results
221
+
222
+ solution = _ThreadedHolistic(
223
+ graph_config=config,
224
+ side_inputs={
225
+ "model_complexity": model_complexity,
226
+ # static_image_mode is False throughout this package: these are video
227
+ # frames from one continuous take, and the tracker's frame-to-frame
228
+ # state is what keeps hands attached through a sign.
229
+ "smooth_landmarks": True,
230
+ "enable_segmentation": False,
231
+ "smooth_segmentation": False,
232
+ "refine_face_landmarks": refine_face,
233
+ "use_prev_landmarks": True,
234
+ },
235
+ calculator_params={
236
+ _DETECTION_THRESHOLD_PARAM: min_detection_confidence,
237
+ _TRACKING_THRESHOLD_PARAM: min_tracking_confidence,
238
+ },
239
+ outputs=[
240
+ "pose_landmarks",
241
+ "pose_world_landmarks",
242
+ "left_hand_landmarks",
243
+ "right_hand_landmarks",
244
+ "face_landmarks",
245
+ "segmentation_mask",
246
+ ],
247
+ )
248
+ log.info(
249
+ "Holistic graph patched for %d XNNPACK threads across %d inference nodes",
250
+ num_threads,
251
+ patched,
252
+ )
253
+ return solution
254
+
255
+
256
+ class HolisticExtractor:
257
+ """Owns MediaPipe. Does not mirror — that used to be wrong here, on purpose.
258
+
259
+ A sibling project flipped the frame before inference because it ran MediaPipe
260
+ *Hands*, whose handedness classifier assumes a selfie-mirrored image. This
261
+ package never reads that classifier's label: `encode_frame` assigns hand slots
262
+ geometrically, by proximity to the dominant-side shoulder, precisely
263
+ because the label is unreliable. Carrying the flip over corrected a label
264
+ this codebase ignores while corrupting the geometry it depends on — a
265
+ mirrored right-handed signer is geometrically left-handed, so `dominant`
266
+ then selects the wrong hand for slot 0.
267
+
268
+ Measured on 60 random INCLUDE clips through the real 262-class checkpoint:
269
+ unmirrored **100.0%** top-1, mirrored **28.3%** top-1. Do not reintroduce the
270
+ flip here. A mirror is a legitimate ergonomic choice for what the signer
271
+ *sees*, but that is a display concern and belongs to the caller, applied to
272
+ the frame only after inference and landmark drawing are done with it.
273
+ """
274
+
275
+ def __init__(
276
+ self,
277
+ model_complexity: int = 1,
278
+ refine_face: bool = True,
279
+ min_detection_confidence: float = 0.5,
280
+ min_tracking_confidence: float = 0.5,
281
+ num_threads: int | None = None,
282
+ ):
283
+ """`num_threads` is how many XNNPACK threads each inference node may use.
284
+
285
+ None keeps MediaPipe's own graph untouched, which means one thread and,
286
+ on the UNO Q, ~2.2 fps — under the rate at which this model stops
287
+ working (see the section above). Set it on any machine where the frame
288
+ rate is what limits recognition; leave it alone on a laptop that is
289
+ already fast enough, since the threaded path rebuilds a compiled graph
290
+ and there is no reason to take that on for nothing.
291
+ """
292
+ import cv2
293
+ from mediapipe.python.solutions import drawing_utils, holistic
294
+
295
+ self._cv2 = cv2
296
+ self._mp_holistic = holistic
297
+ self._drawing = drawing_utils
298
+ self.num_threads = num_threads
299
+ if num_threads is None:
300
+ # NB: the kwarg is refine_face_landmarks. `refine_landmarks` is
301
+ # FaceMesh's spelling and raises TypeError here.
302
+ self._holistic = holistic.Holistic(
303
+ static_image_mode=False,
304
+ model_complexity=model_complexity,
305
+ refine_face_landmarks=refine_face,
306
+ min_detection_confidence=min_detection_confidence,
307
+ min_tracking_confidence=min_tracking_confidence,
308
+ )
309
+ else:
310
+ self._holistic = _threaded_holistic(
311
+ num_threads=num_threads,
312
+ model_complexity=model_complexity,
313
+ refine_face=refine_face,
314
+ min_detection_confidence=min_detection_confidence,
315
+ min_tracking_confidence=min_tracking_confidence,
316
+ )
317
+
318
+ def process(self, frame_bgr: np.ndarray) -> tuple[np.ndarray, RawFrame, Any]:
319
+ """One camera frame -> (the same frame, unmirrored, raw RawFrame, raw MediaPipe results).
320
+
321
+ No flip. INCLUDE is ordinary unmirrored video, so MediaPipe runs on the
322
+ frame exactly as the camera produced it — that is what makes the
323
+ landmark geometry line up with training. See the class docstring for the
324
+ 100.0% vs 28.3% measurement this is pinned against. The returned frame is
325
+ `frame_bgr` itself, handed back so the caller can draw landmarks on it
326
+ before doing anything else to it (a display-only mirror included).
327
+
328
+ The RawFrame is exactly what MediaPipe produced: real visibility, all 478
329
+ face points. `to_include_convention` is deliberately *not* applied here,
330
+ because the caller records these frames raw and a correction made at
331
+ capture time would be baked into data that outlives the decision to make
332
+ it. The correction belongs to inference, and lives in `SignRecogniser`.
333
+ """
334
+ results = self._holistic.process(self._cv2.cvtColor(frame_bgr, self._cv2.COLOR_BGR2RGB))
335
+ return frame_bgr, from_holistic(results), results
336
+
337
+ def draw(self, frame_bgr: np.ndarray, results: Any) -> np.ndarray:
338
+ """Draw pose and hand landmarks onto the frame `process` returned.
339
+
340
+ Call this before any display-only mirror: the landmark coordinates in
341
+ `results` are relative to the unmirrored frame, so drawing has to happen
342
+ while frame and coordinates still agree.
343
+ """
344
+ h = self._mp_holistic
345
+ spec = self._drawing.DrawingSpec(thickness=1, circle_radius=1)
346
+ for landmarks, connections in (
347
+ (results.pose_landmarks, h.POSE_CONNECTIONS),
348
+ (results.left_hand_landmarks, h.HAND_CONNECTIONS),
349
+ (results.right_hand_landmarks, h.HAND_CONNECTIONS),
350
+ ):
351
+ if landmarks is not None:
352
+ self._drawing.draw_landmarks(frame_bgr, landmarks, connections, spec, spec)
353
+ return frame_bgr
354
+
355
+ def close(self) -> None:
356
+ self._holistic.close()
357
+
358
+ def __enter__(self) -> HolisticExtractor:
359
+ return self
360
+
361
+ def __exit__(self, *exc: object) -> None:
362
+ self.close()
363
+
364
+
365
+ class ClipTooShort(ValueError):
366
+ """Too few frames to mean anything. Refuse rather than resample noise."""
367
+
368
+
369
+ class EncoderMismatch(RuntimeError):
370
+ """The checkpoint was fitted with a different encoder than the one installed.
371
+
372
+ Not a warning. The model would run happily and be confidently wrong, which
373
+ is the one outcome this project refuses (declining is about the device saying
374
+ "I don't know"; this is about it never saying something it cannot mean).
375
+ """
376
+
377
+
378
+ @dataclass(frozen=True)
379
+ class Prediction:
380
+ """One classification attempt, including the parts that explain it.
381
+
382
+ `gloss` is None when the model declined (low confidence). `usable_frames` exists so
383
+ a bad answer can be attributed: a clip the tracker never saw is a different
384
+ failure from a clip the model got wrong, and they look identical in a gloss.
385
+ """
386
+
387
+ gloss: str | None
388
+ confidence: float
389
+ top3: list[tuple[str, float]]
390
+ encode_ms: float
391
+ forward_ms: float
392
+ usable_frames: int
393
+ n_frames: int
394
+
395
+
396
+ class SignRecogniser:
397
+ """list[RawFrame] -> Prediction. No camera, no cv2, no window.
398
+
399
+ Deliberately camera-free: it keeps the classification path testable without
400
+ hardware, and it is the piece the board app imports unchanged later.
401
+
402
+ The encoder settings come out of the checkpoint rather than the constructor.
403
+ The label map is frozen because a rebuilt class order predicts
404
+ confidently and wrongly; `T` and `dominant` fail the same way, so they are
405
+ not left to a default that can drift.
406
+
407
+ `to_include_convention` runs here rather than in the extractor because it is
408
+ a train/serve alignment concern, not a capture one: it exists only to make
409
+ live frames resemble what the head was fitted on. Keeping it on this side
410
+ leaves recordings raw, and puts the correction on the exact code
411
+ path the board app imports.
412
+ """
413
+
414
+ def __init__(
415
+ self,
416
+ classifier_path: str | Path,
417
+ labels_path: str | Path | None = None,
418
+ device=None,
419
+ threshold: float = 0.6,
420
+ min_frames: int = 8,
421
+ match_include: bool = True,
422
+ allow_encoder_mismatch: bool = False,
423
+ ):
424
+ from islkit.labels import LabelMap
425
+ from islkit.model import load_classifier
426
+
427
+ classifier_path = Path(classifier_path)
428
+ self.model, self.meta = load_classifier(classifier_path, device)
429
+ self.device = device
430
+ self.threshold = threshold
431
+ self.min_frames = min_frames
432
+ self.match_include = match_include
433
+
434
+ encoder = self.meta["encoder"]
435
+ self.T: int = encoder["T"]
436
+ self.dominant: str = encoder["dominant"]
437
+ self.with_velocity: bool = encoder["with_velocity"]
438
+
439
+ # The label map ships beside the weights, so the checkpoint
440
+ # records a filename and we resolve it next to the checkpoint.
441
+ self.labels_path = Path(labels_path or classifier_path.parent / self.meta["labels"])
442
+ self.label_map = LabelMap.load(self.labels_path)
443
+ if len(self.label_map) != self.meta["n_classes"]:
444
+ raise ValueError(
445
+ f"{self.labels_path.name} has {len(self.label_map)} glosses but the head "
446
+ f"is {self.meta['n_classes']} wide. One of them is from a different run."
447
+ )
448
+
449
+ # Import inside, not at module scope: this check runs at construction,
450
+ # and features is already imported by then via the encoder settings above.
451
+ from islkit.features import encoder_fingerprint, encoder_signature, signatures_match
452
+
453
+ trained_with = self.meta.get("encoder_fingerprint")
454
+ installed = encoder_fingerprint()
455
+
456
+ # Prefer the numeric signature when the checkpoint carries one. The hash
457
+ # is exact and machines are not: the same features.py on macOS/arm64 and
458
+ # on the board's linux/aarch64 produces probe clips that differ by up to
459
+ # 2.4e-07, so hash equality fails a checkpoint that is perfectly good.
460
+ # Left unfixed, that trains people to pass allow_encoder_mismatch, which
461
+ # is how a guard against silent confident wrongness becomes decoration.
462
+ trained_signature = self.meta.get("encoder_signature")
463
+ if trained_signature is not None:
464
+ if not signatures_match(trained_signature, encoder_signature()):
465
+ if not allow_encoder_mismatch:
466
+ raise EncoderMismatch(
467
+ f"{classifier_path.name} was fitted with an encoder whose "
468
+ f"behaviour differs from the installed one (fingerprints "
469
+ f"{trained_with} vs {installed}), by more than float noise. "
470
+ f"Serving it would feed the model input it has never seen — "
471
+ f"silently, and confidently. Retrain, install the matching "
472
+ f"islkit, or pass allow_encoder_mismatch=True if you "
473
+ f"genuinely mean it."
474
+ )
475
+ log.warning(
476
+ "%s was fitted with a materially different encoder. Loading "
477
+ "anyway because allow_encoder_mismatch=True — predictions from "
478
+ "this model are not trustworthy.",
479
+ classifier_path.name,
480
+ )
481
+ elif trained_with is None:
482
+ log.warning(
483
+ "%s carries no encoder fingerprint, so it cannot be checked against "
484
+ "the installed encoder (%s). Checkpoints trained before the guard "
485
+ "existed look like this; anything trained since should not.",
486
+ classifier_path.name,
487
+ installed,
488
+ )
489
+ elif trained_with != installed:
490
+ if not allow_encoder_mismatch:
491
+ raise EncoderMismatch(
492
+ f"{classifier_path.name} was fitted with encoder {trained_with}, but "
493
+ f"the installed islkit encodes as {installed}. Serving it would feed "
494
+ f"the model input it has never seen — silently, and confidently. "
495
+ f"Retrain, install the matching islkit, or pass "
496
+ f"allow_encoder_mismatch=True if you genuinely mean it."
497
+ )
498
+ log.warning(
499
+ "%s was fitted with encoder %s but the installed islkit encodes as %s. "
500
+ "Loading anyway because allow_encoder_mismatch=True — predictions from "
501
+ "this model are not trustworthy.",
502
+ classifier_path.name,
503
+ trained_with,
504
+ installed,
505
+ )
506
+
507
+ def classify(self, frames: list[RawFrame]) -> Prediction:
508
+ from islkit.features import encode_clip, encode_frame
509
+ from islkit.labels import decode_prediction
510
+ from islkit.model import predict_proba
511
+
512
+ if len(frames) < self.min_frames:
513
+ raise ClipTooShort(
514
+ f"{len(frames)} frames, need {self.min_frames}. encode_clip resamples "
515
+ "rather than pads, so a shorter clip would classify without complaint."
516
+ )
517
+
518
+ t0 = time.perf_counter()
519
+ # Once, at the top, and everything downstream sees the corrected frames.
520
+ # The caller's list is left untouched: it is what gets recorded.
521
+ if self.match_include:
522
+ frames = [to_include_convention(f) for f in frames]
523
+ usable = sum(1 for f in frames if encode_frame(f, self.dominant).any())
524
+ clip = encode_clip(
525
+ frames, T=self.T, dominant=self.dominant, with_velocity=self.with_velocity
526
+ )
527
+ t1 = time.perf_counter()
528
+ probs = predict_proba(self.model, clip[None], device=self.device)[0]
529
+ t2 = time.perf_counter()
530
+
531
+ gloss, confidence = decode_prediction(probs, self.label_map, self.threshold)
532
+ order = np.argsort(probs)[::-1][:3]
533
+ return Prediction(
534
+ gloss=gloss,
535
+ confidence=confidence,
536
+ top3=[(self.label_map.decode(int(i)), float(probs[i])) for i in order],
537
+ encode_ms=(t1 - t0) * 1000,
538
+ forward_ms=(t2 - t1) * 1000,
539
+ usable_frames=usable,
540
+ n_frames=len(frames),
541
+ )
542
+
543
+
544
+ class ClipStore:
545
+ """Every classification attempt, as raw landmarks fine-tuning can read back.
546
+
547
+ The layout is `<root>/<gloss>/<session>/take_NN.npz`, which is exactly what
548
+ `build_dataset` globs and exactly where it reads the session id from — so
549
+ fine-tuning consumes these recordings with no adapter and no migration.
550
+
551
+ Unlabelled attempts go to a **sibling root**, not to a placeholder directory
552
+ inside the gloss tree, because `LabelMap.from_directory` would otherwise
553
+ enrol the placeholder as a class and shift every index after it.
554
+
555
+ Raw landmarks only, raw visibility included: normalisation will
556
+ change again, and the corrections in `to_include_convention` belong to
557
+ inference, not to the data.
558
+ """
559
+
560
+ def __init__(
561
+ self,
562
+ root: str | Path = "data/live",
563
+ unlabelled_root: str | Path = "data/live_unlabelled",
564
+ session: str | None = None,
565
+ ):
566
+ self.root = Path(root)
567
+ self.unlabelled_root = Path(unlabelled_root)
568
+ self.session = session or datetime.now().strftime("%Y-%m-%d-%H%M")
569
+
570
+ def _next_take(self, directory: Path) -> Path:
571
+ directory.mkdir(parents=True, exist_ok=True)
572
+ used = [
573
+ int(p.stem.split("_")[1])
574
+ for p in directory.glob("take_*.npz")
575
+ if p.stem.split("_")[1].isdigit()
576
+ ]
577
+ return directory / f"take_{max(used, default=0) + 1:02d}.npz"
578
+
579
+ def save(
580
+ self,
581
+ frames: list[RawFrame],
582
+ gloss: str | None,
583
+ pred: Prediction | None,
584
+ threshold: float,
585
+ meta: dict | None = None,
586
+ ) -> Path:
587
+ """Write one attempt. Returns the npz path; the sidecar sits beside it."""
588
+ directory = (
589
+ self.unlabelled_root / self.session
590
+ if gloss is None
591
+ else self.root / gloss / self.session
592
+ )
593
+ path = self._next_take(directory)
594
+
595
+ # Allocate the object arrays explicitly. np.array(list, dtype=object)
596
+ # over equal-shaped arrays collapses to a 3-D object array, and
597
+ # build_dataset would then hand encode_frame an object-dtype slice that
598
+ # np.linalg.norm cannot take a norm of — at training time, not here.
599
+ blocks: dict[str, np.ndarray] = {}
600
+ for key in ("pose", "face", "hand_left", "hand_right"):
601
+ column = np.empty(len(frames), dtype=object)
602
+ for i, frame in enumerate(frames):
603
+ column[i] = getattr(frame, key)
604
+ blocks[key] = column
605
+ np.savez_compressed(path, **blocks)
606
+
607
+ # meta comes first so it can only add keys, never overwrite them: caller
608
+ # metadata is supplementary, while the structural fields below — above
609
+ # all the threshold in force — are what make a take interpretable later,
610
+ # and a collision must not be able to silently rewrite them.
611
+ sidecar = {
612
+ **(meta or {}),
613
+ "declared_gloss": gloss,
614
+ "session": self.session,
615
+ "n_frames": len(frames),
616
+ "threshold": threshold,
617
+ "saved_at": datetime.now().isoformat(timespec="seconds"),
618
+ "predicted_gloss": pred.gloss if pred else None,
619
+ "confidence": pred.confidence if pred else None,
620
+ "top3": pred.top3 if pred else None,
621
+ "usable_frames": pred.usable_frames if pred else None,
622
+ "encode_ms": pred.encode_ms if pred else None,
623
+ "forward_ms": pred.forward_ms if pred else None,
624
+ }
625
+ path.with_suffix(".json").write_text(json.dumps(sidecar, indent=2))
626
+ return path
627
+
628
+
629
+ # --------------------------------------------------------------------------
630
+ # Framing coach
631
+ # --------------------------------------------------------------------------
632
+ #
633
+ # The first ten field takes were all wrong and none of it was a code defect. INCLUDE
634
+ # clips are roughly half rest frames, arms hanging down, with the sign as a
635
+ # brief excursion; the model learned that whole trajectory. Recorded close-up
636
+ # with the hands already raised, a take sits outside the range of *every* class
637
+ # at once — stripping the rest frames out of INCLUDE's own clips reproduces the
638
+ # failure exactly, turning 12/12 correct into 4/12, confidently wrong.
639
+ #
640
+ # The thresholds below are measured from INCLUDE, not chosen. Shoulder width as
641
+ # a fraction of image width runs p5 0.121 / median 0.140 / p95 0.165, and the
642
+ # dominant wrist sits above y=1.2 in body frame for 52.5% of frames (rest) and
643
+ # below y=0.5 for 40% (signing). The bounds are widened past those percentiles
644
+ # so the coach nudges rather than nags.
645
+
646
+ # The encoder is provably scale-invariant, so how big you are in frame does not
647
+ # by itself matter. What matters is whether the camera can SEE your hands once
648
+ # they hang at your sides — if it cannot, the take has no rest frames and lands
649
+ # outside every class at once. So the check is geometric headroom, not size:
650
+ # project where a hanging wrist would fall and ask whether it is still in shot.
651
+ # INCLUDE lands at 0.42 of frame height (p95 0.49); 0.85 leaves real margin.
652
+ # Two different quantities, easy to conflate. REST_Y is the threshold above
653
+ # which a wrist counts as resting; SIGNING_Y the one below which it counts as
654
+ # raised. They describe a take's shape, which is recorded as a diagnostic but is
655
+ # NOT a requirement — see the tracking section below for why.
656
+ REST_Y, SIGNING_Y = 1.2, 0.5
657
+
658
+ # A landmark this close to the border is truncated by it, and the geometry it
659
+ # feeds is quietly wrong rather than missing.
660
+ EDGE_MARGIN = 0.02
661
+
662
+ _WRIST_Y = 127 # slot-0 wrist height within the encoded frame vector
663
+ _SLOT0_PRESENT = 169
664
+
665
+
666
+ def _wrist_height(raw: RawFrame, dominant: str) -> float | None:
667
+ """Dominant-hand wrist height in body frame, or None if no hand is tracked.
668
+
669
+ Read back out of `encode_frame` rather than recomputed, so this and the model
670
+ always agree about which hand is the dominant one — that choice is geometric
671
+ and non-obvious, and a second implementation of it would drift.
672
+ """
673
+ from islkit.features import encode_frame
674
+
675
+ v = encode_frame(raw, dominant)
676
+ return float(v[_WRIST_Y]) if v[_SLOT0_PRESENT] > 0.5 else None
677
+
678
+
679
+ @dataclass(frozen=True)
680
+ class Tracking:
681
+ """Whether the camera can actually see what the encoder needs."""
682
+
683
+ pose: bool
684
+ hands: int
685
+ clipped: bool
686
+ hips_visible: bool
687
+ ok: bool
688
+ status: str
689
+ hint: str
690
+
691
+
692
+ def check_tracking(raw: RawFrame, dominant: str = "right") -> Tracking:
693
+ """Live verdict on one frame: is this trackable?
694
+
695
+ Deliberately not a check that the signer matches INCLUDE's framing. The
696
+ encoder is scale- and translation-invariant, and the pretrained features stay
697
+ separable with no rest frames at all (leave-one-out 1-NN over 12 classes:
698
+ 100.0% on whole clips, 98.9% on the raised portion alone). Enforcing
699
+ INCLUDE's setup would train the model for conditions nobody deploys in. What
700
+ genuinely matters is that the hands are visible and un-truncated.
701
+ """
702
+ if raw.pose is None:
703
+ return Tracking(
704
+ False, 0, False, False, False, "no pose", "Step into frame — no body detected"
705
+ )
706
+
707
+ hands = [h for h in (raw.hand_left, raw.hand_right) if h is not None]
708
+
709
+ # Only the shoulders and the hands. MediaPipe routinely extrapolates hips
710
+ # and legs beyond the visible frame, which is normal at close framing and
711
+ # would make an all-landmark check fire on essentially every real frame.
712
+ # These are the landmarks the geometry actually rests on: the shoulders fix
713
+ # the body frame, and a truncated hand is a wrong handshape rather than a
714
+ # missing one.
715
+ clipped = False
716
+ for xy in (raw.pose[[11, 12], :2], *(h[:, :2] for h in hands)):
717
+ if xy.size and (xy.min() < EDGE_MARGIN or xy.max() > 1.0 - EDGE_MARGIN):
718
+ clipped = True
719
+ break
720
+
721
+ # Reported, deliberately NOT a failure. The device this feeds will be a small
722
+ # unit framing head and torso, so hips are off-camera by design. Trusting
723
+ # MediaPipe's extrapolated hips would cost 100% -> 62.5% top-1, but
724
+ # `to_include_convention` marks them absent instead and that restores 100.0%.
725
+ # Feature separability in full device conditions — no hips, no rest frames —
726
+ # measures 98.3% against 100.0% in the lab. So this is worth recording and
727
+ # not worth refusing over.
728
+ hips_visible = bool(np.all((raw.pose[[23, 24], 1] >= 0.0) & (raw.pose[[23, 24], 1] <= 1.0)))
729
+
730
+ if clipped:
731
+ return Tracking(
732
+ True,
733
+ len(hands),
734
+ True,
735
+ hips_visible,
736
+ False,
737
+ "clipped",
738
+ "Part of you is at the edge of frame — move back or centre yourself",
739
+ )
740
+ if not hands:
741
+ # Normal between signs, so reported without being called an error.
742
+ return Tracking(True, 0, False, hips_visible, True, "no hands", "Hands not visible")
743
+ return Tracking(True, len(hands), False, hips_visible, True, "ok", "Tracking")
744
+
745
+
746
+ @dataclass(frozen=True)
747
+ class TakeQuality:
748
+ """Whether a recorded take was seen well enough to be worth keeping."""
749
+
750
+ frames: int
751
+ pose_fraction: float
752
+ hand_fraction: float
753
+ clipped_fraction: float
754
+ usable: bool
755
+ hint: str
756
+
757
+
758
+ def take_quality(frames: list[RawFrame], dominant: str = "right") -> TakeQuality:
759
+ """Post-hoc tracking check. Reported, never enforced — the clip is saved regardless."""
760
+ if not frames:
761
+ return TakeQuality(0, 0.0, 0.0, 0.0, False, "Empty take")
762
+
763
+ checks = [check_tracking(f, dominant) for f in frames]
764
+ n = len(checks)
765
+ pose_f = sum(c.pose for c in checks) / n
766
+ hand_f = sum(c.hands > 0 for c in checks) / n
767
+ clip_f = sum(c.clipped for c in checks) / n
768
+
769
+ # Ordered most-fundamental first: a body the camera never found is a
770
+ # different problem from a body it found but only partly framed, and
771
+ # reporting the framing issue there would send the signer to fix the wrong
772
+ # thing.
773
+ if pose_f < 0.8:
774
+ return TakeQuality(n, pose_f, hand_f, clip_f, False, "Could not track your body reliably")
775
+ if hand_f < 0.4:
776
+ return TakeQuality(n, pose_f, hand_f, clip_f, False, "Hands were tracked in too few frames")
777
+ if clip_f > 0.3:
778
+ return TakeQuality(n, pose_f, hand_f, clip_f, False, "You were clipped by the frame edge")
779
+ return TakeQuality(n, pose_f, hand_f, clip_f, True, "Tracked cleanly")
780
+
781
+
782
+ @dataclass(frozen=True)
783
+ class TakeAssessment:
784
+ """Whether a finished take has the shape INCLUDE clips have."""
785
+
786
+ rest_fraction: float
787
+ signing_fraction: float
788
+ starts_at_rest: bool
789
+ ends_at_rest: bool
790
+ looks_like_include: bool
791
+ hint: str
792
+
793
+
794
+ def assess_take(frames: list[RawFrame], dominant: str = "right") -> TakeAssessment:
795
+ """Post-hoc check on a recorded clip, so a bad one is caught at take 1.
796
+
797
+ Reported rather than enforced: the clip is saved either way (a refused take
798
+ is still evidence), but the signer is told immediately instead of finding
799
+ out after two hundred of them.
800
+ """
801
+ heights = [_wrist_height(f, dominant) for f in frames]
802
+ tracked = [h for h in heights if h is not None]
803
+ if not tracked:
804
+ return TakeAssessment(0.0, 0.0, False, False, False, "No hand tracked in this take")
805
+
806
+ rest = sum(h > REST_Y for h in tracked) / len(tracked)
807
+ signing = sum(h < SIGNING_Y for h in tracked) / len(tracked)
808
+ starts = heights[0] is not None and heights[0] > REST_Y
809
+ ends = heights[-1] is not None and heights[-1] > REST_Y
810
+ ok = rest >= 0.25 and signing >= 0.15
811
+
812
+ if signing < 0.15:
813
+ hint = "Nothing was signed — the hands never left the rest position"
814
+ elif rest < 0.25:
815
+ hint = "Hands stayed up — begin and end each take with them resting down"
816
+ elif not starts:
817
+ hint = "Started mid-sign — rest first, then raise"
818
+ elif not ends:
819
+ hint = "Ended mid-sign — return to rest before stopping"
820
+ else:
821
+ hint = "Good shape — rest, sign, rest"
822
+ return TakeAssessment(rest, signing, starts, ends, ok, hint)
823
+
824
+
825
+ class AutoTake:
826
+ """Rest → raise → rest, captured without touching the keyboard.
827
+
828
+ The signer has to stand far enough back for their hands-at-rest to be in
829
+ shot, which puts them out of reach of the spacebar. Rest is already the
830
+ delimiter a usable take depends on, so it serves as the trigger too — and
831
+ triggering on it makes a badly-shaped take structurally impossible instead
832
+ of merely detected afterwards.
833
+
834
+ The pre-roll is the part that matters. A take that began the instant the
835
+ hands moved would contain no rest frames at all, which is precisely the
836
+ shape that made the first ten field recordings unusable. A rolling window of
837
+ recent frames is seeded into every take so the leading rest is always there.
838
+
839
+ Not the auto-segmentation ruled out earlier: that was about carving a continuous
840
+ stream of signing into signs. This is a recording aid delimited by an
841
+ explicit, deliberate return to rest, and it still emits exactly one take per
842
+ gesture.
843
+ """
844
+
845
+ def __init__(
846
+ self,
847
+ dominant: str = "right",
848
+ pre_roll: int = 12,
849
+ rest_to_arm: int = 6,
850
+ raised_to_start: int = 6,
851
+ rest_to_close: int = 8,
852
+ max_frames: int = 400,
853
+ ):
854
+ from collections import deque
855
+
856
+ self.dominant = dominant
857
+ self.rest_to_arm = rest_to_arm
858
+ self.raised_to_start = raised_to_start
859
+ self.rest_to_close = rest_to_close
860
+ self.max_frames = max_frames
861
+ self._recent: deque = deque(maxlen=pre_roll)
862
+ self._buf: list[RawFrame] = []
863
+ self._rest_run = 0
864
+ self._raised_run = 0
865
+ self.state = "waiting"
866
+
867
+ def disarm(self) -> None:
868
+ """Drop any take in progress and require rest again before re-arming."""
869
+ self._buf = []
870
+ self._recent.clear()
871
+ self._rest_run = self._raised_run = 0
872
+ self.state = "waiting"
873
+
874
+ @property
875
+ def n_frames(self) -> int:
876
+ return len(self._buf)
877
+
878
+ def update(self, raw: RawFrame) -> list[RawFrame] | None:
879
+ """Feed one frame. Returns a finished take, or None.
880
+
881
+ An untracked hand counts as rest rather than as unknown: MediaPipe loses
882
+ hands constantly, especially held still against the body, and treating a
883
+ dropout as ambiguous would either stall arming or chop a sign in half.
884
+ """
885
+ y = _wrist_height(raw, self.dominant)
886
+ raised = y is not None and y < SIGNING_Y
887
+ at_rest = y is None or y > REST_Y
888
+
889
+ self._rest_run = self._rest_run + 1 if at_rest else 0
890
+ self._raised_run = self._raised_run + 1 if raised else 0
891
+ self._recent.append(raw)
892
+
893
+ if self.state == "waiting":
894
+ if self._rest_run >= self.rest_to_arm:
895
+ self.state = "armed"
896
+ return None
897
+
898
+ if self.state == "armed":
899
+ if self._raised_run >= self.raised_to_start:
900
+ self._buf = list(self._recent)
901
+ self.state = "recording"
902
+ return None
903
+
904
+ # recording
905
+ self._buf.append(raw)
906
+ if self._rest_run >= self.rest_to_close or len(self._buf) >= self.max_frames:
907
+ take, self._buf = self._buf, []
908
+ self._raised_run = 0
909
+ self.state = "armed"
910
+ return take
911
+ return None