mil-ojos-api / main.py
manuTototDev's picture
Force ZIP download via hf_hub_download to fix LFS pointers
66ffd2a verified
Raw
History Blame Contribute Delete
13.6 kB
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
# ═══════════════════════════════════════════════════════════════════════════════
@app.get("/")
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}
@app.post("/search")
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
})
@app.get("/fichas")
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,
}
@app.get("/fichas/{ficha_id}")
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]
@app.get("/years")
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}