pyfaceau 1.0.6__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.
- {pyfaceau-1.0.6/pyfaceau.egg-info → pyfaceau-1.0.9}/PKG-INFO +26 -14
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/README.md +21 -9
- pyfaceau-1.0.9/pyfaceau/clnf/__init__.py +20 -0
- pyfaceau-1.0.9/pyfaceau/clnf/cen_patch_experts.py +439 -0
- pyfaceau-1.0.9/pyfaceau/clnf/clnf_detector.py +134 -0
- pyfaceau-1.0.9/pyfaceau/clnf/nu_rlms.py +248 -0
- pyfaceau-1.0.9/pyfaceau/clnf/pdm.py +206 -0
- pyfaceau-1.0.9/pyfaceau/detectors/__init__.py +16 -0
- pyfaceau-1.0.9/pyfaceau/detectors/extract_mtcnn_weights.py +191 -0
- pyfaceau-1.0.9/pyfaceau/detectors/openface_mtcnn.py +786 -0
- pyfaceau-1.0.9/pyfaceau/detectors/pymtcnn_detector.py +243 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/features/pdm.py +1 -1
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/parallel_pipeline.py +5 -6
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/pipeline.py +36 -236
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/prediction/model_parser.py +12 -8
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/processor.py +22 -8
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/refinement/__init__.py +2 -1
- pyfaceau-1.0.9/pyfaceau/refinement/pdm.py +286 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/refinement/targeted_refiner.py +6 -16
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/utils/cython_extensions/cython_histogram_median.c +8791 -9258
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/utils/cython_extensions/cython_rotation_update.c +7948 -8172
- {pyfaceau-1.0.6 → pyfaceau-1.0.9/pyfaceau.egg-info}/PKG-INFO +26 -14
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau.egg-info/SOURCES.txt +9 -1
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyproject.toml +5 -5
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/setup.py +18 -4
- pyfaceau-1.0.6/pyfaceau/detectors/retinaface.py +0 -352
- pyfaceau-1.0.6/pyfaceau/utils/__init__.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/COMMERCIAL-LICENSE.md +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/LICENSE +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/MANIFEST.in +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/__init__.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/alignment/__init__.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/alignment/calc_params.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/alignment/face_aligner.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/alignment/numba_calcparams_accelerator.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/detectors/pfld.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/download_weights.py +0 -0
- {pyfaceau-1.0.6/pyfaceau/detectors → pyfaceau-1.0.9/pyfaceau/features}/__init__.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/features/histogram_median_tracker.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/features/triangulation.py +0 -0
- {pyfaceau-1.0.6/pyfaceau/features → pyfaceau-1.0.9/pyfaceau/prediction}/__init__.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/prediction/au_predictor.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/prediction/batched_au_predictor.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/prediction/running_median.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/prediction/running_median_fallback.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/refinement/svr_patch_expert.py +0 -0
- {pyfaceau-1.0.6/pyfaceau/prediction → pyfaceau-1.0.9/pyfaceau/utils}/__init__.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/utils/cython_extensions/cython_histogram_median.pyx +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/utils/cython_extensions/cython_rotation_update.pyx +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau/utils/cython_extensions/setup.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau.egg-info/dependency_links.txt +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau.egg-info/entry_points.txt +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau.egg-info/not-zip-safe +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau.egg-info/requires.txt +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau.egg-info/top_level.txt +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/pyfaceau_gui.py +0 -0
- {pyfaceau-1.0.6 → pyfaceau-1.0.9}/requirements.txt +0 -0
- {pyfaceau-1.0.6 → 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
|
+
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/
|
|
10
|
-
Project-URL: Documentation, https://github.com/johnwilsoniv/
|
|
11
|
-
Project-URL: Repository, https://github.com/johnwilsoniv/
|
|
12
|
-
Project-URL: Bug Tracker, https://github.com/johnwilsoniv/
|
|
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
|
-
#
|
|
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/
|
|
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 (
|
|
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 (
|
|
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
|
-
#
|
|
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/
|
|
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 (
|
|
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 (
|
|
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
|