Skip to content

使用禁区(Keepout Zones)导航

本教程介绍如何在工业环境和仓库场景中,利用禁区/安全区域来限制机器人不可进入的范围,以及利用车道来指定机器人优先行驶的路径。这些功能均由 KeepoutFilter 代价地图过滤器插件提供,本文将介绍如何启用并使用该插件。

假定 ROS 2、Gazebo 和 TurtleBot 4 软件包已安装或在本地构建,同时请确保 Nav2 项目也已在本地构建,详见构建说明。

正如概念中所述,任何代价地图过滤器(包括 Keepout Filter)都会从过滤器掩码文件中读取标记数据。过滤器掩码(filter mask)本质上是一张普通的 Nav2 2D 地图,以 PGM、PNG 或 BMP 栅格文件形式提供,元数据存放在配套的 YAML 文件中。以下步骤介绍如何制作新的过滤器掩码:

创建一个 PGM/PNG/BMP 格式的新图像:从 Nav2 仓库复制主地图 depot.pgm(即仿真中使用的世界地图),另存为 depot_keepout.pgm。

用常用的栅格图形编辑器(如 GIMP)打开 depot_keepout.pgm。掩码中每个像素的亮度(lightness)编码了当前代价地图过滤器所需的信息。每个像素的亮度值范围为 [0..255](或百分比刻度的 [0..100]),其中 0 表示黑色,255 表示白色。「暗度」(darkness)则是亮度的反面,即 color_darkness = 100% - color_lightness。

在 GIMP 中,亮度通过颜色分量值(如百分比刻度下的 R 值)来表示,可在颜色调整工具中拖动 L 滑块来设置:

ligtness_in_GIMP

掩码文件由 Map Server 读取,并转换为 [0..100] 范围内的 OccupancyGrid 值(其中 0 表示空闲单元,100 表示占用单元,介于两者之间的值表示不同程度的占用),或 -1 表示未知。在 Nav2 技术栈中,每个地图都有一个 mode 属性,可选值为 trinary、scale 或 raw。根据所选的 mode,PGM/PNG/BMP 的颜色亮度按以下规则转换为 OccupancyGrid:

  • trinary(默认模式):暗度 >= occupied_thresh 时标记为占用(100);暗度 <= free_thresh 时标记为空闲(0);介于两者之间则状态未知(-1)。
  • scale:Alpha < 1.0 时为未知;暗度 >= occupied_thresh 时标记为占用(100);暗度 <= free_thresh 时标记为空闲(0);介于两者之间则线性插值到 [0..100] 范围内最近的整数。
  • raw:亮度 = 0(深色)标记为空闲(0);亮度 = 100 时标记为占用(100);介于两者之间时 OccupancyGrid 值 = 亮度;亮度 >= 101 时为未知(-1)。

其中 free_thresh 和 occupied_thresh 阈值以最大亮度/暗度(255)的百分比表示。地图模式和阈值存放在 YAML 元数据文件中(见下文),字段名分别为 mode、free_thresh 和 occupied_thresh。

注意:YAML 元数据文件中还有一个名为 negate 的参数,默认为 false。设为 true 时,较黑的像素被视为空闲,较白的像素被视为占用。在这种情况下,对于 trinary 和 scale 模式,应计算颜色亮度而非暗度。negate 对 raw 模式没有影响。

对于 Keepout Filter,OccupancyGrid 值与对应单元区域的可通行性成正比:值越高表示越不可通行。具有占用值的单元所覆盖的区域构成禁区,机器人不会进入或穿过。KeepoutFilter 还可以通过将 OccupancyGrid 设置为 [1-99] 之间的非占用值来充当「加权区域层」。机器人可以进入这些区域,但不鼓励在此停留——值越高,规划器越快将机器人引导离开该区域。

Keepout Filter 也支持优先车道场景,即机器人只在预定义的车道和允许区域内移动,例如在仓库中。要使用此功能,需要制作一个掩码图像,其中车道和允许区域用空闲值标记,其他所有区域标记为占用。在 trinary 或 scale 模式下绘制掩码时有一个小提示:通常车道像素数量远少于其他区域的像素。这时可以先用黑色铅笔在白底上绘制所有车道数据,然后(在保存 PGM 之前)在图像编辑器中使用「颜色反转」工具。

为简单起见,本示例中将标记为禁区的区域填充为黑色(在 trinary 模式下即表示占用):

drawing_keepout_mask

填充完所有禁区区域后,保存 depot_keepout.pgm 图像。

与所有其他地图一样,过滤器掩码也需要自己的 YAML 元数据文件。将 depot.yaml 复制为 depot_keepout.yaml,打开后将 image 字段修改为新制作的 PGM 掩码:

image: depot.pgm
->
image: depot_keepout.pgm

由于过滤器掩码图像是从主地图副本创建的,YAML 文件中的其他字段无需修改。保存 depot_keepout.yaml,新的过滤器掩码即可使用。

注意:世界地图本身和过滤器掩码可以具有不同的大小、原点和分辨率。这在过滤器掩码只覆盖地图中较小区域时,或者同一个过滤器掩码被多次复用时(例如为酒店中相同形状的房间标注禁区)非常有用。此时需要相应修改 YAML 中的 resolution 和 origin 字段,确保过滤器掩码在原始地图上正确定位。

注意:由于 Costmap2D 不支持方向,origin 向量的第三个「yaw」分量应设为零,例如 origin: [1.25, -5.18, 0.0]。

2. 配置代价地图过滤器信息发布服务器

Section titled “2. 配置代价地图过滤器信息发布服务器”

每个代价地图过滤器都会从 nav2_msgs/CostmapFilterInfo 类型的消息中读取元信息(如过滤器类型或数据转换系数)。这些消息由 Costmap Filter Info Publisher Server 发布,该服务器作为生命周期节点运行。根据设计文档,nav2_msgs/CostmapFilterInfo 消息与 OccupancyGrid 过滤器掩码话题成对出现。因此,除了 Costmap Filter Info Publisher Server 外,还需要启用一个新的 Map Server 实例来发布过滤器掩码。

要启用 Keepout Filter,这两个服务器都应在 Python 启动文件中作为生命周期节点启动,也可以将它们作为组合节点(Composition Nodes)添加到导航组件容器(Navigation Component Container)中,如下所示:

import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, GroupAction, SetEnvironmentVariable
from launch.conditions import IfCondition
from launch.substitutions import LaunchConfiguration, PythonExpression
from launch_ros.actions import LoadComposableNodes, Node, PushROSNamespace, SetParameter
from launch_ros.descriptions import ComposableNode, ParameterFile
from nav2_common.launch import LaunchConfigAsBool, RewrittenYaml
def generate_launch_description() -> LaunchDescription:
# Get the launch directory
bringup_dir = get_package_share_directory('nav2_bringup')
namespace = LaunchConfiguration('namespace')
keepout_mask_yaml_file = LaunchConfiguration('keepout_mask')
use_sim_time = LaunchConfigAsBool('use_sim_time')
autostart = LaunchConfigAsBool('autostart')
params_file = LaunchConfiguration('params_file')
use_composition = LaunchConfigAsBool('use_composition')
container_name = LaunchConfiguration('container_name')
container_name_full = (namespace, '/', container_name)
use_respawn = LaunchConfigAsBool('use_respawn')
use_keepout_zones = LaunchConfigAsBool('use_keepout_zones')
log_level = LaunchConfiguration('log_level')
lifecycle_nodes = ['keepout_filter_mask_server', 'keepout_costmap_filter_info_server']
# Map fully qualified names to relative ones so the node's namespace can be prepended.
remappings = [('/tf', 'tf'), ('/tf_static', 'tf_static')]
yaml_substitutions = {
'KEEPOUT_ZONE_ENABLED': use_keepout_zones,
}
configured_params = ParameterFile(
RewrittenYaml(
source_file=params_file,
root_key=namespace,
param_rewrites={},
value_rewrites=yaml_substitutions,
convert_types=True,
),
allow_substs=True,
)
stdout_linebuf_envvar = SetEnvironmentVariable(
'RCUTILS_LOGGING_BUFFERED_STREAM', '1'
)
declare_namespace_cmd = DeclareLaunchArgument(
'namespace', default_value='', description='Top-level namespace'
)
declare_keepout_mask_yaml_cmd = DeclareLaunchArgument(
'keepout_mask',
default_value='',
description='Full path to keepout mask yaml file to load',
)
declare_use_sim_time_cmd = DeclareLaunchArgument(
'use_sim_time',
default_value='false',
description='Use simulation (Gazebo) clock if true',
)
declare_params_file_cmd = DeclareLaunchArgument(
'params_file',
default_value=os.path.join(bringup_dir, 'params', 'nav2_params.yaml'),
description='Full path to the ROS2 parameters file to use for all launched nodes',
)
declare_use_composition_cmd = DeclareLaunchArgument(
'use_composition',
default_value='False',
description='Use composed bringup if True',
)
declare_container_name_cmd = DeclareLaunchArgument(
'container_name',
default_value='nav2_container',
description='the name of container that nodes will load in if use composition',
)
declare_use_respawn_cmd = DeclareLaunchArgument(
'use_respawn',
default_value='False',
description='Whether to respawn if a node crashes. Applied when composition is disabled.',
)
declare_use_keepout_zones_cmd = DeclareLaunchArgument(
'use_keepout_zones', default_value='True',
description='Whether to enable keepout zones or not'
)
declare_log_level_cmd = DeclareLaunchArgument(
'log_level', default_value='info', description='log level'
)
load_nodes = GroupAction(
condition=IfCondition(PythonExpression(['not ', use_composition])),
actions=[
PushROSNamespace(namespace),
SetParameter('use_sim_time', use_sim_time),
Node(
condition=IfCondition(use_keepout_zones),
package='nav2_map_server',
executable='map_server',
name='keepout_filter_mask_server',
output='screen',
respawn=use_respawn,
respawn_delay=2.0,
parameters=[configured_params, {'yaml_filename': keepout_mask_yaml_file}],
arguments=['--ros-args', '--log-level', log_level],
remappings=remappings,
),
Node(
condition=IfCondition(use_keepout_zones),
package='nav2_map_server',
executable='costmap_filter_info_server',
name='keepout_costmap_filter_info_server',
output='screen',
respawn=use_respawn,
respawn_delay=2.0,
parameters=[configured_params],
arguments=['--ros-args', '--log-level', log_level],
remappings=remappings,
),
Node(
package='nav2_lifecycle_manager',
executable='lifecycle_manager',
name='lifecycle_manager_keepout_zone',
output='screen',
arguments=['--ros-args', '--log-level', log_level],
parameters=[{'autostart': autostart}, {'node_names': lifecycle_nodes}],
),
],
)
# LoadComposableNode for map server twice depending if we should use the
# value of map from a CLI or launch default or user defined value in the
# yaml configuration file. They are separated since the conditions
# currently only work on the LoadComposableNodes commands and not on the
# ComposableNode node function itself
load_composable_nodes = GroupAction(
condition=IfCondition(use_composition),
actions=[
PushROSNamespace(namespace),
SetParameter('use_sim_time', use_sim_time),
LoadComposableNodes(
target_container=container_name_full,
condition=IfCondition(use_keepout_zones),
composable_node_descriptions=[
ComposableNode(
package='nav2_map_server',
plugin='nav2_map_server::MapServer',
name='keepout_filter_mask_server',
parameters=[
configured_params,
{'yaml_filename': keepout_mask_yaml_file}
],
remappings=remappings,
),
ComposableNode(
package='nav2_map_server',
plugin='nav2_map_server::CostmapFilterInfoServer',
name='keepout_costmap_filter_info_server',
parameters=[configured_params],
remappings=remappings,
),
],
),
LoadComposableNodes(
target_container=container_name_full,
composable_node_descriptions=[
ComposableNode(
package='nav2_lifecycle_manager',
plugin='nav2_lifecycle_manager::LifecycleManager',
name='lifecycle_manager_keepout_zone',
parameters=[
{'autostart': autostart, 'node_names': lifecycle_nodes}
],
),
],
),
],
)
# Create the launch description and populate
ld = LaunchDescription()
# Set environment variables
ld.add_action(stdout_linebuf_envvar)
# Declare the launch options
ld.add_action(declare_namespace_cmd)
ld.add_action(declare_keepout_mask_yaml_cmd)
ld.add_action(declare_use_sim_time_cmd)
ld.add_action(declare_params_file_cmd)
ld.add_action(declare_use_composition_cmd)
ld.add_action(declare_container_name_cmd)
ld.add_action(declare_use_respawn_cmd)
ld.add_action(declare_use_keepout_zones_cmd)
ld.add_action(declare_log_level_cmd)
# Add the actions to launch all of the map modifier nodes
ld.add_action(load_nodes)
ld.add_action(load_composable_nodes)
return ld

其中 params_file 变量应指向一个 YAML 文件,包含 Costmap Filter Info Publisher Server 和 Map Server 节点的 ROS 参数。这些参数及其含义详见配置指南页面(地图服务器部分)。params_file 示例如下:

keepout_filter_mask_server:
ros__parameters:
topic_name: "keepout_filter_mask"
# yaml_filename: ""
keepout_costmap_filter_info_server:
ros__parameters:
type: 0
filter_info_topic: "keepout_costmap_filter_info"
mask_topic: "keepout_filter_mask"
base: 0.0
multiplier: 1.0

请注意以下几点:

  • 对于 Keepout Filter,代价地图过滤器的 type 应设置为 0。
  • 过滤器掩码话题名称应与 Costmap Filter Info Publisher Server 的 mask_topic 参数和 Map Server 的 topic_name 参数保持一致。
  • 根据 Costmap Filters 设计,OccupancyGrid 值会被线性转换为过滤器空间中的特征图。对于 Keepout Filter,这些值直接作为过滤器空间值传递,无需线性转换。尽管 base 和 multiplier 系数在 Keepout Filter 中未被使用,但仍应分别设置为 0.0 和 1.0,以表明从 OccupancyGrid 值到过滤器值空间是一对一转换。

代价地图过滤器是 Costmap2D 插件。在 nav2_params.yaml 的 plugins 参数中添加 keepout_filter 即可在 Costmap2D 中启用 KeepoutFilter 插件。可将其放入 global_costmap 以实现绕行禁区的路径规划,放入 local_costmap 以确保机器人不会穿越禁区。KeepoutFilter 插件需定义以下参数:

  • plugin:插件类型,此处为 nav2_costmap_2d::KeepoutFilter。
  • filter_info_topic:过滤器信息话题名称,需与上一节中 Costmap Filter Info Publisher Server 的 filter_info_topic 参数一致。

KeepoutFilter 支持的完整参数列表详见Keepout Filter 配置页面。

仅为 global_costmap 启用 KeepoutFilter 只能让规划器生成绕行禁区的路径。仅为 local_costmap 启用则能阻止机器人进入禁区,但路径仍可能穿过禁区。因此最佳实践是在 nav2_params.yaml 中同时为 global_costmap 和 local_costmap 启用 KeepoutFilter。不过也有例外:某些场景下全局和局部代价地图的禁区不必相同。例如,机器人虽不允许主动进入禁区,但若已经靠近边缘或角落,可以快速进出。此时无需在局部代价地图中额外复制一份掩码,以节省资源。

要为全局和局部代价地图使用相同掩码启用 KeepoutFilter,配置如下:

global_costmap:
global_costmap:
ros__parameters:
...
plugins: ["static_layer", "obstacle_layer", "inflation_layer"]
filters: ["keepout_filter"]
...
keepout_filter:
plugin: "nav2_costmap_2d::KeepoutFilter"
enabled: True
filter_info_topic: "keepout_costmap_filter_info"
...
local_costmap:
local_costmap:
ros__parameters:
...
plugins: ["voxel_layer", "inflation_layer"]
filters: ["keepout_filter"]
...
keepout_filter:
plugin: "nav2_costmap_2d::KeepoutFilter"
enabled: True
filter_info_topic: "keepout_costmap_filter_info"

注意:所有代价地图过滤器都应通过 filters 参数启用,而不是放在 plugins 分层代价地图中。这样做是为了将过滤器与各层插件(尤其是膨胀层 inflation layer)分开,避免相互干扰。

现成的独立 Python 启动脚本、ROS 参数 YAML 文件以及 Keepout Filter 的过滤器掩码示例均可在 nav2_bringup 目录中找到。运行演示只需执行以下命令:

Terminal window
ros2 launch nav2_bringup tb4_simulation_launch.py

然后检查过滤器是否正常工作,效果如下图:

keepout_global

注意:更多示例和上下文请参考 Navigation2 教程:https://github.com/ros-navigation/navigation2_tutorials/tree/rolling/nav2_costmap_filters_demo