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/__init__.py +88 -0
- islkit/adapters.py +237 -0
- islkit/baseline.py +287 -0
- islkit/data.py +320 -0
- islkit/device.py +38 -0
- islkit/domain.py +187 -0
- islkit/features.py +323 -0
- islkit/infer.py +911 -0
- islkit/labels.py +213 -0
- islkit/metrics.py +81 -0
- islkit/model.py +623 -0
- islkit/pipeline.py +717 -0
- islkit/plotting.py +131 -0
- islkit/seeding.py +19 -0
- islkit/server.py +246 -0
- islkit/view.py +287 -0
- islkit/viz.py +435 -0
- islkit-0.1.0.dist-info/METADATA +200 -0
- islkit-0.1.0.dist-info/RECORD +21 -0
- islkit-0.1.0.dist-info/WHEEL +4 -0
- islkit-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|