简介:面向目标检测初学者和算法研究者的YOLO系列专题解读文档,围绕YOLOv1至YOLOv5的完整演进脉络,系统梳理算法核心原理、网络设计与实现细节。包内含1个docx文档,约4MB,以文字、公式结合代码注解形式呈现,适合作为论文精读、源码复现与面试准备的伴读笔记。文档从YOLOv1的S×S网格划分、各网格预测B个边界框的置信度定义、xywh加置信度的五元组含义讲起,逐步拆解损失函数中坐标误差与分类误差的权重处理,并对比Fast YOLO的轻量卷积结构,概述YOLOv3、YOLOv5在卷积层和特征融合上的改进思路及其对检测速度和精度的影响。实现层面,文档对predict_boxes输出坐标的真实含义做了专门辨析,说明中心点坐标是相对所属网格左上角的偏移、宽高是归一化后的开方,帮助读者避开最常见的源码理解误区。已有2735人学习/下载,读者可借助训练参数记录、网络结构对比和易错点提示,快速打通从论文原理到工程实现的理解链路。
1. 从 45fps 说起:YOLO v1 ~ YOLO v5 论文解读这条路,能省下几星期的调参时间
某个项目里,视频流小目标检测让我反复翻车:模型在测试集上 mAP 不低,一上真实视频就漏框,调了一周数据增强没有任何起色。后来把 YOLO v1 ~ YOLO v5 的论文和实现细节重新读了一遍,才明白这类问题并不玄学,它早就在 v1 的 grid 设计、v2 的 anchor 引入和 v3 的多尺度 head 里埋好了。接下来的内容按这条时间线,把每代模型到底改了什么、哪些细节能在开源代码里对上号,以及新手最容易在哪些地方踩坑,一条条拆开。读完你会清楚一件事:现在的 YOLO 行为不是某个版本凭空造出来的,而是四代设计决策叠出来的结果;想改好自己手里的模型,必须回到这些决策本身,而不是在训练命令上瞎试。
2. YOLO v1 的损失函数实现:grid、bbox 置信度和三组系数怎样排布
2.1 S×S grid 结构:一次把检测拉回回归问题的大胆设计
目标检测最直接的做法是滑动窗口加分类器,速度慢,窗口怎么滑全靠经验。YOLO v1 走的是另一条路:把整张图切分成 S×S 个 grid cell,每个 cell 同时输出 B 个候选框、一个物体置信度、还有 C 个类别概率。这等于告诉网络,检测就是一次从图像到张量的回归,前向一遍就把框直接吐出来,不需要区域提议也没有二次分类。
当时它在 Pascal VOC 上能跑出 45 fps,比同期的两阶段方法实时性高一个量级,代价是位置精度比较粗糙。理解 v1 只需要抓住一条主线:它用整个 grid 结构换走了后续 anchor、多尺度特征的设计基础。没有 v1 的 S×S grid,就没有 v2 在网格上放 anchor 这件事。
输出张量的排布是这样的:
| 维度 | 含义 |
|---|---|
| S × S | 把输入图划分成的网格数,经典配置 S=7 |
| B | 每个格子负责预测几个边框,经典配置 B=2 |
| 5 | 每个边框包含 cx、cy、w、h、confidence 五个量 |
| C | 类别数,Pascal VOC 上 C=20 |
网络输出形状是 S×S×(B×5+C),前向结束后你拿到的不再是候选框列表,而是一整张稠密特征。训练时要做的就是把每个 GT 框分配到对应 grid cell,再转成同样形状的 target 张量去监督。
2.2 损失函数三组系数:为什么 coord=5、noobj=0.5、宽高要先开根号
v1 损失可以拆成五个求和项:中心点坐标损失、宽高开根号后的误差、有物体的置信度损失、没有物体的置信度损失、类别损失。论文里给了两个容易被人忽略的系数,一个是坐标损失前面乘的 λ_coord=5,一个是无物体置信度损失前面的 λ_noobj=0.5。
为什么要这样调?因为一张图大部分网格里根本没有物体,如果无物体部分的置信度损失权重和正样本一样,网络最优策略就是让所有格子置信度都预测为 0,反正负样本多到足以淹没正样本的信号。这样模型训练早期就不会去尝试回忆目标,训练会变得非常慢。λ_noobj=0.5 就是在告诉网络:没有物体的格子你也可以犯错,但代价只有有物体格子的一半。
坐标损失乘 5 则是相反的原因:一张图里真正有物体的格子太少,框回归的误差在总损失里占不到应有的分量,如果不放大,网络会优先把类别学对而忽略框的位置。宽高开根号处理是 v1 损失里一个很有意思的细节,它让大框和小框的误差落在同一个尺度上。同样偏离 10 个像素,对 50 像素宽的小框和 500 像素宽的大框,语义完全不同;先开根号再求误差,小框的位置偏差会被放大,大框的则被压缩,相当于一种手工设计的尺度均衡。
2.3 在 PyTorch 里把 v1 损失写成可跑代码
理解完公式,最好的验证方式就是自己写一次。我一般会这样落地:
import torch import torch.nn as nn class YOLOv1Loss(nn.Module): """YOLO v1 原始损失,S=7、B=2、C=20 是论文里的经典配置""" def __init__(self, S=7, B=2, num_classes=20): super().__init__() self.S = S self.B = B self.C = num_classes self.lambda_coord = 5.0 self.lambda_noobj = 0.5 def forward(self, pred, target): # pred: (N, S, S, B*5 + C) # target: (N, S, S, B*5 + C) 数据准备阶段已按同样格式排好 N = pred.size(0) pred = pred.view(N, self.S, self.S, self.B, 5 + self.C) target = target.view(N, self.S, self.S, self.B, 5 + self.C) # 有目标的 box 槽位,target[..., 4] 在严格实现里应填入 IoU box_mask = (target[..., 4:5] > 0.5).float() # 分类损失按格算:一个格子任意一个 box 有目标,就要算类别误差 cell_mask = box_mask.max(dim=-2, keepdim=True).values # 中心点坐标误差 xy_loss = self.lambda_coord * box_mask * ( (pred[..., 0:2] - target[..., 0:2]) ** 2 ).sum(dim=-1, keepdim=True) # 宽高先开根号,再算平方误差 sqrt_pred = torch.sqrt(pred[..., 2:4].clamp(min=1e-6)) sqrt_target = torch.sqrt(target[..., 2:4].clamp(min=1e-6)) wh_loss = self.lambda_coord * box_mask * ( (sqrt_pred - sqrt_target) ** 2 ).sum(dim=-1, keepdim=True) # 有目标格子的置信度损失 conf_loss_obj = box_mask * ( (pred[..., 4:5] - target[..., 4:5]) ** 2 ) # 无目标格子的置信度损失,乘 0.5 conf_loss_noobj = self.lambda_noobj * (1 - box_mask) * ( pred[..., 4:5] ** 2 ) # 类别损失,用平方误差实现分类学习 cls_loss = cell_mask * ( (pred[..., 5:] - target[..., 5:]) ** 2 ).sum(dim=-1, keepdim=True) total = ( xy_loss + wh_loss + conf_loss_obj + conf_loss_noobj + cls_loss ).sum() / N return total这里的核心技巧是 mask 匹配。box_mask标记出哪些 box 槽位真正负责物体,cell_mask则把有物体的格子整体标记出来,类别损失只算一次,避免 B 个槽位重复计算把分类信号放大。严格按论文实现时,target[..., 4]要先算出网络预测框和真实框的 IoU 再回填,我上面的代码用 1 简化了,训练时要换成 IoU 值。
代码里 λ_coord 和 λ_noobj 就对应 2.2 里讲的两组系数。你如果自己复现 v1 训练,会发现这个损失在训练初期非常不稳定,原因是正样本太少、负样本太多,即使做了 noobj 降权,背景梯度仍然占主导。这也是后来 v2/v3 改用 anchor 和独立逻辑回归的重要原因。
3. YOLO v2 和 v3 的锚框与多尺度 head:v3 推理脚本一次讲清
3.1 v2 带来的两个关键改动:BatchNorm 和直接位置预测
v2 论文里信息量很大,但对后续实现影响最深的是两件事:一是把 BatchNorm 加到卷积之后,让训练收敛稳定了很多;二是不再让网络直接回归全局坐标,而是引入 anchor box 作为先验,网络只预测相对 anchor 的偏移量。
直接回归全局 cx、cy 有个问题:不同尺寸物体在坐标空间的量纲差距太大,网络学起来很难稳定。v2 改成先在每个 grid cell 上预置 k 个不同宽高的 anchor,然后让网络输出四个偏移量 tx、ty、tw、th。中心点偏移经过 sigmoid 后加上网格坐标,宽高则用 exp 放大 anchor 的基础尺寸。
| 维度 | v1 | v2 之后 |
|---|---|---|
| 位置回归 | 直接输出归一化 cx、cy | 预测基于 anchor 的偏移量 |
| 中心点范围 | 全局 0~1 | sigmoid 限制在所在格内 |
| 每个格子的框数 | 固定 B=2 | 用聚类得到的 k 个 anchor |
| 输入尺寸 | 448 | v2 用 416,v3 常用 608 |
这段演进的直接结果是:anchor 尺寸选择开始影响模型性能。v2 里 k 值和小尺寸由聚类决定,而不是人拍脑袋。后面所有 YOLO 版本都保留了这个机制,区别只在于 anchor 怎么分配到不同尺度特征图。
3.2 v3 的三个尺度:13×13、26×26、52×52 各管什么
v3 论文做的最大结构调整,是把特征金字塔思想引入 YOLO。主干网络从不同深度抽出三张特征图,小特征图语义强、分辨率低,负责大目标;大特征图分辨率高、细节多,负责小目标。每个尺度上都配了三组 anchor,总共九组。
以常见的输入尺寸和 COCO 类别配置来看,anchor 大致是这样分配的:
| 输出尺度 | 下采样倍数 | anchor 尺寸 | 目标类型 |
|---|---|---|---|
| 13×13 | 32 | (116,90)、(156,198)、(373,326) | 大目标 |
| 26×26 | 16 | (30,61)、(62,45)、(59,119) | 中目标 |
| 52×52 | 8 | (10,13)、(16,30)、(33,23) | 小目标 |
每个尺度预测的物体数是格子数乘 3。输入 416 时,总输出框数为 13×13×3 + 26×26×3 + 52×52×3 = 10647 个候选框,远远多于 v1 的 98 个。这也是为什么要做 NMS:网络天生输出大量冗余框,最后一步必须把重叠框合并掉。
3.3 一个能跑通的最小 v3 推理代码:从特征图到图像坐标
v3 的推理和训练坐标解码是同一个函数,理解它比理解训练更容易。下面这段代码把单个尺度的网络输出还原成图像坐标系下的 bbox:
import torch import torchvision.ops def decode_yolov3(feature, anchors, img_size=640): """ feature: head 输出 (batch, (5 + num_classes) * 3, H, W) anchors: 该头对应的三组 [w, h],单位是输入像素 """ batch, _, height, width = feature.shape num_anchors = len(anchors) # 先拆成 (batch, anchors, 5+C, H, W),再换到最后两维 feature = feature.view(batch, num_anchors, 5 + 80, height, width) feature = feature.permute(0, 1, 3, 4, 2).contiguous() # 论文里的解码公式:中心点过 sigmoid,宽高做 exp xy = torch.sigmoid(feature[..., 0:2]) wh = torch.exp(feature[..., 2:4]) * torch.tensor( anchors, device=feature.device ).view(1, num_anchors, 1, 1, 2) conf = torch.sigmoid(feature[..., 4:5]) cls = torch.sigmoid(feature[..., 5:]) # 建立网格坐标索引 grid_y, grid_x = torch.meshgrid( torch.arange(height, device=feature.device), torch.arange(width, device=feature.device), indexing="ij", ) # 中心点 = 网格坐标 + 偏移,再还原到输入图像尺寸 cx = (xy[..., 0] + grid_x.view(1, 1, height, width)) / width * img_size cy = (xy[..., 1] + grid_y.view(1, 1, height, width)) / height * img_size w = wh[..., 0] * img_size h = wh[..., 1] * img_size # 转成 x1, y1, x2, y2 boxes = torch.stack( [cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], dim=-1 ) return boxes, conf, cls三个尺度的输出最后要拼在一张表里:
all_boxes, all_scores = [], [] for feature, anchors in zip(features, anchors_list): boxes, conf, cls = decode_yolov3(feature, anchors, img_size=640) boxes = boxes.view(-1, 4) cls = cls.view(-1, 80) conf = conf.view(-1) class_scores, class_ids = cls.max(dim=-1) scores = conf.squeeze(-1) * class_scores all_boxes.append(boxes) all_scores.append(scores) all_boxes = torch.cat(all_boxes) all_scores = torch.cat(all_scores) mask = all_scores > 0.25 keep = torchvision.ops.nms(all_boxes[mask], all_scores[mask], iou_threshold=0.5)这里最容易出错的是把conf和cls分开用。最终用于阈值筛选的分数是物体置信度乘以类别概率,不能只看类别概率,否则空白背景上也会输出一类伪框。NMS 的 iou_threshold 一般取 0.4~0.5,太低会压制相邻目标的框,太高则输出大量重叠框。
4. YOLOv4 的 Bag-of-Freebies 和 YOLOv5 的工程化调参:从结构到配置 YAML
4.1 v4 在结构上补了哪些:CSPDarknet53、SPP、PANet 与 Mosaic
v4 论文用一个词概括很准确:Bag-of-Freebies,也就是不改变模型主干,只通过在训练集变换、正则化、损失函数上做加法来提升精度。真正落在结构上的改动并不大,但每处都对训练稳定性有益。
CSPDarknet53 是把基础残差块拆成两个分支,一半走深路径,一半走浅路径,最后拼接,好处是减少重复梯度计算,降低显存占用。SPP 则是在某个特征层后做多个不同尺寸的池化再拼接,扩大感受野,让模型能看到更广的上下文。PANet 的作用是把高层语义信息向下融合的同时,把浅层位置信息再往上传,相当于双向特征融合。
训练层面的 Mosaic 增强是 v4 最出名的工程 trick。它把四张图拼成一张图输入,让模型在一个 batch 里看到更多不同尺度的物体,也实际上提升了小目标训练样本的数量。要求不高时,只开 Mosaic 就能观察到小目标召回改善。
4.2 YOLOv5 的模型配置 YAML:depth_multiple 和 width_multiple 怎样决定模型大小
v5 经常被说成没有正式论文,严格讲它确实更像一个工程实现库,而不是新算法论文。所谓“论文解读”落到实际,更多是读它的模型配置和训练策略。模型配置的核心是一份 YAML,里面用数字控制网络深度和宽度:
# 一个典型的 v5 风格模型配置 nc: 80 depth_multiple: 0.33 # 模块内部重复层数的缩放系数 width_multiple: 0.50 # 每层输出通道的缩放系数 anchors: - [10,13, 16,30, 33,23] # P3 小目标 - [30,61, 62,45, 59,119] # P4 中目标 - [116,90, 156,198, 373,326] # P5 大目标 backbone: # from, number, module, args - [-1, 1, Conv, [64, 6, 2, 2]] - [-1, 1, Conv, [128, 3, 2]] - [-1, 3, C3, [128]]depth_multiple 控制每个 C3 模块里重复残差结构的数量,width_multiple 控制每一层卷积的输出通道数。通道数被缩小后,特征图的宽度和计算量会同时下降。不同规模模型基本就是这几组系数的组合:
| 模型规模 | depth_multiple | width_multiple | 相对计算量 |
|---|---|---|---|
| n/s 小模型 | 0.33 | 0.50 | 低 |
| m 中模型 | 0.67 | 0.75 | 中 |
| l 大模型 | 1.0 | 1.0 | 高 |
| x 超大模型 | 1.33 | 1.25 | 最高 |
这份 YAML 里的 anchors 顺序不能随便改,它必须和 backbone 输出的三个特征尺度对应。如果把 P3 的锚框误放在 P5 层,训练时小目标会被强行匹配到低分辨率特征图,召回直接崩盘。训练脚本一般会要求 anchor 按尺度从细到粗排列,顺序错了很难通过报错发现。
4.3 把 v5 训练起来的最小命令:数据配置、batch、学习率和 anchors 自适应
准备数据时,数据集 YAML 至少要包含训练集路径、验证集路径、类别数和类别名。常见约定是:
train: ./datasets/mydata/images/train val: ./datasets/mydata/images/val nc: 3 names: ['person', 'car', 'cat']训练命令保持最小可跑的状态,我一般会这样写:
python train.py \ --data datasets/mydata.yaml \ --weights yolov5s.pt \ --img 640 \ --batch-size 16 \ --epochs 100 \ --device 0batch size 和分辨率是最关键的两个参数。显存不够时优先降 batch size,不要轻易把 640 改成 320,因为训练分辨率会直接影响 anchor 匹配和目标尺度。学习率一般由脚本自动从预热阶段开始,如果 loss 在训练后期明显震荡,通常是 batch size 变大后学习率没有跟着调。
v5 系模型通常带有 autoanchor 机制:训练前根据当前数据集重新聚类 anchor 尺寸,再输出一组更匹配的锚框。换数据集时不要直接跳过这一步,尤其是目标尺度跟预设数据集差别大的场景。如果训练日志里出现 “anchors: 0.0” 之类异常,多半是数据集中目标框没有被正确读取,先修数据而不是去调网络参数。
5. 从 v1 到 v5 的常见问题:五个案例对照排查,让你少踩两个星期坑
5.1 现象:加载预训练权重后模型直接报维度错误
原因很直接:类别数对不上。v5 的检测头输出通道由类别数决定,80 类权重加载到 3 类数据集时,head 层权重形状必然不一致,有时报错在 backbone 最后几层。解决方式是训练脚本里分开加载:backbone 复用预训练权重,head 重新初始化。
现象:RuntimeError: size mismatch for model.24... 原因:预训练权重中的 255 通道输出和自定义数据集的 3×(5+3) 不匹配 解决:把 --weights 换成不带 head 的版本,或者在加载时过滤 head 层这个坑对任何从 v1 之后带 anchor 的版本都适用。v1 因为直接输出分类概率,类别数变化时同样会炸,所以换数据集第一件事就是确认类别数是否同步到配置、数据 YAML 和权重三处。
5.2 现象:模型能检测到大目标,小目标几乎全漏
常见的尴尬局面是 mAP 看起来还行,但实际视频里小目标一个都抓不到。先别怀疑增强策略,去看 anchor。预训练模型自带的九组锚框是按 COCO 尺度聚类的,如果项目里监控摄像头画面中目标普遍很小,COCO 锚框的最小一组可能都比目标大好几倍,匹配阶段直接失效。
解决方法是打开 autoanchor 或手动用聚类脚本重算锚框。聚类不是跑一遍就结束,要看每个尺度上聚出的宽高是否落在对应特征图的感受野范围内。小目标集中时,聚类结果通常会把 P3 层的最小锚框压到几个像素级别,比如从 (10,13) 变成 (4,6),这是正常现象。
5.3 现象:训练用 416、推理用 640,小目标漏检反而更严重
输入分辨率变大,理论上小目标应该更容易被看见,但实际可能更糟。原因是训练时锚框是按 416 下的目标尺度匹配的,模型已经学会了在该尺度下的目标分布;推理喂进 640 后,目标在图像里的像素大小变了,anchor 匹配逻辑却没有重新适应,导致部分小目标落到错误的特征层级。
解决方式有两种:一是训练时开启多尺度训练,让 batch 的输入在 320~640 之间随机抖动,模型不会过度依赖单一尺度;二是推理前把 anchors 按输入尺寸做等比缩放。v5 的推理脚本通常会自动处理锚框缩放,但如果是自己写的自定义推理,要确认这一步没丢。
5.4 现象:置信度调到 0.25 还是输出几十个重叠框
这通常不是模型问题,而是 NMS 参数和候选框生成策略不匹配。YOLO 输出上万候选框,全靠最后一步 NMS 把重叠框合并。如果把置信度阈值压得很低,比如 0.001,NMS 的输入框数量暴增,容易超过默认算法的处理上限,结果就是大量低质量框保留下来。
解决方式是先按类做 NMS,再做跨类去除。同一目标被多个类同时激活时,跨类 NMS 能去掉重复框。iou_threshold 也要按任务调,密集人群场景可以放宽到 0.6,稀疏的大目标场景用 0.4 更干净。v1 时代的 NMS 其实没有这些区分,因为候选框少得多,这正是后来模型候选框变多带来的新坑。
5.5 现象:loss 下降很快,但验证集框的位置乱跳
loss 下降不一定代表模型学好了,特别是自己改损失函数时很容易出现这种假象。把 v1 的 λ_coord 直接搬进 v5 训练,坐标损失可能会被放大几倍,模型为了压 loss 会把框缩成很小的区域,因为这种预测能稳定拿到低误差。
解决方式是先区分“训练 loss 下降”和“验证指标上升”是两件事。检查训练曲线里的 box_loss、cls_loss、obj_loss 三个分量,如果 box_loss 出现锯齿状抖动,优先恢复默认权重,再去调整 IoU loss 的 alpha 参数。调任何损失权重前,先跑一次默认配置作为基线,没有基线的调参都是玄学。
6. 验证技巧:用 v1 的召回率判断法拆开看分类、定位和置信度
读 v1 到 v5 最大的收获不是记住某个公式,而是学会一套诊断模型的方法。v1 时代大家最看重的是在低置信度下模型还记得多少目标,而不是只看最终 mAP。现在的评测体系里,召回率被 mAP 盖住了,导致很多人只调阈值不查根因。
我拿到一个新模型会先做这样一件事:把置信度阈值分别设成 0.001、0.1、0.25、0.5,逐步观察检测结果和真实框的匹配数量:
for thr in [0.001, 0.1, 0.25, 0.5]: detections = detector(images, conf_thres=thr) recall = compute_recall(detections, gt_boxes) precision = compute_precision(detections, gt_boxes) print(f"{thr:.3f} recall={recall:.3f} precision={precision:.3f}")0.001 下的召回率可以近似理解为模型“最多能想起多少目标”,反映的是特征表达和 anchor 匹配的极限。如果 0.001 下召回也只有 60%,那问题不在阈值或 NMS,而在训练数据、锚框或模型容量;如果 0.001 召回很高但 0.25 下掉得厉害,说明模型对目标置信度本身就不自信,要回去看类别不平衡或数据标注噪声。
另外一个实用的习惯是分目标尺寸看指标。小目标对位置误差极其敏感,单个像素的标注偏移都会放大到好几个 box 面积的比例。v1 的宽高开根号处理已经暗示了这一点,后来的版本即使换了损失函数,小目标的定位问题也从来没有消失。所以验证时建议把真实目标按面积分成大、中、小三档,分别统计 recall@0.5 和 recall@0.75,而不是只报一个整体 mAP。
我现在拿到任何 YOLO 系列模型,测试的顺序永远是:先跑最低阈值召回,再按目标尺寸分组统计,最后才调 NMS 和置信度阈值。这个顺序帮我避开了很多假装调参、实际无效的坑,也是读 v1~v5 论文留下来的最有用的习惯,希望帮到你。
本文还有配套的精品资源,点击获取