视图空间梯度
在上一篇文章中,我们已经了解了高斯点的自适应控制(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,就等于让超参数变成了“相对值”,从而实现场景的自适应。
我们将场景比例定义为:所有相机到场景中心点距离的最大值。
- # 自动计算场景比例 (scene_scale)
- c2ws_stacked = torch.stack(c2ws)
- camera_positions = c2ws_stacked[:, :3, 3]
- scene_center = torch.mean(camera_positions, dim=0)
- distances = torch.norm(camera_positions - scene_center, dim=-1)
- scene_scale = torch.max(distances).item() * 1.1
- tau_scale = 0.1 * scene_scale # 区分克隆与分裂的高斯尺寸缩放阈值
视图空间位置梯度(View-space Gradient)
决定高斯是否需要“克隆”的核心指标是:视空间位置梯度(
但这里遇到了一个问题:在我们的主循环里,反向传播拿到的是世界空间梯度 (grad_position,即
解决方法是不要逆推。直接在进行反向传播计算时,把视空间梯度
- # grad_uv shape: (N, 2)
- grad_uv = torch.zeros((pos.shape[0], 2), device=pos.device, dtype=pos.dtype)
- # 累加到 grad_uv
- grad_uv.scatter_add_(
- 0,
- orig_ids_tile.unsqueeze(1).expand(-1, 2),
- tile_grad_uv
- )
- # 保存 2D 视空间位置梯度到类属性中,供外部获取
- RasterizerFunction.gView = grad_uv
原始 3DGS 论文中默认使用的视图空间是 NDC 坐标系 (Normalized Device Coordinates),即
梯度对像素坐标求导,和对 NDC 坐标求导,差了一个尺度的缩放。为了复用官方调优好的超参数,我们必须把像素梯度“伪装”成 NDC 梯度。
- # 将像素空间梯度转换为 NDC 空间梯度 (u分量乘以 W/2,v分量乘以 H/2)
- grad_uv[:, 0] *= (width / 2.0)
- grad_uv[:, 1] *= (height / 2.0)
梯度平均值
假设我们在 360 度环绕一个房间训练。某颗高斯点位于房间左侧。在 100 次迭代中,由于相机是随机采样的,这颗高斯可能只有 50 次出现在相机的视野中,参与了前向和反向传播计算。
如果平均次数错误,许多本该分裂/克隆的高斯点可能会因为均值被低估而达不到阈值,导致画面模糊。
我们需要为高斯属性增加两个私有字段:
1. pos.sum_g_view 累加视空间梯度。
2. pos.denom 记录该高斯真正参与计算的次数。
我们先在反向传播阶段传出参与的高斯点索引。
- # 计算高斯点可见性 Mask (在当前视角下是否 onscreen)
- visible_mask = torch.zeros(pos.shape[0], device=pos.device, dtype=pos.dtype)
- visible_mask[indices_onscreen] = 1.0
- RasterizerFunction.visible_mask = visible_mask
接着我们就可以累加梯度。
- # 只在自适应密度控制的活跃区间内(500 到 3000 步之间)累加 2D 视空间位置梯度幅值与可见性分母
- if iteration > 500 and iteration <= 3000:
- with torch.no_grad():
- # RasterizerFunction.gView: (N, 2)
- # gView_norm: (N,) (计算 2D 梯度向量的 L2 范数/模长)
- gView_norm = torch.norm(RasterizerFunction.gView, dim=-1)
- # pos.sum_g_view: (N,)
- pos.sum_g_view += gView_norm
- # RasterizerFunction.visible_mask: (N,)
- # pos.denom: (N,) (可见性分母计数器累加)
- pos.denom += RasterizerFunction.visible_mask
最后我们就可以完善之前的筛选逻辑。
- # 1. 筛选高梯度高斯点 (通过累计梯度除以可见次数得到真正的平均梯度)
- # pos.sum_g_view: (N,), pos.denom: (N,) -> is_high_grad: (N,)
- is_high_grad = (pos.sum_g_view / torch.clamp(pos.denom, min=1.0)) > tau_pos
- # 重置梯度累加与分母,以便为下 100 步重新累加
- pos.sum_g_view = torch.zeros(pos.shape[0], device=device) # (N,)
- pos.denom = torch.zeros(pos.shape[0], device=device) # (N,)