免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

OccupancyGrid的resolution:ROS2导航地图尺度与坐标换算的关键

OccupancyGrid的resolution:ROS2导航地图尺度与坐标换算的关键 做机器人导航的人应该都跟/map这个话题打过交道。之前我在调 Nav2 的时候地图、定位、代价地图看起来都正常但机器人就是会“擦着墙”过甚至有时候在 RViz 里规划出来的路径明显不在可通行区域里。排查了一圈最后发现不是代价地图参数写错也不是定位飘而是我压根没认真看过OccupancyGrid消息里的info.resolution。这个字段平时不起眼但它决定了整张地图在真实世界里的尺度和坐标换算方式一旦和实际环境对不上后面所有导航表现都会跟着出问题。这篇东西就围绕resolution展开我会先讲清楚它到底是什么、从哪里来再用实际代码演示怎么读它、怎么用它做栅格坐标和世界坐标互转最后谈一谈工程上怎么选、怎么和 Nav2 参数配合。不管你是刚开始看 ROS2 地图消息还是已经跑过几个导航 Demo应该都能从里面找到能直接用的东西。1. resolution 不是“清晰度”而是栅格世界的长度单位1.1 消息结构哪里放着 resolutionROS2 里的二维栅格地图统一用nav_msgs/msg/OccupancyGrid表达。它的消息结构不算复杂展开之后大概是这样的std_msgs/Header header nav_msgs/MapMetaData info time map_load_time float32 resolution uint32 width uint32 height geometry_msgs/Pose origin int8[] datawidth和height的单位是“格子数”不是米。data里存的才是每个栅格的状态ROS2 的约定是-1表示未知0表示空闲1到100表示被占用的概率数字越大越可能是障碍物。很多人第一眼看到resolution会下意识地把它当成图片的 DPI 或者“清晰度”这是最容易出问题的地方。resolution根本不是精度或者清晰度它的单位是m/cell也就是“每一个栅格在真实世界里占多少米”。换句话说它是一把尺子把栅格索引和物理世界长度对应起来的尺子。比如resolution 0.05意思是每个栅格代表实际空间中的 5 厘米。那么 1 米的空间长度在地图里就是 20 个栅格。这个解释听起来很简单但一旦你要在代码里做坐标换算或者要调inflation_radius、robot_radius这类参数时就很容易漏掉这层换算关系。1.2 每个栅格到底有多大怎么换算我们拿一张尺寸为width 200, height 160resolution 0.05的地图来说。它的实际物理尺寸是实际宽度 200 × 0.05 10 米实际高度 160 × 0.05 8 米如果把resolution改成0.1但width、height不变那这张地图表示的就变成了 20 米 × 16 米。地图像素文件里每一个点对应的物理面积变大了原本 5 厘米一个格变成 10 厘米一个格。这里有一个很重要的点resolution和data里的占用值是两个独立的概念。resolution只负责“格子到真实长度”的映射它不影响data里某个格子是 0 还是 100。你不能因为resolution调小了就觉得地图障碍物变多了。障碍物分布是data决定的resolution只是决定这些格子在地图坐标系里铺多大面积。还有一个容易混淆的点OccupancyGrid是二维栅格地图。如果你看到话题类型是octomap或者消息里带三维体素那是另一套体系通常是八叉树地图不能拿OccupancyGrid的width * height * resolution去理解。提示resolution是float32类型做比较或者判断的时候不要直接用尤其它可能是从 YAML 文件读进来的或者由前端里程计/位姿估计计算出来最好用abs(a - b) 1e-6这种方式。2. 地图文件、SLAM 和消息resolution 在数据链路里如何接力2.1 map.yaml 里的 resolution 和消息里的 resolution 是同一个值如果你用nav2_map_server加载一张静态地图通常需要提供两个文件.pgm图片和.yaml配置文件。map.yaml里的内容大致是这个样子image: map.pgm resolution: 0.05 origin: [0.0, 0.0, 0.0] negate: 0 occupied_thresh: 0.65 free_thresh: 0.25启动命令一般是ros2 run nav2_map_server map_server --ros-args -p yaml_filename:/path/to/map.yamlmap_server在加载时会把map.yaml中的resolution读出来填到OccupancyGrid.info.resolution里。所以你在ROS2话题里看到的resolution本质上就是 YAML 里那个值两者是一致的。这个链路里有一个常见的坑有人会为了“让地图看起来更清楚”去调大 YAML 里的resolution以为改完了地图就更精细了。但实际上.pgm图片本身的分辨率没有变图片里每个像素对应的物理大小变了而已。假如原始地图是 1000 × 1000 像素resolution 0.05那它表示 50 米 × 50 米的区域如果直接把 YAML 里的resolution改成0.02同样的 1000 × 1000 像素就只表示 20 米 × 20 米的区域并不是地图变精细了而是整个地图的物理尺度被压缩了。机器人如果不知道这个变化导航时会出现明显的尺度偏差。所以调整resolution的正确方式要么是重新建图要么是对原始地图做重采样再配合修改width、height和data。只改 YAML 里的数值是没办法凭空增加地图细节的。2.2 别被 image 像素坐标和地图坐标绕晕.pgm图片有自己的像素坐标系一般左上角是(0,0)x 向右y 向下。但 ROS2 栅格地图里的坐标通常采用“x 向右、y 向上”的地图坐标系data数组则是按行优先排列的从(0,0)这个栅格开始。如果自己写地图加载代码最容易出现的问题就是 y 方向颠倒。map_server在加载图片时会做像素坐标和栅格坐标之间的翻转。所以你在 RViz 里看到的地图方向和OccupancyGrid.data里存的索引方向不一定和图片文件的行列方向直接对应。这个问题和resolution本身无关但会直接影响你手写的坐标换算代码。如果你直接从图片文件读像素、再按同样顺序塞进OccupancyGrid.data又没有考虑翻转那么在 RViz 里地图可能就是上下颠倒的或者机器人在实际环境中明明应该沿着 y 轴走地图上却走反了。我在自己写地图处理工具时习惯先做一步“打印元信息 查看 origin”把所有坐标关系先确定下来再去做像素级操作。千万别一上来就假设行号和地图 y 坐标一一对应。3. 分辨率选错了导航会出现哪些“看起来很奇怪”的现象3.1 全局地图和局部代价地图对不齐Nav2 里通常会同时维护全局代价地图和局部代价地图。global_costmap一般由地图服务器提供local_costmap则由传感器实时更新。它们各自都有resolution参数。如果局部代价地图的resolution和全局地图的resolution不一致且width、height没有按实际尺寸去设置就会出现一个很经典的故障全局地图里看起来没有障碍的地方局部代价地图里却有障碍或者规划路径在全局地图上正常但局部代价地图一刷新路径就被判成不可通行。有人可能会问代价地图不是有坐标变换吗为什么resolution不一致还会出问题因为resolution决定了栅格占用的物理尺寸不同分辨率下同一个障碍物会被膨胀成不同数量的格子。更重要的是局部代价地图的尺寸计算公式是物理尺寸 width × resolution如果你只改了resolution忘了同步改width、height局部代价地图覆盖的物理范围就变了。这样一来即使坐标变换正确局部代价地图和全局代价地图在空间上的“视野范围”也不同叠加起来自然会对不齐。3.2 路径贴墙走、膨胀半径形同虚设inflation_radius和robot_radius在 Nav2 里都是以米为单位的。代价地图内部要把这些半径换算成栅格数量换算公式很简单膨胀影响格子数 inflation_radius / resolution假设inflation_radius 0.5resolution 0.05时需要膨胀 10 个栅格resolution 0.1时只需要膨胀 5 个栅格resolution 0.01时需要膨胀 50 个栅格。如果地图分辨率太粗比如resolution 0.2robot_radius 0.25的机器人可能只占 1.25 个栅格。代价地图在离散化后会很难准确表达机器人的外轮廓路径规划时就会出现“路径贴着墙”甚至机器人模型扫过障碍物的情况。反过来分辨率太细也不是好事。除了地图文件本身变大之外代价地图在每次传感器数据进来时都需要更新障碍层并重新做膨胀计算栅格越多耗时越长。我在低算力板子上把地图从0.05改成0.02后local costmap 的更新频率肉眼可见地掉了一截就是因为同一片区域里的栅格数量变成了原来的 6 倍多。3.3 内存与算力的量级估算我们按一张 100 米 × 100 米的园区地图来估算一下几种分辨率下的数据量resolutionwidth×heightcells原始 data 数组占用0.0110000 × 10000 1亿约 100 MB0.052000 × 2000 400万约 4 MB0.11000 × 1000 100万约 1 MB0.2500 × 500 25万约 0.25 MB注意这只是OccupancyGrid.data一个int8数组的占用。代价地图还有主层、障碍层、膨胀层等多个层每层都可能维护自己的缓存数据。实际内存占用会比上表再多出好几倍。所以在地图比较大的场景里resolution不是一个可以随手填的“好看”数字它直接影响整条导航链路能不能跑得动。4. 代码实操读 resolution、坐标互转、自己发布 OccupancyGrid4.1 从 /map 中快速读取关键元信息写一个简单的 ROS2 Python 节点订阅/map打印info里的几个字段。这个方法非常实用拿到一张地图的第一时间我就用这种方式确认基础信息。#!/usr/bin/env python3 import rclpy from rclpy.node import Node from nav_msgs.msg import OccupancyGrid class MapInfoPrinter(Node): def __init__(self): super().__init__(map_info_printer) self.sub self.create_subscription(OccupancyGrid, /map, self.cb, 10) def cb(self, msg): info msg.info self.get_logger().info( fresolution {info.resolution:.6f} m/cell, fsize {info.width} x {info.height}, forigin ({info.origin.position.x:.3f}, f{info.origin.position.y:.3f}) ) self.get_logger().info(fdata length {len(msg.data)}) rclpy.shutdown() def main(): rclpy.init() node MapInfoPrinter() rclpy.spin(node) if __name__ __main__: main()如果话题里有数据但data length不等于width * height说明发布端的地图构建逻辑有问题或者消息在序列化过程中出现了不匹配。这种情况下就算resolution是对的地图也是不完整的。4.2 栅格坐标与世界坐标的正反换算拿到resolution之后最常用的就是两套换算格子索引转世界坐标世界坐标转格子索引。这里需要先明确一个约定。大多数 ROS 地图实现比如map_server、costmap_2d会把info.origin理解为(0,0)这个栅格中心在地图坐标系下的位姿。所以由栅格索引求世界坐标时要给索引加 0.5表示取栅格中心。def cell_to_world(msg, col, row): ox msg.info.origin.position.x oy msg.info.origin.position.y res msg.info.resolution wx ox (col 0.5) * res wy oy (row 0.5) * res return wx, wy def world_to_cell(msg, wx, wy): ox msg.info.origin.position.x oy msg.info.origin.position.y res msg.info.resolution col int((wx - ox) / res - 0.5) row int((wy - oy) / res - 0.5) return col, rowcell_to_world的用途很直观比如你在data数组里发现索引i是障碍物那么先算出row i // width、col i % width再用它得到这个障碍物在真实世界里的坐标就能发给其他模块做处理。world_to_cell则相反常用于把机器人当前位置、目标点或者某个传感器的检测点转换成栅格索引然后去查这格地图通不通。提示不同来源的地图origin定义可能略有区别。有的实现把origin当作左下角顶点而不是栅格中心。如果你的地图来自自定义发布端务必先确认这个语义。不确定的话找一个环境里已知坐标的柱子或者墙角用公式反算一遍能对上就说明约定没问题。4.3 自定义发布 OccupancyGrid 时最容易犯的错如果你不是为了建图而是想在自己程序里临时生成一张OccupancyGrid发出来最容易犯的错有这么几个。第一忘了设置info.resolution。默认值是 0后续做除法直接就是除以零或者导致所有坐标换算结果都是无穷大。第二data长度和width * height不一致。RViz 拿到这种消息会拒绝显示或者显示成一块残缺的地图。你可以在填充完data后加一句断言assert len(data) width * height, \ fdata length {len(data)} ! {width} * {height}第三header.frame_id没设置。地图消息必须说明自己是在哪个坐标系下否则/map到odom、base_link的坐标变换无法建立。常见做法是把frame_id设成map。第四origin.orientation没设成单位四元数。geometry_msgs/Pose里有位置和姿态如果姿态没有初始化可能是(0,0,0,0)这是一个非法的四元数坐标变换遇到它会直接报错。没有任何旋转时应该设置成x0, y0, z0, w1。5. 实际调参时的几个工程建议5.1 不同场景的参考分辨率resolution没有绝对的对错只有适不适合场景。我根据自己的使用经验给一个粗略的参考区间。场景参考 resolution备注室内小场景、精细抓取/避障0.01 ~ 0.02地图小、精度要求高但注意算力普通室内机器人导航0.052D 激光雷达和 Kinect 类传感器比较常用园区、走廊、室外大场景0.1 ~ 0.2降低地图尺寸和更新开销低算力板子 大范围地图0.1 以上优先保证实时性精度适当妥协传感器精度也是重要参考。普通 2D 激光雷达的测距噪声通常在 2~3 厘米如果你把地图分辨率设成0.01雷达噪声就会变成地图上的散点建出来的图反而“脏”。一张 5 厘米厚的墙如果分辨率是0.1可能连墙都没法明确表达出来。所以分辨率最好和传感器精度、实际墙厚、机器人尺寸放在一起权衡。5.2 和 Nav2 参数一起看别只看 resolutionNav2 里和地图分辨率相关的参数不只是map_server的yaml_filename还有全局代价地图和局部代价地图里的resolution参数。它们的粒度不同但最终都要在空间上对齐。我遇到过一种情况全局地图由map_server发布resolution 0.05但local_costmap的resolution被配成了0.1而width、height还是按原来的格子数填的。结果局部代价地图覆盖的物理范围翻了一倍机器人在局部地图里看着像是被缩小了障碍物位置也错位。排查的时候只看map_server的resolution是找不到问题的必须把global_costmap和local_costmap的配置一起打印出来看。另外plugin obstacle_layer里的observation_sources如果用了不同的传感器这些传感器数据最终要被栅格化到代价地图上。如果输入地图的分辨率和代价地图分辨率差距太大必然会在栅格化过程中丢失部分障碍物信息。所以我现在的原则是能保持一致就保持一致除非我有明确的性能优化需求才会区分全局和局部分辨率。5.3 验证地图是否正确的土办法与其等导航跑起来之后发现各种奇怪问题不如在地图加载完成后的第一步做一个最简单的“标定”动作。找环境里一个你确定坐标的固定点比如墙角、柱子边缘或者某个标志物。在 RViz 里用 “Publish Point” 点一下这个位置看它发布到/clicked_point的坐标是多少。然后用前面说的world_to_cell函数反查对应的栅格索引再查一下这个栅格在data里的值。如果这个点应该是障碍物边界附近而查出来是-1或者距离差了好几个格子那就说明resolution或者origin没配对。我在实际项目里还会用另一招拿到地图后先用map_server加载然后用ros2 topic echo /map --once在最前面看info再和源 YAML 文件里的resolution、origin对一遍。两边不一致说明有中间程序改过消息不等排查完再继续往下调。最后说一个我自己的习惯我拿到一张地图第一件事不是看它漂不漂亮而是先打印resolution、width、height和origin然后拿机器人起点附近一个已知标志物做一次坐标换算算完再交给导航。这套流程多花两分钟但能省掉后面半天定位漂移的排查时间。resolution不是一个需要反复折腾的参数它更像地图的基本单位理解了它很多导航问题其实都看得更清楚了。
返回列表