刚接触多模态模型时,我也和很多人一样,埋头调网络结构、盯着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.DataParallel,hook的名字层级会发生变化,这时候建议直接用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, lower=1, upper=99): # 输入 [C, H, W],输出聚合后的二维图 agg = fmap.numpy().max(axis=0) # 通道间取max lo = np.percentile(agg, lower) hi = np.percentile(agg, upper) if hi - lo < 1e-6: return np.zeros_like(agg, dtype=np.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, alpha=0.5): # radar_heat和fusion_heat都是归一化后的二维图,需要先resize到图像大小 H, W = image.shape[:2] radar_heat_resize = cv2.resize(radar_heat, (W, H), interpolation=cv2.INTER_LINEAR) fusion_heat_resize = cv2.resize(fusion_heat, (W, H), interpolation=cv2.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(gradient=one_hot) # 取目标层的梯度 grads = model.fusion_module.weight.grad # 这里仅示意,实际需从hook里保存梯度 weights = grads.mean(dim=(2, 3), keepdim=True) cam = torch.relu((weights * fusion_fea).sum(dim=1, keepdim=True)) 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, eps=1e-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指标背后的细节,才是真正决定模型上限的东西。