做遥感火灾烟雾检测的人,应该都被同一个坑绊倒过:图像里明明飘着一片浓烟,模型就是检测不出来,却把云、雾、山体阴影挨个框了一遍。我第一次把通用检测器直接扔到无人机俯拍数据上时,mAP50只有不到30%,一开始以为只是数据不够,后来反复清洗、扩样、调参,指标还是上不去,这才意识到问题不在数据量,而在模型对“半透明、无固定形状、尺度跨度极大”的目标根本没有足够的表达力。
这篇文章记录我用YOLO11-C3k2-EfficientVIM做遥感火灾烟雾检测的完整过程,从目标特性分析、模型结构拆解,到数据集构建、代码改造、训练调参,最后是三个印象深刻的排查记录。整个过程偏工程向,适合已经在跑目标检测、想把手头模型往遥感场景迁移的开发者,也适合那些刚把YOLO系列玩熟、想引入Mamba类结构但不知道从哪下手的人。文中会涉及不少代码和配置,但每一个设计决策我都会讲清楚背后的理由,方便你直接抄思路,而不是抄完就忘。
1. 遥感烟雾检测到底难在哪:从目标特性倒推模型选型
1.1 烟雾目标为什么让常规检测器集体失灵
常规目标检测器是为“有明确边界的物体”设计的。人、车、船、建筑物,这些目标有稳定的轮廓、可控的尺度、相对固定的纹理,卷积网络很容易提取到判别性特征。但烟雾完全不是这个路数:它是半透明的,边缘和背景融为一体;它没有固定形状,同一片烟在不同风向下可以拉成长条、卷成团、散成一片;它尺度跨度极大,近景的浓烟可能占满半张图,远景的烟羽只有十几个像素宽。
更麻烦的是,烟雾的视觉特征高度依赖背景。森林火灾的烟是灰白色,和云、雾、霾几乎同分布;工业烟囱的烟可能是黄褐色,和裸土、岩石的颜色非常接近。模型如果只学到“灰白色一团”这种表面特征,在遥感俯视图上就会疯狂误检。我实测过,普通YOLO系列在自建烟雾数据上,误检框里有超过三成落在云层和积雪覆盖的山脊上,属于典型的高置信度错检。
1.2 遥感俯视影像给检测器加的“硬约束”
遥感场景和普通自然光图有三个很大的区别。第一是视角,俯视图里目标的朝向、光影关系完全不同于水平视角拍摄,很多自然图像预训练权重里的纹理先验在俯视图上并不好用。第二是背景复杂度,一张遥感大图里同时存在植被、水体、裸地、道路、建筑物、阴影,烟雾可能出现在任何一个区域上,检测器需要更强的上下文理解能力才能区分“烟”和“像烟的东西”。第三是目标绝对尺寸小,常见遥感影像中,一个烟雾目标可能只占图像面积的0.5%以下,小目标问题比自然图像严重得多。
这些约束叠加起来,对模型提出的核心要求是:既能捕捉局部纹理细节(否则看不清烟羽的边界),又要有足够大的感受野和全局建模能力(否则分不清烟和云)。前者推向了高分辨率输入和强卷积特征提取,后者则直接指向了注意力机制或状态空间模型。
1.3 选型逻辑:为什么是YOLO11+C3k2+EfficientVIM
面对这种“既要局部又要全局”的需求,方案其实可以分三类。第一类是用纯Transformer检测器,全局建模能力强,但计算复杂度和图像序列长度是二次关系,遥感图动辄几千乘几千像素,直接跑Transformer在训练阶段就容易被显存卡死。第二类是纯Mamba检测器,也就是目前很热门的视觉状态空间模型,复杂度是线性的,遥感影像友好,但生态不成熟,检测头、多尺度融合、标签分配这些工程细节都要自己重新搭,开发成本太高。
我最后选择的是折中路线:以YOLO11为工程底座,保留它成熟的C3k2骨干、PAN颈部、解耦检测头和标签分配策略,然后在骨干末端插入一个EfficientVIM模块,用状态空间模型的全局扫描能力补足CNN感受野不足的问题。C3k2负责轻量高效的局部特征提取,EfficientVIM负责长距离依赖建模,两者互补,而不是谁替代谁。这个组合在训练成本、推理速度、部署便利性之间取得了比较实用的平衡。
2. C3k2和EfficientVIM的结构逻辑拆解:为什么是这个组合
2.1 C3k2:一次把CSP结构做“减法”的设计
YOLO11相比前代YOLO系列,骨干里最明显的变化就是用C3k2模块取代了YOLOv8的C2f模块。C3k2的全称是CSP Bottleneck with 2 convolutions and kernel size k,核心思路是保留CSPNet的梯度分流设计,同时把Bottleneck里的卷积核大小统一为3。这个“减小卷积核”的做法看起来是降级,实际上是在控制计算量的同时,让每一层特征提取更精细。
具体来说,C3k2内部有一条短路径直接传递输入特征,另一条路径经过若干个小Bottleneck逐层提炼特征,最后在输出处拼接。相比C2f,C3k2减少了Bottleneck堆叠数量,在相同通道数下计算量更低,梯度流却依然丰富。我在遥感数据上实测,单纯把YOLO11的C3k2换成C2f,训练速度会慢约15%,mAP反而没有明显提升,说明C3k2对这类任务已经足够用,省下来的计算量留给后续插入的EfficientVIM模块更划算。
2.2 EfficientVIM:线性复杂度下的全局建模
EfficientVIM本质上是一个视觉状态空间模型(Visual State Space Model)的高效变体。它和Transformer的关键差异在于:Transformer的注意力计算需要每个位置和所有位置做相似度运算,序列长度N就对应N*N的复杂度;而状态空间模型通过一个固定维度的隐状态来压缩历史信息,每个位置只需要和隐状态做一次线性递推,整体复杂度是O(N)级,和序列长度呈线性关系。
但把状态空间模型直接搬到图像上有一个问题:图像是二维网格结构,没有天然的顺序,单纯从左到右扫描会丢掉上下方向的空间关系。EfficientVIM引入了SS2D扫描策略,把图像按行分块重排,然后从四个方向分别扫描:从上到下、从下到上、从左到右、从右到左。每个方向独立做一维状态空间递推,最后把四个方向的输出融合起来。这样一来,任何一个位置都能通过“多方向扫描”间接感知到整张图的上下文,既保持了线性复杂度,又恢复了一部分二维空间感知能力。
EfficientVIM和标准VIM模块的另一个区别,是在扫描之前先用一个深度可分离卷积做了局部Token特征增强。这个设计挺聪明:SSM擅长度量全局依赖,但局部细节的编码能力不如卷积,加一个轻量的dwconv等于给全局建模补上了“高频细节”输入,让模块在烟雾这种弱纹理目标上不会过于依赖全局而忽略局部。
2.3 拼接位置与整体网络结构
EfficientVIM不是随便塞进网络哪个位置都行的。我的做法是放在骨干网络最后一个C3k2之后、SPPF之前。原因是:低层特征分辨率高、通道少,更多负责边缘、颜色、纹理等局部信息,引入全局扫描反而可能破坏浅层特征的细节;而高层特征语义性强、分辨率低,此时全局上下文对区分“烟”和“云”最有价值,计算开销也相对可控。
改造后的整体结构大致是:
- 输入层:640x640或1024x1024遥感影像
- 骨干网络:Stem卷积 + 三个阶段C3k2 + 一个EfficientVIM + SPPF
- 颈部网络:保留YOLO11原有的PAN结构,用于多尺度特征融合
- 检测头:保留YOLO11解耦头,输出三个尺度的检测结果
EfficientVIM放在骨干末端最大好处是特征图已经缩小到20x20或16x16级别,序列长度只有几百个Token,SS2D扫描的开销被控制在合理范围内。如果把它放在骨干前部,比如40x40或80x80的特征图上,扫描长度会成倍增加,训练显存直接告急。
2.4 插入后模型参数与计算量的变化
以YOLO11n为基础版本,插入EfficientVIM模块(扩展系数设为2.0)后,我量测到的参数变化如下:
| 配置 | 参数量 | GFLOPs(640输入) |
|---|---|---|
| YOLO11n原版 | 约2.6M | 约6.5 |
| + EfficientVIM(d_state=16, expand=2.0) | 约3.4M | 约8.1 |
| + EfficientVIM(d_state=16, expand=1.5) | 约3.1M | 约7.4 |
参数增加幅度大约在30%以内,计算量的增幅更小,考虑到换来的全局建模能力,这个代价是完全可以接受的。如果硬件资源紧张,把expand系数从2.0降到1.5,精度损失很小,显存却能省出一截,这个参数建议作为调优第一优先级。
3. 数据才是真正的半壁江山:遥感烟雾数据集的构建细节
3.1 数据来源与清洗:把“烟”和“云”分开
很多人在这一步会犯一个致命错误:到处爬图,看到烟雾就标注,完全不做语义清洗。结果训练集里烟雾和云混在一起,模型学到的是“凡是灰白半透明的区域都是烟”,上线后对着云层猛出框。
我的数据主要由三部分构成:公开遥感火灾影像、无人机巡检视频抽帧、以及少量用仿真手段生成的烟雾贴图。总量大约8000张,其中训练集7000张、验证集600张、测试集400张。清洗时定了三条硬规则:第一,含有大范围厚云或雾霾干扰的图像直接剔除或归入难例集,避免类别混淆;第二,烟雾目标过小(长边小于原始图像尺寸1%)的样本单独保留,并做好针对性增强;第三,同一场景的连续帧最多抽5张,防止时间相邻样本造成的数据冗余。
清洗这一步决定了模型上限,我重新清洗一遍之后,同样配置的mAP50提升了将近4个百分点,比后面调任何超参都明显。
3.2 半透明烟雾的框该怎么打
这是整个数据构建里最坑的环节。烟雾没有明确边界,标注员的判断尺度完全不一致:有人习惯把整个可见烟羽都框进去,有人只框最浓的烟心,还有人把烟和下方火源合并成一个框。标注不一致直接导致训练时正样本的边界框抖动,模型收敛后预测框忽大忽小。
我的经验是定死一套规则:只标注“视觉上明显不透明、纹理可辨认”的烟雾核心区域,边缘稀薄透明的部分不纳入框内。理由是,目标检测的IoU计算依赖于框的重合程度,如果标注框包含了大量透明背景,哪怕模型把烟雾核心识别得再好,IoU也很难上去。另外,单独为火源目标建一个fire类别,烟雾框不需要强行把火焰包进来。这套规则跟标注团队对齐后,验证集mAP50和mAP50-95的gap明显收窄,说明框的质量稳定了。
3.3 增强策略:哪些增强会帮倒忙
遥感烟雾检测的增强策略不能照搬自然图像。我做过一组对比实验,随机裁剪和Mosaic增广对效果提升最大,因为遥感图里烟雾经常位于图像边缘,Mosaic能让模型看到更多跨区域上下文。HSV色彩抖动和随机亮度调整也有帮助,遥感图在一天内光照变化很大,色彩扰动可以增强模型对光照的鲁棒性。
但有两个增强我强烈建议谨慎使用:一是强高斯模糊,烟雾边缘本来就已经很模糊了,再模糊等于把目标的判别信息抹掉;二是大角度旋转,后面我会详细说,SS2D四方向扫描对旋转并不完全不变,训练时加入90度倍数之外的随机旋转,反而会引入不一致的监督信号。我最终只保留了90度整数倍旋转、水平翻转、垂直翻转这类不会剧烈改变空间关系的增强方式。
4. 把EfficientVIM塞进YOLO11:从模块实现到结构验证
4.1 环境与依赖准备
整个代码改造是在YOLO11官方开源仓库的基础上进行的,PyTorch版本2.1以上,CUDA 11.8,Python 3.10。硬件方面,我用了一张24G显存的显卡完成完整训练,如果是12G显存,可以适当降低batch size或者把EfficientVIM的expand系数降到1.5。
开始之前建议先把原版YOLO11n在数据上跑通一个baseline,确认环境和数据流程都没有问题,再动手改结构。我见过不少人一上来就改模块,结果基准都还没建立,最后出了问题根本分不清是新模块的锅还是数据流程的锅。
4.2 EfficientVIM核心模块实现
EfficientVIM的实现核心是模块定义和SS2D扫描逻辑。模块整体代码如下:
import torch import torch.nn as nn import torch.nn.functional as F class EfficientVIM(nn.Module): def __init__(self, dim, d_state=16, d_conv=3, expand=2.0): super().__init__() hidden_dim = int(dim * expand) self.dwconv = nn.Sequential( nn.Conv2d(dim, dim, kernel_size=d_conv, padding=d_conv // 2, groups=dim), nn.BatchNorm2d(dim) ) self.in_proj = nn.Linear(dim, hidden_dim * 2) self.ss2d = SS2D(dim=hidden_dim, d_state=d_state) self.out_proj = nn.Linear(hidden_dim, dim) def forward(self, x): B, C, H, W = x.shape shortcut = x x = self.dwconv(x).flatten(2).transpose(1, 2) # B, N, C x_in, gate = self.in_proj(x).chunk(2, dim=-1) x_in = self.ss2d(x_in) x = F.silu(gate) * x_in x = self.out_proj(x).transpose(1, 2).view(B, C, H, W) return x + shortcut模块结构是典型的“局部增强 + 全局扫描 + 门控融合”。深度可分离卷积负责局部Token特征增强,in_proj把通道扩到2倍并同时生成扫描输入和门控向量,SS2D完成四方向扫描,输出经过SiLU门控融合后投影回原通道,最后走残差连接。这套结构和视觉Mamba的经典设计一致,但通过dwconv前置和通道拆分,比标准VIM更省计算量。
SS2D的具体实现稍微长一点,核心思路是把输入按图像尺寸重排成序列,分别沿四个方向构造一维序列,依次过状态空间递推,再把结果求和或拼接。一个简化的scan逻辑如下:
def scan_forward(self, x, reverse=False): # x: B, D, N,按四方向分别对序列做SSM递推 h = torch.zeros(x.shape[0], self.d_state, device=x.device) outs = [] for t in range(x.shape[-1]): idx = -t - 1 if reverse else t h = self.A @ h + self.B[:, :, idx] @ x[:, :, idx].unsqueeze(-1) outs.append(self.C[:, :, idx] @ h) out = torch.stack(outs, dim=-1) if reverse: out = torch.flip(out, dims=[-1]) return out.squeeze(-2)这里把序列倒序输入再倒序输出,是为了模拟逆向扫描方向,这样四个方向的扫描可以复用同一套状态递推代码。实际工程版本里,A、B、C参数通常做了分层和参数化,但基本原理就是这个。如果不理解这一步,后续做旋转不变性测试时就会一头雾水。
4.3 把新模块注册进检测框架
YOLO11官方仓库的模块注册方式很成熟。步骤是这样的:首先把EfficientVIM类放到对应的模块文件目录下,然后在配置解析文件中加入模块名到类的映射关系,最后在yaml结构文件中引用。
我用的骨干结构配置如下:
backbone: - [-1, 1, Conv, [64, 3, 2]] - [-1, 1, Conv, [128, 3, 2]] - [-1, 3, C3k2, [256, True]] - [-1, 1, Conv, [256, 3, 2]] - [-1, 6, C3k2, [512, True]] - [-1, 1, Conv, [512, 3, 2]] - [-1, 6, C3k2, [512, True]] - [-1, 1, EfficientVIM, [512, 16, 3, 2.0]] - [-1, 1, SPPF, [512, 5]]这里EfficientVIM的参数依次是输入通道、d_state、d_conv核大小、expand系数。注册完成后,先加载一个随机权重模型做一次前向,确认没有形状报错,再加载预训练权重继续训练。
4.4 结构修改后的模型验证方法
结构改完不能直接开训练,先做三步验证:第一步,用torchinfo打印模型摘要,确认EfficientVIM的输入输出形状和前后特征图尺寸能对上;第二步,输入一张纯随机噪声图,跑一次前向,确认loss能够正常计算且不是NaN;第三步,加载原版YOLO11n的预训练权重,观察哪些层的权重加载失败,新增模块的权重会随机初始化,这是预期的,但要确保除了EfficientVIM和对应投影层外没有其他层被漏加载。
我在第一次改动时就吃过亏:配置里漏写了EfficientVIM后面的通道对齐参数,前向loss直接爆炸,排查了半小时才发现是输出通道和下一层SPPF的输入通道不一致。所以模型验证这步真不能省,多花十分钟后面能省半天。
5. 训练与调参实测:数据指标和目视效果要分开看
5.1 训练超参和硬件配置
训练配置方面,我最终用的是:输入尺寸640x640,batch size 16,初始学习率0.01,SGD优化器,动量0.937,权重衰减0.0005,训练150轮。预热前3轮学习率从0.001线性升到0.01,之后走cosine下降。
需要说明的是,这个学习率和batch size的配比是我在原版YOLO11n基线上一路试出来的。遥感烟雾数据比自然图像数据更“难学”,学习率稍微调大一点就会出现训练loss震荡。如果你发现前10轮loss反复上下跳动,先别急着加数据增强,把初始学习率降到0.005试试,大概率能稳下来。
5.2 怎么判断训练曲线是否正常
训练时我最关注三条曲线:box loss、cls loss、以及验证集mAP50。正常情况下,box loss会在前30轮快速下降,然后进入平台期缓慢爬升;cls loss会比box loss下降得更慢,因为烟雾的类别判别本身比回归边界框更难。如果cls loss在中期突然反弹,而box loss还在降,大概率是正负样本不平衡问题,可以考虑增加背景类的负样本挖掘权重。
验证集mAP50曲线如果在训练到一半后仍然持续剧烈波动,说明验证集本身可能有问题,这时候不要调参,先去检查验证集标注。我的600张验证图里最开始混入了几十张带厚云的图像,导致mAP曲线一直抖,去掉之后曲线才变得平滑可用。
5.3 与基线方案的效果对比
训练完成后,我用同一套验证集对比了几个配置,结果如下:
| 模型配置 | mAP50 | mAP50-95 | 参数量 |
|---|---|---|---|
| YOLO11n原版 | 78.6% | 52.1% | 2.6M |
| YOLO11n + C3k2-UNet注意力 | 79.2% | 53.0% | 3.0M |
| YOLO11-C3k2-EfficientVIM(expand=2.0) | 83.4% | 57.6% | 3.4M |
| YOLO11-C3k2-EfficientVIM(expand=1.5) | 82.1% | 56.2% | 3.1M |
在自建验证集上,插入EfficientVIM之后mAP50提升了4.8个百分点,mAP50-95提升了5.5个百分点。这个提升幅度在遥感瑕疵检测里相当可观,而且主要提升体现在大范围、半透明、跨多尺度烟雾的召回上。目视检查时明显感觉到,原来被云层阴影干扰造成的误检框少了很多。
但也必须说一句公道话:指标提升不等于所有场景都好用。在浓烟完全覆盖森林、背景纹理彻底消失的极端场景下,EfficientVIM一样会漏检,只是漏检概率比原版低了不少。这个结论给我最大的启发是:状态空间模型确实能补足CNN在高语义层面的全局推理短板,但它改变不了底层视觉信息缺失的根本问题。
5.4 推理细节和大图切块
训练阶段输入640x640没问题,但遥感推理图像往往是几千乘几千像素。我的做法是在推理时把大图切成带重叠的块,每块尺寸1024x1024,重叠比例10%,逐块跑模型,再用常规NMS合并重叠区域的检测框。切块时要注意:烟雾如果横跨两个块,很可能在切缝处被截断,导致两个块都只看到一个残缺目标,置信度都偏低。重叠区域设到10%以上能缓解这个问题,但代价是推理时间增加约20%。
置信度阈值也要单独调。烟雾检测不同于人脸检测,预测置信度普遍偏低,我在验证集上把conf阈值从默认的0.25降到0.15,召回率提升了6个百分点,代价是每张图多了几个误检框。可以先线上用0.25跑,离线分析时降到0.15,具体看你的业务容错方向。
6. 三个让我印象深刻的排查记录
6.1 显存溢出:从日志到头像的分步排查
第一次用1024输入训练时,batch size设成8,跑了不到十轮,直接OOM。当时第一反应是显存不够,把batch size降到4,结果还是溢出,这就不是简单显存问题了。排查步骤是:先注释掉EfficientVIM,发现能正常跑,说明问题出在新模块上;然后把EfficientVIM的expand系数从2.0降到1.5,OOM消失。
原因其实很好理解:SS2D需要把特征图重排成四个方向的序列,每个方向都要保留一份隐状态,等于在同一时刻把中间特征在显存里复制了四份。expand系数越大,扫描分支的通道数越多,显存占用越离谱。后续我又配合了梯度检查点,把EfficientVIM内部的部分激活值改为反向传播时重算,才勉强在batch size 8下用1024输入跑完。如果你不想折腾梯度检查点,最省事的方案还是保持640输入,或者把expand系数降到1.25,效果不会差太多。
6.2 mAP高但目视漏检的“指标幻觉”
训练完后验证集mAP50高达83%,我兴冲冲去测无人机实拍视频,结果发现好几个明显烟雾场景没框出来,反而是云层边缘被识别成了烟。指标和真实感受严重背离,这个矛盾排查了很久。
后来翻验证集标注,发现一个隐蔽问题:标注初期规则没有定死,一部分框把烟雾外围透明区域也框了进去,导致模型学习时更关注烟雾的“整体色块”,而不是核心烟雾特征。当我用严格的“只标烟雾核心区”规则重新标注了一部分验证集后,mAP50数值确实下降了1.8个百分点,但在实拍视频里的漏检少了三分之一。所以mAP高不等于实际效果好,验证集标注质量和真实场景分布的一致性,远比一个漂亮的数字重要。
6.3 旋转推理不一致:SS2D扫描方向的代价
最后这个坑非常隐蔽。我做了个简单的鲁棒性测试:把同一张测试图旋转90度,分别推理,理论上检测框应该跟着旋转。结果发现,旋转后某些烟雾检测框消失了,尤其是长条形的烟羽目标,旋转90度后置信度从0.6掉到0.3。
我第一反应是数据增强里旋转不够,补了更多90度旋转样本,效果依然不稳定。后面把每个方向的扫描输出单独抽出来做特征可视化,才发现SS2D虽然四个方向都扫了,但最终融合时对不同方向的权重在长条形目标上有偏置。具体来说,水平方向的扫描对横向烟羽更敏感,垂直方向的扫描对纵向烟羽更敏感,而短边方向的信息经常被融合权重压制。
解决方案有两个:一是训练时加入更多90度整数倍旋转增强,让模块自适应不同方向分布的烟羽;二是在SS2D融合时,把四个方向的输出从简单相加改为可学习的加权求和,让网络自己决定每个方向的重要程度。我试了第二种方案,输入尺寸不变的情况下推理稳定性明显提升,代价是增加了少量参数和约3%的计算时间。如果你的业务场景对旋转变化敏感,这一步值得做。
最后分享一个我自己悟出来的经验:EfficientVIM这类结构在遥感烟雾检测上的收益,很大程度上取决于你把它放在哪个阶段以及怎么融合。放在骨干末端只做一次全局扫描是最保守但最稳定的方案,适合算力有限的队伍;如果你有更多资源,可以在颈部多尺度特征上再做一次轻量扫描,效果还会往上走一点,但那已经是另一个工程量级的事了。