├── .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 | 6 | 12 | 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 | 348 | 354 | 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 | 15 | 16 | 20 | 25 | 29 | 33 | 37 | 41 | 45 | 46 | 50 | 54 | 58 | 62 | 66 | 70 | 74 | 78 | 82 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 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 | --------------------------------------------------------------------------------