--- name: face-recognition description: Face recognition system patterns for attendance. Use when working with face detection, verification, enrollment, liveness detection, or any biometric authentication features. allowed-tools: Read, Grep, Glob, Edit, Write --- # Face Recognition Skill This skill provides guidance for implementing and maintaining the face recognition system used for attendance tracking. ## Architecture Overview ``` Frontend (Vue 3 + TypeScript) ├── components/ # UI Components │ ├── FaceRecognition.vue │ ├── FaceEnrollment.vue │ └── FaceAnalytics.vue ├── services/ # Detection Libraries │ ├── FaceDetectionService.js # Face-API.js wrapper │ └── MediaPipeFaceService.js # MediaPipe wrapper ├── composables/ │ └── useFaceDetection.js # Composable logic ├── stores/ │ └── faceDetection.ts # Pinia state └── types/ └── face-recognition.ts # TypeScript interfaces Backend (Laravel) ├── Services/ │ └── FaceRecognitionService.php # Core business logic ├── Controllers/ │ └── FaceDetectionController.php └── routes/ └── api_face_recognition.php ``` ## Face Descriptor Format Face descriptors are 128-dimensional float arrays generated by the face recognition neural network: ```typescript // TypeScript type FaceDescriptor = number[] // Length: 128, Range: -1 to 1 // PHP /** @var float[] $descriptor 128 elements */ ``` ## Frontend Implementation ### Using Face-API.js (Primary) ```typescript // resources/js/services/FaceDetectionService.js import * as faceapi from 'face-api.js' // Load models (call once at app init) async function loadModels() { const modelPath = '/models' await Promise.all([ faceapi.nets.tinyFaceDetector.loadFromUri(modelPath), faceapi.nets.faceLandmark68Net.loadFromUri(modelPath), faceapi.nets.faceRecognitionNet.loadFromUri(modelPath), faceapi.nets.faceExpressionNet.loadFromUri(modelPath), ]) } // Detect face and get descriptor async function detectFace(video: HTMLVideoElement): Promise { const detection = await faceapi .detectSingleFace(video, new faceapi.TinyFaceDetectorOptions({ inputSize: 416, scoreThreshold: 0.5 })) .withFaceLandmarks() .withFaceDescriptor() .withFaceExpressions() if (!detection) return null return { confidence: detection.detection.score, boundingBox: detection.detection.box, landmarks: detection.landmarks.positions, descriptor: Array.from(detection.descriptor), // Float32Array → number[] expressions: detection.expressions } } ``` ### Using MediaPipe (Lightweight Alternative) ```typescript // resources/js/services/MediaPipeFaceService.js import { FaceDetection } from '@mediapipe/face_detection' const faceDetection = new FaceDetection({ locateFile: (file) => `/mediapipe/${file}` }) faceDetection.setOptions({ model: 'short', // 'short' (fast) or 'full' (accurate) minDetectionConfidence: 0.5, maxNumFaces: 1 }) // Note: MediaPipe doesn't generate 128-dim descriptors natively // Use Face-API.js for descriptor generation when matching is needed ``` ### Composable Pattern ```typescript // resources/js/composables/useFaceDetection.js import { ref, onMounted, onUnmounted } from 'vue' import { useFaceDetectionStore } from '@/stores/faceDetection' export function useFaceDetection() { const store = useFaceDetectionStore() const videoRef = ref(null) const canvasRef = ref(null) async function startCamera() { const stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' } }) if (videoRef.value) { videoRef.value.srcObject = stream } store.setCameraActive(true) } function stopCamera() { const stream = videoRef.value?.srcObject as MediaStream stream?.getTracks().forEach(track => track.stop()) store.setCameraActive(false) } async function captureAndVerify() { if (!videoRef.value) return null const result = await detectFace(videoRef.value) if (!result || result.confidence < 0.7) { return { success: false, error: 'No face detected or low confidence' } } // Send to backend for verification const response = await faceRecognitionAPI.verifyFace({ descriptor: result.descriptor, confidence: result.confidence, liveness: result.liveness }) return response } onUnmounted(() => stopCamera()) return { videoRef, canvasRef, startCamera, stopCamera, captureAndVerify, isReady: computed(() => store.isInitialized), isProcessing: computed(() => store.processing) } } ``` ### Pinia Store Structure ```typescript // resources/js/stores/faceDetection.ts import { defineStore } from 'pinia' interface FaceDetectionState { cameraActive: boolean isInitialized: boolean processing: boolean currentDetection: FaceDetectionResult | null settings: { minConfidence: number // Default: 0.7 enableLiveness: boolean // Default: true detectionMethod: 'face-api' | 'mediapipe' } } export const useFaceDetectionStore = defineStore('faceDetection', () => { const state = reactive({ cameraActive: false, isInitialized: false, processing: false, currentDetection: null, settings: { minConfidence: 0.7, enableLiveness: true, detectionMethod: 'face-api' } }) // Getters const canCapture = computed(() => state.cameraActive && state.isInitialized && !state.processing ) const detectionQuality = computed(() => { const conf = state.currentDetection?.confidence ?? 0 if (conf >= 0.9) return 'excellent' if (conf >= 0.7) return 'good' if (conf >= 0.5) return 'fair' return 'poor' }) return { ...toRefs(state), canCapture, detectionQuality } }) ``` ## Backend Implementation ### Service Layer ```php // app/Services/FaceRecognitionService.php class FaceRecognitionService { // Configuration constants const SIMILARITY_THRESHOLD = 0.6; // Minimum match score const MIN_CONFIDENCE = 0.7; // Minimum detection confidence const QUALITY_THRESHOLD = 0.7; // Minimum quality for registration const CACHE_TTL = 3600; // Face data cache (1 hour) /** * Register a new face for an employee */ public function registerFace(int $employeeId, array $data): array { // Validate descriptor if (count($data['descriptor']) !== 128) { throw new InvalidArgumentException('Descriptor must be 128-dimensional'); } if ($data['confidence'] < self::MIN_CONFIDENCE) { return [ 'success' => false, 'message' => 'Detection confidence too low' ]; } $employee = Employee::findOrFail($employeeId); // Store face image securely $imagePath = $this->storeFaceImage($employeeId, $data['image']); // Calculate quality score $quality = $this->calculateQualityScore($data); // Save to employee metadata $employee->update([ 'face_descriptor' => json_encode($data['descriptor']), 'face_image_path' => $imagePath, 'face_quality_score' => $quality, 'face_registered_at' => now(), ]); // Clear cache Cache::forget("face_data_{$employeeId}"); return [ 'success' => true, 'quality' => $quality, 'message' => 'Face registered successfully' ]; } /** * Verify a face against registered employees */ public function verifyFace(array $data): array { $descriptor = $data['descriptor']; $registeredFaces = $this->getRegisteredFaces(); $bestMatch = null; $highestSimilarity = 0; foreach ($registeredFaces as $face) { $similarity = $this->cosineSimilarity($descriptor, $face['descriptor']); if ($similarity > $highestSimilarity && $similarity >= self::SIMILARITY_THRESHOLD) { $highestSimilarity = $similarity; $bestMatch = $face; } } if (!$bestMatch) { return [ 'success' => false, 'message' => 'No matching face found' ]; } return [ 'success' => true, 'employee_id' => $bestMatch['employee_id'], 'employee_name' => $bestMatch['employee_name'], 'similarity' => $highestSimilarity, 'confidence' => $data['confidence'] ]; } /** * Calculate cosine similarity between two descriptors */ private function cosineSimilarity(array $a, array $b): float { $dotProduct = 0; $normA = 0; $normB = 0; for ($i = 0; $i < 128; $i++) { $dotProduct += $a[$i] * $b[$i]; $normA += $a[$i] * $a[$i]; $normB += $b[$i] * $b[$i]; } $denominator = sqrt($normA) * sqrt($normB); return $denominator > 0 ? $dotProduct / $denominator : 0; } /** * Calculate face quality score (multi-factor) */ private function calculateQualityScore(array $data): float { $weights = [ 'confidence' => 0.30, 'face_size' => 0.20, 'pose' => 0.20, 'lighting' => 0.15, 'blur' => 0.15, ]; $scores = [ 'confidence' => $data['confidence'] ?? 0, 'face_size' => $this->calculateFaceSizeScore($data['boundingBox'] ?? null), 'pose' => $data['pose_score'] ?? 0.8, 'lighting' => $data['lighting_score'] ?? 0.8, 'blur' => $data['blur_score'] ?? 0.8, ]; $totalScore = 0; foreach ($weights as $factor => $weight) { $totalScore += ($scores[$factor] ?? 0) * $weight; } return round($totalScore, 4); } /** * Get all registered faces (cached) */ private function getRegisteredFaces(): array { return Cache::remember('registered_faces', self::CACHE_TTL, function () { return Employee::whereNotNull('face_descriptor') ->where('status', 'active') ->get() ->map(fn ($e) => [ 'employee_id' => $e->id, 'employee_name' => $e->name, 'descriptor' => json_decode($e->face_descriptor, true), ]) ->toArray(); }); } } ``` ### Controller ```php // app/Http/Controllers/FaceDetectionController.php class FaceDetectionController extends Controller { public function __construct( private readonly FaceRecognitionService $faceService ) {} public function register(Request $request): JsonResponse { $validated = $request->validate([ 'employee_id' => 'required|exists:employees,id', 'descriptor' => 'required|array|size:128', 'descriptor.*' => 'numeric', 'confidence' => 'required|numeric|min:0.7|max:1', 'image' => 'required|string', // Base64 ]); $result = $this->faceService->registerFace( $validated['employee_id'], $validated ); return response()->json($result, $result['success'] ? 200 : 400); } public function verify(Request $request): JsonResponse { $validated = $request->validate([ 'descriptor' => 'required|array|size:128', 'descriptor.*' => 'numeric', 'confidence' => 'required|numeric|min:0|max:1', 'liveness' => 'nullable|numeric|min:0|max:1', ]); $result = $this->faceService->verifyFace($validated); return response()->json($result); } } ``` ## TypeScript Interfaces ```typescript // resources/js/types/face-recognition.ts export interface FaceDetectionResult { confidence: number // 0-1 detection score liveness: number // 0-1 liveness score boundingBox: BoundingBox | null landmarks?: FaceLandmark[] descriptor?: number[] // 128-dim array expressions?: FaceExpressions } export interface BoundingBox { x: number y: number width: number height: number } export interface FaceLandmark { x: number y: number z?: number } export interface FaceExpressions { neutral: number happy: number sad: number angry: number fearful: number disgusted: number surprised: number } export interface VerificationResult { success: boolean employee_id?: string employee_name?: string similarity?: number confidence?: number message?: string } export interface RegistrationResult { success: boolean quality?: number message: string } export type DetectionMethod = 'face-api' | 'mediapipe' ``` ## Liveness Detection Basic liveness checks to prevent photo spoofing: ```typescript // Frontend liveness detection async function checkLiveness(detections: FaceDetectionResult[]): Promise { let score = 0.5 // Base score // Check for blink (eye aspect ratio change) if (detectBlink(detections)) score += 0.15 // Check for head movement if (detectHeadMovement(detections)) score += 0.15 // Check for expression variation if (detectExpressionChange(detections)) score += 0.1 // Texture analysis (photo vs real face) if (analyzeTexture(detections)) score += 0.1 return Math.min(score, 1.0) } ``` ## API Routes ```php // routes/api_face_recognition.php Route::middleware('auth:sanctum')->prefix('face-recognition')->group(function () { Route::post('/register', [FaceDetectionController::class, 'register']); Route::post('/verify', [FaceDetectionController::class, 'verify']); Route::post('/update', [FaceDetectionController::class, 'update']); Route::delete('/delete/{employee}', [FaceDetectionController::class, 'delete']); Route::get('/statistics', [FaceDetectionController::class, 'statistics']); }); ``` ## Quality Thresholds | Score | Rating | Action | |-------|--------|--------| | >= 0.9 | Excellent | Accept | | 0.7 - 0.9 | Good | Accept | | 0.5 - 0.7 | Fair | Warn user, suggest retry | | < 0.5 | Poor | Reject, require retry | ## Best Practices 1. **Always validate descriptor length** (must be exactly 128) 2. **Use cosine similarity** for descriptor comparison (not Euclidean distance) 3. **Cache registered faces** to improve verification speed 4. **Require minimum confidence** of 0.7 for registration 5. **Store face images privately** in non-public storage 6. **Clear cache** when face data is updated or deleted 7. **Use transactions** when updating face data with related records 8. **Log face operations** for audit trail ## Common Issues ### Low Detection Confidence - Ensure adequate lighting - Face should be centered and fully visible - Avoid extreme angles (> 30 degrees) ### Verification Failures - Check similarity threshold (default 0.6) - Verify descriptor format (128 floats) - Confirm employee has registered face ### Performance - Load models once at app initialization - Use `tinyFaceDetector` for faster detection - Cache registered faces (1 hour TTL)