YOLOv8解码流程完全解析
目录
YOLOv8解码流程完全解析
本文详细分析了YOLOv8目标检测算法中的预测解码和后处理机制,包括DFL(Distribution Focal Loss)解码、非极大值抑制(NMS)等关键环节。
目录
1. 预测解码流程 (decode_predictions)
YOLOv8采用anchor-free设计,预测解码过程将网络输出转换为标准的边界框格式。整个流程可分为以下几个关键步骤:
1.1 网格点生成
为每个特征图生成参考点坐标和对应的stride值:
# 生成锚点和对应步长
anchors, strides = (x.transpose(0, 1) for x in
self.make_anchors(predictions[1], self.stride, 0.5))
# anchors: 所有特征图的网格点坐标
# strides: 对应的stride值(8/16/32)
这一步完成了:
- 为三个特征图(P3/P4/P5)生成网格点
- 生成对应的stride值(P3:8, P4:16, P5:32)
1.2 特征图处理
将三个尺度的特征图预测结果统一处理,分离边界框和类别预测:
# 将三个特征图的预测结果拼接
x_cat = torch.cat([xi.view(1, self.nc + 16 * 4, -1) for xi in predictions[1]], 2)
# P3: (1, nc+64, 6400) # 80*80=6400
# P4: (1, nc+64, 1600) # 40*40=1600
# P5: (1, nc+64, 400) # 20*20=400
# 分离边界框预测和类别预测
box, cls = x_cat.split((16 * 4, self.nc), 1)
# box: (1, 64, 8400) # 64=16*4,每个坐标用16个值编码
# cls: (1, nc, 8400) # nc是类别数
维度解包为DFL解码做准备:
b, c, a = box.shape
# b: batch size,通常为1
# c: channels,等于 64 (16*4),表示每个边界框的编码维度
# a: anchors,等于 8400 (6400+1600+400),表示所有特征图的网格点总数
# 例如:
box.shape = (1, 64, 8400)
# 则:
b = 1 # 批次大小
c = 64 # 16*4,每个坐标(x,y,w,h)用16个值编码
a = 8400 # 总网格点数 = P3(80*80) + P4(40*40) + P5(20*20)
1.3 DFL解码实现
YOLOv8使用DFL(Distribution Focal Loss)将离散预测值转换为连续坐标。这一步骤的核心是通过1x1卷积实现加权平均:
# 创建DFL解码用的1x1卷积
# - 输入通道:16(每个坐标的编码维度)
# - 输出通道:1(解码后的单个值)
# - 核大小:1x1
# - 不需要偏置
# - 不需要梯度(固定权重)
conv = nn.Conv2d(16, 1, 1, bias=False).requires_grad_(False)
# 创建数组 [0,1,2,...,15]
x = torch.arange(16, dtype=torch.float) # tensor([0., 1., 2., ..., 15.])
# 设置卷积权重为 [0,1,2,...,15]
conv.weight.data[:] = nn.Parameter(x.view(1, 16, 1, 1))
# 这样卷积操作就相当于加权平均:
# output = 0*p0 + 1*p1 + 2*p2 + ... + 15*p15
实现细节解析:
# 1. 创建数组 [0,1,2,...,15]
x = torch.arange(16, dtype=torch.float)
# tensor([0., 1., 2., ..., 15.])
# 2. 重塑为卷积权重的形状
x = x.view(1, 16, 1, 1)
# 1: 输出通道数
# 16: 输入通道数
# 1,1: 卷积核大小
# shape: torch.Size([1, 16, 1, 1])
# 3. 将其设置为卷积层的权重
conv.weight.data[:] = nn.Parameter(x)
# conv.weight.shape = (1, 16, 1, 1)
DFL设计的核心原理:
- 使用1x1卷积实现加权平均
- 权重固定为[0-15],不需要学习
- 将16个概率值转换为一个连续的坐标值
示例:
# 如果预测概率分布是:
probs = [0.0, 0.7, 0.3, 0.0, ..., 0.0]
# 通过卷积(加权平均)得到:
result = 1*0.7 + 2*0.3 = 1.3
1.4 坐标转换
最后,使用DFL解码并转换为实际边界框坐标:
# 重要:使用这些维度进行张量重塑
# 将每个坐标的16个值转换为实际预测值
dfl = conv(box.view(b, 4, 16, a).transpose(2, 1).softmax(1)).view(b, 4, a)
# 转换为实际边界框坐标
dbox = self.dist2bbox(dfl, anchors.unsqueeze(0), xywh=True, dim=1) * strides
# 组合最终结果
return torch.cat((dbox, cls.sigmoid()), 1)
2. 后处理流程 (post_process)
后处理主要完成四个任务:
- 置信度过滤:去除低置信度的检测框
- 坐标转换:将中心点格式转为左上右下角格式
- NMS处理:去除重叠的检测框,保留最优的
- 尺度还原:将坐标映射回原始图像尺寸
2.1 置信度过滤
首先处理输入格式并进行初步的置信度过滤:
# 1. 处理输入格式
prediction = pred[0] if isinstance(pred, (list, tuple)) else pred
# 2. 第一轮置信度过滤
xc = prediction[:, 4:84].amax(1) > conf_thres # 找出最大类别概率>阈值的框
2.2 坐标格式转换
将中心点格式转为左上右下角格式:
# 3. 坐标格式转换
prediction = prediction.transpose(-1, -2)
prediction[..., :4] = self.xywh2xyxy(prediction[..., :4]) # 中心点格式转左上右下角格式
# 4-5. 根据置信度过滤
x = prediction[0] # 取第一个batch
x = x[xc[0]] # 保留高置信度的框
获取最终的边界框和类别:
# 6-7. 获取最终的边界框和类别
box, cls = x.split((4, self.nc), 1) # 分离坐标和类别概率
conf, j = cls.max(1, keepdim=True) # 找出最高概率的类别
x = torch.cat((box, conf, j.float()), 1)[conf.view(-1) > conf_thres]
代码实现详解:
# 1. 首先看数据形状
box.shape # (N, 4) - 边界框坐标 (x1,y1,x2,y2)
conf.shape # (N, 1) - 最大置信度值
j.shape # (N, 1) - 对应的类别索引
# 2. torch.cat 拼接操作
x = torch.cat((box, conf, j.float()), 1)
# 结果: (N, 6)
# - 前4列是边界框坐标
# - 第5列是置信度
# - 第6列是类别索引
# 3. conf.view(-1)
conf.view(-1) # 将形状从(N,1)变为(N,)
# 4. 根据置信度阈值筛选
mask = conf.view(-1) > conf_thres # 布尔掩码
x = x[mask] # 只保留置信度大于阈值的行
# 举例:
# 假设有3个检测框
box = torch.tensor([[10,20,30,40], # 框1
[50,60,70,80], # 框2
[90,100,110,120]])# 框3
conf = torch.tensor([[0.9], # 框1的置信度
[0.3], # 框2的置信度
[0.8]]) # 框3的置信度
j = torch.tensor([[0], # 框1是类别0
[1], # 框2是类别1
[2]]) # 框3是类别2
# 拼接并筛选(conf_thres=0.5)
x = torch.cat((box, conf, j.float()), 1)[conf.view(-1) > 0.5]
# 结果只保留了框1和框3(置信度>0.5)
2.3 非极大值抑制
执行非极大值抑制(NMS)去除重叠框:
# 8. 非极大值抑制(NMS)
# 1. 计算类别偏移
c = x[:, 5:6] * max_wh # max_wh=7680
# 例如:
# 类别0: 0 * 7680 = 0
# 类别1: 1 * 7680 = 7680
# 类别2: 2 * 7680 = 15360
# 2. 将偏移添加到边界框坐标
boxes, scores = x[:, :4] + c, x[:, 4]
# boxes = x[:, :4] + c
# 这样不同类别的框会被分开到不同的空间区域
# 3. 获取置信度分数
# scores = x[:, 4]
i = torchvision.ops.nms(boxes, scores, iou_thres) # 执行NMS
2.4 坐标缩放还原
最后将边界框坐标缩放回原始图像尺寸:
# 9-10. 缩放到原图尺寸
pred = x[i] # 保留NMS后的框
pred[:, :4] = self.scale_boxes(img.shape[2:], pred[:, :4], orig_img.shape)
3. 核心方法解析
3.1 dist2bbox方法
将预测的距离转换为边界框坐标:
def dist2bbox(self, distance, anchor_points, xywh=True, dim=-1):
"""将预测的距离转换为边界框坐标"""
lt, rb = torch.split(distance, 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)
return torch.cat((x1y1, x2y2), dim)
3.2 scale_boxes方法
将检测框坐标从模型输入尺寸缩放回原始图像尺寸:
def scale_boxes(self, img1_shape, boxes, img0_shape):
"""
img1_shape: 模型输入尺寸 (640, 640)
boxes: 检测框坐标 (x1,y1,x2,y2)
img0_shape: 原始图片尺寸 (h, w)
"""
# 1. 计算缩放比例:gain =输入尺寸/原始尺寸
gain = min(img1_shape[0] / img0_shape[0], # 高度比
img1_shape[1] / img0_shape[1]) # 宽度比
# 2. 计算填充量
pad = ((img1_shape[1] - img0_shape[1] * gain) / 2, # 宽度填充
(img1_shape[0] - img0_shape[0] * gain) / 2) # 高度填充
# 3. 去除填充
boxes[..., [0, 2]] -= pad[0] # x 坐标减去水平填充
boxes[..., [1, 3]] -= pad[1] # y 坐标减去垂直填充
# 4. 缩放回原始尺寸
boxes[..., :4] /= gain
示例解析:
# 假设:
img1_shape = (640, 640) # 模型输入尺寸
img0_shape = (800, 600) # 原始图片尺寸
# 1. 计算缩放比例
gain = min(640/800, 640/600) # = min(0.8, 1.067) = 0.8
# 2. 计算填充量
pad_w = (640 - 600 * 0.8) / 2 # 宽度方向的填充
pad_h = (640 - 800 * 0.8) / 2 # 高度方向的填充
# 原始图片缩放后的尺寸: (800*0.8, 600*0.8) = (640, 480)
# 需要填充到 640x640,所以:
# - 宽度两边各填充 (640-480)/2 像素
# - 高度两边各填充 (640-640)/2 = 0 像素
缩放示例:
# 假设:
# - 原始图片: 1000x800
# - 模型输入: 640x640
# - gain = 640/1000 = 0.64 (缩放比例)
# 1. 模型预测的检测框(在640x640尺度上)
box = [100, 200, 300, 400] # [x1, y1, x2, y2]
# 2. 缩放回原始尺寸
box /= gain # 等价于 box / 0.64
# [100/0.64, 200/0.64, 300/0.64, 400/0.64]
# = [156.25, 312.5, 468.75, 625]
4. 完整代码参考
decode_predictions 完整实现
def decode_predictions(self, predictions):
"""解码模型的原始输出为检测框和类别概率
Args:
predictions: 模型输出的原始预测结果
"""
# 生成锚点和对应步长
anchors, strides = (x.transpose(0, 1) for x in
self.make_anchors(predictions[1], self.stride, 0.5))
# 将三个特征图的预测结果拼接
x_cat = torch.cat([xi.view(1, self.nc + 16 * 4, -1) for xi in predictions[1]], 2)
# 分离边界框预测和类别预测
box, cls = x_cat.split((16 * 4, self.nc), 1) # 16*4用于DFL解码,nc为类别数
b, c, a = box.shape
conv = nn.Conv2d(16, 1, 1, bias=False).requires_grad_(False)
x = torch.arange(16, dtype=torch.float)
conv.weight.data[:] = nn.Parameter(x.view(1, 16, 1, 1))
dfl = conv(box.view(b, 4, 16, a).transpose(2, 1).softmax(1)).view(b, 4, a)
dbox = self.dist2bbox(dfl, anchors.unsqueeze(0), xywh=True, dim=1) * strides
return torch.cat((dbox, cls.sigmoid()), 1)
post_process 完整实现
def post_process(self, pred, img, orig_img, conf_thres=0.25, iou_thres=0.7, max_wh=7680):
"""后处理:执行非极大值抑制(NMS)"""
# 1. 处理验证模式的输出
prediction = pred[0] if isinstance(pred, (list, tuple)) else pred
# 2. 根据置信度阈值筛选候选框
xc = prediction[:, 4:84].amax(1) > conf_thres
# 3. 调整预测结果的形状和格式
prediction = prediction.transpose(-1, -2)
prediction[..., :4] = self.xywh2xyxy(prediction[..., :4])
# 4. 获取当前batch的预测结果
xi = 0
x = prediction[xi]
# 5. 根据置信度过滤预测框
x = x[xc[xi]]
# 6. 分离边界框和类别信息
box, cls = x.split((4, self.nc), 1)
# 7. 获取每个框的最大置信度和对应的类别
conf, j = cls.max(1, keepdim=True)
x = torch.cat((box, conf, j.float()), 1)[conf.view(-1) > conf_thres]
# 8. 执行NMS
c = x[:, 5:6] * max_wh
boxes, scores = x[:, :4] + c, x[:, 4]
i = torchvision.ops.nms(boxes, scores, iou_thres)
# 9. 获取最终预测结果
pred = x[i]
# 10. 将边界框坐标缩放到原始图片尺寸
pred[:, :4] = self.scale_boxes(img.shape[2:], pred[:, :4], orig_img.shape)
return pred
文章对话
由AI生成的"小T"和"好奇宝宝"之间的对话,帮助理解文章内容