Attendance / face_engine.py
Aruplayz's picture
Upload 3 files
471425b verified
Raw
History Blame Contribute Delete
28.8 kB
"""
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