流程

简化3DGS

只使用了视图无关性颜色,并强制高斯是各向同性的。这意味着每个高斯仅由8个值参数化:3个为其RGB颜色,3个为其中心位置,1个为其半径,1个为其不透明度

可微渲染

给定一组3D高斯和相机位姿,首先将所有高斯从前到后排序。 然后,通过将每个高斯的二维投影按顺序在像素空间进行alpha组合,可以有效地渲染RGB图像

SLAM系统

  • 初始化

    • 对于第一帧,对于每个像素,我们添加一个新的高斯,其颜色为该像素的颜色,中心在该像素深度的未投影位置,不透明度为0.5,投影到二维图像上的半径等于一像素半径,用深度除以焦距得到
  • 相机追踪

    • 相机参数初始化 $$ Et+1 = Et + (Et - Et-1) $$
    • 基于梯度的优化迭代更新相机位姿,通过差分渲染RGB,深度和剪影图,并更新相机参数,以最小化以下损失,同时保持高斯参数固定
    • 只使用我们渲染的可见性剪影来应用从地图的优化部分渲染的像素的损失,该剪影捕捉了地图的认知不确定性。这对于跟踪新的相机姿态非常重要,因为通常新的帧包含新的信息,而这些信息在我们的地图中尚未被捕获或很好地优化。如果一个像素没有真值深度,L1损失也为0
  • 致密化

    • 在跟踪之后,我们对这一帧的相机位姿有一个准确的估计,并且通过一幅深度图像,我们对场景中的高斯应该在哪里有一个很好的估计。但是,我们不希望在当前高斯已经精确表示场景几何的情况下添加高斯。因此,我们创建了一个致密化掩膜来确定哪些像素应该被致密化
  • 高斯地图更新

    • 这一步是在已知估计的在线相机姿态集合的情况下,更新3D高斯映射的参数。这又是通过可微渲染和基于梯度的优化来完成的,然而与跟踪不同的是,在这种设置中,相机的姿态是固定的,并且高斯的参数是更新的
    • 这相当于将辐射场拟合到已知姿态的图像的”经典”问题。然而,我们做了两个重要的修改。而不是从头开始,我们从最近构建的地图中热启动优化。我们也没有对所有以前的(关键)帧进行优化,而是选择可能影响新添加的高斯的帧。我们将每第n帧保存为一个关键帧,并选择k个关键帧进行优化,包括当前帧、最近关键帧以及与当前帧重叠度最高的前k-2个关键帧。重叠度通过取当前帧深度图的点云,并确定每个关键帧视锥体内部的点数来确定
    • 这个阶段优化了与跟踪过程中类似的损失,除了我们没有使用轮廓掩模,因为我们想在所有像素上优化。此外,我们在RGB渲染中添加了SSIM损失,并删除了无用的高斯,这些高斯具有接近0的不透明度或太大

总结

首先对3DGS的参数进行简化,每个高斯仅8个参数(3个为其RGB颜色,3个为其中心位置,1个为其半径,1个为其不透明度),对第一帧的每个像素添加一个高斯;
接下来就是一个循环的过程:
1.输入是上一时刻建好的高斯模型和相机渲染出的照片,以及此刻相机拍出的图片(彩色图+深度图)
2.相机追踪
先对相机参数进行初始化(通过上一时刻与上上时刻相机位姿的关系和上一时刻的位姿得出这一时刻的相机参数),然后通过计算出来的位姿渲染出照片。
将这个模型和输入的相机拍出来的图片进行遮罩操作,splatam只在已知区域(即黑色区域)进行计算(因为它知道白色区域是新东西,如果强行对比会导致定位跑偏)。(Gemini认为白色是已知区域,黑色是未知区域,因为S(p)=1表示白色,而S(p)=0表示黑色)
注:这里的$Sil$是可见度(专业术语是Silhouette,轮廓/不透明度累积图。这是高斯球的一个参数,如果某个像素位置上有很多高斯球,$Sil \approx 1$;如果某个像素位置是空的,$Sil \approx 0$),$\lambda$是阈值(论文中设置为0.99),因此,$(Sil > \lambda)$就是只有那些地图里非常确信存在的、实实在在的区域,才算数(为1);半透明的、没探索过的区域,统统不算(为0)。故$(Sil > \lambda)$乘以 $F_t$ 和 Render就相当于遮罩操作。
然后调整相机位置 $E’$,让渲染出来的图和真实照片 $F_t$ 在已知区域进行对比,反向优化相机的位姿,最终得到精确的位姿。这里的公式为(这里的深度(几何信息)对于定位比颜色更重要权值更高(颜色容易受光照影响)):$$E_t = \text{argmin} | (Sil > \lambda) * (\text{Render}(G_{t-1}, E’) - F_t) |_1$$

3.高斯致密化
先根据上一步得到的精确位姿对上一时刻渲染出的高斯模型上渲染出一个图片,然后做一个密集化掩码,标记出“需要添加新点”的区域。这里密集化掩码的公式为:$$M(p) = (S(p) < 0.5) + (D_{GT}(p) < D(p))(L_1(D(p)) > \lambda \text{MDE})$$
注:
1)$M(p)$ 是 Densification Mask(致密化掩膜),如果某个像素 $p$ 计算结果为 1 ,系统就会在这里生成新的高斯球。
2)$S(p)$表示不透明度。如果 $S(p) \approx 1$:说明这里有很厚实的墙或物体。如果 $S(p) \approx 0$:说明这里是空气,或者还没建过图。
3)$D_{GT}(p)$:真实深度(Ground Truth)。无人机刚拍到的深度图。$D(p)$:渲染深度(Rendered Depth)。比如真实地图测得前方 1 米处有东西,旧地图认为前方 3 米处才是墙,1米 < 3米,说明真实世界里,有个未知的东西在前方1米处(而且不是墙)。
4)$L_1(D(p))$:深度误差,即 $|D_{GT} - D_{Render}|$。$\text{MDE}$:中位数深度误差 (Median Depth Error),这是当前这一帧画面的平均误差水平。$\lambda$:敏感度系数。论文中设为 50 。因此这里公式的意思是只有当这个点的深度误差,比全图中位数误差还要大 50 倍时,我才承认它是真的“新物体”,而不是传感器抖动,防止因为传感器的一点点误差就疯狂加点。

然后添加高斯点(即红色区域)。论文中公式如下:$$G_t^d = \text{Densify}(G_{t-1}, F_t, E_t, Sil)$$
注:在函数 Densify 内部,首先利用上一公式筛选像素,遍历 $F_t$ 的每一个像素,如果是空的或有新物体则选中该像素;然后利用相机内参 $K$ 和位姿 $E_t$ 计算 3D 坐标 $\mu$(从相机位置 $E_t$ 出发,沿着像素 $(u,v)$ 的方向,射出一道光,飞行 $D_{GT}$(真实深度)这么远,那个落点就是新高斯球的球心);然后初始化高斯球的属性,颜色 ($c$)直接取像素的 RGB 颜色,大小 ($r$)就根据深度估算(离得越远,像素代表的物理范围越大,球半径 $r = \frac{D_{GT}}{f}$(深度除以焦距)),透明度 ($o$)会初始化为一个中间值(例如 0.5),表示“我不确定我是不是实体,先放这里,等下一步优化来决定去留”。
4.地图更新
先根据精确位姿对上一步得到的粗略高斯模型渲染出新图片,
然后利用可微渲染,同时调整新点和旧点的参数(颜色、位置、大小、透明度),让它们渲染出来的图像与真实图像无缝衔接(也就是通过图片的对比反向优化各高斯元参数),这里公式如下:$$G_t = \text{argmin} \sum | \text{Render}(G’, E_k) - F_k |_1$$
注:这里不仅使用当前帧,还会选取 $k$ 个关键帧 (Keyframes) 一起参与优化,包括当前帧 + 最近的一个关键帧 + $k-2$ 个视锥重叠度最高的历史关键帧。
最终得到新的高斯模型,用作下一时刻的输入。


公式 1:3D 高斯球的定义 (The Atom)

$$f(x) = o \cdot \exp\left(-\frac{||x - \mu||^2}{2r^2}\right)$$

含义:这是 SplaTAM 中最基本的单元

简化版高斯:注意,标准 3DGS 使用各向异性(椭球)的高斯函数,但这篇论文为了 SLAM 的速度和稳定性,将其简化为 各向同性(Isotropic),也就是正球体 。

参数

  • (Mu):球心的 3D 位置 。

  • (Radius):球的半径(控制它的大小)。

  • (Opacity):不透明度(0~1),控制它有多“实”。

物理意义:这个公式计算的是空间中任意一点 受到的该高斯球的影响值(密度/贡献度)。离球心$\mu$ 越近,值越大;越远,值按指数衰减。


公式 2:可微渲染 - 颜色 (The Painter)

$$C(p) = \sum_{i=1}^{n} c_i f_i(p) \prod_{j=1}^{i-1} (1 - f_j(p))$$

含义:这是经典的体积渲染公式(Volumetric Rendering),也就是 alpha-blending(透明度混合)。

如何计算一个像素 的颜色?

  1. 从相机发射一条光线穿过该像素。
  2. 光线会穿过一串高斯球(排好序:$1, 2, …, n$)。
  3. $c_i f_i(p)$:第 $i$ 个球的颜色 $c_i$ 乘以它在该位置的密度 $f_i$。
  4. $\prod (1 - f_j(p))$:这是透射率(Transmittance)。意思是你虽然有颜色,但你的可见度要取决于挡在你前面的球有多透明。如果前面的球完全不透明($f=1$),后面这一项就变成 0,你就看不见了。

作用:生成 RGB 图像,用于和真实照片对比算 Loss。


公式 3:3D 到 2D 的投影 (The Lens)

$$\mu^{2D} = K \frac{E_t \mu}{d}, \quad r^{2D} = \frac{f r}{d}$$

含义:把 3D 世界里的球,拍扁到 2D 屏幕上。

$\mu^{2D}$ (屏幕位置):

  • $E_t \mu$:先把世界坐标转为相机坐标。
  • 除以 $d$(深度):近大远小。
  • 乘 $K$(内参):映射到像素坐标系。

$r^{2D}$ (屏幕半径):

  • 这是高斯球在屏幕上的投影半径
  • $f$ 是焦距,$r$ 是物理半径,$d$ 是深度。
  • 物理规律:物体离相机越远( 越大),在屏幕上看起来就越小。

公式 4:可微渲染 - 深度 (The Ruler)

$$D(p) = \sum_{i=1}^{n} d_i f_i(p) \prod_{j=1}^{i-1} (1 - f_j(p))$$

含义:这跟公式 2 的结构一模一样,只不过把累积的对象从“颜色 $c_i$”换成了“深度 $d_i$”。
深度渲染:它计算的是这光线碰到的所有物体的加权平均深度
作用:生成渲染深度图。这是 SplaTAM 的核心优势,它能直接针对深度误差进行优化(Geometric Loss),而不仅仅是颜色误差。


公式 5:轮廓掩膜 / 可见度 (The Scout)

$$S(p) = \sum_{i=1}^{n} f_i(p) \prod_{j=1}^{i-1} (1 - f_j(p))$$

含义:这就是你要的 Silhouette Mask
结构:依然是体积渲染公式,但去掉了颜色项和深度项,只累积不透明度(Opacity/Density)
结果解读

  • $S(p) \approx 1$:光线最终撞上了厚实的东西(墙壁)。$\rightarrow$ 已知区域。
  • $S(p) \approx 0$:光线穿透了所有东西也没遇到阻碍(看向虚空)。$\rightarrow$ 未知区域。

作用:区分“有地图的地方”和“没地图的地方”,用于过滤 Loss。


公式 6:初始化半径 (The Seed)

$$r = \frac{D_{GT}}{f}$$

含义:当我们要在某个位置新生成一个高斯球时,它应该多大?
逻辑:根据公式 3,我们希望新生成的球投影到屏幕上时,半径刚好对应 1 个像素

  • 令公式 3 中的 $r^{2D} = 1$。
  • 推导出 $1 = \frac{f \cdot r}{d}$。
  • 得到 $r = \frac{d}{f}$(这里用 $D_{GT}$ 代表深度)。

作用:防止新加的点太大(导致模糊)或太小(导致空洞/Aliasing)。


公式 8:相机追踪损失函数 (The Judge)

$$L_t = \sum_{p} (S(p) > 0.99) \cdot (L_1(D(p)) + 0.5 L_1(C(p)))$$

含义:这是 Step 1 (Camera Tracking) 用来优化相机位姿的 Loss Function
组成部分拆解

  • $L_1(D(p))$:深度误差。渲染深度 vs 真实深度。这是定位最主要的依据。
  • $L_1(C(p))$:颜色误差。渲染颜色 vs 真实照片。
  • $0.5$:权重。作者经验发现颜色误差不如深度误差可靠(容易受光照影响),所以打个五折。
  • $(S(p) > 0.99)$:核心掩膜。只有在轮廓图显示“这里非常实 ($>0.99$)”的像素,才计算误差。如果 $S(p) < 0.99$,说明这里可能是边界或者未探索区域,渲染结果不可信,直接忽略,不计入 Loss。

这一套公式环环相扣:用 Eq 1 定义球,用 Eq 2/4/5 渲染出图像,用 Eq 8 对比图像算出位置,最后用 Eq 6 初始化新球来修补误差。这构成了 SplaTAM 的数学闭环。


代码

scripts/splatam.py

get_loss()

它有三个步骤:投影(Projection)、光栅化(Rasterization) 和 评分(Loss Calculation)

一、投影

  • 输入是世界坐标系下的几十万个高斯球,输出是相机平面上的 2D 椭圆(因为splatam里面的高斯球是各向同性,在 3D 空间它是正球体,投影到 2D 平面它永远是一个圆,因此这里其实时园)
  • 如果一个球跑到了相机屁股后面(Z < 0),或者跑出了屏幕边缘,直接扔掉,不参与计算

首先利用当前的相机位姿 (curr_data[‘cam’]),把所有高斯球的中心点 means3D 从世界坐标搬到相机坐标系下;
然后通过一个雅可比矩阵(Jacobian),把3D 高斯球( 3x3 的协方差矩阵)投影到 2D 图像平面上,变成一个 2x2 的 2D 协方差矩阵

二、光栅化

首先把所有在屏幕内的高斯球,按照深度(Depth)从远到近排序,然后遍历每个像素,从近到远(或从远到近)累加颜色的贡献,然后进行渲染,渲染出深度图和颜色图

三、评分

和真实数据计算误差

1
2
3
4
5
6
7
8
9
10
# 1. 颜色误差 (L1 Loss)
# 看看画出来的图和照片差多少
loss_im = torch.abs(gt_im - render_im).mean()

# 2. 深度误差 (L1 Depth Loss)
# SplaTAM 的核心:不仅要看着像,几何位置也得对!
loss_depth = torch.abs(gt_depth - render_depth).mean()

# 3. 总分
loss = w_im * loss_im + w_depth * loss_depth
transform_to_frame()

作用:1)梯度控制开关,如gaussians_grad=False, camera_grad=True就是当反向传播的时候,只有相机的位置会更新,这样就可以再追踪阶段计算相机的精确位姿;2)时间切片与位姿提取:transform_to_frame 根据传入的 iter_time_idx(时间戳),从巨大的轨迹数组中提取出当前这一帧对应的旋转(Rotation)和平移(Translation),还会把存储的四元数(Quaternion)转换为渲染器需要的旋转矩阵,其中params 字典里存储的是所有帧的相机轨迹。函数的输出为打包好的 transformed_gaussians,里面包含:当前时刻的相机位姿(带梯度或不带)、当前所有的高斯球(带梯度或不带),这个包会直接喂给 Renderer 去生成图像。

第一帧时打印:


🔍 DEBUG: params (总仓库)

[Key] means3D [Shape] [255190, 3]

[Key] rgb_colors [Shape] [255190, 3]

[Key] unnorm_rotations [Shape] [255190, 4]

[Key] logit_opacities [Shape] [255190, 1]

[Key] log_scales [Shape] [255190, 1]

[Key] cam_unnorm_rots [Shape] [1, 4, 592]

[Key] cam_trans [Shape] [1, 3, 592]

🔍 DEBUG: transformed_gaussians (当前帧数据包)

[Key] means3D [Shape] [255190, 3]

[Key] unnorm_rotations [Shape] [255190, 4]


各参数解释:
255,190:地图里目前总共有 255,190 个高斯球。系统把第 0 帧图片里每一个有有效深度的像素,都变成了一个 3D 高斯球。图片分辨率是 640x480 = 307,200 像素,其中大约有 5万个像素可能是无效深度(太远或太近),剩下的 255,190 个像素被成功转换成了第一批高斯球。
592:TUM 数据集序列(freiburg1_desk)总共有 592 帧。

  • means3D: [255190, 3]
    • 位置,即x、y、z坐标
  • rgb_colors: [255190, 3]
    • 颜色,即R、G、B参数
  • unnorm_rotations: [255190, 4]
    • 姿态,旋转四元数(w, x, y, z)
    • 注:未归一化
  • logit_opacities:[255190, 1]
    • 不透明度,值大 $\to$ 实心墙壁,值小 $\to$ 稀薄的烟雾(或者噪声)
    • $o = \text{Sigmoid}(\text{logit_opacities})$。我们在 params 里存 logit 值(可以是 $-\infty$ 到 $+\infty$),算的时候过一下 Sigmoid 函数就变成了 $0 \sim 1$
  • log_scales: [255190, 1]
    • 半径,论文里为了快,把球简化成了正球体,只有一个半径参数 $r$
    • 注:这里存储的是半径的对数。因为优化器调整参数时可能会把数字减成负数,故存 log(r),取出来用的时候做 exp(log(r)),就能保证半径永远是正数
  • cam_unnorm_rots:[1, 4, 592]
    • 存的是每一帧相机的 旋转(四元数)
  • cam_trans: [1, 3, 592]
    • Batch Size,这里永远是 1
    • 相机的 (x, y, z) 坐标
    • 数据集序列共592帧
    • 存的是每一帧相机在世界坐标系下的 平移 $(x, y, z)$

具体例子:

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
33
34
35
36
37
38
39
40
41
42
43
{'means3D': Parameter containing:
tensor([[-0.9017, -0.7259, 1.4686],
[-0.8988, -0.7259, 1.4686],
[-0.5105, -0.3596, 0.8394],
...,
[ 0.7847, 0.6526, 1.5068],
[ 0.7876, 0.6526, 1.5068],
[ 0.7905, 0.6526, 1.5068]], device='cuda:0', requires_grad=True), 'rgb_colors': Parameter containing:
tensor([[0.2353, 0.1176, 0.1608],
[0.2353, 0.1176, 0.1176],
[0.4824, 0.4118, 0.5137],
...,
[0.0510, 0.0157, 0.0078],
[0.0627, 0.0196, 0.0078],
[0.0549, 0.0118, 0.0118]], device='cuda:0', requires_grad=True), 'unnorm_rotations': Parameter containing:
tensor([[1., 0., 0., 0.],
[1., 0., 0., 0.],
[1., 0., 0., 0.],
...,
[1., 0., 0., 0.],
[1., 0., 0., 0.],
[1., 0., 0., 0.]], device='cuda:0', requires_grad=True), 'logit_opacities': Parameter containing:
tensor([[0.],
[0.],
[0.],
...,
[0.],
[0.],
[0.]], device='cuda:0', requires_grad=True), 'log_scales': Parameter containing:
tensor([[-5.8635],
[-5.8635],
[-6.4229],
...,
[-5.8379],
[-5.8379],
[-5.8379]], device='cuda:0', requires_grad=True), 'cam_unnorm_rots': Parameter containing:
tensor([[[1., 1., 1., 1., 1.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.]]], device='cuda:0', requires_grad=True), 'cam_trans': Parameter containing:
tensor([[[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.]]], device='cuda:0', requires_grad=True)}

疑问:
1)为什么 transformed_gaussians 要存 25万个球?
答:transform_to_frame 只是把数据从仓库里拿出来,准备喂给 GPU。
Python 把 25万个球全部扔给 CUDA 渲染器(Renderer)。渲染器内部 会自动判断哪些球在相机视野里,哪些在背后。
在 Python 层面手动算“哪个球在视野里”太慢了,不如直接全部扔给 GPU 算。所以 transformed_gaussians 里依然保留了所有球。
2)为何它只存了“位置”和“姿态”,把“颜色”和“半径”弄丢了
答:因为这只是个“中转包”,只有 位置 (means3D) 和 旋转 (rotations) 会受到相机移动的影响,所以需要重点处理,而后面会进行组装,从 params (总仓库) 里直接拿颜色、半径、不透明度(因为这些属性不管相机怎么动,它们本身是不变的,不需要变换坐标系)

恒速运动模型(initialize_camera_pose 函数)

追踪部分,根据过去两帧的运动趋势,计算出相机的初始位姿,对应公式:
$$Pose_{t} = Pose_{t-1} + (Pose_{t-1} - Pose_{t-2})$$

平移

1
2
3
4
prev_tran1 = params['cam_trans'][..., curr_time_idx-1].detach() # 位置 P_{t-1}
prev_tran2 = params['cam_trans'][..., curr_time_idx-2].detach() # 位置 P_{t-2}
# 预测当前位置 P_t
new_tran = prev_tran1 + (prev_tran1 - prev_tran2)

解释:params[‘cam_trans’] 的形状是 [1, 3, 592],因此[…, curr_time_idx-1]的意思是保持前面所有维度(Batch 和 xyz)不变,只取最后一个维度(时间轴)上的第 curr_time_idx-1 帧的数据。.detach()的意思是切断梯度流,因为不需要做反向传播。

旋转

1
2
3
4
5
# 取出并归一化四元数
prev_rot1 = F.normalize(params['cam_unnorm_rots'][..., curr_time_idx-1].detach()) # Q_{t-1}
prev_rot2 = F.normalize(params['cam_unnorm_rots'][..., curr_time_idx-2].detach()) # Q_{t-2}
# 线性外推并重新归一化
new_rot = F.normalize(prev_rot1 + (prev_rot1 - prev_rot2))

slam_external.py/prune_gaussians()

作用

在建图(Mapping)优化的过程中,定期把那些没用的或者错误的高斯球清理掉,以保持地图的干净和显存的高效。其主要是定时剪枝、按条件筛选、重置透明度。

定时剪枝

1
2
if iter <= prune_dict['stop_after']:
if (iter >= prune_dict['start_after']) and (iter % prune_dict['prune_every'] == 0):
  • 它只在迭代次数处于 start_after 和 stop_after 之间进行。

  • 每隔 prune_every (20) 次迭代才执行一次,避免频繁操作拖慢速度。

按条件筛选

条件一:透明度太低

1
2
3
4
5
6
7
if iter == prune_dict['stop_after']:
remove_threshold = prune_dict['final_removal_opacity_threshold']
else:
remove_threshold = prune_dict['removal_opacity_threshold']

# 计算真实透明度并判断
to_remove = (torch.sigmoid(params['logit_opacities']) < remove_threshold).squeeze()

解释:logit_opacities 是存储的参数,通过 sigmoid 变成 0~1 的真实透明度 , remove_threshold 是设置的阈值 0.005

条件二:太大了(遮挡视线)

1
2
3
4
if iter >= prune_dict['remove_big_after']:
# 计算真实半径,判断是否超过场景半径的 10%
big_points_ws = torch.exp(params['log_scales']).max(dim=1).values > 0.1 * variables['scene_radius']
to_remove = torch.logical_or(to_remove, big_points_ws)

解释:exp(log_scales) 还原出真实半径,如果一个球膨胀得巨大(超过整个场景半径的 10%),通常是因为优化失败产生的伪影(Artifact),就像贴在镜头前的一个大光斑。这种球会严重干扰后续建图

最后执行删除操作

1
params, variables = remove_points(to_remove, params, variables, optimizer)

重置透明度

1
2
3
if iter > 0 and iter % prune_dict['reset_opacities_every'] == 0 and prune_dict['reset_opacities']:
new_params = {'logit_opacities': inverse_sigmoid(torch.ones_like(params['logit_opacities']) * 0.01)}
params = update_params_and_optimizer(new_params, params, optimizer)

解释:每隔一段时间,强制把所有高斯球的透明度都设为极低的值(0.01),其目的是

  • 如果一个球是真的墙壁或物体,在接下来的几次迭代中,为了减小 Loss,梯度会迅速把它重新“拉黑”(变不透明)。

  • 如果一个球是早期的噪声或误判,重置后它就再也“站不起来”了(一直保持透明),然后在下一次剪枝中被彻底删掉。

  • 这能有效防止陷入局部最优,去掉那些“僵尸”高斯球。

slam_external.py/keyframe_selection_overlap

用处

在地图更新时使用挑选的关键帧的图像与渲染出的图像对比,进行反向优化,这个函数正是为了挑选关键帧

首先从当前帧里随机挑了一些“探针”点

1
2
3
4
5
6
7
# 1. 找到所有有有效深度(>0)的像素坐标
valid_depth_indices = torch.where(gt_depth[0] > 0)
valid_depth_indices = torch.stack(valid_depth_indices, dim=1)

# 2. 随机抽取 pixels (默认1600) 个点作为代表
indices = torch.randint(valid_depth_indices.shape[0], (pixels,))
sampled_indices = valid_depth_indices[indices]

把这些选出来的 2D 像素点,变成 3D 世界坐标点

1
2
# 把 2D 像素 + 深度 -> 3D 世界坐标点 (pts)
pts = get_pointcloud(gt_depth, intrinsics, w2c, sampled_indices)

拿着这些 3D 世界点,去试探每一个历史关键帧,看看历史帧的相机能看到多少点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
for keyframeid, keyframe in enumerate(keyframe_list):
# 1. 取出历史帧的位姿 (World-to-Camera)
est_w2c = keyframe['est_w2c']

# 2. 把世界点转到历史帧的相机坐标系下
pts4 = torch.cat([pts, torch.ones_like(pts[:, :1])], dim=1) # 变成齐次坐标
transformed_pts = (est_w2c @ pts4.T).T[:, :3]

# 3. 再投影到历史帧的 2D 图像平面
points_2d = torch.matmul(intrinsics, transformed_pts.transpose(0, 1))
# ... (透视除法 /z) ...

# 4. 检查点是否在图像范围内 (在屏幕内且在相机前方)
edge = 20 # 边缘留一点余量
mask = (projected_pts[:, 0] < width-edge) * ... # x, y 均在图像范围内
mask = mask & (points_z[:, 0] > 0) # 深度必须大于0(在相机前面,不能在脑后)

# 5. 计算重叠率:有多少点落在了这帧历史图像里?
percent_inside = mask.sum()/projected_pts.shape[0]

注:这里的历史关键帧是每隔5帧选取一个,另外历史关键帧小于5个时不会触发这里关键帧的选择
在script/splatam.py中的Mapping阶段:

1
2
3
4
5
6
# Add frame to keyframe list
# 这里的 time_idx 是 4
# config['keyframe_every'] 是 5
if ((time_idx == 0) or ((time_idx+1) % config['keyframe_every'] == 0) ...):
# (4 + 1) % 5 == 0,条件成立!
keyframe_list.append(curr_keyframe) <-- Frame 4 在这里被永久存入了历史库

筛选与随机选择

1
2
3
4
5
6
7
8
# 1. 先按重叠度从高到低排序 (虽然这一步在后面被随机打乱削弱了,但在逻辑上是先找最好的)
list_keyframe = sorted(list_keyframe, key=lambda i: i['percent_inside'], reverse=True)

# 2. 剔除那些完全没重叠的 (percent > 0)
selected_keyframe_list = [ ... if keyframe_dict['percent_inside'] > 0.0]

# 3. 【关键】随机打乱并取前 k 个
selected_keyframe_list = list(np.random.permutation(np.array(selected_keyframe_list))[:k])

注:这里为什么要随机打乱再取?按理说应该选 重叠度最高的 k 帧,但如果只选最高的,往往选出来的都是最近的那几帧(因为刚才走过,重叠度肯定最高),而随机打乱并从所有有重叠的帧里选,可以让系统有机会选中很久以前的帧(只要它和当前有重叠)。这对于回环检测(Loop Closure)非常重要——即使是 5 分钟前路过的地方,也有机会被选进来一起优化,从而消除累积误差。

splatam.py/add_new_gaussians()

在当前画面中,寻找哪些地方是“空的”或者“画错了的”

  1. 不透明度检查 (Silhouette Check)
  2. 深度检查 (Depth Check)

反投影

确定了位置后,把这些 2D 像素变回 3D 点

1
new_pt_cld, mean3_sq_dist = get_pointcloud(..., mask=non_presence_mask, ...)

一、 输入

  • curr_data[‘im’]:当前帧颜色(给新球上色)。

  • curr_data[‘depth’]:当前帧深度(决定新球在 3D 空间的 Z 轴位置)。

  • curr_w2c:当前相机位姿(把点从相机坐标系转回世界坐标系)。

二、 动作

利用相机内参和深度值,把 Mask 为 True 的像素反向投射回 3D 空间

三、 输出

new_pt_cld: 一批全新的 3D 坐标点 $(x, y, z)$

分配高斯球的属性,然后合并到总地图中

1
2
3
4
5
6
7
8
9
10
11
# 1. 初始化参数 (给新点分配颜色、大小、旋转)
new_params = initialize_new_params(new_pt_cld, ...)

# 2. 合并到总参数 (Cat)
for k, v in new_params.items():
# 把新参数拼接到老参数后面
params[k] = torch.nn.Parameter(torch.cat((params[k], v), dim=0).requires_grad_(True))

# 3. 初始化优化器状态 (给新兵发装备)
variables['means2D_gradient_accum'] = torch.zeros(...) # 梯度累计清零
# ...

评估指标

Final Average ATE RMSE

全称:Absolute Trajectory Error (绝对轨迹误差) - Root Mean Square Error (均方根误差)
含义:衡量“电脑算出来的相机位置”和“真实相机位置”平均差了多远

以下三个为一组(评价建出来的图,渲染成照片后,跟真实照片像不像的参数)

Average PSNR

全称:Peak Signal-to-Noise Ratio (峰值信噪比)
含义:像素级别的相似度。数值越大越好

Average MS-SSIM

全称:Multi-Scale Structural Similarity Index (多尺度结构相似性)
含义:它比 PSNR 更聪明,它看重“结构”和“纹理”是否清晰。范围是 0~1,越接近 1 越好

Average LPIPS

全称:Learned Perceptual Image Patch Similarity (学习感知图像块相似度)
含义:这是一个 AI 打分器(模仿人类视觉),数值越低越好(0 表示一模一样)

Average Depth RMSE

含义:衡量建出来的 3D 模型表面,距离相机的深浅对不对。越小越好