face-attendance-api / pipeline.py
AryanChougule
Restore face attendance Gradio app
64fbfd0
Raw History Blame Contribute Delete
15.6 kB
"""
Face pipeline for the live demo: detect -> crop -> preprocess -> embed -> match.
The one rule this module exists to enforce: enrollment and recognition run the
EXACT same code path. ArcFace embeddings are only comparable when the crop
geometry and lighting normalisation match, so both directions call
extract_faces() + embed() and nothing else.
Galleries are per-session and in-memory. Nothing is written to disk on the
server: the user's .pkl belongs to the user's device. The production
student_embeddings.pkl is never read or written here.
"""
import base64
import io
import pickle
import threading
import cv2
import numpy as np
from numpy.linalg import norm
MODEL_NAME = "ArcFace"
PAD_RATIO = 0.20 # matches main.py: 20% padding around the MTCNN box
DETECT_MAX_DIM = 640 # downscale before detection; boxes scaled back up
MIN_FACE_CONFIDENCE = 0.90
# TensorFlow graphs are not safe to call from multiple threads at once and
# Flask's dev server is threaded, so every inference goes through this lock.
_LOCK = threading.Lock()
_detector = None
# --------------------------------------------------------------------------
# model loading
# --------------------------------------------------------------------------
def get_detector():
global _detector
if _detector is None:
from mtcnn import MTCNN
_detector = MTCNN()
return _detector
def warmup():
"""Load MTCNN + ArcFace and run one dummy inference of each.
Called at server start so the first real request isn't a 30s stall.
"""
from deepface import DeepFace
dummy = np.zeros((160, 160, 3), dtype=np.uint8)
with _LOCK:
get_detector().detect_faces(cv2.cvtColor(dummy, cv2.COLOR_BGR2RGB))
DeepFace.represent(
img_path=dummy,
model_name=MODEL_NAME,
detector_backend="skip",
enforce_detection=False,
)
# --------------------------------------------------------------------------
# image codec helpers
# --------------------------------------------------------------------------
def decode_data_url(data_url):
"""A data: URL or bare base64 string -> BGR ndarray."""
if "," in data_url:
data_url = data_url.split(",", 1)[1]
raw = base64.b64decode(data_url)
buf = np.frombuffer(raw, dtype=np.uint8)
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
if img is None:
raise ValueError("could not decode image")
return img
def encode_jpeg_data_url(bgr, quality=85):
ok, buf = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), quality])
if not ok:
raise ValueError("could not encode image")
return "data:image/jpeg;base64," + base64.b64encode(buf.tobytes()).decode("ascii")
def l2_normalize(x):
x = np.asarray(x, dtype=np.float32)
return x / (norm(x) + 1e-10)
def cosine_similarity(a, b):
return float(np.dot(a, b) / (norm(a) * norm(b) + 1e-10))
# --------------------------------------------------------------------------
# detection + preprocessing (same logic as main.py's preprocess_face)
# --------------------------------------------------------------------------
def preprocess_face(face_rgb, keypoints=None, origin=None):
"""Adaptive gamma -> eye-alignment rotation -> Y-channel histogram EQ."""
try:
if face_rgb is None or face_rgb.size == 0:
return None
gray = cv2.cvtColor(face_rgb, cv2.COLOR_RGB2GRAY)
mean = float(np.mean(gray))
gamma = 1.0
if mean < 100:
gamma = 1.5
elif mean > 160:
gamma = 0.7
lut = np.array(
[((i / 255.0) ** (1.0 / gamma)) * 255 for i in np.arange(256)]
).astype("uint8")
face_rgb = cv2.LUT(face_rgb, lut)
if keypoints and "left_eye" in keypoints and "right_eye" in keypoints:
le, re = keypoints["left_eye"], keypoints["right_eye"]
if le is not None and re is not None and len(le) == 2 and len(re) == 2:
lx, ly = float(le[0]), float(le[1])
rx, ry = float(re[0]), float(re[1])
if not any(np.isnan(v) for v in (lx, ly, rx, ry)):
if origin is not None:
ox, oy = origin
lx -= ox
rx -= ox
ly -= oy
ry -= oy
angle = float(np.degrees(np.arctan2(ry - ly, rx - lx)))
h, w = face_rgb.shape[:2]
cx = max(0.0, min(float(w - 1), (lx + rx) / 2.0))
cy = max(0.0, min(float(h - 1), (ly + ry) / 2.0))
m = cv2.getRotationMatrix2D((cx, cy), angle, 1.0)
face_rgb = cv2.warpAffine(
face_rgb, m, (w, h),
flags=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE,
)
yuv = cv2.cvtColor(face_rgb, cv2.COLOR_RGB2YUV)
yuv[:, :, 0] = cv2.equalizeHist(yuv[:, :, 0])
return cv2.cvtColor(yuv, cv2.COLOR_YUV2RGB)
except Exception:
return face_rgb
def extract_faces(bgr):
"""Detect every face in a BGR frame.
Detection runs on a downscaled copy (MTCNN cost scales with pixel count)
but crops are taken from the full-resolution original so no detail is lost.
Returns list of {box, confidence, crop_bgr} sorted left-to-right.
"""
rgb_full = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
h, w = rgb_full.shape[:2]
scale = 1.0
rgb_small = rgb_full
if max(h, w) > DETECT_MAX_DIM:
scale = DETECT_MAX_DIM / float(max(h, w))
rgb_small = cv2.resize(
rgb_full, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
)
with _LOCK:
results = get_detector().detect_faces(rgb_small)
faces = []
for res in results:
if res.get("confidence", 0.0) < MIN_FACE_CONFIDENCE:
continue
try:
x, y, bw, bh = [int(round(v / scale)) for v in res["box"]]
except Exception:
continue
if bw <= 0 or bh <= 0:
continue
pad = int(max(1, min(bw, bh) * PAD_RATIO))
x1, y1 = max(0, x - pad), max(0, y - pad)
x2, y2 = min(w, x + bw + pad), min(h, y + bh + pad)
if x2 <= x1 or y2 <= y1:
continue
crop = rgb_full[y1:y2, x1:x2]
if crop.size == 0:
continue
kps = res.get("keypoints") or {}
kps = {k: (v[0] / scale, v[1] / scale) for k, v in kps.items() if v is not None}
processed = preprocess_face(crop, kps, origin=(x1, y1))
if processed is None or processed.size == 0:
continue
faces.append({
"box": [int(x), int(y), int(bw), int(bh)],
"confidence": float(res.get("confidence", 0.0)),
"crop_bgr": cv2.cvtColor(processed, cv2.COLOR_RGB2BGR),
})
faces.sort(key=lambda f: f["box"][0])
return faces
def embed(crop_bgr):
"""512-d L2-normalised ArcFace vector. Array in, no disk round-trip."""
from deepface import DeepFace
with _LOCK:
rep = DeepFace.represent(
img_path=crop_bgr,
model_name=MODEL_NAME,
detector_backend="skip",
enforce_detection=False,
)
return l2_normalize(rep[0]["embedding"])
# --------------------------------------------------------------------------
# gallery
# --------------------------------------------------------------------------
def cohesion(embs):
"""Mean pairwise similarity within one person's own embeddings.
Enrollment quality signal: low cohesion means the reference photos
disagree with each other, and matching against them will be unreliable.
"""
if len(embs) < 2:
return None
a = np.asarray(embs, dtype=np.float32)
sims = a @ a.T
n = len(a)
return float((sims.sum() - np.trace(sims)) / (n * (n - 1)))
class RestrictedUnpickler(pickle.Unpickler):
"""pickle.load() on user-supplied bytes is arbitrary code execution.
A .pkl uploaded from a browser is untrusted input, so imports are limited
to what a numpy embedding file legitimately needs. Anything else raises
instead of executing.
"""
_ALLOWED = {
("numpy", "ndarray"),
("numpy", "dtype"),
("numpy", "float32"),
("numpy", "float64"),
("numpy.core.multiarray", "_reconstruct"),
("numpy.core.multiarray", "scalar"),
("numpy._core.multiarray", "_reconstruct"),
("numpy._core.multiarray", "scalar"),
}
def find_class(self, module, name):
if (module, name) in self._ALLOWED:
return super().find_class(module, name)
raise pickle.UnpicklingError(f"blocked for safety: {module}.{name}")
def restricted_loads(raw):
return RestrictedUnpickler(io.BytesIO(raw)).load()
class Gallery:
"""{person_id: [embedding, ...]} with the same match rule as main.py:
a person's score is the MAX similarity over their enrolled embeddings.
Purely in-memory and scoped to one browser session. Persistence is the
client's job -- see to_pickle_bytes() / to_json().
"""
def __init__(self, data=None):
self.data = data or {}
# ---- serialisation -------------------------------------------------
def to_pickle_bytes(self):
"""Byte-identical in structure to what face_embd.py writes, so an
exported file drops straight into main.py with no conversion."""
return pickle.dumps(
{k: [np.asarray(e, dtype=np.float32) for e in v]
for k, v in self.data.items()},
protocol=4,
)
def to_json(self):
return {k: [[float(x) for x in e] for e in v] for k, v in self.data.items()}
@staticmethod
def _validate(obj):
if not isinstance(obj, dict):
raise ValueError("expected a dict of {person_id: [embedding, ...]}")
clean = {}
for pid, embs in obj.items():
if not isinstance(pid, str) or not pid.strip():
raise ValueError("person ids must be non-empty strings")
if not isinstance(embs, (list, tuple)) or not embs:
raise ValueError(f"'{pid}' has no embeddings")
out = []
for e in embs:
a = np.asarray(e, dtype=np.float32).ravel()
if a.size != 512 or not np.all(np.isfinite(a)):
raise ValueError(f"'{pid}' has a bad embedding (size {a.size})")
out.append(l2_normalize(a))
clean[pid.strip()] = out
return clean
@classmethod
def from_json(cls, obj):
return cls(cls._validate(obj))
@classmethod
def from_pickle_bytes(cls, raw):
return cls(cls._validate(restricted_loads(raw)))
# ---- gallery ops ---------------------------------------------------
def add(self, person_id, embedding):
self.data.setdefault(person_id, []).append(embedding)
def remove(self, person_id):
return self.data.pop(person_id, None) is not None
def clear(self):
self.data = {}
def people(self):
return sorted(self.data.keys())
def summary(self):
return [
{"id": pid, "count": len(embs), "cohesion": cohesion(embs)}
for pid, embs in sorted(self.data.items())
]
def merge(self, source, target):
"""Fold source's embeddings into target and drop the source name.
Used when two names turn out to be one face. Stored embeddings are
moved as-is, never recomputed, so the merged identity is exactly the
union of both registrations.
"""
if not source or not target or source == target:
raise ValueError("pick two different people to merge")
if source not in self.data or target not in self.data:
raise ValueError("both people must be registered in this session")
self.data[target].extend(self.data.pop(source))
def rank(self, probe):
"""All enrolled people scored against the probe, best first."""
scored = [
{"id": pid, "score": max(cosine_similarity(probe, e) for e in embs)}
for pid, embs in self.data.items()
if embs
]
scored.sort(key=lambda s: s["score"], reverse=True)
return scored
# --------------------------------------------------------------------------
# duplicate identities
#
# Pure analysis over embeddings the pipeline has already produced. Nothing
# here touches detection, preprocessing, embedding or rank().
# --------------------------------------------------------------------------
MATCH_THRESHOLD = 0.45 # main.py's recognition threshold
# Calibrated on raw_dataset (9 students). Each student's photos split in half
# stood in for one person under two names (540 splits); every pair of different
# students stood in for different people (576 comparisons). This combined rule
# caught 98.5% of the same-person splits and flagged 0 of the different pairs.
DUP_RATIO_THRESHOLD = 0.88
DUP_CENTROID_FLOOR = 0.60
COHESION_FLOOR = 0.30 # stops a scattered registration from inflating the ratio
def compare_identities(a, b):
"""How alike two registered names are, from their stored embeddings.
ratio - mean cross-name similarity divided by each name's own
cohesion: "are B's photos as close to A's as A's photos are to
each other?" ~1.0 means indistinguishable. The normalisation is
what makes this work: in this pipeline even DIFFERENT students
have raw cross-similarity around 0.3-0.5, so no absolute cosine
cutoff separates them.
centroid - cosine between the two names' mean embeddings. An independent
guard for the ratio's weak spot (dividing by a low cohesion).
match_rate - main.py's rule turned on the gallery: the share of each name's
photos recognised as the other at 0.45. Reported only -- it is
not diagnostic, different students often score high on it too.
"""
A = np.asarray(a, dtype=np.float32)
B = np.asarray(b, dtype=np.float32)
S = A @ B.T
cross = float(S.mean())
ca, cb = A.mean(axis=0), B.mean(axis=0)
centroid = float(ca @ cb / (norm(ca) * norm(cb) + 1e-10))
ka, kb = cohesion(A), cohesion(B)
ka = max(ka if ka is not None else cross, COHESION_FLOOR)
kb = max(kb if kb is not None else cross, COHESION_FLOOR)
hits = int((S.max(axis=1) >= MATCH_THRESHOLD).sum()
+ (S.max(axis=0) >= MATCH_THRESHOLD).sum())
return {
"ratio": cross / float(np.sqrt(ka * kb)),
"centroid": centroid,
"cross_mean": cross,
"match_rate": hits / float(len(A) + len(B)),
}
def find_duplicates(data, dismissed=()):
"""Pairs of different names whose faces look like the same person.
dismissed holds frozenset({a, b}) pairs the user has confirmed are
different people, so they aren't raised again.
"""
ids = sorted(k for k, v in data.items() if v)
out = []
for i, a in enumerate(ids):
for b in ids[i + 1:]:
if frozenset((a, b)) in dismissed:
continue
m = compare_identities(data[a], data[b])
if m["ratio"] >= DUP_RATIO_THRESHOLD and m["centroid"] >= DUP_CENTROID_FLOOR:
out.append({"a": a, "b": b, **{k: round(v, 4) for k, v in m.items()}})
out.sort(key=lambda d: d["ratio"], reverse=True)
return out