第06章 - 构建 3DGS

把一段手机环拍视频变成能在浏览器里自由旋转、可量测、可交付的三维场景,这件事在 2024 年之前还是研究组的活。3D Gaussian Splatting(3DGS)把训练时间从 NeRF 的小时级压到分钟级,把渲染从离线合成压到实时,门槛随之从「有没有 GPU」变成了「拍得对不对、坐标对得齐不齐」。这一章就沿着这条真实工程链路走一遍:先讲清 3DGS 表示与渲染为什么快,再给采集规则和 COLMAP 组网命令,然后给两条重建路线——稳的 COLMAP 与快的 VGGT 前馈,接着用 brush 训练、用 SuperSplat 收尾编辑,最后把模型对齐到真实坐标系、评估精度、做成可交付的网页查看器,并用 DeepSeek Harness(DSH)把整条链路串成可批跑的脚本。中途还会交代 2026 年分化的 3DGS 变体、gsplat 与 brush 的分工,以及把 splat 切成带 LOD 的 3D Tiles 进三维地球的路径。

读完之后你应该能独立完成:一段 1~3 分钟的视频抽帧 → COLMAP 稀疏重建并判断注册率是否达标 → brush 训练出 ply/compsplat → SuperSplat 去浮点补洞 → 按控制点做 7 参数对齐 → 导出可嵌入页面的查看器,并用 DSH headless 模式批量跑完多个场景。

学习目标:掌握 3DGS 的表示、渲染与致密化机制的关键参数;能用 COLMAP 命令序列产出合格稀疏模型并读懂质量指标;能用 VGGT 前馈位姿替代 SfM 并转换到 COLMAP 格式;能用 brush 完成三档训练配置;能用 SuperSplat 清理并导出可发布格式;能完成控制点 7 参数对齐与检查点残差评估;能用 DSH 编排与排错整条流水线;能判断 2DGS、3DGUT、MCMC 等变体的适用性;能把 splat 交付成带 LOD 的 3D Tiles 前置知识:Python 3.10+、能读懂 PyTorch 张量与 CUDA 显存报错;基本摄影测量概念(内参、外参、重投影误差、控制点);命令行与 Git;一台带 NVIDIA 显卡(8 GB 显存起步,16 GB 更从容)的机器

6.1 3D Gaussian Splatting 原理

一句话概括:3DGS 不训练神经网络去「记住」场景,而是把场景本身变成几百万个可微的彩色椭球,训练就是把这堆椭球的位置、形状、透明度和颜色调到与输入照片一致。渲染时把这些椭球投影成屏幕上的 2D 高斯斑点、按深度混合,没有体渲染那样沿射线做上百次采样,所以快。

6.1.1 场景表示:一组各向异性 3D 高斯

场景里第 i 个高斯用四组参数描述,训练时全部可学:

参数 符号 维度 物理含义 典型初始化
中心位置 μ 3 椭球在场景中的坐标 SfM 稀疏点
协方差 Σ 3×3(对称,6 个独立量) 椭球的朝向与三轴长度 最近 3 个稀疏点距离的均值
不透明度 α 1 该椭球对最终颜色的贡献上限 逆 sigmoid(0.1) ≈ -2.197
球谐系数 SH 3×(阶数+1)² 随视角变化的颜色 从稀疏点 RGB 反算 0 阶项

协方差不能直接优化,因为任意 6 个数拼出来的矩阵不一定是半正定的。工程上拆成「缩放 + 旋转」再重建:

Σ = R · S · Sᵀ · Rᵀ

R:由四元数 q 归一化后转成的 3×3 旋转矩阵
S:diag(sx, sy, sz),三个轴长取 exp(原始参数) 保证恒正
这样 Σ 天然对称半正定,渲染时求逆不会炸

高斯在空间中的贡献是各向异性的指数衰减:

G(x) = exp( -0.5 · (x - μ)ᵀ · Σ⁻¹ · (x - μ) )

球谐颜色(阶数 d,共 (d+1)² 个系数/通道):
C(direction) = Σ_{l=0..d} Σ_{m=-l..l} c_{l,m} · Y_{l,m}(direction)
d=0 只有常数色(无视角变化),d=3 用 16 个系数表达镜面高光

阶数 d 是可调参数,官方实现默认 3,brush 允许 0~4。d 越高越能表现反光与高光,但每颗高斯的系数从 3 个涨到 48 个,显存和 ply 体积同步上涨。玻璃幕墙、金属构件这类高反光对象值得开到 3;土坡、植被这类漫反射主导的场景开到 1~2 肉眼几乎看不出差别,却能省下 20%~30% 显存。

6.1.2 渲染:EWA splatting → 深度排序 → alpha 混合

渲染分三步,全部在 GPU 光栅化里完成:

① 投影(EWA splatting)
   世界坐标 x_w → 相机坐标 x_c = W·x_w + t
   3D 协方差 Σ 投影到图像平面:Σ' = J·W·Σ·Wᵀ·Jᵀ
   J 是透视投影在该点的雅可比,把 3D 椭球近似成 2D 椭圆
② 分块与排序
   屏幕切成 16×16 的 tile,剔除不在视锥内的高斯
   每个 tile 内部按高斯中心的相机空间深度排序(一次全局基数排序)
③ alpha 混合
   逐个高斯做 C = Σ c_i · α_i' · Π_{j<i}(1 - α_j')
   α_i' 是 2D 高斯在该像素的值乘不透明度,前到后累积,可提前终止

对比 NeRF 的体渲染:NeRF 每条射线要采样 64~256 个点、每点跑一次 MLP 前向,1080p 一帧是上亿次网络推理;3DGS 只需要把几百万个高斯投影、排序、混合,没有网络推理。这就是「快一到两个数量级」的来源,不是近似得更糙,而是换了一种可并行的表示。

代价也明确:排序是逐帧的,几百万高斯在显存里占几百 MB,高分辨率下 tile 内排序是主要瓶颈。所以 3DGS 在「显存够、分辨率中等、场景静态」的条件下优势最大;超大场景要靠分块、LOD 或者转成 3D Tiles 切片,不能指望一个 ply 打天下。

6.1.3 训练:从稀疏点出发的可微光栅化

训练循环非常朴素,没有神经网络的反向传播穿过几十层,只有每颗高斯的参数在动:

输入:COLMAP 的 cameras/images/points3D + 原始图片
① 初始化:每颗高斯的位置 = 一个 SfM 稀疏点,颜色 = 该点 RGB
② for iter in 1..N:
     a. 随机取一张训练视图
     b. 可微光栅化,得到渲染图 I_render
     c. 损失 L = (1 - λ)·L1(I_render, I_gt) + λ·(1 - SSIM(I_render, I_gt))
        官方实现 λ = 0.2
     d. 反传,更新 μ、缩放、旋转、α、SH
     e. 每 densification_interval 步执行自适应致密化与剪枝
③ 导出 ply(或压缩格式)

致密化的判据是「屏幕空间梯度」:如果一个高斯在视图空间的位置梯度超过阈值,说明它覆盖的区域重建不足,就克隆(小高斯)或分裂(大高斯);同时把不透明度低于阈值的、尺寸超过场景范围的剪掉,避免无限膨胀。这一机制是 3DGS 质量的核心,也是显存爆炸的主要来源——参数配错时高斯数量会从 100 万涨到 1000 万。

下面这张表是 3DGS 官方实现(graphdeco-inria)的默认值,brush 的对应参数名不同、量级也不同,6.4 节会单独给对照:

参数 官方默认 作用 调大的后果
iterations 30000 总优化步数 更久、边际收益递减
sh_degree 3 球谐阶数 显存与体积上升,反光更准
densify_from_iter 500 开始致密化的步数 前期结构更稀疏
densify_until_iter 15000 停止致密化的步数 后段继续加高斯,易过拟合
densification_interval 100 致密化间隔 增长更快、更易爆显存
densify_grad_threshold 0.0002 梯度阈值 高斯更多、细节更多
opacity_reset_interval 3000 不透明度重置周期 抑制近处漂浮物
percent_dense 0.01 判定「大高斯」用于分裂的比例 分裂更激进
lambda_dssim 0.2 SSIM 损失权重 更看重结构相似性
position_lr_init 0.00016 位置初始学习率 收敛快但易抖
feature_lr 0.0025 SH 颜色学习率 颜色拟合更快
opacity_lr 0.025 不透明度学习率 透明/不透明切换更快
scaling_lr 0.005 缩放学习率 椭球尺寸变化更快
rotation_lr 0.001 旋转学习率 朝向调整更快

显存不够时的降级优先级,按「对画质伤害从小到大」排:

第 1 步:降输入分辨率(-r 2 或 --max-resolution 1280)
        伤害最小,训练与渲染都省,细节损失可接受
第 2 步:降 SH 阶数(3 → 2 或 1)
        省 20%~30% 显存,漫反射场景几乎无损
第 3 步:降总迭代数(30000 → 15000)
        收敛略欠,但比训练中途 OOM 强
第 4 步:降最大高斯数上限(brush 的 --max-splats)
        会牺牲远处细节,近景基本保住
第 5 步:缩小训练视图数量(--subsample-frames 2)
        最后手段,位姿覆盖变差可能反而更糊

坑:① 用 –sh_degree 0 省显存又想要反光,结果玻璃幕墙变成一坨灰——阶数决定视角相关颜色,反光场景至少留 2 阶。② 把 densify_grad_threshold 调小以为「更精细」,实际高斯数量翻倍、训练后期 OOM,正确做法是先确认稀疏点质量再动阈值。③ 用 CPU 训练,30000 步能跑一整天,3DGS 训练必须 CUDA。④ 把 ply 直接拖进浏览器查看器,几百万高斯未压缩,加载要几分钟,发布前必须转压缩格式。

6.1.4 与 NeRF / 网格 / 点云的取舍

选表示方式不是信仰问题,看交付要求。下面这张表按 GIS 项目的真实约束排:

维度 3DGS NeRF 倾斜摄影网格 激光点云
表示 显式高斯集合 隐式 MLP / 哈希网格 三角面 + 纹理 离散 XYZ + 属性
训练/重建时长 10 min~1 h(单卡) 1~24 h(单卡) 1~8 h(集群) 采多少算多少
训练显存 6~24 GB,随高斯数增长 8~24 GB 主要吃内存与磁盘 几乎不占显存
渲染帧率(1080p) 30~120 fps 0.1~5 fps 数百 fps(无光照烘焙) 数十 fps(点数大时下降)
视角相关效果 好(SH 表达反光) 好 差(纹理烘焙死) 无
几何精度 中(表面由椭球近似) 中 高(有真实三角面) 最高(有回波精度)
编辑难度 中(可用编辑器删高斯) 高(隐式,难局部编辑) 低(网格通用) 低(点级操作)
体积(中等场景) 200 MB~2 GB 100 MB~1 GB 500 MB~5 GB 1 GB~20 GB
GIS 平台兼容 弱,需转换或独立查看器 弱 强(OSGB/3D Tiles 成熟) 强(LAS/LAZ 全链路)
量测 需额外做深度/平面拟合 需额外做 直接量三角面 直接量点

判断依据可以固化成三条:

  • 要「好看、能实时转、能发网页」:3DGS。巡检对比、房产展示、文旅导览都属于这类。
  • 要「量得准、进 GIS 库、出断面」:点云或网格。3DGS 的表面是概率密度,不适合直接当测量基准。
  • 要「既有观感又能量测」:3DGS 做展示层,点云/网格做量测层,两套数据用同一套控制点对齐后叠加。

这也是本章最后一节要处理的问题:3DGS 单独一个 ply 文件进不了 Cesium 这类三维地球,必须解决坐标系与切片。

6.1.5 2026 年的 3DGS 家族:变体与测绘级选型

原版 3DGS 之后,社区把它的假设逐条拆开重做。到 2026 年,落地项目里常见的组合是「原版 + 四个分支」,彼此不是取代关系,每个分支各补原版的一个硬伤:

  • 原版 3DGS(INRIA,graphdeco-inria):各向异性 3D 椭球 + EWA 投影,所有变体的基线,工具链最全、公开权重最多。
  • 2DGS:把 3D 椭球压成 2D 圆盘(surfel),表面更薄、法向更稳,几何可信度明显好于原版;代价是反光与体积感略弱。
  • 3DGUT:投影一步不再用泰勒一阶近似,改用无迹变换(Unscented Transform)传播高斯,因此能直接吃带畸变的相机模型(鱼眼、广角、部分全景)和二次光线,不必先跑 image_undistorter。
  • MCMC 致密化:把克隆/分裂/剪枝换成 MCMC 采样,低不透明度的高斯被随机搬运而不是直接删掉,高斯数不再随迭代单调爆炸,稀疏视角下塌陷更少。
  • 稀疏 3DGS 光栅化:光栅化阶段按 tile 稀疏遍历,跳过没有高斯覆盖的 tile,显存带宽与峰值占用同时下降,是大场景分块训练的底座。

各变体的定位与代价:

变体 核心改动 修掉的硬伤 代价 测绘级重建里的位置
原版 3DGS 3D 各向异性椭球 + EWA 无(基线) 高斯数易失控、表面偏软 展示层可用,不做量测基准
2DGS 圆盘 surfel + 法向约束 表面厚度大、法向不可靠 反光与掠射角表现略差 立面平整度、断面、网格提取
3DGUT 无迹变换投影 必须先做去畸变 训练略慢、可用实现少 鱼眼/广角原图直接训练
MCMC 致密化 采样搬移替代剪枝 高斯数失控、稀疏视角塌陷 收敛判据不同,步数要重调 控制点少、快拍场景
稀疏光栅化 tile 稀疏遍历 显存与带宽瓶颈 实现复杂、可调参数少 城市级分块训练

落到测绘项目上,选型按交付要求反推,不按新论文换工具:

交付要求 选它 理由 别用
立面平整度、断面、抽 mesh 2DGS 圆盘表面更紧、法向可直接用 原版硬抽 mesh
鱼眼/全景/广角原片,不想去畸变 3DGUT 畸变在投影里建模,省一次重采样 先 undistort 再训
控制点少、快拍、怕高斯数爆炸 MCMC 致密化 采样式增长比阈值剪枝更受控 原版调小 densify 阈值硬压
城市级、要多卡分块 稀疏光栅化(gsplat 路线) 显存与带宽可分摊 单卡单进程刷千万级高斯
只要求好看、能发网页 原版 3DGS 生态最成熟、调参资料最多 为「新」换变体

三条实操结论:

  • 测绘级评估的第一指标是几何,不是 PSNR。2DGS 在平整立面、台阶、栏杆这类平面结构上的法向误差通常小于原版,具体幅度取决于场景与实现,验收时用检查点或点云套合去量,不要拿渲染图好看与否下结论。
  • 有鱼眼或广角素材时,先试 3DGUT 再动 undistort。去畸变会裁掉边缘像素并重采样一次,而大视场恰恰是最容易被这一步丢掉的信息。
  • 变体选择绑定训练器:gsplat 在一套 API 里同时给原版、MCMC、3DGUT 与稀疏光栅化(6.1.6),brush 走的是原版光栅化加稀疏化的工程实现。换变体等于换工具链,换来换去会同时丢掉两套调参经验。

坑:① 以为 2DGS 是「更省的高斯方案」,它省的是表面厚度与法向噪声,不是高斯数量,显存该吃多少还是多少。② 数据已经 undistort 过还上 3DGUT,等于把畸变抹平后再让模型学一遍,白花时间。③ 用原版的 PSNR-迭代数曲线判断 MCMC 是否收敛,MCMC 的增长受采样温度影响,曲线形状不同,判据要重设。④ 只看 PSNR 选变体——原版往往 PSNR 最高,但它的表面最软,拿它做断面会得到一片毛刺。

6.1.6 gsplat 1.6:PyTorch 侧的工业级实现

gsplat 源自 nerfstudio 生态,2026 年的 v1.6.0 已经不只是「官方实现的加速重写」,它把上面几个变体收进同一套 Python API:

  • 稀疏 3DGS 光栅化:tile 稀疏遍历,显存峰值低于稠密实现,大场景切块后可以并行训练。
  • 多 GPU 稠密训练:高斯与图像按卡分摊,城市级场景不用再靠「单卡降分辨率」硬撑。
  • PPISP 后处理:渲染后再过一层与相机 ISP 行为对齐的后处理,多机位、多曝光素材的色调更容易统一;具体模块名与开关以 docs.gsplat.studio 的 examples 为准。
  • 3DGUT 传感器支持:鱼眼、畸变、正交相机都能直接进训练,和 6.1.5 的 3DGUT 是同一套能力。
  • MCMC 致密化策略:策略层同时提供默认增长与 MCMC 两种实现,换策略不用换训练脚本。

构建门槛要先确认,否则 pip install 会停在编译阶段:

# 环境基线:PyTorch 2.7+、CUDA 12.8+ 工具链,版本错配是最常见的失败原因
python -c "import torch; print(torch.__version__, torch.version.cuda)"
nvcc --version

git clone https://github.com/nerfstudio-project/gsplat.git
cd gsplat
pip install -e .              # 从源码构建 CUDA 扩展,首次 5~15 分钟
python -c "import gsplat; print(gsplat.__version__)"   # 期望 1.6.x

最小训练入口用官方 examples,配置名与参数以仓库 examples 目录为准:

# --data_dir 下要有 images/ 与 sparse/0/,与 brush、COLMAP 的数据布局一致
python examples/simple_trainer.py default \
  --data_dir /path/to/dataset \
  --result_dir ./runs/scene01 \
  --max_steps 30000

三个实现的分工,按「研究复现 / 快速出活」划清楚:

维度 graphdeco 官方实现 gsplat 1.6 brush
语言与构建 Python + CUDA 扩展 Python + CUDA 扩展(PyTorch 2.7+/CUDA 12.8+) Rust + WebGPU/Burn,单二进制
变体覆盖 只有原版 原版 + MCMC + 3DGUT + 稀疏光栅化 原版 + 稀疏化工程实现
多 GPU 不支持 支持稠密训练 不支持
相机模型 针孔(先 undistort) 针孔 / 畸变 / 鱼眼 / 正交 针孔(先 undistort)
上手成本 中,脚本偏老 中,API 面大 低,一条命令
适合 复现论文、对齐 baseline 研究复现、需要新变体、多卡 现场快速出活、无 CUDA 机器

一句话分工:要复现论文、要 2DGS/MCMC/3DGUT、要多卡,用 gsplat;要今天下午把一个场景训出来、机器上还没有 NVIDIA 卡,用 brush(6.4)。两者产物都是 ply,下游的 SuperSplat 与对齐脚本通用。

坑:① torch 与 CUDA 版本错配,装到一半报 nvcc 找不到或运行时 undefined symbol,先对齐 PyTorch 2.7+ 与 CUDA 12.8+ 再装。② 把 gsplat 的 --max_steps 当 brush 的 --total-train-iters 直接换算,致密化的停止步数没跟着改,后段高斯还在长。③ 多卡训练每张卡都读全量图像,IO 成瓶颈,先把数据集放本地 SSD 或做分片。④ 以为装好 gsplat 就有 MCMC/3DGUT,策略要在训练配置里显式选,默认仍是原版。

6.2 数据采集与 COLMAP 重建

3DGS 的成败在训练之前就定了七成。训练只会把「你已经拍到的信息」拟合出来,拍漏的地方它不会凭空补上,只会用模糊的高斯糊过去。这一节先把采集规则钉死,再给完整可复制的 COLMAP 命令序列和质量判据。

6.2.1 采集规则:把 SfM 当第一读者

COLMAP 做的是特征点三角化,它需要同一物理点在多张图里被稳定检测到。所有采集规则都是从这一条推出来的:

规则 具体数值 原因
航向重叠率 ≥ 70%,推荐 80% 低于 70% 时相邻帧共视点太少,匹配失败
旁向重叠率 ≥ 60%,推荐 70% 建筑立面转角处最易断链
绕拍圈数 目标物 2~3 圈,不同高度 单圈只能重建一个视锥面,背面缺失
俯仰角变化 每圈之间差 15°~30° 纯水平环拍会让竖直面退化
相邻帧位移 场景尺寸的 1/20~1/10 太密浪费时间,太疏丢共视
快门/曝光 锁定曝光与白平衡 自动曝光的亮度跳变会被当成几何变化
对焦 手动对焦锁定或固定焦距段 变焦会改变内参,SfM 假设内参固定
运动模糊 快门时间 ≤ 1/250 s(步行)/ 1/800 s(车载) 模糊会摧毁特征点
光照 阴天或顺光,避免正午硬阴影 阴影边界在相邻帧位置跳变,产生假特征

补充几条现场经验:

  • 纯旋转是大忌。站在原地只转相机,没有视差,三角化无解。要么走位,要么绕物旋转。
  • 不要穿过玻璃、水面前进。反光面在每张图里成像不同,匹配出的点会飞。
  • 长走廊、重复立面必须人为打断:墙上贴 A4 打印的随机纹理标记,间隔 3~5 m,或者每 10 m 放一个不同颜色的锥桶。
  • 动态物体(行人、车辆、树叶)尽量清场;清不掉就在后期做掩码,见 6.2.4。
  • 补拍比补算便宜。发现某一面缺图,宁可再拍 5 分钟,不要指望训练补救。

6.2.2 采集缺陷 → 后果 → 处理对照表

这张表建议打印出来贴在采集现场,出现症状时逐条对:

缺陷现象 COLMAP 表现 3DGS 表现 处理方式
纹理不足(白墙、纯色地面) 特征点稀少,注册失败或点云空洞 该区域糊成一片灰白 现场喷粉/贴标;或用 VGGT 前馈补位姿(6.3)
强反光玻璃幕墙 错误匹配,重投影误差偏大 玻璃后面出现「鬼影」漂浮物 拍摄时偏振镜/改变角度;后期掩掉玻璃区域
重复结构(长走廊、规则窗格) 匹配到错误对应,重建错位或断裂 走廊错位、双份墙体 加贴随机标记;改用 sequential_matcher 限制匹配范围
动态物体(行人、车) 拖影状伪点云 物体残影、拖尾高斯 清场;或生成动态掩码后重跑
运动模糊 特征点数量断崖式下降 高频细节全丢 提高快门、降低移动速度、重拍
曝光跳变 匹配数量不稳定 颜色分块、亮度不均 锁定曝光;后期统一做匀色
纯旋转拍摄 无法注册或只有少数帧注册 几乎无法训练 重拍,必须有平移视差
覆盖不全(只拍一面) 稀疏点单侧分布 背面空洞、从背面看像纸片 补拍背向与顶部

6.2.3 COLMAP 完整命令序列

以下命令以 COLMAP 官方文档的 CLI 写法为准,变量 DATASET_PATH 换成你的数据集根目录。目录先摆成标准结构:

/path/to/dataset/
├── images/            # 所有输入图片,文件名建议 image0001.jpg 递增
├── database.db        # COLMAP 自动生成
├── sparse/            # mapper 输出,通常为 sparse/0/
│   └── 0/
│       ├── cameras.bin
│       ├── images.bin
│       └── points3D.bin
└── dense/             # 可选,稠密重建输出

完整序列,逐条可复制:

DATASET_PATH=/path/to/dataset

# 1) 特征提取:SIFT GPU 特征 + 写入 database.db
colmap feature_extractor \
  --database_path $DATASET_PATH/database.db \
  --image_path $DATASET_PATH/images \
  --ImageReader.single_camera 1 \
  --FeatureExtraction.max_image_size 3200 \
  --FeatureExtraction.use_gpu 1

# 2) 特征匹配:帧数少用 exhaustive,视频抽帧用 sequential
#    2a) 无序照片(< 300 张)
colmap exhaustive_matcher \
  --database_path $DATASET_PATH/database.db \
  --FeatureMatching.use_gpu 1

#    2b) 视频抽帧(顺序明确),带闭环检测
colmap sequential_matcher \
  --database_path $DATASET_PATH/database.db \
  --SequentialMatching.loop_detection 1

#    2c) 上千张的大场景,用词汇树
colmap vocab_tree_matcher \
  --database_path $DATASET_PATH/database.db \
  --VocabTreeMatching.vocab_tree_path /path/to/vocab_tree_flickr100K_words256K.bin

# 3) 稀疏重建(增量式 SfM)
mkdir -p $DATASET_PATH/sparse
colmap mapper \
  --database_path $DATASET_PATH/database.db \
  --image_path $DATASET_PATH/images \
  --output_path $DATASET_PATH/sparse

# 4) 查看重建质量统计(相机数、点数、重投影误差)
colmap model_analyzer --path $DATASET_PATH/sparse/0

# 5) 图像去畸变(3DGS 训练需要无畸变的针孔图像)
mkdir -p $DATASET_PATH/dense
colmap image_undistorter \
  --image_path $DATASET_PATH/images \
  --input_path $DATASET_PATH/sparse/0 \
  --output_path $DATASET_PATH/dense \
  --output_type COLMAP \
  --max_image_size 2000

# 6) 可选:稠密重建(要网格/点云时做,纯 3DGS 可跳过)
colmap patch_match_stereo \
  --workspace_path $DATASET_PATH/dense \
  --workspace_format COLMAP \
  --PatchMatchStereo.geom_consistency true

colmap stereo_fusion \
  --workspace_path $DATASET_PATH/dense \
  --workspace_format COLMAP \
  --input_type geometric \
  --output_path $DATASET_PATH/dense/fused.ply

参数含义与取舍:

参数 作用 建议值 备注
--ImageReader.single_camera 1 所有图共用一个内参 单机单镜头拍摄时开 手机变焦或混用多机时必须关
--FeatureExtraction.max_image_size 特征提取前的降采样上限 3200 4000 万像素原图开到 3200 已足够
--FeatureExtraction.use_gpu GPU 提特征 1 无显卡时设 0,速度降一个量级
--FeatureMatching.use_gpu GPU 匹配 1 同上
--SequentialMatching.loop_detection 视频序列的闭环检测 1 环拍场景必开,否则首尾接不上
--max_image_size(undistorter) 去畸变输出边长上限 2000 与 brush 的 --max-resolution 对齐可省内存

坑:① 忘记 --ImageReader.single_camera 1,同时拍摄时用了变焦,COLMAP 会为每张图估一组不同内参,训练时相机抖动。② 用 exhaustive_matcher 跑 2000 张图,匹配阶段要几小时,改用 sequential 或 vocab_tree。③ mapper 输出到 sparse/0 还是 sparse/1 取决于是否发生模型分裂,别硬编码路径,先 ls 确认。④ 跳过 image_undistorter 直接拿原始带畸变图训练,边缘会拉伸重影——3DGS 的相机模型是针孔。

6.2.4 质量判据:三个指标决定要不要重拍

不要凭感觉判断 COLMAP 结果好坏。跑完 mapper 后必须看三组数字:

指标 达标线 查看方式 不达标说明
注册图像比例 ≥ 85% 注册数 / 输入图像总数 匹配断链,图像之间共视不足
平均重投影误差 < 1.0 px colmap model_analyzer 输出 mean reprojection error 位姿不准或误匹配多
稀疏点数量 ≥ 5 万(中等场景) model_analyzer 的 points 数 特征稀少或三角化失败
# 一次看全:相机数、点数、重投影误差、每张图的观测数
colmap model_analyzer --path /path/to/dataset/sparse/0

# 想看更细的,导出为文本模型后用脚本统计
colmap model_converter \
  --input_path /path/to/dataset/sparse/0 \
  --output_path /path/to/dataset/sparse/0_txt \
  --output_type TXT
ls /path/to/dataset/sparse/0_txt
# 输出 cameras.txt images.txt points3D.txt(新版本还有 rigs.txt frames.txt)

注意 COLMAP 当前版本的文本模型除了 cameras/images/points3D 三个文件,还会写出 rigs.txt 与 frames.txt;3DGS 训练器大多只读前三个,多出来的文件不影响,但手写转换脚本时不要按「只有三个文件」去假设。

不达标时的处理顺序,从便宜到贵:

① 提高匹配范围:exhaustive → 改 vocab_tree,或反向(大场景误用 exhaustive 导致误匹配)
② 调整特征数量上限:--SiftExtraction.max_num_features 提高(默认值因版本而异,以 --help 为准)
③ 去掉模糊与过曝的图,重新跑
④ 降低 max_image_size 到 2000 重跑(弱纹理的高频噪声被抹掉后反而更稳)
⑤ 分组重建:把场景按区域分成 2~3 组,各自 mapper,再用 model_aligner 对齐
⑥ 换路线:直接上 VGGT 前馈位姿(6.3),跳过 SfM

6.2.5 四类高频失败的定位与处置

失败一:注册率低于 50%,mapper 输出多个稀疏模型。 先看 sparse/ 下有没有 0、1、2 多个子目录。多个子目录说明图像图被切成了不连通的块,通常是重叠率不足或中途换了光照。处置:删掉 database.db 重新提特征,把 --FeatureExtraction.max_image_size 降一档,改用 --SequentialMatching.loop_detection 1 重新匹配;仍不行则按区域分组重建。

失败二:重投影误差 2 px 以上。 多半是误匹配。跑 colmap exhaustive_matcher 后可以加几何校验(COLMAP 默认会做);如果场景是长走廊,误匹配会集中在相似窗格上,必须换 sequential_matcher 把匹配限制在相邻帧。也可用 colmap model_aligner 之后再观察误差,但那是坐标系问题,不能改善 SfM 本身。

失败三:点云呈「拖影」状,有半透明人形。 动态物体被当成了静态结构。处置:给每张图生成动态区域掩码(黑色为忽略),COLMAP 侧目前没有统一的「用掩码提特征」CLI 参数,实用做法是先用掩码把动态区域涂黑再重新提特征;brush 侧另有掩码机制:训练器会读取约定目录下的掩码图片,黑色像素忽略、白色保留,若你的掩码黑白语义相反就用 --invert-masks 反转(掩码目录的确切命名以其 README 与 brush --help 为准)。

失败四:玻璃幕墙后出现「鬼影」高斯。 反光面在相邻视角成像不一致,SfM 在这块区域三角化出的点本身是错的。处置:不要指望训练修复。拍摄阶段加偏振镜、改变入射角,或直接接受并把该区域在 SuperSplat 里裁剪掉;对交付有影响时,用激光点云补该面。

坑:① 只看注册率不看重投影误差,注册 95% 但误差 3 px,训练出来整体发糊。② 拿带畸变的原图直接训练,边缘拉丝,必须先 undistort。③ 稀疏重建分裂成 sparse/0 和 sparse/1 却只用 0 训练,导致一半图片没参与。④ model_analyzer 报的点数是稀疏点数,不是稠密点数,别拿来评估表面完整度。

6.3 VGGT 前馈重建

COLMAP 稳,但它是一条「迭代优化」链路:特征、匹配、增量三角化、BA,任何一步失败都要回头调参。VGGT(Visual Geometry Grounded Transformer,CVPR 2025 最佳论文)走的是另一条路——一个大 Transformer 一次前向,直接从多视图图像回归相机内外参、深度图、点图。对「时间比精度重要、场景不太大」的工程场景,它能省掉整个 SfM 调试过程。

6.3.1 一次前向拿到位姿、深度、点图

VGGT 的输入是 N 张图(N 可以是 1 到几百),输出是每个视图的三个头:

图像序列 (N, 3, H, W)
      │
      ▼
┌─────────────────────────────┐
│  Aggregator(交替注意力)    │  多视图 token 互相看,建立跨视图对应
│  - 帧内注意力:单图空间结构   │
│  - 全局注意力:跨视图几何一致 │
└──────────────┬──────────────┘
               │ 聚合 token
   ┌───────────┼────────────┬──────────────┐
   ▼           ▼            ▼              ▼
Camera Head  Depth Head   Point Head    Track Head
位姿编码     逐像素深度    逐像素3D点    多视图轨迹
→ 外参 R,t   + 置信度     + 置信度
→ 内参 K

和 COLMAP 的差别在「优化 vs 回归」:COLMAP 通过最小化重投影误差迭代求解,VGGT 用训练数据学出来的先验一次回归。所以 VGGT 快、对弱纹理相对鲁棒,但没有 BA 那一步全局优化,长序列会出现轻微漂移,尺度也是相对的。

官方 README 给了两种用法:直接调模型(几行 Python),或者用仓库里的脚本 demo_colmap.py 把预测结果直接写成 COLMAP 格式,供 gsplat、brush 等训练器直接读取。我们走第二条。

6.3.2 环境准备与最小可运行脚本

# 1) 拉代码(官方要求 Python + torch,其余依赖见 requirements.txt)
git clone https://github.com/facebookresearch/vggt.git
cd vggt
python -m venv .venv && source .venv/bin/activate   # Windows: .venv\\Scripts\\activate
pip install -r requirements.txt

# 2) 权重首次运行会自动从 Hugging Face 下载
#    国内网络慢时手动下载 model.pt 后放到指定位置(见官方 README)
python -c "import torch, vggt; print('vggt ok', torch.cuda.is_available())"

最小推理脚本,输出相机参数与深度,确认链路通了再跑批量:

import torch
from vggt.models.vggt import VGGT
from vggt.utils.load_fn import load_and_preprocess_images
from vggt.utils.pose_enc import pose_encoding_to_extri_intri

device = "cuda" if torch.cuda.is_available() else "cpu"
dtype = torch.bfloat16 if torch.cuda.get_device_capability()[0] >= 8 else torch.float16

model = VGGT.from_pretrained("facebook/VGGT-1B").to(device)
model.eval()

image_names = ["images/0001.jpg", "images/0002.jpg", "images/0003.jpg"]
images = load_and_preprocess_images(image_names).to(device)

with torch.no_grad():
    with torch.cuda.amp.autocast(dtype=dtype):
        images_batch = images[None]                      # 加 batch 维,B=1
        aggregated_tokens, ps_idx = model.aggregator(images_batch)
        pose_enc = model.camera_head(aggregated_tokens)[-1]
        # 得到外参与内参,遵循 OpenCV 约定(camera from world)
        extrinsic, intrinsic = pose_encoding_to_extri_intri(pose_enc, images_batch.shape[-2:])

print("extrinsic:", extrinsic.shape)   # (B, N, 3, 4)
print("intrinsic:", intrinsic.shape)   # (B, N, 3, 3)

显存估算:1B 参数的模型本身约 2 GB(bf16),主要开销在注意力,序列长度随帧数线性增长。经验值:24 GB 卡能跑几十帧,16 GB 卡建议把帧数控制在 30 以内或分块推理。官方 README 提到后续版本优化了中间张量占用,同一显存可支撑的帧数提升明显——具体上限以你装的版本实测为准。

6.3.3 用 demo_colmap.py 直接产出 COLMAP 格式

比起自己写转换,直接用官方脚本最省事。它的参数以脚本的 --help 为准,核心只有几个:

# 数据集目录结构(脚本读取 scene_dir/images 下的所有图)
python demo_colmap.py --scene_dir /path/to/dataset --use_ba

# 参数说明
#   --scene_dir       场景根目录,内部必须有 images/ 子目录
#   --use_ba          是否在回归结果上再跑一次 bundle adjustment(更准,更慢)
#   --max_reproj_error  BA 的最大重投影误差(默认 8.0)
#   --camera_type      SIMPLE_PINHOLE / PINHOLE 等,默认 SIMPLE_PINHOLE
#   --shared_camera    所有视图共享同一内参
#   --vis_thresh       轨迹可见性阈值(默认 0.2)
#   --query_frame_num  参与轨迹查询的帧数(默认 8)
#   --max_query_pts    轨迹查询点上限(默认 4096)
#   --conf_thres_value 不用 BA 时的深度置信度过滤阈值(默认 5.0)

脚本会把重建结果写到 <scene_dir>/sparse,并额外输出一个 sparse/points.ply 方便肉眼检查点云是否正常。这一步的验收标准很直观:用任意 ply 查看器打开,点云应该是场景的形状,而不是一团散点或者一条线。

接着把 sparse 模型转成文本,检查图片路径与相机参数:

colmap model_converter \
  --input_path /path/to/dataset/sparse \
  --output_path /path/to/dataset/sparse_txt \
  --output_type TXT

head -n 6 /path/to/dataset/sparse_txt/cameras.txt
head -n 4 /path/to/dataset/sparse_txt/images.txt

images.txt 的格式是每张图两行:第一行是 IMAGE_ID QW QX QY QZ TX TY TZ CAMERA_ID NAME,第二行是该图的关键点 X Y POINT3D_ID 三元组列表,-1 表示该点没有三角化。位姿是 world→camera,相机中心为 -RᵀT。相机 ID 与 cameras.txt 对应,cameras.txt 每行是 CAMERA_ID MODEL WIDTH HEIGHT PARAMS[],PINHOLE 模型的 4 个参数依次是 fx、fy、cx、cy。

坑:① demo_colmap.py 读的是 scene_dir/images,把图片放在别处直接报「No images found」。② 不加 --use_ba 时深度置信度阈值偏低会把远处点滤掉,场景边缘缺失,可先加 --use_ba 看效果。③ VGGT 输出的是相对尺度,点云数值跟你以为的米制差一个系数,后面 6.6 必须做尺度恢复。④ 帧数一多显存直接 OOM,按 30 帧一档做分块,块间用重叠帧做对齐。

6.3.4 VGGT 与 COLMAP 的选择表与转换校验

先把两条路线摆在一起,选型时按这张表对号入座:

维度 COLMAP(SfM) VGGT(前馈)
耗时 分钟到小时,随图像数超线性增长 秒到分钟,随帧数近似线性
精度 高,有全局 BA 中上,长序列有轻微漂移
弱纹理鲁棒性 差,全靠特征点 较好,有数据驱动的几何先验
大规模场景 强,可分块、可 vocab_tree 弱,注意力随帧数增长
输出 位姿 + 稀疏点云(带 BA) 位姿 + 深度/点图 + 轨迹
显存 低(主要吃 CPU/内存) 高(1B 模型 + 注意力)
可解释性 高,误差指标齐全 低,只有置信度可用
适用 交付级、需要精度评估 快速预览、COLMAP 反复失败时的备胎

实践中的组合策略:优先 COLMAP,注册率不达标再上 VGGT,两者都跑时用 COLMAP 的结果做基准,用 VGGT 的结果补缺失视角。不要一上来就 VGGT,因为它没有 BA,做完 6.6 的对齐后你可能发现残差比 COLMAP 路线大一倍。

两条路线产物结构不同,统一成 brush 能吃的布局是衔接的关键:

COLMAP 路线产物:
dataset/
├── images/                 # 原图(undistort 后要用 dense/images)
└── sparse/0/*.bin

VGGT 路线产物:
dataset/
├── images/                 # 原图
└── sparse/                 # demo_colmap.py 直接写在这里,没有 0/ 子目录
    ├── cameras.bin
    ├── images.bin
    ├── points3D.bin
    └── points.ply

brush 期望的布局(两种都认,但要保证 images 与模型同级):
dataset/
├── images/
└── sparse/0/{cameras,images,points3D}.bin

VGGT 产物少一层 0/,补一层目录即可,不需要重写模型文件:

# VGGT 输出 sparse/ 直接放在 dataset 下,这里补成 sparse/0/
mkdir -p /path/to/dataset/sparse/0
mv /path/to/dataset/sparse/cameras.bin \
   /path/to/dataset/sparse/images.bin \
   /path/to/dataset/sparse/points3D.bin \
   /path/to/dataset/sparse/0/
ls /path/to/dataset/sparse/0/
# cameras.bin images.bin points3D.bin

如果 images.txt 里的文件名带了绝对路径或多余前缀,训练器按名字找不到图,用一个十行的脚本把 NAME 列改回相对 images/ 的基名。校验脚本同时统计注册图像数与相机模型,作为质量初筛:

# fix_colmap_names.py —— 校验并修正 images.txt 中的图片名
import os
import sys
from pathlib import Path

scene = Path(sys.argv[1])                 # 数据集根目录,内含 images/ 与 sparse/0/
images_dir = scene / "images"
images_txt = scene / "sparse" / "0" / "images.txt"

lines = images_txt.read_text(encoding="utf-8").splitlines()
out, bad, registered = [], 0, 0
it = iter(range(len(lines)))
i = 0
while i < len(lines):
    line = lines[i]
    if line.startswith("#") or not line.strip():
        out.append(line); i += 1; continue
    parts = line.split()
    # IMAGE_ID QW QX QY QZ TX TY TZ CAMERA_ID NAME
    if len(parts) >= 10:
        name = parts[9]
        base = os.path.basename(name)
        if (images_dir / base).exists():
            parts[9] = base
            out.append(" ".join(parts))
            registered += 1
        else:
            bad += 1
            out.append(line)
    else:
        out.append(line)
    # 下一行是 POINTS2D,原样保留
    if i + 1 < len(lines):
        out.append(lines[i + 1])
    i += 2

images_txt.write_text("\n".join(out) + "\n", encoding="utf-8")
print(f"registered={registered} missing_image_file={bad}")
sys.exit(0 if bad == 0 else 2)
python fix_colmap_names.py /path/to/dataset
# registered=137 missing_image_file=0

这里有个容易忽略的点:COLMAP 的 images.txt 每张图占两行,第二行是 POINTS2D 列表。任何「按行遍历改文件名」的脚本如果不成对处理,都会把点列表行当位姿行解析,结果整个模型报废。上面的脚本用 i += 2 成对推进,就是在防这个。

坑:① 直接把 VGGT 的 sparse 目录喂给 brush,少一层 0/,训练器报「no cameras found」。② 用 Python 的 str.split() 重写 images.txt 时把多个空格压成一个,格式仍合法,但如果你把备注行也重写了就会破坏解析。③ VGGT 的 PINHOLE 内参与原图分辨率不一致时,训练出来整体缩放错误,转换后务必用 cameras.txt 核对 WIDTH/HEIGHT 是否等于原图。④ 混用 COLMAP 与 VGGT 的位姿做同一个场景,坐标系不一致,千万不要拼接两个 sparse 模型。

6.3.5 2026 的 VGGT:显存修复、VGGT-Omega 与许可前置

VGGT 在 2026 年有两个直接影响工程决策的进展,外加一个必须提前处理的法律问题:

  • 2026-05-15 修复显存问题:中间张量的占用量显著下降,官方说法是同等显存可跑约 2~3 倍帧数。对 24 GB 卡,过去 30 帧就吃紧的序列,新版本能到 60 帧量级(随图像分辨率浮动,以你装的版本实测为准)。
  • 2026-05-18 发布 VGGT-Omega:后继版本,能力与权重以下游官方仓库 README 为准;本书 6.3.2 的脚本仍以 VGGT-1B 为基线,换版本时先跑通最小推理脚本再上批量。
  • 商用许可 checkpoint:2025-07-29 起官方提供 VGGT-1B-Commercial,需要单独申请。默认权重是研究许可,直接用在甲方项目里属于越界,这一条比显存更早决定方案能不能落地。

项目启动前先过一遍许可清单,别等交付前才发现权重不能给:

确认项 要问清楚的事 不合格时的替代
权重许可 用的是 research 权重还是 Commercial 权重 申请商用 checkpoint,或改走 COLMAP
数据许可 训练数据是否允许商用、是否允许转售模型 自采数据,或只把 VGGT 结果当内部中间产物
输出权属 生成的点云、位姿、ply 归谁 合同里写明中间产物归属
再分发 是否要把权重打进交付包 只交付 ply 与位姿,不打包权重

帧数规划按「修复前后」两栏对照,规划时取保守值:

显存 修复前经验上限(1080p) 修复后经验上限(1080p) 建议
16 GB 30 帧量级 60 帧量级 1080p 素材,按 30 帧一档分块推理
24 GB 30~50 帧 60~100 帧 单块直接跑,超过 100 帧先降分辨率
48 GB 80 帧量级 150 帧以上 少分块,但注意力仍是平方级,别无限加帧

三条工程建议:

  • 先跑 COLMAP,再决定用不用 VGGT,顺序别反。VGGT 是 COLMAP 反复失败时的备胎,不是默认路线(选型见 6.3.4)。
  • 版本锁死。显存修复是 2026 年 5 月密集发版完成的,pip freeze 里记下 commit 号,否则三个月后复现不出同一组数字。
  • 交付件里不要出现权重文件,只留位姿与 ply。许可风险主要来自「打包送人」这一步,而不是内部使用。

坑:① 拿研究权重做甲方项目,验收时被要求出具授权文件,返工重跑。② 用新版本的帧数经验值直接套旧版本,OOM 后误以为是数据问题。③ 显存修复不等于精度提升,位姿精度仍取决于序列覆盖,别拿「能跑更多帧」当「可以少拍」。④ VGGT-Omega 与 VGGT-1B 的输出接口不一定一致,批量脚本里写死模块路径,换版本当天就崩。

6.4 用 brush 训练

COLMAP 官方训练器要装 CUDA 工具链、一堆 Python 依赖,稍不注意就编译失败。brush 是另一个选择:Rust + wgpu 实现的 3DGS 训练与查看器,编译产物是单个可执行文件,跨 Windows/macOS/Linux,甚至能在浏览器里跑(WebGPU)。它直接读 COLMAP 数据集,带实时查看器,训练时能一边看一边调,很适合工程现场快速迭代。

6.4.1 brush 的定位与安装

先把边界说清楚,避免选错工具:

  • brush 是训练器 + 查看器,不做 SfM,位姿必须由 COLMAP 或 VGGT 提供。
  • 显卡不需要 CUDA,NVIDIA / AMD / Intel 都走 wgpu,只要支持 Vulkan/Metal/DX12。这一点在只有集显的笔记本上很有用,但速度会慢很多。
  • 支持读 COLMAP 数据集、Nerfstudio 数据集,也支持图片透明度与掩码目录。
  • 支持导出 ply 与 .compressed.ply,可与 SuperSplat 等编辑器衔接。

官方提供两条安装路径:从 GitHub Releases 下载对应平台的可执行包,或者从源码用 cargo 构建(Rust 1.88+)。releases 里的资产按 brush-app-<平台> 命名,例如 Windows 是 brush-app-x86_64-pc-windows-msvc.zip。

# 方式 A:下载预编译包(推荐)
# 打开 https://github.com/ArthurBrussee/brush/releases/latest
# 下载 brush-app-x86_64-pc-windows-msvc.zip(Windows)
#      brush-app-x86_64-unknown-linux-gnu.tar.xz(Linux)
#      brush-app-aarch64-apple-darwin.tar.xz(Apple Silicon)
# 解压后把可执行文件放进 PATH

# 方式 B:源码构建(Rust 1.88+)
git clone https://github.com/ArthurBrussee/brush.git
cd brush
cargo run --release            # 从 workspace 根目录构建优化版
# 构建完成后可直接用 target/release/brush 运行

# 验证
brush --help                   # 查看全部 CLI 参数

注意 brush 的 CLI 帮助是判断参数是否存在的唯一权威。它把训练配置拆成数组结构(训练、模型、数据集、流程、rerun 五组),不同版本的参数会增删,别照抄网上的旧命令。

6.4.2 数据集组织与最小训练命令

brush 支持两种数据集格式:COLMAP 与 Nerfstudio。本书用 COLMAP,目录结构如下:

/path/to/scene/
├── images/                 # 输入图片,文件名与 images.bin 里的 NAME 一致
│   ├── 0001.jpg
│   ├── 0002.jpg
│   └── ...
├── sparse/0/               # COLMAP 稀疏模型(brush 只读这一层)
│   ├── cameras.bin         # 或 cameras.txt
│   ├── images.bin          # 或 images.txt
│   └── points3D.bin        # 或 points3D.txt
└── args.txt                # 可选:把常用参数写死,省得每次敲

几个官方行为要记住:

  • brush 会在所有 cameras.bin / cameras.txt 中挑「注册图像最多」的那个模型,不依赖目录名。所以 sparse/0 与 sparse/1 并存时它会自动选大的,但仍建议你手动清理,避免误判。
  • .bin 与 .txt 两种 COLMAP 模型都支持,用哪个都行;.txt 便于人眼核对,.bin 读得快。
  • 支持掩码:图片带透明通道时,透明像素会被当作背景;另有一个 masks 目录约定,黑色像素被忽略、白色保留,若你的掩码含义相反,用 --invert-masks 反转。
  • args.txt 会被自动读取,里面的参数与命令行等价,命令行优先级更高(合并逻辑见 brush 源码 merge_configs)。批量跑场景时这招很好用:每个场景目录放一份自己的 args.txt。

最小训练命令只有两段:数据集路径 + 参数。下面这条是「能跑通」的最低配置:

brush /path/to/scene \
  --total-train-iters 30000 \
  --max-resolution 1600 \
  --max-splats 3000000 \
  --sh-degree 2 \
  --export-path ./exports/ \
  --export-name scene_30k.ply

参数全部来自 brush 的 CLI 定义,逐条说明:

参数 默认值 作用 什么时候改
--total-train-iters 30000 总训练步数 预览改 5000~10000
--max-resolution 1920 加载图像的最大边长 显存不足时降到 1280
--max-splats 10000000 高斯数量上限(只是上限,不是目标值) 显存不足时降到 200~300 万
--sh-degree 3 球谐阶数,取值 0~4 漫反射场景降到 1~2
--refine-every 200 致密化间隔步数 大场景调到 300 减少峰值显存
--growth-grad-threshold 0.0025 增长梯度阈值,越低长得越快 高斯太少时降到 0.0015
--growth-stop-iter 15000 停止增长的步数 与总步数保持一半关系
--eval-every 1000 评估间隔 快速预览可设 5000
--export-every 5000 导出间隔 只要最终产物时设大一点
--export-path ./{dataset}_exports/ 导出目录,{dataset} 会被替换成数据集目录名 统一收集到固定目录
--export-name export_{iter}.ply 导出文件名,{iter} 会被替换成迭代号 多场景批量时带上场景名
--subsample-frames 不启用 每隔 N 帧取一帧 视频抽帧过密时用 2
--subsample-points 不启用 稀疏点降采样 初始化点太多导致显存吃紧
--units-per-meter 1.0 数据集单位换算到米 毫米制数据填 1000
--with-viewer 有数据源时默认 false 打开实时查看器 调试时加,纯批跑别加
--invert-masks false 反转掩码语义 掩码黑白定义相反时

注意 --max-splats 只是上限。最终高斯数由场景复杂度与致密化策略决定,写 1000 万不代表训出 1000 万;反过来,这个值设太小会在增长阶段就被截断,远处细节直接缺失。

6.4.3 三档训练配置与耗时量级

参数不用每次现调,把三档配置固化成脚本。下面是按 1 分钟、约 200 帧、1080p 素材估算的三档,显存基线 12 GB:

档位 用途 total-train-iters max-resolution max-splats sh-degree 显存峰值 耗时(RTX 3060 级)
快速预览 现场判断拍得对不对 5000 1280 1500000 1 4~6 GB 2~4 min
标准 常规交付、网页查看 30000 1600 4000000 2 7~10 GB 15~30 min
高质量 近距离展示、出版级截图 60000 1920 8000000 3 11~16 GB 60~120 min

三档对应的完整命令:

SCENE=/path/to/scene

# 快速预览:先确认位姿与曝光没大问题
brush $SCENE \
  --total-train-iters 5000 \
  --max-resolution 1280 \
  --max-splats 1500000 \
  --sh-degree 1 \
  --growth-stop-iter 3000 \
  --eval-every 2500 \
  --export-every 5000 \
  --export-path ./exports/quick/ \
  --export-name preview.ply

# 标准:常规交付默认选这档
brush $SCENE \
  --total-train-iters 30000 \
  --max-resolution 1600 \
  --max-splats 4000000 \
  --sh-degree 2 \
  --refine-every 200 \
  --growth-stop-iter 15000 \
  --export-every 10000 \
  --export-path ./exports/standard/ \
  --export-name scene_standard.ply

# 高质量:加迭代、提高分辨率、开 3 阶球谐
brush $SCENE \
  --total-train-iters 60000 \
  --max-resolution 1920 \
  --max-splats 8000000 \
  --sh-degree 3 \
  --refine-every 150 \
  --growth-grad-threshold 0.0020 \
  --growth-stop-iter 30000 \
  --export-every 20000 \
  --export-path ./exports/high/ \
  --export-name scene_high.ply

耗时量级只作规划参考,实际取决于高斯数、分辨率与显卡。判断是否「训到位」的实用方法是看导出间隔产出的 ply 数量:如果 20000 步与 30000 步的导出在肉眼看不出差别,说明已经收敛,继续加迭代纯属浪费电。

6.4.4 查看器、导出与 OOM 排查

brush 自带查看器,加 --with-viewer 启动,训练过程中可以实时拖动视角,还能和输入视图切换对比。这个功能对判断过拟合特别有用:训练视图看着很锐、换个新视角就糊成一团,说明视角覆盖不足或致密化过头。

# 带查看器训练(调试用)
brush /path/to/scene --with-viewer --total-train-iters 30000

# 纯查看已训练好的 ply(CLI 查看模式按版本而定,直接拖入查看器窗口也可)
brush /path/to/exports/scene_standard.ply --with-viewer

导出产物:

格式 扩展名 特点 适用
标准 ply .ply 保留全部高斯属性(位置/协方差/不透明度/SH) 后续编辑、SuperSplat 导入
压缩 ply .compressed.ply 量化压缩,体积通常降到 1/4~1/10 网页发布
动画/序列 .zip(多个 splat) 查看器按序播放 四维重建演示

OOM 是 brush 最常见的失败,报错通常出现在增长阶段而不是训练一开始。按这个顺序排查:

现象:训练到 8000 步左右 OOM(增长阶段)
① 先降 --max-resolution:1920 → 1600 → 1280,每次省 30%~40% 图像缓存
② 再降 --max-splats:8000000 → 4000000 → 2000000
③ 调大 --refine-every:200 → 300 → 500,推迟并减少致密化频率
④ 降 --growth-select-fraction(若版本有该参数):减少每次增长的比例
⑤ 用 --subsample-points 8 降低初始化点数
⑥ 最后才降 --total-train-iters,它省的是时间不是显存峰值

现象:加载数据集就 OOM
① 检查是否误读了 sparse/1(更大的那个模型)
② --max-resolution 直接设 1024
③ 关掉 --with-viewer 释放查看器占用的显存

现象:训练能跑完但导出的 ply 打不开
① 确认导出的是 .ply 而不是未完成的临时文件
② 用 3D 查看器先看文件头 magic 是否为 ply
③ 换 SuperSplat 导入验证,能导入说明文件没问题,是浏览器查看器不支持

坑:① 训练时开着 --with-viewer 还要跑别的 GPU 任务,显存被查看器吃掉一半,OOM 莫名其妙。② 以为 --max-resolution 只影响显示,实际它决定加载进显存的原图尺寸,是省显存的第一杠杆。③ 导出路径写相对路径但工作目录不对,ply 被写到别处找不到。④ 用 --export-name 不带场景名批量跑,第二个场景把第一个覆盖了。

6.4.5 brush 的 2026 能力边界:本地训练与浏览器训练

6.4.1 讲过 brush 的定位,2026 年它的边界又扩了一圈,直接影响「在哪训」这个决定。当前实现的硬事实:

  • 实现栈是 Rust + WebGPU(渲染与计算走 Burn 后端),不是 CUDA。所以 AMD、Intel 集显、Apple Metal 都能跑,代价是同等显卡下比 CUDA 实现慢。
  • 平台覆盖 macOS / Windows / Linux,外加 Android 与浏览器。浏览器里跑的是同一套 WebGPU 代码,不是另写的简化版。
  • 数据集格式同时认 COLMAP 与 Nerfstudio,两套布局都能直接喂,不必先转 COLMAP。
  • 掩码约定:masks/ 目录下按同名图片放掩码,黑色像素忽略、白色保留;掩码黑白语义相反时加 --invert-masks。
  • 产物是 .ply 与 .compressed.ply;查看器支持用 ?url= 直接加载远端 splat 文件(流式,不必先下载完);多个 splat 打包成 zip 后可按序播放,用于四维重建或施工进度演示。

两条路径的取舍:

维度 本地 CLI 训练 浏览器训练
安装 下载单二进制或 cargo 构建 打开支持 WebGPU 的浏览器页面
硬件 本机 GPU,NVIDIA/AMD/Intel 都行 浏览器可用的 GPU,显存由标签页管
场景规模 千万级高斯、高分辨率 受浏览器显存与文件读取限制,只适合小场景
可视化 --with-viewer 实时看 天然实时
适合 批量、交付级、要脚本化 现场快速验证、教学演示、临时看效果

本地路径就是 6.4.2~6.4.4 的命令,不再重复。浏览器路径要点:

浏览器侧只做两件事:看 与 小场景试训

1) 看:把导出的 .ply / .compressed.ply 用 WebGPU 版查看器打开
   支持 ?url= 直接给远端地址流式加载,文件放静态托管后即可
   形如 https://<你的静态站点>/viewer/?url=scene.compressed.ply
2) 试训:小场景(≤ 300 帧、≤ 720p)在浏览器里直接训
   看构图、曝光、覆盖是否够,正式交付再回本地 CLI

入口与参数以 brush README 与 releases 页面为准,别照抄博客里的旧地址

浏览器训练有三个现实限制,先知道再决定:

  • 文件读取是瓶颈。几百张 4K 图从本地选进浏览器再解码,耗时可能超过训练本身。
  • 标签页被回收时训练直接丢。后台标签、系统省电都会触发,不要用它跑正式交付。
  • WebGPU 驱动差异大。同样参数在不同机器上速度能差 2~5 倍,批跑一律回本地 CLI。

坑:① 以为 WebGPU 版能替代 CUDA 训练,实际只在「没有 NVIDIA 卡但要出个结果」时成立。② 掩码目录放了图但命名与 images 不一致,等于没放,训练完才发现浮点还在。③ 用 ?url= 加载远端文件时静态站点没配 CORS,浏览器静默失败,控制台才有报错。④ 浏览器试训的结果直接当交付,分辨率与迭代数都不够,放大后一片糊。

6.5 SuperSplat 场景编辑

训练出来的 ply 一定不完美:天空方向有漂浮高斯、镜头背面有拖尾、边缘有半透明雾。这些在训练器里改不了,需要拿进编辑器手工清理。SuperSplat 是 PlayCanvas 开源的浏览器 3DGS 编辑器,打开网页就能用(官方在线版 superspl.at/editor),支持导入 ply/splat/ksplat,做裁剪、删除、补光、相机动画,再导出成可发布格式。

6.5.1 导入与坐标尺度确认

导入前先做三件事,能省掉后面 80% 的困惑:

① 记住训练时的坐标系:COLMAP 的 world→camera 约定,Y 轴方向与 GIS 的 Z-up 不同
② 量一下已知尺寸:场景里放一个 1 m 的标尺或有尺寸的标记物,导入后测它
③ 记下高斯总数:SuperSplat 加载后能看到,超过 800 万在浏览器里会卡

导入流程:

1. 打开 https://superspl.at/editor
2. 点 Open 选择本地 ply(文件不会上传,在浏览器本地解析)
3. 等待解析,底部状态栏显示 splat 数量
4. 视角调整:左键旋转 / 右键平移 / 滚轮缩放(以当前版本界面为准)
5. 用 Measure 工具量标尺,若结果不是 1 m,说明训练尺度不对,回 6.6 做尺度恢复

浏览器显存是硬约束。经验值:Chrome 在 8 GB 显存机器上,600 万高斯是流畅上限;超过 1000 万会明显掉帧甚至标签页崩溃。遇到这种情况不要硬撑,先在 brush 里降 --max-splats 重训,或用 SuperSplat 的压缩导出功能先把文件压下来。

6.5.2 清理:裁剪、删浮点、补洞

清理的顺序建议固定下来,形成肌肉记忆:

第 1 步:删天空与远处浮点
        框选场景上方与外围,Delete 删除
第 2 步:删近处拖尾与雾状高斯
        这类高斯通常低不透明度、大尺寸,用按不透明度筛选 + 框选
第 3 步:补洞(地面缺失、树冠缺口)
        用克隆/填补工具,或接受并在展示时用地面网格遮挡
第 4 步:调曝光与色调
        统一亮度、对比度、白平衡,让多场景风格一致
第 5 步:设置相机动画(若用于展示视频)
        打关键帧,沿预设轨迹播放
第 6 步:导出

几条实操经验:

  • 按不透明度筛选是神器。漂浮物大多是 α < 0.1 的大高斯,先筛出来批量删,比手动画框快十倍。
  • 裁剪框要与视角垂直。斜着框选会把后面的墙体一起删掉。
  • 删之前先复制一份。浏览器崩了不会自动保存,重要编辑每 5 分钟导出一份中间文件。
  • 补洞不要强求。3DGS 的洞是没拍到的区域,编辑器补出来的是「想象」,用于展示可以,用于量测一律不行。

6.5.3 导入发黑、体积暴涨与导出格式

三个高频问题,原因和对策都明确:

现象 原因 对策
导入后整体发黑 颜色空间不匹配(sRGB 与线性)或预乘 alpha 处理差异 在编辑器里调曝光补偿;导出时统一选带颜色空间转换的格式
与参考模型比尺度不对 训练时--units-per-meter 设置错误,或 VGGT 相对尺度未恢复 重新训练或在对齐阶段做整体缩放(6.6)
编辑后文件体积暴涨 保存时未压缩、或补洞生成了大量新高斯 导出用压缩 ply / ksplat,删除编辑历史里未用的高斯
浏览器打开就崩 高斯数超浏览器显存上限 降采样导出,或分成多个区块分别加载
导出后在其他查看器颜色偏 不同查看器对 SH 与 gamma 的解释不同 以目标查看器为准做颜色校准,别在多平台间反复调

导出格式选择:

格式 体积 兼容性 用途
.ply(标准) 大 几乎全部工具 归档、再编辑
.compressed.ply 小 brush、部分查看器 网页发布
.splat 小 splat 生态查看器 轻量网页
.ksplat 小 PlayCanvas 生态 与 PlayCanvas 引擎集成

发布路径有两条,按需求选:

路径 A:直接嵌网页(最省事)
  compressed.ply / splat
  → 用支持 3DGS 的 WebGL 渲染器加载(three.js 生态有多个 3DGS 渲染器实现)
  → 一个 HTML + 一个模型文件,静态托管即可

路径 B:进三维地球(要坐标)
  ply → splat tileset(带 LOD,需按 6.6 对齐到真实坐标)
  → Cesium 等支持 3D Tiles 的引擎加载
  → 可与其他 GIS 图层叠加、可量测、可切片

路径 B 过去要绕:把高斯中心导成点云再转 3D Tiles 点云瓦片,或者用社区工具硬切带 LOD 的 tileset,两条路都以 6.6 的坐标对齐为前提。2026-04 Cesium 发布带层次 LOD 的 3D Gaussian Splats 方案后,splat 可以直接作为 3D Tiles 内容切片按需加载,ion 管线也不再要求先生成 mesh,标准做法见 6.6.4。

坑:① 直接把未压缩的 ply 传上静态托管,单文件几百 MB,移动端加载失败。② 编辑后导出的文件比原文件大 3 倍,是补洞生成的高斯太多,回退重做或用压缩导出。③ 在 SuperSplat 里调好的颜色,换到另一个查看器偏色,是 gamma 处理不同,别在多个平台间来回调,以最终发布平台为准。④ 浏览器标签页崩溃丢失编辑,养成定时导出的习惯。

6.5.4 SuperSplat 的 2026 现状:本地自建与 WebMCP 自动化

6.5 开头介绍的是在线编辑器,2026 年 SuperSplat 的形态已经完整到可以自建,也可以被 agent 驱动:

  • 定位仍是浏览器内的免费开源编辑器:查看、编辑(删除/裁剪/合并)、优化(压缩、去噪)、发布(生成可嵌入的页面与资源)四件事在一个工具里完成。
  • 本地开发需要 Node.js 20.19+。低于这个版本 npm install 会在依赖的 ESM 与构建链上报错,报错信息还指向莫名其妙的上游包。
  • 已暴露原生浏览器 WebMCP 工具:场景检查、选择、编辑这些操作有结构化的工具描述,agent 可以直接调用,不必靠模拟点击。这是把「手工清理」变成「可批处理」的接口。

自建本地副本:

# Node.js 20.19+ 是硬门槛,先确认版本再 clone
node --version

git clone https://github.com/playcanvas/supersplat.git
cd supersplat
npm install
npm run build          # 产出静态站点,可直接托管
npm run develop        # 本地开发服务器,端口以命令输出为准

用 DSH + WebMCP 批量清理的思路,点到为止:

任务:把 ./exports/*.ply 逐个体检并按规则清理
1) 用「场景检查」工具读出高斯总数、包围盒、不透明度分布
2) 若存在不透明度 < 0.05 且落在包围盒外的漂浮簇,用「选择」工具按条件选中
3) 用「编辑」工具删除选中集合,导出 compressed.ply 到 ./clean/
4) 每个场景留一份清理前后的计数与体积对比表

这套流程的价值不在省几次点击,而在「清理规则可版本化」:阈值写进任务书,下一次换场景直接重跑,而不是靠某个人的手感。注意 WebMCP 暴露的工具集由当前部署的 SuperSplat 版本决定,能力清单以其页面实际暴露为准,别按文档里的旧列表写死。

坑:① 用 Node 18 或 20.18 跑 npm install,卡在依赖解析就以为仓库坏了,先升 Node。② 把 WebMCP 当万能接口,它只覆盖检查/选择/编辑这几类操作,复杂补洞仍要人做。③ 批量删除规则写得太宽,把远处真实建筑当成漂浮簇删掉,每次批量前先导出一份原始 ply 留底。④ 自建静态站点时忘了配 MIME,.compressed.ply 被当二进制下载而不是被查看器加载。

6.6 从扫描到可交付

前面的产物都是一个「漂亮的局部模型」,坐标是 COLMAP 的任意世界系,尺度可能是 1,朝向随机。GIS 交付要的是「能叠到已有底图上、能报坐标、能算面积」的东西。这一节解决三件事:坐标系对齐、尺度恢复、精度评估。

6.6.1 坐标系对齐:7 参数 Helmert 变换

要把局部模型搬到目标坐标系(例如 CGCS2000 / UTM 或城市独立坐标),用 7 参数相似变换:3 个平移 + 3 个旋转 + 1 个尺度。

目标坐标 = s · R · 局部坐标 + T

s:尺度因子(标量)
R:3×3 旋转矩阵(由 3 个欧拉角或四元数生成)
T:3 维平移

未知量 7 个:s、rx、ry、rz、tx、ty、tz
控制点每对提供 3 个方程,理论 3 个点可解(9 方程 > 7 未知量),
实际要 4 个以上并分布均匀,用最小二乘抗粗差。

控制点怎么来:现场用 RTK 打 4~8 个特征明显、在模型里能准确点出的点(墙角、井盖中心、标石顶)。坐标记在同一个目标坐标系里。局部坐标从 ply 里量,或用 COLMAP 的 model_converter 导出文本模型后从 points3D.txt 里取。

求解用最小二乘,一次算完 7 个参数:

# helmert_solve.py —— 7 参数相似变换最小二乘求解
# 用法: python helmert_solve.py control.csv
# control.csv 列: name,local_x,local_y,local_z,target_x,target_y,target_z
import sys
import numpy as np

def umeyama(src, dst):
    """标准 Umeyama 算法:求 dst ≈ s * R * src + t 的最优 s/R/t"""
    m, n = src.shape                    # m=3 维, n=点数
    mu_src = src.mean(axis=1, keepdims=True)
    mu_dst = dst.mean(axis=1, keepdims=True)
    src_c = src - mu_src
    dst_c = dst - mu_dst
    cov = dst_c @ src_c.T / n
    U, D, Vt = np.linalg.svd(cov)
    S = np.eye(m)
    if np.linalg.det(U) * np.linalg.det(Vt) < 0:   # 防镜像
        S[-1, -1] = -1
    R = U @ S @ Vt
    var_src = (src_c ** 2).sum() / n
    s = np.trace(np.diag(D) @ S) / var_src
    t = mu_dst - s * R @ mu_src
    return s, R, t

rows = [l.strip() for l in open(sys.argv[1], encoding="utf-8") if l.strip()]
rows = [r for r in rows if not r.startswith("#") and not r.startswith("name")]
src, dst, names = [], [], []
for r in rows:
    p = [x.strip() for x in r.split(",")]
    names.append(p[0])
    src.append([float(p[1]), float(p[2]), float(p[3])])
    dst.append([float(p[4]), float(p[5]), float(p[6])])

src = np.array(src).T     # 3×n
dst = np.array(dst).T

s, R, t = umeyama(src, dst)

# 残差与 RMSE
pred = s * (R @ src) + t
res = pred - dst
rmse = np.sqrt((res ** 2).sum() / (3 * len(names)))
per_point = np.sqrt((res ** 2).sum(axis=0))

print(f"scale = {s:.9f}")
print(f"rotation matrix:\n{np.round(R, 8)}")
print(f"translation = {np.round(t.ravel(), 4)}")
print(f"checkpoint RMSE = {rmse:.4f} (与目标坐标同单位)")
for n, e in zip(names, per_point):
    print(f"  {n}: {e:.4f}")

# 反算欧拉角(ZYX 顺序),便于填进 GIS 软件的变换参数
sy = np.sqrt(R[0, 0] ** 2 + R[1, 0] ** 2)
if sy > 1e-8:
    rx = np.arctan2(R[2, 1], R[2, 2])
    ry = np.arctan2(-R[2, 0], sy)
    rz = np.arctan2(R[1, 0], R[0, 0])
else:
    rx, ry, rz = np.arctan2(-R[1, 2], R[1, 1]), np.arctan2(-R[2, 0], sy), 0.0
print(f"euler_deg = rx={np.degrees(rx):.6f} ry={np.degrees(ry):.6f} rz={np.degrees(rz):.6f}")

# 导出参数供 ply 变换使用
np.save("helmert_s.npy", np.array([s]))
np.save("helmert_R.npy", R)
np.save("helmert_t.npy", t)

拿到 s/R/t 后,把整个 ply 的所有高斯中心变换过去。注意 3DGS 的 ply 不能只平移点云:每个高斯还有协方差(旋转 + 缩放),旋转矩阵要同时作用到高斯的旋转参数上,缩放因子要作用到高斯的三条轴长上,否则模型会「位置对了但椭球朝向错了」,看起来到处是斜向条纹。

# apply_helmert.py —— 把 helmert 参数作用到 ply 的高斯上
# 依赖: pip install plyfile numpy
import numpy as np
from plyfile import PlyData, PlyElement

s = float(np.load("helmert_s.npy")[0])
R = np.load("helmert_R.npy")
t = np.load("helmert_t.npy").ravel()

ply = PlyData.read("scene.ply")
v = ply["vertex"]
xyz = np.stack([v["x"], v["y"], v["z"]], axis=1)

# 1) 中心点变换
xyz_new = s * (xyz @ R.T) + t
v["x"], v["y"], v["z"] = xyz_new[:, 0], xyz_new[:, 1], xyz_new[:, 2]

# 2) 尺度作用到三轴(对数域相加,ply 里存的是 log 尺度)
for k in ("scale_0", "scale_1", "scale_2"):
    if k in v.data.dtype.names:
        v[k] = v[k] + np.log(s)

# 3) 旋转作用到四元数(左乘 R 对应的四元数)
def rotmat_to_quat(Rm):
    tr = np.trace(Rm)
    if tr > 0:
        S = np.sqrt(tr + 1.0) * 2
        qw = 0.25 * S
        qx = (Rm[2, 1] - Rm[1, 2]) / S
        qy = (Rm[0, 2] - Rm[2, 0]) / S
        qz = (Rm[1, 0] - Rm[0, 1]) / S
    else:
        i = int(np.argmax(np.diag(Rm)))
        if i == 0:
            S = np.sqrt(1.0 + Rm[0, 0] - Rm[1, 1] - Rm[2, 2]) * 2
            qw = (Rm[2, 1] - Rm[1, 2]) / S; qx = 0.25 * S
            qy = (Rm[0, 1] + Rm[1, 0]) / S; qz = (Rm[0, 2] + Rm[2, 0]) / S
        elif i == 1:
            S = np.sqrt(1.0 + Rm[1, 1] - Rm[0, 0] - Rm[2, 2]) * 2
            qw = (Rm[0, 2] - Rm[2, 0]) / S; qx = (Rm[0, 1] + Rm[1, 0]) / S
            qy = 0.25 * S; qz = (Rm[1, 2] + Rm[2, 1]) / S
        else:
            S = np.sqrt(1.0 + Rm[2, 2] - Rm[0, 0] - Rm[1, 1]) * 2
            qw = (Rm[1, 0] - Rm[0, 1]) / S; qx = (Rm[0, 2] + Rm[2, 0]) / S
            qy = (Rm[1, 2] + Rm[2, 1]) / S; qz = 0.25 * S
    return np.array([qw, qx, qy, qz])

qr = rotmat_to_quat(R)
qw, qx, qy, qz = v["rot_0"], v["rot_1"], v["rot_2"], v["rot_3"]
# 四元数左乘:q_new = qr ⊗ q
nw = qr[0] * qw - qr[1] * qx - qr[2] * qy - qr[3] * qz
nx = qr[0] * qx + qr[1] * qw + qr[2] * qz - qr[3] * qy
ny = qr[0] * qy - qr[1] * qz + qr[2] * qw + qr[3] * qx
nz = qr[0] * qz + qr[1] * qy - qr[2] * qx + qr[3] * qw
v["rot_0"], v["rot_1"], v["rot_2"], v["rot_3"] = nw, nx, ny, nz

PlyData([PlyElement.describe(v.data, "vertex")], text=False).write("scene_aligned.ply")
print("wrote scene_aligned.ply")

做完变换要回验:用同一批控制点算残差,RMSE 应在厘米级(RTK 控制点、纹理良好的立面)。如果残差是米级,先怀疑控制点点错了位置,其次怀疑尺度。

6.6.2 尺度恢复:单目重建绕不开的问题

COLMAP 与 VGGT 输出的都是「相对尺度」——你拍的楼可能是 3 个单位高,也可能是 30 个单位高,取决于初始化时的基线估计。恢复方法按可靠性排序:

方法 精度 前提 操作
RTK 控制点(≥ 4 个) 厘米级 现场打点 直接进 6.6.1 的 Helmert,s 就是答案
已知距离(标尺/建筑边长) 分米级 场景里有可量测的已知长度 在模型里量同一段,s = 真值 / 模型量测
相机基线 + GPS 轨迹 米级 无人机带 GPS、有重叠影像 用相邻相机中心距离对比 GPS 距离估 s
IMU / 轮速计 米级 有传感器数据 与视觉轨迹对齐时间戳后比位移
纯目视估计 不可用 无 不要用于交付

只有已知距离时,脚本一行就够:

# 已知 A、B 两点的真实距离 L_real,模型里量出 L_model
L_real = 12.50       # 米,现场量
L_model = 3.21       # 模型单位,在 ply / 查看器里量
s = L_real / L_model
print(f"scale = {s:.9f}")   # 之后所有坐标乘以 s,ply 的 log 尺度加 ln(s)

VGGT 路线尤其要注意:它的尺度完全来自训练数据分布,跟真实米制没有约定关系,必须用上面任一方法恢复,不能假定「差不多就是米」。

6.6.3 精度评估与交付形式

对齐完成后必须出一份精度报告,这是交付的验收依据。评估用独立检查点(不参与求解的控制点),指标包括:

检查点残差  d_i = |P_model_aligned - P_check_i|
RMSE       = sqrt( Σ d_i² / n )          平面/高程分开统计更有说服力
最大残差   max(d_i)                        反映局部粗差
套合差     与正射影像/点云叠加后的同名点偏差,反映整体一致性

工程上的经验线:RTK 控制点 + 良好纹理,水平 RMSE ≤ 5 cm、高程 RMSE ≤ 10 cm 算合格;只有 GPS 轨迹辅助的无人机数据,水平 20~30 cm 属正常;用已知距离恢复尺度的手持扫描,10 cm 以内就可以接受。

交付形式按接收方能力选:

交付形式 产物 优点 限制
网页查看器 compressed.ply + HTML 零安装、可分享 无坐标、难量测
带坐标查看器 对齐后模型 + 查看器配置 可量测、可叠加 需自建服务
3D Tiles 切片 splat tileset(tileset.json + 瓦片) 可进三维地球、支持 LOD、按需加载 要先对齐再切片,路径见 6.6.4
业务系统集成 模型 + 标注/量测接口 直接进入业务流程 开发量大

集成到业务系统时,最有用的是「同一视角对比」:把两次巡检的 3DGS 模型按同一控制点对齐,在同一相机轨迹下渲染两张图,差异区域一眼可见。这比给人一张 ply 让他自己转,实用得多。

坑:① 只做平移不做旋转,模型位置对了但朝向是歪的,叠上底图一看全是斜的。② 变换 ply 时只改了中心点,没改四元数与尺度,渲染出斜向拖影。③ 用参与求解的控制点算残差,永远是小值,必须留独立检查点。④ 交付网页查看器却没做压缩,手机打开黑屏,先压到 100 MB 以内再谈交付。

6.6.4 上三维底座:splat → 3D Tiles LOD

6.6.3 的交付表里,3D Tiles 那一行过去被标成「转换链不成熟」。2026 年这个结论要改:Cesium 在 2026-04 发布了带层次 LOD 的 3D Gaussian Splats 方案,把 splat 直接作为一种 3D Tiles 内容切片,CesiumJS 与 Cesium for Unreal 都能加载;Cesium ion 的重建管线也不再要求先生成 mesh 才能产出 splat tileset。也就是说,「splat 进三维地球」不再需要「先转网格、再贴点云」的绕路。

交付路径固定为三步:

① 对齐:局部模型 → 目标坐标系
   6.6.1 解出的 s/R/t 别再逐高斯改 ply
   splat tileset 的根节点 transform 直接吃这组参数
   Cesium 是 ECEF(Y-up),COLMAP 是相机系,中间用 ENU 站心系过渡
   不要手工拼矩阵,用引擎自带的 ENU 构造函数

② 切片:按高斯数与误差阈值分层
   每层高斯数量控制在 20 万~80 万量级(经验值)
   LOD 层级 3~5 级,级间误差比约 2 倍
   粗层只保留大尺度结构:地面、墙面、树冠轮廓

③ 加载:tileset.json + 按需请求
   首屏只请求根层与最近一级
   视锥外与远处的瓦片不请求
   用最大屏幕误差控制何时请求下一层

CesiumJS 侧加载的关键是别自己做坐标变换:

// 用 6.6.1 解出的平移作为 ENU 锚点,旋转与尺度进 tileset 元数据
const position = Cesium.Cartesian3.fromDegrees(120.1234, 30.5678, 12.0);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(position);

const tileset = await Cesium.Cesium3DTileset.fromUrl(
  "https://example.com/splat/tileset.json",
  { maximumScreenSpaceError: 16 }
);
tileset.root.transform = enu;
viewer.scene.primitives.add(tileset);

splat tileset 的加载支持与参数名会随版本变,2026-11 的 CesiumJS 为 1.147,最大屏幕误差与 splat 相关预算参数以你所用版本的 API 文档为准。

性能预算按「首屏可接受」反推,先在集显笔记本上过一遍再谈交付:

指标 目标(经验值) 超了会怎样 调节手段
首屏高斯数 ≤ 150 万 低端笔记本首屏卡 3~5 s 提高最大屏幕误差、砍根层细节
首屏下载量 ≤ 15 MB 4G 下白屏 提高粗层压缩比、减少 LOD 层数
显存占用 ≤ 2 GB 标签页崩溃、移动端直接退出 降最大屏幕误差、减少同时可见瓦片
稳态帧率 ≥ 30 fps(1080p) 旋转拖影 降 splat 预算、关阴影与后期特效

四条工程建议:

  • 对齐参数只写一次。把 s/R/t 写进 tileset 的根变换,不要在切片阶段先把 ply 整体变换一遍再切,否则改一个控制点就要重切全量瓦片。
  • LOD 分层按高斯数量定,不按文件大小定。渲染开销跟着高斯数走,文件小不代表渲染轻。
  • 首屏单独验收。一台集显笔记本加一部中端手机各开一次,这两类设备的显存就是交付下限。
  • 量测仍然回到 6.6.3 的结论。splat LOD 解决的是「看」,量测交给点云或网格层,两层用同一套控制点对齐后叠加,别指望从 splat 上读坐标。

坑:① 把 splat tileset 当普通 3D Tiles 直接塞进场景,忘了根变换,模型飞到地球另一侧。② 只切 1~2 级 LOD,远景全量加载,首屏就超预算。③ 每改一次控制点就重切一遍全量瓦片,一晚上切三次。④ 用桌面独显验收首屏,交付到手机白屏——显存预算要按最低档设备定。

6.7 用 DeepSeek Harness 编排流水线

前面每一节都是单条命令,真实项目里你要对同一个场景反复跑七步、还要对多个场景批量跑。手工敲命令的问题不是慢,是不可复现:昨天的参数、今天的参数、谁改了哪个值,两周后没人记得。这一节用 DeepSeek Harness(dsh)把流水线固化成脚本与技能,让「一键重建」变成可审计的动作。

6.7.1 一键脚本:抽帧 → COLMAP → brush → 导出

先把人工步骤写成 Bash 脚本,脚本负责确定性,DSH 负责写脚本、改脚本、根据日志修脚本。这是使用 AI 编程助手的正确姿势:不要让 AI 直接在你的生产目录上执行破坏性命令,让它产出脚本,你审核后执行。

给 DSH 的任务书要包含验收条件,否则它会给你一个「看起来能跑」的半成品:

任务:为 3DGS 重建写一个一键流水线脚本 build_3dgs.sh
输入:
  1) 视频文件路径(或已抽好帧的 images 目录)
  2) 输出场景目录
步骤:
  1) ffmpeg 抽帧(2 fps,最长边 1920,保持 EXIF)
  2) colmap feature_extractor / exhaustive_matcher 或 sequential_matcher / mapper
  3) 解析 model_analyzer 输出,注册率 < 85% 时打印失败并以非 0 退出
  4) brush 训练,参数来自同目录 tier.env(quick/standard/high 三档)
  5) 训练完成后把 exports 下最新 ply 复制到输出目录并重命名
约束:
  - set -euo pipefail
  - 所有路径可变,不允许硬编码 /home/xxx
  - 每步打印耗时与关键指标
  - 失败时打印复现命令
验收:
  - bash -n 语法检查通过
  - shellcheck 无 error 级问题
  - 在 ./samples/scene01 上跑通,产出 ply 存在且大小 > 10 MB

脚本骨架大致长这样(DSH 生成的版本会更长,这里只保留结构):

#!/usr/bin/env bash
set -euo pipefail

VIDEO="${1:?usage: build_3dgs.sh <video> <out_dir> [tier]}"
OUT="${2:?usage: build_3dgs.sh <video> <out_dir> [tier]}"
TIER="${3:-standard}"

WORK="$OUT/work"
IMAGES="$WORK/images"
mkdir -p "$IMAGES" "$WORK/sparse"

echo "== [1/5] 抽帧 =="
ffmpeg -hide_banner -loglevel warning -i "$VIDEO" \
  -vf "fps=2,scale='if(gt(iw,ih),min(1920,iw),-2)':'if(gt(iw,ih),-2,min(1920,ih))'" \
  -q:v 2 "$IMAGES/frame_%05d.jpg"

echo "== [2/5] COLMAP 特征与匹配 =="
colmap feature_extractor \
  --database_path "$WORK/database.db" \
  --image_path "$IMAGES" \
  --ImageReader.single_camera 1

colmap sequential_matcher \
  --database_path "$WORK/database.db" \
  --SequentialMatching.loop_detection 1

echo "== [3/5] 稀疏重建 =="
colmap mapper \
  --database_path "$WORK/database.db" \
  --image_path "$IMAGES" \
  --output_path "$WORK/sparse"

STATS=$(colmap model_analyzer --path "$WORK/sparse/0" 2>&1 || true)
echo "$STATS" | tail -n 20

echo "== [4/5] brush 训练 =="
source "./tier.${TIER}.env"
brush "$WORK" \
  --total-train-iters "$ITERS" \
  --max-resolution "$RES" \
  --max-splats "$MAXSPLATS" \
  --sh-degree "$SH" \
  --export-path "$OUT/exports/" \
  --export-name "${SCENE_NAME:-scene}_${ITERS}.ply"

echo "== [5/5] 收尾 =="
LATEST=$(ls -t "$OUT/exports/"*.ply | head -n 1)
cp "$LATEST" "$OUT/scene_final.ply"
echo "done -> $OUT/scene_final.ply"

配套的三档参数放成环境文件,谁改了什么一目了然:

# tier.quick.env
ITERS=5000;  RES=1280; MAXSPLATS=1500000; SH=1
# tier.standard.env
ITERS=30000; RES=1600; MAXSPLATS=4000000; SH=2
# tier.high.env
ITERS=60000; RES=1920; MAXSPLATS=8000000; SH=3

注意脚本里的注册率检查是软失败(只打印)。工程上更稳妥的做法是把 model_analyzer 的输出解析成数字再做硬判断,下面这条一行命令能取到注册图像数:

colmap model_analyzer --path "$WORK/sparse/0" 2>&1 | grep -E "Registered images|Points" | head -n 5

6.7.2 headless 批跑多个场景

有十来个场景要跑时,不要让 DSH 一个场景一个场景地对话。用 headless 模式把任务写成一条命令跑完,--json 输出机器可读的事件流,便于接 CI 或写日志:

# 单个场景,跑完即退
dsh --profile headless "按 build_3dgs.sh 的流程把 ./videos/scene01.mp4 处理成 3DGS 模型,参数用 standard 档,完成后汇报高斯数量与导出文件路径"

# 批量:外层用 shell 循环,内层每次启动一个 headless 会话
for v in ./videos/*.mp4; do
  name=$(basename "$v" .mp4)
  echo "=== $name ==="
  dsh --profile headless --json \
    "运行 ./build_3dgs.sh '$v' './out/$name' standard,失败时输出最后 50 行日志并说明最可能的原因" \
    > "./out/$name.dsh.json" 2> "./out/$name.dsh.err" || echo "FAILED: $name"
done

批跑的三个注意点:

  • workspace 是边界。headless 会话的 cwd 就是 workspace,文件系统操作与命令执行都受限在这个目录内。把 videos/、build_3dgs.sh、out/ 都放在同一个 workspace 下,不要在脚本里跨盘符引用数据。
  • 权限预设要显式给。沙箱与审批两轴决定 agent 能不能写文件、能不能执行命令。批跑用 --permission-mode 指定一个允许写工作区、执行命令的预设,不要用交互模式下才有的临时授权。具体预设名以 dsh --help 与当前版本界面为准。
  • 一次会话只做一件事。让 headless 会话「跑一个场景并汇报」比「跑完十个并汇总」稳,失败了只丢一个场景。

长任务不要塞在一个会话里硬等。DSH 支持后台任务(jobs),训练这种几十分钟的活可以让它起后台任务,会话继续做别的排查,用 job 输出回收结果。这是把 agent 当工程工具而不是聊天窗口的关键区别。

6.7.3 读日志定位失败:把排错经验固化成技能

headless 跑批最大的价值不是自动化跑成功,而是失败时有人替你读日志。把 6.2 与 6.4 的排错表交给 DSH,让它按症状给结论,比你逐条翻文档快得多:

任务:分析 ./out/scene03/ 下的失败日志
输入:
  - work/colmap.log(mapper 输出)
  - out/scene03.dsh.err
  - 训练日志片段
判断规则:
  1) 注册图像 < 85% → 检查匹配模式(exhaustive vs sequential)与重叠率
  2) 重投影误差 > 1 px → 误匹配,建议加标记物或换 matcher
  3) 训练 OOM 且出现在增长阶段 → 先降 max-resolution,再降 max-splats
  4) sparse/ 下有多个子模型目录 → 图像图不连通,按区域分组重建
  5) 找不到图片 → images.bin 里的 NAME 与 images/ 目录不一致
输出格式:
  - 一句话结论(最可能的原因)
  - 证据(日志里的具体行)
  - 修复命令(可直接复制)
  - 需要人工确认的点

同理,把「分辨率 × 迭代数 × SH 阶」的参数扫描交给 DSH 编排:让它生成参数矩阵、逐个起 headless 会话、把每次的耗时/高斯数/导出体积收成一张 CSV,最后给你一张对比表。这是它最擅长的活——不是替你做判断,是替你把十次重复实验跑完并且把数据整理好。

值得固化下来的流程,写成技能放在 $DSH_HOME/skills/ 下:每个技能一个目录,目录里放 SKILL.md,正文写清触发条件、输入、步骤、验收标准。例如「3DGS 数据集体检」技能,输入一个数据集目录,输出注册率、重投影误差、稀疏点分布、是否建议重拍。下次新项目直接加载技能,不用重新解释一堆背景。

坑:① 让 DSH 直接在 /data/raw 上执行清理命令,代理误删原始数据,正确做法是只读挂载原始数据、所有写操作落在 out/。② headless 会话报权限错误,多半是 workspace 没选对或权限预设过严,先 dsh --help 确认当前版本的参数名。③ 批跑脚本里不检查退出码,前面的场景失败后面继续跑,最后得到的是一堆不完整产物,用 set -e 或显式 || exit 1。④ 把含密钥的路径写进任务书,日志会原样回显,敏感信息用环境变量注入。

6.8 本章小结

  • 3DGS 用位置、协方差、不透明度、球谐四组参数显式表示场景,协方差必须由「缩放 + 旋转」重建才能保证半正定。
  • 渲染是投影 → tile 内深度排序 → alpha 混合三步,没有体渲染那样沿射线采样,所以比 NeRF 快一到两个数量级;代价是显存与逐帧排序开销。
  • 训练只有损失、反传、致密化三件事;致密化阈值与迭代数决定高斯数量,而高斯数量决定显存峰值,调参优先动分辨率和最大高斯数。
  • 采集质量决定七成成败:重叠率 70%+、至少两圈不同高度绕拍、锁定曝光、避免纯旋转与运动模糊。
  • COLMAP 命令序列固定为 feature_extractor → matcher → mapper → model_analyzer → image_undistorter,质量三指标是注册率 ≥ 85%、重投影误差 < 1 px、稀疏点数量足够。
  • VGGT 一次前向出位姿与深度,适合快速出结果和 COLMAP 失败时的备胎,但尺度是相对的且长序列有漂移。
  • brush 用 Rust + wgpu 实现,单二进制跨平台,读 COLMAP 数据集,关键参数是 total-train-iters / max-resolution / max-splats / sh-degree,OOM 时先降分辨率再降高斯上限。
  • SuperSplat 负责收尾:删浮点、补洞、调色、导出压缩格式;发布前一定压缩,否则网页端加载不动。
  • 无控制点的模型只有相对坐标;对齐用 7 参数 Helmert 最小二乘,尺度靠控制点/已知距离/传感器恢复,验收必须用独立检查点算 RMSE。
  • 用 DSH 把流程固化成脚本与技能、用 headless 批跑、让它读日志给修复命令,是把一次性实验变成可复现工程的关键。
  • 2026 年的 3DGS 已分化出 2DGS、3DGUT、MCMC 致密化、稀疏光栅化几个分支:要几何可信选 2DGS,要畸变原片直训选 3DGUT,怕高斯数爆炸选 MCMC,城市级规模靠稀疏光栅化。
  • gsplat 1.6 是 PyTorch 侧的工业实现(稀疏光栅化、多 GPU、PPISP、3DGUT 传感器),构建要 PyTorch 2.7+ 与 CUDA 12.8+;研究复现用 gsplat,现场快速出活用 brush。
  • VGGT 在 2026 年修了显存(同卡约 2~3 倍帧数)并有 VGGT-Omega 后继版;商用必须换成申请来的 VGGT-1B-Commercial 权重,默认研究权重不能进甲方项目。
  • brush 现在是 Rust + WebGPU 的跨平台实现,COLMAP 与 Nerfstudio 数据都认,本地 CLI 训练与浏览器试训两条路径分工明确。
  • SuperSplat 已暴露 WebMCP 工具,清理规则可以交给 agent 批处理;splat 交付可以走 Cesium 2026 年的层次 LOD tileset,不再必须先生成 mesh。

6.9 动手练习

练习一:用同一段视频对比三档 brush 配置。 输入:一段 60 秒手机环拍视频(绕建筑半圈以上)。要求:抽帧后用 COLMAP 出 sparse 模型,分别用 quick / standard / high 三档训练,记录耗时、导出的 ply 体积、高斯数量。验收标准:提交一张三行对比表,并说明哪一档在你机器上性价比最高,理由要引用具体数字。

练习二:故意拍坏一组数据并定位失败原因。 输入:自选场景,故意做出一种缺陷(只用纯旋转拍摄 / 全部对着白墙 / 把曝光锁定关掉走一圈)。要求:照常跑 COLMAP,用 colmap model_analyzer 记录指标,对照 6.2.2 的缺陷表判断症状,写清「哪个指标异常 → 哪条规则被违反 → 重拍怎么改」。验收标准:注册率或重投影误差有明显异常,且结论与缺陷一一对应。

练习三:VGGT 与 COLMAP 的位姿对比。 输入:同一组 20~30 张图。要求:分别用 COLMAP 与 demo_colmap.py --use_ba 出 sparse 模型,统一成 sparse/0 布局后各训练一版,计算两次重建在若干同名点上的相对偏差。验收标准:给出两版模型的对齐过程(可用 6.6.1 的脚本)、偏差数值,以及「在什么条件下选哪条路线」的结论。

练习四:控制点对齐与精度报告。 输入:任意已训练的 3DGS 模型 + 4 个以上已知坐标的控制点(可用测量仪器或场景中已知尺寸反推)。要求:用 6.6.1 的脚本求解 7 参数,作用到 ply 上,留 1~2 个独立检查点算 RMSE,输出一页精度报告(水平/高程分开)。验收标准:残差数值合理(厘米到分米级),报告里包含变换参数、检查点残差表、以及一条「是否可交付」的判断。

练习五:用 DSH 把流水线做成可批跑脚本并跑通两个场景。 输入:两个场景的视频或图片集。要求:按 6.7.1 的任务书写法让 DSH 生成 build_3dgs.sh 和三档参数文件,用 dsh --profile headless 批跑,失败场景让 DSH 读日志给修复命令并重跑。验收标准:两个场景都产出 ply,目录结构一致,失败日志有明确结论,脚本通过 bash -n 与 shellcheck。

6.10 延伸阅读

按「表示与变体 → 工具链 → 交付」排,先读前三条再动代码: