
简介这份《nerf-slam的复现文档》面向希望入门或复现神经辐射场与同步定位建图结合方案的研究者和工程实践者聚焦稠密三维重建流程。压缩包仅含1个docx文件大小约11.01MB以文字讲解和复现记录为主便于按章节查阅、摘录与做笔记。内容围绕追踪与建图两条主线展开追踪部分涉及基于协方差的稠密SLAM、光流估计和稠密光束法平差建图部分覆盖概率体素化神经辐射场、哈希编码加速及深度与颜色联合监督并说明深度协方差加权、舒尔补和矩阵分解等关键细节。文档还记录了作者复现时遇到的问题与解决思路对深度图噪声、异常值及无纹理区域的重建稳定性有所讨论并给出安装、数据下载与运行等环节的参考兼具范文式梳理和素材参考价值。已有2326人学习适合需要理解算法脉络、排查复现难点或整理实验记录的读者。1. 单目 RGB 也能稠密重建差别体现在一张方差图上多数人第一次跑 nerf-slam 的三维重建复现第一反应是先把 Instant-NGP 训快一点换卡、调学习率、加大 hash 表结果重建出来的东西依旧发糊、边缘抖动。真正的分水岭在追踪前端输出的东西上它交付的不是一张深度图而是一张逐像素带方差的深度图。同一段单目 RGB 序列丢掉方差直接监督和带着方差加权监督收敛速度与边缘锐度差出量级。这个结论解释了一个更有意思的现象为什么在工程上被稀疏关键点地图 dominate 了很多年的 SLAM 领域一个 NeRF-base 的方案敢只吃 RGB。把深度当成「带不确定性的观测」而不是「真值」是整条链路的支点。搞懂这支点怎么从光流网络一路传到体渲染的 loss才算真的读懂这份复现文档。2. 从 RAFT 到 Droid光流网络怎么变成位姿估计器RAFT 在这条链路里的角色近似于给每个像素准备了一张「可以反复查的相似度表」。Droid 想重用这张表但不能重用它的输出语义——RAFT 输出的是像素位移SLAM 需要的是位姿和深度。这一章拆的就是这个语义转换发生在哪里。2.1 相关体把匹配问题压成一次查表RAFT 的结构分三块特征编码器从两张输入图各提一遍逐像素特征上下文编码器只从第一张图提特征相关层把所有特征对做内积得到一个四维的 W×H×W×H 张量更新算子在这个张量里反复查值迭代修正光流。那个四维张量就是所谓的 correlation volume。它的第 (i, j, k, l) 个元素表示第一张图坐标 (i, j) 处的特征和第二张图坐标 (k, l) 处的特征有多像。这个设计的关键取舍在于它放弃了「先算描述子再匹配」的两段式流程把匹配变成了对一张预先算好的表做索引。表的最后两维还会按 1/2、1/4、1/8 做平均池化形成一组多尺度体积粗尺度负责在大位移时兜住细尺度负责收尾。代价也很直白显存占用随分辨率四次方增长。所以工程实现里几乎不会在全分辨率上建满而是靠多尺度池化把粗层压下来。2.2 更新算子为什么 convGRU 不该直接吐位姿RAFT 的更新算子做的是一个迭代精化拿当前光流估计去 correlation volume 里查一组值连同当前光流、上下文特征一起送进 convGRU输出光流的增量。Droid 把这套东西原样搬过来只改了它输出的对象。在 Droid 里第二张图上的对应位置不再是「像素位移」而是一个由几何投影算出来的点 p_ij——帧 i 上的像素 p_i配合估计出的逆深度 d_i 和相对位姿 T_ij投影到帧 j 上得到的坐标。convGRU 输出的不再是全局光流增量而是这个 p_ij 的残差 r_ij外加一个置信权重 w_ij。这个改动看起来很小意义很大。光流增量是一个二维量没有几何约束网络可以随便编而 p_ij 的残差是挂在明确的几何模型上的误差被限制在「投影不准」这一个自由度上。更关键的是那个 w_ij它相当于网络在对每个像素说这条对应关系我有多信。后面整套协方差加权监督源头就在这个权重上。下面这段伪代码把从像素到对应点的几何计算摊开参数的物理含义都在注释里import torch def project(points_cam, K): 相机坐标系三维点 - 像素坐标齐次归一化后乘内参 z points_cam[..., 2:3].clamp(min1e-6) # 防止除零深度必须为正 uv points_cam[..., :2] / z # 归一化平面 return (K torch.cat([uv, torch.ones_like(z)], dim-1).unsqueeze(-1)).squeeze(-1) def backproject(pix, inv_depth, K_inv): 像素 逆深度 - 相机坐标系三维点用逆深度是为了让优化变量在零点附近良态 ray (K_inv torch.cat([pix, torch.ones_like(pix[..., :1])], dim-1).unsqueeze(-1)).squeeze(-1) return ray / inv_depth.clamp(min1e-6) # ray 的 z 分量被逆深度归一 def compute_pij(pix_i, inv_depth_i, T_ij, K, K_inv): 帧 i 的像素投影到帧 jp_ij Pi(T_ij * Pi^-1(p_i, d_i)) X_i backproject(pix_i, inv_depth_i, K_inv) # 帧 i 相机系下的点 X_j (T_ij torch.cat([X_i, torch.ones_like(X_i[..., :1])], dim-1).unsqueeze(-1)).squeeze(-1) return project(X_j[..., :3], K), X_j[..., 2] # 返回像素坐标和投影后深度inv_depth而不是深度本身作为优化变量是视觉 SLAM 的老规矩逆深度在无穷远处趋于零优化过程中的数值范围更收敛而且单目情形下深度本来就没有绝对尺度用逆深度表达能把尺度漂移摊平。K_inv是内参矩阵的逆T_ij是帧 i 到帧 j 的相对位姿。函数末尾多返回一个投影后深度是为了在后续做遮挡判断时可以顺手剔掉 z ≤ 0 的点。2.3 frame graph更新算子作用在边上而不是帧上传统光流是一对一图 A 到图 B。Droid 把它推广成帧图——每个节点是一帧每条边是一对帧之间的稠密对应关系。同一个像素可以在多条边上被观测到这就把「两视图的光流」升级成了「多视图的约束网络」。这个结构上的改动是为了让后面那层优化器有东西可解。如果只有单条边误差只能靠一次投影约束掉无法回头修正位姿有了帧图同一批像素在多个视角上的残差可以联立位姿和深度才有互相纠错的空间。而 convGRU 之所以能作用于边、预测残差也因为边的两端都带着明确的几何量一个位姿、一组逆深度、一组内参。到这一步追踪前端的产物就清楚了一堆带权重的像素级残差以及每条边上残差对位姿、对逆深度的雅可比。这些量交给下一层才轮到真正的优化器出场。3. DBA 层舒尔补凭什么能先解相机再解深度稠密 BA 和传统 BA 在数学上是同一件事区别只在规模稀疏 SLAM 里路标点是几千个特征点稠密 SLAM 里路标点是每一帧的每一个像素。规模一变解方程的策略就必须跟着变。3.1 H 矩阵的块结构稀疏性藏在哪非线性最小二乘的每一步最终都归约成解一个线性系统 H δ b其中 H JᵀWJb -JᵀWr。把所有待优化量排成一个向量顺序是「先相机、后路标点」H 就会呈现一个漂亮的分块结构H [ B E ] B: 相机块c×c对角上是每帧 6×6 的位姿块 [ Eᵀ C ] C: 路标块p×p对角上是每个像素 1×1 的逆深度块 E: 关联块稀疏只有观测到的相机-像素对才非零B和C的对角块之所以小是因为每个位姿只有 6 个自由度、每个逆深度只有 1 个自由度E之所以稀疏是因为一帧只观测到场景的一部分。这个结构是所有 BA 实现的地基Droid 并没有发明新东西它只是把 p 从几千推到了几十万。到这一步一个自然的问题冒出来既然 C 是对角块矩阵为什么不直接把整个 H 求逆因为 H 的维度是 6c p直接求逆的复杂度是三次方级别p 一大就彻底不可行。3.2 舒尔补把大矩阵的逆问题降维舒尔补的思路是消元。把上面的分块方程展开成两个方程从第二个方程里解出 δ_d再代回第一个δ_d C⁻¹ (b_d - Eᵀ δ_c) (1) (B - E C⁻¹ Eᵀ) δ_c b_c - E C⁻¹ b_d (2)关键点在于 C 是对角块矩阵它的求逆是逐块 1×1 或逐块小矩阵的求逆代价几乎可以忽略。于是 (2) 里的B - E C⁻¹ Eᵀ可以被显式构造出来它的规模只和相机数量有关与像素数量无关。先解 δ_c再代回 (1) 拿 δ_d这就是「先解相机、再解深度」的全部理由。B - E C⁻¹ Eᵀ在文献里常被记作舒尔补矩阵它在数值上仍然是稀疏正定的可以用 Cholesky 分解 H L Lᵀ 求解。下面这段 numpy 代码把消元、求解、回代三步串起来可以直接对着跑通一个小规模例子import numpy as np from scipy.linalg import cho_factor, cho_solve def dba_step(B, E, C, b_cam, b_dep): B: (c, c) 相机块 E: (c, p) 关联块 C: (p, p) 对角路标块 b_cam: (c,) 相机梯度 b_dep: (p,) 深度梯度 返回 delta_cam, delta_dep 以及相机边缘协方差 C_inv np.linalg.inv(C) # C 为对角块求逆代价可忽略 S B - E C_inv E.T # 舒尔补只和相机数量有关 rhs b_cam - E C_inv b_dep c_factor cho_factor(S, lowerTrue) # Cholesky: S L L^T delta_cam cho_solve(c_factor, rhs) delta_dep C_inv (b_dep - E.T delta_cam) # 回代求深度增量 cov_cam cho_solve(c_factor, np.eye(S.shape[0])) # 相机边缘协方差 S^-1 return delta_cam, delta_dep, cov_camcho_factor返回的是分解结果而不是逆矩阵cho_solve复用这个分解同时解方程和求逆避免重复分解。cov_cam直接就是 S 的逆也就是相机位姿的边缘协方差——这一步是整条链路里最容易被忽略、但对下游 NeRF 最关键的一步。注意C_inv用显式求逆是可以接受的因为 C 是块对角且每块极小而 S 绝不能用显式求逆必须走 Cholesky。3.3 协方差怎么变成 NeRF 的深度权重到这里还差最后一跳。DBA 给出的是相机位姿的边缘协方差 Σ_T而 NeRF 需要的是每个像素深度的方差 Σ_D。中间靠一次雅可比传播像素深度是位姿和逆深度的函数把它对位姿求导再用 Σ_T 做一次线性变换就得到像素级的深度方差。这套做法的意义在于它让「不确定性」变成了一个可以逐像素查询的数而不是一个全局的超参。纹理丰富、多视图约束强的区域Σ_D 小深度监督的权重就高无纹理、遮挡边缘、纯旋转造成的退化区域Σ_D 大监督自然就被压下去。这正是复现文档里那句「尽管使用了有噪声的姿势和深度只要根据它们的不确定性进行加权就能取得很好的结果」的机制解释。顺带说一句这也是为什么 Droid 的输出不能简单拿去当 NeRF 的深度真值用。它给的是带协方差的观测不是真值把它们当等价物是复现时最常见的误用。4. 建图侧哈希编码为什么能把 NeRF 塞进 SLAM 的时间预算NeRF 原本的训练成本是小时级SLAM 的时间预算是秒级。中间差着三个数量级靠的不是更小的网络而是换了一种输入编码方式。4.1 多分辨率哈希网格用碰撞换速度Instant-NGP 的核心是把坐标编码从「正弦函数族」换成「可学习的哈希表」。具体做法是设 L 层不同分辨率的网格第 l 层的分辨率按几何级数增长对每个输入点在每层网格里找到它所在的体素取该体素的 8 个角点每个角点通过一个哈希函数映射到一张大小为 T 的表里取 F 维特征8 个角点做三线性插值最后把所有层的插值结果拼起来送进 MLP。哈希函数本身是标准的空间哈希三个坐标各乘一个大素数后做异或再对表大小取模。表大小 T 通常远小于网格角点的总数也就是说冲突是必然发生的——不同的角点会撞到同一个槽位。这个设计最反直觉的地方在于冲突不是 bug是特性。撞在一起的角点会共享梯度相当于一种隐式的正则化而因为多分辨率的存在粗层能兜住细层的错误。下面这段 Python 是这个编码的骨架参数含义写在注释里import numpy as np PRIMES np.array([1, 2654435761, 805459861], dtypenp.int64) def hash_encode(x, tables, resolutions, F): x: (3,) 归一化到 [0,1] 的坐标 tables: L 张形状为 (T, F) 的可学习特征表 resolutions: L 个整数每层的网格边长 F: 每层特征维度 out [] for table, N in zip(tables, resolutions): g x * N # 映射到该层网格坐标 i0 np.floor(g).astype(np.int64) frac g - i0 # 三线性插值的权重 feats np.zeros(F) for corner in range(8): # 遍历体素 8 个角点 off np.array([(corner k) 1 for k in range(3)]) idx np.minimum(i0 off, N - 1) # 边界钳制避免越界 h (idx * PRIMES).sum() % len(table) # 空间哈希冲突被有意保留 w np.prod(np.where(off 1, frac, 1 - frac)) feats w * table[h] out.append(feats) return np.concatenate(out) # (L*F,) 拼接后送 MLPresolutions决定了从粗到细覆盖多少个尺度len(table)就是哈希表容量 T它直接决定显存占用和冲突率T 太小冲突过多重建细节糊T 太大显存吃紧。np.minimum(i0 off, N - 1)这步边界钳制不能省否则网格边缘的角点会越界。整个编码的计算量与 MLP 的前向相比几乎可以忽略这就是它能跑到实时量级的根本原因。4.2 体渲染与深度监督两条 loss 怎么分工NeRF 的输出是沿射线的密度和颜色体渲染把它们积分成像素颜色同一套权重也能积分出期望深度深度等于各采样点的权重乘以采样点距离之和而权重由密度和采样间隔共同决定。于是监督分成两路颜色 loss 用 RGB 图像做真值深度 loss 用 SLAM 估计出的深度或 RGB-D 输入的深度做真值。颜色 loss 负责整体的几何一致性和外观深度 loss 负责补上单目尺度不可观的那部分约束——只有颜色的话网络可以收敛到一个几何完全错误但渲染正确的解深度监督是把它拽回物理空间的绳子。复现文档里 λD 被设为 1.0即两路 loss 等权。这个值不是拍脑袋的颜色 loss 是三维向量的 L2深度 loss 是标量的加权平方量纲上接近再乘上协方差权重后实际的有效权重是按区域动态变化的全局系数只需要负责量级对齐。4.3 不确定性加权马氏距离为什么比 L2 稳深度 loss 的形式不是简单的平方差而是带方差的加权平方本质上是马氏距离。这意味着一个方差大的像素即使预测偏了很多贡献的惩罚也不会爆炸反之一个方差小的像素偏一点点就会被重罚。这个设计与 L2 的差别在有噪声深度图上体现得最明显。L2 对所有像素一视同仁噪声大的区域会把梯度拖向错误方向网络学到的是「平均意义上的错」加权之后噪声区域自动退出竞争梯度集中到可信区域。这也是复现文档里那张对比图的机制解释只给噪声位姿、不给深度辐射场在 60 秒内不收敛给了噪声位姿和带协方差的深度反而能出很好的结果。数据处理上还有一个小细节值得注意深度 loss 通常只在有效深度范围内生效超出范围或投影失败的像素要被掩掉否则无效值会被当成真实观测灌进梯度里。5. 复现避坑从 CMake 版本到 GTSAM 包装的隐性依赖这套东西的依赖图比一般项目深一个配置报错往往牵连三层。复现文档里记的那些坑本质都是「编译期依赖」和「运行期依赖」没对齐。5.1 依赖链的形状为什么一个报错能牵出三个包把依赖画出来是Instant-NGP 需要 CMake 3.22 以上用到较新的 FetchContent 行为、需要 OpenGL 相关的开发库哪怕你只是想编 headless 版本CMakeLists 里的 find_package 依然会执行、需要 CUDA 工具链与 PyTorch 的头文件对齐GTSAM 需要 Boost 1.65 以上的一整套组件外加 Python 开发头文件才能编出 wrapper而 wrapper 绑定的 Python 版本必须和你实际运行的解释器一致。这条链上一个环节错位报错信息往往指向的不是根因。比如 CMake 版本太低报出来的可能是某个语法不认识的错误Boost 缺组件报出来的可能只是Could NOT find Boost而不告诉你缺的是哪一个。5.2 编译期 vs 运行期两套版本号必须对齐一个容易混淆的点是编译成功不等于能跑。GTSAM 的 Python wrapper 编译时链接的是某个具体的 libpython运行时 import 的是当前解释器两者版本不一致就会在 import 时报 undefined symbol。同样PyTorch 的 cu 版本、驱动版本、编译 ngp 时用的 CUDA 版本三者要对齐否则会在加载自定义算子时才炸。注意装了新版本 CMake 之后记得执行hash -r否则 shell 的命令缓存还指向旧路径cmake --version显示的仍是旧版本白折腾一轮。5.3 五条踩坑记录现象、原因、解决第一条CMake 配置阶段报找不到 GLEW。现象是执行cmake ./thirdparty/instant-ngp -B build_ngp时输出Could NOT find GLEW (missing: GLEW_INCLUDE_DIRS GLEW_LIBRARIES)。原因是系统没装 OpenGL 扩展加载库的开发包而 ngp 的 CMakeLists 在配置阶段就强制查找不做条件跳过。解决办法是装libglew-dev后重新配置不要指望删掉相关 target 能绕过那会连带影响后续链接。第二条配置报 CMake 版本相关错误升级后依旧报错。现象是编译安装完 3.22 甚至 3.26cmake --version还是老版本。原因是系统的包管理器版本装在/usr/bin源码编译的版本装在/usr/local/bin而 shell 有路径缓存。解决办法是hash -r刷新或者用绝对路径调用新版本顺手确认/usr/local/bin在 PATH 里排在前面。第三条GTSAM 配置报 Boost 组件缺失。现象是Could NOT find Boost (missing: serialization system filesystem thread program_options date_time timer chrono regex)且提示要求 1.65 以上。原因是发行版仓库里的 Boost 版本偏低或没装全组件尤其是只装了头文件包没装编译好的库。解决办法是源码编译 Boost./bootstrap.sh之后用./b2 --buildtypecomplete install全量装默认落到/usr/local/lib装完记得ldconfig否则链接期会找不到新库。如果机器上有多个 Python编译时用--with-pythonpython3.x显式指定否则 wrapper 可能绑到错的那个上。第四条GTSAM Python wrapper 编译过了但 import 失败。现象是import gtsam报 undefined symbol 或 module not found。原因有两个方向一是 CMake 配置时-DGTSAM_BUILD_PYTHON1虽然开了但找到的 Python 解释器和实际运行的不是同一个二是编出来的.so在 build 目录里没进PYTHONPATH。解决办法是配置时显式指定 Python 可执行文件运行时把 build 目录下的包路径加进环境变量并确认sys.version与编译日志里的一致。第五条追踪能跑但位姿漂移、重建发散。现象是前几十帧看着还行之后画面撕裂或整体漂移。原因是协方差估计在高梯度、快速运动、纯旋转这些退化场景下会失真而 NeRF 侧仍在用失真的权重做监督。排查顺序是先看帧间的残差权重分布是否塌缩大面积权重趋近 0 说明对应关系没建立起来再看内参和图像分辨率是否与配置一致分辨率错配会让投影全偏最后才怀疑优化器参数。解决方向通常是降低单帧的位姿更新步长、提高关键帧密度而不是去调 NeRF 的学习率。6. 设计取舍不确定性传递的收益与代价把协方差从追踪一路传到建图不是一个「更精确」的选择而是一个「把误差显式化」的选择。它的收益很清楚单目 SLAM 的深度本来就没有绝对尺度逐像素的置信度让 NeRF 能在噪声里挑出可信的部分而不是被平均掉。复现文档里那张「噪声位姿 噪声深度仍能出好结果」的对比图说的就是这件事。代价同样清楚。第一协方差是近似出来的舒尔补上的 Cholesky 假设了系统局部良态遇到纯旋转、大面积无纹理边缘协方差的数值本身就会失真这时候加权反而会把错误放大。第二整条链路的耦合变紧了追踪端的任何抖动都会通过 Σ_D 传进 loss建图端收敛不好又会反馈到下一帧的监督上调试时很难判断问题出在哪一环属于典型的黑匣子式排查。第三稠密 BA 的显存开销随像素数量线性涨分辨率一提就得重新算预算。方案深度监督方式噪声鲁棒性调试难度适用场景稀疏 SLAM 后处理无只出点云高低只求位姿、不要求稠密几何稠密 SLAM L2 深度监督等权平方差低中深度图质量高、噪声小稠密 SLAM 协方差加权马氏距离高高单目、噪声大、要求稠密重建想验证这套加权到底有没有起作用一个可操作的对比是固定同一段序列、同一组超参只把 Σ_D 换成常数矩阵跑一遍看重建的边缘锐度和收敛曲线差多少。如果差别不明显多半是协方差传播那一跳出了问题而不是加权本身无效。我自己的血泪经验是这类多模块耦合的复现最忌讳一上来就调最上层的超参。从那以后我每次跑 nerf-slam 的复现都强制先把追踪端的残差权重分布画出来看一眼确认对应关系建立正常再去碰 NeRF 那边的学习率和 λD。希望帮到你。本文还有配套的精品资源点击获取