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 객체로 변환한다.

추가 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

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 관련 설정을 추가

// 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 설정을 다양하게 시도해봤지만, 여전히 링킹 에러가 해결되지 않았다.

~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)

defaultConfig {
	....
        externalNativeBuild {
            cmake {
                // Passes optional arguments to CMake.
                arguments += listOf("-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON")
            }
        }
    }

컴파일 이전 단계에서 실행되는 CMake 구성 작업에 옵션 플래그를 전달하는 역할을 한다.

정리

16KB 메모리 페이지 크기 지원을 위해서

  1. CMake에서 16KB 페이지 정렬을 지원하도록 설정
  2. 설정 적용으로 인해 발생하는 오류 처리 (libc++_shared.so)
  3. CMake 구성 단계에 16KB 페이지 대응 기능 플래그 전달

 

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