CRFNet雷达与融合特征图可视化:从坐标对齐到Grad-CAM的实践指南
刚接触多模态模型时我也和很多人一样埋头调网络结构、盯着loss曲线总觉得训练收敛就没问题了。直到有一次一个融合检测模型在夜间场景偶尔漏检我怎么都解释不了原因。后来把雷达分支和融合层的特征图全部拉出来才发现雷达分支对远处低矮金属护栏产生了明显的强激活而这个信息在融合阶段被图像分支的暗部噪声压制了。那一刻我才明白不看特征图所谓的多模态“融合”就是个黑盒子。这个经验也让我对雷达特征图和融合特征图的可视化特别重视。尤其是在CRFNet这类跨模态融合网络中可视化不是发论文时补个效果图那么简单它是定位融合失效、验证模态贡献、甚至判断网络是否在“偷懒”的关键手段。这篇文章我就把自己在实际项目里对CRFNet雷达特征图和融合特征图做可视化的完整过程梳理一遍包括结构的理解、工具的选型、代码的坑、以及那些文档里不会写的小经验。1. 先说背景雷达特征图到底特殊在哪1.1 为什么偏偏要单独看雷达分支的特征图像分支的特征图很好理解输入是RGB三通道图像中间层的特征图天然带有空间结构缩小成灰度图或者用颜色映射一渲染人眼能看出轮廓和语义。雷达分支完全不同。雷达输入往往不是规则的图像而是距离-角度图、距离-多普勒图或者是经过栅格化处理的伪图像。特征是稀疏的、离散的直接铺开来会看到一张黑底上面散落着一些亮点很难从直觉上判断网络学到了什么。可视化雷达特征图本质上是要回答三个问题网络是真的在利用多普勒速度信息还是仅仅把雷达当成一个距离测量器。对目标的响应是集中在物理位置正确的区域还是散布在全图无关位置。融合之后雷达原本的优势信息有没有被保留下来。在CRFNet这类结构里雷达分支输出的是低分辨率特征图融合模块会把它和图像特征做对齐操作。我想强调一下可别觉得可视化就是把特征图输出成图片它其实是在跟一个“坐标系转换”和“尺度不匹配”的问题打交道。1.2 CRFNet手里拿到的数据长什么样CRFNet我这里按常见的跨模态融合网络来处理它的典型设计是双分支输入一个分支吃毫米波雷达的稀疏特征图另一个分支吃摄像头RGB图像中间通过注意力机制或者可变形对齐模块把两个分支的特征做融合。雷达端的输入在送入网络前一般会做两步处理第一步把雷达原始点云投影到距离-角度平面上形成一张二维密度图尺寸通常比图像小很多比如64x64第二步会叠加一些速度信息通道相当于每个像素上除了强度还有速度。这意味着特征图的长宽比例、感受野范围和图像分支完全不一致。直接做可视化你会看到雷达特征图的坐标和图像坐标对不上一个在图像左侧的车辆在雷达特征图上却对应着右侧区域。必须先建立坐标映射关系才能把雷达特征图叠到原始图像上。不同类型的雷达特征图取值分布差异很大。密度图大致服从长尾分布大部分区域是0少数区域可能因为多径效应突然飙高速度通道则是正负都有。可视化时如果一刀切用min-max归一化画面会非常难看——极端值把整个色彩范围都占了真实感兴趣区域反而变成了深色。2. 从网络结构看特征从哪里来、在哪里融合2.1 双分支设计的可视化目标层CRFNet的骨干网络一般分为三段浅层、中层、深层。可视化的时候不必每一层都看否则图上全是密密麻麻的小方块信息量反而低。我建议重点关注三个位置这也是雷达特征图和融合特征图对比价值最大的位置。第一是雷达分支的中层输出通常对应stride为8或16的特征图形状类似[B, C, H/8, W/8]。这个层级能看出雷达对目标形态的编码程度——如果特征图呈现出清晰的团块结构说明网络把雷达回波聚成了物体区域如果还是散点说明滤波器和卷积核没有形成有效的空间聚合。第二是图像分支的同层输出作为参照组。第三是融合模块的输出也就是融合特征图它体现的是跨模态信息真正交汇之后的结果。值得留意的是融合特征图并不是简单地把两个分支的特征图相加。CRFNet的融合模块通常包含注意力权重计算有的设计是加一个门控机制有的设计是交叉注意力图。训练稳定后融合特征图应当在目标边缘处同时保留雷达的距离边界信息和图像的纹理边界信息——这是可视化时判断融合是否有效的第一直觉依据。2.2 融合模块的中间过程同样值得可视化很多人在做特征图可视化时只看融合前后各一条通路会错过融合模块内部的注意力图。CRFNet这类模型里融合模块内部往往会产生一个空间注意力矩阵或者通道注意力向量它决定了图像特征和雷达特征各取多少权重。我做过一次实验把融合模块内部的注意力图单独可视化出来发现非常有意思在近距离视野区域注意力倾向于分配给雷达分支而在远距离、纹理丰富的区域注意力又倾向于分配给图像分支。这是符合物理直觉的因为雷达远距离精度衰减快而图像远距离细节反而更丰富前提是光照充足。要是注意力图完全反向那就说明融合模块没有学到合理的模态取舍需要检查损失函数是否失衡。所以我建议在你的可视化脚本里预留一个“融合内部状态导出”的口子在正向传播时把注意力矩阵也钩出来。这些中间状态的张量一般不大通常是[B, 1, H, W]或者[B, 1, C]可以轻松转化为numpy数组。3. 可视化之前必须解决的两个关键对齐问题3.1 坐标对齐将雷达特征图投影到图像视角雷达特征图的坐标天然是极坐标或者雷达自身坐标系而图像是透视投影。不对齐坐标直接叠加的结果就是特征漂移。具体操作是这样先用雷达的外参矩阵把雷达坐标系下的点变换到自车坐标系再通过相机内参将自车坐标映射到像素坐标。这个过程在雷达点云上做过一次但特征图是栅格化的不能逐点操作。常见的做法是先把雷达特征图转成一张低分辨率掩码图每个像素点保留对应栅格是否有点云的标志然后对掩码图做透视变换插值到和图像特征图相同的分辨率。注意这里不是直接把雷达特征图用cv2.resize放大而是要用单应性矩阵做变换否则投影关系会失真。完成对齐后雷达特征图可以以半透明叠加图的形式盖在原始图像上也可以单独放在旁边做并排对比。个人经验是并排对比更适合写论文和做汇报半透明叠加更适合调试——因为重叠后的空间关系一眼就能看出来。3.2 形状对齐跨尺度的通道动态调整CRFNet的深度融合模块通常会把两个分支的特征图统一到同一分辨率但统一之后通道数也不一定相同。雷达分支的特征通道编码的是距离、速度、RCS散射截面积等物理量图像分支的通道编码的是颜色、边缘、纹理语义上完全不一一对应。所以我为什么强调这个呢原因是在做融合特征图可视化时直接选某个通道显示可能会造成误导。比如你选了融合特征图的第17个通道看到高亮区域刚好在车辆上于是得出结论“这个通道编码了车辆”。但也许这个高亮是图像分支主导的雷达分支的贡献微乎其微这个结论就站不住脚。实际操作中我会做两个层面的处理第一面向融合特征图本身的可视化把特征图按通道做聚合常用的方案是通道间取最大值或者平均值得到一个二维激活图第二分别对雷达特征图和融合特征图做同样的聚合再做差分图差分图的亮点区域就是融合后新增的响应暗点区域就是被抑制的响应。差分图是我做可视化分析时最依赖的工具。4. 动手实操用hook提取并渲染特征图4.1 整体流程概览特征图可视化的完整流程我总结下来就六步确定模型推理方式和数据加载方式。确认要可视化的网络层名称给模型注册hook。跑一次正向传播把中间张量保存下来。对张量做归一化和通道聚合。将雷达分支特征图对齐到图像坐标系。渲染并输出对比图。我再补充一句第2步卡住的概率极高。很多模型代码里层名和结构并不直观torchsummary或者打印model能帮你定位但定位之后还要注意hook注册的时机。一旦模型被model.cuda()之后hook仍然有效但是如果模型内部用了torch.compile或者nn.DataParallelhook的名字层级会发生变化这时候建议直接用model.module下的层名来注册。下面我给出一个可以直接跑的demo代码演示一下提取特征图的基本框架。这里我以一个典型的双分支网络为例雷达输入尺寸是[B, 1, 64, 64]图像输入是[B, 3, 256, 256]融合模块输出是[B, C, 64, 64]然后再上采样到图像分辨率。import torch import numpy as np import cv2 from torchvision import transforms # 假设你已经定义好了CRFNet模型 # model CRFNet() # model.load_state_dict(torch.load(crfnet.pth)) # model.eval() # model.cuda() feature_maps {} def make_hook(name): def hook_fn(module, input, output): # 只保存特征图不保存梯度 feature_maps[name] output.detach().cpu() return hook_fn # 注册hook这里的层名需要根据自己的模型结构调整 model.radar_branch.layer3.register_forward_hook(make_hook(radar_fea)) model.camera_branch.layer3.register_forward_hook(make_hook(camera_fea)) model.fusion_module.register_forward_hook(make_hook(fusion_fea)) # 构造输入 radar_input torch.randn(1, 1, 64, 64).cuda() image_input torch.randn(1, 3, 256, 256).cuda() with torch.no_grad(): model(radar_input, image_input) # 取出后处理 radar_fea feature_maps[radar_fea] # [1, C, 64, 64] fusion_fea feature_maps[fusion_fea] # [1, C, 64, 64]feature_maps里保存的tensor不会自动释放如果你要循环处理很多帧你需要在每轮循环结束后手动清空。这是常见内存泄漏来源。4.2 特征图的归一化与颜色映射提取出特征图之后下一个问题就是归一化。我强烈建议不要直接对整个特征图取最大最小值因为这会受到极端值的干扰。一组特征图中可能只有0.01%的像素处于极高值如果用min-max归一化中间层的正常激活值会全部被压缩到非常暗的范围图像看起来就像一块黑板。我的做法是先按绝对值排序取99%分位作为上限下限固定为0如果是正负都有速度信息的特征图就取1%分位和99%分位作为对称范围。一句话总结就是用百分位数裁剪替代min-max。这样做的代价是你需要抽取一小批样本计算出稳定的百分位数而不是用当前这一帧的数值。归一化后推荐用colormap增强可视化效果。OpenCV里有现成的applyColorMap常用的有COLORMAP_JET和COLORMAP_TURBO。雷达特征图我用TURBO多一些因为JET的蓝色区域太深雷达稀疏区域会显示成大片蓝色观感上容易误以为那些区域有负激活。def normalize_percentile(fmap, lower1, upper99): # 输入 [C, H, W]输出聚合后的二维图 agg fmap.numpy().max(axis0) # 通道间取max lo np.percentile(agg, lower) hi np.percentile(agg, upper) if hi - lo 1e-6: return np.zeros_like(agg, dtypenp.float32) clipped np.clip((agg - lo) / (hi - lo), 0, 1) return clipped # 将特征图转伪彩色 cmap_img cv2.applyColorMap((normalized * 255).astype(np.uint8), cv2.COLORMAP_TURBO)通道间聚合的策略要说明一下。我默认选了max因为雷达特征图很稀疏取均值会把真正有响应的区域淹没而max能突出最强烈的雷达反射位置。对于融合特征图我通常两种聚合方式都试max倾向于展示最强的单通道响应mean更能反映整体激活能量。4.3 雷达特征图与融合特征图的并排对比渲染我的经验是用横向拼接的方式把三种图放在一张图里原始图像放在最左侧图像覆盖上层叠融合特征图的半透明映射图再右侧并列雷达特征图原始坐标系下的伪彩图这样能体现出投影前后的差异。叠加时有个细节融合特征图的分辨率如果远低于原图直接放大到原图大小会有马赛克感。先用cv2.GaussianBlur对二维激活图做一次平滑把核大小设为3到5再上采样视觉效果会干净很多也更贴合特征图本来就是低分辨率体素的实际情况。生成对比图的合成长代码我贴一下def make_comparison(image, radar_heat, fusion_heat, alpha0.5): # radar_heat和fusion_heat都是归一化后的二维图需要先resize到图像大小 H, W image.shape[:2] radar_heat_resize cv2.resize(radar_heat, (W, H), interpolationcv2.INTER_LINEAR) fusion_heat_resize cv2.resize(fusion_heat, (W, H), interpolationcv2.INTER_LINEAR) radar_color cv2.applyColorMap((radar_heat_resize * 255).astype(np.uint8), cv2.COLORMAP_TURBO) fusion_color cv2.applyColorMap((fusion_heat_resize * 255).astype(np.uint8), cv2.COLORMAP_TURBO) radar_overlay cv2.addWeighted(image, 1 - alpha, radar_color, alpha, 0) fusion_overlay cv2.addWeighted(image, 1 - alpha, fusion_color, alpha, 0) # 拼接 grid np.hstack([image, radar_heat_resize, fusion_heat_resize]) return grid提醒一下addWeighted处理的是BGR顺序如果你的image是从matplotlib读取的RGB格式直接addWeighted之后颜色会偏色踩过这个坑的人不少。4.4 Grad-CAM在融合特征图上的扩展上面说的都是直接特征图可视化但在分析“融合特征图对着某个类别的响应”时Grad-CAM的判别性更强。Grad-CAM的思路就是通过类别得分对目标层特征图做梯度反传得到每个通道的重要度权重然后做加权求和最终得到类别热力图。def grad_cam(model, radar_input, image_input, target_layer_output, target_class): fusion_fea feature_maps[fusion_fea] # 需要保留特征图 model.zero_grad() score model(radar_input, image_input) # 取目标类别得分 one_hot torch.zeros_like(score) one_hot[0, target_class] 1 score.backward(gradientone_hot) # 取目标层的梯度 grads model.fusion_module.weight.grad # 这里仅示意实际需从hook里保存梯度 weights grads.mean(dim(2, 3), keepdimTrue) cam torch.relu((weights * fusion_fea).sum(dim1, keepdimTrue)) return cam这段代码里有个陷阱PyTorch的hook默认不会保存输入的梯度所以你需要用带梯度的hook或者在需要梯度可视化时改用register_full_backward_hook。这些细节不要忽略不然后面耗时很长却得不到期望的结果。Grad-CAM得到的类激活图更适合告诉你“融合特征图关注了哪里”原始特征图则更适合告诉你“雷达和图像各自贡献了什么”。两个配合起来分析视角才完整。5. 从融合特征图里读出有效信息5.1 特征图分布验证模态贡献拿到融合特征图的可视化结果后我最常做的事情就是做对比验证用几类场景系统性检查模型——车辆遮挡场景、行人过街场景、夜间无照明场景。每类场景保存对应的雷达特征图和融合特征图统一用相同的可视化参数渲染然后观察分布规律。我把观察到的几个规律积累下来可能对调试你手上的模型有参考价值雷达特征图上车辆区域的激活形态通常是一个紧凑的团块位置偏下的地方可能伴有一条较弱的拖尾这是地面反射和多径造成的。融合特征图在车辆区域通常比单独图像分支的特征图更“聚焦”背景区域的亮点明显更少说明雷达分支提供了背景抑制的先验。如果融合特征图在某个目标上的激活强度比两个单分支都弱这通常意味着对齐模块没有对齐好典型原因是雷达外参标定偏移导致投影位置偏移了几个像素融合时两边特征错位。如果融合特征图大范围高亮看起来像噪声先检查样本中是否存在运动速度接近0的静止目标这类目标在雷达距离-多普勒图上的特征非常弱网络时常会“幻想”一个不存在的响应。上面四条里第四条最有迷惑性。有一阵子我处理的高速场景融合特征图上总在路沿位置出现一条高亮线一开始以为是融合模块学到了路沿特征是雷达回波强区域的规律后来才发现是路边防撞栏形成的强反射在距离-角度图上造成了扩展响应。这一类“特征图看起来合理但实际是伪影”的情况脱离可视化之后很难定位。5.2 关注融合后被抑制的雷达响应我之前提到差分图实践时我最看重这个图。差分图的生成逻辑是先对雷达分支特征图和融合特征图分别做通道聚合得到两张二维图然后把融合特征图的聚合结果减去雷达分支聚合结果的对应位置。如果融合特征图上某个区域的值明显低于雷达分支意味着融合模块主动抑制了那条通路的信息。为什么会发生这种抑制表面上是融合模块学到的权重偏向图像分支但深层原因往往是训练阶段图像分支提供了更强的梯度信号比如损失函数里两个模态任务不平衡或者是图像有丰富纹理反向传播时梯度更大。我遇到过一个非常值得记录的案例一个部署在城区干道上的融合感知模型雷达特征图上对金属隔离护栏的响应非常稳定但在融合特征图上这个响应几乎消失了。交叉验证后发现训练集中隔离护栏的场景和真实护栏反射回波形态不一致护栏样本整体偏少所以融合模块学到的是“在大部分样本里信任图像分支遇到护栏时也照搬了这个决策”。看了这个特征图后我开始针对护栏场景做数据增强融合特征图上的响应恢复了漏检率也下降了不少。这个案例说明特征图可视化不只是知道了网络在哪里“看”还能直接定位到数据层面的偏置问题。数据分析提出来的时候也许要花一两天时间但从实验结果看比盲目地加数据、调网络更有效。6. 常见问题与实操排查技巧实录6.1 稀疏值导致的可视化过曝或过暗雷达特征图是出了名的稀疏。一张64x64的雷达特征图有效激活像素常常只有几百个其他都是接近于零的背景。这就带来可视化呈现上的一个矛盾直接用线性映射背景显黑前景高亮的对比强烈但你看不出物体之间的相对强弱如果用直方图均衡化背景噪声又被放大了。我的处理思路是分两步第一步在原始特征空间用百分位数裁剪把中心趋势拉出来第二步再将数值做对数压缩减小极端值的影响。雷达回波强度的动态范围很大从弱反射的10到强反射的10000都可能直接用线性值是看不到中间层次的。def log_compress(fmap, eps1e-2): # 先裁剪到99分位再做对数增强 hi np.percentile(fmap, 99) fmap_clip np.clip(fmap, 0, hi) log_map np.log1p(fmap_clip * (1 / max(hi, eps))) return log_map / log_map.max()这个log_compress函数应对极端值特别有效。它不会把背景完全压成纯黑同时又能保留比较弱的回波迹象。尤其在可视化雷达特征图时我现在都会默认过这一步。6.2 特征的“通道维度”到底要不要全看前文提到了通道聚合我默认取max但这里再展开说一下原因。CNN训练过程中不同通道会逐渐分化有的通道专门响应边缘有的通道响应高亮区域有的通道响应重复模式。融合特征图的通道数往往在256或512这个量级每个通道携带的信息都存在。如果简单取max相当于告诉可视化工具“只要任何通道有响应我就展示”它能保持高灵敏度但会丢失一些微弱的整体激活信息。如果你想看得更细我的建议是不要直接聚合而是先在通道维度上做一个子集选择。做法是计算每个通道的激活能量就是把特征图平方后求和再开根得到的值求总和然后选出能量最高的top-5通道分别渲染成单通道热度图。这个方法对分析某一个具体样本很有用但在批量筛选时特别耗时间所以我一般在单样本深度调试时才会用。6.3 多卡和 compile 模式下的hook失效先记录一下这里的现象模型用torch.compile加速之后原本注册在某个具体Conv层上的hook名会访问不到用model.named_modules()打印出来的层名前面多了一层_orig_mod或者被替换成了OptimizedModule。这不是你代码有问题而是compile的图优化机制把模块封装了。解决方式比较直接针对该情况我常用这两种一是推理时暂时关闭compile就是用torch.compile的那个推理脚本单独跑一个不带compile的版本二是改用torch._dynamo禁用手动改写层名更推荐第一种因为可视化与训练是两套流程性能影响可以忽略。另一个坑是DataParallel包装后hook里拿到的tensor多了一个维度第一维是卡数。很多人在可视化时没处理这个维度直接把[B, C, H, W]当成[B*num_gpu, C, H, W]去聚合结果图像比预期小。解决办法很简单hook里拿到output后先做output output.view(-1, *output.shape[2:])或者干脆在所有rank上把特征图同步。6.4 可视化环境的搭建最后补充一下配套环境。我常用的一套组合是PyTorch做推理numpy做张量运算OpenCV做渲染matplotlib只在单独显示图像时用。TensorBoard的add_image也可以做特征图可视化但我个人更喜欢直接用cv2把结果拼成一张大图保存这样可以在几十上百张样本里快速横向筛没必要每个样本开一个TensorBoard界面。如果你要在浏览器里交互式查看不同层的特征图推荐用gradio搭一个极简界面核心代码不超过30行输入一张图就能看到所有关键层的可视化结果。实测下来给同事故障演示或者做算法评审时交互式界面的说服力远大于静态图片。写在最后的实践心得做雷达特征图和融合特征图的可视化难度不在代码而在你是否熟悉雷达物理特性和网络空间语义之间的对应关系。同样一张特征图熟悉物理的人能快速判断是否出现了多径鬼影的特征模式而不熟悉的人只会觉得是“一些亮点”。所以我建议做多模态模型的朋友一定要花时间把自己训练数据中的雷达真值、典型干扰场景和对应特征图形态建立起一个心理仓库——这个过程没有捷径只能靠反复观察特征图和对应的数据来积累。我现在做任何模型改动都默认先把特征图可视化脚本架起来。看起来是多花了一上午时间但后面每一次训练迭代、每一个数据增广策略都有了直观的反馈源。如果你还没把手上的CRFNet特征图可视化做起来建议直接从文中的hook代码开始跑通之后再慢慢加上坐标对齐、Grad-CAM和差分图分析。你大概率会跟我一样发现那些藏在loss曲线和mAP指标背后的细节才是真正决定模型上限的东西。