【上】[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知

本博客由于过长,分为上下集来展示,此博客也在CSDN https://blog.csdn.net/Zhaoxi_Li/article/details/127820841发布,欢迎各位点赞收藏哈哈。

去年6月份拿到开发板到现在,转眼已经过去大半年了,这个博客11月初就在写,断断续续写到现在。C++部署需要考虑的问题很多,如果只给个简单部署教程的话,就算整理出来,感觉帮助也不大,各位开发时候我遇到的坑,你们也会重新踩一遍。这段时间我一直在思考作为开发者需要的是什么,应该如何安全的使用一套工具,要以何种方式呈现出来,要如何将一件事情清晰的说清楚。草稿改改变变,最后决定以大流程的形式,从量化到C++部署,进行一遍完整的梳理,整理一套流程,让各位安全、稳定地操作BPU。


BPU部署有几个经典大坑,这些坑说白了就是流程不规范各位多少都会遇见,下面这两个比较常见的问题都会在后续的详解中梳理清楚。

  • 量化后的模型在BPU上得不到与Pytoch一样的结果。这种问题多数是因为模型推理的前后处理没做好检查,但凡一步不对都没法得到正确的结果。
  • 量化后的模型Python能推理出来,部署到C++就推理不出来了。大部分是数据没做对齐导致的,就这个问题卡了我好久,最后问了一圈人才发现是输入的图像是1x256x256x3,需要做对齐为1x256x256x4才能跑出正确结果。

之前写教程时候一直以Python为主,是因为其代码简洁,方便各位理解流程及原理。但这一段时间总会有人问我怎么用C++落地,文档不易理解总是部署失败。在开发社区里有人提供了一个Cpp的教程,《动手实践之一个文件实现分割、检测cpp代码部署》,但Demo不足以了解整个部署的流程与思想,因此我后面也规范了下C++部署模型的流程。值得注意的是,官方API以C语言分割为主,因此我也将相关函数用C++二次包装,这样可以更好的使用相关的API,轻松带各位理解BPU的C++部署方式。特别地,我参考《Effective C++》设计了一套开源库WDR,让各位不需要在C++部署上花费太多精力,这个在后面细说。

既然是大流程,想带给各位的就是“知其然,知其所以然,知何由以知其所以然”。所以本博客作为BPU部署教程三部曲中的最后一部,目的是将部署流程刻在心里,真正成为自己算法落地的一项有效工具。后续相关BPU教程主要以调优或者与一些设备联动为主。

??特别感谢晟哥、富哥、振兄、均兄和诺师弟的技术支持??

目录这些内容:部署导图、每个阶段的构建流程是非常关键的。其他的内容,可以当作字典来使用,遇到问题找对应的内容去分析研究。

本博客关联的文件存放在https://github.com/Li-Zhaoxi/OpenWanderary中,整个过程依赖/生产的数据存放在百度云(提取码:0a09 )中的文件夹OpenWanderary/projects/torchdnn/data/unet中,下载后直接复制到代码OpenWanderary对应位置即可。

部署校验流程导航

一 BPU部署&校验流程导图

首先,对于BPU部署,我希望每个开发人员都可以:

走一次就能部署成功,查一次就能定位问题

没有这个前提,就没有后面的一切。很多人在部署模型的时候,很难一下子就在BPU上成功启动,而且排查问题非常耗时,且麻烦。模型部署无非三个部分:模型文件、数据预处理、数据后处理。思路不难,工具链也不难用,我一直在尝试部署各种不同的模型,在思考到底是什么让模型部署变得这么复杂,而这个问题,实际上是最简单,也最容易忽略的,那就是部署规范化。规范化的目的就是解耦问题,在哪个步骤没通过质检,就说明这步骤是存在问题,直接focus这个地方修改bug即可,不用再回退前面的步骤去排查。

1.1 模型部署风险项

下面通过对比Pytorch和BPU的三个核心部分,来列出部署失败都有哪些风险项。PS:import torch占用较多内存,在开发板中不适合安装torch。

  • Pytorch推理存在三个部分:torch模型、基于torch的预处理、基于torch的后处理。
  • BPU推理存在核心三个部分:BIN模型,无torch的预处理,无torch的后处理。

基于这些,下面列出模型部署失败都有哪些可能性,一共9种潜在的问题,排查问题的成本较高。

  • 模型部分

  • torch模型转onnx模型是否无问题。

  • onnx模型转bin模型是否无问题。如果有问题

  • 是否是引入归一化节点导致的问题。

  • 是否是优化后的模型有问题。

  • 是否是量化后的模型就有问题。

  • 是否是只发生在X3开发板上推理才有问题。

  • 数据预处理部分

  • torch预处理转为无torch的预处理函数,可能存在问题。

  • 将无torch的预处理函数,剥离归一化参数,用模型量化过程中,校准数据的预处理,可能存在问题。

  • 将无torch的预处理函数,剥离归一化参数,用在X3开发板部署中,模型推理的预处理,可能存在问题。

  • 数据后处理部分

  • torch后处理转为无torch的后处理,可能存在问题。

所以,上面的风险,但凡一个发生了,模型在开发板上就不可能部署成功。因此,非常有必要将部署流程以及每一步的可靠性进行规范。

1.2 规范化部署导图

导图的目的是解除风险项质检的耦合,一步一步走踏实了,这样既可以快速定位问题,又可以安全可靠的一次就把事情做好。为了更好的理解这个大流程,请各位按序走完以下博客。做好Docker,理解何为模型转换,理解BPU在开发板的推理流程,利用提供的demo输出正确的结果。后面C++部署也是基于相似的推理流程。

规范化导图的设计改了多个版本,最终BPU整个部署大流程如下图所示,从Pytorch模型开始,到最终开发板BPU推理出目标结果为止,展示了整个环节需要处理的内容。整个流程看似节点较多,较为繁琐。但实际上,只需要重点关注绿色框相关内容,剩下部分就是固定化操作。在部署的整个流程中,切记要保持转换函数/模型的结果一致性,简单来说,在整个部署流程中,我们要保证预处理后处理函数,转换后的模型能够跟原始模型有一样的输出。

建议各位把这个图片保存到本地,对着后面的内容去理解,这样会更清晰哦~~~

二 流程导图详解

整个流程的演示我以医疗应用为背景,基于UNet完成训练、量化、部署整个流程。PS:之前想以细胞分割来展示,但是GT视觉效果比较密恐,所以找师弟要了一套看来舒服一点的医疗数据集重新训练更换效果图。

训练测试UNet我使用的指令如下,在projects/torchdnn目录下执行:

python train.py --task baseline --fold 0 --train-gpus 0 \
  --dataset=2DMRACerebrovascular --dataroot="D:/01 - datasets/008 - 2DMRACerebrovascular" \
  --resultroot="D:/01 - datasets/008 - 2DMRACerebrovascular/Experiments" \
  --train-batch-size=8 --train-workers=4 --name=unet

python test.py --task baseline --fold 0 --train-gpus 0 \
  --dataset=2DMRACerebrovascular --dataroot="D:/01 - datasets/008 - 2DMRACerebrovascular" \
  --resultroot="D:/01 - datasets/008 - 2DMRACerebrovascular/Experiments" 、
  --test-save-flag=true --name=unet

每个阶段我都会分为构造流程和校验流程,如果部署到板端之后,没有得到期望结果,可以参考校验流程去定位问题。这部分重点在于部署/校验的思想,对于其他类型的输入,或者混合类型的输入需要注意下用法。

2.1 阶段1 模型转换:ONNX模型、预/后处理函数的构建与校验

这个阶段需要输出三个关键项:ONNX模型、无torch依赖的预处理和后处理函数。对于ONNX的转换和推理方法,我在构造流程中给出了相关参考代码。

2.1.1 构建流程

① Pytorch转ONNX模型。代码细节见torch2onnx.py,在torchdnn的根目录下执行python demos/unet/torch2onnx.py。导出onnx的核心代码调用的是torch.onnx.export,转换细节参考如下代码:

import os
import sys
sys.path.append(os.getcwd())
import cv2
import numpy as np
import torch
from networks.unet import UNet
import onnx
# 1. 加载Pytorch模型
dataroot = "data/unet"
modelpath = os.path.join(dataroot, "checkpoint_0.pth.tar")
net = UNet(3, 2) # 定义模型,参数1表示输入图像是3通道,参数2表示类别个数
net = torch.nn.DataParallel(net).cpu() # ※这行代码不能删,否则模型参数无法加载成功
state_dict = torch.load(modelpath)
net.load_state_dict(state_dict["state_dict"]) # 把参数拷贝到模型中
net.eval()  # ※这个要有

# 2. 转换ONNX
onnxpath = os.path.join(dataroot, "unet.onnx") # 定义onnx文件保存目录
im = torch.randn(1, 3, 256, 256).cpu() # 定义输入变量,维度重要,内容不重要
# 下面是转onnx的基本配置,为了能够用在BPU上,按照下面这个方式配置参数即可
# 对于多输入输出的模型,参考连接:https://www.cnblogs.com/WenJXUST/p/16334151.html
# 下面这个Warning可以忽略不管
# Warning: Constant folding - Only steps=1 can be constant folded for opset >= 10 onnx::Slice op. Constant folding not applied.
torch.onnx.export(net.module,
                  im,
                  onnxpath,
                  verbose=False,
                  training=torch.onnx.TrainingMode.EVAL,
                  do_constant_folding=True,
                  input_names=['images'],
                  output_names=['output'],
                  dynamic_axes=None,
                  opset_version=11)

# 3. 检查ONNX,如果ONNX有问题,这里会输出一些日志
print('Start check onnx')
model_onnx = onnx.load(onnxpath)
onnx.checker.check_model(model_onnx)

② 构建无torch的预处理和后处理函数。深度学习模型输出前的处理和模型推理后的数据处理,难度并不大,这步构建一次即可,其他类似的需求也就是模型参数不同,构建后的预处理后处理记录在prepare_functions.py

对于数据输入的预处理,无非就是resize、归一化、换维度等等。在我这套代码里,数据预处理部分代码如下所示,关联的变换为图像Resize图像归一化HWC变换为CHW

import albumentations as A
from albumentations.pytorch import ToTensorV2
# 基于torch的数据预处理:输入维度[h,w,c],返回的数据排布为[c,h,w]
def preprocess_torch(img, modelh, modelw):
  transform = A.Compose([A.Resize(modelh, modelw),
              A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
              ToTensorV2()])
  return transform(image = img)

在构造无torch依赖的预处理函数时要注意两点:

  • 将归一化处理放在最后一步。

  • 注意albumentations和BPU的归一化参数的差异。在albumentations中,归一化计算方式为���=���−����⋅���(���)���⋅���(���)img=std_⋅_max(img)img_−_mean_⋅_max(img),���(���)max(img)表示图像的像素最大值,一般为255。而BPU的归一化计算方式为���=��������⋅(���−�������)img=_scalebpu_⋅(img_−_meanbpu)。

  • 构造BPU归一化参数。albumentations归一化参数为����=(0.485,0.456,0.406),���=(0.229,0.224,0.225)mean=(0.485,0.456,0.406),std=(0.229,0.224,0.225),则BPU的归一化参数为 �������=����⋅255=(123.675,116.28,103.53),��������=1���∗255=(0.01712475,0.017507,0.01742919)meanbpu=_mean_⋅255=(123.675,116.28,103.53),scalebpu=_std_∗2551=(0.01712475,0.017507,0.01742919)。

    无torch依赖的预处理函数:img为RGB通道,排布HWC

    def preprocess(img: np.ndarray, modelh, modelw) → np.ndarray:
    img = cv2.resize(img, (modelw, modelh))# Resize图像尺寸
    img = img.transpose(2, 0, 1) # 通道由HWC变为CHW
    img = np.expand_dims(img, 0) # 增加一维,此时维度为1CHW

    图像归一化操作

    img = img.astype(“float32”)
    mu = np.array([123.675,116.28,103.53], dtype=np.float32)
    s= np.array([0.01712475,0.017507,0.01742919], dtype=np.float32)
    for c in range(img.shape[1]):
    img[:, c, :, :] = (img[:, c, :, :] - mu[c]) * s[c]
    return img


对于模型推理输出数据的后处理,不同任务不一样,在当前二分类任务中,基于torch的后处理函数如下。

# 基于torch的数据后处理: 输入[b,c,h,w],输出[b,h,w]
def postprocess_torch(dataout: torch.Tensor) -> np.ndarray:
  y = torch.nn.Softmax(dim=1)(dataout)[:, 1].cpu().detach().numpy()
  pred = (y > 0.5).astype(np.uint8) * 255
  return pred

postprocess_torch转换为无torch依赖的函数postprocess

def postprocess(outputs: np.ndarray) -> np.ndarray: # 输入[b,c,h,w],输出[b,h,w]
  # 元素归一化到[0,1]之后,选择前景部分的数据
  y_list = softmax(outputs, axis = 1)[:, 1, :, :] 
  # 大于0.5的就是前景
  y_list = (y_list > 0.5).astype(np.uint8) * 255 
  return y_list

③ ONNX推理验证。到这里三个关键输出项都已经处理完成,这时候基于ONNX进行推理来验证这些项的有效性。代码细节见detect_onnx.py,在torchdnn的根目录下执行python demos/unet/detect_onnx.py

import os
import numpy as np
import cv2
import scipy 
import onnxruntime
from prepare_functions import get_rgb_image, preprocess, postprocess

dataroot = "data/unet"
imgpath = os.path.join(dataroot, "mra_img_12.jpg")
onnxpath = os.path.join(dataroot, "unet.onnx")

# 加载图像和ONNX模型
img = get_rgb_image(imgpath) # 获取RGB的图像
sess_options = onnxruntime.SessionOptions()
sess_options.intra_op_num_threads = 4 # 设置线程数
session = onnxruntime.InferenceSession(onnxpath, sess_options = sess_options)

# ONNX推理
datain = preprocess(img, 256, 256)
inputs = {session.get_inputs()[0].name: datain} # 构建onnx输入,是个dict
outputs = session.run(None, inputs)
# outputs是个列表,记录了模型的所有输出,unet输出只有一个所以选择[0]
pred = postprocess(outputs[0])
for j in range(pred.shape[0]):
  cv2.imwrite(os.path.join(dataroot, f"pred_onnx_b{j}.png"), pred[j].astype(np.uint8))

执行完代码之后,得到ONNX结果,看起来没啥问题,可以进行阶段2的相关数据转换了。

2.1.2 校验流程

由于各种问题,转换必然存在各种Bug,如果ONNX推理验证之后,没有得到期望结果,可以从下面的3项来校验阶段1关联的数据/函数。其中,校验②③依赖校验①生成的数据,使用的时候请注意这一点。

  • 校验①代码细节见detect_torch.py,在torchdnn的根目录下执行python demos/unet/detect_torch.py
  • 校验②③合并在一个文件里check_onnx_funs.py,不需要的校验项注释掉即可,之后在torchdnn的根目录下执行python demos/unet/check_onnx_funs.py

① Pytorch推理流程校验

在部署前,各位已经拿到了模型的Pytorch文件,为了保证后续的有效性,这里需要构建一个建议的推理流程,验证模型的有效性。

在测试模型时,要注意代码一定要以CPU模式进行推理,不要用GPU进行推理,我测试时候发现两种模式推理结果的部分数据有0.004左右的精度差异,但这个精度差异并未影响最终pred。目前没确定具体原因,初步怀疑内部调用了不同的库导致的精度差异。

import os
import sys
sys.path.append(os.getcwd()) # 保证UNet可被import
import cv2
import numpy as np
import torch
from networks.unet import UNet
import albumentations as A
from albumentations.pytorch import ToTensorV2
from prepare_functions import get_rgb_image

# 这里贴上在构建流程中介绍的函数preprocess_torch和postprocess_torch
# ............................

dataroot = "data/unet"
# 1. Pytorch 模型
modelpath = os.path.join(dataroot, "checkpoint_0.pth.tar")

# 2. 模型加载
net = UNet(3, 2, 2)
net = torch.nn.DataParallel(net)
state_dict = torch.load(modelpath)
net.load_state_dict(state_dict["state_dict"])
net.eval()
net = net.module.cpu() # 一定要指定为CPU 

# 3. 图像数据(模型以RGB为输入)
imgpath = os.path.join(dataroot, "mra_img_12.jpg")
img = get_rgb_image(imgpath)

# 4. 数据预处理
datain = preprocess_torch(img, 256, 256)['image'] # cxhxw
datain = torch.unsqueeze(datain, dim=0).cpu() # 1xcxhxw

# 5. 模型推理:推理输出维度[1,2,256,256]
dataout = net(datain)

# 6. 数据后处理:pred维度[1,256,256]
pred = postprocess_torch(dataout)
for j in range(pred.shape[0]):
  cv2.imwrite(os.path.join(dataroot, f"pred_torch_b{j}.png"), pred[j].astype(np.uint8))

# 7. 保存校验数据
data = {"image": img,
        "datain": datain.cpu().detach().numpy(),
        "dataout": dataout.cpu().detach().numpy(),
        "pred": pred}
np.savez(os.path.join(dataroot, "unet_checkstage1.npz"), **data)

此外,这里介绍下数据集格式和训练测试的脚本,如果各位有需求,可以训练自己的数据集。其中csv文件的生成利用了pandas,生成文件部分的代码如下所示。如果需要修改数据集加载方式的话修改dataloaders/dataload.pyclass DataFolder。目前这套代码仅支持二分类问题,其他任务的支持请关注torchdnn文件夹里面的README

import pandas as pd
csvpath = os.path.join(dsroot, "filenames.csv") 
csvlist = list(zip(imgnames, gtnames))
filenamefile = pd.DataFrame(data=csvlist, columns=['imagename', 'gtname'])
filenamefile.to_csv(csvpath, index=False)

准备好相关的数据之后,可以利用如下脚本进行训练\测试,使用时候删掉\和注释。

# 数据最终保存在{resultroot}/{task}/{name}/fold_{fold}文件夹中,dataset目前没啥用
python train.py --task baseline --fold 0 --name=unet --dataset=2DMRACerebrovascular \ 
  --dataroot="D:/01 - datasets/008 - 2DMRACerebrovascular" \
  --resultroot="D:/01 - datasets/008 - 2DMRACerebrovascular/Experiments" \
  --train-gpus 0 --train-batch-size=8 --train-workers=4 \
  
python test.py --task baseline --fold 0 --name=unet --dataset=2DMRACerebrovascular \
  --dataroot="D:/01 - datasets/008 - 2DMRACerebrovascular" \
  --resultroot="D:/01 - datasets/008 - 2DMRACerebrovascular/Experiments" \
  --train-gpus 0 \
  --test-save-flag=true 

② ONNX模型校验

ONNX模型的校验流程如下图所示,利用校验①的预处理数据,校验ONNX推理输出和理论推理输出的差异。

ONNX的模型校验代码在check_onnx_funs.py中,用法在校验①之前说过了。核心代码如下所示,dataindataout是校验①中保存的数据,如果onnx模型有效,则outputs[0]应该与dataout无明显差异,矩阵差异由函数check_matrix_equal检查。

onnxpath = os.path.join(dataroot, "unet.onnx")
session = onnxruntime.InferenceSession(onnxpath)
inputs = {session.get_inputs()[0].name: datain}
outputs = session.run(None, inputs)
check_matrix_equal(outputs[0], dataout, 1e-4, dataroot, "onnx")

函数check_matrix_equal定义在prepare_functions.py,用于检查两个矩阵的内容是否一致。check_matrix_equal目前仅支持[2,3,4]维矩阵,且最后两个维度表示图像的行和列HW。函数会输出每一个�⋅�_H_⋅_W_矩阵的检查结果,元素差异大于阈值thre的像素点会被标记维白色,并保存到本地来可视化。各位可以参考下面这个代码来理解check_matrix_equal

def check_matrix_equal(src: np.ndarray, dst: np.ndarray, thre, saveroot, name):
  assert isinstance(src, np.ndarray), f"src must be np.ndarray, but it is {type(src)}"
  assert isinstance(dst, np.ndarray), f"dst must be np.ndarray, but it is {type(dst)}"
  assert len(src.shape) in [2, 3, 4] and src.shape == dst.shape, f"the length of the shape must be 4 and the shapes must be equal. src: {src.shape}, dst: {dst.shape}"
  
  if len(src.shape) == 4:
    for idxb in range(src.shape[0]):
      for idxc in range(src.shape[1]):
        diff = np.abs(src[idxb, idxc, ...] - dst[idxb, idxc, ...])
        if np.max(diff) < thre:
          continue
        imgerr = (diff >= thre).astype(np.uint8) * 255
        print(f"Discovered an invalid matrix at (b:{idxb}, c:{idxc}), max diff: {np.max(diff)}")
        cv2.imwrite(os.path.join(saveroot, f"err_{name}_{idxb}_{idxc}.png"), imgerr)
  elif len(src.shape) == 3:
    for idxb in range(src.shape[0]):
      diff = np.abs(src[idxb, ...] - dst[idxb, ...])
      if np.max(diff) < thre:
        continue
      imgerr = (diff >= thre).astype(np.uint8) * 255
      print(f"Discovered an invalid matrix at (b:{idxb}), max diff: {np.max(diff)}")
      cv2.imwrite(os.path.join(saveroot, f"err_{name}_{idxb}.png"), imgerr)
  elif len(src.shape) == 2:
    diff = np.abs(src - dst)
    if np.max(diff) >= thre:
      imgerr = (diff >= thre).astype(np.uint8) * 255
      print(f"Discovered an invalid matrix, max diff: {np.max(diff)}")
      cv2.imwrite(os.path.join(saveroot, f"err_{name}.png"), imgerr)

  print(f"finish the check task: {name}")

以当前使用的UNet为例,output[0]维度为[1,2,256,256],在batch:0, channel:0的位置处,出现了部分元素不匹配问题。输出信息为Discovered an invalid matrix at (b:0, c:0), max diff: 0.0001010894775390625,表示不匹配的元素差异误差最大值为0.000101。这个差异并不验证,因此认为通过了ONNX校验。

③ 构建的预处理/后处理函数的校验

这里image,datain,dataout,pred都是校验①中生成的参考数据,调用函数check_matrix_equal来检查构造的预处理/后处理函数的有效性,与onnx校验不同,这里一定要保证输出的结果不能有任何差异,如果有差异,就需要定位自己构造的函数的问题。

校验数据的核心代码如下所示,很简单,没有多余内容,check_matrix_equal的描述参考校验②。

###### 检查预处理函数preprocess
datainsrc = preprocess(image, 256, 256)
check_matrix_equal(datainsrc, datain, 1e-4, dataroot, "preprocess")

###### 检查后处理函数preprocess
predsrc = postprocess(dataout)
check_matrix_equal(predsrc, pred, 1e-4, dataroot, "postprocess")

2.2 阶段2 模型量化:量化BIN模型、板端预处理函数的构建与校验

模型量化部分一定要看1.2节中给出的两个博客教程,这样才能了解这个阶段介绍的内容的目的。这个阶段需要输出的关键项为:

  • 拆分预处理,分为:归一化参数、校验数据预处理函数、板端数据预处理函数。
  • 板端BIN模型。该模型可以在X3开发板上运行。

在构造流程的最后一段里,给各位一种不基于开发板的量化模型的检查方法→_→。

2.2.1 构建流程

① 拆分预处理函数

看到这里,还记得我在阶段1的构建流程里,说过要将归一化节点放在最后嘛,因为在这里,我们直接把归一化删掉即可,减少代码Bug风险。拆分后的预处理函数也同样记录在定义在prepare_functions.py中。

  • 归一化参数在阶段1已经转换得到:mean_value: '123.675 116.28 103.53'scale_value: '0.01712475 0.017507 0.01742919'

  • 校验数据预处理函数。直接把前面提供的preprocess的图像归一化操作部分扔掉,得到函数preprocess_calibration。注意这里输入的图像是RGB哦。

    校验数据预处理函数, 注意输入的img是RGB通道

    def preprocess_calibration(img: np.ndarray, modelh, modelw) → np.ndarray:
    img = cv2.resize(img, (modelw, modelh))# Resize图像尺寸
    img = img.transpose(2, 0, 1) # 通道由HWC变为CHW
    img = np.expand_dims(img, 0) # 增加一维,此时维度为1CHW
    return img

  • 板端数据预处理函数。为了减少代码复杂度,我直接让网络以BGR/NHWC格式输入,这样transpose就可以丢弃了,得到函数preprocess_onboard

    板端数据预处理函数, 注意输入的img是bgr通道

    def preprocess_onboard(img: np.ndarray, modelh, modelw) → np.ndarray:
    img = cv2.resize(img, (modelw, modelh))# Resize图像尺寸
    img = np.expand_dims(img, 0) # 增加一维,此时维度为1HWC
    img = np.ascontiguousarray(img) # 板端的推理是封装的C++,安全起见这里约束矩阵内存连续
    return img

② 转换ONNX为板端BIN模型。这里转换根之前介绍的博客流程一样,先把yaml和校验数据准备好后,开始走流程。校验数据的转换存放在prepare_calibratedata.py,在torchdnn文件夹下输入python demos/unet/prepare_calibratedata.py生成转换模型所用的标定数据。

import numpy as np
import cv2
import os
from prepare_functions import get_rgb_image, preprocess_calibration

dataroot = "data/unet"
imgroot = os.path.join(dataroot, "images")
calibroot = os.path.join(dataroot, "calibration")
for imgname in os.listdir(imgroot):
  img = get_rgb_image(os.path.join(imgroot, imgname))
  calibdata = preprocess_calibration(img, 256, 256) # 校验数据预处理函数
  calibdata.astype(np.uint8).tofile(os.path.join(calibroot, imgname + ".rgbchw"))

下面是转换模型使用的yaml文件(其实这里非常建议各位train和rt都用一样的值,免得有其他问题),

model_parameters:
  onnx_model: 'unet.onnx'
  output_model_file_prefix: 'unet'
  march: 'bernoulli2'
input_parameters:
  # 校验数据的输入排布为NCHW RGB
  input_type_train: 'rgb'
  input_layout_train: 'NCHW'
  # 前面说明的归一化参数放在这里
  norm_type: 'data_mean_and_scale'
  mean_value: '123.675 116.28 103.53'
  scale_value: '0.01712475 0.017507 0.01742919'
  # 板端的输入数据信息
  input_type_rt: 'bgr'
  input_layout_rt: 'NHWC'
calibration_parameters:
  cal_data_dir: 'Calibration'
  calibration_type: 'max'
  max_percentile: 0.9999
compiler_parameters:
  compile_mode: 'latency'
  optimize_level: 'O3'
  debug: False
  core_num: 2

准备好这些之后,打开OE docker(docker相关的使用请查看[BPU部署教程] 一文带你轻松走出模型部署新手村),参考下面的指令挂载代码和数据文件夹,Windows系统记得删除\并将指令合为一行。

docker run -it --rm \
-v "D:\05 - 项目\01 - 旭日x3派\horizon_xj3_open_explorer_v2.2.3_20220617":/open_explorer \
-v "D:\05 - 项目\05 - OpenWanderary\OpenWanderary\projects\torchdnn\data\unet":/data/horizon_x3/data \
-v "D:\05 - 项目\05 - OpenWanderary\OpenWanderary\projects\torchdnn\demos\unet":/data/horizon_x3/codes \
openexplorer/ai_toolchain_centos_7:v1.13.6

进入数据文件夹/data/horizon_x3/data,模型转换数据将会存放在这里,前面步骤中构建的ONNX模型文件、Yaml配置文件、标定数据文件都存放在这个目录下。依次输入下述指定,每步都成功执行后,则得到最终转换后的bin模型。注意:模型转换中使用了绝对路径,如果路径有空格,会出错误

  • 模型检查:hb_mapper checker --model-type onnx --march bernoulli2 --model unet.onnx。相关日志文件名为hb_mapper_checker.log
  • 模型转换:hb_mapper makertbin --config unet.yaml --model-type onnx。相关日志文件名为hb_mapper_makertbin.log

最后我们需要的bin文件存储在model_output/unet.bin中,这个文件用于在X3中进行网络推理。

③ 量化模型验证。这里我介绍一种在电脑上就能初步验证模型有效性的办法,我们在转换模型时候,主要关注的是unet.bin,但实际上还有个模型unet_quantized_model.onnx,这个模型是量化后的模型,在docker内就可以使用的。

利用Netron工具可以打开这个模型,模型的大部分层都用BPU相关的算子替换了,而且在最开始插入了一个预处理算子,前面的归一化参数就是集成在这个算子里了。这个onnx有几点需要注意:

  • 预处理函数是板端预处理函数,而不是校验预处理函数。如果利用这个模型能得到期望结果的话,bin模型基本可以认为没有问题了。
  • 这里一定要注意输入的数据类型为int8,正常图像类型为uint8,利用img = (img.astype(np.int32) - 128).astype(np.int8)可以完成格式的转换。

基于这些,我们可以对这个模型进行推理,代码记录在detect_quantized_onnx.py,在docker /data/horizon_x3/codes文件夹下输入python3 detect_quantized_onnx.py生成量化onnx模型的推理结果。 官方推理用的是horizon_tc_ui import HB_ONNXRuntimeHB_ONNXRuntimehorizon_onnxruntime的扩展,为了使得用法跟之前onnx的推理保持一致,我还是偏向使用horizon_onnxruntime

### 注意:改代码仅能在OE docker中运行
import numpy as np
import cv2
import os
from prepare_functions import get_rgb_image, preprocess_onboard, postprocess
# horizon_nn 是在docker中才有的包
from horizon_nn import horizon_onnxruntime
from horizon_nn import horizon_onnx

dataroot = "/data/horizon_x3/data"
imgpath = os.path.join(dataroot, "mra_img_12.jpg")
onnxpath = os.path.join(dataroot, "model_output", "unet_quantized_model.onnx")

# 加载图像和ONNX模型
img = get_rgb_image(imgpath)
session = horizon_onnxruntime.InferenceSession(onnxpath)

# 校验预处理函数
datain = preprocess_onboard(img, 256, 256) # 1x256x256x3
# 构建输入并推理,记得要转为int8
inputs = {session.get_inputs()[0].name: (datain.astype(np.int32) - 128).astype(np.int8)}
outputs = session.run(None, inputs)

# 后处理并保存结果
pred = postprocess(outputs[0])[0]
cv2.imwrite(os.path.join(dataroot, "pred_quantized_onnx.png"), pred)

从下图可以看出,量化ONNX的推理结果和原始ONNX推理结果,主体上是相似的,局部有细微的差异(量化必然存在或多或少的精度损失,大部分情况下是可用的)。

其实我最开始以为onnx的输入preprocess_calibration,运行时候报错,错误信息说维度应该是[1,256,256,3],然后我回去看onnx结构才发现预处理节点名叫HzSQuantizedPreprocess,剩余的两个onnx节点名是HzPreprocess

 INVALID_ARGUMENT : Got invalid dimensions for input: images for the following indices
 index: 1 Got: 3 Expected: 256
 index: 3 Got: 256 Expected: 3
 Please fix either the inputs or the model.

2.2.2 校验流程

如果本阶段的构建流程无法得到有效量化模型推理结果,则需要按照下面的校验项依序处理

  • 如果校验①未通过,则需要仔细排查板端预处理(数据排布用的是input_train_layout) 是否有问题,如果没问题则可认为是模型转换初期就有问题,需要在地平线社区反馈问题交给技术人员检查。
  • 如果校验①通过,校验②未通过,则认为在模型优化这一步出了问题,直接社区反馈问题。
  • 如果校验②通过,校验③未通过,则认为模型在量化阶段出了问题,直接社区反馈问题。

关于模型转换过程中的三个ONNX需要输入何种数据,手册里写的比较含糊。通过查看OE包中samples中的代码,输入的数据种类(BGR/RGB等)用的都是input_type_rt的信息,而输入的数据排布不完全相同,即 original_float_modeloptimized_float_model模型用的是input_layout_train的排布,而quantized_model用的是input_layout_rt的排布。我其实并不理解官方这样设计的目的,从用户的角度来说,整个流程基于一个预处理,其他任何问题都应该交给开发人员来处理才比较合理。

① 原始浮点模型校验

如果校验①未通过,则需要仔细排查板端预处理(数据排布用的是input_train_layout) 是否有问题,如果没问题则可认为是模型转换初期就有问题,需要在地平线社区反馈问题交给技术人员检查。注意这里的预处理有点不同,用的是input_layout_train排布,input_type_rt图像类型。代码细节参考check_float_onnx.py,在docker /data/horizon_x3/codes文件夹下输入python3 check_float_onnx.py生成original_float_model.onnx的推理结果。

##### 这里省略了各种import
# 板端数据预处理函数, 注意这里用的是input_layout_train排布,input_type_rt类型
def preprocess_floatmodel(img: np.ndarray, modelh, modelw) -> np.ndarray:
  img = cv2.resize(img, (modelw, modelh))# Resize图像尺寸
  img = img.transpose(2, 0, 1) # 通道由HWC变为CHW
  img = np.expand_dims(img, 0) # 增加一维,此时维度为1CHW
  img = np.ascontiguousarray(img) # 板端的推理是封装的C++,安全起见这里约束矩阵内存连续
  return img

dataroot = "/data/horizon_x3/data"
imgpath = os.path.join(dataroot, "mra_img_12.jpg")
onnxpath = os.path.join(dataroot, "model_output", "unet_original_float_model.onnx")

# 加载图像和ONNX模型
img = get_rgb_image(imgpath)
session = horizon_onnxruntime.InferenceSession(onnxpath)

# 校验预处理函数
datain = preprocess_floatmodel(img, 256, 256) # 1x256x256x3
# 构建输入并推理,记得要转为float32
inputs = {session.get_inputs()[0].name: (datain.astype(np.int32) - 128).astype(np.float32)}
outputs = session.run(None, inputs)

# 后处理并保存结果
pred = postprocess(outputs[0])[0]
cv2.imwrite(os.path.join(dataroot, "pred_original_onnx.png"), pred)

② 优化浮点模型校验

校验②和校验①的模型推理,使用的是同一个预处理函数preprocess_floatmodel,唯一不同的是这里使用的onnx模型不同,使用的是optimized_float_model,是original_float_model模型的优化。因此,若校验①可以出正常结果,校验②无法得到正常结果,则认为在模型优化这一步除了问题,可以直接交给技术人员排查。代码主体与校验①一样,差异部分如下所示。

# onnxpath = os.path.join(dataroot, "model_output", "unet_original_float_model.onnx")
onnxpath = os.path.join(dataroot, "model_output", "unet_optimized_float_model.onnx")
# cv2.imwrite(os.path.join(dataroot, "pred_original_onnx.png"), pred)
cv2.imwrite(os.path.join(dataroot, "pred_optimized_onnx.png"), pred)

③ 量化模型校验

校验③的代码同前面构建流程的最后一步,这里的预处理函数preprocess_onboard与前两个校验所用的函数是不一样的。主体流程如图下图所示,如果校验②有正常结果,校验③无结果,则认为模型在量化阶段除了问题,需要交由技术人员处理。

此外,为了方便后续流程的校验,在这步还需要保存一些中间变量(img,datain,dataout,pred),每个数据的维度以及保存方式参考下面的代码,这段代码放在detect_quantized_onnx.py的最后面。

# 保存校验数据
# img: 256x256x3, datain: 1x256x256x3, dataout: 1x2x256x256, pred: 1x256x256
data = {"image": img,
        "datain": datain,
        "dataout": outputs[0],
        "pred": pred}
np.savez(os.path.join(dataroot, "unet_checkstage2.npz"), **data)

剩下部分是下集,主要内容围绕着板端C++/Python部署,具体内容请参考下一个博客

详细~

专业