1. 问题来了:Gazebo里机器人突然“瞎了”

那天下午,我正在调试一个基于Nav2的移动机器人仿真项目。机器人模型是自己用URDF搭的,为了让它在仓库环境里更好地导航,我把一个2D激光雷达装在了机器人本体的顶部,离地大概1.8米高,想着这样视野能更好,不容易被低矮的障碍物遮挡。

一切准备就绪,启动Gazebo,加载地图,启动Nav2的所有节点。看着RVIZ2里机器人模型加载出来,心里正美呢,准备发个目标点让它跑起来。结果,命令刚发出去,终端里就开始疯狂刷黄字警告。机器人呢?在RVIZ2里纹丝不动,就像突然“瞎了”一样,对周围环境没有任何感知。

我赶紧切到终端,看到了这样几行让人头疼的日志:

[controller_server-4] [WARN] [1730095240.480178142] [local_costmap.local_costmap]: Sensor origin at (5.97, -1.95, 1.88) is out of map bounds (8.95, 1.03, 2.65) to (3.30, -3.45, 0.00). The costmap cannot raytrace for it.
[planner_server-5] [WARN] [1730095240.852686849] [global_costmap.global_costmap]: Sensor origin at (5.92, -2.09, 1.88) is out of map bounds (25.30, 10.63, 2.65) to (-3.35, -9.45, 0.00). The costmap cannot raytrace for it.

这警告信息看着挺唬人,又是“out of map bounds”,又是“cannot raytrace”。翻译成大白话就是:你传感器的“老家”(也就是安装原点)坐标,跑出了我代价地图的“地盘”,所以我没法为你做光线追踪了。这个“光线追踪”(raytrace)是啥?你可以把它想象成传感器(比如激光雷达)发出一束束“光线”去探测障碍物,然后根据光线碰到东西的距离,在代价地图里标记出哪里是自由的(可通行),哪里是占据的(障碍物)。现在Nav2直接告诉我这功能罢工了,难怪机器人感知不到环境。

我当时第一反应是地图尺寸或者原点设错了?但仔细看警告信息里的数字,(5.97, -1.95, 1.88) 这个传感器坐标,看起来是个合理的三维位置。而报错说的地图边界,比如局部代价地图是 (8.95, 1.03, 2.65) 到 (3.30, -3.45, 0.00)。等等,这里有个细节:通常我们说的二维地图边界是 (min_x, min_y) 到 (max_x, max_y),但这里出现了三个数字,明显是三维的 (x, y, z)。而且,1.88 这个传感器的Z坐标,似乎并不在 2.65 和 0.00 这个区间内?问题很可能就出在这个平时不太关注的 Z轴,也就是高度上。

2. 深入虎穴:追踪源码,揪出边界检查逻辑

遇到这种底层警告,光靠猜参数是没用的,必须得去看看Nav2到底是怎么算的。警告信息里给了线索,它来自 voxel_layer.cpp 文件里的 raytraceFreespace() 函数。这个文件是Nav2代价地图插件里处理三维体素(可以理解为三维像素)的核心。

我找到Nav2的源代码(通常在 /opt/ros/humble/share/nav2_costmap_2d/src 下或者从GitHub仓库拉取),翻到了 voxel_layer.cpp。在 raytraceFreespace 函数里,果然找到了打印这行警告的代码段:

if (!worldToMap3DFloat(ox, oy, oz, sensor_x, sensor_y, sensor_z)) {
  RCLCPP_WARN(
    logger_,
    "Sensor origin at (%.2f, %.2f %.2f) is out of map bounds (%.2f, %.2f, %.2f) to (%.2f, %.2f, %.2f). "
    "The costmap cannot raytrace for it.",
    ox, oy, oz,
    ox + getSizeInMetersX(), oy + getSizeInMetersY(), oz + getSizeInMetersZ(),
    origin_x_, origin_y_, origin_z_);
  return;
}

这段代码是问题的核心。它的逻辑是这样的:

  1. 函数首先拿到传感器在世界坐标系下的原点坐标 (ox, oy, oz)。
  2. 然后调用 worldToMap3DFloat 函数,尝试把这个世界坐标转换到代价地图的“体素网格”坐标。
  3. 如果转换失败(函数返回 false),就意味着传感器的原点落在了代价地图定义的三维边界之外。
  4. 一旦越界,就打印我们看到的那个警告,并且直接 return,放弃对这次传感器数据的处理。这就是机器人“失明”的直接原因。

那么,这个三维边界到底是怎么定义的呢?警告信息里拼接了两部分:

  • (ox + getSizeInMetersX(), oy + getSizeInMetersY(), oz + getSizeInMetersZ()):这代表了地图边界在X, Y, Z方向上的最大值。
  • (origin_x_, origin_y_, origin_z_):这代表了地图边界在X, Y, Z方向上的最小值。

所以,对于一个传感器坐标 (sx, sy, sz) 来说,它必须满足:

origin_x_ <= sx <= origin_x_ + size_x
origin_y_ <= sy <= origin_y_ + size_y
origin_z_ <= sz <= origin_z_ + size_z

三个条件必须同时满足,worldToMap3DFloat 才会成功。我的问题就出在 sz,也就是传感器的高度 oz,没有满足Z轴的条件。

3. 关键参数解析:YAML文件里的三维“盒子”

源码告诉我们,边界由 origin_ 和 size_ 决定。那这些值是从哪来的呢?毫无疑问,来自我们的配置文件,通常是 nav2_params.yaml 里关于 local_costmap 和 global_costmap 的插件参数部分。

对于使用 voxel_layer(体素层)的代价地图,我们需要关注这几个关键参数:

local_costmap:
  local_costmap:
    ros__parameters:
      plugins: ["voxel_layer"]
      voxel_layer:
        plugin: "nav2_costmap_2d::VoxelLayer"
        enabled: True
        origin_z: 0.0      # 关键参数:代价地图Z轴起始高度(米)
        z_resolution: 0.1  # 关键参数:Z轴方向每个体素的高度(米)
        z_voxels: 8        # 关键参数:Z轴方向体素的数量
        # ... 其他参数如footprint, inflation_radius等

这三个参数 origin_z、z_resolution 和 z_voxels,共同定义了一个在Z轴上的“观察窗口”或者说一个三维的“薄层”。我们可以用一个简单的公式来理解这个窗口的范围:

Z轴有效范围 = [origin_z, origin_z + z_resolution * z_voxels]

拿上面的默认参数算一下:origin_z 是 0.0 米(地面),z_resolution 是 0.1 米,z_voxels 是 8。那么Z轴的范围就是 [0.0, 0.0 + 0.1 * 8] = [0.0, 0.8] 米。

这意味着什么?意味着默认情况下,Nav2的体素代价地图只关心从地面(0米)到离地0.8米高这个空间范围内的障碍物信息。它假设你的主要传感器(如激光雷达)探测到的点云都落在这个高度区间内。

现在回头看我的问题:我把激光雷达装在了离地1.8米的高度。传感器自身的原点(也就是它发射光束的参考点)高度就是1.88米(警告信息里的 oz)。这个高度远远超出了默认的 [0.0, 0.8] 米范围。因此,在 raytraceFreespace 函数进行 worldToMap3DFloat 转换时,Z坐标检查失败,直接触发警告并退出,导致整个代价地图无法用这个激光雷达的数据来更新。机器人自然就“瞎”了。

4. 实战校准:两种场景的调整策略

找到根因,解决起来就有方向了。核心思路就是调整那三个Z轴参数,让传感器的原点坐标落在定义的三维边界内。这里我总结两种典型场景的调整策略。

4.1 场景一:单个传感器高位安装

这是我的情况,也是很多机器人(尤其是AGV、服务机器人)的常见情况。为了获得更好的视野,避免机身或底层结构遮挡,我们常常把激光雷达装在机器人顶部。

调整目标:确保 origin_z <= 传感器安装高度 <= origin_z + z_resolution * z_voxels。

具体操作:

  1. 确定传感器高度:在URDF或Gazebo模型里,找到激光雷达连杆(link)相对于机器人基座(base_link)的安装高度。比如我的雷达安装在 base_link 上方1.8米处。
  2. 调整参数:这里最直接的方法是调整 origin_z。因为 z_resolution 和 z_voxels 决定了这个“观察窗口”的厚度(z_resolution * z_voxels)。我们通常希望保持这个厚度在一个合理的值(比如1-2米),以覆盖机器人自身高度和可能探测到的障碍物高度。
    • 假设我想保持窗口厚度为1.6米(z_resolution=0.1, z_voxels=16)。
    • 我的传感器高度是1.88米。为了让1.88米落在窗口内,我可以设置 origin_z = 1.88 - 0.8 = 1.08 米(让传感器位于窗口上半部分)。更稳妥一点,设置 origin_z = 1.0 米。
    • 最终参数可以是:
      origin_z: 1.0      # Z轴起始高度设为1.0米
      z_resolution: 0.1  # 保持分辨率
      z_voxels: 16       # 增加体素数,使总高度覆盖 1.0 ~ 2.6米
      
    这样,Z轴范围就是 [1.0, 2.6] 米,完美包含了我的传感器高度1.88米。

修改后的效果:重启Nav2节点后,那个烦人的警告消失了。在RVIZ2里,给机器人发送目标点,它终于能“看见”周围的障碍物并规划路径了。你可以通过RVIZ2的VoxelGrid显示来直观看到这个三维代价地图的更新,点云数据被正确地转换并融入了地图。

4.2 场景二:多传感器高度不一

更复杂一点的情况是,机器上装了多个用于导航的传感器,而且高度不同。比如:

  • 一个用于避障的短距激光雷达,安装在底盘前方,高度0.2米。
  • 一个用于建图和全局定位的长距激光雷达,安装在顶部,高度1.8米。
  • 还可能有一些深度相机,安装在不同高度。

调整目标:让代价地图的Z轴范围能够覆盖所有需要被voxel_layer处理的传感器的安装高度。

具体操作:

  1. 列出所有传感器高度:收集所有通过 voxel_layer 插件订阅的传感器(在YAML中配置的)的安装高度。假设我们有三个高度:0.2米, 0.8米, 1.8米。
  2. 确定覆盖范围:找到最低高度(min_z = 0.2)和最高高度(max_z = 1.8)。
  3. 调整 z_voxels 和 origin_z:这次我们主要调整 z_voxels 来扩大窗口的厚度,同时可能微调 origin_z。
    • 首先决定 z_resolution。分辨率越高(值越小),三维地图越精细,但计算量也越大。0.1米是一个常用的平衡值。
    • 计算需要的总厚度:max_z - min_z = 1.6米。为了留有余地,我们让范围再宽松一点,比如覆盖 0.0 到 2.0米,厚度就是2.0米。
    • 计算需要的 z_voxels:z_voxels = 厚度 / z_resolution = 2.0 / 0.1 = 20。
    • 设置 origin_z 为想要的最低边界,比如 0.0。
    • 最终参数:
      origin_z: 0.0      # 从地面开始
      z_resolution: 0.1  # 保持0.1米分辨率
      z_voxels: 20       # 覆盖0.0 ~ 2.0米的高度范围
      
  4. 权衡与注意:增大 z_voxels 会直接增加 voxel_layer 的内存占用和计算量,因为需要维护一个更大的三维网格。如果机器人计算资源有限,需要仔细评估。一个优化思路是,如果某个极高或极低的传感器数据对导航不是必须的,可以考虑在YAML配置中不通过 voxel_layer 来订阅它,或者使用多个代价地图层分别处理不同高度区间的传感器。

5. 避坑指南与高级调试

解决了基本问题,在实际项目中可能还会遇到一些衍生情况,这里分享几个我踩过的坑和调试技巧。

坑一:只改了一个代价地图 Nav2通常有 local_costmap 和 global_costmap 两个独立的代价地图实例。它们有各自独立的插件参数。如果你只在 local_costmap 的 voxel_layer 里修改了Z轴参数,而忘了改 global_costmap 的,那么全局路径规划可能依然会因传感器越界而失败。务必检查并同步修改两个配置。

坑二:动态参数配置 有时我们会在启动后动态调整机器人的传感器配置。Nav2支持ROS2的参数动态重配置。你可以通过 rqt_reconfigure 图形界面工具,或者在代码里动态设置 origin_z、z_voxels 等参数,而无需重启节点。这对于调试和适应不同任务场景非常有用。

坑三:raytrace与mark/clear区域 raytraceFreespace 只是 voxel_layer 功能的一部分。它负责处理传感器原点与每个数据点之间的“光线”,将其途径的体素标记为自由空间。除此之外,传感器数据点本身所在的体素会被 mark(标记为障碍物),而一些过滤后的区域可能被 clear(清除标记)。传感器原点越界会导致raytrace失效,但如果原点在范围内,而某些数据点的高度超出了Z边界,那么这些点将不会被加入代价地图。这意味着你可能丢失高处或低处的障碍物信息。调试时,除了看警告,一定要在RVIZ2中可视化 VoxelGrid,观察三维障碍物是否被正确标记。

高级调试:打印关键变量 如果你对参数调整后的效果不确定,或者想更深入了解内部状态,可以临时修改 voxel_layer.cpp 代码,添加一些调试打印。比如,在 raytraceFreespace 函数开头,打印出计算出的地图边界:

RCLCPP_INFO(logger_, "Map Z bounds: [%.2f, %.2f]", origin_z_, origin_z_ + getSizeInMetersZ());
RCLCPP_INFO(logger_, "Sensor origin Z: %.2f", oz);

重新编译并运行,就能在终端里清晰地看到当前地图的Z轴范围和你传感器的高度,比对起来一目了然。这比单纯看警告信息更直观。

调整完参数,解决了传感器越界问题,你的Nav2机器人应该就能重新获得“视力”了。这个过程本质上是对代价地图三维工作空间的一次校准。它提醒我们,在三维空间中工作的机器人,其感知模块的配置也必须具备三维思维。不仅仅是X, Y地图的尺寸和原点,那个常常被忽略的Z轴,恰恰是连接真实的物理传感器安装与抽象的代价地图表示之间的关键桥梁。下次当你把雷达装得更高,或者添加一个新高度的视觉传感器时,别忘了回来看看这三个参数:origin_z, z_resolution, z_voxels,它们共同守护着机器人感知世界的“窗口”。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐