用 Gazebo 学习 ROS 2

本文基于约翰霍普金斯大学"机器人学导论"课程中教授的机器人学之避障主题。我们将自定义一个ROS 2机器人并在Gazebo Ionic仿真环境中运行这个机器人。

1、基础(设置与启动)

在我们能够传递传感器数据或驱动机器人之前,我们需要让ROS 2和Gazebo Ionic无缝地一起启动。

两个不同的世界

ROS 2和Gazebo是完全独立的软件生态系统。

  • ROS 2使用一种名为DDS(数据分发服务)的中间件协议进行通信。
  • Gazebo Ionic使用自己的内部消息系统Gazebo Transport进行通信。

因为它们使用不同的语言,我们使用一个名为ros_gz的元包。这个包包含从ROS终端启动Gazebo的启动文件,以及在两者之间转换消息所需的桥接器。

以下是我们构建第一个集成启动文件的实践练习。

步骤1:安装集成包

首先,确保你已为特定的ROS 2发行版(例如Jazzy、Humble或Rolling)安装了ros_gz包。打开终端并运行:

sudo apt update
sudo apt install ros-jazzy-ros-gz

步骤2:创建启动文件

专业的ROS开发者不会从桌面手动打开Gazebo,而是直接从ROS 2 Python启动文件启动模拟器。

首先创建ROS 2包。

ros2 pkg create --build-type ament_python gazebo_control

创建一个名为gazebo_launch.py的新文件,并将以下代码粘贴到其中:

# launch/gazebo_launch.py
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource

def generate_launch_description():
    # 1. Find the official ros_gz_sim package on your computer
    pkg_ros_gz_sim = get_package_share_directory('ros_gz_sim')
    # 2. Setup the IncludeLaunchDescription to trigger Gazebo
    gz_sim = IncludeLaunchDescription(
        PythonLaunchDescriptionSource(
            os.path.join(pkg_ros_gz_sim, 'launch', 'gz_sim.launch.py')
        ),
        # 3. Pass arguments to Gazebo: load an empty world and run immediately (-r)
        launch_arguments={'gz_args': 'empty.sdf -r'}.items(),
    )
    # 4. Return the LaunchDescription
    return LaunchDescription([
        gz_sim
    ])

步骤3:理解代码

让我们分解一下你刚才写的内容:

  • get_package_share_directory('ros_gz_sim'):动态查找官方ros_gz集成包在你硬盘上的位置。
  • IncludeLaunchDescription:这就像一个"盗梦空间"命令。它告诉你的自定义启动文件去触发一个完全不同的启动文件(Gazebo提供的gz_sim.launch.py)。
  • gz_args: 'empty.sdf -r':这是传递给Gazebo的命令行参数。empty.sdf告诉它加载一个空白世界。-r标志至关重要——它告诉Gazebo按下"播放"并立即启动物理引擎。没有它,模拟会以暂停状态启动。

步骤4:执行启动

要测试我们的基础,打开终端,导航到保存文件的文件夹,并运行:

colcon build --packages-select gazebo_control
source install/setup.bash
ros2 launch gazebo_control gazebo_launch.py

如果一切配置正确,Gazebo Ionic GUI应该会弹出一个空白的3D世界,完全从ROS 2生态系统内部启动!

2、ros_gz_bridge和管理多个机器人

通用翻译器

目前,ROS 2和Gazebo就像两个坐在同一个房间里但说着完全不同语言的人。如果ROS 2使用geometry_msgs/msg/Twist消息大喊"向前开!",Gazebo会忽略它,因为它只理解gz.msgs.Twist消息。

ros_gz_bridge就是我们的翻译器。要使用它,我们必须准确定义我们想要翻译的主题、消息类型是什么,以及翻译应该沿哪个方向进行。

标准语法如下:

/topic_name@ROS_2_Type[Direction]Gazebo_Type

方向括号:

  • ]:从ROS 2翻译Gazebo。
  • [:从Gazebo翻译ROS 2。
  • ==:双向翻译。

让我们亲自动手驱动机器人。

步骤1:启动机器人世界

首先,我们需要一个可以驾驶的机器人。Gazebo附带了一个默认的差速驱动车辆可以使用。关闭之前的Gazebo窗口(在该终端中使用Ctrl+C),改为运行:

ros2 launch ros_gz_sim gz_sim.launch.py gz_args:="diff_drive.sdf -r"

当Gazebo打开时,你会注意到一个有趣的事情: 有两个机器人!一个蓝色的和一个绿色的。

因为有两个独立的车辆,我们不能只向/cmd_vel主题发送一个通用的速度命令——Gazebo不知道哪个机器人应该移动。相反,Gazebo根据实体树中的模型名称为每个机器人分配一个特定的命名空间(例如,/model/vehicle_green/cmd_vel)。

步骤2:启动桥接器(ROS 2 -> Gazebo)

我们想从ROS 2向Gazebo中的机器人发送速度命令。机器人在/cmd_vel主题上监听。

打开第二个终端,source你的ROS 2环境,并使用蓝色车辆的特定命名空间运行桥接命令:

ros2 run ros_gz_bridge parameter_bridge \
  /model/vehicle_blue/cmd_vel@geometry_msgs/msg/Twist]gz.msgs.Twist
  • /model/vehicle_blue/cmd_vel:这是蓝色机器人的特定命名空间主题。
  • @geometry_msgs/msg/Twist:我们正在发送的标准ROS 2消息类型。
  • ]:告诉桥接器将数据ROS 2发送Gazebo的方向括号。
  • gz.msgs.Twist:需要翻译成的Gazebo特定消息类型。

步骤3:驱动机器人!

现在是有趣的部分。打开第三个终端,source你的ROS 2环境,手动发布一个ROS 2 Twist消息告诉机器人向前行驶并转弯绕圈:

ros2 topic pub /model/vehicle_blue/cmd_vel \
  geometry_msgs/msg/Twist "{linear: {x: 1.0}, angular: {z: 0.5}}"

看看你的Gazebo窗口——蓝色车辆应该立即开始绕圈行驶!

步骤4:桥接数据回传(Gazebo -> ROS 2)

驾驶很好,但我们也需要从模拟器获取数据。最基本的数据是模拟时钟(它确保ROS 2算法与Gazebo的物理时间保持同步)。

在第二个终端中停止桥接器(Ctrl+C),让我们运行一个同时处理/model/vehicle_blue/cmd_vel(发送到Gazebo)和/clock(返回ROS 2)的新桥接器:

ros2 run ros_gz_bridge parameter_bridge \
/model/vehicle_blue/cmd_vel@geometry_msgs/msg/Twist]gz.msgs.Twist \
/clock@rosgraph_msgs/msg/Clock[gz.msgs.Clock

注意时钟的[括号,表示Gazebo正在向ROS 2发送数据。

为了证明它正在工作,转到你的第三个终端(用Ctrl+C停止发布命令)并回显时钟主题:

ros2 topic echo /clock

你应该看到连续的时间数据流从Gazebo物理引擎直接涌入你的ROS 2终端。

clock:
  sec: 527
  nanosec: 401000000
---
clock:
  sec: 527
  nanosec: 402000000
---
clock:
  sec: 527
  nanosec: 403000000

3、生成URDF(让机器人活起来)

在第2章中,我们学习了如何通过驾驶一些默认的预构建方块来与Gazebo通信。但模拟的目标不是驾驶通用形状——而是测试特定的机器人设计。

在这里,我们遇到了一个经典的语言障碍。Gazebo原生使用SDF(仿真描述格式)文件构建其世界和机器人。然而,整个ROS 2生态系统依赖于URDF(统一机器人描述格式)文件。

ros_gz_sim包提供了一个出色的工具——create节点——而不是强迫你手动将URDF重写为SDF。它可以读取你的ROS 2 URDF并将其动态注入到运行中的Gazebo世界中。

让我们使用以下URDF文件进行此测试。

<?xml version="1.0"?>
<robot name="basic_robot">
  <material name="blue">
    <color rgba="0 0 1 1"/>
  </material>
  <material name="red">
    <color rgba="1 0 0 1"/>
  </material>
  <material name="green">
    <color rgba="0 1 0 1"/>
  </material>

  <link name="chassis">
    <visual>
      <geometry>
        <box size="0.825 0.825 0.075"/>
      </geometry>
    </visual>
    <collision>
      <geometry>
        <box size="0.825 0.825 0.075"/>
      </geometry>
    </collision>
    <inertial>
      <mass value="10.0"/>
      <inertia ixx="0.57" ixy="0.0" ixz="0.0" iyy="0.57" iyz="0.0" izz="1.13"/>
    </inertial>
  </link>

  <link name="left_wheel">
    <visual>
      <geometry>
        <cylinder radius="0.4" length="0.05"/>
      </geometry>
      <material name="blue"/>
    </visual>
    <visual>
      <origin xyz="0.2 0 0" rpy="0 0 0"/>
      <geometry>
        <box size="0.1 0.1 0.06"/>
      </geometry>
      <material name="green"/>
    </visual>
    <collision>
      <geometry>
        <cylinder radius="0.4" length="0.05"/>
      </geometry>
    </collision>
    <inertial>
      <mass value="2.0"/>
      <inertia ixx="0.08" ixy="0.0" ixz="0.0" iyy="0.08" iyz="0.0" izz="0.16"/>
    </inertial>
  </link>

  <joint name="left_wheel_joint" type="continuous">
    <parent link="chassis"/>
    <child link="left_wheel"/>
    <origin xyz="0 0.4375 0" rpy="-1.570796 0 0"/>
    <axis xyz="0 0 1"/>
  </joint>

  <link name="right_wheel">
    <visual>
      <geometry>
        <cylinder radius="0.4" length="0.05"/>
      </geometry>
      <material name="red"/>
    </visual>
    <visual>
      <origin xyz="0.2 0 0" rpy="0 0 0"/>
      <geometry>
        <box size="0.1 0.1 0.06"/>
      </geometry>
      <material name="green"/>
    </visual>
    <collision>
      <geometry>
        <cylinder radius="0.4" length="0.05"/>
      </geometry>
    </collision>
    <inertial>
      <mass value="2.0"/>
      <inertia ixx="0.08" ixy="0.0" ixz="0.0" iyy="0.08" iyz="0.0" izz="0.16"/>
    </inertial>
  </link>

  <joint name="right_wheel_joint" type="continuous">
    <parent link="chassis"/>
    <child link="right_wheel"/>
    <origin xyz="0 -0.4375 0" rpy="-1.570796 0 0"/>
    <axis xyz="0 0 1"/>
  </joint>
</robot>

让我们编写一个启动文件来完成这个任务。

步骤1:启动文件架构

我们将创建一个名为spawn_robot.launch.py的新启动文件。这个文件需要同时完成三件事:

  1. 启动带空白世界的Gazebo。
  2. 启动ROS 2 robot_state_publisher(它读取你的basic_robot.urdf并将其物理结构广播到ROS网络)。
  3. 运行Gazebo的create节点来获取广播的结构并将其生成到3D物理引擎中。

步骤2:编写代码

在你的gazebo_control包中创建spawn_robot.launch.py文件并粘贴以下内容:

# launch/spawn_robot.launch.py
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch_ros.actions import Node

def generate_launch_description():
    # 1. Paths to your package and the URDF file
    pkg_gazebo_control = get_package_share_directory('gazebo_control')
    pkg_ros_gz_sim = get_package_share_directory('ros_gz_sim')

    urdf_file = os.path.join(pkg_gazebo_control, 'urdf', 'basic_robot.urdf')
    with open(urdf_file, 'r') as infp:
        robot_desc = infp.read()

    # 2. Start Gazebo with an empty world
    gz_sim = IncludeLaunchDescription(
        PythonLaunchDescriptionSource(
            os.path.join(pkg_ros_gz_sim, 'launch', 'gz_sim.launch.py')
        ),
        launch_arguments={'gz_args': 'empty.sdf -r'}.items(),
    )

    # 3. Start the Robot State Publisher
    robot_state_publisher = Node(
        package='robot_state_publisher',
        executable='robot_state_publisher',
        name='robot_state_publisher',
        output='screen',
        parameters=[{
            'robot_description': robot_desc,
            'use_sim_time': True  # CRITICAL: Tells ROS to use Gazebo's clock!
        }]
    )

    # 4. The Spawner Node: Bridges URDF into Gazebo
    spawn_robot = Node(
        package='ros_gz_sim',
        executable='create',
        arguments=[
            '-topic', 'robot_description', 
            '-name', 'my_custom_diffdrive',
            '-z', '0.5'  # Drop it slightly above the ground
        ],
        output='screen'
    )
    return LaunchDescription([
        gz_sim,
        robot_state_publisher,
        spawn_robot
    ])

步骤3:理解create节点

仔细看上面代码的第4部分。来自ros_gz_simcreate可执行文件在这里承担了主要工作。让我们看看传递给它的参数:

  • -topic robot_description:告诉Gazebo,"嘿,不要在硬盘上找文件了。相反,监听ROS 2的/robot_description主题并构建你听到的内容。"
  • -name my_custom_diffdrive:为我们的机器人在Gazebo实体树中设置特定的命名空间。
  • -z 0.5:这是物理引擎的一个专业技巧。我们将机器人在空中生成0.1米,让它落到地面上。如果你正好在0.0处生成,车轮可能会穿入地面,导致物理引擎将你的机器人猛烈弹射到天空中!

步骤4:启动并验证

构建你的工作空间(colcon build),source你的终端,并运行新的启动文件:

colcon build --packages-select gazebo_control
source install/setup.bash
ros2 launch gazebo_control spawn_robot.launch.py

Gazebo应该会弹出。你应该能看到你的自定义差速驱动机器人——带有平坦底盘、蓝色左轮、红色右轮和绿色激光雷达穹顶——完美地坐在物理模拟器中!

4、传感器集成与RViz2可视化(给机器人眼睛)

在第3章中,我们成功将你的自定义URDF机器人放入了Gazebo物理引擎。它存在于世界中,但目前完全看不见。

机器人需要外感受传感器(如LiDAR或摄像头)来绘制环境地图并避障。在Gazebo中,我们通过将Gazebo插件附加到现有URDF链接来创建传感器。然后,我们使用ros_gz_bridge将丰富的传感器数据从物理引擎传输到ROS 2进行可视化和处理。

让我们把机器人上那个绿色的lidar_dome变成一个全功能的360度激光扫描器。

步骤1:在URDF中添加传感器插件

打开你的basic_robot.urdf文件。在文件最底部(紧邻最后的</robot>标签上方),粘贴这个<gazebo>块:

  <joint name="lidar_joint" type="fixed">
    <parent link="chassis"/>
    <child link="lidar_dome"/>
    <origin xyz="0.3 0 0.0375" rpy="0 0 0"/>
  </joint>

  <gazebo reference="lidar_dome">
    <sensor name="lidar_sensor" type="gpu_lidar">
      <always_on>true</always_on>
      <visualize>true</visualize>
      <update_rate>10</update_rate>
      <topic>scan</topic>
      <gz_frame_id>lidar_dome</gz_frame_id>
      <lidar>
        <scan>
          <horizontal>
            <samples>360</samples>
            <resolution>1</resolution>
            <min_angle>-3.14159</min_angle>
            <max_angle>3.14159</max_angle>
          </horizontal>
        </scan>
        <range>
          <min>0.15</min>
          <max>10.0</max>
          <resolution>0.01</resolution>
        </range>
      </lidar>
    </sensor>
  </gazebo>

这做了什么:

  • reference="lidar_dome":将虚拟传感器精确地附加到绿色球体所在的位置。
  • type="gpu_lidar":告诉Gazebo使用计算机显卡计算激光射线,这比使用CPU快得多。
  • visualize="true":将在Gazebo窗口内绘制蓝色激光射线,让你看到它们击中物体。
  • min/max_angle-3.143.14弧度提供完整的360度扫描。

步骤2:添加仿真描述文件(SDF)

仿真描述格式(SDF)文件定义了你的机器人在Gazebo中模拟的环境("世界")。

虽然URDF(统一机器人描述格式)用于描述机器人本身(其链接、关节和传感器),但SDF用于描述模拟中其他所有内容。

以下是我的my_world.sdf供参考。

<?xml version="1.0" ?>
<sdf version="1.8">
  <world name="my_world">
    <physics name="1ms" type="ignored">
      <max_step_size>0.001</max_step_size>
      <real_time_factor>1.0</real_time_factor>
    </physics>
    <plugin
      filename="gz-sim-physics-system"
      name="gz::sim::systems::Physics">
    </plugin>
    <plugin
      filename="gz-sim-user-commands-system"
      name="gz::sim::systems::UserCommands">
    </plugin>
    <plugin
      filename="gz-sim-scene-broadcaster-system"
      name="gz::sim::systems::SceneBroadcaster">
    </plugin>
    <plugin
      filename="gz-sim-sensors-system"
      name="gz::sim::systems::Sensors">
      <render_engine>ogre2</render_engine>
    </plugin>

    <gui fullscreen="0">
      <plugin filename="MinimalScene" name="3D View">
        <gz-gui>
          <title>3D View</title>
          <property type="bool" key="showTitleBar">false</property>
          <property type="string" key="state">docked</property>
        </gz-gui>
        <engine>ogre2</engine>
        <scene>scene</scene>
        <ambient_light>0.4 0.4 0.4</ambient_light>
        <background_color>0.8 0.8 0.8</background_color>
        <camera_pose>-6 0 6 0 0.5 0</camera_pose>
      </plugin>

      <plugin filename="EntityContextMenuPlugin" name="Entity context menu">
        <gz-gui>
          <property key="state" type="string">floating</property>
          <property key="width" type="double">5</property>
          <property key="height" type="double">5</property>
          <property key="showTitleBar" type="bool">false</property>
        </gz-gui>
      </plugin>
      <plugin filename="GzSceneManager" name="Scene Manager">
        <gz-gui>
          <property key="resizable" type="bool">false</property>
          <property key="width" type="double">5</property>
          <property key="height" type="double">5</property>
          <property key="state" type="string">floating</property>
          <property key="showTitleBar" type="bool">false</property>
        </gz-gui>
      </plugin>
      <plugin filename="InteractiveViewControl" name="Interactive view control">
        <gz-gui>
          <property key="resizable" type="bool">false</property>
          <property key="width" type="double">5</property>
          <property key="height" type="double">5</property>
          <property key="state" type="string">floating</property>
          <property key="showTitleBar" type="bool">false</property>
        </gz-gui>
      </plugin>
      <plugin filename="CameraTracking" name="Camera Tracking">
        <gz-gui>
          <property key="resizable" type="bool">false</property>
          <property key="width" type="double">5</property>
          <property key="height" type="double">5</property>
          <property key="state" type="string">floating</property>
          <property key="showTitleBar" type="bool">false</property>
        </gz-gui>
      </plugin>

      <plugin filename="VisualizeLidar" name="Visualize Lidar">
      </plugin>
    </gui>

    <light type="directional" name="sun">
      <cast_shadows>true</cast_shadows>
      <pose>0 0 10 0 0 0</pose>
      <diffuse>0.8 0.8 0.8 1</diffuse>
      <specular>0.2 0.2 0.2 1</specular>
      <attenuation>
        <range>1000</range>
        <constant>0.9</constant>
        <linear>0.01</linear>
        <quadratic>0.001</quadratic>
      </attenuation>
      <direction>-0.5 0.1 -0.9</direction>
    </light>

    <model name="ground_plane">
      <static>true</static>
      <link name="link">
        <collision name="collision">
          <geometry>
            <plane>
              <normal>0 0 1</normal>
              <size>100 100</size>
            </plane>
          </geometry>
        </collision>
        <visual name="visual">
          <geometry>
            <plane>
              <normal>0 0 1</normal>
              <size>100 100</size>
            </plane>
          </geometry>
          <material>
            <ambient>0.8 0.8 0.8 1</ambient>
            <diffuse>0.8 0.8 0.8 1</diffuse>
            <specular>0.8 0.8 0.8 1</specular>
          </material>
        </visual>
      </link>
    </model>
  </world>
</sdf>

以下是我们创建的my_world.sdf文件的详细解析:

1. 物理和核心系统

  • 物理引擎:设置物理步长和实时因子,告诉Gazebo运行模拟的速度以及计算碰撞和重力的频率。
  • 核心插件:加载必要的Gazebo插件,如gz-sim-physics-system(处理重力和碰撞)和gz-sim-scene-broadcaster-system(将3D数据发送到GUI供查看)。

2. 传感器系统(LiDAR修复)

<plugin
 filename="gz-sim-sensors-system"
 name="gz::sim::systems::Sensors">
 <render_engine>ogre2</render_engine>
 </plugin>

Gazebo中默认的empty.sdf世界默认不加载传感器插件。没有这个插件,Gazebo会忽略你URDF中的<sensor>标签,这就是为什么你的激光雷达最初没有发射射线的原因。这个块告诉Gazebo主动模拟摄像头和激光雷达等传感器。

3. GUI插件

定义Gazebo用户界面。加载3D视图、实体树和相机控制。关键的是,它包含了<plugin filename="VisualizeLidar" name="Visualize Lidar">块,强制Gazebo GUI自动加载激光雷达可视化工具。

4. 环境(太阳和地面)

  • light(太阳):创建一个方向光源,使世界不会漆黑一片并让物体投射阴影。
  • ground_plane模型:创建你的机器人行驶的平坦灰色网格地板。它包括visual(让你可以看到它)和collision(让机器人不会掉入虚空)。

总之,my_world.sdf是舞台、灯光和物理引擎,而你的basic_robot.urdf是在那个舞台上表演的演员!

步骤3:启动世界并添加障碍物

保存你的URDF并运行我们在第3章中构建的启动文件:

ros2 launch en613_control spawn_robot.launch.py

当Gazebo打开时,你现在应该能看到一个淡淡的蓝色激光环从你的机器人穹顶射出!

为了让数据更有趣,使用Gazebo顶部菜单栏(3D形状图标)在激光路径中直接投放几个方块或球体。

步骤4:桥接传感器数据(Gazebo -> ROS 2)

激光正在Gazebo中发射,但ROS 2毫不知情。我们需要桥接/scan主题。

打开第二个终端,source你的环境,运行桥接命令。注意我们使用的是[括号,因为数据从Gazebo流向ROS 2。

ros2 run ros_gz_bridge parameter_bridge /scan@sensor_msgs/msg/LaserScan[gz.msgs.LaserScan

步骤5:在RViz2中可视化

在Gazebo中看到蓝色线条很好,但自主算法不看Gazebo——它们看ROS 2数据。让我们使用RViz2证明数据已经成功通过桥接。

打开第三个终端并启动可视化器:

rviz2

配置RViz2查看激光:

  • 在左侧的"Displays"面板中,将Fixed Frame更改为lidar_dome
  • 点击左下角的**"Add"**按钮。
  • 选择**"By topic"**并双击/scan主题添加LaserScan显示。
  • 在LaserScan显示设置中将"Size"参数增加到0.05,使点更容易看到。

你现在应该在RViz2中看到明亮的红点,形成了你放入Gazebo世界中的方块的精确轮廓!当你在Gazebo中移动方块时,RViz2中的红点将实时更新。

5、变换树(TF2)与让机器人动起来

在第4章中,我们通过添加LiDAR传感器给自定义机器人装上了"眼睛"。然而,它目前只是一个花哨的高科技雕像。它没有电机,更重要的是,ROS 2不知道机器人的物理部件如何运动。

Gazebo完美地处理物理,但RViz2(以及你的自主算法)依赖于TF2(变换)树。TF2是ROS 2的神经系统。它精确追踪chassis相对于世界(odom)的位置,以及车轮相对于chassis的精确位置。

让我们添加虚拟电机并连接这个神经系统!

步骤1:添加"肌肉"(差速驱动插件)

要让你的自定义URDF行驶,我们需要将差速驱动控制器附加到车轮上。

打开你的basic_robot.urdf文件。在底部,紧接LiDAR <gazebo>块下方,添加这个新插件来定义电机:

<gazebo>
    <plugin filename="gz-sim-diff-drive-system" name="gz::sim::systems::DiffDrive">
      <left_joint>left_wheel_joint</left_joint>
      <right_joint>right_wheel_joint</right_joint>
      <wheel_separation>0.875</wheel_separation>
      <wheel_radius>0.4</wheel_radius>
      <odom_publish_frequency>30</odom_publish_frequency>
      <topic>cmd_vel</topic>
      <odom_topic>odom</odom_topic>
      <frame_id>odom</frame_id>
      <child_frame_id>chassis</child_frame_id>
    </plugin>
  </gazebo>

这做了什么: 告诉Gazebo监听/cmd_vel主题并对left_wheel_jointright_wheel_joint施加物理扭矩。它还自动计算机器人的里程计并发布连接世界(odom)和机器人(chassis)的主要TF(变换)。

步骤2:发布车轮角度(关节状态)

差速驱动插件移动机器人,但RViz2不会动画显示车轮旋转,除非它知道它们的确切角度。我们在差速驱动插件下方再添加一个快速插件:

<gazebo>
    <plugin filename="gz-sim-joint-state-publisher-system" name="gz::sim::systems::JointStatePublisher">
      <topic>joint_states</topic>
      <joint_name>left_wheel_joint</joint_name>
      <joint_name>right_wheel_joint</joint_name>
    </plugin>
  </gazebo>

这做了什么: 每当车轮在物理引擎中转动一度的几分之一时,这个插件都会将该关节角度广播到/joint_states主题。

步骤3:终极桥接

我们需要桥接三个关键内容:速度命令(进入)、关节状态(输出)和TF树(输出)。我们可以将ros_gz_bridge直接添加到我们的spawn_robot.launch.py文件中!

bridge = Node(
        package='ros_gz_bridge',
        executable='parameter_bridge',
        arguments=[
            '/cmd_vel@geometry_msgs/msg/Twist]gz.msgs.Twist',
            '/joint_states@sensor_msgs/msg/JointState[gz.msgs.Model',
            '/model/my_custom_diffdrive/tf@tf2_msgs/msg/TFMessage[gz.msgs.Pose_V',
            '/scan@sensor_msgs/msg/LaserScan[gz.msgs.LaserScan'
        ],
        remappings=[
            ('/model/my_custom_diffdrive/tf', '/tf')
        ],
        output='screen'
    )

这更加方便,因为现在单个启动命令将启动Gazebo、生成机器人、启动机器人状态发布器,并为所有主题(cmd_veljoint_statestfscan)启动桥接器。

注意:我们还需要正确的重映射,以便Gazebo的/model/my_custom_diffdrive/tf被转换为RViz期望的标准ROS 2 /tf主题。

关闭所有正在运行的终端,重新构建并启动你的世界:

ros2 launch gazebo_control spawn_robot.launch.py

步骤4:驾驶和可视化!

打开第二个终端并启动RViz2:

rviz2

配置RViz2:

更改Fixed Frame

  • Fixed Frame更改为odom(现在我们有了TF桥接器运行,这将完美运行!)。

添加RobotModel显示

默认情况下,RViz尝试从参数读取机器人描述,但我们的启动文件将其发布到主题。以下是在RViz中修复的方法:

  • 展开左侧面板中的RobotModel显示。
  • 找到名为Description Source的属性。
  • 将其从Topic更改为Topic(如果还不是的话)。
  • 找到名为Description Topic的属性。
  • 点击旁边的空框并输入/robot_description(或从下拉菜单中选择)。

添加LaserScan

  • 添加一个LaserScan显示(主题/scan)。

你将在RViz中看到机器人。

最后,打开第三个终端并发布驾驶命令:

ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 1.0}, angular: {z: 0.5}}"

注意:要停止机器人,运行以下命令。

ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.0}, angular: {z: 0.0}}"

看看RViz2。你不仅应该看到LiDAR数据在更新,而且你还会看到URDF上的车轮在odom坐标系中精确地旋转,同时机器人绕圈行驶,完美地镜像了Gazebo物理窗口中发生的事情!

你将在Gazebo中看到以下内容,即机器人将会移动。

步骤5:使用TeleopTwistKeyboard控制机器人

teleop_twist_keyboard是在ROS 2中使用键盘控制机器人的标准且最简单的方法。

由于我们已经在使用/cmd_vel主题,它将开箱即用,因为teleop_twist_keyboard默认发布到/cmd_vel

使用方法:

1. 运行Teleop节点打开一个新终端(确保已source你的ROS 2安装)并运行:

ros2 run teleop_twist_keyboard teleop_twist_keyboard

如果需要安装此包:

sudo apt install ros-jazzy-teleop-twist-keyboard

2. 如何控制机器人节点运行后,确保你的终端窗口处于聚焦/活动状态。你可以使用以下按键移动机器人:

  • i:前进
  • ,:后退
  • j:左转
  • l:右转
  • k:停止

调整速度:

  • q / z:增加/减少最大速度10%
  • w / x:仅增加/减少线速度10%
  • e / c:仅增加/减少角速度10%

6、优化(组合与YAML桥接)

如果你跟着第5章操作,你可能注意到了一个明显的问题:终端疲劳

要让我们的机器人完全运行,有时我们需要打开几个独立的终端——一个启动模拟、一个桥接传感器、另一个桥接速度和TF数据,还有一个用于RViz2。在专业的机器人环境中,依赖人类输入大量多行终端命令而不出拼写错误是灾难的根源。

生产系统通过使用YAML配置文件并将所有内容打包到单个自动化启动文件来解决这个问题。我们在第5章中已经应用了一些清理,让我们进一步改进。

步骤1:YAML配置文件

与其在终端中使用神秘的括号(][),ros_gz_bridge可以读取一个干净、易读的YAML文件来精确定义数据应该如何在两个生态系统之间流动。

在你的gazebo_control包中,创建一个名为config的新文件夹。在该文件夹中,创建一个名为bridge_config.yaml的文件并粘贴以下内容:

# bridge_config.yaml
# 1. Drive Commands (ROS 2 to Gazebo)
- ros_topic_name: "/cmd_vel"
  gz_topic_name: "/cmd_vel"
  ros_type_name: "geometry_msgs/msg/Twist"
  gz_type_name: "gz.msgs.Twist"
  direction: ROS_TO_GZ
# 2. LiDAR Sensor Data (Gazebo to ROS 2)
- ros_topic_name: "/scan"
  gz_topic_name: "/scan"
  ros_type_name: "sensor_msgs/msg/LaserScan"
  gz_type_name: "gz.msgs.LaserScan"
  direction: GZ_TO_ROS
# 3. Joint States for Wheels (Gazebo to ROS 2)
- ros_topic_name: "/joint_states"
  gz_topic_name: "/joint_states"
  ros_type_name: "sensor_msgs/msg/JointState"
  gz_type_name: "gz.msgs.Model"
  direction: GZ_TO_ROS
# 4. Transform Tree / TF2 (Gazebo to ROS 2)
- ros_topic_name: "/tf"
  gz_topic_name: "/model/my_custom_diffdrive/tf"
  ros_type_name: "tf2_msgs/msg/TFMessage"
  gz_type_name: "gz.msgs.Pose_V"
  direction: GZ_TO_ROS
# 5. Simulation Clock (Gazebo to ROS 2)
- ros_topic_name: "/clock"
  gz_topic_name: "/clock"
  ros_type_name: "rosgraph_msgs/msg/Clock"
  gz_type_name: "gz.msgs.Clock"
  direction: GZ_TO_ROS

看这有多容易阅读!如果你以后添加新传感器(比如摄像头),只需在这个文件中追加一个新块。

步骤2:"一键"启动文件

现在,让我们更新spawn_robot.launch.py来自动加载这个YAML文件并为我们启动桥接节点。

我们需要在现有Python启动文件中添加两样东西:YAML文件的路径和桥接节点本身。以下是直接在spawn_robot节点下方添加的代码:

# Find the config file path
    bridge_params = os.path.join(
        pkg_en613_control,
        'config',
        'bridge_config.yaml'
    )
# 5. Start the Bridge Node using the YAML file
    gz_bridge = Node(
        package='ros_gz_bridge',
        executable='parameter_bridge',
        parameters=[{
            'config_file': bridge_params,
            'use_sim_time': True
        }],
        output='screen'
    )

(不要忘记将gz_bridge添加到文件底部的LaunchDescription数组中!)

步骤3:测试优化后的系统

因为我们添加了新的config文件夹,你需要更新CMakeLists.txtsetup.py以确保config文件夹在构建时被安装,然后运行colcon build

现在,见证真相的时刻。打开一个终端并运行:

ros2 launch en613_control spawn_robot.launch.py

会发生什么? 只需一个命令,你的计算机就会启动Gazebo物理引擎、读取你的自定义URDF、将机器人生成到3D世界中、使用YAML配置建立五个不同数据流的双向桥接,并开始主动将TF树和传感器数据广播到ROS 2网络的其余部分。

打开RViz2,在机器人前面放一个方块,并发布/cmd_vel消息。一切都会完美运行,你不再有充满终端窗口的杂乱桌面!

7、命令机器人前往在RVIZ中选择的目标点

让我们添加PID控制器节点

import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist, PoseStamped
from tf2_ros import Buffer, TransformListener
import math

class DiffDrivePID(Node):
    def __init__(self):
        super().__init__('diffdrive_pid')

        self.tf_buffer = Buffer()
        self.tf_listener = TransformListener(self.tf_buffer, self)

        self.goal_sub = self.create_subscription(
            PoseStamped,
            '/goal_pose',
            self.goal_callback,
            10
        )

        self.cmd_vel_pub = self.create_publisher(
            Twist,
            '/cmd_vel',
            10
        )

        self.timer = self.create_timer(1.0 / 30.0, self.timer_callback)

        self.goal_x = None
        self.goal_y = None
        self.goal_theta = None

        self.prev_x = None
        self.prev_y = None
        self.prev_theta = None
        self.prev_time = None

        # PD Gains
        self.kp_v = 1.0
        self.kd_v = 0.2
        self.kp_omega = 2.0
        self.kd_omega = 0.2

        self.get_logger().info('DiffDrive PID Controller Node started.')

    def goal_callback(self, msg):
        self.goal_x = msg.pose.position.x
        self.goal_y = msg.pose.position.y

        q = msg.pose.orientation
        siny_cosp = 2 * (q.w * q.z + q.x * q.y)
        cosy_cosp = 1 - 2 * (q.y * q.y + q.z * q.z)
        self.goal_theta = math.atan2(siny_cosp, cosy_cosp)

        self.get_logger().info(f'New goal received: x={self.goal_x:.2f}, y={self.goal_y:.2f}, theta={self.goal_theta:.2f}')

    def timer_callback(self):
        if self.goal_x is None:
            return

        try:
            t = self.tf_buffer.lookup_transform('odom', 'chassis', rclpy.time.Time())
        except Exception as e:
            return

        current_time = self.get_clock().now()

        current_x = t.transform.translation.x
        current_y = t.transform.translation.y
        q = t.transform.rotation

        siny_cosp = 2 * (q.w * q.z + q.x * q.y)
        cosy_cosp = 1 - 2 * (q.y * q.y + q.z * q.z)
        current_theta = math.atan2(siny_cosp, cosy_cosp)

        if self.prev_time is None:
            self.prev_x = current_x
            self.prev_y = current_y
            self.prev_theta = current_theta
            self.prev_time = current_time
            return

        dt = (current_time - self.prev_time).nanoseconds / 1e9
        if dt <= 0:
            return

        dx_robot = current_x - self.prev_x
        dy_robot = current_y - self.prev_y
        dtheta_robot = current_theta - self.prev_theta
        dtheta_robot = math.atan2(math.sin(dtheta_robot), math.cos(dtheta_robot))

        current_v = (dx_robot * math.cos(current_theta) + dy_robot * math.sin(current_theta)) / dt
        current_omega = dtheta_robot / dt

        dx_goal = self.goal_x - current_x
        dy_goal = self.goal_y - current_y
        distance_error = math.sqrt(dx_goal**2 + dy_goal**2)

        angle_to_goal = math.atan2(dy_goal, dx_goal)
        heading_error = angle_to_goal - current_theta
        heading_error = math.atan2(math.sin(heading_error), math.cos(heading_error))

        if distance_error < 0.05:
            distance_error = 0.0
            heading_error = self.goal_theta - current_theta
            heading_error = math.atan2(math.sin(heading_error), math.cos(heading_error))

        v_cmd = self.kp_v * distance_error - self.kd_v * current_v
        omega_cmd = self.kp_omega * heading_error - self.kd_omega * current_omega

        v_cmd = max(min(v_cmd, 1.0), -1.0)
        omega_cmd = max(min(omega_cmd, 2.0), -2.0)

        if distance_error < 0.05 and abs(heading_error) < 0.05:
            v_cmd = 0.0
            omega_cmd = 0.0
            self.goal_x = None
            self.get_logger().info('Goal reached!')

        twist = Twist()
        twist.linear.x = float(v_cmd)
        twist.angular.z = float(omega_cmd)
        self.cmd_vel_pub.publish(twist)

        self.prev_x = current_x
        self.prev_y = current_y
        self.prev_theta = current_theta
        self.prev_time = current_time

def main(args=None):
    rclpy.init(args=args)
    node = DiffDrivePID()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()

if __name__ == '__main__':
    main()

使用PID控制器的步骤:

1. 启动模拟在我们的第一个终端中,像之前一样启动Gazebo模拟和RViz:

ros2 launch gazebo_control spawn_robot.launch.py

2. 运行PID控制器打开一个新终端,source你的工作空间,运行PID节点:

source /opt/ros/jazzy/setup.bash
source /home/ubuntu/gazebo/install/setup.bash
ros2 run gazebo_control diffdrive_pid

3. 在RViz中命令机器人

  1. 转到你的RViz窗口。
  2. 在顶部工具栏中,点击"2D Goal Pose"按钮(看起来像一个绿色箭头)。
  3. 在RViz中的网格上点击任意位置并拖动以设置机器人的目标位置和方向(航向)。
  4. 观察机器人在Gazebo中移动到目标位置!PID控制器会自动计算必要的速度并在机器人到达目的地时停止。

如何避免在单独的终端中运行PID节点?

bridge_config.yaml文件专门用于在Gazebo和ROS 2之间桥接数据,所以我们不能把PID控制器放在里面。

然而,我们有更好的解决方案!我们可以直接将PID控制器添加到spawn_robot.launch.py文件中并重新构建包。

现在,当我们运行:

ros2 launch gazebo_control spawn_robot.launch.py

它将自动同时在一个终端中启动GazeboRVizbridgePID控制器!

为什么Gazebo中的机器人与RViz中的最终位置不完全一致?

是的,这完全正常!你所看到的是机器人学中一个叫做里程计漂移的基本概念。

以下是Gazebo中的机器人("真实"世界)最终位置与RViz("机器人大脑")认为的位置略有不同的确切原因:

1. PID控制器如何工作当我们在RViz中点击"2D Goal Pose"时,我们告诉PID控制器:"移动到相对于起始点(odom)的坐标(X, Y)。"PID控制器然后查看机器人在odom坐标系中的当前位置并计算车轮需要多快旋转才能到达目标。

2. 问题:车轮打滑Gazebo是一个物理模拟器。当机器人转弯或加速时,车轮可能会在地面上轻微打滑。然而,odom坐标系(RViz用来绘制机器人的)纯粹通过计算车轮旋转了多少次来计算。

  • Gazebo:"车轮转了10圈,但打滑了一点,所以机器人移动了9.5米。"
  • 里程计(odom):"车轮转了10圈,所以机器人一定精确移动了10.0米。"

3. 结果由于这种打滑,机器人对自己位置的认知(RViz)慢慢偏离了其实际物理位置(Gazebo)。PID控制器成功地将机器人驱动到它认为目标所在的位置(在RViz中),但由于其内部地图略有偏移,Gazebo中的物理机器人最终位置略有偏差。

真正的机器人如何解决这个问题?为了解决这个问题,真正的机器人使用LiDAR或摄像头等传感器观察环境并纠正其里程计漂移。这被称为定位(如AMCL算法)。

当机器人使用定位时,它会创建一个名为map的新坐标系。map坐标系代表世界的真实绝对位置,机器人不断纠正odom坐标系以与之对齐。

由于我们的机器人目前仅依赖车轮编码器(odom)且还没有运行定位算法,两个窗口之间的轻微漂移是完全正常的,并证明了我们的物理模拟运行得很逼真!

为什么Gazebo中的机器人方向与RViz中的相反?

是的,这也是正常的,原因在于两个不同工具中坐标系的视觉表示方式!

ROS 2标准(右手定则)Gazebo和RViz都使用完全相同的数学坐标系(标准ROS 2右手定则):

  • X轴(红色):前方
  • Y轴(绿色):左方
  • Z轴(蓝色):上方

视觉差异混淆来自于我们首次打开窗口时的默认相机角度:

  1. RViz(俯视图):默认情况下,RViz从天空直视下方。
  • X轴(红色)指向屏幕的右侧。
  • Y轴(绿色)指向屏幕的顶部。
  • 所以,如果我们告诉机器人"向下"移动,我们是在告诉它沿负Y方向移动。
  1. Gazebo(3D透视图):Gazebo通常以一个角度打开。根据生成时相机的旋转方式:
  • X轴(红色)可能指向屏幕的"上方"或"远离你"。
  • Y轴(绿色)可能指向"左方"或"右方"。

如何证明它们实际上是匹配的:要看到它们实际上在做完全相同的事情,我们需要对齐我们的相机:

  1. 查看RViz中的网格。找到红线(X轴)和绿线(Y轴)。
  2. 转到Gazebo并旋转你的相机,使你从正上方俯视机器人。
  3. 旋转Gazebo相机,直到地面上的红线和绿线与RViz中的方向完全一致(红色指向右,绿色指向上)。

现在,如果你在RViz中点击"向下",你会看到机器人在Gazebo中也"向下"移动!机器人并不是反向移动;你只是从两个不同的角度在观察它。

8、结束语

你已经成功将自定义ROS 2机器人迁移到Gazebo Ionic仿真环境中。你学会了如何建模物理属性、转换原生数据流、配置虚拟传感器、映射运动学变换树以及编写生产级启动系统。完整代码请参考这里

仿真世界现在就是你的游乐场。祝你构建愉快!


原文链接: Robotics# Gazebo with Ros2

BimAnt翻译整理,转载请标明出处