pyfaceau 1.0.3__tar.gz → 1.0.9__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 (58) hide show
  1. {pyfaceau-1.0.3/pyfaceau.egg-info → pyfaceau-1.0.9}/PKG-INFO +26 -14
  2. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/README.md +21 -9
  3. pyfaceau-1.0.9/pyfaceau/clnf/__init__.py +20 -0
  4. pyfaceau-1.0.9/pyfaceau/clnf/cen_patch_experts.py +439 -0
  5. pyfaceau-1.0.9/pyfaceau/clnf/clnf_detector.py +134 -0
  6. pyfaceau-1.0.9/pyfaceau/clnf/nu_rlms.py +248 -0
  7. pyfaceau-1.0.9/pyfaceau/clnf/pdm.py +206 -0
  8. pyfaceau-1.0.9/pyfaceau/detectors/__init__.py +16 -0
  9. pyfaceau-1.0.9/pyfaceau/detectors/extract_mtcnn_weights.py +191 -0
  10. pyfaceau-1.0.9/pyfaceau/detectors/openface_mtcnn.py +786 -0
  11. pyfaceau-1.0.9/pyfaceau/detectors/pymtcnn_detector.py +243 -0
  12. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/features/pdm.py +1 -1
  13. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/parallel_pipeline.py +5 -6
  14. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/pipeline.py +36 -236
  15. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/prediction/model_parser.py +12 -8
  16. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/processor.py +22 -8
  17. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/refinement/__init__.py +2 -1
  18. pyfaceau-1.0.9/pyfaceau/refinement/pdm.py +286 -0
  19. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/refinement/targeted_refiner.py +6 -16
  20. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/utils/cython_extensions/cython_histogram_median.c +8791 -9258
  21. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/utils/cython_extensions/cython_rotation_update.c +7948 -8172
  22. {pyfaceau-1.0.3 → pyfaceau-1.0.9/pyfaceau.egg-info}/PKG-INFO +26 -14
  23. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau.egg-info/SOURCES.txt +9 -1
  24. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyproject.toml +5 -5
  25. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/setup.py +18 -4
  26. pyfaceau-1.0.3/pyfaceau/detectors/retinaface.py +0 -352
  27. pyfaceau-1.0.3/pyfaceau/utils/__init__.py +0 -0
  28. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/COMMERCIAL-LICENSE.md +0 -0
  29. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/LICENSE +0 -0
  30. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/MANIFEST.in +0 -0
  31. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/__init__.py +0 -0
  32. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/alignment/__init__.py +0 -0
  33. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/alignment/calc_params.py +0 -0
  34. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/alignment/face_aligner.py +0 -0
  35. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/alignment/numba_calcparams_accelerator.py +0 -0
  36. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/detectors/pfld.py +0 -0
  37. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/download_weights.py +0 -0
  38. {pyfaceau-1.0.3/pyfaceau/detectors → pyfaceau-1.0.9/pyfaceau/features}/__init__.py +0 -0
  39. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/features/histogram_median_tracker.py +0 -0
  40. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/features/triangulation.py +0 -0
  41. {pyfaceau-1.0.3/pyfaceau/features → pyfaceau-1.0.9/pyfaceau/prediction}/__init__.py +0 -0
  42. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/prediction/au_predictor.py +0 -0
  43. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/prediction/batched_au_predictor.py +0 -0
  44. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/prediction/running_median.py +0 -0
  45. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/prediction/running_median_fallback.py +0 -0
  46. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/refinement/svr_patch_expert.py +0 -0
  47. {pyfaceau-1.0.3/pyfaceau/prediction → pyfaceau-1.0.9/pyfaceau/utils}/__init__.py +0 -0
  48. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/utils/cython_extensions/cython_histogram_median.pyx +0 -0
  49. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/utils/cython_extensions/cython_rotation_update.pyx +0 -0
  50. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau/utils/cython_extensions/setup.py +0 -0
  51. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau.egg-info/dependency_links.txt +0 -0
  52. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau.egg-info/entry_points.txt +0 -0
  53. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau.egg-info/not-zip-safe +0 -0
  54. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau.egg-info/requires.txt +0 -0
  55. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau.egg-info/top_level.txt +0 -0
  56. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/pyfaceau_gui.py +0 -0
  57. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/requirements.txt +0 -0
  58. {pyfaceau-1.0.3 → pyfaceau-1.0.9}/setup.cfg +0 -0
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyfaceau
3
- Version: 1.0.3
3
+ Version: 1.0.9
4
4
  Summary: Pure Python OpenFace 2.2 AU extraction with CLNF landmark refinement
5
5
  Home-page: https://github.com/johnwilsoniv/face-analysis
6
6
  Author: John Wilson
7
7
  Author-email:
8
8
  License: CC BY-NC 4.0
9
- Project-URL: Homepage, https://github.com/johnwilsoniv/face-analysis
10
- Project-URL: Documentation, https://github.com/johnwilsoniv/face-analysis/tree/main/S0%20PyfaceAU
11
- Project-URL: Repository, https://github.com/johnwilsoniv/face-analysis
12
- Project-URL: Bug Tracker, https://github.com/johnwilsoniv/face-analysis/issues
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
13
  Keywords: facial-action-units,openface,computer-vision,facial-analysis,emotion-recognition
14
14
  Classifier: Development Status :: 5 - Production/Stable
15
15
  Classifier: Intended Audience :: Science/Research
@@ -74,8 +74,14 @@ pyfaceau is a Python reimplementation of the [OpenFace 2.2](https://github.com/T
74
74
  #### Option 1: Install from PyPI (Recommended)
75
75
 
76
76
  ```bash
77
- # Install pyfaceau
78
- pip install pyfaceau
77
+ # For NVIDIA GPU (CUDA):
78
+ pip install pyfaceau[cuda]
79
+
80
+ # For Apple Silicon (M1/M2/M3):
81
+ pip install pyfaceau[coreml]
82
+
83
+ # For CPU-only:
84
+ pip install pyfaceau[cpu]
79
85
 
80
86
  # Download model weights (14MB)
81
87
  python -m pyfaceau.download_weights
@@ -84,12 +90,14 @@ python -m pyfaceau.download_weights
84
90
  # https://github.com/johnwilsoniv/face-analysis/tree/main/S0%20PyfaceAU/weights
85
91
  ```
86
92
 
93
+ **Note:** PyFaceAU v1.1.0+ uses [PyMTCNN](https://pypi.org/project/pymtcnn/) for cross-platform face detection with CUDA/CoreML/CPU support.
94
+
87
95
  #### Option 2: Install from Source
88
96
 
89
97
  ```bash
90
98
  # Clone repository
91
99
  git clone https://github.com/johnwilsoniv/face-analysis.git
92
- cd "face-analysis/S0 PyfaceAU"
100
+ cd "face-analysis/pyfaceau"
93
101
 
94
102
  # Install in development mode
95
103
  pip install -e .
@@ -104,13 +112,13 @@ pip install -e .
104
112
  ```python
105
113
  from pyfaceau import ParallelAUPipeline
106
114
 
107
- # Initialize parallel pipeline
115
+ # Initialize parallel pipeline
108
116
  pipeline = ParallelAUPipeline(
109
- retinaface_model='weights/retinaface_mobilenet025_coreml.onnx',
110
117
  pfld_model='weights/pfld_cunjian.onnx',
111
118
  pdm_file='weights/In-the-wild_aligned_PDM_68.txt',
112
119
  au_models_dir='path/to/AU_predictors',
113
120
  triangulation_file='weights/tris_68_full.txt',
121
+ mtcnn_backend='auto', # or 'cuda', 'coreml', 'cpu'
114
122
  num_workers=6, # Adjust based on CPU cores
115
123
  batch_size=30
116
124
  )
@@ -125,20 +133,19 @@ print(f"Processed {len(results)} frames")
125
133
  # Typical output: ~28-50 FPS depending on CPU cores
126
134
  ```
127
135
 
128
- #### Standard Mode (4.6 FPS)
136
+ #### Standard Mode (5-35 FPS depending on backend)
129
137
 
130
138
  ```python
131
139
  from pyfaceau import FullPythonAUPipeline
132
140
 
133
141
  # Initialize standard pipeline
134
142
  pipeline = FullPythonAUPipeline(
135
- retinaface_model='weights/retinaface_mobilenet025_coreml.onnx',
136
143
  pfld_model='weights/pfld_cunjian.onnx',
137
144
  pdm_file='weights/In-the-wild_aligned_PDM_68.txt',
138
145
  au_models_dir='path/to/AU_predictors',
139
146
  triangulation_file='weights/tris_68_full.txt',
147
+ mtcnn_backend='auto', # Automatically selects best backend
140
148
  use_calc_params=True,
141
- use_coreml=True, # macOS only
142
149
  verbose=False
143
150
  )
144
151
 
@@ -149,6 +156,11 @@ results = pipeline.process_video(
149
156
  )
150
157
  ```
151
158
 
159
+ **Performance by backend:**
160
+ - CUDA (NVIDIA GPU): ~35 FPS
161
+ - CoreML (Apple Silicon): ~20-25 FPS
162
+ - CPU: ~5-10 FPS
163
+
152
164
  ### Example Output
153
165
 
154
166
  ```csv
@@ -166,7 +178,7 @@ pyfaceau replicates the complete OpenFace 2.2 AU extraction pipeline:
166
178
  ```
167
179
  Video Input
168
180
 
169
- Face Detection (RetinaFace ONNX)
181
+ Face Detection (PyMTCNN - CUDA/CoreML/CPU)
170
182
 
171
183
  Landmark Detection (PFLD 68-point)
172
184
 
@@ -29,8 +29,14 @@ pyfaceau is a Python reimplementation of the [OpenFace 2.2](https://github.com/T
29
29
  #### Option 1: Install from PyPI (Recommended)
30
30
 
31
31
  ```bash
32
- # Install pyfaceau
33
- pip install pyfaceau
32
+ # For NVIDIA GPU (CUDA):
33
+ pip install pyfaceau[cuda]
34
+
35
+ # For Apple Silicon (M1/M2/M3):
36
+ pip install pyfaceau[coreml]
37
+
38
+ # For CPU-only:
39
+ pip install pyfaceau[cpu]
34
40
 
35
41
  # Download model weights (14MB)
36
42
  python -m pyfaceau.download_weights
@@ -39,12 +45,14 @@ python -m pyfaceau.download_weights
39
45
  # https://github.com/johnwilsoniv/face-analysis/tree/main/S0%20PyfaceAU/weights
40
46
  ```
41
47
 
48
+ **Note:** PyFaceAU v1.1.0+ uses [PyMTCNN](https://pypi.org/project/pymtcnn/) for cross-platform face detection with CUDA/CoreML/CPU support.
49
+
42
50
  #### Option 2: Install from Source
43
51
 
44
52
  ```bash
45
53
  # Clone repository
46
54
  git clone https://github.com/johnwilsoniv/face-analysis.git
47
- cd "face-analysis/S0 PyfaceAU"
55
+ cd "face-analysis/pyfaceau"
48
56
 
49
57
  # Install in development mode
50
58
  pip install -e .
@@ -59,13 +67,13 @@ pip install -e .
59
67
  ```python
60
68
  from pyfaceau import ParallelAUPipeline
61
69
 
62
- # Initialize parallel pipeline
70
+ # Initialize parallel pipeline
63
71
  pipeline = ParallelAUPipeline(
64
- retinaface_model='weights/retinaface_mobilenet025_coreml.onnx',
65
72
  pfld_model='weights/pfld_cunjian.onnx',
66
73
  pdm_file='weights/In-the-wild_aligned_PDM_68.txt',
67
74
  au_models_dir='path/to/AU_predictors',
68
75
  triangulation_file='weights/tris_68_full.txt',
76
+ mtcnn_backend='auto', # or 'cuda', 'coreml', 'cpu'
69
77
  num_workers=6, # Adjust based on CPU cores
70
78
  batch_size=30
71
79
  )
@@ -80,20 +88,19 @@ print(f"Processed {len(results)} frames")
80
88
  # Typical output: ~28-50 FPS depending on CPU cores
81
89
  ```
82
90
 
83
- #### Standard Mode (4.6 FPS)
91
+ #### Standard Mode (5-35 FPS depending on backend)
84
92
 
85
93
  ```python
86
94
  from pyfaceau import FullPythonAUPipeline
87
95
 
88
96
  # Initialize standard pipeline
89
97
  pipeline = FullPythonAUPipeline(
90
- retinaface_model='weights/retinaface_mobilenet025_coreml.onnx',
91
98
  pfld_model='weights/pfld_cunjian.onnx',
92
99
  pdm_file='weights/In-the-wild_aligned_PDM_68.txt',
93
100
  au_models_dir='path/to/AU_predictors',
94
101
  triangulation_file='weights/tris_68_full.txt',
102
+ mtcnn_backend='auto', # Automatically selects best backend
95
103
  use_calc_params=True,
96
- use_coreml=True, # macOS only
97
104
  verbose=False
98
105
  )
99
106
 
@@ -104,6 +111,11 @@ results = pipeline.process_video(
104
111
  )
105
112
  ```
106
113
 
114
+ **Performance by backend:**
115
+ - CUDA (NVIDIA GPU): ~35 FPS
116
+ - CoreML (Apple Silicon): ~20-25 FPS
117
+ - CPU: ~5-10 FPS
118
+
107
119
  ### Example Output
108
120
 
109
121
  ```csv
@@ -121,7 +133,7 @@ pyfaceau replicates the complete OpenFace 2.2 AU extraction pipeline:
121
133
  ```
122
134
  Video Input
123
135
 
124
- Face Detection (RetinaFace ONNX)
136
+ Face Detection (PyMTCNN - CUDA/CoreML/CPU)
125
137
 
126
138
  Landmark Detection (PFLD 68-point)
127
139
 
@@ -0,0 +1,20 @@
1
+ """
2
+ Python implementation of Constrained Local Neural Fields (CLNF).
3
+
4
+ This package provides a pure Python implementation of the CLNF landmark
5
+ detection algorithm for handling challenging cases like surgical markings
6
+ and severe facial paralysis.
7
+
8
+ Components:
9
+ - cen_patch_experts: CEN patch expert loader and inference
10
+ - pdm: Point Distribution Model for shape-constrained fitting
11
+ - nu_rlms: Non-Uniform Regularized Landmark Mean Shift optimization
12
+ - clnf_detector: Main CLNF detector integrating all components
13
+ """
14
+
15
+ from .cen_patch_experts import CENPatchExperts
16
+ from .pdm import PointDistributionModel
17
+ from .nu_rlms import NURLMSOptimizer
18
+ from .clnf_detector import CLNFDetector
19
+
20
+ __all__ = ['CENPatchExperts', 'PointDistributionModel', 'NURLMSOptimizer', 'CLNFDetector']
@@ -0,0 +1,439 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CEN (Convolutional Expert Network) patch expert loader and inference.
4
+
5
+ Loads and runs patch expert models from OpenFace 2.2's .dat format.
6
+ """
7
+
8
+ import numpy as np
9
+ import cv2
10
+ from pathlib import Path
11
+ import struct
12
+
13
+ # Try to import numba for JIT compilation (optional)
14
+ try:
15
+ from numba import njit
16
+ NUMBA_AVAILABLE = True
17
+ except ImportError:
18
+ NUMBA_AVAILABLE = False
19
+ # Dummy decorator if numba not available
20
+ def njit(*args, **kwargs):
21
+ def decorator(func):
22
+ return func
23
+ return decorator if not args else decorator(args[0])
24
+
25
+
26
+ class CENPatchExpert:
27
+ """
28
+ Single CEN patch expert for one landmark at one scale.
29
+
30
+ A patch expert is a small neural network that evaluates how likely
31
+ a landmark is at each position in a local patch.
32
+ """
33
+
34
+ def __init__(self):
35
+ self.width_support = 0
36
+ self.height_support = 0
37
+ self.weights = [] # List of weight matrices for each layer
38
+ self.biases = [] # List of bias vectors for each layer
39
+ self.activation_function = [] # Activation type for each layer
40
+ self.confidence = 0.0
41
+ self.is_empty = False
42
+
43
+ @classmethod
44
+ def from_stream(cls, stream):
45
+ """
46
+ Load a CEN patch expert from binary stream.
47
+
48
+ Args:
49
+ stream: Binary file stream positioned at patch expert data
50
+
51
+ Returns:
52
+ CENPatchExpert instance
53
+ """
54
+ expert = cls()
55
+
56
+ # Read header
57
+ read_type = struct.unpack('i', stream.read(4))[0]
58
+ if read_type != 6:
59
+ raise ValueError(f"Invalid CEN patch expert type: {read_type}, expected 6")
60
+
61
+ # Read dimensions and layer count
62
+ expert.width_support = struct.unpack('i', stream.read(4))[0]
63
+ expert.height_support = struct.unpack('i', stream.read(4))[0]
64
+ num_layers = struct.unpack('i', stream.read(4))[0]
65
+
66
+ # Empty patch (landmark invisible at this orientation)
67
+ if num_layers == 0:
68
+ expert.confidence = struct.unpack('d', stream.read(8))[0]
69
+ expert.is_empty = True
70
+ return expert
71
+
72
+ # Read layers
73
+ for i in range(num_layers):
74
+ # Activation function type
75
+ neuron_type = struct.unpack('i', stream.read(4))[0]
76
+ expert.activation_function.append(neuron_type)
77
+
78
+ # Read bias matrix
79
+ bias = read_mat_bin(stream)
80
+ expert.biases.append(bias)
81
+
82
+ # Read weight matrix
83
+ weight = read_mat_bin(stream)
84
+ expert.weights.append(weight)
85
+
86
+ # Read confidence
87
+ expert.confidence = struct.unpack('d', stream.read(8))[0]
88
+
89
+ return expert
90
+
91
+ def response(self, area_of_interest):
92
+ """
93
+ Compute patch expert response map for an image patch.
94
+
95
+ Args:
96
+ area_of_interest: Grayscale image patch (H, W) as float32
97
+
98
+ Returns:
99
+ response: Response map (response_height, response_width) as float32
100
+ """
101
+ if self.is_empty:
102
+ # Return zero response for empty patches
103
+ response_height = max(1, area_of_interest.shape[0] - self.height_support + 1)
104
+ response_width = max(1, area_of_interest.shape[1] - self.width_support + 1)
105
+ return np.zeros((response_height, response_width), dtype=np.float32)
106
+
107
+ # Apply contrast normalization (row-wise)
108
+ normalized = contrast_norm(area_of_interest)
109
+
110
+ # Convert to column format with bias (im2col)
111
+ input_col = im2col_bias(normalized, self.width_support, self.height_support)
112
+
113
+ # Forward pass through neural network layers
114
+ layer_output = input_col
115
+ for i in range(len(self.weights)):
116
+ # Linear: output = input * weight^T + bias
117
+ layer_output = layer_output @ self.weights[i].T + self.biases[i]
118
+
119
+ # Apply activation function
120
+ if self.activation_function[i] == 0:
121
+ # Sigmoid (clamp extreme values to prevent overflow)
122
+ layer_output = np.clip(layer_output, -88, 88)
123
+ layer_output = 1.0 / (1.0 + np.exp(-layer_output))
124
+ elif self.activation_function[i] == 1:
125
+ # Tanh
126
+ layer_output = np.tanh(layer_output)
127
+ elif self.activation_function[i] == 2:
128
+ # ReLU
129
+ layer_output = np.maximum(0, layer_output)
130
+ # else: linear (no activation)
131
+
132
+ # Reshape output to 2D response map
133
+ response_height = area_of_interest.shape[0] - self.height_support + 1
134
+ response_width = area_of_interest.shape[1] - self.width_support + 1
135
+
136
+ # Output is (num_patches,) -> reshape to (height, width)
137
+ # Patches are now in row-major order (natural numpy order)
138
+ response = layer_output.reshape(response_height, response_width)
139
+
140
+ return response.astype(np.float32)
141
+
142
+
143
+ class CENPatchExperts:
144
+ """
145
+ Collection of CEN patch experts for all landmarks and scales.
146
+
147
+ Manages multi-scale patch experts from OpenFace 2.2 .dat files.
148
+ """
149
+
150
+ def __init__(self, model_dir):
151
+ """
152
+ Load CEN patch experts from model directory.
153
+
154
+ Args:
155
+ model_dir: Path to directory containing cen_patches_*.dat files
156
+ """
157
+ self.model_dir = Path(model_dir)
158
+ self.patch_scaling = [0.25, 0.35, 0.50, 1.00] # Scales available
159
+ self.num_landmarks = 68
160
+
161
+ # patch_experts[scale][landmark]
162
+ self.patch_experts = []
163
+
164
+ # Load all scale levels
165
+ print(f"Loading CEN patch experts (410 MB, ~5-10 seconds)...")
166
+ for idx, scale in enumerate(self.patch_scaling):
167
+ scale_file = self.model_dir / f"cen_patches_{scale:.2f}_of.dat"
168
+ if not scale_file.exists():
169
+ # Try patch_experts subdirectory
170
+ scale_file = self.model_dir / "patch_experts" / f"cen_patches_{scale:.2f}_of.dat"
171
+ if not scale_file.exists():
172
+ raise FileNotFoundError(f"CEN model not found: {scale_file}")
173
+
174
+ print(f" [{idx+1}/4] Loading scale {scale}...")
175
+ experts_at_scale = self._load_scale(scale_file)
176
+ self.patch_experts.append(experts_at_scale)
177
+ print(f" ✓ {len(experts_at_scale)} patch experts loaded")
178
+
179
+ def _load_scale(self, dat_file):
180
+ """
181
+ Load all patch experts at one scale level.
182
+
183
+ Args:
184
+ dat_file: Path to .dat file
185
+
186
+ Returns:
187
+ List of CENPatchExpert instances (frontal view only)
188
+ """
189
+ with open(dat_file, 'rb') as f:
190
+ # File structure (confirmed from OpenFace C++ source):
191
+ # 1. Header: patch_scale (double) + num_views (int)
192
+ # 2. View centers: num_views × (x, y, z) as doubles
193
+ # 3. Visibility matrices: num_views × cv::Mat<int>
194
+ # 4. Patch experts: num_views × num_landmarks × CEN_patch_expert
195
+
196
+ # Read file header
197
+ patch_scale = struct.unpack('d', f.read(8))[0]
198
+ num_views = struct.unpack('i', f.read(4))[0]
199
+
200
+ # Read view centers (3D orientation for each view)
201
+ # Each view center is immediately followed by an empty matrix
202
+ view_centers = []
203
+ for _ in range(num_views):
204
+ x = struct.unpack('d', f.read(8))[0]
205
+ y = struct.unpack('d', f.read(8))[0]
206
+ z = struct.unpack('d', f.read(8))[0]
207
+ view_centers.append((x, y, z))
208
+
209
+ # Read empty matrix immediately after this view center
210
+ empty_mat = read_mat_bin(f) # Should be 0×0 matrix
211
+
212
+ # Read visibility matrices (one per view)
213
+ # These are cv::Mat<int> storing which landmarks are visible in each view
214
+ visibilities = []
215
+ for _ in range(num_views):
216
+ vis_mat = read_mat_bin(f)
217
+ visibilities.append(vis_mat)
218
+
219
+ # Read mirror metadata (for facial symmetry)
220
+ mirror_inds = read_mat_bin(f) # 1×68 matrix
221
+ mirror_views = read_mat_bin(f) # 1×7 matrix
222
+
223
+ # Now read patch experts for all views
224
+ all_experts = []
225
+ for view_idx in range(num_views):
226
+ experts_for_view = []
227
+ for lm_idx in range(self.num_landmarks):
228
+ try:
229
+ expert = CENPatchExpert.from_stream(f)
230
+ experts_for_view.append(expert)
231
+ except Exception as e:
232
+ print(f"Error loading expert (view={view_idx}, landmark={lm_idx}): {e}")
233
+ raise
234
+ all_experts.append(experts_for_view)
235
+
236
+ # Return only frontal view (view 0) for now
237
+ # TODO: Add multi-view support for profile faces
238
+ return all_experts[0]
239
+
240
+ def response(self, image, landmarks, scale_idx):
241
+ """
242
+ Compute patch expert responses for all landmarks at given scale.
243
+
244
+ Args:
245
+ image: Grayscale image (H, W) as float32 [0, 255]
246
+ landmarks: Current landmark positions (68, 2) as float32
247
+ scale_idx: Scale index (0-3)
248
+
249
+ Returns:
250
+ responses: List of 68 response maps (one per landmark)
251
+ extraction_bounds: List of 68 tuples (x1, y1, x2, y2) with actual extraction bounds
252
+ """
253
+ if scale_idx < 0 or scale_idx >= len(self.patch_experts):
254
+ raise ValueError(f"Invalid scale index: {scale_idx}")
255
+
256
+ experts_at_scale = self.patch_experts[scale_idx]
257
+ responses = []
258
+ extraction_bounds = []
259
+
260
+ # For each landmark, extract patch and compute response
261
+ for lm_idx in range(self.num_landmarks):
262
+ expert = experts_at_scale[lm_idx]
263
+
264
+ # Extract a SEARCH AREA around the landmark
265
+ # The search area should be larger than the support window to allow
266
+ # the patch expert to evaluate multiple positions
267
+ # Search radius: 2.0x support (OpenFace default for robust refinement)
268
+ search_radius = int(max(expert.width_support, expert.height_support) * 2.0)
269
+
270
+ lm_x, lm_y = landmarks[lm_idx]
271
+ x1 = max(0, int(lm_x - search_radius))
272
+ y1 = max(0, int(lm_y - search_radius))
273
+ x2 = min(image.shape[1], int(lm_x + search_radius))
274
+ y2 = min(image.shape[0], int(lm_y + search_radius))
275
+
276
+ # Extract and compute response
277
+ patch = image[y1:y2, x1:x2]
278
+ if patch.size > 0 and patch.shape[0] > expert.height_support and patch.shape[1] > expert.width_support:
279
+ response = expert.response(patch)
280
+ else:
281
+ response = np.zeros((1, 1), dtype=np.float32)
282
+
283
+ responses.append(response)
284
+ extraction_bounds.append((x1, y1, x2, y2))
285
+
286
+ return responses, extraction_bounds
287
+
288
+
289
+ def read_mat_bin(stream):
290
+ """
291
+ Read OpenCV matrix from binary stream (OpenFace format).
292
+
293
+ Args:
294
+ stream: Binary file stream
295
+
296
+ Returns:
297
+ numpy array with matrix data
298
+ """
299
+ # Read dimensions and type
300
+ rows = struct.unpack('i', stream.read(4))[0]
301
+ cols = struct.unpack('i', stream.read(4))[0]
302
+ cv_type = struct.unpack('i', stream.read(4))[0]
303
+
304
+ # Handle empty matrices (0×0 or 0×N or N×0)
305
+ if rows == 0 or cols == 0:
306
+ return np.array([], dtype=np.float32).reshape(rows, cols) if rows >= 0 and cols >= 0 else np.array([])
307
+
308
+ # Map OpenCV type to numpy dtype
309
+ # OpenCV type codes: CV_8U=0, CV_8S=1, CV_16U=2, CV_16S=3,
310
+ # CV_32S=4, CV_32F=5, CV_64F=6
311
+ if cv_type == 0: # CV_8U
312
+ dtype = np.uint8
313
+ elif cv_type == 1: # CV_8S
314
+ dtype = np.int8
315
+ elif cv_type == 2: # CV_16U
316
+ dtype = np.uint16
317
+ elif cv_type == 3: # CV_16S
318
+ dtype = np.int16
319
+ elif cv_type == 4: # CV_32S
320
+ dtype = np.int32
321
+ elif cv_type == 5: # CV_32F
322
+ dtype = np.float32
323
+ elif cv_type == 6: # CV_64F
324
+ dtype = np.float64
325
+ else:
326
+ raise ValueError(f"Unsupported OpenCV matrix type: {cv_type} (rows={rows}, cols={cols})")
327
+
328
+ # Read data
329
+ size = rows * cols
330
+ data = np.frombuffer(stream.read(size * np.dtype(dtype).itemsize), dtype=dtype)
331
+
332
+ # Reshape to matrix (OpenCV uses row-major order like NumPy)
333
+ matrix = data.reshape(rows, cols)
334
+
335
+ # For weight/bias matrices, convert to float32; for visibility, keep as-is
336
+ if cv_type in [5, 6]: # Float types
337
+ return matrix.astype(np.float32)
338
+ else: # Integer types
339
+ return matrix
340
+
341
+
342
+ @njit(fastmath=True, cache=True)
343
+ def _contrast_norm_numba(input_patch, output):
344
+ """Numba-optimized contrast normalization (5-10x faster)."""
345
+ for y in range(input_patch.shape[0]):
346
+ # Skip first column, compute mean of rest
347
+ row_sum = 0.0
348
+ cols = input_patch.shape[1] - 1
349
+ for x in range(1, input_patch.shape[1]):
350
+ row_sum += input_patch[y, x]
351
+ mean = row_sum / cols
352
+
353
+ # Compute standard deviation
354
+ sum_sq = 0.0
355
+ for x in range(1, input_patch.shape[1]):
356
+ diff = input_patch[y, x] - mean
357
+ sum_sq += diff * diff
358
+
359
+ norm = np.sqrt(sum_sq)
360
+ if norm < 1e-10:
361
+ norm = 1.0
362
+
363
+ # Normalize (skip first column)
364
+ output[y, 0] = input_patch[y, 0] # Keep first column
365
+ for x in range(1, input_patch.shape[1]):
366
+ output[y, x] = (input_patch[y, x] - mean) / norm
367
+
368
+ return output
369
+
370
+
371
+ def contrast_norm(input_patch):
372
+ """
373
+ Apply row-wise contrast normalization.
374
+
375
+ Uses Numba JIT if available for 5-10x speedup.
376
+
377
+ Args:
378
+ input_patch: Image patch (H, W) as float32
379
+
380
+ Returns:
381
+ normalized: Contrast-normalized patch
382
+ """
383
+ output = np.empty_like(input_patch, dtype=np.float32)
384
+
385
+ if NUMBA_AVAILABLE:
386
+ return _contrast_norm_numba(input_patch, output)
387
+ else:
388
+ # Fallback: NumPy vectorized version
389
+ output = input_patch.copy()
390
+ for y in range(input_patch.shape[0]):
391
+ row = input_patch[y, 1:] # Skip first column
392
+ mean = np.mean(row)
393
+ norm = np.std(row)
394
+ if norm < 1e-10:
395
+ norm = 1.0
396
+ output[y, 1:] = (input_patch[y, 1:] - mean) / norm
397
+ return output
398
+
399
+
400
+ def im2col_bias(input_patch, width, height):
401
+ """
402
+ Convert image to column format with bias for convolutional processing.
403
+
404
+ Uses vectorized stride tricks for 10-20x speedup over loop-based version.
405
+
406
+ Args:
407
+ input_patch: Image patch (m, n) as float32
408
+ width: Sliding window width
409
+ height: Sliding window height
410
+
411
+ Returns:
412
+ output: Matrix (num_windows, width*height+1) with bias column
413
+ """
414
+ m, n = input_patch.shape
415
+ y_blocks = m - height + 1
416
+ x_blocks = n - width + 1
417
+ num_windows = y_blocks * x_blocks
418
+
419
+ # Use stride tricks to create view of all sliding windows (zero-copy!)
420
+ from numpy.lib.stride_tricks import as_strided
421
+
422
+ # Create 4D array: (y_blocks, x_blocks, height, width)
423
+ windows = as_strided(
424
+ input_patch,
425
+ shape=(y_blocks, x_blocks, height, width),
426
+ strides=(input_patch.strides[0], input_patch.strides[1],
427
+ input_patch.strides[0], input_patch.strides[1])
428
+ )
429
+
430
+ # Reshape windows - keep natural row-major order
431
+ # Original: windows shape is (y_blocks, x_blocks, height, width)
432
+ # Flatten to (num_windows, width*height) in row-major order (natural numpy order)
433
+ windows_flat = windows.reshape(num_windows, height * width)
434
+
435
+ # Add bias column
436
+ output = np.ones((num_windows, height * width + 1), dtype=np.float32)
437
+ output[:, 1:] = windows_flat
438
+
439
+ return output