做机器人导航的人,应该都跟/map这个话题打过交道。之前我在调 Nav2 的时候,地图、定位、代价地图看起来都正常,但机器人就是会“擦着墙”过,甚至有时候在 RViz 里规划出来的路径明显不在可通行区域里。排查了一圈,最后发现不是代价地图参数写错,也不是定位飘,而是我压根没认真看过OccupancyGrid消息里的info.resolution。这个字段平时不起眼,但它决定了整张地图在真实世界里的尺度和坐标换算方式,一旦和实际环境对不上,后面所有导航表现都会跟着出问题。
这篇东西就围绕resolution展开,我会先讲清楚它到底是什么、从哪里来,再用实际代码演示怎么读它、怎么用它做栅格坐标和世界坐标互转,最后谈一谈工程上怎么选、怎么和 Nav2 参数配合。不管你是刚开始看 ROS2 地图消息,还是已经跑过几个导航 Demo,应该都能从里面找到能直接用的东西。
1. resolution 不是“清晰度”,而是栅格世界的长度单位
1.1 消息结构:哪里放着 resolution
ROS2 里的二维栅格地图统一用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 = 160,resolution = 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.5:
resolution = 0.05时,需要膨胀 10 个栅格;resolution = 0.1时,只需要膨胀 5 个栅格;resolution = 0.01时,需要膨胀 50 个栅格。
如果地图分辨率太粗,比如resolution = 0.2,robot_radius = 0.25的机器人可能只占 1.25 个栅格。代价地图在离散化后会很难准确表达机器人的外轮廓,路径规划时就会出现“路径贴着墙”,甚至机器人模型扫过障碍物的情况。
反过来,分辨率太细也不是好事。除了地图文件本身变大之外,代价地图在每次传感器数据进来时都需要更新障碍层并重新做膨胀计算,栅格越多,耗时越长。我在低算力板子上把地图从0.05改成0.02后,local costmap 的更新频率肉眼可见地掉了一截,就是因为同一片区域里的栅格数量变成了原来的 6 倍多。
3.3 内存与算力的量级估算
我们按一张 100 米 × 100 米的园区地图来估算一下几种分辨率下的数据量:
| resolution | width×height(cells) | 原始 data 数组占用 |
|---|---|---|
| 0.01 | 10000 × 10000 = 1亿 | 约 100 MB |
| 0.05 | 2000 × 2000 = 400万 | 约 4 MB |
| 0.1 | 1000 × 1000 = 100万 | 约 1 MB |
| 0.2 | 500 × 500 = 25万 | 约 0.25 MB |
注意,这只是OccupancyGrid.data一个int8数组的占用。代价地图还有主层、障碍层、膨胀层等多个层,每层都可能维护自己的缓存数据。实际内存占用会比上表再多出好几倍。所以在地图比较大的场景里,resolution不是一个可以随手填的“好看”数字,它直接影响整条导航链路能不能跑得动。
4. 代码实操:读 resolution、坐标互转、自己发布 OccupancyGrid
4.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( f'resolution = {info.resolution:.6f} m/cell, ' f'size = {info.width} x {info.height}, ' f'origin = ({info.origin.position.x:.3f}, ' f'{info.origin.position.y:.3f})' ) self.get_logger().info(f'data 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, \ f'data length {len(data)} != {width} * {height}'第三,header.frame_id没设置。地图消息必须说明自己是在哪个坐标系下,否则/map到odom、base_link的坐标变换无法建立。常见做法是把frame_id设成map。
第四,origin.orientation没设成单位四元数。geometry_msgs/Pose里有位置和姿态,如果姿态没有初始化,可能是(0,0,0,0),这是一个非法的四元数,坐标变换遇到它会直接报错。没有任何旋转时,应该设置成x=0, y=0, z=0, w=1。
5. 实际调参时的几个工程建议
5.1 不同场景的参考分辨率
resolution没有绝对的对错,只有适不适合场景。我根据自己的使用经验,给一个粗略的参考区间。
| 场景 | 参考 resolution | 备注 |
|---|---|---|
| 室内小场景、精细抓取/避障 | 0.01 ~ 0.02 | 地图小、精度要求高,但注意算力 |
| 普通室内机器人导航 | 0.05 | 2D 激光雷达和 Kinect 类传感器比较常用 |
| 园区、走廊、室外大场景 | 0.1 ~ 0.2 | 降低地图尺寸和更新开销 |
| 低算力板子 + 大范围地图 | 0.1 以上 | 优先保证实时性,精度适当妥协 |
传感器精度也是重要参考。普通 2D 激光雷达的测距噪声通常在 2~3 厘米,如果你把地图分辨率设成0.01,雷达噪声就会变成地图上的散点,建出来的图反而“脏”。一张 5 厘米厚的墙,如果分辨率是0.1,可能连墙都没法明确表达出来。所以分辨率最好和传感器精度、实际墙厚、机器人尺寸放在一起权衡。
5.2 和 Nav2 参数一起看,别只看 resolution
Nav2 里和地图分辨率相关的参数不只是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不是一个需要反复折腾的参数,它更像地图的基本单位,理解了它,很多导航问题其实都看得更清楚了。