ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VINS-FUSION代码阅读指南:从数据结构到滑动窗口优化

VINS-FUSION代码阅读指南:从数据结构到滑动窗口优化

1. 从"能跑通"到"能改懂":VINS-FUSION代码阅读的切入点选择

很多人第一次打开VINS-FUSION的代码仓库时,第一反应是"文件好多、类好多、回调好绕"。这很正常,因为它本身就是一个把视觉前端、IMU预积分、滑动窗口优化、回环检测、全局位姿图这几套东西缝在一起的系统。如果一上来就从main函数顺着往下读,大概率会在estimator_node.cpp那一堆ROS回调里迷路,读了两天还在纠结"这帧图像到底进没进优化"。

我自己读这套代码的顺序,和大多数人推荐的"从数据流入口读"不太一样。我更倾向于先读数据结构,再读数据流,最后读优化模型。原因很简单:VINS-FUSION里真正难的不是"数据怎么流",而是"数据以什么形式被组织和约束"。你把FeatureManager、IntegrationBase、ImageFrame这几个核心结构体搞清楚了,后面看Estimator::processImage和Estimator::optimization就是水到渠成的事。

这篇内容面向的是已经能编译运行VINS-FUSION、但读代码时抓不住重点的读者。我会按"VIO1"这个阶段的目标来组织——也就是只聚焦视觉惯性里程计的主链路,回环和全局优化先放一边。整篇会围绕四个问题展开:核心数据结构怎么设计、图像和IMU两条数据流怎么汇合、滑动窗口优化到底在优化什么、以及读代码时最容易踩的几个坑。

提示:读VINS-FUSION之前,建议先把VINS-Mono的论文过一遍,尤其是IMU预积分和滑动窗口边缘化那两节。代码里的很多变量命名直接对应论文符号,比如para_Pose、para_SpeedBias、linearized_jacobians,论文看懂了,代码命名就不再是天书。

2. 核心数据结构:读懂VINS-FUSION的三块"地基"

2.1 FeatureManager:不只是特征管理器,更是滑动窗口的"账本"

FeatureManager这个名字很容易让人以为它只负责特征点的提取和跟踪,实际上它承担了远比名字更重的职责。它内部维护了feature列表(每个特征在各帧中的观测)、window相关的帧索引映射、以及和边缘化直接相关的last_marginalization_info。换句话说,滑动窗口里哪些帧还在、哪些特征被边缘化、先验信息怎么传递,全在这个类里。

读这个类的时候,我建议重点盯住三个函数:

  • addFeatureCheckParallax:判断新帧是"关键帧"还是"普通帧",依据是平均视差和跟踪特征数。这个判断直接决定了滑动窗口是否要挪动。
  • marginalizeOld和marginalizeSecondNew:分别对应"边缘化最老帧"和"边缘化次新帧"两种策略。这两种策略的取舍是VINS-FUSION能保持实时性的关键。
  • getCorresponding:把特征观测映射到优化变量索引上,是连接"特征"和"优化问题"的桥梁。

很多人读到这里会问:为什么要有两种边缘化策略?直接固定窗口大小、每次丢最老帧不行吗?不行。如果新帧视差不够,说明它和上一帧信息高度冗余,这时候把次新帧边缘化掉、保留新帧,可以避免窗口里塞满"看起来很像"的帧,浪费计算资源。这个设计思路在代码里体现得非常直接,但如果不理解动机,就会觉得marginalizeSecondNew这个分支莫名其妙。

2.2 IntegrationBase:IMU预积分的"时间胶囊"

IntegrationBase是IMU预积分的载体。它的核心思想是:在两个视觉关键帧之间,可能有很多IMU测量,与其把每个IMU测量都放进优化变量,不如把它们预积分成一个相对约束。这样优化变量里就只需要保留关键帧的位姿、速度、偏置,维度大幅下降。

这个类里几个关键成员值得反复看:

成员/函数作用读代码时的关注点
delta_p、delta_q、delta_v预积分的位移、旋转、速度增量注意它们是相对于linearized_ba/bg的
jacobian、covariance预积分对偏置的雅可比和协方差偏置更新后如何修正预积分量
midPointIntegration中值积分比欧拉积分精度高,是默认选择
repropagate偏置变化后重新传播理解为什么偏置一变就要重算

这里有个特别容易忽略的点:预积分量不是固定不变的。当优化过程中偏置被更新,IntegrationBase需要根据雅可比对delta_p/q/v做一阶修正,这就是repropagate存在的原因。如果你在读Estimator::optimization时看到偏置更新后调用了repropagate,不要跳过,那是保证预积分一致性的关键一步。

2.3 ImageFrame与feature_per_frame:观测的"容器"

ImageFrame本身很轻,主要存时间戳、位姿、图像指针等。真正承载观测信息的是它内部的features,每个元素是一个FeaturePerFrame,记录了该特征在这一帧的归一化坐标、像素坐标、速度等。

这里的设计有一个细节:同一个特征在不同帧里的观测,是通过FeatureManager::feature列表里的feature_per_frame数组串起来的。也就是说,特征不是按帧组织的,而是按"特征ID"组织的,每帧的观测挂在对应特征下面。这种组织方式对后续构建重投影误差非常友好,因为优化时需要的是"一个特征在所有帧里的观测",而不是"一帧里所有特征"。

读代码时如果搞混了这两种组织方式,就会在Estimator::optimization里构建残差时一头雾水。我的建议是:拿一张纸,画一个二维表格,行是特征ID,列是帧ID,把feature_per_frame填进去,你会立刻明白为什么边缘化时"某些特征会被整条删掉"。

3. 数据流汇合:图像和IMU是怎么"对上暗号"的

3.1 两条独立回调,一个共同的时间基准

VINS-FUSION的输入是两路:图像话题和IMU话题。它们分别进入img_callback和imu_callback。这两个回调是异步的,频率也完全不同(图像通常10-30Hz,IMU通常100-200Hz)。让它们"对上暗号"的关键,是统一的时间戳和buf缓存机制。

IMU回调相对简单:把测量打包成IMUMeasurement塞进imu_buf,同时如果当前还没初始化,就顺便做一下IMU初始化。图像回调则复杂得多,它要做的事包括:

  1. 检查时间戳是否异常(跳变、重复、回退)。
  2. 如果还没初始化,先走initialStructure做视觉-惯性对齐。
  3. 如果已初始化,把当前帧和imu_buf里对应时间段的IMU测量一起送进Estimator::processImage。

这里有个非常关键的细节:图像帧和IMU测量的时间对齐不是简单的"取最近的IMU"。VINS-FUSION会在processImage里根据图像时间戳,从imu_buf中取出覆盖该时间段的IMU测量,并在两端做线性插值。这个插值逻辑在getIMUInterval和Estimator::processIMU里。如果插值做错了,预积分就会引入系统性误差,表现为轨迹整体漂移。

3.2 processImage:VIO主链路的"总调度"

Estimator::processImage是整个VIO主链路的总调度。它的执行顺序大致是:

  1. 用FeatureManager::addFeatureCheckParallax判断新帧类型。
  2. 如果需要,执行边缘化(marginalizeOld或marginalizeSecondNew)。
  3. 把新帧加入滑动窗口。
  4. 调用optimization做滑动窗口优化。
  5. 更新状态、发布位姿。

这个顺序不是随便定的。先边缘化再优化,是因为边缘化会改变优化问题的维度,必须在构建新的优化问题之前完成。如果顺序反了,先验信息就会和当前窗口对不上,优化直接发散。

读这个函数时,我建议配合打印日志一起看。在optimization前后各加一行输出,打印当前窗口大小、特征数、优化前后的位姿差异。跑一段数据,你会直观看到:边缘化发生时窗口大小不变但特征数骤降,优化后位姿会有一次明显修正。这种"看得见"的反馈,比单纯读代码有效得多。

3.3 初始化:VIO最脆弱也最关键的阶段

VINS-FUSION的初始化分两步:纯视觉的SFM初始化和视觉-惯性对齐。前者用initialStructure,后者用VisualIMUAlignment。很多人跑数据集时发现"前几秒轨迹飘得厉害",问题往往就出在初始化阶段。

initialStructure的核心是:选两帧视差足够大的图像,做特征匹配,用对极几何恢复相对位姿和三维点。这一步对纯旋转或低速运动非常敏感,因为视差不够时三角化不稳定。代码里用relativePose函数来选这对帧,判断依据是平均视差和匹配点数。

VisualIMUAlignment则负责估计尺度、重力方向、速度、偏置。它内部又分solveGyroscopeBias和LinearAlignment。前者用旋转约束估计陀螺仪偏置,后者用平移约束估计其余量。这里有个经验:如果IMU噪声参数设得和实际硬件差太多,初始化很容易失败或给出离谱的尺度。我一般会先跑一遍,看初始化输出的重力和尺度是否合理(重力模长应接近9.8),不合理就回去调acc_n、gyr_n这些参数。

4. 滑动窗口优化:VIO1阶段真正的主战场

4.1 优化变量与残差项的全景

Estimator::optimization是VIO1阶段最核心的函数。它用Ceres构建一个滑动窗口内的非线性最小二乘问题。优化变量包括:

  • 滑动窗口内每帧的位姿(para_Pose)和速度偏置(para_SpeedBias)。
  • 外参(para_Ex_Pose),即相机到IMU的变换。
  • 特征逆深度(para_Feature)。
  • 如果有回环,还有回环相关变量(VIO1阶段可以先忽略)。

残差项主要有四类:

残差类型来源约束的是什么
边缘化先验marginalization_info被边缘化掉的历史信息
IMU残差IntegrationBase::evaluate相邻关键帧间的预积分约束
视觉重投影残差ProjectionFactor特征观测与三维点的几何一致性
外参残差ProjectionTwoFrameOneCamFactor等外参的先验或在线估计

这四类残差里,边缘化先验是最难读的。它本质上是一个"信息矩阵",把被移除变量的信息压缩成对剩余变量的约束。Ceres里通过MarginalizationInfo和MarginalizationFactor来实现。读这部分时,不要试图一次看懂所有雅可比,先理解"为什么要边缘化"和"边缘化后信息怎么保留",细节可以后面慢慢啃。

4.2 边缘化:为什么丢帧不能随便丢

边缘化的数学本质是舒尔补。假设优化问题可以写成:

[H_mm H_mr] [dx_m] [b_m] [H_rm H_rr] [dx_r] = [b_r]

其中m是要边缘化的变量,r是保留的变量。舒尔补后,保留变量的信息矩阵变为H_rr - H_rm * H_mm^{-1} * H_mr,右端项也相应修正。这个修正后的信息矩阵,就是先验。

代码里MarginalizationInfo::marginalize做的就是这件事。它先收集所有和边缘化变量相关的残差,构建H和b,然后做舒尔补,最后把结果打包成MarginalizationFactor加进下一次优化。

这里有个非常容易踩的坑:边缘化会引入"信息丢失"。因为舒尔补后,原本稀疏的H矩阵可能变稠密,而且被边缘化变量的信息被"投影"到保留变量上,如果保留变量后续又被边缘化,信息会进一步压缩。这就是为什么VINS-FUSION在长时间运行后,先验会变得越来越"重",最终可能导致优化不一致。理解这一点,你就能明白为什么代码里要区分marginalizeOld和marginalizeSecondNew——前者丢最老帧,后者丢次新帧,目的都是控制先验的"重量"。

4.3 视觉残差的构建:从归一化坐标到重投影误差

ProjectionFactor是视觉残差的核心。它的输入是:特征在某一帧的归一化坐标、该特征对应的逆深度、以及该帧的位姿和外参。输出是重投影误差。

读这个类时,重点看Evaluate函数。它做的事情是:

  1. 把逆深度转成三维点(在宿主帧坐标系下)。
  2. 通过位姿和外参,把三维点变换到目标帧的相机坐标系。
  3. 投影到归一化平面,和观测值做差。
  4. 乘以信息矩阵(通常是单位阵或根据像素噪声设定)。

这里有个细节:VINS-FUSION用的是逆深度参数化,而不是三维坐标参数化。逆深度的好处是,对于远距离点,逆深度接近0,数值更稳定;对于近距离点,逆深度大,也能保证精度。这个选择在代码里体现为para_Feature存的是逆深度,而不是x,y,z。

另一个细节是信息矩阵的设定。代码里通常用1.0 / (pixel_noise * pixel_noise)作为权重。如果这个值设得不对,视觉残差和IMU残差的相对权重就会失衡,表现为"视觉拉不住IMU"或"IMU拉不住视觉"。我一般会先按默认值跑,如果发现轨迹在快速运动时明显滞后,就适当调大视觉权重。

5. 读代码时最容易踩的四个坑

5.1 坐标系混淆:world、body、cam傻傻分不清

VINS-FUSION里涉及至少四个坐标系:世界系world、IMU体系body、相机系cam、以及归一化平面。代码里很多变量没有显式标注坐标系,读的时候必须靠上下文推断。

比如para_Pose存的是body在world下的位姿,而ProjectionFactor里用到的q和t是cam到world的变换。如果搞混了,残差符号就会错,优化直接发散。我的经验是:在纸上画一个坐标系转换图,把每个变量标注清楚,读代码时随时对照。这个前期投入非常值得。

5.2 时间戳单位:秒还是纳秒

ROS里时间戳通常是秒(ros::Time),但VINS-FUSION内部很多地方用秒的浮点数,有些地方又用纳秒整数。imu_buf里的时间戳、FeaturePerFrame里的cur_td、IntegrationBase里的dt,单位要统一。如果单位搞错,预积分的dt会差几个数量级,结果完全不对。

我踩过一次坑:在自定义数据上跑时,IMU时间戳用了毫秒,结果预积分量小得离谱,轨迹几乎不动。排查了半天才发现是单位问题。所以读代码时,看到时间相关的变量,第一件事就是确认单位。

5.3 偏置更新与预积分重传播的时机

前面提过,偏置更新后要repropagate。但代码里不是每次优化后都无条件重传播,而是有一个判断:如果偏置变化超过阈值,才重传播。这个阈值在Estimator::optimization里。如果阈值设得太大,预积分和当前偏置不一致,优化会慢慢漂;设得太小,频繁重传播又浪费计算。

读代码时要注意这个判断逻辑,理解它是在"精度"和"效率"之间做权衡。实际调参时,如果发现轨迹在长时间运行后缓慢漂移,可以试着调小这个阈值。

5.4 边缘化先验的"信息矩阵"不是协方差

很多人第一次看MarginalizationFactor时,会把linearized_jacobians和linearized_residuals误认为是协方差和均值。实际上,它们对应的是舒尔补后的雅可比和残差,信息矩阵是雅可比的转置乘雅可比。这个区别在数学上很重要,但在代码里只体现为几个矩阵乘法的顺序。如果理解错了,调试时会完全找不到方向。

我的建议是:读这部分时,先把舒尔补的公式推导一遍,再对照代码看每一步对应哪个矩阵运算。推导一次,比读十遍代码都管用。

6. 一些让读代码更高效的个人习惯

读VINS-FUSION这种规模的代码,光靠"看"是不够的。我自己的习惯是边读边改边验证。具体做法是:

  • 在关键函数入口加日志,打印输入输出的维度和数值范围。比如在optimization前后打印para_Pose的均值和方差,能直观看到优化是否在收敛。
  • 用gdb或lldb在marginalizeOld处打断点,单步走一遍,看H矩阵是怎么被构建和舒尔补的。这种"慢动作"回放,比静态读代码理解深得多。
  • 把滑动窗口大小临时改小(比如从10改成5),跑一段短数据,观察边缘化发生的频率和先验的变化。窗口小了,问题规模小,更容易看清本质。
  • 对同一个数据集,分别关掉IMU和视觉,看轨迹退化成什么样。这能帮你快速定位"哪部分代码在起主导作用"。

另外,VINS-FUSION的代码里有很多ROS_ASSERT和ROS_WARN,不要忽略它们。这些断言往往标注了"作者认为这里不该出错"的边界条件,读懂了它们,你就读懂了作者的设计假设。

最后分享一个我自己的教训:刚开始读的时候,我总想一次把整个Estimator类读完,结果读了三遍还是记不住。后来改成"每次只盯一个函数,读完就画流程图,画完再读下一个",效率反而高了很多。VIO本身就是一个多模块耦合的系统,想一口吃下,只会消化不良。先把VIO1这条主链路走通,回环和全局优化后面再啃,节奏会舒服很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进