针孔相机模型和点投影

在深入探讨高斯泼溅(Gaussian Splatting)等前沿 3D 渲染技术之前,我们必须先掌握一个核心的基础概念:相机模型。在计算机图形学中,最经典、最常用的便是针孔相机模型(Pinhole Camera Model)。


什么是针孔相机模型?

针孔相机模型描述的是一种最基础的透视投影(Perspective Projection)。

我们可以把它想象成一个简单的“鞋盒实验”:拿一个密封的鞋盒,在前面戳一个小孔(针孔),在鞋盒内部的底面放上一块感光传感器。外界物体反射的光子(Photons)穿过这个小孔,打在传感器上。经过一段时间的曝光(Exposure Time),传感器上就会留下外界真实的影像。

现实中的针孔相机虽然简单,但有一个明显的物理特性:成像在传感器上是上下颠倒的。因为光线是沿直线传播并交叉穿过小孔的。

焦距(Focal Length)

在这个简单的模型中,有一个至关重要的参数:焦距(f)。

焦距指的是针孔(相机中心)到成像平面(传感器)之间的距离。改变焦距会直接影响画面的视野大小:

  • 增大焦距:相当于增加了相机中心和传感器之间的距离,物体在传感器上看起来会更大,产生放大(Zoom in)的效果(例如从 50mm 切换到 100mm 镜头)。
  • 减小焦距:视野变宽,物体看起来更小,产生缩小(Zoom out)的效果。

真实的相机远比针孔模型复杂。为了缩短几十分钟的曝光时间,现代相机引入了复杂的透镜组,这也随之带来了诸如色差(Chromatic Aberration)、景深(Depth of Field)和畸变等物理现象。但在高斯泼溅的计算中,为了简化数学模型,我们通常将其理想化,直接假设为完美的针孔相机模型。

解决“颠倒”问题

前面提到,物理上的针孔成像是上下颠倒的。在编写代码和构建数学模型时,为了避免每次都要将图像“翻转”回来,图形学界采用了一个非常聪明的做法:将成像平面从相机中心“后方”移到“前方”。

通过将成像平面放置在相机中心正前方距离为 f 的位置,光线在到达相机中心之前就会先穿过这个虚拟的平面。根据负负得正的几何原理,此时在虚拟成像平面上得到的图像就是正立的。


投影推导

接下来是整个模型的核心:如何将 3D 世界中的一个点映射到 2D 的像素屏幕上?

假设在相机坐标系下,我们有一个3D点 P,它的坐标是 (x, y, z)。我们的目标是求出它在2D图像上的像素坐标 (u, v)

第一步:消除深度(z轴),统一映射到成像平面

我们希望所有的点都被“拍扁”到距离相机中心深度为 f 的像平面上。向量的特性是:缩放向量不会改变其方向。因此,我们将点 P 的所有坐标除以 z,再乘以 f

x' = \frac{x \cdot f}{z}

y' = \frac{y \cdot f}{z}

此时,点已经被投影到了二维的物理像平面上。

第二步:从物理坐标转换为像素索引 (u, v)

图像是以像素矩阵表示的,且原点 (0,0) 通常在图像的左上角。但我们刚才计算出的 (x', y') 原点是在图像的正中心。

  • 偏移中心点:我们需要加上图像中心的偏移量。设图像宽度的一半为 c_x,高度的一半为 c_y
  • 转换为像素单位:物理尺寸通常是毫米(mm),而图像坐标是像素(Pixel)。我们需要除以每个像素的物理尺寸 s_xs_y

为了简化公式,我们通常将焦距与像素尺寸的转换合并为一个参数:

f_x = \frac{f}{s_x}

f_y = \frac{f}{s_y}

这也是为什么我们在使用 COLMAP 等相机标定软件时,会看到 f_xf_y 两个焦距参数。因为像素可能不是完美的正方形。

最终的投影公式诞生了:

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

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

公式中的加减符号高度依赖于所使用的坐标系约定(右手系还是左手系,x/y 轴的正方向朝向哪里)。例如 COLMAP 有自己固定的一套坐标系,而 PyTorch3D 或 OpenCV 可能有所不同。在跨软件处理数据时,必须第一时间对齐坐标系,否则会在投影时出现莫名其妙的翻转或偏移。


代码实现

理解了公式,我们就可以开始用 PyTorch 构建原先留空的 project_points 函数了。

  1. def project_points(PC, height, width, fx, fy, cx, cy):
  2.     """
  3.     将三维点投影到二维屏幕坐标系。
  4.     根据针孔相机模型公式:
  5.     u = fx * x_cam / z_cam + cx
  6.     v = fy * y_cam / z_cam + cy
  7.     """
  8.     x_cam = PC[:, 0]
  9.     y_cam = PC[:, 1]
  10.     z_cam = PC[:, 2]
  11.  
  12.     u = fx * x_cam / z_cam + cx
  13.     v = fy * y_cam / z_cam + cy
  14.  
  15.     uv = torch.stack([u, v], dim=-1)
  16.     return uv, x_cam, y_cam, z_cam

torch.stack 将独立的 uv 坐标合并为一个 [N, 2] 形状的张量,代表 N 个点的二维坐标。

有了投影逻辑,我们需要捏造一些数据来验证它。我们沿着球面制作一些随机点。完整的可视化代码可查看 Commit d206c66:visualize.py