YOLOv5-6.1从训练到部署(三):模型在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:输入图像(13或者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),也需要耐下心去翻看源码。通过写这个系列的文章,我真正了解到了博客撰写的不易,特别是高质量博客,你需要对行文和程序进行反复修改,确保合适,绝对不是你自己懂了就能很容易写出来,可能这就是知识传播的难点吧。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值