4. sensor_msgs/msg/LaserScan 详解:消息结构、字段含义与坐标系定义

做激光雷达驱动,绕不开这个核心消息类型。说白了,sensor_msgs/msg/LaserScan 就是 ROS2 里描述单线激光雷达数据的标准格式。不管你用的是 RPLIDAR、YDLIDAR 还是思岚的雷达,最终发布出来的数据都得装进这个结构体里。

我个人习惯,拿到一个新雷达,第一件事就是打印它的 LaserScan 消息,看看各个字段到底填了什么。这比看任何文档都直观。

4.1 消息结构总览

先看完整的消息定义。ROS2 里用 IDL 语言描述,但结构很清晰:

# 单线激光雷达扫描数据
std_msgs/Header header          # 时间戳和坐标系ID
float32 angle_min               # 扫描起始角度 [rad]
float32 angle_max               # 扫描结束角度 [rad]
float32 angle_increment         # 角度分辨率 [rad]
float32 time_increment          # 每束激光的时间间隔 [s]
float32 scan_time               # 整圈扫描时间 [s]
float32 range_min               # 最小有效距离 [m]
float32 range_max               # 最大有效距离 [m]
float32[] ranges                # 距离数据数组 [m]
float32[] intensities           # 强度数据数组(可选)

嗯,这里要注意:ranges 数组的长度不是随便定的。它由 angle_min、angle_max 和 angle_increment 共同决定。公式很简单:

len(ranges) = (angle_max - angle_min) / angle_increment + 1

我曾经调试一个雷达,发现数据总是少一束,最后查出来是 angle_increment 算错了,导致数组长度对不上。这种坑,踩过一次就记住了。

4.2 核心字段详解

4.2.1 header:时间戳与坐标系

header 是 ROS 消息的标配。两个关键子字段:

  • stamp:时间戳。表示这帧数据是什么时候采集的。我建议用雷达硬件触发的时间,而不是接收到的系统时间,否则里程计融合时会出问题。
  • frame_id:坐标系名称。通常设为 "laser_frame" 或 "base_laser"。这个字符串决定了后续 TF 变换怎么找。
常见错误:frame_id 写错或漏写。很多新手把 frame_id 写成空字符串,结果 rviz 里看不到任何数据。记住,frame_id 必须和 TF 树里的坐标系名称完全一致。

4.2.2 角度相关字段

字段 含义 典型值
angle_min 扫描起始角度,单位弧度 0.0
angle_max 扫描结束角度,单位弧度 6.28319 (360°)
angle_increment 相邻两束激光的角度差 0.01745 (1°)

你想想看,为什么 angle_min 和 angle_max 是浮点数而不是整数?因为雷达的扫描范围不一定是整圆。有些雷达只扫 270°,那 angle_min 就是 -2.35619,angle_max 是 2.35619。

我个人习惯把角度统一转成度数来调试,毕竟人脑对度数更敏感。写个简单的转换函数:

def rad2deg(rad):
    return rad * 180.0 / 3.1415926

print(f"扫描范围: {rad2deg(angle_min):.1f}° ~ {rad2deg(angle_max):.1f}°")
print(f"角度分辨率: {rad2deg(angle_increment):.3f}°")

4.2.3 时间相关字段

time_increment 和 scan_time 这两个字段,很多人搞混。我解释一下:

  • time_increment:相邻两束激光的时间差。如果雷达是旋转扫描的,这个值就是电机转一个角度步进所需的时间。
  • scan_time:整帧扫描的总耗时。理论上等于 time_increment * 点数,但实际会有偏差。

为什么要区分这两个?因为有些算法需要知道每束激光的精确时间戳,比如去畸变处理。如果雷达转速不均匀,time_increment 就不是常数,这时候就得用更精细的时间模型。

4.2.4 距离与强度字段

range_min 和 range_max 定义了有效距离范围。小于 range_min 或大于 range_max 的值,通常用 inf 或 NaN 表示无效。

ranges 数组是核心数据。每个元素对应一个角度上的距离值。索引 i 对应的角度为:

angle_i = angle_min + i * angle_increment

intensities 是可选的。有些雷达能返回反射强度,可以用来区分不同材质的物体。比如金属表面反射强,黑色吸光物体反射弱。如果雷达不支持,这个数组就为空。

调试技巧:在终端里快速查看 LaserScan 数据,可以用 ros2 topic echo /scan --once。但数据太多,我更喜欢用 ros2 topic echo /scan --once --field ranges 只看距离数组。

4.3 坐标系定义

这是最容易出问题的地方。LaserScan 的坐标系遵循 REP 103 标准:

  • X 轴:向前
  • Y 轴:向左
  • Z 轴:向上

角度定义:angle_min 对应 X 轴正方向,逆时针旋转为正。也就是说,0 弧度是正前方,π/2 是正左方,π 是正后方。

我曾经接手一个项目,发现雷达建的地图总是歪的。查了半天,原来是雷达安装时旋转了 90 度,但 frame_id 没改。后来在 URDF 里加了一个固定变换才解决。

4.3.1 坐标系关系图

下面这张图展示了 LaserScan 的坐标系定义和角度映射关系:

LaserScan 坐标系与角度映射 X (前方) Y (左方) O θ angle_min (0 rad) angle_max (2π rad) 点 i 点 i+1 说明:角度从 X 轴正方向开始,逆时针旋转为正。ranges[i] 对应角度 angle_min + i * angle_increment。

4.4 数据索引与角度换算

实际开发中,最常用的操作就是根据角度找距离,或者根据距离找角度。我总结了一个速查表:

操作 公式 示例
索引 → 角度 angle = angle_min + i * angle_increment i=180, angle_min=0, inc=0.01745 → 3.141 rad (180°)
角度 → 索引 i = (angle - angle_min) / angle_increment angle=1.57, angle_min=0, inc=0.01745 → i≈90
有效点数 N = (angle_max - angle_min) / angle_increment + 1 360°扫描, 1°分辨率 → N=361
核心要点:ranges 数组的索引和角度是一一对应的线性关系。理解了这个映射,你就能自由地在极坐标系和笛卡尔坐标系之间转换。

4.5 实战:从 LaserScan 提取前方障碍物距离

讲个实际场景。你想让机器人检测正前方 1 米内有没有障碍物。怎么做?

第一步,找到正前方对应的索引。正前方是 0 弧度,也就是 angle_min 的位置。如果 angle_min 是 0,那索引就是 0。但很多雷达的 angle_min 不是 0,比如 -π,那正前方对应的索引就是中间那个点。

第二步,取一个角度范围,比如 ±5°,避免单点噪声。代码实现:

def get_front_distance(scan_msg, angle_range=0.087):
    """
    获取正前方指定角度范围内的最小距离
    :param scan_msg: LaserScan 消息
    :param angle_range: 搜索范围(弧度),默认 ±5°
    :return: 最小距离(米),无有效数据返回 inf
    """
    # 正前方角度为 0
    target_angle = 0.0
    
    # 计算目标索引
    i_target = int((target_angle - scan_msg.angle_min) / scan_msg.angle_increment)
    
    # 计算搜索范围对应的索引步长
    i_range = int(angle_range / scan_msg.angle_increment)
    
    # 提取范围内的距离值
    start_idx = max(0, i_target - i_range)
    end_idx = min(len(scan_msg.ranges) - 1, i_target + i_range)
    
    # 过滤无效值
    valid_ranges = [r for r in scan_msg.ranges[start_idx:end_idx+1] 
                    if scan_msg.range_min < r < scan_msg.range_max]
    
    if not valid_ranges:
        return float('inf')
    
    return min(valid_ranges)

这段代码我用了好几年,基本没出过问题。唯一要注意的是边界检查——如果雷达扫描范围不包含正前方,那 i_target 可能越界。

4.6 常见避坑指南

最后分享几个我踩过的坑:

  • 角度单位搞混:ROS 里角度一律用弧度。有人习惯用度数,结果把 90 当弧度传进去,雷达直接指向了奇怪的方向。
  • range_min/range_max 设置不合理:如果 range_min 设得太大,近处的障碍物会被过滤掉。我见过有人设成 0.5 米,结果机器人差点撞墙。
  • intensities 为空时强行访问:有些雷达不提供强度数据,intensities 数组长度为 0。代码里要先判断 len(scan.intensities) > 0 再使用。
  • 时间戳用错时钟源:header.stamp 应该用雷达硬件时间,而不是 node.now()。否则数据回放时时间线会乱。