|
|
@@ -1,86 +1,208 @@
|
|
|
-import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
|
|
+import { Component, ViewChild, ElementRef, AfterViewInit, OnDestroy } from '@angular/core';
|
|
|
import { CommonModule } from '@angular/common';
|
|
|
-import { HttpClient, HttpClientModule } from '@angular/common/http';
|
|
|
import { MatButtonModule } from '@angular/material/button';
|
|
|
import { MatCardModule } from '@angular/material/card';
|
|
|
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
|
|
-import { webConfig } from '../config';
|
|
|
+import * as faceapi from 'face-api.js';
|
|
|
+
|
|
|
import { EnrollDialogComponent } from '../components/enroll-dialog/enroll-dialog.component';
|
|
|
+import { FaceScanResult, FaceService } from './face.service';
|
|
|
+import { EmployeeDialogComponent } from '../components/employee-dialog/employee-dialog.component';
|
|
|
+
|
|
|
+interface TrackedFace {
|
|
|
+ box: faceapi.Box;
|
|
|
+ lastRecognized?: number; // timestamp of last detection (starts cooldown)
|
|
|
+ recognizedName?: string;
|
|
|
+ recognitionConfidence?: number;
|
|
|
+ imageBase64?: string;
|
|
|
+}
|
|
|
+
|
|
|
+interface RecognizedProfile {
|
|
|
+ name: string;
|
|
|
+ confidence: number;
|
|
|
+ color: string;
|
|
|
+ photoUrl?: string; // <-- use server photo
|
|
|
+}
|
|
|
|
|
|
@Component({
|
|
|
- selector: 'app-webcam',
|
|
|
- standalone: true,
|
|
|
- imports: [
|
|
|
- CommonModule,
|
|
|
- HttpClientModule,
|
|
|
- MatButtonModule,
|
|
|
- MatCardModule,
|
|
|
- MatSnackBarModule,
|
|
|
- MatDialogModule
|
|
|
- ],
|
|
|
- templateUrl: './webcam.component.html',
|
|
|
- styleUrls: ['./webcam.component.css']
|
|
|
+ selector: 'app-webcam',
|
|
|
+ standalone: true,
|
|
|
+ imports: [
|
|
|
+ CommonModule,
|
|
|
+ MatButtonModule,
|
|
|
+ MatCardModule,
|
|
|
+ MatSnackBarModule,
|
|
|
+ MatDialogModule
|
|
|
+ ],
|
|
|
+ templateUrl: './webcam.component.html',
|
|
|
+ styleUrls: ['./webcam.component.css']
|
|
|
})
|
|
|
-export class WebcamComponent implements AfterViewInit {
|
|
|
- @ViewChild('video') videoRef!: ElementRef<HTMLVideoElement>;
|
|
|
- @ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
|
|
+export class WebcamComponent implements AfterViewInit, OnDestroy {
|
|
|
+ @ViewChild('video') videoRef!: ElementRef<HTMLVideoElement>;
|
|
|
+ @ViewChild('canvas') canvasRef!: ElementRef<HTMLCanvasElement>;
|
|
|
|
|
|
- result: any = null;
|
|
|
- scanStatus: 'success' | 'fail' | null = null;
|
|
|
+ scanStatus: 'success' | 'fail' | null = null;
|
|
|
+ private detectionInterval: any;
|
|
|
+ private trackedFaces: TrackedFace[] = [];
|
|
|
+ private recognitionCooldown = 5000; // 5 seconds cooldown per face
|
|
|
|
|
|
- constructor(private http: HttpClient, private snack: MatSnackBar, private dialog: MatDialog) { }
|
|
|
+ recognizedProfiles: RecognizedProfile[] = [];
|
|
|
|
|
|
- ngAfterViewInit() {
|
|
|
- navigator.mediaDevices.getUserMedia({ video: true })
|
|
|
- .then(stream => this.videoRef.nativeElement.srcObject = stream)
|
|
|
- .catch(err => console.error('Error accessing webcam:', err));
|
|
|
- }
|
|
|
+ constructor(
|
|
|
+ private faceService: FaceService,
|
|
|
+ private snack: MatSnackBar,
|
|
|
+ private dialog: MatDialog
|
|
|
+ ) { }
|
|
|
+
|
|
|
+ async ngAfterViewInit() {
|
|
|
+ await this.setupCamera();
|
|
|
+ await this.loadFaceModels();
|
|
|
|
|
|
- private captureImage(): string {
|
|
|
- const canvas = this.canvasRef.nativeElement;
|
|
|
- const video = this.videoRef.nativeElement;
|
|
|
- canvas.width = video.videoWidth;
|
|
|
- canvas.height = video.videoHeight;
|
|
|
- canvas.getContext('2d')?.drawImage(video, 0, 0);
|
|
|
- return canvas.toDataURL('image/jpeg').split(',')[1]; // base64 only
|
|
|
+ this.detectionInterval = setInterval(() => this.detectFaces(), 200); // ~5 FPS
|
|
|
+ }
|
|
|
+
|
|
|
+ ngOnDestroy() {
|
|
|
+ if (this.detectionInterval) clearInterval(this.detectionInterval);
|
|
|
+ }
|
|
|
+
|
|
|
+ private async setupCamera() {
|
|
|
+ try {
|
|
|
+ const stream = await navigator.mediaDevices.getUserMedia({ video: {} });
|
|
|
+ this.videoRef.nativeElement.srcObject = stream;
|
|
|
+ } catch (err) {
|
|
|
+ console.error('Error accessing webcam:', err);
|
|
|
}
|
|
|
+ }
|
|
|
+
|
|
|
+ private async loadFaceModels() {
|
|
|
+ const MODEL_URL = '/model'; // Ensure models are hosted here
|
|
|
+ await Promise.all([faceapi.nets.tinyFaceDetector.loadFromUri(MODEL_URL)]);
|
|
|
+ console.log('[INFO] Face-api.js models loaded');
|
|
|
+ }
|
|
|
+
|
|
|
+ private async detectFaces() {
|
|
|
+ const video = this.videoRef.nativeElement;
|
|
|
+ const canvas = this.canvasRef.nativeElement;
|
|
|
+ const displaySize = { width: video.videoWidth, height: video.videoHeight };
|
|
|
+ canvas.width = displaySize.width;
|
|
|
+ canvas.height = displaySize.height;
|
|
|
|
|
|
- scanFace() {
|
|
|
- const base64Image = this.captureImage();
|
|
|
-
|
|
|
- this.http.post<any>(`${webConfig.exposedUrl}/api/face/scan`, { imageBase64: base64Image })
|
|
|
- .subscribe({
|
|
|
- next: res => {
|
|
|
- this.result = res;
|
|
|
- // Determine border color based on confidence
|
|
|
- this.scanStatus = (res.name && res.name !== 'Unknown') ? 'success' : 'fail';
|
|
|
-
|
|
|
- this.snack.open(
|
|
|
- this.scanStatus === 'success'
|
|
|
- ? `Scan successful: ${res.name} (Confidence: ${res.confidence?.toFixed(2)})`
|
|
|
- : 'No match found',
|
|
|
- 'Close',
|
|
|
- { duration: 4000 }
|
|
|
- );
|
|
|
- },
|
|
|
- error: err => {
|
|
|
- console.error(err);
|
|
|
- this.scanStatus = 'fail';
|
|
|
- this.snack.open('Failed to scan face', 'Close', { duration: 3000 });
|
|
|
- }
|
|
|
- });
|
|
|
+ // Detect faces
|
|
|
+ const detections = await faceapi.detectAllFaces(video, new faceapi.TinyFaceDetectorOptions());
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
+ if (!ctx) return;
|
|
|
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
+
|
|
|
+ // Clear recognized profiles if a new detection appears
|
|
|
+ if (detections.length && detections.length !== this.trackedFaces.length) {
|
|
|
+ this.recognizedProfiles = [];
|
|
|
}
|
|
|
|
|
|
- openEnrollDialog() {
|
|
|
- const dialogRef = this.dialog.open(EnrollDialogComponent, {
|
|
|
- width: '400px'
|
|
|
- });
|
|
|
+ // Track faces
|
|
|
+ detections.forEach(det => {
|
|
|
+ const box = det.box;
|
|
|
+ const tracked = this.trackedFaces.find(f => this.isSameFace(f.box, box));
|
|
|
+ if (!tracked) {
|
|
|
+ this.trackedFaces.push({ box });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // Draw bounding boxes and trigger recognition
|
|
|
+ const now = Date.now();
|
|
|
+ for (let face of this.trackedFaces) {
|
|
|
+ let borderColor = 'white';
|
|
|
+
|
|
|
+ if (face.recognizedName) {
|
|
|
+ borderColor = face.recognizedName === 'Unknown' ? 'red' : 'green';
|
|
|
+ }
|
|
|
|
|
|
- dialogRef.afterClosed().subscribe(result => {
|
|
|
- if (result) {
|
|
|
- this.snack.open(`Enrollment completed for ${result.name}`, 'Close', { duration: 3000 });
|
|
|
- }
|
|
|
- });
|
|
|
+ const cooldown = 2000;
|
|
|
+ if (!face.lastRecognized || now - face.lastRecognized > cooldown) {
|
|
|
+ face.lastRecognized = now;
|
|
|
+ this.recognizeFace(face);
|
|
|
+ }
|
|
|
+
|
|
|
+ this.drawBox(ctx, face.box, borderColor);
|
|
|
}
|
|
|
+
|
|
|
+ // Remove faces not detected anymore
|
|
|
+ this.trackedFaces = this.trackedFaces.filter(face =>
|
|
|
+ detections.some(det => this.isSameFace(det.box, face.box))
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ private drawBox(ctx: CanvasRenderingContext2D, box: faceapi.Box, color: string, lineWidth: number = 2) {
|
|
|
+ ctx.strokeStyle = color;
|
|
|
+ ctx.lineWidth = lineWidth;
|
|
|
+ ctx.strokeRect(box.x, box.y, box.width, box.height);
|
|
|
+ }
|
|
|
+
|
|
|
+ private isSameFace(box1: faceapi.Box, box2: faceapi.Box): boolean {
|
|
|
+ const centerDist = Math.hypot(
|
|
|
+ box1.x + box1.width / 2 - (box2.x + box2.width / 2),
|
|
|
+ box1.y + box1.height / 2 - (box2.y + box2.height / 2)
|
|
|
+ );
|
|
|
+ return centerDist < 50; // tweak threshold if needed
|
|
|
+ }
|
|
|
+
|
|
|
+ private recognizeFace(face: TrackedFace) {
|
|
|
+ const video = this.videoRef.nativeElement;
|
|
|
+ const tempCanvas = document.createElement('canvas');
|
|
|
+ tempCanvas.width = face.box.width;
|
|
|
+ tempCanvas.height = face.box.height;
|
|
|
+ const tempCtx = tempCanvas.getContext('2d');
|
|
|
+ if (!tempCtx) return;
|
|
|
+
|
|
|
+ tempCtx.drawImage(
|
|
|
+ video,
|
|
|
+ face.box.x, face.box.y, face.box.width, face.box.height,
|
|
|
+ 0, 0, face.box.width, face.box.height
|
|
|
+ );
|
|
|
+
|
|
|
+ // Store the captured face image
|
|
|
+ face.imageBase64 = tempCanvas.toDataURL('image/jpeg');
|
|
|
+
|
|
|
+ const base64Image = face.imageBase64.split(',')[1];
|
|
|
+
|
|
|
+ this.faceService.scanFace(base64Image).subscribe({
|
|
|
+ next: (res: FaceScanResult) => {
|
|
|
+ const color = res.name === 'Unknown' ? '#fde0e0' : '#e0f7e9';
|
|
|
+
|
|
|
+ // Add profile if not already present
|
|
|
+ if (!this.recognizedProfiles.find(p => p.name === res.name)) {
|
|
|
+ this.recognizedProfiles.push({
|
|
|
+ name: res.name,
|
|
|
+ confidence: res.confidence,
|
|
|
+ color,
|
|
|
+ photoUrl: res.photoUrl // use server image
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ face.recognizedName = res.name || 'Unknown';
|
|
|
+ face.recognitionConfidence = res.confidence || 0;
|
|
|
+ this.scanStatus = res.name && res.name !== 'Unknown' ? 'success' : 'fail';
|
|
|
+ },
|
|
|
+ error: (err) => {
|
|
|
+ console.error('Recognition failed:', err);
|
|
|
+ face.recognizedName = 'Unknown';
|
|
|
+ this.scanStatus = 'fail';
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ openEnrollDialog() {
|
|
|
+ const dialogRef = this.dialog.open(EnrollDialogComponent, { width: '400px' });
|
|
|
+ dialogRef.afterClosed().subscribe(result => {
|
|
|
+ if (result) {
|
|
|
+ this.snack.open(`Enrollment completed for ${result.name}`, 'Close', { duration: 3000 });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ openEmployeeDialog() {
|
|
|
+ this.dialog.open(EmployeeDialogComponent, {
|
|
|
+ width: '450px'
|
|
|
+ });
|
|
|
+ }
|
|
|
}
|