672 lines
25 KiB
Python
672 lines
25 KiB
Python
"""
|
|
Face Preprocessing Module (Revisi)
|
|
================================
|
|
Contains:
|
|
1. FaceAlignment - Using YuNet 5 landmarks for affine transformation
|
|
2. FaceQualityAssessment - Simplified to 3 metrics (Blur, Brightness, Pose)
|
|
|
|
Revisi: Quality assessment disederhanakan menjadi 3 metrik saja
|
|
- Blur (Variance of Laplacian)
|
|
- Brightness (Mean pixel)
|
|
- Pose (Yaw/Pitch dari landmark)
|
|
|
|
Author: Skripsi 2026
|
|
"""
|
|
|
|
import cv2
|
|
import numpy as np
|
|
from typing import Tuple, Dict, Optional
|
|
import logging
|
|
|
|
from config import (
|
|
ALIGNMENT_CONFIG, QUALITY_CONFIG, DETECTION_CONFIG,
|
|
YUNET_MODEL_PATH
|
|
)
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class FaceAlignment:
|
|
"""
|
|
Face Alignment using YuNet's 5 facial landmarks.
|
|
|
|
Uses 5-point similarity transform (cv2.estimateAffinePartial2D) for better
|
|
alignment, matching how ArcFace models are trained with InsightFace.
|
|
|
|
YuNet outputs 5 landmarks:
|
|
0: left_eye
|
|
1: right_eye
|
|
2: nose
|
|
3: left_mouth
|
|
4: right_mouth
|
|
"""
|
|
|
|
def __init__(self, target_size: int = None):
|
|
self.target_size = target_size or ALIGNMENT_CONFIG['target_size']
|
|
self.reference_points = ALIGNMENT_CONFIG['reference_points'].copy()
|
|
scale = self.target_size / ALIGNMENT_CONFIG['reference_scale']
|
|
self.reference_points *= scale
|
|
|
|
def align_face(
|
|
self,
|
|
face_crop: np.ndarray,
|
|
landmarks: np.ndarray
|
|
) -> Tuple[np.ndarray, Dict]:
|
|
"""
|
|
Align face using 5-point similarity transform.
|
|
|
|
Uses cv2.estimateAffinePartial2D for better alignment that matches
|
|
how ArcFace/InsightFace models are trained.
|
|
|
|
Args:
|
|
face_crop: Original face crop (BGR)
|
|
landmarks: 5 facial landmarks
|
|
|
|
Returns:
|
|
Tuple of (aligned_face, alignment_info)
|
|
"""
|
|
if landmarks is None or len(landmarks) < 5:
|
|
return face_crop, {'aligned': False}
|
|
|
|
try:
|
|
# Use all 5 landmarks for better alignment
|
|
src_points = landmarks.astype(np.float64)
|
|
|
|
# 5-point similarity transform using RANSAC for robustness
|
|
# estimateAffinePartial2D finds the optimal similarity transform
|
|
# that best maps src_points to reference_points
|
|
matrix, inliers = cv2.estimateAffinePartial2D(
|
|
src_points,
|
|
self.reference_points,
|
|
method=cv2.RANSAC,
|
|
ransacReprojThreshold=3.0
|
|
)
|
|
|
|
if matrix is None:
|
|
return face_crop, {'aligned': False}
|
|
|
|
aligned = cv2.warpAffine(
|
|
face_crop,
|
|
matrix,
|
|
(self.target_size, self.target_size),
|
|
flags=cv2.INTER_LINEAR,
|
|
borderMode=cv2.BORDER_REFLECT
|
|
)
|
|
|
|
# Count inliers for quality assessment
|
|
n_inliers = np.sum(inliers) if inliers is not None else 5
|
|
alignment_quality = n_inliers / 5.0
|
|
|
|
return aligned, {
|
|
'aligned': True,
|
|
'matrix': matrix,
|
|
'inliers': int(n_inliers),
|
|
'quality': alignment_quality
|
|
}
|
|
|
|
except Exception as e:
|
|
log.warning(f"Alignment failed: {e}")
|
|
return face_crop, {'aligned': False}
|
|
|
|
|
|
class FaceQualityAssessment:
|
|
"""
|
|
Simplified Face Quality Assessment (3 Metrik).
|
|
|
|
Revisi: Hanya 3 metrik untuk kemudahan validasi:
|
|
1. Blur - Variance of Laplacian
|
|
2. Brightness - Mean pixel intensity
|
|
3. Pose - Yaw/Pitch dari landmark offset
|
|
|
|
Formula Quality Score:
|
|
Q = (blur_ok + brightness_ok + pose_ok) / 3
|
|
Pass if Q >= 0.5 (minimal 2 dari 3 metrik bagus)
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.config = QUALITY_CONFIG
|
|
|
|
def assess_blur(self, face: np.ndarray, brightness_hint: float = None) -> Tuple[bool, float]:
|
|
"""
|
|
Assess blur using Variance of Laplacian.
|
|
|
|
Revisi Low-Light: Threshold adaptif berdasarkan kecerahan.
|
|
Gambar gelap secara natural memiliki Laplacian variance lebih rendah
|
|
karena kontras lokal memang lebih rendah — bukan berarti blur!
|
|
Threshold diturunkan saat gelap agar wajah valid tidak ditolak.
|
|
|
|
Args:
|
|
face: Face crop
|
|
brightness_hint: Mean brightness (opsional, untuk threshold adaptif)
|
|
|
|
Returns:
|
|
Tuple of (is_good, score)
|
|
"""
|
|
if len(face.shape) == 3:
|
|
gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY)
|
|
else:
|
|
gray = face
|
|
|
|
gray = cv2.resize(gray, (128, 128))
|
|
variance = cv2.Laplacian(gray, cv2.CV_64F).var()
|
|
|
|
# Revisi: threshold adaptif berdasarkan brightness
|
|
# Saat gelap (mean < 60): threshold diturunkan ke 12 (dari 30)
|
|
# Saat normal (mean >= 60): threshold tetap 30
|
|
if brightness_hint is None:
|
|
brightness_hint = float(np.mean(gray))
|
|
|
|
if brightness_hint < 40:
|
|
threshold = 12.0 # Sangat gelap: sangat toleran
|
|
elif brightness_hint < 60:
|
|
threshold = 18.0 # Gelap: toleran
|
|
elif brightness_hint < 80:
|
|
threshold = 22.0 # Agak gelap: sedikit toleran
|
|
else:
|
|
threshold = self.config['blur']['threshold'] # Normal: 30.0
|
|
|
|
if variance < threshold:
|
|
return False, variance / threshold # 0-1 score
|
|
else:
|
|
return True, min(1.0, variance / (threshold * 2))
|
|
|
|
def assess_brightness(self, face: np.ndarray) -> Tuple[bool, float]:
|
|
"""
|
|
Assess brightness level.
|
|
|
|
Revisi Low-Light: Score lebih smooth untuk kondisi indoor.
|
|
Menggunakan curve yang lebih gradual agar skor tidak langsung 0
|
|
saat kondisi gelap.
|
|
|
|
Args:
|
|
face: Face crop
|
|
|
|
Returns:
|
|
Tuple of (is_good, score)
|
|
"""
|
|
if len(face.shape) == 3:
|
|
gray = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY)
|
|
else:
|
|
gray = face
|
|
|
|
mean_brightness = np.mean(gray)
|
|
min_b = self.config['brightness']['min'] # 20 (revisi)
|
|
max_b = self.config['brightness']['max'] # 230 (revisi)
|
|
|
|
if mean_brightness < min_b or mean_brightness > max_b:
|
|
# Score berdasarkan jarak ke range optimal
|
|
if mean_brightness < min_b:
|
|
# Low-light: score lebih smooth (tidak langsung 0)
|
|
# Saat brightness=10 dengan min=20 → score = 0.5 (bukan 0.25)
|
|
score = 0.3 + 0.7 * (mean_brightness / min_b)
|
|
else:
|
|
score = max_b / mean_brightness
|
|
return False, float(max(0.0, min(1.0, score)))
|
|
else:
|
|
# Dalam range: score 1.0 di tengah, sedikit turun di pinggir
|
|
optimal = (min_b + max_b) / 2
|
|
deviation = abs(mean_brightness - optimal) / ((max_b - min_b) / 2)
|
|
return True, float(max(0.7, 1.0 - 0.3 * deviation))
|
|
|
|
def assess_pose(self, landmarks: np.ndarray = None) -> Tuple[bool, float]:
|
|
"""
|
|
Assess head pose from landmarks.
|
|
|
|
Args:
|
|
landmarks: 5 facial landmarks
|
|
|
|
Returns:
|
|
Tuple of (is_good, score)
|
|
"""
|
|
if landmarks is None or len(landmarks) < 5:
|
|
return True, 0.7 # Unknown = assume good
|
|
|
|
left_eye = landmarks[0]
|
|
right_eye = landmarks[1]
|
|
nose = landmarks[2]
|
|
|
|
eye_dist = np.linalg.norm(right_eye - left_eye)
|
|
if eye_dist < 1:
|
|
return True, 0.7
|
|
|
|
eye_mid = (left_eye + right_eye) / 2
|
|
|
|
# Yaw: nose horizontal offset from the eye line. This is a
|
|
# scale-normalized proxy, so it remains meaningful for different
|
|
# face-crop sizes.
|
|
yaw = abs(nose[0] - eye_mid[0]) / eye_dist * 45
|
|
|
|
# Pitch must not be computed from the absolute eye-to-nose distance.
|
|
# On a normal frontal face the nose is naturally below the eyes, so
|
|
# the old formula marked many frontal faces as strongly pitched.
|
|
# Compare the observed ratio with the frontal reference geometry
|
|
# instead. This keeps the quality score about pose, not anatomy.
|
|
mouth_mid = (landmarks[3] + landmarks[4]) / 2
|
|
ref = ALIGNMENT_CONFIG['reference_points']
|
|
ref_eye_dist = np.linalg.norm(ref[1] - ref[0])
|
|
ref_eye_nose = (ref[2][1] - (ref[0][1] + ref[1][1]) / 2) / ref_eye_dist
|
|
ref_nose_mouth = (ref[3][1] + ref[4][1]) / 2 - ref[2][1]
|
|
ref_nose_mouth /= ref_eye_dist
|
|
|
|
eye_nose = (nose[1] - eye_mid[1]) / eye_dist
|
|
nose_mouth = (mouth_mid[1] - nose[1]) / eye_dist
|
|
pitch = (abs(eye_nose - ref_eye_nose) +
|
|
abs(nose_mouth - ref_nose_mouth)) * 22.5
|
|
|
|
yaw_th = self.config['pose']['yaw_threshold']
|
|
pitch_th = self.config['pose']['pitch_threshold']
|
|
|
|
# Score based on deviation
|
|
yaw_score = max(0, 1 - yaw / yaw_th)
|
|
pitch_score = max(0, 1 - pitch / pitch_th)
|
|
|
|
is_good = yaw < yaw_th and pitch < pitch_th
|
|
|
|
return is_good, (yaw_score + pitch_score) / 2
|
|
|
|
def assess(
|
|
self,
|
|
face: np.ndarray,
|
|
landmarks: np.ndarray = None
|
|
) -> Dict:
|
|
"""
|
|
Comprehensive face quality assessment (3 metrik).
|
|
|
|
Revisi Low-Light: brightness dihitung terlebih dahulu,
|
|
lalu nilainya diteruskan ke assess_blur sebagai hint
|
|
untuk menentukan threshold adaptif.
|
|
|
|
Args:
|
|
face: Face crop (BGR)
|
|
landmarks: 5 facial landmarks (optional)
|
|
|
|
Returns:
|
|
Dictionary with quality scores
|
|
"""
|
|
# Hitung brightness terlebih dahulu (sebagai hint untuk blur)
|
|
brightness_ok, brightness_score = self.assess_brightness(face)
|
|
|
|
# Hitung mean brightness untuk hint adaptif blur threshold
|
|
if len(face.shape) == 3:
|
|
gray_hint = cv2.cvtColor(face, cv2.COLOR_BGR2GRAY)
|
|
else:
|
|
gray_hint = face
|
|
mean_brightness_hint = float(np.mean(gray_hint))
|
|
|
|
# Blur assessment dengan hint brightness (threshold adaptif)
|
|
blur_ok, blur_score = self.assess_blur(face, brightness_hint=mean_brightness_hint)
|
|
|
|
pose_ok, pose_score = self.assess_pose(landmarks)
|
|
|
|
# Calculate quality score
|
|
# Q = rata-rata dari 3 metrik
|
|
quality_score = (blur_score + brightness_score + pose_score) / 3
|
|
|
|
# Determine status
|
|
metriks_passed = sum([blur_ok, brightness_ok, pose_ok])
|
|
|
|
if metriks_passed >= 2:
|
|
status = 'good' if quality_score >= 0.6 else 'acceptable'
|
|
else:
|
|
status = 'poor'
|
|
|
|
return {
|
|
'blur': {
|
|
'ok': blur_ok,
|
|
'score': blur_score
|
|
},
|
|
'brightness': {
|
|
'ok': brightness_ok,
|
|
'score': brightness_score
|
|
},
|
|
'pose': {
|
|
'ok': pose_ok,
|
|
'score': pose_score
|
|
},
|
|
'quality_score': quality_score,
|
|
'status': status,
|
|
'metriks_passed': metriks_passed,
|
|
'mean_brightness': mean_brightness_hint, # Tambahan: untuk debug low-light
|
|
}
|
|
|
|
def meets_threshold(self, quality_results: Dict) -> bool:
|
|
"""Check if face meets quality threshold."""
|
|
return quality_results['quality_score'] >= self.config.get('pass_threshold', 0.5)
|
|
|
|
|
|
class FacePreprocessor:
|
|
"""
|
|
Unified Face Preprocessing Pipeline.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.detector = None
|
|
self.haar = None
|
|
self.alignment = FaceAlignment()
|
|
self.quality = FaceQualityAssessment()
|
|
self._load_detector()
|
|
|
|
def _load_detector(self):
|
|
"""Load YuNet face detector."""
|
|
if YUNET_MODEL_PATH.exists():
|
|
try:
|
|
self.detector = cv2.FaceDetectorYN.create(
|
|
str(YUNET_MODEL_PATH),
|
|
"",
|
|
(320, 320),
|
|
score_threshold=DETECTION_CONFIG['face_confidence_threshold'],
|
|
nms_threshold=DETECTION_CONFIG['face_nms_threshold'],
|
|
top_k=DETECTION_CONFIG['face_max_faces']
|
|
)
|
|
log.info("[Preprocessor] YuNet loaded")
|
|
except Exception as e:
|
|
log.warning(f"[Preprocessor] YuNet failed: {e}")
|
|
self.detector = None
|
|
|
|
# Haar is a conservative fallback for low-resolution or low-light
|
|
# images where YuNet may return no candidate at all. It is not used
|
|
# when YuNet succeeds, so normal inference remains YuNet-based.
|
|
haar_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
|
haar = cv2.CascadeClassifier(haar_path)
|
|
if not haar.empty():
|
|
self.haar = haar
|
|
log.info('[Preprocessor] Haar fallback loaded')
|
|
|
|
def detect_mask(self, aligned_face: np.ndarray) -> Tuple[bool, str, Dict]:
|
|
"""
|
|
Deteksi apakah wajah pada citra aligned (112x112) tertutup masker
|
|
atau memiliki oklusi pada area hidung & mulut.
|
|
|
|
Metode: Analisis krominans kulit (YCrCb) dan fotometri warna pada
|
|
daerah periokular atas (dahi/mata) vs daerah wajah bawah (hidung/mulut).
|
|
|
|
Returns:
|
|
Tuple[is_masked (bool), reason (str), metrics (dict)]
|
|
"""
|
|
if aligned_face is None or aligned_face.shape[:2] != (112, 112):
|
|
return False, "Wajah tidak valid", {}
|
|
|
|
upper_roi = aligned_face[25:55, 25:87]
|
|
lower_roi = aligned_face[65:105, 25:87]
|
|
mouth_roi = aligned_face[80:105, 30:82]
|
|
|
|
ycrcb_upper = cv2.cvtColor(upper_roi, cv2.COLOR_BGR2YCrCb)
|
|
ycrcb_lower = cv2.cvtColor(lower_roi, cv2.COLOR_BGR2YCrCb)
|
|
ycrcb_mouth = cv2.cvtColor(mouth_roi, cv2.COLOR_BGR2YCrCb)
|
|
|
|
def compute_skin_ratio(ycrcb_img):
|
|
cr = ycrcb_img[:, :, 1]
|
|
cb = ycrcb_img[:, :, 2]
|
|
# Rentang standar krominans kulit manusia: Cr in [133, 173], Cb in [77, 127]
|
|
mask = (cr >= 133) & (cr <= 173) & (cb >= 77) & (cb <= 127)
|
|
return float(np.mean(mask))
|
|
|
|
skin_upper = compute_skin_ratio(ycrcb_upper)
|
|
skin_lower = compute_skin_ratio(ycrcb_lower)
|
|
skin_mouth = compute_skin_ratio(ycrcb_mouth)
|
|
|
|
mean_upper = float(np.mean(upper_roi))
|
|
mean_lower = float(np.mean(lower_roi))
|
|
bright_ratio = mean_lower / (mean_upper + 1e-5)
|
|
|
|
hsv_lower = cv2.cvtColor(lower_roi, cv2.COLOR_BGR2HSV)
|
|
hsv_upper = cv2.cvtColor(upper_roi, cv2.COLOR_BGR2HSV)
|
|
sat_lower = float(np.mean(hsv_lower[:, :, 1]))
|
|
sat_upper = float(np.mean(hsv_upper[:, :, 1]))
|
|
|
|
b_mean = float(np.mean(lower_roi[:, :, 0]))
|
|
g_mean = float(np.mean(lower_roi[:, :, 1]))
|
|
r_mean = float(np.mean(lower_roi[:, :, 2]))
|
|
|
|
metrics = {
|
|
'skin_upper': skin_upper,
|
|
'skin_lower': skin_lower,
|
|
'skin_mouth': skin_mouth,
|
|
'bright_ratio': bright_ratio,
|
|
'mean_lower': mean_lower,
|
|
'sat_lower': sat_lower
|
|
}
|
|
|
|
# 1. Masker Gelap / Hitam (kain scuba / medis hitam)
|
|
if skin_lower < 0.40 and (bright_ratio < 0.60 or mean_lower < 60.0):
|
|
return True, "Terdeteksi masker gelap/hitam menutupi mulut dan hidung", metrics
|
|
|
|
# 2. Masker Medis Biru / Hijau (surgical mask)
|
|
if (b_mean > r_mean + 10 or g_mean > r_mean + 15) and skin_lower < 0.45:
|
|
return True, "Terdeteksi masker medis biru/hijau menutupi mulut dan hidung", metrics
|
|
|
|
# 3. Masker Putih / Abu-abu terang (achromatic mask)
|
|
if sat_lower < 25.0 and sat_upper > 35.0 and skin_lower < 0.45:
|
|
return True, "Terdeteksi masker putih/terang menutupi mulut dan hidung", metrics
|
|
|
|
# 4. Oklusi Kulit Umum (Kain / masker motif lain pada area mulut)
|
|
if skin_upper >= 0.50 and skin_mouth < 0.30:
|
|
return True, "Area mulut dan hidung terhalang masker atau penutup wajah", metrics
|
|
|
|
return False, "Wajah terbuka normal", metrics
|
|
|
|
def detect_and_preprocess(
|
|
self,
|
|
image: np.ndarray,
|
|
for_enrollment: bool = False
|
|
) -> Dict:
|
|
"""
|
|
Full detection and preprocessing pipeline.
|
|
|
|
Returns:
|
|
Dictionary with face, aligned_face, landmarks, quality, ready status, is_masked
|
|
"""
|
|
h, w = image.shape[:2]
|
|
|
|
result = {
|
|
'face': None,
|
|
'aligned_face': None,
|
|
'landmarks': None,
|
|
'quality': None,
|
|
'bbox': None,
|
|
'ready': False,
|
|
'detection_confidence': 0.0,
|
|
'is_masked': False,
|
|
'mask_reason': '',
|
|
'mask_metrics': {}
|
|
}
|
|
|
|
# Face Detection with Multi-Scale fallback
|
|
# ─────────────────────────────────────────────────────────────────
|
|
# Close-up selfie photos (common from Android front camera) often
|
|
# have faces filling >80% of the frame. YuNet works best when
|
|
# faces occupy 20-60% of the input. When detection at native
|
|
# resolution fails, we try progressively downscaled versions so
|
|
# the face becomes proportionally smaller and detectable.
|
|
# Coordinates are mapped back to the original image space.
|
|
# ─────────────────────────────────────────────────────────────────
|
|
if self.detector is not None:
|
|
face_info = self._detect_face_multiscale(image)
|
|
|
|
if face_info is not None:
|
|
x, y, bw, bh = map(int, face_info[:4])
|
|
conf = float(face_info[14])
|
|
|
|
if bw >= DETECTION_CONFIG['min_face_size'] and conf >= DETECTION_CONFIG['face_confidence_threshold']:
|
|
# Crop with padding
|
|
pad = int(DETECTION_CONFIG['padding_ratio'] * min(bw, bh))
|
|
x1 = max(0, x - pad)
|
|
y1 = max(0, y - pad)
|
|
x2 = min(w, x + bw + pad)
|
|
y2 = min(h, y + bh + pad)
|
|
|
|
result['face'] = image[y1:y2, x1:x2]
|
|
result['bbox'] = (x1, y1, x2 - x1, y2 - y1)
|
|
result['detection_confidence'] = conf
|
|
|
|
# Extract landmarks from image coordinates and translate them
|
|
# into the padded face crop coordinate space before alignment.
|
|
landmarks_global = face_info[4:14].reshape(5, 2).astype(np.float64)
|
|
crop_offset = np.array([x1, y1], dtype=np.float64)
|
|
result['landmarks'] = landmarks_global - crop_offset
|
|
|
|
# Align face
|
|
aligned, align_info = self.alignment.align_face(
|
|
result['face'],
|
|
result['landmarks']
|
|
)
|
|
result['aligned_face'] = aligned
|
|
|
|
# Assess quality on the detected crop and its original
|
|
# landmarks. The aligned image changes the coordinate
|
|
# system; using the old landmarks on that image makes
|
|
# pose scores inconsistent.
|
|
result['quality'] = self.quality.assess(
|
|
result['face'],
|
|
result['landmarks']
|
|
)
|
|
|
|
# Check for mask / occlusion on aligned face
|
|
is_masked, mask_reason, mask_metrics = self.detect_mask(result['aligned_face'])
|
|
result['is_masked'] = is_masked
|
|
result['mask_reason'] = mask_reason
|
|
result['mask_metrics'] = mask_metrics
|
|
|
|
if is_masked:
|
|
result['ready'] = False
|
|
if result['quality']:
|
|
result['quality']['status'] = 'masked'
|
|
result['quality']['mask_ok'] = False
|
|
result['quality']['quality_score'] = min(result['quality']['quality_score'], 0.20)
|
|
result['quality']['feedback_messages'] = [
|
|
"Wajah terdeteksi menggunakan masker. Harap lepas masker Anda terlebih dahulu!"
|
|
]
|
|
else:
|
|
# Check if ready
|
|
threshold = 0.4 if for_enrollment else 0.5
|
|
result['ready'] = result['quality']['quality_score'] >= threshold
|
|
|
|
# Fallback detection for difficult camera frames. Haar does not
|
|
# provide the five landmarks required for alignment, so the original
|
|
# crop is used and pose quality receives the documented neutral score.
|
|
if result['face'] is None and self.haar is not None:
|
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
|
gray = cv2.equalizeHist(gray)
|
|
min_side = max(40, min(DETECTION_CONFIG['min_face_size'], min(h, w) // 8))
|
|
haar_faces = self.haar.detectMultiScale(
|
|
gray,
|
|
scaleFactor=1.08,
|
|
minNeighbors=4,
|
|
minSize=(min_side, min_side),
|
|
)
|
|
if len(haar_faces) > 0:
|
|
x, y, bw, bh = max(haar_faces, key=lambda box: box[2] * box[3])
|
|
pad = int(DETECTION_CONFIG['padding_ratio'] * min(bw, bh))
|
|
x1 = max(0, int(x - pad))
|
|
y1 = max(0, int(y - pad))
|
|
x2 = min(w, int(x + bw + pad))
|
|
y2 = min(h, int(y + bh + pad))
|
|
result['face'] = image[y1:y2, x1:x2]
|
|
result['bbox'] = (x1, y1, x2 - x1, y2 - y1)
|
|
result['detection_confidence'] = 0.0
|
|
result['quality'] = self.quality.assess(result['face'], None)
|
|
threshold = 0.4 if for_enrollment else 0.5
|
|
result['ready'] = result['quality']['quality_score'] >= threshold
|
|
|
|
return result
|
|
|
|
def _detect_face_multiscale(self, image: np.ndarray):
|
|
"""
|
|
Multi-scale face detection using YuNet.
|
|
|
|
Always tries native resolution AND downscaled versions, then
|
|
returns whichever detection has the **highest confidence**.
|
|
|
|
This is critical for close-up selfie photos: native detection may
|
|
find a face with low confidence (0.67) and poor landmarks, while
|
|
detection at 0.5x scale finds the same face with high confidence
|
|
(0.91) and much better landmarks. Using the highest-confidence
|
|
detection ensures consistent alignment across all photos.
|
|
|
|
Returns:
|
|
face_info array (15 values: x,y,w,h, 5 landmarks x 2, conf)
|
|
or None if no face detected at any scale.
|
|
"""
|
|
h, w = image.shape[:2]
|
|
best_face = None
|
|
best_conf = -1.0
|
|
best_scale_name = "native"
|
|
|
|
# Try native resolution
|
|
self.detector.setInputSize((w, h))
|
|
_, faces = self.detector.detect(image)
|
|
if faces is not None and len(faces) > 0:
|
|
idx = np.argmax(faces[:, 14])
|
|
conf = float(faces[idx, 14])
|
|
if conf > best_conf:
|
|
best_face = faces[idx].copy()
|
|
best_conf = conf
|
|
best_scale_name = "1.0"
|
|
|
|
# Also try downscaled versions — always, not just as fallback
|
|
# For close-up photos, downscaled often gives BETTER detection
|
|
scales = [0.5, 0.4, 0.3]
|
|
for scale in scales:
|
|
sh, sw = int(h * scale), int(w * scale)
|
|
if sh < 100 or sw < 100:
|
|
continue
|
|
|
|
small = cv2.resize(image, (sw, sh), interpolation=cv2.INTER_AREA)
|
|
self.detector.setInputSize((sw, sh))
|
|
_, faces = self.detector.detect(small)
|
|
|
|
if faces is not None and len(faces) > 0:
|
|
idx = np.argmax(faces[:, 14])
|
|
conf = float(faces[idx, 14])
|
|
|
|
if conf > best_conf:
|
|
face_info = faces[idx].copy()
|
|
# Scale coordinates back to original image space
|
|
inv_scale = 1.0 / scale
|
|
face_info[0:4] *= inv_scale # bbox
|
|
face_info[4:14] *= inv_scale # landmarks
|
|
|
|
best_face = face_info
|
|
best_conf = conf
|
|
best_scale_name = f"{scale}"
|
|
|
|
if best_face is not None and best_scale_name != "1.0":
|
|
log.info(f"[Preprocessor] Best detection at scale={best_scale_name} "
|
|
f"(conf={best_conf:.2f})")
|
|
|
|
return best_face
|
|
|
|
|
|
def assess_face_quality(
|
|
face: np.ndarray,
|
|
landmarks: np.ndarray = None
|
|
) -> Dict:
|
|
"""
|
|
Convenience function to assess face quality.
|
|
|
|
Returns:
|
|
Quality assessment dictionary with 3 metriks
|
|
"""
|
|
assessor = FaceQualityAssessment()
|
|
return assessor.assess(face, landmarks)
|
|
|
|
|
|
# Test function
|
|
if __name__ == "__main__":
|
|
log.info("Testing FacePreprocessor...")
|
|
|
|
preprocessor = FacePreprocessor()
|
|
|
|
import sys
|
|
if len(sys.argv) > 1:
|
|
image = cv2.imread(sys.argv[1])
|
|
if image is not None:
|
|
result = preprocessor.detect_and_preprocess(image, for_enrollment=True)
|
|
log.info(f"Detection confidence: {result['detection_confidence']:.2f}")
|
|
if result['quality']:
|
|
q = result['quality']
|
|
log.info(f"Quality Score: {q['quality_score']:.2f} ({q['status']})")
|
|
log.info(f" Blur: {q['blur']['score']:.2f} (ok={q['blur']['ok']})")
|
|
log.info(f" Brightness: {q['brightness']['score']:.2f} (ok={q['brightness']['ok']})")
|
|
log.info(f" Pose: {q['pose']['score']:.2f} (ok={q['pose']['ok']})")
|