Spaces:
Sleeping
Sleeping
| import os | |
| import io | |
| import pickle | |
| import numpy as np | |
| from fastapi import FastAPI, File, UploadFile, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.staticfiles import StaticFiles | |
| from fastapi.responses import JSONResponse | |
| from PIL import Image | |
| import zipfile | |
| import cv2 | |
| from insightface.app import FaceAnalysis | |
| # ββ Rutas βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| BASE_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| DATA_DIR = os.environ.get("DATA_DIR", BASE_DIR) | |
| DB_FILE = os.path.join(DATA_DIR, "face_database.pkl") if os.path.exists(os.path.join(os.environ.get("DATA_DIR", ""), "face_database.pkl")) else os.path.join(BASE_DIR, "face_database.pkl") | |
| STATIC_DIR = os.path.join(DATA_DIR, "static") if os.path.isdir(os.path.join(os.environ.get("DATA_DIR", ""), "static")) else os.path.join(BASE_DIR, "static") | |
| # ββ ExtracciΓ³n de ZIPs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def setup_static_from_zips(): | |
| import zipfile | |
| from huggingface_hub import hf_hub_download | |
| os.makedirs(STATIC_DIR, exist_ok=True) | |
| fotos_dir = os.path.join(STATIC_DIR, "fotos_recortadas") | |
| boletines_dir = os.path.join(STATIC_DIR, "boletines_webp") | |
| SPACE_ID = "manuTototDev/mil-ojos-api" | |
| if not os.path.isdir(fotos_dir): | |
| print("Descargando fotos.zip...") | |
| local_fotos_zip = hf_hub_download(repo_id=SPACE_ID, filename="fotos.zip", repo_type="space") | |
| print(f"Descomprimiendo fotos...") | |
| with zipfile.ZipFile(local_fotos_zip, 'r') as zip_ref: | |
| zip_ref.extractall(fotos_dir) | |
| if not os.path.isdir(boletines_dir): | |
| print("Descargando boletines.zip...") | |
| local_bols_zip = hf_hub_download(repo_id=SPACE_ID, filename="boletines.zip", repo_type="space") | |
| print(f"Descomprimiendo boletines...") | |
| with zipfile.ZipFile(local_bols_zip, 'r') as zip_ref: | |
| zip_ref.extractall(boletines_dir) | |
| # Ejecutar antes de evaluar USE_LOCAL_STATIC | |
| setup_static_from_zips() | |
| STATIC_DIR = os.path.join(DATA_DIR, "static") if os.path.isdir(os.path.join(os.environ.get("DATA_DIR", ""), "static")) else os.path.join(BASE_DIR, "static") | |
| # CDN base URL for images (served from HF Dataset β no file limit) | |
| HF_CDN_BASE = "https://huggingface.co/datasets/manuTototDev/mil-ojos-images/resolve/main" | |
| USE_LOCAL_STATIC = os.path.isdir(STATIC_DIR) and len(os.listdir(os.path.join(STATIC_DIR, "fotos_recortadas", ""))) > 10 if os.path.isdir(os.path.join(STATIC_DIR, "fotos_recortadas")) else False | |
| # ββ FastAPI βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| app = FastAPI(title="Mil Ojos API", version="1.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # ββ Cargar DB de embeddings al arrancar βββββββββββββββββββββββββββββββββββββββ | |
| print("Cargando base de datos de rostros...") | |
| with open(DB_FILE, "rb") as f: | |
| raw_db = pickle.load(f) | |
| # Construir Γndice limpio | |
| database = [] | |
| db_embeddings = [] | |
| for i, entry in enumerate(raw_db): | |
| raw_name = entry["name"] # ej. "AARON ADALID ESCOBEDO CONDE.jpg" | |
| name = os.path.splitext(raw_name)[0] # quitar .jpg | |
| year = str(entry["year"]) | |
| # Boletin: use WebP version (much smaller), foto: keep JPG (already tiny) | |
| bol_base = os.path.splitext(raw_name)[0] + ".webp" | |
| # URLs: local /static/ when images on disk, HF CDN otherwise | |
| if USE_LOCAL_STATIC: | |
| foto_url = f"/static/fotos_recortadas/{year}_foto_{raw_name}" | |
| bol_url = f"/static/boletines_webp/{year}_{bol_base}" | |
| else: | |
| foto_url = f"{HF_CDN_BASE}/fotos_recortadas/{year}_foto_{raw_name}" | |
| bol_url = f"{HF_CDN_BASE}/boletines_webp/{year}_{bol_base}" | |
| database.append({ | |
| "id": i, | |
| "name": name, | |
| "year": year, | |
| "foto": foto_url, | |
| "boletin": bol_url, | |
| }) | |
| db_embeddings.append(entry["embedding"]) | |
| db_matrix = np.array(db_embeddings, dtype=np.float32) | |
| print(f"Base de datos lista: {len(database)} personas.") | |
| # ββ Marcar entradas con imagen disponible βββββββββββββββββββββββββββββββββββββ | |
| if USE_LOCAL_STATIC: | |
| # Local: check disk | |
| fotos_dir = os.path.join(STATIC_DIR, "fotos_recortadas") | |
| available_fotos = set(os.listdir(fotos_dir)) if os.path.isdir(fotos_dir) else set() | |
| else: | |
| # CDN: query HF Dataset file list at startup | |
| print("Consultando archivos disponibles en HF Dataset...") | |
| try: | |
| from huggingface_hub import HfApi | |
| _api = HfApi() | |
| _all_files = _api.list_repo_files("manuTototDev/mil-ojos-images", repo_type="dataset") | |
| available_fotos = {os.path.basename(f) for f in _all_files if f.startswith("fotos_recortadas/")} | |
| print(f" Fotos disponibles en CDN: {len(available_fotos)}") | |
| except Exception as e: | |
| print(f" Error consultando HF: {e} β asumiendo todas disponibles") | |
| available_fotos = None # None = no filter | |
| count_available = 0 | |
| for entry in database: | |
| year = entry["year"] | |
| # Reconstruct filename to check | |
| foto_filename = entry["foto"].split("/")[-1] # e.g. 2024_foto_NAME.jpg | |
| if available_fotos is None: | |
| entry["has_foto"] = True | |
| else: | |
| entry["has_foto"] = foto_filename in available_fotos | |
| if entry["has_foto"]: | |
| count_available += 1 | |
| print(f"Personas con foto disponible: {count_available}/{len(database)}") | |
| # ββ InsightFace βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| print("Cargando modelo InsightFace...") | |
| face_app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"]) | |
| face_app.prepare(ctx_id=0, det_size=(640, 640)) | |
| print("Modelo listo.") | |
| # ββ CachΓ© de face bboxes para las fotos de la DB βββββββββββββββββββββββββββββ | |
| # Se llena lazy: cada foto se detecta una sola vez y se guarda el bbox | |
| face_bbox_cache: dict[int, dict | None] = {} | |
| def detect_face_in_photo(entry_id: int) -> dict | None: | |
| """Detecta el rostro en la foto de una persona y retorna bbox + lm106 normalizados.""" | |
| if entry_id in face_bbox_cache: | |
| return face_bbox_cache[entry_id] | |
| # If images aren't on disk, return a default centered bbox (no landmarks) | |
| if not USE_LOCAL_STATIC: | |
| face_bbox_cache[entry_id] = {"x": 0.1, "y": 0.05, "w": 0.8, "h": 0.85, "lm106": []} | |
| return face_bbox_cache[entry_id] | |
| entry = database[entry_id] | |
| foto_rel = entry["foto"] # e.g. /static/fotos_recortadas/2024_foto_NAME.jpg | |
| foto_path = os.path.join(BASE_DIR, foto_rel.lstrip("/")) | |
| if not os.path.exists(foto_path): | |
| face_bbox_cache[entry_id] = None | |
| return None | |
| try: | |
| img = cv2.imread(foto_path) | |
| if img is None: | |
| face_bbox_cache[entry_id] = None | |
| return None | |
| h, w = img.shape[:2] | |
| faces = face_app.get(img) | |
| if not faces: | |
| face_bbox_cache[entry_id] = {"x": 0.1, "y": 0.05, "w": 0.8, "h": 0.85, "lm106": []} | |
| return face_bbox_cache[entry_id] | |
| face = max(faces, key=lambda f: (f.bbox[2]-f.bbox[0]) * (f.bbox[3]-f.bbox[1])) | |
| bbox = face.bbox.astype(float) | |
| # Extract 106 landmarks if available | |
| lm106 = [] | |
| if hasattr(face, 'landmark_2d_106') and face.landmark_2d_106 is not None: | |
| for pt in face.landmark_2d_106: | |
| lm106.append({"x": float(pt[0] / w), "y": float(pt[1] / h)}) | |
| result = { | |
| "x": float(max(0, bbox[0]) / w), | |
| "y": float(max(0, bbox[1]) / h), | |
| "w": float(min(bbox[2] - bbox[0], w) / w), | |
| "h": float(min(bbox[3] - bbox[1], h) / h), | |
| "lm106": lm106, | |
| } | |
| face_bbox_cache[entry_id] = result | |
| return result | |
| except Exception: | |
| face_bbox_cache[entry_id] = None | |
| return None | |
| # ββ ImΓ‘genes estΓ‘ticas (solo si estΓ‘n en disco) ββββββββββββββββββββββββββββββ | |
| if USE_LOCAL_STATIC: | |
| app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") | |
| print(f"Sirviendo imΓ‘genes locales desde {STATIC_DIR}") | |
| else: | |
| print(f"ImΓ‘genes servidas desde CDN: {HF_CDN_BASE}") | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ENDPOINTS | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def health(): | |
| import traceback | |
| debug_info = { | |
| "BASE_DIR": BASE_DIR, | |
| "DATA_DIR": DATA_DIR, | |
| "STATIC_DIR": STATIC_DIR, | |
| "base_exists": os.path.exists(BASE_DIR), | |
| "fotos_zip_exists": os.path.exists(os.path.join(BASE_DIR, "fotos.zip")), | |
| "base_files": os.listdir(BASE_DIR)[:5], | |
| "use_local_static": USE_LOCAL_STATIC | |
| } | |
| fotos_dir = os.path.join(STATIC_DIR, "fotos_recortadas") | |
| debug_info["fotos_dir_exists"] = os.path.isdir(fotos_dir) | |
| if os.path.isdir(fotos_dir): | |
| debug_info["fotos_count"] = len(os.listdir(fotos_dir)) | |
| available = sum(1 for e in database if e.get("has_foto")) | |
| return {"status": "ok", "personas": len(database), "con_foto": available, "debug_info": debug_info} | |
| async def search_face(file: UploadFile = File(...)): | |
| """ | |
| Recibe una imagen (selfie), detecta el rostro, | |
| y devuelve las 12 personas mΓ‘s parecidas de la DB. | |
| """ | |
| contents = await file.read() | |
| nparr = np.frombuffer(contents, np.uint8) | |
| img_cv = cv2.imdecode(nparr, cv2.IMREAD_COLOR) | |
| if img_cv is None: | |
| raise HTTPException(400, "No se pudo decodificar la imagen") | |
| faces = face_app.get(img_cv) | |
| if not faces: | |
| raise HTTPException(422, "No se detectΓ³ ningΓΊn rostro en la imagen") | |
| # El rostro principal = el mΓ‘s grande | |
| main_face = max(faces, key=lambda f: (f.bbox[2]-f.bbox[0]) * (f.bbox[3]-f.bbox[1])) | |
| query_emb = main_face.normed_embedding.astype(np.float32) | |
| # Similitud coseno (embeddings ya normalizados) | |
| similarities = np.dot(db_matrix, query_emb) | |
| # Get more candidates than needed, then filter by available images | |
| top_idx = np.argsort(similarities)[-30:][::-1] | |
| results = [] | |
| for idx in top_idx: | |
| if len(results) >= 8: | |
| break | |
| entry = database[idx] | |
| if not entry.get("has_foto", True): | |
| continue # Skip entries without uploaded images | |
| result = entry.copy() | |
| result["score"] = float(round(similarities[idx] * 100, 1)) | |
| result["match_face_box"] = detect_face_in_photo(idx) | |
| results.append(result) | |
| # Atributos del visitante | |
| gender = "femenino" if main_face.sex == 0 else "masculino" | |
| age = int(main_face.age) | |
| # BBox normalizado (0-1) | |
| h, w = img_cv.shape[:2] | |
| bbox = main_face.bbox.astype(float) | |
| face_box = { | |
| "x": float(bbox[0] / w), | |
| "y": float(bbox[1] / h), | |
| "w": float((bbox[2] - bbox[0]) / w), | |
| "h": float((bbox[3] - bbox[1]) / h), | |
| } | |
| # 106 landmarks normalizados (0-1) del modelo 2d106det incluido en buffalo_l | |
| lm106 = [] | |
| if hasattr(main_face, 'landmark_2d_106') and main_face.landmark_2d_106 is not None: | |
| for pt in main_face.landmark_2d_106: | |
| lm106.append({"x": float(pt[0] / w), "y": float(pt[1] / h)}) | |
| return JSONResponse({ | |
| "visitor": {"gender": gender, "age": age}, | |
| "face_box": face_box, | |
| "lm106": lm106, | |
| "results": results | |
| }) | |
| def list_fichas(page: int = 1, limit: int = 48, year: str = None, q: str = None): | |
| """Lista paginada de todas las fichas, con filtro opcional por aΓ±o y nombre.""" | |
| filtered = database | |
| if year: | |
| filtered = [p for p in filtered if p["year"] == year] | |
| if q: | |
| q_lower = q.lower() | |
| filtered = [p for p in filtered if q_lower in p["name"].lower()] | |
| total = len(filtered) | |
| start = (page - 1) * limit | |
| end = start + limit | |
| items = filtered[start:end] | |
| return { | |
| "total": total, | |
| "page": page, | |
| "pages": (total + limit - 1) // limit, | |
| "items": items, | |
| } | |
| def get_ficha(ficha_id: int): | |
| """Retorna los datos de una ficha por su ID numΓ©rico.""" | |
| if ficha_id < 0 or ficha_id >= len(database): | |
| raise HTTPException(404, "Ficha no encontrada") | |
| return database[ficha_id] | |
| def get_years(): | |
| """Retorna los aΓ±os disponibles en la base de datos.""" | |
| years = sorted(set(p["year"] for p in database)) | |
| return {"years": years} | |