简介:《nerf-slam的复现文档》面向希望理解并复现NeRF-SLAM三维重建流程的SLAM开发者与深度学习研究者,聚焦神经辐射场与传统SLAM融合后的稠密建图实现。文档围绕Tracking阶段的Droid-SLAM、RAFT光流估计和DBA稠密光束法平差展开,并说明Mapping阶段Instant-NGP哈希编码、概率体素NeRF及深度与颜色监督损失的作用,同时记录复现过程中遇到的问题和解决思路。资源包仅含1个docx文件,大小约11.01MB,便于集中阅读、按章节整理知识脉络。已有2327人学习,适合作为论文阅读后的实践参考;读者可借此理清系统架构、关键公式与工程安装脉络,获得从原理理解到复现排错的线索,以及组织实验与验证重建结果的思路。
1. 单目一段视频,换一个能随便换视角渲染的稠密场
一个单目相机在房间里走一圈,回到工位想要的东西往往不只是一条轨迹,而是一份能任意切视角渲染的稠密模型。传统视觉 SLAM 要凑齐这个结果,得把建图、稠密化、网格化拆成好几个独立工程接起来;而 nerf-slam 这条路线把它压成了两段——前端算位姿和稠密深度,后端用一个神经辐射场把颜色和几何一起学出来。
这份 nerf-slam 复现文档的价值就在这儿:它没有停在论文公式上,而是把 Tracking 侧的 RAFT 光流、Droid-SLAM 的 DBA 层、Mapping 侧的 Instant-NGP 哈希编码,连同 CMake、Boost、GTSAM 这些能让人卡一整天的编译依赖,一起摊开写了一遍。适合两类人:想把单目稠密重建跑出第一帧的,和已经跑起来但不知道为什么深度图有拖影、想改损失函数的。这类文档本身也能当模板抄,换个后端换个前端,流程是通的。
2. 架构拆解:Tracking 与 Mapping 两段式管线怎么咬合
整条管线是串行的两段,但两段之间的耦合点非常窄——窄到只有四个张量。理解这一点,后面改代码的时候就知道哪些地方能动手、哪些地方一碰就崩。
2.1 输入只有单目 RGB:和 RGB-D 方案的分水岭
很多 NeRF 加 SLAM 的工作,输入要 RGB-D,深度图直接喂给辐射场做监督。这类方案在实验室里效果漂亮,工程上麻烦:深度相机量程有限,室外、反光面、透明物体全废。nerf-slam 的输入只需要连续单目图像 Img1、Img2,深度是靠前端 SLAM 自己估出来的。
这个选择带来的连锁反应是:估出来的深度一定有噪声,而且是有结构性噪声的——无纹理区域、锯齿边缘、远平面上,单目 SLAM 照样会吐出深度值,只是这些值离谱。所以后端不能把深度当成硬约束,必须知道"这个像素的深度有多不可信"。这就是边缘协方差 ΣD 存在的理由,它不参与建图本身,只参与加权。整套设计里最关键的一步,是把"不确定性"从一句口号变成了一个可以逐像素计算的矩阵。
2.2 Tracking 侧:Droid-SLAM 输出位姿、稠密深度和协方差
Tracking 段沿用 Droid-SLAM 的思路,本质是 RAFT 光流网络加一个可微的 BA 层。它的输出不是稀疏特征点,而是每个像素的逆深度图和相机位姿,外加一份协方差图。
数据流大致是这样走的:两帧 RGB 进特征编码器,得到逐像素特征;特征做全对全内积,构建 4D 相关体(correlation volume);convGRU 拿着当前位姿和深度估计去相关体里查表,反复迭代,输出一组像素级的残差修正量;这组修正量再送进 DBA 层,映射回相机位姿增量和逆深度增量。
提示:Droid-SLAM 的 convGRU 输出的不是位姿残差和深度残差,而是像素投影残差 r_ij 和置信权重 w_ij。这一点在看代码时非常容易看错,认错了输出语义,后面调 loss 会完全跑偏。
2.3 Mapping 侧:Instant-NGP 承载的概率体素 NeRF
Mapping 段是一个 Instant-NGP 结构的辐射场。它接收 Tracking 给出的位姿、深度图、协方差图,以及原始 RGB,做多轮优化。颜色用光度损失监督,几何用深度损失监督,而深度损失按协方差加权。
为什么非得用 Instant-NGP 而不是原始 NeRF?因为 SLAM 是增量的,每来一个关键帧就要更新一次场,如果单次优化要几分钟,整个系统就没法在线跑。哈希编码把查询从"遍历大 MLP"变成"查表加插值",单帧优化时间压到秒级,这是这套管线能成立的前提,不是可选项。
2.4 两段之间的数据契约
改代码前先把这张表记住,任何一处形状或坐标系对不上,症状都是"能跑但重建是糊的",极难排查。
| 字段 | 形状 | 含义 | 来源 | 消费方 |
|---|---|---|---|---|
| T_wc | 4×4 | 相机到世界位姿 | DBA 层输出 | NeRF 采样射线生成 |
| D | H×W | 逐像素深度 | DBA 层输出的逆深度取倒数 | 深度损失、采样范围裁剪 |
| ΣD | H×W | 深度的边缘协方差 | DBA 的舒尔补矩阵求逆 | 深度损失权重 |
| I | H×W×3 | RGB 图像 | 原始输入 | 光度损失 |
关键帧的选取策略也在这条缝里。常见做法是:平移或旋转超过阈值就新建一个关键帧,同时把旧的帧踢出滑动窗口。窗口太长显存爆炸,太短则 DBA 的舒尔补退化,协方差估出来全是巨大值,深度损失等于没加。
3. 环境搭建:CMake、Boost、GTSAM 三个版本钉子的拆法
这个项目的安装难度不在 Python 侧,全在 C++ 侧。三个东西会反复咬人:CMake 版本、Boost 版本、GTSAM 的 Python wrapper。按下面的顺序走,能省掉一半时间。
3.1 依赖全景与安装顺序
| 依赖 | 版本要求 | 用途 | 安装方式 |
|---|---|---|---|
| CUDA / cuDNN | 与 torch 匹配 | 光流网络与哈希编码推理 | 系统包管理器 |
| torch | 1.12.1+cu113 | RAFT、Droid-SLAM | pip |
| CMake | ≥ 3.22 | instant-ngp 配置 | 源码编译 |
| GLEW | 系统版本即可 | instant-ngp 的 GL 上下文 | apt |
| Boost | ≥ 1.65,建议 1.73 | GTSAM 依赖 | 源码编译 |
| GTSAM | 仓库内 thirdparty | DBA 的因子图后端 | CMake + Python wrapper |
| instant-ngp | 仓库内 thirdparty | 哈希编码辐射场 | CMake 编译 |
顺序上有个经验:先把 torch 装好并验证torch.cuda.is_available(),再动 C++。因为 GTSAM 的 Python wrapper 在编译时会去找当前的 Python 解释器,如果你在虚拟环境里装 torch、却在系统 Python 下编 GTSAM,后面 import 会报符号找不到,错误信息完全指不到根因。
3.2 Python 侧:torch 与两套 requirements
# 拉仓库时必须带子模块,thirdparty 下的 gtsam 和 instant-ngp 都在里面 git clone <repo-url> --recurse-submodules cd <repo-dir> git submodule update --init --recursive # 建虚拟环境,后续所有 pip 和 cmake 都在这个环境里执行 python -m venv venv && source venv/bin/activate # CUDA 11.3 对应的 torch,版本错一位后面 CUDA 算子就编不过 pip install torch==1.12.1+cu113 torchvision==0.13.1+cu113 \ --extra-index-url https://download.pytorch.org/whl/cu113 pip install -r requirements.txt # gtsam 的 python 依赖单独一份,别漏 pip install -r ./thirdparty/gtsam/python/requirements.txt--recurse-submodules和后面那句submodule update看着重复,实际不一样:前者只在克隆那一刻拉取,如果中途网络断了,目录是空的但 git 不报错,编译时才会出现"找不到 gtsam.h"这种莫名其妙的错。后面那句是补救,务必执行。
3.3 C++ 侧:instant-ngp 先编,GTSAM 后编
# 配置 instant-ngp,-B 指定构建目录,保持源码树干净 cmake ./thirdparty/instant-ngp -B build_ngp # 如果报 Could NOT find GLEW,装完再重跑上面那条 sudo apt install libglew-dev # RelWithDebInfo 兼顾速度和调试符号,-j 后面跟核数 cmake --build build_ngp --config RelWithDebInfo -jGTSAM 这边要打开 Python wrapper 开关,否则后面 Python 侧 import gtsam 直接失败:
# GTSAM_BUILD_PYTHON=1 是必须的,默认关闭 cmake ./thirdparty/gtsam -DGTSAM_BUILD_PYTHON=1 -B build_gtsam这一步最常见的报错是找不到 Boost,而且要求至少 1.65。系统自带的往往偏低或者缺组件(serialization、filesystem、timer 这些)。稳妥的做法是源码编译一份,指定完整安装:
wget https://boostorg.jfrog.io/artifactory/main/release/1.73.0/source/boost_1_73_0.tar.bz2 --no-check-certificate tar xvf boost_1_73_0.tar.bz2 cd boost_1_73_0 ./bootstrap.sh # 全量安装,默认落到 /usr/local/lib sudo ./b2 --buildtype=complete install全量编译耗时不小,但省事。如果机器上有多套 Python,加--with-python=python3.x显式指定,否则 Boost.Python 会绑到系统默认解释器上,和你 venv 里的对不上,症状是 import 时报 undefined symbol。要指定安装路径就加--prefix=xxx,装完记得把lib目录塞进LD_LIBRARY_PATH,不然运行时找不到动态库。
3.4 CMake 多版本共存:hash -r这一步别省
instant-ngp 要 3.22 以上,但有些老项目又依赖 3.9。装完新版之后cmake --version还显示旧的,是 shell 的路径缓存没刷新:
# 装完新版本后强制刷新命令哈希表 hash -r cmake --version如果还是旧版本,说明PATH里旧路径排在前面,用which -a cmake看一眼到底有几个,把新装的/usr/local/bin提到前面。这个坑我见过太多次,表现是"明明装了 3.26,配置 instant-ngp 还是报版本不够",然后开始怀疑人生。
4. 核心模块:RAFT 光流、DBA 层与哈希编码各自解决什么问题
三个模块各管一段,但它们的接口语义是连着的:RAFT 提供可微的匹配代价,DBA 把这个代价转成几何量,哈希编码把这些几何量变成一个可渲染的场。
4.1 RAFT 的三个部件与 4D 相关体
RAFT 的结构可以粗暴拆成编码器、相关层、更新算子。编码器有两套:特征编码器对两帧都跑、共享权重;上下文编码器只对第一帧跑,给 GRU 提供初始隐状态。
import torch import torch.nn.functional as F def build_corr_volume(f1, f2, num_levels=4): """f1/f2: [B, C, H, W],输出多尺度 4D 相关体列表""" B, C, H, W = f1.shape # 全对全内积,得到 [B, H, W, H, W] 的 4D 体 corr = torch.einsum('bchw,bdhw->bcdhw', f1, f2).reshape(B, H, W, H, W) # 最后一维归一化,抑制特征幅值差异带来的偏置 corr = corr / torch.sqrt(torch.tensor(C, dtype=corr.dtype)) volumes = [corr] for _ in range(1, num_levels): # 只在后两维(匹配维度)做池化,保持查询维度分辨率 corr = F.avg_pool2d(corr, 2, 2) volumes.append(corr) return volumes相关体是整个方法的地基。einsum那句里bchw,bdhw->bcdhw的意思是:对每一个 batch,把第一帧每个像素的特征和第二帧每个像素的特征做内积,结果是一个 H×W×H×W 的四维张量,数值大小直接代表两个像素的相似度。多尺度池化只作用在后两维,是为了在不损失查询精度的前提下降低匹配搜索的计算量。num_levels一般取 4,层数越多大位移的鲁棒性越好,但显存占用按 2 的幂上涨。
4.2 Droid-SLAM 的改动:GRU 输出的是像素残差
RAFT 原本的更新算子是拿相关体里的查表结果反复修正光流。Droid-SLAM 换掉了这一步的语义:GRU 不再输出位姿和深度的残差,而是输出投影后像素位置的残差 r_ij 和一个标量置信度 w_ij。
def project_and_sample(pi, di, T_ij, corr_volumes): """pi: 帧 i 的像素坐标 [N,2];di: 帧 i 的逆深度 [N,1] T_ij: i 到 j 的相对位姿 [4,4]""" # 反投影到帧 i 的相机坐标系,再变换到帧 j,最后投影回像素平面 pts_i = backproject(pi, di) # [N,3] pts_j = (T_ij[:3, :3] @ pts_i.T).T + T_ij[:3, 3] pij = project(pts_j) # [N,2] 预测的对应点 # 用 pij 在相关体上做双线性查表,得到匹配代价 feat = bilinear_sample(corr_volumes[0], pi, pij) # [N, C'] # GRU 吃匹配特征,吐像素残差和权重 r_ij, w_ij = gru_step(feat) return pij + r_ij, w_ij这里的w_ij是后面协方差能算出来的源头。它表达的是"这个像素的匹配我有多确信",置信度低的像素在 DBA 里贡献的约束就弱,最终解出来的深度方差就大。看懂这一步,ΣD 就不再是黑匣子——它就是这些 w 在 BA 里的自然产物。
4.3 DBA 层:舒尔补和 Cholesky 求边际协方差
DBA 层是传统 BA 的稠密版本。相机数量 c 远小于路标点(这里是像素逆深度)数量 p,所以 H 矩阵按分块写成相机块和点块,用舒尔补先消掉点块。
import numpy as np from scipy.linalg import cho_factor, cho_solve def dba_solve(H_cc, H_cp, H_pp, b_c, b_p): """H_cc: 相机块,H_pp: 点块(分块对角),b: 对应右端项""" # 舒尔补:把点块消掉后剩下的相机侧矩阵 H_pp_inv_H_pc = np.linalg.solve(H_pp, H_cp.T) S = H_cc - H_cp @ H_pp_inv_H_pc b_s = b_c - H_cp @ np.linalg.solve(H_pp, b_p) # 先解相机位姿增量 delta_xi = np.linalg.solve(S, b_s) # 回代解逆深度增量 delta_d = np.linalg.solve(H_pp, b_p - H_cp.T @ delta_xi) # Cholesky 分解求相机侧的边际协方差 L, low = cho_factor(S, lower=True) Sigma_c = cho_solve((L, low), np.eye(S.shape[0])) return delta_xi, delta_d, Sigma_c舒尔补S = H_cc - H_cp H_pp^{-1} H_cp^T是这里的第一层关键。H_pp是分块对角的,求逆代价随像素数线性增长,可以接受;S的维度只跟相机数有关,很小,所以解算分两步走,先解位姿再回代解深度。第二层关键是 Cholesky:S = LL^T,用三角分解求逆比直接求逆稳定一个数量级,而且得到的Sigma_c就是协方差块。实际代码里注意力要放在H_pp的条件数上,深度初始化太差会让它接近奇异,舒尔补结果全是 NaN。
4.4 概率深度监督:λD=1.0 背后的加权逻辑
原始深度图直接拿来监督 NeRF,是有问题的。单目 SLAM 在无纹理和锯齿边缘同样会输出深度,只是噪声极大。所以深度损失必须带权,权重就是边缘协方差的倒数:
def weighted_depth_loss(d_pred, d_gt, cov, eps=1e-6): """d_pred/d_gt/cov: [H, W] cov 越大表示该像素深度越不可信""" # 逆方差加权,等价于马氏距离的平方 w = 1.0 / (cov + eps) # 归一化,避免权重量级随帧变化导致学习率需要反复调 w = w / w.mean() diff = (d_pred - d_gt).abs() return (w * diff).mean() # 总损失:光度项 + λD 加权的几何项,λD 取 1.0 def total_loss(rgb_pred, rgb_gt, d_pred, d_gt, cov, lam_d=1.0): return ((rgb_pred - rgb_gt) ** 2).mean() + lam_d * weighted_depth_loss(d_pred, d_gt, cov)λD取 1.0 意味着两项量级被拉到同一水平。这个值的意义在于平衡:调大几何约束更强,收敛快但容易把错误的深度也学进去;调小则几何松,纹理丰富的地方还能靠光度项撑住,纹理少的地方直接塌。复现文档里提到的那组对照实验其实说明了同一件事——有真值姿态和真值深度时收敛最快,只有姿态时慢但能收敛,只有噪声姿态又没有深度时六十秒内根本不收敛,而噪声姿态加噪声深度、按不确定性加权之后,结果能接近第一档。这个结论对二次开发最有价值:与其去抠网络结构,不如先把协方差估准。
4.5 Instant-NGP 的哈希编码:把 NeRF 压进秒级
体渲染本身没什么新鲜的,沿射线采样、积分颜色和密度。变化在于查询方式。原始 NeRF 把位置编码后的坐标喂给大 MLP,查询一次要过很多层。哈希编码的思路是维护多分辨率哈希表,每个分辨率层有自己的格点数上限,冲突的坐标共享同一个特征向量。
class HashEncoder: def __init__(self, num_levels=16, base_res=16, feat_dim=2, log2_hash=19): self.num_levels = num_levels self.resolutions = [int(base_res * (2 ** i)) for i in range(num_levels)] # 每层一张 2^log2_hash × feat_dim 的表,冲突直接覆盖 self.tables = [np.random.randn(1 << log2_hash, feat_dim) for _ in range(num_levels)] def encode(self, x): """x: [N, 3] 归一化到 [0,1] 的世界坐标""" feats = [] for res, table in zip(self.resolutions, self.tables): # 找到 x 所在格的八个角点,分别查表 corners = get_corner_indices(x, res) hashed = [hash_corner(c, res) % len(table) for c in corners] vals = [table[h] for h in hashed] # 三线性插值,保证编码关于坐标连续可导 feats.append(trilinear_interp(vals, x, res)) return np.concatenate(feats, axis=-1)log2_hash控制表大小,是显存和精度的直接调节旋钮,19 大致对应五十万条特征。num_levels控制细节层次,层数多则高频细节好,但低分辨率层被稀释,大结构可能学不干净。三线性插值那一步不能省,没有它编码关于坐标不连续,梯度传不回去,训练直接发散。
5. 跑通与排查:数据集、启动命令和 5 条血泪记录
编译过了只是开始,运行阶段的问题更零碎,而且报错信息往往指不到真因。这一章按"先摆数据、再跑、最后排"的顺序来。
5.1 数据组织与配置项
输入是连续单目图像序列,目录结构一般按场景分文件夹,每个场景下放图像和一个内参文件。常见做法是先用公开的单目序列跑通,比如室内场景的重建数据集,图像分辨率不要一上来就全尺寸,先降到一半,确认管线通了再放大。
内参必须和图像严格对应,fx/fy/cx/cy错一个数,症状是重建出来的场景整体倾斜或者深度尺度不对。配置文件里通常还要给滑动窗口长度、关键帧阈值、每帧优化迭代次数这几项:
| 配置项 | 典型值 | 影响 |
|---|---|---|
| 窗口长度 | 8~12 帧 | 太短协方差估不准,太长显存爆 |
| 关键帧平移阈值 | 0.05~0.1 | 太小关键帧冗余,太大会丢跟踪 |
| 每帧优化迭代 | 100~200 | 影响在线性,NeRF 收敛慢会拖影 |
5.2 启动与日志观察
# 激活环境后从仓库根目录启动,避免相对路径找模型权重失败 source venv/bin/activate python run.py --config configs/<scene>.yaml --data_root <dataset_dir>启动后别盯着渲染窗口看,先看日志里的三行:Tracking 输出的平均重投影残差、深度图的有效像素占比、Mapping 的光度损失下降曲线。重投影残差如果从第一帧就很大,多半是内参错;有效像素占比低说明深度图大片无效,通常是视差不够或者窗口太短;光度损失不降反升,先检查位姿的坐标系约定,是不是把T_wc当成T_cw用了——这个错误极其常见,而且视觉上前几十帧看起来"只是有点糊",很容易被骗过去。
5.3 五条踩坑记录
现象一:cmake ./thirdparty/instant-ngp -B build_ngp报版本不够,但cmake --version明明显示 3.26。 原因:装过多版本 CMake,shell 缓存了旧的可执行文件路径。 解决:hash -r刷新命令哈希表;仍不行就which -a cmake看路径顺序,把新版本目录提到PATH前面,重开一个终端再试。
现象二:配置 instant-ngp 时报Could NOT find GLEW (missing: GLEW_INCLUDE_DIRS GLEW_LIBRARIES)。 原因:系统缺 OpenGL 扩展加载库,instant-ngp 的可视化部分依赖它。 解决:sudo apt install libglew-dev,装完直接重跑同一条 cmake 命令,不需要删构建目录。
现象三:配置 GTSAM 时报Could NOT find Boost,提示至少 1.65,但系统里明明装了 Boost。 原因:系统包里的 Boost 版本偏低,或者缺 serialization、filesystem、timer 这几个组件,CMake 的 find 模块只要缺一个就整体判失败。 解决:源码编译 1.73,用--buildtype=complete全量安装,避免组件缺失;装完重新跑 cmake 配置。
现象四:Boost 装完,Python 里import gtsam报undefined symbol。 原因:编译 GTSAM 时用的 Python 解释器和当前 venv 里的不是同一个,Boost.Python 绑定到了系统解释器。 解决:在 venv 激活状态下重新编译,或在 bootstrap 阶段加--with-python=显式指定解释器路径,然后清掉build_gtsam重新配置。
现象五:程序能跑,但重建结果像蒙了一层雾,越远越糊。 原因:深度损失权重没有归一化,协方差的绝对量级随场景尺度变化,导致几何项在某些帧上压过了光度项。 解决:在加权损失里对权重做均值归一化(见 4.4 的w / w.mean()),同时打印协方差的分位数,确认没有出现极端值把权重拉爆。
6. 二次开发:从换数据集到改损失权重的落地路径
想在这套代码上做自己的东西,最省力的顺序是:先换数据不改代码,再改参数不改结构,最后才动网络。反过来做,九成时间花在"改了但不知道是哪儿改坏的"。
换数据集这一步,把data_root指过去、改内参、确认图像命名能被读取器解析,就够了。跑之前先拿三五帧单独过一遍 Tracking,把输出的深度图存成图片肉眼看一遍——如果深度图本身是糊的,后面 NeRF 怎么调都是白费。这个前置检查能挡掉后面一半的无效调试。
改参数最值得先动的是λD。把它从 1.0 调到 0.5,几何约束松,纹理丰富的室内场景细节更锐利,但平面区域容易出波纹;调到 2.0,平面变平整,代价是弱纹理处的深度会被过度平滑。验证方式别只看渲染图好不好看,至少同时看三个量:渲染 PSNR、深度图相对真值的 RMSE、轨迹的绝对位姿误差。三个里有两个变好才算真的改对了,只有一个变好通常是过拟合到某一项监督上。
# 遍历 λD 做小规模扫描,每档只跑固定帧数,快速筛掉明显差的 for lam in [0.5, 1.0, 2.0]: metrics = run_eval(lam_d=lam, num_frames=200) print(f"lam={lam} psnr={metrics['psnr']:.2f} " f"depth_rmse={metrics['depth_rmse']:.4f} ate={metrics['ate']:.4f}")真要动结构,建议从关键帧策略下手而不是从网络下手。把"固定阈值触发"换成"根据当前窗口内的平均协方差自适应触发",改动量小、可解释性强:协方差整体偏大说明当前窗口约束不够,就该插入关键帧。这个改法我试过,比调网络结构见效快得多。
注意:改协方差相关的任何逻辑,都要同步检查
H_pp的条件数。舒尔补对病态矩阵极其敏感,一个接近奇异的点块能让整帧解算输出 NaN,而 NaN 会安静地传播到 NeRF 的参数里,几帧之后整个场就废了,日志里却可能只有一行 warning。
我踩过最贵的一次坑,是没做前置的深度图目检就直接开始调损失权重,折腾了大半天以为是超参问题,最后发现是内参里fy少写了一位数。从那以后我每次换数据集,都强制先把前三帧的深度图、协方差图、重投影残差各存一份看一眼,再动任何一行代码。希望这份流程能帮到你,少走点这种完全不必要的弯路。
本文还有配套的精品资源,点击获取