TUSK000 commited on
Commit
ce019e8
·
verified ·
1 Parent(s): 8055a37

Upload main.py

Browse files
Files changed (1) hide show
  1. main.py +233 -0
main.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Busify FaceMatch-style API (InsightFace + ONNX, no dlib).
3
+ Compatible with Hugging Face Spaces / Render Docker.
4
+
5
+ - GET /health
6
+ - POST /embed — multipart field "file" (JPEG/PNG) → { dimensions, embedding }
7
+ - POST /match — JSON { imageBase64, candidates: [{ studentId, embedding: [float] }] }
8
+ → { matchFound, matchedStudentId, confidence, distance, status, topCandidates }
9
+ Inspired by the same architecture as https://huggingface.co/blackmamba2408/FaceMatch (512-D, cosine).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import os
15
+ from typing import Any
16
+
17
+ import cv2
18
+ import numpy as np
19
+ from fastapi import FastAPI, File, HTTPException, UploadFile
20
+ from pydantic import BaseModel, ConfigDict
21
+
22
+ app = FastAPI(title="Busify FaceMatch API", version="2.0.0")
23
+
24
+ _face_app = None
25
+
26
+
27
+ def _get_face_app():
28
+ global _face_app
29
+ if _face_app is None:
30
+ try:
31
+ from insightface.app import FaceAnalysis
32
+ except ImportError as exc:
33
+ raise HTTPException(
34
+ status_code=500,
35
+ detail="insightface not installed",
36
+ ) from exc
37
+ name = os.environ.get("INSIGHTFACE_MODEL_NAME", "buffalo_l")
38
+ providers = ["CPUExecutionProvider"]
39
+ _face_app = FaceAnalysis(name=name, providers=providers)
40
+ det_size = os.environ.get("INSIGHTFACE_DET_SIZE", "640,640")
41
+ try:
42
+ w, _, h = det_size.partition(",")
43
+ det_hw = (int(w.strip()), int(h.strip() or w.strip()))
44
+ except Exception:
45
+ det_hw = (640, 640)
46
+ _face_app.prepare(ctx_id=0, det_size=det_hw)
47
+ return _face_app
48
+
49
+
50
+ def _bgr_from_bytes(raw: bytes) -> np.ndarray | None:
51
+ arr = np.frombuffer(raw, dtype=np.uint8)
52
+ img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
53
+ return img
54
+
55
+
56
+ def _embedding_from_bgr(img: np.ndarray) -> np.ndarray:
57
+ faces = _get_face_app().get(img)
58
+ if not faces:
59
+ raise ValueError("no_face_detected")
60
+ # Stricter default reduces false detections on plain walls / textures (was 0.45).
61
+ min_det = float(os.environ.get("FACE_MIN_DET_SCORE", "0.55"))
62
+ # Prefer the largest confident face to reduce false detections on posters/backgrounds.
63
+ chosen = max(
64
+ (f for f in faces if float(getattr(f, "det_score", 1.0)) >= min_det),
65
+ default=None,
66
+ key=lambda f: float((f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1])),
67
+ )
68
+ if chosen is None:
69
+ chosen = max(
70
+ faces,
71
+ key=lambda f: float((f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1])),
72
+ )
73
+ if float(getattr(chosen, "det_score", 1.0)) < min_det * 0.9:
74
+ raise ValueError("no_face_detected")
75
+ emb = np.asarray(chosen.normed_embedding, dtype=np.float32)
76
+ return emb
77
+
78
+
79
+ @app.get("/health")
80
+ def health() -> dict[str, str]:
81
+ return {"status": "ok", "provider": "insightface-onnx"}
82
+
83
+
84
+ @app.post("/embed")
85
+ async def embed(file: UploadFile = File(...)) -> dict[str, Any]:
86
+ raw = await file.read()
87
+ if not raw:
88
+ raise HTTPException(status_code=400, detail="empty file")
89
+ img = _bgr_from_bytes(raw)
90
+ if img is None:
91
+ raise HTTPException(status_code=400, detail="invalid_image")
92
+ try:
93
+ vec = _embedding_from_bgr(img)
94
+ except ValueError as exc:
95
+ if str(exc) == "no_face_detected":
96
+ return {
97
+ "dimensions": 0,
98
+ "embedding": [],
99
+ "status": "no_face_detected",
100
+ }
101
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
102
+ return {
103
+ "dimensions": int(vec.shape[0]),
104
+ "embedding": vec.astype(float).tolist(),
105
+ }
106
+
107
+
108
+ class CandidateIn(BaseModel):
109
+ model_config = ConfigDict(populate_by_name=True)
110
+ studentId: int
111
+ embedding: list[float]
112
+
113
+
114
+ class MatchBody(BaseModel):
115
+ model_config = ConfigDict(populate_by_name=True)
116
+ imageBase64: str
117
+ candidates: list[CandidateIn]
118
+
119
+
120
+ @app.post("/match")
121
+ async def match(body: MatchBody) -> dict[str, Any]:
122
+ raw = body.imageBase64.strip()
123
+ if "base64," in raw:
124
+ raw = raw.split("base64,", 1)[1]
125
+ try:
126
+ img_bytes = base64.b64decode(raw, validate=False)
127
+ except Exception as exc:
128
+ raise HTTPException(status_code=400, detail=f"invalid_base64: {exc}") from exc
129
+
130
+ img = _bgr_from_bytes(img_bytes)
131
+ if img is None:
132
+ raise HTTPException(status_code=400, detail="invalid_image")
133
+
134
+ try:
135
+ probe = _embedding_from_bgr(img).astype(np.float64)
136
+ except ValueError as exc:
137
+ if str(exc) == "no_face_detected":
138
+ return {
139
+ "matchFound": False,
140
+ "matchedStudentId": None,
141
+ "confidence": 0.0,
142
+ "distance": None,
143
+ "status": "no_face_detected",
144
+ "topCandidates": [],
145
+ }
146
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
147
+
148
+ min_dim = 512
149
+ scored: list[tuple[int, float]] = []
150
+ for c in body.candidates:
151
+ if len(c.embedding) < min_dim:
152
+ continue
153
+ v = np.asarray(c.embedding[:min_dim], dtype=np.float64)
154
+ n = np.linalg.norm(v)
155
+ if n < 1e-6:
156
+ continue
157
+ v = v / n
158
+ sim = float(np.dot(probe, v))
159
+ scored.append((c.studentId, sim))
160
+
161
+ if not scored:
162
+ return {
163
+ "matchFound": False,
164
+ "matchedStudentId": None,
165
+ "confidence": 0.0,
166
+ "distance": None,
167
+ "status": "unknown",
168
+ "topCandidates": [],
169
+ }
170
+
171
+ scored.sort(key=lambda x: x[1], reverse=True)
172
+ # Cosine similarity on L2-normalized 512-D embeddings (InsightFace). Default was 0.32
173
+ # and was too permissive; wrong faces could still exceed it. Override with FACE_MIN_COSINE_SIM.
174
+ min_sim = float(os.environ.get("FACE_MIN_COSINE_SIM", "0.45"))
175
+ # Reject near-ties where top-1 and top-2 are too close to avoid false positives.
176
+ min_margin = float(os.environ.get("FACE_MIN_TOP1_MARGIN", "0.03"))
177
+ best_id, best_sim = scored[0]
178
+ margin_ok = True
179
+ if len(scored) >= 2:
180
+ margin_ok = (best_sim - scored[1][1]) >= min_margin
181
+
182
+ if best_sim < min_sim:
183
+ top = [
184
+ {
185
+ "studentId": sid,
186
+ "distance": round(1.0 - s, 6),
187
+ "confidence": round(s, 6),
188
+ }
189
+ for sid, s in scored[:5]
190
+ ]
191
+ return {
192
+ "matchFound": False,
193
+ "matchedStudentId": None,
194
+ "confidence": round(best_sim, 6),
195
+ "distance": round(1.0 - best_sim, 6),
196
+ "status": "unknown",
197
+ "topCandidates": top,
198
+ }
199
+
200
+ if not margin_ok:
201
+ top = [
202
+ {
203
+ "studentId": sid,
204
+ "distance": round(1.0 - s, 6),
205
+ "confidence": round(s, 6),
206
+ }
207
+ for sid, s in scored[:5]
208
+ ]
209
+ return {
210
+ "matchFound": False,
211
+ "matchedStudentId": None,
212
+ "confidence": round(best_sim, 6),
213
+ "distance": round(1.0 - best_sim, 6),
214
+ "status": "ambiguous",
215
+ "topCandidates": top,
216
+ }
217
+
218
+ top = [
219
+ {
220
+ "studentId": sid,
221
+ "distance": round(1.0 - s, 6),
222
+ "confidence": round(s, 6),
223
+ }
224
+ for sid, s in scored[:5]
225
+ ]
226
+ return {
227
+ "matchFound": True,
228
+ "matchedStudentId": best_id,
229
+ "confidence": round(best_sim, 6),
230
+ "distance": round(1.0 - best_sim, 6),
231
+ "status": "matched",
232
+ "topCandidates": top,
233
+ }