| import os |
| import cv2 |
| import numpy as np |
| import onnxruntime as ort |
| import logging |
|
|
| logger = logging.getLogger(__name__) |
|
|
| class FaceRecognizer: |
| def __init__(self, model_path="models/arcface.onnx"): |
| self.model_path = model_path |
| self.loaded = False |
| self.session = None |
| |
| if os.path.exists(model_path): |
| try: |
| |
| self.session = ort.InferenceSession( |
| model_path, |
| providers=['CPUExecutionProvider'] |
| ) |
| self.loaded = True |
| logger.info(f"ArcFace Recognizer loaded successfully from {model_path}") |
| except Exception as e: |
| logger.error(f"Error initializing ArcFace ONNX session: {e}") |
| else: |
| logger.warning(f"ArcFace recognition model file missing at {model_path}") |
|
|
| def apply_clahe(self, face_crop): |
| """ |
| Drawback Fix - Low Light Handling: |
| Applies CLAHE histogram equalization on the luminance channel (LAB color space) |
| to normalize brightness and contrast before facial recognition. |
| """ |
| if face_crop is None or face_crop.size == 0: |
| return face_crop |
| |
| try: |
| |
| lab = cv2.cvtColor(face_crop, cv2.COLOR_BGR2LAB) |
| l_channel, a_channel, b_channel = cv2.split(lab) |
| |
| |
| clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) |
| cl = clahe.apply(l_channel) |
| |
| |
| limg = cv2.merge((cl, a_channel, b_channel)) |
| bgr_equalized = cv2.cvtColor(limg, cv2.COLOR_LAB2BGR) |
| return bgr_equalized |
| except Exception as e: |
| logger.error(f"Failed to apply CLAHE: {e}") |
| return face_crop |
|
|
| def preprocess(self, face_crop): |
| """ |
| Resizes to 112x112, converts BGR to RGB, normalizes, |
| transposes to CHW, and adds batch dimension. |
| """ |
| |
| img = cv2.resize(face_crop, (112, 112)) |
| |
| |
| img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) |
| |
| |
| img = img.astype(np.float32) |
| img = (img - 127.5) / 128.0 |
| |
| |
| img = np.transpose(img, (2, 0, 1)) |
| |
| |
| input_tensor = np.expand_dims(img, axis=0) |
| return input_tensor |
|
|
| def get_embedding(self, face_crop): |
| """ |
| Generates L2 normalized 512-dimensional embedding for a face crop. |
| Includes low-light CLAHE enhancement. |
| """ |
| if not self.loaded or self.session is None: |
| logger.warning("ArcFace model not loaded. Skipping embedding generation.") |
| return None |
| |
| if face_crop is None or face_crop.size == 0: |
| return None |
| |
| try: |
| |
| enhanced_crop = self.apply_clahe(face_crop) |
| |
| |
| input_tensor = self.preprocess(enhanced_crop) |
| |
| |
| outputs = self.session.run( |
| None, |
| {self.session.get_inputs()[0].name: input_tensor} |
| ) |
| raw_embedding = outputs[0][0] |
| |
| |
| norm = np.linalg.norm(raw_embedding) |
| normalized_embedding = raw_embedding / norm if norm > 0 else raw_embedding |
| return normalized_embedding |
| except Exception as e: |
| logger.error(f"Error generating ArcFace embedding: {e}") |
| return None |
|
|
| def match_student(self, embedding, known_profiles, threshold=0.68): |
| """ |
| Matches a face embedding against a dictionary of known student profiles. |
| Enforces double-match ambiguity rejection. |
| """ |
| if embedding is None or not known_profiles: |
| return None, 0.0 |
| |
| scores = [] |
| for roll_no, profile in known_profiles.items(): |
| |
| similarity = float(np.dot(embedding, profile["embedding"])) |
| scores.append((roll_no, similarity)) |
| |
| if not scores: |
| return None, 0.0 |
| |
| |
| scores.sort(key=lambda x: x[1], reverse=True) |
| top_match, top_score = scores[0] |
| |
| |
| if top_score < threshold: |
| return None, top_score |
| |
| |
| if len(scores) > 1: |
| second_match, second_score = scores[1] |
| if (top_score - second_score) < 0.05: |
| logger.warning( |
| f"Ambiguous match: top choice '{top_match}' (score {top_score:.3f}) " |
| f"is too close to second choice '{second_match}' (score {second_score:.3f}). " |
| f"Margin is {top_score - second_score:.3f} (< 0.05). Rejecting." |
| ) |
| return "AMBIGUOUS", top_score |
| |
| return top_match, top_score |
|
|