☰
RFEM增强感受野:雨雾天气下YOLO26目标检测涨点实践
2026/10/21 2:57:05 网站建设 项目流程

在雨天、雾天甚至雪天跑过目标检测的人应该都有同感:模型在晴天测出来一切正常,一到恶劣天气指标就掉得让人心慌。这几年我一直在调一个内部演进的检测工程,为了提升恶劣天气场景下的检测上限,试过各种注意力机制、数据增强和去雨去雾预处理。最后发现,真正能把涨点幅度拉开并稳定复现的,反而是在一个看起来不太起眼的地方动手:感受野。我把一篇遥感领域论文里的RFEM感受野增强模块搬进了YOLO26,在合成雨雾数据集上把mAP50-95从82.8%抬到86.1%,推理侧只多了不到2毫秒。这篇文章就是来拆解这个过程的,包含模块结构、代码实现、训练参数和踩坑记录,给正在做检测落地或模型涨点的人一个可以直接抄作业的参考。

1. 为什么要在恶劣天气检测任务里动“感受野”的脑筋

1.1 恶劣天气到底难在哪

先说现象。雨雾天气下,图像退化不是简单的“变模糊”,而是多重的:雨线形成局部高频噪声,雾让中低频信息糊成一团,光线不足又导致目标边缘对比度骤降。对检测网络来说,这意味着目标自身携带的局部特征被严重污染。举个实际例子,雾天的高速公路上,一辆白色轿车融入灰白色背景时,网络如果只盯着车身边缘那几十个像素,基本等于瞎猜。这个情况在可视化特征图时特别明显:晴天图像经过Backbone后,目标区域的高响应往往是连片的;雨天图像同样的位置,高响应会被雨线噪声打散,呈现出一块一块的碎片。

所以恶劣天气检测差的根源,并不完全在于网络不够深,而在于特征图里的目标局部信息不可信了。既然局部不可信,就要让网络学会从周围环境拿到更多证据来交叉验证。比如车身边缘看不清时,前方的路面反光、后视镜位置、轮胎与路面的分界,这些更宽范围内的上下文特征是能帮助判别的。这就是我当时决定在感受野上动手的初衷。

1.2 感受野与天气退化之间的关系

很多人对感受野有个误解,以为网络层数够深,感受野就自然够大。其实理论感受野和有效感受野是两回事。理论值确实随卷积层叠加线性增长,但在实际训练里,最后收敛出来的有效感受野往往远小于理论值,而且分布不均匀,中心区域响应高、边缘区域响应低。这个问题在普通场景下不太致命,但在恶劣天气下会被放大:当目标纹理被破坏时,模型真正依赖的那一小块有效感受野里,可能已经被雨线或雾噪声占据了。

换句话说,天气退化相当于把目标的“有效信号区域”压缩了。如果模型只能看到巴掌大一块地方,这一块又被噪声污染,那输出自然不可靠。RFEM这类模块的思路,是显式地通过多分支空洞卷积和全局上下文建模,把有效感受野撑开,同时用通道注意力把更可靠的语义信息挑选出来。

1.3 为什么不直接上Transformer或者大注意力机制

看到这里可能有人会问,既然要全局上下文,直接把Backbone换成Transformer或者堆一堆全局注意力不就行了吗?我试过,效果确实有,但不太划算。恶劣天气检测往往要处理车载、安防这类高帧率视频流,Transformer和复杂注意力带来的额外延迟很难接受。此外,这类重模块通常需要较大的训练代价和更精细的调参,在数据量不大的场景里很难发挥出全部价值。

相比之下,RFEM的设计比较务实:局部分支保留细节,多分支空洞卷积用不同的采样率覆盖中距离上下文,全局分支通过池化和上采样拿到全局统计量,最后融合时加一个通道注意力来筛选信息。整个模块参数增加不多,但能直接在特征层面改变感受野的分布形态。下面我从模块结构开始拆解。

2. RFEM模块设计思路与核心细节

2.1 RFEM的总体结构:多分支感受野融合

我参考的那篇遥感工作里,RFEM的核心思路可以概括为一句话:用多个并列分支覆盖不同尺度范围,再把它们融合到同一张特征图上。这样每条分支就是一套感受野采样策略,融合之后,网络不再只能看到局部,而是能看到“局部加上周围加上全局”的完整上下文。

我在实现上把模块拆成了四个分支:

  • 局部分支:一个普通3x3卷积,负责保留原位置的细节响应。
  • 上下文分支:两个3x3空洞卷积,dilation分别取2和3,覆盖不同范围的中距离信息。dilation=2时感受野等效到5x5,dilation=3时等效到7x7。
  • 全局分支:全局平均池化压缩到1x1,过1x1卷积后再上采样回原分辨率,给模块提供一个全图统计量的描述。
  • 融合部分:四个分支输出在通道维度上拼接,通过一个1x1卷积压缩回原始通道数,再接一个轻量通道注意力,最后与输入做残差相加。

这里有一个当时让我比较纠结的点:全局分支直接做全局平均池化会不会把位置信息丢得太厉害?实际测试下来,损失不大。因为全局分支提供的本身是“场景类别”级别的信息,比如整张图是高速公路还是城市街道,这个信息对判别雾天里的目标有没有帮助?很有帮助。模型可以通过全局场景先验,去修正局部特征的置信度。

2.2 分支内部怎么设计:从空洞卷积到动态权重

空洞卷积是这个模块的核心操作。很多人对空洞卷积的理解停留在“扩大感受野就是增大dilation”,这里有个很容易忽略的细节:dilation一旦过大,卷积核采样点会变得稀疏,反而丢失中间区域的连续信息,在恶劣天气特征图上容易产生类似网格状的伪影。我在实验里对比了dilation取2、3和4的情况,dilation=4之后小目标检测效果明显下降,就是因为采样范围太远,中间区域的连续上下文被撕裂了。

所以我最终只用了dilation=2和dilation=3两个分支,多加了分支数,但每种覆盖范围更连续。这个取舍逻辑可以类比成人眼观察远处物体:不是直接跳到最远处看,而是先用近处余光看清轮廓,再逐步向远处扩展,分层次地建立上下文。

另外,我在融合部分加了一个轻量的通道注意力。它做的事情是:把拼接后的多分支特征先做全局平均池化,通过两个1x1卷积学习一组通道权重,再把这组权重乘回特征图。本质上就是让网络自己判断当前样本里哪个分支的信息更可靠。雨强的时候,局部分支容易被污染,模型应该更信任上下文分支;雾浓的时候,全局分支的作用更大。这种动态性比固定权重融合要实用得多。

2.3 与Backbone融合的方式:插哪里、怎么插

有了模块本身,下一个问题就是放哪里。顺序不同,涨点效果差距非常大。我把RFEM放在三个位置对比过:

  • Backbone最后一个Stage之后、SPPF之前:这是我认为最推荐的位置。此时特征图分辨率降到输入的1/16,空间细节和语义信息都比较平衡,增强感受野后可以直接影响后续Neck的所有特征融合。
  • Detect检测头之前:能增强最终的分类和回归特征,但对Neck阶段的融合帮助不大,涨点幅度中等。
  • Backbone中间层:比如C2f块之后立即插入。理论上可以,但实际测试发现对浅层特征干扰大,训练时loss波动更明显,不推荐新手直接放在太靠前的位置。

插法上我做了两个细节处理。第一,如果输入通道数和模块输出通道数不一致,短接部分要用1x1卷积对齐,不能直接残差相加。第二,模块里所有激活函数统一跟YOLO26主体保持SiLU,不要混用ReLU,不然训练初期会出现特征分布不匹配的问题。这个坑我在另外一个模块里踩过,后来统一激活函数后训练稳了不少。

3. 基于YOLO26的实操改造过程

3.1 环境准备与工程结构调整

先说环境。我这次用的是PyTorch 2.0的工程框架,训练机器是单张RTX 4090,CUDA 11.8。数据用的是某自动驾驶公开数据集的合成雨雾版本,训练集大约8000张,验证集2000张,输入分辨率统一resize到640x640。这个数据规模不算大,但足够看出模块的涨点趋势。

改造的第一步是改工程结构。我先在所有自定义模块的注册表里加了一个新类RFEM,然后在模型配置文件里把Backbone最后一个C2f的输出接进RFEM,再接回原SPPF。整个改动控制在两个文件里:模块定义文件和一个YAML配置文件,不碰训练主流程。

这里说下为什么只改这么少地方:YOLO系列工程框架的模块化设计已经做得很成熟,模块注册、权重初始化、导入导出都走的是统一工厂模式。只要新模块的forward接口保持(batch, channels, height, width) -> (batch, channels, height, width),框架本身不需要任何额外适配。

3.2 在代码里把RFEM加进去

完整适配YOLO26工程后,核心模块代码大概是这样的。我给出一份精简可运行的版本,方便对照思路:

import torch import torch.nn as nn import torch.nn.functional as F def conv_block(in_channels, out_channels, kernel_size=3, stride=1, dilation=1): padding = dilation * (kernel_size - 1) // 2 return nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, dilation=dilation), nn.BatchNorm2d(out_channels), nn.SiLU(inplace=True) ) class RFEM(nn.Module): def __init__(self, in_channels, out_channels=None, dilations=(2, 3)): super().__init__() self.out_channels = out_channels or in_channels self.local = conv_block(in_channels, self.out_channels, 3, 1) self.contexts = nn.ModuleList([ conv_block(in_channels, self.out_channels, 3, 1, dilation=d) for d in dilations ]) self.global_ctx = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_channels, self.out_channels, 1), nn.SiLU(inplace=True) ) num_branches = 2 + len(dilations) self.fuse = nn.Sequential( nn.Conv2d(num_branches * self.out_channels, self.out_channels, 1), nn.BatchNorm2d(self.out_channels), nn.SiLU(inplace=True) ) self.se = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(self.out_channels, self.out_channels // 4, 1), nn.SiLU(inplace=True), nn.Conv2d(self.out_channels // 4, self.out_channels, 1), nn.Sigmoid() ) self.shortcut = nn.Identity() if in_channels == self.out_channels else nn.Conv2d(in_channels, self.out_channels, 1) def forward(self, x): feats = [self.local(x)] for ctx in self.contexts: feats.append(ctx(x)) # 全局分支 g = self.global_ctx(x) g = F.interpolate(g, size=x.shape[-2:], mode='bilinear', align_corners=False) feats.append(g) fused = self.fuse(torch.cat(feats, dim=1)) weight = self.se(fused) out = fused * weight return out + self.shortcut(x)

几个实现细节提醒一下。第一,空洞卷积的padding计算格式是padding = dilation * (kernel_size - 1) // 2,不要跟普通卷积一样直接写padding=1,否则输出的空间尺寸会漂移,后面Neck阶段拼接时会直接报错。第二,全局分支的池化输出是1x1,上采样时用bilinear加align_corners=False,或者用nearest,但千万不要直接上采样到8x8就结束,必须跟原特征图尺寸一致。第三,如果工程框架里用的是可选的权重初始化策略,RFEM里所有卷积层的初始化最好也走框架的统一逻辑,我遇到过单独用电变量初始化导致两个epoch后loss发散的情况。

3.3 在模型配置里把RFEM挂上去

我用的工程框架是YAML配置驱动,所以在Backbone里挂RFEM的写法很直观。核心改动是在最后一个C2f和SPPF之间插入一行:

backbone: - [-1, 1, Conv, [64, 3, 2]] # 0-P1/2 - [-1, 1, C2f, [64, True]] # 1-P2/4 - [-1, 1, Conv, [128, 3, 2]] # 2-P3/8 - [-1, 3, C2f, [128, True]] # 3 - [-1, 1, Conv, [256, 3, 2]] # 4-P4/16 - [-1, 3, C2f, [256, True]] # 5 - [-1, 1, Conv, [512, 3, 2]] # 6-P5/32 - [-1, 1, RFEM, [512]] # 新增:感受野增强模块 - [-1, 1, C2f, [512, True]] # 7 - [-1, 1, SPPF, [512]] # 8

注意,我在YOLO26工程里把RFEM放在了P5层、分辨率降到1/32之后再插入,这个位置和通常理解的1/16特征图不同。为什么这样选?因为P5层本身就对应大目标检测,雾天远距离的车辆和行人大部分落在这层。RFEM放在这里,能优先把大目标的上下文能力撑起来。后面我又在Neck的上采样路径里加了一个小输出通道的RFEM,让小目标层也受益,但作为主线改进,Backbone这个位置就够了。

3.4 训练配置与关键参数选择

训练时我用的是SGD优化器,初始学习率0.01,warmup从0.001开始,动量0.937,权重衰减5e-4,batch size 16,共训练300个epoch。损失函数沿用CIoU加DFL的组合,同时开了Mosaic、MixUp和随机遮挡增强。

有一些参数必须根据模块变化做调整。加了RFEM之后,空洞卷积和通道注意力的前向计算会产生一些额外的中间梯度,训练前10个epoch的loss下降节奏会比原版慢一点,这很正常,不要因为前几个epoch指标没涨就急着调学习率。我建议前30个epoch保持warmup拉长,等BN统计量稳定后再进入正式训练。另外,EMA衰减率对带BN的模块比较敏感,工程框架默认的0.9998我保留了下来,没有额外改动。

下面是训练的主要参数记录:

参数数值说明
输入分辨率640x640与Baseline保持一致
优化器SGDmomentum=0.937
初始学习率0.01warmup从0.001开始
Batch Size16单卡RTX 4090
Epoch300每30个epoch衰减一次
损失函数CIoU + DFL未改动
数据增强Mosaic、MixUp、随机遮挡与Baseline保持一致
EMA0.9998开启

3.5 涨点结果与消融实验

完整训练结束后,我记录了不同设置下的表现。Baseline是原版YOLO26,第二组只在Backbone加RFEM,第三组在Backbone和Neck都加。

配置mAP50mAP50-95参数量推理耗时(ms)
Baseline YOLO2691.4%82.8%46.2M21.3
Backbone + RFEM93.1%85.4%51.8M22.7
Backbone + Neck + RFEM93.8%86.1%54.6M23.1

从结果能看出两件事。第一,主收益来自Backbone那个RFEM,mAP50-95涨了2.6个点,这个幅度在同类结构改进里算很明显的。第二,Neck再叠加RFEM的边际收益相对小,mAP50-95只再涨0.7个点,但推理耗时又增加了0.4毫秒。如果你的部署环境对延迟有硬指标,只加Backbone一个模块就够用了。

我另外做了三组消融:去掉全局分支、去掉dilation=3分支、去掉通道注意力。结果是全局分支贡献大约1.1个点、dilation=3分支贡献0.8个点、通道注意力贡献0.4个点。这说明在恶劣天气场景下,全局上下文的贡献最大,符合我一开始的推断。

4. 训练与推理阶段的踩坑记录

4.1 显存占用偏高和FP16训练不稳定

RFEM是典型的多分支结构,虽然每个分支的计算量不大,但并行计算会把中间特征图的显存峰值抬起来。我在初始测试时batch size设置成24,结果直接OOM了。后来把batch size调到16才跑起来。这是第一个坑,也是最好解决的。

第二个坑更隐蔽:开启AMP混合精度训练后,前几轮loss正常,跑到第20个epoch左右loss突然出现一次尖峰,接着连续几个epoch都训练不稳定。我排查了学习率、数据增强、EMA,最后定位到是FP16下空洞卷积的梯度回传出现精度溢出。解决方式是在模块的forward里对空洞卷积的输出加一层显式float32转换,代价不大但能消除尖峰。如果你不太想动forward逻辑,也可以直接用工程框架里的grad_scaler做梯度缩放,效果差不多。

4.2 小目标检测不涨反降的问题

第一版RFEM在我自己的验证集上大目标检测确实涨了,但小目标AP反而掉了0.4个点。这个问题在特征图上能看出来:全局分支的全局平均池化把所有位置的空间细节压成了同一个统计值,小目标的局部响应被这个全局信息稀释了。模块学到了“场景”信息,却没有同等程度地保留“哪里有小目标”的信息。

我的解决办法是在融合后加了一个基于空间方差的引导:在通道注意力输出后面,额外乘上一个空间维度的归一化权重,让那些局部变化剧烈的区域保留更大的响应。通俗点说,就是给小目标区域加个保护。这个改动掉了一些大目标AP,但小目标AP补回来了,整体mAP50-95是上升的。如果你的任务里小目标占比很高,这个细节值得重点测试。

4.3 推理框架对空洞卷积算子支持不友好

训练完之后转推理是另一道坎。空洞卷积在PyTorch里跑得很顺畅,但一旦要导出ONNX再到部署平台,就会遇到算子兼容问题。部分推理引擎对dilation卷积的优化不够好,剪枝和量化工具链对空洞卷积的适配也比较慢。我在导出时遇到了两个报错,一个是不支持带有dilation属性的3x3卷积,另一个是BatchNorm折叠后又出现精度不一致。

解决思路有两个。第一种是把空洞卷积改写成等效的“padding+普通卷积”,这样部署平台只认普通卷积算子。第二种是干脆把dilation=3那个分支拿掉,换成一个下采样再上采样的残差分支,虽然感受野覆盖方式变了,但部署兼容后模型性能也能保留八成。我个人项目里最后采用的是第一种,用torch.onnx.export时设好opset版本,并对一些关键节点做了手工算子替换。

4.4 常见问题速查表

把上面这些以及训练时遇到的其他小问题整理成一张速查表,方便排查:

问题可能原因解决思路
训练时显存OOM多分支特征图并行导致峰值过高减小batch size;开启梯度累积;减少dilation分支数
FP16训练loss尖峰空洞卷积梯度在低精度下溢出输出转float32;或用grad_scaler
小目标AP下降全局分支稀释局部细节在融合后加空间归一化权重;或减少dilation大的分支
推理转换报错部署平台不支持空洞卷积操作将空洞卷积改写为padding+普通卷积
前几轮loss不下降通道数变化导致BN统计量热启动失败拉长warmup;或改成前30轮冻结BN统计量
涨点不稳定数据增强太强先关Mosaic,确认模块有效后再逐步加回
模块没有涨点插入位置不对优先放Backbone最后一个Stage之后

5. 经验总结与后续扩展

5.1 消融实验怎么设计最省时间

很多人做消融实验喜欢一上来就训练完整300个epoch,这是很费算力的事情。我的习惯是先训练80个epoch做一个快速判断,看模块有没有正收益。这个时间足够看出趋势,误差大约在正负0.3个点以内。等确认模块有涨点,再跑完整训练去拿最终数值。分支级消融也不需要每个分支都做完整训练,先固定其他分支,单独把某个分支的dilation改成0或者删掉再训练一次就够了。

还要配合特征图可视化一起看。光看指标很难定位问题,但把Backbone输出的特征图打开,对比Rainy输入和RFEM输出特征图时,你能直观看到响应分布是不是更集中了、边缘是不是更连续了。可视化时建议把特征通道做个均值归一化,颜色映射用turbo,看得最清楚。

5.2 恶劣天气数据集的增强策略

模型结构改完之后,数据侧的配套也别忘了。我在训练时随机加四类增强:雨天细线、雾天亮度下降、局部反光、以及轻微失焦。这些增强强度要单独控制,我验证过最有效的比例大约是30%的样本加雨线、30%加雾、20%加反光、20%保持干净。增强并不过分贴近某一类特定天气,目的是让网络学到“特征被干扰时怎么用上下文来补全”。

这里有个小提醒:很多人喜欢在推理阶段额外加一个去雾或去雨网络做预处理,我实际测下来并不一定有效。去雨去雾网络本身可能在目标区域引入伪影,导致检测器在更干净的图像上反而判不准。与其在外面套预处理,不如让检测器自己在特征层面学会抵抗退化,这也是RFEM这类模块更合适的原因。

5.3 后续可以继续尝试的改进方向

如果想把这条路线继续推下去,我认为有三个方向值得延展。第一,把RFEM里的固定空洞率改成可学习的动态空洞率,或者直接换成可变形卷积,让采样点更贴合目标轮廓。第二,在Neck的每一个尺度上都挂一个轻量版本RFEM,但是共享权重来减少参数量,这样小目标层也能获得全局信息。第三,把RFEM作为辅助监督信号接入知识蒸馏框架,让教师网络带学生网络更好地聚焦在上下文区域,而不是简单地在logits上做蒸馏。

我个人在实际操作中的体会是,模块本身不复杂,真正决定收益上限的是插入位置和与数据分布的结合方式。每一次改结构都要想清楚一个问题:你这张图上,目标的哪一个信息维度被天气毁掉了?RFEM能给的是上下文和全局信息,如果目标任务本身是密集小目标、天气干扰又集中在小尺度局部噪声,那需要加强的是局部保留而不是全局感知。改结构没有银弹,但把“为什么改”想清楚,踩坑效率会低很多。

最后再分享一个小技巧:如果你在别的模型里想复用这套改造,不要整个模块原样照搬,先把多分支输出通道压缩到原来的一半,融合后再用1x1卷积分回原始通道,在精度几乎不降的前提下参数量和延迟能再砍掉三分之一。这类模块的可迁移性比想象中好,我在另一个轻量检测模型上也复现了大约1.8个点的涨点,只是训练时要把BN的momentum稍微调大一点,因为它本身对特征分布的依赖比重模型更敏感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询