lilblueyes commited on
Commit
c6c2ad9
·
1 Parent(s): 35605b9

Expose stepwise ASL debug pipeline

Browse files
README.md CHANGED
@@ -28,6 +28,21 @@ Video upload / camera capture
28
  The app is organized so each brick can fail independently with diagnostics instead of blocking
29
  the whole interface at startup.
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  ## Local checks
32
 
33
  Run unit tests:
@@ -46,6 +61,11 @@ python3 scripts/test_full_pipeline.py
46
  ```
47
 
48
  `scripts/test_asl_brick.py` creates a tiny temporary demo clip when no video path is supplied.
 
 
 
 
 
49
 
50
  ## ASL model files
51
 
 
28
  The app is organized so each brick can fail independently with diagnostics instead of blocking
29
  the whole interface at startup.
30
 
31
+ The demo screen is intentionally step-by-step:
32
+
33
+ ```text
34
+ 1 Analyze ASL -> debug overlay + intent JSON
35
+ 2 Generate subtitle -> llama.cpp output
36
+ 3 Generate speech -> Qwen3-TTS audio
37
+ ```
38
+
39
+ When the ASL classifier file is missing, the UI exposes a visible debug gloss override instead of
40
+ pretending the model detected words. For an "I love you" demo clip, use:
41
+
42
+ ```text
43
+ I LOVE YOU
44
+ ```
45
+
46
  ## Local checks
47
 
48
  Run unit tests:
 
61
  ```
62
 
63
  `scripts/test_asl_brick.py` creates a tiny temporary demo clip when no video path is supplied.
64
+ It also writes a debug overlay video path. To test the transparent fallback:
65
+
66
+ ```bash
67
+ python3 scripts/test_asl_brick.py --gloss-override "I LOVE YOU"
68
+ ```
69
 
70
  ## ASL model files
71
 
app.py CHANGED
@@ -39,9 +39,9 @@ SPEAKER_CHOICES = [
39
  ]
40
 
41
 
42
- def run_asl_brick(video_file: str | None) -> tuple[str, dict, str]:
43
  try:
44
- return run_asl_video(video_file)
45
  except Exception as exc:
46
  raise gr.Error(f"ASL pipeline failed: {type(exc).__name__}: {exc}") from exc
47
 
@@ -60,17 +60,6 @@ def run_tts_brick(text: str, language: str, speaker: str, instruction: str) -> s
60
  raise gr.Error(f"Qwen3-TTS generation failed: {type(exc).__name__}: {exc}") from exc
61
 
62
 
63
- def run_full_pipeline(
64
- video_file: str | None,
65
- language: str,
66
- speaker: str,
67
- ) -> tuple[str, dict, str, str, str, dict, str]:
68
- intent_json, asl_result, asl_summary = run_asl_brick(video_file)
69
- subtitle, instruction, llm_result = run_llm_brick(intent_json)
70
- audio_path = run_tts_brick(subtitle, language, speaker, instruction)
71
- return intent_json, asl_result, asl_summary, subtitle, instruction, llm_result, audio_path
72
-
73
-
74
  def build_video_input(label: str) -> gr.Video:
75
  return gr.Video(
76
  label=label,
@@ -119,6 +108,12 @@ with gr.Blocks(title="SignSpeak Local") as demo:
119
  with gr.Column(scale=6, elem_classes=["panel-shell", "input-panel"]):
120
  gr.HTML('<div class="section-kicker">01 Capture</div>')
121
  full_video_input = build_video_input("Video or camera capture")
 
 
 
 
 
 
122
  with gr.Row(elem_classes=["control-row"]):
123
  full_language_input = gr.Dropdown(
124
  label="Language",
@@ -130,18 +125,27 @@ with gr.Blocks(title="SignSpeak Local") as demo:
130
  choices=SPEAKER_CHOICES,
131
  value="Ryan",
132
  )
133
- run_full_button = gr.Button("Run full pipeline", elem_id="run_full")
134
 
135
  with gr.Column(scale=5, elem_classes=["panel-shell", "output-panel"]):
136
- gr.HTML('<div class="section-kicker">02 Speech output</div>')
137
- full_audio_output = gr.Audio(label="Generated audio", type="filepath")
138
  full_summary_output = gr.Textbox(label="ASL summary", lines=4)
 
 
 
 
 
 
139
  full_subtitle_output = gr.Textbox(label="Subtitle", lines=3)
140
  full_instruction_output = gr.Textbox(label="Voice instruction", lines=3)
 
 
 
 
141
 
142
  with gr.Accordion("Pipeline diagnostics", open=False):
143
  with gr.Row(elem_classes=["diagnostic-grid"]):
144
- full_intent_output = gr.Code(label="Intent JSON", language="json", lines=12)
145
  full_asl_json_output = gr.JSON(label="ASL structured output")
146
  full_llm_json_output = gr.JSON(label="LLM structured output")
147
 
@@ -150,10 +154,16 @@ with gr.Blocks(title="SignSpeak Local") as demo:
150
  with gr.Column(scale=1, elem_classes=["panel-shell"]):
151
  gr.HTML('<div class="section-kicker">ASL brick</div>')
152
  asl_video_input = build_video_input("Video or camera capture")
 
 
 
 
 
153
  run_asl_button = gr.Button("Run ASL brick", elem_id="run_asl")
154
  asl_summary_output = gr.Textbox(label="ASL summary", lines=4)
155
  asl_intent_output = gr.Code(label="Intent JSON", language="json", lines=12)
156
  with gr.Column(scale=1):
 
157
  asl_json_output = gr.JSON(label="ASL structured output")
158
 
159
  with gr.Row(elem_classes=["brick-grid"]):
@@ -199,24 +209,33 @@ with gr.Blocks(title="SignSpeak Local") as demo:
199
  """
200
  )
201
 
202
- run_full_button.click(
203
- fn=run_full_pipeline,
204
- inputs=[full_video_input, full_language_input, full_speaker_input],
205
- outputs=[
206
- full_intent_output,
207
- full_asl_json_output,
208
- full_summary_output,
 
 
 
 
 
 
 
 
209
  full_subtitle_output,
 
 
210
  full_instruction_output,
211
- full_llm_json_output,
212
- full_audio_output,
213
  ],
 
214
  )
215
 
216
  run_asl_button.click(
217
  fn=run_asl_brick,
218
- inputs=[asl_video_input],
219
- outputs=[asl_intent_output, asl_json_output, asl_summary_output],
220
  )
221
 
222
  run_llm_button.click(
 
39
  ]
40
 
41
 
42
+ def run_asl_brick(video_file: str | None, gloss_override: str | None = None) -> tuple[str, dict, str, str]:
43
  try:
44
+ return run_asl_video(video_file, gloss_override)
45
  except Exception as exc:
46
  raise gr.Error(f"ASL pipeline failed: {type(exc).__name__}: {exc}") from exc
47
 
 
60
  raise gr.Error(f"Qwen3-TTS generation failed: {type(exc).__name__}: {exc}") from exc
61
 
62
 
 
 
 
 
 
 
 
 
 
 
 
63
  def build_video_input(label: str) -> gr.Video:
64
  return gr.Video(
65
  label=label,
 
108
  with gr.Column(scale=6, elem_classes=["panel-shell", "input-panel"]):
109
  gr.HTML('<div class="section-kicker">01 Capture</div>')
110
  full_video_input = build_video_input("Video or camera capture")
111
+ full_gloss_override_input = gr.Textbox(
112
+ label="Debug gloss override",
113
+ value="I LOVE YOU",
114
+ lines=1,
115
+ info="Used when the ASL classifier is missing or uncertain. Leave empty to use raw model output only.",
116
+ )
117
  with gr.Row(elem_classes=["control-row"]):
118
  full_language_input = gr.Dropdown(
119
  label="Language",
 
125
  choices=SPEAKER_CHOICES,
126
  value="Ryan",
127
  )
128
+ run_demo_asl_button = gr.Button("1 Analyze ASL", elem_id="run_demo_asl")
129
 
130
  with gr.Column(scale=5, elem_classes=["panel-shell", "output-panel"]):
131
+ gr.HTML('<div class="section-kicker">02 Live debug</div>')
132
+ full_debug_video_output = gr.Video(label="Debug overlay playback")
133
  full_summary_output = gr.Textbox(label="ASL summary", lines=4)
134
+ full_intent_output = gr.Code(label="Intent JSON", language="json", lines=8)
135
+
136
+ with gr.Row(elem_classes=["demo-grid"]):
137
+ with gr.Column(scale=1, elem_classes=["panel-shell"]):
138
+ gr.HTML('<div class="section-kicker">03 llama.cpp</div>')
139
+ run_demo_llm_button = gr.Button("2 Generate subtitle", elem_id="run_demo_llm")
140
  full_subtitle_output = gr.Textbox(label="Subtitle", lines=3)
141
  full_instruction_output = gr.Textbox(label="Voice instruction", lines=3)
142
+ with gr.Column(scale=1, elem_classes=["panel-shell"]):
143
+ gr.HTML('<div class="section-kicker">04 Qwen3-TTS</div>')
144
+ run_demo_tts_button = gr.Button("3 Generate speech", elem_id="run_demo_tts")
145
+ full_audio_output = gr.Audio(label="Generated audio", type="filepath")
146
 
147
  with gr.Accordion("Pipeline diagnostics", open=False):
148
  with gr.Row(elem_classes=["diagnostic-grid"]):
 
149
  full_asl_json_output = gr.JSON(label="ASL structured output")
150
  full_llm_json_output = gr.JSON(label="LLM structured output")
151
 
 
154
  with gr.Column(scale=1, elem_classes=["panel-shell"]):
155
  gr.HTML('<div class="section-kicker">ASL brick</div>')
156
  asl_video_input = build_video_input("Video or camera capture")
157
+ asl_gloss_override_input = gr.Textbox(
158
+ label="Debug gloss override",
159
+ value="",
160
+ lines=1,
161
+ )
162
  run_asl_button = gr.Button("Run ASL brick", elem_id="run_asl")
163
  asl_summary_output = gr.Textbox(label="ASL summary", lines=4)
164
  asl_intent_output = gr.Code(label="Intent JSON", language="json", lines=12)
165
  with gr.Column(scale=1):
166
+ asl_debug_video_output = gr.Video(label="Debug overlay playback")
167
  asl_json_output = gr.JSON(label="ASL structured output")
168
 
169
  with gr.Row(elem_classes=["brick-grid"]):
 
209
  """
210
  )
211
 
212
+ run_demo_asl_button.click(
213
+ fn=run_asl_brick,
214
+ inputs=[full_video_input, full_gloss_override_input],
215
+ outputs=[full_intent_output, full_asl_json_output, full_summary_output, full_debug_video_output],
216
+ )
217
+
218
+ run_demo_llm_button.click(
219
+ fn=run_llm_brick,
220
+ inputs=[full_intent_output],
221
+ outputs=[full_subtitle_output, full_instruction_output, full_llm_json_output],
222
+ )
223
+
224
+ run_demo_tts_button.click(
225
+ fn=run_tts_brick,
226
+ inputs=[
227
  full_subtitle_output,
228
+ full_language_input,
229
+ full_speaker_input,
230
  full_instruction_output,
 
 
231
  ],
232
+ outputs=[full_audio_output],
233
  )
234
 
235
  run_asl_button.click(
236
  fn=run_asl_brick,
237
+ inputs=[asl_video_input, asl_gloss_override_input],
238
+ outputs=[asl_intent_output, asl_json_output, asl_summary_output, asl_debug_video_output],
239
  )
240
 
241
  run_llm_button.click(
assets/styles.css CHANGED
@@ -278,19 +278,22 @@ button:active {
278
  transform: translateY(0);
279
  }
280
 
281
- #run_asl {
 
282
  background: linear-gradient(135deg, var(--teal), var(--green)) !important;
283
  color: #04111a !important;
284
  border: none !important;
285
  }
286
 
287
- #run_llm {
 
288
  background: linear-gradient(135deg, var(--indigo), var(--blue)) !important;
289
  color: white !important;
290
  border: none !important;
291
  }
292
 
293
  #run_tts,
 
294
  #run_full {
295
  background: linear-gradient(135deg, var(--amber), var(--rose)) !important;
296
  color: #111827 !important;
 
278
  transform: translateY(0);
279
  }
280
 
281
+ #run_asl,
282
+ #run_demo_asl {
283
  background: linear-gradient(135deg, var(--teal), var(--green)) !important;
284
  color: #04111a !important;
285
  border: none !important;
286
  }
287
 
288
+ #run_llm,
289
+ #run_demo_llm {
290
  background: linear-gradient(135deg, var(--indigo), var(--blue)) !important;
291
  color: white !important;
292
  border: none !important;
293
  }
294
 
295
  #run_tts,
296
+ #run_demo_tts,
297
  #run_full {
298
  background: linear-gradient(135deg, var(--amber), var(--rose)) !important;
299
  color: #111827 !important;
scripts/test_asl_brick.py CHANGED
@@ -11,10 +11,12 @@ from signspeak.pipeline import run_asl_video
11
  def main() -> None:
12
  parser = argparse.ArgumentParser(description="Run only the ASL video brick.")
13
  parser.add_argument("video", nargs="?", default=None, help="Video path. Creates a demo clip if omitted.")
 
14
  args = parser.parse_args()
15
 
16
- intent_json, result, summary = run_asl_video(args.video)
17
  print(summary)
 
18
  print("\nIntent JSON:")
19
  print(intent_json)
20
  print("\nFull ASL output:")
 
11
  def main() -> None:
12
  parser = argparse.ArgumentParser(description="Run only the ASL video brick.")
13
  parser.add_argument("video", nargs="?", default=None, help="Video path. Creates a demo clip if omitted.")
14
+ parser.add_argument("--gloss-override", default=None, help="Manual glosses to use when the ASL model is missing.")
15
  args = parser.parse_args()
16
 
17
+ intent_json, result, summary, debug_video_path = run_asl_video(args.video, args.gloss_override)
18
  print(summary)
19
+ print(f"\nDebug overlay: {debug_video_path}")
20
  print("\nIntent JSON:")
21
  print(intent_json)
22
  print("\nFull ASL output:")
scripts/test_full_pipeline.py CHANGED
@@ -12,15 +12,17 @@ from signspeak.tts import generate_tts
12
  def main() -> None:
13
  parser = argparse.ArgumentParser(description="Run ASL -> llama.cpp -> Qwen3-TTS.")
14
  parser.add_argument("video", nargs="?", default=None, help="Video path. Creates a demo clip if omitted.")
 
15
  parser.add_argument("--language", default="English")
16
  parser.add_argument("--speaker", default="Ryan")
17
  args = parser.parse_args()
18
 
19
- intent_json, _, summary = run_asl_video(args.video)
20
  subtitle, instruction, _ = generate_subtitle_and_instruction(intent_json)
21
  audio_path = generate_tts(subtitle, args.language, args.speaker, instruction)
22
 
23
  print(summary)
 
24
  print(f"Subtitle: {subtitle}")
25
  print(f"Voice instruction: {instruction}")
26
  print(f"Audio: {audio_path}")
 
12
  def main() -> None:
13
  parser = argparse.ArgumentParser(description="Run ASL -> llama.cpp -> Qwen3-TTS.")
14
  parser.add_argument("video", nargs="?", default=None, help="Video path. Creates a demo clip if omitted.")
15
+ parser.add_argument("--gloss-override", default=None, help="Manual glosses to use when the ASL model is missing.")
16
  parser.add_argument("--language", default="English")
17
  parser.add_argument("--speaker", default="Ryan")
18
  args = parser.parse_args()
19
 
20
+ intent_json, _, summary, debug_video_path = run_asl_video(args.video, args.gloss_override)
21
  subtitle, instruction, _ = generate_subtitle_and_instruction(intent_json)
22
  audio_path = generate_tts(subtitle, args.language, args.speaker, instruction)
23
 
24
  print(summary)
25
+ print(f"Debug overlay: {debug_video_path}")
26
  print(f"Subtitle: {subtitle}")
27
  print(f"Voice instruction: {instruction}")
28
  print(f"Audio: {audio_path}")
signspeak/debug_video.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ def create_debug_overlay_video(video_path: str | Path, result: dict[str, Any]) -> str:
10
+ cv2 = _load_cv2()
11
+ path = Path(video_path)
12
+ cap = cv2.VideoCapture(str(path))
13
+ if not cap.isOpened():
14
+ raise ValueError(f"Could not open video for debug overlay: {path}")
15
+
16
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 640)
17
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 480)
18
+ fps = float(cap.get(cv2.CAP_PROP_FPS) or 24.0)
19
+ if fps <= 0:
20
+ fps = 24.0
21
+
22
+ output_path = Path(tempfile.gettempdir()) / f"signspeak_debug_{int(time.time() * 1000)}.mp4"
23
+ writer = cv2.VideoWriter(
24
+ str(output_path),
25
+ cv2.VideoWriter_fourcc(*"mp4v"),
26
+ fps,
27
+ (width, height),
28
+ )
29
+ if not writer.isOpened():
30
+ cap.release()
31
+ raise RuntimeError(f"Could not create debug overlay video: {output_path}")
32
+
33
+ asl = result.get("asl", {})
34
+ emotion = result.get("emotion", {})
35
+ intent = result.get("intent_input", {})
36
+ glosses = intent.get("detected_glosses") or asl.get("gloss_sequence") or []
37
+ gloss_text = " ".join(str(gloss) for gloss in glosses) if glosses else "NO ASL WORDS DETECTED"
38
+ emotion_text = str(emotion.get("dominant_emotion", "unknown")).upper()
39
+ status_text = f"ASL {asl.get('status', 'unknown')} | EMOTION {emotion.get('status', 'unknown')}"
40
+
41
+ try:
42
+ while True:
43
+ ok, frame = cap.read()
44
+ if not ok or frame is None:
45
+ break
46
+ _draw_overlay(cv2, frame, gloss_text, emotion_text, status_text)
47
+ writer.write(frame)
48
+ finally:
49
+ cap.release()
50
+ writer.release()
51
+
52
+ return str(output_path)
53
+
54
+
55
+ def _draw_overlay(cv2, frame, gloss_text: str, emotion_text: str, status_text: str) -> None:
56
+ height, width = frame.shape[:2]
57
+ pad = 14
58
+ panel_height = 112
59
+ cv2.rectangle(frame, (0, 0), (width, panel_height), (8, 11, 16), -1)
60
+ cv2.rectangle(frame, (0, panel_height - 2), (width, panel_height), (45, 212, 191), -1)
61
+
62
+ _put_text(cv2, frame, "DETECTED ASL", (pad, 28), 0.52, (203, 213, 225), 1)
63
+ _put_text(cv2, frame, gloss_text, (pad, 64), 0.86, (248, 250, 252), 2)
64
+ _put_text(cv2, frame, f"EMOTION: {emotion_text}", (pad, 96), 0.58, (245, 158, 11), 2)
65
+
66
+ status_size = cv2.getTextSize(status_text, cv2.FONT_HERSHEY_SIMPLEX, 0.46, 1)[0]
67
+ x = max(pad, width - status_size[0] - pad)
68
+ _put_text(cv2, frame, status_text, (x, height - 18), 0.46, (203, 213, 225), 1)
69
+
70
+
71
+ def _put_text(cv2, frame, text: str, origin: tuple[int, int], scale: float, color: tuple[int, int, int], thickness: int) -> None:
72
+ cv2.putText(
73
+ frame,
74
+ text[:90],
75
+ origin,
76
+ cv2.FONT_HERSHEY_SIMPLEX,
77
+ scale,
78
+ color,
79
+ thickness,
80
+ cv2.LINE_AA,
81
+ )
82
+
83
+
84
+ def _load_cv2():
85
+ try:
86
+ import cv2
87
+
88
+ return cv2
89
+ except Exception as exc:
90
+ raise RuntimeError("OpenCV is required for debug overlay video generation.") from exc
91
+
signspeak/llm.py CHANGED
@@ -73,9 +73,67 @@ def normalize_llm_output(parsed: dict[str, Any]) -> dict[str, str]:
73
  }
74
 
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  def generate_subtitle_and_instruction(intent_json_text: str) -> tuple[str, str, dict[str, Any]]:
77
  intent = safe_json_loads(intent_json_text)
78
 
 
 
 
 
 
79
  system_prompt = (
80
  "You are an assistant inside an ASL-to-speech accessibility app. "
81
  "Convert detected ASL glosses and emotion metadata into speech output. "
@@ -122,13 +180,15 @@ Expected output format:
122
  try:
123
  parsed = extract_json_object(raw_content)
124
  normalized: dict[str, Any] = normalize_llm_output(parsed)
 
125
  except Exception as error:
126
- normalized = {
127
- "subtitle": "I am happy to see you.",
128
- "voice_instruction": "Speak warmly, joyfully, and clearly.",
129
  "parser_warning": str(error),
130
  "raw_model_output": raw_content,
131
- }
 
132
 
133
  return (
134
  normalized["subtitle"],
@@ -156,4 +216,3 @@ def get_llm_model() -> Any:
156
  )
157
 
158
  return _llm_model
159
-
 
73
  }
74
 
75
 
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
+ "THANK YOU": "Thank you.",
87
+ "HELLO": "Hello.",
88
+ "YES": "Yes.",
89
+ "NO": "No.",
90
+ }
91
+
92
+ if not glosses:
93
+ return {
94
+ "subtitle": "No ASL words were detected yet.",
95
+ "voice_instruction": "Speak calmly and clearly, indicating that no sign was detected.",
96
+ }
97
+
98
+ subtitle = phrase_map.get(phrase_key)
99
+ if subtitle is None:
100
+ subtitle = " ".join(gloss.lower() for gloss in glosses).capitalize() + "."
101
+
102
+ tone = "clearly and naturally"
103
+ if emotion in ("happy", "joy"):
104
+ tone = "warmly, joyfully, and clearly"
105
+ elif emotion in ("sad", "fear"):
106
+ tone = "gently, slowly, and clearly"
107
+ elif emotion in ("angry",):
108
+ tone = "firmly, controlled, and clearly"
109
+
110
+ return {
111
+ "subtitle": subtitle,
112
+ "voice_instruction": f"Speak {tone}.",
113
+ }
114
+
115
+
116
+ def enforce_intent_consistency(intent: dict[str, Any], normalized: dict[str, Any]) -> dict[str, Any]:
117
+ glosses = [str(gloss).upper() for gloss in intent.get("detected_glosses", []) if str(gloss).strip()]
118
+ phrase_key = " ".join(glosses)
119
+ subtitle = str(normalized.get("subtitle", "")).lower()
120
+
121
+ if phrase_key in ("I LOVE YOU", "LOVE YOU") and "love" not in subtitle:
122
+ corrected = deterministic_speech_from_intent(intent)
123
+ corrected["consistency_warning"] = "LLM subtitle did not match I LOVE YOU glosses; deterministic correction applied."
124
+ return corrected
125
+
126
+ return normalized
127
+
128
+
129
  def generate_subtitle_and_instruction(intent_json_text: str) -> tuple[str, str, dict[str, Any]]:
130
  intent = safe_json_loads(intent_json_text)
131
 
132
+ if not intent.get("detected_glosses"):
133
+ normalized = deterministic_speech_from_intent(intent)
134
+ normalized["llm_skipped"] = "No detected_glosses were available."
135
+ return normalized["subtitle"], normalized["voice_instruction"], normalized
136
+
137
  system_prompt = (
138
  "You are an assistant inside an ASL-to-speech accessibility app. "
139
  "Convert detected ASL glosses and emotion metadata into speech output. "
 
180
  try:
181
  parsed = extract_json_object(raw_content)
182
  normalized: dict[str, Any] = normalize_llm_output(parsed)
183
+ normalized = enforce_intent_consistency(intent, normalized)
184
  except Exception as error:
185
+ normalized = deterministic_speech_from_intent(intent)
186
+ normalized.update(
187
+ {
188
  "parser_warning": str(error),
189
  "raw_model_output": raw_content,
190
+ }
191
+ )
192
 
193
  return (
194
  normalized["subtitle"],
 
216
  )
217
 
218
  return _llm_model
 
signspeak/pipeline.py CHANGED
@@ -8,6 +8,7 @@ from typing import Any
8
  import numpy as np
9
 
10
  from .asl import process_asl_video
 
11
 
12
 
13
  DEFAULT_INTENT = {
@@ -28,11 +29,40 @@ def json_text(data: dict[str, Any]) -> str:
28
  return json.dumps(data, ensure_ascii=False, indent=2)
29
 
30
 
31
- def run_asl_video(video_file: Any | None) -> tuple[str, dict[str, Any], str]:
 
 
 
32
  video_path = resolve_video_path(video_file)
33
  result = process_asl_video(video_path)
 
34
  intent = result["intent_input"]
35
- return json_text(intent), result, summarize_asl_result(result)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  def resolve_video_path(video_file: Any | None) -> Path:
@@ -96,10 +126,15 @@ def create_synthetic_demo_video() -> Path:
96
  def summarize_asl_result(result: dict[str, Any]) -> str:
97
  asl = result.get("asl", {})
98
  emotion = result.get("emotion", {})
 
 
 
 
99
  return (
100
  f"ASL status: {asl.get('status', 'unknown')}\n"
101
- f"Top prediction: {asl.get('top_prediction')}\n"
102
  f"Landmarks: {asl.get('landmarks_status', 'unknown')} via {asl.get('landmarks_detector', 'unknown')}\n"
103
  f"Emotion: {emotion.get('dominant_emotion', 'unknown')} "
104
  f"({float(emotion.get('intensity', 0.0) or 0.0):.2f})"
 
105
  )
 
8
  import numpy as np
9
 
10
  from .asl import process_asl_video
11
+ from .debug_video import create_debug_overlay_video
12
 
13
 
14
  DEFAULT_INTENT = {
 
29
  return json.dumps(data, ensure_ascii=False, indent=2)
30
 
31
 
32
+ def run_asl_video(
33
+ video_file: Any | None,
34
+ gloss_override: str | None = None,
35
+ ) -> tuple[str, dict[str, Any], str, str]:
36
  video_path = resolve_video_path(video_file)
37
  result = process_asl_video(video_path)
38
+ result = apply_gloss_override(result, gloss_override)
39
  intent = result["intent_input"]
40
+ debug_video_path = create_debug_overlay_video(video_path, result)
41
+ return json_text(intent), result, summarize_asl_result(result), debug_video_path
42
+
43
+
44
+ def apply_gloss_override(result: dict[str, Any], gloss_override: str | None) -> dict[str, Any]:
45
+ glosses = parse_gloss_override(gloss_override)
46
+ if not glosses:
47
+ return result
48
+
49
+ asl = result.setdefault("asl", {})
50
+ intent = result.setdefault("intent_input", {})
51
+ asl["gloss_sequence"] = glosses
52
+ asl["top_prediction"] = " ".join(glosses)
53
+ asl["status"] = f"{asl.get('status', 'unknown')}_with_manual_override"
54
+ intent["detected_glosses"] = glosses
55
+ intent["communication_intent"] = "manual_gloss_override_for_demo"
56
+ intent.setdefault("diagnostics", {})["manual_gloss_override"] = True
57
+ intent["diagnostics"]["override_reason"] = "ASL classifier is missing or uncertain; user supplied visible glosses."
58
+ return result
59
+
60
+
61
+ def parse_gloss_override(gloss_override: str | None) -> list[str]:
62
+ text = (gloss_override or "").strip()
63
+ if not text:
64
+ return []
65
+ return [part.strip().upper() for part in text.replace(",", " ").split() if part.strip()]
66
 
67
 
68
  def resolve_video_path(video_file: Any | None) -> Path:
 
126
  def summarize_asl_result(result: dict[str, Any]) -> str:
127
  asl = result.get("asl", {})
128
  emotion = result.get("emotion", {})
129
+ glosses = result.get("intent_input", {}).get("detected_glosses", [])
130
+ gloss_line = " ".join(glosses) if glosses else "None"
131
+ override = result.get("intent_input", {}).get("diagnostics", {}).get("manual_gloss_override")
132
+ override_line = "\nOverride: manual glosses applied" if override else ""
133
  return (
134
  f"ASL status: {asl.get('status', 'unknown')}\n"
135
+ f"Detected words: {gloss_line}\n"
136
  f"Landmarks: {asl.get('landmarks_status', 'unknown')} via {asl.get('landmarks_detector', 'unknown')}\n"
137
  f"Emotion: {emotion.get('dominant_emotion', 'unknown')} "
138
  f"({float(emotion.get('intensity', 0.0) or 0.0):.2f})"
139
+ f"{override_line}"
140
  )
tests/test_asl_pipeline.py CHANGED
@@ -1,5 +1,5 @@
1
  from signspeak.asl.pipeline import build_intent_input
2
- from signspeak.pipeline import resolve_video_path, summarize_asl_result
3
 
4
 
5
  def test_build_intent_input_matches_llm_schema():
@@ -44,3 +44,21 @@ def test_resolve_video_path_accepts_gradio_dict_payload(tmp_path):
44
  resolved = resolve_video_path({"path": str(video_path)})
45
 
46
  assert resolved == video_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
5
  def test_build_intent_input_matches_llm_schema():
 
44
  resolved = resolve_video_path({"path": str(video_path)})
45
 
46
  assert resolved == video_path
47
+
48
+
49
+ def test_parse_gloss_override_normalizes_words():
50
+ assert parse_gloss_override("i love,you") == ["I", "LOVE", "YOU"]
51
+
52
+
53
+ def test_apply_gloss_override_marks_diagnostics():
54
+ result = {
55
+ "asl": {"status": "model_missing", "gloss_sequence": []},
56
+ "emotion": {"status": "emotion_error"},
57
+ "intent_input": {"detected_glosses": [], "diagnostics": {}},
58
+ }
59
+
60
+ updated = apply_gloss_override(result, "I LOVE YOU")
61
+
62
+ assert updated["intent_input"]["detected_glosses"] == ["I", "LOVE", "YOU"]
63
+ assert updated["intent_input"]["diagnostics"]["manual_gloss_override"] is True
64
+ assert updated["asl"]["top_prediction"] == "I LOVE YOU"
tests/test_llm_parsing.py CHANGED
@@ -1,4 +1,10 @@
1
- from signspeak.llm import extract_json_object, normalize_llm_output, safe_json_loads
 
 
 
 
 
 
2
 
3
 
4
  def test_extract_json_object_from_markdown_fence():
@@ -41,3 +47,29 @@ def test_safe_json_loads_falls_back_to_raw_text():
41
  assert parsed["raw_input"] == "not json"
42
  assert "warning" in parsed
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from signspeak.llm import (
2
+ deterministic_speech_from_intent,
3
+ enforce_intent_consistency,
4
+ extract_json_object,
5
+ normalize_llm_output,
6
+ safe_json_loads,
7
+ )
8
 
9
 
10
  def test_extract_json_object_from_markdown_fence():
 
47
  assert parsed["raw_input"] == "not json"
48
  assert "warning" in parsed
49
 
50
+
51
+ def test_deterministic_speech_maps_i_love_you():
52
+ result = deterministic_speech_from_intent(
53
+ {
54
+ "detected_glosses": ["I", "LOVE", "YOU"],
55
+ "detected_facial_expression": "neutral",
56
+ }
57
+ )
58
+
59
+ assert result["subtitle"] == "I love you."
60
+
61
+
62
+ def test_deterministic_speech_does_not_hallucinate_empty_glosses():
63
+ result = deterministic_speech_from_intent({"detected_glosses": []})
64
+
65
+ assert result["subtitle"] == "No ASL words were detected yet."
66
+
67
+
68
+ def test_enforce_intent_consistency_corrects_wrong_love_subtitle():
69
+ result = enforce_intent_consistency(
70
+ {"detected_glosses": ["I", "LOVE", "YOU"]},
71
+ {"subtitle": "I am happy to see you.", "voice_instruction": "Speak warmly."},
72
+ )
73
+
74
+ assert result["subtitle"] == "I love you."
75
+ assert "consistency_warning" in result