pyfaceau 1.3.0__tar.gz → 1.3.3__tar.gz

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 (70) hide show
  1. pyfaceau-1.3.3/PKG-INFO +84 -0
  2. pyfaceau-1.3.3/README.md +39 -0
  3. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/alignment/calc_params.py +40 -49
  4. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/alignment/face_aligner.py +170 -30
  5. pyfaceau-1.3.3/pyfaceau/config.py +118 -0
  6. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/data/hdf5_dataset.py +335 -20
  7. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/data/training_data_generator.py +14 -18
  8. pyfaceau-1.3.3/pyfaceau/features/triangulation.py +109 -0
  9. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/nn/au_prediction_net.py +315 -0
  10. pyfaceau-1.3.3/pyfaceau/nn/fast_pipeline.py +364 -0
  11. pyfaceau-1.3.3/pyfaceau/nn/landmark_pose_net.py +972 -0
  12. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/nn/train_au_prediction.py +137 -24
  13. pyfaceau-1.3.3/pyfaceau/nn/train_landmark_pose.py +819 -0
  14. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/pipeline.py +238 -54
  15. pyfaceau-1.3.3/pyfaceau/prediction/online_au_correction.py +256 -0
  16. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/utils/cython_extensions/cython_rotation_update.pyx +36 -46
  17. pyfaceau-1.3.3/pyfaceau.egg-info/PKG-INFO +84 -0
  18. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau.egg-info/SOURCES.txt +3 -0
  19. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyproject.toml +1 -1
  20. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/setup.py +1 -1
  21. pyfaceau-1.3.0/PKG-INFO +0 -478
  22. pyfaceau-1.3.0/README.md +0 -433
  23. pyfaceau-1.3.0/pyfaceau/features/triangulation.py +0 -64
  24. pyfaceau-1.3.0/pyfaceau/nn/landmark_pose_net.py +0 -497
  25. pyfaceau-1.3.0/pyfaceau/nn/train_landmark_pose.py +0 -508
  26. pyfaceau-1.3.0/pyfaceau.egg-info/PKG-INFO +0 -478
  27. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/LICENSE +0 -0
  28. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/MANIFEST.in +0 -0
  29. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/__init__.py +0 -0
  30. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/alignment/__init__.py +0 -0
  31. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/alignment/numba_calcparams_accelerator.py +0 -0
  32. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/alignment/paw.py +0 -0
  33. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/data/__init__.py +0 -0
  34. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/data/quality_filter.py +0 -0
  35. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/detectors/__init__.py +0 -0
  36. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/detectors/extract_mtcnn_weights.py +0 -0
  37. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/detectors/openface_mtcnn.py +0 -0
  38. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/detectors/pfld.py +0 -0
  39. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/detectors/pymtcnn_detector.py +0 -0
  40. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/detectors/retinaface.py +0 -0
  41. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/download_weights.py +0 -0
  42. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/features/__init__.py +0 -0
  43. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/features/histogram_median_tracker.py +0 -0
  44. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/features/pdm.py +0 -0
  45. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/nn/__init__.py +0 -0
  46. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/nn/au_prediction_inference.py +0 -0
  47. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/nn/landmark_pose_inference.py +0 -0
  48. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/parallel_pipeline.py +0 -0
  49. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/prediction/__init__.py +0 -0
  50. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/prediction/au_predictor.py +0 -0
  51. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/prediction/batched_au_predictor.py +0 -0
  52. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/prediction/model_parser.py +0 -0
  53. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/prediction/running_median.py +0 -0
  54. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/prediction/running_median_fallback.py +0 -0
  55. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/processor.py +0 -0
  56. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/refinement/__init__.py +0 -0
  57. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/refinement/pdm.py +0 -0
  58. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/refinement/svr_patch_expert.py +0 -0
  59. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/refinement/targeted_refiner.py +0 -0
  60. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/utils/__init__.py +0 -0
  61. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/utils/cython_extensions/cython_histogram_median.pyx +0 -0
  62. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau/utils/cython_extensions/setup.py +0 -0
  63. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau.egg-info/dependency_links.txt +0 -0
  64. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau.egg-info/entry_points.txt +0 -0
  65. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau.egg-info/not-zip-safe +0 -0
  66. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau.egg-info/requires.txt +0 -0
  67. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau.egg-info/top_level.txt +0 -0
  68. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/pyfaceau_gui.py +0 -0
  69. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/requirements.txt +0 -0
  70. {pyfaceau-1.3.0 → pyfaceau-1.3.3}/setup.cfg +0 -0
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyfaceau
3
+ Version: 1.3.3
4
+ Summary: Pure Python OpenFace 2.2 AU extraction with CLNF landmark refinement
5
+ Home-page: https://github.com/johnwilsoniv/face-analysis
6
+ Author: John Wilson
7
+ Author-email:
8
+ License: CC BY-NC 4.0
9
+ Project-URL: Homepage, https://github.com/johnwilsoniv/pyfaceau
10
+ Project-URL: Documentation, https://github.com/johnwilsoniv/pyfaceau
11
+ Project-URL: Repository, https://github.com/johnwilsoniv/pyfaceau
12
+ Project-URL: Bug Tracker, https://github.com/johnwilsoniv/pyfaceau/issues
13
+ Keywords: facial-action-units,openface,computer-vision,facial-analysis,emotion-recognition
14
+ Classifier: Development Status :: 5 - Production/Stable
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
18
+ Classifier: License :: Other/Proprietary License
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Operating System :: OS Independent
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: numpy>=1.20.0
28
+ Requires-Dist: opencv-python>=4.5.0
29
+ Requires-Dist: pandas>=1.3.0
30
+ Requires-Dist: onnxruntime>=1.10.0
31
+ Requires-Dist: scipy>=1.7.0
32
+ Requires-Dist: scikit-learn>=1.0.0
33
+ Requires-Dist: tqdm>=4.62.0
34
+ Requires-Dist: pyfhog>=0.1.0
35
+ Requires-Dist: pyclnf>=0.2.0
36
+ Provides-Extra: dev
37
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
38
+ Requires-Dist: black>=22.0.0; extra == "dev"
39
+ Requires-Dist: flake8>=4.0.0; extra == "dev"
40
+ Provides-Extra: accel
41
+ Requires-Dist: onnxruntime-coreml>=1.10.0; extra == "accel"
42
+ Dynamic: home-page
43
+ Dynamic: license-file
44
+ Dynamic: requires-python
45
+
46
+ # pyfaceau
47
+
48
+ Python implementation of OpenFace 2.2's Facial Action Unit extraction pipeline.
49
+
50
+ ## Installation
51
+
52
+ ```bash
53
+ pip install pyfaceau
54
+ ```
55
+
56
+ ## Usage
57
+
58
+ ```python
59
+ from pyfaceau import FaceAnalyzer
60
+
61
+ analyzer = FaceAnalyzer()
62
+ result = analyzer.analyze(image)
63
+
64
+ print(result.au_intensities) # 17 action unit intensities
65
+ print(result.landmarks) # 68 facial landmarks
66
+ print(result.pose) # head pose
67
+ ```
68
+
69
+ ## What it does
70
+
71
+ - Extracts 17 facial action units (AU01, AU02, AU04, AU05, AU06, AU07, AU09, AU10, AU12, AU14, AU15, AU17, AU20, AU23, AU25, AU26, AU45)
72
+ - Detects 68 facial landmarks via [pyclnf](https://github.com/johnwilsoniv/pyclnf)
73
+ - Estimates 3D head pose
74
+ - No C++ compilation required
75
+
76
+ ## Citation
77
+
78
+ If you use this in research, please cite:
79
+
80
+ > Wilson IV, J., Rosenberg, J., Gray, M. L., & Razavi, C. R. (2025). A split-face computer vision/machine learning assessment of facial paralysis using facial action units. *Facial Plastic Surgery & Aesthetic Medicine*. https://doi.org/10.1177/26893614251394382
81
+
82
+ ## License
83
+
84
+ CC BY-NC 4.0 — free for non-commercial use with attribution.
@@ -0,0 +1,39 @@
1
+ # pyfaceau
2
+
3
+ Python implementation of OpenFace 2.2's Facial Action Unit extraction pipeline.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install pyfaceau
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from pyfaceau import FaceAnalyzer
15
+
16
+ analyzer = FaceAnalyzer()
17
+ result = analyzer.analyze(image)
18
+
19
+ print(result.au_intensities) # 17 action unit intensities
20
+ print(result.landmarks) # 68 facial landmarks
21
+ print(result.pose) # head pose
22
+ ```
23
+
24
+ ## What it does
25
+
26
+ - Extracts 17 facial action units (AU01, AU02, AU04, AU05, AU06, AU07, AU09, AU10, AU12, AU14, AU15, AU17, AU20, AU23, AU25, AU26, AU45)
27
+ - Detects 68 facial landmarks via [pyclnf](https://github.com/johnwilsoniv/pyclnf)
28
+ - Estimates 3D head pose
29
+ - No C++ compilation required
30
+
31
+ ## Citation
32
+
33
+ If you use this in research, please cite:
34
+
35
+ > Wilson IV, J., Rosenberg, J., Gray, M. L., & Razavi, C. R. (2025). A split-face computer vision/machine learning assessment of facial paralysis using facial action units. *Facial Plastic Surgery & Aesthetic Medicine*. https://doi.org/10.1177/26893614251394382
36
+
37
+ ## License
38
+
39
+ CC BY-NC 4.0 — free for non-commercial use with attribution.
@@ -17,6 +17,7 @@ Date: 2025-10-29
17
17
  import numpy as np
18
18
  from scipy import linalg
19
19
  import cv2
20
+ import os
20
21
 
21
22
  # Try to import Cython-optimized rotation update for 99.9% accuracy
22
23
  try:
@@ -26,10 +27,12 @@ try:
26
27
  sys.path.insert(0, str(Path(__file__).parent.parent))
27
28
  from cython_rotation_update import update_rotation_cython
28
29
  CYTHON_AVAILABLE = True
29
- print("Cython rotation update module loaded - targeting 99.9% accuracy")
30
+ if os.environ.get('PYFACEAU_VERBOSE', '0') == '1':
31
+ print("Cython rotation update module loaded - targeting 99.9% accuracy")
30
32
  except ImportError:
31
33
  CYTHON_AVAILABLE = False
32
- print("Warning: Cython rotation update not available - using Python (99.45% accuracy)")
34
+ if os.environ.get('PYFACEAU_VERBOSE', '0') == '1':
35
+ print("Warning: Cython rotation update not available - using Python (99.45% accuracy)")
33
36
 
34
37
  # Try to import Numba JIT-accelerated CalcParams functions for 2-5x speedup
35
38
  try:
@@ -41,7 +44,6 @@ try:
41
44
  NUMBA_AVAILABLE = True
42
45
  except ImportError:
43
46
  NUMBA_AVAILABLE = False
44
- print("Warning: Numba JIT accelerator not available - using standard Python (slower)")
45
47
 
46
48
 
47
49
  class CalcParams:
@@ -97,10 +99,10 @@ class CalcParams:
97
99
  @staticmethod
98
100
  def rotation_matrix_to_euler(R):
99
101
  """
100
- Convert 3x3 rotation matrix to Euler angles using robust quaternion extraction
102
+ Convert 3x3 rotation matrix to Euler angles
101
103
 
102
- Matches RotationMatrix2Euler() from RotationHelpers.h
103
- Uses Shepperd's method for robust quaternion extraction (handles all cases)
104
+ EXACTLY matches RotationMatrix2Euler() from RotationHelpers.h lines 73-90
105
+ Uses simple quaternion extraction (assumes trace+1 > 0)
104
106
 
105
107
  Args:
106
108
  R: 3x3 rotation matrix
@@ -108,47 +110,34 @@ class CalcParams:
108
110
  Returns:
109
111
  (rx, ry, rz) Euler angles in radians
110
112
  """
111
- # Robust quaternion extraction using Shepperd's method
112
- # This handles all rotation cases without singularities
113
- trace = R[0,0] + R[1,1] + R[2,2]
114
-
115
- if trace > 0:
116
- # Standard case: trace is positive
117
- s = np.sqrt(trace + 1.0) * 2.0 # s = 4*q0
118
- q0 = 0.25 * s
119
- q1 = (R[2,1] - R[1,2]) / s
120
- q2 = (R[0,2] - R[2,0]) / s
121
- q3 = (R[1,0] - R[0,1]) / s
122
- elif (R[0,0] > R[1,1]) and (R[0,0] > R[2,2]):
123
- # q1 is largest component
124
- s = np.sqrt(1.0 + R[0,0] - R[1,1] - R[2,2]) * 2.0 # s = 4*q1
125
- q0 = (R[2,1] - R[1,2]) / s
126
- q1 = 0.25 * s
127
- q2 = (R[0,1] + R[1,0]) / s
128
- q3 = (R[0,2] + R[2,0]) / s
129
- elif R[1,1] > R[2,2]:
130
- # q2 is largest component
131
- s = np.sqrt(1.0 + R[1,1] - R[0,0] - R[2,2]) * 2.0 # s = 4*q2
132
- q0 = (R[0,2] - R[2,0]) / s
133
- q1 = (R[0,1] + R[1,0]) / s
134
- q2 = 0.25 * s
135
- q3 = (R[1,2] + R[2,1]) / s
136
- else:
137
- # q3 is largest component
138
- s = np.sqrt(1.0 + R[2,2] - R[0,0] - R[1,1]) * 2.0 # s = 4*q3
139
- q0 = (R[1,0] - R[0,1]) / s
140
- q1 = (R[0,2] + R[2,0]) / s
141
- q2 = (R[1,2] + R[2,1]) / s
142
- q3 = 0.25 * s
143
-
144
- # Quaternion to Euler angles
113
+ # EXACT C++ implementation from RotationHelpers.h
114
+ # float q0 = sqrt(1 + rotation_matrix(0, 0) + rotation_matrix(1, 1) + rotation_matrix(2, 2)) / 2.0f;
115
+ q0 = np.sqrt(1.0 + R[0,0] + R[1,1] + R[2,2]) / 2.0
116
+
117
+ # float q1 = (rotation_matrix(2, 1) - rotation_matrix(1, 2)) / (4.0f*q0);
118
+ q1 = (R[2,1] - R[1,2]) / (4.0 * q0)
119
+ # float q2 = (rotation_matrix(0, 2) - rotation_matrix(2, 0)) / (4.0f*q0);
120
+ q2 = (R[0,2] - R[2,0]) / (4.0 * q0)
121
+ # float q3 = (rotation_matrix(1, 0) - rotation_matrix(0, 1)) / (4.0f*q0);
122
+ q3 = (R[1,0] - R[0,1]) / (4.0 * q0)
123
+
124
+ # Quaternion to Euler angles (exactly as in C++)
125
+ # float t1 = 2.0f * (q0*q2 + q1*q3);
145
126
  t1 = 2.0 * (q0*q2 + q1*q3)
146
- t1 = np.clip(t1, -1.0, 1.0) # Handle precision issues
127
+ # if (t1 > 1) t1 = 1.0f; if (t1 < -1) t1 = -1.0f;
128
+ if t1 > 1.0:
129
+ t1 = 1.0
130
+ if t1 < -1.0:
131
+ t1 = -1.0
147
132
 
133
+ # float yaw = asin(t1);
148
134
  yaw = np.arcsin(t1)
135
+ # float pitch = atan2(2.0f * (q0*q1 - q2*q3), q0*q0 - q1*q1 - q2*q2 + q3*q3);
149
136
  pitch = np.arctan2(2.0 * (q0*q1 - q2*q3), q0*q0 - q1*q1 - q2*q2 + q3*q3)
137
+ # float roll = atan2(2.0f * (q0*q3 - q1*q2), q0*q0 + q1*q1 - q2*q2 - q3*q3);
150
138
  roll = np.arctan2(2.0 * (q0*q3 - q1*q2), q0*q0 + q1*q1 - q2*q2 - q3*q3)
151
139
 
140
+ # return cv::Vec3f(pitch, yaw, roll);
152
141
  return np.array([pitch, yaw, roll], dtype=np.float32)
153
142
 
154
143
  @staticmethod
@@ -381,15 +370,15 @@ class CalcParams:
381
370
  euler_new = update_rotation_cython(euler_current, delta_rotation)
382
371
  updated_global[1:4] = euler_new
383
372
  else:
384
- # Fallback to Python implementation (99.45% accuracy)
373
+ # Fallback to Python implementation matching C++ EXACTLY
385
374
  # Get current rotation matrix
386
375
  euler_current = params_global[1:4]
387
376
  R1 = self.euler_to_rotation_matrix(euler_current)
388
377
 
389
378
  # Construct incremental rotation matrix R'
390
- # R' = [1, -wz, wy ]
391
- # [wz, 1, -wx ]
392
- # [-wy, wx, 1 ]
379
+ # R2(1,2) = -1.0*(R2(2,1) = delta_p.at<float>(1,0)); // wx
380
+ # R2(2,0) = -1.0*(R2(0,2) = delta_p.at<float>(2,0)); // wy
381
+ # R2(0,1) = -1.0*(R2(1,0) = delta_p.at<float>(3,0)); // wz
393
382
  R2 = np.eye(3, dtype=np.float32)
394
383
  R2[1, 2] = -delta_p[1] # -wx
395
384
  R2[2, 1] = delta_p[1] # wx
@@ -404,10 +393,12 @@ class CalcParams:
404
393
  # Combine rotations
405
394
  R3 = R1 @ R2
406
395
 
407
- # Convert back to Euler angles using quaternion (matching C++ RotationHelpers.h)
408
- # C++ uses: RotationMatrix2AxisAngle then AxisAngle2Euler (via quaternion)
409
- # Direct quaternion conversion matches C++ better than axis-angle via OpenCV
410
- euler_new = self.rotation_matrix_to_euler(R3)
396
+ # C++ uses: RotationMatrix2AxisAngle -> AxisAngle2Euler
397
+ # cv::Vec3f axis_angle = Utilities::RotationMatrix2AxisAngle(R3);
398
+ # cv::Vec3f euler = Utilities::AxisAngle2Euler(axis_angle);
399
+ # This is: Rodrigues(R3) -> Rodrigues(axis_angle) -> RotationMatrix2Euler
400
+ axis_angle = self.rotation_matrix_to_axis_angle(R3)
401
+ euler_new = self.axis_angle_to_euler(axis_angle)
411
402
 
412
403
  # Handle numerical instability
413
404
  if np.any(np.isnan(euler_new)):
@@ -37,7 +37,7 @@ class OpenFace22FaceAligner:
37
37
  # Testing shows removing eyes improves STABILITY but ruins MAGNITUDE (31° vs 5°)
38
38
  RIGID_INDICES = [1, 2, 3, 4, 12, 13, 14, 15, 27, 28, 29, 31, 32, 33, 34, 35, 36, 39, 40, 41, 42, 45, 46, 47]
39
39
 
40
- def __init__(self, pdm_file: str, sim_scale: float = 0.7, output_size: Tuple[int, int] = (112, 112)):
40
+ def __init__(self, pdm_file: str, sim_scale: float = 0.7, output_size: Tuple[int, int] = (112, 112), y_offset: float = 0.0):
41
41
  """
42
42
  Initialize face aligner with PDM reference shape
43
43
 
@@ -45,9 +45,12 @@ class OpenFace22FaceAligner:
45
45
  pdm_file: Path to PDM model file (e.g., "pdm_68_multi_pie.txt")
46
46
  sim_scale: Scaling factor for reference shape (default: 0.7 for AU analysis)
47
47
  output_size: Output aligned face size in pixels (default: 112×112)
48
+ y_offset: Y-axis offset for centering (negative shifts face UP, default: 0.0)
49
+ Note: Non-zero values can disrupt HOG feature alignment with C++ models.
48
50
  """
49
51
  self.sim_scale = sim_scale
50
52
  self.output_width, self.output_height = output_size
53
+ self.y_offset = y_offset
51
54
 
52
55
  # Load PDM and extract mean shape
53
56
  print(f"Loading PDM from: {pdm_file}")
@@ -56,15 +59,17 @@ class OpenFace22FaceAligner:
56
59
  # Preprocess mean shape: 204 values (68 landmarks × 3D) → 68 landmarks × 2D
57
60
  # OpenFace C++ logic (Face_utils.cpp:112-119):
58
61
  # 1. Scale mean shape by sim_scale
59
- # 2. Discard Z component (take first 136 values = all X,Y coords)
60
- # 3. Reshape to (68, 2) format
62
+ # 2. Extract X and Y coordinates (grouped format)
63
+ # 3. Stack to (68, 2) format
61
64
  #
62
- # CRITICAL: PDM stores as: [x0, y0, x1, y1, ..., x67, y67, z0, z1, ..., z67]
63
- # NOT as: [x0, y0, z0, x1, y1, z1, ...]
64
- # So we must: take first 136 values (all X,Y), then reshape
65
- mean_shape_scaled = pdm.mean_shape * sim_scale # (204, 1)
66
- mean_shape_2d = mean_shape_scaled[:136] # First 136 = all X,Y values
67
- self.reference_shape = mean_shape_2d.reshape(68, 2) # (68, 2)
65
+ # CRITICAL FIX: PDM stores as GROUPED format:
66
+ # [x0, x1, ..., x67, y0, y1, ..., y67, z0, z1, ..., z67]
67
+ # NOT interleaved: [x0, y0, x1, y1, ...]
68
+ # So we must: take first 68 as X, next 68 as Y, stack them
69
+ mean_shape_scaled = pdm.mean_shape.flatten() * sim_scale # (204,)
70
+ x_coords = mean_shape_scaled[:68] # First 68 = all X values
71
+ y_coords = mean_shape_scaled[68:136] # Next 68 = all Y values
72
+ self.reference_shape = np.column_stack([x_coords, y_coords]) # (68, 2)
68
73
 
69
74
  print(f"Face aligner initialized")
70
75
  print(f" Sim scale: {sim_scale}")
@@ -74,7 +79,8 @@ class OpenFace22FaceAligner:
74
79
 
75
80
  def align_face(self, image: np.ndarray, landmarks_68: np.ndarray,
76
81
  pose_tx: float, pose_ty: float, p_rz: float = 0.0,
77
- apply_mask: bool = False, triangulation=None) -> np.ndarray:
82
+ apply_mask: bool = False, triangulation=None,
83
+ mask_style: str = 'detected') -> np.ndarray:
78
84
  """
79
85
  Align face to canonical 112×112 reference frame
80
86
 
@@ -86,6 +92,8 @@ class OpenFace22FaceAligner:
86
92
  p_rz: Pose rotation Z in radians (from OpenFace params_global[3])
87
93
  apply_mask: If True, mask out regions outside the face (like OpenFace C++)
88
94
  triangulation: TriangulationParser object (required if apply_mask=True)
95
+ mask_style: 'detected' uses warped detected landmarks (C++ OpenFace style),
96
+ 'reference' uses reference shape (legacy behavior)
89
97
 
90
98
  Returns:
91
99
  aligned_face: 112×112 aligned face image (BGR format)
@@ -100,22 +108,12 @@ class OpenFace22FaceAligner:
100
108
  source_rigid = self._extract_rigid_points(landmarks_68)
101
109
  dest_rigid = self._extract_rigid_points(self.reference_shape)
102
110
 
103
- # Compute scale (no rotation from Kabsch) - matching working commit approach
104
- scale_identity = self._compute_scale_only(source_rigid, dest_rigid)
105
- scale = scale_identity
106
-
107
- # Apply INVERSE of p_rz rotation
108
- # p_rz describes rotation FROM canonical TO tilted
109
- # We need rotation FROM tilted TO canonical, which is -p_rz
110
- angle = -p_rz
111
- cos_a = np.cos(angle)
112
- sin_a = np.sin(angle)
113
-
114
- R = np.array([[cos_a, -sin_a],
115
- [sin_a, cos_a]], dtype=np.float32)
116
-
117
- # Combine scale and rotation
118
- scale_rot_matrix = scale * R
111
+ # Match C++ exactly: use AlignShapesWithScale to compute BOTH scale and rotation
112
+ # via Kabsch algorithm. This does NOT use p_rz - the rotation comes from
113
+ # finding the optimal alignment between source and destination rigid points.
114
+ # C++ code: Face_utils.cpp line 127:
115
+ # cv::Matx22f scale_rot_matrix = Utilities::AlignShapesWithScale(source_landmarks, destination_landmarks);
116
+ scale_rot_matrix = self._align_shapes_with_scale(source_rigid, dest_rigid)
119
117
 
120
118
  # Build 2×3 affine warp matrix using pose translation
121
119
  warp_matrix = self._build_warp_matrix(scale_rot_matrix, pose_tx, pose_ty)
@@ -133,8 +131,20 @@ class OpenFace22FaceAligner:
133
131
  if triangulation is None:
134
132
  raise ValueError("triangulation required when apply_mask=True")
135
133
 
136
- # Transform landmarks to aligned space
137
- aligned_landmarks = self._transform_landmarks(landmarks_68, warp_matrix)
134
+ if mask_style == 'detected':
135
+ # C++ OpenFace style: transform detected landmarks by warp matrix
136
+ # This adapts the mask per-frame based on actual face shape
137
+ # Reference: Face_utils.cpp::AlignFaceMask() lines 186-209
138
+ warp_2d = scale_rot_matrix
139
+ translation = np.array([warp_matrix[0, 2], warp_matrix[1, 2]])
140
+ aligned_landmarks = landmarks_68 @ warp_2d.T + translation
141
+ else:
142
+ # Legacy style: use reference shape (consistent mask across frames)
143
+ center = np.array([self.output_width / 2, self.output_height / 2])
144
+ aligned_landmarks = self.reference_shape + center
145
+ # Apply correction shift for reference shape centering
146
+ aligned_landmarks[:, 0] += 5.0
147
+ aligned_landmarks[:, 1] += 3.0
138
148
 
139
149
  # Adjust eyebrow landmarks upward to include forehead (like C++)
140
150
  # Indices 17-26 are eyebrows, 0 and 16 are jaw corners
@@ -154,6 +164,80 @@ class OpenFace22FaceAligner:
154
164
 
155
165
  return aligned_face
156
166
 
167
+ def align_face_with_matrix(self, image: np.ndarray, landmarks_68: np.ndarray,
168
+ pose_tx: float, pose_ty: float, p_rz: float = 0.0,
169
+ apply_mask: bool = False, triangulation=None,
170
+ mask_style: str = 'detected') -> tuple:
171
+ """
172
+ Align face and return both the aligned image and the warp matrix.
173
+
174
+ This is the same as align_face() but also returns the 2x3 affine transform
175
+ matrix used for alignment, which can be used to transform landmarks from
176
+ original frame coordinates to aligned face coordinates.
177
+
178
+ Returns:
179
+ tuple: (aligned_face, warp_matrix)
180
+ - aligned_face: 112×112 aligned face image (BGR format)
181
+ - warp_matrix: (2, 3) affine transform matrix
182
+ """
183
+ # Ensure landmarks are (68, 2) shape
184
+ if landmarks_68.shape == (136,):
185
+ landmarks_68 = landmarks_68.reshape(68, 2)
186
+ elif landmarks_68.shape != (68, 2):
187
+ raise ValueError(f"landmarks_68 must be (68, 2) or (136,), got {landmarks_68.shape}")
188
+
189
+ # Extract rigid points from both source and destination
190
+ source_rigid = self._extract_rigid_points(landmarks_68)
191
+ dest_rigid = self._extract_rigid_points(self.reference_shape)
192
+
193
+ # Compute scale-rotation matrix using Kabsch algorithm
194
+ scale_rot_matrix = self._align_shapes_with_scale(source_rigid, dest_rigid)
195
+
196
+ # Build 2×3 affine warp matrix using pose translation
197
+ warp_matrix = self._build_warp_matrix(scale_rot_matrix, pose_tx, pose_ty)
198
+
199
+ # Apply affine transformation
200
+ aligned_face = cv2.warpAffine(
201
+ image,
202
+ warp_matrix,
203
+ (self.output_width, self.output_height),
204
+ flags=cv2.INTER_LINEAR
205
+ )
206
+
207
+ # Apply face mask if requested
208
+ if apply_mask:
209
+ if triangulation is None:
210
+ raise ValueError("triangulation required when apply_mask=True")
211
+
212
+ if mask_style == 'detected':
213
+ # C++ OpenFace style: transform detected landmarks by warp matrix
214
+ warp_2d = scale_rot_matrix
215
+ translation = np.array([warp_matrix[0, 2], warp_matrix[1, 2]])
216
+ aligned_landmarks = landmarks_68 @ warp_2d.T + translation
217
+ else:
218
+ # Legacy style: use reference shape
219
+ center = np.array([self.output_width / 2, self.output_height / 2])
220
+ aligned_landmarks = self.reference_shape + center
221
+ aligned_landmarks[:, 0] += 5.0
222
+ aligned_landmarks[:, 1] += 3.0
223
+
224
+ # Adjust eyebrow landmarks upward to include forehead
225
+ forehead_offset = (30 / 0.7) * self.sim_scale
226
+ for idx in [0, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26]:
227
+ aligned_landmarks[idx, 1] -= forehead_offset
228
+
229
+ # Create mask
230
+ mask = triangulation.create_face_mask(
231
+ aligned_landmarks,
232
+ self.output_width,
233
+ self.output_height
234
+ )
235
+
236
+ # Apply mask to each channel
237
+ aligned_face = cv2.bitwise_and(aligned_face, aligned_face, mask=mask)
238
+
239
+ return aligned_face, warp_matrix
240
+
157
241
  def _transform_landmarks(self, landmarks: np.ndarray, warp_matrix: np.ndarray) -> np.ndarray:
158
242
  """
159
243
  Transform landmarks using affine warp matrix
@@ -197,6 +281,60 @@ class OpenFace22FaceAligner:
197
281
 
198
282
  return s_dst / s_src
199
283
 
284
+ def _align_shapes_with_scale(self, src: np.ndarray, dst: np.ndarray) -> np.ndarray:
285
+ """
286
+ Compute scale AND rotation using Kabsch algorithm (like C++ AlignShapesWithScale)
287
+
288
+ This is the correct approach used by OpenFace C++:
289
+ 1. Mean-normalize both point sets
290
+ 2. Compute RMS scale for each
291
+ 3. Normalize to unit scale
292
+ 4. Use Kabsch/SVD to find optimal rotation
293
+ 5. Return scale * rotation matrix
294
+
295
+ Args:
296
+ src: (N, 2) source points (detected landmarks)
297
+ dst: (N, 2) destination points (reference shape)
298
+
299
+ Returns:
300
+ (2, 2) scale-rotation matrix
301
+ """
302
+ n = src.shape[0]
303
+
304
+ # 1. Mean normalize both
305
+ src_mean = src.mean(axis=0)
306
+ dst_mean = dst.mean(axis=0)
307
+ src_centered = src - src_mean
308
+ dst_centered = dst - dst_mean
309
+
310
+ # 2. Compute RMS scale for each
311
+ s_src = np.sqrt(np.sum(src_centered ** 2) / n)
312
+ s_dst = np.sqrt(np.sum(dst_centered ** 2) / n)
313
+
314
+ # 3. Normalize to unit scale
315
+ src_normed = src_centered / s_src
316
+ dst_normed = dst_centered / s_dst
317
+
318
+ # 4. Kabsch algorithm (SVD) to find optimal rotation
319
+ H = src_normed.T @ dst_normed
320
+ U, S, Vt = np.linalg.svd(H)
321
+
322
+ # Handle reflection (ensure proper rotation) - check BEFORE computing R
323
+ d = np.linalg.det(Vt.T @ U.T)
324
+ corr = np.eye(2)
325
+ if d < 0:
326
+ corr[1, 1] = -1
327
+
328
+ R = Vt.T @ corr @ U.T
329
+
330
+ # Note: NOT transposing R - testing if direct Kabsch matches C++
331
+
332
+ # 5. Combine scale and rotation
333
+ scale = s_dst / s_src
334
+ scale_rot = scale * R
335
+
336
+ return scale_rot.astype(np.float32)
337
+
200
338
  def _build_warp_matrix(self, scale_rot: np.ndarray, pose_tx: float, pose_ty: float) -> np.ndarray:
201
339
  """
202
340
  Build 2×3 affine warp matrix from 2×2 scale-rotation matrix and pose translation
@@ -233,9 +371,11 @@ class OpenFace22FaceAligner:
233
371
  # C++ code (lines 142-143):
234
372
  # warp_matrix(0,2) = -T(0) + out_width/2;
235
373
  # warp_matrix(1,2) = -T(1) + out_height/2;
236
- # NO empirical shifts (+2, -2) - those were incorrect!
374
+ # We add y_offset to shift the face up slightly (negative = up)
375
+ # to account for small differences between Python CalcParams and C++ CLNF fitting
376
+
237
377
  warp_matrix[0, 2] = -T_transformed[0] + self.output_width / 2
238
- warp_matrix[1, 2] = -T_transformed[1] + self.output_height / 2
378
+ warp_matrix[1, 2] = -T_transformed[1] + self.output_height / 2 + self.y_offset
239
379
 
240
380
  return warp_matrix
241
381
 
@@ -0,0 +1,118 @@
1
+ """
2
+ Canonical Configuration for PyFaceAU Pipeline
3
+
4
+ These settings match C++ OpenFace 2.2 defaults for accurate AU extraction.
5
+ DO NOT modify without thorough testing against C++ reference output.
6
+
7
+ Configuration locked on: Dec 5, 2025
8
+ Tested against: IMG_0942.MOV (1110 frames), IMG_0422.MOV (bearded)
9
+ Target accuracy: Sub-pixel landmark error (<1.0 px), AU correlation >0.95
10
+ """
11
+
12
+ # =============================================================================
13
+ # CLNF Landmark Detection Configuration
14
+ # =============================================================================
15
+ CLNF_CONFIG = {
16
+ 'max_iterations': 10,
17
+ 'convergence_threshold': 0.005, # Gold standard (stricter than 0.01)
18
+ 'sigma': 2.25, # C++ CECLM default (1.5 × 1.5 scale factor)
19
+ 'use_eye_refinement': True, # Enable hierarchical eye model refinement
20
+ 'convergence_profile': 'video', # Enable template tracking + scale adaptation
21
+ 'detector': False, # Disable built-in detector (pyfaceau handles)
22
+ }
23
+
24
+ # =============================================================================
25
+ # MTCNN Face Detection Configuration
26
+ # =============================================================================
27
+ MTCNN_CONFIG = {
28
+ 'backend': 'coreml', # Deterministic backend for reproducibility
29
+ 'confidence_threshold': 0.5, # Face confidence threshold
30
+ 'nms_threshold': 0.7, # Non-max suppression threshold
31
+ }
32
+
33
+ # =============================================================================
34
+ # HOG Feature Extraction Configuration
35
+ # =============================================================================
36
+ HOG_CONFIG = {
37
+ 'hog_dim': 4464, # 56×14 cell grid × 9 bins (4464 total)
38
+ 'hog_bins': 1000, # Histogram bins for running median
39
+ 'hog_min': -0.005, # CRITICAL: NOT 0.0 - matches C++ OpenFace
40
+ 'hog_max': 1.0, # Maximum HOG value
41
+ }
42
+
43
+ # =============================================================================
44
+ # Geometric Feature Configuration
45
+ # =============================================================================
46
+ GEOM_CONFIG = {
47
+ 'geom_dim': 238, # 34 PDM params × 7 derivatives
48
+ 'geom_bins': 10000, # Histogram bins for running median
49
+ 'geom_min': -60.0, # Minimum geometric feature value
50
+ 'geom_max': 60.0, # Maximum geometric feature value
51
+ }
52
+
53
+ # =============================================================================
54
+ # AU Prediction Configuration
55
+ # =============================================================================
56
+ AU_CONFIG = {
57
+ # Online AU correction (C++ CorrectOnlineAUs equivalent)
58
+ 'num_bins': 200, # C++ default
59
+ 'min_val': -3.0, # C++ default
60
+ 'max_val': 5.0, # C++ default
61
+ 'cutoff_ratio': 0.10, # 10th percentile baseline
62
+ 'min_frames': 10, # Minimum frames before correction
63
+ 'skip_au17_cutoff': True, # AU17 exception (unusual weight distribution)
64
+ 'apply_online_dyn_shift': False, # Online 10% shift (no impact in testing)
65
+
66
+ # Two-pass processing
67
+ 'max_stored_frames': 3000, # OpenFace default for re-prediction
68
+
69
+ # AU-specific cutoff overrides
70
+ # Python raw predictions are systematically higher than C++ for certain AUs.
71
+ # These adjusted cutoffs compensate to match C++ behavior.
72
+ # See diagnose_raw_prediction_diff.py for derivation.
73
+ 'cutoff_overrides': {
74
+ 'AU20_r': 0.40, # Original: 0.65 -> 0.9729 correlation (PASS)
75
+ 'AU26_r': 0.12, # Original: 0.30 -> 0.9317 correlation (best achievable)
76
+ },
77
+ }
78
+
79
+ # =============================================================================
80
+ # Running Median Tracker Configuration
81
+ # =============================================================================
82
+ RUNNING_MEDIAN_CONFIG = {
83
+ 'hog_dim': HOG_CONFIG['hog_dim'],
84
+ 'geom_dim': GEOM_CONFIG['geom_dim'],
85
+ 'hog_bins': HOG_CONFIG['hog_bins'],
86
+ 'hog_min': HOG_CONFIG['hog_min'],
87
+ 'hog_max': HOG_CONFIG['hog_max'],
88
+ 'geom_bins': GEOM_CONFIG['geom_bins'],
89
+ 'geom_min': GEOM_CONFIG['geom_min'],
90
+ 'geom_max': GEOM_CONFIG['geom_max'],
91
+ }
92
+
93
+ # =============================================================================
94
+ # CLNF Optimizer Defaults (in pyclnf/clnf.py)
95
+ # =============================================================================
96
+ # These are documented here for reference - actual defaults are in pyclnf:
97
+ # regularization: 22.5 # C++ CECLM: 25.0 base × 0.9 = 22.5
98
+ # sigma: 2.25 # C++ CECLM: 1.5 base × 1.5 = 2.25
99
+ # weight_multiplier: 0.0 # C++ disables NU-RLMS weighting
100
+
101
+ # =============================================================================
102
+ # Known Fixes Applied
103
+ # =============================================================================
104
+ # 1. Optimizer defaults: reg=22.5, sigma=2.25 (in pyclnf/clnf.py)
105
+ # 2. PDM epsilon: No +1e-10 in eigenvalue regularization (in pyclnf/core/pdm.py)
106
+ # 3. BORDER_REPLICATE: Used in patch extraction (in pyclnf/core/optimizer.py)
107
+ # 4. Template tracking: Enabled in video mode (in pyclnf/clnf.py)
108
+ # 5. PyMTCNN bbox: [x,y,w,h] format handled correctly (in pymtcnn_detector.py)
109
+ # 6. HOG min: -0.005 (NOT 0.0) matches C++ OpenFace
110
+
111
+ # =============================================================================
112
+ # Validation Targets
113
+ # =============================================================================
114
+ VALIDATION_TARGETS = {
115
+ 'max_landmark_error_px': 1.0, # Mean error threshold
116
+ 'min_au_correlation': 0.95, # For expressed AUs (std > 0.02)
117
+ 'test_video': 'IMG_0942.MOV', # Primary test video
118
+ }