1. 小目标怎么就漏了:先把检测头这件事讲透
做目标检测时间久一点的人,大概都有过这种体验:模型在验证集上mAP看着还行,一放到实际场景里,那些远处的小车、监控画面里的行人、工业质检里的细小划痕、无人机视角下的光伏板热斑,要么框不出来,要么框得歪歪扭扭。更气人的是,你把置信度阈值往下调,它又能冒出来几个,但误检也跟着涨。这个现象背后,八成不是你的数据不够多,也不是训练轮数不够,而是检测头本身的分辨率不够用了。这篇就围绕 YOLOv5 的小目标检测头改造来讲,把为什么加、怎么加、加完怎么调、加完会不会掉点这几件事一次说清楚,适合已经跑通过 YOLOv5 训练、想在小目标上再抠一抠精度的人。
先把话说在前头:加检测头不是万能药,它本质上是拿计算量和推理速度去换小目标的召回。你得先判断自己手里的漏检到底是不是分辨率问题。我见过太多人一上来就改网络,结果真正的病根是标注框太小被过滤掉了、图片被过度缩放、或者anchor尺寸和自家数据完全对不上。所以在动手改结构之前,先做一件事——把漏检的图单独存出来看,量一下这些目标的像素尺寸。如果大量目标在原图里都小于32x32像素(COCO里 small object 的定义),那这篇内容对你就很有用;如果你的目标普遍是中等尺寸,加头带来的收益可能还不够抵掉速度损失。
1.1 检测头到底在干什么
很多人对"检测头"这个词是模糊的,觉得它就是个输出层。其实检测头做的事情可以拆成三件:第一,在特征图的每一个空间位置上,预测若干个先验框(anchor)的偏移量;第二,判断这个位置有没有目标、是什么类别;第三,把这些预测还原回原图坐标。YOLOv5 用的是一种叫解耦头之前的一体式设计,最近几个版本里 Detect 层会输出三个张量,分别对应 box 回归、objectness/类别、以及 DFL 的分布参数。
关键的约束在第一步和第二步之间的那个"空间位置"上。特征图有多大,模型就只能在多少个格子上做预测。拿 640x640 的输入举例,P3 层的特征图是 80x80,stride 是 8,意味着原图上每 8 个像素才有一个预测点。一个 16x16 像素的小目标,落在这张特征图上可能只占两个格子,模型要在这两个格子上同时完成定位和分类,本身就很勉强。说白了,检测头不是看不见小目标,是它"看"的密度不够,格子太粗,小目标在两个格子之间就被平均掉了。
1.2 YOLOv5三级头的能力边界
YOLOv5 默认挂三个检测头,分别接在 P3/8、P4/16、P5/32 上。stride 越小,特征图越细,对小目标越友好。P3 已经是三者中最细的了,stride 8,负责的最小 anchor 大概是 10x13 这种量级。问题在于,一旦你的目标普遍小于这个尺度,P3 也顶不住。
我做水果分拣那会儿就有切身体会:整幅图里果子挺大,但果柄上的小黑点、表面霉斑这种瑕疵,像素只有十几个,P3 层根本抓不住,模型只能靠纹理特征瞎猜,召回率惨不忍睹。后来实测下来,真正让这类小目标起死回生的,就是把检测密度再加一档——在 P2/4 上加第四个头,stride 变成 4,特征图从 80x80 变成 160x160,格子数量翻了四倍,小目标终于有多几个落点了。
但这里有个容易被忽略的代价:P2 层是浅层特征,感受野小,语义信息弱。它擅长的是"这是什么形状",不擅长"这是什么类别"。所以加 P2 头不是简单复制一层卷积就完事,还得靠 neck 把深层的语义信息往上传,否则小目标定位准了、分类却错了。这也是为什么改造的重点其实在 neck 的路径设计上,而不只是那一个 Detect。
1.3 什么场景值得加第四个头
不是所有项目都值得加。我一般用下面这张表快速判断,主要看目标像素分布和业务对召回的要求:
| 判断维度 | 建议加 P2 头 | 不建议加 P2 头 |
|---|---|---|
| 目标平均像素尺寸 | 小于 32x32 占多数 | 普遍大于 64x64 |
| 漏检代价 | 漏一个就出事故/丢订单 | 漏检可接受,抓大放小 |
| 推理算力 | 有 GPU 余量,能接受降速 | 端侧部署,帧率吃紧 |
| 图像分辨率 | 高分辨率大图,目标被摊薄 | 输入本身很小 |
| 数据集标注质量 | 小目标标注完整 | 小目标标注缺失严重 |
提示:如果你的小目标标注本身就漏标严重,加头只会把漏标的那些目标也检出来,反而拉低 precision。改造前先花半天时间抽查标注,这一步比改网络重要。
2. 加头之前,anchor与stride必须先算清楚
结构改错了还能重来,anchor 没算对就是白改。我见过最典型的翻车:辛辛苦苦把 P2 头加上去,anchor 直接照搬了 P3 的尺寸,结果新头预测的框全部偏大,训练 loss 震荡,最后还不如不加。为什么?因为 stride 从 8 变成了 4,同样的 anchor 在更细的特征图上对应的实际尺寸就小了一半,anchor 和真实框严重不匹配,正样本匹配质量一塌糊涂。
2.1 stride与特征图的对应关系复算
其实 stride 的计算特别简单,就是把输入尺寸除以特征图尺寸。但很多人从来没自己算过,直接抄配置,抄错了也不知道。我们把常见输入的对应关系列出来,方便你对照检查:
| 检测头 | 输入 640 时特征图 | 输入 1280 时特征图 | stride |
|---|---|---|---|
| P2 | 160 x 160 | 320 x 320 | 4 |
| P3 | 80 x 80 | 160 x 160 | 8 |
| P4 | 40 x 40 | 80 x 80 | 16 |
| P5 | 20 x 20 | 40 x 40 | 32 |
从表里能看出,输入翻倍,特征图翻倍,每个格子对应的原图像素不变,所以小目标在高分辨率输入下相对更"占格"。这也是为什么在小目标场景里,把 imgsz 从 640 提到 1024 或 1280,往往比改结构见效更快、风险更低。当然,代价是显存和推理时间成倍上升,这个后面细说。
2.2 新增P2头的anchor从哪来
P2 头负责的是最小的一档目标,anchor 自然也要跟着变小。YOLOv5 官方在 yolov5s-p2 这个配置里给出了参考值,P2 的 anchor 大概是 5x6、8x14、15x11 这种量级,三个 anchor 覆盖了比 P3 更小的尺度区间。你自己改的时候没必要硬抄,最好基于自家数据重新聚类,但官方值可以作为初始化的兜底。
这里要强调一个细节:anchor 是相对原图像素定义的,不是相对特征图。很多人搞混,把 5 写成了特征图上的 5 个格子,那实际对应原图就是 5 乘 stride 4 等于 20 像素,差了一个数量级。配 anchor 的时候脑子里要始终装着"这是原图尺度"。
2.3 用k-means重新聚类自家数据集
YOLOv5 自带 autoanchor 功能,训练时会自动检查 anchor 是否适配,不适配会重新聚类并打印建议。你也可以单独跑。核心逻辑就是把所有训练标注框的宽高读出来,用 k-means 聚成 9 或 12 类(几个头乘每头几个 anchor),然后用遗传算法做变异优化,最终输出一组 IoU 最优的 anchor。
聚类的时候有几个坑要注意。第一,标注特别小(比如宽高小于 2 像素)的框要过滤掉,它们本身就是噪声,会把聚类结果带偏。第二,如果你的数据里既有超小目标又有超大目标,k-means 会倾向于中间尺度,这时候可以手动把聚类后的 anchor 按尺度排序,把最小的几个分给 P2,最大的分给 P5。第三,改完 anchor 一定要重新初始化训练,不要在不匹配的 anchor 上接着训,那样前面学的全废。
3. 改模型配置:从yaml到Detect层的完整改造
真正动手改了,你会发现 YOLOv5 的结构配置全在一个 yaml 文件里,改起来其实比想象中清爽。但它的坑在于每层的索引都是隐式的,全靠列表顺序,一旦你在中间插一层,后面所有层的引用编号都得跟着改。这一节我把逐行改造的过程写清楚,照着抄基本不会错。
3.1 复制一份yolov5s-p2.yaml
不要直接改原文件,复制一份出来命名成 yolov5s-p2.yaml,放到 models 目录下。然后确认你的训练脚本可以通过 --cfg models/yolov5s-p2.yaml 指定它。这么做的好处是出问题了能随时切回原配置对比,也能做消融实验时随时切换,不用来回改文件。
3.2 backbone引出P2特征
标准 YOLOv5s 的 backbone 里,P2/4 其实是存在的,只是没被 neck 引出来用。你看前两层,第一个 Conv stride 2 得到 P1/2,第二个 Conv stride 2 得到 P2/4,紧接着才是 C3。要做的是在 P2/4 这层之后再加一个 C3 模块,把特征强化一下再引出去,否则浅层特征直接用效果不稳定。
官方 yolov5s-p2 的做法是在第二个 Conv 后面接一个 C3,形成 0-P1/2、1-P2/4、2-C3(新增)、3-P3/8 这样的结构。加这一层的理由是:浅层特征虽然分辨率高,但通道数少、感受野小,直接拿去 Concat 语义太弱,加个 C3 能既保持分辨率又提一点抽象能力,实测对小目标分类准确率有正向帮助。
3.3 neck的Concat索引逐行核对
这是最容易翻车的地方。因为 backbone 多了一层,后面所有层号都往后挪了一位,neck 里引用的 backbone 层号也要跟着改,否则 Concat 会对齐到错误的特征图,要么通道不匹配直接报错,要么尺寸对不上训练时崩。改完一定要打印模型结构确认。
下面是我实际用的 head 部分配置,Detect 挂在 21、24、27、30 四层上,分别对应 P2 到 P5:
head: [[-1, 1, Conv, [512, 1, 1]], # 10 [-1, 1, nn.Upsample, [None, 2, 'nearest']], [[-1, 6], 1, Concat, [1]], # 12 cat backbone P4 [-1, 3, C3, [512, False]], # 13 [-1, 1, Conv, [256, 1, 1]], # 14 [-1, 1, nn.Upsample, [None, 2, 'nearest']], [[-1, 4], 1, Concat, [1]], # 16 cat backbone P3 [-1, 3, C3, [256, False]], # 17 P3/8-small [-1, 1, Conv, [128, 1, 1]], # 18 [-1, 1, nn.Upsample, [None, 2, 'nearest']], [[-1, 2], 1, Concat, [1]], # 20 cat backbone P2 [-1, 3, C3, [128, False]], # 21 P2/4-xsmall [-1, 1, Conv, [128, 3, 2]], # 22 [[-1, 17], 1, Concat, [1]], # 23 cat head P3 [-1, 3, C3, [256, False]], # 24 P3/8-small [-1, 1, Conv, [256, 3, 2]], # 25 [[-1, 13], 1, Concat, [1]], # 26 cat head P4 [-1, 3, C3, [512, False]], # 27 P4/16-medium [-1, 1, Conv, [512, 3, 2]], # 28 [[-1, 9], 1, Concat, [1]], # 29 cat head P5 [-1, 3, C3, [1024, False]], # 30 P5/32-large [[21, 24, 27, 30], 1, Detect, [nc, anchors]], # Detect(P2,P3,P4,P5) ]注意看第 20 层的 Concat,它引用的是第 2 层(新增的 C3),而不是原来的第 1 层。如果你漏改了这里,模型要么报通道数不对,要么悄悄对齐到 P1/2 上,训练能跑但效果奇差。这种"能跑但不对"的问题最难查,所以改完 yaml 之后,用脚本把模型 summary 打印出来,逐层核对输出形状是不是 160、80、40、20 这个序列。
3.4 Detect层anchor列表与nc参数
Detect 层的参数里,nc 是类别数,anchors 是四组 anchor。加了 P2 之后 anchor 从三组变四组,每组三个,一共 12 个。官方配置里四组锚框按尺度从小到大排列,P2 那组是最小的。你要做的是把之前聚类得到的 12 个 anchor 按尺度升序填进去,保证最小的三个对应 P2。这里顺序填错,等于 anchor 和 stride 全错配,训练会非常不稳定。
3.5 验证模型结构是否搭对
改完别急着上数据跑,先用随机张量做一次前向。确认三件事:一是 Detect 输出确实是四组而不是三组;二是每组特征图尺寸符合 stride 关系;三是参数量和官方 p2 版本量级接近。参数量如果有明显偏差(比如多了几百万),说明某处通道数写错了,比如 C3 的通道或 Conv 的输入输出没对齐。
4. 训练调参:让小目标头真正起作用
结构改对只是拿到了入场券,训练策略跟不上,新头照样学不好。这里我踩过的坑最多,逐条讲。
4.1 显存与batch size的现实取舍
加了 P2 头,中间特征图最大到 160x160、通道 128 甚至更多,激活内存直接涨一大截。同样的输入尺寸和 batch size,显存占用可能涨 30% 到 50%。我的建议是先降 batch,别急着降输入尺寸,因为小目标本来就依赖分辨率。实在显存不够,可以用梯度累积模拟大 batch,或者开 amp 混合精度,省下来的显存比降分辨率划算。如果还是爆,再考虑把输入从 640 降到 512,但要做好精度回退的心理准备。
4.2 学习率和warmup的调整
新增的层是随机初始化的,和预训练权重不在一个"成熟度"上。直接拿原学习率开训,新层容易震荡甚至把整个网络带偏。我的做法是把 warmup 轮数适当延长,让新增层先慢慢追上来,同时可以考虑对 backbone 冻结几轮,只训 neck 和 head,等 loss 稳住了再解冻全量微调。这个策略在小数据集上尤其管用,能明显减少前几轮的 loss 尖峰。
4.3 数据增强对小目标的影响
这里要特别提醒一点:Mosaic 和 MixUp 这类增强,对小目标是把双刃剑。Mosaic 把四张图拼一起,实际上做了缩小,原本就不大的目标变得更小,如果 P2 头不够强,反而增加学习难度。但反过来,Mosaic 也让模型见到更多小目标的组合,泛化更好。我的经验是:加头初期可以先把 mosaic 的概率调低一点,等新头学得像样了再调回默认值。Copy-Paste 增强对小目标特别有效,把标注好的小目标裁剪出来贴到其他图上,能显著增加小目标样本数量,性价比很高。
4.4 损失权重与正样本匹配
YOLOv5 的损失里,box、cls、dfl 三部分的权重是可以调的。加 P2 头之后,小目标的正样本数变多了,如果 cls 权重不变,分类分支可能学得慢。我在具体项目里试过把 box 损失权重适当加大,配合标签分配的宽严参数调整,让小目标更容易被分到正样本。但要注意别调过头,不然小目标的框会过拟合,中大型目标开始漂。这块没有万能值,建议用消融实验一格一格试,记录每次的 mAP_small 变化。
注意:改损失权重比改结构更敏感,一次只动一个参数,动完至少跑够 50 个 epoch 再判断,不然你看到的只是随机波动。
5. 常见问题与排查实录
底下这些是我和朋友在实际项目里反复遇到的问题,整理成速查表,遇到先对号入座。
| 现象 | 大概率原因 | 处理方向 |
|---|---|---|
| 训练直接报通道数不匹配 | Concat 引用的层号没跟着改 | 核对 yaml 层索引 |
| 能跑但 mAP 奇低 | anchor 与 stride 错配 | 重新聚类 anchor |
| 新头完全不收敛 | 新层未进 warmup,学习率过大 | 延长 warmup,冻结 backbone |
| 小目标召回涨了但误检暴涨 | 正样本分配过松,置信度阈值低 | 收紧匹配,提高 conf |
| 训练 loss 正常,验证掉点 | 过拟合,小目标样本不足 | 加 copy-paste,增强正则 |
| 推理速度断崖式下降 | P2 头计算量大,输入又高 | 权衡输入尺寸与头数 |
5.1 训练报错速查
通道不匹配最常见,基本就是 yaml 索引问题,照着上面 3.3 逐行核对。另一个高频错误是 anchor 数量对不上,四组头必须 12 个 anchor,写成 9 个会直接报错。还有人把 nc 忘了改,改成自己的类别数后忘了同步 anchor,结果训练集类别数对但检测框乱飞。
5.2 加了头反而掉点怎么办
先别慌,这种情况多半不是结构问题,而是训练策略没跟上。我一般按这个顺序排查:先看是不是新头把整体 loss 带高了,导致老的头也学不好,如果是就冻结 backbone 再训;再看小目标相关的数据是不是本身就少,anchor 聚类是不是偏向了大目标;最后才怀疑结构。实测里,八成"加头掉点"都能靠重训和调 anchor 解决,真正需要回退结构的很少。
5.3 推理速度和部署侧的代价
加 P2 头,参数量大概会多出零点几 M,FLOPs 涨得更明显。在桌面 GPU 上可能只是每帧多一两毫秒,但在 Jetson 这类边缘设备上,可能就是能不能跑到实时的问题。部署到 TensorRT 的时候,P2 那层大特征图对显存带宽压力不小,如果帧率卡死,可以考虑只保留 P2 头做两阶段筛选,或者用输入缩放配合。我的建议是:上线前一定在目标硬件上实测帧率,别拿 3090 的成绩去估边缘设备。
6. 效果对比与消融:怎么判断这刀加得值
改造完总得有个说法。判断标准不能只看总 mAP,小目标的收益往往被大中目标的高分掩盖,要看 AP_small 这个分项。如果数据集没有 COCO 那种按尺度分档的评测,可以自己在验证集里按目标像素面积分桶,单独统计小目标桶的召回和精度。
6.1 指标怎么看
mAP@0.5 是最常用的,但对小目标不够敏感。我一般同时看三个数:整体 mAP、小目标子集 mAP、以及固定误检率下的召回。前两个看趋势,第三个看实际业务能不能用。很多项目最后卡的不是精度,是误检率,加头之后小目标召回涨了,但如果误检也涨,业务上未必接受,得根据场景权衡。
6.2 一组对比参考
下面是我在类似水果瑕疵检测任务上的经验数据,仅作趋势参考,你的绝对值肯定不一样:
| 配置 | 整体 mAP@0.5 | 小目标 mAP | 单帧耗时(相对) |
|---|---|---|---|
| yolov5s 原版 | 0.86 | 0.41 | 1.0x |
| +P2 头,未调 anchor | 0.84 | 0.44 | 1.28x |
| +P2 头,重聚类 anchor | 0.88 | 0.53 | 1.28x |
| +P2 头,anchor+增强调优 | 0.89 | 0.57 | 1.30x |
能看出来,光加头不调 anchor 甚至整体掉点,把 anchor 和增强补上之后小目标才真正起飞。这也印证了前面反复说的一句话:结构只是基础,配套调优才是收益来源。
6.3 什么情况下别加
如果你的目标本来就不小,或者部署硬件算力锁死,老老实实用输入分辨率提升或者更强的主干更划算。加 P2 头适合的是"小目标确实多、漏检代价大、算力有余量"这三个条件同时满足的场景。我个人的体会是,先试提高输入尺寸,再试数据增强,最后才动结构,这个顺序能帮你用最小代价拿到大部分收益。
最后分享一个小技巧:把加了 P2 头和没加 P2 头的两个模型在同一批困难样本上跑一遍,把两者预测框画到同一张图上对比。哪些目标只有新模型检出来了、哪些被检歪了,一眼就清楚。这种可视化比盯着 mAP 数字看有用得多,也能帮你判断下一步该调哪里。这个内容后续还可以往轻量化方向扩展,比如用深度可分离卷积替换 P2 头的标准卷积,在保持小目标精度的同时把速度损失压回去,感兴趣的话可以自己动手试试。