""" 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