模型部署从0到1

Posted by kevin on December 10, 2021

preface

由于项目需要,kevin 要将模型放到手机里面去测试速度,于是乎写了这篇文章,记录这个过程。本来想尝试 ncnn 进行部署,然而流程有些复杂,于是乎在师兄的建议下先用 PyTorch 官方的 Mobile 模块试试,GitHub 仓库里面有很多详细的 demo 展示,直接 clone 下来就行了。

装包配环境

众所周知,将模型放到手机中去测试速度的话呢,肯定得先搞个 APP 出来,目前有安卓开发和 IOS 开发,比较普遍的是安卓开发,因为可以用 JAVA 作为开发语言,IOS 开发的话还需要一个 MAC 笔记本才能做这事,金钱门槛比较高,并且用的也是 Swift 语言,受众比较少。这里我们选择 Android 应用。首先直接安装 Android Studio,安装的过程很省事,并且会将安卓开发需要的两个环境: SDK 和 NDK 都安装好。不过得看网络快不快,毕竟下载的库都在国外,可能会出现错误。

下载完之后就导入项目, PyTorch 官方提供了教程合集,链接在下面,kevin 使用了 PyTorchDemoApp 这个项目进行操作。

https://github.com/pytorch/android-demo-app.git

在 Gradle 文件夹中有个 gradle_wrapper.properties 文件,似乎每次导入项目都会根据里面提到 gradle 的版本去下载 gradle,大大拖慢时间,后续尝试一下把里面的 distributionUrl 给注释掉 (不能注释,建议改成低一点能成功编译的版本)

#Thu Nov 19 15:33:02 PST 2020
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
# distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip

按照网上的教程来说的话,直接点击绿色的锤子开始编译,然后将手机通过 USB 线连接到电脑之后点击绿色三角形就可以在真机上进行操作了,然后这些教程就没有后续了,大概率都是抄来抄去的,kevin 在搞的时候就遇到了很多的麻烦,包括但不限于:我的绿色锤子是灰色的。

1

大多数麻烦都来自配置 Android Studio 环境,各种报错。配置 Android Studio 的具体步骤我已经记不起来了,这里说几个我还记得的错误(怪不得网上的教程到这一步直接就跳过了,因为 Android Studio 的环境确实难搞,很多写博客的我估计他自己压根没有自己尝试过这一步就瞎几把写)。

首先,Gradle 这个东西应该是 AS 里面的一种插件之类的吧,我发现每次新建一个项目他都会给我重新下载一个 Gradle,我暂时不知道这是在干什么的,比较重要的是,我们的 Gradle 的版本是比较重要的,有些版本是不兼容的,搞起来就非常麻烦。每一个项目都有两个 build.gradle 文件,一个在根目录,一个在 app 文件夹里面。我们一般要更改的是根目录下的 build.gradle。AS 自己下载好 Gradle 之后,一般来说,上方的锤子就会变绿,并且会有一个安卓图标的 app 配置在右边。但是一般情况下直接编译的话是会报错的,会说类似如下的东西。

Minimum supported Gradle version is 6.1.1. Current version is 5.6.2.

经过一番心态爆炸之后我才知道 Android Gradle 插件与 Gradle 版本是有对应关系的,我们得下载对应版本的插件?不然会报错,CNM

AS 中 Gradle 插件版本 所需的 Gradle 版本
1.0.0 - 1.1.3 2.2.1 - 2.3
1.2.0 - 1.3.1 2.2.1 - 2.9
1.5.0 2.2.1 - 2.13
2.0.0 - 2.1.2 2.10 - 2.13
2.1.3 - 2.2.3 2.14.1+
2.3.0+ 3.3+
3.0.0+ 4.1+
3.1.0+ 4.4+
3.2.0 - 3.2.1 4.6+
3.3.0 - 3.3.3 4.10.1+
3.4.0 - 3.4.3 5.1.1+
3.5.0 - 3.5.4 5.4.1+
3.6.0 - 3.6.4 5.6.4+
4.0.0+ 6.1.1+
4.1.0+ 6.5+

我们可以在 File - Project Structure 里面看看我们的配置,跟上面的表不对应的话就说明我们要改一下这个东西

image.png

那么在哪里改呢,在根目录下的 build.gradle 里面改,改完之后重新编译一下,不出意外的话又会出错,接下去我们看看又出了什么勾八问题

    dependencies {
        classpath 'com.android.tools.build:gradle:3.5.2' // 草他🐎的比,这里的插件版本跟环境里的版本不一样
    }

这下报的是这个错

No toolchains found in the NDK toolchains folder for ABI with prefix: arm-linux-androideabi

字面意思,说我们的 NDK 缺少了一个编译链工具,然后去找的时候发现是存在的,并没有缺少,又是一通心态爆炸之后我在 StackOverflow 找到一个答案,说是 NDK 版本太高了,需要降成低版本的就行了,我看了一下我的版本是 23.x 的,重新在 AS 里面安装了一个 20.x 的(最好在 AS 里面安装,不要自己装,到时候还要解压之类的很麻烦),按照我下面给的步骤就可以重装了,大概十分钟左右

image.png

然后我们需要去 Project Structure 里面配置 NDK 的路径,使改动生效。

image.png

如果 SDK 和 NDK 都装好的话在项目根目录的 local.properties 中就会出现具体的路径

sdk.dir=C\:\\Users\\kevin\\AppData\\Local\\Android\\Sdk
ndk.dir=C\:\\Users\\kevin\\AppData\\Local\\Android\\Sdk\\ndk\\20.1.5948944

然后再次点击绿色小锤子进行编译,TMD,成功编译!!!

然后将手机连到电脑,调成开发者模式,打开 USB 调试开关,AS 就能够识别到设备了,然后点击绿色三角形进行打包,成功的话编译完的 apk 将会导入到手机中,我们只需要安装就行了,但是又出错了,具体忘了,但是是一个 NDK 的错误,但是明明我们已经安装了正确的 NDK 了,这时 kevin 又通过 Google 找到了答案,我们这次要改 app/build.gradle,将里面的 NDK 版本改成我们的版本

android {
	...
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    ndkVersion '20.1.5948944'
}

然后,终于 OK 了,官方 demo 到此就可以在手机上运行了,第一步大功告成!

通过分割 demo 介绍部署

按照教程直接下载权重的话会出现问题 file bytecode.pkl: file not found () ,模型在 Netron 里面也打不开,后来在官网上发现咱们还少了一个步骤

If you see the error message: PytorchStreamReader failed locating file bytecode.pkl: file not found (), likely you are using a torch script model that requires the use of the PyTorch JIT interpreter (a version of our PyTorch interpreter that is not as size-efficient). In order to leverage our efficient interpreter, please regenerate the model by running: module._save_for_lite_interpreter(${model_path}).

  • If bytecode.pkl is missing, likely the model is generated with the api: module.save(${model_psth}).
  • The api _load_for_lite_interpreter(${model_psth}) can be helpful to validate model with the efficient mobile interpreter.

他说我们这个是 TorchScript 模型,还要针对移动端进行优化,彳亍,那我就继续往下走,根据官方代码冲

import torch
from torch.utils.mobile_optimizer import optimize_for_mobile
model = torch.jit.script('deeplabv3_scripted.pt')

optimized_scripted_module = optimize_for_mobile(scripted_module)
optimized_scripted_module._save_for_lite_interpreter("deeplabv3_scripted.ptl")

然后又报错了,说什么 Objects is bot supported ... ,后来又是一番查找,发现这个模型可能是用高版本的 PyTorch 训练出来的,不兼容,于是我又将我的 PyTorch 升级到了最新的 0.10.0 版本,这次就可以了,最终会在根目录生成三个模型,我们要的是最后一个经过优化过的模型

models

然后就可以了!之前一直是因为模型的问题导致一打开应用就闪退,还好 AS 看日志也比较方便,通过 Log.e(msg) 输出错误信息,然后我们在下方的视窗中就可以定位到是什么错误了

error

然后发现官方的例子中已经对步骤都讲的特别详细了,我就不再脱裤子放屁了,建议直接看官方的教程,我在这里简单讲一下 AS 开发项目的一个主要模块,一般我们东西都在 app 文件夹中写,编译成功之后会生成一个 build 文件夹,里面放置了编译文件以及生成的 apk 文件。src 里面是我们的主要代码与逻辑,main/assets 里面装着需要用到的素材,main/java 里面是主要控制代码,是用 JAVA 写的,我们需要在里面实现所有的功能。 main/res 里面是一些布局之类的,layout 里面是整个 UI,点进去的话会出现 QT designer 一样的控件页面,可以拖拽,其他的没啥讲的,我们主要是看 main/java 里面的代码

.
├── app
│   ├── build
│   └── src
│       └── main
│           ├── assets
│           ├── java
│           │   └── org
│           │       └── pytorch
│           │           └── imagesegmentation
│           └── res
│               ├── drawable
│               ├── drawable-v24
│               ├── layout
│               ├── mipmap-hdpi
│               ├── mipmap-mdpi
│               ├── mipmap-xhdpi
│               ├── mipmap-xxhdpi
│               ├── mipmap-xxxhdpi
│               └── values
└── gradle

由于官方讲的很详细了,我这里就只记录一下一些主要函数,不管是不是移动端,推理都要进行下面这些步骤,只不过这里用的是 JAVA 实现

Bitmap bitmap = BitmapFactory.decodeStream(getAssets().open("image.jpg"));	// 读取素材
Bitmap bitmap = BitmapFactory.decodeStream(getAssets().open("image.jpg"));	// 导入模型
Tensor inputTensor = TensorImageUtils.bitmapToFloat32Tensor(bitmap,
    TensorImageUtils.TORCHVISION_NORM_MEAN_RGB, TensorImageUtils.TORCHVISION_NORM_STD_RGB);	// 预处理图片
Tensor outputTensor = module.forward(IValue.from(inputTensor)).toTensor();	// 前向推理
float[] scores = outputTensor.getDataAsFloatArray();	// 得到结果

float maxScore = -Float.MAX_VALUE;
int maxScoreIdx = -1;
for (int i = 0; i < scores.length; i++) {
  if (scores[i] > maxScore) {
    maxScore = scores[i];
    maxScoreIdx = i;
  }
}
String className = ImageNetClasses.IMAGENET_CLASSES[maxScoreIdx];	// 处理结果

Android Studio 一些简单知识点

APP 打开时运行的第一个入口程序就是 onCreate,类似 main 函数,所以我们重点看这个函数干了啥。


当 implements 了 Runnable 的话,需要在类里面重载一个 run() 函数,这样实现 Runnable 这个类的话可以方便创建线程,这是 JAVA 知识。

public class MainActivity extends AppCompatActivity implements Runnable{
   
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mButtonSegment.setOnClickListener(new View.OnClickListener() {
            public void onClick(View v) {
                mButtonSegment.setEnabled(false);   // 按钮不让按了
                Thread thread = new Thread(MainActivity.this);  //创建一个线程去干活
                thread.start();
            }
        });
    }
    
    @Override
    public void run() {
		//do something 执行任务
        
        runOnUiThread(new Runnable() {	// 执行完毕,让主线程更新 UI
            @Override
            public void run() {

            }
        });
    }
}

上面这段例子是我从 PyTorch 官方的分割实例中找到的,很有代表性,首先在我们的 onCreate 函数中当我们点击按钮的时候,他会创建一个线程去执行任务,执行任务的内容就在 run() 里面,执行完了之后如果需要更新 UI 的话,用 runOnUiThread() 方法让主线程去更新。我在一个博客中找到的解释是这样的:

在开发 Android 应用的时候我们总是要记住应用主线程。

主线程非常繁忙,因为它要处理绘制 UI,响应用户的交互,默认情况下执行我们写下的大部分代码。

好的开发者知道他/她需要将重负荷的任务移除到工作线程避免主线程阻塞,同时获得更流畅的用户体验,避免 ANR 的发生。

但是,当需要更新 UI 的时候我们需要“返回”到主线程,因为只有它才可以更新应用 UI。

最常用的方式是调用 Activity 的 runOnUiThread() 方法:

模型转成 ONNX 格式代码记录

  1. 将 PyTorch 转成 onnx 的时候用 NetRon 看模型的图结构时会很复杂, 因为 onnx 可能将 PyTorch 中一个很简单的操作变成一堆复杂的操作,比如会将 permute 操作变成一堆 gather, 不是很方便部署,所以我们可以用 onnx-simplifier 将模型的 op 给简化一下,这样子的话我们看到的 op 就很直观了.
  2. 导出 onnx 格式时 opset_version 参数设置导出 op 的版本,太高版本的话不一定好,比如 11 会将 upsample 操作变成 resize,导致部署困难,而用 9 版本的话就不会有这个问题.
model = create_model()

wide = 416

input_names=['input']
output_names=['classification', 'regression']	# 根据自己的输出个数确定

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)

x = torch.randn((1, 3, wide, wide), requires_grad=True)

try:

    f = "kevin_ckpt/{}_{}_3_{}_{}.onnx".format(model_name, 1, wide, wide)

    torch.onnx.export(model, 
                    x, 
                    f, 
                    verbose=True,	# 是否输出每一层的信息
                    opset_version=11,
                    input_names=input_names,
                    output_names=output_names,
                    dynamic_axes=None)

    # Checks
    model_onnx = onnx.load(f)  # load onnx model
    onnx.checker.check_model(model_onnx)  # check onnx model

    model_onnx = onnx.load(f)  # load onnx model
    model_simp, check = simplify(model_onnx)
    assert check, "Simplified ONNX model could not be validated"
    onnx.save(model_simp, f"{f.split('.')[0]}_sim.onnx")
    print('finished exporting onnx')

except Exception as e:
    logging.info(f'{prefix} export failure: {e}')

reference

gradle插件与gradle版本对应表

python - urllib.error.HTTPError: HTTP Error 403: rate limit exceeded when loading resnet18 from pytorch hub - Stack Overflow

(beta) Efficient mobile interpreter in Android and iOS — PyTorch Tutorials 1.10.0+cu102 documentation

[Android PyTorch(必看!)](https://pytorch.org/mobile/android/#custom-build)

理解 Activity.runOnUiThread - 简书 (jianshu.com)

使用upsample时转onnx的注意事项