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 ratio5 个层级就是 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_overlap0.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 Lossdef 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是高斯核峰值位置值为 1neg_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, reductionsum) loss loss / (mask.sum() 1e-4) return lossmask就是热力图峰值位置的二值图。注意分母加了1e-4防止除零这个在小 batch 训练时特别重要我有一次忘了加loss 直接变成 NaN排查了半天。尺寸回归的目标是log(width)和log(height)还是直接回归原始值论文里用的是直接回归但我在实际项目里发现对尺寸跨度大的数据集比如同时有 20 像素和 500 像素的目标取 log 之后训练更稳定。这个不是论文的原始做法算是我自己的经验补充你可以根据数据集特点决定。4. 完整实操流程从零复现一个 CenterNet4.1 环境准备与依赖选择我复现时用的技术栈是 PyTorch CUDAbackbone 选了 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 格式为例流程是这样的读入图像和标注把图像缩放到固定尺寸比如 512x512记录缩放比例。对每个 GT 框计算中心点在缩放后图像上的坐标。把中心点坐标除以 4下采样倍数得到热力图上的浮点坐标。取整数部分作为网格位置小数部分作为偏移目标。用高斯核在热力图对应通道的网格位置画峰值。宽高除以缩放比例作为尺寸目标。这里有个容易忽略的点如果两个物体的中心点落在同一个网格上怎么办官方代码的处理是取高斯核半径较大的那个覆盖掉小的。这种情况在小目标密集场景下会出现我建议在数据加载时加个日志统计一下冲突次数如果太频繁说明下采样倍数 4 可能太大了可以考虑改成 2。4.3 模型搭建与训练配置Backbone 用 ResNet-50去掉最后的全连接层保留到 stage 4 的输出。然后接三个 head热力图 head3x3 卷积通道数 64→ 1x1 卷积通道数 C最后 sigmoid。偏移 head3x3 卷积64→ 1x1 卷积2。尺寸 head3x3 卷积64→ 1x1 卷积2。训练配置我试过几组比较稳的一套是优化器Adam初始学习率 1.25e-4Batch size32如果显存不够可以降到 16学习率相应减半训练轮数140 epoch学习率衰减在第 90 和 120 epoch 各降 10 倍数据增强随机翻转、随机缩放0.6 到 1.4、颜色抖动提示CenterNet 对学习率比较敏感1.25e-4 是论文里的值但如果你换了 backbone 或数据集建议先用小学习率 warmup 几个 epoch再切到主学习率能明显减少早期震荡。4.4 推理与后处理推理流程比训练简单得多图像过网络得到热力图、偏移、尺寸三路输出。对热力图做 3x3 max-pooling保留局部峰值。取 top-100 个峰值记录位置、类别、分数。根据偏移修正中心点坐标根据尺寸得到框的宽高。把框映射回原图尺寸。整个过程没有 NMS但有一个细节如果两个峰值靠得很近比如距离小于某个阈值可能需要手动去重。官方代码里其实有个极简的 NMS但阈值设得很宽松基本不影响正常情况。我在实际部署时如果发现同一物体出现多个检测框会加一个基于中心点距离的简单去重距离小于 3 个像素就合并。5. 常见问题与排查技巧实录5.1 训练 loss 不下降或震荡这是最常见的问题我遇到过好几次。排查思路按优先级排现象可能原因解决方法loss 一开始就 NaN学习率太大或除零检查 reg loss 的分母加 1e-4loss 震荡不收敛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)一不小心就传反了。