Приложение для сканирования документов — это программное приложение, которое использует камеру вашего устройства для захвата изображений физических документов и преобразования их в цифровые форматы. Как правило, эти приложения могут сканировать документы, фотографии, квитанции, визитные карточки и многое другое.

Приложения для сканирования документов полезны в различных отраслях и сценариях, включая, помимо прочего, образование, бизнес, финансы и здравоохранение. Возможно, вы знакомы с некоторыми известными приложениями для сканирования документов, такими как 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.

Начало работы с приложением

  1. Создайте новый проект 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 г.