YassminOsama/FaceVerificationAPI
0
1"""2Shared constants, models, and helper functions for the AI Identity3Verification service.4 5This module is imported by both the enrollment and verification routers6and by ``main.py`` itself. It contains nothing FastAPI-specific (no7endpoints) — only pure logic.8"""9 10import io11import os12import sys13from dataclasses import dataclass, field14from datetime import datetime, timezone15from enum import Enum16from pathlib import Path17from typing import Any18 19# Fix for Windows cp1256 encoding — DeepFace logger uses emoji that crash20# on non-UTF-8 consoles. Must be set before importing DeepFace.21os.environ.setdefault("PYTHONIOENCODING", "utf-8")22if hasattr(sys.stdout, "reconfigure"):23 sys.stdout.reconfigure(encoding="utf-8", errors="replace")24if hasattr(sys.stderr, "reconfigure"):25 sys.stderr.reconfigure(encoding="utf-8", errors="replace")26 27# pyrefly: ignore [missing-import]28import cv229import numpy as np30# pyrefly: ignore [missing-import]31from deepface import DeepFace32from fastapi import HTTPException33from PIL import Image34 35# ---------------------------------------------------------------------------36# Constants37# ---------------------------------------------------------------------------38MODEL_NAME = "ArcFace"39DETECTOR_BACKEND = "retinaface"40SIMILARITY_THRESHOLD = 0.60 # 60 %41MIN_IMAGE_DIMENSION = 80 # pixels – reject if either side is smaller42MIN_LAPLACIAN_VARIANCE = 30.0 # blur detection threshold43SESSION_TTL_MINUTES = 15 # enrollment sessions expire after this44MAX_RETAKES_PER_POSE = 545 46# CORS origins — defaults to ["*"] for development.47# Set CORS_ORIGINS env var to a comma-separated list for production.48CORS_ORIGINS: list[str] = [49 o.strip()50 for o in os.environ.get("CORS_ORIGINS", "*").split(",")51 if o.strip()52]53 54# ---------------------------------------------------------------------------55# Head Pose Definitions56# ---------------------------------------------------------------------------57 58class HeadPose(Enum):59 FRONT = "front"60 LEFT = "left"61 RIGHT = "right"62 UP = "up"63 DOWN = "down"64 65 66# Yaw/pitch angle bands (degrees) per required pose.67# Yaw > 0 → face turned right; Yaw < 0 → face turned left.68# Pitch > 0 → face tilted down; Pitch < 0 → face tilted up.69#70# Bands overlap intentionally at boundaries to avoid dead zones.71# When multiple bands match, classify_pose picks the best one.72POSE_ANGLE_BANDS: dict[HeadPose, dict] = {73 HeadPose.FRONT: {"yaw": (-12, 12), "pitch": (-12, 12)},74 HeadPose.LEFT: {"yaw": (-90, -10), "pitch": (-30, 30)},75 HeadPose.RIGHT: {"yaw": ( 10, 90), "pitch": (-30, 30)},76 HeadPose.UP: {"yaw": (-25, 25), "pitch": (-60, -10)},77 HeadPose.DOWN: {"yaw": (-25, 25), "pitch": ( 10, 60)},78}79 80# Ordered sequence of poses the enrollment session will walk through81REQUIRED_ENROLLMENT_POSES: list[HeadPose] = [82 HeadPose.FRONT,83 HeadPose.LEFT,84 HeadPose.RIGHT,85 HeadPose.UP,86 HeadPose.DOWN,87]88 89 90# ---------------------------------------------------------------------------91# Enrollment Session State92# ---------------------------------------------------------------------------93 94@dataclass95class PoseCapture:96 pose: HeadPose97 embedding: list[float]98 is_valid: bool = True99 100 101@dataclass102class EnrollmentSession:103 captures: dict[HeadPose, PoseCapture] = field(default_factory=dict)104 current_pose_index: int = 0105 retake_count: int = 0106 created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))107 108 @property109 def current_target(self) -> HeadPose | None:110 if self.current_pose_index < len(REQUIRED_ENROLLMENT_POSES):111 return REQUIRED_ENROLLMENT_POSES[self.current_pose_index]112 return None113 114 @property115 def is_complete(self) -> bool:116 return self.current_pose_index >= len(REQUIRED_ENROLLMENT_POSES)117 118 def advance(self) -> None:119 """Mark current pose as done and move to the next one."""120 self.current_pose_index += 1121 self.retake_count = 0122 123 124# In-memory session store.125_enrollment_sessions: dict[str, EnrollmentSession] = {}126 127 128# ---------------------------------------------------------------------------129# Image validation130# ---------------------------------------------------------------------------131def validate_image(image_bytes: bytes) -> np.ndarray:132 """133 Validate an uploaded image for minimum size and sharpness.134 135 Returns the decoded image as a BGR numpy array (OpenCV format).136 137 Raises:138 HTTPException 400 — image too small or too blurry139 HTTPException 422 — image cannot be decoded140 """141 try:142 pil_image = Image.open(io.BytesIO(image_bytes))143 pil_image.verify()144 pil_image = Image.open(io.BytesIO(image_bytes)).convert("RGB")145 except Exception:146 raise HTTPException(status_code=422, detail="Unable to decode the uploaded image.")147 148 width, height = pil_image.size149 150 if width < MIN_IMAGE_DIMENSION or height < MIN_IMAGE_DIMENSION:151 raise HTTPException(152 status_code=400,153 detail=f"Image too small ({width}x{height}). "154 f"Minimum dimension is {MIN_IMAGE_DIMENSION}px.",155 )156 157 cv_image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)158 159 gray = cv2.cvtColor(cv_image, cv2.COLOR_BGR2GRAY)160 laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()161 if laplacian_var < MIN_LAPLACIAN_VARIANCE:162 raise HTTPException(163 status_code=400,164 detail=f"Image is too blurry (variance={laplacian_var:.1f}, "165 f"minimum={MIN_LAPLACIAN_VARIANCE}).",166 )167 168 return cv_image169 170 171# ---------------------------------------------------------------------------172# Head Pose Detection173# ---------------------------------------------------------------------------174 175def get_pose_angles(cv_image: np.ndarray) -> dict[str, float] | None:176 """177 Estimate yaw and pitch from RetinaFace 5-point landmarks.178 179 Uses the geometric relationship between the eye midpoint, nose tip,180 and mouth midpoint — no 3-D model required.181 182 Returns {"yaw": float, "pitch": float} in approximate degrees, or183 None when no face is detected.184 185 Convention (matches POSE_ANGLE_BANDS above):186 yaw > 0 → face turned RIGHT187 yaw < 0 → face turned LEFT188 pitch > 0 → face tilted DOWN (nose below eye-mouth midpoint)189 pitch < 0 → face tilted UP190 """191 try:192 # RetinaFace is already a dependency of DeepFace; import lazily so193 # the rest of the module loads even if retina-face isn't installed yet.194 from retinaface import RetinaFace # pip install retina-face195 except ImportError:196 # Graceful fallback: skip angle-based pose check197 return None198 199 faces = RetinaFace.detect_faces(cv_image)200 if not faces or not isinstance(faces, dict):201 return None202 203 # Pick the face with the highest detection confidence204 face = max(faces.values(), key=lambda f: f.get("score", 0))205 lm = face.get("landmarks", {})206 207 # RetinaFace landmark keys (note: names are from the sitter's perspective)208 right_eye = np.array(lm.get("right_eye", [0, 0]), dtype=float)209 left_eye = np.array(lm.get("left_eye", [0, 0]), dtype=float)210 nose = np.array(lm.get("nose", [0, 0]), dtype=float)211 mouth_right = np.array(lm.get("mouth_right", [0, 0]), dtype=float)212 mouth_left = np.array(lm.get("mouth_left", [0, 0]), dtype=float)213 214 eye_mid = (right_eye + left_eye) / 2.0215 mouth_mid = (mouth_right + mouth_left) / 2.0216 217 # Use the face bounding box width as a stable reference dimension218 # instead of inter-eye distance (which shrinks when head turns).219 bbox = face.get("facial_area", [])220 if len(bbox) >= 4:221 face_bbox_w = float(bbox[2] - bbox[0])222 face_bbox_h = float(bbox[3] - bbox[1])223 else:224 # Fallback: estimate from landmarks225 all_x = [right_eye[0], left_eye[0], nose[0], mouth_right[0], mouth_left[0]]226 all_y = [right_eye[1], left_eye[1], nose[1], mouth_right[1], mouth_left[1]]227 face_bbox_w = float(max(all_x) - min(all_x)) * 1.5228 face_bbox_h = float(max(all_y) - min(all_y)) * 1.3229 230 if face_bbox_w < 1.0 or face_bbox_h < 1.0:231 return None232 233 # --- Yaw estimation ---------------------------------------------------234 # Measure nose offset from eye-midpoint, normalised by face bounding box235 # width. The bbox width is much more stable than inter-eye distance236 # when the head rotates (inter-eye distance shrinks).237 nose_offset_x = (nose[0] - eye_mid[0]) / face_bbox_w238 # Scale: for a frontal face the nose is roughly at eye-midpoint (offset~0).239 # At a 45° turn the offset is ~0.20–0.25 of bbox width.240 yaw = float(nose_offset_x * 180.0)241 242 # --- Pitch estimation -------------------------------------------------243 # Use vertical position of nose relative to face bbox height.244 face_height = float(mouth_mid[1] - eye_mid[1])245 if face_height < 1.0:246 # Face nearly horizontal — use bbox height as fallback247 face_height = face_bbox_h * 0.4248 nose_offset_y = (nose[1] - eye_mid[1]) / face_height249 pitch = float((nose_offset_y - 0.5) * 90.0)250 251 print(f"[POSE ANGLES] landmarks: RE={right_eye.tolist()}, LE={left_eye.tolist()}, "252 f"nose={nose.tolist()}, bbox_w={face_bbox_w:.0f}, "253 f"nose_off_x={nose_offset_x:.3f}, yaw={yaw:.1f}, pitch={pitch:.1f}")254 255 return {"yaw": yaw, "pitch": pitch}256 257 258def classify_pose(angles: dict[str, float]) -> HeadPose | None:259 """260 Map a (yaw, pitch) angle dict onto one of the required HeadPose values.261 Returns None when no band matches.262 263 When multiple bands match (due to intentional overlaps that prevent dead264 zones), the pose whose band-center is closest to the measured angles wins.265 """266 matches: list[tuple[HeadPose, float]] = []267 for pose, band in POSE_ANGLE_BANDS.items():268 yaw_ok = band["yaw"][0] <= angles["yaw"] <= band["yaw"][1]269 pitch_ok = band["pitch"][0] <= angles["pitch"] <= band["pitch"][1]270 if yaw_ok and pitch_ok:271 # Distance from the center of this band272 yaw_center = (band["yaw"][0] + band["yaw"][1]) / 2.0273 pitch_center = (band["pitch"][0] + band["pitch"][1]) / 2.0274 dist = (angles["yaw"] - yaw_center) ** 2 + (angles["pitch"] - pitch_center) ** 2275 matches.append((pose, dist))276 if not matches:277 return None278 # Return the pose with the smallest distance to its band center279 return min(matches, key=lambda m: m[1])[0]280 281 282# ---------------------------------------------------------------------------283# Embedding extraction284# ---------------------------------------------------------------------------285 286def extract_embedding(image_bytes: bytes, require_straight: bool = False) -> list[float]:287 """288 Validate the image, detect exactly one face, and return a 512-d289 ArcFace embedding vector.290 291 Args:292 image_bytes: Raw bytes of the uploaded image.293 require_straight: When True, also verify the face is looking294 straight ahead.295 296 Raises:297 HTTPException 400 — multiple faces, image quality, or bad pose298 HTTPException 422 — no face detected / image cannot be decoded299 HTTPException 500 — model error300 """301 cv_image = validate_image(image_bytes)302 303 try:304 results: list[dict[str, Any]] = DeepFace.represent(305 img_path=cv_image,306 model_name=MODEL_NAME,307 detector_backend=DETECTOR_BACKEND,308 enforce_detection=True,309 )310 except ValueError as exc:311 exc_msg = str(exc).lower()312 if "no face" in exc_msg or "face could not be detected" in exc_msg:313 raise HTTPException(314 status_code=422,315 detail="No face detected in the uploaded image.",316 )317 raise HTTPException(318 status_code=500,319 detail=f"Face recognition model error: {exc}",320 )321 except Exception as exc:322 raise HTTPException(323 status_code=500,324 detail=f"Unexpected error during face detection: {exc}",325 )326 327 if len(results) == 0:328 raise HTTPException(329 status_code=422,330 detail="No face detected in the uploaded image.",331 )332 333 if len(results) > 1:334 raise HTTPException(335 status_code=400,336 detail=f"Multiple faces detected ({len(results)}). "337 "Please upload an image with a single face.",338 )339 340 if require_straight:341 angles = get_pose_angles(cv_image)342 if angles is not None:343 detected = classify_pose(angles)344 if detected != HeadPose.FRONT:345 direction = detected.value.upper() if detected else "unknown direction"346 raise HTTPException(347 status_code=400,348 detail=(349 f"Invalid head pose (facing {direction}). "350 "Please look straight at the camera and retry."351 ),352 )353 354 return results[0]["embedding"]355 356 357def detect_faces(image_bytes: bytes) -> list[dict[str, Any]]:358 """359 Validate the image, detect all faces, and return the raw DeepFace360 results list. Each element contains an ``"embedding"`` key.361 362 Unlike :func:`extract_embedding`, this function does **not** raise363 when multiple faces are found — callers decide how to handle that.364 365 Raises:366 HTTPException 422 — no face detected / image cannot be decoded367 HTTPException 400 — image too small or too blurry368 HTTPException 500 — model error369 """370 cv_image = validate_image(image_bytes)371 372 try:373 results: list[dict[str, Any]] = DeepFace.represent(374 img_path=cv_image,375 model_name=MODEL_NAME,376 detector_backend=DETECTOR_BACKEND,377 enforce_detection=True,378 )379 except ValueError as exc:380 exc_msg = str(exc).lower()381 if "no face" in exc_msg or "face could not be detected" in exc_msg:382 raise HTTPException(383 status_code=422,384 detail="No face detected in the uploaded image.",385 )386 raise HTTPException(387 status_code=500,388 detail=f"Face recognition model error: {exc}",389 )390 except Exception as exc:391 raise HTTPException(392 status_code=500,393 detail=f"Unexpected error during face detection: {exc}",394 )395 396 if len(results) == 0:397 raise HTTPException(398 status_code=422,399 detail="No face detected in the uploaded image.",400 )401 402 return results403 404 405# ---------------------------------------------------------------------------406# Cosine similarity407# ---------------------------------------------------------------------------408 409def cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float:410 """Return cosine similarity as a percentage (0–100)."""411 a = np.array(vec_a)412 b = np.array(vec_b)413 dot = np.dot(a, b)414 norm = np.linalg.norm(a) * np.linalg.norm(b)415 if norm == 0:416 return 0.0417 similarity = dot / norm418 return round(float(max(0.0, min(1.0, similarity))) * 100, 2)419 