Implementierung eines Flutter-Plugins mit nativer OpenCV-Unterstützung über dart::ffi - Teil 2/2

In diesem Artikel werden wir beenden, was wir in Teil 1 dieses Artikels begonnen haben. Bislang haben wir eine leere Flutter-App erstellt und sie mit vorkompilierten nativen Binärdateien verknüpft, um sie mit der dart::ffi -Fremdfunktionsschnittstelle zu verwenden.

app store

Unser ultimatives Ziel ist es, unsere neuen FFI-Bindings mit OpenCV zu verwenden, um Formen in einem Kamerastream zu erkennen und diese in einem Overlay hervorzuheben. Um dies zu ermöglichen, werden wir nun das flutter_camera plugin -Plugin hinzufügen und es mit zusätzlichen Funktionen für die Live-Erkennung ausstatten.

Los geht's!

Hinzufügen weiterer Flutter-Dependencies

Zuerst müssen wir die folgenden Dependencies in unserer  flutter_ffi_demo: camera, permission_handler, logging und ffi. Öffnen Sie die Datei pubspec.yaml (das Repository unserer Beispiel-App finden Sie hier) und fügen Sie diese Abhängigkeiten wie folgt hinzu:

environment:
  sdk: ">=2.14.0 <3.0.0"
  flutter: ">=1.20.0"

dependencies:
  flutter:
    sdk: flutter
  camera: 0.9.4+1 # plugin for camera support
  permission_handler: ^8.2.0 # to handle platform permissions
  logging: ^1.0.2 # for logging
  ffi: ^1.1.2 # dart ffi dependency itself

Nachdem Sie neue Abhängigkeiten hinzugefügt haben, stellen Sie sicher, dass Sie diese über Android Studio installieren oder sie im flutter pub get Terminal ausführen.

Implementierung der Camera-Preview

Als Nächstes wollen wir eine Camera-Preview in unserer Flutter - Demo App implementieren. Den kompletten Code dazu finden Sie in der Datei lib/camera_preview.dart. Um die folgenden Schritte nachzuvollziehen, können Sie die gleiche Datei in Ihrem Projekt erstellen.

CameraPreview ist ein Widget, das vom camera -Plugin bereitgestellt wird, welches wir im vorherigen Schritt als Dependency hinzugefügt haben. Es arbeitet gemeinsam mit einer Komponente namens CameraController

Lassen Sie uns mit dem Widget beginnen:

@override
Widget build(BuildContext context) {
 if (!_initialized) {
   return Container();
 }
 final camera = controller.value;

 [...]

 var combinedOverlay = Center(
   child: Stack(
     children: [debugOverlay, overlay ?? Container()],
   ),
 );
 return Center(
     child: CameraPreview(
   controller,
   child: combinedOverlay,
 ));
}

Seit Einführung der CameraController -Widget ermöglicht die Steuerung der meisten Kamera-Aspekte, wie z.B. die Auswahl einer geeigneten Kamera (vorne, hinten ...), das Zoomen oder das Aktivieren von Frame-Streaming. 

Umhüllung der Kamera-Vorschau mit der Handhabung des Seitenverhältnisses

Um mit der Kamera-Preview zu arbeiten, initialisieren wir den CameraController zuerst: 

@override
void initState() {
 super.initState();

 controller.initialize().then((_) {
   setState(() {
     _initialized = true;
   });
   if (!mounted) {
     return;
   }
   if (detectHandler != null) {
     controller.startImageStream((image) {
       if (!_isDetecting && this.mounted) {
         callFrameDetection(image, finder);
       }
     });
   }
 });
}
@override
void dispose() {
 controller.dispose();
 super.dispose();
}

Das Wichtigste dabei ist, dass wir ein Frame-Streaming mit controller.startImageStream starten und alle Frames ignorieren, während das aktuelle Frame verarbeitet wird. Wir führen eine Erkennung für eingehende Frames mit callFrameDetection(image, finder)durch. Da die Erkennung eines Bildes einige Zeit in Anspruch nehmen kann, werden andere eingehende Bilder während der Erkennung ignoriert.

Bevor wir die eigentliche Erkennung aufrufen, müssen wir den rect of interest (ROI) anhand der aktuellen Bildausrichtung und des Finders berechnen:

void callFrameDetection(CameraImage image, FinderConfig? finder) async {
 try {
   _isDetecting = true;
   Rect? roi; // rect of interest
   const rotation = 90;
   // calculate ROI based on image orientation/rotation and finder
   if (finder is AspectRatioFinderConfig) {
     roi = calculateRoiFromAspectRatio(image, finder, rotation);
   }
   if (finder is FixedSizeFinderConfig) {}
   // and run the actual detection
   await detectHandler?.detect(image, roi, rotation);
 } catch (e) {
   // todo: error handling
 } finally {
   _isDetecting = false;
 }
}

Hinweis: Es gibt keine Informationen über die Bildausrichtung, wenn sie aus dem Kamerabild-Stream stammt. Im Moment behandeln wir nur die vertikale Geräteausrichtung, indem wir die Bilder um 90 Grad drehen. 

In dem obigen Ausschnitt ist detectHandler eine abstrakte Klasse, die eine Erkennung implementiert. Anschließend gibt sie das Ergebnis an den Stream zurück, sodass alle Subscriber das Ergebnis abrufen können:

abstract class FrameHandler<T> {
 abstract StreamController<T> detectionResultStreamController;

 Future<void> detect(CameraImage image, Rect? roi, int rotation);
}

class OpenCvFramesHandler extends FrameHandler<ProcessingResult> {
 OpenCvShapeDetector frameProcessor;

 @override
 StreamController<ProcessingResult> detectionResultStreamController;

 OpenCvFramesHandler(
     this.frameProcessor, this.detectionResultStreamController);

 Future<void> detect(CameraImage image, Rect? roi, int rotation) async {
 print("frame aspect ratio ${image.width/image.height}");
 final ProcessingResult result = await frameProcessor.processFrame(image, rotation, roi);
 detectionResultStreamController.add(result);
}

}

Da wir unsere Kamera zum Bildschirm hinzufügen werden, müssen wir auch eine zweite Ebene über unserer aktuellen CameraView hinzufügen. Nennen wir diese neue Ebene LiveDetectionIn diesem Layer müssen wir den Kameracontroller initialisieren und auch die Kameraberechtigungen verwalten.

Handhabung von Berechtigungen

Für die Handhabung von Berechtigungen verwenden wir das permission_handler - Plugin. Dieses Plugin bietet Funktionen zum Anfordern und Bearbeiten von Berechtigungen auf Android und iOS. Damit dieses Plugin richtig funktioniert, lesen Sie bitte seine Readme-Datei und überprüfen Sie die Installationsdetails für die iOS-Plattform. 

Der gesamte Arbeitsablauf für Berechtigungen sieht wie folgt aus: Es gibt eine Widget-Eigenschaft namens permissionGranted , die beschreibt, ob die Kameraberechtigung erteilt ist oder nicht. Wenn ja, zeigen wir das Kamera-Widget an, wenn nicht, zeigen wir ein leeres Widget oder einen Platzhalter an. Bei der Initialisierung des Zustands wird die Berechtigung geprüft. Wenn sie noch nicht erteilt wurde, wird der Genehmigungsdialog der Plattform aufgerufen:

@override
void initState() {
 checkPermission();
 super.initState();
}

void checkPermission() async {
 final permissionResult = await [Permission.camera].request();
 setState(() {
   permissionGranted =
       permissionResult[Permission.camera]?.isGranted ?? false;
 });
}

Initialisierung des CameraControllers

Sobald wir unsere Berechtigungen haben, können wir mit der Initialisierung des CameraController beginnen. Das Widget FutureBuilder hilft uns, Widgets zu erstellen, die auf dem Future -Ansatz basieren, um die verfügbaren Kameras zu erhalten. 

Wenn die Kameras bereit sind, prüfen wir, ob der CameraController bereits initialisiert wurde. Wenn nicht, können wir cameraData verwenden, um das zu erledigen: 

@override
 Widget build(BuildContext context) {
   late Widget cameraPlaceholder;
   if (permissionGranted) {
     cameraPlaceholder = FutureBuilder(
       future: availableCameras(),
       builder: (BuildContext context,
           AsyncSnapshot<List<CameraDescription>> snapshot) {
         final data = snapshot.data;
         if (data != null) {
           final cameraData = data[0];
           if (cameraData != null) {
             var resolutionPreset = ResolutionPreset.max;
             if (Platform.isIOS) {
               resolutionPreset = ResolutionPreset.medium;
             }
             controller ??= CameraController(cameraData, resolutionPreset,
                 imageFormatGroup: ImageFormatGroup.yuv420);

             return ScanbotCameraWidget(
               const Key('Camera'),
               controller!,
               finderConfig: aspectRatioFinderConfig,
               detectHandler: handler,
               overlay: overlay,
             );
           } else {
             return Container();
           }
         } else {
           return Container();
         }
       },
     );
   } else {
     cameraPlaceholder = Container();
   }

   return cameraPlaceholder;
 }
}

@override
void dispose() {
 controller?.dispose();
 controller = null;
 super.dispose();
}

Stellen Sie sicher, dass Sie ImageFormatGroup.yuv420 verwenden, da dies das einzige Format ist, das für beide nativen Plattformen funktioniert.

Nun, da wir den grundlegenden Code für die Live-Erkennung haben, können wir mit dem Erkennungsablauf fortfahren. Dabei gibt es ein paar Haken, die wir beachten müssen.

Sobald wir das Bild im CameraImage -Format erhalten haben, müssen wir ein natives Objekt erstellen, das das Kamerabild im nativen Speicher darstellt. Das iOS-Bild wird als eine Ebene im YUV422 Format geliefert, während die Android-Bilder zwar ebenfalls als YUV422, aber in YUV422, but in drei Ebenen geliefert werden, die wir später zusammenführen müssen.

Aufgrund der unterschiedlichen Darstellung der Bilddaten in iOS und Android brauchen wir einige generische Strukturen:

class SdkImage extends Struct {
 external Pointer<SdkImagePlane> plane;
 @Int32()
 external int platform; // 0 ios, 1 android
 @Int32()
 external int width;
 @Int32()
 external int height;
 @Int32()
 external int rotation;
}

class SdkImagePlane extends Struct {
 external Pointer<Uint8> planeData;
 @Int32()
 external int length;
 @Int32()
 external int bytesPerRow;
 external Pointer<SdkImagePlane> nextPlane;
}

So beschreiben wir dart::ffi -Strukturen für Bilder, die Ebenen mit Byte-Daten enthalten. Der Aufbau ist ähnlich dem der CameraImage -Klasse aus dem flutter_camera -Plugin.

Scanbot SDK:
Unbegrenzte Scans zum Fixpreis

Ihre zuverlässige Datenerfassungs-Lösung für die Integration in Mobil- und Web-Apps.


Unterstützung aller gängigen Plattformen und Frameworks.

Vorbereitung der Bilder für die Erkennung

Future<ProcessingResult> processFrameAsync(_FrameData detect) async {
 try {
   final stopwatch = Stopwatch()..start();
   ffi.Pointer<SdkImage> image =
       detect.image.toSdkImagePointer(detect.rotation);
   final scanner = ffi.Pointer.fromAddress(detect.scanner);
   ffi.Pointer<_ShapeNative> result;
   var roi = detect.roi;
   if (roi != null) {
     result = _processFrameWithRoi(scanner, image, roi.left.toInt(),
         roi.top.toInt(), roi.right.toInt(), roi.bottom.toInt());
   } else {
     result = _processFrame(scanner, image);
   }
   print('recognise() detect in ${stopwatch.elapsedMilliseconds}');
   stopwatch.stop();
   final shapes = _mapNativeItems(result);
   image.release();
   print("shapes total found ${shapes.length}");
   return ProcessingResult(shapes);
 } catch (e) {
   print(e);
 }

 return ProcessingResult([]);
}

Dies ist die Hauptmethode des Shape Detector. Sie bereitet ein Bild vor und ruft die Erkennung auf der nativen Ebene auf. Werfen wir nun einen Blick auf mage.toSdkImagePointer(detect.rotation). Dies ist eine Erweiterungsmethode für CameraImage ie ein solches in eine Datenstruktur umwandelt, welche wir in unserem C++-Code verwenden können. Alle Erweiterungsmethoden, die wir verwenden, finden Sie hier.

extension CameraImageExtention on CameraImage {
 bool isEmpty() => planes.any((element) => element.bytes.isEmpty);

 Pointer<SdkImage> toSdkImagePointer(int rotation) {
   var pointer = createImageFrame();
   final image = pointer.ref;
   image.width = width;
   image.height = height;
   image.rotation = rotation;

   if (Platform.isIOS) {
     image.platform = 0;
     final plane = planes[0];
     final bytesPerRow = planes[0].bytesPerRow;
     final pLength = plane.bytes.length;
     final p = malloc.allocate<Uint8>(pLength);
     // Assign the planes data to the pointers of the image
     final pointerList0 = p.asTypedList(pLength);
     pointerList0.setRange(0, pLength, plane.bytes);
     final sdkPlanePointer = createImagePlane();
     final sdkPlane = sdkPlanePointer.ref;
     sdkPlane.bytesPerRow = bytesPerRow;
     sdkPlane.length = pLength;
     sdkPlane.planeData = p;
     sdkPlane.nextPlane = nullptr;
     image.plane = sdkPlanePointer;
   }

   if (Platform.isAndroid) {
     image.platform = 1;
     final plane0 = planes[0];
     final pLength0 = plane0.bytes.length;
     final plane1 = planes[1];
     final pLength1 = plane1.bytes.length;
     final plane2 = planes[2];
     final pLength2 = plane2.bytes.length;
     final bytesPerRow0 = planes[0].bytesPerRow;
     final bytesPerRow1 = planes[1].bytesPerRow;
     final bytesPerRow2 = planes[2].bytesPerRow;

     final p0 = malloc.allocate<Uint8>(pLength0);
     final p1 = malloc.allocate<Uint8>(pLength1);
     final p2 = malloc.allocate<Uint8>(pLength2);

     // Assign the planes data to the pointers of the image
     final pointerList0 = p0.asTypedList(pLength0);
     final pointerList1 = p1.asTypedList(pLength1);
     final pointerList2 = p2.asTypedList(pLength2);
     pointerList0.setRange(0, pLength0, plane0.bytes);
     pointerList1.setRange(0, pLength1, plane1.bytes);
     pointerList2.setRange(0, pLength2, plane2.bytes);

     //final allocate = malloc.allocate<SdkImagePlane>(0);
     final sdkPlanePointer0 = createImagePlane();
     final sdkPlanePointer1 = createImagePlane();
     final sdkPlanePointer2 = createImagePlane();
     final sdkPlane0 = sdkPlanePointer0.ref;
     final sdkPlane1 = sdkPlanePointer1.ref;
     final sdkPlane2 = sdkPlanePointer2.ref;

     sdkPlane2.bytesPerRow = bytesPerRow2;
     sdkPlane2.nextPlane = nullptr;
     sdkPlane2.length = pLength2;
     sdkPlane2.planeData = p2;
     sdkPlane1.nextPlane = sdkPlanePointer2;

     sdkPlane1.bytesPerRow = bytesPerRow1;
     sdkPlane1.length = pLength1;
     sdkPlane1.planeData = p1;
     sdkPlane0.nextPlane = sdkPlanePointer1;

     sdkPlane0.bytesPerRow = bytesPerRow0;
     sdkPlane0.length = pLength0;
     sdkPlane0.planeData = p0;
     image.plane = sdkPlanePointer0;
   }
   return pointer;
 }
}

Diese Methode beschreibt, wie man Objekte im nativen Speicher erstellt und mit den Byte-Daten unseres Bildes füllt. Hier sehen wir den Unterschied zwischen den Bildstrukturen von iOS und Android. iOS-Bilder haben nur eine Ebene, während Android-Bilder drei Ebenen haben, obwohl beide das YUV422 -Bildformat verwenden.

Wir verwenden native Methoden, um den Speicher für Strukturen zuzuweisen. Dann füllen wir diesen zugewiesenen Speicher mit einigen Daten. Im Folgenden beschreiben wir unsere dart::ffi-Schnittstellen für die Methoden zur Zuweisung von Strukturen im Speicher. Die Implementierung erfolgt in C++-Code als Teil dieser Datei.

createImagePlane();
createImageFrame();

final createImageFrame =
   sdkNative.lookupFunction<_CreateImageFrameNative, _CreateImageFrame>(
       'MathUtils_createImageFrame');

final createImagePlane =
   sdkNative.lookupFunction<_CreateImagePlaneNative, _CreateImagePlane>(
       'MathUtils_createPlane');

typedef _CreateImageFrameNative = ffi.Pointer<SdkImage> Function();
typedef _CreateImageFrame = ffi.Pointer<SdkImage> Function();

typedef _CreateImagePlaneNative = ffi.Pointer<SdkImagePlane> Function();
typedef _CreateImagePlane = ffi.Pointer<SdkImagePlane> Function();

MathUtils_createPlane and MathUtils_createImage sind native Methoden , die Strukturen im systemeigenen Speicher allozieren und Pointer auf sie zurückgeben.

#ifdef __cplusplus
extern "C" {
#endif

flutter::Plane *MathUtils_createPlane() {
   return (struct flutter::Plane *) malloc(sizeof(struct flutter::Plane));
}

flutter::ImageForDetect *MathUtils_createImageFrame() {
   return (struct flutter::ImageForDetect *) malloc(sizeof(struct flutter::ImageForDetect));
}

#ifdef __cplusplus
}
#endif

Nachdem wir die Pointer erhalten und alle Daten in Structs gefüllt haben, können wir die Erkennung mit den Methoden _processFrame und _processFrameWithRoi. Siehe dazu diesen dart::ffi Teil:

final _processFrame = sdkNative
   .lookupFunction<_ProcessFrameNative, _ProcessFrame>('processFrame');

typedef _ProcessFrameNative = ffi.Pointer<_ShapeNative> Function(
   ffi.Pointer<ffi.NativeType>, ffi.Pointer<SdkImage>);
typedef _ProcessFrame = ffi.Pointer<_ShapeNative> Function(
   ffi.Pointer<ffi.NativeType>, ffi.Pointer<SdkImage>);

final _processFrameWithRoi =
   sdkNative.lookupFunction<_ProcessFrameWithRoiNative, _ProcessFrameWithRoi>(
       'processFrameWithRoi');

typedef _ProcessFrameWithRoiNative = ffi.Pointer<_ShapeNative> Function(
 ffi.Pointer<ffi.NativeType>,
 ffi.Pointer<SdkImage>,
 ffi.Int32,
 ffi.Int32,
 ffi.Int32,
 ffi.Int32,
);
typedef _ProcessFrameWithRoi = ffi.Pointer<_ShapeNative> Function(
   ffi.Pointer<ffi.NativeType>, ffi.Pointer<SdkImage>, int, int, int, int);

Und ihre native Darstellung als vollständiger Code hier:

flutter::Shape *processFrame(ShapeDetector *scanner, flutter::ImageForDetect *image) {
   auto img = flutter::prepareMat(image);
   auto shapes = scanner->detectShapes(img);
   //we need to map result as a linked list of items to return multiple result
   flutter::Shape *first = mapShapesFFiResultStruct(shapes);
   return first;
}

flutter::Shape *processFrameWithRoi(ShapeDetector *scanner, flutter::ImageForDetect *image, int areaLeft,
                   int areaTop, int areaRight, int areaBottom) {
   auto areaWidth = areaRight - areaLeft;
   auto areaHeight = areaBottom - areaTop;
   auto img = flutter::prepareMat(image);
   if (areaLeft >= 0 && areaTop >= 0 && areaWidth > 0 && areaHeight > 0) {
       cv::Rect mrzRoi(areaLeft, areaTop, areaWidth, areaHeight);
       img = img(mrzRoi);
   }
   auto shapes = scanner->detectShapes(img);
   //we need to map result as a linked list of items to return multiple result
   flutter::Shape *first = mapShapesFFiResultStruct(shapes);
   return first;
}

Als Nächstes müssen wir eine cv::Mat -Instanz für OpenCV vorbereiten. Da wir unterschiedliche Bildformate haben, brauchen wir eine unterschiedliche Logik für iOS und Android. Werfen wir nun einen Blick in die Methode flutter::prepareMat in MatUtils.h:

  static cv::Mat prepareMat(flutter::ImageForDetect *image) {
       if (image->platform == 0) {
           auto *plane = image->plane;
           return flutter::prepareMatIos(plane->planeData,
                                         plane->bytesPerRow,
                                         image->width,
                                         image->height,
                                         image->orientation);
       }
       if (image->platform == 1) {
           auto *plane0 = image->plane;
           auto *plane1 = plane0->nextPlane;
           auto *plane2 = plane1->nextPlane;
           return flutter::prepareMatAndroid(plane0->planeData,
                                             plane0->bytesPerRow,
                                             plane1->planeData,
                                             plane1->length,
                                             plane1->bytesPerRow,
                                             plane2->planeData,
                                             plane2->length,
                                             plane2->bytesPerRow,
                                             image->width,
                                             image->height,
                                             image->orientation);
       }
       throw "Can't parse image data due to the unknown platform";
   }

Die Konvertierung von iOS-Bildern ist ziemlich einfach, da das Bild in nur einer Ebene vorliegt:

static cv::Mat prepareMatIos(uint8_t *plane,
                            int bytesPerRow,
                            int width,
                            int height,
                            int orientation) {
   uint8_t *yPixel = plane;

   cv::Mat mYUV = cv::Mat(height, width, CV_8UC4, yPixel, bytesPerRow);

   fixMatOrientation(orientation, mYUV);

   return mYUV;

}

Die Android-Konvertierung ist etwas komplizierter, da wir drei Ebenen erst zu einer einzigen zusammenführen müssen:

static cv::Mat prepareMatAndroid(
       uint8_t *plane0,
       int bytesPerRow0,
       uint8_t *plane1,
       int lenght1,
       int bytesPerRow1,
       uint8_t *plane2,
       int lenght2,
       int bytesPerRow2,
       int width,
       int height,
       int orientation) {

   uint8_t *yPixel = plane0;
   uint8_t *uPixel = plane1;
   uint8_t *vPixel = plane2;

   int32_t uLen = lenght1;
   int32_t vLen = lenght2;

   cv::Mat _yuv_rgb_img;
   assert(bytesPerRow0 == bytesPerRow1 && bytesPerRow1 == bytesPerRow2);
   uint8_t *uv = new uint8_t[uLen + vLen];
   memcpy(uv, uPixel, vLen);
   memcpy(uv + uLen, vPixel, vLen);
   cv::Mat mYUV = cv::Mat(height, width, CV_8UC1, yPixel, bytesPerRow0);
   cv::copyMakeBorder(mYUV, mYUV, 0, height >> 1, 0, 0, BORDER_CONSTANT, 0);

   cv::Mat mUV = cv::Mat((height >> 1), width, CV_8UC1, uv, bytesPerRow0);
   cv:Mat dst_roi = mYUV(Rect(0, height, width, height >> 1));
   mUV.copyTo(dst_roi);

   cv::cvtColor(mYUV, _yuv_rgb_img, COLOR_YUV2RGBA_NV21, 3);

   fixMatOrientation(orientation, _yuv_rgb_img);

   return _yuv_rgb_img;
}

Wir werden in diesem Artikel nicht auf die Einzelheiten des Erkennungsalgorithmus selbst eingehen. 

Nach der erfolgreichen (oder erfolglosen) Erkennung müssen wir unsere internen Objekte in Strukturen umwandeln, auf die wir mit dart::ffi zugreifen können. Aus diesem Grund müssen sie mit malloc alloziert und mit dem extern C -Protokoll beschrieben werden (keine C++ Vektorobjekte, alle Strings werden dargestellt als char[], etc.). 

Die vollständige Implementierung finden Sie hier.

Speicherbereinigung

Eine weitere Herausforderung, die wir in diesem Artikel erwähnen sollten, ist memory management. Im Wesentlichen müssen native Speicherzuweisungen nach der Verwendung immer bereinigt werden. 
Werfen wir dazu einen Blick auf die Methode processFrameAsync in shape_detector.dart. Es gibt zwei Stellen, an denen der Speicher aufgeräumt wird. Die erste ist in _mapNativeItems, die native Ergebnisstrukturen in Dart abbildet und dann den Speicher des nativen Objekts freigibt.

List<Shape> _mapNativeItems(ffi.Pointer<_ShapeNative> result) {
 final shapes = <Shape>[];
 var currentShapeNative = result;
 while (currentShapeNative != ffi.nullptr) {
   try {
     final item = currentShapeNative.ref;
     final points = <Point<double>>[];
     var currentPointNative = item.point;
     _mapNativePoints(currentPointNative, points);
     shapes.add(Shape(item.corners, points));
     final tempItem = currentShapeNative;
     currentShapeNative = item.next;
     malloc.free(tempItem); // need to deallocate pointer to the object
   } catch (e) {
     print(e);
   }
 }
 return shapes;
}

void _mapNativePoints(
   ffi.Pointer<_PointNative> currentPointNative, List<Point<double>> points) {
 while (currentPointNative != ffi.nullptr) {
   points.add(Point(currentPointNative.ref.x, currentPointNative.ref.y));
   final tempItem = currentPointNative;
   currentPointNative = currentPointNative.ref.next;
   malloc.free(tempItem); // need to deallocate pointer to the object
 }
}

Die andere ist die image.release()Erweiterung , die alle Frame-bezogenen Daten bereinigt. 

extension SdkImagePoinerExtention on Pointer<SdkImage> {
 void release() {
   var plane = ref.plane;
   while (plane != nullptr) {
     if (plane.ref.planeData != nullptr) {
       malloc.free(plane.ref.planeData);
     }
     final tmpPlane = plane;
     plane = plane.ref.nextPlane;
     malloc.free(tmpPlane);
   }
   malloc.free(this);
 }
}

Hier bereinigen wir über ihre jeweiligen Pointer alle internen Byte-Arrays und anderen Objekte. 

Grundsätzlich müssen wir alle Zeiger auf den nativen Speicher freigeben , sobald wir sie nicht mehr verwenden. Dies ist sehr wichtig , vor allem für den Live-Erkennungsprozess – sonst geht uns der Speicher aus. 

Threading

Die nächste große Herausforderung ist die Frage des Threadings. Um die Live-Erkennung auf nativen Plattformen zu nutzen, müssen wir normalerweise in einen Hintergrund-Thread wechseln, um mit der Frame-Erkennung fortzufahren. Das Threading bereitet uns in Flutter jedoch einige Probleme. 

Das erste Problem besteht darin, dass async - Funktionen im selben Thread aufgerufen wird wie das Rendering der Benutzeroberfläche (Hauptthread). Die Verwendung einer asynchronen Funktion ist also keine Option, da sie den UI-Thread einfrieren würde. In den offiziellen Tutorials wird stattdessen die Verwendung von Isolaten empfohlen. 

Das Hauptproblem bei Isolaten ist, dass sie alle Objekte in einen anderen Thread-Speicherheap kopieren. Das führt zu einem bestimmten Zeitpunkt zu einer Frame-Duplikation. Sollten wir also Isolate verwenden? Die Antwort lautet: Ja! Isolate sind der einzig richtige Weg, um ressourcenintensive Operationen in Flutter aufzurufen.

In früheren Versionen hatten Flutter-Isolate Probleme mit Speicherlecks bei Frame-Daten. Frames wurden einfach nicht bereinigt, nachdem die compute -Methode beendet war. Stellen Sie sicher, dass Sie mindestens Flutter SDK Version 1.20.0 und Dart SDK 2.14.0 verwenden, die dieses Problem behoben haben (siehe pubspec.yaml).

Die gesamte API von Isolaten ist ziemlich komplex, aber Flutter schlägt eine Methode namens compute, vor, die das Öffnen des Isolats, die Datenverarbeitung und das Schließen des Isolats übernimmt. 

Lassen Sie uns also unser processFrameAsync mit etwas Threading umhüllen:

Future<ProcessingResult> processFrame(
   CameraImage image, int rotation, Rect? roi) async {
 // make sure we have valid image data (flutter camera plugin might provide an empty image)
 if (!image.isEmpty() && scanner != ffi.nullptr) {
   return compute(processFrameAsync,
       _FrameData(scanner.address, image, rotation, roi: roi));
 } else {
   return ProcessingResult([]);
 }
}

/// We need to pass serializable data to the isolate to process the frame in another thread and unblock the UI thread
class _FrameData {
 CameraImage image;
 int rotation;
 int scanner;
 Rect? roi;

 _FrameData(this.scanner, this.image, this.rotation, {this.roi});
}

Wenn wir die compute -Methode ausführen, können wir ihr nur serialisierbare Daten übergeben. Aus diesem Grund haben wir die Klasse _FrameDataclass dafür geschaffen. _FrameData stellt ein serialisierbares Objekt dar, das alle Metadaten des Bildes und einen Zeiger auf den Scanner im Native Memory enthält. 

Präsentation der Ergebnisse 

Wir haben nun das meiste abgedeckt, was man braucht, um eine native Live-Erkennung von Kamera-Streams in Flutter zu implementieren. Der letzte Schritt ist die Darstellung der Ergebnisse. In der Klasse FrameHandler haben wir StreamController<T> detectionResultStreamController. Wir können diesen Stream abonnieren und die Erkennungsergebnisse abrufen. Wenn Sie die Ergebnisse über der Vorschau zeichnen wollen, stellen Sie sicher, dass Ihr Widget die gleiche Größe wie die Vorschau hat.

@override
void initState() {
 notifier = ValueNotifier([]);
 startListenStream(_stream);
 super.initState();
}

void startListenStream(Stream<ProcessingResult> stream) async {
 await for (final result in stream) {
  //todo do something with the result.
 }
}

In diesem Beispiel zeichnen wir mit ShapesResultOverlay Kreise über die Vorschau.

Zusammenfassung

Wie Sie sehen, ist es möglich, mit Flutter eine plattformübergreifende Live-Erkennungsfunktion zu implementieren, indem man C++-Code über dart:ffi verwendet. Nativer Code kann so nicht nur für iOS und Android, sondern auch für Windows oder macOS erstellt werden. Wenn Sie Flutter-Anwendungen implementieren möchten, die verschiedene Plattformen und Elemente der nativen Live-Erkennung von Kamerabildern abdecken, sollten dieser Artikel und unser Beispielprojekt sehr nützlich sein.

Sie möchten jetzt loslegen? Dann probieren Sie unsere Flutter Document oder Flutter Barcode Scanner SKDs aus.

Bereit zum Ausprobieren?

Das Hinzufügen unserer kostenlosen Testversion zu Ihrer App ist ganz einfach. Laden Sie das Scanbot SDK jetzt herunter und entdecken Sie die Möglichkeiten der mobilen Datenerfassung.