| """FaceGuard - Advanced Face Recognition & Attendance Backend |
| |
| Layers of anti-spoofing: |
| 1. Client-side MediaPipe Face Mesh liveness challenges (blink, head turn, nod, smile) - defeats static photos |
| 2. Server-side MiniFASNetV2 CNN anti-spoof texture check - defeats printed photos + screen replays |
| 3. Server-side InsightFace ArcFace recognition (via uniface) for identity match |
| """ |
| import os |
| import io |
| import csv |
| import json |
| import base64 |
| import asyncio |
| import secrets |
| from datetime import datetime, timezone, timedelta |
| from typing import List, Optional, Iterable, Annotated |
| from contextlib import asynccontextmanager |
|
|
| import cv2 |
| import numpy as np |
| import onnxruntime as ort |
| from bson import ObjectId |
| from dotenv import load_dotenv |
| from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Query, Request |
| from fastapi.responses import StreamingResponse, JSONResponse |
| from pydantic import BaseModel, Field, BeforeValidator |
| from starlette.middleware.cors import CORSMiddleware |
| from starlette.concurrency import run_in_threadpool |
| from motor.motor_asyncio import AsyncIOMotorClient |
| from uniface import RetinaFace, ArcFace |
|
|
| load_dotenv() |
|
|
| |
| MONGO_URL = os.environ["MONGO_URL"] |
| DB_NAME = os.environ["DB_NAME"] |
| MODEL_PATH = os.environ.get("MODEL_PATH", "/app/backend/models/2.7_80x80_MiniFASNetV2.onnx") |
| FACE_SIM_THRESHOLD = float(os.environ.get("FACE_SIM_THRESHOLD", "0.42")) |
| DETECTION_THRESHOLD = float(os.environ.get("DETECTION_THRESHOLD", "0.7")) |
| LIVENESS_THRESHOLD = float(os.environ.get("LIVENESS_THRESHOLD", "0.7")) |
|
|
| |
| models: dict = {"detector": None, "recognizer": None, "liveness": None} |
|
|
| def _init_models(): |
| print("[FaceGuard] Loading RetinaFace detector...") |
| models["detector"] = RetinaFace() |
| print("[FaceGuard] Loading ArcFace recognizer...") |
| models["recognizer"] = ArcFace() |
| print("[FaceGuard] Loading MiniFASNetV2 liveness (texture anti-spoof)...") |
| sess = ort.InferenceSession(MODEL_PATH, providers=["CPUExecutionProvider"]) |
| models["liveness"] = { |
| "session": sess, |
| "input_name": sess.get_inputs()[0].name, |
| } |
| print("[FaceGuard] All models ready.") |
|
|
| |
| mongo_client: Optional[AsyncIOMotorClient] = None |
| db = None |
|
|
| @asynccontextmanager |
| async def lifespan(app: FastAPI): |
| global mongo_client, db |
| mongo_client = AsyncIOMotorClient(MONGO_URL) |
| db = mongo_client[DB_NAME] |
| await db.employees.create_index("employee_id", unique=True) |
| await db.attendance.create_index([("employee_id", 1), ("timestamp", -1)]) |
| try: |
| _init_models() |
| except Exception as e: |
| print(f"[FaceGuard] Model init error: {e}") |
| yield |
| mongo_client.close() |
|
|
| app = FastAPI(title="FaceGuard API", version="1.0.0", lifespan=lifespan) |
|
|
| app.add_middleware( |
| CORSMiddleware, |
| allow_origins=["*"], |
| allow_credentials=True, |
| allow_methods=["*"], |
| allow_headers=["*"], |
| ) |
|
|
| |
| PyObjectId = Annotated[str, BeforeValidator(lambda v: str(v) if isinstance(v, ObjectId) else v)] |
|
|
| def _strip_b64(s: str) -> str: |
| if isinstance(s, str) and "," in s and s.lstrip().lower().startswith("data:"): |
| return s.split(",", 1)[1] |
| return s |
|
|
| def _b64_to_bgr(b64: str) -> Optional[np.ndarray]: |
| try: |
| raw = base64.b64decode(_strip_b64(b64), validate=False) |
| arr = np.frombuffer(raw, np.uint8) |
| return cv2.imdecode(arr, cv2.IMREAD_COLOR) |
| except Exception: |
| return None |
|
|
| def _bgr_to_b64_jpeg(img: np.ndarray, quality: int = 80) -> str: |
| ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, quality]) |
| if not ok: |
| return "" |
| return "data:image/jpeg;base64," + base64.b64encode(buf.tobytes()).decode() |
|
|
| def _softmax(x: np.ndarray) -> np.ndarray: |
| e = np.exp(x - np.max(x)) |
| return e / e.sum() |
|
|
| def _predict_liveness(img_bgr: np.ndarray, bbox) -> tuple: |
| """MiniFASNetV2 texture-based anti-spoof. |
| |
| Returns (is_real: bool, live_score: float in [0,1]) |
| """ |
| if models["liveness"] is None: |
| return True, 1.0 |
| h_img, w_img, _ = img_bgr.shape |
| x1, y1, x2, y2 = map(int, bbox) |
| box_h = max(1, y2 - y1) |
| box_w = max(1, x2 - x1) |
| cx = x1 + box_w // 2 |
| cy = y1 + box_h // 2 |
| side = int(((box_h + box_w) / 2) * 2.7) |
| nx1 = max(0, cx - side // 2) |
| ny1 = max(0, cy - side // 2) |
| nx2 = min(w_img, cx + side // 2) |
| ny2 = min(h_img, cy + side // 2) |
| crop = img_bgr[ny1:ny2, nx1:nx2] |
| if crop.size == 0: |
| return False, 0.0 |
| resized = cv2.resize(crop, (80, 80)).astype(np.float32) |
| blob = np.expand_dims(np.transpose(resized, (2, 0, 1)), 0) |
| sess = models["liveness"]["session"] |
| inp = models["liveness"]["input_name"] |
| out = sess.run(None, {inp: blob}) |
| probs = _softmax(out[0][0]) |
| |
| real_score = float(probs[1]) |
| is_real = bool(np.argmax(probs) == 1 and real_score >= LIVENESS_THRESHOLD) |
| return is_real, real_score |
|
|
| def _detect_and_embed(img_bgr: np.ndarray): |
| """Detect largest face and compute normalized embedding + spoof check.""" |
| faces = models["detector"].detect(img_bgr) |
| if not faces: |
| return None |
| |
| def area(f): |
| x1, y1, x2, y2 = f.bbox |
| return (x2 - x1) * (y2 - y1) |
| face = max(faces, key=area) |
| if face.confidence < DETECTION_THRESHOLD: |
| return {"error": "low_confidence", "det_score": float(face.confidence)} |
| emb = models["recognizer"].get_normalized_embedding(img_bgr, face.landmarks) |
| if emb is None: |
| return {"error": "no_embedding"} |
| return { |
| "bbox": [float(v) for v in face.bbox], |
| "det_score": float(face.confidence), |
| "embedding": emb.flatten().astype(np.float32), |
| "landmarks": [[float(x), float(y)] for x, y in face.landmarks], |
| } |
|
|
| async def _search_match(probe_emb: np.ndarray, employee_id: Optional[str] = None): |
| """Search MongoDB embeddings for best cosine similarity match.""" |
| query = {"employee_id": employee_id} if employee_id else {} |
| best_score = -1.0 |
| best = None |
| async for emp in db.employees.find(query): |
| for enc in emp.get("embeddings", []): |
| ref = np.array(enc, dtype=np.float32) |
| sim = float(np.dot(probe_emb, ref)) |
| if sim > best_score: |
| best_score = sim |
| best = emp |
| return best, best_score |
|
|
| |
| class EmployeeCreate(BaseModel): |
| employee_id: str |
| name: str |
| email: Optional[str] = None |
| department: Optional[str] = None |
| images: List[str] = Field(..., description="Base64/data-url face images (>=1)") |
|
|
| class EmployeeOut(BaseModel): |
| id: PyObjectId = Field(alias="_id") |
| employee_id: str |
| name: str |
| email: Optional[str] = None |
| department: Optional[str] = None |
| thumbnail: Optional[str] = None |
| embeddings_count: int = 0 |
| created_at: str |
| model_config = {"populate_by_name": True, "arbitrary_types_allowed": True} |
|
|
| class VerifyRequest(BaseModel): |
| image: str |
| employee_id: Optional[str] = None |
| challenges_passed: List[str] = Field(default_factory=list) |
| challenge_token: Optional[str] = None |
|
|
| class AttendanceRequest(BaseModel): |
| image: str |
| type: str |
| employee_id: Optional[str] = None |
| challenges_passed: List[str] = Field(default_factory=list) |
| challenge_token: Optional[str] = None |
| latitude: Optional[float] = None |
| longitude: Optional[float] = None |
| address: Optional[str] = None |
|
|
| |
| _challenge_store: dict = {} |
| CHALLENGE_TTL_SECS = 120 |
|
|
| def _issue_challenges(): |
| import random |
| pool = ["blink", "turn_left", "turn_right", "nod", "smile"] |
| picked = random.sample(pool, 3) |
| tok = secrets.token_urlsafe(18) |
| _challenge_store[tok] = { |
| "challenges": picked, |
| "expires": datetime.now(timezone.utc) + timedelta(seconds=CHALLENGE_TTL_SECS), |
| "consumed": False, |
| } |
| |
| now = datetime.now(timezone.utc) |
| for k in list(_challenge_store.keys()): |
| if _challenge_store[k]["expires"] < now: |
| _challenge_store.pop(k, None) |
| return tok, picked |
|
|
| def _validate_challenges(token: Optional[str], passed: List[str]) -> tuple: |
| if not token or token not in _challenge_store: |
| return False, "no_or_invalid_challenge_token" |
| entry = _challenge_store[token] |
| if entry["consumed"]: |
| return False, "challenge_already_used" |
| if entry["expires"] < datetime.now(timezone.utc): |
| _challenge_store.pop(token, None) |
| return False, "challenge_expired" |
| required = set(entry["challenges"]) |
| got = set(passed or []) |
| if not required.issubset(got): |
| return False, f"missing_challenges:{sorted(required - got)}" |
| entry["consumed"] = True |
| return True, "ok" |
|
|
| |
| @app.get("/api/health") |
| async def health(): |
| ready = all(models.get(k) is not None for k in ("detector", "recognizer", "liveness")) |
| return {"status": "ok" if ready else "loading", "models_ready": ready} |
|
|
| @app.post("/api/attendance/challenge") |
| async def get_challenge(): |
| tok, picked = _issue_challenges() |
| return {"token": tok, "challenges": picked, "ttl_seconds": CHALLENGE_TTL_SECS} |
|
|
| @app.post("/api/employees") |
| async def create_employee(payload: EmployeeCreate): |
| if not payload.images: |
| raise HTTPException(400, "At least one face image required") |
| if models["detector"] is None or models["recognizer"] is None: |
| raise HTTPException(503, "Models loading, try again") |
| embeddings = [] |
| thumbnail_b64 = None |
| for img_str in payload.images: |
| img = _b64_to_bgr(img_str) |
| if img is None: |
| continue |
| res = await run_in_threadpool(_detect_and_embed, img) |
| if not res or "error" in res: |
| continue |
| embeddings.append(res["embedding"].tolist()) |
| if thumbnail_b64 is None: |
| |
| x1, y1, x2, y2 = map(int, res["bbox"]) |
| pad = int(max(x2 - x1, y2 - y1) * 0.2) |
| h, w = img.shape[:2] |
| cy1 = max(0, y1 - pad) |
| cy2 = min(h, y2 + pad) |
| cx1 = max(0, x1 - pad) |
| cx2 = min(w, x2 + pad) |
| face_crop = img[cy1:cy2, cx1:cx2] |
| face_crop = cv2.resize(face_crop, (256, 256)) |
| thumbnail_b64 = _bgr_to_b64_jpeg(face_crop, 82) |
| if not embeddings: |
| raise HTTPException(400, "No valid face detected in provided images") |
| doc = { |
| "employee_id": payload.employee_id.strip(), |
| "name": payload.name.strip(), |
| "email": (payload.email or "").strip() or None, |
| "department": (payload.department or "").strip() or None, |
| "embeddings": embeddings, |
| "embeddings_count": len(embeddings), |
| "thumbnail": thumbnail_b64, |
| "created_at": datetime.now(timezone.utc).isoformat(), |
| } |
| try: |
| result = await db.employees.insert_one(doc) |
| except Exception as e: |
| if "duplicate" in str(e).lower(): |
| raise HTTPException(409, "employee_id already exists") |
| raise |
| return { |
| "id": str(result.inserted_id), |
| "employee_id": doc["employee_id"], |
| "name": doc["name"], |
| "embeddings_count": len(embeddings), |
| "thumbnail": thumbnail_b64, |
| } |
|
|
| @app.get("/api/employees") |
| async def list_employees(): |
| out = [] |
| async for e in db.employees.find({}, {"embeddings": 0}).sort("created_at", -1): |
| out.append({ |
| "id": str(e["_id"]), |
| "employee_id": e["employee_id"], |
| "name": e["name"], |
| "email": e.get("email"), |
| "department": e.get("department"), |
| "thumbnail": e.get("thumbnail"), |
| "created_at": e.get("created_at"), |
| "embeddings_count": e.get("embeddings_count", 0), |
| }) |
| return out |
|
|
| @app.get("/api/employees/{employee_id}") |
| async def get_employee(employee_id: str): |
| e = await db.employees.find_one({"employee_id": employee_id}, {"embeddings": 0}) |
| if not e: |
| raise HTTPException(404, "employee not found") |
| e["id"] = str(e.pop("_id")) |
| return e |
|
|
| @app.delete("/api/employees/{employee_id}") |
| async def delete_employee(employee_id: str): |
| res = await db.employees.delete_one({"employee_id": employee_id}) |
| if res.deleted_count == 0: |
| raise HTTPException(404, "employee not found") |
| return {"deleted": True, "employee_id": employee_id} |
|
|
| @app.post("/api/face/verify") |
| async def face_verify(payload: VerifyRequest): |
| """Verify a single frame -> face match + spoof check. |
| |
| challenge_token/challenges_passed enforced when provided.""" |
| if models["detector"] is None: |
| raise HTTPException(503, "Models loading") |
| img = _b64_to_bgr(payload.image) |
| if img is None: |
| raise HTTPException(400, "invalid image") |
| if payload.challenge_token: |
| ok, reason = _validate_challenges(payload.challenge_token, payload.challenges_passed) |
| if not ok: |
| return {"authorized": False, "reason": reason, "stage": "liveness_challenge"} |
| res = await run_in_threadpool(_detect_and_embed, img) |
| if res is None: |
| return {"authorized": False, "reason": "no_face_found", "stage": "detection"} |
| if "error" in res: |
| return {"authorized": False, "reason": res["error"], "stage": "detection", **{k: v for k, v in res.items() if k != "embedding"}} |
| |
| is_real, live_score = await run_in_threadpool(_predict_liveness, img, res["bbox"]) |
| if not is_real: |
| return { |
| "authorized": False, |
| "reason": "spoof_detected", |
| "stage": "anti_spoof", |
| "live_score": round(live_score, 4), |
| "bbox": res["bbox"], |
| } |
| match, score = await _search_match(res["embedding"], payload.employee_id) |
| authorized = bool(match and score >= FACE_SIM_THRESHOLD) |
| return { |
| "authorized": authorized, |
| "similarity": round(score, 4), |
| "threshold": FACE_SIM_THRESHOLD, |
| "live_score": round(live_score, 4), |
| "det_score": round(res["det_score"], 4), |
| "bbox": res["bbox"], |
| "employee": ({ |
| "employee_id": match["employee_id"], |
| "name": match["name"], |
| "department": match.get("department"), |
| "thumbnail": match.get("thumbnail"), |
| } if authorized and match else None), |
| "reason": "ok" if authorized else "no_match", |
| "stage": "recognition", |
| } |
|
|
| @app.post("/api/attendance") |
| async def record_attendance(payload: AttendanceRequest): |
| if payload.type not in ("check-in", "check-out"): |
| raise HTTPException(400, "type must be check-in or check-out") |
| if models["detector"] is None: |
| raise HTTPException(503, "Models loading") |
| img = _b64_to_bgr(payload.image) |
| if img is None: |
| raise HTTPException(400, "invalid image") |
| |
| ok, reason = _validate_challenges(payload.challenge_token, payload.challenges_passed) |
| if not ok: |
| return {"success": False, "reason": reason, "stage": "liveness_challenge"} |
| res = await run_in_threadpool(_detect_and_embed, img) |
| if res is None: |
| return {"success": False, "reason": "no_face_found", "stage": "detection"} |
| if "error" in res: |
| return {"success": False, "reason": res["error"], "stage": "detection"} |
| is_real, live_score = await run_in_threadpool(_predict_liveness, img, res["bbox"]) |
| if not is_real: |
| return {"success": False, "reason": "spoof_detected", "stage": "anti_spoof", "live_score": round(live_score, 4)} |
| match, score = await _search_match(res["embedding"], payload.employee_id) |
| if not match or score < FACE_SIM_THRESHOLD: |
| return { |
| "success": False, |
| "reason": "no_match", |
| "stage": "recognition", |
| "similarity": round(score, 4), |
| "threshold": FACE_SIM_THRESHOLD, |
| } |
| |
| x1, y1, x2, y2 = map(int, res["bbox"]) |
| pad = int(max(x2 - x1, y2 - y1) * 0.15) |
| h, w = img.shape[:2] |
| face_crop = img[max(0, y1 - pad):min(h, y2 + pad), max(0, x1 - pad):min(w, x2 + pad)] |
| if face_crop.size > 0: |
| face_crop = cv2.resize(face_crop, (240, 240)) |
| snapshot_b64 = _bgr_to_b64_jpeg(face_crop if face_crop.size > 0 else img, 78) |
| now = datetime.now(timezone.utc) |
| record = { |
| "employee_id": match["employee_id"], |
| "employee_name": match["name"], |
| "department": match.get("department"), |
| "type": payload.type, |
| "timestamp": now.isoformat(), |
| "date": now.strftime("%Y-%m-%d"), |
| "similarity": round(score, 4), |
| "live_score": round(live_score, 4), |
| "location": { |
| "latitude": payload.latitude, |
| "longitude": payload.longitude, |
| "address": payload.address, |
| }, |
| "snapshot": snapshot_b64, |
| "challenges_passed": payload.challenges_passed, |
| } |
| result = await db.attendance.insert_one(record) |
| return { |
| "success": True, |
| "id": str(result.inserted_id), |
| "type": payload.type, |
| "timestamp": record["timestamp"], |
| "employee": { |
| "employee_id": match["employee_id"], |
| "name": match["name"], |
| "department": match.get("department"), |
| "thumbnail": match.get("thumbnail"), |
| }, |
| "similarity": round(score, 4), |
| "live_score": round(live_score, 4), |
| } |
|
|
| @app.get("/api/attendance") |
| async def list_attendance( |
| date: Optional[str] = Query(None, description="YYYY-MM-DD"), |
| employee_id: Optional[str] = None, |
| limit: int = Query(200, le=1000), |
| include_snapshot: bool = False, |
| ): |
| q = {} |
| if date: |
| q["date"] = date |
| if employee_id: |
| q["employee_id"] = employee_id |
| proj = None if include_snapshot else {"snapshot": 0} |
| out = [] |
| cursor = db.attendance.find(q, proj).sort("timestamp", -1).limit(limit) |
| async for r in cursor: |
| r["id"] = str(r.pop("_id")) |
| out.append(r) |
| return out |
|
|
| @app.get("/api/attendance/{att_id}") |
| async def get_attendance(att_id: str): |
| try: |
| oid = ObjectId(att_id) |
| except Exception: |
| raise HTTPException(400, "invalid id") |
| r = await db.attendance.find_one({"_id": oid}) |
| if not r: |
| raise HTTPException(404, "not found") |
| r["id"] = str(r.pop("_id")) |
| return r |
|
|
| @app.get("/api/attendance/stats/summary") |
| async def attendance_stats(date: Optional[str] = None): |
| if not date: |
| date = datetime.now(timezone.utc).strftime("%Y-%m-%d") |
| total_employees = await db.employees.count_documents({}) |
| today_records = await db.attendance.find({"date": date}, {"snapshot": 0}).to_list(None) |
| |
| checked_in_ids = set() |
| checked_out_ids = set() |
| late_ids = set() |
| for r in today_records: |
| if r["type"] == "check-in": |
| checked_in_ids.add(r["employee_id"]) |
| |
| try: |
| t = datetime.fromisoformat(r["timestamp"]) |
| if t.hour >= 9: |
| late_ids.add(r["employee_id"]) |
| except Exception: |
| pass |
| elif r["type"] == "check-out": |
| checked_out_ids.add(r["employee_id"]) |
| return { |
| "date": date, |
| "total_employees": total_employees, |
| "present_today": len(checked_in_ids), |
| "checked_out": len(checked_out_ids), |
| "late_today": len(late_ids), |
| "absent_today": max(0, total_employees - len(checked_in_ids)), |
| "total_records_today": len(today_records), |
| } |
|
|
| @app.get("/api/attendance/export/csv") |
| async def export_csv(date: Optional[str] = None, employee_id: Optional[str] = None): |
| q = {} |
| if date: |
| q["date"] = date |
| if employee_id: |
| q["employee_id"] = employee_id |
| buf = io.StringIO() |
| writer = csv.writer(buf) |
| writer.writerow([ |
| "Timestamp", "Employee ID", "Name", "Department", "Type", |
| "Similarity", "Live Score", "Latitude", "Longitude", "Address", |
| ]) |
| async for r in db.attendance.find(q, {"snapshot": 0}).sort("timestamp", -1): |
| loc = r.get("location") or {} |
| writer.writerow([ |
| r.get("timestamp", ""), |
| r.get("employee_id", ""), |
| r.get("employee_name", ""), |
| r.get("department", "") or "", |
| r.get("type", ""), |
| r.get("similarity", ""), |
| r.get("live_score", ""), |
| loc.get("latitude", "") or "", |
| loc.get("longitude", "") or "", |
| loc.get("address", "") or "", |
| ]) |
| buf.seek(0) |
| return StreamingResponse( |
| iter([buf.getvalue()]), |
| media_type="text/csv", |
| headers={"Content-Disposition": f"attachment; filename=attendance_{date or 'all'}.csv"}, |
| ) |
|
|