@rick427/react-native-liveness 0.2.4 → 0.3.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 +7 -14
- package/lib/module/LivenessCamera.js +27 -34
- package/lib/module/LivenessCamera.js.map +1 -1
- package/lib/module/LivenessCameraModal.js +0 -4
- package/lib/module/LivenessCameraModal.js.map +1 -1
- package/lib/module/livenessScoring.js +33 -61
- package/lib/module/livenessScoring.js.map +1 -1
- package/lib/module/useLivenessCamera.js +49 -75
- package/lib/module/useLivenessCamera.js.map +1 -1
- package/lib/typescript/src/LivenessCamera.d.ts +1 -1
- package/lib/typescript/src/LivenessCamera.d.ts.map +1 -1
- package/lib/typescript/src/LivenessCameraModal.d.ts +1 -1
- package/lib/typescript/src/LivenessCameraModal.d.ts.map +1 -1
- package/lib/typescript/src/livenessScoring.d.ts +13 -8
- package/lib/typescript/src/livenessScoring.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +2 -12
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/lib/typescript/src/useLivenessCamera.d.ts +0 -2
- package/lib/typescript/src/useLivenessCamera.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/LivenessCamera.tsx +10 -20
- package/src/LivenessCameraModal.tsx +0 -4
- package/src/livenessScoring.ts +54 -91
- package/src/types.ts +4 -18
- package/src/useLivenessCamera.ts +56 -96
package/src/livenessScoring.ts
CHANGED
|
@@ -1,98 +1,61 @@
|
|
|
1
1
|
import type { FaceData, FeedbackMessage } from './types';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
// require gestures. Natural blinks are involuntary and must never penalise the
|
|
5
|
-
// user. Liveness is confirmed via face presence, size, and frontal pose only.
|
|
6
|
-
const WEIGHTS = {
|
|
7
|
-
faceDetected: 0.25,
|
|
8
|
-
faceSize: 0.3,
|
|
9
|
-
headPose: 0.45,
|
|
10
|
-
} as const;
|
|
11
|
-
|
|
12
|
-
// Loosened from 0.20 / 0.65 — gives more room for the user to breathe
|
|
3
|
+
// ─── Thresholds ───────────────────────────────────────────────────────────────
|
|
13
4
|
const FACE_SIZE_MIN = 0.15;
|
|
14
5
|
const FACE_SIZE_MAX = 0.8;
|
|
15
|
-
const FACE_SIZE_SOFT_MARGIN = 0.05; // gradient ramp at each edge
|
|
16
|
-
|
|
17
|
-
// Loosened from 20° — ML Kit head pose is noisy, 25° still rejects clear tilts
|
|
18
|
-
const MAX_YAW_DEG = 25;
|
|
19
|
-
const MAX_PITCH_DEG = 25;
|
|
20
6
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
7
|
+
// ─── Challenge step ───────────────────────────────────────────────────────────
|
|
8
|
+
export type ChallengeStep = {
|
|
9
|
+
/** Text shown to the user while this step is active. */
|
|
10
|
+
instruction: FeedbackMessage;
|
|
11
|
+
/** How many consecutive ML Kit frames must pass the check to complete the step. */
|
|
12
|
+
framesRequired: number;
|
|
13
|
+
/** Returns true when the face satisfies this step's condition. */
|
|
14
|
+
check: (face: FaceData, frameWidth: number) => boolean;
|
|
26
15
|
};
|
|
27
16
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return { total, faceSize, headPose, eyesOpen: 0 };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function rollingAverage(scores: number[]): number {
|
|
77
|
-
if (scores.length === 0) return 0;
|
|
78
|
-
return scores.reduce((a, b) => a + b, 0) / scores.length;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function getFeedback(
|
|
82
|
-
face: FaceData,
|
|
83
|
-
frameWidth: number,
|
|
84
|
-
livenessConfirmed: boolean
|
|
85
|
-
): FeedbackMessage {
|
|
86
|
-
if (livenessConfirmed) return 'Liveness confirmed';
|
|
87
|
-
if (!face.detected) return 'Position your face in the circle';
|
|
88
|
-
|
|
89
|
-
const faceWidthRatio = face.bounds.width / frameWidth;
|
|
90
|
-
if (faceWidthRatio < FACE_SIZE_MIN) return 'Move closer';
|
|
91
|
-
if (faceWidthRatio > FACE_SIZE_MAX) return 'Move farther away';
|
|
92
|
-
|
|
93
|
-
const yawBad = Math.abs(face.yawAngle) >= MAX_YAW_DEG;
|
|
94
|
-
const pitchBad = Math.abs(face.pitchAngle) >= MAX_PITCH_DEG;
|
|
95
|
-
if (yawBad || pitchBad) return 'Look straight ahead';
|
|
96
|
-
|
|
97
|
-
return 'Hold still...';
|
|
98
|
-
}
|
|
17
|
+
/**
|
|
18
|
+
* Sequential liveness challenges.
|
|
19
|
+
* Each step completes when `framesRequired` consecutive frames pass `check`.
|
|
20
|
+
* The progress arc fills 1/N per step, then turns green at the end.
|
|
21
|
+
*/
|
|
22
|
+
export const CHALLENGE_STEPS: readonly ChallengeStep[] = [
|
|
23
|
+
{
|
|
24
|
+
instruction: 'Position your face in the circle',
|
|
25
|
+
framesRequired: 5,
|
|
26
|
+
check: (face, fw) => {
|
|
27
|
+
if (!face.detected || fw === 0) return false;
|
|
28
|
+
const ratio = face.bounds.width / fw;
|
|
29
|
+
return (
|
|
30
|
+
ratio >= FACE_SIZE_MIN &&
|
|
31
|
+
ratio <= FACE_SIZE_MAX &&
|
|
32
|
+
Math.abs(face.yawAngle) < 20 &&
|
|
33
|
+
Math.abs(face.pitchAngle) < 20
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
instruction: 'Turn your head slightly',
|
|
39
|
+
framesRequired: 4,
|
|
40
|
+
check: (face) => face.detected && Math.abs(face.yawAngle) > 15,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
instruction: 'Now look straight ahead',
|
|
44
|
+
framesRequired: 5,
|
|
45
|
+
check: (face) =>
|
|
46
|
+
face.detected &&
|
|
47
|
+
Math.abs(face.yawAngle) < 10 &&
|
|
48
|
+
Math.abs(face.pitchAngle) < 15,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
instruction: 'Now blink',
|
|
52
|
+
framesRequired: 2,
|
|
53
|
+
check: (face) => {
|
|
54
|
+
if (!face.detected) return false;
|
|
55
|
+
const l = face.leftEyeOpenProbability;
|
|
56
|
+
const r = face.rightEyeOpenProbability;
|
|
57
|
+
// -1 means ML Kit couldn't classify — don't count as a blink
|
|
58
|
+
return l >= 0 && l < 0.3 && r >= 0 && r < 0.3;
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
] as const;
|
package/src/types.ts
CHANGED
|
@@ -28,16 +28,15 @@ export type LivenessState =
|
|
|
28
28
|
|
|
29
29
|
export type FeedbackMessage =
|
|
30
30
|
| 'Position your face in the circle'
|
|
31
|
-
| '
|
|
32
|
-
| '
|
|
33
|
-
| '
|
|
34
|
-
| 'Hold still...'
|
|
35
|
-
| 'Stay still'
|
|
31
|
+
| 'Turn your head slightly'
|
|
32
|
+
| 'Now look straight ahead'
|
|
33
|
+
| 'Now blink'
|
|
36
34
|
| 'Liveness confirmed'
|
|
37
35
|
| '';
|
|
38
36
|
|
|
39
37
|
export type CaptureResult = {
|
|
40
38
|
photo: PhotoFile;
|
|
39
|
+
/** Always 1.0 — all challenges were passed before capture. */
|
|
41
40
|
livenessScore: number;
|
|
42
41
|
timestamp: number;
|
|
43
42
|
};
|
|
@@ -63,19 +62,6 @@ export type LivenessCameraProps = {
|
|
|
63
62
|
*/
|
|
64
63
|
countdownFrom?: number;
|
|
65
64
|
|
|
66
|
-
/**
|
|
67
|
-
* Per-frame score (0–1) a frame must reach to count as a good frame.
|
|
68
|
-
* Defaults to 0.65. Scored on face presence, size, and head pose only —
|
|
69
|
-
* eye openness is not used (passive detection, no gestures required).
|
|
70
|
-
*/
|
|
71
|
-
livenessThreshold?: number;
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Number of consecutive good frames required to confirm liveness.
|
|
75
|
-
* Defaults to 7 (~350ms at 20fps).
|
|
76
|
-
*/
|
|
77
|
-
confirmFrames?: number;
|
|
78
|
-
|
|
79
65
|
/**
|
|
80
66
|
* Style applied to the root container.
|
|
81
67
|
*/
|
package/src/useLivenessCamera.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { Camera, Frame } from 'react-native-vision-camera';
|
|
|
3
3
|
import { runAtTargetFps, useFrameProcessor } from 'react-native-vision-camera';
|
|
4
4
|
import { Worklets } from 'react-native-worklets-core';
|
|
5
5
|
import { useLivenessPlugin } from './LivenessDetector';
|
|
6
|
-
import {
|
|
6
|
+
import { CHALLENGE_STEPS } from './livenessScoring';
|
|
7
7
|
import type {
|
|
8
8
|
CaptureResult,
|
|
9
9
|
FaceData,
|
|
@@ -11,14 +11,9 @@ import type {
|
|
|
11
11
|
LivenessState,
|
|
12
12
|
} from './types';
|
|
13
13
|
|
|
14
|
-
const
|
|
15
|
-
// How many frames to decay consecutiveGood by on a bad frame.
|
|
16
|
-
// 1 means a single noisy/blink frame only undoes one good frame.
|
|
17
|
-
const CONSECUTIVE_DECAY = 1;
|
|
14
|
+
const TOTAL_STEPS = CHALLENGE_STEPS.length;
|
|
18
15
|
|
|
19
16
|
type Options = {
|
|
20
|
-
livenessThreshold: number;
|
|
21
|
-
confirmFrames: number;
|
|
22
17
|
countdownFrom: number;
|
|
23
18
|
soundEnabled: boolean;
|
|
24
19
|
cameraRef: React.RefObject<Camera | null>;
|
|
@@ -29,6 +24,7 @@ type Options = {
|
|
|
29
24
|
|
|
30
25
|
type LivenessCameraState = {
|
|
31
26
|
livenessState: LivenessState;
|
|
27
|
+
/** Challenge progress 0–1. Drives the arc. */
|
|
32
28
|
livenessScore: number;
|
|
33
29
|
countdown: number | null;
|
|
34
30
|
feedback: FeedbackMessage;
|
|
@@ -36,8 +32,6 @@ type LivenessCameraState = {
|
|
|
36
32
|
|
|
37
33
|
export function useLivenessCamera(options: Options) {
|
|
38
34
|
const {
|
|
39
|
-
livenessThreshold,
|
|
40
|
-
confirmFrames,
|
|
41
35
|
countdownFrom,
|
|
42
36
|
soundEnabled,
|
|
43
37
|
cameraRef,
|
|
@@ -48,27 +42,17 @@ export function useLivenessCamera(options: Options) {
|
|
|
48
42
|
|
|
49
43
|
const plugin = useLivenessPlugin();
|
|
50
44
|
|
|
51
|
-
//
|
|
52
|
-
const
|
|
53
|
-
const
|
|
54
|
-
const frameWidth = useRef(0);
|
|
45
|
+
// ── Step machine refs — mutated every frame, never cause re-renders ────────
|
|
46
|
+
const currentStepIdx = useRef(0);
|
|
47
|
+
const stepFrameCount = useRef(0);
|
|
55
48
|
const stateRef = useRef<LivenessState>('scanning');
|
|
56
49
|
const isCaptured = useRef(false);
|
|
57
50
|
|
|
58
|
-
// Feedback debouncing: only update the displayed text after the same message
|
|
59
|
-
// has been stable for FEEDBACK_DEBOUNCE_MS. Prevents rapid flickering when
|
|
60
|
-
// ML Kit oscillates between two states on consecutive frames.
|
|
61
|
-
const FEEDBACK_DEBOUNCE_MS = 400;
|
|
62
|
-
const feedbackTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
63
|
-
const pendingFeedback = useRef<FeedbackMessage>(
|
|
64
|
-
'Position your face in the circle'
|
|
65
|
-
);
|
|
66
|
-
|
|
67
51
|
const [state, setState] = useState<LivenessCameraState>({
|
|
68
52
|
livenessState: 'scanning',
|
|
69
53
|
livenessScore: 0,
|
|
70
54
|
countdown: null,
|
|
71
|
-
feedback:
|
|
55
|
+
feedback: CHALLENGE_STEPS[0]!.instruction,
|
|
72
56
|
});
|
|
73
57
|
|
|
74
58
|
const setLivenessState = useCallback((next: LivenessState) => {
|
|
@@ -76,37 +60,29 @@ export function useLivenessCamera(options: Options) {
|
|
|
76
60
|
setState((prev) => ({ ...prev, livenessState: next }));
|
|
77
61
|
}, []);
|
|
78
62
|
|
|
79
|
-
//
|
|
80
|
-
|
|
63
|
+
// ── Capture ────────────────────────────────────────────────────────────────
|
|
81
64
|
const capture = useCallback(async () => {
|
|
82
65
|
if (isCaptured.current || !cameraRef.current) return;
|
|
83
66
|
isCaptured.current = true;
|
|
84
|
-
|
|
85
67
|
setLivenessState('capturing');
|
|
86
|
-
|
|
87
68
|
try {
|
|
88
69
|
const photo = await cameraRef.current.takePhoto({
|
|
89
70
|
flash: 'off',
|
|
90
71
|
enableShutterSound: soundEnabled,
|
|
91
72
|
});
|
|
92
|
-
const score = rollingAverage(frameScores.current);
|
|
93
|
-
|
|
94
73
|
setLivenessState('done');
|
|
95
|
-
onCapture({ photo, livenessScore:
|
|
74
|
+
onCapture({ photo, livenessScore: 1, timestamp: Date.now() });
|
|
96
75
|
} catch (err) {
|
|
97
76
|
setLivenessState('error');
|
|
98
77
|
onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
99
78
|
}
|
|
100
79
|
}, [cameraRef, onCapture, onError, soundEnabled, setLivenessState]);
|
|
101
80
|
|
|
102
|
-
//
|
|
103
|
-
|
|
81
|
+
// ── Countdown ──────────────────────────────────────────────────────────────
|
|
104
82
|
const startCountdown = useCallback(() => {
|
|
105
83
|
setLivenessState('countdown');
|
|
106
84
|
let tick = countdownFrom;
|
|
107
|
-
|
|
108
85
|
setState((prev) => ({ ...prev, countdown: tick }));
|
|
109
|
-
|
|
110
86
|
const interval = setInterval(() => {
|
|
111
87
|
tick -= 1;
|
|
112
88
|
if (tick <= 0) {
|
|
@@ -117,23 +93,21 @@ export function useLivenessCamera(options: Options) {
|
|
|
117
93
|
setState((prev) => ({ ...prev, countdown: tick }));
|
|
118
94
|
}
|
|
119
95
|
}, 1000);
|
|
120
|
-
|
|
121
96
|
return () => clearInterval(interval);
|
|
122
97
|
}, [capture, countdownFrom, setLivenessState]);
|
|
123
98
|
|
|
124
|
-
//
|
|
125
|
-
|
|
99
|
+
// ── Per-frame face handler (runs on JS thread, called from worklet) ────────
|
|
126
100
|
const handleFaceData = useCallback(
|
|
127
101
|
(face: FaceData | null, width: number) => {
|
|
102
|
+
const s = stateRef.current;
|
|
128
103
|
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
104
|
+
s === 'confirmed' ||
|
|
105
|
+
s === 'countdown' ||
|
|
106
|
+
s === 'capturing' ||
|
|
107
|
+
s === 'done' ||
|
|
108
|
+
s === 'error'
|
|
109
|
+
)
|
|
133
110
|
return;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
frameWidth.current = width;
|
|
137
111
|
|
|
138
112
|
const safeFace: FaceData = face ?? {
|
|
139
113
|
detected: false,
|
|
@@ -146,65 +120,54 @@ export function useLivenessCamera(options: Options) {
|
|
|
146
120
|
smilingProbability: -1,
|
|
147
121
|
};
|
|
148
122
|
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
frameScores.current.push(total);
|
|
152
|
-
if (frameScores.current.length > WINDOW_SIZE) {
|
|
153
|
-
frameScores.current.shift();
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const avgScore = rollingAverage(frameScores.current);
|
|
123
|
+
const step = CHALLENGE_STEPS[currentStepIdx.current]!;
|
|
124
|
+
const stepMet = step.check(safeFace, width);
|
|
157
125
|
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
consecutiveGood.current += 1;
|
|
126
|
+
// Advance or gently decay the frame counter
|
|
127
|
+
if (stepMet) {
|
|
128
|
+
stepFrameCount.current += 1;
|
|
162
129
|
} else {
|
|
163
|
-
|
|
164
|
-
0,
|
|
165
|
-
consecutiveGood.current - CONSECUTIVE_DECAY
|
|
166
|
-
);
|
|
130
|
+
stepFrameCount.current = Math.max(0, stepFrameCount.current - 1);
|
|
167
131
|
}
|
|
168
132
|
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
133
|
+
// Step complete?
|
|
134
|
+
if (stepFrameCount.current >= step.framesRequired) {
|
|
135
|
+
stepFrameCount.current = 0;
|
|
136
|
+
const nextIdx = currentStepIdx.current + 1;
|
|
137
|
+
|
|
138
|
+
if (nextIdx >= TOTAL_STEPS) {
|
|
139
|
+
// All challenges done — confirm liveness
|
|
140
|
+
setState((prev) => ({
|
|
141
|
+
...prev,
|
|
142
|
+
livenessScore: 1,
|
|
143
|
+
feedback: 'Liveness confirmed',
|
|
144
|
+
}));
|
|
145
|
+
setLivenessState('confirmed');
|
|
146
|
+
onLivenessConfirmed?.();
|
|
147
|
+
startCountdown();
|
|
148
|
+
} else {
|
|
149
|
+
// Advance to next challenge
|
|
150
|
+
currentStepIdx.current = nextIdx;
|
|
182
151
|
setState((prev) => ({
|
|
183
152
|
...prev,
|
|
184
|
-
|
|
153
|
+
livenessScore: nextIdx / TOTAL_STEPS,
|
|
154
|
+
feedback: CHALLENGE_STEPS[nextIdx]!.instruction,
|
|
185
155
|
}));
|
|
186
|
-
}
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
187
158
|
}
|
|
188
159
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
}
|
|
160
|
+
// Smooth arc progress within the current step
|
|
161
|
+
const progress =
|
|
162
|
+
(currentStepIdx.current +
|
|
163
|
+
stepFrameCount.current / step.framesRequired) /
|
|
164
|
+
TOTAL_STEPS;
|
|
165
|
+
setState((prev) => ({ ...prev, livenessScore: progress }));
|
|
196
166
|
},
|
|
197
|
-
[
|
|
198
|
-
confirmFrames,
|
|
199
|
-
livenessThreshold,
|
|
200
|
-
onLivenessConfirmed,
|
|
201
|
-
setLivenessState,
|
|
202
|
-
startCountdown,
|
|
203
|
-
]
|
|
167
|
+
[onLivenessConfirmed, setLivenessState, startCountdown]
|
|
204
168
|
);
|
|
205
169
|
|
|
206
|
-
//
|
|
207
|
-
|
|
170
|
+
// ── Frame processor ────────────────────────────────────────────────────────
|
|
208
171
|
const handleFaceDataJS = useMemo(
|
|
209
172
|
() => Worklets.createRunOnJS(handleFaceData),
|
|
210
173
|
[handleFaceData]
|
|
@@ -213,8 +176,6 @@ export function useLivenessCamera(options: Options) {
|
|
|
213
176
|
const frameProcessor = useFrameProcessor(
|
|
214
177
|
(frame: Frame) => {
|
|
215
178
|
'worklet';
|
|
216
|
-
// Camera preview renders at full fps (60). ML Kit only needs ~20fps —
|
|
217
|
-
// running it on every frame would block the render thread unnecessarily.
|
|
218
179
|
runAtTargetFps(20, () => {
|
|
219
180
|
'worklet';
|
|
220
181
|
const face = plugin.detectLiveness(frame);
|
|
@@ -226,10 +187,9 @@ export function useLivenessCamera(options: Options) {
|
|
|
226
187
|
|
|
227
188
|
useEffect(() => {
|
|
228
189
|
return () => {
|
|
229
|
-
|
|
230
|
-
|
|
190
|
+
currentStepIdx.current = 0;
|
|
191
|
+
stepFrameCount.current = 0;
|
|
231
192
|
isCaptured.current = false;
|
|
232
|
-
if (feedbackTimer.current) clearTimeout(feedbackTimer.current);
|
|
233
193
|
};
|
|
234
194
|
}, []);
|
|
235
195
|
|