lilblueyes commited on
Commit
20aa7e1
·
1 Parent(s): 7d969c6

Add experimental WLASL I3D backend

Browse files
.gitignore CHANGED
@@ -2,4 +2,5 @@ __pycache__/
2
  *.py[cod]
3
  .pytest_cache/
4
  data/examples/*.mp4
 
5
  external/
 
2
  *.py[cod]
3
  .pytest_cache/
4
  data/examples/*.mp4
5
+ data/models/wlasl_i3d/
6
  external/
README.md CHANGED
@@ -102,6 +102,24 @@ ASL_CONFIDENCE_THRESHOLD=0.70
102
  This is still not full continuous ASL translation, but it lets recorded phrase clips become
103
  `hello where water`-style gloss sequences instead of one global class.
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  Live camera debug prioritizes speed over long temporal batching. It starts predicting after
106
  `LIVE_ASL_MIN_FRAMES=4`, keeps a rolling buffer of `LIVE_ASL_MAX_FRAMES=12`, and runs ASL
107
  prediction every `LIVE_ASL_PREDICT_EVERY=1` frame. DeepFace emotion is heavier, so it runs every
 
102
  This is still not full continuous ASL translation, but it lets recorded phrase clips become
103
  `hello where water`-style gloss sequences instead of one global class.
104
 
105
+ An experimental WLASL2000 I3D backend is also available for broader vocabulary coverage. It uses
106
+ `raghuhasan/asl2000-i3d` from Hugging Face, downloads the I3D architecture helper if needed, and
107
+ falls back to the TFLite detector when `ASL_DETECTOR_BACKEND=auto` cannot initialize it.
108
+
109
+ ```text
110
+ ASL_DETECTOR_BACKEND=tflite # default lightweight backend
111
+ ASL_DETECTOR_BACKEND=wlasl_i3d # WLASL2000 I3D only
112
+ ASL_DETECTOR_BACKEND=auto # try WLASL2000, then fallback to TFLite
113
+ WLASL_I3D_CONFIDENCE_THRESHOLD=0.20
114
+ WLASL_I3D_SEQUENCE_WINDOW=64
115
+ WLASL_I3D_SEQUENCE_STRIDE=32
116
+ WLASL_I3D_FRAME_SIZE=224
117
+ ```
118
+
119
+ The WLASL backend is heavier and more experimental. The model card reports 2,000 classes with
120
+ 32.48% top-1, 57.31% top-5, and 66.31% top-10 accuracy, so the UI exposes top candidates and
121
+ segment diagnostics instead of hiding uncertainty.
122
+
123
  Live camera debug prioritizes speed over long temporal batching. It starts predicting after
124
  `LIVE_ASL_MIN_FRAMES=4`, keeps a rolling buffer of `LIVE_ASL_MAX_FRAMES=12`, and runs ASL
125
  prediction every `LIVE_ASL_PREDICT_EVERY=1` frame. DeepFace emotion is heavier, so it runs every
signspeak/asl/pipeline.py CHANGED
@@ -9,6 +9,7 @@ import numpy as np
9
  from .asl_detector import ASLDetector
10
  from .emotion_detector import detect_emotion_on_frames
11
  from .video_utils import sample_video_frames_for_emotion, sample_video_frames_sequential
 
12
 
13
 
14
  def process_asl_video(video_path: str | Path) -> dict[str, Any]:
@@ -30,7 +31,7 @@ def process_asl_frames(
30
  source: str = "frames",
31
  ) -> dict[str, Any]:
32
  emotion_frames = emotion_frames if emotion_frames is not None else asl_frames[:12]
33
- asl = ASLDetector().predict_sequence_from_frames(asl_frames)
34
  emotion = detect_emotion_on_frames(emotion_frames)
35
  intent_input = build_intent_input(asl, emotion)
36
 
@@ -49,6 +50,9 @@ def process_asl_frames(
49
  "sequence_window": int(asl.get("sequence_window", 0) or 0),
50
  "sequence_stride": int(asl.get("sequence_stride", 0) or 0),
51
  "recognition_mode": asl.get("recognition_mode"),
 
 
 
52
  "keypoints_shape": asl.get("keypoints_shape", []),
53
  "landmarks_status": asl.get("landmarks_status"),
54
  "landmarks_detector": asl.get("landmarks_detector"),
@@ -59,6 +63,24 @@ def process_asl_frames(
59
  }
60
 
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def build_intent_input(asl: dict[str, Any], emotion: dict[str, Any]) -> dict[str, Any]:
63
  glosses = asl.get("gloss_sequence", []) or []
64
  dominant_emotion = emotion.get("dominant_emotion", "unknown")
@@ -99,6 +121,9 @@ def build_intent_input(asl: dict[str, Any], emotion: dict[str, Any]) -> dict[str
99
  "sequence_window": int(asl.get("sequence_window", 0) or 0),
100
  "sequence_stride": int(asl.get("sequence_stride", 0) or 0),
101
  "recognition_mode": asl.get("recognition_mode"),
 
 
 
102
  "landmarks_status": asl.get("landmarks_status"),
103
  "landmarks_detector": asl.get("landmarks_detector"),
104
  },
@@ -112,6 +137,8 @@ def build_intent_input(asl: dict[str, Any], emotion: dict[str, Any]) -> dict[str
112
  "sign_confidence": confidence,
113
  "confidence_threshold": threshold,
114
  "accepted": bool(glosses),
 
 
115
  },
116
  }
117
 
 
9
  from .asl_detector import ASLDetector
10
  from .emotion_detector import detect_emotion_on_frames
11
  from .video_utils import sample_video_frames_for_emotion, sample_video_frames_sequential
12
+ from .wlasl_i3d_detector import WLASLI3DDetector
13
 
14
 
15
  def process_asl_video(video_path: str | Path) -> dict[str, Any]:
 
31
  source: str = "frames",
32
  ) -> dict[str, Any]:
33
  emotion_frames = emotion_frames if emotion_frames is not None else asl_frames[:12]
34
+ asl = predict_asl_sequence(asl_frames)
35
  emotion = detect_emotion_on_frames(emotion_frames)
36
  intent_input = build_intent_input(asl, emotion)
37
 
 
50
  "sequence_window": int(asl.get("sequence_window", 0) or 0),
51
  "sequence_stride": int(asl.get("sequence_stride", 0) or 0),
52
  "recognition_mode": asl.get("recognition_mode"),
53
+ "model_repo_id": asl.get("model_repo_id"),
54
+ "label_count": int(asl.get("label_count", 0) or 0),
55
+ "fallback_from": asl.get("fallback_from"),
56
  "keypoints_shape": asl.get("keypoints_shape", []),
57
  "landmarks_status": asl.get("landmarks_status"),
58
  "landmarks_detector": asl.get("landmarks_detector"),
 
63
  }
64
 
65
 
66
+ def predict_asl_sequence(asl_frames: list[np.ndarray]) -> dict[str, Any]:
67
+ backend = os.getenv("ASL_DETECTOR_BACKEND", "tflite").strip().lower()
68
+ if backend in ("wlasl", "wlasl_i3d", "i3d"):
69
+ return WLASLI3DDetector().predict_sequence_from_frames(asl_frames)
70
+ if backend == "auto":
71
+ wlasl = WLASLI3DDetector().predict_sequence_from_frames(asl_frames)
72
+ if wlasl.get("status") not in ("wlasl_i3d_unavailable", "wlasl_i3d_error"):
73
+ return wlasl
74
+ tflite = ASLDetector().predict_sequence_from_frames(asl_frames)
75
+ tflite["fallback_from"] = {
76
+ "backend": "wlasl_i3d",
77
+ "status": wlasl.get("status"),
78
+ "error": wlasl.get("error"),
79
+ }
80
+ return tflite
81
+ return ASLDetector().predict_sequence_from_frames(asl_frames)
82
+
83
+
84
  def build_intent_input(asl: dict[str, Any], emotion: dict[str, Any]) -> dict[str, Any]:
85
  glosses = asl.get("gloss_sequence", []) or []
86
  dominant_emotion = emotion.get("dominant_emotion", "unknown")
 
121
  "sequence_window": int(asl.get("sequence_window", 0) or 0),
122
  "sequence_stride": int(asl.get("sequence_stride", 0) or 0),
123
  "recognition_mode": asl.get("recognition_mode"),
124
+ "model_repo_id": asl.get("model_repo_id"),
125
+ "label_count": int(asl.get("label_count", 0) or 0),
126
+ "fallback_from": asl.get("fallback_from"),
127
  "landmarks_status": asl.get("landmarks_status"),
128
  "landmarks_detector": asl.get("landmarks_detector"),
129
  },
 
137
  "sign_confidence": confidence,
138
  "confidence_threshold": threshold,
139
  "accepted": bool(glosses),
140
+ "recognition_mode": asl.get("recognition_mode"),
141
+ "fallback_from": asl.get("fallback_from"),
142
  },
143
  }
144
 
signspeak/asl/wlasl_i3d_detector.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import importlib.util
5
+ import os
6
+ import urllib.request
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+
13
+ WLASL_MODEL_REPO_ID = "raghuhasan/asl2000-i3d"
14
+ WLASL_LABELS_URL = "https://raw.githubusercontent.com/dxli94/WLASL/master/start_kit/WLASL_v0.3.json"
15
+ PYTORCH_I3D_URL = "https://raw.githubusercontent.com/piergiaj/pytorch-i3d/master/pytorch_i3d.py"
16
+
17
+
18
+ class WLASLI3DDetector:
19
+ """Experimental WLASL2000 video classifier backed by a Hugging Face I3D checkpoint."""
20
+
21
+ def __init__(self, cache_dir: str | Path | None = None) -> None:
22
+ repo_root = Path(__file__).resolve().parents[2]
23
+ self.cache_dir = Path(cache_dir or os.getenv("WLASL_I3D_CACHE_DIR") or repo_root / "data" / "models" / "wlasl_i3d")
24
+ self.repo_id = os.getenv("WLASL_I3D_REPO_ID", WLASL_MODEL_REPO_ID)
25
+ self.confidence_threshold = float(os.getenv("WLASL_I3D_CONFIDENCE_THRESHOLD", "0.20"))
26
+ self.sequence_window = max(8, int(os.getenv("WLASL_I3D_SEQUENCE_WINDOW", "64")))
27
+ self.sequence_stride = max(1, int(os.getenv("WLASL_I3D_SEQUENCE_STRIDE", "32")))
28
+ self.frame_size = max(64, int(os.getenv("WLASL_I3D_FRAME_SIZE", "224")))
29
+ self.labels = self._load_labels()
30
+
31
+ def predict_sequence_from_frames(self, frames: list[np.ndarray]) -> dict[str, Any]:
32
+ base = {
33
+ "status": "wlasl_i3d_unavailable",
34
+ "gloss_sequence": [],
35
+ "top_prediction": None,
36
+ "confidence": 0.0,
37
+ "confidence_threshold": self.confidence_threshold,
38
+ "top_predictions": [],
39
+ "segment_predictions": [],
40
+ "frames_used": len(frames),
41
+ "windows_used": 0,
42
+ "sequence_window": self.sequence_window,
43
+ "sequence_stride": self.sequence_stride,
44
+ "recognition_mode": "wlasl_i3d_sliding_window",
45
+ "model_repo_id": self.repo_id,
46
+ "label_count": len(self.labels),
47
+ }
48
+ if not frames:
49
+ base["error"] = "No frames supplied to WLASL I3D detector."
50
+ return base
51
+
52
+ try:
53
+ torch = self._load_torch()
54
+ model = self._load_model(torch)
55
+ segments = []
56
+ for window_index, start, end, window_frames in self._iter_frame_windows(frames):
57
+ segments.append(self._predict_window(torch, model, window_frames, window_index, start, end))
58
+
59
+ accepted_glosses = self._collapse_segments(segments)
60
+ best_segment = max(segments, key=lambda item: float(item.get("confidence", 0.0) or 0.0), default=None)
61
+ confidence = float(best_segment.get("confidence", 0.0) or 0.0) if best_segment else 0.0
62
+ top_prediction = best_segment.get("label") if best_segment else None
63
+ base.update(
64
+ {
65
+ "status": "ok" if accepted_glosses else "low_confidence",
66
+ "gloss_sequence": accepted_glosses,
67
+ "top_prediction": top_prediction,
68
+ "confidence": confidence,
69
+ "top_predictions": self._merge_top_predictions(segments),
70
+ "segment_predictions": segments,
71
+ "windows_used": len(segments),
72
+ }
73
+ )
74
+ return base
75
+ except Exception as exc:
76
+ base.update(
77
+ {
78
+ "status": "wlasl_i3d_error",
79
+ "error": f"{type(exc).__name__}: {exc}",
80
+ }
81
+ )
82
+ return base
83
+
84
+ def _load_torch(self) -> Any:
85
+ try:
86
+ import torch
87
+
88
+ return torch
89
+ except Exception as exc:
90
+ raise RuntimeError("PyTorch is required for WLASL I3D. Install torch to enable this backend.") from exc
91
+
92
+ def _load_model(self, torch: Any) -> Any:
93
+ InceptionI3d = self._load_inception_i3d()
94
+
95
+ weights_path = self._download_weights()
96
+ model = InceptionI3d(400, in_channels=3)
97
+ model.replace_logits(2000)
98
+ state_dict = torch.load(weights_path, map_location="cpu")
99
+ model.load_state_dict(state_dict)
100
+ model.eval()
101
+ return model
102
+
103
+ def _load_inception_i3d(self) -> Any:
104
+ try:
105
+ from pytorch_i3d import InceptionI3d
106
+
107
+ return InceptionI3d
108
+ except Exception:
109
+ pass
110
+
111
+ module_path = self.cache_dir / "pytorch_i3d.py"
112
+ if not module_path.exists():
113
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
114
+ try:
115
+ with urllib.request.urlopen(PYTORCH_I3D_URL, timeout=20) as response:
116
+ module_path.write_bytes(response.read())
117
+ except Exception as exc:
118
+ raise RuntimeError("Could not download pytorch_i3d architecture for WLASL I3D.") from exc
119
+
120
+ spec = importlib.util.spec_from_file_location("signspeak_cached_pytorch_i3d", module_path)
121
+ if spec is None or spec.loader is None:
122
+ raise RuntimeError(f"Could not import cached pytorch_i3d module from {module_path}.")
123
+ module = importlib.util.module_from_spec(spec)
124
+ spec.loader.exec_module(module)
125
+ return module.InceptionI3d
126
+
127
+ def _download_weights(self) -> str:
128
+ try:
129
+ from huggingface_hub import hf_hub_download
130
+
131
+ return hf_hub_download(self.repo_id, "pytorch_model.bin")
132
+ except Exception as exc:
133
+ raise RuntimeError(f"Could not download WLASL I3D weights from {self.repo_id}.") from exc
134
+
135
+ def _predict_window(
136
+ self,
137
+ torch: Any,
138
+ model: Any,
139
+ frames: list[np.ndarray],
140
+ window_index: int,
141
+ start_frame: int,
142
+ end_frame: int,
143
+ ) -> dict[str, Any]:
144
+ tensor = self._frames_to_tensor(torch, frames)
145
+ with torch.no_grad():
146
+ logits = model(tensor)
147
+ if logits.ndim == 3:
148
+ logits = logits.mean(dim=2)
149
+ probs = torch.softmax(logits, dim=1)[0].detach().cpu().numpy()
150
+ top_idx = int(np.argmax(probs))
151
+ confidence = float(probs[top_idx])
152
+ label = self._label_for_index(top_idx)
153
+ return {
154
+ "window_index": window_index,
155
+ "start_frame": start_frame,
156
+ "end_frame": end_frame,
157
+ "label": label,
158
+ "confidence": confidence,
159
+ "accepted": bool(label) and confidence >= self.confidence_threshold,
160
+ "top_predictions": self._top_predictions(probs, limit=10),
161
+ }
162
+
163
+ def _frames_to_tensor(self, torch: Any, frames: list[np.ndarray]) -> Any:
164
+ cv2 = self._load_cv2()
165
+ processed = []
166
+ for frame in frames:
167
+ resized = cv2.resize(frame, (self.frame_size, self.frame_size), interpolation=cv2.INTER_AREA)
168
+ normalized = resized.astype(np.float32) / 127.5 - 1.0
169
+ processed.append(normalized)
170
+ array = np.stack(processed, axis=0)
171
+ array = np.transpose(array, (3, 0, 1, 2))
172
+ return torch.from_numpy(array).unsqueeze(0).float()
173
+
174
+ def _iter_frame_windows(self, frames: list[np.ndarray]):
175
+ frame_count = len(frames)
176
+ if frame_count <= self.sequence_window:
177
+ yield 0, 0, frame_count, self._fit_window(frames)
178
+ return
179
+
180
+ window_index = 0
181
+ last_start = frame_count - self.sequence_window
182
+ for start in range(0, last_start + 1, self.sequence_stride):
183
+ end = start + self.sequence_window
184
+ yield window_index, start, end, frames[start:end]
185
+ window_index += 1
186
+
187
+ covered_end = (window_index - 1) * self.sequence_stride + self.sequence_window if window_index else 0
188
+ if covered_end < frame_count:
189
+ yield window_index, last_start, frame_count, frames[last_start:frame_count]
190
+
191
+ def _fit_window(self, frames: list[np.ndarray]) -> list[np.ndarray]:
192
+ if len(frames) >= self.sequence_window:
193
+ return frames
194
+ return frames + [frames[-1]] * (self.sequence_window - len(frames))
195
+
196
+ def _load_labels(self) -> list[str]:
197
+ labels_path = self.cache_dir / "wlasl_2000_labels.json"
198
+ if labels_path.exists():
199
+ try:
200
+ labels = json.loads(labels_path.read_text(encoding="utf-8"))
201
+ if isinstance(labels, list):
202
+ return [str(label) for label in labels]
203
+ except Exception:
204
+ pass
205
+
206
+ try:
207
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
208
+ with urllib.request.urlopen(WLASL_LABELS_URL, timeout=20) as response:
209
+ data = json.loads(response.read().decode("utf-8"))
210
+ labels = [str(item["gloss"]) for item in data[:2000] if "gloss" in item]
211
+ labels_path.write_text(json.dumps(labels, ensure_ascii=False, indent=2), encoding="utf-8")
212
+ return labels
213
+ except Exception:
214
+ return []
215
+
216
+ def _label_for_index(self, index: int) -> str:
217
+ if 0 <= index < len(self.labels):
218
+ return self.labels[index]
219
+ return str(index)
220
+
221
+ def _top_predictions(self, probs: np.ndarray, limit: int = 10) -> list[dict[str, Any]]:
222
+ top_indices = np.argsort(probs)[::-1][:limit]
223
+ return [
224
+ {
225
+ "label": self._label_for_index(int(index)),
226
+ "confidence": float(probs[int(index)]),
227
+ }
228
+ for index in top_indices
229
+ ]
230
+
231
+ def _collapse_segments(self, segments: list[dict[str, Any]]) -> list[str]:
232
+ glosses = []
233
+ for segment in segments:
234
+ if not segment.get("accepted"):
235
+ continue
236
+ label = segment.get("label")
237
+ if not label:
238
+ continue
239
+ if glosses and glosses[-1] == label:
240
+ continue
241
+ glosses.append(str(label))
242
+ return glosses
243
+
244
+ def _merge_top_predictions(self, segments: list[dict[str, Any]], limit: int = 10) -> list[dict[str, Any]]:
245
+ best_by_label: dict[str, float] = {}
246
+ for segment in segments:
247
+ for item in segment.get("top_predictions", []):
248
+ label = str(item.get("label") or "")
249
+ confidence = float(item.get("confidence", 0.0) or 0.0)
250
+ if label:
251
+ best_by_label[label] = max(best_by_label.get(label, 0.0), confidence)
252
+ return [
253
+ {"label": label, "confidence": confidence}
254
+ for label, confidence in sorted(best_by_label.items(), key=lambda item: item[1], reverse=True)[:limit]
255
+ ]
256
+
257
+ def _load_cv2(self):
258
+ try:
259
+ import cv2
260
+
261
+ return cv2
262
+ except Exception as exc:
263
+ raise RuntimeError("OpenCV is required for WLASL I3D frame preprocessing.") from exc
tests/test_asl_pipeline.py CHANGED
@@ -108,6 +108,43 @@ def test_process_asl_frames_preserves_detector_diagnostics(monkeypatch):
108
  assert result["intent_input"]["candidate_gloss_sequence"][0]["gloss"] == "talk"
109
 
110
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  def test_summarize_asl_result_is_stable_for_missing_fields():
112
  summary = summarize_asl_result(
113
  {
 
108
  assert result["intent_input"]["candidate_gloss_sequence"][0]["gloss"] == "talk"
109
 
110
 
111
+ def test_predict_asl_sequence_auto_falls_back_to_tflite(monkeypatch):
112
+ class FakeWLASLDetector:
113
+ def predict_sequence_from_frames(self, frames):
114
+ return {
115
+ "status": "wlasl_i3d_error",
116
+ "error": "missing torch",
117
+ "gloss_sequence": [],
118
+ }
119
+
120
+ class FakeASLDetector:
121
+ def predict_sequence_from_frames(self, frames):
122
+ return {
123
+ "status": "ok",
124
+ "gloss_sequence": ["hello"],
125
+ "top_prediction": "hello",
126
+ "confidence": 0.91,
127
+ "confidence_threshold": 0.70,
128
+ "top_predictions": [{"label": "hello", "confidence": 0.91}],
129
+ "segment_predictions": [],
130
+ "frames_used": len(frames),
131
+ "windows_used": 1,
132
+ "sequence_window": 30,
133
+ "sequence_stride": 15,
134
+ "recognition_mode": "sliding_window_sequence",
135
+ }
136
+
137
+ monkeypatch.setenv("ASL_DETECTOR_BACKEND", "auto")
138
+ monkeypatch.setattr(asl_pipeline, "WLASLI3DDetector", FakeWLASLDetector)
139
+ monkeypatch.setattr(asl_pipeline, "ASLDetector", FakeASLDetector)
140
+
141
+ result = asl_pipeline.predict_asl_sequence([np.zeros((2, 2, 3), dtype=np.uint8)] * 30)
142
+
143
+ assert result["gloss_sequence"] == ["hello"]
144
+ assert result["fallback_from"]["backend"] == "wlasl_i3d"
145
+ assert result["fallback_from"]["error"] == "missing torch"
146
+
147
+
148
  def test_summarize_asl_result_is_stable_for_missing_fields():
149
  summary = summarize_asl_result(
150
  {
tests/test_wlasl_i3d_detector.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ import numpy as np
4
+
5
+ from signspeak.asl.wlasl_i3d_detector import WLASLI3DDetector
6
+
7
+
8
+ def test_wlasl_detector_loads_cached_labels(tmp_path):
9
+ cache_dir = tmp_path / "wlasl_i3d"
10
+ cache_dir.mkdir()
11
+ (cache_dir / "wlasl_2000_labels.json").write_text(json.dumps(["book", "hello"]), encoding="utf-8")
12
+
13
+ detector = WLASLI3DDetector(cache_dir=cache_dir)
14
+
15
+ assert detector.labels == ["book", "hello"]
16
+ assert detector._label_for_index(1) == "hello"
17
+ assert detector._label_for_index(99) == "99"
18
+
19
+
20
+ def test_wlasl_detector_reports_missing_runtime(monkeypatch, tmp_path):
21
+ (tmp_path / "wlasl_2000_labels.json").write_text(json.dumps(["book"]), encoding="utf-8")
22
+ detector = WLASLI3DDetector(cache_dir=tmp_path)
23
+ monkeypatch.setattr(detector, "_load_torch", lambda: (_ for _ in ()).throw(RuntimeError("missing torch")))
24
+
25
+ result = detector.predict_sequence_from_frames([np.zeros((2, 2, 3), dtype=np.uint8)])
26
+
27
+ assert result["status"] == "wlasl_i3d_error"
28
+ assert "missing torch" in result["error"]