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

DriveStudio 代码地图

前两课讲的是「这套东西在数学上是什么」,这一课讲「它在磁盘上长什么样」。目标只有一个:以后你想改任何一处,都能在一分钟内找到该动哪个文件、哪个函数,以及改了会波及谁。

前置:第一课 · 第二课
代码:ziyc/drivestudio 主分支,2026-09-03 逐条核对
规模:models/ 5993 行 · datasets/ 13891 行 · tools/ 639 行 · utils/ 1290 行

0.0开场

这一课是拿来查的,不是拿来背的

前两课那种「能默写」的要求,这一课基本不适用。代码结构记不住很正常,你需要的是知道去哪儿找。所以这一课的读法是:现在通读一遍建立索引,以后真要动手时回来查表。

两个数字先摆着,它们决定了你该往哪儿使劲:

目录Python 行数你会改它吗
models/5 993会,而且是主战场。训练动力学、损失、密度控制全在这里
datasets/13 891偶尔。行数最多,但大半是六个数据集各自的读取器,你只碰 Waymo 那支
tools/639会,但很少。主循环和评测入口
utils/1 290基本不改。日志、可视化、备份
configs/—天天改,但都是 yaml,不是代码
third_party/6 457不改。SMPL 和 4D-Humans 的搬运

datasets/ 行数最多这件事容易误导人。它大是因为要支持六个数据集,而每个数据集的原始格式都不一样。真正的公共逻辑只在 datasets/base/ 和 datasets/driving_dataset.py 里,其余是各自的适配层。

1.0全景

仓库全景

点一下就知道每个文件管什么。橙色的是你大概率会改的:

仪器 1 · 仓库地图

点任意一项看它管什么、多少行、在哪一课讲过。

2.0启动

从命令行到第一步

你八月跑的每一条命令长这样:

python tools/train.py \
    --config_file configs/streetgs.yaml \
    --output_root logs/multiscene \
    --project baseline \
    --run_name streetgs_s23 \
    dataset=waymo/3cams data.scene_idx=23 data.end_timestep=150 \
    trainer.optim.num_iters=30000

前面带 -- 的是 argparse 参数,后面不带的全是配置覆盖——那个 opts 用了 argparse.REMAINDER,把剩下的一切原样收走交给 OmegaConf。

2.1 配置是三层合出来的

setup() 里的合并顺序很重要,因为它决定了谁盖过谁:

① 主配置 configs/streetgs.yaml ② 数据集配置 configs/datasets/<dataset>.yaml ③ 命令行 key=value 直接覆盖 后面的盖前面的 · 结果存成 log_dir/config.yaml 注意:dataset 这个键会被先取出来,用来决定②读哪个文件
图 1配置合并顺序。dataset=waymo/3cams 是个特例:它先从命令行参数里被 pop 出来,用来定位第二层要加载哪个文件,然后才做合并。

2.2 输出目录是三段拼出来的

$$\texttt{log\_dir} \;=\; \texttt{output\_root} \,/\, \texttt{project} \,/\, \texttt{run\_name}$$

所以 --output_root logs/multiscene --project baseline --run_name streetgs_s23 落到 logs/multiscene/baseline/streetgs_s23。你之前写的那份 logs 说明文档里「目录名就能反推出实验」,机制就是这一行。

建目录时一次性创建六个子目录:images、videos、metrics、configs_bk、buffer_maps、backup。

一个你已经用过、但可能不知道机制的东西

setup() 最后会调 backup_project(),把 configs / datasets / models / utils / tools 这五个目录下所有 .py .h .cpp .cuh .cu .sh .yaml 文件整个复制到 log_dir/backup/。这就是你说的「事后追当时代码长啥样全靠它」。代价是每个 run 都存一份完整源码副本,几 MB,但比起 checkpoint 的几百 MB 不算什么。

3.0结构

两条继承链

整个 models/ 的结构可以压缩成两棵树。看懂这两棵树,就知道改一个方法会波及谁。

Trainer 链 · 管一次训练怎么跑 BasicTrainer trainers/base.py · 787 行 MultiTrainer scene_graph.py · 场景图 SingleTrainer single.py · 单一表征(PVG) 高斯节点链 · 管一堆高斯怎么表示与增删 VanillaGaussians gaussians/vanilla.py · 473 行 RigidNodes nodes/rigid.py PeriodicVibration… gaussians/pvg.py DeformableGaussians gaussians/deformgs.py SMPLNodes nodes/smpl.py · 750 行 DeformableNodes nodes/deformable.py
图 2两条继承链。橙色是 streetgs.yaml 实际用到的。注意右下角:SMPLNodes 和 DeformableNodes 继承的是 RigidNodes 而不是 VanillaGaussians——因为行人和骑车人也需要「每个实例一套逐帧位姿」这套机制,只是在它之上再加了形变。

3.1 节点类必须提供的接口

Trainer 不关心你是车还是行人,它只按下面这几个方法调用你。你要加一类新节点,实现这些就够了:

方法什么时候被调干什么
get_gaussians(cam)每步前向把自己的高斯搬到世界系,返回五个张量
get_param_groups()建优化器时报告自己有哪些可学参数,见 §5.2
postprocess_per_train_step()每步反传后攒证据 + 每 100 步做密度控制
compute_reg_loss()每步算损失时返回自己的正则项字典
state_dict() / load_state_dict()存读 checkpoint除了参数,还要存自己特有的东西
set_cur_frame(i)每步前向开头可选。需要知道当前是第几帧的节点才实现

举个具体的:RigidNodes.state_dict() 在父类基础上多存了 points_ids、instances_size、instances_fv 三样——因为这三样是结构信息不是参数,不存的话读 checkpoint 时没法知道哪个高斯属于哪辆车。

4.0调用栈

一步训练的调用栈

第一课给过六行主循环,这里把它展开成完整调用链。点任意一层看它做什么、以及你可能在这一层插什么:

仪器 2 · 调用栈追踪器

缩进表示调用深度。橙色是你最可能改动的层。

这个链条里最容易被忽略的一环

preprocess_per_train_step() 看着像样板代码,其实它做了一件关键的事:把当前 step 广播给每一个节点模型。节点里所有跟步数有关的行为——球谐升阶、密度控制的各种窗口、形变网络什么时候启用——全靠这个值。你要是自己写脚本直接调 trainer 的内部方法,忘了这一步,行为会静默地不对。

5.0接口

三个接口:数据、参数组、产物

这一节是三张查阅表。真要动手时你会反复回来看。

5.1 每一步拿到的数据长什么样

dataset.train_image_set.next() 返回两个字典。所有跟当前这张图有关的东西都在这里,你想加新的监督信号,也是往这里加:

image_infos —— 逐像素或逐图的量(值为 None 的键会被过滤掉,所以实际存在哪些键取决于数据集配置)
键形状含义
pixelsH×W×3真值 RGB
origins / viewdirsH×W×3每个像素的光线起点与方向。天空模型用的就是 viewdirs
direction_normH×W×1方向归一化前的模长
pixel_coordsH×W×2归一化的 (y/H, x/W)
img_idx / frame_idx标量全局图像编号 / 时间步编号。曝光仿射和相机位姿都按 img_idx 查表
normed_time标量归一化到 [0,1] 的时间,用来定位 cur_frame
sky_masksH×W天空掩码,掩码损失用
dynamic_masks / human_masks / vehicle_masksH×W分区评测和动态区加权用
egocar_masksH×W自车车身,从损失里整个排除
lidar_depth_mapH×W稀疏深度真值,没命中的像素是 0
cam_infos —— 相机本身
键含义
cam_id / cam_name相机编号与名字(0 = front,1 = front_left,2 = front_right)
camera_to_world4×4 外参。位姿优化模块改的就是它
intrinsics3×3 内参
height / width当前分辨率,会随分辨率课程变

取图的那层是 SplitWrapper(datasets/base/split_wrapper.py,只有 54 行)。它做两件事:按 split_indices 决定这个 split 能看到哪些图,以及在取图前后临时调整分辨率——分辨率课程就是在这里生效的,取完立刻 reset_downscale_factor() 还原。

5.2 参数组的名字是拼出来的

这个机制不难,但不知道的话你会完全不知道学习率配置是怎么找到参数的。每个模型报告参数组时,键名是 类名 + "#" + 组件名:

# models/gaussians/vanilla.py
self.class_prefix = class_name + "#"       # 比如 "Background#"
{
    self.class_prefix + "xyz":      [self._means],
    self.class_prefix + "sh_dc":    [self._features_dc],
    ...
}

建优化器时再把这个名字劈开,回配置里查:

# models/trainers/base.py · initialize_optimizer()
class_name     = params_name.split("#")[0]      # Background
component_name = params_name.split("#")[1]      # xyz
raw_optim_cfg  = model_config[class_name]["optim"][component_name]

所以 Background#xyz 这个组的学习率,来自配置里 Background.optim.xyz(由 gaussian_optim_general_cfg 填充)。加一个新的可学参数,你要做的就是在 get_param_groups() 里多返回一项,并在配置对应位置补上它的 lr——名字对不上会直接 assert 失败,不会静默出错,这点很友好。

还有一个容易忘的:scale_factor: scene_radius 这个写法会让学习率乘上场景半径。目前只有 xyz 用了它。所以同一份配置在大场景和小场景上,位置的实际学习率并不相同。

5.3 跑完之后目录里有什么

路径内容什么时候看它
config.yaml合并后的完整配置确认这次跑的到底是什么参数
configs_bk/带时间戳的配置副本同一目录重跑过多次时
backup/启动瞬间的源码快照追「当时代码长啥样」
checkpoint_*.pth见下做分析、续跑、渲染
metrics/images_test_*.json测试集指标,含分区主表数据来源
metrics/images_full_*.json全集指标(训练视角也算)诊断过拟合
images/ / videos/可视化产物vis_freq=-1 时基本是空的
buffer_maps/误差驱动采样器的缓存那个采样器一直关着,所以永远是空的

checkpoint 里存的东西比你以为的少:

{
  "step":   当前步数,
  "models": {"Background": {...}, "RigidNodes": {...}, "Sky": {...}, ...}
}

默认不存优化器状态(save_only_model=True),源码里 load_state_dict 那条分支甚至直接 raise NotImplementedError。所以 --resume_from 恢复的是模型不是训练状态,Adam 的动量从零开始。做续跑实验时这一点必须记着。

6.0动手

我想改 X,该动哪儿

这一节是整课最实用的部分。选一个你想做的事,看该动哪儿:

仪器 3 · 改动索引

按类别筛,或者直接点条目看详情。

7.0收尾

会咬人的地方

一半来自源码,一半来自你自己八月那份 logs 说明文档。放在一起省得两边翻。

  1. 正在运行的 bash 脚本不能编辑。bash 按字节偏移增量读脚本,改一下控制流就错乱。你说这个坑毁掉过五个基线。要改就 kill、复制成新文件、重启。
  2. 服务器上没有系统 python3,得用 /root/miniconda3/envs/drivestudio/bin/python。
  3. 别用 tail -N 管道跑训练,报错栈会被吞掉。重定向到日志文件,非零退出时再看尾巴。
  4. metrics.json 里 vis_freq 整数倍的那些步,iter_time 会跳到约 0.37 秒,那是可视化开销不是训练开销,算平均时要剔掉。
  5. 链式脚本用 [ -f bench_summary.json ] && skip 做断点续跑,所以重跑前要先删那个 json。
  6. 计时敏感:GPU 上同时只跑一个训练,CPU 预处理会把 GPU 利用率饿到 10% 以下。
  7. checkpoint 不含优化器状态,见 §5.3。
  8. 节点为空时模型会被整个删掉。init_gaussians_from_dataset() 末尾,没有初始点的类会从 self.models 和 gaussian_classes 里删除并打一条 warning。所以在没有动态车的场景上,RigidNodes 根本不存在——你的代码如果假设它一定在,会拿到 KeyError。这条跟第二课 §3 是同一件事的两面。

❧

自查清单

OmegaConf 配置库
支持点号访问和多层合并的 yaml 配置库。OmegaConf.from_cli 把 a.b=c 形式的命令行参数解析成嵌套配置。
参数组 param group
PyTorch 优化器里一组共享同一学习率的参数。这里的键名是 类名#组件名。
钩子方法 hook
preprocess_per_train_step / postprocess_per_train_step 这类由主循环在固定时机调用、供子类覆盖的方法。
SplitWrapper
包在数据源外面的一层,负责「这个 split 能看到哪些图」和取图时的分辨率调整。
代码快照 backup
每个 run 启动时自动复制的源码副本,存在 log_dir/backup/。