AryanChougule commited on
Commit
c4c85cf
·
1 Parent(s): cd69fa1

Add face attendance API

Browse files
Files changed (6) hide show
  1. .dockerignore +10 -0
  2. Dockerfile +31 -0
  3. README.md +10 -10
  4. app.py +306 -0
  5. pipeline.py +425 -0
  6. requirements.txt +9 -0
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .git
6
+ .github
7
+ .env
8
+ facenv
9
+ venv
10
+ .venv
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ ENV DEBIAN_FRONTEND=noninteractive
4
+ ENV PYTHONUNBUFFERED=1
5
+
6
+ # Install runtime dependencies for OpenCV and TensorFlow
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ libgl1 \
9
+ libglib2.0-0 \
10
+ libgomp1 \
11
+ build-essential \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ WORKDIR /app
15
+
16
+ # Install Python requirements
17
+ COPY requirements.txt .
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ # Pre-download MTCNN and ArcFace weights during build so container starts instantly
21
+ RUN python -c "from deepface.basemodels import ArcFace; from mtcnn import MTCNN; ArcFace.load_model(); MTCNN()"
22
+
23
+ # Copy application files
24
+ COPY . .
25
+
26
+ # Hugging Face Spaces standard port
27
+ ENV PORT=7860
28
+ EXPOSE 7860
29
+
30
+ # Single worker with 4 threads (ML models take ~1.5-2GB RAM)
31
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", "--threads", "4", "--timeout", "180", "app:app"]
README.md CHANGED
@@ -1,13 +1,13 @@
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: docker
7
+ app_port: 7860
 
 
8
  pinned: false
9
  ---
10
 
11
+ # Face Attendance Backend API
12
+ Backend inference engine for MTCNN face detection and ArcFace embedding generation.
13
+ Serves the Vercel frontend live demo.
app.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Flask API backend for the Face Recognition system.
3
+ Configured for Hugging Face Spaces (Docker) with CORS support.
4
+ """
5
+
6
+ import base64
7
+ import os
8
+ import secrets
9
+ import sys
10
+ import threading
11
+ import time
12
+ import traceback
13
+
14
+ from flask import Flask, g, jsonify, make_response, request
15
+ from flask_cors import CORS
16
+
17
+ import pipeline as P
18
+
19
+ app = Flask(__name__)
20
+ app.config["MAX_CONTENT_LENGTH"] = 64 * 1024 * 1024 # 64 MB
21
+
22
+ # Enable CORS for cross-origin requests from Vercel
23
+ CORS(
24
+ app,
25
+ supports_credentials=True,
26
+ origins=r".*",
27
+ expose_headers=["Content-Disposition", "X-Session-Id"],
28
+ allow_headers=["Content-Type", "Authorization", "X-Session-Id"]
29
+ )
30
+
31
+ SESSION_TTL = 4 * 3600
32
+ SESSIONS = {}
33
+ _SESS_LOCK = threading.Lock()
34
+
35
+ _status = {"ready": False, "error": None, "seconds": None}
36
+
37
+
38
+ def _warm():
39
+ t0 = time.time()
40
+ try:
41
+ P.warmup()
42
+ _status["ready"] = True
43
+ except Exception as exc:
44
+ _status["error"] = f"{type(exc).__name__}: {exc}"
45
+ traceback.print_exc()
46
+ finally:
47
+ _status["seconds"] = round(time.time() - t0, 1)
48
+ print(f"[warmup] ready={_status['ready']} in {_status['seconds']}s")
49
+
50
+
51
+ threading.Thread(target=_warm, daemon=True).start()
52
+
53
+
54
+ # --------------------------------------------------------------------------
55
+ # per-visitor session
56
+ # --------------------------------------------------------------------------
57
+ @app.before_request
58
+ def _attach_session():
59
+ if not request.path.startswith("/api/"):
60
+ return
61
+ # Check both cookie and custom header (guards against third-party cookie blocking)
62
+ sid = request.cookies.get("sid") or request.headers.get("X-Session-Id")
63
+ now = time.time()
64
+ with _SESS_LOCK:
65
+ for dead in [s for s, v in SESSIONS.items() if now - v["touched"] > SESSION_TTL]:
66
+ SESSIONS.pop(dead, None)
67
+ if not sid or sid not in SESSIONS:
68
+ sid = secrets.token_urlsafe(18)
69
+ SESSIONS[sid] = {"gallery": P.Gallery(), "dismissed": set(), "touched": now}
70
+ g.new_sid = sid
71
+ SESSIONS[sid]["touched"] = now
72
+ g.sid = sid
73
+ g.gallery = SESSIONS[sid]["gallery"]
74
+ # pairs the user confirmed are DIFFERENT people: frozenset({a, b})
75
+ g.dismissed = SESSIONS[sid]["dismissed"]
76
+
77
+
78
+ @app.after_request
79
+ def _issue_cookie(resp):
80
+ sid = getattr(g, "sid", None) or getattr(g, "new_sid", None)
81
+ if sid:
82
+ # SameSite=None + secure=True allows cross-domain cookies
83
+ resp.set_cookie("sid", sid, httponly=True, samesite="None", secure=True, max_age=SESSION_TTL)
84
+ resp.headers["X-Session-Id"] = sid
85
+ return resp
86
+
87
+
88
+ def _replace_gallery(gal, dismissed=None):
89
+ dismissed = dismissed if dismissed is not None else set()
90
+ with _SESS_LOCK:
91
+ SESSIONS[g.sid]["gallery"] = gal
92
+ SESSIONS[g.sid]["dismissed"] = dismissed
93
+ g.gallery = gal
94
+ g.dismissed = dismissed
95
+
96
+
97
+ def _forget_dismissals(person_id):
98
+ for pair in [p for p in g.dismissed if person_id in p]:
99
+ g.dismissed.discard(pair)
100
+
101
+
102
+ def _session_payload():
103
+ return {
104
+ "people": g.gallery.summary(),
105
+ "total_embeddings": sum(len(v) for v in g.gallery.data.values()),
106
+ "duplicates": P.find_duplicates(g.gallery.data, g.dismissed),
107
+ }
108
+
109
+
110
+ # --------------------------------------------------------------------------
111
+ # routes
112
+ # --------------------------------------------------------------------------
113
+ @app.get("/")
114
+ @app.get("/health")
115
+ def index():
116
+ return jsonify({
117
+ "status": "online",
118
+ "service": "Face Recognition Backend API",
119
+ "model_ready": _status["ready"]
120
+ })
121
+
122
+
123
+ @app.get("/api/status")
124
+ def status():
125
+ return jsonify({**_status, "people": len(g.gallery.data) if hasattr(g, "gallery") else 0})
126
+
127
+
128
+ @app.get("/api/session")
129
+ def session_info():
130
+ return jsonify(_session_payload())
131
+
132
+
133
+ @app.post("/api/session/reset")
134
+ def session_reset():
135
+ _replace_gallery(P.Gallery())
136
+ return jsonify({"ok": True})
137
+
138
+
139
+ @app.post("/api/enroll")
140
+ def enroll():
141
+ body = request.get_json(force=True, silent=True) or {}
142
+ person_id = (body.get("person_id") or "").strip()
143
+ images = body.get("images") or []
144
+
145
+ if not person_id:
146
+ return jsonify({"error": "a name is required"}), 400
147
+ if not images:
148
+ return jsonify({"error": "no images supplied"}), 400
149
+ if not _status["ready"]:
150
+ return jsonify({"error": "models still loading"}), 503
151
+
152
+ results = []
153
+ added = 0
154
+ for idx, data_url in enumerate(images):
155
+ try:
156
+ bgr = P.decode_data_url(data_url)
157
+ faces = P.extract_faces(bgr)
158
+ if not faces:
159
+ results.append({"index": idx, "ok": False, "reason": "no face detected"})
160
+ continue
161
+ if len(faces) > 1:
162
+ results.append({
163
+ "index": idx, "ok": False,
164
+ "reason": f"{len(faces)} faces in frame - register one at a time",
165
+ })
166
+ continue
167
+
168
+ face = faces[0]
169
+ g.gallery.add(person_id, P.embed(face["crop_bgr"]))
170
+ added += 1
171
+ results.append({
172
+ "index": idx,
173
+ "ok": True,
174
+ "confidence": round(face["confidence"], 4),
175
+ "crop": P.encode_jpeg_data_url(face["crop_bgr"], quality=75),
176
+ })
177
+ except Exception as exc:
178
+ results.append({"index": idx, "ok": False, "reason": str(exc)})
179
+
180
+ return jsonify({
181
+ "person_id": person_id,
182
+ "added": added,
183
+ "total": len(g.gallery.data.get(person_id, [])),
184
+ "cohesion": P.cohesion(g.gallery.data.get(person_id, [])),
185
+ "results": results,
186
+ })
187
+
188
+
189
+ @app.post("/api/recognize")
190
+ def recognize():
191
+ body = request.get_json(force=True, silent=True) or {}
192
+ data_url = body.get("image")
193
+
194
+ if not data_url:
195
+ return jsonify({"error": "image is required"}), 400
196
+ if not _status["ready"]:
197
+ return jsonify({"error": "models still loading"}), 503
198
+ if not g.gallery.data:
199
+ return jsonify({"error": "nobody registered yet"}), 400
200
+
201
+ t0 = time.time()
202
+ try:
203
+ bgr = P.decode_data_url(data_url)
204
+ h, w = bgr.shape[:2]
205
+ faces = P.extract_faces(bgr)
206
+ except Exception as exc:
207
+ return jsonify({"error": str(exc)}), 400
208
+
209
+ out = []
210
+ for face in faces:
211
+ ranked = g.gallery.rank(P.embed(face["crop_bgr"]))
212
+ top = ranked[0] if ranked else None
213
+ runner = ranked[1] if len(ranked) > 1 else None
214
+ out.append({
215
+ "box": face["box"],
216
+ "confidence": round(face["confidence"], 4),
217
+ "crop": P.encode_jpeg_data_url(face["crop_bgr"], quality=70),
218
+ "ranked": [{"id": r["id"], "score": round(r["score"], 4)} for r in ranked],
219
+ "top": {"id": top["id"], "score": round(top["score"], 4)} if top else None,
220
+ "margin": round(top["score"] - runner["score"], 4) if runner else None,
221
+ })
222
+
223
+ return jsonify({
224
+ "faces": out,
225
+ "frame": {"width": w, "height": h},
226
+ "elapsed_ms": int((time.time() - t0) * 1000),
227
+ })
228
+
229
+
230
+ @app.delete("/api/person/<path:person_id>")
231
+ def delete_person(person_id):
232
+ ok = g.gallery.remove(person_id)
233
+ _forget_dismissals(person_id)
234
+ return jsonify({"ok": ok})
235
+
236
+
237
+ @app.post("/api/person/merge")
238
+ def merge_people():
239
+ body = request.get_json(force=True, silent=True) or {}
240
+ source = (body.get("source") or "").strip()
241
+ target = (body.get("target") or "").strip()
242
+ try:
243
+ g.gallery.merge(source, target)
244
+ except ValueError as exc:
245
+ return jsonify({"error": str(exc)}), 400
246
+ _forget_dismissals(source)
247
+ return jsonify({"ok": True, **_session_payload()})
248
+
249
+
250
+ @app.post("/api/duplicates/dismiss")
251
+ def dismiss_duplicate():
252
+ body = request.get_json(force=True, silent=True) or {}
253
+ a = (body.get("a") or "").strip()
254
+ b = (body.get("b") or "").strip()
255
+ if not a or not b or a == b or a not in g.gallery.data or b not in g.gallery.data:
256
+ return jsonify({"error": "pick two different registered people"}), 400
257
+ g.dismissed.add(frozenset((a, b)))
258
+ return jsonify({"ok": True, **_session_payload()})
259
+
260
+
261
+ @app.get("/api/session/export.pkl")
262
+ def export_pkl():
263
+ if not g.gallery.data:
264
+ return jsonify({"error": "nothing to export"}), 400
265
+ resp = make_response(g.gallery.to_pickle_bytes())
266
+ resp.headers["Content-Type"] = "application/octet-stream"
267
+ resp.headers["Content-Disposition"] = 'attachment; filename="student_embeddings.pkl"'
268
+ return resp
269
+
270
+
271
+ @app.get("/api/session/export.json")
272
+ def export_json():
273
+ return jsonify({
274
+ "embeddings": g.gallery.to_json(),
275
+ "dismissed": [sorted(p) for p in g.dismissed],
276
+ })
277
+
278
+
279
+ @app.post("/api/session/import")
280
+ def import_gallery():
281
+ body = request.get_json(force=True, silent=True) or {}
282
+ try:
283
+ if body.get("json") is not None:
284
+ gal = P.Gallery.from_json(body["json"])
285
+ elif body.get("pkl_b64"):
286
+ gal = P.Gallery.from_pickle_bytes(base64.b64decode(body["pkl_b64"]))
287
+ else:
288
+ return jsonify({"error": "send either 'json' or 'pkl_b64'"}), 400
289
+ except Exception as exc:
290
+ return jsonify({"error": f"could not load: {exc}"}), 400
291
+
292
+ dismissed = set()
293
+ for pair in body.get("dismissed") or []:
294
+ if (isinstance(pair, (list, tuple)) and len(pair) == 2
295
+ and all(isinstance(x, str) and x in gal.data for x in pair)
296
+ and pair[0] != pair[1]):
297
+ dismissed.add(frozenset(pair))
298
+
299
+ _replace_gallery(gal, dismissed)
300
+ return jsonify({"ok": True, **_session_payload()})
301
+
302
+
303
+ if __name__ == "__main__":
304
+ port = int(os.environ.get("PORT", "7860"))
305
+ print(f"\n Face recognition API -> http://0.0.0.0:{port}\n")
306
+ app.run(host="0.0.0.0", port=port, debug=False, threaded=True)
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,9 @@
 
 
 
 
 
 
 
 
 
 
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
+ flask>=2.2
8
+ flask-cors>=3.0.10
9
+ gunicorn