MinaNasser commited on
Commit
12d0de7
Β·
1 Parent(s): 09405db

initialcommit

Browse files
.dockerignore ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .Python
6
+ *.so
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ chroma_data/
12
+ assets/*.pt
13
+ .env
14
+ *.log
15
+ .git/
16
+ .gitignore
17
+ README.md
18
+ .DS_Store
19
+ *.pem
20
+ static/crops/*
21
+ !static/crops/.gitkeep
.env ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ APP_NAME=FaceRecognitionAPI
2
+ APP_VERSION=1.0.0
3
+ APP_VARIENT=v1
4
+ host=0.0.0.0
5
+ port=7860
6
+ DETECTION_MODEL=mtcnn
7
+ YOLOFACE_MODEL_PATH=assets/yolov11n-face.pt
8
+ CHROMA_DB_PATH=./chroma_data
9
+ COLLECTION_NAME=face_embeddings_collection
10
+ SIMILARITY_THRESHOLD=0.7
11
+ MAX_RESULTS=1
.env_example ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ APP_NAME="Auto_Proctor"
2
+ APP_VERSION="0.1"
3
+ APP_VARIENT="FaceRecognitionApi"
4
+
5
+
6
+ host="0.0.0.0"
7
+ port=3030
8
+ DETECTION_MODEL="yoloface" # Options: mtcnn, yoloface
9
+
10
+ YOLOFACE_MODEL_PATH="assets/yolov12n-face.pt" # Options: yolov12n-face.pt, yolov12s-face.pt
11
+
12
+ CHROMA_DB_PATH = "./chroma_data"
13
+ COLLECTION_NAME = "face_embeddings_collection"
14
+
15
+ SIMILARITY_THRESHOLD=0.6 # Youstina use them while quering
16
+ MAX_RESULTS=1
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies for OpenCV and other libraries
6
+ RUN apt-get update && apt-get install -y \
7
+ libgl1-mesa-glx \
8
+ libglib2.0-0 \
9
+ libsm6 \
10
+ libxext6 \
11
+ libxrender-dev \
12
+ libgomp1 \
13
+ libglib2.0-0 \
14
+ wget \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Copy requirements first for better caching
18
+ COPY requirements.txt .
19
+ RUN pip install --no-cache-dir -r requirements.txt
20
+
21
+ # Copy the rest of the application
22
+ COPY . .
23
+
24
+ # Create assets directory and download YOLO model
25
+ RUN mkdir -p assets && \
26
+ wget -O assets/yolov11n-face.pt https://github.com/YapaLab/yolo-face/releases/download/1.0.0/yolov11n-face.pt || true
27
+
28
+ # Create directories for ChromaDB and static files
29
+ RUN mkdir -p chroma_data static/crops
30
+
31
+ # Expose port (Hugging Face Spaces uses 7860 by default)
32
+ EXPOSE 7860
33
+
34
+ # Run the application
35
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,53 @@
1
  ---
2
- title: FaceRecognitionAPI
3
- emoji: πŸ‘
4
- colorFrom: gray
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Face Recognition API
3
+ emoji: πŸ‘οΈ
4
+ colorFrom: purple
5
+ colorTo: blue
6
  sdk: docker
7
  pinned: false
8
+ license: mit
9
  ---
10
 
11
+ # Face Recognition API with FastAPI
12
+
13
+ A real-time face detection and recognition API with WebSocket support for streaming video frames.
14
+
15
+ ## Features
16
+
17
+ - 🎯 **Face Detection**: MTCNN or YOLO face detection
18
+ - πŸ” **Face Recognition**: FaceNet embeddings with ChromaDB vector storage
19
+ - πŸ“‘ **WebSocket Streaming**: Real-time face detection over WebSockets
20
+ - πŸ–ΌοΈ **REST API**: Embed, update, delete, and recognize faces
21
+ - 🎨 **Data Augmentation**: Automatic face augmentation for better recognition
22
+ - πŸ“Š **Live Demo UI**: Built-in HTML interface for testing
23
+
24
+ ## Quick Start
25
+
26
+ 1. Access the API at the public URL provided by Hugging Face Spaces
27
+ 2. Use the built-in UI at `/AutoProctor/v1/` for live testing
28
+ 3. Check API docs at `/docs` for Swagger documentation
29
+
30
+ ## API Endpoints
31
+
32
+ ### Base Endpoints
33
+ - `GET /AutoProctor/v1/` - Web interface for testing
34
+ - `GET /AutoProctor/v1/health` - Health check
35
+ - `GET /AutoProctor/v1/config` - Get current configuration
36
+ - `GET /AutoProctor/v1/count` - Count stored embeddings
37
+
38
+ ### Face Management
39
+ - `POST /AutoProctor/v1/data/embed/{user_id}` - Add face embeddings
40
+ - `POST /AutoProctor/v1/data/update/{user_id}` - Update face embeddings
41
+ - `POST /AutoProctor/v1/data/delete/{user_id}` - Delete user embeddings
42
+
43
+ ### Recognition
44
+ - `POST /AutoProctor/v1/data/detect/frame` - Detect faces in single frame
45
+ - `POST /AutoProctor/v1/data/recognize/frame` - Recognize face in frame
46
+ - `WebSocket /AutoProctor/v1/data/detect/stream` - Real-time streaming
47
+
48
+ ## Example Usage
49
+
50
+ ### Add Face Embedding
51
+ ```bash
52
+ curl -X POST "https://your-space.hf.space/AutoProctor/v1/data/embed/user123" \
53
+ -F "file=@face.jpg"
controllers/EmbeddingController.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import cv2
3
+ import os
4
+ from datetime import datetime
5
+ import numpy as np
6
+ import torch
7
+ from facenet_pytorch import MTCNN, InceptionResnetV1
8
+ from ultralytics import YOLO
9
+ from helpers.db import get_chroma
10
+ import uuid
11
+
12
+ from helpers.Augmentions import FaceAugmentor
13
+
14
+ class EmbeddingController:
15
+ def __init__(self, DETECTION_MODEL: str, YOLOFACE_MODEL_PATH=None):
16
+ self.client, self.collection = get_chroma()
17
+ self.detection_model = DETECTION_MODEL
18
+ if DETECTION_MODEL == "yoloface":
19
+ self.detector = YOLO(model=YOLOFACE_MODEL_PATH)
20
+ else:
21
+ self.detector = MTCNN(
22
+ image_size=160,
23
+ margin=10, # tight crop, small context
24
+ min_face_size=20, # allow smaller faces
25
+ thresholds=[0.6, 0.7, 0.8], # higher recall, fewer misses
26
+ factor=0.709,
27
+ post_process=True,
28
+ keep_all=True,
29
+ device=torch.device('cpu')
30
+ )
31
+
32
+ self.facenet = InceptionResnetV1(pretrained="vggface2").eval().to("cpu")
33
+ self.augmentor = FaceAugmentor()
34
+
35
+ def detect_faces(self, image):
36
+ if isinstance(self.detector, YOLO):
37
+ results = self.detector(image,verbose=False)
38
+ boxes = results[0].boxes.xyxy.cpu().numpy()
39
+ else:
40
+ boxes, _ = self.detector.detect(image)
41
+ if boxes is None:
42
+ return []
43
+ faces = []
44
+ for box in boxes:
45
+ x1, y1, x2, y2 = map(int, box)
46
+ face = image[y1:y2, x1:x2]
47
+ if face.size > 0:
48
+ faces.append(face)
49
+ return faces
50
+
51
+ def get_embedding(self, face):
52
+ try:
53
+ face_rgb = cv2.cvtColor(face, cv2.COLOR_BGR2RGB)
54
+ except Exception:
55
+ face_rgb = face
56
+ face_resized = cv2.resize(face_rgb, (160, 160))
57
+ face_tensor = torch.tensor(face_resized).permute(2, 0, 1).unsqueeze(0).float() / 255.0
58
+ with torch.no_grad():
59
+ embedding = self.facenet(face_tensor.to("cpu")).cpu().numpy()
60
+ return embedding.flatten()
61
+
62
+ def face_to_base64(self, face):
63
+ _, buffer = cv2.imencode('.jpg', face)
64
+ return base64.b64encode(buffer).decode("utf-8")
65
+
66
+ def save_cropped_face(self, face, user_id: str = None, idx: int = 0):
67
+ try:
68
+ out_dir = os.path.join(os.getcwd(), 'static', 'crops')
69
+ os.makedirs(out_dir, exist_ok=True)
70
+ ts = datetime.now().strftime('%Y%m%d_%H%M%S')
71
+ user_part = user_id if user_id else 'unknown'
72
+ filename = f"{user_part}_{self.detection_model}_{idx}_{ts}.jpg"
73
+ path = os.path.join(out_dir, filename)
74
+ cv2.imwrite(path, face)
75
+ return path
76
+ except Exception:
77
+ return None
78
+
79
+ def add_embedding(self, face, embedding, metadata: dict):
80
+ user_id = metadata["user_id"]
81
+ record_id = f"{user_id}_{uuid.uuid4().hex}"
82
+ face_b64 = self.face_to_base64(face)
83
+
84
+ # try:
85
+ # self.save_cropped_face(face, user_id=user_id, idx=0)
86
+ # except Exception:
87
+ # pass
88
+
89
+ embedding = embedding / np.linalg.norm(embedding)
90
+ self.collection.add(
91
+ ids=[record_id],
92
+ embeddings=[embedding.tolist()],
93
+ documents=[face_b64],
94
+ metadatas=[metadata]
95
+ )
96
+
97
+
98
+ aug_faces = self.augmentor.generate(face)
99
+
100
+ for i, aug_face in enumerate(aug_faces):
101
+ aug_embedding = self.get_embedding(aug_face)
102
+ aug_metadata = metadata.copy()
103
+ aug_metadata["augmented"] = True
104
+ aug_id = f"{user_id}_aug_{i}_{uuid.uuid4().hex}"
105
+ # try:
106
+ # self.save_cropped_face(aug_face, user_id=aug_id, idx=i)
107
+ # except Exception:
108
+ # pass
109
+
110
+ aug_embedding = aug_embedding / np.linalg.norm(aug_embedding)
111
+ self.collection.add(
112
+ ids=[aug_id],
113
+ embeddings=[aug_embedding.tolist()],
114
+ documents=[self.face_to_base64(aug_face)],
115
+ metadatas=[aug_metadata]
116
+ )
117
+
118
+ def update_embeddings(self, user_id: str, faces: list, embeddings: list, metadata: dict = None):
119
+ try:
120
+ self.collection.delete(where={"user_id": user_id})
121
+ except Exception:
122
+ pass
123
+
124
+ for idx, (face, emb) in enumerate(zip(faces, embeddings)):
125
+ meta = metadata.copy() if metadata else {}
126
+ meta.update({"user_id": user_id})
127
+ # try:
128
+ # self.save_cropped_face(face, user_id=user_id, idx=idx)
129
+ # except Exception:
130
+ # pass
131
+
132
+ record_id = f"{user_id}_{idx}_{datetime.now().timestamp()}"
133
+
134
+ emb = emb / np.linalg.norm(emb)
135
+ self.collection.add(
136
+ ids=[record_id],
137
+ embeddings=[emb.tolist()],
138
+ documents=[self.face_to_base64(face)],
139
+ metadatas=[meta]
140
+ )
141
+
142
+ aug_faces = self.augmentor.generate(face)
143
+ for j, aug_face in enumerate(aug_faces):
144
+ aug_embedding = self.get_embedding(aug_face)
145
+ aug_meta = meta.copy()
146
+ aug_meta["augmented"] = True
147
+ aug_id = f"{user_id}_upd_aug_{j}_{uuid.uuid4().hex}"
148
+ aug_embedding = aug_embedding / np.linalg.norm(aug_embedding)
149
+ self.collection.add(
150
+ ids=[aug_id],
151
+ embeddings=[aug_embedding.tolist()],
152
+ documents=[self.face_to_base64(aug_face)],
153
+ metadatas=[aug_meta]
154
+ )
155
+
156
+ def delete_embeddings_by_user(self, user_id: str):
157
+ try:
158
+ self.collection.delete(where={"user_id": user_id})
159
+ return True
160
+ except Exception as e:
161
+ print("Deletion error:", e)
162
+ return False
163
+
164
+
165
+ def query_embedding(self, embedding, n_results=5, threshold=0.6):
166
+ embedding = embedding / np.linalg.norm(embedding)
167
+ results = self.collection.query(
168
+ query_embeddings=[embedding.tolist()],
169
+ n_results=n_results
170
+ )
171
+ if not results or not results.get("distances"):
172
+ return {
173
+ "match": False,
174
+ "reason": "No results from database"
175
+ }
176
+
177
+ distances = results["distances"][0]
178
+ metadatas = results["metadatas"][0]
179
+ if not distances or not metadatas:
180
+ return {
181
+ "match": False,
182
+ "reason": "Empty results from database"
183
+ }
184
+ best_distance = min(distances)
185
+ best_index = distances.index(best_distance)
186
+ best_metadata = metadatas[best_index]
187
+ similarity = 1 - best_distance
188
+ if similarity >= threshold:
189
+ return {
190
+ "match": True,
191
+ "user_id": best_metadata.get("user_id"),
192
+ "similarity": round(similarity, 5),
193
+ "metadata": best_metadata
194
+ }
195
+
196
+ return {
197
+ "match": False,
198
+ "similarity": round(similarity, 5)
199
+ }
200
+
controllers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .EmbeddingController import EmbeddingController
controllers/__pycache__/EmbeddingController.cpython-311.pyc ADDED
Binary file (11.5 kB). View file
 
controllers/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (261 Bytes). View file
 
docker-compose.yml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ face-recognition-api:
5
+ build: .
6
+ ports:
7
+ - "7860:7860"
8
+ volumes:
9
+ - ./chroma_data:/app/chroma_data
10
+ - ./static/crops:/app/static/crops
11
+ environment:
12
+ - APP_NAME=FaceRecognitionAPI
13
+ - APP_VERSION=1.0.0
14
+ - APP_VARIENT=v1
15
+ - HOST=0.0.0.0
16
+ - PORT=7860
17
+ - DETECTION_MODEL=mtcnn
18
+ - YOLOFACE_MODEL_PATH=assets/yolov11n-face.pt
19
+ - CHROMA_DB_PATH=./chroma_data
20
+ - COLLECTION_NAME=face_embeddings_collection
21
+ - SIMILARITY_THRESHOLD=0.7
22
+ - MAX_RESULTS=1
23
+ restart: unless-stopped
helpers/Augmentions.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+ import random
4
+ from PIL import Image, ImageEnhance, ImageFilter
5
+
6
+
7
+ class FaceAugmentor:
8
+ #Low light
9
+ def low_light(self, face_bgr):
10
+ face_rgb = cv2.cvtColor(face_bgr, cv2.COLOR_BGR2RGB)
11
+ img = Image.fromarray(face_rgb)
12
+
13
+ img = ImageEnhance.Brightness(img).enhance(0.45)
14
+ img = ImageEnhance.Contrast(img).enhance(0.7)
15
+ img = img.filter(ImageFilter.GaussianBlur(radius=1))
16
+
17
+ return cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
18
+
19
+ #Bright light
20
+ def bright(self, face):
21
+ return cv2.convertScaleAbs(face, alpha=1.3, beta=10)
22
+
23
+ # Contrast 3shan lw camera mokhtlfa
24
+ def contrast(self, face):
25
+ return cv2.convertScaleAbs(face, alpha=1.2, beta=0)
26
+
27
+ #Color jitter
28
+ def color_jitter(self, face):
29
+ hsv = cv2.cvtColor(face, cv2.COLOR_BGR2HSV).astype(np.float32)
30
+ hsv[..., 1] *= random.uniform(0.8, 1.2)
31
+ hsv[..., 2] *= random.uniform(0.8, 1.2)
32
+ hsv = np.clip(hsv, 0, 255).astype(np.uint8)
33
+ return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
34
+
35
+ # ---- Noise (low quality webcam) ----
36
+ def add_noise(self, face):
37
+ noise = np.random.normal(0,0.9, face.shape).astype(np.uint8)
38
+ return cv2.add(face, noise)
39
+
40
+
41
+ #Rotation
42
+ def rotate(self, face, angle=5):
43
+ h, w = face.shape[:2]
44
+ M = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0)
45
+ return cv2.warpAffine(face, M, (w, h))
46
+
47
+ #Partial crop
48
+ def partial_crop(self, face):
49
+ h, w = face.shape[:2]
50
+ return face[int(0.15*h):int(0.9*h), int(0.1*w):int(0.9*w)]
51
+
52
+ def generate(self, face):
53
+ variants = [
54
+ self.low_light(face),
55
+ self.bright(face),
56
+ self.contrast(face),
57
+ self.color_jitter(face),
58
+ self.add_noise(face),
59
+ self.rotate(face, 25),
60
+ self.rotate(face, -25),
61
+ self.partial_crop(face),
62
+ ]
63
+
64
+ #random.shuffle(variants)
65
+ return variants[:7]
helpers/__pycache__/Augmentions.cpython-311.pyc ADDED
Binary file (4.92 kB). View file
 
helpers/__pycache__/configs.cpython-311.pyc ADDED
Binary file (1.48 kB). View file
 
helpers/__pycache__/configs.cpython-314.pyc ADDED
Binary file (1.66 kB). View file
 
helpers/__pycache__/db.cpython-311.pyc ADDED
Binary file (1.1 kB). View file
 
helpers/configs.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic_settings import BaseSettings
2
+
3
+ class Settings(BaseSettings):
4
+ APP_NAME: str
5
+ APP_VERSION: str
6
+ APP_VARIENT: str
7
+
8
+ host:str
9
+ port:int
10
+
11
+ DETECTION_MODEL:str = "mtcnn" # Options: mtcnn, yoloface
12
+
13
+ YOLOFACE_MODEL_PATH: str = "assets/yolov12n-face.pt"
14
+ CHROMA_DB_PATH:str = "./chroma_data"
15
+ COLLECTION_NAME:str = "face_embeddings_collection"
16
+ SIMILARITY_THRESHOLD:float = 0.7
17
+ MAX_RESULTS:int = 1
18
+ class Config:
19
+ env_file = ".env"
20
+
21
+ def get_settings(): ## this makes any got by "get_settings().APP_NAME"
22
+ return Settings()
helpers/db.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import chromadb
2
+ from chromadb.config import Settings
3
+ from helpers.configs import get_settings
4
+
5
+ # Globals
6
+ chroma_client = None
7
+ chroma_collection = None
8
+
9
+
10
+
11
+ def init_chroma():
12
+ global chroma_client, chroma_collection
13
+ settings = get_settings()
14
+
15
+ chroma_client = chromadb.PersistentClient(
16
+ path=settings.CHROMA_DB_PATH
17
+ )
18
+
19
+ chroma_collection = chroma_client.get_or_create_collection(
20
+ name=settings.COLLECTION_NAME,
21
+ # metadata={"hnsw:space": "cosine"}
22
+ metadata={"hnsw:space": "cosine"}
23
+ )
24
+
25
+ return chroma_client, chroma_collection
26
+
27
+
28
+ def get_chroma():
29
+ return chroma_client, chroma_collection
main.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from contextlib import asynccontextmanager
4
+ from routes import base, data
5
+ from helpers.db import init_chroma, chroma_client
6
+ from helpers.configs import get_settings
7
+ from pathlib import Path
8
+ import os
9
+ import urllib.request
10
+
11
+ ASSETS_DIR = "assets"
12
+ MODEL_URL = "https://github.com/YapaLab/yolo-face/releases/download/1.0.0/yolov11n-face.pt"
13
+ MODEL_PATH = os.path.join(ASSETS_DIR, "yolov11n-face.pt")
14
+
15
+ @asynccontextmanager
16
+ async def lifespan(app: FastAPI):
17
+ print("Initializing ChromaDB...")
18
+ init_chroma()
19
+ print("ChromaDB Ready.")
20
+ if not Path(MODEL_PATH).exists():
21
+ Path(ASSETS_DIR).mkdir(exist_ok=True)
22
+ print("⬇ Downloading YOLO face model...")
23
+ urllib.request.urlretrieve(MODEL_URL, MODEL_PATH)
24
+ print("Model ready.")
25
+ else:
26
+ print("Model already exists.")
27
+ yield
28
+ if chroma_client is not None:
29
+ try:
30
+ chroma_client.persist()
31
+ print("ChromaDB persisted & closed.")
32
+ except:
33
+ pass
34
+
35
+
36
+ app = FastAPI(lifespan=lifespan)
37
+
38
+ app.add_middleware(
39
+ CORSMiddleware,
40
+ allow_origins=["*"],
41
+ allow_credentials=True,
42
+ allow_methods=["*"],
43
+ allow_headers=["*"],
44
+ )
45
+
46
+ app.include_router(base.base_router)
47
+ app.include_router(data.data_router)
48
+
49
+
50
+ if __name__ == "__main__":
51
+ import uvicorn
52
+
53
+ settings = get_settings()
54
+ print(f"Server running at http://127.0.0.1:{settings.port}")
55
+ uvicorn.run(app, host=settings.host, port=settings.port)
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.124.2
2
+ uvicorn[standard]
3
+ pydantic-settings
4
+ chromadb
5
+ ultralytics
6
+ python-dotenv==1.2.1
7
+ facenet-pytorch==2.6.0
8
+ torch==2.2.2
9
+ torchvision==0.17.2
10
+ opencv-python-headless==4.11.0.86
11
+ python-multipart==0.0.20
routes/__pycache__/base.cpython-311.pyc ADDED
Binary file (2.49 kB). View file
 
routes/__pycache__/base.cpython-314.pyc ADDED
Binary file (2.79 kB). View file
 
routes/__pycache__/data.cpython-311.pyc ADDED
Binary file (17 kB). View file
 
routes/base.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter , Depends
2
+ from helpers.configs import Settings , get_settings
3
+ from helpers.db import get_chroma
4
+ from fastapi.responses import FileResponse
5
+
6
+
7
+ base_router = APIRouter(
8
+ prefix=f"/AutoProctor/{get_settings().APP_VARIENT}",
9
+ tags=["AutoProctor_v1"])
10
+
11
+
12
+ @base_router.get("/")
13
+ async def welcome(app_settings: Settings = Depends(get_settings)):
14
+ return FileResponse('static/index.html')
15
+ # app_name = app_settings.APP_NAME
16
+ # app_varient=app_settings.APP_VARIENT
17
+ # app_version = app_settings.APP_VERSION
18
+ # return {"app_name": app_name, "app_version": app_version ,"app_varient":app_varient, "status": "healthy"}
19
+
20
+
21
+ @base_router.get('/config')
22
+ async def config(app_settings: Settings = Depends(get_settings)):
23
+ return {
24
+ 'app_name': app_settings.APP_NAME,
25
+ 'app_version': app_settings.APP_VERSION,
26
+ 'app_variant': app_settings.APP_VARIENT,
27
+ 'detection_model': app_settings.DETECTION_MODEL,
28
+ 'yoloface_model_path': app_settings.YOLOFACE_MODEL_PATH,
29
+ 'chroma_db_path': app_settings.CHROMA_DB_PATH,
30
+ 'collection_name': app_settings.COLLECTION_NAME,
31
+ 'similarity_threshold': app_settings.SIMILARITY_THRESHOLD,
32
+ 'max_results': app_settings.MAX_RESULTS
33
+ }
34
+
35
+
36
+ @base_router.get("/health")
37
+ async def health(app_settings: Settings = Depends(get_settings)):
38
+ app_name = app_settings.APP_NAME
39
+ app_version = app_settings.APP_VERSION
40
+ return {"app_name": app_name, "app_version": app_version , "status": "healthy"}
41
+
42
+
43
+ @base_router.get("/count")
44
+ def count_documents():
45
+ _, collection = get_chroma()
46
+ return {"documents": collection.count()}
routes/data.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from fastapi import APIRouter, UploadFile, WebSocket, File, WebSocketDisconnect ,Depends
3
+ from fastapi.responses import JSONResponse
4
+ import logging
5
+ import cv2
6
+ import numpy as np
7
+ import base64
8
+ from helpers.configs import get_settings, Settings
9
+ from controllers.EmbeddingController import EmbeddingController
10
+
11
+ logger = logging.getLogger('uvicorn.error')
12
+
13
+ data_router = APIRouter(
14
+ prefix=f"/AutoProctor/{get_settings().APP_VARIENT}/data",
15
+ tags=["AutoProctor_v1"]
16
+ )
17
+
18
+ # Initialize the embedding controller globally
19
+ embedding_controller = None
20
+
21
+
22
+ def get_embedding_controller():
23
+ global embedding_controller
24
+ if embedding_controller is None:
25
+ try:
26
+ logger.info("Initializing EmbeddingController...")
27
+ embedding_controller = EmbeddingController(
28
+ DETECTION_MODEL=get_settings().DETECTION_MODEL,
29
+ YOLOFACE_MODEL_PATH=get_settings().YOLOFACE_MODEL_PATH
30
+ )
31
+ logger.info("EmbeddingController initialized successfully")
32
+ if not hasattr(embedding_controller, 'collection') or embedding_controller.collection is None:
33
+ logger.error("Collection not initialized in EmbeddingController!")
34
+ raise Exception("Collection initialization failed")
35
+ except Exception as e:
36
+ logger.error(f"Failed to initialize EmbeddingController: {e}")
37
+ raise
38
+ return embedding_controller
39
+
40
+
41
+ @data_router.post("/embed/{user_id}")
42
+ async def embed_frame_api(user_id: str, file: UploadFile):
43
+ try:
44
+ controller = get_embedding_controller()
45
+ image = await file.read()
46
+ nparr = np.frombuffer(image, np.uint8)
47
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
48
+ if img is None:
49
+ logger.error("Failed to decode image")
50
+ return JSONResponse(status_code=400, content={"message": "Invalid image format"})
51
+ faces = controller.detect_faces(img)
52
+ if not faces:
53
+ return JSONResponse(status_code=404, content={"message": "No faces detected."})
54
+ logger.info(f"Detected {len(faces)} face(s) for user_id: {user_id}")
55
+ for idx, face in enumerate(faces):
56
+ try:
57
+ embedding = controller.get_embedding(face)
58
+ metadata = {"user_id": user_id}
59
+ controller.add_embedding(face, embedding, metadata)
60
+ logger.info(f"Added embedding {idx + 1}/{len(faces)} for user_id: {user_id}")
61
+ except Exception as e:
62
+ logger.error(f"Error adding embedding {idx + 1} for user_id {user_id}: {e}")
63
+ raise
64
+ return {
65
+ "message": f"Embeddings added for user_id: {user_id}",
66
+ "num_faces": len(faces)
67
+ }
68
+ except Exception as e:
69
+ logger.error(f"Error in embed_frame_api: {e}", exc_info=True)
70
+ return JSONResponse(status_code=500, content={"message": f"Internal server error: {str(e)}"})
71
+
72
+
73
+
74
+ @data_router.post("/delete/{user_id}")
75
+ async def delete_embeddings_api(user_id: str):
76
+ try:
77
+ controller = get_embedding_controller()
78
+ delete_result = controller.delete_embeddings_by_user(user_id)
79
+ return {
80
+ "message": f"Deleted embeddings for user_id: {user_id}",
81
+ "details": delete_result
82
+ }
83
+ except Exception as e:
84
+ logger.error(f"Error in delete_embeddings_api: {e}", exc_info=True)
85
+ return JSONResponse(status_code=500, content={"message": f"Internal server error: {str(e)}"})
86
+
87
+
88
+ @data_router.post("/update/{user_id}")
89
+ async def update_embeddings_api(user_id: str, file: UploadFile, app_settings: Settings = Depends(get_settings)):
90
+ try:
91
+ controller = get_embedding_controller()
92
+ image_bytes = await file.read()
93
+ nparr = np.frombuffer(image_bytes, np.uint8)
94
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
95
+ if img is None:
96
+ return JSONResponse(status_code=400, content={"message": "Invalid image format"})
97
+
98
+ faces = controller.detect_faces(img)
99
+ if not faces:
100
+ return JSONResponse(status_code=404, content={"message": "No faces detected."})
101
+ embeddings = [controller.get_embedding(face) for face in faces]
102
+ metadata = {"user_id": user_id}
103
+ controller.update_embeddings(
104
+ user_id=user_id,
105
+ faces=faces,
106
+ embeddings=embeddings,
107
+ metadata=metadata
108
+ )
109
+ return {
110
+ "message": f"Embeddings updated for user_id: {user_id}",
111
+ "num_faces": len(faces)
112
+ }
113
+ except Exception as e:
114
+ logger.error(f"Error in update_embeddings_api: {e}", exc_info=True)
115
+ return JSONResponse(status_code=500, content={"message": f"Internal server error: {str(e)}"})
116
+
117
+
118
+
119
+ @data_router.post("/detect/frame")
120
+ async def detect_frame_api(file: UploadFile = File(...), app_settings: Settings = Depends(get_settings)):
121
+ controller = get_embedding_controller()
122
+ image_bytes = await file.read()
123
+ nparr = np.frombuffer(image_bytes, np.uint8)
124
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
125
+ if img is None:
126
+ return JSONResponse(status_code=400, content={"message": "Invalid image"})
127
+ faces = controller.detect_faces(img)
128
+ if not faces:
129
+ return JSONResponse(status_code=404, content={"message": "No faces detected"})
130
+ results = []
131
+ for face in faces:
132
+ embedding = controller.get_embedding(face)
133
+ result = controller.query_embedding(
134
+ embedding,
135
+ n_results=app_settings.MAX_RESULTS,
136
+ threshold=app_settings.SIMILARITY_THRESHOLD
137
+ )
138
+ results.append(result)
139
+ print("Detected : " , results)
140
+ return {
141
+ "num_faces": len(faces),
142
+ "results": results
143
+ }
144
+
145
+
146
+ @data_router.post("/recognize/frame")
147
+ async def detect_frame_api(file: UploadFile = File(...), app_settings: Settings = Depends(get_settings)):
148
+ controller = get_embedding_controller()
149
+ image_bytes = await file.read()
150
+ nparr = np.frombuffer(image_bytes, np.uint8)
151
+ img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
152
+ if img is None:
153
+ return JSONResponse(status_code=400, content={"message": "Invalid image"})
154
+ results = []
155
+ embedding = controller.get_embedding(img)
156
+ result = controller.query_embedding(
157
+ embedding,
158
+ n_results=app_settings.MAX_RESULTS,
159
+ threshold=app_settings.SIMILARITY_THRESHOLD
160
+ )
161
+ results.append(result)
162
+ print(results)
163
+ return {
164
+ "results": results
165
+ }
166
+
167
+
168
+ @data_router.websocket("/detect/stream")
169
+ async def detect_stream(websocket: WebSocket):
170
+ await websocket.accept()
171
+ logger.info("WebSocket connected")
172
+ controller = get_embedding_controller()
173
+ frame_queue: asyncio.Queue = asyncio.Queue(maxsize=1)
174
+ stop_event = asyncio.Event()
175
+ frame_count = 0
176
+
177
+ async def receiver():
178
+ """Receive frames and keep ONLY the latest one"""
179
+ try:
180
+ while not stop_event.is_set():
181
+ msg = await websocket.receive()
182
+
183
+ if msg.get("type") == "websocket.disconnect":
184
+ break
185
+
186
+ data = None
187
+ if msg.get("bytes"):
188
+ data = msg["bytes"]
189
+ elif msg.get("text"):
190
+ text = msg["text"]
191
+ if text.startswith("data:image"):
192
+ text = text.split(",", 1)[1]
193
+ data = base64.b64decode(text)
194
+
195
+ if not data:
196
+ continue
197
+
198
+ # Drop old frame if queue is full
199
+ if frame_queue.full():
200
+ try:
201
+ frame_queue.get_nowait()
202
+ except asyncio.QueueEmpty:
203
+ pass
204
+
205
+ await frame_queue.put(data)
206
+
207
+ except WebSocketDisconnect:
208
+ logger.info("Receiver: client disconnected")
209
+ except Exception as e:
210
+ logger.error(f"Receiver error: {e}", exc_info=True)
211
+ finally:
212
+ stop_event.set()
213
+
214
+ async def processor():
215
+ """Process ONLY the latest frame"""
216
+ nonlocal frame_count
217
+ try:
218
+ while not stop_event.is_set():
219
+ try:
220
+ data = await asyncio.wait_for(frame_queue.get(), timeout=0.5)
221
+ except asyncio.TimeoutError:
222
+ continue
223
+
224
+ if websocket.client_state.name != "CONNECTED":
225
+ break
226
+
227
+ # Decode image
228
+ nparr = np.frombuffer(data, np.uint8)
229
+ frame = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
230
+ if frame is None:
231
+ continue
232
+
233
+ frame_count += 1
234
+
235
+ # Detect faces
236
+ try:
237
+ faces = controller.detect_faces(frame)
238
+ if not faces:
239
+ await websocket.send_json({
240
+ "frame": frame_count,
241
+ "faces_detected": 0
242
+ })
243
+ continue
244
+ except Exception as e:
245
+ await websocket.send_json({"error": f"detection failed: {e}"})
246
+ continue
247
+
248
+ # Process faces
249
+ results = []
250
+ for face in faces:
251
+ try:
252
+ emb = controller.get_embedding(face)
253
+ res = controller.query_embedding(
254
+ emb,
255
+ n_results=get_settings().MAX_RESULTS,
256
+ threshold=get_settings().SIMILARITY_THRESHOLD
257
+ )
258
+ results.append(res)
259
+ except Exception as e:
260
+ results.append({"error": str(e)})
261
+
262
+ # Send results
263
+ try:
264
+ await websocket.send_json({
265
+ "frame": frame_count,
266
+ "faces_detected": len(faces),
267
+ "results": results
268
+ })
269
+ except Exception:
270
+ break
271
+
272
+ except Exception as e:
273
+ logger.error(f"Processor error: {e}", exc_info=True)
274
+ finally:
275
+ stop_event.set()
276
+
277
+ # Run tasks
278
+ recv_task = asyncio.create_task(receiver())
279
+ proc_task = asyncio.create_task(processor())
280
+
281
+ # Wait for receiver to finish (disconnect)
282
+ await recv_task
283
+
284
+ # Stop processor immediately
285
+ proc_task.cancel()
286
+
287
+ try:
288
+ await proc_task
289
+ except asyncio.CancelledError:
290
+ pass
291
+
292
+ try:
293
+ await websocket.close()
294
+ except Exception:
295
+ pass
296
+
297
+ logger.info(f"WebSocket closed (processed {frame_count} frames)")
298
+
299
+
static/index.html ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>FaceRecognition - Live Detection</title>
6
+ <style>
7
+ * { margin: 0; padding: 0; box-sizing: border-box; }
8
+ body {
9
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
10
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
11
+ min-height: 100vh;
12
+ padding: 20px;
13
+ }
14
+ .container {
15
+ max-width: 1200px;
16
+ margin: 0 auto;
17
+ background: white;
18
+ border-radius: 15px;
19
+ box-shadow: 0 20px 60px rgba(0,0,0,0.3);
20
+ overflow: hidden;
21
+ }
22
+ .header {
23
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
24
+ color: white;
25
+ padding: 30px;
26
+ text-align: center;
27
+ }
28
+ .header h1 { font-size: 2.5em; margin-bottom: 10px; }
29
+ .content {
30
+ display: grid;
31
+ grid-template-columns: 1fr 1fr;
32
+ gap: 30px;
33
+ padding: 30px;
34
+ }
35
+ .video-section {
36
+ display: flex;
37
+ flex-direction: column;
38
+ gap: 15px;
39
+ }
40
+ .video-container {
41
+ position: relative;
42
+ width: 100%;
43
+ background: #000;
44
+ border-radius: 10px;
45
+ overflow: hidden;
46
+ aspect-ratio: 4/3;
47
+ }
48
+ video {
49
+ width: 100%;
50
+ height: 100%;
51
+ object-fit: cover;
52
+ }
53
+ .controls {
54
+ display: flex;
55
+ gap: 10px;
56
+ flex-wrap: wrap;
57
+ }
58
+ button {
59
+ flex: 1;
60
+ min-width: 120px;
61
+ padding: 12px 20px;
62
+ border: none;
63
+ border-radius: 8px;
64
+ font-size: 1em;
65
+ font-weight: 600;
66
+ cursor: pointer;
67
+ transition: all 0.3s ease;
68
+ }
69
+ button:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 5px 15px rgba(0,0,0,0.2); }
70
+ button:disabled { opacity: 0.5; cursor: not-allowed; }
71
+ .btn-detect { background: #4CAF50; color: white; }
72
+ .btn-start { background: #2196F3; color: white; }
73
+ .btn-stop { background: #f44336; color: white; }
74
+ .btn-embed { background: #FF9800; color: white; }
75
+ .output-section {
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: 15px;
79
+ }
80
+ .output-header {
81
+ display: flex;
82
+ justify-content: space-between;
83
+ align-items: center;
84
+ }
85
+ .status-badge {
86
+ padding: 8px 15px;
87
+ border-radius: 20px;
88
+ font-weight: 600;
89
+ font-size: 0.9em;
90
+ }
91
+ .status-connected { background: #4CAF50; color: white; }
92
+ .status-disconnected { background: #999; color: white; }
93
+ .status-idle { background: #2196F3; color: white; }
94
+ #output {
95
+ flex: 1;
96
+ background: #f5f5f5;
97
+ border: 2px solid #ddd;
98
+ border-radius: 8px;
99
+ padding: 15px;
100
+ font-family: 'Courier New', monospace;
101
+ font-size: 0.9em;
102
+ overflow: auto;
103
+ max-height: 400px;
104
+ white-space: pre-wrap;
105
+ word-wrap: break-word;
106
+ }
107
+ .input-group {
108
+ display: flex;
109
+ gap: 10px;
110
+ }
111
+ input[type="text"] {
112
+ flex: 1;
113
+ padding: 10px;
114
+ border: 2px solid #ddd;
115
+ border-radius: 8px;
116
+ font-size: 1em;
117
+ }
118
+ .stats {
119
+ display: grid;
120
+ grid-template-columns: repeat(2, 1fr);
121
+ gap: 10px;
122
+ margin-top: 10px;
123
+ }
124
+ .stat-box {
125
+ background: #f0f0f0;
126
+ padding: 10px;
127
+ border-radius: 8px;
128
+ text-align: center;
129
+ }
130
+ .stat-label { font-size: 0.8em; color: #666; }
131
+ .stat-value { font-size: 1.5em; font-weight: bold; color: #333; }
132
+ @media (max-width: 768px) {
133
+ .content { grid-template-columns: 1fr; }
134
+ .header h1 { font-size: 1.8em; }
135
+ }
136
+ </style>
137
+ </head>
138
+ <body>
139
+ <div class="container">
140
+ <div class="header">
141
+ <h1>πŸ” FaceRecognition - Live Detection</h1>
142
+ <p>Real-time face detection and recognition via WebSocket</p>
143
+ </div>
144
+
145
+ <div class="content">
146
+ <div class="video-section">
147
+ <div class="video-container">
148
+ <video id="video" autoplay muted playsinline></video>
149
+ </div>
150
+ <div class="controls">
151
+ <button id="snap" class="btn-detect">πŸ“Έ Single Frame</button>
152
+ <button id="startStream" class="btn-start">▢️ Start Stream</button>
153
+ <button id="stopStream" class="btn-stop" disabled>⏹️ Stop Stream</button>
154
+ </div>
155
+ <div class="input-group">
156
+ <input type="text" id="userId" placeholder="Enter user ID (optional)" />
157
+ <button id="embedBtn" class="btn-embed">βž• Embed</button>
158
+ </div>
159
+ </div>
160
+
161
+ <div class="output-section">
162
+ <div class="output-header">
163
+ <h3>πŸ“Š Results</h3>
164
+ <span id="status" class="status-badge status-idle">βšͺ Idle</span>
165
+ </div>
166
+ <div id="output">Waiting for input...</div>
167
+ <div class="stats">
168
+ <div class="stat-box">
169
+ <div class="stat-label">Frames Sent</div>
170
+ <div class="stat-value" id="frameCount">0</div>
171
+ </div>
172
+ <div class="stat-box">
173
+ <div class="stat-label">Faces Detected</div>
174
+ <div class="stat-value" id="faceCount">0</div>
175
+ </div>
176
+ <div class="stat-box">
177
+ <div class="stat-label">Connection</div>
178
+ <div class="stat-value" id="connStatus">β€”</div>
179
+ </div>
180
+ <div class="stat-box">
181
+ <div class="stat-label">FPS</div>
182
+ <div class="stat-value" id="fpsCount">0</div>
183
+ </div>
184
+ </div>
185
+ </div>
186
+ </div>
187
+ </div>
188
+
189
+ <script>
190
+ const pathParts = location.pathname.split('/').filter(p => p.length > 0);
191
+ let variant = 'v1';
192
+ if (pathParts.length >= 2 && pathParts[0].toLowerCase().includes('autoproctor')) {
193
+ variant = pathParts[1] || variant;
194
+ }
195
+
196
+ const video = document.getElementById('video');
197
+ const snapBtn = document.getElementById('snap');
198
+ const startBtn = document.getElementById('startStream');
199
+ const stopBtn = document.getElementById('stopStream');
200
+ const embedBtn = document.getElementById('embedBtn');
201
+ const userIdInput = document.getElementById('userId');
202
+ const output = document.getElementById('output');
203
+ const statusBadge = document.getElementById('status');
204
+ const frameCountEl = document.getElementById('frameCount');
205
+ const faceCountEl = document.getElementById('faceCount');
206
+ const connStatusEl = document.getElementById('connStatus');
207
+ const fpsCountEl = document.getElementById('fpsCount');
208
+
209
+ let ws = null;
210
+ let streamRunning = false;
211
+ let frameCount = 0;
212
+ let totalFaceCount = 0;
213
+ let lastFrameTime = 0;
214
+
215
+ function updateStatus(text, cssClass) {
216
+ statusBadge.textContent = text;
217
+ statusBadge.className = `status-badge ${cssClass}`;
218
+ }
219
+
220
+ function logOutput(msg) {
221
+ output.textContent = typeof msg === 'string' ? msg : JSON.stringify(msg, null, 2);
222
+ output.scrollTop = output.scrollHeight;
223
+ }
224
+
225
+ navigator.mediaDevices.getUserMedia({ video: true, audio: false })
226
+ .then(s => { video.srcObject = s; updateStatus('βœ“ Camera Ready', 'status-idle'); })
227
+ .catch(e => { logOutput('❌ Camera error: ' + e); updateStatus('βœ— Camera Failed', 'status-disconnected'); });
228
+
229
+ function canvasToBase64() {
230
+ const c = document.createElement('canvas');
231
+ c.width = video.videoWidth || 640;
232
+ c.height = video.videoHeight || 480;
233
+ const ctx = c.getContext('2d');
234
+ ctx.drawImage(video, 0, 0, c.width, c.height);
235
+ return c.toDataURL('image/jpeg', 0.85).split(',')[1]; // Return only base64 part
236
+ }
237
+
238
+ snapBtn.onclick = async () => {
239
+ try {
240
+ updateStatus('Processing...', 'status-idle');
241
+ const base64 = canvasToBase64();
242
+ const blob = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
243
+ const fd = new FormData();
244
+ fd.append('file', new Blob([blob], { type: 'image/jpeg' }), 'frame.jpg');
245
+ const res = await fetch(`/AutoProctor/${variant}/data/detect/frame`, { method: 'POST', body: fd });
246
+ const j = await res.json();
247
+ logOutput(j);
248
+ const facesDetected = j.results ? j.results.ids?.length || 0 : 0;
249
+ totalFaceCount += facesDetected;
250
+ faceCountEl.textContent = totalFaceCount;
251
+ updateStatus('βœ“ Result Ready', 'status-connected');
252
+ } catch (e) {
253
+ logOutput('❌ Error: ' + e.message);
254
+ updateStatus('βœ— Error', 'status-disconnected');
255
+ }
256
+ };
257
+
258
+ startBtn.onclick = async () => {
259
+ if (ws) { ws.close(); ws = null; }
260
+ const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
261
+ const url = `${protocol}://${location.host}/AutoProctor/${variant}/data/detect/stream`;
262
+ console.log('Connecting to:', url);
263
+
264
+ ws = new WebSocket(url);
265
+ ws.onopen = () => {
266
+ logOutput('🟒 WebSocket Connected');
267
+ updateStatus('🟒 Connected', 'status-connected');
268
+ connStatusEl.textContent = 'Online';
269
+ startBtn.disabled = true;
270
+ stopBtn.disabled = false;
271
+ frameCount = 0;
272
+ lastFrameTime = Date.now();
273
+ streamRunning = true;
274
+ sendFrames();
275
+ };
276
+
277
+ ws.onmessage = (ev) => {
278
+ try {
279
+ const d = JSON.parse(ev.data);
280
+ logOutput(d);
281
+ if (d.faces_detected) {
282
+ totalFaceCount += d.faces_detected;
283
+ faceCountEl.textContent = totalFaceCount;
284
+ }
285
+ // Update FPS
286
+ const now = Date.now();
287
+ if (lastFrameTime > 0) {
288
+ const fps = Math.round(1000 / (now - lastFrameTime));
289
+ fpsCountEl.textContent = fps;
290
+ }
291
+ lastFrameTime = now;
292
+ } catch (e) {
293
+ logOutput('⚠️ ' + ev.data);
294
+ }
295
+ };
296
+
297
+ ws.onerror = (err) => {
298
+ logOutput('❌ WebSocket error: ' + err);
299
+ updateStatus('βœ— Error', 'status-disconnected');
300
+ };
301
+
302
+ ws.onclose = () => {
303
+ logOutput('πŸ”΄ WebSocket Closed');
304
+ updateStatus('βšͺ Disconnected', 'status-disconnected');
305
+ connStatusEl.textContent = 'Offline';
306
+ startBtn.disabled = false;
307
+ stopBtn.disabled = true;
308
+ streamRunning = false;
309
+ };
310
+ };
311
+
312
+ async function sendFrames() {
313
+ while (streamRunning && ws && ws.readyState === 1) {
314
+ const base64 = canvasToBase64();
315
+ frameCount++;
316
+ frameCountEl.textContent = frameCount;
317
+ try {
318
+ ws.send(base64);
319
+ } catch (e) {
320
+ console.error('Send error:', e);
321
+ break;
322
+ }
323
+ await new Promise(r => setTimeout(r, 2000)); // F P 2S
324
+ }
325
+ }
326
+
327
+ stopBtn.onclick = () => {
328
+ streamRunning = false;
329
+ if (ws) { ws.close(); }
330
+ };
331
+
332
+ embedBtn.onclick = async () => {
333
+ const userId = userIdInput.value.trim();
334
+ if (!userId) {
335
+ logOutput('❌ Please enter a user ID');
336
+ return;
337
+ }
338
+ try {
339
+ updateStatus('Embedding...', 'status-idle');
340
+ const base64 = canvasToBase64();
341
+ const blob = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
342
+ const fd = new FormData();
343
+ fd.append('file', new Blob([blob], { type: 'image/jpeg' }), 'frame.jpg');
344
+ const res = await fetch(`/AutoProctor/${variant}/data/embed/${userId}`, { method: 'POST', body: fd });
345
+ const j = await res.json();
346
+ logOutput(j);
347
+ updateStatus('βœ“ Embedded', 'status-connected');
348
+ } catch (e) {
349
+ logOutput('❌ Embedding error: ' + e.message);
350
+ updateStatus('βœ— Error', 'status-disconnected');
351
+ }
352
+ };
353
+ </script>
354
+ </body>
355
+ </html>