街景 3DGS 补课 · 第三课 · 2026-09-03
DriveStudio 代码地图
前两课讲的是「这套东西在数学上是什么」,这一课讲「它在磁盘上长什么样」。目标只有一个:以后你想改任何一处,都能在一分钟内找到该动哪个文件、哪个函数,以及改了会波及谁。
这一课是拿来查的,不是拿来背的
前两课那种「能默写」的要求,这一课基本不适用。代码结构记不住很正常,你需要的是知道去哪儿找。所以这一课的读法是:现在通读一遍建立索引,以后真要动手时回来查表。
两个数字先摆着,它们决定了你该往哪儿使劲:
| 目录 | 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 · 仓库地图
点任意一项看它管什么、多少行、在哪一课讲过。
从命令行到第一步
你八月跑的每一条命令长这样:
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() 里的合并顺序很重要,因为它决定了谁盖过谁:
dataset=waymo/3cams 是个特例:它先从命令行参数里被 pop 出来,用来定位第二层要加载哪个文件,然后才做合并。2.2 输出目录是三段拼出来的
所以 --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 不算什么。
两条继承链
整个 models/ 的结构可以压缩成两棵树。看懂这两棵树,就知道改一个方法会波及谁。
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 时没法知道哪个高斯属于哪辆车。
一步训练的调用栈
第一课给过六行主循环,这里把它展开成完整调用链。点任意一层看它做什么、以及你可能在这一层插什么:
仪器 2 · 调用栈追踪器
缩进表示调用深度。橙色是你最可能改动的层。
preprocess_per_train_step() 看着像样板代码,其实它做了一件关键的事:把当前 step 广播给每一个节点模型。节点里所有跟步数有关的行为——球谐升阶、密度控制的各种窗口、形变网络什么时候启用——全靠这个值。你要是自己写脚本直接调 trainer 的内部方法,忘了这一步,行为会静默地不对。
三个接口:数据、参数组、产物
这一节是三张查阅表。真要动手时你会反复回来看。
5.1 每一步拿到的数据长什么样
dataset.train_image_set.next() 返回两个字典。所有跟当前这张图有关的东西都在这里,你想加新的监督信号,也是往这里加:
| 键 | 形状 | 含义 |
|---|---|---|
pixels | H×W×3 | 真值 RGB |
origins / viewdirs | H×W×3 | 每个像素的光线起点与方向。天空模型用的就是 viewdirs |
direction_norm | H×W×1 | 方向归一化前的模长 |
pixel_coords | H×W×2 | 归一化的 (y/H, x/W) |
img_idx / frame_idx | 标量 | 全局图像编号 / 时间步编号。曝光仿射和相机位姿都按 img_idx 查表 |
normed_time | 标量 | 归一化到 [0,1] 的时间,用来定位 cur_frame |
sky_masks | H×W | 天空掩码,掩码损失用 |
dynamic_masks / human_masks / vehicle_masks | H×W | 分区评测和动态区加权用 |
egocar_masks | H×W | 自车车身,从损失里整个排除 |
lidar_depth_map | H×W | 稀疏深度真值,没命中的像素是 0 |
| 键 | 含义 |
|---|---|
cam_id / cam_name | 相机编号与名字(0 = front,1 = front_left,2 = front_right) |
camera_to_world | 4×4 外参。位姿优化模块改的就是它 |
intrinsics | 3×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 的动量从零开始。做续跑实验时这一点必须记着。
我想改 X,该动哪儿
这一节是整课最实用的部分。选一个你想做的事,看该动哪儿:
仪器 3 · 改动索引
按类别筛,或者直接点条目看详情。
会咬人的地方
一半来自源码,一半来自你自己八月那份 logs 说明文档。放在一起省得两边翻。
- 正在运行的 bash 脚本不能编辑。bash 按字节偏移增量读脚本,改一下控制流就错乱。你说这个坑毁掉过五个基线。要改就 kill、复制成新文件、重启。
- 服务器上没有系统
python3,得用/root/miniconda3/envs/drivestudio/bin/python。 - 别用
tail -N管道跑训练,报错栈会被吞掉。重定向到日志文件,非零退出时再看尾巴。 metrics.json里vis_freq整数倍的那些步,iter_time会跳到约 0.37 秒,那是可视化开销不是训练开销,算平均时要剔掉。- 链式脚本用
[ -f bench_summary.json ] && skip做断点续跑,所以重跑前要先删那个 json。 - 计时敏感:GPU 上同时只跑一个训练,CPU 预处理会把 GPU 利用率饿到 10% 以下。
- checkpoint 不含优化器状态,见 §5.3。
- 节点为空时模型会被整个删掉。
init_gaussians_from_dataset()末尾,没有初始点的类会从self.models和gaussian_classes里删除并打一条 warning。所以在没有动态车的场景上,RigidNodes根本不存在——你的代码如果假设它一定在,会拿到 KeyError。这条跟第二课 §3 是同一件事的两面。
❧
自查清单
- 说出配置的三层合并顺序,以及
dataset=这个键为什么特殊。 - 说出
log_dir是哪三段拼出来的。 - 画出两条继承链,并说出
SMPLNodes为什么继承RigidNodes。 - 说出节点类必须实现的那几个方法,以及各自什么时候被调。
- 说出参数组名字的拼法,以及加一个新可学参数要改哪两处。
- 说出 checkpoint 里存了什么、没存什么。
- 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/。