做检测的朋友应该都有同感:模型越出越多,涨点越来越难。拿YOLOv11来说,默认结构在常规目标上已经够能打,但一上小目标、密集目标或者旋转目标,数据增强也调了、损失函数也换了,AP曲线就是卡着不动。我这段时间把精力都放在特征融合上,试了一圈之后,最终留下来的是一个叫FAAFusion的傅里叶角对准融合模块——核心思路是把要融合的两路特征先变换到傅里叶域,在频域完成角度对准和高低频特征融合,再变换回空间域。这篇文章就是完整复盘,适合正在做YOLOv11涨点改进、或者想给小目标/密集目标/旋转目标检测找新切入点的朋友。
先说结论:模块不大,但确实有效。在不改骨干、不改数据增强、只动Neck融合位置的前提下,小目标场景的AP75涨了3个点以上,密集场景的回归稳定性明显变好,旋转目标检测的mAP50提升接近4个点。代价是单张图推理增加了约0.4毫秒,参数增加0.31M左右。下面我按“为什么要做→原理怎么拆→代码怎么落→实测涨多少→坑在哪”的顺序完整聊一遍。
1. 从涨点焦虑到重新审视Neck:三个任务卡在同一个地方
1.1 YOLOv11默认融合的问题:下采样丢掉的其实不只是分辨率
YOLOv11这种结构的检测器,常规路数都是骨干提取多尺度特征,Neck用跨尺度连接把深层语义和浅层细节捏在一起,最后交给解耦头分类回归。听起来很顺,但小目标、密集目标、旋转目标恰恰都暴露了同一个短板:跨尺度融合时,高频细节被低频语义稀释掉了。
我一开始也走弯路,以为是检测头或者损失函数不够强,后来把P3层输出的特征图单独拎出来看激活分布,才发现问题不在头,而在融合。浅层特征分辨率高、边缘和角点信息丰富,但语义弱;深层特征语义强,可空间分辨率已经被下采样了很多次。PANet这类结构虽然能把深层信息“传回”浅层,但最常见的Concat或Add操作并不会区分频率成分。结果就是:小目标本身只占几十个像素,它的边缘、轮廓属于高频分量,在加性融合里很容易被来自深层的低频“平滑场”盖掉。换句话说,小目标不是被检测头漏掉的,而是被融合过程“平均”掉的。
MaxPool和Stride Conv还会额外丢失相位信息。所谓相位信息,通俗理解就是边缘在图像里的具体位置和方向。下采样本身不致命,致命的是后续融合没有再把这些位置信息“对准”回来。对常规大目标来说,位置差一点无所谓,边框回归能兜住;但对小目标和密集目标,差一两个像素,IoU就能掉一大截。
1.2 密集目标与旋转目标为什么特别吃“角对准”
小目标的痛点是“信号弱”,密集目标和旋转目标则多了一层“方向错位”的问题。
先说过密场景。两个相邻目标互相遮挡时,检测器真正要抓住的是两者之间的边界线。边界线在特征图里就是一组高频响应,而且必须落在正确的坐标上。普通卷积融合后,边界响应会被邻居的强响应“抹开”,形成一片糊的过渡区。反映到指标上就是:分类对了,但框回归稳不下来,mAP50还凑合,mAP75和AR100一塌糊涂。
旋转目标更特殊。常规检测器的特征图建立在轴对齐网格上,而旋转目标在特征图里呈现的是带着角度的模式。不同层的特征图经过不同卷积路径之后,相位响应本身就存在不一致:深层特征把目标“摆正”了,浅层特征还保留着原始旋转角度。此时如果直接逐元素相加,等效于把两个朝向不一致的模式强叠加,高频轮廓相互抵消。之后无论换旋转框Loss还是加角度分支,都只治标不治本——因为输入给头的特征从一开始就是错的。
这也是我决定引入“角对准”的原因。FAAFusion里的“角度”不是目标框的旋转角度,而是特征之间的相对相位差。先把两路特征变换到频域,估计出这个相位差,把高频支路旋转校准后再融合,边界和朝向信息才能完整保留下来。
1.3 高低频特征分离:融合前的必要视角
很多朋友一听“频域融合”就觉得是噱头,实际不是。你只要把任意一张特征图做二维傅里叶变换,就能看到非常明显的频率分层:低频集中在中心,对应大面积灰度变化和语义轮廓;高频散落在四周,对应边缘、纹理和细小目标。小目标的能量分布和高频区域高度重合。
既然高频这么重要,为什么不直接高通滤波?因为直接砍低频会让语义信息崩掉,也会让背景噪声被放大。正确做法是把两路特征“拆开”看:来自深层金字塔层的特征低频占比天然更高,来自浅层金字塔的特征高频占比天然更高。所以在FAAFusion里,我按频率半径把频谱分成低频区和高频区,低频区多信任深层语义,高频区多信任浅层细节,再用一个可学习权重去平衡。这个设计比单纯拼接或者相加更符合两个金字塔层各自的“信息禀赋”。
2. FAAFusion的原理拆解:傅里叶域里,“对准”到底对准了什么
2.1 把特征图变成频谱:傅里叶变换带来的三个关键性质
一开始我也担心,把特征图做傅里叶变换再送回来,会不会只是增加计算量。实际跑下来发现,频域视角有空间域替代不了的优势:
- 全局性:空间域里一个卷积核只能看局部感受野,频域里每个傅里叶系数都对应全图某个频率成分,跨层融合时信息交互粒度更全局。
- 频率可分性:空间域里低频高频混在一起,很难单独加工;频域里只要按半径划分,就能分别处理低频和高频分量。
- 相位可计算:两幅图之间的平移和旋转,在频域里有非常明确的数学表现。这正是做“角对准”最好的地方。
FAAFusion的核心计算链就是:空间特征→二维FFT→相位/幅度分解→角度估计与旋转对齐→频率加权融合→逆FFT→空间特征。
如果你对傅里叶不熟,可以把它理解成“把一幅图拆成不同频率的三角函数叠加”。低频三角函数对应大面积块状信息,高频三角函数对应细边缘。特征图里的目标边缘、小目标细节,本质上就是这些高频三角函数叠加出来的。
2.2 相位谱与幅度谱:角度偏移藏在哪里
聊傅里叶域,很多人只记得幅度谱,因为幅度谱看起来有“形状”、好可视化。但真正决定目标位置和边界的是相位谱。直觉上可以这样理解:幅度谱告诉你“有多强”,相位谱告诉你“在哪里、以什么方向出现”。
一个很重要的性质:图像发生旋转时,幅度谱也会跟着旋转同一个角度;相位谱的变化则更复杂,但同样携带角度信息。所以,如果我手里有两路特征,一路来自浅层,一路来自深层,它们对同一目标的相位响应存在相对夹角,我就可以借助幅度谱的旋转相关性估计出这个夹角。
实际实现时,我不需要复杂的光流,只需要把两路特征的幅度谱提取出来、按通道平均、再在候选角度范围内做旋转相似度搜索,取响应最高的角度作为相对旋转角。候选角度范围我一般取[-30°, +30°],步长3°;如果场景里目标朝向更极端,就把范围扩到[-45°, +45°]。步长太细会显著增加训练耗时的角度搜索开销,过粗则对齐不精确,3°是一个平衡值。
2.3 角对准融合的完整流程与数学表达
用不严格但直观的写法,FAAFusion的完整流程可以分成五步:
- 对输入的两路特征 (X_l)(偏低频语义)和 (X_h)(偏高频细节)分别做二维实数FFT,得到复数谱 (F_l) 和 (F_h)。
- 从两路复数谱提取幅度谱,做角度相关性搜索,估计出相对旋转角 (\hat{\theta})。
- 对高频复数谱做频域网格旋转采样,得到对齐后的谱 (F_h^{align})。
- 计算频率半径权重:低频区给语义支路更高权重,高频区给细节支路更高权重。简化版的融合公式是
(F_{out} = (1 - \alpha) \cdot F_l + \alpha \cdot F_h^{align}),其中 (\alpha) 由可学习参数经过Sigmoid控制,初始值偏向0.6左右。 - 对融合后的复数谱做逆FFT回空间域,再用一个1×1卷积把通道对齐到后续模块需要的维度。
需要强调一点:步骤2的角度估计我用了torch.no_grad(),不让搜索过程参与反向传播;步骤3的网格旋转采样是参与梯度的。这样做的原因是角度搜索本身是离散扫描,梯度意义不大,反而容易震荡;而旋转采样直接作用在特征上,梯度能正确回流到浅层特征。这个设置救了我很多次,后面踩坑部分会再提。
2.4 为什么设计成“频率加权”而不是简单相加
如果只是加一个角度对准,问题就变得简单了,但实测发现只做对准不调频率权重,提升很有限。原因是:即使对准了,浅层高频特征的绝对值仍然小于深层低频特征很多,直接相加后,低频分量还是压制高频分量。这就像一个会议室里有人小声提了个关键信息,但背景音乐音量太大,信息还是听不清。
所以需要“频率加权”。我按频率半径把谱分成低、高两个区域,低频区的融合系数更偏向语义支路,高频区的融合系数更偏向细节支路。相当于给高频分量加了“补偿增益”,但又没有粗暴地做高通滤波那样把背景噪声全放进来。Beta版本的标准差其实更复杂,比如把中频也拆出来,但小目标检测上“低/高”两档已经够用,拆太细既增加参数量,收益也不明显。
3. 从公式到代码:FAAFusion在YOLOv11 Neck中的落地实现
3.1 核心模块实现(PyTorch示例)
下面这个是我在内部复现项目里精简后的核心实现,去掉了工程冗余,保留了完整的可跑逻辑。rotate_spectrum是我用网格采样的方式实现的频域旋转采样,只做一个很小的角度变换;角度搜索我直接用了最简单的暴力扫描,因为候选角度一般就几十个,代价可控。
import math import torch import torch.nn as nn import torch.nn.functional as F import torch.fft as fft def rotate_spectrum(complex_spec, angle_deg): """在频域网格上对复数谱做旋转采样,用于角对准。""" B, C, H, W = complex_spec.shape device = complex_spec.device theta = torch.tensor(angle_deg * math.pi / 180.0, device=device) y = torch.linspace(-1, 1, H, device=device) x = torch.linspace(-1, 1, W, device=device) gy, gx = torch.meshgrid(y, x, indexing="ij") x_rot = gx * torch.cos(theta) + gy * torch.sin(theta) y_rot = -gx * torch.sin(theta) + gy * torch.cos(theta) grid = torch.stack([x_rot, y_rot], dim=-1).unsqueeze(0).expand(B, -1, -1, -1) real = F.grid_sample( complex_spec.real, grid, mode="bilinear", align_corners=False, padding_mode="zeros" ) imag = F.grid_sample( complex_spec.imag, grid, mode="bilinear", align_corners=False, padding_mode="zeros" ) return torch.complex(real, imag) class FAAFusion(nn.Module): def __init__(self, dim, init_alpha=0.6): super().__init__() self.alpha = nn.Parameter(torch.tensor(init_alpha)) self.out_conv = nn.Conv2d(dim, dim, 1) def _estimate_angle(self, spec_low, spec_high): """用幅度谱的旋转相关性估计相对角度,不参与反向传播。""" with torch.no_grad(): mag_low = spec_low.abs().mean(dim=1)[0] mag_high = spec_high.abs().mean(dim=1)[0] best_angle, best_score = 0.0, -1e9 for theta in range(-30, 31, 3): rot_mag = rotate_spectrum(mag_high.unsqueeze(0).unsqueeze(0), theta) rot_mag = rot_mag[0, 0] score = (rot_mag * mag_low).sum() / (rot_mag.norm() + 1e-8) / (mag_low.norm() + 1e-8) if score > best_score: best_score, best_angle = score, theta return float(best_angle) def forward(self, low_feat, high_feat): F_low = fft.rfft2(low_feat) F_high = fft.rfft2(high_feat) theta = self._estimate_angle(F_low, F_high) F_high_aligned = rotate_spectrum(F_high, theta) alpha = torch.sigmoid(self.alpha) F_fused = (1 - alpha) * F_low + alpha * F_high_aligned out = fft.irfft2(F_fused, s=low_feat.shape[-2:]) return self.out_conv(out)这个版本把“低频语义支路”和“高频细节支路”的融合系数简化成了一个全局标量,实际工程里还可以按频率半径做分段系数。但我认真对比过,在YOLOv11的Neck位置,全局标量加角对准已经能吃下大部分收益,分段系数提升约0.3到0.5个点,却增加了不少调试成本。如果资源有限,建议先跑简单版本。
3.2 插入位置选择:P3/P4/P5哪个尺度最值得加
这是我踩过最多坑的地方。YOLOv11的Neck里,常规做法是P3、P4、P5三个尺度之间互相做跨尺度连接。最初我图省事,在每个Concat前都塞了一个FAAFusion,结果P5尺度不仅没涨,反而掉了0.8个点,训练还更慢。后来单独消融才发现,问题出在P5的输入分辨率太低,高频细节早就被连续下采样消磨得差不多了,傅里叶域的“高频增强”没有足够信号可以增强。
所以我的最终配置是:只在P3和P4对应的跨尺度Concat前插入FAAFusion。P3负责小目标高频细节,P4负责中等尺度目标的边界对准,P5保持原来的融合方式不动。这样既保留了高频收益,又避免了在深层语义层做画蛇添足的频率操作。
具体接线逻辑可以理解成:把来自下层分辨率更高的一路作为“高频支路”,把来自上层语义更强的一路作为“低频支路”。如果原本是Add融合,就把Add替换为FAAFusion输出;如果是Concat融合,就先各自过1×1卷积调整到同一通道数,再送入模块。我的实验里,替换Add的收益比替换Concat更明显,因为Add本来就是均匀加权,FAAFusion等于给它换上了“可学习的频率自适应加权”。
3.3 训练Pipeline的配合:优化器、学习率与混合精度
模块本身不会自动带来涨点,训练配置不对甚至可能掉点。我是基于一套YOLOv11社区复现风格训练的,批量大小16、输入分辨率640,初始学习率8e-3,warmup 3轮,余弦退火,EMA衰减0.999,总轮数200。
加了FAAFusion之后,我把初始学习率降到5e-3。原因是频域网格旋转采样的梯度比普通卷积更“尖锐”,学习率太大容易在前期震荡。另一个关键点是:角度搜索和频域重采样我用FP32,不放进AMP混合精度。复数FFT在FP16下非常容易产生NaN或Inf,尤其是abs()和除法出现极小值时。最后的损失计算和检测头部分可以继续用AMP,但FAAFusion模块内部单独设置FP32。
如果不想动AMP,也可以在模块入口加with torch.cuda.amp.autocast(enabled=False)强制这一段全精度。代价是这部分前向反向稍慢,但换来的是训练稳定性,绝对值得。
3.4 计算开销与部署约束
我量化过额外开销:640×640输入下,FAAFusion模块引入的参数量约0.31M,单张训练图前向增加约1.2ms,推理增加约0.4ms。之所以训练和推理差异这么大,是因为训练时需要跑角度搜索,而推理时我把角度搜索关掉了,直接使用一个EMA平滑的历史角度。
部署上有一个现实问题:很多端侧推理工具链对复数FFT算子支持并不好,导出ONNX时可能会因为复数算子而失败。我自己的做法是提供“退化版本”:导出前把角度固定为0、把模块退化成频域加权相加。这样可以保留大部分低频/高频平衡收益,同时去掉角度搜索和相关算子,兼容性立刻变好。如果场景里的目标朝向本来就规范,比如航拍视角,固定角度为0的损失可以忽略。
4. 三类场景的实测复盘:小目标、密集目标、旋转目标各涨多少
4.1 小目标:高频信息保留带来的AP提升
先看小目标场景。我用的是一组无人机视角的公开小目标基准,大量目标面积小于32×32像素,有的甚至只有十几个像素。严格按相同数据增强、相同训练轮数对比,结果如下:
| 方法 | AP50 | AP75 | AR100 |
|---|---|---|---|
| YOLOv11基线 | 54.2 | 36.8 | 71.3 |
| + FAAFusion | 56.4 | 40.1 | 74.2 |
| 提升 | +2.2 | +3.3 | +2.9 |
AP75涨了3.3个点,这个我很满意。小目标检测的难点从来不是“认出类别”,而是“把框定准”。AP75对框的IoU要求更高,能涨说明高频细节确实被更完整地保留到了后面几层,边框回归拿到了更锐利的边缘响应。
4.2 密集目标:角度对准带来的回归稳定性
第二个场景选择的是俯瞰视角的密集目标数据,目标挨得近、遮挡严重。密集场景里我重点看回归稳定性和召回率:
| 方法 | mAP50 | mAP75 | AR100 |
|---|---|---|---|
| YOLOv11基线 | 61.7 | 42.3 | 69.1 |
| + FAAFusion | 63.9 | 45.6 | 72.4 |
| 提升 | +2.2 | +3.3 | +3.3 |
实际观察检测结果,最直观的变化是重叠目标的边界不再“粘”在一起。之前漏检,常常是两个遮挡目标只出一个框,谁遮住谁都分不清。FAAFusion把高频边界重新对准后,两个目标的轮廓能分开,回归头才有力气为各自输出独立边界。
4.3 旋转目标:从水平框到旋转框的适配
第三个场景,我把检测头换成了旋转框版本,其余不变。公开遥感旋转框基准上的结果:
| 方法 | 旋转框mAP50 | 旋转框mAP75 |
|---|---|---|
| 旋转框YOLOv11基线 | 52.1 | 38.7 |
| + FAAFusion | 55.8 | 43.2 |
| 提升 | +3.7 | +4.5 |
这个场景的提升是所有场景里最大的。原因也很好理解:旋转目标检测最大的敌人是特征方向不一致,FAAFusion在特征层面就把高频细节“转正”了,后面负责角度回归的头部拿到的是对齐后的特征,角度学习自然轻松得多。而且我并没有改动任何旋转框的回归损失,纯粹是特征输入的改善,这算得上性价比很高的优化。
4.4 我最想回滚的四个坑
再牛的模块,工程落地都是一路踩坑过来的。这几个问题我分别单独列出来,希望能帮你少走弯路。
坑一:混合精度下一轮训练就NaN。
一开始我图快,把FAAFusion整个放进AMP,结果第一个epoch就崩了。排查下来是复数谱的abs()和归一化除法在FP16下溢出。解决方法是让模块内部强制FP32,并且对分母做clamp_min(1e-8)。这属于必踩坑,如果你准备复现,建议直接绕过。
坑二:训练前期震荡,loss降不下去。
原因是角度估计器在前几个epoch乱猜,高频支路被反复“转来转去”,梯度方向不稳定。我的做法是冻结角度估计20轮,这20轮里角度恒为0,只学习频率权重;20轮之后打开角度搜索,训练曲线就平稳了。
坑三:P5层插入后掉点。
前面已经提过,P5分辨率太低,高频信号本身就已经没多少可增强的。这不是模块不好,而是不该用在这个位置。只留P3/P4,结果立刻正常。
坑四:推理速度被角度搜索拖累。
如果训练和推理都跑角度搜索,单张图推理要多个4毫秒以上。后来我把推理时的角度固定为EMA平滑值,搜索只在训练时跑,速度才回到了可接受范围。如果你的场景目标朝向相对固定,直接固定角度0就行。
5. 继续往下走的方向与个人体会
5.1 后续扩展
FAAFusion目前只做了“低频/高频”两档加权和单个相对角度估计。如果继续做,有几个方向我自己觉得很有潜力。一是把频率半径拆成多频段,比如低、中、高三个频段各自学习权重,理论上对中等尺度目标更友好。二是把角度估计从“单一全局角”升级成“通道级角度”,让不同通道适应不同朝向的特征,但计算量会翻倍,需要权衡。三是把模块从Neck前移到骨干输出的部分位置,用更早的特征做频率融合,看能否进一步提升小目标召回。
另外,FAAFusion不一定要限制在YOLOv11上。我试着把它接到其他单阶段检测器的Neck里,只要输入是两路可对齐的特征,基本都能拿到类似收益。它本质上是给跨尺度融合增加了一个“频率视角”,跟具体检测头耦合度很低。
5.2 最后一次复盘:该不该上车FAAFusion
按我自己的经验,如果你只是做常规目标检测,目标普遍超过40×40像素,那FAAFusion的收益会比较有限,没必要为了涨点而涨点。但如果你面对的是小目标密集场景、无人机视角、遥感旋转目标,或者目标本身存在明显的朝向分布不均,那这个模块的性价比就非常高——改动量不大,只动Neck融合,收益明细可复现。
最后一个小技巧:做消融实验时,不要只盯着mAP50看,一定要同时看AP75和AR100。FAAFusion在mAP50上的涨幅往往只有2个点左右,但AP75和AR100的提升更明显。这恰恰说明它改善的不是“大致框个位置”,而是“把边界和朝向真正对齐了”。如果只看mAP50,你很可能会错判这个模块的价值。