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/data.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Ingest the Kaggle INCLUDE landmark dump into encoded feature arrays.
|
|
2
|
+
|
|
3
|
+
The dump is long-format parquet, one row per landmark per frame:
|
|
4
|
+
|
|
5
|
+
frame | row_id | type | landmark_index | x | y | z
|
|
6
|
+
|
|
7
|
+
with 553 rows per frame — face(478) + left_hand(21) + pose(33) + right_hand(21),
|
|
8
|
+
in that order.
|
|
9
|
+
|
|
10
|
+
Three things about this source differ from what features.py was written against,
|
|
11
|
+
and each is handled here rather than by changing the encoder:
|
|
12
|
+
|
|
13
|
+
1. **Missing parts are NaN, not zero.** Other public 1662-vector dumps
|
|
14
|
+
zero-fill missing parts. This one uses NaN, which is strictly better
|
|
15
|
+
— no ambiguity with a landmark that genuinely sits at the origin. Either way the
|
|
16
|
+
part becomes None and the validity mask carries it.
|
|
17
|
+
2. **Face has 478 landmarks, not 468.** That is refine_landmarks=True: 468 canonical
|
|
18
|
+
mesh points plus 10 iris points appended at 468..477. Indices 0..467 are unchanged,
|
|
19
|
+
and FACE_IDX only reaches 454, so the block is truncated to [:468] and the four
|
|
20
|
+
non-manual scalars are computed exactly as they are on live capture.
|
|
21
|
+
3. **There is no visibility column.** encode_frame writes pose visibility straight
|
|
22
|
+
into the validity mask, so it is synthesised here as 1.0 present / 0.0 NaN. This
|
|
23
|
+
is a real pretrain/deploy difference: 11 of the 183 dims are hard 0/1 here and
|
|
24
|
+
continuous in [0, 1] from live MediaPipe. Flagged deliberately — do not
|
|
25
|
+
paper over it by "fixing" the encoder.
|
|
26
|
+
|
|
27
|
+
**There is no signer identifier in this dump.** Not in filenames, folders, parquet
|
|
28
|
+
metadata, or any manifest — the archive contains only keypoints/ and label_map.json.
|
|
29
|
+
`load_include` therefore returns `signer=None`, deliberately, so that a caller reaching
|
|
30
|
+
for a group-held-out split fails loudly instead of silently splitting at random.
|
|
31
|
+
A random frame-level split over this data inflates accuracy by 10-15 points.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import hashlib
|
|
37
|
+
import json
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
import numpy as np
|
|
42
|
+
|
|
43
|
+
from islkit import features
|
|
44
|
+
from islkit.features import RawFrame, encode_clip
|
|
45
|
+
from islkit.labels import LabelMap
|
|
46
|
+
|
|
47
|
+
# --------------------------------------------------------------------------
|
|
48
|
+
# Source layout
|
|
49
|
+
# --------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
N_FACE_RAW = 478 # 468 canonical mesh + 10 iris (refine_landmarks=True)
|
|
52
|
+
N_FACE_USE = 468 # what FACE_IDX indexes into
|
|
53
|
+
N_HAND = 21
|
|
54
|
+
N_POSE = 33
|
|
55
|
+
|
|
56
|
+
# Row order within one frame. Verified per file before the fast reshape path.
|
|
57
|
+
BLOCKS = (("face", N_FACE_RAW), ("left_hand", N_HAND), ("pose", N_POSE), ("right_hand", N_HAND))
|
|
58
|
+
ROWS_PER_FRAME = sum(n for _, n in BLOCKS) # 553
|
|
59
|
+
|
|
60
|
+
_OFFSETS = {}
|
|
61
|
+
_acc = 0
|
|
62
|
+
for _name, _n in BLOCKS:
|
|
63
|
+
_OFFSETS[_name] = (_acc, _acc + _n)
|
|
64
|
+
_acc += _n
|
|
65
|
+
|
|
66
|
+
# Expected landmark_index sequence for one frame. Reading this int32 column is
|
|
67
|
+
# cheap and pins the block order without materialising the string `type` column.
|
|
68
|
+
_EXPECTED_INDEX = np.concatenate([np.arange(n, dtype=np.int32) for _, n in BLOCKS])
|
|
69
|
+
|
|
70
|
+
# Bump when the reading rules above change in a way that alters the output
|
|
71
|
+
# arrays. Feeds the cache fingerprint alongside the hash of features.py.
|
|
72
|
+
READER_VERSION = "1"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class LayoutError(ValueError):
|
|
76
|
+
"""A parquet file does not match the layout this module was written for."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# --------------------------------------------------------------------------
|
|
80
|
+
# Reading one clip
|
|
81
|
+
# --------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def read_clip(path: str | Path) -> list[RawFrame]:
|
|
85
|
+
"""One INCLUDE parquet -> list of RawFrame, one per source frame.
|
|
86
|
+
|
|
87
|
+
Raises LayoutError rather than guessing if the row layout is not the
|
|
88
|
+
expected 553-row canonical block order. Assuming a layout and being wrong
|
|
89
|
+
trains fine and predicts nonsense, which is the whole reason this module inspects
|
|
90
|
+
before it ingests.
|
|
91
|
+
"""
|
|
92
|
+
import pyarrow.parquet as pq # noqa: PLC0415 — [train]-only, deferred so bare islkit imports
|
|
93
|
+
|
|
94
|
+
path = Path(path)
|
|
95
|
+
tbl = pq.read_table(path, columns=["landmark_index", "x", "y", "z"])
|
|
96
|
+
n_rows = tbl.num_rows
|
|
97
|
+
|
|
98
|
+
if n_rows == 0 or n_rows % ROWS_PER_FRAME:
|
|
99
|
+
raise LayoutError(f"{path.name}: {n_rows} rows is not a multiple of {ROWS_PER_FRAME}")
|
|
100
|
+
n_frames = n_rows // ROWS_PER_FRAME
|
|
101
|
+
|
|
102
|
+
idx = tbl.column("landmark_index").to_numpy(zero_copy_only=False)
|
|
103
|
+
idx = idx.reshape(n_frames, ROWS_PER_FRAME)
|
|
104
|
+
if not np.array_equal(idx[0], _EXPECTED_INDEX) or not (idx == idx[0]).all():
|
|
105
|
+
raise LayoutError(f"{path.name}: landmark rows are not in the canonical block order")
|
|
106
|
+
|
|
107
|
+
xyz = np.empty((n_rows, 3), np.float32)
|
|
108
|
+
for j, col in enumerate(("x", "y", "z")):
|
|
109
|
+
xyz[:, j] = tbl.column(col).to_numpy(zero_copy_only=False)
|
|
110
|
+
xyz = xyz.reshape(n_frames, ROWS_PER_FRAME, 3)
|
|
111
|
+
|
|
112
|
+
def block(name: str) -> np.ndarray:
|
|
113
|
+
a, b = _OFFSETS[name]
|
|
114
|
+
return xyz[:, a:b, :]
|
|
115
|
+
|
|
116
|
+
face_all, lh_all = block("face"), block("left_hand")
|
|
117
|
+
pose_all, rh_all = block("pose"), block("right_hand")
|
|
118
|
+
|
|
119
|
+
# A part is absent when the tracker lost it, which this dump marks as NaN
|
|
120
|
+
# across the whole block. Test with .any() rather than .all() so a partially
|
|
121
|
+
# NaN block is treated as lost instead of silently encoding NaN into the
|
|
122
|
+
# feature vector, where it would propagate through every downstream sum.
|
|
123
|
+
face_lost = np.isnan(face_all).any(axis=(1, 2))
|
|
124
|
+
lh_lost = np.isnan(lh_all).any(axis=(1, 2))
|
|
125
|
+
rh_lost = np.isnan(rh_all).any(axis=(1, 2))
|
|
126
|
+
pose_lost = np.isnan(pose_all).any(axis=(1, 2))
|
|
127
|
+
|
|
128
|
+
frames: list[RawFrame] = []
|
|
129
|
+
for t in range(n_frames):
|
|
130
|
+
pose = None
|
|
131
|
+
if not pose_lost[t]:
|
|
132
|
+
# encode_frame reads column 3 as visibility and writes it into the
|
|
133
|
+
# validity mask. The dump has no such column, so presence stands in.
|
|
134
|
+
pose = np.concatenate([pose_all[t], np.ones((N_POSE, 1), np.float32)], axis=1)
|
|
135
|
+
frames.append(
|
|
136
|
+
RawFrame(
|
|
137
|
+
pose=pose,
|
|
138
|
+
face=None if face_lost[t] else face_all[t, :N_FACE_USE],
|
|
139
|
+
hand_left=None if lh_lost[t] else lh_all[t],
|
|
140
|
+
hand_right=None if rh_lost[t] else rh_all[t],
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
return frames
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def both_hands_fraction(frames: list[RawFrame]) -> float:
|
|
147
|
+
"""Share of source frames where the tracker held both hands at once."""
|
|
148
|
+
if not frames:
|
|
149
|
+
return 0.0
|
|
150
|
+
both = sum(f.hand_left is not None and f.hand_right is not None for f in frames)
|
|
151
|
+
return both / len(frames)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# --------------------------------------------------------------------------
|
|
155
|
+
# Cache identity
|
|
156
|
+
# --------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def encoder_fingerprint(T: int, dominant: str, with_velocity: bool) -> str:
|
|
160
|
+
"""Short hash identifying exactly which features these arrays are.
|
|
161
|
+
|
|
162
|
+
Hashes the *source* of features.py, not a hand-maintained version constant:
|
|
163
|
+
a cache that survives an encoder edit is worse than no cache, and a version
|
|
164
|
+
number someone forgets to bump is precisely how that happens.
|
|
165
|
+
"""
|
|
166
|
+
h = hashlib.sha256()
|
|
167
|
+
h.update(Path(features.__file__).read_bytes())
|
|
168
|
+
h.update(READER_VERSION.encode())
|
|
169
|
+
h.update(f"T={T};dominant={dominant};velocity={with_velocity}".encode())
|
|
170
|
+
return h.hexdigest()[:12]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cache_path(cache_dir: str | Path, T: int, dominant: str, with_velocity: bool) -> Path:
|
|
174
|
+
fp = encoder_fingerprint(T, dominant, with_velocity)
|
|
175
|
+
return Path(cache_dir) / f"include_{features.DIM_FRAME}_{fp}.npz"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --------------------------------------------------------------------------
|
|
179
|
+
# The dataset
|
|
180
|
+
# --------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class IncludeData:
|
|
185
|
+
"""Encoded INCLUDE clips plus everything needed to split them honestly.
|
|
186
|
+
|
|
187
|
+
`signer` is None because the dump carries no signer identifier. It is kept
|
|
188
|
+
in the signature so the absence is visible at every call site rather than
|
|
189
|
+
being something a reader has to know.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
X: np.ndarray # (N, T, 352) float32
|
|
193
|
+
y: np.ndarray # (N,) int64
|
|
194
|
+
signer: np.ndarray | None # None for this source — see module docstring
|
|
195
|
+
label_map: LabelMap
|
|
196
|
+
clips: np.ndarray # (N,) source path relative to the dataset root
|
|
197
|
+
category: np.ndarray # (N,) INCLUDE's own topic folder, e.g. "adjectives"
|
|
198
|
+
both_hands: np.ndarray # (N,) fraction of source frames with both hands
|
|
199
|
+
n_frames: np.ndarray # (N,) source frame count, before resampling to T
|
|
200
|
+
|
|
201
|
+
def __len__(self) -> int:
|
|
202
|
+
return len(self.y)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _clip_files(root: Path) -> list[Path]:
|
|
206
|
+
return sorted((root / "keypoints").rglob("*.parquet"))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def load_include(
|
|
210
|
+
root: str | Path,
|
|
211
|
+
T: int = 48,
|
|
212
|
+
dominant: str = "right",
|
|
213
|
+
with_velocity: bool = True,
|
|
214
|
+
cache_dir: str | Path = "data/cache",
|
|
215
|
+
rebuild: bool = False,
|
|
216
|
+
limit: int | None = None,
|
|
217
|
+
verbose: bool = True,
|
|
218
|
+
) -> IncludeData:
|
|
219
|
+
"""Encode the INCLUDE dump to (N, T, 352), caching the result.
|
|
220
|
+
|
|
221
|
+
The cache filename carries a hash of features.py, so editing the encoder
|
|
222
|
+
invalidates it automatically and the next call re-encodes from the raw
|
|
223
|
+
parquet. Raw landmarks are never cached in encoded form anywhere else —
|
|
224
|
+
normalisation will change, the recordings will not.
|
|
225
|
+
|
|
226
|
+
`limit` truncates to the first N clips for a smoke run and bypasses the
|
|
227
|
+
cache entirely, so a partial run can never be mistaken for a full one.
|
|
228
|
+
"""
|
|
229
|
+
root = Path(root)
|
|
230
|
+
path = cache_path(cache_dir, T, dominant, with_velocity)
|
|
231
|
+
|
|
232
|
+
if limit is None and not rebuild and path.exists():
|
|
233
|
+
if verbose:
|
|
234
|
+
print(f"cache hit: {path}")
|
|
235
|
+
return _from_cache(path)
|
|
236
|
+
|
|
237
|
+
files = _clip_files(root)
|
|
238
|
+
if not files:
|
|
239
|
+
raise FileNotFoundError(f"no parquet clips under {root / 'keypoints'}")
|
|
240
|
+
if limit is not None:
|
|
241
|
+
files = files[:limit]
|
|
242
|
+
|
|
243
|
+
# Label map is built from the directories that actually hold clips, then
|
|
244
|
+
# frozen and shipped beside the weights. The dump's own label_map.json
|
|
245
|
+
# lists one class with no data, so trusting it would leave a dead output
|
|
246
|
+
# unit and shift every index above it.
|
|
247
|
+
glosses = sorted({p.parent.name for p in files})
|
|
248
|
+
label_map = LabelMap(glosses)
|
|
249
|
+
|
|
250
|
+
X, y, clips, cats, both, lens = [], [], [], [], [], []
|
|
251
|
+
for i, p in enumerate(files):
|
|
252
|
+
if verbose and i % 250 == 0:
|
|
253
|
+
print(f" encoding {i}/{len(files)}")
|
|
254
|
+
frames = read_clip(p)
|
|
255
|
+
X.append(encode_clip(frames, T=T, dominant=dominant, with_velocity=with_velocity))
|
|
256
|
+
y.append(label_map.encode(p.parent.name))
|
|
257
|
+
clips.append(str(p.relative_to(root)))
|
|
258
|
+
cats.append(p.parent.parent.name)
|
|
259
|
+
both.append(both_hands_fraction(frames))
|
|
260
|
+
lens.append(len(frames))
|
|
261
|
+
|
|
262
|
+
data = IncludeData(
|
|
263
|
+
X=np.stack(X).astype(np.float32),
|
|
264
|
+
y=np.array(y, np.int64),
|
|
265
|
+
signer=None,
|
|
266
|
+
label_map=label_map,
|
|
267
|
+
clips=np.array(clips),
|
|
268
|
+
category=np.array(cats),
|
|
269
|
+
both_hands=np.array(both, np.float32),
|
|
270
|
+
n_frames=np.array(lens, np.int32),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if limit is None:
|
|
274
|
+
_to_cache(path, data, root, T, dominant, with_velocity)
|
|
275
|
+
if verbose:
|
|
276
|
+
print(f"cached: {path} ({path.stat().st_size / 1e6:.0f} MB)")
|
|
277
|
+
return data
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _to_cache(path: Path, d: IncludeData, root: Path, T, dominant, with_velocity) -> None:
|
|
281
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
meta = {
|
|
283
|
+
"source_root": str(root),
|
|
284
|
+
"reader_version": READER_VERSION,
|
|
285
|
+
"encoder_fingerprint": encoder_fingerprint(T, dominant, with_velocity),
|
|
286
|
+
"T": T,
|
|
287
|
+
"dominant": dominant,
|
|
288
|
+
"with_velocity": with_velocity,
|
|
289
|
+
"signer": None,
|
|
290
|
+
"signer_note": "INCLUDE dump carries no signer identifier",
|
|
291
|
+
}
|
|
292
|
+
# Uncompressed: dense float32 barely compresses and training will reload this
|
|
293
|
+
# on every run. Gitignored either way.
|
|
294
|
+
np.savez(
|
|
295
|
+
path,
|
|
296
|
+
X=d.X,
|
|
297
|
+
y=d.y,
|
|
298
|
+
clips=d.clips,
|
|
299
|
+
category=d.category,
|
|
300
|
+
both_hands=d.both_hands,
|
|
301
|
+
n_frames=d.n_frames,
|
|
302
|
+
glosses=np.array(d.label_map.glosses),
|
|
303
|
+
meta=json.dumps(meta),
|
|
304
|
+
)
|
|
305
|
+
label_json = path.with_name(path.stem + "_labels.json")
|
|
306
|
+
d.label_map.save(label_json)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _from_cache(path: Path) -> IncludeData:
|
|
310
|
+
z = np.load(path, allow_pickle=False)
|
|
311
|
+
return IncludeData(
|
|
312
|
+
X=z["X"],
|
|
313
|
+
y=z["y"],
|
|
314
|
+
signer=None,
|
|
315
|
+
label_map=LabelMap(list(z["glosses"])),
|
|
316
|
+
clips=z["clips"],
|
|
317
|
+
category=z["category"],
|
|
318
|
+
both_hands=z["both_hands"],
|
|
319
|
+
n_frames=z["n_frames"],
|
|
320
|
+
)
|
islkit/device.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Where the tensors go.
|
|
2
|
+
|
|
3
|
+
On this machine that is almost always MPS (Apple Silicon GPU). The fallback
|
|
4
|
+
order is deliberate: MPS, then CUDA, then CPU.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def pick_device(prefer: str | None = None) -> torch.device:
|
|
11
|
+
"""Return the best available device.
|
|
12
|
+
|
|
13
|
+
Pass `prefer` ("cpu", "mps", "cuda") to override — useful when you suspect
|
|
14
|
+
an MPS-specific bug and want to check the same code on CPU.
|
|
15
|
+
"""
|
|
16
|
+
if prefer is not None:
|
|
17
|
+
return torch.device(prefer)
|
|
18
|
+
if torch.backends.mps.is_available():
|
|
19
|
+
return torch.device("mps")
|
|
20
|
+
if torch.cuda.is_available():
|
|
21
|
+
return torch.device("cuda")
|
|
22
|
+
return torch.device("cpu")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def describe_device() -> str:
|
|
26
|
+
"""A one-block summary of the compute situation, for sanity checks."""
|
|
27
|
+
lines = [
|
|
28
|
+
f"torch : {torch.__version__}",
|
|
29
|
+
f"selected device : {pick_device()}",
|
|
30
|
+
f"mps available : {torch.backends.mps.is_available()}",
|
|
31
|
+
f"mps built : {torch.backends.mps.is_built()}",
|
|
32
|
+
f"cuda available : {torch.cuda.is_available()}",
|
|
33
|
+
]
|
|
34
|
+
return "\n".join(lines)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
print(describe_device())
|
islkit/domain.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Make an INCLUDE clip look like something the device's camera produced.
|
|
2
|
+
|
|
3
|
+
The pretrained head is accurate on INCLUDE and wrong on the board, and the gap
|
|
4
|
+
is not the encoder — INCLUDE clips score 100% top-1
|
|
5
|
+
through the full inference path. It is that INCLUDE clips and board takes are
|
|
6
|
+
drawn from different distributions in four measurable ways. Every constant here
|
|
7
|
+
is measured off the board (2026-09-07, 47 takes), not chosen:
|
|
8
|
+
|
|
9
|
+
| property | INCLUDE | board |
|
|
10
|
+
|-----------------|--------------------|-----------------------------|
|
|
11
|
+
| frame rate | ~25 fps native | 2.1 - 4.7, median ~3 |
|
|
12
|
+
| hands tracked | near always | 43% of frames (0.22 - 0.87) |
|
|
13
|
+
| dropout runs | -- | 1-5 frames, mode 2 |
|
|
14
|
+
| hips | in shot | below the frame |
|
|
15
|
+
| rest frames | ~half the clip | close framing loses them |
|
|
16
|
+
|
|
17
|
+
Training on the raw dump and serving this is the train/serve skew that feature design and
|
|
18
|
+
masking exist to prevent, one level up: not a wrong feature, a wrong
|
|
19
|
+
*distribution*. So rather than demanding the signer stand where INCLUDE's
|
|
20
|
+
signers stood — which is not available on a handheld device — the training
|
|
21
|
+
clips are pushed into the device's domain and the model learns that instead.
|
|
22
|
+
|
|
23
|
+
This augments RAW frames and never encoded ones, for the same reason recordings
|
|
24
|
+
store raw landmarks: the encoder will change again, and an augmentation baked
|
|
25
|
+
into encoded features could not be re-derived.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass, replace
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
|
|
34
|
+
from islkit.features import RawFrame
|
|
35
|
+
|
|
36
|
+
# Body-frame wrist height above which a wrist counts as resting. Imported
|
|
37
|
+
# rather than redefined so this and the segmenter can never disagree about
|
|
38
|
+
# what "at rest" means.
|
|
39
|
+
from islkit.infer import REST_Y, _wrist_height
|
|
40
|
+
|
|
41
|
+
# BlazePose's lower body. On a handheld unit framing head and torso these are
|
|
42
|
+
# never in shot, and MediaPipe extrapolates them anyway — the fabricated hips
|
|
43
|
+
# land ~2.4 shoulder-widths down against INCLUDE's 1.53, which cost 100% ->
|
|
44
|
+
# 62.5% top-1 until `to_include_convention` started masking them.
|
|
45
|
+
LOWER_BODY = [23, 24, 25, 26, 27, 28, 29, 30, 31, 32]
|
|
46
|
+
|
|
47
|
+
INCLUDE_FPS = 25.0 # the dump's native rate
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class DeviceProfile:
|
|
52
|
+
"""The board's camera, as measured. Defaults are measured on an Arduino UNO Q."""
|
|
53
|
+
|
|
54
|
+
fps_low: float = 2.1
|
|
55
|
+
fps_high: float = 4.7
|
|
56
|
+
source_fps: float = INCLUDE_FPS
|
|
57
|
+
# 1 - hand_fraction, median 0.43 across 47 takes
|
|
58
|
+
hand_dropout: float = 0.45
|
|
59
|
+
# Consecutive frames with no dominant wrist, measured {1:15, 2:61, 3:27, 5:2}
|
|
60
|
+
run_lengths: tuple[int, ...] = (1, 2, 3, 5)
|
|
61
|
+
run_weights: tuple[float, ...] = (0.143, 0.581, 0.257, 0.019)
|
|
62
|
+
hide_lower_body: bool = True
|
|
63
|
+
crop_rest: bool = True
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
if len(self.run_lengths) != len(self.run_weights):
|
|
67
|
+
raise ValueError("run_lengths and run_weights must be the same length")
|
|
68
|
+
if not 0.0 <= self.hand_dropout < 1.0:
|
|
69
|
+
raise ValueError(f"hand_dropout must be in [0, 1), got {self.hand_dropout}")
|
|
70
|
+
if self.fps_low <= 0 or self.fps_high < self.fps_low:
|
|
71
|
+
raise ValueError("need 0 < fps_low <= fps_high")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def subsample_to_rate(
|
|
75
|
+
frames: list[RawFrame], target_fps: float, source_fps: float, rng: np.random.Generator
|
|
76
|
+
) -> list[RawFrame]:
|
|
77
|
+
"""Keep every Nth frame, so a 25 fps clip becomes a 3 fps one.
|
|
78
|
+
|
|
79
|
+
This is the augmentation that matters most. `encode_clip` resamples to T
|
|
80
|
+
frames rather than padding, so a sign the camera sampled five times reaches
|
|
81
|
+
the model as a piecewise-linear trajectory with five knots where training
|
|
82
|
+
had forty — and the TCN keys on exactly the fine temporal texture that
|
|
83
|
+
removes. That is why top-1 falls off a cliff below
|
|
84
|
+
5 fps. Training on sparse trajectories puts the model's expectations where
|
|
85
|
+
the device actually operates instead of demanding hardware that cannot
|
|
86
|
+
exist on a Cortex-A53.
|
|
87
|
+
|
|
88
|
+
The phase is random so the same clip yields different sparse views.
|
|
89
|
+
"""
|
|
90
|
+
step = max(1, int(round(source_fps / max(target_fps, 0.1))))
|
|
91
|
+
if step == 1:
|
|
92
|
+
return list(frames)
|
|
93
|
+
offset = int(rng.integers(0, step))
|
|
94
|
+
return frames[offset::step] or frames[:1]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def drop_hands(
|
|
98
|
+
frames: list[RawFrame], profile: DeviceProfile, rng: np.random.Generator
|
|
99
|
+
) -> list[RawFrame]:
|
|
100
|
+
"""Lose the hands in runs, the way MediaPipe actually loses them.
|
|
101
|
+
|
|
102
|
+
Dropping independent frames at 45% would be the wrong noise: the board's
|
|
103
|
+
losses come in runs of 1-5 (mode 2) because the tracker needs a few frames
|
|
104
|
+
to re-acquire. A model trained against independent noise would never see
|
|
105
|
+
the gap structure it has to survive at inference.
|
|
106
|
+
"""
|
|
107
|
+
if profile.hand_dropout <= 0:
|
|
108
|
+
return list(frames)
|
|
109
|
+
|
|
110
|
+
out = list(frames)
|
|
111
|
+
weights = np.asarray(profile.run_weights, float)
|
|
112
|
+
weights = weights / weights.sum()
|
|
113
|
+
target = int(round(profile.hand_dropout * len(out)))
|
|
114
|
+
dropped, guard = 0, 0
|
|
115
|
+
while dropped < target and guard < 100:
|
|
116
|
+
guard += 1
|
|
117
|
+
run = int(rng.choice(profile.run_lengths, p=weights))
|
|
118
|
+
start = int(rng.integers(0, max(1, len(out))))
|
|
119
|
+
for i in range(start, min(start + run, len(out))):
|
|
120
|
+
if out[i].hand_left is None and out[i].hand_right is None:
|
|
121
|
+
continue
|
|
122
|
+
out[i] = replace(out[i], hand_left=None, hand_right=None)
|
|
123
|
+
dropped += 1
|
|
124
|
+
return out
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def hide_lower_body(frames: list[RawFrame]) -> list[RawFrame]:
|
|
128
|
+
"""Push the hips and legs out of shot, as a head-and-torso framing does.
|
|
129
|
+
|
|
130
|
+
Moved outside [0, 1] rather than deleted, because that is what the device
|
|
131
|
+
hands the encoder: MediaPipe still reports a position for an off-camera
|
|
132
|
+
landmark, and `to_include_convention` is what marks it absent. Simulating
|
|
133
|
+
the absence any other way would train against a mask the live path never
|
|
134
|
+
produces.
|
|
135
|
+
"""
|
|
136
|
+
out = []
|
|
137
|
+
for frame in frames:
|
|
138
|
+
if frame.pose is None:
|
|
139
|
+
out.append(frame)
|
|
140
|
+
continue
|
|
141
|
+
pose = frame.pose.copy()
|
|
142
|
+
pose[LOWER_BODY, 1] = np.maximum(pose[LOWER_BODY, 1], 1.05)
|
|
143
|
+
out.append(replace(frame, pose=pose))
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def crop_to_signing(frames: list[RawFrame], dominant: str = "right") -> list[RawFrame]:
|
|
148
|
+
"""Drop the leading and trailing rest, keeping the excursion.
|
|
149
|
+
|
|
150
|
+
A close-framed take has no rest frames because the signer's hands leave the
|
|
151
|
+
bottom of the picture when lowered — measured on the board, the wrist was
|
|
152
|
+
above REST_Y in 2 of 92 frames across three takes. INCLUDE clips are
|
|
153
|
+
roughly half rest, so a model trained on them expects a shape the device
|
|
154
|
+
cannot deliver.
|
|
155
|
+
|
|
156
|
+
Returns the clip unchanged when there is no rest to remove, so a clip that
|
|
157
|
+
is already all signing is not truncated to nothing.
|
|
158
|
+
"""
|
|
159
|
+
heights = [_wrist_height(f, dominant) for f in frames]
|
|
160
|
+
active = [i for i, y in enumerate(heights) if y is not None and y <= REST_Y]
|
|
161
|
+
if not active:
|
|
162
|
+
return list(frames)
|
|
163
|
+
return frames[active[0] : active[-1] + 1]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def to_device_domain(
|
|
167
|
+
frames: list[RawFrame],
|
|
168
|
+
profile: DeviceProfile | None = None,
|
|
169
|
+
rng: np.random.Generator | None = None,
|
|
170
|
+
) -> list[RawFrame]:
|
|
171
|
+
"""One INCLUDE clip -> one plausible board take.
|
|
172
|
+
|
|
173
|
+
Order matters. Cropping happens first, while the wrist track is still dense
|
|
174
|
+
enough to locate the excursion; the hands are dropped last, so the dropout
|
|
175
|
+
rate applies to the frames that actually survive into the take.
|
|
176
|
+
"""
|
|
177
|
+
profile = profile or DeviceProfile()
|
|
178
|
+
rng = rng or np.random.default_rng()
|
|
179
|
+
|
|
180
|
+
out = list(frames)
|
|
181
|
+
if profile.crop_rest:
|
|
182
|
+
out = crop_to_signing(out)
|
|
183
|
+
target_fps = float(rng.uniform(profile.fps_low, profile.fps_high))
|
|
184
|
+
out = subsample_to_rate(out, target_fps, profile.source_fps, rng)
|
|
185
|
+
if profile.hide_lower_body:
|
|
186
|
+
out = hide_lower_body(out)
|
|
187
|
+
return drop_hands(out, profile, rng)
|