| import os |
| import cv2 |
| import numpy as np |
| import logging |
|
|
| try: |
| import onnxruntime as ort |
| except ImportError: |
| ort = None |
|
|
| from app.core.config import settings |
|
|
| logger = logging.getLogger("FaceEngine") |
|
|
| class FaceEngine: |
| def __init__(self): |
| self.models_dir = settings.MODELS_DIR |
| self.mock_mode = False |
| self.embeddings_cache = None |
| |
| |
| self.det_model_path = os.path.join(self.models_dir, "det_2.5g.onnx") |
| self.rec_model_path = os.path.join(self.models_dir, "w600k_r50.onnx") |
| self.liveness_model_27 = os.path.join(self.models_dir, "2.7k_80x80.onnx") |
| self.liveness_model_18 = os.path.join(self.models_dir, "1.8k_128x128.onnx") |
|
|
| |
| if getattr(settings, "FORCE_MOCK_MODE", False): |
| logger.info("FORCE_MOCK_MODE is enabled. Running in MOCK MODE.") |
| self.mock_mode = True |
| return |
|
|
| |
| if ort is None: |
| logger.warning("onnxruntime is not installed. Running in MOCK MODE.") |
| self.mock_mode = True |
| return |
|
|
| |
| required_models = [self.det_model_path, self.rec_model_path, self.liveness_model_27] |
| missing = [m for m in required_models if not os.path.exists(m)] |
| |
| if missing: |
| logger.warning(f"The following models are missing: {missing}. Starting background downloader...") |
| self.mock_mode = True |
| import threading |
| threading.Thread(target=self._download_and_init_async, daemon=True).start() |
| else: |
| self._init_sessions() |
|
|
| def _download_and_init_async(self): |
| try: |
| from app.core.download_models import download_all_models |
| download_all_models(self.models_dir) |
| |
| |
| required_models = [self.det_model_path, self.rec_model_path, self.liveness_model_27] |
| missing = [m for m in required_models if not os.path.exists(m)] |
| if not missing: |
| logger.info("Models downloaded successfully in background. Initializing real ONNX sessions...") |
| self._init_sessions() |
| else: |
| logger.error(f"Models background download finished but some required models are still missing: {missing}") |
| except Exception as e: |
| logger.error(f"Error in background model download and initialization: {e}") |
|
|
| def _init_sessions(self): |
| try: |
| import gc |
| opts = ort.SessionOptions() |
| low_mem = getattr(settings, "LOW_MEMORY_MODE", True) |
| if low_mem: |
| opts.intra_op_num_threads = 1 |
| opts.inter_op_num_threads = 1 |
| opts.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL |
| opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL |
| opts.enable_cpu_mem_arena = False |
| opts.add_session_config_entry("memory.enable_memory_arena_shrinkage", "cpu:0") |
| logger.info("ONNX Runtime running in LOW MEMORY MODE (optimized for <= 512MB RAM instances).") |
| else: |
| |
| threads = getattr(settings, "ORT_INTRA_OP_NUM_THREADS", 0) |
| opts.intra_op_num_threads = threads |
| opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL |
| logger.info(f"ONNX Runtime running in HIGH PERFORMANCE MODE (intra_op_threads={threads if threads > 0 else 'auto'}, all optimizations enabled).") |
| |
| providers = ['CPUExecutionProvider'] |
| |
| if 'CUDAExecutionProvider' in ort.get_available_providers(): |
| providers = ['CUDAExecutionProvider'] + providers |
| |
| logger.info(f"Initializing ONNX sessions with memory optimization and providers: {providers}") |
| |
| self.det_session = ort.InferenceSession(self.det_model_path, opts, providers=providers) |
| gc.collect() |
| |
| self.rec_session = ort.InferenceSession(self.rec_model_path, opts, providers=providers) |
| gc.collect() |
| |
| self.live_session_27 = ort.InferenceSession(self.liveness_model_27, opts, providers=providers) |
| gc.collect() |
| |
| |
| if os.path.exists(self.liveness_model_18): |
| self.live_session_18 = ort.InferenceSession(self.liveness_model_18, opts, providers=providers) |
| gc.collect() |
| else: |
| self.live_session_18 = None |
| |
| self.mock_mode = False |
| logger.info("FaceEngine initialized successfully with all required AI models. Switched out of MOCK MODE.") |
| except Exception as e: |
| logger.error(f"Error initializing ONNX sessions: {e}. Falling back to/remaining in MOCK MODE.") |
| self.mock_mode = True |
|
|
| def detect_faces(self, image_np, conf_threshold=0.5): |
| """ |
| Detects faces using SCRFD detector. |
| Returns: list of dicts [{"bbox": [x1, y1, x2, y2], "confidence": score, "landmarks": [[x,y], ...]}] |
| """ |
| if self.mock_mode: |
| |
| h, w = image_np.shape[:2] |
| cx, cy = w // 2, h // 2 |
| bw, bh = int(w * 0.4), int(h * 0.5) |
| x1, y1 = max(0, cx - bw // 2), max(0, cy - bh // 2) |
| x2, y2 = min(w, cx + bw // 2), min(h, cy + bh // 2) |
| |
| mock_landmarks = [ |
| [cx - bw // 6, cy - bh // 8], |
| [cx + bw // 6, cy - bh // 8], |
| [cx, cy], |
| [cx - bw // 8, cy + bh // 6], |
| [cx + bw // 8, cy + bh // 6] |
| ] |
| |
| return [{ |
| "bbox": [float(x1), float(y1), float(x2), float(y2)], |
| "confidence": 0.99, |
| "landmarks": mock_landmarks |
| }] |
|
|
| try: |
| |
| h, w = image_np.shape[:2] |
| |
| target_size = 640 |
| scale = target_size / max(h, w) |
| nh, nw = int(h * scale), int(w * scale) |
| resized = cv2.resize(image_np, (nw, nh)) |
| |
| |
| padded = np.zeros((target_size, target_size, 3), dtype=np.uint8) |
| padded[:nh, :nw, :] = resized |
| |
| |
| blob = padded.astype(np.float32) |
| blob = (blob - 127.5) / 128.0 |
| blob = np.transpose(blob, (2, 0, 1)) |
| blob = np.expand_dims(blob, axis=0) |
|
|
| |
| outputs = self.det_session.run(None, {self.det_session.get_inputs()[0].name: blob}) |
| |
| |
| |
| |
| |
| |
| |
| |
| faces = self._parse_scrfd(outputs, scale, w, h, conf_threshold) |
| |
| |
| |
| return faces |
| except Exception as e: |
| logger.error(f"Error in detect_faces: {e}") |
| return [] |
|
|
| def _parse_scrfd(self, outputs, scale, orig_w, orig_h, conf_threshold): |
| """ |
| Parse SCRFD ONNX model outputs into face detections. |
| |
| The det_2.5g.onnx model outputs 9 tensors (3 strides x 3 types): |
| outputs[0,1,2]: scores shape (N_anchors_at_stride, 1) -- strides 8,16,32 |
| outputs[3,4,5]: bbox_pred shape (N_anchors_at_stride, 4) -- strides 8,16,32 |
| outputs[6,7,8]: kps_pred shape (N_anchors_at_stride, 10) -- strides 8,16,32 |
| |
| Anchors per stride = (640/stride)^2 * num_anchors_per_cell (typically 2) |
| """ |
| input_h = input_w = 640 |
| strides = [8, 16, 32] |
| num_anchors = 2 |
| faces = [] |
| |
| for idx, stride in enumerate(strides): |
| scores_raw = outputs[idx] |
| bbox_raw = outputs[idx + 3] |
| kps_raw = outputs[idx + 6] |
| |
| |
| feat_h = input_h // stride |
| feat_w = input_w // stride |
| |
| |
| anchor_centers = [] |
| for ay in range(feat_h): |
| for ax in range(feat_w): |
| for _ in range(num_anchors): |
| |
| cx = (ax + 0.5) * stride |
| cy = (ay + 0.5) * stride |
| anchor_centers.append([cx, cy]) |
| anchor_centers = np.array(anchor_centers, dtype=np.float32) |
| |
| |
| scores = scores_raw[:, 0] |
| valid_mask = scores >= conf_threshold |
| valid_indices = np.where(valid_mask)[0] |
| |
| if len(valid_indices) == 0: |
| continue |
| |
| valid_scores = scores[valid_indices] |
| valid_bbox = bbox_raw[valid_indices] |
| valid_kps = kps_raw[valid_indices] |
| valid_anchors = anchor_centers[valid_indices] |
| |
| |
| |
| x1 = valid_anchors[:, 0] - valid_bbox[:, 0] * stride |
| y1 = valid_anchors[:, 1] - valid_bbox[:, 1] * stride |
| x2 = valid_anchors[:, 0] + valid_bbox[:, 2] * stride |
| y2 = valid_anchors[:, 1] + valid_bbox[:, 3] * stride |
| |
| |
| |
| |
| for i in range(len(valid_indices)): |
| |
| rx1 = float(max(0, x1[i] / scale)) |
| ry1 = float(max(0, y1[i] / scale)) |
| rx2 = float(min(orig_w, x2[i] / scale)) |
| ry2 = float(min(orig_h, y2[i] / scale)) |
| |
| landmarks = [] |
| for k in range(5): |
| |
| kx = float((valid_anchors[i, 0] + valid_kps[i, k*2] * stride) / scale) |
| ky = float((valid_anchors[i, 1] + valid_kps[i, k*2+1] * stride) / scale) |
| kx = max(0, min(orig_w, kx)) |
| ky = max(0, min(orig_h, ky)) |
| landmarks.append([kx, ky]) |
| |
| faces.append({ |
| "bbox": [rx1, ry1, rx2, ry2], |
| "confidence": float(valid_scores[i]), |
| "landmarks": landmarks |
| }) |
| |
| |
| faces = self._nms(faces, iou_threshold=0.4) |
| return faces |
|
|
| def _nms(self, faces, iou_threshold): |
| if not faces: |
| return [] |
| |
| |
| faces = sorted(faces, key=lambda x: x["confidence"], reverse=True) |
| keep = [] |
| |
| while faces: |
| best = faces.pop(0) |
| keep.append(best) |
| |
| |
| remaining = [] |
| for f in faces: |
| iou = self._iou(best["bbox"], f["bbox"]) |
| if iou < iou_threshold: |
| remaining.append(f) |
| faces = remaining |
| |
| return keep |
|
|
| def _iou(self, box1, box2): |
| x1_1, y1_1, x2_1, y2_1 = box1 |
| x1_2, y1_2, x2_2, y2_2 = box2 |
| |
| xi1 = max(x1_1, x1_2) |
| yi1 = max(y1_1, y1_2) |
| xi2 = min(x2_1, x2_2) |
| yi2 = min(y2_1, y2_2) |
| |
| inter_area = max(0, xi2 - xi1) * max(0, yi2 - yi1) |
| box1_area = (x2_1 - x1_1) * (y2_1 - y1_1) |
| box2_area = (x2_2 - x1_2) * (y2_2 - y1_2) |
| union_area = box1_area + box2_area - inter_area |
| |
| return inter_area / union_area if union_area > 0 else 0 |
|
|
| def align_face(self, image_np, landmarks): |
| """ |
| Aligns the face using the 5 landmarks using standard similarity transformation. |
| Output is 112x112 image, standard for ArcFace. |
| """ |
| if not landmarks or len(landmarks) < 5: |
| |
| return cv2.resize(image_np, (112, 112)) |
| |
| |
| reference_landmarks = np.array([ |
| [38.2946, 51.6963], |
| [73.5318, 51.6963], |
| [56.0252, 71.7366], |
| [41.5493, 92.3655], |
| [70.7299, 92.3655] |
| ], dtype=np.float32) |
| |
| src = np.array(landmarks, dtype=np.float32) |
| |
| |
| |
| M, inliers = cv2.estimateAffinePartial2D(src, reference_landmarks) |
| if M is None: |
| |
| return cv2.resize(image_np, (112, 112)) |
| |
| |
| aligned = cv2.warpAffine(image_np, M, (112, 112)) |
| return aligned |
|
|
| def extract_embedding(self, aligned_face): |
| """ |
| Extracts 512-D face embedding vector using ArcFace model. |
| Returns a normalized 512-D numpy array. |
| """ |
| if self.mock_mode: |
| |
| |
| |
| |
| |
| |
| |
| try: |
| tiny = cv2.resize(aligned_face, (4, 4), interpolation=cv2.INTER_AREA) |
| |
| if len(tiny.shape) == 3: |
| tiny_gray = cv2.cvtColor(tiny, cv2.COLOR_BGR2GRAY) |
| else: |
| tiny_gray = tiny |
| |
| quantized = (tiny_gray // 32).flatten() |
| seed_str = ''.join([str(v) for v in quantized]) |
| seed_val = int(seed_str, 8) % 2147483647 |
| except Exception: |
| |
| seed_val = 42 |
| |
| np.random.seed(seed_val) |
| vec = np.random.randn(512).astype(np.float32) |
| |
| norm = np.linalg.norm(vec) |
| return vec / norm if norm > 0 else vec |
|
|
| try: |
| |
| |
| |
| |
| |
| blob = aligned_face.astype(np.float32) |
| |
| blob = (blob - 127.5) / 128.0 |
| blob = np.transpose(blob, (2, 0, 1)) |
| blob = np.expand_dims(blob, axis=0) |
|
|
| |
| outputs = self.rec_session.run(None, {self.rec_session.get_inputs()[0].name: blob}) |
| embedding = outputs[0][0] |
| |
| |
| norm = np.linalg.norm(embedding) |
| if norm > 0: |
| embedding = embedding / norm |
| |
| return embedding |
| except Exception as e: |
| logger.error(f"Error in extract_embedding: {e}") |
| |
| vec = np.random.randn(512).astype(np.float32) |
| return vec / np.linalg.norm(vec) |
|
|
| def check_liveness(self, image_np, bbox, threshold=None): |
| """ |
| Silent Face Anti-Spoofing MiniFASNet model. |
| Crops face, resizes, runs liveness model. |
| Returns: liveness_score (float), is_live (bool) |
| """ |
| if self.mock_mode: |
| |
| |
| |
| import random |
| score = random.uniform(0.91, 0.98) |
| return score, True |
|
|
| try: |
| x1, y1, x2, y2 = bbox |
| w, h = x2 - x1, y2 - y1 |
| |
| |
| scale_27 = 2.7 |
| cx, cy = x1 + w/2, y1 + h/2 |
| |
| |
| w_new, h_new = w * scale_27, h * scale_27 |
| x1_new = int(max(0, cx - w_new/2)) |
| y1_new = int(max(0, cy - h_new/2)) |
| x2_new = int(min(image_np.shape[1], cx + w_new/2)) |
| y2_new = int(min(image_np.shape[0], cy + h_new/2)) |
| |
| crop_27 = image_np[y1_new:y2_new, x1_new:x2_new] |
| if crop_27.size == 0: |
| return 0.0, False |
| |
| |
| resized_27 = cv2.resize(crop_27, (80, 80)) |
| |
| blob_27 = np.transpose(resized_27, (2, 0, 1)).astype(np.float32) |
| blob_27 = np.expand_dims(blob_27, axis=0) |
|
|
| |
| output_27 = self.live_session_27.run(None, {self.live_session_27.get_inputs()[0].name: blob_27})[0][0] |
|
|
| |
| def softmax(x): |
| e_x = np.exp(x - np.max(x)) |
| return e_x / e_x.sum(axis=0) |
|
|
| prob_27 = softmax(output_27) |
| score_27 = float(prob_27[1]) |
| |
| |
| if self.live_session_18 is not None: |
| |
| scale_18 = 1.8 |
| w_new_18, h_new_18 = w * scale_18, h * scale_18 |
| x1_new_18 = int(max(0, cx - w_new_18/2)) |
| y1_new_18 = int(max(0, cy - h_new_18/2)) |
| x2_new_18 = int(min(image_np.shape[1], cx + w_new_18/2)) |
| y2_new_18 = int(min(image_np.shape[0], cy + h_new_18/2)) |
| |
| crop_18 = image_np[y1_new_18:y2_new_18, x1_new_18:x2_new_18] |
| if crop_18.size > 0: |
| |
| resized_18 = cv2.resize(crop_18, (128, 128)) |
| |
| blob_18 = np.transpose(resized_18, (2, 0, 1)).astype(np.float32) |
| blob_18 = np.expand_dims(blob_18, axis=0) |
| |
| output_18 = self.live_session_18.run(None, {self.live_session_18.get_inputs()[0].name: blob_18})[0][0] |
| prob_18 = softmax(output_18) |
| score_18 = float(prob_18[1]) |
| avg_score = (score_27 + score_18) / 2.0 |
| else: |
| avg_score = score_27 |
| else: |
| avg_score = score_27 |
| |
| liveness_threshold = threshold if threshold is not None else settings.KIOSK_LIVENESS_THRESHOLD |
| is_live = avg_score >= liveness_threshold |
| return avg_score, is_live |
|
|
| except Exception as e: |
| logger.error(f"Error in check_liveness: {e}") |
| return 0.0, False |
| |
| def cosine_similarity(self, embedding1, embedding2): |
| """ |
| Computes cosine similarity between two 512-D embeddings. |
| Since they are L2-normalized, cosine similarity is just the dot product. |
| """ |
| return float(np.dot(embedding1, embedding2)) |
|
|
| def load_embeddings_cache(self, db_session): |
| from app.models import models |
| try: |
| records = db_session.query(models.FaceEmbedding).all() |
| cache = [] |
| for r in records: |
| |
| if isinstance(r.embedding, str): |
| import json |
| vec = np.array(json.loads(r.embedding), dtype=np.float32) |
| else: |
| vec = np.array(r.embedding, dtype=np.float32) |
| cache.append({ |
| "id": r.id, |
| "employee_id": r.employee_id, |
| "embedding": vec |
| }) |
| self.embeddings_cache = cache |
| logger.info(f"Loaded {len(cache)} face embeddings into local memory cache.") |
| except Exception as e: |
| logger.error(f"Failed to load embeddings cache: {e}") |
| self.embeddings_cache = [] |
|
|
| def invalidate_cache(self): |
| self.embeddings_cache = None |
| logger.info("FaceEngine memory cache invalidated.") |
|
|