facecog-liveness-showcase 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.browserslistrc +15 -0
- package/.dockerignore +48 -0
- package/.editorconfig +16 -0
- package/.eslintrc.json +47 -0
- package/.vercelignore +7 -0
- package/.vscode/extensions.json +5 -0
- package/.vscode/settings.json +3 -0
- package/DOCKER.md +221 -0
- package/Dockerfile +33 -0
- package/README.md +268 -0
- package/angular.json +156 -0
- package/capacitor.config.ts +9 -0
- package/docker-compose.dev.yml +20 -0
- package/docker-compose.yml +18 -0
- package/ionic.config.json +7 -0
- package/jest.config.js +38 -0
- package/nginx.conf +50 -0
- package/package.json +131 -0
- package/patches/ng-packagr+20.3.2.patch +60 -0
- package/projects/facecog-liveness-verification/README.md +295 -0
- package/projects/facecog-liveness-verification/ng-package.json +7 -0
- package/projects/facecog-liveness-verification/package.json +48 -0
- package/projects/facecog-liveness-verification/scripts/build-with-wrapper-copy.js +38 -0
- package/projects/facecog-liveness-verification/scripts/copy-wrapper-after-ngc.js +35 -0
- package/projects/facecog-liveness-verification/sources/FaceLivenessReactWrapper.tsx +320 -0
- package/projects/facecog-liveness-verification/src/lib/components/aws-face-liveness/FaceLivenessReactWrapper.generated.d.ts +28 -0
- package/projects/facecog-liveness-verification/src/lib/components/aws-face-liveness/FaceLivenessReactWrapper.generated.js +247 -0
- package/projects/facecog-liveness-verification/src/lib/components/aws-face-liveness/FaceLivenessReactWrapper.generated.js.map +1 -0
- package/projects/facecog-liveness-verification/src/lib/components/aws-face-liveness/FaceLivenessReactWrapper.js.map +1 -0
- package/projects/facecog-liveness-verification/src/lib/components/aws-face-liveness/FaceLivenessReactWrapper.ts +5 -0
- package/projects/facecog-liveness-verification/src/lib/components/aws-face-liveness/aws-face-liveness.component.ts +500 -0
- package/projects/facecog-liveness-verification/src/lib/components/camera-permission/camera-permission.component.html +41 -0
- package/projects/facecog-liveness-verification/src/lib/components/camera-permission/camera-permission.component.scss +234 -0
- package/projects/facecog-liveness-verification/src/lib/components/camera-permission/camera-permission.component.spec.ts +158 -0
- package/projects/facecog-liveness-verification/src/lib/components/camera-permission/camera-permission.component.ts +58 -0
- package/projects/facecog-liveness-verification/src/lib/components/camera-verification/camera-verification.component.html +34 -0
- package/projects/facecog-liveness-verification/src/lib/components/camera-verification/camera-verification.component.ts +210 -0
- package/projects/facecog-liveness-verification/src/lib/components/dialogs/save-custom-pose-dialog.component.ts +174 -0
- package/projects/facecog-liveness-verification/src/lib/components/facetec-scan/facetec-scan.component.html +45 -0
- package/projects/facecog-liveness-verification/src/lib/components/facetec-scan/facetec-scan.component.scss +87 -0
- package/projects/facecog-liveness-verification/src/lib/components/facetec-scan/facetec-scan.component.ts +182 -0
- package/projects/facecog-liveness-verification/src/lib/components/intro/intro.component.html +394 -0
- package/projects/facecog-liveness-verification/src/lib/components/intro/intro.component.scss +1567 -0
- package/projects/facecog-liveness-verification/src/lib/components/intro/intro.component.spec.ts +699 -0
- package/projects/facecog-liveness-verification/src/lib/components/intro/intro.component.ts +721 -0
- package/projects/facecog-liveness-verification/src/lib/components/live-preview/live-preview.component.html +120 -0
- package/projects/facecog-liveness-verification/src/lib/components/live-preview/live-preview.component.scss +611 -0
- package/projects/facecog-liveness-verification/src/lib/components/live-preview/live-preview.component.spec.ts +605 -0
- package/projects/facecog-liveness-verification/src/lib/components/live-preview/live-preview.component.ts +524 -0
- package/projects/facecog-liveness-verification/src/lib/components/liveness-flow/liveness-flow.component.html +73 -0
- package/projects/facecog-liveness-verification/src/lib/components/liveness-flow/liveness-flow.component.scss +19 -0
- package/projects/facecog-liveness-verification/src/lib/components/liveness-flow/liveness-flow.component.spec.ts +673 -0
- package/projects/facecog-liveness-verification/src/lib/components/liveness-flow/liveness-flow.component.ts +963 -0
- package/projects/facecog-liveness-verification/src/lib/components/liveness-verification/liveness-verification.component.html +38 -0
- package/projects/facecog-liveness-verification/src/lib/components/liveness-verification/liveness-verification.component.scss +10 -0
- package/projects/facecog-liveness-verification/src/lib/components/liveness-verification/liveness-verification.component.ts +233 -0
- package/projects/facecog-liveness-verification/src/lib/components/pose-selection/pose-selection.component.html +17 -0
- package/projects/facecog-liveness-verification/src/lib/components/pose-selection/pose-selection.component.spec.ts +35 -0
- package/projects/facecog-liveness-verification/src/lib/components/pose-selection/pose-selection.component.ts +33 -0
- package/projects/facecog-liveness-verification/src/lib/components/processing/processing.component.html +17 -0
- package/projects/facecog-liveness-verification/src/lib/components/processing/processing.component.scss +156 -0
- package/projects/facecog-liveness-verification/src/lib/components/processing/processing.component.spec.ts +46 -0
- package/projects/facecog-liveness-verification/src/lib/components/processing/processing.component.ts +18 -0
- package/projects/facecog-liveness-verification/src/lib/components/verification-result/verification-result.component.html +190 -0
- package/projects/facecog-liveness-verification/src/lib/components/verification-result/verification-result.component.scss +534 -0
- package/projects/facecog-liveness-verification/src/lib/components/verification-result/verification-result.component.spec.ts +286 -0
- package/projects/facecog-liveness-verification/src/lib/components/verification-result/verification-result.component.ts +155 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/analyze-response.interface.ts +16 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/aws-face-liveness.interface.ts +46 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/backend-adapter.interface.ts +21 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/backend-http-client.interface.ts +93 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/backend-response.interface.ts +9 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/camera-provider.interface.ts +107 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/category-info.interface.ts +9 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/custom-pose-data.interface.ts +14 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/custom-pose-repository.interface.ts +48 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/custom-pose-response.interface.ts +14 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/index.ts +52 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/liveness-action-result.interface.ts +13 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/liveness-config.interface.ts +17 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/liveness-metadata.interface.ts +17 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/liveness-result.interface.ts +24 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/liveness-verification-config.interface.ts +41 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/multi-backend-analyze-response.interface.ts +21 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/multi-backend-liveness-result.interface.ts +14 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/pose-definition.interface.ts +35 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/pose-keypoint.interface.ts +12 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/pose-match-result.interface.ts +9 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/pose-verify-response.interface.ts +8 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/scan-results.interface.ts +29 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/verification-plan.interface.ts +42 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/verification-progress-event.interface.ts +12 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/verification-session.interface.ts +72 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/verification-step-change-event.interface.ts +11 -0
- package/projects/facecog-liveness-verification/src/lib/interfaces/video-recording.interface.ts +9 -0
- package/projects/facecog-liveness-verification/src/lib/liveness-verification.module.ts +123 -0
- package/projects/facecog-liveness-verification/src/lib/models/constants/aws-face-liveness-component.token.ts +23 -0
- package/projects/facecog-liveness-verification/src/lib/models/constants/category-info.constant.ts +14 -0
- package/projects/facecog-liveness-verification/src/lib/models/constants/default-liveness-config.constant.ts +18 -0
- package/projects/facecog-liveness-verification/src/lib/models/constants/index.ts +5 -0
- package/projects/facecog-liveness-verification/src/lib/models/constants/liveness-verification-config.token.ts +16 -0
- package/projects/facecog-liveness-verification/src/lib/models/constants/pose-definitions.constant.ts +377 -0
- package/projects/facecog-liveness-verification/src/lib/models/index.ts +5 -0
- package/projects/facecog-liveness-verification/src/lib/models/utils/index.ts +2 -0
- package/projects/facecog-liveness-verification/src/lib/models/utils/pose.utils.spec.ts +76 -0
- package/projects/facecog-liveness-verification/src/lib/models/utils/pose.utils.ts +59 -0
- package/projects/facecog-liveness-verification/src/lib/services/aws-face-liveness.service.ts +49 -0
- package/projects/facecog-liveness-verification/src/lib/services/backend-http.service.spec.ts +111 -0
- package/projects/facecog-liveness-verification/src/lib/services/backend-http.service.ts +130 -0
- package/projects/facecog-liveness-verification/src/lib/services/backends/azure-backend.service.spec.ts +69 -0
- package/projects/facecog-liveness-verification/src/lib/services/backends/azure-backend.service.ts +72 -0
- package/projects/facecog-liveness-verification/src/lib/services/backends/facetec-backend.service.spec.ts +24 -0
- package/projects/facecog-liveness-verification/src/lib/services/backends/facetec-backend.service.ts +35 -0
- package/projects/facecog-liveness-verification/src/lib/services/backends/mock-backend.service.spec.ts +36 -0
- package/projects/facecog-liveness-verification/src/lib/services/backends/mock-backend.service.ts +39 -0
- package/projects/facecog-liveness-verification/src/lib/services/backends/openpose-backend.service.spec.ts +81 -0
- package/projects/facecog-liveness-verification/src/lib/services/backends/openpose-backend.service.ts +72 -0
- package/projects/facecog-liveness-verification/src/lib/services/backends/rekognition-analysis-backend.service.spec.ts +69 -0
- package/projects/facecog-liveness-verification/src/lib/services/backends/rekognition-analysis-backend.service.ts +83 -0
- package/projects/facecog-liveness-verification/src/lib/services/camera.service.spec.ts +200 -0
- package/projects/facecog-liveness-verification/src/lib/services/camera.service.ts +155 -0
- package/projects/facecog-liveness-verification/src/lib/services/custom-poses-api.service.ts +117 -0
- package/projects/facecog-liveness-verification/src/lib/services/index.ts +18 -0
- package/projects/facecog-liveness-verification/src/lib/services/liveness-backend.service.spec.ts +103 -0
- package/projects/facecog-liveness-verification/src/lib/services/liveness-backend.service.ts +61 -0
- package/projects/facecog-liveness-verification/src/lib/services/liveness-config.service.spec.ts +109 -0
- package/projects/facecog-liveness-verification/src/lib/services/liveness-config.service.ts +70 -0
- package/projects/facecog-liveness-verification/src/lib/services/liveness-orchestrator.service.spec.ts +144 -0
- package/projects/facecog-liveness-verification/src/lib/services/liveness-orchestrator.service.ts +162 -0
- package/projects/facecog-liveness-verification/src/lib/services/pose-detection/hand-gesture-detection.service.ts +315 -0
- package/projects/facecog-liveness-verification/src/lib/services/pose-detection/index.ts +5 -0
- package/projects/facecog-liveness-verification/src/lib/services/pose-detection/openpose.service.ts +287 -0
- package/projects/facecog-liveness-verification/src/lib/services/pose-detection/pose-comparison.service.ts +353 -0
- package/projects/facecog-liveness-verification/src/lib/services/pose-detection/pose-matching.service.ts +2370 -0
- package/projects/facecog-liveness-verification/src/lib/services/pose-detection/reference-pose.service.ts +271 -0
- package/projects/facecog-liveness-verification/src/lib/services/pose-selection.service.spec.ts +183 -0
- package/projects/facecog-liveness-verification/src/lib/services/pose-selection.service.ts +179 -0
- package/projects/facecog-liveness-verification/src/lib/services/verification-api.service.spec.ts +159 -0
- package/projects/facecog-liveness-verification/src/lib/services/verification-api.service.ts +151 -0
- package/projects/facecog-liveness-verification/src/lib/services/verification-plan.service.spec.ts +184 -0
- package/projects/facecog-liveness-verification/src/lib/services/verification-plan.service.ts +94 -0
- package/projects/facecog-liveness-verification/src/lib/services/video-recorder.service.spec.ts +52 -0
- package/projects/facecog-liveness-verification/src/lib/services/video-recorder.service.ts +117 -0
- package/projects/facecog-liveness-verification/src/lib/types/detection-strategy.type.ts +5 -0
- package/projects/facecog-liveness-verification/src/lib/types/index.ts +7 -0
- package/projects/facecog-liveness-verification/src/lib/types/liveness-action.type.ts +31 -0
- package/projects/facecog-liveness-verification/src/lib/types/liveness-backend.type.ts +5 -0
- package/projects/facecog-liveness-verification/src/lib/types/pose-category.type.ts +5 -0
- package/projects/facecog-liveness-verification/src/lib/types/pose-difficulty.type.ts +5 -0
- package/projects/facecog-liveness-verification/src/lib/types/verification-flow-step.type.ts +5 -0
- package/projects/facecog-liveness-verification/src/lib/types/verification-step-kind.type.ts +4 -0
- package/projects/facecog-liveness-verification/src/public-api.ts +150 -0
- package/projects/facecog-liveness-verification/tsconfig.lib.json +20 -0
- package/projects/facecog-liveness-verification/tsconfig.lib.prod.json +11 -0
- package/projects/facecog-liveness-verification/tsconfig.spec.json +13 -0
- package/projects/facecog-liveness-verification/tsconfig.wrapper.json +15 -0
- package/projects/facecog-liveness-verification-test/src/app/app-routing.module.ts +22 -0
- package/projects/facecog-liveness-verification-test/src/app/app.component.html +3 -0
- package/projects/facecog-liveness-verification-test/src/app/app.component.scss +0 -0
- package/projects/facecog-liveness-verification-test/src/app/app.component.ts +11 -0
- package/projects/facecog-liveness-verification-test/src/app/app.module.ts +27 -0
- package/projects/facecog-liveness-verification-test/src/app/home/home-routing.module.ts +16 -0
- package/projects/facecog-liveness-verification-test/src/app/home/home.module.ts +19 -0
- package/projects/facecog-liveness-verification-test/src/app/home/home.page.html +39 -0
- package/projects/facecog-liveness-verification-test/src/app/home/home.page.scss +97 -0
- package/projects/facecog-liveness-verification-test/src/app/home/home.page.spec.ts +24 -0
- package/projects/facecog-liveness-verification-test/src/app/home/home.page.ts +92 -0
- package/projects/facecog-liveness-verification-test/src/app/home/verification-modal.component.ts +106 -0
- package/projects/facecog-liveness-verification-test/src/assets/fonts/gilroy/Gilroy-Bold_0.ttf +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/fonts/gilroy/Gilroy-Medium_0.ttf +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/fonts/gilroy/Gilroy-Regular_0.ttf +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/fonts/gilroy/Gilroy-SemiBold_0.ttf +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/fonts/gilroy/Gilroy-Thin_0.ttf +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/icon/favicon.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/Five_Fingers_Left.jpg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/Left_Palm.jpg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/Ok_Sign_Right.jpg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/Peace_Sign_Left.jpg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/README.md +77 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/Right_Palm.jpg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/Speak_Phrase.jpg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/Three_Fingers_Right.jpg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/Thumbs_Up_Left.jpg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/Thumbs_Up_Right.jpg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/Wave_Right.jpg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/blink.jpeg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/blink_twice.jpeg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/center_face.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/clap.jpeg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/cover_mouth.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/cover_right_eye.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/cross_arms.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/face_straight.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/follow_dot.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/look_down.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/look_up.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/move_closer.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/nod.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/open_mouth.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/raise_eyebrow.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/rotate_face.jpeg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/shake_head.jpeg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/smile.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/tilt_left.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/tilt_right.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/touch_chin_left.jpg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/touch_left_cheek.jpeg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/touch_nose_right.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/touch_right_cheek.jpeg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/turn_left.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/turn_right.png +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/poses/wink.jpeg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/images/reference-pose.jpg +0 -0
- package/projects/facecog-liveness-verification-test/src/assets/shapes.svg +1 -0
- package/projects/facecog-liveness-verification-test/src/environments/environment.prod.ts +4 -0
- package/projects/facecog-liveness-verification-test/src/environments/environment.ts +17 -0
- package/projects/facecog-liveness-verification-test/src/global.scss +288 -0
- package/projects/facecog-liveness-verification-test/src/index.html +31 -0
- package/projects/facecog-liveness-verification-test/src/main.ts +6 -0
- package/projects/facecog-liveness-verification-test/src/polyfills.ts +55 -0
- package/projects/facecog-liveness-verification-test/src/theme/nextsapien-theme.scss +174 -0
- package/projects/facecog-liveness-verification-test/src/theme/variables.scss +2 -0
- package/projects/facecog-liveness-verification-test/src/zone-flags.ts +6 -0
- package/projects/facecog-liveness-verification-test/tsconfig.app.json +15 -0
- package/projects/facecog-liveness-verification-test/tsconfig.spec.json +14 -0
- package/setup-jest.ts +118 -0
- package/tsconfig.json +41 -0
- package/tsconfig.spec.json +15 -0
- package/vercel.json +24 -0
|
@@ -0,0 +1,2370 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { PoseMatchResult } from '../../interfaces/pose-match-result.interface';
|
|
3
|
+
import { LivenessAction } from '../../types/liveness-action.type';
|
|
4
|
+
import { LivenessActionResult } from '../../interfaces/liveness-action-result.interface';
|
|
5
|
+
import { FaceLandmarker, FilesetResolver, FaceLandmarkerResult } from '@mediapipe/tasks-vision';
|
|
6
|
+
import { OpenposeService } from './openpose.service';
|
|
7
|
+
import { PoseComparisonService } from './pose-comparison.service';
|
|
8
|
+
import { ReferencePoseService, ReferencePose } from './reference-pose.service';
|
|
9
|
+
import { HandGestureDetectionService } from './hand-gesture-detection.service';
|
|
10
|
+
|
|
11
|
+
@Injectable({
|
|
12
|
+
providedIn: 'root'
|
|
13
|
+
})
|
|
14
|
+
export class PoseMatchingService {
|
|
15
|
+
private canvas: HTMLCanvasElement;
|
|
16
|
+
private ctx: CanvasRenderingContext2D;
|
|
17
|
+
private faceLandmarker: FaceLandmarker | null = null;
|
|
18
|
+
private modelsLoaded = false;
|
|
19
|
+
|
|
20
|
+
// Blink detection state
|
|
21
|
+
private maxEAR: number = 0;
|
|
22
|
+
private minEARDuringBlink: number = Infinity;
|
|
23
|
+
private blinkDetected: boolean = false;
|
|
24
|
+
private frameCount: number = 0;
|
|
25
|
+
private blinkCount: number = 0;
|
|
26
|
+
private lastBlinkTime: number = 0;
|
|
27
|
+
|
|
28
|
+
// Nod detection state
|
|
29
|
+
private initialPitch: number | null = null;
|
|
30
|
+
private nodDirection: 'up' | 'down' | null = null;
|
|
31
|
+
|
|
32
|
+
// Shake head detection state
|
|
33
|
+
private initialYaw: number | null = null;
|
|
34
|
+
private shakeDirection: 'left' | 'right' | null = null;
|
|
35
|
+
private shakeCount: number = 0;
|
|
36
|
+
|
|
37
|
+
// Smile detection state
|
|
38
|
+
private smileDetected: boolean = false;
|
|
39
|
+
|
|
40
|
+
// Open mouth detection state
|
|
41
|
+
private openMouthDetected: boolean = false;
|
|
42
|
+
|
|
43
|
+
// Raise eyebrows detection state
|
|
44
|
+
private eyebrowsRaisedDetected: boolean = false;
|
|
45
|
+
|
|
46
|
+
// Wink detection state
|
|
47
|
+
private winkDetected: boolean = false;
|
|
48
|
+
|
|
49
|
+
// Follow dot detection state
|
|
50
|
+
private followDotMovements: number = 0;
|
|
51
|
+
private lastGazeDirection: string | null = null;
|
|
52
|
+
|
|
53
|
+
// Speak phrase detection state
|
|
54
|
+
private mouthMovementCount: number = 0;
|
|
55
|
+
private lastMouthOpenness: number = 0;
|
|
56
|
+
|
|
57
|
+
// Smile + Thumbs Up detection state
|
|
58
|
+
private smileThumbsUpDetected: boolean = false;
|
|
59
|
+
private thumbsUpDetected: boolean = false;
|
|
60
|
+
|
|
61
|
+
// Custom pose matching state
|
|
62
|
+
private customPoseDetected: boolean = false;
|
|
63
|
+
|
|
64
|
+
// Hand gesture detection state
|
|
65
|
+
private handGestureDetected: boolean = false;
|
|
66
|
+
private expectedGesture: string | null = null;
|
|
67
|
+
private expectedHandedness: 'Left' | 'Right' | 'Any' = 'Any';
|
|
68
|
+
|
|
69
|
+
// Movement gesture detection state
|
|
70
|
+
private waveDetected: boolean = false;
|
|
71
|
+
private waveDirection: 'left' | 'right' | null = null;
|
|
72
|
+
private previousWristX: number | null = null;
|
|
73
|
+
private waveCount: number = 0;
|
|
74
|
+
|
|
75
|
+
private clapDetected: boolean = false;
|
|
76
|
+
private handsApart: boolean = false;
|
|
77
|
+
|
|
78
|
+
private crossArmsDetected: boolean = false;
|
|
79
|
+
|
|
80
|
+
private pointDetected: boolean = false;
|
|
81
|
+
private pointedLeft: boolean = false;
|
|
82
|
+
private pointedRight: boolean = false;
|
|
83
|
+
|
|
84
|
+
// Combined pose detection state
|
|
85
|
+
private combinedPoseDetected: boolean = false;
|
|
86
|
+
private combinedPoseType: string | null = null;
|
|
87
|
+
|
|
88
|
+
// Rotate face detection state
|
|
89
|
+
private rotationSequence: string[] = []; // Track sequence: center -> left -> center -> right -> center
|
|
90
|
+
private lastRotationYaw: number = 0;
|
|
91
|
+
private rotationStage: 'center' | 'left' | 'right' | 'complete' = 'center';
|
|
92
|
+
private rotateFaceDetected: boolean = false;
|
|
93
|
+
|
|
94
|
+
constructor(
|
|
95
|
+
private openposeService: OpenposeService,
|
|
96
|
+
private poseComparisonService: PoseComparisonService,
|
|
97
|
+
private referencePoseService: ReferencePoseService,
|
|
98
|
+
private handGestureService: HandGestureDetectionService
|
|
99
|
+
) {
|
|
100
|
+
this.canvas = document.createElement('canvas');
|
|
101
|
+
this.ctx = this.canvas.getContext('2d')!;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Load MediaPipe Face Landmarker models
|
|
106
|
+
*/
|
|
107
|
+
async loadModels(): Promise<void> {
|
|
108
|
+
if (this.modelsLoaded) return;
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
console.log('[MediaPipe] Initializing Face Landmarker...');
|
|
112
|
+
|
|
113
|
+
const vision = await FilesetResolver.forVisionTasks(
|
|
114
|
+
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm'
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Try GPU first, fallback to CPU for Safari compatibility
|
|
118
|
+
try {
|
|
119
|
+
this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
|
|
120
|
+
baseOptions: {
|
|
121
|
+
modelAssetPath: 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task',
|
|
122
|
+
delegate: 'GPU'
|
|
123
|
+
},
|
|
124
|
+
runningMode: 'VIDEO',
|
|
125
|
+
numFaces: 1,
|
|
126
|
+
outputFaceBlendshapes: true,
|
|
127
|
+
outputFacialTransformationMatrixes: true
|
|
128
|
+
});
|
|
129
|
+
console.log('[MediaPipe] Face Landmarker loaded with GPU delegate');
|
|
130
|
+
} catch (gpuError) {
|
|
131
|
+
console.warn('[MediaPipe] GPU delegate failed, trying CPU for Safari compatibility:', gpuError);
|
|
132
|
+
this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
|
|
133
|
+
baseOptions: {
|
|
134
|
+
modelAssetPath: 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task',
|
|
135
|
+
delegate: 'CPU'
|
|
136
|
+
},
|
|
137
|
+
runningMode: 'VIDEO',
|
|
138
|
+
numFaces: 1,
|
|
139
|
+
outputFaceBlendshapes: true,
|
|
140
|
+
outputFacialTransformationMatrixes: true
|
|
141
|
+
});
|
|
142
|
+
console.log('[MediaPipe] Face Landmarker loaded with CPU delegate (Safari fallback)');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.modelsLoaded = true;
|
|
146
|
+
console.log('[MediaPipe] Face Landmarker loaded successfully');
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.error('[MediaPipe] Failed to load models:', error);
|
|
149
|
+
throw new Error('Failed to load face detection models');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Load and process the reference image (not needed for MediaPipe blendshapes approach)
|
|
155
|
+
* Kept for API compatibility but not used
|
|
156
|
+
*/
|
|
157
|
+
async loadReferenceImage(imageUrl: string): Promise<void> {
|
|
158
|
+
console.log('[MediaPipe] Reference image not needed for blendshape-based detection');
|
|
159
|
+
// No-op for MediaPipe - we use blendshapes for smile detection
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Detect face in video frame using MediaPipe
|
|
164
|
+
*/
|
|
165
|
+
async detectFace(videoElement: HTMLVideoElement): Promise<FaceLandmarkerResult | null> {
|
|
166
|
+
try {
|
|
167
|
+
if (!this.modelsLoaded) {
|
|
168
|
+
console.warn('[MediaPipe] Models not loaded, attempting to load...');
|
|
169
|
+
await this.loadModels();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!this.faceLandmarker) {
|
|
173
|
+
console.error('[MediaPipe] Face Landmarker not initialized');
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Safari debugging: check video element state
|
|
178
|
+
console.log(`[MediaPipe] Video ready: ${videoElement.readyState}, dimensions: ${videoElement.videoWidth}x${videoElement.videoHeight}`);
|
|
179
|
+
|
|
180
|
+
if (videoElement.readyState < 2) {
|
|
181
|
+
console.warn('[MediaPipe] Video not ready yet (readyState < 2)');
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (videoElement.videoWidth === 0 || videoElement.videoHeight === 0) {
|
|
186
|
+
console.warn('[MediaPipe] Video has zero dimensions');
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const timestamp = performance.now();
|
|
191
|
+
const result = this.faceLandmarker.detectForVideo(videoElement, timestamp);
|
|
192
|
+
|
|
193
|
+
console.log(`[MediaPipe] Detection result: ${result ? 'success' : 'null'}, faces: ${result?.faceLandmarks?.length || 0}`);
|
|
194
|
+
|
|
195
|
+
return result && result.faceLandmarks && result.faceLandmarks.length > 0 ? result : null;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
console.error('[MediaPipe] Face detection error:', error);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Compare live video frame with reference image (basic compatibility)
|
|
204
|
+
* For smile detection, this is replaced by detectAction('smile')
|
|
205
|
+
*/
|
|
206
|
+
async matchPose(
|
|
207
|
+
videoElement: HTMLVideoElement,
|
|
208
|
+
exampleImage?: HTMLImageElement,
|
|
209
|
+
threshold: number = 0.8
|
|
210
|
+
): Promise<PoseMatchResult> {
|
|
211
|
+
try {
|
|
212
|
+
const result = await this.detectFace(videoElement);
|
|
213
|
+
|
|
214
|
+
if (!result) {
|
|
215
|
+
return {
|
|
216
|
+
matched: false,
|
|
217
|
+
similarity: 0,
|
|
218
|
+
message: 'No face detected. Please position your face in the oval.'
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Use face centering and size for basic matching
|
|
223
|
+
const faceLandmarks = result.faceLandmarks[0];
|
|
224
|
+
const centerScore = this.calculateCenterScoreFromLandmarks(videoElement, faceLandmarks);
|
|
225
|
+
const sizeScore = this.calculateSizeScoreFromLandmarks(videoElement, faceLandmarks);
|
|
226
|
+
|
|
227
|
+
const similarity = (centerScore + sizeScore) / 2;
|
|
228
|
+
const matched = similarity >= 0.65;
|
|
229
|
+
|
|
230
|
+
console.log(`[MediaPipe Pose Match] Center: ${(centerScore * 100).toFixed(1)}%, Size: ${(sizeScore * 100).toFixed(1)}%, Overall: ${(similarity * 100).toFixed(1)}%`);
|
|
231
|
+
|
|
232
|
+
let message: string;
|
|
233
|
+
if (matched) {
|
|
234
|
+
message = 'Perfect! Hold steady...';
|
|
235
|
+
} else if (centerScore < 0.6) {
|
|
236
|
+
message = 'Center your face in the oval';
|
|
237
|
+
} else {
|
|
238
|
+
message = `${Math.round(similarity * 100)}% - Almost there!`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
matched,
|
|
243
|
+
similarity,
|
|
244
|
+
message
|
|
245
|
+
};
|
|
246
|
+
} catch (error) {
|
|
247
|
+
console.error('[MediaPipe] Pose matching error:', error);
|
|
248
|
+
return {
|
|
249
|
+
matched: false,
|
|
250
|
+
similarity: 0,
|
|
251
|
+
message: 'Unable to detect face. Please ensure good lighting.'
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Calculate how centered the face is using MediaPipe landmarks
|
|
258
|
+
*/
|
|
259
|
+
private calculateCenterScoreFromLandmarks(
|
|
260
|
+
videoElement: HTMLVideoElement,
|
|
261
|
+
landmarks: any[]
|
|
262
|
+
): number {
|
|
263
|
+
// Use nose tip (landmark 1) as face center
|
|
264
|
+
const noseTip = landmarks[1];
|
|
265
|
+
|
|
266
|
+
const videoCenterX = 0.5;
|
|
267
|
+
const videoCenterY = 0.5;
|
|
268
|
+
|
|
269
|
+
const dx = Math.abs(videoCenterX - noseTip.x);
|
|
270
|
+
const dy = Math.abs(videoCenterY - noseTip.y);
|
|
271
|
+
|
|
272
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
273
|
+
const maxDistance = Math.sqrt(0.5 * 0.5 + 0.5 * 0.5);
|
|
274
|
+
|
|
275
|
+
return Math.max(0, 1 - (distance / maxDistance));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Calculate face size score using MediaPipe landmarks
|
|
280
|
+
*/
|
|
281
|
+
private calculateSizeScoreFromLandmarks(
|
|
282
|
+
videoElement: HTMLVideoElement,
|
|
283
|
+
landmarks: any[]
|
|
284
|
+
): number {
|
|
285
|
+
// Calculate face width using jaw landmarks
|
|
286
|
+
const leftJaw = landmarks[234]; // Left jaw
|
|
287
|
+
const rightJaw = landmarks[454]; // Right jaw
|
|
288
|
+
|
|
289
|
+
const faceWidth = Math.abs(rightJaw.x - leftJaw.x);
|
|
290
|
+
const idealWidth = 0.35; // 35% of frame width
|
|
291
|
+
|
|
292
|
+
const ratio = faceWidth / idealWidth;
|
|
293
|
+
|
|
294
|
+
if (ratio < 0.5 || ratio > 1.5) {
|
|
295
|
+
return Math.max(0, 1 - Math.abs(1 - ratio) * 0.5);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return 1;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Capture a frame from the video element
|
|
303
|
+
*/
|
|
304
|
+
captureFrame(videoElement: HTMLVideoElement): ImageData {
|
|
305
|
+
this.canvas.width = videoElement.videoWidth;
|
|
306
|
+
this.canvas.height = videoElement.videoHeight;
|
|
307
|
+
this.ctx.drawImage(videoElement, 0, 0);
|
|
308
|
+
return this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Convert frame to base64 JPEG
|
|
313
|
+
* Flips the image horizontally to show normal (non-mirrored) view
|
|
314
|
+
*/
|
|
315
|
+
frameToBase64(videoElement: HTMLVideoElement, quality: number = 0.8): string {
|
|
316
|
+
this.canvas.width = videoElement.videoWidth;
|
|
317
|
+
this.canvas.height = videoElement.videoHeight;
|
|
318
|
+
|
|
319
|
+
// Save context state
|
|
320
|
+
this.ctx.save();
|
|
321
|
+
|
|
322
|
+
// Flip horizontally to convert from mirror view to normal view
|
|
323
|
+
this.ctx.translate(this.canvas.width, 0);
|
|
324
|
+
this.ctx.scale(-1, 1);
|
|
325
|
+
|
|
326
|
+
// Draw the flipped image
|
|
327
|
+
this.ctx.drawImage(videoElement, 0, 0);
|
|
328
|
+
|
|
329
|
+
// Restore context state
|
|
330
|
+
this.ctx.restore();
|
|
331
|
+
|
|
332
|
+
return this.canvas.toDataURL('image/jpeg', quality);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Detect liveness action in video frame using MediaPipe
|
|
337
|
+
*/
|
|
338
|
+
async detectAction(
|
|
339
|
+
videoElement: HTMLVideoElement,
|
|
340
|
+
action: LivenessAction
|
|
341
|
+
): Promise<LivenessActionResult> {
|
|
342
|
+
try {
|
|
343
|
+
const result = await this.detectFace(videoElement);
|
|
344
|
+
|
|
345
|
+
if (!result || !result.faceLandmarks || result.faceLandmarks.length === 0) {
|
|
346
|
+
return {
|
|
347
|
+
action,
|
|
348
|
+
completed: false,
|
|
349
|
+
progress: 0,
|
|
350
|
+
message: 'No face detected. Please position your face in the frame.'
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const faceLandmarks = result.faceLandmarks[0];
|
|
355
|
+
const faceBlendshapes = result.faceBlendshapes?.[0]?.categories || [];
|
|
356
|
+
const transformationMatrix = result.facialTransformationMatrixes?.[0];
|
|
357
|
+
|
|
358
|
+
switch (action) {
|
|
359
|
+
case 'face-center':
|
|
360
|
+
return this.detectFaceCentered(videoElement, faceLandmarks);
|
|
361
|
+
|
|
362
|
+
case 'turn-left':
|
|
363
|
+
return this.detectFaceTurn(transformationMatrix, 'left');
|
|
364
|
+
|
|
365
|
+
case 'turn-right':
|
|
366
|
+
return this.detectFaceTurn(transformationMatrix, 'right');
|
|
367
|
+
|
|
368
|
+
case 'tilt-left':
|
|
369
|
+
return this.detectHeadTilt(transformationMatrix, 'left');
|
|
370
|
+
|
|
371
|
+
case 'tilt-right':
|
|
372
|
+
return this.detectHeadTilt(transformationMatrix, 'right');
|
|
373
|
+
|
|
374
|
+
case 'look-up':
|
|
375
|
+
return this.detectEyeGaze(faceBlendshapes, 'up');
|
|
376
|
+
|
|
377
|
+
case 'look-down':
|
|
378
|
+
return this.detectEyeGaze(faceBlendshapes, 'down');
|
|
379
|
+
|
|
380
|
+
case 'blink':
|
|
381
|
+
return this.detectBlink(faceBlendshapes);
|
|
382
|
+
|
|
383
|
+
case 'blink-twice':
|
|
384
|
+
return this.detectBlinkTwice(faceBlendshapes);
|
|
385
|
+
|
|
386
|
+
case 'wink':
|
|
387
|
+
return this.detectWink(faceBlendshapes);
|
|
388
|
+
|
|
389
|
+
case 'follow-dot':
|
|
390
|
+
return this.detectFollowDot(faceBlendshapes, transformationMatrix);
|
|
391
|
+
|
|
392
|
+
case 'nod':
|
|
393
|
+
return this.detectNod(transformationMatrix);
|
|
394
|
+
|
|
395
|
+
case 'shake-head':
|
|
396
|
+
return this.detectShakeHead(transformationMatrix);
|
|
397
|
+
|
|
398
|
+
case 'smile':
|
|
399
|
+
return this.detectSmile(faceBlendshapes);
|
|
400
|
+
|
|
401
|
+
case 'open-mouth':
|
|
402
|
+
return this.detectOpenMouth(faceBlendshapes);
|
|
403
|
+
|
|
404
|
+
case 'raise-eyebrows':
|
|
405
|
+
return this.detectRaiseEyebrows(faceBlendshapes);
|
|
406
|
+
|
|
407
|
+
case 'rotate-face':
|
|
408
|
+
return this.detectRotateFace(transformationMatrix);
|
|
409
|
+
|
|
410
|
+
case 'speak-phrase':
|
|
411
|
+
return this.detectSpeakPhrase(faceBlendshapes);
|
|
412
|
+
|
|
413
|
+
case 'smile-thumbs-up':
|
|
414
|
+
return this.detectSmileThumbsUp(videoElement, faceBlendshapes);
|
|
415
|
+
|
|
416
|
+
case 'hand-gesture':
|
|
417
|
+
return this.detectHandGesture(videoElement);
|
|
418
|
+
|
|
419
|
+
case 'wave':
|
|
420
|
+
return this.detectWave(videoElement);
|
|
421
|
+
|
|
422
|
+
case 'clap':
|
|
423
|
+
return this.detectClap(videoElement);
|
|
424
|
+
|
|
425
|
+
case 'cross-arms':
|
|
426
|
+
return this.detectCrossArms(videoElement);
|
|
427
|
+
|
|
428
|
+
case 'point':
|
|
429
|
+
return this.detectPoint(videoElement);
|
|
430
|
+
|
|
431
|
+
case 'combined-pose':
|
|
432
|
+
return this.detectCombinedPose(videoElement);
|
|
433
|
+
|
|
434
|
+
case 'custom-pose':
|
|
435
|
+
return this.detectCustomPose(videoElement);
|
|
436
|
+
|
|
437
|
+
default:
|
|
438
|
+
return {
|
|
439
|
+
action,
|
|
440
|
+
completed: false,
|
|
441
|
+
progress: 0,
|
|
442
|
+
message: 'Unknown action'
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
} catch (error) {
|
|
446
|
+
console.error('[MediaPipe] Action detection error:', error);
|
|
447
|
+
return {
|
|
448
|
+
action,
|
|
449
|
+
completed: false,
|
|
450
|
+
progress: 0,
|
|
451
|
+
message: 'Detection error. Please ensure good lighting.'
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Detect if face is centered using MediaPipe landmarks
|
|
458
|
+
*/
|
|
459
|
+
private detectFaceCentered(
|
|
460
|
+
videoElement: HTMLVideoElement,
|
|
461
|
+
faceLandmarks: any[]
|
|
462
|
+
): LivenessActionResult {
|
|
463
|
+
const centerScore = this.calculateCenterScoreFromLandmarks(videoElement, faceLandmarks);
|
|
464
|
+
const sizeScore = this.calculateSizeScoreFromLandmarks(videoElement, faceLandmarks);
|
|
465
|
+
|
|
466
|
+
const progress = (centerScore + sizeScore) / 2;
|
|
467
|
+
const completed = progress >= 0.8;
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
action: 'face-center',
|
|
471
|
+
completed,
|
|
472
|
+
progress,
|
|
473
|
+
message: completed ? 'Face centered!' :
|
|
474
|
+
centerScore < 0.7 ? 'Center your face in the oval' :
|
|
475
|
+
'Move a bit closer'
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Detect face turn using head rotation matrix (yaw angle)
|
|
481
|
+
*/
|
|
482
|
+
private detectFaceTurn(
|
|
483
|
+
transformationMatrix: any,
|
|
484
|
+
direction: 'left' | 'right'
|
|
485
|
+
): LivenessActionResult {
|
|
486
|
+
if (!transformationMatrix) {
|
|
487
|
+
return {
|
|
488
|
+
action: direction === 'left' ? 'turn-left' : 'turn-right',
|
|
489
|
+
completed: false,
|
|
490
|
+
progress: 0,
|
|
491
|
+
message: 'Unable to detect head rotation'
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Extract yaw angle from transformation matrix
|
|
496
|
+
// The matrix is column-major format
|
|
497
|
+
const yaw = this.calculateYawAngle(transformationMatrix.data);
|
|
498
|
+
|
|
499
|
+
// Yaw angle in radians (positive = turn right in camera view, negative = turn left)
|
|
500
|
+
// Convert to degrees for easier thresholds
|
|
501
|
+
const yawDegrees = (yaw * 180) / Math.PI;
|
|
502
|
+
|
|
503
|
+
// Because camera is mirrored, we need to swap the yaw interpretation:
|
|
504
|
+
// When user turns physically LEFT → yaw is NEGATIVE in MediaPipe's coordinate system
|
|
505
|
+
// When user turns physically RIGHT → yaw is POSITIVE in MediaPipe's coordinate system
|
|
506
|
+
let progress = 0;
|
|
507
|
+
let completed = false;
|
|
508
|
+
|
|
509
|
+
const turnThreshold = 25; // degrees
|
|
510
|
+
|
|
511
|
+
if (direction === 'left') {
|
|
512
|
+
// User turns physically left → negative yaw
|
|
513
|
+
progress = Math.max(0, Math.min(1, -yawDegrees / turnThreshold));
|
|
514
|
+
completed = yawDegrees < -turnThreshold;
|
|
515
|
+
console.log(`[Turn Left] Yaw: ${yawDegrees.toFixed(1)}°, Progress: ${(progress * 100).toFixed(1)}%, Completed: ${completed}`);
|
|
516
|
+
} else {
|
|
517
|
+
// User turns physically right → positive yaw
|
|
518
|
+
progress = Math.max(0, Math.min(1, yawDegrees / turnThreshold));
|
|
519
|
+
completed = yawDegrees > turnThreshold;
|
|
520
|
+
console.log(`[Turn Right] Yaw: ${yawDegrees.toFixed(1)}°, Progress: ${(progress * 100).toFixed(1)}%, Completed: ${completed}`);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
action: direction === 'left' ? 'turn-left' : 'turn-right',
|
|
525
|
+
completed,
|
|
526
|
+
progress,
|
|
527
|
+
message: completed ? `Face turned ${direction}!` : `Turn your face ${direction}`
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Calculate yaw angle from transformation matrix
|
|
533
|
+
*/
|
|
534
|
+
private calculateYawAngle(matrix: Float32Array): number {
|
|
535
|
+
// MediaPipe transformation matrix is 4x4 in column-major order
|
|
536
|
+
// Extract rotation components
|
|
537
|
+
const r11 = matrix[0];
|
|
538
|
+
const r21 = matrix[1];
|
|
539
|
+
const r31 = matrix[2];
|
|
540
|
+
|
|
541
|
+
// Calculate yaw (rotation around Y-axis)
|
|
542
|
+
const yaw = Math.atan2(r31, r11);
|
|
543
|
+
return yaw;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Calculate pitch angle from transformation matrix
|
|
548
|
+
*/
|
|
549
|
+
private calculatePitchAngle(matrix: Float32Array): number {
|
|
550
|
+
// Extract pitch (rotation around X-axis)
|
|
551
|
+
const r32 = matrix[6];
|
|
552
|
+
const r33 = matrix[10];
|
|
553
|
+
|
|
554
|
+
const pitch = Math.atan2(-matrix[9], Math.sqrt(r32 * r32 + r33 * r33));
|
|
555
|
+
return pitch;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Calculate roll angle from transformation matrix (head tilt left/right)
|
|
560
|
+
*/
|
|
561
|
+
private calculateRollAngle(matrix: Float32Array): number {
|
|
562
|
+
// Extract roll (rotation around Z-axis) - head tilt
|
|
563
|
+
// For roll, we use R21 and R22
|
|
564
|
+
const r21 = matrix[4]; // matrix[1*4 + 0]
|
|
565
|
+
const r22 = matrix[5]; // matrix[1*4 + 1]
|
|
566
|
+
|
|
567
|
+
const roll = Math.atan2(r21, r22);
|
|
568
|
+
return roll;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Detect head tilt (roll) left or right
|
|
573
|
+
*/
|
|
574
|
+
private detectHeadTilt(transformationMatrix: any, direction: 'left' | 'right'): LivenessActionResult {
|
|
575
|
+
if (!transformationMatrix || !transformationMatrix.data) {
|
|
576
|
+
return {
|
|
577
|
+
action: direction === 'left' ? 'tilt-left' : 'tilt-right',
|
|
578
|
+
completed: false,
|
|
579
|
+
progress: 0,
|
|
580
|
+
message: 'Face not detected'
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Extract roll angle from transformation matrix
|
|
585
|
+
const roll = this.calculateRollAngle(transformationMatrix.data);
|
|
586
|
+
const rollDegrees = (roll * 180) / Math.PI;
|
|
587
|
+
|
|
588
|
+
// MediaPipe roll angle:
|
|
589
|
+
// Positive roll = head tilts RIGHT in MediaPipe's coordinate system
|
|
590
|
+
// Negative roll = head tilts LEFT in MediaPipe's coordinate system
|
|
591
|
+
//
|
|
592
|
+
// BUT: Because camera is MIRRORED, we need to SWAP the logic:
|
|
593
|
+
// When user tilts physically LEFT → positive roll (appears right in mirrored view)
|
|
594
|
+
// When user tilts physically RIGHT → negative roll (appears left in mirrored view)
|
|
595
|
+
|
|
596
|
+
let progress = 0;
|
|
597
|
+
let completed = false;
|
|
598
|
+
const tiltThreshold = 20; // degrees
|
|
599
|
+
|
|
600
|
+
if (direction === 'left') {
|
|
601
|
+
// User tilts head physically left → POSITIVE roll (due to mirrored camera)
|
|
602
|
+
progress = Math.max(0, Math.min(1, rollDegrees / tiltThreshold));
|
|
603
|
+
completed = rollDegrees > tiltThreshold;
|
|
604
|
+
console.log(`[Tilt Left] Roll: ${rollDegrees.toFixed(1)}°, Progress: ${(progress * 100).toFixed(1)}%, Completed: ${completed}`);
|
|
605
|
+
} else {
|
|
606
|
+
// User tilts head physically right → NEGATIVE roll (due to mirrored camera)
|
|
607
|
+
progress = Math.max(0, Math.min(1, -rollDegrees / tiltThreshold));
|
|
608
|
+
completed = rollDegrees < -tiltThreshold;
|
|
609
|
+
console.log(`[Tilt Right] Roll: ${rollDegrees.toFixed(1)}°, Progress: ${(progress * 100).toFixed(1)}%, Completed: ${completed}`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
action: direction === 'left' ? 'tilt-left' : 'tilt-right',
|
|
614
|
+
completed,
|
|
615
|
+
progress,
|
|
616
|
+
message: completed ? `Head tilted ${direction}!` : `Tilt your head ${direction} (ear to shoulder)`
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Detect eye gaze direction (look up/down)
|
|
622
|
+
*/
|
|
623
|
+
private detectEyeGaze(blendshapes: any[], direction: 'up' | 'down'): LivenessActionResult {
|
|
624
|
+
if (direction === 'up') {
|
|
625
|
+
// Get eye look up blendshapes
|
|
626
|
+
const eyeLookUpLeft = blendshapes.find((b: any) => b.categoryName === 'eyeLookUpLeft')?.score || 0;
|
|
627
|
+
const eyeLookUpRight = blendshapes.find((b: any) => b.categoryName === 'eyeLookUpRight')?.score || 0;
|
|
628
|
+
const avgLookUp = (eyeLookUpLeft + eyeLookUpRight) / 2;
|
|
629
|
+
|
|
630
|
+
const isLookingUp = avgLookUp > 0.5;
|
|
631
|
+
|
|
632
|
+
console.log(`[Look Up] LeftEye: ${eyeLookUpLeft.toFixed(3)}, RightEye: ${eyeLookUpRight.toFixed(3)}, Avg: ${avgLookUp.toFixed(3)}, Looking Up: ${isLookingUp}`);
|
|
633
|
+
|
|
634
|
+
const progress = Math.min(1, avgLookUp / 0.5);
|
|
635
|
+
const completed = isLookingUp;
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
action: 'look-up',
|
|
639
|
+
completed,
|
|
640
|
+
progress,
|
|
641
|
+
message: completed ? 'Looking up!' : 'Look up with your eyes (keep head straight)'
|
|
642
|
+
};
|
|
643
|
+
} else {
|
|
644
|
+
// Get eye look down blendshapes
|
|
645
|
+
const eyeLookDownLeft = blendshapes.find((b: any) => b.categoryName === 'eyeLookDownLeft')?.score || 0;
|
|
646
|
+
const eyeLookDownRight = blendshapes.find((b: any) => b.categoryName === 'eyeLookDownRight')?.score || 0;
|
|
647
|
+
const avgLookDown = (eyeLookDownLeft + eyeLookDownRight) / 2;
|
|
648
|
+
|
|
649
|
+
const isLookingDown = avgLookDown > 0.5;
|
|
650
|
+
|
|
651
|
+
console.log(`[Look Down] LeftEye: ${eyeLookDownLeft.toFixed(3)}, RightEye: ${eyeLookDownRight.toFixed(3)}, Avg: ${avgLookDown.toFixed(3)}, Looking Down: ${isLookingDown}`);
|
|
652
|
+
|
|
653
|
+
const progress = Math.min(1, avgLookDown / 0.5);
|
|
654
|
+
const completed = isLookingDown;
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
action: 'look-down',
|
|
658
|
+
completed,
|
|
659
|
+
progress,
|
|
660
|
+
message: completed ? 'Looking down!' : 'Look down with your eyes (keep head straight)'
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Detect eye blink using MediaPipe blendshapes
|
|
667
|
+
*/
|
|
668
|
+
private detectBlink(blendshapes: any[]): LivenessActionResult {
|
|
669
|
+
// Get blink blendshape scores
|
|
670
|
+
const eyeBlinkLeft = blendshapes.find((b: any) => b.categoryName === 'eyeBlinkLeft')?.score || 0;
|
|
671
|
+
const eyeBlinkRight = blendshapes.find((b: any) => b.categoryName === 'eyeBlinkRight')?.score || 0;
|
|
672
|
+
const avgBlink = (eyeBlinkLeft + eyeBlinkRight) / 2;
|
|
673
|
+
|
|
674
|
+
this.frameCount++;
|
|
675
|
+
|
|
676
|
+
// Calibration phase - wait for eyes to be open
|
|
677
|
+
if (this.frameCount <= 10) {
|
|
678
|
+
this.maxEAR = Math.max(this.maxEAR, 1 - avgBlink); // Store max "openness"
|
|
679
|
+
console.log(`[Blink] Calibrating... Blink score: ${avgBlink.toFixed(3)}, Frame: ${this.frameCount}`);
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
action: 'blink',
|
|
683
|
+
completed: false,
|
|
684
|
+
progress: 0,
|
|
685
|
+
message: 'Keep your eyes open'
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Blink detection: score > 0.5 means eyes are closed
|
|
690
|
+
const eyesClosed = avgBlink > 0.5;
|
|
691
|
+
|
|
692
|
+
console.log(`[Blink] Left: ${eyeBlinkLeft.toFixed(3)}, Right: ${eyeBlinkRight.toFixed(3)}, Avg: ${avgBlink.toFixed(3)}, Closed: ${eyesClosed}, BlinkStarted: ${this.blinkDetected}`);
|
|
693
|
+
|
|
694
|
+
// Track blink sequence
|
|
695
|
+
if (eyesClosed && !this.blinkDetected) {
|
|
696
|
+
this.blinkDetected = true;
|
|
697
|
+
console.log('[Blink] ✓ Eyes CLOSED - blink started!');
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Blink complete when eyes reopen
|
|
701
|
+
const completed = this.blinkDetected && !eyesClosed;
|
|
702
|
+
|
|
703
|
+
if (completed) {
|
|
704
|
+
console.log('[Blink] ✓✓ BLINK COMPLETED! Eyes reopened.');
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Calculate progress
|
|
708
|
+
let progress = 0;
|
|
709
|
+
if (!this.blinkDetected) {
|
|
710
|
+
progress = 0.3;
|
|
711
|
+
} else if (!completed) {
|
|
712
|
+
progress = 0.6;
|
|
713
|
+
} else {
|
|
714
|
+
progress = 1.0;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return {
|
|
718
|
+
action: 'blink',
|
|
719
|
+
completed,
|
|
720
|
+
progress,
|
|
721
|
+
message: completed ? 'Blink detected!' :
|
|
722
|
+
this.blinkDetected ? 'Open your eyes again' :
|
|
723
|
+
'Blink your eyes'
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Detect blink twice - count two separate blinks
|
|
729
|
+
*/
|
|
730
|
+
private detectBlinkTwice(blendshapes: any[]): LivenessActionResult {
|
|
731
|
+
// Get blink blendshape scores
|
|
732
|
+
const eyeBlinkLeft = blendshapes.find((b: any) => b.categoryName === 'eyeBlinkLeft')?.score || 0;
|
|
733
|
+
const eyeBlinkRight = blendshapes.find((b: any) => b.categoryName === 'eyeBlinkRight')?.score || 0;
|
|
734
|
+
const avgBlink = (eyeBlinkLeft + eyeBlinkRight) / 2;
|
|
735
|
+
|
|
736
|
+
this.frameCount++;
|
|
737
|
+
const currentTime = Date.now();
|
|
738
|
+
|
|
739
|
+
// Calibration phase - wait for eyes to be open
|
|
740
|
+
if (this.frameCount <= 10) {
|
|
741
|
+
this.maxEAR = Math.max(this.maxEAR, 1 - avgBlink);
|
|
742
|
+
console.log(`[Blink Twice] Calibrating... Blink score: ${avgBlink.toFixed(3)}, Frame: ${this.frameCount}`);
|
|
743
|
+
|
|
744
|
+
return {
|
|
745
|
+
action: 'blink-twice',
|
|
746
|
+
completed: false,
|
|
747
|
+
progress: 0,
|
|
748
|
+
message: 'Keep your eyes open'
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Blink detection: score > 0.5 means eyes are closed
|
|
753
|
+
const eyesClosed = avgBlink > 0.5;
|
|
754
|
+
|
|
755
|
+
console.log(`[Blink Twice] Left: ${eyeBlinkLeft.toFixed(3)}, Right: ${eyeBlinkRight.toFixed(3)}, Avg: ${avgBlink.toFixed(3)}, Closed: ${eyesClosed}, Count: ${this.blinkCount}, BlinkStarted: ${this.blinkDetected}`);
|
|
756
|
+
|
|
757
|
+
// Track blink sequence
|
|
758
|
+
if (eyesClosed && !this.blinkDetected) {
|
|
759
|
+
this.blinkDetected = true;
|
|
760
|
+
console.log('[Blink Twice] ✓ Eyes CLOSED - blink started!');
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Blink complete when eyes reopen
|
|
764
|
+
if (this.blinkDetected && !eyesClosed) {
|
|
765
|
+
// Only count if enough time has passed since last blink (prevent double-counting)
|
|
766
|
+
if (this.blinkCount === 0 || (currentTime - this.lastBlinkTime) > 300) {
|
|
767
|
+
this.blinkCount++;
|
|
768
|
+
this.lastBlinkTime = currentTime;
|
|
769
|
+
console.log(`[Blink Twice] ✓✓ BLINK ${this.blinkCount} COMPLETED! Eyes reopened.`);
|
|
770
|
+
}
|
|
771
|
+
this.blinkDetected = false;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const completed = this.blinkCount >= 2;
|
|
775
|
+
|
|
776
|
+
if (completed) {
|
|
777
|
+
console.log('[Blink Twice] ✓✓✓ TWO BLINKS DETECTED! Challenge complete.');
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Calculate progress
|
|
781
|
+
let progress = 0;
|
|
782
|
+
if (this.blinkCount === 0) {
|
|
783
|
+
progress = this.blinkDetected ? 0.25 : 0.1;
|
|
784
|
+
} else if (this.blinkCount === 1) {
|
|
785
|
+
progress = this.blinkDetected ? 0.65 : 0.5;
|
|
786
|
+
} else {
|
|
787
|
+
progress = 1.0;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
return {
|
|
791
|
+
action: 'blink-twice',
|
|
792
|
+
completed,
|
|
793
|
+
progress,
|
|
794
|
+
message: completed ? 'Two blinks detected!' :
|
|
795
|
+
this.blinkCount === 1 ? (this.blinkDetected ? 'Open your eyes' : 'Blink one more time') :
|
|
796
|
+
this.blinkDetected ? 'Open your eyes again' :
|
|
797
|
+
'Blink twice'
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Detect wink - one eye closed while other stays open
|
|
803
|
+
*/
|
|
804
|
+
private detectWink(blendshapes: any[]): LivenessActionResult {
|
|
805
|
+
const eyeBlinkLeft = blendshapes.find((b: any) => b.categoryName === 'eyeBlinkLeft')?.score || 0;
|
|
806
|
+
const eyeBlinkRight = blendshapes.find((b: any) => b.categoryName === 'eyeBlinkRight')?.score || 0;
|
|
807
|
+
|
|
808
|
+
// Improved thresholds for wink detection
|
|
809
|
+
// Closed eye: score > 0.4 (more lenient - easier to trigger)
|
|
810
|
+
// Open eye: score < 0.5 (more lenient - easier to maintain)
|
|
811
|
+
const leftEyeClosed = eyeBlinkLeft > 0.4;
|
|
812
|
+
const rightEyeClosed = eyeBlinkRight > 0.4;
|
|
813
|
+
const leftEyeOpen = eyeBlinkLeft < 0.5;
|
|
814
|
+
const rightEyeOpen = eyeBlinkRight < 0.5;
|
|
815
|
+
|
|
816
|
+
// Calculate difference to detect asymmetry
|
|
817
|
+
const eyeDifference = Math.abs(eyeBlinkLeft - eyeBlinkRight);
|
|
818
|
+
// Reduced asymmetry threshold from 0.3 to 0.25 for easier detection
|
|
819
|
+
const hasAsymmetry = eyeDifference > 0.25;
|
|
820
|
+
|
|
821
|
+
// Detect wink: one eye closed AND other eye open (not both closed, not both open)
|
|
822
|
+
const isWinking = hasAsymmetry && ((leftEyeClosed && rightEyeOpen) || (rightEyeClosed && leftEyeOpen));
|
|
823
|
+
|
|
824
|
+
console.log(`[Wink] Left: ${eyeBlinkLeft.toFixed(3)}, Right: ${eyeBlinkRight.toFixed(3)}, Diff: ${eyeDifference.toFixed(3)}, Asymmetry: ${hasAsymmetry}, Winking: ${isWinking}, Detected: ${this.winkDetected}`);
|
|
825
|
+
|
|
826
|
+
// Once we detect a wink, mark it as detected
|
|
827
|
+
if (isWinking && !this.winkDetected) {
|
|
828
|
+
this.winkDetected = true;
|
|
829
|
+
console.log('[Wink] ✓ WINK DETECTED! Left closed & right open OR right closed & left open');
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const completed = this.winkDetected;
|
|
833
|
+
|
|
834
|
+
// Show progress based on eye difference to help user
|
|
835
|
+
let progress = 0;
|
|
836
|
+
if (completed) {
|
|
837
|
+
progress = 1.0;
|
|
838
|
+
} else if (isWinking) {
|
|
839
|
+
progress = 0.8; // Almost there
|
|
840
|
+
} else if (hasAsymmetry) {
|
|
841
|
+
progress = 0.5; // Getting closer
|
|
842
|
+
} else if (eyeDifference > 0.15) {
|
|
843
|
+
progress = 0.3; // Some asymmetry detected
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return {
|
|
847
|
+
action: 'wink',
|
|
848
|
+
completed,
|
|
849
|
+
progress,
|
|
850
|
+
message: completed ? 'Wink detected!' :
|
|
851
|
+
isWinking ? 'Hold it...' :
|
|
852
|
+
hasAsymmetry ? 'Close one eye more' :
|
|
853
|
+
'Wink with one eye (close one, keep other open)'
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Detect eye movement following a dot - requires multiple gaze direction changes
|
|
859
|
+
*/
|
|
860
|
+
private detectFollowDot(blendshapes: any[], transformationMatrix: any): LivenessActionResult {
|
|
861
|
+
// Track eye gaze changes using eye look blendshapes
|
|
862
|
+
const eyeLookUpLeft = blendshapes.find((b: any) => b.categoryName === 'eyeLookUpLeft')?.score || 0;
|
|
863
|
+
const eyeLookUpRight = blendshapes.find((b: any) => b.categoryName === 'eyeLookUpRight')?.score || 0;
|
|
864
|
+
const eyeLookDownLeft = blendshapes.find((b: any) => b.categoryName === 'eyeLookDownLeft')?.score || 0;
|
|
865
|
+
const eyeLookDownRight = blendshapes.find((b: any) => b.categoryName === 'eyeLookDownRight')?.score || 0;
|
|
866
|
+
const eyeLookInLeft = blendshapes.find((b: any) => b.categoryName === 'eyeLookInLeft')?.score || 0;
|
|
867
|
+
const eyeLookOutLeft = blendshapes.find((b: any) => b.categoryName === 'eyeLookOutLeft')?.score || 0;
|
|
868
|
+
|
|
869
|
+
const avgLookUp = (eyeLookUpLeft + eyeLookUpRight) / 2;
|
|
870
|
+
const avgLookDown = (eyeLookDownLeft + eyeLookDownRight) / 2;
|
|
871
|
+
const lookLeft = eyeLookInLeft;
|
|
872
|
+
const lookRight = eyeLookOutLeft;
|
|
873
|
+
|
|
874
|
+
// Determine current gaze direction
|
|
875
|
+
let currentGaze = 'center';
|
|
876
|
+
const threshold = 0.4;
|
|
877
|
+
|
|
878
|
+
if (avgLookUp > threshold) currentGaze = 'up';
|
|
879
|
+
else if (avgLookDown > threshold) currentGaze = 'down';
|
|
880
|
+
else if (lookLeft > threshold) currentGaze = 'left';
|
|
881
|
+
else if (lookRight > threshold) currentGaze = 'right';
|
|
882
|
+
|
|
883
|
+
// Count direction changes
|
|
884
|
+
if (currentGaze !== 'center' && currentGaze !== this.lastGazeDirection) {
|
|
885
|
+
this.followDotMovements++;
|
|
886
|
+
this.lastGazeDirection = currentGaze;
|
|
887
|
+
console.log(`[Follow Dot] Gaze direction changed to: ${currentGaze}, movements: ${this.followDotMovements}`);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Need at least 3 direction changes to complete
|
|
891
|
+
const completed = this.followDotMovements >= 3;
|
|
892
|
+
const progress = Math.min(1.0, this.followDotMovements / 3);
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
action: 'follow-dot',
|
|
896
|
+
completed,
|
|
897
|
+
progress,
|
|
898
|
+
message: completed ? 'Eye tracking complete!' : `Follow the dot with your eyes (${this.followDotMovements}/3)`
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Detect speaking by tracking mouth movement over time
|
|
904
|
+
*/
|
|
905
|
+
private detectSpeakPhrase(blendshapes: any[]): LivenessActionResult {
|
|
906
|
+
const jawOpen = blendshapes.find((b: any) => b.categoryName === 'jawOpen')?.score || 0;
|
|
907
|
+
const mouthClose = blendshapes.find((b: any) => b.categoryName === 'mouthClose')?.score || 0;
|
|
908
|
+
|
|
909
|
+
// Calculate mouth openness (higher = more open)
|
|
910
|
+
const mouthOpenness = jawOpen - mouthClose;
|
|
911
|
+
|
|
912
|
+
// Detect mouth movements (opening and closing)
|
|
913
|
+
const threshold = 0.2;
|
|
914
|
+
const movementDelta = Math.abs(mouthOpenness - this.lastMouthOpenness);
|
|
915
|
+
|
|
916
|
+
if (movementDelta > threshold) {
|
|
917
|
+
this.mouthMovementCount++;
|
|
918
|
+
console.log(`[Speak] Mouth movement detected: ${mouthOpenness.toFixed(3)}, count: ${this.mouthMovementCount}`);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
this.lastMouthOpenness = mouthOpenness;
|
|
922
|
+
|
|
923
|
+
// Need at least 5 mouth movements to simulate speaking
|
|
924
|
+
const completed = this.mouthMovementCount >= 5;
|
|
925
|
+
const progress = Math.min(1.0, this.mouthMovementCount / 5);
|
|
926
|
+
|
|
927
|
+
return {
|
|
928
|
+
action: 'speak-phrase',
|
|
929
|
+
completed,
|
|
930
|
+
progress,
|
|
931
|
+
message: completed ? 'Speaking detected!' : 'Speak a short phrase'
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Detect head nod using pitch angle from transformation matrix
|
|
937
|
+
*/
|
|
938
|
+
private detectNod(transformationMatrix: any): LivenessActionResult {
|
|
939
|
+
if (!transformationMatrix) {
|
|
940
|
+
return {
|
|
941
|
+
action: 'nod',
|
|
942
|
+
completed: false,
|
|
943
|
+
progress: 0,
|
|
944
|
+
message: 'Unable to detect head movement'
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const pitch = this.calculatePitchAngle(transformationMatrix.data);
|
|
949
|
+
const pitchDegrees = (pitch * 180) / Math.PI;
|
|
950
|
+
|
|
951
|
+
// Initialize baseline
|
|
952
|
+
if (this.initialPitch === null) {
|
|
953
|
+
this.initialPitch = pitchDegrees;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const pitchChange = pitchDegrees - this.initialPitch;
|
|
957
|
+
const threshold = 10; // degrees
|
|
958
|
+
|
|
959
|
+
// Detect nod: pitch down then up
|
|
960
|
+
if (pitchChange > threshold && this.nodDirection !== 'down') {
|
|
961
|
+
this.nodDirection = 'down';
|
|
962
|
+
console.log(`[Nod] Head moved DOWN, pitch change: ${pitchChange.toFixed(1)}°`);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const completed = this.nodDirection === 'down' && pitchChange < -threshold;
|
|
966
|
+
|
|
967
|
+
if (completed) {
|
|
968
|
+
console.log(`[Nod] ✓ NOD COMPLETED! Head moved back up, pitch change: ${pitchChange.toFixed(1)}°`);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const progress = this.nodDirection === 'down' ?
|
|
972
|
+
Math.min(1, Math.abs(pitchChange) / threshold) * 0.8 :
|
|
973
|
+
Math.min(0.5, Math.abs(pitchChange) / threshold);
|
|
974
|
+
|
|
975
|
+
return {
|
|
976
|
+
action: 'nod',
|
|
977
|
+
completed,
|
|
978
|
+
progress,
|
|
979
|
+
message: completed ? 'Nod detected!' :
|
|
980
|
+
this.nodDirection === 'down' ? 'Now move your head up' :
|
|
981
|
+
'Nod your head down'
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* Detect shake head (side to side like saying "no")
|
|
987
|
+
*/
|
|
988
|
+
private detectShakeHead(transformationMatrix: any): LivenessActionResult {
|
|
989
|
+
if (!transformationMatrix || !transformationMatrix.data) {
|
|
990
|
+
return {
|
|
991
|
+
action: 'shake-head',
|
|
992
|
+
completed: false,
|
|
993
|
+
progress: 0,
|
|
994
|
+
message: 'Face not detected'
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Extract yaw angle from transformation matrix
|
|
999
|
+
const yaw = this.calculateYawAngle(transformationMatrix.data);
|
|
1000
|
+
const yawDegrees = (yaw * 180) / Math.PI;
|
|
1001
|
+
|
|
1002
|
+
// Initialize baseline
|
|
1003
|
+
if (this.initialYaw === null) {
|
|
1004
|
+
this.initialYaw = yawDegrees;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const yawChange = yawDegrees - this.initialYaw;
|
|
1008
|
+
const threshold = 15; // degrees (less than turn threshold)
|
|
1009
|
+
|
|
1010
|
+
// Detect shake: yaw left then right (or vice versa)
|
|
1011
|
+
// Note: Because camera is mirrored, the signs are swapped
|
|
1012
|
+
if (Math.abs(yawChange) > threshold) {
|
|
1013
|
+
if (yawChange < -threshold && this.shakeDirection !== 'left') {
|
|
1014
|
+
this.shakeDirection = 'left';
|
|
1015
|
+
this.shakeCount++;
|
|
1016
|
+
console.log(`[Shake Head] Head moved LEFT, yaw change: ${yawChange.toFixed(1)}°, count: ${this.shakeCount}`);
|
|
1017
|
+
} else if (yawChange > threshold && this.shakeDirection !== 'right') {
|
|
1018
|
+
this.shakeDirection = 'right';
|
|
1019
|
+
this.shakeCount++;
|
|
1020
|
+
console.log(`[Shake Head] Head moved RIGHT, yaw change: ${yawChange.toFixed(1)}°, count: ${this.shakeCount}`);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Complete after 2 direction changes (one full shake)
|
|
1025
|
+
const completed = this.shakeCount >= 2;
|
|
1026
|
+
|
|
1027
|
+
if (completed) {
|
|
1028
|
+
console.log(`[Shake Head] ✓ SHAKE HEAD COMPLETED! Total direction changes: ${this.shakeCount}`);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const progress = Math.min(1, this.shakeCount / 2);
|
|
1032
|
+
|
|
1033
|
+
return {
|
|
1034
|
+
action: 'shake-head',
|
|
1035
|
+
completed,
|
|
1036
|
+
progress,
|
|
1037
|
+
message: completed ? 'Head shake detected!' :
|
|
1038
|
+
this.shakeCount > 0 ? `Continue shaking (${this.shakeCount}/2)` :
|
|
1039
|
+
'Shake your head left to right (like saying "no")'
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Detect open mouth using MediaPipe blendshapes
|
|
1045
|
+
*/
|
|
1046
|
+
private detectOpenMouth(blendshapes: any[]): LivenessActionResult {
|
|
1047
|
+
// Get jaw open blendshape score
|
|
1048
|
+
const jawOpen = blendshapes.find((b: any) => b.categoryName === 'jawOpen')?.score || 0;
|
|
1049
|
+
const mouthClose = blendshapes.find((b: any) => b.categoryName === 'mouthClose')?.score || 0;
|
|
1050
|
+
|
|
1051
|
+
// Mouth is open when jawOpen > 0.6 and mouthClose is low
|
|
1052
|
+
const isMouthOpen = jawOpen > 0.6 && mouthClose < 0.3;
|
|
1053
|
+
|
|
1054
|
+
console.log(`[Open Mouth] JawOpen: ${jawOpen.toFixed(3)}, MouthClose: ${mouthClose.toFixed(3)}, Open: ${isMouthOpen}`);
|
|
1055
|
+
|
|
1056
|
+
// Track mouth opening
|
|
1057
|
+
if (isMouthOpen && !this.openMouthDetected) {
|
|
1058
|
+
this.openMouthDetected = true;
|
|
1059
|
+
console.log('[Open Mouth] ✓ MOUTH OPENED!');
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const completed = this.openMouthDetected && isMouthOpen;
|
|
1063
|
+
const progress = isMouthOpen ? 1.0 : (jawOpen / 0.6); // Progress based on jaw opening
|
|
1064
|
+
|
|
1065
|
+
return {
|
|
1066
|
+
action: 'open-mouth' as LivenessAction,
|
|
1067
|
+
completed,
|
|
1068
|
+
progress,
|
|
1069
|
+
message: completed ? 'Mouth opened!' :
|
|
1070
|
+
isMouthOpen ? 'Hold your mouth open' :
|
|
1071
|
+
'Open your mouth wide'
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Detect raised eyebrows using MediaPipe blendshapes
|
|
1077
|
+
*/
|
|
1078
|
+
private detectRaiseEyebrows(blendshapes: any[]): LivenessActionResult {
|
|
1079
|
+
// Get eyebrow blendshape scores
|
|
1080
|
+
const browInnerUp = blendshapes.find((b: any) => b.categoryName === 'browInnerUp')?.score || 0;
|
|
1081
|
+
const browOuterUpLeft = blendshapes.find((b: any) => b.categoryName === 'browOuterUpLeft')?.score || 0;
|
|
1082
|
+
const browOuterUpRight = blendshapes.find((b: any) => b.categoryName === 'browOuterUpRight')?.score || 0;
|
|
1083
|
+
|
|
1084
|
+
const avgBrowUp = (browInnerUp + browOuterUpLeft + browOuterUpRight) / 3;
|
|
1085
|
+
|
|
1086
|
+
// Eyebrows are raised when average > 0.5
|
|
1087
|
+
const areEyebrowsRaised = avgBrowUp > 0.5;
|
|
1088
|
+
|
|
1089
|
+
console.log(`[Raise Eyebrows] InnerUp: ${browInnerUp.toFixed(3)}, OuterLeft: ${browOuterUpLeft.toFixed(3)}, OuterRight: ${browOuterUpRight.toFixed(3)}, Avg: ${avgBrowUp.toFixed(3)}, Raised: ${areEyebrowsRaised}`);
|
|
1090
|
+
|
|
1091
|
+
// Track eyebrow raising
|
|
1092
|
+
if (areEyebrowsRaised && !this.eyebrowsRaisedDetected) {
|
|
1093
|
+
this.eyebrowsRaisedDetected = true;
|
|
1094
|
+
console.log('[Raise Eyebrows] ✓ EYEBROWS RAISED!');
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const completed = this.eyebrowsRaisedDetected && areEyebrowsRaised;
|
|
1098
|
+
const progress = areEyebrowsRaised ? 1.0 : (avgBrowUp / 0.5); // Progress based on eyebrow movement
|
|
1099
|
+
|
|
1100
|
+
return {
|
|
1101
|
+
action: 'raise-eyebrows' as LivenessAction,
|
|
1102
|
+
completed,
|
|
1103
|
+
progress,
|
|
1104
|
+
message: completed ? 'Eyebrows raised!' :
|
|
1105
|
+
areEyebrowsRaised ? 'Hold your eyebrows up' :
|
|
1106
|
+
'Raise your eyebrows (surprised look)'
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* Detect face rotation - user rotates their face slowly from center to left, back to center, then to right
|
|
1112
|
+
* This is a passive liveness check that ensures the face can be viewed from multiple angles
|
|
1113
|
+
*/
|
|
1114
|
+
private detectRotateFace(transformationMatrix: any): LivenessActionResult {
|
|
1115
|
+
if (!transformationMatrix || !transformationMatrix.data) {
|
|
1116
|
+
return {
|
|
1117
|
+
action: 'rotate-face' as LivenessAction,
|
|
1118
|
+
completed: false,
|
|
1119
|
+
progress: 0,
|
|
1120
|
+
message: 'Face not detected'
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Extract yaw angle from transformation matrix
|
|
1125
|
+
const yaw = this.calculateYawAngle(transformationMatrix.data);
|
|
1126
|
+
const yawDegrees = (yaw * 180) / Math.PI;
|
|
1127
|
+
|
|
1128
|
+
// Define thresholds for rotation detection
|
|
1129
|
+
const centerThreshold = 10; // Within 10 degrees is considered center
|
|
1130
|
+
const turnThreshold = 20; // Need at least 20 degrees rotation
|
|
1131
|
+
|
|
1132
|
+
// Detect current head position
|
|
1133
|
+
let currentPosition = 'center';
|
|
1134
|
+
if (yawDegrees < -turnThreshold) {
|
|
1135
|
+
currentPosition = 'left'; // User turns physically left (negative yaw)
|
|
1136
|
+
} else if (yawDegrees > turnThreshold) {
|
|
1137
|
+
currentPosition = 'right'; // User turns physically right (positive yaw)
|
|
1138
|
+
} else if (Math.abs(yawDegrees) <= centerThreshold) {
|
|
1139
|
+
currentPosition = 'center';
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
console.log(`[Rotate Face] Yaw: ${yawDegrees.toFixed(1)}°, Position: ${currentPosition}, Stage: ${this.rotationStage}, Sequence: [${this.rotationSequence.join(', ')}]`);
|
|
1143
|
+
|
|
1144
|
+
// State machine for rotation sequence
|
|
1145
|
+
if (this.rotationStage === 'center' && currentPosition === 'center') {
|
|
1146
|
+
// Starting position confirmed - waiting for left turn
|
|
1147
|
+
// Do nothing, just wait
|
|
1148
|
+
} else if (this.rotationStage === 'center' && currentPosition === 'left') {
|
|
1149
|
+
// User turned left from center
|
|
1150
|
+
this.rotationStage = 'left';
|
|
1151
|
+
this.rotationSequence.push('left');
|
|
1152
|
+
console.log('[Rotate Face] ✓ Turned LEFT');
|
|
1153
|
+
} else if (this.rotationStage === 'left' && currentPosition === 'center') {
|
|
1154
|
+
// User returned to center from left
|
|
1155
|
+
this.rotationStage = 'right'; // Now waiting for right turn
|
|
1156
|
+
this.rotationSequence.push('center');
|
|
1157
|
+
console.log('[Rotate Face] ✓ Back to CENTER from left');
|
|
1158
|
+
} else if (this.rotationStage === 'right' && currentPosition === 'right') {
|
|
1159
|
+
// User turned right from center
|
|
1160
|
+
this.rotationStage = 'complete';
|
|
1161
|
+
this.rotationSequence.push('right');
|
|
1162
|
+
this.rotateFaceDetected = true;
|
|
1163
|
+
console.log('[Rotate Face] ✓✓ ROTATION COMPLETE! Turned RIGHT');
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const completed = this.rotateFaceDetected;
|
|
1167
|
+
|
|
1168
|
+
// Calculate progress based on rotation stage
|
|
1169
|
+
let progress = 0;
|
|
1170
|
+
if (this.rotationStage === 'center') {
|
|
1171
|
+
progress = 0.1; // Just started
|
|
1172
|
+
} else if (this.rotationStage === 'left') {
|
|
1173
|
+
progress = 0.4; // Turned left
|
|
1174
|
+
} else if (this.rotationStage === 'right') {
|
|
1175
|
+
progress = 0.7; // Back to center, waiting for right
|
|
1176
|
+
} else if (this.rotationStage === 'complete') {
|
|
1177
|
+
progress = 1.0; // Complete!
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// Generate helpful message
|
|
1181
|
+
let message: string;
|
|
1182
|
+
if (completed) {
|
|
1183
|
+
message = 'Face rotation complete!';
|
|
1184
|
+
} else if (this.rotationStage === 'center') {
|
|
1185
|
+
message = 'Slowly turn your face to the left';
|
|
1186
|
+
} else if (this.rotationStage === 'left') {
|
|
1187
|
+
message = 'Good! Now return to center';
|
|
1188
|
+
} else if (this.rotationStage === 'right') {
|
|
1189
|
+
message = 'Perfect! Now turn your face to the right';
|
|
1190
|
+
} else {
|
|
1191
|
+
message = 'Slowly rotate your face';
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
return {
|
|
1195
|
+
action: 'rotate-face' as LivenessAction,
|
|
1196
|
+
completed,
|
|
1197
|
+
progress,
|
|
1198
|
+
message
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Detect smile using MediaPipe blendshapes
|
|
1204
|
+
*/
|
|
1205
|
+
private detectSmile(blendshapes: any[]): LivenessActionResult {
|
|
1206
|
+
// Get smile blendshape scores
|
|
1207
|
+
const mouthSmileLeft = blendshapes.find((b: any) => b.categoryName === 'mouthSmileLeft')?.score || 0;
|
|
1208
|
+
const mouthSmileRight = blendshapes.find((b: any) => b.categoryName === 'mouthSmileRight')?.score || 0;
|
|
1209
|
+
const avgSmile = (mouthSmileLeft + mouthSmileRight) / 2;
|
|
1210
|
+
|
|
1211
|
+
// Smile threshold - score > 0.5 means smiling
|
|
1212
|
+
const isSmiling = avgSmile > 0.5;
|
|
1213
|
+
|
|
1214
|
+
console.log(`[Smile] Left: ${mouthSmileLeft.toFixed(3)}, Right: ${mouthSmileRight.toFixed(3)}, Avg: ${avgSmile.toFixed(3)}, Smiling: ${isSmiling}`);
|
|
1215
|
+
|
|
1216
|
+
// Track smile
|
|
1217
|
+
if (isSmiling && !this.smileDetected) {
|
|
1218
|
+
this.smileDetected = true;
|
|
1219
|
+
console.log('[Smile] ✓ SMILE DETECTED!');
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const completed = this.smileDetected && isSmiling;
|
|
1223
|
+
|
|
1224
|
+
// Calculate progress based on smile intensity
|
|
1225
|
+
const progress = isSmiling ? Math.min(1, avgSmile / 0.5) : avgSmile / 0.5;
|
|
1226
|
+
|
|
1227
|
+
return {
|
|
1228
|
+
action: 'smile',
|
|
1229
|
+
completed,
|
|
1230
|
+
progress,
|
|
1231
|
+
message: completed ? 'Great smile!' : 'Smile for the camera'
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Detect smile + thumbs up gesture (default Phase 2 action)
|
|
1237
|
+
*/
|
|
1238
|
+
private async detectSmileThumbsUp(
|
|
1239
|
+
videoElement: HTMLVideoElement,
|
|
1240
|
+
faceBlendshapes: any[]
|
|
1241
|
+
): Promise<LivenessActionResult> {
|
|
1242
|
+
// First, check for smile using MediaPipe blendshapes
|
|
1243
|
+
const mouthSmileLeft = faceBlendshapes.find((b: any) => b.categoryName === 'mouthSmileLeft')?.score || 0;
|
|
1244
|
+
const mouthSmileRight = faceBlendshapes.find((b: any) => b.categoryName === 'mouthSmileRight')?.score || 0;
|
|
1245
|
+
const avgSmile = (mouthSmileLeft + mouthSmileRight) / 2;
|
|
1246
|
+
const isSmiling = avgSmile > 0.5;
|
|
1247
|
+
|
|
1248
|
+
// Second, check for thumbs up using OpenPose/MoveNet
|
|
1249
|
+
const pose = await this.openposeService.detectPose(videoElement);
|
|
1250
|
+
let isThumbsUp = false;
|
|
1251
|
+
let thumbsUpConfidence = 0;
|
|
1252
|
+
|
|
1253
|
+
if (pose) {
|
|
1254
|
+
const thumbsUpResult = this.openposeService.detectThumbsUp(pose);
|
|
1255
|
+
isThumbsUp = thumbsUpResult.anyThumbsUp;
|
|
1256
|
+
thumbsUpConfidence = thumbsUpResult.confidence;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
console.log(`[Smile+ThumbsUp] Smile: ${avgSmile.toFixed(3)} (${isSmiling}), ThumbsUp: ${isThumbsUp} (confidence: ${thumbsUpConfidence.toFixed(2)})`);
|
|
1260
|
+
|
|
1261
|
+
// Track state for completion
|
|
1262
|
+
if (isSmiling && !this.smileDetected) {
|
|
1263
|
+
this.smileDetected = true;
|
|
1264
|
+
console.log('[Smile+ThumbsUp] ✓ SMILE DETECTED!');
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
if (isThumbsUp && !this.thumbsUpDetected) {
|
|
1268
|
+
this.thumbsUpDetected = true;
|
|
1269
|
+
console.log('[Smile+ThumbsUp] ✓ THUMBS UP DETECTED!');
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// Both conditions must be met
|
|
1273
|
+
const bothDetected = this.smileDetected && this.thumbsUpDetected;
|
|
1274
|
+
const currentlyBoth = isSmiling && isThumbsUp;
|
|
1275
|
+
|
|
1276
|
+
// Complete when both are currently active
|
|
1277
|
+
const completed = bothDetected && currentlyBoth;
|
|
1278
|
+
|
|
1279
|
+
if (completed && !this.smileThumbsUpDetected) {
|
|
1280
|
+
this.smileThumbsUpDetected = true;
|
|
1281
|
+
console.log('[Smile+ThumbsUp] ✓✓ BOTH DETECTED TOGETHER!');
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Calculate progress
|
|
1285
|
+
let progress = 0;
|
|
1286
|
+
if (completed) {
|
|
1287
|
+
progress = 1.0;
|
|
1288
|
+
} else if (bothDetected) {
|
|
1289
|
+
progress = 0.8;
|
|
1290
|
+
} else if (this.smileDetected || this.thumbsUpDetected) {
|
|
1291
|
+
progress = 0.5;
|
|
1292
|
+
} else if (isSmiling || isThumbsUp) {
|
|
1293
|
+
progress = 0.3;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Generate message
|
|
1297
|
+
let message: string;
|
|
1298
|
+
if (completed) {
|
|
1299
|
+
message = 'Perfect! Smile with thumbs up!';
|
|
1300
|
+
} else if (bothDetected && !currentlyBoth) {
|
|
1301
|
+
if (!isSmiling) message = 'Keep the thumbs up and smile!';
|
|
1302
|
+
else message = 'Keep smiling and show thumbs up!';
|
|
1303
|
+
} else if (this.smileDetected && !this.thumbsUpDetected) {
|
|
1304
|
+
message = 'Great smile! Now show thumbs up!';
|
|
1305
|
+
} else if (this.thumbsUpDetected && !this.smileDetected) {
|
|
1306
|
+
message = 'Nice thumbs up! Now smile!';
|
|
1307
|
+
} else if (isSmiling && !isThumbsUp) {
|
|
1308
|
+
message = 'Good! Now add thumbs up!';
|
|
1309
|
+
} else if (isThumbsUp && !isSmiling) {
|
|
1310
|
+
message = 'Nice! Now smile!';
|
|
1311
|
+
} else {
|
|
1312
|
+
message = 'Smile and show thumbs up!';
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
return {
|
|
1316
|
+
action: 'smile-thumbs-up' as LivenessAction,
|
|
1317
|
+
completed,
|
|
1318
|
+
progress,
|
|
1319
|
+
message
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Store static pose keypoints for comparison
|
|
1324
|
+
private staticPoseKeypoints: any[] | null = null;
|
|
1325
|
+
|
|
1326
|
+
/**
|
|
1327
|
+
* Set static pose keypoints for detection (from PoseDefinition)
|
|
1328
|
+
*/
|
|
1329
|
+
setStaticPoseKeypoints(keypoints: any[] | null): void {
|
|
1330
|
+
this.staticPoseKeypoints = keypoints;
|
|
1331
|
+
console.log('[Pose Matching] Static pose keypoints set:', keypoints ? keypoints.length : 0);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Set expected hand gesture for detection
|
|
1336
|
+
* @param gesture - The gesture type (palm, thumbs_up, peace, etc.)
|
|
1337
|
+
* @param handedness - Which hand is expected: 'Left', 'Right', or 'Any'
|
|
1338
|
+
*/
|
|
1339
|
+
setExpectedGesture(gesture: string | null, handedness: 'Left' | 'Right' | 'Any' = 'Any'): void {
|
|
1340
|
+
this.expectedGesture = gesture;
|
|
1341
|
+
this.expectedHandedness = handedness;
|
|
1342
|
+
this.handGestureDetected = false;
|
|
1343
|
+
console.log('[Hand Gesture] Expected gesture set:', gesture, 'with handedness:', handedness);
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
/**
|
|
1347
|
+
* Detect hand gesture using MediaPipe Hands
|
|
1348
|
+
*/
|
|
1349
|
+
private async detectHandGesture(
|
|
1350
|
+
videoElement: HTMLVideoElement
|
|
1351
|
+
): Promise<LivenessActionResult> {
|
|
1352
|
+
console.log('[Hand Gesture] detectHandGesture called - starting detection');
|
|
1353
|
+
|
|
1354
|
+
if (!this.expectedGesture) {
|
|
1355
|
+
return {
|
|
1356
|
+
action: 'hand-gesture' as LivenessAction,
|
|
1357
|
+
completed: false,
|
|
1358
|
+
progress: 0,
|
|
1359
|
+
message: 'No expected gesture set'
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Detect hands using MediaPipe Hands
|
|
1364
|
+
const handsResult = await this.handGestureService.detectHands(videoElement);
|
|
1365
|
+
|
|
1366
|
+
if (!handsResult || !handsResult.landmarks || handsResult.landmarks.length === 0) {
|
|
1367
|
+
return {
|
|
1368
|
+
action: 'hand-gesture' as LivenessAction,
|
|
1369
|
+
completed: false,
|
|
1370
|
+
progress: 0,
|
|
1371
|
+
message: 'No hand detected. Please show your hand to the camera.'
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Detect gestures from hand landmarks
|
|
1376
|
+
const detectedGestures = this.handGestureService.detectGesture(handsResult);
|
|
1377
|
+
|
|
1378
|
+
console.log(`[Hand Gesture] Detected ${detectedGestures.length} hand(s)`);
|
|
1379
|
+
|
|
1380
|
+
if (detectedGestures.length === 0) {
|
|
1381
|
+
return {
|
|
1382
|
+
action: 'hand-gesture' as LivenessAction,
|
|
1383
|
+
completed: false,
|
|
1384
|
+
progress: 0,
|
|
1385
|
+
message: 'Unable to recognize hand gesture'
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Check if any detected gesture matches expected gesture AND handedness
|
|
1390
|
+
const matched = detectedGestures.some(g => {
|
|
1391
|
+
const gestureMatches = this.handGestureService.matchGesture(g.gesture, this.expectedGesture!);
|
|
1392
|
+
|
|
1393
|
+
// Check handedness
|
|
1394
|
+
// MediaPipe Hand Landmarker reports handedness correctly based on the person's actual hand
|
|
1395
|
+
// when using a mirrored front-facing camera (selfie mode).
|
|
1396
|
+
//
|
|
1397
|
+
// The handedness is detected from the person's perspective, not the camera's perspective.
|
|
1398
|
+
// So if the user shows their RIGHT hand, MediaPipe reports "Right"
|
|
1399
|
+
// If the user shows their LEFT hand, MediaPipe reports "Left"
|
|
1400
|
+
//
|
|
1401
|
+
// NO FLIPPING NEEDED - Direct comparison
|
|
1402
|
+
let handednessMatches = true;
|
|
1403
|
+
if (this.expectedHandedness !== 'Any') {
|
|
1404
|
+
handednessMatches = g.handedness === this.expectedHandedness;
|
|
1405
|
+
console.log(`[Hand Gesture Match Check] MediaPipe reports: ${g.handedness}, Expected: ${this.expectedHandedness}, Match: ${handednessMatches}`);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
console.log(`[Hand Gesture Match Check] Gesture: ${g.gesture} vs ${this.expectedGesture} = ${gestureMatches}, Overall: ${gestureMatches && handednessMatches}`);
|
|
1409
|
+
|
|
1410
|
+
return gestureMatches && handednessMatches;
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
const detectedInfo = detectedGestures.map(g => `${g.gesture}(${g.handedness})`).join(', ');
|
|
1414
|
+
console.log(`[Hand Gesture] Expected: ${this.expectedGesture} (${this.expectedHandedness}), Detected: ${detectedInfo}, Matched: ${matched}`);
|
|
1415
|
+
|
|
1416
|
+
if (matched && !this.handGestureDetected) {
|
|
1417
|
+
this.handGestureDetected = true;
|
|
1418
|
+
console.log('[Hand Gesture] ✓ GESTURE MATCHED!');
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
const completed = this.handGestureDetected && matched;
|
|
1422
|
+
const progress = matched ? 1.0 : 0.5;
|
|
1423
|
+
|
|
1424
|
+
// Build gesture name with handedness info
|
|
1425
|
+
const gestureName = this.expectedGesture.replace('_', ' ');
|
|
1426
|
+
const handPrefix = this.expectedHandedness !== 'Any' ? `${this.expectedHandedness.toLowerCase()} ` : '';
|
|
1427
|
+
const fullGestureName = `${handPrefix}${gestureName}`;
|
|
1428
|
+
|
|
1429
|
+
let message: string;
|
|
1430
|
+
if (completed) {
|
|
1431
|
+
message = `Perfect! ${fullGestureName} detected!`;
|
|
1432
|
+
} else if (matched) {
|
|
1433
|
+
message = `Good! Hold the ${fullGestureName}...`;
|
|
1434
|
+
} else {
|
|
1435
|
+
const detected = detectedGestures[0]?.gesture.replace('_', ' ') || 'unknown';
|
|
1436
|
+
const detectedHand = detectedGestures[0]?.handedness || 'unknown';
|
|
1437
|
+
message = `Show ${fullGestureName} (detected: ${detected} ${detectedHand})`;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
return {
|
|
1441
|
+
action: 'hand-gesture' as LivenessAction,
|
|
1442
|
+
completed,
|
|
1443
|
+
progress,
|
|
1444
|
+
message
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Detect wave gesture using MoveNet body tracking
|
|
1450
|
+
*/
|
|
1451
|
+
private async detectWave(
|
|
1452
|
+
videoElement: HTMLVideoElement
|
|
1453
|
+
): Promise<LivenessActionResult> {
|
|
1454
|
+
// Detect body pose using MoveNet
|
|
1455
|
+
const pose = await this.openposeService.detectPose(videoElement);
|
|
1456
|
+
|
|
1457
|
+
if (!pose) {
|
|
1458
|
+
return {
|
|
1459
|
+
action: 'wave' as LivenessAction,
|
|
1460
|
+
completed: false,
|
|
1461
|
+
progress: 0,
|
|
1462
|
+
message: 'No pose detected. Please raise your hand and wave.'
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// Get right wrist keypoint (index 10 in MoveNet)
|
|
1467
|
+
const rightWrist = pose.keypoints[10];
|
|
1468
|
+
const rightShoulder = pose.keypoints[6];
|
|
1469
|
+
|
|
1470
|
+
if (!rightWrist || rightWrist.score! < 0.3 || !rightShoulder) {
|
|
1471
|
+
return {
|
|
1472
|
+
action: 'wave' as LivenessAction,
|
|
1473
|
+
completed: false,
|
|
1474
|
+
progress: 0,
|
|
1475
|
+
message: 'Raise your right hand above shoulder level'
|
|
1476
|
+
};
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
// Check if hand is raised (wrist above shoulder)
|
|
1480
|
+
const handRaised = rightWrist.y < rightShoulder.y;
|
|
1481
|
+
|
|
1482
|
+
if (!handRaised) {
|
|
1483
|
+
return {
|
|
1484
|
+
action: 'wave' as LivenessAction,
|
|
1485
|
+
completed: false,
|
|
1486
|
+
progress: 0.2,
|
|
1487
|
+
message: 'Raise your right hand higher'
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
// Track horizontal movement for waving
|
|
1492
|
+
if (this.previousWristX === null) {
|
|
1493
|
+
this.previousWristX = rightWrist.x;
|
|
1494
|
+
} else {
|
|
1495
|
+
const deltaX = rightWrist.x - this.previousWristX;
|
|
1496
|
+
const videoWidth = videoElement.videoWidth || 640;
|
|
1497
|
+
const normalizedDelta = Math.abs(deltaX) / videoWidth;
|
|
1498
|
+
|
|
1499
|
+
// Detect significant horizontal movement (wave motion)
|
|
1500
|
+
if (normalizedDelta > 0.05) { // 5% of screen width
|
|
1501
|
+
const newDirection = deltaX > 0 ? 'right' : 'left';
|
|
1502
|
+
|
|
1503
|
+
// Count direction changes (waving back and forth)
|
|
1504
|
+
if (this.waveDirection && this.waveDirection !== newDirection) {
|
|
1505
|
+
this.waveCount++;
|
|
1506
|
+
console.log(`[Wave] Direction change detected! Count: ${this.waveCount}`);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
this.waveDirection = newDirection;
|
|
1510
|
+
this.previousWristX = rightWrist.x;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// Complete after 2-3 wave motions
|
|
1515
|
+
const completed = this.waveCount >= 2;
|
|
1516
|
+
|
|
1517
|
+
if (completed && !this.waveDetected) {
|
|
1518
|
+
this.waveDetected = true;
|
|
1519
|
+
console.log('[Wave] ✓ WAVE GESTURE COMPLETED!');
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const progress = Math.min(1, (this.waveCount / 2) * 0.5 + 0.5);
|
|
1523
|
+
|
|
1524
|
+
return {
|
|
1525
|
+
action: 'wave' as LivenessAction,
|
|
1526
|
+
completed,
|
|
1527
|
+
progress,
|
|
1528
|
+
message: completed ? 'Wave detected!' : `Wave your hand left and right (${this.waveCount}/2)`
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
/**
|
|
1533
|
+
* Detect clap gesture using MoveNet body tracking
|
|
1534
|
+
*/
|
|
1535
|
+
private async detectClap(
|
|
1536
|
+
videoElement: HTMLVideoElement
|
|
1537
|
+
): Promise<LivenessActionResult> {
|
|
1538
|
+
// Detect body pose using MoveNet
|
|
1539
|
+
const pose = await this.openposeService.detectPose(videoElement);
|
|
1540
|
+
|
|
1541
|
+
if (!pose) {
|
|
1542
|
+
return {
|
|
1543
|
+
action: 'clap' as LivenessAction,
|
|
1544
|
+
completed: false,
|
|
1545
|
+
progress: 0,
|
|
1546
|
+
message: 'No pose detected. Please position yourself in view.'
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Get both wrist keypoints (indices 9 and 10 in MoveNet)
|
|
1551
|
+
const leftWrist = pose.keypoints[9];
|
|
1552
|
+
const rightWrist = pose.keypoints[10];
|
|
1553
|
+
|
|
1554
|
+
if (!leftWrist || leftWrist.score! < 0.3 || !rightWrist || rightWrist.score! < 0.3) {
|
|
1555
|
+
return {
|
|
1556
|
+
action: 'clap' as LivenessAction,
|
|
1557
|
+
completed: false,
|
|
1558
|
+
progress: 0,
|
|
1559
|
+
message: 'Show both hands in view'
|
|
1560
|
+
};
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// Calculate distance between wrists
|
|
1564
|
+
const dx = leftWrist.x - rightWrist.x;
|
|
1565
|
+
const dy = leftWrist.y - rightWrist.y;
|
|
1566
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
1567
|
+
const videoWidth = videoElement.videoWidth || 640;
|
|
1568
|
+
const normalizedDistance = distance / videoWidth;
|
|
1569
|
+
|
|
1570
|
+
console.log(`[Clap] Distance between wrists: ${normalizedDistance.toFixed(3)}`);
|
|
1571
|
+
|
|
1572
|
+
// Detect hands apart (starting position)
|
|
1573
|
+
if (normalizedDistance > 0.3 && !this.handsApart) {
|
|
1574
|
+
this.handsApart = true;
|
|
1575
|
+
console.log('[Clap] Hands apart detected');
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// Detect hands together (clapping)
|
|
1579
|
+
const handsTogether = normalizedDistance < 0.1;
|
|
1580
|
+
|
|
1581
|
+
if (handsTogether && this.handsApart && !this.clapDetected) {
|
|
1582
|
+
this.clapDetected = true;
|
|
1583
|
+
console.log('[Clap] ✓ CLAP DETECTED!');
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
const completed = this.clapDetected;
|
|
1587
|
+
const progress = this.handsApart ? (handsTogether ? 1.0 : 0.5) : 0.2;
|
|
1588
|
+
|
|
1589
|
+
return {
|
|
1590
|
+
action: 'clap' as LivenessAction,
|
|
1591
|
+
completed,
|
|
1592
|
+
progress,
|
|
1593
|
+
message: completed ? 'Clap detected!' :
|
|
1594
|
+
this.handsApart ? 'Now bring your hands together' :
|
|
1595
|
+
'Spread your hands apart first'
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* Detect cross arms gesture using MoveNet body tracking
|
|
1601
|
+
*/
|
|
1602
|
+
private async detectCrossArms(
|
|
1603
|
+
videoElement: HTMLVideoElement
|
|
1604
|
+
): Promise<LivenessActionResult> {
|
|
1605
|
+
// Detect body pose using MoveNet
|
|
1606
|
+
const pose = await this.openposeService.detectPose(videoElement);
|
|
1607
|
+
|
|
1608
|
+
if (!pose) {
|
|
1609
|
+
return {
|
|
1610
|
+
action: 'cross-arms' as LivenessAction,
|
|
1611
|
+
completed: false,
|
|
1612
|
+
progress: 0,
|
|
1613
|
+
message: 'No pose detected. Please position yourself in view.'
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// Get wrist and shoulder keypoints
|
|
1618
|
+
const leftWrist = pose.keypoints[9];
|
|
1619
|
+
const rightWrist = pose.keypoints[10];
|
|
1620
|
+
const leftShoulder = pose.keypoints[5];
|
|
1621
|
+
const rightShoulder = pose.keypoints[6];
|
|
1622
|
+
|
|
1623
|
+
if (!leftWrist || leftWrist.score! < 0.3 || !rightWrist || rightWrist.score! < 0.3 ||
|
|
1624
|
+
!leftShoulder || !rightShoulder) {
|
|
1625
|
+
return {
|
|
1626
|
+
action: 'cross-arms' as LivenessAction,
|
|
1627
|
+
completed: false,
|
|
1628
|
+
progress: 0,
|
|
1629
|
+
message: 'Show both hands and shoulders in view'
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// Check if arms are crossed (left wrist near right shoulder, right wrist near left shoulder)
|
|
1634
|
+
const leftWristToRightShoulder = Math.abs(leftWrist.x - rightShoulder.x);
|
|
1635
|
+
const rightWristToLeftShoulder = Math.abs(rightWrist.x - leftShoulder.x);
|
|
1636
|
+
const videoWidth = videoElement.videoWidth || 640;
|
|
1637
|
+
|
|
1638
|
+
const leftCrossed = (leftWristToRightShoulder / videoWidth) < 0.2;
|
|
1639
|
+
const rightCrossed = (rightWristToLeftShoulder / videoWidth) < 0.2;
|
|
1640
|
+
|
|
1641
|
+
// Both wrists should be at chest level (between shoulders and hips)
|
|
1642
|
+
const leftElbow = pose.keypoints[7];
|
|
1643
|
+
const rightElbow = pose.keypoints[8];
|
|
1644
|
+
const atChestLevel = leftWrist.y > leftShoulder.y && rightWrist.y > rightShoulder.y &&
|
|
1645
|
+
leftElbow && rightElbow &&
|
|
1646
|
+
leftElbow.y > leftShoulder.y && rightElbow.y > rightShoulder.y;
|
|
1647
|
+
|
|
1648
|
+
const armsCrossed = leftCrossed && rightCrossed && atChestLevel;
|
|
1649
|
+
|
|
1650
|
+
console.log(`[CrossArms] Left crossed: ${leftCrossed}, Right crossed: ${rightCrossed}, At chest: ${atChestLevel}`);
|
|
1651
|
+
|
|
1652
|
+
if (armsCrossed && !this.crossArmsDetected) {
|
|
1653
|
+
this.crossArmsDetected = true;
|
|
1654
|
+
console.log('[CrossArms] ✓ CROSS ARMS DETECTED!');
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
const completed = this.crossArmsDetected;
|
|
1658
|
+
const progress = armsCrossed ? 1.0 : (leftCrossed || rightCrossed ? 0.5 : 0.2);
|
|
1659
|
+
|
|
1660
|
+
return {
|
|
1661
|
+
action: 'cross-arms' as LivenessAction,
|
|
1662
|
+
completed,
|
|
1663
|
+
progress,
|
|
1664
|
+
message: completed ? 'Arms crossed detected!' : 'Cross your arms in front of your chest'
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* Detect point left-right gesture using MoveNet body tracking
|
|
1670
|
+
*/
|
|
1671
|
+
private async detectPoint(
|
|
1672
|
+
videoElement: HTMLVideoElement
|
|
1673
|
+
): Promise<LivenessActionResult> {
|
|
1674
|
+
// Detect body pose using MoveNet
|
|
1675
|
+
const pose = await this.openposeService.detectPose(videoElement);
|
|
1676
|
+
|
|
1677
|
+
if (!pose) {
|
|
1678
|
+
return {
|
|
1679
|
+
action: 'point' as LivenessAction,
|
|
1680
|
+
completed: false,
|
|
1681
|
+
progress: 0,
|
|
1682
|
+
message: 'No pose detected. Please position yourself in view.'
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
// Get right wrist keypoint for pointing
|
|
1687
|
+
const rightWrist = pose.keypoints[10];
|
|
1688
|
+
const nose = pose.keypoints[0];
|
|
1689
|
+
|
|
1690
|
+
if (!rightWrist || rightWrist.score! < 0.3 || !nose) {
|
|
1691
|
+
return {
|
|
1692
|
+
action: 'point' as LivenessAction,
|
|
1693
|
+
completed: false,
|
|
1694
|
+
progress: 0,
|
|
1695
|
+
message: 'Raise your right hand to point'
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
const videoWidth = videoElement.videoWidth || 640;
|
|
1700
|
+
const normalizedWristX = rightWrist.x / videoWidth;
|
|
1701
|
+
const normalizedNoseX = nose.x / videoWidth;
|
|
1702
|
+
|
|
1703
|
+
console.log(`[Point] Wrist X: ${normalizedWristX.toFixed(3)}, Nose X: ${normalizedNoseX.toFixed(3)}`);
|
|
1704
|
+
|
|
1705
|
+
// Detect pointing left (wrist significantly left of nose)
|
|
1706
|
+
if (normalizedWristX < normalizedNoseX - 0.2 && !this.pointedLeft) {
|
|
1707
|
+
this.pointedLeft = true;
|
|
1708
|
+
console.log('[Point] Pointed LEFT!');
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// Detect pointing right (wrist significantly right of nose)
|
|
1712
|
+
if (normalizedWristX > normalizedNoseX + 0.2 && !this.pointedRight) {
|
|
1713
|
+
this.pointedRight = true;
|
|
1714
|
+
console.log('[Point] Pointed RIGHT!');
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
const completed = this.pointedLeft && this.pointedRight;
|
|
1718
|
+
|
|
1719
|
+
if (completed && !this.pointDetected) {
|
|
1720
|
+
this.pointDetected = true;
|
|
1721
|
+
console.log('[Point] ✓ POINT LEFT-RIGHT COMPLETED!');
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
let progress = 0;
|
|
1725
|
+
if (this.pointedLeft && this.pointedRight) progress = 1.0;
|
|
1726
|
+
else if (this.pointedLeft || this.pointedRight) progress = 0.5;
|
|
1727
|
+
|
|
1728
|
+
let message: string;
|
|
1729
|
+
if (completed) {
|
|
1730
|
+
message = 'Point gesture completed!';
|
|
1731
|
+
} else if (this.pointedLeft) {
|
|
1732
|
+
message = 'Now point to the right';
|
|
1733
|
+
} else if (this.pointedRight) {
|
|
1734
|
+
message = 'Now point to the left';
|
|
1735
|
+
} else {
|
|
1736
|
+
message = 'Point to the left edge of screen';
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
return {
|
|
1740
|
+
action: 'point' as LivenessAction,
|
|
1741
|
+
completed,
|
|
1742
|
+
progress,
|
|
1743
|
+
message
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* Set expected combined pose type (e.g., "touch-nose", "touch-chin")
|
|
1749
|
+
*/
|
|
1750
|
+
setCombinedPoseType(poseType: string | null): void {
|
|
1751
|
+
this.combinedPoseType = poseType;
|
|
1752
|
+
this.combinedPoseDetected = false;
|
|
1753
|
+
console.log('[Combined Pose] Expected pose type set:', poseType);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
/**
|
|
1757
|
+
* Detect combined face + hand gestures (touch nose, chin, cheek, etc.)
|
|
1758
|
+
*/
|
|
1759
|
+
private async detectCombinedPose(
|
|
1760
|
+
videoElement: HTMLVideoElement
|
|
1761
|
+
): Promise<LivenessActionResult> {
|
|
1762
|
+
if (!this.combinedPoseType) {
|
|
1763
|
+
return {
|
|
1764
|
+
action: 'combined-pose' as LivenessAction,
|
|
1765
|
+
completed: false,
|
|
1766
|
+
progress: 0,
|
|
1767
|
+
message: 'No combined pose type set'
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
console.log(`[Combined Pose] Type: "${this.combinedPoseType}"`);
|
|
1772
|
+
|
|
1773
|
+
// Detect face using MediaPipe
|
|
1774
|
+
const faceResult = await this.detectFace(videoElement);
|
|
1775
|
+
|
|
1776
|
+
// Detect body pose using MoveNet
|
|
1777
|
+
const bodyPose = await this.openposeService.detectPose(videoElement);
|
|
1778
|
+
|
|
1779
|
+
console.log(`[Combined Pose] Face detected: ${!!faceResult}, Face landmarks: ${faceResult?.faceLandmarks?.length || 0}`);
|
|
1780
|
+
console.log(`[Combined Pose] Body pose detected: ${!!bodyPose}, Keypoints: ${bodyPose?.keypoints?.length || 0}`);
|
|
1781
|
+
|
|
1782
|
+
if (!faceResult || !faceResult.faceLandmarks || faceResult.faceLandmarks.length === 0) {
|
|
1783
|
+
return {
|
|
1784
|
+
action: 'combined-pose' as LivenessAction,
|
|
1785
|
+
completed: false,
|
|
1786
|
+
progress: 0,
|
|
1787
|
+
message: 'No face detected. Please show your face.'
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
if (!bodyPose || !bodyPose.keypoints || bodyPose.keypoints.length < 11) {
|
|
1792
|
+
console.log(`[Combined Pose] Body pose not detected or insufficient keypoints`);
|
|
1793
|
+
return {
|
|
1794
|
+
action: 'combined-pose' as LivenessAction,
|
|
1795
|
+
completed: false,
|
|
1796
|
+
progress: 0.2,
|
|
1797
|
+
message: 'Face detected. Now show your hand.'
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
const faceLandmarks = faceResult.faceLandmarks[0];
|
|
1802
|
+
const nose = faceLandmarks[1]; // Nose tip
|
|
1803
|
+
const videoWidth = videoElement.videoWidth || 640;
|
|
1804
|
+
const videoHeight = videoElement.videoHeight || 480;
|
|
1805
|
+
|
|
1806
|
+
// Get hand keypoints from MoveNet
|
|
1807
|
+
const rightWrist = bodyPose.keypoints[10];
|
|
1808
|
+
const leftWrist = bodyPose.keypoints[9];
|
|
1809
|
+
|
|
1810
|
+
console.log(`[Combined Pose] Pose Type: "${this.combinedPoseType}"`);
|
|
1811
|
+
console.log(`[Combined Pose] Left wrist (9): score=${leftWrist?.score?.toFixed(3)}, pos=(${leftWrist?.x?.toFixed(0)}, ${leftWrist?.y?.toFixed(0)})`);
|
|
1812
|
+
console.log(`[Combined Pose] Right wrist (10): score=${rightWrist?.score?.toFixed(3)}, pos=(${rightWrist?.x?.toFixed(0)}, ${rightWrist?.y?.toFixed(0)})`);
|
|
1813
|
+
console.log(`[Combined Pose] Nose: pos=(${(nose.x * videoWidth).toFixed(0)}, ${(nose.y * videoHeight).toFixed(0)})`);
|
|
1814
|
+
console.log(`[Combined Pose] Video dimensions: ${videoWidth}x${videoHeight}`);
|
|
1815
|
+
|
|
1816
|
+
let matched = false;
|
|
1817
|
+
let message = '';
|
|
1818
|
+
|
|
1819
|
+
// Match based on combined pose type
|
|
1820
|
+
if (this.combinedPoseType.includes('touch-nose')) {
|
|
1821
|
+
// Determine which hand to check based on pose name
|
|
1822
|
+
const isRightHand = this.combinedPoseType.includes('right');
|
|
1823
|
+
const isLeftHand = this.combinedPoseType.includes('left');
|
|
1824
|
+
|
|
1825
|
+
// MoveNet keypoints: 9 = left wrist, 10 = right wrist
|
|
1826
|
+
// MoveNet reports from the PERSON'S perspective (not camera mirrored)
|
|
1827
|
+
// User's right hand → MoveNet right wrist (10)
|
|
1828
|
+
// User's left hand → MoveNet left wrist (9)
|
|
1829
|
+
let wrist;
|
|
1830
|
+
let handLabel;
|
|
1831
|
+
|
|
1832
|
+
if (isRightHand) {
|
|
1833
|
+
// User shows RIGHT hand → use rightWrist (10)
|
|
1834
|
+
wrist = rightWrist;
|
|
1835
|
+
handLabel = 'right';
|
|
1836
|
+
console.log(`[Touch Nose] Checking user's RIGHT hand (MoveNet rightWrist index 10)`);
|
|
1837
|
+
} else if (isLeftHand) {
|
|
1838
|
+
// User shows LEFT hand → use leftWrist (9)
|
|
1839
|
+
wrist = leftWrist;
|
|
1840
|
+
handLabel = 'left';
|
|
1841
|
+
console.log(`[Touch Nose] Checking user's LEFT hand (MoveNet leftWrist index 9)`);
|
|
1842
|
+
} else {
|
|
1843
|
+
// Default: check right wrist (user's right hand)
|
|
1844
|
+
wrist = rightWrist;
|
|
1845
|
+
handLabel = 'right';
|
|
1846
|
+
console.log(`[Touch Nose] Default: checking user's RIGHT hand (MoveNet rightWrist index 10)`);
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
console.log(`[Touch Nose] Expected ${handLabel} wrist detected: ${!!wrist}, Score: ${wrist?.score?.toFixed(3) || 'N/A'}, Position: (${wrist?.x?.toFixed(0)}, ${wrist?.y?.toFixed(0)})`);
|
|
1850
|
+
console.log(`[Touch Nose] Nose position: (${(nose.x * videoWidth).toFixed(0)}, ${(nose.y * videoHeight).toFixed(0)})`);
|
|
1851
|
+
|
|
1852
|
+
// STRICT MODE: Only use the hand specified in the pose name
|
|
1853
|
+
// Do NOT fall back to the other hand, even if it has better confidence
|
|
1854
|
+
// This ensures users use the correct hand as instructed
|
|
1855
|
+
|
|
1856
|
+
// Lowered threshold (0.10) - accommodates weaker MoveNet detection on one side
|
|
1857
|
+
if (wrist && wrist.score! > 0.10) {
|
|
1858
|
+
const dx = (wrist.x / videoWidth) - nose.x;
|
|
1859
|
+
const dy = (wrist.y / videoHeight) - nose.y;
|
|
1860
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
1861
|
+
|
|
1862
|
+
// Additional check: wrist should be BELOW nose (not above/behind)
|
|
1863
|
+
// When touching nose from front, wrist is below nose tip
|
|
1864
|
+
// If wrist is above nose (dy < -0.1), hand might be behind head
|
|
1865
|
+
const wristY = wrist.y / videoHeight;
|
|
1866
|
+
const isBelowOrNearNose = dy > -0.08; // Allow slight margin for natural hand position
|
|
1867
|
+
|
|
1868
|
+
console.log(`[Touch Nose] Using ${handLabel} wrist - Distance: ${distance.toFixed(4)} (threshold: 0.18), dy: ${dy.toFixed(4)}, belowNose: ${isBelowOrNearNose}`);
|
|
1869
|
+
|
|
1870
|
+
// Two conditions must be met:
|
|
1871
|
+
// 1. Distance close enough (< 0.18)
|
|
1872
|
+
// 2. Wrist not significantly above nose (prevents behind-head detection)
|
|
1873
|
+
const distanceCheck = distance < 0.18;
|
|
1874
|
+
matched = distanceCheck && isBelowOrNearNose;
|
|
1875
|
+
|
|
1876
|
+
if (!isBelowOrNearNose) {
|
|
1877
|
+
message = `Bring your ${handLabel} hand in FRONT of your face (not behind head)`;
|
|
1878
|
+
console.log(`[Touch Nose] Hand appears to be above/behind nose (dy=${dy.toFixed(3)})`);
|
|
1879
|
+
} else if (matched) {
|
|
1880
|
+
message = `Touching nose with ${handLabel} hand!`;
|
|
1881
|
+
} else if (distance < 0.25) {
|
|
1882
|
+
message = `Almost there! Move ${handLabel} hand closer (${(distance * 100).toFixed(0)}% away)`;
|
|
1883
|
+
} else if (distance < 0.40) {
|
|
1884
|
+
message = `Getting closer with ${handLabel} hand... (${(distance * 100).toFixed(0)}% away)`;
|
|
1885
|
+
} else {
|
|
1886
|
+
message = `Raise your ${handLabel} hand toward your nose`;
|
|
1887
|
+
}
|
|
1888
|
+
} else {
|
|
1889
|
+
message = `Use your ${handLabel} hand to touch your nose (hand not detected clearly)`;
|
|
1890
|
+
console.log(`[Touch Nose] ${handLabel} wrist not detected or confidence too low (need > 0.10, got ${wrist?.score?.toFixed(3) || 'N/A'})`);
|
|
1891
|
+
}
|
|
1892
|
+
} else if (this.combinedPoseType.includes('touch-chin')) {
|
|
1893
|
+
const chin = faceLandmarks[152]; // Chin landmark
|
|
1894
|
+
if (leftWrist && leftWrist.score! > 0.3) {
|
|
1895
|
+
const dx = (leftWrist.x / videoWidth) - chin.x;
|
|
1896
|
+
const dy = (leftWrist.y / videoHeight) - chin.y;
|
|
1897
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
1898
|
+
matched = distance < 0.1;
|
|
1899
|
+
message = matched ? 'Touching chin!' : 'Move your finger closer to your chin';
|
|
1900
|
+
} else {
|
|
1901
|
+
message = 'Raise your left hand to your chin';
|
|
1902
|
+
}
|
|
1903
|
+
} else if (this.combinedPoseType.includes('touch') && this.combinedPoseType.includes('cheek')) {
|
|
1904
|
+
// Determine which hand and which cheek based on pose name
|
|
1905
|
+
const isRightCheek = this.combinedPoseType.includes('right');
|
|
1906
|
+
const isLeftCheek = this.combinedPoseType.includes('left');
|
|
1907
|
+
|
|
1908
|
+
// MediaPipe face landmarks: left cheek ≈ 234, right cheek ≈ 454
|
|
1909
|
+
const leftCheek = faceLandmarks[234];
|
|
1910
|
+
const rightCheek = faceLandmarks[454];
|
|
1911
|
+
|
|
1912
|
+
let wrist;
|
|
1913
|
+
let cheek;
|
|
1914
|
+
let handLabel;
|
|
1915
|
+
|
|
1916
|
+
if (isRightCheek) {
|
|
1917
|
+
// Touch RIGHT cheek → use RIGHT hand (rightWrist index 10)
|
|
1918
|
+
wrist = rightWrist;
|
|
1919
|
+
cheek = rightCheek;
|
|
1920
|
+
handLabel = 'right';
|
|
1921
|
+
console.log(`[Touch Cheek] Checking user's RIGHT hand for RIGHT cheek`);
|
|
1922
|
+
} else if (isLeftCheek) {
|
|
1923
|
+
// Touch LEFT cheek → use LEFT hand (leftWrist index 9)
|
|
1924
|
+
wrist = leftWrist;
|
|
1925
|
+
cheek = leftCheek;
|
|
1926
|
+
handLabel = 'left';
|
|
1927
|
+
console.log(`[Touch Cheek] Checking user's LEFT hand for LEFT cheek`);
|
|
1928
|
+
} else {
|
|
1929
|
+
// Default: right cheek with right hand
|
|
1930
|
+
wrist = rightWrist;
|
|
1931
|
+
cheek = rightCheek;
|
|
1932
|
+
handLabel = 'right';
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
console.log(`[Touch Cheek] ${handLabel} wrist detected: ${!!wrist}, Score: ${wrist?.score?.toFixed(3) || 'N/A'}`);
|
|
1936
|
+
console.log(`[Touch Cheek] Cheek position: (${(cheek.x * videoWidth).toFixed(0)}, ${(cheek.y * videoHeight).toFixed(0)})`);
|
|
1937
|
+
|
|
1938
|
+
// Lower threshold (0.10) - same as touch nose for consistency
|
|
1939
|
+
if (wrist && wrist.score! > 0.10) {
|
|
1940
|
+
const dx = (wrist.x / videoWidth) - cheek.x;
|
|
1941
|
+
const dy = (wrist.y / videoHeight) - cheek.y;
|
|
1942
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
1943
|
+
|
|
1944
|
+
console.log(`[Touch Cheek] Using ${handLabel} wrist - Distance: ${distance.toFixed(4)} (threshold: 0.15)`);
|
|
1945
|
+
|
|
1946
|
+
// Cheek is wider area than nose, so slightly more lenient distance
|
|
1947
|
+
const distanceCheck = distance < 0.15;
|
|
1948
|
+
matched = distanceCheck;
|
|
1949
|
+
|
|
1950
|
+
if (matched) {
|
|
1951
|
+
message = `Touching ${handLabel} cheek!`;
|
|
1952
|
+
} else if (distance < 0.25) {
|
|
1953
|
+
message = `Almost there! Move ${handLabel} hand closer to ${handLabel} cheek (${(distance * 100).toFixed(0)}% away)`;
|
|
1954
|
+
} else {
|
|
1955
|
+
message = `Raise your ${handLabel} hand toward your ${handLabel} cheek`;
|
|
1956
|
+
}
|
|
1957
|
+
} else {
|
|
1958
|
+
message = `Use your ${handLabel} hand to touch your ${handLabel} cheek (hand not detected clearly)`;
|
|
1959
|
+
console.log(`[Touch Cheek] ${handLabel} wrist not detected or confidence too low (need > 0.10, got ${wrist?.score?.toFixed(3) || 'N/A'})`);
|
|
1960
|
+
}
|
|
1961
|
+
} else if (this.combinedPoseType.includes('cover') && this.combinedPoseType.includes('eye')) {
|
|
1962
|
+
// For covering eye, we need MediaPipe hand landmarks (palm position)
|
|
1963
|
+
// MoveNet wrist is too far from the actual covering position
|
|
1964
|
+
const handResult = await this.handGestureService.detectHands(videoElement);
|
|
1965
|
+
|
|
1966
|
+
// Determine which eye and hand based on pose name
|
|
1967
|
+
const isRightEye = this.combinedPoseType.includes('right');
|
|
1968
|
+
const isLeftEye = this.combinedPoseType.includes('left');
|
|
1969
|
+
|
|
1970
|
+
// MediaPipe face landmarks: left eye ≈ 33, right eye ≈ 263
|
|
1971
|
+
const leftEye = faceLandmarks[33];
|
|
1972
|
+
const rightEye = faceLandmarks[263];
|
|
1973
|
+
|
|
1974
|
+
let eye;
|
|
1975
|
+
let handLabel;
|
|
1976
|
+
let expectedHandedness;
|
|
1977
|
+
|
|
1978
|
+
if (isRightEye) {
|
|
1979
|
+
eye = rightEye;
|
|
1980
|
+
handLabel = 'right';
|
|
1981
|
+
expectedHandedness = 'Right';
|
|
1982
|
+
console.log(`[Cover Eye] Checking user's RIGHT hand for RIGHT eye`);
|
|
1983
|
+
} else if (isLeftEye) {
|
|
1984
|
+
eye = leftEye;
|
|
1985
|
+
handLabel = 'left';
|
|
1986
|
+
expectedHandedness = 'Left';
|
|
1987
|
+
console.log(`[Cover Eye] Checking user's LEFT hand for LEFT eye`);
|
|
1988
|
+
} else {
|
|
1989
|
+
// Default: right eye with right hand
|
|
1990
|
+
eye = rightEye;
|
|
1991
|
+
handLabel = 'right';
|
|
1992
|
+
expectedHandedness = 'Right';
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
console.log(`[Cover Eye] Eye position: (${(eye.x * videoWidth).toFixed(0)}, ${(eye.y * videoHeight).toFixed(0)})`);
|
|
1996
|
+
|
|
1997
|
+
// Check if correct hand is detected by MediaPipe
|
|
1998
|
+
let handDetected = false;
|
|
1999
|
+
let palmX = 0;
|
|
2000
|
+
let palmY = 0;
|
|
2001
|
+
|
|
2002
|
+
if (handResult && handResult.landmarks && handResult.landmarks.length > 0) {
|
|
2003
|
+
// Find the correct hand based on handedness
|
|
2004
|
+
for (let i = 0; i < handResult.landmarks.length; i++) {
|
|
2005
|
+
const handedness = handResult.handedness[i][0];
|
|
2006
|
+
console.log(`[Cover Eye] Hand ${i}: ${handedness.categoryName} (confidence: ${handedness.score.toFixed(3)})`);
|
|
2007
|
+
|
|
2008
|
+
if (handedness.categoryName === expectedHandedness && handedness.score > 0.5) {
|
|
2009
|
+
// Use palm center (landmark 0) for better accuracy
|
|
2010
|
+
const palmLandmark = handResult.landmarks[i][0];
|
|
2011
|
+
palmX = palmLandmark.x;
|
|
2012
|
+
palmY = palmLandmark.y;
|
|
2013
|
+
handDetected = true;
|
|
2014
|
+
console.log(`[Cover Eye] ${handLabel} hand palm detected at: (${(palmX * videoWidth).toFixed(0)}, ${(palmY * videoHeight).toFixed(0)})`);
|
|
2015
|
+
break;
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
if (handDetected) {
|
|
2021
|
+
const dx = palmX - eye.x;
|
|
2022
|
+
const dy = palmY - eye.y;
|
|
2023
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
2024
|
+
|
|
2025
|
+
console.log(`[Cover Eye] Using ${handLabel} hand palm - Distance: ${distance.toFixed(4)} (threshold: 0.15)`);
|
|
2026
|
+
|
|
2027
|
+
// More lenient distance since palm is being used instead of fingertips
|
|
2028
|
+
const distanceCheck = distance < 0.15;
|
|
2029
|
+
matched = distanceCheck;
|
|
2030
|
+
|
|
2031
|
+
if (matched) {
|
|
2032
|
+
message = `Covering ${handLabel} eye!`;
|
|
2033
|
+
} else if (distance < 0.25) {
|
|
2034
|
+
message = `Almost there! Move ${handLabel} hand closer to ${handLabel} eye (${(distance * 100).toFixed(0)}% away)`;
|
|
2035
|
+
} else {
|
|
2036
|
+
message = `Raise your ${handLabel} hand to cover your ${handLabel} eye`;
|
|
2037
|
+
}
|
|
2038
|
+
} else {
|
|
2039
|
+
message = `Use your ${handLabel} hand to cover your ${handLabel} eye (hand not detected clearly)`;
|
|
2040
|
+
console.log(`[Cover Eye] ${handLabel} hand not detected by MediaPipe`);
|
|
2041
|
+
}
|
|
2042
|
+
} else if (this.combinedPoseType.includes('cover') && this.combinedPoseType.includes('mouth')) {
|
|
2043
|
+
// Check if hand is near mouth level
|
|
2044
|
+
// Use mouth landmarks: 13 (upper lip), 14 (lower lip center)
|
|
2045
|
+
const upperLip = faceLandmarks[13];
|
|
2046
|
+
const lowerLip = faceLandmarks[14];
|
|
2047
|
+
const mouthCenterY = (upperLip.y + lowerLip.y) / 2;
|
|
2048
|
+
const mouthCenterX = (upperLip.x + lowerLip.x) / 2;
|
|
2049
|
+
const wrist = rightWrist || leftWrist;
|
|
2050
|
+
|
|
2051
|
+
if (wrist && wrist.score! > 0.3) {
|
|
2052
|
+
const dx = (wrist.x / videoWidth) - mouthCenterX;
|
|
2053
|
+
const dy = (wrist.y / videoHeight) - mouthCenterY;
|
|
2054
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
2055
|
+
|
|
2056
|
+
console.log(`[Combined Pose] Cover Mouth - Distance: ${distance.toFixed(3)}, Wrist: (${(wrist.x / videoWidth).toFixed(3)}, ${(wrist.y / videoHeight).toFixed(3)}), Mouth: (${mouthCenterX.toFixed(3)}, ${mouthCenterY.toFixed(3)})`);
|
|
2057
|
+
|
|
2058
|
+
matched = distance < 0.15; // Increased threshold for easier detection
|
|
2059
|
+
message = matched ? 'Covering mouth!' : `Move your hand closer to your mouth (${Math.round(distance * 100)}% away)`;
|
|
2060
|
+
} else {
|
|
2061
|
+
message = 'Raise your hand to cover your mouth';
|
|
2062
|
+
}
|
|
2063
|
+
} else if (this.combinedPoseType.includes('frame-face')) {
|
|
2064
|
+
// Check if both hands are near sides of face
|
|
2065
|
+
if (leftWrist && leftWrist.score! > 0.3 && rightWrist && rightWrist.score! > 0.3) {
|
|
2066
|
+
const faceX = nose.x;
|
|
2067
|
+
const leftX = leftWrist.x / videoWidth;
|
|
2068
|
+
const rightX = rightWrist.x / videoWidth;
|
|
2069
|
+
|
|
2070
|
+
// Hands should be on opposite sides of face
|
|
2071
|
+
const leftSide = leftX < faceX - 0.1;
|
|
2072
|
+
const rightSide = rightX > faceX + 0.1;
|
|
2073
|
+
const atFaceLevel = Math.abs((leftWrist.y + rightWrist.y) / 2 / videoHeight - nose.y) < 0.15;
|
|
2074
|
+
|
|
2075
|
+
matched = leftSide && rightSide && atFaceLevel;
|
|
2076
|
+
message = matched ? 'Framing face!' : 'Position both hands on sides of your face';
|
|
2077
|
+
} else {
|
|
2078
|
+
message = 'Raise both hands to frame your face';
|
|
2079
|
+
}
|
|
2080
|
+
} else {
|
|
2081
|
+
// Generic combined pose
|
|
2082
|
+
matched = rightWrist && rightWrist.score! > 0.3 && leftWrist && leftWrist.score! > 0.3;
|
|
2083
|
+
message = matched ? 'Pose detected!' : 'Show the required pose';
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
console.log(`[Combined Pose] Matched: ${matched}, Previously detected: ${this.combinedPoseDetected}`);
|
|
2087
|
+
|
|
2088
|
+
if (matched && !this.combinedPoseDetected) {
|
|
2089
|
+
this.combinedPoseDetected = true;
|
|
2090
|
+
console.log('[Combined Pose] ✓ COMBINED POSE MATCHED!');
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
const completed = this.combinedPoseDetected && matched;
|
|
2094
|
+
const progress = matched ? 1.0 : (bodyPose ? 0.5 : 0.2);
|
|
2095
|
+
|
|
2096
|
+
console.log(`[Combined Pose] Completed: ${completed}, Progress: ${progress}, Message: "${message}"`);
|
|
2097
|
+
|
|
2098
|
+
return {
|
|
2099
|
+
action: 'combined-pose' as LivenessAction,
|
|
2100
|
+
completed,
|
|
2101
|
+
progress,
|
|
2102
|
+
message: completed ? `Perfect! ${this.combinedPoseType} detected!` : message
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/**
|
|
2107
|
+
* Detect pose using static reference keypoints
|
|
2108
|
+
*/
|
|
2109
|
+
private async detectStaticPose(
|
|
2110
|
+
videoElement: HTMLVideoElement
|
|
2111
|
+
): Promise<LivenessActionResult> {
|
|
2112
|
+
console.log('[StaticPose] detectStaticPose called - starting detection');
|
|
2113
|
+
|
|
2114
|
+
// Detect current pose using OpenPose
|
|
2115
|
+
const currentPose = await this.openposeService.detectPose(videoElement);
|
|
2116
|
+
|
|
2117
|
+
if (!currentPose) {
|
|
2118
|
+
console.log('[StaticPose] No pose detected by OpenPose/MoveNet - currentPose is null');
|
|
2119
|
+
return {
|
|
2120
|
+
action: 'custom-pose' as LivenessAction,
|
|
2121
|
+
completed: false,
|
|
2122
|
+
progress: 0,
|
|
2123
|
+
message: 'No pose detected. Please position yourself in frame.'
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// Get video dimensions for normalization
|
|
2128
|
+
const videoWidth = videoElement.videoWidth || 640;
|
|
2129
|
+
const videoHeight = videoElement.videoHeight || 480;
|
|
2130
|
+
|
|
2131
|
+
console.log(`[StaticPose] Pose detected! Keypoints: ${currentPose.keypoints?.length || 0}, Score: ${currentPose.score?.toFixed(3) || 'N/A'}`);
|
|
2132
|
+
console.log(`[StaticPose] Video dimensions: ${videoWidth}x${videoHeight}`);
|
|
2133
|
+
|
|
2134
|
+
// Compare with static keypoints (pass video dimensions for normalization)
|
|
2135
|
+
const similarity = this.compareWithStaticKeypoints(currentPose, videoWidth, videoHeight);
|
|
2136
|
+
|
|
2137
|
+
console.log(`[StaticPose] Similarity: ${(similarity * 100).toFixed(1)}%`);
|
|
2138
|
+
|
|
2139
|
+
// Track completion (require 45% similarity - adjusted for real-world use)
|
|
2140
|
+
const isMatched = similarity >= 0.45;
|
|
2141
|
+
|
|
2142
|
+
if (isMatched && !this.customPoseDetected) {
|
|
2143
|
+
this.customPoseDetected = true;
|
|
2144
|
+
console.log('[StaticPose] ✓ POSE MATCHED!');
|
|
2145
|
+
} else if (!isMatched) {
|
|
2146
|
+
this.customPoseDetected = false;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
const completed = this.customPoseDetected && isMatched;
|
|
2150
|
+
const progress = similarity;
|
|
2151
|
+
|
|
2152
|
+
let message: string;
|
|
2153
|
+
if (completed) {
|
|
2154
|
+
message = 'Perfect! Pose matched!';
|
|
2155
|
+
} else if (similarity >= 0.40) {
|
|
2156
|
+
message = `Almost there! ${Math.round(similarity * 100)}% - Hold steady`;
|
|
2157
|
+
} else if (similarity >= 0.30) {
|
|
2158
|
+
message = `Getting closer... ${Math.round(similarity * 100)}% - Adjust slightly`;
|
|
2159
|
+
} else if (similarity >= 0.15) {
|
|
2160
|
+
message = `Raise your hand to shoulder level - ${Math.round(similarity * 100)}%`;
|
|
2161
|
+
} else {
|
|
2162
|
+
message = 'Position your hand as shown in the reference image';
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
return {
|
|
2166
|
+
action: 'custom-pose' as LivenessAction,
|
|
2167
|
+
completed,
|
|
2168
|
+
progress,
|
|
2169
|
+
message
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
/**
|
|
2174
|
+
* Compare current pose keypoints with static reference keypoints
|
|
2175
|
+
* @param currentPose - Pose detected by MoveNet (pixel coordinates)
|
|
2176
|
+
* @param videoWidth - Video width for normalization
|
|
2177
|
+
* @param videoHeight - Video height for normalization
|
|
2178
|
+
*/
|
|
2179
|
+
private compareWithStaticKeypoints(currentPose: any, videoWidth: number, videoHeight: number): number {
|
|
2180
|
+
if (!this.staticPoseKeypoints || this.staticPoseKeypoints.length === 0) {
|
|
2181
|
+
console.log('[StaticPose] No static keypoints available for comparison');
|
|
2182
|
+
return 0;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
console.log(`[StaticPose] Comparing ${currentPose.keypoints.length} detected keypoints with ${this.staticPoseKeypoints.length} reference keypoints`);
|
|
2186
|
+
|
|
2187
|
+
let totalScore = 0;
|
|
2188
|
+
let totalWeight = 0;
|
|
2189
|
+
let detectedCount = 0;
|
|
2190
|
+
let skippedCount = 0;
|
|
2191
|
+
|
|
2192
|
+
// Compare each keypoint
|
|
2193
|
+
for (let i = 0; i < Math.min(currentPose.keypoints.length, this.staticPoseKeypoints.length); i++) {
|
|
2194
|
+
const currentKp = currentPose.keypoints[i];
|
|
2195
|
+
const referenceKp = this.staticPoseKeypoints[i];
|
|
2196
|
+
|
|
2197
|
+
// Skip keypoints with low confidence in reference
|
|
2198
|
+
if (referenceKp.confidence < 0.1) {
|
|
2199
|
+
skippedCount++;
|
|
2200
|
+
continue;
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// Check if current keypoint is detected (MoveNet uses 'score')
|
|
2204
|
+
if (!currentKp || currentKp.score < 0.1) {
|
|
2205
|
+
// Keypoint not detected - penalize based on reference confidence
|
|
2206
|
+
totalWeight += referenceKp.confidence;
|
|
2207
|
+
continue;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
detectedCount++;
|
|
2211
|
+
|
|
2212
|
+
// Normalize pixel coordinates to 0-1 range
|
|
2213
|
+
// MoveNet returns pixel coordinates, but our reference uses normalized 0-1
|
|
2214
|
+
const normalizedX = currentKp.x / videoWidth;
|
|
2215
|
+
const normalizedY = currentKp.y / videoHeight;
|
|
2216
|
+
|
|
2217
|
+
// Calculate distance between normalized keypoints
|
|
2218
|
+
const dx = normalizedX - referenceKp.x;
|
|
2219
|
+
const dy = normalizedY - referenceKp.y;
|
|
2220
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
2221
|
+
|
|
2222
|
+
// Convert distance to similarity score (0-1)
|
|
2223
|
+
// Distance threshold: 0.25 (25% of normalized space) - more forgiving for real-world use
|
|
2224
|
+
const maxDist = 0.25;
|
|
2225
|
+
const kpSimilarity = Math.max(0, 1 - (distance / maxDist));
|
|
2226
|
+
|
|
2227
|
+
// Weight by reference confidence
|
|
2228
|
+
const weight = referenceKp.confidence;
|
|
2229
|
+
totalScore += kpSimilarity * weight;
|
|
2230
|
+
totalWeight += weight;
|
|
2231
|
+
|
|
2232
|
+
// Log important keypoints (right wrist for Right Palm pose)
|
|
2233
|
+
if (i === 10 && referenceKp.confidence >= 1.0) {
|
|
2234
|
+
console.log(`[StaticPose] Key keypoint #${i} (right_wrist):`);
|
|
2235
|
+
console.log(` Detected (pixel): (${currentKp.x.toFixed(1)}, ${currentKp.y.toFixed(1)})`);
|
|
2236
|
+
console.log(` Detected (normalized): (${normalizedX.toFixed(3)}, ${normalizedY.toFixed(3)})`);
|
|
2237
|
+
console.log(` Reference (normalized): (${referenceKp.x.toFixed(3)}, ${referenceKp.y.toFixed(3)})`);
|
|
2238
|
+
console.log(` Distance: ${distance.toFixed(3)}, Similarity: ${kpSimilarity.toFixed(3)}`);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
const finalScore = totalWeight > 0 ? totalScore / totalWeight : 0;
|
|
2243
|
+
console.log(`[StaticPose] Comparison: ${detectedCount} keypoints detected, ${skippedCount} skipped, total weight: ${totalWeight.toFixed(2)}, final score: ${finalScore.toFixed(3)}`);
|
|
2244
|
+
|
|
2245
|
+
return finalScore;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
/**
|
|
2249
|
+
* Detect custom reference pose (using static keypoints or uploaded reference)
|
|
2250
|
+
*/
|
|
2251
|
+
private async detectCustomPose(
|
|
2252
|
+
videoElement: HTMLVideoElement
|
|
2253
|
+
): Promise<LivenessActionResult> {
|
|
2254
|
+
// Check for static keypoints first (from predefined poses)
|
|
2255
|
+
if (this.staticPoseKeypoints && this.staticPoseKeypoints.length > 0) {
|
|
2256
|
+
return await this.detectStaticPose(videoElement);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
// Fallback to uploaded reference pose
|
|
2260
|
+
const referencePose = this.referencePoseService.getReferencePose();
|
|
2261
|
+
|
|
2262
|
+
if (!referencePose) {
|
|
2263
|
+
return {
|
|
2264
|
+
action: 'custom-pose' as LivenessAction,
|
|
2265
|
+
completed: false,
|
|
2266
|
+
progress: 0,
|
|
2267
|
+
message: 'No reference pose loaded'
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// Detect current pose using OpenPose
|
|
2272
|
+
const currentPose = await this.openposeService.detectPose(videoElement);
|
|
2273
|
+
|
|
2274
|
+
if (!currentPose) {
|
|
2275
|
+
return {
|
|
2276
|
+
action: 'custom-pose' as LivenessAction,
|
|
2277
|
+
completed: false,
|
|
2278
|
+
progress: 0,
|
|
2279
|
+
message: 'No pose detected. Please position yourself in frame.'
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
// Compare poses
|
|
2284
|
+
const comparisonResult = this.poseComparisonService.comparePoses(
|
|
2285
|
+
currentPose,
|
|
2286
|
+
referencePose
|
|
2287
|
+
);
|
|
2288
|
+
|
|
2289
|
+
console.log(`[CustomPose] Similarity: ${(comparisonResult.overallScore * 100).toFixed(1)}%, Matched: ${comparisonResult.matched}`);
|
|
2290
|
+
|
|
2291
|
+
// Track completion
|
|
2292
|
+
if (comparisonResult.matched && !this.customPoseDetected) {
|
|
2293
|
+
this.customPoseDetected = true;
|
|
2294
|
+
console.log('[CustomPose] ✓ CUSTOM POSE MATCHED!');
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
const completed = this.customPoseDetected && comparisonResult.matched;
|
|
2298
|
+
|
|
2299
|
+
return {
|
|
2300
|
+
action: 'custom-pose' as LivenessAction,
|
|
2301
|
+
completed,
|
|
2302
|
+
progress: comparisonResult.overallScore,
|
|
2303
|
+
message: comparisonResult.feedback
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
/**
|
|
2308
|
+
* Reset action detection state
|
|
2309
|
+
*/
|
|
2310
|
+
resetActionState(): void {
|
|
2311
|
+
this.blinkDetected = false;
|
|
2312
|
+
this.blinkCount = 0;
|
|
2313
|
+
this.lastBlinkTime = 0;
|
|
2314
|
+
this.maxEAR = 0;
|
|
2315
|
+
this.minEARDuringBlink = Infinity;
|
|
2316
|
+
this.frameCount = 0;
|
|
2317
|
+
this.initialPitch = null;
|
|
2318
|
+
this.nodDirection = null;
|
|
2319
|
+
this.initialYaw = null;
|
|
2320
|
+
this.shakeDirection = null;
|
|
2321
|
+
this.shakeCount = 0;
|
|
2322
|
+
this.smileDetected = false;
|
|
2323
|
+
this.openMouthDetected = false;
|
|
2324
|
+
this.eyebrowsRaisedDetected = false;
|
|
2325
|
+
this.winkDetected = false;
|
|
2326
|
+
this.followDotMovements = 0;
|
|
2327
|
+
this.lastGazeDirection = null;
|
|
2328
|
+
this.mouthMovementCount = 0;
|
|
2329
|
+
this.lastMouthOpenness = 0;
|
|
2330
|
+
this.smileThumbsUpDetected = false;
|
|
2331
|
+
this.thumbsUpDetected = false;
|
|
2332
|
+
this.customPoseDetected = false;
|
|
2333
|
+
this.handGestureDetected = false;
|
|
2334
|
+
this.expectedGesture = null;
|
|
2335
|
+
this.expectedHandedness = 'Any';
|
|
2336
|
+
// Reset rotate face state
|
|
2337
|
+
this.rotationSequence = [];
|
|
2338
|
+
this.lastRotationYaw = 0;
|
|
2339
|
+
this.rotationStage = 'center';
|
|
2340
|
+
this.rotateFaceDetected = false;
|
|
2341
|
+
// Reset movement gesture state
|
|
2342
|
+
this.waveDetected = false;
|
|
2343
|
+
this.waveDirection = null;
|
|
2344
|
+
this.previousWristX = null;
|
|
2345
|
+
this.waveCount = 0;
|
|
2346
|
+
this.clapDetected = false;
|
|
2347
|
+
this.handsApart = false;
|
|
2348
|
+
this.crossArmsDetected = false;
|
|
2349
|
+
this.pointDetected = false;
|
|
2350
|
+
this.pointedLeft = false;
|
|
2351
|
+
this.pointedRight = false;
|
|
2352
|
+
// Reset combined pose state
|
|
2353
|
+
this.combinedPoseDetected = false;
|
|
2354
|
+
this.combinedPoseType = null;
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
/**
|
|
2358
|
+
* Cleanup resources
|
|
2359
|
+
*/
|
|
2360
|
+
cleanup(): void {
|
|
2361
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
2362
|
+
this.resetActionState();
|
|
2363
|
+
|
|
2364
|
+
if (this.faceLandmarker) {
|
|
2365
|
+
this.faceLandmarker.close();
|
|
2366
|
+
this.faceLandmarker = null;
|
|
2367
|
+
this.modelsLoaded = false;
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
}
|