├── .expo-shared
└── assets.json
├── .gitignore
├── .vscode
└── tasks.json
├── App.tsx
├── README.md
├── app.json
├── assets
├── adaptive-icon.png
├── favicon.png
├── icon.png
└── splash.png
├── babel.config.js
├── package.json
├── src
├── CameraPreviewMask.tsx
├── Home.tsx
├── Liveness.android.tsx
├── Liveness.tsx
├── SelfieSvg.tsx
└── contains.ts
├── tsconfig.json
└── yarn.lock
/.expo-shared/assets.json:
--------------------------------------------------------------------------------
1 | {
2 | "12bb71342c6255bbf50437ec8f4441c083f47cdb74bd89160c15e4f43e52a1cb": true,
3 | "40b842e832070c58deac6aa9e08fa459302ee3f9da492c7e77d93d2fbf4a56fd": true
4 | }
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/**/*
2 | .expo/*
3 | npm-debug.*
4 | *.jks
5 | *.p8
6 | *.p12
7 | *.key
8 | *.mobileprovision
9 | *.orig.*
10 | web-build/
11 |
12 | # macOS
13 | .DS_Store
14 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "npm",
6 | "script": "start",
7 | "problemMatcher": [],
8 | "label": "npm: start",
9 | "detail": "expo start"
10 | }
11 | ]
12 | }
--------------------------------------------------------------------------------
/App.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { NavigationContainer } from "@react-navigation/native"
3 | import { createStackNavigator } from "@react-navigation/stack"
4 | import Liveness from "./src/Liveness"
5 | import Home from "./src/Home"
6 |
7 | const Stack = createStackNavigator()
8 |
9 | const App = () => {
10 | return (
11 |
12 |
13 |
18 |
19 |
20 |
21 | )
22 | }
23 |
24 | export default App
25 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # liveness-detection-react-native
2 |
3 | Codebase in blog post:
4 |
5 | Intro to Liveness Detection with React Native
6 |
7 | https://osamaqarem.com/blog/intro-to-liveness-detection-with-react-native
8 |
9 | To see demo run `yarn` and then `npm run start`. Download Expo Go on your device and scan the QR code that appears after running npm run start in the terminal. You will need to use a real device to see the demo in action since it's not supported on an emulator/simulator.
10 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "liveness-detection",
4 | "slug": "liveness-detection",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "splash": {
9 | "image": "./assets/splash.png",
10 | "resizeMode": "contain",
11 | "backgroundColor": "#ffffff"
12 | },
13 | "updates": {
14 | "fallbackToCacheTimeout": 0
15 | },
16 | "assetBundlePatterns": ["**/*"],
17 | "ios": {
18 | "supportsTablet": true
19 | },
20 | "android": {
21 | "adaptiveIcon": {
22 | "foregroundImage": "./assets/adaptive-icon.png",
23 | "backgroundColor": "#FFFFFF"
24 | }
25 | },
26 | "web": {
27 | "favicon": "./assets/favicon.png"
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osamaqarem/liveness-detection-react-native/d31b81327bb23856eea612331c8294ccb4c6eff6/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osamaqarem/liveness-detection-react-native/d31b81327bb23856eea612331c8294ccb4c6eff6/assets/favicon.png
--------------------------------------------------------------------------------
/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osamaqarem/liveness-detection-react-native/d31b81327bb23856eea612331c8294ccb4c6eff6/assets/icon.png
--------------------------------------------------------------------------------
/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osamaqarem/liveness-detection-react-native/d31b81327bb23856eea612331c8294ccb4c6eff6/assets/splash.png
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function(api) {
2 | api.cache(true);
3 | return {
4 | presets: ['babel-preset-expo'],
5 | };
6 | };
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "main": "node_modules/expo/AppEntry.js",
3 | "scripts": {
4 | "start": "expo start",
5 | "android": "expo start --android",
6 | "ios": "expo start --ios",
7 | "web": "expo start --web",
8 | "eject": "expo eject"
9 | },
10 | "dependencies": {
11 | "@react-native-masked-view/masked-view": "0.2.8",
12 | "@react-navigation/native": "^5.8.10",
13 | "@react-navigation/stack": "^5.12.8",
14 | "expo": "^48.0.0",
15 | "expo-camera": "~13.2.1",
16 | "expo-face-detector": "~12.1.1",
17 | "expo-status-bar": "~1.4.4",
18 | "react": "18.2.0",
19 | "react-dom": "18.2.0",
20 | "react-native": "0.71.8",
21 | "react-native-circular-progress": "^1.3.6",
22 | "react-native-gesture-handler": "~2.9.0",
23 | "react-native-reanimated": "~2.14.4",
24 | "react-native-safe-area-context": "4.5.0",
25 | "react-native-screens": "~3.20.0",
26 | "react-native-svg": "13.4.0",
27 | "react-native-web": "~0.18.11"
28 | },
29 | "devDependencies": {
30 | "@babel/core": "^7.20.0",
31 | "@types/react": "~18.0.27",
32 | "@types/react-dom": "~18.0.10",
33 | "@types/react-native": "~0.63.2",
34 | "typescript": "^4.9.4"
35 | },
36 | "private": true
37 | }
38 |
--------------------------------------------------------------------------------
/src/CameraPreviewMask.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import Svg, { SvgProps, Path } from "react-native-svg"
3 |
4 | const CameraPreviewMask = (props: SvgProps) => (
5 |
13 | )
14 |
15 | export default CameraPreviewMask
16 |
--------------------------------------------------------------------------------
/src/Home.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { useNavigation } from "@react-navigation/native"
3 | import { SafeAreaView, View, Text, StyleSheet } from "react-native"
4 | import { TouchableOpacity } from "react-native-gesture-handler"
5 | import SelfieSvg from "./SelfieSvg"
6 |
7 | const Home = () => {
8 | const navigation = useNavigation()
9 | const startDetection = () => navigation.navigate("Detection")
10 |
11 | return (
12 |
13 | Liveness Detection
14 |
15 |
16 |
17 | START
18 |
19 |
20 |
21 | )
22 | }
23 |
24 | const styles = StyleSheet.create({
25 | container: {
26 | flex: 1,
27 | backgroundColor: "#f8f8f8"
28 | },
29 | title: {
30 | fontSize: 64,
31 | fontWeight: "bold",
32 | alignSelf: "center",
33 | marginTop: 50,
34 | color: "#1e293b",
35 | zIndex: 1
36 | },
37 | selfieSvg: {
38 | position: "absolute",
39 | bottom: 58
40 | },
41 | bottomContainer: {
42 | flex: 1,
43 | justifyContent: "flex-end",
44 | alignItems: "center",
45 | marginBottom: 25
46 | },
47 | btn: {
48 | width: 300,
49 | height: 60,
50 | borderRadius: 5,
51 | justifyContent: "center",
52 | backgroundColor: "#334155"
53 | },
54 | btnText: {
55 | fontSize: 24,
56 | textAlign: "center",
57 | color: "white"
58 | }
59 | })
60 |
61 | export default Home
62 |
--------------------------------------------------------------------------------
/src/Liveness.android.tsx:
--------------------------------------------------------------------------------
1 | import * as FaceDetector from "expo-face-detector"
2 | import React, { useEffect, useReducer, useRef, useState } from "react"
3 | import { StyleSheet, Text, View, Dimensions, PixelRatio, Alert } from "react-native"
4 | import { Camera, FaceDetectionResult } from "expo-camera"
5 | import { AnimatedCircularProgress } from "react-native-circular-progress"
6 | import { useNavigation } from "@react-navigation/native"
7 | import Svg, { Path, SvgProps } from "react-native-svg"
8 |
9 | const { width: windowWidth } = Dimensions.get("window")
10 |
11 | // TODO: Thresholds are different for MLKit Android
12 | // TODO: Camera preview size takes actual specified size and not the entire screen.
13 |
14 | interface FaceDetection {
15 | rollAngle: number
16 | yawAngle: number
17 | smilingProbability: number
18 | leftEyeOpenProbability: number
19 | rightEyeOpenProbability: number
20 | bounds: {
21 | origin: {
22 | x: number
23 | y: number
24 | }
25 | size: {
26 | width: number
27 | height: number
28 | }
29 | }
30 | }
31 |
32 | const detections = {
33 | BLINK: { promptText: "Blink both eyes", minProbability: 0.4 },
34 | TURN_HEAD_LEFT: { promptText: "Turn head left", maxAngle: 310 },
35 | TURN_HEAD_RIGHT: { promptText: "Turn head right", minAngle: 50 },
36 | NOD: { promptText: "Nod", minDiff: 1 },
37 | SMILE: { promptText: "Smile", minProbability: 0.7 }
38 | }
39 |
40 | type DetectionActions = keyof typeof detections
41 |
42 | const promptsText = {
43 | noFaceDetected: "No face detected",
44 | performActions: "Perform the following actions:"
45 | }
46 |
47 | const detectionsList: DetectionActions[] = [
48 | "BLINK",
49 | "TURN_HEAD_LEFT",
50 | "TURN_HEAD_RIGHT",
51 | "NOD",
52 | "SMILE"
53 | ]
54 |
55 | const initialState = {
56 | faceDetected: false,
57 | promptText: promptsText.noFaceDetected,
58 | detectionsList,
59 | currentDetectionIndex: 0,
60 | progressFill: 0,
61 | processComplete: false
62 | }
63 |
64 | export default function Liveness() {
65 | const navigation = useNavigation()
66 | const [hasPermission, setHasPermission] = useState(false)
67 | const [state, dispatch] = useReducer(detectionReducer, initialState)
68 | const rollAngles = useRef([])
69 | const rect = useRef(null)
70 |
71 | useEffect(() => {
72 | const requestPermissions = async () => {
73 | const { status } = await Camera.requestCameraPermissionsAsync()
74 | setHasPermission(status === "granted")
75 | }
76 |
77 | requestPermissions()
78 | }, [])
79 |
80 | const drawFaceRect = (face: FaceDetection) => {
81 | rect.current?.setNativeProps({
82 | width: face.bounds.size.width,
83 | height: face.bounds.size.height,
84 | top: face.bounds.origin.y,
85 | left: face.bounds.origin.x
86 | })
87 | }
88 |
89 | const onFacesDetected = (result: FaceDetectionResult) => {
90 | if (result.faces.length !== 1) {
91 | dispatch({ type: "FACE_DETECTED", value: "no" })
92 | return
93 | }
94 |
95 | //@ts-ignore
96 | const face: FaceDetection = result.faces[0]
97 |
98 | // offset used to get the center of the face, instead of top left corner
99 | const midFaceOffsetY = face.bounds.size.height / 2
100 | const midFaceOffsetX = face.bounds.size.width / 2
101 |
102 | drawFaceRect(face)
103 | // make sure face is centered
104 | const faceMidYPoint = face.bounds.origin.y + midFaceOffsetY
105 | console.log(`
106 | face.bounds.origin.y: ${face.bounds.origin.y}
107 |
108 | `)
109 | if (
110 | // if middle of face is outside the preview towards the top
111 | faceMidYPoint <= PREVIEW_MARGIN_TOP ||
112 | // if middle of face is outside the preview towards the bottom
113 | faceMidYPoint >= PREVIEW_SIZE + PREVIEW_MARGIN_TOP
114 | ) {
115 | dispatch({ type: "FACE_DETECTED", value: "no" })
116 | return
117 | }
118 |
119 | const faceMidXPoint = face.bounds.origin.x + midFaceOffsetX
120 | if (
121 | // if face is outside the preview towards the left
122 | faceMidXPoint <= windowWidth / 2 - PREVIEW_SIZE / 2 ||
123 | // if face is outside the preview towards the right
124 | faceMidXPoint >= windowWidth / 2 + PREVIEW_SIZE / 2
125 | ) {
126 | dispatch({ type: "FACE_DETECTED", value: "no" })
127 | return
128 | }
129 |
130 | // drawFaceRect(face)
131 |
132 | if (!state.faceDetected) {
133 | dispatch({ type: "FACE_DETECTED", value: "yes" })
134 | }
135 |
136 | const detectionAction = state.detectionsList[state.currentDetectionIndex]
137 |
138 | switch (detectionAction) {
139 | case "BLINK":
140 | // lower probabiltiy is when eyes are closed
141 | const leftEyeClosed =
142 | face.leftEyeOpenProbability <= detections.BLINK.minProbability
143 | const rightEyeClosed =
144 | face.rightEyeOpenProbability <= detections.BLINK.minProbability
145 | if (leftEyeClosed && rightEyeClosed) {
146 | dispatch({ type: "NEXT_DETECTION", value: null })
147 | }
148 | return
149 | case "NOD":
150 | // Collect roll angle data
151 | rollAngles.current.push(face.rollAngle)
152 |
153 | // Don't keep more than 10 roll angles
154 | if (rollAngles.current.length > 10) {
155 | rollAngles.current.shift()
156 | }
157 |
158 | // If not enough roll angle data, then don't process
159 | if (rollAngles.current.length < 10) return
160 |
161 | // Calculate avg from collected data, except current angle data
162 | const rollAnglesExceptCurrent = [...rollAngles.current].splice(
163 | 0,
164 | rollAngles.current.length - 1
165 | )
166 | const rollAnglesSum = rollAnglesExceptCurrent.reduce((prev, curr) => {
167 | return prev + Math.abs(curr)
168 | }, 0)
169 | const avgAngle = rollAnglesSum / rollAnglesExceptCurrent.length
170 |
171 | // If the difference between the current angle and the average is above threshold, pass.
172 | const diff = Math.abs(avgAngle - Math.abs(face.rollAngle))
173 |
174 | // console.log(`
175 | // avgAngle: ${avgAngle}
176 | // rollAngle: ${face.rollAngle}
177 | // diff: ${diff}
178 | // `)
179 | if (diff >= detections.NOD.minDiff) {
180 | dispatch({ type: "NEXT_DETECTION", value: null })
181 | }
182 | return
183 | case "TURN_HEAD_LEFT":
184 | // console.log("TURN_HEAD_LEFT " + face.yawAngle)
185 | if (face.yawAngle <= detections.TURN_HEAD_LEFT.maxAngle) {
186 | dispatch({ type: "NEXT_DETECTION", value: null })
187 | }
188 | return
189 | case "TURN_HEAD_RIGHT":
190 | // console.log("TURN_HEAD_RIGHT " + face.yawAngle)
191 | if (face.yawAngle >= detections.TURN_HEAD_RIGHT.minAngle) {
192 | dispatch({ type: "NEXT_DETECTION", value: null })
193 | }
194 | return
195 | case "SMILE":
196 | // higher probabiltiy is when smiling
197 | // console.log(face.smilingProbability)
198 | if (face.smilingProbability >= detections.SMILE.minProbability) {
199 | dispatch({ type: "NEXT_DETECTION", value: null })
200 | }
201 | return
202 | }
203 | }
204 |
205 | useEffect(() => {
206 | if (state.processComplete) {
207 | Alert.alert('Liveness', 'Liveness check passed')
208 | setTimeout(() => {
209 | // delay so we can see progress fill aniamtion (500ms)
210 | navigation.goBack()
211 | }, 750)
212 | }
213 | }, [state.processComplete])
214 |
215 | if (hasPermission === false) {
216 | return No access to camera
217 | }
218 |
219 | return (
220 |
221 |
231 |
242 |
253 |
254 |
266 |
267 |
276 |
277 |
286 |
287 |
288 | {!state.faceDetected && promptsText.noFaceDetected}
289 |
290 |
291 | {state.faceDetected && promptsText.performActions}
292 |
293 |
294 | {state.faceDetected &&
295 | detections[state.detectionsList[state.currentDetectionIndex]]
296 | .promptText}
297 |
298 |
299 |
300 | )
301 | }
302 |
303 | interface Action {
304 | type: T
305 | value: Actions[T]
306 | }
307 | interface Actions {
308 | FACE_DETECTED: "yes" | "no"
309 | NEXT_DETECTION: null
310 | }
311 |
312 | const detectionReducer = (
313 | state: typeof initialState,
314 | action: Action
315 | ): typeof initialState => {
316 | const numDetections = state.detectionsList.length
317 | // +1 for face detection
318 | const newProgressFill =
319 | (100 / (numDetections + 1)) * (state.currentDetectionIndex + 1)
320 |
321 | switch (action.type) {
322 | case "FACE_DETECTED":
323 | if (action.value === "yes") {
324 | return { ...state, faceDetected: true, progressFill: newProgressFill }
325 | } else {
326 | // Reset
327 | return initialState
328 | }
329 | case "NEXT_DETECTION":
330 | const nextIndex = state.currentDetectionIndex + 1
331 | if (nextIndex === numDetections) {
332 | // success
333 | return { ...state, processComplete: true, progressFill: 100 }
334 | }
335 | // next
336 | return {
337 | ...state,
338 | currentDetectionIndex: nextIndex,
339 | progressFill: newProgressFill
340 | }
341 | default:
342 | throw new Error("Unexpeceted action type.")
343 | }
344 | }
345 |
346 | const CameraPreviewMask = (props: SvgProps) => (
347 |
355 | )
356 |
357 | const PREVIEW_MARGIN_TOP = 50
358 | const PREVIEW_SIZE = 300
359 |
360 | const styles = StyleSheet.create({
361 | actionPrompt: {
362 | fontSize: 20,
363 | textAlign: "center"
364 | },
365 | container: {
366 | flex: 1,
367 | backgroundColor: "#fff"
368 | },
369 | promptContainer: {
370 | position: "absolute",
371 | alignSelf: "center",
372 | top: PREVIEW_MARGIN_TOP + PREVIEW_SIZE,
373 | height: "100%",
374 | width: "100%",
375 | backgroundColor: "white"
376 | },
377 | faceStatus: {
378 | fontSize: 24,
379 | textAlign: "center",
380 | marginTop: 10
381 | },
382 | cameraPreview: {
383 | flex: 1
384 | },
385 | circularProgress: {
386 | position: "absolute",
387 | width: PREVIEW_SIZE,
388 | height: PREVIEW_SIZE,
389 | top: PREVIEW_MARGIN_TOP,
390 | alignSelf: "center"
391 | },
392 | action: {
393 | fontSize: 24,
394 | textAlign: "center",
395 | marginTop: 10,
396 | fontWeight: "bold"
397 | }
398 | })
399 |
--------------------------------------------------------------------------------
/src/Liveness.tsx:
--------------------------------------------------------------------------------
1 | import * as FaceDetector from "expo-face-detector"
2 | import * as React from "react"
3 | import { useNavigation } from "@react-navigation/native"
4 | import { Camera, FaceDetectionResult } from "expo-camera"
5 | import { Alert, Dimensions, StyleSheet, Text, View } from "react-native"
6 | import { AnimatedCircularProgress } from "react-native-circular-progress"
7 | import { contains, Rect } from "./contains"
8 | import MaskedView from "@react-native-masked-view/masked-view"
9 |
10 | interface FaceDetection {
11 | rollAngle: number
12 | yawAngle: number
13 | smilingProbability: number
14 | leftEyeOpenProbability: number
15 | rightEyeOpenProbability: number
16 | bounds: {
17 | origin: {
18 | x: number
19 | y: number
20 | }
21 | size: {
22 | width: number
23 | height: number
24 | }
25 | }
26 | }
27 |
28 | const { width: windowWidth } = Dimensions.get("window")
29 |
30 | const PREVIEW_SIZE = 325
31 | const PREVIEW_RECT: Rect = {
32 | minX: (windowWidth - PREVIEW_SIZE) / 2,
33 | minY: 50,
34 | width: PREVIEW_SIZE,
35 | height: PREVIEW_SIZE
36 | }
37 |
38 | const instructionsText = {
39 | initialPrompt: "Position your face in the circle",
40 | performActions: "Keep the device still and perform the following actions:",
41 | tooClose: "You're too close. Hold the device further."
42 | }
43 |
44 | const detections = {
45 | BLINK: { instruction: "Blink both eyes", minProbability: 0.3 },
46 | TURN_HEAD_LEFT: { instruction: "Turn head left", maxAngle: -15 },
47 | TURN_HEAD_RIGHT: { instruction: "Turn head right", minAngle: 15 },
48 | NOD: { instruction: "Nod", minDiff: 1.5 },
49 | SMILE: { instruction: "Smile", minProbability: 0.7 }
50 | }
51 |
52 | type DetectionActions = keyof typeof detections
53 | const detectionsList: DetectionActions[] = [
54 | "BLINK",
55 | "TURN_HEAD_LEFT",
56 | "TURN_HEAD_RIGHT",
57 | "NOD",
58 | "SMILE"
59 | ]
60 |
61 | const initialState = {
62 | faceDetected: "no" as "yes" | "no",
63 | faceTooBig: "no" as "yes" | "no",
64 | detectionsList,
65 | currentDetectionIndex: 0,
66 | progressFill: 0,
67 | processComplete: false
68 | }
69 |
70 | interface Actions {
71 | FACE_DETECTED: "yes" | "no"
72 | FACE_TOO_BIG: "yes" | "no"
73 | NEXT_DETECTION: null
74 | }
75 |
76 | interface Action {
77 | type: T
78 | payload: Actions[T]
79 | }
80 |
81 | type PossibleActions = {
82 | [K in keyof Actions]: Action
83 | }[keyof Actions]
84 |
85 | const detectionReducer = (
86 | state: typeof initialState,
87 | action: PossibleActions
88 | ): typeof initialState => {
89 | switch (action.type) {
90 | case "FACE_DETECTED":
91 | if (action.payload === "yes") {
92 | return {
93 | ...state,
94 | faceDetected: action.payload,
95 | progressFill: 100 / (state.detectionsList.length + 1)
96 | }
97 | } else {
98 | // Reset
99 | return initialState
100 | }
101 | case "FACE_TOO_BIG":
102 | return { ...state, faceTooBig: action.payload }
103 | case "NEXT_DETECTION":
104 | // next detection index
105 | const nextDetectionIndex = state.currentDetectionIndex + 1
106 |
107 | // skip 0 index
108 | const progressMultiplier = nextDetectionIndex + 1
109 |
110 | const newProgressFill =
111 | (100 / (state.detectionsList.length + 1)) * progressMultiplier
112 |
113 | if (nextDetectionIndex === state.detectionsList.length) {
114 | // success
115 | return {
116 | ...state,
117 | processComplete: true,
118 | progressFill: newProgressFill
119 | }
120 | }
121 | // next
122 | return {
123 | ...state,
124 | currentDetectionIndex: nextDetectionIndex,
125 | progressFill: newProgressFill
126 | }
127 | default:
128 | throw new Error("Unexpected action type.")
129 | }
130 | }
131 |
132 | export default function Liveness() {
133 | const navigation = useNavigation()
134 | const [hasPermission, setHasPermission] = React.useState(false)
135 | const [state, dispatch] = React.useReducer(detectionReducer, initialState)
136 | const rollAngles = React.useRef([])
137 |
138 | React.useEffect(() => {
139 | const requestPermissions = async () => {
140 | const { status } = await Camera.requestCameraPermissionsAsync()
141 | setHasPermission(status === "granted")
142 | }
143 | requestPermissions()
144 | }, [])
145 |
146 | const onFacesDetected = (result: FaceDetectionResult) => {
147 | // 1. There is only a single face in the detection results.
148 | if (result.faces.length !== 1) {
149 | dispatch({ type: "FACE_DETECTED", payload: "no" })
150 | return
151 | }
152 |
153 | //@ts-ignore
154 | const face: FaceDetection = result.faces[0]
155 | const faceRect: Rect = {
156 | minX: face.bounds.origin.x,
157 | minY: face.bounds.origin.y,
158 | width: face.bounds.size.width,
159 | height: face.bounds.size.height
160 | }
161 |
162 | // 2. The face is almost fully contained within the camera preview.
163 | const edgeOffset = 50
164 | const faceRectSmaller: Rect = {
165 | width: faceRect.width - edgeOffset,
166 | height: faceRect.height - edgeOffset,
167 | minY: faceRect.minY + edgeOffset / 2,
168 | minX: faceRect.minX + edgeOffset / 2
169 | }
170 | const previewContainsFace = contains({
171 | outside: PREVIEW_RECT,
172 | inside: faceRectSmaller
173 | })
174 | if (!previewContainsFace) {
175 | dispatch({ type: "FACE_DETECTED", payload: "no" })
176 | return
177 | }
178 |
179 | if (state.faceDetected === "no") {
180 | // 3. The face is not as big as the camera preview.
181 | const faceMaxSize = PREVIEW_SIZE - 90
182 | if (faceRect.width >= faceMaxSize && faceRect.height >= faceMaxSize) {
183 | dispatch({ type: "FACE_TOO_BIG", payload: "yes" })
184 | return
185 | }
186 |
187 | if (state.faceTooBig === "yes") {
188 | dispatch({ type: "FACE_TOO_BIG", payload: "no" })
189 | }
190 | }
191 |
192 | if (state.faceDetected === "no") {
193 | dispatch({ type: "FACE_DETECTED", payload: "yes" })
194 | }
195 |
196 | const detectionAction = state.detectionsList[state.currentDetectionIndex]
197 |
198 | switch (detectionAction) {
199 | case "BLINK":
200 | // Lower probabiltiy is when eyes are closed
201 | const leftEyeClosed =
202 | face.leftEyeOpenProbability <= detections.BLINK.minProbability
203 | const rightEyeClosed =
204 | face.rightEyeOpenProbability <= detections.BLINK.minProbability
205 | if (leftEyeClosed && rightEyeClosed) {
206 | dispatch({ type: "NEXT_DETECTION", payload: null })
207 | }
208 | return
209 | case "NOD":
210 | // Collect roll angle data
211 | rollAngles.current.push(face.rollAngle)
212 |
213 | // Don't keep more than 10 roll angles (10 detection frames)
214 | if (rollAngles.current.length > 10) {
215 | rollAngles.current.shift()
216 | }
217 |
218 | // If not enough roll angle data, then don't process
219 | if (rollAngles.current.length < 10) return
220 |
221 | // Calculate avg from collected data, except current angle data
222 | const rollAnglesExceptCurrent = [...rollAngles.current].splice(
223 | 0,
224 | rollAngles.current.length - 1
225 | )
226 |
227 | // Summation
228 | const rollAnglesSum = rollAnglesExceptCurrent.reduce((prev, curr) => {
229 | return prev + Math.abs(curr)
230 | }, 0)
231 |
232 | // Average
233 | const avgAngle = rollAnglesSum / rollAnglesExceptCurrent.length
234 |
235 | // If the difference between the current angle and the average is above threshold, pass.
236 | const diff = Math.abs(avgAngle - Math.abs(face.rollAngle))
237 |
238 | if (diff >= detections.NOD.minDiff) {
239 | dispatch({ type: "NEXT_DETECTION", payload: null })
240 | }
241 | return
242 | case "TURN_HEAD_LEFT":
243 | // Negative angle is the when the face turns left
244 | if (face.yawAngle <= detections.TURN_HEAD_LEFT.maxAngle) {
245 | dispatch({ type: "NEXT_DETECTION", payload: null })
246 | }
247 | return
248 | case "TURN_HEAD_RIGHT":
249 | // Positive angle is the when the face turns right
250 | if (face.yawAngle >= detections.TURN_HEAD_RIGHT.minAngle) {
251 | dispatch({ type: "NEXT_DETECTION", payload: null })
252 | }
253 | return
254 | case "SMILE":
255 | // Higher probabiltiy is when smiling
256 | if (face.smilingProbability >= detections.SMILE.minProbability) {
257 | dispatch({ type: "NEXT_DETECTION", payload: null })
258 | }
259 | return
260 | }
261 | }
262 |
263 | React.useEffect(() => {
264 | if (state.processComplete) {
265 | Alert.alert('Liveness', 'Liveness check passed')
266 | setTimeout(() => {
267 | navigation.goBack()
268 | // enough delay for the final progress fill animation.
269 | }, 500)
270 | }
271 | }, [state.processComplete])
272 |
273 | if (hasPermission === false) {
274 | return No access to camera
275 | }
276 |
277 | return (
278 |
279 | }
282 | >
283 |
295 |
304 |
305 |
306 |
307 |
308 | {state.faceDetected === "no" &&
309 | state.faceTooBig === "no" &&
310 | instructionsText.initialPrompt}
311 |
312 | {state.faceTooBig === "yes" && instructionsText.tooClose}
313 |
314 | {state.faceDetected === "yes" &&
315 | state.faceTooBig === "no" &&
316 | instructionsText.performActions}
317 |
318 |
319 | {state.faceDetected === "yes" &&
320 | state.faceTooBig === "no" &&
321 | detections[state.detectionsList[state.currentDetectionIndex]]
322 | .instruction}
323 |
324 |
325 |
326 | )
327 | }
328 |
329 | const styles = StyleSheet.create({
330 | container: {
331 | flex: 1
332 | },
333 | mask: {
334 | borderRadius: PREVIEW_SIZE / 2,
335 | height: PREVIEW_SIZE,
336 | width: PREVIEW_SIZE,
337 | marginTop: PREVIEW_RECT.minY,
338 | alignSelf: "center",
339 | backgroundColor: "white"
340 | },
341 | circularProgress: {
342 | width: PREVIEW_SIZE,
343 | height: PREVIEW_SIZE,
344 | marginTop: PREVIEW_RECT.minY,
345 | marginLeft: PREVIEW_RECT.minX
346 | },
347 | instructions: {
348 | fontSize: 20,
349 | textAlign: "center",
350 | top: 25,
351 | position: "absolute"
352 | },
353 | instructionsContainer: {
354 | flex: 1,
355 | justifyContent: "center",
356 | alignItems: "center",
357 | marginTop: PREVIEW_RECT.minY + PREVIEW_SIZE
358 | },
359 | action: {
360 | fontSize: 24,
361 | textAlign: "center",
362 | fontWeight: "bold"
363 | }
364 | })
365 |
--------------------------------------------------------------------------------
/src/SelfieSvg.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import Svg, { SvgProps, G, Path, Defs, ClipPath } from "react-native-svg"
3 |
4 | // Art by
5 | // https://undraw.co/
6 | export default function SelfieSvg(props: SvgProps & { size: number }) {
7 | return (
8 |
93 | )
94 | }
95 |
--------------------------------------------------------------------------------
/src/contains.ts:
--------------------------------------------------------------------------------
1 | export interface Rect {
2 | minX: number
3 | minY: number
4 | width: number
5 | height: number
6 | }
7 |
8 | interface Contains {
9 | outside: Rect
10 | inside: Rect
11 | }
12 |
13 | export function contains({ outside, inside }: Contains) {
14 | const outsideMaxX = outside.minX + outside.width
15 | const insideMaxX = inside.minX + inside.width
16 |
17 | const outsideMaxY = outside.minY + outside.height
18 | const insideMaxY = inside.minY + inside.height
19 |
20 | if (inside.minX < outside.minX) {
21 | return false
22 | }
23 | if (insideMaxX > outsideMaxX) {
24 | return false
25 | }
26 | if (inside.minY < outside.minY) {
27 | return false
28 | }
29 | if (insideMaxY > outsideMaxY) {
30 | return false
31 | }
32 |
33 | return true
34 | }
35 |
36 | export function contains2({ outside, inside }: Contains) {
37 | const outsideMaxX = outside.minX + outside.width
38 | const insideMaxX = inside.minX + inside.width
39 |
40 | const outsideMaxY = outside.minY + outside.height
41 | const insideMaxY = inside.minY + inside.height
42 |
43 | const xIntersect = Math.max(0, Math.min(insideMaxX, outsideMaxX) - Math.max(inside.minX, outside.minX))
44 | const yIntersect = Math.max(0, Math.min(insideMaxY, outsideMaxY) - Math.max(inside.minY, outside.minY))
45 | const intersectArea = xIntersect * yIntersect
46 |
47 | const insideArea = inside.width * inside.height
48 | const outsideArea = outside.width * outside.height
49 |
50 | const unionArea = insideArea + outsideArea - intersectArea
51 |
52 | return unionArea === outsideArea
53 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "jsx": "react-native",
5 | "lib": [
6 | "dom",
7 | "esnext"
8 | ],
9 | "moduleResolution": "node",
10 | "noEmit": true,
11 | "skipLibCheck": true,
12 | "resolveJsonModule": true,
13 | "strict": true
14 | },
15 | "extends": "expo/tsconfig.base"
16 | }
17 |
--------------------------------------------------------------------------------