1. 先把两条路线的分工说清楚:几何求解与神经场拟合
这几年只要提到从照片做三维重建,SFM(Structure from Motion)和 NeRF(Neural Radiance Fields)这两个词几乎是绑在一起出现的。很多人第一次接触时的直觉是"新的替代旧的"——既然 NeRF 渲染效果那么惊艳,那 SFM 那套特征匹配、光束法平差的流程是不是该淘汰了?我在带新人时听到最多的一句话就是"直接跑 NeRF 就行了,为什么还要折腾 COLMAP"。
这个判断对了一半。NeRF 确实把"从照片里重建出可以自由漫游的场景"这件事的门槛拉低了一大截,但它并没有取代 SFM,反而比以往任何时候都更依赖 SFM 的产物。一个未经位姿标注的图像集合,喂给 NeRF 的原始实现是跑不出任何东西的——网络根本不知道这些照片是从哪儿拍的。所以更准确的描述是:SFM 负责回答"照片从哪个角度拍的",NeRF 负责回答"空间里长什么样",两者是上下游关系,不是替代关系。
这篇内容我想把这两个环节各自的技术核心、实操路径、以及我在实际项目中踩过的坑完整摊开讲。适合两类人看:一类是刚接触三维重建、想知道该从哪套工具链入手的新手;另一类是已经跑通过某个 demo、但结果总是飘、糊、崩,想搞清楚问题出在哪一环的实践者。文中会涉及具体的命令、参数、坐标系约定和排错思路,不需要你有很深的多视几何基础,但需要你至少上手跑过一次完整流程,否则一些细节会没有体感。
1.1 两种输出,本质是两类不同的数学对象
SFM 的输出非常"朴素":一组相机内参和外参(每张照片的位置和朝向),加上一个稀疏的 3D 点云。这些点云的数量通常在几千到几万之间,稀疏到肉眼看上去只是一层浮在空中的尘埃,但每个点的位置在数学上是被多视角三角化约束过的。整套流程的底层是多视几何:对极约束、本质矩阵分解、三角化、光束法平差。它的每一步都有明确的几何意义,误差可以追到具体的某一次匹配或某一次平差。
NeRF 的输出则是完全另一种东西:一个用多层感知机(或者哈希网格+小 MLP)表示的函数,输入是空间坐标 (x, y, z) 和一个观察方向 (θ, φ),输出是该点的体积密度 σ 和该方向的颜色 c。它不生成点,也不生成网格,你没法直接量它"这个平面离相机多远",只能通过渲染射线、沿射线积分密度来间接推断深度。这种表示的优点是连续、无分辨率上限、能表达半透明和镜面反射这类传统网格很难处理的效果;缺点也很明显——它是一个"黑盒场",几何精度不好直接验证,训练和渲染都要吃算力。
我一般这么跟人解释这个区别:SFM 像是在给房间做测量,拿卷尺一尺一尺量出墙角在哪;NeRF 像是给房间录了一段可以任意回放的影像,你能从任何角度看,但要说"这面墙精确厚度是 12.4 厘米",它给不出这个数。
1.2 为什么 NeRF 训练前必须先有位姿
体渲染的积分是在世界坐标系里做的。网络需要知道每一条采样射线是从哪个原点出发、朝哪个方向走的,才能把采样到的密度和颜色正确投影回像素上,和真实照片比对算损失。位姿就是这条射线的起点和方向。
有人会问,位姿不能一起学吗?可以,有一类工作就是位姿与辐射场联合优化,但那样做的收敛难度会陡增:位姿和场景几何是强耦合的,一起优化很容易收敛到"位姿和几何互相解释得通、但都和真实情况不符"的退化解。所以工程上最稳妥的路线仍然是先用 SFM 把位姿估出来,锁死,再训 NeRF。
这也解释了一个现象:很多人第一次跑 NeRF,渲染出来全是雾或者画面糊成一片,八成不是网络结构的问题,而是 COLMAP 那边给出的位姿不准,或者某个环节把坐标系搞反了。我见过最典型的一次,某开发者把 COLMAP 输出的四元数顺序当成 (w, x, y, z) 读,实际是 (x, y, z, w),结果相机朝向全部错乱,训练 30 万步后渲染结果像一锅融化的塑料。
1.3 两者能力边界的对照
把关键差异列成表,后面调试时对照着看会方便很多:
| 维度 | SFM | NeRF |
|---|---|---|
| 输出形态 | 相机位姿 + 稀疏点云(可稠密化) | 隐式辐射场(可导出深度、网格) |
| 数学基础 | 多视几何、非线性最小二乘 | 体渲染、隐式神经表示 |
| 尺度 | 单目下尺度不可知 | 继承自输入位姿,同样不可知 |
| 几何精度 | 高,可量化 | 依赖训练充分度,边界模糊 |
| 新视角渲染 | 不支持(只能投影点云) | 核心能力,支持连续视角 |
| 反光/透明 | 处理困难 | 相对友好 |
| 算力需求 | CPU 可跑,分钟到小时级 | 基本必须 GPU,小时级 |
| 典型失败模式 | 弱纹理、纯旋转、漂移 | 位姿错、过拟合、采样范围错 |
这张表是我自己在做技术选型时反复用到的东西。注意最后一行"典型失败模式"——这是后面几个章节要重点展开的部分,因为绝大部分项目失败都不是原理不懂,而是卡在这些具体故障上。
2. SFM 流水线拆到底:从特征点到光束法平差
很多人对 SFM 的理解停留在"跑一下 COLMAP 就有结果了",但真出问题的时候,你是需要知道该调哪一步的。整条流水线的骨架其实很清晰:提特征 → 匹配 → 几何验证 → 增量式重建(初始化、注册、平差循环)→ 可选稠密化。每一段都有它的失败条件。
2.1 特征提取与匹配:SIFT 为什么还没被彻底淘汰
SIFT 诞生已经二十多年,深度学习特征(SuperPoint、DISK、LoFTR 之类)在弱纹理和大幅视角变化下确实更强,但 COLMAP 的默认选项仍然是 SIFT 系的实现(内部叫 SiftGPU 或者 DSP-SIFT 变体),原因很实在:它对尺度、旋转、亮度变化有解析上的不变性,匹配结果可解释,不需要 GPU 也能跑,而且在"纹理充足、光照一致、视角变化不大"的常规采集里,它的表现和深度特征差距很小。
真正让深度特征有价值的场景是两类:一是低纹理表面(白墙、金属件、瓷砖地面),SIFT 在上面几乎提不出稳定点;二是大视角基线,比如环绕拍摄时相邻帧夹角超过 60 度,SIFT 的描述子就失效了。这两种情况下换 SuperPoint + SuperGlue 或者直接用 LoFTR 这类无需描述子的稠密匹配,重建成功率会明显提升。
特征数量的参数值得说一句。--SiftExtraction.max_num_features默认是 8192,很多人为了"提高精度"往上调到 32768,结果匹配时间翻几倍,重建质量却没有提升。我的经验是:单张图 2000 万像素以内,8192 到 16384 足够;如果场景纹理特别丰富(比如树叶、砖墙),反而应该降低这个值,因为过多的重复纹理特征会导致大量错误匹配,几何验证阶段会被 RANSAC 大量剔除,白费算力。
2.2 对极几何验证:匹配表里至少三成是错的
原始匹配结果里错误比例通常高得吓人——同一面砖墙上,任意两块砖的特征描述子可能都很像,于是产生海量错误对应。这一步必须做几何验证,标准做法是用基础矩阵 F 配合 RANSAC:
给定一对匹配点 x 和 x',它们必须满足 x'ᵀ F x = 0。RANSAC 每次随机抽 7 点或 8 点算一个 F,统计满足上式(在阈值内)的内点数,迭代到内点比例稳定。COLMAP 在--TwoViewGeometry.min_num_inliers上默认要求至少 15 个内点,低于这个数就判定这两张图没有可靠的重叠关系,直接丢弃这条边。
这里有个容易忽略的细节:内点比例比内点绝对数量更能反映问题。如果两张图匹配上 3000 个点,但只有 200 个是内点(6.7%),那说明要么两张图根本不重叠、要么场景里有大量重复纹理。这时候即使勉强保留这条边,后面三角化出来的点也会是错的。我在调试时习惯把--TwoViewGeometry.min_inlier_ratio显式设成 0.2 到 0.25 之间,宁可少要边,也不要脏边。
还有一个经常被误解的点:F 矩阵只对未标定相机有意义,一旦你知道内参 K,就该转到本质矩阵 E = Kᵀ F K,E 的奇异值必须满足 (σ, σ, 0) 的形式,分解后能得到 4 组 (R, t) 候选,再用三角化点的深度为正这个约束挑出唯一解。这一步是理解"为什么单目 SFM 尺度不可知"的关键——E 分解出的 t 是单位向量,长度信息在分解过程中就丢掉了。
2.3 增量式重建的三步循环:初始化、注册、平差
增量式重建是这个流程里最核心也最脆弱的部分,它是个循环:
- 选初始像对:从所有通过几何验证的像对里挑一对内点数多、基线足够长的作为种子。基线太短(两帧几乎同位置)会导致三角化精度极差,后续整个重建都会被带偏。COLMAP 用
--Mapper.init_min_num_inliers(默认 100)做门槛,我在实际项目里通常提到 300 以上,宁可多试几对。 - 注册新视角:对尚未注册的图像,用已知的 3D 点做 2D-3D 对应,跑 PnP + RANSAC 解出位姿。门槛是
--Mapper.abs_pose_min_num_inliers(默认 30),这个值偏低,弱纹理场景建议提到 50 到 80。 - 局部/全局平差:把新注册的相机和它看到的点一起做光束法平差(Bundle Adjustment),最小化重投影误差。平差用的是 Levenberg-Marquardt,大规模问题靠 Schur 补消元把点参数边缘化掉,只解相机参数,这是让 BA 能在几万点规模上跑起来的关键技巧。
这个循环会一直跑到没有新图像能被注册为止。实际项目里最常见的失败是"注册到一半停了"——比如 200 张图只注册进去 60 张。原因通常是中段某几张图质量差(运动模糊、曝光过度),导致从它们开始的那个子图无法和其他部分连上。解决办法不是无脑调低阈值,而是先把模糊帧剔除,重跑一遍。
2.4 尺度、漂移与闭环:单目重建绕不过去的三个问题
尺度不可知是单目 SFM 的物理限制,不是 bug。你重建出的模型整体缩放任意倍数,重投影误差都不变。要在真实单位下使用,必须引入外部约束:场景里放一个已知长度的标尺、用双目/深度相机、或者用 IMU 提供的位移先验。我见过不少团队在这上面翻车——拿着重建结果去量尺寸,差了两倍多,最后发现是没做尺度标定。
漂移是累积误差的必然结果。增量式重建本质上是链式的,每一帧的位姿误差会传递到下一帧,几百帧之后整体可能偏移几十厘米。表现是重建出来的点云"首尾对不上",绕一圈回到起点时,起点位置的墙面出现了两层重叠的复制。缓解手段是回环检测加位姿图优化,COLMAP 里有--Mapper.ba_global_images_ratio和--Mapper.ba_global_points_ratio控制全局 BA 的触发频率,这两个值调小(比如从默认 1.1 调到 1.05)会让全局 BA 更频繁地执行,能明显压制漂移,代价是时间变长。序列拍摄(视频抽帧)时尤其建议调小。
纯旋转序列是另一个高频坑。如果拍摄时相机只转不动(比如站在一个点上原地转圈拍全景),基线为零,三角化退化,重建必然失败或者点云全部堆在一个球面上。这不是参数能救的,只能重新采集——移动起来,让相邻帧之间有实实在在的位移。
顺带说下稠密化。稀疏点云通常不够用,想要有面的效果需要跑 MVS(多视图立体),COLMAP 自带的流程是:
colmap image_undistorter \ --image_path ./images \ --input_path ./sparse/0 \ --output_path ./dense \ --output_type COLMAP colmap patch_match_stereo \ --workspace_path ./dense \ --workspace_format COLMAP \ --PatchMatchStereo.geom_consistency true colmap stereo_fusion \ --workspace_path ./dense \ --workspace_format COLMAP \ --input_type geometric \ --output_path ./dense/fused.plyPatchMatch 那一步非常吃显存和时间,200 张 2000 万像素的图在单卡上跑一整晚是常态。如果只是给 NeRF 提供位姿,这一步完全没必要做——NeRF 只需要稀疏模型里的相机参数。
3. NeRF 的输入准备:位姿、坐标系与归一化
拿到 COLMAP 的稀疏结果之后,很多人以为直接就能训了,实际上从"COLMAP 输出"到"NeRF 期望的输入"之间还有一段转换,这段转换是新手翻车率最高的地方,因为一旦坐标系搞错,程序不会报错,只会安静地训出一个糊掉的结果。
3.1 三种数据格式的坐标系坑
业界常见的几种数据组织方式,坐标系约定各不相同,必须搞清楚:
| 格式 | 相机朝向 | Y 轴方向 | 位姿表示 |
|---|---|---|---|
| COLMAP (OpenCV) | +Z 朝前 | +Y 朝下 | 世界到相机 (R, t) |
| Blender / OpenGL | -Z 朝前 | +Y 朝上 | 相机到世界 |
| LLFF / NeRF 原始 | -Z 朝前 | +Y 朝上 | 相机到世界 |
三处差异,每一处搞反都会让训练彻底失败:朝向轴的正负、Y 轴朝上还是朝下、变换方向是世界到相机还是相机到世界。COLMAP 习惯存世界到相机的变换,NeRF 代码里习惯用相机到世界(c2w),这两者互为逆矩阵。如果你只转置不平移取逆,得到的矩阵看着像那么回事,但相机位置全错,训出来的结果就是一片模糊的平均颜色。
实操上,最省事的路线是别自己写转换,直接用现成工具。nerfstudio 提供了一条命令搞定从原始图片到训练数据:
ns-process-data images \ --data ./raw_images \ --output-dir ./data/nerfstudio \ --camera-type perspective \ --matching-method exhaustive它会自动跑 COLMAP、做 undistort、转换坐标系、生成transforms.json。这里有个细节值得注意:ns-process-data默认会做图像去畸变,这会裁掉一部分边缘像素。如果你的图像畸变很大(广角镜头),去畸变后的有效视野会明显缩小,重建时会发现四周信息丢了。这种情况建议在 COLMAP 阶段就用OPENCV或FULL_OPENCV相机模型把畸变参数估出来,而不是用SIMPLE_RADIAL硬套——畸变模型选错,重投影误差会偏大,位姿精度下降。
另外还有一批实现(比如一些早期的 PyTorch 版本)读取的是 COLMAP 的images.bin转出来的文本格式,四元数顺序是 (qw, qx, qy, qz),而很多数学库(scipy 的Rotation.from_quat)期望的是 (x, y, z, w)。这个顺序错位是隐蔽性最强的 bug 之一,因为四元数归一化之后看起来都合法,错的只是旋转轴。
3.2 场景归一化:为什么几乎所有实现都要把相机缩进单位球
几乎所有开源实现都有一段归一化代码,做两件事:把相机中心坐标的均值平移到原点,然后整体缩放,让所有相机中心到原点的最大距离等于某个固定值(常见是 1.0,Blender 合成数据里是 4.0 附近的球半径)。
这么做的理由很实际。第一,位置编码对坐标数值范围敏感,如果场景坐标在几百米量级,sin(2^L · x)这种高频项会直接进入混乱的振荡区域,梯度爆炸或者变成噪声;第二,不同场景的绝对尺度差异极大,归一化之后可以用同一套超参(学习率、near/far、采样数)来训,不用每个场景重新调;第三,体渲染的采样是沿射线在 [near, far] 里均匀分层,未归一化的话 far 可能大到几十万,采样密度完全不够。
归一化的实现通常是这样的:
# 假设 c2w 是 (N, 4, 4) 的相机到世界矩阵 centers = c2w[:, :3, 3] center_avg = centers.mean(axis=0) translation = np.eye(4) translation[:3, 3] = -center_avg c2w = translation @ c2w centers = c2w[:, :3, 3] scale = 1.0 / np.linalg.norm(centers, axis=-1).max() c2w[:, :3, 3] *= scale # 只缩放平移部分,旋转部分不受影响注意最后一行的写法:只缩放平移分量,旋转矩阵部分不能乘 scale,否则相机会被"拉变形"。这个坑我见过不止一次,有人图省事直接c2w = c2w * scale,结果所有相机朝向都乱了,训练出来全是重影。
归一化之后,near 和 far 也要跟着变。经验值:如果场景是物体级的环绕拍摄,near 设 0.05、far 设 2.0 到 3.0 通常能覆盖;如果是室内房间的 forward-facing 拍摄,far 要放大到 6 到 8。判断方法很简单:把点位云和相机位置一起可视化,看所有点是不是都落在 [near, far] 范围内。超出范围的点会被采样截断,表现为渲染图里背景被"削掉"一块或者远处糊成一片。
3.3 near/far 设错的代价比想象中大
这个参数看起来不起眼,但它直接决定采样区间。设小了,远处的墙和天空没被采样,渲染出来是空的;设大了,同样数量的采样点被摊薄在更长的射线上,近处细节得不到足够采样,表现为表面发虚、边缘毛刺。
有一个诊断技巧很有用:训练完几百步之后,把每条射线的权重分布画出来(就是体渲染里的 weights)。如果权重几乎全部集中在 z_vals 的最后一个采样点附近,说明 far 设小了,物体被截断;如果权重在前 20% 的采样点里就已经衰减完,说明 near/far 范围给大了,采样密度浪费了。这个图能省掉大量瞎调的时间。
分层采样(perturb)在训练时开启、推理时关闭,这是标准做法。训练时加抖动是为了让网络见到连续空间的不同采样位置,避免过拟合到固定的离散层;推理时关掉抖动,让采样确定化,输出更干净。不过关闭抖动之后,只用粗网络的话会看到明显的分层条纹(banding),所以通常还要配合重要性采样,在密度高的区域再撒一层细采样点。
4. 训练环节的实操细节:从位置编码到哈希网格
真正开始训的时候,围绕网络的几个设计选择会直接影响收敛速度和最终质量。这部分我想把"为什么这么设计"讲透,因为照着代码抄的人很多,知道每一行意图的人不多。
4.1 体渲染公式在代码里到底长什么样
体渲染的核心是这条连续积分:
C(r) = ∫_{t_n}^{t_f} T(t) · σ(t) · c(t) dt,其中 T(t) = exp(−∫_{t_n}^{t} σ(s) ds)
T(t) 是从相机到 t 位置的透射率,可以理解为"光线走到这里还没被挡住的比例"。离散化之后变成对采样点的加权求和:
# z_vals: (N_rays, N_samples) 采样点的深度 # sigma: (N_rays, N_samples) 密度 # colors: (N_rays, N_samples, 3) 颜色 dists = z_vals[..., 1:] - z_vals[..., :-1] dists = torch.cat([dists, torch.full_like(dists[..., :1], 1e-3)], -1) alpha = 1.0 - torch.exp(-sigma * dists) # 每个采样点的遮挡概率 trans = torch.cumprod( torch.cat([torch.ones_like(alpha[..., :1]), 1.0 - alpha + 1e-10], -1), -1 )[..., :-1] # 透射率,累乘 weights = alpha * trans # 每个点的贡献权重 rgb = (weights[..., None] * colors).sum(dim=-2) # 沿射线积分成像素这段代码里有几个容易出错的地方。1e-10那个小量不能省,cumprod一旦遇到 0 就会把后面全部清零,加上它保证数值稳定。最后一个 dists 用 1e-3 填充是因为最后一段没有下一个采样点,用一个极小值代替,避免射线末端贡献异常。trans的拼接顺序也要注意,先补一个 1 在头部,累乘之后再去掉最后一个,这才是"到第 i 个点之前"的透射率。
损失就是渲染出的 rgb 和真实像素做 L2:
loss = ((rgb - gt_rgb) ** 2).mean()早期的 NeRF 会用粗网络和细网络各算一次损失再相加,两个网络共享权重之外的参数,细网络的采样位置由粗网络的权重分布决定(重要性采样)。
4.2 位置编码与视角依赖:两个设计各自的用意
位置编码是 NeRF 能做高频细节的根本。直接把 (x,y,z) 这种低维坐标喂给 MLP,网络会严重偏向低频——这是神经网络的谱偏置(spectral bias),它天然倾向先拟合平滑函数。所以要把坐标映射到高维:
γ(p) = (sin(2⁰πp), cos(2⁰πp), sin(2¹πp), cos(2¹πp), …, sin(2^{L−1}πp), cos(2^{L−1}πp))
def positional_encoding(x, L): out = [x] for i in range(L): out.append(torch.sin(2.0 ** i * x)) out.append(torch.cos(2.0 ** i * x)) return torch.cat(out, dim=-1)位置坐标一般取 L=10,方向向量取 L=4。为什么方向用的 L 更小?因为视角依赖主要用来表达镜面高光这类低频变化,高频的方向编码会让网络把光照变化当作几何细节拟合,产生"exploding"的伪影。这个不对称是有意设计的,不是随手写的。
视角依赖的设计更精妙:网络先只用位置计算密度 σ 和一个中间特征,再把特征和方向拼接算出颜色 c。密度与方向无关,保证了同一个空间点的几何是唯一的(否则从不同角度看密度不同,几何就不成立了);颜色与方向相关,才能表达反光和菲涅尔效应。
这里我踩过一个很典型的坑:训练时如果把世界坐标系的方向向量直接喂进去,网络会学到"从某个固定方向看永远是亮的",只要测试视角和训练视角的分布不同,高光位置就会错得离谱。正确做法是把方向转换到每个相机自己的局部坐标系(通常是用相机的旋转矩阵把世界方向转成相机坐标系下的方向)。这样网络学到的"视角依赖"才是相对于相机的,泛化到新视角时高光位置才对。
4.3 收敛慢怎么办:从纯 MLP 到哈希网格的加速路径
原始 NeRF 一个场景要训一两天,这是它落地最大的障碍。后来出现的加速路线大致分三类:
| 方案 | 核心思路 | 典型提速 | 代价 |
|---|---|---|---|
| 哈希网格编码 | 用多分辨率可学习哈希表代替位置编码 | 数十倍 | 显存占用上升 |
| 张量分解 | 用低秩张量表示三维网格 | 十几倍 | 实现复杂,需调秩 |
| 深度引导采样 | 用单目深度先验减少无效采样 | 数倍 | 依赖深度估计质量 |
最实用的还是哈希网格,Instant-NGP 那套参数配置值得记下来:16 个分辨率层级(levels),基础分辨率 16,每层缩放 1.38 到 1.5 倍,哈希表大小取 2^19。这几组数不是随便定的——层级数决定能表达的最高频率,缩放倍率决定层级之间的覆盖是否连续,哈希表大小决定碰撞率。
哈希碰撞是这类方法固有代价。不同空间位置映射到同一个哈希桶时会产生"虚假密度",表现为场景里凭空出现一些细碎的漂浮物。缓解手段有两个:一是提高表大小(从 2^19 提到 2^20,显存翻倍但明显干净),二是配合一个小 MLP 做解码,让网络有机会纠正碰撞带来的错误(纯查表的话没法纠错)。
4.4 过拟合与伪影:训练集 PSNR 很高但一换角度就崩
这是最坑的一种情况,因为监控指标看起来完美。训练集 PSNR 45dB 以上,你满心欢喜地在两个训练视角中间插一个视角渲染,结果画面糊成一团浆糊。原因是视角太稀疏,网络只用少数几个角度来"记住"了训练图像,没有真正学出连续场。
判定方法是留出验证集:从采集的图像里均匀抽 10%~20% 不参与训练,只看验证 PSNR。如果训练 PSNR 涨到 40 而验证 PSNR 卡在 26 不动,那就是典型过拟合。
缓解手段按有效性排序:
- 增加视角数量,这是根本解法。环绕拍摄时相邻视角夹角建议控制在 10 度以内,室内场景每隔 20~30 厘米拍一张。
- 降低网络容量。哈希表层级从 16 降到 12,或者 MLP 隐藏层宽度从 256 降到 128。
- 早停。验证 PSNR 连续若干步下降就停,别硬训到 30 万步。
- 加入深度平滑正则或者 TV 损失,抑制高频噪声。不过权重要小,调大了会把细节一起抹掉。
还有一类伪影是"浮空絮状物",出现在相机路径周围的空间里。这通常是位姿误差引起的:某几张图的位姿估偏了几厘米,网络为了让这些图渲染正确,就在对应位置塞了一团低密度云雾来"糊弄"。治法只能是回 COLMAP 重新处理那几张图,网络侧调参救不了。
5. 采集与排错:那些在文档里查不到的失败模式
写了这么多原理,最后决定成败的往往还是采集那一刻。我在几个模拟项目里反复验证过一个结论:采集质量决定上限,算法参数只能决定离上限多近。这一章把采集要点和典型故障的排查链路整理出来。
5.1 采集阶段就要定下来的六件事
第一,曝光和白平衡锁死。自动曝光会让同一面墙在不同帧里亮度不同,NeRF 会把这部分亮度差异吸收到颜色场里,表现为渲染时场景表面出现奇怪的明暗斑块。手机拍摄的话,用专业模式手动锁定 ISO 和快门。
第二,避免运动模糊。快门速度不够或者移动太快,边缘会出现拖影。特征是特征点匹配内点率大幅下降。室外光照足的时候问题不大,室内务必补光。
第三,基线要够但要均匀。相邻两帧既不能太近(三角化精度差),也不能太远(特征匹配不上)。经验值:物距 1 米时,相邻帧位移 5 到 15 厘米。序列拍摄的话,视频抽帧间隔要根据移动速度调,走快了就多抽,走慢了就少抽。
第四,重叠度至少 60%。这个数字来自匹配的稳定性——重叠低于 60% 时,SIFT 匹配数量会断崖式下跌。
第五,别绕着一个点转。前面说过,纯旋转退化。要么平移,要么边转边移。
第六,拍之前先想清楚要重建哪部分。NeRF 的视锥是有边界的,你只拍了正面,背面渲染出来就是乱七八糟的幻觉。想要环绕效果就老老实实绕一圈,包括俯视和仰视。
5.2 位姿漂移的排查链路
假设你拿到一个"点云分成了两团、中间断掉"的结果,怎么查?我一般的顺序是这样:
先看匹配图。COLMAP 的model_analyzer能看到每张图注册了多少点、重投影误差多大。误差超过 1.5 像素的图要重点关注。
colmap model_analyzer --path ./sparse/0再看两张子图之间的连接。用--Mapper.min_num_matches这个参数可以把匹配门槛临时调低跑一次,看能不能把两团连起来。如果能连上但误差大,说明是弱纹理导致的匹配质量差;如果根本连不上,说明两组图之间没有共享视野,采集时中间漏拍了。
然后检查是不是畸变模型惹的祸。用一个错误的相机模型(比如实际是广角却用了 SIMPLE_RADIAL)会导致边缘区域的重投影误差系统性偏大,整个重建呈碗状弯曲。换 FULL_OPENCV 重跑通常能改善。
最后才考虑调 BA 参数。把--Mapper.ba_global_function_tolerance从默认的 1e-6 放宽到 1e-5,让全局 BA 多迭代几轮;把--Mapper.ba_local_max_num_iterations提到 50。这些调整能救回一部分漂移,但救不回采集缺失。
5.3 纹理、反光与透明物体的处理策略
弱纹理是 SFM 的天敌。白墙、纯色桌面、金属件表面,SIFT 一个点都提不出来。对策有三:一是往场景里贴临时标记(打印的棋盘格或者二维码),采完再后期处理掉;二是投影仪投散斑图案,人为制造纹理,这个方法在工业扫描里很常见;三是换深度特征,LoFTR 这类基于 Transformer 的匹配器在低纹理下确实有效,代价是显存和速度。
反光面对 SFM 和 NeRF 都是麻烦。SFM 层面,反光会导致同一个物理位置在不同帧里呈现不同外观,特征描述子对不上;NeRF 层面,反光其实是可以被视角依赖项表达出来的,但前提是位姿足够准。所以处理反光的原则是:先解决 SFM 的位姿问题(用偏振片压掉一部分镜面反射,或者改变打光角度避开),再让 NeRF 去学剩下的一点高光。
透明物体更棘手。玻璃杯、水面,特征匹配几乎全错,因为你看穿过去了。这类场景基本只能靠额外手段:喷哑光涂层(破坏性,慎用)、偏振成像、或者干脆用结构光扫描替代。
6. 选型判断与验收标准:什么时候用哪一套
技术选型这部分没有标准答案,但有几个判断维度是通用的。
6.1 按需求选:测量、渲染、还是资产生产
如果最终目的是测量——比如量房间尺寸、检查零件形位——那 SFM 加 MVS 是更合理的选择。稠密点云的误差是可追溯的,你可以拿标尺去验证;NeRF 的深度图则要经过额外的转换,精度还不一定能保证。
如果目的是新视角渲染——比如商品展示、虚拟看房、影视预演——NeRF 优势明显。传统点云渲染在视角变化时会出现空洞和拉扯,NeRF 因为是连续场,插值天然平滑。
如果目的是生成可导入引擎的网格资产(游戏、VR 场景),那通常要走"NeRF 训练 → 提取密度场做 marching cubes → 网格后处理"的路线,或者直接用 3D Gaussian Splatting 这类显式表示,它在渲染速度和编辑便利性上确实更好。不过 3DGS 对位姿精度的要求比 NeRF 更高,SFM 那一步不能省。
6.2 精度验收:PSNR 高不等于几何对
这是我特别想强调的一点。很多团队验收 NeRF 只看 PSNR 和 SSIM,这两个是渲染指标,跟几何精度没有严格对应关系。一个几何完全错误但颜色纹理贴合的场景,PSNR 可以很高。
几何精度的验证建议用这几招:
- 多视角深度一致性:从两个不同视角渲染深度图,把它们投影到同一个参考坐标系,看是否重合。真实几何应该在毫米到厘米级重合。
- 与稀疏点云对比:SFM 出的稀疏点虽然是"尘埃",但每个点的位置是几何约束过的,可以作为真值参考。把 NeRF 渲染深度在对应像素上采样,和稀疏点的深度比,偏差超过阈值就说明几何有问题。
- 尺度标定:放一个已知尺寸的物体进场景,重建后量一下,反推尺度因子。这一步是必须的,除非你有其他尺度来源。
| 验收项 | 指标 | 参考阈值 |
|---|---|---|
| 渲染质量 | 验证集 PSNR | 物体级 ≥ 28 dB,室内 ≥ 25 dB |
| 位姿质量 | 平均重投影误差 | ≤ 1.0 像素 |
| 几何一致性 | 跨视角深度偏差 | 相对场景尺度 ≤ 2% |
| 完整性 | 有效视角覆盖率 | ≥ 90% |
| 尺度误差 | 标尺测量偏差 | ≤ 1% |
这些数字不是硬标准,是我用过之后觉得比较能反映"能不能交付"的经验线。
6.3 融合思路:SFM 打底、NeRF 提渲染、网格化落地
实际项目里最有价值的往往是把两套东西串起来用,而不是二选一。一条比较稳的链路是:
先用 COLMAP 出稀疏位姿,把重投影误差压到 1 像素以内,这一步是地基,不达标就重采;再用好的位姿训 NeRF,把渲染质量做上去;最后从 NeRF 的密度场里提取网格(或者用 3DGS 重建后做泊松重建),交给下游做编辑和优化。
这条链路的每一环都可以独立验证:位姿环节看重投影误差和点云形状,辐射场环节看验证 PSNR 和新视角一致性,网格环节看有没有孔洞和翻面。哪一环出问题就在哪一环停住,不要带着问题往下走——带着错位姿训 NeRF,训完发现是位姿问题,浪费的是双倍时间。
还有个实用的小技巧:训 NeRF 之前,先把 COLMAP 的点云和相机位置在一张 3D 图里看一眼。相机应该沿着你拍摄的路径排成一条环或者一个网格,点云应该和场景轮廓大致贴合。这个 30 秒的检查能拦住大部分坐标系错误和归一化错误——相机位置明显错位、或者全挤在一个点上,那就是位姿转换出问题了,不用等训练完才发现。
我自己在这些项目里最深的体会是,三维重建这行的门槛不在算法公式,而在对"数据质量决定一切"这件事的敬畏。同一套代码,采集做得好和做得差,结果能差出一个数量级。所以我现在的习惯是:动手跑任何流程之前,先花二十分钟把采集方案在纸上画一遍——相机走什么路径、相邻帧间隔多少、光线怎么打、有没有弱纹理死角。这二十分钟省下来的调试时间,通常是它的十倍以上。