Commit ·
20aa7e1
1
Parent(s): 7d969c6
Add experimental WLASL I3D backend
Browse files- .gitignore +1 -0
- README.md +18 -0
- signspeak/asl/pipeline.py +28 -1
- signspeak/asl/wlasl_i3d_detector.py +263 -0
- tests/test_asl_pipeline.py +37 -0
- tests/test_wlasl_i3d_detector.py +28 -0
.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 =
|
| 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"]
|