ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CenterNet无锚框检测实战:从热力图峰值到关键点估计的复现与调参

CenterNet无锚框检测实战:从热力图峰值到关键点估计的复现与调参

1. 为什么“把目标当成点”这件事值得单独写一篇笔记

第一次读到 CenterNet 那篇《Objects as Points》的时候,我正被一个检测项目折磨得够呛。当时用的是典型的 anchor-based 两阶段方案,光 anchor 的 scale 和 aspect ratio 就调了快两周,正样本判定阈值、NMS 的 IoU 门限、还有那一堆回归分支的权重平衡,随便动一个参数 mAP 就抖得厉害。后来同事甩给我这篇论文,说“你试试这个,没有 anchor,没有 NMS 调参地狱”,我半信半疑地复现了一版,结果在自建的一个小数据集上,训练时间直接砍掉一半,精度还涨了两个点。从那以后,CenterNet 就成了我处理检测类任务时的默认 baseline 之一。

这篇笔记想做的事情很明确:把 CenterNet 的核心思想、关键实现细节、以及我在复现和调参过程中踩过的坑,完整地梳理一遍。它不是论文的逐段翻译,而是站在一个实际动手复现过的从业者角度,讲清楚“为什么这么设计”“代码里到底怎么落地”“哪些地方容易翻车”。如果你正在做目标检测、关键点估计、或者任何需要从图像里定位物体的任务,不管你是刚入门想找个干净的框架上手,还是已经用过 Faster R-CNN、YOLO 系列想换个思路,这篇内容应该都能给你一些直接能用的东西。

核心关键词我先摆出来:CenterNet、Objects as Points、无锚框检测、热力图峰值、关键点估计。这几个词基本概括了它的技术定位——用关键点估计的思路做目标检测,把每个物体压缩成其中心点,通过预测热力图峰值来定位,再回归尺寸和偏移。听起来简单,但魔鬼全在细节里。

2. 核心思路拆解:为什么“点”比“框”更优雅

2.1 从 anchor 的痛点说起

要理解 CenterNet 为什么这么设计,得先搞清楚它想解决什么问题。传统的 anchor-based 检测器,不管是单阶段还是两阶段,本质上都在做同一件事:在特征图上铺满密密麻麻的候选框,然后分类+回归。这套范式统治了很多年,但它有几个天生的麻烦。

第一个麻烦是 anchor 的超参数太多。以典型的 FPN 结构为例,每个特征层级要设 3 种 scale、3 种 aspect ratio,5 个层级就是 45 个 anchor。这些 anchor 的尺寸得跟数据集里的目标分布匹配,换一个数据集就得重新聚类分析。我见过太多项目卡在这一步,anchor 没设好,召回率死活上不去。

第二个麻烦是正负样本的判定。anchor 和 GT 的 IoU 超过多少算正样本?0.5 还是 0.7?低于多少算负样本?中间那些“忽略”的怎么办?这套阈值逻辑不仅影响训练稳定性,还直接决定了模型能不能学到小目标。更别提还有 OHEM、Focal Loss 这些为了缓解正负样本不平衡而引入的额外机制。

第三个麻烦是 NMS。anchor 密集铺开之后,同一个物体周围会有一堆高分框,必须靠 NMS 去重。NMS 本身不算复杂,但它的 IoU 阈值又是一个需要调的参数,而且 NMS 是串行操作,在边缘设备上部署时对延迟影响不小。

CenterNet 的思路是:既然这些麻烦都来自“框”这个表示形式,那干脆别用框了。每个物体用一个点表示——就是它的中心点。检测问题转化成:在图像里找到所有物体的中心点,然后回归出每个点对应的宽高。这一下子把问题简化成了关键点估计,而关键点估计在姿态估计领域已经有非常成熟的方案(比如 Hourglass、CPN 这些),直接拿来用就行。

2.2 热力图峰值:把离散的点变成可学习的信号

“用中心点表示物体”这个想法本身不新鲜,难点在于怎么让网络学会输出这些点。如果直接回归坐标,会面临一个根本性问题:坐标是连续值,而且同一张图里物体数量不固定,网络输出维度没法固定。CenterNet 的解法是热力图。

具体来说,网络输出一张和输入图像下采样 4 倍的热力图,通道数等于类别数。热力图上每个位置的值表示“这个位置是某个类别物体中心点的概率”。训练时,把 GT 里每个物体的中心点投影到热力图上,用一个高斯核在对应位置生成一个峰值。这样,原本离散的点坐标就变成了一个平滑的、可微的监督信号。

这个高斯核的半径不是拍脑袋定的。论文里给了一个基于物体尺寸的自适应计算方式:半径和物体的宽高相关,物体越大,高斯核半径越大。背后的逻辑是,大物体的中心点定位允许有更大的容差,而小物体必须精确。这个细节很关键,我后面会展开讲怎么在代码里实现。

推理的时候,在热力图上做一次 3x3 的 max-pooling,保留局部峰值,然后取 top-k 个峰值作为检测结果。每个峰值的位置就是物体中心,对应的通道就是类别,再配合偏移和尺寸分支得到完整的框。整个过程没有 anchor,没有 IoU 阈值,没有 NMS(严格说有一个极简的峰值筛选,但和传统 NMS 完全不是一回事)。

2.3 三个输出分支的分工

CenterNet 的网络输出分三路,每一路都有明确的任务:

  • 热力图分支:输出 C 个通道(C 是类别数),每个通道是一张热力图,峰值位置表示该类物体的中心。
  • 偏移分支:输出 2 个通道,预测中心点相对于热力图网格的亚像素偏移。因为热力图是下采样 4 倍的,一个网格对应原图 4 个像素,直接取整会有量化误差,偏移分支就是用来补偿这个误差的。
  • 尺寸分支:输出 2 个通道,预测每个中心点对应物体的宽和高,单位是原图像素。

这三路输出共享同一个 backbone 的特征,通过几个轻量的 head 分别预测。整个结构非常干净,没有复杂的多尺度融合(虽然也可以加),没有 RPN,没有 RoI Pooling。我第一次看完结构图的时候有点不敢相信——这么简单的东西真能打?后来实测下来,在 COCO 上确实能到 40+ mAP,而且推理速度很快,这才服气。

3. 关键实现细节:从论文公式到可运行代码

3.1 高斯核半径的计算逻辑

前面提到,GT 中心点的高斯核半径是根据物体尺寸自适应计算的。论文里给的公式涉及物体宽高和 IoU 阈值,推导过程有点绕,但结论很清晰:半径 r 满足“以中心点为圆心、半径 r 的圆,和物体框的 IoU 不低于某个阈值(通常取 0.7)”。实际实现时,常用的做法是:

def gaussian_radius(det_size, min_overlap=0.7): height, width = det_size a1 = 1 b1 = (height + width) c1 = width * height * (1 - min_overlap) / (1 + min_overlap) sq1 = np.sqrt(b1 ** 2 - 4 * a1 * c1) r1 = (b1 + sq1) / 2 a2 = 4 b2 = 2 * (height + width) c2 = (1 - min_overlap) * width * height sq2 = np.sqrt(b2 ** 2 - 4 * a2 * c2) r2 = (b2 + sq2) / 2 a3 = 4 * min_overlap b3 = -2 * min_overlap * (height + width) c3 = (min_overlap - 1) * width * height sq3 = np.sqrt(b3 ** 2 - 4 * a3 * c3) r3 = (b3 + sq3) / 2 return min(r1, r2, r3)

这段代码我第一次看的时候觉得莫名其妙,三个候选半径取最小值是什么操作?后来自己推了一遍才明白:这三个半径分别对应“圆在框内”“圆包含框”“圆和框相交”三种几何关系下的临界情况,取最小值是为了保证在最严格的约束下 IoU 仍然达标。这个细节论文正文里没展开,但在官方代码里是这么实现的,复现时如果忽略,小目标的定位精度会明显下降。

注意:高斯核半径的计算依赖物体的真实宽高,所以必须在数据加载阶段就拿到 GT 的尺寸信息。如果你用的是自定义数据集,确保标注格式里包含宽高,而不是只有中心点和类别。

3.2 热力图上的损失函数设计

热力图的监督信号是高斯分布,预测值经过 sigmoid 之后在 0 到 1 之间。如果直接用 MSE,会有个问题:高斯核边缘那些接近 0 的位置,梯度太小,网络学不动;而峰值附近梯度又太大,容易过拟合。CenterNet 用的是改进版的 Focal Loss:

def focal_loss(pred, gt): pos_inds = gt.eq(1).float() neg_inds = gt.lt(1).float() neg_weights = torch.pow(1 - gt, 4) pos_loss = torch.log(pred) * torch.pow(1 - pred, 2) * pos_inds neg_loss = torch.log(1 - pred) * torch.pow(pred, 2) * neg_weights * neg_inds num_pos = pos_inds.float().sum() pos_loss = pos_loss.sum() neg_loss = neg_loss.sum() if num_pos == 0: loss = -neg_loss else: loss = -(pos_loss + neg_loss) / num_pos return loss

这里的pos_inds是高斯核峰值位置(值为 1),neg_inds是其他所有位置。关键在neg_weights这一项:(1 - gt)^4让靠近峰值的负样本权重降低,因为那些位置虽然标签不是 1,但也不该被当成完全的负样本。这个设计很巧妙,相当于给高斯核的“斜坡”区域一个缓冲,避免网络在峰值附近产生震荡。

我实测下来的经验是,这个4次方是个比较敏感的参数。调到 2 的时候,峰值附近容易出现多个小峰;调到 6 以上,小目标的召回会掉。官方默认值 4 是经过大量实验的,建议不要轻易改。

3.3 偏移和尺寸回归的损失

偏移分支和尺寸分支都用 L1 Loss,而且只在中心点位置计算,其他位置不参与。这一点很重要——如果全图都算 L1,网络会被大量无意义的背景位置带偏。代码里通常这么写:

def reg_l1_loss(pred, target, mask): expand_mask = torch.unsqueeze(mask, -1).repeat(1, 1, 1, 2) loss = F.l1_loss(pred * expand_mask, target * expand_mask, reduction='sum') loss = loss / (mask.sum() + 1e-4) return loss

mask就是热力图峰值位置的二值图。注意分母加了1e-4防止除零,这个在小 batch 训练时特别重要,我有一次忘了加,loss 直接变成 NaN,排查了半天。

尺寸回归的目标是log(width)和log(height)还是直接回归原始值?论文里用的是直接回归,但我在实际项目里发现,对尺寸跨度大的数据集(比如同时有 20 像素和 500 像素的目标),取 log 之后训练更稳定。这个不是论文的原始做法,算是我自己的经验补充,你可以根据数据集特点决定。

4. 完整实操流程:从零复现一个 CenterNet

4.1 环境准备与依赖选择

我复现时用的技术栈是 PyTorch + CUDA,backbone 选了 ResNet-50 加 DCN(可变形卷积)。DCN 不是必须的,但论文里提到用 DCN 能涨点,我实测大概有 1-2 个 mAP 的提升。如果你追求部署简单,用普通 ResNet 也完全能跑。

依赖清单大致如下:

组件版本建议说明
Python3.8+太新的版本有些库兼容性不好
PyTorch1.10+需要支持 DCN 的版本
CUDA11.3+根据显卡驱动选
numpy1.21+数据处理
opencv-python4.5+图像读写和可视化
pycocotools最新如果要在 COCO 上评估

DCN 的编译是个坑。PyTorch 自带的torchvision.ops.deform_conv2d从 1.8 开始就有了,直接用这个就行,不用自己编译 C++ 扩展。我早期版本自己编译过,各种环境问题,后来发现官方已经内置了,白白折腾一天。

4.2 数据加载与 GT 生成

数据加载这块,核心任务是把标注转成三路监督信号:热力图、偏移图、尺寸图。以 COCO 格式为例,流程是这样的:

  1. 读入图像和标注,把图像缩放到固定尺寸(比如 512x512),记录缩放比例。
  2. 对每个 GT 框,计算中心点在缩放后图像上的坐标。
  3. 把中心点坐标除以 4(下采样倍数),得到热力图上的浮点坐标。
  4. 取整数部分作为网格位置,小数部分作为偏移目标。
  5. 用高斯核在热力图对应通道的网格位置画峰值。
  6. 宽高除以缩放比例,作为尺寸目标。

这里有个容易忽略的点:如果两个物体的中心点落在同一个网格上怎么办?官方代码的处理是取高斯核半径较大的那个,覆盖掉小的。这种情况在小目标密集场景下会出现,我建议在数据加载时加个日志,统计一下冲突次数,如果太频繁,说明下采样倍数 4 可能太大了,可以考虑改成 2。

4.3 模型搭建与训练配置

Backbone 用 ResNet-50,去掉最后的全连接层,保留到 stage 4 的输出。然后接三个 head:

  • 热力图 head:3x3 卷积(通道数 64)→ 1x1 卷积(通道数 C),最后 sigmoid。
  • 偏移 head:3x3 卷积(64)→ 1x1 卷积(2)。
  • 尺寸 head:3x3 卷积(64)→ 1x1 卷积(2)。

训练配置我试过几组,比较稳的一套是:

  • 优化器:Adam,初始学习率 1.25e-4
  • Batch size:32(如果显存不够,可以降到 16,学习率相应减半)
  • 训练轮数:140 epoch
  • 学习率衰减:在第 90 和 120 epoch 各降 10 倍
  • 数据增强:随机翻转、随机缩放(0.6 到 1.4)、颜色抖动

提示:CenterNet 对学习率比较敏感,1.25e-4 是论文里的值,但如果你换了 backbone 或数据集,建议先用小学习率 warmup 几个 epoch,再切到主学习率,能明显减少早期震荡。

4.4 推理与后处理

推理流程比训练简单得多:

  1. 图像过网络,得到热力图、偏移、尺寸三路输出。
  2. 对热力图做 3x3 max-pooling,保留局部峰值。
  3. 取 top-100 个峰值,记录位置、类别、分数。
  4. 根据偏移修正中心点坐标,根据尺寸得到框的宽高。
  5. 把框映射回原图尺寸。

整个过程没有 NMS,但有一个细节:如果两个峰值靠得很近(比如距离小于某个阈值),可能需要手动去重。官方代码里其实有个极简的 NMS,但阈值设得很宽松,基本不影响正常情况。我在实际部署时,如果发现同一物体出现多个检测框,会加一个基于中心点距离的简单去重,距离小于 3 个像素就合并。

5. 常见问题与排查技巧实录

5.1 训练 loss 不下降或震荡

这是最常见的问题,我遇到过好几次。排查思路按优先级排:

现象可能原因解决方法
loss 一开始就 NaN学习率太大或除零检查 reg loss 的分母,加 1e-4
loss 震荡不收敛batch size 太小或学习率太大增大 batch 或降低学习率
热力图 loss 降但 reg loss 不降偏移/尺寸的 mask 有问题检查 mask 是否只在峰值位置为 1
训练后期 loss 突然飙升学习率衰减时机不对调整衰减 epoch

我印象最深的一次是热力图 loss 正常下降,但偏移 loss 一直在 0.5 左右徘徊。查了两天才发现,数据加载时偏移目标的计算用了int()取整,把小数部分直接丢了。改成x - int(x)之后,偏移 loss 很快降到 0.1 以下。这种细节,论文里不会写,只有自己踩过才知道。

5.2 小目标检测效果差

CenterNet 在小目标上确实不如大目标,这是下采样带来的天然劣势。下采样 4 倍之后,一个 16x16 的小目标在热力图上只有 4x4,高斯核半径可能只有 1 甚至 0,峰值信号非常弱。

我试过几个缓解方案:

  • 把下采样倍数从 4 改成 2,热力图分辨率翻倍,小目标召回明显提升,但显存和计算量也翻倍。
  • 在 backbone 里加一个高分辨率的特征分支,类似 FPN 的做法,把浅层特征和深层特征融合。
  • 数据增强时多复制一些小目标,提高小目标在训练集中的比例。

实测下来,方案二性价比最高,加一个简单的上采样融合,小目标 mAP 能涨 3-5 个点,计算量只增加 10% 左右。

5.3 峰值定位偏移

有时候检测框的位置整体偏了半个网格,这通常是偏移分支没学好。检查两个地方:一是偏移目标的归一化方式,官方代码里偏移值是在 0 到 1 之间,如果你归一化到其他范围,推理时记得反归一化;二是偏移 loss 的权重,默认是 1,如果热力图 loss 太大,偏移分支会被压制,可以适当调高偏移 loss 的权重。

还有一个隐蔽的坑:如果你在推理时用了torch.nn.functional.max_pool2d做峰值筛选,注意它的stride要和kernel_size一致,否则峰值位置会偏移。我一开始没注意,stride 默认等于 kernel_size,但如果你手动设了别的值,位置就对不上了。

5.4 类别不平衡导致的漏检

如果某个类别的样本特别少,热力图对应通道的峰值会很弱,容易被其他类别的峰值淹没。除了常规的类别加权损失,我还会在数据加载时对稀有类别做过采样。另外,top-k 的 k 值也要注意,默认 100 在类别多的时候可能不够,可以适当调大,但太大又会引入误检,需要根据实际场景权衡。

6. 一些个人体会和扩展思路

CenterNet 最让我欣赏的地方,是它把检测问题“降维”成了关键点估计,整个框架干净得不像一个检测器。没有 anchor,没有 NMS 调参,没有复杂的正负样本判定,代码量比 Faster R-CNN 少了一大半,但精度和速度都不输。这种“用更简单的表示解决更复杂的问题”的思路,我觉得比具体的网络结构更有价值。

后来我在几个项目里把 CenterNet 扩展到了其他任务:比如人体姿态估计,把关键点从“物体中心”换成“关节位置”,热力图通道数改成关节数,其他部分基本不用动;还有旋转框检测,在尺寸分支上加一个角度回归,也能跑通。这种可扩展性,是 anchor-based 方法很难做到的。

如果你打算在自己的数据集上试 CenterNet,我的建议是先用官方代码跑通 COCO 的预训练模型,确认环境和推理流程没问题,再换自己的数据。换数据时,重点检查 GT 生成那一步,尤其是高斯核半径和偏移计算,这两个地方最容易出错。训练时盯着热力图 loss 和 reg loss 的比例,正常情况热力图 loss 应该比 reg loss 大一个数量级左右,如果比例不对,说明监督信号有问题。

最后分享一个小技巧:可视化热力图的时候,不要只看峰值位置,把整张热力图叠加到原图上,观察高斯核的覆盖范围。如果发现某个物体的高斯核明显偏大或偏小,说明半径计算有问题,回去检查gaussian_radius函数的输入尺寸是不是搞反了宽高。这个错误我犯过不止一次,因为 numpy 的 shape 是 (h, w),而很多标注格式是 (w, h),一不小心就传反了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进