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内部用的是归一化坐标。如果你要保存结果到文件或传给其他系统,记得统一单位。我在项目中就遇到过因为单位不一致导致框的位置完全错位的情况——排查了半天才发现是归一化和反归一化搞反了。

YOLO输出解码流程 原始输出张量 [1, 84, 8400] 步骤1:维度转置 [1, 84, 8400] → [1, 8400, 84] 步骤2:坐标解码 cx, cy, w, h → x1, y1, x2, y2 反归一化 + 边界裁剪 步骤3:类别提取 argmax 获取最佳类别 max 获取置信度分数 步骤4:置信度阈值过滤 保留 scores > threshold 的预测框 解码结果 boxes + scores + class_ids 输入说明 batch=1, 类别数=80 预测框总数=8400 输出说明 boxes: 像素坐标 x1y1x2y2 scores: 0~1 置信度分数 class_ids: 类别索引 (0~79)

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。