elix3r commited on
Commit
b3fce51
·
verified ·
1 Parent(s): a55cc0c

Upload folder using huggingface_hub

Browse files
BUILD_GUIDE_CLIP_ANALYZER_FIX.md ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TalkingHeadBench — Clip Analyzer Fix Guide
2
+
3
+ > **Problem:** The clip analyzer produces identical default gradings for all clips
4
+ > because the primary extractor crashes on every clip, and the fallback extractor
5
+ > uses hardcoded/miscalibrated values.
6
+ >
7
+ > **Root causes (in order of severity):**
8
+ >
9
+ > 1. **MediaPipe 0.10.33 removed the legacy `mp.solutions` API.** The primary
10
+ > clip extractor (`node4_clip_extractor.py`) calls `mp.solutions.face_mesh.FaceMesh(...)`.
11
+ > This throws `AttributeError: module 'mediapipe' has no attribute 'solutions'`.
12
+ > Every clip falls to the fallback extractor. The new API is
13
+ > `mediapipe.tasks.python.vision.FaceLandmarker`.
14
+ >
15
+ > 2. **`insightface` is not installed** (missing `onnxruntime` dependency). Even
16
+ > if MediaPipe is fixed, the ArcFace embedding block in the primary extractor will
17
+ > fail the `import insightface` check and produce `embeddings = []`, which means
18
+ > `face_embedding_variance = 1.0` and `identity_cosine_drift = 1.0` (worst case
19
+ > defaults).
20
+ >
21
+ > 3. **Fallback blur score calibration is 120× off.** The primary extractor divides
22
+ > by `_BLUR_CALIBRATION_CEILING = 0.12`. The fallback divides by `0.001`. This
23
+ > means the fallback produces blur scores ~120× smaller than the primary would
24
+ > for the same frame, making every clip look excessively blurry.
25
+ >
26
+ > 4. **Fallback extractor hardcodes non-diagnostic values:**
27
+ > - `lip_sync_confidence = 0.5` (not measured from actual lip movement)
28
+ > - `phoneme_sequence = []` (no phoneme analysis at all)
29
+ > - `phoneme_coverage_new = 0.0` (hardcoded)
30
+ > - `optical_flow_magnitude = 1.0` (hardcoded)
31
+ > - `occlusion_frames = 0` (hardcoded)
32
+ > - `blink_count = len(frames) // 60` (crude frame count estimate)
33
+ >
34
+ > 5. **`artifact_ingest.py` also uses the broken legacy API** in
35
+ > `_estimate_face_occupancy_ratio()`, which calls `mp.solutions.face_detection`.
36
+ > This silently falls back to returning `0.35` for every image.
37
+
38
+ ---
39
+
40
+ ## Task 1 — Migrate the primary clip extractor from legacy MediaPipe to the Tasks API
41
+
42
+ **File:** `src/envs/subenv2/node4_clip_extractor.py`
43
+
44
+ **Prompt:** Open `src/envs/subenv2/node4_clip_extractor.py`. The file imports
45
+ `mediapipe as mp` and uses `mp.solutions.face_mesh.FaceMesh(...)` to create a
46
+ face mesh detector (around line 280–286). MediaPipe 0.10.33 removed the legacy
47
+ `mp.solutions` namespace entirely. The `mp` module now only exposes `Image`,
48
+ `ImageFormat`, `tasks`, and `__version__`.
49
+
50
+ The replacement API is `mediapipe.tasks.python.vision.FaceLandmarker`. It
51
+ requires a model asset file (`.task` bundle) and uses a different calling
52
+ convention:
53
+
54
+ - Instead of `FaceMesh(static_image_mode=True, ...)`, you create a
55
+ `FaceLandmarkerOptions` with `running_mode=VisionRunningMode.IMAGE` and
56
+ `num_faces=1`.
57
+ - Instead of `face_mesh.process(rgb_frame)`, you wrap the frame in a
58
+ `mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb_array)` and call
59
+ `landmarker.detect(mp_image)`.
60
+ - The result object has `face_landmarks` (list of lists of
61
+ `NormalizedLandmark`) instead of `multi_face_landmarks`.
62
+ - Each `NormalizedLandmark` still has `.x`, `.y`, `.z` attributes.
63
+
64
+ The model file can be downloaded from Google's MediaPipe model catalog. The
65
+ agent should either download it at startup or bundle it. A common approach is
66
+ to auto-download to a cache directory on first use.
67
+
68
+ Rewrite the FaceMesh section of `extract_clip_signals()` to use the Tasks API.
69
+ The landmark index constants (`_LEFT_EYE_IDX`, `_RIGHT_EYE_IDX`,
70
+ `_UPPER_LIP_IDX`, `_LOWER_LIP_IDX`) reference the 468-point FaceMesh topology
71
+ which is preserved in the Tasks API, so those constants stay the same.
72
+
73
+ Make sure to handle the case where the model file is not available — fall back
74
+ gracefully with a warning rather than crashing the entire extraction.
75
+
76
+ Also update the `import mediapipe as mp` at the top of the file. The new
77
+ imports will be from `mediapipe.tasks.python.vision` and `mediapipe.tasks.python`.
78
+
79
+ ---
80
+
81
+ ## Task 2 — Fix or replace ArcFace embedding extraction
82
+
83
+ **File:** `src/envs/subenv2/node4_clip_extractor.py`
84
+
85
+ **Prompt:** Open `src/envs/subenv2/node4_clip_extractor.py` and find the ArcFace
86
+ embeddings section (around line 329–342). It does
87
+ `import insightface` inside a try/except block. When insightface is missing
88
+ (which it currently is — `onnxruntime` is not installed), `embeddings`
89
+ stays empty, and the identity signals default to worst-case values
90
+ (`face_embedding_variance = 1.0`, `identity_cosine_drift = 1.0`).
91
+
92
+ There are two options:
93
+
94
+ **Option A (recommended for deployment):** Replace the insightface dependency
95
+ with a lighter alternative. Since we only need face embeddings for cosine
96
+ distance between frames, we can use the MediaPipe FaceLandmarker itself — the
97
+ normalized landmark coordinates form a sufficient proxy embedding for
98
+ frame-to-frame identity comparison. Compute a flattened landmark vector per
99
+ frame (468 landmarks × 3 coords = 1404-dimensional vector), then compute
100
+ cosine drift and variance on those. This eliminates the insightface dependency
101
+ entirely.
102
+
103
+ **Option B (full fidelity):** Add `insightface` and `onnxruntime` to
104
+ `requirements.txt` as core dependencies instead of optional. Then document that
105
+ the Hugging Face Space Dockerfile must install them.
106
+
107
+ Choose Option A for now since it avoids adding heavy dependencies and still
108
+ produces meaningful identity signals. If Option A is chosen, also update the
109
+ identity signal computation to use landmark-based embeddings instead of
110
+ ArcFace. The cosine distance math stays the same.
111
+
112
+ ---
113
+
114
+ ## Task 3 — Fix the fallback blur score calibration
115
+
116
+ **File:** `server/artifact_ingest.py`
117
+
118
+ **Prompt:** Open `server/artifact_ingest.py` and find the
119
+ `_extract_clip_signals_fallback` function (starting around line 228). Look at
120
+ line 259 where the blur score is calculated:
121
+
122
+ The fallback uses a ceiling of `0.001` for normalizing the per-pixel Laplacian
123
+ variance. The primary extractor in `node4_clip_extractor.py` uses
124
+ `_BLUR_CALIBRATION_CEILING = 0.12`. This 120× difference means a clip that
125
+ would score `blur_score = 0.70` (good) in the primary extractor scores
126
+ `blur_score = 0.006` (terrible) in the fallback.
127
+
128
+ Fix this by aligning the fallback's blur normalization ceiling with the primary
129
+ extractor's constant. Import `_BLUR_CALIBRATION_CEILING` from the primary
130
+ extractor module, or define the same constant locally. The formula should be:
131
+ `clamp(lap_var_per_pixel / CEILING, 0.0, 1.0)` where `CEILING = 0.12`, not
132
+ `0.001`.
133
+
134
+ ---
135
+
136
+ ## Task 4 — Improve the fallback extractor's lip sync proxy
137
+
138
+ **File:** `server/artifact_ingest.py`
139
+
140
+ **Prompt:** In the same `_extract_clip_signals_fallback` function, the lip sync
141
+ confidence is hardcoded to `0.5` (line 281). This gives every fallback clip an
142
+ identical, meaningless score.
143
+
144
+ The fallback extractor already reads frames and computes per-pixel differences.
145
+ It can compute a simple lip sync proxy using the same approach as the primary
146
+ extractor: look at the standard deviation of brightness in the lower-third of
147
+ the face region across frames. Higher variance in the mouth area suggests
148
+ active lip movement. This isn't as good as the MediaPipe-based lip opening
149
+ measurement, but it's far better than a hardcoded constant.
150
+
151
+ Alternatively, since MediaPipe FaceLandmarker is being fixed in Task 1, consider
152
+ whether the fallback should also use the Tasks API for basic landmark detection
153
+ (making it a "light" version of the primary extractor rather than pure
154
+ OpenCV). If so, the fallback would only fall back to pure OpenCV when MediaPipe
155
+ itself is unavailable.
156
+
157
+ The key requirement is: different clips should produce different lip sync
158
+ values. The fallback currently produces `0.5` for every clip, which defeats
159
+ the purpose of per-clip analysis.
160
+
161
+ ---
162
+
163
+ ## Task 5 — Add optical flow computation to the fallback
164
+
165
+ **File:** `server/artifact_ingest.py`
166
+
167
+ **Prompt:** In `_extract_clip_signals_fallback`, `optical_flow_magnitude` is
168
+ hardcoded to `1.0` (line 279). The fallback already reads all frames into
169
+ memory, so computing Farneback optical flow is straightforward — it's a pure
170
+ OpenCV operation with no external dependencies.
171
+
172
+ The primary extractor computes face-region vs background-region optical flow
173
+ ratio (lines 399–432 in `node4_clip_extractor.py`). The fallback can use a
174
+ simplified version: compute optical flow on the full frame and use the
175
+ central 40% as the face region proxy (exactly as the primary extractor's
176
+ own fallback face mask does when landmarks are unavailable).
177
+
178
+ This gives each clip a distinct motion signature instead of a uniform `1.0`.
179
+
180
+ ---
181
+
182
+ ## Task 6 — Add blink detection to the fallback using the eye region
183
+
184
+ **File:** `server/artifact_ingest.py`
185
+
186
+ **Prompt:** In `_extract_clip_signals_fallback`, `blink_count` is estimated as
187
+ `max(0, len(frames) // 60)` (line 280) — a crude frame-count-based guess.
188
+ A 120-frame clip gets `blink_count = 2` regardless of whether anyone blinked.
189
+
190
+ A simple improvement: compute eye-region brightness variance across frames.
191
+ Blinks cause momentary brightness drops in the eye region. The fallback can
192
+ crop the upper-quarter of the central face region and track brightness drops
193
+ below a threshold across consecutive frames. Count the number of brightness
194
+ drops as blink count.
195
+
196
+ This is a rough proxy but produces clip-specific values rather than a
197
+ frame-count formula.
198
+
199
+ ---
200
+
201
+ ## Task 7 — Fix `_estimate_face_occupancy_ratio` legacy API usage
202
+
203
+ **File:** `server/artifact_ingest.py`
204
+
205
+ **Prompt:** Open `server/artifact_ingest.py` and find `_estimate_face_occupancy_ratio`
206
+ (around line 89). It uses `mp.solutions.face_detection.FaceDetection(...)` which
207
+ is the same broken legacy MediaPipe API. This silently catches the exception
208
+ and returns the hardcoded fallback of `0.35` for every image.
209
+
210
+ Migrate this to the MediaPipe Tasks API. The replacement is
211
+ `mediapipe.tasks.python.vision.FaceDetector`. The calling convention is
212
+ similar to the FaceLandmarker migration in Task 1: create options, create a
213
+ detector, wrap the image in `mp.Image`, call `detect()`.
214
+
215
+ If the model file is unavailable, fall back to a reasonable OpenCV-based
216
+ face detection (e.g., Haar cascades or DNN face detector), or at minimum
217
+ compute a face occupancy estimate from skin-color segmentation.
218
+
219
+ ---
220
+
221
+ ## Task 8 — Add occlusion frame detection to the fallback
222
+
223
+ **File:** `server/artifact_ingest.py`
224
+
225
+ **Prompt:** In `_extract_clip_signals_fallback`, `occlusion_frames` is hardcoded
226
+ to `0` (line 286). In the primary extractor, a frame is marked as occluded
227
+ when MediaPipe FaceMesh fails to detect a face. The fallback can approximate
228
+ this with OpenCV's face detection: run a Haar cascade face detector on each
229
+ frame and count frames where no face is found. This gives a real occlusion
230
+ signal instead of always-zero.
231
+
232
+ Alternatively, use a simpler heuristic: if the central region's skin-tone
233
+ pixel ratio drops below a threshold in a frame (compared to the median across
234
+ all frames), count it as potentially occluded.
235
+
236
+ ---
237
+
238
+ ## Task 9 — Update requirements.txt
239
+
240
+ **File:** `requirements.txt`
241
+
242
+ **Prompt:** Open `requirements.txt`. Currently `insightface` and `transformers`
243
+ are listed as optional comments. Based on the decisions made in Task 2:
244
+
245
+ If Option A was chosen (landmark-based embeddings), add a comment explaining
246
+ that insightface is no longer needed and landmark embeddings are used instead.
247
+
248
+ If Option B was chosen (install insightface), move `insightface` and
249
+ `onnxruntime` from optional comments to required dependencies.
250
+
251
+ Either way, verify that `mediapipe>=0.10.33` is pinned correctly since the
252
+ Tasks API is required.
253
+
254
+ Also add any model asset download instructions if the MediaPipe Tasks API
255
+ requires a `.task` model file.
256
+
257
+ ---
258
+
259
+ ## Task 10 — Ensure the primary extractor gracefully degrades
260
+
261
+ **File:** `src/envs/subenv2/node4_clip_extractor.py`
262
+
263
+ **Prompt:** After the MediaPipe migration in Task 1, the primary extractor
264
+ should gracefully handle missing model files or initialization failures. If
265
+ the FaceLandmarker model file is not found, the function should log a warning
266
+ and fall through to the fallback extractor in `artifact_ingest.py` rather
267
+ than crashing with an unhandled exception.
268
+
269
+ Verify the error handling path: `_extract_clip_signal_observations` in
270
+ `artifact_ingest.py` wraps `extract_clip_signals()` in a broad `except
271
+ Exception` that triggers the fallback. This safety net is correct, but the
272
+ primary extractor should also produce a clear log message explaining WHY it
273
+ failed (e.g., "FaceLandmarker model file not found at {path}") so debugging
274
+ is straightforward.
275
+
276
+ ---
277
+
278
+ ## Task 11 — Verify calibration alignment between primary and fallback
279
+
280
+ **Prompt:** After fixing both extractors, run a quick calibration test. Take
281
+ one clip and extract signals using both the primary extractor (with the Tasks
282
+ API) and the fallback extractor (with corrected constants). Compare the outputs
283
+ side by side:
284
+
285
+ - `blur_score` should be in the same ballpark (±0.15) between both extractors
286
+ - `identity_cosine_drift` should be directionally consistent
287
+ - `frame_difference_mean` should be identical (both use the same OpenCV code)
288
+ - `lip_sync_confidence` will differ (primary uses landmark-based, fallback uses
289
+ proxy) but both should be non-zero and clip-specific
290
+
291
+ If the blur scores still diverge wildly, the calibration ceiling needs further
292
+ tuning. The ceiling `0.12` was derived from a specific test set and may need
293
+ adjustment.
294
+
295
+ ---
296
+
297
+ ## Task 12 — Verify with the same 12-clip dataset
298
+
299
+ **Prompt:** After all fixes, re-run the same 12-clip ingestion that produced
300
+ the original "all clips needs-fix" report. Verify:
301
+
302
+ 1. The primary extractor does NOT crash (no fallback used for any clip, unless
303
+ the model file is genuinely missing)
304
+ 2. `clip_extractor_fallback_count` is `0` in the ingestion metadata
305
+ 3. Each clip has a distinct `blur_score` (not all the same)
306
+ 4. Each clip has a distinct `lip_sync_confidence` (not all 0.5)
307
+ 5. Each clip has a distinct `identity_cosine_drift` (not all the same)
308
+ 6. The LLM report differentiates between clips — some get "good" or
309
+ "acceptable" verdicts, not uniform "needs-fix"
310
+ 7. The quality distribution in the report reflects actual clip quality
311
+
312
+ ---
313
+
314
+ ## Summary of changes by file
315
+
316
+ | File | Changes |
317
+ |------|---------|
318
+ | `src/envs/subenv2/node4_clip_extractor.py` | Migrate FaceMesh from legacy `mp.solutions` to Tasks API. Replace or fix ArcFace embedding extraction. Add graceful degradation. |
319
+ | `server/artifact_ingest.py` | Fix blur calibration (0.001 → 0.12). Add real lip sync proxy. Add optical flow computation. Add blink detection. Add occlusion detection. Fix `_estimate_face_occupancy_ratio` legacy API. |
320
+ | `requirements.txt` | Update dependency notes. Pin mediapipe version. Add/remove insightface based on Task 2 decision. |
321
+
322
+ ## Files NOT to modify
323
+
324
+ | File | Reason |
325
+ |------|--------|
326
+ | `src/envs/subenv2/node5_disposition.py` | Disposition logic is fine — it was getting bad inputs |
327
+ | `src/envs/subenv2/node6_grader.py` | Grading logic is fine — tested and deterministic |
328
+ | `server/llm_adapter.py` | Signal digest and prompt structure already fixed in previous guide |
329
+ | `src/schemas/subenv2.py` | Schema is correct — fields match expected signals |
requirements.txt CHANGED
@@ -1,5 +1,6 @@
1
  # Install core: pip install -r requirements.txt
2
- # Install full extraction stack: pip install insightface transformers
 
3
 
4
  # Core (required)
5
  numpy
@@ -8,9 +9,9 @@ pydantic>=2.0
8
  scipy
9
  safetensors
10
  opencv-python
11
- mediapipe
12
  pytest
13
 
14
- # Optional: full-fidelity signal extraction
15
- # pip install insightface # ArcFace face embeddings (Sub-env 2)
16
  # pip install transformers # Tokenizer reconstruction (Sub-env 3)
 
1
  # Install core: pip install -r requirements.txt
2
+ # MediaPipe Tasks model assets are auto-downloaded on first use and can be
3
+ # overridden with THB_FACE_LANDMARKER_MODEL / THB_FACE_DETECTOR_MODEL.
4
 
5
  # Core (required)
6
  numpy
 
9
  scipy
10
  safetensors
11
  opencv-python
12
+ mediapipe>=0.10.33
13
  pytest
14
 
15
+ # Optional: extended tooling
16
+ # Sub-env 2 identity drift now uses landmark embeddings (no insightface needed).
17
  # pip install transformers # Tokenizer reconstruction (Sub-env 3)
server/artifact_ingest.py CHANGED
@@ -14,15 +14,21 @@ import os
14
  import shutil
15
  import tempfile
16
  import time
 
 
17
  from pathlib import Path
18
  from threading import Lock
19
  from typing import Any, Optional
20
  from uuid import uuid4
21
 
22
  import cv2
 
23
  from fastapi import UploadFile
24
 
25
- from src.envs.subenv2.node4_clip_extractor import extract_clip_signals
 
 
 
26
  from src.envs.subenv3.node7_weight_extractor import extract_weight_signals
27
  from src.schemas.subenv1 import ImageDiagnosticsObservation
28
  from src.schemas.subenv2 import ClipSignalObservation
@@ -65,11 +71,27 @@ _DEFAULT_PARAM_CONFIG: dict[str, float] = {
65
  "eta": 0.10,
66
  }
67
 
 
 
 
 
 
 
 
 
 
68
 
69
  def _clamp(value: float, low: float, high: float) -> float:
70
  return max(low, min(high, value))
71
 
72
 
 
 
 
 
 
 
 
73
  def _detect_conflicting_descriptors(prompt: str) -> list[str]:
74
  text = prompt.lower()
75
  pairs = [
@@ -86,22 +108,131 @@ def _detect_conflicting_descriptors(prompt: str) -> list[str]:
86
  return conflicts
87
 
88
 
89
- def _estimate_face_occupancy_ratio(image_bgr) -> float:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  try:
91
- import mediapipe as mp
 
 
 
 
 
92
 
93
- with mp.solutions.face_detection.FaceDetection(
94
- model_selection=1,
 
95
  min_detection_confidence=0.5,
96
- ) as detector:
97
- rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
98
- result = detector.process(rgb)
 
 
99
 
100
- if result.detections:
101
- bbox = result.detections[0].location_data.relative_bounding_box
102
- return float(_clamp(float(bbox.width * bbox.height), 0.0, 1.0))
103
- except Exception:
104
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
  return 0.35
107
 
@@ -250,17 +381,67 @@ def _extract_clip_signals_fallback(
250
  blur_scores: list[float] = []
251
  exposure_scores: list[float] = []
252
  diffs: list[float] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
 
254
  for idx, frame in enumerate(frames):
255
  gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
 
256
 
257
- lap_var = float(cv2.Laplacian(gray, cv2.CV_64F).var())
258
- pixel_count = max(1, gray.shape[0] * gray.shape[1])
259
- blur_scores.append(_clamp((lap_var / pixel_count) / 0.001, 0.0, 1.0))
260
 
261
- brightness = float(gray.mean() / 255.0)
 
 
 
 
262
  exposure_scores.append(_clamp(1.0 - abs(brightness - 0.5) * 2.0, 0.0, 1.0))
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  if idx > 0:
265
  prev = frames[idx - 1].astype("float32")
266
  cur = frame.astype("float32")
@@ -270,20 +451,77 @@ def _extract_clip_signals_fallback(
270
  identity_drift_proxy = _clamp(frame_difference_mean / 45.0, 0.0, 1.0)
271
  landmark_jitter_proxy = _clamp(frame_difference_mean / 500.0, 0.0, 1.0)
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  return ClipSignalObservation(
274
  clip_id=clip_path.stem,
275
  face_embedding_variance=round(identity_drift_proxy, 4),
276
  landmark_stability_score=round(landmark_jitter_proxy, 4),
277
  identity_cosine_drift=round(identity_drift_proxy, 4),
278
  frame_difference_mean=round(frame_difference_mean, 4),
279
- optical_flow_magnitude=1.0,
280
- blink_count=max(0, len(frames) // 60),
281
- lip_sync_confidence=0.5,
282
  phoneme_sequence=[],
283
  phoneme_coverage_new=0.0,
284
  blur_score=round(float(sum(blur_scores) / max(1, len(blur_scores))), 4),
285
  exposure_score=round(float(sum(exposure_scores) / max(1, len(exposure_scores))), 4),
286
- occlusion_frames=0,
287
  clips_audited_so_far=int(dataset_context.get("clips_audited_so_far", 0)),
288
  current_phoneme_coverage=dataset_context.get("current_phoneme_coverage", {}),
289
  current_pose_distribution=dataset_context.get("current_pose_distribution", {}),
 
14
  import shutil
15
  import tempfile
16
  import time
17
+ import urllib.error
18
+ import urllib.request
19
  from pathlib import Path
20
  from threading import Lock
21
  from typing import Any, Optional
22
  from uuid import uuid4
23
 
24
  import cv2
25
+ import numpy as np
26
  from fastapi import UploadFile
27
 
28
+ from src.envs.subenv2.node4_clip_extractor import (
29
+ _BLUR_CALIBRATION_CEILING,
30
+ extract_clip_signals,
31
+ )
32
  from src.envs.subenv3.node7_weight_extractor import extract_weight_signals
33
  from src.schemas.subenv1 import ImageDiagnosticsObservation
34
  from src.schemas.subenv2 import ClipSignalObservation
 
71
  "eta": 0.10,
72
  }
73
 
74
+ _FACE_DETECTOR_URL = (
75
+ "https://storage.googleapis.com/mediapipe-models/face_detector/"
76
+ "blaze_face_short_range/float16/latest/blaze_face_short_range.tflite"
77
+ )
78
+ _DEFAULT_FACE_DETECTOR_MODEL_CANDIDATES: tuple[Path, ...] = (
79
+ Path(__file__).resolve().parents[1] / "data" / "models" / "face_detector.tflite",
80
+ Path.home() / ".cache" / "talkingheadbench" / "models" / "face_detector.tflite",
81
+ )
82
+
83
 
84
  def _clamp(value: float, low: float, high: float) -> float:
85
  return max(low, min(high, value))
86
 
87
 
88
+ def _env_truthy(name: str, *, default: bool) -> bool:
89
+ raw = os.getenv(name)
90
+ if raw is None:
91
+ return default
92
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
93
+
94
+
95
  def _detect_conflicting_descriptors(prompt: str) -> list[str]:
96
  text = prompt.lower()
97
  pairs = [
 
108
  return conflicts
109
 
110
 
111
+ def _candidate_face_detector_model_paths() -> list[Path]:
112
+ env_path = os.getenv("THB_FACE_DETECTOR_MODEL", "").strip()
113
+ candidates: list[Path] = []
114
+ if env_path:
115
+ candidates.append(Path(env_path).expanduser())
116
+ candidates.extend(_DEFAULT_FACE_DETECTOR_MODEL_CANDIDATES)
117
+
118
+ deduped: list[Path] = []
119
+ seen: set[str] = set()
120
+ for path in candidates:
121
+ key = str(path)
122
+ if key in seen:
123
+ continue
124
+ seen.add(key)
125
+ deduped.append(path)
126
+ return deduped
127
+
128
+
129
+ def _download_face_detector_model(dest: Path) -> Path:
130
+ dest.parent.mkdir(parents=True, exist_ok=True)
131
+ urllib.request.urlretrieve(_FACE_DETECTOR_URL, dest)
132
+ return dest
133
+
134
+
135
+ def _resolve_face_detector_model_path() -> Path | None:
136
+ for candidate in _candidate_face_detector_model_paths():
137
+ if candidate.exists() and candidate.is_file():
138
+ return candidate
139
+
140
+ if not _env_truthy("THB_AUTO_DOWNLOAD_FACE_DETECTOR", default=True):
141
+ return None
142
+
143
+ cache_target = _DEFAULT_FACE_DETECTOR_MODEL_CANDIDATES[-1]
144
+ try:
145
+ return _download_face_detector_model(cache_target)
146
+ except (OSError, urllib.error.URLError, ValueError) as exc:
147
+ log.warning(
148
+ "Unable to auto-download FaceDetector model to %s: %s",
149
+ cache_target,
150
+ exc,
151
+ )
152
+ return None
153
+
154
+
155
+ def _create_tasks_face_detector() -> Any | None:
156
+ model_path = _resolve_face_detector_model_path()
157
+ if model_path is None:
158
+ return None
159
+
160
  try:
161
+ from mediapipe.tasks.python import BaseOptions
162
+ from mediapipe.tasks.python.vision import (
163
+ FaceDetector,
164
+ FaceDetectorOptions,
165
+ RunningMode,
166
+ )
167
 
168
+ options = FaceDetectorOptions(
169
+ base_options=BaseOptions(model_asset_path=str(model_path)),
170
+ running_mode=RunningMode.IMAGE,
171
  min_detection_confidence=0.5,
172
+ )
173
+ return FaceDetector.create_from_options(options)
174
+ except Exception as exc: # noqa: BLE001
175
+ log.warning("Failed to initialize Tasks FaceDetector from %s: %s", model_path, exc)
176
+ return None
177
 
178
+
179
+ def _get_haar_face_cascade() -> cv2.CascadeClassifier | None:
180
+ cascade_path = Path(cv2.data.haarcascades) / "haarcascade_frontalface_default.xml"
181
+ if not cascade_path.exists():
182
+ return None
183
+
184
+ cascade = cv2.CascadeClassifier(str(cascade_path))
185
+ if cascade.empty():
186
+ return None
187
+ return cascade
188
+
189
+
190
+ def _estimate_face_occupancy_ratio(image_bgr) -> float:
191
+ height, width = image_bgr.shape[:2]
192
+ detector = _create_tasks_face_detector()
193
+ if detector is not None:
194
+ try:
195
+ import mediapipe as mp
196
+
197
+ rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
198
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb.copy())
199
+ result = detector.detect(mp_image)
200
+
201
+ detections = getattr(result, "detections", [])
202
+ areas: list[float] = []
203
+ for detection in detections:
204
+ bbox = getattr(detection, "bounding_box", None)
205
+ if bbox is None:
206
+ continue
207
+ box_width = float(getattr(bbox, "width", 0.0))
208
+ box_height = float(getattr(bbox, "height", 0.0))
209
+ if box_width <= 0.0 or box_height <= 0.0:
210
+ continue
211
+ areas.append((box_width * box_height) / float(max(width * height, 1)))
212
+
213
+ if areas:
214
+ return float(_clamp(max(areas), 0.0, 1.0))
215
+ except Exception as exc: # noqa: BLE001
216
+ log.warning("Tasks face occupancy detection failed; falling back to Haar: %s", exc)
217
+ finally:
218
+ if hasattr(detector, "close"):
219
+ detector.close()
220
+
221
+ cascade = _get_haar_face_cascade()
222
+ if cascade is not None:
223
+ gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
224
+ faces = cascade.detectMultiScale(
225
+ gray,
226
+ scaleFactor=1.1,
227
+ minNeighbors=4,
228
+ minSize=(max(24, width // 10), max(24, height // 10)),
229
+ )
230
+ if len(faces) > 0:
231
+ areas = [
232
+ (float(face_w) * float(face_h)) / float(max(width * height, 1))
233
+ for (_, _, face_w, face_h) in faces
234
+ ]
235
+ return float(_clamp(max(areas), 0.0, 1.0))
236
 
237
  return 0.35
238
 
 
381
  blur_scores: list[float] = []
382
  exposure_scores: list[float] = []
383
  diffs: list[float] = []
384
+ gray_frames: list[Any] = []
385
+ mouth_brightness_series: list[float] = []
386
+ eye_brightness_series: list[float] = []
387
+
388
+ height, width = frames[0].shape[:2]
389
+ x1 = max(0, int(width * 0.30))
390
+ x2 = min(width, int(width * 0.70))
391
+ y1 = max(0, int(height * 0.20))
392
+ y2 = min(height, int(height * 0.80))
393
+
394
+ mouth_y1 = y1 + int((y2 - y1) * 0.66)
395
+ eye_y1 = y1
396
+ eye_y2 = y1 + max(1, int((y2 - y1) * 0.25))
397
+
398
+ cascade = _get_haar_face_cascade()
399
+ occlusion_frames = 0
400
 
401
  for idx, frame in enumerate(frames):
402
  gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
403
+ gray_frames.append(gray)
404
 
405
+ face_roi = gray[y1:y2, x1:x2]
406
+ if face_roi.size == 0:
407
+ face_roi = gray
408
 
409
+ lap_var = float(cv2.Laplacian(face_roi, cv2.CV_64F).var())
410
+ pixel_count = max(1, face_roi.shape[0] * face_roi.shape[1])
411
+ blur_scores.append(_clamp((lap_var / pixel_count) / _BLUR_CALIBRATION_CEILING, 0.0, 1.0))
412
+
413
+ brightness = float(face_roi.mean() / 255.0)
414
  exposure_scores.append(_clamp(1.0 - abs(brightness - 0.5) * 2.0, 0.0, 1.0))
415
 
416
+ mouth_roi = gray[mouth_y1:y2, x1:x2]
417
+ eye_roi = gray[eye_y1:eye_y2, x1:x2]
418
+ mouth_brightness_series.append(float(mouth_roi.mean()) if mouth_roi.size else float(gray.mean()))
419
+ eye_brightness_series.append(float(eye_roi.mean()) if eye_roi.size else float(gray.mean()))
420
+
421
+ if cascade is not None:
422
+ faces = cascade.detectMultiScale(
423
+ gray,
424
+ scaleFactor=1.1,
425
+ minNeighbors=4,
426
+ minSize=(max(24, width // 8), max(24, height // 8)),
427
+ )
428
+ if len(faces) == 0:
429
+ occlusion_frames += 1
430
+ else:
431
+ hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
432
+ center_hsv = hsv[y1:y2, x1:x2]
433
+ if center_hsv.size:
434
+ skin_like = (
435
+ (center_hsv[..., 0] >= 0)
436
+ & (center_hsv[..., 0] <= 25)
437
+ & (center_hsv[..., 1] >= 30)
438
+ & (center_hsv[..., 1] <= 200)
439
+ & (center_hsv[..., 2] >= 60)
440
+ )
441
+ skin_ratio = float(skin_like.mean())
442
+ if skin_ratio < 0.06:
443
+ occlusion_frames += 1
444
+
445
  if idx > 0:
446
  prev = frames[idx - 1].astype("float32")
447
  cur = frame.astype("float32")
 
451
  identity_drift_proxy = _clamp(frame_difference_mean / 45.0, 0.0, 1.0)
452
  landmark_jitter_proxy = _clamp(frame_difference_mean / 500.0, 0.0, 1.0)
453
 
454
+ if len(mouth_brightness_series) >= 2:
455
+ mouth_delta_std = float(np.std(np.diff(np.array(mouth_brightness_series, dtype=np.float32))))
456
+ # Mouth-region brightness deltas (in 0–255 pixel units) for active speech
457
+ # are typically 0.5–4.0 per frame. Dividing by 2.5 maps:
458
+ # active speech (delta_std ≈ 2.0–4.0) → 0.80–1.00
459
+ # mild movement (delta_std ≈ 0.8–2.0) → 0.32–0.80
460
+ # near-silence (delta_std < 0.5) → < 0.20
461
+ lip_sync_confidence = _clamp(mouth_delta_std / 2.5, 0.0, 1.0)
462
+ else:
463
+ lip_sync_confidence = 0.0
464
+
465
+ blink_count = 0
466
+ if eye_brightness_series:
467
+ eye_arr = np.array(eye_brightness_series, dtype=np.float32)
468
+ baseline = float(np.median(eye_arr))
469
+ threshold = baseline * 0.88
470
+ in_blink = False
471
+ for value in eye_arr:
472
+ if value < threshold:
473
+ if not in_blink:
474
+ blink_count += 1
475
+ in_blink = True
476
+ else:
477
+ in_blink = False
478
+
479
+ optical_flow_magnitude = 1.0
480
+ if len(gray_frames) >= 2:
481
+ face_flows: list[float] = []
482
+ bg_flows: list[float] = []
483
+
484
+ face_mask = np.zeros_like(gray_frames[0], dtype=bool)
485
+ face_mask[y1:y2, x1:x2] = True
486
+ for i in range(min(len(gray_frames) - 1, 30)):
487
+ flow = cv2.calcOpticalFlowFarneback(
488
+ gray_frames[i],
489
+ gray_frames[i + 1],
490
+ None,
491
+ 0.5,
492
+ 3,
493
+ 15,
494
+ 3,
495
+ 5,
496
+ 1.2,
497
+ 0,
498
+ )
499
+ mag = np.sqrt(flow[..., 0] ** 2 + flow[..., 1] ** 2)
500
+ face_mean = float(mag[face_mask].mean()) if face_mask.any() else 0.0
501
+ bg_mean = float(mag[~face_mask].mean() + 1e-6)
502
+ face_flows.append(face_mean)
503
+ bg_flows.append(bg_mean)
504
+
505
+ if face_flows and bg_flows:
506
+ optical_flow_magnitude = (
507
+ float(sum(face_flows) / len(face_flows))
508
+ / float(sum(bg_flows) / len(bg_flows))
509
+ )
510
+
511
  return ClipSignalObservation(
512
  clip_id=clip_path.stem,
513
  face_embedding_variance=round(identity_drift_proxy, 4),
514
  landmark_stability_score=round(landmark_jitter_proxy, 4),
515
  identity_cosine_drift=round(identity_drift_proxy, 4),
516
  frame_difference_mean=round(frame_difference_mean, 4),
517
+ optical_flow_magnitude=round(optical_flow_magnitude, 4),
518
+ blink_count=blink_count,
519
+ lip_sync_confidence=round(lip_sync_confidence, 4),
520
  phoneme_sequence=[],
521
  phoneme_coverage_new=0.0,
522
  blur_score=round(float(sum(blur_scores) / max(1, len(blur_scores))), 4),
523
  exposure_score=round(float(sum(exposure_scores) / max(1, len(exposure_scores))), 4),
524
+ occlusion_frames=occlusion_frames,
525
  clips_audited_so_far=int(dataset_context.get("clips_audited_so_far", 0)),
526
  current_phoneme_coverage=dataset_context.get("current_phoneme_coverage", {}),
527
  current_pose_distribution=dataset_context.get("current_pose_distribution", {}),
src/envs/subenv2/node4_clip_extractor.py CHANGED
@@ -1,87 +1,218 @@
1
  """
2
  Node 4: Clip Signal Extractor — Sub-env 2.
3
 
4
- Extracts pre-computed CV signals from a raw video clip using OpenCV, MediaPipe,
5
- and ArcFace. The resulting ``ClipSignalObservation`` is consumed by the Clip
6
- Signal Extractor agent (Node 4) which does diagnostic reasoning, not perception.
 
7
 
8
- **No model inference is performed inline.** Phoneme sequences are accepted from
9
- a pre-run forced-aligner output (e.g. Montreal Forced Aligner) passed as an
10
- argument. ArcFace embeddings are extracted via the InsightFace library, which
11
- encapsulates the model loading externally.
12
 
13
  Blur score normalization
14
  ------------------------
15
  ``blur_score = clip(mean_laplacian_variance / pixel_count / CEILING, 0.0, 1.0)``
16
 
17
  ``_BLUR_CALIBRATION_CEILING`` is a calibration constant derived from the test
18
- set. It maps the per-pixel Laplacian variance of a perfectly sharp reference
19
- frame to 1.0; values above the ceiling are clipped.
20
-
21
- MediaPipe landmark indices
22
- --------------------------
23
- Eye Aspect Ratio (EAR) blink detection uses the standard six-point eye model
24
- from the 468-point FaceMesh topology. Occlusion is inferred from face-mesh
25
- detection failure or anomalously low face landmark visibility scores.
26
  """
27
 
28
  from __future__ import annotations
29
 
30
- import math
 
31
  from pathlib import Path
32
- from typing import Optional
 
 
33
 
34
  import cv2
35
  import mediapipe as mp
36
  import numpy as np
 
 
 
 
 
 
37
  from numpy.typing import NDArray
38
 
39
  from src.schemas.subenv2 import ClipSignalObservation
40
 
 
 
41
  # ---------------------------------------------------------------------------
42
  # Constants
43
  # ---------------------------------------------------------------------------
44
 
45
- # Minimum frame count; clips shorter than this are rejected.
46
  _MIN_FRAMES: int = 24
47
-
48
- # Calibration ceiling for blur score normalization (per-pixel Laplacian
49
- # variance of a sharp reference frame, derived from the test set).
50
- _BLUR_CALIBRATION_CEILING: float = 0.12
51
-
52
- # Eye Aspect Ratio threshold below which a frame is counted as a blink.
53
  _EAR_BLINK_THRESHOLD: float = 0.20
54
 
55
- # MediaPipe FaceMesh landmark indices for left and right eye (6-point model).
56
- # Indices follow the canonical 468-point topology.
57
  _LEFT_EYE_IDX: tuple[int, ...] = (362, 385, 387, 263, 373, 380)
58
  _RIGHT_EYE_IDX: tuple[int, ...] = (33, 160, 158, 133, 153, 144)
59
-
60
- # Landmark indices for upper and lower lip centre (for lip opening proxy).
61
  _UPPER_LIP_IDX: int = 13
62
  _LOWER_LIP_IDX: int = 14
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  # ---------------------------------------------------------------------------
66
  # Private helpers — signal computation
67
  # ---------------------------------------------------------------------------
68
 
69
 
70
- def _eye_aspect_ratio(landmarks: list, indices: tuple[int, ...]) -> float:
71
- """Compute EAR for a single eye given its six landmark indices."""
72
- pts = np.array(
73
- [(landmarks[i].x, landmarks[i].y) for i in indices], dtype=np.float32
74
- )
75
- # Vertical distances
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  v1 = np.linalg.norm(pts[1] - pts[5])
77
  v2 = np.linalg.norm(pts[2] - pts[4])
78
- # Horizontal distance
79
  h = np.linalg.norm(pts[0] - pts[3])
80
  return (v1 + v2) / (2.0 * h + 1e-6)
81
 
82
 
83
  def _cosine_distance(a: NDArray[np.float32], b: NDArray[np.float32]) -> float:
84
- """Cosine distance (1 − cosine_similarity) between two 1-D vectors."""
85
  norm_a = np.linalg.norm(a)
86
  norm_b = np.linalg.norm(b)
87
  if norm_a < 1e-8 or norm_b < 1e-8:
@@ -90,7 +221,6 @@ def _cosine_distance(a: NDArray[np.float32], b: NDArray[np.float32]) -> float:
90
 
91
 
92
  def _laplacian_blur_score(gray: NDArray[np.uint8]) -> float:
93
- """Per-pixel Laplacian variance for a single grayscale frame."""
94
  pixel_count = gray.shape[0] * gray.shape[1]
95
  lap_var = float(cv2.Laplacian(gray, cv2.CV_64F).var())
96
  raw = lap_var / pixel_count
@@ -98,45 +228,18 @@ def _laplacian_blur_score(gray: NDArray[np.uint8]) -> float:
98
 
99
 
100
  def _exposure_score(gray: NDArray[np.uint8]) -> float:
101
- """Composite exposure score: normalised mean brightness − clipping fraction.
102
-
103
- Returns a value in [0.0, 1.0] where 1.0 is ideal exposure.
104
- Frames with high clipping (over- or under-exposure) score lower.
105
- """
106
  hist = cv2.calcHist([gray], [0], None, [256], [0, 256]).flatten()
107
  total = gray.size
108
- clipping = float((hist[0] + hist[255]) / total) # fraction of clipped pixels
109
  mean_norm = float(gray.mean() / 255.0)
110
- # Penalise extreme means (too dark or too bright) and clipping
111
  mean_score = 1.0 - abs(mean_norm - 0.5) * 2.0
112
  return float(np.clip(mean_score * (1.0 - clipping), 0.0, 1.0))
113
 
114
 
115
  def _parse_aligner_phonemes(aligner_output: dict) -> list[str]:
116
- """Extract an ordered phoneme list from a forced-aligner output dict.
117
-
118
- Supports two common Montreal Forced Aligner output formats:
119
-
120
- Format A — flat list::
121
-
122
- {"phonemes": ["AH", "B", "AH", ...]}
123
-
124
- Format B — TextGrid-style tiers (MFA JSON export)::
125
-
126
- {"tiers": {"phones": {"entries": [[t0, t1, "AH"], ...]}}}
127
-
128
- Args:
129
- aligner_output: Parsed JSON dict from the forced aligner.
130
-
131
- Returns:
132
- Ordered list of phoneme strings (silence tokens ``"SIL"``/``"sp"``
133
- are preserved; callers may filter them if desired).
134
- """
135
- # Format A
136
  if "phonemes" in aligner_output:
137
  return [str(p) for p in aligner_output["phonemes"]]
138
 
139
- # Format B
140
  try:
141
  entries = aligner_output["tiers"]["phones"]["entries"]
142
  return [str(entry[2]) for entry in entries]
@@ -151,53 +254,28 @@ def _phoneme_coverage_new(
151
  phoneme_sequence: list[str],
152
  current_phoneme_coverage: dict,
153
  ) -> float:
154
- """Fraction of phonemes in this clip not yet covered by the dataset.
155
-
156
- A phoneme is considered «covered» if its count in
157
- ``current_phoneme_coverage`` is greater than zero.
158
-
159
- Returns 0.0 if ``phoneme_sequence`` is empty.
160
- """
161
  unique_in_clip = set(phoneme_sequence)
162
  if not unique_in_clip:
163
  return 0.0
164
- new_count = sum(
165
- 1
166
- for p in unique_in_clip
167
- if current_phoneme_coverage.get(p, 0) == 0
168
- )
169
  return new_count / len(unique_in_clip)
170
 
171
 
172
- def _lip_sync_confidence_proxy(
173
- lip_openings: list[float],
174
- cap: cv2.VideoCapture,
175
- ) -> float:
176
- """Compute a proxy lip-sync confidence score from lip opening variance.
177
-
178
- Without running Wav2Lip inference, we estimate sync quality by measuring
179
- whether lip movement is correlated with audio energy extracted directly
180
- from the video's audio track via OpenCV. If no audio is available, the
181
- score is the normalised standard deviation of lip openings (a proxy for
182
- whether the speaker's lips were moving at all).
183
-
184
- This is a heuristic proxy for the Wav2Lip-style alignment score described
185
- in the spec. Replace with a proper AV-sync model in production.
186
-
187
- Args:
188
- lip_openings: Per-frame lip opening distance (in normalised coords).
189
- cap: Already-opened ``cv2.VideoCapture`` for the clip (used only to
190
- probe for audio; audio extraction is not performed here).
191
 
192
- Returns:
193
- A float in [0.0, 1.0].
 
 
 
 
194
  """
195
  if not lip_openings:
196
  return 0.0
197
  arr = np.array(lip_openings, dtype=np.float32)
198
  std = float(arr.std())
199
- # Normalise: std of 0 means no movement → 0.0; std ≥ 0.05 → full score
200
- return float(np.clip(std / 0.05, 0.0, 1.0))
201
 
202
 
203
  # ---------------------------------------------------------------------------
@@ -210,47 +288,13 @@ def extract_clip_signals(
210
  dataset_context: dict,
211
  aligner_output: Optional[dict] = None,
212
  ) -> ClipSignalObservation:
213
- """Extract CV signals from a raw video clip for the Clip Signal Extractor.
214
-
215
- All signals are computed deterministically from pixel and landmark data
216
- using OpenCV, MediaPipe FaceMesh, and InsightFace ArcFace. No generative
217
- model inference is performed. The phoneme sequence is accepted from a
218
- pre-run forced-aligner rather than being derived inline.
219
-
220
- Args:
221
- clip_path: Absolute or relative path to the video file.
222
- dataset_context: Dict with the following required keys:
223
-
224
- - ``"clips_audited_so_far"`` (int): Clips already processed.
225
- - ``"current_phoneme_coverage"`` (dict[str, int]): Phoneme →
226
- count across accepted clips so far.
227
- - ``"current_pose_distribution"`` (dict[str, int]): Regime →
228
- accepted-clip count.
229
- - ``"similar_clips_accepted"`` (int): Count of already-accepted
230
- clips sharing the same regime and similar ArcFace embedding.
231
-
232
- aligner_output: Parsed JSON dict from a forced aligner (e.g.
233
- Montreal Forced Aligner). If ``None``, ``phoneme_sequence`` is
234
- set to an empty list and ``phoneme_coverage_new`` to 0.0.
235
- Supported formats are described in ``_parse_aligner_phonemes``.
236
-
237
- Returns:
238
- A fully populated :class:`ClipSignalObservation`.
239
-
240
- Raises:
241
- FileNotFoundError: If ``clip_path`` does not exist.
242
- ValueError: If the clip contains fewer than ``_MIN_FRAMES`` (24) frames,
243
- or if the video cannot be opened by OpenCV.
244
- """
245
  clip_path = Path(clip_path)
246
  if not clip_path.exists():
247
  raise FileNotFoundError(f"Clip not found: {clip_path}")
248
 
249
  clip_id = clip_path.stem
250
 
251
- # ------------------------------------------------------------------
252
- # Open video
253
- # ------------------------------------------------------------------
254
  cap = cv2.VideoCapture(str(clip_path))
255
  if not cap.isOpened():
256
  raise ValueError(f"OpenCV could not open video file: {clip_path}")
@@ -267,98 +311,75 @@ def extract_clip_signals(
267
 
268
  if len(frames_bgr) < _MIN_FRAMES:
269
  raise ValueError(
270
- f"Clip '{clip_id}' has only {len(frames_bgr)} frames; "
271
- f"at least {_MIN_FRAMES} are required."
272
  )
273
 
274
  n_frames = len(frames_bgr)
275
  h, w = frames_bgr[0].shape[:2]
276
 
277
- # ------------------------------------------------------------------
278
- # MediaPipe FaceMesh setup
279
- # ------------------------------------------------------------------
280
- mp_face_mesh = mp.solutions.face_mesh
281
- face_mesh = mp_face_mesh.FaceMesh(
282
- static_image_mode=True,
283
- max_num_faces=1,
284
- refine_landmarks=True,
285
- min_detection_confidence=0.5,
286
- )
287
 
288
- # Per-frame collections
289
- landmark_sets: list[Optional[list]] = [] # None if no face detected
290
  lip_openings: list[float] = []
291
  blur_scores: list[float] = []
292
  exposure_scores: list[float] = []
293
  ear_values: list[float] = []
294
  occlusion_frame_count: int = 0
295
 
296
- for frame_bgr in frames_bgr:
297
- gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
298
- blur_scores.append(_laplacian_blur_score(gray))
299
- exposure_scores.append(_exposure_score(gray))
300
-
301
- rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
302
- result = face_mesh.process(rgb)
303
-
304
- if result.multi_face_landmarks:
305
- lm = result.multi_face_landmarks[0].landmark
306
- landmark_sets.append(lm)
307
 
308
- # EAR for blink detection
309
- ear = 0.5 * (
310
- _eye_aspect_ratio(lm, _LEFT_EYE_IDX)
311
- + _eye_aspect_ratio(lm, _RIGHT_EYE_IDX)
312
- )
313
- ear_values.append(ear)
314
 
315
- # Lip opening (normalised image coords)
316
- lip_open = abs(lm[_LOWER_LIP_IDX].y - lm[_UPPER_LIP_IDX].y)
317
- lip_openings.append(lip_open)
318
- else:
319
- landmark_sets.append(None)
320
- ear_values.append(1.0) # assume open (no blink) when undetected
321
- lip_openings.append(0.0)
322
- occlusion_frame_count += 1
323
 
324
- face_mesh.close()
 
 
 
 
 
325
 
326
- # ------------------------------------------------------------------
327
- # ArcFace embeddings (InsightFace)
328
- # ------------------------------------------------------------------
329
- try:
330
- import insightface
331
- from insightface.app import FaceAnalysis
332
 
333
- fa = FaceAnalysis(allowed_modules=["detection", "recognition"])
334
- fa.prepare(ctx_id=-1) # CPU; set ctx_id ≥ 0 for GPU
 
 
 
 
 
 
 
 
 
 
335
 
336
- embeddings: list[NDArray[np.float32]] = []
337
- for frame_bgr in frames_bgr:
338
- faces = fa.get(frame_bgr)
339
- if faces:
340
- embeddings.append(faces[0].normed_embedding.astype(np.float32))
341
- except ImportError:
342
- embeddings = []
343
-
344
- # Identity signals
345
- if len(embeddings) >= 2:
346
- emb_matrix = np.stack(embeddings, axis=0) # (K, D)
347
  face_embedding_variance = float(np.var(emb_matrix, axis=0).mean())
348
  identity_cosine_drift = _cosine_distance(emb_matrix[0], emb_matrix[-1])
349
- elif len(embeddings) == 1:
350
  face_embedding_variance = 0.0
351
  identity_cosine_drift = 0.0
352
  else:
353
- # No face detected in any frame — treat as maximum variance/drift
354
  face_embedding_variance = 1.0
355
  identity_cosine_drift = 1.0
356
 
357
- # ------------------------------------------------------------------
358
- # Landmark stability (frame-to-frame jitter)
359
- # ------------------------------------------------------------------
360
  detected_lm = [(i, lm) for i, lm in enumerate(landmark_sets) if lm is not None]
361
-
362
  if len(detected_lm) >= 2:
363
  jitter_values: list[float] = []
364
  for (_, lm_a), (_, lm_b) in zip(detected_lm, detected_lm[1:]):
@@ -367,11 +388,8 @@ def extract_clip_signals(
367
  jitter_values.append(float(np.mean(np.linalg.norm(pts_a - pts_b, axis=1))))
368
  landmark_stability_score = float(np.mean(jitter_values))
369
  else:
370
- landmark_stability_score = 1.0 # worst case — no stable landmarks
371
 
372
- # ------------------------------------------------------------------
373
- # Blink count (EAR threshold)
374
- # ------------------------------------------------------------------
375
  blink_count = 0
376
  in_blink = False
377
  for ear in ear_values:
@@ -382,9 +400,6 @@ def extract_clip_signals(
382
  else:
383
  in_blink = False
384
 
385
- # ------------------------------------------------------------------
386
- # Frame difference mean (temporal signal)
387
- # ------------------------------------------------------------------
388
  if n_frames >= 2:
389
  diffs: list[float] = []
390
  for fa_fr, fb_fr in zip(frames_bgr, frames_bgr[1:]):
@@ -393,22 +408,16 @@ def extract_clip_signals(
393
  else:
394
  frame_difference_mean = 0.0
395
 
396
- # ------------------------------------------------------------------
397
- # Optical flow magnitude — face region vs background ratio
398
- # ------------------------------------------------------------------
399
  if n_frames >= 2:
400
  face_flows: list[float] = []
401
  bg_flows: list[float] = []
402
 
403
- for i in range(min(n_frames - 1, 30)): # cap at 30 pairs for speed
404
  g1 = cv2.cvtColor(frames_bgr[i], cv2.COLOR_BGR2GRAY)
405
  g2 = cv2.cvtColor(frames_bgr[i + 1], cv2.COLOR_BGR2GRAY)
406
- flow = cv2.calcOpticalFlowFarneback(
407
- g1, g2, None, 0.5, 3, 15, 3, 5, 1.2, 0
408
- )
409
  mag = np.sqrt(flow[..., 0] ** 2 + flow[..., 1] ** 2)
410
 
411
- # Use landmark bounding box as face region if available
412
  lm_a = landmark_sets[i]
413
  if lm_a is not None:
414
  xs = [int(p.x * w) for p in lm_a]
@@ -418,7 +427,6 @@ def extract_clip_signals(
418
  face_mask = np.zeros((h, w), dtype=bool)
419
  face_mask[y1:y2, x1:x2] = True
420
  else:
421
- # Fallback: central 40 % of frame
422
  cx, cy = w // 2, h // 2
423
  face_mask = np.zeros((h, w), dtype=bool)
424
  face_mask[cy - h // 5 : cy + h // 5, cx - w // 5 : cx + w // 5] = True
@@ -431,22 +439,10 @@ def extract_clip_signals(
431
  else:
432
  optical_flow_magnitude = 1.0
433
 
434
- # ------------------------------------------------------------------
435
- # Aggregate quality signals
436
- # ------------------------------------------------------------------
437
  blur_score = float(np.mean(blur_scores))
438
  exposure_score_val = float(np.mean(exposure_scores))
 
439
 
440
- # ------------------------------------------------------------------
441
- # Lip sync confidence proxy
442
- # ------------------------------------------------------------------
443
- cap2 = cv2.VideoCapture(str(clip_path))
444
- lip_sync_confidence = _lip_sync_confidence_proxy(lip_openings, cap2)
445
- cap2.release()
446
-
447
- # ------------------------------------------------------------------
448
- # Phoneme signals
449
- # ------------------------------------------------------------------
450
  if aligner_output is not None:
451
  phoneme_sequence = _parse_aligner_phonemes(aligner_output)
452
  else:
@@ -455,28 +451,20 @@ def extract_clip_signals(
455
  current_phoneme_coverage: dict = dataset_context.get("current_phoneme_coverage", {})
456
  phone_cov_new = _phoneme_coverage_new(phoneme_sequence, current_phoneme_coverage)
457
 
458
- # ------------------------------------------------------------------
459
- # Assemble observation
460
- # ------------------------------------------------------------------
461
  return ClipSignalObservation(
462
  clip_id=clip_id,
463
- # Identity consistency
464
  face_embedding_variance=face_embedding_variance,
465
  landmark_stability_score=landmark_stability_score,
466
  identity_cosine_drift=identity_cosine_drift,
467
- # Temporal
468
  frame_difference_mean=frame_difference_mean,
469
  optical_flow_magnitude=optical_flow_magnitude,
470
  blink_count=blink_count,
471
- # Audio-visual alignment
472
  lip_sync_confidence=lip_sync_confidence,
473
  phoneme_sequence=phoneme_sequence,
474
  phoneme_coverage_new=phone_cov_new,
475
- # Quality
476
  blur_score=blur_score,
477
  exposure_score=exposure_score_val,
478
  occlusion_frames=occlusion_frame_count,
479
- # Dataset context (passed through from caller)
480
  clips_audited_so_far=int(dataset_context.get("clips_audited_so_far", 0)),
481
  current_phoneme_coverage=current_phoneme_coverage,
482
  current_pose_distribution=dataset_context.get("current_pose_distribution", {}),
 
1
  """
2
  Node 4: Clip Signal Extractor — Sub-env 2.
3
 
4
+ Extracts pre-computed CV signals from a raw video clip using OpenCV and
5
+ MediaPipe Tasks FaceLandmarker. The resulting ``ClipSignalObservation`` is
6
+ consumed by the Clip Signal Extractor agent (Node 4) which does diagnostic
7
+ reasoning, not perception.
8
 
9
+ **No model inference is performed inline.** Phoneme sequences are accepted from
10
+ an optional pre-run forced-aligner output (e.g. Montreal Forced Aligner)
11
+ passed as an argument. Identity drift signals are computed from normalized
12
+ landmark vectors, avoiding heavyweight ArcFace runtime dependencies.
13
 
14
  Blur score normalization
15
  ------------------------
16
  ``blur_score = clip(mean_laplacian_variance / pixel_count / CEILING, 0.0, 1.0)``
17
 
18
  ``_BLUR_CALIBRATION_CEILING`` is a calibration constant derived from the test
19
+ set. It maps per-pixel Laplacian variance of a sharp reference frame to 1.0.
 
 
 
 
 
 
 
20
  """
21
 
22
  from __future__ import annotations
23
 
24
+ import logging
25
+ import os
26
  from pathlib import Path
27
+ from typing import Any, Optional
28
+ import urllib.error
29
+ import urllib.request
30
 
31
  import cv2
32
  import mediapipe as mp
33
  import numpy as np
34
+ from mediapipe.tasks.python import BaseOptions
35
+ from mediapipe.tasks.python.vision import (
36
+ FaceLandmarker,
37
+ FaceLandmarkerOptions,
38
+ RunningMode,
39
+ )
40
  from numpy.typing import NDArray
41
 
42
  from src.schemas.subenv2 import ClipSignalObservation
43
 
44
+ log = logging.getLogger(__name__)
45
+
46
  # ---------------------------------------------------------------------------
47
  # Constants
48
  # ---------------------------------------------------------------------------
49
 
 
50
  _MIN_FRAMES: int = 24
51
+ # Per-pixel Laplacian variance calibration ceiling.
52
+ # Empirically derived from sharp talking-head face ROIs at 480p–1080p:
53
+ # a sharp 300×300 face crop has lap_var 150–600, giving per-pixel ≈ 0.0017–0.0067.
54
+ # Setting the ceiling to 0.005 maps a sharp face to ≈ 0.33–1.0 and
55
+ # a blurry face (lap_var ≈ 10–30) to ≈ 0.002–0.02.
56
+ _BLUR_CALIBRATION_CEILING: float = 0.005
57
  _EAR_BLINK_THRESHOLD: float = 0.20
58
 
59
+ # 468-landmark topology indices (Tasks API keeps FaceMesh indexing).
 
60
  _LEFT_EYE_IDX: tuple[int, ...] = (362, 385, 387, 263, 373, 380)
61
  _RIGHT_EYE_IDX: tuple[int, ...] = (33, 160, 158, 133, 153, 144)
 
 
62
  _UPPER_LIP_IDX: int = 13
63
  _LOWER_LIP_IDX: int = 14
64
 
65
+ _PROJECT_ROOT = Path(__file__).resolve().parents[3]
66
+ _FACE_LANDMARKER_URL = (
67
+ "https://storage.googleapis.com/mediapipe-models/face_landmarker/"
68
+ "face_landmarker/float16/latest/face_landmarker.task"
69
+ )
70
+ _DEFAULT_MODEL_CANDIDATES: tuple[Path, ...] = (
71
+ _PROJECT_ROOT / "data" / "models" / "face_landmarker.task",
72
+ Path.home() / ".cache" / "talkingheadbench" / "models" / "face_landmarker.task",
73
+ )
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Private helpers — model setup
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ def _env_truthy(name: str, *, default: bool) -> bool:
82
+ raw = os.getenv(name)
83
+ if raw is None:
84
+ return default
85
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
86
+
87
+
88
+ def _candidate_landmarker_model_paths() -> list[Path]:
89
+ env_path = os.getenv("THB_FACE_LANDMARKER_MODEL", "").strip()
90
+ candidates: list[Path] = []
91
+ if env_path:
92
+ candidates.append(Path(env_path).expanduser())
93
+ candidates.extend(_DEFAULT_MODEL_CANDIDATES)
94
+
95
+ deduped: list[Path] = []
96
+ seen: set[str] = set()
97
+ for path in candidates:
98
+ key = str(path)
99
+ if key in seen:
100
+ continue
101
+ seen.add(key)
102
+ deduped.append(path)
103
+ return deduped
104
+
105
+
106
+ def _download_landmarker_model(dest: Path) -> Path:
107
+ dest.parent.mkdir(parents=True, exist_ok=True)
108
+ urllib.request.urlretrieve(_FACE_LANDMARKER_URL, dest)
109
+ return dest
110
+
111
+
112
+ def _resolve_landmarker_model_path() -> Path | None:
113
+ for candidate in _candidate_landmarker_model_paths():
114
+ if candidate.exists() and candidate.is_file():
115
+ return candidate
116
+
117
+ if not _env_truthy("THB_AUTO_DOWNLOAD_FACE_LANDMARKER", default=True):
118
+ return None
119
+
120
+ cache_target = _DEFAULT_MODEL_CANDIDATES[-1]
121
+ try:
122
+ downloaded = _download_landmarker_model(cache_target)
123
+ except (OSError, urllib.error.URLError, ValueError) as exc:
124
+ log.warning(
125
+ "Unable to auto-download FaceLandmarker model to %s: %s",
126
+ cache_target,
127
+ exc,
128
+ )
129
+ return None
130
+
131
+ log.info("Downloaded MediaPipe FaceLandmarker model to %s", downloaded)
132
+ return downloaded
133
+
134
+
135
+ def _create_face_landmarker() -> Any | None:
136
+ model_path = _resolve_landmarker_model_path()
137
+ if model_path is None:
138
+ log.warning(
139
+ "FaceLandmarker model file not found. Checked: %s",
140
+ ", ".join(str(p) for p in _candidate_landmarker_model_paths()),
141
+ )
142
+ return None
143
+
144
+ try:
145
+ options = FaceLandmarkerOptions(
146
+ base_options=BaseOptions(model_asset_path=str(model_path)),
147
+ running_mode=RunningMode.IMAGE,
148
+ num_faces=1,
149
+ min_face_detection_confidence=0.5,
150
+ min_face_presence_confidence=0.5,
151
+ output_face_blendshapes=False,
152
+ output_facial_transformation_matrixes=False,
153
+ )
154
+ return FaceLandmarker.create_from_options(options)
155
+ except Exception as exc: # noqa: BLE001
156
+ log.warning(
157
+ "Failed to initialize FaceLandmarker from %s: %s",
158
+ model_path,
159
+ exc,
160
+ )
161
+ return None
162
+
163
 
164
  # ---------------------------------------------------------------------------
165
  # Private helpers — signal computation
166
  # ---------------------------------------------------------------------------
167
 
168
 
169
+ def _landmark_embedding(landmarks: list[Any]) -> NDArray[np.float32]:
170
+ coords = np.array([(lm.x, lm.y, lm.z) for lm in landmarks], dtype=np.float32)
171
+ centered = coords - coords.mean(axis=0, keepdims=True)
172
+ scale = float(np.std(centered) + 1e-6)
173
+ return (centered / scale).flatten().astype(np.float32)
174
+
175
+
176
+ def _face_bbox_from_landmarks(
177
+ landmarks: list[Any],
178
+ width: int,
179
+ height: int,
180
+ *,
181
+ padding_ratio: float = 0.2,
182
+ ) -> tuple[int, int, int, int]:
183
+ xs = np.array([lm.x * width for lm in landmarks], dtype=np.float32)
184
+ ys = np.array([lm.y * height for lm in landmarks], dtype=np.float32)
185
+
186
+ x0 = int(np.clip(np.floor(xs.min()), 0, width - 1))
187
+ x1 = int(np.clip(np.ceil(xs.max()), 1, width))
188
+ y0 = int(np.clip(np.floor(ys.min()), 0, height - 1))
189
+ y1 = int(np.clip(np.ceil(ys.max()), 1, height))
190
+
191
+ pad_x = int((x1 - x0) * padding_ratio)
192
+ pad_y = int((y1 - y0) * padding_ratio)
193
+
194
+ x0 = max(0, x0 - pad_x)
195
+ y0 = max(0, y0 - pad_y)
196
+ x1 = min(width, x1 + pad_x)
197
+ y1 = min(height, y1 + pad_y)
198
+
199
+ if x1 <= x0:
200
+ x1 = min(width, x0 + 1)
201
+ if y1 <= y0:
202
+ y1 = min(height, y0 + 1)
203
+
204
+ return x0, y0, x1, y1
205
+
206
+
207
+ def _eye_aspect_ratio(landmarks: list[Any], indices: tuple[int, ...]) -> float:
208
+ pts = np.array([(landmarks[i].x, landmarks[i].y) for i in indices], dtype=np.float32)
209
  v1 = np.linalg.norm(pts[1] - pts[5])
210
  v2 = np.linalg.norm(pts[2] - pts[4])
 
211
  h = np.linalg.norm(pts[0] - pts[3])
212
  return (v1 + v2) / (2.0 * h + 1e-6)
213
 
214
 
215
  def _cosine_distance(a: NDArray[np.float32], b: NDArray[np.float32]) -> float:
 
216
  norm_a = np.linalg.norm(a)
217
  norm_b = np.linalg.norm(b)
218
  if norm_a < 1e-8 or norm_b < 1e-8:
 
221
 
222
 
223
  def _laplacian_blur_score(gray: NDArray[np.uint8]) -> float:
 
224
  pixel_count = gray.shape[0] * gray.shape[1]
225
  lap_var = float(cv2.Laplacian(gray, cv2.CV_64F).var())
226
  raw = lap_var / pixel_count
 
228
 
229
 
230
  def _exposure_score(gray: NDArray[np.uint8]) -> float:
 
 
 
 
 
231
  hist = cv2.calcHist([gray], [0], None, [256], [0, 256]).flatten()
232
  total = gray.size
233
+ clipping = float((hist[0] + hist[255]) / total)
234
  mean_norm = float(gray.mean() / 255.0)
 
235
  mean_score = 1.0 - abs(mean_norm - 0.5) * 2.0
236
  return float(np.clip(mean_score * (1.0 - clipping), 0.0, 1.0))
237
 
238
 
239
  def _parse_aligner_phonemes(aligner_output: dict) -> list[str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
  if "phonemes" in aligner_output:
241
  return [str(p) for p in aligner_output["phonemes"]]
242
 
 
243
  try:
244
  entries = aligner_output["tiers"]["phones"]["entries"]
245
  return [str(entry[2]) for entry in entries]
 
254
  phoneme_sequence: list[str],
255
  current_phoneme_coverage: dict,
256
  ) -> float:
 
 
 
 
 
 
 
257
  unique_in_clip = set(phoneme_sequence)
258
  if not unique_in_clip:
259
  return 0.0
260
+ new_count = sum(1 for p in unique_in_clip if current_phoneme_coverage.get(p, 0) == 0)
 
 
 
 
261
  return new_count / len(unique_in_clip)
262
 
263
 
264
+ def _lip_sync_confidence_proxy(lip_openings: list[float]) -> float:
265
+ """Map mouth-opening variance to a lip-sync confidence score in [0, 1].
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
 
267
+ Lip openings are normalized landmark Y-distances (range ~ 0.00–0.08).
268
+ A talking sequence has std ≈ 0.003–0.010; silence is near 0.
269
+ Divisor 0.008 maps:
270
+ - active talking (std ≈ 0.006–0.010) → 0.75–1.00
271
+ - mild movement (std ≈ 0.003–0.006) → 0.38–0.75
272
+ - near-silence (std < 0.003) → < 0.38
273
  """
274
  if not lip_openings:
275
  return 0.0
276
  arr = np.array(lip_openings, dtype=np.float32)
277
  std = float(arr.std())
278
+ return float(np.clip(std / 0.008, 0.0, 1.0))
 
279
 
280
 
281
  # ---------------------------------------------------------------------------
 
288
  dataset_context: dict,
289
  aligner_output: Optional[dict] = None,
290
  ) -> ClipSignalObservation:
291
+ """Extract CV signals from a raw video clip for Node 4."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  clip_path = Path(clip_path)
293
  if not clip_path.exists():
294
  raise FileNotFoundError(f"Clip not found: {clip_path}")
295
 
296
  clip_id = clip_path.stem
297
 
 
 
 
298
  cap = cv2.VideoCapture(str(clip_path))
299
  if not cap.isOpened():
300
  raise ValueError(f"OpenCV could not open video file: {clip_path}")
 
311
 
312
  if len(frames_bgr) < _MIN_FRAMES:
313
  raise ValueError(
314
+ f"Clip '{clip_id}' has only {len(frames_bgr)} frames; at least {_MIN_FRAMES} are required."
 
315
  )
316
 
317
  n_frames = len(frames_bgr)
318
  h, w = frames_bgr[0].shape[:2]
319
 
320
+ face_landmarker = _create_face_landmarker()
321
+ if face_landmarker is None:
322
+ raise ValueError(
323
+ "FaceLandmarker model file not found or failed to initialize. "
324
+ "Set THB_FACE_LANDMARKER_MODEL or place model at data/models/face_landmarker.task."
325
+ )
 
 
 
 
326
 
327
+ landmark_sets: list[Optional[list[Any]]] = []
328
+ landmark_embeddings: list[NDArray[np.float32]] = []
329
  lip_openings: list[float] = []
330
  blur_scores: list[float] = []
331
  exposure_scores: list[float] = []
332
  ear_values: list[float] = []
333
  occlusion_frame_count: int = 0
334
 
335
+ try:
336
+ for frame_bgr in frames_bgr:
337
+ gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY)
 
 
 
 
 
 
 
 
338
 
339
+ rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
340
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb.copy())
341
+ result = face_landmarker.detect(mp_image)
 
 
 
342
 
343
+ if result.face_landmarks:
344
+ lm = result.face_landmarks[0]
345
+ landmark_sets.append(lm)
346
+ landmark_embeddings.append(_landmark_embedding(lm))
 
 
 
 
347
 
348
+ x0, y0, x1, y1 = _face_bbox_from_landmarks(lm, w, h)
349
+ face_gray = gray[y0:y1, x0:x1]
350
+ if face_gray.size == 0:
351
+ face_gray = gray
352
+ blur_scores.append(_laplacian_blur_score(face_gray))
353
+ exposure_scores.append(_exposure_score(face_gray))
354
 
355
+ ear = 0.5 * (_eye_aspect_ratio(lm, _LEFT_EYE_IDX) + _eye_aspect_ratio(lm, _RIGHT_EYE_IDX))
356
+ ear_values.append(ear)
 
 
 
 
357
 
358
+ lip_open = abs(lm[_LOWER_LIP_IDX].y - lm[_UPPER_LIP_IDX].y)
359
+ lip_openings.append(lip_open)
360
+ else:
361
+ landmark_sets.append(None)
362
+ blur_scores.append(_laplacian_blur_score(gray))
363
+ exposure_scores.append(_exposure_score(gray))
364
+ ear_values.append(1.0)
365
+ lip_openings.append(0.0)
366
+ occlusion_frame_count += 1
367
+ finally:
368
+ if hasattr(face_landmarker, "close"):
369
+ face_landmarker.close()
370
 
371
+ if len(landmark_embeddings) >= 2:
372
+ emb_matrix = np.stack(landmark_embeddings, axis=0)
 
 
 
 
 
 
 
 
 
373
  face_embedding_variance = float(np.var(emb_matrix, axis=0).mean())
374
  identity_cosine_drift = _cosine_distance(emb_matrix[0], emb_matrix[-1])
375
+ elif len(landmark_embeddings) == 1:
376
  face_embedding_variance = 0.0
377
  identity_cosine_drift = 0.0
378
  else:
 
379
  face_embedding_variance = 1.0
380
  identity_cosine_drift = 1.0
381
 
 
 
 
382
  detected_lm = [(i, lm) for i, lm in enumerate(landmark_sets) if lm is not None]
 
383
  if len(detected_lm) >= 2:
384
  jitter_values: list[float] = []
385
  for (_, lm_a), (_, lm_b) in zip(detected_lm, detected_lm[1:]):
 
388
  jitter_values.append(float(np.mean(np.linalg.norm(pts_a - pts_b, axis=1))))
389
  landmark_stability_score = float(np.mean(jitter_values))
390
  else:
391
+ landmark_stability_score = 1.0
392
 
 
 
 
393
  blink_count = 0
394
  in_blink = False
395
  for ear in ear_values:
 
400
  else:
401
  in_blink = False
402
 
 
 
 
403
  if n_frames >= 2:
404
  diffs: list[float] = []
405
  for fa_fr, fb_fr in zip(frames_bgr, frames_bgr[1:]):
 
408
  else:
409
  frame_difference_mean = 0.0
410
 
 
 
 
411
  if n_frames >= 2:
412
  face_flows: list[float] = []
413
  bg_flows: list[float] = []
414
 
415
+ for i in range(min(n_frames - 1, 30)):
416
  g1 = cv2.cvtColor(frames_bgr[i], cv2.COLOR_BGR2GRAY)
417
  g2 = cv2.cvtColor(frames_bgr[i + 1], cv2.COLOR_BGR2GRAY)
418
+ flow = cv2.calcOpticalFlowFarneback(g1, g2, None, 0.5, 3, 15, 3, 5, 1.2, 0)
 
 
419
  mag = np.sqrt(flow[..., 0] ** 2 + flow[..., 1] ** 2)
420
 
 
421
  lm_a = landmark_sets[i]
422
  if lm_a is not None:
423
  xs = [int(p.x * w) for p in lm_a]
 
427
  face_mask = np.zeros((h, w), dtype=bool)
428
  face_mask[y1:y2, x1:x2] = True
429
  else:
 
430
  cx, cy = w // 2, h // 2
431
  face_mask = np.zeros((h, w), dtype=bool)
432
  face_mask[cy - h // 5 : cy + h // 5, cx - w // 5 : cx + w // 5] = True
 
439
  else:
440
  optical_flow_magnitude = 1.0
441
 
 
 
 
442
  blur_score = float(np.mean(blur_scores))
443
  exposure_score_val = float(np.mean(exposure_scores))
444
+ lip_sync_confidence = _lip_sync_confidence_proxy(lip_openings)
445
 
 
 
 
 
 
 
 
 
 
 
446
  if aligner_output is not None:
447
  phoneme_sequence = _parse_aligner_phonemes(aligner_output)
448
  else:
 
451
  current_phoneme_coverage: dict = dataset_context.get("current_phoneme_coverage", {})
452
  phone_cov_new = _phoneme_coverage_new(phoneme_sequence, current_phoneme_coverage)
453
 
 
 
 
454
  return ClipSignalObservation(
455
  clip_id=clip_id,
 
456
  face_embedding_variance=face_embedding_variance,
457
  landmark_stability_score=landmark_stability_score,
458
  identity_cosine_drift=identity_cosine_drift,
 
459
  frame_difference_mean=frame_difference_mean,
460
  optical_flow_magnitude=optical_flow_magnitude,
461
  blink_count=blink_count,
 
462
  lip_sync_confidence=lip_sync_confidence,
463
  phoneme_sequence=phoneme_sequence,
464
  phoneme_coverage_new=phone_cov_new,
 
465
  blur_score=blur_score,
466
  exposure_score=exposure_score_val,
467
  occlusion_frames=occlusion_frame_count,
 
468
  clips_audited_so_far=int(dataset_context.get("clips_audited_so_far", 0)),
469
  current_phoneme_coverage=current_phoneme_coverage,
470
  current_pose_distribution=dataset_context.get("current_pose_distribution", {}),
tests/unit/test_artifact_ingest.py CHANGED
@@ -7,6 +7,7 @@ from io import BytesIO
7
  from pathlib import Path
8
 
9
  from fastapi import UploadFile
 
10
 
11
  import server.artifact_ingest as artifact_ingest
12
 
@@ -100,3 +101,63 @@ def test_ingest_artifacts_to_bundle_sets_per_clip_fallback_flags(monkeypatch, tm
100
  extractor_metadata = bundle["ingestion_metadata"]["extractor_metadata"]
101
  assert extractor_metadata["clip_extractor_fallback_count"] == 1
102
  assert extractor_metadata["fallback_clip_ids"] == ["clip_b"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  from pathlib import Path
8
 
9
  from fastapi import UploadFile
10
+ import numpy as np
11
 
12
  import server.artifact_ingest as artifact_ingest
13
 
 
101
  extractor_metadata = bundle["ingestion_metadata"]["extractor_metadata"]
102
  assert extractor_metadata["clip_extractor_fallback_count"] == 1
103
  assert extractor_metadata["fallback_clip_ids"] == ["clip_b"]
104
+
105
+
106
+ def test_extract_clip_signals_fallback_computes_dynamic_proxies(monkeypatch):
107
+ class _FakeCapture:
108
+ def __init__(self, frames):
109
+ self._frames = [frame.copy() for frame in frames]
110
+ self._idx = 0
111
+
112
+ def isOpened(self):
113
+ return True
114
+
115
+ def read(self):
116
+ if self._idx >= len(self._frames):
117
+ return False, None
118
+ frame = self._frames[self._idx]
119
+ self._idx += 1
120
+ return True, frame
121
+
122
+ def release(self):
123
+ return None
124
+
125
+ class _FakeCascade:
126
+ def __init__(self):
127
+ self.calls = 0
128
+
129
+ def detectMultiScale(self, gray, scaleFactor, minNeighbors, minSize):
130
+ self.calls += 1
131
+ if self.calls % 2 == 0:
132
+ return np.empty((0, 4), dtype=np.int32)
133
+ return np.array([[10, 10, 30, 30]], dtype=np.int32)
134
+
135
+ frames = []
136
+ for idx in range(8):
137
+ frame = np.full((80, 120, 3), 80, dtype=np.uint8)
138
+ # Add changing mouth-region brightness for lip-sync proxy variability.
139
+ frame[46:64, 40:80] = 80 + idx * 12
140
+ # Add moving patch to induce optical flow.
141
+ start = 10 + idx
142
+ frame[20:35, start : start + 15] = 220
143
+ frames.append(frame)
144
+
145
+ monkeypatch.setattr(artifact_ingest.cv2, "VideoCapture", lambda _: _FakeCapture(frames))
146
+ monkeypatch.setattr(artifact_ingest, "_get_haar_face_cascade", lambda: _FakeCascade())
147
+
148
+ obs = artifact_ingest._extract_clip_signals_fallback(
149
+ Path("clip_dynamic.mp4"),
150
+ {
151
+ "clips_audited_so_far": 0,
152
+ "current_phoneme_coverage": {},
153
+ "current_pose_distribution": {},
154
+ "similar_clips_accepted": 0,
155
+ },
156
+ )
157
+
158
+ assert 0.0 <= obs.blur_score <= 1.0
159
+ assert 0.0 <= obs.lip_sync_confidence <= 1.0
160
+ assert 0.0 <= obs.optical_flow_magnitude
161
+ assert obs.lip_sync_confidence != 0.5
162
+ assert obs.optical_flow_magnitude != 1.0
163
+ assert obs.occlusion_frames > 0
tests/unit/test_node4_extractor.py CHANGED
@@ -2,15 +2,13 @@
2
  Unit tests for Node 4: Clip Signal Extractor
3
  (src/envs/subenv2/node4_clip_extractor.py)
4
 
5
- All heavy dependencies OpenCV VideoCapture, InsightFace, and MediaPipe
6
- FaceMesh — are fully mocked so no real video files or GPU/model weights
7
- are required.
8
  """
9
 
10
  from __future__ import annotations
11
 
12
  import json
13
- import sys
14
  import types
15
  from pathlib import Path
16
  from unittest.mock import MagicMock, patch
@@ -29,112 +27,45 @@ _RNG = np.random.default_rng(42)
29
 
30
 
31
  def _random_frame(height: int = 72, width: int = 128) -> np.ndarray:
32
- """Return a random uint8 BGR frame."""
33
  return _RNG.integers(0, 256, (height, width, 3), dtype=np.uint8)
34
 
35
 
36
- # ---------------------------------------------------------------------------
37
- # Fixture: mock_capture
38
- # ---------------------------------------------------------------------------
39
-
40
-
41
  def mock_capture(frame_count: int, height: int = 72, width: int = 128) -> MagicMock:
42
- """Return a MagicMock that behaves like cv2.VideoCapture.
43
-
44
- * ``get(cv2.CAP_PROP_FRAME_COUNT)`` → *frame_count*
45
- * ``get(cv2.CAP_PROP_FPS)`` → 24.0
46
- * ``read()`` cycles through *frame_count* random numpy frames then
47
- returns ``(False, None)``
48
- * ``isOpened()`` → True
49
- * ``release()`` → no-op
50
- """
51
- import cv2 # noqa: PLC0415
52
-
53
  frames = [_random_frame(height, width) for _ in range(frame_count)]
54
  read_returns = [(True, f) for f in frames] + [(False, None)]
55
  read_iter = iter(read_returns)
56
 
57
  cap = MagicMock()
58
  cap.isOpened.return_value = True
59
-
60
- def _get(prop_id):
61
- if prop_id == cv2.CAP_PROP_FRAME_COUNT:
62
- return float(frame_count)
63
- if prop_id == cv2.CAP_PROP_FPS:
64
- return 24.0
65
- return 0.0
66
-
67
- cap.get.side_effect = _get
68
  cap.read.side_effect = lambda: next(read_iter)
69
  cap.release.return_value = None
70
  return cap
71
 
72
 
73
- # ---------------------------------------------------------------------------
74
- # Shared MediaPipe / InsightFace mock builders
75
- # ---------------------------------------------------------------------------
76
-
77
-
78
- def _make_face_mesh_mock() -> MagicMock:
79
- """Return a MagicMock mimicking mediapipe FaceMesh.
80
-
81
- Each call to ``process()`` returns a result with a single detected face
82
- carrying 478 stable landmarks (x=0.5, y=0.5, z=0.0).
83
- """
84
- lm = MagicMock()
85
- lm.x = 0.5
86
- lm.y = 0.5
87
- lm.z = 0.0
88
-
89
- face_landmark = MagicMock()
90
- face_landmark.landmark = [lm] * 478
91
-
92
- mp_result = MagicMock()
93
- mp_result.multi_face_landmarks = [face_landmark]
94
-
95
- face_mesh = MagicMock()
96
- face_mesh.process.return_value = mp_result
97
- face_mesh.close.return_value = None
98
- return face_mesh
99
-
100
-
101
- def _make_mediapipe_mock(face_mesh_instance: MagicMock) -> MagicMock:
102
- """Build a fake ``mp`` module alias as imported in node4_clip_extractor."""
103
- mp_mock = MagicMock()
104
- mp_mock.solutions.face_mesh.FaceMesh.return_value = face_mesh_instance
105
- return mp_mock
106
-
107
 
108
- def _make_insightface_modules() -> dict:
109
- """Return a sys.modules patch dict for insightface.
 
 
110
 
111
- ``FaceAnalysis.get()`` returns a single face with a unit 512-D embedding.
112
- """
113
- embedding = np.ones(512, dtype=np.float32)
114
 
115
- face = MagicMock()
116
- face.normed_embedding = embedding
117
-
118
- fa_instance = MagicMock()
119
- fa_instance.get.return_value = [face]
120
- fa_instance.prepare.return_value = None
121
-
122
- FaceAnalysis = MagicMock(return_value=fa_instance)
123
-
124
- insightface_mod = types.ModuleType("insightface")
125
- app_mod = types.ModuleType("insightface.app")
126
- app_mod.FaceAnalysis = FaceAnalysis
127
- insightface_mod.app = app_mod
128
-
129
- return {
130
- "insightface": insightface_mod,
131
- "insightface.app": app_mod,
132
- }
133
 
 
 
 
 
 
 
 
 
 
134
 
135
- # ---------------------------------------------------------------------------
136
- # Common dataset context skeleton
137
- # ---------------------------------------------------------------------------
138
 
139
  _EMPTY_CTX: dict = {
140
  "current_phoneme_coverage": {},
@@ -145,69 +76,39 @@ _EMPTY_CTX: dict = {
145
 
146
 
147
  # ---------------------------------------------------------------------------
148
- # Context manager: full env patch
149
- # ---------------------------------------------------------------------------
150
-
151
-
152
- def _full_patch(cap_mock: MagicMock, mp_mock: MagicMock):
153
- """Return a combined context manager that patches cv2, insightface, and mp."""
154
- from contextlib import ExitStack
155
-
156
- stack = ExitStack()
157
- stack.enter_context(patch("cv2.VideoCapture", return_value=cap_mock))
158
- stack.enter_context(patch.dict("sys.modules", _make_insightface_modules()))
159
- stack.enter_context(
160
- patch("src.envs.subenv2.node4_clip_extractor.mp", mp_mock)
161
- )
162
- return stack
163
-
164
-
165
- # ---------------------------------------------------------------------------
166
- # Test 1 — short clip raises ValueError mentioning the minimum frame count
167
  # ---------------------------------------------------------------------------
168
 
169
 
170
  def test_raises_on_short_clip(tmp_path):
171
- """Clips with fewer than 24 frames must raise ValueError containing '24'."""
172
  dummy = tmp_path / "dummy.mp4"
173
  dummy.touch()
174
 
175
  cap = mock_capture(10)
176
-
177
  with patch("cv2.VideoCapture", return_value=cap):
178
  with pytest.raises(ValueError, match="24"):
179
  from src.envs.subenv2.node4_clip_extractor import extract_clip_signals
180
- extract_clip_signals(dummy, {})
181
-
182
 
183
- # ---------------------------------------------------------------------------
184
- # Test 2 — blur_score is in [0, 1] and result is ClipSignalObservation
185
- # ---------------------------------------------------------------------------
186
 
187
 
188
  def test_blur_score_in_range(tmp_path):
189
- """blur_score must be in [0.0, 1.0] and return type must be ClipSignalObservation."""
190
  dummy = tmp_path / "dummy.mp4"
191
  dummy.touch()
192
 
193
  cap30 = mock_capture(30)
194
- mp_mock = _make_mediapipe_mock(_make_face_mesh_mock())
195
 
196
- with _full_patch(cap30, mp_mock):
197
  from src.envs.subenv2.node4_clip_extractor import extract_clip_signals
 
198
  result = extract_clip_signals(dummy, _EMPTY_CTX)
199
 
200
  assert isinstance(result, ClipSignalObservation)
201
  assert 0.0 <= result.blur_score <= 1.0
202
 
203
 
204
- # ---------------------------------------------------------------------------
205
- # Test 3 — phoneme_coverage_new == 1.0 when dataset phoneme coverage is empty
206
- # ---------------------------------------------------------------------------
207
-
208
-
209
  def test_phoneme_coverage_new_empty_dataset(tmp_path):
210
- """All phonemes in the clip are new when the dataset coverage is empty."""
211
  dummy = tmp_path / "dummy.mp4"
212
  dummy.touch()
213
 
@@ -216,11 +117,12 @@ def test_phoneme_coverage_new_empty_dataset(tmp_path):
216
  aligner_json.write_text(json.dumps(aligner_data))
217
 
218
  cap30 = mock_capture(30)
219
- mp_mock = _make_mediapipe_mock(_make_face_mesh_mock())
220
  ctx = {**_EMPTY_CTX, "current_phoneme_coverage": {}}
221
 
222
- with _full_patch(cap30, mp_mock):
223
  from src.envs.subenv2.node4_clip_extractor import extract_clip_signals
 
224
  result = extract_clip_signals(
225
  dummy,
226
  ctx,
@@ -230,13 +132,7 @@ def test_phoneme_coverage_new_empty_dataset(tmp_path):
230
  assert result.phoneme_coverage_new == 1.0
231
 
232
 
233
- # ---------------------------------------------------------------------------
234
- # Test 4 — phoneme_coverage_new ≈ 2/3 when one phoneme already covered
235
- # ---------------------------------------------------------------------------
236
-
237
-
238
  def test_phoneme_coverage_new_partial(tmp_path):
239
- """When one of three unique phonemes is already covered, new coverage = 2/3."""
240
  dummy = tmp_path / "dummy.mp4"
241
  dummy.touch()
242
 
@@ -245,11 +141,12 @@ def test_phoneme_coverage_new_partial(tmp_path):
245
  aligner_json.write_text(json.dumps(aligner_data))
246
 
247
  cap30 = mock_capture(30)
248
- mp_mock = _make_mediapipe_mock(_make_face_mesh_mock())
249
  ctx = {**_EMPTY_CTX, "current_phoneme_coverage": {"AH": 3}}
250
 
251
- with _full_patch(cap30, mp_mock):
252
  from src.envs.subenv2.node4_clip_extractor import extract_clip_signals
 
253
  result = extract_clip_signals(
254
  dummy,
255
  ctx,
@@ -259,22 +156,32 @@ def test_phoneme_coverage_new_partial(tmp_path):
259
  assert abs(result.phoneme_coverage_new - 2 / 3) < 1e-6
260
 
261
 
262
- # ---------------------------------------------------------------------------
263
- # Test 5 — no forced-align path → empty phoneme sequence and zero lip sync
264
- # ---------------------------------------------------------------------------
265
-
266
-
267
  def test_no_forced_align_path(tmp_path):
268
- """When aligner_output is None, phoneme_sequence=[] and lip_sync_confidence=0.0."""
269
  dummy = tmp_path / "dummy.mp4"
270
  dummy.touch()
271
 
272
  cap30 = mock_capture(30)
273
- mp_mock = _make_mediapipe_mock(_make_face_mesh_mock())
274
 
275
- with _full_patch(cap30, mp_mock):
276
  from src.envs.subenv2.node4_clip_extractor import extract_clip_signals
 
277
  result = extract_clip_signals(dummy, _EMPTY_CTX, aligner_output=None)
278
 
279
  assert result.phoneme_sequence == []
280
  assert result.lip_sync_confidence == 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  Unit tests for Node 4: Clip Signal Extractor
3
  (src/envs/subenv2/node4_clip_extractor.py)
4
 
5
+ OpenCV VideoCapture and FaceLandmarker initialization are mocked so no real
6
+ video files or model assets are required.
 
7
  """
8
 
9
  from __future__ import annotations
10
 
11
  import json
 
12
  import types
13
  from pathlib import Path
14
  from unittest.mock import MagicMock, patch
 
27
 
28
 
29
  def _random_frame(height: int = 72, width: int = 128) -> np.ndarray:
 
30
  return _RNG.integers(0, 256, (height, width, 3), dtype=np.uint8)
31
 
32
 
 
 
 
 
 
33
  def mock_capture(frame_count: int, height: int = 72, width: int = 128) -> MagicMock:
 
 
 
 
 
 
 
 
 
 
 
34
  frames = [_random_frame(height, width) for _ in range(frame_count)]
35
  read_returns = [(True, f) for f in frames] + [(False, None)]
36
  read_iter = iter(read_returns)
37
 
38
  cap = MagicMock()
39
  cap.isOpened.return_value = True
 
 
 
 
 
 
 
 
 
40
  cap.read.side_effect = lambda: next(read_iter)
41
  cap.release.return_value = None
42
  return cap
43
 
44
 
45
+ def _make_landmarker_mock() -> MagicMock:
46
+ lm = types.SimpleNamespace(x=0.5, y=0.5, z=0.0)
47
+ landmarks = [lm] * 478
48
+ detect_result = types.SimpleNamespace(face_landmarks=[landmarks])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
 
50
+ landmarker = MagicMock()
51
+ landmarker.detect.return_value = detect_result
52
+ landmarker.close.return_value = None
53
+ return landmarker
54
 
 
 
 
55
 
56
+ def _full_patch(cap_mock: MagicMock, landmarker: MagicMock):
57
+ from contextlib import ExitStack
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
+ stack = ExitStack()
60
+ stack.enter_context(patch("cv2.VideoCapture", return_value=cap_mock))
61
+ stack.enter_context(
62
+ patch(
63
+ "src.envs.subenv2.node4_clip_extractor._create_face_landmarker",
64
+ return_value=landmarker,
65
+ )
66
+ )
67
+ return stack
68
 
 
 
 
69
 
70
  _EMPTY_CTX: dict = {
71
  "current_phoneme_coverage": {},
 
76
 
77
 
78
  # ---------------------------------------------------------------------------
79
+ # Tests
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  # ---------------------------------------------------------------------------
81
 
82
 
83
  def test_raises_on_short_clip(tmp_path):
 
84
  dummy = tmp_path / "dummy.mp4"
85
  dummy.touch()
86
 
87
  cap = mock_capture(10)
 
88
  with patch("cv2.VideoCapture", return_value=cap):
89
  with pytest.raises(ValueError, match="24"):
90
  from src.envs.subenv2.node4_clip_extractor import extract_clip_signals
 
 
91
 
92
+ extract_clip_signals(dummy, {})
 
 
93
 
94
 
95
  def test_blur_score_in_range(tmp_path):
 
96
  dummy = tmp_path / "dummy.mp4"
97
  dummy.touch()
98
 
99
  cap30 = mock_capture(30)
100
+ landmarker = _make_landmarker_mock()
101
 
102
+ with _full_patch(cap30, landmarker):
103
  from src.envs.subenv2.node4_clip_extractor import extract_clip_signals
104
+
105
  result = extract_clip_signals(dummy, _EMPTY_CTX)
106
 
107
  assert isinstance(result, ClipSignalObservation)
108
  assert 0.0 <= result.blur_score <= 1.0
109
 
110
 
 
 
 
 
 
111
  def test_phoneme_coverage_new_empty_dataset(tmp_path):
 
112
  dummy = tmp_path / "dummy.mp4"
113
  dummy.touch()
114
 
 
117
  aligner_json.write_text(json.dumps(aligner_data))
118
 
119
  cap30 = mock_capture(30)
120
+ landmarker = _make_landmarker_mock()
121
  ctx = {**_EMPTY_CTX, "current_phoneme_coverage": {}}
122
 
123
+ with _full_patch(cap30, landmarker):
124
  from src.envs.subenv2.node4_clip_extractor import extract_clip_signals
125
+
126
  result = extract_clip_signals(
127
  dummy,
128
  ctx,
 
132
  assert result.phoneme_coverage_new == 1.0
133
 
134
 
 
 
 
 
 
135
  def test_phoneme_coverage_new_partial(tmp_path):
 
136
  dummy = tmp_path / "dummy.mp4"
137
  dummy.touch()
138
 
 
141
  aligner_json.write_text(json.dumps(aligner_data))
142
 
143
  cap30 = mock_capture(30)
144
+ landmarker = _make_landmarker_mock()
145
  ctx = {**_EMPTY_CTX, "current_phoneme_coverage": {"AH": 3}}
146
 
147
+ with _full_patch(cap30, landmarker):
148
  from src.envs.subenv2.node4_clip_extractor import extract_clip_signals
149
+
150
  result = extract_clip_signals(
151
  dummy,
152
  ctx,
 
156
  assert abs(result.phoneme_coverage_new - 2 / 3) < 1e-6
157
 
158
 
 
 
 
 
 
159
  def test_no_forced_align_path(tmp_path):
 
160
  dummy = tmp_path / "dummy.mp4"
161
  dummy.touch()
162
 
163
  cap30 = mock_capture(30)
164
+ landmarker = _make_landmarker_mock()
165
 
166
+ with _full_patch(cap30, landmarker):
167
  from src.envs.subenv2.node4_clip_extractor import extract_clip_signals
168
+
169
  result = extract_clip_signals(dummy, _EMPTY_CTX, aligner_output=None)
170
 
171
  assert result.phoneme_sequence == []
172
  assert result.lip_sync_confidence == 0.0
173
+
174
+
175
+ def test_raises_when_landmarker_unavailable(tmp_path):
176
+ dummy = tmp_path / "dummy.mp4"
177
+ dummy.touch()
178
+
179
+ cap30 = mock_capture(30)
180
+ with patch("cv2.VideoCapture", return_value=cap30), patch(
181
+ "src.envs.subenv2.node4_clip_extractor._create_face_landmarker",
182
+ return_value=None,
183
+ ):
184
+ with pytest.raises(ValueError, match="FaceLandmarker model file"):
185
+ from src.envs.subenv2.node4_clip_extractor import extract_clip_signals
186
+
187
+ extract_clip_signals(dummy, _EMPTY_CTX)