
做3DGS的第七篇想聊聊一个绕不开的话题数据从哪来。前六篇把3D Gaussian Splatting的原理拆得差不多了从高斯的参数化到优化策略都过了一遍但这套东西真正落地的时候卡住最多人的往往不是理论而是喂给训练管线的那批数据——尤其是想自己拍、自己做一个数据集的时候。以前大家习惯去官方示例里下载现成场景跑个demo网络热门词里那句“3dgs自己制作数据集”其实喊出了很多人的真实需求用自己的相机、拍自己的物体最后还能得到一套稳定的渲染表达器输出。这篇文章就把自制数据集的完整链路和渲染表达器内部怎么工作串起来讲一遍顺便把我在实操中踩过的坑和排查思路一并整理出来。顺便说一句渲染表达器这个词在3DGS里实际指的就是那条从3D高斯到2D像素的可微分渲染管线。它决定了训练时梯度怎么回传、推理时画质怎么呈现。数据集的制作只是前半场渲染表达器才是后半场真正见真章的地方。下面按环节拆开讲。1. 自制数据集的全流程设计1.1 采集设备与拍摄方法3DGS不像传统NeRF那样对位姿估计有极高的鲁棒性要求但它照样吃视角覆盖。我自己用的设备从手机到微单都试过结论是设备不是瓶颈覆盖率和清晰度才是。先说设备要求。手机完全够用但有个前提关闭自动对焦和自动曝光锁定固定焦距、固定白平衡尽量用高帧率视频模式而不是连拍照片。这一点我反复在群里强调很多人直接拿手机录像模式拍一圈物体结果在处理视频帧时发现大量运动模糊和曝光漂移。其实拍照也可以但连拍模式下手机自动调参太激进同一物体连续几张的曝光和色温都会变这对后续COLMAP特征匹配是致命的。再说拍摄路径。绕物体一圈是基础但只绕一圈远远不够。以我拍一个桌面小雕塑为例路径设计大概是这样的先水平绕物体一圈再抬高相机角度绕一圈再降低角度补一圈最后在几个关键角度做缓慢推进和拉远让每个表面点至少被3个不同视角看到。整个拍摄过程的移动要缓慢帧间位移控制在总场景尺寸的5%以内这样视频抽帧后相邻帧才有足够重叠度供特征匹配。有个容易忽略的细节背景。如果物体是透明、反光或者纯色的背景一定要做特征丰富的材质比如贴满报纸或者放一些纸箱。否则COLMAP在前景上找不到特征点时整个场景的位姿估计都会崩塌。我最初用纯色背景拍一个白色石膏像COLMAP直接失败加了背景纹理后一次通过。拍摄时长和帧数方面我一般拍30秒到60秒的1080p或4K视频然后离线抽帧。不同设备、不同场景参数不一样但框架是一致的宁可多拍不可少拍后期可以筛。1.2 帧提取与质量筛选视频拍完下一步是从视频流里抽帧。这一步我用ffmpeg完成命令很简单ffmpeg -i input_video.MP4 -qscale:v 2 -vf fps5 frames/%05d.jpg这里fps5表示每秒抽5帧。30秒视频大概出150帧。这个密度对于中等规模场景是合适的。帧率太低会造成相邻帧重叠度不够太高则冗余帧太多、训练时间暴涨且优化不稳定。抽完帧后不能直接进COLMAP必须做质量筛选。我写过一个简单的清晰度筛选脚本用拉普拉斯方差作指标import cv2 import numpy as np import os frame_dir frames keep_dir frames_filtered os.makedirs(keep_dir, exist_okTrue) for name in sorted(os.listdir(frame_dir)): path os.path.join(frame_dir, name) img cv2.imread(path, cv2.IMREAD_GRAYSCALE) if img is None: continue laplacian_var cv2.Laplacian(img, cv2.CV_64F).var() if laplacian_var 80: # 阈值根据场景调整 cv2.imwrite(os.path.join(keep_dir, name), img)这个阈值需要根据场景内容调整。纹理丰富的场景阈值可以设高一些纹理简单的场景阈值过高会误删正常帧。我第一次处理一个白色墙面场景时阈值设到100结果删了一半帧位姿链条直接断掉。后来降到50就稳定了。另外还有一个筛选维度重复度。绕场景一周时如果相机几乎原地不动连续录了很多帧这些帧对位姿估计没有增量信息反而会拖慢COLMAP。手动筛太累我一般看COLMAP匹配结果里的轨迹图发现轨迹密集扎堆的位置就删掉部分帧。这一步没有标准命令属于经验活但筛选后的数据集能让后面的重建质量明显提升。1.3 COLMAP位姿估计要点帧准备齐了进入位姿估计环节。3DGS官方代码用的是COLMAP常规的流程是自动重建脚本但我强烈建议手动分步执行这样出错时知道问题出在哪一步。先把特征提取和图构建跑一遍colmap feature_extractor --database_path database.db --image_path images --ImageReader.single_camera 1 colmap exhaustive_matcher --database_path database.db colmap mapper --database_path database.db --image_path images --output_path sparse其中--ImageReader.single_camera 1非常关键。它告诉COLMAP所有图片来自同一相机这对用视频抽帧得到的数据集尤其重要。如果不加这个参数COLMAP会假设每张图可能是不同相机拍的把所有图像按不同内参分开标定特征匹配就乱了。mapper跑完后我习惯先做一次全局Bundle Adjustment再导出相机参数。官方代码里的convert.py脚本主要负责把COLMAP的输出转成3DGS训练需要的格式。这里要检查的是输出里的cameras.bin内参是否合理焦距和主点坐标是否在正常范围。如果焦距异常大或者出现负内参基本是特征匹配阶段出了问题需要回看图像质量和拍摄路径。还有一个常见问题是尺度不一致。COLMAP输出的坐标系是无尺度的它只能恢复相对位姿整体尺度是任意的。这对3DGS训练没有本质影响因为高斯场景本身就是在稀疏点云的坐标系里优化出来的渲染结果只要在该坐标系下建模就没问题。但如果想和真实物理尺度对齐就得在重建场景里放置已知尺寸的标定物。2. 渲染表达器的核心原理2.1 3D高斯从世界到相机数据集准备完成后训练阶段的核心就是渲染表达器。我一直认为理解了渲染表达器才算真正理解3DGS否则只是会跑官方脚本的调参工。3DGS场景里每一个高斯不是平面点而是带有协方差矩阵的三维椭球。每个高斯有5组可学习参数中心位置3维、协方差矩阵由缩放向量和旋转四元数表示、颜色一般用球谐系数表示阶数越高越能表达视角相关颜色、不透明度1维。表达器要做的第一件事就是把这组三维椭球投影到二维图像平面上。投影的核心是透视投影变换。相机把3D点投影到2D高斯椭球的协方差矩阵也通过雅可比矩阵做了一次近似变换。这个变换不是简单地把协方差矩阵丢进投影公式而是用投影变换在中心点处的雅可比矩阵来做线性近似cov2d J * W * cov3d * W^T * J^TW是观察方向旋转矩阵J是投影变换雅可比矩阵。这一步相当于把三维椭球在相机成像平面上压成一个二维高斯斑块后续光栅化处理的就是这个斑块。三维高斯的协方差矩阵本身需要约束为半正定矩阵。3DGS的做法不是直接优化矩阵元素而是优化一个缩放向量和一个旋转四元数再组合成协方差矩阵。这么做就是为了保证优化过程中矩阵始终合法不会在梯度下降时出现漂移。这一点和NeRF完全不同NeRF的MLP输出自由度高而3DGS通过参数化设计把约束融进了表征本身。2.2 像素级光栅化与Alpha合成投影完成后每个高斯在图像平面上都对应一个二维高斯分布。接下来要对每个像素做着色。这个过程在图形学里叫splatting就是把高斯斑块像抛雪球一样丢到像素上。渲染表达器逐像素遍历所有覆盖到该像素的高斯然后对这些高斯按深度排序从近到远做alpha混合。每个高斯的贡献不是简单的中心值而是根据二维高斯分布在该像素位置的概率密度加权再乘上该高斯的颜色和不透明度。最终像素颜色是C sum(T_i * alpha_i * c_i) T_i prod_{ji} (1 - alpha_j)这个公式和传统alpha合成完全一致关键却在于逐像素的排序。因为高斯的深度在图像平面上的不同位置可能交叠顺序不一样不能只按中心深度排一次序而要按像素逐一确认顺序这个操作在官方实现里用GPU并行做了tile级排序加细化是渲染表达器性能的命门。之所以叫“可微分渲染表达器”是因为整个过程要支持反向传播。光栅化结果对高斯参数中心、协方差、颜色、不透明度的梯度要一路回传。这样优化器才能知道某个高斯往哪个方向调整会让图像更像真实照片。官方代码里的深度排序是显式操作梯度的传播要靠精心设计的反向算子支持这也是官方工程里最复杂的部分之一。我刚开始看源码时最喜欢做的事情就是盯着光栅化前端的CUDA代码看每个tile如何分配线程、高斯如何按深度入桶。看了几天才缓过来。但如果只是用现成框架训练不理解这些细节也能跑只是出了问题就很难定位。2.3 表达器管线中的关键数据流把渲染表达器拆开看数据流大概是这样的稀疏点云初始化高斯种群颜色初始化为点云颜色不透明度初始化为一个固定值然后每轮迭代随机选一批相机视角渲染出图像和真实图像计算损失反向传播更新所有高斯参数。每隔一定步数还要触发一次自适应控制删除不透明度低于阈值的高斯复制梯度很大的小高斯分裂梯度很大的大高斯。这一套流程里渲染表达器承担了两个看似矛盾的任务一边要输出高质量图像用于计算损失一边要在反向传播时把梯度高效地分配到每个高斯参数上。这两个任务做得好不好直接决定训练速度和最终质量。实际使用中我遇到过渲染表达器输出黑色马赛克的问题后来发现是光栅化时浮点精度不够高斯投影后的范围超出了tile边界。官方版本在新版本里已经做了大量数值稳定性修复所以要做自制数据集我建议直接拉最新代码不要用网上流传的老版本。老版本在特殊视角下很容易出NaN排查起来非常头疼。3. 实操过程从拍摄到首帧渲染3.1 完整命令流程把上面所有环节串起来一次完整的自制数据集实操流程长这样。依然用我拍摄桌面雕塑的例子。拍摄完的视频文件先抽帧然后用筛选脚本过滤模糊帧再把可用帧整理到一个images目录接着跑COLMAP# 1. 抽帧 ffmpeg -i sculpture.mp4 -qscale:v 2 -vf fps5 raw/%05d.jpg # 2. 筛选用上文Python脚本 # 3. COLMAP建稀疏点云 colmap feature_extractor --database_path db.db --image_path images --ImageReader.single_camera 1 colmap exhaustive_matcher --database_path db.db colmap mapper --database_path db.db --image_path images --output_path sparse # 4. 稠密重建可选但推荐做能得到更稳定的初始点云 colmap image_undistorter --image_path images --input_path sparse/0 --output_path dense colmap patch_match_stereo --workspace_path dense colmap stereo_fusion --workspace_path dense --output_path dense/fused.ply # 5. 转成3DGS训练格式 python convert.py --source_path ./sculpture --skip_matching--skip_matching是在已经有colmap输出的情况下跳过匹配步骤的常用参数避免浪费时间重新算。如果是从头跑官方脚本就不要加这个参数。3.2 训练超参数与表达器配置数据集转好后训练命令很简单python train.py -s ./sculpture -m ./output/sculpture --iterations 30000真正影响效果的是train.py内部的超参数这些参数在官方默认值下对大多数场景都可用但自制数据集的场景尺度、内容复杂度和官方示例未必一致需要针对性调整。第一个关键参数是初始化点云的稠密程度。如果COLMAP只生成了一两万个点高斯的初始数量少训练后面要频繁触发复制分裂时间会很长。我一般如果点云少于5万点会考虑用稠密重建得到的fused.ply做初始化这样初始覆盖更均匀收敛快很多。第二个是球谐阶数官方默认是3阶即每个颜色通道4个球谐系数。对于漫反射占主导的场景3阶和2阶几乎没有肉眼区别但显存和训练显存会降一些。对于金属、玻璃这类视角高光明显的物体3阶勉强够用真要细扣反光细节还得更高阶。第三个是lambda_dssim这个感知损失权重默认值是0.2。这个参数控制SSIM损失和L1损失之间的平衡。如果训练的物体边缘清晰但纹理区域模糊可以试着降到0.1或0.05让L1主导细节重建。如果训练结果过度平滑则提高一些。训练步数方面官方默认3万步。对于单一物体我试过1万步就基本收敛。对于大场景3万步可能不够。训练过程中可以盯一下输出目录里保存的训练影像看到loss曲线平了就可以提前停不必盲目等满3万步。3.3 渲染与导出训练完成后渲染表达器除了输出训练好的高斯模型还能专门做一批指定视角的渲染图。官方代码里render.py负责渲染python render.py -m ./output/sculpture --skip_train --skip_test这里--skip_train和--skip_test是跳过训练集和测试集的默认渲染只渲染指定路径或自己指定的视角。如果想导出一段绕场景旋转的动画需要自己准备相机轨迹。方法有很多最简单的做法是从训练时的某几个视角做插值生成一段平滑路径然后逐帧渲染拼视频。我自己常用的导出方式是在训练好的模型上采样一个球面轨迹import numpy as np # 假设半径为5绕Z轴旋转相机看向原点 radii np.ones(240) * 5.0 angles np.linspace(0, 2 * np.pi, 240, endpointFalse) cam_positions np.stack([ radii * np.cos(angles), np.zeros_like(radii), radii * np.sin(angles) ], axis-1) # 写成人可以读的相机参数列表简化示意 for i, pos in enumerate(cam_positions[:3]): print(fview {i}: position({pos[0]:.3f}, {pos[1]:.3f}, {pos[2]:.3f}))实际渲染动画时需要把每一帧的相机参数写入特定格式传给render.py不同版本的代码对相机轨迹文件的格式要求略有差异这个直接看对应仓库的示例即可。4. 常见问题与排查技巧实录4.1 数据集问题自查清单自制数据集的坑90%都出在拍摄和位姿估计阶段。我整理了一个问题对照表基本涵盖了日常能见到的绝大多数情况。现象可能原因处理建议COLMAP匹配不到特征点画面纹理太少、运动模糊、过曝加背景纹理、降低帧率、检查曝光设置稀疏点云断裂成几块拍摄视角跳变太大、邻帧重叠不足放慢移动速度增加密度缩小帧间位移重建出来的场景尺度特别小COLMAP无尺度定位正常现象不影响训练如需真实尺寸需加标定物训练后画面糊但结构对初始化点云太少或训练步数不够用稠密点云初始化、拉长迭代、调低dssim权重出现了明显的漂浮物背景高反光或透明物体被建模分割背景、或用掩码约束训练这套清单我贴在工位上很久了。每次数据集出了问题先对着清单排查一遍往往能省下大量反复训练的时间。4.2 渲染表达器的显存与速度瓶颈自制数据集的常见痛点是显存不够。渲染表达器在训练时要保存当前所有高斯的参数、梯度同时要在光栅化时维护必要的中间状态显存消耗比NeRF的MLP推理还要高一些。我试过在8G显存的卡上训练一个室内大场景官方默认的超参直接OOM。解决办法是缩小tile大小、降低光栅化的并行块数或者砍掉部分点的最大数量。但其实更实际的做法是先对数据集做一次降采样把图像尺寸从2K降到1.2K效果差距肉眼几乎看不出来显存需求却能降一半。速度方面影响最大的是高斯数量而不是图像分辨率。10万高斯的场景训练速度明显快于50万高斯的场景。所以如果只是做初步验证可以在初始化阶段随机采样一半点云先把管线跑通再上全量数据做最终训练。这一点和传统渲染管线不太一样很多人第一次用3DGS时不适应总觉得是代码没优化好其实是桶里高斯太多光栅化和梯度回传都成了瓶颈。4.3 训练过程中的NaN排查训练过程中最让人崩溃的问题就是loss突然变成NaN然后所有输出变成一片噪点。这类问题在近一年的官方更新里已经大幅减少但自制数据集还是可能复现。我见过的主力案例有两类。一类是球谐阶数过高导致数值不稳定。在视角剧烈变化或图像过曝光的情况下高阶球谐系数的反向传播梯度很容易溢出。解决方法是先降到0阶或1阶跑一遍确认稳定后再调高。另一类是相机内参异常。COLMAP在某些极端角度下会估计出不合理的畸变参数训练时渲染出来的图像在边缘处产生剧烈扭曲反馈回来的梯度也就极不稳定。这种情况下光调超参数没用要回头检查cameras.bin里的内参和畸变系数必要时手动修正后再转格式。排查NaN还有个技巧用固定随机种子训练。官方代码里可以通过参数固定随机种子这样每次跑的结果可复现。如果某次跑到5000步出现NaN用相同种子重跑一遍把batch size调小一点或者把学习率降一点往往就能跳过那个不稳定区间。有朋友问过我为什么他的场景每次训练到不同步数都会NaN排除了数据和代码问题后发现是显卡驱动导致的浮点异常换了驱动版本就正常了。这种问题属于环境问题排查成本高只能靠系统化验证。5. 一点个人经验我刚开始做3DGS自制数据集时犯过的最大的错误就是跳过了质量筛选直接把所有视频帧丢给COLMAP。结果特征匹配花了四个小时出来的位姿还是错的。后来强制自己在数据环节多做一步筛选、降尺寸、检查轨迹后续训练反而快了一大截。数据集的功夫花在拍摄阶段回报远大于花在调参阶段。最后再分享一个实用小技巧。训练结束后如果想快速判断渲染表达器是否正常工作不要只看测试集PSNR。把训练过程中保存的中间帧导出来盯住某一帧看第1000步、第5000步、第10000步时这一帧的细节变化。如果高光、边缘、纹理逐步变清晰说明梯度传导正常数据集也没有系统性问题。如果中间帧出现闪烁或者局部区域从清晰变模糊那就是优化过程出了问题这时回到数据集排查比在渲染器里折腾要高效得多。这套检查习惯我沿用到现在几乎所有自制数据集的场景都能用它快速定位问题。