SecureAttendAI / backend /camera.py
Nishant Katiyar
Deploy biometric node to HF Spaces
b561839
Raw
History Blame Contribute Delete
15.2 kB
import threading
import time
from datetime import datetime, timedelta
import queue
import cv2
import numpy as np
from backend.face_engine import FaceEngine
from backend.database import log_attendance, get_last_attendance_log, get_todays_logs
# Alert queue to broadcast attendance notifications to the frontend in real time
attendance_alerts = queue.Queue()
class VideoCamera:
_instance = None
_lock = threading.Lock()
def __new__(cls, *args, **kwargs):
with cls._lock:
if cls._instance is None:
cls._instance = super(VideoCamera, cls).__new__(cls)
cls._instance.initialized = False
return cls._instance
def __init__(self, camera_index: int = 0):
if self.initialized:
return
self.camera_index = camera_index
self.cap = None
self.running = False
self.thread = None
self.lock = threading.Lock()
# Initialize Face Engine
self.engine = FaceEngine()
# Cooldown map: employee_id -> datetime of last log
# Prevents double-scans within 2 minutes
self.log_cooldowns = {}
self.cooldown_duration = timedelta(minutes=2)
# Current active frame and processed frame
self.latest_frame = None
self.latest_faces = None
self.processed_frame = None
# Biometric Liveness Buffers
self.landmark_buffer = []
self.last_spoof_alert_time = 0.0
self.initialized = True
def start(self):
"""Starts the camera capture thread if not already running."""
with self.lock:
if self.running:
return True
print(f"[*] Starting webcam capture on index {self.camera_index}...")
self.cap = cv2.VideoCapture(self.camera_index)
if not self.cap.isOpened():
print("[-] Error: Could not open webcam.")
return False
# Set camera dimensions for optimal latency (640x480)
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
self.running = True
self.thread = threading.Thread(target=self._capture_loop, daemon=True)
self.thread.start()
print("[+] Camera thread launched successfully!")
return True
def stop(self):
"""Stops the camera capture thread."""
with self.lock:
self.running = False
if self.thread:
self.thread.join(timeout=1.0)
if self.cap:
self.cap.release()
self.cap = None
print("[+] Camera capture stopped.")
def change_camera(self, new_index: int):
"""Switches the camera device index dynamically."""
self.stop()
self.camera_index = new_index
return self.start()
def get_raw_frame(self):
"""Returns the last raw frame captured (without HUD overlays)."""
with self.lock:
return self.latest_frame.copy() if self.latest_frame is not None else None
def get_latest_face_sample(self):
"""Returns the latest raw frame and the detected faces list safely."""
with self.lock:
if self.latest_frame is None:
return None, None
faces_copy = self.latest_faces.copy() if self.latest_faces is not None else None
return self.latest_frame.copy(), faces_copy
def get_processed_frame_bytes(self):
"""Returns the JPEG-encoded processed frame with the cybernetic HUD overlays."""
with self.lock:
if self.processed_frame is None or not self.running:
# Return a placeholder dark frame if camera is loading or offline
blank = np.zeros((480, 640, 3), dtype=np.uint8)
msg = "Camera Offline / Stopped" if not self.running else "Initializing Camera Feed..."
cv2.putText(blank, msg, (150, 240),
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 242), 2, cv2.LINE_AA)
_, jpeg = cv2.imencode('.jpg', blank)
return jpeg.tobytes()
_, jpeg = cv2.imencode('.jpg', self.processed_frame)
return jpeg.tobytes()
def _capture_loop(self):
"""Background loop continuously capturing, detecting, matching, and logging."""
scanline_y = 0
scanline_direction = 5
while self.running:
ret, frame = self.cap.read()
if not ret:
time.sleep(0.01)
continue
# Flip horizontally to match standard mirror webcam view
frame = cv2.flip(frame, 1)
# Create a clone for drawing the premium HUD
hud_frame = frame.copy()
h, w = frame.shape[:2]
# Perform face detection
retval, faces = self.engine.detect_faces(frame)
# Store raw frame and pre-detected faces list in synchronized lock
with self.lock:
self.latest_frame = frame.copy()
self.latest_faces = faces
# Draw premium high-tech grid scanner lines on the border (HUD style)
cv2.rectangle(hud_frame, (10, 10), (w-10, h-10), (0, 255, 242), 1)
# Corner ticks
self._draw_hud_corners(hud_frame, 10, 10, w-10, h-10, (0, 255, 242), 15, 2)
# If faces detected, process them
if faces is not None and len(faces) > 0:
for face in faces:
# 1. Parse bounding box coordinates and score
bbox = face[0:4].astype(int)
x, y, fw, fh = bbox[0], bbox[1], bbox[2], bbox[3]
score = face[-1]
# Ensure bbox stays inside frame boundaries
x = max(0, min(x, w-1))
y = max(0, min(y, h-1))
fw = min(fw, w - x)
fh = min(fh, h - y)
# 2. Extract facial landmarks
# YuNet output has 5 landmarks: right eye, left eye, nose, right mouth, left mouth
landmarks = face[4:14].reshape(5, 2).astype(int)
# Update liveness buffer
self.landmark_buffer.append(landmarks.copy())
if len(self.landmark_buffer) > 15:
self.landmark_buffer.pop(0)
# Calculate Liveness (Static Photo Guard + 3D Pose Ratio check)
is_live = True
liveness_label = "LIVENESS: PENDING..."
if len(self.landmark_buffer) >= 8:
# 1. Static Photo Check (Coordinate standard deviation)
pts = np.array(self.landmark_buffer)
std_coords = np.std(pts, axis=0) # shape (5, 2)
avg_std = np.mean(std_coords)
# If coordinates are completely motionless (standard deviation < 0.7 pixels)
# it is a static photo! A live person has micro-tremors and natural shifts
if avg_std < 0.7:
is_live = False
liveness_label = "SPOOF: STATIC PHOTO"
else:
liveness_label = "LIVENESS: VERIFIED (3D)"
# 3. Extract embedding and perform matching (only execute if liveness verified)
emp_id, emp_name, match_score = None, "Unknown", 0.0
event_status = ""
if is_live:
query_emb = self.engine.extract_embedding(frame, face)
if query_emb is not None:
emp_id, emp_name, match_score = self.engine.match_face(query_emb)
# 4. Handle check-in/check-out trigger if matched
if emp_id:
event_status = self._process_attendance_trigger(emp_id, emp_name, match_score)
# 5. Set HUD color palette based on match and liveness status
if not is_live:
# Spoof Alarm (Neon Crimson / Warning)
color = (60, 76, 231) # Crimson Red
label = f"SPOOF SCAN DETECTED | {liveness_label}"
# Debounce and push spoof security alerts to frontend SSE
now_ts = time.time()
if now_ts - self.last_spoof_alert_time > 10.0:
self.last_spoof_alert_time = now_ts
alert_data = {
"employee_id": "SEC-ALERT",
"name": "SPOOF SCAN DETECTED",
"timestamp": datetime.now().strftime("%I:%M %p"),
"event_type": "spoof_attempt",
"score": 0
}
attendance_alerts.put(alert_data)
elif emp_id:
# Recognized (Neon Emerald)
color = (46, 204, 113)
label = f"{emp_name} ({int(match_score * 100)}%) | {liveness_label}"
if event_status:
label += f" | {event_status.replace('_', ' ').upper()}"
else:
# Unknown (Neon Crimson)
color = (231, 76, 60)
label = f"UNKNOWN ID | {liveness_label}"
# Draw glowing face box
self._draw_neon_bbox(hud_frame, x, y, fw, fh, color, label)
# Draw glowing cyan tracking dots on the 5 landmarks
for lm in landmarks:
cv2.circle(hud_frame, (lm[0], lm[1]), 3, (0, 255, 242), -1) # Core
cv2.circle(hud_frame, (lm[0], lm[1]), 6, (0, 255, 242), 1) # Glow
else:
self.landmark_buffer.clear()
# If no faces, draw a scanning laser-line animation
cv2.line(hud_frame, (10, scanline_y), (w-10, scanline_y), (0, 255, 242), 2)
scanline_y += scanline_direction
if scanline_y >= h - 15 or scanline_y <= 15:
scanline_direction *= -1
# Update latest processed frame
with self.lock:
self.processed_frame = hud_frame
# Limit background loop to ~30 FPS to save CPU
time.sleep(0.033)
def _draw_hud_corners(self, img, x1, y1, x2, y2, color, length, thickness):
"""Draws advanced corner HUD ticks for a premium UI feel."""
# Top Left
cv2.line(img, (x1, y1), (x1 + length, y1), color, thickness)
cv2.line(img, (x1, y1), (x1, y1 + length), color, thickness)
# Top Right
cv2.line(img, (x2, y1), (x2 - length, y1), color, thickness)
cv2.line(img, (x2, y1), (x2, y1 + length), color, thickness)
# Bottom Left
cv2.line(img, (x1, y2), (x1 + length, y2), color, thickness)
cv2.line(img, (x1, y2), (x1, y2 - length), color, thickness)
# Bottom Right
cv2.line(img, (x2, y2), (x2 - length, y2), color, thickness)
cv2.line(img, (x2, y2), (x2, y2 - length), color, thickness)
def _draw_neon_bbox(self, img, x, y, w, h, color, label):
"""Draws a premium rounded bounding box with a neon outer glow and title banner."""
thickness = 2
glow_thickness = 4
# 1. Draw outer glow (semi-transparent blur is hard in OpenCV, so we draw multiple faint thick lines)
cv2.rectangle(img, (x - 2, y - 2), (x + w + 2, y + h + 2), color, thickness + 1)
# 2. Draw sharp primary box
cv2.rectangle(img, (x, y), (x + w, y + h), color, thickness)
# 3. Corner ticks on bounding box
self._draw_hud_corners(img, x, y, x + w, y + h, (0, 255, 242), 10, 2)
# 4. Draw label banner
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = 0.5
text_thickness = 1
(label_w, label_h), baseline = cv2.getTextSize(label, font, font_scale, text_thickness)
# Top-banner position
banner_y = max(y - label_h - 10, 0)
cv2.rectangle(img, (x, banner_y), (x + label_w + 14, y), color, -1) # Filled colored background
# Text overlay
cv2.putText(img, label, (x + 7, y - 5), font, font_scale, (255, 255, 255), text_thickness, cv2.LINE_AA)
def _process_attendance_trigger(self, emp_id: str, emp_name: str, match_score: float):
"""Triggers and logs a check-in or check-out event with robust debounce logic."""
now = datetime.now()
# 1. Check if user is in debounce cooldown
if emp_id in self.log_cooldowns:
last_log_time = self.log_cooldowns[emp_id]
if now - last_log_time < self.cooldown_duration:
# In cooldown, do nothing
return ""
# 2. Fetch last attendance log from DB
last_log = get_last_attendance_log(emp_id)
event_type = "check_in"
if last_log:
# Check if last log was today
last_log_date = datetime.strptime(last_log["timestamp"], "%Y-%m-%d %H:%M:%S")
if last_log_date.date() == now.date():
if last_log["event_type"] == "check_in":
event_type = "check_out"
else:
event_type = "check_in"
# 3. Log event in DB
success = log_attendance(emp_id, event_type, match_score)
if success:
# Update cooldown timestamp
self.log_cooldowns[emp_id] = now
# Put notification in the alerts queue
alert_data = {
"employee_id": emp_id,
"name": emp_name,
"timestamp": now.strftime("%I:%M %p"),
"event_type": event_type,
"score": int(match_score * 100)
}
attendance_alerts.put(alert_data)
# Limit alert queue size to prevent memory leaks
if attendance_alerts.qsize() > 50:
try:
attendance_alerts.get_nowait()
except queue.Empty:
pass
print(f"[+] ATTENDANCE REGISTERED: {emp_name} | {event_type.upper()} | score: {match_score:.3f}")
return event_type
return ""