☰
VINS全解析:视觉惯性里程计核心原理与代码实现
2026/10/6 3:45:59 网站建设 项目流程

聊到视觉惯性里程计(VIO),VINS是绕不开的名字。作为一套单目/双目视觉与IMU紧耦合的开源系统,VINS-Mono和后续的VINS-Fusion几乎是我见过代码风格最规范、工程落地价值最高的SLAM项目之一。很多人第一次拿到这套代码,看起来很吃力,不知道从哪里读起,也不知道bag文件怎么跑,更不知道怎么改。这篇文章我就从技术路线到代码实现,把VINS拆开揉碎过一遍,尽量说人话,把流程里每个环节为什么这么做、代码在哪里、关键函数长什么样,一次讲清楚。适合正要上手VINS或者想从代码角度彻底搞懂VIO流程的同学。

1. VINS项目背景:它解决什么问题,为什么值得深入学习

1.1 为什么同时需要相机和IMU

VINS解决的核心问题是:在没有GPS、没有外部定位设施的环境里,让设备靠自己的“眼睛”(相机)和“前庭系统”(IMU)推测自己在空间中的位置和姿态。单目相机本身存在一个致命缺陷——它只能看出方向,看不出距离,拍回来的图像在尺度上天生是模糊的。一个物体拍出来大小减半,既可能是它真的离你远了,也可能是它实际就小了一半,单目视觉无法区分。

IMU则完全是另一个极端。它以200Hz左右的高频率测量角速度和加速度,能提供快速的运动变化信息,但数据里有bias(零偏)和噪声,而且这些bias还会随时间和温度漂移,单独积分出来的轨迹几秒钟就会飞出去。更关键的是,IMU可以感知重力方向,重力向量一旦对齐,系统就能在整个运行中拿到“绝对水平”的参考,这是纯视觉系统给不了的。

把两者放在同一个优化框架里“紧耦合”,就是VINS的核心思路。视觉给IMU提供缓慢但可靠的位置参考,用来压制bias发散;IMU给视觉提供尺度、重力方向和运动先验,用来弥补单目的尺度缺失和快速运动下的图像模糊。两者互为锚点,这比简单的松耦合拼接可靠得多。

1.2 VINS-Mono与VINS-Fusion:两个版本该怎么选

港科大团队最早开源的是VINS-Mono,只支持单目相机加IMU。它对应的是无人机、手持设备这类最常见的单目配置。后来团队放出了VINS-Fusion,实际上是Mono的超集,支持四种模式:单目+IMU、双目、双目+IMU、GNSS融合。代码结构上增加了global_fusion节点,用来把GPS/RTK信息与VIO里程计做位姿级融合。

选型建议很直接。如果你刚入门,只是想理解完整的VIO流程,VINS-Mono代码更精简,主线更清晰,适合逐行读。如果你有双目相机,或者最终产品需要接GPS做室外大场景建图,就直接上VINS-Fusion。两者的核心估计器代码高度相似,学会一个,另一个基本能无缝切换。我自己做无人机室内定位时用的是Mono,后来做车载测试换到了Fusion的双目+GPS模式,核心优化部分的代码几乎没换过。

2. 整体技术路线拆解:四大核心模块的功能分工

2.1 四个模块和代码位置

VINS整个系统在代码上分成了几个独立的功能节点,最先做的一步就是把这些节点和它们对应的源码文件摸清楚,否则看代码时会一直在不同包之间跳来跳去。

功能模块节点名核心代码文件职责
视觉前端feature_trackerfeature_tracker_node.cpp, feature_tracker.cpp提取Harris角点、KLT光流跟踪、剔除异常点、对外发布特征点
状态估计器vins_estimatorestimator_node.cpp, estimator.cpp, initial_alignment.cpp初始化、滑动窗口后端优化、边缘化、输出VIO里程计
回环检测pose_graphpose_graph_node.cpp, pose_graph.cpp, keyframe.cpp维护关键帧数据库、DBoW2词袋检测回环、四自由度位姿图优化
全局融合global_fusionglobal_fusion_node.cpp接收VIO里程计与GPS,做融合输出全局位姿

这四个模块各自跑在独立的ROS节点里,彼此通过topic通信,而不是函数直接调用。这样做的好处是解耦彻底,任何一个模块崩了,其余节点还能继续跑,调试时可以单独重启,不用整个系统推倒重来。

2.2 数据流与状态向量设计

整个系统的数据流很清晰。相机图像进feature_tracker,2D特征点以sensor_msgs/PointCloud的格式发出去;IMU原生数据以sensor_msgs/Imu格式发出。这两个topic在vins_estimator节点里按时间戳对齐、触发计算。估计结果以odometry topic输出,同时发送给pose_graph节点做回环与全局优化,最后在rviz里可视化出轨迹和地图。

值得展开说说状态向量的设计。VINS维护的优化变量包括滑动窗口内所有帧的位姿、速度、IMU的bias(陀螺仪bias和加速度计bias各三个自由度)、重力向量,以及所有被追踪到的3D特征点的逆深度。这里用“逆深度”(1/depth)而不是直接用xyz坐标表示特征点,很多人第一次看会不适应。它的好处是把特征点的深度值约束在正数区间附近,优化时数值稳定性更好,而且逆深度对远点估计天然不敏感——一个百米外的点和一千米外的点,逆深度几乎没差别,优化就不会因为它们消耗过多计算量。

2.3 为什么说这套架构“紧耦合”

“紧耦合”这个词在VINS代码里体现在一个关键细节:视觉重投影误差和IMU预积分误差被同时放进同一个Ceres问题里求解,同一个目标函数里两类残差泾渭分明又互相牵制。对比很多早期SLAM系统,视觉跑一个估计算法、惯性单独跑一个滤波,最后再融合结果,VINS每一次优化迭代都会同时调整视觉和惯性相关的状态量。

举个简单例子:某帧图像上,一个特征点预测到下一帧的位置偏了三个像素,vision残差希望把相机位姿往左调;同时IMU预积分说这两帧之间角速度积分应该在某个范围,imu残差希望位姿别动太大。两个残差在同一个非线性优化问题里反复迭代、互相折中,最终得到对两个传感器误差都相对合理的估计。这种“互相监督”的机制,正是紧耦合的精髓。

3. 前端与初始化:代码层面如何“从零醒来”

3.1 feature_tracker:光流跟踪与特征管理的代码细节

视觉前端代码放在feature_tracker包里,入口是feature_tracker_node.cpp里的回调函数img_callback。每次图像进来,先调readImage函数做完整的特征处理。这里有个容易被忽略的细节:整张图的处理会先缩放到一个输入分辨率,一般配置文件里的IMAGE_COL和IMAGE_ROW约定了处理尺寸,很多初学者以为这两个参数是相机原图分辨率,其实是前端处理分辨率,改错了会直接影响内参使用逻辑。

readImage里有三步核心操作。第一步用goodFeaturesToTrack提取角点,这就是在图像高梯度区域找Harris响应值足够大的点。第二步通过calcOpticalFlowPyrLK做金字塔LK光流追踪,把上一帧的特征点跟踪到当前帧。第三步是质量控制,光流返回的status数组会标记哪些点跟踪失败,VINS还会计算基础矩阵来剔除外点,确保进入估计器的特征点是干净的。

特征点数量管理也是前端的关键环节。VINS会对所有特征点做“均匀化”,也就是setMask函数里实现的:把图像划分成网格,每个网格里只保留响应最强的特征点,同时把已经跟踪了很久的长时特征点标记出来,优先保留它们参与后续优化。这样能保证特征点在图像上分布均匀,避免所有点都堆在纹理丰富的角落,导致其他区域没有观测约束。

3.2 联合初始化:为什么“动起来”才能初始化

VINS会在系统启动后的一段时间里专心做初始化,初始化的成败直接决定后面整条轨迹能不能收敛。很多人跑官方bag没问题,自己拿摄像头一试就初始化失败,多半是对这部分机制理解不够。

初始化代码的核心在initial_alignment.cpp文件里,整套流程可以拆成四步。第一步是纯视觉SFM,在滑动窗口里用对极几何和三角化,恢复出十几帧图像的位姿和特征点3D位置。注意,这一步的结果没有真实尺度,只有“形状”是对的。第二步估计陀螺仪bias,通过对比视觉位姿推算出的帧间旋转和IMU预积分给出的旋转,算出bias的一个初始值。第三步构建一个线性最小二乘问题,求解每帧速度、重力向量和尺度因子。第四步对重力向量做精细化,把重力的模值约束到已知的9.8m/s²上,再迭代一轮。

为什么初始化对运动方式这么敏感?关键在于单目SFM恢复的位姿和IMU预积分之间的尺度对齐,需要系统在多个方向上有充分的加速度激励。如果设备平移很少,纯旋转,视觉对平移的估计本身就不准,尺度就无法被“激活”;如果一直匀速直线运动,IMU预积分的加速度几乎被重力主导,无法分离出尺度信息。实际操作中,启动VINS后先缓缓平移,再做一些加速和变速运动,最后转几个圈,让所有方向都被激励到,是提高初始化成功率最有效的手段。

4. 后端优化与边缘化:精度提升的核心引擎

4.1 滑动窗口与非线性最小二乘

后端部分集中在estimator.cpp,主流程由processImage函数驱动。VINS并没有把所有的历史帧都拿来做全局优化,而是维护一个大小固定的“滑动窗口”,默认窗口大小是10帧。每进来一帧新图像,就决定哪一帧被踢出窗口、哪一帧被保留,整个过程由optimization函数用Ceres求解。

窗口内的优化变量包括窗口内所有帧的姿态、速度、IMU bias,以及特征点的逆深度。目标函数由三部分组成。第一部分是视觉重投影误差,它把第i帧观察到的特征点投影到第j帧的相机坐标系下,和实际观测到的像素坐标求差。第二部分是IMU预积分残差,比较IMU预积分预测的相对运动与优化变量表达的运动是否一致。第三部分是边缘化产生的先验残差,它把被剔除掉的旧帧的信息以先验因子的形式保留下来。

这三个残差在Ceres里通过AddResidualBlock添加到Problem里,每一轮迭代都会同时调整整个窗口中所有的位姿和特征点。理解这一点很关键——很多刚入门的人以为后端优化只是“把轨迹调平滑”,其实它是在所有相互矛盾的观测里找最小二乘解,让整体误差尽可能均衡。

4.2 边缘化的原理与代码实现

滑动窗口想保持窗口内帧数固定,就必须不断剔除旧帧。一个最粗暴的办法是直接丢弃旧帧及其观测,但这样会丢掉旧帧携带了大量历史信息,新估计值会因为缺少先验而产生明显漂移。VINS的做法是“边缘化”,对应代码里的MargOldFrame和MargNewFrame两个函数,底层由MarginalizationInfo类实现。

边缘化的数学本质是Schur Complement。假设窗口内所有待优化变量包括要被移除的旧状态和保留的新状态,目标函数可以看作一个高斯牛顿问题,A矩阵被划分成四块。通过Schur Complement消去被移除变量,会得到一个关于保留变量的新先验项,这个先验项带着被移除变量留下的全部约束信息,在下一次优化中作为残差加入。

代码实现上有一个很容易出错的细节:边缘化完成后,新问题里的优化变量顺序和之前并不完全一致,需要维护一个parameter_block_idx的映射关系,把旧的参数块索引映射到新的优化问题中。VINS源码里用了一堆局部变量和vector来管理这些索引,读的时候很容易绕晕。我的建议是不要死磕每一行索引变换,先理解“这条边被边缘化之后,信息被压缩成一个先验因子”这个核心逻辑,再看代码就会顺畅很多。

5. 回环检测与位姿图优化:消除累计漂移的兜底方案

5.1 DBoW2词袋模型与关键帧管理

VIO的优化是窗口内的局部优化,时间一长必然累积漂移,这是所有增量式里程计都绕不开的问题。VINS用回环检测来消除这个累积误差,相关代码在pose_graph包里,节点输入是vins_estimator发布的关键帧位姿与特征信息,输出是优化后的全局轨迹。

回环检测的实现基于DBoW2词袋模型。每一帧关键帧到来时,VINS用BRIEF描述子描述特征点,通过词表量化成视觉词向量,用这个向量去词袋数据库里检索相似的历史关键帧。如果找到的候选帧相似度超过一定阈值,并且几何校验通过,就认为检测到了回环。

关键帧的选取策略也值得注意。VINS并非每帧都保存成关键帧,而是依据视差判断:如果当前帧与上一个关键帧之间的平均视差足够大,或者跟踪的特征点数量降到一定阈值以下,才判定为新关键帧。这种策略保证回环数据库里的节点之间有足够的视差区分度,又不会让关键帧过于密集导致匹配太慢。

5.2 为什么只优化四个自由度

VINS在做回环全局优化时,姿态图优化的自由度只有四个:三维平移和绕重力方向的偏航角,roll和pitch是不参与优化的。很多人看代码时觉得奇怪:明明位姿有六个自由度,为什么只动四个?

关键在于VIO系统的可观测性。因为有重力向量作为绝对参考,roll和pitch在VIO估计中是可观测的、难以漂移的,即使发生回环校正,这两个值也不需要被大范围调整。真正容易漂移的是x、y、z平移和偏航角——平移漂移来自累计误差,偏航角漂移来自纯旋转运动的不可观性。所以VINS用四自由度位姿图优化,一方面减少了优化变量,另一方面避免了在本来估计准确的roll和pitch方向上引入错误的调整。

后端的回环优化结果会被发回给vins_estimator,但VINS并不会直接把全局位姿覆盖到滑动窗口里,而是把窗口内所有关键帧的位姿做一个差量修正,让当前窗口里的相对位姿保持不变、绝对位置对齐到全局轨迹。这种“主动对齐”的思想非常实用,实际跑下来轨迹修正平滑,不会出现跳变。

6. 实操指南:从零跑通VINS并学会改代码

6.1 环境搭建与编译过程

实操部分直接说操作流程。VINS官方支持Ubuntu 16.04/18.04 + ROS Kinetic/Melodic,我用得比较多的是Ubuntu 18.04 + Melodic,跑起来最省事。核心依赖有Ceres Solver、OpenCV、Eigen,其中Ceres Solver建议直接按照官方教程从源码编译安装,Ubuntu自带的版本往往版本较低,编译VINS时会报一些API兼容性问题。

代码拉下来之后,先创建一个catkin工作空间,把VINS源码放到src目录下。如果只跑VINS-Mono,直接catkin_make编译整个工作空间即可。VINS-Fusion也一样。通常第一次编译会卡在找不到某些依赖库,最常见的两个坑是Ceres版本太旧、OpenCV的find_package路径不对。建议把opencv版本显式设置在vins_estimator的CMakeLists.txt里,比如Ubuntu 18.04默认OpenCV 3.2,如果想用OpenCV 4,需要手动指定OpenCV_DIR。

编译通过后,检查环境变量source。我习惯把source命令写进~/.bashrc,避免每次开新终端都手动source一遍。

6.2 运行官方bag文件:从下载到可视化

VINS官方推荐的测试数据是EuRoC数据集,跑室内MH_01效果最好,初始化快、轨迹清晰、回环也能正常触发。需要说明的是,很多人提到的“vins fusion的bag文件”其实指的是EuRoC数据集,或者VINS仓库里示例配置对应的rosbag,两者都可以直接用来测试。

具体步骤很简单。先启动roscore,然后roslaunch vins_estimator euroc.launch,这条命令会同时拉起vins_estimator节点和rviz可视化界面。接着播放bag文件,执行rosbag play MH_01.bag,如果时序不太对可以加--clock参数同步时间戳。正常情况下rviz会逐渐画出运动轨迹和稀疏特征点地图。

跑通之后可以做几个基础验证。第一,看初始化日志,终端会输出“Init OK”或者类似提示,说明视觉与IMU对齐成功。第二,看轨迹形态,EuRoC MH_01场景是一个室内房间,轨迹应该是一个明显的闭合回路,回到起点后与起点的轨迹偏差应该非常小。第三,在rviz里打开pose_graph节点发布的path,确认回环闭合后全局轨迹是否有明显校正。

6.3 如何修改参数与代码:以改特征点数量为例

跑通之后,基本需求就变成了“怎么改参数”“怎么改代码”。VINS的配置文件和代码是分离的,所有可调参数都在config目录下的yaml文件里,改动不需要重新编译,改完重启节点就生效。

以调节特征点数量为例。打开euroc_config.yaml,会看到max_cnt字段,默认是150,表示每一帧最多提取多少特征点。想提高精度可以把这个值调大,比如200到300,特征点多了约束更多、优化结果通常更稳,但计算量也会明显上升,CPU占用率可能翻倍。想提高实时性就调小到100以下。另一个值得动的参数是KEYFRAME_MIN_DISTANCE之类的阈值,它控制关键帧的选取密度,直接影响回环数据库大小和优化频率。

如果改了代码,比如重写了特征提取算法或后端残差定义,就需要重新编译,一般是catkin_make之后重新source。我在实际改代码时习惯先用测试bag把整个pipeline跑通,确认输出与改动前一致,再替换自己要改的算法。这样能避免在调试阶段把“算法本身的bug”和“集成代码的bug”混在一起。

7. 常见问题与代码调试心得

7.1 常见问题速查表:从初始化失败到轨迹发散

实操中一定会遇到各种问题。下面这张表是我自己踩过的坑和对应的排查思路,列出来可以直接对照。

现象可能原因排查与解决建议
初始化反复失败,终端一直提示等待初始化运动激励不足,或者IMU数据时间戳异常试试大幅度平移+旋转,确认IMU话题和图像话题时间戳对齐
轨迹在几步之内发散甚至冲飞IMU内参或外参配置与数据不符用kalibr重新标定,检查yaml里的加速度计噪声密度和陀螺仪噪声密度数量级
初始化成功了,但尺度明显不对单目初始化时尺度估计退化检查初始化阶段运动轨迹是否单一方向,重新启动并做更丰富的运动
回环无法触发词袋数据库规模不够,或场景重复度低先跑EuRoC MH_01验证回环,确认场景本身纹理重复度足够
CPU占用过高,实时性差特征点数量太大或图像分辨率过高降低max_cnt、缩小平移处理分辨率,观察效果变化
代码编译报Ceres相关错误Ceres版本过旧从源码编译安装Ceres,并确认CMake能找到新版本

还有一个隐蔽问题容易被忽视:IMU和相机的时钟同步。如果bag里的IMU数据时间戳是bag播放时刻而非传感器采集时刻,VINS在时间对齐时会把IMU数据配错,现象是初始化能过、轨迹也能跑,但精度比预期差很多。排查方法是检查IMU话题的频率是否稳定,以及发布时是否带准确的header时间戳。

7.2 代码阅读路线建议:怎么高效读懂这套工程

最后说一说读码顺序。直接一头扎进vins_estimator的main函数往往看不懂,因为VINS的代码是事件驱动、回调嵌套回调。

我的建议是先读配置文件和launch文件,理解节点结构和话题关系:vins_estimator订阅什么话题、pose_graph又订阅什么话题,整个消息流理清了,再顺着消息流读代码。第一步读feature_tracker里的readImage,只看它输出什么;第二步读estimator的imu_callback,理解IMU预积分缓存的目的是给后续优化提供运动增量;第三步读processImage,这才是整套估计器的入口逻辑;第四步读optimization,配合ceres的AddResidualBlock看三类残差的定义;第五步再回头补初始化细节。

如果时间有限,就优先读estimator_node.cpp和estimator.cpp里processImage、optimization、MargOldFrame这三个函数。把这三个函数串起来,就已经覆盖了整个VIO核心流程的八成内容。

我自己在实际项目里有一个习惯:每读懂一个模块,就在代码里注释一句话总结那个函数在干什么。这不仅帮我回顾,也方便团队其他人接手。SLAM这种大型工程,读懂代码靠的不是过目不忘,而是有节奏地反复过关键路径。VINS值得这么啃一遍,把这个工程吃透之后,再去看ORB-SLAM、LIO-SAM这类系统,你会发现很多思路都是相通的。

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

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

立即咨询