ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

VINS全解析:视觉惯性里程计核心原理与代码实现

VINS全解析:视觉惯性里程计核心原理与代码实现 聊到视觉惯性里程计VIOVINS是绕不开的名字。作为一套单目/双目视觉与IMU紧耦合的开源系统VINS-Mono和后续的VINS-Fusion几乎是我见过代码风格最规范、工程落地价值最高的SLAM项目之一。很多人第一次拿到这套代码看起来很吃力不知道从哪里读起也不知道bag文件怎么跑更不知道怎么改。这篇文章我就从技术路线到代码实现把VINS拆开揉碎过一遍尽量说人话把流程里每个环节为什么这么做、代码在哪里、关键函数长什么样一次讲清楚。适合正要上手VINS或者想从代码角度彻底搞懂VIO流程的同学。1. VINS项目背景它解决什么问题为什么值得深入学习1.1 为什么同时需要相机和IMUVINS解决的核心问题是在没有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_tracker2D特征点以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这类系统你会发现很多思路都是相通的。
返回列表