3. 解析YOLO输出:边界框坐标、置信度、类别概率的提取与解码
YOLO模型跑完一次前向推理,输出的到底是什么?说白了,就是一个巨大的张量(Tensor)。很多人拿到这个张量就懵了——一堆浮点数,怎么变成画面上的框和标签?
我刚开始接触YOLO时也踩过这个坑。模型推理成功了,但输出结果完全看不懂。后来才明白,YOLO的输出格式其实有一套固定的编码规则。搞懂它,你就能从原始数据里把边界框、置信度、类别概率一个个拆出来。
3.1 YOLO输出的原始结构
YOLOv5/v8的输出张量形状通常是:[batch_size, num_anchors * (5 + num_classes), grid_h, grid_w]。但经过后处理(如NMS)之前,我们更常看到的是扁平化后的形状:[batch_size, num_predictions, 5 + num_classes]。
举个例子,YOLOv8默认输入640×640,输出就是[1, 84, 8400]。这里的8400是三个尺度特征图上所有网格预测框的总数。84这个维度里,前4个是边界框坐标,第5个是置信度(或目标性分数),后面80个是COCO数据集的类别概率。
| 维度索引 | 含义 | 说明 |
|---|---|---|
| 0-3 | 边界框坐标 | 通常是 cx, cy, w, h(中心点+宽高) |
| 4 | 目标置信度 | 该位置存在目标的概率,范围0~1 |
| 5~84 | 类别概率 | 每个类别的得分,共80个(COCO) |
关键点:YOLOv5和YOLOv8的输出格式略有不同。v5的输出是[batch, 25200, 85](三个尺度共25200个预测框),而v8改成了[batch, 84, 8400],并且去掉了objectness分支,直接用类别概率的最大值作为置信度。
3.2 边界框坐标解码
YOLO输出的坐标是归一化后的值,范围在0~1之间。但注意,它用的是中心点坐标+宽高的格式,而不是左上角+右下角。
解码公式其实很简单:
# 假设输出张量为 output,形状 [1, 84, 8400]
# 先转置成 [8400, 84] 方便处理
output = output.permute(0, 2, 1) # [1, 8400, 84]
# 提取前4个值:cx, cy, w, h
cx = output[0, :, 0]
cy = output[0, :, 1]
w = output[0, :, 2]
h = output[0, :, 3]
# 转换成 x1, y1, x2, y2(左上角+右下角)
x1 = cx - w / 2
y1 = cy - h / 2
x2 = cx + w / 2
y2 = cy + h / 2
# 如果原始图像尺寸是 (img_w, img_h),需要反归一化
x1 = x1 * img_w
y1 = y1 * img_h
x2 = x2 * img_w
y2 = y2 * img_h
我的习惯:我会在解码后立即把坐标裁剪到图像边界内,用 np.clip() 或 torch.clamp()。因为有些预测框的中心点靠近边缘时,宽高计算后可能会超出图像范围。虽然NMS阶段也能处理,但提前裁剪能避免后续画框时出现奇怪的越界。
3.3 置信度与类别概率的提取
置信度代表模型对这个预测框的「自信程度」。在YOLOv8中,它直接取80个类别概率的最大值。而在YOLOv5中,它是objectness分数乘以最大类别概率。
提取类别概率时,我一般用argmax找到得分最高的类别,同时记录这个最高分作为置信度。
# 提取类别概率部分(索引5到末尾)
class_probs = output[0, :, 5:] # [8400, 80]
# 找到每个预测框的最佳类别和对应分数
scores, class_ids = torch.max(class_probs, dim=1)
# 过滤低置信度的预测框
confidence_threshold = 0.5
mask = scores > confidence_threshold
filtered_boxes = boxes[mask]
filtered_scores = scores[mask]
filtered_class_ids = class_ids[mask]
我曾经踩过的坑:有一次我直接用了 torch.max() 的结果作为最终置信度,但忘了检查这个最大值是否真的有意义。结果模型对背景区域也输出了0.3左右的分数,导致画面上全是假阳性框。后来我加了一个置信度阈值(0.5以上才保留),问题就解决了。阈值设多少取决于你的场景——严格场景设0.7,宽松场景0.3也行。
3.4 完整解码流程
把上面几步串起来,就是一个完整的YOLO输出解析流程。我个人习惯把它封装成一个函数,方便复用。
def decode_yolo_output(output, img_shape, conf_threshold=0.5):
"""
解析YOLO输出张量
:param output: 模型输出,形状 [1, 84, 8400]
:param img_shape: 原始图像尺寸 (h, w)
:param conf_threshold: 置信度阈值
:return: boxes, scores, class_ids
"""
img_h, img_w = img_shape
# 转置成 [8400, 84]
output = output.permute(0, 2, 1).squeeze(0)
# 提取坐标
cx, cy, w, h = output[:, 0], output[:, 1], output[:, 2], output[:, 3]
# 转成 x1y1x2y2 并反归一化
x1 = (cx - w / 2) * img_w
y1 = (cy - h / 2) * img_h
x2 = (cx + w / 2) * img_w
y2 = (cy + h / 2) * img_h
boxes = torch.stack([x1, y1, x2, y2], dim=1)
# 裁剪到图像边界
boxes[:, 0] = boxes[:, 0].clamp(0, img_w)
boxes[:, 1] = boxes[:, 1].clamp(0, img_h)
boxes[:, 2] = boxes[:, 2].clamp(0, img_w)
boxes[:, 3] = boxes[:, 3].clamp(0, img_h)
# 提取类别概率和置信度
class_probs = output[:, 5:]
scores, class_ids = torch.max(class_probs, dim=1)
# 过滤低置信度
mask = scores > conf_threshold
return boxes[mask], scores[mask], class_ids[mask]
3.5 解码后的数据流向
解码完成后,我们得到的是三个列表:边界框坐标、置信度分数、类别ID。这些数据接下来会进入NMS(非极大值抑制)阶段,去除重复的检测框。但那是下一章的内容了。
这里我想强调一点:解码后的坐标单位是像素,但YOLO内部用的是归一化坐标。如果你要保存结果到文件或传给其他系统,记得统一单位。我在项目中就遇到过因为单位不一致导致框的位置完全错位的情况——排查了半天才发现是归一化和反归一化搞反了。
3.6 常见问题与避坑
- 坐标格式混淆:YOLOv5输出的是归一化的cxcywh,但有些版本的OpenCV或绘图函数需要x1y1x2y2。转换时注意除法顺序,我见过有人把w/2写成了2/w,结果框全乱了。
- 置信度阈值选择:没有万能阈值。我在做行人检测时用0.3,做工业缺陷检测时用0.8。建议先跑一批数据,画出置信度分布直方图,再决定阈值。
- 类别索引偏移:COCO数据集的类别ID从0开始(person=0),但有些自定义数据集从1开始。解码后记得检查class_id的范围,避免索引越界。
- 批量处理:如果batch_size大于1,解码时要遍历每个batch。我习惯用向量化操作一次性处理,避免Python循环拖慢速度。
调试小技巧:解码后别急着做NMS。先随便选一个预测框,把它的坐标、置信度、类别打印出来,手动验证一下。比如图像宽640,预测框cx=0.5,那x中心点应该是320像素。这种快速验证能帮你尽早发现解码逻辑中的bug。