fast-face-python 0.1.1__py3-none-any.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.
- fast_face/__init__.py +6 -0
- fast_face/models/__init__.py +11 -0
- fast_face/models/adaface.py +91 -0
- fast_face/models/base.py +108 -0
- fast_face/models/base_recognition.py +46 -0
- fast_face/models/downloader.py +46 -0
- fast_face/models/factory.py +69 -0
- fast_face/models/retinaface.py +109 -0
- fast_face/models/session.py +29 -0
- fast_face/models/yunet.py +202 -0
- fast_face/schema.py +13 -0
- fast_face/tools.py +501 -0
- fast_face_python-0.1.1.dist-info/METADATA +237 -0
- fast_face_python-0.1.1.dist-info/RECORD +17 -0
- fast_face_python-0.1.1.dist-info/WHEEL +5 -0
- fast_face_python-0.1.1.dist-info/licenses/LICENSE +21 -0
- fast_face_python-0.1.1.dist-info/top_level.txt +1 -0
fast_face/tools.py
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
# Adapted from https://github.com/elliottzheng/batch-face
|
|
2
|
+
from math import ceil
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
FACIAL_REF_POINT = [
|
|
9
|
+
[30.29459953, 51.69630051],
|
|
10
|
+
[65.53179932, 51.50139999],
|
|
11
|
+
[48.02519989, 71.73660278],
|
|
12
|
+
[33.54930115, 92.3655014],
|
|
13
|
+
[62.72990036, 92.20410156],
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
DEFAULT_CROP_SIZE = (96, 112)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resize_single(image: np.ndarray, img_size: int = 640) -> np.ndarray:
|
|
20
|
+
"""Resize a single image using letterbox padding to a fixed (img_size x img_size) square.
|
|
21
|
+
|
|
22
|
+
The image is scaled proportionally so its longest dimension equals img_size,
|
|
23
|
+
and black padding is added to the right or bottom to make it square.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
image (np.ndarray): Input image array (H, W, C).
|
|
27
|
+
img_size (int): Target width and height.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
np.ndarray: The resized image array of shape (img_size, img_size, C).
|
|
31
|
+
"""
|
|
32
|
+
h, w = image.shape[:2]
|
|
33
|
+
scale = img_size / max(h, w)
|
|
34
|
+
new_w, new_h = int(w * scale), int(h * scale)
|
|
35
|
+
|
|
36
|
+
resized = cv2.resize(image, (new_w, new_h))
|
|
37
|
+
|
|
38
|
+
pad_w = img_size - new_w
|
|
39
|
+
pad_h = img_size - new_h
|
|
40
|
+
|
|
41
|
+
return cv2.copyMakeBorder(
|
|
42
|
+
resized, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value=(0, 0, 0)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def resize(
|
|
47
|
+
image: np.ndarray, img_size: int = 640, expand_dims: bool = False
|
|
48
|
+
) -> np.ndarray:
|
|
49
|
+
"""Resize input images using letterbox padding to a fixed square of size img_size.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
image (np.ndarray): Input image array (B, H, W, C) or (H, W, C).
|
|
53
|
+
img_size (int): Target size for the square dimensions.
|
|
54
|
+
expand_dims (bool): Whether to add a batch dimension (for unbatched inputs).
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
np.ndarray: The resized and padded image array.
|
|
58
|
+
"""
|
|
59
|
+
if len(image.shape) == 4:
|
|
60
|
+
out = np.stack(
|
|
61
|
+
[resize_single(image[i], img_size) for i in range(image.shape[0])]
|
|
62
|
+
)
|
|
63
|
+
return out
|
|
64
|
+
else:
|
|
65
|
+
out = resize_single(image, img_size)
|
|
66
|
+
if expand_dims:
|
|
67
|
+
out = np.expand_dims(out, axis=0)
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def normalize(
|
|
72
|
+
image: np.ndarray,
|
|
73
|
+
mean: tuple[float, float, float] = (0.485, 0.456, 0.406),
|
|
74
|
+
std: tuple[float, float, float] = (0.229, 0.224, 0.225),
|
|
75
|
+
max_pixel_value=255.0,
|
|
76
|
+
) -> np.ndarray:
|
|
77
|
+
"""
|
|
78
|
+
Normalize image value.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
image (np.ndarray): Input image array (B, H, W, C) or (H, W, C)
|
|
82
|
+
mean (tuple[float,float,float]): Mean value for every channel
|
|
83
|
+
std (tuple[float,float,float]): Std deviation value for every channel
|
|
84
|
+
max_pixel_value (float): Maximum pixel value
|
|
85
|
+
Return:
|
|
86
|
+
np.ndarray: Normalized image
|
|
87
|
+
"""
|
|
88
|
+
img = image.astype(np.float32)
|
|
89
|
+
|
|
90
|
+
if max_pixel_value != 1.0:
|
|
91
|
+
img /= max_pixel_value
|
|
92
|
+
|
|
93
|
+
mean_np = np.array(mean, dtype=np.float32)
|
|
94
|
+
std_np = np.array(std, dtype=np.float32)
|
|
95
|
+
|
|
96
|
+
img -= mean_np
|
|
97
|
+
|
|
98
|
+
img /= std_np
|
|
99
|
+
|
|
100
|
+
return img
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_priorbox(
|
|
104
|
+
image_size: tuple[int, int] = (640, 640),
|
|
105
|
+
min_sizes: list[list[int]] | None = None,
|
|
106
|
+
steps: list[int] | None = None,
|
|
107
|
+
clip: bool = False,
|
|
108
|
+
) -> np.ndarray:
|
|
109
|
+
"""Generate prior anchor boxes for ONNX model inference.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
image_size (tuple[int, int], optional): Tuple of (height, width) for model input image size. Defaults to (640, 640).
|
|
113
|
+
min_sizes (list[list[int]], optional): Minimum anchor sizes per feature map layer. Defaults to RetinaFace standard.
|
|
114
|
+
steps (list[int], optional): Strides/steps per feature map layer. Defaults to [8, 16, 32].
|
|
115
|
+
clip (bool, optional): Whether to clip anchor coordinates to [0, 1]. Defaults to False.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
np.ndarray: Generated anchor boxes of shape (N, 4).
|
|
119
|
+
"""
|
|
120
|
+
if min_sizes is None:
|
|
121
|
+
min_sizes = [[16, 32], [64, 128], [256, 512]]
|
|
122
|
+
if steps is None:
|
|
123
|
+
steps = [8, 16, 32]
|
|
124
|
+
|
|
125
|
+
feature_maps = [
|
|
126
|
+
[ceil(image_size[0] / step), ceil(image_size[1] / step)] for step in steps
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
anchors = []
|
|
130
|
+
for k, f in enumerate(feature_maps):
|
|
131
|
+
layer_min_sizes = min_sizes[k]
|
|
132
|
+
step = steps[k]
|
|
133
|
+
|
|
134
|
+
xv, yv = np.meshgrid(np.arange(f[1]), np.arange(f[0]))
|
|
135
|
+
|
|
136
|
+
cx = (xv.flatten() + 0.5) * step / image_size[1]
|
|
137
|
+
cy = (yv.flatten() + 0.5) * step / image_size[0]
|
|
138
|
+
|
|
139
|
+
num_min_sizes = len(layer_min_sizes)
|
|
140
|
+
|
|
141
|
+
cx = np.repeat(cx, num_min_sizes)
|
|
142
|
+
cy = np.repeat(cy, num_min_sizes)
|
|
143
|
+
s_kx = np.tile([m / image_size[1] for m in layer_min_sizes], len(xv.flatten()))
|
|
144
|
+
s_ky = np.tile([m / image_size[0] for m in layer_min_sizes], len(yv.flatten()))
|
|
145
|
+
|
|
146
|
+
layer_anchors = np.stack((cx, cy, s_kx, s_ky), axis=1)
|
|
147
|
+
anchors.append(layer_anchors)
|
|
148
|
+
|
|
149
|
+
output = np.vstack(anchors).astype(np.float32)
|
|
150
|
+
|
|
151
|
+
if clip:
|
|
152
|
+
output = np.clip(output, 0, 1)
|
|
153
|
+
|
|
154
|
+
return output
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def decode(
|
|
158
|
+
loc: np.ndarray,
|
|
159
|
+
priors: np.ndarray,
|
|
160
|
+
variances: tuple[float, float] | list[float] = (0.1, 0.2),
|
|
161
|
+
) -> np.ndarray:
|
|
162
|
+
"""Decode bounding box predictions using prior boxes and variance scaling.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
loc (np.ndarray): Bounding box offset predictions from model of shape (N, 4).
|
|
166
|
+
priors (np.ndarray): Prior anchor boxes of shape (N, 4) in (cx, cy, w, h) format.
|
|
167
|
+
variances (Union[Tuple[float, float], List[float]]): Variance scaling factors for (center, size). Defaults to (0.1, 0.2).
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
np.ndarray: Decoded bounding boxes of shape (N, 4) in (xmin, ymin, xmax, ymax) format.
|
|
171
|
+
"""
|
|
172
|
+
boxes = np.concatenate(
|
|
173
|
+
(
|
|
174
|
+
priors[:, :2] + loc[:, :2] * variances[0] * priors[:, 2:],
|
|
175
|
+
priors[:, 2:] * np.exp(loc[:, 2:] * variances[1]),
|
|
176
|
+
),
|
|
177
|
+
axis=1,
|
|
178
|
+
)
|
|
179
|
+
boxes[:, :2] -= boxes[:, 2:] / 2
|
|
180
|
+
boxes[:, 2:] += boxes[:, :2]
|
|
181
|
+
return boxes
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def decode_landmark(
|
|
185
|
+
pre: np.ndarray,
|
|
186
|
+
priors: np.ndarray,
|
|
187
|
+
variances: tuple[float, float] | list[float] = (0.1, 0.2),
|
|
188
|
+
) -> np.ndarray:
|
|
189
|
+
"""Decode facial landmark location predictions using prior boxes and variance scaling.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
pre (np.ndarray): Landmark offset predictions from model of shape (N, 10).
|
|
193
|
+
priors (np.ndarray): Prior anchor boxes of shape (N, 4) in (cx, cy, w, h) format.
|
|
194
|
+
variances (Union[Tuple[float, float], List[float]]): Variance scaling factors for landmarks. Defaults to (0.1, 0.2).
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
np.ndarray: Decoded facial landmarks of shape (N, 10) in (x1, y1, x2, y2, ..., x5, y5) format.
|
|
198
|
+
"""
|
|
199
|
+
landms = np.concatenate(
|
|
200
|
+
(
|
|
201
|
+
priors[:, :2] + pre[:, :2] * variances[0] * priors[:, 2:],
|
|
202
|
+
priors[:, :2] + pre[:, 2:4] * variances[0] * priors[:, 2:],
|
|
203
|
+
priors[:, :2] + pre[:, 4:6] * variances[0] * priors[:, 2:],
|
|
204
|
+
priors[:, :2] + pre[:, 6:8] * variances[0] * priors[:, 2:],
|
|
205
|
+
priors[:, :2] + pre[:, 8:10] * variances[0] * priors[:, 2:],
|
|
206
|
+
),
|
|
207
|
+
axis=1,
|
|
208
|
+
)
|
|
209
|
+
return landms
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def nms(dets: np.ndarray, thresh: float) -> list[int]:
|
|
213
|
+
"""Perform Non-Maximum Suppression (NMS) on bounding boxes.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
dets (np.ndarray): Bounding box detections with confidence scores of shape (N, 5),
|
|
217
|
+
where each row represents (x1, y1, x2, y2, score).
|
|
218
|
+
thresh (float): Intersection-over-Union (IoU) threshold for suppressing overlapping boxes.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
List[int]: List of kept bounding box indices after NMS.
|
|
222
|
+
"""
|
|
223
|
+
x1 = dets[:, 0]
|
|
224
|
+
y1 = dets[:, 1]
|
|
225
|
+
x2 = dets[:, 2]
|
|
226
|
+
y2 = dets[:, 3]
|
|
227
|
+
scores = dets[:, 4]
|
|
228
|
+
|
|
229
|
+
areas = (x2 - x1 + 1) * (y2 - y1 + 1)
|
|
230
|
+
order = scores.argsort()[::-1]
|
|
231
|
+
|
|
232
|
+
keep = []
|
|
233
|
+
while order.size > 0:
|
|
234
|
+
i = order[0]
|
|
235
|
+
keep.append(i)
|
|
236
|
+
xx1 = np.maximum(x1[i], x1[order[1:]])
|
|
237
|
+
yy1 = np.maximum(y1[i], y1[order[1:]])
|
|
238
|
+
xx2 = np.minimum(x2[i], x2[order[1:]])
|
|
239
|
+
yy2 = np.minimum(y2[i], y2[order[1:]])
|
|
240
|
+
|
|
241
|
+
w = np.maximum(0.0, xx2 - xx1 + 1)
|
|
242
|
+
h = np.maximum(0.0, yy2 - yy1 + 1)
|
|
243
|
+
inter = w * h
|
|
244
|
+
ovr = inter / (areas[i] + areas[order[1:]] - inter)
|
|
245
|
+
|
|
246
|
+
inds = np.where(ovr <= thresh)[0]
|
|
247
|
+
order = order[inds + 1]
|
|
248
|
+
|
|
249
|
+
return keep
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def parse_det(det: np.ndarray) -> dict[str, Any]:
|
|
253
|
+
"""Parse a raw detection array into a structured dictionary.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
det (np.ndarray): Detection array of shape (15,) containing
|
|
257
|
+
[x1, y1, x2, y2, score, l1_x, l1_y, ..., l5_x, l5_y].
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Dict[str, Any]: Structured dictionary with bounding box, confidence, and facial landmarks.
|
|
261
|
+
"""
|
|
262
|
+
return {
|
|
263
|
+
"bbox": [float(x) for x in det[0:4]],
|
|
264
|
+
"confidence": float(det[4]),
|
|
265
|
+
"landmarks": {
|
|
266
|
+
"left_eye": [float(det[5]), float(det[6])],
|
|
267
|
+
"right_eye": [float(det[7]), float(det[8])],
|
|
268
|
+
"nose": [float(det[9]), float(det[10])],
|
|
269
|
+
"left_mouth": [float(det[11]), float(det[12])],
|
|
270
|
+
"right_mouth": [float(det[13]), float(det[14])],
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def crop_face(
|
|
276
|
+
img: np.ndarray,
|
|
277
|
+
det: np.ndarray | dict[str, Any] | list[dict[str, Any]],
|
|
278
|
+
return_dict: bool = False,
|
|
279
|
+
) -> tuple[list[np.ndarray], np.ndarray | list[dict[str, Any]]]:
|
|
280
|
+
"""Crop face(s) from image and adjust landmarks relative to the cropped bounding box.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
img (np.ndarray): Original image array (H, W, C).
|
|
284
|
+
det (Union[np.ndarray, Dict, List[Dict]]): Detection output containing bbox and landmarks.
|
|
285
|
+
Can be a single dict, a list of dicts, a 1D array (15,), or a 2D array (N, 15).
|
|
286
|
+
return_dict (bool): If True, returns landmarks as a dictionary. Default False.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Tuple[List[np.ndarray], Union[np.ndarray, List[Dict[str, Any]]]]: A list of cropped face images
|
|
290
|
+
and their corresponding adjusted landmarks (as an array or a list of dictionaries).
|
|
291
|
+
"""
|
|
292
|
+
if isinstance(det, list):
|
|
293
|
+
dets_list = det
|
|
294
|
+
elif isinstance(det, np.ndarray) and det.ndim == 2:
|
|
295
|
+
dets_list = [det[i] for i in range(det.shape[0])]
|
|
296
|
+
else:
|
|
297
|
+
dets_list = [det]
|
|
298
|
+
|
|
299
|
+
cropped_imgs = []
|
|
300
|
+
adjusted_lmks_list = []
|
|
301
|
+
h, w = img.shape[:2]
|
|
302
|
+
|
|
303
|
+
for d in dets_list:
|
|
304
|
+
if isinstance(d, dict):
|
|
305
|
+
bbox = d["bbox"]
|
|
306
|
+
landmarks = d["landmarks"]
|
|
307
|
+
lmk = np.array(
|
|
308
|
+
[
|
|
309
|
+
landmarks["left_eye"],
|
|
310
|
+
landmarks["right_eye"],
|
|
311
|
+
landmarks["nose"],
|
|
312
|
+
landmarks["left_mouth"],
|
|
313
|
+
landmarks["right_mouth"],
|
|
314
|
+
],
|
|
315
|
+
dtype=np.float32,
|
|
316
|
+
)
|
|
317
|
+
elif isinstance(d, np.ndarray):
|
|
318
|
+
if d.shape[0] < 15:
|
|
319
|
+
raise ValueError("Detection array must have at least 15 elements")
|
|
320
|
+
bbox = d[0:4]
|
|
321
|
+
lmk = d[5:15].reshape((5, 2)).astype(np.float32)
|
|
322
|
+
else:
|
|
323
|
+
raise TypeError("det must be a dictionary or numpy array")
|
|
324
|
+
|
|
325
|
+
x1, y1, x2, y2 = [int(x) for x in bbox]
|
|
326
|
+
x1 = max(0, x1)
|
|
327
|
+
y1 = max(0, y1)
|
|
328
|
+
x2 = min(w, x2)
|
|
329
|
+
y2 = min(h, y2)
|
|
330
|
+
|
|
331
|
+
cropped_img = img[y1:y2, x1:x2]
|
|
332
|
+
cropped_imgs.append(cropped_img)
|
|
333
|
+
|
|
334
|
+
adjusted_lmk = lmk.copy()
|
|
335
|
+
adjusted_lmk[:, 0] -= x1
|
|
336
|
+
adjusted_lmk[:, 1] -= y1
|
|
337
|
+
|
|
338
|
+
if return_dict:
|
|
339
|
+
ret_landmarks = {
|
|
340
|
+
"left_eye": adjusted_lmk[0].tolist(),
|
|
341
|
+
"right_eye": adjusted_lmk[1].tolist(),
|
|
342
|
+
"nose": adjusted_lmk[2].tolist(),
|
|
343
|
+
"left_mouth": adjusted_lmk[3].tolist(),
|
|
344
|
+
"right_mouth": adjusted_lmk[4].tolist(),
|
|
345
|
+
}
|
|
346
|
+
adjusted_lmks_list.append(ret_landmarks)
|
|
347
|
+
else:
|
|
348
|
+
adjusted_lmks_list.append(adjusted_lmk)
|
|
349
|
+
|
|
350
|
+
if not return_dict:
|
|
351
|
+
return cropped_imgs, np.array(adjusted_lmks_list)
|
|
352
|
+
return cropped_imgs, adjusted_lmks_list
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _get_similarity_transform(src_pts: np.ndarray, dst_pts: np.ndarray) -> np.ndarray:
|
|
356
|
+
"""Compute similarity transform matrix from src_pts to dst_pts.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
src_pts (np.ndarray): Source points, shape (K, 2).
|
|
360
|
+
dst_pts (np.ndarray): Destination points, shape (K, 2).
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
np.ndarray: 2x3 affine transform matrix.
|
|
364
|
+
"""
|
|
365
|
+
num = src_pts.shape[0]
|
|
366
|
+
|
|
367
|
+
src_mean = np.mean(src_pts, axis=0)
|
|
368
|
+
dst_mean = np.mean(dst_pts, axis=0)
|
|
369
|
+
|
|
370
|
+
src_demean = src_pts - src_mean
|
|
371
|
+
dst_demean = dst_pts - dst_mean
|
|
372
|
+
|
|
373
|
+
A = np.zeros((2 * num, 4), dtype=np.float64)
|
|
374
|
+
b = np.zeros((2 * num, 1), dtype=np.float64)
|
|
375
|
+
|
|
376
|
+
for i in range(num):
|
|
377
|
+
A[2 * i, 0] = src_demean[i, 0]
|
|
378
|
+
A[2 * i, 1] = -src_demean[i, 1]
|
|
379
|
+
A[2 * i, 2] = 1
|
|
380
|
+
A[2 * i, 3] = 0
|
|
381
|
+
A[2 * i + 1, 0] = src_demean[i, 1]
|
|
382
|
+
A[2 * i + 1, 1] = src_demean[i, 0]
|
|
383
|
+
A[2 * i + 1, 2] = 0
|
|
384
|
+
A[2 * i + 1, 3] = 1
|
|
385
|
+
b[2 * i] = dst_demean[i, 0]
|
|
386
|
+
b[2 * i + 1] = dst_demean[i, 1]
|
|
387
|
+
|
|
388
|
+
params, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
|
|
389
|
+
params = params.flatten()
|
|
390
|
+
|
|
391
|
+
# params = [a, b, tx, ty] where the transform is:
|
|
392
|
+
# [a, -b, tx] [x] [x']
|
|
393
|
+
# [b, a, ty] * [y] = [y']
|
|
394
|
+
a, b_val, tx, ty = params
|
|
395
|
+
|
|
396
|
+
tfm = np.float64(
|
|
397
|
+
[
|
|
398
|
+
[a, -b_val, dst_mean[0] - (a * src_mean[0] - b_val * src_mean[1]) + tx],
|
|
399
|
+
[b_val, a, dst_mean[1] - (b_val * src_mean[0] + a * src_mean[1]) + ty],
|
|
400
|
+
]
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
return tfm
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _get_reference_facial_points(
|
|
407
|
+
output_size: tuple[int, int] | None = None, default_square: bool = False
|
|
408
|
+
) -> np.ndarray:
|
|
409
|
+
"""Get reference facial points scaled to the given output size.
|
|
410
|
+
|
|
411
|
+
Matches AdaFace's get_reference_facial_points behavior.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
output_size (Tuple[int, int], optional): Target (w, h). If None, returns default points.
|
|
415
|
+
default_square (bool): If True, pad default (96, 112) to (112, 112) before scaling.
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
np.ndarray: Reference points, shape (5, 2).
|
|
419
|
+
"""
|
|
420
|
+
tmp_5pts = np.array(FACIAL_REF_POINT, dtype=np.float64)
|
|
421
|
+
tmp_crop_size = np.array(DEFAULT_CROP_SIZE, dtype=np.float64)
|
|
422
|
+
|
|
423
|
+
if default_square:
|
|
424
|
+
size_diff = max(tmp_crop_size) - tmp_crop_size
|
|
425
|
+
tmp_5pts += size_diff / 2
|
|
426
|
+
tmp_crop_size += size_diff
|
|
427
|
+
|
|
428
|
+
if output_size is None:
|
|
429
|
+
return tmp_5pts.astype(np.float32)
|
|
430
|
+
|
|
431
|
+
output_size = np.array(output_size, dtype=np.float64)
|
|
432
|
+
if output_size[0] == tmp_crop_size[0] and output_size[1] == tmp_crop_size[1]:
|
|
433
|
+
return tmp_5pts.astype(np.float32)
|
|
434
|
+
|
|
435
|
+
scale_factor = output_size / tmp_crop_size
|
|
436
|
+
tmp_5pts[:, 0] *= scale_factor[0]
|
|
437
|
+
tmp_5pts[:, 1] *= scale_factor[1]
|
|
438
|
+
|
|
439
|
+
return tmp_5pts.astype(np.float32)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def align_face(
|
|
443
|
+
img: np.ndarray,
|
|
444
|
+
landmarks: np.ndarray | dict[str, Any],
|
|
445
|
+
align_size: tuple[int, int] = (112, 112),
|
|
446
|
+
) -> np.ndarray:
|
|
447
|
+
"""Align a face image based on facial reference points.
|
|
448
|
+
|
|
449
|
+
Uses a similarity transform matching AdaFace's warp_and_crop_face behavior.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
img (np.ndarray): Original image array (H, W, C) or cropped face image.
|
|
453
|
+
landmarks (Union[np.ndarray, Dict[str, Any]]): Facial landmarks. Can be an array of shape (5, 2)
|
|
454
|
+
or a dictionary containing landmark coordinates.
|
|
455
|
+
align_size (Tuple[int, int], optional): Target size (width, height) of the aligned face. Defaults to (112, 112).
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
np.ndarray: Aligned face image array of shape (align_size[1], align_size[0], C).
|
|
459
|
+
"""
|
|
460
|
+
default_square = align_size[0] == align_size[1]
|
|
461
|
+
ref_pts = _get_reference_facial_points(
|
|
462
|
+
output_size=align_size, default_square=default_square
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
if isinstance(landmarks, dict):
|
|
466
|
+
if "landmarks" in landmarks:
|
|
467
|
+
landmarks = landmarks["landmarks"]
|
|
468
|
+
|
|
469
|
+
for i in ["left_eye", "right_eye", "nose", "left_mouth", "right_mouth"]:
|
|
470
|
+
if i not in landmarks:
|
|
471
|
+
raise ValueError(f"Landmarks dictionary must contain '{i}' etc.")
|
|
472
|
+
|
|
473
|
+
lmk = np.array(
|
|
474
|
+
[
|
|
475
|
+
landmarks["left_eye"],
|
|
476
|
+
landmarks["right_eye"],
|
|
477
|
+
landmarks["nose"],
|
|
478
|
+
landmarks["left_mouth"],
|
|
479
|
+
landmarks["right_mouth"],
|
|
480
|
+
],
|
|
481
|
+
dtype=np.float32,
|
|
482
|
+
)
|
|
483
|
+
elif isinstance(landmarks, np.ndarray):
|
|
484
|
+
if landmarks.shape == (5, 2):
|
|
485
|
+
lmk = landmarks.astype(np.float32)
|
|
486
|
+
elif landmarks.size == 10:
|
|
487
|
+
lmk = landmarks.reshape((5, 2)).astype(np.float32)
|
|
488
|
+
elif landmarks.size >= 15:
|
|
489
|
+
lmk = landmarks[5:15].reshape((5, 2)).astype(np.float32)
|
|
490
|
+
else:
|
|
491
|
+
raise ValueError("Landmarks array must have shape (5, 2) or (10,)")
|
|
492
|
+
else:
|
|
493
|
+
raise TypeError("landmarks must be a dictionary or numpy array")
|
|
494
|
+
|
|
495
|
+
tform = _get_similarity_transform(lmk, ref_pts)
|
|
496
|
+
|
|
497
|
+
if tform is None:
|
|
498
|
+
raise ValueError("Failed to estimate similarity transform for face alignment.")
|
|
499
|
+
|
|
500
|
+
aligned_img = cv2.warpAffine(img, tform, align_size)
|
|
501
|
+
return aligned_img
|