@smileid/web-components 11.4.4 → 11.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/{DocumentCaptureScreens-bLFW-yEM.js → DocumentCaptureScreens-ucJDu5nH.js} +555 -2470
- package/dist/esm/DocumentCaptureScreens-ucJDu5nH.js.map +1 -0
- package/dist/esm/{EndUserConsent-D26UoVk5.js → EndUserConsent-CsiwoThZ.js} +3 -3
- package/dist/esm/{EndUserConsent-D26UoVk5.js.map → EndUserConsent-CsiwoThZ.js.map} +1 -1
- package/dist/esm/{Navigation-nvehze1F.js → Navigation-Xg565kcu.js} +28 -22
- package/dist/esm/Navigation-Xg565kcu.js.map +1 -0
- package/dist/esm/SelfieCaptureScreens-D3KuMzZA.js +11471 -0
- package/dist/esm/SelfieCaptureScreens-D3KuMzZA.js.map +1 -0
- package/dist/esm/{TotpConsent-owUOdKzP.js → TotpConsent-CRtmtudl.js} +2 -2
- package/dist/esm/{TotpConsent-owUOdKzP.js.map → TotpConsent-CRtmtudl.js.map} +1 -1
- package/dist/esm/combobox.js +1 -1
- package/dist/esm/document.js +1 -1
- package/dist/esm/end-user-consent.js +1 -1
- package/dist/esm/index-CUwa6MPI.js +1363 -0
- package/dist/esm/{index-5Nn2kzHI.js.map → index-CUwa6MPI.js.map} +1 -1
- package/dist/esm/localisation.js +1 -1
- package/dist/esm/main.js +6 -6
- package/dist/esm/navigation.js +1 -1
- package/dist/esm/package-BmVbDNny.js +2535 -0
- package/dist/esm/package-BmVbDNny.js.map +1 -0
- package/dist/esm/selfie.js +1 -1
- package/dist/esm/smart-camera-web.js +67 -40
- package/dist/esm/smart-camera-web.js.map +1 -1
- package/dist/esm/totp-consent.js +1 -1
- package/dist/smart-camera-web.js +877 -122
- package/dist/smart-camera-web.js.map +1 -1
- package/dist/types/main.d.ts +13 -0
- package/lib/components/navigation/src/Navigation.js +27 -8
- package/lib/components/selfie/src/SelfieCaptureScreens.js +139 -8
- package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieCapture.tsx +684 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieConsent.tsx +71 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/EnhancedSmartSelfieSubmission.tsx +181 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/OvalProgress.tsx +87 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/Icon.svg +8 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/accessories.svg +77 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/active_liveness_animation.lottie +0 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/device.svg +12 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/device_orientation.lottie +0 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/good.svg +52 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/id-card.svg +9 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/illustrations.tsx +852 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/instructions-img.svg +3 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/multiple-faces.svg +69 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/person.svg +6 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/phone.svg +8 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/poor-lighting.svg +53 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/assets/too_dark_animation.lottie +0 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/ActiveLivenessOverlay.tsx +226 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/AlertDisplay.tsx +38 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/BackNavigation.tsx +45 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CameraPreview.tsx +96 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CaptureControls.tsx +97 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/CaptureGuidelines.tsx +374 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/ConsentView.tsx +460 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/SubmissionView.tsx +426 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/components/index.ts +3 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/constants.ts +23 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/index.ts +2 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/useCamera.ts +238 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/hooks/useFaceCapture.ts +1075 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/index.ts +1 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/alertMessages.ts +20 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/canvas.ts +108 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/faceDetection.ts +545 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/imageCapture.ts +66 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/imageQuality.ts +151 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/index.ts +5 -0
- package/lib/components/selfie/src/enhanced-smartselfie-capture/utils/mediapipeManager.ts +215 -0
- package/lib/components/selfie/src/selfie-capture-wrapper/SelfieCaptureWrapper.tsx +163 -17
- package/lib/components/selfie/src/smartselfie-capture/SmartSelfieCapture.tsx +2 -2
- package/lib/components/selfie/src/smartselfie-capture/hooks/useFaceCapture.ts +15 -7
- package/lib/components/selfie/src/smartselfie-capture/utils/canvas.ts +4 -6
- package/lib/components/selfie/src/smartselfie-capture/utils/mediapipeManager.ts +145 -9
- package/lib/components/signature-pad/package.json +1 -1
- package/lib/components/smart-camera-web/src/SmartCameraWeb.js +70 -11
- package/lib/domain/localisation/index.js +2 -2
- package/package.json +3 -3
- package/dist/esm/DocumentCaptureScreens-bLFW-yEM.js.map +0 -1
- package/dist/esm/Navigation-nvehze1F.js.map +0 -1
- package/dist/esm/SelfieCaptureScreens-BXIs6_tl.js +0 -7522
- package/dist/esm/SelfieCaptureScreens-BXIs6_tl.js.map +0 -1
- package/dist/esm/index-5Nn2kzHI.js +0 -1360
- package/dist/esm/package-DmH-I6GW.js +0 -565
- package/dist/esm/package-DmH-I6GW.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as EnhancedSmartSelfieCapture } from './EnhancedSmartSelfieCapture';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { t } from '../../../../../domain/localisation';
|
|
2
|
+
|
|
3
|
+
export const MESSAGES = {
|
|
4
|
+
'no-face': () => t('selfie.smart.alert.noFace'),
|
|
5
|
+
'out-of-bounds': () => t('selfie.smart.alert.outOfBounds'),
|
|
6
|
+
'too-close': () => t('selfie.smart.alert.tooClose'),
|
|
7
|
+
'too-far': () => t('selfie.smart.alert.tooFar'),
|
|
8
|
+
'neutral-expression': () => t('selfie.smart.alert.neutralExpression'),
|
|
9
|
+
'smile-required': () => t('selfie.smart.alert.smileRequired'),
|
|
10
|
+
'open-mouth-smile': () => t('selfie.smart.alert.openMouthSmile'),
|
|
11
|
+
'too-dark': () => t('selfie.smart.alert.tooDark'),
|
|
12
|
+
'too-blurry': () => t('selfie.smart.alert.tooBlurry'),
|
|
13
|
+
'face-not-centered': () => t('selfie.smart.alert.faceNotCentered'),
|
|
14
|
+
'turn-head-left': () => t('selfie.smart.alert.turnHeadLeft'),
|
|
15
|
+
'turn-head-right': () => t('selfie.smart.alert.turnHeadRight'),
|
|
16
|
+
'tilt-head-up': () => t('selfie.smart.alert.tiltHeadUp'),
|
|
17
|
+
initializing: () => t('selfie.smart.alert.initializing'),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type MessageKey = keyof typeof MESSAGES;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a cropped square canvas from video for face detection
|
|
3
|
+
*/
|
|
4
|
+
import { DrawingUtils, FaceLandmarker } from '@mediapipe/tasks-vision';
|
|
5
|
+
|
|
6
|
+
export const createCroppedVideoFrame = (
|
|
7
|
+
videoElement: HTMLVideoElement,
|
|
8
|
+
): HTMLCanvasElement | null => {
|
|
9
|
+
const canvas = document.createElement('canvas');
|
|
10
|
+
const ctx = canvas.getContext('2d');
|
|
11
|
+
if (!ctx) return null;
|
|
12
|
+
|
|
13
|
+
const sourceWidth = videoElement.videoWidth;
|
|
14
|
+
const sourceHeight = videoElement.videoHeight;
|
|
15
|
+
|
|
16
|
+
const squareSize = Math.min(sourceWidth, sourceHeight);
|
|
17
|
+
const cropX = (sourceWidth - squareSize) / 2;
|
|
18
|
+
const cropY = (sourceHeight - squareSize) / 2;
|
|
19
|
+
|
|
20
|
+
canvas.width = squareSize;
|
|
21
|
+
canvas.height = squareSize;
|
|
22
|
+
|
|
23
|
+
ctx.drawImage(
|
|
24
|
+
videoElement,
|
|
25
|
+
cropX,
|
|
26
|
+
cropY,
|
|
27
|
+
squareSize,
|
|
28
|
+
squareSize,
|
|
29
|
+
0,
|
|
30
|
+
0,
|
|
31
|
+
squareSize,
|
|
32
|
+
squareSize,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return canvas;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Draw face mesh overlay on canvas
|
|
40
|
+
*/
|
|
41
|
+
export const drawFaceMesh = (
|
|
42
|
+
canvas: HTMLCanvasElement,
|
|
43
|
+
landmarks: any[],
|
|
44
|
+
capturesTaken: number,
|
|
45
|
+
smileCheckpoint: number,
|
|
46
|
+
useStrictMode = false,
|
|
47
|
+
): void => {
|
|
48
|
+
const ctx = canvas.getContext('2d');
|
|
49
|
+
if (!ctx) return;
|
|
50
|
+
|
|
51
|
+
const canvasWidth = canvas.width;
|
|
52
|
+
const canvasHeight = canvas.height;
|
|
53
|
+
|
|
54
|
+
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
|
55
|
+
|
|
56
|
+
// In strict (Active Liveness) mode we render a Lottie overlay instead of the
|
|
57
|
+
// landmark/connector mesh, so leave the canvas cleared and bail.
|
|
58
|
+
if (useStrictMode) return;
|
|
59
|
+
|
|
60
|
+
const drawingUtils = new DrawingUtils(ctx);
|
|
61
|
+
|
|
62
|
+
landmarks.forEach((landmark) => {
|
|
63
|
+
if (!landmark || landmark.length === 0) return;
|
|
64
|
+
|
|
65
|
+
const outlineColor = 'rgba(162, 155, 254,0.4)';
|
|
66
|
+
const lineWidth = 2;
|
|
67
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
|
|
68
|
+
ctx.lineWidth = lineWidth;
|
|
69
|
+
ctx.lineCap = 'round';
|
|
70
|
+
ctx.lineJoin = 'round';
|
|
71
|
+
|
|
72
|
+
drawingUtils.drawLandmarks(landmark, {
|
|
73
|
+
color: 'rgba(9, 132, 227,0.7)',
|
|
74
|
+
lineWidth: 0.5,
|
|
75
|
+
radius: 0.5,
|
|
76
|
+
});
|
|
77
|
+
drawingUtils.drawConnectors(
|
|
78
|
+
landmark,
|
|
79
|
+
FaceLandmarker.FACE_LANDMARKS_FACE_OVAL,
|
|
80
|
+
{
|
|
81
|
+
color: outlineColor,
|
|
82
|
+
lineWidth,
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const isInSmileZone = capturesTaken > 0 && capturesTaken >= smileCheckpoint;
|
|
87
|
+
if (isInSmileZone) {
|
|
88
|
+
drawingUtils.drawConnectors(
|
|
89
|
+
landmark,
|
|
90
|
+
FaceLandmarker.FACE_LANDMARKS_LIPS,
|
|
91
|
+
{
|
|
92
|
+
color: outlineColor,
|
|
93
|
+
lineWidth,
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Clear canvas completely
|
|
102
|
+
*/
|
|
103
|
+
export const clearCanvas = (canvas: HTMLCanvasElement): void => {
|
|
104
|
+
const ctx = canvas.getContext('2d');
|
|
105
|
+
if (ctx) {
|
|
106
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculate the size of a face relative to the video frame.
|
|
3
|
+
*
|
|
4
|
+
* @param landmarks MediaPipe face-landmark result
|
|
5
|
+
* @param options.rotationStable When true, also factors in the inter-eye
|
|
6
|
+
* distance (landmarks 33 and 263) and returns the largest of the three
|
|
7
|
+
* measures. Use this for active-liveness mode where the bounding-box
|
|
8
|
+
* height shrinks as the user pitches their head up and width shrinks as
|
|
9
|
+
* they yaw, both of which would otherwise trigger a false "too far".
|
|
10
|
+
*/
|
|
11
|
+
export const calculateFaceSize = (
|
|
12
|
+
landmarks: any,
|
|
13
|
+
options: { rotationStable?: boolean } = {},
|
|
14
|
+
): number => {
|
|
15
|
+
if (!landmarks || landmarks.length === 0) return 0;
|
|
16
|
+
|
|
17
|
+
const face = landmarks[0];
|
|
18
|
+
|
|
19
|
+
if (!face || face.length === 0) return 0;
|
|
20
|
+
|
|
21
|
+
// Get bounding box of face landmarks
|
|
22
|
+
let minX = 1;
|
|
23
|
+
let maxX = 0;
|
|
24
|
+
let minY = 1;
|
|
25
|
+
let maxY = 0;
|
|
26
|
+
|
|
27
|
+
face.forEach((landmark: any) => {
|
|
28
|
+
if (
|
|
29
|
+
landmark &&
|
|
30
|
+
typeof landmark.x === 'number' &&
|
|
31
|
+
typeof landmark.y === 'number'
|
|
32
|
+
) {
|
|
33
|
+
minX = Math.min(minX, landmark.x);
|
|
34
|
+
maxX = Math.max(maxX, landmark.x);
|
|
35
|
+
minY = Math.min(minY, landmark.y);
|
|
36
|
+
maxY = Math.max(maxY, landmark.y);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Calculate face size as percentage of video area
|
|
41
|
+
const faceWidth = maxX - minX;
|
|
42
|
+
const faceHeight = maxY - minY;
|
|
43
|
+
let faceSize = Math.max(faceWidth, faceHeight);
|
|
44
|
+
|
|
45
|
+
if (options.rotationStable) {
|
|
46
|
+
const leftEye = face[33];
|
|
47
|
+
const rightEye = face[263];
|
|
48
|
+
if (leftEye && rightEye) {
|
|
49
|
+
const dx = rightEye.x - leftEye.x;
|
|
50
|
+
const dy = rightEye.y - leftEye.y;
|
|
51
|
+
const eyeSpan = Math.sqrt(dx * dx + dy * dy);
|
|
52
|
+
// Eye-corner span is ≈ 0.45 of face width on a frontal face. Scale up
|
|
53
|
+
// so the value is comparable to the bbox-derived size and use the max
|
|
54
|
+
// of the three measures — that way pitch (shrinks height) and yaw
|
|
55
|
+
// (shrinks width) don't push the user into a "too far" state.
|
|
56
|
+
faceSize = Math.max(faceSize, eyeSpan / 0.45);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return faceSize;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if a face is positioned within the oval bounds
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* Check if a face is positioned within the oval bounds.
|
|
68
|
+
*
|
|
69
|
+
* @param landmarks MediaPipe face landmarks
|
|
70
|
+
* @param videoAspectRatio width / height of the source video
|
|
71
|
+
* @param options.centerOnly When true, only the face centre needs to fall
|
|
72
|
+
* inside the oval. Use this for active-liveness mode, where head rotation
|
|
73
|
+
* legitimately widens the bounding box and would otherwise fail the
|
|
74
|
+
* four-corner check.
|
|
75
|
+
*/
|
|
76
|
+
export const isFaceInBounds = (
|
|
77
|
+
landmarks: any,
|
|
78
|
+
videoAspectRatio: number,
|
|
79
|
+
options: { centerOnly?: boolean } = {},
|
|
80
|
+
): boolean => {
|
|
81
|
+
if (!landmarks || landmarks.length === 0) return false;
|
|
82
|
+
|
|
83
|
+
const face = landmarks[0];
|
|
84
|
+
|
|
85
|
+
let minX = 1;
|
|
86
|
+
let maxX = 0;
|
|
87
|
+
let minY = 1;
|
|
88
|
+
let maxY = 0;
|
|
89
|
+
face.forEach((landmark: any) => {
|
|
90
|
+
minX = Math.min(minX, landmark.x);
|
|
91
|
+
maxX = Math.max(maxX, landmark.x);
|
|
92
|
+
minY = Math.min(minY, landmark.y);
|
|
93
|
+
maxY = Math.max(maxY, landmark.y);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const ovalCenterX = 0.5;
|
|
97
|
+
const ovalCenterY = 0.6;
|
|
98
|
+
|
|
99
|
+
const isLandscape = videoAspectRatio > 1;
|
|
100
|
+
let ovalWidth;
|
|
101
|
+
let ovalHeight;
|
|
102
|
+
if (isLandscape) {
|
|
103
|
+
ovalWidth = 0.4;
|
|
104
|
+
ovalHeight = 0.3;
|
|
105
|
+
} else {
|
|
106
|
+
ovalWidth = 0.35;
|
|
107
|
+
ovalHeight = 0.5;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const faceCenterX = (minX + maxX) / 2;
|
|
111
|
+
const faceCenterY = (minY + maxY) / 2;
|
|
112
|
+
|
|
113
|
+
// In strict (Active Liveness) mode the bounding-box centre drifts as the
|
|
114
|
+
// user yaws/pitches their head, which can falsely fail this check. Prefer
|
|
115
|
+
// the nose tip (landmark 1) — it's anatomically stable. Keep the
|
|
116
|
+
// tolerance tight so that obvious off-centre framing (face partly off the
|
|
117
|
+
// oval edge) is still flagged.
|
|
118
|
+
const noseTip = options.centerOnly ? face[1] : null;
|
|
119
|
+
const centerX = noseTip ? noseTip.x : faceCenterX;
|
|
120
|
+
const centerY = noseTip ? noseTip.y : faceCenterY;
|
|
121
|
+
|
|
122
|
+
const centerTolerance = options.centerOnly ? 0.15 : 0.2;
|
|
123
|
+
const centerOvalWidth = ovalWidth * (1 + centerTolerance);
|
|
124
|
+
const centerOvalHeight = ovalHeight * (1 + centerTolerance);
|
|
125
|
+
|
|
126
|
+
const checkPointInCenterOval = (x: number, y: number) => {
|
|
127
|
+
const dx = (x - ovalCenterX) / centerOvalWidth;
|
|
128
|
+
const dy = (y - ovalCenterY) / centerOvalHeight;
|
|
129
|
+
return dx * dx + dy * dy <= 1;
|
|
130
|
+
};
|
|
131
|
+
const centerInBounds = checkPointInCenterOval(centerX, centerY);
|
|
132
|
+
|
|
133
|
+
if (options.centerOnly) {
|
|
134
|
+
// Strict mode: nose-tip must be centred AND the face bounding box must
|
|
135
|
+
// not clip the oval edges. The four-corner check uses a slightly looser
|
|
136
|
+
// tolerance than the non-strict case because head rotation legitimately
|
|
137
|
+
// widens the bounding box, but we still want to reject framings where
|
|
138
|
+
// a chunk of the face is outside the oval (e.g. ear or chin clipped).
|
|
139
|
+
const strictBoundsToleranceX = 0.25;
|
|
140
|
+
const strictBoundsToleranceY = 0.15;
|
|
141
|
+
const strictOvalWidth = ovalWidth * (1 + strictBoundsToleranceX);
|
|
142
|
+
const strictOvalHeight = ovalHeight * (1 + strictBoundsToleranceY);
|
|
143
|
+
const checkPointInStrictOval = (x: number, y: number) => {
|
|
144
|
+
const dx = (x - ovalCenterX) / strictOvalWidth;
|
|
145
|
+
const dy = (y - ovalCenterY) / strictOvalHeight;
|
|
146
|
+
return dx * dx + dy * dy <= 1;
|
|
147
|
+
};
|
|
148
|
+
const tl = checkPointInStrictOval(minX, minY);
|
|
149
|
+
const tr = checkPointInStrictOval(maxX, minY);
|
|
150
|
+
const bl = checkPointInStrictOval(minX, maxY);
|
|
151
|
+
const br = checkPointInStrictOval(maxX, maxY);
|
|
152
|
+
|
|
153
|
+
// Frame-edge clipping guard: if any side of the bounding box is hard
|
|
154
|
+
// against the camera frame edge, part of the face is almost certainly
|
|
155
|
+
// cut off (e.g. chin or forehead outside the visible video). The oval
|
|
156
|
+
// check alone misses this because the oval extends most of the frame
|
|
157
|
+
// height, so a face that fills the frame also fills the oval.
|
|
158
|
+
const FRAME_EDGE_MARGIN = 0.03;
|
|
159
|
+
const notClipped =
|
|
160
|
+
minX > FRAME_EDGE_MARGIN &&
|
|
161
|
+
minY > FRAME_EDGE_MARGIN &&
|
|
162
|
+
maxX < 1 - FRAME_EDGE_MARGIN &&
|
|
163
|
+
maxY < 1 - FRAME_EDGE_MARGIN;
|
|
164
|
+
|
|
165
|
+
return centerInBounds && tl && tr && bl && br && notClipped;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const toleranceX = 0.2;
|
|
169
|
+
const toleranceY = 0.1;
|
|
170
|
+
const adjustedOvalWidth = ovalWidth * (1 + toleranceX);
|
|
171
|
+
const adjustedOvalHeight = ovalHeight * (1 + toleranceY);
|
|
172
|
+
|
|
173
|
+
const checkPointInExpandedOval = (x: number, y: number) => {
|
|
174
|
+
const dx = (x - ovalCenterX) / adjustedOvalWidth;
|
|
175
|
+
const dy = (y - ovalCenterY) / adjustedOvalHeight;
|
|
176
|
+
return dx * dx + dy * dy <= 1;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const topLeft = checkPointInExpandedOval(minX, minY);
|
|
180
|
+
const topRight = checkPointInExpandedOval(maxX, minY);
|
|
181
|
+
const bottomLeft = checkPointInExpandedOval(minX, maxY);
|
|
182
|
+
const bottomRight = checkPointInExpandedOval(maxX, maxY);
|
|
183
|
+
|
|
184
|
+
return centerInBounds && topLeft && topRight && bottomLeft && bottomRight;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Detect whether the visible egg-shaped oval mask is clipping any part of
|
|
189
|
+
* the face. Uses the actual rendered DOM rects of the <video> element and
|
|
190
|
+
* its wrapper, so the result is always aligned with what the user sees,
|
|
191
|
+
* regardless of the camera's intrinsic resolution or how the video is
|
|
192
|
+
* cropped/positioned by CSS.
|
|
193
|
+
*
|
|
194
|
+
* The visible oval is approximated as a centred ellipse matching the SVG
|
|
195
|
+
* ellipse drawn by OvalProgress (cx=155.5/311, cy=209/418, rx=153.5/311,
|
|
196
|
+
* ry=207/418 — i.e. essentially fills the wrapper, inset by the 2px stroke).
|
|
197
|
+
*
|
|
198
|
+
* @returns true if any landmark falls outside the visible oval (i.e. the
|
|
199
|
+
* oval boundary is clipping the face), false otherwise.
|
|
200
|
+
*/
|
|
201
|
+
// Visible-oval geometry in wrapper-normalised coordinates. Kept in one place
|
|
202
|
+
// so the boolean check and the directional check below can't drift apart.
|
|
203
|
+
// Matches OvalProgress.tsx (rx≈0.494, ry≈0.495). A small inward inset gives
|
|
204
|
+
// us a hair of tolerance for landmark jitter so a face flush against the
|
|
205
|
+
// border doesn't flicker between clipping/not.
|
|
206
|
+
const OVAL_CX = 0.5;
|
|
207
|
+
const OVAL_CY = 0.5;
|
|
208
|
+
const OVAL_HALF_W = 0.49;
|
|
209
|
+
const OVAL_HALF_H = 0.49;
|
|
210
|
+
|
|
211
|
+
interface ProjectedOval {
|
|
212
|
+
videoRect: DOMRect;
|
|
213
|
+
wrapperRect: DOMRect;
|
|
214
|
+
/** Top-left of the centre-cropped square within wrapper-pixel coords. */
|
|
215
|
+
cropLeftInWrapper: number;
|
|
216
|
+
cropTopInWrapper: number;
|
|
217
|
+
/** Side length of the centre-cropped square in wrapper-pixel coords. */
|
|
218
|
+
cropSize: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const projectOval = (videoEl: HTMLVideoElement): ProjectedOval | null => {
|
|
222
|
+
const wrapper = videoEl.parentElement?.parentElement;
|
|
223
|
+
if (!wrapper) return null;
|
|
224
|
+
const videoRect = videoEl.getBoundingClientRect();
|
|
225
|
+
const wrapperRect = wrapper.getBoundingClientRect();
|
|
226
|
+
if (
|
|
227
|
+
videoRect.width <= 0 ||
|
|
228
|
+
videoRect.height <= 0 ||
|
|
229
|
+
wrapperRect.width <= 0 ||
|
|
230
|
+
wrapperRect.height <= 0
|
|
231
|
+
) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
// Detection runs on a centre-cropped SQUARE of the source video (see
|
|
235
|
+
// createCroppedVideoFrame). Landmark coords are normalised to that
|
|
236
|
+
// square's [0,1]² space, NOT the full video — so we need the crop's
|
|
237
|
+
// rendered position and size to project them onto the wrapper.
|
|
238
|
+
const cropSize = Math.min(videoRect.width, videoRect.height);
|
|
239
|
+
const cropLeftInWrapper =
|
|
240
|
+
videoRect.left - wrapperRect.left + (videoRect.width - cropSize) / 2;
|
|
241
|
+
const cropTopInWrapper =
|
|
242
|
+
videoRect.top - wrapperRect.top + (videoRect.height - cropSize) / 2;
|
|
243
|
+
return {
|
|
244
|
+
videoRect,
|
|
245
|
+
wrapperRect,
|
|
246
|
+
cropLeftInWrapper,
|
|
247
|
+
cropTopInWrapper,
|
|
248
|
+
cropSize,
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export const computeFaceClippingOval = (
|
|
253
|
+
face: any,
|
|
254
|
+
videoEl: HTMLVideoElement | null,
|
|
255
|
+
): boolean => {
|
|
256
|
+
if (!face || face.length === 0 || !videoEl) return false;
|
|
257
|
+
const proj = projectOval(videoEl);
|
|
258
|
+
if (!proj) return false;
|
|
259
|
+
|
|
260
|
+
for (let i = 0; i < face.length; i += 1) {
|
|
261
|
+
const lm = face[i];
|
|
262
|
+
const renderedX = proj.cropLeftInWrapper + lm.x * proj.cropSize;
|
|
263
|
+
const renderedY = proj.cropTopInWrapper + lm.y * proj.cropSize;
|
|
264
|
+
const nx = renderedX / proj.wrapperRect.width;
|
|
265
|
+
const ny = renderedY / proj.wrapperRect.height;
|
|
266
|
+
const dx = (nx - OVAL_CX) / OVAL_HALF_W;
|
|
267
|
+
const dy = (ny - OVAL_CY) / OVAL_HALF_H;
|
|
268
|
+
if (dx * dx + dy * dy > 1) {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Like {@link computeFaceClippingOval} but returns which side of the visible
|
|
277
|
+
* oval is being clipped (or null when the face is fully inside).
|
|
278
|
+
*
|
|
279
|
+
* Side is derived from the FACE BOUNDING-BOX CENTRE relative to the oval
|
|
280
|
+
* centre — using the worst single landmark flips between left/right ear on
|
|
281
|
+
* symmetric clips (the user sees the prompt ping-pong every frame). The
|
|
282
|
+
* bbox centre moves slowly and points consistently in one direction.
|
|
283
|
+
*
|
|
284
|
+
* Mirroring: the preview is CSS-mirrored (scaleX(-1)). A face whose source
|
|
285
|
+
* centre has x<0.5 appears on screen-RIGHT, so we report 'right' to nudge
|
|
286
|
+
* the device in the correct direction in the mirrored preview.
|
|
287
|
+
*/
|
|
288
|
+
export const computeFaceClippingSide = (
|
|
289
|
+
face: any,
|
|
290
|
+
videoEl: HTMLVideoElement | null,
|
|
291
|
+
): 'top' | 'right' | 'bottom' | 'left' | null => {
|
|
292
|
+
if (!face || face.length === 0 || !videoEl) return null;
|
|
293
|
+
const proj = projectOval(videoEl);
|
|
294
|
+
if (!proj) return null;
|
|
295
|
+
|
|
296
|
+
// First pass: are we actually clipping? Same test as
|
|
297
|
+
// computeFaceClippingOval — keep it inline to avoid two DOM reads.
|
|
298
|
+
let clipping = false;
|
|
299
|
+
let minNx = Infinity;
|
|
300
|
+
let maxNx = -Infinity;
|
|
301
|
+
let minNy = Infinity;
|
|
302
|
+
let maxNy = -Infinity;
|
|
303
|
+
for (let i = 0; i < face.length; i += 1) {
|
|
304
|
+
const lm = face[i];
|
|
305
|
+
const renderedX = proj.cropLeftInWrapper + lm.x * proj.cropSize;
|
|
306
|
+
const renderedY = proj.cropTopInWrapper + lm.y * proj.cropSize;
|
|
307
|
+
const nx = renderedX / proj.wrapperRect.width;
|
|
308
|
+
const ny = renderedY / proj.wrapperRect.height;
|
|
309
|
+
if (nx < minNx) minNx = nx;
|
|
310
|
+
if (nx > maxNx) maxNx = nx;
|
|
311
|
+
if (ny < minNy) minNy = ny;
|
|
312
|
+
if (ny > maxNy) maxNy = ny;
|
|
313
|
+
if (!clipping) {
|
|
314
|
+
const dx = (nx - OVAL_CX) / OVAL_HALF_W;
|
|
315
|
+
const dy = (ny - OVAL_CY) / OVAL_HALF_H;
|
|
316
|
+
if (dx * dx + dy * dy > 1) clipping = true;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (!clipping) return null;
|
|
320
|
+
|
|
321
|
+
// Direction from bbox centre to oval centre, ellipse-normalised so the
|
|
322
|
+
// dominant axis comparison is fair (the oval isn't quite a circle).
|
|
323
|
+
const centreNx = (minNx + maxNx) / 2;
|
|
324
|
+
const centreNy = (minNy + maxNy) / 2;
|
|
325
|
+
const dx = (centreNx - OVAL_CX) / OVAL_HALF_W;
|
|
326
|
+
const dy = (centreNy - OVAL_CY) / OVAL_HALF_H;
|
|
327
|
+
|
|
328
|
+
// Hysteresis-friendly axis pick: require horizontal lead over vertical
|
|
329
|
+
// by a clear margin before reporting left/right. Prevents axis flicker
|
|
330
|
+
// when |dx| ≈ |dy| (e.g. face nudged into a corner).
|
|
331
|
+
const AXIS_MARGIN = 1.2;
|
|
332
|
+
const horizontalDominant = Math.abs(dx) > Math.abs(dy) * AXIS_MARGIN;
|
|
333
|
+
const verticalDominant = Math.abs(dy) > Math.abs(dx) * AXIS_MARGIN;
|
|
334
|
+
|
|
335
|
+
if (horizontalDominant) {
|
|
336
|
+
// Mirror: source-left (dx<0) appears on screen-right.
|
|
337
|
+
return dx < 0 ? 'right' : 'left';
|
|
338
|
+
}
|
|
339
|
+
if (verticalDominant) {
|
|
340
|
+
return dy < 0 ? 'top' : 'bottom';
|
|
341
|
+
}
|
|
342
|
+
// Mixed corner clip — pick by raw magnitude as a tiebreak.
|
|
343
|
+
if (Math.abs(dx) >= Math.abs(dy)) {
|
|
344
|
+
return dx < 0 ? 'right' : 'left';
|
|
345
|
+
}
|
|
346
|
+
return dy < 0 ? 'top' : 'bottom';
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Calculate mouth opening using face landmarks
|
|
351
|
+
*/
|
|
352
|
+
export const calculateMouthOpening = (landmarks: any): number => {
|
|
353
|
+
if (!landmarks || landmarks.length === 0) return 0;
|
|
354
|
+
|
|
355
|
+
const face = landmarks[0];
|
|
356
|
+
if (!face || face.length === 0) return 0;
|
|
357
|
+
|
|
358
|
+
// MediaPipe face landmark indices for mouth
|
|
359
|
+
const upperLipCenter = face[13]; // Upper lip center
|
|
360
|
+
const lowerLipCenter = face[14]; // Lower lip center
|
|
361
|
+
|
|
362
|
+
if (!upperLipCenter || !lowerLipCenter) return 0;
|
|
363
|
+
|
|
364
|
+
const mouthHeight = Math.abs(lowerLipCenter.y - upperLipCenter.y);
|
|
365
|
+
|
|
366
|
+
const faceTop = Math.min(...face.map((p: any) => p.y));
|
|
367
|
+
const faceBottom = Math.max(...face.map((p: any) => p.y));
|
|
368
|
+
const faceHeight = faceBottom - faceTop;
|
|
369
|
+
|
|
370
|
+
return faceHeight > 0 ? mouthHeight / faceHeight : 0;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Discrete head-pose direction used by the active liveness state machine.
|
|
375
|
+
*/
|
|
376
|
+
export type HeadPoseDirection = 'left' | 'right' | 'up';
|
|
377
|
+
|
|
378
|
+
export interface HeadPoseAngles {
|
|
379
|
+
/** Left/right rotation in degrees. Negative = subject's left, positive = subject's right. */
|
|
380
|
+
yaw: number;
|
|
381
|
+
/**
|
|
382
|
+
* Vertical tilt as a signed ratio (×100 so it reads like degrees).
|
|
383
|
+
* Positive = looking up, negative = looking down, ~0 = neutral.
|
|
384
|
+
*
|
|
385
|
+
* Derived from the nose tip's vertical position between the forehead and
|
|
386
|
+
* chin landmarks, which is a stable 2D measure independent of z noise.
|
|
387
|
+
*/
|
|
388
|
+
pitch: number;
|
|
389
|
+
/** Side-to-side tilt in degrees. */
|
|
390
|
+
roll: number;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Estimate head pose (yaw/pitch/roll) from MediaPipe face landmarks.
|
|
395
|
+
*
|
|
396
|
+
* Uses a small set of stable landmarks rather than full PnP solving so it
|
|
397
|
+
* stays cheap enough to run every detection frame on the main thread.
|
|
398
|
+
*
|
|
399
|
+
* Landmark indices (MediaPipe FaceLandmarker, 478-point model):
|
|
400
|
+
* 1 - nose tip
|
|
401
|
+
* 10 - forehead (top of face oval)
|
|
402
|
+
* 33 - left eye outer corner
|
|
403
|
+
* 152 - chin (bottom of face oval)
|
|
404
|
+
* 263 - right eye outer corner
|
|
405
|
+
* 207 - left cheek
|
|
406
|
+
* 426 - right cheek
|
|
407
|
+
*/
|
|
408
|
+
export const calculateHeadPose = (landmarks: any): HeadPoseAngles | null => {
|
|
409
|
+
if (!landmarks || landmarks.length === 0) return null;
|
|
410
|
+
const face = landmarks[0];
|
|
411
|
+
if (!face || face.length < 427) return null;
|
|
412
|
+
|
|
413
|
+
const noseTip = face[1];
|
|
414
|
+
const leftEye = face[33];
|
|
415
|
+
const rightEye = face[263];
|
|
416
|
+
const leftCheek = face[207];
|
|
417
|
+
const rightCheek = face[426];
|
|
418
|
+
const forehead = face[10];
|
|
419
|
+
const chin = face[152];
|
|
420
|
+
|
|
421
|
+
if (
|
|
422
|
+
!noseTip ||
|
|
423
|
+
!leftEye ||
|
|
424
|
+
!rightEye ||
|
|
425
|
+
!leftCheek ||
|
|
426
|
+
!rightCheek ||
|
|
427
|
+
!forehead ||
|
|
428
|
+
!chin
|
|
429
|
+
) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const toDeg = (rad: number) => (rad * 180) / Math.PI;
|
|
434
|
+
const clamp = (v: number, lo: number, hi: number) =>
|
|
435
|
+
Math.max(lo, Math.min(hi, v));
|
|
436
|
+
|
|
437
|
+
// Yaw: signed cheek-depth component projected onto the physical 3D
|
|
438
|
+
// distance between the two cheek landmarks. asin is symmetric and has
|
|
439
|
+
// no singularity, unlike atan2(dz, dx) which inflates and jitters when
|
|
440
|
+
// the inter-cheek x-gap collapses on a left turn. The 3D cheek span is
|
|
441
|
+
// approximately constant across head rotation (the cheeks don't move
|
|
442
|
+
// relative to each other on the skull), so dz / cheekSpan3D is a clean
|
|
443
|
+
// sin(yaw) signal. Positive = turning the head's right (subject's
|
|
444
|
+
// right cheek goes back), negative = turning left.
|
|
445
|
+
const dx = rightCheek.x - leftCheek.x;
|
|
446
|
+
const dy = rightCheek.y - leftCheek.y;
|
|
447
|
+
const dz = rightCheek.z - leftCheek.z;
|
|
448
|
+
const cheekSpan3D = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
|
449
|
+
const yaw =
|
|
450
|
+
cheekSpan3D > 0 ? toDeg(Math.asin(clamp(dz / cheekSpan3D, -1, 1))) : 0;
|
|
451
|
+
|
|
452
|
+
// Pitch: where the nose sits vertically between the forehead (top) and chin
|
|
453
|
+
// (bottom). Neutral ≈ 0.5; tilting up pushes the nose toward the forehead
|
|
454
|
+
// (ratio < 0.5) so we negate to make "up" positive. Multiplied by 100 to
|
|
455
|
+
// keep numbers in a similar order of magnitude as yaw/roll degrees.
|
|
456
|
+
const faceHeight = chin.y - forehead.y;
|
|
457
|
+
let pitch = 0;
|
|
458
|
+
if (faceHeight > 0) {
|
|
459
|
+
const ratio = (noseTip.y - forehead.y) / faceHeight;
|
|
460
|
+
pitch = (0.5 - ratio) * 100;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Roll: in-plane tilt between the two eye corners.
|
|
464
|
+
const roll = toDeg(
|
|
465
|
+
Math.atan2(rightEye.y - leftEye.y, rightEye.x - leftEye.x),
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
return { yaw, pitch, roll };
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Classify head-pose angles into a discrete required-pose direction.
|
|
473
|
+
*
|
|
474
|
+
* Strict-mode prompts the user for one of: turn left, turn right, tilt slightly
|
|
475
|
+
* up. Yaw thresholds favour deliberate rotation (~25°); pitch threshold (~6)
|
|
476
|
+
* corresponds to roughly the nose moving 6% of face-height toward the
|
|
477
|
+
* forehead, which is a noticeable but comfortable upward tilt.
|
|
478
|
+
*
|
|
479
|
+
* The "up" classification only fires when yaw is small, so a sideways turn
|
|
480
|
+
* doesn't accidentally satisfy the up prompt.
|
|
481
|
+
*/
|
|
482
|
+
export const classifyHeadPose = (
|
|
483
|
+
pose: HeadPoseAngles | null,
|
|
484
|
+
thresholds: { yawSide?: number; yawNeutral?: number; pitchUp?: number } = {},
|
|
485
|
+
): HeadPoseDirection | null => {
|
|
486
|
+
if (!pose) return null;
|
|
487
|
+
|
|
488
|
+
// Yaw is now an asin-based signed sin(yaw) value (degrees), so it's
|
|
489
|
+
// symmetric around 0 and bounded to ±90°. In theory one threshold would
|
|
490
|
+
// work for both directions, but MediaPipe landmarks 207 / 426 aren't
|
|
491
|
+
// true mirror counterparts — dz/cheekSpan grows faster for one rotation
|
|
492
|
+
// direction than the other, so the same physical turn produces
|
|
493
|
+
// unequal yaw magnitudes. Compensate with a slightly lower threshold
|
|
494
|
+
// on the positive (screen-left) side so both prompts feel like the
|
|
495
|
+
// same amount of head movement.
|
|
496
|
+
const yawSide = thresholds.yawSide ?? 18;
|
|
497
|
+
const yawSidePositive = thresholds.yawSide ?? 13;
|
|
498
|
+
const yawNeutral = thresholds.yawNeutral ?? 12;
|
|
499
|
+
// Pitch threshold corresponds to ~7% nose shift toward the forehead —
|
|
500
|
+
// a deliberate tilt rather than the resting-posture drift produced at
|
|
501
|
+
// smaller values.
|
|
502
|
+
const pitchUp = thresholds.pitchUp ?? 7;
|
|
503
|
+
|
|
504
|
+
if (pose.yaw <= -yawSide) return 'right';
|
|
505
|
+
if (pose.yaw >= yawSidePositive) return 'left';
|
|
506
|
+
if (Math.abs(pose.yaw) <= yawNeutral && pose.pitch >= pitchUp) return 'up';
|
|
507
|
+
return null;
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
/*
|
|
511
|
+
* With only 3 poses there are 6 possible permutations, so adjacent sessions
|
|
512
|
+
* will repeat orders fairly often by chance — that's expected, not a bug.
|
|
513
|
+
* Swap indices are drawn from `crypto.getRandomValues` so the sequence is
|
|
514
|
+
* unpredictable to an attacker who has observed previous sessions.
|
|
515
|
+
*/
|
|
516
|
+
const randomInt = (maxExclusive: number): number => {
|
|
517
|
+
if (maxExclusive <= 1) return 0;
|
|
518
|
+
// Rejection-sample to avoid modulo bias: discard draws in the unused
|
|
519
|
+
// tail of the uint32 range so the remaining values divide evenly by
|
|
520
|
+
// `maxExclusive`.
|
|
521
|
+
const limit = Math.floor(0x100000000 / maxExclusive) * maxExclusive;
|
|
522
|
+
const buf = new Uint32Array(1);
|
|
523
|
+
do {
|
|
524
|
+
crypto.getRandomValues(buf);
|
|
525
|
+
} while (buf[0] >= limit);
|
|
526
|
+
return buf[0] % maxExclusive;
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Build the pose sequence for an active-liveness session.
|
|
531
|
+
*
|
|
532
|
+
* Randomised order across {left, right, up} to mirror the mobile SDKs and
|
|
533
|
+
* make the active-liveness challenge harder to pre-record. Any leftover
|
|
534
|
+
* frames in the capture window are taken silently while the user is neutral
|
|
535
|
+
* before the first pose prompt — see `useFaceCapture` for that logic.
|
|
536
|
+
*/
|
|
537
|
+
export const buildRandomPoseSequence = (): HeadPoseDirection[] => {
|
|
538
|
+
const poses: HeadPoseDirection[] = ['left', 'right', 'up'];
|
|
539
|
+
// Fisher–Yates shuffle.
|
|
540
|
+
for (let i = poses.length - 1; i > 0; i -= 1) {
|
|
541
|
+
const j = randomInt(i + 1);
|
|
542
|
+
[poses[i], poses[j]] = [poses[j], poses[i]];
|
|
543
|
+
}
|
|
544
|
+
return poses;
|
|
545
|
+
};
|