投影协方差

在之前的文章中,我们已经成功地将 3D 高斯的均值(Means,即位置 x, y, z)投影到了屏幕的二维图像平面上。但这仅仅完成了一半的工作。在 3DGS 中,一个高斯球不仅有中心点(均值),还有决定它形状和体积的 3D 协方差(Covariance,参数 \Sigma)。

此篇文章的核心目标,就是将 3D 协方差投影到 2D 平面上,也就是求出 \Sigma'。一旦我们完成了这一步,才算是真正完成了从 3D 到 2D 的投影全过程。

将 3D 协方差投影到 2D 的数学公式如下:

\Sigma' = J W \Sigma W^T J^T

这个公式看着吓人,但我们在代码里其实只需要拆解成三步:

1. W:从世界坐标系到相机坐标系的变换矩阵(World to Camera matrix)。

2. J:投影变换的雅可比矩阵(Jacobian)。

3. 连乘:把它们和原始的 3D 协方差 \Sigma 乘起来。


为什么需要雅可比矩阵 J

在相机坐标系中,从 3D 点映射到 2D 像素坐标的“透视投影(Perspective Projection)”是一个非线性变换(因为计算时需要除以深度 z)。

如果我们对一个高斯分布进行非线性变换,投影出来的结果就不再是标准的高斯分布了。为了在 2D 平面上依然能够用椭圆(2D 高斯)来近似渲染,我们需要在 3D 高斯的中心点处,利用一阶泰勒展开对非线性的透视投影进行线性近似(仿射变换)。

求取这个线性近似的导数矩阵,也就是在求雅可比矩阵 J

假设 3D 高斯的中心点在世界坐标系下为 \mu_w

首先,通过视图矩阵 W 将其转换到相机坐标系下,得到点 \mu_c

\mu_c = \begin{bmatrix} x \\ y \\ z \end{bmatrix} = W \mu_w

接下来,我们需要将相机坐标系下的点 \mu_c 投影到 2D 屏幕坐标系 \mathbf{p} = [u, v]^T 中。根据经典的针孔相机模型(Pinhole Camera Model),透视投影公式为:

u = f_x \frac{x}{z} + c_x

v = f_y \frac{y}{z} + c_y

其中,f_x, f_y 是相机在 xy 轴上的焦距,c_x, c_y 是图像中心的偏移量。

雅可比矩阵 J 本质上是输出的 2D 坐标 [u, v] 针对输入的 3D 坐标 [x, y, z] 的偏导数矩阵。它的维度是 2 \times 3

J = \frac{\partial (u, v)}{\partial (x, y, z)} = \begin{bmatrix} \frac{\partial u}{\partial x} & \frac{\partial u}{\partial y} & \frac{\partial u}{\partial z} \\ \frac{\partial v}{\partial x} & \frac{\partial v}{\partial y} & \frac{\partial v}{\partial z} \end{bmatrix}

将计算得到的所有偏导数代入矩阵中,我们就得到了最终的核心雅可比矩阵 J

J = \begin{bmatrix} \frac{f_x}{z} & 0 & -\frac{f_x \cdot x}{z^2} \\ 0 & \frac{f_y}{z} & -\frac{f_y \cdot y}{z^2} \end{bmatrix}


代码实现

首先我们需要得到世界到相机的变换矩阵 W。代码中我们已有“相机到世界(Camera to World, C2W)”的变换。我们需要提取其左上角的旋转部分,并对其进行转置,就能得到“世界到相机(World to Camera)”的旋转矩阵。

  1. # W: 从世界坐标系到相机坐标系的变换矩阵
  2. W = camera2world[:3, :3].T

接着我们构建雅可比矩阵。我们先初始化一个全零矩阵,然后填入我们推导出的偏导数。

  1. # 构建雅可比矩阵 J
  2. J = torch.zeros((N, 2, 3), device=pos.device, dtype=pos.dtype)
  3. J[:, 0, 0] = fx / z_cam
  4. J[:, 1, 1] = fy / z_cam
  5. J[:, 0, 2] = -(fx * x_cam) / (z_cam ** 2)
  6. J[:, 1, 2] = -(fy * y_cam) / (z_cam ** 2)

现在我们有了 WJ,就可以直接组装公式了。

  1. # 矩阵乘法
  2. TMP = W.unsqueeze(0) @ sigma @ W.unsqueeze(0).transpose(1, 2)
  3. sigma_camera = J @ TMP @ J.transpose(1, 2)