掘金 人工智能 05月22日 19:03
代码层面上学习yolo12
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文深入探讨了YOLO模型的运作原理,特别是通过解析Ultralytics库的代码,详细分析了YOLOv12的推理过程和YOLOv11的训练机制。文章涵盖了图像预处理、模型推理流程、后处理以及训练过程中的关键技术,如CIoU和DFL。尽管YOLO模型变得越来越复杂,但理解其内部机制对于实际应用和优化至关重要。本文旨在帮助读者更好地掌握YOLO模型的细节,并能有效利用Ultralytics库进行推理和训练。

🖼️ 图像预处理:YOLO模型在推理前会对输入图像进行预处理,主要包括使用LetterBox方法将图像缩放并填充至最长边不超过640像素,且边长为32的倍数,同时将像素值归一化到[0, 1]范围内,为后续模型推理做准备。

🧠 模型推理流程:YOLO模型推理过程涉及多个网络层,包括Conv、C3k2、A2C2f等,这些层通过卷积、拼接、上采样等操作提取图像特征。模型接收输入后,通过一系列的层级结构,最终输出不同尺度的特征图,用于后续的目标检测。

🎯 后处理与NMS算法:模型输出的tensor经过后处理,包括根据置信度阈值筛选框,并使用非极大值抑制(NMS)算法去除重复框。NMS通过计算框之间的IoU值,并结合类别信息,有效减少冗余检测结果,提高目标检测的准确性。

⚖️ 损失函数与DFL:YOLO的训练过程依赖于精心设计的损失函数,包括CIoU Loss、BCEWithLogitsLoss和DFL Loss。DFL(Distribution Focal Loss)将连续坐标回归问题转化为离散的分类问题,通过预测框的位置分布来优化模型,提高定位精度。

总览

对 YOLO 的具体运作原理仍然不甚理解,来读下 ultralytics 库的代码吧。调试 YOLO v12 的推理,以及 YOLO v11 的训练。

写完后就感觉,还不如直接用 ultralytics 库来推理或者训练 YOLO 模型。现在的 YOLO 不论是网络结构还是训练方法都整得太复杂了。

本文写得太乱了。现在的 YOLO 用上了数不清的技巧和 trick,头快炸了。

感觉最大的收获是了解到了 CIoU(Complete IoU)和 DFL(Distribution Focal)的概念。

推理流程

模型推理流程

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 很像。具体来说:

可见,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。输入模型预测结果和标签,进行一些预处理转换得到:

将这些参数输入到 self.assignerTaskAlignedAssigner),获得调整过的学习目标。

损失分为三个部分,

由于用到了 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_labelsgt_bboxes 转换为合适的形式,获得学习目标 target_bboxes target_scores,便于 loss 计算。

Normalize,该步骤会调整上一步获得的仅含 0 值 和 1 值的 target_scores。思路是“质量”越高的分区目标分数也越高,而不是单纯的 0 和 1。这个“质量”追根溯源是通过置信度确定的。

至此,获得了三个重要变量:

self.get_pos_mask() 具体如何筛选分区

不可能对所有分区都计算 loss,否则计算量太大了。代码在 self.get_pos_mask() 中按顺序用到以下方法筛选分区。

select_candidates_in_gts(),输入一系列中心点和标注框,返回一个用于指示分区中心点是否在框内的 mask mask_in_gts

get_box_metrics(),计算获得预测框与标注框的重叠率 overlaps,以及重叠率与对应类别置信度的乘积指标 align_metric

select_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 与其变体:

这样看来 CIoU 性能最好,同时考虑了重叠面积、中心点距离和长宽比三个因素。

YOLO 写的计算 IoU 的代码可以直接拿来用。写得很好,输入两个 tensor 只要保证最后一个维度大小为 4 即可。

from ultralytics.utils.metrics import bbox_iou

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

YOLO Ultralytics 目标检测 深度学习
相关文章