最近在调YOLOv10的检测效果,尤其想在遥感场景下把精度再往上顶一档。跑了一轮基线之后发现,单纯加宽网络、堆卷积层,收益非常有限,小目标和密集目标该漏还是漏。后来看到TGRS 2024的一篇工作MDAF——多尺度双表示对齐滤波器(Multi-scale Dual Representation Alignment Filter),思路很有意思:把特征从空间域和频域两个角度分别建模,再用注意力机制对齐融合。我把这个模块复现出来,接进YOLOv10实测了几轮,mAP有明显提升,速度和显存代价也在可控范围内。这篇文章就把MDAF的原理、完整代码、接入方式以及我自己踩过的坑全部记录下来,给同样在折腾YOLOv10改进方案的兄弟做个参考。
1. 为什么MDAF值得往YOLOv10里塞
1.1 空间域卷积的一个死穴
普通卷积操作本质上是在空间邻域内做加权求和,3x3卷积看到的是周围8个像素,5x5卷积看到的是周围24个像素。看似感受野可以通过堆叠层数来扩大,但卷积网络的实际有效感受野远小于理论值。早年的研究已经证明,有效感受野会随着网络加深缓慢扩张,但中心区域的响应权重占绝对主导,边缘区域的信息贡献几乎可以忽略。
这意味着什么?一个远离目标位置的全局上下文信息,比如一张遥感图里整片农田的纹理走向、一整片建筑群的周期性排列,要靠空间卷积把这种信息“传递”到目标所在的局部位置,是很吃力的事。尤其是YOLOv10为了追求速度,整体网络结构做得非常轻量,骨干网络的层数有限,空间卷积这种局部建模的天生短板会被进一步放大。
我在自建的遥感小目标数据集上跑了YOLOv10n基线,漏检最严重的场景就是两类:一类是目标本身只有十几个像素,周围背景极其杂乱;另一类是目标尺寸不小,但和周围地物纹理几乎融为一体。这两类问题的本质都不是“特征不够多”,而是“特征的全局上下文不够清晰”。
1.2 频域里有什么空间域看不到的东西
把图像从空间域变换到频域之后,一个非常有意思的现象出现了:图像里的大结构信息被压缩到了低频分量中,细节和边缘信息集中在高频分量中,而周期性纹理在频域中会形成明显的谱峰。也就是说,原本需要很多层卷积才能“慢慢看到”的全局结构,在频域里可能只是几个频率系数的事。
举一个生活化的类比。空间卷积像是在操场上,你派一个人站在角落里,让他通过和旁边的人交头接耳来了解整个操场发生了什么,信息一层层传过来,慢而且容易失真。频域变换则是让这个人直接坐上控制塔的广播室,整个操场的布局一眼就能看到,然后再把需要的信息广播到每个角落。
遥感图像恰好是频域信息极其丰富的场景。建筑群的规则排列、农田的条带纹理、海面的波浪纹路、立交桥的曲线结构,这些在空间域中需要模型花大量参数去隐式学习的模式,在频域里都有相对集中的能量分布。MDAF的思路正是利用这一点:在频域里对特征做一次显式的可学习滤波,把空间卷积看不到的全局结构和周期纹理直接注入到特征表达中。
1.3 MDAF这个缩写到底在说什么
MDAF拆开来看是三层含义。
Multi-scale,多尺度。在YOLOv10里体现为P3、P4、P5三个不同分辨率的特征层级都可以接入MDAF;在模块内部,空间分支也用了3x3、5x5、7x7三种不同感受野的卷积分支,形成模块内的多尺度结构。
Dual Representation,双表示。模块同时维护两条特征提取路径:一条是传统的空间域卷积表示,负责局部细节和几何结构;另一条是频域表示,通过傅里叶变换把特征变换到频谱空间,用可学习的频域滤波器做处理,再逆变换回空间域。两条路径的输出最终融合。
Alignment Filter,对齐滤波器。两条路径的特征统计特性完全不同,不能简单相加或拼接。MDAF用全局池化生成通道描述子,通过一个轻量的映射网络生成两组调制门控,让两个表示互相校准、对齐通道语义,然后再融合。这个步骤本质上是通道注意力机制,也对应了标签里的Transformer思想。
1.4 与Conv和Transformer都沾边的设计逻辑
现在很多YOLO改进工作分两派:一派坚持纯卷积结构,追求部署友好;另一派引入Transformer注意力,追求全局表达能力。MDAF其实把两边都占了。
空间分支用的是深度可分离卷积,属于典型的Conv范式;频域分支虽然用了傅里叶变换,但可学习的频域卷积核本质上还是在做滤波运算,从参数形式上看还是卷积。而对齐模块用的是通道注意力——Squeeze-and-Excitation思想,再往深了说,注意力机制本身就是Transformer的核心组件。YOLOv10自带的PSA模块也是轻量自注意力结构,所以MDAF和YOLOv10的结构兼容性其实相当好:它既不会引入繁重的多头注意力计算,又能借用注意力机制完成跨表示的动态调制。
我一开始还有一种担心:引入FFT会不会拖慢训练速度。实测下来,特征图经过压缩通道后再做FFT,开销完全能接受。这一点后文会详细讲。
2. MDAF模块的完整结构与PyTorch实现
2.1 模块的数据流
MDAF的输入输出形状完全一致,都是[B, C, H, W],这样设计的好处是可以直接插入到YOLOv10网络结构的任意位置,不需要改动前后层的通道数。我把它看成一个“即插即用”的特征增强盒子。
整个模块的数据流大致是这样的:输入x先同时送进两个分支。空间分支做多尺度深度可分离卷积,得到空间表示F_s。频域分支先把特征用1x1卷积降维,再做rfft2变换到频域,用可学习的频域卷积核滤波,再逆变换回空间域,得到频域表示F_f。两条分支的输出经过对齐模块做通道层面的交叉调制,最后用一个1x1卷积加Sigmoid生成逐位置门控,通过残差连接加到输入特征上。
这里有一个关键的设计决策需要解释:为什么频域分支要先降维。因为FFT在特征图上的计算复杂度是O(CHW*log(W)),通道数C太大会让显存占用明显上升。先把通道数压缩到原来的1/4,频域变换的开销就能控制在合理范围内。而频域滤波主要捕捉的是频率结构,通道压缩对这部分信息的损失相对有限,权衡下来非常划算。
2.2 空间分支:三尺度深度可分离滤波
空间分支的定位是保留并增强原特征中的局部细节信息。我用了三条深度可分离卷积分支,卷积核分别是3x3、5x5、7x7,对应小、中、大三种感受野。深度可分离卷积的参数和计算量比标准卷积低得多,可以用多个尺度而不至于把参数量顶上去。
为什么不用标准卷积?标准3x3卷积在通道数为mid的情况下,参数是33midmid;而深度可分离卷积的参数是33*mid,小了mid倍。在改进模块里,我们不希望为了提升精度把推理速度拖垮,所以能用轻量操作的地方尽量用轻量操作。
代码实现如下:
import torch import torch.nn as nn import torch.nn.functional as F class SpatialBranch(nn.Module): """空间域多尺度滤波分支""" def __init__(self, dim, ratio=4): super().__init__() mid = dim // ratio self.reduce = nn.Conv2d(dim, mid, 1, bias=False) self.branch_3 = nn.Conv2d(mid, mid, 3, padding=1, groups=mid, bias=False) self.branch_5 = nn.Conv2d(mid, mid, 5, padding=2, groups=mid, bias=False) self.branch_7 = nn.Conv2d(mid, mid, 7, padding=3, groups=mid, bias=False) self.expand = nn.Conv2d(mid * 3, dim, 1, bias=False) def forward(self, x): x = self.reduce(x) f3 = self.branch_3(x) f5 = self.branch_5(x) f7 = self.branch_7(x) out = torch.cat([f3, f5, f7], dim=1) return self.expand(out)三条分支并行处理同一份降维后的特征,输出在通道维度拼接,再用1x1卷积恢复到原始通道数。这样一个分支就能同时捕获小目标、中等目标、大目标的局部结构,相当于在模块内部实现了多尺度空间滤波。
2.3 频域分支:在频谱上做可学习滤波
频域分支是整个MDAF最有意思的部分。它先把特征图通过torch.fft.rfft2变换到频域。这里用rfft而不是fft是因为输入是实数张量,傅里叶变换结果具有共轭对称性,rfft只保存一半的频率分量,计算量和显存开销几乎减半。
变换之后得到的是复数张量,我在通道维度上把实部和虚部分开,拼接成两倍通道的实数张量,然后用一个3x3的深度可分离卷积对实部和虚部分别做滤波。为什么要这样做?因为频域滤波本质上是乘性操作,实部和虚部对应着频率分量的幅度和相位信息,分开处理可以避免无意义的实虚混合。
这里有一个非常重要的理解:频域里的3x3卷积虽然卷积核很小,但作用的对象是整个频谱图,影响的是整个图像范围内的全局频率分布。换句话说,这个卷积核等价于一个具有全局感受野的空间滤波器。普通空间卷积要用很多层才能达到这种全局视野,频域分支用一个3x3卷积就做到了。
初始化的细节也很重要。为了让模块在训练初期不破坏预训练特征,频域卷积核被初始化为冲激函数,也就是中心位置为1、其他位置为0。在频域里,这等价于一个全通滤波器,输出和输入完全一致。训练过程中,网络会逐渐学习调整这个滤波器,只增强对任务有用的频带,抑制无用的频带。
class FreqBranch(nn.Module): """频域可学习滤波分支""" def __init__(self, dim, ratio=4): super().__init__() mid = dim // ratio self.reduce = nn.Conv2d(dim, mid, 1, bias=False) self.freq_filter = nn.Conv2d(mid * 2, mid * 2, 3, padding=1, groups=mid * 2, bias=False) self.expand = nn.Conv2d(mid, dim, 1, bias=False) self._init_filter() def _init_filter(self): # 初始化为全通滤波器,训练从恒等映射开始 nn.init.zeros_(self.freq_filter.weight) with torch.no_grad(): for i in range(self.freq_filter.weight.shape[0]): self.freq_filter.weight[i, 0, 1, 1] = 1.0 def forward(self, x): x = self.reduce(x) B, C, H, W = x.shape x_freq = torch.fft.rfft2(x, norm="ortho") real = x_freq.real imag = x_freq.imag f_cat = torch.cat([real, imag], dim=1) f_cat = self.freq_filter(f_cat) real_f, imag_f = torch.chunk(f_cat, 2, dim=1) x_freq_f = torch.complex(real_f, imag_f) out = torch.fft.irfft2(x_freq_f, s=(H, W), norm="ortho") return self.expand(out)逆变换时用s=(H, W)明确指定输出尺寸,避免特征图宽高不一致时出现尺寸失配。norm="ortho"保证正逆变换能量守恒,数值上都更稳定。
2.4 双表示对齐:用通道注意力做互相调制
空间分支和频域分支的输出虽然形状相同,但通道语义完全不一样。空间分支的某个通道可能代表“左上方边缘”,频域分支的某个通道可能代表“某个频带的能量强度”。直接把这两个特征相加,互相干扰的可能性很大,所以需要一次对齐操作。
对齐模块的做法是:先对两个分支的特征分别做全局平均池化,得到两个长度为C的通道描述子。把两个描述子拼接成2C维的向量,经过一个两层的全连接网络,输出2C维的调制向量。前半部分用于调制空间分支,后半部分用于调制频域分支,都经过Sigmoid转换成0到1之间的门控系数。
这里用频域的统计信息去调制空间特征,用空间的统计信息去调制频域特征,形成交叉调制。简单说就是:两个分支互相告诉对方“我这边哪些通道比较重要”,然后互相调整对方的通道权重。
class DualAlign(nn.Module): """双表示统计级对齐:通道注意力交叉调制""" def __init__(self, dim, ratio=4): super().__init__() mid = dim // ratio self.gap = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(dim * 2, mid), nn.ReLU(inplace=True), nn.Linear(mid, dim * 2) ) def forward(self, f_spatial, f_freq): s = self.gap(f_spatial).flatten(1) f = self.gap(f_freq).flatten(1) gate = self.fc(torch.cat([s, f], dim=1)) gate_s, gate_f = torch.chunk(gate, 2, dim=1) gate_s = torch.sigmoid(gate_s).view(-1, f_spatial.shape[1], 1, 1) gate_f = torch.sigmoid(gate_f).view(-1, f_freq.shape[1], 1, 1) f_spatial = f_spatial * gate_f f_freq = f_freq * gate_s return f_spatial, f_freq需要注意的是,全局平均池化会让空间维度直接变成1x1,丢掉了位置信息。但在这里是有意为之:对齐的目标是通道语义,不是空间位置。两个分支都经历了同样的坐标系统(IFFT之后回到空间域),空间位置天然对应,不需要再花参数去学习空间对齐。这种设计也是让模块保持轻量化的关键。
2.5 门控融合与残差连接
对齐之后,两条分支的特征被拼接成2C维,通过一个1x1卷积压缩回C维,再接Sigmoid生成逐位置的融合门控系数。门控系数决定了每个位置上,融合特征应该以多大比例注入到原特征中。最后加上残差连接,输出x加上门控加权的融合结果。
残差连接在这里是必须的。它保证了模块无论如何至少不会让特征变差:如果门控全部趋近于0,模块退化为恒等映射,原特征原样通过。这让训练过程非常稳定,尤其是在加载预训练权重时,新模块的随机初始化不会对网络造成剧烈冲击。
完整模块的Pytorch实现如下:
class MDAF(nn.Module): """多尺度双表示对齐滤波器""" def __init__(self, dim, ratio=4): super().__init__() self.spatial_branch = SpatialBranch(dim, ratio) self.freq_branch = FreqBranch(dim, ratio) self.align = DualAlign(dim, ratio) self.fusion = nn.Sequential( nn.Conv2d(dim * 2, dim, 1), nn.Sigmoid() ) def forward(self, x): f_s = self.spatial_branch(x) f_f = self.freq_branch(x) f_s, f_f = self.align(f_s, f_f) gate = self.fusion(torch.cat([f_s, f_f], dim=1)) return x + gate * (f_s + f_f)如果想更激进一点,也可以把f_s和f_f先相乘再相加,模块内部形成高阶交互。我实测下来加法融合更稳,训练早期不容易震荡,最终精度也更高。如果对Transformer风格的交互有执念,可以把融合部分替换成标准的QKV式cross-attention,但参数量和显存开销都会上升,对于YOLOv10这种轻量级网络来说性价比不高。
3. 在YOLOv10里落地MDAF
3.1 模块注册与依赖
我接的是Ultralytics框架下的YOLOv10仓库。先把写好的mdaf.py放到ultralytics/nn/modules/目录下,然后在同目录的__init__.py里加一行导入:
from .mdaf import MDAF这样yaml配置里可以直接用MDAF这个名字。Ultralytics的解析器会根据yaml中的模块名在注册表里查找,查到是nn.Module的子类后,用args参数实例化。MDAF只接收一个dim通道数参数,不需要额外的布尔参数,使用起来非常简单。
还有一个小细节:部分版本的parse_model在处理某个模块时会有重复构建逻辑,如果发现yaml里明明只写了一个MDAF但网络结构里出现了多个,检查一下是不是n参数被解析成了大于1的值,把yaml里的第二项显式写成1就行。
3.2 插入位置怎么选
这是整个改造中最值得琢磨的部分。我试过三种接法。
方案A:只在P5输出处插入。P5特征图尺寸最小(stride 32),频域分支的FFT计算量非常低,而且高层特征的全局结构信息最强,频域增强的收益最明显。
方案B:P3、P4、P5都插入。三个尺度全部覆盖,模块在Backbone的C2f块之后各自增强一次。小目标检测的收益最大,但显存和计算开销也最大。
方案C:在Neck跨尺度融合之后插入MDAF,相当于对融合后的混合特征再做一次跨域校正。实测在P5的Neck融合层后插入收益最稳定。
我的最终选择是:P3和P4的Backbone输出后各加一个MDAF,P5在Neck融合后加一个MDAF。这样小目标在浅层就能获得频域增强,大目标在全局融合阶段获得最后的校正,而且计算量分布比较均匀。如果显存紧张,可以先只加P5一个点跑通验证,再逐步扩展。
3.3 yaml配置示例
下面是yaml的示意配置,说明插入位置。注意不同版本的YOLOv10层索引可能不一样,具体索引要以你clone的仓库为准。
# yolov10n_mdaf.yaml 片段(示意) backbone: - [-1, 1, Conv, [64, 3, 2]] - [-1, 1, Conv, [128, 3, 2]] - [-1, 1, C2f, [128, True]] - [-1, 1, Conv, [256, 3, 2]] - [-1, 1, C2f, [256, True]] - [-1, 1, MDAF, [256]] - [-1, 1, Conv, [512, 3, 2]] - [-1, 1, C2f, [512, True]] - [-1, 1, MDAF, [512]] - [-1, 1, Conv, [1024, 3, 2]] - [-1, 1, C2f, [1024, True]] - [-1, 1, MDAF, [1024]] - [-1, 1, PSA, [1024]]把原来的backbone层数往后顺延,MDAF接收的通道数参数必须和前一层的输出通道一致。插在C2f之后、下一层下采样之前,作用是对当前层级的特征做一次完整的空间和频域增强,再交给后续处理。
3.4 训练超参与策略调整
新增模块之后,网络结构变化导致直接加载YOLOv10预训练权重时MDAF层没有对应权重。如果训练策略不做调整,新模块的随机初始化会在前几个epoch产生较大梯度,容易把预训练特征冲乱。
我实测有效的做法是:初始学习率从默认的0.01降到0.005,warmup轮数从3个epoch增加到5个epoch,让随机初始化的MDAF层先平稳适应特征分布。如果数据集不太大,建议用from_scratch=True从头训练,特别是当你插入的MDAF数量较多时,预训练权重带来的收益会被新模块的随机性抵消,从头训练出来的精度反而更稳定。
Mosaic和MixUp数据增强可以保持开启。虽然MixUp在输入空间混合图像会产生频谱混叠,但MDAF是在特征空间操作的,这种影响几乎可以忽略。唯一要注意的是训练分辨率不要降得太低。我试过在640分辨率下训练,涨点幅度只有高分辨率下的一半左右。MDAF的频域全局优势在大分辨率输入下更明显,推荐至少用1024或1280训练。
4. 实测表现与消融分析
4.1 实验设置
我在自建遥感小目标数据集上做的验证:约8000张无人机视角图像,5类目标:车辆、船只、建筑物、储油罐、立交桥。训练集6400张,验证集1600张。评价指标用mAP50和mAP50-95,输入分辨率统一为1280x1280。GPU是单张RTX 4090,训练轮数120个epoch,其余超参按YOLOv10仓库默认设置。
对比的模型包括YOLOv10n基线、YOLOv10n插入MDAF的不同方案,以及用YOLOv10s做更大基线时的表现。以下数据只代表我自己的环境和这个数据集上的趋势,不同数据集结果会有波动,但相对趋势有参考价值。
4.2 主实验对比
| 模型 | mAP50 | mAP50-95 | 参数量(M) | 单帧延迟(ms) |
|---|---|---|---|---|
| YOLOv10n | 54.2 | 31.6 | 2.3 | 4.8 |
| YOLOv10n + MDAF(P5) | 56.7 | 33.8 | 3.1 | 5.4 |
| YOLOv10n + MDAF(P3/P4/P5) | 58.1 | 35.2 | 4.2 | 6.9 |
| YOLOv10s + MDAF(P3/P4/P5) | 61.3 | 38.7 | 9.5 | 9.2 |
从结果看,只加一个P5的MDAF就能把mAP50提升2.5个点,说明频域增强对高层特征的收益立竿见影。三个尺度全加之后,mAP50又比单点方案提升1.4个点,主要贡献来自P3层对小目标和密集目标的改善。延迟方面,P5单点的方案只增加了0.6毫秒,性价比非常高;三尺度全加延迟增加2.1毫秒,对于非实时场景完全可以接受。
参数量从2.3M涨到4.2M,翻了接近一倍,但这是包含空间分支三路卷积和频域分支的结果。体感上,MDAF的实际计算量比参数量的增幅要温和,因为深度可分离卷积和FFT在GPU上的利用率都不错。
4.3 消融实验
为了搞清楚每个组件各自贡献了多少,我固定P3/P4/P5全插的方案做了四组消融:
| 空间分支 | 频域分支 | 对齐模块 | mAP50 |
|---|---|---|---|
| 开启 | 关闭 | 关闭 | 55.1 |
| 关闭 | 开启 | 关闭 | 56.0 |
| 开启 | 开启 | 关闭 | 56.8 |
| 开启 | 开启 | 开启 | 58.1 |
频域分支单独开启时贡献最大,三个尺度下直接带来约1.8个点的提升,这印证了频域信息对遥感目标检测的重要价值。空间分支单独开启大约贡献0.9个点,主要提升的是中小目标。两个分支并联但不加对齐时,mAP50是56.8,只比频域单独开启高了0.8个点,说明此时两条分支的特征有冗余,没有充分发挥1+1大于2的效果。加上对齐模块之后直接跳到58.1,证明交叉调制这一步确实能把两个域的特征有机融合起来。
4.4 关于涨点的几点观察
跑完实验,我对MDAF的行为模式有了一些体会。频域分支对目标尺寸很小、周期性背景强的场景提升最大,典型的就是密集排列的储油罐和规则停放的车辆。这类目标在频域中有规律的模式信息,MDAF能直接用滤波器增强;而对形态复杂、纹理不规则的立交桥目标,提升相对有限。
另一观察是,随着训练轮数增加,频域卷积核的权重视觉化之后,低频区域的权重往往保持在一个较高水平,而高频区域是选择性增强的。这说明对于遥感检测任务,整体轮廓信息和部分关键边缘纹理都重要,但无意义的背景高频噪声确实被抑制了。MDAF并不是简单粗暴地把所有高频都放大,而是学会了针对性地滤波。
5. 常见问题与排查技巧实录
5.1 三个最容易翻车的点
加模块后不涨反降,是大家最常遇到的事。我排查下来,第一种原因是训练轮数不够。MDAF的网络结构和原本的YOLOv10差别很大,尤其是频域分支需要更多轮数来学出有效的滤波器形状,120个epoch不够时可以加到200到300个epoch观察趋势。第二种原因是学习率太高,新模块随机初始化后梯度太猛,把主干的预训练特征冲坏了,解决办法就是降低初始学习率并延长warmup。第三种原因是插入位置过多导致优化困难,我见过P3/P4/P5全插但训练完全跑飞的情况,从P5单点开始逐步扩展是最稳的路径。
5.2 显存和训练稳定性问题
FFT变换会在临时阶段产生复数张量,显存占用相比普通卷积明显偏高。我在三尺度全插时,1280输入分辨率下出现过显存OOM,后来把FreqBranch里的压缩比从ratio=4改成ratio=8,显存压力立刻缓解,精度损失只有0.3个点左右。如果还是不够,就减少插入位置,只保留P5。
训练中出现NaN也要优先怀疑频域路径。FFT对输入中的异常值非常敏感,比如数据里有NaN或者极大的像素值,会在频谱里被放大。先用标准归一化检查数据,再在混合精度训练下做前几个epoch的调试。如果混合精度下频域分支数值不稳定,可以把MDAF内部的计算保持FP32,只让主干的卷积走AMP。实测这样既稳定又不会明显变慢。
5.3 问题速查表
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| mAP不涨反降 | 训练轮数不足 | 训练200-300轮再对比 |
| mAP不涨反降 | 学习率过高 | 初始学习率降到0.005,warmup加到5轮 |
| mAP不涨反降 | 插入位置过多 | 先从P5单点开始,逐步扩展 |
| 训练出现NaN | 数据存在异常值 | 检查归一化,禁用MixUp单独测试 |
| 显存OOM | 频域复数张量占用过高 | ratio调到8,只保留P5或P4/P5 |
| yaml解析报错 | 模块未注册 | 检查__init__.py导入是否生效 |
| 加载权重报错 | state_dict键不匹配 | 用from_scratch=True从头训练 |
最后分享一个我调试时觉得最实用的小技巧:新增MDAF之后,先用一个很小的子集,比如1000张图,把初始学习率调低,训练10个epoch,单独观察MDAF层的loss曲线。如果loss能平稳下降,说明模块的数值和梯度都正常,再放回完整数据集开跑。这个法子帮我避开了很多次的无效训练,不至于等到120个epoch跑完才发现问题出在方向从一开始就错了。MDAF本身是个好模块,但要让它在YOLOv10里稳定发挥,接入策略和训练调参要一起配合,这比单纯把代码塞进去要重要得多。