news 2026/10/16 8:12:53

CenterNet:去掉Anchor和NMS,用关键点检测简化目标检测

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CenterNet:去掉Anchor和NMS,用关键点检测简化目标检测

目标检测这个系列写到第十三篇,越写越觉得有意思。前面聊过两条主线,一是以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:每个分支负责什么

分支输出通道数含义
heatmapC(类别数)每个类别一张热力图,峰值位置即目标中心
offset2中心点坐标在下采样和取整过程中产生的偏移
size2目标的宽和高

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直接决定小目标表现
输出stride4精度与计算量的折中
优化器Adam训练稳定,收敛快
学习率1.25e-4batch size 128时的基准
epoch140中途可早停
数据增强随机翻转、随机缩放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跑一份自己的数据试试。整体流程不难,踩过坑之后你会发现,原来目标检测的后处理可以这么轻。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 8:12:30

airllm实测:24G显存跑27B大模型的按需加载方案

最近在折腾大模型本地部署的时候,我遇到一个非常实际的问题:手里的显卡不是A100,也不是H100,就是一张24G显存的普通消费级卡,却想把一个27B参数的开源对话模型塞进去跑起来。试过量化,试过各种加速框架&…

作者头像 李华
网站建设 2026/10/16 8:10:33

Android StaticLayout深度解析:文本渲染、断行算法与性能优化实战

1. 从一次文本渲染异常说起:为什么需要深入StaticLayout做Android开发的朋友大概率都遇到过这类问题:一段长文本在TextView里显示时,换行位置总是不对,明明设置了maxLines和ellipsize,省略号出现的位置却飘忽不定&…

作者头像 李华
网站建设 2026/10/16 8:10:31

免费调用Claude代码能力:从零构建命令行AI代码助手

“free-claude-code”这个标题,乍一看像是某个开源项目的仓库名,但混过开发者社区的朋友应该能立刻捕捉到背后的潜台词:在不额外烧钱的前提下,把 Claude 的代码能力接入自己的工具链、自动化脚本或者本地工作流里。过去一年里&…

作者头像 李华
网站建设 2026/10/16 8:10:16

从域控排查到权限治理:清理企业“僵尸账号”的完整实践

前阵子帮一家制造企业做内网安全排查,登进他们的域控一看,一个离职快两年的运维部门主管账号居然还挂在“域管理员”组里,密码策略因为长期没登录而被跳过。我当场就绷不住了——这哪是账号,分明是给攻击者留的后门钥匙。后来顺着…

作者头像 李华
网站建设 2026/10/16 8:10:16

5G双层重传协议全解析:HARQ与RLC ARQ的协同机制与调优实践

如果你做5G协议或无线链路性能优化,一定绕不过“重传协议”这四个字。5G基站和手机之间的无线信道是出了名的不稳定,信号衰减、干扰、移动多径随随便便就能让一包数据在空口上出错。这时候如果没有一套快速、省资源、又能兜底的数据重传机制,…

作者头像 李华
网站建设 2026/10/16 8:09:37

深度学习训练崩了?warmup预热策略从原理到实战全解析

先交代一个我自己的翻车现场。去年某次图像分类实验,我拿到一个预训练模型,准备在120类自定义数据集上做微调。当时图省事,直接开了0.3的学习率,没加任何warmup。结果第1个step loss就从2.31跳到8.9,第12个step直接出现…

作者头像 李华