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

Deploy face attendance Gradio app

Browse files
Files changed (7) hide show
  1. .dockerignore +0 -10
  2. Dockerfile +0 -31
  3. README.md +10 -10
  4. app.py +0 -306
  5. packages.txt +2 -0
  6. pipeline.py +0 -425
  7. requirements.txt +0 -9
.dockerignore DELETED
@@ -1,10 +0,0 @@
1
- __pycache__
2
- *.pyc
3
- *.pyo
4
- *.pyd
5
- .git
6
- .github
7
- .env
8
- facenv
9
- venv
10
- .venv
 
 
 
 
 
 
 
 
 
 
 
Dockerfile DELETED
@@ -1,31 +0,0 @@
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: 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.
 
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
 
 
app.py DELETED
@@ -1,306 +0,0 @@
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)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
packages.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ libgl1
2
+ libglib2.0-0
pipeline.py DELETED
@@ -1,425 +0,0 @@
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 DELETED
@@ -1,9 +0,0 @@
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