import cv2 import numpy as np from dataclasses import dataclass from ratio_calculator import FacialRatios, PITCH_MIN, PITCH_MAX BLUR_WEIGHT = 40 LIGHTING_WEIGHT = 30 POSE_WEIGHT = 30 SCORE_THRESHOLD = 60 _face_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_frontalface_default.xml") _eye_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + "haarcascade_eye.xml") @dataclass class GateResult: valid: bool message: str = "" gray: np.ndarray | None = None face_rect: tuple | None = None MAX_DETECTION_SIZE = 1536 MAX_IMAGE_DIMENSION = 8000 MAX_IMAGE_PIXELS = 40_000_000 def validate_image(image_bytes: bytes) -> GateResult: nparr = np.frombuffer(image_bytes, np.uint8) image = cv2.imdecode(nparr, cv2.IMREAD_COLOR) if image is None: return GateResult(False, "Unable to decode the image.") h, w = image.shape[:2] if max(h, w) > MAX_IMAGE_DIMENSION or h * w > MAX_IMAGE_PIXELS: return GateResult(False, "Image dimensions too large.") gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) h, w = gray.shape if max(h, w) > MAX_DETECTION_SIZE: scale = MAX_DETECTION_SIZE / max(h, w) detect_gray = cv2.resize(gray, (int(w * scale), int(h * scale))) else: scale = 1.0 detect_gray = gray faces = _face_cascade.detectMultiScale(detect_gray, scaleFactor=1.1, minNeighbors=5, minSize=(80, 80)) if len(faces) == 0: return GateResult(False, "No face detected in the image.") if len(faces) > 1: return GateResult(False, "Multiple faces detected. Please upload a photo with a single face.") x, y, fw, fh = faces[0] face_rect = (int(x / scale), int(y / scale), int(fw / scale), int(fh / scale)) return GateResult(True, gray=gray, face_rect=face_rect) BLUR_REJECT_THRESHOLD = 5 def score_quality(gray: np.ndarray, face_rect: tuple) -> tuple[int, str | None, str | None]: x, y, fw, fh = face_rect face_crop = gray[y:y + fh, x:x + fw] blur = _score_blur(face_crop) lighting = _score_lighting(gray) pose = _score_pose(gray, face_rect) score = int(blur + lighting + pose) if blur < BLUR_REJECT_THRESHOLD: return score, None, "Image too blurry to process. Please use a sharper photo." warning = ( "Low quality image, results may be inaccurate. For best results: use a sharp, " "well-lit, front-facing photo with both eyes visible." if score < SCORE_THRESHOLD else None ) return score, warning, None def score_pitch(ratios: FacialRatios) -> str | None: if PITCH_MIN <= ratios.pitch_ratio <= PITCH_MAX: return None direction = "too high (look straight ahead)" if ratios.pitch_ratio < PITCH_MIN else "too low" return f"Camera angle {direction}, results may be less accurate." def _score_blur(gray) -> float: variance = cv2.Laplacian(gray, cv2.CV_64F).var() score = np.clip((variance - 30) / (200 - 30) * BLUR_WEIGHT, 0, BLUR_WEIGHT) return float(score) def _score_lighting(gray) -> float: mean = gray.mean() if mean < 60: score = np.clip((mean - 40) / (60 - 40) * LIGHTING_WEIGHT, 0, LIGHTING_WEIGHT) elif mean > 200: score = np.clip((230 - mean) / (230 - 200) * LIGHTING_WEIGHT, 0, LIGHTING_WEIGHT) else: score = float(LIGHTING_WEIGHT) return float(score) def _score_pose(gray, face_rect) -> float: x, y, w, h = face_rect face_roi = gray[y : y + h, x : x + w] eyes = _eye_cascade.detectMultiScale(face_roi, scaleFactor=1.1, minNeighbors=5) if len(eyes) < 2: return POSE_WEIGHT * 0.3 eyes = sorted(eyes, key=lambda e: e[2] * e[3], reverse=True)[:2] (cx1, cy1) = (eyes[0][0] + eyes[0][2] // 2, eyes[0][1] + eyes[0][3] // 2) (cx2, cy2) = (eyes[1][0] + eyes[1][2] // 2, eyes[1][1] + eyes[1][3] // 2) inter_eye = abs(cx1 - cx2) + 1e-6 roll_offset = abs(cy1 - cy2) / inter_eye yaw_offset = abs((cx1 + cx2) / 2 - w / 2) / (w / 2 + 1e-6) yaw_penalty = float(np.clip(yaw_offset / 0.2, 0, 1)) roll_penalty = float(np.clip(roll_offset / 0.15, 0, 1)) score = POSE_WEIGHT * (1 - (yaw_penalty * 0.6 + roll_penalty * 0.4)) return max(0.0, score)