☰
从零啃DSO源码:初始化阶段核心模块与实现细节解析
2026/10/12 19:52:03 网站建设 项目流程

1. 从零啃DSO源码:初始化阶段到底在做什么

第一次打开DSO(Direct Sparse Odometry)的源码时,我的反应和大多数人一样——文件不多,但每个文件都像天书。尤其是初始化部分,代码量不算大,但涉及的概念密度极高:光度标定、稀疏直接法、滑动窗口、边缘化先验,这些东西如果不在脑子里建立起清晰的图景,后面根本读不下去。

DSO是直接法视觉里程计里绕不开的一个经典系统。它和特征点法最大的区别在于:不提取关键点、不算描述子,直接拿像素灰度值来优化相机位姿和地图点。这个思路听起来简单,但真正落地时会遇到一堆问题——光度不一致怎么办、初始化怎么收敛、滑窗里的信息怎么保留。而初始化阶段,恰恰是这些问题最集中的地方。

这篇笔记面向的是已经了解SLAM基本概念、想深入读DSO源码的人。我会把初始化阶段拆成几个核心模块,讲清楚每一步在干什么、为什么这么设计、代码里对应哪些函数。不是逐行翻译代码,而是把设计意图和实现细节串起来。读完之后,你应该能自己顺着代码走一遍初始化流程,而不是对着屏幕发呆。

2. 初始化在整个DSO系统中的位置与设计考量

2.1 为什么初始化阶段值得单独拿出来研究

DSO的初始化不是简单的“给个初值然后开始优化”。它要解决一个鸡生蛋蛋生鸡的问题:要优化位姿和深度,需要光度误差;要算光度误差,需要知道曝光时间和光度响应;而这些东西在系统刚启动时全是未知的。

我的理解是,DSO的初始化本质上在做三件事:第一,选定一个参考帧,建立初始的窗口结构;第二,在参考帧上撒点,给每个点一个粗糙的深度初值;第三,通过几帧的观测,把光度参数和深度值一起优化到一个可用的状态。这三件事做完,系统才算真正“跑起来”。

从代码结构上看,初始化相关的逻辑主要集中在FullSystem的addActiveFrame函数里,配合CoarseInitializer类完成。CoarseInitializer是一个独立的类,专门负责前两帧的初始化,做完之后就把结果交给正式的优化流程。

2.2 直接法初始化与特征点法初始化的本质差异

特征点法的初始化通常靠对极几何或者三角化,流程是:提取特征点、匹配、算基础矩阵或单应矩阵、分解出位姿、三角化地图点。这套流程的好处是几何关系明确,坏处是依赖匹配质量,纹理弱或者重复纹理多的时候容易崩。

DSO走的是完全不同的路。它不匹配特征点,而是直接在参考帧上选一批像素点,然后通过最小化光度误差来同时优化位姿和深度。这意味着初始化阶段就要处理光度问题——不同帧的曝光可能不同,相机响应曲线也不是线性的。所以DSO在初始化时就要估计曝光时间,甚至光度响应函数。

这个差异带来的直接影响是:DSO的初始化对光照变化更敏感,但一旦初始化成功,在纹理弱的环境下反而比特征点法更稳。因为像素点多,信息量大,不像特征点法那样依赖少数几个关键点。

2.3 初始化阶段的核心模块划分

把初始化阶段的代码拆开看,大致可以分成这么几块:

  • 帧的预处理:包括图像金字塔构建、梯度计算、光度标定参数的读取。这部分在FullSystem::addActiveFrame的前半段。
  • 参考帧选择与窗口管理:决定当前帧是作为新的参考帧还是跟踪帧,维护滑动窗口的结构。
  • 粗初始化(CoarseInitializer):前两帧之间的位姿和深度初始化,这是最核心的部分。
  • 点的选取与深度初值:在参考帧上选点,给每个点一个初始深度范围。
  • 光度参数初始化:曝光时间的初始估计和后续优化。

这几块不是严格线性的,有些步骤会交叉。比如点的选取和深度初值在粗初始化阶段就会做,而光度参数的优化会贯穿整个初始化过程。

3. 粗初始化模块的代码结构与关键实现

3.1 CoarseInitializer类的整体设计

CoarseInitializer这个类在DSO源码里算是比较独立的一块,它的职责很明确:给定两帧图像,估计它们之间的相对位姿和每个点的深度。这个类只在初始化阶段用,初始化完成后就不再参与后续优化。

类的核心成员包括:

  • int w, h:图像宽高。
  • MatXX JbBuffer系列:用于累积雅可比矩阵的缓冲区。
  • Vec10f* JbBuffer_new:每个点的雅可比缓冲区。
  • Frame* frame:参考帧指针。
  • CalibHessian* Hcalib:光度标定对象。

核心函数有setFirst、setSecond、trackFrame、calcResAndGS、calcEC、optE、optD等。这些函数的分工是:setFirst和setSecond分别设置参考帧和当前帧,trackFrame做一次跟踪,calcResAndGS计算残差和高斯-牛顿系统的增量,optE优化曝光,optD优化深度。

我的理解是,这个类的设计思路是“先粗后精”:先用少量点、低分辨率做一次粗略的位姿估计,然后逐步增加点数和分辨率,最后把结果交给正式优化。这种策略在直接法里很常见,因为直接法的优化问题是非凸的,初值不好很容易陷入局部极小。

3.2 setFirst与setSecond:两帧的初始化配置

setFirst做的事情比较直接:把传入的帧设为参考帧,然后初始化一些内部状态。具体来说,它会:

  1. 保存帧指针和标定对象。
  2. 根据图像尺寸分配缓冲区。
  3. 初始化一些计数器,比如numOpts、numRefs等。
  4. 设置初始的曝光参数。

setSecond稍微复杂一点。它除了设置当前帧之外,还要做几件关键的事:

  • 计算两帧之间的初始位姿猜测。这个猜测通常来自常速模型或者单位矩阵。
  • 在参考帧上选点,并给每个点一个初始深度范围。
  • 初始化每个点的雅可比缓冲区。

选点的逻辑在setSecond里是通过makePoints或者类似的函数完成的。选点的策略是:在参考帧上均匀撒点,但会避开梯度太小的区域,因为梯度小的地方光度误差对深度不敏感,优化起来没意义。

深度初值的给法也很有讲究。DSO不是随便给一个固定值,而是根据场景的典型深度范围给一个区间,然后在这个区间里取一个初始值。这个区间通常是根据相机的基线或者场景的先验知识来定的。

3.3 trackFrame:粗初始化中的跟踪流程

trackFrame是粗初始化的核心函数,它做的是:给定当前帧,通过优化位姿和深度来最小化光度误差。这个函数的流程大致是:

  1. 构建图像金字塔。DSO在粗初始化阶段会用多层金字塔,从粗到精逐层优化。
  2. 对每一层,迭代执行以下步骤:
    • 计算残差和雅可比。
    • 构建高斯-牛顿方程。
    • 求解增量,更新位姿和深度。
    • 判断收敛,如果收敛就进入下一层。
  3. 把优化结果保存下来,供后续使用。

这里有个细节值得注意:在粗初始化阶段,DSO并不是同时优化所有点的深度,而是先优化位姿,再优化深度,交替进行。这种做法在直接法里叫“交替优化”,好处是每次优化的变量少,计算量小,而且不容易陷入局部极小。坏处是收敛速度可能慢一些,但在初始化阶段,稳定性比速度更重要。

3.4 calcResAndGS:残差与高斯-牛顿系统的构建

calcResAndGS这个函数是计算量的主要来源。它要做的事情是:对每个选中的点,计算光度残差,然后累积雅可比矩阵和残差向量,最终形成高斯-牛顿系统。

光度残差的定义是:

r = I_ref(p) - I_cur(p') * (t_cur / t_ref) - b

其中I_ref和I_cur分别是参考帧和当前帧的灰度值,t_ref和t_cur是曝光时间,b是亮度偏移。这个残差对位姿和深度的雅可比需要分别计算。

对位姿的雅可比涉及到图像梯度、相机内参和点的深度。对深度的雅可比则涉及到图像梯度和位姿。这些雅可比的计算在代码里是通过链式法则一步步展开的,看起来复杂,但逻辑是清晰的。

我的经验是,读这部分代码的时候,不要一上来就盯着公式看。先把每个变量的物理意义搞清楚,然后看代码里是怎么一步步算出来的。比如JbBuffer这个缓冲区,它存的是每个点的雅可比和残差,理解了这个,后面的累积过程就顺了。

3.5 optE与optD:曝光与深度的交替优化

optE和optD是两个相对独立的优化函数。optE优化的是曝光时间,optD优化的是深度。

optE的逻辑比较简单:固定位姿和深度,只优化曝光时间。因为曝光时间是一个标量,所以这个优化是一维的,可以用解析解直接算出来。代码里就是累加一些量,然后做一次除法。

optD优化的是每个点的深度。这里DSO用了一个技巧:不是直接优化深度值,而是优化深度的倒数(也叫逆深度)。逆深度的好处是,在深度很大的时候,逆深度接近零,数值上更稳定。而且逆深度的分布通常更接近高斯分布,适合高斯-牛顿优化。

optD的流程是:对每个点,计算残差对逆深度的雅可比,然后更新逆深度。这里有个细节:DSO会限制逆深度的范围,避免出现负深度或者无穷大深度。这个限制在代码里是通过setting_depthMin和setting_depthMax来控制的。

4. 初始化中的光度标定与参数估计

4.1 光度模型的基本假设与参数化

DSO的光度模型基于一个假设:不同帧之间的灰度差异可以用一个仿射变换来近似。具体来说:

I_cur(p') = a * I_ref(p) + b

其中a和b是光度参数,a主要跟曝光时间有关,b是亮度偏移。在实际实现中,DSO把a参数化为曝光时间的比值,即a = t_cur / t_ref。

这个模型看起来简单,但它解决了一个大问题:不同帧的曝光不同导致的灰度差异。如果没有这个模型,直接法优化会被光度差异带偏,位姿估计会不准。

我的理解是,这个模型的假设在大多数情况下是成立的,因为相机的响应曲线在中间调区域近似线性。但在高动态范围场景下,这个假设可能不成立,这时候就需要更复杂的光度模型,比如考虑相机的响应函数。DSO的完整版本里确实有响应函数的估计,但在初始化阶段,通常只用仿射模型就够了。

4.2 曝光时间的初始估计与优化策略

曝光时间的初始估计在setSecond里做。DSO的做法是:假设两帧的曝光时间相同,即t_cur = t_ref。这个假设在大多数情况下是合理的,因为相机自动曝光通常不会在两帧之间剧烈变化。

然后,在trackFrame的迭代过程中,曝光时间会通过optE不断优化。optE的优化目标是:固定位姿和深度,找到使光度误差最小的曝光时间。因为曝光时间是一维的,这个优化可以解析求解。

具体来说,optE会累加每个点的I_ref * I_cur和I_cur * I_cur,然后做一次除法得到最优的曝光时间比值。这个计算在代码里就是几行,但背后的推导需要理解光度误差对曝光时间的导数。

4.3 光度参数对初始化收敛的影响

光度参数估计不准,初始化很容易失败。我踩过的一个坑是:在光照变化剧烈的场景下,如果曝光时间的初始估计偏差太大,粗初始化会收敛到一个错误的位姿,然后整个系统就崩了。

DSO的处理方式是:在粗初始化阶段,曝光时间的优化和位姿的优化是交替进行的。这样,即使初始曝光估计不准,经过几轮迭代也能收敛到合理值。但前提是,场景的纹理要足够丰富,否则光度误差的梯度太小,优化会停滞。

另一个经验是:如果场景的亮度整体偏暗或者偏亮,光度模型的线性假设可能不成立,这时候初始化容易失败。解决办法是,在预处理阶段做一次直方图均衡化,或者调整相机的曝光设置,让图像亮度落在中间调区域。

5. 点的选取、深度初始化与窗口管理

5.1 参考帧上选点的策略与实现细节

选点的逻辑在CoarseInitializer里是通过一个叫makePoints的函数完成的(不同版本的DSO可能函数名略有差异)。选点的策略是:

  1. 在参考帧上均匀撒点,但会避开图像边界。
  2. 对每个候选点,计算其局部梯度。如果梯度太小,就丢弃这个点。
  3. 对保留下来的点,再做一个非极大值抑制,避免点太集中。

这个策略的目的是:选出来的点要有足够的光度变化,这样光度误差对深度和位姿才敏感。梯度小的点,比如白墙上的点,光度误差几乎不随深度变化,优化起来没意义。

选点的数量在粗初始化阶段通常比较少,比如几百个。这是因为粗初始化只需要一个粗略的位姿和深度,不需要太多点。点太多反而会增加计算量,而且容易引入噪声。

5.2 深度初值的给定方式与范围选择

深度初值的给定方式直接影响初始化的收敛性。DSO的做法是:给每个点一个深度范围,然后在这个范围内取一个初始值。这个范围通常是根据场景的典型深度来定的。

具体来说,代码里会有一个setting_depthMin和setting_depthMax,分别对应深度的最小值和最大值。初始深度通常取这两个值的几何平均或者算术平均。然后,在优化过程中,深度会被限制在这个范围内。

这个范围的选择很关键。如果范围太窄,可能把真实深度排除在外;如果范围太宽,优化容易陷入局部极小。我的经验是,对于室内场景,深度范围可以设为0.5米到10米;对于室外场景,可以设为1米到100米。当然,这只是一个粗略的参考,具体还要看相机的基线和场景的尺度。

5.3 滑动窗口的初始化与关键帧管理

DSO的滑动窗口在初始化阶段就开始建立了。窗口的大小由setting_windowSize控制,通常是5到10帧。初始化的前两帧会作为窗口的起始帧,后续帧通过跟踪加入窗口。

窗口管理的逻辑在FullSystem::addActiveFrame里。当新的一帧到来时,系统会判断它是作为关键帧还是普通帧。判断的依据通常是:当前帧和参考帧之间的视差是否足够大,或者跟踪质量是否下降。

如果当前帧被选为关键帧,它会加入窗口,同时窗口中最老的一帧会被边缘化。边缘化的过程会把老帧的信息转化为先验,保留在优化问题中。这个先验在初始化阶段尤其重要,因为初始化时窗口里的帧少,信息量不足,先验可以帮助稳定优化。

我的体会是,初始化阶段的窗口管理要特别小心。如果窗口太小,信息量不够,优化容易发散;如果窗口太大,计算量增加,而且老帧的信息可能已经过时。DSO的默认窗口大小是7,这个值在大多数场景下都能工作,但在快速运动的场景下可能需要调小。

6. 初始化过程中的常见问题与排查经验

6.1 初始化失败的典型表现与原因分析

初始化失败的表现通常有几种:位姿估计明显错误、深度图一片模糊、系统直接崩溃或者卡死。这些表现背后的原因各不相同。

位姿估计错误通常是因为光度误差的优化陷入了局部极小。可能的原因包括:初始位姿猜测太离谱、场景纹理太弱、光照变化太剧烈。排查的方法是:检查初始位姿的猜测是否合理,看看图像有没有明显的纹理,对比两帧的亮度直方图。

深度图模糊通常是因为深度初值给得不好,或者优化没有收敛。可能的原因包括:深度范围设得太宽、选的点太少、光度参数估计不准。排查的方法是:检查深度范围是否合理,看看选点的数量和分布,检查曝光时间的优化结果。

系统崩溃或者卡死通常是因为数值问题,比如出现了NaN或者无穷大。可能的原因包括:逆深度变成了负数、雅可比矩阵奇异、曝光时间变成了零。排查的方法是:在关键步骤加打印,看看哪个变量出了问题。

6.2 光度变化剧烈时的应对方法

光度变化剧烈是直接法初始化的一大挑战。DSO虽然有能力估计曝光时间,但如果变化太剧烈,仿射模型就不够用了。

我的经验是,在这种情况下,可以尝试以下几个方法:

  • 在预处理阶段做一次直方图匹配,把当前帧的亮度分布对齐到参考帧。
  • 如果相机支持,关闭自动曝光,手动设置一个固定的曝光时间。
  • 在初始化阶段增加曝光时间的优化迭代次数,让它有足够的时间收敛。
  • 如果还是不行,可以考虑换一个光照更稳定的场景做初始化,等系统跑起来之后再进入复杂场景。

6.3 初始化耗时过长的优化思路

初始化耗时过长通常是因为迭代次数太多或者每轮迭代的计算量太大。DSO的粗初始化在默认设置下通常能在几十毫秒内完成,但如果场景复杂或者参数设置不当,可能会慢很多。

优化思路包括:

  • 减少选点的数量。粗初始化不需要太多点,几百个就够了。
  • 降低图像金字塔的层数。层数越多,计算量越大。
  • 放宽收敛条件。初始化不需要太精确,差不多就行。
  • 如果是在嵌入式平台上跑,可以考虑用定点数代替浮点数,或者用SIMD指令加速。

下面这个表格整理了我遇到过的典型问题、可能原因和排查方法,方便快速查阅:

问题表现可能原因排查方法
位姿估计错误初始位姿猜测太离谱检查常速模型的输出
位姿估计错误场景纹理太弱查看图像梯度分布
位姿估计错误光照变化太剧烈对比两帧亮度直方图
深度图模糊深度范围设得太宽检查depthMin和depthMax
深度图模糊选点太少统计选点数量
深度图模糊光度参数估计不准打印曝光时间优化结果
系统崩溃逆深度变成负数检查深度更新公式
系统崩溃雅可比矩阵奇异检查矩阵条件数
系统崩溃曝光时间变成零检查optE的输出
初始化耗时过长选点太多减少选点数量
初始化耗时过长金字塔层数太多减少金字塔层数
初始化耗时过长收敛条件太严放宽收敛阈值

6.4 从初始化阶段过渡到正式优化的注意事项

初始化完成后,系统会从粗初始化切换到正式优化。这个切换不是瞬间完成的,中间有一个过渡过程。过渡期间,粗初始化得到的位姿和深度会作为正式优化的初值,同时窗口里的帧会重新组织。

我的经验是,过渡期间最容易出问题的地方是:粗初始化得到的深度可能不够精确,正式优化需要几帧才能把它调整过来。如果在这几帧里出现了快速运动或者光照变化,系统可能会跟丢。

为了避免这个问题,可以在过渡期间降低优化的学习率,让系统慢慢调整。另外,可以在过渡期间增加关键帧的选取频率,让窗口里尽快积累足够的观测。

7. 读DSO初始化源码的方法论与工具建议

7.1 如何高效阅读直接法SLAM的源码

读直接法SLAM的源码,我的建议是:不要一上来就逐行读代码,先搞清楚系统的整体流程,然后再深入细节。

具体来说,可以分三步走:

  1. 画流程图:把初始化的流程画成一张图,标出每个步骤的输入输出。不用太详细,能看出数据流向就行。
  2. 找关键函数:在流程图上标出每个步骤对应的函数,然后重点读这些函数。其他辅助函数可以暂时跳过。
  3. 加打印调试:在关键函数里加打印,输出中间结果。然后跑一个简单的数据集,看看输出是否符合预期。

这个方法的好处是,你能快速建立起对系统的整体认识,然后再深入细节。如果一上来就逐行读,很容易迷失在代码的海洋里。

7.2 调试工具与可视化手段推荐

调试直接法SLAM,可视化非常重要。我常用的工具包括:

  • 图像显示:把参考帧、当前帧、深度图、残差图都显示出来,直观地看到问题。
  • 轨迹显示:把估计的轨迹和真值轨迹画在一起,看看偏差有多大。
  • 参数曲线:把曝光时间、深度值、残差等随时间的变化画出来,看看有没有异常。

这些工具在DSO的源码里有一部分已经提供了,比如pangolin的显示。如果没有,可以自己用OpenCV或者Matplotlib写一个简单的显示程序。

7.3 从初始化扩展到整个系统的学习路径

初始化只是DSO的一部分,读懂了初始化,接下来可以顺着数据流往下读:

  • 跟踪:初始化完成后,系统进入跟踪模式,逐帧估计位姿。
  • 优化:跟踪过程中,系统会定期做一次全局优化,调整窗口里的所有变量。
  • 边缘化:当窗口满了,老帧会被边缘化,信息转化为先验。
  • 回环:DSO本身没有回环检测,但可以结合其他模块实现。

我的建议是,先把初始化读透,然后读跟踪,最后读优化和边缘化。这样循序渐进,每一步都有前面的基础,不会太吃力。

7.4 实操心得:从编译到跑通第一个数据集

最后分享一点实操心得。DSO的编译不算复杂,但依赖比较多,尤其是Pangolin和OpenCV的版本要匹配。我踩过的坑是:Pangolin的版本太新,和DSO的代码不兼容,编译报了一堆错。后来换了一个老版本才编译通过。

跑第一个数据集的时候,建议用TUM的单目数据集,因为它的场景比较简单,光照也比较稳定。跑之前,先检查一下配置文件里的参数,尤其是相机的内参和光度标定参数。这些参数不对,初始化肯定失败。

跑通之后,可以试着改一些参数,比如窗口大小、选点数量、深度范围,看看对初始化的影响。这种实验性的学习方式,比单纯读代码要有效得多。

提示:DSO的初始化对参数比较敏感,建议先用默认参数跑通,再逐步调整。每次只改一个参数,观察效果,避免同时改多个参数导致问题难以定位。

注意:如果初始化一直失败,先检查图像质量。图像太暗、太亮、太模糊,都会导致初始化失败。确保图像清晰、亮度适中,再检查参数。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询