这是一个简单的图像分类应用,基于 MegEngine C++接口、Android JNI及Camera API,帮助大家快速在Android平台实现一个图像分类的App。 在这个例子中所使用的模型,为 MegEngine 官方预训练的 ShuffleNet V2模型 ,用于做简单的图像分类任务。
按照MegEngine的安装提示,完成python库的安装
通过包管理器 pip 安装 MegEngine,将MegEngine加入到python包中
pip3 install megengine -f https://megengine.org.cn/whl/mge.html
我们需要使用 C++ 环境进行最终的部署,所以这里还需要通过源文件来编译安装 C++ 库。
git clone https://github.com/MegEngine/MegEngine.git
MegEngine的依赖组件都位于 third_party 目录下,请在有网络支持的条件下,使用如下脚本进行安装。
./third_party/prepare.sh ./third_party/install-mkl.sh
MegEngine可以支持多平台的交叉编译,可以根据官方指导文档选择不同目标的编译。 对这个例子来说,我们选择ARM-Android的交叉编译。
在ubuntu(16.04/18.04)上进行 ARM-Android的交叉编译:
到Android的官网下载NDK的相关工具,这里推荐 android-ndk-r21 以上的版本: NDK下载
在bash中设置 NDK_ROOT 环境变量:export NDK_ROOT=NDK_DIR
使用以下脚本进行ARM-Android的交叉编译:
./scripts/cmake-build/cross_build_android_arm_inference.sh
编译完成后,我们可以在 build_dir/android/arm64-v8a/Release/install 目录下找到编译生成的库文件和相关头文件。 这时,可以检查一下生成的库是否对应目标架构:
file build_dir/android/arm64-v8a/Release/install/lib64/libmegengine.so #libmegengine.so: ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, BuildID[sha1]=xxxxx, stripped
想要使用MegEngine C++ API 来加载模型,我们还需要做一些准备工作
获取基于python接口预训练好的神经网络
将基于动态图的神经网络转换成静态图后,再转换成MegEngine C++ API可以加载的 mge文件
官方 MegEngine ModelHub 提供了多种预训练模型,以及基于python对这些模型进行训练、推理的指导文档。 通过这些指导文档,我们就可以大体了解训练和推理的基本过程。
接下来,通过以下python代码基于动态图的神经网络,实现动态图到静态图的转换并dump出可供C++调用的文件。
代码片段:
import megengine.hub import megengine.module as M import megengine.functional as F from megengine.jit import trace from megengine import tensor import numpy as np if __name__ == '__main__': net = megengine.hub.load("megengine/models", "shufflenet_v2_x1_0", pretrained=True) net.eval() @trace(symbolic=True, capture_as_const=True) def fun(data,*, net): pred = net(data) pred_normalized = F.softmax(pred) return pred_normalized data = np.random.random([1, 3, 224, 224]).astype(np.float32) fun(tensor(data), net=net) fun.dump("shufflenet_deploy.mge", arg_names=["data"])
执行脚本,并完成模型转换后,我们就获得了可以通过 MegEngine C++ API 加载的预训练模型文件 shufflenet_deploy.mge。
这里需要注意,dump函数定义了input 为 “data”,在后续使用推理接口传入数据时,需要保持名称一致。 另外,dump参数 “optimize_for_inference=True” 可以对dump出的模型进行优化,具体信息可以参考 dump optimize API
dump optimize API
基于官方的 xor net C++ 案例 xor net 部署 ,我们可以实现自己的基于 ShuffleNet V2 的推理代码。 代码的任务分成四步:
参考官网对于 ShuffleNet V2模型 要求, 需要先将图像数据转换为指定格式的tensor
将转换好的数据输入到模型的输入层
调用MegEngine C++接口,实现推理过程
将模型的预测结果进行解析,并打印出来
在前面章节,我们在将PKL文件转换成mge模型的时候,为了计算图的全流程,我们是给模型的input层填充了一些随机数据。 现在需要将真实的图像数据填充到input层,以完成对图像的推理。在这个例子中,模型要求的输入数据为 CHW:3*224*224。 根据 ShuffleNet V2模型 的说明,我们需要对图像做以下的预处理
将图像格式转换为 BGR
先将图像缩放到 256*256,避免在后续的裁切中有更多的信息损失
将图像中心裁切到 224*224 的大小,保留ROI区域,并适配模型输入要求
将裁切后的图像做归一化处理, 根据 ModelHub 上的说明,这里用到的 mean 和 std 为:
mean: [103.530, 116.280, 123.675]
std: [57.375, 57.120, 58.395]
关于图像转换的步骤,可以参考 inference.py 中的原始代码片段:
transform = T.Compose( [ T.Resize(256), T.CenterCrop(224), T.Normalize( mean=[103.530, 116.280, 123.675], std=[57.375, 57.120, 58.395] ), # BGR T.ToMode("CHW"), ] )
具体到 C++ 代码的实现,也同样分成三步,我们以 OpenCV 为例:
宽高 resize到 256*256
中心裁切为 224*224
对图像做归一化处理
constexpr int RESIZE_WIDTH = 256; constexpr int RESIZE_HEIGHT = 256; constexpr int CROP_SIZE = 224; void image_transform(const cv::Mat& src, cv::Mat& dst){ cv::Mat tmp; cv::Mat tmp2; // resize cv::resize(src, tmp, cv::Size(RESIZE_WIDTH, RESIZE_HEIGHT), (0, 0), (0, 0), cv::INTER_LINEAR); //center crop const int offsetW = (tmp.cols - CROP_SIZE) / 2; const int offsetH = (tmp.rows - CROP_SIZE) / 2; const cv::Rect roi(offsetW, offsetH, CROP_SIZE, CROP_SIZE); tmp = tmp(roi).clone(); //normalize tmp.convertTo(tmp2, CV_32FC1); cv::normalize(tmp2, dst, 0, 1,cv::NORM_MINMAX, CV_32F); }
原始图像数据格式是 ‘HWC’, 需要转成模型需要的 ‘CHW’ 数据格式。HW表示宽高,C表示通道数
‘CHW’ 是 ‘NCHW’ 的子集, N表示batch size
以下是一个转换的参考示例代码:
auto data = network.tensor_map.at("data"); data->resize({1,3,224,224}); auto iptr = data->ptr<float>(); auto iptr2 = iptr + 224*224; auto iptr3 = iptr2 + 224*224; auto imgptr = dst.ptr<float>(); // 给输入 Tensor 赋值 for (size_t j =0; j< 224*224; j++){ iptr[j] = imgptr[3*j]; iptr2[j] = imgptr[3*j +1]; iptr3[j] = imgptr[3*j +2]; }
Note
注意,此处网络的输入层名称为“data”,需要和第3节中dump时传入的名称保持一致。
完成数据格式转换后,调用MegEngine的推理接口,对输入图像数据进行预测。
// 读取通过运行参数指定的模型文件,inp_file 需要输入的shufflenet_v2.mge文件 std::unique_ptr<serialization::InputFile> inp_file = serialization::InputFile::make_fs(argv[1]); // 使用 GraphLoader 将模型文件转成 LoadResult,包括了计算图和输入等信息 auto loader = serialization::GraphLoader::make(std::move(inp_file)); serialization::GraphLoadConfig config; serialization::GraphLoader::LoadResult network = loader->load(config, false); // 参考上一节代码,将图像数据输入input layer // 将网络编译为异步执行函数 // 输出output_var为一个字典的列表,second拿到键值对中的值,并存在 predict 中 HostTensorND predict; std::unique_ptr<cg::AsyncExecutable> func = network.graph->compile({make_callback_copy( network.output_var_map.begin()->second, predict)}); func->execute(); func->wait(); float* predict_ptr = predict.ptr<float>();
推理函数执行完毕后,会通过回调函数 make_callback_copy 将结果保存在 predict中,predict的类型为:
HostTensorND predict;
我们可以通过打印函数来确认predict 的shape(1,1000)和dimension(2):
//shape predict.shape() //dimension predict.shape().ndim
对于 ShuffleNet V2 这个case来说,num_class 也即是 类别数 保存在:
predict.shape(1)
根据类别数量,可以以此打印出每个类别的confidence,根据预设的阈值THRESHOLD,打印出高于阈值的类别。confidence最高的类别就是此次预测的 top1 结果:
for (int i = 0; i < num_classes; i++){ sum += predict_ptr[i]; if (predict_ptr[i] > THRESHOLD) std::cout << " Predicted: " << predict_ptr[i] << " i: "<< i << std::endl; }
如果更进一步,我们还可以将label文件进行解析,并对照predict结果输出具体预测的类别。 对于这个示例,label信息保存在 MegEngine Model 的以下文件中:
imagenet_class_info.json
调用MegEngine 推理接口的完整代码可以参考:C++ 推理代码 。
接下来,我们来看看如何做ARM-Android的动态库封装,以使我们的Android应用程序可以正常调用推理接口。
基本了解C++推理过程后,我们接着将相关通用过程封装为SDK动态库,提供API给主程序使用,方便后面通过JNI部署到Android APP上。 主要有如下过程:
设计API并实现API功能。
交叉编译动态库。
测试验证。
JNI 整体的目录结构设计如下:
. inference_jni //shufflenet 子模块,提供java 和jni interface,并包含megengine动态库 ├── build.gradle └── src └─── main ├── AndroidManifest.xml ├── cpp │ ├── CMakeLists.txt │ ├── inference_jni.cpp │ └── native_interface │ ├── build_inference.sh │ ├── CMakeLists.txt │ ├── prebuilt //构建native shuffletnet interface需要使用的动态库 │ │ ├── megengine //MegEngine 动态库及相关头文件 │ │ └── opencv2 //图像处理需要使用的opencv库及相关头文件 │ ├── src //Shufflenet SDK interface实现 │ │ ├── inference_log.h │ │ ├── shufflenet_interface.cpp │ │ ├── shufflenet_interface.h │ │ └── shufflenet_run.cpp //shuffleNet可执行文件源码 │ └── third_party │ └── cJSON-1.7.13 //解析json需要用到的cjson, 源码编译 ├── java │ └── com │ └── example │ └── inference //java shuffletnet interface定义和实现类 │ └── ImageNetClassifier.java └── jniLibs //最终会打包到aar中的动态库
推理过程主要有init, recognize和close三步,将其分别封装为API,其他函数则作为动态库的static函数内部使用。
头文件shufflenet_interface.h代码片段:
typedef void *ShuffleNetContext_PTR; ShuffleNetContext_PTR PUBLIC_API shufflenet_init(const ModelInit &init); void PUBLIC_API shufflenet_recognize(ShuffleNetContext_PTR sc, const FrameData &frame, int number, FrameResult *results, int *output_size); void PUBLIC_API shufflenet_close(ShuffleNetContext_PTR sc);
动态库主体shufflenet_interface.cpp 参考代码: shufflenet interface 代码
主程序的代码就相对比较简单了。
测试程序shufflenet_loadrun.cpp代码片段:
#include "shufflenet_interface.h" using namespace std; int main(int argc, char *argv[]) { if (argc != 3) { std::cout << " Wrong argument" << std::endl; return 1; } //BGR cv::Mat bgr_ = cv::imread(argv[2], cv::IMREAD_COLOR); fprintf(stdout, "pic %dx%d c%d\n", bgr_.cols, bgr_.rows, bgr_.elemSize()); vector<uint8_t> models; //读取模型文件 readBufFromFile(models, argv[1]); fprintf(stdout, "======== model size %ld\n", models.size()); int num_size = 5; int output_size = 0; FrameResult f_results[5]; //初始化shufflenet interface ShuffleNetContext_PTR ptr = shufflenet_init({.model_data = models.data(), .model_size = models.size(), .json = IMAGENET_CLASS_INFOS, .limit_count = 1, .threshold=0.01f}); if (ptr == nullptr) { fprintf(stderr, "fail to init model\n"); return 1; } //调用识别接口 shufflenet_recognize(ptr, FrameData{.data = bgr_.data, .size = static_cast<size_t>(bgr_.rows * bgr_.cols * bgr_.elemSize()), .width = bgr_.cols, .height = bgr_.rows, .rotation = ROTATION_0}, num_size, f_results, &output_size); for (int ii = 0; ii < output_size; ii++) { printf("output result[%d] Label:%s, Predict:%.2f\n", ii, (f_results + ii)->label, (f_results + ii)->accuracy); } printf("test done!"); //销毁shufflenet handle shufflenet_close(ptr); return 0; }
代码准备好之后,我们使用CMake构建动态库和测试程序。
构建的启动脚本参考 build inference 脚本
CMake构建脚本参考 libshufflenet_inference CMake 构建脚本
最终install目录下的文件
install/ ├── cat.jpg ├── libmegengine.so ├── libshufflenet_inference.so ├── shufflenet_deploy.mge └── shufflenet_loadrun
推送相关文件到手机运行验证功能。
adb shell "rm -rf /data/local/tmp/mge_tests" adb shell "mkdir -p /data/local/tmp/mge_tests" files_=$(ls ${NATIVE_SRC_DIR}/install) for pf in $files_ do adb push ${NATIVE_SRC_DIR}/install/$pf /data/local/tmp/mge_tests/ done
执行命令行示例
adb shell "chmod +x /data/local/tmp/mge_tests/shufflenet_loadrun" && adb shell "cd /data/local/tmp/mge_tests/ && LD_LIBRARY_PATH=./ ./shufflenet_loadrun ./shufflenet_deploy.mge ./cat.jpg"
测试图片
执行测试程序后,我们可以从标准输出获得predict的结果:
# 阈值设置为0.01f ========output size 5 ========output result[0] Label:Siamese_cat, Predict:0.55 ========output result[1] Label:Persian_cat, Predict:0.05 ========output result[2] Label:Siberian_husky, Predict:0.03 ========output result[3] Label:tabby, Predict:0.03 ========output result[4] Label:Eskimo_dog, Predict:0.03
在这个章节,我们来看一下如何使用Android Camera做实时推理 我们可以基于 Android Camera Example github 修改,快速搭建我们的APP。
主要有如下过程:
将labels json文件和Model文件以assets方式打包到APK
将libmegengine.so和libshufflenet_inference.so作为动态库打包到APK
使用shufflenet interface实现JNI interface
获取Android Camera Preview数据, 经由JNI,最终送到MegEngine完成推理
app 的目录结构设计如下:
. app //Android Camera APP 目录 └── src └── main ├── AndroidManifest.xml ├── assets │ ├── imagenet_class_info.json │ └── shufflenet_deploy.mge └── java └── com └── example └── android └── camera2basic ├── AutoFitTextureView.java ├── Camera2BasicFragment.java └── CameraActivity.java
这里我们只需要将json文件和model 文件直接放到app的assets 目录即可, APP在构建的时候会自动将该目录的文件打包到apk
我们将APP依赖的功能相关的逻辑抽离出来,作为一个独立module打包成aar并添加到app依赖项中。我们来看一下构建脚本 APP添加inference_jni依赖项
implementation project(path: ':inference_jni')
在inference_jni gradle配置Java和jni的编译选项, 这里我们选择只是构建arm64-v8a,如需要armeabi-v7a, 可以在abiFilters添加即可
defaultConfig { minSdkVersion 27 targetSdkVersion 28 versionCode 1 versionName "1.0" consumerProguardFiles 'consumer-rules.pro' externalNativeBuild { cmake { abiFilters 'arm64-v8a' arguments "-DANDROID_ARM_NEON=TRUE", "-DANDROID_STL=c++_static" cppFlags "-frtti -fexceptions" } } } externalNativeBuild { cmake { path "src/main/cpp/CMakeLists.txt" } }
inference jni构建脚本示例参考: inference jni CMake 构建脚本 这里会生成Java interface会加载的动态库inference-jni。 inference-jni以动态链接方式链接前面章节实现的libshufflenet_inference.so(已经预置放到jniLibs目录)
我们定义一个Java class:ImageNetClassifier。 该类关键函数如下功能:
Create为工厂函数,用来实例化ImageNetClassifier并初始化jni interface(对应前文的shufflenet_init)
prepareRun里实现加载动态库libinference-jni.so
recognizeYUV420Tp1,推理函数(对应前文的shufflenet_recognize),并返回Top1
close,销毁jni handle(对应前文的shufflenet_close)及当前classifier对象
ImageNetClassifier 参考代码:ImageNetClassifier
JNI interface主要是衔接Java interface和shufflenet interface, 也就是将Java 传递到native的参数转成shufflenet interface 可以识别的参数,完成shufflenet interface的调用。 其中就包含了YUV420_888转BGR的逻辑.
JNI 参考代码:inference jni 参考代码
透过前面内容,我们已经封装出Java的上层API,也即可以将camera的preview 数据直接送到Java API即可将整个流程串通。 大家可以自行选择使用Camera API,还是Camera API2来获取预览数据,API使用上会有些许差异,本章节我们使用主流的API2来演示。
流程可以简化为: * 创建一个格式为YUV420_888的ImageReader并设置为Camera Preview的Surface,然后开启预览。 * 在ImageReader收到预览帧数据后,我们就可以将帧数据post到后台线程并调用classifier.recognizeYUV420Tp1, * 在jni完成YUV转BGR后送到Shufflenet interface,最终送到MegEngine完成推理。 * 在inference结果返回后,就可以在UI Thread 实时更新推理结果。
配置Camera预览的参考代码:Camera preview 参考代码
经过前面实现,我们就可以build APP了。构建完成后, 我们就可以得到一个apk文件, 可以安装到手机来测试并继续优化了。
MegEngine 也可以采用量化的模型在ARM-Android上进行部署,部署过程和本文的上述4-7章完全一致。 推理接口可以支持int8或fp32的模型部署。 具体量化模型的训练和dump方法可以参考github上的指导: 模型量化 Model Quantization