简介:《自动驾驶与机器人中的SLAM技术》源码修改版是高博原书配套代码的定制版,依据深蓝学院的教学与科研要求对代码结构和实现细节做了调整,面向机器人、自动驾驶方向的初学者与工程师,重点是帮助读者把同时定位与建图的理论知识应用到实际工程中。压缩包共1923个文件,核心以C/C++源码为主,包括651个.h、554个.cpp、89个.cc、56个.c,同时含29个makefile、22个cmake、23个sln等构建文件,以及22个pcd点云、7个yaml配置等实验数据,便于不同平台下编译运行,整体约235MB。目前已有927人学习/下载。代码库围绕SLAM技术链路组织,覆盖传感器数据读取与处理、前端配准与里程计、后端优化、地图构建及回环检测等关键环节,并提供多个测试演示工程和跨平台项目解决方案;readme与说明文档会指导读者完成环境配置与排错。通过修改参数、切换数据集并运行实验,读者可以直观观察算法效果,深入理解定位建图过程,也能基于现有模块进行二次开发,因此非常适合作为课程配套或科研入门资源。
1. 这本书的源码修改版,到底改了什么
拿到《自动驾驶与机器人中的SLAM技术》配套源码的那一刻,大多数人做的第一件事不是读代码,而是先编译——然后被一堆报错劝退。这本书讲的是多传感器融合SLAM,从ESKF、激光里程计到因子图优化,理论部分写得顺,但配套代码往往绑定老版本依赖库,在新系统上常常跑不起来。源码修改版就是按某学习平台的要求把这套代码重新整理过:统一依赖版本、修正旧接口、让每个章节的实验按顺序可跑。它解决的是“书读懂了,代码不敢跑”的问题,适合想跟着书把多传感器融合在自己的机器上完整复现一遍的人。这篇笔记聊的就是这套修改版源码怎么读、怎么编译、坑在哪里。
2. 源码背后的技术体系:这本书讲的是哪几条线
2.1 从单传感器到多传感器融合的脉络
这本书的内容主线并不是某个单一算法,而是“多传感器融合”这条线。很多初学者以为SLAM就是激光建图或者视觉里程计,实际上自动驾驶和机器人场景里,单独靠某一类传感器都撑不住长时程、大场景的定位需求。激光点云在结构化场景里表现好,但遇到走廊、隧道这种几何退化环境就会漂;视觉在有纹理的地方能补,但光照一变就失灵;IMU不依赖外部观测,却会积分漂移。所以这本书的核心套路是:用IMU做高频预测,用激光或视觉观测做低频修正,再用因子图或滤波把两者的不确定性揉在一起。
代码修改版里的实验顺序也基本按这条线展开。前面的章节往往先把单传感器前端做通,比如激光点云配准、视觉特征跟踪,后面再逐步引入IMU耦合,最后落到一个完整的多传感器融合定位系统上。读源码的时候如果按这个顺序走,会比按文件名猜逻辑清晰得多。一个实用的读法:先跑通最后的融合实验,再回头逐个看前端的实现,代码里很多参数也就知道为什么要那么设。
2.2 修改版源码的目录结构怎么读
我拿到这种修改版源码,不会先打开任何 .cpp 文件,而是先看三个东西:README、CMakeLists.txt、config 目录。一套代码如果按课程要求整理过,这三个地方几乎能看到全部编译和运行信息。
常见的目录组织方式是:根目录下有一个或多个 CMakeLists.txt,每个章节或实验对应一个子目录,可执行文件名基本和实验名一致。config 目录通常放 yaml 参数文件,里面包含数据路径、传感器内参、配准参数这类运行期可改的东西。用代码目录结构说,大致长这样:
repo_root/ ├── CMakeLists.txt # 顶层 CMake,负责组织全部子模块 ├── README.md # 编译步骤、依赖说明、数据获取方式 ├── config/ │ ├── map.yaml # 地图或全局配置 │ └── params.yaml # 传感器参数、配准阈值等 ├── src/ # 源码主目录 │ ├── frontend/ # 点云配准、里程计相关 │ ├── fusion/ # IMU + 激光 / IMU + 视觉融合 │ └── utils/ # 数据读取、可视化、时间戳处理 └── data/ # 放置数据集,通常是 .bag 或离线帧目录这里要提醒一句:data 目录往往不随代码一起分发,因为数据集体积太大。很多新手拿到源码发现编译过了却跑不起来,多半就是少了数据集,或者路径没对上。修改版一般会把数据集获取方式和放置路径写进 README,如果你手里的版本没有,就自己建一个 data 目录,再把 config 里的路径指过去。
2.3 依赖与版本:为什么修改版要动依赖
源码修改版最值钱的地方不是多写了几个功能,而是把依赖关系整理了一轮。这本书涉及的核心库包括 Eigen、Sophus、Ceres、OpenCV、PCL,另外还经常用到 yaml-cpp 做参数读取。这些库的组合在“老代码”时代是固定的一套,但到了新系统上,问题就出来了。
举个例子,OpenCV 3 到 OpenCV 4 之间改了不少接口:cv::imread的默认参数变了,CV_BGR2GRAY这种宏改成了cv::COLOR_BGR2GRAY,如果代码里还写旧宏,编译器直接报错。PCL 新版本对 C++ 标准要求更高,如果还用默认的 C++11 编译老代码,模板实例化会报一堆莫名奇妙的错。Sophus 早期版本只支持非模板形式的李代数,后来改成模板化接口,调用方式也不一样。
所以修改版通常会做这几件事:把每个库的版本要求写死在 README 或 CMake 里;把旧接口改成新接口;如果实在改不动,就用兼容宏处理。你自己动手的时候,不要一上来升级所有库到最新版,一个合理的组合比一组最新的库更重要。最省事的路径是:按照官方要求装指定版本,而不是把系统里已有的库全升级一遍。
我把常见依赖和作用整理成一张表,读代码前可以先对照:
| 依赖库 | 作用 | 常见坑 |
|---|---|---|
| Eigen | 矩阵与向量运算 | 版本差异不大,但需注意与 Ceres 的兼容 |
| Sophus | 李代数 / 李群运算 | 模板化版本的接口和非模板版差别很大 |
| Ceres | 非线性优化后端 | 版本升级后部分求解器配置项被移除 |
| OpenCV | 图像读取、特征、可视化 | OpenCV3 到 4 的 API 断裂点较多 |
| PCL | 点云存储、滤波、配准 | 编译慢、依赖 Boost;版本间接口有变动 |
| yaml-cpp | 参数文件解析 | 注意旧版使用YAML::Node的方式 |
这条章的关键结论是:源码修改版的“修改”大多集中在依赖兼容和工程可复现上。你不需要把每个底层库的内部都搞懂,但要在读代码之前搞清楚它们之间的版本关系。否则编译报错时你会连错误信息都看不明白。
3. 从零把源码编译跑通:环境、CMake 与最小命令
3.1 环境准备:Ubuntu 选型与依赖安装
对于这套源码,最常见的系统环境是 Ubuntu 20.04 或 22.04。我一般会建议用 20.04,因为很多老一批的第三方库在 20.04 上能直接装到合适应版本,22.04 上虽然也能跑,但某些库需要额外处理。如果你手上只有 22.04,也别急,后面避坑章会专门讲两个版本差异导致的报错。
装依赖的最小命令集大致是这样:
# 更新包索引 sudo apt update # 编译工具链 sudo apt install -y build-essential cmake git # 矩阵与数值库 sudo apt install -y libeigen3-dev libyaml-cpp-dev # 图像与点云库 sudo apt install -y libopencv-dev libpcl-dev # 优化库(Ceres 源码编译,推荐固定版本) sudo apt install -y libceres-dev说明一下:这些命令里的libceres-dev在 Ubuntu 20.04 上是 Ceres 1.14,在 22.04 上是 2.x。如果源码修改版在 CMake 里明确指定了 Ceres 版本,建议以源码编译的方式安装对应版本,而不是直接用 apt 版。原因会在避坑章展开:Ceres 1.x 和 2.x 在Solver::Options一些字段上有移除和改名,直接跳版本可能导致运行到优化阶段就崩。
Sophus 一般不需要用 apt 装,它本来就是 header-only 的,很多源码包直接把 Sophus 以子模块或源码目录形式放在 thirdparty 里。如果代码里是自己维护了一份 Sophus,优先用代码自带的,不要另装系统版本,避免符号重复定义。
3.2 编译命令与最小验证流程
依赖装完之后,编译本身不复杂,绝大多数 Linux 下的 C++ 工程都走同一套流程。常见做法是:
# 回到源码根目录 cd repo_root # 创建独立构建目录,保持源码目录干净 mkdir -p build && cd build # 配置 CMake,生成 Makefile cmake .. # 并行编译,-j 后面的数字是并行任务数 make -j$(nproc)几个关键点:第一,cmake ..的..指的是源码根目录,不要在源码根目录直接跑 cmake,否则生成的中间文件会和源码混在一起;第二,$(nproc)会自动取 CPU 核数,如果你机器内存不大,建议写make -j4而不是把核数拉满,否则编译期间内存会被多个并行编译任务吃满,系统直接卡死;第三,如果 CMake 在配置阶段报“找不到某个库”,不要急着改源码,先检查依赖库是否装到了系统默认搜索路径。
编译完成后,怎么判断是不是真的能跑,我一般会看 build 目录里生成了哪些可执行文件:
# 查看生成的可执行文件 find . -maxdepth 2 -type f -executable如果可执行文件存在,下一步就是准备数据集。此时不要直接盲跑,先看一个关键文件:config 里的路径配置。
3.3 数据集放置与路径配置
源码修改版通常会在 config 文件里用一个字段指定数据路径。例如 params.yaml 里可能有这么一段:
data_path: "/absolute/path/to/your/dataset" imu_topic: "/imu/data" lidar_topic: "/velodyne_points"这里的data_path建议写成绝对路径,不要写相对路径。原因很现实:大部分可执行文件在 build 目录下运行,相对路径会以 build 目录为基准,如果你把数据放在源码根目录下,直接写相对路径很容易指错。我一般会把数据下载后放在repo_root/data下,然后在 yaml 里写/home/yourname/.../repo_root/data。
还有一个常见细节:IMU 话题和激光话题的名字必须和 rosbag 里的对应。如果不一致,程序会卡在“等待数据”状态,日志里一直显示接收不到话题消息。这个话题我会在避坑章再提,但你现在就要养成习惯:拿到数据集先rosbag info看一眼,不要假定话题名和代码默认值一致。
3.4 编译后第一跑:用什么命令验证跑通
第一次运行时,不要追求收敛精度,先确认程序能顺利读完一段数据并输出结果。常见的运行方式是:
cd build ./slam_example ../config/params.yaml程序正常启动时,你应当能看到这几类输出:数据集被成功打开、参数加载成功、每帧数据被处理并输出了当前帧的位姿估计、最终保存轨迹文件。如果程序运行几十秒后没有任何位姿输出,大概率是数据读取环节出了问题,此时按 Ctrl+C 中断,先看数据路径和话题名。
一个实用的验证办法是:跑一个较短的数据包,跑完后检查输出目录里是否生成了轨迹文件,比如 trajectory.txt。文件里的每一行一般对应一帧的位姿(时间戳、x、y、z、四元数)。把这个文件简单画出来,如果轨迹首尾连续、无明显跳变,就说明核心链路是通的。画图可以用 Python,也可以用系统自带的可视化工具,重点是链路有没有走通,而不是画得多好看。
4. 核心模块源码怎么读:ESKF、配准与参数入口
4.1 ESKF 的代码骨架:预测和更新在哪
误差状态卡尔曼滤波是这本书里融合部分的核心状态估计器。理解它,比理解任何一行具体代码都重要。ESKF 的基本思路是:把真实状态拆成标称状态加误差状态,标称状态用 IMU 积分更新,误差状态用卡尔曼滤波估计,最后再把误差修正回标称状态。代码里通常会有两个核心函数,一个做预测,一个做更新。
常见代码骨架长这样:
// 预测:根据 IMU 测量值推进标称状态和误差协方差 void Eskf::Predict(const ImuData& imu, double dt) { // 更新标称状态:位置、速度、姿态 nominal_.p += nominal_.v * dt + 0.5 * nominal_.R * (imu.acc - ba_) * dt * dt; nominal_.v += nominal_.R * (imu.acc - ba_) * dt; nominal_.R = nominal_.R * Sophus::SO3d::exp((imu.gyro - bg_) * dt); // 更新误差状态协方差 Eigen::Matrix<double, 18, 18> F = BuildStateTransition(imu, dt); Eigen::Matrix<double, 18, 18> Q = BuildNoiseCovariance(imu, dt); P_ = F * P_ * F.transpose() + Q; }这里有几个点需要细看。第一,imu.acc和imu.gyro是原始测量值,ba_和bg_是加速度计和陀螺仪的零偏估计,它们在融合过程中会被持续更新,正常情况下应该缓慢收敛。第二,exp((imu.gyro - bg_) * dt)是 SO3 上的指数映射,实际上是用李代数做姿态积分,这一行如果写错,后面所有姿态都会偏。第三,协方差预测里 F 矩阵的推导是 ESKF 理论的中心,调试时如果发现状态发散,首先要检查 F 矩阵有没有漏项。
参数说明:dt是相邻两帧 IMU 之间的时间间隔,代码里不要直接用固定值,应当用时间戳差算出来。如果 IMU 帧率是 200Hz,dt大约 0.005 秒,一旦用错,协方差的量级就会错得离谱,后期观测更新会完全不信任预测。
4.2 激光前端:点云配准的调用方式与参数
激光前端在代码里一般表现为两种:ICP 或者 NDT。ICP 直接最小化点对之间的距离,NDT 先把空间网格化,用正态分布描述每个网格内的点分布,然后匹配。代码里往往两个都有实现,然后在 config 里用参数切换。我一般会优先用 NDT,因为对初值不敏感,在室外大场景里比 ICP 稳。
配准部分的调用通常长这样:
// 设置 NDT 配准参数 pcl::NormalDistributionsTransform<pcl::PointXYZI, pcl::PointXYZI> ndt; ndt.setTransformationEpsilon(0.01); // 收敛判断阈值 ndt.setStepSize(0.1); // 迭代步长 ndt.setResolution(1.0); // 网格分辨率 ndt.setMaximumIterations(30); // 最大迭代次数 // 输入源点云与目标点云 ndt.setInputSource(current_scan); ndt.setInputTarget(map_cloud); // 配准,输出当前帧到地图的位姿变换 pcl::PointCloud<pcl::PointXYZI>::Ptr aligned(new pcl::PointCloud<pcl::PointXYZI>); ndt.align(*aligned, initial_guess.matrix()); Eigen::Matrix4f T = ndt.getFinalTransformation();这里最需要花时间调的是setResolution。分辨率设太大,匹配精度差,轨迹会“发虚”;设太小,网格内点太少,正态分布估计不准,而且计算量直线上升。我一般先设 1.0 米,跑一段数据看效果,再往 0.5 或 2.0 两个方向试探。此外,initial_guess不能随便给单位矩阵,尤其在快速转弯的场景,初值差太远 NDT 会陷到局部极值,轨迹直接跳飞。初始猜通常来自上一帧位姿加上 IMU 积分递推,代码里对应“运动预测”的那一小段,很多人忽略它。
4.3 参数改动入口:yaml 里到底能改什么
这套源码修改版的一大优点是参数集中管理。不必为改一个阈值去翻源代码重新编译,改 yaml 就能生效。一个典型配置文件可能长这样:
frontend: registration: "ndt" # 可选 icp / ndt ndt_resolution: 1.0 # 网格分辨率,单位米 max_correspondence_distance: 2.0 # 匹配点对的最大距离 body_to_lidar: x: 0.0 y: 0.0 z: 0.3 # 雷达相对车体的安装高度 roll: 0.0 pitch: 0.0 yaw: 0.0 fusion: enable_imu: true # 是否启用 IMU 融合 imu_rate: 200 # IMU 帧率,单位 Hz dataset: data_path: "/data/your_dataset" imu_topic: "/imu/data" lidar_topic: "/velodyne_points"每个参数的修改都应该只改一处,而不是在代码里再硬编码一遍。表里凝练几个最常动的参数和效果:
| 参数 | 位置 | 调大后的影响 |
|---|---|---|
| ndt_resolution | frontend | 速度变快,精度下降 |
| max_correspondence_distance | frontend | 容忍更大初值误差,但增加误匹配 |
| body_to_lidar.z | frontend | 外参不准会让地图出现双层结构 |
| imu_rate | fusion | 设置错误会导致时间积分尺度错误 |
读代码时的一个实用习惯:先搜 config 里的参数名,再跳到代码里看它被哪个函数使用。这样你能很快建立“参数 → 算法 → 效果”的映射。不要试图把每个变量都读懂,先把能通过配置改变的入口摸清楚,调试的时候会省很多事。
5. 源码运行避坑:5 个编译与调试翻车现场
5.1 现象一:OpenCV 版本冲突导致编译失败
编译时经常出现类似error: 'CV_BGR2GRay' was not declared in this scope的报错。原因是代码是按 OpenCV 3 写的,系统里装的是 OpenCV 4。OpenCV 4 把很多常量名从CV_前缀改成了cv::前缀,宏定义位置也变了。
解决方法是优先改源码而不是降级 OpenCV。打开报错文件,把CV_BGR2GRAY改成cv::COLOR_BGR2GRAY,CV_LOAD_IMAGE_COLOR改成cv::IMREAD_COLOR。如果源码量很大,可以用全局搜索替换,但替换后一定要重新编译确认没有漏网。更稳妥的做法是看修改版是否已经在 CMake 里做了兼容判断,比如通过 OpenCV_VERSION 宏区分。
5.2 现象二:Ceres 版本不对,跑到优化时崩溃或断言失败
这类问题最隐蔽。编译可能顺利通过,但程序一运行到后端优化就报Check failed: ...,或者直接卡住不往下走。原因多数是代码用 1.x 写法写的,系统装的是 Ceres 2.x。2.x 里Solver::Options的一些字段被移除,数值求解器的行为也变了。
我的建议是:优先按源码 README 里指定的 Ceres 版本源码编译,不要用系统的libceres-dev。编译 Ceres 本身不难,依赖也少。装上指定版本后,把 CMake 里的find_package(Ceres)路径指到新安装位置,再重新 build 整个工程。版本对齐以后,这类玄学崩溃基本消失。
5.3 现象三:Segmentation fault,一运行就崩
运行起来还没看到任何位姿输出,程序直接段错误。最常见的原因是点云为空或者地图还没初始化就进入了配准函数。代码里可能只对“输入点云非空”做了检查,但没检查目标点云是否为空;或者读取数据时第一帧没成功解析,却直接把空点云传进了 PCL。
排查时先用 gdb 跑一遍,拿到崩溃栈:
gdb ./slam_example run ../config/params.yaml btbt会打出调用栈,看到崩溃在哪个函数。如果栈顶是 PCL 的align或setInputTarget,大概率就是空点云问题。解决方法是把加载数据的代码段附近加上判空保护,并输出日志确认每帧数据是否真的读进来了。用 gdb 定位这类问题,比瞎加打印快得多。
5.4 现象四:能跑但轨迹明显发散,越跑越远
编译没问题,运行也顺利,但画出来的轨迹一路飘走,完全不符合实际。这种情况下九成是外参或零偏初值有问题。常见的是body_to_lidar外参里的平移或旋转填错,导致每个时刻的观测都被加了一个系统偏差,前端配准始终在错误的方向上找匹配。
另一个隐蔽的原因是 IMU 协方差初始值设得太大或太小。初始协方差太大,滤波会过度相信观测,轨迹跳动剧烈;初始协方差太小,滤波会长期被 IMU 预测主导,外部观测修正得很慢,轨迹也会慢慢飘。调整方法是先把外参按标定值填准,再逐步把加速度计噪声密度和陀螺仪噪声密度往真实器件手册的量级靠,不要拿别人配置文件里的值直接用。
5.5 现象五:数据一直在原地,或者程序在等话题
程序启动后日志停在“waiting for data”,或者画面只有第一帧。多数情况是话题名不匹配,或者数据包是循环播放的,时间戳处理出了问题。先检查 config 里配置的imu_topic和lidar_topic是不是和数据集一致,用 rosbag info 命令查看实际话题名,而不是凭记忆猜。
还有一个容易忽略的点:rosbag 循环播放时,如果代码内部按时间戳单调递增来维护队列,循环回绕后时间戳会往回跳,导致前端一直拿旧数据或者队列越积越大。我遇到过一次,程序跑到一半 CPU 和内存暴涨,最后发现是 bag 循环播放导致时间戳回跳。解决方法是改用一次播放,或者让 bag 播放器连续发送而不是循环。
6. 进阶用法:用自己的传感器数据替换官方数据集
6.1 把官方数据换成自己录制的 bag
官方数据集跑通后,下一步自然是换自己的数据。前提是你的传感器话题名和数据结构能和代码对上。处理思路是这样的:先rosbag info查看自己的 bag 有哪些话题,然后写一个小脚本把 bag 的话题名改到代码期望的名字。
很多代码只接受离线帧目录或者 rosbag。如果是 rosbag 输入,最简单的做法是用rosbag reindex重写索引,再通过rosbag play加上话题重映射参数,把实际话题映射成代码需要的名字,比如把/lidar/points映射成/velodyne_points。这样已经可以跑起来了。
6.2 时间戳对齐:三种常见做法
真正需要花时间的是时间戳对齐。自动驾驶传感器的时钟源往往不统一,激光雷达、IMU、相机各自有自己的时间戳基准。代码里如果只按时间戳顺序消费数据,没有做插值或同步,融合精度会明显变差。我一般按场景选三种方式:
| 同步方式 | 适用场景 | 精度 | 工程量 |
|---|---|---|---|
| 最近邻匹配 | IMU 帧率远高于激光 | 中 | 低 |
| 线性插值 | 两传感器帧率接近 | 高 | 中 |
| 硬件时间同步 | 高精度融合 | 最高 | 高 |
最近邻匹配实现最快:拿到激光帧时间戳,在 IMU 队列里找时间差最小的一帧直接用。线性插值稍复杂一点,但效果明显:找到激光时间戳前后两帧 IMU,按时间比例插值出虚拟 IMU 测量。我自己的习惯是,只要 IMU 帧率是激光的十倍以上,先做最近邻匹配就够了,不需要一开始就上插值。
结尾说一个我踩过的细节:有一次我把 bag 设置成了循环播放,日志里一切正常,但融合轨迹在某个位置反复跳变,排查了大半天才发现不是代码问题,而是 bag 循环回绕后时间戳回退,代码里的数据队列因为时间戳不单调而乱了排序。从那以后我立了个规矩:所有传感器数据流,先检查时间戳是否单调递增,再谈精度和调参。这个习惯帮我挡住了很多不必要的翻车。希望帮到你。
本文还有配套的精品资源,点击获取