CoolFace
Apppublic

YassminOsama/FaceVerificationAPI

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
core.py419 linesDownload Raw Back to root
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