☰
PyTorch实战:FPN目标检测objectness map可视化与调试指南
2026/10/15 11:51:54 网站建设 项目流程

1. 从一张“热力图”说起:objectness map 到底在告诉我们什么

做目标检测的朋友大概率都经历过这样一个阶段:模型训练完了,mAP 看着还行,但心里总是不踏实——它到底“看”到了什么?尤其是用 FPN(Feature Pyramid Network)做多尺度检测的时候,P3、P4、P5 每一层都在输出自己的预测,这些预测叠加起来到底长什么样,光看数字是看不出来的。这时候,把 objectness map 可视化出来,就成了一件特别有价值的事。

objectness map,直译过来就是“目标性热力图”。它反映的是网络在特征图的每个空间位置上,判断“这里有没有物体”的置信程度。注意,它不区分具体类别,只回答“有没有东西”这个问题。在 FPN 结构里,每一层金字塔都会产生自己的 objectness 预测,把这些预测按空间位置映射回原图,就能得到一张热力图,颜色越亮的地方,说明网络越确信那里有目标。

这篇文章要聊的,就是怎么用 PyTorch 把 FPN 层的 objectness map 可视化出来。我会从 FPN 的输出结构讲起,一步步拆解怎么拿到每一层的 objectness 预测、怎么做 sigmoid 激活、怎么把不同尺度的特征图对齐回原图、怎么叠加显示。中间会涉及不少实操细节,比如 anchor 的排布方式、特征图的 stride 计算、上采样的对齐问题,这些都是我自己踩过坑的地方。适合已经跑通过基础检测模型、想进一步理解模型内部行为的读者,也适合正在做模型调试和可解释性分析的同学。

先说清楚一件事:objectness map 的可视化不是“锦上添花”,它在实际调试中非常有用。比如模型对某些小目标漏检严重,你可以直接看 P3 层的热力图,判断是特征本身没学到东西,还是 anchor 匹配出了问题;再比如模型在背景区域误检多,看热力图就能发现是不是某些层的 objectness 普遍偏高。这些信息,比单纯看 loss 曲线直观得多。

2. FPN 的多尺度输出结构:先搞清楚你要可视化的东西在哪

2.1 FPN 到底输出了几层,每层代表什么

标准的 FPN 结构,是在 backbone(比如 ResNet)的基础上,把不同 stage 的特征图拿过来,经过横向连接(lateral connection)和自顶向下(top-down)的融合,生成一组金字塔特征。以 ResNet-50 为例,通常取 C3、C4、C5 三个 stage 的输出,对应 stride 为 8、16、32,融合之后得到 P3、P4、P5,有时候还会在 P5 后面接一个 stride 为 64 的 P6,用于更大的目标。

每一层金字塔后面都会接一个检测头(detection head),这个头通常包含两个分支:一个负责分类(classification),一个负责回归(regression)。objectness 就藏在分类分支里。对于二阶段检测器(比如 Faster R-CNN),RPN 阶段输出的就是 objectness,它只有前景和背景两个类别;对于单阶段检测器(比如 RetinaNet、FCOS),分类分支输出的是每个类别的分数,但很多实现里也会单独保留一个 objectness 通道,或者用所有类别分数的最大值来近似。

这里有个容易混淆的点:不同检测框架里 objectness 的定义不完全一样。RPN 里的 objectness 是二分类的,经过 sigmoid 之后直接就是前景概率;而 RetinaNet 里的分类分支是多分类的,每个 anchor 对应 C 个类别的分数,没有单独的 objectness。如果你用的是 RetinaNet,想看 objectness,通常的做法是取所有类别分数的最大值,或者取背景类之外的最大值。这个细节在可视化之前一定要搞清楚,否则你画出来的热力图可能根本不是你想要的东西。

2.2 每一层的特征图尺寸和 stride 是怎么算的

要可视化,就必须知道每一层特征图上的一个点对应原图的哪个区域。这个对应关系由 stride 决定。假设输入图像是 800×800,P3 的 stride 是 8,那么 P3 特征图的尺寸就是 100×100;P4 的 stride 是 16,尺寸是 50×50;P5 的 stride 是 32,尺寸是 25×25。这个计算看起来简单,但实际中经常出问题,因为输入图像不一定能被 stride 整除,很多框架会做 padding,导致特征图尺寸和理论值有偏差。

更稳妥的做法是直接从特征图的 shape 反推 stride:stride_h = input_h / feat_h,stride_w = input_w / feat_w。这样不管框架怎么 padding,你都能拿到准确的映射关系。我在实际项目里就遇到过因为手动假设 stride 导致热力图整体偏移半个格子的情况,排查了半天才发现是 padding 的问题。

另外要注意的是,FPN 的 P3 到 P5 虽然 stride 不同,但它们的通道数通常是一样的(比如都是 256)。这意味着你可以用同一套可视化代码处理所有层,只需要根据 stride 做不同的上采样倍数就行。

2.3 objectness 在检测头里的具体位置

以 Faster R-CNN 的 RPN 为例,RPN head 通常是一个 3×3 卷积后面接两个 1×1 卷积,一个输出num_anchors * 2个通道(前景和背景),另一个输出num_anchors * 4个通道(回归偏移)。这里的num_anchors是每个空间位置预设的 anchor 数量,通常是 3 或者 9。objectness 就是那个num_anchors * 2通道的输出,经过 reshape 和 sigmoid 之后,得到每个 anchor 的前景概率。

如果你用的是 torchvision 的 FasterRCNN,RPN 的输出可以通过 hook 拿到。但要注意,torchvision 的实现里,RPN 的 objectness 是在rpn.head里计算的,你需要注册一个 forward hook 到对应的卷积层上,才能拿到原始输出。这个输出是未经 sigmoid 的 logits,需要自己做激活。

对于单阶段检测器,比如 FCOS,分类分支的输出是num_anchors * num_classes个通道,每个位置有多个 anchor 点,每个点对应所有类别的分数。这时候 objectness 可以取所有类别分数的最大值,也可以取前景类别的分数之和。具体取哪种,取决于你想看什么。如果只想看“有没有物体”,取最大值就够了;如果想看“网络对前景的整体响应”,取和可能更合适。

3. 拿到 objectness 原始输出:hook 机制与张量拆解

3.1 用 forward hook 截获中间层输出

PyTorch 的 hook 机制是拿中间层输出的标准手段。你不需要修改模型源码,只需要在目标层上注册一个 hook,就能在 forward 过程中把输出“偷”出来。对于 FPN 的每一层,你需要找到对应的检测头卷积层,然后分别注册 hook。

具体操作是这样的:先遍历模型的命名模块,找到名字里包含rpn或者cls_logits之类的层,然后调用register_forward_hook。hook 函数接收三个参数:module、input、output。output 就是这一层的输出张量,你可以把它存到一个字典里,key 用层名或者层索引。

这里有个坑:有些模型的检测头是共享的,也就是说 P3、P4、P5 用的是同一个卷积层。这种情况下,hook 会被调用多次,你需要根据 input 的 shape 来区分是哪一层的输出。我一般会在 hook 里记录 input 的 spatial size,然后跟已知的 stride 对应起来。

还有一种情况是模型用了torch.no_grad()或者torch.inference_mode(),这时候 hook 依然能正常工作,但要注意输出张量可能不保留梯度,如果你后续需要做反向传播相关的分析,就得在训练模式下跑。

3.2 从num_anchors * 2通道里拆出前景概率

拿到原始输出之后,下一步是拆解。假设输出张量的 shape 是[batch, num_anchors * 2, H, W],你需要把它 reshape 成[batch, num_anchors, 2, H, W],然后在类别维度上做 softmax 或者 sigmoid。对于 RPN 来说,通常用的是 sigmoid,因为前景和背景是独立的二分类;但有些实现用的是 softmax,这时候两个通道是互斥的。

拆解的时候要注意通道的顺序。不同的实现里,前景和背景的排列可能不一样。有的实现是[前景, 背景, 前景, 背景, ...],有的是[背景, 前景, 背景, 前景, ...]。如果你搞反了,画出来的热力图就是反的——背景亮、前景暗。我一般会先取第一个 anchor 的两个通道,分别做 sigmoid,然后看哪个通道的均值更高。通常前景概率的均值会低一些,因为大部分位置是背景。但这也不是绝对的,最稳妥的办法是看源码或者打印几个样本验证一下。

拆解完之后,你会得到[batch, num_anchors, H, W]的前景概率张量。接下来要做的,是把num_anchors这个维度处理掉。有两种做法:一种是取所有 anchor 的最大值,表示“这个位置只要有一个 anchor 认为有物体,就算有”;另一种是取均值,表示“这个位置所有 anchor 的平均置信度”。我一般用最大值,因为 objectness map 的目的是找“哪里可能有物体”,取最大值更敏感,不容易漏掉小目标。

3.3 多尺度输出的统一处理策略

FPN 的每一层输出 shape 都不一样,但处理逻辑是一样的。你可以写一个函数,接收原始输出和该层的 stride,返回一张归一化到 [0, 1] 的热力图。这个函数内部做三件事:reshape、激活、聚合 anchor 维度。

处理完之后,每一层都会得到一张[H, W]的热力图。这些热力图的尺寸不同,P3 最大,P5 最小。为了叠加显示,你需要把它们统一到同一个尺寸。通常的做法是上采样到原图尺寸,或者上采样到 P3 的尺寸。上采样到原图尺寸更直观,但计算量大一些;上采样到 P3 尺寸够用,而且速度快。

上采样的时候,推荐用双线性插值(F.interpolate的mode='bilinear'),不要用最近邻。最近邻会产生块状伪影,看起来很不舒服,而且会掩盖一些细节。双线性插值虽然会模糊一些,但整体趋势更清晰。

4. 把热力图对齐回原图:上采样、归一化与叠加显示

4.1 上采样倍数的精确计算

上采样倍数不是简单地把特征图尺寸乘以 stride 就完事了。因为输入图像可能经过 resize,特征图的尺寸和原图尺寸之间不一定严格是 stride 的整数倍关系。正确的做法是:先拿到原图的尺寸(orig_h, orig_w),然后计算scale_h = orig_h / feat_h,scale_w = orig_w / feat_w,用这个 scale 去做F.interpolate的size参数。

如果你用的是scale_factor参数,要注意它只能接受一个数或者一个元组,而且必须是浮点数。我一般直接用size=(orig_h, orig_w),这样最稳妥,不用担心舍入误差。

还有一个细节:FPN 的 P3 到 P5 虽然 stride 不同,但它们的感受野有重叠。上采样到原图之后,你会发现同一块区域在多个层上都有响应。这时候你可以选择只显示某一层,也可以把多层叠加起来。叠加的方式有几种:取最大值、取平均、或者按权重加权。取最大值最能突出“任意一层认为有物体”的区域;取平均则更平滑,适合看整体趋势。

4.2 热力图的归一化:为什么不能直接画原始概率

原始的前景概率经过 sigmoid 之后,范围是 (0, 1),但大部分值都集中在 0 附近,因为背景区域占绝大多数。如果你直接把原始概率映射到颜色,会发现整张图几乎全是暗的,只有极少数点稍微亮一点,根本看不出结构。

所以必须做归一化。常用的做法有两种:一种是全局归一化,把整张图的最小值映射到 0,最大值映射到 1;另一种是分位数归一化,把 1% 分位数映射到 0,99% 分位数映射到 1。全局归一化简单,但如果图里有一个极亮的点,其他区域就会被压得很暗。分位数归一化更鲁棒,能保留更多细节。

我一般用分位数归一化,具体做法是:先展平热力图,用torch.quantile计算 0.01 和 0.99 分位数,然后做 clip 和线性映射。这样画出来的图,亮区更集中,暗区也不会全黑,整体对比度更好。

归一化之后,还要决定用什么 colormap。常用的有 jet、viridis、hot 等。jet 对比度高,但颜色过渡不够自然;viridis 更均匀,适合做科学可视化;hot 从黑到红到黄到白,适合突出高亮区域。我个人偏好 jet,因为它在检测任务里用得多,看起来习惯。

4.3 叠加到原图上的两种方式

热力图最终要叠加到原图上,才能看出它和实际物体的对应关系。叠加方式有两种:一种是半透明覆盖,把热力图转成 RGBA,然后和原图做 alpha blend;另一种是画等高线或者轮廓,只显示高亮区域的边界。

半透明覆盖更直观,但要注意透明度。透明度太高,原图看不清;透明度太低,热力图不明显。我一般用 0.5 左右的 alpha,然后根据实际效果微调。如果热力图本身对比度不够,可以先把热力图做一次 gamma 校正,提升暗部细节。

等高线方式适合做定量分析,比如你想测量某个高亮区域的面积,或者想看不同层的响应边界是否一致。OpenCV 的findContours和drawContours可以很方便地画出轮廓。但这种方式不够直观,一般用于辅助分析,不用于展示。

还有一个技巧:可以把原图转成灰度,然后和热力图做加权叠加。这样热力图的颜色不会被原图的颜色干扰,看起来更干净。具体做法是:gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY),然后gray_rgb = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR),再和热力图叠加。

5. 实操中容易踩的坑:从通道顺序到坐标偏移

5.1 通道顺序搞反导致前景背景颠倒

这是最常见的问题。前面提到过,不同实现里前景和背景的通道顺序可能不一样。如果你发现热力图在背景区域特别亮,而在物体区域很暗,大概率是通道顺序搞反了。解决办法很简单:把两个通道交换一下,重新画一遍。但更根本的办法是,在拆解之前先打印几个样本的通道均值,确认哪个是前景。

还有一种情况是,模型用了 focal loss 或者其他的损失函数,导致 objectness 的数值范围不是标准的 (0, 1)。这时候 sigmoid 之后可能大部分值都很小,归一化之后看起来还行,但实际阈值需要调整。我一般会先画一张原始概率的直方图,看看分布情况,再决定归一化策略。

5.2 特征图坐标和原图坐标的偏移问题

这个问题比较隐蔽。FPN 的特征图上的一个点,对应的原图区域不是一个点,而是一个感受野。如果你直接把特征图上的坐标乘以 stride,得到的原图坐标其实是感受野的中心,而不是左上角。对于可视化来说,这个偏移通常可以忽略,因为感受野比较大,中心对齐已经够用了。但如果你要做精确的定量分析,比如计算某个高亮区域和标注框的 IoU,这个偏移就会影响结果。

更麻烦的是 padding 导致的偏移。如果输入图像经过了 padding,特征图的边界和原图的边界就不对齐了。这时候你需要根据 padding 的大小做补偿。具体补偿多少,取决于框架的实现。我一般会在可视化之前,先用一个已知位置的物体做测试,看看热力图的高亮区域是否和物体对齐,如果不对齐,就调整偏移量。

5.3 多尺度叠加时的权重分配

把 P3、P4、P5 的热力图叠加在一起的时候,如果直接取平均,小目标的响应会被大目标的响应淹没。因为 P5 的感受野大,对大目标响应强,但小目标在 P5 上几乎没响应。反过来,P3 对小目标响应强,但对大目标可能只响应局部。

所以叠加的时候,最好按层加权。一个经验性的做法是:P3 的权重高一些,P4 中等,P5 低一些。具体权重可以根据你的数据集调整。如果你的数据集以小目标为主,P3 的权重可以设到 0.5 以上;如果以大目标为主,P5 的权重可以适当提高。

还有一种做法是,不叠加,而是把三层并排显示。这样虽然占地方,但信息最完整,不会互相干扰。我在做模型调试的时候,通常用并排显示;做展示的时候,才用叠加。

6. 从热力图里读出模型的行为:几个真实的调试案例

6.1 小目标漏检:P3 层热力图一片漆黑

有一次我训练一个检测模型,小目标的召回率一直上不去。我把 P3 层的 objectness map 画出来,发现小目标所在区域几乎全是暗的,说明网络根本没有在这些位置产生高置信度的前景预测。进一步排查发现,是 anchor 的尺度设置有问题——最小的 anchor 还是太大,和小目标的尺寸不匹配。调整 anchor 尺度之后,P3 的热力图明显亮了,召回率也上去了。

这个案例说明,objectness map 可以直接反映 anchor 匹配的质量。如果某个尺度的目标在对应层上热力图很暗,大概率是 anchor 设置不合理。

6.2 背景误检:P5 层出现大面积高亮

另一个案例是,模型在天空、墙面等纯背景区域产生了大量误检。我看 P5 层的热力图,发现这些区域有大面积的中等亮度响应。这说明网络在 P5 层对背景的抑制不够。后来我在训练时增加了背景样本的权重,并且调整了 P5 层的正负样本采样比例,误检明显减少。

这个案例说明,objectness map 不仅能看“哪里检到了”,还能看“哪里不该检却检了”。后者对于减少误检特别有用。

6.3 多尺度不一致:同一物体在不同层上的响应位置偏移

还有一个比较有意思的现象:同一个物体,在 P3 上的高亮区域偏左,在 P5 上偏右。这说明不同层的特征对齐有问题。排查后发现,是 FPN 的自顶向下路径里,上采样和横向连接相加的时候,没有做正确的对齐。修正之后,各层的响应位置就一致了。

这个案例说明,objectness map 还可以用来检查 FPN 的特征对齐质量。如果你发现不同层的热力图对同一个物体的响应位置有明显偏移,那就要检查 FPN 的实现细节了。

7. 把可视化做成工具:封装、批处理与交互式查看

7.1 封装成一个可复用的函数

每次调试都写一遍 hook、拆解、上采样、叠加,太麻烦了。我一般会封装成一个函数,输入是模型、图像、目标层名称列表,输出是叠加了热力图的可视化结果。函数内部自动完成 hook 注册、前向传播、输出提取、热力图生成、上采样、叠加的全过程。

封装的时候要注意,hook 注册之后要及时移除,否则下次调用会重复注册,导致内存泄漏。我一般用handle = layer.register_forward_hook(fn)拿到 handle,用完调handle.remove()。

另外,函数最好支持指定 batch 里的某一张图。因为模型通常是以 batch 为单位前向的,但可视化的时候你只想看其中一张。这时候可以在 hook 里只保留对应 batch index 的输出,减少内存占用。

7.2 批处理:一次看多张图的热力图

调试的时候,往往需要看多张图的热力图,才能发现规律。一张一张跑太慢,可以做成批处理。具体做法是:把多张图组成一个 batch,一次前向,然后在 hook 里把每张图的输出分别存下来。后处理的时候,对每张图分别生成热力图,然后拼成一张大图。

拼图的时候,可以用torchvision.utils.make_grid,也可以手动用 numpy 拼接。我一般用 numpy 拼接,因为可以灵活控制间距和标签。拼完之后,用 matplotlib 显示或者保存成图片。

批处理的时候要注意,不同图像的尺寸可能不一样。如果尺寸不一样,就没法组成一个 batch。这时候要么统一 resize 到相同尺寸,要么逐张处理。我一般选择统一 resize,因为检测模型通常也要求输入尺寸一致。

7.3 交互式查看:用滑块切换不同层

如果是在 Jupyter Notebook 里做分析,可以用 ipywidgets 做一个交互式滑块,拖动滑块切换显示 P3、P4、P5 的热力图。这样比反复运行代码方便得多。

实现思路是:先把三层热力图都算好,存到一个列表里,然后用ipywidgets.interact或者ipywidgets.IntSlider绑定一个显示函数。显示函数根据滑块的值,选择对应的热力图,用 matplotlib 画出来。matplotlib 的imshow在 Notebook 里是动态更新的,不需要重新创建 figure。

这个技巧在探索性分析阶段特别有用,可以快速对比不同层的响应模式,发现一些静态图片看不出来的规律。

8. 几个提升可视化效果的细节技巧

8.1 用高斯模糊平滑热力图

原始的热力图是逐像素的,看起来比较噪。做一次高斯模糊,可以让热力图更平滑,趋势更明显。模糊的核大小可以根据特征图的尺寸调整,一般用 5×5 或者 7×7 就够了。模糊之后再做归一化,效果更好。

但要注意,模糊会损失一些细节,如果你要看精确的边界,就不要模糊。我一般会同时保留模糊和未模糊两个版本,模糊的用于展示,未模糊的用于分析。

8.2 用对数变换增强暗部细节

如果热力图的动态范围很大,亮区很亮,暗区很暗,可以用对数变换压缩动态范围。具体做法是:log_map = torch.log(heatmap + eps),然后再归一化。这样暗部的细节会被提升,亮区也不会过曝。

对数变换的缺点是会放大噪声,所以最好先做一次轻微的模糊,再做对数变换。另外,eps 不能太小,否则 log 会趋向负无穷。一般取 1e-6 或者 1e-8。

8.3 保存为高质量图片的注意事项

保存热力图的时候,要注意分辨率。matplotlib 默认的 DPI 是 100,保存出来的图片可能不够清晰。可以设置dpi=300或者更高。另外,保存格式推荐用 PNG,不要用 JPEG,因为 JPEG 是有损压缩,会在热力图的高对比度边缘产生伪影。

如果热力图要放到论文或者报告里,还要注意字体大小和颜色搭配。matplotlib 的默认字体在缩小之后可能看不清,可以手动设置fontsize。颜色方面,如果打印出来是黑白的,jet colormap 可能区分度不够,可以考虑用灰度 colormap 或者 viridis。

9. 写在最后:一点个人体会

objectness map 的可视化,看起来只是画一张图,但背后涉及的东西不少:FPN 的结构、检测头的输出格式、hook 机制、张量操作、图像处理、坐标映射,每一个环节出问题,画出来的图都不对。我自己在最初做这个的时候,光是通道顺序就搞反了好几次,后来养成了一个习惯:每次画完图,先拿一个已知位置的物体做验证,确认高亮区域和物体对齐了,再往下做分析。

另外一个体会是,可视化不是一次性的工作,而是一个迭代的过程。你可能会先画一个粗糙的版本,发现某些区域不对劲,然后去排查原因,调整代码,再画一遍。这个过程本身就是在理解模型。很多时候,你以为是模型的问题,其实是可视化代码的问题;反过来,有时候你以为是可视化代码的问题,其实是模型真的有问题。分清楚这两者,需要经验,也需要耐心。

如果你也在做类似的工作,我的建议是:先把单层的热力图做对,再做多层叠加;先把单张图做对,再做批处理;先把静态图做对,再做交互式。一步一步来,每一步都验证清楚,最后得到的工具才可靠。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询