3 Commits

Author SHA1 Message Date
github-actions[bot]
2fea3d980e chore: Release v4.0.0rc2 2026-08-07 06:48:31 +00:00
yakhyo
bb8553f6b6 fix: Match CenterFace preprocessing to upstream 2026-08-07 15:32:40 +09:00
yakhyo
d42778296b fix: Keep BlazeFace boxes unclipped for MediaPipe parity 2026-08-07 14:26:52 +09:00
8 changed files with 190 additions and 34 deletions

View File

@@ -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.
---

View File

@@ -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"

View File

@@ -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'),
[

View File

@@ -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):

View File

@@ -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

View File

@@ -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(

View File

@@ -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
View File

@@ -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'" },