3DGS 模型保存与加载完整指南:PLY 格式扩展与断点续训实战
【免费下载链接】gaussian-splattingOriginal reference implementation of "3D Gaussian Splatting for Real-Time Radiance Field Rendering"项目地址: https://gitcode.com/gh_mirrors/ga/gaussian-splatting
跑 3D Gaussian Splatting(3DGS)训练的人大概都经历过这种时刻:迭代到 3 万步,机器断电 / 显存爆了 / 被同事的脚本抢卡。训练目录里只有一个 .ply,重新加载后发现优化器状态全丢,等于从"半成品"倒退回"刚起步"。其实 3DGS 官方仓库 gaussian-splatting 的模型持久化方案分两条线:PLY 文件负责把模型参数完整落盘,checkpoint 文件负责把优化器状态落盘。本文沿着"存了什么 → 怎么存 → 怎么加载 → 怎么续训 → 体积怎么省"这条线,把 scene/gaussian_model.py 里的保存与加载机制讲透,帮你把崩溃后的损失降到最低。
3DGS 模型文件到底存了什么:和网格模型差在哪
先说人话:网格模型(OBJ/GLB)存的是"顶点 + 面片拓扑",而一个 3DGS 模型存的是几百万个可变形椭球,每个椭球自带一套参数——位置、三个方向的缩放、朝向四元数、不透明度,再加一组球谐(SH)系数来描述"从不同角度看它是什么颜色"。
这决定了它的模型文件没法套用标准网格格式。官方在 scene/gaussian_model.py 的construct_list_of_attributes里给出了完整清单,一张表对比清楚:
| 参数 | 字段(PLY 列名) | 个数 | 作用 |
|---|---|---|---|
| 椭球中心 | xyz | 3 | 空间坐标 |
| 法向量 | nxnynz | 3 | 纯占位,值全为 0 |
| 球谐 DC 项 | f_dc_0~2 | 3 | 基础颜色(视角无关部分) |
| 球谐高阶项 | f_rest_0~44 | 3×(L²−1),L=4 时为 45 | 随视角变化的颜色 |
| 不透明度 | opacity | 1 | 椭球"实体感"强度 |
| 缩放 | scale_0~2 | 3 | 三轴半径 |
| 旋转 | rot_0~3 | 4 | 单位四元数定朝向 |
合计23 个属性,全部是 float32。注意后四类参数(缩放、旋转、不透明度、SH 系数)训练时会被持续优化,所以文件必须把它们的当前值原样存下来——这正是save_ply的职责。
PLY 格式怎么扩展的:23 个属性背后的一个关键取舍
PLY 本质是一张可自定义列的表格文件:element vertex声明有哪些列,后面每行一个顶点数据。3DGS 没有发明新格式,而是像给表格追加新列一样,把 23 个参数全部挂成 vertex 的自定义属性。save_ply的骨架只有四步:
xyz = self._xyz.detach().cpu().numpy() normals = np.zeros_like(xyz) # 法线列只作占位 f_dc = self._features_dc.detach().transpose(1, 2).flatten(start_dim=1).cpu().numpy() f_rest = self._features_rest.detach().transpose(1, 2).flatten(start_dim=1).cpu().numpy() scale = self._scaling.detach().cpu().numpy() rotation = self._rotation.detach().cpu().numpy() # ...把各数组沿 axis=1 拼接成 (N, 23) 后,用 PlyData 逐行写入这里有个容易忽略的关键设计:所有参数存的都是原始优化值,不是渲染时用的激活值。缩放存的是exp之前的对数空间值,不透明度存的是sigmoid之前的值。为什么?因为模型加载后往往还要继续训练,参数必须和nn.Parameter里的值一一对应才能接上梯度;若存激活后的值,反推回去就有精度损失,续训直接"变味"。
顺带算笔账:100 万高斯的 PLY 约100MB(每个高斯平均 100 字节左右);千万级场景则轻松突破1GB——这个数字后面讲省空间技巧时会再用到。
3DGS 模型加载:load_ply 如何把文件还原成可训练的模型
加载是保存的逆过程,load_ply里有两个值得注意的细节。
第一个:SH 系数数量要动态解析。高阶球谐列数取决于阶数 L(3*(L²−1)),文件里有多少列不能靠假设,得扫描出来再核对:
extra_f_names = [p.name for p in plydata.elements[0].properties if p.name.startswith("f_rest_")] extra_f_names = sorted(extra_f_names, key=lambda x: int(x.split('_')[-1])) assert len(extra_f_names) == 3*(self.max_sh_degree + 1) ** 2 - 3这里藏着最常见的坑:如果加载时配置的max_sh_degree和训练保存时不一致,这个assert会直接炸(或者炸之前渲染出怪颜色)。scale_、rot_两组列用同样的"前缀扫描 + 数字后缀排序"方式解析。
第二个:张量重组。解析出的 numpy 数组统一包成nn.Parameter并requires_grad_(True),直接建在 CUDA 上,比如:
self._features_rest = nn.Parameter( torch.tensor(features_extra, dtype=torch.float, device="cuda") .transpose(1, 2).contiguous().requires_grad_(True))SH 系数在文件里是每行展平的一串数,加载时reshape回(N, 3, L²−1)再转置成(N, 1, C)的布局,和create_from_pcd初始化时的形状对齐。函数最后一行self.active_sh_degree = self.max_sh_degree也很实在:既然文件里存的是满阶系数,加载后直接拉满。
3DGS 断点续训:capture 与 restore 的双持久化机制
回到开头的痛点:训练中断后怎么接着跑?答案是两个文件分工。PLY 只存参数;而 Adam 优化器的动量exp_avg、二阶矩exp_avg_sq,以及自适应点增删用到的xyz_gradient_accum、denom这些"训练记忆",都不在 PLY 里。
train.py 的保存逻辑是:每到--checkpoint_iterations指定的迭代,就把gaussians.capture()的返回值连同迭代号一起存成.pth:
# train.py 中,iteration 命中 checkpoint_iterations 时: torch.save((gaussians.capture(), iteration), scene.model_path + "/chkpnt" + str(iteration) + ".pth")capture()在 scene/gaussian_model.py 里返回一个 12 元组,成员按职责分三类:
| 类别 | 成员 | 用途 |
|---|---|---|
| 模型参数 | _xyz_features_dc_features_rest_scaling_rotation_opacity | 权重本体 |
| 训练记忆 | max_radii2D、xyz_gradient_accum、denom、active_sh_degree、spatial_lr_scale | 点云增删与学习率调度 |
| 优化器 | optimizer.state_dict() | 恢复 Adam 动量与二阶矩 |
恢复侧是restore(model_args, training_args):解包元组后先调training_setup重建优化器,再self.optimizer.load_state_dict(opt_dict)把动量灌回去。启动时加--start_checkpoint path/chkpnt30000.pth,train.py 里就会torch.load后执行gaussians.restore(model_params, opt),从对应迭代继续——动量不丢,收敛曲线能无缝接上。
高斯模型文件体积怎么优化:千万级高斯的三个方向
千万高斯的 PLY 超过 1GB,存储和加载都会疼。三个可落地的方向:
- 量化压缩:
features_dc/features_rest默认 float32,改用numpy.astype(np.float16)落盘可省一半体积,代价是加载后转回 float32 有一丁点精度损失。 - 空间分区:把点云按空间位置切成多个 PLY 文件,部署时只加载视锥相关的块,加载与渲染都能减负:
# 按坐标块切分,每块独立写一个 PLY def save_partitioned_ply(xyz, params, partition_size=1e6): for i in range(0, xyz.shape[0], partition_size): save_ply(f"model_part_{i//partition_size}.ply", xyz[i:i+partition_size], params[i:i+partition_size])- 增量更新:只存相对基线模型的 diff 文件,适合高频迭代的场景版本管理。
加载侧则推荐内存映射延迟读入,避免大文件一次性占满内存:
plydata = PlyData.read(path) # 先读元数据 xyz = np.memmap(path, dtype='f4', mode='r', offset=plydata.elements[0].data.offset, shape=(plydata.elements[0].count, 3))三条策略可叠加:先 prune 瘦身,再分区,最后按需 memmap 加载,千万级场景的磁盘和内存压力都能压到可接受范围。
3DGS 加载后渲染异常 / 续训失败的常见坑速查
| 现象 | 可能原因 | 解法 |
|---|---|---|
| 加载后颜色发灰 / 发怪 | max_sh_degree与保存时不一致,SH 系数维度对不上 | 统一 SH 阶数配置再加载 |
| 优化器状态加载报错 | 中途做过点云修剪,参数数量和快照不匹配 | 修剪走prune_points/_prune_optimizer,同步剪 optimizer 状态 |
| PLY 体积过大 | 大量低贡献高斯没被清掉 | 保存前执行prune_points,按不透明度阈值 0.01 过滤 |
| 大模型加载卡内存 | 整个文件一次读入 | 分块 PLY +numpy.memmap延迟加载 |
最后一行效果直观对比一下:低质量阶段的渲染(左)和收敛后的渲染(右)差距明显——所以体积优化时别为了省空间把"有用的高斯"也剪没了。
小结
一句话收束:PLY 存参数,.pth 存记忆——save_ply落盘 23 个 float32 属性且坚持存原始优化值,capture/restore负责把 Adam 动量和训练统计量完整续上;体积问题交给 prune(阈值 0.01)、量化、分区和 memmap 组合拳。随着实时渲染需求增长,更紧凑的专用二进制格式与硬件加速的加载路径大概率会登场,但理解这套 PLY + checkpoint 的双持久化机制,永远是你玩转 3DGS 工程化的基本功。
【免费下载链接】gaussian-splattingOriginal reference implementation of "3D Gaussian Splatting for Real-Time Radiance Field Rendering"项目地址: https://gitcode.com/gh_mirrors/ga/gaussian-splatting
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考