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

8,338 阅读7分钟

前言

事情是这样的,最近有算法老哥找我做移动端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:由于此部分不是笔者专业,本文仅根据接触过的提供一个示例。)

#include <cstdio>
#include "Reco.h"
#include <opencv2/highgui/highgui.hpp>
#include <ncnn/platform.h>
#include <ncnn/net.h>

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<float> &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<float> &cls_scores) {
    return 0;
}

int interface(const cv::Mat &bgr) {
    std::vector<float> cls_scores;
    if (detect(bgr, cls_scores) == 0) {
        return 0;
    }
    process(cls_scores);
    return 0;
}

主要分为3部分:

  • 模型加载(初始化):ncnn模型需要提供一个bin文件和一个param文件作为模型加载的必要文件。
  • 前处理和识别/检测
    1. 首先会利用cv::Mat指向的那块内存地址传递给ncnn::Mat,然后进行resize224 * 224
    2. 进行识别/检测
    3. 最后拿到的cls_scores为最终的结果,后续根据这个结果进行后处理。
  • 后处理:后处理要视具体业务而定,这里就不给出了。

基于上述的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_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中加入
externalNativeBuild {
    cmake {
        path "src/main/cpp/CMakeLists.txt"
    }
}
  • android.defaultConfig中加入
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层。直接上代码:

#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类:

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代码

@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