lilblueyes commited on
Commit
6dc8a0d
·
1 Parent(s): 2290cb9

Speed up live ASL debug diagnostics

Browse files
README.md CHANGED
@@ -85,6 +85,12 @@ 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
  Good first signs to test because they are in the model vocabulary:
89
 
90
  ```text
 
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
+ Live camera debug prioritizes speed over long temporal batching. It starts predicting after
89
+ `LIVE_ASL_MIN_FRAMES=4`, keeps a rolling buffer of `LIVE_ASL_MAX_FRAMES=12`, and runs ASL
90
+ prediction every `LIVE_ASL_PREDICT_EVERY=1` frame. DeepFace emotion is heavier, so it runs every
91
+ `LIVE_EMOTION_EVERY=45` frames by default. The overlay and status panel still show the current top
92
+ candidates even when the accepted gloss is empty because the confidence is below threshold.
93
+
94
  Good first signs to test because they are in the model vocabulary:
95
 
96
  ```text
signspeak/asl/pipeline.py CHANGED
@@ -36,6 +36,8 @@ def process_asl_frames(
36
  "gloss_sequence": asl.get("gloss_sequence", []),
37
  "top_prediction": asl.get("top_prediction"),
38
  "confidence": float(asl.get("confidence", 0.0) or 0.0),
 
 
39
  "frames_used": int(asl.get("frames_used", len(asl_frames)) or 0),
40
  "keypoints_shape": asl.get("keypoints_shape", []),
41
  "landmarks_status": asl.get("landmarks_status"),
 
36
  "gloss_sequence": asl.get("gloss_sequence", []),
37
  "top_prediction": asl.get("top_prediction"),
38
  "confidence": float(asl.get("confidence", 0.0) or 0.0),
39
+ "confidence_threshold": float(asl.get("confidence_threshold", 0.0) or 0.0),
40
+ "top_predictions": asl.get("top_predictions", []),
41
  "frames_used": int(asl.get("frames_used", len(asl_frames)) or 0),
42
  "keypoints_shape": asl.get("keypoints_shape", []),
43
  "landmarks_status": asl.get("landmarks_status"),
signspeak/live_debug.py CHANGED
@@ -15,8 +15,14 @@ class LiveASLSession:
15
  self.interpreter: Any | None = None
16
  self.frame_keypoints: list[np.ndarray] = []
17
  self.latest_prediction = ""
 
 
18
  self.latest_emotion = "unknown"
19
- self.latest_status = "Waiting for 30 frames."
 
 
 
 
20
  self.frames_seen = 0
21
  self.mp: Any | None = None
22
  self.holistic: Any | None = None
@@ -34,15 +40,17 @@ class LiveASLSession:
34
  draw_holistic_landmarks(self.mp, output, results)
35
  keypoints = extract_keypoints_from_holistic(results, missing_value=np.nan)
36
  self.frame_keypoints.append(keypoints)
37
- self.frame_keypoints = self.frame_keypoints[-30:]
38
- if self.frames_seen % 15 == 0:
39
  self._update_emotion(frame_rgb)
40
- if len(self.frame_keypoints) == 30:
 
 
41
  self._predict_latest()
42
  except Exception as exc:
43
  self.latest_status = f"Live ASL error: {type(exc).__name__}: {exc}"
44
 
45
- return self._draw(output), self.latest_status
46
 
47
  def _load_mediapipe(self) -> None:
48
  try:
@@ -50,6 +58,7 @@ class LiveASLSession:
50
 
51
  self.mp = mp
52
  self.holistic = mp.solutions.holistic.Holistic(
 
53
  min_detection_confidence=0.5,
54
  min_tracking_confidence=0.5,
55
  )
@@ -71,13 +80,22 @@ class LiveASLSession:
71
  top_idx = int(np.argmax(probs))
72
  label = self.asl._label_for_index(top_idx)
73
  confidence = float(probs[top_idx])
 
 
 
74
 
75
  if confidence >= self.asl.confidence_threshold:
76
  self.latest_prediction = f"{label} ({confidence:.0%})"
77
- self.latest_status = f"Accepted: {label} at {confidence:.2f}"
 
 
 
78
  else:
79
  self.latest_prediction = ""
80
- self.latest_status = f"Low confidence: {label} at {confidence:.2f}"
 
 
 
81
 
82
  def _update_emotion(self, frame_rgb: np.ndarray) -> None:
83
  try:
@@ -104,7 +122,7 @@ class LiveASLSession:
104
  import cv2
105
 
106
  height, width = frame_rgb.shape[:2]
107
- cv2.rectangle(frame_rgb, (0, 0), (width, 116), (8, 11, 16), -1)
108
  cv2.putText(
109
  frame_rgb,
110
  f"Sign: {self.latest_prediction or '-'}",
@@ -117,10 +135,20 @@ class LiveASLSession:
117
  )
118
  cv2.putText(
119
  frame_rgb,
120
- f"Emotion: {self.latest_emotion}",
121
  (14, 68),
122
  cv2.FONT_HERSHEY_SIMPLEX,
123
  0.58,
 
 
 
 
 
 
 
 
 
 
124
  (245, 158, 11),
125
  2,
126
  cv2.LINE_AA,
@@ -128,7 +156,7 @@ class LiveASLSession:
128
  cv2.putText(
129
  frame_rgb,
130
  self.latest_status[:96],
131
- (14, 100),
132
  cv2.FONT_HERSHEY_SIMPLEX,
133
  0.48,
134
  (248, 250, 252),
@@ -137,6 +165,21 @@ class LiveASLSession:
137
  )
138
  return frame_rgb
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
  _live_session: LiveASLSession | None = None
142
 
 
15
  self.interpreter: Any | None = None
16
  self.frame_keypoints: list[np.ndarray] = []
17
  self.latest_prediction = ""
18
+ self.latest_top_candidate = ""
19
+ self.latest_top_predictions: list[dict[str, Any]] = []
20
  self.latest_emotion = "unknown"
21
+ self.min_prediction_frames = max(1, int(os.getenv("LIVE_ASL_MIN_FRAMES", "4")))
22
+ self.max_prediction_frames = max(self.min_prediction_frames, int(os.getenv("LIVE_ASL_MAX_FRAMES", "12")))
23
+ self.predict_every = max(1, int(os.getenv("LIVE_ASL_PREDICT_EVERY", "1")))
24
+ self.emotion_every = max(1, int(os.getenv("LIVE_EMOTION_EVERY", "45")))
25
+ self.latest_status = f"Waiting for live frames: 0/{self.min_prediction_frames}."
26
  self.frames_seen = 0
27
  self.mp: Any | None = None
28
  self.holistic: Any | None = None
 
40
  draw_holistic_landmarks(self.mp, output, results)
41
  keypoints = extract_keypoints_from_holistic(results, missing_value=np.nan)
42
  self.frame_keypoints.append(keypoints)
43
+ self.frame_keypoints = self.frame_keypoints[-self.max_prediction_frames :]
44
+ if self.frames_seen % self.emotion_every == 0:
45
  self._update_emotion(frame_rgb)
46
+ if len(self.frame_keypoints) < self.min_prediction_frames:
47
+ self.latest_status = f"Waiting for live frames: {len(self.frame_keypoints)}/{self.min_prediction_frames}."
48
+ elif self.frames_seen % self.predict_every == 0:
49
  self._predict_latest()
50
  except Exception as exc:
51
  self.latest_status = f"Live ASL error: {type(exc).__name__}: {exc}"
52
 
53
+ return self._draw(output), self._status_text()
54
 
55
  def _load_mediapipe(self) -> None:
56
  try:
 
58
 
59
  self.mp = mp
60
  self.holistic = mp.solutions.holistic.Holistic(
61
+ model_complexity=0,
62
  min_detection_confidence=0.5,
63
  min_tracking_confidence=0.5,
64
  )
 
80
  top_idx = int(np.argmax(probs))
81
  label = self.asl._label_for_index(top_idx)
82
  confidence = float(probs[top_idx])
83
+ top_predictions = self.asl._top_predictions(probs, limit=3)
84
+ self.latest_top_predictions = top_predictions
85
+ self.latest_top_candidate = f"{label} ({confidence:.0%})"
86
 
87
  if confidence >= self.asl.confidence_threshold:
88
  self.latest_prediction = f"{label} ({confidence:.0%})"
89
+ self.latest_status = (
90
+ f"Accepted: {label} at {confidence:.2f} "
91
+ f"with {len(self.frame_keypoints)} live frames."
92
+ )
93
  else:
94
  self.latest_prediction = ""
95
+ self.latest_status = (
96
+ f"Top candidate: {label} at {confidence:.2f}; "
97
+ f"waiting for {self.asl.confidence_threshold:.2f}."
98
+ )
99
 
100
  def _update_emotion(self, frame_rgb: np.ndarray) -> None:
101
  try:
 
122
  import cv2
123
 
124
  height, width = frame_rgb.shape[:2]
125
+ cv2.rectangle(frame_rgb, (0, 0), (width, 146), (8, 11, 16), -1)
126
  cv2.putText(
127
  frame_rgb,
128
  f"Sign: {self.latest_prediction or '-'}",
 
135
  )
136
  cv2.putText(
137
  frame_rgb,
138
+ f"Top: {self.latest_top_candidate or '-'}",
139
  (14, 68),
140
  cv2.FONT_HERSHEY_SIMPLEX,
141
  0.58,
142
+ (129, 140, 248),
143
+ 2,
144
+ cv2.LINE_AA,
145
+ )
146
+ cv2.putText(
147
+ frame_rgb,
148
+ f"Emotion: {self.latest_emotion}",
149
+ (14, 98),
150
+ cv2.FONT_HERSHEY_SIMPLEX,
151
+ 0.58,
152
  (245, 158, 11),
153
  2,
154
  cv2.LINE_AA,
 
156
  cv2.putText(
157
  frame_rgb,
158
  self.latest_status[:96],
159
+ (14, 128),
160
  cv2.FONT_HERSHEY_SIMPLEX,
161
  0.48,
162
  (248, 250, 252),
 
165
  )
166
  return frame_rgb
167
 
168
+ def _status_text(self) -> str:
169
+ top_lines = [
170
+ f"- {item.get('label')}: {float(item.get('confidence', 0.0) or 0.0):.2f}"
171
+ for item in self.latest_top_predictions
172
+ ]
173
+ top_block = "\n".join(top_lines) if top_lines else "- None yet"
174
+ return (
175
+ f"{self.latest_status}\n"
176
+ f"Accepted sign: {self.latest_prediction or 'None'}\n"
177
+ f"Top candidates:\n{top_block}\n"
178
+ f"Frames in rolling buffer: {len(self.frame_keypoints)}/{self.max_prediction_frames}\n"
179
+ f"Acceptance threshold: {self.asl.confidence_threshold:.2f}\n"
180
+ f"Emotion: {self.latest_emotion}"
181
+ )
182
+
183
 
184
  _live_session: LiveASLSession | None = None
185
 
signspeak/pipeline.py CHANGED
@@ -134,14 +134,25 @@ def summarize_asl_result(result: dict[str, Any]) -> str:
134
  top_prediction = asl.get("top_prediction") or "None"
135
  confidence = float(asl.get("confidence", 0.0) or 0.0)
136
  threshold = float(asl.get("confidence_threshold", 0.0) or 0.0)
 
137
  override = result.get("intent_input", {}).get("diagnostics", {}).get("manual_gloss_override")
138
  override_line = "\nOverride: manual glosses applied" if override else ""
139
  return (
140
  f"ASL status: {asl.get('status', 'unknown')}\n"
141
  f"Detected words: {gloss_line}\n"
142
  f"Top candidate: {top_prediction} ({confidence:.2f}, threshold {threshold:.2f})\n"
 
143
  f"Landmarks: {asl.get('landmarks_status', 'unknown')} via {asl.get('landmarks_detector', 'unknown')}\n"
144
  f"Emotion: {emotion.get('dominant_emotion', 'unknown')} "
145
  f"({float(emotion.get('intensity', 0.0) or 0.0):.2f})"
146
  f"{override_line}"
147
  )
 
 
 
 
 
 
 
 
 
 
134
  top_prediction = asl.get("top_prediction") or "None"
135
  confidence = float(asl.get("confidence", 0.0) or 0.0)
136
  threshold = float(asl.get("confidence_threshold", 0.0) or 0.0)
137
+ top_predictions = _format_top_predictions(asl.get("top_predictions", []))
138
  override = result.get("intent_input", {}).get("diagnostics", {}).get("manual_gloss_override")
139
  override_line = "\nOverride: manual glosses applied" if override else ""
140
  return (
141
  f"ASL status: {asl.get('status', 'unknown')}\n"
142
  f"Detected words: {gloss_line}\n"
143
  f"Top candidate: {top_prediction} ({confidence:.2f}, threshold {threshold:.2f})\n"
144
+ f"Top candidates: {top_predictions}\n"
145
  f"Landmarks: {asl.get('landmarks_status', 'unknown')} via {asl.get('landmarks_detector', 'unknown')}\n"
146
  f"Emotion: {emotion.get('dominant_emotion', 'unknown')} "
147
  f"({float(emotion.get('intensity', 0.0) or 0.0):.2f})"
148
  f"{override_line}"
149
  )
150
+
151
+
152
+ def _format_top_predictions(top_predictions: list[dict[str, Any]]) -> str:
153
+ if not top_predictions:
154
+ return "None"
155
+ return ", ".join(
156
+ f"{item.get('label')} {float(item.get('confidence', 0.0) or 0.0):.2f}"
157
+ for item in top_predictions[:5]
158
+ )
tests/test_asl_pipeline.py CHANGED
@@ -1,4 +1,7 @@
1
- from signspeak.asl.pipeline import build_intent_input
 
 
 
2
  from signspeak.pipeline import apply_gloss_override, parse_gloss_override, resolve_video_path, summarize_asl_result
3
 
4
 
@@ -25,6 +28,41 @@ def test_build_intent_input_matches_llm_schema():
25
  assert intent["diagnostics"]["asl_status"] == "ok"
26
 
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  def test_summarize_asl_result_is_stable_for_missing_fields():
29
  summary = summarize_asl_result(
30
  {
 
1
+ import numpy as np
2
+
3
+ import signspeak.asl.pipeline as asl_pipeline
4
+ from signspeak.asl.pipeline import build_intent_input, process_asl_frames
5
  from signspeak.pipeline import apply_gloss_override, parse_gloss_override, resolve_video_path, summarize_asl_result
6
 
7
 
 
28
  assert intent["diagnostics"]["asl_status"] == "ok"
29
 
30
 
31
+ def test_process_asl_frames_preserves_detector_diagnostics(monkeypatch):
32
+ class FakeASLDetector:
33
+ def predict_from_frames(self, frames):
34
+ return {
35
+ "status": "ok",
36
+ "gloss_sequence": ["talk"],
37
+ "top_prediction": "talk",
38
+ "confidence": 0.96,
39
+ "confidence_threshold": 0.70,
40
+ "top_predictions": [{"label": "talk", "confidence": 0.96}],
41
+ "frames_used": len(frames),
42
+ "keypoints_shape": [8, 543, 3],
43
+ "landmarks_status": "ok",
44
+ "landmarks_detector": "holistic",
45
+ }
46
+
47
+ monkeypatch.setattr(asl_pipeline, "ASLDetector", FakeASLDetector)
48
+ monkeypatch.setattr(
49
+ asl_pipeline,
50
+ "detect_emotion_on_frames",
51
+ lambda frames: {
52
+ "status": "ok",
53
+ "dominant_emotion": "neutral",
54
+ "intensity": 0.29,
55
+ "emotion_scores": {"neutral": 0.29},
56
+ },
57
+ )
58
+
59
+ result = process_asl_frames([np.zeros((2, 2, 3), dtype=np.uint8)] * 8)
60
+
61
+ assert result["asl"]["confidence_threshold"] == 0.70
62
+ assert result["asl"]["top_predictions"] == [{"label": "talk", "confidence": 0.96}]
63
+ assert result["intent_input"]["detected_glosses"] == ["talk"]
64
+
65
+
66
  def test_summarize_asl_result_is_stable_for_missing_fields():
67
  summary = summarize_asl_result(
68
  {