ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C²Former双光谱目标检测复现:ICA、AFS与MMDetection实战

C²Former双光谱目标检测复现:ICA、AFS与MMDetection实战 简介围绕 RGB 与红外双光谱目标检测的 C²Former 复现文档面向熟悉 Python、PyTorch 且从事计算机视觉目标识别的研究者与开发者。内容以校准互补变压器为核心依次覆盖环境搭建、DroneVehicle 与 KAIST 数据准备、ICA 跨模态交互注意力模块、AFS 自适应特征采样模块、模型训练与性能评价等环节并给出关键组件的 PyTorch 代码片段、模块构造示例及系统组建细节帮助理解跨模态信息如何校准与互补从而提升复杂光照下目标捕捉与分类的准确性可服务于无人机侦察、智能监控等场景。整包为 1 个 docx 文件约 25KB属轻量文档型资料重点在代码解释与完整开源项目获取指引便于对照复现、排错和二次开发。文档按步骤组织方便边读边验证各模块的输入输出与训练配置。已有 211 人学习适合需要快速掌握 C²Former 结构、补齐双光谱检测实验流程的读者参考。1. 双光谱目标检测里RGB 和红外为什么非得同时喂进网络白天无人机巡检拍到的行人和黑色沥青路面对比清晰一到夜间或者强逆光可见光通道里那个人几乎和背景糊成一块而红外通道里他的热辐射轮廓反倒是最清晰的。C²Former 这类双光谱检测器的出发点就在这里不做简单的通道 concat也不做后期结果级融合而是在骨干网络的最后一层特征图上让两个模态互相校准、互相补充。它由两个核心模块组成ICA 负责跨模态交叉注意力用一路模态的特征去调制另一路AFS 负责自适应特征采样在采样阶段把采样点往真正有目标的像素位置上偏。复现路线是 Python PyTorch MMDetection MMRotate数据落 DroneVehicle 和 KAIST 这两个双光谱数据集。这套东西适合已经能跑通单模态检测训练、想往多模态方向走的人如果 mmdet 的训练脚本还没跑通过建议先把单 RGB 的基线跑一遍再上这里。2. MMDetection 环境搭建与 DroneVehicle/KAIST 双光数据管线2.1 torch、mmcv-full、mmdet 的版本必须对齐复现这类基于 MMDetection 的工作翻车最多的一步不是模型写错而是 mmcv-full 和 torch 的 CUDA 版本对不上装上之后import mmcv直接报undefined symbol。mmcv-full 里大量算子是用 C/CUDA 写死的官方只提供针对特定 torch CUDA 组合编译好的 wheel不能随便pip install mmcv-full让它自己去编译。# 第一步先确认驱动和运行时 CUDA 版本 nvcc -V python -c import torch; print(torch.__version__, torch.version.cuda) # 以 CUDA 11.3 torch 1.10.1 为例先固定 torch pip install torch1.10.1cu113 torchvision0.11.2cu113 \ -f https://download.pytorch.org/whl/torch_stable.html # 再按 torch/CUDA 组合去 openmmlab 的 wheel 索引里取 mmcv-full pip install mmcv-full1.5.0 \ -f https://download.openmmlab.com/mmcv/dist/cu113/torch1.10.0/index.html pip install mmdet2.24.0 mmrotate0.3.2这段命令的顺序不能颠倒。torch 装完先验证torch.cuda.is_available()返回 True再去装 mmcv-full如果先装 mmcv-full它会尝试从源码编译在没配好 nvcc 的机器上会跑十几分钟然后报错。-f参数的作用是把 openmmlab 的 wheel 索引作为附加查找源pip 会在里面找与当前 torch 版本匹配的预编译包。torchCUDAmmcv-fullmmdetmmrotate备注1.9.011.11.4.02.22.00.3.0老驱动环境可选1.10.111.31.5.02.24.00.3.2复现文档最常用1.11.011.31.6.02.25.00.3.3新卡驱动建议1.12.111.61.7.02.26.00.3.4需 CUDA 11.6 驱动提示驱动版本只能向下兼容 CUDA 运行时不能向上。nvidia-smi右上角显示的是驱动支持的最高 CUDA 版本实际用哪个由你装的 torch 决定两者不一致时按驱动版本往下取一档。2.2 DroneVehicle 与 KAIST 的目录组织DroneVehicle 是无人机视角的双光车辆数据集RGB 和红外成对采集标注以水平框为主KAIST 是行人检测的经典双光数据集标注粒度更细。两者目录结构不一样建议统一成同一套布局让 Dataset 类只依赖一种路径拼接逻辑。DroneVehicle ├── train │ ├── rgb # 可见光图 │ ├── ir # 红外图文件名与 rgb 一一对应 │ └── annotations # 转成 COCO json 后的标注 └── test ├── rgb ├── ir └── annotations KAIST ├── images │ ├── rgb │ └── ir └── annotations2.3 配对一致性检查与 COCO 标注转换双光数据集最容易出的事是 RGB 和 IR 数量对不上——采集时某一帧丢了或者文件名后缀大小写不同。这个问题不在训练前查出来会在训练到一半时以FileNotFoundError的形式炸掉白跑几个小时。下面这个检查脚本我基本每次拿到新数据都会先跑一遍。import os from PIL import Image def check_pair(root, splittrain): rgb_dir os.path.join(root, split, rgb) ir_dir os.path.join(root, split, ir) rgb_files {os.path.splitext(f)[0] for f in os.listdir(rgb_dir)} ir_files {os.path.splitext(f)[0] for f in os.listdir(ir_dir)} only_rgb rgb_files - ir_files # 有可见光没红外 only_ir ir_files - rgb_files # 有红外没可见光 print(f[{split}] 缺红外: {len(only_rgb)}, 缺可见光: {len(only_ir)}) mismatch 0 for stem in rgb_files ir_files: rgb_path os.path.join(rgb_dir, stem .jpg) ir_path os.path.join(ir_dir, stem .jpg) if not (os.path.exists(rgb_path) and os.path.exists(ir_path)): continue if Image.open(rgb_path).size ! Image.open(ir_path).size: mismatch 1 print(f[{split}] 尺寸不一致配对数: {mismatch}) return only_rgb, only_ir, mismatch check_pair(data/DroneVehicle, train)os.path.splitext去掉扩展名是为了避免.jpg和.png混用时被误判成不配对。rgb_files ir_files取交集只对成对存在的样本做尺寸比对——因为Image.open只读文件头速度快几千张图几秒钟就能扫完。尺寸不一致的配对必须删掉双光模型在extract_feat阶段会把两路特征图加在一起尺寸不同会直接 shape 报错。标注转换用pycocotools的格式把每张图的file_name写成相对img_prefix的路径annotations里挂image_id、bboxxywh、category_id。转换脚本跑完后用json.load打开数一下 images 数量和 annotations 数量两者的比例大概能反映平均每张图几个目标和目标数明显对不上就是漏标了。3. ICA 跨模态交叉注意力通道仿射调制与软注意力融合3.1 为什么不用简单 concat 或相加直接把 RGB 和红外按通道拼起来送进 neck网络得自己去学“哪个通道在什么场景下更可信”这件事在小数据集上很难学出来。ICA 换了个思路先用一路模态预测出的仿射参数去调制另一路模态的归一化结果再让调制后的特征生成 Query去和另一路模态的 Key/Value 做交叉注意力。前者是逐通道的全局校准后者是逐像素的空间对齐两层叠加比纯 concat 的表达能力强不少。具体流程是对 RGB 做 InstanceNorm 得到零均值单位方差的分布然后用红外特征经过三层卷积产生的gamma和beta去缩放平移它。这一步的直觉是红外特征本身编码了“哪里有热源”用它生成的仿射参数能把 RGB 特征里对应位置的重要性抬起来。3.2 ICA 模块的 PyTorch 实现import torch import torch.nn as nn class IntermodalityCrossAttention(nn.Module): 跨模态交叉注意力两路模态互相提供 K/V 来校准对方的 Query。 def __init__(self, in_channels): super().__init__() self.in_channels in_channels # 1x1 卷积分别生成两模态的 Q / K / V不改变空间尺寸 self.q_rgb_conv nn.Conv2d(in_channels, in_channels, 1) self.k_rgb_conv nn.Conv2d(in_channels, in_channels, 1) self.v_rgb_conv nn.Conv2d(in_channels, in_channels, 1) self.q_ir_conv nn.Conv2d(in_channels, in_channels, 1) self.k_ir_conv nn.Conv2d(in_channels, in_channels, 1) self.v_ir_conv nn.Conv2d(in_channels, in_channels, 1) # 对两路特征各做一次 InstanceNorm不引入可学习参数 self.norm_rgb nn.InstanceNorm2d(in_channels) self.norm_ir nn.InstanceNorm2d(in_channels) def make_affine(): # 输出 2C 通道chunk 后拆成 gamma / beta return nn.Sequential( nn.Conv2d(in_channels, in_channels, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels, in_channels, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels, 2 * in_channels, 3, padding1), ) # 注意RGB 的仿射参数由 IR 特征预测IR 的由 RGB 预测 self.transform_rgb make_affine() self.transform_ir make_affine() self.out_rgb_conv nn.Conv2d(in_channels, in_channels, 1) self.out_ir_conv nn.Conv2d(in_channels, in_channels, 1) def forward(self, x_rgb, x_ir): # 步骤 1跨模态仿射调制 norm_rgb self.norm_rgb(x_rgb) gamma_rgb, beta_rgb torch.chunk(self.transform_rgb(x_ir), 2, dim1) norm_rgb norm_rgb * gamma_rgb beta_rgb norm_ir self.norm_ir(x_ir) gamma_ir, beta_ir torch.chunk(self.transform_ir(x_rgb), 2, dim1) norm_ir norm_ir * gamma_ir beta_ir b, c, h, w x_rgb.shape # 步骤 2生成 Q / K / Vflatten(2) 把 H*W 压成一维再转置成 (B, HW, C) q_rgb self.q_rgb_conv(norm_rgb).flatten(2).transpose(1, 2) k_rgb self.k_rgb_conv(norm_rgb).flatten(2).transpose(1, 2) v_rgb self.v_rgb_conv(norm_rgb).flatten(2).transpose(1, 2) q_ir self.q_ir_conv(norm_ir).flatten(2).transpose(1, 2) k_ir self.k_ir_conv(norm_ir).flatten(2).transpose(1, 2) v_ir self.v_ir_conv(norm_ir).flatten(2).transpose(1, 2) # 步骤 3双向交叉注意力缩放因子为 sqrt(C) scale c ** 0.5 sim_rgb torch.softmax(q_rgb k_ir.transpose(1, 2) / scale, dim-1) sim_ir torch.softmax(q_ir k_rgb.transpose(1, 2) / scale, dim-1) # 步骤 4加权求和后还原回 (B, C, H, W) attn_rgb (sim_rgb v_ir).transpose(1, 2).reshape(b, c, h, w) attn_ir (sim_ir v_rgb).transpose(1, 2).reshape(b, c, h, w) return self.out_rgb_conv(attn_rgb), self.out_ir_conv(attn_ir)几个关键点值得单独说。flatten(2)把(B, C, H, W)变成(B, C, H*W).transpose(1, 2)之后才是(B, H*W, C)这个顺序决定了后面q k.transpose(1, 2)得到的是(B, HW, HW)的相似度矩阵dim-1上的 softmax 是对“每个 query 位置对所有 key 位置”做归一化方向不能搞反。除以sqrt(c)是为了防止通道数大时点积结果量级过大、softmax 梯度趋近于零。self.norm_rgb用的是InstanceNorm2d而不是BatchNorm2d因为双光数据集的 batch 常常只能开到 2 甚至 1后面第四章会讲显存BN 在这个 batch size 下方差估计极不稳定IN 是对每个样本每个通道单独统计不受 batch 影响。3.3 用最小输入验证形状和数值稳定性写完模块别急着往检测器里塞先用随机张量跑一遍把形状和 NaN 都查出来。if __name__ __main__: torch.manual_seed(0) dev cuda if torch.cuda.is_available() else cpu ica IntermodalityCrossAttention(in_channels256).to(dev).eval() rgb torch.randn(2, 256, 20, 20, devicedev) ir torch.randn(2, 256, 20, 20, devicedev) with torch.no_grad(): out_rgb, out_ir ica(rgb, ir) # 期望输出与输入同形状 assert out_rgb.shape rgb.shape, out_rgb.shape assert out_ir.shape ir.shape, out_ir.shape # 检查是否出现 NaN 或 Inf print(NaN:, torch.isnan(out_rgb).any().item(), torch.isnan(out_ir).any().item()) print(输入推荐范围:, rgb.abs().max().item(), out_rgb.abs().max().item())输入用torch.randn而不是全零是因为全零经过 softmax 后均匀分布掩盖不了除零问题。输出最大值如果比输入大两三个数量级多半是gamma的初始化没约束好——make_affine最后一层卷积默认初始化的方差偏大稳妥做法是给这一层加nn.init.zeros_(m.weight)和nn.init.zeros_(m.bias)让训练初期退化成恒等映射。4. AFS 自适应特征采样与 C²Former 在 MMDetection 中的注册4.1 偏移量预测与 grid_sample 的采样逻辑AFS 要做的事是先预测一组采样偏移量把常规的均匀网格挪到信息密集的位置再用F.grid_sample从原特征图上按新网格取值。偏移量由两路特征的融合结果预测通过tanh限制在 (-1, 1) 再乘 2保证采样点不会跑出太远。import torch import torch.nn as nn import torch.nn.functional as F class AdaptiveFeatureSampling(nn.Module): 自适应特征采样预测偏移量后用 grid_sample 重采样两路特征。 def __init__(self, in_channels, stride3): super().__init__() self.in_channels in_channels self.stride stride # 融合 RGB IR2C 通道降回 C self.fuse_conv nn.Conv2d(2 * in_channels, in_channels, 1) # 输出 2 通道分别代表 x、y 方向的偏移 self.offset_conv nn.Sequential( nn.Conv2d(in_channels, in_channels, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(in_channels, 2, 3, padding1), ) def forward(self, x_rgb, x_ir): x_fuse self.fuse_conv(torch.cat([x_rgb, x_ir], dim1)) # tanh 限制到 (-1, 1)乘 2 后偏移范围为 (-2, 2) offset 2.0 * torch.tanh(self.offset_conv(x_fuse)) b, c, h, w x_rgb.shape h_s, w_s h // self.stride, w // self.stride # 构建归一化到 [-1, 1] 的参考网格形状 (h_s, w_s, 2) ys torch.linspace(-1, 1, h_s, devicex_rgb.device) xs torch.linspace(-1, 1, w_s, devicex_rgb.device) grid_y, grid_x torch.meshgrid(ys, xs, indexingij) base_grid torch.stack([grid_x, grid_y], dim-1) # 注意 x 在前 base_grid base_grid.unsqueeze(0).expand(b, -1, -1, -1) # 偏移量切到与采样网格同尺寸再 permute 成 (B, h_s, w_s, 2) offset offset[:, :, :h_s, :w_s].permute(0, 2, 3, 1) sample_grid base_grid offset # align_cornersFalse 与双线性插值配套边界外补零 x_rgb_s F.grid_sample(x_rgb, sample_grid, modebilinear, padding_modezeros, align_cornersFalse) x_ir_s F.grid_sample(x_ir, sample_grid, modebilinear, padding_modezeros, align_cornersFalse) return x_rgb_s, x_ir_sgrid_sample要求网格的最后一维是(x, y)顺序而torch.meshgrid默认按(行, 列)也就是(y, x)返回所以stack的时候必须把grid_x放在前面。这个顺序错了不会报错只会让采样结果整体旋转 90 度训练 loss 还能降但 mAP 会低一截属于最难排查的那类 bug。align_cornersFalse是配padding_modezeros用的它把像素中心而非角点映射到-1和1采样更均匀如果混用align_cornersTrue边缘会有半像素的系统性偏移。4.2 stride 参数对显存和精度的影响stride决定了采样后特征图的空间尺寸直接决定交叉注意力矩阵的大小。注意力矩阵是(B, HW, HW)量级空间尺寸减半显存占用降为原来的四分之一所以 stride 是显存和精度之间最直接的旋钮。stride采样后尺寸 (640x512 输入)注意力矩阵规模相对显存精度影响180 x 645120 x 51201.0x信息最全显存最高240 x 321280 x 1280约 0.06x小目标召回略降326 x 21546 x 546约 0.01x默认配置平衡点420 x 16320 x 320约 0.004x密集小目标明显退化h // stride的整除会丢掉几行几列像素这是可接受的——被丢的那部分在检测器里由浅层特征图的 FPN 通路补回来。如果卡在 11GB 显存上stride 从 3 调到 4 通常能省出训练的空间代价是中小目标 mAP 掉一两个点具体掉多少要看数据集的尺寸分布。4.3 C²Former 组合模块与检测器注册ICA 和 AFS 串起来就是 C²Former。注意采样先做注意力后做顺序不能反没有 AFS 的降采样ICA 直接在全尺寸特征图上算注意力会 OOM。class C2Former(nn.Module): def __init__(self, in_channels, stride3): super().__init__() self.afs AdaptiveFeatureSampling(in_channels, stride) self.ica IntermodalityCrossAttention(in_channels) def forward(self, x_rgb, x_ir): # 先降采样再算注意力控制矩阵规模 x_rgb_s, x_ir_s self.afs(x_rgb, x_ir) return self.ica(x_rgb_s, x_ir_s)挂到 MMDetection 的一阶段检测器上关键是把extract_feat改成接收双路输入并把 C²Former 输出的互补特征交叉加回原特征。from mmdet.models.builder import DETECTORS from mmdet.models.detectors import SingleStageDetector DETECTORS.register_module() class C2FormerS2ANet(SingleStageDetector): def __init__(self, backbone, neck, bbox_head, c2former_in_channels, c2former_stride, train_cfgNone, test_cfgNone): super().__init__(backbone, neck, bbox_head, train_cfg, test_cfg) self.c2former C2Former(c2former_in_channels, c2former_stride) def extract_feat(self, img_rgb, img_ir): x_rgb self.backbone(img_rgb) x_ir self.backbone(img_ir) # 只在最高层特征上做跨模态交互 out_rgb, out_ir self.c2former(x_rgb[-1], x_ir[-1]) # 交叉相加RGB 高层补上 IR 的信息反之亦然 x_rgb[-1] x_rgb[-1] out_ir x_ir[-1] x_ir[-1] out_rgb # 逐层 concat 后交给 neck x [torch.cat([x_rgb[i], x_ir[i]], dim1) for i in range(len(x_rgb))] if self.with_neck: x self.neck(x) return x def forward_train(self, img_rgb, img_ir, img_metas, gt_bboxes, gt_labels, **kwargs): x self.extract_feat(img_rgb, img_ir) return self.bbox_head.forward_train( x, img_metas, gt_bboxes, gt_labels, **kwargs) def simple_test(self, img_rgb, img_ir, img_metas, rescaleFalse): x self.extract_feat(img_rgb, img_ir) return self.bbox_head.simple_test(x, img_metas, rescalerescale)x_rgb[-1] x_rgb[-1] out_ir这行是交叉融合的关键C²Former 的输出不是直接替换原特征而是作为残差加回去。这样即使 C²Former 初期学得不好主干本身的特征也不会被破坏训练更稳。DETECTORS.register_module()把这个类注册进 MMDetection 的模块表配置文件里写typeC2FormerS2ANet就能找到它。5. 训练配置、mAP 评估与几个能省半天的排错技巧5.1 配置文件里必须改的几处配置继承自基类配置只需要覆盖model、data和optimizer三块。最容易漏的是data里LoadImageFromFile之后要挂一个自定义的LoadDualImage把 RGB 和 IR 一起读进来组成双通道输入如果直接沿用单光配置dataloader 只会给你一路图跑到extract_feat就会收到 None。optimizer的学习率要跟着 batch size 走。基类配置通常是 8 卡 × 2 样本如果单卡 batch2 训练初始 lr 按线性缩放从 0.0025 降到 0.0006 左右比较稳。warmup_iters500保留双光模型前期两个模态的特征尺度还没对齐warmup 能避免第一轮就发散。optimizer_config里的grad_clip(max_norm35)建议保留交叉注意力模块的反向梯度偶尔会出现尖峰。5.2 训练与评估命令# 单卡训练 python tools/train.py configs/c2former_s2anet_dronevehicle.py # 多卡分布式训练4 卡 bash tools/dist_train.sh configs/c2former_s2anet_dronevehicle.py 4 # 断点续训从最近一次 checkpoint 恢复 python tools/train.py configs/c2former_s2anet_dronevehicle.py \ --resume-from work_dirs/c2former_s2anet_dronevehicle/latest.pth # 评估指定权重文件并输出 mAP python tools/test.py configs/c2former_s2anet_dronevehicle.py \ work_dirs/c2former_s2anet_dronevehicle/latest.pth --eval mAPdist_train.sh的第二个参数是 GPU 数量它内部用torch.distributed.launch起进程所以配置文件里的samples_per_gpu是每卡样本数总 batch 要乘以卡数。评估命令里的--eval mAP会调用 COCO 评估器输出里bbox_mAP是全体平均bbox_mAP_50是 IoU 0.5 下的平均值双光数据集上两者差距通常在 20 个点以上看趋势主要看bbox_mAP。评估间隔evaluation dict(interval1)意味着每个 epoch 都跑一遍验证集如果验证集大可以改成interval2省时间。5.3 显存爆掉和 loss 不降的排查顺序显存问题按这个顺序查先把samples_per_gpu降到 1还爆就调大c2former_stride再爆就把img_scale从 (640, 512) 降到 (480, 384)。这三个旋钮的优先级是递减的因为调 stride 只影响 C²Former 部分调输入尺寸会影响全部网络代价最大。提示samples_per_gpu1时一定要确认模型里所有BatchNorm2d要么被替换成InstanceNorm要么设置norm_evalTrue冻结统计量否则 BN 的单样本方差为 0前向直接输出全零。loss 不降的排查顺序不一样先看 RPN 或 head 的分类 loss如果它一直停在-log(1/num_classes)附近不动说明正负样本分配出了问题检查 anchor 的scales和ratios是否覆盖了数据集的真实目标尺寸如果分类 loss 在降但 bbox loss 平着不动检查标注的坐标格式xywh 和 xyxy 写错是高频事故。最后才怀疑 C²Former 本身验证方法是在配置文件里把c2former_stride设成一个极大的值比如 64让 AFS 输出只有 1x1等于把 C²Former 的输出压成一个常数——如果这种情况下 mAP 和完整模型差不了几个点说明 C²Former 实际没起作用问题出在特征尺度没对齐上。5.4 先跑单模态基线再上双模态真正省时间的做法是先把同一套 backbone neck head 配置里的C2FormerS2ANet换成标准SingleStageDetector只用 RGB 跑 12 个 epoch记下 mAP。然后再切成双模态配置跑同样轮数。两个数值的差才是 C²Former 真正带来的增益如果差值是负的或者只有零点几个点问题多半在数据配对或者融合位置上而不是模块本身写错。我见过不少人跳过这一步直接拿双模态的结果和论文里的数字比最后发现数据集的类别数和标注质量都不一样白白折腾了好几天。本文还有配套的精品资源点击获取
返回列表