Flutter C++ OpenCV로 이미지 처리하기 + 16KB Memory Page Size
이 글은 Tistory에서 이전된 포스팅입니다. 원본은 여기에서 확인할 수 있습니다.
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
1
2
3
4
5
6
7
8
9
10
11
12
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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 객체로 변환한다.
추가 25.07 )
⚠️ 주의: 다트에서 포인터에 할당한 채널(색상) 순서를 주의하자.
cv::Mat input_image(height, width, CV_8UC3, input_data);
예를 들어 dartRgb_to_ptr 함수는 rgb 순서대로 데이터를 쌓았고, input_data에는 rgb 순으로 데이터가 할당되어 있다.
그러면 input_image 객체에서 채널이나 색공간을 변환할 때 RGB2XXX 으로 해야 한다.
cv의 기본 채널 순서는 bgr이므로, 다트에서 bgr 순으로 데이터를 할당하는 것도 좋은 방법이다.
lib/main.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 활용을 포함했다.)
###
앱이 16KB 메모리 페이지 크기를 지원해야 합니다. (추가 25.11.04)
NDK를 사용한 앱을 플레이 스토어에 등록하면,
“앱이 16KB 메모리 페이지 크기를 지원해야 합니다” 라는 경고가 뜬다.
앱 게시는 가능하지만, 이 문제를 해결하지 않으면 일정 기간 이후에는 업데이트가 제한된다는 경고가 있다.
테스트 및 출시 - 최신 버전 및 번들에서 지원하지 않는 라이브러리를 확인할 수 있다.
CMake에 16KB 관련 설정을 추가
1
2
3
4
5
6
7
8
// CMake
# Higher page alignment: 16KB (0x4000)
set(CMAKE_EXE_LINKER_FLAGS
"${CMAKE_EXE_LINKER_FLAGS} -Wl,-z,max-page-size=0x4000"
)
set(CMAKE_SHARED_LINKER_FLAGS
"${CMAKE_SHARED_LINKER_FLAGS} -Wl,-z,max-page-size=0x4000"
)
기본적으로 NDK로 빌드된 Native Code는 16KB 메모리 페이지 크기를 지원하도록 링커 설정이 되어 있지 않다.
Side Effect
하지만 이 옵션을 적용하면 다음과 같은 에러가 생긴다.
“library “libc++_shared.so” not found”\
사실 이 파일은 NDK 폴더 내에 존재하지만, 16KB 페이지 설정을 추가한 이후에는
빌드 과정에서 링커가 해당 라이브러리를 제대로 참조하지 못한다.
앱 프로젝트에 직접 포함한다. (가장 확실)
수동으로 포함시키는 방법을 피하고자 CMake와 Gradle 설정을 다양하게 시도해봤지만, 여전히 링킹 에러가 해결되지 않았다.
1
2
3
4
5
~android\sdk\ndk\27.3.13750724\toolchains\llvm\prebuilt\windows-x86_64\sysroot\usr\lib\arm-linux-androideabi\libc++_shared.so
=> android\app\src\main\jniLibs\armeabi-v7a\libc++shared.so
~android\sdk\ndk\27.3.13750724\toolchains\llvm\prebuilt\windows-x86_64\sysroot\usr\lib\aarch64-linux-android\libc++_shared.so
=> android\app\src\main\jniLibs\arm64-v8a\libc++shared.so
복사해야 하는 libc++_shared.so 파일은 flutter doctor -v 명령으로 확인할 수 있는 Android SDK 경로에 포함되어 있다.
각 ABI에 따라 적절한 경로에서 가져와야 하며, 매핑 규칙은 위 예시와 동일하다.
참고로, JNI 라이브러리를 Gradle에서 자동으로 로드하기 위해서는 반드시 jniLibs 라는 폴더명을 사용해야 한다.
이 폴더명은 Gradle에 하드코딩된 규칙이다.
+) ABI(Application Binary Interface)
예시에서는 armeabi-v7a와 arm64-v8a 두 가지 ABI만 포함했지만,
지원하지 않는 라이브러리와 타겟 디바이스를 고려해 필요한 ABI를 추가하거나 제거하면 된다.
Gradle (app)
1
2
3
4
5
6
7
8
9
defaultConfig {
....
externalNativeBuild {
cmake {
// Passes optional arguments to CMake.
arguments += listOf("-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON")
}
}
}
컴파일 이전 단계에서 실행되는 CMake 구성 작업에 옵션 플래그를 전달하는 역할을 한다.
정리
16KB 메모리 페이지 크기 지원을 위해서
- CMake에서 16KB 페이지 정렬을 지원하도록 설정
- 설정 적용으로 인해 발생하는 오류 처리 (libc++_shared.so)
- CMake 구성 단계에 16KB 페이지 대응 기능 플래그 전달
Example Source Code
https://github.com/cornpip/flutter_cpp_opencv_example
함께 보면 좋은 글
2025.11.26 - Flutter FFI Plugin Project
현재 포스팅(flutter_cpp_opencv_example)과 차이
수동으로 작성했던 native_process.dart를 generate script로 생성
compute 기반 단발성 처리 대신 Isolate.spawn + ReceivePort 조합으로 재사용 가능한 worker isolate 구조 사용


