Explicite/api-test
0
1# app/services/face_service.py2from io import BytesIO3import base644import numpy as np5from PIL import Image6from fastapi import HTTPException, UploadFile7import math8 9from app.core.config import settings10from app.services.insightface_engine import (11 get_face_app, pick_best_face, to_bbox_dict, cosine_similarity, augment_rgb12)13from app.services.doc_classifier import classify_document14from app.services.face_quality import assess_quality15 16def ensure_image(upload: UploadFile, field_name: str) -> None:17 if not upload:18 raise HTTPException(status_code=400, detail=f"Falta el archivo: {field_name}")19 if not upload.content_type or not upload.content_type.startswith("image/"):20 raise HTTPException(status_code=400, detail=f"'{field_name}' debe ser una imagen.")21 22async def read_as_np_rgb(upload: UploadFile) -> np.ndarray:23 raw = await upload.read()24 if not raw:25 raise HTTPException(status_code=400, detail="La imagen está vacía.")26 try:27 pil = Image.open(BytesIO(raw)).convert("RGB")28 return np.array(pil) # RGB29 except Exception:30 raise HTTPException(status_code=400, detail="No pude leer la imagen. ¿Formato válido?")31 32def crop_face(np_rgb: np.ndarray, bbox: dict):33 pil_img = Image.fromarray(np_rgb) # RGB34 width, height = pil_img.size35 top, right, bottom, left = bbox["top"], bbox["right"], bbox["bottom"], bbox["left"]36 37 top = max(0, top); left = max(0, left)38 bottom = min(height, bottom); right = min(width, right)39 40 return pil_img.crop((left, top, right, bottom))41 42def img_to_base64_jpeg(pil_img: Image.Image, quality: int = 95):43 buf = BytesIO()44 pil_img.save(buf, format="JPEG", quality=quality)45 b64 = base64.b64encode(buf.getvalue()).decode("utf-8")46 return b64, f"data:image/jpeg;base64,{b64}"47 48def similarity_to_percent_sigmoid(sim: float, mid: float, k: float) -> float:49 """50 sim suele estar entre ~0.0 y ~1.0 (cosine similarity).51 NO es probabilidad. Esto es un mapeo UI-friendly con curva sigmoid.52 """53 sim = max(0.0, min(1.0, float(sim)))54 x = (sim - mid) * k55 y = 1.0 / (1.0 + math.exp(-x))56 return round(y * 100.0, 2)57 58async def compare_two_uploads(image1: UploadFile, image2: UploadFile):59 ensure_image(image1, "image1")60 ensure_image(image2, "image2")61 62 np1 = await read_as_np_rgb(image1)63 np2 = await read_as_np_rgb(image2)64 65 # Clasificación documento (solo añade info)66 doc1 = classify_document(np1)67 68 face_app = get_face_app()69 70 # Detectar caras base (sin augment)71 faces1 = face_app.get(np1)72 faces2 = face_app.get(np2)73 f1 = pick_best_face(faces1)74 f2 = pick_best_face(faces2)75 76 if f1 is None or f2 is None:77 raise HTTPException(status_code=422, detail="No se detectó rostro en una de las imágenes.")78 79 # BBoxes80 bbox1 = to_bbox_dict(f1)81 bbox2_xyxy = f2.bbox # para quality82 bbox1_xyxy = f1.bbox83 84 # Recorte para UI (img1)85 face1_crop = crop_face(np1, bbox1)86 _, face1_data_uri = img_to_base64_jpeg(face1_crop)87 88 # Quality gate (ambas)89 q1 = assess_quality(90 np1, bbox1_xyxy,91 min_face_area_ratio=settings.min_face_area_ratio,92 blur_var_min=settings.blur_var_min,93 brightness_min=settings.brightness_min,94 brightness_max=settings.brightness_max,95 )96 q2 = assess_quality(97 np2, bbox2_xyxy,98 min_face_area_ratio=settings.min_face_area_ratio,99 blur_var_min=settings.blur_var_min,100 brightness_min=settings.brightness_min,101 brightness_max=settings.brightness_max,102 )103 104 # Embeddings base105 emb1 = f1.normed_embedding106 emb2 = f2.normed_embedding107 base_sim = cosine_similarity(emb1, emb2)108 109 # Multi-sample similarity (jitter)110 sims = [base_sim]111 if settings.multi_samples and settings.multi_samples > 1:112 # Nota: re-ejecuta detector+embedding sobre variantes: más costo pero mejora robustez113 for i in range(settings.multi_samples - 1):114 np1_aug = augment_rgb(np1, seed=1000 + i)115 np2_aug = augment_rgb(np2, seed=2000 + i)116 117 fa1 = pick_best_face(face_app.get(np1_aug))118 fa2 = pick_best_face(face_app.get(np2_aug))119 if fa1 is None or fa2 is None:120 continue121 122 s = cosine_similarity(fa1.normed_embedding, fa2.normed_embedding)123 sims.append(s)124 125 # Score final126 raw_sim = max(sims) if settings.use_best_of_samples else float(np.median(np.array(sims)))127 128 # Porcentaje UI-friendly (sigmoid)129 similarity_percent = similarity_to_percent_sigmoid(raw_sim, settings.score_mid, settings.score_k)130 131 # Decision132 threshold = settings.sim_threshold133 if not (q1.ok and q2.ok):134 decision = "low_quality"135 is_match = False136 else:137 is_match = raw_sim >= threshold138 decision = "match" if is_match else "no_match"139 140 return {141 "tolerance": float(threshold),142 "distance": round(1.0 - float(raw_sim), 6),143 "is_match": bool(is_match),144 "similarity_percent": float(similarity_percent),145 "raw_similarity": round(float(raw_sim), 6),146 "decision": decision,147 "samples_used": int(len(sims)),148 "samples": [round(float(x), 6) for x in sims],149 150 "image1_bbox": bbox1,151 "image1_face_data_uri": face1_data_uri,152 153 "document1": doc1,154 155 "quality1": {156 "ok": q1.ok,157 "blur_var": round(q1.blur_var, 2),158 "brightness_mean": round(q1.brightness_mean, 2),159 "face_area_ratio": round(q1.face_area_ratio, 6),160 "signals": q1.signals,161 },162 "quality2": {163 "ok": q2.ok,164 "blur_var": round(q2.blur_var, 2),165 "brightness_mean": round(q2.brightness_mean, 2),166 "face_area_ratio": round(q2.face_area_ratio, 6),167 "signals": q2.signals,168 },169 }170 