文档扫描应用大都是移动应用。结合手机摄像头可以实现文档拍摄,边缘检测,自动剪裁,透视矫正,滤镜等功能。但是桌面办公也少不了文档处理。这里分享下如何使用Dynamsoft Document Normalizer C++ SDK实现用于桌面文档处理的Flutter插件。
Flutter文档扫描插件下载
需要快速体验可以直接访问
https://pub.dev/packages/flutter_document_scan_sdk
生成配置Flutter桌面插件工程
因为C++ SDK只提供了Windows和Linux的库,所以我们创建一个支持Windows和Linux的Flutter插件工程:
flutter create --org com.dynamsoft --template=plugin --platforms=windows,linux .
我们看到Windows和Linux插件代码都是通过CMake编译的。所以需要打开CMakeLists.txt
文件配置下编译环境。
Windows
link_directories("${PROJECT_SOURCE_DIR}/lib/")
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin "DynamsoftCorex64" "DynamsoftDocumentNormalizerx64")
set(flutter_document_scan_sdk_bundled_libraries
"${PROJECT_SOURCE_DIR}/bin/"
PARENT_SCOPE
)
Linux
link_directories("${PROJECT_SOURCE_DIR}/lib/")
target_link_libraries(${PLUGIN_NAME} PRIVATE flutter "DynamsoftCore" "DynamsoftDocumentNormalizer")
set(flutter_document_scan_sdk_bundled_libraries
PARENT_SCOPE
)
两个平台有一些差异。用于Windows和Linux的库名字有一些不同。Windows上因为链接的是*.lib
文件,最后还需要指定*.dll
文件的路径用于打包。
Flutter和C++接口实现
Flutter和底层代码是通过method channel通信的。
首先在flutter_document_scan_sdk_method_channel.dart
中定义上层接口:
class MethodChannelFlutterDocumentScanSdk
extends FlutterDocumentScanSdkPlatform {
final methodChannel = const MethodChannel('flutter_document_scan_sdk');
Future<String?> getPlatformVersion() async {
final version =
await methodChannel.invokeMethod<String>('getPlatformVersion');
return version;
}
Future<int?> init(String path, String key) async {
return await methodChannel
.invokeMethod<int>('init', {'path': path, 'key': key});
}
Future<int?> setParameters(String params) async {
return await methodChannel
.invokeMethod<int>('setParameters', {'params': params});
}
Future<String?> getParameters() async {
return await methodChannel.invokeMethod<String>('getParameters');
}
Future<List<DocumentResult>> detect(String file) async {
List? results = await methodChannel.invokeListMethod<dynamic>(
'detect',
{'file': file},
);
return _resultWrapper(results);
}
List<DocumentResult> _resultWrapper(List<dynamic>? results) {
List<DocumentResult> output = [];
if (results != null) {
for (var result in results) {
int confidence = result['confidence'];
List<Offset> offsets = [];
int x1 = result['x1'];
int y1 = result['y1'];
int x2 = result['x2'];
int y2 = result['y2'];
int x3 = result['x3'];
int y3 = result['y3'];
int x4 = result['x4'];
int y4 = result['y4'];
offsets.add(Offset(x1.toDouble(), y1.toDouble()));
offsets.add(Offset(x2.toDouble(), y2.toDouble()));
offsets.add(Offset(x3.toDouble(), y3.toDouble()));
offsets.add(Offset(x4.toDouble(), y4.toDouble()));
DocumentResult documentResult = DocumentResult(confidence, offsets, []);
output.add(documentResult);
}
}
return output;
}
Future<NormalizedImage?> normalize(String file, dynamic points) async {
Offset offset = points[0];
int x1 = offset.dx.toInt();
int y1 = offset.dy.toInt();
offset = points[1];
int x2 = offset.dx.toInt();
int y2 = offset.dy.toInt();
offset = points[2];
int x3 = offset.dx.toInt();
int y3 = offset.dy.toInt();
offset = points[3];
int x4 = offset.dx.toInt();
int y4 = offset.dy.toInt();
Map? result = await methodChannel.invokeMapMethod<String, dynamic>(
'normalize',
{
'file': file,
'x1': x1,
'y1': y1,
'x2': x2,
'y2': y2,
'x3': x3,
'y3': y3,
'x4': x4,
'y4': y4
},
);
if (result != null) {
return NormalizedImage(
result['data'],
result['width'],
result['height'],
);
}
return null;
}
Future<int?> save(String filename) async {
return await methodChannel
.invokeMethod<int>('save', {'filename': filename});
}
}
然后在C++中解析接口名和参数名。Windows上C++的入口函数是FlutterDocumentScanSdkPlugin::HandleMethodCall
,而Linux上是flutter_document_scan_sdk_plugin_handle_method_call
。
Windows
#include "flutter_document_scan_sdk_plugin.h"
#include <windows.h>
#include <VersionHelpers.h>
#include <flutter/method_channel.h>
#include <flutter/plugin_registrar_windows.h>
#include <flutter/standard_method_codec.h>
#include <memory>
#include <sstream>
void FlutterDocumentScanSdkPlugin::HandleMethodCall(
const flutter::MethodCall<flutter::EncodableValue> &method_call,
std::unique_ptr<flutter::MethodResult<flutter::EncodableValue>> result){}
Linux
#include "include/flutter_document_scan_sdk/flutter_document_scan_sdk_plugin.h"
#include <flutter_linux/flutter_linux.h>
#include <gtk/gtk.h>
#include <sys/utsname.h>
#include <cstring>
static void flutter_document_scan_sdk_plugin_handle_method_call(
FlutterDocumentScanSdkPlugin *self,
FlMethodCall *method_call){}
因为Windows和Linux的头文件和数据结构不同,所以对应的解析代码也有区别。
Windows
const auto *arguments = std::get_if<EncodableMap>(method_call.arguments());
if (method_call.method_name().compare("init") == 0)
{
std::string license;
int ret = 0;
if (arguments)
{
auto license_it = arguments->find(EncodableValue("key"));
if (license_it != arguments->end())
{
license = std::get<std::string>(license_it->second);
}
ret = DocumentManager::SetLicense(license.c_str());
}
result->Success(EncodableValue(ret));
}
...
Linux
if (strcmp(method, "init") == 0)
{
if (fl_value_get_type(args) != FL_VALUE_TYPE_MAP)
{
return;
}
FlValue *value = fl_value_lookup_string(args, "key");
if (value == nullptr)
{
return;
}
const char *license = fl_value_get_string(value);
int ret = DocumentManager::SetLicense(license);
g_autoptr(FlValue) result = fl_value_new_int(ret);
response = FL_METHOD_RESPONSE(fl_method_success_response_new(result));
}
...
接下来创建一个document_manager.h
文件来实现所有的底层接口:
-
导入Dynamsoft Document Normalizer的头文件:
#include "DynamsoftCore.h" #include "DynamsoftDocumentNormalizer.h" using namespace std; using namespace dynamsoft::ddn; using namespace dynamsoft::core;
-
创建和销毁Dynamsoft Document Normalizer:
class DocumentManager { public: ~DocumentManager() { if (normalizer != NULL) { DDN_DestroyInstance(normalizer); normalizer = NULL; } FreeImage(); }; const char *GetVersion() { return DDN_GetVersion(); } void Init() { normalizer = DDN_CreateInstance(); imageResult = NULL; } private: void *normalizer; NormalizedImageResult *imageResult; void FreeImage() { if (imageResult != NULL) { DDN_FreeNormalizedImageResult(&imageResult); imageResult = NULL; } } };
-
设置license:
static int SetLicense(const char *license) { char errorMsgBuffer[512]; // Click https://www.dynamsoft.com/customer/license/trialLicense/?product=ddn to get a trial license. int ret = DC_InitLicense(license, errorMsgBuffer, 512); if (ret != DM_OK) { cout << errorMsgBuffer << endl; } return ret; }
-
文档边缘检测:
WindowsEncodableList Detect(const char *filename) { EncodableList out; if (normalizer == NULL) return out; DetectedQuadResultArray *pResults = NULL; int ret = DDN_DetectQuadFromFile(normalizer, filename, "", &pResults); if (ret) { printf("Detection error: %s\n", DC_GetErrorString(ret)); } if (pResults) { int count = pResults->resultsCount; for (int i = 0; i < count; i++) { EncodableMap map; DetectedQuadResult *quadResult = pResults->detectedQuadResults[i]; int confidence = quadResult->confidenceAsDocumentBoundary; DM_Point *points = quadResult->location->points; int x1 = points[0].coordinate[0]; int y1 = points[0].coordinate[1]; int x2 = points[1].coordinate[0]; int y2 = points[1].coordinate[1]; int x3 = points[2].coordinate[0]; int y3 = points[2].coordinate[1]; int x4 = points[3].coordinate[0]; int y4 = points[3].coordinate[1]; map[EncodableValue("confidence")] = EncodableValue(confidence); map[EncodableValue("x1")] = EncodableValue(x1); map[EncodableValue("y1")] = EncodableValue(y1); map[EncodableValue("x2")] = EncodableValue(x2); map[EncodableValue("y2")] = EncodableValue(y2); map[EncodableValue("x3")] = EncodableValue(x3); map[EncodableValue("y3")] = EncodableValue(y3); map[EncodableValue("x4")] = EncodableValue(x4); map[EncodableValue("y4")] = EncodableValue(y4); out.push_back(map); } } if (pResults != NULL) DDN_FreeDetectedQuadResultArray(&pResults); return out; }
Linux
FlValue* Detect(const char *filename) { FlValue* out = fl_value_new_list(); if (normalizer == NULL) return out; DetectedQuadResultArray *pResults = NULL; int ret = DDN_DetectQuadFromFile(normalizer, filename, "", &pResults); if (ret) { printf("Detection error: %s\n", DC_GetErrorString(ret)); } if (pResults) { int count = pResults->resultsCount; for (int i = 0; i < count; i++) { FlValue* result = fl_value_new_map (); DetectedQuadResult *quadResult = pResults->detectedQuadResults[i]; int confidence = quadResult->confidenceAsDocumentBoundary; DM_Point *points = quadResult->location->points; int x1 = points[0].coordinate[0]; int y1 = points[0].coordinate[1]; int x2 = points[1].coordinate[0]; int y2 = points[1].coordinate[1]; int x3 = points[2].coordinate[0]; int y3 = points[2].coordinate[1]; int x4 = points[3].coordinate[0]; int y4 = points[3].coordinate[1]; fl_value_set_string_take (result, "confidence", fl_value_new_int(confidence)); fl_value_set_string_take (result, "x1", fl_value_new_int(x1)); fl_value_set_string_take (result, "y1", fl_value_new_int(y1)); fl_value_set_string_take (result, "x2", fl_value_new_int(x2)); fl_value_set_string_take (result, "y2", fl_value_new_int(y2)); fl_value_set_string_take (result, "x3", fl_value_new_int(x3)); fl_value_set_string_take (result, "y3", fl_value_new_int(y3)); fl_value_set_string_take (result, "x4", fl_value_new_int(x4)); fl_value_set_string_take (result, "y4", fl_value_new_int(y4)); fl_value_append_take (out, result); } } if (pResults != NULL) DDN_FreeDetectedQuadResultArray(&pResults); return out; }
-
文档剪裁,实现标准化效果:
Quadrilateral quad; quad.points[0].coordinate[0] = x1; quad.points[0].coordinate[1] = y1; quad.points[1].coordinate[0] = x2; quad.points[1].coordinate[1] = y2; quad.points[2].coordinate[0] = x3; quad.points[2].coordinate[1] = y3; quad.points[3].coordinate[0] = x4; quad.points[3].coordinate[1] = y4; int errorCode = DDN_NormalizeFile(normalizer, filename, "", &quad, &imageResult); if (errorCode != DM_OK) printf("%s\r\n", DC_GetErrorString(errorCode)); if (imageResult) { ImageData *imageData = imageResult->image; int width = imageData->width; int height = imageData->height; int stride = imageData->stride; int format = (int)imageData->format; unsigned char* data = imageData->bytes; int orientation = imageData->orientation; int length = imageData->bytesLength; }
如果得到的数据是黑白图,1字节保存了8个像素值。为了显示,需要把每个bit的值转成0或255:
unsigned char *grayscale = new unsigned char[width * height]; binary2grayscale(data, grayscale, width, height, stride, length); void binary2grayscale(unsigned char* data, unsigned char* output, int width, int height, int stride, int length) { int index = 0; int skip = stride * 8 - width; int shift = 0; int n = 1; for (int i = 0; i < length; ++i) { unsigned char b = data[i]; int byteCount = 7; while (byteCount >= 0) { int tmp = (b & (1 << byteCount)) >> byteCount; if (shift < stride * 8 * n - skip) { if (tmp == 1) output[index] = 255; else output[index] = 0; index += 1; } byteCount -= 1; shift += 1; } if (shift == stride * 8 * n) { n += 1; } } }
我们调用Flutter的
decodeImageFromPixels()
接口来创建一个显示用的图像,这个接口只支持rgba8888
和bgra8888
。因此返回的数据要加上alpha通道。unsigned char *rgba = new unsigned char[width * height * 4]; memset(rgba, 0, width * height * 4); if (format == IPF_RGB_888) { int dataIndex = 0; for (int i = 0; i < height; i++) { for (int j = 0; j < width; j++) { int index = i * width + j; rgba[index * 4] = data[dataIndex + 2]; // red rgba[index * 4 + 1] = data[dataIndex + 1]; // green rgba[index * 4 + 2] = data[dataIndex]; // blue rgba[index * 4 + 3] = 255; // alpha dataIndex += 3; } } } else if (format == IPF_GRAYSCALED) { int dataIndex = 0; for (int i = 0; i < height; i++) { for (int j = 0; j < width; j++) { int index = i * width + j; rgba[index * 4] = data[dataIndex]; rgba[index * 4 + 1] = data[dataIndex]; rgba[index * 4 + 2] = data[dataIndex]; rgba[index * 4 + 3] = 255; dataIndex += 1; } } } else if (format == IPF_BINARY) { unsigned char *grayscale = new unsigned char[width * height]; binary2grayscale(data, grayscale, width, height, stride, length); int dataIndex = 0; for (int i = 0; i < height; i++) { for (int j = 0; j < width; j++) { int index = i * width + j; rgba[index * 4] = grayscale[dataIndex]; rgba[index * 4 + 1] = grayscale[dataIndex]; rgba[index * 4 + 2] = grayscale[dataIndex]; rgba[index * 4 + 3] = 255; dataIndex += 1; } } free(grayscale); }
Flutter桌面文档扫描应用
流程:
- 读取文件。
- 自动边缘检测。
- 根据坐标做图片剪裁以及透视矫正。
- 保存文档。
你需要自己申请一个30天试用密钥,也可以使用我提供的一天可用密钥。
代码:
import 'dart:ui';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_document_scan_sdk/document_result.dart';
import 'package:flutter_document_scan_sdk/flutter_document_scan_sdk.dart';
import 'package:flutter_document_scan_sdk/template.dart';
import 'package:flutter_document_scan_sdk/normalized_image.dart';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart' show kIsWeb;
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
State<MyApp> createState() => _MyAppState();
}
class ImagePainter extends CustomPainter {
ImagePainter(this.image, this.results);
final ui.Image image;
final List<DocumentResult> results;
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.red
..strokeWidth = 5
..style = PaintingStyle.stroke;
canvas.drawImage(image, Offset.zero, paint);
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);
}
}
bool shouldRepaint(ImagePainter oldDelegate) =>
image != oldDelegate.image || results != oldDelegate.results;
}
class _MyAppState extends State<MyApp> {
String _platformVersion = 'Unknown';
final _flutterDocumentScanSdkPlugin = FlutterDocumentScanSdk();
String file = '';
ui.Image? image;
ui.Image? normalizedUiImage;
NormalizedImage? normalizedImage;
List<DocumentResult>? detectionResults = [];
String _pixelFormat = 'grayscale';
void initState() {
super.initState();
initPlatformState();
}
Future<ui.Image> loadImage(XFile file) async {
final data = await file.readAsBytes();
return await decodeImageFromList(data);
}
// Platform messages are asynchronous, so we initialize in an async method.
Future<void> initPlatformState() async {
String platformVersion;
// Platform messages may fail, so we use a try/catch PlatformException.
// We also handle the message potentially returning null.
try {
platformVersion =
await _flutterDocumentScanSdkPlugin.getPlatformVersion() ??
'Unknown platform version';
} on PlatformException {
platformVersion = 'Failed to get platform version.';
}
int? ret = await _flutterDocumentScanSdkPlugin.init(
"https://cdn.jsdelivr.net/npm/dynamsoft-document-normalizer@1.0.11/dist/",
"DLS2eyJoYW5kc2hha2VDb2RlIjoiMjAwMDAxLTE2NDk4Mjk3OTI2MzUiLCJvcmdhbml6YXRpb25JRCI6IjIwMDAwMSIsInNlc3Npb25QYXNzd29yZCI6IndTcGR6Vm05WDJrcEQ5YUoifQ==");
String? params = await _flutterDocumentScanSdkPlugin.getParameters();
print(params);
ret = await _flutterDocumentScanSdkPlugin.setParameters(Template.grayscale);
if (ret != 0) {
print("setParameters failed");
}
// If the widget was removed from the tree while the asynchronous platform
// message was in flight, we want to discard the reply rather than calling
// setState to update our non-existent appearance.
if (!mounted) return;
setState(() {
_platformVersion = platformVersion;
});
}
Widget createCustomImage(ui.Image image, List<DocumentResult> results) {
return SizedBox(
width: image.width.toDouble(),
height: image.height.toDouble(),
child: CustomPaint(
painter: ImagePainter(image, results),
),
);
}
Future<void> normalizeFile(String file, dynamic points) async {
normalizedImage = await _flutterDocumentScanSdkPlugin.normalize(
file, detectionResults![0].points);
if (normalizedImage != null) {
decodeImageFromPixels(normalizedImage!.data, normalizedImage!.width,
normalizedImage!.height, PixelFormat.rgba8888, (ui.Image img) {
normalizedUiImage = img;
setState(() {});
});
}
}
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Dynamsoft Document Normalizer'),
),
body: Stack(children: <Widget>[
Center(
child: GridView.count(
padding: const EdgeInsets.all(30.0),
crossAxisSpacing: 10.0,
mainAxisSpacing: 10.0,
crossAxisCount: 2,
children: <Widget>[
SingleChildScrollView(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: image == null
? Image.asset('images/default.png')
: createCustomImage(image!, detectionResults!),
)),
SingleChildScrollView(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: normalizedUiImage == null
? Image.asset('images/default.png')
: createCustomImage(normalizedUiImage!, []),
),
)
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Radio(
value: 'binary',
groupValue: _pixelFormat,
onChanged: (String? value) async {
setState(() {
_pixelFormat = value!;
});
await _flutterDocumentScanSdkPlugin
.setParameters(Template.binary);
if (detectionResults!.isNotEmpty) {
await normalizeFile(file, detectionResults![0].points);
}
},
),
const Text('Binary'),
Radio(
value: 'grayscale',
groupValue: _pixelFormat,
onChanged: (String? value) async {
setState(() {
_pixelFormat = value!;
});
await _flutterDocumentScanSdkPlugin
.setParameters(Template.grayscale);
if (detectionResults!.isNotEmpty) {
await normalizeFile(file, detectionResults![0].points);
}
},
),
const Text('Gray'),
Radio(
value: 'color',
groupValue: _pixelFormat,
onChanged: (String? value) async {
setState(() {
_pixelFormat = value!;
});
await _flutterDocumentScanSdkPlugin
.setParameters(Template.color);
if (detectionResults!.isNotEmpty) {
await normalizeFile(file, detectionResults![0].points);
}
},
),
const Text('Color'),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
SizedBox(
height: 100,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
MaterialButton(
textColor: Colors.white,
color: Colors.blue,
onPressed: () async {
const XTypeGroup typeGroup = XTypeGroup(
label: 'images',
extensions: <String>['jpg', 'png'],
);
final XFile? pickedFile = await openFile(
acceptedTypeGroups: <XTypeGroup>[typeGroup]);
if (pickedFile != null) {
image = await loadImage(pickedFile);
file = pickedFile.path;
detectionResults =
await _flutterDocumentScanSdkPlugin
.detect(file);
setState(() {});
if (detectionResults!.isEmpty) {
print("No document detected");
} else {
setState(() {});
print("Document detected");
await normalizeFile(
file, detectionResults![0].points);
}
}
},
child: const Text('Load Document')),
MaterialButton(
textColor: Colors.white,
color: Colors.blue,
onPressed: () async {
const String fileName = 'normalized.png';
if (kIsWeb) {
await _flutterDocumentScanSdkPlugin
.save(fileName);
String path = 'normalized.webp';
if (normalizedUiImage != null) {
const String mimeType = 'image/webp';
ByteData? data = await normalizedUiImage!
.toByteData(format: ui.ImageByteFormat.png);
if (data != null) {
final XFile imageFile = XFile.fromData(
data.buffer.asUint8List(),
mimeType: mimeType,
);
await imageFile.saveTo(path);
}
}
} else {
String? path =
await getSavePath(suggestedName: fileName);
path ??= fileName;
await _flutterDocumentScanSdkPlugin.save(path);
path = '${path.split('.png')[0]}.webp';
if (normalizedUiImage != null) {
const String mimeType = 'image/webp';
ByteData? data = await normalizedUiImage!
.toByteData(format: ui.ImageByteFormat.png);
if (data != null) {
final XFile imageFile = XFile.fromData(
data.buffer.asUint8List(),
mimeType: mimeType,
);
await imageFile.saveTo(path);
}
}
}
},
child: const Text("Save Document"))
]),
),
],
)
]),
),
);
}
}
程序可以在Windows, Linux和web上运行。
测试环境:统信UOS
flutter run -d linux
源码
https://github.com/yushulx/flutter_document_scan_sdk