摘要:从激光测距、扫描角度和多线 3D 雷达出发,串联 LaserScan、GMapping、OccupancyGrid、AMCL 与 map→odom,建立 ROS1 建图和已有地图定位的完整数据链。
@[toc]
第 15 章已经把轮式里程计与 IMU 接入 robot_localization,并让状态估计器稳定提供:
1 | odom -> base_link |
这条变换适合描述机器人连续的局部运动,但 odom 允许随着时间积累漂移。真正进入移动机器人全局定位时,还需要回答另一个问题:
1 | 机器人现在位于整张地图的什么位置? |
第 16 章从激光雷达开始,把这个问题拆成一条完整链路:
1 | 激光如何得到距离 |
本章不推导粒子滤波、scan matching 或 SLAM 后端优化的数学细节。目标是先把 ROS1 中谁提供什么数据、谁消费什么数据、谁拥有哪条 TF建立清楚。
本章新增配套包:
1 | /workspace/ros_ws/src/ros1_slam_lab |
它使用一个确定的二维墙体环境生成 /scan,避免先引入 Gazebo。这样可以直接观察 LaserScan -> SLAM/AMCL -> map -> odom 的关系。
1. 激光雷达到底在测什么
移动机器人常见的激光雷达本质上是主动测距传感器:自己发出激光,再接收物体反射回来的能量,根据测量结果求出目标距离。
最容易建立直觉的是脉冲式飞行时间法(Time of Flight,ToF)。假设发出一个激光脉冲,经过时间 Δt 收到反射:
1 | 雷达 ----> 障碍物 |
光走过的是“去程 + 回程”,所以距离近似为:
1 | d = c * Δt / 2 |
其中:
1 | c 光速 |
真实产品还会处理脉冲整形、接收阈值、回波强度、多回波、温漂和标定等问题。本章只需要保留一个核心结论:
雷达的一次测量首先得到的是“某一个方向上的距离”,还不是地图。
另外,并非所有激光雷达都采用完全相同的 ToF 实现。相位式、FMCW、MEMS/固态扫描等方案在硬件实现上不同;本章用 ToF + 扫描方向作为移动机器人数据模型的直观入口,不把它泛化成所有雷达的唯一原理。
2. 为什么还要“扫描”:一个距离不够描述周围环境
如果雷达只朝机器人正前方发射一束激光,只能得到:
1 | 0° -> 2.35 m |
这只能说明“正前方约 2.35 m 有反射目标”。
要知道周围一整圈环境,就需要不断改变测量方向。例如二维旋转激光雷达可以依次测量:
1 | -90° -> 1.82 m |
把这些方向放在同一个雷达坐标系中,可以形成一圈极坐标采样:
1 | +x / 0° |
ROS 的标准坐标约定中,如果 +Z 朝上,则 LaserScan 的角度绕 +Z 轴测量,正角度为逆时针,0 rad 沿 +X 方向。
因此从上往下看,一个常见移动机器人坐标系可以记成:
1 | +Y / 左 |
这也是后面理解 angle_min、angle_max 和 angle_increment 的基础。
3. “1 线、16 线、32 线、64 线”到底是什么意思
这一组术语主要出现在 3D 激光雷达中。
3.1 先看二维雷达:本质上只有一个扫描平面
典型 2D 激光雷达测量的是某个固定高度附近的一张平面:
1 | 侧视: |
所以它最终适合用:
1 | 角度 θ + 距离 r |
来表达。
二维雷达通常不会用“32 线、64 线”描述,因为它主要只有一个扫描平面。
3.2 多线 3D 雷达:垂直方向同时有多个采样方向
机械式多线 3D 雷达可以粗略理解为:在垂直方向安排多个不同俯仰角的测距通道。
例如一个简化的 4 线示意:
1 | 侧视: |
当整个雷达再绕竖直轴旋转时,每个垂直通道都会扫过水平方向,于是得到三维空间中的大量点。
因此常说的:
1 | 16 线 |
通常表示垂直方向存在多少个测距通道/扫描层。线数越多,一般意味着垂直方向采样更密,但不能直接把“线数”理解成总点数、水平角分辨率或最终地图精度。
实际产品的垂直角可能并不均匀。例如 32 条线不一定正好在一个固定垂直视场中等间隔排列,每条 channel 的 elevation angle 往往以厂家标定值为准。
固态、MEMS、Flash 或其他扫描架构也不一定适合直接套用传统机械雷达的“几线”概念,所以“线数”更适合作为机械多通道 3D LiDAR 的常见工程术语,而不是所有激光雷达的统一物理定义。
4. 雷达中经常说的“角度”至少要区分四件事
看到雷达规格中的“角度”,不能只问“是多少度”,还要问它描述哪个方向、哪个概念。
4.1 水平视场角 Horizontal FOV
表示水平方向能看到多大范围。
例如旋转式雷达常见:
1 | Horizontal FOV = 360° |
意味着绕竖直轴可以观察完整一圈。
二维前向激光也可能只有:
1 | 180° |
等有限视场。
4.2 水平角分辨率 / angle increment
假设一圈 360° 采样 360 个方向,可以近似理解成:
1 | 水平角间隔约 1° |
如果采样 720 个方向,则约为:
1 | 0.5° |
角间隔越小,相邻射线越密,但点数、转速、带宽和处理量也会上升。
4.3 垂直视场角 Vertical FOV
3D 雷达还需要描述向上、向下能看到多大的角度范围。例如:
1 | -15° ~ +15° |
表示总垂直视场约 30°。
4.4 垂直通道角 / elevation angle
16 线、32 线雷达的每一条“线”通常对应一个具体的垂直俯仰角。
因此一个 3D 点至少可以从:
1 | 水平角 azimuth |
计算到笛卡尔坐标:
1 | x, y, z |
后面第 19~21 章进入 PointCloud2 与 PCL 时,再详细处理这些三维点的数据布局、字段访问、TF 转换和滤波。本章只把 3D 雷达接回定位主线。
5. ROS1 中二维激光为什么用 sensor_msgs/LaserScan
ROS1 对平面激光扫描定义了:
1 | sensor_msgs/LaserScan |
最重要的字段是:
1 | std_msgs/Header header |
它描述的不是图片,而是一组按固定角度顺序排列的测距结果。
5.1 angle_min / angle_max / angle_increment
假设:
1 | angle_min = -π |
那么:
1 | ranges[0] -> -180° |
第 i 个点对应角度近似为:
1 | angle_i = angle_min + i * angle_increment |
射线数量通常满足近似关系:
1 | N ≈ floor((angle_max - angle_min) / angle_increment) + 1 |
具体驱动必须以消息中的实际数组长度和角参数为准,不要仅凭产品宣传中的“360°”反推出数组大小。
5.2 ranges[]
ranges[i] 是第 i 个角度方向测得的距离,单位为米。
例如:
1 | angle = 0 rad |
表示:
在
header.frame_id所表示的雷达坐标系中,沿 +X 方向大约 2 m 处检测到回波目标。
标准消息约定低于 range_min 或高于 range_max 的距离不应作为有效测量使用。
5.3 intensities[]
某些雷达还能给出回波强度:
1 | intensities[] |
它可以和物体反射特性、入射角、距离以及设备内部处理有关,但单位和标定通常是设备相关的,不能把不同厂商的强度值直接当成统一物理量比较。
设备不提供强度时,该数组可以为空。
5.4 header.stamp 与 time_increment
这一点对真正的 SLAM 很重要。
LaserScan 约定 header.stamp 对应扫描第一条射线的采集时间;真实旋转雷达的一整圈扫描并不是物理上完全同时完成的,因此还存在:
1 | time_increment |
机器人如果边移动边扫描,不同射线实际对应的机器人姿态可能不同。更高精度的系统会利用时间戳进行运动补偿或 deskew。
本章教学仿真器为了把第一条成功路径保持简单,把一整帧 /scan 视为同一时刻,因此:
1 | time_increment = 0 |
这只是教学模型,不代表真实旋转雷达应该固定填 0。
6. 3D 雷达为什么通常不再用 LaserScan
LaserScan 天然表达的是:
1 | 一个平面 |
多线 3D 雷达最终得到的是大量三维点,因此 ROS 中常见输出是:
1 | sensor_msgs/PointCloud2 |
其核心可以理解成:
1 | header |
真正的点内容由 fields 描述。常见字段可能包括:
1 | x |
某些 3D LiDAR Driver 还会增加:
1 | ring |
这些额外字段不是所有 PointCloud2 都强制存在,必须以具体 Driver 的 fields 为准。
因此可以先记住:
1 | 2D LiDAR |
二者的数据格式不同,但进入定位系统以后仍然会反复遇到同样的三件事:
1 | 数据在哪个 frame? |
7. 为什么只有 /scan 还不能得到地图
假设某一帧激光看到:
1 | 前方 2.0 m 有墙 |
这只是在 laser_link 中描述环境。
如果下一秒机器人向前走了 0.5 m,又得到一帧扫描,那么要把两帧数据放在同一张地图上,就必须知道:
1 | 第一帧扫描发生时机器人在哪里? |
所以真正进入 SLAM 时,最少要同时理解三类输入:
1 | /scan |
第 14 章已经由 URDF / robot_state_publisher 提供:
1 | base_link -> laser_link |
第 15 章已经由 robot_localization 提供:
1 | odom -> base_link |
现在第 16 章只需要把 /scan 接进来。
8. frame_id 为什么不是一个随便填写的字符串
本章教学 /scan 使用:
1 | header.frame_id = laser_link |
这意味着一条距离数据首先存在于 laser_link 坐标系。
例如:
1 | ranges[i] = 2.0 m |
在二维平面中可以转换成雷达坐标系点:
1 | x_laser = 2.0 * cos(30°) |
但 SLAM 不能停在这里,因为它还需要把这个点与机器人运动联系起来:
1 | laser_link |
如果真实雷达安装在机器人前方 25 cm,而 TF 错误写成机器人原点,所有激光点都会系统性错位。Topic 即使持续有数据,地图仍可能扭曲。
因此对于激光问题,排查顺序不能只看:
1 | rostopic echo /scan |
还必须同时看 TF 与时间。
9. SLAM 是什么:地图未知时,同时解决定位与建图
SLAM 全称:
1 | Simultaneous Localization and Mapping |
即:
1 | 同时定位与建图 |
这里有一个天然的“鸡生蛋”问题:
1 | 没有地图 -> 很难知道机器人全局位置 |
SLAM 的核心任务就是在机器人运动和持续观测过程中,同时估计:
1 | 机器人轨迹 |
本章使用 ROS1 Noetic 中经典的 slam_gmapping 作为教学实现。它消费平面激光扫描,并通过 odom/TF 获得运动先验,再进行扫描匹配和粒子式 SLAM;这里不展开算法内部推导。
从 ROS 数据链看,更重要的是:
1 | graph LR |
因此建图阶段真正发生的是:
1 | /scan + odom + TF |
10. /map 和 map frame 是两件完全不同的东西
这是进入 Navigation 前必须彻底分开的概念。
10.1 /map 是 Topic
常见类型:
1 | nav_msgs/OccupancyGrid |
它描述一张二维栅格地图,包括:
1 | resolution |
常见语义为:
1 | -1 unknown |
它回答的是:
环境中哪些网格是自由空间、障碍物或未知区域?
10.2 map 是 TF frame
map frame 回答的是:
全局地图使用哪个坐标参考系?
所以:
1 | /map 地图数据 |
三者不能混为一谈。
11. 为什么还要有 map -> odom
REP-105 对移动机器人坐标系的核心设计是:
1 | map -> odom -> base_link |
其中:
1 | odom |
假设轮式里程计认为机器人已经走到:
1 | x_odom = 9.7 m |
而激光与全局地图匹配后判断机器人实际应该在:
1 | x_map = 10.0 m |
不应该直接把连续的 odom -> base_link 从 9.7 m 瞬间改成 10.0 m。否则依赖局部连续运动的控制器会看到突跳。
更合理的分层是:
1 | map |
变换关系可以写成:
1 | T_map_base = T_map_odom * T_odom_base |
因此全局定位器可以求:
1 | T_map_odom = T_map_base * inverse(T_odom_base) |
也就是让:
1 | map -> odom |
吸收“全局判断”和“局部里程计”之间的差值。
这就是本章最重要的 TF ownership。
12. 本章实验为什么故意制造 3% 里程计尺度误差
如果激光观测和里程计都完全理想:
1 | 真实走 1.00 m |
那么 map -> odom 很可能长期非常接近单位变换,不容易直观看到它在“修正什么”。
因此 ros1_slam_lab 做了一个只用于教学的可控误差:
1 | truth_linear_scale: 1.03 |
含义是:
1 | /cmd_vel 要求 1.00 m 的名义运动 |
于是机器人走得越远,激光看到的环境和 odom 预测之间差异越明显。
SLAM 或 AMCL 根据环境观测纠正全局位姿时,就会逐渐把差值反映到:
1 | map -> odom |
把参数改成:
1 | truth_linear_scale:=1.0 |
即可得到一个近似无尺度漂移的对照组。
13. 先看 ros1_slam_lab 的最小运行结构
新增目录:
1 | ros_ws/src/ros1_slam_lab/ |
其中职责是:
1 | ros1_driver_lab |
simple_laser_world.py 不模拟串口、UDP、厂商 packet 或真实雷达协议。它只承担本章需要的一个职责:
根据一个确定的二维墙体几何环境生成符合
sensor_msgs/LaserScan数据契约的/scan。
真实项目中,它的位置应该被真实 LiDAR Driver 替换。
14. 更新 Container:安装 GMapping、AMCL、map_server、RViz 与 teleop
Dockerfile 已增加:
1 | ros-noetic-slam-gmapping |
由于 Dockerfile 发生变化,需要在 Host 重新构建镜像:
1 | cd /home/wdfk/share/ros1-docker |
进入 Container:
1 | docker compose exec ros1-dev bash |
然后构建新增 package:
1 | cd /workspace/ros_ws |
这些构建命令属于本章需要在实际 ROS Container 中执行的操作;当前仓库修改本身不等于已经完成编译验证。
15. 第一次观察 /scan:先不急着看地图
启动建图实验:
1 | roslaunch ros1_slam_lab slam_mapping.launch |
另开一个 Container 终端:
1 | source /workspace/ros_ws/devel/setup.bash |
预期:
1 | sensor_msgs/LaserScan |
查看消息定义:
1 | rosmsg show sensor_msgs/LaserScan |
只取一帧:
1 | rostopic echo -n 1 /scan |
重点观察:
1 | header.frame_id: laser_link |
本实验默认:
1 | beam_count = 360 |
所以可以把它理解成:
每 0.1 s 发布一帧;每帧大约围绕机器人扫描一整圈,包含 360 个水平方向的距离结果。
16. 再看 TF:一帧激光是怎样找到机器人位置的
查看局部位姿:
1 | rosrun tf tf_echo odom base_link |
再查看雷达安装关系:
1 | rosrun tf tf_echo base_link laser_link |
第 14 章的 URDF 定义雷达大致位于:
1 | base_link 前方 0.25 m |
二维扫描本身主要使用平面位置和 yaw,但完整 TF 仍然保留三维安装关系。
现在 SLAM 可以在扫描对应时间建立:
1 | odom -> base_link -> laser_link |
于是 /scan 中的极坐标点才能被变换到机器人运动参考系中。
17. 让机器人移动,观察 GMapping 接管 map -> odom
另开终端运行键盘控制:
1 | rosrun teleop_twist_keyboard teleop_twist_keyboard.py |
让机器人在环境中缓慢前进、转弯,不要长期顶着边界墙。
再开一个终端查看:
1 | rosrun tf tf_echo map odom |
同时可以看:
1 | rostopic info /map |
此时 ownership 应该是:
| 数据 / TF | owner |
|---|---|
/odom/raw |
ros1_driver_lab |
odom -> base_link |
robot_localization |
base_link -> laser_link |
robot_state_publisher |
/scan |
simple_laser_world |
/map |
slam_gmapping |
map -> odom |
slam_gmapping |
注意:
GMapping 不应该重新接管
odom -> base_link。它利用这条连续局部运动链,再通过自己的全局估计补上map -> odom。
18. 在 RViz 中把“数据”和“坐标系”同时看见
Container 已有 X11 显示通路,可以启动:
1 | rviz |
把:
1 | Fixed Frame |
设为:
1 | map |
然后添加:
1 | Map -> /map |
这里最值得观察的不是界面效果,而是三种信息处在不同层:
1 | Map |
如果 /scan 有数据但 RViz 中不显示在正确位置,优先检查:
1 | Fixed Frame |
而不是马上怀疑 SLAM 算法。
19. 保存建好的地图:保存的是地图数据,不是 TF
走完一部分环境后,可以保存 /map:
1 | mkdir -p /workspace/ros_ws/src/ros1_slam_lab/maps/generated |
会生成类似:
1 | my_map.pgm |
其中 YAML 通常描述:
1 | image |
PGM 保存地图图像;YAML 告诉 map_server 如何把图像解释成 ROS 栅格地图。
这里必须注意:
保存地图并没有保存一条永久的
map -> odom。
下一次启动时,机器人仍然需要重新确定自己在这张地图中的位置,这就是 AMCL 要解决的问题。
20. AMCL 是什么:地图已经存在,只做定位
AMCL 全称:
1 | Adaptive Monte Carlo Localization |
常译为:
1 | 自适应蒙特卡洛定位 |
和 SLAM 最大的任务区别是:
1 | SLAM |
AMCL 的直观思路可以先理解成:维护许多可能的机器人位姿假设,也就是粒子。
假设已有地图,同时当前 /scan 看到:
1 | 前方墙约 2.0 m |
AMCL 会比较:
1 | 如果机器人在位置 A,地图预测的激光观测像不像当前 /scan? |
更符合实际观测的位姿假设会获得更高权重,粒子分布逐步集中到更可信的位置附近。
本章不继续推导重要性采样、KLD sampling 和 measurement model,只保留数据流:
1 | graph LR |
21. map_server 负责什么,它不负责什么
停止 GMapping 后,已有地图通常由:
1 | map_server |
加载。
它读取:
1 | map.yaml |
然后提供:
1 | /map |
它的职责是提供静态地图数据。
它不会根据激光定位机器人,也不会因为 /map 已经存在就自动发布:
1 | map -> odom |
所以已有地图定位阶段是两个组件协作:
1 | map_server |
22. 运行已有地图定位实验
先停止前面的 GMapping launch,确保系统中不再有另一个 map -> odom owner。
然后启动:
1 | roslaunch ros1_slam_lab amcl_localization.launch |
本仓库附带一张和教学墙体环境一致的:
1 | maps/lab_map.yaml |
默认初始位姿是:
1 | x = 0 |
与教学环境内部初始状态一致,因此第一次运行不要求先手工设置 2D Pose Estimate。
如果要使用前面 GMapping 自己保存的地图:
1 | roslaunch ros1_slam_lab amcl_localization.launch \ |
此时必须确保保存地图时的环境几何与当前激光仿真环境一致;否则 AMCL 会拿错误地图解释当前 /scan。
23. 看 AMCL 的输出:/amcl_pose 和 map -> odom 分别表达什么
查看 AMCL 位姿:
1 | rostopic echo /amcl_pose |
常见类型:
1 | geometry_msgs/PoseWithCovarianceStamped |
这里表达的是:
AMCL 对机器人在全局
mapframe 中位姿的估计及其不确定性。
再看 TF:
1 | rosrun tf tf_echo map odom |
这条变换是 AMCL 为了把全局定位结果接到连续 odom 链上而发布的修正。
完整关系仍然是:
1 | map -> odom -> base_link -> laser_link |
所以 AMCL 并没有替代第 15 章的状态估计器。
二者职责不同:
1 | robot_localization |
24. 为什么 AMCL 不直接发布 map -> base_link
假设某一时刻:
1 | odom -> base_link |
给出的局部位姿是:
1 | x = 9.7 m |
AMCL 根据地图与激光判断机器人全局位姿应该是:
1 | map 中 x = 10.0 m |
如果直接用 AMCL 强行覆盖 odom -> base_link,就破坏了 odom 连续性的职责边界。
ROS 的分层做法是保留:
1 | odom -> base_link = 连续局部运动 |
再求一个:
1 | map -> odom = 全局校正 |
使组合结果:
1 | map -> odom -> base_link |
与 AMCL 的全局判断一致。
因此 map -> odom 可以理解成:
“全局世界认为 odom 原点现在应该放在哪里”的修正量。
这也是为什么全局重定位或闭环发生时,map 层可以变化,而底层控制仍继续使用平滑的 odom 运动。
25. 建图和已有地图定位终于可以放在同一条链中理解
现在两种运行模式只需要看 owner 变化。
25.1 建图阶段
1 | /scan + odom + TF |
25.2 已有地图定位阶段
1 | map_server -> /map ----\ |
最核心的区别不是“有没有激光”,因为两者都使用激光。
而是:
1 | SLAM |
26. 把 2D 经验扩展到 3D:主干关系没有消失
换成 3D 多线 LiDAR 后,传感器输出常从:
1 | LaserScan |
变成:
1 | PointCloud2 |
环境表示也可能从二维:
1 | OccupancyGrid |
变成:
1 | 3D point cloud |
具体 SLAM/Localization 算法也会变化,但系统工程上仍然绕不开:
1 | LiDAR observation |
也就是说,从二维升级到三维以后,不应该丢掉本章建立的三个问题:
1 | 1. 点在哪个传感器 frame? |
3D 点云数据量更大,扫描过程中运动畸变通常也更明显,因此时间同步、IMU、deskew 和外参标定的重要性只会提高。
本章只建立这条迁移关系。PointCloud2 二进制布局、organized/unorganized cloud、PCL 类型转换与滤波仍留在第 19~21 章系统展开,避免把 SLAM 主线变成点云接口百科。
27. 最终应该形成的模块 ownership
第 12~16 章组合后,可以得到一张稳定的职责表:
| 模块 | 主要输出 | 不应该顺手接管的职责 |
|---|---|---|
| Chassis Driver | /odom/raw、/imu/data_raw、/joint_states |
不负责 map -> odom |
robot_localization |
/odometry/filtered、odom -> base_link |
不负责建图 |
robot_state_publisher |
base_link -> laser_link / imu_link / ... |
不负责全局定位 |
| LiDAR Driver / 本章教学仿真器 | /scan 或 3D 场景中的点云 |
不负责机器人全局位姿 |
slam_gmapping |
/map、map -> odom |
不替代连续 odom owner |
map_server |
静态 /map |
不定位,不发布 map -> odom |
| AMCL | /amcl_pose、map -> odom |
不重新建图 |
因此最终 TF 主链仍是:
1 | map |
map -> odom 的 owner 在同一个运行模式中应该只有一个:
1 | 建图:slam_gmapping |
不要同时启动两者并让它们竞争同一条 TF。
28. 从这一章进入 Navigation
到这里,Navigation 的关键前置链已经完整:
1 | /map |
下一章进入 move_base 后,重点会从“地图和定位是谁提供的”转向:
1 | localization |
如果第 16 章中的 /scan、时间、TF 或 map -> odom ownership 有问题,错误会继续向 costmap 和 planner 传播。因此在进入规划之前,先把这条定位基础链建立正确。
参考资料
- ROS
sensor_msgs/LaserScan:https://docs.ros.org/en/noetic/api/sensor_msgs/html/msg/LaserScan.html - ROS
sensor_msgs/PointCloud2:https://github.com/ros/common_msgs/blob/noetic-devel/sensor_msgs/msg/PointCloud2.msg - REP-105 Coordinate Frames for Mobile Platforms:https://reps.openrobotics.org/rep-0105/
- ROS1 Noetic
slam_gmappingAPI:https://docs.ros.org/en/noetic/api/gmapping/html/index.html - ROS1 Navigation
amclNoetic 源码:https://github.com/ros-planning/navigation/blob/noetic-devel/amcl/src/amcl_node.cpp - ROS1 Navigation
map_serverNoetic 源码:https://github.com/ros-planning/navigation/blob/noetic-devel/map_server/src/main.cpp









