truescene-face-id-capture-sdk 1.0.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/README.md +77 -0
- package/dist/_redirects +3 -0
- package/dist/components/FaceAndIdCapture.js +628 -0
- package/dist/index.js +26005 -0
- package/dist/sdk/CaptureExperience.js +241 -0
- package/dist/sdk/element.js +221 -0
- package/dist/sdk/react/index.js +125 -0
- package/dist/sdk/styles.js +2 -0
- package/dist/sdk/types.js +1 -0
- package/dist/types/components/FaceAndIdCapture.d.ts +47 -0
- package/dist/types/sdk/CaptureExperience.d.ts +26 -0
- package/dist/types/sdk/element.d.ts +27 -0
- package/dist/types/sdk/index.d.ts +4 -0
- package/dist/types/sdk/react/index.d.ts +42 -0
- package/dist/types/sdk/styles.d.ts +1 -0
- package/dist/types/sdk/types.d.ts +13 -0
- package/dist/types/utils/config.d.ts +52 -0
- package/dist/types/utils/faceAnalysis.d.ts +26 -0
- package/dist/types/utils/faceChecks.d.ts +19 -0
- package/dist/types/utils/idPlacement.d.ts +13 -0
- package/dist/types/utils/overlayDraw.d.ts +15 -0
- package/dist/utils/config.js +44 -0
- package/dist/utils/faceAnalysis.js +144 -0
- package/dist/utils/faceChecks.js +84 -0
- package/dist/utils/idPlacement.js +66 -0
- package/dist/utils/overlayDraw.js +96 -0
- package/dist/verification-login-svgrepo-com.svg +45 -0
- package/dist/vite.svg +1 -0
- package/package.json +67 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare class TrueSceneCaptureElement extends HTMLElement {
|
|
2
|
+
static observedAttributes: string[];
|
|
3
|
+
private root;
|
|
4
|
+
private mount;
|
|
5
|
+
private styleEl;
|
|
6
|
+
connectedCallback(): void;
|
|
7
|
+
disconnectedCallback(): void;
|
|
8
|
+
attributeChangedCallback(): void;
|
|
9
|
+
set sessionToken(value: string);
|
|
10
|
+
get sessionToken(): string;
|
|
11
|
+
set compareUrl(value: string | undefined);
|
|
12
|
+
get compareUrl(): string | undefined;
|
|
13
|
+
set tokenHeader(value: string | undefined);
|
|
14
|
+
get tokenHeader(): string | undefined;
|
|
15
|
+
set tokenPrefix(value: string | undefined);
|
|
16
|
+
get tokenPrefix(): string | undefined;
|
|
17
|
+
set tokenField(value: string | null | undefined);
|
|
18
|
+
get tokenField(): string | null | undefined;
|
|
19
|
+
set autoStart(value: boolean);
|
|
20
|
+
get autoStart(): boolean;
|
|
21
|
+
set initialShowDebug(value: boolean);
|
|
22
|
+
get initialShowDebug(): boolean;
|
|
23
|
+
set showDebugToggle(value: boolean);
|
|
24
|
+
get showDebugToggle(): boolean;
|
|
25
|
+
private renderReact;
|
|
26
|
+
}
|
|
27
|
+
export declare const defineTrueSceneCapture: (tagName?: string) => void;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { defineTrueSceneCapture, TrueSceneCaptureElement } from './element';
|
|
2
|
+
export type { CaptureExperienceProps } from './CaptureExperience';
|
|
3
|
+
export type { CaptureImages, CompareResult } from './types';
|
|
4
|
+
export type { CaptureStep, FaceAndIdCaptureMetrics, } from '../components/FaceAndIdCapture';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import type { CaptureImages, CompareResult } from '../types';
|
|
3
|
+
import type { CaptureStep, FaceAndIdCaptureMetrics } from '../../components/FaceAndIdCapture';
|
|
4
|
+
type ReadyState = {
|
|
5
|
+
faceReady: boolean;
|
|
6
|
+
idReady: boolean;
|
|
7
|
+
};
|
|
8
|
+
type TrueSceneCaptureElement = HTMLElement & {
|
|
9
|
+
sessionToken: string;
|
|
10
|
+
compareUrl?: string;
|
|
11
|
+
tokenHeader?: string;
|
|
12
|
+
tokenPrefix?: string;
|
|
13
|
+
tokenField?: string | null;
|
|
14
|
+
autoStart: boolean;
|
|
15
|
+
initialShowDebug: boolean;
|
|
16
|
+
showDebugToggle: boolean;
|
|
17
|
+
};
|
|
18
|
+
export type TrueSceneCaptureProps = {
|
|
19
|
+
sessionToken: string;
|
|
20
|
+
compareUrl?: string;
|
|
21
|
+
tokenHeader?: string;
|
|
22
|
+
tokenPrefix?: string;
|
|
23
|
+
tokenField?: string | null;
|
|
24
|
+
autoStart?: boolean;
|
|
25
|
+
initialShowDebug?: boolean;
|
|
26
|
+
showDebugToggle?: boolean;
|
|
27
|
+
className?: string;
|
|
28
|
+
style?: CSSProperties;
|
|
29
|
+
onReadyChange?: (ready: ReadyState) => void;
|
|
30
|
+
onStepChange?: (step: CaptureStep) => void;
|
|
31
|
+
onMetricsChange?: (metrics: FaceAndIdCaptureMetrics) => void;
|
|
32
|
+
onCapture?: (images: CaptureImages) => void;
|
|
33
|
+
onCompareResult?: (result: CompareResult | null) => void;
|
|
34
|
+
onCompareError?: (message: string | null) => void;
|
|
35
|
+
onError?: (message: string) => void;
|
|
36
|
+
};
|
|
37
|
+
declare const TrueSceneCapture: ({ sessionToken, compareUrl, tokenHeader, tokenPrefix, tokenField, autoStart, initialShowDebug, showDebugToggle, className, style, onReadyChange, onStepChange, onMetricsChange, onCapture, onCompareResult, onCompareError, onError, }: TrueSceneCaptureProps) => import("react").ReactElement<{
|
|
38
|
+
ref: import("react").RefObject<TrueSceneCaptureElement | null>;
|
|
39
|
+
className: string | undefined;
|
|
40
|
+
style: CSSProperties | undefined;
|
|
41
|
+
}, string | import("react").JSXElementConstructor<any>>;
|
|
42
|
+
export default TrueSceneCapture;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const captureStyles: string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type CaptureImages = {
|
|
2
|
+
faceImage: string | null;
|
|
3
|
+
idImage: string | null;
|
|
4
|
+
fullImage: string | null;
|
|
5
|
+
};
|
|
6
|
+
export type CompareResult = {
|
|
7
|
+
match: boolean;
|
|
8
|
+
match_percentage: number;
|
|
9
|
+
cosine_distance: number;
|
|
10
|
+
cosine_similarity: number;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
error?: string;
|
|
13
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type Range = {
|
|
2
|
+
min: number;
|
|
3
|
+
max: number;
|
|
4
|
+
};
|
|
5
|
+
export type FaceConfig = {
|
|
6
|
+
analysisWidth: number;
|
|
7
|
+
analysisHeight: number;
|
|
8
|
+
ovalRxRatio: number;
|
|
9
|
+
ovalRyRatio: number;
|
|
10
|
+
minHeightRatio: number;
|
|
11
|
+
maxHeightRatio: number;
|
|
12
|
+
marginRatio: number;
|
|
13
|
+
meanLum: Range;
|
|
14
|
+
overexposureRatioMax: number;
|
|
15
|
+
blurScoreMin: number;
|
|
16
|
+
pose: {
|
|
17
|
+
yawMax: number;
|
|
18
|
+
pitchMax: number;
|
|
19
|
+
rollMax: number;
|
|
20
|
+
yawScale: number;
|
|
21
|
+
pitchScale: number;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export type IdPlacementConfig = {
|
|
25
|
+
rectAspect: number;
|
|
26
|
+
rectWidthScale: number;
|
|
27
|
+
rectWidthMin: number;
|
|
28
|
+
rectWidthMax: number;
|
|
29
|
+
rectTopOffset: number;
|
|
30
|
+
smoothAlpha: number;
|
|
31
|
+
missingHoldMs: number;
|
|
32
|
+
eyesHoldMs: number;
|
|
33
|
+
};
|
|
34
|
+
export type IdRoiConfig = {
|
|
35
|
+
detectionFps: number;
|
|
36
|
+
maxRoiWidth: number;
|
|
37
|
+
expandBelowRatio: number;
|
|
38
|
+
minFaceHeightRatio: number;
|
|
39
|
+
minFaceAreaRatio: number;
|
|
40
|
+
meanLum: Range;
|
|
41
|
+
blurScoreMin: number;
|
|
42
|
+
};
|
|
43
|
+
export type CaptureConfig = {
|
|
44
|
+
detectionFps: number;
|
|
45
|
+
stablePassCount: number;
|
|
46
|
+
face: FaceConfig;
|
|
47
|
+
id: {
|
|
48
|
+
placement: IdPlacementConfig;
|
|
49
|
+
roi: IdRoiConfig;
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
export declare const DEFAULT_CAPTURE_CONFIG: CaptureConfig;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type Landmark = {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
z?: number;
|
|
5
|
+
};
|
|
6
|
+
export type NormalizedBox = {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
w: number;
|
|
10
|
+
h: number;
|
|
11
|
+
};
|
|
12
|
+
export declare const computeFaceBox: (landmarks: Landmark[]) => NormalizedBox;
|
|
13
|
+
export declare const captureFrameToCanvas: (video: HTMLVideoElement, canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D) => void;
|
|
14
|
+
export declare const computeMeanLuminance: (imageData: ImageData) => number;
|
|
15
|
+
export declare const computeOverexposureRatio: (imageData: ImageData) => number;
|
|
16
|
+
export declare const computeBlurLaplacianVariance: (imageData: ImageData) => number;
|
|
17
|
+
export type PoseMetrics = {
|
|
18
|
+
yawDeg: number;
|
|
19
|
+
pitchDeg: number;
|
|
20
|
+
rollDeg: number;
|
|
21
|
+
valid: boolean;
|
|
22
|
+
};
|
|
23
|
+
export declare const estimatePoseFromLandmarks: (landmarks: Landmark[], config: {
|
|
24
|
+
yawScale: number;
|
|
25
|
+
pitchScale: number;
|
|
26
|
+
}) => PoseMetrics;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Landmark, type NormalizedBox } from './faceAnalysis';
|
|
2
|
+
import type { FaceConfig } from './config';
|
|
3
|
+
export type FaceCheckMetrics = {
|
|
4
|
+
faceCount: number;
|
|
5
|
+
faceBox: NormalizedBox | null;
|
|
6
|
+
meanLum: number;
|
|
7
|
+
blurScore: number;
|
|
8
|
+
overexposureRatio: number;
|
|
9
|
+
yawDeg: number;
|
|
10
|
+
pitchDeg: number;
|
|
11
|
+
rollDeg: number;
|
|
12
|
+
poseValid: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type FaceCheckResult = {
|
|
15
|
+
pass: boolean;
|
|
16
|
+
hint: string;
|
|
17
|
+
metrics: FaceCheckMetrics;
|
|
18
|
+
};
|
|
19
|
+
export declare const evaluateFace: (faceLandmarks: Landmark[][], meanLum: number, overexposureRatio: number, blurScore: number, config: FaceConfig) => FaceCheckResult;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Landmark, NormalizedBox } from './faceAnalysis';
|
|
2
|
+
import type { IdPlacementConfig } from './config';
|
|
3
|
+
export type NormalizedRect = NormalizedBox;
|
|
4
|
+
export type IdRectState = {
|
|
5
|
+
rect: NormalizedRect | null;
|
|
6
|
+
lastSeenMs: number;
|
|
7
|
+
};
|
|
8
|
+
export type IdRectUpdate = {
|
|
9
|
+
rect: NormalizedRect | null;
|
|
10
|
+
state: IdRectState;
|
|
11
|
+
missingTooLong: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare const updateIdRect: (state: IdRectState, landmarks: Landmark[] | null, faceBox: NormalizedBox | null, nowMs: number, config: IdPlacementConfig) => IdRectUpdate;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FaceConfig } from './config';
|
|
2
|
+
import type { NormalizedBox } from './faceAnalysis';
|
|
3
|
+
export type OverlayStep = 'FACE_ALIGN' | 'ID_ALIGN';
|
|
4
|
+
export type OverlayOptions = {
|
|
5
|
+
step: OverlayStep;
|
|
6
|
+
hint: string;
|
|
7
|
+
statusColor: 'green' | 'yellow' | 'red';
|
|
8
|
+
faceConfig: FaceConfig;
|
|
9
|
+
idRect: NormalizedBox | null;
|
|
10
|
+
videoSize?: {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export declare const drawOverlay: (ctx: CanvasRenderingContext2D, width: number, height: number, options: OverlayOptions) => void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const DEFAULT_CAPTURE_CONFIG = {
|
|
2
|
+
detectionFps: 6,
|
|
3
|
+
stablePassCount: 8,
|
|
4
|
+
face: {
|
|
5
|
+
analysisWidth: 320,
|
|
6
|
+
analysisHeight: 180,
|
|
7
|
+
ovalRxRatio: 0.23,
|
|
8
|
+
ovalRyRatio: 0.39,
|
|
9
|
+
minHeightRatio: 0.2,
|
|
10
|
+
maxHeightRatio: 0.8,
|
|
11
|
+
marginRatio: 0.03,
|
|
12
|
+
meanLum: { min: 70, max: 190 },
|
|
13
|
+
overexposureRatioMax: 0.52,
|
|
14
|
+
blurScoreMin: 120,
|
|
15
|
+
pose: {
|
|
16
|
+
yawMax: 15,
|
|
17
|
+
pitchMax: 12,
|
|
18
|
+
rollMax: 10,
|
|
19
|
+
yawScale: 50,
|
|
20
|
+
pitchScale: 45,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
id: {
|
|
24
|
+
placement: {
|
|
25
|
+
rectAspect: 1.586,
|
|
26
|
+
rectWidthScale: 1,
|
|
27
|
+
rectWidthMin: 0.45,
|
|
28
|
+
rectWidthMax: 0.85,
|
|
29
|
+
rectTopOffset: 0.03,
|
|
30
|
+
smoothAlpha: 0.35,
|
|
31
|
+
missingHoldMs: 1000,
|
|
32
|
+
eyesHoldMs: 1500,
|
|
33
|
+
},
|
|
34
|
+
roi: {
|
|
35
|
+
detectionFps: 6,
|
|
36
|
+
maxRoiWidth: 360,
|
|
37
|
+
expandBelowRatio: 3,
|
|
38
|
+
minFaceHeightRatio: 0.18,
|
|
39
|
+
minFaceAreaRatio: 0.03,
|
|
40
|
+
meanLum: { min: 70, max: 190 },
|
|
41
|
+
blurScoreMin: 90,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export const computeFaceBox = (landmarks) => {
|
|
2
|
+
let minX = Infinity;
|
|
3
|
+
let minY = Infinity;
|
|
4
|
+
let maxX = -Infinity;
|
|
5
|
+
let maxY = -Infinity;
|
|
6
|
+
for (const lm of landmarks) {
|
|
7
|
+
if (lm.x < minX)
|
|
8
|
+
minX = lm.x;
|
|
9
|
+
if (lm.y < minY)
|
|
10
|
+
minY = lm.y;
|
|
11
|
+
if (lm.x > maxX)
|
|
12
|
+
maxX = lm.x;
|
|
13
|
+
if (lm.y > maxY)
|
|
14
|
+
maxY = lm.y;
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
x: minX,
|
|
18
|
+
y: minY,
|
|
19
|
+
w: Math.max(0, maxX - minX),
|
|
20
|
+
h: Math.max(0, maxY - minY),
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
export const captureFrameToCanvas = (video, canvas, ctx) => {
|
|
24
|
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
25
|
+
};
|
|
26
|
+
export const computeMeanLuminance = (imageData) => {
|
|
27
|
+
const { data } = imageData;
|
|
28
|
+
const totalPixels = data.length / 4;
|
|
29
|
+
let sum = 0;
|
|
30
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
31
|
+
const r = data[i];
|
|
32
|
+
const g = data[i + 1];
|
|
33
|
+
const b = data[i + 2];
|
|
34
|
+
sum += 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
35
|
+
}
|
|
36
|
+
return totalPixels > 0 ? sum / totalPixels : 0;
|
|
37
|
+
};
|
|
38
|
+
export const computeOverexposureRatio = (imageData) => {
|
|
39
|
+
const { data } = imageData;
|
|
40
|
+
const totalPixels = data.length / 4;
|
|
41
|
+
let overexposed = 0;
|
|
42
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
43
|
+
const r = data[i];
|
|
44
|
+
const g = data[i + 1];
|
|
45
|
+
const b = data[i + 2];
|
|
46
|
+
if (r > 245 && g > 245 && b > 245) {
|
|
47
|
+
overexposed += 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return totalPixels > 0 ? overexposed / totalPixels : 0;
|
|
51
|
+
};
|
|
52
|
+
export const computeBlurLaplacianVariance = (imageData) => {
|
|
53
|
+
const { data, width, height } = imageData;
|
|
54
|
+
if (width < 3 || height < 3) {
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
const gray = new Float32Array(width * height);
|
|
58
|
+
for (let i = 0, p = 0; i < data.length; i += 4, p += 1) {
|
|
59
|
+
const r = data[i];
|
|
60
|
+
const g = data[i + 1];
|
|
61
|
+
const b = data[i + 2];
|
|
62
|
+
gray[p] = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
63
|
+
}
|
|
64
|
+
let sum = 0;
|
|
65
|
+
let sumSq = 0;
|
|
66
|
+
let count = 0;
|
|
67
|
+
for (let y = 1; y < height - 1; y += 1) {
|
|
68
|
+
for (let x = 1; x < width - 1; x += 1) {
|
|
69
|
+
const idx = y * width + x;
|
|
70
|
+
const lap = -4 * gray[idx] +
|
|
71
|
+
gray[idx - 1] +
|
|
72
|
+
gray[idx + 1] +
|
|
73
|
+
gray[idx - width] +
|
|
74
|
+
gray[idx + width];
|
|
75
|
+
sum += lap;
|
|
76
|
+
sumSq += lap * lap;
|
|
77
|
+
count += 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (count === 0) {
|
|
81
|
+
return 0;
|
|
82
|
+
}
|
|
83
|
+
const mean = sum / count;
|
|
84
|
+
return sumSq / count - mean * mean;
|
|
85
|
+
};
|
|
86
|
+
const NOSE_TIP_INDEX = 1;
|
|
87
|
+
const MOUTH_LEFT_INDEX = 61;
|
|
88
|
+
const MOUTH_RIGHT_INDEX = 291;
|
|
89
|
+
const LEFT_EYE_INDEX = 33;
|
|
90
|
+
const RIGHT_EYE_INDEX = 263;
|
|
91
|
+
const LEFT_CHEEK_INDEX = 234;
|
|
92
|
+
const RIGHT_CHEEK_INDEX = 454;
|
|
93
|
+
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
94
|
+
export const estimatePoseFromLandmarks = (landmarks, config) => {
|
|
95
|
+
const nose = landmarks[NOSE_TIP_INDEX];
|
|
96
|
+
const mouthLeft = landmarks[MOUTH_LEFT_INDEX];
|
|
97
|
+
const mouthRight = landmarks[MOUTH_RIGHT_INDEX];
|
|
98
|
+
const leftEye = landmarks[LEFT_EYE_INDEX];
|
|
99
|
+
const rightEye = landmarks[RIGHT_EYE_INDEX];
|
|
100
|
+
const leftCheek = landmarks[LEFT_CHEEK_INDEX];
|
|
101
|
+
const rightCheek = landmarks[RIGHT_CHEEK_INDEX];
|
|
102
|
+
if (!nose ||
|
|
103
|
+
!mouthLeft ||
|
|
104
|
+
!mouthRight ||
|
|
105
|
+
!leftEye ||
|
|
106
|
+
!rightEye ||
|
|
107
|
+
!leftCheek ||
|
|
108
|
+
!rightCheek) {
|
|
109
|
+
return {
|
|
110
|
+
yawDeg: 0,
|
|
111
|
+
pitchDeg: 0,
|
|
112
|
+
rollDeg: 0,
|
|
113
|
+
valid: false,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const rollRad = Math.atan2(rightEye.y - leftEye.y, rightEye.x - leftEye.x);
|
|
117
|
+
const rollDeg = (rollRad * 180) / Math.PI;
|
|
118
|
+
const distLeft = distance(nose, leftCheek);
|
|
119
|
+
const distRight = distance(nose, rightCheek);
|
|
120
|
+
const yawSignal = distLeft + distRight > 0
|
|
121
|
+
? (distRight - distLeft) / (distRight + distLeft)
|
|
122
|
+
: 0;
|
|
123
|
+
const yawDeg = yawSignal * config.yawScale;
|
|
124
|
+
const eyesMid = {
|
|
125
|
+
x: (leftEye.x + rightEye.x) / 2,
|
|
126
|
+
y: (leftEye.y + rightEye.y) / 2,
|
|
127
|
+
};
|
|
128
|
+
const mouthMid = {
|
|
129
|
+
x: (mouthLeft.x + mouthRight.x) / 2,
|
|
130
|
+
y: (mouthLeft.y + mouthRight.y) / 2,
|
|
131
|
+
};
|
|
132
|
+
const eyesToNose = distance(eyesMid, nose);
|
|
133
|
+
const noseToMouth = distance(nose, mouthMid);
|
|
134
|
+
const pitchSignal = eyesToNose + noseToMouth > 0
|
|
135
|
+
? (noseToMouth - eyesToNose) / (noseToMouth + eyesToNose)
|
|
136
|
+
: 0;
|
|
137
|
+
const pitchDeg = pitchSignal * config.pitchScale;
|
|
138
|
+
return {
|
|
139
|
+
yawDeg,
|
|
140
|
+
pitchDeg,
|
|
141
|
+
rollDeg,
|
|
142
|
+
valid: true,
|
|
143
|
+
};
|
|
144
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { computeFaceBox, estimatePoseFromLandmarks, } from './faceAnalysis';
|
|
2
|
+
export const evaluateFace = (faceLandmarks, meanLum, overexposureRatio, blurScore, config) => {
|
|
3
|
+
const faceCount = faceLandmarks.length;
|
|
4
|
+
let faceBox = null;
|
|
5
|
+
let pose = null;
|
|
6
|
+
if (faceCount === 1) {
|
|
7
|
+
faceBox = computeFaceBox(faceLandmarks[0]);
|
|
8
|
+
pose = estimatePoseFromLandmarks(faceLandmarks[0], config.pose);
|
|
9
|
+
}
|
|
10
|
+
let hint = '';
|
|
11
|
+
let pass = true;
|
|
12
|
+
if (faceCount === 0) {
|
|
13
|
+
hint = 'Show your face in the frame';
|
|
14
|
+
pass = false;
|
|
15
|
+
}
|
|
16
|
+
else if (faceCount > 1) {
|
|
17
|
+
hint = 'Only one person in frame';
|
|
18
|
+
pass = false;
|
|
19
|
+
}
|
|
20
|
+
else if (faceBox) {
|
|
21
|
+
if (faceBox.h < config.minHeightRatio) {
|
|
22
|
+
hint = 'Move closer';
|
|
23
|
+
pass = false;
|
|
24
|
+
}
|
|
25
|
+
else if (faceBox.h > config.maxHeightRatio) {
|
|
26
|
+
hint = 'Move a bit back';
|
|
27
|
+
pass = false;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const margin = config.marginRatio;
|
|
31
|
+
const insideMargins = faceBox.x >= margin &&
|
|
32
|
+
faceBox.y >= margin &&
|
|
33
|
+
faceBox.x + faceBox.w <= 1 - margin &&
|
|
34
|
+
faceBox.y + faceBox.h <= 1 - margin;
|
|
35
|
+
if (!insideMargins) {
|
|
36
|
+
hint = 'Center your face';
|
|
37
|
+
pass = false;
|
|
38
|
+
}
|
|
39
|
+
else if (pose?.valid && Math.abs(pose.yawDeg) > config.pose.yawMax) {
|
|
40
|
+
hint = pose.yawDeg > 0 ? 'Turn your face slightly right' : 'Turn your face slightly left';
|
|
41
|
+
pass = false;
|
|
42
|
+
}
|
|
43
|
+
else if (pose?.valid && Math.abs(pose.pitchDeg) > config.pose.pitchMax) {
|
|
44
|
+
hint = pose.pitchDeg > 0 ? 'Lower your chin slightly' : 'Raise your chin slightly';
|
|
45
|
+
pass = false;
|
|
46
|
+
}
|
|
47
|
+
else if (pose?.valid && Math.abs(pose.rollDeg) > config.pose.rollMax) {
|
|
48
|
+
hint = pose.rollDeg > 0 ? 'Tilt your head left slightly' : 'Tilt your head right slightly';
|
|
49
|
+
pass = false;
|
|
50
|
+
}
|
|
51
|
+
else if (meanLum < config.meanLum.min) {
|
|
52
|
+
hint = 'Move to a brighter place';
|
|
53
|
+
pass = false;
|
|
54
|
+
}
|
|
55
|
+
else if (meanLum > config.meanLum.max ||
|
|
56
|
+
overexposureRatio > config.overexposureRatioMax) {
|
|
57
|
+
hint = 'Avoid direct light';
|
|
58
|
+
pass = false;
|
|
59
|
+
}
|
|
60
|
+
else if (blurScore < config.blurScoreMin) {
|
|
61
|
+
hint = 'Hold still';
|
|
62
|
+
pass = false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (pass && !hint) {
|
|
67
|
+
hint = 'Hold still for a moment';
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
pass,
|
|
71
|
+
hint,
|
|
72
|
+
metrics: {
|
|
73
|
+
faceCount,
|
|
74
|
+
faceBox,
|
|
75
|
+
meanLum,
|
|
76
|
+
blurScore,
|
|
77
|
+
overexposureRatio,
|
|
78
|
+
yawDeg: pose?.yawDeg ?? 0,
|
|
79
|
+
pitchDeg: pose?.pitchDeg ?? 0,
|
|
80
|
+
rollDeg: pose?.rollDeg ?? 0,
|
|
81
|
+
poseValid: pose?.valid ?? false,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const NOSE_TIP_INDEX = 1;
|
|
2
|
+
const MOUTH_LEFT_INDEX = 61;
|
|
3
|
+
const MOUTH_RIGHT_INDEX = 291;
|
|
4
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
|
|
5
|
+
const clampRect = (rect) => {
|
|
6
|
+
const w = clamp(rect.w, 0, 1);
|
|
7
|
+
const h = clamp(rect.h, 0, 1);
|
|
8
|
+
const x = clamp(rect.x, 0, 1 - w);
|
|
9
|
+
const y = clamp(rect.y, 0, 1 - h);
|
|
10
|
+
return { x, y, w, h };
|
|
11
|
+
};
|
|
12
|
+
const lerp = (from, to, alpha) => from + (to - from) * alpha;
|
|
13
|
+
const smoothRect = (prev, next, alpha) => {
|
|
14
|
+
if (!prev) {
|
|
15
|
+
return next;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
x: lerp(prev.x, next.x, alpha),
|
|
19
|
+
y: lerp(prev.y, next.y, alpha),
|
|
20
|
+
w: lerp(prev.w, next.w, alpha),
|
|
21
|
+
h: lerp(prev.h, next.h, alpha),
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export const updateIdRect = (state, landmarks, faceBox, nowMs, config) => {
|
|
25
|
+
if (!landmarks || !faceBox) {
|
|
26
|
+
const missingTooLong = !state.rect ||
|
|
27
|
+
nowMs - state.lastSeenMs > config.missingHoldMs;
|
|
28
|
+
return {
|
|
29
|
+
rect: state.rect,
|
|
30
|
+
state,
|
|
31
|
+
missingTooLong,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
const nose = landmarks[NOSE_TIP_INDEX];
|
|
35
|
+
const mouthLeft = landmarks[MOUTH_LEFT_INDEX];
|
|
36
|
+
const mouthRight = landmarks[MOUTH_RIGHT_INDEX];
|
|
37
|
+
if (!nose || !mouthLeft || !mouthRight) {
|
|
38
|
+
const missingTooLong = !state.rect ||
|
|
39
|
+
nowMs - state.lastSeenMs > config.missingHoldMs;
|
|
40
|
+
return {
|
|
41
|
+
rect: state.rect,
|
|
42
|
+
state,
|
|
43
|
+
missingTooLong,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const mouthCenterX = (mouthLeft.x + mouthRight.x) / 2;
|
|
47
|
+
const rectWidth = clamp(faceBox.w * config.rectWidthScale, config.rectWidthMin, config.rectWidthMax);
|
|
48
|
+
const rectHeight = rectWidth / config.rectAspect;
|
|
49
|
+
const rectTopY = nose.y + config.rectTopOffset;
|
|
50
|
+
const nextRect = clampRect({
|
|
51
|
+
x: mouthCenterX - rectWidth / 2,
|
|
52
|
+
y: rectTopY,
|
|
53
|
+
w: rectWidth,
|
|
54
|
+
h: rectHeight,
|
|
55
|
+
});
|
|
56
|
+
const smoothed = smoothRect(state.rect, nextRect, config.smoothAlpha);
|
|
57
|
+
const updatedState = {
|
|
58
|
+
rect: smoothed,
|
|
59
|
+
lastSeenMs: nowMs,
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
rect: smoothed,
|
|
63
|
+
state: updatedState,
|
|
64
|
+
missingTooLong: false,
|
|
65
|
+
};
|
|
66
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const drawRoundedRect = (ctx, x, y, width, height, radius) => {
|
|
2
|
+
const r = Math.min(radius, width / 2, height / 2);
|
|
3
|
+
ctx.beginPath();
|
|
4
|
+
ctx.moveTo(x + r, y);
|
|
5
|
+
ctx.lineTo(x + width - r, y);
|
|
6
|
+
ctx.arcTo(x + width, y, x + width, y + r, r);
|
|
7
|
+
ctx.lineTo(x + width, y + height - r);
|
|
8
|
+
ctx.arcTo(x + width, y + height, x + width - r, y + height, r);
|
|
9
|
+
ctx.lineTo(x + r, y + height);
|
|
10
|
+
ctx.arcTo(x, y + height, x, y + height - r, r);
|
|
11
|
+
ctx.lineTo(x, y + r);
|
|
12
|
+
ctx.arcTo(x, y, x + r, y, r);
|
|
13
|
+
ctx.closePath();
|
|
14
|
+
};
|
|
15
|
+
export const drawOverlay = (ctx, width, height, options) => {
|
|
16
|
+
ctx.clearRect(0, 0, width, height);
|
|
17
|
+
const strokeColor = options.statusColor === 'green'
|
|
18
|
+
? '#46e3a8'
|
|
19
|
+
: options.statusColor === 'yellow'
|
|
20
|
+
? '#f7c452'
|
|
21
|
+
: '#ff6b6b';
|
|
22
|
+
if (options.step === 'FACE_ALIGN') {
|
|
23
|
+
const centerX = width / 2;
|
|
24
|
+
const centerY = height / 2;
|
|
25
|
+
const radiusX = options.faceConfig.ovalRxRatio * width;
|
|
26
|
+
const radiusY = options.faceConfig.ovalRyRatio * height;
|
|
27
|
+
ctx.save();
|
|
28
|
+
ctx.fillStyle = 'rgba(8, 12, 16, 0.55)';
|
|
29
|
+
ctx.fillRect(0, 0, width, height);
|
|
30
|
+
ctx.globalCompositeOperation = 'destination-out';
|
|
31
|
+
ctx.beginPath();
|
|
32
|
+
ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2);
|
|
33
|
+
ctx.fill();
|
|
34
|
+
ctx.restore();
|
|
35
|
+
ctx.save();
|
|
36
|
+
ctx.strokeStyle = strokeColor;
|
|
37
|
+
ctx.lineWidth = 3;
|
|
38
|
+
ctx.shadowColor = strokeColor;
|
|
39
|
+
ctx.shadowBlur = 12;
|
|
40
|
+
ctx.beginPath();
|
|
41
|
+
ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2);
|
|
42
|
+
ctx.stroke();
|
|
43
|
+
ctx.restore();
|
|
44
|
+
}
|
|
45
|
+
else if (options.idRect) {
|
|
46
|
+
const rect = options.idRect;
|
|
47
|
+
let rectX = rect.x * width;
|
|
48
|
+
let rectY = rect.y * height;
|
|
49
|
+
let rectW = rect.w * width;
|
|
50
|
+
let rectH = rect.h * height;
|
|
51
|
+
const videoSize = options.videoSize;
|
|
52
|
+
if (videoSize && videoSize.width > 0 && videoSize.height > 0) {
|
|
53
|
+
const scale = Math.max(width / videoSize.width, height / videoSize.height);
|
|
54
|
+
const drawWidth = videoSize.width * scale;
|
|
55
|
+
const drawHeight = videoSize.height * scale;
|
|
56
|
+
const offsetX = (width - drawWidth) / 2;
|
|
57
|
+
const offsetY = (height - drawHeight) / 2;
|
|
58
|
+
rectX = offsetX + rect.x * drawWidth;
|
|
59
|
+
rectY = offsetY + rect.y * drawHeight;
|
|
60
|
+
rectW = rect.w * drawWidth;
|
|
61
|
+
rectH = rect.h * drawHeight;
|
|
62
|
+
}
|
|
63
|
+
ctx.save();
|
|
64
|
+
ctx.fillStyle = 'rgba(8, 12, 16, 0.55)';
|
|
65
|
+
ctx.fillRect(0, 0, width, height);
|
|
66
|
+
ctx.globalCompositeOperation = 'destination-out';
|
|
67
|
+
drawRoundedRect(ctx, rectX, rectY, rectW, rectH, 18);
|
|
68
|
+
ctx.fill();
|
|
69
|
+
ctx.restore();
|
|
70
|
+
ctx.save();
|
|
71
|
+
ctx.strokeStyle = strokeColor;
|
|
72
|
+
ctx.lineWidth = 3;
|
|
73
|
+
ctx.shadowColor = strokeColor;
|
|
74
|
+
ctx.shadowBlur = 12;
|
|
75
|
+
drawRoundedRect(ctx, rectX, rectY, rectW, rectH, 18);
|
|
76
|
+
ctx.stroke();
|
|
77
|
+
ctx.restore();
|
|
78
|
+
}
|
|
79
|
+
const hintText = options.hint;
|
|
80
|
+
const fontSize = Math.max(9, Math.round(height * 0.02));
|
|
81
|
+
ctx.font = `600 ${fontSize}px "Sora", sans-serif`;
|
|
82
|
+
ctx.textAlign = 'center';
|
|
83
|
+
ctx.textBaseline = 'middle';
|
|
84
|
+
const textWidth = ctx.measureText(hintText).width;
|
|
85
|
+
const paddingX = 18;
|
|
86
|
+
const paddingY = 10;
|
|
87
|
+
const boxWidth = textWidth + paddingX * 2;
|
|
88
|
+
const boxHeight = fontSize + paddingY * 2;
|
|
89
|
+
const boxX = width / 2 - boxWidth / 2;
|
|
90
|
+
const boxY = height - boxHeight - Math.max(16, height * 0.06);
|
|
91
|
+
ctx.fillStyle = 'rgba(8, 12, 16, 0.7)';
|
|
92
|
+
drawRoundedRect(ctx, boxX, boxY, boxWidth, boxHeight, 18);
|
|
93
|
+
ctx.fill();
|
|
94
|
+
ctx.fillStyle = '#f8fafc';
|
|
95
|
+
ctx.fillText(hintText, width / 2, boxY + boxHeight / 2);
|
|
96
|
+
};
|