前一阵子在折腾YOLOv11的目标检测,主要想把小目标召回拉上来。试了不少常规套路,SPPF改ASPP、加注意力模块,多少有点效果,但总感觉在细节保留上差一口气。后来在一个遥感场景的项目里做了个高频引导信道增强模块HFG-CEB,插到特征融合路径里,AP_s直接涨了三个多点,而且推理速度没有明显变慢。这篇就把设计思路、完整实现和几个容易踩的坑写清楚,想在YOLOv11上做特征融合改进的朋友可以直接参考。
1. 整体思路:为什么选“特征融合”和“高频引导”作为涨点方向
1.1 特征融合路径是最值得先动刀的地方
YOLOv11的结构从主干到颈部再到检测头,在公开数据集上已经很强了。但实际业务数据往往不是那种“主角居中、目标大而清晰”的分布。尤其是遥感场景、无人机航拍、工业质检这类任务,小目标多、边界模糊、背景纹理复杂,模型跑出来的结果经常是漏检多、定位不准。
很多人上来就换backbone,比如把主干换成分类精度更高的网络,这当然有效,但换backbone对训练成本和推理速度的影响都很大,有时候还会破坏原本已经调好的预训练权重。相比之下,特征融合路径是性价比最高的手术点。原因很简单:颈部结构连接着主干的语义信息和细节信息,这一段改动一小块,收益会传导到所有尺度的检测分支上。
标准结构里,深层特征分辨率低、语义强;浅层特征分辨率高、细节多。融合时虽然有多层金字塔和自顶向下、自底向上的路径,但本质上还是“拼接/相加 + 卷积自己去学”。问题在于,如果融合前特征的通道重要程度是“一视同仁”的,那细节信息很容易被语义信息带偏。HFG-CEB做的事情,就是让高频响应先说话,告诉融合模块:哪些通道带着清晰边界和小目标纹理,给这些通道更高的权重。
1.2 高频信息是小目标的“救命稻草”
小目标在图像上通常只有几十甚至十几个像素。这个尺度下,颜色统计、形状语义都不太可靠,最能区分目标和背景的往往是局部边缘和灰度突变,也就是高频分量。
打个比方:在低分辨率图上,一栋楼和一辆车可能都有类似的色块,但它们的边界陡峭程度完全不同。高频算子对灰度突变极其敏感,能一下子把突变位置标出来。所以把小目标检测问题部分转化为“找高频响应集中区”,是一条合理路径。
有人可能试过直接给输入图像做锐化或者加高通滤波器,但那只在RGB层面做,对网络特征图的影响非常有限。HFG-CEB是在特征图内部做,这更接近网络的语义层次:卷积网络的每个通道已经编码了不同的模式,我们不是单纯增强某个固定频率,而是利用高频能量分布重新分配通道权重。
1.3 HFG-CEB模块的整体架构
HFG-CEB的全称是High-Frequency Guided Channel Enhancement Block,名字里的“信道”其实就是特征图的通道,叫法不同而已。模块整体拆成三个部分:
- 高频提取分支:先过一个可学习的深度卷积,再用固定的拉普拉斯核逐通道提取高频响应;
- 信道权重生成:把高频响应图的平均池化和最大池化结果拼起来,通过一个1x1卷积瓶颈结构生成0到1之间的通道权重;
- 残差加权输出:原始特征图乘以通道权重,经过1x1卷积后,再与输入做残差相加。
重点在于“高频引导”这四个字。通道权重不是根据原始特征的全局均值算的,而是根据高频能量算的,这样对细小目标的敏感度会明显更高。残差结构则保证模块在训练初期接近恒等映射,不会一上来就把网络梯度搅乱。
2. 核心细节解析:模块里的每个设计都不是多余的
2.1 高频提取分支:为什么先用可学习卷积再用固定拉普拉斯核
很多人看到拉普拉斯算子第一反应是:直接用OpenCV里的高通滤波不就行了?但在特征图内部做,情况不太一样。特征图是网络学出来的抽象表示,它的“边缘”不一定对应RGB空间里的边缘,可能是一种更复杂的局部模式。
所以我多放了一个深度卷积在拉普拉斯前面。这个深度卷积是3x3、groups等于通道数的,每个通道独立学习一组权重。它可以从数据里自适应地学出“哪种局部模式应该被当成高频来关注”,然后再用拉普拉斯做固定提取。两步合在一起,既保持了拉普拉斯零和、响应集中的优点,又不至于被固定核的限制框死。
实现上有三个坑要特别注意:
第一,拉普拉斯核必须注册成buffer,不要放进nn.Parameter。一旦放进可学习参数里,训练过程中核的中心值很容易被梯度带偏,最后学出来的东西就不是高频提取了,模块就名存实亡。
第二,用F.conv2d做逐通道拉普拉斯时,要把核expand成与通道数一致的形状,并且设置groups=c。这样才能保证每个通道独立卷积,不会跨通道混叠。普通卷积会把不同通道的高频信息“抹匀”,失去了逐通道的独立性。
第三,拉普拉斯输出是有正有负的,不是标准的ReLU友好的分布。后面接Sigmoid的MLP能起到一定的数值压缩作用,但如果上游BatchNorm没有收敛好,拉普拉斯响应可能会在训练初期出现较大的数值波动,这时候需要在代码里对响应做一次缩放或裁剪,防止梯度异常。
2.2 信道权重生成:平均池化和最大池化缺一不可
高频响应图拿到之后,最常见的做法是全局平均池化成c维向量,然后接全连接层。但我实际调试中发现,只用平均池化效果并不好。原因是:平均池化代表的是“这个通道所有位置的高频能量均值”,它稳定,但容易被大量弱响应稀释;而小目标只在图中一小块区域产生强边缘,平均完之后它的贡献变得很小。
所以我把最大池化也加进来了。最大池化代表“最强的一处边缘响应”,对小目标非常敏感,但只取一个点又容易被噪点干扰。平均池化负责整体稳定性,最大池化负责捕捉峰值信号,两个拼在一起正好互补。
权重生成部分用的是1x1卷积构成的瓶颈结构,不是全连接层。其实在1x1输入上,1x1卷积和全连接是等价的,但用卷积写起来更自然,通道数变化也更直观。hidden维度我取的是max(c // 16, 8),也就是说如果通道特别小,不会压到低于8个维度,避免信息损失太厉害。
这里的reduction是个可调参数。我的经验是:如果你的模块放在P5这种低分辨率层,建议把reduction从16调到8,因为高层通道数大,压到c/16信息损失有点多。放在P3/P4层时,reduction=16就够用。
2.3 残差结构:模块不听话的时候怎么稳住训练
模块输出如果直接替换掉原特征图,网络在最开始几轮非常容易震荡。所以我加了一条残差路径:输出 = 输入 + 1x1卷积(输入 * 通道权重)。
这里有两个细节值得说。第一,1x1卷积的权重初始化成零。这样训练刚开始时,模块输出等于原输入,不会干扰已经初始化的主干和检测头;训练几十轮后,网络再慢慢“放开”这个模块的增益。第二,如果输入输出通道数不一致,残差路径上需要一个1x1卷积把输入对齐到输出通道。目标检测的neck里一般通道数保持不变,但写成兼容版本总没错。
我自己实际测试过,不做零初始化直接硬训,loss曲线在最初200轮会明显抖动;做了零初始化后曲线平滑很多。对于想快速验证模块效果的人来说,这个初始化技巧能省下不少排查时间。
2.4 模块放置位置不同,效果差别很大
HFG-CEB不是放在哪里都能涨点的。我在自己的数据集上试了四个位置,结论差异非常大:
- 位置A:P3层C2f之后,这个位置分辨率高、小目标信息最丰富;
- 位置B:P4层Concat之后,这里是语义信息和浅层细节融合的第一个交汇点;
- 位置C:P5层Concat之后,分辨率低,高频信息已经被压缩得差不多了;
- 位置D:每层Concat后都加,计算量上涨最明显。
最终保留的是P3和P4各加一个模块,收益最大。P5那层加上几乎不影响小目标AP,只有大目标指标微涨。原因不难理解:P5特征图空间分辨率太低,高频细节在多次下采样里丢得差不多了,模块有点“无米下锅”的感觉。如果你的数据集以大目标为主,模块加在P5也许还有一点用,但小目标是主力场景时,优先放在P3/P4。
3. 实操过程:完整代码、集成方法和训练配置
3.1 HFG-CEB模块的PyTorch完整实现
下面的代码是我实际在项目里用的版本,PyTorch 2.0以上都能直接跑。代码里注释写得比较细,照着敲就行。
import torch import torch.nn as nn import torch.nn.functional as F class HFG_CEB(nn.Module): """High-Frequency Guided Channel Enhancement Block 输入输出通道数可以不同,残差路径会自动对齐。 构造函数第一个参数是输出通道数c2,reduction是通道缩减比例。 实际插入时一般保持输出通道数和输入一致。 """ def __init__(self, c2, reduction=16, k=3): super().__init__() hidden = max(c2 // reduction, 8) # 逐通道高频模式学习:3x3深度卷积,不跨通道混合 self.pre_conv = nn.Conv2d( c2, c2, kernel_size=k, padding=k // 2, groups=c2, bias=False ) # 固定拉普拉斯核,注册为buffer,不允许训练改变 laplacian = torch.tensor( [[0., 1., 0.], [1., -4., 1.], [0., 1., 0.]], dtype=torch.float32 ).reshape(1, 1, 3, 3) self.register_buffer("laplacian", laplacian) # 信道权重生成:2c -> hidden -> c self.fc = nn.Sequential( nn.Conv2d(c2 * 2, hidden, 1, bias=False), nn.ReLU(inplace=True), nn.Conv2d(hidden, c2, 1, bias=False), nn.Sigmoid(), ) # 输出变换和残差对齐 self.out_conv = nn.Conv2d(c2, c2, 1, bias=False) # 如果放在通道数变化的位置,这里改成对应的对齐卷积即可 self.align = nn.Identity() # 关键初始化:让新增路径初始时几乎不改变输出 nn.init.zeros_(self.out_conv.weight) def forward(self, x): identity = self.align(x) # 1) 深度卷积增强局部模式 x_hf = self.pre_conv(x) # 2) 拉普拉斯逐通道提高频 b, c, _, _ = x_hf.shape lk = self.laplacian.expand(c, -1, -1, -1) x_hf = F.conv2d(x_hf, lk, padding=1, groups=c) # 3) 高频响应统计量:均值 + 最大值 hf_avg = F.adaptive_avg_pool2d(x_hf, 1) hf_max = F.adaptive_max_pool2d(x_hf, 1) z = torch.cat([hf_avg, hf_max], dim=1) # 4) 生成高频引导的通道权重 w = self.fc(z) # 5) 加权输出 + 残差 out = self.out_conv(x * w) return identity + out启动初始化里把out_conv权重全部置零,配合残差结构,训练初期模块完全不对特征图做改变,等网络自己学起来。这个细节是我踩了几次坑之后加上的,效果很稳。
3.2 把模块接入YOLOv11模型配置
实际接入YOLOv11有两条路,取决于你的代码是自己魔改过的还是官方开源的原始结构。两种我都说。
第一种,直接改模型配置文件。先把HFG_CEB这个类放到模型模块的目录下,比如新建一个hf_ceb.py,然后在模型解析的注册表里把HFG_CEB加进去。不同代码的注册方式不一样,最核心的是让解析器在遇到yaml里的HFG_CEB字符串时能找到对应的类。
在head部分插入一行,示意如下:
head: - [-1, 1, Conv, [256, 3, 2]] - [-1, 1, HFG_CEB, [256, 16]] # P3 高频引导增强 - [-1, 1, Concat, [-1, 3]] - [-1, 1, C2f, [256, 3]]这里第一个参数256是模块的输出通道数,第二个是reduction。模块内部的输入通道数是从特征图的shape动态拿的,所以在通道不变的neck里插入不会出问题。如果你的代码版本解析参数的习惯是把第一个参数当输入通道传,那写的时候把类签名调整一下即可,原理一样。
第二种,不动yaml,直接在模型forward里调用。如果你用的是一个深度魔改过的代码,解析机制比较复杂,那就在neck的特征融合处手动调用模块:
from hf_ceb import HFG_CEB hf_module = HFG_CEB(c2=256, reduction=16) x = hf_module(x) # 放在Concat之后、C2f之前这种方法最稳妥,坏处是每次改网络结构都要动Python代码而不是配置文件,但胜在逻辑清晰,适合调试阶段。
3.3 训练配置与推荐的实验顺序
我在模拟项目X的数据集上用的配置是:输入尺寸640x640,优化器SGD,初始学习率0.01、momentum 0.937、weight_decay 5e-4,epochs设置150,batch size根据显存调节。8G显存跑n模型可以到batch 16,跑l模型就只能放4到8。
如果目标是更大的小目标检测,建议把输入尺寸提到960或1024,小目标在特征图上的可学习信息会多不少。代价是训练时间变长,GPU显存需求量也上来了,可以先在640下完成消融对比,最后上大分辨率。
做对比实验时,我强烈建议先用n模型跑,不要在l或者x模型上直接验证。n模型参数少、单轮速度快,能快速看出模块到底有没有正收益。等确认方向对了再往s/ml上迁移。另外一定要固定随机种子,每个配置至少跑两次取平均,不然差0.1个点很难判断是模块的功劳还是随机性。
3.4 我实际用到的消融实验与结果
下面这张表是我在自建的一个无人机航拍小目标数据集上做的对比结果,负样本和正样本比例接近真实作业场景,共3000多张图,主要目标是车辆和船舶两类。训练细节保持完全一致,唯一的变量是是否加入HFG-CEB模块:
| 配置 | mAP50 | mAP50-95 | AP_s(小目标) | 推理耗时(单张) |
|---|---|---|---|---|
| YOLOv11n baseline | 0.786 | 0.528 | 0.314 | 6.8ms |
| YOLOv11n + HFG-CEB(P3/P4) | 0.805 | 0.551 | 0.351 | 7.0ms |
| YOLOv11s baseline | 0.812 | 0.556 | 0.342 | 11.5ms |
| YOLOv11s + HFG-CEB(P3/P4) | 0.829 | 0.579 | 0.381 | 11.8ms |
可以看到mAP50的涨幅在一个点左右,但小目标AP_s涨幅能到3.7个点,正好印证了模块对细节保留和微小目标的作用。推理耗时增加在3%以内,对工程落地是可以接受的。
如果你手里的数据是大中目标为主,这个模块带来的收益通常只在0.2到0.5个点之间,不用抱太高期待。它更像一个“细节放大器”,增益大小取决于数据本身有多少细碎信息可以放大。
4. 常见问题与排查技巧实录
4.1 加了模块之后loss变成NaN
这个问题我遇到过一次,触发条件是在混合精度训练下,拉普拉斯响应值的数值范围偏大,反向传播时梯度溢出。排查思路很直接:先看loss是第几百轮开始出问题的,把AMP关掉试一轮,如果正常,基本就是数值溢出。
解决办法有两个。一是对拉普拉斯输出做完归一化,最简单的做法是乘以一个固定缩放系数0.1,让高频响应回到相对温和的数值区间。二是在out_conv上保持零初始化,训练前期模块不参与输出,epoch开到一定数量后再放开。两个方法可以同时用。
这个坑不是模块特有的,很多带固定卷积核的特征增强模块在AMP下都会撞上,大家遇到可以先往数值范围这个方向想。
4.2 加了模块之后不掉点也不涨点
同样硬件、同样epoch,加了模块前后指标几乎一样,最常见原因不是模块没用,而是数据集里小目标占比太低了。我统计过,小目标(面积小于32x32像素)占比低于5%时,模块的收益会被大目标指标稀释在噪声里,根本看不出来。想让AP_s有可感知的提升,小目标占比最好在15%以上。
还有一个容易被忽略的点:如果数据增强里mosaic、copy-paste的力度太狠,目标被切碎、缩放后高频信息本身已经被破坏了,模块自然无从发挥。这种情况下,先关闭部分数据增强,看看模块收益是不是就出来了。
4.3 显存和推理速度涨太多怎么办
显存方面,在P3和P4各加一个模块,batch=8、输入640的前提下,显存增加大概300到400M,属于正常范围。推理耗时大约增加2%到5%,一般都能接受。如果显存实在紧张,最简单的做法是把pre_conv去掉,只保留拉普拉斯提取和MLP权重生成,显存还能再降一半。
特别提醒:不要在P2这种超浅层特征图上加模块。P2分辨率太高,深度卷积加拉普拉斯在这么大的特征图上的计算量会明显上升,而收益非常小,性价比极低。
4.4 一个验证模块有没有起作用的小技巧
训练之前,先用一个batch的输入数据跑一次前向,把每个HFG_CEB输出的拉普拉斯响应统计量打印出来,看看是不是零均值、有正负尖峰的分布。如果统计量全是0,说明前置深度卷积的初始化或上游的BatchNorm把特征“压平”了,模块实际上没有在做事。
这种情况下可以先把它放到网络更浅的位置再测,或者把pre_conv换成独立初始化的卷积,不要让它继承一个可能已经饱和的分布。这个小习惯能帮你快速排除“模块存在但没起作用”的状态,省下整轮训练的时间。
我个人实际操作中的体会是,HFG-CEB真正的价值不是“多加了一个模块”,而是把高频细节从直觉层面变成了模型可学习的通道权重。这个思路在遥感航拍、工业缺陷检测这类高频信息丰富的任务上很吃香,但通用大目标场景收益没那么夸张。后续想扩展的话,把它用在语义分割或者超分辨率的特征融合里理论上也成立。最后再多说一句:新模块第一次试验时,先在小模型、小数据集上跑通,把数值曲线看明白,再全量训练,能省非常多时间。