lifedebugger's picture
Deploy files from GitHub repository with LFS
e249c2c
Raw
History Blame Contribute Delete
21 kB
"""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()
# ---------------- Config ----------------
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"))
# ---------------- Model container ----------------
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 ----------------
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=["*"],
)
# ---------------- Utils ----------------
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])
# class index 1 is "real"
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
# take face with largest area
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
# ---------------- Schemas ----------------
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 # if provided, search only this user
challenges_passed: List[str] = Field(default_factory=list)
challenge_token: Optional[str] = None
class AttendanceRequest(BaseModel):
image: str
type: str # 'check-in' or 'check-out'
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
# In-memory challenge tokens (short lived)
_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,
}
# cleanup old
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"
# ---------------- Endpoints ----------------
@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:
# crop face for thumbnail
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"}}
# Texture-based anti-spoof
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")
# 1) challenges
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,
}
# Save attendance
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)
# unique check-ins today
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"])
# Late if check-in after 09:00 UTC (adjust in prod)
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"},
)