[Flutter] OpenCV 사용하기

[Flutter] OpenCV 사용하기

1. RealTime 카메라 환경 구축

일단은 tfilte 를 사용하여 실시간으로 Object Detection 을 하는 프로젝트를 하나 들고왔다. 참고로한 프로젝트 사이트는 이 곳이다.

Flutter 에서 RealTime 으로 카메라로 부터 이미지를 가져오는 방법은 camera library 를 사용하여 Camera Controller 의 startImageStream 이라는 함수를 사용하는 것이다. (자세한 방법은 참고 프로젝트 또는 다른 글 참고)

        controller.startImageStream((CameraImage img) {
          if (!isDetecting) {
            isDetecting = true;
            Tflite.detectObjectOnFrame(
              bytesList: img.planes.map((plane) {
                return plane.bytes;
              }).toList(),
              model: "SSDMobileNet",
              imageHeight: img.height,
              imageWidth: img.width,
              imageMean: 127.5,
              imageStd: 127.5,
              numResultsPerClass: 3,
              threshold: 0.4,
            ).then((recognitions) {
              /*
              When setRecognitions is called here, the parameters are being passed on to the parent widget as callback. i.e. to the LiveFeed class
               */
              widget.setRecognitions(recognitions, img.height, img.width);
              isDetecting = false;
            });
          }
        });

2. OpenCV Liberary 다운로드

아래 과정은 여기 를 참고하였다. 참고로 IOS 기기가 없는 관계로 여기서는 안드로이드에 대한 설명만 작성하였다.

아직 Flutter 에서 OpenCV 를 직접적으로 사용할 수 있는 라이브러리가 따로 존재하지 않기 때문에 Android 또는 IOS 에서 OpenCV 를 실행할 수 있는 환경을 구축, InvokeMethod 를 통해 이를 사용하도록 해야 한다.

Android 또는 IOS 에 대한 OpenCV 파일은 여기 에서 다운로드 가능하다.

그리고 이제 OpenCV 파일을 Android 에서 사용할 수 있게 추가를 해 줄 것이다. 이를 위해서는 File > Open > {현재 Flutter 프로젝트}/android 폴더를 Android Studio 를 이용해서 새로 열어야 한다.

다음 새로 연 Android Studio 에서 File > New > Import Module 을 선택하여 다운받은 OpenCV 파일 안의 sdk 폴더를 선택해 준다. 그리고 module name 을 :sdk > :opencv 로 변경해 주자.

마지막으로 app 수준의 gradle 파일의 dependencies 에 아래 문장을 추가해 준다.

implementation project(":opencv")

그러면 이제 kotlin 을 사용하여 OpenCV 함수를 호출할 수 있게 된다.

3. InvokeMethod 사용

Platform 별로 특정 함수를 호출하기 위해서는 InvokeMethod 라는 것을 사용하여야 한다. 이는 Flutter 공식문서의 Writing custom platform-specific code 를 참고하는 것이 좋다. (참고로 종종 한글 버전이 코드 업데이트가 안된 경우가 있으므로 영어 버전을 참고하자) 여기서 가장 중요한 것은 Dart 와 Kotlin 간의 주고 받는 데이터 형태가 꼭 매칭되어야 한다는 것이다. 때문에 함수 작성시 주의해주자.

먼저 android 의 MainActivity 파일로 들어가서 class 변수로 CHANNEL 이름을 작성해 준다.

    private val CHANNEL = "samples.flutter.dev/opencv"

그리고 사용할 함수를 작성해 준다. 참고로 아래 코드는 여기 를 참고하였다.

    private fun calOpticalFlowImg(imageHeight: Int, imageWidth: Int, beforeImg: ByteArray, img: ByteArray) : ByteArray {

        // however you fill it

        val oldFrame = Mat(imageWidth, imageHeight, CvType.CV_8UC3)
        oldFrame.put(0, 0, beforeImg)
        val oldGray = Mat(imageWidth, imageHeight, CvType.CV_8UC1)
        Imgproc.cvtColor(oldFrame, oldGray, Imgproc.COLOR_BGR2GRAY)

        val p0MatofPoint = MatOfPoint()
        Imgproc.goodFeaturesToTrack(oldGray, p0MatofPoint, 100, 0.3, 7.0, Mat(), 7, false, 0.04)

        val p0 = MatOfPoint2f(*p0MatofPoint.toArray())
        val p1 = MatOfPoint2f()

        val frame = Mat(imageWidth, imageHeight, CvType.CV_8UC3)
        frame.put(0, 0, img)
        val frameGray = Mat(imageWidth, imageHeight, CvType.CV_8UC1)
        Imgproc.cvtColor(frame, frameGray, Imgproc.COLOR_BGR2GRAY)

        // Create a mask image for drawing purposes
        val mask: Mat = Mat.zeros(frame.size(), frame.type())

        // calculate optical flow
        val status = MatOfByte()
        val err = MatOfFloat()
        val criteria = TermCriteria(TermCriteria.COUNT + TermCriteria.EPS, 10, 0.0001)
        Video.calcOpticalFlowPyrLK(oldGray, frameGray, p0, p1, status, err, Size(15.0, 15.0), 2, criteria)
        
        val red = Scalar(255.0, 0.0, 0.0)

        val StatusArr = status.toArray()
        val p0Arr = p0.toArray()
        val p1Arr = p1.toArray()
        val good_new: ArrayList<Point> = ArrayList()
        for (i in StatusArr.indices) {
            if (StatusArr[i].toInt() == 1) {
                good_new.add(p1Arr[i])
                Imgproc.line(mask, p1Arr[i], p0Arr[i], red, 2)
                Imgproc.circle(frame, p1Arr[i], 5, red, -1)
            }
        }
        val resultImg = Mat()
        Core.add(frame, mask, resultImg)
        
        val result : ByteArray = ByteArray((resultImg.total() * resultImg.channels()).toInt())
        resultImg.get(0, 0, result)
        return result
    }

마지막으로 아래와 같이 configureFlutterEngine 함수를 작성해 준다. 이때 OpenCVLoader.initDebug() 함수를 꼭 먼저 작성하여 opencv 함수를 호출 하기전 opencv 를 동작할 수 있는 환경이 구축되도록 해야한다.

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        OpenCVLoader.initDebug()

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
            // Note: this method is invoked on the main thread.
            if (call.method == "opticalFlowImg") {
                val beforeImg = call.argument<ByteArray>("beforeImage") ?: byteArrayOf()
                val img = call.argument<ByteArray>("image") ?: byteArrayOf()
                val imageHeight = call.argument<Int>("imageHeight") ?: 0
                val imageWidth = call.argument<Int>("imageWidth") ?: 0
                val bytes = calOpticalFlowImg(imageHeight, imageWidth, beforeImg, img)
                result.success(bytes)
            }
        }
    }

다시 Flutter 로 돌아와서 MethodChannel() 과 함께 platform 의 특정 함수를 불러오는 함수를 작성해 준다.

  // make Channel
  static const platform = const MethodChannel('samples.flutter.dev/opencv');
  
  // call platform method
  Future<Uint8List> _getOpticalFlowImg(int imageHeight, int imageWidth, Uint8List beforeImg, Uint8List curImg) async {
    try {
      final Uint8List result = await platform.invokeMethod('opticalFlowImg', <String, dynamic> {
        'imageHeight' : imageHeight,
        'imageWidth' : imageWidth,
        'beforeImage': beforeImg,
        'image' : curImg,
      });
      return result;
    } on PlatformException catch (e) {
    }
    return Uint8List(0);
  }

4. Image Type 변환 함수 작성

Flutter 의 camera library 를 통해 이미지를 받아오면 android 는 YUV420, IOS 는 bgra type 으로 받아오게 된다. 하지만 예시에서 사용된 opencv 는 bgr type 을 요구한다.

이유는 잘 모르겠지만 단순히 flutter 의 image library 를 사용해서 YUV420 을 bgr 로 변환할 경우 뭔가 잘 안되었다. 때문에 이 곳 을 참고하여 Image Type 을 바꾸는 함수를 새로 작성하였다.

먼저 아래 두 library 를 추가한다. 이때 ffi 는 아래 버전을 사용하는 것을 추천한다. (2021.04 1.0.0v 이 최신이지만 이 경우 몇 함수가 존재하지 않았다.)

  image: ^3.0.2
  ffi: ^0.1.3

다음으로 ./android 폴더에 CmakeLists.txt 라는 이름의 폴더를 생성하고 아래 내용을 작성한다.

cmake_minimum_required(VERSION 3.4.1)  # for example
add_library( convertImage
    # Sets the library as a shared library.
    SHARED
    # Provides a relative path to your source file(s).
    ../ios/Classes/converter.c
)

그리고 ./android/app/build.gradle 파일을 열어 아래 코드를 추가한다.

// ...
android{
  // ...
  externalNativeBuild{
    cmake{
      path "../CMakeLists.txt"
    }
  }
  // ...
}
// ...

이제 ./ios/Classes 라는 폴더를 생성하고 그 안에 converter.h 라는 파일을 생성 후 아래 내용을 작성해 준다.

uint32_t *convertImage(uint8_t *plane0, uint8_t *plane1, uint8_t *plane2, int bytesPerRow, int bytesPerPixel, int width, int height);

다음으로 같은 위치에 converter.c 라는 파일을 생성 후 아래 내용을 작성해 준다.

#include <stdio.h>
#include "converter.h"
#include <math.h>
#include <stdlib.h>

int clamp(int lower, int higher, int val){
    if(val < lower)
        return 0;
    else if(val > higher)
        return 255;
    else
        return val;
}

int getRotatedImageByteIndex(int x, int y, int rotatedImageWidth){
    return rotatedImageWidth*(y+1)-(x+1);
}

uint32_t *convertImage(uint8_t *plane0, uint8_t *plane1, uint8_t *plane2, int bytesPerRow, int bytesPerPixel, int width, int height){
     int hexFF = 255;
    int x, y, uvIndex, index;
    int yp, up, vp;
    int r, g, b;
    int rt, gt, bt;

    uint32_t *image = malloc(sizeof(uint32_t) * (width * height));

    for(x = 0; x < width; x++){
        for(y = 0; y < height; y++){
            
            uvIndex = bytesPerPixel * ((int) floor(x/2)) + bytesPerRow * ((int) floor(y/2));
            index = y*width+x;

            yp = plane0[index];
            up = plane1[uvIndex];
            vp = plane2[uvIndex];
            rt = round(yp + vp * 1436 / 1024 - 179);
            gt = round(yp - up * 46549 / 131072 + 44 - vp * 93604 / 131072 + 91);
            bt = round(yp + up * 1814 / 1024 - 227);
            r = clamp(0, 255, rt);
            g = clamp(0, 255, gt);
            b = clamp(0, 255, bt);
            image[getRotatedImageByteIndex(y, x, height)] = (hexFF << 24) | (b << 16) | (g << 8) | r;
        }
    }
    return image;
}

이제 Flutter 로 돌아와서 아래와 필요한 타입에 대한 정의를 해 준다.

typedef convert_func = Pointer<Uint32> Function(Pointer<Uint8>, Pointer<Uint8>, Pointer<Uint8>, Int32, Int32, Int32, Int32);
typedef Convert = Pointer<Uint32> Function(Pointer<Uint8>, Pointer<Uint8>, Pointer<Uint8>, int, int, int, int);

그리고 타입을 변환하는 코드를 작성해 준다.

  final DynamicLibrary convertImageLib = Platform.isAndroid
      ? DynamicLibrary.open("libconvertImage.so")
      : DynamicLibrary.process();
  Convert conv;

  Future<imglib.Image> _convertImg(CameraImage _savedImage) async {
    imglib.Image img;
    if(Platform.isAndroid){
      // Allocate memory for the 3 planes of the image
      Pointer<Uint8> p = allocate(count: _savedImage.planes[0].bytes.length);
      Pointer<Uint8> p1 = allocate(count: _savedImage.planes[1].bytes.length);
      Pointer<Uint8> p2 = allocate(count: _savedImage.planes[2].bytes.length);

      // Assign the planes data to the pointers of the image
      Uint8List pointerList = p.asTypedList(_savedImage.planes[0].bytes.length);
      Uint8List pointerList1 = p1.asTypedList(_savedImage.planes[1].bytes.length);
      Uint8List pointerList2 = p2.asTypedList(_savedImage.planes[2].bytes.length);
      pointerList.setRange(0, _savedImage.planes[0].bytes.length, _savedImage.planes[0].bytes);
      pointerList1.setRange(0, _savedImage.planes[1].bytes.length, _savedImage.planes[1].bytes);
      pointerList2.setRange(0, _savedImage.planes[2].bytes.length, _savedImage.planes[2].bytes);

      // Call the convertImage function and convert the YUV to RGB
      Pointer<Uint32> imgP = conv(p, p1, p2, _savedImage.planes[1].bytesPerRow,
          _savedImage.planes[1].bytesPerPixel, _savedImage.planes[0].bytesPerRow, _savedImage.height);

      // Get the pointer of the data returned from the function to a List
      List imgData = imgP.asTypedList((_savedImage.planes[0].bytesPerRow * _savedImage.height));
      // Generate image from the converted data
      img = imglib.Image.fromBytes(_savedImage.height, _savedImage.planes[0].bytesPerRow, imgData);

      // Free the memory space allocated
      // from the planes and the converted data
      free(p);
      free(p1);
      free(p2);
      free(imgP);
    } else if(Platform.isIOS){
      img = imglib.Image.fromBytes(
        _savedImage.planes[0].bytesPerRow,
        _savedImage.height,
        _savedImage.planes[0].bytes,
        format: imglib.Format.bgra,
      );
    }
    return img;
  }

마지막으로 _convertImg() 함수를 사용하기 이전에 아래와 같이 conv 변수를 initialize 해준다.

    // Load the convertImage() function from the library
    conv = convertImageLib.lookup<NativeFunction<convert_func>>('convertImage').asFunction<Convert>();

5. 동작하는 코드 작성

이제 controller 로부터 CameraImage 파일을 받아오면 CameraImage > _convertImg() 를 통해 bgra tpye 의 imglib.Image 로 변환 > bgr type 의 Uint8List로 변환 > InvokeMethod 를 통해 전달 > Kotlin 에서 OpenCV 동작 > Uint8List 형태로 전달 > 다시 libImg.Image 로 변경 > libImg.encodeJpg() 함수를 통해 적절한 List<Int> Type 으로 변환 > Image.memory() 를 통해 화면에 출력 의 과정을 통해 화면에 변환된 이미지를 표시하게 된다.

아래는 위 과정 중 일부를 작성한 것이다.

        controller.startImageStream((CameraImage img) {

          if (!isDetecting) {
                isDetecting = true;
                _convertImg(img).then((jpg) {
                  var curImg = jpg.getBytes(format: imglib.Format.bgr); //imglib.encodeJpg(jpg);
                  if (this.beforeImg == null) {
                    this.beforeImg = curImg;
                    isDetecting = false;
                  } else {
                    _getOpticalFlowImg(img.height, img.width, beforeImg, curImg).then((value) async {
                      var valueImg = imglib.Image.fromBytes(img.height, img.width, value, format: imglib.Format.bgr);
                      List<int> png = imglib.encodeJpg(valueImg);
                      this.beforeImg = curImg;
                      setState(() {
                        _bytes = png;
                      });
                      Future.delayed(Duration(milliseconds: 100), () => isDetecting = false);
                    });
                  }
                });
          }
        });

실제로 작성한 코드는 이 곳 을 통해 확인 가능하다.


© 2021. All rights reserved.