Spaces:
Sleeping
Sleeping
| import os | |
| import urllib.request | |
| from contextlib import asynccontextmanager | |
| from dataclasses import dataclass | |
| from threading import Lock | |
| from typing import Any, List, Optional | |
| import cv2 | |
| import mediapipe as mp | |
| import numpy as np | |
| import requests as http_requests | |
| from fastapi import Body, FastAPI, HTTPException, Request | |
| from mediapipe.tasks import python as mp_python | |
| from mediapipe.tasks.python import vision as mp_vision | |
| from PIL import Image | |
| from io import BytesIO | |
| from pydantic import BaseModel | |
| # --------------------------------------------------------------------------- | |
| # Model download | |
| # --------------------------------------------------------------------------- | |
| MODEL_PATH = "face_landmarker_v2.task" | |
| MODEL_URL = ( | |
| "https://storage.googleapis.com/mediapipe-models/" | |
| "face_landmarker/face_landmarker/float16/latest/face_landmarker.task" | |
| ) | |
| if not os.path.exists(MODEL_PATH): | |
| print(f"Downloading face landmarker model to {MODEL_PATH} …") | |
| urllib.request.urlretrieve(MODEL_URL, MODEL_PATH) | |
| print("Download complete.") | |
| # --------------------------------------------------------------------------- | |
| # Data classes | |
| # --------------------------------------------------------------------------- | |
| class OrientationResult: | |
| orientation: str | |
| confidence: float | |
| face_visibility: float | |
| nose_depth_signal: float | |
| shoulder_score: float | |
| combined_score: float | |
| details: str | |
| # --------------------------------------------------------------------------- | |
| # Detector | |
| # --------------------------------------------------------------------------- | |
| class FrontBackDetector: | |
| """ | |
| Front/back classification based on eye visibility. | |
| Logic: | |
| - Both eyes visible → FRONT | |
| - No eyes visible → BACK | |
| - One eye visible → SIDE / ANGLED | |
| """ | |
| LEFT_EYE_OUTER = 33 | |
| LEFT_EYE_INNER = 133 | |
| RIGHT_EYE_OUTER = 263 | |
| RIGHT_EYE_INNER = 362 | |
| NOSE_TIP = 1 | |
| def __init__(self): | |
| options = mp_vision.FaceLandmarkerOptions( | |
| base_options=mp_python.BaseOptions(model_asset_path=MODEL_PATH), | |
| running_mode=mp_vision.RunningMode.IMAGE, | |
| num_faces=1, | |
| min_face_detection_confidence=0.3, | |
| min_face_presence_confidence=0.3, | |
| min_tracking_confidence=0.3, | |
| ) | |
| self.landmarker = mp_vision.FaceLandmarker.create_from_options(options) | |
| # ---- helpers -------------------------------------------------------- | |
| def _eye_visibility(self, face_landmarks, img_w, img_h): | |
| lm = face_landmarks | |
| le_outer = lm[self.LEFT_EYE_OUTER] | |
| le_inner = lm[self.LEFT_EYE_INNER] | |
| re_outer = lm[self.RIGHT_EYE_OUTER] | |
| re_inner = lm[self.RIGHT_EYE_INNER] | |
| left_eye_width = abs(le_inner.x - le_outer.x) * img_w | |
| right_eye_width = abs(re_inner.x - re_outer.x) * img_w | |
| face_width = abs(le_outer.x - re_outer.x) * img_w | |
| if face_width < 5: | |
| return False, False, 0.0, 0.0 | |
| left_ratio = left_eye_width / face_width | |
| right_ratio = right_eye_width / face_width | |
| left_score = min(left_ratio / 0.20, 1.0) | |
| right_score = min(right_ratio / 0.20, 1.0) | |
| left_in_bounds = 0.02 < le_outer.x < 0.98 and 0.02 < le_inner.x < 0.98 | |
| right_in_bounds = 0.02 < re_outer.x < 0.98 and 0.02 < re_inner.x < 0.98 | |
| left_visible = left_score > 0.35 and left_in_bounds | |
| right_visible = right_score > 0.35 and right_in_bounds | |
| return left_visible, right_visible, left_score, right_score | |
| # ---- main ----------------------------------------------------------- | |
| def detect(self, image_bgr: np.ndarray) -> Optional[OrientationResult]: | |
| rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB) | |
| h, w = rgb.shape[:2] | |
| mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb) | |
| result = self.landmarker.detect(mp_image) | |
| if not result.face_landmarks: | |
| return OrientationResult( | |
| orientation="BACK", | |
| confidence=0.95, | |
| face_visibility=0.0, | |
| nose_depth_signal=0.0, | |
| shoulder_score=0.0, | |
| combined_score=-0.95, | |
| details="no_face_detected → BACK", | |
| ) | |
| face_lm = result.face_landmarks[0] | |
| left_vis, right_vis, left_score, right_score = self._eye_visibility( | |
| face_lm, w, h | |
| ) | |
| eyes_visible = int(left_vis) + int(right_vis) | |
| if eyes_visible == 2: | |
| confidence = 0.7 + min((left_score + right_score) / 2, 1.0) * 0.3 | |
| orientation = "FRONT" | |
| combined = confidence | |
| elif eyes_visible == 1: | |
| orientation = "SIDE" | |
| confidence = 0.6 | |
| combined = 0.0 | |
| else: | |
| orientation = "BACK" | |
| confidence = 0.75 | |
| combined = -0.75 | |
| details = ( | |
| f"left_eye={'YES' if left_vis else 'NO'}({left_score:.2f}) | " | |
| f"right_eye={'YES' if right_vis else 'NO'}({right_score:.2f}) | " | |
| f"eyes_count={eyes_visible}/2" | |
| ) | |
| return OrientationResult( | |
| orientation=orientation, | |
| confidence=confidence, | |
| face_visibility=left_score + right_score, | |
| nose_depth_signal=0.0, | |
| shoulder_score=0.0, | |
| combined_score=combined, | |
| details=details, | |
| ) | |
| def close(self): | |
| self.landmarker.close() | |
| # --------------------------------------------------------------------------- | |
| # Image helpers | |
| # --------------------------------------------------------------------------- | |
| def load_image_from_url(url: str, max_dim: int = 1280) -> Optional[np.ndarray]: | |
| try: | |
| resp = http_requests.get(url, timeout=20) | |
| resp.raise_for_status() | |
| pil = Image.open(BytesIO(resp.content)).convert("RGB") | |
| if max(pil.size) > max_dim: | |
| pil.thumbnail((max_dim, max_dim), Image.LANCZOS) | |
| return cv2.cvtColor(np.array(pil), cv2.COLOR_RGB2BGR) | |
| except Exception: | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # Core logic | |
| # --------------------------------------------------------------------------- | |
| def extract_front_back( | |
| image_urls: List[str], detector: FrontBackDetector, detector_lock: Lock | |
| ) -> dict: | |
| front_candidates: list[tuple[float, float, int]] = [] | |
| back_candidates: list[tuple[float, float, int]] = [] | |
| urls_with_results: list[tuple[str, Optional[OrientationResult]]] = [] | |
| for idx, url in enumerate(image_urls): | |
| image = load_image_from_url(url) | |
| if image is None: | |
| urls_with_results.append((url, None)) | |
| continue | |
| # MediaPipe FaceLandmarker isn't guaranteed thread-safe. | |
| # Keep one shared instance for performance, guarded by a lock. | |
| with detector_lock: | |
| result = detector.detect(image) | |
| urls_with_results.append((url, result)) | |
| if result is None: | |
| continue | |
| if result.orientation == "FRONT": | |
| front_candidates.append((result.confidence, result.combined_score, idx)) | |
| elif result.orientation == "BACK": | |
| back_candidates.append((result.confidence, -result.combined_score, idx)) | |
| elif result.orientation == "SIDE": | |
| front_candidates.append( | |
| (result.confidence * 0.5, result.combined_score, idx) | |
| ) | |
| best_front_url, best_front_conf = None, 0.0 | |
| best_back_url, best_back_conf = None, 0.0 | |
| if front_candidates: | |
| front_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True) | |
| best_idx = front_candidates[0][2] | |
| best_front_url = urls_with_results[best_idx][0] | |
| best_front_conf = front_candidates[0][0] | |
| if back_candidates: | |
| back_candidates.sort(key=lambda x: (x[0], x[1]), reverse=True) | |
| best_idx = back_candidates[0][2] | |
| best_back_url = urls_with_results[best_idx][0] | |
| best_back_conf = back_candidates[0][0] | |
| return { | |
| "front_url": best_front_url, | |
| "front_confidence": round(best_front_conf, 4), | |
| "back_url": best_back_url, | |
| "back_confidence": round(best_back_conf, 4), | |
| } | |
| # --------------------------------------------------------------------------- | |
| # FastAPI | |
| # --------------------------------------------------------------------------- | |
| async def lifespan(app: FastAPI): | |
| app.state.detector = FrontBackDetector() | |
| app.state.detector_lock = Lock() | |
| try: | |
| yield | |
| finally: | |
| app.state.detector.close() | |
| app = FastAPI(title="Front/Back View API", lifespan=lifespan) | |
| class DetectResponse(BaseModel): | |
| front_url: Optional[str] = None | |
| front_confidence: float = 0.0 | |
| back_url: Optional[str] = None | |
| back_confidence: float = 0.0 | |
| class ClassifyResponse(BaseModel): | |
| is_front: int | |
| def root(): | |
| return {"status": "ok", "message": "Front/Back View Detection API"} | |
| def parse_image_urls(payload: Any) -> List[str]: | |
| """ | |
| Accepts multiple request shapes and normalizes them to List[str]. | |
| Supported examples: | |
| {"image_urls": ["..."]} | |
| {"imageUrls": ["..."]} | |
| {"urls": ["..."]} | |
| {"image_urls": "..."} | |
| {"image_urls": [{"url": "..."}, {"image_url": "..."}]} | |
| ["...", "..."] | |
| "..." | |
| """ | |
| if isinstance(payload, dict): | |
| for key in ("image_urls", "imageUrls", "urls", "images"): | |
| if key in payload: | |
| payload = payload[key] | |
| break | |
| else: | |
| # Allow single-url payloads at root level too: | |
| # {"url": "..."} / {"image_url": "..."} / {"imageUrl": "..."} | |
| direct = payload.get("url") or payload.get("image_url") or payload.get("imageUrl") | |
| if isinstance(direct, str) and direct.strip(): | |
| payload = [direct.strip()] | |
| else: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=( | |
| "Request body must contain one of: image_urls, imageUrls, urls, images, " | |
| "or a single url/image_url/imageUrl" | |
| ), | |
| ) | |
| if isinstance(payload, str): | |
| payload = [payload] | |
| if not isinstance(payload, list): | |
| raise HTTPException( | |
| status_code=400, | |
| detail="image_urls must be a URL string or a list of URL strings", | |
| ) | |
| normalized: List[str] = [] | |
| for item in payload: | |
| if isinstance(item, str): | |
| url = item.strip() | |
| if url: | |
| normalized.append(url) | |
| continue | |
| if isinstance(item, dict): | |
| candidate = item.get("url") or item.get("image_url") or item.get("imageUrl") | |
| if isinstance(candidate, str) and candidate.strip(): | |
| normalized.append(candidate.strip()) | |
| continue | |
| raise HTTPException( | |
| status_code=400, | |
| detail=( | |
| "Each image entry must be a URL string or an object containing " | |
| "'url'/'image_url'/'imageUrl'" | |
| ), | |
| ) | |
| if not normalized: | |
| raise HTTPException(status_code=400, detail="image_urls must not be empty") | |
| return normalized | |
| def parse_single_image_url(payload: Any) -> str: | |
| image_urls = parse_image_urls(payload) | |
| if len(image_urls) != 1: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="This endpoint expects exactly one image URL", | |
| ) | |
| return image_urls[0] | |
| def detect(request: Request, payload: Any = Body(...)): | |
| image_urls = parse_image_urls(payload) | |
| result = extract_front_back( | |
| image_urls, request.app.state.detector, request.app.state.detector_lock | |
| ) | |
| return DetectResponse(**result) | |
| def classify(request: Request, payload: Any = Body(...)): | |
| image_url = parse_single_image_url(payload) | |
| image = load_image_from_url(image_url) | |
| if image is None: | |
| raise HTTPException(status_code=400, detail="Unable to load image from URL") | |
| with request.app.state.detector_lock: | |
| result = request.app.state.detector.detect(image) | |
| is_front = 1 if result and result.orientation == "FRONT" else 0 | |
| return ClassifyResponse(is_front=is_front) | |