Приложение для сканирования документов — это программное приложение, которое использует камеру вашего устройства для захвата изображений физических документов и преобразования их в цифровые форматы. Как правило, эти приложения могут сканировать документы, фотографии, квитанции, визитные карточки и многое другое.
Приложения для сканирования документов полезны в различных отраслях и сценариях, включая, помимо прочего, образование, бизнес, финансы и здравоохранение. Возможно, вы знакомы с некоторыми известными приложениями для сканирования документов, такими как Adobe Scan, CamScanner и Microsoft Office Lens. Цель этой статьи — помочь вам в разработке собственного кроссплатформенного приложения для сканирования документов с использованием Flutter и Dynamsoft Document Normalizer. Используя единую кодовую базу, вы можете создать приложение для сканирования документов, работающее на платформах Windows, Android, iOS и Web.
Попробуйте онлайн-демонстрацию на своих мобильных устройствах
https://yushulx.me/flutter-document-scanner/
Изучение различий между сканерами Flutter MRZ, сканерами штрих-кода и сканерами документов
В предыдущих проектах мы разработали как Приложение сканера Flutter MRZ, так и Приложение сканера штрих-кода Flutter. Плагины Flutter, используемые в этих трех приложениях, во многом идентичны. Основное различие заключается в SDK, используемых для конкретных задач машинного зрения: flutter_ocr_sdk
для обнаружения MRZ, flutter_barcode_sdk
для сканирования штрих-кода и flutter_document_scan_sdk
для обнаружения краев документа и коррекции перспективы.
Большая часть кода пользовательского интерфейса используется всеми тремя приложениями. Например, все они используют панель вкладок для навигации между домашней страницей, страницей истории и страницей о странице. Логика управления камерой остается неизменной во всех приложениях. Основное отличие заключается в том, что приложение сканера документов включает страницу редактирования для настройки перспективы отсканированного документа. После завершения четырехугольной корректировки документ обрезается и редактируется на отдельной странице сохранения. Для улучшения качества изображения доступны различные фильтры, такие как оттенки серого, черно-белые и цветные.
Необходимые плагины Flutter
- flutter_document_scan_sdk: включает SDK Dynamsoft Document Normalizer для Flutter, поддерживающий Windows, Linux, Android, iOS и Интернет. Для использования плагина требуется действующий лицензионный ключ.
- image_picker: предоставляет простой способ выбрать изображение/видео из библиотеки изображений или сделать фото/видео с помощью камеры.
- shared_preferences: Обертывает постоянное хранилище для конкретных платформ для простых данных (NSUserDefaults на iOS и macOS, SharedPreferences на Android и т. д.).
- камера: предоставляет API для идентификации камер, отображения предварительного просмотра и захвата изображений или видео с камеры.
- share_plus: делится контентом через пользовательский интерфейс платформы.
- url_launcher: запускает URL-адреса, облегчая открытие веб-страницы.
- flutter_exif_rotation: автоматически поворачивает изображения в зависимости от ориентации EXIF на Android и iOS.
Начало работы с приложением
- Создайте новый проект Flutter с помощью команды:
flutter create documentscanner
2. Добавьте зависимости в pubspec.yaml
:
dependencies: flutter_document_scan_sdk: ^1.0.2 image_picker: ^1.0.0 shared_preferences: ^2.1.1 camera: git: url: https://github.com/yushulx/flutter_camera.git camera_windows: git: url: https://github.com/yushulx/flutter_camera_windows.git share_plus: ^7.0.2 url_launcher: ^6.1.11 flutter_exif_rotation: ^0.5.1
3. Создайте файл global.dart
для хранения глобальных переменных:
import 'package:flutter_document_scan_sdk/flutter_document_scan_sdk.dart'; FlutterDocumentScanSdk docScanner = FlutterDocumentScanSdk(); bool isLicenseValid = false; Future<int> initDocumentSDK() async { int? ret = await docScanner.init( 'DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ=='); if (ret == 0) isLicenseValid = true; await docScanner.setParameters(Template.color); return ret ?? -1; }
4. Замените содержимое lib/main.dart
следующим кодом:
import 'package:flutter/material.dart'; import 'tab_page.dart'; import 'dart:async'; import 'global.dart'; Future<void> main() async { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); Future<int> loadData() async { return await initDocumentSDK(); } @override Widget build(BuildContext context) { return MaterialApp( title: 'Dynamsoft Barcode Detection', theme: ThemeData( scaffoldBackgroundColor: colorMainTheme, ), home: FutureBuilder<int>( future: loadData(), builder: (BuildContext context, AsyncSnapshot<int> snapshot) { if (!snapshot.hasData) { return const CircularProgressIndicator(); } Future.microtask(() { Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => const TabPage())); }); return Container(); }, ), ); } }
Реализация функций приложения сканера документов
Ознакомьтесь с Руководством по дизайну пользовательского интерфейса:
В следующих параграфах мы углубимся в функции приложения для сканирования документов. Мы сосредоточимся на использовании API обнаружения краев и коррекции перспективы, реализации страницы редактирования краев и создании страницы сохранения документа.
Обнаружение краев документа и коррекция перспективы
Приложение сканера документов предлагает два метода сканирования документа: сканирование в реальном времени через поток камеры или обнаружение из выбранного файла изображения. При использовании камеры буфер видеопотока принимается и вызывается метод detectBuffer()
. Что касается файла изображения, вы можете либо вызвать detectFile()
, либо сначала декодировать изображение в буфер, вызвав decodeImageFromList()
, а затем вызвать метод detectBuffer()
. Оба метода возвращают объект Future<List<DocumentResult>?>
.
// Image file XFile? photo = await picker.pickImage(source: ImageSource.gallery); // detectFile() var results = await docScanner.detectFile(photo.path); // detectBuffer() Uint8List fileBytes = await photo.readAsBytes(); ui.Image image = await decodeImageFromList(fileBytes); ByteData? byteData = await image.toByteData(format: ui.ImageByteFormat.rawRgba); if (byteData != null) { List<DocumentResult>? results = await docScanner.detectBuffer( byteData.buffer.asUint8List(), image.width, image.height, byteData.lengthInBytes ~/ image.height, ImagePixelFormat.IPF_ARGB_8888.index); } // Video streaming buffer Future<void> processDocument(List<Uint8List> bytes, int width, int height, List<int> strides, int format, List<int> pixelStrides) async { var results = docScanner.detectBuffer(bytes[0], width, height, strides[0], format); ... }
Объект DocumentResult
содержит следующие свойства:
List<Offset> points
: Координаты четырехугольника.int confidence
: Уверенность в результате.
На основе свойства points
документ можно обрезать и исправить, вызвав метод normalizeFile()
или normalizeBuffer()
.
// File var normalizedImage = await docScanner.normalizeFile(file, points); // Buffer Future<void> handleDocument(Uint8List bytes, int width, int height, int stride, int format, dynamic points) async { var normalizedImage = docScanner .normalizeBuffer(bytes, width, height, stride, format, points); }
Оба метода возвращают объект NormalizedImage
, представляющий исправленное изображение:
class NormalizedImage { /// Image data. final Uint8List data; /// Image width. final int width; /// Image height. final int height; NormalizedImage(this.data, this.width, this.height); }
Чтобы отобразить изображение, данные Uint8List
необходимо декодировать в объект ui.Image
с помощью метода decodeImageFromPixels()
:
decodeImageFromPixels(normalizedImage.data, normalizedImage.width, normalizedImage.height, pixelFormat, (ui.Image img) { });
Страница редактирования края
Автоопределение не всегда точно. Вот почему нам нужна страница редактирования ребер для корректировки четырехугольника. Страница содержит виджет OverlayPainter
для рисования четырехугольника и изображения, виджет GestureDetector
для обработки событий касания и повторного рисования четырехугольника, а также виджет Stack
для отображения изображения и четырехугольника.
class OverlayPainter extends CustomPainter { ui.Image? image; List<DocumentResult>? results; OverlayPainter(this.image, this.results); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = colorOrange ..strokeWidth = 10 ..style = PaintingStyle.stroke; if (image != null) { canvas.drawImage(image!, Offset.zero, paint); } Paint circlePaint = Paint() ..color = colorOrange ..strokeWidth = 20 ..style = PaintingStyle.fill; if (results == null) return; for (var result in results!) { canvas.drawLine(result.points[0], result.points[1], paint); canvas.drawLine(result.points[1], result.points[2], paint); canvas.drawLine(result.points[2], result.points[3], paint); canvas.drawLine(result.points[3], result.points[0], paint); if (image != null) { double radius = 20; canvas.drawCircle(result.points[0], radius, circlePaint); canvas.drawCircle(result.points[1], radius, circlePaint); canvas.drawCircle(result.points[2], radius, circlePaint); canvas.drawCircle(result.points[3], radius, circlePaint); } } } @override bool shouldRepaint(OverlayPainter oldDelegate) => true; } Widget createCustomImage() { var image = widget.documentData.image; var detectionResults = widget.documentData.documentResults; return FittedBox( fit: BoxFit.contain, child: SizedBox( width: image!.width.toDouble(), height: image.height.toDouble(), child: GestureDetector( onPanUpdate: (details) { if (details.localPosition.dx < 0 || details.localPosition.dy < 0 || details.localPosition.dx > image.width || details.localPosition.dy > image.height) { return; } for (int i = 0; i < detectionResults.length; i++) { for (int j = 0; j < detectionResults[i].points.length; j++) { if ((detectionResults[i].points[j] - details.localPosition) .distance < 100) { bool isCollided = false; for (int index = 1; index < 4; index++) { int otherIndex = (j + 1) % 4; if ((detectionResults[i].points[otherIndex] - details.localPosition) .distance < 20) { isCollided = true; return; } } setState(() { if (!isCollided) { detectionResults[i].points[j] = details.localPosition; } }); } } } }, child: CustomPaint( painter: OverlayPainter(image, detectionResults!), ), ))); } body: Stack( children: <Widget>[ Positioned.fill( child: createCustomImage(), ), const Positioned( left: 122, right: 122, bottom: 28, child: Text('Powered by Dynamsoft', textAlign: TextAlign.center, style: TextStyle( fontSize: 12, color: Colors.white, )), ), ], ),
Исправление документа и сохранение страницы
Страница исправления и сохранения документа содержит виджет OverlayPainter
для рисования изображения исправленного документа, группу Radio
для выбора формата изображения и виджет ElevatedButton
для сохранения изображения.
Widget createCustomImage(BuildContext context, ui.Image image, List<DocumentResult> detectionResults) { return FittedBox( fit: BoxFit.contain, child: SizedBox( width: image.width.toDouble(), height: image.height.toDouble(), child: CustomPaint( painter: OverlayPainter(image, detectionResults), ))); } <Widget>[ Theme( data: Theme.of(context).copyWith( unselectedWidgetColor: Colors.white, // Color when unselected ), child: Radio( activeColor: colorOrange, value: 'binary', groupValue: _pixelFormat, onChanged: (String? value) async { setState(() { _pixelFormat = value!; }); await docScanner.setParameters(Template.binary); if (widget.documentData.documentResults!.isNotEmpty) { await normalizeBuffer(widget.documentData.image!, widget.documentData.documentResults![0].points); } }, ), ), const Text('Binary', style: TextStyle(color: Colors.white)), Theme( data: Theme.of(context).copyWith( unselectedWidgetColor: Colors.white, // Color when unselected ), child: Radio( activeColor: colorOrange, value: 'grayscale', groupValue: _pixelFormat, onChanged: (String? value) async { setState(() { _pixelFormat = value!; }); await docScanner.setParameters(Template.grayscale); if (widget.documentData.documentResults!.isNotEmpty) { await normalizeBuffer(widget.documentData.image!, widget.documentData.documentResults![0].points); } }, )), const Text('Gray', style: TextStyle(color: Colors.white)), Theme( data: Theme.of(context).copyWith( unselectedWidgetColor: Colors.white, // Color when unselected ), child: Radio( activeColor: colorOrange, value: 'color', groupValue: _pixelFormat, onChanged: (String? value) async { setState(() { _pixelFormat = value!; }); await docScanner.setParameters(Template.color); if (widget.documentData.documentResults!.isNotEmpty) { await normalizeBuffer(widget.documentData.image!, widget.documentData.documentResults![0].points); } }, )), const Text('Color', style: TextStyle(color: Colors.white)), ] ElevatedButton( onPressed: () async { String imageString = await convertImagetoPngBase64(normalizedUiImage!); final SharedPreferences prefs = await SharedPreferences.getInstance(); var results = prefs.getStringList('document_data'); List<String> imageList = <String>[]; imageList.add(imageString); if (results == null) { prefs.setStringList('document_data', imageList); } else { results.addAll(imageList); prefs.setStringList('document_data', results); } close(); }, style: ButtonStyle( backgroundColor: MaterialStateProperty.all(colorMainTheme)), child: Text('Save', style: TextStyle(color: colorOrange, fontSize: 22)), )
Известные проблемы на веб-платформе Flutter
- Ограничение размера локального веб-хранилища: исправленные изображения преобразуются в строки base64 и сохраняются с параметром shared_preferences. Когда общий размер изображений, которые вы пытаетесь сохранить, превышает ограничение размера локального веб-хранилища (обычно около 5 МБ), это может привести к таким проблемам, как сбой приложения или неожиданное поведение.
- Ошибка кодека изображения: метод
decodeImageFromPixels()
может работать некорректно по сравнению с другими платформами.
Веб-платформа:
Платформа Windows:
Исходный код
https://github.com/yushulx/flutter-document-scanner
Первоначально опубликовано на https://www.dynamsoft.com 16 июля 2023 г.