peleg34 commited on
Commit
82909cf
Β·
verified Β·
1 Parent(s): f771a33

Working pipeline: CPU landmarks, memory-aware resolution/decoding controls, verified end-to-end locally

Browse files
Files changed (3) hide show
  1. app.py +613 -395
  2. preprocessing.py +17 -0
  3. requirements.txt +9 -13
app.py CHANGED
@@ -1,395 +1,613 @@
1
- """
2
- sign-language-bridge β€” ASL to English translation demo.
3
-
4
- Runs `mamounyosef/sign-language-bridge` (a multi-tier LoRA/RSLoRA adapter on
5
- Qwen3-VL-2B-Instruct) on an uploaded or webcam-recorded ASL clip.
6
-
7
- The adapter was trained with three *always-on* preprocessing stages, so this
8
- Space reproduces all of them before the model sees a frame β€” see
9
- `preprocessing.py`. The processed clip is returned alongside the translation so
10
- you can see exactly what the model was shown.
11
- """
12
-
13
- import os
14
-
15
- # Must precede every CUDA-touching / native-library import.
16
- os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
17
- os.environ.setdefault("GLOG_minloglevel", "2")
18
- os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3")
19
- # torchvision is the most reliable decoder in the Spaces image.
20
- os.environ.setdefault("FORCE_QWENVL_VIDEO_READER", "torchvision")
21
-
22
- import spaces # noqa: E402 β€” must come before torch
23
-
24
- import tempfile
25
- import time
26
- import traceback
27
-
28
- import gradio as gr
29
- import numpy as np
30
- import torch
31
- from peft import PeftModel
32
- from qwen_vl_utils import process_vision_info
33
- from transformers import AutoModelForImageTextToText, AutoProcessor
34
-
35
- import preprocessing as pp
36
-
37
- # ---------------------------------------------------------------------------
38
- # Configuration β€” mirrors the evaluated checkpoint's config exactly.
39
- # Source: saved_metrics/test_results_qwen3vl/step_4610_optimized_final/summary.txt
40
- # ---------------------------------------------------------------------------
41
- BASE_MODEL = "Qwen/Qwen3-VL-2B-Instruct"
42
- ADAPTER_REPO = "mamounyosef/sign-language-bridge"
43
- ADAPTER_SUBFOLDER = "adapter"
44
-
45
- VIDEO_FPS = 20
46
- VIDEO_MIN_PIXELS = 4 * 32 * 32 # 4096
47
- VIDEO_MAX_PIXELS = 180 * 32 * 32 # 184320
48
- VIDEO_TOTAL_PIXELS = 20480 * 32 * 32 # 20971520
49
-
50
- SYSTEM_PROMPT = "You are a sign language translator."
51
- USER_PROMPT = "Translate this American Sign Language video into English."
52
-
53
- GENERATION_KWARGS = dict(
54
- max_new_tokens=32,
55
- num_beams=5,
56
- length_penalty=0.6,
57
- no_repeat_ngram_size=4,
58
- repetition_penalty=1.1,
59
- do_sample=False,
60
- )
61
-
62
- MAX_CLIP_SECONDS = 15.0 # keeps preprocessing bounded; training clips were short
63
- BBOX_FRAME_STRIDE = 4 # the bbox pass only ever samples every 4th frame
64
-
65
- # Where the MediaPipe .task file is cached. Overridable so the same code runs
66
- # on a Space (ephemeral /tmp) and on a local machine (persistent, any drive).
67
- MODELS_DIR = os.environ.get(
68
- "SLB_MODELS_DIR", os.path.join(tempfile.gettempdir(), "slb_models")
69
- )
70
-
71
- # ---------------------------------------------------------------------------
72
- # Load once, at module scope. ZeroGPU intercepts .to("cuda") here and streams
73
- # the weights into VRAM on the first @spaces.GPU entry.
74
- # ---------------------------------------------------------------------------
75
- print(f"Loading processor + base model: {BASE_MODEL}")
76
- processor = AutoProcessor.from_pretrained(BASE_MODEL)
77
- base_model = AutoModelForImageTextToText.from_pretrained(
78
- BASE_MODEL,
79
- torch_dtype=torch.bfloat16,
80
- attn_implementation="sdpa",
81
- )
82
- print(f"Attaching adapter: {ADAPTER_REPO}/{ADAPTER_SUBFOLDER}")
83
- model = PeftModel.from_pretrained(base_model, ADAPTER_REPO, subfolder=ADAPTER_SUBFOLDER)
84
- model.eval()
85
- model.to("cuda")
86
- print("Model ready.")
87
-
88
- # CPU-only preprocessing models. Built lazily on first use so a cold boot that
89
- # never gets a request does not pay for them, then cached for the process.
90
- _signer_cropper = None
91
- _landmark_extractor = None
92
-
93
-
94
- def _get_signer_cropper():
95
- global _signer_cropper
96
- if _signer_cropper is None:
97
- # sample_every_n=1: the caller hands us frames that are already strided.
98
- _signer_cropper = pp.SignerCropper(
99
- models_dir=MODELS_DIR, model_variant="full", sample_every_n=1
100
- )
101
- return _signer_cropper
102
-
103
-
104
- def _get_landmark_extractor():
105
- global _landmark_extractor
106
- if _landmark_extractor is None:
107
- # "auto" prefers the CUDA execution provider β€” on CPU this model costs
108
- # ~1s/frame, which would dominate the whole request.
109
- _landmark_extractor = pp.LandmarkExtractor(mode="performance", device="auto")
110
- return _landmark_extractor
111
-
112
-
113
- def _estimate_duration(video_path, *args, **kwargs) -> int:
114
- """GPU reservation in seconds. Preprocessing dominates and scales with clip length.
115
-
116
- Gradio passes extra arguments positionally, so the signature has to swallow them.
117
- """
118
- seconds = MAX_CLIP_SECONDS
119
- try:
120
- import cv2
121
-
122
- cap = cv2.VideoCapture(video_path)
123
- total = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0
124
- native_fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
125
- cap.release()
126
- if total > 0 and native_fps > 0:
127
- seconds = min(MAX_CLIP_SECONDS, total / native_fps)
128
- except Exception: # noqa: BLE001
129
- pass
130
- return int(min(240, 60 + 12 * seconds))
131
-
132
-
133
- def _translate_impl(video_path, use_signer_crop, use_clahe, use_landmark_overlay):
134
- if not video_path:
135
- return "", None, "Upload or record a clip first."
136
-
137
- timings = {}
138
- notes = []
139
- t_all = time.perf_counter()
140
-
141
- # -- Native frames: needed for the pose-guided crop, which is computed in
142
- # source-video pixel space exactly as it was during training. Only every
143
- # 4th frame is retained -- that is all the bbox pass samples.
144
- t0 = time.perf_counter()
145
- frames_bgr, native_fps, n_scanned = pp.read_video_frames_bgr(
146
- video_path, stride=BBOX_FRAME_STRIDE, max_seconds=MAX_CLIP_SECONDS
147
- )
148
- timings["decode (native)"] = time.perf_counter() - t0
149
-
150
- native_fps = native_fps if native_fps > 0 else 25.0
151
- full_duration_s = pp.probe_duration_seconds(video_path) or (n_scanned / native_fps)
152
- truncated = full_duration_s > MAX_CLIP_SECONDS + 0.5
153
- duration_s = min(full_duration_s, MAX_CLIP_SECONDS)
154
- if truncated:
155
- notes.append(
156
- f"Clip is {full_duration_s:.1f}s β€” only the first "
157
- f"{MAX_CLIP_SECONDS:.0f}s were translated."
158
- )
159
-
160
- # -- Pose-guided signer bbox (MediaPipe). The frames are already strided, so
161
- # the cropper walks them one by one.
162
- bbox = None
163
- if use_signer_crop:
164
- t0 = time.perf_counter()
165
- try:
166
- bbox = _get_signer_cropper().compute_bbox(frames_bgr)
167
- if bbox.failed:
168
- notes.append("No pose detected β€” the full frame was used instead of a crop.")
169
- bbox = None
170
- else:
171
- notes.append(
172
- f"Signer crop: {bbox.x2 - bbox.x1}x{bbox.y2 - bbox.y1}px from "
173
- f"{bbox.frame_width}x{bbox.frame_height}px "
174
- f"(pose found in {bbox.detection_rate:.0%} of sampled frames)."
175
- )
176
- except Exception as exc: # noqa: BLE001
177
- notes.append(f"Signer crop unavailable ({exc!r}) β€” using the full frame.")
178
- timings["signer crop (MediaPipe)"] = time.perf_counter() - t0
179
- del frames_bgr
180
-
181
- # -- Decode through qwen_vl_utils so the frame sampling and pixel budget
182
- # match training, then apply the crop to the decoded tensor.
183
- video_content = {
184
- "type": "video",
185
- "video": video_path,
186
- "fps": VIDEO_FPS,
187
- "min_pixels": VIDEO_MIN_PIXELS,
188
- "max_pixels": VIDEO_MAX_PIXELS,
189
- "total_pixels": VIDEO_TOTAL_PIXELS,
190
- }
191
- if truncated:
192
- # Bound what the model decodes too, not just the bbox pass.
193
- video_content["video_end"] = MAX_CLIP_SECONDS
194
-
195
- messages = [
196
- {"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT}]},
197
- {
198
- "role": "user",
199
- "content": [video_content, {"type": "text", "text": USER_PROMPT}],
200
- },
201
- ]
202
-
203
- text = processor.apply_chat_template(
204
- messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
205
- )
206
-
207
- t0 = time.perf_counter()
208
- _, videos, video_kwargs = process_vision_info(
209
- messages, image_patch_size=16, return_video_kwargs=True, return_video_metadata=True
210
- )
211
- timings["decode (Qwen sampler)"] = time.perf_counter() - t0
212
-
213
- if not videos:
214
- return "", None, "Could not decode any frames from that clip."
215
-
216
- (video, video_metadata), = videos
217
- if not isinstance(video, torch.Tensor):
218
- video = torch.as_tensor(np.asarray(video))
219
- if video.dtype != torch.uint8:
220
- video = video.clamp(0, 255).to(torch.uint8)
221
-
222
- if use_signer_crop and bbox is not None:
223
- video = pp.apply_signer_crop(video, bbox)
224
-
225
- if use_clahe:
226
- t0 = time.perf_counter()
227
- video = pp.apply_clahe(video)
228
- timings["CLAHE"] = time.perf_counter() - t0
229
-
230
- if use_landmark_overlay:
231
- t0 = time.perf_counter()
232
- try:
233
- frames_rgb = video.permute(0, 2, 3, 1).contiguous().numpy()
234
- frames_bgr_crop = np.ascontiguousarray(frames_rgb[..., ::-1])
235
- cur_h, cur_w = int(video.shape[-2]), int(video.shape[-1])
236
- pose, lh, rh = _get_landmark_extractor().extract(frames_bgr_crop, cur_w, cur_h)
237
- pose, lh, rh = pp.postprocess_landmarks(pose, lh, rh)
238
- video = pp.apply_landmark_overlay(video, pose, lh, rh)
239
-
240
- hands_seen = int(np.mean(
241
- [(~np.all(np.isnan(lh), axis=(1, 2))).mean(),
242
- (~np.all(np.isnan(rh), axis=(1, 2))).mean()]
243
- ) * 100)
244
- notes.append(f"Landmark overlay: hands tracked in ~{hands_seen}% of frames.")
245
- del frames_rgb, frames_bgr_crop
246
- except Exception as exc: # noqa: BLE001
247
- notes.append(
248
- f"Landmark overlay unavailable ({exc!r}). The model expects it β€” "
249
- "output quality will be degraded."
250
- )
251
- timings["landmarks (RTMPose)"] = time.perf_counter() - t0
252
-
253
- n_frames, _, out_h, out_w = video.shape
254
-
255
- # -- Preview of the exact tensor handed to the model.
256
- preview_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
257
- try:
258
- pp.write_preview_mp4(video, preview_path, fps=VIDEO_FPS)
259
- except Exception as exc: # noqa: BLE001
260
- notes.append(f"Could not render the preview clip ({exc!r}).")
261
- preview_path = None
262
-
263
- # -- Generate. do_resize=False: frames are already at the training resolution.
264
- t0 = time.perf_counter()
265
- inputs = processor(
266
- text=[text],
267
- videos=[video],
268
- video_metadata=[video_metadata],
269
- return_tensors="pt",
270
- padding=True,
271
- do_resize=False,
272
- **video_kwargs,
273
- ).to(model.device)
274
-
275
- with torch.inference_mode():
276
- generated = model.generate(**inputs, **GENERATION_KWARGS)
277
-
278
- trimmed = generated[:, inputs.input_ids.shape[1]:]
279
- translation = processor.batch_decode(trimmed, skip_special_tokens=True)[0].strip()
280
- timings["generation"] = time.perf_counter() - t0
281
- timings["total"] = time.perf_counter() - t_all
282
-
283
- info = [
284
- f"**Model input** β€” {n_frames} frames at {VIDEO_FPS} fps, {out_w}x{out_h} px "
285
- f"({duration_s:.1f}s of signing).",
286
- "",
287
- "**Preprocessing**",
288
- ]
289
- info += [f"- {n}" for n in notes] or ["- (all stages disabled)"]
290
- info += ["", "**Timings**"]
291
- info += [f"- {k}: {v:.1f}s" for k, v in timings.items()]
292
-
293
- return translation or "(empty output)", preview_path, "\n".join(info)
294
-
295
-
296
- @spaces.GPU(duration=_estimate_duration)
297
- def translate(video_path, use_signer_crop: bool = True, use_clahe: bool = True,
298
- use_landmark_overlay: bool = True):
299
- """Translate an American Sign Language video clip into English text.
300
-
301
- Args:
302
- video_path: Path to an ASL video clip, 1-15 seconds of continuous signing.
303
- use_signer_crop: Crop to the signer using pose landmarks (training default: on).
304
- use_clahe: Apply CLAHE contrast enhancement (training default: on).
305
- use_landmark_overlay: Draw the pose/hand skeleton overlay (training default: on).
306
-
307
- Returns:
308
- The English translation, the preprocessed clip the model actually saw,
309
- and a breakdown of the preprocessing pipeline that produced it.
310
- """
311
- try:
312
- return _translate_impl(video_path, use_signer_crop, use_clahe, use_landmark_overlay)
313
- except Exception as exc: # noqa: BLE001 β€” surface errors in the UI, not as a stack trace
314
- traceback.print_exc()
315
- return "", None, f"**Something went wrong**\n\n```\n{exc!r}\n```"
316
-
317
-
318
- # ---------------------------------------------------------------------------
319
- # UI
320
- # ---------------------------------------------------------------------------
321
- DESCRIPTION = """
322
- # 🀟 sign-language-bridge β€” ASL β†’ English
323
-
324
- Continuous **American Sign Language** translation with
325
- [`mamounyosef/sign-language-bridge`](https://huggingface.co/mamounyosef/sign-language-bridge):
326
- a multi-tier LoRA / RSLoRA fine-tune of
327
- [`Qwen3-VL-2B-Instruct`](https://huggingface.co/Qwen/Qwen3-VL-2B-Instruct)
328
- trained on How2Sign + OpenASL.
329
-
330
- Upload a clip or record one with your webcam. Clips of **1–15 seconds** of
331
- continuous signing, framed head-and-shoulders with good lighting, work best.
332
-
333
- > ⚠️ **Research preview.** BLEU-4 is 1.64 and WER is 112 % on the author's
334
- > How2Sign test partition β€” the model produces fluent English that is often
335
- > topically right but frequently disagrees with the reference word-for-word.
336
- > It can be confidently wrong. Do not use it where a mistranslation could cause
337
- > harm (medical, legal, safety-critical, or emergency settings).
338
- """
339
-
340
- PIPELINE_NOTE = """
341
- ### Why the processed clip looks like that
342
-
343
- The adapter was trained with three preprocessing stages applied to **every**
344
- clip, so inference has to reproduce them:
345
-
346
- 1. **Pose-guided signer crop** β€” MediaPipe pose landmarks are unioned across the
347
- clip and padded 25 %, giving one stable box around the signing space.
348
- 2. **CLAHE** β€” contrast equalisation on the L channel in LAB (clip 2.0, 8Γ—8 tiles).
349
- 3. **Landmark overlay** β€” RTMPose Wholebody draws 6 upper-body joints (yellow)
350
- and 21 keypoints per hand (green = left, blue = right) directly onto the pixels.
351
-
352
- Turning any of them off shows you how much the model leans on them β€” the output
353
- usually gets noticeably worse.
354
- """
355
-
356
- with gr.Blocks(title="sign-language-bridge β€” ASL to English") as demo:
357
- gr.Markdown(DESCRIPTION)
358
-
359
- with gr.Row():
360
- with gr.Column(scale=1):
361
- video_in = gr.Video(
362
- label="ASL clip",
363
- sources=["upload", "webcam"],
364
- include_audio=False,
365
- )
366
- with gr.Accordion("Preprocessing (training defaults: all on)", open=False):
367
- crop_cb = gr.Checkbox(value=True, label="Pose-guided signer crop")
368
- clahe_cb = gr.Checkbox(value=True, label="CLAHE contrast enhancement")
369
- overlay_cb = gr.Checkbox(value=True, label="Landmark skeleton overlay")
370
- run_btn = gr.Button("Translate", variant="primary")
371
-
372
- with gr.Column(scale=1):
373
- translation_out = gr.Textbox(
374
- label="English translation",
375
- lines=3,
376
- show_copy_button=True,
377
- )
378
- preview_out = gr.Video(label="What the model actually saw", autoplay=True)
379
- info_out = gr.Markdown()
380
-
381
- gr.Markdown(PIPELINE_NOTE)
382
-
383
- run_btn.click(
384
- fn=translate,
385
- inputs=[video_in, crop_cb, clahe_cb, overlay_cb],
386
- outputs=[translation_out, preview_out, info_out],
387
- api_name="translate",
388
- )
389
-
390
- if __name__ == "__main__":
391
- # SLB_OPEN_BROWSER is set by the local launcher; on a Space it is unset.
392
- demo.queue(max_size=12).launch(
393
- mcp_server=True,
394
- inbrowser=os.environ.get("SLB_OPEN_BROWSER") == "1",
395
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ sign-language-bridge β€” ASL to English translation demo.
3
+
4
+ Runs `mamounyosef/sign-language-bridge` (a multi-tier LoRA/RSLoRA adapter on
5
+ Qwen3-VL-2B-Instruct) on an uploaded or webcam-recorded ASL clip.
6
+
7
+ The adapter was trained with three *always-on* preprocessing stages, so this
8
+ Space reproduces all of them before the model sees a frame β€” see
9
+ `preprocessing.py`. The processed clip is returned alongside the translation so
10
+ you can see exactly what the model was shown.
11
+ """
12
+
13
+ import os
14
+
15
+ # Must precede every CUDA-touching / native-library import.
16
+ # expandable_segments is a Linux/ZeroGPU memory-fragmentation fix. On Windows it
17
+ # is unsupported by torch 2.6 and makes the allocator fail small allocations
18
+ # while reporting gigabytes free, so it is scoped to non-Windows.
19
+ if os.name != "nt":
20
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
21
+ os.environ.setdefault("GLOG_minloglevel", "2")
22
+ os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3")
23
+ # torchvision is the most reliable decoder in the Spaces image.
24
+ os.environ.setdefault("FORCE_QWENVL_VIDEO_READER", "torchvision")
25
+
26
+ import spaces # noqa: E402 β€” must come before torch
27
+
28
+ import gc
29
+ import shutil
30
+ import tempfile
31
+ import time
32
+ import traceback
33
+
34
+ import gradio as gr
35
+ import numpy as np
36
+ import torch
37
+ from huggingface_hub import snapshot_download
38
+ from peft import PeftModel
39
+ from qwen_vl_utils import process_vision_info
40
+ from transformers import AutoModelForImageTextToText, AutoProcessor
41
+
42
+ import preprocessing as pp
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Configuration β€” mirrors the evaluated checkpoint's config exactly.
46
+ # Source: saved_metrics/test_results_qwen3vl/step_4610_optimized_final/summary.txt
47
+ # ---------------------------------------------------------------------------
48
+ BASE_MODEL = "Qwen/Qwen3-VL-2B-Instruct"
49
+ ADAPTER_REPO = "mamounyosef/sign-language-bridge"
50
+ ADAPTER_SUBFOLDER = "adapter"
51
+
52
+ VIDEO_FPS = 20
53
+ VIDEO_MIN_PIXELS = 4 * 32 * 32 # 4096
54
+ VIDEO_MAX_PIXELS = 180 * 32 * 32 # 184320
55
+ VIDEO_TOTAL_PIXELS = 20480 * 32 * 32 # 20971520
56
+
57
+ SYSTEM_PROMPT = "You are a sign language translator."
58
+ USER_PROMPT = "Translate this American Sign Language video into English."
59
+
60
+ GENERATION_KWARGS = dict(
61
+ max_new_tokens=32,
62
+ num_beams=5,
63
+ length_penalty=0.6,
64
+ no_repeat_ngram_size=4,
65
+ repetition_penalty=1.1,
66
+ do_sample=False,
67
+ )
68
+
69
+ MAX_CLIP_SECONDS = 10.0 # keeps memory and preprocessing bounded; training clips were short
70
+ BBOX_FRAME_STRIDE = 4 # the bbox pass only ever samples every 4th frame
71
+
72
+ # Qwen3-VL requires frame dimensions to be a multiple of patch_size x merge_size.
73
+ SNAP = 32
74
+
75
+ # How large a frame the vision tower is asked to encode, as a fraction of the
76
+ # training resolution. The tower encodes every temporal patch in one forward
77
+ # pass, so this β€” not beam width β€” is what decides whether a clip fits in memory.
78
+ #
79
+ # Measured on a 12 GB RTX 3060 with ~6 GB of host commit free: full resolution
80
+ # needs greedy decoding and often will not fit at all, while half resolution runs
81
+ # the full 5-beam search comfortably. Picking the size up front matters: letting
82
+ # a full-resolution attempt fail and retrying tends to leave the CUDA context in
83
+ # a bad state ("CUDA error: unknown error") rather than recovering cleanly.
84
+ RESOLUTION_PRESETS = {
85
+ "Full (training resolution β€” needs a large GPU)": 1.0,
86
+ "Half (recommended β€” fits a 12 GB card)": 0.5,
87
+ "Quarter (last resort)": 0.375,
88
+ }
89
+ DEFAULT_RESOLUTION = "Half (recommended β€” fits a 12 GB card)"
90
+
91
+ # Beam search replicates the video tensor before the vision tower runs, so 5
92
+ # beams means encoding the clip five times. That is by far the largest memory
93
+ # consumer: on a 12 GB card, greedy decoding fits at full resolution while 5-beam
94
+ # search does not. The evaluation numbers in the model card assume 5 beams.
95
+ DECODING_PRESETS = {
96
+ "Greedy (recommended β€” fits in ~12 GB)": 1,
97
+ "Beam search x5 (matches the paper; needs a big GPU)": 5,
98
+ }
99
+ DEFAULT_DECODING = "Greedy (recommended β€” fits in ~12 GB)"
100
+
101
+ # Where the MediaPipe .task file is cached. Overridable so the same code runs
102
+ # on a Space (ephemeral /tmp) and on a local machine (persistent, any drive).
103
+ MODELS_DIR = os.environ.get(
104
+ "SLB_MODELS_DIR", os.path.join(tempfile.gettempdir(), "slb_models")
105
+ )
106
+
107
+
108
+ def _check_ffmpeg_tooling() -> None:
109
+ """Report on the ffmpeg tooling Gradio's video component depends on.
110
+
111
+ Deliberately does NOT put a bare `ffmpeg` on PATH. Gradio only probes an
112
+ output video's codec when it finds `ffmpeg` there, and that probe shells out
113
+ to `ffprobe` -- so a *partial* install (ffmpeg but no ffprobe, which is
114
+ exactly what imageio-ffmpeg provides) makes every response fail with
115
+ FFExecutableNotFoundError. With neither on PATH, Gradio skips the check and
116
+ serves the file as-is, which is correct here: the preview is written as
117
+ H.264 / yuv420p in an .mp4, already browser-playable. The input side needs no
118
+ ffmpeg either, because the component is created with `format=None`.
119
+
120
+ Spaces images ship both binaries, so the full path runs there.
121
+ """
122
+ have_ffmpeg = shutil.which("ffmpeg") is not None
123
+ have_ffprobe = shutil.which("ffprobe") is not None
124
+ if have_ffmpeg and not have_ffprobe:
125
+ print(
126
+ "WARNING: `ffmpeg` is on PATH but `ffprobe` is not. Gradio needs both; "
127
+ "video responses may fail. Install a complete ffmpeg build, or remove "
128
+ "ffmpeg from PATH to make Gradio skip the codec probe.",
129
+ flush=True,
130
+ )
131
+
132
+
133
+ _check_ffmpeg_tooling()
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # Load once, at module scope. ZeroGPU intercepts .to("cuda") here and streams
137
+ # the weights into VRAM on the first @spaces.GPU entry.
138
+ # ---------------------------------------------------------------------------
139
+ print(f"Loading processor + base model: {BASE_MODEL}")
140
+ processor = AutoProcessor.from_pretrained(BASE_MODEL)
141
+
142
+ # low_cpu_mem_usage memory-maps the checkpoint instead of materialising all
143
+ # 4.3 GB in host RAM before the GPU copy. Without it, peak host usage is roughly
144
+ # double, which on a 16 GB machine under memory pressure surfaces as a CUDA OOM
145
+ # that confusingly reports gigabytes of VRAM still free.
146
+ #
147
+ # Note: NOT device_map="cuda". That routes through accelerate, which both
148
+ # bypasses the ZeroGPU hijack on a Space and segfaults here in the meta-device
149
+ # loader when host memory is tight.
150
+ base_model = AutoModelForImageTextToText.from_pretrained(
151
+ BASE_MODEL,
152
+ dtype=torch.bfloat16,
153
+ attn_implementation="sdpa",
154
+ low_cpu_mem_usage=True,
155
+ )
156
+ # Place the base on the GPU BEFORE attaching the adapter. This adapter carries
157
+ # `modules_to_save` (the embedding matrix and output head, ~721 MB), and PEFT
158
+ # materialises those as real CPU tensors plus copies of the originals. Doing
159
+ # that while the base is still resident in host RAM pushes a 16 GB machine over
160
+ # its commit limit, which the CUDA driver reports as an OOM despite free VRAM.
161
+ base_model.to("cuda")
162
+ print(f"Attaching adapter: {ADAPTER_REPO}/{ADAPTER_SUBFOLDER}")
163
+ # Resolve the adapter to a local directory rather than passing repo + subfolder
164
+ # to PEFT. Two reasons:
165
+ # 1. PEFT builds the remote filename with os.path.join, so on Windows the
166
+ # existence probe asks the Hub for "adapter\adapter_model.safetensors";
167
+ # that never matches, and it falls back to a .bin that isn't there.
168
+ # 2. allow_patterns skips training_state.pt (~600 MB of optimizer/InfoNCE
169
+ # state) which is only needed to resume training, never for inference.
170
+ _adapter_dir = os.path.join(
171
+ snapshot_download(ADAPTER_REPO, allow_patterns=[f"{ADAPTER_SUBFOLDER}/*"]),
172
+ ADAPTER_SUBFOLDER,
173
+ )
174
+ model = PeftModel.from_pretrained(base_model, _adapter_dir)
175
+ model.eval()
176
+ # On ZeroGPU this is intercepted at module scope and the weights are streamed
177
+ # into VRAM on the first @spaces.GPU entry; locally it is a plain copy.
178
+ model.to("cuda")
179
+ print(f"Model ready on {next(model.parameters()).device}.")
180
+
181
+ # CPU-only preprocessing models. Built lazily on first use so a cold boot that
182
+ # never gets a request does not pay for them, then cached for the process.
183
+ _signer_cropper = None
184
+ _landmark_extractor = None
185
+
186
+
187
+ def _get_signer_cropper():
188
+ global _signer_cropper
189
+ if _signer_cropper is None:
190
+ # sample_every_n=1: the caller hands us frames that are already strided.
191
+ _signer_cropper = pp.SignerCropper(
192
+ models_dir=MODELS_DIR, model_variant="full", sample_every_n=1
193
+ )
194
+ return _signer_cropper
195
+
196
+
197
+ def _get_landmark_extractor():
198
+ """RTMPose Wholebody, deliberately pinned to the CPU.
199
+
200
+ Training used the `performance` model (x-large, 288x384 input). `balanced` is
201
+ the same backbone at 192x256, and measured here it is *faster on CPU*
202
+ (47 ms/frame) than `performance` is on the GPU (105 ms/frame) -- while
203
+ leaving the GPU entirely to the translation model.
204
+
205
+ Keeping ONNX Runtime off the GPU also avoids two real failures: its CUDA
206
+ arena competes with PyTorch for VRAM and host commit, and tearing the session
207
+ down mid-request left PyTorch unable to find cuDNN kernels
208
+ ("GET was unable to find an engine to execute this computation").
209
+ """
210
+ global _landmark_extractor
211
+ if _landmark_extractor is None:
212
+ _landmark_extractor = pp.LandmarkExtractor(mode="balanced", device="cpu")
213
+ return _landmark_extractor
214
+
215
+
216
+
217
+
218
+ def _estimate_duration(video_path, *args, **kwargs) -> int:
219
+ """GPU reservation in seconds. Preprocessing dominates and scales with clip length.
220
+
221
+ Gradio passes extra arguments positionally, so the signature has to swallow them.
222
+ """
223
+ seconds = MAX_CLIP_SECONDS
224
+ try:
225
+ import cv2
226
+
227
+ cap = cv2.VideoCapture(video_path)
228
+ total = cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0
229
+ native_fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
230
+ cap.release()
231
+ if total > 0 and native_fps > 0:
232
+ seconds = min(MAX_CLIP_SECONDS, total / native_fps)
233
+ except Exception: # noqa: BLE001
234
+ pass
235
+ return int(min(240, 60 + 12 * seconds))
236
+
237
+
238
+ def _generate_once(inputs, beams: int):
239
+ """Run generation exactly once at the requested beam width.
240
+
241
+ Deliberately no retry-on-OOM. Beam search replicates `pixel_values_videos`
242
+ *before* the vision tower runs, so N beams means encoding the whole clip N
243
+ times -- it is the single largest memory consumer here, far more than the
244
+ weights. Catching an OOM mid-`generate` and retrying in the same process was
245
+ measured to leave the CUDA context unusable ("unknown error", "illegal
246
+ memory access") rather than recovering, so the caller picks a size that fits
247
+ up front and a failure is reported honestly instead of papered over.
248
+ """
249
+ if inputs.get("video_grid_thw") is not None and beams > 1:
250
+ # Qwen3-VL emits per-frame timestamps, so the beam-search input expansion
251
+ # splits video_grid_thw by a count equal to the number of temporal patches
252
+ # rather than the number of videos. Rewriting [[T,H,W]] as T rows of
253
+ # [1,H,W] makes the split line up. (Same workaround as the project's own
254
+ # eval script.) Greedy decoding does no expansion and needs it left alone.
255
+ vgt = inputs["video_grid_thw"]
256
+ vgt = torch.repeat_interleave(vgt, vgt[:, 0], dim=0).clone()
257
+ vgt[:, 0] = 1
258
+ inputs["video_grid_thw"] = vgt
259
+
260
+ kwargs = dict(GENERATION_KWARGS, num_beams=beams)
261
+ if beams == 1:
262
+ kwargs.pop("length_penalty", None) # meaningless without beam search
263
+
264
+ with torch.inference_mode(), torch.amp.autocast("cuda", dtype=torch.bfloat16):
265
+ return model.generate(
266
+ **inputs,
267
+ **kwargs,
268
+ # temperature / top_p / top_k are baked into generation_config.json but
269
+ # unused here; passing None silences the invalid-flag warning.
270
+ temperature=None,
271
+ top_p=None,
272
+ top_k=None,
273
+ use_cache=True,
274
+ )
275
+
276
+
277
+ def _translate_impl(video_path, use_signer_crop, use_clahe, use_landmark_overlay,
278
+ resolution, decoding):
279
+ if not video_path:
280
+ return "", None, "Upload or record a clip first."
281
+
282
+ timings = {}
283
+ notes = []
284
+ t_all = time.perf_counter()
285
+
286
+ # -- Native frames: needed for the pose-guided crop, which is computed in
287
+ # source-video pixel space exactly as it was during training. Only every
288
+ # 4th frame is retained -- that is all the bbox pass samples.
289
+ t0 = time.perf_counter()
290
+ frames_bgr, native_fps, n_scanned = pp.read_video_frames_bgr(
291
+ video_path, stride=BBOX_FRAME_STRIDE, max_seconds=MAX_CLIP_SECONDS
292
+ )
293
+ timings["decode (native)"] = time.perf_counter() - t0
294
+
295
+ native_fps = native_fps if native_fps > 0 else 25.0
296
+ full_duration_s = pp.probe_duration_seconds(video_path) or (n_scanned / native_fps)
297
+ truncated = full_duration_s > MAX_CLIP_SECONDS + 0.5
298
+ duration_s = min(full_duration_s, MAX_CLIP_SECONDS)
299
+ if truncated:
300
+ notes.append(
301
+ f"Clip is {full_duration_s:.1f}s β€” only the first "
302
+ f"{MAX_CLIP_SECONDS:.0f}s were translated."
303
+ )
304
+
305
+ # -- Pose-guided signer bbox (MediaPipe). The frames are already strided, so
306
+ # the cropper walks them one by one.
307
+ bbox = None
308
+ if use_signer_crop:
309
+ t0 = time.perf_counter()
310
+ try:
311
+ bbox = _get_signer_cropper().compute_bbox(frames_bgr)
312
+ if bbox.failed:
313
+ notes.append("No pose detected β€” the full frame was used instead of a crop.")
314
+ bbox = None
315
+ else:
316
+ notes.append(
317
+ f"Signer crop: {bbox.x2 - bbox.x1}x{bbox.y2 - bbox.y1}px from "
318
+ f"{bbox.frame_width}x{bbox.frame_height}px "
319
+ f"(pose found in {bbox.detection_rate:.0%} of sampled frames)."
320
+ )
321
+ except Exception as exc: # noqa: BLE001
322
+ notes.append(f"Signer crop unavailable ({exc!r}) β€” using the full frame.")
323
+ timings["signer crop (MediaPipe)"] = time.perf_counter() - t0
324
+ del frames_bgr
325
+
326
+ # -- Decode + preprocess + generate, stepping the pixel budget down if the
327
+ # vision tower cannot fit. The tower encodes every temporal patch in one
328
+ # forward pass, so peak memory is driven by total_pixels far more than by
329
+ # beam width; on a 12 GB card the training budget does not always fit.
330
+ # Landmarks are normalised to the crop, so they are extracted once at the
331
+ # first (largest) resolution and reused verbatim by every later attempt.
332
+ video_content = {
333
+ "type": "video",
334
+ "video": video_path,
335
+ "fps": VIDEO_FPS,
336
+ "min_pixels": VIDEO_MIN_PIXELS,
337
+ "max_pixels": VIDEO_MAX_PIXELS,
338
+ "total_pixels": VIDEO_TOTAL_PIXELS,
339
+ }
340
+ if truncated:
341
+ # Bound what the model decodes too, not just the bbox pass.
342
+ video_content["video_end"] = MAX_CLIP_SECONDS
343
+
344
+ messages = [
345
+ {"role": "system", "content": [{"type": "text", "text": SYSTEM_PROMPT}]},
346
+ {"role": "user", "content": [video_content, {"type": "text", "text": USER_PROMPT}]},
347
+ ]
348
+ text = processor.apply_chat_template(
349
+ messages, tokenize=False, add_generation_prompt=True, enable_thinking=False
350
+ )
351
+
352
+ t0 = time.perf_counter()
353
+ _, videos, video_kwargs = process_vision_info(
354
+ messages, image_patch_size=16, return_video_kwargs=True, return_video_metadata=True
355
+ )
356
+ timings["decode (Qwen sampler)"] = time.perf_counter() - t0
357
+ if not videos:
358
+ return "", None, "Could not decode any frames from that clip."
359
+
360
+ (video, video_metadata), = videos
361
+ if not isinstance(video, torch.Tensor):
362
+ video = torch.as_tensor(np.asarray(video))
363
+ if video.dtype != torch.uint8:
364
+ video = video.clamp(0, 255).to(torch.uint8)
365
+
366
+ if use_signer_crop and bbox is not None:
367
+ video = pp.apply_signer_crop(video, bbox)
368
+
369
+ if use_clahe:
370
+ t0 = time.perf_counter()
371
+ video = pp.apply_clahe(video)
372
+ timings["CLAHE"] = time.perf_counter() - t0
373
+
374
+ # Landmarks are normalised to the crop, so one extraction serves every scale
375
+ # in the retry ladder below. This is also by far the most expensive stage.
376
+ landmarks = None
377
+ if use_landmark_overlay:
378
+ t0 = time.perf_counter()
379
+ try:
380
+ frames_rgb = video.permute(0, 2, 3, 1).contiguous().numpy()
381
+ frames_bgr_crop = np.ascontiguousarray(frames_rgb[..., ::-1])
382
+ cur_h, cur_w = int(video.shape[-2]), int(video.shape[-1])
383
+
384
+ pose, lh, rh = _get_landmark_extractor().extract(frames_bgr_crop, cur_w, cur_h)
385
+ landmarks = pp.postprocess_landmarks(pose, lh, rh)
386
+ del frames_rgb, frames_bgr_crop
387
+
388
+ hands_seen = int(np.mean([
389
+ (~np.all(np.isnan(landmarks[1]), axis=(1, 2))).mean(),
390
+ (~np.all(np.isnan(landmarks[2]), axis=(1, 2))).mean(),
391
+ ]) * 100)
392
+ notes.append(f"Landmark overlay: hands tracked in ~{hands_seen}% of frames.")
393
+ except Exception as exc: # noqa: BLE001
394
+ notes.append(
395
+ f"Landmark overlay unavailable ({exc!r}). The model expects it β€” "
396
+ "output quality will be degraded."
397
+ )
398
+ timings["landmarks (RTMPose)"] = time.perf_counter() - t0
399
+
400
+ base_h, base_w = int(video.shape[-2]), int(video.shape[-1])
401
+
402
+ # The vision tower encodes every temporal patch in one forward pass, and beam
403
+ # search replicates the pixel tensor before it does. Both are sized up front
404
+ # from the user's choices rather than discovered by failing.
405
+ scale = RESOLUTION_PRESETS.get(resolution, RESOLUTION_PRESETS[DEFAULT_RESOLUTION])
406
+ beams_used = DECODING_PRESETS.get(decoding, DECODING_PRESETS[DEFAULT_DECODING])
407
+
408
+ target_h = max(SNAP, (int(base_h * scale) // SNAP) * SNAP)
409
+ target_w = max(SNAP, (int(base_w * scale) // SNAP) * SNAP)
410
+
411
+ same_size = (target_h, target_w) == (base_h, base_w)
412
+ attempt = video if same_size else pp.resize_video(video, target_h, target_w)
413
+ del video
414
+ # Redraw the skeleton at the working resolution so the 1 px strokes stay
415
+ # crisp, exactly as training drew them onto already-resized frames.
416
+ if landmarks is not None:
417
+ attempt = pp.apply_landmark_overlay(attempt, *landmarks)
418
+
419
+ n_frames, _, out_h, out_w = attempt.shape
420
+
421
+ preview_path = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False).name
422
+ try:
423
+ pp.write_preview_mp4(attempt, preview_path, fps=VIDEO_FPS)
424
+ except Exception as exc: # noqa: BLE001
425
+ notes.append(f"Could not render the preview clip ({exc!r}).")
426
+ preview_path = None
427
+
428
+ # do_resize=False: frames are already at the intended resolution.
429
+ t0 = time.perf_counter()
430
+ inputs = processor(
431
+ text=[text],
432
+ videos=[attempt],
433
+ video_metadata=[video_metadata],
434
+ return_tensors="pt",
435
+ padding=True,
436
+ do_resize=False,
437
+ **video_kwargs,
438
+ ).to(model.device)
439
+
440
+ del attempt
441
+ gc.collect()
442
+ torch.cuda.empty_cache()
443
+
444
+ generated = _generate_once(inputs, beams_used)
445
+ trimmed = [out[len(inp):] for inp, out in zip(inputs["input_ids"], generated)]
446
+ translation = processor.batch_decode(
447
+ trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
448
+ )[0].strip()
449
+ timings["generation"] = time.perf_counter() - t0
450
+ del inputs, generated
451
+ gc.collect()
452
+ torch.cuda.empty_cache()
453
+
454
+ downscaled = (out_h, out_w) != (base_h, base_w)
455
+
456
+ if beams_used != GENERATION_KWARGS["num_beams"]:
457
+ notes.append(
458
+ f"Decoded greedily rather than with the "
459
+ f"{GENERATION_KWARGS['num_beams']}-beam search the published metrics used."
460
+ )
461
+ if downscaled:
462
+ notes.append(
463
+ f"Frames downscaled from {base_w}x{base_h} to {out_w}x{out_h} β€” "
464
+ "raise *Input resolution* to feed the model the training resolution."
465
+ )
466
+ if beams_used != GENERATION_KWARGS["num_beams"] or downscaled:
467
+ notes.append(
468
+ "Output therefore differs from the published evaluation setup."
469
+ )
470
+
471
+ timings["total"] = time.perf_counter() - t_all
472
+
473
+ info = [
474
+ f"**Model input** β€” {n_frames} frames at {VIDEO_FPS} fps, {out_w}x{out_h} px "
475
+ f"({duration_s:.1f}s of signing), {beams_used} beam(s).",
476
+ "",
477
+ "**Preprocessing**",
478
+ ]
479
+ info += [f"- {n}" for n in notes] or ["- (all stages disabled)"]
480
+ info += ["", "**Timings**"]
481
+ info += [f"- {k}: {v:.1f}s" for k, v in timings.items()]
482
+
483
+ return translation or "(empty output)", preview_path, "\n".join(info)
484
+
485
+
486
+ @spaces.GPU(duration=_estimate_duration)
487
+ def translate(video_path, use_signer_crop: bool = True, use_clahe: bool = True,
488
+ use_landmark_overlay: bool = True, resolution: str = DEFAULT_RESOLUTION,
489
+ decoding: str = DEFAULT_DECODING):
490
+ """Translate an American Sign Language video clip into English text.
491
+
492
+ Args:
493
+ video_path: Path to an ASL video clip, 1-10 seconds of continuous signing.
494
+ use_signer_crop: Crop to the signer using pose landmarks (training default: on).
495
+ use_clahe: Apply CLAHE contrast enhancement (training default: on).
496
+ use_landmark_overlay: Draw the pose/hand skeleton overlay (training default: on).
497
+ resolution: How large a frame the vision tower encodes. Higher is closer
498
+ to the training setup but needs considerably more GPU memory.
499
+ decoding: Greedy, or the 5-beam search the published metrics used. Beam
500
+ search re-encodes the clip once per beam and needs far more memory.
501
+
502
+ Returns:
503
+ The English translation, the preprocessed clip the model actually saw,
504
+ and a breakdown of the preprocessing pipeline that produced it.
505
+ """
506
+ try:
507
+ return _translate_impl(
508
+ video_path, use_signer_crop, use_clahe, use_landmark_overlay,
509
+ resolution, decoding,
510
+ )
511
+ except torch.OutOfMemoryError:
512
+ traceback.print_exc()
513
+ return "", None, (
514
+ "**Out of GPU memory**\n\nEven the fallback size did not fit. Try a "
515
+ "shorter clip or a smaller setting under *Input resolution*, and close "
516
+ "other GPU or memory-heavy applications (browsers especially)."
517
+ )
518
+ except Exception as exc: # noqa: BLE001 β€” surface errors in the UI, not as a stack trace
519
+ traceback.print_exc()
520
+ return "", None, f"**Something went wrong**\n\n```\n{exc!r}\n```"
521
+
522
+
523
+ # ---------------------------------------------------------------------------
524
+ # UI
525
+ # ---------------------------------------------------------------------------
526
+ DESCRIPTION = """
527
+ # 🀟 sign-language-bridge β€” ASL β†’ English
528
+
529
+ Continuous **American Sign Language** translation with
530
+ [`mamounyosef/sign-language-bridge`](https://huggingface.co/mamounyosef/sign-language-bridge):
531
+ a multi-tier LoRA / RSLoRA fine-tune of
532
+ [`Qwen3-VL-2B-Instruct`](https://huggingface.co/Qwen/Qwen3-VL-2B-Instruct)
533
+ trained on How2Sign + OpenASL.
534
+
535
+ Upload a clip or record one with your webcam. Clips of **1–15 seconds** of
536
+ continuous signing, framed head-and-shoulders with good lighting, work best.
537
+
538
+ > ⚠️ **Research preview.** BLEU-4 is 1.64 and WER is 112 % on the author's
539
+ > How2Sign test partition β€” the model produces fluent English that is often
540
+ > topically right but frequently disagrees with the reference word-for-word.
541
+ > It can be confidently wrong. Do not use it where a mistranslation could cause
542
+ > harm (medical, legal, safety-critical, or emergency settings).
543
+ """
544
+
545
+ PIPELINE_NOTE = """
546
+ ### Why the processed clip looks like that
547
+
548
+ The adapter was trained with three preprocessing stages applied to **every**
549
+ clip, so inference has to reproduce them:
550
+
551
+ 1. **Pose-guided signer crop** β€” MediaPipe pose landmarks are unioned across the
552
+ clip and padded 25 %, giving one stable box around the signing space.
553
+ 2. **CLAHE** β€” contrast equalisation on the L channel in LAB (clip 2.0, 8Γ—8 tiles).
554
+ 3. **Landmark overlay** β€” RTMPose Wholebody draws 6 upper-body joints (yellow)
555
+ and 21 keypoints per hand (green = left, blue = right) directly onto the pixels.
556
+
557
+ Turning any of them off shows you how much the model leans on them β€” the output
558
+ usually gets noticeably worse.
559
+ """
560
+
561
+ with gr.Blocks(title="sign-language-bridge β€” ASL to English") as demo:
562
+ gr.Markdown(DESCRIPTION)
563
+
564
+ with gr.Row():
565
+ with gr.Column(scale=1):
566
+ video_in = gr.Video(
567
+ label="ASL clip",
568
+ sources=["upload", "webcam"],
569
+ include_audio=False,
570
+ format=None, # skip Gradio's re-encode; we decode the original ourselves
571
+ )
572
+ resolution_dd = gr.Dropdown(
573
+ choices=list(RESOLUTION_PRESETS),
574
+ value=DEFAULT_RESOLUTION,
575
+ label="Input resolution",
576
+ info="Higher is closer to the training setup but needs much more GPU memory.",
577
+ )
578
+ decoding_dd = gr.Dropdown(
579
+ choices=list(DECODING_PRESETS),
580
+ value=DEFAULT_DECODING,
581
+ label="Decoding",
582
+ info="Beam search re-encodes the clip once per beam β€” accurate, but memory-hungry.",
583
+ )
584
+ with gr.Accordion("Preprocessing (training defaults: all on)", open=False):
585
+ crop_cb = gr.Checkbox(value=True, label="Pose-guided signer crop")
586
+ clahe_cb = gr.Checkbox(value=True, label="CLAHE contrast enhancement")
587
+ overlay_cb = gr.Checkbox(value=True, label="Landmark skeleton overlay")
588
+ run_btn = gr.Button("Translate", variant="primary")
589
+
590
+ with gr.Column(scale=1):
591
+ translation_out = gr.Textbox(
592
+ label="English translation",
593
+ lines=3,
594
+ )
595
+ preview_out = gr.Video(label="What the model actually saw", autoplay=True)
596
+ info_out = gr.Markdown()
597
+
598
+ gr.Markdown(PIPELINE_NOTE)
599
+
600
+ run_btn.click(
601
+ fn=translate,
602
+ inputs=[video_in, crop_cb, clahe_cb, overlay_cb, resolution_dd, decoding_dd],
603
+ outputs=[translation_out, preview_out, info_out],
604
+ api_name="translate",
605
+ )
606
+
607
+ if __name__ == "__main__":
608
+ # SLB_OPEN_BROWSER is set by the local launcher; on a Space it is unset.
609
+ demo.queue(max_size=12).launch(
610
+ mcp_server=True,
611
+ show_error=True,
612
+ inbrowser=os.environ.get("SLB_OPEN_BROWSER") == "1",
613
+ )
preprocessing.py CHANGED
@@ -542,6 +542,23 @@ def _download_once(url: str, dest: str) -> str:
542
  return dest
543
 
544
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
545
  def probe_duration_seconds(path: str) -> float:
546
  """Clip duration in seconds from container metadata, or 0.0 if unknown."""
547
  cap = cv2.VideoCapture(path)
 
542
  return dest
543
 
544
 
545
+ def resize_video(video: torch.Tensor, target_h: int, target_w: int) -> torch.Tensor:
546
+ """Downscale a (T, C, H, W) uint8 clip, one frame at a time.
547
+
548
+ Deliberately not `F.interpolate`: that needs a float32 copy of the whole clip
549
+ (~200 MB for a few seconds of video) before it produces anything, which is
550
+ enough to fail on a memory-constrained machine. cv2 works per frame in uint8,
551
+ and INTER_AREA is the correct filter for downscaling.
552
+ """
553
+ T, C = video.shape[0], video.shape[1]
554
+ out = torch.empty((T, C, target_h, target_w), dtype=torch.uint8)
555
+ for t in range(T):
556
+ frame = video[t].permute(1, 2, 0).contiguous().numpy()
557
+ resized = cv2.resize(frame, (target_w, target_h), interpolation=cv2.INTER_AREA)
558
+ out[t] = torch.from_numpy(resized).permute(2, 0, 1)
559
+ return out
560
+
561
+
562
  def probe_duration_seconds(path: str) -> float:
563
  """Clip duration in seconds from container metadata, or 0.0 if unknown."""
564
  cap = cv2.VideoCapture(path)
requirements.txt CHANGED
@@ -14,20 +14,16 @@ torchvision
14
  mediapipe
15
  rtmlib
16
 
17
- # NOTE β€” read before first GPU run.
18
- # rtmlib's default `onnxruntime` is the CPU build. Measured locally, the
19
- # `performance` wholebody model costs ~950 ms/frame on CPU versus ~105 ms/frame
20
- # on CUDA: a 7-second clip goes from 133 s to 15 s. On CPU this Space would be
21
- # unusable, so `onnxruntime-gpu` needs to be enabled once hardware is attached.
22
  #
23
- # It is deliberately NOT pinned here: onnxruntime-gpu wheels target CUDA 12.x
24
- # while the ZeroGPU runtime is CUDA 13 (torch 2.11/cu130), and shipping an
25
- # untested pin would risk breaking the build for a Space that cannot currently
26
- # be tested. `LandmarkExtractor(device="auto")` falls back to CPU and says so in
27
- # the UI, so the app degrades rather than crashes.
28
- #
29
- # First thing to try when a GPU is available:
30
- # onnxruntime-gpu
31
 
32
  # Browser-playable H.264 preview of the processed clip.
33
  imageio
 
14
  mediapipe
15
  rtmlib
16
 
17
+ # rtmlib brings the CPU build of onnxruntime, which is what this app wants.
18
+ # It runs the `balanced` wholebody model: measured on a local RTX 3060 that is
19
+ # 47 ms/frame on CPU, *faster* than the larger `performance` model on the GPU
20
+ # (105 ms/frame), because it is the same backbone at 192x256 instead of 288x384.
 
21
  #
22
+ # onnxruntime-gpu is deliberately NOT used. Sharing the GPU between ONNX Runtime
23
+ # and PyTorch caused two failures locally: the ORT CUDA arena competed with the
24
+ # model for memory, and releasing the session mid-request left PyTorch unable to
25
+ # find cuDNN kernels ("GET was unable to find an engine to execute this
26
+ # computation"). Keeping ONNX Runtime on the CPU leaves the GPU to the model.
 
 
 
27
 
28
  # Browser-playable H.264 preview of the processed clip.
29
  imageio