Browse Source

facial recogniition

Dr-Swopt 1 week ago
parent
commit
3f165a254e

+ 73 - 0
package-lock.json

@@ -25,6 +25,7 @@
         "@simplewebauthn/browser": "^13.1.0",
         "angularx-qrcode": "^20.0.0",
         "chart.js": "^4.5.1",
+        "face-api.js": "^0.22.2",
         "ng2-charts": "^5.0.4",
         "ngx-socket-io": "^4.9.1",
         "rxjs": "~7.8.2",
@@ -5647,6 +5648,23 @@
       "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
       "license": "MIT"
     },
+    "node_modules/@tensorflow/tfjs-core": {
+      "version": "1.7.0",
+      "resolved": "https://registry.npmjs.org/@tensorflow/tfjs-core/-/tfjs-core-1.7.0.tgz",
+      "integrity": "sha512-uwQdiklNjqBnHPeseOdG0sGxrI3+d6lybaKu2+ou3ajVeKdPEwpWbgqA6iHjq1iylnOGkgkbbnQ6r2lwkiIIHw==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@types/offscreencanvas": "~2019.3.0",
+        "@types/seedrandom": "2.4.27",
+        "@types/webgl-ext": "0.0.30",
+        "@types/webgl2": "0.0.4",
+        "node-fetch": "~2.1.2",
+        "seedrandom": "2.4.3"
+      },
+      "engines": {
+        "yarn": ">= 1.3.2"
+      }
+    },
     "node_modules/@tufjs/canonical-json": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz",
@@ -5871,6 +5889,12 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/offscreencanvas": {
+      "version": "2019.3.0",
+      "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.3.0.tgz",
+      "integrity": "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q==",
+      "license": "MIT"
+    },
     "node_modules/@types/qs": {
       "version": "6.14.0",
       "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -5892,6 +5916,12 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@types/seedrandom": {
+      "version": "2.4.27",
+      "resolved": "https://registry.npmjs.org/@types/seedrandom/-/seedrandom-2.4.27.tgz",
+      "integrity": "sha512-YvMLqFak/7rt//lPBtEHv3M4sRNA+HGxrhFZ+DQs9K2IkYJbNwVIb8avtJfhDiuaUBX/AW0jnjv48FV8h3u9bQ==",
+      "license": "MIT"
+    },
     "node_modules/@types/send": {
       "version": "0.17.5",
       "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz",
@@ -5942,6 +5972,18 @@
         "@types/node": "*"
       }
     },
+    "node_modules/@types/webgl-ext": {
+      "version": "0.0.30",
+      "resolved": "https://registry.npmjs.org/@types/webgl-ext/-/webgl-ext-0.0.30.tgz",
+      "integrity": "sha512-LKVgNmBxN0BbljJrVUwkxwRYqzsAEPcZOe6S2T6ZaBDIrFp0qu4FNlpc5sM1tGbXUYFgdVQIoeLk1Y1UoblyEg==",
+      "license": "MIT"
+    },
+    "node_modules/@types/webgl2": {
+      "version": "0.0.4",
+      "resolved": "https://registry.npmjs.org/@types/webgl2/-/webgl2-0.0.4.tgz",
+      "integrity": "sha512-PACt1xdErJbMUOUweSrbVM7gSIYm1vTncW2hF6Os/EeWi6TXYAYMPp+8v6rzHmypE5gHrxaxZNXgMkJVIdZpHw==",
+      "license": "MIT"
+    },
     "node_modules/@types/ws": {
       "version": "8.18.1",
       "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -8501,6 +8543,22 @@
         "node": ">=4"
       }
     },
+    "node_modules/face-api.js": {
+      "version": "0.22.2",
+      "resolved": "https://registry.npmjs.org/face-api.js/-/face-api.js-0.22.2.tgz",
+      "integrity": "sha512-9Bbv/yaBRTKCXjiDqzryeKhYxmgSjJ7ukvOvEBy6krA0Ah/vNBlsf7iBNfJljWiPA8Tys1/MnB3lyP2Hfmsuyw==",
+      "license": "MIT",
+      "dependencies": {
+        "@tensorflow/tfjs-core": "1.7.0",
+        "tslib": "^1.11.1"
+      }
+    },
+    "node_modules/face-api.js/node_modules/tslib": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+      "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
+      "license": "0BSD"
+    },
     "node_modules/fast-deep-equal": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -11368,6 +11426,15 @@
       "license": "MIT",
       "optional": true
     },
+    "node_modules/node-fetch": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.1.2.tgz",
+      "integrity": "sha512-IHLHYskTc2arMYsHZH82PVX8CSKT5lzb7AXeyO06QnjGDKtkv+pv3mEki6S7reB/x1QPo+YPxQRNEVgR5V/w3Q==",
+      "license": "MIT",
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      }
+    },
     "node_modules/node-forge": {
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
@@ -13203,6 +13270,12 @@
         }
       }
     },
+    "node_modules/seedrandom": {
+      "version": "2.4.3",
+      "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-2.4.3.tgz",
+      "integrity": "sha512-2CkZ9Wn2dS4mMUWQaXLsOAfGD+irMlLEeSP3cMxpGbgyOOzJGFa+MWCOMTOCMyZinHRPxyOj/S/C57li/1to6Q==",
+      "license": "MIT"
+    },
     "node_modules/select-hose": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",

+ 2 - 1
package.json

@@ -30,6 +30,7 @@
     "@simplewebauthn/browser": "^13.1.0",
     "angularx-qrcode": "^20.0.0",
     "chart.js": "^4.5.1",
+    "face-api.js": "^0.22.2",
     "ng2-charts": "^5.0.4",
     "ngx-socket-io": "^4.9.1",
     "rxjs": "~7.8.2",
@@ -52,4 +53,4 @@
     "karma-jasmine-html-reporter": "~2.1.0",
     "typescript": "~5.8.3"
   }
-}
+}

BIN
public/model/tiny_face_detector_model-shard1


File diff suppressed because it is too large
+ 0 - 0
public/model/tiny_face_detector_model-weights_manifest.json


+ 17 - 0
src/app/components/employee-dialog/employee-dialog.component.css

@@ -0,0 +1,17 @@
+.employee-photo {
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  object-fit: cover;
+  margin-right: 12px;
+}
+
+.mat-list-item {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.employee-name {
+  flex: 1;
+}

+ 19 - 0
src/app/components/employee-dialog/employee-dialog.component.html

@@ -0,0 +1,19 @@
+<h2 mat-dialog-title>Employee List</h2>
+
+<div mat-dialog-content *ngIf="!loading; else loadingTpl">
+  <mat-list>
+    <mat-list-item *ngFor="let employee of employees">
+      <img *ngIf="employee.photoUrl" [src]="employee.photoUrl" class="employee-photo" alt="{{ employee.name }}">
+      <span class="employee-name">{{ employee.name }}</span>
+      <button mat-mini-button color="warn" (click)="deleteEmployee(employee)">Delete</button>
+    </mat-list-item>
+  </mat-list>
+</div>
+
+<ng-template #loadingTpl>
+  <p>Loading employees...</p>
+</ng-template>
+
+<div mat-dialog-actions>
+  <button mat-button (click)="closeDialog()">Close</button>
+</div>

+ 79 - 0
src/app/components/employee-dialog/employee-dialog.component.ts

@@ -0,0 +1,79 @@
+// employee-dialog.component.ts
+import { Component, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { MatListModule } from '@angular/material/list';
+import { HttpClientModule, HttpClient } from '@angular/common/http';
+import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
+import { webConfig } from '../../config';
+
+interface Employee {
+  name: string;
+  photoUrl?: string;
+}
+
+@Component({
+  selector: 'app-employee-dialog',
+  standalone: true,
+  imports: [
+    CommonModule,
+    HttpClientModule,
+    MatDialogModule,
+    MatButtonModule,
+    MatListModule,
+    MatSnackBarModule
+  ],
+  templateUrl: './employee-dialog.component.html',
+  styleUrls: ['./employee-dialog.component.css']
+})
+export class EmployeeDialogComponent implements OnInit {
+  employees: Employee[] = [];
+  loading = false;
+
+  constructor(
+    private http: HttpClient,
+    private snack: MatSnackBar,
+    private dialogRef: MatDialogRef<EmployeeDialogComponent>
+  ) {}
+
+  ngOnInit(): void {
+    this.fetchEmployees();
+  }
+
+  fetchEmployees() {
+    this.loading = true;
+    this.http.get<Employee[]>(`${webConfig.exposedUrl}/api/face/list`)
+      .subscribe({
+        next: res => {
+          this.employees = res;
+          this.loading = false;
+        },
+        error: err => {
+          console.error('Failed to fetch employees', err);
+          this.snack.open('Failed to load employees', 'Close', { duration: 3000 });
+          this.loading = false;
+        }
+      });
+  }
+
+  deleteEmployee(employee: Employee) {
+    if (!confirm(`Are you sure you want to delete ${employee.name}?`)) return;
+
+    this.http.delete(`${webConfig.exposedUrl}/api/face/delete/${employee.name}`)
+      .subscribe({
+        next: () => {
+          this.snack.open(`${employee.name} deleted`, 'Close', { duration: 3000 });
+          this.employees = this.employees.filter(e => e.name !== employee.name);
+        },
+        error: err => {
+          console.error('Delete failed', err);
+          this.snack.open('Failed to delete employee', 'Close', { duration: 3000 });
+        }
+      });
+  }
+
+  closeDialog() {
+    this.dialogRef.close();
+  }
+}

+ 34 - 0
src/app/webcam/face.service.ts

@@ -0,0 +1,34 @@
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+import { webConfig } from '../config';
+import { Observable } from 'rxjs';
+
+export interface FaceScanResult {
+  name: string;
+  confidence: number;
+  photoUrl?: string; // server-provided enrolled image
+}
+
+
+@Injectable({
+  providedIn: 'root'
+})
+export class FaceService {
+  constructor(private http: HttpClient) {}
+
+  scanFace(base64Image: string): Observable<FaceScanResult> {
+    return this.http.post<FaceScanResult>(`${webConfig.exposedUrl}/api/face/scan`, { imageBase64: base64Image });
+  }
+
+  enrollFace(base64Image: string, name: string): Observable<any> {
+    return this.http.post(`${webConfig.exposedUrl}/api/face/enroll`, { imageBase64: base64Image, name });
+  }
+
+  listEmployees(): Observable<any> {
+    return this.http.get(`${webConfig.exposedUrl}/api/face/list`);
+  }
+
+  deleteEmployee(name: string): Observable<any> {
+    return this.http.delete(`${webConfig.exposedUrl}/api/face/delete/${name}`);
+  }
+}

+ 67 - 54
src/app/webcam/webcam.component.css

@@ -1,77 +1,90 @@
 .webcam-card {
-    max-width: 480px;
-    margin: 40px auto;
-    padding: 24px;
-    border-radius: 12px;
-    text-align: center;
-    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
-    background: #ffffff;
+  max-width: 480px;
+  margin: 40px auto;
+  padding: 24px;
+  border-radius: 12px;
+  text-align: center;
+  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
+  background: #ffffff;
 }
 
 .title {
-    margin-bottom: 16px;
-    font-weight: 600;
-    color: #333;
+  margin-bottom: 16px;
+  font-weight: 600;
+  color: #333;
 }
 
-.video-container {
-    position: relative;
-    width: 100%;
-    overflow: hidden;
-    border-radius: 8px;
-    border: 6px solid #ddd;
-    /* thicker default border */
-    background: #000;
-    transition: border-color 0.3s ease;
+.video-wrapper {
+  position: relative;
+  width: 100%;
+  overflow: hidden;
+  border-radius: 8px;
+  background: #000;
 }
 
-.video-container.success-border {
-    border-color: #4caf50;
-    /* green */
-}
-
-.video-container.fail-border {
-    border-color: #f44336;
-    /* red */
+video {
+  width: 100%;
+  height: auto;
+  display: block;
+  border-radius: 8px;
+  object-fit: cover;
 }
 
-video {
-    width: 100%;
-    height: auto;
-    display: block;
-    border-radius: 8px;
-    object-fit: cover;
+.overlay-canvas {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  pointer-events: none;
+  /* allows clicking through */
 }
 
 .actions {
-    margin-top: 16px;
-    display: flex;
-    justify-content: center;
-    gap: 16px;
+  margin-top: 16px;
+  display: flex;
+  justify-content: center;
+  gap: 16px;
 }
 
 .actions button {
-    min-width: 120px;
-    font-weight: 500;
+  min-width: 120px;
+  font-weight: 500;
+}
+
+.recognized-profiles {
+  margin-top: 24px;
+  text-align: left;
+}
+
+.profile-card {
+  display: flex;
+  align-items: center;
+  margin-top: 12px;
+  padding: 12px;
+  border-radius: 8px;
+  border-left: 4px solid green;
+  text-align: left;
 }
 
-.result-card {
-    margin-top: 24px;
-    padding: 12px;
-    border-radius: 8px;
-    background: #f5f5f5;
-    text-align: left;
+.profile-info {
+  display: flex;
+  align-items: center;
+  gap: 12px;
 }
 
-.result-card h3 {
-    margin: 0 0 8px 0;
-    font-weight: 600;
-    font-size: 1.1rem;
-    color: #444;
+.profile-photo {
+  width: 60px;
+  height: 60px;
+  border-radius: 50%;
+  object-fit: cover;
+  border: 2px solid #fff;
+  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
 }
 
-.result-card p {
-    margin: 4px 0;
-    font-size: 0.95rem;
-    color: #555;
+.profile-text p {
+  margin: 2px 0;
+  font-size: 0.95rem;
+  color: #333;
+  font-weight: 500;
 }

+ 22 - 14
src/app/webcam/webcam.component.html

@@ -1,20 +1,28 @@
 <mat-card class="webcam-card">
-    <h2 class="title">Face Recognition</h2>
+  <h2 class="title">Face Recognition</h2>
 
-    <div class="video-container"
-        [ngClass]="{'success-border': scanStatus === 'success', 'fail-border': scanStatus === 'fail'}">
-        <video #video autoplay></video>
-        <canvas #canvas style="display:none;"></canvas>
-    </div>
+  <div class="video-wrapper">
+    <video #video autoplay muted></video>
+    <canvas #canvas class="overlay-canvas"></canvas>
+  </div>
 
-    <div class="actions">
-        <button mat-raised-button color="primary" (click)="scanFace()">Scan Face</button>
-        <button mat-raised-button color="accent" (click)="openEnrollDialog()">Enroll Face</button>
-    </div>
+  <div class="actions">
+    <button mat-raised-button color="accent" (click)="openEnrollDialog()">Enroll Face</button>
+    <button mat-raised-button color="primary" (click)="openEmployeeDialog()">Manage Employees</button>
+  </div>
 
-    <div *ngIf="result" class="result-card">
-        <h3>Scan Result</h3>
-        <p><strong>Name:</strong> {{ result.name }}</p>
-        <p><strong>Confidence:</strong> {{ result.confidence | number:'1.2-2' }}</p>
+  <!-- Recognized Faces Section -->
+  <div *ngIf="recognizedProfiles.length" class="recognized-profiles">
+    <h3>Recognized Faces</h3>
+    <div *ngFor="let profile of recognizedProfiles" class="profile-card" [style.background]="profile.color">
+      <div class="profile-info">
+        <img *ngIf="profile.photoUrl" [src]="profile.photoUrl" alt="{{ profile.name }}" class="profile-photo">
+        <div class="profile-text">
+          <p><strong>Name:</strong> {{ profile.name }}</p>
+          <p><strong>Confidence:</strong> {{ profile.confidence | number:'1.2-2' }}</p>
+        </div>
+      </div>
     </div>
+  </div>
+
 </mat-card>

+ 188 - 66
src/app/webcam/webcam.component.ts

@@ -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'
+    });
+  }
 }

Some files were not shown because too many files changed in this diff