classify_card
On-device Card Gate V3 for Flutter. Given a photo, it decides whether an identity document is the main subject — so you only spend a network round trip on photos that actually contain one.
It runs entirely on the device. Nothing is uploaded, and no network permission is needed.
What it does and does not do
| Does | Does not |
|---|---|
Answers card or other for a photo |
OCR, read fields, or return text |
| Returns a calibrated confidence score | Identify the document subtype (NRC / passport / licence) |
| Runs offline in ~20 ms warm | Produce bounding boxes or crop the card |
| — | Liveness, face matching, or anti-fraud |
It is a pre-check, not a replacement for your classify/OCR backend.
Requirements
| Minimum | Why | |
|---|---|---|
| Android | minSdkVersion 21 |
LiteRT needs API 21. The Flutter plugin template defaults to 19 — if your app is still on 19 you must raise it. |
| iOS | 12.0 | Flutter 3.x minimum; the pod is set to match. |
| App size | +3.8 MB per platform | The model ships inside the plugin. |
You do not need to add noCompress 'tflite', register Xcode resources, or ship the model
yourself — the plugin carries its own model and loads it in a way that does not depend on host-app
packaging settings.
Install
dependencies:
classify_card: ^1.0.1
One host-app change is required.
Android — android/app/build.gradle:
android {
defaultConfig {
minSdkVersion 21 // was flutter.minSdkVersion / 19
}
}
iOS — nothing. The pod declares static_framework = true, so it installs under a plain
use_frameworks! alongside other TFLite-based pods.
Then rebuild the app — this plugin contains native code, so a hot restart is not enough.
Quick start
import 'package:classify_card/classify_card.dart';
final gate = ClassifyCard();
// Once, e.g. in initState of your capture screen. Never throws.
await gate.warmUp();
// After the user captures or picks a photo.
final decision = await gate.evaluate(photoPath, mode: CardGateMode.enforce);
if (!decision.allow) {
showRetakeGuidance(); // no backend call
return;
}
await myBackend.classify(photoPath); // your existing flow, unchanged
photoPath should be the whole photo, upright. Do not crop it to the card first — the model was
trained on full frames. A file:// prefix is fine, as is a bare path; Android also accepts a
content:// URI. EXIF rotation is handled for you.
The two rules that matter
1. It fails open
decision.allow is false for exactly one reason: CardGateReason.other, meaning the model is
confident there is no document. Every other outcome lets the request through — a missing plugin,
a corrupt model, a decode error, a timeout. A broken classifier must never block a customer holding
a valid card.
reason |
allow |
Meaning |
|---|---|---|
card |
✅ | Document found |
other |
❌ | No document — the only blocking case |
shadow |
✅ | A result was produced but mode was shadow |
disabled |
✅ | mode was off |
unavailable |
✅ | Plugin absent from the binary, or the classifier could not be built |
timeout |
✅ | Native call did not settle |
failed |
✅ | Decode or inference failed |
reason.isFailure is true for the last three. Log those — a spike means something is broken.
2. Start in shadow, not enforce
mode defaults to CardGateMode.shadow: the model runs and reports, but never blocks. Ship
that first, log the results next to what your backend decides, and only switch to
CardGateMode.enforce once the two agree on your own traffic.
final decision = await gate.evaluate(path); // shadow — cannot reject anyone
final r = decision.result;
if (r != null) {
analytics.log('card_gate', r.toMap()); // safe to log: scores only, never the image
}
API
Future<String?> warmUp();
Future<CardGateResult> classify(String imagePath, {Duration timeout});
Future<CardGateDecision> evaluate(String imagePath, {CardGateMode mode, Duration timeout});
Future<void> release();
| Method | Throws? | Use when |
|---|---|---|
warmUp() |
No — returns null on failure |
On screen mount, so the first photo is not charged the cold-start cost |
evaluate() |
No | Normal use. Handles fail-open for you |
classify() |
CardGateException |
You want to own the error handling yourself |
release() |
No | Capture flow is done and you want the ~4 MB back |
CardGateResult carries label, score (calibrated), rawScore (the model's own output),
modelVersion, latencyMs and decodeMs. toMap() is safe to log — it never contains image data.
Do not re-threshold rawScore yourself. 0.5 is not the production operating point. score is
the calibrated value that label was derived from, using the Platt transform and threshold in the
bundled policy.
The plugin deliberately does no logging of its own. It hands you the data; you decide what goes to your own telemetry.
How each platform runs the model
| Android | iOS | |
|---|---|---|
| Runtime | com.google.ai.edge.litert:litert + -support + -metadata 1.4.0 |
TensorFlowLiteSwift ~> 2.12.0 |
| Preprocessing | Hand-written | Hand-written |
| Matches the vendor reference exactly | Equivalent, not bit-identical | Equivalent, not bit-identical |
Both platforms run the same pipeline: EXIF-upright, bilinear resize to 224x224 with half-pixel centres, the metadata normalization, then the input tensor's own quantization. The arithmetic is identical on both, so a photo should score the same either side.
Neither uses the TFLite Task Library, which is what the vendor reference is written against.
org.tensorflow:tensorflow-lite-task-vision was never ported to LiteRT — there is no
litert-task-vision — and shipping it alongside LiteRT puts two TFLite runtimes in one APK.
On iOS TensorFlowLiteTaskVision is unusable in a host app for three separate reasons:
- its 0.4.3 static framework exports the same
TfLite*symbols asTensorFlowLiteC, so it collides with any app that already ships TFLite; - it redefines MLKit's
GMLImageclass, so it collides with any app using MLKit; - it has no arm64 simulator slice, so it cannot build for the iOS Simulator on Apple Silicon;
- it has not been released since November 2022.
The consequence you should know about: the preprocessing is a faithful reimplementation, not the
vendor's own code, so rawScore can differ slightly from the reference implementation. Measure it
on your own images before enforcing — this is the main reason to run shadow first.
Verifying it before you enforce
- Run the
example/app, set the mode selector toshadow. - Feed it ~10 real documents (including rotated 90° / 180° / 270°) and ~10 non-documents — portraits, landscapes, junk.
- Check the label is right in every case, and compare
rawScorebetween an Android device and an iPhone on the same photos. - Watch
latencyMs. Target is p95 under 100 ms after warm-up. - Only then switch to
enforce.
Integrity
The model is verified on every startup: its SHA-256 must equal metadata.model_sha256 in the
bundled policy.json, and the policy's schema_version, score_transform.kind, scale and
threshold are all range-checked. A mismatch makes the classifier unavailable — which fails open,
it does not silently reject photos. The bundled model hashes to
361bbfc4e540e717d6a961ed119f47719416241ed939b2e0e90024ca14dd5855.
Model tensors, for reference: input input_1 UINT8 [1,224,224,3] scale 0.003921568859368563
zero-point 0; output Identity UINT8 [1,2] scale 0.00390625 zero-point 0.
Troubleshooting
reason is always unavailable
Either the plugin is not in the binary — do a full rebuild, not a hot restart — or the native setup
failed. Check the device log for card_gate_unavailable; the message names the exact cause (missing
resource, SHA-256 mismatch, bad policy field).
Android build fails on minSdkVersion
Raise minSdkVersion to at least 21 in your app's android/app/build.gradle.
CocoaPods cannot resolve TensorFlowLiteSwift
This pod asks for ~> 2.12.0 (>= 2.12.0, < 2.13.0), matching ekyc_flutter_sdk and
tflite_flutter. If another pod in your app needs a different 2.x, CocoaPods cannot install both —
one version of a pod is all it will take. Align the constraints, then re-run the verification steps
above.
Note that changing a development pod's constraint is not picked up by pod install; run
pod update TensorFlowLiteSwift to make CocoaPods re-resolve it.
transitive dependencies that include statically linked binaries
You should not see this: the podspec declares static_framework = true, so a plain
use_frameworks! is enough. If it appears anyway, another pod depending on TensorFlowLiteSwift is
missing that declaration.
Duplicate symbol errors mentioning TfLite
Something else in your app links its own TFLite copy — most often
TensorFlowLiteTaskVision, MediaPipeTasksVision, or a vendored .framework. Only one TFLite
runtime can be linked; consolidate on TensorFlowLiteSwift.
Android: duplicate libtensorflowlite_jni.so
Another dependency pulls org.tensorflow:tensorflow-lite* while this plugin uses LiteRT. They are
different Maven coordinates, so Gradle keeps both. Move the other dependency to
com.google.ai.edge.litert:*.
iOS Simulator on an Apple Silicon Mac
Works. TensorFlowLiteSwift ships a proper xcframework with an arm64 simulator slice — this is one
of the reasons Task Vision was rejected.
License
See LICENSE. The bundled model and policy are covered by your agreement with the model provider,
not by this package's license.