asterioskryos commited on
Commit
aa24466
·
verified ·
1 Parent(s): fc7b43a

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. server/app.py +163 -0
  2. server/data/norms.json +76 -0
  3. server/requirements.txt +5 -0
server/app.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI backend for Face Analysis HF Space.
3
+
4
+ Provides:
5
+ - Static file serving for the React SPA (from /app/dist)
6
+ - /api/norms — returns demographic ideal ranges (from HF Dataset or local fallback)
7
+ - /api/upload — stores analysis result to HF Storage Bucket
8
+ - /api/percentile — returns percentile rank from stored distribution
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import uuid
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from fastapi import FastAPI, HTTPException
18
+ from fastapi.responses import FileResponse, JSONResponse
19
+ from fastapi.staticfiles import StaticFiles
20
+ from huggingface_hub import HfApi, create_commit, upload_file
21
+ from pydantic import BaseModel
22
+
23
+ app = FastAPI(title="Face Analysis API")
24
+
25
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
26
+ SPACE_ID = os.environ.get("SPACE_ID", "asterioskryos/face-analysis")
27
+ API = HfApi(token=HF_TOKEN) if HF_TOKEN else None
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Norms: loaded from HF Dataset or local fallback
31
+ # ---------------------------------------------------------------------------
32
+
33
+ NORMS_PATH = Path("/app/server/data/norms.json")
34
+ NORMS: dict = {}
35
+
36
+ def _load_norms():
37
+ global NORMS
38
+ if NORMS_PATH.exists():
39
+ NORMS = json.loads(NORMS_PATH.read_text())
40
+ return
41
+ # Fallback: bundled defaults
42
+ NORMS = {
43
+ "caucasian": {
44
+ "male": {},
45
+ "female": {},
46
+ },
47
+ "african": {
48
+ "male": {
49
+ "nose.alarWidth": {"ideal_adj": 0.03, "mult_adj": 0.9},
50
+ "nose.nasalTipRotation": {"ideal_adj": -5, "mult_adj": 1.0},
51
+ "lips.upperToLowerRatio": {"ideal_adj": 0.05, "mult_adj": 0.9},
52
+ },
53
+ "female": {
54
+ "nose.alarWidth": {"ideal_adj": 0.03, "mult_adj": 0.9},
55
+ "nose.nasalTipRotation": {"ideal_adj": -3, "mult_adj": 1.0},
56
+ },
57
+ },
58
+ "east-asian": {
59
+ "male": {
60
+ "eyes.eyeAspectRatio": {"ideal_adj": 0.02, "mult_adj": 0.95},
61
+ "nose.nasofrontalAngle": {"ideal_adj": 5, "mult_adj": 1.0},
62
+ "profile.facialConvexity": {"ideal_adj": 4, "mult_adj": 1.0},
63
+ },
64
+ "female": {
65
+ "eyes.eyeAspectRatio": {"ideal_adj": 0.03, "mult_adj": 0.95},
66
+ "nose.nasofrontalAngle": {"ideal_adj": 5, "mult_adj": 1.0},
67
+ "profile.facialConvexity": {"ideal_adj": 4, "mult_adj": 1.0},
68
+ },
69
+ },
70
+ }
71
+
72
+ _load_norms()
73
+
74
+ class NormsRequest(BaseModel):
75
+ ethnicity: str = "caucasian"
76
+ sex: str = "male"
77
+ ageGroup: str = "20-30"
78
+
79
+ @app.post("/api/norms")
80
+ async def get_norms(req: NormsRequest):
81
+ """Return per-metric ideal adjustments for a given demographic profile."""
82
+ ethnic = NORMS.get(req.ethnicity, {})
83
+ sex_table = ethnic.get(req.sex, {})
84
+ return JSONResponse(content=sex_table)
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # Upload to HF Storage Bucket
88
+ # ---------------------------------------------------------------------------
89
+
90
+ class UploadRequest(BaseModel):
91
+ image_data: Optional[str] = None # base64 encoded
92
+ scores: dict = {}
93
+
94
+ @app.post("/api/upload")
95
+ async def upload_result(req: UploadRequest):
96
+ """Store an analysis result to HF Storage Bucket for percentile tracking."""
97
+ if not API:
98
+ raise HTTPException(503, "HF API not configured (no token)")
99
+
100
+ result_id = str(uuid.uuid4())[:8]
101
+ result_path = f"results/{result_id}.json"
102
+
103
+ payload = {
104
+ "id": result_id,
105
+ "scores": req.scores,
106
+ "demographics": {
107
+ "ethnicity": "unknown",
108
+ "sex": "unknown",
109
+ "ageGroup": "unknown",
110
+ },
111
+ }
112
+
113
+ try:
114
+ API.upload_file(
115
+ path_or_fileobj=json.dumps(payload).encode(),
116
+ path_in_repo=result_path,
117
+ repo_id=SPACE_ID,
118
+ repo_type="space",
119
+ )
120
+ return {"status": "ok", "id": result_id, "path": result_path}
121
+ except Exception as e:
122
+ raise HTTPException(500, f"Upload failed: {e}")
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Percentile (from stored results or simulated)
126
+ # ---------------------------------------------------------------------------
127
+
128
+ @app.get("/api/percentile")
129
+ async def get_percentile(score: float = 5.0):
130
+ """Return approximate percentile rank for a given overall score."""
131
+ # In production, query stored results for real percentile.
132
+ # For now, approximate using a normal distribution centered on 5.5 with SD 1.5.
133
+ import math
134
+ z = (score - 5.5) / 1.5
135
+ percentile = round(0.5 * (1 + math.erf(z / math.sqrt(2))) * 100, 1)
136
+ return {"score": score, "percentile": percentile, "note": "Approximate (population model)"}
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Health / status
140
+ # ---------------------------------------------------------------------------
141
+
142
+ @app.get("/api/health")
143
+ async def health():
144
+ return {"status": "ok", "api": "v1"}
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Static file serving for React SPA (Vite build output)
148
+ # ---------------------------------------------------------------------------
149
+
150
+ if Path("/app/dist").exists():
151
+ app.mount("/", StaticFiles(directory="/app/dist", html=True), name="dist")
152
+ else:
153
+ @app.get("/")
154
+ async def root():
155
+ return {"error": "Frontend not built. Run `npm run build` first."}
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Entrypoint
159
+ # ---------------------------------------------------------------------------
160
+
161
+ if __name__ == "__main__":
162
+ import uvicorn
163
+ uvicorn.run(app, host="0.0.0.0", port=7860)
server/data/norms.json ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "caucasian": {"male": {}, "female": {}},
3
+ "african": {
4
+ "male": {
5
+ "nose.alarWidth": {"ideal_adj": 0.03, "mult_adj": 0.9},
6
+ "nose.nasalTipRotation": {"ideal_adj": -5, "mult_adj": 1.0},
7
+ "lips.upperToLowerRatio": {"ideal_adj": 0.05, "mult_adj": 0.9},
8
+ "lips.rickettsELine": {"ideal_adj": -0.03, "mult_adj": 1.0},
9
+ "profile.facialConvexity": {"ideal_adj": 3, "mult_adj": 1.0}
10
+ },
11
+ "female": {
12
+ "nose.alarWidth": {"ideal_adj": 0.03, "mult_adj": 0.9},
13
+ "nose.nasalTipRotation": {"ideal_adj": -3, "mult_adj": 1.0},
14
+ "lips.upperToLowerRatio": {"ideal_adj": 0.05, "mult_adj": 0.9},
15
+ "lips.rickettsELine": {"ideal_adj": -0.03, "mult_adj": 1.0},
16
+ "profile.facialConvexity": {"ideal_adj": 3, "mult_adj": 1.0}
17
+ }
18
+ },
19
+ "east-asian": {
20
+ "male": {
21
+ "eyes.eyeAspectRatio": {"ideal_adj": 0.02, "mult_adj": 0.95},
22
+ "eyes.midfaceRatio": {"ideal_adj": 0.02, "mult_adj": 1.0},
23
+ "nose.alarWidth": {"ideal_adj": 0.02, "mult_adj": 1.0},
24
+ "nose.nasalTipRotation": {"ideal_adj": -4, "mult_adj": 1.0},
25
+ "nose.nasofrontalAngle": {"ideal_adj": 5, "mult_adj": 1.0},
26
+ "lips.rickettsELine": {"ideal_adj": -0.02, "mult_adj": 1.0},
27
+ "profile.facialConvexity": {"ideal_adj": 4, "mult_adj": 1.0}
28
+ },
29
+ "female": {
30
+ "eyes.eyeAspectRatio": {"ideal_adj": 0.03, "mult_adj": 0.95},
31
+ "eyes.midfaceRatio": {"ideal_adj": 0.02, "mult_adj": 1.0},
32
+ "nose.alarWidth": {"ideal_adj": 0.02, "mult_adj": 1.0},
33
+ "nose.nasalTipRotation": {"ideal_adj": -2, "mult_adj": 1.0},
34
+ "nose.nasofrontalAngle": {"ideal_adj": 5, "mult_adj": 1.0},
35
+ "lips.rickettsELine": {"ideal_adj": -0.02, "mult_adj": 1.0},
36
+ "profile.facialConvexity": {"ideal_adj": 4, "mult_adj": 1.0}
37
+ }
38
+ },
39
+ "south-asian": {
40
+ "male": {
41
+ "nose.alarWidth": {"ideal_adj": 0.015, "mult_adj": 1.0},
42
+ "nose.nasofrontalAngle": {"ideal_adj": 3, "mult_adj": 1.0},
43
+ "lips.rickettsELine": {"ideal_adj": -0.02, "mult_adj": 1.0},
44
+ "profile.facialConvexity": {"ideal_adj": 2, "mult_adj": 1.0}
45
+ },
46
+ "female": {
47
+ "nose.alarWidth": {"ideal_adj": 0.015, "mult_adj": 1.0},
48
+ "nose.nasofrontalAngle": {"ideal_adj": 3, "mult_adj": 1.0},
49
+ "profile.facialConvexity": {"ideal_adj": 2, "mult_adj": 1.0}
50
+ }
51
+ },
52
+ "arabian": {
53
+ "male": {
54
+ "nose.nasalLength": {"ideal_adj": 0.015, "mult_adj": 1.0},
55
+ "nose.nasofrontalAngle": {"ideal_adj": -3, "mult_adj": 1.0},
56
+ "profile.facialConvexity": {"ideal_adj": 2, "mult_adj": 1.0}
57
+ },
58
+ "female": {
59
+ "nose.nasalLength": {"ideal_adj": 0.015, "mult_adj": 1.0},
60
+ "nose.nasofrontalAngle": {"ideal_adj": -3, "mult_adj": 1.0},
61
+ "profile.facialConvexity": {"ideal_adj": 2, "mult_adj": 1.0}
62
+ }
63
+ },
64
+ "hispanic": {
65
+ "male": {
66
+ "nose.alarWidth": {"ideal_adj": 0.01, "mult_adj": 1.0},
67
+ "nose.nasofrontalAngle": {"ideal_adj": 2, "mult_adj": 1.0},
68
+ "profile.facialConvexity": {"ideal_adj": 1, "mult_adj": 1.0}
69
+ },
70
+ "female": {
71
+ "nose.alarWidth": {"ideal_adj": 0.01, "mult_adj": 1.0},
72
+ "nose.nasofrontalAngle": {"ideal_adj": 2, "mult_adj": 1.0},
73
+ "profile.facialConvexity": {"ideal_adj": 1, "mult_adj": 1.0}
74
+ }
75
+ }
76
+ }
server/requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn==0.34.0
3
+ huggingface-hub==0.29.3
4
+ python-multipart==0.0.20
5
+ Pillow==11.1.0