Spaces:
Sleeping
Sleeping
| """ | |
| face_engine.py β Hybrid Face Recognition Engine | |
| PRIMARY : InsightFace ArcFace (engine_insight.py) β 512-dim learned embedding. | |
| This is the actual identity signal: trained specifically to be | |
| pose/lighting-robust and discriminative between different people. | |
| FALLBACK : 3D-aware geometric mesh fingerprint (this file) β used only when | |
| ArcFace can't load (missing dependency / model download blocked) | |
| or can't detect a face that MediaPipe still finds. Built from | |
| MediaPipe's 478-point face mesh using x, y, AND z (relative depth) | |
| for every landmark β previous versions discarded z entirely, | |
| which made the fingerprint unnecessarily fragile to head turns. | |
| Note on "3D": MediaPipe's z is a monocular depth *estimate* from | |
| a neural net, not a measured depth-sensor value. It meaningfully | |
| improves pose-robustness over 2D-only ratios, but it is not a | |
| substitute for ArcFace's discriminative power β hence ArcFace | |
| stays primary whenever it's available. | |
| Storage (see database.py β NOT modified by this rewrite, to avoid touching | |
| the working enrollment/webcam UI in app.py): every enrolled pose's | |
| fingerprint still lands in the existing "insight" list field exactly as | |
| before. What changed is WHAT gets put in that list: extract_fingerprint() | |
| now tries ArcFace first and stores a 512-dim embedding when it succeeds, | |
| falling back to the 196-dim 3D-geometric vector only when ArcFace can't | |
| produce one for that pose. A single student's stored list can therefore | |
| contain a mix of 512-dim and 196-dim vectors across their enrolled poses. | |
| At matching time, vectors are disambiguated purely by length (512 = ArcFace, | |
| anything else = geometric) β see _get_stored_vectors() below β so a face | |
| extracted via one engine is only ever compared against stored vectors from | |
| that same engine, never across the two incompatible similarity scales. | |
| Mesh drawing (the visual 478-point overlay) is unrelated to matching and is | |
| unchanged β it's MediaPipe output rendered for the UI either way. | |
| """ | |
| import os, urllib.request, math, threading, logging | |
| import cv2 | |
| import numpy as np | |
| from collections import defaultdict | |
| import mediapipe as mp | |
| from mediapipe.tasks import python as mp_python | |
| from mediapipe.tasks.python import vision as mp_vision | |
| import engine_insight as _arcface # primary matcher; degrades gracefully if unavailable | |
| logger = logging.getLogger(__name__) | |
| # ββ Model βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MODEL_FILE = "face_landmarker.task" | |
| MODEL_URL = ( | |
| "https://storage.googleapis.com/mediapipe-models/" | |
| "face_landmarker/face_landmarker/float16/1/face_landmarker.task" | |
| ) | |
| # ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| THRESHOLD = 0.75 # geometric-fallback cosine threshold (unchanged scale) | |
| ARCFACE_THRESHOLD = 0.45 # ArcFace cosine similarity threshold β DIFFERENT scale | |
| # than the geometric method; ArcFace embeddings are | |
| # trained so that genuine pairs cluster much tighter, | |
| # so its threshold is calibrated separately and is | |
| # NOT comparable in magnitude to THRESHOLD above. | |
| # ββ Colours βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| DOT_COLOR = (0, 255, 180) | |
| OVAL_COLOR = (0, 220, 80) | |
| EYE_COLOR = (0, 120, 255) | |
| LIP_COLOR = (0, 180, 255) | |
| IRIS_COLOR = (0, 230, 255) | |
| KNOWN_COLOR = (30, 210, 60) # green β recognised face | |
| UNKNOWN_COLOR = (0, 0, 220) # red β unknown face (BGR: B=0, G=0, R=220) | |
| # ββ Mesh connections ββββββββββββββββββββββββββββββββββββββββββ | |
| FACE_OVAL = [ | |
| (10,338),(338,297),(297,332),(332,284),(284,251),(251,389),(389,356),(356,454), | |
| (454,323),(323,361),(361,288),(288,397),(397,365),(365,379),(379,378),(378,400), | |
| (400,377),(377,152),(152,148),(148,176),(176,149),(149,150),(150,136),(136,172), | |
| (172,58),(58,132),(132,93),(93,234),(234,127),(127,162),(162,21),(21,54), | |
| (54,103),(103,67),(67,109),(109,10), | |
| ] | |
| LEFT_EYE = [ | |
| (33,7),(7,163),(163,144),(144,145),(145,153),(153,154),(154,155),(155,133), | |
| (33,246),(246,161),(161,160),(160,159),(159,158),(158,157),(157,173),(173,133), | |
| ] | |
| RIGHT_EYE = [ | |
| (362,382),(382,381),(381,380),(380,374),(374,373),(373,390),(390,249),(249,263), | |
| (362,398),(398,384),(384,385),(385,386),(386,387),(387,388),(388,466),(466,263), | |
| ] | |
| LIPS = [ | |
| (61,146),(146,91),(91,181),(181,84),(84,17),(17,314),(314,405),(405,321), | |
| (321,375),(375,291),(61,185),(185,40),(40,39),(39,37),(37,0),(0,267), | |
| (267,269),(269,270),(270,409),(409,291), | |
| ] | |
| LEFT_IRIS = [(468,469),(469,470),(470,471),(471,472),(472,468)] | |
| RIGHT_IRIS = [(473,474),(474,475),(475,476),(476,477),(477,473)] | |
| ALL_CONNECTIONS = FACE_OVAL + LEFT_EYE + RIGHT_EYE + LIPS + LEFT_IRIS + RIGHT_IRIS | |
| # Pre-build adjacency for angle computation | |
| _ADJ = defaultdict(set) | |
| for _a, _b in ALL_CONNECTIONS: | |
| _ADJ[_a].add(_b) | |
| _ADJ[_b].add(_a) | |
| ANGLE_VERTS = sorted([v for v, nb in _ADJ.items() if len(nb) >= 2]) | |
| CONN_COLORS = ( | |
| [(c, OVAL_COLOR) for c in FACE_OVAL] + | |
| [(c, EYE_COLOR) for c in LEFT_EYE] + | |
| [(c, EYE_COLOR) for c in RIGHT_EYE] + | |
| [(c, LIP_COLOR) for c in LIPS] + | |
| [(c, IRIS_COLOR) for c in LEFT_IRIS] + | |
| [(c, IRIS_COLOR) for c in RIGHT_IRIS] | |
| ) | |
| # ββ Landmarker singletons (Tasks API) βββββββββββββββββββββββββ | |
| _lm_static = None # IMAGE mode β enrollment photos | |
| _lm_video = None # IMAGE mode with fresh instance β live cam | |
| _init_lock = threading.Lock() | |
| def _ensure_model(): | |
| if not os.path.exists(MODEL_FILE): | |
| print("[face_engine] Downloading face_landmarker.task (~5 MB)β¦") | |
| urllib.request.urlretrieve(MODEL_URL, MODEL_FILE) | |
| print("[face_engine] Model downloaded.") | |
| def _make_landmarker(num_faces=4): | |
| opts = mp_vision.FaceLandmarkerOptions( | |
| base_options=mp_python.BaseOptions(model_asset_path=MODEL_FILE), | |
| num_faces=num_faces, | |
| min_face_detection_confidence=0.30, | |
| min_face_presence_confidence=0.30, | |
| min_tracking_confidence=0.30, | |
| running_mode=mp_vision.RunningMode.IMAGE, | |
| ) | |
| return mp_vision.FaceLandmarker.create_from_options(opts) | |
| def _ensure_static(): | |
| global _lm_static | |
| if _lm_static is None: | |
| with _init_lock: | |
| if _lm_static is None: | |
| _ensure_model() | |
| _lm_static = _make_landmarker(num_faces=4) | |
| print("[face_engine] static landmarker ready") | |
| def _ensure_video(): | |
| global _lm_video | |
| if _lm_video is None: | |
| with _init_lock: | |
| if _lm_video is None: | |
| _ensure_model() | |
| _lm_video = _make_landmarker(num_faces=4) | |
| print("[face_engine] video landmarker ready") | |
| def warmup(): | |
| """Pre-warm both MediaPipe landmarkers and the ArcFace model. Safe to call multiple times.""" | |
| _ensure_static() | |
| _ensure_video() | |
| arcface_ready = False | |
| try: | |
| arcface_ready = _arcface.available() | |
| except Exception as e: | |
| logger.warning(f"ArcFace warmup check failed: {e}") | |
| engine_status = "ArcFace (primary)" if arcface_ready else "3D-geometric ONLY β ArcFace unavailable, check insightface install/model download" | |
| print(f"[face_engine] Ready. Connections={len(ALL_CONNECTIONS)}, " | |
| f"Geometric fallback=196-dim (3D), Geo threshold={THRESHOLD:.0%}, " | |
| f"ArcFace threshold={ARCFACE_THRESHOLD:.0%}, Active matcher: {engine_status}") | |
| # ββ Detection βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _MAX_VIDEO_DIM = 640 # cap longest side for live-cam frames; landmarks are | |
| # normalized [0,1] so downscaling doesn't change results, | |
| # it just cuts MediaPipe's per-frame compute cost. | |
| def _detect(frame_bgr, static=True): | |
| """ | |
| Returns list of landmark lists, one per face. | |
| Each landmark list is a plain list of NormalizedLandmark objects. | |
| """ | |
| if static: | |
| _ensure_static() | |
| lm = _lm_static | |
| else: | |
| _ensure_video() | |
| lm = _lm_video | |
| # Downscale only the live/video path β enrollment photos stay full-res | |
| # since they're processed once, not on every snapshot click. | |
| h, w = frame_bgr.shape[:2] | |
| longest = max(h, w) | |
| if longest > _MAX_VIDEO_DIM: | |
| scale = _MAX_VIDEO_DIM / longest | |
| frame_bgr = cv2.resize(frame_bgr, (int(w * scale), int(h * scale)), | |
| interpolation=cv2.INTER_AREA) | |
| if lm is None: | |
| return [] | |
| rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB) | |
| mp_img = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb) | |
| result = lm.detect(mp_img) | |
| # result.face_landmarks is a list of lists of NormalizedLandmark | |
| return result.face_landmarks if result.face_landmarks else [] | |
| # ββ Geometric fingerprint (3D-aware) βββββββββββββββββββββββββββ | |
| # Precompute connection index arrays once at import time (not per-call) | |
| _CONN_A = np.array([a for a, b in ALL_CONNECTIONS], dtype=np.int32) | |
| _CONN_B = np.array([b for a, b in ALL_CONNECTIONS], dtype=np.int32) | |
| def _landmarks_to_vector(lms): | |
| """ | |
| 196-dim geometric fingerprint, now built from full 3D (x, y, z) landmark | |
| coordinates instead of x,y only. | |
| [0 :98] = 3D Euclidean length of each drawn segment / inter-eye dist | |
| (scale-invariant; previously 2D-only, now includes depth so | |
| a segment that's actually foreshortened by head rotation | |
| measures closer to its true length instead of its | |
| projected β and rotation-shrunk β 2D length) | |
| [98:196] = angle at each mesh junction, computed in full 3D via the | |
| dot-product formula (rotation-invariant in 3D, not just | |
| in-plane) / Ο | |
| MediaPipe's z is a monocular depth *estimate*, not a measured depth β but | |
| even an imperfect depth estimate corrects a real source of error: pure | |
| 2D ratios change non-uniformly when a head turns (near-camera features | |
| appear to separate, far-camera features appear to compress). Including z | |
| lets the same physical 3D distance/angle be recovered more consistently | |
| across head poses than x,y alone ever could. | |
| Vectorized: builds one (N,3) coordinate array up front instead of | |
| allocating a tiny NumPy array per landmark/pair. | |
| """ | |
| pts = np.empty((len(lms), 3), dtype=np.float64) | |
| for i, lm in enumerate(lms): | |
| pts[i, 0] = lm.x | |
| pts[i, 1] = lm.y | |
| pts[i, 2] = lm.z # relative depth estimate from MediaPipe | |
| U = float(np.linalg.norm(pts[33] - pts[263])) # inter-outer-eye distance, now 3D | |
| if U < 1e-6: | |
| return None | |
| # Part 1 β all segment lengths / U in one batched operation (98 features) | |
| seg_lengths = np.linalg.norm(pts[_CONN_A] - pts[_CONN_B], axis=1) / U | |
| feats = list(seg_lengths) | |
| # Part 2 β junction angles / Ο, computed in 3D via dot product (98 features) | |
| for v in ANGLE_VERTS: | |
| pv = pts[v] | |
| vecs = [] | |
| for n in sorted(_ADJ[v]): | |
| vec = pts[n] - pv | |
| norm = float(np.linalg.norm(vec)) | |
| if norm > 1e-9: | |
| vecs.append(vec / norm) # unit vector in 3D | |
| if len(vecs) >= 2: | |
| diffs = [] | |
| for i in range(len(vecs)): | |
| for j in range(i + 1, len(vecs)): | |
| # angle between two 3D unit vectors via dot product, | |
| # clamped against float drift pushing |dot| slightly > 1 | |
| dot = float(np.clip(np.dot(vecs[i], vecs[j]), -1.0, 1.0)) | |
| diffs.append(math.acos(dot) / math.pi) | |
| feats.append(float(np.mean(diffs))) | |
| else: | |
| feats.append(0.0) | |
| vec = np.array(feats, dtype=np.float32) | |
| norm = np.linalg.norm(vec) | |
| return (vec / norm).tolist() if norm > 1e-6 else None | |
| def _cosine_sim(a, b): | |
| a = np.array(a, dtype=np.float32) | |
| b = np.array(b, dtype=np.float32) | |
| na = np.linalg.norm(a) | |
| nb = np.linalg.norm(b) | |
| if na < 1e-9 or nb < 1e-9: | |
| return 0.0 | |
| return float(np.dot(a, b) / (na * nb)) | |
| # ββ Drawing βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _draw_mesh(frame, lms, w, h): | |
| n = len(lms) | |
| # Dots at every landmark | |
| for lm in lms: | |
| cx, cy = int(lm.x * w), int(lm.y * h) | |
| if 0 <= cx < w and 0 <= cy < h: | |
| cv2.circle(frame, (cx, cy), 1, DOT_COLOR, -1) | |
| # Connected line segments with colour per region | |
| for (a, b), color in CONN_COLORS: | |
| if a < n and b < n: | |
| p1 = (int(lms[a].x * w), int(lms[a].y * h)) | |
| p2 = (int(lms[b].x * w), int(lms[b].y * h)) | |
| cv2.line(frame, p1, p2, color, 1, cv2.LINE_AA) | |
| def _get_bbox(lms, w, h, pad=22): | |
| xs = [lm.x * w for lm in lms] | |
| ys = [lm.y * h for lm in lms] | |
| return (max(0, int(min(xs)) - pad), max(0, int(min(ys)) - pad), | |
| min(w, int(max(xs)) + pad), min(h, int(max(ys)) + pad)) | |
| def _draw_box(frame, x1, y1, x2, y2, name, score, color): | |
| cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) | |
| L = 18 | |
| for cx, cy, dx, dy in [(x1,y1,1,1),(x2,y1,-1,1),(x1,y2,1,-1),(x2,y2,-1,-1)]: | |
| cv2.line(frame, (cx, cy), (cx + dx*L, cy), color, 3, cv2.LINE_AA) | |
| cv2.line(frame, (cx, cy), (cx, cy + dy*L), color, 3, cv2.LINE_AA) | |
| label = f" {name} {score:.0%}" if name != "Unknown" else f" Unknown {score:.0%}" | |
| (lw, lh), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.60, 1) | |
| cv2.rectangle(frame, (x1, y1 - lh - 14), (x1 + lw + 8, y1), color, -1) | |
| cv2.putText(frame, label, (x1 + 4, y1 - 5), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.60, | |
| (0, 0, 0) if name != "Unknown" else (255, 255, 255), | |
| 1, cv2.LINE_AA) | |
| # ββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Vectors are disambiguated purely by length at matching time: ArcFace | |
| # embeddings are 512-dim, the geometric fallback is 196-dim. No schema | |
| # change needed in database.py β app.py keeps collecting "one fingerprint | |
| # per enrolled pose" exactly as before, unaware of which engine produced it. | |
| _ARCFACE_DIM = 512 | |
| def _extract_arcface(frame_bgr): | |
| """ | |
| Try the primary ArcFace path on a still frame. Returns a 512-dim | |
| embedding (list) or None if ArcFace is unavailable or found no face. | |
| Never raises β engine_insight.py already degrades to None internally | |
| if the model failed to load (e.g. no network access for the model | |
| download), and we treat any other exception here the same way: fall | |
| through to the geometric method rather than crashing enrollment. | |
| """ | |
| try: | |
| faces = _arcface.get_faces(frame_bgr) | |
| except Exception as e: | |
| logger.warning(f"ArcFace extraction failed, falling back to geometric: {e}") | |
| return None | |
| if not faces: | |
| return None | |
| # Pick the largest detected face (by bbox area) as the primary subject β | |
| # mirrors _detect()'s "first face" convention for enrollment photos. | |
| best = max(faces, key=lambda f: f["bbox"][2] * f["bbox"][3]) | |
| return best.get("embedding") | |
| def extract_fingerprint(image_path): | |
| """ | |
| Extract one fingerprint from an image file for enrollment. | |
| Tries ArcFace (primary, 512-dim) first; falls back to the 3D-aware | |
| geometric mesh fingerprint (196-dim) if ArcFace can't produce an | |
| embedding for this image. Returns a flat list or None. | |
| """ | |
| frame = cv2.imread(str(image_path)) | |
| if frame is None: | |
| return None | |
| arcface_vec = _extract_arcface(frame) | |
| if arcface_vec is not None: | |
| return arcface_vec | |
| faces = _detect(frame, static=True) | |
| if not faces: | |
| return None | |
| return _landmarks_to_vector(faces[0]) | |
| def average_fingerprints(fps): | |
| """Average multiple fingerprint vectors and re-normalise to unit length.""" | |
| arr = np.array(fps, dtype=np.float32) | |
| avg = arr.mean(axis=0) | |
| norm = np.linalg.norm(avg) | |
| return (avg / norm).tolist() if norm > 1e-6 else avg.tolist() | |
| def process_photo(image_path): | |
| """ | |
| Draw connected 478-point mesh on a still photo (enrollment preview). | |
| Returns (annotated_rgb_ndarray, face_count, vector_or_None). | |
| """ | |
| frame = cv2.imread(str(image_path)) | |
| if frame is None: | |
| return None, 0, None | |
| h, w = frame.shape[:2] | |
| ann = frame.copy() | |
| faces = _detect(ann, static=True) | |
| vec = None | |
| for lms in faces: | |
| _draw_mesh(ann, lms, w, h) | |
| x1, y1, x2, y2 = _get_bbox(lms, w, h) | |
| cv2.rectangle(ann, (x1, y1), (x2, y2), KNOWN_COLOR, 2) | |
| L = 16 | |
| for cx, cy, dx, dy in [(x1,y1,1,1),(x2,y1,-1,1),(x1,y2,1,-1),(x2,y2,-1,-1)]: | |
| cv2.line(ann, (cx, cy), (cx + dx*L, cy), KNOWN_COLOR, 3, cv2.LINE_AA) | |
| cv2.line(ann, (cx, cy), (cx, cy + dy*L), KNOWN_COLOR, 3, cv2.LINE_AA) | |
| label = " 478pts mesh (visual ref) | ArcFace embedding stored " | |
| (lw, lh), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.50, 1) | |
| cv2.rectangle(ann, (x1, y1 - lh - 12), (x1 + lw + 6, y1), KNOWN_COLOR, -1) | |
| cv2.putText(ann, label, (x1 + 3, y1 - 4), | |
| cv2.FONT_HERSHEY_SIMPLEX, 0.50, (0, 0, 0), 1, cv2.LINE_AA) | |
| if vec is None: | |
| vec = _landmarks_to_vector(lms) # geometric vector, kept for preview/back-compat only; | |
| # the actual stored fingerprint comes from | |
| # extract_fingerprint(), which tries ArcFace first | |
| return cv2.cvtColor(ann, cv2.COLOR_BGR2RGB), len(faces), vec | |
| def _get_stored_vectors(data, engine): | |
| """ | |
| Return this student's stored pose vectors that match the requested | |
| engine ("arcface" or "geo"), disambiguated purely by vector length β | |
| ArcFace embeddings are 512-dim, geometric fingerprints are 196-dim. | |
| No schema change to database.py was needed: enrollment may have mixed | |
| engines across poses (e.g. ArcFace was available for "Front" but failed | |
| for "Left"), so a single student's stored list can legitimately contain | |
| both vector lengths. We only ever return the ones matching the engine | |
| the CURRENT face was extracted with, so a 512-dim face vector is never | |
| compared against a 196-dim stored vector or vice versa. | |
| Reads from whichever legacy/current key the data happens to use | |
| ("insight", "vector", "embedding", "fingerprint", "geo", "arcface") β | |
| same tolerant lookup the rest of this codebase already relies on. | |
| """ | |
| raw = (data.get("arcface") or data.get("geo") or data.get("insight") or | |
| data.get("vector") or data.get("embedding") or data.get("fingerprint") or []) | |
| if not raw: | |
| return [] | |
| # Normalise to a list-of-vectors regardless of whether it's stored as | |
| # a single flat vector (legacy single-pose enrollment) or a list of | |
| # pose vectors (current multi-pose enrollment). | |
| if isinstance(raw[0], (int, float)): | |
| pose_vectors = [raw] | |
| else: | |
| pose_vectors = raw | |
| target_dim = _ARCFACE_DIM if engine == "arcface" else None | |
| matches = [] | |
| for pv in pose_vectors: | |
| if not pv: | |
| continue | |
| if engine == "arcface": | |
| if len(pv) == _ARCFACE_DIM: | |
| matches.append(pv) | |
| else: | |
| if len(pv) != _ARCFACE_DIM: # anything not 512-dim is treated as geometric | |
| matches.append(pv) | |
| return matches | |
| def process_frame(frame_bgr, embs): | |
| """ | |
| Detect all faces in a webcam frame, draw connected mesh + boxes, run recognition. | |
| embs: dict {sid: {name, insight/geo/arcface: list-of-pose-vectors, ...}} | |
| Returns (annotated_bgr, detections). | |
| Each detection: {matched, student_id, name, confidence, engine} | |
| Matching strategy: | |
| 1. ENGINE PER FACE β for each detected face, try ArcFace first (primary, | |
| 512-dim, learned embedding). If ArcFace can't produce an embedding | |
| for that specific face (model unavailable, or this particular crop | |
| failed ArcFace's own detector), fall back to the 3D-aware geometric | |
| mesh fingerprint (196-dim) for that face only. Different faces in | |
| the same frame may end up on different engines β that's fine, each | |
| face's own best-available signal is used independently. | |
| 2. STORED-VECTOR MATCHING β a student's stored vectors may be a mix of | |
| 512-dim (ArcFace) and 196-dim (geometric) poses, since enrollment | |
| also tries ArcFace-first-with-fallback per pose. We only ever compare | |
| like-for-like: an ArcFace face-vector is compared only against the | |
| student's stored ArcFace-dim vectors, a geometric face-vector only | |
| against stored geometric-dim vectors. The two scales are never mixed | |
| in a single cosine_sim call. | |
| 3. CROSS-ENGINE-SAFE GLOBAL ASSIGNMENT β ArcFace cosine similarity and | |
| geometric cosine similarity live on different scales (different | |
| thresholds), so raw scores can't be sorted together fairly: a | |
| mediocre geometric 0.80 must not outrank a strong ArcFace 0.50 just | |
| because the raw number is bigger. Before the global one-to-one | |
| assignment, every candidate score is normalised to "how far above | |
| its OWN engine's threshold it is" β only that normalised margin is | |
| used for cross-face/cross-engine ranking. The original raw score is | |
| still what's displayed and still what's checked against threshold | |
| for the final matched/unmatched decision. | |
| The one-to-one assignment itself is otherwise unchanged from before: | |
| every face used to be matched independently with no constraint | |
| stopping two different faces from both claiming the same identity. | |
| We still compute the full (face, student) candidate set first, sort | |
| by normalised confidence, and greedily assign β once a face or a | |
| student is claimed, neither can be reused β so identities stay | |
| unique within a single frame. | |
| """ | |
| h, w = frame_bgr.shape[:2] | |
| ann = frame_bgr.copy() | |
| faces = _detect(ann, static=False) | |
| # Step 1 β for every detected face, draw the mesh (always, for the UI) | |
| # and get its best-available fingerprint: ArcFace if possible, else the | |
| # 3D geometric fallback. Track which engine produced it. | |
| face_vecs = [] # the vector itself | |
| face_engines = [] # "arcface" or "geo", or None if extraction failed entirely | |
| for lms in faces: | |
| _draw_mesh(ann, lms, w, h) | |
| # ArcFace runs on the whole frame at once (it does its own face | |
| # detection internally) rather than per-MediaPipe-face-crop, since | |
| # InsightFace's own detector + alignment is what it was trained against. | |
| arcface_faces = [] | |
| try: | |
| arcface_faces = _arcface.get_faces(frame_bgr) | |
| except Exception as e: | |
| logger.warning(f"ArcFace frame extraction failed, using geometric fallback: {e}") | |
| # Match MediaPipe's faces to ArcFace's faces by bounding-box overlap, so | |
| # each MediaPipe-detected face (which we already have landmarks/mesh | |
| # for) gets paired with the right ArcFace embedding if one exists. | |
| def _bbox_from_lms(lms): | |
| x1, y1, x2, y2 = _get_bbox(lms, w, h, pad=0) | |
| return (x1, y1, x2 - x1, y2 - y1) | |
| def _iou(b1, b2): | |
| ax1, ay1, aw, ah = b1; ax2, ay2 = ax1 + aw, ay1 + ah | |
| bx1, by1, bw, bh = b2; bx2, by2 = bx1 + bw, by1 + bh | |
| ix1, iy1 = max(ax1, bx1), max(ay1, by1) | |
| ix2, iy2 = min(ax2, bx2), min(ay2, by2) | |
| iw, ih = max(0, ix2 - ix1), max(0, iy2 - iy1) | |
| inter = iw * ih | |
| union = aw * ah + bw * bh - inter | |
| return inter / union if union > 0 else 0.0 | |
| for lms in faces: | |
| mp_bbox = _bbox_from_lms(lms) | |
| best_iou, best_af = 0.0, None | |
| for af in arcface_faces: | |
| iou = _iou(mp_bbox, af["bbox"]) | |
| if iou > best_iou: | |
| best_iou, best_af = iou, af | |
| if best_af is not None and best_iou > 0.3 and best_af.get("embedding") is not None: | |
| face_vecs.append(best_af["embedding"]) | |
| face_engines.append("arcface") | |
| else: | |
| vec = _landmarks_to_vector(lms) | |
| face_vecs.append(vec) | |
| face_engines.append("geo" if vec is not None else None) | |
| # Step 2 β build the full (face, student) candidate set, comparing each | |
| # face only against same-engine stored vectors for that student. | |
| candidates = [] # list of (raw_score, normalised_score, face_idx, sid, name, engine) | |
| for face_idx, vec in enumerate(face_vecs): | |
| if vec is None or face_engines[face_idx] is None: | |
| continue | |
| engine = face_engines[face_idx] | |
| engine_threshold = ARCFACE_THRESHOLD if engine == "arcface" else THRESHOLD | |
| for sid, data in embs.items(): | |
| stored = _get_stored_vectors(data, engine) | |
| if not stored: | |
| continue | |
| best_for_pair = 0.0 | |
| for pv in stored: | |
| sim = _cosine_sim(vec, pv) | |
| if sim > best_for_pair: | |
| best_for_pair = sim | |
| # Normalise: how far above this engine's own threshold, as a | |
| # ratio β lets candidates from different engines be ranked | |
| # against each other fairly during global assignment. | |
| normalised = best_for_pair / engine_threshold if engine_threshold > 1e-9 else 0.0 | |
| candidates.append((best_for_pair, normalised, face_idx, sid, | |
| data.get("name", "Unknown"), engine)) | |
| # Step 3 β greedy global assignment using the NORMALISED score for | |
| # ranking (cross-engine-fair), but the RAW score for the actual | |
| # threshold check and for what gets displayed. | |
| candidates.sort(key=lambda c: c[1], reverse=True) | |
| assigned_face = {} # face_idx -> (raw_score, sid, name, engine) | |
| used_faces = set() | |
| used_students = set() | |
| for raw_score, normalised, face_idx, sid, name, engine in candidates: | |
| if face_idx in used_faces or sid in used_students: | |
| continue | |
| engine_threshold = ARCFACE_THRESHOLD if engine == "arcface" else THRESHOLD | |
| if raw_score < engine_threshold: | |
| continue # below this engine's own threshold β leave for Unknown pass | |
| assigned_face[face_idx] = (raw_score, sid, name, engine) | |
| used_faces.add(face_idx) | |
| used_students.add(sid) | |
| # Step 4 β draw + build detections for every face, matched or not. | |
| dets = [] | |
| for face_idx, lms in enumerate(faces): | |
| if face_vecs[face_idx] is None: | |
| continue | |
| if face_idx in assigned_face: | |
| score, sid, name, engine = assigned_face[face_idx] | |
| matched = True | |
| else: | |
| # Unmatched β still report the closest-scoring student (even | |
| # below threshold) so the UI can show "closest match: X" without | |
| # that face actually being granted the identity. | |
| best_score, best_name, best_sid, best_engine = 0.0, "Unknown", None, face_engines[face_idx] | |
| for raw_score, normalised, fidx, cand_sid, cand_name, cand_engine in candidates: | |
| if fidx == face_idx and raw_score > best_score: | |
| best_score, best_name, best_sid = raw_score, cand_name, cand_sid | |
| score, sid, name, matched = best_score, best_sid, best_name, False | |
| engine = best_engine | |
| name = "Unknown" | |
| sid = None | |
| x1, y1, x2, y2 = _get_bbox(lms, w, h) | |
| _draw_box(ann, x1, y1, x2, y2, name, score, | |
| KNOWN_COLOR if matched else UNKNOWN_COLOR) | |
| dets.append({ | |
| "matched": matched, | |
| "student_id": sid, | |
| "name": name, | |
| "confidence": score, | |
| "engine": engine, # "arcface" or "geo" β which engine produced this score | |
| }) | |
| return ann, dets | |