视图空间梯度

在上一篇文章中,我们已经了解了高斯点的自适应控制(Adaptive Control)——也就是“稠密化”(Densification)。高斯点会在需要细节的地方克隆(Clone),在过于庞大的地方分裂(Split)。

但这背后有一个核心问题:算法具体凭什么决定哪颗高斯需要克隆,哪颗需要分裂?

在这篇文章中,我们就来一步步实现稠密化策略的两个核心前置指标:场景绝对比例(Scene Scale)与视图空间位置梯度(View-space Positional Gradient)。


场景比例(Scene Scale)

在决定高斯是否“过大”而需要分裂时,官方代码给出的阈值公式是:tau_size = 0.1 * 1.1 * scene_scale

为什么不能用绝对值?想象一下,如果重建的是一个“城市级别”的巨大场景,几颗巨大的高斯球就能覆盖天空;但如果重建的是桌上的一个“微小盆栽”,高斯球必须非常细腻。如果我们把判断高斯“过大”的超参数设为绝对值(比如 0.5),那每次换场景都要重新调参。引入 scene_scale,就等于让超参数变成了“相对值”,从而实现场景的自适应。

我们将场景比例定义为:所有相机到场景中心点距离的最大值。

  1. # 自动计算场景比例 (scene_scale)
  2. c2ws_stacked = torch.stack(c2ws)
  3. camera_positions = c2ws_stacked[:, :3, 3]
  4. scene_center = torch.mean(camera_positions, dim=0)
  5. distances = torch.norm(camera_positions - scene_center, dim=-1)
  6. scene_scale = torch.max(distances).item() * 1.1
  1. tau_scale = 0.1 * scene_scale  # 区分克隆与分裂的高斯尺寸缩放阈值

视图空间位置梯度(View-space Gradient)

决定高斯是否需要“克隆”的核心指标是:视空间位置梯度( dL/du, dL/dv )。如果一个高斯点在屏幕空间(像素坐标)上被强烈地拉扯(梯度很大),说明它试图去拟合更多的细节,这时候我们就应该把它克隆一份。

但这里遇到了一个问题:在我们的主循环里,反向传播拿到的是世界空间梯度 (grad_position,即 dL/dx, dL/dy, dL/dz)。如果想把世界空间梯度逆推回视空间梯度,需要乘以雅可比矩阵的逆,而雅可比矩阵每时每刻都在变,计算量巨大。

解决方法是不要逆推。直接在进行反向传播计算时,把视空间梯度 dL/du, dL/dv 保存下来。

  1. # grad_uv shape: (N, 2)
  2. grad_uv = torch.zeros((pos.shape[0], 2), device=pos.device, dtype=pos.dtype)
  1. # 累加到 grad_uv
  2. grad_uv.scatter_add_(
  3.     0,
  4.     orig_ids_tile.unsqueeze(1).expand(-1, 2),
  5.     tile_grad_uv
  6. )
  1. # 保存 2D 视空间位置梯度到类属性中,供外部获取
  2. RasterizerFunction.gView = grad_uv

原始 3DGS 论文中默认使用的视图空间是 NDC 坐标系 (Normalized Device Coordinates),即 u, v \in [0, 1](或 [-1, 1])。而我们此处使用的是像素坐标系(u \in [0, W], v \in [0, H])。

梯度对像素坐标求导,和对 NDC 坐标求导,差了一个尺度的缩放。为了复用官方调优好的超参数,我们必须把像素梯度“伪装”成 NDC 梯度。

  1. # 将像素空间梯度转换为 NDC 空间梯度 (u分量乘以 W/2,v分量乘以 H/2)
  2. grad_uv[:, 0] *= (width / 2.0)
  3. grad_uv[:, 1] *= (height / 2.0)

梯度平均值

假设我们在 360 度环绕一个房间训练。某颗高斯点位于房间左侧。在 100 次迭代中,由于相机是随机采样的,这颗高斯可能只有 50 次出现在相机的视野中,参与了前向和反向传播计算。

如果平均次数错误,许多本该分裂/克隆的高斯点可能会因为均值被低估而达不到阈值,导致画面模糊。

我们需要为高斯属性增加两个私有字段:

1. pos.sum_g_view 累加视空间梯度。

2. pos.denom 记录该高斯真正参与计算的次数。

我们先在反向传播阶段传出参与的高斯点索引。

  1. # 计算高斯点可见性 Mask (在当前视角下是否 onscreen)
  2. visible_mask = torch.zeros(pos.shape[0], device=pos.device, dtype=pos.dtype)
  3. visible_mask[indices_onscreen] = 1.0
  4. RasterizerFunction.visible_mask = visible_mask

接着我们就可以累加梯度。

  1. # 只在自适应密度控制的活跃区间内(500 到 3000 步之间)累加 2D 视空间位置梯度幅值与可见性分母
  2. if iteration > 500 and iteration <= 3000:
  3.     with torch.no_grad():
  4.         # RasterizerFunction.gView: (N, 2)
  5.         # gView_norm: (N,) (计算 2D 梯度向量的 L2 范数/模长)
  6.         gView_norm = torch.norm(RasterizerFunction.gView, dim=-1)
  7.         # pos.sum_g_view: (N,)
  8.         pos.sum_g_view += gView_norm
  9.         # RasterizerFunction.visible_mask: (N,)
  10.         # pos.denom: (N,) (可见性分母计数器累加)
  11.         pos.denom += RasterizerFunction.visible_mask

最后我们就可以完善之前的筛选逻辑。

  1. # 1. 筛选高梯度高斯点 (通过累计梯度除以可见次数得到真正的平均梯度)
  2. # pos.sum_g_view: (N,), pos.denom: (N,) -> is_high_grad: (N,)
  3. is_high_grad = (pos.sum_g_view / torch.clamp(pos.denom, min=1.0)) > tau_pos
  1. # 重置梯度累加与分母,以便为下 100 步重新累加
  2. pos.sum_g_view = torch.zeros(pos.shape[0], device=device)  # (N,)
  3. pos.denom = torch.zeros(pos.shape[0], device=device)  # (N,)