AryanChougule commited on
Commit
64fbfd0
·
1 Parent(s): 5d1d507

Restore face attendance Gradio app

Browse files
Files changed (5) hide show
  1. README.md +8 -9
  2. app.py +341 -0
  3. packages.txt +0 -2
  4. pipeline.py +425 -0
  5. requirements.txt +10 -0
README.md CHANGED
@@ -1,13 +1,12 @@
1
- ---
2
- title: Face Attendance Api
3
- emoji: 📚
4
- colorFrom: pink
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.27.0
8
- python_version: '3.12'
9
- app_file: app.py
10
  pinned: false
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
+ ---
2
+ title: Face Attendance API
3
+ emoji: 🚀
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: gradio
 
 
 
7
  pinned: false
8
  ---
9
 
10
+ # Face Attendance Backend API
11
+ Backend inference engine for MTCNN face detection and ArcFace embedding generation.
12
+ Serves the Vercel frontend live demo.
app.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Face Recognition Backend API on Hugging Face Spaces (Gradio SDK + FastAPI).
3
+ Serves MTCNN + ArcFace vector inference with full CORS support for Vercel.
4
+ """
5
+
6
+ import base64
7
+ import os
8
+ import secrets
9
+ import sys
10
+ import threading
11
+ import time
12
+ import traceback
13
+ from typing import Optional, List, Dict, Any
14
+
15
+ from fastapi import FastAPI, Request, Response, HTTPException, status
16
+ from fastapi.responses import JSONResponse, Response as RawResponse
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ from starlette.middleware.base import BaseHTTPMiddleware
19
+ from pydantic import BaseModel
20
+ import gradio as gr
21
+
22
+ import pipeline as P
23
+
24
+ # --------------------------------------------------------------------------
25
+ # State & Sessions
26
+ # --------------------------------------------------------------------------
27
+ SESSION_TTL = 4 * 3600
28
+ SESSIONS = {}
29
+ _SESS_LOCK = threading.Lock()
30
+ _status = {"ready": False, "error": None, "seconds": None}
31
+
32
+
33
+ def _warm():
34
+ t0 = time.time()
35
+ try:
36
+ P.warmup()
37
+ _status["ready"] = True
38
+ except Exception as exc:
39
+ _status["error"] = f"{type(exc).__name__}: {exc}"
40
+ traceback.print_exc()
41
+ finally:
42
+ _status["seconds"] = round(time.time() - t0, 1)
43
+ print(f"[warmup] ready={_status['ready']} in {_status['seconds']}s")
44
+
45
+
46
+ threading.Thread(target=_warm, daemon=True).start()
47
+
48
+
49
+ def _get_session(sid: Optional[str]):
50
+ now = time.time()
51
+ with _SESS_LOCK:
52
+ for dead in [s for s, v in SESSIONS.items() if now - v["touched"] > SESSION_TTL]:
53
+ SESSIONS.pop(dead, None)
54
+ if not sid or sid not in SESSIONS:
55
+ sid = secrets.token_urlsafe(18)
56
+ SESSIONS[sid] = {"gallery": P.Gallery(), "dismissed": set(), "touched": now}
57
+ SESSIONS[sid]["touched"] = now
58
+ return sid, SESSIONS[sid]["gallery"], SESSIONS[sid]["dismissed"]
59
+
60
+
61
+ def _session_payload(gallery, dismissed):
62
+ return {
63
+ "people": gallery.summary(),
64
+ "total_embeddings": sum(len(v) for v in gallery.data.values()),
65
+ "duplicates": P.find_duplicates(gallery.data, dismissed),
66
+ }
67
+
68
+
69
+ # --------------------------------------------------------------------------
70
+ # FastAPI Application
71
+ # --------------------------------------------------------------------------
72
+ api_app = FastAPI(title="Face Recognition API", docs_url="/docs")
73
+
74
+ # CORS middleware for cross-origin Vercel requests
75
+ api_app.add_middleware(
76
+ CORSMiddleware,
77
+ allow_origins=["*"],
78
+ allow_credentials=True,
79
+ allow_methods=["*"],
80
+ allow_headers=["*"],
81
+ expose_headers=["Content-Disposition", "X-Session-Id"],
82
+ )
83
+
84
+
85
+ class SessionMiddleware(BaseHTTPMiddleware):
86
+ async def dispatch(self, request: Request, call_next):
87
+ sid_in = request.cookies.get("sid") or request.headers.get("X-Session-Id")
88
+ sid, gallery, dismissed = _get_session(sid_in)
89
+ request.state.sid = sid
90
+ request.state.gallery = gallery
91
+ request.state.dismissed = dismissed
92
+
93
+ response = await call_next(request)
94
+
95
+ # Attach session id to cookie & response header
96
+ response.set_cookie(
97
+ "sid", sid, httponly=True, samesite="none", secure=True, max_age=SESSION_TTL
98
+ )
99
+ response.headers["X-Session-Id"] = sid
100
+ return response
101
+
102
+
103
+ api_app.add_middleware(SessionMiddleware)
104
+
105
+
106
+ # --------------------------------------------------------------------------
107
+ # Pydantic Request Models
108
+ # --------------------------------------------------------------------------
109
+ class EnrollRequest(BaseModel):
110
+ person_id: str
111
+ images: List[str]
112
+
113
+
114
+ class RecognizeRequest(BaseModel):
115
+ image: str
116
+
117
+
118
+ class MergeRequest(BaseModel):
119
+ source: str
120
+ target: str
121
+
122
+
123
+ class DismissRequest(BaseModel):
124
+ a: str
125
+ b: str
126
+
127
+
128
+ # --------------------------------------------------------------------------
129
+ # REST API Endpoints
130
+ # --------------------------------------------------------------------------
131
+ @api_app.get("/api/status")
132
+ async def get_status(request: Request):
133
+ people_count = len(request.state.gallery.data) if hasattr(request.state, "gallery") else 0
134
+ return {**_status, "people": people_count}
135
+
136
+
137
+ @api_app.get("/api/session")
138
+ async def get_session_info(request: Request):
139
+ return _session_payload(request.state.gallery, request.state.dismissed)
140
+
141
+
142
+ @api_app.post("/api/session/reset")
143
+ async def reset_session(request: Request):
144
+ with _SESS_LOCK:
145
+ SESSIONS[request.state.sid]["gallery"] = P.Gallery()
146
+ SESSIONS[request.state.sid]["dismissed"] = set()
147
+ return {"ok": True}
148
+
149
+
150
+ @api_app.post("/api/enroll")
151
+ async def enroll(req: EnrollRequest, request: Request):
152
+ person_id = req.person_id.strip()
153
+ images = req.images
154
+
155
+ if not person_id:
156
+ raise HTTPException(status_code=400, detail="A name is required")
157
+ if not images:
158
+ raise HTTPException(status_code=400, detail="No images supplied")
159
+ if not _status["ready"]:
160
+ raise HTTPException(status_code=503, detail="Models are still loading, please wait")
161
+
162
+ results = []
163
+ added = 0
164
+ for idx, data_url in enumerate(images):
165
+ try:
166
+ bgr = P.decode_data_url(data_url)
167
+ faces = P.extract_faces(bgr)
168
+ if not faces:
169
+ results.append({"index": idx, "ok": False, "reason": "No face detected"})
170
+ continue
171
+ if len(faces) > 1:
172
+ results.append({
173
+ "index": idx, "ok": False,
174
+ "reason": f"{len(faces)} faces in frame - register one face at a time"
175
+ })
176
+ continue
177
+
178
+ face = faces[0]
179
+ request.state.gallery.add(person_id, P.embed(face["crop_bgr"]))
180
+ added += 1
181
+ results.append({
182
+ "index": idx,
183
+ "ok": True,
184
+ "confidence": round(face["confidence"], 4),
185
+ "crop": P.encode_jpeg_data_url(face["crop_bgr"], quality=75),
186
+ })
187
+ except Exception as exc:
188
+ results.append({"index": idx, "ok": False, "reason": str(exc)})
189
+
190
+ return {
191
+ "person_id": person_id,
192
+ "added": added,
193
+ "total": len(request.state.gallery.data.get(person_id, [])),
194
+ "cohesion": P.cohesion(request.state.gallery.data.get(person_id, [])),
195
+ "results": results,
196
+ }
197
+
198
+
199
+ @api_app.post("/api/recognize")
200
+ async def recognize(req: RecognizeRequest, request: Request):
201
+ data_url = req.image
202
+ if not data_url:
203
+ raise HTTPException(status_code=400, detail="Image is required")
204
+ if not _status["ready"]:
205
+ raise HTTPException(status_code=503, detail="Models are still loading")
206
+ if not request.state.gallery.data:
207
+ raise HTTPException(status_code=400, detail="Nobody registered yet")
208
+
209
+ t0 = time.time()
210
+ try:
211
+ bgr = P.decode_data_url(data_url)
212
+ h, w = bgr.shape[:2]
213
+ faces = P.extract_faces(bgr)
214
+ except Exception as exc:
215
+ raise HTTPException(status_code=400, detail=str(exc))
216
+
217
+ out = []
218
+ for face in faces:
219
+ ranked = request.state.gallery.rank(P.embed(face["crop_bgr"]))
220
+ top = ranked[0] if ranked else None
221
+ runner = ranked[1] if len(ranked) > 1 else None
222
+ out.append({
223
+ "box": face["box"],
224
+ "confidence": round(face["confidence"], 4),
225
+ "crop": P.encode_jpeg_data_url(face["crop_bgr"], quality=70),
226
+ "ranked": [{"id": r["id"], "score": round(r["score"], 4)} for r in ranked],
227
+ "top": {"id": top["id"], "score": round(top["score"], 4)} if top else None,
228
+ "margin": round(top["score"] - runner["score"], 4) if runner else None,
229
+ })
230
+
231
+ return {
232
+ "faces": out,
233
+ "frame": {"width": w, "height": h},
234
+ "elapsed_ms": int((time.time() - t0) * 1000),
235
+ }
236
+
237
+
238
+ @api_app.delete("/api/person/{person_id}")
239
+ async def delete_person(person_id: str, request: Request):
240
+ ok = request.state.gallery.remove(person_id)
241
+ for pair in [p for p in request.state.dismissed if person_id in p]:
242
+ request.state.dismissed.discard(pair)
243
+ return {"ok": ok}
244
+
245
+
246
+ @api_app.post("/api/person/merge")
247
+ async def merge_people(req: MergeRequest, request: Request):
248
+ source, target = req.source.strip(), req.target.strip()
249
+ try:
250
+ request.state.gallery.merge(source, target)
251
+ except ValueError as exc:
252
+ raise HTTPException(status_code=400, detail=str(exc))
253
+ for pair in [p for p in request.state.dismissed if source in p]:
254
+ request.state.dismissed.discard(pair)
255
+ return {"ok": True, **_session_payload(request.state.gallery, request.state.dismissed)}
256
+
257
+
258
+ @api_app.post("/api/duplicates/dismiss")
259
+ async def dismiss_duplicate(req: DismissRequest, request: Request):
260
+ a, b = req.a.strip(), req.b.strip()
261
+ gal = request.state.gallery
262
+ if not a or not b or a == b or a not in gal.data or b not in gal.data:
263
+ raise HTTPException(status_code=400, detail="Pick two different registered people")
264
+ request.state.dismissed.add(frozenset((a, b)))
265
+ return {"ok": True, **_session_payload(request.state.gallery, request.state.dismissed)}
266
+
267
+
268
+ @api_app.get("/api/session/export.pkl")
269
+ async def export_pkl(request: Request):
270
+ if not request.state.gallery.data:
271
+ raise HTTPException(status_code=400, detail="Nothing to export")
272
+ data = request.state.gallery.to_pickle_bytes()
273
+ return RawResponse(
274
+ content=data,
275
+ media_type="application/octet-stream",
276
+ headers={"Content-Disposition": 'attachment; filename="student_embeddings.pkl"'}
277
+ )
278
+
279
+
280
+ @api_app.get("/api/session/export.json")
281
+ async def export_json(request: Request):
282
+ return {
283
+ "embeddings": request.state.gallery.to_json(),
284
+ "dismissed": [sorted(p) for p in request.state.dismissed],
285
+ }
286
+
287
+
288
+ @api_app.post("/api/session/import")
289
+ async def import_gallery(request: Request):
290
+ body = await request.json()
291
+ try:
292
+ if body.get("json") is not None:
293
+ gal = P.Gallery.from_json(body["json"])
294
+ elif body.get("pkl_b64"):
295
+ gal = P.Gallery.from_pickle_bytes(base64.b64decode(body["pkl_b64"]))
296
+ else:
297
+ raise HTTPException(status_code=400, detail="Send either 'json' or 'pkl_b64'")
298
+ except Exception as exc:
299
+ raise HTTPException(status_code=400, detail=f"Could not load: {exc}")
300
+
301
+ dismissed = set()
302
+ for pair in body.get("dismissed") or []:
303
+ if (isinstance(pair, (list, tuple)) and len(pair) == 2
304
+ and all(isinstance(x, str) and x in gal.data for x in pair)
305
+ and pair[0] != pair[1]):
306
+ dismissed.add(frozenset(pair))
307
+
308
+ with _SESS_LOCK:
309
+ SESSIONS[request.state.sid]["gallery"] = gal
310
+ SESSIONS[request.state.sid]["dismissed"] = dismissed
311
+
312
+ return {"ok": True, **_session_payload(gal, dismissed)}
313
+
314
+
315
+ # --------------------------------------------------------------------------
316
+ # Gradio Interface (Mounted at /)
317
+ # --------------------------------------------------------------------------
318
+ with gr.Blocks(title="Face Recognition Backend API") as demo:
319
+ gr.Markdown(
320
+ """
321
+ # 🚀 Face Recognition Backend API
322
+ **Powered by MTCNN + ArcFace (Retrieval-Augmented Recognition)**
323
+
324
+ This Hugging Face Space hosts the ML inference API for the attendance application.
325
+
326
+ - **Status:** Running
327
+ - **Documentation:** Interactive OpenAPI docs available at [`/docs`](/docs)
328
+ - **Frontend:** Connected to your live Vercel web application
329
+ """
330
+ )
331
+ with gr.Row():
332
+ btn = gr.Button("Check Model Readiness")
333
+ out = gr.JSON(label="Readiness Info")
334
+ btn.click(fn=lambda: {**_status, "status": "online"}, outputs=out)
335
+
336
+ app = gr.mount_gradio_app(api_app, demo, path="/")
337
+
338
+ if __name__ == "__main__":
339
+ import uvicorn
340
+ port = int(os.environ.get("PORT", "7860"))
341
+ uvicorn.run("app:app", host="0.0.0.0", port=port, reload=False)
packages.txt DELETED
@@ -1,2 +0,0 @@
1
- libgl1
2
- libglib2.0-0
 
 
 
pipeline.py ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Face pipeline for the live demo: detect -> crop -> preprocess -> embed -> match.
3
+
4
+ The one rule this module exists to enforce: enrollment and recognition run the
5
+ EXACT same code path. ArcFace embeddings are only comparable when the crop
6
+ geometry and lighting normalisation match, so both directions call
7
+ extract_faces() + embed() and nothing else.
8
+
9
+ Galleries are per-session and in-memory. Nothing is written to disk on the
10
+ server: the user's .pkl belongs to the user's device. The production
11
+ student_embeddings.pkl is never read or written here.
12
+ """
13
+
14
+ import base64
15
+ import io
16
+ import pickle
17
+ import threading
18
+
19
+ import cv2
20
+ import numpy as np
21
+ from numpy.linalg import norm
22
+
23
+ MODEL_NAME = "ArcFace"
24
+ PAD_RATIO = 0.20 # matches main.py: 20% padding around the MTCNN box
25
+ DETECT_MAX_DIM = 640 # downscale before detection; boxes scaled back up
26
+ MIN_FACE_CONFIDENCE = 0.90
27
+
28
+ # TensorFlow graphs are not safe to call from multiple threads at once and
29
+ # Flask's dev server is threaded, so every inference goes through this lock.
30
+ _LOCK = threading.Lock()
31
+ _detector = None
32
+
33
+
34
+ # --------------------------------------------------------------------------
35
+ # model loading
36
+ # --------------------------------------------------------------------------
37
+ def get_detector():
38
+ global _detector
39
+ if _detector is None:
40
+ from mtcnn import MTCNN
41
+ _detector = MTCNN()
42
+ return _detector
43
+
44
+
45
+ def warmup():
46
+ """Load MTCNN + ArcFace and run one dummy inference of each.
47
+
48
+ Called at server start so the first real request isn't a 30s stall.
49
+ """
50
+ from deepface import DeepFace
51
+
52
+ dummy = np.zeros((160, 160, 3), dtype=np.uint8)
53
+ with _LOCK:
54
+ get_detector().detect_faces(cv2.cvtColor(dummy, cv2.COLOR_BGR2RGB))
55
+ DeepFace.represent(
56
+ img_path=dummy,
57
+ model_name=MODEL_NAME,
58
+ detector_backend="skip",
59
+ enforce_detection=False,
60
+ )
61
+
62
+
63
+ # --------------------------------------------------------------------------
64
+ # image codec helpers
65
+ # --------------------------------------------------------------------------
66
+ def decode_data_url(data_url):
67
+ """A data: URL or bare base64 string -> BGR ndarray."""
68
+ if "," in data_url:
69
+ data_url = data_url.split(",", 1)[1]
70
+ raw = base64.b64decode(data_url)
71
+ buf = np.frombuffer(raw, dtype=np.uint8)
72
+ img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
73
+ if img is None:
74
+ raise ValueError("could not decode image")
75
+ return img
76
+
77
+
78
+ def encode_jpeg_data_url(bgr, quality=85):
79
+ ok, buf = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), quality])
80
+ if not ok:
81
+ raise ValueError("could not encode image")
82
+ return "data:image/jpeg;base64," + base64.b64encode(buf.tobytes()).decode("ascii")
83
+
84
+
85
+ def l2_normalize(x):
86
+ x = np.asarray(x, dtype=np.float32)
87
+ return x / (norm(x) + 1e-10)
88
+
89
+
90
+ def cosine_similarity(a, b):
91
+ return float(np.dot(a, b) / (norm(a) * norm(b) + 1e-10))
92
+
93
+
94
+ # --------------------------------------------------------------------------
95
+ # detection + preprocessing (same logic as main.py's preprocess_face)
96
+ # --------------------------------------------------------------------------
97
+ def preprocess_face(face_rgb, keypoints=None, origin=None):
98
+ """Adaptive gamma -> eye-alignment rotation -> Y-channel histogram EQ."""
99
+ try:
100
+ if face_rgb is None or face_rgb.size == 0:
101
+ return None
102
+
103
+ gray = cv2.cvtColor(face_rgb, cv2.COLOR_RGB2GRAY)
104
+ mean = float(np.mean(gray))
105
+ gamma = 1.0
106
+ if mean < 100:
107
+ gamma = 1.5
108
+ elif mean > 160:
109
+ gamma = 0.7
110
+ lut = np.array(
111
+ [((i / 255.0) ** (1.0 / gamma)) * 255 for i in np.arange(256)]
112
+ ).astype("uint8")
113
+ face_rgb = cv2.LUT(face_rgb, lut)
114
+
115
+ if keypoints and "left_eye" in keypoints and "right_eye" in keypoints:
116
+ le, re = keypoints["left_eye"], keypoints["right_eye"]
117
+ if le is not None and re is not None and len(le) == 2 and len(re) == 2:
118
+ lx, ly = float(le[0]), float(le[1])
119
+ rx, ry = float(re[0]), float(re[1])
120
+ if not any(np.isnan(v) for v in (lx, ly, rx, ry)):
121
+ if origin is not None:
122
+ ox, oy = origin
123
+ lx -= ox
124
+ rx -= ox
125
+ ly -= oy
126
+ ry -= oy
127
+ angle = float(np.degrees(np.arctan2(ry - ly, rx - lx)))
128
+ h, w = face_rgb.shape[:2]
129
+ cx = max(0.0, min(float(w - 1), (lx + rx) / 2.0))
130
+ cy = max(0.0, min(float(h - 1), (ly + ry) / 2.0))
131
+ m = cv2.getRotationMatrix2D((cx, cy), angle, 1.0)
132
+ face_rgb = cv2.warpAffine(
133
+ face_rgb, m, (w, h),
134
+ flags=cv2.INTER_LINEAR,
135
+ borderMode=cv2.BORDER_REPLICATE,
136
+ )
137
+
138
+ yuv = cv2.cvtColor(face_rgb, cv2.COLOR_RGB2YUV)
139
+ yuv[:, :, 0] = cv2.equalizeHist(yuv[:, :, 0])
140
+ return cv2.cvtColor(yuv, cv2.COLOR_YUV2RGB)
141
+ except Exception:
142
+ return face_rgb
143
+
144
+
145
+ def extract_faces(bgr):
146
+ """Detect every face in a BGR frame.
147
+
148
+ Detection runs on a downscaled copy (MTCNN cost scales with pixel count)
149
+ but crops are taken from the full-resolution original so no detail is lost.
150
+
151
+ Returns list of {box, confidence, crop_bgr} sorted left-to-right.
152
+ """
153
+ rgb_full = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
154
+ h, w = rgb_full.shape[:2]
155
+
156
+ scale = 1.0
157
+ rgb_small = rgb_full
158
+ if max(h, w) > DETECT_MAX_DIM:
159
+ scale = DETECT_MAX_DIM / float(max(h, w))
160
+ rgb_small = cv2.resize(
161
+ rgb_full, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
162
+ )
163
+
164
+ with _LOCK:
165
+ results = get_detector().detect_faces(rgb_small)
166
+
167
+ faces = []
168
+ for res in results:
169
+ if res.get("confidence", 0.0) < MIN_FACE_CONFIDENCE:
170
+ continue
171
+ try:
172
+ x, y, bw, bh = [int(round(v / scale)) for v in res["box"]]
173
+ except Exception:
174
+ continue
175
+ if bw <= 0 or bh <= 0:
176
+ continue
177
+
178
+ pad = int(max(1, min(bw, bh) * PAD_RATIO))
179
+ x1, y1 = max(0, x - pad), max(0, y - pad)
180
+ x2, y2 = min(w, x + bw + pad), min(h, y + bh + pad)
181
+ if x2 <= x1 or y2 <= y1:
182
+ continue
183
+
184
+ crop = rgb_full[y1:y2, x1:x2]
185
+ if crop.size == 0:
186
+ continue
187
+
188
+ kps = res.get("keypoints") or {}
189
+ kps = {k: (v[0] / scale, v[1] / scale) for k, v in kps.items() if v is not None}
190
+
191
+ processed = preprocess_face(crop, kps, origin=(x1, y1))
192
+ if processed is None or processed.size == 0:
193
+ continue
194
+
195
+ faces.append({
196
+ "box": [int(x), int(y), int(bw), int(bh)],
197
+ "confidence": float(res.get("confidence", 0.0)),
198
+ "crop_bgr": cv2.cvtColor(processed, cv2.COLOR_RGB2BGR),
199
+ })
200
+
201
+ faces.sort(key=lambda f: f["box"][0])
202
+ return faces
203
+
204
+
205
+ def embed(crop_bgr):
206
+ """512-d L2-normalised ArcFace vector. Array in, no disk round-trip."""
207
+ from deepface import DeepFace
208
+ with _LOCK:
209
+ rep = DeepFace.represent(
210
+ img_path=crop_bgr,
211
+ model_name=MODEL_NAME,
212
+ detector_backend="skip",
213
+ enforce_detection=False,
214
+ )
215
+ return l2_normalize(rep[0]["embedding"])
216
+
217
+
218
+ # --------------------------------------------------------------------------
219
+ # gallery
220
+ # --------------------------------------------------------------------------
221
+ def cohesion(embs):
222
+ """Mean pairwise similarity within one person's own embeddings.
223
+
224
+ Enrollment quality signal: low cohesion means the reference photos
225
+ disagree with each other, and matching against them will be unreliable.
226
+ """
227
+ if len(embs) < 2:
228
+ return None
229
+ a = np.asarray(embs, dtype=np.float32)
230
+ sims = a @ a.T
231
+ n = len(a)
232
+ return float((sims.sum() - np.trace(sims)) / (n * (n - 1)))
233
+
234
+
235
+ class RestrictedUnpickler(pickle.Unpickler):
236
+ """pickle.load() on user-supplied bytes is arbitrary code execution.
237
+
238
+ A .pkl uploaded from a browser is untrusted input, so imports are limited
239
+ to what a numpy embedding file legitimately needs. Anything else raises
240
+ instead of executing.
241
+ """
242
+
243
+ _ALLOWED = {
244
+ ("numpy", "ndarray"),
245
+ ("numpy", "dtype"),
246
+ ("numpy", "float32"),
247
+ ("numpy", "float64"),
248
+ ("numpy.core.multiarray", "_reconstruct"),
249
+ ("numpy.core.multiarray", "scalar"),
250
+ ("numpy._core.multiarray", "_reconstruct"),
251
+ ("numpy._core.multiarray", "scalar"),
252
+ }
253
+
254
+ def find_class(self, module, name):
255
+ if (module, name) in self._ALLOWED:
256
+ return super().find_class(module, name)
257
+ raise pickle.UnpicklingError(f"blocked for safety: {module}.{name}")
258
+
259
+
260
+ def restricted_loads(raw):
261
+ return RestrictedUnpickler(io.BytesIO(raw)).load()
262
+
263
+
264
+ class Gallery:
265
+ """{person_id: [embedding, ...]} with the same match rule as main.py:
266
+ a person's score is the MAX similarity over their enrolled embeddings.
267
+
268
+ Purely in-memory and scoped to one browser session. Persistence is the
269
+ client's job -- see to_pickle_bytes() / to_json().
270
+ """
271
+
272
+ def __init__(self, data=None):
273
+ self.data = data or {}
274
+
275
+ # ---- serialisation -------------------------------------------------
276
+ def to_pickle_bytes(self):
277
+ """Byte-identical in structure to what face_embd.py writes, so an
278
+ exported file drops straight into main.py with no conversion."""
279
+ return pickle.dumps(
280
+ {k: [np.asarray(e, dtype=np.float32) for e in v]
281
+ for k, v in self.data.items()},
282
+ protocol=4,
283
+ )
284
+
285
+ def to_json(self):
286
+ return {k: [[float(x) for x in e] for e in v] for k, v in self.data.items()}
287
+
288
+ @staticmethod
289
+ def _validate(obj):
290
+ if not isinstance(obj, dict):
291
+ raise ValueError("expected a dict of {person_id: [embedding, ...]}")
292
+ clean = {}
293
+ for pid, embs in obj.items():
294
+ if not isinstance(pid, str) or not pid.strip():
295
+ raise ValueError("person ids must be non-empty strings")
296
+ if not isinstance(embs, (list, tuple)) or not embs:
297
+ raise ValueError(f"'{pid}' has no embeddings")
298
+ out = []
299
+ for e in embs:
300
+ a = np.asarray(e, dtype=np.float32).ravel()
301
+ if a.size != 512 or not np.all(np.isfinite(a)):
302
+ raise ValueError(f"'{pid}' has a bad embedding (size {a.size})")
303
+ out.append(l2_normalize(a))
304
+ clean[pid.strip()] = out
305
+ return clean
306
+
307
+ @classmethod
308
+ def from_json(cls, obj):
309
+ return cls(cls._validate(obj))
310
+
311
+ @classmethod
312
+ def from_pickle_bytes(cls, raw):
313
+ return cls(cls._validate(restricted_loads(raw)))
314
+
315
+ # ---- gallery ops ---------------------------------------------------
316
+ def add(self, person_id, embedding):
317
+ self.data.setdefault(person_id, []).append(embedding)
318
+
319
+ def remove(self, person_id):
320
+ return self.data.pop(person_id, None) is not None
321
+
322
+ def clear(self):
323
+ self.data = {}
324
+
325
+ def people(self):
326
+ return sorted(self.data.keys())
327
+
328
+ def summary(self):
329
+ return [
330
+ {"id": pid, "count": len(embs), "cohesion": cohesion(embs)}
331
+ for pid, embs in sorted(self.data.items())
332
+ ]
333
+
334
+ def merge(self, source, target):
335
+ """Fold source's embeddings into target and drop the source name.
336
+
337
+ Used when two names turn out to be one face. Stored embeddings are
338
+ moved as-is, never recomputed, so the merged identity is exactly the
339
+ union of both registrations.
340
+ """
341
+ if not source or not target or source == target:
342
+ raise ValueError("pick two different people to merge")
343
+ if source not in self.data or target not in self.data:
344
+ raise ValueError("both people must be registered in this session")
345
+ self.data[target].extend(self.data.pop(source))
346
+
347
+ def rank(self, probe):
348
+ """All enrolled people scored against the probe, best first."""
349
+ scored = [
350
+ {"id": pid, "score": max(cosine_similarity(probe, e) for e in embs)}
351
+ for pid, embs in self.data.items()
352
+ if embs
353
+ ]
354
+ scored.sort(key=lambda s: s["score"], reverse=True)
355
+ return scored
356
+
357
+
358
+ # --------------------------------------------------------------------------
359
+ # duplicate identities
360
+ #
361
+ # Pure analysis over embeddings the pipeline has already produced. Nothing
362
+ # here touches detection, preprocessing, embedding or rank().
363
+ # --------------------------------------------------------------------------
364
+ MATCH_THRESHOLD = 0.45 # main.py's recognition threshold
365
+
366
+ # Calibrated on raw_dataset (9 students). Each student's photos split in half
367
+ # stood in for one person under two names (540 splits); every pair of different
368
+ # students stood in for different people (576 comparisons). This combined rule
369
+ # caught 98.5% of the same-person splits and flagged 0 of the different pairs.
370
+ DUP_RATIO_THRESHOLD = 0.88
371
+ DUP_CENTROID_FLOOR = 0.60
372
+ COHESION_FLOOR = 0.30 # stops a scattered registration from inflating the ratio
373
+
374
+
375
+ def compare_identities(a, b):
376
+ """How alike two registered names are, from their stored embeddings.
377
+
378
+ ratio - mean cross-name similarity divided by each name's own
379
+ cohesion: "are B's photos as close to A's as A's photos are to
380
+ each other?" ~1.0 means indistinguishable. The normalisation is
381
+ what makes this work: in this pipeline even DIFFERENT students
382
+ have raw cross-similarity around 0.3-0.5, so no absolute cosine
383
+ cutoff separates them.
384
+ centroid - cosine between the two names' mean embeddings. An independent
385
+ guard for the ratio's weak spot (dividing by a low cohesion).
386
+ match_rate - main.py's rule turned on the gallery: the share of each name's
387
+ photos recognised as the other at 0.45. Reported only -- it is
388
+ not diagnostic, different students often score high on it too.
389
+ """
390
+ A = np.asarray(a, dtype=np.float32)
391
+ B = np.asarray(b, dtype=np.float32)
392
+ S = A @ B.T
393
+ cross = float(S.mean())
394
+ ca, cb = A.mean(axis=0), B.mean(axis=0)
395
+ centroid = float(ca @ cb / (norm(ca) * norm(cb) + 1e-10))
396
+ ka, kb = cohesion(A), cohesion(B)
397
+ ka = max(ka if ka is not None else cross, COHESION_FLOOR)
398
+ kb = max(kb if kb is not None else cross, COHESION_FLOOR)
399
+ hits = int((S.max(axis=1) >= MATCH_THRESHOLD).sum()
400
+ + (S.max(axis=0) >= MATCH_THRESHOLD).sum())
401
+ return {
402
+ "ratio": cross / float(np.sqrt(ka * kb)),
403
+ "centroid": centroid,
404
+ "cross_mean": cross,
405
+ "match_rate": hits / float(len(A) + len(B)),
406
+ }
407
+
408
+
409
+ def find_duplicates(data, dismissed=()):
410
+ """Pairs of different names whose faces look like the same person.
411
+
412
+ dismissed holds frozenset({a, b}) pairs the user has confirmed are
413
+ different people, so they aren't raised again.
414
+ """
415
+ ids = sorted(k for k, v in data.items() if v)
416
+ out = []
417
+ for i, a in enumerate(ids):
418
+ for b in ids[i + 1:]:
419
+ if frozenset((a, b)) in dismissed:
420
+ continue
421
+ m = compare_identities(data[a], data[b])
422
+ if m["ratio"] >= DUP_RATIO_THRESHOLD and m["centroid"] >= DUP_CENTROID_FLOOR:
423
+ out.append({"a": a, "b": b, **{k: round(v, 4) for k, v in m.items()}})
424
+ out.sort(key=lambda d: d["ratio"], reverse=True)
425
+ return out
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ numpy==1.24.4
2
+ tensorflow==2.11.0
3
+ mtcnn==0.1.1
4
+ deepface==0.0.83
5
+ opencv-python-headless
6
+ pandas
7
+ fastapi
8
+ uvicorn
9
+ python-multipart
10
+ gradio>=4.0