Commit ·
c6057fb
1
Parent(s): c2837be
Align ASL inference with upstream model
Browse files- README.md +20 -0
- app.py +2 -33
- signspeak/asl/asl_detector.py +40 -7
- signspeak/asl/pipeline.py +1 -1
- signspeak/live_debug.py +137 -0
- signspeak/llm.py +23 -21
- tests/test_asl_detector.py +12 -0
README.md
CHANGED
|
@@ -85,6 +85,26 @@ This model recognizes the isolated signs listed in `sign_to_prediction_index_map
|
|
| 85 |
a full sentence or fingerspelling recognizer. Predictions below `ASL_CONFIDENCE_THRESHOLD`
|
| 86 |
defaulting to `0.70` are reported as `low_confidence` and are not forwarded as detected glosses.
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
## GPU dependencies
|
| 89 |
|
| 90 |
`flash-attn` is only useful on a CUDA GPU Space with compatible PyTorch/CUDA versions.
|
|
|
|
| 85 |
a full sentence or fingerspelling recognizer. Predictions below `ASL_CONFIDENCE_THRESHOLD`
|
| 86 |
defaulting to `0.70` are reported as `low_confidence` and are not forwarded as detected glosses.
|
| 87 |
|
| 88 |
+
Good first signs to test because they are in the model vocabulary:
|
| 89 |
+
|
| 90 |
+
```text
|
| 91 |
+
hello, where, who, why, yes, no, thankyou, please, water, happy, sad
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
Reference clips/GIFs from the upstream demo list:
|
| 95 |
+
|
| 96 |
+
```text
|
| 97 |
+
hello https://media.giphy.com/media/3o7TKNKOfKlIhbD3gY/giphy.gif
|
| 98 |
+
where https://lifeprint.com/asl101/gifs/w/where.gif
|
| 99 |
+
who https://lifeprint.com/asl101/gifs/w/who.gif
|
| 100 |
+
why https://lifeprint.com/asl101/gifs/w/why.gif
|
| 101 |
+
yes https://media.tenor.com/oYIirlyIih0AAAAC/yes-asl.gif
|
| 102 |
+
no https://lifeprint.com/asl101/gifs/n/no-2-movement.gif
|
| 103 |
+
thankyou https://lifeprint.com/asl101/gifs/t/thank-you.gif
|
| 104 |
+
please https://lifeprint.com/asl101/gifs-animated/pleasecloseup.gif
|
| 105 |
+
water https://lifeprint.com/asl101/gifs/w/water-2.gif
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
## GPU dependencies
|
| 109 |
|
| 110 |
`flash-attn` is only useful on a CUDA GPU Space with compatible PyTorch/CUDA versions.
|
app.py
CHANGED
|
@@ -5,6 +5,7 @@ from pathlib import Path
|
|
| 5 |
import gradio as gr
|
| 6 |
|
| 7 |
from signspeak.llm import generate_subtitle_and_instruction
|
|
|
|
| 8 |
from signspeak.pipeline import DEFAULT_INTENT, json_text, run_asl_video
|
| 9 |
from signspeak.tts import generate_tts
|
| 10 |
|
|
@@ -70,38 +71,6 @@ def build_video_input(label: str) -> gr.Video:
|
|
| 70 |
)
|
| 71 |
|
| 72 |
|
| 73 |
-
def render_live_frame_debug(frame):
|
| 74 |
-
if frame is None:
|
| 75 |
-
return None, "Waiting for camera frame."
|
| 76 |
-
|
| 77 |
-
import cv2
|
| 78 |
-
|
| 79 |
-
output = frame.copy()
|
| 80 |
-
height, width = output.shape[:2]
|
| 81 |
-
cv2.rectangle(output, (0, 0), (width, 72), (8, 11, 16), -1)
|
| 82 |
-
cv2.putText(
|
| 83 |
-
output,
|
| 84 |
-
"LIVE CAMERA DEBUG",
|
| 85 |
-
(14, 28),
|
| 86 |
-
cv2.FONT_HERSHEY_SIMPLEX,
|
| 87 |
-
0.62,
|
| 88 |
-
(45, 212, 191),
|
| 89 |
-
2,
|
| 90 |
-
cv2.LINE_AA,
|
| 91 |
-
)
|
| 92 |
-
cv2.putText(
|
| 93 |
-
output,
|
| 94 |
-
"Use Analyze ASL for 30-frame TFLite gloss inference",
|
| 95 |
-
(14, 58),
|
| 96 |
-
cv2.FONT_HERSHEY_SIMPLEX,
|
| 97 |
-
0.46,
|
| 98 |
-
(248, 250, 252),
|
| 99 |
-
1,
|
| 100 |
-
cv2.LINE_AA,
|
| 101 |
-
)
|
| 102 |
-
return output, "Live frame received. Sequence inference runs from the recorded/uploaded clip."
|
| 103 |
-
|
| 104 |
-
|
| 105 |
with gr.Blocks(title="SignSpeak Local") as demo:
|
| 106 |
gr.HTML(
|
| 107 |
"""
|
|
@@ -306,7 +275,7 @@ with gr.Blocks(title="SignSpeak Local") as demo:
|
|
| 306 |
)
|
| 307 |
|
| 308 |
live_camera_input.stream(
|
| 309 |
-
fn=
|
| 310 |
inputs=[live_camera_input],
|
| 311 |
outputs=[live_camera_output, live_camera_status],
|
| 312 |
)
|
|
|
|
| 5 |
import gradio as gr
|
| 6 |
|
| 7 |
from signspeak.llm import generate_subtitle_and_instruction
|
| 8 |
+
from signspeak.live_debug import process_live_debug_frame
|
| 9 |
from signspeak.pipeline import DEFAULT_INTENT, json_text, run_asl_video
|
| 10 |
from signspeak.tts import generate_tts
|
| 11 |
|
|
|
|
| 71 |
)
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
with gr.Blocks(title="SignSpeak Local") as demo:
|
| 75 |
gr.HTML(
|
| 76 |
"""
|
|
|
|
| 275 |
)
|
| 276 |
|
| 277 |
live_camera_input.stream(
|
| 278 |
+
fn=process_live_debug_frame,
|
| 279 |
inputs=[live_camera_input],
|
| 280 |
outputs=[live_camera_output, live_camera_status],
|
| 281 |
)
|
signspeak/asl/asl_detector.py
CHANGED
|
@@ -23,7 +23,7 @@ class ASLDetector:
|
|
| 23 |
self.labels = self._load_labels()
|
| 24 |
|
| 25 |
def predict_from_frames(self, frames: list[np.ndarray]) -> dict[str, Any]:
|
| 26 |
-
detector = LandmarksDetector(missing_value=
|
| 27 |
try:
|
| 28 |
landmark_result = detector.detect_sequence(frames)
|
| 29 |
finally:
|
|
@@ -48,12 +48,25 @@ class ASLDetector:
|
|
| 48 |
|
| 49 |
try:
|
| 50 |
interpreter = self._load_interpreter()
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
top_prediction = self._label_for_index(top_idx)
|
| 56 |
-
confidence = float(
|
| 57 |
accepted = confidence >= self.confidence_threshold
|
| 58 |
|
| 59 |
base.update(
|
|
@@ -64,6 +77,8 @@ class ASLDetector:
|
|
| 64 |
"confidence": confidence,
|
| 65 |
"confidence_threshold": self.confidence_threshold,
|
| 66 |
"top_predictions": self._top_predictions(probs),
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
)
|
| 69 |
return base
|
|
@@ -91,7 +106,7 @@ class ASLDetector:
|
|
| 91 |
if "serving_default" in signatures:
|
| 92 |
prediction_fn = interpreter.get_signature_runner("serving_default")
|
| 93 |
input_name = self._signature_input_name(signatures["serving_default"])
|
| 94 |
-
input_data =
|
| 95 |
prediction = prediction_fn(**{input_name: input_data})
|
| 96 |
output_name = self._signature_output_name(prediction)
|
| 97 |
return np.asarray(prediction[output_name])
|
|
@@ -114,6 +129,24 @@ class ASLDetector:
|
|
| 114 |
return "outputs"
|
| 115 |
return next(iter(prediction))
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
def _prepare_input(self, keypoints: np.ndarray, input_detail: dict[str, Any]) -> np.ndarray:
|
| 118 |
shape = input_detail.get("shape")
|
| 119 |
dtype = input_detail.get("dtype", np.float32)
|
|
|
|
| 23 |
self.labels = self._load_labels()
|
| 24 |
|
| 25 |
def predict_from_frames(self, frames: list[np.ndarray]) -> dict[str, Any]:
|
| 26 |
+
detector = LandmarksDetector(missing_value=np.nan)
|
| 27 |
try:
|
| 28 |
landmark_result = detector.detect_sequence(frames)
|
| 29 |
finally:
|
|
|
|
| 48 |
|
| 49 |
try:
|
| 50 |
interpreter = self._load_interpreter()
|
| 51 |
+
window_results = []
|
| 52 |
+
for window_index, window_keypoints in enumerate(self._keypoint_windows(keypoints)):
|
| 53 |
+
output = self._predict(interpreter, window_keypoints)
|
| 54 |
+
probs = self._softmax_if_needed(np.asarray(output).reshape(-1))
|
| 55 |
+
top_idx = int(np.argmax(probs))
|
| 56 |
+
window_results.append(
|
| 57 |
+
{
|
| 58 |
+
"window_index": window_index,
|
| 59 |
+
"probs": probs,
|
| 60 |
+
"top_idx": top_idx,
|
| 61 |
+
"confidence": float(probs[top_idx]),
|
| 62 |
+
}
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
best = max(window_results, key=lambda item: item["confidence"])
|
| 66 |
+
probs = best["probs"]
|
| 67 |
+
top_idx = int(best["top_idx"])
|
| 68 |
top_prediction = self._label_for_index(top_idx)
|
| 69 |
+
confidence = float(best["confidence"])
|
| 70 |
accepted = confidence >= self.confidence_threshold
|
| 71 |
|
| 72 |
base.update(
|
|
|
|
| 77 |
"confidence": confidence,
|
| 78 |
"confidence_threshold": self.confidence_threshold,
|
| 79 |
"top_predictions": self._top_predictions(probs),
|
| 80 |
+
"windows_analyzed": len(window_results),
|
| 81 |
+
"best_window_index": int(best["window_index"]),
|
| 82 |
}
|
| 83 |
)
|
| 84 |
return base
|
|
|
|
| 106 |
if "serving_default" in signatures:
|
| 107 |
prediction_fn = interpreter.get_signature_runner("serving_default")
|
| 108 |
input_name = self._signature_input_name(signatures["serving_default"])
|
| 109 |
+
input_data = keypoints.astype(np.float32)
|
| 110 |
prediction = prediction_fn(**{input_name: input_data})
|
| 111 |
output_name = self._signature_output_name(prediction)
|
| 112 |
return np.asarray(prediction[output_name])
|
|
|
|
| 129 |
return "outputs"
|
| 130 |
return next(iter(prediction))
|
| 131 |
|
| 132 |
+
def _keypoint_windows(self, keypoints: np.ndarray, window_size: int = 30, max_windows: int = 5) -> list[np.ndarray]:
|
| 133 |
+
frame_count = int(keypoints.shape[0])
|
| 134 |
+
if frame_count <= window_size:
|
| 135 |
+
return [keypoints.astype(np.float32)]
|
| 136 |
+
|
| 137 |
+
max_start = frame_count - window_size
|
| 138 |
+
if max_windows <= 1:
|
| 139 |
+
starts = [max_start // 2]
|
| 140 |
+
else:
|
| 141 |
+
starts = np.linspace(0, max_start, num=min(max_windows, max_start + 1), dtype=int).tolist()
|
| 142 |
+
|
| 143 |
+
unique_starts = []
|
| 144 |
+
for start in starts:
|
| 145 |
+
if start not in unique_starts:
|
| 146 |
+
unique_starts.append(int(start))
|
| 147 |
+
|
| 148 |
+
return [keypoints[start : start + window_size].astype(np.float32) for start in unique_starts]
|
| 149 |
+
|
| 150 |
def _prepare_input(self, keypoints: np.ndarray, input_detail: dict[str, Any]) -> np.ndarray:
|
| 151 |
shape = input_detail.get("shape")
|
| 152 |
dtype = input_detail.get("dtype", np.float32)
|
signspeak/asl/pipeline.py
CHANGED
|
@@ -12,7 +12,7 @@ from .video_utils import sample_video_frames, sample_video_frames_for_emotion
|
|
| 12 |
|
| 13 |
def process_asl_video(video_path: str | Path) -> dict[str, Any]:
|
| 14 |
path = Path(video_path)
|
| 15 |
-
asl_frames = sample_video_frames(path, target_frames=
|
| 16 |
emotion_frames = sample_video_frames_for_emotion(path, target_frames=12)
|
| 17 |
|
| 18 |
return process_asl_frames(asl_frames, emotion_frames, source=str(path))
|
|
|
|
| 12 |
|
| 13 |
def process_asl_video(video_path: str | Path) -> dict[str, Any]:
|
| 14 |
path = Path(video_path)
|
| 15 |
+
asl_frames = sample_video_frames(path, target_frames=90)
|
| 16 |
emotion_frames = sample_video_frames_for_emotion(path, target_frames=12)
|
| 17 |
|
| 18 |
return process_asl_frames(asl_frames, emotion_frames, source=str(path))
|
signspeak/live_debug.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
from .asl.asl_detector import ASLDetector
|
| 9 |
+
from .asl.landmarks_detector import LandmarksDetector
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class LiveASLSession:
|
| 13 |
+
def __init__(self) -> None:
|
| 14 |
+
self.detector = LandmarksDetector(missing_value=np.nan)
|
| 15 |
+
self.asl = ASLDetector()
|
| 16 |
+
self.interpreter: Any | None = None
|
| 17 |
+
self.frame_keypoints: list[np.ndarray] = []
|
| 18 |
+
self.latest_prediction = ""
|
| 19 |
+
self.latest_emotion = "unknown"
|
| 20 |
+
self.latest_status = "Waiting for 30 frames."
|
| 21 |
+
self.frames_seen = 0
|
| 22 |
+
|
| 23 |
+
def process_frame(self, frame_rgb: np.ndarray) -> tuple[np.ndarray, str]:
|
| 24 |
+
output = frame_rgb.copy()
|
| 25 |
+
self.frames_seen += 1
|
| 26 |
+
if self.detector.mp is None:
|
| 27 |
+
self.latest_status = self.detector.error or "MediaPipe unavailable."
|
| 28 |
+
return self._draw(output), self.latest_status
|
| 29 |
+
|
| 30 |
+
try:
|
| 31 |
+
keypoints = self.detector._detect_frame(frame_rgb)
|
| 32 |
+
self.frame_keypoints.append(keypoints)
|
| 33 |
+
self.frame_keypoints = self.frame_keypoints[-30:]
|
| 34 |
+
if self.frames_seen % 15 == 0:
|
| 35 |
+
self._update_emotion(frame_rgb)
|
| 36 |
+
if len(self.frame_keypoints) == 30:
|
| 37 |
+
self._predict_latest()
|
| 38 |
+
except Exception as exc:
|
| 39 |
+
self.latest_status = f"Live ASL error: {type(exc).__name__}: {exc}"
|
| 40 |
+
|
| 41 |
+
return self._draw(output), self.latest_status
|
| 42 |
+
|
| 43 |
+
def _predict_latest(self) -> None:
|
| 44 |
+
if not self.asl.model_path.exists():
|
| 45 |
+
self.latest_prediction = ""
|
| 46 |
+
self.latest_status = "ASL model missing."
|
| 47 |
+
return
|
| 48 |
+
|
| 49 |
+
if self.interpreter is None:
|
| 50 |
+
self.interpreter = self.asl._load_interpreter()
|
| 51 |
+
|
| 52 |
+
keypoints = np.asarray(self.frame_keypoints, dtype=np.float32)
|
| 53 |
+
output = self.asl._predict(self.interpreter, keypoints)
|
| 54 |
+
probs = self.asl._softmax_if_needed(np.asarray(output).reshape(-1))
|
| 55 |
+
top_idx = int(np.argmax(probs))
|
| 56 |
+
label = self.asl._label_for_index(top_idx)
|
| 57 |
+
confidence = float(probs[top_idx])
|
| 58 |
+
|
| 59 |
+
if confidence >= self.asl.confidence_threshold:
|
| 60 |
+
self.latest_prediction = f"{label} ({confidence:.0%})"
|
| 61 |
+
self.latest_status = f"Accepted: {label} at {confidence:.2f}"
|
| 62 |
+
else:
|
| 63 |
+
self.latest_prediction = ""
|
| 64 |
+
self.latest_status = f"Low confidence: {label} at {confidence:.2f}"
|
| 65 |
+
|
| 66 |
+
def _update_emotion(self, frame_rgb: np.ndarray) -> None:
|
| 67 |
+
try:
|
| 68 |
+
os.environ.setdefault("TF_USE_LEGACY_KERAS", "1")
|
| 69 |
+
import cv2
|
| 70 |
+
from deepface import DeepFace
|
| 71 |
+
|
| 72 |
+
frame_bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
|
| 73 |
+
analysis = DeepFace.analyze(
|
| 74 |
+
img_path=frame_bgr,
|
| 75 |
+
actions=["emotion"],
|
| 76 |
+
enforce_detection=False,
|
| 77 |
+
silent=True,
|
| 78 |
+
)
|
| 79 |
+
if isinstance(analysis, list):
|
| 80 |
+
analysis = analysis[0] if analysis else {}
|
| 81 |
+
dominant = analysis.get("dominant_emotion") if isinstance(analysis, dict) else None
|
| 82 |
+
if dominant:
|
| 83 |
+
self.latest_emotion = str(dominant)
|
| 84 |
+
except Exception:
|
| 85 |
+
self.latest_emotion = "unavailable"
|
| 86 |
+
|
| 87 |
+
def _draw(self, frame_rgb: np.ndarray) -> np.ndarray:
|
| 88 |
+
import cv2
|
| 89 |
+
|
| 90 |
+
height, width = frame_rgb.shape[:2]
|
| 91 |
+
cv2.rectangle(frame_rgb, (0, 0), (width, 116), (8, 11, 16), -1)
|
| 92 |
+
cv2.putText(
|
| 93 |
+
frame_rgb,
|
| 94 |
+
f"Sign: {self.latest_prediction or '-'}",
|
| 95 |
+
(14, 36),
|
| 96 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 97 |
+
0.86,
|
| 98 |
+
(45, 212, 191),
|
| 99 |
+
2,
|
| 100 |
+
cv2.LINE_AA,
|
| 101 |
+
)
|
| 102 |
+
cv2.putText(
|
| 103 |
+
frame_rgb,
|
| 104 |
+
f"Emotion: {self.latest_emotion}",
|
| 105 |
+
(14, 68),
|
| 106 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 107 |
+
0.58,
|
| 108 |
+
(245, 158, 11),
|
| 109 |
+
2,
|
| 110 |
+
cv2.LINE_AA,
|
| 111 |
+
)
|
| 112 |
+
cv2.putText(
|
| 113 |
+
frame_rgb,
|
| 114 |
+
self.latest_status[:96],
|
| 115 |
+
(14, 100),
|
| 116 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 117 |
+
0.48,
|
| 118 |
+
(248, 250, 252),
|
| 119 |
+
1,
|
| 120 |
+
cv2.LINE_AA,
|
| 121 |
+
)
|
| 122 |
+
return frame_rgb
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
_live_session: LiveASLSession | None = None
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def process_live_debug_frame(frame: np.ndarray | None) -> tuple[np.ndarray | None, str]:
|
| 129 |
+
global _live_session
|
| 130 |
+
|
| 131 |
+
if frame is None:
|
| 132 |
+
return None, "Waiting for camera frame."
|
| 133 |
+
|
| 134 |
+
if _live_session is None:
|
| 135 |
+
_live_session = LiveASLSession()
|
| 136 |
+
|
| 137 |
+
return _live_session.process_frame(frame)
|
signspeak/llm.py
CHANGED
|
@@ -76,24 +76,6 @@ def normalize_llm_output(parsed: dict[str, Any]) -> dict[str, str]:
|
|
| 76 |
def deterministic_speech_from_intent(intent: dict[str, Any]) -> dict[str, str]:
|
| 77 |
glosses = [str(gloss).upper() for gloss in intent.get("detected_glosses", []) if str(gloss).strip()]
|
| 78 |
emotion = str(intent.get("detected_facial_expression") or intent.get("emotion_profile", {}).get("dominant") or "neutral")
|
| 79 |
-
phrase_key = " ".join(glosses)
|
| 80 |
-
|
| 81 |
-
phrase_map = {
|
| 82 |
-
"I LOVE YOU": "I love you.",
|
| 83 |
-
"LOVE YOU": "I love you.",
|
| 84 |
-
"I HAPPY SEE YOU": "I am happy to see you.",
|
| 85 |
-
"I SEE YOU": "I see you.",
|
| 86 |
-
"WHERE": "Where?",
|
| 87 |
-
"WHO": "Who?",
|
| 88 |
-
"WHY": "Why?",
|
| 89 |
-
"WHAT": "What?",
|
| 90 |
-
"HOW": "How?",
|
| 91 |
-
"THANK YOU": "Thank you.",
|
| 92 |
-
"THANKYOU": "Thank you.",
|
| 93 |
-
"HELLO": "Hello.",
|
| 94 |
-
"YES": "Yes.",
|
| 95 |
-
"NO": "No.",
|
| 96 |
-
}
|
| 97 |
|
| 98 |
if not glosses:
|
| 99 |
return {
|
|
@@ -101,9 +83,14 @@ def deterministic_speech_from_intent(intent: dict[str, Any]) -> dict[str, str]:
|
|
| 101 |
"voice_instruction": "Speak calmly and clearly, indicating that no sign was detected.",
|
| 102 |
}
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
tone = "clearly and naturally"
|
| 109 |
if emotion in ("happy", "joy"):
|
|
@@ -119,6 +106,21 @@ def deterministic_speech_from_intent(intent: dict[str, Any]) -> dict[str, str]:
|
|
| 119 |
}
|
| 120 |
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
def enforce_intent_consistency(intent: dict[str, Any], normalized: dict[str, Any]) -> dict[str, Any]:
|
| 123 |
glosses = [str(gloss).upper() for gloss in intent.get("detected_glosses", []) if str(gloss).strip()]
|
| 124 |
phrase_key = " ".join(glosses)
|
|
|
|
| 76 |
def deterministic_speech_from_intent(intent: dict[str, Any]) -> dict[str, str]:
|
| 77 |
glosses = [str(gloss).upper() for gloss in intent.get("detected_glosses", []) if str(gloss).strip()]
|
| 78 |
emotion = str(intent.get("detected_facial_expression") or intent.get("emotion_profile", {}).get("dominant") or "neutral")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
if not glosses:
|
| 81 |
return {
|
|
|
|
| 83 |
"voice_instruction": "Speak calmly and clearly, indicating that no sign was detected.",
|
| 84 |
}
|
| 85 |
|
| 86 |
+
words = [normalize_gloss_token(gloss) for gloss in glosses]
|
| 87 |
+
subtitle = " ".join(word for word in words if word).strip()
|
| 88 |
+
if not subtitle:
|
| 89 |
+
subtitle = " ".join(gloss.lower() for gloss in glosses).strip()
|
| 90 |
+
punctuation = "?" if len(words) == 1 and words[0].lower() in {"where", "who", "why", "what", "how"} else "."
|
| 91 |
+
subtitle = subtitle[:1].upper() + subtitle[1:]
|
| 92 |
+
if subtitle[-1:] not in ".?!":
|
| 93 |
+
subtitle += punctuation
|
| 94 |
|
| 95 |
tone = "clearly and naturally"
|
| 96 |
if emotion in ("happy", "joy"):
|
|
|
|
| 106 |
}
|
| 107 |
|
| 108 |
|
| 109 |
+
def normalize_gloss_token(gloss: str) -> str:
|
| 110 |
+
token = gloss.strip().lower().replace("_", " ").replace("-", " ")
|
| 111 |
+
compact_map = {
|
| 112 |
+
"thankyou": "thank you",
|
| 113 |
+
"callonphone": "call on phone",
|
| 114 |
+
"frenchfries": "french fries",
|
| 115 |
+
"glasswindow": "glass window",
|
| 116 |
+
"haveto": "have to",
|
| 117 |
+
"hesheit": "he she it",
|
| 118 |
+
"minemy": "mine my",
|
| 119 |
+
"weus": "we us",
|
| 120 |
+
}
|
| 121 |
+
return compact_map.get(token, token)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
def enforce_intent_consistency(intent: dict[str, Any], normalized: dict[str, Any]) -> dict[str, Any]:
|
| 125 |
glosses = [str(gloss).upper() for gloss in intent.get("detected_glosses", []) if str(gloss).strip()]
|
| 126 |
phrase_key = " ".join(glosses)
|
tests/test_asl_detector.py
CHANGED
|
@@ -28,6 +28,7 @@ class FakeSignatureInterpreter:
|
|
| 28 |
|
| 29 |
def predict(**kwargs):
|
| 30 |
assert kwargs["inputs"].shape == (543, 3)
|
|
|
|
| 31 |
return {"outputs": np.asarray([[0.1, 0.8, 0.1]], dtype=np.float32)}
|
| 32 |
|
| 33 |
return predict
|
|
@@ -36,6 +37,7 @@ class FakeSignatureInterpreter:
|
|
| 36 |
def test_predict_uses_tflite_signature_runner(tmp_path):
|
| 37 |
detector = ASLDetector(model_dir=tmp_path)
|
| 38 |
keypoints = np.zeros((543, 3), dtype=np.float32)
|
|
|
|
| 39 |
|
| 40 |
output = detector._predict(FakeSignatureInterpreter(), keypoints)
|
| 41 |
|
|
@@ -83,3 +85,13 @@ def test_low_confidence_prediction_is_not_accepted(monkeypatch, tmp_path):
|
|
| 83 |
assert result["status"] == "low_confidence"
|
| 84 |
assert result["top_prediction"] == "where"
|
| 85 |
assert result["gloss_sequence"] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
def predict(**kwargs):
|
| 30 |
assert kwargs["inputs"].shape == (543, 3)
|
| 31 |
+
assert np.isnan(kwargs["inputs"][0][0])
|
| 32 |
return {"outputs": np.asarray([[0.1, 0.8, 0.1]], dtype=np.float32)}
|
| 33 |
|
| 34 |
return predict
|
|
|
|
| 37 |
def test_predict_uses_tflite_signature_runner(tmp_path):
|
| 38 |
detector = ASLDetector(model_dir=tmp_path)
|
| 39 |
keypoints = np.zeros((543, 3), dtype=np.float32)
|
| 40 |
+
keypoints[0][0] = np.nan
|
| 41 |
|
| 42 |
output = detector._predict(FakeSignatureInterpreter(), keypoints)
|
| 43 |
|
|
|
|
| 85 |
assert result["status"] == "low_confidence"
|
| 86 |
assert result["top_prediction"] == "where"
|
| 87 |
assert result["gloss_sequence"] == []
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_keypoint_windows_cover_long_sequences(tmp_path):
|
| 91 |
+
detector = ASLDetector(model_dir=tmp_path)
|
| 92 |
+
keypoints = np.zeros((90, 543, 3), dtype=np.float32)
|
| 93 |
+
|
| 94 |
+
windows = detector._keypoint_windows(keypoints, window_size=30, max_windows=5)
|
| 95 |
+
|
| 96 |
+
assert len(windows) == 5
|
| 97 |
+
assert all(window.shape == (30, 543, 3) for window in windows)
|