街景 3DGS 补课 · 第一课 · 2026-09-03

从一个椭球到一张图

这一课把原版 3D Gaussian Splatting 拆开:场景怎么被表示成几十万个模糊椭球,椭球怎么变成屏幕上的像素,训练怎么反向修改它们,以及那套决定高斯生生死死的密度控制。每一条都对到你手上 DriveStudio 的源码位置和默认值。

底座代码:ziyc/drivestudio(OmniRe 官方实现)
原始论文:Kerbl et al., 3D Gaussian Splatting for Real-Time Radiance Field Rendering, SIGGRAPH 2023
本页所有配置默认值均取自 configs/streetgs.yaml 与 models/gaussians/vanilla.py(2026-09-03 逐条核对)

0.0开场

这一课要装进脑子的东西

你已经跑过八个场景的基线、做过通宵实验、读过 Fisher 信息那一套。但中间有一层被跳过了:3DGS 本身。这一课就是补这一层。补完之后再谈创新点,你会知道每个可以拧的旋钮在哪、拧了会动到什么。

先给定位。下面这张表决定你该在哪些地方花力气,别默认全都要背:

本课知识点的投入档位
内容要到什么程度为什么
协方差的拼装 \(\Sigma=RSS^\top R^\top\)能默写面试常问,也是形状正则的作用对象
alpha 合成公式能默写整个渲染的核心,深度图 / 不透明度图全从它派生
密度控制四件事能默写你八月一半的现象都是它造成的
球谐怎么表达视角相关色能说思路知道阶数意味着什么就够,系数表不用背
投影雅可比 \(J\)能说思路知道为什么需要它、它带来什么误差
tile 分块与排序的工程细节知道有这回事gsplat 已经封装好,你不会去改 CUDA
各个配置项的具体数值知道去哪查本页给了完整表,用的时候翻

再给一张全景。下面这张图是 3DGS 训练一步的全部内容,后面七个阶段就是把它逐块展开:

高斯参数 N × 59 个数 投影 3D 椭球 → 2D 椭圆 分块与排序 按 tile、按深度 alpha 合成 从前往后累加 渲染图 RGB · 深度 · alpha 损失 对比真值 反向传播:梯度一路回到每个高斯的 59 个参数 每 100 步:自适应密度控制 复制 · 分裂 · 剪除 · 重置不透明度 —— 直接改变 N
图 0一步训练的全景。上排是前向,橙色虚线是反向传播,下方那个盒子是 3DGS 区别于普通神经网络的地方:参数的数量本身也在被训练过程改变。
一句话总纲

3DGS 不是神经网络。它没有隐藏层、没有权重矩阵,只有一堆直接被梯度下降优化的几何图元。所有你熟悉的 PyTorch 直觉都还成立(Adam、学习率、损失),但多了一件 LLM 里没有的事:参数的个数会在训练中变。

1.0表示

一个高斯是什么

场景被表示成几十万到几百万个带颜色的模糊椭球。每一个椭球叫一个高斯,它是一个三维空间里的各向异性高斯分布,加上不透明度和颜色。你训练的全部东西就是这些椭球的参数。

一个高斯存哪些数(DriveStudio 的字段名在最后一列)
属性实际存的量个数激活函数代码字段
位置 \(\mu\)xyz 三个坐标3无_means
尺度 \(s\)三个轴半径的对数3\(\exp\)_scales
旋转 \(q\)四元数4归一化_quats
不透明度 \(o\)logit 值1\(\mathrm{sigmoid}\)_opacities
基础色0 阶球谐系数 × RGB3见 §2_features_dc
视角相关色1–3 阶球谐系数 × RGB45见 §2_features_rest
合计59

这个 59 就是你在 v3 方案里写的每个高斯参数维度 \(d=59\) 的来历。

1.1 为什么每样东西都套一层激活

看第四列。位置是唯一一个裸奔的量,其他三个都套了激活函数。原因统一:梯度下降不知道什么叫合法。

1.2 协方差:拼出来,不是学出来

一个三维高斯的形状由协方差矩阵 \(\Sigma\)(3×3 对称)决定。3DGS 不直接学 \(\Sigma\) 的六个自由数,而是从尺度和旋转拼出来:

$$\Sigma \;=\; R\,S\,S^\top R^\top,\qquad S=\mathrm{diag}(s_1,s_2,s_3),\quad R=\mathrm{quat\_to\_rotmat}(q)$$

读法很直白:\(S\) 先把一个单位球在三个坐标轴方向拉伸成一个轴对齐的椭球,\(R\) 再把它转到空间中该在的朝向。\(SS^\top=\mathrm{diag}(s_1^2,s_2^2,s_3^2)\),所以 \(\Sigma\) 的三个特征值恰好是 \(s_i^2\),特征向量就是 \(R\) 的三个列。椭球的主轴长度和朝向直接写在参数里,一眼可读——这是后面所有形状分析(各向异性、薄轴方向、有效秩)能做的前提。

拖一拖下面这个,把这个式子变成肌肉记忆。为了看得见,这里降到二维:

仪器 1 · 协方差沙盒

拖动两个轴长和转角,看 \(\Sigma\) 怎么被拼出来。注意:无论你怎么拖,右边的椭圆永远是一个合法椭圆。

1.60 0.45 30°

那为什么不干脆直接学 \(\Sigma\) 的六个数?

因为协方差矩阵必须半正定——它的特征值就是各方向的方差,不能为负。直接把六个数交给 Adam,几步之后它就会走到一个特征值为负的矩阵,那玩意儿在几何上不对应任何椭球,渲染直接崩。

下面这个是二维版的反例,你拖一下 \(\Sigma_{12}\) 就能看到它失效的瞬间:

1.40 0.50 0.80

用 \((s,q)\) 参数化就没有这个问题:参数空间里任何一点都对应一个合法椭球,优化器怎么走都走不出去。这类做法在深度学习里很常见,本质和你熟悉的“学 \(\log\sigma\) 而不是学 \(\sigma\)”是同一件事。

接到你的课题

因为形状是 \((s,q)\) 显式参数化的,“这个高斯被拉成了什么样、朝哪儿”可以直接量化:把 \(\Sigma\) 特征分解,最小特征值对应的特征向量就是薄轴,最大的就是长轴。

值得说清楚的是,你 2026-09-02 那晚实测的结果推翻了原来的猜法。原来沿用 VEGS 的说法是“薄轴倒向相机”,但量出来薄轴与视线的 \(|\cos|\) 只有 0.29,真正倒向视线的是中轴和长轴(0.6 到 0.78)。机制也不是“纵向被拉长”,而是横向被数据压薄、纵向没人管,于是长短比越训越大。静止场景反而没有这个趋势,因为横向本来也没多少可收的。DriveStudio 里管这件事的是 sharp_shape_reg,但它要长短比超过 10 才开始罚,§5 会讲到。

2.0颜色

球谐:让同一个椭球换个角度换个颜色

如果每个高斯只有一个固定颜色,车漆的高光、路面的反光、玻璃的反射全都表达不了——这些东西的颜色取决于你从哪儿看。所以颜色不存成一个 RGB 三元组,而存成一个方向的函数:给定观察方向 \(d\),返回一个颜色。

球谐(spherical harmonics,SH)就是球面上的傅里叶基。任何定义在球面上的函数都能展开成一组基函数的加权和,阶数越高,能表达的方向变化越精细:

$$c(d)\;=\;\sum_{\ell=0}^{L}\sum_{m=-\ell}^{\ell} k_{\ell m}\,Y_{\ell m}(d)\;+\;0.5$$

\(Y_{\ell m}\) 是固定的基函数,\(k_{\ell m}\) 才是学出来的系数(每个颜色通道各一套)。最后那个 \(+0.5\) 是 3DGS 的约定:让全零系数对应中灰而不是纯黑。

阶数、基函数个数与它能表达什么
阶 L基函数个数 (L+1)²系数个数 ×RGB能表达
013一个常数色,完全不随视角变
1412沿某个方向的线性明暗渐变
2927一个宽的凸起或凹陷
31648一个还算集中的高光瓣(3DGS 的默认上限)

下面这台仪器把“阶数意味着什么”变成可以看的东西。目标(虚线)是一个真实的高光瓣;实线是用有限阶球谐去逼近它的结果。拖阶数:

仪器 2 · 球谐能表达多细的视角变化

横轴是观察方向绕高斯转一圈的角度。系数由目标函数在球面上做投影得到(4000 个 Fibonacci 采样点,用的正是 3DGS 源码里的那组归一化常数)。

L = 0
目标外观:

两个目标各说明一件事。平滑高光:L=0 是一条水平线,高光整个消失;即使放到 3 阶,峰值也只追到 0.75,真值是 1.0——球谐是低频基,越锐利的高光越追不上。硬边:明暗交界处出现明显的过冲与下冲(1 阶时下探到 −0.13、上冲到 1.13),这就是傅里叶式逼近的振铃。渲染时两头都被 clamp 到 [0,1],于是交界两侧各留下一条死黑和一条过曝的边——3DGS 已知的伪影来源之一。

2.1 训练时阶数是慢慢放开的

如果一上来就允许 3 阶,模型会用高频系数去拟合噪声和曝光差异,把本该是几何问题的误差塞进颜色里。所以 3DGS 让阶数随训练步数递增,DriveStudio 里就一行:

# models/gaussians/vanilla.py
n = min(self.step // self.ctrl_cfg.sh_degree_interval, self.sh_degree)
rgbs = spherical_harmonics(n, viewdirs, colors)
rgbs = torch.clamp(rgbs + 0.5, 0.0, 1.0)

sh_degree_interval 默认 1000,sh_degree 默认 3。所以 0–1k 步只有基础色,1k 升到 1 阶,2k 升到 2 阶,3k 步之后才全部放开。这就是你在训练日志里看到 PSNR 在前三千步有几次台阶式跳升的原因之一。

一个容易记混的方向

viewdirs = means − camera_center,是从相机指向高斯,不是反过来。做视角相关分析时符号搞反,结论会整个镜像。

3.0投影

投影:3D 椭球怎么变成屏幕上的椭圆

渲染的第一步是把每个高斯投到图像平面上。中心点的投影是老生常谈:

$$\mu' \;=\; \pi\!\left(K\,W\,\mu\right)$$

\(W\) 是世界到相机的变换,\(K\) 是内参,\(\pi\) 是除以深度的齐次归一化。麻烦的是形状怎么投。

协方差描述的是中心点周围的一小片分布。透视投影不是线性变换(有个除以 \(z\)),所以椭球投出来严格说不是椭圆。3DGS 的做法是在中心点处做一阶泰勒展开,用雅可比矩阵 \(J\) 把投影局部近似成线性映射:

$$\Sigma' \;=\; J\,W\,\Sigma\,W^\top J^\top,\qquad J=\left.\frac{\partial \pi}{\partial \mathbf{x}}\right|_{\mathbf{x}=W\mu}$$

然后取 \(\Sigma'\) 左上角的 2×2 子块,就得到屏幕上那个二维高斯。这就是所谓的 EWA splatting,做法本身来自 2001 年的体渲染文献,不是 3DGS 发明的。

相机 像平面 轴上 离轴 投影到图像上的样子 正圆 被拉长、被转向
图 1同样大小的两个球,位置不同投出来的形状就不同:越靠画面边缘,透视拉伸越强。这个拉伸完全由 \(J\) 编码,也正是 \(J\) 随位置变化的意思。
\(J\) 长什么样,以及它的近似误差在哪

设相机系下的点是 \((x,y,z)\),针孔投影是 \(u=f_x x/z + c_x\),\(v=f_y y/z + c_y\)。对它求导:

$$J=\begin{bmatrix} f_x/z & 0 & -f_x x/z^2\\[2pt] 0 & f_y/z & -f_y y/z^2 \end{bmatrix}$$

三件事值得注意。第一,\(J\) 里处处是 \(1/z\):越远的高斯投出来越小,这是透视的全部内容。第二,第三列非零,说明深度方向的扩展会渗进屏幕上的形状,这就是离轴拉伸的来源。第三,\(J\) 在中心点算一次就固定了,所以越大的高斯,这个线性近似越不准——高斯横跨的区域越大,\(J\) 在它内部的变化就越不能忽略。这是 3DGS 要设 cull_scale_thresh 剪掉巨大高斯的理由之一,也是为什么远景大高斯容易出现形状错误。

与你的 Fisher 假说的关系

注意 \(J\) 的第三列:深度方向 \((z)\) 的贡献带着 \(x/z^2\)、\(y/z^2\) 这样的系数。当高斯在光轴附近(\(x,y\to 0\))时,这一列趋近于零——也就是说,沿视线移动这个高斯,屏幕上几乎没有变化。这正是你实测到的“光度 Fisher 沿视线退化”的解析来源,前向行驶的相机把大部分背景高斯都放在了这个位置上。

4.0光栅化

光栅化:alpha 合成才是渲染的核心

投影完之后,每个高斯在屏幕上是一个椭圆。现在要决定每个像素最终是什么颜色。分四步:

  1. 屏幕切成 16×16 的小块(tile),每个高斯登记到它覆盖的所有 tile 上;
  2. 每个 tile 内部,把登记进来的高斯按深度排序;
  3. 每个像素沿这个顺序从前往后累加颜色;
  4. 累计透过率掉到 1e−4 以下就提前退出,这个像素后面的高斯一律不看。

第三步的公式是整个 3DGS 里最该背下来的东西:

$$C \;=\; \sum_{i=1}^{N} c_i\,\alpha_i \prod_{j<i}\bigl(1-\alpha_j\bigr),\qquad \alpha_i \;=\; o_i\cdot\exp\!\left(-\tfrac12 (\mathbf{x}-\mu'_i)^\top {\Sigma'_i}^{-1}(\mathbf{x}-\mu'_i)\right)$$

拆开看。\(\alpha_i\) 是这个高斯在这个像素上的有效不透明度:等于它自身的不透明度 \(o_i\),乘上那个二维高斯在该像素处的取值(中心处是 1,往外指数衰减)。前面那个连乘 \(T_i=\prod_{j<i}(1-\alpha_j)\) 叫透过率,意思是“光走到第 \(i\) 个高斯时还剩多少没被挡住”。

实现里还有两个小闸门,都在 gsplat 的光栅化核里:\(\alpha_i\) 被截在 0.999(不允许任何单个高斯把光线完全挡死),而 \(\alpha_i < 1/255\) 的高斯直接跳过不累加(贡献小于一个色阶,算了也是浪费)。

所以每个高斯对这个像素的贡献权重是 \(w_i = T_i\alpha_i\)。下面这台仪器让你亲手感受“前面的高斯怎么吃掉后面的份额”:

仪器 3 · alpha 合成沙盒

一条光线穿过四个高斯(假设正好穿过中心,所以 \(\alpha_i=o_i\))。拖动不透明度,看权重怎么分配。

0.35 0.60 0.80 0.90

试试把高斯 1 的不透明度拉到 1:后面三个的权重立刻全归零,它们对这个像素再也没有梯度。这就是为什么被完全遮挡的高斯会一直保留着初始化时的错误形状——数据对它彻底沉默了。

4.1 一套权重,四种产物

关键洞察:那组权重 \(w_i\) 算一次,换掉被加权的东西就能渲染出别的图。

渲染什么把 \(c_i\) 换成用途
RGB 图球谐算出的颜色主损失
深度图该高斯到相机的距离 \(d_i\)LiDAR 深度监督
不透明度图 \(O\)常数 1天空掩码损失、天空合成
任意逐高斯标量那个标量可视化、诊断、你自己做的分析

不透明度图 \(O=\sum_i w_i\) 有个直白的意思:这个像素被高斯盖住了多少。天空就靠它合成——高斯没盖住的地方,漏出天空贴图:

$$C_{\text{final}} \;=\; C_{\mathcal G} \;+\; (1-O)\,C_{\text{sky}}$$

DriveStudio 里天空是一个 1024 分辨率的可优化环境贴图(models.modules.EnvLight),学习率 0.01。它和高斯是两条独立的路径,最后才用 \(O\) 混合。

4.2 排序是近似的,这会咬人

严格的体渲染要求每个像素都按自己视线上的真实前后顺序积分。3DGS 为了速度,改成每个 tile 排一次序,同一个 tile 里 256 个像素共用同一个顺序。当两个高斯互相穿插时,这个顺序对某些像素是错的。

后果是 popping:相机连续移动时,某个高斯的排序位次会在某一帧突然跳变,画面上出现一块颜色的瞬间闪动。这在静态评测里几乎看不出来,在渲染视频时很明显。知道有这回事就够了,gsplat 已经把这一层封装掉,你不会去改。

DriveStudio 用的是哪个光栅化器

models/trainers/base.py 的 render_gaussians() 直接调 gsplat 的 rasterization(),一次返回 4 个通道:RGB 三通道加深度一通道,外加一张 alpha 图。DriveStudio 钉的是 gsplat v1.3.0,那一版 tile_size 默认就是 16。配置里 rasterize_mode 默认 "classic"(antialiased: false),absgrad: true——最后这一项很重要,§6 会说它改变了密度控制的判据。

5.0训练

训练:损失、学习率、分辨率课程

前向讲完,现在是你最熟的部分。每一步:随机取一张训练图,渲染,算损失,反传,Adam 更新。原版 3DGS 的损失只有两项:

$$\mathcal L \;=\; (1-\lambda)\,\mathcal L_1 \;+\; \lambda\,\bigl(1-\mathrm{SSIM}\bigr),\qquad \lambda=0.2$$

L1 管逐像素的绝对误差,SSIM 管局部结构和纹理。DriveStudio 在这之上又加了几项,全在 configs/streetgs.yaml 的 losses 段:

DriveStudio(streetgs.yaml)的完整损失表
项权重算什么
rgb0.8L1 逐像素
ssim0.21 − SSIM
mask0.05不透明度图与非天空掩码的二元交叉熵
depth0.01渲染深度与 LiDAR 深度的 L1,只在有 LiDAR 回波的像素上算
affine1e-5把逐相机的曝光仿射变换拉回单位阵
sharp_shape_reg1.0见下

最后那个形状正则值得单独看一眼,它是 models/gaussians/vanilla.py 里的:

scale_reg = max(s_max / s_min, max_gauss_ratio) - max_gauss_ratio

max_gauss_ratio 默认 10,每 10 步算一次。读法:长短轴比小于 10 的高斯完全不受惩罚,只有超过 10 倍的才开始被拉回来。这个门槛非常松——一个比值 9.9 的针状高斯是完全合法的。你观察到的“沿视线越训越长”正是在这个免罚区里发生的。

5.1 学习率:只有位置在衰减

这是本课最值得你记住的一张图。六个参数组里,只有 xyz 配了 lr_final,其余五个学习率从第一步到第三万步一个数都不变:

1e−1 1e−2 1e−3 1e−4 1e−5 1e−6 0 10k 20k 30k 训练步数 opacity 0.05 scaling 5e−3 sh_dc 2.5e−3 rotation 1e−3 sh_rest 1.25e−4 xyz
图 2DriveStudio 六个参数组的学习率随训练步数变化(纵轴对数)。橙色那条是位置,30k 步里降了整整 100 倍;灰色五条是形状、旋转、不透明度和颜色,一动不动。调度器就是 models/trainers/base.py 的 lr_scheduler_fn,在对数空间线性插值。
这张图为什么重要

你 v3 里的 H2 假说说的是:Adam 按坐标归一化步长,在数据约束不住的方向上,梯度虽小但有噪,归一化之后步长仍是学习率量级,参数就被噪声推着走。位置有衰减兜底,后期步长小到走不动;形状、旋转没有——它们的步长从头到尾是 5e−3 和 1e−3。

2026-09-02 那晚的实测把这条假说劈成了两半:位置版本不成立(15k 到 30k 的位移主要落在数据钉得住的方向上,Fisher 零空间只占一到两成),形状版本还活着。这跟上面这张表读出来的结论方向一致——有衰减的那个确实被压住了,没衰减的那个没有。

5.2 还有一个你可能没注意的课程:分辨率

streetgs.yaml 里有这么一段:

res_schedule:
  double_steps: 250      # 每 250 步分辨率翻一倍
  downscale_times: 2     # 开局用 1/2² = 1/4 分辨率

训练不是从全分辨率开始的。开局图像被降到四分之一,每 250 步翻倍,到第 500 步才用上全分辨率。这是标准的由粗到细:低分辨率下高频细节看不见,梯度只推动大尺度结构,几何先站稳再谈纹理。你八月做过一个消融是把 double_steps 从 250 拉到 2500,结果全程只省了 4.9% ——现在你知道为什么收益这么小了,因为这段课程本来就只覆盖前 500 步。

5.3 Adam 的一个细节

DriveStudio 建优化器时用了 eps=1e-15,比 PyTorch 默认的 1e-8 小七个数量级。原因是 3DGS 的梯度尺度极小,默认 eps 会在分母里占主导,把自适应步长直接变成固定步长。这个数是从原版 3DGS 继承来的,别随手改。

6.0灵魂

自适应密度控制:3DGS 真正的灵魂

初始点云永远不对:该密的地方不够密,该空的地方一堆浮点。所以 3DGS 一边训练一边增删高斯。这一节是本课信息密度最高的地方,也是你八月一半现象的根源。

6.1 先攒证据

每一步渲染完,after_train() 给每个高斯累加三个量:

累加器累加什么意思
xys_grad_norm该高斯 2D 屏幕坐标梯度的模长“图像还不满意,这个高斯想往旁边挪”
vis_counts它可见的次数(半径 > 0)用来做平均的分母
max_2Dsize历史最大屏幕半径 ÷ 图宽它在画面里占多大

为什么用屏幕坐标的梯度而不是三维位置的梯度?因为屏幕梯度天然是“视觉误差”的度量,且与深度无关:远处一个高斯即使三维位置差得多,屏幕上也差不了几个像素。用它做判据等于说“只有看起来不对的地方才需要更多高斯”。

另外注意配置里的 absgrad: true。默认情况下同一个高斯在一张图里被多个像素往不同方向拉扯,梯度会正负相消;开了之后累加的是逐像素梯度的绝对值,不会互相抵消。这是 AbsGS 的改进,而且 DriveStudio 确实在用它——base.py 里读的是 means2d.absgrad 而不是 .grad,所以它直接决定了哪些高斯会被判定为“需要致密化”。

6.2 再动刀

每 refine_interval(默认 100)步执行一次决策。判据看下面这张图:

平均梯度 grad_norm / vis_counts > 5e−4 ? densify_grad_thresh 尺度 > 0.003 × 场景半径 ? 或屏幕占比 > 5% 分裂 split · 1 → 3 尺度各除以 1.6,两个新的采样落位 复制 dup · 1 → 2 原地复制一份,参数完全相同 不动它 图像对它没有意见 是 否 大 小
图 3致密化的判据。逻辑一句话:视觉误差大的地方要么该拆开、要么该加密;判断拆还是加,看它现在是不是已经太大了。

两条分支的语义完全不同:

剪除是另一条路径,cull_gaussians() 三条判据:

剪掉谁阈值什么时候生效
几乎透明的不透明度 < 0.005一直
世界尺度太大的尺度 > 0.5 × 场景半径3000 步之后
屏幕上太大的屏幕占比 > 15%3000–4000 步之间

6.3 不透明度重置的真面目

每 3000 步(reset_alpha_interval)会做一次不透明度重置。常见的说法是“把所有不透明度设成 0.01”,源码里其实是取小:

reset_value = min(current_opacity, 0.01)
self._opacities.data = logit(reset_value)
# 同时把 Adam 里 opacity 那一组的 exp_avg / exp_avg_sq 清零

所以它是一个上限:本来就低于 0.01 的不动,高于的一律压到 0.01。目的是让所有高斯重新证明自己——真正有用的会被梯度快速推回高不透明度,浮空的杂点推不回来,下一轮就被 cull_alpha_thresh 剪掉。连 Adam 的一阶二阶动量都被清零了,防止旧动量把刚压下去的值直接顶回来。

这就是训练 PSNR 曲线每 3000 步掉一个坑又爬回来的原因。你八月做自适应早停时,停止判据必须绕开这些坑,否则会在坑底误判为“不再改善”。

6.4 一个很容易漏掉的封锁窗口

refinement_after() 开头有这么一个门:

do_densification = (
    step < stop_split_at
    and step % reset_alpha_interval > max(num_train_images, refine_interval)
)

翻译过来:每次不透明度重置之后,要等到所有训练图都被看过一遍,才允许再动刀。三相机 150 帧就是 450 张图,所以每个 3000 步周期里,头 450 步是致密化和剪除的封锁期。理由很实在:刚重置完,累加器里的证据是残缺的,这时候做决策等于掷骰子。

把上面所有事件排到时间轴上,就是下面这张图:

仪器 4 · 密度控制时间轴

把鼠标移到标记上看每个事件在做什么。曲线形状是示意的,标注的步数和配置值是 streetgs.yaml 的真实默认。

把鼠标移到时间轴的标记上。

6.5 完整配置表

gaussian_ctrl_general_cfg · streetgs.yaml 与 omnire.yaml 完全一致;最后一列是车辆节点在 RigidNodes.ctrl 里的覆盖值
配置项背景默认含义车节点
warmup_steps500之前完全不做密度控制—
refine_interval100每多少步决策一次—
densify_grad_thresh5e−4平均 2D 梯度阈值—
densify_size_thresh0.003分裂/复制的分界,要乘场景半径—
split_screen_size0.05屏幕占比超过就强制走分裂—
n_split_samples2分裂时新生成几个—
cull_alpha_thresh0.005不透明度低于此剪掉—
cull_scale_thresh0.5尺度大于此×场景半径剪掉0.1
cull_screen_size0.15屏幕占比大于此剪掉—
stop_screen_size_at4000之后不再按屏幕尺寸判定30000
stop_split_at15000之后只剪不长20000
reset_alpha_interval3000每多少步重置不透明度—
reset_alpha_value0.01重置的上限值—
sh_degree_interval1000每多少步升一阶球谐—
sh_degree3球谐阶数上限—

车节点那三个覆盖值是有道理的:车小,所以尺度剪除阈值收紧五倍;车在规范空间里被多帧从不同角度看到,收敛更慢,所以分裂窗口放宽到 20k、屏幕尺寸判据一直开到 30k。

7.0代码

把这一切接回 DriveStudio

整个训练循环在 tools/train.py,核心就这么几行:

for step in metric_logger.log_every(all_iters, print_freq):
    trainer.preprocess_per_train_step(step=step)          # 把 step 广播给每个节点
    image_infos, cam_infos = dataset.train_image_set.next(downscale)
    outputs = trainer(image_infos, cam_infos)             # collect → gsplat 光栅化 → 合天空 → 曝光仿射
    loss_dict = trainer.compute_losses(outputs, image_infos, cam_infos)
    trainer.backward(loss_dict)                           # 反传 + optimizer.step + lr 调度
    trainer.postprocess_per_train_step(step=step)         # after_train 攒证据 + refinement_after 动刀

本课每个概念对应的代码位置:

概念文件函数 / 字段
高斯的 59 个参数models/gaussians/vanilla.py_means _scales _quats _opacities _features_dc _features_rest
激活(exp / 归一化 / sigmoid)同上get_scaling get_quats get_opacity
球谐求值与升阶同上get_gaussians() 里的 spherical_harmonics(n, ...)
投影 + 光栅化models/trainers/base.pyrender_gaussians() → gsplat rasterization()
天空合成models/trainers/scene_graph.pyforward() 里用 opacity 混合 EnvLight
损失models/trainers/base.pycompute_losses()
形状正则models/gaussians/vanilla.pycompute_reg_loss() 里的 sharp_shape_reg
优化器与学习率调度models/trainers/base.pylr_scheduler_fn()、initialize_optimizer()
密度控制证据累加models/gaussians/vanilla.pyafter_train()
致密化 / 剪除 / 重置同上refinement_after() split_gaussians() dup_gaussians() cull_gaussians()
车节点的覆盖行为models/nodes/rigid.py同名方法的重写
一句提醒

DriveStudio 只把 gsplat 当光栅化器用。密度控制、损失、调度、场景图组装全是它自己的 Python 代码。所以你要改训练动力学,改的是 DriveStudio 的 models/,不用碰 CUDA;但要拿到光栅化内部的量(比如逐高斯的命中计数),就得动 gsplat 或者绕道用梯度做代理。

8.0兑现

回到你自己的实验

补完这一层之后,八月那批现象里有几条现在可以直接解释了。

为什么 s552 冻结时背景高斯有 199 万,比最终的 152 万还多

因为 stop_split_at = 15000。15k 之前是又长又剪、净增长;15k 之后只剪不长,数量单调下降。你的控制器在 9.5k 冻结,正好锁在数量的上升段中途——冻结把一个还没被剪过的中间态固化了。你后来加的“数量稳定门”本质上是在等这条曲线走过峰值。

为什么 10k 步比 30k 步好

这一课给出了动力学上的一半答案:15k 之后没有新高斯了,剩下 15k 步全部用来微调既有高斯的 59 个参数;而这些参数里只有位置有学习率衰减,形状、旋转、颜色的步长一直保持在初始值。在观测约束弱的方向上,这就是持续的随机游走。另一半答案在下一课的外推评测协议里。

为什么 FastGS 的门控在前向 rig 上会失效

FastGS 的判据是“随机采 10 个视角,其中至少 5 个都说这个高斯有高误差”。这个假设成立的前提是采到的视角都看得见这个高斯。你实测的共视率是均值 12.7%、中位数 0,也就是随便采十个视角,大概率一个都看不见——门控自然触发不了。你测出的触发率 9.2% 正是这么来的。

下一课预告

第二课讲街景怎么把这套东西撑大:场景图(背景 / 刚体车 / 行人节点)、LiDAR 初始化与深度监督、天空模型、逐相机曝光仿射、可优化的车辆位姿,以及 Street Gaussians 的 4D 球谐。讲完你就能完整读懂 streetgs.yaml 的每一行了。

9.0收尾

自查清单与术语表

合上页面之前,下面这几条你应该能不看笔记说出来:

高斯 / splat Gaussian, splat
场景的基本图元。一个各向异性三维高斯分布,带不透明度和球谐颜色。splat 是它投到屏幕上之后的那一摊。
不透明度 opacity
高斯自身的透明程度 \(o\)。注意区别于 \(\alpha_i\),后者还要乘上高斯在该像素处的取值。
透过率 transmittance
\(T_i=\prod_{j<i}(1-\alpha_j)\)。光走到第 \(i\) 个高斯时还剩多少没被挡住。
球谐 spherical harmonics, SH
球面上的正交基。用来把颜色表达成观察方向的函数。3DGS 用到 3 阶,共 16 个基、48 个系数。
致密化 densification
密度控制里“长”的那一半,包括复制和分裂两种。
剪除 culling / pruning
删掉不透明度太低、尺度太大或屏幕占比太大的高斯。
EWA splatting
把三维高斯用雅可比线性近似投成二维高斯的经典方法,3DGS 沿用了它。
popping
因为排序是按 tile 而非按像素做的,相机移动时排序位次跳变造成的闪烁伪影。
场景半径 scene scale / radius
由点云范围估出的场景尺度。多个尺寸阈值和位置学习率都要乘它,所以同一个配置在大场景和小场景上的实际行为并不相同。

❧