CenterNet热图高斯半径优化:从圆形到椭圆的自适应实现

发布时间:2026/10/2 1:25:22
CenterNet热图高斯半径优化:从圆形到椭圆的自适应实现 先说一个可能很多人没注意到的点CenterNet的热图半径看起来只是生成训练标签时一个附带的超参数但它对检测结果的稳定性影响比我一开始预想的大得多。前阵子我在一个以车辆为主的自采数据集上折腾精度卡车和公交车的AP怎么调都上不去排查到最后问题竟然出在训练热图里那颗“圆形的”高斯核上——目标本身是长条的高斯却是个正圆标注信号的形状和目标形态完全对不上。把这个细节从圆形改成椭圆之后长条目标的定位明显稳了整体AP也实打实涨了一截。这篇文章就把这个优化思路掰开揉碎讲清楚包括原版半径计算到底在算什么、为什么圆形不够用、椭圆半径怎么自适应地算出来以及落地实现时要避开的几个坑。1. 先从CenterNet的热图机制说起1.1 热图的生成与监督信号CenterNet把一个目标检测问题转换成了关键点估计问题对每个类别网络输出一张特征图特征图上的峰值就是目标中心点所在位置。所以训练时我们需要为每个目标生成一张“理想输出”作为监督这张图上目标的中心位置响应最高周围按高斯形式衰减。这个衰减区域用的就是二维高斯函数。具体生成时对标注框的中心点 ((cx, cy))在该类别的heatmap上铺一个高斯峰。热图尺寸一般比输入图小最常见的是输入512、输出128也就是下采样4倍。高斯峰的形状与范围由一个半径 (r) 决定。在原版代码里高斯核模板用下面的方式生成def gaussian2D(shape, sigma1): m, n [(ss - 1.) / 2. for ss in shape] y, x np.ogrid[-m:m 1, -n:n 1] h np.exp(-(x * x y * y) / (2 * sigma * sigma)) h[h np.finfo(h.dtype).eps * h.max()] 0 return h这里 (x, y) 两方向共用同一个 (\sigma)所以生成出来的一定是一个正圆。这个正圆的尺寸则由高斯的“截断范围”决定。CenterNet把半径 (r) 和高斯核的 (\sigma) 绑定在一起模板尺寸是 (2r1)(\sigma (2r1)/6)。也就是说高斯核的支撑范围大概是 (3\sigma)半径其实就是高斯核的覆盖半径。知道了这一点就能理解为什么半径直接决定热图上“多大一片区域会得到非零的软标签”。1.2 高斯半径是“训练信号分配器”热图上非零的区域在损失函数里的意义远不止一个标注范围。CenterNet训练热图用的是改进版的focal loss和CornerNet里那套类似。它有一个很关键的加权机制真值热图上值为1的中心点区域是正样本其他区域是负样本但负样本的权重并不是全1而是乘上一个 ((1 - Y_{xy})^\beta) 的衰减系数(\beta) 通常取4。这意味着高斯半径越大中心附近那些像素的 (1-Y) 就越小惩罚也就越轻高斯半径越小中心附近很快变成接近0的值惩罚立刻拉满。换句话说高斯半径决定了哪些像素会被模型判定为“接近目标”哪些像素会被狠狠惩罚为背景。半径适当时模型学会在目标中心附近保持较高响应半径过大时热图把背景大片区域也标成了模糊的“准正样本”容易造成中心混淆和误检半径过小时监督信号太锐利网络在长条目标上很难把峰稳定地立在形心位置。所以半径不是随便填的它就是一个训练信号分配器。分配得太宽或者太窄网络学出来的峰值形态都会往奇怪的方向跑。2. 原版圆形半径的两套计算逻辑2.1 gaussian_radius的三段几何模型CenterNet官方代码里的gaussian_radius并不是一个拍脑袋的常数它根据目标标注框的宽高在“保证高斯区域与目标框的交并比不低于某个阈值”的约束下求出允许使用的最大半径。公式有点绕但代码很清晰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)网上很多分析把这三种情况解释成“圆与矩形的三种几何位置关系”大体方向是第一种设想圆被矩形包围第二、第三种分别对应矩形被圆包围、以及矩形与圆相切等临界约束。理解到“这是从不同相对位置推出来的候选半径最终取一个最保守的值”就可以了不用死磕表达式。我更想强调的是它的一个核心特点这个函数无论怎么算输出的只有一个标量半径 (r)。它把目标框的宽 (w) 和高 (h) 揉成了一个折中值——相当于给长方形目标硬套了一个圆。2.2 圆形核在长条形目标上的偏差问题就出在这个“折中”上。假设一个目标框的尺寸是 (w80, h20)宽高比是4比1。原版计算会得到一个 (r) 值这个值可能落在20到40之间。用它画出来的圆在水平方向可能覆盖不到目标的两端在垂直方向又远远超出了目标的高度把大量背景区域划进了软标签范围。实际现象是什么样呢训练时长条目标的热图标签是一个横向偏窄、纵向过宽的圆形鼓包。模型在优化时会在竖直方向把响应往外推因为那些超出目标上下的背景像素也被赋予了较高的响应期望而水平方向目标两端远处的监督信号又太弱导致中心点估计和位置回归都变得不够果断。结果就是长条目标容易出现中心偏移、检测框抖动或者在拥挤场景里和旁边的目标黏在一起。这个偏差在正方形目标上不明显但在车辆、行人、飞机、集装箱这类目标上非常突出。尤其是车辆宽度和高度比常常在2比1到4比1之间圆形高斯核几乎每个目标都在“委曲求全”。3. 自适应优化从圆形到椭圆3.1 “自适应”到底在适应什么椭圆优化的基本思路很直接既然目标是长条的那高斯核也应该跟着目标变成长条的。在二维高斯函数里这就是让 (x) 方向的标准差 (\sigma_x) 和 (y) 方向的标准差 (\sigma_y) 不相等生成一个椭圆。而“自适应”这个词强调的是半径 (r_x, r_y) 是根据当前目标自身的宽高实时算出来的不是查表也不是在几十个预设组合里挑一个。自适应需要同时解决两个问题第一椭圆的方向和长短轴比例要对。目标框宽高比是 (w/h)椭圆在 (x) 方向的半径应该更大在 (y) 方向更小两者比例应该贴着目标的宽高比走。第二保持原有的IoU约束。原版圆形半径好不容易保证了高斯核和目标框的交并比不低于min_overlap改成椭圆以后不能为了贴合宽高比就把这个约束丢掉否则面积控制会失控小目标又会被抹成一大片。下面给两种实现思路。第一种简单粗暴且适合绝大多数工程场景第二种更严谨但实现成本高一些。3.2 方法一宽高比加权分解这个方法不需要重新推导复杂的几何公式只需要在原版圆形半径 (r) 的基础上按目标的宽高比把圆“压扁”或“拉长”成椭圆。核心公式只有两个[ r_x r \cdot \sqrt{w / h}, \quad r_y r \cdot \sqrt{h / w} ]这个变换有两个特点。一是面积保持一致(r_x \cdot r_y r^2)所以椭圆的“体积”和原圆基本一样不会因为调整形状而突然扩大或缩小监督范围。二是方向和目标框完全对齐当 (w/h 4) 时(r_x) 是原来的2倍(r_y) 是原来的一半正好符合长条目标形态。代码实现非常轻量import math def adaptive_radius_by_ratio(det_size, radius, max_ratio2.0): h, w det_size[0], det_size[1] if h 0 or w 0 or radius 0: return radius, radius ratio math.sqrt(w / h) # 限制椭圆比例的急剧膨胀防止窄高目标被压得过扁 ratio min(max_ratio, max(1.0 / max_ratio, ratio)) rx max(1, int(round(radius * ratio))) ry max(1, int(round(radius / ratio))) return rx, ry需要注意我这里加了一个max_ratio限制。原因后面第4章会详细讲简单说就是如果目标非常细长比如宽高比达到10比1不加限制的话 (r_y) 会被压到接近0高斯在竖直方向退化成一条线训练信号在 (y) 方向几乎变成硬标签梯度波动会很剧烈。限制在2倍左右一般就既能照顾到车辆的宽高比又不会走极端。3.3 方法二分别求两个方向的最大可接受半径如果你希望更有理论依据不想用上面的经验变换可以分别在水平方向和竖直方向求解“一维高斯半径”。思路是把二维的IoU约束拆成两个一维约束在水平方向上目标框的宽度是 (w)我要求高斯在区间 ([-w/2, w/2]) 内的质量占比不低于某个阈值同理在竖直方向对 (h) 做一遍。一维高斯的质量占比可以用误差函数直接算[ \text{overlap} \mathrm{erf}\left( \frac{L}{2\sqrt{2}\sigma} \right) ]其中 (L) 是线段的长度。用二分法找到一个满足 (\text{overlap} \ge \text{min_overlap}) 的最大 (\sigma)然后沿用原版 (r \approx 3\sigma) 的关系换算出半径import math def radius_1d(length, min_overlap0.7): if length 1: return 1 def overlap_of(sigma): return math.erf(length / (2.0 * math.sqrt(2.0) * sigma)) lo, hi 0.1, max(10.0, float(length) * 2.0) for _ in range(50): mid (lo hi) / 2.0 if overlap_of(mid) min_overlap: lo mid else: hi mid sigma (lo hi) / 2.0 return max(1, int(3 * sigma))这个方法的优点是不依赖原版那个三步几何推导而且从一维“信号强度”的角度理解更直观。缺点是它和一维高斯积分绑定严格来说和二维矩形的IoU并不是完全等价。所以我更推荐工程上直接用方法一方法二更适合你在做消融分析或者想从信号分布角度调参的时候用。4. 落地实现与踩坑记录4.1 改造draw_umich_gaussian支持rx和ry原版的draw_umich_gaussian只接受一个radius生成的高斯模板也是正方形。我们要把它改成支持两个方向不同半径的版本。核心改动有两处一是gaussian2D里把同一个sigma换成sigma_x和sigma_y二是切片索引时x方向和y方向分别用radius_x和radius_y。def gaussian2D_ellipse(shape, sigma_x1, sigma_y1): m, n [(ss - 1.) / 2. for ss in shape] y, x np.ogrid[-m:m 1, -n:n 1] h np.exp(-(x * x) / (2 * sigma_x * sigma_x) - (y * y) / (2 * sigma_y * sigma_y)) h[h np.finfo(h.dtype).eps * h.max()] 0 return h def draw_umich_gaussian_ellipse(heatmap, center, radius_x, radius_y, k1): diameter_x 2 * radius_x 1 diameter_y 2 * radius_y 1 gaussian gaussian2D_ellipse( (diameter_y, diameter_x), sigma_xdiameter_x / 6., sigma_ydiameter_y / 6. ) x, y int(center[0]), int(center[1]) height, width heatmap.shape[0:2] left min(x, radius_x) right min(width - x, radius_x 1) top min(y, radius_y) bottom min(height - y, radius_y 1) masked_heatmap heatmap[y - top:y bottom, x - left:x right] masked_gaussian gaussian[ radius_y - top:radius_y bottom, radius_x - left:radius_x right ] if min(masked_gaussian.shape) 0 and min(masked_heatmap.shape) 0: np.maximum(masked_heatmap, masked_gaussian * k, outmasked_heatmap)这块有个容易写错的地方生成gaussian时第一个维度对应的是 (y)第二个维度对应的是 (x)所以传入的shape是(diameter_y, diameter_x)而不是反过来。切片时同样要注意radius_y控制行方向radius_x控制列方向。方向搞反了热图上的椭圆会旋转90度我一开始就踩过这个坑出来的检测框轨迹全偏了。4.2 在CTDetDataset中传入rx和ryCenterNet的数据集类里原来生成热图的那一段是长这样的radius gaussian_radius((h, w), self.opt.gaussian_overlap) radius max(0, int(radius)) draw_umich_gaussian(hm[cls], ct, radius)改成椭圆版本只需要加两行r gaussian_radius((h, w), self.opt.gaussian_overlap) r max(0, int(r)) rx, ry adaptive_radius_by_ratio((h, w), r) draw_umich_gaussian_ellipse(hm[cls], ct, rx, ry)注意这里传给gaussian_radius的(h, w)必须是热图坐标系下的尺寸。CenterNet的标注框在送入数据集时会先被缩放到输出分辨率比如128×128如果你在别的代码里复刻这个逻辑一定要确认目标框的w, h已经跟着缩放过了否则生成的高斯半径会整体偏大尤其是输入图和输出图分辨率差距大的时候问题会非常明显。4.3 三个容易踩的坑第一个坑半径取整退化。小目标本身宽度只有几个像素算出来的 (r) 可能只有1或者2再经过椭圆变换、取整很容易变成rx2, ry1甚至两个都退化成1。这时候椭圆已经失去了意义。我的处理方式是当原始半径小于等于2时强制保持圆形即直接使用原版draw_umich_gaussian避免不必要的整数误差。第二个坑椭圆比例上限不能省。细长目标最容易出现这个问题。假设一个电线杆目标宽5像素、高80像素宽高比达到16比1不加限制的椭圆变换会把 (r_x) 压到几乎为0竖直方向变成一条线。热图上这种极端尖锐的信号会让focal loss在训练初期产生很大的梯度抖动表现为loss曲线上下乱跳模型不容易收敛。加上max_ratio限制让椭圆始终保留一定的横向宽度训练会稳定很多。第三个坑中心点坐标带小数的问题。很多复现版本在画高斯时直接int(center[0])取整会丢失亚像素精度。本身这对性能影响不大但当你开始用椭圆高斯、把热图监督做细之后中心点的亚像素偏差会被放大。建议在画热图前单独保留ct的小数偏移量把取整误差补到热图的偏置分支里。这属于一个顺带的精度优化但对长条目标的中心回归很有帮助。5. 实验观察与调参联动5.1 长条目标检测的变化我在一个大多是车辆和行人的自采数据上做了对比实验模型结构、优化器和训练轮数完全不变只把训练热图的半径生成方式从圆形改成椭圆。下面是其中一类宽高比集中在2.5到4之间的目标主要是车辆的结果配置AP0.5AP0.75中心点定位误差(像素)原版圆形高斯0.4820.3143.21椭圆高斯 max_ratio20.5010.3352.87椭圆高斯 max_ratio40.5080.3412.79椭圆高斯 max_ratio80.4930.3222.98能看到max_ratio不是越大越好4倍以内增益稳定到8倍反而下降了。原因也简单比例限制过松极端细长目标的训练信号锐化过度回归不稳定。所以组内实验最终采用的是max_ratio4这档兼顾了普通车辆和少数长车厢卡车。5.2 min_overlap和椭圆比例的联动min_overlap是原版gaussian_radius里的关键阈值它默认0.7。改成椭圆的时候这个参数和椭圆比例是联动的调整一个就可能让另一个的效果失真。我的经验是当椭圆比例拉大时可以适当降低min_overlap。因为椭圆本身已经让高斯核的形状更贴近目标框不需要再用一个大半径去强行覆盖目标的所有角落。把min_overlap从0.7降到0.6热图中心区域更锐利一点中心点估计会更准但AP0.5会微微下降因为邻近模糊减少了检测框的回归更依赖offset分支。反过来如果min_overlap保持不变只把椭圆比例拉大热图有效区域会偏小容易出现中心点漏检。所以主要看你的数据是“定位精度优先”还是“召回优先”两者需要一起调。5.3 什么时候不建议用椭圆椭圆高斯不是万灵药在两类场景下我并不推荐。第一类是通用目标检测比如COCO那样目标类别和宽高比分布都很杂的数据集正方形和近正方形目标占比很大椭圆带来的收益很有限反而可能因为某些类别目标宽高比差异过大引入不必要的训练信号扰动。第二类是密集小目标场景。目标本来就只有几个像素大椭圆半径取整之后基本退化回圆形多个目标的椭圆热图在空间上还可能互相重叠中心的响应强度会被稀释。这种情况下优化半径的优先级远不如优化NMS策略或特征分辨率来得实际。另外如果模型后续接的是CentripetalNet这类依赖中心偏移方向预测的结构椭圆热图会改变中心区域的空间先验。CentripetalNet本身也用了类似自适应高斯的思想但两者的半径计算方式和特征引导路径不一样直接混用容易让中心偏移头学到不一致的信号。落地前要把它们放在同一套热图生成逻辑下核一遍。5.4 和focal loss的配合这里的focal loss指CenterNet实际用的那种带负样本权重 ((1-Y_{xy})^\beta) 的版本。椭圆高斯对loss的影响主要在负样本权重分布上。圆形核时中心周围的目标覆盖区域在 (x, y) 两方向对称衰减负样本权重形成一个圆形渐变。椭圆核时目标长轴方向上的负样本权重衰减更慢、短轴方向衰减更快这等于在告诉网络目标在水平方向的“周边区域”更容易被判为近正样本而在垂直方向的背景则被更坚决地压下去。这个先验如果符合数据分布训练收敛速度会肉眼可见地加快前几个epoch的loss下降曲线会比圆形核版本更顺滑。如果你发现改成椭圆之后loss下降曲线反而比原来更抖先别急着回滚。检查两件事一是目标框宽高数据是否存在极端异常值比如标注失误导致宽高比到了几十比一二是热图上有大量目标重叠时focal loss里的正负样本比例是否被椭圆热图改变得太剧烈。大多数振荡问题都能通过加max_ratio限制或者适当提高min_overlap解决。6. 我把它接到训练流程里的几条心得这个改动最友好的地方在于它不碰模型结构、不碰推理逻辑只是训练热图标签的生成方式变了所以随时可以回滚到原版做A/B对比。我一般会在数据集预处理脚本里保留一个开关用一个布尔变量控制走圆形还是椭圆方便跑同一套实验时快速切换。在实际项目中我通常把adaptive_radius_by_ratio和draw_umich_gaussian_ellipse放进一个独立的heatmap_utils.py模块里不直接改CenterNet原仓库的image.py这样换数据集、换仓库版本时带着走比较省心。另外记得在验证代码时可以把某张训练图的热图标签单独dump出来直接用图像查看器叠在原图上确认椭圆方向和中心点位置是否和标注框一致这一步比看任何指标都直观能省下大量排查时间。如果你也是在做车辆、行人、船舶这类目标宽高比相对固定的检测任务这个从圆到椭圆的改动值得花半天时间试一下。即使最终收益不到一个点单是训练信号更符合目标形态这一点也能让后面的模型分析和错误排查轻松不少这是我在实际落地中体会最深的地方。