1. 为什么值得花时间啃 OpenVINS 的代码框架
如果你正在做视觉惯性里程计(VIO)相关的项目,大概率绕不开 OpenVINS 这个开源框架。它由多所高校的研究者联合维护,核心定位是一个面向科研与教学的多状态约束卡尔曼滤波(MSCKF)视觉惯性导航系统。相比 VINS-Mono 这类基于优化的方案,OpenVINS 在嵌入式平台上的实时性表现更稳,代码结构也更适合做二次开发——这正是我当初选择它作为项目底座的原因。
但说实话,第一次打开它的源码目录时,我是有点懵的。ov_core、ov_init、ov_msckf、ov_eval四个模块并列摆在那里,每个模块下面又是几十个文件,类型定义、状态管理、特征跟踪、更新策略全混在一起。如果只是跑个 demo 那无所谓,照着 README 编译就行;可一旦要改东西——比如换一种特征提取器、加一个新的观测模型、或者把状态向量扩展几个维度——不把框架吃透,改一处崩三处。
这篇内容就是把我自己啃 OpenVINS 代码框架的过程整理出来。我会从整体模块划分讲到核心数据结构的组织方式,再深入到 MSCKF 更新流程的具体实现,最后给出几个二次开发时最容易踩的坑和对应的排查思路。目标读者是已经跑通过 OpenVINS demo、准备动手改代码的工程师或研究生。如果你还没编译过,建议先把官方文档的安装步骤走一遍再回来,不然有些类名和文件路径会对不上。
整篇内容基于 OpenVINS 的稳定版本(2.x 系列)展开,不同小版本之间文件命名可能有细微差异,但核心架构是一致的。我会尽量把“为什么这么设计”讲清楚,而不只是罗列“这个文件干什么”。
2. 代码整体架构与模块划分逻辑
2.1 四个核心模块的职责边界
OpenVINS 的源码根目录下,ov_core、ov_init、ov_msckf、ov_eval四个文件夹构成了整个系统的主干。很多人第一次看会觉得分得太细,但用久了会发现这种划分恰好对应了 VIO 系统的四个关注点:基础工具、初始化、滤波主体、评估工具。
ov_core是最底层的模块,里面放的是与具体滤波算法无关的通用组件。特征跟踪、相机模型、数据类型定义、数学工具函数都在这里。你可以把它理解成一个“VIO 工具箱”——它不关心你用 MSCKF 还是 EKF,只负责把图像里的特征点跟踪好、把相机投影模型算对。二次开发时如果要替换特征提取算法,改的就是这个模块。
ov_init专门处理初始化问题。VIO 系统启动时,状态向量里的速度、重力方向、IMU 偏置都是未知的,需要一段时间的观测来估计这些量。OpenVINS 支持静态初始化和动态初始化两种模式,相关逻辑全部封装在这个模块里。它的存在让ov_msckf不用操心“系统还没初始化好怎么办”这件事。
ov_msckf是滤波主体,也是二次开发改动最频繁的地方。状态定义、预测步骤、更新步骤、状态管理(滑窗、特征剔除)都在这里。MSCKF 的核心思想——用多个相机观测约束同一个特征,从而避免把特征位置加入状态向量——在这个模块的代码里体现得非常清晰。
ov_eval是评估工具,负责计算轨迹误差、画图、对比不同算法的输出。做科研发论文的话这个模块很重要,但纯工程落地时用得少。
提示:二次开发前先明确你要改的是哪个模块。改特征跟踪去
ov_core,改状态估计去ov_msckf,改初始化策略去ov_init。跨模块改动往往意味着你要同时理解多个模块的接口约定。
2.2 模块间的依赖关系与数据流
四个模块不是平级关系,而是有明确的依赖方向。ov_core被所有其他模块依赖,ov_init依赖ov_core,ov_msckf同时依赖ov_core和ov_init,ov_eval则依赖ov_msckf的输出格式。
数据流是这样的:相机图像和 IMU 数据进入系统后,先在ov_core里做特征跟踪,提取出的特征观测和 IMU 测量一起送入ov_msckf。如果系统还没初始化,ov_msckf会调用ov_init的接口来估计初始状态。初始化完成后,ov_msckf进入正常的预测-更新循环。最后ov_eval读取ov_msckf输出的轨迹文件做评估。
这个数据流决定了一个重要的开发原则:改底层接口要慎之又慎。比如你改了ov_core里特征数据结构的定义,那ov_msckf里所有引用这个结构的地方都得跟着改。我见过有人为了加一个特征描述子字段,直接改了ov_core的核心类型,结果ov_msckf里几十处编译报错。更稳妥的做法是在ov_msckf层面做数据转换,而不是动底层类型。
2.3 为什么选择 MSCKF 而非优化式框架
这个问题在二次开发前值得想清楚。OpenVINS 用的是 MSCKF,属于滤波类方法;而 VINS-Mono、ORB-SLAM3 用的是滑动窗口优化。两者的代码框架差异很大。
MSCKF 的核心优势是计算量可控。它不把特征点位置加入状态向量,而是通过零空间投影把特征观测压缩成对相机状态的约束。状态向量的维度只跟相机数量有关,不跟特征数量有关。这意味着无论场景里有多少特征点,滤波的更新步骤计算量都是稳定的。对于嵌入式平台或者需要长时间运行的场景,这个特性很关键。
代价是精度通常不如优化式方法,尤其是在特征跟踪质量差或者运动剧烈的时候。但 OpenVINS 在 MSCKF 的基础上做了不少改进——比如支持 SLAM 特征(把部分特征加入状态向量)、支持多种相机模型、支持在线标定——这些改进让它在精度上追回了不少。
二次开发时,如果你要做的是“在现有框架上加功能”,MSCKF 的结构其实比优化式框架更好改。优化式框架里因子图、雅可比矩阵、稀疏求解器耦合在一起,改一个因子可能影响整个求解过程。MSCKF 的预测和更新是分离的,更新步骤里每个特征的贡献可以独立计算,模块化程度更高。
3. 核心数据结构与状态管理机制
3.1 状态向量的组织方式
OpenVINS 的状态向量定义在ov_msckf的State类里。理解这个类的成员变量布局,是读懂整个滤波流程的前提。
状态向量的核心部分包括:IMU 状态(姿态、位置、速度、陀螺仪偏置、加速度计偏置)、相机状态(每个滑窗内相机的姿态和位置)、标定参数(相机-IMU 外参、时间偏移)。这些量在代码里不是简单地塞进一个大数组,而是用Eigen的向量块(block)来管理。
具体来说,State类维护一个大的Eigen::VectorXd作为状态向量,然后用Eigen::VectorBlock或者索引偏移量来访问各个子状态。这样做的好处是预测和更新时可以直接对整个向量做矩阵运算,同时又能按需访问子状态。
// 状态向量的典型布局(简化示意) // [IMU姿态(4) | IMU位置(3) | IMU速度(3) | 陀螺偏置(3) | 加计偏置(3) | 相机状态... | 标定参数...]这里有个容易踩的坑:状态向量的索引是动态计算的。因为相机状态的数量会随着滑窗变化,标定参数也可能根据配置增减。所以代码里到处是_C->imu()->q()->id()这样的调用,而不是硬编码的索引值。二次开发时如果要加新的状态量,必须按照同样的模式注册到状态管理框架里,不能直接往向量后面 append。
3.2 特征跟踪与特征数据库
ov_core里的特征跟踪模块负责从图像中提取和跟踪特征点。OpenVINS 默认用的是 FAST 角点检测加 KLT 光流跟踪,这个组合在计算效率和跟踪稳定性之间取得了不错的平衡。
跟踪到的特征会存入一个叫TrackData的结构里,记录了这个特征在哪些帧被观测到、每帧的归一化坐标是多少、对应的相机 ID 是什么。这些数据在ov_msckf的更新步骤里会被用来构建观测方程。
特征数据库的管理有个关键设计:特征不是永久保留的。当一个特征不再被任何滑窗内的相机观测到时,它会被标记为“丢失”并从数据库中移除。这个清理过程在每次更新后执行,防止内存无限增长。
二次开发时如果想换特征提取器——比如换成 ORB 或者 SuperPoint——需要实现ov_core里定义的跟踪器接口。接口本身不复杂,主要是feed_new_camera和feed_new_imu两个函数。但要注意,换特征提取器后特征点的分布特性会变,MSCKF 更新时的特征筛选阈值可能需要重新调。
3.3 滑窗管理与状态边缘化
MSCKF 的滑窗管理比优化式框架简单得多。优化式框架需要做复杂的边缘化来保持信息不丢失,MSCKF 只需要在滑窗满的时候把最老的相机状态移除即可。
OpenVINS 里这个逻辑在State类的marginalize_old_clone函数里。当滑窗内的相机数量超过设定值(默认是 11 个),最老的相机状态会被移除。移除操作包括:从状态向量里删掉对应的块、从协方差矩阵里删掉对应的行列、更新所有相关的索引。
听起来简单,但协方差矩阵的删除操作是二次开发时最容易出 bug 的地方。因为协方差矩阵是稠密的,删除行列后剩下的块需要重新排列。如果索引更新不彻底,就会出现“状态向量和协方差矩阵维度不匹配”的运行时错误。
注意:修改滑窗大小或者增加新的状态量时,务必检查
marginalize_old_clone里的索引更新逻辑。我建议在改动后打印状态向量维度和协方差矩阵维度做交叉验证,这两个值必须始终一致。
4. MSCKF 更新流程的代码实现细节
4.1 预测步骤:IMU 积分与协方差传播
预测步骤在ov_msckf的Propagator类里实现。核心任务是用 IMU 测量值积分出当前时刻的状态估计,同时传播协方差矩阵。
IMU 积分用的是四阶龙格-库塔法,比欧拉积分精度高不少。积分过程中会用到陀螺仪和加速度计的测量值,减去估计的偏置后得到“纯”角速度和加速度,再积分出姿态、速度、位置的变化量。
协方差传播的公式是P = Φ P Φ^T + Q,其中Φ是状态转移矩阵,Q是过程噪声矩阵。Φ的构建涉及大量偏导数计算,代码里用Eigen的自动微分或者手动推导的雅可比矩阵来实现。
这里有个性能优化的细节:IMU 积分不是每来一个测量就做一次。OpenVINS 会把两个相机帧之间的所有 IMU 测量缓存起来,在需要的时候一次性积分。这样做减少了重复计算,也避免了频繁的协方差传播带来的数值误差累积。
二次开发时如果要改 IMU 噪声模型或者加新的 IMU 相关状态量(比如加计的比例因子),改的就是Propagator类里的predict_and_compensate函数和对应的雅可比矩阵。这个函数里的数学推导比较密集,建议对照论文里的公式逐行看。
4.2 更新步骤:特征观测的零空间投影
更新步骤是 MSCKF 的精髓所在,代码主要在UpdaterMSCKF类里。整个流程可以拆成几个阶段。
第一阶段是特征筛选。不是所有跟踪到的特征都会用于更新。代码里会检查特征被观测到的次数、观测的视差、跟踪质量等指标,只有满足条件的特征才会进入更新流程。这个筛选逻辑在UpdaterMSCKF::update函数开头。
第二阶段是构建观测方程。对于每个选中的特征,把它在所有观测帧里的归一化坐标和对应的相机状态组织成残差向量和雅可比矩阵。残差是“实际观测到的归一化坐标”减去“根据当前状态预测的归一化坐标”。
第三阶段是零空间投影。这是 MSCKF 区别于普通 EKF 的关键步骤。因为特征位置是未知的,不能直接把它加入状态向量。做法是把观测方程投影到特征雅可比矩阵的零空间上,消去特征位置的影响,得到只跟相机状态相关的压缩观测方程。
第四阶段是卡尔曼更新。用压缩后的观测方程做标准的 EKF 更新,修正状态估计和协方差矩阵。
// 零空间投影的核心逻辑(简化示意) // H_x 是残差对相机状态的雅可比 // H_f 是残差对特征位置的雅可比 // 构造 H_f 的零空间矩阵 V // 压缩后的雅可比 H = V^T * H_x // 压缩后的残差 r = V^T * residual这个流程里,零空间投影的数值稳定性很关键。如果H_f的条件数很差,投影矩阵的计算会引入较大误差。OpenVINS 用了 QR 分解来计算零空间,比直接求伪逆更稳定。
4.3 SLAM 特征的额外处理
OpenVINS 支持把一部分特征作为 SLAM 特征加入状态向量,而不是全部用 MSCKF 方式处理。这个特性在UpdaterSLAM类里实现。
SLAM 特征的更新流程和 MSCKF 特征不同:它的位置是状态向量的一部分,所以不需要零空间投影,直接构建观测方程做 EKF 更新即可。代价是状态向量维度增加,计算量上升。
代码里有个UpdaterSLAM和UpdaterMSCKF的调度逻辑,根据特征被观测的次数和视差来决定用哪种方式处理。一般来说,被长期跟踪的特征更适合作为 SLAM 特征,因为它的位置估计已经比较准了。
二次开发时如果想调整这个调度策略——比如让更多特征走 SLAM 路径以提高精度——改的是UpdaterMSCKF::update里的特征分类逻辑。但要注意,SLAM 特征太多会导致状态向量膨胀,实时性下降。我实测下来,SLAM 特征数量控制在总特征数的 10% 到 20% 之间比较合适。
5. 二次开发实操:从改一个参数到加一个模块
5.1 环境搭建与编译踩坑记录
OpenVINS 的编译依赖不算复杂,主要是 Eigen、OpenCV、Ceres(可选)。但实际编译时还是有几个坑。
第一个坑是OpenCV 版本兼容性。OpenVINS 对 OpenCV 4.x 的支持在早期版本里不完善,有些函数签名变了导致编译报错。如果你用的是 Ubuntu 20.04 自带的 OpenCV 4.2,建议先检查一下ov_core里特征跟踪相关的代码有没有用到已废弃的 API。
第二个坑是Eigen 的对齐问题。OpenVINS 里大量使用了固定大小的 Eigen 类型(比如Eigen::Vector3d),如果结构体里包含这些类型又没有做内存对齐,运行时会出现段错误。解决办法是在结构体定义里加EIGEN_MAKE_ALIGNED_OPERATOR_NEW宏。
第三个坑是ROS 版本。OpenVINS 同时支持 ROS1 和 ROS2,但两个版本的节点代码是分开的。如果你用的是 ROS2,注意ov_msckf里的 ROS 节点代码路径和 ROS1 不同,别改错了文件。
编译命令本身很简单:
mkdir -p catkin_ws/src cd catkin_ws/src git clone https://github.com/rpng/open_vins.git cd .. catkin build但如果你要改代码,建议用catkin build --cmake-args -DCMAKE_BUILD_TYPE=Debug打开调试符号,方便用 gdb 定位问题。
5.2 修改状态向量的完整流程
假设我们要加一个新的状态量——比如相机的滚动快门时间偏移。这个需求在高速运动场景下很常见。改动步骤如下。
第一步,在State类里注册新状态。找到State的构造函数,按照现有标定参数的注册方式,添加新的变量和对应的索引。注意新状态的维度要和你在状态向量里预留的空间一致。
第二步,修改协方差矩阵的初始化。新状态需要一个初始协方差值,这个值反映了你对这个量的初始不确定度。滚动快门时间偏移的初始不确定度可以设得大一些,让滤波器自己去收敛。
第三步,修改预测步骤。如果新状态在预测过程中会变化(比如时间偏移可能随温度漂移),需要在Propagator里加对应的状态转移项。如果假设它恒定不变,那预测步骤不用改,但协方差传播时要加上过程噪声。
第四步,修改更新步骤。新状态会影响观测方程,需要在UpdaterMSCKF的雅可比矩阵里加上对应的偏导数项。这一步最容易出错,因为滚动快门的影响体现在特征点的归一化坐标计算里,需要重新推导投影模型。
第五步,修改边缘化逻辑。滑窗边缘化时,新状态如果和相机状态耦合,需要正确处理。如果新状态是全局的(不随相机滑窗变化),那边缘化时不用动它。
整个流程走下来,改动涉及State、Propagator、UpdaterMSCKF三个类。建议每改完一步就编译一次,确保没有引入编译错误。全部改完后用仿真数据或者录制的数据集做回归测试,对比改动前后的轨迹误差。
5.3 替换特征提取器的接口约定
换特征提取器是另一个常见的二次开发需求。比如你想用深度学习特征点替代 FAST 角点。改动集中在ov_core模块。
ov_core里定义了一个TrackBase基类,规定了特征跟踪器的接口。你需要继承这个基类,实现feed_new_camera和feed_new_imu两个纯虚函数。feed_new_camera接收一帧图像,输出跟踪到的特征列表;feed_new_imu接收 IMU 测量,用于时间对齐。
实现新跟踪器时要注意几点。第一,特征点的 ID 管理要连续,不能重复。MSCKF 更新时依赖特征 ID 来关联不同帧的观测。第二,特征的归一化坐标要基于相机模型计算,不能直接输出像素坐标。第三,跟踪失败的特征要及时标记,不能让它们进入更新流程。
替换完成后,在ov_msckf的配置文件里把跟踪器类型改成你新实现的类名。OpenVINS 用工厂模式管理跟踪器,只要类名注册对了就能自动加载。
实操心得:换特征提取器后,MSCKF 更新时的特征筛选阈值需要重新调。FAST 角点的分布比较均匀,深度学习特征点可能集中在纹理丰富区域。如果还用原来的视差阈值,可能导致更新时可用特征太少。我一般会把最小视差阈值降低 20% 左右,同时把最大特征数提高一些。
6. 常见问题排查与避坑指南
6.1 编译与运行时错误速查
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 编译报错 “invalid use of incomplete type” | 头文件包含顺序问题 | 检查是否在类定义前包含了前置声明 |
| 运行时段错误,堆栈指向 Eigen 操作 | 内存对齐问题 | 在结构体里加 EIGEN_MAKE_ALIGNED_OPERATOR_NEW |
| 状态向量和协方差维度不匹配 | 索引更新不彻底 | 检查 marginalize_old_clone 里的索引逻辑 |
| 轨迹漂移严重 | 标定参数不准 | 检查相机-IMU 外参和时间偏移的初始值 |
| 更新步骤数值不稳定 | 零空间投影条件数差 | 检查特征观测的视差是否足够 |
6.2 调试技巧与日志分析
OpenVINS 的日志输出比较丰富,但默认级别下很多调试信息不打印。建议在开发阶段把日志级别调到 DEBUG,这样能看到每次更新用了多少特征、残差多大、卡尔曼增益的数值范围等信息。
如果发现轨迹异常,第一步是看特征跟踪的质量。可以在ov_core里加一段代码把跟踪到的特征画出来存成图片,直观地看特征点是否跟丢了或者聚集在一处。第二步是看 IMU 数据是否正常,有没有明显的偏置或者噪声突变。第三步才是看滤波更新步骤,检查残差是否在合理范围内。
有个很实用的技巧:用ov_eval的工具做单步调试。ov_eval里有个plot_trajectory脚本,可以把估计轨迹和真值轨迹画在一起。如果你有真值数据(比如仿真环境或者动捕系统),这个对比能快速定位问题是出在预测还是更新。
6.3 性能优化的几个入手点
OpenVINS 在嵌入式平台上的实时性通常没问题,但如果你的平台算力有限,或者想跑更高的图像分辨率,还是需要做一些优化。
第一个入手点是特征数量。默认配置下每帧最多跟踪 200 个特征,这个数量对大多数场景足够了。如果算力紧张,可以降到 100 甚至 50。特征少了精度会降,但实时性会明显提升。
第二个入手点是滑窗大小。默认滑窗里有 11 个相机状态,减小到 5 到 7 个能显著降低协方差矩阵的维度,从而减少更新步骤的计算量。代价是对快速运动的适应性变差。
第三个入手点是IMU 积分频率。如果 IMU 频率很高(比如 400Hz),可以每两个测量做一次积分,减少协方差传播的次数。这个改动在Propagator里,把积分步长调大即可。
第四个入手点是编译优化。Release 模式下打开-O3和-march=native,能让 Eigen 的矩阵运算快不少。如果平台支持 SIMD 指令集,Eigen 会自动向量化。
7. 框架扩展的几种可能方向
7.1 多相机与多 IMU 支持
OpenVINS 本身支持多相机,代码里相机状态是按相机 ID 组织的。但多 IMU 的支持在稳定版本里还不完善。如果你要做多 IMU 融合,需要在State里为每个 IMU 注册独立的状态块,在Propagator里分别做积分和协方差传播,最后在更新步骤里融合多个 IMU 的预测结果。
这个改动量不小,因为多 IMU 之间的时间同步和外参标定都需要处理。建议先从一个 IMU 加一个辅助 IMU 的场景做起,验证融合效果后再扩展。
7.2 与深度学习模块的集成
把深度学习特征点或者深度位姿估计集成到 OpenVINS 里,是这两年的一个热门方向。集成方式有两种:一种是用深度学习特征点替换 FAST 角点,另一种是用深度网络输出一个相对位姿作为额外的观测约束。
第一种方式改动在ov_core,前面已经讲过。第二种方式需要在ov_msckf的更新步骤里加一个新的观测模型,把网络输出的位姿作为观测值。这个观测的雅可比矩阵需要根据网络输出的不确定性来加权,不能简单地当成精确观测。
7.3 面向特定平台的裁剪
如果你要把 OpenVINS 部署到算力受限的嵌入式平台,可能需要对框架做裁剪。比如去掉ov_eval模块、去掉 SLAM 特征支持、简化相机模型(只保留针孔模型)、降低状态向量维度。
裁剪的原则是保留核心滤波逻辑,去掉辅助功能。ov_core里的特征跟踪和ov_msckf里的预测更新是必须保留的,其他都可以根据需求取舍。裁剪后要做充分的回归测试,确保精度下降在可接受范围内。
我个人在实际操作中的体会是,OpenVINS 的代码框架设计得比较克制,没有过度抽象,也没有为了通用性牺牲可读性。二次开发时最大的障碍不是代码本身复杂,而是缺乏一份“从架构到细节”的导览。希望这篇内容能帮你少走一些弯路。如果你在改代码时遇到奇怪的问题,先检查状态向量和协方差矩阵的维度是否一致,这个检查能解决我遇到过的大部分 bug。