vins-mono 跑完一遍,手里通常只有几个 CSV 位姿文件,仓库里那个/pose_graph/save_maptopic 看着挺诱人,但真要把地图存下来、下次重载接着用、再拿 evo 工具给精度打分,中间的问题是一串接一串。这篇是我把整条链路完整走通之后的记录:从触发保存到落盘文件结构,从重载后日志怎么读到用 evo 算 APE/RPE 并对比闭环效果。适合已经能跑通 vins-mono、但还没搞清楚“地图”到底存了什么,以及如何客观评估定位效果的同学。
1. 先把“地图”这两个字在VINS-Mono里掰开揉碎
1.1 VINS-Mono的“地图”不是点云,而是位姿图加描述子库
很多刚从 ORB-SLAM 或者 Cartographer 转过来的人,第一反应是“保存地图不就是保存点云吗”。这个理解放在 VINS-Mono 上会直接跑偏。VINS-Mono 核心是一个单目视觉惯性紧耦合系统,它通过滑窗优化估计的是当前机体(IMU 坐标系)的位姿,维护的是滑动窗口内的关键帧和路标点,而不是一份供导航或可视化用的稠密点云地图。
代码仓库里的 PoseGraph 模块,维护的所谓“地图”,实际由两部分构成:一部分是关键帧的位姿节点和相邻帧约束、回环约束组成的位姿图;另一部分是这些关键帧上提取的 BRIEF 描述子以及对应的 DBoW2 词袋数据库。回环检测靠的就是“当前帧 BRIEF 描述子”在数据库里检索历史关键帧,找到匹配后再在位姿图里加一条回环边,做 4 自由度优化。
所以,VINS-Mono 保存地图,等价于保存:
- 关键帧索引和位姿;
- 每个关键帧的 BRIEF 描述子;
- 用于回环检索的 DBoW2 数据库;
- 已经建立好的位姿图节点和边。
它没有保存任何可用于渲染和导航的稠密地图,也不像 FAST-LIO 那样直接导出 PCD 点云。搞清楚这一点之后,后面所有操作都不会再被“地图”这个词带偏。
1.2 保存和重载地图到底解决什么问题
在单次跑通的基础上,保存地图这件事的价值主要体现在三个场景里。
第一个是大型场景分段建图。真机跑 vins-mono,不可能像仿真一样一口气把整栋楼飞完。往往要先飞第一段,回到起点附近换电池,再飞第二段。如果每次都从零开始初始化,累积漂移会越来越大;但如果把第一段保存下来的位姿图重载,第二段跑到重复区域时,回环检测能直接把当前轨迹拉回历史轨迹,避免误差滚雪球。
第二个是算法参数对比实验。调特征点数量、IMU 权重、回环阈值这些参数时,如果场景和轨迹每次都不同,对比结果没有说服力。把地图固定下来,只改前端或后端参数,重载同一份地图,才能在相同基准下比较效果。
第三个是精度回测。保存地图时,vins-mono 同时会输出vins_result_loop.csv和vins_result_no_loop.csv两条轨迹,分别对应闭环开启前后的里程计结果。这两份文件配合 evo 工具,能直观量化“回环到底把精度提升到了什么水平”。
其实从工程交付角度看,能保存、能重载、能量化,才意味着这套系统不是只能在 Rviz 里看个热闹,而是真正可以复用和验收的。
2. 保存地图:一条rostopic命令到磁盘文件的完整链路
2.1 保存入口:/pose_graph/save_map
VINS-Mono 官方把保存地图做成了一个 ROS topic,而不是启动参数。在pose_graph_node.cpp里有一个订阅:
ros::Subscriber sub_save_map = n.subscribe("/pose_graph/save_map", 1, save_map_callback);回调里做的事情也很直接:调用posegraph.savePoseGraph(),把当前内存里的关键帧、描述子、位姿图全部写盘。
所以触发保存的命令只有一行:
rostopic pub /pose_graph/save_map std_msgs/Bool "data: true" --once用--once是为了避免按了 Tab 自动补全后,命令变成一个持续发布的循环。否则你可能每隔几秒就触发一次保存,磁盘里会多出一堆时间戳混乱的半成品文件。
之所以设计成 topic 而不是配置项,我的理解是它把“保存地图”变成了一种运行期可控行为。数据采集跑到一半,或者回环优化刚结束,外部脚本可以随时发一条消息触发保存,不需要重启节点,这在真机外场调试时非常有用。
2.2 落盘文件到底长什么样
默认保存路径是~/.ros/pose_graph/。如果你在 config 文件里改过output_path,那保存位置也会跟着变。执行保存后,这个目录下会多出几个文件:
| 文件 | 内容 | 作用 |
|---|---|---|
keyframe.json | 关键帧索引、时间戳、位姿四元数等元信息 | 重载时先读它,知道有多少关键帧 |
brief_kf.dat | 所有关键帧的 BRIEF 描述子 | 重载后重建关键帧检索结构 |
brief_loop.dat | 参与回环的关键帧描述子 | 回环匹配时使用 |
brief_db.dat | DBoW2 词袋数据库文件 | 当前帧快速检索历史帧的核心 |
pose_graph.txt | 位姿图节点、边、回环信息 | 重载后恢复图优化结构 |
这里有个很容易忽略的细节:为什么描述子要分成多个.dat文件存,而不是直接一个文件装完?因为回环检索时,系统需要区分“普通关键帧”和“已经产生回环约束的关键帧”。重载时如果混在一起,会对所有历史帧做一遍无差别检索,在线维护成本会明显增加。分开保存后,加载逻辑可以按需重建,这也是 VINS 系工程里比较典型的空间换时间做法。
保存完成后,你还可以用文件大小初步判断结果是否合理:
ls -lh ~/.ros/pose_graph/如果brief_db.dat只有几百字节,说明关键帧数量少得可怜,或者你触发保存时系统几乎没怎么运动。这种情况重载地图的意义不大。
2.3 保存前要注意的三件事
第一,回环优化生效之后再保存。如果保存时系统还没建立任何回环,那保存下来的位姿图基本就是一段裸的里程计轨迹,重载后价值很低。建议先跑完一个包含回环的完整序列,再触发保存。
第二,确认路径可写。savePoseGraph这个函数在写文件失败时,往往不会有特别显眼的报错,最多在终端里打一行路径信息。如果pose_graph目录不存在,或者权限不对,你以为保存成功了,实际盘里什么都没有。最简单的方法是在触发保存前后各执行一次ls -lh ~/.ros/pose_graph/,对比文件变化。
第三,保存地图和输出轨迹 CSV 是两件事。很多人以为保存地图后,vins_result_loop.csv这些轨迹文件也会跟着生成。实际上轨迹 CSV 是 estimator 节点在运行过程中持续写的,和 PoseGraph 节点的保存动作没有直接关系。两者是独立产物,评估精度时用的是 CSV,重载复用用的是 pose_graph 目录。
3. 重载地图:从启动日志到回环验证的排查路径
3.1 启动时到底怎么加载的
VINS-Mono 的重载逻辑写在 PoseGraph 构造函数里。节点启动后,会去pose_graph_load_path指定的路径下找pose_graph目录,如果有,就依次读取前面的几个文件;如果没有,就从空图开始跑。
实际启动时,你会在终端看到类似这样的日志:
load pose graph from /home/xxx/.ros/pose_graph/ load keyframe: 342这里的关键信息是load keyframe: 342。如果数字是 0,或者日志里提示找不到路径,那就说明重载没有生效,后面跑的还是全新的一张图。
还有一种情况很容易误导人:加载动作本身成功了,但加载进去的关键帧太少,后续回环匹配几乎不可能命中。比如你保存时只飞了几十米,关键帧可能就一两百个,重载到新场景里,当前帧的描述子很难和这些旧关键帧对上。这不是代码问题,而是数据覆盖度不够。
3.2 重载地图不等于重定位
这是我在踩坑之后才彻底想明白的一件事。重载地图后,系统并不会直接告诉你“你现在在地图里的哪个位置”。VINS-Mono 的重载机制只是把历史关键帧放回了回环检索库,当相机重新看到和旧地图重叠的区域时,当前帧会检索到历史关键帧,建立回环边,再把当前轨迹拉回去。
换句话说,重载提供的是“再相遇时的约束”,而不是“启动瞬间的定位”。
所以在验证重载是否成功时,不要指望一启动 Rviz 里就会出现当前位姿和旧地图的完美叠加。正确做法是:重新走一遍曾经走过的路线,观察是否出现回环边。如果走到了重复区域,Rviz 里能看到当前帧和历史关键帧之间的连线,或者位姿图节点明显多出一批旧的节点,这才是重载真正起到作用的时刻。
3.3 重载成功与否的三个验证手段
我常用的验证手段有三个,按简单到复杂排序:
- 看启动日志里的关键帧数量,和保存时是否一致。
- 跑一遍重叠路线,看 Rviz 里是否出现回环边或历史关键帧的连线。
- 对比重载前后两条轨迹的漂移趋势。如果重载后跑同一段路线,终点漂移明显更小,说明历史地图参与约束后起了作用。
第三个手段其实已经是定量验证了,需要配合 evo 工具才能把“漂移更小”变成具体数字。这正好引出了这篇的另一个重点:如何在跑完 vins-mono 之后,用 evo 把精度测明白。
4. EVO准备:把vins_result轨迹喂给评测工具
4.1 EVO是什么,怎么装
EVO 是 SLAM 领域非常常用的轨迹评估工具,不是某个算法,而是一个 Python 写的评测框架。它支持 TUM、EuRoC、KITTI 等常见数据格式,核心功能包括轨迹对齐、尺度修正、AP E 和 RPE 计算,以及各种可视化。
安装非常直接:
pip install evo --upgrade --no-binary evo如果系统里同时存在 ROS 的 Python 环境,建议加--user装到当前用户目录,避免污染系统环境:
python3 -m pip install evo --user装完验证一下:
evo pkg能打印出版本号就算成功。
这里多说一句,很多人会在同时装了很多 Python 包的环境里遇到evo命令找不到的情况。多半是 pip 装的位置和 PATH 不一致。查看which evo的输出,如果指向了一个不存在的路径,用python3 -m evo的方式调用往往能绕过去。
4.2 把 VINS 的 CSV 转成 TUM 格式
vins-mono 的轨迹输出在~/.ros/目录下,文件名是vins_result_loop.csv和vins_result_no_loop.csv。列顺序是:
时间戳, x, y, z, qx, qy, qz, qwTUM 格式的顺序刚好也是:
时间戳 x y z qx qy qz qw所以转换非常无脑,一个 awk 就够了:
awk -F, 'NR>1{print $1,$2,$3,$4,$5,$6,$7,$8}' ~/.ros/vins_result_loop.csv > vins_loop.tum-F,表示按逗号切分,NR>1跳过第一行表头。转换完看一眼文件头:
head -n 5 vins_loop.tum如果前几行是完整的数字串,没有表头和注释,就可以直接进 evo 了。
你可能也会看到有人用evo_traj csv直接读 VINS 原始 CSV。这个方法有时候能成,但前提是 evo 的 CSV 解析器能跳过 VINS 那种带#注释和空格的表头。为了排除版本差异带来的不确定性,我更推荐先转成干净 TUM 再做后续操作,这也是离线批量测试时可控性最高的做法。
4.3 真值轨迹也要先统一格式
EVO 的评估必须有一个参考轨迹,也就是真值。用 EuRoC 数据集时,groundtruth.csv本身就是 TUM 格式,可以直接用。真机数据就麻烦一些,动作捕捉系统导出的轨迹往往带有很多额外列,或者四元数顺序不同,需要先洗成标准的t x y z qx qy qz qw。
这一步看起来简单,但很容易埋雷。我之前整理真机数据时,动捕导出的四元数顺序是w x y z,直接拿给 evo 用,算出来的 APE 大得离谱,但轨迹图画出来又是重合的,折腾了半天才发现是四元数顺序问题。建议转完格式后先用evo_traj跑一遍可视化,确认轨迹形状和真值基本一致,再进入正式误差计算。
5. 用EVO量化闭环与漂移:APE和RPE怎么选、怎么看
5.1 先看轨迹重合程度
正式计算误差之前,我习惯先看两条轨迹在空间上是否重合。这个检查能暴露很多格式和坐标系问题:
evo_traj tum vins_loop.tum --ref GT.tum -a -s --plot_mode=xyz这里两个参数很关键:
-a:用 SE(3) Umeyama 算法对两条轨迹做刚体变换对齐,把坐标系差异消掉。-s:估计尺度并修正。
对单目 vins-mono 来说,-s几乎是必须的。因为单目视觉惯性系统恢复的轨迹存在尺度不确定性,即便加了 IMU,尺度标定也不会绝对准确。不做尺度对齐直接算误差,结果里会混入一大块“尺度误差”,掩盖了算法本身在轨迹形态上的表现。
这一步输出的图里能直观看到 vins 轨迹和真值是否贴合。如果两条轨迹整体形状一样但有个固定旋转偏差,说明坐标系没对齐;如果形状都对不上,那后面 APE/RPE 算出来的数字没有任何参考价值。
5.2 APE:衡量全局绝对误差
APE,全称 Absolute Pose Error,衡量的是每个时间戳上,估计位姿和真实位姿之间的绝对偏差。它最能反映全局一致性,也就是“整条轨迹离真值到底漂了多少”。
命令:
evo_ape tum vins_loop.tum GT.tum -v -a --plot --plot_mode xyz运行结束后,终端会打印一组统计量:
| 指标 | 含义 |
|---|---|
| max | 最大误差 |
| mean | 平均误差 |
| median | 中位数误差 |
| rmse | 均方根误差 |
| std | 标准差 |
| sse | 误差平方和 |
日常对比中,我主要看rmse和max。rmse对大的离群误差更敏感,能反映整体定位稳定性;max则代表最坏情况,无人机避障、路径规划这类场景非常看重这个数字。
如果只想要最终数字,不想看一堆中间输出,可以去掉-v,输出会更干净。
5.3 RPE:衡量局部相对误差
RPE,Relative Pose Error,衡量的是沿轨迹每隔一定距离或时间,相邻位姿之间的相对误差。它更关注局部平滑性,对里程计层面的飘移更敏感。
命令示例:
evo_rpe tum vins_loop.tum GT.tum -r trans_part --delta 1 --delta_unit m -v -a --plot --plot_mode xyz-r trans_part表示只计算平移部分误差;--delta 1 --delta_unit m表示每隔 1 米取一个相对位姿来比较。你也可以把--delta_unit m改成--delta_unit s,按时间间隔计算。
在实际项目里,APE 和 RPE 要配合着看。APE 好了但 RPE 很差,说明轨迹整体被回环或后处理拉回来了,但局部运动估计仍然不平滑;反过来 RPE 好但 APE 差,说明里程计局部稳定,但缺少闭环约束,长距离整体漂移收不住。
5.4 对比闭环前后:这一步最有说服力
vins-mono 之所以同时输出vins_result_loop.csv和vins_result_no_loop.csv,就是为了让你对比闭环开启前后的差异。这是评估回环模块最直接的实验设计。
先把两条轨迹都转成 TUM:
awk -F, 'NR>1{print $1,$2,$3,$4,$5,$6,$7,$8}' ~/.ros/vins_result_no_loop.csv > vins_noloop.tum然后分别算 APE:
evo_ape tum vins_noloop.tum GT.tum -v -a --save_results noloop.zip evo_ape tum vins_loop.tum GT.tum -v -a --save_results loop.zip最后把两份结果放到一起比较:
evo_res noloop.zip loop.zip --plotevo_res会把多份结果并排展示,表格里对rmse、mean等逐项对比。如果闭环真的有效,loop的 rmse 应该明显低于noloop,而且差距越大,说明回环对这个场景的修正越显著。
在我自己测过的 EuRoC 序列上,闭环前后 rmse 能差出 3 到 10 倍不等。但也遇到过一个现象:某个场景里回环明明建立了,APE 却几乎没变化。后来定位到原因是回环检测虽然触发了,但位姿图优化的权重设置或者外点剔除把回环边当成了异常约束,相当于回环白建立了。所以“看到回环边”和“精度提升”不能直接画等号,最终还是要靠 evo 的数字说话。
6. 我踩过的三个隐性坑:时间戳、尺度、坐标系
6.1 时间戳不同步会让所有指标失真
EVO 默认会按照时间戳把两条轨迹配对。但如果 VINS 轨迹和真值轨迹的时间戳起始时间差很多,或者频率不一致,配对结果就会很差。
处理方式是加同步限制:
evo_ape tum vins_loop.tum GT.tum -v -a --sync --t_max_diff 0.01--sync让 evo 先做时间戳同步,--t_max_diff 0.01表示只接受最大 10 毫秒的时间差。这个阈值要按你实际系统的帧率调整,VINS 输出频率通常在 10Hz 到 20Hz 之间,真值如果是 100Hz 的动捕数据,10 毫秒的阈值是合理的。
如果时间戳偏移是固定的,比如真值从 0 开始,而 VINS 从系统启动时间开始,--sync不一定能完全解决。更好的做法是在数据采集时,用同一个时间源的 topic 记录,或者转 TUM 之前先做一次时间对齐。
6.2 单目尺度问题:不要被 -s 掩盖
-s参数在做轨迹对齐时非常方便,但它也有副作用:它会把尺度误差从误差指标里“藏掉”。如果你用了-s,算出来的 APE/RPE 代表的是“尺度对齐后的轨迹形态误差”,而不是系统在真实物理尺度下的绝对精度。
对于大多数 vins-mono 算法验证工作,这没问题。但如果你最终要把它用到真实抓取、无人机降落这类对绝对尺度敏感的任务里,一定要单独看尺度漂移。方法是不用-s,直接跑一次 APE,然后对比两次结果的差异,差异越大,说明尺度问题越严重。
单目里的尺度恢复依赖 IMU 加速度积分,激励不充分时尺度会慢慢漂。这也是为什么很多应用到最后都换成了双目 VINS 或者加了额外的测距传感器。
6.3 坐标系定义不一致
VINS 输出的是 IMU 本体坐标系下的位姿,而真值轨迹通常是动捕系统定义的某个刚体坐标系,甚至可能是相机坐标系。两者如果不一致,即便做了刚体对齐,也无法彻底消除误差。
我自己遇到的情况是,动捕系统贴的 marker 中心点和 IMU 中心点本身就有几厘米的杆臂误差,这个误差会在飞机做旋转运动时放大。解决方式是把 marker 的外参标定好,或者在评估之前把轨迹都换算到同一个参考点。
对于直接用公开数据集的同学,这个问题会小很多,因为 EuRoC 的 groundtruth 和 VINS 输出已经定义了明确坐标关系。但如果跑的是自采数据,这一步不能跳过。
最后再分享一条我的个人习惯:不要在跑完的第一时间就急着保存地图和测精度。先拿vins_result_loop.csv做一次快速evo_traj可视化,确认轨迹整体收敛、没有明显的飞点,再触发/pose_graph/save_map保存地图。这样保存下来的位姿图才有复用价值。每次重载地图前,也记得把旧pose_graph目录备份一下,否则一次失败的加载可能把原来那份好好的地图覆盖掉。这套流程走顺之后,vins-mono 就不再是只能看看效果的 demo,而是能给出量化指标、能复用地图、能被反复验证的定位方案了。