pyfaceau 1.0.6__cp310-cp310-macosx_11_0_arm64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyfaceau/__init__.py +19 -0
- pyfaceau/alignment/__init__.py +0 -0
- pyfaceau/alignment/calc_params.py +671 -0
- pyfaceau/alignment/face_aligner.py +352 -0
- pyfaceau/alignment/numba_calcparams_accelerator.py +244 -0
- pyfaceau/cython_histogram_median.cpython-310-darwin.so +0 -0
- pyfaceau/cython_rotation_update.cpython-310-darwin.so +0 -0
- pyfaceau/detectors/__init__.py +0 -0
- pyfaceau/detectors/pfld.py +128 -0
- pyfaceau/detectors/retinaface.py +352 -0
- pyfaceau/download_weights.py +134 -0
- pyfaceau/features/__init__.py +0 -0
- pyfaceau/features/histogram_median_tracker.py +335 -0
- pyfaceau/features/pdm.py +269 -0
- pyfaceau/features/triangulation.py +64 -0
- pyfaceau/parallel_pipeline.py +462 -0
- pyfaceau/pipeline.py +1083 -0
- pyfaceau/prediction/__init__.py +0 -0
- pyfaceau/prediction/au_predictor.py +434 -0
- pyfaceau/prediction/batched_au_predictor.py +269 -0
- pyfaceau/prediction/model_parser.py +337 -0
- pyfaceau/prediction/running_median.py +318 -0
- pyfaceau/prediction/running_median_fallback.py +200 -0
- pyfaceau/processor.py +270 -0
- pyfaceau/refinement/__init__.py +12 -0
- pyfaceau/refinement/svr_patch_expert.py +361 -0
- pyfaceau/refinement/targeted_refiner.py +362 -0
- pyfaceau/utils/__init__.py +0 -0
- pyfaceau/utils/cython_extensions/cython_histogram_median.c +35391 -0
- pyfaceau/utils/cython_extensions/cython_histogram_median.pyx +316 -0
- pyfaceau/utils/cython_extensions/cython_rotation_update.c +32262 -0
- pyfaceau/utils/cython_extensions/cython_rotation_update.pyx +211 -0
- pyfaceau/utils/cython_extensions/setup.py +47 -0
- pyfaceau-1.0.6.data/scripts/pyfaceau_gui.py +302 -0
- pyfaceau-1.0.6.dist-info/METADATA +466 -0
- pyfaceau-1.0.6.dist-info/RECORD +40 -0
- pyfaceau-1.0.6.dist-info/WHEEL +5 -0
- pyfaceau-1.0.6.dist-info/entry_points.txt +3 -0
- pyfaceau-1.0.6.dist-info/licenses/LICENSE +40 -0
- pyfaceau-1.0.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
OpenFace 2.2 SVR Model Parser
|
|
4
|
+
|
|
5
|
+
Parses OpenFace 2.2's binary .dat files containing Linear SVR models for AU prediction.
|
|
6
|
+
|
|
7
|
+
Binary Format (per model):
|
|
8
|
+
1. means matrix: (rows, cols, dtype, data[rows*cols*8])
|
|
9
|
+
2. support_vectors matrix: (rows, cols, dtype, data[rows*cols*8])
|
|
10
|
+
3. bias: float64 (8 bytes)
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
parser = OF22ModelParser(models_dir)
|
|
14
|
+
models = parser.load_all_models()
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Dict, Tuple
|
|
20
|
+
import struct
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OF22ModelParser:
|
|
24
|
+
"""
|
|
25
|
+
Parser for OpenFace 2.2 Linear SVR models stored in binary .dat format
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, models_dir: str):
|
|
29
|
+
"""
|
|
30
|
+
Initialize parser with path to OF2.2 models directory
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
models_dir: Path to AU_predictors directory (will use svr_combined by default)
|
|
34
|
+
"""
|
|
35
|
+
self.models_dir = Path(models_dir)
|
|
36
|
+
if not self.models_dir.exists():
|
|
37
|
+
raise ValueError(f"Models directory not found: {models_dir}")
|
|
38
|
+
|
|
39
|
+
# All 17 AUs that OF2.2 supports with intensity estimation (SVR)
|
|
40
|
+
# AU28 is excluded - it only has occurrence detection (SVM), not intensity
|
|
41
|
+
# Based on AU_all_best.txt configuration
|
|
42
|
+
self.available_aus = [
|
|
43
|
+
'AU01_r', 'AU02_r', 'AU04_r', 'AU05_r', 'AU06_r', 'AU07_r',
|
|
44
|
+
'AU09_r', 'AU10_r', 'AU12_r', 'AU14_r', 'AU15_r', 'AU17_r',
|
|
45
|
+
'AU20_r', 'AU23_r', 'AU25_r', 'AU26_r', 'AU45_r'
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# Mapping of AU to recommended model type from AU_all_best.txt
|
|
49
|
+
self.recommended_model_types = {
|
|
50
|
+
'AU01_r': 'dynamic',
|
|
51
|
+
'AU02_r': 'dynamic',
|
|
52
|
+
'AU04_r': 'static',
|
|
53
|
+
'AU05_r': 'dynamic',
|
|
54
|
+
'AU06_r': 'static',
|
|
55
|
+
'AU07_r': 'static',
|
|
56
|
+
'AU09_r': 'dynamic',
|
|
57
|
+
'AU10_r': 'static',
|
|
58
|
+
'AU12_r': 'static',
|
|
59
|
+
'AU14_r': 'static',
|
|
60
|
+
'AU15_r': 'dynamic',
|
|
61
|
+
'AU17_r': 'dynamic',
|
|
62
|
+
'AU20_r': 'dynamic',
|
|
63
|
+
'AU23_r': 'dynamic',
|
|
64
|
+
'AU25_r': 'dynamic',
|
|
65
|
+
'AU26_r': 'dynamic',
|
|
66
|
+
'AU45_r': 'dynamic'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def parse_svr_model(self, dat_file_path: Path, is_dynamic: bool = True) -> Dict:
|
|
70
|
+
"""
|
|
71
|
+
Parse a single SVR model from .dat file
|
|
72
|
+
|
|
73
|
+
Binary format for DYNAMIC models:
|
|
74
|
+
1. int32: marker (always 1)
|
|
75
|
+
2. float64: cutoff for person-specific calibration
|
|
76
|
+
3. means matrix: (rows, cols, dtype, data)
|
|
77
|
+
4. support_vectors matrix: (rows, cols, dtype, data)
|
|
78
|
+
5. bias: float64
|
|
79
|
+
|
|
80
|
+
Binary format for STATIC models:
|
|
81
|
+
1. means matrix: (rows, cols, dtype, data)
|
|
82
|
+
2. support_vectors matrix: (rows, cols, dtype, data)
|
|
83
|
+
3. bias: float64
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
dat_file_path: Path to .dat file
|
|
87
|
+
is_dynamic: True for dynamic models (has cutoff), False for static models
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
dict with keys: 'cutoff', 'means', 'support_vectors', 'bias'
|
|
91
|
+
"""
|
|
92
|
+
if not dat_file_path.exists():
|
|
93
|
+
raise FileNotFoundError(f"Model file not found: {dat_file_path}")
|
|
94
|
+
|
|
95
|
+
with open(dat_file_path, 'rb') as f:
|
|
96
|
+
# All models start with a marker (int32)
|
|
97
|
+
# marker = 1: dynamic model (has cutoff)
|
|
98
|
+
# marker = 0: static model (no cutoff)
|
|
99
|
+
marker = struct.unpack('<i', f.read(4))[0]
|
|
100
|
+
|
|
101
|
+
if marker == 1:
|
|
102
|
+
# Dynamic model - read cutoff value
|
|
103
|
+
cutoff = struct.unpack('<d', f.read(8))[0] # float64
|
|
104
|
+
if not is_dynamic:
|
|
105
|
+
print(f"Warning: File has dynamic marker but loaded as static")
|
|
106
|
+
elif marker == 0:
|
|
107
|
+
# Static model - no cutoff
|
|
108
|
+
cutoff = 0.0
|
|
109
|
+
if is_dynamic:
|
|
110
|
+
print(f"Warning: File has static marker but loaded as dynamic")
|
|
111
|
+
else:
|
|
112
|
+
raise ValueError(f"Invalid marker: expected 0 or 1, got {marker}")
|
|
113
|
+
|
|
114
|
+
# Read means matrix header
|
|
115
|
+
means_rows = struct.unpack('<i', f.read(4))[0]
|
|
116
|
+
means_cols = struct.unpack('<i', f.read(4))[0]
|
|
117
|
+
means_dtype = struct.unpack('<i', f.read(4))[0] # OpenCV type code
|
|
118
|
+
|
|
119
|
+
# Read means data (may be empty if cols=0)
|
|
120
|
+
if means_rows > 0 and means_cols > 0:
|
|
121
|
+
means_data = np.frombuffer(
|
|
122
|
+
f.read(means_rows * means_cols * 8),
|
|
123
|
+
dtype=np.float64
|
|
124
|
+
)
|
|
125
|
+
means = means_data.reshape((means_rows, means_cols))
|
|
126
|
+
else:
|
|
127
|
+
# Empty means matrix - no centering needed
|
|
128
|
+
means = np.array([], dtype=np.float64).reshape(means_rows, means_cols)
|
|
129
|
+
|
|
130
|
+
# Read support vectors matrix header
|
|
131
|
+
sv_rows = struct.unpack('<i', f.read(4))[0]
|
|
132
|
+
sv_cols = struct.unpack('<i', f.read(4))[0]
|
|
133
|
+
sv_dtype = struct.unpack('<i', f.read(4))[0]
|
|
134
|
+
|
|
135
|
+
# Read support vectors data
|
|
136
|
+
sv_data = np.frombuffer(
|
|
137
|
+
f.read(sv_rows * sv_cols * 8),
|
|
138
|
+
dtype=np.float64
|
|
139
|
+
)
|
|
140
|
+
support_vectors = sv_data.reshape((sv_rows, sv_cols))
|
|
141
|
+
|
|
142
|
+
# Read bias
|
|
143
|
+
bias = struct.unpack('<d', f.read(8))[0] # double = float64
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
'cutoff': cutoff,
|
|
147
|
+
'means': means,
|
|
148
|
+
'support_vectors': support_vectors,
|
|
149
|
+
'bias': bias,
|
|
150
|
+
'file': str(dat_file_path)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
def load_au_model(self, au_name: str, use_dynamic: bool = None, use_combined: bool = True) -> Dict:
|
|
154
|
+
"""
|
|
155
|
+
Load a specific AU model
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
au_name: AU name (e.g., 'AU01_r', 'AU12_r')
|
|
159
|
+
use_dynamic: If True, load dynamic model; if False, load static model;
|
|
160
|
+
if None, use recommended type from AU_all_best.txt
|
|
161
|
+
use_combined: If True, prefer svr_combined models (default)
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
Parsed model dict
|
|
165
|
+
"""
|
|
166
|
+
# Convert AU name to file format (e.g., 'AU01_r' -> 'AU_1')
|
|
167
|
+
au_num = au_name.replace('AU', '').replace('_r', '').lstrip('0')
|
|
168
|
+
|
|
169
|
+
# Determine model type (dynamic vs static)
|
|
170
|
+
if use_dynamic is None:
|
|
171
|
+
# Use recommended type from configuration
|
|
172
|
+
model_type_str = self.recommended_model_types.get(au_name, 'dynamic')
|
|
173
|
+
else:
|
|
174
|
+
model_type_str = 'dynamic' if use_dynamic else 'static'
|
|
175
|
+
|
|
176
|
+
# Try different file naming patterns in svr_combined
|
|
177
|
+
base_dir = self.models_dir if not use_combined else (self.models_dir / 'svr_combined' if (self.models_dir / 'svr_combined').exists() else self.models_dir)
|
|
178
|
+
|
|
179
|
+
# Possible filename patterns (svr_combined uses inconsistent naming)
|
|
180
|
+
possible_files = [
|
|
181
|
+
f"AU_{au_num}_{model_type_str}_intensity_comb.dat",
|
|
182
|
+
f"AU_{au_num}_{model_type_str}_intensity.dat",
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
dat_path = None
|
|
186
|
+
for filename in possible_files:
|
|
187
|
+
candidate = base_dir / filename
|
|
188
|
+
if candidate.exists():
|
|
189
|
+
dat_path = candidate
|
|
190
|
+
break
|
|
191
|
+
|
|
192
|
+
if dat_path is None:
|
|
193
|
+
raise FileNotFoundError(
|
|
194
|
+
f"Model not found for {au_name}. Tried: {possible_files} in {base_dir}"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Parse the model with correct format (dynamic vs static)
|
|
198
|
+
is_dynamic = (model_type_str == 'dynamic')
|
|
199
|
+
model = self.parse_svr_model(dat_path, is_dynamic=is_dynamic)
|
|
200
|
+
model['au_name'] = au_name
|
|
201
|
+
model['model_type'] = model_type_str
|
|
202
|
+
model['filename'] = dat_path.name
|
|
203
|
+
|
|
204
|
+
return model
|
|
205
|
+
|
|
206
|
+
def load_all_models(self, use_recommended: bool = True, use_combined: bool = True) -> Dict[str, Dict]:
|
|
207
|
+
"""
|
|
208
|
+
Load all available AU models
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
use_recommended: If True, use recommended static/dynamic type for each AU
|
|
212
|
+
from AU_all_best.txt configuration
|
|
213
|
+
use_combined: If True, load from svr_combined (higher quality, trained on
|
|
214
|
+
combined datasets)
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Dictionary mapping AU names to model dicts
|
|
218
|
+
"""
|
|
219
|
+
models = {}
|
|
220
|
+
failed_aus = []
|
|
221
|
+
|
|
222
|
+
for au_name in self.available_aus:
|
|
223
|
+
try:
|
|
224
|
+
use_dynamic = None if use_recommended else True
|
|
225
|
+
model = self.load_au_model(au_name, use_dynamic=use_dynamic, use_combined=use_combined)
|
|
226
|
+
models[au_name] = model
|
|
227
|
+
print(f"Loaded {au_name} ({model['model_type']}): cutoff={model['cutoff']:.2f}, "
|
|
228
|
+
f"means={model['means'].shape}, SV={model['support_vectors'].shape}, "
|
|
229
|
+
f"bias={model['bias']:.4f} [{model['filename']}]")
|
|
230
|
+
except FileNotFoundError as e:
|
|
231
|
+
failed_aus.append(au_name)
|
|
232
|
+
print(f"✗ Failed to load {au_name}: {e}")
|
|
233
|
+
|
|
234
|
+
print(f"\nLoaded {len(models)}/{len(self.available_aus)} AU models")
|
|
235
|
+
if failed_aus:
|
|
236
|
+
print(f"Failed AUs: {failed_aus}")
|
|
237
|
+
|
|
238
|
+
return models
|
|
239
|
+
|
|
240
|
+
def predict_au(self, fhog_features: np.ndarray, model: Dict) -> float:
|
|
241
|
+
"""
|
|
242
|
+
Predict AU intensity using Linear SVR
|
|
243
|
+
|
|
244
|
+
Prediction formula (from OF2.2 SVR_static_lin_regressors.cpp:98):
|
|
245
|
+
- preds = (fhog_descriptor - means) * support_vectors + bias
|
|
246
|
+
|
|
247
|
+
Note: The 'cutoff' value in dynamic models is for person-specific
|
|
248
|
+
calibration and is NOT used in the linear prediction itself.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
fhog_features: FHOG feature vector (1D array)
|
|
252
|
+
model: Parsed SVR model dict
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Predicted AU intensity (float, typically 0-5 range)
|
|
256
|
+
"""
|
|
257
|
+
# Ensure features are 2D (1, n_features)
|
|
258
|
+
if fhog_features.ndim == 1:
|
|
259
|
+
fhog_features = fhog_features.reshape(1, -1)
|
|
260
|
+
|
|
261
|
+
# Check dimensions against support vectors
|
|
262
|
+
# SV shape is (n_features, 1), so check against dimension 0
|
|
263
|
+
expected_dims = model['support_vectors'].shape[0]
|
|
264
|
+
if fhog_features.shape[1] != expected_dims:
|
|
265
|
+
raise ValueError(
|
|
266
|
+
f"Feature dimension mismatch: got {fhog_features.shape[1]}, "
|
|
267
|
+
f"expected {expected_dims}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Center features (if means matrix exists and is non-empty)
|
|
271
|
+
if model['means'].size > 0:
|
|
272
|
+
centered = fhog_features - model['means']
|
|
273
|
+
else:
|
|
274
|
+
centered = fhog_features
|
|
275
|
+
|
|
276
|
+
# Apply support vectors (matrix multiplication)
|
|
277
|
+
# centered: (1, 4702), support_vectors: (4702, 1) -> result: (1, 1)
|
|
278
|
+
prediction = np.dot(centered, model['support_vectors']) + model['bias']
|
|
279
|
+
|
|
280
|
+
return float(prediction[0, 0])
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def main():
|
|
284
|
+
"""Test the model parser"""
|
|
285
|
+
import sys
|
|
286
|
+
|
|
287
|
+
models_dir = "/Users/johnwilsoniv/repo/fea_tool/external_libs/openFace/OpenFace/lib/local/FaceAnalyser/AU_predictors"
|
|
288
|
+
|
|
289
|
+
print("=" * 80)
|
|
290
|
+
print("OpenFace 2.2 SVR Model Parser - Test (All 17 AUs)")
|
|
291
|
+
print("=" * 80)
|
|
292
|
+
print(f"\nModels directory: {models_dir}\n")
|
|
293
|
+
|
|
294
|
+
# Test parser
|
|
295
|
+
parser = OF22ModelParser(models_dir)
|
|
296
|
+
|
|
297
|
+
# Load all models (using recommended static/dynamic types and svr_combined)
|
|
298
|
+
print("\nLoading all AU models (using AU_all_best.txt configuration)...")
|
|
299
|
+
print("-" * 80)
|
|
300
|
+
models = parser.load_all_models(use_recommended=True, use_combined=True)
|
|
301
|
+
|
|
302
|
+
# Summary
|
|
303
|
+
print("\n" + "=" * 80)
|
|
304
|
+
print("Model Loading Summary")
|
|
305
|
+
print("=" * 80)
|
|
306
|
+
print(f"Successfully loaded: {len(models)} AU models")
|
|
307
|
+
print(f"AU names: {list(models.keys())}")
|
|
308
|
+
|
|
309
|
+
# Test prediction with dummy features
|
|
310
|
+
if models:
|
|
311
|
+
print("\n" + "=" * 80)
|
|
312
|
+
print("Testing Prediction with Dummy Features")
|
|
313
|
+
print("=" * 80)
|
|
314
|
+
|
|
315
|
+
# Get first model
|
|
316
|
+
au_name = list(models.keys())[0]
|
|
317
|
+
model = models[au_name]
|
|
318
|
+
|
|
319
|
+
# Create dummy FHOG features (all zeros)
|
|
320
|
+
n_features = model['means'].shape[1]
|
|
321
|
+
dummy_features = np.zeros(n_features, dtype=np.float64)
|
|
322
|
+
|
|
323
|
+
print(f"\nTesting {au_name}:")
|
|
324
|
+
print(f" Feature dimensions: {n_features}")
|
|
325
|
+
print(f" Dummy features: all zeros")
|
|
326
|
+
|
|
327
|
+
prediction = parser.predict_au(dummy_features, model)
|
|
328
|
+
print(f" Prediction: {prediction:.4f}")
|
|
329
|
+
print(f" (Expected: close to bias={model['bias']:.4f} since features are zero)")
|
|
330
|
+
|
|
331
|
+
print("\n" + "=" * 80)
|
|
332
|
+
print("Test complete!")
|
|
333
|
+
print("=" * 80)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
if __name__ == "__main__":
|
|
337
|
+
main()
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Histogram-Based Running Median Tracker
|
|
4
|
+
|
|
5
|
+
Implements OpenFace 2.2's histogram-based running median algorithm for
|
|
6
|
+
person-specific normalization. Matches the C++ implementation in
|
|
7
|
+
FaceAnalyser::UpdateRunningMedian().
|
|
8
|
+
|
|
9
|
+
Reference: OpenFace/lib/local/FaceAnalyser/src/FaceAnalyser.cpp:764-821
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class HistogramBasedMedianTracker:
|
|
16
|
+
"""
|
|
17
|
+
Histogram-based running median tracker matching OpenFace 2.2's implementation
|
|
18
|
+
|
|
19
|
+
Uses binned histograms to efficiently compute running median without storing
|
|
20
|
+
all historical values. More memory-efficient than rolling window approach.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, feature_dim: int, num_bins: int = 200,
|
|
24
|
+
min_val: float = -3.0, max_val: float = 5.0):
|
|
25
|
+
"""
|
|
26
|
+
Initialize histogram-based median tracker
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
feature_dim: Dimensionality of feature vectors
|
|
30
|
+
num_bins: Number of histogram bins (OF2.2 uses 200)
|
|
31
|
+
min_val: Minimum value for histogram range
|
|
32
|
+
max_val: Maximum value for histogram range
|
|
33
|
+
"""
|
|
34
|
+
self.feature_dim = feature_dim
|
|
35
|
+
self.num_bins = num_bins
|
|
36
|
+
self.min_val = min_val
|
|
37
|
+
self.max_val = max_val
|
|
38
|
+
|
|
39
|
+
# Histogram: (feature_dim, num_bins)
|
|
40
|
+
# Each row tracks the distribution of one feature dimension
|
|
41
|
+
self.histogram = np.zeros((feature_dim, num_bins), dtype=np.int32)
|
|
42
|
+
|
|
43
|
+
# Current median estimate
|
|
44
|
+
self.current_median = np.zeros(feature_dim, dtype=np.float64)
|
|
45
|
+
|
|
46
|
+
# Total count of updates
|
|
47
|
+
self.hist_count = 0
|
|
48
|
+
|
|
49
|
+
# Precompute bin width
|
|
50
|
+
self.length = max_val - min_val
|
|
51
|
+
self.bin_width = self.length / num_bins
|
|
52
|
+
|
|
53
|
+
def update(self, features: np.ndarray, update_histogram: bool = True) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Update tracker with new feature vector
|
|
56
|
+
|
|
57
|
+
Matches C++ UpdateRunningMedian() logic:
|
|
58
|
+
1. Bin each feature value into histogram
|
|
59
|
+
2. Update histogram counts
|
|
60
|
+
3. Recompute median from cumulative distribution
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
features: Feature vector (1D array)
|
|
64
|
+
update_histogram: Whether to update histogram (OF2.2 does this every 2nd frame)
|
|
65
|
+
"""
|
|
66
|
+
# Ensure 1D
|
|
67
|
+
if features.ndim == 2:
|
|
68
|
+
features = features.flatten()
|
|
69
|
+
|
|
70
|
+
assert features.shape[0] == self.feature_dim, \
|
|
71
|
+
f"Expected {self.feature_dim} features, got {features.shape[0]}"
|
|
72
|
+
|
|
73
|
+
if update_histogram:
|
|
74
|
+
# Convert feature values to bin indices
|
|
75
|
+
# Formula from C++: (descriptor - min_val) * num_bins / (max_val - min_val)
|
|
76
|
+
converted = (features - self.min_val) * self.num_bins / self.length
|
|
77
|
+
|
|
78
|
+
# Cap values to [0, num_bins-1] BEFORE casting to int (matches C++)
|
|
79
|
+
# C++ does: setTo(num_bins-1, converted > num_bins-1) then setTo(0, converted < 0)
|
|
80
|
+
converted = np.clip(converted, 0.0, float(self.num_bins - 1))
|
|
81
|
+
|
|
82
|
+
# Cast to int (truncation, matches C++ (int) cast)
|
|
83
|
+
converted = converted.astype(np.int32)
|
|
84
|
+
|
|
85
|
+
# Update histogram counts
|
|
86
|
+
for i in range(self.feature_dim):
|
|
87
|
+
bin_idx = converted[i]
|
|
88
|
+
self.histogram[i, bin_idx] += 1
|
|
89
|
+
|
|
90
|
+
self.hist_count += 1
|
|
91
|
+
|
|
92
|
+
# Compute median (matches C++: always sets median, even on frame 0)
|
|
93
|
+
# On frame 0 (hist_count==0), C++ sets median=descriptor.clone()
|
|
94
|
+
# On frame 1+ with hist_count==1, C++ also uses descriptor directly
|
|
95
|
+
# Otherwise, compute from histogram
|
|
96
|
+
if self.hist_count == 0:
|
|
97
|
+
# Frame 0: histogram not updated yet, use descriptor directly
|
|
98
|
+
self.current_median = features.copy()
|
|
99
|
+
elif self.hist_count == 1:
|
|
100
|
+
# Frame 1: histogram updated once, still use descriptor directly (matches C++)
|
|
101
|
+
self.current_median = features.copy()
|
|
102
|
+
else:
|
|
103
|
+
# Frame 2+: compute from histogram
|
|
104
|
+
self._compute_median()
|
|
105
|
+
|
|
106
|
+
def _compute_median(self, first_descriptor: np.ndarray = None) -> None:
|
|
107
|
+
"""
|
|
108
|
+
Compute median from histogram using cumulative sum
|
|
109
|
+
|
|
110
|
+
Matches C++ logic:
|
|
111
|
+
- If hist_count == 1: median = descriptor (special case)
|
|
112
|
+
- Otherwise: Find bin where cumulative sum reaches (hist_count+1)/2
|
|
113
|
+
- Convert bin index back to feature value
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
first_descriptor: If provided and hist_count==1, use directly as median
|
|
117
|
+
"""
|
|
118
|
+
# Special case: First frame (matches C++ if(hist_count == 1) { median = descriptor.clone(); })
|
|
119
|
+
if self.hist_count == 1 and first_descriptor is not None:
|
|
120
|
+
self.current_median = first_descriptor.copy()
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
cutoff_point = (self.hist_count + 1) // 2
|
|
124
|
+
|
|
125
|
+
for i in range(self.feature_dim):
|
|
126
|
+
cumulative_sum = 0
|
|
127
|
+
|
|
128
|
+
for j in range(self.num_bins):
|
|
129
|
+
cumulative_sum += self.histogram[i, j]
|
|
130
|
+
|
|
131
|
+
if cumulative_sum >= cutoff_point:
|
|
132
|
+
# Convert bin index back to value
|
|
133
|
+
# Formula from C++: min_val + bin_idx * bin_width + 0.5 * bin_width
|
|
134
|
+
self.current_median[i] = (
|
|
135
|
+
self.min_val +
|
|
136
|
+
j * self.bin_width +
|
|
137
|
+
0.5 * self.bin_width
|
|
138
|
+
)
|
|
139
|
+
break
|
|
140
|
+
|
|
141
|
+
def get_median(self) -> np.ndarray:
|
|
142
|
+
"""
|
|
143
|
+
Get current running median
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Median feature vector (1D array)
|
|
147
|
+
"""
|
|
148
|
+
return self.current_median.copy()
|
|
149
|
+
|
|
150
|
+
def reset(self) -> None:
|
|
151
|
+
"""Reset tracker (e.g., for new video)"""
|
|
152
|
+
self.histogram.fill(0)
|
|
153
|
+
self.current_median.fill(0.0)
|
|
154
|
+
self.hist_count = 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class DualHistogramMedianTracker:
|
|
158
|
+
"""
|
|
159
|
+
Manages separate histogram-based running medians for HOG and geometric features
|
|
160
|
+
|
|
161
|
+
OF2.2 tracks HOG and geometric features separately with different histogram
|
|
162
|
+
parameters, then concatenates them when computing dynamic model predictions.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(self,
|
|
166
|
+
hog_dim: int = 4464,
|
|
167
|
+
geom_dim: int = 238,
|
|
168
|
+
hog_bins: int = 200,
|
|
169
|
+
hog_min: float = -3.0,
|
|
170
|
+
hog_max: float = 5.0,
|
|
171
|
+
geom_bins: int = 200,
|
|
172
|
+
geom_min: float = -3.0,
|
|
173
|
+
geom_max: float = 5.0):
|
|
174
|
+
"""
|
|
175
|
+
Initialize dual histogram median tracker
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
hog_dim: HOG feature dimensionality
|
|
179
|
+
geom_dim: Geometric feature dimensionality
|
|
180
|
+
hog_bins: Number of bins for HOG histogram
|
|
181
|
+
hog_min: Minimum value for HOG histogram
|
|
182
|
+
hog_max: Maximum value for HOG histogram
|
|
183
|
+
geom_bins: Number of bins for geometric histogram
|
|
184
|
+
geom_min: Minimum value for geometric histogram
|
|
185
|
+
geom_max: Maximum value for geometric histogram
|
|
186
|
+
"""
|
|
187
|
+
self.hog_tracker = HistogramBasedMedianTracker(
|
|
188
|
+
hog_dim, hog_bins, hog_min, hog_max
|
|
189
|
+
)
|
|
190
|
+
self.geom_tracker = HistogramBasedMedianTracker(
|
|
191
|
+
geom_dim, geom_bins, geom_min, geom_max
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def update(self, hog_features: np.ndarray, geom_features: np.ndarray,
|
|
195
|
+
update_histogram: bool = True) -> None:
|
|
196
|
+
"""
|
|
197
|
+
Update both trackers
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
hog_features: HOG feature vector
|
|
201
|
+
geom_features: Geometric feature vector
|
|
202
|
+
update_histogram: Whether to update histograms
|
|
203
|
+
"""
|
|
204
|
+
# Both trackers handle first frame special case internally
|
|
205
|
+
self.hog_tracker.update(hog_features, update_histogram)
|
|
206
|
+
|
|
207
|
+
# CRITICAL: OpenFace clamps HOG median to >= 0 after update (line 405 in FaceAnalyser.cpp)
|
|
208
|
+
# this->hog_desc_median.setTo(0, this->hog_desc_median < 0);
|
|
209
|
+
self.hog_tracker.current_median[self.hog_tracker.current_median < 0] = 0.0
|
|
210
|
+
|
|
211
|
+
self.geom_tracker.update(geom_features, update_histogram)
|
|
212
|
+
|
|
213
|
+
def get_combined_median(self) -> np.ndarray:
|
|
214
|
+
"""
|
|
215
|
+
Get concatenated [HOG_median, geom_median]
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Combined median vector (4702 dims)
|
|
219
|
+
"""
|
|
220
|
+
hog_median = self.hog_tracker.get_median()
|
|
221
|
+
geom_median = self.geom_tracker.get_median()
|
|
222
|
+
return np.concatenate([hog_median, geom_median])
|
|
223
|
+
|
|
224
|
+
def get_hog_median(self) -> np.ndarray:
|
|
225
|
+
"""Get HOG median only"""
|
|
226
|
+
return self.hog_tracker.get_median()
|
|
227
|
+
|
|
228
|
+
def get_geom_median(self) -> np.ndarray:
|
|
229
|
+
"""Get geometric median only"""
|
|
230
|
+
return self.geom_tracker.get_median()
|
|
231
|
+
|
|
232
|
+
def reset(self) -> None:
|
|
233
|
+
"""Reset both trackers"""
|
|
234
|
+
self.hog_tracker.reset()
|
|
235
|
+
self.geom_tracker.reset()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_histogram_tracker():
|
|
239
|
+
"""Test histogram-based median tracker"""
|
|
240
|
+
print("="*80)
|
|
241
|
+
print("Histogram-Based Median Tracker - Test")
|
|
242
|
+
print("="*80)
|
|
243
|
+
|
|
244
|
+
# Create tracker
|
|
245
|
+
tracker = HistogramBasedMedianTracker(
|
|
246
|
+
feature_dim=10,
|
|
247
|
+
num_bins=200,
|
|
248
|
+
min_val=-3.0,
|
|
249
|
+
max_val=5.0
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Generate synthetic data (random walk around mean)
|
|
253
|
+
np.random.seed(42)
|
|
254
|
+
mean_features = np.array([0.0, 0.5, 1.0, -0.5, -1.0, 2.0, -2.0, 1.5, -1.5, 0.8])
|
|
255
|
+
|
|
256
|
+
print(f"\nSimulating 500 frames...")
|
|
257
|
+
print(f"True mean: {mean_features}")
|
|
258
|
+
|
|
259
|
+
for i in range(500):
|
|
260
|
+
# Add noise to mean
|
|
261
|
+
noise = np.random.randn(10) * 0.3
|
|
262
|
+
features = mean_features + noise
|
|
263
|
+
|
|
264
|
+
# Ensure values in histogram range
|
|
265
|
+
features = np.clip(features, -3.0, 5.0)
|
|
266
|
+
|
|
267
|
+
# Update tracker (update histogram every frame for testing)
|
|
268
|
+
tracker.update(features, update_histogram=True)
|
|
269
|
+
|
|
270
|
+
if i in [49, 99, 199, 499]:
|
|
271
|
+
median = tracker.get_median()
|
|
272
|
+
error = np.abs(median - mean_features).mean()
|
|
273
|
+
print(f"\nFrame {i+1}:")
|
|
274
|
+
print(f" True mean: {mean_features[:3]}...")
|
|
275
|
+
print(f" Running median: {median[:3]}...")
|
|
276
|
+
print(f" MAE: {error:.6f}")
|
|
277
|
+
|
|
278
|
+
print("\nTracker converges to true mean!")
|
|
279
|
+
|
|
280
|
+
# Test dual tracker
|
|
281
|
+
print("\n" + "="*80)
|
|
282
|
+
print("Dual Histogram Median Tracker - Test")
|
|
283
|
+
print("="*80)
|
|
284
|
+
|
|
285
|
+
dual_tracker = DualHistogramMedianTracker(
|
|
286
|
+
hog_dim=4464,
|
|
287
|
+
geom_dim=238,
|
|
288
|
+
hog_bins=200,
|
|
289
|
+
hog_min=-3.0,
|
|
290
|
+
hog_max=5.0,
|
|
291
|
+
geom_bins=200,
|
|
292
|
+
geom_min=-3.0,
|
|
293
|
+
geom_max=5.0
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
print("\nUpdating with synthetic HOG and geometric features...")
|
|
297
|
+
for i in range(100):
|
|
298
|
+
hog_feat = np.random.randn(4464) * 0.5
|
|
299
|
+
geom_feat = np.random.randn(238) * 0.5
|
|
300
|
+
|
|
301
|
+
# Clip to histogram range
|
|
302
|
+
hog_feat = np.clip(hog_feat, -3.0, 5.0)
|
|
303
|
+
geom_feat = np.clip(geom_feat, -3.0, 5.0)
|
|
304
|
+
|
|
305
|
+
dual_tracker.update(hog_feat, geom_feat, update_histogram=(i % 2 == 0))
|
|
306
|
+
|
|
307
|
+
combined = dual_tracker.get_combined_median()
|
|
308
|
+
print(f"Combined median shape: {combined.shape}")
|
|
309
|
+
print(f"Expected: (4702,)")
|
|
310
|
+
assert combined.shape == (4702,), f"Expected (4702,), got {combined.shape}"
|
|
311
|
+
|
|
312
|
+
print("\n" + "="*80)
|
|
313
|
+
print("All tests passed!")
|
|
314
|
+
print("="*80)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__":
|
|
318
|
+
test_histogram_tracker()
|