如何在Flutter应用中使用 OpenCV和 CC++库进行图像流处理

本文将帮助你在 Android 和 iOS 中为 Flutter 应用程序集成 C/C++ 插件。

问题1: Flutter camera 插件没有为快速复杂的图像流处理提供完整的指南。

问题2: Flutter camera 插件处理图像流太慢。

问题3:图像处理需要OpenCV包

问题4:你当前的图像流处理实现正在阻塞 UI 并导致你的应用程序滞后和内存泄漏

问题5:缺乏实现接口和使用 Dart FFI 库有效集成 C/C++ 库的知识

本指南将通过使用 OpenCV C++ 构建适用于 Android 和 iOS 的示例应用程序来解决每个问题,以进行图像流处理。

先决条件

在开始之前,需要一个带有 OpenCV C++ 的 Flutter 插件及其适用于 Android 的静态库以及适用于 iOS 的 xcframework。本文不会介绍如何执行此操作,但你可以按照下方链接提供的指南或在我提供的 GitHub 存储库中运行脚本文件。

  • 指南:https://medium.com/@khaifunglim97/how-to-build-a-flutter-app-with-c-c-libraries-via-ffi-on-android-and-ios-including-opencv-1e2124e85019

  • 脚本文件:https://github.com/khaifunglim97/flutter_ffi_examples

对于 Android,确保 CMakeLists.txt 构建所需的库,并将 build.gradle 设置为以下值,防止OpenCV 产生错误。

externalNativeBuild {            
    cmake {                
        cppFlags "-frtti -fexceptions -std=c++17"
        abiFilters 'armeabi-v7a', 'arm64-v8a'            
    }        
}

对于 iOS,确保 podspec 下的 vendored_frameworks 包含 OpenCV 的 xcframework。

此外,本指南假设有一个工作状态小部件,类似于 flutter camera 插件中提供的简单示例,其中包含一个 CameraPreview 小部件及其所需的设置。

示例:https://pub.dev/packages/camera

假设

本指南仅承诺在手机后置摄像头上以纵向模式进行图像处理的工作模型,设置捕获分辨率为720p (1280x720)。但是,它可以作为读者集成其他相机、设备方向模式和捕获分辨率的指南。

注意:本指南仅在适用于 Android 的 Android NDK 版本 25.0.8775105 上进行测试。它可能无法使用其他 NDK 版本成功构建 C/C++ 文件。

建议的结构

b0d00f6e485bdde9db2e44791cbe5409.png

相机图像流处理的建议结构

移动设备上任何流处理的主要挑战是处理帧的速度足够快,以每秒至少处理 60 帧(每帧约 16 毫秒)。传统的图像处理过程几乎总是需要比每帧 16 毫秒多得多的时间。因此,必须使用后台线程来不阻塞 UI 并导致应用程序卡顿。

我们需要利用 Dart Isolates 来实现这一点。包含 CameraPreview 小部件的视图旁边将有一个 CameraProcessor 小部件,这样每个帧都将被传递给处理器进行处理。处理器将依次生成Isolate,以运行 C/C++ OpenCV 代码,并异步等待它们完成。

最后,从处理器中的Isolate接收到的任何结果都将传递给 CameraPainter 以更新 UI 状态并显示结果。尽管具有流畅的 UI,但这种方法的缺点是响应延迟,其中 UI 和Painter将显示不匹配,其中绘制的结果来自先前捕获的帧,具体取决于设备和处理速度

相机视图/相机预览

camera 插件页面中提供的示例足以构建最小的相机视图。我们只需要在小部件内添加帧捕获和相机处理器,如下所示:

// Start frame capture in controller under initState()
...
try {
    await controller?.initialize();
    controller?.startImageStream(_processCameraImage);
    setState(() {});
} catch (e) {
...

// Set what to do with frames inside stateful widget
Future _processCameraImage(CameraImage camImage) async {
   if (!mounted) return;    
   setState(() {
      cameraImage = camImage;
   });
}

// Pass each frame into CameraProcessor under build()
CameraPreview(
    camController,
    child: CameraProcessor(
        image: camImg,
    ),
)

CameraPainter

此处的指南为Painter 展示了一些不错的模板:https://github.com/bharat-biradar/Google-Ml-Kit-plugin/tree/master/packages/google_ml_kit/example/lib/vision_detector_views)

我们需要将要绘制到画布上的对象传递给这个小部件,并使用绘制函数来显示结果。需要注意的最重要的事情是图像的旋转,以及它如何影响 CameraPreview 和 CameraImage 的相对坐标。

CameraProcessor

CameraProcessor 将处理从_processCameraImage 下的CameraView 传递给它的每一帧。通过让 CameraProcessor 成为 CameraPreview 的覆盖,CameraProcessor 将负责通过CameraPainter 更新预览覆盖。

@override
Widget build(BuildContext context) {
    _processCameraImage(widget.image);
    return Scaffold(
        extendBodyBehindAppBar: true,
        appBar: AppBar(backgroundColor: Colors.transparent,),
        backgroundColor: Colors.transparent,
        body: Stack(
            children: <Widget>[
                if (customPaint != null) customPaint!
            ],
        ),
    );
}

注意:示例应用程序将 CameraPreview 设计为全屏小部件,因此同一页面下没有 Scaffolds 来显示 snackbar 错误。因此,一个 Scaffold 被添加到处理器中,以显示在 CameraPreview 之上的流处理期间引发的任何错误。

相机帧的所有处理都将在*_processCameraImage中,包括生成和等待isolates。因此,有必要确保一次运行一个_processCameraImage*,以避免产生大量繁重任务导致应用程序和设备崩溃。

举个例子:

bool isBusy = false;

Future _processCameraImage(CameraImage camImage) async {
    // Ensure a single process is ran at a time   
    if (isBusy) return;
    isBusy = true;
    final resConvert = await spawnAndConvertImage(camImage);
    
    Uint8List? rgbaBytes = resConvert[keyConvert];
    if (haarCascadesLoaded && rgbaBytes != null) {
        // Portrait capture of Android cam images are in landscape
        // while iOS cam images are in portrait
        int height = Platform.isAndroid 
                     ? camImage.width 
                     : camImage.height;
        int width = Platform.isAndroid 
                    ? camImage.height 
                    : camImage.width;
        final resDetect = 
            await spawnAndDetect(rgbaBytes, height, width);
        Faces? detected = resDetect[keyDetect];
        
        if (detected != null && detected.count > 0) {
            if (!mounted) return;
            setState(() {
                customPaint = CustomPaint(
                    size: Size.infinite, // Full screen preview
                    painter: CameraPainter(
                        detected,
                        Size(width.toDouble(), height.toDouble())
                    )
                );
           });
       } else {
           if (!mounted) return;
           setState(() {
               // To remove any painted elements on overlay
               customPaint = null;
           });
       }
    isBusy = false;
}

Isolates

如 CameraProcessor 所示,spawnAndConvertImagespawnAndDetect都是生成 Isolates 的函数,以便后台线程处理它们。这是必要的,这样缓慢的进程不会阻塞 UI 并允许应用程序继续运行。

示例指南:https://github.com/dart-lang/samples/blob/master/isolates/bin/send_and_receive.dart

只要你知道如何在 OpenCV Mat 中读取,OpenCV 中的处理可以支持任何图像格式。Flutter 相机捕获图像并传递诸如 Android 的 yuv420 和 iOS 的 bgra888 之类的 CameraImage。

将图像字节数据的任何传递标准化为 OpenCV C++ 是一个很好的做法,我发现 BGRA 是 OpenCV 读取输入以及使用图像包(https://pub.dev/packages/image)转换为 Flutter Image 的最简单格式。

为什么我们使用isolate和OpenCV将CameraImage转换为BGRA字节数据?

因为在 Flutter 中将 CameraImage 转换为 Flutter Image 与 C++ 转换相比非常慢,这反过来会导致 UI 卡顿且无响应。这个问题相信大部分使用前文提到的 camera 插件的用户都会遇到。

static Future<Map<String, dynamic>> spawnAndConvertImage(
    CameraImage image) async {    
    final p = ReceivePort();    
    await Isolate.spawn(_convertCamImageToBgra, [      
        p.sendPort,      
        image    
    ]);    
    return (await p.first) as Map<String, dynamic>;  
}   

static void _convertCamImageToBgra(List<dynamic> args) async { 
    SendPort responsePort = args[0];    
    CameraImage image = args[1];    
    Uint8List? outBgra;    
    
    try {      
        if (image.format.group == ImageFormatGroup.yuv420) { 
            outBgra = FFIBindings.convertAndroidCamImage2Bgra( 
                    image.planes[0].bytes,
                    image.planes[1].bytes,
                    image.planes[2].bytes,
                    image.planes[0].bytesPerRow,
                    image.planes[0].bytesPerPixel!,
                    image.planes[1].bytesPerRow,
                    image.planes[1].bytesPerPixel!,
                    image.width,
                    image.height);      
       } else if (image.format.group == ImageFormatGroup.bgra8888) {
           outBgra = image.planes[0].bytes;      
       }
    } catch (e) {
    // Catch errors
    }
    
    Map result = <String, dynamic>{};
    result[keyConvert] = outBgra;
    Isolate.exit(responsePort, result);  
}

官方 isolates 模板:https://github.com/dart-lang/samples/blob/master/isolates/bin/send_and_receive.dart

由于 iOS 已经返回 BGRA 格式的图像字节,我们不需要转换图像。

FFI 绑定和 C/C++ 代码

FFI 绑定充当 C/C++ 代码和 Dart 代码之间的接口。这是应该正确处理动态内存的地方,这样来自 Dart 的调用者就不需要手动管理内存。如果你使用现成的 C/C++ 文件,ffigen 包可以帮助你自动生成此类绑定,但该包通常会返回带有指针的绑定,其中调用者仍需要内存分配。

ffigen 包:https://pub.dev/packages/ffigen

因此,鼓励在 ffigen 生成的绑定文件之后编写自己的绑定作为一个很好的参考。

这里是 Dart 绑定的工作示例和FFIBindings.convertAndroidCamImage2Bgra的 C 代码。

  • https://github.com/khaifunglim97/flutter-image-processing/blob/master/lib/camera/camera_bindings.dart

  • https://github.com/khaifunglim97/flutter-image-processing/blob/master/ios/Classes/converter.c

使用 OpenCV 加快处理速度的建议

注 1:由于我们正在传递 BGRA,因此需要以 CV_8UC4 格式读取字节数据(将图像数据从 CameraPreview 导入 C++ 代码的最快且一致的方式)到 OpenCV Mat 对象中。

如果要将 BGRA 转换为 BGR,则格式将为 CV_8UC3,而传递灰度(直接从 Android CameraImage 的平面 0 字节/在 iOS 中将 BGRA 转换为灰色)将为 CV_8UC1。

注 2:大多数情况下,整个相机预览并不构成感兴趣的区域。因此,建议尽可能减小处理图像的大小,以加快每个处理循环。可以通过根据相机控制器中设置的 ResolutionPreset 的固定大小裁剪图像或将图像调整为较低分辨率来完成。

注 3:由于初始化和用户对目标聚焦的调整,相机图像流通常会出现模糊期。因此,可以使用诸如快速傅里叶变换 (FFT) 或拉普拉斯算子之类的模糊检测技术,来确定任何进一步的处理。

每当需要进一步处理时,可以将得分最高的帧/图像保存在有状态小部件 (CameraProcessor) 中。请注意,得分最高的帧将需要在一段时间后重置,以避免处理旧帧而不是最近的帧。

注 4:任何需要加载模型文件的处理都应该从initState调用C++的初始化和释放调用,并从处理器的有状态小部件释放。这是为了确保模型只加载一次并正确处理以避免内存泄漏。

要使用此类模型,你需要将文件添加到资源中,从 rootBundle 将它们加载到内存中并编写一个新文件,以便你可以获得要传递给需要模型路径的 OpenCV 函数的文件路径。

这里有一个例子:https://github.com/khaifunglim97/flutter-image-processing/blob/master/lib/face_detector/face_detector_bindings.dart

但是,如果这对你来说仍然太慢,更好的方法是将模型文件的内容转换为可以包含并直接从 C++ 代码调用的变量。

注5:图像的输入/返回字节数据将是Uint32而不是Uint8。因此,需要分配 sizeOf() * width * height 的内存。但是,我们可以使用现有的 ptr.ref.asTypedList(width * height).buffer.asUint8List() 函数将 Uint32List 转换回 Uint8List 以供外部使用。

FFI 绑定指南

  1. 将查找代码添加到将与插件一起构建的共享库中。例子:

final DynamicLibrary nativeLib = Platform.isAndroid
    ? DynamicLibrary.open('libnative.so')
    : DynamicLibrary.process();
  1. 在 Uint8List 上准备一个通用扩展来创建一个指针:

// Credits to Tims !
extension Uint8ListBlobConversion on Uint8List {
    Pointer<Uint8> allocatePointer() {
        final blob = calloc<Uint8>(length);
        final blobBytes = blob.asTypedList(length);
        blobBytes.setAll(0, this);
        return blob;
    }
}
  1. 通过特征而不是同一文件中的所有绑定将绑定添加到插件类中。例子:

Future<String?> getPlatformVersion() {
    ...
}
static final cameraBindings = CameraBindings(nativeLib);
static final FaceDetectorBindings = FaceDetectorBindings(nativeLib);
...
  1. 在每个绑定类中,公共函数应该有参数和 Dart 类型的返回对象,而不是 FFI 指针。这是为了确保指针的所有内存管理都包含在绑定中。

Uint8List convertAndroidCamImage2Bgra(
    Uint8List plane0Bytes,
    Uint8List plane1Bytes,
    Uint8List plane2Bytes,
    int yBytesPerRow,
    int yBytesPerPixel,
    int uvBytesPerRow,
    int uvBytesPerPixel,
    int width,
    int height
) {
    ...
    return Uint8List.fromList(...); // Copy the value out
}

注意:从指针返回值总是需要将指针的 ref 值复制到 Dart 对象中,这样当指针被释放时,值仍然存在。

  1. 在动态内存分配(malloc / calloc)之后始终将你的代码放入 try 块中,以便在任何失败的情况下,将执行malloc.free() ,释放所有先前分配的内存的 finally 块,以避免任何内存泄漏。 例子:

Pointer<ExampleStruct> ptrStruct = malloc<ExampleStruct>();
Pointer<Uint8> ptrData = uInt8ListData.allocatePointer();
Pointer<Utf8> ptrStr = "test".toNativeUtf8();

try {
    ... // Perform call to C/C++ code
} finally {
    malloc.free(ptrStruct);
    malloc.free(ptrData);
    malloc.free(ptrStr);
}
  1. 对于结构体,它需要遵循与 Dart 中 C/C++ 结构完全相同的类型。

    此外,建议创建一个扩展 FFI Struct 的类,并在 Dart 代码中使用相同的类。例子:

// C++ struct
typedef struct Coordinate {
    int x;
    int y;
    unsigned char* data;
} Coordinate;

// FFI Bindings struct
class Coordinate extends ffi.Struct {
    @Int32()
    external int x;
    @Int32()
    external int y;
    external Pointer<Uint8> data;
}

// Dart struct
class DartCoordinate {
    late int x;
    late int y;
    late Uint8List data;
    DartCoordinate.fromCoordinate(Coordinate coord) {
        x = coord.x;
        y = coord.y;
        data = Uint8List.fromList(coord.data.asTypedList().buffer)
    }
}

// To allocate a struct
Pointer<Coordinate> ptr = malloc<Coordinate>();
Pointer<Uint8List> ptrData = data.allocatePointer();
ptr.ref.x = x;
ptr.ref.y = y;
ptr.ref.data = ptrData;
  1. 处理列表(C/C++ 数组和双指针)。例子:

// C++ struct
typedef struct Point {
    Coordinate* coordinates;
    int count // there should always be a count for list size
} Point;

// FFI Bindings struct
class Point extends ffi.Struct {
    external Pointer<Point> coordinates;
    @Int32()
    external int count;
}

// Dart struct
class DartPoint {
    late List<DartCoordinate> coordinates;
    DartPoint.fromPoint(Point point) {
        final dartCoords = <DartCoordinate>[];
        for (int i = 0; i < point.count; i++) {
            final coord = point.coordinates[i];
            dartCoords.add(DartCoordinate.fromCoordinate(coord);
        }
        coordinates = dartCoords;
    }
}

// To allocate (should be in C/C++ function)
Point* point = (Point*) malloc(pointCount * sizeof(*point));
point.count = pointCount;
for (int i=0; i < pointCount; i++) {
    point[i] = (Coordinate) {x, y};
    point[i].data = data;
}

// To free (should be in Dart)
// ensure each child pointer is freed
for (int=0; i < pointPtr.count; i++) {
    malloc.free(pointPtr.coordinates[i]);
}
malloc.free(pointPtr); // then free the struct

C/C++ 代码指南

  1. 确保所有静态库的导入都用 <> 而不是“”括起来。只有 /ios/Classes/ 目录中的 C/C++ 文件才能用“”括起来以表示#include。

  2. 当某些静态库使用不同的头文件构建或 Android 和 iOS 平台的要求不同时,对 Android 使用 #ifdef ANDROID ,对 iOS 使用 #else。

  3. Android 的日志记录将与 iOS 不同。

  4. .cpp 文件的头文件 (.h) 必须具有外部“C”及其保护。

// OpenCV tutorials often #include "opencv2/imgproc.hpp"
#include <opencv2/imgproc.hpp>
#include "test.h"

// Include different files for Android and iOS
#ifdef __ANDROID__
#include <android/log.h>
#include <someAndroidHeader.h>
#else
#include <someiOSHeader.h>
#endif

// Logging for Android and iOS
#ifdef __ANDROID__
    __android_log_print(ANDROID_LOG_DEBUG, "title", "%d", integer);
#else
    std::cout << "title: " << integer << "\n";
#endif

// Cpp header files must haves
#ifdef __cplusplus
extern "C" {
#endif
    int GetCoordinate(Coordinate* outCoordinate);
#ifdef __cplusplus
}
#endif

5 — C/C++ 函数应该返回整数(状态码)并使用指针来修改和返回值。这是为了确保错误代码可以从 C/C++ 函数传回以进行日志记录,并将内存管理仅推送到 FFI 绑定而不是内部 C/C++ 代码。

// in C file
int GetCoordinate(Coordinate* outCoordinate) {
    outCoordinate->x = getX();
    outCoordinate->y = getY();
    outCoordinate->data = getData();
    return 0;
}

6 — 如果需要从 C++ 函数返回 std::string,则该函数的返回类型应为 const char *。

// in C++ file
const char* GetString() {
    std::string strTest = "test"
    return strTest.c_str();
}

// in Dart
getString().toDartString();

GitHub 仓库

https://github.com/khaifunglim97/flutter-image-processing

这个 repo 带有使用 OpenCV 的简单面部和眼睛检测,旨在为读者提供一个基本的模板和指南来构建他们的图像处理应用程序。

☆ END ☆

如果看到这里,说明你喜欢这篇文章,请转发、点赞。微信搜索「uncle_pn」,欢迎添加小编微信「 woshicver」,每日朋友圈更新一篇高质量博文。

扫描二维码添加小编↓

80fc4c09f136b13ccf5fb94ea2fcd6f9.jpeg

猜你喜欢

转载自blog.csdn.net/woshicver/article/details/128597511