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.
Files changed (229) hide show
  1. package/.browserslistrc +15 -0
  2. package/.dockerignore +48 -0
  3. package/.editorconfig +16 -0
  4. package/.eslintrc.json +47 -0
  5. package/.vercelignore +7 -0
  6. package/.vscode/extensions.json +5 -0
  7. package/.vscode/settings.json +3 -0
  8. package/DOCKER.md +221 -0
  9. package/Dockerfile +33 -0
  10. package/README.md +268 -0
  11. package/angular.json +156 -0
  12. package/capacitor.config.ts +9 -0
  13. package/docker-compose.dev.yml +20 -0
  14. package/docker-compose.yml +18 -0
  15. package/ionic.config.json +7 -0
  16. package/jest.config.js +38 -0
  17. package/nginx.conf +50 -0
  18. package/package.json +131 -0
  19. package/patches/ng-packagr+20.3.2.patch +60 -0
  20. package/projects/facecog-liveness-verification/README.md +295 -0
  21. package/projects/facecog-liveness-verification/ng-package.json +7 -0
  22. package/projects/facecog-liveness-verification/package.json +48 -0
  23. package/projects/facecog-liveness-verification/scripts/build-with-wrapper-copy.js +38 -0
  24. package/projects/facecog-liveness-verification/scripts/copy-wrapper-after-ngc.js +35 -0
  25. package/projects/facecog-liveness-verification/sources/FaceLivenessReactWrapper.tsx +320 -0
  26. package/projects/facecog-liveness-verification/src/lib/components/aws-face-liveness/FaceLivenessReactWrapper.generated.d.ts +28 -0
  27. package/projects/facecog-liveness-verification/src/lib/components/aws-face-liveness/FaceLivenessReactWrapper.generated.js +247 -0
  28. package/projects/facecog-liveness-verification/src/lib/components/aws-face-liveness/FaceLivenessReactWrapper.generated.js.map +1 -0
  29. package/projects/facecog-liveness-verification/src/lib/components/aws-face-liveness/FaceLivenessReactWrapper.js.map +1 -0
  30. package/projects/facecog-liveness-verification/src/lib/components/aws-face-liveness/FaceLivenessReactWrapper.ts +5 -0
  31. package/projects/facecog-liveness-verification/src/lib/components/aws-face-liveness/aws-face-liveness.component.ts +500 -0
  32. package/projects/facecog-liveness-verification/src/lib/components/camera-permission/camera-permission.component.html +41 -0
  33. package/projects/facecog-liveness-verification/src/lib/components/camera-permission/camera-permission.component.scss +234 -0
  34. package/projects/facecog-liveness-verification/src/lib/components/camera-permission/camera-permission.component.spec.ts +158 -0
  35. package/projects/facecog-liveness-verification/src/lib/components/camera-permission/camera-permission.component.ts +58 -0
  36. package/projects/facecog-liveness-verification/src/lib/components/camera-verification/camera-verification.component.html +34 -0
  37. package/projects/facecog-liveness-verification/src/lib/components/camera-verification/camera-verification.component.ts +210 -0
  38. package/projects/facecog-liveness-verification/src/lib/components/dialogs/save-custom-pose-dialog.component.ts +174 -0
  39. package/projects/facecog-liveness-verification/src/lib/components/facetec-scan/facetec-scan.component.html +45 -0
  40. package/projects/facecog-liveness-verification/src/lib/components/facetec-scan/facetec-scan.component.scss +87 -0
  41. package/projects/facecog-liveness-verification/src/lib/components/facetec-scan/facetec-scan.component.ts +182 -0
  42. package/projects/facecog-liveness-verification/src/lib/components/intro/intro.component.html +394 -0
  43. package/projects/facecog-liveness-verification/src/lib/components/intro/intro.component.scss +1567 -0
  44. package/projects/facecog-liveness-verification/src/lib/components/intro/intro.component.spec.ts +699 -0
  45. package/projects/facecog-liveness-verification/src/lib/components/intro/intro.component.ts +721 -0
  46. package/projects/facecog-liveness-verification/src/lib/components/live-preview/live-preview.component.html +120 -0
  47. package/projects/facecog-liveness-verification/src/lib/components/live-preview/live-preview.component.scss +611 -0
  48. package/projects/facecog-liveness-verification/src/lib/components/live-preview/live-preview.component.spec.ts +605 -0
  49. package/projects/facecog-liveness-verification/src/lib/components/live-preview/live-preview.component.ts +524 -0
  50. package/projects/facecog-liveness-verification/src/lib/components/liveness-flow/liveness-flow.component.html +73 -0
  51. package/projects/facecog-liveness-verification/src/lib/components/liveness-flow/liveness-flow.component.scss +19 -0
  52. package/projects/facecog-liveness-verification/src/lib/components/liveness-flow/liveness-flow.component.spec.ts +673 -0
  53. package/projects/facecog-liveness-verification/src/lib/components/liveness-flow/liveness-flow.component.ts +963 -0
  54. package/projects/facecog-liveness-verification/src/lib/components/liveness-verification/liveness-verification.component.html +38 -0
  55. package/projects/facecog-liveness-verification/src/lib/components/liveness-verification/liveness-verification.component.scss +10 -0
  56. package/projects/facecog-liveness-verification/src/lib/components/liveness-verification/liveness-verification.component.ts +233 -0
  57. package/projects/facecog-liveness-verification/src/lib/components/pose-selection/pose-selection.component.html +17 -0
  58. package/projects/facecog-liveness-verification/src/lib/components/pose-selection/pose-selection.component.spec.ts +35 -0
  59. package/projects/facecog-liveness-verification/src/lib/components/pose-selection/pose-selection.component.ts +33 -0
  60. package/projects/facecog-liveness-verification/src/lib/components/processing/processing.component.html +17 -0
  61. package/projects/facecog-liveness-verification/src/lib/components/processing/processing.component.scss +156 -0
  62. package/projects/facecog-liveness-verification/src/lib/components/processing/processing.component.spec.ts +46 -0
  63. package/projects/facecog-liveness-verification/src/lib/components/processing/processing.component.ts +18 -0
  64. package/projects/facecog-liveness-verification/src/lib/components/verification-result/verification-result.component.html +190 -0
  65. package/projects/facecog-liveness-verification/src/lib/components/verification-result/verification-result.component.scss +534 -0
  66. package/projects/facecog-liveness-verification/src/lib/components/verification-result/verification-result.component.spec.ts +286 -0
  67. package/projects/facecog-liveness-verification/src/lib/components/verification-result/verification-result.component.ts +155 -0
  68. package/projects/facecog-liveness-verification/src/lib/interfaces/analyze-response.interface.ts +16 -0
  69. package/projects/facecog-liveness-verification/src/lib/interfaces/aws-face-liveness.interface.ts +46 -0
  70. package/projects/facecog-liveness-verification/src/lib/interfaces/backend-adapter.interface.ts +21 -0
  71. package/projects/facecog-liveness-verification/src/lib/interfaces/backend-http-client.interface.ts +93 -0
  72. package/projects/facecog-liveness-verification/src/lib/interfaces/backend-response.interface.ts +9 -0
  73. package/projects/facecog-liveness-verification/src/lib/interfaces/camera-provider.interface.ts +107 -0
  74. package/projects/facecog-liveness-verification/src/lib/interfaces/category-info.interface.ts +9 -0
  75. package/projects/facecog-liveness-verification/src/lib/interfaces/custom-pose-data.interface.ts +14 -0
  76. package/projects/facecog-liveness-verification/src/lib/interfaces/custom-pose-repository.interface.ts +48 -0
  77. package/projects/facecog-liveness-verification/src/lib/interfaces/custom-pose-response.interface.ts +14 -0
  78. package/projects/facecog-liveness-verification/src/lib/interfaces/index.ts +52 -0
  79. package/projects/facecog-liveness-verification/src/lib/interfaces/liveness-action-result.interface.ts +13 -0
  80. package/projects/facecog-liveness-verification/src/lib/interfaces/liveness-config.interface.ts +17 -0
  81. package/projects/facecog-liveness-verification/src/lib/interfaces/liveness-metadata.interface.ts +17 -0
  82. package/projects/facecog-liveness-verification/src/lib/interfaces/liveness-result.interface.ts +24 -0
  83. package/projects/facecog-liveness-verification/src/lib/interfaces/liveness-verification-config.interface.ts +41 -0
  84. package/projects/facecog-liveness-verification/src/lib/interfaces/multi-backend-analyze-response.interface.ts +21 -0
  85. package/projects/facecog-liveness-verification/src/lib/interfaces/multi-backend-liveness-result.interface.ts +14 -0
  86. package/projects/facecog-liveness-verification/src/lib/interfaces/pose-definition.interface.ts +35 -0
  87. package/projects/facecog-liveness-verification/src/lib/interfaces/pose-keypoint.interface.ts +12 -0
  88. package/projects/facecog-liveness-verification/src/lib/interfaces/pose-match-result.interface.ts +9 -0
  89. package/projects/facecog-liveness-verification/src/lib/interfaces/pose-verify-response.interface.ts +8 -0
  90. package/projects/facecog-liveness-verification/src/lib/interfaces/scan-results.interface.ts +29 -0
  91. package/projects/facecog-liveness-verification/src/lib/interfaces/verification-plan.interface.ts +42 -0
  92. package/projects/facecog-liveness-verification/src/lib/interfaces/verification-progress-event.interface.ts +12 -0
  93. package/projects/facecog-liveness-verification/src/lib/interfaces/verification-session.interface.ts +72 -0
  94. package/projects/facecog-liveness-verification/src/lib/interfaces/verification-step-change-event.interface.ts +11 -0
  95. package/projects/facecog-liveness-verification/src/lib/interfaces/video-recording.interface.ts +9 -0
  96. package/projects/facecog-liveness-verification/src/lib/liveness-verification.module.ts +123 -0
  97. package/projects/facecog-liveness-verification/src/lib/models/constants/aws-face-liveness-component.token.ts +23 -0
  98. package/projects/facecog-liveness-verification/src/lib/models/constants/category-info.constant.ts +14 -0
  99. package/projects/facecog-liveness-verification/src/lib/models/constants/default-liveness-config.constant.ts +18 -0
  100. package/projects/facecog-liveness-verification/src/lib/models/constants/index.ts +5 -0
  101. package/projects/facecog-liveness-verification/src/lib/models/constants/liveness-verification-config.token.ts +16 -0
  102. package/projects/facecog-liveness-verification/src/lib/models/constants/pose-definitions.constant.ts +377 -0
  103. package/projects/facecog-liveness-verification/src/lib/models/index.ts +5 -0
  104. package/projects/facecog-liveness-verification/src/lib/models/utils/index.ts +2 -0
  105. package/projects/facecog-liveness-verification/src/lib/models/utils/pose.utils.spec.ts +76 -0
  106. package/projects/facecog-liveness-verification/src/lib/models/utils/pose.utils.ts +59 -0
  107. package/projects/facecog-liveness-verification/src/lib/services/aws-face-liveness.service.ts +49 -0
  108. package/projects/facecog-liveness-verification/src/lib/services/backend-http.service.spec.ts +111 -0
  109. package/projects/facecog-liveness-verification/src/lib/services/backend-http.service.ts +130 -0
  110. package/projects/facecog-liveness-verification/src/lib/services/backends/azure-backend.service.spec.ts +69 -0
  111. package/projects/facecog-liveness-verification/src/lib/services/backends/azure-backend.service.ts +72 -0
  112. package/projects/facecog-liveness-verification/src/lib/services/backends/facetec-backend.service.spec.ts +24 -0
  113. package/projects/facecog-liveness-verification/src/lib/services/backends/facetec-backend.service.ts +35 -0
  114. package/projects/facecog-liveness-verification/src/lib/services/backends/mock-backend.service.spec.ts +36 -0
  115. package/projects/facecog-liveness-verification/src/lib/services/backends/mock-backend.service.ts +39 -0
  116. package/projects/facecog-liveness-verification/src/lib/services/backends/openpose-backend.service.spec.ts +81 -0
  117. package/projects/facecog-liveness-verification/src/lib/services/backends/openpose-backend.service.ts +72 -0
  118. package/projects/facecog-liveness-verification/src/lib/services/backends/rekognition-analysis-backend.service.spec.ts +69 -0
  119. package/projects/facecog-liveness-verification/src/lib/services/backends/rekognition-analysis-backend.service.ts +83 -0
  120. package/projects/facecog-liveness-verification/src/lib/services/camera.service.spec.ts +200 -0
  121. package/projects/facecog-liveness-verification/src/lib/services/camera.service.ts +155 -0
  122. package/projects/facecog-liveness-verification/src/lib/services/custom-poses-api.service.ts +117 -0
  123. package/projects/facecog-liveness-verification/src/lib/services/index.ts +18 -0
  124. package/projects/facecog-liveness-verification/src/lib/services/liveness-backend.service.spec.ts +103 -0
  125. package/projects/facecog-liveness-verification/src/lib/services/liveness-backend.service.ts +61 -0
  126. package/projects/facecog-liveness-verification/src/lib/services/liveness-config.service.spec.ts +109 -0
  127. package/projects/facecog-liveness-verification/src/lib/services/liveness-config.service.ts +70 -0
  128. package/projects/facecog-liveness-verification/src/lib/services/liveness-orchestrator.service.spec.ts +144 -0
  129. package/projects/facecog-liveness-verification/src/lib/services/liveness-orchestrator.service.ts +162 -0
  130. package/projects/facecog-liveness-verification/src/lib/services/pose-detection/hand-gesture-detection.service.ts +315 -0
  131. package/projects/facecog-liveness-verification/src/lib/services/pose-detection/index.ts +5 -0
  132. package/projects/facecog-liveness-verification/src/lib/services/pose-detection/openpose.service.ts +287 -0
  133. package/projects/facecog-liveness-verification/src/lib/services/pose-detection/pose-comparison.service.ts +353 -0
  134. package/projects/facecog-liveness-verification/src/lib/services/pose-detection/pose-matching.service.ts +2370 -0
  135. package/projects/facecog-liveness-verification/src/lib/services/pose-detection/reference-pose.service.ts +271 -0
  136. package/projects/facecog-liveness-verification/src/lib/services/pose-selection.service.spec.ts +183 -0
  137. package/projects/facecog-liveness-verification/src/lib/services/pose-selection.service.ts +179 -0
  138. package/projects/facecog-liveness-verification/src/lib/services/verification-api.service.spec.ts +159 -0
  139. package/projects/facecog-liveness-verification/src/lib/services/verification-api.service.ts +151 -0
  140. package/projects/facecog-liveness-verification/src/lib/services/verification-plan.service.spec.ts +184 -0
  141. package/projects/facecog-liveness-verification/src/lib/services/verification-plan.service.ts +94 -0
  142. package/projects/facecog-liveness-verification/src/lib/services/video-recorder.service.spec.ts +52 -0
  143. package/projects/facecog-liveness-verification/src/lib/services/video-recorder.service.ts +117 -0
  144. package/projects/facecog-liveness-verification/src/lib/types/detection-strategy.type.ts +5 -0
  145. package/projects/facecog-liveness-verification/src/lib/types/index.ts +7 -0
  146. package/projects/facecog-liveness-verification/src/lib/types/liveness-action.type.ts +31 -0
  147. package/projects/facecog-liveness-verification/src/lib/types/liveness-backend.type.ts +5 -0
  148. package/projects/facecog-liveness-verification/src/lib/types/pose-category.type.ts +5 -0
  149. package/projects/facecog-liveness-verification/src/lib/types/pose-difficulty.type.ts +5 -0
  150. package/projects/facecog-liveness-verification/src/lib/types/verification-flow-step.type.ts +5 -0
  151. package/projects/facecog-liveness-verification/src/lib/types/verification-step-kind.type.ts +4 -0
  152. package/projects/facecog-liveness-verification/src/public-api.ts +150 -0
  153. package/projects/facecog-liveness-verification/tsconfig.lib.json +20 -0
  154. package/projects/facecog-liveness-verification/tsconfig.lib.prod.json +11 -0
  155. package/projects/facecog-liveness-verification/tsconfig.spec.json +13 -0
  156. package/projects/facecog-liveness-verification/tsconfig.wrapper.json +15 -0
  157. package/projects/facecog-liveness-verification-test/src/app/app-routing.module.ts +22 -0
  158. package/projects/facecog-liveness-verification-test/src/app/app.component.html +3 -0
  159. package/projects/facecog-liveness-verification-test/src/app/app.component.scss +0 -0
  160. package/projects/facecog-liveness-verification-test/src/app/app.component.ts +11 -0
  161. package/projects/facecog-liveness-verification-test/src/app/app.module.ts +27 -0
  162. package/projects/facecog-liveness-verification-test/src/app/home/home-routing.module.ts +16 -0
  163. package/projects/facecog-liveness-verification-test/src/app/home/home.module.ts +19 -0
  164. package/projects/facecog-liveness-verification-test/src/app/home/home.page.html +39 -0
  165. package/projects/facecog-liveness-verification-test/src/app/home/home.page.scss +97 -0
  166. package/projects/facecog-liveness-verification-test/src/app/home/home.page.spec.ts +24 -0
  167. package/projects/facecog-liveness-verification-test/src/app/home/home.page.ts +92 -0
  168. package/projects/facecog-liveness-verification-test/src/app/home/verification-modal.component.ts +106 -0
  169. package/projects/facecog-liveness-verification-test/src/assets/fonts/gilroy/Gilroy-Bold_0.ttf +0 -0
  170. package/projects/facecog-liveness-verification-test/src/assets/fonts/gilroy/Gilroy-Medium_0.ttf +0 -0
  171. package/projects/facecog-liveness-verification-test/src/assets/fonts/gilroy/Gilroy-Regular_0.ttf +0 -0
  172. package/projects/facecog-liveness-verification-test/src/assets/fonts/gilroy/Gilroy-SemiBold_0.ttf +0 -0
  173. package/projects/facecog-liveness-verification-test/src/assets/fonts/gilroy/Gilroy-Thin_0.ttf +0 -0
  174. package/projects/facecog-liveness-verification-test/src/assets/icon/favicon.png +0 -0
  175. package/projects/facecog-liveness-verification-test/src/assets/images/poses/Five_Fingers_Left.jpg +0 -0
  176. package/projects/facecog-liveness-verification-test/src/assets/images/poses/Left_Palm.jpg +0 -0
  177. package/projects/facecog-liveness-verification-test/src/assets/images/poses/Ok_Sign_Right.jpg +0 -0
  178. package/projects/facecog-liveness-verification-test/src/assets/images/poses/Peace_Sign_Left.jpg +0 -0
  179. package/projects/facecog-liveness-verification-test/src/assets/images/poses/README.md +77 -0
  180. package/projects/facecog-liveness-verification-test/src/assets/images/poses/Right_Palm.jpg +0 -0
  181. package/projects/facecog-liveness-verification-test/src/assets/images/poses/Speak_Phrase.jpg +0 -0
  182. package/projects/facecog-liveness-verification-test/src/assets/images/poses/Three_Fingers_Right.jpg +0 -0
  183. package/projects/facecog-liveness-verification-test/src/assets/images/poses/Thumbs_Up_Left.jpg +0 -0
  184. package/projects/facecog-liveness-verification-test/src/assets/images/poses/Thumbs_Up_Right.jpg +0 -0
  185. package/projects/facecog-liveness-verification-test/src/assets/images/poses/Wave_Right.jpg +0 -0
  186. package/projects/facecog-liveness-verification-test/src/assets/images/poses/blink.jpeg +0 -0
  187. package/projects/facecog-liveness-verification-test/src/assets/images/poses/blink_twice.jpeg +0 -0
  188. package/projects/facecog-liveness-verification-test/src/assets/images/poses/center_face.png +0 -0
  189. package/projects/facecog-liveness-verification-test/src/assets/images/poses/clap.jpeg +0 -0
  190. package/projects/facecog-liveness-verification-test/src/assets/images/poses/cover_mouth.png +0 -0
  191. package/projects/facecog-liveness-verification-test/src/assets/images/poses/cover_right_eye.png +0 -0
  192. package/projects/facecog-liveness-verification-test/src/assets/images/poses/cross_arms.png +0 -0
  193. package/projects/facecog-liveness-verification-test/src/assets/images/poses/face_straight.png +0 -0
  194. package/projects/facecog-liveness-verification-test/src/assets/images/poses/follow_dot.png +0 -0
  195. package/projects/facecog-liveness-verification-test/src/assets/images/poses/look_down.png +0 -0
  196. package/projects/facecog-liveness-verification-test/src/assets/images/poses/look_up.png +0 -0
  197. package/projects/facecog-liveness-verification-test/src/assets/images/poses/move_closer.png +0 -0
  198. package/projects/facecog-liveness-verification-test/src/assets/images/poses/nod.png +0 -0
  199. package/projects/facecog-liveness-verification-test/src/assets/images/poses/open_mouth.png +0 -0
  200. package/projects/facecog-liveness-verification-test/src/assets/images/poses/raise_eyebrow.png +0 -0
  201. package/projects/facecog-liveness-verification-test/src/assets/images/poses/rotate_face.jpeg +0 -0
  202. package/projects/facecog-liveness-verification-test/src/assets/images/poses/shake_head.jpeg +0 -0
  203. package/projects/facecog-liveness-verification-test/src/assets/images/poses/smile.png +0 -0
  204. package/projects/facecog-liveness-verification-test/src/assets/images/poses/tilt_left.png +0 -0
  205. package/projects/facecog-liveness-verification-test/src/assets/images/poses/tilt_right.png +0 -0
  206. package/projects/facecog-liveness-verification-test/src/assets/images/poses/touch_chin_left.jpg +0 -0
  207. package/projects/facecog-liveness-verification-test/src/assets/images/poses/touch_left_cheek.jpeg +0 -0
  208. package/projects/facecog-liveness-verification-test/src/assets/images/poses/touch_nose_right.png +0 -0
  209. package/projects/facecog-liveness-verification-test/src/assets/images/poses/touch_right_cheek.jpeg +0 -0
  210. package/projects/facecog-liveness-verification-test/src/assets/images/poses/turn_left.png +0 -0
  211. package/projects/facecog-liveness-verification-test/src/assets/images/poses/turn_right.png +0 -0
  212. package/projects/facecog-liveness-verification-test/src/assets/images/poses/wink.jpeg +0 -0
  213. package/projects/facecog-liveness-verification-test/src/assets/images/reference-pose.jpg +0 -0
  214. package/projects/facecog-liveness-verification-test/src/assets/shapes.svg +1 -0
  215. package/projects/facecog-liveness-verification-test/src/environments/environment.prod.ts +4 -0
  216. package/projects/facecog-liveness-verification-test/src/environments/environment.ts +17 -0
  217. package/projects/facecog-liveness-verification-test/src/global.scss +288 -0
  218. package/projects/facecog-liveness-verification-test/src/index.html +31 -0
  219. package/projects/facecog-liveness-verification-test/src/main.ts +6 -0
  220. package/projects/facecog-liveness-verification-test/src/polyfills.ts +55 -0
  221. package/projects/facecog-liveness-verification-test/src/theme/nextsapien-theme.scss +174 -0
  222. package/projects/facecog-liveness-verification-test/src/theme/variables.scss +2 -0
  223. package/projects/facecog-liveness-verification-test/src/zone-flags.ts +6 -0
  224. package/projects/facecog-liveness-verification-test/tsconfig.app.json +15 -0
  225. package/projects/facecog-liveness-verification-test/tsconfig.spec.json +14 -0
  226. package/setup-jest.ts +118 -0
  227. package/tsconfig.json +41 -0
  228. package/tsconfig.spec.json +15 -0
  229. 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
+ }