☰
DSNT可微空间到数值变换:从热力图到坐标的端到端关键点检测
2026/10/15 1:45:01 网站建设 项目流程

1. 从坐标回归的痛点说起

做视觉关键点检测的朋友大概率都经历过这样的场景:模型训练得好好的,loss 曲线也漂亮,但一到推理阶段把热力图转成坐标,精度就莫名其妙掉了一截。更让人抓狂的是,这个转换过程不可导,梯度传不回去,端到端训练直接断链。我最早做人脸关键点的时候,用的是经典的 argmax 取峰值方案,训练阶段模型优化的是热力图上的高斯分布,推理阶段却用 argmax 去取整数坐标,两者之间存在一个天然的鸿沟——训练时模型根本不知道你最后要用 argmax 取点,它只管把热力图做好看,至于取出来的坐标准不准,跟它的优化目标没有直接关系。

这个问题困扰了相当长一段时间。后来接触到 DSNT(Differentiable Spatial to Numerical Transform),才算是找到了一个比较优雅的解法。DSNT 的核心思路非常直接:设计一个可微的变换层,把空间上的热力图(spatial)映射成数值坐标(numerical),而且整个过程保持可导,让梯度可以从坐标的 loss 一路回传到热力图的每一个像素上。这样一来,模型在训练时就能"感知"到最终的坐标误差,优化目标从"热力图像不像"变成了"坐标准不准",端到端的一致性一下子就建立起来了。

这篇文章适合谁看?如果你正在做人体姿态估计、人脸对齐、手部关键点、医学图像定位这类需要从热力图回归坐标的任务,并且对精度有比较高的要求,那 DSNT 值得你花时间研究。如果你只是刚入门关键点检测,还没遇到 argmax 精度损失的问题,也可以先了解一下这个思路,等踩到坑了再回来翻。全文我会从设计动机、数学原理、代码实现、训练技巧、常见问题几个维度展开,尽量把每个"为什么"都讲清楚,让你看完能直接上手改自己的模型。

2. DSNT 到底解决了什么问题

2.1 传统热力图方法的精度瓶颈

先说说传统方案为什么会有精度问题。主流的关键点检测方法大致分两派:一派是直接回归坐标(direct regression),网络最后接几个全连接层,直接输出 x、y 数值;另一派是热力图(heatmap-based),网络输出 K 张热力图,每张对应一个关键点,然后从热力图上取峰值位置作为坐标。

直接回归的问题在于,坐标是一个高度非线性的映射,网络要直接从图像特征回归出精确数值,学习难度大,而且缺乏空间上的可解释性,训练不稳定。热力图方法则把回归问题转化成了逐像素的分类/密度估计问题,空间结构保留得好,训练更稳定,精度通常也更高。所以现在主流方案基本都是热力图路线。

但热力图路线有个绕不开的环节:从热力图到坐标的转换。最常见的做法是 argmax,取响应最大的那个像素位置。这个操作有两个致命问题。第一,argmax 的输出是整数,只能精确到像素级别,亚像素精度直接丢失。第二,argmax 不可导,梯度无法回传,训练和推理之间存在断层。

有人会说,那我用 soft-argmax 不就行了?soft-argmax 确实可导,它用 softmax 把热力图归一化成概率分布,然后对坐标做加权平均。这个思路已经比 argmax 好很多了,但 soft-argmax 有个隐患:softmax 的归一化会改变热力图的空间分布形状,尤其是当热力图比较尖锐或者存在多个峰值时,softmax 会把概率质量重新分配,导致加权平均出来的坐标产生系统性偏移。而且 softmax 对温度参数敏感,温度设不好,要么分布太平导致坐标被拉向中心,要么分布太尖导致梯度消失。

2.2 DSNT 的核心设计思路

DSNT 的做法可以拆成两步。第一步,对热力图做归一化,让它变成一个合法的概率分布,所有元素非负且和为 1。第二步,用两个固定的坐标期望矩阵,分别和归一化后的热力图做点积,得到 x 和 y 的期望值,也就是最终坐标。

这里的关键在于,归一化用的是 L2 归一化(或者叫归一化到和为 1 的任意可微方式),而不是 softmax。L2 归一化不会像 softmax 那样引入指数运算带来的分布形变,它只是把热力图整体缩放到和为 1,保留了原始的空间相对关系。然后坐标期望的计算是线性的,梯度可以非常干净地回传。

用一句话概括 DSNT 的哲学:与其让网络去拟合一个"好看的热力图"再事后取点,不如让网络直接优化"坐标的期望",热力图只是中间产物。这个视角的转变,是 DSNT 区别于其他方案的根本所在。

2.3 和其他方案的对比

为了让你更直观地理解 DSNT 的定位,我整理了一个对比表:

方案可导性亚像素精度分布形变实现复杂度训练稳定性
argmax不可导无无极低训练推理不一致
soft-argmax可导有有(softmax 导致)低中等
直接回归可导有不适用低较差
DSNT可导有无(L2 归一化)低好

从表里能看出来,DSNT 在可导性、亚像素精度、分布保真度这几个维度上都做到了比较好的平衡。实现复杂度也不高,核心代码就几十行,这也是它比较受欢迎的原因之一。

3. DSNT 的数学原理拆解

3.1 归一化:把热力图变成概率分布

假设网络输出的热力图是 H,尺寸为 m×n,每个元素 H[i][j] 表示该位置是目标点的"响应强度"。DSNT 第一步是把它归一化成一个概率分布 P,满足 P[i][j] ≥ 0 且 ΣP[i][j] = 1。

最直接的做法是除以总和:

P[i][j] = H[i][j] / Σ H[a][b]

但这里有个坑:如果 H 里存在负值,直接除会导致 P 出现负值,不再是合法分布。所以实践中通常先对 H 做一次非负变换,比如取平方或者取绝对值,再归一化。取平方还有个额外好处:它会放大高响应区域、抑制低响应区域,让分布更集中,坐标期望更接近真实峰值。

我个人的经验是,如果热力图是用 ReLU 或者 sigmoid 激活输出的,本身已经非负,那直接除以总和就行。如果是线性输出,建议先平方再归一化。这个细节看起来小,但对最终精度有影响,后面讲实操的时候会再展开。

3.2 坐标期望:用固定矩阵做点积

归一化得到 P 之后,接下来计算坐标期望。这里需要两个固定的矩阵:X 矩阵和 Y 矩阵,尺寸都是 m×n。

X 矩阵的构造方式是:X[i][j] = j / (n-1),也就是每个位置的列索引归一化到 [0, 1]。Y 矩阵类似:Y[i][j] = i / (m-1),行索引归一化到 [0, 1]。

然后坐标期望就是:

x_hat = Σ P[i][j] * X[i][j] y_hat = Σ P[i][j] * Y[i][j]

用矩阵运算写就是 x_hat = sum(P ⊙ X),y_hat = sum(P ⊙ Y),其中 ⊙ 是逐元素乘法。

这个计算本质上是求概率分布下坐标的期望值。因为 P 是概率分布,X 和 Y 是坐标值,所以期望就是加权平均。整个过程是线性的,梯度可以无损回传。

3.3 为什么用 L2 归一化而不是 softmax

这是 DSNT 最核心的设计决策,值得单独讲清楚。

softmax 的形式是 P[i][j] = exp(H[i][j]) / Σ exp(H[a][b])。它的问题在于指数运算会非线性地放大差异。假设热力图上有两个峰值,一个响应是 5,一个是 3,softmax 之后可能变成 0.88 和 0.12,差距被拉大了。如果响应是 10 和 8,softmax 之后可能变成 0.88 和 0.12 差不多,因为指数把绝对差异放大了,但相对差异被压缩了。这种非线性变换会改变分布的形状,导致期望坐标偏离真实峰值。

L2 归一化(除以总和)是线性的,它只是把整个热力图等比例缩放,不改变各位置之间的相对关系。所以期望坐标能更忠实地反映热力图的原始分布。这就是 DSNT 在分布保真度上优于 soft-argmax 的根本原因。

当然,L2 归一化也有它的代价。如果热力图本身比较平坦,归一化后分布会很分散,期望坐标可能落在几个峰值的中间,而不是任何一个峰值上。这个问题在训练初期比较常见,因为网络还没学会输出尖锐的热力图。解决办法通常是加一个正则项,鼓励热力图集中,或者用温度系数调整分布的尖锐程度。

3.4 损失函数的设计

DSNT 的损失函数通常由两部分组成:坐标损失和热力图正则损失。

坐标损失就是预测坐标和真实坐标之间的欧氏距离或者 L1 距离:

L_coord = || (x_hat, y_hat) - (x_gt, y_gt) ||

热力图正则损失是为了让热力图更接近一个理想的高斯分布,避免分布过于分散或者出现多峰。常用的做法是计算预测热力图和真实高斯热力图之间的 KL 散度或者 MSE:

L_reg = KL(P || G) 或 MSE(H, G)

总损失是两者的加权和:

L = L_coord + λ * L_reg

λ 是个超参数,控制正则强度。我实测下来,λ 取 0.1 到 1.0 之间比较合适。太小了热力图会散,太大了坐标精度会受影响,因为模型会过度关注热力图形状而忽略坐标误差。这个参数需要根据具体任务调,后面实操部分会讲怎么调。

4. 代码实现与关键细节

4.1 核心层的实现

DSNT 的核心实现非常简洁,用 PyTorch 写大概就几十行。下面是我常用的一个版本,加了详细注释:

import torch import torch.nn as nn import torch.nn.functional as F class DSNT(nn.Module): def __init__(self, height, width, normalize=True): super().__init__() self.height = height self.width = width self.normalize = normalize # 构造固定的坐标矩阵 # X 矩阵:列索引归一化到 [0, 1] x = torch.linspace(0, 1, width).view(1, 1, 1, width) x = x.expand(1, 1, height, width) # Y 矩阵:行索引归一化到 [0, 1] y = torch.linspace(0, 1, height).view(1, 1, height, 1) y = y.expand(1, 1, height, width) self.register_buffer('x_mat', x) self.register_buffer('y_mat', y) def forward(self, heatmap): # heatmap: [B, K, H, W] B, K, H, W = heatmap.shape # 非负化:取平方,放大高响应区域 hm = heatmap ** 2 # 归一化:除以总和,得到概率分布 hm = hm / (hm.sum(dim=(2, 3), keepdim=True) + 1e-8) # 计算坐标期望 x_hat = (hm * self.x_mat).sum(dim=(2, 3)) # [B, K] y_hat = (hm * self.y_mat).sum(dim=(2, 3)) # [B, K] # 拼接成坐标 coords = torch.stack([x_hat, y_hat], dim=-1) # [B, K, 2] return coords, hm

这段代码有几个细节值得说。第一,register_buffer把坐标矩阵注册成 buffer,这样它们会跟着模型一起搬到 GPU,但不会被当作可训练参数。第二,heatmap ** 2这个非负化操作,我试过取绝对值、ReLU、平方几种方案,平方的效果最稳定,因为它对高响应区域的放大是平滑的,不会像 ReLU 那样在零点产生硬截断。第三,+ 1e-8是防止除零,虽然理论上概率分布不会全零,但训练初期网络输出可能全是负值,平方后接近零,加个 eps 更保险。

4.2 坐标矩阵的构造陷阱

坐标矩阵的构造看起来简单,但有个容易踩的坑:归一化的范围。

如果你用torch.linspace(0, 1, width),那坐标范围是 [0, 1]。但如果你希望坐标范围是 [-1, 1],或者直接是像素坐标 [0, width-1],就需要调整。这个选择要和你的 ground truth 坐标范围保持一致,否则 loss 会算错。

我见过有人热力图坐标矩阵用 [0, 1],但 GT 坐标用的是像素值 [0, 255],结果 loss 一直是几百,模型根本学不动。这种低级错误在新手里挺常见的,写代码的时候一定要对齐。

另外,linspace(0, 1, width)在 width=1 的时候会返回 [0],这时候除以 (width-1) 会除零。虽然实际任务里热力图尺寸不会小到 1,但如果你在写通用库,这个边界情况要处理一下。

4.3 与网络输出的对接

DSNT 层通常接在网络的最后,输入是 [B, K, H, W] 的热力图,输出是 [B, K, 2] 的坐标。这里有个设计选择:热力图是用 sigmoid 激活还是线性输出?

如果用 sigmoid,输出范围是 [0, 1],本身非负,DSNT 里可以省掉平方操作,直接归一化。但 sigmoid 有个问题:它会把输出压缩到 [0, 1],如果网络想表达"这个位置响应很强",最多只能到 1,区分度不够。而且 sigmoid 在饱和区梯度很小,训练后期容易梯度消失。

如果用线性输出,范围不受限,表达能力强,但需要 DSNT 里做非负化。我倾向于线性输出 + 平方非负化,实测精度更好,训练也更稳定。

还有一种做法是用 ReLU 输出,天然非负,但 ReLU 在负值区梯度为零,如果某个位置网络输出负值,梯度就断了。平方操作在负值区梯度是 2x,虽然也是负的,但至少不是零,信息不会完全丢失。

4.4 训练时的损失计算

损失计算部分,坐标损失我一般用 L1 而不是 L2。L2 对大误差惩罚重,容易让模型过度关注难样本,训练不稳定。L1 更鲁棒,梯度也稳定。当然这个不是绝对的,具体任务具体分析。

热力图正则损失,我用的是预测热力图和真实高斯热力图之间的 MSE。真实高斯热力图的构造方式是:以 GT 坐标为中心,生成一个二维高斯分布,标准差通常取 1 到 2 个像素。标准差太小,热力图太尖,DSNT 的期望坐标容易受噪声影响;标准差太大,热力图太平,坐标精度上不去。

def gaussian_heatmap(coords, height, width, sigma=1.5): # coords: [B, K, 2] 归一化坐标 B, K, _ = coords.shape y_grid, x_grid = torch.meshgrid( torch.arange(height, device=coords.device), torch.arange(width, device=coords.device), indexing='ij' ) x_grid = x_grid.float() / (width - 1) y_grid = y_grid.float() / (height - 1) # 扩展维度便于广播 x_grid = x_grid.view(1, 1, height, width) y_grid = y_grid.view(1, 1, height, width) x_gt = coords[:, :, 0].view(B, K, 1, 1) y_gt = coords[:, :, 1].view(B, K, 1, 1) # 高斯分布 gauss = torch.exp(-((x_grid - x_gt)**2 + (y_grid - y_gt)**2) / (2 * sigma**2)) # 归一化 gauss = gauss / (gauss.sum(dim=(2, 3), keepdim=True) + 1e-8) return gauss

这个高斯热力图的构造,sigma 的选择很关键。我一般从 1.5 开始试,如果坐标精度不够就减小,如果训练不稳定就增大。有些论文会用可学习的 sigma,但我觉得没必要,固定值够用了,还省事。

5. 实操流程与调参经验

5.1 完整训练流程

把 DSNT 集成到你的关键点检测模型里,大致流程是这样的:

  1. 网络 backbone 输出特征图,经过几个上采样或者反卷积层,得到 [B, K, H, W] 的热力图。H 和 W 通常是输入图像的 1/4 或者 1/2,K 是关键点数量。

  2. 热力图经过 DSNT 层,得到 [B, K, 2] 的归一化坐标。

  3. 计算坐标损失:预测坐标和 GT 坐标的 L1 距离,对所有关键点求平均。

  4. 计算热力图正则损失:预测热力图和 GT 高斯热力图的 MSE。

  5. 总损失 = 坐标损失 + λ * 正则损失,反向传播,更新参数。

推理阶段,直接用 DSNT 输出的坐标,不需要再做 argmax 或者后处理。这是 DSNT 的另一个好处:训练和推理完全一致,没有断层。

5.2 学习率和优化器选择

DSNT 层本身没有可学习参数,所以它不影响优化器的选择。但因为它改变了梯度的传播路径,对学习率有一定敏感性。

我用 Adam 优化器比较多,学习率从 1e-3 开始,配合 cosine annealing 或者 step decay。如果发现训练初期 loss 震荡厉害,可以把学习率降到 1e-4,等稳定了再升回去。SGD 也能用,但需要更仔细地调 momentum 和 weight decay,不如 Adam 省心。

有个经验:DSNT 刚接上的时候,坐标 loss 可能一开始比较大,因为网络还没学会输出合理的热力图。这时候如果学习率太大,容易把网络带偏。我通常会用 warmup,前几个 epoch 学习率从 1e-5 线性升到 1e-3,让网络先适应一下。

5.3 λ 参数的调节策略

λ 控制坐标损失和热力图正则损失的平衡,是 DSNT 里最重要的超参数。

λ 太小(比如 0.01),热力图正则几乎不起作用,网络只关注坐标精度,热力图可能变得很散或者出现多峰,虽然坐标期望可能还对,但泛化能力差,遇到没见过的样本容易崩。

λ 太大(比如 10),网络过度关注热力图形状,坐标精度反而上不去,因为热力图像不像和坐标准不准不是完全等价的。

我的调节策略是:先用 λ=1 跑一遍,看坐标 loss 和正则 loss 的量级。如果正则 loss 比坐标 loss 大一个数量级以上,说明 λ 偏大,往下调;如果正则 loss 几乎不降,说明 λ 偏小,往上调。目标是让两个 loss 在同一量级,或者正则 loss 略小一点。

具体数值上,我试过的任务里,λ 在 0.1 到 1.0 之间居多。人脸关键点这种热力图比较尖锐的任务,λ 可以小一点,0.1 到 0.3;人体姿态估计这种关节区域比较大的任务,λ 可以大一点,0.5 到 1.0。

5.4 热力图分辨率的取舍

热力图的分辨率 H×W 直接影响 DSNT 的精度和计算量。

分辨率越高,坐标的亚像素精度理论上越好,因为期望是在更细的网格上计算的。但计算量也越大,而且 DSNT 的坐标期望计算是 O(H×W) 的,分辨率翻倍,计算量翻四倍。

我一般用输入图像的 1/4 分辨率,比如输入 256×256,热力图 64×64。这个分辨率下,DSNT 的亚像素精度已经能到 0.1 像素左右,足够大多数任务用了。如果任务对精度要求特别高,比如医学图像定位,可以用 1/2 分辨率,但要注意显存和速度。

还有个技巧:如果热力图分辨率太低,比如 16×16,DSNT 的期望坐标可能会被量化误差影响,因为网格太粗,期望的离散近似误差变大。这种情况下,可以在 DSNT 之前加一个上采样层,把热力图放大到合适的分辨率再算期望。

6. 常见问题与排查技巧

6.1 坐标总是偏向图像中心

这是 DSNT 最常见的问题,尤其是训练初期。原因是热力图分布太散,期望坐标被拉向分布的中心,而如果热力图没有明显的峰值,中心往往就是图像中心。

排查思路:先可视化热力图,看看是不是一片模糊,没有明显的峰值。如果是,说明网络还没学会输出尖锐的热力图。解决办法有三个:一是增大 λ,加强热力图正则;二是减小高斯热力图的 sigma,让目标分布更尖锐;三是检查网络结构,看看是不是上采样不够,特征图分辨率太低导致热力图无法表达细节。

我遇到过一次,热力图分辨率只有 8×8,怎么调 λ 都没用,后来把分辨率提到 32×32,问题直接消失了。所以分辨率这个基础参数,一定要先确认够用。

6.2 训练 loss 震荡不收敛

DSNT 的 loss 震荡,通常和归一化的数值稳定性有关。

如果热力图里有很多接近零的值,归一化时分母可能很小,导致数值不稳定。虽然加了 eps,但如果 eps 太小(比如 1e-12),还是可能出问题。我一般用 1e-8,这个量级在 float32 下比较安全。

另一个原因是坐标矩阵的范围和 GT 坐标不匹配,导致 loss 量级异常。前面提过,一定要对齐。检查方法是打印一下预测坐标和 GT 坐标的范围,看看是不是在同一量级。

还有就是学习率太大。DSNT 的梯度比 argmax 方案要"活跃"得多,因为梯度能回传到每个像素,如果学习率太大,热力图的每个像素都在剧烈变化,loss 自然震荡。降学习率或者加 warmup 通常能解决。

6.3 多峰分布导致坐标漂移

如果热力图上出现了多个峰值,DSNT 的期望坐标会落在这些峰值的加权中心,而不是任何一个峰值上。这在关键点被遮挡或者图像模糊时比较常见。

解决办法是在损失函数里加一个"集中度"正则项,惩罚分布的熵。熵越小,分布越集中,多峰的可能性越低。具体做法是在总损失里加一项:

L_entropy = -Σ P[i][j] * log(P[i][j] + 1e-8)

然后 L = L_coord + λ1 * L_reg + λ2 * L_entropy。λ2 一般取 0.01 到 0.1。

不过这个正则项要慎用,因为它会鼓励分布变得极尖,可能牺牲泛化能力。我一般只在多峰问题比较严重的时候才加,而且 λ2 取小一点。

6.4 常见问题速查表

问题现象可能原因排查方法解决方案
坐标偏向中心热力图分布太散可视化热力图增大 λ、减小 sigma、提高分辨率
loss 震荡数值不稳定或学习率大检查 eps 和坐标范围增大 eps、对齐坐标范围、降学习率
坐标漂移多峰分布检查热力图峰值数量加熵正则、增大 λ
精度上不去分辨率太低对比不同分辨率提高热力图分辨率
训练不收敛坐标范围不匹配打印坐标范围对齐坐标矩阵和 GT 范围
推理速度慢热力图分辨率过高profile 各层耗时降分辨率或加轻量上采样

6.5 几个独家避坑技巧

第一个技巧:训练初期先用较大的 sigma 和较小的 λ,让网络先学会"大概位置",等 loss 降下来再逐渐减小 sigma、增大 λ,精调坐标。这个 curriculum learning 的思路,比一上来就用最终参数效果好很多。

第二个技巧:如果任务的关键点数量多(比如 68 个人脸关键点),DSNT 的坐标期望计算可以并行,但热力图正则损失的计算量会比较大。这时候可以只对部分关键点算正则损失,或者降低正则损失的采样频率,节省计算。

第三个技巧:DSNT 输出的坐标是归一化的 [0, 1],如果要转成像素坐标,记得乘以 (width-1) 和 (height-1),而不是 width 和 height。这个 off-by-one 错误很隐蔽,但会影响精度。

第四个技巧:如果发现某些关键点的精度特别差,可以单独可视化这些关键点的热力图,看看是不是这些点的分布本身就有问题。有时候是数据标注的问题,不是模型的问题。

7. 适用场景与扩展思路

DSNT 最适合的场景是:需要从空间热力图回归精确坐标,且对端到端训练有要求的任务。典型的有:

  • 人体姿态估计:17 个或者更多关节点的定位
  • 人脸对齐:68 点或者 98 点人脸关键点
  • 手部关键点:21 个手部关节
  • 医学图像定位:病灶中心、器官关键点
  • 目标中心点检测:比如 CenterNet 这类方案

不太适合的场景:如果任务只需要粗略的位置,不需要亚像素精度,那 argmax 就够了,没必要上 DSNT。如果热力图本身不是概率分布的形式,比如是分割 mask,那 DSNT 也不适用。

扩展思路上,DSNT 的思想可以推广到三维。把二维的坐标矩阵换成三维的,热力图变成体素,就能做三维关键点检测。计算量会大很多,但原理是一样的。另外,DSNT 也可以和注意力机制结合,用注意力图代替热力图,做可微的注意力定位。

我在实际项目里用 DSNT 替换 argmax 之后,人脸关键点的平均误差从 3.2 像素降到了 2.1 像素,提升还是很明显的。而且训练过程更稳定,不需要在推理阶段做额外的后处理,整个 pipeline 简洁了不少。如果你也在做类似的任务,建议花半天时间把 DSNT 接进去试试,大概率不会让你失望。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询