ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

视觉惯性导航的经典实现:VINS-Mono源码解析与工程实践

视觉惯性导航的经典实现:VINS-Mono源码解析与工程实践 我第一次把VINS-Mono跑起来是几年前在室内无人机平台上做视觉定位的时候。当时最强烈的感受是这个系统里每个模块单拆出来都能写一本书但它偏偏用工整的代码把整套流程串成了一个完整方案。VINS全称Visual-Inertial Navigation System到今天仍是视觉惯性导航系统里绕不开的一个开源实现后面被反复提及的VINS-Mono和VINS-Fusion几乎成了这个方向的教科书级代码库。这篇文章不想只教你怎么跑一个demo而是想把VINS的技术路线和代码实现揉在一起讲清楚IMU和相机数据进来之后经过前端跟踪、预积分、初始化、滑窗优化、回环检测最终怎么变成一条稳定可用的轨迹。适合刚接触视觉SLAM的学生、做机器人导航的工程师以及想把VINS源码真正读透的开发者。如果你手里有EuRoC系列的bag文件建议一边跑一边对着本文看效果会好很多。1. 技术路线全景VINS到底在解决什么问题1.1 为什么视觉要配IMU纯视觉的失效场景纯视觉SLAM靠图像特征估计位姿它的问题很实在相机快速运动时图像会运动模糊纹理稀少的墙面走廊会导致特征匹配大面积丢失光照突变时灰度不变假设直接失效。说白了纯视觉方案在一个稳定、纹理丰富、运动缓慢的环境里表现不错但放到无人机、机器人、AR设备这类真实场景里常常熬不过前几秒。IMU刚好是另一个极端。它以几百赫兹的频率输出加速度和角速度在短时间内积分得到的相对运动非常准但加速度计和陀螺仪都有零偏长时间积分之后位置误差会膨胀得很快。一个直观的说法是IMU短期局部准视觉长期全局准。把两者放在一起视觉能修正IMU的漂移IMU能支撑视觉在快速运动或短暂遮挡时维持状态估计这就是视觉惯性导航系统的基本出发点。整套系统里VINS采用的优化框架不是把视觉和IMU各自跑完再做加权融合而是让两类观测进入同一个代价函数这就是所谓的紧耦合。后面所有代码模块本质上都在为这个“同一个优化问题”服务。1.2 紧耦合为什么成为主流方案和紧耦合对应的叫松耦合。松耦合的做法通常是视觉模块自己估计位姿IMU模块也估计位姿然后把这两个结果用卡尔曼滤波或者简单的加权平均融合起来。好处是模块解耦、开发简单坏处是两个模块各自的误差会累积并且在优化时无法利用两者之间更底层的相关性。紧耦合则把相机的重投影误差和IMU的预积分误差放到同一个Ceres问题里和位姿、速度、零偏、甚至特征点逆深度一起优化。这样做计算量更大但精度和鲁棒性显著提升。VINS选择的正是这条路线具体到代码层面就是vins_estimator里那个大的optimization函数以及围绕它构建的一大堆Factor类。一句话总结紧耦合之所以成为主流是因为它尊重了“视觉观测和IMU观测本质上来自同一段真实运动”这一事实而不是把它们割裂开来分别处理。1.3 系统模块与代码目录的对应关系VINS-Mono的代码组织方式非常清晰整个工程被拆成几个ROS功能包这种模块化设计本身就是一道不错的学习素材。简单对照一下feature_tracker视觉前端负责LK光流跟踪特征点并对外发布特征点消息。vins_estimator核心估计器负责IMU预积分、初始化、滑动窗口优化、边缘化、重定位。pose_graph负责回环检测、全局位姿图优化输出最终轨迹。camera_model相机模型库支持针孔和鱼眼相机。很多人读代码时容易一头扎进优化函数里出不来我的建议是先把这个目录对应关系记在心里然后在阅读过程中不断把具体函数归类到对应的模块里。这样哪怕公式再复杂脑子里也始终有一条清晰的数据流主线。2. 工程搭建与数据准备先把代码跑起来再谈深入2.1 依赖安装与编译UbuntuROS下的最快路径VINS-Mono是在ROS框架下开发的所以你必须先有一个能用的ROS环境。我用的是Ubuntu 18.04配ROS Melodic后来也在Ubuntu 20.04配ROS Noetic上编译过基本没有大的障碍。除了ROS还有三个核心依赖OpenCV、Eigen、Ceres。Eigen和Ceres是优化部分的地基OpenCV则负责前端图像处理。编译的过程我直接给出一套能用的命令mkdir -p ~/vins_ws/src cd ~/vins_ws/src git clone https://github.com/HKUST-Aerial-Robotics/VINS-Mono.git cd ~/vins_ws rosdep install --from-paths src --ignore-src -r -y catkin_make source devel/setup.bash如果你用的发行版自带OpenCV 4要留意Ceres的版本是否兼容。这里最常见的坑是Ceres找不到LAPACK或者SUITESPARSE导致编译到最后才报一个莫名其妙的链接错误。稳妥的做法是先确认这三个依赖都已正确安装再开始编译VINS而不是边编译边看报错边装库。编译过程中如果报找不到某个包优先检查环境变量比如source /opt/ros/melodic/setup.bash这类步骤是否漏了。2.2 bag文件与数据集选择用对数据才能跑出效果很多人在这一步直接卡住代码编译过了但rviz里什么都没有。绝大多数情况是bag文件跟配置里的话题名对不上。VINS-Mono官方测试用的是EuRoC MAV数据集里面MH_01到MH_05是室内飞行V1_01到V2_03是室内复杂场景下载下来直接跑就行。跑起来其实就两条命令roslaunch vins_estimator euroc.launch rosbag play ~/dataset/MH_01_easy.bag在你自己的终端里可以先用rosbag info查看bag包含的话题再用rostopic list确认当前系统收到的话题。EUROC标准话题是/cam0/image_raw和/imu0对应的配置文件是config/euroc/euroc_config.yaml。如果你用的是自己录制的数据比如用RealSense录的必须把yaml里的imu_topic和image_topic改成实际话题名否则系统根本接不到数据表现就是rviz黑屏、日志里没有任何状态输出。另外要注意EuRoC的数据时间戳是已经同步好的但自录数据经常出现相机和IMU时间基准不一致的情况。轻则初始化失败重则优化发散。录制bag时尽量让所有传感器使用同一个ros::Time时钟这是最省心的办法。2.3 代码阅读环境WSL、VSCode与字体细节如果你打算认真读代码而不是只跑包阅读环境很重要。我自己习惯用WSL2做日常代码阅读把Ubuntu环境装在Windows下再用VSCode的Remote-WSL连进去跳转、搜索、补全都比直接在虚拟机里流畅不少。唯一要留意的是WSL2在USB设备直通上有一些限制如果你需要接相机实时测试还是建议用原生Linux系统如果只是读代码、看论文、对着bag分析WSL完全够用。在WSL里用VSCode读C工程C/C插件必须配上正确的include路径否则IntelliSense会疯狂报红线。我一般会加一个.vscode/c_cpp_properties.json把Eigen、Ceres、OpenCV、ROS的include目录显式写进去这样代码跳转和代码补全就正常多了。字体方面我目前用的是Sarasa Term SC在中英文混排的场景下可读性很好整体观感接近macOS终端的清爽体验。代码规范上VINS本身遵循Google风格缩进、命名都比较统一读起来不会太累。3. 核心代码拆解从传感器数据到位姿的完整链路3.1 入口与线程模型estimator_node.cpp在做什么VINS-Mono的核心入口在vins_estimator/src/estimator_node.cpp。你打开main函数会发现它做的事本质上就是订阅ROS话题ros::Subscriber sub_imu n.subscribe(IMU_TOPIC, 2000, imu_callback); ros::Subscriber sub_image n.subscribe(IMAGE_TOPIC, 100, img_callback);IMU话题的队列长度给到了2000因为IMU频率高一次图像帧间隔内往往能收到几十条IMU消息。图像话题队列给100。两个回调各自把数据存入imu_buf和feature_buf这类缓冲结构然后在img_callback里进行时间配准。这里的线程模型值得多说一句。IMU回调和图像回调分属不同触发源如果不加缓冲系统很容易被高频IMU数据打断图像处理流程。VINS的做法是让图像回调先把当前时刻之前的IMU消息都取出来组合成一个measurements容器再统一交给estimator.processImage处理。这样既保证了时间对齐又避免了频繁的锁竞争。很多人读代码时直接跳进processImage却忽略了入口处的缓冲设计。我建议你先把这个过程理清楚因为它决定了后续所有模块拿到的数据是不是同一时间基准。时间不对齐后面一切优化都是空谈。3.2 视觉前端LK光流与特征管理视觉前端在feature_tracker包中核心类是FeatureTracker。每一帧图像进来后它先用cv::calcOpticalFlowPyrLK做金字塔LK光流跟踪把上一帧的特征点匹配到当前帧。光流跟踪有一个常见问题特征点会跟着跟踪误差越跑越偏。VINS的处理措施是加前后向误差检测即从当前帧再反向跟踪回上一帧如果往返点离得太远就认为跟踪失败直接丢弃。光流跟踪可以追踪已有特征但新场景不断出现必须有新特征补充进来。这里用了一个很巧妙的均匀化策略先把已跟踪的特征点画到一个mask图上在每个特征点周围设置一个最小距离禁区然后再用cv::goodFeaturesToTrack找新的角点。这样提取出来的特征点在图像上分布均匀不会全部堆在某个纹理丰富的角落。看config参数的时候几个数字很关键max_cnt表示最大特征数VINS默认是150min_dist表示特征之间的最小像素距离默认30。如果你跑的是低纹理环境建议调低min_dist并结合直方图均衡化预处理否则很容易因为特征太少导致跟踪不稳定。前端最后输出的是归一化平面坐标加上特征点ID、跟踪次数这些数据会直接喂给后端优化。3.3 IMU预积分把高频测量压缩成帧间约束IMU数据进入estimator.processIMU之后最核心的数据结构是IntegrationBase预积分器。你可能会问为什么IMU积分前面要加一个“预”字假设两帧图像之间有50条IMU测量。如果每次都从零开始积分这50条数据当两帧位姿在优化中被更新一次所有积分就得重算一次计算量完全不可接受。预积分的思路是把这段IMU测量相对的旋转增量、速度增量、位移增量提前算好这样优化时只需要拿这个预积分量和关键帧位姿做差形成残差。关键帧位姿发生变化时预积分量本身不用重算只需要针对bias变化做一阶修正。代码里每次来一条IMU消息就执行一次pre_integration-push_back(dt, acc, gyr)同时更新协方差和雅可比矩阵。这部分在vins_estimator/src/factor/integration_base.h里看着公式很多但核心思想没有变把高频IMU压缩成帧间相对运动约束避免优化时的重复积分。理解了这个动机再回头看那些矩阵更新公式心里会踏实很多。3.4 初始化为什么需要晃几下才能起飞VINS-Mono是单目方案单目视觉本身没有尺度信息。初始化阶段的任务就是利用视觉SFM和IMU预积分之间的约束关系把尺度、重力向量、速度、陀螺仪零偏这些不可直接观测的量估计出来。这一步在estimator.cpp的processImage里触发。系统会先对滑窗内的帧做纯视觉SFM得到若干帧的位姿和地图点然后用这些视觉位姿和IMU预积分之间的对齐求解重力方向、尺度因子和每帧速度。初始化完成的标志是solver_flag从INITIAL变成NON_LINEAR。我看到不少初学者问为什么VINS启动后必须把设备晃几下才能跑起来这不是玄学。初始化需要充分激励也就是说IMU的加速度必须在一个可观测的范围内变化。如果设备静止或者匀速直线运动重力和加速度之间的耦合关系没法解开初始化算法无法收敛。你拿着设备做几次大幅度的旋转和加速平移就是给初始化提供足够的约束信息。初始化失败通常有三类表现系统长时间停留在INITIAL状态、优化后轨迹直接飘走、日志里反复出现“vision not stable”。优先检查数据时间戳是否同步、外参是否合理、运动激励是否足够这三个因素占据了绝大多数初始化失败场景。3.5 滑动窗口优化与边缘化计算量和信息量的取舍进入NON_LINEAR之后后端优化会在一个固定大小的滑动窗口内进行窗口默认是10帧。核心函数是estimator.cpp里的optimization()。为什么不能像全局BA一样把所有历史帧都放进优化因为计算量会随时间无限增长实时系统扛不住。滑窗的思路是始终只优化最近N帧旧帧的信息不能简单丢弃否则约束会断裂。VINS通过边缘化marginalization来解决这个问题——把被移除出窗口的老帧观测转化成对剩余变量的先验约束用Schur补的方式保留信息。代码里marginalization_factor.h和MarginalizationInfo类就是干这件事的。读这里的时候你不需要一次性把Schur补的公式背下来但要理解它的工程意义滑动窗口是计算量约束边缘化是信息量补偿两者配合才能保证系统既能实时运行又不因为丢了历史信息而精度崩坏。优化过程中Ceres会同时添加视觉重投影残差、IMU预积分残差、边缘化先验残差。整个代价函数把所有传感器约束拉进了一个统一的问题里这正是前面讲的紧耦合思想在代码层面的落地。3.6 回环检测与位姿图优化让历史轨迹重新自洽即便有滑窗和边缘化纯里程计层面的VINS仍然会有累积漂移。回环检测的作用是当相机重新经过曾经去过的地方时通过图像检索识别出历史帧从而修正整个轨迹的漂移。VINS-Mono的回环模块在pose_graph包中。它用DBoW2词袋模型对关键帧做检索同时用Brief描述子描述关键帧视觉信息。检测到候选回环后还要经过几何校验通过PNP求解回环帧之间的相对位姿确认空间关系合理后才把回环约束加入全局位姿图优化。很多人跑小范围bag时会觉得回环似乎没起作用这很可能是因为场景太短相机没有真正回到之前的位置。回环检测对视角变化也比较敏感视角差异过大时描述子匹配效果会明显下降。读代码阶段可以先理解回环的完整链路关键帧筛选、描述子提取、词袋检索、几何校验、位姿图优化然后逐层往里看数据流比直接盯DBoW的源码更有效率。4. 调参与排坑实测中遇到的典型问题与解决方案4.1 话题与时间配置新手最常踩的坑我见过最多的新手问题不是算法看不懂而是数据根本没进到系统里。最常见的是话题名不匹配。比如你launch里加载的配置写的是/cam0/image_raw但bag里实际发布的是/camera/color/image_raw结果就是rviz里一片黑日志里也没有任何状态输出。遇到这种问题排查顺序很重要rosbag info your_bag.bag rostopic list roslaunch vins_estimator euroc.launch先确认bag里有什么话题再确认ROS系统里实际出现了什么话题最后检查yaml配置。三个对应上数据链路就通了一半。时间戳同步问题更加隐蔽如果IMU和相机时间戳相差几十毫秒短时间看不出问题跑久了轨迹会明显发散。EuRoC这类官方数据集已经同步过但自录数据一定要先用脚本检查两条话题的时间戳偏移。4.2 外参、标定与性能问题VINS的配置文件里有相机到IMU的外参也就是body_T_cam_*和body_R_cam_*。很多人为了省事直接填0结果系统表现非常诡异初始化偶尔能过但轨迹跑一会就飘日志里报的残差始终降不下来。这是因为外参偏差导致的误差会随着运动积累前端跟踪再稳也救不回来。外参标定建议用kalibr这类工具打印出标定板采集一段充分激励的数据离线计算出相机到IMU的变换。如果你用的传感器本身带有出厂外参也要实测验证一下因为安装过程可能引入微小的偏差。VINS-Fusion支持在线估计外参但Mono版本更依赖准确的初始值这是两者在使用上的一个明显差异。性能方面如果优化节点的CPU占用过高优先检查是不是IMU消息队列积压导致预积分频繁计算。可以适当降低IMU的采样频率或者在前端对图像做降分辨率处理。还有一个容易被忽略的点pose_graph的回环检索在大规模场景下也会吃CPU如果只是做小范围测试可以关闭回环模块来排除性能瓶颈。4.3 常见问题速查表现象可能原因解决思路rviz无图像话题名不匹配比对bag话题和yaml配置修改配置迟迟无法初始化时间戳不同步或运动激励不足检查时间偏移晃动设备充分激励轨迹发散漂移快外参不准或IMU零偏未收敛重新标定外参延长初始化阶段编译报Ceres相关错误Ceres依赖缺失或版本不兼容安装LAPACK/SUITESPARSE降低版本CPU占用过高IMU频率高或图像分辨率大降频、降分辨率、关闭回环回环无效果场景太短或视角差异太大换长序列bag增大关键帧覆盖这张表基本覆盖了我在不同平台、不同数据集上遇到过的高频问题。如果你还遇到其他情况可以依照同样的思路去定位先看数据链路通不通再看配置合不合理最后才怀疑算法本身。5. 从VINS-Mono到VINS-Fusion代码演进与后续学习路径5.1 VINS-Fusion解决了VINS-Mono哪些短板VINS-Mono是单目IMU单目方案虽然能估计尺度但初始化阶段必须有充分的运动激励且鲁棒性受环境影响较大。VINS-Fusion在此基础上支持了双目IMU双目带来的好处是尺度可以直接观测初始化更快、更稳定它还支持GPS融合在室外场景下可以有效抑制全局位置的长期漂移这对无人机航线和自动驾驶都很重要。实际项目中很多团队直接用VINS-Fusion做基础框架再接入自己的传感器和硬件平台。它相当于把视觉惯性导航从单目扩展到了一个更通用的多传感器融合框架。如果你已经读懂了VINS-Mono再把VINS-Fusion拿过来看会省下大量时间。5.2 两个工程在代码组织上的差异VINS-Fusion的代码组织比Mono更规整它把视觉估计器、全局融合、可视化分成了不同目录camera_model独立维护传感器的接入方式也更清晰。从核心算法角度看Mono里很多模块在Fusion里仍然存在只是换了一些接口但是初始化、滑窗优化、边缘化这些核心逻辑你完全可以复用之前的理解。有一个值得留意的变化Fusion中引入了GPS因子这意味着优化函数里新增了一类残差代价函数的结构更加多样化。另外Fusion对外参在线估计的支持更好代码里会看到与estimate_extrinsic相关的逻辑这部分在Mono里只是一个开关在Fusion里则要更完整地融入优化框架。5.3 读代码的推荐路径与扩展方向我读VINS源码时踩过一个很大的坑一上来就拼命啃边缘化那一堆公式推导结果前两周基本没有实质进展。后来换个思路先不管公式推导顺着数据流把代码主线画出来效果立刻不一样。具体路径我建议这样走先跑通官方bag确认整个系统能出轨迹这是“直感”建立阶段然后改一次话题配置和参数体会哪些参数影响上游哪些参数影响下游接着去读processImage的主链路把前端点云到后端优化的数据流串起来最后再去深抠factor目录下的矩阵和雅可比。主线通了分支自然就清楚了。扩展方向上现在很多工作把深度学习特征点、语义信息、可学习描述子引入到视觉前端替换传统LK光流和Brief描述子。热词里也有不少人在关注代码复现相关的问题。如果你把VINS的传统框架吃透再去接SuperPoint这类深度学习前端会容易很多因为约束关系、优化结构基本不变变的只是特征从哪里来。最后分享一点个人体会。VINS读起来难不是难在某一个公式而是难在整套系统涉及传感器、多视图几何、非线性优化、数据结构多个层面知识点之间互相牵连。我的习惯是画数据流图把“谁产生消息、谁消费消息、哪几类残差进了同一个Ceres problem”都画出来画清楚后再回去看公式效率会高很多。多跑几遍不同场景的bag亲手把初始化失败、滑窗翻车、回环不生效这些问题都调一遍你对这套系统的理解才算真正落地。
RELATED READING

延伸阅读

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