lilblueyes commited on
Commit
7d969c6
·
1 Parent(s): 5d87234

Add sliding-window ASL phrase prototype

Browse files
README.md CHANGED
@@ -17,9 +17,10 @@ MVP Gradio pour tester la pipeline ASL video/camera -> intent JSON -> llama.cpp
17
 
18
  ```text
19
  Video upload / camera capture
20
- -> ASL frame sampling
21
  -> MediaPipe landmarks, if installed
22
- -> TFLite ASL classifier, if data/models/asl/model.tflite is present
 
23
  -> DeepFace emotion aggregation, if installed
24
  -> llama.cpp subtitle + voice instruction
25
  -> Qwen3-TTS audio
@@ -85,6 +86,22 @@ 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
  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
 
17
 
18
  ```text
19
  Video upload / camera capture
20
+ -> ASL sequential frame sampling
21
  -> MediaPipe landmarks, if installed
22
+ -> TFLite ASL classifier over sliding temporal windows, if data/models/asl/model.tflite is present
23
+ -> gloss sequence aggregation
24
  -> DeepFace emotion aggregation, if installed
25
  -> llama.cpp subtitle + voice instruction
26
  -> Qwen3-TTS audio
 
86
  a full sentence or fingerspelling recognizer. Predictions below `ASL_CONFIDENCE_THRESHOLD`
87
  defaulting to `0.70` are reported as `low_confidence` and are not forwarded as detected glosses.
88
 
89
+ Uploaded videos use a phrase-prototype mode: frames are read in temporal order, landmarks are
90
+ extracted once, then the ASL model runs over sliding windows. Accepted window predictions are
91
+ collapsed into an ordered gloss sequence before llama.cpp rewrites them as natural speech. Tune it
92
+ with:
93
+
94
+ ```text
95
+ ASL_UPLOAD_TARGET_FPS=12
96
+ ASL_UPLOAD_MAX_FRAMES=240
97
+ ASL_SEQUENCE_WINDOW=30
98
+ ASL_SEQUENCE_STRIDE=15
99
+ ASL_CONFIDENCE_THRESHOLD=0.70
100
+ ```
101
+
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
signspeak/asl/asl_detector.py CHANGED
@@ -20,6 +20,8 @@ class ASLDetector:
20
  self.train_csv_path = self.model_dir / "train.csv"
21
  self.sign_map_path = self.model_dir / "sign_to_prediction_index_map.json"
22
  self.confidence_threshold = float(os.getenv("ASL_CONFIDENCE_THRESHOLD", "0.70"))
 
 
23
  self.labels = self._load_labels()
24
 
25
  def predict_from_frames(self, frames: list[np.ndarray]) -> dict[str, Any]:
@@ -75,6 +77,70 @@ class ASLDetector:
75
  )
76
  return base
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  def _load_interpreter(self) -> Any:
79
  try:
80
  from tensorflow.lite.python.interpreter import Interpreter
@@ -104,6 +170,73 @@ class ASLDetector:
104
  interpreter.invoke()
105
  return np.asarray(interpreter.get_tensor(output_details[0]["index"]))
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  def _signature_input_name(self, signature: dict[str, Any]) -> str:
108
  inputs = signature.get("inputs") or ["inputs"]
109
  return inputs[0]
 
20
  self.train_csv_path = self.model_dir / "train.csv"
21
  self.sign_map_path = self.model_dir / "sign_to_prediction_index_map.json"
22
  self.confidence_threshold = float(os.getenv("ASL_CONFIDENCE_THRESHOLD", "0.70"))
23
+ self.sequence_window = max(1, int(os.getenv("ASL_SEQUENCE_WINDOW", "30")))
24
+ self.sequence_stride = max(1, int(os.getenv("ASL_SEQUENCE_STRIDE", "15")))
25
  self.labels = self._load_labels()
26
 
27
  def predict_from_frames(self, frames: list[np.ndarray]) -> dict[str, Any]:
 
77
  )
78
  return base
79
 
80
+ def predict_sequence_from_frames(self, frames: list[np.ndarray]) -> dict[str, Any]:
81
+ detector = LandmarksDetector(missing_value=np.nan)
82
+ try:
83
+ landmark_result = detector.detect_sequence(frames)
84
+ finally:
85
+ detector.close()
86
+
87
+ keypoints = landmark_result.keypoints
88
+ base = {
89
+ "status": "model_missing",
90
+ "gloss_sequence": [],
91
+ "top_prediction": None,
92
+ "confidence": 0.0,
93
+ "confidence_threshold": self.confidence_threshold,
94
+ "top_predictions": [],
95
+ "segment_predictions": [],
96
+ "frames_used": len(frames),
97
+ "windows_used": 0,
98
+ "sequence_window": self.sequence_window,
99
+ "sequence_stride": self.sequence_stride,
100
+ "keypoints_shape": list(keypoints.shape),
101
+ "landmarks_status": landmark_result.status,
102
+ "landmarks_detector": landmark_result.detector,
103
+ "recognition_mode": "sliding_window_sequence",
104
+ }
105
+ if landmark_result.error:
106
+ base["landmarks_error"] = landmark_result.error
107
+
108
+ if not self.model_path.exists():
109
+ return base
110
+
111
+ try:
112
+ interpreter = self._load_interpreter()
113
+ segments = []
114
+ for window_index, start, end, window_keypoints in self._iter_keypoint_windows(keypoints):
115
+ prediction = self._predict_keypoint_window(interpreter, window_keypoints, window_index, start, end)
116
+ segments.append(prediction)
117
+
118
+ accepted_glosses = self._collapse_segments(segments)
119
+ best_segment = max(segments, key=lambda item: float(item.get("confidence", 0.0) or 0.0), default=None)
120
+ confidence = float(best_segment.get("confidence", 0.0) or 0.0) if best_segment else 0.0
121
+ top_prediction = best_segment.get("label") if best_segment else None
122
+
123
+ base.update(
124
+ {
125
+ "status": "ok" if accepted_glosses else "low_confidence",
126
+ "gloss_sequence": accepted_glosses,
127
+ "top_prediction": top_prediction,
128
+ "confidence": confidence,
129
+ "top_predictions": self._merge_top_predictions(segments),
130
+ "segment_predictions": segments,
131
+ "windows_used": len(segments),
132
+ }
133
+ )
134
+ return base
135
+ except Exception as exc:
136
+ base.update(
137
+ {
138
+ "status": "inference_error",
139
+ "error": f"{type(exc).__name__}: {exc}",
140
+ }
141
+ )
142
+ return base
143
+
144
  def _load_interpreter(self) -> Any:
145
  try:
146
  from tensorflow.lite.python.interpreter import Interpreter
 
170
  interpreter.invoke()
171
  return np.asarray(interpreter.get_tensor(output_details[0]["index"]))
172
 
173
+ def _iter_keypoint_windows(self, keypoints: np.ndarray):
174
+ frame_count = int(keypoints.shape[0])
175
+ if frame_count <= self.sequence_window:
176
+ yield 0, 0, frame_count, keypoints
177
+ return
178
+
179
+ window_index = 0
180
+ last_start = frame_count - self.sequence_window
181
+ for start in range(0, last_start + 1, self.sequence_stride):
182
+ end = start + self.sequence_window
183
+ yield window_index, start, end, keypoints[start:end]
184
+ window_index += 1
185
+
186
+ covered_end = (window_index - 1) * self.sequence_stride + self.sequence_window if window_index else 0
187
+ if covered_end < frame_count:
188
+ yield window_index, last_start, frame_count, keypoints[last_start:frame_count]
189
+
190
+ def _predict_keypoint_window(
191
+ self,
192
+ interpreter: Any,
193
+ keypoints: np.ndarray,
194
+ window_index: int,
195
+ start_frame: int,
196
+ end_frame: int,
197
+ ) -> dict[str, Any]:
198
+ output = self._predict(interpreter, keypoints.astype(np.float32))
199
+ probs = self._softmax_if_needed(np.asarray(output).reshape(-1))
200
+ top_idx = int(np.argmax(probs))
201
+ label = self._label_for_index(top_idx)
202
+ confidence = float(probs[top_idx])
203
+ return {
204
+ "window_index": window_index,
205
+ "start_frame": start_frame,
206
+ "end_frame": end_frame,
207
+ "label": label,
208
+ "confidence": confidence,
209
+ "accepted": bool(label) and confidence >= self.confidence_threshold,
210
+ "top_predictions": self._top_predictions(probs, limit=5),
211
+ }
212
+
213
+ def _collapse_segments(self, segments: list[dict[str, Any]]) -> list[str]:
214
+ glosses = []
215
+ for segment in segments:
216
+ if not segment.get("accepted"):
217
+ continue
218
+ label = segment.get("label")
219
+ if not label:
220
+ continue
221
+ if glosses and glosses[-1] == label:
222
+ continue
223
+ glosses.append(str(label))
224
+ return glosses
225
+
226
+ def _merge_top_predictions(self, segments: list[dict[str, Any]], limit: int = 5) -> list[dict[str, Any]]:
227
+ best_by_label: dict[str, float] = {}
228
+ for segment in segments:
229
+ for item in segment.get("top_predictions", []):
230
+ label = str(item.get("label") or "")
231
+ if not label:
232
+ continue
233
+ confidence = float(item.get("confidence", 0.0) or 0.0)
234
+ best_by_label[label] = max(best_by_label.get(label, 0.0), confidence)
235
+ return [
236
+ {"label": label, "confidence": confidence}
237
+ for label, confidence in sorted(best_by_label.items(), key=lambda item: item[1], reverse=True)[:limit]
238
+ ]
239
+
240
  def _signature_input_name(self, signature: dict[str, Any]) -> str:
241
  inputs = signature.get("inputs") or ["inputs"]
242
  return inputs[0]
signspeak/asl/pipeline.py CHANGED
@@ -1,5 +1,6 @@
1
  from __future__ import annotations
2
 
 
3
  from pathlib import Path
4
  from typing import Any
5
 
@@ -7,12 +8,16 @@ import numpy as np
7
 
8
  from .asl_detector import ASLDetector
9
  from .emotion_detector import detect_emotion_on_frames
10
- from .video_utils import sample_video_frames, sample_video_frames_for_emotion
11
 
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))
@@ -25,7 +30,7 @@ def process_asl_frames(
25
  source: str = "frames",
26
  ) -> dict[str, Any]:
27
  emotion_frames = emotion_frames if emotion_frames is not None else asl_frames[:12]
28
- asl = ASLDetector().predict_from_frames(asl_frames)
29
  emotion = detect_emotion_on_frames(emotion_frames)
30
  intent_input = build_intent_input(asl, emotion)
31
 
@@ -38,7 +43,12 @@ def process_asl_frames(
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"),
44
  "landmarks_detector": asl.get("landmarks_detector"),
@@ -56,8 +66,19 @@ def build_intent_input(asl: dict[str, Any], emotion: dict[str, Any]) -> dict[str
56
  threshold = float(asl.get("confidence_threshold", 0.0) or 0.0)
57
  top_prediction = asl.get("top_prediction")
58
  top_predictions = asl.get("top_predictions", [])
 
59
  return {
60
  "detected_glosses": glosses,
 
 
 
 
 
 
 
 
 
 
61
  "detected_facial_expression": dominant_emotion,
62
  "emotion_profile": {
63
  "dominant": dominant_emotion,
@@ -73,6 +94,11 @@ def build_intent_input(asl: dict[str, Any], emotion: dict[str, Any]) -> dict[str
73
  "confidence": confidence,
74
  "confidence_threshold": threshold,
75
  "top_predictions": top_predictions,
 
 
 
 
 
76
  "landmarks_status": asl.get("landmarks_status"),
77
  "landmarks_detector": asl.get("landmarks_detector"),
78
  },
@@ -81,6 +107,7 @@ def build_intent_input(asl: dict[str, Any], emotion: dict[str, Any]) -> dict[str
81
  "asl_status": asl.get("status", "unknown"),
82
  "emotion_status": emotion.get("status", "unknown"),
83
  "frames_used": int(asl.get("frames_used", 0) or 0),
 
84
  "top_prediction": top_prediction,
85
  "sign_confidence": confidence,
86
  "confidence_threshold": threshold,
 
1
  from __future__ import annotations
2
 
3
+ import os
4
  from pathlib import Path
5
  from typing import Any
6
 
 
8
 
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]:
15
  path = Path(video_path)
16
+ asl_frames = sample_video_frames_sequential(
17
+ path,
18
+ target_fps=float(os.getenv("ASL_UPLOAD_TARGET_FPS", "12")),
19
+ max_frames=int(os.getenv("ASL_UPLOAD_MAX_FRAMES", "240")),
20
+ )
21
  emotion_frames = sample_video_frames_for_emotion(path, target_frames=12)
22
 
23
  return process_asl_frames(asl_frames, emotion_frames, source=str(path))
 
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 = ASLDetector().predict_sequence_from_frames(asl_frames)
34
  emotion = detect_emotion_on_frames(emotion_frames)
35
  intent_input = build_intent_input(asl, emotion)
36
 
 
43
  "confidence": float(asl.get("confidence", 0.0) or 0.0),
44
  "confidence_threshold": float(asl.get("confidence_threshold", 0.0) or 0.0),
45
  "top_predictions": asl.get("top_predictions", []),
46
+ "segment_predictions": asl.get("segment_predictions", []),
47
  "frames_used": int(asl.get("frames_used", len(asl_frames)) or 0),
48
+ "windows_used": int(asl.get("windows_used", 0) or 0),
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"),
 
66
  threshold = float(asl.get("confidence_threshold", 0.0) or 0.0)
67
  top_prediction = asl.get("top_prediction")
68
  top_predictions = asl.get("top_predictions", [])
69
+ segments = asl.get("segment_predictions", [])
70
  return {
71
  "detected_glosses": glosses,
72
+ "candidate_gloss_sequence": [
73
+ {
74
+ "gloss": segment.get("label"),
75
+ "confidence": float(segment.get("confidence", 0.0) or 0.0),
76
+ "accepted": bool(segment.get("accepted")),
77
+ "start_frame": int(segment.get("start_frame", 0) or 0),
78
+ "end_frame": int(segment.get("end_frame", 0) or 0),
79
+ }
80
+ for segment in segments
81
+ ],
82
  "detected_facial_expression": dominant_emotion,
83
  "emotion_profile": {
84
  "dominant": dominant_emotion,
 
94
  "confidence": confidence,
95
  "confidence_threshold": threshold,
96
  "top_predictions": top_predictions,
97
+ "segment_predictions": segments,
98
+ "windows_used": int(asl.get("windows_used", 0) or 0),
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
  },
 
107
  "asl_status": asl.get("status", "unknown"),
108
  "emotion_status": emotion.get("status", "unknown"),
109
  "frames_used": int(asl.get("frames_used", 0) or 0),
110
+ "windows_used": int(asl.get("windows_used", 0) or 0),
111
  "top_prediction": top_prediction,
112
  "sign_confidence": confidence,
113
  "confidence_threshold": threshold,
signspeak/asl/video_utils.py CHANGED
@@ -8,6 +8,47 @@ def sample_video_frames(video_path: str | Path, target_frames: int = 30) -> list
8
  return _sample_video_frames(video_path, target_frames)
9
 
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  def sample_video_frames_for_emotion(video_path: str | Path, target_frames: int = 12) -> list:
12
  """Sample fewer RGB frames for temporal emotion aggregation."""
13
  return _sample_video_frames(video_path, target_frames)
 
8
  return _sample_video_frames(video_path, target_frames)
9
 
10
 
11
+ def sample_video_frames_sequential(
12
+ video_path: str | Path,
13
+ *,
14
+ target_fps: float = 12.0,
15
+ max_frames: int = 240,
16
+ ) -> list:
17
+ """Read RGB frames in temporal order for sliding-window sign recognition."""
18
+ cv2 = _load_cv2()
19
+ path = Path(video_path)
20
+ if max_frames <= 0:
21
+ return []
22
+ if not path.exists():
23
+ raise FileNotFoundError(f"Video not found: {path}")
24
+
25
+ cap = cv2.VideoCapture(str(path))
26
+ if not cap.isOpened():
27
+ raise ValueError(f"Could not open video: {path}")
28
+
29
+ try:
30
+ source_fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0)
31
+ stride = 1
32
+ if source_fps > 0 and target_fps > 0:
33
+ stride = max(1, round(source_fps / target_fps))
34
+
35
+ frames = []
36
+ frame_idx = 0
37
+ while len(frames) < max_frames:
38
+ ok, frame_bgr = cap.read()
39
+ if not ok or frame_bgr is None:
40
+ break
41
+ if frame_idx % stride == 0:
42
+ frames.append(cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB))
43
+ frame_idx += 1
44
+
45
+ if not frames:
46
+ raise ValueError(f"No readable frames found in: {path}")
47
+ return frames
48
+ finally:
49
+ cap.release()
50
+
51
+
52
  def sample_video_frames_for_emotion(video_path: str | Path, target_frames: int = 12) -> list:
53
  """Sample fewer RGB frames for temporal emotion aggregation."""
54
  return _sample_video_frames(video_path, target_frames)
signspeak/debug_video.py CHANGED
@@ -42,9 +42,10 @@ def create_debug_overlay_video(video_path: str | Path, result: dict[str, Any]) -
42
  top_prediction = asl.get("top_prediction") or "none"
43
  confidence = float(asl.get("confidence", 0.0) or 0.0)
44
  threshold = float(asl.get("confidence_threshold", 0.0) or 0.0)
 
45
  status_text = (
46
  f"ASL {asl.get('status', 'unknown')} | top {top_prediction} "
47
- f"{confidence:.2f}/{threshold:.2f} | EMOTION {emotion.get('status', 'unknown')}"
48
  )
49
 
50
  try:
 
42
  top_prediction = asl.get("top_prediction") or "none"
43
  confidence = float(asl.get("confidence", 0.0) or 0.0)
44
  threshold = float(asl.get("confidence_threshold", 0.0) or 0.0)
45
+ windows_used = int(asl.get("windows_used", 0) or 0)
46
  status_text = (
47
  f"ASL {asl.get('status', 'unknown')} | top {top_prediction} "
48
+ f"{confidence:.2f}/{threshold:.2f} | windows {windows_used} | EMOTION {emotion.get('status', 'unknown')}"
49
  )
50
 
51
  try:
signspeak/pipeline.py CHANGED
@@ -135,11 +135,14 @@ def summarize_asl_result(result: dict[str, Any]) -> str:
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"
 
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
+ windows_used = int(asl.get("windows_used", 0) or 0)
139
+ recognition_mode = asl.get("recognition_mode") or "single_clip"
140
  override = result.get("intent_input", {}).get("diagnostics", {}).get("manual_gloss_override")
141
  override_line = "\nOverride: manual glosses applied" if override else ""
142
  return (
143
  f"ASL status: {asl.get('status', 'unknown')}\n"
144
  f"Detected words: {gloss_line}\n"
145
+ f"Recognition: {recognition_mode}, windows {windows_used}\n"
146
  f"Top candidate: {top_prediction} ({confidence:.2f}, threshold {threshold:.2f})\n"
147
  f"Top candidates: {top_predictions}\n"
148
  f"Landmarks: {asl.get('landmarks_status', 'unknown')} via {asl.get('landmarks_detector', 'unknown')}\n"
tests/test_asl_detector.py CHANGED
@@ -85,3 +85,52 @@ def test_low_confidence_prediction_is_not_accepted(monkeypatch, tmp_path):
85
  assert result["status"] == "low_confidence"
86
  assert result["top_prediction"] == "where"
87
  assert result["gloss_sequence"] == []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  assert result["status"] == "low_confidence"
86
  assert result["top_prediction"] == "where"
87
  assert result["gloss_sequence"] == []
88
+
89
+
90
+ def test_sequence_prediction_collapses_accepted_windows(monkeypatch, tmp_path):
91
+ model_dir = tmp_path / "asl"
92
+ model_dir.mkdir()
93
+ (model_dir / "model.tflite").write_bytes(b"demo")
94
+ (model_dir / "sign_to_prediction_index_map.json").write_text(
95
+ json.dumps({"hello": 0, "water": 1}),
96
+ encoding="utf-8",
97
+ )
98
+
99
+ class SequenceLandmarkResult:
100
+ keypoints = np.zeros((60, 543, 3), dtype=np.float32)
101
+ status = "ok"
102
+ detector = "fake"
103
+ error = None
104
+
105
+ class SequenceLandmarksDetector:
106
+ def __init__(self, missing_value=0.0):
107
+ pass
108
+
109
+ def detect_sequence(self, frames):
110
+ return SequenceLandmarkResult()
111
+
112
+ def close(self):
113
+ pass
114
+
115
+ calls = iter(
116
+ [
117
+ np.asarray([[0.9, 0.1]], dtype=np.float32),
118
+ np.asarray([[0.86, 0.14]], dtype=np.float32),
119
+ np.asarray([[0.1, 0.9]], dtype=np.float32),
120
+ ]
121
+ )
122
+ monkeypatch.setattr(asl_detector_module, "LandmarksDetector", SequenceLandmarksDetector)
123
+ monkeypatch.setenv("ASL_CONFIDENCE_THRESHOLD", "0.70")
124
+ monkeypatch.setenv("ASL_SEQUENCE_WINDOW", "30")
125
+ monkeypatch.setenv("ASL_SEQUENCE_STRIDE", "15")
126
+ monkeypatch.setattr(ASLDetector, "_load_interpreter", lambda self: object())
127
+ monkeypatch.setattr(ASLDetector, "_predict", lambda self, interpreter, keypoints: next(calls))
128
+
129
+ result = ASLDetector(model_dir=model_dir).predict_sequence_from_frames(
130
+ [np.zeros((2, 2, 3), dtype=np.uint8)] * 60
131
+ )
132
+
133
+ assert result["status"] == "ok"
134
+ assert result["gloss_sequence"] == ["hello", "water"]
135
+ assert result["windows_used"] == 3
136
+ assert result["recognition_mode"] == "sliding_window_sequence"
tests/test_asl_pipeline.py CHANGED
@@ -59,7 +59,7 @@ def test_build_intent_input_exposes_rejected_top_prediction():
59
 
60
  def test_process_asl_frames_preserves_detector_diagnostics(monkeypatch):
61
  class FakeASLDetector:
62
- def predict_from_frames(self, frames):
63
  return {
64
  "status": "ok",
65
  "gloss_sequence": ["talk"],
@@ -67,7 +67,21 @@ def test_process_asl_frames_preserves_detector_diagnostics(monkeypatch):
67
  "confidence": 0.96,
68
  "confidence_threshold": 0.70,
69
  "top_predictions": [{"label": "talk", "confidence": 0.96}],
 
 
 
 
 
 
 
 
 
 
70
  "frames_used": len(frames),
 
 
 
 
71
  "keypoints_shape": [8, 543, 3],
72
  "landmarks_status": "ok",
73
  "landmarks_detector": "holistic",
@@ -91,6 +105,7 @@ def test_process_asl_frames_preserves_detector_diagnostics(monkeypatch):
91
  assert result["asl"]["top_predictions"] == [{"label": "talk", "confidence": 0.96}]
92
  assert result["intent_input"]["detected_glosses"] == ["talk"]
93
  assert result["intent_input"]["sign_detection"]["top_prediction"] == "talk"
 
94
 
95
 
96
  def test_summarize_asl_result_is_stable_for_missing_fields():
 
59
 
60
  def test_process_asl_frames_preserves_detector_diagnostics(monkeypatch):
61
  class FakeASLDetector:
62
+ def predict_sequence_from_frames(self, frames):
63
  return {
64
  "status": "ok",
65
  "gloss_sequence": ["talk"],
 
67
  "confidence": 0.96,
68
  "confidence_threshold": 0.70,
69
  "top_predictions": [{"label": "talk", "confidence": 0.96}],
70
+ "segment_predictions": [
71
+ {
72
+ "window_index": 0,
73
+ "start_frame": 0,
74
+ "end_frame": 8,
75
+ "label": "talk",
76
+ "confidence": 0.96,
77
+ "accepted": True,
78
+ }
79
+ ],
80
  "frames_used": len(frames),
81
+ "windows_used": 1,
82
+ "sequence_window": 30,
83
+ "sequence_stride": 15,
84
+ "recognition_mode": "sliding_window_sequence",
85
  "keypoints_shape": [8, 543, 3],
86
  "landmarks_status": "ok",
87
  "landmarks_detector": "holistic",
 
105
  assert result["asl"]["top_predictions"] == [{"label": "talk", "confidence": 0.96}]
106
  assert result["intent_input"]["detected_glosses"] == ["talk"]
107
  assert result["intent_input"]["sign_detection"]["top_prediction"] == "talk"
108
+ assert result["intent_input"]["candidate_gloss_sequence"][0]["gloss"] == "talk"
109
 
110
 
111
  def test_summarize_asl_result_is_stable_for_missing_fields():