YOLOv8目标检测算法移植
YOLOv8与YOLOv5的异同
- YOLOv8与YOLOv5的网络结构相似, 缺省仍然采用三尺度检测
- YOLOv8的Backbone用C2f替换C3结构,仍使用残差结构, 但输入被分为两半, 一半用采用与C3相同的残差结构, 另一半直通.
- YOLOv8采用anchor-free模型, 使用DFL损失函数, Detect用DFL计算bounding box
- YOLOv8取消目标置信度,直接使用分类置信度(目标置信度存在信息冗余)
C2f中使用了split和concat算子,需要进行大量内存操作,对有些NPU实现不友好(不支持或者速度慢)。
YOLOv5/8的Backbone仅使用基础算子如Conv2D、Batch Normalization、Sigmoid、Split、Concat、Max Pooling、Upsample等,主流的NPU都能支持,不是移植关注的重点。
目标置信度
YOLOv5的后处理先对目标置信度进行筛选,再搜索分类置信度中的最大值作为分类置信度,用目标置信度x分类置信度得到最终的置信度。
YOLOv8取消了目标置信度,因此要先搜索分类置信度的最大值,再进行筛选。
目标框(bonding box)
目标框的计算公式与损失函数中目标框损失有对应关系。
- YOLOv5
YOLOv5检测头对每个GRID回归得到以GRID中心为原点的目标框中心点位置(xy)和基于anchor的目标框尺寸(wh)。
小目标的目标框尺寸的偏移1像素产生的误差,比大目标的目标框尺寸偏移1像素产生的误差要大。
出于平衡小目标和大目标的目标框尺寸的误差的需要,YOLOv5的目标框损失函数对目标框尺寸(wh)取平方根,这也是YOLOv5检测头计算wh时取平方的原因。[参见YOLOv5算法移植后处理
中的转换bouding box
]
- YOLOv8
YOLOv8为解决anchor-base问题,使用DFL Loss,对xyxy(左上角坐标和右下角坐标)进行回归并计算损失。其本质是对两个点四个坐标值进行如下处理:
- 当作分类问题,计算0~15共16个分类(bouding box的16分类是YOLOv8的缺省超参数,根据目标框的聚合情况可以进行修改)的分类概率
- 再使用softmax将分类问题转换为回归问题,计算出最终的坐标值
对于左上角坐标,0~15表示以当前GRID中心点为基准,向左或向上偏移0~15个GRID;
对于右下角坐标,0~15表示以当前GRID中心点为基准,向右或向下偏移0~15个GRID
导出和裁剪
与YOLOv5类似,YOLOv8的Detect层仅cv2和cv3有需要训练的权重,分别对应分类置信度和bounding box。
后面的计算更多地是为了方便利用numpy的向量计算,但是增加了大量concat、split、常量和乘除法。这些算子有一些可能不被NPU加速器支持,即使支持必要性也不大,因此全部删除。
从github下载YOLOv8源代码
修改文件ultralytics/nn/modules/head.py
中Detect
类的forward
函数:
def forward(self, x):
"""Concatenates and returns predicted bounding boxes and class probabilities."""
export_cut = []
for i in range(self.nl):
#### 删除开始 ####
#x[i] = torch.cat((self.cv2[i](x[i]), self.cv3[i](x[i])), 1)
#### 删除结束 ####
#### 增加开始 ####
cv2, cv3 = self.cv2[i](x[i]), self.cv3[i](x[i])
if self.export:
# cv2(分类置信度)添加sigmoid,这里的sigmoid也可以不要,在后处理中计算
# cv3(bounding box)添加sigmoid
# cv2和cv3都进行转置,转置前通道数位于第二维度,方便进行Conv2D运算
# 转置后通道数位于第四维度,符合C/C++数组定义的习惯,方便C/C++代码计算缓存偏移
# 上一篇文章中YOLOv5导出裁剪时没有进行转置,C/C++代码计算缓存偏移有些别扭
export_cut += [cv2.permute(0, 2, 3, 1), cv3.sigmoid().permute(0, 2, 3, 1)]
x[i] = torch.cat((cv2, cv3), 1)
#### 增加结束 ####
#### 增加开始 ####
if self.export:
return export_cut
#### 增加结束 ####
#### 以下不变 ####
if self.training: # Training path
return x
在根目录下添加文件yolo
,添加以下内容
#!/usr/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from ultralytics.cfg import entrypoint
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(entrypoint())
执行脚本导出onnx模型文件
python3 yolo export detect model=yolov8n.pt format=onnx imgsz=416,736 opset=12
参数可通过执行python3 yolo
查看,与YOLOv5导出脚本的参数含义基本一致。
YOLOv8代码的封装层级比YOLOv5多,程序执行流程不太直观,多借助python调试工具单步执行有助于熟悉代码。
测试
加载模型文件、测试图片并运行与YOLOv5模型验证完全相同,不再重复。
后处理
YOLOv8是anchor-free模型,不需要从数据集中聚类anchor,后处理过程与输入图像的尺寸也不直接相关。
- 用DFL计算bounding box
代码如下:
softmax函数
def softmax(x, axis=-1):
e = np.exp(x)
return e / np.sum(e, axis=axis).reshape(-1, 1)
dfl函数:从模型输出的bounding box特征图计算bounding box的回归值
def dfl(bbox):
'''从模型输出的bounding box特征图计算bounding box的回归值
'''
# bounding box输出缓存的最后一个维度大小默认为64 = 4 * 16
n = bbox.shape[-1]
a = np.arrange(n // 4)
s = softmax(bbox.reshape(4, n // 4), axis=-1)
d = s.dot(a)
return d
坐标转换函数: 从bouding box回归值计算bounding box的归一化坐标
def conv(b, row, col, w, h):
'''从bouding box回归值计算bounding box的归一化坐标
b: bounding box的回归值
row: 当前grid的行序号
col: 当前grid的列序号
w: 当前特征图的宽度
h: 当前输出特征图的高度
左上角回归值相对于grid中心点作副偏移
右下角回归值相对于grid中心点作正偏移
```
x1 = (col + 0.5 - d[0]) / w
y1 = (row + 0.5 - d[1]) / h
x2 = (col + 0.5 + d[2]) / w
y2 = (row + 0.5 + d[3]) / h
return x1, y1, x2, y2
- 除DFL外,YOLOv8的后处理与YOLOv5基本一样,区别在于:
由于YOLOv8是anchor-free模型,因此不像YOLOv5的每个输出尺度中需要分三种anchor分别对每个grid进行过滤和转换,也就是YOLOv8的后处理少了anchor这一层循环,仅需要处理batch, height, width三层循环。不过借助numpy的向量化处理能力,python代码仅需要正确设置特征图的维度,不需要显式书写循环。
由于YOLOv8取消了目标置信度,因此用类别置信度与置信度阈值进行比较即可。
模型转换
YOLOv8和YOLOv5的模型转换过程相同,需要注意的地方也相同。
C/C++调用代码
模型加载、图片加载、分配缓存、运行模型等主要与处理器(AI库)相关,与模型关系不大。
后处理部分与YOLOv5的后处理的差别主要是DFL。
float yolov8cut_convertor::dfl(const int16_t* buf, float scale)
{
// YOLOv8的DFL实现为softmax + 1x1卷积
// 输入通道数为16, 输出通道为1, 卷积权重为[0...15],权重不需要学习
// 实际上dfl是16分类问题通过softmax转换为回归问题
// 这里实现时先算自然指数, 分别求softmax和dfl卷积和, 再相除得到最后结果
// 这么计算可以减少一次循环和15次浮点除法
float d[BBOX_DATA_LENGTH];
for (ulong i = 0; i < BBOX_DATA_LENGTH; ++i) {
d[i] = buf[i] * scale;
}
float sum_sm = 0; // softmax的分母
float sum_dfl = 0; // (dfl+softmax)的分子
for (ulong i = 0; i < BBOX_DATA_LENGTH; ++i) {
float e = exp(d[i]);
sum_sm += e;
sum_dfl += e * i;
}
return sum_dfl / sum_sm;
}
将bounding box从回归值转换为归一化坐标的代码与python代码相同
common::xyxy yolov8cut_convertor::convert_box(const int16_t* bb, ulong col, ulong row, ulong w, ulong h, float scale)
{
float d[4];
for (ulong i = 0; i < sizeof(d) / sizeof(d[0]); ++i) {
d[i] = this->dfl(&bb[i * BBOX_DATA_LENGTH], scale);
}
float x1 = (col + 0.5 - d[0]) / w;
float y1 = (row + 0.5 - d[1]) / h;
float x2 = (col + 0.5 + d[2]) / w;
float y2 = (row + 0.5 + d[3]) / h;
return common::xyxy{x1, y1, x2, y2};
}
整体转换过程与python代码相同,按照batch、height、weight三层循环逐层计算
1. 查找并计算分类置信度,并记录类别索引,乘以量化系数,如果小于阈值则忽略
2. 计算并保存bounding box、类别索引、置信度
完成过滤和转换之后,分类对bounding box进行NMS
下节预告:YOLOv8支持关键点(人体姿态/骨骼点)检测。关键点检测的Backbone和Neck与目标检测相同,在Head网络增加关键点预测分支,并在目标检测损失函数的基础上融合关键点损失,使得YOLOv8具备关键点检测能力。YOLOv8的关键点是xyc三元组,分别表示坐标xy和置信度。关键点检测除了可用于人体姿态估计之外,也可以用于人脸检测、车牌定位等场景。后续文章会描述在YOLOv8目标检测模型移植的基础上实现关键点检测模型的移植细节。