lilblueyes commited on
Commit
57d04e5
·
1 Parent(s): 2eb805a

Import ASL video pipeline

Browse files
data/examples/.gitkeep ADDED
File without changes
data/models/asl/.gitkeep ADDED
File without changes
signspeak/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """SignSpeak local ASL-to-speech pipeline package."""
2
+
signspeak/asl/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """ASL video, landmark, and emotion processing helpers."""
2
+
3
+ from .pipeline import build_intent_input, process_asl_frames, process_asl_video
4
+
5
+ __all__ = ["build_intent_input", "process_asl_frames", "process_asl_video"]
6
+
signspeak/asl/asl_detector.py ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+ from .landmarks_detector import LandmarksDetector
9
+
10
+
11
+ class ASLDetector:
12
+ def __init__(self, model_dir: str | Path | None = None) -> None:
13
+ repo_root = Path(__file__).resolve().parents[2]
14
+ default_model_dir = repo_root / "data" / "models" / "asl"
15
+ configured_model_dir = model_dir or __import__("os").getenv("ASL_MODEL_DIR")
16
+ self.model_dir = Path(configured_model_dir) if configured_model_dir else default_model_dir
17
+ self.model_path = self.model_dir / "model.tflite"
18
+ self.train_csv_path = self.model_dir / "train.csv"
19
+ self.labels = self._load_labels()
20
+
21
+ def predict_from_frames(self, frames: list[np.ndarray]) -> dict[str, Any]:
22
+ detector = LandmarksDetector(missing_value=0.0)
23
+ try:
24
+ landmark_result = detector.detect_sequence(frames)
25
+ finally:
26
+ detector.close()
27
+
28
+ keypoints = landmark_result.keypoints
29
+ base = {
30
+ "status": "model_missing",
31
+ "gloss_sequence": [],
32
+ "top_prediction": None,
33
+ "confidence": 0.0,
34
+ "frames_used": len(frames),
35
+ "keypoints_shape": list(keypoints.shape),
36
+ "landmarks_status": landmark_result.status,
37
+ "landmarks_detector": landmark_result.detector,
38
+ }
39
+ if landmark_result.error:
40
+ base["landmarks_error"] = landmark_result.error
41
+
42
+ if not self.model_path.exists():
43
+ return base
44
+
45
+ try:
46
+ interpreter = self._load_interpreter()
47
+ interpreter.allocate_tensors()
48
+ input_details = interpreter.get_input_details()
49
+ output_details = interpreter.get_output_details()
50
+
51
+ input_data = self._prepare_input(keypoints, input_details[0])
52
+ interpreter.set_tensor(input_details[0]["index"], input_data)
53
+ interpreter.invoke()
54
+ output = interpreter.get_tensor(output_details[0]["index"])
55
+
56
+ probs = self._softmax_if_needed(np.asarray(output).reshape(-1))
57
+ top_idx = int(np.argmax(probs))
58
+ top_prediction = self._label_for_index(top_idx)
59
+ confidence = float(probs[top_idx])
60
+
61
+ base.update(
62
+ {
63
+ "status": "ok",
64
+ "gloss_sequence": [top_prediction] if top_prediction else [],
65
+ "top_prediction": top_prediction,
66
+ "confidence": confidence,
67
+ }
68
+ )
69
+ return base
70
+ except Exception as exc:
71
+ base.update(
72
+ {
73
+ "status": "inference_error",
74
+ "error": f"{type(exc).__name__}: {exc}",
75
+ }
76
+ )
77
+ return base
78
+
79
+ def _load_interpreter(self) -> Any:
80
+ try:
81
+ from tensorflow.lite.python.interpreter import Interpreter
82
+
83
+ return Interpreter(model_path=str(self.model_path))
84
+ except Exception:
85
+ from tflite_runtime.interpreter import Interpreter
86
+
87
+ return Interpreter(model_path=str(self.model_path))
88
+
89
+ def _prepare_input(self, keypoints: np.ndarray, input_detail: dict[str, Any]) -> np.ndarray:
90
+ shape = input_detail.get("shape")
91
+ dtype = input_detail.get("dtype", np.float32)
92
+ data = np.nan_to_num(keypoints, nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32)
93
+
94
+ if shape is None or len(shape) == 0:
95
+ return data.astype(dtype)
96
+
97
+ target_shape = [int(dim) for dim in shape]
98
+ if target_shape[0] == 1:
99
+ no_batch_shape = target_shape[1:]
100
+ else:
101
+ no_batch_shape = target_shape
102
+
103
+ prepared = self._fit_to_shape(data, no_batch_shape)
104
+ if target_shape[0] == 1:
105
+ prepared = np.expand_dims(prepared, axis=0)
106
+ return prepared.astype(dtype)
107
+
108
+ def _fit_to_shape(self, data: np.ndarray, target_shape: list[int]) -> np.ndarray:
109
+ if list(data.shape) == target_shape:
110
+ return data
111
+
112
+ flat = data.reshape(-1)
113
+ target_size = int(np.prod(target_shape))
114
+ fitted = np.zeros(target_size, dtype=np.float32)
115
+ copy_size = min(flat.size, target_size)
116
+ fitted[:copy_size] = flat[:copy_size]
117
+ return fitted.reshape(target_shape)
118
+
119
+ def _load_labels(self) -> list[str]:
120
+ if not self.train_csv_path.exists():
121
+ return []
122
+ try:
123
+ import pandas as pd
124
+
125
+ df = pd.read_csv(self.train_csv_path)
126
+ for column in ("sign", "label", "gloss", "target"):
127
+ if column in df.columns:
128
+ values = df[column].dropna().astype(str).unique().tolist()
129
+ return sorted(values)
130
+ except Exception:
131
+ return []
132
+ return []
133
+
134
+ def _label_for_index(self, index: int) -> str | None:
135
+ if 0 <= index < len(self.labels):
136
+ return self.labels[index]
137
+ return str(index)
138
+
139
+ def _softmax_if_needed(self, values: np.ndarray) -> np.ndarray:
140
+ if values.size == 0:
141
+ return np.asarray([0.0], dtype=np.float32)
142
+ if np.all(values >= 0) and np.isclose(np.sum(values), 1.0, atol=1e-3):
143
+ return values.astype(np.float32)
144
+ shifted = values - np.max(values)
145
+ exp = np.exp(shifted)
146
+ denom = np.sum(exp)
147
+ if denom <= 0:
148
+ return np.zeros_like(values, dtype=np.float32)
149
+ return (exp / denom).astype(np.float32)
signspeak/asl/emotion_detector.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from collections import defaultdict
5
+ from typing import Any
6
+
7
+ import cv2
8
+ import numpy as np
9
+
10
+
11
+ EMOTIONS = ("angry", "disgust", "fear", "happy", "sad", "surprise", "neutral")
12
+
13
+
14
+ def detect_emotion_on_frames(frames: list[np.ndarray]) -> dict[str, Any]:
15
+ if not frames:
16
+ return _error_result("No frames provided", frames_analyzed=0)
17
+
18
+ try:
19
+ os.environ.setdefault("TF_USE_LEGACY_KERAS", "1")
20
+ from deepface import DeepFace
21
+ except Exception as exc:
22
+ return _error_result(f"DeepFace import failed: {type(exc).__name__}: {exc}", frames_analyzed=0)
23
+
24
+ scores_sum: dict[str, float] = defaultdict(float)
25
+ frames_analyzed = 0
26
+ errors = []
27
+
28
+ for frame_rgb in frames:
29
+ try:
30
+ frame_bgr = cv2.cvtColor(frame_rgb, cv2.COLOR_RGB2BGR)
31
+ analysis = DeepFace.analyze(
32
+ img_path=frame_bgr,
33
+ actions=["emotion"],
34
+ enforce_detection=False,
35
+ silent=True,
36
+ )
37
+ if isinstance(analysis, list):
38
+ analysis = analysis[0] if analysis else {}
39
+ emotion = analysis.get("emotion", {}) if isinstance(analysis, dict) else {}
40
+ if not emotion:
41
+ continue
42
+
43
+ total = 0.0
44
+ normalized = {}
45
+ for key, value in emotion.items():
46
+ score = float(value)
47
+ normalized[key] = score
48
+ total += score
49
+ if total > 1.5:
50
+ normalized = {key: value / 100.0 for key, value in normalized.items()}
51
+
52
+ for key in EMOTIONS:
53
+ scores_sum[key] += float(normalized.get(key, 0.0))
54
+ frames_analyzed += 1
55
+ except Exception as exc:
56
+ errors.append(f"{type(exc).__name__}: {exc}")
57
+
58
+ if frames_analyzed == 0:
59
+ message = errors[0] if errors else "No emotion scores produced"
60
+ return _error_result(message, frames_analyzed=0)
61
+
62
+ averaged = {key: scores_sum[key] / frames_analyzed for key in EMOTIONS}
63
+ dominant = max(averaged, key=averaged.get)
64
+ intensity = float(np.clip(averaged[dominant], 0.0, 1.0))
65
+
66
+ result = {
67
+ "status": "ok",
68
+ "dominant_emotion": dominant,
69
+ "emotion_scores": averaged,
70
+ "intensity": intensity,
71
+ "frames_analyzed": frames_analyzed,
72
+ }
73
+ if errors:
74
+ result["frame_errors"] = errors[:3]
75
+ return result
76
+
77
+
78
+ def _error_result(error: str, frames_analyzed: int) -> dict[str, Any]:
79
+ return {
80
+ "status": "emotion_error",
81
+ "dominant_emotion": "unknown",
82
+ "emotion_scores": {key: 0.0 for key in EMOTIONS},
83
+ "intensity": 0.0,
84
+ "frames_analyzed": frames_analyzed,
85
+ "error": error,
86
+ }
signspeak/asl/landmarks_detector.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+
9
+ FACE_POINTS = 468
10
+ HAND_POINTS = 21
11
+ POSE_POINTS = 33
12
+ TOTAL_POINTS = FACE_POINTS + HAND_POINTS + POSE_POINTS + HAND_POINTS
13
+
14
+
15
+ @dataclass
16
+ class LandmarkDetectionResult:
17
+ keypoints: np.ndarray
18
+ detector: str
19
+ status: str
20
+ error: str | None = None
21
+
22
+
23
+ class LandmarksDetector:
24
+ """MediaPipe detector with Holistic first and component fallback."""
25
+
26
+ def __init__(self, missing_value: float = 0.0) -> None:
27
+ self.missing_value = missing_value
28
+ self.mp: Any | None = None
29
+ self.mode = "unavailable"
30
+ self.error: str | None = None
31
+ self._holistic = None
32
+ self._hands = None
33
+ self._face_mesh = None
34
+ self._pose = None
35
+ self._load_mediapipe()
36
+
37
+ def close(self) -> None:
38
+ for detector in (self._holistic, self._hands, self._face_mesh, self._pose):
39
+ if detector is not None:
40
+ detector.close()
41
+
42
+ def detect_sequence(self, frames: list[np.ndarray]) -> LandmarkDetectionResult:
43
+ if self.mp is None:
44
+ return LandmarkDetectionResult(
45
+ keypoints=self._empty_sequence(len(frames)),
46
+ detector=self.mode,
47
+ status="landmarks_unavailable",
48
+ error=self.error,
49
+ )
50
+
51
+ output = []
52
+ try:
53
+ for frame in frames:
54
+ output.append(self._detect_frame(frame))
55
+ return LandmarkDetectionResult(
56
+ keypoints=np.asarray(output, dtype=np.float32),
57
+ detector=self.mode,
58
+ status="ok",
59
+ )
60
+ except Exception as exc:
61
+ return LandmarkDetectionResult(
62
+ keypoints=self._empty_sequence(len(frames)),
63
+ detector=self.mode,
64
+ status="landmarks_error",
65
+ error=f"{type(exc).__name__}: {exc}",
66
+ )
67
+
68
+ def _load_mediapipe(self) -> None:
69
+ try:
70
+ import mediapipe as mp
71
+
72
+ self.mp = mp
73
+ except Exception as exc:
74
+ self.error = f"MediaPipe import failed: {type(exc).__name__}: {exc}"
75
+ return
76
+
77
+ try:
78
+ self._holistic = self.mp.solutions.holistic.Holistic(
79
+ static_image_mode=False,
80
+ model_complexity=1,
81
+ smooth_landmarks=True,
82
+ refine_face_landmarks=False,
83
+ min_detection_confidence=0.5,
84
+ min_tracking_confidence=0.5,
85
+ )
86
+ self.mode = "holistic"
87
+ return
88
+ except Exception as exc:
89
+ self.error = f"Holistic unavailable, using fallback if possible: {type(exc).__name__}: {exc}"
90
+
91
+ self._init_component_fallback()
92
+
93
+ def _init_component_fallback(self) -> bool:
94
+ if self.mp is None:
95
+ return False
96
+ if self._hands is not None and self._face_mesh is not None and self._pose is not None:
97
+ self.mode = "components"
98
+ return True
99
+
100
+ try:
101
+ self._hands = self.mp.solutions.hands.Hands(
102
+ static_image_mode=False,
103
+ max_num_hands=2,
104
+ min_detection_confidence=0.5,
105
+ min_tracking_confidence=0.5,
106
+ )
107
+ self._face_mesh = self.mp.solutions.face_mesh.FaceMesh(
108
+ static_image_mode=False,
109
+ max_num_faces=1,
110
+ refine_landmarks=False,
111
+ min_detection_confidence=0.5,
112
+ min_tracking_confidence=0.5,
113
+ )
114
+ self._pose = self.mp.solutions.pose.Pose(
115
+ static_image_mode=False,
116
+ model_complexity=1,
117
+ smooth_landmarks=True,
118
+ min_detection_confidence=0.5,
119
+ min_tracking_confidence=0.5,
120
+ )
121
+ self.mode = "components"
122
+ return True
123
+ except Exception as exc:
124
+ self.mode = "unavailable"
125
+ self.error = f"MediaPipe component fallback failed: {type(exc).__name__}: {exc}"
126
+ return False
127
+
128
+ def _detect_frame(self, frame_rgb: np.ndarray) -> np.ndarray:
129
+ if self.mode == "holistic":
130
+ try:
131
+ results = self._holistic.process(frame_rgb)
132
+ return self._from_holistic(results)
133
+ except Exception as exc:
134
+ self.error = f"Holistic process failed, switched to components: {type(exc).__name__}: {exc}"
135
+ if self._holistic is not None:
136
+ self._holistic.close()
137
+ self._holistic = None
138
+ if self._init_component_fallback():
139
+ return self._from_components(frame_rgb)
140
+ raise
141
+ if self.mode == "components":
142
+ return self._from_components(frame_rgb)
143
+ return self._empty_frame()
144
+
145
+ def _from_holistic(self, results: Any) -> np.ndarray:
146
+ face = self._landmark_array(getattr(results, "face_landmarks", None), FACE_POINTS)
147
+ left = self._landmark_array(getattr(results, "left_hand_landmarks", None), HAND_POINTS)
148
+ pose = self._landmark_array(getattr(results, "pose_landmarks", None), POSE_POINTS)
149
+ right = self._landmark_array(getattr(results, "right_hand_landmarks", None), HAND_POINTS)
150
+ return np.vstack([face, left, pose, right]).astype(np.float32)
151
+
152
+ def _from_components(self, frame_rgb: np.ndarray) -> np.ndarray:
153
+ face_results = self._face_mesh.process(frame_rgb) if self._face_mesh else None
154
+ pose_results = self._pose.process(frame_rgb) if self._pose else None
155
+ hands_results = self._hands.process(frame_rgb) if self._hands else None
156
+
157
+ face_landmarks = None
158
+ if getattr(face_results, "multi_face_landmarks", None):
159
+ face_landmarks = face_results.multi_face_landmarks[0]
160
+
161
+ left_landmarks = None
162
+ right_landmarks = None
163
+ if getattr(hands_results, "multi_hand_landmarks", None):
164
+ handedness = getattr(hands_results, "multi_handedness", []) or []
165
+ for hand_lms, hand_info in zip(hands_results.multi_hand_landmarks, handedness):
166
+ label = hand_info.classification[0].label.lower()
167
+ if label == "left":
168
+ left_landmarks = hand_lms
169
+ elif label == "right":
170
+ right_landmarks = hand_lms
171
+
172
+ face = self._landmark_array(face_landmarks, FACE_POINTS)
173
+ left = self._landmark_array(left_landmarks, HAND_POINTS)
174
+ pose = self._landmark_array(getattr(pose_results, "pose_landmarks", None), POSE_POINTS)
175
+ right = self._landmark_array(right_landmarks, HAND_POINTS)
176
+ return np.vstack([face, left, pose, right]).astype(np.float32)
177
+
178
+ def _landmark_array(self, landmark_list: Any, expected_points: int) -> np.ndarray:
179
+ empty = np.full((expected_points, 3), self.missing_value, dtype=np.float32)
180
+ if landmark_list is None or not getattr(landmark_list, "landmark", None):
181
+ return empty
182
+
183
+ points = landmark_list.landmark[:expected_points]
184
+ for idx, point in enumerate(points):
185
+ empty[idx] = [point.x, point.y, point.z]
186
+ return empty
187
+
188
+ def _empty_frame(self) -> np.ndarray:
189
+ return np.full((TOTAL_POINTS, 3), self.missing_value, dtype=np.float32)
190
+
191
+ def _empty_sequence(self, frame_count: int) -> np.ndarray:
192
+ return np.full((frame_count, TOTAL_POINTS, 3), self.missing_value, dtype=np.float32)
signspeak/asl/pipeline.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+ from .asl_detector import ASLDetector
9
+ from .emotion_detector import detect_emotion_on_frames
10
+ from .video_utils import sample_video_frames, sample_video_frames_for_emotion
11
+
12
+
13
+ def process_asl_video(video_path: str | Path) -> dict[str, Any]:
14
+ path = Path(video_path)
15
+ asl_frames = sample_video_frames(path, target_frames=30)
16
+ emotion_frames = sample_video_frames_for_emotion(path, target_frames=12)
17
+
18
+ return process_asl_frames(asl_frames, emotion_frames, source=str(path))
19
+
20
+
21
+ def process_asl_frames(
22
+ asl_frames: list[np.ndarray],
23
+ emotion_frames: list[np.ndarray] | None = None,
24
+ *,
25
+ source: str = "frames",
26
+ ) -> dict[str, Any]:
27
+ emotion_frames = emotion_frames if emotion_frames is not None else asl_frames[:12]
28
+ asl = ASLDetector().predict_from_frames(asl_frames)
29
+ emotion = detect_emotion_on_frames(emotion_frames)
30
+ intent_input = build_intent_input(asl, emotion)
31
+
32
+ return {
33
+ "source": source,
34
+ "asl": {
35
+ "status": asl.get("status", "unknown"),
36
+ "gloss_sequence": asl.get("gloss_sequence", []),
37
+ "top_prediction": asl.get("top_prediction"),
38
+ "confidence": float(asl.get("confidence", 0.0) or 0.0),
39
+ "frames_used": int(asl.get("frames_used", len(asl_frames)) or 0),
40
+ "keypoints_shape": asl.get("keypoints_shape", []),
41
+ "landmarks_status": asl.get("landmarks_status"),
42
+ "landmarks_detector": asl.get("landmarks_detector"),
43
+ **_optional_error_fields(asl, ("error", "landmarks_error")),
44
+ },
45
+ "emotion": emotion,
46
+ "intent_input": intent_input,
47
+ }
48
+
49
+
50
+ def build_intent_input(asl: dict[str, Any], emotion: dict[str, Any]) -> dict[str, Any]:
51
+ glosses = asl.get("gloss_sequence", []) or []
52
+ dominant_emotion = emotion.get("dominant_emotion", "unknown")
53
+ return {
54
+ "detected_glosses": glosses,
55
+ "detected_facial_expression": dominant_emotion,
56
+ "emotion_profile": {
57
+ "dominant": dominant_emotion,
58
+ "confidence": float(emotion.get("intensity", 0.0) or 0.0),
59
+ "scores": emotion.get("emotion_scores", {}),
60
+ },
61
+ "communication_intent": "derived_from_asl_video",
62
+ "sign_confidence": float(asl.get("confidence", 0.0) or 0.0),
63
+ "pipeline_stage": "asl_video_to_llama_cpp_intent",
64
+ "diagnostics": {
65
+ "asl_status": asl.get("status", "unknown"),
66
+ "emotion_status": emotion.get("status", "unknown"),
67
+ "frames_used": int(asl.get("frames_used", 0) or 0),
68
+ },
69
+ }
70
+
71
+
72
+ def _optional_error_fields(data: dict[str, Any], keys: tuple[str, ...]) -> dict[str, Any]:
73
+ return {key: data[key] for key in keys if key in data and data[key]}
signspeak/asl/video_utils.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ def sample_video_frames(video_path: str | Path, target_frames: int = 30) -> list:
7
+ """Sample RGB frames uniformly from a video."""
8
+ return _sample_video_frames(video_path, target_frames)
9
+
10
+
11
+ def sample_video_frames_for_emotion(video_path: str | Path, target_frames: int = 12) -> list:
12
+ """Sample fewer RGB frames for temporal emotion aggregation."""
13
+ return _sample_video_frames(video_path, target_frames)
14
+
15
+
16
+ def _sample_video_frames(video_path: str | Path, target_frames: int) -> list:
17
+ cv2 = _load_cv2()
18
+ path = Path(video_path)
19
+ if target_frames <= 0:
20
+ return []
21
+ if not path.exists():
22
+ raise FileNotFoundError(f"Video not found: {path}")
23
+
24
+ cap = cv2.VideoCapture(str(path))
25
+ if not cap.isOpened():
26
+ raise ValueError(f"Could not open video: {path}")
27
+
28
+ try:
29
+ frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
30
+ if frame_count <= 0:
31
+ return _sample_unknown_length(cap, target_frames)
32
+
33
+ if frame_count <= target_frames:
34
+ indices = list(range(frame_count))
35
+ else:
36
+ step = (frame_count - 1) / float(target_frames - 1) if target_frames > 1 else 0
37
+ indices = [round(i * step) for i in range(target_frames)]
38
+
39
+ frames = []
40
+ for idx in indices:
41
+ cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
42
+ ok, frame_bgr = cap.read()
43
+ if ok and frame_bgr is not None:
44
+ frames.append(cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB))
45
+
46
+ if not frames:
47
+ raise ValueError(f"No readable frames found in: {path}")
48
+ return frames
49
+ finally:
50
+ cap.release()
51
+
52
+
53
+ def _sample_unknown_length(cap, target_frames: int) -> list:
54
+ cv2 = _load_cv2()
55
+ frames = []
56
+ while True:
57
+ ok, frame_bgr = cap.read()
58
+ if not ok or frame_bgr is None:
59
+ break
60
+ frames.append(cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB))
61
+
62
+ if not frames:
63
+ return []
64
+ if len(frames) <= target_frames:
65
+ return frames
66
+
67
+ step = (len(frames) - 1) / float(target_frames - 1) if target_frames > 1 else 0
68
+ return [frames[round(i * step)] for i in range(target_frames)]
69
+
70
+
71
+ def _load_cv2():
72
+ try:
73
+ import cv2
74
+
75
+ return cv2
76
+ except Exception as exc:
77
+ raise RuntimeError(
78
+ "OpenCV is required for video sampling. Install opencv-python-headless "
79
+ "or opencv-contrib-python to enable this brick."
80
+ ) from exc