Spaces:
Runtime error
Runtime error
| 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") | |
| 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 | |
| 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 | |
| def list_employees(role: str = Depends(get_current_role)): | |
| return db.get_all_employees() | |
| 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} | |
| 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 | |
| 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 | |
| } | |
| 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} | |
| 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 | |
| def fetch_logs(limit: int = 100, role: str = Depends(get_current_role)): | |
| return db.get_attendance_history(limit) | |
| def fetch_stats(role: str = Depends(get_current_role)): | |
| return db.get_todays_stats() | |
| def fetch_analytics(role: str = Depends(get_current_role)): | |
| return { | |
| "weekly": db.get_weekly_attendance(), | |
| "hourly": db.get_hourly_attendance() | |
| } | |
| # 4. Camera Setup API | |
| 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}."} | |
| 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) | |
| 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) | |
| 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") | |
| def serve_index(): | |
| return RedirectResponse(url="/static/index.html") | |
| else: | |
| print(f"[-] Warning: Frontend directory {FRONTEND_DIR} not found. Serve endpoints only.") | |