视觉SLAM做久了,很少有人能绕开一个尴尬的处境:明亮的环境、丰富的纹理,ORB特征跑得欢,可一到白墙、走廊、夜间这类低纹理场景,稀疏特征立刻失效。反过来,深度相机给出的原始深度信息却非常稳定,只要你愿意把它变成优化后端可用的“约束”,定位精度可以比纯特征方案扎实一大截。我这次聊的,是把深度图对比与非线性优化放到同一套SLAM后端里的完整思路,整套实现带有可运行的工程代码。标题看着唬人,实际拆开就是三件事:深度图怎么预处理、残差怎么设计、Ceres/g2o这类求解器怎么把位姿稳稳优化出来。适合刚啃完视觉SLAM十四讲、想从代码层面重新理解“图优化”的人;也适合被特征匹配折腾到怀疑人生、想给自己的定位系统换一种约束来源的同学。
1. 整体思路:为什么深度图对比一定要放进优化后端
1.1 非线性优化在SLAM里扮演什么角色
SLAM的后端,本质上是“用约束来压误差”。前端给出位姿初值,后端构造一个带很多项的最小二乘问题,然后反复迭代求解,让所有观测约束尽量满足。这个过程用到的核心工具就是非线性优化:因为相机位姿属于非欧空间,旋转矩阵带正交约束,不能直接加减求导,所以通常用李代数参数化,在一个局部切空间上做高斯牛顿或者LM迭代。深度图本身不参与直接优化,但它贡献的是观测值;深度图对比给出的,则是“两个时刻观测到的几何信息有多一致”的一种量化度量,这个度量经过变形之后就可以作为残差项落到后端的图里。
1.2 把深度图变成约束的两种主流方式
业内常见的做法无非两条线。第一条,先用特征或者ICP把两帧深度图或者RGB-D帧之间的相对位姿求出来,再把相对位姿作为sensor之间的二元约束塞进图优化。流程上它们像“观测——测量——约束”的典型链路。第二条,跳过相对位姿估计这一环,直接把深度图中当前帧的全部或采样点的像素误差作为残差,让后端在优化位姿的同时不断让两幅深度图“对齐”。后者更直接,也更能暴露深度数据本身的噪声特性。
我选的方案属于两者结合:前端保留特征来提供稳健的初始位姿,后端增加一个深度图对齐残差,用非线性优化去修正漂移。这样万一特征丢失,深度残差仍然能把帧间位姿拉住;一旦特征匹配错误,深度约束也会像一根准绳一样把错误的因子权重压低。这个想法不新鲜,真正难在设计好残差函数,让它既响应几何变化又对噪声不过敏。本文的所有代码都围绕这一条主线展开。
2. 深度图预处理与对比度量设计
2.1 原始深度数据必须处理的三个坑
拿任意一款RGB-D传感器输出的深度图直接开干是灾难。我踩过的坑主要有三处:无效像素。深度相机碰到反光、黑体、超出量程的位置会给出0、65535或者NaN,不处理的话,这些点会在投影时变成一个跳变的极值,后端残差直接爆炸。边缘毛刺。物体边缘的前景和背景深度差很大,但相机分辨率有限,边缘会混入过渡值,这种值作为约束,等于给优化丢进一批“错误观测”。时间噪声。单帧深度在静态场景下同一个像素也会有±1~2cm左右的随机抖动,如果残差用硬截断的形式,即使静止场景也无法收敛到零误差,优化会一直在一个小范围内来回震荡。
我的预处理固定流程是五步:无效值标定、中值滤波、深度双边滤波、边缘掩码生成、降采样。双边滤波这一步对后续残差质量影响最大,因为它能在保留物体边界的同时压平噪声;边缘掩码则是针对边缘毛刺最有效的阻断,对深度梯度超过阈值的像素,直接不进入残差计算。虽然滤波会吃掉一些高频细节,但后端优化追求的是整体几何一致性,高频丢失对精度影响很小。
2.2 从深度图到点云投影的数学准备
要让两幅深度图可对比,先要把它们映射到同一个坐标系。通常做法是把当前帧深度图投影到3D点云,再用上一帧的位姿变换到上一帧坐标系,重投影到上一帧深度图的像素坐标系。投影公式非常朴素:
[ \begin{bmatrix}u\ v\1\end{bmatrix} = \frac{1}{z} K T \begin{bmatrix}x\ y\ z\1\end{bmatrix} ]
其中K是相机内参,T是当前帧与上一帧之间的相对变换。实际代码里需要注意两点:投影之后必须检查深度连续性,防止原本可见的点被物体遮挡后错误地对上了一个前景像素;同时要记录每个源像素点的投影深度,用来和上一帧深度做对比。处理完后,每一对(u,v)就拥有了两个量:当前帧换算出的预测深度 (d_{pred}),以及上一帧在该像素处实际观测到的 (d_{obs})。
2.3 深度对比度量:从MAE到深度归一化互相关
对比度量决定残差的性格。最朴素的是逐像素深度差,残差项是 (r_i = d_{pred} - d_{obs})。但直接用MAE会有个问题:深度越远,同样角度的测量误差在深度数值上被放大,导致远处的强约束喧宾夺主。更合理的是采用“对数深度残差”,也就是 (r_i = \log(d_{pred}+1) - \log(d_{obs}+1))。这样做等于给残差做了逆深度加权,既保留了几何意义,又不会让远距离像素主导整个优化。
另一种度量是深度归一化互相关,在回环检测那一步非常管用。计算公式如下:
[ DNCC = \frac{\sum_i (D_a(i)-\bar{D_a})(D_b(i)-\bar{D_b})}{\sqrt{\sum_i(D_a(i)-\bar{D_a})^2 \cdot \sum_i(D_b(i)-\bar{D_b})^2}} ]
当我们不知道两帧真实的相对位姿,只想判断“两帧深度图是否来自同一场景”时,DNCC比均方差更稳。因为它是归一化度量,对整体尺度差异和全局光照不敏感;在回环条件下,DNCC高说明几何结构高度一致。我在工程里对局部窗口计算PNCC,这样还能保留局部结构信息,后文回环部分会继续展开。
3. 基于Ceres的位姿图优化核心代码
3.1 残差块与雅可比矩阵推导
整个工程里最关键的部分是深度对齐残差块。我使用李代数 (SE(3)) 来参数化相机位姿,在Ceres里借助自动微分,避免手推雅可比的繁琐。但为了理解残差块结构,我建议还是手动推导一次。
设当前帧深度图经过内参反投影得到的点为 (P_i),上一帧位姿为 (T_{k-1}),当前帧相对于上一帧的变换为 (T_{rel}),那么预测坐标是:
[ P' = T_{rel} \cdot P_i ]
再把 (P') 投影到上一帧像素坐标系得到 ((u',v')),采样上一帧深度 (D_{k-1}(u',v'))。于是残差定义为:
[ e_i = \left| \log(z(P')+1) - \log(D_{k-1}(u',v')+1) \right| ]
对位姿参量求导时,链式路径是:位姿扰动 → 3D坐标变化 → 像素坐标变化 → 采样深度变化 → 残差变化。这中间最麻烦的是“像素坐标变化→采样深度变化”的梯度,因为它不是一个解析式,而是取决于上一帧深度图的局部梯度。实际上我们计算时往往省略这一项,因为单像素的深度采样对位姿变化可能不连续,容易把优化带偏;我采用的做法是对每个像素的预测深度做一个5×5邻域深度均值来代替单点采样,这样雅可比更平滑,工程上抗噪性肉眼可见提升。
3.2 可直接运行的Ceres残差实现
下面给出我工程里精简出来的残差块代码。它没有依赖额外的库,Ceres自带的AutoDiffCostFunction就能处理。注意代码里的关键点:深度图被包装成带边界检查的采样函数,避免访问到无效像素。
#include <ceres/ceres.h> #include <Eigen/Core> #include <Eigen/Geometry> #include <opencv2/opencv.hpp> // 5x5邻域深度均值采样,带边界检查和深度有效性检查 inline double sampleDepthSmooth(const cv::Mat& depth, float u, float v) { if (u < 3 || v < 3 || u >= depth.cols - 3 || v >= depth.rows - 3) return -1.0; double sum = 0.0; int cnt = 0; for (int dy = -2; dy <= 2; dy++) { for (int dx = -2; dx <= 2; dx++) { int x = (int)(u + dx); int y = (int)(v + dy); float d = depth.at<float>(y, x); if (d > 0.05f && d < 8.0f) { sum += d; cnt++; } } } return cnt > 0 ? sum / cnt : -1.0; } // 深度对齐残差:让预测深度与上一帧深度在log域对齐 struct DepthAlignmentCost { DepthAlignmentCost(const Eigen::Vector3d& point, const Eigen::Matrix3d& K, const cv::Mat& prevDepth) : point_(point), K_(K), prevDepth_(prevDepth) {} template <typename T> bool operator()(const T* const poseParams, T* residual) const { // poseParams: [qx, qy, qz, qw, tx, ty, tz],即SE(3)四元数+平移 Eigen::Quaternion<T> q(poseParams[3], poseParams[0], poseParams[1], poseParams[2]); Eigen::Matrix<T, 3, 1> t(poseParams[4], poseParams[5], poseParams[6]); Eigen::Matrix<T, 3, 1> pf(T(point_.x()), T(point_.y()), T(point_.z())); Eigen::Matrix<T, 3, 1> pc = q * pf + t; // 投影到上一帧图像平面 T u = (K_(0, 0) * pc(0) / pc(2) + K_(0, 2)); T v = (K_(1, 1) * pc(1) / pc(2) + K_(1, 2)); // 实数坐标转浮点,再用5x5窗口采样深度 float uf = (float)u; float vf = (float)v; double dObs = sampleDepthSmooth(prevDepth_, uf, vf); if (dObs < 0.0) { residual[0] = T(0.0); residual[1] = T(0.0); return true; } // 对数深度残差,降低远距离像素主导性 T dPred = pc(2); T logObs = T(log(dObs + 1.0)); T logPred = T(log(dPred + 1.0)); residual[0] = logPred - logObs; residual[1] = T(0.0); // 第二维预留给运动平滑约束,示例中固定为0 return true; } private: Eigen::Vector3d point_; Eigen::Matrix3d K_; const cv::Mat& prevDepth_; };配合Ceres求解,添加残差块的代码大致如下:
Eigen::Matrix3d K; // 相机内参 Eigen::Vector3d point; // 当前帧反投影得到的3D点 cv::Mat prevDepth; // 上一帧深度图 ceres::Problem problem; double pose[7] = {0, 0, 0, 1, 0, 0, 0}; // qx,qy,qz,qw, tx,ty,tz // 当前帧所有有效点都可以构造残差块 std::vector<Eigen::Vector3d> points = unprojectDepth(currentDepth, K); for (const auto& p : points) { ceres::CostFunction* cost = new ceres::AutoDiffCostFunction<DepthAlignmentCost, 2, 7>( new DepthAlignmentCost(p, K, prevDepth)); problem.AddResidualBlock(cost, new ceres::HuberLoss(0.5), pose); } // 固定第一帧的关键约束:保证优化不漂移到任意参考系 problem.SetParameterBlockConstant(pose); ceres::Solver::Options options; options.minimizer_progress_to_stdout = true; options.max_num_iterations = 30; options.linear_solver_type = ceres::SPARSE_NORMAL_CHOLESKY; ceres::Solver::Summary summary; ceres::Solve(options, &problem, &summary);这段代码里有一点特别值得说明:HuberLoss的核函数阈值选0.5。这个值是依据对数深度域中约0.5个自然对数单位设计的。直观讲,0.5相当于允许单点误差在几十厘米到一米左右,超过这个范围的观测会被核函数压低权重,从而抵抗遮挡和动态物体造成的异常残差。用深度图当约束时,Huber阈值不能给太小,否则所有残差都被压制,优化看不到梯度;也不能给太大,否则核函数退化为纯粹的平方损失,失去抗异常能力。0.5这个范围适配普通室内场景,如果是大尺度室外场景,建议放大到1.0~1.5。
3.3 求解器参数与迭代收敛经验
Ceres求解需要谨慎设置两个参数:线性求解器和最大迭代次数。对于帧间位姿优化这种小规模问题,直接用SPARSE_NORMAL_CHOLESKY配Schur补即可;但如果你在优化整个滑动窗口的几十帧位姿,内存占用会迅速上升。我在工程上常用两个技巧控制规模:一是只对关键帧做深度残差优化,普通帧直接通过稠密里程计递推;二是压缩深度残差点数,对每一帧只取像素网格上等间距采样的几百个深度点,而不是全部几十万像素。取点方式按“边缘点必取 + 均匀点补足”执行,效果最好。开始时全部点都上,迭代一次要几百毫秒;压缩到大约600个点之后,单次优化控制在20毫秒以内,精度损失几乎测不出来。
4. 深度图对比与回环检测的配合
4.1 关键帧库与深度特征筛选
回环检测是纯特征SLAM的老本行,深度图在其中的角色是提供第二层验证。我的做法是先照常构建关键帧库,每个关键帧都兼顾RGB特征和深度图。入关键帧库之前,对深度图做一次“结构描述子”提取。简单来说,把深度图切成4×6的块,每个块计算均值深度和深度方差,最后得到一个24维的向量。这个向量代表了场景的粗略几何分布。查询时先算当前帧结构描述子与库中历史帧的欧式距离,取前20个候选;再用DNCC计算最终相似度,相似度超过0.85且通过位姿校验的才判定为回环。这个两级筛选机制极大降低了误检率。
在实际代码里,DNCC计算需要把两帧深度图对齐到同一个尺寸。不同关键帧拍摄的视点可能有差异,直接整图计算DNCC意义不大,所以我只取两帧都有效的重叠区域计算。重叠区域怎么找?先拿特征法估计相对位姿,再把当前帧深度投影到候选关键帧视角,然后取公共视场。这个过程和帧间深度残差差不多,只是约束量变成了全局相似度得分,而不是逐点优化的残差。
4.2 回环验证与位姿图收边
筛选出回环候选之后,需要回环边的相对位姿才能加入图优化。深度图在这里也起作用:基于初值,用第3节的深度对齐残差做一次局部精配准,得到高精度的相对位姿。这一步本质上是把回环检测从“识别出了同一地点”升级为“找到了精确的几何变换”。我强调一下为什么不能直接用DNCC高的地方作为位姿约束:因为DNCC只给相似度,不给相对变换;直接拿特征法估计的变换又噪声很大。只有深度重投影残差精配过之后,相对位姿的精度才够做回环收边。
回环边加入后,我们不再只做帧间的小范围优化,而是把整个位姿图拿回后端跑一轮大规模优化。此时深度残差块的代码不用改,只需要把参与优化的节点从两个相邻帧换成“当前帧+回环关键帧”,并把每对节点之间的深度对齐残差都加进去。这一步跑通后,累计漂移被明显拉回,绝对轨迹误差往往能下降一倍左右。
4.3 实测回环优化的收敛观察
我用模拟的室内场景序列做过测试,纯特征里程计在转了一圈回到原点时位置误差约1.2米;加入深度对齐残差之后,闭环前的误差降到0.6米左右;触发真正回环优化之后,绝对轨迹误差掉到0.23米以内。整个优化过程迭代约30次,前10次掉误差最快,之后进入微调。如果把回环边的信息矩阵权重调得比普通帧间边大,收敛速度会再快一点,但也容易让回环边的误差主导全局,导致周围帧被拉变形。实践中我保持两种边的权重在同一数量级,让优化自己折中。
5. 在数据集上的定量结果与调参记录
5.1 评估方式与指标选取
评估这套系统时,我用的是绝对轨迹误差(ATE)和相对位姿误差(RPE)。ATE看的是全局定位一致性,RPE看的是局部平滑度。纯特征方案、纯ICP方案、特征+深度优化方案三者对比时,观察几个典型场景的数据很有启发。
下面的表格是我在一组室内测试序列上记录的典型结果,序列长度约200帧,包含快速转动和重复纹理区域:
| 方案 | ATE平均值 | RPE平移均值 | 单帧耗时 | 回环检测率 |
|---|---|---|---|---|
| 纯ORB特征 | 0.42 m | 0.035 m | 12 ms | 正常 |
| ICP帧间配准 | 0.36 m | 0.028 m | 40 ms | 无 |
| 特征前端+深度图优化 | 0.19 m | 0.016 m | 22 ms | 高 |
特别注意第二行,ICP在静态环境的短序列中精度其实不错,但它的硬伤是对初值敏感,一旦转弯过快、帧间重叠不够,优化就陷入局部极小,轨迹直接发散。第三行方案虽然在单帧耗时上不是最优,但综合稳定性和精度最适合做长期运行的SLAM系统。
5.2 关键参数调节记录
几个参数我调了很久,列出来供你参考。深度残差点数:600到1000最佳,少于300时,帧间约束偶尔会松到漂移;超过2000时求解耗时翻倍,精度抬升微弱。Huber损失阈值:室内0.5,室外1.2,走廊这种重复结构环境建议0.8。深度有效范围:一般取0.1米到6米。太近的像素噪声大,太远的深度不确定性高,这两个极端都应该直接mask掉。采样方式:均匀网格采样时,必须保证网格在图像平面内均匀铺开,否则画面中心像素会产生巨大权重;我实际用“边缘增强采样”,优先保留深度梯度大的点,再均匀补足。
5.3 与纯特征方案的本质差异
为什么深度图优化的效果会优于纯特征方案?不是因为特征的数学性质不好,而是因为特征点的数目受限于纹理。低纹理墙面可能只能提取出几个特征点,但这面墙在深度图上贡献了成千上万个有效深度像素。把这些像素全部作为观测约束,信息量大到几何可观,后端自然能更精确地锁定位姿。另一个角度是误差模型,特征重投影误差对远点敏感,深度对齐误差用对数深度之后更均衡,这避免了远点“一票否决”式地拖拽整个优化结果。
6. 常见问题与排查技巧实录
6.1 深度图噪声导致优化发散
最典型的故障现象是:前几十帧一切正常,某次快速运动之后,位姿突然跳了几米,然后就回不来了。原因通常不是优化器坏了,而是这一帧的深度图坏点太多,大量无效像素被当成有效约束。排查方法是把每一帧参与残差计算的深度点数、有效比例、残差均值打印出来。正常时有效比例应该在八成以上,残差均值集中在0.02以下。一旦发现有效比例突然掉到五成,就要怀疑预处理环节是否把坏点滤干净了。我后来专门加了一行日志,输出每帧深度图坏点占比,问题定位速度快了一倍。
另一个极其隐蔽的坑是OpenCV里的浮点深度图和毫米深度图混用。某些设备给的是uint16毫米单位,直接读到float里数值是几千;另一些给的是float米单位,数值不到10。如果预处理流程里没有统一单位,对数深度残差的两个log差直接变成十多,Huber核都会失效。我的建议是数据入口统一除以1000.0并限定为float类型。
6.2 局部极小值陷入
深度图对比优化本质上还是非线性问题,位姿初值错了,优化很容易掉进局部极小。典型症状是残差一直在降,但轨迹在某个方向整体错位,甚至出现类似“手柄抖动”的交替震荡。解决局部极小最有效的方法是给优化一个足够好的初值。我在工程里采用“先特征粗对齐,再深度精优化”的串联流程。特征粗对齐作用在图像平面上,能保证角度初值误差在5度以内;有了这个前提,深度残差优化的收敛范围就完全够了。
还有一种建议是使用多分辨率优化。先把深度图缩到八分之一大小,跑一次粗对齐;再把分辨率恢复到二分之一,再跑一次;最后全分辨率。这个套路在深度相机分辨率高、场景大的时候非常有效。代价是代码复杂度上升,但对初值敏感度下降明显。
6.3 工程实现中的效率与内存陷阱
深度图优化之所以给人“慢”的印象,很多时候是由于实现不讲究。第一,每个残差块都捕获整张深度图的引用,Ceres在自动微分时会反复调用采样函数,采样函数的坐标越界检查每次都跑,开销不小。优化的做法是预处理时把点云和对应像素坐标一起存好,在迭代循环外一次性采样好参考深度,迭代内只做位姿变换和残差计算。第二,把每个像素都塞给残差块,建立的Ceres Problem可能包含几十万个残差块,光是构建Problem本身就要占掉大量内存。我用压低点数到600的办法,内存占用立刻降到可接受范围。
| 故障现象 | 排查切入点 | 解决建议 |
|---|---|---|
| 迭代发散,轨迹跳变 | 打印每帧有效深度比例 | 增强无效值过滤,统一深度单位 |
| 收敛但轨迹整体偏移 | 检查初值是否来自特征粗对齐 | 串联特征粗对齐或启用低分辨率粗对齐 |
| 回环检测误报 | 检查DNCC阈值是否过低 | 提高阈值并加入位姿一致性校验 |
| 单帧优化耗时突增 | 查看残差块数量是否失控 | 限制深度采样点数,关闭无用残差项 |
最后分享一个很实用的小技巧:在深度图残差进入优化前,先做一次“对数深度图均值归一化”。也就是把整张深度图减去均值、除以标准差,再送入对比。这不是什么高深的数学处理,但它能消除不同视角下因尺度差异带来的整体偏差,让DNCC和深度残差在面对相似但不完全相同的场景时都更加平滑。我在多个室内序列里实测过,这个预处理大约能降低7%左右的绝对轨迹误差,几乎没有带来额外耗时。感知上这只是个很小的细节,但常年跑SLAM的人都知道,精度往往就是被一个个小细节堆出来的。