资讯详情

资讯详情

FPN多尺度目标检测objectness热力图可视化与调试实战

1. 从一张热力图说起objectness map到底在告诉我们什么做过目标检测的朋友大概率都经历过这样的场景模型训练完了mAP看着还行但心里总不踏实——它到底“看”到了什么尤其是用FPN结构做多尺度检测的时候P3、P4、P5每一层各管一段尺度最后到底哪一层对哪个目标响应最强这时候把objectness map拉出来可视化基本是最直接的排查手段。objectness map中文一般叫“目标性热力图”或者“物体置信度图”它反映的是检测头在特征图每个空间位置上预测“这里有东西”的概率强度。注意它和最终的分类置信度不是一回事——objectness只管“有没有目标”不管“是什么目标”。在FPN这种多尺度结构里每一层都会输出自己的objectness预测把这些预测图叠加回原图尺寸就能直观看到网络在不同尺度上的注意力分布。这篇文章面向的是已经跑通过至少一个检测模型、想进一步做调试和可解释性分析的读者。如果你还在纠结怎么搭模型那这篇可能稍微超前了一点但如果你已经能训练出结果只是不知道模型为什么漏检、为什么误检那接下来的内容应该能帮上忙。我会从FPN的输出结构讲起一步步拆到怎么把objectness map画得既准确又好看中间穿插我自己踩过的坑和几个实用技巧。2. FPN层的输出结构与objectness map的来源2.1 FPN到底输出了什么FPNFeature Pyramid Network的核心思路不复杂骨干网络自底向上提特征然后自顶向下做上采样并和横向连接的特征相加得到一组分辨率递减、语义递增的特征图。以常见的ResNetFPN为例通常会得到P3、P4、P5、P6、P7五层stride分别是8、16、32、64、128。检测头接在每一层后面输出两个分支一个是分类分支输出每个anchor在每个类别上的概率另一个是回归分支输出框的偏移量。但很多人会忽略一点——在不少实现里分类分支的第一个通道或者单独一个通道就是objectness。比如在一些单阶段检测器的实现中分类头输出的通道数是num_classes而objectness是隐含在分类置信度里的但在另一些实现里会显式地多出一个通道专门预测objectness。注意不同代码库对objectness的定义不一样。有的把“前景vs背景”的二分类结果叫objectness有的把分类分支的最大值叫objectness。做可视化之前一定要先确认你用的模型到底输出的是什么。2.2 为什么FPN每一层都要单独可视化这里有个很容易犯的错误只可视化最后一层的objectness或者把所有层的输出简单相加。FPN的设计初衷就是让不同层负责不同尺度的目标P3的stride是8感受野小适合小目标P7的stride是128感受野大适合大目标。如果你只看一层很可能得出错误结论——比如P5上小目标响应弱你就以为模型学废了其实小目标本来就不该由P5负责。所以正确的做法是每一层的objectness map单独提取、单独可视化然后再对比着看。这样才能判断出“某个目标漏检是因为负责它的那一层没响应还是因为后处理把它滤掉了”。2.3 objectness map的数值范围与归一化objectness的输出通常经过sigmoid激活数值在0到1之间。但实际拿到的张量可能是logits也可能是已经过sigmoid的概率。可视化之前必须确认这一点否则画出来的热力图要么全白要么全黑。我一般会先打印一下张量的min和maxprint(fmin: {obj.min().item():.4f}, max: {obj.max().item():.4f})如果范围在负数到正数之间说明是logits需要手动过sigmoid如果已经在0到1之间那大概率已经激活过了。归一化的时候我倾向于用固定的0到1范围而不是按当前图的最大最小值做拉伸。原因是按图拉伸会放大噪声让本来响应很弱的地方看起来也很亮容易误判。固定范围虽然对比度低一点但更真实。3. 动手之前环境准备与模型输出钩子3.1 需要哪些依赖这套可视化流程对环境的依赖其实很轻核心就是PyTorch和Matplotlib再加上OpenCV或者PIL做图像读写。我常用的组合是PyTorch 1.10以上太老的版本hook机制有些差异torchvision用来做图像预处理OpenCV或者Pillow读原图Matplotlib画热力图和叠加图NumPy做数组运算不需要额外装什么可视化库Matplotlib足够灵活而且能精确控制颜色映射和透明度。3.2 用hook提取FPN各层输出最优雅的方式是注册forward hook而不是去改模型源码。hook的好处是不侵入原模型用完就删不会影响后续推理。下面是我常用的写法import torch import torch.nn as nn class FPNHook: def __init__(self, model, layer_names): self.model model self.layer_names layer_names self.features {} self.hooks [] def _make_hook(self, name): def hook(module, input, output): self.features[name] output return hook def register(self): for name, module in self.model.named_modules(): if name in self.layer_names: h module.register_forward_hook(self._make_hook(name)) self.hooks.append(h) def remove(self): for h in self.hooks: h.remove() self.hooks []这里的关键是layer_names怎么填。不同代码库的层命名不一样我一般会先打印一遍named_modules()找到FPN输出那几层的名字。常见的有fpn.inner_blocks.0、fpn.layer_blocks.0这种也有直接叫p3、p4的。提示注册hook之后第一次前向传播会填充self.features字典。记得在推理结束后调用remove()否则hook会一直挂着多次推理会累积内存。3.3 确认objectness在输出张量中的位置拿到某一层的输出后它可能是多种形状。常见的有[batch, num_anchors * (num_classes 1), H, W]其中最后一个通道组里包含objectness[batch, num_anchors, H, W]直接就是objectness[batch, num_classes, H, W]需要取最大值或者特定通道我一般会先跑一个dummy输入打印每一层输出的shape然后对照模型定义去确认。这一步不能偷懒搞错了后面全白费。4. 核心实操从张量到热力图的完整流程4.1 单层objectness的提取与reshape假设我们已经确认某一层的输出是[1, num_anchors * num_classes, H, W]并且objectness是每个anchor的第一个类别也就是前景类。那么提取过程大概是import torch.nn.functional as F def extract_objectness(feat, num_anchors, num_classes): # feat: [1, num_anchors * num_classes, H, W] b, c, h, w feat.shape feat feat.view(b, num_anchors, num_classes, h, w) # 取前景类假设索引为0 obj feat[:, :, 0, :, :] # [1, num_anchors, H, W] # 对anchor维度取最大值 obj, _ obj.max(dim1) # [1, H, W] return obj这里对anchor维度取max而不是mean是因为每个位置可能有多个anchor只要有一个anchor响应强就说明这个位置可能有目标。取mean会把强响应稀释掉。如果输出已经是sigmoid后的概率直接取max即可如果是logits记得先过sigmoid。4.2 上采样回原图尺寸FPN各层的H和W是不一样的P3可能是80x80P7可能只有10x10。要叠加到原图上必须上采样回原图尺寸。我一般用双线性插值obj obj.unsqueeze(0) # [1, 1, H, W] obj_up F.interpolate(obj, size(img_h, img_w), modebilinear, align_cornersFalse) obj_up obj_up.squeeze().cpu().numpy()align_cornersFalse是我习惯的设置它在大多数情况下更平滑。如果你发现边缘有偏移可以试试True但一般不建议。注意上采样之后原本低分辨率的热力图会变得很“块状”。这是正常的因为P7本来就只有10x10插值到640x640必然是大色块。看的时候要心里有数不要以为是bug。4.3 热力图叠加与原图融合单纯画热力图不够直观我一般会把热力图以半透明的方式叠加到原图上。Matplotlib的imshow配合alpha就能做到import matplotlib.pyplot as plt import matplotlib.cm as cm def overlay_heatmap(img, heatmap, alpha0.5, colormapjet): # img: HxWx3, uint8 # heatmap: HxW, 0~1 heatmap np.clip(heatmap, 0, 1) cmap cm.get_cmap(colormap) heatmap_color cmap(heatmap)[:, :, :3] # 去掉alpha通道 heatmap_color (heatmap_color * 255).astype(np.uint8) overlay (img * (1 - alpha) heatmap_color * alpha).astype(np.uint8) return overlaycolormap我常用jet因为对比度高红黄蓝分明。但jet有个缺点人眼对绿色区域的变化不敏感容易低估中等响应。如果要做定量分析建议用viridis或者inferno感知均匀性更好。4.4 多层对比图的排版单层看完了接下来要把P3到P7放在一起对比。我一般用Matplotlib的subplot一行放原图加各层热力图fig, axes plt.subplots(1, 6, figsize(24, 4)) axes[0].imshow(img) axes[0].set_title(Original) for i, (name, obj_map) in enumerate(layer_maps.items()): axes[i1].imshow(img) axes[i1].imshow(obj_map, alpha0.5, cmapjet) axes[i1].set_title(name) for ax in axes: ax.axis(off) plt.tight_layout() plt.savefig(fpn_objectness.png, dpi150, bbox_inchestight)这样一张图就能看出不同层的响应差异。实测下来P3对小目标响应强P5对大目标响应强中间层P4比较均衡。如果某一层几乎全黑那就要警惕了——可能是该层的objectness预测塌缩了。5. 参数选择与视觉效果调优5.1 上采样方式对热力图的影响双线性插值是最常用的但它会让热力图看起来比较“糊”。如果你想要更锐利的效果可以试试最近邻插值obj_up F.interpolate(obj, size(img_h, img_w), modenearest)最近邻的好处是保留了原始格子的边界能清楚看到每个特征点的响应。缺点是块状感太强叠加到原图上不太美观。我的经验是做定量分析用最近邻做展示用双线性。5.2 阈值截断去掉低响应的噪声objectness map里大量区域响应值很低比如0.01到0.1之间。这些低响应区域叠加到原图上会让整张图蒙一层淡蓝色影响观察。我一般会设一个阈值比如0.2低于这个值的直接置零heatmap[heatmap 0.2] 0阈值怎么选没有固定标准。我的做法是先看热力图的直方图找到那个明显的“低谷”把阈值设在低谷附近。如果响应分布比较连续那就凭经验设0.15到0.3之间。5.3 颜色映射的选择与对比不同colormap对同一张热力图的呈现差异很大。我做过一个简单对比colormap优点缺点适用场景jet对比度高红黄蓝分明感知不均匀绿色区域变化被低估快速展示viridis感知均匀色盲友好对比度偏低定量分析inferno暗部细节好高亮突出整体偏暗暗背景图像hot热度感强直观只有红黄白层次少单目标突出我个人的习惯是论文或者正式报告用viridis内部调试用jet因为jet一眼就能看出哪里最红。5.4 多anchor的聚合策略前面提到对anchor维度取max。但有些实现里每个位置有3个anchor小、中、大取max会偏向大anchor因为大anchor的感受野大响应往往更强。如果你想看每个anchor单独的响应可以分别可视化for a in range(num_anchors): obj_a feat[:, a, 0, :, :] # 单独可视化这样能看出不同尺度的anchor对同一目标的响应差异。实测下来小目标在小anchor上响应强大目标在大anchor上响应强符合预期。6. 常见问题与排查实录6.1 热力图全黑或者全白这是最常见的问题。原因通常有三个张量没经过sigmoid全是负数或者很大的正数。解决打印min/max确认。归一化范围搞错了。比如数据在0到1之间但你按0到255归一化结果全黑。取错了通道。比如把背景类当成前景类取了背景类响应普遍很低。排查顺序先打印min/max再确认通道索引最后检查归一化。6.2 热力图和原图对不上叠加之后发现热力图的目标位置偏移了。这通常是预处理的问题。模型输入经过了resize、padding、归一化而原图没有。你需要把热力图映射回原图坐标系而不是直接叠加到原图上。如果预处理是简单的resize那把热力图resize回原图尺寸就行。如果有padding需要先裁掉padding区域再resize。我一般会在预处理阶段记录下scale和pad参数可视化时反向操作。6.3 某一层完全没有响应如果P7的objectness全是0可能是该层没有正样本匹配。在anchor匹配阶段如果大目标的GT框没有匹配到P7的anchorP7就学不到东西。该层的梯度被截断了。有些实现会对低层梯度做缩放如果配置不当高层可能梯度消失。该层根本没参与损失计算。检查一下损失函数里是不是只用了P3到P5。6.4 热力图看起来太“糊”上采样导致的模糊是不可避免的但可以通过以下方式改善用最近邻插值代替双线性在叠加前对热力图做锐化比如用cv2.filter2D加一个锐化核提高输入图像分辨率让特征图本身更大不过要记住糊不代表错低分辨率特征图本来就是这样。不要为了好看而过度处理导致失真。6.5 显存不够怎么办FPN五层全提取如果输入分辨率很大显存会吃紧。我的做法是用torch.no_grad()包住推理过程每提取一层就搬到CPU不要全留在GPU上如果还是不够减小batch size到1with torch.no_grad(): outputs model(img) for name, feat in hook.features.items(): obj extract_objectness(feat) obj obj.cpu() # 立刻搬走7. 几个进阶技巧与个人经验7.1 把objectness和最终检测框叠在一起看单独看objectness map还不够最好把最终检测框也画上去。这样能直接看出某个目标有框但objectness很低说明是后处理救回来的某个目标objectness很高但没框说明被NMS滤掉了或者分类置信度不够。我一般用不同颜色画框绿色是最终保留的红色是被滤掉的。这样一眼就能定位问题。7.2 用滑动窗口看局部响应整张图看完了如果某个区域有问题可以裁出来放大看。我经常把漏检区域裁出来单独可视化该区域的objectness往往能发现是特征图对应位置响应就是弱而不是后处理的问题。7.3 对比不同checkpoint的objectness训练过程中保存多个checkpoint分别可视化objectness能看出网络注意力的演化过程。我实测过早期epoch的objectness比较弥散随着训练进行响应越来越集中在目标上。如果某个epoch之后突然弥散回去那可能是学习率没降好或者过拟合了。7.4 注意batch normalization的影响如果模型里有BN层推理时一定要用model.eval()。否则BN会用batch统计量导致objectness输出和训练时不一致可视化结果没有参考价值。这个坑我踩过不止一次明明训练时响应很好可视化出来一片糊最后发现是忘了eval。7.5 保存中间结果方便复现可视化脚本我一般会加一个保存选项把每一层的objectness原始张量存成.npy文件。这样后面想换colormap或者调阈值不用重新跑模型直接加载就行。省时间也方便对比不同参数的效果。np.save(fobj_{name}.npy, obj_up)这个习惯帮我省了大量重复推理的时间尤其是模型比较大的时候。7.6 关于FPN层数选择的个人看法不是所有任务都需要P3到P7五层。如果你的数据里目标尺度比较集中比如都是中等大小那P4和P5可能就够了。层数多了反而增加计算量而且高层可能学不到有效特征。我一般会先可视化一遍看看哪些层真正有响应然后把没响应的层砍掉重新训练。实测下来砍掉冗余层之后mAP基本不掉速度还快了不少。7.7 一个容易忽略的细节特征图的对齐FPN的横向连接里上采样后的特征图和自底向上的特征图相加时如果尺寸差一个像素PyTorch会报错或者自动裁剪。但有些实现里用了align_cornersTrue做上采样导致特征图有半个像素的偏移。这个偏移在可视化时表现为热力图整体偏移一点点不容易发现但确实存在。如果你发现热力图总是往一个方向偏检查一下上采样的align_corners设置。8. 写在最后可视化不是目的定位问题才是objectness map可视化本身不复杂核心就是提取张量、上采样、叠加。但真正有价值的是你怎么用它。我见过不少人画了一堆漂亮的热力图然后呢没有然后了。图是给人看的看完了要能回答具体问题为什么这个目标漏了为什么那个区域误检了哪一层的特征没学好我的习惯是每次模型表现不如预期先画objectness再画最终检测框两个叠在一起看。大部分情况下问题要么出在某一层特征没响应要么出在后处理把好框滤掉了。定位到具体环节改起来就有方向了。最后分享一个小技巧如果你发现某一层objectness整体偏弱但其他层正常可以试试单独调高那一层的损失权重。我在一个项目里遇到过P3对小目标响应普遍偏低的情况把P3的损失权重从1.0调到1.5之后小目标召回明显改善。当然这个权重不能乱调调太高会导致其他层学不好一般1.0到2.0之间比较安全。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →