一、本文介绍
本文给大家带来的是YOLOv8中的损失函数计算的完整解析,内容包括v8DetectionLoss的解析,以及BboxLoss的解析,如果你相对损失函数的计算原理,本文内容绝对会对你有所帮助,全文内容包含1万两千字,手打分析文字超过5000字,全部为干货内容,包含示例解释辅助大家理解,对于小白来说十分适合阅读,以下图片内容为文章中部分的解析截图,本文内容为独家整理和理解全网无第二份。
欢迎大家订阅我的专栏一起学习YOLO!
专栏目录:YOLOv8改进有效系列目录 | 包含卷积、主干、检测头、注意力机制、Neck上百种创新机制
专栏回顾:YOLOv8改进系列专栏——本专栏持续复习各种顶会内容——科研必备
目录
一、本文介绍
二、代码解析
2.1 v8DetectionLoss解析
2.2 BboxLoss
2.3 bbox_iou
2.4 DFL
2.5 make_anchors
2.6 dist2bbox
三、本文总结
二、代码解析
本文内容在观看之前需要大家先阅读了我上一个检测头的分析因为两篇文章内容有些概念需要理解。
YOLOv8 | 代码逐行解析(四) | YOLOv8中从检测头到损失函数计算的详解,小白必看(上)
2.1 v8DetectionLoss解析
YOLOv8损失函数计算的代码在'ultralytics/utils/loss.py'中(下面的代码),我们可以在下面的文件中找到下面的代码,代码的解析我已经在代码中注释给出大家可以在其中看到。
class v8DetectionLoss:
"""Criterion class for computing training losses."""
# 这个类就是我们YOLOv8目标检测的损失函数!
def __init__(self, model, tal_topk=10): # model must be de-paralleled
"""Initializes v8DetectionLoss with the model, defining model-related properties and BCE loss function."""
device = next(model.parameters()).device # # 获取模型所在的设备
h = model.args # 超参数列表,这个model.args返回的就是我们ultralytics/cfg/default.yaml文件下的所有参数设定!
m = model.model[-1] # 这一步就是获取我们的模型最后一层(检测头,上一章已经讲过了),这里获取过来就是为了获取其中的参数.
self.bce = nn.BCEWithLogitsLoss(reduction="none") # 二分类交叉熵损失
self.hyp = h # 这个h就是我们上面的所有超参数!
self.stride = m.stride # 这个参数大家需要看我上一章文章解析的部分才明白,这个是特张图图像的缩放比例不懂的可以返回去看看.
self.nc = m.nc # number of classes 我们数据集中的类别数量
self.no = m.nc + m.reg_max * 4 # 这个也解释过了在上一章! nc是类别数量 reg_max是每个位置信息的预测数量, 4 代表四个位置!
# x - 边界框的中心点 x 坐标
# y - 边界框的中心点 y 坐标
# w - 边界框的宽度
# h - 边界框的高度
self.reg_max = m.reg_max # DFL 通道数量 (每个预测框的回归输出通道数)
self.device = device # 模型设备
self.use_dfl = m.reg_max > 1 # 一个简单的判断其实没人必要大家不用理会!
self.assigner = TaskAlignedAssigner(topk=tal_topk, num_classes=self.nc, alpha=0.5, beta=6.0) # 任务对齐分配器
self.bbox_loss = BboxLoss(m.reg_max).to(device) # 边界框损失计算函数
self.proj = torch.arange(m.reg_max, dtype=torch.float, device=device) # 投影张量
def preprocess(self, targets, batch_size, scale_tensor):
"""Preprocesses the target counts and matches with the input batch size to output a tensor."""
nl, ne = targets.shape # 获取 targets 的形状
if nl == 0: # 如果没有目标
out = torch.zeros(batch_size, 0, ne - 1, device=self.device) # 返回一个空张量
else:
i = targets[:, 0] # 获取图像索引
_, counts = i.unique(return_counts=True) # 获取每个图像的目标计数
counts = counts.to(dtype=torch.int32)
out = torch.zeros(batch_size, counts.max(), ne - 1, device=self.device) # 初始化输出张量
for j in range(batch_size): # 遍历每个批次
matches = i == j # 匹配当前批次的目标
n = matches.sum() # 目标数量
if n:
out[j, :n] = targets[matches, 1:] # 填充目标
out[..., 1:5] = xywh2xyxy(out[..., 1:5].mul_(scale_tensor)) # 转换坐标
return out
def bbox_decode(self, anchor_points, pred_dist):
"""从锚点和分布预测中解码出预测的目标边界框坐标。
参数:
anchor_points (torch.Tensor): 锚点坐标,形状为 [num_anchors, 2]。
pred_dist (torch.Tensor): 预测的边界框分布,形状为 [batch_size, num_anchors, num_channels]。
返回:
torch.Tensor: 解码后的边界框坐标,形状为 [batch_size, num_anchors, 4]。
"""
if self.use_dfl:
b, a, c = pred_dist.shape # 获取 batch 大小、锚点数量和通道数
# 将预测分布变形为 [batch_size, num_anchors, 4, num_channels // 4],并在通道维度上应用 softmax
pred_dist = pred_dist.view(b, a, 4, c // 4).softmax(3).matmul(self.proj.type(pred_dist.dtype))
# pred_dist = pred_dist.view(b, a, c // 4, 4).transpose(2,3).softmax(3).matmul(self.proj.type(pred_dist.dtype))
# pred_dist = (pred_dist.view(b, a, c // 4, 4).softmax(2) * self.proj.type(pred_dist.dtype).view(1, 1, -1, 1)).sum(2)
return dist2bbox(pred_dist, anchor_points, xywh=False)
def __call__(self, preds, batch):
"""Calculate the sum of the loss for box, cls and dfl multiplied by batch size."""
loss = torch.zeros(3, device=self.device) # box, cls, dfl 初始化损失张量初始全是0
feats = preds[1] if isinstance(preds, tuple) else preds # 这里有两种不同情况!
# 情况1(训练时)在检测头代码中我们如果是训练则直接return x不知道大家还记得不,不记得需要回去看看!
# 在情况1时我们此时返回的是list列表,其中返回的形状是我的nc=25
# (batch_size, nc + reg_max * 4=89, 80, 80)
# (batch_size, nc + reg_max * 4=89, 40, 40)
# (batch_size, nc + reg_max * 4=89, 20, 20)
# 情况2(训练时的验证阶段)此时我们返回的是元组
# 元组里面包含了上面的x除此之外还额外包含我们预测的信息
# 形状为(batch_size * 2, nc + 四个位置的信息 = 29, 20 * 20 + 40 * 40 + 80 * 80 = 8400)
pred_distri, pred_scores = torch.cat([xi.view(feats[0].shape[0], self.no, -1) for xi in feats], 2).split(
(self.reg_max * 4, self.nc), 1
)
# 上面的一行代码操作含义是首先执行xi.view(feats[0].shape[0], self.no, -1) for xi in feats
# 这是将
# (batch_size, nc + reg_max * 4=89, 80, 80)
# (batch_size, nc + reg_max * 4=89, 40, 40)
# (batch_size, nc + reg_max * 4=89, 20, 20)
# 进行处理成[batch_size, 89, 80 * 80 = 6400]
# 进行处理成[batch_size, 89, 40 * 40 = 1600]
# 进行处理成[batch_size, 89, 20 * 20 = 400]
# 之后进行torch.cat([xi.view(feats[0].shape[0], self.no, -1) for xi in feats], 2)
# 变为[batch_size, 89, 6400 + 1600 + 400 = 8400] 大家需要明白cat的意义, dim =2
# 之后进行torch.cat([xi.view(feats[0].shape[0], self.no, -1) for xi in feats], 2).split((self.reg_max * 4, self.nc), 1)
# 其中的split会将tensor进行分割沿着维度1变为如果这个都不明白说明大家的Pytorch学的不是很扎实!
#(batch_size, 64, 8400)
#(batch_size, 25, 8400)
# 然后会将上面两个tensor分别按照顺序赋值给pred_distri, pred_scores,这两个变量代表的信息如下:
# pred_distri表示模型预测的每个锚点位置的边界框分布(共8400个锚点)
# pred_scores表示模型预测的每个锚点位置的分类分数 (共8400个锚点)
pred_scores = pred_scores.permute(0, 2, 1).contiguous() # 张量转置操作,(batch_size, 25, 8400)变为形状为 (batch_size, 8400, 25)
pred_distri = pred_distri.permute(0, 2, 1).contiguous() # 张量转置操作,(batch_size, 64, 8400)变为形状为 (batch_size, 8400, 64)
dtype = pred_scores.dtype # 获得设备大家不用理
batch_size = pred_scores.shape[0] # 获得第一个位置的形状即 batch_size
imgsz = torch.tensor(feats[0].shape[2:], device=self.device, dtype=dtype) * self.stride[0] # image size (h,w)
# 这个imgsz是一个我们模型真实输入的大小的一个的tensor 大部分默认的即(640,640)
anchor_points, stride_tensor = make_anchors(feats, self.stride, 0.5) # 这里在检测头里也调用了之前讲过了
# 上面这一步的含义涉及到了代码make_anchors其返回的两个变量我们需要理解一下self.anchors, self.strides
# anchor_points形状为(2, 8400)其中的含义是坐标信息(中心点的坐标!!),
# stride_tensor为我们的比例信息每一个像素点的
# 这是参数的含义不懂得返回上一章看看可以!
# Targets
targets = torch.cat((batch["batch_idx"].view(-1, 1), batch["cls"].view(-1, 1), batch["bboxes"]), 1)
# batch["batch_idx"]:批次中每个目标对应的图像索引 1个数字
# batch["cls"]:每个目标的类别标签 1个数字
# batch["bboxes"]:每个目标的边界框坐标 4个数字
# 这一行代码的主要目的是将批次中的图像索引、类别标签和边界框坐标拼接在一起,形成一个新的张量 targets,其中每行包含一个目标的完整信息。
# 我举例一个(图像索引, 类别标签, x1, y1, x2, y2) 其中x1y1x2y2 为边界框的左上点和右下点的坐标两个点确定一个边界框!
targets = self.preprocess(targets.to(self.device), batch_size, scale_tensor=imgsz[[1, 0, 1, 0]])
# 上面一行代码是对targets进一步处理方便我们后面进行操作!预处理步骤包括将目标数据移动到设备上、重新调整目标数据的格式和坐标转换。
# 返回的targets张量形状为三维(batch_size, 批次中最多目标的数量, 每个目标的信息维度(类别和边界框信息))
# 需要注意的是这里涉及到补0的操作, 因为第二个维度上是批次中最多目标的数量, 那么batch_size代表不同的图片,目标数量不足批次中最多目标的数量的照片,多余的数量位置就会补0!
gt_labels, gt_bboxes = targets.split((1, 4), 2) # cls, xyxy 这一步是将我们上面得到的 targets第2个维度即(每个目标的信息维度(类别和边界框信息))进行拆分开为cls(真实的类别标签)和 xyxy(边界框信息)
# gt_labels = [batch_size, max_num_target=批次中最多目标的数量, 1] 其中1是类别信息
# gt_bboxes = [batch_size, max_num_targets=批次中最多目标的数量, 4] 其中4是边界框信息
mask_gt = gt_bboxes.sum(2, keepdim=True).gt_(0.0)
# 这行代码的作用是创建一个掩码张量 mask_gt,用于标识哪些目标是有效的(即有实际的边界框),哪些目标是无效的(即填充的零)。
# sum(2, keepdim=True):沿着第 2 维(即每个边界框的坐标维度)进行求和,保持维度不变。
# 这样会将每个边界框的 4 个坐标值相加,如果该目标是有效的边界框,和将大于 0;如果该目标是填充的零,和将等于 0。
# Pboxes
pred_bboxes = self.bbox_decode(anchor_points, pred_distri) # xyxy, (b, h*w, 4)
# 这行代码的作用是调用 bbox_decode 方法,将预测的边界框分布 (pred_distri)
# 解码为实际的边界框坐标 (pred_bboxes),具体返回的是左上角和右下角的坐标 (xyxy) 格式
# 其中bbox_decode我在上一个文章已经讲过了大家可以去看,这里不在重复解释了。
# pred_bboxes:形状为 [batch_size, 8400, 4] 的张量,表示解码后的实际边界框坐标(左上角和右下角即xyxy已经和前面保持一致了)
_, target_bboxes, target_scores, fg_mask, _ = self.assigner(
pred_scores.detach().sigmoid(),
(pred_bboxes.detach() * stride_tensor).type(gt_bboxes.dtype),
anchor_points * stride_tensor,
gt_labels,
gt_bboxes,
mask_gt,
)
# self.assigner 是一个 TaskAlignedAssigner 对象,其 forward 方法用于将预测结果与真实目标进行匹配。
# 上面这几行代码需要解释一下,但是比较麻烦其代码内部处理我不过多的解释了内容有200多行全是计算,有兴趣的可以看一下,我这里只解释一下输入和输出的参数含义!
"""
输入(共6个输入值)
1.pred_scores:表示模型预测的每个锚点位置的分类分数 形状为:(batch_size, 8400, 25)
2.(pred_bboxes.detach() * stride_tensor).type(gt_bboxes.dtype)
pred_bboxes:解码后的边界框坐标,形状为 [batch_size, 8400, 4]。
detach():从计算图中分离出预测的边界框,避免反向传播时更新它们。
* stride_tensor:将边界框坐标乘以步幅张量 stride_tensor,恢复到原图尺度。
type(gt_bboxes.dtype):将边界框的类型转换为与 gt_bboxes 相同的类型。
3.anchor_points * stride_tensor
anchor_points:锚点位置,形状为 [num_anchors, 2]
将锚点位置乘以步幅张量 stride_tensor,恢复到原图尺度
4.gt_labels:真实目标的类别标签
5.gt_bboxes:真实目标的边界框坐标
6.mask_gt:掩码张量,标识哪些目标是有效的
"""
"""
输出(共5个返回值)
target_labels, 这里用_代替了:形状为 [batch_size, num_anchors],包含每个锚点的目标标签
target_bboxes:形状为 [batch_size, num_anchors, 4],包含每个锚点的目标边界框
target_scores:形状为 [batch_size, num_anchors, num_classes],包含每个锚点的目标得分。
fg_mask:形状为 [batch_size, num_anchors],标识哪些锚点是前景(即有效的目标, 正样本)。
fg_mask作用:标识哪些锚点是前景(正样本),哪些是背景(负样本)。
正样本:锚点被分配给一个真实的目标,表示这个锚点负责检测这个目标。
负样本:锚点未被分配给任何目标,表示这个锚点不负责检测任何目标
target_gt_idx, 这里用_代替了:形状为 [batch_size, num_anchors],包含每个锚点对应的真实目标索引。
"""
target_scores_sum = max(target_scores.sum(), 1) # 求和
# 这行代码通过计算 target_scores 张量中所有分数的总和,并确保这个总和至少为 1,来避免在后续的损失计算中出现除以零的情况。
# Cls loss
# loss[1] = self.varifocal_loss(pred_scores, target_scores, target_labels) / target_scores_sum # VFL way
loss[1] = self.bce(pred_scores, target_scores.to(dtype)).sum() / target_scores_sum # BCE
# 这一步就是计算cls损失了,就是我们打印在控制台的cls Loss,
# 损失计算概念给大家简单解释一下,简单来说求是真实值和预测值求差值!
# 所以我们需要输入标签的预测值pred_scores和真实值target_scores来求bce二分类交叉熵损失.
# 但是后面跟了.sum() / target_scores_sum大家会发现,这是因为我们上面的pred_scores, target_scores为每一个锚点的计算值返回.
# 然后我们求和之后在除以张量中所有分数的总和, 这是为了标准化损失,避免由于正样本数量不同而造成的影响(就是为了避免极端值的出现会对模型的导引很大也是为了稳定训练)。
# Bbox loss
if fg_mask.sum(): # 对fg_mask进行.sum()求和上面讲了这个fg_mask里面是布尔值,但是也是可以求和的True为1, False为0, 如果有一个是正样本是 1 则会进行边界框损失计算, 这是python基础问题怕大家有疑问解释一下!
target_bboxes /= stride_tensor
# 将目标边界框的坐标除以步幅,恢复到特征图尺度(这里可以看到stride这个参数其实很重要的需要辅助我们真是图和特征图之间相互转化大家需要理解!)
loss[0], loss[2] = self.bbox_loss(
pred_distri, pred_bboxes, anchor_points, target_bboxes, target_scores, target_scores_sum, fg_mask
)
# 上面一行代码在下面的内容中讲了
loss[0] *= self.hyp.box # box gain 边界框损失
loss[1] *= self.hyp.cls # cls gain 分类损失
loss[2] *= self.hyp.dfl # dfl gain DFL损失(之前讲过作用了上一章不懂的可以回去看看)
# 到此我们就全部分析了,损失函数计算完剩下的就是反向传播了就是黑盒内容了.
return loss.sum() * batch_size, loss.detach() # loss(box, cls, dfl)
2.2 BboxLoss
在上面v8DetectionLoss中我们还有部分内容没有解析到,下面是BboxLoss的解析,
class BboxLoss(nn.Module):
"""在训练期间计算训练损失的准则类。"""
def __init__(self, reg_max=16):
"""
初始化 BboxLoss 模块,设置正则化最大值和 DFL 设置。
参数:
reg_max (int): 回归的最大值。如果大于 1,则使用 DFL。
"""
super().__init__()
self.dfl_loss = DFLoss(reg_max) if reg_max > 1 else None # 如果 reg_max > 1,则初始化 DFLoss
def forward(self, pred_dist, pred_bboxes, anchor_points, target_bboxes, target_scores, target_scores_sum, fg_mask):
"""
计算 IoU 损失和可选的 DFL 损失。
参数:
pred_dist (Tensor): 预测的边界框分布。
pred_bboxes (Tensor): 预测的边界框。
anchor_points (Tensor): 锚点。
target_bboxes (Tensor): 目标(真实)边界框。
target_scores (Tensor): 每个锚点的目标(真实)分数。
target_scores_sum (Tensor): 目标分数的总和。
fg_mask (Tensor): 指示正样本的前景掩码。
返回:
Tuple[Tensor, Tensor]: IoU 损失和 DFL 损失。
"""
# 计算前景样本(正样本)的权重
weight = target_scores.sum(-1)[fg_mask].unsqueeze(-1)
# 计算 IoU 损失
iou = bbox_iou(pred_bboxes[fg_mask], target_bboxes[fg_mask], xywh=False, CIoU=True)
# 大家可能最想知道这里的bbox_iou是如何计算的毕竟我们很多人都修改了损失函数,但是这里面有很多的选项,
# 我只讲其中的一些关键部分不可能每一个损失的计算都讲解,有兴趣其实可以看对应损失函数的论文即可。
loss_iou = ((1.0 - iou) * weight).sum() / target_scores_sum
# 对iou进行加权、求和和标准化,得到最终的 IoU 损失值。这种方法确保了损失的计算考虑了样本的置信度,并且损失值是标准化的
# 如果使用 DFL,则计算 DFL 损失
if self.dfl_loss: # DFL损失的计算这里不多讲了之前讲过了在上一章
target_ltrb = bbox2dist(anchor_points, target_bboxes, self.dfl_loss.reg_max - 1)
loss_dfl = self.dfl_loss(pred_dist[fg_mask].view(-1, self.dfl_loss.reg_max), target_ltrb[fg_mask]) * weight
loss_dfl = loss_dfl.sum() / target_scores_sum
else:
loss_dfl = torch.tensor(0.0).to(pred_dist.device) # 执行不到
return loss_iou, loss_dfl # 返回损失值
下面的内容在上一章已经介绍过了,给大家做个回顾,不需要的可以跳过了本文内容到此就结束了!
2.3 bbox_iou
在BboxLoss中还涉及到bbox_iou的计算也就是我们平时修改损失函数的代码,这一部分大家可以仔细看看涉及到IoU的计算.
def bbox_iou(box1, box2, xywh=True, GIoU=False, DIoU=False, CIoU=False, eps=1e-7):
"""
计算 box1 (1, 4) 和 box2 (n, 4) 的 IoU。
参数:
box1 (torch.Tensor): 表示单个边界框的张量,形状为 (1, 4)。
box2 (torch.Tensor): 表示 n 个边界框的张量,形状为 (n, 4)。
xywh (bool, optional): 如果为 True,输入的框格式为 (x, y, w, h)。
如果为 False,输入的框格式为 (x1, y1, x2, y2)。默认值为 True(但是我们这里是False,因为外部给设置为False了)不知道大家记不记得我前面讲了坐标为xyxy的形式。
GIoU (bool, optional): 如果为 True,计算广义 IoU。默认值为 False。
DIoU (bool, optional): 如果为 True,计算距离 IoU。默认值为 False。
CIoU (bool, optional): 如果为 True,计算完全 IoU。默认值为 False。
eps (float, optional): 防止除零的小值。默认值为 1e-7。
返回:
(torch.Tensor): 根据指定的标志返回 IoU、GIoU、DIoU 或 CIoU 值。
"""
# 获取边界框的坐标
if xywh: # 从 xywh 转换为 xyxy 坐标转换内容都是公式不多解释了。
(x1, y1, w1, h1), (x2, y2, w2, h2) = box1.chunk(4, -1), box2.chunk(4, -1)
w1_, h1_, w2_, h2_ = w1 / 2, h1 / 2, w2 / 2, h2 / 2
b1_x1, b1_x2, b1_y1, b1_y2 = x1 - w1_, x1 + w1_, y1 - h1_, y1 + h1_
b2_x1, b2_x2, b2_y1, b2_y2 = x2 - w2_, x2 + w2_, y2 - h2_, y2 + h2_
else: # x1, y1, x2, y2 = box1
b1_x1, b1_y1, b1_x2, b1_y2 = box1.chunk(4, -1)
b2_x1, b2_y1, b2_x2, b2_y2 = box2.chunk(4, -1)
w1, h1 = b1_x2 - b1_x1, b1_y2 - b1_y1 + eps
w2, h2 = b2_x2 - b2_x1, b2_y2 - b2_y1 + eps
# 交集区域, 这段代码的作用是计算两个边界框的交集区域面积。具体来说,它通过计算两个边界框在 x 轴和 y 轴方向上的重叠部分,进而求出交集区域的面积
# 大家可以想象两个正方形然和交际的部分内容,如果你懂IoU那么对这种描述应该是有一个内心绘图的.
inter = (b1_x2.minimum(b2_x2) - b1_x1.maximum(b2_x1)).clamp_(0) * (
b1_y2.minimum(b2_y2) - b1_y1.maximum(b2_y1)
).clamp_(0)
# 并集区域
union = w1 * h1 + w2 * h2 - inter + eps
# Intersection over Union (IoU) 在中文中的翻译是交并比
# 那么计算公式就很明显了并集面积/交集面积就是下面
# IoU
iou = inter / union
if CIoU or DIoU or GIoU:
# 这里就属于各种损失函数的计算了,大家看各自的论文内容解释就行了。
# 最小包围盒的宽度
cw = b1_x2.maximum(b2_x2) - b1_x1.minimum(b2_x1)
# 最小包围盒的高度
ch = b1_y2.maximum(b2_y2) - b1_y1.minimum(b2_y1)
if CIoU or DIoU: # 距离或完全 IoU https://arxiv.org/abs/1911.08287v1
c2 = cw.pow(2) + ch.pow(2) + eps # 最小包围盒对角线的平方
rho2 = (
(b2_x1 + b2_x2 - b1_x1 - b1_x2).pow(2) + (b2_y1 + b2_y2 - b1_y1 - b1_y2).pow(2)
) / 4 # 中心距离的平方
if CIoU: # https://github.com/Zzh-tju/DIoU-SSD-pytorch/blob/master/utils/box/box_utils.py#L47
v = (4 / math.pi**2) * ((w2 / h2).atan() - (w1 / h1).atan()).pow(2)
with torch.no_grad():
alpha = v / (v - iou + (1 + eps))
return iou - (rho2 / c2 + v * alpha) # CIoU
return iou - rho2 / c2 # DIoU
c_area = cw * ch + eps # 最小包围盒的面积
return iou - (c_area - union) / c_area # GIoU https://arxiv.org/pdf/1902.09630.pdf
return iou # IoU
部分内容回顾!
2.4 DFL
DFL是一个用于对象检测的损失函数模块,主要用于提高边界框回归的精度。它的核心思想是将每个预测的边界框参数(如 x, y, w, h)分解为多个通道,然后通过 softmax 操作得到一个分布,并计算分布的积分来预测实际值。
下面是部分代码解析!
class DFL(nn.Module):
"""
Integral module of Distribution Focal Loss (DFL).
Proposed in Generalized Focal Loss https://ieeexplore.ieee.org/document/9792391
"""
def __init__(self, c1=16):
"""Initialize a convolutional layer with a given number of input channels."""
super().__init__()
self.conv = nn.Conv2d(c1, 1, 1, bias=False).requires_grad_(False)
x = torch.arange(c1, dtype=torch.float)
self.conv.weight.data[:] = nn.Parameter(x.view(1, c1, 1, 1))
self.c1 = c1
c1:表示输入通道数,默认为 16。
self.conv:定义了一个卷积层,输入通道数为 c1`,输出通道数为 1,卷积核大小为 1x1。这个卷积层的权重初始化为 0 到 c1-1 的浮点数,并且不更新requires_grad_(False)不更新梯度的意思代表。
self.c1:保存输入通道数。
def forward(self, x):
"""Applies a transformer layer on input tensor 'x' and returns a tensor."""
b, _, a = x.shape # batch, channels, anchors
return self.conv(x.view(b, 4, self.c1, a).transpose(2, 1).softmax(1)).view(b, 4, a)
输入 x:形状为 [batch_size, channels, anchors]的张量,其中 channels为 4× c1,每个边界框参数(如 x, y, w, h)都有 c1个通道(这里和我们前面的解释一致)。
下面的操作为正向传播中最后一行代码的解析!
1.变形操作:
x.view(b, 4, self.c1, a)
将 x变形为 [batch_size, 4, c1, anchors]的张量,其中 4 表示四个边界框参数(x, y, w, h),c1 是每个参数的通道数。
2. 转置操作:
.transpose(2, 1)
将张量转置为 [batch_size, c1, 4, anchors],将通道维度 c1 和参数维度 4交换。
3. softmax 操作:
.softmax(1)
在通道维度 c1上应用 softmax,得到每个参数的概率分布。
4. 卷积操作:
self.conv(...)
使用卷积层 self.conv将分布的积分计算出来。卷积层的权重初始化为 0 到 c1-1,这样卷积操作实际上计算的是这些通道的加权平均值。
5. 变形回原始形状:
.view(b, 4, a)
最终将输出变形为 [batch_size, 4, anchors],即每个锚点的四个边界框参数。
具体操作流程
1. 输入形状:假设输入张量 x的形状为 [2, 64, 100](batch_size=2,channels=64,anchors=100),其中 64 是 4 × c1(每个参数 16 个通道)。
2. 变形:将输入张量变形为 [2, 4, 16, 100],表示每个锚点的四个参数(x, y, w, h),每个参数有 16 个通道。
3. 转置:将张量转置为 [2, 16, 4, 100]。
4. softmax:在通道维度 16 上应用 softmax,得到每个参数的概率分布。
5. 卷积:使用卷积层计算分布的积分,得到每个参数的加权平均值。
6. 输出形状:将输出变形为 [2, 4, 100],即每个锚点的四个边界框参数。
DFL 模块通过将每个边界框参数分解为多个通道,使用 softmax 获得概率分布,然后通过卷积计算分布的积分来预测实际的边界框参数。这样的方法可以提高边界框回归的精度。
2.5 make_anchors
下面的代码是我们检测头代码中详解没有解析的内容大家可以去上面找一下!
def make_anchors(feats, strides, grid_cell_offset=0.5):
"""Generate anchors from features."""
anchor_points, stride_tensor = [], []
assert feats is not None
dtype, device = feats[0].dtype, feats[0].device
for i, stride in enumerate(strides):
_, _, h, w = feats[i].shape
sx = torch.arange(end=w, device=device, dtype=dtype) + grid_cell_offset # shift x
sy = torch.arange(end=h, device=device, dtype=dtype) + grid_cell_offset # shift y
sy, sx = torch.meshgrid(sy, sx, indexing="ij") if TORCH_1_10 else torch.meshgrid(sy, sx)
anchor_points.append(torch.stack((sx, sy), -1).view(-1, 2))
stride_tensor.append(torch.full((h * w, 1), stride, dtype=dtype, device=device))
return torch.cat(anchor_points), torch.cat(stride_tensor)
好的,让我们具体解释一下每个 anchor 如何表示特征图中每个单元格的位置(需要先理解的概念)。
什么是 Anchor
在对象检测中,anchor 是一个预定义的边界框模板,用于在特征图的每个单元格(即特征图的每个位置)上进行预测。每个 anchor 具有固定的大小和形状,模型通过调整这些 anchors 来拟合实际的物体边界框。
特征图和输入图像
假设我们有一个输入图像和对应的特征图:
- 输入图像大小:640x640
- 特征图大小:80x80
特征图的每个单元格对应于输入图像的一个区域。具体来说,特征图的一个单元格覆盖输入图像的 8x8 像素(假设 stride 为 8,前面讲过如何计算就是放缩比例640 ÷ 80 = 8)。
Anchor 在特征图中的位置
当我们在特征图上生成 anchors 时,每个单元格中心都会有一个 anchor。anchor 的位置用单元格的坐标表示(例如,特征图的第 (i, j) 个单元格,这些其实是图像基础的内容)。### 具体实现
在 make_anchors函数中,我们通过以下步骤生成 anchors(代码中内容的解析):
1. 生成网格点坐标
sx = torch.arange(end=w, device=device, dtype=dtype) + grid_cell_offset # shift x sy = torch.arange(end=h, device=device, dtype=dtype) + grid_cell_offset # shift y
- torch.arange(end=w, device=device, dtype=dtype)生成从 0 到 w-1 的序列,表示特征图宽度方向上的索引。
- torch.arange(end=h, device=device, dtype=dtype)生成从 0 到 h-1 的序列,表示特征图高度方向上的索引。
- grid_cell_offset(默认为 0.5)用于将网格点移动到单元格的中心。
2. 生成网格坐标的所有组合
sy, sx = torch.meshgrid(sy, sx, indexing="ij") if TORCH_1_10 else torch.meshgrid(sy, sx)
- torch.meshgrid(sy, sx)生成网格点的所有组合,即每个单元格的中心坐标。
3. 组合并展平
anchor_points.append(torch.stack((sx, sy), -1).view(-1, 2))
- torch.stack((sx, sy), -1)将 x 和 y 坐标堆叠在一起,形成形状为 (h, w, 2)的张量。
- .view(-1, 2) 将其展平为形状为 (h * w, 2)的二维张量,其中每一行表示一个 anchor 的中心点坐标。
例子说明(用具体例子给大家阐述一下辅助大家理解一下)
假设我们有一个 3x3 的特征图,其单元格的中心坐标如下:
(0.5, 0.5) (1.5, 0.5) (2.5, 0.5)
(0.5, 1.5) (1.5, 1.5) (2.5, 1.5)
(0.5, 2.5) (1.5, 2.5) (2.5, 2.5)使用 make_anchors函数生成的 anchor points 将是一个形状为 (9, 2)的张量 (数学内容),每一行表示一个单元格的中心坐标(其中的.5为偏移量!):
[[0.5, 0.5],
[1.5, 0.5],
[2.5, 0.5],
[0.5, 1.5],
[1.5, 1.5],
[2.5, 1.5],
[0.5, 2.5],
[1.5, 2.5],
[2.5, 2.5]]总结
每个 anchor 表示特征图中每个单元格的位置,这些位置是特征图网格点的中心坐标。通过 make_anchors函数,我们生成了这些 anchors,并将它们用于对象检测模型的边界框预测,同时还有sride为放缩比例同时计算。
2.6 dist2bbox
下面的代码为位置坐标信息的解码操作!
def dist2bbox(distance, anchor_points, xywh=True, dim=-1):
"""Transform distance(ltrb) to box(xywh or xyxy)."""
lt, rb = distance.chunk(2, dim)
x1y1 = anchor_points - lt
x2y2 = anchor_points + rb
if xywh:
c_xy = (x1y1 + x2y2) / 2
wh = x2y2 - x1y1
return torch.cat((c_xy, wh), dim) # xywh bbox
return torch.cat((x1y1, x2y2), dim) # xyxy bbox
这段代码的作用是将距离转换为边界框坐标,可以选择转换为中心点坐标和宽高(xywh)或者左上角和右下角坐标(xyxy)。
def dist2bbox(distance, anchor_points, xywh=True, dim=-1): """Transform distance(ltrb) to box(xywh or xyxy)."""
- distance:表示从 anchor points 到边界框的左、上、右、下边界的距离(left, top, right, bottom)。
- anchor_points:anchor points 的坐标。
- xywh:布尔值,决定输出边界框的格式,默认为 `True,表示输出为中心点坐标和宽高(xywh),否则输出为左上角和右下角坐标(xyxy)。
- dim:指明在哪个维度上切分距离张量,默认值为 -1。
核心逻辑
1. 切分距离张量
lt, rb = distance.chunk(2, dim)
将 distance 张量分为左右两个部分:
- lt:left, top 距离
- rb:right, bottom 距离
- chunk(2, dim) 表示在指定维度 dim 上将 distance切分为 2 个张量。
2. 计算左上角和右下角坐标
x1y1 = anchor_points - lt x2y2 = anchor_points + rb
- x1y1:左上角坐标,由 anchor points 减去 left 和 top 距离得到。
- x2y2:右下角坐标,由 anchor points 加上 right 和 bottom 距离得到。
3. 转换为指定格式的边界框
如果 xywh 为 True,转换为中心点坐标和宽高:
if xywh: c_xy = (x1y1 + x2y2) / 2 wh = x2y2 - x1y1 return torch.cat((c_xy, wh), dim) # xywh bbox
- c_xy:中心点坐标,由左上角和右下角坐标的平均值计算得到。
- wh:宽高,由右下角坐标减去左上角坐标计算得到。
最终返回中心点坐标和宽高的组合。
如果 xywh为 False,返回左上角和右下角坐标:
return torch.cat((x1y1, x2y2), dim) # xyxy bbox
最终返回左上角和右下角坐标的组合。
示例
假设 distance为一个形状为 [N, 4] 的张量,其中 N是 anchor points 的数量,4表示 left, top, right, bottom 距离。anchor_points为一个形状为 [N, 2]的张量,表示每个 anchor 的坐标。
例子:转换为 xywh
distance = torch.tensor([[1, 1, 2, 2], [2, 2, 3, 3]]) anchor_points = torch.tensor([[5, 5], [10, 10]]) xywh = True bbox = dist2bbox(distance, anchor_points, xywh)
步骤:
1. 切分 distance为 lt和 rb:
- lt:[[1, 1], [2, 2]]
- rb:[[2, 2], [3, 3]]
2. 计算 x1y1和 x2y2:
- x1y1:[[4, 4], [8, 8]]
- x2y2:[[7, 7], [13, 13]]
3. 转换为中心点坐标和宽高:
- c_xy:[[5.5, 5.5], [10.5, 10.5]]
- wh:[[3, 3], [5, 5]]
4. 返回的 bbox为 [[5.5, 5.5, 3, 3], [10.5, 10.5, 5, 5]]
例子:转换为 xyxy
xywh = False bbox = dist2bbox(distance, anchor_points, xywh)
步骤:
1. 切分 distance为 lt 和rb:
- lt:[[1, 1], [2, 2]]
- rb:[[2, 2], [3, 3]]
2. 计算 x1y1和 x2y2:
- x1y1:[[4, 4], [8, 8]]
- x2y2:[[7, 7], [13, 13]]
3. 返回的bbox为 [[4, 4, 7, 7], [8, 8, 13, 13]]
总结
dist2bbox`函数将表示 left, top, right, bottom 距离的张量转换为边界框坐标,可以选择转换为中心点坐标和宽高(xywh)或者左上角和右下角坐标(xyxy)。通过这个函数,模型可以从预测的距离值中计算出实际的边界框坐标。
到此本文的内容就全部完事了,主要内容都在三个代码块中给出了解析!
三、本文总结
到此本文的正式分享内容就结束了,在这里给大家推荐我的YOLOv8改进有效涨点专栏,本专栏目前为新开的平均质量分98分,后期我会根据各种最新的前沿顶会进行论文复现,也会对一些老的改进机制进行补充,目前本专栏免费阅读(暂时,大家尽早关注不迷路~),如果大家觉得本文帮助到你了,订阅本专栏,关注后续更多的更新~
专栏回顾:YOLOv8改进系列专栏——本专栏持续复习各种顶会内容——科研必备