总览
对 YOLO 的具体运作原理仍然不甚理解,来读下 ultralytics 库的代码吧。调试 YOLO v12 的推理,以及 YOLO v11 的训练。
写完后就感觉,还不如直接用 ultralytics 库来推理或者训练 YOLO 模型。现在的 YOLO 不论是网络结构还是训练方法都整得太复杂了。
本文写得太乱了。现在的 YOLO 用上了数不清的技巧和 trick,头快炸了。
感觉最大的收获是了解到了 CIoU(Complete IoU)和 DFL(Distribution Focal)的概念。
推理流程
- 图像预处理
- 使用
LetterBox
,缩放与填充为 最长边不超过 640、边长为 32 倍数的图片除以 255,数值归一化到 [0, 1]- 进入到 PyTorch 后端
BaseModel
,继承于 nn.Module获得 [1, 84, 3360] 和 [1, 144, 32, 80]、[1, 144, 16, 40]、[1, 144, 8, 20]模型推理流程
model = [ Conv, # 3 -> 64, kernel_size=3, stride=2, padding=1 Conv, # 64 -> 128, kernel_size=3, stride=2, padding=1 C3k2 Conv, # 256 -> 256, kernel_size=3, stride=2, padding=1 C3k2 Conv, # 512 -> 512, kernel_size=3, stride=2, padding=1 A2C2f, # attention Conv, # 512 -> 512, kernel_size=3, stride=2, padding=1 A2C2f, # attention Upsample(scale_factor=2.0, mode='nearest'), Concat(-1, 6), # 拼接上层和第 6 层的 x 结果 A2C2f, Upsample(scale_factor=2.0, mode='nearest'), Concat(-1, 4), # 拼接上层和第 4 层的 x 结果 A2C2f, Conv, # 256 -> 256, kernel_size=3, stride=2, padding=1 Concat(-1, 11), # 拼接上层和第 11 层的 x 结果 A2C2f, Conv, # 512 -> 512, kernel_size=3, stride=2, padding=1 Concat(-1, 8), # 拼接上层和第 8 层的 x 结果 C3k2 Detect # 接受第 14, 17, 20 层的 x 结果,记为 x_1, x_2, x_3]
模型详情
{ 10: [-1, 6], 13: [-1, 4], 16: [-1, 11], 19: [-1, 8], 21: [14, 17, 20],}
尝试伪代码,以类名代表网络层。请结合注释阅读。
没有标注 padding 的其值为 0。
# x 是输入张量# 定义会频繁用到的层Conv = [ Conv2d, BatchNorm2d, SiLU,]Bottleneck = [ Conv, Conv,]# C3k 继承于 C3C3k = [ cat( # 在通道维度拼接 [ Conv, # 2n -> n, kernel_size=1, stride=1 Bottleneck, # n -> n, kernel_size=3, stride=1, padding=1 Bottleneck, # n -> n, kernel_size=3, stride=1, padding=1 ], Conv, # 2n -> n, kernel_size=1, stride=1 ), Conv, # 2n -> 2n, kernel_size=1, stride=1]# 模型推理流程model = [ Conv(x), # 3 -> 64, kernel_size=3, stride=2, padding=1 Conv(x), # 64 -> 128, kernel_size=3, stride=2, padding=1 C3k2[ # 继承于 C2f Conv(x), # 128 -> 128, kernel_size=1, stride=1 y = chunk(x), # 在通道维度平分出两份 tensor y_1 = C3k(y[-1]), # n=32 y_2 = C3k(y[-1]), # n=32 y.extend([y_1, y_2]), x = cat(y), # 在通道维度拼接 Conv(x), # 256 -> 256, kernel_size=1, stride=1 ], Conv(x), # 256 -> 256, kernel_size=3, stride=2, padding=1 C3k2[ Conv(x), # 256 -> 256, kernel_size=1, stride=1 y = chunk(x), # 在通道维度平分出两份 tensor y_1 = C3k(y[-1]), # n=64 y_2 = C3k(y[-1]), # n=64 y.extend([y_1, y_2]), x = cat(y), # 在通道维度拼接 Conv(x), # 512 -> 512, kernel_size=1, stride=1 ], Conv(x), # 512 -> 512, kernel_size=3, stride=2, padding=1 A2C2f[ y = x Conv(y), # 512 -> 256, kernel_size=1, stride=1 y_1 = 2 * ABlock, y_2 = 2 * ABlock, y_3 = 2 * ABlock, y_4 = 2 * ABlock, y = cat(y, y_1, y_2, y_3, y_4), # 在通道维度拼接 Conv(y), # 1280 -> 512, kernel_size=1, stride=1 x = x + y * gamma, # 通道层面的残差控制 ], Conv(x), # 512 -> 512, kernel_size=3, stride=2, padding=1 A2C2f[ y = x Conv(y), # 512 -> 256, kernel_size=1, stride=1 y_1 = 2 * ABlock, y_2 = 2 * ABlock, y_3 = 2 * ABlock, y_4 = 2 * ABlock, y = cat(y, y_1, y_2, y_3, y_4), # 在通道维度拼接 Conv(y), # 1280 -> 512, kernel_size=1, stride=1 x = x + y * gamma, # 通道层面的残差控制 ], Upsample(scale_factor=2.0, mode='nearest'), Concat(-1, 6), # 拼接上层和第 6 层的 x 结果 A2C2f[ y = x Conv(y), # 1024 -> 256, kernel_size=1, stride=1 y_1 = C3k(y[-1]), # n=128 y_2 = C3k(y[-1]), # n=128 y = cat(y, y_1, y_2), # 在通道维度拼接 Conv(y), # 768 -> 512, kernel_size=1, stride=1 ], Upsample(scale_factor=2.0, mode='nearest'), Concat(-1, 4), # 拼接上层和第 4 层的 x 结果 A2C2f[ y = x Conv(y), # 1024 -> 128, kernel_size=1, stride=1 y_1 = C3k(y[-1]), # n=64 y_2 = C3k(y[-1]), # n=64 y = cat(y, y_1, y_2), # 在通道维度拼接 Conv(y), # 384 -> 256, kernel_size=1, stride=1 ], Conv(x), # 256 -> 256, kernel_size=3, stride=2, padding=1 Concat(-1, 11), # 拼接上层和第 11 层的 x 结果 A2C2f[ y = x Conv(y), # 768 -> 256, kernel_size=1, stride=1 y_1 = C3k(y[-1]), # n=128 y_2 = C3k(y[-1]), # n=128 y = cat(y, y_1, y_2), # 在通道维度拼接 Conv(y), # 768 -> 512, kernel_size=1, stride=1 ], Conv(x), # 512 -> 512, kernel_size=3, stride=2, padding=1 Concat(-1, 8), # 拼接上层和第 8 层的 x 结果 C3k2[ Conv(x), # 1024 -> 512, kernel_size=1, stride=1 y = chunk(x), # 在通道维度平分出两份 tensor y_1 = C3k(y[-1]), # n=128 y_2 = C3k(y[-1]), # n=128 y.extend([y_1, y_2]), x = cat(y), # 在通道维度拼接 Conv(x), # 1024 -> 512, kernel_size=1, stride=1 ], Detect[ # 接受第 14, 17, 20 层的 x 结果,记为 x_1, x_2, x_3 x_1_a = x_1, x_1_b = x_1, Conv(x_1_a), # 256 -> 64, kernel_size=3, stride=1, padding=1 Conv(x_1_a), # 64 -> 64, kernel_size=3, stride=1, padding=1 Conv2d(x_1_a), # 64 -> 64, kernel_size=1, stride=1 2 * [ Conv(x_1_b), # 256 -> 256, kernel_size=3, stride=1, padding=1, groups=256 Conv(x_1_b), # 256 -> 256, kernel_size=1, stride=1 ] Conv2d(x_1_b), # 256 -> 80, kernel_size=1, stride=1 x_1 = cat(x_1_a, x_1_b), # 在通道维度拼接 x_2_a = x_2, x_2_b = x_2, Conv(x_2_a), # 512 -> 64, kernel_size=3, stride=1, padding=1 Conv(x_2_a), # 64 -> 64, kernel_size=3, stride=1, padding=1 Conv2d(x_2_a), # 64 -> 64, kernel_size=1, stride=1 2 * [ Conv(x_2_b), # 512 -> 512, kernel_size=3, stride=1, padding=1, groups=512 Conv(x_2_b), # 512 -> 256, kernel_size=1, stride=1 ] Conv2d(x_2_b), # 256 -> 80, kernel_size=1, stride=1 x_2 = cat(x_2_a, x_2_b), # 在通道维度拼接 x_3_a = x_3, x_3_b = x_3, Conv(x_3_a), # 512 -> 64, kernel_size=3, stride=1, padding=1 Conv(x_3_a), # 64 -> 64, kernel_size=3, stride=1, padding=1 Conv2d(x_3_a), # 64 -> 64, kernel_size=1, stride=1 2 * [ Conv(x_3_b), # 512 -> 512, kernel_size=3, stride=1, padding=1, groups=512 Conv(x_3_b), # 512 -> 256, kernel_size=1, stride=1 ] Conv2d(x_3_b), # 256 -> 80, kernel_size=1, stride=1 x_3 = cat(x_3_a, x_3_b), # 在通道维度拼接 x_cat = cat(x_1, x_2, x_3), # WH 拍平后在 WH 维度进行拼接 box, cls = x_cat.split(64, 80), # 在通道维度拆分。对应刚才的 ab 处理 view(box), # 在通道维度拆分,64 -> (4, 16) transpose(box), # (4, 16) -> (16, 4)。此时 16 变成了通道,4 变成了 H softmax(box), # 施加在通道维度 Conv2d(box), # 16 -> 1 且 bias=False 的 1×1 卷积。权重手动指定为了 arange(16) sigmoid(cls) # 此时的 box 已经具有 xywh 方形框形式。每个小格子都有一个框 # 后续处理围绕着偏移处理。就不写了 ]]
ABlock
,会先后经过 x = x + self.attn(x)
和 x = x + self.mlp(x)
,和 Transformer 很像。具体来说:
- self.attn,
AAttn
- 维度 256,头数 8,HW 维度摊平作为序列长度标准 scaled-dot-product-attention,没有 mask,没有位置编码最后额外有
x = x + Conv(v)
,kernel_size=7, stride=1, padding=3, act=False,加上 v 的值最后额外有 x = Conv(x)
,kernel_size=1, stride=1, act=False,作为 projConv
,256 -> 307, kernel_size=1, stride=1Conv
,307 -> 256, kernel_size=1, stride=1, act=False可见,attention 在最后让 x 加上了 value 的值,mlp 是中间维度不是很高的两次卷积实现的。
后处理流程
现有一个维度为 [batch, 84, 3360] 的 tensor。其中 3360 意味着不同分割框(随输入图像大小变化而改变),84 中分别含有 4 份 xyhw 信息代表标注框、80 份分类信息。
变量 xc
指向 3360 个框中 最大的分类概率大于 conf_thres 值的框。这样一来,绝大部分的框都被淘汰了。
会用到 nms(non-maximum suppression) 算法进一步去重筛选。算法步骤:
- 输入三个参数:box 框、各个框的置信度分数、目标 iou 阈值将置信度最高的框转移到保留队列剩余的框计算其与上一步被转移的框的 iou,去掉大于目标 iou 阈值的框回到 2,不断重复,直到无框可移
不同类别的框不应该相互覆盖,影响 iou 计算。按理说应该一个类别一个类别地应用 nms 来筛选框。
源代码用到了一个很讨巧的方法,使得不同类别的 box 框能在一个批次里同时被处理,缩短时间。
方法是:让每个类别的框的坐标加上不同偏移。具体来说,若是有 4 个类别,输入图像最大边长为 7680,那么让每个类别的框坐标相互间隔 7680、7680 * 2、7680 * 3 即可。
训练过程(v8DetectionLoss
)
代码将 loss 的计算封装在 v8DetectionLoss
。输入模型预测结果和标签,进行一些预处理转换得到:
pd_scores
,predicted scores,预测类别置信度- 维度 [batch, seq, num_cls]
pd_bboxes
,predicted bboxes,预测框- 维度 [batch, seq, 4]
anc_points
,anchor points。各个分区的中心点- 维度 [seq, 2]
gt_labels
,ground truth labels,标注框对应类别- 维度 [batch, max_seq_len, num_cls]
gt_bboxes
,ground truth bboxes,标注框- 维度 [batch, max_seq_len, 4]
mask_gt
,由于需要填充到 max_seq_len 故有此 mask- 维度 [batch, max_seq_len, 1]
将这些参数输入到 self.assigner
(TaskAlignedAssigner
),获得调整过的学习目标。
损失分为三个部分,
- iou loss,使用的 CIoU 计算 bbox损失cls loss,用 BCEWithLogitsLoss 计算置信度损失dfl loss,使用 DFL 计算 bbox 损失。
由于用到了 softmax 计算置信度,使用 MSELoss 会导致在 0 和 1 处梯度很小。置信度损失使用 BCELoss 会更合适。
cls loss 和 dfl loss 都会用目标置信度作为权重进行加权相加。三种损失都会用目标置信度之和 进行归一化。
BboxLoss
计算出 dfl loss
模型没有直接输出 4 个特征来指示框的 xyhw,而是把输出特征翻了 16 倍。这是因为模型把连续的数值预测问题转换为了离散的分类问题,从 0 到 15 总共 16 个分类。换句话说,模型用 16 个数字来代表范围在 [0, 15] 的一个数字。
dfl loss 的计算逻辑封装在 BboxLoss
类内。DFL(Distribution Focal)将连续坐标回归问题转换为离散的分类问题,计算时用到了两次 F.cross_entropy 以考虑小数取整时的向上和向下取整两个情况。
模型用的分区,最小有 8×8,最大有 32×32。只有 16 个分类就意味着最大输出为 15 倍分区边长,那么模型能画出的最大框就为 480×480。
TaskAlignedAssigner
获得调整过的学习目标
self.get_pos_mask()
筛选分区,减轻计算量。
self.select_highest_overlaps()
,在上一步 mask 挑选的基础上,如果一个分区同时预测了多个预测框,用 overlaps
决定到底用哪一个预测框。
self.get_targets()
,对于 mask 挑选的结果,将 gt_labels
和 gt_bboxes
转换为合适的形式,获得学习目标 target_bboxes
target_scores
,便于 loss 计算。
Normalize,该步骤会调整上一步获得的仅含 0 值 和 1 值的 target_scores
。思路是“质量”越高的分区目标分数也越高,而不是单纯的 0 和 1。这个“质量”追根溯源是通过置信度确定的。
至此,获得了三个重要变量:
target_bboxes
,target_scores
fg_mask
self.get_pos_mask()
具体如何筛选分区
不可能对所有分区都计算 loss,否则计算量太大了。代码在 self.get_pos_mask()
中按顺序用到以下方法筛选分区。
select_candidates_in_gts()
,输入一系列中心点和标注框,返回一个用于指示分区中心点是否在框内的 mask mask_in_gts
。
get_box_metrics()
,计算获得预测框与标注框的重叠率 overlaps
,以及重叠率与对应类别置信度的乘积指标 align_metric
。
- 会先用
mask_in_gts
* mask_gt
减少计算量重叠率用 CIoU 衡量虽然单个分区只会预测出一个框,但此时假设分区会预测所有标注框用 mask 跳过中心点不在标注框内的分区align_metric
计算方式:置信度^alpha * 重叠率。这个 alpha 我调试出来被设为了 0.5select_topk_candidates()
,输入刚刚算出的重叠率指标 align_metric
,输出进一步筛选的分区,返回为 mask mask_topk
。对于单张图:
- 对图中所有标注框,分别选出指标最大的 self.topk 个对象(10 个)
- 意味着,总共选出 (num_cls, 10) 个分区的指标
最终,用 mask_topk
* mask_in_gts
* mask_gt
结果作为筛出分区的 mask。八千个分区就筛得只剩下三十几个了。
一些值得注意的地方
模型输出了 4 * 16 个特征来指示预测框
模型没有直接输出 4 个特征来指示框的 xyhw,而是把输出特征翻了 16 倍。需要经过加权求和才能最终获得 4 个特征。
这是因为模型把连续的数值预测问题转换为了离散的分类问题,从 0 到 15 总共 16 个分类。换句话说,模型用 16 个数字来代表范围在 [0, 15] 的一个数字。
例如模型输出的张量维度 [batch, seq, 4 * 16],转换为 xyhw 的流程如下:
- 转换维度到 [batch, seq, 4, 16]对 16 这个维度施加 softmax对 16 这个维度加权求和,张量维度变换到 [batch, seq, 4]
- 加权权重为 arange(16)(0、1、2、……、15)
推理时加权相加的做法是用的 1×1 卷积做到的,很聪明的提速方法。
如此可以方便地将问题从连续数值预测转换离散类别分类,进而获得 dfl loss。怪不得要用上 softmax,因为问题是离散的。
顺便一提,模型输出的 4 个特征代表着 x1y1x2y2 距离中心点的偏移量。
使用 CIoU 计算两框重叠率
为了衡量预测框与标注框的差距,进而获得 loss,代码使用了 IoU 的变体 CIoU 进行计算。
关于 IoU 与其变体:
- IoU(Intersection over Union)通过计算交集面积与并集面积之比得到。缺点是当两框无交集时不能提供“两框距离多远”的信息
- IoU = Area(Intersection) / Area(Union)
- GIoU = IoU - ( Area(C) - Area(Union) ) / Area(C)
- DIoU = IoU - ( Distance^2 / Diagonal^2 )
- CIoU = DIoU - alpha * v其中,
这样看来 CIoU 性能最好,同时考虑了重叠面积、中心点距离和长宽比三个因素。
YOLO 写的计算 IoU 的代码可以直接拿来用。写得很好,输入两个 tensor 只要保证最后一个维度大小为 4 即可。
from ultralytics.utils.metrics import bbox_iou