原理

Splatting

  • 渲染(Rendering)是计算机图形学中的核心环节,指将三维场景(3D Scene)通过计算转化为二维图像(2D Image)的过程。简单来说,就是“把3D模型变成2D画面”。
  • 在传统渲染中,物体表面由无数个三角形(面)拼接而成。而 Splatting 认为,物体表面也可以由无数个带有大小和颜色的“点”来构成。渲染时,计算机不再计算三角形的边和面,而是直接将这些“点”投影到屏幕上,并填充像素。
  • 步骤:
    • 选择雪球
    • 抛掷雪球:从3D到2D,得到足迹
    • 加以合成,形成最后的图像
  • 选择雪球:
    • 点是没有体积的,我们需要给点一个核,核可以是高斯/圆/正方体,使得它有体积,能够被投影。
    • 高斯椭球有很好的数学性质:仿射变换后高斯核仍然闭合,3D降维到2D后依然为高斯椭球。
    • 三维高斯分布,其概率分布的形状(想一下正态分布的y-x图像)由均值和协方差矩阵决定
      为常数,
    • 经过变形可以得到
    • 显然这是一个椭球面的方程,当取不同常数时,椭球面不同,最终形成实心椭球。
    • 高斯分布的仿射变换:对于三维空间中的做仿射变换,有
      三维空间中的标准高斯分布为,因为三维高斯分布的协方差矩阵一定是正定对称矩阵,所以其可以被分解为矩阵的合同变换),所以任意的高斯分布都可以看作是标准高斯分布经过仿射变换得到的。
    • 对于任意可逆的仿射变换Ax+b,它的线性变换矩阵A都可以被分解为缩放矩阵S和旋转矩阵R的乘积,以下是其推导过程:

      仿射变换是否可逆,完全取决于其线性部分矩阵是否可逆,因为 可逆,故 对称正定矩阵,可以正交对角化。其中
      定义。显然 也是对称正定的,是缩放矩阵。因为可逆,定义,所以是正交矩阵,所以是旋转矩阵。

    • 所以协方差矩阵可以被分解
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      def computeCov3D(scale, mod, rot):
      # create scaling matrix
      S = np.array(
      [[scale[0] * mod, 0, 0], [0, scale[1] * mod, 0], [0, 0, scale[2] * mod]]
      )

      # normalize quaternion to get valid rotation
      # we use rotation matrix
      R = rot

      # compute 3d world covariance matrix Sigma
      M = np.dot(R, S)
      cov3D = np.dot(M, M.T)

      return cov3D

抛掷

观测变换:

  • 在计算机图形学中,观测变换(Viewing Transformation)包含了视图变换(View Transformation)和投影变换(Projection Transformation)两个阶段。
  • 模型变换和视图变换:
    • 在计算机图形学中,模型变换(Model Transformation)是指将三维模型从其局部坐标系(Model Space)变换到世界坐标系(World Space)的过程。视图变换(View Transformation)是指将场景中所有物体的坐标从世界坐标系(World Space)转换到相机坐标系(View Space)的过程。
    • 模型变换和视图变换都是仿射变换
    • 世界坐标系:
      • 高斯核中心:
      • 高斯核:
      • 是该高斯分布的协方差矩阵
    • 相机坐标系:
      • 高斯核中心:
      • 高斯核:
      • 均值:
      • 协方差矩阵:
  • 投影变换:
    • 投影变换通常是非线性变换
    • 相机坐标系:
      • 高斯核中心:
      • 高斯核:
      • 均值:
      • 协方差矩阵:
    • 投影变换后的坐标系:
      • 高斯核中心:
      • 高斯核:
      • 均值:
        这里我的理解是:,我们把高斯核中心移动到了中。
      • 协方差矩阵:
    • 由于这一步是非线性、非仿射的,我们不能用来求出协方差矩阵。但是可以用雅可比矩阵来局部线性近似,从而
    • 雅可比矩阵的推导:

视口变换:

  • 均值控制椭球的位置,协方差矩阵控制椭球的形状,在投影变换中,我们对协方差矩阵只做了从四棱锥到长方体的变换,并没有进行缩放和平移(从长方体到原点为中心的正方体的变换),椭球的形状是正常的,因此我们只需要对均值进行视口变换。
  • 像素坐标系:
    • 均值:
    • 协方差矩阵:

着色

球谐函数

  • 球函数(Spherical Function)是定义在单位球面(即半径为1的球面)上的函数。它通常用球坐标表示,自变量是极角(θ)和方位角(φ),记作 。在数学上,球函数通常表示为:
  • 球函数描述的是球面上的某种属性分布,例如地球表面的温度、重力场,或者计算机图形学中物体表面的光照强度。由于球面是二维曲面,所以球函数只需要两个参数(θ, φ)就能确定。
  • 球谐函数(Spherical Harmonics)是定义在球面上的特殊函数,它构成了球面上的正交基,类似于傅里叶级数在圆上的作用。简单来说,它是球面上的“正弦波”和“余弦波”。通常表示为:。其中阶数越高,表达能力越强。任何定义在球面上的函数都可以用球谐函数的线性组合来近似表示(类似于傅里叶级数展开)。
  • 在3D-Gaussian-Splatting中点云颜色(R,G,B)使用球谐函数来表示

    其中是球谐函数,是系数,也是三维向量(R,G,B),作者采用3阶球谐函数进行拟合,因此我们知道需要个参数。
  • 我的理解是:我们事先计算好了48个参数,因此现在的颜色只与相机位置和高斯椭球的位置(即均值)有关,这两个变量决定了球谐函数里的,最终我们实现了对于不同的观测角度都有不同的颜色。

实现

训练总流程

初始化

  • 从SfM点云初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
COLMAP稀疏点云

┌─────────────────────────────────────────────┐
│ 1. 提取3D点位置 │
│ μᵢ = 点云中的点位置 │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 2. 初始化颜色 │
│ cᵢ = 最近视角的平均颜色 │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 3. 初始化不透明度 │
│ αᵢ = 0.1(较小初始值) │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 4. 初始化协方差矩阵 │
│ Σᵢ = 基于最近邻距离估计 │
│ 或使用固定小值 │
└─────────────────────────────────────────────┘
使用KNN法找到3近邻,半径是三近邻的距离的平均
  • 从随机位置初始化
    • 使用场景:没有SfM点云时
    • 在场景边界内随机采样位置
    • 其他参数随机初始化
  • 对每一个点,我们有:
    • 均值(三维向量)
    • 协方差矩阵(三行三列矩阵)
    • 颜色(三维向量RGB)
    • 不透明度(一个数)

迭代优化

  • 单次迭代流程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
迭代 t:
┌─────────────────────────────────────────────┐
│ 1. 前向传播 │
│ - 可微光栅化 │
│ - 生成渲染图像 I_rendered │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 2. 损失计算 │
│ L = L1(I_rendered, I_gt) + │
│ λ_ssim × L_ssim(I_rendered, I_gt) │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 3. 反向传播 │
│ - 计算梯度 ∇L │
│ - 更新参数: │
│ μ ← μ - lr_μ × ∇L/∇μ │
│ c ← c - lr_c × ∇L/∇c │
│ α ← α - lr_α × ∇L/∇α │
│ Σ ← Σ - lr_Σ × ∇L/∇Σ │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 4. 密度控制(每N次迭代) │
│ - 克隆/分割/剪枝 │
└─────────────────────────────────────────────┘
  • 训练循环伪代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 伪代码示例
def train_3dgs(images, cameras, iterations=30000):
# 初始化
gaussians = initialize_from_sfm(cameras)

for iteration in range(iterations):
# 随机选择一个视角
camera_idx = random.randint(0, len(cameras)-1)
camera = cameras[camera_idx]
image_gt = images[camera_idx]

# 前向传播:可微光栅化
image_rendered = rasterize(gaussians, camera)

# 损失计算
loss = L1_loss(image_rendered, image_gt) + \
lambda_ssim * SSIM_loss(image_rendered, image_gt)

# 反向传播
loss.backward()

# 参数更新
optimizer.step()
optimizer.zero_grad()

# 密度控制(每100次迭代)
if iteration % 100 == 0 and iteration < 15000:
densify_and_prune(gaussians)

# 定期保存
if iteration % 1000 == 0:
save_checkpoint(gaussians, iteration)

渲染算法

迭代流程的第一步:前向传播

前向渲染流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
输入:高斯点云 + 相机参数

┌─────────────────────────────────────────────┐
│ 1. 视锥剔除 │
│ - 移除视锥外的点 │
│ - 减少计算量 │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 2. 投影变换 │
│ - 3D高斯 → 2D高斯 │
│ - 计算2D协方差矩阵 │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 3. 深度排序 │
│ - 按深度值排序 │
│ - 准备从前到后渲染 │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│ 4. 光栅化 │
│ - 对每个像素: │
│ * 找到影响该像素的高斯点 │
│ * 计算2D高斯值 │
│ * Alpha混合 │
└─────────────────────────────────────────────┘

输出图像

视锥剔除

  • 对于某个相机,把所有的点进行视图变换,判断点是否在视锥里,如果在视锥里,把它放进列表。

投影变换

  • 使用投影矩阵对视锥之内的点进行变换。计算出2D协方差矩阵。

深度排序

  • 每个高斯有一个视图空间深度 (View Space Depth),其指在图形学渲染管线中,从相机视角出发,一个3D点到相机平面的垂直距离。将高斯按深度重新排列。

光栅化

  • 找到影响该像素的高斯点和计算2D高斯值:
    • 屏幕分块:将整个屏幕或渲染目标划分为固定大小的瓦片(例如 16×16 像素)。
    • 分配高斯点到瓦片:遍历所有高斯点,通过其2D协方差矩阵计算其影响的大致矩形范围,根据计算出的2D边界框,判断它会影响哪些瓦片,并将其索引添加到这些瓦片的关联列表中。
    • 这一步适合使用GPU并行计算。
    • 数据结构:最终得到一个数组或列表 tile_gaussian_lists[tile_id],其中存储了所有可能影响该瓦片的高斯点索引。
    • 当需要查找影响像素 (x, y)的高斯点时:从 tile_gaussian_lists[tile_id]中取出所有可能影响该瓦片的高斯点索引列表。对于列表中的每个高斯点,进行精确的2D高斯权重计算。
  • Alpha混合:
    • 根据球谐函数计算出高斯点的颜色
    • 对光线上的粒子颜色进行求和:
    • 线线\delta$是取样距离
    • 得到每个像素的颜色

机器学习

损失计算

迭代流程第二步

  • L1 损失(L1 Loss),在机器学习中也称为平均绝对误差
  • D-SSIM Loss(Structural Dissimilarity Loss)是一种基于结构相似性的图像质量评估指标
  • 损失函数

反向传播

迭代优化第三步

  • Stochastic Gradient Descent (SGD),中文常译为随机梯度下降,是机器学习中最基础、最核心的优化算法之一。它的核心思想是利用随机抽取的单个样本(或小批量样本)的梯度来近似整个数据集的梯度,从而进行参数更新。

密度控制

  • 克隆:
    • 触发条件:当某个高斯点的梯度(Gradient)​ 很大,且其位置(Position)​ 的梯度远大于其形状(Scale)​ 的梯度时。
    • 逻辑:梯度大意味着这个点对当前视图的贡献很大,但位置梯度大说明它“站错了地方”。此时,系统会复制一个完全相同的点,并让它们分别向不同的方向微调,从而更精确地覆盖该区域。
  • 分裂:
    • 触发条件:当某个高斯点的形状(Scale)​ 变得过大时。
    • 逻辑:一个点变得太大,说明它试图覆盖一个过大的区域,导致细节模糊。此时,系统会将它分裂成两个更小的点,分别继承原点的部分属性,从而提升局部的分辨率。
  • 剪枝:
    • 触发条件:不透明度过低,空间尺度过小
    • 逻辑:视觉贡献可以忽略

操作

环境配置

  • 在AutoDL上租赁一台RTX3090的服务器,选择Miniconda - python 3.10的配置
  • 在VSCode里安装Remote SSH,使用Ctrl + Shift + P 输入Remote SSH: Add,添加新的SSH主机,输入登录命令
  • 再Ctrl+Shift+P连接,输入密码
  • 在终端里配置Git的用户名和密码,生成密钥,添加到Github上
  • 克隆仓库git clone git@github.com:graphdeco-inria/gaussian-splatting.git --recursive,这一步可能因网络问题失败,可以换国内镜像源
  • 输入nvcc --version检测已经安装CUDA SDK 11
  • 输入cd gaussian-splatting然后执行conda env create --file environment.yml
  • 这一步可能因为网络问题而失败。
  • colmap -h检测有没有COLMAP,如果没有,安装COLMAP,sudo apt updatesudo apt install colmap(这一步可能有版本不兼容的问题,暂时没有想到解决方法)
  • 首次使用需要conda init bash,再conda activate gaussian_splatting

训练

  • 用FileZilla连接服务器(sftp)
  • 在gaussian_splatting下创建存放训练数据的文件夹,在文件夹里创建一个项目的文件夹,然后创建input文件夹
  • 把拍的照片用FileZilla放到input里
  • 返回gaussian_splatting,执行python convert.py -s <location>,location是input的父文件夹,这一步完成了COLMAP初始化稀疏点云
  • 遇到qt.qpa.xcb: could not connect to display问题我也不知道该怎么解决了,于是直接社区镜像
  • 执行python train.py -s <location>,这一步完成了训练
  • 在output文件夹下可以找到point_cloud.ply,一个是7000次训练,一个是30000次训练。
  • 使用supersplat - 导入ply文件查看,如果遇到权限不足的情况,在wsl里执行chmod 777 文件名,拖进去就行