pyfaceau 1.0.3__cp310-cp310-win_amd64.whl
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.
- pyfaceau/__init__.py +19 -0
- pyfaceau/alignment/__init__.py +0 -0
- pyfaceau/alignment/calc_params.py +671 -0
- pyfaceau/alignment/face_aligner.py +352 -0
- pyfaceau/alignment/numba_calcparams_accelerator.py +244 -0
- pyfaceau/cython_histogram_median.cp310-win_amd64.pyd +0 -0
- pyfaceau/cython_rotation_update.cp310-win_amd64.pyd +0 -0
- pyfaceau/detectors/__init__.py +0 -0
- pyfaceau/detectors/pfld.py +128 -0
- pyfaceau/detectors/retinaface.py +352 -0
- pyfaceau/download_weights.py +134 -0
- pyfaceau/features/__init__.py +0 -0
- pyfaceau/features/histogram_median_tracker.py +335 -0
- pyfaceau/features/pdm.py +269 -0
- pyfaceau/features/triangulation.py +64 -0
- pyfaceau/parallel_pipeline.py +462 -0
- pyfaceau/pipeline.py +1083 -0
- pyfaceau/prediction/__init__.py +0 -0
- pyfaceau/prediction/au_predictor.py +434 -0
- pyfaceau/prediction/batched_au_predictor.py +269 -0
- pyfaceau/prediction/model_parser.py +337 -0
- pyfaceau/prediction/running_median.py +318 -0
- pyfaceau/prediction/running_median_fallback.py +200 -0
- pyfaceau/processor.py +270 -0
- pyfaceau/refinement/__init__.py +12 -0
- pyfaceau/refinement/svr_patch_expert.py +361 -0
- pyfaceau/refinement/targeted_refiner.py +362 -0
- pyfaceau/utils/__init__.py +0 -0
- pyfaceau/utils/cython_extensions/cython_histogram_median.c +35391 -0
- pyfaceau/utils/cython_extensions/cython_histogram_median.pyx +316 -0
- pyfaceau/utils/cython_extensions/cython_rotation_update.c +32262 -0
- pyfaceau/utils/cython_extensions/cython_rotation_update.pyx +211 -0
- pyfaceau/utils/cython_extensions/setup.py +47 -0
- pyfaceau-1.0.3.data/scripts/pyfaceau_gui.py +302 -0
- pyfaceau-1.0.3.dist-info/METADATA +466 -0
- pyfaceau-1.0.3.dist-info/RECORD +40 -0
- pyfaceau-1.0.3.dist-info/WHEEL +5 -0
- pyfaceau-1.0.3.dist-info/entry_points.txt +3 -0
- pyfaceau-1.0.3.dist-info/licenses/LICENSE +40 -0
- pyfaceau-1.0.3.dist-info/top_level.txt +1 -0
pyfaceau/pipeline.py
ADDED
|
@@ -0,0 +1,1083 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Full Python AU Extraction Pipeline - End-to-End
|
|
4
|
+
|
|
5
|
+
This script integrates all Python components into a complete AU extraction pipeline:
|
|
6
|
+
1. Face Detection (RetinaFace ONNX)
|
|
7
|
+
2. Landmark Detection (Cunjian PFLD)
|
|
8
|
+
3. 3D Pose Estimation (CalcParams or simplified PnP)
|
|
9
|
+
4. Face Alignment (OpenFace 2.2 algorithm)
|
|
10
|
+
5. HOG Feature Extraction (PyFHOG)
|
|
11
|
+
6. Geometric Feature Extraction (PDM)
|
|
12
|
+
7. Running Median Tracking (Cython-optimized)
|
|
13
|
+
8. AU Prediction (SVR models)
|
|
14
|
+
|
|
15
|
+
No C++ OpenFace binary required - 100% Python!
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python full_python_au_pipeline.py --video input.mp4 --output results.csv
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import pandas as pd
|
|
23
|
+
import cv2
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Dict, List, Optional, Tuple
|
|
26
|
+
import argparse
|
|
27
|
+
import sys
|
|
28
|
+
|
|
29
|
+
# Import all pipeline components
|
|
30
|
+
from pyfaceau.detectors.retinaface import ONNXRetinaFaceDetector
|
|
31
|
+
from pyfaceau.detectors.pfld import CunjianPFLDDetector
|
|
32
|
+
from pyfaceau.alignment.calc_params import CalcParams
|
|
33
|
+
from pyfaceau.features.pdm import PDMParser
|
|
34
|
+
from pyfaceau.alignment.face_aligner import OpenFace22FaceAligner
|
|
35
|
+
from pyfaceau.features.triangulation import TriangulationParser
|
|
36
|
+
from pyfaceau.prediction.model_parser import OF22ModelParser
|
|
37
|
+
from pyfaceau.refinement.targeted_refiner import TargetedCLNFRefiner
|
|
38
|
+
|
|
39
|
+
# Import Cython-optimized running median (with fallback)
|
|
40
|
+
try:
|
|
41
|
+
from cython_histogram_median import DualHistogramMedianTrackerCython as DualHistogramMedianTracker
|
|
42
|
+
USING_CYTHON = True
|
|
43
|
+
except ImportError:
|
|
44
|
+
from pyfaceau.features.histogram_median_tracker import DualHistogramMedianTracker
|
|
45
|
+
USING_CYTHON = False
|
|
46
|
+
|
|
47
|
+
# Import optimized batched AU predictor
|
|
48
|
+
try:
|
|
49
|
+
from pyfaceau.prediction.batched_au_predictor import BatchedAUPredictor
|
|
50
|
+
USING_BATCHED_PREDICTOR = True
|
|
51
|
+
except ImportError:
|
|
52
|
+
USING_BATCHED_PREDICTOR = False
|
|
53
|
+
|
|
54
|
+
# Import PyFHOG for HOG extraction
|
|
55
|
+
# Try different paths where pyfhog might be installed
|
|
56
|
+
try:
|
|
57
|
+
import pyfhog
|
|
58
|
+
except ImportError:
|
|
59
|
+
# Try parent directory (for development)
|
|
60
|
+
import sys
|
|
61
|
+
pyfhog_src_path = Path(__file__).parent.parent / 'pyfhog' / 'src'
|
|
62
|
+
if pyfhog_src_path.exists():
|
|
63
|
+
sys.path.insert(0, str(pyfhog_src_path))
|
|
64
|
+
import pyfhog
|
|
65
|
+
else:
|
|
66
|
+
print("Error: pyfhog not found. Please install it:")
|
|
67
|
+
print(" cd ../pyfhog && pip install -e .")
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class FullPythonAUPipeline:
|
|
72
|
+
"""
|
|
73
|
+
Complete Python AU extraction pipeline
|
|
74
|
+
|
|
75
|
+
Integrates face detection, landmark detection, pose estimation,
|
|
76
|
+
alignment, feature extraction, and AU prediction into a single
|
|
77
|
+
end-to-end pipeline.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
retinaface_model: str,
|
|
83
|
+
pfld_model: str,
|
|
84
|
+
pdm_file: str,
|
|
85
|
+
au_models_dir: str,
|
|
86
|
+
triangulation_file: str,
|
|
87
|
+
patch_expert_file: Optional[str] = None,
|
|
88
|
+
use_calc_params: bool = True,
|
|
89
|
+
use_coreml: bool = True,
|
|
90
|
+
track_faces: bool = True,
|
|
91
|
+
use_batched_predictor: bool = True,
|
|
92
|
+
use_clnf_refinement: bool = True,
|
|
93
|
+
enforce_clnf_pdm: bool = False,
|
|
94
|
+
verbose: bool = True
|
|
95
|
+
):
|
|
96
|
+
"""
|
|
97
|
+
Initialize the full Python AU pipeline with lazy initialization for CoreML
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
retinaface_model: Path to RetinaFace ONNX model
|
|
101
|
+
pfld_model: Path to PFLD ONNX model
|
|
102
|
+
pdm_file: Path to PDM shape model
|
|
103
|
+
au_models_dir: Directory containing AU SVR models
|
|
104
|
+
triangulation_file: Path to triangulation file for masking
|
|
105
|
+
patch_expert_file: Path to patch expert file for CLNF refinement (optional)
|
|
106
|
+
use_calc_params: Use full CalcParams vs. simplified PnP (default: True)
|
|
107
|
+
use_coreml: Enable CoreML Neural Engine acceleration (default: True)
|
|
108
|
+
track_faces: Use face tracking (detect once, track between frames) (default: True)
|
|
109
|
+
use_batched_predictor: Use optimized batched AU predictor (2-5x faster) (default: True)
|
|
110
|
+
use_clnf_refinement: Enable CLNF landmark refinement (default: True)
|
|
111
|
+
enforce_clnf_pdm: Enforce PDM constraints after CLNF refinement (default: False)
|
|
112
|
+
verbose: Print progress messages (default: True)
|
|
113
|
+
"""
|
|
114
|
+
import threading
|
|
115
|
+
|
|
116
|
+
self.verbose = verbose
|
|
117
|
+
self.use_calc_params = use_calc_params
|
|
118
|
+
self.use_coreml = use_coreml
|
|
119
|
+
self.track_faces = track_faces
|
|
120
|
+
self.use_batched_predictor = use_batched_predictor and USING_BATCHED_PREDICTOR
|
|
121
|
+
self.use_clnf_refinement = use_clnf_refinement
|
|
122
|
+
self.enforce_clnf_pdm = enforce_clnf_pdm
|
|
123
|
+
|
|
124
|
+
# Face tracking: cache bbox and only re-detect on failure (3x speedup!)
|
|
125
|
+
self.cached_bbox = None
|
|
126
|
+
self.detection_failures = 0
|
|
127
|
+
self.frames_since_detection = 0
|
|
128
|
+
|
|
129
|
+
# Store initialization parameters (lazy initialization for CoreML)
|
|
130
|
+
self._init_params = {
|
|
131
|
+
'retinaface_model': retinaface_model,
|
|
132
|
+
'pfld_model': pfld_model,
|
|
133
|
+
'pdm_file': pdm_file,
|
|
134
|
+
'au_models_dir': au_models_dir,
|
|
135
|
+
'triangulation_file': triangulation_file,
|
|
136
|
+
'patch_expert_file': patch_expert_file,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# Components will be initialized on first use (in worker thread if CoreML)
|
|
140
|
+
self._components_initialized = False
|
|
141
|
+
self._initialization_lock = threading.Lock()
|
|
142
|
+
|
|
143
|
+
# Component placeholders
|
|
144
|
+
self.face_detector = None
|
|
145
|
+
self.landmark_detector = None
|
|
146
|
+
self.clnf_refiner = None
|
|
147
|
+
self.pdm_parser = None
|
|
148
|
+
self.calc_params = None
|
|
149
|
+
self.face_aligner = None
|
|
150
|
+
self.triangulation = None
|
|
151
|
+
self.au_models = None
|
|
152
|
+
self.batched_au_predictor = None
|
|
153
|
+
self.running_median = None
|
|
154
|
+
|
|
155
|
+
# Two-pass processing: Store features for early frames
|
|
156
|
+
self.stored_features = [] # List of (frame_idx, hog_features, geom_features)
|
|
157
|
+
self.max_stored_frames = 3000 # OpenFace default
|
|
158
|
+
|
|
159
|
+
# Note: Actual initialization happens in _initialize_components()
|
|
160
|
+
# This is called lazily on first use (in worker thread if CoreML enabled)
|
|
161
|
+
|
|
162
|
+
def _initialize_components(self):
|
|
163
|
+
"""
|
|
164
|
+
Initialize all pipeline components (called lazily on first use).
|
|
165
|
+
This allows CoreML to be initialized in worker thread.
|
|
166
|
+
"""
|
|
167
|
+
with self._initialization_lock:
|
|
168
|
+
if self._components_initialized:
|
|
169
|
+
return # Already initialized
|
|
170
|
+
|
|
171
|
+
import threading
|
|
172
|
+
|
|
173
|
+
if self.verbose:
|
|
174
|
+
thread_name = threading.current_thread().name
|
|
175
|
+
is_main = threading.current_thread() == threading.main_thread()
|
|
176
|
+
print("=" * 80)
|
|
177
|
+
print("INITIALIZING COMPONENTS")
|
|
178
|
+
print(f"Thread: {thread_name} (main={is_main})")
|
|
179
|
+
print("=" * 80)
|
|
180
|
+
print("")
|
|
181
|
+
|
|
182
|
+
# Get initialization parameters
|
|
183
|
+
retinaface_model = self._init_params['retinaface_model']
|
|
184
|
+
pfld_model = self._init_params['pfld_model']
|
|
185
|
+
pdm_file = self._init_params['pdm_file']
|
|
186
|
+
au_models_dir = self._init_params['au_models_dir']
|
|
187
|
+
triangulation_file = self._init_params['triangulation_file']
|
|
188
|
+
|
|
189
|
+
# Component 1: Face Detection (with CoreML support)
|
|
190
|
+
if self.verbose:
|
|
191
|
+
print("[1/8] Loading face detector (RetinaFace ONNX)...")
|
|
192
|
+
if self.use_coreml:
|
|
193
|
+
print(" CoreML: Enabled (initializing in worker thread)")
|
|
194
|
+
else:
|
|
195
|
+
print(" CoreML: Disabled (CPU mode)")
|
|
196
|
+
|
|
197
|
+
self.face_detector = ONNXRetinaFaceDetector(
|
|
198
|
+
retinaface_model,
|
|
199
|
+
use_coreml=self.use_coreml,
|
|
200
|
+
confidence_threshold=0.5,
|
|
201
|
+
nms_threshold=0.4
|
|
202
|
+
)
|
|
203
|
+
if self.verbose:
|
|
204
|
+
print("Face detector loaded\n")
|
|
205
|
+
|
|
206
|
+
# Component 2: Landmark Detection
|
|
207
|
+
if self.verbose:
|
|
208
|
+
print("[2/8] Loading landmark detector (PFLD)...")
|
|
209
|
+
self.landmark_detector = CunjianPFLDDetector(pfld_model)
|
|
210
|
+
if self.verbose:
|
|
211
|
+
print(f"Landmark detector loaded: {self.landmark_detector}\n")
|
|
212
|
+
|
|
213
|
+
# Component 3: PDM Parser (moved before CLNF to support PDM enforcement)
|
|
214
|
+
if self.verbose:
|
|
215
|
+
print("[3/8] Loading PDM shape model...")
|
|
216
|
+
self.pdm_parser = PDMParser(pdm_file)
|
|
217
|
+
if self.verbose:
|
|
218
|
+
print(f"PDM loaded: {self.pdm_parser.mean_shape.shape[0]//3} landmarks\n")
|
|
219
|
+
|
|
220
|
+
# Initialize CalcParams if using full pose estimation OR CLNF+PDM enforcement
|
|
221
|
+
if self.use_calc_params or self.enforce_clnf_pdm:
|
|
222
|
+
self.calc_params = CalcParams(self.pdm_parser)
|
|
223
|
+
else:
|
|
224
|
+
self.calc_params = None
|
|
225
|
+
|
|
226
|
+
# Component 4: Face Aligner (needed for CLNF PDM enforcement)
|
|
227
|
+
if self.verbose:
|
|
228
|
+
print("[4/8] Initializing face aligner...")
|
|
229
|
+
self.face_aligner = OpenFace22FaceAligner(
|
|
230
|
+
pdm_file=pdm_file,
|
|
231
|
+
sim_scale=0.7,
|
|
232
|
+
output_size=(112, 112)
|
|
233
|
+
)
|
|
234
|
+
if self.verbose:
|
|
235
|
+
print("Face aligner initialized\n")
|
|
236
|
+
|
|
237
|
+
# Component 2.5: CLNF Refiner (optional, after PDM for constraint support)
|
|
238
|
+
if self.use_clnf_refinement:
|
|
239
|
+
if self.verbose:
|
|
240
|
+
print("[2.5/8] Loading CLNF landmark refiner...")
|
|
241
|
+
patch_expert_file = self._init_params['patch_expert_file']
|
|
242
|
+
if patch_expert_file is None:
|
|
243
|
+
patch_expert_file = 'weights/svr_patches_0.25_general.txt'
|
|
244
|
+
|
|
245
|
+
# Pass CalcParams (has CalcParams/CalcShape methods) for PDM enforcement
|
|
246
|
+
# Note: calc_params is initialized above if enforce_clnf_pdm is enabled
|
|
247
|
+
pdm_for_clnf = self.calc_params if (self.enforce_clnf_pdm and self.calc_params) else None
|
|
248
|
+
self.clnf_refiner = TargetedCLNFRefiner(
|
|
249
|
+
patch_expert_file,
|
|
250
|
+
search_window=3,
|
|
251
|
+
pdm=pdm_for_clnf,
|
|
252
|
+
enforce_pdm=self.enforce_clnf_pdm and (pdm_for_clnf is not None)
|
|
253
|
+
)
|
|
254
|
+
if self.verbose:
|
|
255
|
+
pdm_status = " + PDM constraints" if (self.enforce_clnf_pdm and pdm_for_clnf) else ""
|
|
256
|
+
print(f"CLNF refiner loaded with {len(self.clnf_refiner.patch_experts)} patch experts{pdm_status}\n")
|
|
257
|
+
else:
|
|
258
|
+
self.clnf_refiner = None
|
|
259
|
+
|
|
260
|
+
# Component 5: Triangulation
|
|
261
|
+
if self.verbose:
|
|
262
|
+
print("[5/8] Loading triangulation...")
|
|
263
|
+
self.triangulation = TriangulationParser(triangulation_file)
|
|
264
|
+
if self.verbose:
|
|
265
|
+
print(f"Triangulation loaded: {len(self.triangulation.triangles)} triangles\n")
|
|
266
|
+
|
|
267
|
+
# Component 6: AU Models
|
|
268
|
+
if self.verbose:
|
|
269
|
+
print("[6/8] Loading AU SVR models...")
|
|
270
|
+
model_parser = OF22ModelParser(au_models_dir)
|
|
271
|
+
self.au_models = model_parser.load_all_models(
|
|
272
|
+
use_recommended=True,
|
|
273
|
+
use_combined=True
|
|
274
|
+
)
|
|
275
|
+
if self.verbose:
|
|
276
|
+
print(f"Loaded {len(self.au_models)} AU models")
|
|
277
|
+
|
|
278
|
+
# Initialize batched predictor if enabled
|
|
279
|
+
if self.use_batched_predictor:
|
|
280
|
+
self.batched_au_predictor = BatchedAUPredictor(self.au_models)
|
|
281
|
+
if self.verbose:
|
|
282
|
+
print(f"Batched AU predictor enabled (2-5x faster)")
|
|
283
|
+
if self.verbose:
|
|
284
|
+
print("")
|
|
285
|
+
|
|
286
|
+
# Component 7: Running Median Tracker
|
|
287
|
+
if self.verbose:
|
|
288
|
+
print("[7/8] Initializing running median tracker...")
|
|
289
|
+
# Revert to original parameters while investigating C++ OpenFace histogram usage
|
|
290
|
+
# TODO: Verify if C++ uses same histogram for HOG and geometric features
|
|
291
|
+
self.running_median = DualHistogramMedianTracker(
|
|
292
|
+
hog_dim=4464,
|
|
293
|
+
geom_dim=238,
|
|
294
|
+
hog_bins=1000,
|
|
295
|
+
hog_min=-0.005,
|
|
296
|
+
hog_max=1.0,
|
|
297
|
+
geom_bins=10000,
|
|
298
|
+
geom_min=-60.0,
|
|
299
|
+
geom_max=60.0
|
|
300
|
+
)
|
|
301
|
+
if self.verbose:
|
|
302
|
+
if USING_CYTHON:
|
|
303
|
+
print("Running median tracker initialized (Cython-optimized, 260x faster)\n")
|
|
304
|
+
else:
|
|
305
|
+
print("Running median tracker initialized (Python version)\n")
|
|
306
|
+
|
|
307
|
+
# Component 8: PyFHOG
|
|
308
|
+
if self.verbose:
|
|
309
|
+
print("[8/8] PyFHOG ready for HOG extraction")
|
|
310
|
+
print("")
|
|
311
|
+
print("All components initialized successfully")
|
|
312
|
+
print("=" * 80)
|
|
313
|
+
print("")
|
|
314
|
+
|
|
315
|
+
self._components_initialized = True
|
|
316
|
+
|
|
317
|
+
def process_video(
|
|
318
|
+
self,
|
|
319
|
+
video_path: str,
|
|
320
|
+
output_csv: Optional[str] = None,
|
|
321
|
+
max_frames: Optional[int] = None
|
|
322
|
+
) -> pd.DataFrame:
|
|
323
|
+
"""
|
|
324
|
+
Process a video and extract AUs for all frames
|
|
325
|
+
|
|
326
|
+
ARCHITECTURE (CoreML mode):
|
|
327
|
+
- Main thread: Opens VideoCapture (macOS NSRunLoop requirement), reads frames
|
|
328
|
+
- Worker thread: Initializes CoreML, processes frames
|
|
329
|
+
- Communication: Queues (main→worker: frames, worker→main: results)
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
video_path: Path to input video
|
|
333
|
+
output_csv: Optional path to save CSV results
|
|
334
|
+
max_frames: Optional limit on frames to process (for testing)
|
|
335
|
+
|
|
336
|
+
Returns:
|
|
337
|
+
DataFrame with columns: frame, timestamp, success, AU01_r, AU02_r, ...
|
|
338
|
+
"""
|
|
339
|
+
import threading
|
|
340
|
+
import queue
|
|
341
|
+
|
|
342
|
+
# Reset stored features for new video processing
|
|
343
|
+
self.stored_features = []
|
|
344
|
+
|
|
345
|
+
# CoreML mode: Use queue-based architecture
|
|
346
|
+
# Main thread handles VideoCapture, worker thread handles CoreML processing
|
|
347
|
+
if self.use_coreml and threading.current_thread() == threading.main_thread():
|
|
348
|
+
if self.verbose:
|
|
349
|
+
print("[CoreML Mode] Queue-based processing:")
|
|
350
|
+
print(" Main thread: VideoCapture (macOS NSRunLoop)")
|
|
351
|
+
print(" Worker thread: CoreML + processing\n")
|
|
352
|
+
|
|
353
|
+
# Validate path
|
|
354
|
+
video_path = Path(video_path)
|
|
355
|
+
if not video_path.exists():
|
|
356
|
+
raise FileNotFoundError(f"Video not found: {video_path}")
|
|
357
|
+
|
|
358
|
+
# Open VideoCapture in MAIN thread (macOS requirement!)
|
|
359
|
+
cap = cv2.VideoCapture(str(video_path))
|
|
360
|
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
|
361
|
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
362
|
+
|
|
363
|
+
if max_frames:
|
|
364
|
+
total_frames = min(total_frames, max_frames)
|
|
365
|
+
|
|
366
|
+
if self.verbose:
|
|
367
|
+
print(f"Processing video: {video_path.name}")
|
|
368
|
+
print("=" * 80)
|
|
369
|
+
print(f"Video info:")
|
|
370
|
+
print(f" FPS: {fps:.2f}")
|
|
371
|
+
print(f" Total frames: {total_frames}")
|
|
372
|
+
print(f" Duration: {total_frames/fps:.2f} seconds")
|
|
373
|
+
print("")
|
|
374
|
+
|
|
375
|
+
# Create queues for communication
|
|
376
|
+
frame_queue = queue.Queue(maxsize=10) # Limit buffering
|
|
377
|
+
result_queue = queue.Queue()
|
|
378
|
+
error_container = {'error': None}
|
|
379
|
+
|
|
380
|
+
# Start worker thread for processing
|
|
381
|
+
def worker():
|
|
382
|
+
try:
|
|
383
|
+
self._process_frames_worker(frame_queue, result_queue, fps)
|
|
384
|
+
except Exception as e:
|
|
385
|
+
error_container['error'] = e
|
|
386
|
+
import traceback
|
|
387
|
+
traceback.print_exc()
|
|
388
|
+
|
|
389
|
+
worker_thread = threading.Thread(target=worker, daemon=False, name="CoreMLWorker")
|
|
390
|
+
worker_thread.start()
|
|
391
|
+
|
|
392
|
+
# Main thread: Read frames and send to worker
|
|
393
|
+
frame_idx = 0
|
|
394
|
+
try:
|
|
395
|
+
if self.verbose:
|
|
396
|
+
print(f"[Main Thread] Reading frames from video...")
|
|
397
|
+
while True:
|
|
398
|
+
ret, frame = cap.read()
|
|
399
|
+
if not ret or (max_frames and frame_idx >= max_frames):
|
|
400
|
+
break
|
|
401
|
+
|
|
402
|
+
timestamp = frame_idx / fps
|
|
403
|
+
if self.verbose and frame_idx < 3:
|
|
404
|
+
print(f"[Main Thread] Sending frame {frame_idx} to worker queue")
|
|
405
|
+
frame_queue.put((frame_idx, timestamp, frame))
|
|
406
|
+
frame_idx += 1
|
|
407
|
+
|
|
408
|
+
if self.verbose:
|
|
409
|
+
print(f"[Main Thread] Finished reading {frame_idx} frames, sending termination signal")
|
|
410
|
+
|
|
411
|
+
finally:
|
|
412
|
+
# Signal worker to finish
|
|
413
|
+
frame_queue.put(None)
|
|
414
|
+
cap.release()
|
|
415
|
+
if self.verbose:
|
|
416
|
+
print(f"[Main Thread] VideoCapture released, waiting for worker to finish...")
|
|
417
|
+
|
|
418
|
+
# Wait for worker to complete
|
|
419
|
+
worker_thread.join()
|
|
420
|
+
if self.verbose:
|
|
421
|
+
print(f"[Main Thread] Worker thread completed")
|
|
422
|
+
|
|
423
|
+
# Check for errors
|
|
424
|
+
if error_container['error']:
|
|
425
|
+
raise error_container['error']
|
|
426
|
+
|
|
427
|
+
# Collect results from worker
|
|
428
|
+
results = []
|
|
429
|
+
while not result_queue.empty():
|
|
430
|
+
results.append(result_queue.get())
|
|
431
|
+
|
|
432
|
+
# Sort by frame index (in case of out-of-order processing)
|
|
433
|
+
results.sort(key=lambda x: x['frame'])
|
|
434
|
+
|
|
435
|
+
# Convert to DataFrame
|
|
436
|
+
df = pd.DataFrame(results)
|
|
437
|
+
|
|
438
|
+
# Apply post-processing (cutoff adjustment, temporal smoothing)
|
|
439
|
+
# This is CRITICAL for dynamic AU accuracy!
|
|
440
|
+
if self.verbose:
|
|
441
|
+
print("\nApplying post-processing (cutoff adjustment, temporal smoothing)...")
|
|
442
|
+
df = self.finalize_predictions(df)
|
|
443
|
+
|
|
444
|
+
# Statistics
|
|
445
|
+
total_processed = df['success'].sum()
|
|
446
|
+
total_failed = len(df) - total_processed
|
|
447
|
+
|
|
448
|
+
if self.verbose:
|
|
449
|
+
print("")
|
|
450
|
+
print("=" * 80)
|
|
451
|
+
print("PROCESSING COMPLETE")
|
|
452
|
+
print("=" * 80)
|
|
453
|
+
print(f"Total frames processed: {total_processed}")
|
|
454
|
+
print(f"Failed frames: {total_failed}")
|
|
455
|
+
if len(df) > 0:
|
|
456
|
+
print(f"Success rate: {total_processed/len(df)*100:.1f}%")
|
|
457
|
+
print("")
|
|
458
|
+
|
|
459
|
+
# Save to CSV if requested
|
|
460
|
+
if output_csv:
|
|
461
|
+
df.to_csv(output_csv, index=False)
|
|
462
|
+
if self.verbose:
|
|
463
|
+
print(f"Results saved to: {output_csv}")
|
|
464
|
+
print("")
|
|
465
|
+
|
|
466
|
+
return df
|
|
467
|
+
|
|
468
|
+
# Direct processing (CPU mode - no threading needed)
|
|
469
|
+
return self._process_video_impl(video_path, output_csv, max_frames)
|
|
470
|
+
|
|
471
|
+
def _process_frames_worker(self, frame_queue, result_queue, fps):
|
|
472
|
+
"""
|
|
473
|
+
Worker thread method for processing frames (CoreML mode)
|
|
474
|
+
|
|
475
|
+
This runs in a worker thread and:
|
|
476
|
+
1. Initializes CoreML and all components (lazy init in worker thread)
|
|
477
|
+
2. Receives frames from frame_queue
|
|
478
|
+
3. Processes each frame
|
|
479
|
+
4. Sends results to result_queue
|
|
480
|
+
|
|
481
|
+
Args:
|
|
482
|
+
frame_queue: Queue receiving (frame_idx, timestamp, frame) tuples from main thread
|
|
483
|
+
result_queue: Queue for sending results back to main thread
|
|
484
|
+
fps: Video framerate (for progress reporting)
|
|
485
|
+
"""
|
|
486
|
+
import queue
|
|
487
|
+
|
|
488
|
+
# Initialize all components in worker thread (CoreML loads here!)
|
|
489
|
+
self._initialize_components()
|
|
490
|
+
|
|
491
|
+
if self.verbose:
|
|
492
|
+
print("[Worker Thread] Components initialized")
|
|
493
|
+
if self.face_detector:
|
|
494
|
+
print(f"[Worker Thread] Detector backend: {self.face_detector.backend}")
|
|
495
|
+
print("[Worker Thread] Starting frame processing loop...")
|
|
496
|
+
print("")
|
|
497
|
+
|
|
498
|
+
# Process frames from queue
|
|
499
|
+
total_processed = 0
|
|
500
|
+
total_failed = 0
|
|
501
|
+
|
|
502
|
+
while True:
|
|
503
|
+
try:
|
|
504
|
+
# Get frame from queue (blocks until available)
|
|
505
|
+
if self.verbose and total_processed + total_failed < 3:
|
|
506
|
+
print(f"[Worker Thread] Waiting for frame from queue...")
|
|
507
|
+
item = frame_queue.get(timeout=1.0)
|
|
508
|
+
if self.verbose and total_processed + total_failed < 3:
|
|
509
|
+
print(f"[Worker Thread] Received item from queue")
|
|
510
|
+
|
|
511
|
+
# Check for termination signal
|
|
512
|
+
if item is None:
|
|
513
|
+
break
|
|
514
|
+
|
|
515
|
+
frame_idx, timestamp, frame = item
|
|
516
|
+
|
|
517
|
+
# Process frame
|
|
518
|
+
frame_result = self._process_frame(frame, frame_idx, timestamp)
|
|
519
|
+
result_queue.put(frame_result)
|
|
520
|
+
|
|
521
|
+
# Update statistics
|
|
522
|
+
if frame_result['success']:
|
|
523
|
+
total_processed += 1
|
|
524
|
+
else:
|
|
525
|
+
total_failed += 1
|
|
526
|
+
|
|
527
|
+
# Progress update
|
|
528
|
+
if self.verbose and (frame_idx + 1) % 10 == 0:
|
|
529
|
+
print(f"[Worker] Processed {frame_idx + 1} frames - "
|
|
530
|
+
f"Success: {total_processed}, Failed: {total_failed}")
|
|
531
|
+
|
|
532
|
+
except queue.Empty:
|
|
533
|
+
# Timeout waiting for frame - continue
|
|
534
|
+
continue
|
|
535
|
+
except Exception as e:
|
|
536
|
+
# Error processing frame - log and continue
|
|
537
|
+
print(f"[Worker] Error processing frame: {e}")
|
|
538
|
+
import traceback
|
|
539
|
+
traceback.print_exc()
|
|
540
|
+
continue
|
|
541
|
+
|
|
542
|
+
if self.verbose:
|
|
543
|
+
print(f"\n[Worker Thread] Finished processing")
|
|
544
|
+
print(f" Total processed: {total_processed}")
|
|
545
|
+
print(f" Total failed: {total_failed}")
|
|
546
|
+
|
|
547
|
+
def _process_video_impl(
|
|
548
|
+
self,
|
|
549
|
+
video_path: str,
|
|
550
|
+
output_csv: Optional[str] = None,
|
|
551
|
+
max_frames: Optional[int] = None
|
|
552
|
+
) -> pd.DataFrame:
|
|
553
|
+
"""Internal implementation of video processing"""
|
|
554
|
+
|
|
555
|
+
# Ensure components are initialized (lazy initialization)
|
|
556
|
+
self._initialize_components()
|
|
557
|
+
|
|
558
|
+
video_path = Path(video_path)
|
|
559
|
+
if not video_path.exists():
|
|
560
|
+
raise FileNotFoundError(f"Video not found: {video_path}")
|
|
561
|
+
|
|
562
|
+
if self.verbose:
|
|
563
|
+
print(f"Processing video: {video_path.name}")
|
|
564
|
+
print("=" * 80)
|
|
565
|
+
print("")
|
|
566
|
+
|
|
567
|
+
# Open video
|
|
568
|
+
cap = cv2.VideoCapture(str(video_path))
|
|
569
|
+
fps = cap.get(cv2.CAP_PROP_FPS)
|
|
570
|
+
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
571
|
+
|
|
572
|
+
if max_frames:
|
|
573
|
+
total_frames = min(total_frames, max_frames)
|
|
574
|
+
|
|
575
|
+
if self.verbose:
|
|
576
|
+
print(f"Video info:")
|
|
577
|
+
print(f" FPS: {fps:.2f}")
|
|
578
|
+
print(f" Total frames: {total_frames}")
|
|
579
|
+
print(f" Duration: {total_frames/fps:.2f} seconds")
|
|
580
|
+
print("")
|
|
581
|
+
|
|
582
|
+
# Results storage
|
|
583
|
+
results = []
|
|
584
|
+
frame_idx = 0
|
|
585
|
+
|
|
586
|
+
# Statistics
|
|
587
|
+
total_processed = 0
|
|
588
|
+
total_failed = 0
|
|
589
|
+
|
|
590
|
+
try:
|
|
591
|
+
while True:
|
|
592
|
+
ret, frame = cap.read()
|
|
593
|
+
if not ret or (max_frames and frame_idx >= max_frames):
|
|
594
|
+
break
|
|
595
|
+
|
|
596
|
+
timestamp = frame_idx / fps
|
|
597
|
+
|
|
598
|
+
# Process frame
|
|
599
|
+
frame_result = self._process_frame(frame, frame_idx, timestamp)
|
|
600
|
+
results.append(frame_result)
|
|
601
|
+
|
|
602
|
+
if frame_result['success']:
|
|
603
|
+
total_processed += 1
|
|
604
|
+
else:
|
|
605
|
+
total_failed += 1
|
|
606
|
+
|
|
607
|
+
# Progress update
|
|
608
|
+
if self.verbose and (frame_idx + 1) % 10 == 0:
|
|
609
|
+
progress = (frame_idx + 1) / total_frames * 100
|
|
610
|
+
print(f"Progress: {frame_idx + 1}/{total_frames} frames ({progress:.1f}%) - "
|
|
611
|
+
f"Success: {total_processed}, Failed: {total_failed}")
|
|
612
|
+
|
|
613
|
+
frame_idx += 1
|
|
614
|
+
|
|
615
|
+
finally:
|
|
616
|
+
cap.release()
|
|
617
|
+
|
|
618
|
+
# Convert to DataFrame
|
|
619
|
+
df = pd.DataFrame(results)
|
|
620
|
+
|
|
621
|
+
# Apply post-processing (cutoff adjustment, temporal smoothing)
|
|
622
|
+
# This is CRITICAL for dynamic AU accuracy!
|
|
623
|
+
if self.verbose:
|
|
624
|
+
print("\nApplying post-processing (cutoff adjustment, temporal smoothing)...")
|
|
625
|
+
df = self.finalize_predictions(df)
|
|
626
|
+
|
|
627
|
+
if self.verbose:
|
|
628
|
+
print("")
|
|
629
|
+
print("=" * 80)
|
|
630
|
+
print("PROCESSING COMPLETE")
|
|
631
|
+
print("=" * 80)
|
|
632
|
+
print(f"Total frames processed: {total_processed}")
|
|
633
|
+
print(f"Failed frames: {total_failed}")
|
|
634
|
+
print(f"Success rate: {total_processed/(total_processed+total_failed)*100:.1f}%")
|
|
635
|
+
print("")
|
|
636
|
+
|
|
637
|
+
# Save to CSV if requested
|
|
638
|
+
if output_csv:
|
|
639
|
+
df.to_csv(output_csv, index=False)
|
|
640
|
+
if self.verbose:
|
|
641
|
+
print(f"Results saved to: {output_csv}")
|
|
642
|
+
print("")
|
|
643
|
+
|
|
644
|
+
return df
|
|
645
|
+
|
|
646
|
+
def _process_frame(
|
|
647
|
+
self,
|
|
648
|
+
frame: np.ndarray,
|
|
649
|
+
frame_idx: int,
|
|
650
|
+
timestamp: float
|
|
651
|
+
) -> Dict:
|
|
652
|
+
"""
|
|
653
|
+
Process a single frame through the complete pipeline
|
|
654
|
+
|
|
655
|
+
Face Tracking Strategy (when enabled):
|
|
656
|
+
- Frame 0: Run RetinaFace detection, cache bbox
|
|
657
|
+
- Frame 1+: Try cached bbox first
|
|
658
|
+
- If landmark/alignment succeeds → keep using cached bbox
|
|
659
|
+
- If landmark/alignment fails → re-run RetinaFace, update cache
|
|
660
|
+
|
|
661
|
+
This provides ~3x speedup by skipping expensive face detection!
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
frame: BGR image
|
|
665
|
+
frame_idx: Frame index
|
|
666
|
+
timestamp: Frame timestamp in seconds
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
Dictionary with frame results (success, AUs, etc.)
|
|
670
|
+
"""
|
|
671
|
+
result = {
|
|
672
|
+
'frame': frame_idx,
|
|
673
|
+
'timestamp': timestamp,
|
|
674
|
+
'success': False
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
try:
|
|
678
|
+
bbox = None
|
|
679
|
+
need_detection = True
|
|
680
|
+
|
|
681
|
+
# Step 1: Face Detection (with tracking optimization)
|
|
682
|
+
if self.track_faces and self.cached_bbox is not None:
|
|
683
|
+
# Try using cached bbox (skip expensive RetinaFace!)
|
|
684
|
+
if self.verbose and frame_idx < 3:
|
|
685
|
+
print(f"[Frame {frame_idx}] Step 1: Using cached bbox (tracking mode)")
|
|
686
|
+
bbox = self.cached_bbox
|
|
687
|
+
need_detection = False
|
|
688
|
+
self.frames_since_detection += 1
|
|
689
|
+
|
|
690
|
+
if need_detection or bbox is None:
|
|
691
|
+
# First frame OR previous tracking failed - run RetinaFace
|
|
692
|
+
if self.verbose and frame_idx < 3:
|
|
693
|
+
print(f"[Frame {frame_idx}] Step 1: Detecting face with {self.face_detector.backend}...")
|
|
694
|
+
detections, _ = self.face_detector.detect_faces(frame)
|
|
695
|
+
if self.verbose and frame_idx < 3:
|
|
696
|
+
print(f"[Frame {frame_idx}] Step 1: Found {len(detections)} faces")
|
|
697
|
+
|
|
698
|
+
if len(detections) == 0:
|
|
699
|
+
# No face detected - clear cache
|
|
700
|
+
self.cached_bbox = None
|
|
701
|
+
self.detection_failures += 1
|
|
702
|
+
return result
|
|
703
|
+
|
|
704
|
+
# Use primary face (highest confidence)
|
|
705
|
+
det = detections[0]
|
|
706
|
+
bbox = det[:4].astype(int) # [x1, y1, x2, y2]
|
|
707
|
+
|
|
708
|
+
# Cache bbox for next frame
|
|
709
|
+
if self.track_faces:
|
|
710
|
+
self.cached_bbox = bbox
|
|
711
|
+
self.frames_since_detection = 0
|
|
712
|
+
|
|
713
|
+
# Step 2: Detect landmarks
|
|
714
|
+
if self.verbose and frame_idx < 3:
|
|
715
|
+
print(f"[Frame {frame_idx}] Step 2: Detecting landmarks...")
|
|
716
|
+
|
|
717
|
+
try:
|
|
718
|
+
landmarks_68, _ = self.landmark_detector.detect_landmarks(frame, bbox)
|
|
719
|
+
|
|
720
|
+
# Optional CLNF refinement for critical landmarks
|
|
721
|
+
if self.use_clnf_refinement and self.clnf_refiner is not None:
|
|
722
|
+
landmarks_68 = self.clnf_refiner.refine_landmarks(frame, landmarks_68)
|
|
723
|
+
|
|
724
|
+
if self.verbose and frame_idx < 3:
|
|
725
|
+
print(f"[Frame {frame_idx}] Step 2: Got {len(landmarks_68)} landmarks")
|
|
726
|
+
except Exception as e:
|
|
727
|
+
# Landmark detection failed with cached bbox - re-run face detection
|
|
728
|
+
if self.track_faces and not need_detection:
|
|
729
|
+
if self.verbose and frame_idx < 3:
|
|
730
|
+
print(f"[Frame {frame_idx}] Step 2: Landmark detection failed with cached bbox, re-detecting face...")
|
|
731
|
+
self.detection_failures += 1
|
|
732
|
+
self.cached_bbox = None
|
|
733
|
+
|
|
734
|
+
# Re-run face detection
|
|
735
|
+
detections, _ = self.face_detector.detect_faces(frame)
|
|
736
|
+
if len(detections) == 0:
|
|
737
|
+
return result
|
|
738
|
+
|
|
739
|
+
det = detections[0]
|
|
740
|
+
bbox = det[:4].astype(int)
|
|
741
|
+
self.cached_bbox = bbox
|
|
742
|
+
self.frames_since_detection = 0
|
|
743
|
+
|
|
744
|
+
# Retry landmark detection with new bbox
|
|
745
|
+
landmarks_68, _ = self.landmark_detector.detect_landmarks(frame, bbox)
|
|
746
|
+
|
|
747
|
+
# Optional CLNF refinement for critical landmarks
|
|
748
|
+
if self.use_clnf_refinement and self.clnf_refiner is not None:
|
|
749
|
+
landmarks_68 = self.clnf_refiner.refine_landmarks(frame, landmarks_68)
|
|
750
|
+
else:
|
|
751
|
+
# Not tracking or already re-detected - fail
|
|
752
|
+
raise
|
|
753
|
+
|
|
754
|
+
# Step 3: Estimate 3D pose
|
|
755
|
+
if self.verbose and frame_idx < 3:
|
|
756
|
+
print(f"[Frame {frame_idx}] Step 3: Estimating 3D pose...")
|
|
757
|
+
if self.use_calc_params and self.calc_params:
|
|
758
|
+
# Full CalcParams optimization
|
|
759
|
+
# Pass landmarks as (68, 2) array - CalcParams handles format conversion
|
|
760
|
+
params_global, params_local = self.calc_params.calc_params(
|
|
761
|
+
landmarks_68
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
# Extract pose parameters
|
|
765
|
+
scale = params_global[0]
|
|
766
|
+
rx, ry, rz = params_global[1:4]
|
|
767
|
+
tx, ty = params_global[4:6]
|
|
768
|
+
else:
|
|
769
|
+
# Simplified approach: use bounding box for rough pose
|
|
770
|
+
# (This is a fallback - CalcParams is recommended)
|
|
771
|
+
scale = 1.0
|
|
772
|
+
rx = ry = rz = 0.0
|
|
773
|
+
tx = (bbox[0] + bbox[2]) / 2
|
|
774
|
+
ty = (bbox[1] + bbox[3]) / 2
|
|
775
|
+
params_local = np.zeros(34)
|
|
776
|
+
|
|
777
|
+
# Step 4: Align face
|
|
778
|
+
if self.verbose and frame_idx < 3:
|
|
779
|
+
print(f"[Frame {frame_idx}] Step 4: Aligning face...")
|
|
780
|
+
aligned_face = self.face_aligner.align_face(
|
|
781
|
+
image=frame,
|
|
782
|
+
landmarks_68=landmarks_68,
|
|
783
|
+
pose_tx=tx,
|
|
784
|
+
pose_ty=ty,
|
|
785
|
+
p_rz=rz,
|
|
786
|
+
apply_mask=True,
|
|
787
|
+
triangulation=self.triangulation
|
|
788
|
+
)
|
|
789
|
+
if self.verbose and frame_idx < 3:
|
|
790
|
+
print(f"[Frame {frame_idx}] Step 4: Aligned face shape: {aligned_face.shape}")
|
|
791
|
+
|
|
792
|
+
# Step 5: Extract HOG features
|
|
793
|
+
if self.verbose and frame_idx < 3:
|
|
794
|
+
print(f"[Frame {frame_idx}] Step 5: Extracting HOG features...")
|
|
795
|
+
hog_features = pyfhog.extract_fhog_features(
|
|
796
|
+
aligned_face,
|
|
797
|
+
cell_size=8
|
|
798
|
+
)
|
|
799
|
+
hog_features = hog_features.flatten() # Should be 4464 dims
|
|
800
|
+
if self.verbose and frame_idx < 3:
|
|
801
|
+
print(f"[Frame {frame_idx}] Step 5: HOG features shape: {hog_features.shape}")
|
|
802
|
+
|
|
803
|
+
# Step 6: Extract geometric features
|
|
804
|
+
if self.verbose and frame_idx < 3:
|
|
805
|
+
print(f"[Frame {frame_idx}] Step 6: Extracting geometric features...")
|
|
806
|
+
geom_features = self.pdm_parser.extract_geometric_features(params_local)
|
|
807
|
+
if self.verbose and frame_idx < 3:
|
|
808
|
+
print(f"[Frame {frame_idx}] Step 6: Geometric features shape: {geom_features.shape}")
|
|
809
|
+
|
|
810
|
+
# Ensure float32 for Cython compatibility
|
|
811
|
+
hog_features = hog_features.astype(np.float32)
|
|
812
|
+
geom_features = geom_features.astype(np.float32)
|
|
813
|
+
|
|
814
|
+
# Step 7: Update running median
|
|
815
|
+
if self.verbose and frame_idx < 3:
|
|
816
|
+
print(f"[Frame {frame_idx}] Step 7: Updating running median...")
|
|
817
|
+
update_histogram = (frame_idx % 2 == 1) # Every 2nd frame
|
|
818
|
+
self.running_median.update(hog_features, geom_features, update_histogram=update_histogram)
|
|
819
|
+
running_median = self.running_median.get_combined_median()
|
|
820
|
+
if self.verbose and frame_idx < 3:
|
|
821
|
+
print(f"[Frame {frame_idx}] Step 7: Running median shape: {running_median.shape}")
|
|
822
|
+
|
|
823
|
+
# Store features for two-pass processing (OpenFace reprocesses first 3000 frames)
|
|
824
|
+
if frame_idx < self.max_stored_frames:
|
|
825
|
+
self.stored_features.append((frame_idx, hog_features.copy(), geom_features.copy()))
|
|
826
|
+
|
|
827
|
+
# Step 8: Predict AUs
|
|
828
|
+
if self.verbose and frame_idx < 3:
|
|
829
|
+
print(f"[Frame {frame_idx}] Step 8: Predicting AUs...")
|
|
830
|
+
au_results = self._predict_aus(
|
|
831
|
+
hog_features,
|
|
832
|
+
geom_features,
|
|
833
|
+
running_median
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
# Add AU predictions to result
|
|
837
|
+
result.update(au_results)
|
|
838
|
+
result['success'] = True
|
|
839
|
+
|
|
840
|
+
except Exception as e:
|
|
841
|
+
if self.verbose:
|
|
842
|
+
print(f"Warning: Frame {frame_idx} failed: {e}")
|
|
843
|
+
|
|
844
|
+
return result
|
|
845
|
+
|
|
846
|
+
def _predict_aus(
|
|
847
|
+
self,
|
|
848
|
+
hog_features: np.ndarray,
|
|
849
|
+
geom_features: np.ndarray,
|
|
850
|
+
running_median: np.ndarray
|
|
851
|
+
) -> Dict[str, float]:
|
|
852
|
+
"""
|
|
853
|
+
Predict AU intensities using SVR models
|
|
854
|
+
|
|
855
|
+
Uses batched predictor if enabled (2-5x faster), otherwise falls back
|
|
856
|
+
to sequential prediction.
|
|
857
|
+
|
|
858
|
+
Args:
|
|
859
|
+
hog_features: HOG feature vector (4464,)
|
|
860
|
+
geom_features: Geometric feature vector (238,)
|
|
861
|
+
running_median: Combined running median (4702,)
|
|
862
|
+
|
|
863
|
+
Returns:
|
|
864
|
+
Dictionary of AU predictions {AU_name: intensity}
|
|
865
|
+
"""
|
|
866
|
+
# Use batched predictor if available (2-5x faster)
|
|
867
|
+
if self.use_batched_predictor and self.batched_au_predictor is not None:
|
|
868
|
+
return self.batched_au_predictor.predict(hog_features, geom_features, running_median)
|
|
869
|
+
|
|
870
|
+
# Fallback to sequential prediction
|
|
871
|
+
predictions = {}
|
|
872
|
+
|
|
873
|
+
# Construct full feature vector
|
|
874
|
+
full_vector = np.concatenate([hog_features, geom_features])
|
|
875
|
+
|
|
876
|
+
for au_name, model in self.au_models.items():
|
|
877
|
+
is_dynamic = (model['model_type'] == 'dynamic')
|
|
878
|
+
|
|
879
|
+
# Center features
|
|
880
|
+
if is_dynamic:
|
|
881
|
+
centered = full_vector - model['means'].flatten() - running_median
|
|
882
|
+
else:
|
|
883
|
+
centered = full_vector - model['means'].flatten()
|
|
884
|
+
|
|
885
|
+
# SVR prediction
|
|
886
|
+
pred = np.dot(centered.reshape(1, -1), model['support_vectors']) + model['bias']
|
|
887
|
+
pred = float(pred[0, 0])
|
|
888
|
+
|
|
889
|
+
# Clamp to [0, 5]
|
|
890
|
+
pred = np.clip(pred, 0.0, 5.0)
|
|
891
|
+
|
|
892
|
+
predictions[au_name] = pred
|
|
893
|
+
|
|
894
|
+
return predictions
|
|
895
|
+
|
|
896
|
+
def finalize_predictions(
|
|
897
|
+
self,
|
|
898
|
+
df: pd.DataFrame,
|
|
899
|
+
max_init_frames: int = 3000
|
|
900
|
+
) -> pd.DataFrame:
|
|
901
|
+
"""
|
|
902
|
+
Apply post-processing to AU predictions
|
|
903
|
+
|
|
904
|
+
This includes:
|
|
905
|
+
1. Two-pass processing (replace early frames with final median)
|
|
906
|
+
2. Cutoff adjustment (person-specific calibration)
|
|
907
|
+
3. Temporal smoothing (3-frame moving average)
|
|
908
|
+
|
|
909
|
+
Args:
|
|
910
|
+
df: DataFrame with raw AU predictions
|
|
911
|
+
max_init_frames: Number of early frames to reprocess (default: 3000)
|
|
912
|
+
|
|
913
|
+
Returns:
|
|
914
|
+
DataFrame with finalized AU predictions
|
|
915
|
+
"""
|
|
916
|
+
if self.verbose:
|
|
917
|
+
print("")
|
|
918
|
+
print("Applying post-processing...")
|
|
919
|
+
print(" [1/3] Two-pass median correction...")
|
|
920
|
+
|
|
921
|
+
# Two-pass reprocessing: Re-predict AUs for early frames using final running median
|
|
922
|
+
# This fixes systematic baseline offset from immature running median in early frames
|
|
923
|
+
if len(self.stored_features) > 0:
|
|
924
|
+
final_median = self.running_median.get_combined_median()
|
|
925
|
+
|
|
926
|
+
if self.verbose:
|
|
927
|
+
print(f" Re-predicting {len(self.stored_features)} early frames with final median...")
|
|
928
|
+
|
|
929
|
+
# Re-predict AUs for stored frames
|
|
930
|
+
for frame_idx, hog_features, geom_features in self.stored_features:
|
|
931
|
+
# Re-predict AUs using final running median
|
|
932
|
+
au_results = self._predict_aus(hog_features, geom_features, final_median)
|
|
933
|
+
|
|
934
|
+
# Update DataFrame with re-predicted values
|
|
935
|
+
for au_name, au_value in au_results.items():
|
|
936
|
+
df.loc[frame_idx, au_name] = au_value
|
|
937
|
+
|
|
938
|
+
# Clear stored features to free memory
|
|
939
|
+
self.stored_features = []
|
|
940
|
+
|
|
941
|
+
if self.verbose:
|
|
942
|
+
print(f" Two-pass correction complete")
|
|
943
|
+
else:
|
|
944
|
+
if self.verbose:
|
|
945
|
+
print(" (No stored features - skipping)")
|
|
946
|
+
|
|
947
|
+
if self.verbose:
|
|
948
|
+
print(" [2/3] Cutoff adjustment...")
|
|
949
|
+
|
|
950
|
+
# Apply cutoff adjustment for dynamic models
|
|
951
|
+
au_cols = [col for col in df.columns if col.startswith('AU') and col.endswith('_r')]
|
|
952
|
+
|
|
953
|
+
for au_col in au_cols:
|
|
954
|
+
au_name = au_col
|
|
955
|
+
if au_name not in self.au_models:
|
|
956
|
+
continue
|
|
957
|
+
|
|
958
|
+
model = self.au_models[au_name]
|
|
959
|
+
is_dynamic = (model['model_type'] == 'dynamic')
|
|
960
|
+
|
|
961
|
+
if is_dynamic and model.get('cutoff', -1) != -1:
|
|
962
|
+
cutoff = model['cutoff']
|
|
963
|
+
au_values = df[au_col].values
|
|
964
|
+
sorted_vals = np.sort(au_values)
|
|
965
|
+
cutoff_idx = int(len(sorted_vals) * cutoff)
|
|
966
|
+
offset = sorted_vals[cutoff_idx]
|
|
967
|
+
df[au_col] = np.clip(au_values - offset, 0.0, 5.0)
|
|
968
|
+
|
|
969
|
+
if self.verbose:
|
|
970
|
+
print(" [3/3] Temporal smoothing...")
|
|
971
|
+
|
|
972
|
+
# Apply 3-frame moving average
|
|
973
|
+
for au_col in au_cols:
|
|
974
|
+
smoothed = df[au_col].rolling(window=3, center=True, min_periods=1).mean()
|
|
975
|
+
df[au_col] = smoothed
|
|
976
|
+
|
|
977
|
+
if self.verbose:
|
|
978
|
+
print("Post-processing complete")
|
|
979
|
+
|
|
980
|
+
return df
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def main():
|
|
984
|
+
"""Command-line interface for full Python AU pipeline"""
|
|
985
|
+
|
|
986
|
+
parser = argparse.ArgumentParser(
|
|
987
|
+
description="Full Python AU Extraction Pipeline",
|
|
988
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
989
|
+
epilog="""
|
|
990
|
+
Examples:
|
|
991
|
+
# Process video with default settings
|
|
992
|
+
python full_python_au_pipeline.py --video input.mp4 --output results.csv
|
|
993
|
+
|
|
994
|
+
# Process first 100 frames only (for testing)
|
|
995
|
+
python full_python_au_pipeline.py --video input.mp4 --max-frames 100
|
|
996
|
+
|
|
997
|
+
# Use simplified pose estimation (faster, less accurate)
|
|
998
|
+
python full_python_au_pipeline.py --video input.mp4 --simple-pose
|
|
999
|
+
"""
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
parser.add_argument('--video', required=True, help='Input video file')
|
|
1003
|
+
parser.add_argument('--output', help='Output CSV file (default: <video>_aus.csv)')
|
|
1004
|
+
parser.add_argument('--max-frames', type=int, help='Maximum frames to process (for testing)')
|
|
1005
|
+
parser.add_argument('--simple-pose', action='store_true', help='Use simplified pose estimation')
|
|
1006
|
+
|
|
1007
|
+
# Model paths (with defaults)
|
|
1008
|
+
parser.add_argument('--retinaface', default='weights/retinaface_mobilenet025_coreml.onnx',
|
|
1009
|
+
help='RetinaFace ONNX model path')
|
|
1010
|
+
parser.add_argument('--pfld', default='weights/pfld_cunjian.onnx',
|
|
1011
|
+
help='PFLD ONNX model path')
|
|
1012
|
+
parser.add_argument('--pdm', default='weights/In-the-wild_aligned_PDM_68.txt',
|
|
1013
|
+
help='PDM shape model path')
|
|
1014
|
+
parser.add_argument('--au-models', default='weights/AU_predictors',
|
|
1015
|
+
help='AU models directory')
|
|
1016
|
+
parser.add_argument('--triangulation', default='weights/tris_68_full.txt',
|
|
1017
|
+
help='Triangulation file path')
|
|
1018
|
+
|
|
1019
|
+
args = parser.parse_args()
|
|
1020
|
+
|
|
1021
|
+
# Set default output path
|
|
1022
|
+
if not args.output:
|
|
1023
|
+
video_path = Path(args.video)
|
|
1024
|
+
args.output = str(video_path.parent / f"{video_path.stem}_python_aus.csv")
|
|
1025
|
+
|
|
1026
|
+
# Initialize pipeline
|
|
1027
|
+
try:
|
|
1028
|
+
pipeline = FullPythonAUPipeline(
|
|
1029
|
+
retinaface_model=args.retinaface,
|
|
1030
|
+
pfld_model=args.pfld,
|
|
1031
|
+
pdm_file=args.pdm,
|
|
1032
|
+
au_models_dir=args.au_models,
|
|
1033
|
+
triangulation_file=args.triangulation,
|
|
1034
|
+
use_calc_params=not args.simple_pose,
|
|
1035
|
+
verbose=True
|
|
1036
|
+
)
|
|
1037
|
+
except Exception as e:
|
|
1038
|
+
print(f"Failed to initialize pipeline: {e}")
|
|
1039
|
+
return 1
|
|
1040
|
+
|
|
1041
|
+
# Process video
|
|
1042
|
+
try:
|
|
1043
|
+
df = pipeline.process_video(
|
|
1044
|
+
video_path=args.video,
|
|
1045
|
+
output_csv=args.output,
|
|
1046
|
+
max_frames=args.max_frames
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
# Apply post-processing
|
|
1050
|
+
df = pipeline.finalize_predictions(df)
|
|
1051
|
+
|
|
1052
|
+
# Save final results
|
|
1053
|
+
df.to_csv(args.output, index=False)
|
|
1054
|
+
|
|
1055
|
+
print("=" * 80)
|
|
1056
|
+
print("SUCCESS")
|
|
1057
|
+
print("=" * 80)
|
|
1058
|
+
print(f"Processed {len(df)} frames")
|
|
1059
|
+
print(f"Results saved to: {args.output}")
|
|
1060
|
+
print("")
|
|
1061
|
+
|
|
1062
|
+
# Show AU statistics
|
|
1063
|
+
au_cols = [col for col in df.columns if col.startswith('AU') and col.endswith('_r')]
|
|
1064
|
+
if au_cols:
|
|
1065
|
+
print("AU Statistics:")
|
|
1066
|
+
for au_col in sorted(au_cols):
|
|
1067
|
+
success_frames = df[df['success'] == True]
|
|
1068
|
+
if len(success_frames) > 0:
|
|
1069
|
+
mean_val = success_frames[au_col].mean()
|
|
1070
|
+
max_val = success_frames[au_col].max()
|
|
1071
|
+
print(f" {au_col}: mean={mean_val:.3f}, max={max_val:.3f}")
|
|
1072
|
+
|
|
1073
|
+
return 0
|
|
1074
|
+
|
|
1075
|
+
except Exception as e:
|
|
1076
|
+
print(f"Processing failed: {e}")
|
|
1077
|
+
import traceback
|
|
1078
|
+
traceback.print_exc()
|
|
1079
|
+
return 1
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
if __name__ == '__main__':
|
|
1083
|
+
sys.exit(main())
|