@rick427/react-native-liveness 0.2.3 → 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.
@@ -1,110 +1,61 @@
1
1
  import type { FaceData, FeedbackMessage } from './types';
2
2
 
3
- const WEIGHTS = {
4
- faceDetected: 0.2,
5
- faceSize: 0.2,
6
- headPose: 0.3,
7
- eyesOpen: 0.3,
8
- } as const;
9
-
10
- // Loosened from 0.20 / 0.65 — gives more room for the user to breathe
3
+ // ─── Thresholds ───────────────────────────────────────────────────────────────
11
4
  const FACE_SIZE_MIN = 0.15;
12
5
  const FACE_SIZE_MAX = 0.8;
13
- const FACE_SIZE_SOFT_MARGIN = 0.05; // gradient ramp at each edge
14
-
15
- // Loosened from 20° — ML Kit head pose is noisy, 25° still rejects clear tilts
16
- const MAX_YAW_DEG = 25;
17
- const MAX_PITCH_DEG = 25;
18
6
 
19
- export type FrameScore = {
20
- total: number;
21
- faceSize: number;
22
- headPose: number;
23
- eyesOpen: number;
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;
24
15
  };
25
16
 
26
- export function scoreFrame(face: FaceData, frameWidth: number): FrameScore {
27
- if (!face.detected || frameWidth === 0) {
28
- return { total: 0, faceSize: 0, headPose: 0, eyesOpen: 0 };
29
- }
30
-
31
- const faceWidthRatio = face.bounds.width / frameWidth;
32
-
33
- // Soft-edge face size: smooth ramp into / out of the valid range so a face
34
- // sitting just outside the threshold doesn't score hard 0.
35
- let faceSize: number;
36
- if (faceWidthRatio >= FACE_SIZE_MIN && faceWidthRatio <= FACE_SIZE_MAX) {
37
- faceSize = 1.0;
38
- } else if (faceWidthRatio < FACE_SIZE_MIN) {
39
- faceSize = Math.max(
40
- 0,
41
- (faceWidthRatio - (FACE_SIZE_MIN - FACE_SIZE_SOFT_MARGIN)) /
42
- FACE_SIZE_SOFT_MARGIN
43
- );
44
- } else {
45
- faceSize = Math.max(
46
- 0,
47
- (FACE_SIZE_MAX + FACE_SIZE_SOFT_MARGIN - faceWidthRatio) /
48
- FACE_SIZE_SOFT_MARGIN
49
- );
50
- }
51
-
52
- // Soft-edge head pose: full score inside threshold, linear decay outside
53
- const yawScore =
54
- Math.abs(face.yawAngle) <= MAX_YAW_DEG
55
- ? 1.0
56
- : Math.max(0, 1 - (Math.abs(face.yawAngle) - MAX_YAW_DEG) / MAX_YAW_DEG);
57
- const pitchScore =
58
- Math.abs(face.pitchAngle) <= MAX_PITCH_DEG
59
- ? 1.0
60
- : Math.max(
61
- 0,
62
- 1 - (Math.abs(face.pitchAngle) - MAX_PITCH_DEG) / MAX_PITCH_DEG
63
- );
64
- const headPose = (yawScore + pitchScore) / 2;
65
-
66
- // ML Kit returns -1 when classification is disabled or unavailable
67
- const leftEye =
68
- face.leftEyeOpenProbability >= 0 ? face.leftEyeOpenProbability : 0.5;
69
- const rightEye =
70
- face.rightEyeOpenProbability >= 0 ? face.rightEyeOpenProbability : 0.5;
71
- const eyesOpen = (leftEye + rightEye) / 2;
72
-
73
- const total =
74
- WEIGHTS.faceDetected * 1.0 +
75
- WEIGHTS.faceSize * faceSize +
76
- WEIGHTS.headPose * headPose +
77
- WEIGHTS.eyesOpen * eyesOpen;
78
-
79
- return { total, faceSize, headPose, eyesOpen };
80
- }
81
-
82
- export function rollingAverage(scores: number[]): number {
83
- if (scores.length === 0) return 0;
84
- return scores.reduce((a, b) => a + b, 0) / scores.length;
85
- }
86
-
87
- export function getFeedback(
88
- face: FaceData,
89
- frameWidth: number,
90
- livenessConfirmed: boolean
91
- ): FeedbackMessage {
92
- if (livenessConfirmed) return 'Liveness confirmed';
93
- if (!face.detected) return 'Position your face in the circle';
94
-
95
- const faceWidthRatio = face.bounds.width / frameWidth;
96
- if (faceWidthRatio < FACE_SIZE_MIN) return 'Move closer';
97
- if (faceWidthRatio > FACE_SIZE_MAX) return 'Move farther away';
98
-
99
- const yawBad = Math.abs(face.yawAngle) >= MAX_YAW_DEG;
100
- const pitchBad = Math.abs(face.pitchAngle) >= MAX_PITCH_DEG;
101
- if (yawBad || pitchBad) return 'Look straight ahead';
102
-
103
- const leftEye =
104
- face.leftEyeOpenProbability >= 0 ? face.leftEyeOpenProbability : 1;
105
- const rightEye =
106
- face.rightEyeOpenProbability >= 0 ? face.rightEyeOpenProbability : 1;
107
- if (leftEye < 0.4 || rightEye < 0.4) return 'Open your eyes';
108
-
109
- return 'Hold still...';
110
- }
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,17 +28,15 @@ export type LivenessState =
28
28
 
29
29
  export type FeedbackMessage =
30
30
  | 'Position your face in the circle'
31
- | 'Move closer'
32
- | 'Move farther away'
33
- | 'Look straight ahead'
34
- | 'Hold still...'
35
- | 'Stay still'
36
- | 'Open your eyes'
31
+ | 'Turn your head slightly'
32
+ | 'Now look straight ahead'
33
+ | 'Now blink'
37
34
  | 'Liveness confirmed'
38
35
  | '';
39
36
 
40
37
  export type CaptureResult = {
41
38
  photo: PhotoFile;
39
+ /** Always 1.0 — all challenges were passed before capture. */
42
40
  livenessScore: number;
43
41
  timestamp: number;
44
42
  };
@@ -64,17 +62,6 @@ export type LivenessCameraProps = {
64
62
  */
65
63
  countdownFrom?: number;
66
64
 
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 10 (~500ms at 20fps).
75
- */
76
- confirmFrames?: number;
77
-
78
65
  /**
79
66
  * Style applied to the root container.
80
67
  */
@@ -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 { getFeedback, rollingAverage, scoreFrame } from './livenessScoring';
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 WINDOW_SIZE = 20;
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
- // Mutable refs — updated every frame, never cause re-renders
52
- const frameScores = useRef<number[]>([]);
53
- const consecutiveGood = useRef(0);
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: 'Position your face in the circle',
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
- // ─── Capture ──────────────────────────────────────────────────────────────
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: score, timestamp: Date.now() });
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
- // ─── Countdown ────────────────────────────────────────────────────────────
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
- // ─── Per-frame face handler (runs on JS thread, called from worklet) ──────
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
- stateRef.current === 'capturing' ||
130
- stateRef.current === 'done' ||
131
- stateRef.current === 'error'
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 { total } = scoreFrame(safeFace, width);
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
- // Decay on bad frames instead of hard reset — one noisy ML Kit result
159
- // won't wipe out progress the user has already built up.
160
- if (total >= livenessThreshold) {
161
- consecutiveGood.current += 1;
126
+ // Advance or gently decay the frame counter
127
+ if (stepMet) {
128
+ stepFrameCount.current += 1;
162
129
  } else {
163
- consecutiveGood.current = Math.max(
164
- 0,
165
- consecutiveGood.current - CONSECUTIVE_DECAY
166
- );
130
+ stepFrameCount.current = Math.max(0, stepFrameCount.current - 1);
167
131
  }
168
132
 
169
- // Consecutive-frames gate only. The avgScore is displayed on the arc but
170
- // not used as a confirmation gate — the rolling window starts empty so
171
- // early frames (before the face was positioned) drag the average down and
172
- // would prevent confirmation even when the face is clearly live.
173
- const isLive = consecutiveGood.current >= confirmFrames;
174
-
175
- // Debounce the feedback text: only apply a new message after it has been
176
- // stable for FEEDBACK_DEBOUNCE_MS, preventing rapid label flickering.
177
- const newFeedback = getFeedback(safeFace, width, isLive);
178
- if (newFeedback !== pendingFeedback.current) {
179
- pendingFeedback.current = newFeedback;
180
- if (feedbackTimer.current) clearTimeout(feedbackTimer.current);
181
- feedbackTimer.current = setTimeout(() => {
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
- feedback: pendingFeedback.current,
153
+ livenessScore: nextIdx / TOTAL_STEPS,
154
+ feedback: CHALLENGE_STEPS[nextIdx]!.instruction,
185
155
  }));
186
- }, FEEDBACK_DEBOUNCE_MS);
156
+ }
157
+ return;
187
158
  }
188
159
 
189
- setState((prev) => ({ ...prev, livenessScore: avgScore }));
190
-
191
- if (isLive && stateRef.current === 'scanning') {
192
- setLivenessState('confirmed');
193
- onLivenessConfirmed?.();
194
- startCountdown();
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
- // ─── Frame processor ──────────────────────────────────────────────────────
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
- frameScores.current = [];
230
- consecutiveGood.current = 0;
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