davinci-resolve-mcp 2.60.0 → 2.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,347 @@
1
+ """Face strata — blink events + gaze/expression curves (optional capability).
2
+
3
+ Requires mediapipe + opencv (``pip install mediapipe opencv-python``). Both
4
+ are optional extras: when absent this module still imports, reports itself
5
+ unavailable, and run_face_strata refuses honestly. Nothing else in the
6
+ strata stack depends on it — cut_candidates simply notes the blink track as
7
+ missing.
8
+
9
+ What it writes (geometric measurements, not emotion classification):
10
+ - events ``blink`` — eye-aspect-ratio dip (Soukupová/Čech EAR)
11
+ - curves ``gaze_x`` / ``gaze_y`` — iris offset within the eye box, -1..1
12
+ - curves ``expression_mouth_open`` — mouth aspect ratio 0..~1
13
+ - curves ``expression_brow_raise`` — brow-to-eye distance, face-height units
14
+
15
+ The compute layer is pure (landmark series in, tracks out) so blink/gaze
16
+ logic is unit-testable without mediapipe; only the capture loop needs it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import math
23
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
24
+
25
+ from src.utils import strata, timeline_brain_db
26
+
27
+ logger = logging.getLogger("resolve-mcp.strata-faces")
28
+
29
+ FACE_SOURCE = "face_v1"
30
+ FACE_VERSION = "1.0.0"
31
+
32
+ FACE_CURVE_RATE_DEFAULT = 12.0 # analysis fps; blinks need >=10 to be caught
33
+
34
+ # Broad excepts on purpose: mediapipe (and cv2) can raise non-ImportError
35
+ # exceptions at import time (e.g. protobuf version mismatches raise TypeError).
36
+ # A broken optional dependency must degrade to "face analyzer unavailable",
37
+ # never crash strata_status / strata_run.
38
+ try: # pragma: no cover - environment-dependent
39
+ import cv2 as _cv2
40
+ except Exception as _exc: # pragma: no cover
41
+ logger.debug("cv2 unavailable: %s", _exc)
42
+ _cv2 = None
43
+
44
+ try: # pragma: no cover - environment-dependent
45
+ import mediapipe as _mp
46
+ except Exception as _exc: # pragma: no cover
47
+ logger.debug("mediapipe unavailable: %s", _exc)
48
+ _mp = None
49
+
50
+ # FaceMesh landmark indices (canonical mediapipe topology).
51
+ _LEFT_EYE = {"outer": 33, "inner": 133, "top1": 160, "top2": 158, "bot1": 144, "bot2": 153}
52
+ _RIGHT_EYE = {"outer": 362, "inner": 263, "top1": 385, "top2": 387, "bot1": 380, "bot2": 373}
53
+ _LEFT_IRIS = (468, 469, 470, 471, 472)
54
+ _RIGHT_IRIS = (473, 474, 475, 476, 477)
55
+ _MOUTH = {"left": 61, "right": 291, "top": 13, "bottom": 14}
56
+ _BROW = {"left": 105, "right": 334}
57
+ _FACE_BOX = {"top": 10, "bottom": 152}
58
+
59
+ EAR_BLINK_THRESHOLD = 0.21
60
+ EAR_MIN_CLOSED_FRAMES = 1
61
+ EAR_MAX_CLOSED_SECONDS = 0.5 # longer than this is eyes-closed, not a blink
62
+
63
+
64
+ def capabilities() -> Dict[str, Any]:
65
+ available = _cv2 is not None and _mp is not None
66
+ return {
67
+ "available": available,
68
+ "requires": ["opencv-python", "mediapipe"],
69
+ "missing": [
70
+ name
71
+ for name, mod in (("opencv-python", _cv2), ("mediapipe", _mp))
72
+ if mod is None
73
+ ],
74
+ "writes": {
75
+ "events": ["blink"],
76
+ "curves": ["gaze_x", "gaze_y", "expression_mouth_open", "expression_brow_raise"],
77
+ },
78
+ }
79
+
80
+
81
+ # ── pure compute: landmark series → tracks ───────────────────────────────────
82
+
83
+
84
+ def _dist(a: Tuple[float, float], b: Tuple[float, float]) -> float:
85
+ return math.hypot(a[0] - b[0], a[1] - b[1])
86
+
87
+
88
+ def eye_aspect_ratio(pts: Dict[str, Tuple[float, float]]) -> float:
89
+ """EAR = (‖top1−bot1‖ + ‖top2−bot2‖) / (2·‖outer−inner‖)."""
90
+ horiz = _dist(pts["outer"], pts["inner"])
91
+ if horiz <= 0:
92
+ return 0.0
93
+ return (_dist(pts["top1"], pts["bot1"]) + _dist(pts["top2"], pts["bot2"])) / (2.0 * horiz)
94
+
95
+
96
+ def detect_blinks(
97
+ ear_series: Sequence[Optional[float]],
98
+ rate_hz: float,
99
+ threshold: float = EAR_BLINK_THRESHOLD,
100
+ ) -> List[Dict[str, Any]]:
101
+ """Blink events from an EAR time series (None = no face that frame).
102
+
103
+ A blink is a run of frames with EAR below threshold, at least
104
+ EAR_MIN_CLOSED_FRAMES long and shorter than EAR_MAX_CLOSED_SECONDS
105
+ (longer runs are eyes-closed, recorded with kind='eyes_closed').
106
+ """
107
+ blinks: List[Dict[str, Any]] = []
108
+ run_start: Optional[int] = None
109
+ max_frames = int(EAR_MAX_CLOSED_SECONDS * rate_hz)
110
+
111
+ def flush(end_index: int) -> None:
112
+ nonlocal run_start
113
+ if run_start is None:
114
+ return
115
+ length = end_index - run_start
116
+ if length >= EAR_MIN_CLOSED_FRAMES:
117
+ event = {
118
+ "time_seconds": run_start / rate_hz,
119
+ "duration_seconds": length / rate_hz,
120
+ "payload": {"kind": "blink" if length <= max_frames else "eyes_closed"},
121
+ }
122
+ blinks.append(event)
123
+ run_start = None
124
+
125
+ for i, ear in enumerate(ear_series):
126
+ below = ear is not None and ear < threshold
127
+ if below and run_start is None:
128
+ run_start = i
129
+ elif not below:
130
+ flush(i)
131
+ flush(len(ear_series))
132
+ return blinks
133
+
134
+
135
+ def iris_offset(
136
+ eye_pts: Dict[str, Tuple[float, float]],
137
+ iris_center: Tuple[float, float],
138
+ ) -> Tuple[float, float]:
139
+ """Iris position inside the eye box, each axis -1..1 (0 = centered)."""
140
+ cx = (eye_pts["outer"][0] + eye_pts["inner"][0]) / 2.0
141
+ cy = (eye_pts["top1"][1] + eye_pts["top2"][1] + eye_pts["bot1"][1] + eye_pts["bot2"][1]) / 4.0
142
+ half_w = abs(eye_pts["inner"][0] - eye_pts["outer"][0]) / 2.0 or 1.0
143
+ half_h = (
144
+ abs((eye_pts["bot1"][1] + eye_pts["bot2"][1]) / 2.0 - (eye_pts["top1"][1] + eye_pts["top2"][1]) / 2.0)
145
+ ) / 2.0 or 1.0
146
+ return (
147
+ max(-1.0, min(1.0, (iris_center[0] - cx) / half_w)),
148
+ max(-1.0, min(1.0, (iris_center[1] - cy) / half_h)),
149
+ )
150
+
151
+
152
+ def landmarks_to_tracks(
153
+ frames: Sequence[Optional[Dict[str, Any]]],
154
+ rate_hz: float,
155
+ ) -> Dict[str, Any]:
156
+ """Per-frame landmark dicts → blink events + gaze/expression curves.
157
+
158
+ Each frame entry (or None when no face): {
159
+ left_eye/right_eye: {outer,inner,top1,top2,bot1,bot2: (x,y)},
160
+ left_iris/right_iris: (x,y),
161
+ mouth: {left,right,top,bottom: (x,y)},
162
+ brow: {left,right: (x,y)}, face: {top,bottom: (x,y)},
163
+ }
164
+ """
165
+ nan = float("nan")
166
+ ear_series: List[Optional[float]] = []
167
+ gaze_x: List[float] = []
168
+ gaze_y: List[float] = []
169
+ mouth_open: List[float] = []
170
+ brow_raise: List[float] = []
171
+
172
+ for frame in frames:
173
+ if not frame:
174
+ ear_series.append(None)
175
+ gaze_x.append(nan)
176
+ gaze_y.append(nan)
177
+ mouth_open.append(nan)
178
+ brow_raise.append(nan)
179
+ continue
180
+ ears = []
181
+ offsets = []
182
+ for eye_key, iris_key in (("left_eye", "left_iris"), ("right_eye", "right_iris")):
183
+ eye = frame.get(eye_key)
184
+ if not eye:
185
+ continue
186
+ ears.append(eye_aspect_ratio(eye))
187
+ iris = frame.get(iris_key)
188
+ if iris:
189
+ offsets.append(iris_offset(eye, iris))
190
+ ear_series.append(sum(ears) / len(ears) if ears else None)
191
+ if offsets:
192
+ gaze_x.append(sum(o[0] for o in offsets) / len(offsets))
193
+ gaze_y.append(sum(o[1] for o in offsets) / len(offsets))
194
+ else:
195
+ gaze_x.append(nan)
196
+ gaze_y.append(nan)
197
+
198
+ mouth = frame.get("mouth")
199
+ if mouth:
200
+ width = _dist(mouth["left"], mouth["right"]) or 1.0
201
+ mouth_open.append(_dist(mouth["top"], mouth["bottom"]) / width)
202
+ else:
203
+ mouth_open.append(nan)
204
+
205
+ brow = frame.get("brow")
206
+ face = frame.get("face")
207
+ eye = frame.get("left_eye") or frame.get("right_eye")
208
+ if brow and face and eye:
209
+ face_h = _dist(face["top"], face["bottom"]) or 1.0
210
+ eye_top_y = (eye["top1"][1] + eye["top2"][1]) / 2.0
211
+ brow_y = (brow["left"][1] + brow["right"][1]) / 2.0
212
+ brow_raise.append(abs(eye_top_y - brow_y) / face_h)
213
+ else:
214
+ brow_raise.append(nan)
215
+
216
+ return {
217
+ "blinks": detect_blinks(ear_series, rate_hz),
218
+ "curves": {
219
+ "gaze_x": gaze_x,
220
+ "gaze_y": gaze_y,
221
+ "expression_mouth_open": mouth_open,
222
+ "expression_brow_raise": brow_raise,
223
+ },
224
+ "face_frame_count": sum(1 for e in ear_series if e is not None),
225
+ "frame_count": len(frames),
226
+ }
227
+
228
+
229
+ # ── capture loop (mediapipe/cv2 required) ────────────────────────────────────
230
+
231
+
232
+ def _landmark_frame(landmarks, width: int, height: int) -> Dict[str, Any]:
233
+ def pt(idx: int) -> Tuple[float, float]:
234
+ lm = landmarks[idx]
235
+ return (lm.x * width, lm.y * height)
236
+
237
+ def group(spec: Dict[str, int]) -> Dict[str, Tuple[float, float]]:
238
+ return {name: pt(idx) for name, idx in spec.items()}
239
+
240
+ frame: Dict[str, Any] = {
241
+ "left_eye": group(_LEFT_EYE),
242
+ "right_eye": group(_RIGHT_EYE),
243
+ "mouth": group(_MOUTH),
244
+ "brow": group(_BROW),
245
+ "face": group(_FACE_BOX),
246
+ }
247
+ if len(landmarks) > _RIGHT_IRIS[-1]:
248
+ for key, indices in (("left_iris", _LEFT_IRIS), ("right_iris", _RIGHT_IRIS)):
249
+ xs = [pt(i)[0] for i in indices]
250
+ ys = [pt(i)[1] for i in indices]
251
+ frame[key] = (sum(xs) / len(xs), sum(ys) / len(ys))
252
+ return frame
253
+
254
+
255
+ def extract_landmark_frames(
256
+ video_path: str,
257
+ rate_hz: float = FACE_CURVE_RATE_DEFAULT,
258
+ max_seconds: Optional[float] = None,
259
+ ) -> Tuple[List[Optional[Dict[str, Any]]], float]:
260
+ """Sample the video at ~rate_hz and run FaceMesh per sampled frame."""
261
+ if _cv2 is None or _mp is None: # pragma: no cover
262
+ raise RuntimeError("face strata require opencv-python + mediapipe")
263
+ cap = _cv2.VideoCapture(video_path)
264
+ if not cap.isOpened():
265
+ raise RuntimeError(f"could not open video: {video_path}")
266
+ native_fps = cap.get(_cv2.CAP_PROP_FPS) or 24.0
267
+ step = max(1, int(round(native_fps / rate_hz)))
268
+ effective_rate = native_fps / step
269
+ frames: List[Optional[Dict[str, Any]]] = []
270
+ with _mp.solutions.face_mesh.FaceMesh(
271
+ static_image_mode=False,
272
+ max_num_faces=1,
273
+ refine_landmarks=True,
274
+ min_detection_confidence=0.5,
275
+ min_tracking_confidence=0.5,
276
+ ) as mesh:
277
+ index = 0
278
+ while True:
279
+ ok, image = cap.read()
280
+ if not ok:
281
+ break
282
+ if index % step == 0:
283
+ if max_seconds is not None and (index / native_fps) > max_seconds:
284
+ break
285
+ result = mesh.process(_cv2.cvtColor(image, _cv2.COLOR_BGR2RGB))
286
+ if result.multi_face_landmarks:
287
+ height, width = image.shape[:2]
288
+ frames.append(
289
+ _landmark_frame(result.multi_face_landmarks[0].landmark, width, height)
290
+ )
291
+ else:
292
+ frames.append(None)
293
+ index += 1
294
+ cap.release()
295
+ return frames, effective_rate
296
+
297
+
298
+ def run_face_strata(
299
+ project_root: str,
300
+ clip_ref: Any,
301
+ *,
302
+ rate_hz: float = FACE_CURVE_RATE_DEFAULT,
303
+ max_seconds: Optional[float] = None,
304
+ ) -> Dict[str, Any]:
305
+ """Compute + persist face strata for one clip (refuses without deps)."""
306
+ caps = capabilities()
307
+ if not caps["available"]:
308
+ return {
309
+ "success": False,
310
+ "error": f"face strata unavailable — install: {', '.join(caps['missing'])}",
311
+ "missing": caps["missing"],
312
+ }
313
+ from src.utils.strata_analyzers import _clip_row
314
+
315
+ clip, err = _clip_row(project_root, clip_ref)
316
+ if err:
317
+ return err
318
+ try:
319
+ frames, effective_rate = extract_landmark_frames(
320
+ clip["file_path"], rate_hz=rate_hz, max_seconds=max_seconds
321
+ )
322
+ except RuntimeError as exc:
323
+ return {"success": False, "error": str(exc), "clip_uuid": clip["clip_uuid"]}
324
+ if not frames:
325
+ return {"success": False, "error": "no video frames sampled", "clip_uuid": clip["clip_uuid"]}
326
+
327
+ tracks = landmarks_to_tracks(frames, effective_rate)
328
+ with timeline_brain_db.transaction(project_root) as txn:
329
+ strata.replace_track_events(
330
+ txn, clip["clip_uuid"], "blink", tracks["blinks"],
331
+ source=FACE_SOURCE, analyzer_version=FACE_VERSION,
332
+ )
333
+ for track_name, values in tracks["curves"].items():
334
+ strata.write_curve(
335
+ txn, clip["clip_uuid"], track_name, values,
336
+ sample_rate=effective_rate, source=FACE_SOURCE, analyzer_version=FACE_VERSION,
337
+ )
338
+ return {
339
+ "success": True,
340
+ "clip_uuid": clip["clip_uuid"],
341
+ "clip_name": clip["clip_name"],
342
+ "sample_rate": round(effective_rate, 3),
343
+ "frames_sampled": tracks["frame_count"],
344
+ "frames_with_face": tracks["face_frame_count"],
345
+ "blink_count": len(tracks["blinks"]),
346
+ "analyzer_version": FACE_VERSION,
347
+ }