Spaces:
Running
Running
| """ | |
| FastAPI backend for Face Analysis HF Space. | |
| Provides: | |
| - Static file serving for the React SPA (from /app/dist) | |
| - /api/norms — returns demographic ideal ranges (from HF Dataset or local fallback) | |
| - /api/upload — stores analysis result to HF Storage Bucket | |
| - /api/percentile — returns percentile rank from stored distribution | |
| """ | |
| import json | |
| import os | |
| import uuid | |
| from pathlib import Path | |
| from typing import Optional | |
| from fastapi import FastAPI, HTTPException | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from huggingface_hub import HfApi, create_commit, upload_file | |
| from pydantic import BaseModel | |
| app = FastAPI(title="Face Analysis API") | |
| HF_TOKEN = os.environ.get("HF_TOKEN", "") | |
| SPACE_ID = os.environ.get("SPACE_ID", "asterioskryos/face-analysis") | |
| API = HfApi(token=HF_TOKEN) if HF_TOKEN else None | |
| # --------------------------------------------------------------------------- | |
| # Norms: loaded from HF Dataset or local fallback | |
| # --------------------------------------------------------------------------- | |
| NORMS_PATH = Path("/app/server/data/norms.json") | |
| NORMS: dict = {} | |
| def _load_norms(): | |
| global NORMS | |
| if NORMS_PATH.exists(): | |
| NORMS = json.loads(NORMS_PATH.read_text()) | |
| return | |
| # Fallback: bundled defaults | |
| NORMS = { | |
| "caucasian": { | |
| "male": {}, | |
| "female": {}, | |
| }, | |
| "african": { | |
| "male": { | |
| "nose.alarWidth": {"ideal_adj": 0.03, "mult_adj": 0.9}, | |
| "nose.nasalTipRotation": {"ideal_adj": -5, "mult_adj": 1.0}, | |
| "lips.upperToLowerRatio": {"ideal_adj": 0.05, "mult_adj": 0.9}, | |
| }, | |
| "female": { | |
| "nose.alarWidth": {"ideal_adj": 0.03, "mult_adj": 0.9}, | |
| "nose.nasalTipRotation": {"ideal_adj": -3, "mult_adj": 1.0}, | |
| }, | |
| }, | |
| "east-asian": { | |
| "male": { | |
| "eyes.eyeAspectRatio": {"ideal_adj": 0.02, "mult_adj": 0.95}, | |
| "nose.nasofrontalAngle": {"ideal_adj": 5, "mult_adj": 1.0}, | |
| "profile.facialConvexity": {"ideal_adj": 4, "mult_adj": 1.0}, | |
| }, | |
| "female": { | |
| "eyes.eyeAspectRatio": {"ideal_adj": 0.03, "mult_adj": 0.95}, | |
| "nose.nasofrontalAngle": {"ideal_adj": 5, "mult_adj": 1.0}, | |
| "profile.facialConvexity": {"ideal_adj": 4, "mult_adj": 1.0}, | |
| }, | |
| }, | |
| } | |
| _load_norms() | |
| class NormsRequest(BaseModel): | |
| ethnicity: str = "caucasian" | |
| sex: str = "male" | |
| ageGroup: str = "20-30" | |
| async def get_norms(req: NormsRequest): | |
| """Return per-metric ideal adjustments for a given demographic profile.""" | |
| ethnic = NORMS.get(req.ethnicity, {}) | |
| sex_table = ethnic.get(req.sex, {}) | |
| return JSONResponse(content=sex_table) | |
| # --------------------------------------------------------------------------- | |
| # Upload to HF Storage Bucket | |
| # --------------------------------------------------------------------------- | |
| class UploadRequest(BaseModel): | |
| image_data: Optional[str] = None # base64 encoded | |
| scores: dict = {} | |
| async def upload_result(req: UploadRequest): | |
| """Store an analysis result to HF Storage Bucket for percentile tracking.""" | |
| if not API: | |
| raise HTTPException(503, "HF API not configured (no token)") | |
| result_id = str(uuid.uuid4())[:8] | |
| result_path = f"results/{result_id}.json" | |
| payload = { | |
| "id": result_id, | |
| "scores": req.scores, | |
| "demographics": { | |
| "ethnicity": "unknown", | |
| "sex": "unknown", | |
| "ageGroup": "unknown", | |
| }, | |
| } | |
| try: | |
| API.upload_file( | |
| path_or_fileobj=json.dumps(payload).encode(), | |
| path_in_repo=result_path, | |
| repo_id=SPACE_ID, | |
| repo_type="space", | |
| ) | |
| return {"status": "ok", "id": result_id, "path": result_path} | |
| except Exception as e: | |
| raise HTTPException(500, f"Upload failed: {e}") | |
| # --------------------------------------------------------------------------- | |
| # Percentile (from stored results or simulated) | |
| # --------------------------------------------------------------------------- | |
| async def get_percentile(score: float = 5.0): | |
| """Return approximate percentile rank for a given overall score.""" | |
| # In production, query stored results for real percentile. | |
| # For now, approximate using a normal distribution centered on 5.5 with SD 1.5. | |
| import math | |
| z = (score - 5.5) / 1.5 | |
| percentile = round(0.5 * (1 + math.erf(z / math.sqrt(2))) * 100, 1) | |
| return {"score": score, "percentile": percentile, "note": "Approximate (population model)"} | |
| # --------------------------------------------------------------------------- | |
| # Health / status | |
| # --------------------------------------------------------------------------- | |
| async def health(): | |
| return {"status": "ok", "api": "v1"} | |
| # --------------------------------------------------------------------------- | |
| # Static file serving for React SPA (Vite build output) | |
| # --------------------------------------------------------------------------- | |
| if Path("/app/dist").exists(): | |
| app.mount("/", StaticFiles(directory="/app/dist", html=True), name="dist") | |
| else: | |
| async def root(): | |
| return {"error": "Frontend not built. Run `npm run build` first."} | |
| # --------------------------------------------------------------------------- | |
| # Entrypoint | |
| # --------------------------------------------------------------------------- | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=7860) | |