CAFR跨光谱注意力精修:红外可见光双光融合检测即插即用实践
做过红外 + 可见光双光融合检测的开发者,大概率踩过同一个坑:数据集上两路特征直接torch.cat或者逐元素相加,验证集 mAP 涨了一两个点,心里一松;换到另一个场景的数据集、或者换成一个光照更极端的夜晚子集,同样的融合方式直接掉点,甚至比单模态的 RGB 基线还差。这时候很多人会以为是数据量不够、标注不准,或者干脆怀疑双光本身没什么收益。
问题往往不在数据量,而在融合这一步做错了。可见光和红外成像是两种物理机制,一个是反射光成像,一个是热辐射成像;同一个像素位置上,两路的纹理、对比度、噪声分布完全不同。当你把两路特征在通道维度拼在一起时,网络学到的是"这两个张量一起出现的统计规律",而不是"红外这路补上了 RGB 那路缺的什么信息"。前者在训练集上很容易饱和,后者才是真正的跨模态增益。
这篇要聊的CAFR(Cross-Spectral Attention Feature Refinement,跨光谱注意力特征精修),正是把这件事从"拼接"改成"选择"的一类做法。它来自 AAAI 2025 的 OSSDet 相关工作,核心是个即插即用的特征精修模块:不动主干网络、不改损失函数、不依赖特定数据集,可以挂在 YOLO 系列(标题里的 yolo26 指新一代 YOLO 检测框架,模块本身与具体版本解耦)的 neck 融合节点上。本文不复述论文的实验表格,而是把它的机制拆开讲清楚,给出一个可以直接跑通的 PyTorch 实现,再讨论插在哪、怎么验证、什么情况下不值得插。
需要先说明一点:跨光谱注意力本身不是新概念,Transformer 里的 cross-attention 早就在用,多模态领域也有一堆变体。CAFR 真正值得关注的地方在于它的工程定位——它把自己做成了一个"可以塞进现有检测流水线、按需开关、单卡就能训练"的中间件,而不是一个需要重写整个网络结构的新模型。这个定位决定了它的落地成本和验证方式,也决定了什么样的团队适合引入它。
1. 为什么多光谱检测的融合层最容易白干
1.1 三路信息,一个张量:融合层到底在承担什么
先回到最朴素的场景。你有一台双光相机,同一时刻采到一张 RGB 图和一张红外图,两张图经过几何配准后尺寸一致,都是 640×640。现在要给 YOLO 喂数据,最直接的做法是把它们叠成 6 通道张量,或者训练两条 backbone 后在中层做融合。
看起来只是多了一路输入,实际上融合层要同时处理三件事:模态差异、配准误差、单模态噪声。模态差异是本质的——红外的目标边缘通常比 RGB 更"糊",高温物体在红外里有强响应、在 RGB 里可能一片黑;配准误差是工程引入的——双光相机有物理基线,视差、镜头畸变、时间不同步都会让两路特征在空间上错开几个像素;单模态噪声是场景相关的——夜间 RGB 几乎全黑,噪声占主导,红外的热反射、地面余温也会产生大量虚警。
把这三件事压给一个简单的concat或者add,等于要求 1×1 卷积自己去学会"哪些位置的红外特征可信、哪些位置的 RGB 特征该被忽略"。这个任务对参数量极小的融合层来说太难了,训练信号也远不够充分。于是结果就是:训练集上拟合了某一批场景的固定偏置,换个场景就不适用。
1.2 参数相加 ≠ 信息互补
这里有个很常见的误区值得单独拎出来说。很多人在做三尺度特征相加时,默认"两路都加了,信息量肯定更大"。但在特征空间里,两路特征的分布并不对齐:RGB 特征在白天可能有很大的激活值,红外特征在白天反而响应弱、在夜晚响应强。直接相加时,激活值大的那一路会主导结果,另一路的贡献被数值层面淹没。
这就是为什么 CAFR 这类模块不约而同地引入了门控和归一化:在融合之前先把两路的特征分布拉到同一量纲,再通过注意力决定"在这个位置上,该听谁的"。这个思路在 SE、CBAM 里都出现过,区别在于单模态的通道注意力只看一路特征自身的统计,而跨光谱注意力需要一路特征去"查询"另一路,这个查询关系才是跨模态的核心。
1.3 什么样的读者应该继续往下看
如果你正在做这几类项目,本文的内容大概率能直接用上:夜间的周界安防或森林防火监控,车载的夜间行人检测与障碍物识别,无人机载的电力巡检(用热成像找过热点),以及工业场景里的设备异常检测。共同点是目标在单一模态下不可靠,必须靠两路互补。
反过来,如果你的场景光照稳定、RGB 已经能稳定检出目标,引入双流带来的复杂度提升大概率换不回对应收益,这篇文章看看思路即可。判断标准很简单:单模态基线在你的目标场景里,召回率是不是明显低于别的场景。如果只是差一点点,加一路红外未必划算。
另外说明一下集成前提:CAFR 是双输入融合模块,原生 YOLO 是单输入网络,所以直接pip install ultralytics然后改个 yaml 是跑不起来的,必须先有双流的数据加载和 forward 改造。这个门槛很多文章会跳过,但它恰恰是落地时最花时间的地方。
2. CAFR 的核心机制:跨光谱注意力与特征精修
2.1 从自注意力到跨光谱注意力
先用一句话把自注意力和交叉注意力的区别说清楚。标准自注意力里,Query、Key、Value 都来自同一个张量,它在做的是"同一张特征图上,位置 A 该从位置 B 取多少信息"。而跨光谱注意力里,Query 来自 RGB 特征,Key 和 Value 来自红外特征(以及反向的一路),它在做的是"在这个空间位置上,RGB 的描述应该从红外特征里补充多少信息"。
这个方向性很重要。双向设计意味着不是简单的一方主导另一方,而是形成一个互相校正的闭环:RGB 分支用红外的信息修补自己在暗区的缺失,红外分支用 RGB 的纹理信息修补自己边缘模糊的问题。两路各自带着对方的信息往前走,再在最后做融合,比单向注入要稳。
实现上有一个细节会直接影响能不能跑起来:注意力的空间分辨率。如果在 40×40 的特征图上做全尺寸注意力,注意力矩阵就是 1600×1600,batch=8、8 个 head 的显存开销会直接把 24G 卡打爆。所以标准做法是先用一个带 stride 的卷积把 Key/Value 的空间维度降下来(论文里的sr_ratio就干这事),只在 Query 侧保留完整分辨率。这样注意力的输出仍然对齐原特征图,但计算量降一个数量级。
2.2 "精修"不是多余的一步
很多人看到模块名里的 Refinement 会觉得是凑字数,注意力做完就够了吧?其实不是。注意力机制的输出是一组加权后的聚合特征,它的特点是"从别处搬过来",而不是"在此处修正"。经过残差相加之后,特征图里会混入来自对侧模态的、可能错位的、甚至是对侧模态的噪声。如果直接拿去送进 PAN 或 FPN,这些噪声会被后续的上采样放大。
所以精修分支的作用更像是一个后置滤波器:用深度可分离卷积做局部特征聚合,再用通道门控和空间门控把注意力带来的"毛刺"压掉。通道门控决定"这个通道的跨模态补充到底可信不可信",空间门控决定"这个位置是不是真的需要融合"——比如天空区域的红外特征在夜间几乎是纯噪声,空间门控应该把它压到接近 0。
这两级门控的设计在实现上很轻量,但对稳定性影响很大。在后面的代码里我会把门控初始化为接近 0 的系数,让模块在训练初期近似恒等映射,这是让即插即用真正"插得进去"的关键技巧。
2.3 与常见融合方式的对比
| 融合方式 | 计算开销 | 是否建模跨模态关系 | 对配准误差的容忍度 | 落地难度 | 适用场景 |
|---|---|---|---|---|---|
| 通道拼接 + 1×1 卷积 | 极低 | 否,只学联合分布 | 低 | 极低 | 两路高度对齐、场景固定 |
| 逐元素相加 | 最低 | 否 | 极低 | 极低 | 几乎不建议单独使用 |
| SE / CBAM(各模态独立) | 低 | 否,只看自身统计 | 中 | 低 | 单模态增强,非真正融合 |
| 跨光谱注意力(本文 CAFR) | 中 | 是,双向查询 | 中高 | 中 | 光照变化大、模态互补性强 |
| 早期输入级融合(6 通道) | 低 | 弱,靠 backbone 隐式学 | 低 | 低 | 快速验证双光是否有收益 |
| 多模态 Transformer 整体改造 | 高 | 是 | 高 | 高 | 数据量充足、算力充裕的团队 |
这张表里最值得琢磨的是"对配准误差的容忍度"这一列。跨光谱注意力之所以比直接相加更耐受配准误差,是因为注意力本身带有一定的空间搜索能力:即使红外的目标位置偏移了两三个像素,Query 仍然能在 Key 里找到响应最强的位置,聚合结果不会完全错位。而逐元素相加在错位时会把两个不同位置的响应叠在一起,直接产生伪影。
3. 模块结构与关键设计取舍
3.1 数据流:从双路输入到单路输出
把整个模块的数据流按顺序拆开,一共五步:
第一步是分布对齐。两路特征进入模块后各自过一层 LayerNorm。这里不用 BatchNorm 是有原因的——双光数据集通常不大,batch size 受显存限制经常只能开到 8 甚至 4,BatchNorm 在这种规模下统计量抖动明显,而且推理时的 running stats 容易和训练分布偏离。LayerNorm 按通道归一化,与 batch 无关,在小数据集上更稳。
第二步是双向跨光谱注意力。RGB 作为 Query、红外作为 Key/Value 走一路;红外作为 Query、RGB 作为 Key/Value 走另一路。两路并行计算,输出维度与输入一致。
第三步是带系数的残差回注。注意力的输出不是直接替换原特征,而是乘以一个可学习的逐通道系数gamma后加回去。gamma初始化为一个很小的值(比如 0.1),意味着训练刚开始时模块近似恒等映射,不会破坏预训练权重的初始表现。这一点对"即插即用"至关重要:如果残差系数初始化为 1,训练前几个 iter 的梯度会非常大,预训练 backbone 的特征分布直接被冲垮。
第四步是融合与精修。两路修正后的特征在通道维拼接,用一个 1×1 卷积降回原通道数,然后进入精修分支做通道门控和空间门控。
第五步是输出归一化。精修结果与融合结果再做一次残差相加,过一层 LayerNorm 输出,保证输出张量的数值范围稳定,不会因为模块内部的多级运算而产生尺度漂移。
3.2 三个容易写错的细节
第一个是空间维度不匹配。双光相机的两路图像在配准后理论上尺寸一致,但实际工程里经常出现红外图被裁剪或缩放后差一两个像素的情况,尤其是下采样到 P3 尺度(80×80)时,除不尽的坑非常常见。稳妥的做法是在模块入口做一次尺寸校验,不一致就用双线性插值对齐,而不是让 PyTorch 在 einsum 时报一个看不懂的形状错误。
第二个是下采样比例的整除问题。sr_ratio=2意味着做一次 stride=2 的卷积,如果输入是 80×80 那没问题,但如果是 79×79,降采样后是 39×39,注意力的 Key 长度就变了,虽然数学上仍然成立,但要注意别在代码里写死尺寸。用flatten(2)而不是view就是为了避开这个坑。
第三个是归一化的位置。LayerNorm 在卷积网络里需要手动处理通道维度,直接套nn.LayerNorm会作用在最后一维(也就是宽),必须自己写一个[B, C, H, W]版本。下面代码里的LayerNorm2d就是这个用途。
4. 环境准备与前置条件
实操部分先说清楚环境,避免读者在版本问题上耗时间。本文的代码基于 PyTorch 生态,不依赖特定版本的 YOLO 框架,模块本身可以独立运行和单元测试。
推荐环境如下,具体版本请以你所在团队的既有环境为准,本文重点演示通用思路,不锁定版本号:
| 项目 | 建议 | 说明 |
|---|---|---|
| 操作系统 | Linux(Ubuntu 20.04 及以上) | Windows 也能跑,但分布式训练和多卡支持较弱 |
| Python | 3.9 ~ 3.11 | 低于 3.9 部分类型标注语法不可用 |
| PyTorch | 2.0 及以上 | 需要支持torch.einsum与混合精度训练 |
| CUDA | 与 PyTorch 版本匹配即可 | 注意力用 FP16 时注意数值稳定性 |
| 显存 | 单卡 16G 起步 | batch=8、imgsz=640、P3 尺度融合的情况下大致够用 |
| 训练框架 | Ultralytics YOLO 或自研训练脚本 | 模块本身与框架解耦 |
安装依赖只需要 PyTorch 基础包:
# 创建虚拟环境 conda create -n cafr python=3.10 -y conda activate cafr # 安装 PyTorch(请按你的 CUDA 版本选择对应命令) pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 # 可选:安装 ultralytics 用于后续集成 pip install ultralytics前置条件里有一条比环境更重要:两路数据必须完成几何配准。如果 RGB 和红外之间还存在明显的视差没有校正,跨光谱注意力学到的会是错误的对应关系,效果反而不如直接拼接。配准这一步不在本文范围内,但要强调它是一个硬前提,跳过它去调模块参数是徒劳的。
5. CAFR 模块完整 PyTorch 实现
5.1 核心模块代码
下面这份实现是完整可运行的,包含归一化层、跨光谱注意力、精修分支和整体模块四部分。文件放在models/cafr.py。
# 文件路径:models/cafr.py import torch import torch.nn as nn import torch.nn.functional as F class LayerNorm2d(nn.Module): """对 [B, C, H, W] 张量按通道做 LayerNorm。 卷积网络里直接使用 nn.LayerNorm 会作用在最后一维(宽), 这里手动实现,保证归一化发生在通道维度上。 """ def __init__(self, dim: int, eps: float = 1e-6): super().__init__() self.weight = nn.Parameter(torch.ones(dim)) self.bias = nn.Parameter(torch.zeros(dim)) self.eps = eps def forward(self, x: torch.Tensor) -> torch.Tensor: mu = x.mean(dim=1, keepdim=True) var = (x - mu).pow(2).mean(dim=1, keepdim=True) x = (x - mu) / torch.sqrt(var + self.eps) return self.weight[:, None, None] * x + self.bias[:, None, None] class CrossSpectrumAttention(nn.Module): """单向跨光谱注意力。 query 来自一路特征,key/value 来自另一路特征。 sr_ratio > 1 时对 key/value 做空间下采样,把注意力矩阵的 规模从 O(HW * HW) 降到 O(HW * HW / sr_ratio^2)。 """ def __init__(self, dim: int, num_heads: int = 8, sr_ratio: int = 2): super().__init__() assert dim % num_heads == 0, "dim 必须能被 num_heads 整除" self.num_heads = num_heads self.head_dim = dim // num_heads self.scale = self.head_dim ** -0.5 self.sr_ratio = sr_ratio self.q = nn.Conv2d(dim, dim, kernel_size=1, bias=True) self.kv = nn.Conv2d(dim, dim * 2, kernel_size=1, bias=True) if sr_ratio > 1: self.sr = nn.Conv2d(dim, dim, kernel_size=sr_ratio, stride=sr_ratio) self.kv_norm = nn.LayerNorm(dim) self.proj = nn.Conv2d(dim, dim, kernel_size=1, bias=True) def forward(self, q_feat: torch.Tensor, kv_feat: torch.Tensor) -> torch.Tensor: B, C, H, W = q_feat.shape # Query:保持完整空间分辨率 [B, heads, HW, head_dim] q = self.q(q_feat) q = q.reshape(B, self.num_heads, self.head_dim, H * W) q = q.permute(0, 1, 3, 2) # Key / Value:可选下采样,降低显存与计算量 kv = self.kv(kv_feat) if self.sr_ratio > 1: kv = self.sr(kv) Bk, Ck, Hk, Wk = kv.shape kv = kv.flatten(2).transpose(1, 2) kv = self.kv_norm(kv) kv = kv.transpose(1, 2).reshape(Bk, 2 * Ck, Hk, Wk) kv = kv.flatten(2) # [B, 2C, N] k, v = kv.chunk(2, dim=1) # 各 [B, C, N] N = k.shape[-1] k = k.reshape(B, self.num_heads, self.head_dim, N) v = v.reshape(B, self.num_heads, self.head_dim, N) attn = torch.einsum("bhqd,bhkd->bhqk", q, k) * self.scale attn = attn.softmax(dim=-1) out = torch.einsum("bhqk,bhkd->bhqd", attn, v) out = out.permute(0, 1, 3, 2).reshape(B, C, H, W) return self.proj(out) class FeatureRefine(nn.Module): """通道 + 空间双门控精修分支。 作用是压掉跨模态注意力带进来的错位响应与模态噪声, 避免这些噪声被后续的上采样放大。 """ def __init__(self, dim: int, reduction: int = 4): super().__init__() hidden = max(dim // reduction, 8) self.dw = nn.Conv2d(dim, dim, 3, padding=1, groups=dim, bias=False) self.pw = nn.Conv2d(dim, dim, 1, bias=False) self.norm = nn.BatchNorm2d(dim) self.channel_gate = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(dim, hidden, 1), nn.SiLU(inplace=True), nn.Conv2d(hidden, dim, 1), nn.Sigmoid(), ) self.spatial_gate = nn.Sequential( nn.Conv2d(2, 1, kernel_size=7, padding=3), nn.Sigmoid(), ) def forward(self, x: torch.Tensor) -> torch.Tensor: y = self.norm(self.pw(self.dw(x))) # 通道维门控:哪些通道的跨模态补充值得保留 y = y * self.channel_gate(y) # 空间维门控:哪些位置真的需要融合 sa_in = torch.cat( [y.mean(dim=1, keepdim=True), y.max(dim=1, keepdim=True)[0]], dim=1 ) y = y * self.spatial_gate(sa_in) return y class CAFR(nn.Module): """跨光谱注意力特征精修模块,即插即用。 Args: dim: 输入特征通道数,两路必须一致 num_heads: 注意力头数 sr_ratio: key/value 的空间下采样比例,1 表示不下采样 reduction: 精修分支门控的通道压缩比 init_gamma: 残差回注系数的初始值,小值让模块起步接近恒等映射 """ def __init__(self, dim: int, num_heads: int = 8, sr_ratio: int = 2, reduction: int = 4, init_gamma: float = 0.1): super().__init__() self.rgb2ir = CrossSpectrumAttention(dim, num_heads, sr_ratio) self.ir2rgb = CrossSpectrumAttention(dim, num_heads, sr_ratio) self.norm_rgb = LayerNorm2d(dim) self.norm_ir = LayerNorm2d(dim) self.norm_out = LayerNorm2d(dim) self.gamma_rgb = nn.Parameter(torch.full((dim,), init_gamma)) self.gamma_ir = nn.Parameter(torch.full((dim,), init_gamma)) self.fuse = nn.Sequential( nn.Conv2d(dim * 2, dim, kernel_size=1, bias=False), nn.BatchNorm2d(dim), nn.SiLU(inplace=True), ) self.refine = FeatureRefine(dim, reduction) def forward(self, rgb: torch.Tensor, ir: torch.Tensor) -> torch.Tensor: # 尺寸兜底:实际项目中红外图裁剪后差一两个像素很常见 if ir.shape[-2:] != rgb.shape[-2:]: ir = F.interpolate(ir, size=rgb.shape[-2:], mode="bilinear", align_corners=False) rgb_n = self.norm_rgb(rgb) ir_n = self.norm_ir(ir) rgb_ctx = self.rgb2ir(rgb_n, ir_n) ir_ctx = self.ir2rgb(ir_n, rgb_n) rgb_r = rgb + self.gamma_rgb.view(1, -1, 1, 1) * rgb_ctx ir_r = ir + self.gamma_ir.view(1, -1, 1, 1) * ir_ctx fused = self.fuse(torch.cat([rgb_r, ir_r], dim=1)) refined = self.refine(fused) return self.norm_out(refined + fused)这里的关键逻辑有三处。第一处是gamma_rgb/gamma_ir用nn.Parameter声明为逐通道向量而不是标量,这样不同通道可以学到不同的跨模态注入强度,实测比单一标量的收敛更稳。第二处是cross注意力的 Key/Value 下采样通过flatten(2)而不是view完成,避免输入尺寸除不尽时抛异常。第三处是最后的norm_out(refined + fused),用残差保证即使精修分支输出退化,模块整体也不会比直接融合差太多。
5.2 单元测试:先确认模块本身能跑
在接进任何检测框架之前,先用随机张量把模块跑通,确认前向、反向、eval 模式都正常。这一步能省掉大量"接进框架后报错但不知道错在哪"的时间。
# 文件路径:tests/test_cafr.py import torch from models.cafr import CAFR def test_forward_and_backward(): torch.manual_seed(0) # 模拟 P3/P4/P5 三个尺度的融合 configs = [ (256, 80, 80), # P3 (512, 40, 40), # P4 (1024, 20, 20), # P5 ] for dim, h, w in configs: cafr = CAFR(dim=dim, num_heads=8, sr_ratio=2).cuda() rgb = torch.randn(2, dim, h, w).cuda() ir = torch.randn(2, dim, h, w).cuda() out = cafr(rgb, ir) assert out.shape == rgb.shape, f"shape mismatch: {out.shape} vs {rgb.shape}" loss = out.mean() loss.backward() assert cafr.gamma_rgb.grad is not None, "gamma_rgb 没有梯度" print(f"dim={dim:>4d} size={h}x{w} out={tuple(out.shape)} " f"gamma_grad={cafr.gamma_rgb.grad.norm().item():.4e}") # eval 模式验证 cafr.eval() with torch.no_grad(): out_eval = cafr(rgb, ir) assert out_eval.shape == rgb.shape # 尺寸不一致的兜底校验 ir_bad = torch.randn(2, dim, h - 1, w - 1).cuda() out_fix = cafr(rgb, ir_bad) assert out_fix.shape == rgb.shape, "尺寸兜底失败" print(f" 尺寸兜底通过:{h - 1}x{w - 1} -> {h}x{w}") if __name__ == "__main__": test_forward_and_backward() print("CAFR 单元测试全部通过")运行方式:
python tests/test_cafr.py预期输出是三个尺度各打印一行形状信息、gamma 的梯度范数,最后打印"CAFR 单元测试全部通过"。如果 gamma 的梯度范数是 0 或者 nan,说明残差分支没有正确参与计算图,需要检查view(1, -1, 1, 1)那一行的广播是否正确。
5.3 接入检测网络的融合节点
这一步是落地时最需要因地制宜的部分。CAFR 是双输入模块,所以前提是你的检测器已经是双流结构。下面给出一个通用的融合节点写法,假设 backbone 已经能分别返回 P3/P4/P5 三个尺度的特征。
# 文件路径:models/dual_stream_fusion.py import torch import torch.nn as nn from models.cafr import CAFR class DualStreamFusion(nn.Module): """双流 backbone 的三尺度跨光谱融合节点。 假设 rgb_backbone 与 ir_backbone 结构一致,forward 均返回 [P3, P4, P5] 三个尺度的特征列表,通道数由 channels 指定。 """ def __init__(self, rgb_backbone, ir_backbone, channels=(256, 512, 1024), num_heads=8, sr_ratio=2): super().__init__() self.rgb_backbone = rgb_backbone self.ir_backbone = ir_backbone self.fusions = nn.ModuleList([ CAFR(dim=c, num_heads=num_heads, sr_ratio=sr_ratio) for c in channels ]) self.out_channels = list(channels) def forward(self, rgb, ir): rgb_feats = self.rgb_backbone(rgb) ir_feats = self.ir_backbone(ir) assert len(rgb_feats) == len(self.fusions), \ "backbone 输出尺度数与融合层数不一致" outs = [] for fuse, rf, irf in zip(self.fusions, rgb_feats, ir_feats): outs.append(fuse(rf, irf)) return outs class DualStreamDetector(nn.Module): """把融合输出接回检测头。 neck 与 head 直接复用你现有的实现,这里只演示接线方式。 """ def __init__(self, fusion, neck, head): super().__init__() self.fusion = fusion self.neck = neck self.head = head def forward(self, rgb, ir): feats = self.fusion(rgb, ir) feats = self.neck(feats) return self.head(feats)接线的思路就是把原来的neck(backbone(x))换成head(neck(fusion(rgb, ir)))。ir_backbone 的初始化有两种常见选择:一是直接复制 rgb_backbone 的预训练权重(两路都是三通道输入时可行),二是从零训练。前者收敛更快,后者在数据量充足时上限更高。如果红外是单通道图,复制权重时要处理第一层卷积的通道数,常见做法是把三通道权重按通道求和后再乘 1/3,或者直接取均值扩展。
5.4 训练命令与消融配置
用 Ultralytics 的时候,训练命令本身是标准的,关键是数据 yaml 和模型 yaml 需要你自己准备。数据 yaml 里要额外声明红外图的目录,这一点原生格式不支持,需要在自定义 Dataset 里读取。
# 基线:单模态 RGB yolo detect train \ data=configs/rgbt_rgb_only.yaml \ model=yolov8s.yaml \ imgsz=640 epochs=100 batch=16 device=0 \ project=runs/cafr_ablation name=baseline_rgb # 消融 A:双流 + 直接拼接融合 yolo detect train \ data=configs/rgbt_dual.yaml \ model=configs/dual_concat.yaml \ imgsz=640 epochs=100 batch=16 device=0 \ project=runs/cafr_ablation name=dual_concat # 消融 B:双流 + CAFR 融合 yolo detect train \ data=configs/rgbt_dual.yaml \ model=configs/dual_cafr.yaml \ imgsz=640 epochs=100 batch=16 device=0 \ project=runs/cafr_ablation name=dual_cafr三组实验除了融合模块不同,其余超参必须完全一致:相同的随机种子、相同的增强策略、相同的学习率调度。这一点看起来是常识,但实际做消融时因为显存不同而顺手调了 batch size,导致结论作废的情况非常多。
6. 运行结果与效果验证
6.1 怎么判断一次训练是不是 "成功"
跑通不全等于有效。建议按下面的顺序逐层验证,任何一层不通过都不要急着看 mAP。
第一层看数值健康度。训练前 10 个 epoch 打印gamma_rgb和gamma_ir的均值。如果初始化是 0.1,正常情况下它应该缓慢上升或下降,最终稳定在 0.05 到 0.5 之间。如果它一路冲到 2.0 以上,说明跨模态信息被过度依赖,通常是学习率偏大或者两路特征分布差距过大;如果它一直停在 0.1 附近几乎不动,说明梯度没有有效传过去,可能是 LayerNorm 的参数初始化或残差路径写错了。
第二层看训练损失曲线。CAFR 引入后,前 5 个 epoch 的 loss 通常会略高于纯 concat 版本,因为模块起步接近恒等映射、等效参数量增加了。但从第 10 个 epoch 开始应该追平甚至反超。如果 30 个 epoch 后 loss 还持续高于基线 0.5 以上,大概率是模块在拖后腿,先别调参,回去检查配准。
第三层看验证集指标的分模态表现。这是最有信息量的一步:把验证集按白天、黄昏、夜间分桶,分别算 AP。如果 CAFR 只在夜间提升、白天基本持平,说明它确实在补暗区的信息,这是符合预期的结果;如果白天掉点严重,说明注意力把 RGB 的清晰纹理覆盖掉了,需要调小gamma或者调大sr_ratio让注意力更"钝"一些。
6.2 预期输出与判断标准
一个比较健康的结果形态是:整体 mAP50 相比直接拼接有小幅提升,夜间子集提升更明显,白天子集基本持平或略降一点点。要求所有场景都涨是不现实的,跨模态融合本质上是拿一部分场景的表现去换另一部分场景,关键是总体加权收益为正,且最差场景没有崩掉。
推理速度方面要有心理准备。在 640 输入、P3/P4/P5 三尺度都插 CAFR 的情况下,单帧耗时相比单流基线会有明显增加。如果你的部署是边缘设备,建议只在 P3 和 P4 插,P5 分辨率和通道数都大,但目标已经很小了,融合收益有限。这个取舍比盲目堆模块重要得多。
如果训练直接失败,第一步应该看的是:显存是否溢出(报CUDA out of memory)、梯度是否为 nan(在 backward 后加torch.nn.utils.clip_grad_norm_并打印 grad norm)、以及数据加载里 RGB 与 IR 是否严格一一对应(错位的数据不会报错,只会让精度莫名其妙地上不去)。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 训练第一个 epoch 就 loss=nan | gamma初始化过大 / 学习率过高 / 未做输入归一化 | 打印 forward 中间张量的 min/max | 把init_gamma降到 0.01,加梯度裁剪,确认输入图归一化一致 |
| 显存溢出,P3 尺度必崩 | sr_ratio=1导致注意力矩阵为 O(HW²) | 用torch.cuda.max_memory_allocated定位 | 设sr_ratio=2或 4,或降低 batch,或只在 P4/P5 融合 |
| 训练正常但 mAP 不涨反降 | RGB 与红外未完成几何配准 | 取几组样本叠加查看目标是否错位 | 先做配准再谈融合,配准误差大于 5 像素时注意力会学到错误关系 |
| 白天子集掉点明显 | 注意力覆盖了 RGB 的纹理信息 | 分模态分时段评估 | 调小gamma初值,或在 RGB 强响应区域加门控抑制 |
gamma梯度恒为 0 | 残差分支未正确接入计算图 | backward后检查gamma.grad | 检查广播维度,确认没有.detach()误用 |
| BatchNorm 在 batch=4 时波动大 | 双光数据集小,统计量不稳 | 对比 batch=8 与 batch=4 的曲线 | 融合层改用 GroupNorm 或 LayerNorm2d |
| 推理耗时增加超预期 | 三尺度全插 + 未用 FP16 | 用 profile 工具逐层测耗时 | 只插 P3/P4,开启半精度,融合层导出时检查是否被优化掉 |
| 换数据集后完全失效 | 模块过拟合到原数据集的模态强度分布 | 在新数据集上只微调融合层 | 冻结 backbone 先训融合层 10 个 epoch,再全量微调 |
这张表里最容易被忽略的是第二行和第三行。显存问题很多人都知道要调sr_ratio,但"配准不准导致注意力学错"这件事不会报错,只会表现成精度上不去,非常难定位。建议在接入模块之前,先随机抽 20 组样本把红外和 RGB 叠一起看一眼,确认目标大体对齐,这十分钟能省掉后面几天的调参。
8. 最佳实践与工程建议
8.1 模块插入位置与数量
不是插得越多越好。从信息论的角度看,跨模态融合最有效的时机是"两路特征都还有足够空间分辨率、且语义层级接近"的位置。P3(80×80)保留了大量细节但也有大量噪声,P4(40×40)是收益最稳的一档,P5(20×20)语义已经很强、空间信息很少,融合的边际收益递减。
工程上比较推荐的组合是P3 + P4 插 CAFR,P5 保持普通拼接。这样既能拿到细节层的跨模态增益,又不至于在耗时上失控。如果部署端是算力受限的边缘设备,只插 P4 也值得试,很多时候能拿到大部分收益。
8.2 数据增强必须分两类处理
这是双光项目里一个高频翻车点。几何类增强(随机缩放、裁剪、翻转、旋转、Mosaic)必须对两路同步施加完全相同的变换,否则配准直接失效;而光度类增强(亮度、对比度、饱和度、色相抖动)只能作用于 RGB 那一路,绝对不能同步施加到红外图上——红外图本身就是辐射强度图,调亮度等于伪造物理量,训练出来的模型在真实场景会明显偏。
更稳妥的做法是把增强管线拆成两个 Dataset wrapper,几何变换共用一个随机种子生成器,光度变换各自独立。这个改动看起来小,但对最终精度的影响可能比换模块还大。
8.3 初始化与训练策略
推荐的训练节奏是三步走。第一步,冻结两路 backbone,只训练融合层和检测头,跑 5 到 10 个 epoch,让融合分支先学到一个合理的初始状态,避免一上来就用大梯度冲击预训练主干。第二步,解冻全部参数,用较小的学习率(比如基线的 1/5)联合训练。第三步,如果最终指标波动大,用 EMA 权重做推理,双光场景下不同光照子集的分布差异大,EMA 对稳定性的帮助比单模态更明显。
红外分支的初始化有两个方案:复制 RGB 预训练权重,或者从零训练。前者收敛快,适合数据量在几千张级别的情况;后者上限更高,但需要至少上万张的量级。单通道红外复制三通道权重时,把权重的三通道求和除以 3 作为初始化,比随机初始化收敛快不少。
8.4 安全边界与生产建议
涉及实际部署时,有几点要强调。第一,任何对生产模型的改动都应在测试环境完成验证,并在切换前保留可回滚的旧版本权重与配置文件,双光融合模块引入后模型体积和依赖都会变化,回滚路径必须提前演练。第二,融合模块涉及的可学习参数应纳入模型版本管理,不要出现"线上权重和配置对不上"的情况。第三,如果系统里有隐私相关的可见光图像,双流数据的存储和访问权限要按最小权限原则配置,红外图虽然画面抽象,但同样可能包含可识别的场景信息