模型在CPU上部署
1 导出ONNX文件并进行推理
我们在第一篇文章中介绍过如何导出并使用ONNX文件,但没有交代ONNX文件是什么。ONNX(Open Neural Network Exchange),翻译成中文为开放神经网络交换(所谓的开放,可以理解为“通用性”),是一种模型IR(Intermediate Representation),用于在各种深度学习训练和推理框架转换的一个中间表示格式。在实际业务中,可以使用Pytorch或者TensorFlow训练模型,导出成ONNX格式,然后在转换成目标设备上支持的模型格式,比如TensorRT Engine、NCNN、MNN等格式。ONNX定义了一组和环境、平台均无关的标准格式,来增强各种AI模型的可交互性,因此开放性较强。
上面的文字太过于学术,我说简单一点,ONNX文件的作用就是在不依赖Pytorch/TensorFlow等训练框架的情况下,做到模型的正向推理。
由于在AutoDL服务器上训练的效果比价好,因此我们使用服务器上训练得到的权重(runs/train/exp2是云服务器上的训练结果,我把它下载到了本地),要导出ONNX文件,在命令行中输入以下命令:
python export.py --weights runs/train/exp2/weights/best.pt --include onnx
此时如果使用onnx文件推理,则需要指定数据配置文件,否则默认使用data/coco128.yaml,例如,在命令行输入
python detect.py --source uav_bird_training/data/images/valid/valdrone-1.jpg --weights runs/train/exp2/weights/best.onnx
我们打开 runs\detect\exp2 查看检测结果:
bicycle并不是我们数据集中的类,问题在于未指定数据配置文件。现在我们指定数据配置文件,在命令行中输入:
python detect.py --source uav_bird_training/data/images/valid/valdrone-1.jpg --weights runs/train/exp2/weights/best.onnx --data uav_bird_training/dataset.yaml
我们打开 runs\detect\exp3 查看检测结果:
这下正常了。
上一篇文章中,我们使用pt文件直接进行推理时,同样没有使用自定义的数据配置文件,这意味着使用的是默认的data/coco128.yaml。但最后却没有把无人机预测成自行车,之所以这样,是因为在保存pt文件时,除了模型权重,还把训练集的类型名也给保存进去了,简而言之,就是runs/train/exp/weights/best.pt中包括了类名。(如果不相信,可以自己debug一下detect.py,使用best.pt文件)
2 OpenCV DNN部署
我们训练好的模型,该如何给别人使用,难道直接把我们本地的项目文件夹扔给他吗?真实场景中,深度学习模型经常只是为了完成某个大项目中的某项功能,如果我们把训练的项目结构集成到这个大项目中,会非常麻烦,除了遇见各种路径问题,还需要把训练环境在别人的设备上重新安装一遍。而所谓的部署,就是解决这个问题,让模型与训练环境解耦合,摆脱对训练过程中所建立的各种类/函数(例如YOLO类、Head类等)的依赖,甚至是对训练框架的依赖。
这一节算是本文的核心,因为不同推理框架的流程大致相似,都是图像预处理—>模型推理—>推理结果后处理,后面的几种推理框架都是以本章为基础,图像预处理和推理结果后处理的代码和本节基本一致。这里需要强调的是,OpenCV必须是4.5.4之后的版本,在此之前的版本则会出现个别算子不支持的情况。本机使用的CPU型号为:Intel® Core™ i7-10870H,不同型号的CPU,运行本文的程序所花的时间不一样。
(1)推理类的基本结构
在yolov5-6.1目录下新建一个名为inference_opencv.py的文件,我们在里面新建一个名为Inference_Opencv的类,该类要实现初始化方法、图像预测(模型推理)、图像预处理、推理结果后处理、边框绘制等方法,大致结构如下:
class Inference_Opencv():
def __init__(self, **kwargs):
"""
初始化方法
"""
pass
def pred_img(self, **kwargs):
"""
预测图像
"""
pass
def preprocess(self, **kwargs):
"""
图像预处理
"""
pass
def wrap_detection(self, **kwargs):
"""
推理结果后处理,先进行置信度过滤,然后再调用OpenCV的NMS-API
"""
pass
def draw_boxes(self, **kwargs):
"""
绘制边框
"""
pass
(2)初始化方法
在初始化方法中,我们要实现模型文件(ONNX文件)的导入、类名的导入、相关阈值的设置、输出目录的创建等工作。其中,ONNX文件导入到OpenCV中可以调用OpenCV的相关API,类名的导入可以从数据集的配置文件(yaml文件)中获取,而阈值主要分两种,即分数阈值(有的资料也叫置信度阈值)与IOU阈值(有的资料也叫NMS阈值),还有模型的输出尺寸,这个我觉得既然使用的是yolov5s.pt模型,训练时已经规定了其输入为640,因此可以写成类属性,当然,也可以将输入尺寸通过参数传入到初始化方法中。
因此初始化方法如下:
import os
import cv2
import time
import yaml
import numpy as np
class Inference_Opencv():
# 全局设置(也可以在__init__中将它们设置成实例属性)
INPUT_WIDTH = 640
INPUT_HEIGHT = 640
def __init__(self, onnx_path, yaml_path, score_threshold=0.25, nms_threshold=0.45, out_dir='out'):
"""
初始化方法
Args:
onnx_path:
yaml_path: 数据集配置文件路径,这里主要是通过它来获取数据集有哪些类别
score_threshold: 置信度得分阈值
nms_threshold: NMS时的IOU阈值
out_dir: 检测结果保存目录,暂时只能保存图像,摄像头/视频后续可以加
"""
# 获取类列表
with open(yaml_path, "r", errors='ignore') as f:
self.class_list = yaml.safe_load(f)['names']
# 创建模型,并做相应的设置
self.net = cv2.dnn.readNet(onnx_path)
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV) # 依赖的推理后端(框架),如果不选择OPENCV,则需重新编译
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) # 依赖的硬件设备,这个看自己有什么
# 绘制预测框、文字所用的颜色
self.colors = [(255, 255, 0), (0, 255, 0), (0, 255, 255), (255, 0, 0)]
# 预测框过滤相关的阈值设置
self.score_threshold = score_threshold
self.nms_threshold = nms_threshold
# 检测结果保存目录
self.out_dir = out_dir
if not os.path.exists(self.out_dir):
os.makedirs(self.out_dir)
(3)图像预测
在这个方法中,我们需要读入图像、图像预处理、模型推理、推理结果后处理、计算FPS、画框(绘图)、结果保存等操作,其中图像预处理、预测结果后处理、画框等操作我这都封装成了API,使这里的代码更具简洁性,稍后会对它们进行详细讲解。预测图像的代码如下:
def pred_img(self, img_path):
"""
预测图像
Args:
img_path: 图像路径
"""
start = time.time()
# 读取图像并预处理
image = cv2.imread(img_path)
inputImage, factor, (dh, dw) = self.preprocess(image,
(Inference_Opencv.INPUT_HEIGHT, Inference_Opencv.INPUT_WIDTH))
# 设置模型的输入
self.net.setInput(inputImage)
# 前向传播
outs = self.net.forward()
# 解析推理结果(后处理)
class_ids, scores, boxes = self.wrap_detection(outs[0])
# 绘图
image = self.draw_boxes(image, factor, (dh, dw), class_ids, scores, boxes)
# 计算fps
end = time.time()
inf_end = end - start
fps = 1 / inf_end
fps_label = "FPS: %.2f" % fps
cv2.putText(image, fps_label, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
# 保存
basename = os.path.basename(img_path)
cv2.imwrite(os.path.join(self.out_dir, basename), image)
(4)图像预处理
图像预处理就是将图像处理成模型训练时喂进去时的样子,喂进去之间进行了哪些操作,这里就对图像进行哪些操作(数据增强除外)。想知道喂进去时的样子,最好是进行debug,由于YOLOv5项目给我们写好了detect.py文件,里面有关于数据预处理的代码,因此我们对其debug。
在Pycharm中,按如下方式配置命令行参数:
点击Run——Edit Configurations
然后我们在调用main方法的位置打断点:
我们查看的顺序为:进入main方法 ——> 进入run方法 ——> 遍历dataset的for循环 ——> 进入LoadImages的__iter__
方法和__next__
方法。这里有个反常操作,一般情况下都是建立一个数据集导入器去包裹数据集类,这里是直接用for去遍历数据集类对象,可能是处理单张图片的原因吧,如果能用for直接遍历数据集类对象,则类内部一定实现了__iter__
方法和__next__
方法,我们这里就是。
在LoadImages的__next__
方法中(第224~228行),对图像进行了处理,包括了letterbox变换、通道前置、BGR转RGB、数据连续性设置(因为进行了通道变换,因此获得的数据并不连续),代码如下:
detect.py的图像预处理还有一个坑,即数据预处理并非都在__next__
方法进行,还有一部分在方法外,我们回到迭代dataset的for循环,在第117~121行:
从这里对图像的处理,包括:转numpy、转半精度(若需要)、归一化、扩维(若需要)。这里因为half为False,因此并没有将图像数据(数组im)转成半精度,同时im是三维的,因此需要扩维操作。在这里对图像(数组im)的操作结束后,再次出现im已经是在推理阶段了,因此预处理至此结束。
好了,我们可以归纳一下,图像预处理包括了这些步骤:letterbox变换、通道前置、BGR转RGB、数据连续性设置、转numpy、归一化、扩维。其中,缩放、通道前置、通道前置、BGR转RGB、数据连续性设置、转numpy、归一化等操作,可以直接使用OpenCV的API解决,这个API我们稍后会介绍。
缩放比例和letterbox计算得到的偏移需要返回,因为后处理时需要对边框的位置和大小进行还原,填充用的颜色根据对detect.py进行debug时,进入letterbox方法可以获得。
综上,图像预处理的代码如下:
def preprocess(self, image, new_shape=(640, 640), color=(114, 114, 114)):
"""
图像预处理
Args:
image: opencv读取的图像,BRG通道
new_shape: 模型的输入尺寸
color: 灰度条填充的颜色
Returns: 预处理后的图像数组、缩放因子(新图尺寸/老图尺寸)、letterbox后的图像相对于原图的偏移
"""
# 获取图像的高宽
h, w = image.shape[:2]
# 根据长边计算缩放比例(new/old)
r = min(new_shape[0]/h, new_shape[1]/w) # 因为是原图的高宽做分母,因此分母越大,比例越小
# 计算缩放后,但未padding的图像尺寸
new_unpad_h, new_unpad_w = int(round(h * r)), int(round(w * r))
# 缩放
im = cv2.resize(image, (new_unpad_w, new_unpad_h), interpolation=cv2.INTER_LINEAR)
# 计算padding的灰度条宽度
dh = (new_shape[0] - new_unpad_h) / 2
dw = (new_shape[1] - new_unpad_w) / 2
# 灰度条宽度调整,因为dh和dw有可能是小数,比如30.5、40.5等,这种情况下一边加0.1、对边减0.1
top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
# 灰度调填充
im = cv2.copyMakeBorder(im, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color) # add border
# 通道前置、BGR转RGB、数据连续性设置、归一化、扩维
im = cv2.dnn.blobFromImage(im, 1/255.0, swapRB=True)
return im, r, (dh, dw)
这里面出现的cv2.dnn.blobFromImage
函数就是刚刚提到的API,这个函数非常重要,它的参数如下:
cv2.dnn.blobFromImage(image, scalefactor=1.0, size=None, mean=None, swapRB=True, crop=False, ddepth=cv2.CV_32F)
作用:
对图像进行预处理,包括减均值,比例缩放,裁剪,交换通道等,返回一个4通道的blob(blob可以简单理解为一个N维的数组,用于神经网络的输入)
参数:
image:输入图像(1、3或者4通道),因为OpenCV默认其是图像,因此其类型必须是np.uint8,否则报错
可选参数
scalefactor:图像各通道数值的缩放比例(图像减去mean之后缩放的比例)
size:图像要转化成的空间尺寸,如size=(200,300)表示高h=300,宽w=200,相当于resize
mean:用于各通道减去的值,以降低光照的影响(e.g. image为BGR3通道的图像,mean=[104.0, 177.0, 123.0],表示b通道的值-104,g-177,r-123)
如果scalefactor和mean都是一个值(而不是包含三个数字的列表/元组),则是同时对各个通道都使用
swapRB:交换RB通道,默认为False.(cv2.imread读取的是彩图是bgr通道)
crop:图像裁剪,默认为False.
当值为True时,先按保持原来的高宽比缩放,直到其中一条边等于对应方向的长度,另一条边大于对应方向长度,然后从中心裁剪成size尺寸;
如果值为False,则不管高宽比,直接缩放成指定尺寸(即size参数)。
e.g.原图(300, 200),目标尺寸(400, 300)
若crop=True, (300, 200) --resize-->(450, 300)--crop-->(400, 300)
若crop=False, (300, 200) --resize-->(400, 300)
ddepth:输出的图像深度,可选CV_32F 或者 CV_8U,如果做过OpenCV C++开发,对这个可能比较了解,但在深度学习中这个参数不用关注.
因为图像预处理需要和yolov5-6.1/detect.py中的数据预处理程序对齐,因此讲的比较多。
遇见不太懂的API,现在不需要去看文档了,直接问GPT
也可以让GPT直接帮我们写函数,然后我们自己进行调试修改,例如:
(5)图像后处理
预测推理之后,会产生25200个预测框,这些预测框未必都是合格的,我们需要对它们进行筛选。筛选的标准流程分两轮,一是使用置信度过滤(即得分过滤),二是进行非极大值抑制。这两步可以调用cv2.dnn.NMSBoxes
一次性完成,但让OpenCV直接处理那么多预测框,影响推理速度,因此可以先对目标置信度(P(obj))和得分(score)进行过滤。因为目标置信度小于1,类别置信度也小于1,那么当P(obj)<score_conf
时,必然score<score_conf
,即这类边框必然是不符合要求的,提前筛掉减轻cv2.dnn.NMSBoxes
的负担。
关于分数阈值(又称置信度阈值)和NMS阈值(又称IOU阈值),这个可以对detect.py进行debug时可以看到,parse_opt()
中的默认参数中可以找到。
后处理代码如下:
def wrap_detection(self, output_data):
"""
推理结果后处理,先进行置信度过滤,然后再调用OpenCV的NMS-API
Args:
output_data: 模型的输出信息
Returns:
"""
class_ids = []
scores = []
boxes = []
# 获取筛选前的box数目,该数目是25200
rows = output_data.shape[0]
for r in range(rows):
# 获取第r个检测框
row = output_data[r]
# 获取目标置信度,即框内是否存在目标的概率P(obj)
confidence = row[4]
# 置信度过滤(初筛)
if confidence >= self.score_threshold:
# 获取类别置信度,即框内存在目标的条件下,目标是某个类别的概率 max{P(cls_i/obj)|i=0...num_cls}
classes_probility = row[5:]
_, _, _, max_indx = cv2.minMaxLoc(classes_probility) # 获取最大概率所在索引
class_id = max_indx[1]
p_cls = classes_probility[class_id]
# 计算置信度得分 假设框内第k个类别的概率最大,则其得分为:score = P(obj) * P(cls_k/obj)
score = confidence * p_cls
# 过滤掉置信度得分比较低的边框
if (score > self.score_threshold):
scores.append(score) # 将置信度得分保存
class_ids.append(class_id) # 将类别ID保存
# 获取预测框的位置及宽高(0-1)
x, y, w, h = row[0].item(), row[1].item(), row[2].item(), row[3].item()
# 边框宽高还原(还原成letterbox中的高宽)
left = int(x - 0.5 * w)
top = int(y - 0.5 * h)
width = int(w)
height = int(h)
box = np.array([left, top, width, height])
boxes.append(box)
# 非极大值抑制,返回的将是符合条件的boxes的索引
# 也可以不进行初筛,即通过OpenCV自带的NMS的API一次性完成置信度过滤和非极大值抑制
# 但不进行初筛的话,全扔给NMS会增加工作量,降低FPS,可以自己尝试一些
indexes = cv2.dnn.NMSBoxes(boxes, scores, self.score_threshold, self.nms_threshold)
# 根据索引从boxes、class_ids、confidences中提取信息
result_class_ids = []
result_confidences = []
result_boxes = []
for i in indexes:
result_confidences.append(scores[i])
result_class_ids.append(class_ids[i])
result_boxes.append(boxes[i])
return result_class_ids, result_confidences, result_boxes
有些资料中,第一轮筛选时没有计算得分,而是直接使用目标置信度和阈值进行比较,以此完成第一轮过滤,并在非极大值抑制时,直接使用目标指标置信度替代得分。那第一轮过滤究竟是使用目标置信度,还是使用分数?我查看了YOLOv5和YOLOX的源码,里面都是使用分数,并且这个更符合条件概率公式。不过从预测结果来看,用目标置信度和分数,效果其实也没差太多,使用目标置信度对边框的筛选,得到的结果稍微严一点(能通过目标置信度筛选,必然能通过得分筛选)。
上述程序比较直观一些,有些人喜欢用numpy的广播,不喜欢用for循环,那么可以用下面的程序替代:
def wrap_detection2(self, output_data):
"""
推理结果后处理,先进行置信度过滤,然后再调用OpenCV的NMS-API
Args:
output_data: 模型的输出信息,二维numpy数组
Returns:
"""
# 先对目标置信度进行过滤
xc = output_data[:, 4] > self.score_threshold
candidates = output_data[xc]
# 各个类别的概率乘上目标置信度
candidates[:, 5:] *= candidates[:, 4:5]
# 计算置信度得分
scores = np.max(candidates[:, 5:], axis=1)
# 获取得分最高的类别索引
class_ids = np.argmax(candidates[:, 5:], axis=1)
# 根据得分再筛一遍
ids = scores > self.score_threshold
scores = scores[ids].tolist()
class_ids = class_ids[ids].astype(int).tolist()
candidates = candidates[ids]
# 对边框进行处理
lefts = (candidates[:, 0] - 0.5 * candidates[:, 2]).astype(int)
tops = (candidates[:, 1] - 0.5 * candidates[:, 3]).astype(int)
weights = candidates[:, 2].astype(int)
heights = candidates[:, 3].astype(int)
boxes = np.vstack((lefts, tops, weights, heights)).transpose() # 确保每行是一个边框,所以转置
boxes = boxes.astype(int)
# 非极大值抑制,返回的将是符合条件的boxes的索引
# 也可以不进行初筛,即通过OpenCV自带的NMS的API一次性完成置信度过滤和非极大值抑制
# 但不进行初筛的话,全扔给NMS会增加工作量,降低FPS,可以自己尝试一些
indexes = cv2.dnn.NMSBoxes(boxes.tolist(), scores, self.score_threshold, self.nms_threshold)
# 根据索引从boxes、class_ids、confidences中提取信息
result_class_ids = []
result_confidences = []
result_boxes = []
for i in indexes:
result_confidences.append(scores[i])
result_class_ids.append(class_ids[i])
result_boxes.append(boxes[i])
return result_class_ids, result_confidences, result_boxes
使用广播机制带来的速度提升,我们后面会讲。
(6)画框
这个没什么好讲的,就是对边框信息还原,然后画在图上,代码如下:
def draw_boxes(self, img, factor, offet, class_ids, scores, boxes):
"""
绘制边框
Args:
img: 待绘制边框的图像,numpy数组
factor: 缩放因子(新图尺寸/老图尺寸)
offet: letterbox后的图像相对于原图的偏移(dh, dw)
class_ids: 边框的类别索引
scores: 边框的置信度得分
boxes: 边框的左上角点和右下角点横纵坐标
Returns: 绘制边框的img
"""
for (classid, score, box) in zip(class_ids, scores, boxes):
# 边框偏移还原(还原成缩放后、padding前,这部分只改变位置,不改变高宽)
box[0] = box[0] - offet[1]
box[1] = box[1] - offet[0]
# 边框缩放还原(还原成缩放前,这部分位置和高宽一起改变)
box = (box / factor).astype(int) # 将边框信息转回原图信息
# 获取边框颜色
color = self.colors[int(classid) % len(self.colors)]
# 画框
cv2.rectangle(img, box, color, 2) # 绘制边框
cv2.rectangle(img, (box[0], box[1] - 20), (box[0] + box[2], box[1]), color, -1) # 文字背景边框
# 目标目标文字
text = self.class_list[classid] + ' ' + str(round(score, 2))
cv2.putText(img, text, (box[0], box[1] - 10),
cv2.FONT_HERSHEY_SIMPLEX, .5, (0, 0, 0))
return img
(7)对比官方程序推理结果
我们先试用yolov5-6.1自带的detect.py进行图像预测,在命令行输入命令:
python detect.py --source uav_bird_training/data/images/train/20220318_01.jpg --data uav_bird_training/dataset.yaml --weights runs/train/exp2/weights/best.pt --device cpu
输出:
预测结果:
预处理、图像推理、后处理,加起来只用了115.7ms,可以说非常快,至于FPS,我也不知道它是怎么算的,或许内部有bug。
接下来我们来测试一下我们自己写的inference_opencv.py,在这个文件的最下面加入下面的测试程序:
if __name__ == '__main__':
onnx_path = "runs/train/exp2/weights/best.onnx"
yaml_path = "uav_bird_training/dataset.yaml"
inference_Model = Inference_Opencv(onnx_path, yaml_path)
# 对保存在磁盘上的图片进行推理
start = time.time()
inference_Model.pred_img('uav_bird_training/data/images/train/20220318_01.jpg')
print(time.time() - start)
输出:
2.14990496635437
测试结果:
从目标的score来看,数据是对齐了,都是0.52。从推理时间来看,我们自己写的基于OpenCV的部署程序,单张图片预测时间要两秒多,是官方推理时间的20倍。但官方的处理时间不包括读图、画框和保存,只包含预处理、模型前向传播和后处理三个步骤,接下来我们就来分析各个步骤的时间消耗。
(8)各个步骤的时间消耗分析
时间分析是算法工程师的一项重要能力,这里展示一下如何对各个步骤的时间消耗进行统计。在代码中,使用下面的def pred_img(self, img_path)
替代原先的图像预测方法,新方法对各个步骤的执行前后打了时间点,以便于统计时间消耗:
def pred_img(self, img_path):
"""
预测图像
Args:
img_path: 图像路径
"""
start = time.time()
# 读取图像并预处理
image = cv2.imread(img_path)
time1 = time.time()
print('read:', time1 - start)
inputImage, factor, (dh, dw) = self.preprocess(image,
(Inference_Opencv.INPUT_HEIGHT, Inference_Opencv.INPUT_WIDTH))
time2 = time.time()
print('preprocess:', time2 - time1)
# 设置模型的输入
self.net.setInput(inputImage)
# 前向传播
outs = self.net.forward()
time3 = time.time()
print('refer:', time3 - time2)
# 解析推理结果(后处理)
class_ids, scores, boxes = self.wrap_detection(outs[0])
time4 = time.time()
print('wrap_detection:', time4 - time3)
# 绘图
image = self.draw_boxes(image, factor, (dh, dw), class_ids, scores, boxes)
time5 = time.time()
print('draw boxes:', time5 - time4)
# 计算fps
end = time.time()
inf_end = end - start
fps = 1 / inf_end
fps_label = "FPS: %.2f" % fps
cv2.putText(image, fps_label, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
time6 = time.time()
print('compute fps:', time6 - time5)
# 保存
basename = os.path.basename(img_path)
cv2.imwrite(os.path.join(self.out_dir, basename), image)
time7 = time.time()
print('save:', time7 - time6)
输出
read: 0.0019958019256591797
preprocess: 0.00898122787475586
refer: 0.08274102210998535
wrap_detection: 0.03494095802307129
draw boxes: 2.0045762062072754
compute fps: 0.0
save: 0.0029914379119873047
2.1362266540527344
compute fps
的耗时是0.0,并不是说不花时间,而是时间太短,以至于可以忽略不计。
可以看到,总时间约为2.14秒,但画框就超过了2秒,也就是说,时间消耗的大头在画框的步骤。其他几步加起来才一百多毫秒,其中预处理、模型前向传播、后处理大概合计约126.7ms,虽然还是比官方(detect.py)处理时间(115.7ms)多,但已经没有像20倍那么夸张了。
另外,我们自己写的程序,图像预处理话的时间是官方时间的将近3倍(8.98/3),但官方程序的预处理时间,其实只包括转numpy、转半精度(若需要)、归一化和扩维的时间,关于letterbox、通道前置、BGR转RGB、数据连续性设置等内容都在数据集类的__getitem__
方法中,时间消耗不容易统计(需要改代码,让__getitem__
方法把这部分的时间消耗也传回来,但不建议改,就几毫秒而已),因此预处理时间的差距其实也没有数字表现出来的那么大。
如果把后处理方法改成用numpy的广播机制实现(即前面介绍的self.wrap_detection2
),代码如下:
def pred_img(self, img_path):
"""
预测图像
Args:
img_path: 图像路径
"""
start = time.time()
# 读取图像并预处理
image = cv2.imread(img_path)
time1 = time.time()
print('read:', time1 - start)
inputImage, factor, (dh, dw) = self.preprocess(image,
(Inference_Opencv.INPUT_HEIGHT, Inference_Opencv.INPUT_WIDTH))
time2 = time.time()
print('preprocess:', time2 - time1)
# 设置模型的输入
self.net.setInput(inputImage)
# 前向传播
outs = self.net.forward()
time3 = time.time()
print('refer:', time3 - time2)
# 解析推理结果(后处理)
class_ids, scores, boxes = self.wrap_detection2(outs[0]) # self.wrap_detection2内部使用numpy广播机制
time4 = time.time()
print('wrap_detection:', time4 - time3)
# 绘图
image = self.draw_boxes(image, factor, (dh, dw), class_ids, scores, boxes)
time5 = time.time()
print('draw boxes:', time5 - time4)
# 计算fps
end = time.time()
inf_end = end - start
fps = 1 / inf_end
fps_label = "FPS: %.2f" % fps
cv2.putText(image, fps_label, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
time6 = time.time()
print('compute fps:', time6 - time5)
# 保存
basename = os.path.basename(img_path)
cv2.imwrite(os.path.join(self.out_dir, basename), image)
time7 = time.time()
print('save:', time7 - time6)
输出:
read: 0.002026081085205078
preprocess: 0.009975433349609375
refer: 0.09271788597106934
wrap_detection: 0.0009980201721191406
draw boxes: 2.011383295059204
compute fps: 0.0
save: 0.0029935836791992188
2.121091842651367
注意看wrap_detection的耗时,这里不到1ms,这使得预处理、模型前向传播、后处理这三步加起来的耗时降到了103.7ms,由此可以看出,广播机制相比于for循环有巨大的优势。
3 OpenVINO部署
我们刚刚通过OpenCV,实现了模型在CPU上部署,但推理时间比较慢,不是很理想,此时,我们可以考虑使用OpenVINO。
OpenVINO是Intel公司推出的深度学习推理部署工具包,通过该工具,可以使模型部署在CPU和GPU上面,如果模型的部署设备是Intel自家的处理器或者集成显卡,则优先使用OpenVINO进行部署。关于OpenVINO的快速入门,可以看这篇文章。
推理文件既可以使用onnx文件,也可以使用OpenVINO自己的IR,根据使用经验,使用OpenVINO自己的IR,推理速度能快一些。OpenVINO的IR可以使用下面的命令导出:
python export.py --weights runs/train/exp2/weights/best.pt --include openvino
接下来,在yolov5-6.1下面新建一个名为inference_openvino.py的文件,里面新建一个名为Inference_Openvino的类,该类的结构与Inference_Opencv基本一样,改变的只有__init__
和pred_img
两个类内函数,并且也只是稍微改动了一点而已。这两个函数的代码如下:
import os
import cv2
import time
import yaml
import numpy as np
from openvino.runtime import Core
class Inference_Openvino():
# 全局设置(也可以在__init__中将它们设置成实例属性)
INPUT_WIDTH = 640
INPUT_HEIGHT = 640
def __init__(self, ie_path, yaml_path, score_threshold=0.25, nms_threshold=0.45, out_dir='out'):
"""
初始化方法
Args:
ie_path: 中间表示的文件路径
yaml_path: 数据集配置文件路径,这里主要是通过它来获取数据集有哪些类别
score_threshold: 置信度得分阈值
nms_threshold: NMS时的IOU阈值
out_dir: 检测结果保存目录,暂时只能保存图像,摄像头/视频后续可以加
"""
# 获取类列表
with open(yaml_path, "r", errors='ignore') as f:
self.class_list = yaml.safe_load(f)['names']
# 创建推理核
core = Core() # 创建推理核(推理引擎)
# 创建模型,并做相应的设置
if ie_path.endswith('.onnx'):
ov_model = core.read_model(model=ie_path) # 读取onnx文件
elif ie_path.endswith('.xml'):
ov_model = core.read_model(model=ie_path) # 读取xml文件
else:
exit(0)
self.compiled_model = core.compile_model(ov_model, 'CPU') # 编译到CPU
self.output_layer = self.compiled_model.output(0) # 获取输出层
# 绘制预测框、文字所用的颜色
self.colors = [(255, 255, 0), (0, 255, 0), (0, 255, 255), (255, 0, 0)]
# 预测框过滤相关的阈值设置
self.score_threshold = score_threshold
self.nms_threshold = nms_threshold
# 检测结果保存目录
self.out_dir = out_dir
if not os.path.exists(self.out_dir):
os.makedirs(self.out_dir)
def pred_img(self, img_path):
"""
预测图像
Args:
img_path: 图像路径
"""
start = time.time()
# 读取图像并预处理
image = cv2.imread(img_path)
time1 = time.time()
print('read:', time1 - start)
inputImage, factor, (dh, dw) = self.preprocess(image,
(Inference_Openvino.INPUT_HEIGHT, Inference_Openvino.INPUT_WIDTH)) # 这里输入图像的高宽(类属性),也只是换了类名而已
time2 = time.time()
print('preprocess:', time2 - time1)
# 模型正向推理
outs = self.compiled_model([inputImage])[self.output_layer] # 这里和Inference_Opencv的pred_img不一样
time3 = time.time()
print('refer:', time3 - time2)
# 解析推理结果(后处理)
class_ids, scores, boxes = self.wrap_detection2(outs[0]) # self.wrap_detection2内部使用numpy广播机制
time4 = time.time()
print('wrap_detection:', time4 - time3)
# 绘图
image = self.draw_boxes(image, factor, (dh, dw), class_ids, scores, boxes)
time5 = time.time()
print('draw boxes:', time5 - time4)
# 计算fps
end = time.time()
inf_end = end - start
fps = 1 / inf_end
fps_label = "FPS: %.2f" % fps
cv2.putText(image, fps_label, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
time6 = time.time()
print('compute fps:', time6 - time5)
# 保存
basename = os.path.basename(img_path)
cv2.imwrite(os.path.join(self.out_dir, basename), image)
time7 = time.time()
print('save:', time7 - time6)
其他关于前处理(preprocess)、后处理(wrap_detection2)、画框(draw_boxes)的函数,与Inference_Opencv类完全一致,这里不再赘述。
测试代码如下:
if __name__ == '__main__':
ie_path = "runs/train/exp2/weights/best_openvino_model/best.xml"
# ie_path = "runs/train/exp2/weights/best.onnx"
yaml_path = "uav_bird_training/dataset.yaml"
inference_Model = Inference_Openvino(ie_path, yaml_path)
# 对保存在磁盘上的图片进行推理
start = time.time()
inference_Model.pred_img('uav_bird_training/data/images/train/20220318_01.jpg')
print(time.time() - start)
输出:
read: 0.0019903182983398438
preprocess: 0.00997304916381836
refer: 0.028923511505126953
wrap_detection: 0.0
draw boxes: 2.02312970161438
compute fps: 0.0
save: 0.002993345260620117
2.067009925842285
这里注意refer,耗时只有28.9ms,相比于上一节使用OpenCV推理的80+ms、90+ms,速度有了巨大的提升。我本地是Intel的CPU,OpenVINO也是Intel推出的,英特尔在CPU指令层面对模型的推理过程进行了优化。
此时,预处理、模型前向传播、后处理三步合计耗时为38.9ms,已经远低于官方程序的处理时间了(115.7ms)。
4 总结
现在我们已经可以做到摆脱对PyTorch依赖的同时,还能比官方处理时间更快了,但如果部署设备上有GPU,那么还可以更快,这正是我们下一篇文章要讲的内容。本文需要掌握的内容有:(1)如何导出ONNX文件和OpenVINO的IR文件;(2)通过OpenCV进行深度学习模型推理;(3)通过OpenVINO进行深度学习模型推理;(4)了解从读图到保存结果,最耗时的步骤是哪一步。
这篇文章看似内容不多,但却花了我十几天的时间(每天两小时)才写完,其中的程序经历了反复修改,以保证程序的简洁工整,为了和官方程序数据对齐,花费了很大的心血进行debug,有些混淆不清的内容(例如置信度过滤时是使用目标置信度obj_conf,还是使用置信度得分score),也需要耐下心去翻看源码。通过写这个系列的文章,我真正了解到了博客撰写的不易,特别是高质量博客,你需要对行文和程序进行反复修改,确保合适,绝对不是你自己懂了就能很容易写出来,可能这就是知识传播的难点吧。