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.
Files changed (40) hide show
  1. pyfaceau/__init__.py +19 -0
  2. pyfaceau/alignment/__init__.py +0 -0
  3. pyfaceau/alignment/calc_params.py +671 -0
  4. pyfaceau/alignment/face_aligner.py +352 -0
  5. pyfaceau/alignment/numba_calcparams_accelerator.py +244 -0
  6. pyfaceau/cython_histogram_median.cp310-win_amd64.pyd +0 -0
  7. pyfaceau/cython_rotation_update.cp310-win_amd64.pyd +0 -0
  8. pyfaceau/detectors/__init__.py +0 -0
  9. pyfaceau/detectors/pfld.py +128 -0
  10. pyfaceau/detectors/retinaface.py +352 -0
  11. pyfaceau/download_weights.py +134 -0
  12. pyfaceau/features/__init__.py +0 -0
  13. pyfaceau/features/histogram_median_tracker.py +335 -0
  14. pyfaceau/features/pdm.py +269 -0
  15. pyfaceau/features/triangulation.py +64 -0
  16. pyfaceau/parallel_pipeline.py +462 -0
  17. pyfaceau/pipeline.py +1083 -0
  18. pyfaceau/prediction/__init__.py +0 -0
  19. pyfaceau/prediction/au_predictor.py +434 -0
  20. pyfaceau/prediction/batched_au_predictor.py +269 -0
  21. pyfaceau/prediction/model_parser.py +337 -0
  22. pyfaceau/prediction/running_median.py +318 -0
  23. pyfaceau/prediction/running_median_fallback.py +200 -0
  24. pyfaceau/processor.py +270 -0
  25. pyfaceau/refinement/__init__.py +12 -0
  26. pyfaceau/refinement/svr_patch_expert.py +361 -0
  27. pyfaceau/refinement/targeted_refiner.py +362 -0
  28. pyfaceau/utils/__init__.py +0 -0
  29. pyfaceau/utils/cython_extensions/cython_histogram_median.c +35391 -0
  30. pyfaceau/utils/cython_extensions/cython_histogram_median.pyx +316 -0
  31. pyfaceau/utils/cython_extensions/cython_rotation_update.c +32262 -0
  32. pyfaceau/utils/cython_extensions/cython_rotation_update.pyx +211 -0
  33. pyfaceau/utils/cython_extensions/setup.py +47 -0
  34. pyfaceau-1.0.3.data/scripts/pyfaceau_gui.py +302 -0
  35. pyfaceau-1.0.3.dist-info/METADATA +466 -0
  36. pyfaceau-1.0.3.dist-info/RECORD +40 -0
  37. pyfaceau-1.0.3.dist-info/WHEEL +5 -0
  38. pyfaceau-1.0.3.dist-info/entry_points.txt +3 -0
  39. pyfaceau-1.0.3.dist-info/licenses/LICENSE +40 -0
  40. 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())