File size: 25,194 Bytes
ba4f938
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f771a33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ba4f938
 
 
 
 
 
 
f771a33
 
 
 
 
ba4f938
 
f771a33
ba4f938
 
 
f771a33
 
 
 
 
 
 
 
 
 
 
 
 
ba4f938
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82909cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f771a33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ba4f938
 
 
f771a33
ba4f938
f771a33
 
ba4f938
f771a33
 
ba4f938
 
 
 
f771a33
 
 
 
 
ba4f938
f771a33
ba4f938
 
f771a33
ba4f938
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
"""
preprocessing.py
================
Faithful port of the *always-on* training-time video preprocessing used by
`mamounyosef/sign-language-bridge`.

The adapter was trained with three preprocessing stages applied to **every**
split (train / val / test), so inference must reproduce them or the model sees
an out-of-distribution input distribution and quality collapses:

  1. Pose-guided signer crop  — MediaPipe PoseLandmarker, union of upper-body +
     hand landmarks across the clip, 25 % padding, snapped down to a multiple
     of 32 (Qwen3-VL patch_size 16 x merge_size 2).
  2. CLAHE                    — on the L channel in LAB, clipLimit 2.0, 8x8 tiles.
  3. Landmark overlay         — RTMPose Wholebody (COCO-Wholebody 133 kpts):
     6 upper-body joints + 21 keypoints per hand, drawn as a 1 px skeleton.

Sources this mirrors, in the project repo
(https://github.com/mamounyosef/sign-language-bridge):

  * `data_code/signer_cropper.py`                      -> SignerCropper
  * `data_code/21_extract_landmarks.py`                -> LandmarkExtractor, _postprocess
  * `model_training_scripts/qwen3vl_training.py`
        Qwen3VLCollator._apply_signer_crop             -> apply_signer_crop
        Qwen3VLCollator._apply_clahe_opencv            -> apply_clahe
        Qwen3VLCollator._apply_landmark_overlay        -> apply_landmark_overlay

One deliberate deviation: the training pipeline extracted landmarks offline
from *native-resolution* crops and mapped them onto the decoded frames by
nearest native frame index. Here the landmarks are extracted directly from the
decoded (already pixel-budgeted) crop, so the mapping is 1:1. Landmark
coordinates are normalized to the crop in both cases, so the drawn overlay is
equivalent; this just removes a resampling step.
"""

from __future__ import annotations

import logging
import os

# Silence MediaPipe / glog native-layer INFO + WARNING spam BEFORE importing
# mediapipe -- these env vars are read when the native library loads.
os.environ.setdefault("GLOG_minloglevel", "2")
os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3")
os.environ.setdefault("absl_logging_verbosity", "2")

from dataclasses import dataclass
from typing import Iterable, Optional

import cv2
import numpy as np
import torch

logger = logging.getLogger(__name__)

# Qwen3-VL requires H/W divisible by patch_size * merge_size = 16 * 2 = 32.
SNAP_ALIGN = 32


# ============================================================================
#  1. Pose-guided signer crop  (port of data_code/signer_cropper.py)
# ============================================================================

# Upper-body + hand landmarks (excludes legs/hips for a tight signing-space box):
#   0 nose | 1-6 eyes | 7-8 ears | 9-10 mouth | 11-12 shoulders
#   13-14 elbows | 15-16 wrists | 17-22 hand keypoints
SIGNING_LANDMARK_INDICES: tuple[int, ...] = tuple(range(0, 23))
MIN_LANDMARK_VISIBILITY = 0.5

POSE_MODEL_URLS = {
    "lite": "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/latest/pose_landmarker_lite.task",
    "full": "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_full/float16/latest/pose_landmarker_full.task",
    "heavy": "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_heavy/float16/latest/pose_landmarker_heavy.task",
}


@dataclass(frozen=True)
class BBoxResult:
    """Signer bbox in PIXEL space of the source video (x = width, y = height)."""

    x1: int
    y1: int
    x2: int
    y2: int
    frame_width: int
    frame_height: int
    detection_rate: float
    num_sampled_frames: int
    num_detected_frames: int
    failed: bool


class SignerCropper:
    """One static bbox per clip: union of pose landmarks over sampled frames.

    Using the union (rather than a per-frame box) guarantees a briefly extended
    hand stays inside the crop for the whole clip, which keeps the signing space
    stable -- exactly what the model was trained on.
    """

    def __init__(
        self,
        models_dir: str,
        sample_every_n: int = 4,
        padding_ratio: float = 0.25,
        min_detection_confidence: float = 0.5,
        min_presence_confidence: float = 0.5,
        landmark_indices: Iterable[int] = SIGNING_LANDMARK_INDICES,
        min_landmark_visibility: float = MIN_LANDMARK_VISIBILITY,
        model_variant: str = "full",
    ) -> None:
        self.sample_every_n = int(sample_every_n)
        self.padding_ratio = float(padding_ratio)
        self.landmark_indices = tuple(landmark_indices)
        self.min_landmark_visibility = float(min_landmark_visibility)

        import mediapipe as mp
        from mediapipe.tasks import python as mp_python
        from mediapipe.tasks.python import vision as mp_vision

        self._mp = mp
        self._mp_vision = mp_vision

        model_path = _download_once(
            POSE_MODEL_URLS[model_variant],
            os.path.join(models_dir, f"pose_landmarker_{model_variant}.task"),
        )
        # IMAGE mode is the default; setting running_mode / min_tracking_confidence
        # explicitly has caused proto-binding mismatches in some MediaPipe builds.
        self._options = mp_vision.PoseLandmarkerOptions(
            base_options=mp_python.BaseOptions(model_asset_path=model_path),
            num_poses=1,
            min_pose_detection_confidence=float(min_detection_confidence),
            min_pose_presence_confidence=float(min_presence_confidence),
        )

    def compute_bbox(self, frames_bgr: np.ndarray) -> BBoxResult:
        """frames_bgr: (T, H, W, 3) uint8 BGR (cv2 order). MediaPipe wants RGB."""
        T, H, W, _ = frames_bgr.shape

        sampled = list(range(0, T, self.sample_every_n))
        if sampled and sampled[-1] != T - 1:
            sampled.append(T - 1)
        elif not sampled:
            sampled = [0]

        xs: list[float] = []
        ys: list[float] = []
        num_detected = 0

        with self._mp_vision.PoseLandmarker.create_from_options(self._options) as landmarker:
            for idx in sampled:
                frame_rgb = cv2.cvtColor(frames_bgr[idx], cv2.COLOR_BGR2RGB)
                mp_image = self._mp.Image(
                    image_format=self._mp.ImageFormat.SRGB,
                    data=np.ascontiguousarray(frame_rgb),
                )
                result = landmarker.detect(mp_image)
                if not result.pose_landmarks:
                    continue
                landmarks = result.pose_landmarks[0]  # num_poses=1

                got_any = False
                for lm_idx in self.landmark_indices:
                    lm = landmarks[lm_idx]
                    visibility = getattr(lm, "visibility", 1.0)
                    if visibility is not None and visibility < self.min_landmark_visibility:
                        continue
                    # MediaPipe can predict outside 0..1 when the body is occluded.
                    if not (0.0 <= lm.x <= 1.0 and 0.0 <= lm.y <= 1.0):
                        continue
                    xs.append(lm.x * W)
                    ys.append(lm.y * H)
                    got_any = True
                if got_any:
                    num_detected += 1

        num_sampled = len(sampled)
        detection_rate = num_detected / num_sampled if num_sampled else 0.0

        def _failed() -> BBoxResult:
            return BBoxResult(0, 0, 0, 0, W, H, detection_rate, num_sampled, num_detected, True)

        if not xs:
            return _failed()

        raw_x1, raw_x2 = min(xs), max(xs)
        raw_y1, raw_y2 = min(ys), max(ys)

        pad_x = self.padding_ratio * (raw_x2 - raw_x1)
        pad_y = self.padding_ratio * (raw_y2 - raw_y1)

        x1 = int(round(max(0.0, raw_x1 - pad_x)))
        y1 = int(round(max(0.0, raw_y1 - pad_y)))
        x2 = int(round(min(float(W), raw_x2 + pad_x)))
        y2 = int(round(min(float(H), raw_y2 + pad_y)))

        if x2 <= x1 or y2 <= y1:
            return _failed()

        return BBoxResult(x1, y1, x2, y2, W, H, detection_rate, num_sampled, num_detected, False)


def _snap_down_to_align(lo: int, hi: int, align: int) -> tuple[int, int]:
    """Shrink [lo, hi) so its length is a multiple of `align`, trimming symmetrically."""
    length = hi - lo
    target = (length // align) * align
    if target <= 0 or target == length:
        return lo, hi
    trim = length - target
    left = trim // 2
    return lo + left, lo + left + target


def apply_signer_crop(video: torch.Tensor, bbox: Optional[BBoxResult]) -> torch.Tensor:
    """Crop a decoded (T, C, H, W) clip to its signer bbox.

    The bbox is in source-video pixels; `process_vision_info` returns frames at
    a different resolution, so it is rescaled to the current H/W before slicing
    (mirrors Qwen3VLCollator._apply_signer_crop).
    """
    cur_h, cur_w = int(video.shape[-2]), int(video.shape[-1])

    cx1, cy1, cx2, cy2 = 0, 0, cur_w, cur_h
    bbox_used = False

    if bbox is not None and not bbox.failed:
        sx = cur_w / max(bbox.frame_width, 1)
        sy = cur_h / max(bbox.frame_height, 1)
        bx1 = max(0, int(round(bbox.x1 * sx)))
        by1 = max(0, int(round(bbox.y1 * sy)))
        bx2 = min(cur_w, int(round(bbox.x2 * sx)))
        by2 = min(cur_h, int(round(bbox.y2 * sy)))
        if bx2 - bx1 >= SNAP_ALIGN and by2 - by1 >= SNAP_ALIGN:
            cx1, cy1, cx2, cy2 = bx1, by1, bx2, by2
            bbox_used = True
        else:
            logger.warning("Degenerate signer bbox -> falling back to the full frame.")

    cx1, cx2 = _snap_down_to_align(cx1, cx2, SNAP_ALIGN)
    cy1, cy2 = _snap_down_to_align(cy1, cy2, SNAP_ALIGN)

    take_full_frame = not bbox_used and cx1 == 0 and cy1 == 0 and cx2 == cur_w and cy2 == cur_h
    if take_full_frame:
        return video
    return video[..., cy1:cy2, cx1:cx2].contiguous()


# ============================================================================
#  2. CLAHE  (port of Qwen3VLCollator._apply_clahe_opencv)
# ============================================================================

def apply_clahe(video: torch.Tensor, clip_limit: float = 2.0,
                tile_grid: tuple[int, int] = (8, 8)) -> torch.Tensor:
    """CLAHE on the L channel in LAB. Input/output: (T, C, H, W) uint8 RGB."""
    clahe = cv2.createCLAHE(clipLimit=float(clip_limit), tileGridSize=tuple(tile_grid))
    out = video.clone()
    for t in range(video.shape[0]):
        frame_rgb = video[t].permute(1, 2, 0).contiguous().numpy()
        lab = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2LAB)
        lab[:, :, 0] = clahe.apply(lab[:, :, 0])
        frame_rgb = cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)
        out[t] = torch.from_numpy(frame_rgb).permute(2, 0, 1)
    return out


# ============================================================================
#  3. Landmark overlay  (port of data_code/21_extract_landmarks.py + collator)
# ============================================================================

# COCO-Wholebody 133-keypoint layout.
POSE_INDICES = [5, 6, 7, 8, 9, 10]      # L/R shoulder, L/R elbow, L/R wrist
LEFT_HAND_SLICE = slice(91, 112)        # 21 keypoints
RIGHT_HAND_SLICE = slice(112, 133)      # 21 keypoints
POSE_CONF_THRESHOLD = 0.3               # joints below this -> NaN
HAND_CONF_THRESHOLD = 0.2               # whole hand rejected if mean conf below this

# Pose array stores COCO indices [5,6,7,8,9,10] at positions [0..5].
POSE_CONNECTIONS = [
    (0, 1),  # left shoulder - right shoulder
    (0, 2),  # left shoulder - left elbow
    (2, 4),  # left elbow    - left wrist
    (1, 3),  # right shoulder- right elbow
    (3, 5),  # right elbow   - right wrist
]

HAND_CONNECTIONS = [
    (0, 1), (1, 2), (2, 3), (3, 4),            # thumb
    (0, 5), (5, 6), (6, 7), (7, 8),            # index
    (0, 9), (9, 10), (10, 11), (11, 12),       # middle
    (0, 13), (13, 14), (14, 15), (15, 16),     # ring
    (0, 17), (17, 18), (18, 19), (19, 20),     # pinky
    (5, 9), (9, 13), (13, 17),                 # palm arch
]

# BGR, as drawn by the training collator.
COLOR_POSE = (0, 255, 255)        # yellow
COLOR_LEFT_HAND = (0, 200, 0)     # green
COLOR_RIGHT_HAND = (255, 80, 0)   # blue


def _add_torch_cuda_dlls() -> None:
    """Point the Windows DLL loader at PyTorch's bundled cuDNN/CUDA libraries.

    onnxruntime-gpu needs cudnn64_9.dll, which isn't on PATH by default. PyTorch
    ships cuDNN 9, so adding its lib directory before the first ORT session is
    created is enough. No-op off Windows or without torch. (Ported from
    `data_code/21_extract_landmarks.py` in the project repo, which hit exactly
    this on the training machine.)
    """
    if os.name != "nt":
        return
    try:
        import torch as _torch

        torch_lib = os.path.join(os.path.dirname(_torch.__file__), "lib")
        if os.path.isdir(torch_lib):
            os.add_dll_directory(torch_lib)
    except Exception:  # noqa: BLE001
        pass


def _onnx_providers_available() -> list[str]:
    try:
        import onnxruntime as ort

        return list(ort.get_available_providers())
    except Exception:  # noqa: BLE001
        return []


class LandmarkExtractor:
    """RTMPose Wholebody keypoints, normalized to the crop.

    The training pipeline skips person detection: frames are already tightly
    cropped around the signer, so `bboxes=[]` makes RTMPose treat the full frame
    as the person region -- identical to what a detector would return, minus an
    entire forward pass per frame.

    The `performance` model is an x-large 384x288 network: roughly 1 s per frame
    on CPU, which makes a short clip take minutes. `device="auto"` therefore
    prefers CUDA and only falls back to CPU when no GPU execution provider is
    present.
    """

    def __init__(self, mode: str = "performance", device: str = "auto",
                 backend: str = "onnxruntime") -> None:
        from rtmlib import RTMPose, Wholebody

        if device == "auto":
            _add_torch_cuda_dlls()
            providers = _onnx_providers_available()
            device = "cuda" if "CUDAExecutionProvider" in providers else "cpu"
            if device == "cpu":
                logger.warning(
                    "No CUDA execution provider for onnxruntime (%s); landmark "
                    "extraction will run on CPU and be slow.", providers,
                )
        elif device == "cuda":
            _add_torch_cuda_dlls()

        self.device = device
        mode_cfg = Wholebody.MODE[mode]
        self.model = RTMPose(
            mode_cfg["pose"],
            model_input_size=mode_cfg["pose_input_size"],
            to_openpose=False,
            backend=backend,
            device=device,
        )

    def extract(self, frames_bgr: np.ndarray, crop_w: int, crop_h: int):
        """frames_bgr: (T, H, W, 3) uint8 BGR. Returns pose/lh/rh, NaN where absent."""
        nan6 = np.full((6, 2), np.nan, dtype=np.float32)
        nan21 = np.full((21, 2), np.nan, dtype=np.float32)

        pose_rows, lh_rows, rh_rows = [], [], []
        for frame_bgr in frames_bgr:
            try:
                keypoints, scores = self.model(np.ascontiguousarray(frame_bgr), bboxes=[])
            except Exception as exc:  # noqa: BLE001 - a bad frame must not kill the clip
                logger.warning("RTMPose failed on a frame: %r", exc)
                keypoints = []

            if len(keypoints) == 0:
                pose_rows.append(nan6.copy())
                lh_rows.append(nan21.copy())
                rh_rows.append(nan21.copy())
                continue

            kps = keypoints[0].astype(np.float32)   # (133, 2) pixel coords
            scr = scores[0].astype(np.float32)      # (133,)

            kps_norm = kps.copy()
            kps_norm[:, 0] = np.clip(kps_norm[:, 0] / max(crop_w, 1), 0.0, 1.0)
            kps_norm[:, 1] = np.clip(kps_norm[:, 1] / max(crop_h, 1), 0.0, 1.0)

            pose_row = nan6.copy()
            for out_i, coco_i in enumerate(POSE_INDICES):
                if scr[coco_i] >= POSE_CONF_THRESHOLD:
                    pose_row[out_i] = kps_norm[coco_i]
            pose_rows.append(pose_row)

            # Accept a hand on mean confidence, then NaN out weak individual joints.
            for sl, sink in ((LEFT_HAND_SLICE, lh_rows), (RIGHT_HAND_SLICE, rh_rows)):
                hand_scores = scr[sl]
                if np.mean(hand_scores) >= HAND_CONF_THRESHOLD:
                    row = kps_norm[sl].copy()
                    row[hand_scores < HAND_CONF_THRESHOLD] = np.nan
                else:
                    row = nan21.copy()
                sink.append(row)

        return (
            np.stack(pose_rows).astype(np.float32),
            np.stack(lh_rows).astype(np.float32),
            np.stack(rh_rows).astype(np.float32),
        )


def _hand_centroid(hand: np.ndarray) -> Optional[np.ndarray]:
    valid = hand[~np.any(np.isnan(hand), axis=1)]
    return valid.mean(axis=0) if len(valid) else None


def _reject_spikes(arr: np.ndarray, threshold: float = 0.12) -> np.ndarray:
    """NaN out frames whose centroid deviates from its neighbours' midpoint.

    Catches brief 'teleport' detections. threshold = 12 % of the crop dimension.
    """
    out = arr.copy()
    for t in range(1, arr.shape[0] - 1):
        c_prev = _hand_centroid(arr[t - 1])
        c_curr = _hand_centroid(arr[t])
        c_next = _hand_centroid(arr[t + 1])
        if c_prev is None or c_curr is None or c_next is None:
            continue
        if np.linalg.norm(c_curr - (c_prev + c_next) / 2.0) > threshold:
            out[t] = np.nan
    return out


def _fill_landmark_gaps(arr: np.ndarray, max_gap: int = 8) -> np.ndarray:
    """Forward-fill NaN runs of <= max_gap frames per joint (brief occlusions)."""
    arr = arr.copy()
    _, K, _ = arr.shape
    for k in range(K):
        last_valid = None
        gap_start = None
        for t in range(arr.shape[0]):
            if not np.any(np.isnan(arr[t, k])):
                if gap_start is not None and last_valid is not None and (t - gap_start) <= max_gap:
                    arr[gap_start:t, k] = last_valid
                gap_start = None
                last_valid = arr[t, k].copy()
            elif gap_start is None:
                gap_start = t
    return arr


def _smooth_landmarks(arr: np.ndarray) -> np.ndarray:
    """3-frame weighted smoothing [0.25, 0.5, 0.25], only on fully-valid windows."""
    if arr.shape[0] < 3:
        return arr
    out = arr.copy()
    for t in range(1, arr.shape[0] - 1):
        window = arr[t - 1: t + 2]
        if not np.any(np.isnan(window)):
            out[t] = 0.25 * arr[t - 1] + 0.5 * arr[t] + 0.25 * arr[t + 1]
    return out


def postprocess_landmarks(pose: np.ndarray, lh: np.ndarray, rh: np.ndarray):
    """Spike rejection -> gap fill -> temporal smoothing."""
    lh = _smooth_landmarks(_fill_landmark_gaps(_reject_spikes(lh), max_gap=8))
    rh = _smooth_landmarks(_fill_landmark_gaps(_reject_spikes(rh), max_gap=8))
    pose = _smooth_landmarks(_fill_landmark_gaps(pose, max_gap=8))
    return pose, lh, rh


def apply_landmark_overlay(video: torch.Tensor, pose: np.ndarray,
                           lh: np.ndarray, rh: np.ndarray) -> torch.Tensor:
    """Draw the pose + hand skeleton onto (T, C, H, W) uint8 RGB frames.

    Landmarks are normalized to the crop, so they scale to any resolution:
    px = norm_x * crop_W. Line/dot size is 1 px, matching training.
    """
    T_frames, _, cur_h, cur_w = video.shape
    out = video.clone()

    for t in range(T_frames):
        idx = min(t, pose.shape[0] - 1)

        frame_rgb = video[t].permute(1, 2, 0).contiguous().numpy().copy()
        frame_bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)

        def to_px(nx, ny):
            return (int(round(float(nx) * cur_w)), int(round(float(ny) * cur_h)))

        pose_t = pose[idx]
        for i, j in POSE_CONNECTIONS:
            if np.any(np.isnan(pose_t[i])) or np.any(np.isnan(pose_t[j])):
                continue
            cv2.line(frame_bgr, to_px(*pose_t[i]), to_px(*pose_t[j]),
                     COLOR_POSE, 1, cv2.LINE_AA)
        for i in range(6):
            if not np.any(np.isnan(pose_t[i])):
                cv2.circle(frame_bgr, to_px(*pose_t[i]), 1, COLOR_POSE, -1, cv2.LINE_AA)

        for hand_arr, color in ((lh, COLOR_LEFT_HAND), (rh, COLOR_RIGHT_HAND)):
            hand = hand_arr[idx]
            if np.all(np.isnan(hand)):
                continue
            for i, j in HAND_CONNECTIONS:
                if np.any(np.isnan(hand[i])) or np.any(np.isnan(hand[j])):
                    continue
                cv2.line(frame_bgr, to_px(*hand[i]), to_px(*hand[j]), color, 1, cv2.LINE_AA)
            for i in range(21):
                if not np.any(np.isnan(hand[i])):
                    cv2.circle(frame_bgr, to_px(*hand[i]), 1, color, -1, cv2.LINE_AA)

        frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
        out[t] = torch.from_numpy(frame_rgb).permute(2, 0, 1)

    return out


# ============================================================================
#  Helpers
# ============================================================================

def _download_once(url: str, dest: str) -> str:
    """Download `url` to `dest` unless it is already there. Returns `dest`."""
    import urllib.request

    os.makedirs(os.path.dirname(dest), exist_ok=True)
    if not os.path.exists(dest):
        logger.info("Downloading %s -> %s", url, dest)
        tmp = dest + ".part"
        urllib.request.urlretrieve(url, tmp)
        os.replace(tmp, dest)
    return dest


def resize_video(video: torch.Tensor, target_h: int, target_w: int) -> torch.Tensor:
    """Downscale a (T, C, H, W) uint8 clip, one frame at a time.

    Deliberately not `F.interpolate`: that needs a float32 copy of the whole clip
    (~200 MB for a few seconds of video) before it produces anything, which is
    enough to fail on a memory-constrained machine. cv2 works per frame in uint8,
    and INTER_AREA is the correct filter for downscaling.
    """
    T, C = video.shape[0], video.shape[1]
    out = torch.empty((T, C, target_h, target_w), dtype=torch.uint8)
    for t in range(T):
        frame = video[t].permute(1, 2, 0).contiguous().numpy()
        resized = cv2.resize(frame, (target_w, target_h), interpolation=cv2.INTER_AREA)
        out[t] = torch.from_numpy(resized).permute(2, 0, 1)
    return out


def probe_duration_seconds(path: str) -> float:
    """Clip duration in seconds from container metadata, or 0.0 if unknown."""
    cap = cv2.VideoCapture(path)
    try:
        if not cap.isOpened():
            return 0.0
        fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
        total = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0.0
        return float(total / fps) if fps > 0 and total > 0 else 0.0
    finally:
        cap.release()


def read_video_frames_bgr(
    path: str,
    stride: int = 1,
    max_seconds: Optional[float] = None,
    max_frames: int = 2000,
) -> tuple[np.ndarray, float, int]:
    """Load every `stride`-th frame as (T, H, W, 3) uint8 BGR.

    Only the kept frames are held in memory: decoding a 1080p clip in full would
    cost ~2 GB, and the bbox pass only ever looks at every 4th frame anyway.

    Returns (frames, native_fps, n_frames_scanned) — the scan count is the number
    of source frames traversed, so callers can recover the real clip duration.
    """
    cap = cv2.VideoCapture(path)
    if not cap.isOpened():
        raise IOError(f"Could not open video: {path}")

    fps = cap.get(cv2.CAP_PROP_FPS) or 0.0
    limit = int(max_seconds * fps) if (max_seconds and fps > 0) else None

    frames: list[np.ndarray] = []
    scanned = 0
    stride = max(1, int(stride))
    while len(frames) < max_frames:
        ok, frame = cap.read()
        if not ok:
            break
        if scanned % stride == 0:
            frames.append(frame)
        scanned += 1
        if limit is not None and scanned >= limit:
            break
    cap.release()

    if not frames:
        raise IOError(f"Video contained zero readable frames: {path}")
    return np.stack(frames, axis=0), float(fps), scanned


def write_preview_mp4(video: torch.Tensor, path: str, fps: float = 20.0) -> str:
    """Write a (T, C, H, W) uint8 RGB clip to `path` so users see the real model input.

    Uses imageio-ffmpeg (libx264) rather than cv2.VideoWriter: the pip OpenCV
    builds ship no H.264 encoder, and their mp4v fallback produces MPEG-4 Part 2,
    which most browsers refuse to play. A near-lossless CRF keeps the 1 px
    landmark overlay from being smeared away by compression.
    """
    import imageio.v2 as imageio

    frames = video.permute(0, 2, 3, 1).contiguous().numpy()  # (T, H, W, C) RGB
    writer = imageio.get_writer(
        path,
        fps=fps,
        codec="libx264",
        quality=None,
        output_params=["-crf", "12", "-pix_fmt", "yuv420p"],
        macro_block_size=1,   # dims are already multiples of 32; don't let it pad
    )
    try:
        for frame in frames:
            writer.append_data(frame)
    finally:
        writer.close()
    return path