写这篇的时候,我的电脑桌面还留着当年跑《视觉SLAM十四讲》的工程目录。前面十二讲学的是特征提取、对极几何、PnP、BA、回环检测这些算法零件,到了第十三讲,才真正体会到什么叫“把零件装成一台能跑的车”。我二刷这一讲时,在编译环境上耗了接近一天,后面又是各种运行时崩溃和地图点异常。这篇就把第13讲从代码结构、环境依赖、KITTI数据准备,到编译运行、故障排查、轨迹评估的完整流程梳理出来,给卡在这一讲的读者一条可以直接照着走的路径。
1. 第13讲真正在教什么:从模块算法到系统工程的跃迁
1.1 为什么一本书讲到这里突然变难
前几讲的练习基本都是“单点突破”:给你一张图或一段数据集,验证某个算法的效果。第13讲的难度曲线突然拉高,原因在于它要求你把之前学过的所有模块拼成一个完整的SLAM系统——前端视觉里程计要做特征提取与匹配、位姿求解、三角化;后端要用局部BA优化关键帧位姿和地图点;同时还要管理关键帧、维护地图、用Pangolin做实时可视化。这里面任何一个模块单拿出来你都能看懂,但串在一起之后,数据流、线程关系、内存管理、参数匹配、坐标系一致性全是坑。
我见过不少初学者在第十三讲下载好代码后,第一反应是“先看看主函数”。讲实话,第13讲的主函数反而是整个工程里最好懂的部分,它无非是读取配置、创建数据集对象、循环喂图像、等待系统结束。真正难的在于类之间的协作关系,以及各个模块之间传递的数据结构到底长什么样。看懂了这些,后面调试才会有方向感。
1.2 ch13工程的整体代码结构与数据流
以《视觉SLAM十四讲》第二版的ch13工程为例,它的myslam目录下按模块拆了很多文件,我在第一次看的时候没有先理清文件结构,直接扎进某个cpp里读,结果云里雾里。后来退出来,在纸上画了一张类的关系图,才觉得整个系统清晰了。
简单来说,这套系统的数据流是这样的:Dataset负责从KITTI数据集读取图像和时间戳,Camera封装相机内参和双目baseline,Frame是单帧数据的容器,会持有左目图像、提取出的Feature特征、以及对应的位姿;MapPoint表示地图点,Map则负责保存所有关键帧和地图点,并提供增删查接口。前端的核心是Frontend,它从Dataset拿到新帧,估计初始位姿、三角化新点、判断是否产生关键帧;后端的Backend在线程中处理关键帧队列,用G2O做局部BA;Viewer则用Pangolin把相机轨迹和稀疏地图画出来。
把这几个类的依赖关系画出来后,你会发现它和书里前面讲的单目/双目SLAM框架完全对得上,只是多了一层工程上的组织。这也是第13讲最值钱的地方:它教你用合理的类划分去承载算法逻辑,而不是把所有东西都堆在main()里。
2. 跑通前必须处理的环境问题:第三方库与版本搭配
2.1 第13讲需要的依赖库清单与安装顺序
第13讲工程在编译时依赖的库比前面任何一讲都多。我在自己的Ubuntu系统上总结了一份清单,按安装顺序排下来大概是:Eigen、OpenCV、Sophus、g2o、Pangolin、fmt,另外如果你后续想加回环检测,还会用到DBoW3的词袋部分。逐一说明一下作用:Eigen是线性代数基础库,所有矩阵运算都靠它;OpenCV负责图像读取、ORB特征提取和匹配;Sophus提供李代数上的SO3/SE3类型,位姿表示和更新都依赖它;g2o是后端图优化库,局部BA跑在它上面;Pangolin是可视化管理器,负责显示轨迹和地图点。
安装顺序我个人强烈建议按上面的顺序来,因为后编译的库在CMake里会去find_package前面已经装好的依赖。尤其是g2o,它本身依赖Eigen和Sophus,如果你先编译g2o再回头装Sophus,可能出现g2o用了旧版Sophus头文件的情况,运行期表现出一堆莫名其妙的模板报错。
2.2 版本搭配的坑与CMake匹配问题
版本搭配是第十三讲环境里最大的坑。我自己第一次编译时,系统里的OpenCV是4.x,而书中示例代码很多地方用的是OpenCV 3.x时代的接口,虽然大多数API能兼容,但一些枚举类型和函数签名有变化,编译报错很零碎。如果你不是非要保留系统OpenCV不可,建议单独编译一个OpenCV 3.4.x版本供这个工程使用,或者直接按作者书中推荐的依赖版本表来装。
g2o也是重灾区。书中工程自带了一个g2o版本放在了3rdparty目录下,我后来的做法是优先用工程自带的版本,而不是系统apt装的那个。因为自带版本和高翔的示例代码在头文件路径、求解器类型、顶点边类型上是对齐的,换成系统版很容易出现“找不到g2o::BlockSolverX”这种错误。Sophus同理,建议直接用书中配套的Sophus,避免和后续安装的其他库产生符号冲突。
2.3 编译前必须检查的CMake细节
在ch13目录里,CMakeLists.txt会通过find_package寻找OpenCV、g2o、Pangolin、fmt等库。如果你在编译时不断报“找不到某某库”,先不要怀疑代码,先去检查库是不是真的装上了、版本是不是匹配。我踩过最典型的问题是:Pangolin装到了/usr/local/lib,而CMakeLists里指定的搜索路径没有覆盖到,导致链接时找不到libpangolin.so。这类问题有个通用的排查方式:在CMakeLists.txt里临时打印message(STATUS ${Pangolin_DIR})等变量,看路径是否指向你安装的目录。
另外有一个经验:凡是改过依赖库版本或路径,一定记得把build目录整个删掉重新cmake,不要只删CMakeCache.txt。CMake缓存里的旧路径非常顽固,我因为在已有build目录上反复cmake ..浪费了不少时间。删干净重来,往往一句报错都没有了。
3. 数据与配置:KITTI序列和default.yaml逐项拆解
3.1 KITTI数据集的下载与目录结构
第十三讲的示例代码默认跑KITTI数据集,很多人在这一步就卡住了——不知道下哪个、下完放哪里。KITTI官网上有odometry benchmark,里面按序列号(00到21)组织。第13讲和配套代码通常用00序列或者05序列来演示,因为这两个序列有回环且轨迹长度适中,地图效果比较好看。下载的时候注意要选择带“synced+rectified data”的版本,那个压缩包里包含左右目校正后的灰度图和彩色图,体积比较大,一个00序列大概几个GB,建议提前准备好磁盘空间和稳定网络。
解压后,一个序列目录的结构大概是这样:sequences/00/下面有image_0和image_1两个子目录,分别存放左目和右目的灰度图,文件名是按六位数字编号的PNG;同目录下还有calib.txt记录各相机投影矩阵,times.txt记录每帧的时间戳。第13讲的Dataset类就是按这个约定来读图的,所以如果你自己换了数据集目录,必须保持这个结构,否则运行时会因为读不到图像直接崩掉。
3.2 配置文件里的关键参数分别控制什么
ch13工程运行时需要一个配置文件,默认是config/default.yaml。这个文件里的参数直接影响系统行为,我强烈建议在跑代码之前先逐行读一遍,而不是直接双击运行。以我调试用过的配置为例,核心字段大致包括:数据集路径、相机内参(fx、fy、cx、cy)、双目baseline、每帧提取的特征点数量、初始化需要的特征点数量、关键帧判定阈值、后端BA是否启用、可视化是否开启等。
这些参数里,相机内参和baseline是最不能乱填的。KITTI的calib.txt里给出了P0、P1、P2、P3四个3x4投影矩阵,以00序列的P0为例,格式大概是三行四列,第一行前三个数是fx、0、cx,第四个数是-fx * baseline。我把P0里的值摘出来换算过,00序列的fx约718.856,主点cx约607.193,cy约185.216,baseline约0.537米。你如果跑别的序列,不要直接抄这个数字,要用对应序列calib.txt里的P0矩阵自己换算一遍,否则相机模型不对,初始化就会失败。
3.3 换数据集序列时需要同步改哪些参数
很多人跑完00序列想换到05或07序列看看效果,结果发现直接改dataset_dir后系统表现非常差。问题往往出在内参没换。KITTI不同序列的标定参数不完全相同,主点、焦距都有毫米级甚至像素级的差异,SLAM对这部分误差很敏感。所以换序列时我建议做两件事:一是把calib.txt里P0矩阵对应的fx/fy/cx/cy和baseline换算出来,更新到yaml;二是确认times.txt存在且格式正确,因为工程的Dataset会依赖时间戳来同步左右目图像。
我有个习惯:每跑一个新序列之前,写个小脚本解析calib.txt,直接把参数打印成yaml格式,再手填到配置文件里。这个操作虽然简单,但能省掉后面很多“为什么这个序列跑飞了”的排查时间。
4. 从编译到跑通:完整操作流程与输出解读
4.1 标准的编译流程与预期结果
环境依赖装好之后,编译本身不算复杂。我习惯按下面这套流程来:
cd slambook2/ch13 mkdir -p build cd build cmake .. make -j4这里的-j4是并行编译参数,取决于你CPU核数,8核机器可以用-j8。如果cmake阶段没有飘红,make能顺利产出可执行文件,环境基本就算过了。第13讲编译产出的可执行文件通常叫run_kitti_stereo,还有一些其他辅助程序。如果你的机器比较老,第一次编译时间会比较长,G2O和Sophus的模板实例化在最底层,CPU会拉满,风扇呼呼转,这是正常的。
如果你在这一步遇到了报错,我建议把第一条error信息复制出来去检索,而不是只看后面的“Error 1”。CMake的报错经常是连锁反应,真正导致失败的那一行往往在最上面。另外建议在编译时加上-j1先跑一遍拿到完整错误链,再用多线程重编,效率不一定低,因为排查起来更快。
4.2 运行KITTI序列的实际命令
编译完成后,进入build目录,用下面的命令启动:
./run_kitti_stereo ../config/default.yaml /path/to/dataset/sequences/00注意第一个参数是配置文件路径,第二个是数据序列根目录。路径建议都写绝对路径,相对路径一旦工作目录不对就会读不到图,程序直接就结束了。启动之后,如果一切正常,你会看到Pangolin窗口弹出来,同时终端开始打印每帧的处理信息,包括当前帧号、关键帧数量、地图点数量、每帧耗时等。
我第一次跑起来的时候,看到Pangolin窗口里一条蓝色的轨迹慢慢延伸出来,周围散落着稀疏的红色地图点,说实话是有点兴奋的——前面那么多理论,到这里终于变成了一个实时在跑的“系统”。不过兴奋归兴奋,这时候更该做的是盯着终端输出,确认几个关键指标:关键帧数量是不是在增长,地图点数量是不是在增长,每帧处理时间是不是稳定在几十毫秒级。如果这三个指标里有一个不动,系统其实已经出问题了。
4.3 Pangolin可视化界面里到底在看什么
Pangolin界面看起来简单,实际上它显示了三类信息:当前相机位姿、历史轨迹、以及当前地图中的稀疏地图点。相机位姿通常画成一个小的锥体或者坐标系,代表当前帧在世界坐标系下的朝向和位置;轨迹是相机中心经过的路径线;地图点则是三角化后恢复出来的空间点,形成一种稀疏的三维点云。
我在调试时会被界面误导,因为Pangolin理论上很炫,但我更关心数值层面的东西,比如轨迹是否和真实路径走向一致。KITTI 00序列的路径是有明确形状的——车辆从一段城市道路出发,绕了一个大圈回到起点附近。如果你的轨迹画出来是一条直线,或者中途开始剧烈抖动,说明后端的BA没有正常起作用,或者前端跟踪已经丢了。
4.4 系统正常工作的判断标准
怎么判断系统跑得“正常”?我的标准有三个:第一,地图点数量在前几百帧里逐步增加,而不是一直停留在初始化时的几十个;第二,每帧处理时间波动不大,没有越跑越慢;第三,轨迹在和KITTI的ground truth对照时,整体形状吻合。如果你看到地图点数量突增然后骤减,多半是后端在剔除异常点,短时间波动可以接受,但连续震荡就需要排查了。
另外要注意运行过程中不要主动去拖拽Pangolin窗口里的相机视角太频繁,虽然界面响应很流畅,但可视化的渲染也是要占用主线程资源的。我在跑数据集时通常把界面窗口缩小,等跑完再放大了看轨迹细节。
5. 调试实战:我在第13讲里踩过的四类故障
5.1 启动即崩溃:先查数据集路径和空指针
第13讲代码最容易在启动阶段崩溃,表现形式是刚运行一两秒,终端报一个Segmentation fault (core dumped)。这类问题的最大元凶是路径错误。Dataset读取图像时会对路径做拼接,如果dataset_dir写错或者末尾少了斜杠,文件加载失败后会返回空图像或者空指针,后面任何一步处理空图像都会直接段错误。
我的排查顺序是:先确认配置文件里的路径能ls到,再看代码里Dataset初始化时有没有对加载失败做检查。如果这些都没问题,下一个嫌疑是相机内参没有加载成功——Config类在读取yaml时如果字段名和你填的不一致,会用默认值,而默认值未必合理,也会在后续的投影、去畸变环节产生奇奇怪怪的崩溃。建议在程序启动后加一行打印,把最终使用的fx、fy、cx、cy和baseline打出来,一眼就能看出对不对。
5.2 初始化阶段卡死或者反复失败:特征点太少的连锁反应
系统初始化是另一个高频故障点。初始化阶段需要提取足够多的特征点,并且在前两帧之间找到足够多的匹配,才能计算基础矩阵或者单应矩阵。如果你的配置里num_features设得太低,或者KITTI图像某些场景纹理弱(比如大片天空、路面),初始化就会一直不成功,终端上表现为跑了很久地图点还是零,或者程序直接退出。
遇到这种情况,我的调法不是一次性把特征点数量翻倍,而是分步调整:先把num_features_init调高,再看匹配阶段是否把距离阈值放宽一点。还要注意把ORB金字塔层数适当增加,这样在尺度变化明显的场景里匹配率会更高。如果你改了这些参数初始化还是失败,建议换一个纹理更丰富的序列段试试,先跑通流程,再回来调参数,减少变量。
5.3 运行中界面卡死:线程与可视化的矛盾
还有一种情况,程序不是崩溃,而是跑到中途Pangolin窗口完全卡死,终端也不再输出,像是整体冻住了。这种问题大概率不是算法死了,而是线程竞争。第13讲里前端和后端在不同的线程中跑,Viewer也会实时读取地图数据来渲染,如果共享数据没有及时加锁,或者Viewer的刷新频率过高,主界面就会卡住。
我当时的解决办法是把Viewer的渲染帧率调低,比如从60fps降到30fps,减少渲染对锁的争用。另外检查Pangolin的版本,某些新版本在旧代码的接口下会有很奇怪的挂起问题,换回0.6或0.8老版本反而稳定得多。
5.4 轨迹跑飞或单方向漂移:后端参数与关键帧策略
如果你发现轨迹整体形状是有的,但越到后面越偏,甚至最后反向,问题多半出在后端BA或者关键帧选择上。关键帧间隔设得太小,会让BA窗口里的帧相关性太强,优化掉的是冗余信息而不是误差;设得太大,则两帧之间累积漂移太多,BA救不回来。把keyframe_min_rot和keyframe_min_trans稍微调大一点,让系统少产生关键帧、每次BA窗口覆盖更大的运动范围,是我实测中比较有效的方向。
另外,如果只是某个方向匀速漂移,很可能是标定内参不够准。KITTI的标定参数已经是准的了,但如果你换成自己的相机,一定要用棋盘格重新标定,别用网上随便找的近似值。
6. 用轨迹评估验证系统:EVO工具与精度分析
6.1 从运行结果中导出轨迹文件
跑通第13讲还只算第一步,严谨一点的做法是用工具评估系统轨迹和真值之间的误差。KITTI的ground truth在每个序列的poses/目录下,文件名和序列号对应,里面每行12个数字组成一个4x4的变换矩阵。第13讲工程运行结束后,会把自己的估计轨迹输出成一个文件,格式通常和KITTI的pose格式类似。
我在评估时常用的工具是EVO,一个Python写的SLAM轨迹评估库。安装很简单:
pip install evo它支持KITTI、TUM等多种轨迹格式,还可以做轨迹对齐、计算ATE和RPE。使用时把真值文件和估计轨迹文件准备好,一条命令就能得到量化结果。
6.2 EVO评估命令和指标解读
以KITTI格式为例,如果想看绝对轨迹误差,我用的是:
evo_ape kitti /path/to/00.txt /path/to/estimated_traj.txt -a-a参数表示在做误差统计前先做轨迹对齐,消除坐标系不一致带来的整体偏差。输出里最关键的几个指标是RMSE、mean、max。对KITTI 00这样几公里长的序列,一个调好参数的视觉SLAM系统,ATE的RMSE大概能到几米到十几米量级,具体取决于是纯单目还是双目、后端优化效果如何。如果RMSE在几十上百米,那基本说明系统跟踪或者优化有问题,不是正常结果。
相对位姿误差(RPE)用来衡量局部漂移,用evo_rpe评估:
evo_rpe kitti /path/to/00.txt /path/to/estimated_traj.txt -aRPE过大说明前端帧间运动估计不稳定,你可以反推是特征跟踪的问题还是位姿求解的问题。
6.3 输出轨迹可视化与错误定位
EVO还有个非常实用的功能,是把它把误差画成轨迹上不同颜色的线段,红色部分误差大,蓝色部分误差小。我一般先用这个图快速定位是哪一段轨迹崩了,再回去看那个时间段对应的图像序列里有什么特点——是转弯、是光照变化、还是场景纹理稀疏。这种“先全局量化、再局部定位”的调试方式,比对着yaml盲调参数高效得多。
7. 从ch13到自己的SLAM系统:改造思路与扩展建议
7.1 把KITTI换成自己采集的数据
跑通KITTI之后,很多人下一个想法是拿第13讲的系统来跑自己的数据。这个过程比想象中要多做几步:如果你的设备是双目相机,需要先做双目标定,得到内参、畸变系数、双目外参和baseline,然后按image_0、image_1的目录结构整理左右目图像,同时准备一份times.txt,保证左右目图像按时间戳配对。如果你的设备是单目,第13讲工程不一定能直接跑,因为单目系统存在尺度不确定性,初始化之后还需要额外的尺度恢复策略,工程里很多地方是按双目或者已知尺度设计的。
我建议先在短序列上试水,比如采集30秒到1分钟的走路视频,截成帧,跑通之后再逐渐加长。不要一上来就是半小时的园区数据,就算系统崩了你都不确定崩在哪一段。
7.2 在RK3588这类嵌入式平台上运行的注意点
因为最近嵌入式设备跑视觉SLAM的需求越来越多,比如RK3588这类开发板上跑第13讲,也有些人问过我可不可行。结论是可行,但需要具体优化。首先是依赖库版本,RK3588上通常跑的是Linux发行版或者Ubuntu类系统,OpenCV如果走交叉编译,要确认OpenCV的WITH_GTK、WITH_V4L这些选项是否开启,否则图像读取和显示都可能有问题。其次,ARM平台上的G2O、Sophus性能比x86弱一些,参数上需要把特征点数量和keyframe频率适当降低,让实时性优先。
实际测试中,我建议先在PC上完全调通同一份数据和参数,再移植到嵌入式板子上。SLAM系统排查问题的时候,平台差异会造成很大的干扰,先把算法层面跑对了,再处理平台的性能瓶颈,会省很多事。
7.3 下一步可以补的模块:回环、全局优化与重建
第13讲的代码侧重点在“从零到一搭建一个完整可跑的SLAM系统”,如果你后面真的要做工程应用,需要补的模块还有不少。回环检测是最值得加的一个,跑KITTI 00这种有重合路径的序列,没有回环检测,轨迹会在回到起点后明显累积漂移。加回环需要词袋模型、回环候选检测、位姿图优化这几块,代码量不小,但和前端解耦比较好,可以作为一个独立线程插进去。
另一个值得扩展的方向是把稀疏地图转成更适合导航的半稠密或稠密地图。这涉及深度估计、体素地图或网格地图的构建,计算开销很大,但也是从“视觉里程计演示”走向“机器人系统”的关键一步。
第13讲的意义,不在于它给出了多么前沿的算法,而在于它让你理解了SLAM工程化的全貌。我后来在RK3588上做嵌入式视觉定位方案时,回头翻的最多的也是这一讲的代码结构——类的拆分方式、关键帧和后端的交互模式、配置化驱动的思想,这些直接复用到了我的实际项目里。如果你正在读这一讲,别急着追求“跑完就算会了”,把每一行输出、每一个参数都吃透,你收获的会是一个能真正迁移到工程里的系统认知。