目标检测这个系列写到第十三篇,越写越觉得有意思。前面聊过两条主线,一是以Faster R-CNN、YOLO为代表的anchor-based方法,二是以FCOS为代表的无anchor逐像素预测。今天要拆的CenterNet,思路比两者都更激进:它把目标检测完全转换成关键点检测——不设anchor,不做NMS,不生成先验框,模型直接输出每个目标中心点的热力分布,再用两个辅助分支回归尺寸和偏移。它解决的问题非常朴素:既然框是包围目标的矩形,目标在人的认知里本质上是“那个位置有个东西”,那为什么不直接检测“点”?这篇文章我准备把原理、标签生成、损失设计、代码实现和踩坑记录串在一起讲,适合已经熟悉anchor-based检测器、正被NMS和anchor调参困扰的读者,也适合需要把检测模型部署到边缘设备、想压缩后处理流程的工程师。
1. 先弄清楚:为什么要干掉Anchor和NMS
1.1 Anchor机制的三宗罪
用过Faster R-CNN或YOLOv3的朋友都知道,anchor是从“预定义一堆框再筛选”的角度理解检测的。一张512x512的图,anchor动辄几万个候选,真正能和GT匹配上的可能只有几十个。这个数量级差异直接导致正负样本极度不平衡。为了压住海量负样本,需要上OHEM、focal loss,或者用ATSS里的动态匹配策略。每加一个机制,训练的不确定性就高一分,排查问题的难度也跟着加一分。我见过不少初学者在调正负样本比例时把自己绕进去,其实很多时候问题根本不在比例,而在anchor本身的匹配规则太僵硬。
anchor的尺度、长宽比、特征图上的步长、匹配时使用的IOU阈值,每一项都是超参。换一个业务场景,目标的典型尺寸变了,anchor就得从头调。我以前在一个工业缺陷检测项目里,要同时找出长条形的划痕和近圆形的麻点,光anchor scale就试了十几组,训练一轮看一次指标,来回折腾了将近两周。后来复盘发现,真正有效的其实不是某一组参数,而是数据增强和对负样本的抑制方式。这属于典型的“花大力气解决了一个自己制造出来的问题”。
再说NMS。训练完模型只是开始,到了推理阶段,NMS依然牢牢占着后处理的主要位置。模型通常输出上千个候选框,NMS要按类别做置信度排序、逐个做IOU比较、迭代抑制。这些逻辑用GPU算子实现还说得过去,但换到CPU推理或者边缘设备,NMS的耗时占比会明显上升。更要命的是,当模型导出到另一种推理后端时,NMS往往不是标准算子,需要单独写一个匹配的插件,每次框架升级都可能踩坑。这个工作量不大,但它会高频出现在每一个检测项目里,属于一种持续性的维护成本。
1.2 中心点思路:把候选框问题变成峰值查找问题
如果从头想,目标检测的本质其实是回答两个问题:图里有哪些目标,每个目标在哪里。“在哪里”不一定非要表达成“一组候选框”。CenterNet给出的答案是:用一个点来表示一个目标。物体的形状可以被矩形的宽高近似,但物体本身的存在性,用中心点来表达最直接。中心点没有尺度、长宽比的先验概念,训练时只需要告诉网络“这个位置的响应要高”,剩下宽高统统放到另一个分支去回归。
这个思路妙在推理阶段。由于训练时中心点位置被设计成高斯分布的峰值,一个目标在heatmap上只会有一个明显局部最大值。推理时用3x3的最大池化在特征图上扫一遍,凡是等于池化结果的位置就是局部峰值,再把所有峰值排序取前K个,便得到最终目标。整个过程和NMS无关。有人会问:如果相邻两个同类目标特别近怎么办?答案是通常不会误杀,因为它们在heatmap上是两个独立的峰值,只要高斯核半径不至于把两个峰粘连到无法区分即可。倒是传统NMS在这种情况下需要精心调IOU阈值,调松了出现重框,调紧了误删目标,两边都是坑。
2. 核心结构拆解:三分支各司其职
2.1 从骨干网络到128x128输出
CenterNet沿用的是常见做法:输入图像统一缩放到512x512,经过骨干网络提取特征,再经过上采样模块把特征图放大到输入的1/4,也就是128x128。骨干可以在ResNet-50和DLA之间选。DLA的全称是Deep Layer Aggregation,核心操作是把不同层级的特征做反复的聚合与融合,对小目标更友好。我实际对比下来,同等输入尺寸下DLA-34的精度确实比ResNet-50高一点,但推理时间也相应增加。如果不是特别追求极致精度,ResNet-50已经足够起步。
为什么stride要用4而不是更大的8或16?定位问题的精度要求决定了这一点。stride越小,输出特征图越密集,中心点的量化误差越小,这和语义分割很像,输出分辨率越高,边界越准。代价是计算量变大。CenterNet选4是在精度和效率之间做的平衡。结构示意图可以在纸上画一下,输入图经过骨干和上采样后,同一个特征图分别接三个卷积头,输出三个不同通道数的分支,非常清爽。
2.2 heatmap、offset、size:每个分支负责什么
| 分支 | 输出通道数 | 含义 |
|---|---|---|
| heatmap | C(类别数) | 每个类别一张热力图,峰值位置即目标中心 |
| offset | 2 | 中心点坐标在下采样和取整过程中产生的偏移 |
| size | 2 | 目标的宽和高 |
heatmap分支是模型的“眼睛”。每个通道上,响应值在目标中心点处最高,向外逐渐衰减。推理时,网络输出的值需要经过sigmoid变成0到1之间的置信度。offset分支只有2个通道,负责修正中心点从原图映射到下采样特征图时产生的量化误差。因为中心点坐标除以4之后往往不是整数,而特征图坐标必须是整数网格,直接取整会让框的位置偏半个格点,offset分支就是来修正这半格误差的。size分支同样是2个通道,直接回归目标的宽和高,不受归一化限制,所以它的损失权重一般会调低一些。
推理时合成框的公式很简洁:中心点在特征图上的坐标 = 峰值坐标 + offset,再乘以4得到原图坐标;然后左右各扩展w/2,上下各扩展h/2,就得到最终的矩形框。我观察到一个初学者容易困惑的地方:既然offset只修正半格误差,为什么不直接给峰值坐标加0.5?请注意,0.5是常数,offset是网络根据图像内容预测出来的,它不仅有量化修正的作用,还包含模型对中心点细微偏移的估计,两者不是一回事。
2.3 训练标签:高斯核是如何画出来的
要让网络学到“中心点响应高、中心附近响应次之、远处为0”,不能只把中心点标成1,其他位置标成0。那样的话正负样本依然极端不平衡,而且网络学到的是一个硬分类而不是置信度分布。标准做法是以每个目标中心为中心画一个二维高斯核,半径由目标框的尺寸自适应决定。半径太大,周围背景被当成正样本;半径太小,监督信号过于稀疏。我最初图省事直接使用固定sigma=3,后来发现小目标和中大目标对高斯核覆盖范围的要求差别很大,导致小目标周围全是模糊监督,漏检率明显上升。
import math def gaussian_radius(height, width, min_overlap=0.7): # 根据目标框尺寸计算高斯核半径,保证GT内任意点的归一化高斯核IOU不低于min_overlap a1 = 1 b1 = height + width c1 = width * height * (1 - min_overlap) / (1 + min_overlap) r1 = (b1 - math.sqrt(b1 ** 2 - 4 * a1 * c1)) / 2 b2 = height + width c2 = width * height * (1 + min_overlap) / (1 - min_overlap) r2 = (b2 - math.sqrt(b2 ** 2 - 4 * a1 * c2)) / 2 b3 = height + width c3 = width * height r3 = (b3 - math.sqrt(b3 ** 2 - 4 * a1 * c3)) / 2 return max(0, int(min(r1, r2, r3)))这个函数的思路是对几种可能的半径形态求最小值,保证高斯核衰减到边缘时目标框内部仍有足够高的响应。如果计算出来的半径小于1,我会手动拉高到1,否则该目标在标签图上可能完全没有正值,网络永远不会在那个位置产生响应,目标直接消失。这个细节花了我不少时间去排查。
重叠目标在同一个通道上遇到两个高斯核时怎么办?我踩过坑的是把两个高斯核直接相加。这会让重叠区域的响应超过1,等于给了这个位置更强的监督,但现实中重叠区域属于两个目标的边界,不该被额外强调。开源实现里最常见的做法是逐元素取max,也就是两个高斯核覆盖同一格点时保留较大值。我沿用这个做法,效果稳定。
2.4 损失函数:三份Loss如何平衡
heatmap分支用的是一个改进版focal loss。对准目标中心的位置,损失形式偏向“把置信度拉向1”;对于非中心位置,还要根据该位置离中心点的距离做惩罚衰减。公式里的幂指数α和β是超参,常见取值是α=2、β=4。α负责难易样本之间的权重对比,β负责控制高斯核衰减区域对损失的贡献。β=4是我觉得最值得注意的,它决定了中心点附近那些“看似是目标但又不是中心”的位置被抑制的程度。如果β太小,模型会在真中心附近出现一团模糊响应,导致解码时峰值不尖锐;如果β太大,少量难样本会让训练震荡。
offset和size分支都用L1损失,L1对离群点不敏感,适合回归位置和尺寸。常见配置是offset权重为1,size权重为0.1。为什么不给size更高的权重?原因很直接:宽和高的数值范围比坐标偏移大得多,同样一个L1误差,尺寸分支产生的梯度能量可能是偏移分支的几十倍。如果不降权,模型会把主要精力放在把宽高回归准,中心点定位精度反而被拖累。这个比例不用太细抠,0.1到1之间按数据集微调即可。
训练配置方面,我的习惯是:优化器用Adam,学习率从1.25e-4开始,先用500步warmup,总共跑140个epoch。batch size在显存允许的前提下尽量开大,小batch需要相应调整学习率。数据增强包括随机翻转、随机缩放和随机裁剪。翻转增强对中心点思路尤其有效,因为目标中心位置会随着翻转精确映射,不像anchor框还要考虑翻转后的长宽是否仍然匹配。
3. 动手复现:从数据准备到推理部署
3.1 数据组织与标签转换
我这次用一个模拟项目X的图像数据来演示,标注格式是常规的目标检测框格式,每行包含图片名、类别、左上角坐标和右下角坐标。训练之前要先把这些框全部转成CenterNet需要的形式:先算中心点坐标和宽高,再把中心点除以stride并向下取整,得到特征图上的中心点网格坐标,同时记录中心点真实值减去取整值得到的偏移量。这一步要小心的是,下采样后中心点坐标可能超出特征图边界,遇到这种情况我会跳过该目标,并在日志里打印提醒。
数据增强是另一个容易出问题的地方。缩放时,中心点坐标要跟着原图尺寸一起缩放;翻转时,坐标的x方向要映射为W-1-x;随机裁剪过后,中心点如果落在图外,该目标应被舍弃。这些逻辑听起来琐碎,但漏掉任何一个,训练曲线看起来正常,实际解码却会频繁出现错位框。我自己在早期版本里漏过翻转坐标同步,结果精度差了整整三个点,排查了很久才反应过来。
3.2 解码模块:没有NMS的廉价峰值查找
import torch import torch.nn as nn def decode_detections(heat, offset, size, topk=100, stride=4): batch, C, H, W = heat.shape heat = torch.sigmoid(heat) # 3x3 max pooling 找局部峰值 pool = nn.MaxPool2d(kernel_size=3, stride=1, padding=1) peak = pool(heat) keep = (peak == heat).float() heat = heat * keep # 按类别取topk scores, inds = heat.view(batch, C, -1).topk(topk, dim=-1) inds = inds + (torch.arange(C, device=inds.device) * H * W).view(1, -1, 1) inds = inds.view(batch, -1) scores = scores.view(batch, -1) # 恢复特征图坐标 ys = inds // W xs = inds % W # 取offset和size分支对应位置 offset = offset.permute(0, 2, 3, 1).contiguous().view(batch, -1, 2) size = size.permute(0, 2, 3, 1).contiguous().view(batch, -1, 2) offsets = torch.gather(offset, 1, inds.unsqueeze(-1).expand(-1, -1, 2)) sizes = torch.gather(size, 1, inds.unsqueeze(-1).expand(-1, -1, 2)) # 还原到原图坐标 cx = (xs + offsets[..., 0]) * stride cy = (ys + offsets[..., 1]) * stride w = sizes[..., 0] h = sizes[..., 1] boxes = torch.stack([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], dim=-1) return boxes, scores这段代码的核心只有两步。第一步,对heatmap做3x3 max pooling,再与原图比较,凡是等于原图的位置就是局部峰值。第二步,把每个类别内部的前K个峰值拿出来,恢复出它们在特征图上的坐标。注意这里的scores取的是经过sigmoid之后的值,而不是focal loss里使用的logits,这个细节在导出模型时特别容易错。
拿到所有候选峰值坐标后,从offset和size分支取对应位置的数值,做后处理:坐标等于峰值坐标加offset再乘stride,宽高直接用size。如果你喜欢把宽高也除以stride来回归,解码时再乘回去,原理上没有区别。这一步做完,输出框已经是最终结果,不需要第三个阶段。
到这里很多朋友会问:topk=100够不够?我的经验是,普通场景100完全够,但涉及密集目标,比如工业零件计数,建议开到300。topk变大带来的额外成本只是最后的全局排序和截断,相比传统NMS的复杂度几乎可以忽略。这也是把检测变成点查找最实在的工程红利。
3.3 训练与推理的配置参考
| 配置项 | 建议值 | 说明 |
|---|---|---|
| 输入分辨率 | 512x512 | 直接决定小目标表现 |
| 输出stride | 4 | 精度与计算量的折中 |
| 优化器 | Adam | 训练稳定,收敛快 |
| 学习率 | 1.25e-4 | batch size 128时的基准 |
| epoch | 140 | 中途可早停 |
| 数据增强 | 随机翻转、随机缩放0.6~1.3、随机裁剪 | 翻转必须同步更新标签 |
| loss权重 | heatmap=1、offset=1、size=0.1 | 按数据集微调 |
这个配置是我在多组实验中相对稳的起点。如果只有一张卡,batch size先减半,学习率也相应除以2。别小看这一条,很多复现翻车都是因为batch size和学习率不匹配。硬件方面,我用8张主流数据卡跑ResNet-50版本大约5小时跑完140个epoch,DLA-34会慢一些。训练初期heatmap的loss下降很快是正常的,focal loss对背景负样本的压制很强。如果训练到一半损失突然震荡,优先检查学习率,再检查数据增强里的标签同步。
3.4 推理链路对比:省掉的NMS到底省了什么
我最直观的一次体会,是在某图像处理Demo里,把一套两阶段检测方案换成CenterNet。两阶段方案的后处理包括候选框整理、按类别NMS、甚至还有针对多尺度的结果融合,整套跑下来,后处理耗时占整个推理链路将近两成。换成CenterNet之后,后处理变成几十毫秒内的索引和排序操作,整条链路的延迟下降了将近三成。对服务器端来说这个数字可能不震撼,但对嵌入式设备和强调端到端时延的场景,效果非常明显。
省掉NMS还有一层隐藏价值:可解释性和可调试性。当检测结果出现一个多余的框,传统流程里你很难判断是模型误检还是NMS漏抑;CenterNet的每个输出都可以追溯到heatmap上的一个峰值,打开热力图看哪里多了不该有的峰,问题一目了然。这也是我后来调试效率提高的重要原因。
4. 常见问题、避坑指南与适用边界
4.1 问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 训练不收敛,heatmap全黑 | 学习率过大、没有warmup | 降低学习率,加500步warmup |
| 小目标漏检严重 | 输入分辨率不足、stride偏大 | 提高输入分辨率,换DLA骨干 |
| 密集场景输出框重叠多 | topk太小、高斯核粘连 | 增大topk,检查标签生成 |
| 中心点整体偏移 | offset分支未收敛、解码忘乘stride | 检查解码公式和offset归一化 |
| 推理置信度异常 | sigmoid重复或缺失 | 统一训练和推理时的sigmoid入口 |
| loss震荡 | 学习率不匹配、增强没有同步标签 | 复查增强逻辑,调整学习率 |
这个表是我自己调试过程中总结的,不一定覆盖全部场景,但按它排查能解决我遇到的大部分问题。最隐蔽的是表格里第三行:密集场景输出框重叠。高斯核在特征图上相互叠加后,峰值可能被顶得连成一片,3x3 maxpool依然会找到两个相邻峰值,但它们的间距小于真实目标间距,导致最终两个框重叠过多。这种现象不是CenterNet特有,只是它把矛盾转移到了标签生成阶段。遇到这种情况,我会检查目标框的宽高分布,适当调小高斯核半径,或者换用带质量评估分支的检测头。
4.2 复现路上的三个深坑
第一个坑是高斯核半径的计算。最开始我图省事,直接按固定sigma生成高斯核,结果在目标尺寸相差一个数量级的数据集里,小目标区域出现大量模糊监督。后来换成根据目标框尺寸自适应计算radius,并强制最小值为1,问题才缓解。记住,半径不是越大越好,它的作用是让GT框内部保持合理的正样本比例,半径过大反而会抹平边界。
第二个坑是offset分支的标签尺度。如果直接回归原图尺度下的偏移,损失函数的数值范围会大好几倍,模型在前几个epoch很难把中心和偏移同时学稳。我这里建议严格把偏移目标控制在[-0.5, 0.5]之间,因为中心点到最近整格点的距离不会超过半个格点。考虑到不同stride,原理都是一样的,只是数值范围要匹配。
第三个坑是推理阶段的sigmoid。训练loss里对heatmap logits做了sigmoid处理,有人导出模型时把sigmoid留在训练图里,有人则在推理代码里又做一次。两种方式只留一处即可,否则置信度会变成二次压缩后的值,解码阈值怎么调都不对。我习惯把sigmoid写进模型forward的最后一步,推理代码只负责取坐标,清晰很多。
4.3 CenterNet适合什么项目,以及它留下的思想遗产
CenterNet不是万能的。它对尺度分布比较集中、中心点语义明显的目标效果很好,比如车辆检测、行人检测、工业零件计数、票据印章定位这类场景。但在极端密集、目标特别小、或者有大量目标相互遮挡的任务里,高斯核半径和topk上限都会成为掣肘。想清楚自己的数据长什么样再去选模型,比凑一堆SOTA模型再硬套更重要。
从方法论上讲,CenterNet真正留下的财富是“找点-回归”这个范式。后来的CentripetalNet改进了角点匹配,TTFNet尝试把CenterNet和密集预测融合,再往后出现的FCOS、ATSS等无anchor方法,虽然各有取舍,但多少都能看到“不做先验框、直接预测空间位置”这条思路的影子。对于做工程的人,我的建议是:先把这条思路跑通,再根据业务数据决定要不要往更复杂的结构上迁移。至少你会知道,原来没有anchor和NMS的检测器,也能在真实项目里稳定工作。
最后说一点个人感受。我从早期重度依赖anchor-based方案,到后来真正在项目里把CenterNet用起来,最明显的变化不是mAP涨了多少,而是调试和部署环节的焦虑感少了很多。少一套anchor参数,少一个NMS算子,模型的行为就更容易被解释。如果你也正在为后处理复杂度和调参头疼,不妨拿CenterNet跑一份自己的数据试试。整体流程不难,踩过坑之后你会发现,原来目标检测的后处理可以这么轻。