pyfaceau 1.0.3__cp313-cp313-win_amd64.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.cp313-win_amd64.pyd +0 -0
- pyfaceau/cython_rotation_update.cp313-win_amd64.pyd +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.3.data/scripts/pyfaceau_gui.py +302 -0
- pyfaceau-1.0.3.dist-info/METADATA +466 -0
- pyfaceau-1.0.3.dist-info/RECORD +40 -0
- pyfaceau-1.0.3.dist-info/WHEEL +5 -0
- pyfaceau-1.0.3.dist-info/entry_points.txt +3 -0
- pyfaceau-1.0.3.dist-info/licenses/LICENSE +40 -0
- pyfaceau-1.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Targeted CLNF refinement for critical landmarks
|
|
3
|
+
|
|
4
|
+
This module implements focused CLNF refinement for brow landmarks (17-26) and
|
|
5
|
+
lip corners (48, 54) to improve AU01, AU02, and AU23 detection accuracy.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import cv2
|
|
10
|
+
from typing import Dict, Tuple, Optional, Any
|
|
11
|
+
|
|
12
|
+
# Handle both relative and absolute imports
|
|
13
|
+
if __name__ == '__main__':
|
|
14
|
+
import sys
|
|
15
|
+
import os
|
|
16
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
|
17
|
+
from pyfaceau.refinement.svr_patch_expert import SVRPatchExpert, SVRPatchExpertLoader
|
|
18
|
+
else:
|
|
19
|
+
from .svr_patch_expert import SVRPatchExpert, SVRPatchExpertLoader
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TargetedCLNFRefiner:
|
|
23
|
+
"""
|
|
24
|
+
Targeted CLNF landmark refinement for critical AU landmarks.
|
|
25
|
+
|
|
26
|
+
Refines only brow landmarks (17-26) and lip corners (48, 54) using
|
|
27
|
+
OpenFace SVR patch experts. This focused approach maintains speed
|
|
28
|
+
while improving accuracy for AU01, AU02, and AU23.
|
|
29
|
+
|
|
30
|
+
Optionally enforces PDM (Point Distribution Model) constraints to ensure
|
|
31
|
+
refined landmarks remain anatomically plausible.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
# Critical landmarks for AU improvement
|
|
35
|
+
CRITICAL_LANDMARKS = [17, 18, 19, 20, 21, 22, 26, 48, 54]
|
|
36
|
+
|
|
37
|
+
def __init__(self, patch_expert_file: str, search_window: int = 3,
|
|
38
|
+
pdm: Optional[Any] = None, enforce_pdm: bool = False):
|
|
39
|
+
"""
|
|
40
|
+
Initialize targeted CLNF refiner.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
patch_expert_file: Path to svr_patches_*.txt file
|
|
44
|
+
search_window: Search radius around initial landmark (pixels)
|
|
45
|
+
pdm: Optional PDM for shape constraint enforcement
|
|
46
|
+
enforce_pdm: Whether to project refined landmarks onto PDM
|
|
47
|
+
"""
|
|
48
|
+
self.search_window = search_window
|
|
49
|
+
self.pdm = pdm
|
|
50
|
+
self.enforce_pdm = enforce_pdm
|
|
51
|
+
|
|
52
|
+
# Load only critical patch experts
|
|
53
|
+
loader = SVRPatchExpertLoader(patch_expert_file)
|
|
54
|
+
self.patch_experts = loader.load(target_landmarks=self.CRITICAL_LANDMARKS)
|
|
55
|
+
|
|
56
|
+
if self.enforce_pdm and self.pdm is None:
|
|
57
|
+
print("⚠️ Warning: enforce_pdm=True but no PDM provided. PDM constraints disabled.")
|
|
58
|
+
self.enforce_pdm = False
|
|
59
|
+
|
|
60
|
+
pdm_status = " + PDM constraints" if self.enforce_pdm else ""
|
|
61
|
+
print(f"Loaded {len(self.patch_experts)} patch experts for refinement{pdm_status}")
|
|
62
|
+
|
|
63
|
+
def refine_landmarks(self, image: np.ndarray, landmarks: np.ndarray) -> np.ndarray:
|
|
64
|
+
"""
|
|
65
|
+
Refine critical landmarks using CLNF patch experts.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
image: Grayscale image (H, W) as uint8
|
|
69
|
+
landmarks: Initial 68 landmarks as (68, 2) array
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Refined landmarks (68, 2) array
|
|
73
|
+
"""
|
|
74
|
+
# Convert to grayscale if needed
|
|
75
|
+
if len(image.shape) == 3:
|
|
76
|
+
image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
|
77
|
+
|
|
78
|
+
# Ensure float image for processing
|
|
79
|
+
if image.dtype == np.uint8:
|
|
80
|
+
image_float = image.astype(np.float32) / 255.0
|
|
81
|
+
else:
|
|
82
|
+
image_float = image.astype(np.float32)
|
|
83
|
+
|
|
84
|
+
# Copy landmarks for refinement
|
|
85
|
+
refined = landmarks.copy()
|
|
86
|
+
|
|
87
|
+
# Refine each critical landmark
|
|
88
|
+
for landmark_idx in self.CRITICAL_LANDMARKS:
|
|
89
|
+
if landmark_idx not in self.patch_experts:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
expert = self.patch_experts[landmark_idx]
|
|
93
|
+
initial_pos = refined[landmark_idx]
|
|
94
|
+
|
|
95
|
+
# Search for best position
|
|
96
|
+
refined_pos = self._search_for_best_position(
|
|
97
|
+
image_float,
|
|
98
|
+
initial_pos,
|
|
99
|
+
expert
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
refined[landmark_idx] = refined_pos
|
|
103
|
+
|
|
104
|
+
# Optional: Enforce PDM constraints to ensure anatomically plausible shape
|
|
105
|
+
if self.enforce_pdm and self.pdm is not None:
|
|
106
|
+
refined = self._project_to_pdm(refined)
|
|
107
|
+
|
|
108
|
+
return refined
|
|
109
|
+
|
|
110
|
+
def _project_to_pdm(self, landmarks: np.ndarray) -> np.ndarray:
|
|
111
|
+
"""
|
|
112
|
+
Project refined landmarks onto PDM to enforce shape constraints.
|
|
113
|
+
|
|
114
|
+
This ensures refined landmarks remain anatomically plausible by:
|
|
115
|
+
1. Finding best-fit PDM parameters using calc_params
|
|
116
|
+
2. Reconstructing landmarks from those parameters using calc_shape_3d
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
landmarks: Refined landmarks (68, 2)
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
PDM-constrained landmarks (68, 2)
|
|
123
|
+
"""
|
|
124
|
+
# Find best PDM parameters (calc_params accepts (68, 2) landmarks)
|
|
125
|
+
params_global, params_local = self.pdm.calc_params(landmarks)
|
|
126
|
+
|
|
127
|
+
# Reconstruct 3D landmarks from parameters
|
|
128
|
+
# calc_shape_3d returns flat array (204,) = 68 landmarks * 3 coords (X, Y, Z)
|
|
129
|
+
landmarks_3d_flat = self.pdm.calc_shape_3d(params_local)
|
|
130
|
+
|
|
131
|
+
# Reshape to (68, 3)
|
|
132
|
+
landmarks_3d = landmarks_3d_flat.reshape(68, 3)
|
|
133
|
+
|
|
134
|
+
# Project back to 2D using global params
|
|
135
|
+
# For simplicity, just use X and Y coordinates
|
|
136
|
+
constrained = landmarks_3d[:, :2] # Take only X, Y (drop Z)
|
|
137
|
+
|
|
138
|
+
return constrained
|
|
139
|
+
|
|
140
|
+
def _search_for_best_position(
|
|
141
|
+
self,
|
|
142
|
+
image: np.ndarray,
|
|
143
|
+
initial_pos: np.ndarray,
|
|
144
|
+
expert: SVRPatchExpert
|
|
145
|
+
) -> np.ndarray:
|
|
146
|
+
"""
|
|
147
|
+
Search for optimal landmark position using patch expert.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
image: Grayscale image (normalized to 0-1)
|
|
151
|
+
initial_pos: Initial landmark position (x, y)
|
|
152
|
+
expert: SVR patch expert for this landmark
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Refined position (x, y)
|
|
156
|
+
"""
|
|
157
|
+
best_response = -float('inf')
|
|
158
|
+
best_pos = initial_pos.copy()
|
|
159
|
+
|
|
160
|
+
# Search in window around initial position
|
|
161
|
+
for dx in range(-self.search_window, self.search_window + 1):
|
|
162
|
+
for dy in range(-self.search_window, self.search_window + 1):
|
|
163
|
+
candidate = initial_pos + np.array([dx, dy], dtype=np.float32)
|
|
164
|
+
|
|
165
|
+
# Extract patch at candidate position
|
|
166
|
+
patch = self._extract_patch(image, candidate, expert)
|
|
167
|
+
|
|
168
|
+
if patch is None:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
# Compute patch expert response
|
|
172
|
+
response = self._compute_response(patch, expert)
|
|
173
|
+
|
|
174
|
+
if response > best_response:
|
|
175
|
+
best_response = response
|
|
176
|
+
best_pos = candidate
|
|
177
|
+
|
|
178
|
+
return best_pos
|
|
179
|
+
|
|
180
|
+
def _extract_patch(
|
|
181
|
+
self,
|
|
182
|
+
image: np.ndarray,
|
|
183
|
+
position: np.ndarray,
|
|
184
|
+
expert: SVRPatchExpert
|
|
185
|
+
) -> Optional[np.ndarray]:
|
|
186
|
+
"""
|
|
187
|
+
Extract patch around landmark position.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
image: Grayscale image (normalized to 0-1)
|
|
191
|
+
position: Landmark position (x, y)
|
|
192
|
+
expert: Patch expert (determines patch type)
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Feature vector for patch, or None if out of bounds
|
|
196
|
+
"""
|
|
197
|
+
# Patch experts use 11x11 patches
|
|
198
|
+
patch_size = expert.weights.shape[0] # Should be 11
|
|
199
|
+
half_size = patch_size // 2
|
|
200
|
+
|
|
201
|
+
# Get integer position
|
|
202
|
+
x, y = int(round(position[0])), int(round(position[1]))
|
|
203
|
+
|
|
204
|
+
# Check bounds
|
|
205
|
+
if (x - half_size < 0 or x + half_size >= image.shape[1] or
|
|
206
|
+
y - half_size < 0 or y + half_size >= image.shape[0]):
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
# Extract patch
|
|
210
|
+
patch = image[y - half_size:y + half_size + 1,
|
|
211
|
+
x - half_size:x + half_size + 1]
|
|
212
|
+
|
|
213
|
+
# Convert to features based on patch expert type
|
|
214
|
+
if expert.type == 0:
|
|
215
|
+
# Raw pixel features
|
|
216
|
+
features = patch.flatten()
|
|
217
|
+
else:
|
|
218
|
+
# Gradient features (type == 1)
|
|
219
|
+
# Compute image gradients
|
|
220
|
+
grad_x = cv2.Sobel(patch, cv2.CV_32F, 1, 0, ksize=3)
|
|
221
|
+
grad_y = cv2.Sobel(patch, cv2.CV_32F, 0, 1, ksize=3)
|
|
222
|
+
|
|
223
|
+
# Concatenate gradient features
|
|
224
|
+
features = np.concatenate([grad_x.flatten(), grad_y.flatten()])
|
|
225
|
+
|
|
226
|
+
return features
|
|
227
|
+
|
|
228
|
+
def _compute_response(
|
|
229
|
+
self,
|
|
230
|
+
patch_features: np.ndarray,
|
|
231
|
+
expert: SVRPatchExpert
|
|
232
|
+
) -> float:
|
|
233
|
+
"""
|
|
234
|
+
Compute patch expert response using SVR.
|
|
235
|
+
|
|
236
|
+
This is identical to AU prediction:
|
|
237
|
+
response = features @ weights + bias
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
patch_features: Extracted patch features
|
|
241
|
+
expert: SVR patch expert with weights
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
Response score (higher = better landmark position)
|
|
245
|
+
"""
|
|
246
|
+
# Flatten weights for dot product
|
|
247
|
+
weights_flat = expert.weights.flatten()
|
|
248
|
+
|
|
249
|
+
# Ensure feature dimensions match
|
|
250
|
+
if len(patch_features) != len(weights_flat):
|
|
251
|
+
# If patch expert expects raw pixels but we have gradients (or vice versa)
|
|
252
|
+
# This shouldn't happen if patch extraction is correct
|
|
253
|
+
return -float('inf')
|
|
254
|
+
|
|
255
|
+
# SVR prediction: features @ weights + bias
|
|
256
|
+
response = np.dot(patch_features, weights_flat) + expert.bias
|
|
257
|
+
|
|
258
|
+
# Apply logistic scaling (optional, makes response 0-1)
|
|
259
|
+
# OpenFace uses: 1.0 / (1.0 + exp(-scaling * response))
|
|
260
|
+
response = 1.0 / (1.0 + np.exp(-expert.scaling * response))
|
|
261
|
+
|
|
262
|
+
return response
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def test_refiner():
|
|
266
|
+
"""Test the targeted CLNF refiner on a sample image"""
|
|
267
|
+
import os
|
|
268
|
+
import sys
|
|
269
|
+
|
|
270
|
+
# Add parent directory to path to enable absolute imports
|
|
271
|
+
parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../..'))
|
|
272
|
+
if parent_dir not in sys.path:
|
|
273
|
+
sys.path.insert(0, parent_dir)
|
|
274
|
+
|
|
275
|
+
from pyfaceau.detectors.pfld import CunjianPFLDDetector
|
|
276
|
+
from pyfaceau.detectors.retinaface import OptimizedFaceDetector
|
|
277
|
+
|
|
278
|
+
print("="*80)
|
|
279
|
+
print("TESTING TARGETED CLNF REFINER")
|
|
280
|
+
print("="*80)
|
|
281
|
+
|
|
282
|
+
# Initialize detectors
|
|
283
|
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
284
|
+
weights_dir = os.path.join(current_dir, '../../weights')
|
|
285
|
+
retinaface_model = os.path.join(weights_dir, 'retinaface_mobilenet025_coreml.onnx')
|
|
286
|
+
pfld_model = os.path.join(weights_dir, 'pfld_cunjian.onnx')
|
|
287
|
+
patch_expert_file = os.path.join(weights_dir, 'svr_patches_0.25_general.txt')
|
|
288
|
+
|
|
289
|
+
print("\n1. Loading models...")
|
|
290
|
+
face_detector = OptimizedFaceDetector(retinaface_model)
|
|
291
|
+
landmark_detector = CunjianPFLDDetector(pfld_model)
|
|
292
|
+
refiner = TargetedCLNFRefiner(patch_expert_file, search_window=3)
|
|
293
|
+
|
|
294
|
+
# Load test image
|
|
295
|
+
test_video = '/Users/johnwilsoniv/Documents/SplitFace Open3/D Normal Pts/IMG_0434.MOV'
|
|
296
|
+
cap = cv2.VideoCapture(test_video)
|
|
297
|
+
|
|
298
|
+
if not cap.isOpened():
|
|
299
|
+
print(f"Could not open video: {test_video}")
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
# Read first frame
|
|
303
|
+
ret, frame = cap.read()
|
|
304
|
+
cap.release()
|
|
305
|
+
|
|
306
|
+
if not ret:
|
|
307
|
+
print("Could not read frame from video")
|
|
308
|
+
return
|
|
309
|
+
|
|
310
|
+
print(f" Frame shape: {frame.shape}")
|
|
311
|
+
|
|
312
|
+
# Detect face
|
|
313
|
+
print("\n2. Detecting face...")
|
|
314
|
+
detections, img_raw = face_detector.detect_faces(frame)
|
|
315
|
+
|
|
316
|
+
if len(detections) == 0:
|
|
317
|
+
print(" No faces detected")
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
# Extract bbox coordinates (first 4 values: x_min, y_min, x_max, y_max)
|
|
321
|
+
first_face = detections[0]
|
|
322
|
+
bbox = first_face[:4] # x_min, y_min, x_max, y_max
|
|
323
|
+
print(f" Face bbox: {bbox}")
|
|
324
|
+
|
|
325
|
+
# Detect landmarks with PFLD
|
|
326
|
+
print("\n3. Detecting initial landmarks with PFLD...")
|
|
327
|
+
pfld_landmarks, confidence = landmark_detector.detect_landmarks(frame, bbox)
|
|
328
|
+
print(f" PFLD landmarks shape: {pfld_landmarks.shape}")
|
|
329
|
+
print(f" Sample landmarks (17-22):")
|
|
330
|
+
for i in range(17, 23):
|
|
331
|
+
print(f" {i}: ({pfld_landmarks[i, 0]:.1f}, {pfld_landmarks[i, 1]:.1f})")
|
|
332
|
+
|
|
333
|
+
# Refine landmarks with CLNF
|
|
334
|
+
print("\n4. Refining landmarks with CLNF...")
|
|
335
|
+
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
|
336
|
+
refined_landmarks = refiner.refine_landmarks(gray, pfld_landmarks)
|
|
337
|
+
|
|
338
|
+
print(f" Refined landmarks shape: {refined_landmarks.shape}")
|
|
339
|
+
print(f" Sample refined landmarks (17-22):")
|
|
340
|
+
for i in range(17, 23):
|
|
341
|
+
delta_x = refined_landmarks[i, 0] - pfld_landmarks[i, 0]
|
|
342
|
+
delta_y = refined_landmarks[i, 1] - pfld_landmarks[i, 1]
|
|
343
|
+
print(f" {i}: ({refined_landmarks[i, 0]:.1f}, {refined_landmarks[i, 1]:.1f}) "
|
|
344
|
+
f"[Δx={delta_x:+.1f}, Δy={delta_y:+.1f}]")
|
|
345
|
+
|
|
346
|
+
# Compute displacement statistics
|
|
347
|
+
print("\n5. Refinement statistics:")
|
|
348
|
+
displacements = []
|
|
349
|
+
for idx in refiner.CRITICAL_LANDMARKS:
|
|
350
|
+
delta = refined_landmarks[idx] - pfld_landmarks[idx]
|
|
351
|
+
displacement = np.linalg.norm(delta)
|
|
352
|
+
displacements.append(displacement)
|
|
353
|
+
|
|
354
|
+
print(f" Mean displacement: {np.mean(displacements):.2f} pixels")
|
|
355
|
+
print(f" Max displacement: {np.max(displacements):.2f} pixels")
|
|
356
|
+
print(f" Min displacement: {np.min(displacements):.2f} pixels")
|
|
357
|
+
|
|
358
|
+
print("\nTargeted CLNF refiner test complete!")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
if __name__ == '__main__':
|
|
362
|
+
test_refiner()
|
|
File without changes
|