260523

继我们上次的进度继续。

我们上次已经给我们的 Nav2 的全局代价地图接入了滚动窗口。

上次我们少说了一个参数,track_unknown_space。

global_costmap:
  global_costmap:
    ros__parameters:
      global_frame: map
      robot_base_frame: base_link
      robot_radius: 0.34
      rolling_window: true
      width: 20
      height: 20
      track_unknown_space: false

这个参数是是否追踪未知区域的功能,如果设置为 false 的话,未知区域通常会被按照普通区域或可通行区域来进行处理;而如果设置为 true 的话,则未知区域通常会被按照 UNKNOWN 进行单独保留。

后续 planner 可以根据配置来决定是否穿行未知区域,来决定是否保守导航。

接下来我们给全局代价地图加入障碍层:

global_costmap:
  global_costmap:
    ros__parameters:
      global_frame: map
      robot_base_frame: base_link
      robot_radius: 0.34
      rolling_window: True
      width: 20
      height: 20
      track_unknown_space: false
      plugins: ["obstacle_layer", "inflation_layer"]
      obstacle_layer:
        plugin: "nav2_costmap_2d::ObstcaleLayer"
        observation_sources: scan
        scan:
          topic: /scan
          date_type: "LaserScan"
          clearing: True
          marking: True
      inflation_layer:
        plugin: "nav2_costmap_2d::InflationLayer"
        inflation_radius: 0.7

和我们局部代价地图的障碍层同理,这也是为了在全局进行 SLAM 建模而使用的参数。

然后就可以正式接入我们的 planner 了。

配置一下 planner server:

planner_server:
  ros__parameters:
    planner_plugins: ["GridBased"]
    GridBased:
      plugin: "nav2_navfn_planner::NavfnPlanner"

这里我们选择的 planner 实例名称为 GridBased,它使用了 NavfnPlanner 这个插件来作为实现。

至于为什么叫 GridBased...,因为 NavfnPlanner 是基于 costmap 网格来进行路径规划的经典全局规划器。它在二维栅格地图上探索路径,所以我们配置项也通常将这个 planner 实例命名为 GridBased。

再加入容差和 unknown 的策略:

planner_server:
  ros__parameters:
    planner_plugins: ["GridBased"]
    GridBased:
      plugin: "nav2_navfn_planner::NavfnPlanner"
      tolerance: 0.10
      use_astar: false
      allow_unknown: true

这里我们容差为 0.10,单位为米,即在目标不可达时,在周围 0.1 米范围内找一个可达的点来作为目标点来进行寻路过去。

然后是 use_astar,表示是否使用 A* 寻路算法, 我们设置为 false,表示使用基础的 Dijkstra 寻路算法。

最后的 allow_unknown 就是表示是否经过未知区域,相当于将未知区域当作普通的可通行区域来进行对待。

除此之外,我们还要接入 velocity smoother,来平滑我们的速度,让原始速度不是直接交给我们的 Gazebo,而是先交给 Smoother。

velocity_smoother:
  ros__parameters:
    smoothing_frequency: 20.0
    feedback: "OPEN_LOOP"
    max_velocity: [0.45, 0.0, 1.2]
    min_velocity: [-0.20, 0.0, -1.2]
    max_accel: [0.8, 0.0, 1.5]
    max_decel: [-0.8, 0.0, -1.5]

这里的 smoothing_frequency 表示每秒发布多少次平滑后的速度,我们这里设置为 20,即 20Hz。

然后是 feedback,这个参数在这里的作用是让平滑器根据什么反馈来估计当前速度,我们这里是让它根据上一次自己平滑后的速度输出来作为一个反馈,然后进行逼近 controller 所预期的速度。

下面的max/min_velocity 即表示最大的速度,这个数组从左到右分别代表 x, y 方向上的线速度和 z 轴上的角速度。

而 max_accel/decel则分别代表这几个方向上的最大加/减速度。

最后加入碰撞监控,用来在小车接近障碍物时限制运动:

collision_monitor:
  ros__parameters:
    base_frame_id: "base_link"
    odom_frame_id: "odom"
    cmd_vel_in_topic: "cmd_vel_smoothed"
    cmd_vel_out_topic: "cmd_vel"

这里表示它读取平滑速度,然后发布监控后的速度,但是当前由于我们的 Gazebo Bridge 是直接订阅的平滑速度,因此这里的 cmd_vel 更多的是作为一种观察是否接近碰撞的监控,而非速度的直接入口。

然后加入 footprint approach:

collision_monitor:
  ros__parameters:
    base_frame_id: "base_link"
    odom_frame_id: "odom"
    cmd_vel_in_topic: "cmd_vel_smoothed"
    cmd_vel_out_topic: "cmd_vel"
    polygons: ["FootprintApproach"]
    FootprintApproach:
      type: "polygon"
      action_type: "approach"
      footprint_topic: "/local_costmap/published_footprint"
      time_before_collision: 1.2

这里我们启用了一个碰撞检测区域,名字叫:FootprintApproach。

类型为polygon多边形,action_type 行动策略为 approach,表示根据当前速度预测机器人沿轨迹前进时,多久以后会撞上检测区域内的障碍物。如果碰撞时间太短,则就限制速度,让机器人不会在限制的时间内撞上去。

而这个多边形则从 footprint_topic 中的 /local_costmap/published_footprint 中进行获取,也就是用机器人自身的轮廓作为碰撞检查区域,而不是手写一个多边形。

最后的 time_before_collision 则就表示安全时间窗口,我们设置为了 1.2 秒。

总的来说就是:如果按照当前速度继续运动,预计 1.2 秒内会发生碰撞的话,则就触发 approach 的速度限制来进行保护。

然后我们编写一下启动文件 src/kibot_one_sim/launch/nav2.launch.py。

我们上次编写好了基本的骨架:

from launch import LaunchDescription

def generate_launch_description() -> LaunchDescription:
    return LaunchDescription([])

然后将我们需要复用的路径添加到里面:

from pathlib import Path

from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription

def generate_launch_description() -> LaunchDescription:
    sim_pkg_share = Path(get_package_share_directory(package_name="kibot_one_sim"))
    nav2_bringup_share = Path(get_package_share_directory(package_name="nav2_bringup"))

    return LaunchDescription(initial_entities=[])

然后引入我们需要的文件:

from pathlib import Path

from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription

def generate_launch_description() -> LaunchDescription:
    sim_pkg_share = Path(get_package_share_directory(package_name="kibot_one_sim"))
    nav2_bringup_share = Path(get_package_share_directory(package_name="nav2_bringup"))

    default_world = sim_pkg_share / "worlds" / "kibot_one_obstacles.worlds.sdf"
    sim_with_bridge_launch = sim_pkg_share / "launch" / "sim_with_bridge.launch.py"
    slam_launch = sim_pkg_share / "launch" / "slam.launch.py"
    nav2_navigation_launch = nav2_bringup_share / "launch" / "navigation_launch.py"
    nav2_rviz_config = nav2_bringup_share / "rviz" / "nav2_default_view.rviz"
    nav2_params = sim_pkg_share / "config" / "nav2_params.yaml"

    return LaunchDescription(initial_entities=[])

这些变量对应的文件还没有启动对应的东西,只是先引入进来。

然后声明我们所需的参数:

    world_arg = DeclareLaunchArgument(
        name="world",
        default_value=str(default_world),
        description="Gazebo 世界文件的绝对路径。",
    )
    params_file_arg = DeclareLaunchArgument(
        name="params_file",
        default_value=str(nav2_params),
        description="Nav2 参数文件路径。"
    )

这两个参数是核心参数,用于控制我们拉起 Nav2 的世界和 Nav2 参数文件的路径。

    use_sim_time_arg = DeclareLaunchArgument(
        name="use_sim_time",
        default_value="true",
        description="Nav2 是否使用仿真时间"
    )
    autostart_arg = DeclareLaunchArgument(
        name="autostart",
        default_value="true",
        description="是否自动激活 Nav2 lifecycle nodes。"
    )

这两个参数用于控制 Nav2 是否使用仿真时间,以及是否自动激活 Nav2 lifecycle nodes,如果不自动激活的话,可能会出现 Nav2 节点启动了但是进入不了激活状态的问题,/navigate_to_pose也不能正常执行目标。

    use_composition_arg = DeclareLaunchArgument(
        name="use_composition",
        default_value="false",
        description="是否使用 Nav2 组合节点容器。"
    )
    use_respawn_arg = DeclareLaunchArgument(
        name="use_respawn",
        default_value="false",
        description="Nav2 节点退出后是否自动重启。"
    )

我们当前节点默认不使用组合节点和自动重启,方便进行调试各个节点和日志。

然后接下来把 Gazebo 和 bridge 进行包起来。

    start_sim_arg = DeclareLaunchArgument(
        name="start_sim",
        default_value="true",
        description="是否同步启动 Gazebo 和 ros_gz_bridge。",
    )
    run_on_start_arg = DeclareLaunchArgument(
        name="run_on_start",
        default_value="true",
        description="是否使用 `-r` 参数让 Gazebo 启动后立刻运行仿真。"
    )

这里 run_on_start_arg 是一个很重要的参数,因为如果 Gazebo 在暂停状态的话, /odom 和 TF 不会稳定推送, Nav2 lifecycle 的激活会变得不可靠。

然后给他们包含进去:

    start_sim = IncludeLaunchDescription(
        launch_description_source=PythonLaunchDescriptionSource(launch_file_path=str(sim_with_bridge_launch)),
        launch_arguments={
            "world": LaunchConfiguration(variable_name="world"),
            "run_on_start": LaunchConfiguration(variable_name="run_on_start"),
        }.items(),
        condition=IfCondition(predicate_expression=LaunchConfiguration(variable_name="start_sim"))
    )

接下来把我们的 SLAM 也给包进去:

    start_slam_arg = DeclareLaunchArgument(
        name="start_slam",
        default_value="true",
        description="是否同时启动 slam_toolbox。"
    )
    use_lidar_static_tf_arg = DeclareLaunchArgument(
        name="use_lidar_static_tf",
        default_value="true",
        description="透传给 slam.launch.py,用于补齐 base_link -> lidar_link。"
    )
    start_slam = IncludeLaunchDescription(
        launch_description_source=PythonLaunchDescriptionSource(launch_file_path=str(slam_launch)),
        launch_arguments={
            "use_lidar_static_tf": LaunchConfiguration(variable_name="use_lidar_static_tf"),
        }.items(),
        condition=IfCondition(predicate_expression=LaunchConfiguration(variable_name="start_slam"))
    )

Nav2 和日志等级:

    start_nav2_arg = DeclareLaunchArgument(
        name="start_nav2",
        default_value="true",
        description="是否启动 Nav2 navigation stack。",
    )
    log_level_arg = DeclareLaunchArgument(
        name="log_level",
        default_value="info",
        description="Nav2 日志等级。",
    )
    start_nav2 = IncludeLaunchDescription(
        launch_description_source=PythonLaunchDescriptionSource(str(nav2_navigation_launch)),
        launch_arguments={
            "use_sim_time": LaunchConfiguration("use_sim_time"),
            "autostart": LaunchConfiguration("autostart"),
            "params_file": LaunchConfiguration("params_file"),
            "use_composition": LaunchConfiguration("use_composition"),
            "use_respawn": LaunchConfiguration("use_respawn"),
            "log_level": LaunchConfiguration("log_level"),
        }.items(),
        condition=IfCondition(LaunchConfiguration("start_nav2")),
    )

最后加入我们可选的 RViz:

use_rviz_arg = DeclareLaunchArgument(
    name="use_rviz",
    default_value="false",
    description="是否启动 RViz Nav2 默认视图。",
)

现在我们就可以把这些 actions 全部一股脑的塞到 LaunchDescription 中了:

    return LaunchDescription(initial_entities=[
        world_arg,
        params_file_arg,
        use_sim_time_arg,
        autostart_arg,
        use_composition_arg,
        use_respawn_arg,
        log_level_arg,
        start_sim_arg,
        run_on_start_arg,
        start_slam_arg,
        start_nav2_arg,
        use_lidar_static_tf_arg,
        use_rviz_arg,
        start_sim,
        start_slam,
        start_nav2,
        start_rviz,
    ])

最后构建,看看我们预期的参数:

(.venv) jese--ki@KiBall:~/Projects/dev/KiBots/KiBotTwo$ ros2 launch kibot_one_sim nav2.launch.py --show-args
Arguments (pass arguments as '<name>:=<value>'):

    'world':
        Gazebo 世界文件的绝对路径。
        (default: '/home/jese--ki/Projects/dev/KiBots/KiBotTwo/install/kibot_one_sim/share/kibot_one_sim/worlds/kibot_one_obstacles.worlds.sdf')

    'params_file':
        Nav2 参数文件路径。
        (default: '/home/jese--ki/Projects/dev/KiBots/KiBotTwo/install/kibot_one_sim/share/kibot_one_sim/config/nav2_params.yaml')

    'use_sim_time':
        Nav2 是否使用仿真时间
        (default: 'true')

    'autostart':
        是否自动激活 Nav2 lifecycle nodes。
        (default: 'true')

    'use_composition':
        是否使用 Nav2 组合节点容器。
        (default: 'false')

    'use_respawn':
        Nav2 节点退出后是否自动重启。
        (default: 'false')

    'log_level':
        Nav2 日志等级。
        (default: 'info')

    'start_sim':
        是否同步启动 Gazebo 和 ros_gz_bridge。
        (default: 'true')

    'run_on_start':
        是否使用 `-r` 参数让 Gazebo 启动后立刻运行仿真。
        (default: 'true')

    'start_slam':
        是否同时启动 slam_toolbox。
        (default: 'true')

    'start_nav2':
        是否启动 Nav2 navigation stack。
        (default: 'true')

    'use_lidar_static_tf':
        透传给 slam.launch.py,用于补齐 base_link -> lidar_link。
        (default: 'true')

    'use_rviz':
        是否启动 RViz Nav2 默认视图。
        (default: 'false')

    'use_lifecycle_manager':
        Enable bond connection during node activation
        (default: 'false')

    'slam_params_file':
        Full path to the ROS2 parameters file to use for the slam_toolbox node
        (default: '/opt/ros/jazzy/share/slam_toolbox/config/mapper_params_online_async.yaml')

    'namespace':
        Top-level namespace
        (default: '')

    'container_name':
        the name of conatiner that nodes will load in if use composition
        (default: 'nav2_container')

可以看到都符合我们的预期。

现在启动一下看看:

(.venv) jese--ki@KiBall:~/Projects/dev/KiBots/KiBotTwo$ ros2 launch kibot_one_sim nav2.launch.py use_rviz:=false
[INFO] [launch]: All log files can be found below /home/jese--ki/.ros/log/2026-05-23-13-54-01-379669-KiBall-45227
[INFO] [launch]: Default logging verbosity is set to INFO
[ERROR] [launch]: Caught exception in launch (see debug for traceback): name 'false' is not defined
[INFO] [gz-1]: process started with pid [45244]
[INFO] [bridge_node-2]: process started with pid [45245]
[INFO] [static_transform_publisher-3]: process started with pid [45246]
[INFO] [async_slam_toolbox_node-4]: process started with pid [45247]
[static_transform_publisher-3] [WARN] [1779515641.656860303] []: Old-style arguments are deprecated; see --help for new-style arguments
[static_transform_publisher-3] [INFO] [1779515641.702381386] [base_to_lidar_static_tf]: Spinning until stopped - publishing transform
[static_transform_publisher-3] translation: ('0.200000', '0.000000', '0.165000')
[static_transform_publisher-3] rotation: ('0.000000', '0.000000', '0.000000', '1.000000')
[static_transform_publisher-3] from 'base_link' to 'lidar_link'
[async_slam_toolbox_node-4] [INFO] [1779515641.841881314] [slam_toolbox]: Configuring
[INFO] [gz-1]: sending signal 'SIGINT' to process[gz-1]
[INFO] [bridge_node-2]: sending signal 'SIGINT' to process[bridge_node-2]
[INFO] [static_transform_publisher-3]: sending signal 'SIGINT' to process[static_transform_publisher-3]
[INFO] [async_slam_toolbox_node-4]: sending signal 'SIGINT' to process[async_slam_toolbox_node-4]
[bridge_node-2] [INFO] [1779515641.846686591] [rclcpp]: signal_handler(SIGINT/SIGTERM)
[static_transform_publisher-3] [INFO] [1779515641.846815963] [rclcpp]: signal_handler(SIGINT/SIGTERM)
[async_slam_toolbox_node-4] [INFO] [1779515641.847124222] [rclcpp]: signal_handler(SIGINT/SIGTERM)
[async_slam_toolbox_node-4] [INFO] [1779515641.925462560] [slam_toolbox]: Using solver plugin solver_plugins::CeresSolver
[async_slam_toolbox_node-4] [ERROR] [1779515641.925670731] [slam_toolbox]: Caught exception in callback for transition 10
[async_slam_toolbox_node-4] [ERROR] [1779515641.925673286] [slam_toolbox]: Original error: failed to create guard condition: the given context is not valid, either rcl_init() was not called or rcl_shutdown() was called., at ./src/rcl/guard_condition.c:67
[async_slam_toolbox_node-4] [ERROR] [1779515641.925685579] [slam_toolbox]: Failed to finish transition 1. Current state is now: errorprocessing (Could not publish transition: publisher's context is invalid, at ./src/rcl/publisher.c:423, at ./src/rcl_lifecycle.c:368)
[INFO] [static_transform_publisher-3]: process has finished cleanly [pid 45246]
[gz-1] /usr/lib/ruby/3.2.0/fiddle.rb:62:in `initialize': Interrupt
[gz-1]  from /usr/lib/ruby/3.2.0/fiddle.rb:62:in `new'
[gz-1]  from /usr/lib/ruby/3.2.0/fiddle.rb:62:in `dlopen'
[gz-1]  from /usr/lib/ruby/3.2.0/fiddle/import.rb:86:in `block in dlload'
[gz-1]  from /usr/lib/ruby/3.2.0/fiddle/import.rb:77:in `collect'
[gz-1]  from /usr/lib/ruby/3.2.0/fiddle/import.rb:77:in `dlload'
[gz-1]  from /opt/ros/jazzy/opt/gz_sim_vendor/lib/ruby/gz/cmdsim8.rb:398:in `execute'
[gz-1]  from /opt/ros/jazzy/opt/gz_tools_vendor/bin/gz:316:in `<main>'
[ERROR] [gz-1]: process has died [pid 45244, exit code -2, cmd 'gz sim -v 4 /home/jese--ki/Projects/dev/KiBots/KiBotTwo/install/kibot_one_sim/share/kibot_one_sim/worlds/kibot_one_obstacles.worlds.sdf'].
[INFO] [async_slam_toolbox_node-4]: process has finished cleanly [pid 45247]
[WARNING] [launch_ros.utilities.lifecycle_event_manager]: Abandoning wait for the '/slam_toolbox/change_state' service response, due to shutdown.
[ERROR] [bridge_node-2]: process has died [pid 45245, exit code -11, cmd '/opt/ros/jazzy/lib/ros_gz_bridge/bridge_node --ros-args --log-level info --ros-args -r __node:=kibot_one_bridge -r __ns:=/ --params-file /tmp/launch_params_i90tgh2y'].

嗯,貌似出现了报错,目前来看有下面的问题:

  1. use_composition_arg的default_value我们设置为的 false,但是这在 Python 中并不被识别,因此我们需要改回到 False。
  2. 我们的地图路径写的有问题,应该是 kibot_one_obstacles.world.sdf,我们写的是kibot_one_obstacles.worlds.sdf,world 后面多了个 s。

以及好几处单词拼写的问题,然后给我们的 collision_monitor 添加上 observation_sources:

collision_monitor:
  ros__parameters:
    base_frame_id: "base_link"
    odom_frame_id: "odom"
    cmd_vel_in_topic: "cmd_vel_smoothed"
    cmd_vel_out_topic: "cmd_vel"
    polygons: ["FootprintApproach"]
    FootprintApproach:
      type: "polygon"
      action_type: "approach"
      footprint_topic: "/local_costmap/published_footprint"
      time_before_collision: 1.2
    observation_sources: ["scan"]
    scan:
      type: "scan"
      topic: "scan"
      min_height: 0.15
      max_height: 2.0
      enabled: true

现在及已经成功拉起了: