YOLOv8 的 Detect Head 是整个检测模型中最关键的部分,它负责将神经网络提取的多尺度特征图转化为最终的物体检测结果。简单来说,它的工作就像是一个"翻译官",把神经网络看到的抽象特征"翻译"成我们能理解的物体位置和类别信息。
在实际工作中,Detect Head 需要完成三个核心任务:
这三个任务看似简单,但实现起来却需要一系列精妙的数学转换。举个例子,当模型看到一个80×80的特征图时,它实际上是在用6400个"小眼睛"(每个网格点)观察图像的不同区域,每个"小眼睛"都要负责预测这个区域是否有物体、是什么物体、以及物体的精确位置。
YOLOv8 会从骨干网络(Backbone)获取三个不同尺度的特征图,通常尺寸分别是80×80、40×40和20×20。这三个尺度的特征图各有所长:
在代码实现中,这三个特征图首先会被展平并拼接在一起:
python复制x_cat = torch.cat([xi.view(shape[0], self.no, -1) for xi in x], 2) # (1,144,8400)
这里的8400是怎么来的呢?其实就是三个特征图网格点数的总和:80×80 + 40×40 + 20×20 = 6400 + 1600 + 400 = 8400。
拼接后的特征图包含了边界框预测和类别预测两部分信息,需要通过拆分来分别处理:
python复制box, cls = x_cat.split((self.reg_max * 4, self.nc), 1) # (1,64,8400),(1,80,8400)
box 张量存储了边界框预测信息,维度是64×8400cls 张量存储了类别预测信息,维度是80×8400(假设有80个类别)这种设计让模型能够并行处理位置和类别信息,大大提高了检测效率。
与早期YOLO版本不同,YOLOv8采用了Anchor-Free的方式,不再需要预定义各种形状的anchor box。取而代之的是为每个特征图生成网格点(grid points),这些点就是潜在的物体中心位置预测点。
生成网格点的关键函数是make_anchors:
python复制def make_anchors(feats, strides, grid_cell_offset=0.5):
anchor_points, stride_tensor = [], []
for i, stride in enumerate(strides):
_, _, h, w = feats[i].shape
sx = torch.arange(end=w, device=device, dtype=dtype) + grid_cell_offset
sy = torch.arange(end=h, device=device, dtype=dtype) + grid_cell_offset
sy, sx = torch.meshgrid(sy, sx, indexing="ij")
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)
这个函数为每个特征图生成网格坐标,并记录对应的stride(步长)值。比如对于80×80的特征图,会生成6400个网格点,每个点的坐标都是像(0.5,0.5)、(1.5,0.5)这样的形式,对应图像上的实际位置需要乘以stride(这里是8)来得到。
Stride在YOLOv8中扮演着关键角色,它决定了:
例如:
这种多尺度设计让YOLOv8能够同时检测各种尺寸的物体,从微小的昆虫到巨大的车辆都能处理。
YOLOv8引入DFL(Distribution Focal Loss)来更精确地预测边界框位置。传统方法直接回归边界框坐标,而DFL将位置预测视为一个离散分布的学习问题。
简单来说,DFL不是直接说"边界框左边距离中心点5.3像素",而是预测"左边距离中心点有70%概率在5像素,30%概率在6像素"。这种表示方式让位置预测更加灵活和准确。
DFL的核心实现是一个特殊的卷积层:
python复制class DFL(nn.Module):
def __init__(self, c1=16):
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
def forward(self, x):
b, _, a = x.shape
return self.conv(x.view(b, 4, self.c1, a).transpose(2, 1).softmax(1)).view(b, 4, a)
这个实现有几个精妙之处:
举个例子,如果某个边界框左侧的16个概率值是[0,0,0.1,0.3,0.4,0.2,...],那么计算出的左侧偏移量就是3×0.1 + 4×0.3 + 5×0.4 + 6×0.2 = 4.7(假设stride=1)。
得到四个边的偏移量后,还需要将其转换为实际的边界框坐标。这是通过dist2bbox函数完成的:
python复制def dist2bbox(distance, anchor_points, xywh=True, dim=-1):
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
这个函数做了以下几件事:
类别预测相对简单,就是对原始输出应用sigmoid函数:
python复制cls.sigmoid() # 将logits转换为概率
这里使用sigmoid而不是softmax,是因为YOLOv8支持多标签分类(一个物体可能属于多个类别)。
最后,将处理好的边界框坐标和类别概率合并起来:
python复制y = torch.cat((dbox, cls.sigmoid()), 1) # (1,84,8400)
这个合并后的张量就是Detect Head的最终输出,其中:
在实际应用中,还需要对这三万多个预测结果进行过滤(通过置信度阈值和NMS),才能得到最终的检测结果。
在将YOLOv8部署到实际项目中时,有几个关键点需要注意:
特征图尺度的选择:如果你的应用场景中小物体特别多,可能需要增加更大尺度的特征图;反之如果主要是大物体,可以减少小尺度特征图。
DFL的reg_max参数:这个参数决定了位置预测的离散程度。默认16适用于大多数场景,但对于特别大的图像或需要极高定位精度的任务,可以适当增大。
导出模型时的处理:当导出为ONNX或其他格式时,Detect Head的实现会有一些调整,主要是为了兼容不同的推理引擎。特别要注意stride和anchor的处理方式可能发生变化。
训练时的初始化:YOLOv8的Detect Head在训练初期会进行特殊的偏置初始化,这对模型收敛很重要。如果自己从头训练,不要随意修改这部分代码。
我在实际项目中发现,理解Detect Head的每个细节对于调试模型性能非常有帮助。当遇到检测不准的情况时,可以有针对性地检查是特征图的问题、DFL解码的问题,还是后续处理的问题,而不是盲目调整超参数。