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