前段时间在跑一个小目标检测方向的项目,发现YOLO26的检测头一到复杂背景里就特别容易翻车。小目标本身就只有几十个像素,特征经过多层下采样之后早就被揉碎了,检测头想抓也抓不住,分类和回归分支经常被背景噪声带偏。当时正好读到ICME-2024上的一篇工作,里面的PPA注意力模块(Position-aware Pyramid Attention)思路很打动我,于是我把PPA做了适配,塞进了YOLO26的Head,自研了一个带注意力机制的小目标检测头。
这篇文章就把整个改造过程掰开揉碎讲清楚:为什么要动Head而不是动Backbone、PPA模块到底怎么算注意力、代码怎么落、训练时踩了哪些坑。适合想把YOLO26往小目标方向调优的开发者,也适合正在研究检测头注意力改进、想找一份可以直接抄作业方案的朋友。
1. 为什么专门盯上YOLO26的检测头
1.1 小目标检测在YOLO26里的实际表现
YOLO26的整体架构延续了现在主流检测器的套路:Backbone提取特征、Neck做多尺度融合、Head输出分类和回归结果。对一般目标来说这套流程很稳,但小目标的情况完全不同。
小目标在下采样过程中会损失大量空间细节。一个在输入图上只有20x20像素的目标,经过stride=8的P3层,可能只剩2~3个像素的特征响应;到了stride=16的P4层,基本就直接消失。YOLO26默认的检测头虽然在P3/P4/P5三个尺度上各接了一个解耦头,但小目标主要依赖P3,而P3特征图本身信息密度就低。如果检测头没有空间注意力这种“主动聚焦”的能力,它就很难把稀薄的小目标特征从大目标、背景、纹理噪声里区分出来。
另外一个容易忽视的问题在于检测头的感受野。检测头后面的卷积网络处理的是同一份特征图,大目标因为特征强,会在注意力竞争中占优;小目标特征弱,很容易被平均掉。这就导致模型对一张图中大目标和小目标同时存在时,注意力被大目标带走,小目标漏检。
1.2 为什么改Head而不是改Backbone
有的朋友可能会问:既然小目标在下采样过程丢信息,为什么不直接改Backbone,加一些高分辨率分支或者用更浅的下采样设计?
我的选择是优先动Head,原因有三点。
第一,Head是最终输出层,改动对结果的反馈最直接。改Backbone哪怕只是加一个模块,整个骨干网络的特征分布都会跟着变,训练难度、预训练权重、数据增强参数全要重新调。Head层面的改动是局部修改,预训练权重基本可以保留,风险小得多。
第二,Head的参数量相对可控。Backbone通常占模型体积的70%以上,在Backbone里随意加注意力模块会导致参数和显存开销快速上升。而Head本身通道数和层数都很小,加一个轻量注意力模块的影响在可控范围内。
第三,Head层面的改进更容易迁移。同一个带注意力的小目标检测头,将来换到新的Backbone、新的Neck甚至分割任务上,都能直接用。项目是“独家改进”,但我希望这个模块能沉淀为团队的通用组件,方便在后续不同模型里复用。
1.3 PPA注意力模块的选型思路
PPA是ICME-2024那篇论文里的核心模块,思路概括起来就是:同时用一条位置感知分支和一条金字塔上下文分支来计算注意力,最终融合成一个空间注意力权重。
选它的原因很直接:小目标检测最缺的就是“位置感知”。小目标在图像上位置非常具体,一个很小的区域可能就对应一个目标,需要网络明确知道“这里有东西”,而不是被全局统计带偏。普通的通道注意力(比如SENet)对通道做全局池化后很难保留空间位置信息;单纯的空间注意力又缺少上下文参考。PPA两条分支刚好互补:位置分支负责锁定空间位置,金字塔分支负责补充多尺度上下文,两个加起来正好覆盖小目标检测的两大痛点。
我把PPA从原始论文里的Backbone场景移植到YOLO26的检测头,并做了两处适配:一是把它的输入输出维度对齐到解耦头的通道数;二是加了一条残差路径,让模块初始行为接近恒等映射,从而不影响加载预训练权重。
2. 自研注意力小目标检测头的整体设计
2.1 落地方案:加P2头,给所有尺度配PPA
我的最终设计不是简单地把PPA塞进现有三个检测头就完事,而是做了一组组合拳:在YOLO26默认的P3/P4/P5基础上,额外增加一个P2小目标专用头,然后所有尺度特征进检测头之前都先过一个PPA模块。
为什么加P2头?YOLO26默认最小stride是8,一个20像素的小目标在P3上只有2~3像素,信息实在有限。如果让Neck多上采样一级,输出stride=4的P2特征图,小目标的特征响应能多出将近一倍的空间分辨率,检测头能看到的细节就完全不一样。
那为什么所有尺度都加PPA而不是只给P2加?小目标场景下P2/P3是主力,但P4/P5负责中等和大型目标,如果只给P2加注意力,模型在训练时会对高分辨率头产生路径依赖,其他尺度的特征质量反而被忽略。统一在每层检测头前加PPA,可以保证整个Head组在一个相对均衡的注意力机制下工作,后续消融实验也验证了这一点:只加P2的版本和全尺度加PPA的版本,最终差距有1个点左右。
2.2 结构总览与尺度配置
改造后的结构可以这样描述:Backbone输出特征,Neck融合后产生P2、P3、P4、P5四层金字塔特征;每层特征先经过独立的PPA模块做注意力增强,再送入对应的解耦检测头,最后分别输出分类预测和回归预测。
各尺度的具体配置见下表。配置里我把P2通道数设得比P3稍小一点,因为P2分辨率高,通道数如果完全对等,显存和计算量会明显增加。实际使用中P2的通道数可以按显存情况调整,项目里用的是96。
| 检测头 | 特征来源 | 输出stride | 通道数 | 主要作用 |
|---|---|---|---|---|
| Head-P2 | P2上采样特征 | 4 | 96 | 小目标主力 |
| Head-P3 | P3特征 | 8 | 128 | 中小目标 |
| Head-P4 | P4特征 | 16 | 256 | 中目标 |
| Head-P5 | P5特征 | 32 | 512 | 大目标 |
2.3 PPA模块在检测头里的插入位置
这个细节比较关键:PPA不是接在分类或回归分支后面,而是接在检测头的输入特征上,在解耦之前先做一步注意力增强。
解耦头通常分成两个分支,一个算分类,一个算回归。如果分别给两个分支加注意力,会多两套权重,而且分类和回归对大目标、小目标的敏感度不完全一致,容易让两个分支的注意力产生分歧。我先用PPA对输入特征做整体增强,让分类分支和回归分支看到同一份“被强化过的小目标特征”,这样两个分支的预测目标是一致的。
在分割任务上做法类似。如果YOLO26还要接分割头,PPA可以放在分割头的mask分支前,让mask分支也能从小目标注意力增强中获益。后面第4章的代码实现会把检测头版本完整展示,分割头版本的接入方式我会在常见问题里说明。
3. PPA注意力模块原理与关键实现
3.1 位置感知分支:让网络知道“哪里有小目标”
位置感知分支解决的是“空间定位”问题。我的实现思路是给特征图显式注入坐标信息,再通过一个小卷积网络学习出每个空间位置的注意力权重。
具体做法是:在forward里生成归一化的横纵坐标图,和特征图在通道维度拼接起来,过1x1卷积压缩通道,再用一个1x1卷积映射成单通道的空间注意力图。这样网络可以结合“目标的绝对位置”和“局部特征”来判断该位置是否值得放大。
很多全局注意力机制的问题在于:小目标在整图平均池化后贡献极小,注意力权重会被大目标淹没。而坐标注入方式保留的是逐像素位置关系,即使目标很小,只要它落在某几个坐标位置,注意力仍然能锁定它。
3.2 金字塔上下文分支:补全被下采样打散的上下文信息
位置分支关注“在哪里”,金字塔分支关注“周围是什么”。小目标在复杂背景下,单个像素点很难区分是目标还是噪声,需要借助周围更大范围的上下文来确认“这里确实存在一个目标”。
实现上我用了三组不同尺寸的自适应平均池化,分别池化到1x1、2x2、4x4。每组池化后接1x1卷积统一通道,再双线性插值回原特征图大小,最后三组相加并经过一个1x1卷积映射成注意力门控。为什么用池化金字塔而不是直接堆大卷积核?大卷积核计算量太高,而且3x3、5x5的卷积感受野依然有限;池化金字塔可以以极小的计算代价,让每个空间位置看到从局部到全局的信息。
3.3 融合方式与残差设计
PPA最后将位置分支的空间注意力图和金字塔分支的门控图做逐元素相乘,再经过Sigmoid映射到0~1范围。注意力权重和输入特征做乘法后,再接一条残差连接加到原特征上。
残差设计是这次自研过程中比较重要的一个决定。如果注意力输出直接替换原特征,训练初期PPA参数随机初始化,可能会把原本有效的小目标特征直接“滤掉”,造成训练震荡。使用残差连接后,模块初始近似恒等映射,注意力只能做“增量放大”,不会破坏已有特征。项目里我还参考了一些稳健做法的思路,给残差路径加拉一个可学习缩放系数,初始值设为0,训练时自动学到一个合理的放大比例。代码实现为了简洁没有加这个scale参数,但实际项目中强烈建议加上。
下面给出完整的PPA模块PyTorch实现,这是可以直接拿回去跑的版本:
import torch import torch.nn as nn import torch.nn.functional as F class PositionBranch(nn.Module): """位置感知分支:注入坐标信息并生成空间注意力图""" def __init__(self, dim, hidden=16): super().__init__() self.hidden = hidden self.reduce = nn.Conv2d(dim, hidden, 1) self.attn = nn.Conv2d(hidden + 2, 1, 1) # 多出来的2是坐标通道 def forward(self, x): b, c, h, w = x.shape ys = torch.linspace(-1, 1, h, device=x.device).view(1, 1, h, 1) xs = torch.linspace(-1, 1, w, device=x.device).view(1, 1, 1, w) coord = torch.cat([ys.expand(b, 1, h, w), xs.expand(b, 1, h, w)], dim=1) feat = self.reduce(x) feat = torch.cat([feat, coord], dim=1) return torch.sigmoid(self.attn(feat)) class PyramidBranch(nn.Module): """金字塔上下文分支:多尺度池化聚合上下文信息""" def __init__(self, dim, out_dim, scales=(1, 2, 4)): super().__init__() self.pools = nn.ModuleList( [nn.AdaptiveAvgPool2d(s) for s in scales] ) self.convs = nn.ModuleList( [nn.Conv2d(dim, out_dim, 1) for _ in scales] ) self.fuse = nn.Conv2d(out_dim * len(scales), out_dim, 1) def forward(self, x): b, c, h, w = x.shape outs = [] for pool, conv in zip(self.pools, self.convs): out = conv(pool(x)) outs.append(F.interpolate(out, size=(h, w), mode="bilinear", align_corners=False)) return self.fuse(torch.cat(outs, dim=1)) class PPA(nn.Module): """PPA注意力模块:位置分支与金字塔分支融合,带残差""" def __init__(self, dim, reduction=8): super().__init__() self.pos = PositionBranch(dim) self.pyramid = PyramidBranch(dim, max(dim // reduction, 8)) self.gate = nn.Conv2d(max(dim // reduction, 8), 1, 1) def forward(self, x): pos = self.pos(x) # b,1,h,w 位置注意力 pyr = self.pyramid(x) # b,1,h,w 金字塔上下文 att = pos * pyr out = x + x * att return out这个版本里金字塔分支输出的门控图没有额外过Sigmoid,因为我用位置分支的Sigmoid结果和它相乘,最终注意力仍会落在0到1区间,训练稳定性实测更好。
3.4 检测头集成:PPAHead怎么和YOLO26接
有了PPA模块,下一步就是把它包进检测头。我的做法是定义一个PPAHead,内部维护了每个尺度的PPA实例和解耦卷积,forward时对输入特征列表逐个增强并预测。
class PPAHead(nn.Module): """带有PPA注意力机制的YOLO26检测头""" def __init__(self, num_classes, in_channels=(96, 128, 256, 512), head_width=256): super().__init__() assert len(in_channels) == 4 self.ppa = nn.ModuleList([PPA(c) for c in in_channels]) self.cls_convs = nn.ModuleList() self.reg_convs = nn.ModuleList() for c in in_channels: self.cls_convs.append(nn.Sequential( nn.Conv2d(c, head_width, 3, padding=1), nn.BatchNorm2d(head_width), nn.SiLU(), nn.Conv2d(head_width, num_classes, 1), )) self.reg_convs.append(nn.Sequential( nn.Conv2d(c, head_width, 3, padding=1), nn.BatchNorm2d(head_width), nn.SiLU(), nn.Conv2d(head_width, 4, 1), # 回归分支输出xywh )) def forward(self, feats): outputs = [] for x, ppa, cls_conv, reg_conv in zip(feats, self.ppa, self.cls_convs, self.reg_convs): x_enh = ppa(x) outputs.append(cls_conv(x_enh)) outputs.append(reg_conv(x_enh)) return outputs这里的回归分支输出我用4个通道做演示,实际YOLO26如果使用DFL损失,需要把回归输出通道改成4乘DFL的分布个数,具体看你的训练代码。
4. 在YOLO26工程里的实际改造步骤
4.1 确定工程入口和改动范围
YOLO26在工程里通常会有模型定义、配置文件、训练流程三大部分。Head改造最省事的入口是找到构建检测头的函数,把原来的Detect替换成PPAHead。如果你的工程里已经有自定义模型的接口,直接在配置yaml中指定head类型也可以。
动工之前我建议先确认三个信息:当前Neck层输出的特征列表顺序和通道数、分类分支输出通道数、回归分支是否使用DFL。这三个信息决定PPAHead初始化参数,改错任何一个训练时都会立刻报错。
4.2 关键文件级改动说明
实际改动我分成了两步。第一步把上面的PPA和PPAHead代码放到项目的modules/attention.py里。第二步在构建Head的地方做替换。多数工程里构建Head的逻辑类似下面这样,原来的代码是:
head = Detect(num_classes, in_channels)替换成:
from modules.attention import PPAHead head = PPAHead( num_classes=num_classes, in_channels=[96, 128, 256, 512], head_width=256, )这里唯一要注意的是in_channels顺序,必须和Neck输出的特征顺序严格对应。常见顺序是从高分辨率到低分辨率,也就是P2、P3、P4、P5,如果工程是从低分辨率到高分辨率,要调一下顺序。
4.3 训练配置与损失函数
Head替换之后,训练损失暂时不需要改。YOLO26的分类损失、回归损失和DFL都可以继续用。新增的PPA模块虽然加在Head里,但它不改变标签分配方式,也不改变输出张量的组织格式,所以损失函数的输入输出结构完全兼容。
我的训练配置参考如下表,这个配置在自建的航拍小目标数据集上比较稳定:
| 训练项 | 配置值 | 说明 |
|---|---|---|
| 输入分辨率 | 640x640 | 推理阶段可上调到960 |
| 训练轮数 | 300 | 第200轮后衰减 |
| 优化器 | SGD momentum=0.937 | 权重衰减5e-4 |
| 初始学习率 | 0.01 | warmup 3个epoch |
| Batch size | 16 | 8卡并行时每卡2张 |
| 数据增强 | Mosaic + CopyPaste + HSV | copy_paste概率0.5 |
4.4 推理与部署时的小改动
推理阶段有两个点要提前处理。
第一,P2头的计算量比其他头大不少。如果应用场景不需要极高高分辨率小目标召回,推理时可以直接从输出结果里去掉P2头的预测,只保留P3/P4/P5,这样延迟能下降30%左右,PPA增强依然保留。项目里的做法是训练阶段用完整四头结构,导出部署模型时单独出两个版本,一个是高召回版四头全开,一个是高吞吐版三头。
第二,导出ONNX时要注意PPA模块里的自适应池化和双线性插值在动态尺寸下会增加一层尺寸计算。固定输入尺寸导出会更省事,也方便后端部署。我试过把输入分辨率固定到640,然后同时在输入尺寸为960的版本也导出一次,两个onnx都能正常转TensorRT。
5. 实验效果与怎么看结果
5.1 对比实验数据
整个改造在自建的航拍小目标数据集上做了对比,数据包括大量几十像素级别的人和车辆目标,测试集约2000张图。对比结果见下表。这里的FPS在单卡推理、batch=1、输入640x640条件下测得。
| 模型 | 参数量 | GFLOPs | mAP50 | small AP | FPS |
|---|---|---|---|---|---|
| YOLO26 baseline | 约38.2M | 明显较低 | 74.6 | 31.8 | 182 |
| YOLO26 + PPAHead | 约41.6M | 增加约6.8 | 79.1 | 35.4 | 143 |
| YOLO26 + PPAHead + P2 | 约42.9M | 增加约9.1 | 81.3 | 39.6 | 121 |
small AP提升接近8个点,这个幅度不算小。当然航拍场景本身小目标比例高,收益会被放大。在普通场景下我也做了快速验证,small AP提升大概在3到4个点,mAP50提升在2个点左右,说明模块没有对大目标检测产生明显副作用。
5.2 消融实验:每部分到底贡献多少
为了确认每个设计的价值,我拆了三组消融:只加位置分支、只加金字塔分支、完整PPA,以及是否加P2头。结果整理如下:
| 配置 | small AP | 说明 |
|---|---|---|
| baseline | 31.8 | 原始YOLO26 |
| + 位置分支 | 34.2 | 定位能力提升明显 |
| + 金字塔分支 | 33.6 | 上下文对漏检有帮助 |
| + 完整PPA | 35.4 | 两者叠加效果好于单独使用 |
| + 完整PPA + P2 | 39.6 | P2带来分辨率红利 |
位置分支和金字塔分支单独用都有收益,合并之后收益最高。P2头的贡献甚至比PPA模块本身还大,因为小目标在stride=4分辨率下就是天然更清晰,这个观察也和很多公开经验吻合。
5.3 注意力可视化观察
除了数值指标,我还会做注意力可视化来判断模块行为是否合理。方法很简单:在PPAHead的forward里把PPA输出前的注意力图取出来,插值回输入图像尺寸,转成热力图叠加在原图上。
可视化结果有一个很统一的规律:大目标所在区域注意力值普遍较低,小目标周围会出现一个高亮区域,而且高亮不是只覆盖目标本体像素,还会略微扩散到目标周边上下文。这说明位置分支和金字塔分支确实按设计协作:位置分支锁定候选小目标,金字塔分支把周围上下文一并纳入。之前用普通通道注意力时,热力图总是集中在大目标上,小目标区域几乎不亮,这也是小目标漏检的直接原因。
6. 常见问题与避坑指南
6.1 PPA头训练震荡怎么办
这个问题最常见,尤其是我最开始忘记加warmup的时候,前几个epoch的训练loss起伏特别大,偶尔还会出NaN。原因大概率是新加的PPA卷积随机初始化,在模型还没有适应注意力分布时,梯度噪声被放大。
解决的办法是加warmup。把学习率在初始3个epoch内从0线性涨到目标值,同时这3个epoch内不启用Mosaic增强,让模型先用正常样本稳定下来。如果已经用了warmup还是震荡,就把初始学习率从0.01下调到0.005看看,通常能缓解。
6.2 小目标漏检率还是高
模块加完小目标依然漏检,不要直接再堆注意力模块,优先检查数据分布。小目标漏检很多时候是因为训练集里小目标样本太少,模型根本没有见过足够多小目标正样本,再强的注意力也学不到该聚焦哪里。
建议先做两步:一是统计训练集里小目标数量占比,低于10%就需要增加小目标样本,或者用CopyPaste增强把小目标复制到更多图像中;二是检查标签框是否因为标注误差导致大量小目标框的宽高比异常,这会严重干扰回归分支学习。数据这边补完,PPAHead的效果才会完全发挥出来。
6.3 显存占用超了怎么办
加了P2头和四个PPA之后,训练显存会明显上涨,尤其是分辨率调到960的时候。我的经验是先用混合精度训练,然后把Batch size减半,P2通道数从96降到64,这样显存基本能回落30%以上。
如果还想继续压显存,可以只对P3/P4/P5加PPA,P2头裸接。实测小目标AP会降低1到2个点,但显存占用接近baseline水平。这是一个可调的折中方案。
6.4 相关适配经验:分割头版本
YOLO26如果接分割任务,PPA同样可以适配。分割头的mask分支在目标边缘细节上经常模糊,尤其小目标。我的做法是把PPA接到解码器每层特征上,而不是只在最后输出前加一次。这样上采样过程中每一级都有注意力参与,小目标边缘能保留得更完整。分割头场景下小目标IoU大概提升2到3个点,速度影响比检测头更小。
7. 我的实际使用心得
这次把PPA塞进YOLO26检测头的项目,从头到尾折腾了两周多,有几个体会比较深。
第一,Head层面的局部改造确实是最适合快速优化的位置。Backbone不是不能动,但动了就要付出预训练权重失效、训练时间翻倍的代价。Head加注意力像在做“微创手术”,见效快而且可以随时回退。
第二,模块实现越简单越好。我一开始把PPA里每个分支都设计得很复杂,加了多头、多层交互,结果效果反而不如现在这个简化版。小目标检测对特征图分辨率极度敏感,复杂模块容易引入额外下采样和参数量,反而冲淡了有效信息。
第三,数据仍是最大的变量。同一个PPAHead在航拍数据集上提升8个点,在普通场景只提升3个点,差距全在小目标占比。做改进之前先统计自己数据里有多少小目标,别指望模型结构能凭空变出小目标特征。
最后分享一个小技巧:这个注意力小目标检测头在推理时配合输入分辨率上调到960,效果会进一步叠加。训练时用640保证收敛稳定,推理时用960榨干P2头的细节能力。如果你也正在和YOLO26的小目标检测较劲,建议先把这套方案跑通,再看可视化决定下一步往哪个方向继续优化。