@rick427/react-native-liveness 0.1.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/LICENSE +20 -0
- package/LivenessCamera.podspec +26 -0
- package/README.md +167 -0
- package/android/build.gradle +80 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/livenesscamera/LivenessCameraPackage.kt +28 -0
- package/android/src/main/java/com/livenesscamera/LivenessCameraPlugin.kt +63 -0
- package/ios/LivenessCameraPlugin-Bridging-Header.h +3 -0
- package/ios/LivenessCameraPlugin.m +8 -0
- package/ios/LivenessCameraPlugin.swift +69 -0
- package/lib/module/LivenessCamera.js +283 -0
- package/lib/module/LivenessCamera.js.map +1 -0
- package/lib/module/LivenessDetector.js +23 -0
- package/lib/module/LivenessDetector.js.map +1 -0
- package/lib/module/index.js +5 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/livenessScoring.js +58 -0
- package/lib/module/livenessScoring.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +4 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/useLivenessCamera.js +167 -0
- package/lib/module/useLivenessCamera.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/LivenessCamera.d.ts +3 -0
- package/lib/typescript/src/LivenessCamera.d.ts.map +1 -0
- package/lib/typescript/src/LivenessDetector.d.ts +8 -0
- package/lib/typescript/src/LivenessDetector.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +4 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/livenessScoring.d.ts +11 -0
- package/lib/typescript/src/livenessScoring.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +61 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/useLivenessCamera.d.ts +21 -0
- package/lib/typescript/src/useLivenessCamera.d.ts.map +1 -0
- package/package.json +120 -0
- package/src/LivenessCamera.tsx +284 -0
- package/src/LivenessDetector.ts +34 -0
- package/src/index.ts +9 -0
- package/src/livenessScoring.ts +81 -0
- package/src/types.ts +88 -0
- package/src/useLivenessCamera.ts +206 -0
package/src/types.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ViewStyle } from 'react-native';
|
|
2
|
+
import type { PhotoFile } from 'react-native-vision-camera';
|
|
3
|
+
|
|
4
|
+
export type FaceData = {
|
|
5
|
+
detected: boolean;
|
|
6
|
+
bounds: {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
yawAngle: number;
|
|
13
|
+
pitchAngle: number;
|
|
14
|
+
rollAngle: number;
|
|
15
|
+
leftEyeOpenProbability: number;
|
|
16
|
+
rightEyeOpenProbability: number;
|
|
17
|
+
smilingProbability: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type LivenessState =
|
|
21
|
+
| 'idle'
|
|
22
|
+
| 'scanning'
|
|
23
|
+
| 'confirmed'
|
|
24
|
+
| 'countdown'
|
|
25
|
+
| 'capturing'
|
|
26
|
+
| 'done'
|
|
27
|
+
| 'error';
|
|
28
|
+
|
|
29
|
+
export type FeedbackMessage =
|
|
30
|
+
| 'Position your face in the oval'
|
|
31
|
+
| 'Move closer'
|
|
32
|
+
| 'Move farther away'
|
|
33
|
+
| 'Look straight ahead'
|
|
34
|
+
| 'Hold still...'
|
|
35
|
+
| 'Stay still'
|
|
36
|
+
| 'Open your eyes'
|
|
37
|
+
| 'Liveness confirmed'
|
|
38
|
+
| '';
|
|
39
|
+
|
|
40
|
+
export type CaptureResult = {
|
|
41
|
+
photo: PhotoFile;
|
|
42
|
+
livenessScore: number;
|
|
43
|
+
timestamp: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type LivenessCameraProps = {
|
|
47
|
+
/**
|
|
48
|
+
* Called when liveness is confirmed and photo is captured.
|
|
49
|
+
*/
|
|
50
|
+
onCapture: (result: CaptureResult) => void;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Called the moment liveness is confirmed, before the countdown starts.
|
|
54
|
+
*/
|
|
55
|
+
onLivenessConfirmed?: () => void;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Called on any unrecoverable error.
|
|
59
|
+
*/
|
|
60
|
+
onError?: (error: Error) => void;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Countdown start value. Defaults to 3.
|
|
64
|
+
*/
|
|
65
|
+
countdownFrom?: number;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Score (0–1) required to confirm liveness. Defaults to 0.75.
|
|
69
|
+
*/
|
|
70
|
+
livenessThreshold?: number;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Number of consecutive high-score frames required before liveness is
|
|
74
|
+
* confirmed. Defaults to 15 (~500ms at 30fps).
|
|
75
|
+
*/
|
|
76
|
+
confirmFrames?: number;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Style applied to the root container.
|
|
80
|
+
*/
|
|
81
|
+
style?: ViewStyle;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether to play a shutter sound on capture. Requires react-native-sound
|
|
85
|
+
* to be installed. Defaults to true.
|
|
86
|
+
*/
|
|
87
|
+
soundEnabled?: boolean;
|
|
88
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { Camera, Frame } from 'react-native-vision-camera';
|
|
3
|
+
import { useFrameProcessor } from 'react-native-vision-camera';
|
|
4
|
+
import { Worklets } from 'react-native-worklets-core';
|
|
5
|
+
import { useLivenessPlugin } from './LivenessDetector';
|
|
6
|
+
import { getFeedback, rollingAverage, scoreFrame } from './livenessScoring';
|
|
7
|
+
import type {
|
|
8
|
+
CaptureResult,
|
|
9
|
+
FaceData,
|
|
10
|
+
FeedbackMessage,
|
|
11
|
+
LivenessState,
|
|
12
|
+
} from './types';
|
|
13
|
+
|
|
14
|
+
const WINDOW_SIZE = 20;
|
|
15
|
+
|
|
16
|
+
type Options = {
|
|
17
|
+
livenessThreshold: number;
|
|
18
|
+
confirmFrames: number;
|
|
19
|
+
countdownFrom: number;
|
|
20
|
+
soundEnabled: boolean;
|
|
21
|
+
cameraRef: React.RefObject<Camera | null>;
|
|
22
|
+
onCapture: (result: CaptureResult) => void;
|
|
23
|
+
onLivenessConfirmed?: () => void;
|
|
24
|
+
onError?: (error: Error) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type LivenessCameraState = {
|
|
28
|
+
livenessState: LivenessState;
|
|
29
|
+
livenessScore: number;
|
|
30
|
+
countdown: number | null;
|
|
31
|
+
feedback: FeedbackMessage;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function useLivenessCamera(options: Options) {
|
|
35
|
+
const {
|
|
36
|
+
livenessThreshold,
|
|
37
|
+
confirmFrames,
|
|
38
|
+
countdownFrom,
|
|
39
|
+
soundEnabled,
|
|
40
|
+
cameraRef,
|
|
41
|
+
onCapture,
|
|
42
|
+
onLivenessConfirmed,
|
|
43
|
+
onError,
|
|
44
|
+
} = options;
|
|
45
|
+
|
|
46
|
+
const plugin = useLivenessPlugin();
|
|
47
|
+
|
|
48
|
+
// Mutable refs — updated every frame, never cause re-renders
|
|
49
|
+
const frameScores = useRef<number[]>([]);
|
|
50
|
+
const consecutiveGood = useRef(0);
|
|
51
|
+
const frameWidth = useRef(0);
|
|
52
|
+
const stateRef = useRef<LivenessState>('scanning');
|
|
53
|
+
const isCaptured = useRef(false);
|
|
54
|
+
|
|
55
|
+
const [state, setState] = useState<LivenessCameraState>({
|
|
56
|
+
livenessState: 'scanning',
|
|
57
|
+
livenessScore: 0,
|
|
58
|
+
countdown: null,
|
|
59
|
+
feedback: 'Position your face in the oval',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const setLivenessState = useCallback((next: LivenessState) => {
|
|
63
|
+
stateRef.current = next;
|
|
64
|
+
setState((prev) => ({ ...prev, livenessState: next }));
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
// ─── Capture ──────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
const capture = useCallback(async () => {
|
|
70
|
+
if (isCaptured.current || !cameraRef.current) return;
|
|
71
|
+
isCaptured.current = true;
|
|
72
|
+
|
|
73
|
+
setLivenessState('capturing');
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const photo = await cameraRef.current.takePhoto({
|
|
77
|
+
flash: 'off',
|
|
78
|
+
enableShutterSound: soundEnabled,
|
|
79
|
+
});
|
|
80
|
+
const score = rollingAverage(frameScores.current);
|
|
81
|
+
|
|
82
|
+
setLivenessState('done');
|
|
83
|
+
onCapture({ photo, livenessScore: score, timestamp: Date.now() });
|
|
84
|
+
} catch (err) {
|
|
85
|
+
setLivenessState('error');
|
|
86
|
+
onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
87
|
+
}
|
|
88
|
+
}, [cameraRef, onCapture, onError, soundEnabled, setLivenessState]);
|
|
89
|
+
|
|
90
|
+
// ─── Countdown ────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
const startCountdown = useCallback(() => {
|
|
93
|
+
setLivenessState('countdown');
|
|
94
|
+
let tick = countdownFrom;
|
|
95
|
+
|
|
96
|
+
setState((prev) => ({ ...prev, countdown: tick }));
|
|
97
|
+
|
|
98
|
+
const interval = setInterval(() => {
|
|
99
|
+
tick -= 1;
|
|
100
|
+
if (tick <= 0) {
|
|
101
|
+
clearInterval(interval);
|
|
102
|
+
setState((prev) => ({ ...prev, countdown: null }));
|
|
103
|
+
capture();
|
|
104
|
+
} else {
|
|
105
|
+
setState((prev) => ({ ...prev, countdown: tick }));
|
|
106
|
+
}
|
|
107
|
+
}, 1000);
|
|
108
|
+
|
|
109
|
+
return () => clearInterval(interval);
|
|
110
|
+
}, [capture, countdownFrom, setLivenessState]);
|
|
111
|
+
|
|
112
|
+
// ─── Per-frame face handler (runs on JS thread, called from worklet) ──────
|
|
113
|
+
|
|
114
|
+
const handleFaceData = useCallback(
|
|
115
|
+
(face: FaceData | null, width: number) => {
|
|
116
|
+
if (
|
|
117
|
+
stateRef.current === 'capturing' ||
|
|
118
|
+
stateRef.current === 'done' ||
|
|
119
|
+
stateRef.current === 'error'
|
|
120
|
+
) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
frameWidth.current = width;
|
|
125
|
+
|
|
126
|
+
const safeFace: FaceData = face ?? {
|
|
127
|
+
detected: false,
|
|
128
|
+
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
129
|
+
yawAngle: 0,
|
|
130
|
+
pitchAngle: 0,
|
|
131
|
+
rollAngle: 0,
|
|
132
|
+
leftEyeOpenProbability: -1,
|
|
133
|
+
rightEyeOpenProbability: -1,
|
|
134
|
+
smilingProbability: -1,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const { total } = scoreFrame(safeFace, width);
|
|
138
|
+
|
|
139
|
+
frameScores.current.push(total);
|
|
140
|
+
if (frameScores.current.length > WINDOW_SIZE) {
|
|
141
|
+
frameScores.current.shift();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const avgScore = rollingAverage(frameScores.current);
|
|
145
|
+
|
|
146
|
+
if (total >= livenessThreshold) {
|
|
147
|
+
consecutiveGood.current += 1;
|
|
148
|
+
} else {
|
|
149
|
+
consecutiveGood.current = 0;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const isLive =
|
|
153
|
+
consecutiveGood.current >= confirmFrames &&
|
|
154
|
+
avgScore >= livenessThreshold;
|
|
155
|
+
|
|
156
|
+
const feedback = getFeedback(safeFace, width, isLive);
|
|
157
|
+
|
|
158
|
+
setState((prev) => ({ ...prev, livenessScore: avgScore, feedback }));
|
|
159
|
+
|
|
160
|
+
if (isLive && stateRef.current === 'scanning') {
|
|
161
|
+
setLivenessState('confirmed');
|
|
162
|
+
onLivenessConfirmed?.();
|
|
163
|
+
startCountdown();
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
[
|
|
167
|
+
confirmFrames,
|
|
168
|
+
livenessThreshold,
|
|
169
|
+
onLivenessConfirmed,
|
|
170
|
+
setLivenessState,
|
|
171
|
+
startCountdown,
|
|
172
|
+
]
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// ─── Frame processor ──────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
const handleFaceDataJS = useMemo(
|
|
178
|
+
() => Worklets.createRunOnJS(handleFaceData),
|
|
179
|
+
[handleFaceData]
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const frameProcessor = useFrameProcessor(
|
|
183
|
+
(frame: Frame) => {
|
|
184
|
+
'worklet';
|
|
185
|
+
const face = plugin.detectLiveness(frame);
|
|
186
|
+
handleFaceDataJS(face, frame.width);
|
|
187
|
+
},
|
|
188
|
+
[plugin, handleFaceDataJS]
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
return () => {
|
|
193
|
+
frameScores.current = [];
|
|
194
|
+
consecutiveGood.current = 0;
|
|
195
|
+
isCaptured.current = false;
|
|
196
|
+
};
|
|
197
|
+
}, []);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
frameProcessor,
|
|
201
|
+
livenessState: state.livenessState,
|
|
202
|
+
livenessScore: state.livenessScore,
|
|
203
|
+
countdown: state.countdown,
|
|
204
|
+
feedback: state.feedback,
|
|
205
|
+
};
|
|
206
|
+
}
|