fix: face detection — use OpenCV FaceDetectorYN and full-res originals
- Rewrite faces.py to use cv2.FaceDetectorYN instead of raw ONNX (handles multi-scale anchor decoding and NMS internally) - Load original photo files at up to 4000px for face detection instead of 240px thumbnails — faces were too small to detect at thumbnail res - Falls back to thumbnail if original is unavailable Tested: 33 faces extracted from 13 photos, clustered into 1 person. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
Face detection (YuNet) + recognition (SFace) using ONNX Runtime.
|
Face detection (YuNet) + recognition (SFace) using OpenCV DNN.
|
||||||
|
|
||||||
|
YuNet is loaded via cv2.FaceDetectorYN which handles the multi-scale
|
||||||
|
anchor decoding and NMS internally. SFace recognition uses raw ONNX
|
||||||
|
Runtime for the 128-d embedding.
|
||||||
|
|
||||||
Both models are from opencv_zoo (Apache 2.0 license).
|
Both models are from opencv_zoo (Apache 2.0 license).
|
||||||
Expects {models_dir}/face/:
|
Expects {models_dir}/face/:
|
||||||
- yunet.onnx (~75 KB)
|
- yunet.onnx (~233 KB)
|
||||||
- sface.onnx (~37 MB, 128-d embeddings)
|
- sface.onnx (~37 MB, 128-d embeddings)
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import cv2
|
||||||
import onnxruntime as ort
|
import onnxruntime as ort
|
||||||
|
|
||||||
from app.config import VisionSettings
|
from app.config import VisionSettings
|
||||||
@@ -17,131 +22,114 @@ from app.services.vision.base import FaceProcessor, FaceDetection
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_YUNET_INPUT_SIZE = 640
|
|
||||||
|
|
||||||
|
|
||||||
def _align_face(image: np.ndarray, landmarks: np.ndarray) -> np.ndarray:
|
def _align_face(image: np.ndarray, landmarks: np.ndarray) -> np.ndarray:
|
||||||
"""Align and crop a 112x112 face patch using 5-point landmarks.
|
"""Align and crop a 112x112 face patch using 5-point landmarks."""
|
||||||
landmarks shape: (5, 2) — left_eye, right_eye, nose, left_mouth, right_mouth."""
|
|
||||||
from PIL import Image
|
|
||||||
import math
|
|
||||||
|
|
||||||
left_eye = landmarks[0]
|
left_eye = landmarks[0]
|
||||||
right_eye = landmarks[1]
|
right_eye = landmarks[1]
|
||||||
|
|
||||||
dx = right_eye[0] - left_eye[0]
|
dx = right_eye[0] - left_eye[0]
|
||||||
dy = right_eye[1] - left_eye[1]
|
dy = right_eye[1] - left_eye[1]
|
||||||
angle = math.degrees(math.atan2(dy, dx))
|
angle = np.degrees(np.arctan2(dy, dx))
|
||||||
eye_center = ((left_eye[0] + right_eye[0]) / 2, (left_eye[1] + right_eye[1]) / 2)
|
eye_center = ((left_eye[0] + right_eye[0]) / 2, (left_eye[1] + right_eye[1]) / 2)
|
||||||
eye_dist = math.sqrt(dx * dx + dy * dy)
|
eye_dist = np.sqrt(dx * dx + dy * dy)
|
||||||
scale = 64.0 / max(eye_dist, 1e-6) # target: eyes at ~64px apart in 112x112
|
|
||||||
|
|
||||||
img = Image.fromarray(image)
|
M = cv2.getRotationMatrix2D(eye_center, angle, 1.0)
|
||||||
img = img.rotate(-angle, center=eye_center, resample=Image.BICUBIC)
|
rotated = cv2.warpAffine(image, M, (image.shape[1], image.shape[0]))
|
||||||
|
|
||||||
|
# Crop around face center
|
||||||
|
scale = 64.0 / max(eye_dist, 1e-6)
|
||||||
cx, cy = eye_center
|
cx, cy = eye_center
|
||||||
half = 56.0 / scale
|
half = 56.0 / scale
|
||||||
crop = img.crop((int(cx - half), int(cy - half * 0.8), int(cx + half), int(cy + half * 1.2)))
|
x1 = max(0, int(cx - half))
|
||||||
crop = crop.resize((112, 112), Image.BICUBIC)
|
y1 = max(0, int(cy - half * 0.8))
|
||||||
return np.array(crop, dtype=np.float32)
|
x2 = min(rotated.shape[1], int(cx + half))
|
||||||
|
y2 = min(rotated.shape[0], int(cy + half * 1.2))
|
||||||
|
crop = rotated[y1:y2, x1:x2]
|
||||||
|
|
||||||
|
if crop.size == 0:
|
||||||
|
return np.zeros((112, 112, 3), dtype=np.float32)
|
||||||
|
|
||||||
|
return cv2.resize(crop, (112, 112)).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
class YuNetSFaceProcessor(FaceProcessor):
|
class YuNetSFaceProcessor(FaceProcessor):
|
||||||
def __init__(self, settings: VisionSettings):
|
def __init__(self, settings: VisionSettings):
|
||||||
face_dir = Path(settings.models_dir) / "face"
|
face_dir = Path(settings.models_dir) / "face"
|
||||||
yunet_path = face_dir / "yunet.onnx"
|
yunet_path = str(face_dir / "yunet.onnx")
|
||||||
sface_path = face_dir / "sface.onnx"
|
sface_path = str(face_dir / "sface.onnx")
|
||||||
|
|
||||||
|
# YuNet via OpenCV's FaceDetectorYN — handles anchor decoding + NMS
|
||||||
|
self._detector = cv2.FaceDetectorYN.create(
|
||||||
|
yunet_path,
|
||||||
|
"",
|
||||||
|
(640, 640),
|
||||||
|
settings.faces.recognition_threshold,
|
||||||
|
0.3, # NMS threshold
|
||||||
|
5000, # top_k
|
||||||
|
)
|
||||||
|
logger.info("YuNet face detector loaded via OpenCV")
|
||||||
|
|
||||||
|
# SFace via ONNX Runtime
|
||||||
opts = ort.SessionOptions()
|
opts = ort.SessionOptions()
|
||||||
opts.inter_op_num_threads = 2
|
opts.inter_op_num_threads = 2
|
||||||
opts.intra_op_num_threads = 2
|
opts.intra_op_num_threads = 2
|
||||||
|
ort.set_default_logger_severity(3)
|
||||||
logger.info("Loading YuNet from %s", yunet_path)
|
self._recognizer = ort.InferenceSession(sface_path, opts, providers=["CPUExecutionProvider"])
|
||||||
self._detector = ort.InferenceSession(str(yunet_path), opts, providers=["CPUExecutionProvider"])
|
logger.info("SFace recognizer loaded via ONNX Runtime")
|
||||||
|
|
||||||
logger.info("Loading SFace from %s", sface_path)
|
|
||||||
self._recognizer = ort.InferenceSession(str(sface_path), opts, providers=["CPUExecutionProvider"])
|
|
||||||
|
|
||||||
self._min_face_size = settings.faces.min_face_size
|
self._min_face_size = settings.faces.min_face_size
|
||||||
self._score_threshold = settings.faces.recognition_threshold
|
|
||||||
|
|
||||||
def process(self, image: np.ndarray) -> list[FaceDetection]:
|
def process(self, image: np.ndarray) -> list[FaceDetection]:
|
||||||
orig_h, orig_w = image.shape[:2]
|
orig_h, orig_w = image.shape[:2]
|
||||||
|
|
||||||
# Scale + letterbox to exactly 640x640 (YuNet fixed input)
|
# Convert RGB → BGR for OpenCV
|
||||||
scale = min(_YUNET_INPUT_SIZE / orig_w, _YUNET_INPUT_SIZE / orig_h)
|
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
|
||||||
new_w = int(orig_w * scale)
|
|
||||||
new_h = int(orig_h * scale)
|
|
||||||
|
|
||||||
from PIL import Image as PILImage
|
# Set input size to actual image dimensions
|
||||||
resized = np.array(
|
self._detector.setInputSize((orig_w, orig_h))
|
||||||
PILImage.fromarray(image).resize((new_w, new_h), PILImage.BICUBIC),
|
|
||||||
dtype=np.uint8,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Letterbox pad to 640x640
|
# Detect faces
|
||||||
canvas = np.full((_YUNET_INPUT_SIZE, _YUNET_INPUT_SIZE, 3), 128, dtype=np.uint8)
|
_, faces_raw = self._detector.detect(bgr)
|
||||||
pad_y = (_YUNET_INPUT_SIZE - new_h) // 2
|
|
||||||
pad_x = (_YUNET_INPUT_SIZE - new_w) // 2
|
|
||||||
canvas[pad_y:pad_y + new_h, pad_x:pad_x + new_w] = resized
|
|
||||||
|
|
||||||
# YuNet expects BGR
|
if faces_raw is None or len(faces_raw) == 0:
|
||||||
bgr = canvas[:, :, ::-1].copy()
|
|
||||||
|
|
||||||
# Run detection
|
|
||||||
det_input = self._detector.get_inputs()[0]
|
|
||||||
blob = bgr.astype(np.float32)[np.newaxis] # (1, H, W, 3)
|
|
||||||
# Some YuNet ONNX exports expect (1, 3, H, W)
|
|
||||||
if det_input.shape and len(det_input.shape) == 4 and det_input.shape[1] == 3:
|
|
||||||
blob = blob.transpose(0, 3, 1, 2)
|
|
||||||
|
|
||||||
detections_raw = self._detector.run(None, {det_input.name: blob})
|
|
||||||
dets = detections_raw[0] # (N, 15): x,y,w,h,score, 5x landmark pairs
|
|
||||||
|
|
||||||
if dets is None or len(dets) == 0:
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for det in dets:
|
for face in faces_raw:
|
||||||
score = float(det[4]) if len(det) > 4 else float(det[-1])
|
# face: [x, y, w, h, right_eye_x, right_eye_y, left_eye_x, left_eye_y,
|
||||||
if score < self._score_threshold:
|
# nose_x, nose_y, right_mouth_x, right_mouth_y, left_mouth_x, left_mouth_y, score]
|
||||||
continue
|
x, y, w, h = int(face[0]), int(face[1]), int(face[2]), int(face[3])
|
||||||
|
score = float(face[14])
|
||||||
x, y, w, h = det[0], det[1], det[2], det[3]
|
|
||||||
|
|
||||||
# Filter small faces
|
# Filter small faces
|
||||||
face_size = max(w, h) / scale
|
face_size = max(w, h)
|
||||||
if face_size < self._min_face_size:
|
if face_size < self._min_face_size:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Remove letterbox padding and rescale to original image coords
|
# Normalized bbox
|
||||||
x1 = (x - pad_x) / scale
|
|
||||||
y1 = (y - pad_y) / scale
|
|
||||||
x2 = (x + w - pad_x) / scale
|
|
||||||
y2 = (y + h - pad_y) / scale
|
|
||||||
|
|
||||||
bbox = [
|
bbox = [
|
||||||
max(0, x1 / orig_w),
|
max(0, x / orig_w),
|
||||||
max(0, y1 / orig_h),
|
max(0, y / orig_h),
|
||||||
min(1, x2 / orig_w),
|
min(1, (x + w) / orig_w),
|
||||||
min(1, y2 / orig_h),
|
min(1, (y + h) / orig_h),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Extract landmarks (5 points) for alignment
|
# Extract 5-point landmarks for alignment
|
||||||
if len(det) >= 15:
|
landmarks = np.array([
|
||||||
landmarks = det[5:15].reshape(5, 2)
|
[face[4], face[5]], # right eye
|
||||||
landmarks[:, 0] = (landmarks[:, 0] - pad_x) / scale
|
[face[6], face[7]], # left eye
|
||||||
landmarks[:, 1] = (landmarks[:, 1] - pad_y) / scale
|
[face[8], face[9]], # nose
|
||||||
else:
|
[face[10], face[11]], # right mouth
|
||||||
# Fallback: no landmarks, skip recognition
|
[face[12], face[13]], # left mouth
|
||||||
continue
|
], dtype=np.float32)
|
||||||
|
|
||||||
# Align face for recognition
|
# Align face for recognition
|
||||||
face_crop = _align_face(image, landmarks)
|
face_crop = _align_face(image, landmarks)
|
||||||
|
|
||||||
# SFace expects (1, 3, 112, 112) float32, BGR, normalized
|
# SFace expects (1, 3, 112, 112) float32, BGR
|
||||||
face_bgr = face_crop[:, :, ::-1].copy()
|
face_bgr = cv2.cvtColor(face_crop.astype(np.uint8), cv2.COLOR_RGB2BGR)
|
||||||
face_blob = (face_bgr / 255.0).transpose(2, 0, 1)[np.newaxis].astype(np.float32)
|
face_blob = (face_bgr.astype(np.float32) / 255.0).transpose(2, 0, 1)[np.newaxis]
|
||||||
|
|
||||||
rec_input = self._recognizer.get_inputs()[0].name
|
rec_input = self._recognizer.get_inputs()[0].name
|
||||||
embedding = self._recognizer.run(None, {rec_input: face_blob})[0][0]
|
embedding = self._recognizer.run(None, {rec_input: face_blob})[0][0]
|
||||||
|
|||||||
@@ -206,6 +206,42 @@ def detect_objects(photo_id: str):
|
|||||||
return {'status': 'success', 'photo_id': photo_id, 'objects': len(detections)}
|
return {'status': 'success', 'photo_id': photo_id, 'objects': len(detections)}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_original(photo_id: str) -> np.ndarray | None:
|
||||||
|
"""Load the original photo file as an RGB numpy array, resized to
|
||||||
|
max 1280px on the longest edge for face detection."""
|
||||||
|
from sqlalchemy import create_engine, select as sa_select, text as sa_text
|
||||||
|
from app.models import Photo
|
||||||
|
|
||||||
|
session = _get_sync_session()
|
||||||
|
try:
|
||||||
|
photo = session.execute(
|
||||||
|
sa_select(Photo).where(Photo.id == photo_id)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if not photo or not photo.filepath:
|
||||||
|
return None
|
||||||
|
filepath = photo.filepath
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
if not Path(filepath).exists():
|
||||||
|
logger.warning("Original file not found: %s", filepath)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(filepath).convert("RGB")
|
||||||
|
# Cap at 4000px on longest edge to avoid OOM, but keep as large
|
||||||
|
# as possible for face detection accuracy
|
||||||
|
max_dim = 4000
|
||||||
|
w, h = img.size
|
||||||
|
if max(w, h) > max_dim:
|
||||||
|
scale = max_dim / max(w, h)
|
||||||
|
img = img.resize((int(w * scale), int(h * scale)), Image.BICUBIC)
|
||||||
|
return np.array(img)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to load original %s: %s", filepath, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@shared_task(name='extract_faces', queue='vision')
|
@shared_task(name='extract_faces', queue='vision')
|
||||||
def extract_faces(photo_id: str):
|
def extract_faces(photo_id: str):
|
||||||
"""Detect faces and store recognition embeddings. Clustering is
|
"""Detect faces and store recognition embeddings. Clustering is
|
||||||
@@ -213,9 +249,13 @@ def extract_faces(photo_id: str):
|
|||||||
if not settings.vision.enabled or not settings.vision.faces.enabled:
|
if not settings.vision.enabled or not settings.vision.faces.enabled:
|
||||||
return {'status': 'skipped', 'reason': 'faces disabled'}
|
return {'status': 'skipped', 'reason': 'faces disabled'}
|
||||||
|
|
||||||
image = _load_thumb(photo_id, "large") # 1280px for better face detection
|
# Use original file for face detection — thumbnails are often too
|
||||||
|
# small (240px) for reliable face detection.
|
||||||
|
image = _load_original(photo_id)
|
||||||
if image is None:
|
if image is None:
|
||||||
return {'status': 'error', 'message': 'thumbnail not found'}
|
image = _load_thumb(photo_id, "large")
|
||||||
|
if image is None:
|
||||||
|
return {'status': 'error', 'message': 'no image available'}
|
||||||
|
|
||||||
from app.services.vision.registry import registry
|
from app.services.vision.registry import registry
|
||||||
face_proc = registry.get_face_processor()
|
face_proc = registry.get_face_processor()
|
||||||
|
|||||||
Reference in New Issue
Block a user