街景 3DGS 补课 · 第一课 · 2026-09-03
从一个椭球到一张图
这一课把原版 3D Gaussian Splatting 拆开:场景怎么被表示成几十万个模糊椭球,椭球怎么变成屏幕上的像素,训练怎么反向修改它们,以及那套决定高斯生生死死的密度控制。每一条都对到你手上 DriveStudio 的源码位置和默认值。
这一课要装进脑子的东西
你已经跑过八个场景的基线、做过通宵实验、读过 Fisher 信息那一套。但中间有一层被跳过了:3DGS 本身。这一课就是补这一层。补完之后再谈创新点,你会知道每个可以拧的旋钮在哪、拧了会动到什么。
先给定位。下面这张表决定你该在哪些地方花力气,别默认全都要背:
| 内容 | 要到什么程度 | 为什么 |
|---|---|---|
| 协方差的拼装 \(\Sigma=RSS^\top R^\top\) | 能默写 | 面试常问,也是形状正则的作用对象 |
| alpha 合成公式 | 能默写 | 整个渲染的核心,深度图 / 不透明度图全从它派生 |
| 密度控制四件事 | 能默写 | 你八月一半的现象都是它造成的 |
| 球谐怎么表达视角相关色 | 能说思路 | 知道阶数意味着什么就够,系数表不用背 |
| 投影雅可比 \(J\) | 能说思路 | 知道为什么需要它、它带来什么误差 |
| tile 分块与排序的工程细节 | 知道有这回事 | gsplat 已经封装好,你不会去改 CUDA |
| 各个配置项的具体数值 | 知道去哪查 | 本页给了完整表,用的时候翻 |
再给一张全景。下面这张图是 3DGS 训练一步的全部内容,后面七个阶段就是把它逐块展开:
3DGS 不是神经网络。它没有隐藏层、没有权重矩阵,只有一堆直接被梯度下降优化的几何图元。所有你熟悉的 PyTorch 直觉都还成立(Adam、学习率、损失),但多了一件 LLM 里没有的事:参数的个数会在训练中变。
一个高斯是什么
场景被表示成几十万到几百万个带颜色的模糊椭球。每一个椭球叫一个高斯,它是一个三维空间里的各向异性高斯分布,加上不透明度和颜色。你训练的全部东西就是这些椭球的参数。
| 属性 | 实际存的量 | 个数 | 激活函数 | 代码字段 |
|---|---|---|---|---|
| 位置 \(\mu\) | xyz 三个坐标 | 3 | 无 | _means |
| 尺度 \(s\) | 三个轴半径的对数 | 3 | \(\exp\) | _scales |
| 旋转 \(q\) | 四元数 | 4 | 归一化 | _quats |
| 不透明度 \(o\) | logit 值 | 1 | \(\mathrm{sigmoid}\) | _opacities |
| 基础色 | 0 阶球谐系数 × RGB | 3 | 见 §2 | _features_dc |
| 视角相关色 | 1–3 阶球谐系数 × RGB | 45 | 见 §2 | _features_rest |
| 合计 | 59 |
这个 59 就是你在 v3 方案里写的每个高斯参数维度 \(d=59\) 的来历。
1.1 为什么每样东西都套一层激活
看第四列。位置是唯一一个裸奔的量,其他三个都套了激活函数。原因统一:梯度下降不知道什么叫合法。
- 尺度存对数。椭球半径必须为正,但 Adam 会毫不犹豫地把参数推成负数。存 \(\log s\) 之后,\(\exp\) 保证任意实数出来都是正数,而且乘除变成加减,尺度的相对变化在参数空间里是均匀的。
- 旋转存四元数并归一化。四个数归一化之后一定对应一个合法的三维旋转,不像欧拉角有万向锁,也不像直接学 3×3 矩阵要额外约束正交。
- 不透明度存 logit。\(\mathrm{sigmoid}\) 把任意实数压进 \((0,1)\)。
1.2 协方差:拼出来,不是学出来
一个三维高斯的形状由协方差矩阵 \(\Sigma\)(3×3 对称)决定。3DGS 不直接学 \(\Sigma\) 的六个自由数,而是从尺度和旋转拼出来:
读法很直白:\(S\) 先把一个单位球在三个坐标轴方向拉伸成一个轴对齐的椭球,\(R\) 再把它转到空间中该在的朝向。\(SS^\top=\mathrm{diag}(s_1^2,s_2^2,s_3^2)\),所以 \(\Sigma\) 的三个特征值恰好是 \(s_i^2\),特征向量就是 \(R\) 的三个列。椭球的主轴长度和朝向直接写在参数里,一眼可读——这是后面所有形状分析(各向异性、薄轴方向、有效秩)能做的前提。
拖一拖下面这个,把这个式子变成肌肉记忆。为了看得见,这里降到二维:
仪器 1 · 协方差沙盒
拖动两个轴长和转角,看 \(\Sigma\) 怎么被拼出来。注意:无论你怎么拖,右边的椭圆永远是一个合法椭圆。
那为什么不干脆直接学 \(\Sigma\) 的六个数?
因为协方差矩阵必须半正定——它的特征值就是各方向的方差,不能为负。直接把六个数交给 Adam,几步之后它就会走到一个特征值为负的矩阵,那玩意儿在几何上不对应任何椭球,渲染直接崩。
下面这个是二维版的反例,你拖一下 \(\Sigma_{12}\) 就能看到它失效的瞬间:
用 \((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 会讲到。
球谐:让同一个椭球换个角度换个颜色
如果每个高斯只有一个固定颜色,车漆的高光、路面的反光、玻璃的反射全都表达不了——这些东西的颜色取决于你从哪儿看。所以颜色不存成一个 RGB 三元组,而存成一个方向的函数:给定观察方向 \(d\),返回一个颜色。
球谐(spherical harmonics,SH)就是球面上的傅里叶基。任何定义在球面上的函数都能展开成一组基函数的加权和,阶数越高,能表达的方向变化越精细:
\(Y_{\ell m}\) 是固定的基函数,\(k_{\ell m}\) 才是学出来的系数(每个颜色通道各一套)。最后那个 \(+0.5\) 是 3DGS 的约定:让全零系数对应中灰而不是纯黑。
| 阶 L | 基函数个数 (L+1)² | 系数个数 ×RGB | 能表达 |
|---|---|---|---|
| 0 | 1 | 3 | 一个常数色,完全不随视角变 |
| 1 | 4 | 12 | 沿某个方向的线性明暗渐变 |
| 2 | 9 | 27 | 一个宽的凸起或凹陷 |
| 3 | 16 | 48 | 一个还算集中的高光瓣(3DGS 的默认上限) |
下面这台仪器把“阶数意味着什么”变成可以看的东西。目标(虚线)是一个真实的高光瓣;实线是用有限阶球谐去逼近它的结果。拖阶数:
仪器 2 · 球谐能表达多细的视角变化
横轴是观察方向绕高斯转一圈的角度。系数由目标函数在球面上做投影得到(4000 个 Fibonacci 采样点,用的正是 3DGS 源码里的那组归一化常数)。
两个目标各说明一件事。平滑高光: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,是从相机指向高斯,不是反过来。做视角相关分析时符号搞反,结论会整个镜像。
投影:3D 椭球怎么变成屏幕上的椭圆
渲染的第一步是把每个高斯投到图像平面上。中心点的投影是老生常谈:
\(W\) 是世界到相机的变换,\(K\) 是内参,\(\pi\) 是除以深度的齐次归一化。麻烦的是形状怎么投。
协方差描述的是中心点周围的一小片分布。透视投影不是线性变换(有个除以 \(z\)),所以椭球投出来严格说不是椭圆。3DGS 的做法是在中心点处做一阶泰勒展开,用雅可比矩阵 \(J\) 把投影局部近似成线性映射:
然后取 \(\Sigma'\) 左上角的 2×2 子块,就得到屏幕上那个二维高斯。这就是所谓的 EWA splatting,做法本身来自 2001 年的体渲染文献,不是 3DGS 发明的。
\(J\) 长什么样,以及它的近似误差在哪
设相机系下的点是 \((x,y,z)\),针孔投影是 \(u=f_x x/z + c_x\),\(v=f_y y/z + c_y\)。对它求导:
三件事值得注意。第一,\(J\) 里处处是 \(1/z\):越远的高斯投出来越小,这是透视的全部内容。第二,第三列非零,说明深度方向的扩展会渗进屏幕上的形状,这就是离轴拉伸的来源。第三,\(J\) 在中心点算一次就固定了,所以越大的高斯,这个线性近似越不准——高斯横跨的区域越大,\(J\) 在它内部的变化就越不能忽略。这是 3DGS 要设 cull_scale_thresh 剪掉巨大高斯的理由之一,也是为什么远景大高斯容易出现形状错误。
注意 \(J\) 的第三列:深度方向 \((z)\) 的贡献带着 \(x/z^2\)、\(y/z^2\) 这样的系数。当高斯在光轴附近(\(x,y\to 0\))时,这一列趋近于零——也就是说,沿视线移动这个高斯,屏幕上几乎没有变化。这正是你实测到的“光度 Fisher 沿视线退化”的解析来源,前向行驶的相机把大部分背景高斯都放在了这个位置上。
光栅化:alpha 合成才是渲染的核心
投影完之后,每个高斯在屏幕上是一个椭圆。现在要决定每个像素最终是什么颜色。分四步:
- 屏幕切成 16×16 的小块(tile),每个高斯登记到它覆盖的所有 tile 上;
- 每个 tile 内部,把登记进来的高斯按深度排序;
- 每个像素沿这个顺序从前往后累加颜色;
- 累计透过率掉到 1e−4 以下就提前退出,这个像素后面的高斯一律不看。
第三步的公式是整个 3DGS 里最该背下来的东西:
拆开看。\(\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\))。拖动不透明度,看权重怎么分配。
试试把高斯 1 的不透明度拉到 1:后面三个的权重立刻全归零,它们对这个像素再也没有梯度。这就是为什么被完全遮挡的高斯会一直保留着初始化时的错误形状——数据对它彻底沉默了。
4.1 一套权重,四种产物
关键洞察:那组权重 \(w_i\) 算一次,换掉被加权的东西就能渲染出别的图。
| 渲染什么 | 把 \(c_i\) 换成 | 用途 |
|---|---|---|
| RGB 图 | 球谐算出的颜色 | 主损失 |
| 深度图 | 该高斯到相机的距离 \(d_i\) | LiDAR 深度监督 |
| 不透明度图 \(O\) | 常数 1 | 天空掩码损失、天空合成 |
| 任意逐高斯标量 | 那个标量 | 可视化、诊断、你自己做的分析 |
不透明度图 \(O=\sum_i w_i\) 有个直白的意思:这个像素被高斯盖住了多少。天空就靠它合成——高斯没盖住的地方,漏出天空贴图:
DriveStudio 里天空是一个 1024 分辨率的可优化环境贴图(models.modules.EnvLight),学习率 0.01。它和高斯是两条独立的路径,最后才用 \(O\) 混合。
4.2 排序是近似的,这会咬人
严格的体渲染要求每个像素都按自己视线上的真实前后顺序积分。3DGS 为了速度,改成每个 tile 排一次序,同一个 tile 里 256 个像素共用同一个顺序。当两个高斯互相穿插时,这个顺序对某些像素是错的。
后果是 popping:相机连续移动时,某个高斯的排序位次会在某一帧突然跳变,画面上出现一块颜色的瞬间闪动。这在静态评测里几乎看不出来,在渲染视频时很明显。知道有这回事就够了,gsplat 已经把这一层封装掉,你不会去改。
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 会说它改变了密度控制的判据。
训练:损失、学习率、分辨率课程
前向讲完,现在是你最熟的部分。每一步:随机取一张训练图,渲染,算损失,反传,Adam 更新。原版 3DGS 的损失只有两项:
L1 管逐像素的绝对误差,SSIM 管局部结构和纹理。DriveStudio 在这之上又加了几项,全在 configs/streetgs.yaml 的 losses 段:
| 项 | 权重 | 算什么 |
|---|---|---|
rgb | 0.8 | L1 逐像素 |
ssim | 0.2 | 1 − SSIM |
mask | 0.05 | 不透明度图与非天空掩码的二元交叉熵 |
depth | 0.01 | 渲染深度与 LiDAR 深度的 L1,只在有 LiDAR 回波的像素上算 |
affine | 1e-5 | 把逐相机的曝光仿射变换拉回单位阵 |
sharp_shape_reg | 1.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,其余五个学习率从第一步到第三万步一个数都不变:
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 继承来的,别随手改。
自适应密度控制: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)步执行一次决策。判据看下面这张图:
两条分支的语义完全不同:
- 复制(under-reconstruction)。这个高斯很小但误差很大,说明这块区域高斯不够用。原地复制一份,让它们各自往需要的方向漂。
- 分裂(over-reconstruction)。这个高斯很大而且误差大,说明它想用一坨模糊去盖住本该有细节的区域。把它拆成更小的:
split_gaussians()的实现是尺度都除以 1.6,原来那个留在原地,另外生成两个位置从它自身分布里采样的新高斯——所以 DriveStudio 里一次分裂是 1 变 3,不是常见说法里的 1 变 2。
剪除是另一条路径,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 完整配置表
| 配置项 | 背景默认 | 含义 | 车节点 |
|---|---|---|---|
warmup_steps | 500 | 之前完全不做密度控制 | — |
refine_interval | 100 | 每多少步决策一次 | — |
densify_grad_thresh | 5e−4 | 平均 2D 梯度阈值 | — |
densify_size_thresh | 0.003 | 分裂/复制的分界,要乘场景半径 | — |
split_screen_size | 0.05 | 屏幕占比超过就强制走分裂 | — |
n_split_samples | 2 | 分裂时新生成几个 | — |
cull_alpha_thresh | 0.005 | 不透明度低于此剪掉 | — |
cull_scale_thresh | 0.5 | 尺度大于此×场景半径剪掉 | 0.1 |
cull_screen_size | 0.15 | 屏幕占比大于此剪掉 | — |
stop_screen_size_at | 4000 | 之后不再按屏幕尺寸判定 | 30000 |
stop_split_at | 15000 | 之后只剪不长 | 20000 |
reset_alpha_interval | 3000 | 每多少步重置不透明度 | — |
reset_alpha_value | 0.01 | 重置的上限值 | — |
sh_degree_interval | 1000 | 每多少步升一阶球谐 | — |
sh_degree | 3 | 球谐阶数上限 | — |
车节点那三个覆盖值是有道理的:车小,所以尺度剪除阈值收紧五倍;车在规范空间里被多帧从不同角度看到,收敛更慢,所以分裂窗口放宽到 20k、屏幕尺寸判据一直开到 30k。
把这一切接回 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.py | render_gaussians() → gsplat rasterization() |
| 天空合成 | models/trainers/scene_graph.py | forward() 里用 opacity 混合 EnvLight |
| 损失 | models/trainers/base.py | compute_losses() |
| 形状正则 | models/gaussians/vanilla.py | compute_reg_loss() 里的 sharp_shape_reg |
| 优化器与学习率调度 | models/trainers/base.py | lr_scheduler_fn()、initialize_optimizer() |
| 密度控制证据累加 | models/gaussians/vanilla.py | after_train() |
| 致密化 / 剪除 / 重置 | 同上 | refinement_after() split_gaussians() dup_gaussians() cull_gaussians() |
| 车节点的覆盖行为 | models/nodes/rigid.py | 同名方法的重写 |
DriveStudio 只把 gsplat 当光栅化器用。密度控制、损失、调度、场景图组装全是它自己的 Python 代码。所以你要改训练动力学,改的是 DriveStudio 的 models/,不用碰 CUDA;但要拿到光栅化内部的量(比如逐高斯的命中计数),就得动 gsplat 或者绕道用梯度做代理。
回到你自己的实验
补完这一层之后,八月那批现象里有几条现在可以直接解释了。
为什么 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 的每一行了。
自查清单与术语表
合上页面之前,下面这几条你应该能不看笔记说出来:
- 一个高斯存哪 59 个数,其中三样为什么要套激活函数。
- 写出 \(\Sigma=RSS^\top R^\top\),并说明它的特征值和特征向量分别是什么。
- 写出 alpha 合成公式,解释 \(T_i\) 的物理含义。
- 说出不透明度图 \(O\) 是怎么算的,天空怎么合上去。
- 说出密度控制的四件事,以及复制和分裂各自对应什么病症。
- 说出哪些参数组的学习率在衰减、哪些不在。
- 高斯 / 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
- 由点云范围估出的场景尺度。多个尺寸阈值和位置学习率都要乘它,所以同一个配置在大场景和小场景上的实际行为并不相同。
❧