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

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

[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第1张图片

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

  • 量化后的模型在BPU上得不到与Pytoch一样的结果。这种问题多数是因为模型推理的前后处理没做好检查,但凡一步不对都没法得到正确的结果。
  • 量化后的模型Python能推理出来,部署到C++就推理不出来了。大部分是数据没做对齐导致的,就这个问题卡了我好久,最后问了一圈人才发现是输入的图像是1x256x256x3,需要做对齐为1x256x256x4才能跑出正确结果。
[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第2张图片

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

[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第3张图片

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

[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第4张图片

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

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

本博客关联的文件存放在https://github.com/Li-Zhaoxi/OpenWanderary中,整个过程依赖/生产的数据存放在百度云(提取码:0a09 )中的文件夹OpenWanderary/projects/torchdnn/data/unet中,下载后直接复制到代码OpenWanderary对应位置即可。
[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第5张图片

部署校验流程导航

  • 一 BPU部署&校验流程导图
    • 1.1 模型部署风险项
    • 1.2 规范化部署导图
  • 二 流程导图详解
    • 2.1 阶段1 模型转换:ONNX模型、预/后处理函数的构建与校验
      • 2.1.1 构建流程
      • 2.1.2 校验流程
        • ① Pytorch推理流程校验
        • ② ONNX模型校验
        • ③ 构建的预处理/后处理函数的校验
    • 2.2 阶段2 模型量化:量化BIN模型、板端预处理函数的构建与校验
      • 2.2.1 构建流程
      • 2.2.2 校验流程
        • ① 原始浮点模型校验
        • ② 优化浮点模型校验
        • ③ 量化模型校验
    • 2.3 阶段3 模型上板:Python推理部署与校验
      • 2.3.1 构建流程
      • 2.3.2 校验流程
        • ① BIN量化模型上板校验
      • 2.3.3 pyeasy_dnn内容分析
    • 2.4 阶段4 模型上板:C++推理部署与校验
      • 2.4.1 编译配置
      • 2.4.2 构建流程
      • 2.4.3 校验流程
        • ① C++板端预处理校验
        • ② C++板端后处理校验
        • ③ C++板端推理校验
  • 三 基于wdr::BPU的模型部署方案
    • 3.1 利用WDR打印模型参数信息
    • 3.2 利用WDR实现UNet推理
  • 四 总结

一 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部署教程] 一文带你轻松走出模型部署新手村
  • [BPU部署教程] 教你搞定YOLOV5部署 (版本: 6.2)

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

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

[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第6张图片

二 流程导图详解

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

  • 数据集来自2022年发表在TMI的论文《Attention-Assisted Adversarial Model for Cerebrovascular Segmentation in 3D TOF-MRA Volumes》。数据集实际上是MRA图像,我这里只保留了脑血管GT,用二分类的Unet来介绍整体的部署流程(这里感谢诺师弟的帮助)。
  • UNet相关的训练测试代码基于hust-linyi/MedISeg,本博客相关的Unet代码上传在projects/torchdnn/demos/unet,执行相关脚本时,请保证文件根目录为projects/torchdnn
  • 本博客相关的代码上传在百度云(提取码:0a09 )中的datasets/008 - 2DMRACerebrovascular.zip文件夹中。
    [BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第7张图片

训练测试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中,归一化计算方式为 i m g = i m g − m e a n ⋅ m a x ( i m g ) s t d ⋅ m a x ( i m g ) img = \dfrac{img - mean \cdot max(img)}{std \cdot max(img)} img=stdmax(img)imgmeanmax(img) m a x ( i m g ) max(img) max(img)表示图像的像素最大值,一般为255。而BPU的归一化计算方式为 i m g = s c a l e b p u ⋅ ( i m g − m e a n b p u ) img = scale_{bpu}\cdot(img - mean_{bpu}) img=scalebpu(imgmeanbpu)
  • 构造BPU归一化参数。albumentations归一化参数为 m e a n = ( 0.485 , 0.456 , 0.406 ) , s t d = ( 0.229 , 0.224 , 0.225 ) mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225) mean=(0.485,0.456,0.406),std=(0.229,0.224,0.225),则BPU的归一化参数为 m e a n b p u = m e a n ⋅ 255 = ( 123.675 , 116.28 , 103.53 ) , s c a l e b p u = 1 s t d ∗ 255 = ( 0.01712475 , 0.017507 , 0.01742919 ) mean_{bpu}=mean\cdot 255=(123.675,116.28,103.53), scale_{bpu}=\dfrac{1}{std * 255}=(0.01712475,0.017507,0.01742919) meanbpu=mean255=(123.675,116.28,103.53),scalebpu=std2551=(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的相关数据转换了。

[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第8张图片

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.py中class 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)

[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第9张图片
准备好相关的数据之后,可以利用如下脚本进行训练\测试,使用时候删掉\和注释。

# 数据最终保存在{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推理输出和理论推理输出的差异。

[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第10张图片

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 H\cdot W HW矩阵的检查结果,元素差异大于阈值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校验。

[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第11张图片

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

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

[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第12张图片

校验数据的核心代码如下所示,很简单,没有多余内容,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)可以完成格式的转换。
    [BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第13张图片
    基于这些,我们可以对这个模型进行推理,代码记录在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推理结果,主体上是相似的,局部有细微的差异(量化必然存在或多或少的精度损失,大部分情况下是可用的)。

[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第14张图片

其实我最开始以为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的排布。我其实并不理解官方这样设计的目的,从用户的角度来说,整个流程基于一个预处理,其他任何问题都应该交给开发人员来处理才比较合理。
[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第15张图片

① 原始浮点模型校验

如果校验①未通过,则需要仔细排查板端预处理(数据排布用的是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)

2.3 阶段3 模型上板:Python推理部署与校验

理论上,阶段2校验③走通了,就一定能在板子上成功推理出来,因为这两者主要区别是模型加载方式不同(一个是onnx一个是bin),因此该阶段的构建流程代码与阶段2校验③的代码高度相似

但代码跨平台很容易出现问题,因此安全起见,也要规范一下校验流程来定位问题,因此,构建流程部分没有得到正确结果的话,请参考本部分的校验流程。

基于Python的部署主要调用的是pyeasy_dnn,这个包里面的一些函数/类/数据类型的用法我会在后面详细解释。

2.3.1 构建流程

BIN模型的推理代码如下所示,prepare_functions 中的函数get_rgb_image, preprocess_onboard, postprocess在这里可以直接复用。而模型的推理和加载也非常简单。代码细节见detect_bin.py,在torchdnn的根目录下执行sudo python demos/unet/detect_bin.py(这个代码只能在开发板中运行)。

import numpy as np
import cv2
import os
from prepare_functions import get_bgr_image, preprocess_onboard, postprocess
from hobot_dnn import pyeasy_dnn as dnn

# 记得要安装一些包
# sudo pip3 install scipy opencv-contrib-python

def get_hw(pro):
    if pro.layout == "NCHW":
        return pro.shape[2], pro.shape[3]
    else:
        return pro.shape[1], pro.shape[2]


dataroot = "data/unet"
imgpath = os.path.join(dataroot, "mra_img_12.jpg")
binpath = os.path.join(dataroot, "model_output/unet.bin")


# 加载图像和BIN模型
img = get_bgr_image(imgpath)
models = dnn.load(binpath)

# 图像数据预处理,这里对几个地方进行解释:
# models[0]:BPU支持加载多个模型,这里只有一个unet,因此[0]表示访问第一个模型
# inputs[0]:模型输入可能多种,unet的输入只有一个,因此[0]表示获取第一个输入的参数
model_h, model_w = get_hw(models[0].inputs[0].properties)
datain = preprocess_onboard(img, model_h, model_w) # 1x256x256x3


# 模型推理:相比于onnx推理,这里不用再重新构造一个inputs
t1 = cv2.getTickCount()
outputs = models[0].forward(datain)
t2 = cv2.getTickCount()
print('time consumption {0} ms'.format((t2-t1)*1000/cv2.getTickFrequency()))

# 后处理并保存结果,这里对几个地方进行解释
# outputs数据类型为tuple,每个元素的数据类型是pyDNNTensor
# 因此想要获取输出的矩阵的话,需要调用buffer
pred = postprocess(outputs[0].buffer) # outputs[0].buffer: (1, 2, 256, 256)
cv2.imwrite(os.path.join(dataroot, "pred_bin.png"), pred[0])

对比unet.binunet_quantized_model.onnx,结果是一模一样的,所以,转换模型后,在docker里就可以直接验证我们的量化模型是否可以用在开发板上。

[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第16张图片

2.3.2 校验流程

如果本阶段的构建流程无法得到有效推理结果,则需要按照下面的校验项依序处理,只要校验不通过,说明unet_quantized_model.onnxunet.bin的过程除了问题,在地平线社区反馈问题交给技术人员检查。

PS:如果校验都通过,那就认真检查下推理的前后处理吧,肯定是某个细节写错了→_→。

其实这里的校验流程应该需要补充个unet_quantized_model.onnx在开发板的校验,但是推理这个onnx,依赖from horizon_nn import horizon_onnxruntime,这个包只能在docker中运行,不能在开发板运行(里面有个so文件依赖docker)。(希望官方后续能在开发板支持horizon_nn的使用

① BIN量化模型上板校验

这里的校验BIN,就是直接输入推理数据,与理论的输出数据进行对比(dataindataout是阶段2校验③中生成的校验数据),解除前后处理的耦合影响。代码细节见detect_bin.py,在torchdnn的根目录下执行sudo python demos/unet/check_bin_onboard.py(这个代码只能在开发板中运行)。

dataroot = "data/unet"
data = np.load(os.path.join(dataroot, "unet_checkstage2.npz"))

datain = data["datain"]
dataout = data["dataout"]

###### 检查量化BIN有效性
binpath = os.path.join(dataroot, "model_output/unet.bin")
models = dnn.load(binpath)

outputs = models[0].forward(datain)

check_matrix_equal(outputs[0].buffer, dataout, 1e-4, dataroot, "onnxonboard")

2.3.3 pyeasy_dnn内容分析

Python版本的推理包hobot_dnn是C++推理的封装,只留下了简单的推理过程,因此在实际落地应用时,建议使用C++部署。hobot_dnn里面只有一个pyeasy_dnn.so,存放地址/usr/local/lib/python3.8/dist-packages/hobot_dnn/pyeasy_dnn.so。利用from hobot_dnn import pyeasy_dnn as dnn导入包之后,执行print(help(dnn))可以看到dnn内部的注释信息。

为了更好的理解dnn里面都有哪些内容,我在这里进行了详细分析,官网手册《5.4. 模型推理接口说明》里给出了简单的介绍。我个人觉得pyeasy_dnn的设计还是可以的,至少用户在操作时,不需要掌握太多的新知识(学习成本低)。里面有三种数据类型ModelTensorPropertiespyDNNTensor

为了更好的理解所有Class和Functions之间的关系,我下面给出一个思维导图,利用这张图在部署时候,就能随意调取相关的属性,完成自己的算法落地。
[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第17张图片

2.4 阶段4 模型上板:C++推理部署与校验

Python适用于快速算法验证,验证无误后,需要转换为C++落地。任何嵌入式应用基本都无法脱离C++,因为相比于Python,C++执行的速度更快,能够节省嵌入式本来就有限的资源。BPU提供的SDK是C接口,可以根据自己的需求做优化。BPU的相关API文档参考链接:5.2. BPU SDK API手册。

C语言接口,在提高了开发灵活性的同时,也降低了开发的安全性。因为操作基于指针,内存的分配与释放、数据对齐拷贝由用户来管理,很容易出现部署失败但不知如何Debug出问题,本节会讲清楚C语言部署/校验的流程。

此外,考虑到部署的不安全性,我自己在C接口的基础上,补充了一个C++的API,以减少学习成本,提高部署安全性,这个会在下一章节(wdr::BPU部署)介绍。

2.4.1 编译配置

大部分Linux的代码都通过CMake进行编译,C++推理依赖项整理如下:

  • 头文件:BPU部署相关的头文件存放在/usr/include/dnn/,配置时候不需要利用include_directories指定头文件目录,大部分的BPU部署直接在代码中添加下面两行代码即可#include #include 。若有函数找不到,就去dnn根目录下查找对应的函数头文件。
  • 库文件:BPU相关的库文件存放在/usr/lib/hbbpu/中,因此需要在CMakeList.txt中补充库目录link_directories(/usr/lib/hbbpu/),编译最终可执行文件时,在target_link_libraries中补充相关库-ldnn -lcnn_intf -lhbrt_bernoulli_aarch64。(PS:除了这个还有libhlog.so,这个就是打印日志用的,我觉得可以用glog替代,就不使用这个了。)

博客里相关的C++代码都放置在github上:https://github.com/Li-Zhaoxi/OpenWanderary,编译流程如下:

# 安装OpenCV:不要用X3自带的opencv,版本低,不完整。
sudo apt-get install libopencv-dev 
# 安装boost:如果编译出错,再安装这两个libboost-filesystem1.71-dev libboost-wave1.71-dev
sudo apt-get install libboost1.71-all-dev 

git clone https://github.com/Li-Zhaoxi/OpenWanderary.git
cd OpenWanderary
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release ..

# 根据内存情况选择-j4还是-j2
# 这里编译会编译所有项目,后续会补充选择性编译的功能,欢迎关注OpenWanderary仓库
make -j4 

2.4.2 构建流程

下面我们开始着手写C++部署代码,代码细节见infer_unet_standalong.cpp中的函数void infer_unet(),在OpenWanderary的根目录下执行sudo ./build/examples/BPU/infer_unet_standalong --mode infer(这个代码只能在开发板中运行)。

下面对代码中的一些关键内容进行讲解。

① 通过代码判断代码执行是否用了sudo。在介绍正式内容之前,我先说下咱们开发常遇见的一个坑:调用BPU是需要sudo权限的,但我们经常会忘记用sudo。使用getuid函数(在#include 里) 可以避免这个问题,在main函数里面第一行加入如下代码

int main(int argc, char **argv)
{
  if (getuid())
    CV_Error(cv::Error::StsError, "You must use ROOT or SUDO to use these BPU functions.");
  /// 各种代码
}

这样当我们忘记使用sudo时候,代码就可以直接反馈错误信息
[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第18张图片


② 构建C++版本的预/后处理函数。对比着前面Python版本的预处理函数preprocess_onboard和后处理函数postprocess,复现对应的C++版本,关键代码细节如下所示。具体细节没什么好说的,我这里特别介绍下C++代码里多维矩阵定义和使用的技巧。

void preprocess_onboard(const cv::Mat img, int modelh, int modelw, cv::Mat &datain)
{
  cv::Mat tmp;
  // Python: img = cv2.resize(img, (modelw, modelh))
  cv::resize(img, tmp, cv::Size(modelw, modelh));

  // Python: img = np.expand_dims(img, 0) 
  // Python: img = np.ascontiguousarray(img)
  std::vector<int> dims = {1, tmp.rows, tmp.cols, tmp.channels()};
  datain.create(dims.size(), dims.data(), CV_MAKETYPE(img.depth(), 1));
  
  memcpy(datain.data, tmp.data, tmp.total() * tmp.elemSize());
}
void postprocess(const cv::Mat outputs, cv::Mat &pred)
{
  // 格式检查:保证outputs的维度是[b,2,h,w],数据类型为float
  CV_Assert(outputs.size.dims() == 4 && outputs.channels() == 1 && outputs.type() == CV_32F);
  int b = outputs.size[0], c = outputs.size[1], h = outputs.size[2], w = outputs.size[3];
  CV_Assert(c == 2);

  // Python: y_list = softmax(outputs, axis = 1)[:, 1, :, :] 
  // Python:y_list = (y_list > 0.5).astype(np.uint8) * 255
  // 这里可以简化为,比较[:, 1, :, :]和[:, 0, :, :]的大小,若前景大于背景,Label给255
  std::vector<int> dims = {b, h, w};
  pred.create(dims.size(), dims.data(), CV_8UC1);
  for(int i = 0; i < b; i++)
  {
    float *_bdata = ((float*)outputs.data) + i * c * h * w; // 背景指针
    float *_fdata = _bdata + h * w; // 前景指针

    unsigned char *_label = pred.data + i * h * w;

    int total_hw = h * w;
    for(int k = 0; k < total_hw; k++, _label++, _bdata++, _fdata++)
      *_label = *_fdata > *_bdata ? 255 : 0;
  }
}

多维矩阵的构建 。正常的opencv矩阵的宽高通过其中的rows和cols访问,通道数调用channels()这个函数获取。对于多维矩阵,比如1x256x256x3的矩阵,我们就得用下面这种方式定义:

cv::Mat datain;
std::vector<int> dims = {1, 256, 256, 3}; // 定义矩阵维度信息
datain.create(dims.size(), dims.data(), CV_32FC1); // 构建多维矩阵,C1固定不要变

如果用了这种方式构建矩阵,有一些地方需要注意下:

  • datain.rowsdatain.cols的值均为-1,维度个数可通过datain.size.dims()获取,其第k维大小可通过datain.size[k]获取。
  • datain.at(i,j)这种访问元素的形式失效。只能利用数据指针(float*)datain.data来访问元素。

③ C++部署调用API详解 。下面给出调用BPU的C接口API进行推理的完整流程,下面我给出BPU部署的代码流程图,各位可以对着这个流程去看相关的代码。

[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第19张图片
在给出代码细节之前,我先说几个注意点:

  • 流程图中模型推理这一过程,绑定输出表示输出的Tensor作为参数输入到hbDNNInfer中。
  • 在推理代码中,BPU推理API操作的是硬件,这里应该对每个BPU函数套用一个HB_CHECK_SUCCESS来检查是否成功执行,比如HB_CHECK_SUCCESS(hbDNNInitializeFromFiles(&pPackedNets, cpaths, pathnum), "hbDNNInitializeFromFiles failed");,为了方便理解流程,下面的代码省去了HB_CHECK_SUCCESS。下面这个代码片给出的定义。
#define HB_CHECK_SUCCESS(value, errmsg)                              \
  do {                                                               \
    /*value can be call of function*/                                \
    auto ret_code = value;                                           \
    if (ret_code != 0) {                                             \
      LOG(ERROR) << errmsg << ", error code:" << ret_code; \
      abort();                                               \
    }                                                                \
  } while (0);

详细BPU的C++推理代码如下所示,执行之后会保存推理结果,推理结果跟Python版本推理结果是一样的,这里就不再展示了。

void infer_unet()
{
  std::string dataroot = "projects/torchdnn/data/unet/";
  std::string binpath = dataroot + "model_output/unet.bin";
  std::string imgpath = dataroot + "mra_img_12.jpg";

  // -----------------模型加载部分--------------------
  // 1. 加载BIN模型集
  hbPackedDNNHandle_t packed_dnn_handle; // 模型集合指针
  const char *model_file_name = binpath.c_str();
  hbDNNInitializeFromFiles(&packed_dnn_handle, &model_file_name, 1);

  // 2. 提取模型集中所有的模型名称
  const char **model_name_list;
  int model_count = 0;
  hbDNNGetModelNameList(&model_name_list, &model_count, packed_dnn_handle);
  for (int k = 0; k < model_count; k++) // 输出提取出的所有模型的名称
    LOG(INFO) << "Parsed Model Name: " << std::string(model_name_list[k]);
  // 3. 利用目标模型名提取模型指针
  hbDNNHandle_t dnn_handle; // ※模型指针
  hbDNNGetModelHandle(&dnn_handle, packed_dnn_handle, model_name_list[0]);

  // -----------------输入输出内存分配--------------------
  // 1. 获取输入/输出Tensor个数
  int input_tensornum = 0, output_tensornum = 0;
  hbDNNGetInputCount(&input_tensornum, dnn_handle);
  hbDNNGetOutputCount(&output_tensornum, dnn_handle);
  LOG(INFO) << "input tensor num: " << input_tensornum << ", output tensor num: " << output_tensornum;

  // 2. 获取输入/输出Tensor参数
  std::vector<hbDNNTensorProperties> input_properties, output_properties; // 输入/输出Tensor参数
  input_properties.resize(input_tensornum), output_properties.resize(output_tensornum);
  for (int k = 0; k < input_tensornum; k++)
    hbDNNGetInputTensorProperties(&input_properties[k], dnn_handle, k);
  for (int k = 0; k < output_tensornum; k++)
    hbDNNGetOutputTensorProperties(&output_properties[k], dnn_handle, k);

  // 3. 利用参数分配Tensor内存
  std::vector<hbDNNTensor> input_tensors, output_tensors; // ※输入/输出Tensor
  input_tensors.resize(input_tensornum), output_tensors.resize(output_tensornum);
  for (int k = 0; k < input_tensornum; k++)
  {
    const auto &property = input_properties[k];
    input_tensors[k].properties = property;
    hbSysAllocCachedMem(&input_tensors[k].sysMem[0], property.alignedByteSize);
  }
  for (int k = 0; k < output_tensornum; k++)
  {
    const auto &property = output_properties[k];
    output_tensors[k].properties = property;
    hbSysAllocCachedMem(&output_tensors[k].sysMem[0], property.alignedByteSize);
  }
  LOG(INFO) << "Finish initializing input/output tensors";

   Tensor详细属性信息如下:
  // input[0]:
  //   valid shape: (1,256,256,3,)
  //   aligned shape: (1,256,256,4,)
  //   tensor type: HB_DNN_IMG_TYPE_BGR
  //   tensor layout: HB_DNN_LAYOUT_NHWC
  //   quanti type: SHIFT
  //   shift data: 0,0,0,
  // output[0]:
  //   valid shape: (1,2,256,256,)
  //   aligned shape: (1,2,256,256,)
  //   tensor type: HB_DNN_TENSOR_TYPE_F32
  //   tensor layout: HB_DNN_LAYOUT_NCHW
  //   quanti type: NONE

  // -----------------模型推理:预处理→BPU推理→后处理--------------------
  cv::Mat img, datain, dataout, pred;
  // 1. 加载图像&&图像预处理
  get_bgr_image(imgpath, img);
  cv::Size tensorhw = get_hw(input_tensors[0].properties);
  LOG(INFO) << "Loaded img size: " << img.size() << ", target size: " << tensorhw;
  preprocess_onboard(img, tensorhw.height, tensorhw.width, datain);
  LOG(INFO) << "Finish preprocess_onboard";

  // 由于输入和BPU的Tensor存在不对齐问题,因此需要配置为自动对齐
  // input_tensors[0]: valid shape: (1,256,256,3,),aligned shape: (1,256,256,4,)
  auto &tensor = input_tensors[0];
  tensor.properties.alignedShape = tensor.properties.validShape;

  // 2. 预处理数据memcpy至BPU
  memcpy(tensor.sysMem[0].virAddr, datain.data, datain.total() * datain.elemSize());
  
  // 3. 刷新CPU数据到BPU
  hbSysFlushMem(&tensor.sysMem[0], HB_SYS_MEM_CACHE_CLEAN);
  // 4. 推理模型

  hbDNNTaskHandle_t task_handle = nullptr; // 任务句柄
  hbDNNInferCtrlParam infer_ctrl_param;
  HB_DNN_INITIALIZE_INFER_CTRL_PARAM(&infer_ctrl_param);
  auto ptr_outtensor = output_tensors.data();
  hbDNNInfer(&task_handle, &ptr_outtensor, input_tensors.data(), dnn_handle, &infer_ctrl_param);

  // 5. 等待任务结束
  hbDNNWaitTaskDone(task_handle, 0);
  // 6. 释放任务
  hbDNNReleaseTask(task_handle);
  // 7. 刷新BPU数据到CPU
  hbSysFlushMem(&(output_tensors[0].sysMem[0]), HB_SYS_MEM_CACHE_INVALIDATE);
  // 8. 从Tensor地址memcpy后处理数据
  dataout.create(output_tensors[0].properties.alignedShape.numDimensions, 
                 output_tensors[0].properties.alignedShape.dimensionSize, CV_32FC1);
  memcpy(dataout.data, (unsigned char *)output_tensors[0].sysMem[0].virAddr, dataout.total() * dataout.elemSize());
  LOG(INFO) << "Finish infer";

  // 9. 数据后处理+保存最终分割结果
  postprocess(dataout, pred);
  int offset = pred.size[1] * pred.size[2] * pred.elemSize();
  for(int k = 0; k < pred.size[0]; k++)
  {
    cv::Mat batchpred(pred.size[1], pred.size[2], CV_8UC1, pred.data + k * offset);
    cv::imwrite(dataroot + "pred_bin_cpp_b" + std::to_string(k) + ".png", batchpred);
  }


   模型推理:预处理→BPU推理→后处理 
  // 1. 释放内存
  for (auto &input : input_tensors)
    hbSysFreeMem(&(input.sysMem[0]));
  for (auto &output : output_tensors)
    hbSysFreeMem(&(output.sysMem[0]));

  // 2. 释放模型
  hbDNNRelease(packed_dnn_handle);
}

2.4.3 校验流程

从Python代码转C++代码,一下次就成功是很难的,这里的校验就非常关键了。这阶段的校验过程包含三个阶段:预处理、后处理、BIN模型校验,相比于其他几个阶段,这里的校验相互独立。

这个阶段的校验流程如下所示,所有校验过程存在infer_unet_standalong.cpp中的函数void check_all()中,在OpenWanderary的根目录下执行sudo ./build/examples/BPU/infer_unet_standalong --mode check保存所属有输出结果为npy文件,之后在torchdnn的根目录下执行python3 demos/unet/check_cpp_onboard.py完成各个阶段的输出数据校验。

[BPU部署教程] 万字长文!通透解读模型部署端到端大流程——以终为始,以行为知_第20张图片

值得注意,npz文件在python下的读写很简单,但在校验过程中,大部分代码在C++中实现,有npz文件读写的需求。因此我们使用了一个库cnpy来满足我们的需求,github地址为:https://github.com/rogersce/cnpy。我将其中的核心代码复制到OpenWanderary/3rdparty/cnpy中,编译时候已经链接了这个库了。void check_all()的代码细节如下所示,所有的校验过程代码都记录在这里。

void check_all()
{
  std::string dataroot = "projects/torchdnn/data/unet/";
  std::string npzpath = dataroot + "unet_checkstage2.npz";
  std::string binpath = dataroot + "model_output/unet.bin";
  std::string savepath = dataroot + "unet_checkcppresults.npz";

  // 加载各阶段理论值npz文件
  cnpy::npz_t datanpz = cnpy::npz_load(npzpath);

  cv::Mat datain, dataout, pred;

  // (1) 预处理校验过程:输入理论图像数据,返回预处理结果
  LOG(INFO) << "Start check_preprocess";
  // 通过字符串可直接访问npz中的数据,返回cnpy::NpyArray
  check_preprocess(datanpz["image"], datain);

  // (2) 后处理校验过程:输入推理理论输出,返回后处理预测结果
  LOG(INFO) << "Start check_postprocess";
  check_postprocess(datanpz["dataout"], pred);

  // (3) 推理校验过程:输入推理所需的理论值,返回推理结果
  LOG(INFO) << "Start check_infer";
  check_infer(binpath, datanpz["datain"], dataout);

  // 保存各个阶段的输出值到npz文件
  LOG(INFO) << "Start saving results";
  cnpy::npz_save(savepath, "datain", (unsigned char*)datain.data, get_shape(datain), "w"); 
  cnpy::npz_save(savepath, "dataout", (float*)dataout.data, get_shape(dataout), "a"); 
  cnpy::npz_save(savepath, "pred", (unsigned char*)pred.data, get_shape(pred), "a"); 
  LOG(INFO) << "Finish saving results in " << savepath;
} 

① C++板端预处理校验

板端预处理校验就是检查C++版的preprocess_onboard是否正确,函数输入是标准的图像格式(不是多维矩阵的构造方式),也就是img.rows>0

arr_image的输入维度我们是预先知道的,矩阵排布为 H W C HWC HWC,数据类型为uint8,因此利用img.create(arr_image.shape[0], arr_image.shape[1], CV_MAKETYPE(CV_8U, arr_image.shape[2]));完成图像矩阵的定义,假如图像为3通道,则CV_MAKETYPE(CV_8U, arr_image.shape[2])等价于CV_8UC3

img.total() * img.elemSize()是这个矩阵的总共字节数,利用memcpy实现内存的拷贝。

预处理函数校验的代码细节如下所示:

void check_preprocess(const cnpy::NpyArray &arr_image, cv::Mat &datain)
{
  // Load image
  cv::Mat img;
  CV_Assert(arr_image.shape.size() == 3);
  img.create(arr_image.shape[0], arr_image.shape[1], CV_MAKETYPE(CV_8U, arr_image.shape[2]));
  memcpy(img.data, arr_image.data<unsigned char>(), img.total() * img.elemSize());

  // Get datain
  preprocess_onboard(img, 256, 256, datain);
}

② C++板端后处理校验

板端后处理校验就是检查C++版的postprocess是否正确。要注意,函数输入是多维矩阵,这时构造的后处理输入矩阵dataout.rows<0

我们已经预先知道了后处理输入是一个4维float的矩阵,因此数据类型指定为CV_32FC1即可,后处理函数校验的代码细节如下所示:

void check_postprocess(const cnpy::NpyArray &arr_dataout, cv::Mat &pred)
{
  // Load dataout
  cv::Mat dataout;
  std::vector<int> dims = get_dims(arr_dataout);
  dataout.create(dims.size(), dims.data(), CV_32FC1);
  memcpy(dataout.data, arr_dataout.data<unsigned char>(), dataout.total() * dataout.elemSize());

  // Get pred
  postprocess(dataout, pred);
}

③ C++板端推理校验

板端推理校验就是检查C++版的BPU推理是否正确,我们只需要给它推理输入即可。要注意,函数输入是多维矩阵,这时构造的后处理输入矩阵dataout.rows<0。我们已经预先知道了后处理输入是一个4维float的矩阵,因此数据类型指定为CV_32FC1即可。

这个校验过程的代码包含一堆BPU模型加载/初始化/释放相关的代码,为了减少冗余,我只放上不一样的地方。

void check_infer(const std::string &binpath, const cnpy::NpyArray &arr_datain, cv::Mat &dataout)
{
  // Load datain
  cv::Mat datain;
  CV_Assert(arr_datain.shape.size() == 4);
  std::vector<int> dims = get_dims(arr_datain);
  datain.create(dims.size(), dims.data(), CV_8UC1);
  memcpy(datain.data, arr_datain.data<unsigned char>(), datain.total() * datain.elemSize());
  
  ....... // 省略模型加载/Tensor初始化等代码
  
  // 预处理数据memcpy至BPU。在推理过程:这里是要加载图像→预处理得到 datain。
  memcpy(tensor.sysMem[0].virAddr, datain.data, datain.total() * datain.elemSize());
  
  ....... // 省略模型推理,任务释放等代码
  
  // 记录模型推理输出到dataout。在推理过程:这里需要利用dataout进行后处理得到最终pred推理结果。
  dataout.create(output_tensors[0].properties.alignedShape.numDimensions, 
                 output_tensors[0].properties.alignedShape.dimensionSize, CV_32FC1);
  memcpy(dataout.data, (unsigned char *)output_tensors[0].sysMem[0].virAddr, dataout.total() * dataout.elemSize());
  
  ....... // 省略模型释放/Tensor内存释放等代码
}

三 基于wdr::BPU的模型部署方案

从上面内容的介绍,我们可以了解了整体BPU的部署方案,整个方案是比较长的,特别是C++部署。

  • 官方提供的BPU函数接口是C语言的。C语言是面向过程的语言,因此对于开发者来说,就存在很多不安全地方:操作是指针,内存分配和释放由用户指定,因此需要较多的学习和Debug成本。
  • C++是面向对象的。既然是面向对象,就要考虑到开发者可能面临的一些错误,也就意味着C语言中存在的一些不安全性要从工具/代码的角度主动避免。

为了降低部署BPU的各种不安全性和开发成本,我总结了自己开发过程中遇到的一些问题,设计了一个BPU部署工具OpenWanderary(WDR)。WDR开发了2个月,利用业余时间开发完成,代码量接近3k行,开发模式参考了Effective C++,尽可能参考其中的条款。WDR的设计,是简化用户操作难度,加速部署效率,我走过的坑不希望你们重复走。剩下的时间做些更有意义的事情。

这套工具具有以下几个优点:

  • 不需要开发者特意去学习更多的数据类型。数据操作以OpenCV的Mat为主,只要OpenCV用的比较熟,就很容易理解这个框架的使用方法。
  • 大量重载运算符来意会功能
    • 比如访问第i个网络,直接用net[i]即可。
    • 若想将推理输入Mat矩阵datain输入到第i个tensor中,则bpumats[i] << datain即可。将第i个tensor数据输出到Mat矩阵dataout中,则bpumats[i] >> dataout即可。这样极大降低用户操作成本。
  • 规避了大量潜在的用户操作成本
    • 代码中大量使用CV_Error来判断用户的输入是否合法,不合法的输入将会给出详细的报错信息。
    • 代码中也补充了大量开发者可能需要的API,矩阵/推理的基本操作都已经实现了。输入Tensor存在数据对齐问题,用户通过指定某个变量维true,交给工具完成自动对齐。
  • Mode和Tensor的内存是自动释放的。在使用时,我们只需要初始化一下Tensor即可,在代码结束后,释放工作由库自动调用析构函数完成。
  • 提供了多个功能的独立API函数,方便做更灵活的二次开发(目前还在测试中,后续会根据自己的需求不断完善)。

WDR中由三个关键的Class,这里简要说明下其作用,更多功能可以查看wanderary/BPU/bpu.h

  • BpuNets:多模型加载,初始化Tensor,以及推理。
  • BpuMats:模型的输入/输出组,用于推理。
  • BpuMat:每个Tensor的数据交互,BpuMats[idx]返回的就是BpuMat类型。

这个工具目前还在不断优化中,这里会给出一些demo来展示开发的库的方便性,文档之类的,待经过大量验证, 成熟了之后会单独发版,如果各位在使用时候出现Bug,欢迎反馈,一起调试。(业余时间开发的,时间很紧张,使用时候出现的问题求各位轻喷 )

下面基于WDR工具,给出两种C++部署功能。

3.1 利用WDR打印模型参数信息

代码细节见examples/BPU/print-infos.cpp中的函数test_class,在OpenWanderary的根目录下执行sudo ./build/examples/BPU/print-infos --binpath projects/torchdnn/data/unet/model_output/unet.bin --mode class,即可输出模型的各种信息。

int test_class(const boost::filesystem::path &binpath)
{
  // 0. 加载模型
  wdr::BPU::BpuNets nets;
  nets.readNets({binpath.string()}); // 加载模型

  // 1. 打印模型个数,以及模型名称
  std::stringstream ss;
  ss << "model num: " << nets.total() << ", model names: ";
  for (int k = 0; k < nets.total(); k++)
    ss << nets.index2name(k) << ", ";
  LOG(INFO) << ss.str(), ss.clear();
  // I0623 18:25:22.159559 1268383 print-infos.cpp:61] model num: 1, model names: unet

  // 2. 打印第一个模型的输入Tensor的第一个Tensor
  LOG(INFO) << nets[0][wdr::BPU::NET_INPUT][0];
  // I0623 18:25:22.159651 1268383 print-infos.cpp:64] [hbDNNTensorProperties] List Properties:
  // {
  //   "alignedShape": "[hbDNNTensorShape] dim: 4 [1x256x256x4]",
  //   "quantiType": 262144,
  //   "scale": "[hbDNNQuantiScale] scaleLen: 0, scaleData: 0 [], zeroPointLen: 0, zeroPointData:  []",
  //   "shift": "[hbDNNQuantiShift] shiftLen: 3, shiftData: [  0,   0,   0]",
  //   "tensorLayout": "[hbDNNTensorLayout]: HB_DNN_LAYOUT_NHWC",
  //   "tensorType": "[hbDNNDataType]: HB_DNN_IMG_TYPE_BGR",
  //   "validShape": "[hbDNNTensorShape] dim: 4 [1x256x256x3]"
  // }
  
  // 3. 打印第一个模型的输出Tensor的第一个Tensor
  LOG(INFO) << nets[0][wdr::BPU::NET_OUTPUT][0];
  // I0623 18:25:22.160048 1268383 print-infos.cpp:67] [hbDNNTensorProperties] List Properties:
  // {
  //   "alignedShape": "[hbDNNTensorShape] dim: 4 [1x2x256x256]",
  //   "quantiType": 524288,
  //   "scale": "[hbDNNQuantiScale] scaleLen: 0, scaleData: 0 [], zeroPointLen: 0, zeroPointData:  []",
  //   "shift": "[hbDNNQuantiShift] shiftLen: 0, shiftData: []",
  //   "tensorLayout": "[hbDNNTensorLayout]: HB_DNN_LAYOUT_NCHW",
  //   "tensorType": "[hbDNNDataType]: HB_DNN_TENSOR_TYPE_F32",
  //   "validShape": "[hbDNNTensorShape] dim: 4 [1x2x256x256]"
  // }
  
  // 4. 打印所有模型参数信息
  for (int k = 0; k < nets.total(); k++)
    LOG(INFO) << nets.index2name(k) << ", " << nets[k];
  // I0623 18:25:22.160290 1268383 print-infos.cpp:71] unet, [Net] All Input && Output Tensor Properties:
  // {
  //   "Input": [
  //     {
  //       "alignedShape": "[hbDNNTensorShape] dim: 4 [1x256x256x4]",
  //       "quantiType": 262144,
  //       "scale": "[hbDNNQuantiScale] scaleLen: 0, scaleData: 0 [], zeroPointLen: 0, zeroPointData:  []",
  //       "shift": "[hbDNNQuantiShift] shiftLen: 3, shiftData: [  0,   0,   0]",
  //       "tensorLayout": "[hbDNNTensorLayout]: HB_DNN_LAYOUT_NHWC",
  //       "tensorType": "[hbDNNDataType]: HB_DNN_IMG_TYPE_BGR",
  //       "validShape": "[hbDNNTensorShape] dim: 4 [1x256x256x3]"
  //     }
  //   ],
  //   "Output": [
  //     {
  //       "alignedShape": "[hbDNNTensorShape] dim: 4 [1x2x256x256]",
  //       "quantiType": 524288,
  //       "scale": "[hbDNNQuantiScale] scaleLen: 0, scaleData: 0 [], zeroPointLen: 0, zeroPointData:  []",
  //       "shift": "[hbDNNQuantiShift] shiftLen: 0, shiftData: []",
  //       "tensorLayout": "[hbDNNTensorLayout]: HB_DNN_LAYOUT_NCHW",
  //       "tensorType": "[hbDNNDataType]: HB_DNN_TENSOR_TYPE_F32",
  //       "validShape": "[hbDNNTensorShape] dim: 4 [1x2x256x256]"
  //     }
  //   ],
  //   "name": "unet"
  // }
  return 0;
}

3.2 利用WDR实现UNet推理

代码细节见examples/BPU/infer-unet.cpp中的函数test_class,在OpenWanderary的根目录下执行sudo ./build/examples/BPU/infer-unet --mode class,即可输出模型的各种信息。

int test_class()
{
  const std::string modelname = "unet";
  // 1. 加载模型
  wdr::BPU::BpuNets nets;
  nets.readNets({binpath});
  int idxmode = nets.name2index(modelname);
  LOG(INFO) << "model index: " << idxmode;
  CV_Assert(idxmode >= 0);

  // 2. 加载图像
  cv::Mat img;
  wdr::get_bgr_image(imgpath, img);
  LOG(INFO) << "Finish load bgr image";

  // 3. 内存分配
  wdr::BPU::BpuMats input_mats, output_mats;
  nets.init(idxmode, input_mats, output_mats, true);
  LOG(INFO) << "input tensor num: " << input_mats.size() << ", output tensor num: " << output_mats.size();

  // 3. 构造预处理输出,模型输入是256,256
  cv::Mat datain;
  cv::Size modsize = input_mats[0].size(false);
  LOG(INFO) << "Input model size: " << modsize;

  wdr::preprocess_onboard_NHWC(img, modsize.height, modsize.width, datain);
  input_mats[0] << datain; // datain数据拷贝到Tensor里
  input_mats.bpu();        // 更新数据到BPU中
  LOG(INFO) << "Finish preprocess";

  // 4. 模型推理
  cv::Mat dataout;
  nets.forward(idxmode, input_mats, output_mats);
  output_mats.cpu();         // 从BPU中下载数据
  output_mats[0] >> dataout; // 从Tensor里拷出数据到dataout
  LOG(INFO) << "Finish infer";

  // 5. 构造后处理数据,并保存最终预测结果
  std::vector<cv::Mat> preds;
  wdr::parseBinarySegmentResult(dataout, preds);
  for (int k = 0; k < preds.size(); k++)
    cv::imwrite(saveroot + "pred_cpp_wdr_" + std::to_string(k) + ".png", preds[k]);

  // 6. 保存校验数据
  std::string savepath = saveroot + "unet_check_wdrresults.npz";
  LOG(INFO) << "Start saving results";
  cnpy::npz_save(savepath, "datain", (unsigned char *)datain.data, wdr::get_shape(datain), "w");
  cnpy::npz_save(savepath, "dataout", (float *)dataout.data, wdr::get_shape(dataout), "a");
  cnpy::npz_save(savepath, "pred", (unsigned char *)preds[0].data, wdr::get_shape(preds[0]), "a");
  LOG(INFO) << "Finish saving results in " << savepath;

  // 内存释放由代码的析构函数自动完成,无需主动调用

  return 0;
}

代码执行之后,会输出如下信息,预测结果图保存在projects/torchdnn/data/unet/pred_cpp_wdr_0.png,校验信息存在projects/torchdnn/data/unet/unet_check_wdrresults.npz,通过调用projects/torchdnn/demos/unet/check_wdr_onboard.py,确保了C++推理结果和Python版本的推理结果是一致的。

I0623 18:30:25.858119 1282517 infer-unet.cpp:38] mode: class
[BPU_PLAT]BPU Platform Version(1.3.1)!
[HBRT] set log level as 0. version = 3.14.5
[DNN] Runtime version = 1.9.7_(3.14.5 HBRT)
I0623 18:30:26.150298 1282517 infer-unet.cpp:58] model index: 0
I0623 18:30:26.158201 1282517 infer-unet.cpp:64] Finish load bgr image
I0623 18:30:26.158665 1282517 infer-unet.cpp:69] input tensor num: 1, output tensor num: 1
I0623 18:30:26.158731 1282517 infer-unet.cpp:74] Input model size: [256 x 256]
I0623 18:30:26.161554 1282517 infer-unet.cpp:79] Finish preprocess
I0623 18:30:26.261695 1282517 infer-unet.cpp:86] Finish infer
I0623 18:30:26.266692 1282517 infer-unet.cpp:96] Start saving results
I0623 18:30:26.274824 1282517 infer-unet.cpp:100] Finish saving results in projects/torchdnn/data/unet/unet_check_wdrresults.npz

四 总结

去年11月-6月,历时8月,凝练出这个博客。创作空间统计的字数是4w+,预估阅读时间超过1个小时,哈哈哈。为了讲明白一件事,我自己不断的优化文案,废弃的文案也大概1w了。不断在打磨,就是在想办法提供给读者干货,删了一堆,最开始我想讲怎么配置cmake,vscode插件,C接口api也要想写个文档。后来发现,这没意义,我需要提供个各位的是部署意识,而不是字典类型的博客。从表达上也可能直接说清楚。

个人认为BPU部署三部曲,足够让不了解BPU的人能够踏入开发的大门。本系列的结束,是另一个内容的开始。这段时间内的整理,也发现BPU部署工具中存在一些地方可以优化,优化后可以让每个开发者轻松上阵,优化点整理如下:

  • 从pytorch到bin模型这个阶段应该有一个可靠的工具。最终的目标就是谁都可以用,不需要了解太多,类似GPT一样,我需要什么就能给我什么。
  • 要不断打磨底层API接口。目前用起来还行,除了数据对齐这个问题坑了一段时间。文档手册每个函数最好都能提供一个example来讲清楚用法。
  • 模型转换过程存在不完备的验证。量化的onnx和板端的bin文件能确保结果一致吗,最好在开发板上也能安装horizon_nn
  • 简化部署/校验流程。流程多那就补充个可视化界面,每个工具,把明确输入+明确差异,写清楚,就几段话的事情。我开发时候经常面临问都不知道咋问的情况。

对于部署的未来工作:

  • 不断打磨WDR库,修复其中的bug,而且对于工具链的更新也会尽可能做好适配。
  • 将历史博客部署的一些算法的预处理和后处理用wdr实现推理。

博客内容较多,可能会存在一些错误,错误修复后我会及时更新在WDR仓库里,希望各位多多关注。

你可能感兴趣的:(地平线开发工具,人工智能,python,开发语言,BPU,旭日x3开发板)