Dart나 Java에서 복잡한 이미지 처리 알고리즘을 직접 구현하면 코드량이 많아지고 연산 부담이 커진다.
예를 들어, filter2D 알고리즘을 구현하려면 컨볼루션 연산을 수행해야 하는데, 이를 위해 각 픽셀에 직접 접근하여 연산하는 코드를 작성해야 한다. 그러나 C/C++과 달리, 높은 수준의 언어들은 메모리에 직접 접근할 수 없기 때문에 성능이 저하될 수 있다.
이러한 한계를 고려하여, Flutter에서 C++ OpenCV를 사용해 보자.
Flutter FFI(Foreign Function Interface)를 활용하면 Dart 코드에서 네이티브 코드를 직접 호출할 수 있다.
예제에서는 cv::cvtColor를 사용해 컬러 이미지를 흑백으로 변환하고, 이를 Flutter 화면에 표시해 볼 것이다.
OpenCV 다운로드

안드로이드용 OpenCV를 다운로드한 후, 압축을 해제한 sdk 폴더를 android/app/src/main/cpp/opencv 경로에 복사한다.
(android/app/src/main/cpp/opencv/sdk 구조가 되도록)
CMake 작성
android\app\src\main\cpp\CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(native_process)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# OpenCV SDK 경로 설정
set(OpenCV_DIR "${CMAKE_SOURCE_DIR}/opencv/sdk/native/jni")
find_package(OpenCV REQUIRED)
# 네이티브 라이브러리 생성
add_library(native_process SHARED native_process.cpp)
# OpenCV 포함
target_include_directories(native_process PRIVATE ${OpenCV_INCLUDE_DIRS})
target_link_libraries(native_process ${OpenCV_LIBS} log)
build.gradle(app) 설정
android\app\build.gradle
defaultConfig {
...
ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
}
}
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
}
}
FFI를 활용한 네이티브 코드 호출
android\app\src\main\cpp\native_process.cpp
#include <opencv2/opencv.hpp>
#include <cstdint>
#include <cstdlib>
#include <android/log.h>
#define LOG_TAG "NativeDebug"
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
extern "C" {
// Flutter와 연결될 함수
int
convert_to_grayscale(unsigned char *input_data, unsigned char *output_data, int width, int height) {
try {
cv::Mat input_image(height, width, CV_8UC3, input_data);
cv::Mat gray_image;
cv::cvtColor(input_image, gray_image, cv::COLOR_BGR2GRAY);
memcpy(output_data, gray_image.data, width * height);
LOGD("convert_to_grayscale Success");
return 0;
} catch (...) {
LOGE("convert_to_grayscale Err");
return -1;
}
}
}
포인터를 사용하여 input_data와 output_data를 직접 다룬다.
android/log.h를 사용하면 Flutter 로그에서 디버깅이 가능하다.
⚠️ 주의: 올바른 메모리 크기 할당 필요
예를 들어, output_data가 그레이스케일(width * height) 크기로 할당된 상태에서 컬러 이미지(width * height * 3) 데이터를 memcpy로 복사하면 버퍼 오버플로우가 발생한다.
이러한 메모리 오버플로우는 std::exception을 발생시키지 않고 try-catch로 감싸도 잡을 수 없다.
잘못된 메모리 접근(segmentation fault)으로 인해 앱이 강제 종료된다.
lib/native/native_process.dart
import 'dart:ffi';
typedef ConvertToGrayscaleC = Int32 Function(
Pointer<Uint8> inputData, Pointer<Uint8> outputData, Int32 width, Int32 height);
typedef ConvertToGrayscaleDart = int Function(
Pointer<Uint8> inputData, Pointer<Uint8> outputData, int width, int height);
class NativeProcess {
static final DynamicLibrary _dylib = DynamicLibrary.open('libnative_process.so');
static void convertToGrayscale(Pointer<Uint8> inputData,
Pointer<Uint8> outputData, int width, int height) {
final ConvertToGrayscaleDart convertToGrayscale =
_dylib.lookupFunction<ConvertToGrayscaleC, ConvertToGrayscaleDart>(
'convert_to_grayscale');
final result = convertToGrayscale(inputData, outputData, width, height);
if (result != 0) {
throw Exception("Err convertToGrayscale");
}
}
}
lib/native/native_control.dart
class NativeControl {
static void dartRgb_to_ptr(
{required ffi.Pointer<ffi.Uint8> inputPtr, required img.Image dartImg}) {
final width = dartImg.width;
final height = dartImg.height;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// (행 우선 방식)
img.Pixel pixel = dartImg.getPixel(x, y);
int index = (y * width + x) * 3;
inputPtr[index] = pixel.r.toInt();
inputPtr[index + 1] = pixel.g.toInt();
inputPtr[index + 2] = pixel.b.toInt();
}
}
}
static void grayPtr_to_dartImg(
{required ffi.Pointer<ffi.Uint8> grayPtr, required img.Image dartImg}) {
final width = dartImg.width;
final height = dartImg.height;
for (int i = 0; i < width * height; i++) {
int value = grayPtr[i];
dartImg.setPixel(
i % width, i ~/ width, img.ColorInt8.rgb(value, value, value));
}
}
static img.Image convert_to_gray_func({required img.Image image}) {
final width = image.width;
final height = image.height;
ffi.Pointer<ffi.Uint8> inputData = calloc<ffi.Uint8>(width * height * 3);
ffi.Pointer<ffi.Uint8> outputData = calloc<ffi.Uint8>(width * height);
try {
dartRgb_to_ptr(
inputPtr: inputData,
dartImg: image,
);
NativeProcess.convertToGrayscale(inputData, outputData, width, height);
final grayImage = img.Image(width: width, height: height);
grayPtr_to_dartImg(grayPtr: outputData, dartImg: grayImage);
return grayImage;
} finally {
calloc.free(inputData);
calloc.free(outputData);
}
}
}
FFI를 사용하면 C의 malloc, calloc 등을 활용해 메모리를 할당할 수 있다.
그러나 이렇게 할당된 메모리는 가비지 컬렉터의 관리 대상이 아니므로, free를 직접 호출해야 한다.
dartRgb_to_ptr: Dart Image 객체를 포인터에 저장한다.
grayPtr_to_dartImg: 결과 포인터의 내용을 Dart Image 객체로 변환한다.
lib/main.dart
img.Image _convert_to_gray_func(Map<String, dynamic> params) {
img.Image image = params["image"];
return NativeControl.convert_to_gray_func(image: image);
}
...
...
Future<void> _toggleImage() async {
ByteData data = await rootBundle.load("assets/bird-4728857_1280.jpg");
Uint8List bytes = data.buffer.asUint8List();
img.Image? image = img.decodeImage(bytes);
image!;
img.Image grayImage = await compute(_convert_to_gray_func, {
"image": image,
});
setState(() {
_imageBytes = Uint8List.fromList(img.encodePng(grayImage));
});
}
...
...
Flutter는 기본적으로 단일 메인 스레드에서 동작하며, UI 렌더링과 로직 처리를 함께 수행한다.
따라서 무거운 연산을 직접 실행하면 UI가 멈추거나 지연될 수 있다.
compute는 새로운 Isolate(경량 스레드)를 생성하여 연산을 분리하고,
메인 스레드의 성능 저하 없이 비동기적으로 작업을 처리할 수 있도록 돕는다.
(예제의 흑백 변환 하나는 가벼운 연산이지만, Flutter에서 OpenCV/C++을 찾았다면 더 복잡한 이미지 처리가 필요했을 것이므로 compute 활용을 포함했다.)

Example Source Code
https://github.com/cornpip/flutter_cpp_opencv_example
GitHub - cornpip/flutter_cpp_opencv_example
Contribute to cornpip/flutter_cpp_opencv_example development by creating an account on GitHub.
github.com