前言
事情是这样的,最近有算法老哥找我做移动端ncnn模型的对接工作。对接模型的逻辑已经很久没有看过了,我又重新看起了项目的祖传代码。由于每次弄这一块时都有可能踩重复的坑,所以打算用一篇文章记录一下大概的接入步骤。因为属于应用层的事情,只能大概介绍一下流程,细节部分视具体场景而定。
本文会涉及到移动端原生开发对接native层
(C/C++)的逻辑,包括iOS和Android。有需要的同学可以按目录结构阅读。
环境准备
接入ncnn时,我们需要集成关于ncnn库。由于是native层作图像识别,这里还会用到OpenCV(ps:这个只是因为OpenCV提供各式各样的api,可以进行图像处理的扩展,可视真实场景添加)。
本文用到的
ncnn、OpenCV版本
OpenCV – 3.4.5
本文的ncnn和OpenCV都会选择官方已经编译好的动态库
- ncnn
- OpenCV
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 ¶mPath, 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文件作为模型加载的必要文件。 - 前处理和识别/检测:
- 首先会利用
cv::Mat指向的那块内存地址传递给ncnn::Mat,然后进行resize成224 * 224。 - 进行识别/检测。
- 最后拿到的
cls_scores为最终的结果,后续根据这个结果进行后处理。
- 首先会利用
- 后处理:后处理要视具体业务而定,这里就不给出了。
基于上述的C++代码,头文件声明为:
void init(const std::string ¶mPath, const std::string &binPath);
int interface(const cv::Mat &bgr);
ps:上述的C++代码文件被命名为Reco.h、Reco.cpp。
Android接入ncnn
环境集成
ps:本文只支持armeabi-v7a、arm64-v8a两个架构。
-
OpenCV集成
-
ncnn集成
如上图,将
OpenCV和ncnn的动态库及头文件复制到项目的对应目录。
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。
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++代码的桥接层,后面两个方法,nativeInit和nativeInterface分别对应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
环境集成
如上图,将下载的OpenCV及ncnn相关的
framework复制到项目中自定义的一个framework目录,在将目录引用到Project当中。最后形成的依赖项:
ps:如果出现xxx.h not found的情况,可以考虑将其framework中的Headers目录添加到Build Settings的Header Search Paths中。
Objective-c桥接层
在iOS中调用C++代码就简单很多了,我们只需要实现一个Objective-c代码对接C++代码。如果上层使用的是Swift,只需要进行Objective-c和Swift的桥接即可。
定义一个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,原理是利用原生的CGContextDrawImage将UIImage的内容绘制一份到新建的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