Spaces:
Running
Running
Commit Β·
5fde056
0
Parent(s):
Implemented the first version of the application, handled the UI, face recognition, and database handling
Browse files- Dockerfile +36 -0
- README.md +64 -0
- __pycache__/main.cpython-313.pyc +0 -0
- init_db.py +56 -0
- main.py +317 -0
- requirements.txt +11 -0
- test_core_flows.py +132 -0
- test_db.py +14 -0
- test_face.jpg +0 -0
Dockerfile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use the official Python 3.10 image as a base
|
| 2 |
+
FROM python:3.10-slim
|
| 3 |
+
|
| 4 |
+
# Install necessary system dependencies for OpenCV and other heavy ML tools
|
| 5 |
+
# DeepFace and OpenCV require libgl1 and libglib2.0
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
libgl1-mesa-glx \
|
| 8 |
+
libglib2.0-0 \
|
| 9 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
# Set the working directory to /app
|
| 12 |
+
WORKDIR /app
|
| 13 |
+
|
| 14 |
+
# Copy requirement files first to cache dependencies correctly
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
|
| 17 |
+
# Install dependencies using pip
|
| 18 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 19 |
+
|
| 20 |
+
# Create a non-root user required by Hugging Face Spaces environment
|
| 21 |
+
# Since HF Spaces run on port 7860 by default under non-root
|
| 22 |
+
RUN useradd -m -u 1000 user
|
| 23 |
+
USER user
|
| 24 |
+
ENV HOME=/home/user \
|
| 25 |
+
PATH=/home/user/.local/bin:$PATH
|
| 26 |
+
|
| 27 |
+
WORKDIR $HOME/app
|
| 28 |
+
|
| 29 |
+
# Copy the rest of the application files in the directory
|
| 30 |
+
COPY --chown=user . $HOME/app
|
| 31 |
+
|
| 32 |
+
# Hugging Face Spaces expose port 7860 by default
|
| 33 |
+
EXPOSE 7860
|
| 34 |
+
|
| 35 |
+
# Run uvicorn on port 7860, bound to 0.0.0.0
|
| 36 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# VisionAttend Backend
|
| 2 |
+
|
| 3 |
+
The backend of `VisionAttend` handles the computational heavy lifting. It exposes the API endpoints necessary for student management and uses a DeepFace model for instantaneous facial embedding extraction.
|
| 4 |
+
|
| 5 |
+
## Features
|
| 6 |
+
|
| 7 |
+
- **FastAPI Core**: Utilizes asynchronous endpoints for maximum concurrency.
|
| 8 |
+
- **DeepFace Integration**: Leverages the `Facenet` model for robust 128-dimensional encodings. Uses the `opencv` detector backend for rapid alignment performance compared to `retinaface` or `mtcnn`.
|
| 9 |
+
- **Postgres pgvector Matchmaking**: Embeddings are stored in Neon Postgres using `pgvector`. A student is recognized instantly using the `<=>` cosine distance operator to find the closest match under a set confidence threshold (0.40).
|
| 10 |
+
|
| 11 |
+
## Requirements
|
| 12 |
+
|
| 13 |
+
Before starting, ensure you have a standard Python 3.10+ environment.
|
| 14 |
+
|
| 15 |
+
- `fastapi`
|
| 16 |
+
- `uvicorn`
|
| 17 |
+
- `deepface`
|
| 18 |
+
- `psycopg` (with pool and pgvector integration)
|
| 19 |
+
- `python-dotenv`
|
| 20 |
+
- `numpy` & `pillow`
|
| 21 |
+
|
| 22 |
+
## Setup & Running
|
| 23 |
+
|
| 24 |
+
1. **Virtual Environment Setup**
|
| 25 |
+
```bash
|
| 26 |
+
python -m venv venv
|
| 27 |
+
source venv/bin/activate
|
| 28 |
+
pip install -r requirements.txt
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
2. **Environment Configuration**
|
| 32 |
+
Create a `.env` file referencing your Neon Postgres database containing the `pgvector` extension.
|
| 33 |
+
```env
|
| 34 |
+
DATABASE_URL="postgres://user:password@host/dbname?sslmode=require"
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
3. **Initialize the Database**
|
| 38 |
+
Before running the app, execute the init script to build the exact table architecture needed.
|
| 39 |
+
```bash
|
| 40 |
+
python init_db.py
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
4. **Launch Server**
|
| 44 |
+
```bash
|
| 45 |
+
uvicorn main:app --reload --port 8000
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
## REST API Overview
|
| 49 |
+
|
| 50 |
+
All routes accept appropriate `multipart/form-data` for image payloads or generic `application/json` strings.
|
| 51 |
+
|
| 52 |
+
- `POST /api/register`: Stores a new student photo representation.
|
| 53 |
+
- `POST /api/recognize`: Validates a live frame against existing students and marks attendance.
|
| 54 |
+
- `GET /api/attendance`: Retrieves attendance logs, optionally filtered by `?date_filter=YYYY-MM-DD`.
|
| 55 |
+
- `PUT /api/students/{id}`: Manual override to rename or change a roll number.
|
| 56 |
+
- `DELETE /api/students/{id}`: Purges a student and all historical logs.
|
| 57 |
+
- `POST /api/attendance/manual`: Enables manual toggling of "Present" status without the camera.
|
| 58 |
+
|
| 59 |
+
## Testing
|
| 60 |
+
|
| 61 |
+
A programmatic integration test suite mimicking camera requests is available:
|
| 62 |
+
```bash
|
| 63 |
+
python test_core_flows.py
|
| 64 |
+
```
|
__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (16.8 kB). View file
|
|
|
init_db.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import psycopg
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
DATABASE_URL = os.getenv("DATABASE_URL")
|
| 8 |
+
|
| 9 |
+
if not DATABASE_URL:
|
| 10 |
+
raise ValueError("DATABASE_URL environment variable is not set")
|
| 11 |
+
|
| 12 |
+
print(f"Connecting to database...")
|
| 13 |
+
|
| 14 |
+
def init_db():
|
| 15 |
+
try:
|
| 16 |
+
# Connect to the Neon database
|
| 17 |
+
with psycopg.connect(DATABASE_URL) as conn:
|
| 18 |
+
with conn.cursor() as cur:
|
| 19 |
+
print("Connected! Enabling vector extension...")
|
| 20 |
+
cur.execute("CREATE EXTENSION IF NOT EXISTS vector;")
|
| 21 |
+
|
| 22 |
+
print("Creating students table...")
|
| 23 |
+
cur.execute("""
|
| 24 |
+
CREATE TABLE IF NOT EXISTS students (
|
| 25 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 26 |
+
name VARCHAR(255) NOT NULL,
|
| 27 |
+
roll_number VARCHAR(100) UNIQUE NOT NULL,
|
| 28 |
+
-- Note: We use an arbitrary vector dimension size
|
| 29 |
+
-- However, we must specify the exact dimension if we want pgvector HNSW index.
|
| 30 |
+
-- dlib face embeddings (used by face_recognition) are 128 dimensions.
|
| 31 |
+
-- deepface (VGG-Face) is 2622, Facenet is 128, ArcFace is 512, Datashape is 512.
|
| 32 |
+
-- By default deepface uses VGG-Face (2622) or Facenet (128). Let's use 128 (Facenet or Dlib) or we can leave it dynamic without dimension limit.
|
| 33 |
+
-- Since we switched to deepface, let's just use `vector` (dynamic)
|
| 34 |
+
face_encoding vector NOT NULL,
|
| 35 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 36 |
+
);
|
| 37 |
+
""")
|
| 38 |
+
|
| 39 |
+
print("Creating attendance table...")
|
| 40 |
+
cur.execute("""
|
| 41 |
+
CREATE TABLE IF NOT EXISTS attendance (
|
| 42 |
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
| 43 |
+
student_id UUID REFERENCES students(id) ON DELETE CASCADE,
|
| 44 |
+
timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 45 |
+
status VARCHAR(50) DEFAULT 'Present'
|
| 46 |
+
);
|
| 47 |
+
""")
|
| 48 |
+
|
| 49 |
+
conn.commit()
|
| 50 |
+
print("Database initialized successfully!")
|
| 51 |
+
|
| 52 |
+
except Exception as e:
|
| 53 |
+
print(f"Error initializing database: {e}")
|
| 54 |
+
|
| 55 |
+
if __name__ == "__main__":
|
| 56 |
+
init_db()
|
main.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
import psycopg
|
| 6 |
+
from pgvector.psycopg import register_vector
|
| 7 |
+
import numpy as np
|
| 8 |
+
import uuid
|
| 9 |
+
import io
|
| 10 |
+
from PIL import Image
|
| 11 |
+
from deepface import DeepFace
|
| 12 |
+
from datetime import date
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
|
| 15 |
+
load_dotenv()
|
| 16 |
+
|
| 17 |
+
app = FastAPI(title="VisionAttend API")
|
| 18 |
+
|
| 19 |
+
# Configure CORS
|
| 20 |
+
app.add_middleware(
|
| 21 |
+
CORSMiddleware,
|
| 22 |
+
allow_origins=["*"], # In production, replace with frontend URL
|
| 23 |
+
allow_credentials=True,
|
| 24 |
+
allow_methods=["*"],
|
| 25 |
+
allow_headers=["*"],
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
DATABASE_URL = os.getenv("DATABASE_URL")
|
| 29 |
+
|
| 30 |
+
class MatchResponse(BaseModel):
|
| 31 |
+
message: str
|
| 32 |
+
student_name: str
|
| 33 |
+
roll_number: str
|
| 34 |
+
|
| 35 |
+
from psycopg_pool import ConnectionPool
|
| 36 |
+
|
| 37 |
+
def configure_conn(conn):
|
| 38 |
+
register_vector(conn)
|
| 39 |
+
|
| 40 |
+
# By using a ConnectionPool, we keep the TCP connection alive to Neon.
|
| 41 |
+
# This eliminates the ~6-8 second TLS/SSL handshake delay on every request.
|
| 42 |
+
db_pool = ConnectionPool(
|
| 43 |
+
conninfo=DATABASE_URL,
|
| 44 |
+
configure=configure_conn,
|
| 45 |
+
min_size=1,
|
| 46 |
+
max_size=5,
|
| 47 |
+
timeout=30.0
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
def get_db_connection():
|
| 51 |
+
return db_pool.connection()
|
| 52 |
+
|
| 53 |
+
def extract_face_embedding(file_bytes: bytes) -> tuple[list[float], str]:
|
| 54 |
+
try:
|
| 55 |
+
image = Image.open(io.BytesIO(file_bytes)).convert("RGB")
|
| 56 |
+
img_array = np.array(image)
|
| 57 |
+
|
| 58 |
+
results = DeepFace.represent(
|
| 59 |
+
img_path=img_array,
|
| 60 |
+
enforce_detection=True,
|
| 61 |
+
model_name="Facenet",
|
| 62 |
+
detector_backend="opencv"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
if len(results) == 0:
|
| 66 |
+
raise ValueError("No face detected.")
|
| 67 |
+
if len(results) > 1:
|
| 68 |
+
raise ValueError("Multiple faces detected. Please show only one face.")
|
| 69 |
+
|
| 70 |
+
embedding = results[0]["embedding"]
|
| 71 |
+
return embedding, "success"
|
| 72 |
+
except ValueError as ve:
|
| 73 |
+
return [], str(ve)
|
| 74 |
+
except Exception as e:
|
| 75 |
+
return [], f"An error occurred during face extraction: {str(e)}"
|
| 76 |
+
|
| 77 |
+
@app.post("/api/register")
|
| 78 |
+
async def register_student(
|
| 79 |
+
name: str = Form(...),
|
| 80 |
+
roll_number: str = Form(...),
|
| 81 |
+
image: UploadFile = File(...)
|
| 82 |
+
):
|
| 83 |
+
file_bytes = await image.read()
|
| 84 |
+
|
| 85 |
+
embedding, error = extract_face_embedding(file_bytes)
|
| 86 |
+
if not embedding:
|
| 87 |
+
raise HTTPException(status_code=400, detail=error)
|
| 88 |
+
|
| 89 |
+
try:
|
| 90 |
+
with get_db_connection() as conn:
|
| 91 |
+
with conn.cursor() as cur:
|
| 92 |
+
cur.execute("SELECT id FROM students WHERE roll_number = %s", (roll_number,))
|
| 93 |
+
if cur.fetchone():
|
| 94 |
+
raise HTTPException(status_code=400, detail="Student with this roll number already exists.")
|
| 95 |
+
|
| 96 |
+
cur.execute(
|
| 97 |
+
"INSERT INTO students (name, roll_number, face_encoding) VALUES (%s, %s, %s) RETURNING id",
|
| 98 |
+
(name, roll_number, str(embedding))
|
| 99 |
+
)
|
| 100 |
+
student_id = cur.fetchone()[0]
|
| 101 |
+
conn.commit()
|
| 102 |
+
|
| 103 |
+
return {"message": "Student registered successfully", "student_id": student_id}
|
| 104 |
+
except HTTPException:
|
| 105 |
+
raise
|
| 106 |
+
except Exception as e:
|
| 107 |
+
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@app.post("/api/recognize", response_model=MatchResponse)
|
| 111 |
+
async def recognize_student(image: UploadFile = File(...)):
|
| 112 |
+
file_bytes = await image.read()
|
| 113 |
+
|
| 114 |
+
embedding, error = extract_face_embedding(file_bytes)
|
| 115 |
+
if not embedding:
|
| 116 |
+
raise HTTPException(status_code=400, detail=error)
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
with get_db_connection() as conn:
|
| 120 |
+
with conn.cursor() as cur:
|
| 121 |
+
cur.execute("""
|
| 122 |
+
SELECT id, name, roll_number, (face_encoding <=> %s::vector) AS distance
|
| 123 |
+
FROM students
|
| 124 |
+
ORDER BY distance ASC
|
| 125 |
+
LIMIT 1;
|
| 126 |
+
""", (str(embedding),))
|
| 127 |
+
|
| 128 |
+
result = cur.fetchone()
|
| 129 |
+
|
| 130 |
+
if not result:
|
| 131 |
+
raise HTTPException(status_code=404, detail="No match found in database.")
|
| 132 |
+
|
| 133 |
+
student_id, name, roll_number, distance = result
|
| 134 |
+
print(f"Discovered {name} with distance {distance}")
|
| 135 |
+
|
| 136 |
+
if distance > 0.40: # Threshold for Facenet
|
| 137 |
+
raise HTTPException(status_code=404, detail="Face recognized but distance exceeds confidence threshold.")
|
| 138 |
+
|
| 139 |
+
cur.execute("""
|
| 140 |
+
SELECT id FROM attendance
|
| 141 |
+
WHERE student_id = %s AND timestamp::date = %s
|
| 142 |
+
""", (student_id, date.today()))
|
| 143 |
+
|
| 144 |
+
if not cur.fetchone():
|
| 145 |
+
cur.execute(
|
| 146 |
+
"INSERT INTO attendance (student_id, status) VALUES (%s, 'Present')",
|
| 147 |
+
(student_id,)
|
| 148 |
+
)
|
| 149 |
+
conn.commit()
|
| 150 |
+
message = "Attendance marked."
|
| 151 |
+
else:
|
| 152 |
+
message = "Attendance already marked for today."
|
| 153 |
+
|
| 154 |
+
return MatchResponse(message=message, student_name=name, roll_number=roll_number)
|
| 155 |
+
|
| 156 |
+
except HTTPException:
|
| 157 |
+
raise
|
| 158 |
+
except Exception as e:
|
| 159 |
+
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
| 160 |
+
|
| 161 |
+
@app.get("/api/attendance")
|
| 162 |
+
async def get_attendance(date_filter: str = None):
|
| 163 |
+
try:
|
| 164 |
+
with get_db_connection() as conn:
|
| 165 |
+
with conn.cursor() as cur:
|
| 166 |
+
if date_filter:
|
| 167 |
+
cur.execute("""
|
| 168 |
+
SELECT s.id, s.name, s.roll_number, a.timestamp,
|
| 169 |
+
CASE WHEN a.id IS NOT NULL THEN 'Present' ELSE 'Absent' END as status,
|
| 170 |
+
s.id as student_id
|
| 171 |
+
FROM students s
|
| 172 |
+
LEFT JOIN attendance a ON s.id = a.student_id AND DATE(a.timestamp) = %s
|
| 173 |
+
ORDER BY
|
| 174 |
+
CASE WHEN a.id IS NOT NULL THEN 0 ELSE 1 END,
|
| 175 |
+
s.name ASC
|
| 176 |
+
LIMIT 100;
|
| 177 |
+
""", (date_filter,))
|
| 178 |
+
rows = cur.fetchall()
|
| 179 |
+
logs = [
|
| 180 |
+
{
|
| 181 |
+
"id": str(row[0]),
|
| 182 |
+
"name": row[1],
|
| 183 |
+
"roll_number": row[2],
|
| 184 |
+
"timestamp": row[3],
|
| 185 |
+
"status": row[4],
|
| 186 |
+
"student_id": str(row[5])
|
| 187 |
+
} for row in rows
|
| 188 |
+
]
|
| 189 |
+
else:
|
| 190 |
+
cur.execute("""
|
| 191 |
+
SELECT a.id, s.name, s.roll_number, a.timestamp, a.status, s.id as student_id
|
| 192 |
+
FROM attendance a
|
| 193 |
+
JOIN students s ON a.student_id = s.id
|
| 194 |
+
ORDER BY a.timestamp DESC
|
| 195 |
+
LIMIT 100;
|
| 196 |
+
""")
|
| 197 |
+
rows = cur.fetchall()
|
| 198 |
+
logs = [
|
| 199 |
+
{
|
| 200 |
+
"id": str(row[0]),
|
| 201 |
+
"name": row[1],
|
| 202 |
+
"roll_number": row[2],
|
| 203 |
+
"timestamp": row[3],
|
| 204 |
+
"status": row[4],
|
| 205 |
+
"student_id": str(row[5])
|
| 206 |
+
} for row in rows
|
| 207 |
+
]
|
| 208 |
+
return {"logs": logs}
|
| 209 |
+
except Exception as e:
|
| 210 |
+
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
| 211 |
+
|
| 212 |
+
class StudentUpdate(BaseModel):
|
| 213 |
+
name: str
|
| 214 |
+
roll_number: str
|
| 215 |
+
|
| 216 |
+
@app.put("/api/students/{student_id}")
|
| 217 |
+
async def update_student(student_id: uuid.UUID, data: StudentUpdate):
|
| 218 |
+
try:
|
| 219 |
+
with get_db_connection() as conn:
|
| 220 |
+
with conn.cursor() as cur:
|
| 221 |
+
cur.execute("SELECT id FROM students WHERE roll_number = %s AND id != %s", (data.roll_number, student_id))
|
| 222 |
+
if cur.fetchone():
|
| 223 |
+
raise HTTPException(status_code=400, detail="Another student with this roll number already exists.")
|
| 224 |
+
|
| 225 |
+
cur.execute(
|
| 226 |
+
"UPDATE students SET name = %s, roll_number = %s WHERE id = %s RETURNING id",
|
| 227 |
+
(data.name, data.roll_number, student_id)
|
| 228 |
+
)
|
| 229 |
+
if not cur.fetchone():
|
| 230 |
+
raise HTTPException(status_code=404, detail="Student not found.")
|
| 231 |
+
conn.commit()
|
| 232 |
+
return {"message": "Student updated successfully."}
|
| 233 |
+
except HTTPException:
|
| 234 |
+
raise
|
| 235 |
+
except Exception as e:
|
| 236 |
+
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
| 237 |
+
|
| 238 |
+
@app.delete("/api/students/{student_id}")
|
| 239 |
+
async def delete_student(student_id: uuid.UUID):
|
| 240 |
+
try:
|
| 241 |
+
with get_db_connection() as conn:
|
| 242 |
+
with conn.cursor() as cur:
|
| 243 |
+
cur.execute("DELETE FROM students WHERE id = %s RETURNING id", (student_id,))
|
| 244 |
+
if not cur.fetchone():
|
| 245 |
+
raise HTTPException(status_code=404, detail="Student not found.")
|
| 246 |
+
conn.commit()
|
| 247 |
+
return {"message": "Student deleted successfully."}
|
| 248 |
+
except HTTPException:
|
| 249 |
+
raise
|
| 250 |
+
except Exception as e:
|
| 251 |
+
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
| 252 |
+
|
| 253 |
+
@app.put("/api/students/{student_id}/photo")
|
| 254 |
+
async def update_student_photo(student_id: uuid.UUID, image: UploadFile = File(...)):
|
| 255 |
+
file_bytes = await image.read()
|
| 256 |
+
embedding, error = extract_face_embedding(file_bytes)
|
| 257 |
+
if not embedding:
|
| 258 |
+
raise HTTPException(status_code=400, detail=error)
|
| 259 |
+
|
| 260 |
+
try:
|
| 261 |
+
with get_db_connection() as conn:
|
| 262 |
+
with conn.cursor() as cur:
|
| 263 |
+
cur.execute(
|
| 264 |
+
"UPDATE students SET face_encoding = %s WHERE id = %s RETURNING id",
|
| 265 |
+
(str(embedding), student_id)
|
| 266 |
+
)
|
| 267 |
+
if not cur.fetchone():
|
| 268 |
+
raise HTTPException(status_code=404, detail="Student not found.")
|
| 269 |
+
conn.commit()
|
| 270 |
+
return {"message": "Student photo updated successfully."}
|
| 271 |
+
except HTTPException:
|
| 272 |
+
raise
|
| 273 |
+
except Exception as e:
|
| 274 |
+
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
| 275 |
+
|
| 276 |
+
class ManualAttendance(BaseModel):
|
| 277 |
+
student_id: uuid.UUID
|
| 278 |
+
date: str # YYYY-MM-DD
|
| 279 |
+
status: str # "Present" or "Absent"
|
| 280 |
+
|
| 281 |
+
@app.post("/api/attendance/manual")
|
| 282 |
+
async def mark_manual_attendance(data: ManualAttendance):
|
| 283 |
+
try:
|
| 284 |
+
with get_db_connection() as conn:
|
| 285 |
+
with conn.cursor() as cur:
|
| 286 |
+
if data.status == "Present":
|
| 287 |
+
cur.execute("""
|
| 288 |
+
SELECT id FROM attendance
|
| 289 |
+
WHERE student_id = %s AND timestamp::date = %s
|
| 290 |
+
""", (data.student_id, data.date))
|
| 291 |
+
|
| 292 |
+
if not cur.fetchone():
|
| 293 |
+
cur.execute(
|
| 294 |
+
"INSERT INTO attendance (student_id, status, timestamp) VALUES (%s, 'Present', %s)",
|
| 295 |
+
(data.student_id, f"{data.date} 12:00:00") # Default to noon for manual entries
|
| 296 |
+
)
|
| 297 |
+
conn.commit()
|
| 298 |
+
return {"message": "Marked present successfully."}
|
| 299 |
+
else:
|
| 300 |
+
return {"message": "Already marked present for this date."}
|
| 301 |
+
elif data.status == "Absent":
|
| 302 |
+
cur.execute("""
|
| 303 |
+
DELETE FROM attendance
|
| 304 |
+
WHERE student_id = %s AND timestamp::date = %s
|
| 305 |
+
""", (data.student_id, data.date))
|
| 306 |
+
conn.commit()
|
| 307 |
+
return {"message": "Marked absent successfully."}
|
| 308 |
+
else:
|
| 309 |
+
raise HTTPException(status_code=400, detail="Invalid status.")
|
| 310 |
+
except HTTPException:
|
| 311 |
+
raise
|
| 312 |
+
except Exception as e:
|
| 313 |
+
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
| 314 |
+
|
| 315 |
+
@app.get("/")
|
| 316 |
+
def read_root():
|
| 317 |
+
return {"status": "ok", "message": "VisionAttend API is active"}
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
psycopg[binary]
|
| 4 |
+
pgvector
|
| 5 |
+
python-multipart
|
| 6 |
+
numpy
|
| 7 |
+
deepface
|
| 8 |
+
tf-keras
|
| 9 |
+
pydantic
|
| 10 |
+
python-dotenv
|
| 11 |
+
cors
|
test_core_flows.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
import uuid
|
| 5 |
+
from datetime import date
|
| 6 |
+
|
| 7 |
+
# Base URL for the FastAPI server
|
| 8 |
+
BASE_URL = "http://localhost:8000"
|
| 9 |
+
|
| 10 |
+
# Path to the test image
|
| 11 |
+
IMAGE_PATH = "test_face.jpg"
|
| 12 |
+
|
| 13 |
+
def download_test_image():
|
| 14 |
+
if not os.path.exists(IMAGE_PATH):
|
| 15 |
+
print(f"Downloading test image to {IMAGE_PATH}...")
|
| 16 |
+
url = "https://raw.githubusercontent.com/opencv/opencv/master/samples/data/lena.jpg"
|
| 17 |
+
response = requests.get(url)
|
| 18 |
+
if response.status_code == 200:
|
| 19 |
+
with open(IMAGE_PATH, 'wb') as f:
|
| 20 |
+
f.write(response.content)
|
| 21 |
+
print("Downloaded successfully.")
|
| 22 |
+
else:
|
| 23 |
+
print("Failed to download test image.")
|
| 24 |
+
sys.exit(1)
|
| 25 |
+
|
| 26 |
+
def run_tests():
|
| 27 |
+
print("--- VisionAttend Integration Tests ---")
|
| 28 |
+
|
| 29 |
+
# Generate unique roll number to avoid constraint errors
|
| 30 |
+
roll_number = f"TEST-{uuid.uuid4().hex[:6].upper()}"
|
| 31 |
+
student_name = "Test Student"
|
| 32 |
+
student_id = None
|
| 33 |
+
|
| 34 |
+
print("\n[1] Testing Registration...")
|
| 35 |
+
try:
|
| 36 |
+
with open(IMAGE_PATH, 'rb') as f:
|
| 37 |
+
files = {'image': (IMAGE_PATH, f, 'image/jpeg')}
|
| 38 |
+
data = {'name': student_name, 'roll_number': roll_number}
|
| 39 |
+
response = requests.post(f"{BASE_URL}/api/register", data=data, files=files)
|
| 40 |
+
|
| 41 |
+
if response.status_code == 200:
|
| 42 |
+
result = response.json()
|
| 43 |
+
student_id = result.get('student_id')
|
| 44 |
+
print(f"β
Registration successful. Student ID: {student_id}")
|
| 45 |
+
else:
|
| 46 |
+
print(f"β Registration failed: {response.text}")
|
| 47 |
+
return
|
| 48 |
+
except Exception as e:
|
| 49 |
+
print(f"β Registration exception: {e}")
|
| 50 |
+
return
|
| 51 |
+
|
| 52 |
+
print("\n[2] Testing Recognition & Attendance Marking...")
|
| 53 |
+
try:
|
| 54 |
+
with open(IMAGE_PATH, 'rb') as f:
|
| 55 |
+
files = {'image': (IMAGE_PATH, f, 'image/jpeg')}
|
| 56 |
+
response = requests.post(f"{BASE_URL}/api/recognize", files=files)
|
| 57 |
+
|
| 58 |
+
if response.status_code == 200:
|
| 59 |
+
result = response.json()
|
| 60 |
+
print(f"β
Recognition successful: {result['message']} - {result['student_name']} ({result['roll_number']})")
|
| 61 |
+
assert result['roll_number'] == roll_number
|
| 62 |
+
else:
|
| 63 |
+
print(f"β Recognition failed: {response.text}")
|
| 64 |
+
except Exception as e:
|
| 65 |
+
print(f"β Recognition exception: {e}")
|
| 66 |
+
|
| 67 |
+
print("\n[3] Testing Attendance Logs Query...")
|
| 68 |
+
try:
|
| 69 |
+
today_str = date.today().strftime("%Y-%m-%d")
|
| 70 |
+
response = requests.get(f"{BASE_URL}/api/attendance?date_filter={today_str}")
|
| 71 |
+
if response.status_code == 200:
|
| 72 |
+
logs = response.json().get('logs', [])
|
| 73 |
+
found = False
|
| 74 |
+
for log in logs:
|
| 75 |
+
if log.get('student_id') == str(student_id):
|
| 76 |
+
found = True
|
| 77 |
+
print(f"β
Found student in logs. Status: {log.get('status')}")
|
| 78 |
+
assert log.get('status') == 'Present'
|
| 79 |
+
break
|
| 80 |
+
if not found:
|
| 81 |
+
print("β Student not found in attendance logs.")
|
| 82 |
+
else:
|
| 83 |
+
print(f"β Attendance query failed: {response.text}")
|
| 84 |
+
except Exception as e:
|
| 85 |
+
print(f"β Attendance query exception: {e}")
|
| 86 |
+
|
| 87 |
+
print("\n[4] Testing Manual Attendance Update...")
|
| 88 |
+
try:
|
| 89 |
+
data = {
|
| 90 |
+
"student_id": str(student_id),
|
| 91 |
+
"date": date.today().strftime("%Y-%m-%d"),
|
| 92 |
+
"status": "Absent"
|
| 93 |
+
}
|
| 94 |
+
response = requests.post(f"{BASE_URL}/api/attendance/manual", json=data)
|
| 95 |
+
if response.status_code == 200:
|
| 96 |
+
print("β
Manual attendance marked successfully.")
|
| 97 |
+
else:
|
| 98 |
+
print(f"β Manual attendance failed: {response.text}")
|
| 99 |
+
except Exception as e:
|
| 100 |
+
print(f"β Manual attendance exception: {e}")
|
| 101 |
+
|
| 102 |
+
print("\n[5] Testing Student Details Update...")
|
| 103 |
+
try:
|
| 104 |
+
new_name = "Updated Test Student"
|
| 105 |
+
# We'll use the same roll_number as checking existing logic
|
| 106 |
+
data = {
|
| 107 |
+
"name": new_name,
|
| 108 |
+
"roll_number": roll_number
|
| 109 |
+
}
|
| 110 |
+
response = requests.put(f"{BASE_URL}/api/students/{student_id}", json=data)
|
| 111 |
+
if response.status_code == 200:
|
| 112 |
+
print("β
Student details updated successfully.")
|
| 113 |
+
else:
|
| 114 |
+
print(f"β Student detail update failed: {response.text}")
|
| 115 |
+
except Exception as e:
|
| 116 |
+
print(f"β Student detail update exception: {e}")
|
| 117 |
+
|
| 118 |
+
print("\n[6] Testing Student Deletion (Cleanup)...")
|
| 119 |
+
if student_id:
|
| 120 |
+
try:
|
| 121 |
+
response = requests.delete(f"{BASE_URL}/api/students/{student_id}")
|
| 122 |
+
if response.status_code == 200:
|
| 123 |
+
print("β
Student deleted successfully.")
|
| 124 |
+
else:
|
| 125 |
+
print(f"β Student deletion failed: {response.text}")
|
| 126 |
+
except Exception as e:
|
| 127 |
+
print(f"β Student deletion exception: {e}")
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
if __name__ == "__main__":
|
| 131 |
+
download_test_image()
|
| 132 |
+
run_tests()
|
test_db.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import psycopg
|
| 2 |
+
import os
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
db_url = os.environ.get("DATABASE_URL")
|
| 7 |
+
|
| 8 |
+
conn = psycopg.connect(db_url)
|
| 9 |
+
print("Connected.", conn.closed)
|
| 10 |
+
|
| 11 |
+
with conn:
|
| 12 |
+
print("Inside with block")
|
| 13 |
+
|
| 14 |
+
print("After with block, closed:", conn.closed)
|
test_face.jpg
ADDED
|