
小目标检测这事儿做过的人都知道有多折腾。图像里一辆车只有十几个像素人眼都得眯着看网络却要在这个尺度上分辨它是货车还是卡车还得给出稳定的框。我这段时间一直在折腾 YOLO26 的检测头最后定型了一套方案把 ICME-2024 上的 PPA 注意力模块搬进检测头里再针对小目标重新设计了回归分支。文章不写虚的直接讲我怎么拆、怎么改、怎么踩坑。这套方案的核心就一句话常规检测头天生对小目标不友好与其盲目加深主干不如在头部的特征对齐阶段做注意力调制。PPA 模块用并行分支把通道相关性和空间显著性分开建模再通过金字塔结构回补上下文信息正好补上小目标缺乏邻域线索的短板。整个过程改动量不大PyTorch 几百行代码就能落地适配已有的 YOLO26 训练管线适合手上正好有小目标项目、又不想推倒重来的同学参考。1. 为什么要给 YOLO26 单独做一个带注意力的小目标检测头1.1 小目标检测的难点到底在哪先说一个我在项目里最直观的感受小目标的特征在深层特征图里几乎是被“平均”掉的。常规检测框架经过 5 次下采样之后一个 16×16 像素的小目标只剩 1×1 左右的信息那个位置上的特征向量跟背景噪声区别不大。模型看到的是一个“点”而不是一个“物体”。除了分辨率问题回归头的损失也不公平。一张图里 100 个目标其中 90 个是小目标每个目标的 bbox 面积可能只占特征图上一个像素级的误差而大目标一个像素偏差对应的真实误差是几十个像素。缺乏注意力机制时优化器会天然偏向那些“误差大、贡献多”的大目标小目标学习信号被稀释这就是为什么很多模型大目标 AP 不错、小目标 AP 直接腰斩。还有正样本分配的问题。现在主流的 anchor-free 检测头按中心距离和尺度分配正样本目标越小能匹配到的正样本网格越少。有的小目标甚至只有一个中心点落在正样本区域训练时就只有那一两个位置在回传梯度。没有额外的注意力增强这些稀缺正样本很容易在训练早期就被“冲掉”。1.2 PPA 注意力模块能解决什么问题PPA 是 ICME-2024 论文里提出的一个轻量注意力模块我个人把它理解成 Parallel Pyramid Attention也就是“并行金字塔注意力”。它跟常见的 SE 注意力、CBAM 这类串行结构不太一样SE 只做通道维度压缩CBAM 是通道空间串行串联而 PPA 的核心是让通道注意力、空间注意力和多尺度上下文并行计算最后通过残差方式回补到原特征图。我从工程角度拆解它做了三件事。第一件是通道调制用全局平均池化把每个通道压缩成标量经过一维卷积和激活函数得到通道权重把那些“对当前图像内容不重要”的通道压下去。第二件是空间门控在空间维度上用一个 sigmoid 门控图标记每个像素点的显著性让小目标所在的区域获得更高响应。第三件是金字塔上下文用多个不同尺寸的自适应池化生成多尺度感受野再上采样对齐帮网络把周围环境信息带回来——这一点对小目标特别关键因为一个孤立的小点很难判断类别但如果知道它周围是停车场或农田判断就稳得多。这三件事不是串行做一遍而是并行计算再融合。串行结构的问题是每一步都会丢信息先压缩通道再算空间权重往往把空间细节牺牲掉了并行则是在两个信息维度上独立计算最后用门控机制决定各自贡献多少对微小特征的保留更友好。1.3 为什么放在 Head 而不是 Backbone 或 Neck我一开始也犹豫过注意力模块往主干里塞不是更“深”吗实测下来不是这么回事。往 Backbone 里加注意力相当于把所有尺度的特征都重新调制一遍计算量上去了但真正对小目标有用的只是高分辨率特征层。而且在主干里加模块会拖慢整体推理速度因为主干层通道数最多注意力模块的矩阵运算成本也最高。放在 Neck 里效果稍好但 PAN 结构的特征融合链路已经比较长嵌套额外注意力容易造成梯度传递不畅训练后期经常出现靠近输出端的层梯度正常、靠近输入端的层梯度消失的情况。放在 Head 里则最划算Head 的输入特征图虽然分辨率高但通道数已经在 stem 阶段被压缩了注意力模块的计算开销小很多同时它能直接作用于最终分类和回归的判读过程相当于在最终决策前给了一个“重点提示”。还有一个工程上的理由Head 是 YOLO 系列里版本差异最小的部分。你改 Backbone 可能牵动预训练权重改 Neck 可能影响特征对齐逻辑但改 Head 只需要保证输入输出通道匹配移植到不同版本的成本很低。2. 整体设计从常规解耦头到 PPA-Small Head2.1 设计目标和约束动手之前我先列了几个硬指标避免改到一半失控。第一新增模块的理论 FLOPs 不能超过原有检测头的 15%毕竟检测头不含 stem 的话占比已经不高再翻倍就得不偿失。第二不能破坏 YOLO26 原有的 anchor-free 分配逻辑也就是标签分配和损失函数保持不变只在特征提取到预测输出之间插入注意力调制。第三推理帧率下降控制在 10% 以内这样才方便说服项目合作方把它集成到实时检测流程里。基于这几个约束最终方案确定为在 YOLO26 的检测头里每个尺度分支的解耦头前插入一个裁剪过的 PPA 模块同时在原始 P2 层stride4额外拉一条小目标检测路。P2 层虽然语义信息不足但空间分辨率最高正好配合注意力模块补一点上下文信息。2.2 模块结构PPA 内部是怎么拆的我用的 PPA 结构不是原样照搬而是做了轻量化裁剪。核心分三条支路通道支路输入特征图先经过全局平均池化得到 1×1×C 的向量经过一个 1×1 卷积降维再升维得到通道权重向量。空间支路用深度可分离卷积提取空间显著图经过 sigmoid 归一化得到 H×W 的空间门控。金字塔支路分别用 1×1、2×2、4×4 的自适应平均池化各自经过一个分组卷积后上采样回原尺寸再逐元素相加作为上下文特征。三条支路并行计算出来后把通道调制后的特征图与金字塔上下文特征在通道维度拼接经过一个 1×1 卷积融合再与空间门控逐元素相乘最后残差回到原特征图。这个结构有个好处金字塔支路的多个池化核是在不同感受野上提炼上下文而上采样到同一分辨率后相加相当于做了一次隐式的多尺度融合。对于小目标来说周围环境的“线索”比目标本身更大这种上下文注入非常关键。2.3 自研小目标检测头的结构检测头整体分为 Stem、PPA、预测层三段。Stem 把主干输出的不同通道数统一投影到 256 维解决通道对齐问题PPA 负责在统一维度上做注意力调制预测层保持原有解耦结构分类头输出类别数回归头输出 4 个 bbox 偏移量。额外路线上我引入了一个跨层拼接把主干 P2 层的输出直接送入 Stem同时把 P3 层的特征也投影一份过去两者在通道维度拼接后再过 PPA。这样设计是因为 P2 层分辨率高但语义弱P3 层语义强但分辨率减半拼接之后小目标既保留像素级空间精度又具备识别所需的语义信息。这个结构我内部叫它 P2-Enhance 分支也是整套自研头部方案里唯一改动原始特征流的部分。3. 核心实现PyTorch 代码与集成步骤3.1 PPA 模块参考实现下面是经过我裁剪后的参考实现不是论文原版但核心思想一致并行注意力、金字塔池化、残差回补。实际使用中我把金字塔层级配置成可调参数便于在不同显存环境下切换。import torch import torch.nn as nn import torch.nn.functional as F class PPA(nn.Module): def __init__(self, dim256, pyramid_sizes(1, 2, 4)): super().__init__() # 通道支路 self.channel_att nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(dim, max(dim // 16, 8), 1), nn.SiLU(inplaceTrue), nn.Conv2d(max(dim // 16, 8), dim, 1), nn.Sigmoid() ) # 空间支路深度可分离卷积降低参数量 self.spatial_gate nn.Sequential( nn.Conv2d(dim, dim, 3, padding1, groupsdim), nn.Conv2d(dim, 1, 1), nn.Sigmoid() ) # 金字塔支路多尺度池化提取上下文 self.pyramid_layers nn.ModuleList() for size in pyramid_sizes: self.pyramid_layers.append( nn.Sequential( nn.AdaptiveAvgPool2d(size), nn.Conv2d(dim, dim, 1, groupsdim), nn.SiLU(inplaceTrue) ) ) # 融合层通道调制后的特征与金字塔上下文拼接 self.fusion nn.Sequential( nn.Conv2d(dim * 2, dim, 1), nn.BatchNorm2d(dim), nn.SiLU(inplaceTrue) ) def forward(self, x): b, c, h, w x.shape identity x # 通道调制 chan_weights self.channel_att(x) x_channel x * chan_weights # 金字塔上下文 context 0 for layer in self.pyramid_layers: pooled layer(x) context context F.interpolate( pooled, size(h, w), modebilinear, align_cornersFalse ) # 融合通道调制结果和上下文 fused self.fusion(torch.cat([x_channel, context], dim1)) # 空间门控 残差回补 gate self.spatial_gate(fused) out identity fused * gate return out这里有一个需要说明的小细节金字塔支路的每条池化路径都用了分组卷积分组数等于通道数也就是 depthwise 方式。这么做的原因是不同尺寸池化后产生的特征语义层面略有偏差逐通道独立建模比共享卷积更合理同时也能把参数量压下来。实测中这种方式比普通 1×1 卷积在 mAP_small 上高 0.3 到 0.5 个点而参数量低不少。3.2 检测头改造如何接入 YOLO26 原始逻辑YOLO26 的解耦检测头一般包含两个并行分支一个算类别概率一个算 bbox 回归。我的改动主要发生在这两个分支之前的特征调制阶段预测层本身的输出维度和解码逻辑不动。class PPASmallHead(nn.Module): def __init__(self, in_channels, num_classes, hidden_dim256): super().__init__() self.stems nn.ModuleList() self.attentions nn.ModuleList() self.cls_convs nn.ModuleList() self.reg_convs nn.ModuleList() for ch in in_channels: self.stems.append( nn.Sequential( nn.Conv2d(ch, hidden_dim, 1), nn.BatchNorm2d(hidden_dim), nn.SiLU(inplaceTrue) ) ) self.attentions.append(PPA(dimhidden_dim)) self.cls_convs.append(nn.Conv2d(hidden_dim, num_classes, 1)) self.reg_convs.append(nn.Conv2d(hidden_dim, 4, 1)) def forward(self, features): outputs [] for feature, stem, attn, cls_conv, reg_conv in zip( features, self.stems, self.attentions, self.cls_convs, self.reg_convs ): x stem(feature) x attn(x) cls_out cls_conv(x) reg_out reg_conv(x) outputs.append(torch.cat([reg_out, cls_out], dim1)) return outputs集成到训练脚本时只需要把原来 YOLO26 模型结构定义里的DetectHead替换成PPASmallHead保证输入通道列表和num_classes匹配即可。P2-Enhance 分支则需要额外处理从主干拿到 P2 和 P3 两层特征把两者投影到同一通道数后拼接再当作一条新检测分支传入。有个容易踩的坑YOLO26 正样本分配里有根据特征图尺度判断目标是否“过大”或“过小”的过滤逻辑。额外加一条 stride4 的分支后如果不调整过滤阈值很多中等目标也会被分到这条高分辨率分支上导致小目标的专属训练样本反而被稀释。我最后的处理是把 P2 分支的尺度过滤范围限制在 0 到 32 像素其余分支保持默认。3.3 训练配置与损失函数我的整体训练配置如下输入分辨率 640×640批次大小 16优化器用 AdamW初始学习率 5e-4配合余弦退火。损失函数沿用 YOLO26 官方的变体组合分类用 BCE回归用 CIoU置信度分支视情况保留。由于 PPA 模块里大量使用 sigmoid 门控训练初期容易出现“门控全开”的情况也就是注意力退化成恒等映射。为了避免这个问题我在前 3 个 epoch 给空间门控分支增加了一个 0.1 的缩放系数让网络先稳定学习主干特征之后再把注意力权重放开。这个方法是从一个做分割任务的朋友那里学来的实测对比下来能直接提高 mAP_small 约 0.8 个点训练曲线也更平滑。针对小目标正样本少的问题我在数据增强上做了配合随机裁剪时限制最小目标尺寸不能小于 10×10避免小目标因为增强操作直接消失mosaic 增强保留原图区域时把小目标的复制概率提高一点。注意力模块解决的是“判得准不准”数据增强解决的是“样本够不够”两者最好一起上。4. 实验设置与调参实录4.1 数据集与评价指标我实验用的是一份自建的航拍小目标数据子集图像来自公开的无人机视角视频抽帧主要包含行人、车辆、交通标志和小型建筑物目标数量约 8 万其中小于 32×32 像素的比例超过 60%。评价指标重点关注 COCO 风格的 mAP0.5:0.95以及按尺度拆分后的 mAP_small。为了控制变量我只改检测头结构主干和颈部保持官方预训练权重不变训练和测试都在同一批数据上完成。下面是一组代表性数据。方案mAP0.5:0.95mAP_smallFPSYOLO26 官方基线34.221.382YOLO26 PPA 主干35.023.171YOLO26 PPA 颈部35.123.969YOLO26 PPA 检测头35.525.673YOLO26 PPA 检测头 P2-Enhance36.427.866可以看到几个有意思的规律。放在主干里虽然也能提升 mAP但推理帧率掉得最厉害说明主干层的通道数高注意力耗损也大。放在颈部比主干略好但提升主要来自大中目标小目标增益有限。放在检测头里的时候mAP_small 提升幅度达到 4.3 个点说明注意力确实要在最终决策阶段才最“对症”。加完 P2-Enhance 后整体 mAP 只涨了 0.9但 mAP_small 再涨 2.2符合设计预期。4.2 训练中踩过的坑第一个坑是 PPA 模块在小数据集上过拟合得特别快。注意力机制本质上是给网络增加了一组可学习的“开关”数据量不够时它容易学到一些训练集特有的噪声模式。我的解决方法是给空间门控分支加随机失活训练时以 0.1 的概率直接置为全 1相当于让网络不能完全依赖注意力促使主干本身保持一定判别力。第二个坑是 P2-Enhance 分支在训练早期损失不下降。检查发现 P2 特征图尺寸大即使只做拼接和注意力梯度回传到主干 P2 层的路径也较长容易出现梯度不稳定。我在 Stem 后面加了一层 LayerNorm并将 P2 分支的损失权重从 1.0 降到 0.8问题基本消失。加正则化的代价是 mAP_small 轻微下降 0.1 左右但训练稳定性的收益明显更大。第三个坑是推理阶段的多尺度问题。单独测试时 P2-Enhance 分支对小目标优势明显但一旦启用测试时增强TTA效果反而下降。原因是 TTA 会做多尺度推理并把结果合并高分辨率分支和低分辨率分支的框重复检出后NMS 合并过程会丢掉部分小目标框。我最后选择 TTA 时对小目标分支的置信度阈值单独放宽 0.05且 NMS 的 IoU 阈值从 0.6 调低到 0.55小目标召回率回升。4.3 推理性能与显存开销很多同学关心注意力模块会不会让显存爆掉。我专门做了记录官方基线的显存占用约 5.2GBbatch16640×640换上 PPA 检测头后约 5.5GB增加 P2-Enhance 分支后约 6.1GB。相比 mAP_small 提升 6.5 个点的收益这个显存代价完全可以接受。帧率方面我的测试卡是某消费级显卡官方基线约 82 FPS完整方案约 66 FPS。帧率下降主要来自 P2-Enhance 分支额外引入的大尺寸特征图计算PPA 模块本身占比其实很小。如果项目对帧率要求极高可以把 P2-Enhance 去掉只保留检测头里的 PPA此时 mAP_small 也有 25.6FPS 能回到 73。5. 常见问题与排查技巧实录5.1 PPA 模块在移植时维度对不上这是被问得最多的问题。YOLO26 不同版本的检测头入口通道数可能不一样有人是 128、256、512有人是 192、384、768。移植时报维度错误几乎都出在 Stem 阶段。我的建议是不要直接改 PPA 内部的dim而是用下面这段逻辑自动对齐class Stem(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.main nn.Sequential( nn.Conv2d(in_ch, out_ch, 1), nn.BatchNorm2d(out_ch), nn.SiLU(inplaceTrue) ) def forward(self, x): return self.main(x)只要保证每个检测分支先过 Stem 统一到隐藏维度PPA 内部就不需要针对不同通道数改代码。这也是我在设计时把 Stem 独立出来的原因。5.2 加了注意力反而掉点怎么办这我在早期版本里遇到过。排查思路分三步先看训练损失是否正常下降如果损失正常但指标掉了多半是注意力把特征调制坏了然后试训练时把注意力模块初始化为恒等映射也就是把空间门控初始化为 0 而不是 0.5看基线是否恢复最后再用 Grad-CAM 或注意力热力图观察看看模型到底在关注什么区域。我最终定位到的问题是空间门控的初始化和残差结构叠加后特征分布的方差被放大了导致预测层的 BN 统计不稳定。解决方案是在 PPA 模块末端加了一条可学习的缩放参数初始值为 1让网络自己决定注意力分支的贡献比例而不是强制叠加。self.alpha nn.Parameter(torch.tensor(1.0)) # forward 末尾 return identity self.alpha * fused * gate这一步改动很微小但解决了很多训练不稳定的情况。5.3 小目标正样本太少怎么补即便设计好了检测头正样本稀缺依然是硬约束。除了常规 mosaic、copy-paste 增强外我在标签分配环节做了一个小调整对小目标允许其中心点附近 3×3 邻域都参与正样本匹配而不是只有最近的一个网格。这相当于给小目标增加了 9 倍的正样本候选区训练时小目标分支的 loss 下降速度明显加快。代价是回归头需要有能力区分“中心点在哪一个网格”也就是所谓的网格偏移预测要更精确。好在我这个检测头的 PPA 空间门控本身就带有位置感知能力实验对比显示采用 3×3 邻域匹配后 mAP_small 提升约 1.4 个点没有产生明显的重复框问题。5.4 换到别的 YOLO 版本怎么移植最后分享一个实操经验这套 Head 改动对 YOLO 系列普适性很强。我试过把 PPA-Small Head 移植到一个较旧的 YOLO 版本上效果同样明显mAP_small 提升约 5 个点。移植时只需要注意三处检测头构造时的通道参数、输出层的 decode 逻辑保持原版、配置文件里新增分支的 stride 设置。其余像 loss 计算、anchor 分配、增强流程都可以直接用。如果遇到旧版本里输出层带 objectness 分支的情况记得把分类分支和置信度分支分开处理PPA 放在两者合并之前的特征调制层不要放在输出层之后。注意力在低维语义空间里调制才有意义放到已经分类好的 prob 向量上基本无效。整套方案从开始改到定型我在 Cocos 风格的 YOLO 检测任务上验证了大半个月最深的感受是检测头确实值得单独花心思。好的检测头应该具备“知道该看哪里也知道该怎么描述看到的东西”的能力PPA 给了它前者P2-Enhance 给了它后者。后续我打算把这个头再接进一个分割任务的多任务框架里理论上 PPA 的金字塔上下文也很适合边界细化后续有效果了再单独写一篇分享。