先说一个可能很多人没注意到的点:CenterNet的热图半径,看起来只是生成训练标签时一个附带的超参数,但它对检测结果的稳定性影响,比我一开始预想的大得多。前阵子我在一个以车辆为主的自采数据集上折腾精度,卡车和公交车的AP怎么调都上不去,排查到最后,问题竟然出在训练热图里那颗“圆形的”高斯核上——目标本身是长条的,高斯却是个正圆,标注信号的形状和目标形态完全对不上。把这个细节从圆形改成椭圆之后,长条目标的定位明显稳了,整体AP也实打实涨了一截。这篇文章就把这个优化思路掰开揉碎讲清楚,包括原版半径计算到底在算什么、为什么圆形不够用、椭圆半径怎么自适应地算出来,以及落地实现时要避开的几个坑。
1. 先从CenterNet的热图机制说起
1.1 热图的生成与监督信号
CenterNet把一个目标检测问题转换成了关键点估计问题:对每个类别,网络输出一张特征图,特征图上的峰值就是目标中心点所在位置。所以训练时我们需要为每个目标生成一张“理想输出”作为监督,这张图上,目标的中心位置响应最高,周围按高斯形式衰减。这个衰减区域用的就是二维高斯函数。
具体生成时,对标注框的中心点 ((cx, cy)),在该类别的heatmap上铺一个高斯峰。热图尺寸一般比输入图小,最常见的是输入512、输出128,也就是下采样4倍。高斯峰的形状与范围,由一个半径 (r) 决定。在原版代码里,高斯核模板用下面的方式生成:
def gaussian2D(shape, sigma=1): 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) 绑定在一起:模板尺寸是 (2r+1),(\sigma = (2r+1)/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_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)网上很多分析把这三种情况解释成“圆与矩形的三种几何位置关系”,大体方向是:第一种设想圆被矩形包围,第二、第三种分别对应矩形被圆包围、以及矩形与圆相切等临界约束。理解到“这是从不同相对位置推出来的候选半径,最终取一个最保守的值”就可以了,不用死磕表达式。
我更想强调的是它的一个核心特点:这个函数无论怎么算,输出的只有一个标量半径 (r)。它把目标框的宽 (w) 和高 (h) 揉成了一个折中值——相当于给长方形目标硬套了一个圆。
2.2 圆形核在长条形目标上的偏差
问题就出在这个“折中”上。假设一个目标框的尺寸是 (w=80, h=20),宽高比是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_ratio=2.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_overlap=0.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_x=1, sigma_y=1): 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, k=1): diameter_x = 2 * radius_x + 1 diameter_y = 2 * radius_y + 1 gaussian = gaussian2D_ellipse( (diameter_y, diameter_x), sigma_x=diameter_x / 6., sigma_y=diameter_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, out=masked_heatmap)这块有个容易写错的地方:生成gaussian时,第一个维度对应的是 (y),第二个维度对应的是 (x),所以传入的shape是(diameter_y, diameter_x),而不是反过来。切片时同样要注意,radius_y控制行方向,radius_x控制列方向。方向搞反了,热图上的椭圆会旋转90度,我一开始就踩过这个坑,出来的检测框轨迹全偏了。
4.2 在CTDetDataset中传入rx和ry
CenterNet的数据集类里,原来生成热图的那一段是长这样的:
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,再经过椭圆变换、取整,很容易变成rx=2, ry=1甚至两个都退化成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之间的目标(主要是车辆)的结果:
| 配置 | AP@0.5 | AP@0.75 | 中心点定位误差(像素) |
|---|---|---|---|
| 原版圆形高斯 | 0.482 | 0.314 | 3.21 |
| 椭圆高斯 + max_ratio=2 | 0.501 | 0.335 | 2.87 |
| 椭圆高斯 + max_ratio=4 | 0.508 | 0.341 | 2.79 |
| 椭圆高斯 + max_ratio=8 | 0.493 | 0.322 | 2.98 |
能看到max_ratio不是越大越好,4倍以内增益稳定,到8倍反而下降了。原因也简单,比例限制过松,极端细长目标的训练信号锐化过度,回归不稳定。所以组内实验最终采用的是max_ratio=4这档,兼顾了普通车辆和少数长车厢卡车。
5.2 min_overlap和椭圆比例的联动
min_overlap是原版gaussian_radius里的关键阈值,它默认0.7。改成椭圆的时候,这个参数和椭圆比例是联动的,调整一个就可能让另一个的效果失真。
我的经验是:当椭圆比例拉大时,可以适当降低min_overlap。因为椭圆本身已经让高斯核的形状更贴近目标框,不需要再用一个大半径去强行覆盖目标的所有角落。把min_overlap从0.7降到0.6,热图中心区域更锐利一点,中心点估计会更准,但AP@0.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出来,直接用图像查看器叠在原图上确认椭圆方向和中心点位置是否和标注框一致,这一步比看任何指标都直观,能省下大量排查时间。
如果你也是在做车辆、行人、船舶这类目标宽高比相对固定的检测任务,这个从圆到椭圆的改动值得花半天时间试一下。即使最终收益不到一个点,单是训练信号更符合目标形态这一点,也能让后面的模型分析和错误排查轻松不少,这是我在实际落地中体会最深的地方。