Android 推理示例

ShuffleNet V2 ARM-Android 示例快速入门

这是一个简单的图像分类应用,基于 MegEngine C++接口、Android JNI及Camera API,帮助大家快速在Android平台实现一个图像分类的App。 在这个例子中所使用的模型,为 MegEngine 官方预训练的 ShuffleNet V2模型 ,用于做简单的图像分类任务。

1. 安装MegEngine python库

按照MegEngine的安装提示,完成python库的安装

  • 通过包管理器 pip 安装 MegEngine,将MegEngine加入到python包中

    pip3 install megengine -f https://megengine.org.cn/whl/mge.html
    

2. 下载MegEngine的代码仓库

我们需要使用 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的交叉编译:

  1. 到Android的官网下载NDK的相关工具,这里推荐 android-ndk-r21 以上的版本: NDK下载

  2. 在bash中设置 NDK_ROOT 环境变量:export NDK_ROOT=NDK_DIR

  3. 使用以下脚本进行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

3. 准备预训练模型

想要使用MegEngine C++ API 来加载模型,我们还需要做一些准备工作

  1. 获取基于python接口预训练好的神经网络

  2. 将基于动态图的神经网络转换成静态图后,再转换成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

4. ShuffleNet V2 C++ 实现示例

基于官方的 xor net C++ 案例 xor net 部署 ,我们可以实现自己的基于 ShuffleNet V2 的推理代码。 代码的任务分成四步:

  1. 参考官网对于 ShuffleNet V2模型 要求, 需要先将图像数据转换为指定格式的tensor

  2. 将转换好的数据输入到模型的输入层

  3. 调用MegEngine C++接口,实现推理过程

  4. 将模型的预测结果进行解析,并打印出来

4.1. 将图像数据转换成tensor张量

在前面章节,我们在将PKL文件转换成mge模型的时候,为了计算图的全流程,我们是给模型的input层填充了一些随机数据。 现在需要将真实的图像数据填充到input层,以完成对图像的推理。在这个例子中,模型要求的输入数据为 CHW:3*224*224。 根据 ShuffleNet V2模型 的说明,我们需要对图像做以下的预处理

  1. 将图像格式转换为 BGR

  2. 先将图像缩放到 256*256,避免在后续的裁切中有更多的信息损失

  3. 将图像中心裁切到 224*224 的大小,保留ROI区域,并适配模型输入要求

  4. 将裁切后的图像做归一化处理, 根据 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 为例:

  1. 宽高 resize到 256*256

  2. 中心裁切为 224*224

  3. 对图像做归一化处理

代码片段:

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);
}

4.2. 将转换好的图像数据传给 input 层

  1. 原始图像数据格式是 ‘HWC’, 需要转成模型需要的 ‘CHW’ 数据格式。HW表示宽高,C表示通道数

  2. ‘CHW’ 是 ‘NCHW’ 的子集, N表示batch size

  3. 以下是一个转换的参考示例代码:

代码片段:

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的推理接口,对输入图像数据进行预测。

4.3. 调用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 的以下文件中:

调用MegEngine 推理接口的完整代码可以参考:C++ 推理代码

接下来,我们来看看如何做ARM-Android的动态库封装,以使我们的Android应用程序可以正常调用推理接口。

5. C++ Shufflenet SDK封装

基本了解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中的动态库

5.1. 设计API,提取公共流程代码为单独函数

推理过程主要有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;
}

5.2. 交叉编译动态库和测试程序

代码准备好之后,我们使用CMake构建动态库和测试程序。

最终install目录下的文件

install/
├── cat.jpg
├── libmegengine.so
├── libshufflenet_inference.so
├── shufflenet_deploy.mge
└── shufflenet_loadrun

5.3. 测试验证

推送相关文件到手机运行验证功能。

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"

测试图片

../_images/cat1.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

6. Android Camera 预览实时推理

在这个章节,我们来看一下如何使用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

6.1. 打包APP使用的资源文件

这里我们只需要将json文件和model 文件直接放到app的assets 目录即可, APP在构建的时候会自动将该目录的文件打包到apk

6.2. 将APP依赖的JNI及动态库打包成aar module

我们将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目录)

6.3. 实现Java interface及JNI的调用

我们定义一个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

6.4. 实现JNI interface及libshufflenet_inference的调用

JNI interface主要是衔接Java interface和shufflenet interface, 也就是将Java 传递到native的参数转成shufflenet interface 可以识别的参数,完成shufflenet interface的调用。 其中就包含了YUV420_888转BGR的逻辑.

JNI 参考代码:inference jni 参考代码

6.5. 获取Camera Preview帧数据,完成推理

透过前面内容,我们已经封装出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 参考代码

6.6. 演示

经过前面实现,我们就可以build APP了。构建完成后, 我们就可以得到一个apk文件, 可以安装到手机来测试并继续优化了。

../_images/inference_demo.png

7. 量化部署

MegEngine 也可以采用量化的模型在ARM-Android上进行部署,部署过程和本文的上述4-7章完全一致。 推理接口可以支持int8或fp32的模型部署。 具体量化模型的训练和dump方法可以参考github上的指导: 模型量化 Model Quantization