3D Gaussian Splatting 模型保存/加载全解:PLY 属性扩展与优化器断点续训
【免费下载链接】gaussian-splattingOriginal reference implementation of "3D Gaussian Splatting for Real-Time Radiance Field Rendering"项目地址: https://gitcode.com/gh_mirrors/ga/gaussian-splatting
训练跑了几小时,眼看快收尾机器却宕机、进程被 kill——40 万步的进度怎么不丢?这正是 3D Gaussian Splatting(3DGS)模型保存/加载问题的核心,官方实现把答案写在两种文件里:一份扩展了高斯参数的 PLY,一份捕获优化器完整状态的 .pth。本文以 3DGS 官方参考实现 gaussian-splatting 为准:核心代码集中在scene/gaussian_model.py的GaussianModel类,保存与加载的触发点在train.py的训练循环里,下面把整条链路拆开走一遍。
3DGS 到底要存什么——参数全景
先盘家底。一个高斯不只是一个点,它带着一整组连续参数:位置、协方差(官方拆成三轴缩放 + 旋转四元数表达)、逐视角颜色(球谐 SH 系数)、不透明度。在代码里它们是六组张量:_xyz、_features_dc、_features_rest、_opacity、_scaling、_rotation,PLY 的顶点属性表就是按它们的形状逐项生成的。
| 字段前缀 | 数量 | 物理含义 | 存储阶段 |
|---|---|---|---|
| x / y / z | 3 | 高斯中心三维坐标 | 原始优化值 |
| f_dc_ | 3 | SH 0 阶(DC)系数,即基色 | 原始优化值 |
| f_rest_ | 3×(L²−1) | SH 高阶系数,L 为 max_sh_degree | 原始优化值 |
| scale_ | 3 | 三轴缩放,存对数空间 | 对数空间原始值 |
| rot_ | 4 | 未归一化旋转四元数 | 归一化前原始值 |
| opacity | 1 | 每个高斯的不透明度 | sigmoid 前原始值 |
重点在"存储阶段"这一列:全部是原始优化值。gaussian_model.py的setup_functions里,激活函数是单独注册的:
self.scaling_activation = torch.exp # _scaling 存的是对数空间原始值 self.opacity_activation = torch.sigmoid # _opacity 存的是 sigmoid 前原始值 self.rotation_activation = torch.nn.functional.normalize真实的缩放、不透明度、归一化四元数,都是渲染时经过get_scaling、get_opacity、get_rotation这些 property 即时激活出来的。这样设计有两层好处:其一,磁盘里的值和内存里的值完全一致,加载后直接requires_grad_(True)就能继续训练,无需任何换算;其二,优化器(Adam)存的是针对原始值的梯度一、二阶矩估计,只有存原始值,动量链才能一致恢复。存激活后的值,矩状态和恢复出来的参数就对不上了,续训越训越发散。
标准 PLY 属性为什么不够用
标准 PLY 是为多边形网格设计的,顶点字段基本是 x/y/z、法向和颜色。3DGS 的球谐系数、对数缩放、四元数没有标准字段可放,官方实现就把它们全部塞进 vertex 元素的自定义 float 属性,下游查看器和工具都得自行识别这些扩展字段。法向那三个字段则是纯占位——高斯本身没有法向概念,放着只是为了凑格式。
💾 写出磁盘:save_ply 三步拆解
save_ply只干三件事,顺序是:提取张量、拼接属性、写 PlyElement。
第一步:张量提取,所有参数.detach().cpu().numpy()搬离 GPU,法向槽位补零:
xyz = self._xyz.detach().cpu().numpy() normals = np.zeros_like(xyz) # 补 0 占住法向槽位 f_dc = self._features_dc.detach().transpose(1, 2).flatten(start_dim=1).cpu().numpy()注意 transpose(1, 2) + flatten 这两步:SH 系数的张量布局是 (N, 3 通道, C 系数),PLY 是行式数据,必须转置成每个高斯一行 (N, 3C),否则通道轴和系数轴就串了。
第二步:属性拼接,字段名列表与 dtype 由construct_list_of_attributes按张量形状动态生成,拼接顺序固定:
dtype_full = [(a, 'f4') for a in self.construct_list_of_attributes()] attributes = np.concatenate((xyz, normals, f_dc, f_rest, opacities, scale, rotation), axis=1) elements[:] = list(map(tuple, attributes))这里所有字段统一 'f4'(float32),f_rest_ 的个数会随 sh_degree 自然变多,PLY 文件里因此隐含记录了当时的球谐阶数。
第三步:PlyElement 写入:
el = PlyElement.describe(elements, 'vertex') PlyData([el]).write(path)💡 量级感知:默认 SH 3 阶时每个高斯 62 个 float32 字段(含 3 个法向占位),约 248 B,百万高斯的模型 PLY 在 250 MB 量级;若降到 1 阶,每个高斯缩到约 104 B,百万高斯文件约 100 MB。
读回内存:load_ply 怎么重组 SH 系数、capture/restore 怎么续训
读回最费劲的是 SH 高阶系数。DC 分量固定 3 个,直接填就行;f_rest_ 的个数却取决于max_sh_degree,且 PLY 头里属性的顺序并不保证和保存时一致,所以load_ply按前缀扫属性、按下标排序,先做断言:
assert len(extra_f_names) == 3 * (self.max_sh_degree + 1) ** 2 - 3 features_extra = features_extra.reshape((features_extra.shape[0], 3, (self.max_sh_degree + 1) ** 2 - 1))这一行把 (N, 3×15) 拍回 (N, 3, 15),是"加载后颜色异常"问题的根源所在。scale_ 和 rot_ 字段同样按前缀收集排序,最后所有参数包成nn.Parameter并requires_grad_(True),方法收尾处还有self.active_sh_degree = self.max_sh_degree——意思是加载后模型立即全阶激活,不再逐步升阶。
但 PLY 只有参数、没有优化器,续训靠它不够。官方实现有第二条通道:capture()把六组参数、max_radii2D、稠密化记账张量xyz_gradient_accum/denom连同optimizer.state_dict()一起打成元组,train.py里存成 checkpoint:
torch.save((gaussians.capture(), iteration), scene.model_path + "/chkpnt" + str(iteration) + ".pth")恢复时restore先重新跑一遍training_setup建出新的 Adam,然后:
self.optimizer.load_state_dict(opt_dict) # exp_avg / exp_avg_sq 全部还原注意 checkpoint 里连带 iteration 一起存了,train.py读回后训练循环直接从该步继续;restore还会把xyz_gradient_accum和denom放回来,这两个是稠密化策略的记账变量,漏掉它们 clone/split 的节奏会乱。
两条通道定位一目了然:
| 维度 | save_ply(.ply) | capture → .pth |
|---|---|---|
| 存的内容 | 6 组模型参数 | 参数 + optimizer state_dict + 稠密化记账 |
| 面向场景 | 部署渲染、模型交换 | 仅断点续训 |
| 触发时机 | train.py的 saving_iterations | train.py的 checkpoint_iterations |
| 恢复入口 | load_ply(无优化器) | restore+load_state_dict |
上规模之后的存储与加载优化
量化降精度:先压 SH 系数
SH 系数是体量大头(3 阶时 f_rest 占 62 个字段中的 45 个),且对精度最不敏感;坐标和缩放敏感,保留 float32。先试features.astype(np.float16)把 f_dc / f_rest 压半精度,存储直接减半;再往下做 int8 定点则要带零点和 scale,PLY 头里的 'f4' 类型也要一并改。
空间分块:大场景切成多个子 PLY
千万高斯一个 PLY 超过 2 GB,写和读都难受。思路是按空间(网格或八叉树)分区,输出多个自包含的子 PLY,命名成model_part_{i}.ply之类,渲染时按视锥剔除挑块加载。可以参照scene/dataset_readers.py整场加载点云的做法,扩展成"按块编号加载",块文件之间可并行读写与传输。
内存映射:np.memmap 按需读
PLY 是文本头 + 二进制 float 体,解析完头之后不必走PlyData.read整块入内存,直接映射数据区:np.memmap(path, dtype=np.float32, mode='r', offset=header_size, shape=(N, F)),用哪行读哪行。极端情况下先只读 xyz 列做粗剔除,再对可见高斯回读完整字段。
⚠️ 踩坑速查
| 现象 | 根因 | 处理 |
|---|---|---|
| 加载后报 assert / 颜色发灰、偏色 | 加载与保存的 sh_degree 不一致,f_rest_ 个数对不上 3(L+1)²−3 | 保证--sh_degree与保存时一致,重训或重新导出 |
load_state_dictshape mismatch | 高斯数量变过(稠密化/剪枝),PLY 与 .pth 不同步 | 只用 .pth 续训(参数与优化器同源),别拿 PLY 配旧优化器状态 |
| PLY 文件过大 | 默认 SH 3 阶保存、百万级高斯,每高斯 |
【免费下载链接】gaussian-splattingOriginal reference implementation of "3D Gaussian Splatting for Real-Time Radiance Field Rendering"项目地址: https://gitcode.com/gh_mirrors/ga/gaussian-splatting
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考