lilblueyes commited on
Commit
c6057fb
·
1 Parent(s): c2837be

Align ASL inference with upstream model

Browse files
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=render_live_frame_debug,
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=0.0)
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
- output = self._predict(interpreter, keypoints)
52
-
53
- probs = self._softmax_if_needed(np.asarray(output).reshape(-1))
54
- top_idx = int(np.argmax(probs))
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  top_prediction = self._label_for_index(top_idx)
56
- confidence = float(probs[top_idx])
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 = np.nan_to_num(keypoints, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32)
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=30)
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
- subtitle = phrase_map.get(phrase_key)
105
- if subtitle is None:
106
- subtitle = " ".join(gloss.lower() for gloss in glosses).capitalize() + "."
 
 
 
 
 
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)