mirror of
https://github.com/yakhyo/uniface.git
synced 2026-09-03 05:30:11 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fea3d980e | ||
|
|
bb8553f6b6 | ||
|
|
d42778296b |
@@ -64,8 +64,17 @@ CenterFace is an anchor-free detector (MobileNetV2 + FPN) that treats faces as c
|
||||
**Speed**: Benchmark on your own hardware using `python tools/detect.py --source <image> --method centerface`
|
||||
|
||||
!!! note "Input Size"
|
||||
Input width and height must be multiples of 32 (default 640×640). The ONNX model
|
||||
supports dynamic batch and spatial dimensions.
|
||||
The ONNX model has dynamic spatial dimensions. `input_size` is an upper bound
|
||||
(default 640×640), not a fixed shape: larger images are scaled down to fit and smaller
|
||||
ones are left alone, preserving aspect ratio. Each side is then rounded up to a
|
||||
multiple of 32. Pass `input_size=None` to always run at native resolution, like upstream.
|
||||
|
||||
!!! warning "Weakest landmarks for rotated faces"
|
||||
CenterFace decodes all five points from a single stride-4 cell, and its landmark
|
||||
accuracy is the lowest in this library at every in-plane rotation — roughly 8.5% NME at
|
||||
30° and 11.7% at 45°, against 5.4% / 6.2% for RetinaFace-R50. Past ~60° all WIDER
|
||||
FACE-trained detectors here degrade sharply. Prefer `RetinaFace` or `SCRFD` when faces
|
||||
may be tilted; this is a property of the model, not of the port.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "uniface"
|
||||
version = "4.0.0rc1"
|
||||
version = "4.0.0rc2"
|
||||
description = "UniFace: A Unified Face Analysis Library for Python"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -68,15 +68,30 @@ def test_first_two_keypoints_are_the_eyes(blazeface_model, face_image):
|
||||
assert face.landmarks[0][0] < face.landmarks[1][0]
|
||||
|
||||
|
||||
def test_bbox_is_clipped_to_the_frame(blazeface_model, face_image):
|
||||
"""Decoded boxes can run past the frame; detect() clips them."""
|
||||
height, width = face_image.shape[:2]
|
||||
|
||||
def test_bbox_is_well_formed(blazeface_model, face_image):
|
||||
"""Boxes are ordered and non-degenerate, but not clipped to the frame."""
|
||||
for face in blazeface_model.detect(face_image):
|
||||
x1, y1, x2, y2 = face.bbox
|
||||
assert x1 < x2 and y1 < y2
|
||||
assert 0 <= x1 <= width and 0 <= x2 <= width
|
||||
assert 0 <= y1 <= height and 0 <= y2 <= height
|
||||
|
||||
|
||||
def test_bbox_stays_square_when_the_face_runs_off_frame(blazeface_model, face_image):
|
||||
"""The box is left unclipped so FaceMesh's square-ROI rule still holds.
|
||||
|
||||
MediaPipe's `detection_to_roi` expands a *square* box by 1.5x. Clipping to the
|
||||
frame breaks the squareness and shifts the centre, which drags the mesh off the
|
||||
mouth on webcam close-ups — the exact case where the box overflows.
|
||||
"""
|
||||
# Crop tight around the face so the decoded box overflows the frame.
|
||||
frame = cv2.resize(face_image[400:870, 380:850], (640, 480))
|
||||
height, width = frame.shape[:2]
|
||||
|
||||
faces = blazeface_model.detect(frame)
|
||||
assert faces, 'expected a detection on the close-up crop'
|
||||
|
||||
x1, y1, x2, y2 = faces[0].bbox
|
||||
assert x1 < 0 or y1 < 0 or x2 > width or y2 > height, 'crop is not tight enough to exercise overflow'
|
||||
assert (y2 - y1) / (x2 - x1) == pytest.approx(1.0, abs=0.02)
|
||||
|
||||
|
||||
def test_no_faces_in_blank_images(blazeface_model):
|
||||
@@ -85,6 +100,75 @@ def test_no_faces_in_blank_images(blazeface_model):
|
||||
assert blazeface_model.detect(np.zeros(shape, dtype=np.uint8)) == []
|
||||
|
||||
|
||||
# Captured from mediapipe 0.10.14, mp.solutions.face_detection.FaceDetection(
|
||||
# model_selection=0, min_detection_confidence=0.5), pixel coords.
|
||||
# That is the blaze_face_short_range graph this class reimplements. Regenerating needs a
|
||||
# Python 3.12 venv: 0.10.35 dropped mp.solutions.
|
||||
MEDIAPIPE_REFERENCE = {
|
||||
'einstein.png': {
|
||||
'bbox': (335.2566, 346.1731, 891.4519, 902.2785),
|
||||
'score': 0.852732,
|
||||
'keypoints': (
|
||||
(549.8562, 509.9917),
|
||||
(777.7883, 505.4715),
|
||||
(706.1156, 653.1544),
|
||||
(686.1717, 762.5642),
|
||||
(364.3153, 543.7503),
|
||||
(839.8786, 537.1355),
|
||||
),
|
||||
},
|
||||
'test_images/image0.jpg': {
|
||||
'bbox': (115.3043, 111.4395, 447.7941, 443.9094),
|
||||
'score': 0.883393,
|
||||
'keypoints': (
|
||||
(191.1302, 276.5923),
|
||||
(328.5422, 206.7665),
|
||||
(299.1591, 347.9716),
|
||||
(333.2188, 389.0749),
|
||||
(143.1687, 295.0384),
|
||||
(419.6207, 154.5568),
|
||||
),
|
||||
},
|
||||
'demos/src_man1.jpg': {
|
||||
'bbox': (284.8197, 223.9135, 961.3036, 900.3185),
|
||||
'score': 0.944123,
|
||||
'keypoints': (
|
||||
(492.2396, 430.3650),
|
||||
(759.8115, 440.1673),
|
||||
(623.2774, 621.8229),
|
||||
(619.6940, 738.1563),
|
||||
(349.4137, 456.7012),
|
||||
(898.0755, 473.6671),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize('asset', list(MEDIAPIPE_REFERENCE))
|
||||
def test_matches_mediapipe_reference_output(blazeface_model, asset):
|
||||
"""Pin the port to MediaPipe's own numbers so preprocessing drift cannot pass silently.
|
||||
|
||||
MediaPipe parity is the reason to pick this detector over SCRFD or RetinaFace, but
|
||||
nothing else in the suite enforces it. Measured agreement is ~0.15px on boxes and
|
||||
~0.24px on keypoints; the 1.5px tolerance leaves room for ONNX Runtime provider and
|
||||
platform differences while still catching real drift. Verified to fail on all three
|
||||
assets when the letterbox resampler is swapped for INTER_AREA, which shifts boxes
|
||||
4-16px. These faces all sit inside the frame, so re-clipping is *not* caught here —
|
||||
`test_bbox_stays_square_when_the_face_runs_off_frame` covers that.
|
||||
"""
|
||||
expected = MEDIAPIPE_REFERENCE[asset]
|
||||
image = cv2.imread(str(TEST_IMAGE.parent / asset))
|
||||
assert image is not None, f'Missing test asset: {asset}'
|
||||
|
||||
faces = blazeface_model.detect(image)
|
||||
assert len(faces) == 1, f'MediaPipe finds exactly one face in {asset}'
|
||||
face = faces[0]
|
||||
|
||||
assert face.bbox == pytest.approx(expected['bbox'], abs=1.5)
|
||||
assert face.confidence == pytest.approx(expected['score'], abs=0.01)
|
||||
assert face.landmarks == pytest.approx(np.array(expected['keypoints']), abs=1.5)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('label', 'bad_image'),
|
||||
[
|
||||
|
||||
@@ -26,8 +26,40 @@ def test_model_initialization(centerface_model):
|
||||
|
||||
|
||||
def test_invalid_input_size():
|
||||
with pytest.raises(ValueError, match='multiple of 32'):
|
||||
CenterFace(input_size=(650, 480))
|
||||
with pytest.raises(ValueError, match='strictly positive'):
|
||||
CenterFace(input_size=(0, 480))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('size', [(480, 640), (519, 713), (720, 1280)])
|
||||
def test_inference_shape_is_padded_to_multiple_of_32(centerface_model, size):
|
||||
"""The FPN needs both sides divisible by 32, whatever the caller passes in."""
|
||||
image = np.zeros((*size, 3), dtype=np.uint8)
|
||||
resized, scale_w, scale_h = centerface_model._resize(image)
|
||||
|
||||
assert resized.shape[0] % 32 == 0
|
||||
assert resized.shape[1] % 32 == 0
|
||||
assert scale_w == pytest.approx(resized.shape[1] / size[1])
|
||||
assert scale_h == pytest.approx(resized.shape[0] / size[0])
|
||||
|
||||
|
||||
def test_large_input_is_capped_but_small_input_is_not_upscaled(centerface_model):
|
||||
"""input_size bounds cost on huge images without touching ordinary frames."""
|
||||
detector = CenterFace(input_size=(640, 640))
|
||||
|
||||
big, _, _ = detector._resize(np.zeros((2000, 4000, 3), dtype=np.uint8))
|
||||
assert big.shape[1] <= 640 + 31 and big.shape[0] <= 320 + 31
|
||||
|
||||
# A 640x480 camera frame runs at its own resolution, not letterboxed to 640x640
|
||||
frame, scale_w, scale_h = detector._resize(np.zeros((480, 640, 3), dtype=np.uint8))
|
||||
assert frame.shape[:2] == (480, 640)
|
||||
assert (scale_w, scale_h) == (1.0, 1.0)
|
||||
|
||||
|
||||
def test_native_input_size_never_rescales(centerface_model):
|
||||
"""input_size=None reproduces upstream: native resolution, rounded up only."""
|
||||
detector = CenterFace(input_size=None)
|
||||
resized, _, _ = detector._resize(np.zeros((1080, 1920, 3), dtype=np.uint8))
|
||||
assert resized.shape[:2] == (1088, 1920)
|
||||
|
||||
|
||||
def test_inference_on_640x640_image(centerface_model):
|
||||
|
||||
@@ -26,7 +26,7 @@ from __future__ import annotations
|
||||
|
||||
__license__ = 'MIT'
|
||||
__author__ = 'Yakhyokhuja Valikhujaev'
|
||||
__version__ = '4.0.0rc1'
|
||||
__version__ = '4.0.0rc2'
|
||||
|
||||
from uniface.face_utils import compute_similarity, face_alignment
|
||||
from uniface.log import Logger, enable_logging
|
||||
|
||||
@@ -333,10 +333,6 @@ class BlazeFace(BaseDetector):
|
||||
if bboxes.shape[0] == 0:
|
||||
return []
|
||||
|
||||
# The decoded box can extend past the frame
|
||||
bboxes[:, 0::2] = np.clip(bboxes[:, 0::2], 0, width)
|
||||
bboxes[:, 1::2] = np.clip(bboxes[:, 1::2], 0, height)
|
||||
|
||||
detections = np.hstack((bboxes, scores[:, None])).astype(np.float32, copy=False)
|
||||
|
||||
detections, keypoints = self._select_top_detections(
|
||||
|
||||
@@ -6,9 +6,10 @@ from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from uniface.common import non_max_suppression, resize_image
|
||||
from uniface.common import non_max_suppression, validate_image
|
||||
from uniface.constants import CenterFaceWeights
|
||||
from uniface.log import Logger
|
||||
from uniface.model_store import verify_model_weights
|
||||
@@ -22,6 +23,9 @@ __all__ = ['CenterFace']
|
||||
# CenterFace predicts on a single feature map downsampled 4x from the input
|
||||
_STRIDE = 4
|
||||
|
||||
# The FPN needs both input sides to be multiples of 32
|
||||
_SIZE_DIVISOR = 32
|
||||
|
||||
|
||||
class CenterFace(BaseDetector):
|
||||
"""Anchor-free face detector based on the CenterFace architecture.
|
||||
@@ -36,14 +40,16 @@ class CenterFace(BaseDetector):
|
||||
Note:
|
||||
Landmarks are decoded from a single coarse feature-map cell per face and are less
|
||||
precise than SCRFD/RetinaFace; accuracy also degrades faster for in-plane rotated
|
||||
faces (beyond ~20-30 degrees). Best suited for roughly upright faces.
|
||||
faces (beyond ~20-30 degrees), upstream included. Best suited for upright faces.
|
||||
|
||||
Args:
|
||||
model_name (CenterFaceWeights): Predefined model enum. Defaults to CenterFaceWeights.DEFAULT.
|
||||
confidence_threshold (float): Confidence threshold for filtering detections. Defaults to 0.35.
|
||||
nms_threshold (float): Non-Maximum Suppression threshold. Defaults to 0.3.
|
||||
input_size (tuple[int, int]): Input image size (width, height). Both must be multiples of 32.
|
||||
Defaults to (640, 640).
|
||||
input_size (tuple[int, int] | None): Upper bound on the inference resolution as
|
||||
(width, height), not a fixed shape: larger images are scaled down to fit and
|
||||
smaller ones are left alone, preserving aspect ratio. Defaults to (640, 640).
|
||||
Pass None to always run at native resolution, like upstream.
|
||||
providers (list[str] | None): ONNX Runtime execution providers. If None, auto-detects
|
||||
the best available provider. Example: ['CPUExecutionProvider'] to force CPU.
|
||||
|
||||
@@ -51,11 +57,11 @@ class CenterFace(BaseDetector):
|
||||
model_name (CenterFaceWeights): Selected model variant.
|
||||
confidence_threshold (float): Threshold used to filter low-confidence detections.
|
||||
nms_threshold (float): Threshold used during NMS to suppress overlapping boxes.
|
||||
input_size (tuple[int, int]): Image size to which inputs are resized before inference.
|
||||
input_size (tuple[int, int] | None): Maximum inference resolution, or None for native.
|
||||
_model_path (str): Absolute path to the downloaded/verified model weights.
|
||||
|
||||
Raises:
|
||||
ValueError: If `input_size` is not a multiple of 32, or the model weights are invalid.
|
||||
ValueError: If `input_size` is not strictly positive, or the model weights are invalid.
|
||||
RuntimeError: If the ONNX model fails to load or initialize.
|
||||
"""
|
||||
|
||||
@@ -68,7 +74,7 @@ class CenterFace(BaseDetector):
|
||||
model_name: CenterFaceWeights = CenterFaceWeights.DEFAULT,
|
||||
confidence_threshold: float = 0.35,
|
||||
nms_threshold: float = 0.3,
|
||||
input_size: tuple[int, int] = (640, 640),
|
||||
input_size: tuple[int, int] | None = (640, 640),
|
||||
providers: list[str] | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
@@ -78,8 +84,8 @@ class CenterFace(BaseDetector):
|
||||
input_size=input_size,
|
||||
providers=providers,
|
||||
)
|
||||
if input_size[0] % 32 != 0 or input_size[1] % 32 != 0:
|
||||
raise ValueError(f'input_size must be a multiple of 32, got {input_size}')
|
||||
if input_size is not None and (input_size[0] <= 0 or input_size[1] <= 0):
|
||||
raise ValueError(f'input_size must be strictly positive, got {input_size}')
|
||||
|
||||
self.model_name = model_name
|
||||
self.confidence_threshold = confidence_threshold
|
||||
@@ -116,18 +122,43 @@ class CenterFace(BaseDetector):
|
||||
Logger.error(f"Failed to load model from '{model_path}': {e}", exc_info=True)
|
||||
raise RuntimeError(f"Failed to initialize model session for '{model_path}'") from e
|
||||
|
||||
def preprocess(self, image: np.ndarray) -> np.ndarray:
|
||||
"""Preprocess image for inference.
|
||||
|
||||
CenterFace consumes raw pixel values (no mean subtraction or scaling).
|
||||
def _resize(self, image: np.ndarray) -> tuple[np.ndarray, float, float]:
|
||||
"""Resize an image to a network-compatible shape, preserving aspect ratio.
|
||||
|
||||
Args:
|
||||
image: Input image with shape (H, W, C).
|
||||
|
||||
Returns:
|
||||
A tuple of (resized image, width scale factor, height scale factor), where each
|
||||
scale factor maps original coordinates to resized ones.
|
||||
"""
|
||||
height, width = image.shape[:2]
|
||||
|
||||
ratio = 1.0
|
||||
if self.input_size is not None:
|
||||
max_width, max_height = self.input_size
|
||||
ratio = min(1.0, max_width / width, max_height / height)
|
||||
|
||||
# Round up independently per axis, so the two scale factors need not match
|
||||
new_width = max(_SIZE_DIVISOR, int(np.ceil(width * ratio / _SIZE_DIVISOR) * _SIZE_DIVISOR))
|
||||
new_height = max(_SIZE_DIVISOR, int(np.ceil(height * ratio / _SIZE_DIVISOR) * _SIZE_DIVISOR))
|
||||
|
||||
resized = cv2.resize(image, (new_width, new_height))
|
||||
|
||||
return resized, new_width / width, new_height / height
|
||||
|
||||
def preprocess(self, image: np.ndarray) -> np.ndarray:
|
||||
"""Preprocess image for inference.
|
||||
|
||||
CenterFace consumes raw RGB pixel values (no mean subtraction or scaling).
|
||||
|
||||
Args:
|
||||
image: Input image with shape (H, W, C) in BGR order.
|
||||
|
||||
Returns:
|
||||
Preprocessed image tensor with shape (1, C, H, W).
|
||||
"""
|
||||
image = image.astype(np.float32)
|
||||
image = image[:, :, ::-1].astype(np.float32) # BGR to RGB
|
||||
image = image.transpose(2, 0, 1) # HWC to CHW
|
||||
image = np.expand_dims(image, axis=0)
|
||||
|
||||
@@ -221,9 +252,10 @@ class CenterFace(BaseDetector):
|
||||
... landmarks = face.landmarks # np.ndarray with shape (5, 2)
|
||||
"""
|
||||
|
||||
validate_image(image)
|
||||
original_height, original_width = image.shape[:2]
|
||||
|
||||
image, resize_factor = resize_image(image, target_shape=self.input_size)
|
||||
image, scale_w, scale_h = self._resize(image)
|
||||
|
||||
image_tensor = self.preprocess(image)
|
||||
|
||||
@@ -236,8 +268,11 @@ class CenterFace(BaseDetector):
|
||||
if bboxes.shape[0] == 0:
|
||||
return []
|
||||
|
||||
bboxes = bboxes / resize_factor
|
||||
landmarks = landmarks / resize_factor
|
||||
# Rounding each side up to a multiple of 32 skews the axes independently
|
||||
bboxes[:, 0::2] /= scale_w
|
||||
bboxes[:, 1::2] /= scale_h
|
||||
landmarks[..., 0] /= scale_w
|
||||
landmarks[..., 1] /= scale_h
|
||||
|
||||
order = scores.argsort()[::-1]
|
||||
pre_det = np.hstack((bboxes, scores[:, None])).astype(np.float32, copy=False)
|
||||
|
||||
2
uv.lock
generated
2
uv.lock
generated
@@ -1654,7 +1654,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "uniface"
|
||||
version = "4.0.0rc1"
|
||||
version = "4.0.0rc2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
|
||||
Reference in New Issue
Block a user