[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);
});
}
});
}
});
실제로 작성한 코드는 이 곳 을 통해 확인 가능하다.