Nishant Katiyar
Deploy biometric node to HF Spaces
b561839
Raw
History Blame Contribute Delete
16.1 kB
import os
import asyncio
import queue
import cv2
import numpy as np
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, status, Depends, Header, Query, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from backend.schemas import EmployeeCreate, EnrollmentCaptureRequest, CameraSelectRequest
import backend.database as db
from backend.camera import VideoCamera, attendance_alerts
# local USERS credentials mapping and role scopes
USERS = {
"admin": "admin123",
"hr": "hr123",
"kiosk": "kiosk123"
}
ROLES = {
"admin": "admin",
"hr": "hr",
"kiosk": "kiosk"
}
class LoginRequest(BaseModel):
username: str
password: str
def get_current_role(authorization: str = Header(None), token: str = Query(None)):
"""Extracts and validates mock-token from Header or Query parameters."""
raw_token = None
if isinstance(authorization, str) and authorization.startswith("Bearer "):
raw_token = authorization.split(" ")[1]
elif token:
raw_token = token
if not raw_token or not raw_token.startswith("mock-token-"):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired or authorization token missing."
)
# Format: mock-token-{username}-{role}
parts = raw_token.split("-")
if len(parts) < 4:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authorization token format."
)
username = parts[2]
role = parts[3]
if username not in USERS or ROLES[username] != role:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Unauthorized security token context."
)
return role
# Define static directories
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FRONTEND_DIR = os.path.join(BASE_DIR, "frontend")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: Initialize the VideoCamera instance securely
# (Webcam will start dynamically only upon authorized streaming requests)
camera = VideoCamera()
print("[+] SecureAttend Biometric Attendance Node initialized and active.")
yield
# Shutdown: Release webcam resources safely
camera.stop()
print("[+] Webcam stream released on app shutdown.")
app = FastAPI(
title="AI Face Recognition Attendance System",
description="Production-ready localized attendance module utilizing OpenCV YuNet + SFace",
version="1.0.0",
lifespan=lifespan
)
# Configure CORS for local development
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API Endpoints
@app.post("/api/auth/login")
def auth_login(req: LoginRequest):
username = req.username.lower().strip()
password = req.password.strip()
if username in USERS and USERS[username] == password:
role = ROLES[username]
return {
"token": f"mock-token-{username}-{role}",
"role": role,
"username": username
}
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password. Secure access denied."
)
# 1. Employees Management
@app.get("/api/employees")
def list_employees(role: str = Depends(get_current_role)):
return db.get_all_employees()
@app.post("/api/employees", status_code=status.HTTP_201_CREATED)
def create_employee(emp: EmployeeCreate, role: str = Depends(get_current_role)):
if role not in ["admin", "hr"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. Requires HR or Admin privileges."
)
# Check if ID or Email already exists
existing = db.get_employee(emp.id)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Employee ID already registered."
)
success = db.add_employee(emp.id, emp.name, emp.email, emp.role)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to register employee. Ensure email is unique."
)
return {"message": "Employee registered successfully", "employee_id": emp.id}
@app.delete("/api/employees/{emp_id}")
def remove_employee(emp_id: str, role: str = Depends(get_current_role)):
if role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. Requires Admin privileges."
)
existing = db.get_employee(emp_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Employee not found."
)
success = db.delete_employee(emp_id)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete employee profile."
)
# Reload embeddings cache immediately to purge deleted user
VideoCamera().engine.reload_embeddings()
return {"message": f"Employee {emp_id} successfully deleted."}
# 2. Multi-Angle Enrollment Capture Endpoint
@app.post("/api/enroll/capture")
def capture_enrollment_frame(req: EnrollmentCaptureRequest, role: str = Depends(get_current_role)):
if role not in ["admin", "hr"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. Requires HR or Admin privileges."
)
camera = VideoCamera()
# 1. Validate employee exists
emp = db.get_employee(req.employee_id)
if not emp:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Employee profile must be created first before capturing face scans."
)
# 2. Pull the latest raw frame and pre-detected faces from the active thread-safe capture loop
raw_frame, faces = camera.get_latest_face_sample()
if raw_frame is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Webcam frame is currently loading or unavailable. Make sure webcam is active!"
)
if faces is None or len(faces) == 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="No face detected in camera view! Position yourself centered in the frame."
)
if len(faces) > 1:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Multiple faces detected. Ensure only the enrolling employee is visible in front of the camera!"
)
# 4. Extract embedding from the single detected face
face = faces[0]
embedding = camera.engine.extract_embedding(raw_frame, face)
if embedding is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to extract biometric features. Re-align and try again."
)
# 5. Save raw bytes in SQLite
save_success = db.save_face_embedding(req.employee_id, req.angle.lower(), embedding)
if not save_success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to write face embedding to database."
)
# 6. Reload engine's cache in background to integrate new embedding
camera.engine.reload_embeddings()
# 7. Check if all 5 angles are complete
return {
"message": f"Successfully captured {req.angle.upper()} scan!",
"angle": req.angle,
"is_fully_enrolled": is_fully_enrolled
}
@app.post("/api/scan/frame")
async def scan_frame(file: UploadFile = File(...), role: str = Depends(get_current_role)):
# Read and decode the image file
contents = await file.read()
nparr = np.frombuffer(contents, np.uint8)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if frame is None:
raise HTTPException(status_code=400, detail="Invalid image frame received.")
camera = VideoCamera()
# Perform face detection
retval, faces = camera.engine.detect_faces(frame)
results = []
if faces is not None and len(faces) > 0:
for face in faces:
# Parse bounding box and landmarks
bbox = face[0:4].astype(int).tolist() # [x, y, w, h]
landmarks = face[4:14].reshape(5, 2).astype(int).tolist() # [[x, y], ...]
# Extract embedding & match
emp_id, emp_name, match_score = None, "Unknown", 0.0
query_emb = camera.engine.extract_embedding(frame, face)
if query_emb is not None:
emp_id, emp_name, match_score = camera.engine.match_face(query_emb)
# Process attendance check-in/out
event_status = ""
if emp_id:
event_status = camera._process_attendance_trigger(emp_id, emp_name, match_score)
results.append({
"bbox": bbox,
"landmarks": landmarks,
"employee_id": emp_id,
"name": emp_name,
"match_score": float(match_score),
"event_status": event_status
})
return {"faces": results}
@app.post("/api/enroll/upload-frame")
async def enroll_upload_frame(
employee_id: str = Form(...),
angle: str = Form(...),
file: UploadFile = File(...),
role: str = Depends(get_current_role)
):
if role not in ["admin", "hr"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. Requires HR or Admin privileges."
)
# Decode frame
contents = await file.read()
nparr = np.frombuffer(contents, np.uint8)
frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if frame is None:
raise HTTPException(status_code=400, detail="Invalid image frame received.")
camera = VideoCamera()
# 1. Validate employee exists
emp = db.get_employee(employee_id)
if not emp:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Employee profile must be created first before capturing face scans."
)
# 2. Detect faces
retval, faces = camera.engine.detect_faces(frame)
if faces is None or len(faces) == 0:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="No face detected in camera view! Position yourself centered in the frame."
)
if len(faces) > 1:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Multiple faces detected. Ensure only the enrolling employee is visible!"
)
# 3. Extract embedding
face = faces[0]
embedding = camera.engine.extract_embedding(frame, face)
if embedding is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to extract biometric features. Re-align and try again."
)
# 4. Save embedding
save_success = db.save_face_embedding(employee_id, angle.lower(), embedding)
if not save_success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to write face embedding to database."
)
# 5. Reload engine's cache
camera.engine.reload_embeddings()
# 6. Check enrollment status
is_fully_enrolled = db.has_all_embeddings(employee_id)
return {
"message": f"Successfully captured {angle.upper()} scan!",
"angle": angle,
"is_fully_enrolled": is_fully_enrolled
}
# 3. Attendance Reports
@app.get("/api/attendance/logs")
def fetch_logs(limit: int = 100, role: str = Depends(get_current_role)):
return db.get_attendance_history(limit)
@app.get("/api/attendance/stats")
def fetch_stats(role: str = Depends(get_current_role)):
return db.get_todays_stats()
@app.get("/api/attendance/analytics")
def fetch_analytics(role: str = Depends(get_current_role)):
return {
"weekly": db.get_weekly_attendance(),
"hourly": db.get_hourly_attendance()
}
# 4. Camera Setup API
@app.post("/api/camera/select")
def select_camera(req: CameraSelectRequest, role: str = Depends(get_current_role)):
if role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. Requires Admin privileges."
)
camera = VideoCamera()
success = camera.change_camera(req.index)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Could not connect to camera device index {req.index}."
)
return {"message": f"Successfully switched camera to index {req.index}."}
@app.post("/api/camera/toggle")
def toggle_camera(req: dict, role: str = Depends(get_current_role)):
if role != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied. Requires Admin privileges."
)
active = req.get("active", True)
camera = VideoCamera()
if active:
success = camera.start()
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to initialize webcam."
)
return {"message": "Camera stream enabled successfully.", "active": True}
else:
camera.stop()
return {"message": "Camera stream disabled successfully.", "active": False}
# 5. Live MJPEG Streaming Route
def mjpeg_generator(camera: VideoCamera):
while True:
# Fetch latest processed frame bytes
frame_bytes = camera.get_processed_frame_bytes()
# Yield as standard boundary frame
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n')
# Cap streaming transfer frequency
time_to_sleep = 0.033
import time
time.sleep(time_to_sleep)
@app.get("/api/stream/live")
def get_live_mjpeg_stream(token: str = Query(None)):
get_current_role(token=token) # Validate token query parameter
camera = VideoCamera()
# Force start camera if it's currently stopped
camera.start()
return StreamingResponse(
mjpeg_generator(camera),
media_type="multipart/x-mixed-replace; boundary=frame"
)
# 6. Real-Time Alert Event Stream (SSE)
async def alert_event_generator():
while True:
try:
# Poll for any new logs logged by the camera background thread
# Non-blocking pull from queue
alert = attendance_alerts.get_nowait()
import json
yield f"data: {json.dumps(alert)}\n\n"
except queue.Empty:
# Send keep-alive comment every 15 seconds to keep browser connection active
await asyncio.sleep(0.5)
@app.get("/api/stream/alerts")
def get_alert_stream(token: str = Query(None)):
get_current_role(token=token) # Validate token query parameter
return StreamingResponse(
alert_event_generator(),
media_type="text/event-stream"
)
# Mount Frontend Static Assets
if os.path.exists(FRONTEND_DIR):
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
@app.get("/")
def serve_index():
return RedirectResponse(url="/static/index.html")
else:
print(f"[-] Warning: Frontend directory {FRONTEND_DIR} not found. Serve endpoints only.")