active_face_liveness 0.2.0
active_face_liveness: ^0.2.0 copied to clipboard
On-device active face liveness for Flutter using RGBW illumination, eye response, head turns, parallax, and replay evidence.
active_face_liveness #
On-device active face liveness for Flutter using a randomized RGBW illumination challenge, eye response, head turns, parallax, screen/replay evidence, and multi-region face colour correlation.
The package includes a ready-to-use scanner UI and returns a typed result with optional PNG bytes, Base64, both representations, or no image.
This package is an experimental liveness signal, not certified Presentation Attack Detection (PAD). Do not use it as the only control for high-risk identity, financial, or KYC workflows.
Features #
- Front-camera-only active liveness flow
- Randomized full-intensity RGBW sequence with two seconds of nominal illumination
- Open–closed–open blink response bound to the white pulse
- Landmark-aligned, per-frame multi-region colour correlation
- Two head turns with configurable continuous hold time
- Nose/cheek motion coherence and temporal parallax
- Presentation-media and moving repeated-sequence evidence
- Automatic application-brightness restoration
- Best full-frame capture selected during the scan
- Configurable capture contrast and maximum output size
- PNG bytes, Base64, both, or disabled image output
- Typed configuration, evidence, and result objects
Platform support #
- Android
- iOS
All face and object processing is performed on the device.
Installation #
dependencies:
active_face_liveness: ^0.2.0
Android #
Add camera permission to android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />
iOS #
Add a usage description to ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Camera access is required to perform the liveness scan.</string>
Basic usage #
If the application only needs a true/false answer, integration is one line:
final isLive = await ActiveFaceLiveness.verify(context);
verify uses the privacy-first preset and does not encode or return a face
image.
Use start when the application needs failure details, evidence, or an image:
import 'package:active_face_liveness/active_face_liveness.dart';
final result = await ActiveFaceLiveness.start(context);
if (result.isLive) {
print('Verified as a real person');
} else if (result.cancelled) {
print('The user cancelled the scan');
} else {
print(result.failureReason ?? 'Liveness could not be verified');
}
ActiveFaceLiveness.start requests camera permission, chooses the first
front-facing camera, opens the scanner route, restores application brightness,
and returns a LivenessResult.
Configure capture contrast and output #
Contrast is applied only to the returned image. It never changes the camera pixels used by the liveness decision.
final result = await ActiveFaceLiveness.start(
context,
config: const LivenessConfig(
capturedImageContrast: 1.25,
capturedImageMaxLongEdge: 1024,
imageOutput: LivenessImageOutput.both,
outsideFaceGuideColor: Color(0x66000000),
),
);
final pngBytes = result.pngBytes;
final base64Png = result.base64Image;
UI, header language, and progress #
final result = await ActiveFaceLiveness.start(
context,
config: const LivenessConfig(
strings: LivenessStrings.english(),
ui: LivenessUiConfig(
primaryColor: Color(0xFF006C66),
showStepNumber: true,
),
),
onStepChanged: (step) => analytics.logEvent(name: step.name),
);
LivenessResult.failureCode is a stable machine-readable error. Results and
evidence can be serialized without including biometric image data:
final payload = result.toJson(); // Does not contain the captured image.
The Base64 value contains the same PNG payload and does not include a
data:image/png;base64, prefix.
Image output modes #
| Value | pngBytes |
base64Image |
Encoding performed |
|---|---|---|---|
LivenessImageOutput.none |
No | No | No |
LivenessImageOutput.pngBytes |
Yes | No | Yes |
LivenessImageOutput.base64 |
No | Yes | Yes |
LivenessImageOutput.both |
Yes | Yes | Yes |
Configuration #
| Property | Default | Description |
|---|---|---|
imageOutput |
pngBytes |
Returned image representation |
capturedImageContrast |
1.0 |
Output-only contrast from 0.0 to 3.0 |
capturedImageMaxLongEdge |
800 |
Maximum long edge in pixels |
scanTimeout |
75 seconds | Maximum duration of one scan |
turnHoldDuration |
1 second | Required hold for each head turn |
farFaceMinFraction |
0.20 |
Minimum initial face-width fraction |
farFaceMaxFraction |
0.42 |
Maximum initial face-width fraction |
nearFaceMinFraction |
0.50 |
Minimum near-stage fraction |
nearFaceMaxFraction |
0.90 |
Maximum near-stage fraction |
screenBrightness |
1.0 |
Application brightness during RGBW |
illuminationOpacity |
0.94 |
RGBW overlay opacity |
outsideFaceGuideColor |
Color(0x66000000) |
Colour outside the face guide; use null to disable |
antiSpoofScoreThreshold |
0.58 |
Minimum combined evidence score |
Changing detection thresholds can materially affect false accepts and false rejects. Calibrate them on every supported device family and lighting range.
Result #
final LivenessResult result = await ActiveFaceLiveness.start(context);
result.isLive;
result.classification;
result.cancelled;
result.failureReason;
result.pngBytes;
result.base64Image;
result.imageWidth;
result.imageHeight;
result.imageSharpness;
result.evidence;
result.technicalDetails;
High-level evidence is available without parsing the diagnostic map:
final evidence = result.evidence;
print(evidence.lightChallengePassed);
print(evidence.blinkChallengePassed);
print(evidence.firstTurnPassed);
print(evidence.secondTurnPassed);
print(evidence.parallaxPassed);
print(evidence.antiSpoofScore);
Direct widget usage #
Applications that already manage cameras and permissions can open
ActiveFaceLivenessView directly:
final result = await Navigator.of(context).push<LivenessResult>(
MaterialPageRoute(
builder: (_) => ActiveFaceLivenessView(
cameraDescription: frontCamera,
config: const LivenessConfig(
imageOutput: LivenessImageOutput.pngBytes,
),
),
),
);
How the flow works #
flowchart TD
A["Open front camera"] --> B["Initial face distance and media check"]
B --> C["Move closer and calibrate exposure"]
C --> D["Random RGBW challenge for 2 seconds"]
D --> E{"Colour response + white-pulse eye response pass?"}
E -- "No" --> X["Return failed result"]
E -- "Yes" --> F["Hold first head turn"]
F --> G["Hold opposite head turn"]
G --> H{"Motion coherence and parallax pass?"}
H -- "No" --> X
H -- "Yes" --> I["Fuse anti-spoof evidence"]
I --> J{"Mandatory gates and score pass?"}
J -- "No" --> X
J -- "Yes" --> K["Return real-person result"]
X --> L["Restore brightness and return best capture"]
K --> L
Image handling and privacy #
The package keeps only the highest-scoring eligible frame in memory. It rotates and mirrors the complete camera frame, scales it while preserving aspect ratio, applies the configured output contrast, and encodes it as PNG. It does not write the capture to the gallery or another persistent file.
The integrating application is responsible for consent, privacy notices, retention, encryption, transport security, and deletion.
Running the example #
cd example
flutter run
The example lets you change contrast and select PNG, Base64, both, or no image before starting a scan.
Evidence and timing behavior #
classifyLiveness defaults omitted evidence flags to false. Direct callers
must explicitly provide all collected gates to obtain realPerson. Incomplete
challenges, missing measurements and unsuccessful scans return unknown, not
photo. The legacy photo enum remains for compatibility; the scanner does not
claim that a failed challenge proves a photo attack.
The evidence score is a heuristic, not a calibrated probability. Technical
results include scoreKind, camera callback timestamp source, pulse render
markers, sample times, and dropped stale-frame counts. Rendering and bounded
inference-drain gaps add overhead to the nominal two-second illumination.
See VALIDATION.md for regression checks, timing limitations, a device-test protocol, and a JSONL evaluation tool. Measured APCER/BPCER improvements still require labelled real-device trials.
Optional mouth and tongue challenges #
Both are disabled by default. They run after the mandatory RGBW and head-turn checks and never replace them. Enable the built-in mouth challenge with:
final result = await ActiveFaceLiveness.start(
context,
config: const LivenessConfig(enableMouthOpen: true),
);
The user closes their mouth, opens and holds it, then closes it again. The
scanner uses the distance between inner lip landmarks normalized by mouth
width, requires a 400 ms hold and repeated observations, and resets incomplete
evidence when tracking is interrupted. Prompts are customizable through
LivenessStrings, including the English preset. The example app has a switch
for this challenge.
Tongue detection requires an application-supplied detector. The bundled
52-coefficient face model has no tongueOut output. An open mouth is not treated
as proof of a visible tongue. The package supplies the optional challenge flow,
validation, prompts and evidence, but does not bundle a tongue model:
Future<LivenessResult> scanWithTongue(
BuildContext context,
LivenessTongueDetector detector,
) => ActiveFaceLiveness.start(
context,
config: LivenessConfig(
enableMouthOpen: true,
enableTongueOut: true,
tongueOutDetector: detector,
gestureTimeout: const Duration(seconds: 12),
),
);
The detector receives LivenessTongueFrame with the raw CameraImage, upright
Face, rotation and monotonic callback receipt time. Return a finite tongue
extension probability from 0 to 1, or null when uncertain. Copy pixels before
asynchronous processing and return within 300 ms; timed-out Futures cannot be
cancelled by the scanner. Exceptions, invalid probabilities and timeouts fail
the scan. Null observations reset the gesture. Enabling tongue without a
detector returns invalidConfiguration through start.
Tongue flow requires retracted → extended and held → retracted, using initial
thresholds 0.30/0.70. Mouth uses rest/open ratios 0.10/0.25. These thresholds
need device and model validation. Each gesture has its own gestureTimeout
and remains bounded by the overall scanTimeout.
onStepChanged reports LivenessStep.mouthOpen / LivenessStep.tongueOut.
result.evidence.mouthOpenPassed and tongueOutPassed are null when disabled,
false when requested but incomplete, and true when completed for a scan.
Pre-scan errors and cancellation carry empty evidence. If both are enabled,
both must pass. Existing default behavior remains unchanged.