flutter_face_liveness 3.6.0
flutter_face_liveness: ^3.6.0 copied to clipboard
Production-ready Flutter SDK for face detection, liveness verification, and anti-spoof protection using ML Kit, TensorFlow Lite, and optional YOLOv8n-face / SCRFD-2.5G-KPS backends.
3.6.0 #
New Features #
-
SCRFD-2.5G-KPS detection backend for face identity —
FaceDetectorBackendgains a third value,scrfd, alongsidemlkit(default) andyolov8. Uses InsightFace's SCRFD-2.5G-KPS (anchor-based, FCOS-style distance regression, 3 detection heads at strides 8/16/32) instead of ML Kit's own landmarks for the face box + eye keypoints fed intoFaceEmbeddingModel— generally more accurate than YOLOv8n-face at small/angled faces, similar extra per-frame inference cost. Liveness actions (blink/smile/head-pose) always use ML Kit regardless of this setting, unchanged from theyolov8backend's behavior. Configured viaLivenessConfig.faceDetectorBackend,scrfdModelUrl,scrfdConfidenceThreshold,scrfdIouThreshold.ScrfdFaceDetectorService/ScrfdFaceDetection— same isolate-worker architecture asYoloFaceDetectorService(inference never blocks the caller's isolate). Decode differs from YOLO's single unified output tensor: SCRFD emits 9 raw tensors (score/bbox/kps× 3 strides), auto-classified at load time by channel count (1/4/10) and anchor count — never hardcoded by tensor index — then decoded with SCRFD's owndistance2bbox/distance2kpsmath (no+0.5cell-center offset, distances pre-multiplied by stride) and greedy NMS. Preprocessing also differs from YOLO: top-left-aligned zero-padded resize (not centered letterboxing) and(pixel - 127.5) / 128.0normalization (notpixel / 255), matching InsightFace's own reference preprocessing exactly.ScrfdModelDownloader— same cache-on-first-use pattern asYoloModelDownloader/FaceModelDownloader.ScrfdModelDownloader.bundledModelUrlpoints to a hostedscrfd_2.5g_bnkps_float32.tfliterelease asset, converted from the verified official InsightFace ONNX export (a third-party GitHub-LFS mirror was used as the source — InsightFace's own release bundles only ship the 500M variant, not 2.5G — verified by parsing the ONNX graph's input/output tensor names and shapes before conversion) viaonnx2tf. Works out of the box, no setup required. OverrideLivenessConfig.scrfdModelUrlonly if you want to swap in a custom model.- The
LivenessController._processFrame()fire-and-forget dispatch (unawaited, guarded by a busy flag so a slow model never throttles camera-frame delivery — see the 3.5.0 blink-detection fix below) is now backend-generic: a small internal_IdentityFaceBoxadapter normalises either detector's output before the shared frontal-frame-tracking/embedding code runs, soscrfdgets the same non-blocking guarantee asyolov8with no separate bug class to reintroduce.
-
Example app: SCRFD backend option — the existing ML Kit/YOLOv8 toggle is now a 3-way segmented selector (ML Kit / YOLOv8 / SCRFD) so all three can be compared live, applied to every Face ID preset.
-
Live confirmation that
yolov8/scrfdare actually detecting —YOLOv8n-face/SCRFD-2.5G-KPSresults never reach the on-screen face-tracking oval (that always reflects ML Kit, which drives liveness actions regardless of backend), so there was previously no visible proof either backend was running at all.LivenessControllergainsisSecondaryDetectorActive,secondaryDetectorRunCount, andlastSecondaryDetectionCount;FlutterFaceLivenessshows a small badge (top-right, only when a secondary backend is active) reading e.g. "SCRFD-2.5G-KPS · face detected (4x)" that updates live as the backend runs. -
Example app: clarified that the backend selector only affects Face ID presets — the YOLOv8/SCRFD subtitle text was easy to miss; toggling the backend and then launching Standard/Extended/TFLite (none of which set
enableFaceId: true) looked like the backend had no effect, since it genuinely doesn't apply there. Subtitles now say so explicitly, pointing at Register/Verify Face as the presets to test with.
Bug Fixes #
FlutterFaceLiveness's loading screen showed no download progress for theyolov8/scrfdbackends —_loadingView()only readtfliteModelDownloadProgress/faceIdModelDownloadProgress; the YOLOv8n-face/SCRFD-2.5G-KPS model downloads (which, like every other model, complete before the camera opens — seeLivenessController.initialize()) ran with no visible percentage, just a spinner and "Starting camera…", making a multi-second first-time download look stuck. Now shows a labelled progress bar for whichever model is currently downloading (Anti-Spoof / Face ID / YOLOv8n-face / SCRFD-2.5G-KPS), same as the existing models.coordinatesInSensorSpacecould be set incorrectly when a YOLOv8 detection had a box but no confident eye keypoints —_onEngineComplete()fell back to ML Kit's own eye landmarks (rotated display-space) in that case but still tagged the coordinates as sensor-space (since a detection object was present), skipping the un-rotation ML Kit's coordinates need — silently cropping the wrong region for the identity embedding. Fixed by deriving the flag from which source actually supplies the coordinates that get used, not merely whether a detection exists. (SCRFD is unaffected — itshasEyesis always true.)FaceCaptureService.initialize()had no error isolation for YOLOv8/SCRFD model loads — unlikeLivenessController, a failed download/load threw an uncaught exception instead of the documented silent fallback, and a subsequentcaptureAndIdentify()call with anulldetector reported the misleading'No face detected'instead of the real cause. Both fixed:initialize()now catches load failures per-backend, andcaptureAndIdentify()reports a clear'... detector unavailable — model failed to load or download'rejection when relevant.enableBestFrontalCapture's JPEG was rotated 180° off from correct on the common AndroidsensorOrientationvalues (90/270) —frame_encoder.dartrotated raw sensor pixels bysensorOrientationdirectly, but that value's 90°/270° cases need to be swapped relative tocopyRotate's angle — verified by deriving the correct transform as the algebraic inverse of this codebase's own already-testedFacePreprocessor._landmarkToSensor, composed with theimagepackage's actual per-angle pixel mapping.faceDetectorBackend: yolov8/scrfddispatched an inference request on every qualifying camera frame (~20-30fps), not just identity-eligible candidate frames (~7-15 per session) — a regression introduced while generalizing the fire-and-forget dispatch for thescrfdbackend: the detector's async call was constructed (which, being an async function, starts executing — sending the isolate request — immediately, before its firstawait) before the candidacy/busy-flag guard was checked, rather than inside it. Fixed by moving construction back inside the guard, restoring the original throttling.ScrfdFaceDetectorServicecollapsed all 9 output tensors' batch-dimension detection into one shared flag, keeping only the last tensor's value — harmless for the bundled model (all 9 tensors are[1,N,C]), but a custom SCRFD export (explicitly supported viascrfdModelUrl) with a mix of batched/unbatched output tensors would silently corrupt decoding for whichever tensors didn't match the last one's shape. Fixed by tracking each output tensor's batch-dim-ness independently and applying the correct one per score/bbox/kps buffer.- The frame that completed the last liveness action could be silently excluded from frontal-frame identity bookkeeping — a side effect of the 3.5.0 blink-latency fix, which moved
_engine.processFrame()before the frontal-frame-tracking code. When that same frame finished the session, engine completion (which runs_onEngineComplete()synchronously up to its firstawait) could snapshot_frontalFrames/_bestFrontalFramebefore this frame got a chance to update them. Fixed by reordering the frontal-frame-tracking block (which never itself awaits) ahead of the engine call — closes the race for the defaultmlkitbackend entirely, without reintroducing the original blink-latency issue.
3.5.0 #
New Features #
-
Example app:
faceDetectorBackendtoggle —LivenessConfig.faceDetectorBackend(ML Kit / YOLOv8n-face) was previously only settable in code with no example-app UI, so it always silently used themlkitdefault. Added a home-screen toggle (alongside the existing performance-mode toggle) so both backends can be compared live, applied to every Face ID preset. -
Example app: "Verify Face — Head Turn Only" preset — demonstrates that
LivenessConfig.actionsfully controls the challenge set with zero SDK changes required:FaceIdMode.verificationOnlyusing only[LivenessAction.turnLeft, LivenessAction.turnRight], no blink. A reminder that trimming which actions are required is an app-level config choice — see Liveness Actions — as opposed to removing detector code from the SDK itself, which would also weaken the anti-spoof/replay-detection signals that read eye/smile data independently of the requested action list (AntiSpoofEngine,ReplayAnalyzer,FaceGeometryAnalyzer).
Bug Fixes #
- Blink/turn/smile actions slow or not registering with
faceDetectorBackend: yolov8— two compounding issues, both in_processFrame():_engine.processFrame()(the actual blink/turn/smile detection) ran afterawaiting YOLO detection for identity-eligible frames, instead of immediately after ML Kit detection. Fixed by moving it up, before anyawaitin the rest of the function.- Even after (1), YOLO detection was still
awaited inline — andCameraServiceholds its_isProcessingFrameguard for the entire_processFrame()call, so the next camera frame couldn't be accepted until YOLO's round-trip finished. This throttled overall frame throughput to YOLO's inference rate, which is still too slow to catch fast transitions like an eye-closed→open blink even though each individual frame's action detection was no longer delayed. Fixed by making YOLO detection fire-and-forget (unawaited, guarded by a new_isYoloRunningflag — same pattern already used for the TFLite/video-replay models), with the best-frontal/frontal-frame-collection bookkeeping moved into a_recordFrontalFrame()helper called either synchronously (ML Kit backend) or from YOLO's.then()callback once it resolves. Camera frame delivery, and therefore liveness-action detection, is no longer coupled to YOLO's latency at all.
Dependencies #
- Widened outdated constraints flagged by pub.dev's dependency-freshness check:
google_mlkit_face_detection^0.13.2→^0.14.0,google_mlkit_face_mesh_detection^0.4.2→^0.5.0. No breaking API usage found in this codebase after the upgrade —flutter analyzeclean on both the package and example app.- Capped at these versions deliberately, not the latest (
0.15.1/0.6.1): those require Dart SDK^3.12.0, which broke resolution for anyone on an older Flutter/Dart install (reported: Dart 3.10.4).0.14.0/0.5.0need only>=3.8.0 <4.0.0, matching this package's own broadsdk: ">=3.0.0 <4.0.0"declaration. Revisit once^3.12.0is a safe minimum to require. permission_handlerdeliberately left at^12.0.1(not bumped to^13.0.1): the newer version pulls inpermission_handler_android 14.0.0, whosebuild.gradle.ktsuses a Kotlin Gradle Plugin 2.0+-only DSL (compilerOptions { jvmTarget = ... }) that failed to compile against this project's configured Kotlin toolchain (reported:Unresolved reference: compilerOptions). Revisit once the project's Kotlin/AGP versions are updated to match.
- Capped at these versions deliberately, not the latest (
3.4.0 #
New Features #
-
LivenessConfig.faceDetectorPerformanceMode— ML Kit precision/speed trade-off is now configurable (was hardcoded). Default switched fromFaceDetectorMode.fastto.accurate, which improves face tracking through moderate off-angle head positions (turn-left/right, look-up/down), where.fastwas more prone to losing the face mid-turn before an action registered — set it back to.fastfor lower per-frame latency if your target devices need it. Trade-off not yet re-benchmarked (see README Performance table). Does not extend ML Kit's fundamental angle ceiling; very steep/near-profile angles can still lose tracking either way.FaceDetectorModeis now exported from the package. Demoed in the example app via a new toggle on the home screen. -
LivenessConfig.enableBestFrontalCapture— automatically captures the most-frontal frame seen during a session (by|yaw|+|pitch|) and exposes it asLivenessResult.bestFrontalImageBytes(upright JPEG) on success, for apps that want a human-viewable photo of the user alongside the liveness/identity result (e.g. a KYC review screen) without a separate capture step. Independent ofenableFaceId— shares the same internal frame-tracking at no extra cost when both are on, but works standalone. JPEG quality configurable viabestFrontalJpegQuality(default85).encodeFrameToJpeg()(lib/src/ml/frame_encoder.dart) — converts the raw NV21/BGRA frame to an upright JPEG (rotation-corrected viaLivenessConfig's existing sensor-orientation handling), reusingFacePreprocessor.yuv2rgbNv21rather than a third copy of the YUV conversion. Runs on a background isolate (Isolate.run) so a full-resolution encode never blocks the UI thread.
Bug Fixes #
-
Swift Package Manager build failure —
ios/flutter_face_liveness/Package.swiftexported its library product asflutter_face_liveness(underscore). Flutter's auto-generatedFlutterGeneratedPluginSwiftPackagerequires plugin product names to be hyphenated (flutter-face-liveness) per the Flutter SPM naming convention, so any app built withflutter config --enable-swift-package-managerfailed withproduct 'flutter-face-liveness' ... not found. Fixed by renaming the library product (target name unchanged). -
ConcurrentModificationErroron_frontalFramesin release builds — whenenableFaceId: true,_onEngineComplete()iterates_frontalFrameswith aforloop that awaits per-frame embedding computation. Because_processFrame()had no guard against the engine already being complete, a camera frame arriving during that async window could still append to the same list mid-iteration, throwingConcurrent modification during iteration. Fixed by (1) short-circuiting_processFrame()once_engine.isCompleteis true, and (2) iterating aList.of(_frontalFrames)snapshot as defense in depth.
3.3.0 #
New Features #
-
YOLOv8n-face detection backend for face identity —
FaceDetectorBackend(mlkitdefault,yolov8) lets the identity/embedding pipeline use a YOLOv8n-face TFLite model instead of ML Kit's own landmarks for the face box + eye keypoints fed intoFaceEmbeddingModel. Liveness actions (blink/smile/head-pose) always use ML Kit regardless of this setting, since only ML Kit exposes that classification output. Configured viaLivenessConfig.faceDetectorBackend,yoloModelUrl,yoloConfidenceThreshold,yoloIouThreshold.YoloFaceDetectorService/YoloFaceDetection— TFLite inference + letterbox preprocessing + NMS decode (standard Ultralytics raw pose-export layout: box + confidence + 5 keypoints).YoloModelDownloader— same cache-on-first-use pattern asFaceModelDownloader/TFLiteModelDownloader.YoloModelDownloader.bundledModelUrlalready points to a hostedyolov8n-face.tfliterelease asset — works out of the box, no setup required. OverrideLivenessConfig.yoloModelUrlonly if you want to swap in a custom model.
-
FaceCaptureService— enrollment/verification from a single captured photo (as opposed to a full live liveness session): decode → detect (either backend) → pre-capture quality gate (brightness/blur/face-size, distinct fromFaceIdentityService's post-hoc embedding-quality check) → eye-aligned crop → embed → match/register, reusingFaceIdentityService's gallery so a person registered via live session or via photo capture is the same lookup. ReturnsFaceCaptureResult(either aFaceMatchResultor a pre-match rejection reason).FaceEmbeddingModel/FacePreprocessorgained a static-RGB entry point (FacePreprocessor.prepareFromRgb,FaceIdentityService.computeEmbeddingFromRgb) so the same loaded model/gallery serve both the live-frame and captured-photo paths — no duplicate model instance.
-
imagepackage now a direct dependency (decoding captured photos forFaceCaptureService).
3.2.0 #
New Features #
-
Face ID operation modes (
FaceIdMode) — three distinct modes replace the previous single-threshold matching:FaceIdMode.auto(default) — match existing face or register new oneFaceIdMode.registrationOnly— enrolment-only; rejects duplicate registrations withonFailed("Face already registered"). Use for one-time sign-up flows that guarantee one ID per personFaceIdMode.verificationOnly— login-only; never registers unknown faces; fails withonFailed("Face not recognized — please register first")- Configured via
LivenessConfig.faceIdMode
-
FaceMatchResult/FaceMatchOutcome— rich result types returned byFaceIdentityService.identifyFromEmbeddings():FaceMatchOutcomeenum:matched,registered,alreadyExists,notFoundFaceMatchResultfields:outcome,faceId,similarity,qualityScore
-
LivenessResultnew fields:faceAlreadyRegistered(bool?) —truewhenregistrationOnlymode detects a duplicate facefaceMatchScore(double?) — cosine similarity returned from gallery search; useful for UI feedback
-
LivenessConfignew fields:faceIdMode(FaceIdMode, defaultFaceIdMode.auto)registrationDuplicateThreshold(double, default0.75) — duplicate block threshold forregistrationOnly; intentionally lower thanfaceIdSimilarityThresholdto reject borderline casesminEmbeddingQuality(double, default0.50) — discard degenerate embeddings (bad L2 norm, near-zero variance) before averaging
-
Encrypted embedding storage (v4) — face embeddings are now stored as XOR-encrypted Float32 bytes (base64-encoded) using a 64-byte per-installation key generated with
Random.secure()on first run. Key stored inSharedPreferences; gallery stored under keyffl_known_faces_v4. Previous unencrypted galleries (v1–v3) are automatically invalidated on first launch. -
Accessory Validation —
LivenessConfig.enableAccessoryValidation(defaultfalse). When enabled, verification is blocked if the user is wearing sunglasses/goggles or a cap/hat, with a clear suggestion message shown in the status badge. Detection is purely heuristic — no extra model required:wearingSunglasses— both eye-open probabilities < 0.25 for 6+ consecutive frameswearingCap— forehead gap above eye landmarks < 12% of face bounding-box height for 6+ frames- Two new
DetectionStatusvalues:wearingSunglasses,wearingCap StatusIndicatorWidgetupdated: amber"REMOVE GLASSES"/"REMOVE CAP"badges- Blink action guard: sunglasses check is automatically skipped during blink challenge to prevent false positives from intentional eye closure
Improvements #
-
Gallery-based face matching (best-of-5) —
FaceIdentityServicenow stores up to 5 embeddings per face ID (rolling window, oldest dropped first). Matching uses the maximum cosine similarity across all stored embeddings for a face. This handles within-class variance (lighting, pose, expression) across sessions and significantly reduces false "new face" registrations. -
Eye-landmark filtered frame collection — frontal frames are now only collected when both
leftEyePositionandrightEyePositionlandmarks are detected. This ensures all embeddings in the averaging pool are eye-aligned. The fallback (bounding-box-only crop) is still used when no frontal frames are available. -
Embedding quality filter — embeddings with quality score below
minEmbeddingQualityare discarded before averaging. Quality is scored 0.0–1.0 based on: L2 norm deviation from 1.0 (weight 0.60) + embedding variance (weight 0.40). Degenerate embeddings (all-zero, constant vector, out-of-range norm) score 0.0 and are always discarded. -
faceIdSimilarityThresholddefault raised from0.65→0.82— recalibrated for gallery-based best-of-5 matching. The higher threshold is reliable now that multiple aligned embeddings are averaged per session. -
FaceIdentityServiceconstructor exposes all config —similarityThreshold,registrationDuplicateThreshold,minEmbeddingQuality,mode,maxEmbeddingsPerFaceare all constructor parameters. -
Faster action detection on low-end devices — detection thresholds recalibrated based on real ML Kit output ranges across device tiers:
Action Old threshold New threshold Reason Blink (closed) 0.500.60Slow-camera devices report 0.55–0.60 for closed eyes Blink (open guard) 0.650.65Kept — maintains 0.05 hysteresis gap above closed threshold Turn Left / Right ±15°±12°Cheap Android devices cap ML Kit yaw output at ~12–13° Look Up / Down ±15°±12°Same sensor limitation Hold duration 80 ms50 ms1 frame at 20 fps is sufficient; 80 ms was 1.6 frames Inter-action debounce 800 ms600 msReduces dead zone between consecutive actions Smile > 0.80> 0.72Natural smiles rarely reach 0.80 on ML Kit
Bug Fixes #
-
Face preprocessor inverse similarity transform (
cos(-srcAngle)→cos(srcAngle)) —FacePreprocessor._alignedCrop()usedmath.cos(-srcAngle)andmath.sin(-srcAngle)for the inverse transform. For a tilted face (e.g.srcAngle = -0.245 rad), this mapped the output left-eye pixel to the wrong source y-coordinate (~70 px instead of ~90 px), producing misaligned crops and inconsistent embeddings across sessions. The correct inverse of the forwardR(-srcAngle)rotation isR(+srcAngle). Fixed by removing the negation. -
verificationOnlymode silently passed as success when face not found — whenFaceIdMode.verificationOnlyfound no matching face (FaceMatchOutcome.notFound), the controller printed a debug log but did not fail the session. If all liveness actions were completed,onSuccesswas called withisSuccess: trueandfaceId: null. Fixed:match.isNotFoundnow immediately callsonFailed('Face not recognized — please register first')and returns, matching thefaceAlreadyRegisteredpattern. -
Camera never starts after model download (race condition) —
LivenessController.initialize()ran model downloads as a fire-and-forget coroutine. If the widget was disposed while a download was in progress,_isDisposedwas set totruebutinitialize()continued and opened the camera anyway._processFrame()then silently dropped all frames. Fixed by addingif (_isDisposed) returnguards after each majorawaitin the initialization chain — after each model download, after FaceID model load, and critically before_cameraService.initialize(). -
Blink false positives (hysteresis gap removed) —
_openThresholdwas mistakenly lowered to equal_closedThreshold(both0.60), eliminating the hysteresis gap. Users with naturally droopy eyelids (~0.60 probability) would oscillate rapidly between "closed" and "open" states, triggering false blinks. Fixed by keeping_openThreshold = 0.65, maintaining a 0.05 gap above_closedThreshold. -
AccessoryValidatornot reset on session retry —LivenessEngine.reset()did not call_accessoryValidator.reset(), causing_sunglassesCountand_capCountto carry over into a new session after a retry. Fixed by adding_accessoryValidator.reset()toLivenessEngine.reset().
3.1.0 #
New Features #
-
8-signal on-device replay detection — five new pure-Dart pixel-level signals run every frame alongside MiniFASNet. Final score = minimum of all available signals, so an attacker must simultaneously defeat every layer:
Signal File What it catches S5 – ReplayAnalyzer analysis/replay_analyzer.dartLooped video (perceptual fingerprint), stabilised video (angular micro-jitter), periodic replay (motion entropy), frozen replay (blink consistency) S6 – ScreenArtifactDetector analysis/screen_artifact_detector.dartLCD/OLED glare (specular highlight density), screen backlight (skin chromatic warmth, iOS only), steady backlight (temporal luma stability) S7 – OpticalFlowAnalyzer analysis/optical_flow_analyzer.dartStatic photo (stasis — all blocks near-zero MAD), rigid-body replay (low spatial variance of block motion energies) S8 – FaceGeometryAnalyzer analysis/face_geometry_analyzer.dartFlat surface (3-D depth via cos(yaw) Pearson correlation, eye-ratio consistency), no motion (nose landmark velocity), suspicious asymmetry (eye-open symmetry) -
Face landmarks —
FaceDetectorOptions.enableLandmarks: truenow active.FaceDataexposes 10LandmarkPoint? ({double x, double y})fields:leftEyePosition,rightEyePosition,noseBasePosition,leftCheekPosition,rightCheekPosition,leftMouthPosition,rightMouthPosition,bottomMouthPosition,leftEarPosition,rightEarPosition. -
New
LivenessControllerscore getters —liveReplayScore,liveScreenScore,liveFlowScore,liveGeoScoreexpose rolling per-signal scores for custom overlays.
Improvements #
-
openMouthdetection faster and more reliable — replaced single-frame bbox delta (required 8% jump in one frame — too strict) with a 6-frame rolling median baseline comparison at 5% threshold, held for 2 frames. AddedsmilingProbability > 0.65as a secondary OR-signal (ML Kit raises this when teeth are visible). Net result: detection fires in ~2 frames (~100 ms at 20 fps). -
MiniFASNet preprocessing corrected —
NormalisedMiniFASwrapper expects input[−1, 1]; the previous wrapper-compensation formula produced[−4.6, +4.3]which collapsed outputs to a degenerate ~0.94 for all inputs. Changed to simple BGRp / 127.5 − 1.0. -
Debug overlay extended —
showDebugOverlay: truenow shows all 8 signals:VR-B,LAP,HET,TF,RA,SCR,FLOW,GEOwith inline⚠/okindicators.
3.0.0 #
New Features #
-
Video Replay Attack Detection —
enableVideoReplayDetection: trueadds a second TFLite model (MiniFASNet-V2, 1.7 MB) that runs alongside the existing anti-spoof model to detect pre-recorded video replay attacks. Auto-downloads and caches on first use — no manual model management.LivenessConfig.enableVideoReplayDetection(defaultfalse)LivenessConfig.videoReplayThreshold(default0.50) — score below this flagsvideoReplayDetected: trueLivenessConfig.videoReplayModelPath/videoReplayModelUrl/videoReplayInputSize— custom model supportLivenessResult.videoReplayScore— raw MiniFASNet real-face probability (0.0–1.0)LivenessResult.videoReplayDetected—truewhen a video replay attack is flaggedVideoReplayModelDownloader— streaming HTTP download with progress, primary + fallback URL, cache validation
-
Deepfake threshold —
LivenessConfig.tfliteDeepfakeThreshold(default0.40).deepfakeDetectedis now correctly set based on TFLite real-face score vs this threshold.
Improvements #
-
Anti-spoof engine upgraded to 9 signals — two new heuristic signals added:
- Signal 8 — Brightness variance (weight 0.12): screens have a stable backlight; real rooms flicker subtly
- Signal 9 — Motion jitter (weight 0.05): real humans have micro-tremors; video playback is unnaturally smooth
- Composite threshold raised from 0.45 → 0.50
-
TFLite
_singleScoreuses softmax — replaced raw value clamping with numerically stable softmax. Fixes incorrect 0% real-face scores when model outputs raw logits with negative values.
Bug Fixes #
_dualScorespoof fraction inverted — leaf[i]=1 means a spoof vote;realScoreis now correctly1.0 − spoofFraction(was usingspoofFractionas real score, giving ~8% for real faces).deepfakeDetectedalways false — was never set; now correctly derived fromtfliteScore < tfliteDeepfakeThreshold.
2.9.0 #
New Features #
- Bundled anti-spoof model — zero-config TFLite —
enableTFLite: trueis now all you need. The package automatically downloadsFaceAntiSpoofing.tflite(3.9 MB) on first launch and caches it permanently. NotfliteModelUrl, notfliteInputSize, no model file to bundle. Custom models are still fully supported viatfliteModelPath/tfliteModelUrl.TFLiteModelDownloader.bundledModelUrl— package-internal constant; not exposed in the public API.TFLiteModelDownloader.bundledInputSize—256(required by the bundled FaceAntiSpoofing model).LivenessConfig.tfliteInputSizechanged fromint(default128) toint?(defaultnull→ resolves to256for the bundled model automatically).LivenessConfig.tfliteModelUrl— still accepted for custom models; when omitted, the bundled URL is used.
Improvements #
-
TFLite inference moved to a persistent background isolate —
TFLiteServicenow spawns a long-livedIsolatethat owns theInterpreter. All frame preprocessing (pixel iteration, YUV→RGB, bbox crop/resize) andinvoke()run entirely off the main thread. The camera preview and face-detection pipeline are never blocked, fixing the lag introduced when TFLite was enabled in v2.8.0.Interpreter.fromBuffer()is used in the worker isolate so no Flutter asset bundle is required there.TransferableTypedDatais used for per-frame image bytes — zero-copy transfer to the worker isolate.- Main thread only sends a message and awaits a
Completer; it yields the event loop while waiting.
-
_tfliteWarningbanner auto-clears — The red warning banner now disappears automatically once a successful TFLite inference result is received, rather than persisting for the whole session. -
Race condition fix —
tfliteScorealways non-null on success —LivenessController._onEngineComplete()now tracks_tfliteFutureandawaits it before reading_lastTfliteScore. Previously, if the session completed on the same frame that fired the lastunawaitedinference, the score was alwaysnull.
Bug Fixes #
-
Camera lag and eye-blink detection broken when TFLite enabled — Root cause:
allocateTensors()was being called on every camera frame (an expensive synchronous native call). Fixed by callingresizeInputTensor()+allocateTensors()once at model-load time inload()and removing them fromrun(). Combined with the isolate move above, the main thread is now completely free of TFLite work. -
Blink detection: instant fire on close —
BlinkDetectorpreviously required the full close → re-open cycle before confirming a blink, adding 150–300 ms of latency. Now fires immediately when both eyes drop below the closed threshold. A_wasOpenWindowMs = 1500 msguard (both eyes must have been clearly open within the last 1.5 s) prevents false positives from naturally droopy eyelids. -
Blink detection: raised closed threshold to
0.50— Fast blinks at 20 fps often only drop ML Kit's eye-open probability to0.45–0.55. The previous threshold of0.25(and even0.40) silently missed these.0.50catches them reliably. -
Blink detection: L/R eye sync window widened to 200 ms — ML Kit at
FaceDetectorMode.fastoften reports left and right eye close events 1–3 frames apart. The previous implementation required both eyes in the exact same frame. The new_eyeSyncWindowMs = 200 mswindow (≈ 4 frames at 20 fps) counts them as simultaneous. -
Blink debounce lowered to 400 ms — Was 800 ms; user can retry a missed blink in under half a second.
2.8.0 #
Bug Fixes #
-
TFLite inference was never executed —
TFLiteService.load()printed a success log but never instantiated anInterpreter;TFLiteService.run()returnednullimmediately because_interpreterwas alwaysnull;LivenessController._processFrame()never called_tflite?.run()during frame processing. Net effect:LivenessResult.tfliteScorewas alwaysnullregardless ofenableTFLite: true.- Fixed
TFLiteService.load()to callInterpreter.fromFile()(absolute path) orInterpreter.fromAsset()(Flutter asset key) depending on whethertfliteModelPathstarts with/. - Fixed
TFLiteService.run()— now accepts raw camera frame bytes + face bounding box + sensor orientation, internally crops and resizes the face region toinputSize × inputSize, and calls_interpreter!.runForMultipleInputs()for real inference. - Fixed
LivenessController._processFrame()to fire_tflite!.run()asynchronously on every frame where a face is detected;_isTfliteRunningguard prevents frame queue-up when inference is slower than the camera rate. - Fixed
LivenessController._onEngineComplete()to attach the cached_lastTfliteScoretoLivenessResultvia the newwithTfliteScore()method. - Added
LivenessResult.withTfliteScore()helper (mirrors the existingwithFaceId()pattern). captureRawFramein_processFrame()is now also enabled whenenableTFLite: true(was only enabled forenableFaceId).
- Fixed
-
tfliteModelPathaccepted asset paths in docs but required absolute paths in code — updatedLivenessConfig.tfliteModelPathdocumentation andTFLiteService.load()to explicitly support both Flutter asset keys and absolute filesystem paths.
2.7.0 #
Bug Fixes #
- iOS headLeft / headRight detection inverted — iOS front-camera delivers horizontally-mirrored BGRA8888 frames;
_buildInputImage()passes them to ML Kit withrotation0degand no mirror correction. This caused ML Kit to report a flippedheadEulerAngleYsign: physical right turn produced positive yaw (mapped toturnLeft), physical left turn produced negative yaw (mapped toturnRight). Fixed inFaceData.fromFace()by negatingheadEulerAngleYonPlatform.isIOS, aligning both platforms to the same convention (positive yaw = user physically turned left). Android is unaffected — ML Kit's sensor-rotation correction already provides the correct sign there.
2.5.0 #
Bug Fixes #
- Added
library;declaration toflutter_face_liveness.dart— fixes dangling library doc comment lint warning - Enclosed
forloop body inface_embedding_model.dartwith braces — fixescurly_braces_in_flow_control_structureslint warning - Wrapped home screen
ColumninSingleChildScrollViewin example app — fixesRenderFlexoverflow on small screens; replacedSpacer()withSizedBox(height: 20)(Spacer is incompatible with scroll views)
2.6.0 #
Improvements #
- Added Swift Package Manager (SPM) support for iOS —
ios/flutter_face_liveness/Package.swiftadded with correctSources/structure
Bug Fixes #
- Removed unnecessary
as List<double>cast inTFLiteService._runInference()(line 88) — type was already inferred correctly fromList.filled - Removed unused
_Float32Reshapeextension onFloat32List—reshape()call it depended on was already commented out
2.2.0 #
Improvements #
- Added banner image to README for pub.dev and GitHub documentation
- Upgraded Android Gradle Plugin to 8.9.1 (required by
androidx.camera:1.6.0) - Upgraded Gradle wrapper to 8.11.1
- Updated
permission_handlerto^12.0.1(requires Flutter 3.24+ / Dart 3.5+) - Example app Face ID history screen — locally stores and displays all registered Face IDs with match/new status
2.0.0 #
New Features #
Persistent Face Identity (Face ID)
FaceIdentityService— assigns a stableFID-XXXXidentifier to each unique face that persists across all app sessions usingSharedPreferencesFaceEmbeddingModel— wraps a FaceNet TFLite model (128-dim L2-normalised embeddings); model is auto-downloaded on first use (~23 MB, cached permanently)FaceModelDownloader— streaming HTTP download with progress callback; primary URL + fallback URL; re-downloads automatically if the cached file is corruptedFacePreprocessor— crops + resizes face region to 160×160, normalises pixels to[-1, 1]; runs in acompute()isolate; handles both NV21 (Android) and BGRA8888 (iOS) inputLivenessConfig.enableFaceIdflag (defaultfalse) — zero-config opt-in; no model file to bundleLivenessConfig.faceIdSimilarityThreshold(default0.65) — cosine-similarity cutoff for same-face matchingLivenessResult.faceId— returned alongsidesessionIdon successful verificationLivenessController.clearFaceIdentities()— removes all stored embeddings (e.g. on logout)- Embedding adaptation — stored embedding is updated toward each confirmed new observation (
75% old + 25% new, then re-normalised) so the template improves over time
Isolate-based ML Preprocessing
FrameProcessor— YUV→NV21 conversion, brightness, blur score, and FNV-1a hash all computed in a backgroundcompute()isolate; UI thread stays at 60 fps
Frame Quality Validation
- Per-frame brightness check with debounce (6 consecutive bad frames required before reporting
lowLight/overExposed, absorbing camera auto-exposure settling time) - Platform-correct brightness calculation: iOS BGRA8888 uses BT.601 luminance (
Y = (77R + 150G + 29B) >> 8); Android NV21 uses Y-plane directly - Blur detection via Y-plane variance
Anti-Spoof Engine
- 7-signal composite scoring: eye variance, face geometry, head pose naturalness, eye-open probability, face tracking continuity, micro-motion (yaw/pitch variance), and frame quality
- Rolling 12-frame history — no model file required
Security
SessionManager— cryptographically unique session IDs usingRandom.secure()(12-char timestamp hex + 8-char secure random hex, e.g.LV-018F3A2B9C4E-D7E31F08)FrameHasher— FNV-1a sliding-window replay detection- Fisher-Yates shuffle for randomised action sequences
New Liveness Action
LivenessAction.openMouth— detected via bounding-box height growth (>8%) with low smile probability
UI
LivenessStepIndicator— animated progress dots for current / completed / remaining steps- Download-progress loading screen — shows
%while FaceNet model downloads on first run - Dark / light theme support via
LivenessConfig.themeMode @Deprecated showDebugInfo— replaced byLivenessConfig.showDebugOverlay
New Exports
FaceIdentityService,FaceModelDownloader,FaceModelDownloadExceptionAntiSpoofEngine,AntiSpoofResult,TFLiteServiceSessionManager,RawFrameData
Bug Fixes #
- iOS brightness falsely reported as "too dark" — single-plane BGRA frames were being sampled as if they were NV21 Y-plane data (Blue channel average ≠ luminance); fixed with BT.601 per-pixel luminance
- iOS face crop height clamping —
_resampleBgrausedw-1for both axes; portrait/landscape frames could produce out-of-bounds crops; fixed toh-1for the Y axis - iOS raw frame bytes mismatch —
RawFrameDatastored NV21-converted bytes even on iOS whereFacePreprocessorexpects BGRA8888; now storesimage.planes[0].bytes(original BGRA) on iOS - Same face → different Face ID — similarity threshold
0.78was too strict for cross-session lighting/angle variation; lowered to0.65; stored embedding now adapts toward each confirmed match - Session ID collision — old generator used deterministic XOR of timestamp; replaced with
Random.secure() tflite_flutter 0.10.4compilation failure on Dart ≥ 3.4 —UnmodifiableUint8ListViewwas removed fromdart:typed_data; resolved by overriding totflite_fluttergitmain(v0.12.1)completedActions/remainingActionsnot defined onLivenessController— fixed broken_EngineSequenceextension; getters added directly to controllerbrightness > 0.90false overexposure — threshold raised to0.92to match real sensor output
Breaking Changes #
- Android
minSdkVersionraised from 21 → 26 — required bytflite_flutter 0.12.1 LivenessResultgains optionalfaceIdfield (non-breaking;nullwhenenableFaceIdisfalse)brightnessMindefault changed from0.20→0.12brightnessMaxdefault changed from0.90→0.92
Dependencies Added #
tflite_flutter: (git main — v0.12.1)
shared_preferences: ^2.2.2
http: ^1.2.1
path_provider: ^2.1.3
1.0.0 #
- Initial release
- Real-time face detection via Google ML Kit Face Detection
- Liveness actions: blink, turnLeft, turnRight, lookUp, lookDown, smile
- Anti-spoofing heuristic validator (5-signal composite score)
- Animated face overlay with status indicator and progress bar
- Clean architecture: Camera → ML → Liveness engine → UI layers
- Full null-safety support (Dart 3 / Flutter 3.10+)
- Android API 21+ and iOS 13+ support
- Example app with standard and custom challenge modes