一文搞定移動端接入ncnn模型(包括Android、iOS)

語言: CN / TW / HK

theme: mk-cute

前言

事情是這樣的,最近有演算法老哥找我做移動端ncnn模型的對接工作。對接模型的邏輯已經很久沒有看過了,我又重新看起了專案的祖傳程式碼。由於每次弄這一塊時都有可能踩重複的坑,所以打算用一篇文章記錄一下大概的接入步驟。因為屬於應用層的事情,只能大概介紹一下流程,細節部分視具體場景而定

本文會涉及到移動端原生開發對接native層(C/C++)的邏輯,包括iOS和Android。有需要的同學可以按目錄結構閱讀。

環境準備

接入ncnn時,我們需要整合關於ncnn庫。由於是native層作影象識別,這裡還會用到OpenCV(ps:這個只是因為OpenCV提供各式各樣的api,可以進行影象處理的擴充套件,可視真實場景新增)。

本文用到的ncnnOpenCV版本

ncnn 預編譯庫 20211208 8916d1e

OpenCV – 3.4.5

本文的ncnn和OpenCV都會選擇官方已經編譯好的動態庫 - ncnn image.png - OpenCV image.png

ps:由於OpenCV涵蓋的api較多,實際專案可根據其原始碼作刪減處理,以減少包體積

簡單的ncnn模型識別

先來看看純native層的ncnn接入邏輯。參考官方文件:use-ncnn-with-alexnet筆者寫了一個簡單的C++程式碼作為native層的ncnn模型對接。(ps:由於此部分不是筆者專業,本文僅根據接觸過的提供一個示例。)

```C++

include

include "Reco.h"

include

include

include

ncnn::Net *net = nullptr;

void unInit() { if (net != nullptr) { delete net; net = nullptr; } }

void init(const std::string &paramPath, const std::string &binPath) { unInit();

net = new ncnn::Net;
net->load_param(paramPath.c_str());
net->load_model(binPath.c_str());

}

int detect(const cv::Mat &bgr, std::vector &cls_scores) { if (net == nullptr) return 1; ncnn::Mat in = ncnn::Mat::from_pixels_resize(bgr.data, ncnn::Mat::PIXEL_BGR, bgr.cols, bgr.rows, 224, 224);

const float mean[3] = {0.0f, 0.0f, 0.0f};
const float normal[3] = {0.0f, 0.0f, 0.0f};

in.substract_mean_normalize(mean, normal);
ncnn::Extractor ex = net->create_extractor();

ex.input("input", in);
ncnn::Mat out;
ex.extract("output", out);
out = out.reshape(out.w * out.h * out.c);
cls_scores.resize(out.w);
for (int j = 0; j < out.w; j++) {
    cls_scores[j] = out[j];
}
return 0;

}

int process(const std::vector &cls_scores) { return 0; }

int interface(const cv::Mat &bgr) { std::vector cls_scores; if (detect(bgr, cls_scores) == 0) { return 0; } process(cls_scores); return 0; } `` 主要分為3部分: - **模型載入(初始化)**:ncnn模型需要提供一個bin檔案和一個param檔案作為模型載入的必要檔案。 - **前處理和識別/檢測**: 1. 首先會利用cv::Mat指向的**那塊記憶體地址**傳遞給ncnn::Mat,然後進行resize成**224 * 224**。 2. 進行**識別/檢測**。 3. 最後拿到的cls_scores`為最終的結果,後續根據這個結果進行後處理。 - 後處理:後處理要視具體業務而定,這裡就不給出了。

基於上述的C++程式碼,標頭檔案宣告為: C++ void init(const std::string &paramPath, const std::string &binPath); int interface(const cv::Mat &bgr);

ps:上述的C++程式碼檔案被命名為Reco.h、Reco.cpp

Android接入ncnn

環境整合

ps:本文只支援armeabi-v7a、arm64-v8a兩個架構。

  • OpenCV整合 image.png

  • ncnn整合 image.png 如上圖,將OpenCVncnn動態庫及標頭檔案複製到專案的對應目錄

Reco.h、Reco.cpp為上述接入ncnn的C++程式碼。接下來需要新建一個CMakeLists.txt,作為cmake的宣告檔案。具體邏輯可見註釋。 ```cmake cmake_minimum_required(VERSION 3.4.1) set(CMAKE_VERBOSE_MAKEFILE on)

set(CMAKE_CXX_STANDARD 14)

編譯包含的原始碼

include_directories(${CMAKE_SOURCE_DIR}) include_directories(${CMAKE_SOURCE_DIR}/include)

FIND_PACKAGE(OpenMP REQUIRED) if (OPENMP_FOUND) message("OPENMP FOUND") set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${OpenMP_C_FLAGS}") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${OpenMP_CXX_FLAGS}") set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} ${OpenMP_EXE_LINKER_FLAGS}") endif ()

加入上述OpenCV對應的動態庫libopencv_java3.so,並設定具體路徑

add_library(opencv_java3 SHARED IMPORTED) set_target_properties(opencv_java3 PROPERTIES IMPORTED_LOCATION ${PROJECT_SOURCE_DIR}/../../../libs/${ANDROID_ABI}/libopencv_java3.so)

加入上述ncnn對應的動態庫libncnn.so,並設定具體路徑

add_library(ncnn SHARED IMPORTED) set_target_properties(ncnn PROPERTIES IMPORTED_LOCATION ${PROJECT_SOURCE_DIR}/../../../libs/${ANDROID_ABI}/libncnn.so)

給本專案的native程式碼生成的so庫命名為example(libexample.so)

add_library(example SHARED Reco.cpp JNIReco.cpp)

宣告所有需要用到的庫

target_link_libraries( # Specifies the target library. example ncnn opencv_java3 android log jnigraphics) ```

在該模組的build.gradle

  • android中加入 groovy externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" } }
  • android.defaultConfig中加入 groovy externalNativeBuild { cmake { cppFlags "-frtti -fexceptions -std=c++11" arguments '-DANDROID_TOOLCHAIN=clang', '-DANDROID_PLATFORM=android-21', '-DANDROID_STL=gnustl_static' abiFilters 'armeabi-v7a', 'arm64-v8a' } }

最終編譯出來的apk就攜帶了上述三個so庫:libexample.so、libopencv_java3.so、libncnn.so

image.png

ps:需要注意的是,由於筆者的example用了最新的AndroidStudio建立,使用了7.x的Gradle外掛,所以上述的整合過程可能會有所出入。譬如so庫在舊版本中需要放到src/main/jniLibs目錄中,參考Android Gradle 外掛版本說明。所以,需要根據構建版本作出相應調整

JNI層

JVM呼叫C++層需要一個JNI層。直接上程式碼: ```C++

define ASSERT(status, ret) if (!(status)) { return ret; }

define ASSERT_FALSE(status) ASSERT(status, false)

#define JNI_METHOD(return_type, method_name) \ JNIEXPORT return_type JNICALL \ Java_me_xcyoung_ncnn_Reco_##method_name

extern "C" { bool bitmapToMat(JNIEnv env, jobject input_bitmap, cv::Mat &output) { void bitmapPixels; AndroidBitmapInfo bitmapInfo; ASSERT_FALSE(AndroidBitmap_getInfo(env, input_bitmap, &bitmapInfo) >= 0) ASSERT_FALSE(bitmapInfo.format == ANDROID_BITMAP_FORMAT_RGBA_8888)

ASSERT_FALSE(AndroidBitmap_lockPixels(env, input_bitmap, &bitmapPixels) >= 0)
ASSERT_FALSE(bitmapPixels)

cv::Mat tmp(bitmapInfo.height, bitmapInfo.width, CV_8UC4, bitmapPixels);
cv::cvtColor(tmp, output, cv::COLOR_RGBA2BGR);

AndroidBitmap_unlockPixels(env, input_bitmap);
return true;

}

JNI_METHOD(void, nativeInit)(JNIEnv *env, jobject instance, jstring paramPath, jstring binPath) { jboolean isCopy; std::string mParamPath = env->GetStringUTFChars(paramPath, &isCopy); std::string mBinPath = env->GetStringUTFChars(binPath, &isCopy); init(mParamPath, mBinPath); }

JNI_METHOD(jint, nativeInterface)(JNIEnv *env, jobject instance, jobject bitmap) { cv::Mat input;

bool res = bitmapToMat(env, bitmap, input);
if (res) {
    return interface(input);
} else {
    return 0;
}

} } ``JNI層就是對接C++程式碼的**橋接層**,後面兩個方法,nativeInitnativeInterface分別對應Reco.cpp中的init和interface`的橋接方法,可在這兩個方法下呼叫C++程式碼,也可在此之前做一些其他操作

bitmapToMat方法主要是將Bitmap轉換成一個cv::Mat。關於JNI中的Bitmap相關使用可以參考Android JNI 之 Bitmap 操作。 - 該方法主要是獲取到Bitmap所指向的那塊影象記憶體地址,將其例項一個cv::Mat。 - cv::cvtColor(tmp, output, cv::COLOR_RGBA2BGR);是將色彩空間RGBA轉成BGR,因為識別的C++程式碼中,演算法模型需要BGR格式

值得注意的是,cv::Mat預設格式為BGR,但這裡由於Bitmap通過載入圖片檔案得到,預設為RGBA,這裡直接引用了那塊記憶體地址,所以色彩空間也是RGBA

最後就是和此JNI關聯的Java類: ```java public class Reco { Reco() { System.loadLibrary("example"); }

void init(String paramPath, String binPath) {
    nativeInit(paramPath, binPath);
}

int reco(Bitmap bitmap) {
    return nativeInterface(bitmap);
}

native void nativeInit(String paramPath, String binPath);
native int nativeInterface(Bitmap bitmap);

} ```

iOS接入ncnn

環境整合

image.png 如上圖,將下載的OpenCV及ncnn相關的framework複製到專案中自定義的一個framework目錄,在將目錄引用到Project當中。最後形成的依賴項:

image.png

ps:如果出現xxx.h not found的情況,可以考慮將其framework中的Headers目錄新增到Build Settings的Header Search Paths中。

Objective-c橋接層

在iOS中呼叫C++程式碼就簡單很多了,我們只需要實現一個Objective-c程式碼對接C++程式碼。如果上層使用的是Swift,只需要進行Objective-cSwift的橋接即可

定義一個RecoInterface.mm(ps:這裡.mm字尾才能引用.cpp程式碼

```swift @implementation RecoInterface

  • (void)init:(NSString )paramPath binPath:(NSString )binPath { init([paramPath UTF8String], [binPath UTF8String]); }

  • (int)interface:(UIImage *)image { cv::Mat input = [self image2Mat:image]; return interface(input); }

  • (cv::Mat)image2Mat:(UIImage *)image { CGColorSpaceRef colorSpace = CGImageGetColorSpace(image.CGImage); CGFloat cols = image.size.width; CGFloat rows = image.size.height;

    cv::Mat cvMat(rows, cols, CV_8UC4); CGContextRef contextRef = CGBitmapContextCreate(cvMat.data, cols, rows, 8, cvMat.step[0], colorSpace, kCGImageAlphaNoneSkipLast | kCGBitmapByteOrderDefault); CGContextDrawImage(contextRef, CGRectMake(0, 0, cols, rows), image.CGImage); CGContextRelease(contextRef); cv::cvtColor(cvMat, cvMat, CV_RGBA2BGR); return cvMat; }

@end ```

image2Mat方法是將UIImage轉換為cv::Mat,原理是利用原生的CGContextDrawImageUIImage的內容繪製一份到新建的cv::Mat的記憶體空間,本質上是一次拷貝

cv::cvtColor(cvMat, cvMat, CV_RGBA2BGR);這裡再次出現,原因是因為本來UIImage色彩空間為RGBA執行CGContextDrawImage時按照了RGBA來繪製,所以需要轉換成BGR。這個和Android還是有區別的。

ps:測試中發現,使用此方式進行轉換後,後續的模型結果對齊會有小數點後幾位的偏差。筆者推測是CGContextDrawImage有精度的缺失,但由於未找到合適的解釋,所以也沒找到合適的解決方法。如果條件允許的話,可使用cv::imread讀取圖片,以此避免精度問題。而且image2Mat畢竟是一次記憶體拷貝,對於解析度高的圖片也是一筆不小的開銷

最後

以上就是關於移動端接入ncnn模型的方法,由於大部分涉及的其實是原生呼叫native層(C/C++)的方法,所以可以遷移到其他邏輯當中。最後貼出本文的程式碼,但由於眾所周知的原因,模型和測試圖片無法提供,程式碼僅供參考。且因iOS的framework檔案過大,也沒有被上傳,可以根據上述流程自行整合。

本文Example:xcyoung/ncnn-example