Locococ's picture
Update app.py
a13e7cd verified
Raw History Blame Contribute Delete
11.9 kB
"""
Face Recognition Backend API on Hugging Face Spaces (Gradio SDK + FastAPI).
Serves MTCNN + ArcFace vector inference with full CORS support for Vercel.
"""
import base64
import os
import secrets
import sys
import threading
import time
import traceback
from typing import Optional, List, Dict, Any
from fastapi import FastAPI, Request, Response, HTTPException, status
from fastapi.responses import JSONResponse, Response as RawResponse
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware
from pydantic import BaseModel
import gradio as gr
import pipeline as P
# --------------------------------------------------------------------------
# State & Sessions
# --------------------------------------------------------------------------
SESSION_TTL = 4 * 3600
SESSIONS = {}
_SESS_LOCK = threading.Lock()
_status = {"ready": False, "error": None, "seconds": None}
def _warm():
t0 = time.time()
try:
P.warmup()
_status["ready"] = True
except Exception as exc:
_status["error"] = f"{type(exc).__name__}: {exc}"
traceback.print_exc()
finally:
_status["seconds"] = round(time.time() - t0, 1)
print(f"[warmup] ready={_status['ready']} in {_status['seconds']}s")
threading.Thread(target=_warm, daemon=True).start()
def _get_session(sid: Optional[str]):
now = time.time()
with _SESS_LOCK:
for dead in [s for s, v in SESSIONS.items() if now - v["touched"] > SESSION_TTL]:
SESSIONS.pop(dead, None)
if not sid or sid not in SESSIONS:
sid = secrets.token_urlsafe(18)
SESSIONS[sid] = {"gallery": P.Gallery(), "dismissed": set(), "touched": now}
SESSIONS[sid]["touched"] = now
return sid, SESSIONS[sid]["gallery"], SESSIONS[sid]["dismissed"]
def _session_payload(gallery, dismissed):
return {
"people": gallery.summary(),
"total_embeddings": sum(len(v) for v in gallery.data.values()),
"duplicates": P.find_duplicates(gallery.data, dismissed),
}
# --------------------------------------------------------------------------
# FastAPI Application
# --------------------------------------------------------------------------
api_app = FastAPI(title="Face Recognition API", docs_url="/docs")
# CORS middleware for cross-origin Vercel requests
api_app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["Content-Disposition", "X-Session-Id"],
)
class SessionMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
sid_in = request.cookies.get("sid") or request.headers.get("X-Session-Id")
sid, gallery, dismissed = _get_session(sid_in)
request.state.sid = sid
request.state.gallery = gallery
request.state.dismissed = dismissed
response = await call_next(request)
# Attach session id to cookie & response header
response.set_cookie(
"sid", sid, httponly=True, samesite="none", secure=True, max_age=SESSION_TTL
)
response.headers["X-Session-Id"] = sid
return response
api_app.add_middleware(SessionMiddleware)
# --------------------------------------------------------------------------
# Pydantic Request Models
# --------------------------------------------------------------------------
class EnrollRequest(BaseModel):
person_id: str
images: List[str]
class RecognizeRequest(BaseModel):
image: str
class MergeRequest(BaseModel):
source: str
target: str
class DismissRequest(BaseModel):
a: str
b: str
# --------------------------------------------------------------------------
# REST API Endpoints
# --------------------------------------------------------------------------
@api_app.get("/api/status")
async def get_status(request: Request):
people_count = len(request.state.gallery.data) if hasattr(request.state, "gallery") else 0
return {**_status, "people": people_count}
@api_app.get("/api/session")
async def get_session_info(request: Request):
return _session_payload(request.state.gallery, request.state.dismissed)
@api_app.post("/api/session/reset")
async def reset_session(request: Request):
with _SESS_LOCK:
SESSIONS[request.state.sid]["gallery"] = P.Gallery()
SESSIONS[request.state.sid]["dismissed"] = set()
return {"ok": True}
@api_app.post("/api/enroll")
async def enroll(req: EnrollRequest, request: Request):
person_id = req.person_id.strip()
images = req.images
if not person_id:
raise HTTPException(status_code=400, detail="A name is required")
if not images:
raise HTTPException(status_code=400, detail="No images supplied")
if not _status["ready"]:
raise HTTPException(status_code=503, detail="Models are still loading, please wait")
results = []
added = 0
for idx, data_url in enumerate(images):
try:
bgr = P.decode_data_url(data_url)
faces = P.extract_faces(bgr)
if not faces:
results.append({"index": idx, "ok": False, "reason": "No face detected"})
continue
if len(faces) > 1:
results.append({
"index": idx, "ok": False,
"reason": f"{len(faces)} faces in frame - register one face at a time"
})
continue
face = faces[0]
request.state.gallery.add(person_id, P.embed(face["crop_bgr"]))
added += 1
results.append({
"index": idx,
"ok": True,
"confidence": round(face["confidence"], 4),
"crop": P.encode_jpeg_data_url(face["crop_bgr"], quality=75),
})
except Exception as exc:
results.append({"index": idx, "ok": False, "reason": str(exc)})
return {
"person_id": person_id,
"added": added,
"total": len(request.state.gallery.data.get(person_id, [])),
"cohesion": P.cohesion(request.state.gallery.data.get(person_id, [])),
"results": results,
}
@api_app.post("/api/recognize")
async def recognize(req: RecognizeRequest, request: Request):
data_url = req.image
if not data_url:
raise HTTPException(status_code=400, detail="Image is required")
if not _status["ready"]:
raise HTTPException(status_code=503, detail="Models are still loading")
if not request.state.gallery.data:
raise HTTPException(status_code=400, detail="Nobody registered yet")
t0 = time.time()
try:
bgr = P.decode_data_url(data_url)
h, w = bgr.shape[:2]
faces = P.extract_faces(bgr)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
out = []
for face in faces:
ranked = request.state.gallery.rank(P.embed(face["crop_bgr"]))
top = ranked[0] if ranked else None
runner = ranked[1] if len(ranked) > 1 else None
out.append({
"box": face["box"],
"confidence": round(face["confidence"], 4),
"crop": P.encode_jpeg_data_url(face["crop_bgr"], quality=70),
"ranked": [{"id": r["id"], "score": round(r["score"], 4)} for r in ranked],
"top": {"id": top["id"], "score": round(top["score"], 4)} if top else None,
"margin": round(top["score"] - runner["score"], 4) if runner else None,
})
return {
"faces": out,
"frame": {"width": w, "height": h},
"elapsed_ms": int((time.time() - t0) * 1000),
}
@api_app.delete("/api/person/{person_id}")
async def delete_person(person_id: str, request: Request):
ok = request.state.gallery.remove(person_id)
for pair in [p for p in request.state.dismissed if person_id in p]:
request.state.dismissed.discard(pair)
return {"ok": ok}
@api_app.post("/api/person/merge")
async def merge_people(req: MergeRequest, request: Request):
source, target = req.source.strip(), req.target.strip()
try:
request.state.gallery.merge(source, target)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
for pair in [p for p in request.state.dismissed if source in p]:
request.state.dismissed.discard(pair)
return {"ok": True, **_session_payload(request.state.gallery, request.state.dismissed)}
@api_app.post("/api/duplicates/dismiss")
async def dismiss_duplicate(req: DismissRequest, request: Request):
a, b = req.a.strip(), req.b.strip()
gal = request.state.gallery
if not a or not b or a == b or a not in gal.data or b not in gal.data:
raise HTTPException(status_code=400, detail="Pick two different registered people")
request.state.dismissed.add(frozenset((a, b)))
return {"ok": True, **_session_payload(request.state.gallery, request.state.dismissed)}
@api_app.get("/api/session/export.pkl")
async def export_pkl(request: Request):
if not request.state.gallery.data:
raise HTTPException(status_code=400, detail="Nothing to export")
data = request.state.gallery.to_pickle_bytes()
return RawResponse(
content=data,
media_type="application/octet-stream",
headers={"Content-Disposition": 'attachment; filename="student_embeddings.pkl"'}
)
@api_app.get("/api/session/export.json")
async def export_json(request: Request):
return {
"embeddings": request.state.gallery.to_json(),
"dismissed": [sorted(p) for p in request.state.dismissed],
}
@api_app.post("/api/session/import")
async def import_gallery(request: Request):
body = await request.json()
try:
if body.get("json") is not None:
gal = P.Gallery.from_json(body["json"])
elif body.get("pkl_b64"):
gal = P.Gallery.from_pickle_bytes(base64.b64decode(body["pkl_b64"]))
else:
raise HTTPException(status_code=400, detail="Send either 'json' or 'pkl_b64'")
except Exception as exc:
raise HTTPException(status_code=400, detail=f"Could not load: {exc}")
dismissed = set()
for pair in body.get("dismissed") or []:
if (isinstance(pair, (list, tuple)) and len(pair) == 2
and all(isinstance(x, str) and x in gal.data for x in pair)
and pair[0] != pair[1]):
dismissed.add(frozenset(pair))
with _SESS_LOCK:
SESSIONS[request.state.sid]["gallery"] = gal
SESSIONS[request.state.sid]["dismissed"] = dismissed
return {"ok": True, **_session_payload(gal, dismissed)}
# --------------------------------------------------------------------------
# Gradio Interface (Mounted at /)
# --------------------------------------------------------------------------
with gr.Blocks(title="Face Recognition Backend API") as demo:
gr.Markdown(
"""
# 🚀 Face Recognition Backend API
**Powered by MTCNN + ArcFace (Retrieval-Augmented Recognition)**
This Hugging Face Space hosts the ML inference API for the attendance application.
- **Status:** Running
- **Documentation:** Interactive OpenAPI docs available at [`/docs`](/docs)
- **Frontend:** Connected to your live Vercel web application
"""
)
with gr.Row():
btn = gr.Button("Check Model Readiness")
out = gr.JSON(label="Readiness Info")
btn.click(fn=lambda: {**_status, "status": "online"}, outputs=out)
app = gr.mount_gradio_app(api_app, demo, path="/")
if __name__ == "__main__":
print("[startup] Starting Gradio server", flush=True)
demo.launch(
server_name="0.0.0.0",
server_port=1008,
show_error=True
)