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