What this is
A drop-in React Native module. Face recognition and liveness run entirely on the handset. The network is optional and is used only to drain an offline queue.
Measured on a Vivo V2246, a 3 GB-class mid-range handset
| Metric | Measured | Requirement in the brief |
|---|---|---|
| On-device model footprint | 17.3 MB | around 20 MB, smaller is better |
| Full verification verdict | 371 to 604 ms | under 1 second |
| Recognition accuracy | 99.76 percent on LFW | above 95 percent |
| Genuine match score on device | 0.65 to 0.86 | accept threshold 0.38 |
| Minimum Android | 8.0, minSdkVersion 26 | Android 8.0 and above |
| GPU | not required, CPU delegate only | no high-end GPU |
Module map
All 22 files under app/src/netraid/ and app/src/screens/. Select one for what it owns, what it exports, and what it connects to. Exports, imports and line counts are extracted from the shipped source, so this cannot drift from the code.
Dependency graph
Generated from the actual import statements in the shipped source rather than drawn by
hand: 22 modules, 47 edges. Click any module to isolate it and see exactly what it imports and what
imports it.
types.ts sits at the bottom because everything depends on the configuration and nothing depends back
on it. index.ts is the only module a host application calls. The screens depend on the core and the
core never depends on a screen, which is what makes this a drop-in module rather than an app.
One verification, start to finish
What runs, in order, and on which thread.
| # | Step | Where | Notes |
|---|---|---|---|
| 1 | Frame arrives | worklet thread | Every second frame is processed; the rest are dropped to bound worklet memory |
| 2 | Sensor warm-up | worklet | The first 1.2 seconds are discarded while exposure and white balance converge |
| 3 | Detect | BlazeFace | Bounding box and 6 keypoints, with adaptive gain applied in dim light |
| 4 | Landmarks | FaceLandmarker | 468 points, giving eye aspect ratio, mouth width ratio and yaw |
| 5 | Active challenge | worklet FSM | Random order. Each step requires a confirmed neutral face, then the gesture no sooner than 350 ms and no later than 4 seconds |
| 6 | Continuity binding | worklet | From the last gesture through the capture, the same tracked face must remain in frame |
| 7 | Passive anti-spoof | MiniFASNet | BGR in [0,1], crop 2.7x the face box, padded rather than clamped |
| 8 | Quality gates | worklet | Frontal, sharp, correctly exposed, and colour balance settled |
| 9 | Align | JS thread | 5-point similarity transform to 112x112 |
| 10 | Embed | MobileFaceNet | 512-d vector, with flip test-time augmentation |
| 11 | Match | JS thread | Cosine against enrolled templates, aggregated across 3 captures with a margin rule |
| 12 | Record | SQLCipher | Attendance row queued locally, encrypted |
On-device models
Four TFLite graphs, 17.3 MB together. All run on the CPU delegate, so no GPU is required.
| Model | Size | Licence | Input convention | Used for |
|---|---|---|---|---|
blazeface_short_range | 0.22 MB | Apache-2.0 | RGB 128x128, [0,1] | Face detection |
face_landmarker | 2.44 MB | Apache-2.0 | RGB 256x256 | 468 landmarks, gesture geometry |
minifasnet_fp32 | 1.67 MB | Apache-2.0 | BGR 80x80, [0,1], crop 2.7x face box | Passive anti-spoof |
mobilefacenet_f32 | 13.00 MB | MIT | RGB 112x112, aligned | 512-d face embedding |
ml/scripts/06_verify_minifasnet_fidelity.py runs identical tensors through the
original PyTorch weights and the shipped TFLite file. Fed [0,1] they agree to 3e-8. Fed raw
0-255 they differ by 0.99, and the model then returns P(real) = 0.995 on random noise, which
means it is no longer computing anything meaningful. Run this check before changing
preprocessing and after any re-conversion.
Reproducing every model from source
cd ml python scripts/01_download_models.py # fetch upstream weights python scripts/02_convert_to_tflite.py # recognition graph python scripts/04_export_app_models.py # stage into app/assets/models python scripts/05_convert_minifasnet.py # anti-spoof graph python scripts/06_verify_minifasnet_fidelity.py # ground-truth check
Configuration reference
Every operating point lives in app/src/netraid/types.ts as DEFAULT_CONFIG. The values below are what ships.
Recognition
| Field | Value | Meaning |
|---|---|---|
matchThreshold | 0.38 | Minimum cosine similarity to accept a match |
matchMargin | 0.08 | Required gap over the next-best different person |
verifyShots | 3 | Frames aggregated per verification |
enrollShots | 6 | Candidate frames per enrollment |
Liveness gates
| Gate | Operating point | State |
|---|---|---|
| Active challenge | 2 steps, drawn without replacement, 350 ms to 4 s each | ENFORCED |
| Continuity binding | 600 ms gap, 0.22 positional jump, 1.7x scale change | ENFORCED |
passiveMode | passiveThreshold 0.15 on the median | MEASURED |
screenSpoofMode | screenSpoofMax 0.85 on the consensus | MEASURED |
enrollSpoofMode | enrollPassiveThreshold 0.08 | MEASURED |
chromaMode | chromaThreshold 1.25 | OFF |
docs/CALIBRATION.md is
the procedure for arming it, and it is roughly a thirty-minute job on a target device. A
threshold carried over from other hardware is not a security control.
Integration into Datalake 3.0
One module directory, two call sites, and one optional endpoint.
1. Copy the module
cp -r app/src/netraid <datalake>/src/netraid cp -r app/assets/models <datalake>/assets/models
2. Peer dependencies
npm install react-native-vision-camera react-native-worklets-core \
react-native-fast-tflite @op-engineering/op-sqlite \
react-native-uuid @react-native-community/netinfo
3. The two call sites
import { NetraID } from "./netraid";
await NetraID.init();
// Enrollment, 6 quality-gated shots
await NetraID.enroll({ personId: "NHAI-04821", captures });
// Verification, 3-frame aggregate
const r = await NetraID.verify({ captures, requireLiveness: true });
// r -> { ok, personId, score, liveness, elapsedMs }
captures come from useNetraFrameProcessor, which emits
only quality-gated, liveness-passed, aligned 112x112 crops. enroll throws
DuplicateFaceError if the face is already enrolled under a different id: one face,
one identity.
HomeScreen,
EnrollScreen, VerifyScreen) straight into an existing navigator.
Sync and purge contract
The only network dependency. Authentication itself never needs a server.
Request
POST /v1/attendance/sync
Content-Type: application/json
X-Device-Id: <device uuid>
Authorization: Bearer <JWT>
{ "records": [
{ "id": "<client uuid>", "personId": "NHAI-04821", "ts": 1755500000000,
"siteId": "TOLL-12", "lat": 19.07, "lng": 72.87,
"deviceId": "<device uuid>", "livenessPassed": true, "matchScore": 0.86 }
] }
Response
{ "results": [ { "id": "<client uuid>", "status": "ok" } ] }
ConditionExpression: attribute_not_exists(id), so a retry
cannot duplicate a record. The device deletes a local row only after the server returns
status: "ok" for that id. Anything not acknowledged stays queued.
backend/
implements this endpoint on API Gateway, Lambda and DynamoDB so the contract and its
idempotency behaviour are unambiguous. A deployment can point the client at Datalake 3.0's own
backend and delete it entirely.
Platform status
Stated precisely, including what has not been verified.
| Platform | Status | Evidence |
|---|---|---|
| Android | VERIFIED ON DEVICE | Signed release APK built from source for all four ABIs. Enrollment, verification and matching exercised on a Vivo V2246 |
| iOS | BUILDS IN CI | Xcode build succeeds in GitHub Actions, with simulator video and screenshots. A device pass on the current revision is pending |
app/ios/ has camera permissions and deep links wired. One native module,
ScreenLampModule, is Android-only and is called behind a platform guard, so iOS is
unaffected by it. What has not happened is a run of the current revision on physical iOS
hardware. The deployment target follows React Native 0.76, which is iOS 15.1; embedding the
module sources into a host app built on an older React Native reaches the iOS 12 floor named in
the brief. See docs/INTEGRATION.md.
Build from source
cd app && npm install --legacy-peer-deps cd android && ./gradlew assembleRelease
This produces one APK per ABI plus a universal one. On Windows the native build
stages itself at a short path automatically, because a stock React Native 0.76 project exceeds
the 260-character path limit on its own. See docs/BUILD.md.