zp_cccd_scanner
Flutter plugin quét và nhận diện thẻ CCCD / Căn cước Việt Nam bằng camera native, hiệu ứng giống Zalo: khi phát hiện đúng thẻ trong khung, frame đóng băng và tia quét chạy từ trên xuống.
- iOS: AVFoundation + Vision (nhận diện chữ, QR, PDF417, khuôn mặt)
- Android: CameraX + ML Kit bundled (Text Recognition Latin, Barcode) — không có Face Detection (đã gỡ để giảm dung lượng, xem CHANGELOG)
- Toàn bộ xử lý trên máy, không gửi ảnh ra ngoài
Thẻ được hỗ trợ
| Mẫu thẻ | Mặt trước | Mặt sau |
|---|---|---|
| CCCD mã vạch (2016–2021) | ✅ | ✅ (PDF417 / cơ quan cấp, cả mẫu trước và sau 2019) |
| CCCD gắn chip (2021–06/2024) | ✅ (QR) | ✅ (MRZ) |
| Thẻ Căn cước (từ 07/2024) | ✅ | ✅ (QR + MRZ) |
CMND (hết hiệu lực từ 01/01/2025), GPLX, hộ chiếu, thẻ ngân hàng, giấy chứng nhận căn cước... đều bị loại.
Chống nhận nhầm
Chỉ chấp nhận khi có bằng chứng mạnh, rồi xác nhận lại lần 2 trên chính frame đã đóng băng:
- MRZ (mặt sau thẻ chip / Căn cước):
IDVNM, kiểm tra đủ 5 check digit ICAO 9303 (gồm check digit riêng của số định danh 12 số). - PDF417 trải ngang mép trên + cụm chữ đặc trưng (mặt sau CCCD mã vạch).
- Mặt trước: so khớp cả dòng tiêu đề, số định danh hợp lệ (mã tỉnh, thế kỷ, năm sinh) và khớp chéo với ngày sinh / giới tính in trên thẻ.
- Thẻ phải nằm trọn trong khung: dò 4 cạnh thẻ trên ảnh xám của vùng khung (dùng chung cho iOS/Android). Không chụp khi có góc bị cắt, thẻ quá gần / quá xa, hoặc tỉ lệ không đúng thẻ ID-1; lúc đó hiện hướng dẫn "Đưa toàn bộ thẻ vào trong khung".
- Cùng kết luận ở 2 frame liên tiếp trong 1.5 s.
Cài đặt
dependencies:
zp_cccd_scanner: ^1.9.0
- iOS ≥ 15.5. Thêm vào
Info.plist:<key>NSCameraUsageDescription</key> <string>Cần dùng camera để quét thẻ CCCD</string> - Android minSdk 24 (nâng từ 21 kể từ 1.7.0, bắt buộc bởi dependency ONNX Runtime của bộ dò AI
tuỳ chọn — xem mục "Dò thẻ bằng AI" bên dưới, ảnh hưởng mọi app host dù có bật AI hay không),
Activity phải là
FlutterActivity/FlutterFragmentActivity.
Sử dụng
Mỗi CccdScannerView quét đúng một mặt. Luồng eKYC: chụp mặt trước rồi mặt sau, sau đó kiểm tra 2 mặt cùng một thẻ.
final front = await Navigator.push<CccdScanResult>(context, MaterialPageRoute(
builder: (context) => Scaffold(
body: CccdScannerView(
side: CccdSide.front,
onScanned: (r) async => Navigator.pop(context, r),
),
),
));
// ... quét mặt sau tương tự với side: CccdSide.back
switch (CccdPairing.check(front!, back!)) {
case CccdPairStatus.match:
case CccdPairStatus.sameTypeOnly:
// OK
break;
default:
// Hai mặt không cùng một thẻ
}
CccdScanResult gồm: type (mẫu thẻ), side, image (JPEG chứa trọn thẻ), corners (4 góc thẻ), cardRect, pairingKey (khóa nội bộ để ghép 2 mặt), highResolution (ảnh cắt từ ảnh chụp tĩnh độ phân giải cao hay từ frame video). Lib không trích xuất họ tên, ngày sinh...
Chất lượng ảnh
- Chỉ chụp khi frame đủ nét: camera không đang lấy nét / đổi ống kính và độ nét không kém các frame vừa thấy thẻ.
- Mặc định (
highResolutionImage: true) ảnh thẻ được cắt từ ảnh chụp tĩnh (~12MP trên iOS, độ phân giải cao nhất trên Android); nếu máy không chụp được hoặc thẻ đã xê dịch thì dùng frame video. - iPhone có macro vẫn tự chuyển ống ultra-wide khi thẻ quá gần; lib zoom bù để khoảng cầm máy tự nhiên nằm ngoài vùng chuyển ống.
- Thiếu sáng: camera tự tăng bù sáng, không tự bật đèn (đèn dễ gây lóa mặt thẻ). Dùng
ZpCccdScanner.lowLightđể gợi ý vàZpCccdScanner.setTorch/ZpCccdScanner.torchcho nút đèn. - Gợi ý phụ
CccdScanTip(giữ yên máy, đưa ra xa, thiếu sáng, giấy tờ khác, thẻ bị che) hiển thị trong dòng hướng dẫn mặc định; dùngonTipChangednếu tự làmhintBuilder. - Chặn chụp khi thẻ bị che: nếu ngón tay/vật cản che 1 góc thẻ hoặc che vùng nội dung quan trọng (avatar, mã vạch, QR, dòng MRZ, nhãn thông tin, vân tay — tuỳ loại thẻ), thư viện không tự chụp/nhận diện dù phần còn lại của thẻ vẫn đọc được, báo qua
CccdScanTip.cardObscured. Nếu app cóswitchđầy đủ theoCccdScanTipthì cần thêm case này. Dò che dùng tối đa 3 tín hiệu chạy song song (OR): đo độ phẳng vùng ảnh (luôn có, cả 2 nền tảng), nhận diện khuôn mặt cho vùng avatar (chỉ iOS, quaVision— Android đã gỡ Face Detection để giảm dung lượng, chỉ còn đo độ phẳng cho avatar), và dò màu cho vân tay/QR/PDF417 (vùng in mực đen trắng bị che bởi vật có màu như da tay, vải..., cả 2 nền tảng).
Tùy biến giao diện: guideWidthFactor, guideAlignment, maskColor, idleColor, lockedColor, scanLineColor, scanDuration, hintBuilder, overlay. Bật showDebugOverlay để xem các dòng chữ / mã native đọc được, 4 cạnh thẻ dò được (khung cam) và lý do từ chối của frame gần nhất. (detectedColor vẫn còn tham số nhưng từ bản này không còn hiệu lực nhìn thấy được ở bất kỳ đâu — xem lockedColor bên dưới.)
Từ lúc thẻ được khoá (CccdScanState.verifying trở đi, giữ nguyên tới hết rejected/done), màu 4 góc khung đổi thành lockedColor (mặc định xanh lá Color(0xFF22C55E)) — áp dụng cho mọi app, không cần bật gì thêm. Lớp phủ tối ngoài khung cũng ẩn trong khoảng verifying/done này (ảnh đã đóng băng, lớp phủ chỉ còn che bớt hình) — hiện lại ngay khi bị từ chối (rejected) hoặc khi thực sự quét lại.
Trong lúc đang tìm thẻ, 4 góc overlay luôn ở khung hướng dẫn tĩnh (kể cả khi bật trackCardCorners) — tránh cảm giác giật do quad dò được dao động mỗi khung hình. Nếu trackCardCorners: true (mặc định false) và freezeOnVerify: true (mặc định), lúc khoá thẻ 4 góc trượt mượt — đúng một lần — từ khung tĩnh tới đúng 4 góc thẻ thật đã dò được, và tia quét chạy dọc theo đúng hình thẻ đó (nghiêng theo thẻ nếu thẻ nghiêng) thay vì theo khung hướng dẫn tĩnh; trackCardCorners: false (mặc định) hoặc freezeOnVerify: false thì 4 góc vẫn ở khung tĩnh như trước, chỉ đổi màu.
Không đóng băng camera khi xác thực (tuỳ chọn)
Mặc định (freezeOnVerify: true), khi thẻ được phát hiện và bắt đầu xác thực lần 2, view đóng
băng khung hình (hiệu ứng giống Zalo) như các bản trước — hành vi giữ nguyên, không cần đổi gì.
Đặt freezeOnVerify: false để thay vào đó giữ camera chạy live (native retain() thay vì
freeze()) và phủ hiệu ứng lưới lên vùng thẻ trong lúc xác thực vẫn chạy ngầm trên frame đã giữ
lại; hint mặc định lúc này cũng đổi thành "Giữ vững, đang chụp..." thay vì "Giữ yên điện thoại,
đang chụp ảnh thẻ...". Dùng kèm tipWarningColor để đổi màu 4 góc khung khi đang tìm thẻ mà có
bất kỳ gợi ý CccdScanTip nào đang active (mất nét, đưa ra xa, thiếu sáng, sai loại giấy tờ, bị
che) hoặc khi khung hình chưa thấy giấy tờ nào:
CccdScannerView(
side: CccdSide.front,
freezeOnVerify: false,
tipWarningColor: Colors.red,
onScanned: (r) async => Navigator.pop(context, r),
)
Nếu app tự vẽ hint ở nơi khác (qua overlay) thay vì dùng hintBuilder/màu khung mặc định, dùng
onNoCandidateChanged — callback báo true/false mỗi khi tín hiệu "khung hình hiện tại có/không
thấy giấy tờ nào" đổi (chỉ có ý nghĩa lúc đang tìm thẻ, trạng thái CccdScanState.searching).
Chụp thủ công khi không nhận diện được
Nếu người dùng đưa thẻ mãi mà auto-detect không nhận diện được (thẻ mòn, ánh sáng kém...), app có thể tự quyết định khi nào hiện nút "chụp thủ công" (VD sau 15 giây) và để người dùng tự chụp, gửi ảnh lên BE xử lý tiếp — thư viện không tự quyết định thời điểm này, chỉ cung cấp hàm chụp:
final controller = CccdScannerController();
Timer? manualCaptureTimer;
var manualCaptureAvailable = false;
// app tự quản lý đồng hồ đếm giờ + ẩn/hiện nút theo onStateChanged
manualCaptureTimer = Timer(const Duration(seconds: 15), () {
setState(() => manualCaptureAvailable = true);
});
CccdScannerView(
side: CccdSide.front,
controller: controller,
onScanned: (r) async => Navigator.pop(context, r),
onStateChanged: (s) => setState(() => scanState = s),
overlay: manualCaptureAvailable &&
scanState != CccdScanState.verifying &&
scanState != CccdScanState.done
? FilledButton(
onPressed: () async {
final result = await controller.captureManual();
if (result != null) Navigator.pop(context, result);
},
child: const Text('Chụp thủ công'),
)
: null,
)
CccdManualCaptureResult gồm side (mặt đã yêu cầu quét, chưa được xác thực — ảnh chưa qua
OCR), image (JPEG vùng khung hướng dẫn, luôn có), highResolution. Không có type/pairingKey
vì chưa chạy validator — BE tự xử lý tiếp. Chụp thủ công hoạt động ở mọi trạng thái tìm thẻ (kể cả
wrongSide), và không chặn luồng eKYC: có thể chụp thủ công một mặt rồi vẫn quét tự động mặt còn
lại bình thường (lúc đó bỏ qua được CccdPairing.check vì thiếu pairingKey).
Sau khi chụp thành công, view giữ nguyên trạng thái đóng băng cho tới khi app rời màn hình hoặc
gọi controller.resume() (quay lại quét tự động mà không cần mở lại camera).
Dò thẻ bằng AI (tuỳ chọn, mặc định tắt)
Mặc định thư viện vẫn dò 4 góc thẻ bằng kỹ thuật Hough transform cổ điển (CardQuadDetector) như
các bản trước — không set gì thêm thì hành vi giữ nguyên 100%. Có thể chuyển sang dùng model AI
(ONNX Runtime, train từ Ultralytics YOLO26) để dò 4 góc thẻ + phát hiện bị che chính xác hơn trong
điều kiện khó — thay cho Hough, không chạy song song. Đã chọn aiModel thì cả phiên chỉ dùng AI để dò 4 góc và bị che: không gọi Hough, không chấm che theo độ phẳng/khuôn mặt/màu của luồng cũ, và không tự chuyển về Hough khi model lỗi. Model không nạp được thì phiên đó không dò được thẻ. AI không đọc chữ và không thay CccdValidator — OCR/MRZ/QR vẫn chạy để đọc nội dung thẻ.
Set trước khi gọi ZpCccdScanner.start() / mở CccdScannerView:
import 'package:zp_cccd_scanner/zp_cccd_scanner.dart';
ZpCccdScanner.aiModel = ZpCccdAiModel.fp32;
ZpCccdAiModel.fp32: model duy nhất hiện có (~10.6MB mỗi nền tảng), đầy đủ độ chính xác (model lượng tử hoáint8đã bị gỡ — chỉ tiết kiệm ~3.2MB trong khi phần nặng nhất của plugin là thư viện native ONNX Runtime (~34-41MB tuỳ ABI), không phải file model; đổi lại độ chính xác kém hơn rõ rệt nên không đáng đánh đổi — xem CHANGELOG).ZpCccdScanner.aiActive(read-only):truekhi model yêu cầu đã load thành công trên máy hiện tại trong phiên này. Nếu native load model thất bại, phiên đó không dò được thẻ — không chuyển sang Hough.- Mặc định
aiModel = null(AI tắt hoàn toàn) — app host hiện tại không cần đổi gì. - Giới hạn phạm vi dò che khuất: model AI chỉ có lớp "bị che" riêng cho CCCD 2021 và thẻ Căn
cước 2024 (xem
model_handover/README.mdmục 2) — với CCCD 2016 và CMND giấy, bật AI nghĩa là KHÔNG có tín hiệu che khuất hình học nào cho loại thẻ đó (khác bộ dò Hough cổ điển vẫn có kiểm tra che góc chung cho mọi hình dạng thẻ).
Ảnh hưởng tới mọi app host cập nhật lên bản có tính năng này, kể cả không bật AI (vì model được đóng gói kèm sẵn để bật/tắt runtime không cần tải thêm):
- Android
minSdknâng từ 21 lên 24 (xem mục "Cài đặt" ở trên). - File model (
cccd_seg_fp32.onnx, ~10.6MB) được đóng gói vô điều kiện vào asset của cả Android và iOS — tăng dung lượng APK/IPA dù có dùng AI hay không. Chưa hỗ trợ tách theo flavor/dynamic delivery ở bản này. - Dung lượng thư viện native ONNX Runtime (KHÁC, LỚN HƠN 2 file model ở trên) — đo trực tiếp
trên bản dựng thật:
- Android:
libonnxruntime.so~34.2MB (arm64-v8a, ABI phổ biến nhất trên máy thật), ~24.2MB (armeabi-v7a), ~40.7-40.8MB (x86/x86_64), cộng thêmlibonnxruntime4j_jni.sonhỏ (~60-120KB mỗi ABI). Build AAB/split-APK (khuyến nghị) chỉ gửi 1 ABI/máy nên người dùng cuối chỉ tải thêm ~24-41MB tuỳ thiết bị, không phải cộng dồn cả 4 ABI như build APK "fat". - iOS: pod
onnxruntime-cnặng ~243MB trên đĩa (nhiều lát kiến trúc: thiết bị + simulator + macOS); riêng lát thiết bị thật (ios-arm64) ~46MB. Dung lượng thực tế cộng vào app sau khi linker loại bỏ phần không dùng nhỏ hơn con số này nhưng chưa được đo chính xác (cần thiết bị thật + App Store Connect size report).
- Android:
- Telemetry ONNX Runtime:
- Android: đã xác nhận bị tắt từ bản này.
ai.onnxruntime.TelemetryInitializer(mộtContentProviderchạy ở MỌI lần khởi động process app host, bất kể có bật AI hay không, nạp thư viện native và dựng một client telemetry Microsoft 1DS) đã bị gỡ khỏi manifest (tools:node="remove"), và plugin tự gọiOrtEnvironment.setTelemetry(false)khi nạp model (phòng vệ kép). Đã kiểm chứng bằng build release thật: log merger xác nhận provider bịREJECTED, log runtime của chính ONNX Runtime xác nhậnHttpClienttelemetry "was not initialized". (Tài liệu C API chính thức của ONNX Runtime ghi "telemetry mặc định BẬT vì nhẹ" — áp dụng mọi nền tảng, KHÔNG riêng Windows.) - Quyền
INTERNET/ACCESS_NETWORK_STATEvẫn merge vào manifest app host dù provider đã bị gỡ (dependency tự khai 2 quyền này độc lập với provider). Muốn gỡ luôn 2 quyền này, app host có thể tự thêmtools:node="remove"trong manifest của mình — cân nhắc kỹ nếu app còn cần mạng cho việc khác (upload ảnh lên backend eKYC chẳng hạn). - iOS: đã triển khai tắt qua C API thô (
OrtApi::DisableTelemetryEvents, vìonnxruntime-objc'sORTEnvkhông expose API này) — đã kiểm chứng chuỗi gọiCreateEnv→DisableTelemetryEventschạy thành công, không crash, trên iOS Simulator. Không bắt được gói tin mạng thực tế để xác nhận 0% traffic (không có công cụ network-capture khi kiểm thử) — chỉ xác nhận lệnh gọi API công khai của ONNX Runtime báo cáo thành công.
- Android: đã xác nhận bị tắt từ bản này.
Giấy phép trọng số model (AGPL-3.0): cccd_seg_fp32.onnx được train bắt
đầu từ Ultralytics YOLO26 (giấy phép AGPL-3.0), nên trọng số bàn giao kèm theo cũng phải xem
là AGPL-3.0 (xem model_handover/README.md mục 7). Nếu sản phẩm cần đóng source hoặc dùng thương
mại theo cách không tương thích AGPL-3.0, cần: (a) tự kiểm tra điều khoản đúng phiên bản
Ultralytics đã dùng (8.4.175) tại thời điểm phát hành, (b) mua giấy phép Ultralytics Enterprise,
hoặc (c) train lại từ một model nền khác mà mình có giấy phép.
Giấy phép dữ liệu huấn luyện (ràng buộc khác, độc lập với AGPL-3.0 ở trên): ảnh che khuất tổng
hợp dùng để train model được tạo bằng cách dán vật che lên ảnh thẻ gốc lấy từ các bộ dữ liệu công
khai (Roboflow) — các bộ này có điều khoản riêng (thường giới hạn cho mục đích phi thương mại) và
ảnh tổng hợp kế thừa điều khoản của ảnh gốc. Phải tự kiểm tra giấy phép của từng nguồn ảnh trước khi
phát hành thương mại; xem chi tiết tại model_handover/README.md mục 7.
App demo
cd example
fvm flutter run
Có 3 lựa chọn: quét riêng mặt trước, quét riêng mặt sau, và luồng eKYC (trước → sau → kiểm tra cùng thẻ). Có thêm switch "Dùng AI dò thẻ" để thử nhanh bộ dò AI tuỳ chọn ở trên mà không cần sửa code.