街景 3DGS 补课 · 第二课 · 2026-09-03
街景怎么把 3DGS 撑大
第一课那套东西是为「围着一个物体转一圈」设计的。搬到一辆车往前开、路上还有别的车在动的场景里,会缺六样东西。这一课把这六样一个一个补上,讲完你能逐行读懂 streetgs.yaml。
街景多出来的那些东西
原版 3DGS 假设了一件事:场景是静止的,相机围着它转。这两条在驾驶数据上都不成立。相机装在车上一路往前开,路上还有别的车在动,而且六个相机的曝光各不相同、标定给的位姿还带误差。
把这些差异列出来,正好就是这一课的目录:
| 问题 | 街景版的做法 | 本课 |
|---|---|---|
| 场景里有东西在动 | 场景图:静态背景 + 每辆车一个刚体节点 | §1 §2 §3 |
| 没有 SfM 点云可用,且天空无穷远 | LiDAR 撒点初始化 + 逆距离远景随机点 | §4 |
| 前向行驶下几何约束太弱 | LiDAR 深度监督 | §5 |
| 天空不能用高斯表示 | 单独一张可优化的环境立方体贴图 | §6 |
| 每张图曝光不一致 | 逐图像的仿射色彩变换 | §6 |
| 标定位姿有误差 | 相机位姿也当参数一起优化 | §6 |
还是先给定位,别默认全都要背:
| 内容 | 要到什么程度 |
|---|---|
| 场景图的四类节点、以及它们最后被拼成一次光栅化 | 能默写 |
| 刚体节点的世界系变换公式 | 能默写 |
| 「会动的车」的判据,以及不达标的车去哪了 | 能默写 |
| 曝光仿射是逐图像的、测试时用平均嵌入 | 能默写 |
| LiDAR 怎么初始化、深度监督怎么算 | 能说思路 |
| 天空环境贴图的实现 | 知道有这回事 |
| SMPL / 可变形节点(omnire 才有) | 知道有这回事 |
加粗的那四条不是随便挑的——它们每一条都在你八月到九月的实验里咬过你一次,§9 会一条条对回去。
场景图:把动的和不动的拆开
核心想法一句话:一堆高斯没法同时表示「墙不动」和「车在动」,那就分成几堆。不动的那堆定义在世界系里,动的那堆定义在各自的规范空间(canonical space,可以理解成「以车自己为原点的坐标系」)里,每一帧再按这辆车当时的位姿搬到世界系。
DriveStudio 的场景图有四类高斯节点,外加三个不产生高斯的辅助模块:
streetgs.yaml 只开 Background + RigidNodes(加上 Sky / Affine / CamPose);omnire.yaml 才把行人和骑车人那两类也打开。你八月跑的 OmniRe-lite 就是把 SMPL 那一支关掉。1.1 最关键的一点:最后只有一次光栅化
这里非常容易想错,而且你踩过。四类节点不是各渲染一张图再叠起来的。它们各自把自己的高斯变换到世界系,然后被拼接成一个大数组,一次性丢进光栅化器:
# models/trainers/base.py · collect_gaussians()
for class_name in self.gaussian_classes: # Background, RigidNodes, ...
gs = self.models[class_name].get_gaussians(cam) # 各自搬到世界系
gs["class_labels"] = # 记下这批来自哪个节点
...
gs_dict[k] = torch.cat(v, dim=0) # 拼成一个数组
为什么这件事重要?因为只有拼在一起排序,深度关系才是对的。车挡住墙、墙挡住更远的车,这些遮挡是在同一次 alpha 合成里自然算出来的。如果分开渲染再叠加,你就得自己处理谁挡谁——那正是你八月做背景渲染缓存时遇到的问题:训练期两层 over-composite 不等于评测期的联合光栅化,硬切换在车和背景的接触带产生脏梯度,最后靠软混合 \(w_{\text{front}} = \sigma((d_{\text{bg}} - d_{\text{dyn}})/\tau)\) 才补回来。那个 bug 的根源就是绕过了这里的「一次光栅化」。
那 class_labels 是干嘛的?留给评测:只有在评测时,代码才会按标签做掩码,把每一类单独渲一遍,产出 Background_rgb、RigidNodes_rgb 这些分区结果。你在 images_test_*.json 里看到的 occupied_psnr 和 vehicle_psnr 就是从这儿来的。
仪器 1 · 场景图随时间组装
俯视图。灰点是静态背景,橙色是三辆各自独立的车。拖时间轴看它们怎么被搬到世界系,再被拼成同一个数组。
刚体节点:车怎么动起来
一辆车的高斯存在它自己的规范空间里,坐标原点在车中心。这些参数不随时间变——车的形状就是车的形状。变的是每一帧把它搬到世界系的那个位姿:
\(v(p)\) 是「第 \(p\) 个高斯属于哪辆车」,代码里叫 point_ids。位姿参数是两个大张量,形状都是 (帧数, 车数, ...):
| 参数 | 形状 | 学习率 | 说明 |
|---|---|---|---|
instances_trans | (F, N, 3) | 5e−4 → 1e−4 | 每帧每辆车的平移,指数衰减 |
instances_quats | (F, N, 4) | 1e−5 → 5e−6 | 每帧每辆车的旋转,指数衰减 |
instances_fv | (F, N) | — | 这一帧这辆车在不在(不可学) |
注意这两个是可学参数,不是固定的标注。数据集给的跟踪框有噪声,直接拿来用会把误差烙进车的形状里,所以 Street Gaussians 的做法是把位姿也交给梯度下降。这也是为什么车节点是全场唯二配了学习率衰减的参数组(另一个是背景的 xyz,见第一课 §5.1)。
2.1 车不在画面里的时候怎么办
instances_fv(frame validity)记录每一帧每辆车是否出现。渲染时它直接乘进不透明度:
# models/nodes/rigid.py · get_gaussians()
valid_mask = self.get_pts_valid_mask() # instances_fv[当前帧][每个点所属的车]
activated_opacities = self.get_opacity * valid_mask.float().unsqueeze(-1)
车没出现,它所有高斯的不透明度就是 0,完全不参与合成。这也意味着它们在这一帧收不到任何梯度——一辆只在前 30 帧出现的车,它的高斯只被 30 帧监督过。这是场景内部覆盖度差异的一个来源。
2.2 一个只在测试帧生效的开关
这条很隐蔽,但它直接影响你怎么读评测数字。位姿是逐帧的可学参数,而测试帧从来没被训练过——它们的位姿会一直停在跟踪器给的初值上,周围的训练帧却都被优化过了。这会在测试帧上凭空造出一个位姿跳变。
DriveStudio 的处理是:在测试帧上,不用该帧自己的位姿,而用前后两帧插值出来的。
# models/nodes/rigid.py · transform_means()
if self.in_test_set and 0 < self.cur_frame - 1 and self.cur_frame + 1 < self.num_frames:
interpolated_trans = (instances_trans[t-1] + instances_trans[t+1]) * 0.5
interpolated_quats = interpolate_quats(instances_quats[t-1], instances_quats[t+1])
平移取算术平均,旋转做四元数插值,而且只在前后两帧都有效时才用。所以在标准评测协议下,测试帧的车辆位姿是被邻居插出来的。这个协议在时间插值下很合理,但如果你要做的是留出整条相机的外推评测,这条路径就得重新想——第四课细说。
2.3 一个容易看错的细节:球谐算在世界系
车的高斯存在规范空间,但它的颜色是按世界系的观察方向算的:
world_means = self.transform_means(self._means) # 先搬到世界系
viewdirs = world_means.detach() - cam.camtoworlds[..., :3, 3]
rgbs = spherical_harmonics(n, viewdirs, colors) # 再按世界方向求值
意思是:同一个高斯,车头朝东和车头朝北的时候,即使你站在车的同一侧看,喂给球谐的方向也不一样。这不是 bug,是一个取舍——世界系球谐让模型可以把「车开到树荫下变暗」这种环境光变化吸收进颜色里。代价是车自身的高光不再跟着车走。Street Gaussians 原论文用的是另一条路:4D 球谐,把每个球谐系数换成一组傅里叶系数、按时间步反变换出来。知道有这回事——DriveStudio 的重实现没有包含 4D 球谐这一项,别把论文里的写法当成你手上代码的写法。
2.4 轨迹平滑正则
位姿可学就意味着它可能被光度损失带跑偏,比如一帧一抖。所以有一项二阶差分惩罚:
权重 0.01,每步随机抽一个间隔 \(k\)。读法:惩罚轨迹的「弯折」,但不惩罚匀速运动(匀速时二阶差分正好是零)。只对该帧和前后 \(k\) 帧都有效的车生效。
谁算「会动的车」
数据集给了所有车的标注框,但不是每个框都会变成一个刚体节点。判据在 datasets/driving_dataset.py 里,只有一句话:
# 只在这辆车出现的那些帧上算
valid_trans = instances_trans[frame_info]
traj_length = torch.norm(valid_trans[1:] - valid_trans[:-1], dim=-1).sum()
if traj_length > traj_length_thres: # streetgs / omnire 都是 1.0 米
new_instance_dict[k] = v # 才给它开一个节点
翻译:把这辆车逐帧位移的长度加起来,超过 1 米才算「会动」。注意是累计弧长,不是首尾直线距离——所以一辆原地不动但跟踪框在抖的车,抖够 1 米也会被判成动的。KITTI 的默认阈值被专门调到 5.0 米,注释里写明就是为了滤掉这种噪声轨迹。
留在背景里。背景初始化时会用 filter_pts_in_boxes() 剔掉落在车框内的 LiDAR 点,但传进去的是已经过滤后的实例字典。所以不达标的车,它的点从来没被剔除过,直接成了静态背景的一部分。
这正好解释了你 2026-09-02 那晚查到的事:DriveStudio 的八个示例场景里,有四个场景的车完全没动。它们根本没有刚体节点,整个场景就是一坨静态背景。你八月所有的「动态节点」结论,其实有一半跑在没有动态节点的场景上。
仪器 2 · 累计弧长判据
四条轨迹。拖阈值看哪些车会被开成刚体节点、哪些直接沉进背景。注意 3 号:它首尾几乎回到原点,但累计弧长很长。
默认的 1.0 米很松:只要跟踪框抖动累计超过一米就会被当成动态目标,开出一个刚体节点。反过来,一辆缓慢挪动但总位移不到一米的车,会被当成背景的一部分,而它确实动了——这些点就成了背景里的鬼影。
初始化:LiDAR 撒点
原版 3DGS 用 SfM 点云初始化。街景不行:路面和天空没纹理,SfM 在这些地方几乎给不出点,而这恰好是画面的大部分。好在驾驶数据自带 LiDAR。
streetgs.yaml 的背景初始化是三部分拼起来的:
| 来源 | 数量 | 怎么来的 |
|---|---|---|
from_lidar.num_samples | 800 000 | 整段序列的 LiDAR 点聚合后采样,带颜色 |
near_randoms | 100 000 | 场景球内均匀采样,颜色随机 |
far_randoms | 100 000 | 按逆距离均匀采样,管远景和天际线 |
两组随机点各自先按 3 倍数量采,再用 check_pts_visibility() 筛掉任何训练相机都看不见的,最后剩下的才留。far_randoms 的采样方式值得留意:在 \(1/r\) 上均匀而不是在 \(r\) 上均匀,等价于把大量点堆在近处、少量点撒到很远——这符合透视下「远处占的像素少」的事实。
最后一步是把落在动态车辆框内的点剔掉(filter_pts_in_boxes),免得同一块表面同时被背景和车节点表示。§3 说过,这一步只对通过了运动判据的车生效。
4.1 LiDAR 点的颜色是从图像投过来的
LiDAR 只给几何,不给颜色。所以初始化时要把点投影到图像上取色:
# datasets/driving_dataset.py · project_lidar_pts_on_images()
for cam in self.pixel_source.camera_data.values(): # ← 遍历所有相机
for frame_idx in range(len(cam)):
...
points_color = cam.images[frame_idx][v.long(), u.long()]
self.lidar_source.colors[visible_indices] = points_color
这个循环遍历所有相机,没有任何留出的概念,而且同一个点被多个相机看到时,后写的覆盖先写的。所以只要你把某条相机设为留出测试集,它的像素颜色仍然通过这条路径漏进了初始化点云。留出相机上「未观测区」的绝对数字要打折扣。
你记的修法是对的:在这个循环里按 HELDOUT_CAMS 跳过,然后重跑 s552 的五相机基线。
顺带一提,这个函数还有个副作用:它同时把每个 LiDAR 点是否被任何相机看见记进 visible_masks,末尾 delete_invisible_pts() 会把没被看见的点整个删掉。所以留出相机同时还影响了哪些点会被保留,不只是颜色。
LiDAR 深度监督
光度损失在前向行驶下管不住深度——这是你整个 v3 方案的出发点,第一课 §3 那个雅可比也从解析上说明了原因。LiDAR 深度监督就是用来补这个洞的,虽然补得很轻。
做法:把每帧的 LiDAR 点投到图像上,命中哪个像素就在那个像素写下深度,其余全是 0:
depth_map = torch.zeros(cam.HEIGHT, cam.WIDTH)
depth_map[v.long(), u.long()] = depth # 只有命中的像素有值
然后拿它跟渲染深度算 L1。渲染深度用的是「期望深度」,也就是 gsplat 的 render_mode="RGB+ED":
注意分母是不透明度 \(O\),不是 1。所以它是沿这条光线的加权平均深度,不是累积深度。半透明的区域也能给出一个有意义的深度值。
损失本身很朴素,配置全在 losses.depth:
| 项 | 值 | 含义 |
|---|---|---|
w | 0.01 | 权重,相比 rgb 的 0.8 小两个数量级 |
loss_type | l1 | 逐像素绝对差 |
normalize | False | 直接在米为单位的深度上算 |
inverse_depth | False | 不取倒数 |
| 有效像素判据 | 真值 > 0.01 米、< 80 米,且渲染深度 > 1e−4 | |
| 归约方式 | mean_on_hit——只在命中像素上平均 | |
仪器 3 · 深度监督到底覆盖了多少像素
LiDAR 在图像上是一组扫描线。拖线束数量看被监督的像素占比;右边上半是各项权重之比,下半才是真正该看的那个数——每个命中像素上的梯度比。
别被 0.9% 这个数骗了,它是权重之比,不是实际影响。两项的归约分母完全不同:rgb 在全部 960×640×3 个数上取平均,depth 只在命中的那几万个像素上取平均。L1 的导数是 ±权重÷计数、跟误差大小无关,所以真正该比的是右下角那个梯度比——在一个有回波的像素上,深度项的梯度跟颜色项是同一量级。它显得小,只是因为它作用的像素少。
严谨起见:两个梯度的单位不同(每米 vs 每个颜色单位),换算成对高斯参数的推动力还要各乘自己的雅可比,所以不能说影响完全相等。但「只占百分之一」那个读法是错的。
你 v3 的 M2 说的是:LiDAR 深度的 Fisher 主方向恰好是相机 Fisher 的最弱方向,所以应该按方向加权而不是全局统一权重。上面这张表就是「全局统一权重」的证据——w 是一个标量,均匀撒在所有命中像素上,跟这个像素上相机约束强不强完全无关。
顺带一个值得查的点:既然默认配置下深度项在命中像素上的梯度本来就跟颜色项同量级,那「×10 没反应」也许不是因为它太弱,而是因为它早就在起作用、瓶颈不在这儿。这只是一个说得通的读法,没验证过。
但要分清哪一版已经试过了。2026-09-02 那晚测的是最粗的形式:把 w 直接放大 10 倍。结果是观测区 ±0.1 dB、未观测区更差,属于负结果。没被测过的是「按方向加权」那一版——把标量换成随每个高斯的相机 Fisher 缺口变化的权重。这两件事不是一回事,别把前者的失败算到后者头上,也别拿它当后者已经成立的理由。
天空、曝光、相机位姿
三个不产生高斯、但每一项都在影响你读到的数字的模块。
6.1 天空:一张可优化的环境立方体贴图
天空在无穷远,用高斯表示会退化。DriveStudio 用一张立方体贴图:六个面、每面 1024×1024、每像素 RGB,全部初始化为 0.5,学习率 0.01。查表方式是拿每个像素的观察方向去采样:
# models/modules.py · EnvLight.forward()
l = image_infos["viewdirs"] @ self.to_opengl.T
light = dr.texture(self.base[None], l, filter_mode='linear', boundary_mode='cube')
合成方式就是第一课讲过的那条:\(C = C_{\mathcal G} + (1-O)\,C_{\text{sky}}\)。高斯没盖住的地方漏出天空。
另外有一项掩码损失把这件事钉住:渲染出的不透明度 \(O\) 要跟数据集给的「非天空掩码」对齐,用二元交叉熵,权重 0.05。它在两个方向上都起作用——不让高斯长到天上去,也不让本该实心的地方漏出天空。
6.2 曝光:逐图像的仿射色彩变换
这一节你要格外仔细看,因为它是你外推评测里最大的一个混淆项。
六个相机的自动曝光各跑各的,同一面墙在不同相机、不同时刻拍出来亮度就是不一样。如果不管,模型只能把这个差异编进几何或颜色里。DriveStudio 的做法是给每一张图配一个 4 维嵌入,过一个两层 MLP 输出 12 个数,reshape 成 3×4 的仿射矩阵:
整个模块零初始化,所以训练一开始它就是恒等变换,只学偏离恒等的那部分。这也是为什么还有一项 affine 正则(权重 1e−5)把它往恒等拉。
注意 §1 图里的顺序:仿射是作用在已经合成完天空之后的图上的。
outputs["rgb"] = self.affine_transformation(
outputs["rgb_gaussians"] + outputs["rgb_sky"] * (1.0 - outputs["opacity"]), image_infos
)
现在是关键的那一句。测试时怎么办?测试图没有训练过的嵌入。代码的选择是用所有训练图嵌入的平均值:
# models/modules.py · AffineTransform.forward()
if "img_idx" in image_infos and not self.in_test_set:
embedding = self.embedding(image_infos["img_idx"])
else:
# use mean appearance embedding
embedding = ones(...) * self.embedding.weight.mean(dim=0)
所以测试帧拿到的是一个「平均曝光」,而不是它自己该有的曝光。这中间的残差全部计入 PSNR,跟你的重建质量没有任何关系。P2GS 那条教训——评测前必须对每帧后拟合一个全局仿射——说的就是这件事。下面这台仪器把这个代价量出来:
仪器 4 · 曝光失配吃掉多少 PSNR
模拟:一批训练图各有自己的曝光增益,留出图的曝光落在它们之外。重建误差底固定在 34 dB,只改曝光的处理方式,看留出视角的 PSNR 怎么变。
下面那根柱子是正确做法:对留出帧后拟合一个全局仿射(闭式最小二乘),再算 PSNR。它把曝光这个混淆项彻底移出评测,所以正好落在虚线那个上限上。不做这一步的话,你测到的「外推变差了」里有一部分只是曝光对不上。
6.3 相机位姿也是参数
标定和里程计给的位姿有误差,几毫米的偏差在 30 米外就是好几个像素。CameraOptModule 给每张图学 9 个数:3 个平移增量 + 6D 表示的旋转增量,零初始化,学习率 1e−5、权重衰减 1e−6。
这里同样有个评测上的坑:渲染新视角时这个模块要被旁路掉。代码里 forward() 有个 novel_view 开关就是干这个的——新视角没有对应的图像索引,也就没有它的位姿修正量。
训练集和测试集怎么切
这一节短,但它是第四课的引子,也是你整个课题争论的起点。
# datasets/driving_dataset.py · split_train_test()
test_timesteps = np.arange(test_image_stride, num_img_timesteps, test_image_stride)
train_timesteps = [i for i in range(num_img_timesteps) if i not in test_timesteps]
关键是它切的是 timestep(时间步),不是单张图片。一个时间步被划进测试集,这一刻所有相机的图都进测试集。
拿你的基线配置算一遍:150 帧、3 相机、test_image_stride=10:
| 时间步 | 图像数 | |
|---|---|---|
| 测试 | 14 | 42 |
| 训练 | 136 | 408 |
| 合计 | 150 | 450 |
408 这个数你应该眼熟——你八月做背景渲染缓存时缓存的就是这 408 个训练视角。
因为它决定了「常规 NVS 指标好」意味着什么。在这个协议下拿到 27 dB,说明模型能在训练轨迹附近几米内插值。它不说明模型知道路边那栋楼侧面长什么样。你留出整条侧相机之后测到的 33% 到 82% 未观测像素,就是这个差距的量化。
逐行读 streetgs.yaml
到这里 streetgs.yaml 里已经没有你不认识的段落了。整理成一张表收尾:
| 段落 | 键 | 值 | 这个键在做什么 |
|---|---|---|---|
dataset | waymo/3cams | — | 选哪份数据集配置。这里是 Waymo 的前向三相机(0 正前、1 前左、2 前右),载入时长宽各降一半,所以实际训练分辨率是 960×640。 |
test_image_stride | 0 | 每隔多少个时间步留一个进测试集。0 的意思是全部拿去训练、不留测试集;你的实验在命令行覆盖成 10。§7 | |
trainer.optim | num_iters | 30000 | 训练总步数。第一课 §5 |
use_grad_scaler | false | 混合精度训练时防止 fp16 梯度下溢的放大器。这里全程 fp32,所以关掉。 | |
cache_buffer_freq | −1 | 误差驱动图像采样器每隔多少步刷新一次误差缓存。负数表示整个采样器关闭,退化成均匀随机挑图。你实测开了净亏 22%。 | |
trainer.render | near_plane / far_plane | 0.1 / 1e10 | 深度裁剪范围,落在区间外的高斯直接跳过不渲染。近面 0.1 米防止贴着镜头的高斯炸开,远面基本等于不设限。 |
antialiased | false | 选 classic 还是 Mip-Splatting 那套抗锯齿。开了会给每个高斯的不透明度乘一个补偿系数 \(\rho=\sqrt{\det\Sigma' / \det(\Sigma'+\varepsilon I)}\),把被二维膨胀滤波撑大的那部分压回去。这里没开。 | |
packed / sparse_grad | false | gsplat 的两个省显存开关。packed 用紧凑格式存「高斯与 tile 的相交关系」,sparse_grad 让位置、旋转、尺度的梯度走稀疏 COO 布局。4090 的 24 GB 够用,所以都没开。 | |
absgrad | true | 致密化用的屏幕梯度先取绝对值再累加。不开的话,同一个高斯被多个像素往不同方向拉扯时梯度正负相消,该分裂的地方看起来「没意见」。第一课 §6.1 | |
gaussian_ | xyz / sh_dc / sh_rest / opacity / scaling / rotation | 见右 | 六个参数组的学习率。只有 xyz 配了 lr_final(1.6e−4 → 1.6e−6),其余五个从第一步到第三万步一个数都不变;xyz 还会再乘场景半径。第一课 §5.1 那张图 |
losses | rgb.w / ssim.w | 0.8 / 0.2 | 光度损失的两半。L1 管逐像素的绝对误差,SSIM 管局部结构和纹理。第一课 §5 |
mask.w | 0.05 | 用二元交叉熵把渲染出的不透明度图 \(O\) 钉到数据集给的非天空掩码上。两个方向都管:不让高斯长到天上去,也不让本该实心的地方漏出天空。§6.1 | |
depth.w | 0.01 | LiDAR 深度监督的权重,L1,只在有回波的像素上取平均。这个数看着小,但不能按权重之比读——见 §5 那台仪器。 | |
affine.w | 1e−5 | 把学出来的逐图像曝光仿射往单位阵拉,防止它顺手把重建误差也吸收进去。§6.2 | |
sharp_shape_reg | 1.0 / 比值 10 | 形状正则,惩罚长短轴比超过 10 的针状高斯,每 10 步算一次。比值 10 以内完全不罚,所以这是个很松的门。第一课 §5 | |
temporal_smooth_reg | 0.01 / 范围 5 | 车辆轨迹的二阶差分惩罚:罚「弯折」,不罚匀速运动。每步随机抽一个 1 到 5 的间隔。只作用在车节点上。§2.4 | |
res_schedule | double_steps / downscale_times | 250 / 2 | 分辨率课程。开局用 1/2² = 1/4 分辨率,每 250 步翻一倍,第 500 步才用上全分辨率。由粗到细,先让几何站稳再谈纹理。第一课 §5.2 |
Background.init | from_lidar.num_samples | 800 000 | 从整段序列聚合的 LiDAR 点里采这么多个当初始高斯,颜色由图像投影取得。§4 |
near_randoms | 100 000 | 场景球内均匀撒的随机点,补 LiDAR 打不到的地方。先按 3 倍数量采,再筛掉所有训练相机都看不见的。§4 | |
far_randoms | 100 000 | 同上,但在 \(1/r\) 上均匀采样而不是在 \(r\) 上。等价于大量点堆在近处、少量撒到很远,用来管远景和天际线。§4 | |
RigidNodes.init | instance_max_pts | 5000 | 每辆车最多用这么多 LiDAR 点初始化,超了就随机下采样。§3 |
only_moving | true | 是否只给「会动的」车开刚体节点。关掉的话所有标注框都会开节点。§3 | |
traj_length_thres | 1.0 | 判定「会动」的阈值,单位米。比的是逐帧位移之和(累计弧长),不是首尾直线距离。没过线的车不开节点,它的点留在背景里。§3 | |
RigidNodes.optim | ins_translation | 5e−4 → 1e−4 | 每帧每辆车的平移量的学习率,指数衰减。跟踪框有噪声,所以位姿也当参数一起优化。§2 |
ins_rotation | 1e−5 → 5e−6 | 同上,但学的是旋转四元数。它和平移是全场除了 xyz 之外仅有的带衰减的参数组。§2 | |
Sky | resolution | 1024 | 环境立方体贴图每个面的边长。六个面合计 6×1024×1024×3 个可学参数,初值全是 0.5。§6.1 |
optim.all.lr | 0.01 | 天空贴图的学习率,全场第二高,仅次于不透明度的 0.05。§6.1 | |
Affine | embedding_dim | 4 | 每张图一个 4 维外观嵌入。注意是逐图像,不是逐相机。§6.2 |
base_mlp_layer_width | 64 | 把那 4 维嵌入解码成 12 个数(一个 3×4 仿射)的两层 MLP 的隐藏宽度。整个模块零初始化,所以训练一开始它就是恒等变换。§6.2 | |
pixel_affine | False | 仿射矩阵是否逐像素变化。关着,所以整张图共用一个 3×4 矩阵;开了的话嵌入会拼上像素坐标再进 MLP。§6.2 | |
optim.all | lr 1e−5 / wd 1e−6 | 曝光模块自己的学习率与权重衰减。带权重衰减是为了让它在没必要时退回恒等。§6.2 | |
CamPose | optim.all | lr 1e−5 / wd 1e−6 | 相机位姿修正量的学习率。每张图学 9 个数(3 个平移增量 + 6D 表示的旋转增量),零初始化。渲染新视角时这个模块要被旁路掉。§6.3 |
多两类节点:DeformableNodes(骑车人等,用一个共享的形变网络 D=8 W=256,加实例嵌入区分个体;3000 步之后才启用)和 SMPLNodes(行人,绑在 SMPL 模板网格上做线性混合蒙皮)。密度控制那一段两个配置完全一致。你跑的 OmniRe-lite 就是把 SMPL 那一支去掉——SMPL 姿态要靠 4D-Humans 预处理,复现成本高。
回到你自己的实验
这一课里有四条直接落在你手上的实验里。
为什么八个示例场景里有四个的车完全没动
因为 traj_length_thres = 1.0 把它们全滤掉了,它们的 LiDAR 点留在了背景里。你 09-02 那晚的 scan_motion.py 测到的就是这件事。后果是:你八月关于「动态节点」的那批结论,有一半跑在根本没有动态节点的场景上,包括 vehicle_psnr 那一列。新筛的场景 21(100 米)和 22(196 米)才是真正有动态内容的。
为什么背景渲染缓存会掉分,靠软合成才补回来
因为 §1.1:正常路径是所有节点拼成一个数组做一次光栅化,遮挡关系在同一次 alpha 合成里自然解决。缓存背景等于把它拆成两层事后叠加,接触带的硬切换 \(\mathbb{1}[d_{\text{dyn}} \le d_{\text{bg}}]\) 会产生脏梯度。你换成软混合是对症的,但根子上这是一个「绕过了联合光栅化」的代价。
为什么留出相机的未观测区数字要打折扣
§4.1。LiDAR 上色遍历所有相机,留出相机的像素颜色照样漏进初始化点云,而且还影响了哪些点会被保留。这就是你 09-03 早上记下的待办①。
为什么深度权重开到 10 倍几乎没反应
§5。被监督的像素本来就只是稀疏扫描线,权重又只有 0.01,而且是全局统一的标量。放大十倍仍然是均匀地放大,没有把信息送到最需要它的方向上。你 v3 的 M2(按相机 Fisher 的最弱方向加权 LiDAR)针对的正是这个结构性缺陷,而不是权重大小。
❧
自查清单
- 说出场景图的四类高斯节点,以及它们最后是怎么进入光栅化的。
- 写出刚体节点从规范空间到世界系的变换公式,说明
point_ids的作用。 - 说出「会动的车」的判据是什么,不达标的车去了哪里。
- 说出测试帧上车辆位姿是怎么来的。
- 说出曝光仿射是逐什么的,测试时用什么值,为什么评测前要后拟合。
- 说出标准协议切的是时间步还是图片,以及这意味着测的是插值还是外推。
- 场景图 scene graph
- 把场景拆成若干节点,每个节点有自己的坐标系和随时间变化的位姿。渲染时统一变换到世界系。
- 规范空间 canonical space
- 物体自己的坐标系,原点在物体中心。物体的高斯参数存在这里,不随时间变。
- 刚体节点 rigid node
- 形状不变、只做整体平移旋转的节点,用来表示车辆。
- 帧有效性 frame validity
instances_fv,记录每一帧每个实例在不在场。不在场时它的高斯不透明度被乘 0。- 期望深度 expected depth
- \(\sum_i w_i z_i / \sum_i w_i\),沿光线的加权平均深度。gsplat 的
ED模式。 - 环境立方体贴图 environment cube map
- 六个面的纹理,按观察方向查表得到天空颜色。这里是可优化参数,不是固定贴图。
- 曝光仿射 affine appearance transform
- 逐图像的 3×4 色彩变换,用来吸收相机自动曝光造成的亮度差异。测试时退化为所有训练图嵌入的平均。
- 累计弧长 trajectory arc length
- 逐帧位移长度之和。判断一个实例是否「会动」的依据,注意它不等于首尾直线距离。