Building a Map of the Environment Using SLAM – ROS 2 Jazzy

In this tutorial, we will explore SLAM (Simultaneous Localization and Mapping), a fundamental concept in robotics. SLAM allows a robot to create a map of its environment while simultaneously keeping track of its own location within that map. It’s like giving a robot the ability to explore an unknown area, remember where it has been, and use that information to navigate effectively. SLAM combines data from the robot’s sensors, such as cameras, LIDAR, and odometry, to incrementally build a consistent map and estimate the robot’s trajectory.

By the end of this tutorial, you will be able to build this:

mapping-slam-ros2-rviz

Real-World Applications

Real-world applications of what you will learn in this tutorial include:

  • Autonomous navigation for robots in various environments, such as homes, offices, or outdoor settings
  • Mapping and exploration of unknown or dynamic environments
  • Search and rescue operations where a robot needs to navigate and map an area

Prerequisites

All my code for this project is located here on GitHub.

Create Folders to Store Maps and Configuration Files

Open a new terminal window, and type:

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_navigation/

Add two new folders.

mkdir -p config maps

Create a Configuration File for Nav2

Create the File

Now let’s create a file that stores the values for parameters defined for Nav2 nodes. These parameters 

control how the robot builds a map, localizes itself, and navigates.

cd config
touch rosmaster_x3_nav2_default_params.yaml

Add this code.

Save the file.

This file has a lot of values. Don’t be intimidated. Over time you will get to know the meaning of each of these parameters.

If you want a detailed understanding of these parameters, go to this page.

Let’s discuss the main nodes that are responsible for mapping, localization, and obstacle detection.

Understanding the slam_toolbox

The slam_toolbox package helps the robot build a map of its environment as it moves around. SLAM stands for Simultaneous Localization and Mapping. As the robot explores, it uses its sensors like the lidar to detect obstacles. It localizes itself relative to those obstacles to figure out where it is on the map. At the same time, it adds the obstacle information to the map to build a complete picture of the environment.

The slam_toolbox is responsible for publishing the coordinate transformation between the map frame and the odometry (odom) frame. This transformation allows the robot to understand its position and orientation within the map as it moves.

Understanding the amcl

The amcl (Adaptive Monte Carlo Localization) package helps the robot figure out where it is on a known map. This package is important once you have a complete map and want the robot to navigate autonomously.

amcl takes the map and LIDAR data as inputs. It then estimates the robot’s position and orientation by comparing the LIDAR scans to the expected readings at each possible position on the map. It uses a particle filter approach, maintaining a set of “particles” that represent guesses of the robot’s state.

Understanding the global_costmap

A costmap is like a special version of the map used for navigation. In the costmap, each cell has a specific value representing how difficult or dangerous it is to traverse. A value of 0 indicates completely free space, while 254 marks lethal obstacles. Special values like 253 mark inflated obstacle areas, and 255 indicates unknown space. Values between 1-252 represent increasing levels of navigation difficulty.

The navigation algorithms use these cost values to plan paths that steer the robot away from obstacles. You can picture it like hills on a landscape – the robot will naturally take the “valleys” that have low cost while avoiding the “peaks” of high-cost areas.

The global_costmap is used for long-term planning over the entire known map. It has several plugins or “layers” that track different kinds of data:

  • The static_layer holds a copy of the map from the map server
  • The obstacle_layer tracks obstacles detected by the robot’s sensors (typically LIDAR).
  • The voxel_layer tracks 3D data if available (typically RGBD camera).
  • The inflation_layer adds extra costs around obstacles to make the robot keep a safer distance

Understanding the local_costmap

The local_costmap is similar to the global one, but it’s used for short-term planning in a small area right around the robot.

The Difference Between the Global and the Local Costmap

The key differences between the global and local costmap are as follows:

  • The global costmap covers the entire known environment, while the local costmap only covers a small area around the robot.
  • The global costmap is used for big-picture planning of the best route given known obstacles (e.g. walls, sofas, etc.), while the local costmap is used for immediate decisions about avoiding both known (walls, sofas, etc.) and unknown obstacles (e.g. people walking through the environment).

Let’s use an analogy to make this clearer:

  • Global Costmap: Imagine you are planning a road trip across the country. You use a large map to plot the best route from your starting point to your destination, considering major highways and cities. This is like the global costmap, helping you plan the overall journey.
  • Local Costmap: Now, imagine you are driving through your neighborhood. You pay close attention to the streets and obstacles immediately around you, like parked cars or pedestrians, and make quick decisions to navigate through them. This is like the local costmap, helping you with immediate navigation.

So bottom line…the global costmap handles long-term path planning over a wide area, while the local costmap deals with real-time, short-term obstacle avoidance around the robot. By working together, these costmaps enable robots to navigate their environments efficiently and safely.

Create a Velocity Relay Node

We need to create a file that subscribes to the /cmd_vel topic (geometry_msgs/msg/Twist) and publishes to the /mecanum_drive_controller/cmd_vel ( geometry_msgs/msg/TwistStamped) topic.

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_navigation/src
touch cmd_vel_relay_node.cpp

Add this code:

/**
 * @file cmd_vel_relay.cpp
 * @brief Relay node that converts Twist messages to TwistStamped messages
 *
 * This program subscribes to velocity commands published as Twist messages and
 * republishes them as TwistStamped messages. This is useful when interfacing
 * between different ROS2 nodes that expect different message types for velocity
 * commands.
 *
 * Subscription Topics:
 *     /cmd_vel (geometry_msgs/Twist): Raw velocity commands
 *
 * Publishing Topics:
 *     /mecanum_drive_controller/cmd_vel (geometry_msgs/TwistStamped):
 *     Timestamped velocity commands
 *
 * @author Addison Sears-Collins
 * @date November 30, 2024
 */

// Include necessary header files
#include <memory>  // For smart pointers
#include "rclcpp/rclcpp.hpp"  // Main ROS2 C++ library
#include "geometry_msgs/msg/twist.hpp"  // For Twist messages
#include "geometry_msgs/msg/twist_stamped.hpp"  // For TwistStamped messages

/**
 * @brief A ROS2 node that relays velocity commands between different message types
 *
 * This class creates a node that subscribes to Twist messages and republishes
 * them as TwistStamped messages, adding a timestamp and frame ID.
 */
class CmdVelRelay : public rclcpp::Node {
public:
    /**
     * @brief Constructor for the CmdVelRelay node
     *
     * Initializes the node, creates a subscription to the input topic,
     * and sets up a publisher for the output topic.
     */
    CmdVelRelay() : Node("cmd_vel_relay") {
        // Create subscription to /cmd_vel topic
        // The '10' represents the queue size - how many messages to store if we can't process them fast enough
        subscription_ = this->create_subscription<geometry_msgs::msg::Twist>(
            "/cmd_vel", 10,
            std::bind(&CmdVelRelay::cmd_vel_callback, this, std::placeholders::_1));

        // Create publisher for /mecanum_drive_controller/cmd_vel topic
        // Again, '10' is the queue size for outgoing messages
        publisher_ = this->create_publisher<geometry_msgs::msg::TwistStamped>(
            "/mecanum_drive_controller/cmd_vel", 10);

        // Log a message indicating the node has started
        RCLCPP_INFO(this->get_logger(), "Velocity relay node started");
    }

private:
    /**
     * @brief Callback function that handles incoming Twist messages
     *
     * This function is called whenever a new message arrives on the /cmd_vel topic.
     * It creates a new TwistStamped message, copies the velocity data, adds
     * a timestamp and frame ID, and publishes the result.
     *
     * @param msg Shared pointer to the incoming Twist message
     */
    void cmd_vel_callback(const geometry_msgs::msg::Twist::SharedPtr msg) {
        // Create a new TwistStamped message
        auto stamped_msg = std::make_unique<geometry_msgs::msg::TwistStamped>();

        // Set the timestamp to current time
        stamped_msg->header.stamp = this->now();
        // Set the frame ID (coordinate frame this velocity is expressed in)
        stamped_msg->header.frame_id = "base_link";

        // Copy the twist message content (linear and angular velocities)
        stamped_msg->twist = *msg;

        // Publish the stamped message
        // std::move is used for efficient transfer of the message
        publisher_->publish(std::move(stamped_msg));
    }

    // Declare class member variables for the subscriber and publisher
    rclcpp::Subscription<geometry_msgs::msg::Twist>::SharedPtr subscription_;
    rclcpp::Publisher<geometry_msgs::msg::TwistStamped>::SharedPtr publisher_;
};

/**
 * @brief Main function that initializes and runs the node
 *
 * @param argc Number of command line arguments
 * @param argv Array of command line arguments
 * @return int Exit status
 */
int main(int argc, char* argv[]) {
    // Initialize ROS2
    rclcpp::init(argc, argv);

    // Create an instance of our node
    auto node = std::make_shared<CmdVelRelay>();

    // Spin the node, making it respond to incoming messages
    rclcpp::spin(node);

    // Clean up ROS2 resources
    rclcpp::shutdown();

    return 0;
}

Save the file, and close it. 

cd ..

Add these lines to CMakeLists.txt:

add_executable(cmd_vel_relay src/cmd_vel_relay_node.cpp)
ament_target_dependencies(cmd_vel_relay
  rclcpp
  geometry_msgs
)

install(TARGETS
  cmd_vel_relay
  DESTINATION lib/${PROJECT_NAME}
)

Add these lines to package.xml:

<depend>rclcpp</depend>
<depend>geometry_msgs</depend>

Edit the Launch File

To be able to map our environment, we need to edit our launch file.

Open up a new terminal window, and type:

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_bringup/launch

Open rosmaster_x3_navigation_launch.py.

Add this code.

#!/usr/bin/env python3
"""
Launch Nav2 for the Yahboom ROSMASTER X3 robot in Gazebo.

This launch file sets up a complete ROS 2 navigation environment.

:author: Addison Sears-Collins
:date: November 30, 2024
"""

import os
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare


def generate_launch_description():
    """
    Generate a launch description.

    Returns:
        LaunchDescription: A complete launch description for the robot.
    """
    # Constants for paths to different packages
    package_name_gazebo = 'yahboom_rosmaster_gazebo'
    package_name_localization = 'yahboom_rosmaster_localization'
    package_name_navigation = 'yahboom_rosmaster_navigation'

    # Launch and config file paths
    gazebo_launch_file_path = 'launch/yahboom_rosmaster.gazebo.launch.py'
    ekf_launch_file_path = 'launch/ekf_gazebo.launch.py'
    ekf_config_file_path = 'config/ekf.yaml'
    map_file_path = 'maps/cafe_world_map.yaml'
    nav2_params_path = 'config/rosmaster_x3_nav2_default_params.yaml'
    rviz_config_file_path = 'rviz/nav2_default_view.rviz'

    # Set the path to different packages
    pkg_share_gazebo = FindPackageShare(package=package_name_gazebo).find(package_name_gazebo)
    pkg_share_localization = FindPackageShare(
        package=package_name_localization).find(package_name_localization)
    pkg_share_navigation = FindPackageShare(
        package=package_name_navigation).find(package_name_navigation)

    # Set default paths
    default_gazebo_launch_path = os.path.join(pkg_share_gazebo, gazebo_launch_file_path)
    default_ekf_launch_path = os.path.join(pkg_share_localization, ekf_launch_file_path)
    default_ekf_config_path = os.path.join(pkg_share_localization, ekf_config_file_path)
    default_rviz_config_path = os.path.join(pkg_share_navigation, rviz_config_file_path)

    nav2_dir = FindPackageShare(package='nav2_bringup').find('nav2_bringup')
    nav2_launch_dir = os.path.join(nav2_dir, 'launch')
    nav2_params_path = os.path.join(pkg_share_navigation, nav2_params_path)
    static_map_path = os.path.join(pkg_share_navigation, map_file_path)

    # Launch configuration variables
    # Config and launch files
    autostart = LaunchConfiguration('autostart')
    enable_odom_tf = LaunchConfiguration('enable_odom_tf')
    ekf_config_file = LaunchConfiguration('ekf_config_file')
    ekf_launch_file = LaunchConfiguration('ekf_launch_file')
    gazebo_launch_file = LaunchConfiguration('gazebo_launch_file')
    map_yaml_file = LaunchConfiguration('map')
    namespace = LaunchConfiguration('namespace')
    nav2_params_file = LaunchConfiguration('nav2_params_file')
    rviz_config_file = LaunchConfiguration('rviz_config_file')
    slam = LaunchConfiguration('slam')
    use_composition = LaunchConfiguration('use_composition')
    use_namespace = LaunchConfiguration('use_namespace')
    use_respawn = LaunchConfiguration('use_respawn')

    # Robot configuration
    robot_name = LaunchConfiguration('robot_name')
    world_file = LaunchConfiguration('world_file')

    # Position and orientation
    x = LaunchConfiguration('x')
    y = LaunchConfiguration('y')
    z = LaunchConfiguration('z')
    roll = LaunchConfiguration('roll')
    pitch = LaunchConfiguration('pitch')
    yaw = LaunchConfiguration('yaw')

    # Feature flags related to Gazebo and the robot description
    headless = LaunchConfiguration('headless')
    jsp_gui = LaunchConfiguration('jsp_gui')
    load_controllers = LaunchConfiguration('load_controllers')
    use_gazebo = LaunchConfiguration('use_gazebo')
    use_robot_state_pub = LaunchConfiguration('use_robot_state_pub')
    use_rviz = LaunchConfiguration('use_rviz')
    use_sim_time = LaunchConfiguration('use_sim_time')

    # Declare all launch arguments
    # Config and launch files
    declare_autostart_cmd = DeclareLaunchArgument(
        name='autostart',
        default_value='true',
        description='Automatically startup the Nav2 stack')

    declare_ekf_config_file_cmd = DeclareLaunchArgument(
        name='ekf_config_file',
        default_value=default_ekf_config_path,
        description='Full path to the EKF configuration YAML file')

    declare_ekf_launch_file_cmd = DeclareLaunchArgument(
        name='ekf_launch_file',
        default_value=default_ekf_launch_path,
        description='Full path to the EKF launch file to use')

    declare_enable_odom_tf_cmd = DeclareLaunchArgument(
        name='enable_odom_tf',
        default_value='true',
        choices=['true', 'false'],
        description='Whether to enable odometry transform broadcasting via ROS 2 Control')

    declare_gazebo_launch_file_cmd = DeclareLaunchArgument(
        name='gazebo_launch_file',
        default_value=default_gazebo_launch_path,
        description='Full path to the Gazebo launch file to use')

    declare_map_yaml_cmd = DeclareLaunchArgument(
        name='map',
        default_value=static_map_path,
        description='Full path to map file to load')

    declare_namespace_cmd = DeclareLaunchArgument(
        name='namespace',
        default_value='',
        description='Top-level namespace')

    declare_nav2_params_file_cmd = DeclareLaunchArgument(
        name='nav2_params_file',
        default_value=nav2_params_path,
        description='Full path to the ROS2 parameters file to use for navigation nodes')

    declare_rviz_config_file_cmd = DeclareLaunchArgument(
        name='rviz_config_file',
        default_value=default_rviz_config_path,
        description='Full path to the RVIZ config file to use')

    # Robot configuration
    declare_robot_name_cmd = DeclareLaunchArgument(
        name='robot_name',
        default_value='rosmaster_x3',
        description='The name for the robot')

    declare_slam_cmd = DeclareLaunchArgument(
        name='slam',
        default_value='False',
        description='Whether to run SLAM')

    declare_use_namespace_cmd = DeclareLaunchArgument(
        name='use_namespace',
        default_value='false',
        description='Whether to apply a namespace to the navigation stack')

    declare_world_cmd = DeclareLaunchArgument(
        name='world_file',
        default_value='cafe.world',
        description='World file name (e.g., empty.world, house.world)')

    # Position arguments
    declare_x_cmd = DeclareLaunchArgument(
        name='x',
        default_value='0.0',
        description='x component of initial position, meters')

    declare_y_cmd = DeclareLaunchArgument(
        name='y',
        default_value='0.0',
        description='y component of initial position, meters')

    declare_z_cmd = DeclareLaunchArgument(
        name='z',
        default_value='0.20',
        description='z component of initial position, meters')

    # Orientation arguments
    declare_roll_cmd = DeclareLaunchArgument(
        name='roll',
        default_value='0.0',
        description='roll angle of initial orientation, radians')

    declare_pitch_cmd = DeclareLaunchArgument(
        name='pitch',
        default_value='0.0',
        description='pitch angle of initial orientation, radians')

    declare_yaw_cmd = DeclareLaunchArgument(
        name='yaw',
        default_value='0.0',
        description='yaw angle of initial orientation, radians')

    # Feature flags
    declare_headless_cmd = DeclareLaunchArgument(
        name='headless',
        default_value='False',
        description='Whether to execute gzclient (visualization)')

    declare_jsp_gui_cmd = DeclareLaunchArgument(
        name='jsp_gui',
        default_value='false',
        description='Flag to enable joint_state_publisher_gui')

    declare_load_controllers_cmd = DeclareLaunchArgument(
        name='load_controllers',
        default_value='true',
        description='Flag to enable loading of ROS 2 controllers')

    declare_use_composition_cmd = DeclareLaunchArgument(
        name='use_composition',
        default_value='True',
        description='Whether to use composed bringup')

    declare_use_respawn_cmd = DeclareLaunchArgument(
        name='use_respawn',
        default_value='False',
        description='Whether to respawn if a node crashes. Applied when composition is disabled.')

    declare_use_gazebo_cmd = DeclareLaunchArgument(
        name='use_gazebo',
        default_value='true',
        description='Flag to enable Gazebo')

    declare_use_robot_state_pub_cmd = DeclareLaunchArgument(
        name='use_robot_state_pub',
        default_value='true',
        description='Flag to enable robot state publisher')

    declare_use_rviz_cmd = DeclareLaunchArgument(
        name='use_rviz',
        default_value='true',
        description='Flag to enable RViz')

    declare_use_sim_time_cmd = DeclareLaunchArgument(
        name='use_sim_time',
        default_value='true',
        description='Use simulation (Gazebo) clock if true')

    # Specify the actions

    # Start the node that relays /cmd_vel to /mecanum_drive_controller/cmd_vel
    start_cmd_vel_relay_cmd = Node(
        package='yahboom_rosmaster_navigation',
        executable='cmd_vel_relay',
        name='cmd_vel_relay',
        output='screen',
        parameters=[{'use_sim_time': use_sim_time}]
    )

    # Start Extended Kalman Filter node from the robot_localization ROS 2 package
    start_ekf_cmd = IncludeLaunchDescription(
        PythonLaunchDescriptionSource([ekf_launch_file]),
        launch_arguments={
            'ekf_config_file': ekf_config_file,
            'use_sim_time': use_sim_time
        }.items()
    )

    # Start Gazebo
    start_gazebo_cmd = IncludeLaunchDescription(
        PythonLaunchDescriptionSource([gazebo_launch_file]),
        launch_arguments={
            'enable_odom_tf': enable_odom_tf,
            'headless': headless,
            'jsp_gui': jsp_gui,
            'load_controllers': load_controllers,
            'robot_name': robot_name,
            'rviz_config_file': rviz_config_file,
            'use_rviz': use_rviz,
            'use_gazebo': use_gazebo,
            'use_robot_state_pub': use_robot_state_pub,
            'use_sim_time': use_sim_time,
            'world_file': world_file,
            'x': x,
            'y': y,
            'z': z,
            'roll': roll,
            'pitch': pitch,
            'yaw': yaw
        }.items()
    )

    # Launch the ROS 2 Navigation Stack
    start_ros2_navigation_cmd = IncludeLaunchDescription(
        PythonLaunchDescriptionSource(os.path.join(nav2_launch_dir, 'bringup_launch.py')),
        launch_arguments={
            'namespace': namespace,
            'use_namespace': use_namespace,
            'slam': slam,
            'map': map_yaml_file,
            'use_sim_time': use_sim_time,
            'params_file': nav2_params_file,
            'autostart': autostart,
            'use_composition': use_composition,
            'use_respawn': use_respawn,
        }.items()
    )

    # Create the launch description and populate
    ld = LaunchDescription()

    # Add all launch arguments
    # Config and launch files
    ld.add_action(declare_autostart_cmd)
    ld.add_action(declare_enable_odom_tf_cmd)
    ld.add_action(declare_ekf_config_file_cmd)
    ld.add_action(declare_ekf_launch_file_cmd)
    ld.add_action(declare_gazebo_launch_file_cmd)
    ld.add_action(declare_map_yaml_cmd)
    ld.add_action(declare_namespace_cmd)
    ld.add_action(declare_nav2_params_file_cmd)
    ld.add_action(declare_rviz_config_file_cmd)
    ld.add_action(declare_slam_cmd)
    ld.add_action(declare_use_composition_cmd)
    ld.add_action(declare_use_namespace_cmd)
    ld.add_action(declare_use_respawn_cmd)

    # Robot configuration
    ld.add_action(declare_robot_name_cmd)
    ld.add_action(declare_world_cmd)

    # Position declarations
    ld.add_action(declare_x_cmd)
    ld.add_action(declare_y_cmd)
    ld.add_action(declare_z_cmd)

    # Orientation declarations
    ld.add_action(declare_roll_cmd)
    ld.add_action(declare_pitch_cmd)
    ld.add_action(declare_yaw_cmd)

    # Feature flags
    ld.add_action(declare_headless_cmd)
    ld.add_action(declare_jsp_gui_cmd)
    ld.add_action(declare_load_controllers_cmd)
    ld.add_action(declare_use_gazebo_cmd)
    ld.add_action(declare_use_robot_state_pub_cmd)
    ld.add_action(declare_use_rviz_cmd)
    ld.add_action(declare_use_sim_time_cmd)

    # Add any actions
    ld.add_action(start_cmd_vel_relay_cmd)
    ld.add_action(start_ekf_cmd)
    ld.add_action(start_gazebo_cmd)
    ld.add_action(start_ros2_navigation_cmd)

    return ld

Save the file, and close it.

Edit CMakeLists.txt

Edit the CMakeLists.txt file for the yahboom_rosmaster_navigation package.

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_navigation/

Make sure to add these lines:

…

install(
  DIRECTORY config maps rviz
  DESTINATION share/${PROJECT_NAME}
)

if(BUILD_TESTING)
  find_package(ament_lint_auto REQUIRED)
  # the following line skips the linter which checks for copyrights
  # comment the line when a copyright and license is added to all source files
  set(ament_cmake_copyright_FOUND TRUE)
  # the following line skips cpplint (only works in a git repo)
  # comment the line when this package is in a git repo and when
  # a copyright and license is added to all source files
  set(ament_cmake_cpplint_FOUND TRUE)
  ament_lint_auto_find_test_dependencies()
endif()

ament_package()

Save the file, and close it.

Edit the Launch Script

Open up a new terminal window, and type:

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_bringup/scripts/

Edit rosmaster_x3_navigation.sh so we can pass a boolean variable called slam to it.

#!/bin/bash
# Single script to launch the Yahboom ROSMASTERX3 with Gazebo, Nav2 and ROS 2 Controllers

cleanup() {
    echo "Cleaning up..."
    sleep 5.0
    pkill -9 -f "ros2|gazebo|gz|nav2|amcl|bt_navigator|nav_to_pose|rviz2|assisted_teleop|cmd_vel_relay|robot_state_publisher|joint_state_publisher|move_to_free|mqtt|autodock|cliff_detection|moveit|move_group|basic_navigator"
}

# Set up cleanup trap
trap 'cleanup' SIGINT SIGTERM

# Check if SLAM argument is provided
if [ "$1" = "slam" ]; then
    SLAM_ARG="slam:=True"
else
    SLAM_ARG="slam:=False"
fi

# For cafe.world -> z:=0.20
# For house.world -> z:=0.05
# To change Gazebo camera pose: gz service -s /gui/move_to/pose --reqtype gz.msgs.GUICamera --reptype gz.msgs.Boolean --timeout 2000 --req "pose: {position: {x: 0.0, y: -2.0, z: 2.0} orientation: {x: -0.2706, y: 0.2706, z: 0.6533, w: 0.6533}}"

echo "Launching Gazebo simulation with Nav2..."
ros2 launch yahboom_rosmaster_bringup rosmaster_x3_navigation_launch.py \
   enable_odom_tf:=false \
   headless:=False \
   load_controllers:=true \
   world_file:=cafe.world \
   use_rviz:=true \
   use_robot_state_pub:=true \
   use_sim_time:=true \
   x:=0.0 \
   y:=0.0 \
   z:=0.20 \
   roll:=0.0 \
   pitch:=0.0 \
   yaw:=0.0 \
   "$SLAM_ARG" \
   map:=/home/ubuntu/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_navigation/maps/cafe_world_map.yaml &

echo "Waiting 25 seconds for simulation to initialize..."
sleep 25
echo "Adjusting camera position..."
gz service -s /gui/move_to/pose --reqtype gz.msgs.GUICamera --reptype gz.msgs.Boolean --timeout 2000 --req "pose: {position: {x: 0.0, y: -2.0, z: 2.0} orientation: {x: -0.2706, y: 0.2706, z: 0.6533, w: 0.6533}}"

# Keep the script running until Ctrl+C
wait

Save the file, and close it.

Build the Package

Now let’s build our new package:

cd ~/ros2_ws
colcon build 
source ~/.bashrc

Launch the Robot and Mapping

Let’s start mapping. Let’s bring our robot to life. Open a terminal window, and use this command to launch the robot:

nav slam

or

bash ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_bringup/scripts/rosmaster_x3_navigation.sh slam

This command launches the Gazebo simulation with the Yahboom ROSMASTER X3 robot, and sets the slam argument to True. This tells the launch file to start the slam_toolbox node, which will perform SLAM as the robot moves around.

creating-a-map-slam-ros2-jazzy
creating-a-map-slam-ros2-jazzy-2
creating-a-map-slam-ros2-jazzy-4

Let’s check out the velocity data. We have many topics, which you can see if you type this command:

ros2 topic list

Let’s go through each velocity topic to understand the publishers and subscribers of each topic.

ros2 topic info /cmd_vel_nav -v

/cmd_vel_nav (geometry_msgs/msg/Twist)

  • Publishers: controller_server and behavior_server 
  • Subscribers: velocity_smoother
ros2 topic info /cmd_vel_smoothed -v

/cmd_vel_smoothed (geometry_msgs/msg/Twist)

  • Publishers: velocity_smoother
  • Subscribers: collision_monitor
ros2 topic info /cmd_vel -v

/cmd_vel (geometry_msgs/msg/Twist)

  • Publishers: collision_monitor and docking_server 
  • Subscribers: cmd_vel_relay
ros2 topic info /mecanum_drive_controller/cmd_vel -v

/mecanum_drive_controller/cmd_vel ( geometry_msgs/msg/TwistStamped)

  • Publishers: cmd_vel_relay
  • Subscribers: ros_gz_bridge (Gazebo) and mecanum_drive_controller (ROS 2 Control)
ros2 topic info /cmd_vel_teleop -v

/cmd_vel_teleop (geometry_msgs/msg/Twist)

  • Publishers: None
  • Subscribers: behavior_server

In RViz, you’ll notice a visualization of the robot in an empty grey space. This grey space will fill in with the map as we move the robot around. Let’s do that now.

You can now either drive the robot around manually by moving the virtual joystick:

rqt_robot_steering

Set the topic to /cmd_vel.

Or you can send the robot to goals at the edge of the white (i.e. free space). 

Explore all areas of the simulated environment to ensure good map coverage.

As you move the robot, you will see the map update in real-time in RViz:

  • Grey areas represent unexplored space
  • White areas represent free space that the robot has mapped with its laser scanner
  • Black areas represent obstacles or walls that the robot has detected

The map will gradually fill in as the robot covers more ground. Drive the robot around until you have a complete map of the environment.

Some tips for mapping for real-world robots:

  • Drive slowly and smoothly to minimize odometry errors and ensure clear laser scans
  • Overlap your paths to give the SLAM algorithm plenty of opportunities to “close the loop” and correct for accumulated errors
  • If the map starts to look distorted or misaligned, you may need to adjust the SLAM parameters or start over in a new area

Once you’re satisfied with your map, you can save it using the map_saver utility, which we’ll cover in the next section.

Save the Map

When see that you have a good map that you want to save, do this:

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_navigation/maps/
ros2 run nav2_map_server map_saver_cli -f cafe_world_map

The syntax is:

ros2 run nav2_map_server map_saver_cli -f <map_name>

Your cafe_world_map.pgm and cafe_world_map.yaml files will automatically save to the current directory.

Let’s go through the fields in the yaml file. This file provides all the necessary information to interpret the cafe_world_map.pgm image correctly, including how to scale it to real-world dimensions, where to place it in the world, and how to differentiate between free space, obstacles, and unknown areas:

image: cafe_world_map.pgm

This specifies the filename of the PGM (Portable Gray Map) image file that represents the actual occupancy grid map.

mode: trinary

The mode parameter sets the interpretation of the map data in the image. “trinary” means each pixel has three possible values – free (0-254), unknown (205), or occupied (255).

resolution: 0.05

Resolution is the real-world size in meters of each pixel in the map image. Here, each pixel represents a 5cm x 5cm area.

origin: [-5.03, -11.1, 0] (for example)

This parameter sets the origin point of the map in the real world. The coordinates [-5.03, -11.1, 0] tell you where the bottom-left corner of the map image is located in the real-world space. The first two numbers are the x and y positions, and the third number (0) is the orientation (in radians).

Suppose the origin value is [-5.03, -11.1, 0].

This means the bottom-left corner of the map image is positioned at -5.03 meters on the x-axis and -11.1 meters on the y-axis from the [x=0, y=0, yaw=0] location of the map in the real-world coordinate system.

The third value 0 indicates the orientation, which is the rotation around the z-axis. In this case, it is 0 radians, meaning no rotation.

negate: 0

This indicates whether the colors in the image need to be inverted. A value of 0 means no inversion is needed. It’s like saying the colors in the picture are already correct and don’t need to be flipped.

occupied_thresh: 0.65

This sets the threshold for determining which pixels in the image represent occupied space (e.g., walls, obstacles). If a pixel’s value is above 0.65, it is considered occupied.

free_thresh: 0.25

This sets the threshold for determining which pixels represent free space (e.g., navigable areas). If a pixel’s value is below 0.25, it is considered free.

View and Edit the Map

To view and edit the map, I recommend using a program like GIMP.

Install gimp.

sudo apt-get update
sudo apt-get install gimp
cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_navigation/maps/

Run gimp.

gimp cafe_world_map.pgm

Once the map is loaded in GIMP, you can edit it. Here are some tips:

  • Use the Pencil or Brush tool to draw on the map. Drawing with black (RGB 0,0,0) will add obstacles/walls. Drawing with white (RGB 255,255,255) will erase obstacles and create free space. I like to draw with white to clean out salt and pepper noise from the LIDAR.
  • Use the Eraser tool to remove obstacles and create free space.
  • Focus on cleaning up major errors like incorrect walls or obstacles. Don’t get too caught up in tiny details.
  • Always make a backup of your original map files before editing, in case you make a mistake or want to revert changes.
1-edit-the-map-using-gimp

After making your edits, save the file with File -> Export. Make sure to export it in the .pgm format with the same name as the original map file.

With your map cleaned up and saved, your robot will have a more accurate representation of its environment for navigation. 

That’s it. Keep building!

creating-map-house-world

Exploring the Common Sensors for Nav2

In this tutorial, we will explore the various sensors commonly used in mobile robotics for navigation and mapping using Nav2

Mobile robots rely on a wide array of sensors to perceive and understand their surroundings, enabling them to:

  • Build accurate maps
  • Localize themselves within those maps
  • Detect obstacles for safe navigation through dynamic environments

Prerequisites

All my code for this project is located here on GitHub.

Common Sensors

Some of the most frequently used sensors in mobile robotics include:

  • Lidar
  • IMUs
  • Depth cameras
  • RGB cameras
  • GPS

The mobile robot I use for the examples in this tutorial is equipped with simulated LIDAR, IMU, and a depth camera.

An RGB camera is nice to have but not critical for navigation. 

GPS is not relevant to use because our robot is designed for indoor navigation.

For a real robot, instead of using simulated sensors, we would use real hardware. Fortunately, most of the popular LIDARs, IMUs, and depth cameras on the market have ROS 2 publishers you can find on GitHub that will read the data from the sensor and publish that data in a ROS 2-compatible format. These ROS 2 publishers are also known as “drivers.”

How ROS 2 Helps Sensors Work Together

The beauty of ROS 2 is how it makes it easier for different types of sensors to work together, even if they’re made by different companies. It does this by providing special packages that define standard ways for sensors to communicate.

The sensor_msgs package creates a common language for a variety of sensors. This means that you can use any sensor as long as it speaks this common language. 

Some of the most important messages for navigation include:

  • sensor_msgs/LaserScan: Used for LIDARs
  • sensor_msgs/PointCloud2: Used for depth cameras
  • sensor_msgs/Range: Used for distance sensors, both infrared and ultrasonic sensors
  • sensor_msgs/Image: Used for cameras

In addition to the sensor_msgs package, we have two other packages that you should know about if you come across these during your robotics career:

radar_msgs

The radar_msgs package is specifically for radar sensors and defines how they should communicate.

vision_msgs

The vision_msgs package is for sensors used in computer vision, like cameras that can detect and recognize objects or people. Some of the messages it supports include:

  • vision_msgs/Classification2D and vision_msgs/Classification3D: Used for identifying objects in 2D or 3D
  • vision_msgs/Detection2D and vision_msgs/Detection3D: Used for detecting objects in 2D or 3D

As mentioned earlier, most real robots come with special programs called ROS drivers that allow their sensors to talk using the common language defined in these packages. This makes it easier to use sensors from different manufacturers because general software packages like Nav2 can understand this common language and do their job no matter what specific sensor hardware is being used.

In simulated environments, like what we will use in this tutorial, special plugins, which we defined in the URDF file, are used to make the simulated sensors communicate using the sensor_msgs package format.

Visualizing Sensor Data in RViz

Let’s take a look at the sensor data we have to work with on our simulated robot. Open a terminal window, and type this command:

nav

or

bash ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_bringup/scripts/rosmaster_x3_navigation.sh

In the “Fixed Frame” field, enter odom as the Fixed Frame.

Let’s look at the sensor readings.

Click the “Add” button at the bottom of the RViz window.

Click the “By topic” tab.

Select the “/scan” topic so we can view the sensor_msgs/LaserScan messages.

On the left panel, set the Reliability Policy in RViz to “Best Effort”. Setting the Reliability Policy to “Best Effort” for a LIDAR sensor means that RViz will display the most recently received data from the sensor, even if some data packets have been lost or delayed along the way.

Now set the size to 0.1 so it is easier to see the scans. You can see the scans match up pretty well with the detected objects around the robot in the Gazebo environment.

1-lidar-scan-data
2-laser-scan-data

Let’s look at the IMU information.

Click the “Add” button at the bottom of the RViz window.

Click the “By topic” tab.

Select the “/imu/data” topic so we can view the sensor_msgs/Imu messages.

You will see the IMU orientation and acceleration visualized using the IMU plugin.

3-imu-information

Observe how the visualizations change in real-time as your robot moves and interacts with the environment.

ros2 topic pub /mecanum_drive_controller/cmd_vel geometry_msgs/msg/TwistStamped "{
  header: {
    stamp: {sec: `date +%s`, nanosec: 0},
    frame_id: ''
  },
  twist: {
    linear: {
      x: 0.05,
      y: 0.0,
      z: 0.0
    },
    angular: {
      x: 0.0,
      y: 0.0,
      z: 0.0
    }
  }
}"

That’s it! Keep building!

Sensor Fusion and Robot Localization Using ROS 2 Jazzy

When you build a mobile robot, one of the most important things to consider is how the robot will figure out where it is and which direction it’s facing. This process is called localization

Imagine you’re blindfolded and placed in a room. You might be able to feel around and make guesses about where you are based on what you touch, but it would be much easier if you could see.

This is where the ROS2 robot_localization package comes in. It’s like giving your robot a special superpower to combine (or “fuse”) information from different sensors, like wheel encoders and an Inertial Measurement Unit (IMU), to better understand its position and orientation. 

In this tutorial, I will show you how to set up the robot_localization ROS 2 package on a simulated mobile robot. We will fuse odometry data from the /mecanum_drive_controller/odom topic (which is like the robot counting its own steps) with IMU data from the /imu/data topic (which tells the robot if it’s tilting or turning). By combining these two sources of information using a special mathematical tool called an Extended Kalman Filter, our robot will have a much better idea of where it is and which way it’s pointing.

Why is this important? Here are a few reasons:

  1. Correcting Wheel Slip: Sometimes the robot’s wheels can slip, causing inaccurate data. By fusing data from both the wheels and the IMU, we can correct these errors and improve the robot’s navigation.
  2. Enhancing Navigation: Accurate data from sensor fusion allows the robot to navigate more efficiently, making fewer mistakes and taking more precise paths.
  3. Improving Reliability: Combining multiple data sources makes the robot’s movement data more reliable, even if one of the sensors temporarily fails or provides bad or noisy data.
  4. Optimizing Performance: With smoother and more accurate movement data, the robot can perform tasks more effectively, whether it’s moving objects, exploring new environments, or performing complex maneuvers.

Let’s get started!

Prerequisites

All my code for this project is located here on GitHub.

What is an Extended Kalman Filter?

The engine behind the robot_localization package for ROS 2 is an Extended Kalman Filter (EKF). The default name of the ROS 2 node is ekf_node.

An EKF is a mathematical algorithm that combines data from different sensors to figure out a robot’s position, which way it’s facing, and how fast it’s moving. It does this by constantly making educated guesses based on how the robot is expected to move, and then fine-tuning these guesses using the actual sensor readings. This helps to smooth out any noise or inaccuracies in the sensor data, giving us a cleaner and more reliable estimate of where the robot is and what it’s doing.

One of the best things about the EKF is that it can handle noisy sensor data. It’s smart enough to trust the sensors that are more likely to be accurate, while still using data from the less precise sensors. This means that even if one sensor is a bit inaccurate, the EKF can still do a good job of locating the robot by relying more on the other sensors.

Another great aspect of the EKF is that it can keep working even if a sensor stops providing data for a little while. It does this by using its educated guesses and the last known sensor readings to keep tracking the robot. When the sensor starts working again, the EKF smoothly brings the new data back into the mix. This helps to keep the robot’s location estimates reliable, even if things get a bit tricky in the real world.

The ekf_node we will configure in this tutorial will subscribe to the following topics (ROS message types are in parentheses):

This node will publish data to the following topics:

  • /odometry/filtered : The smoothed odometry information (nav_msgs/Odometry) generated by fusing the IMU and wheel odometry data.
  • /tf : Coordinate transform from the odom frame (parent) to the base_footprint frame (child).

Configure Robot Localization

We need to specify the configuration parameters of the ekf_node by creating a YAML file. Your friend for configuring the robot_localization package for your robot is the official documentation at this website.

Let’s get into it!

Open a new terminal window, and type:

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_localization/
mkdir -p config && cd config
touch ekf.yaml

Add this code inside this file:

### ekf config file ###
ekf_filter_node:
    ros__parameters:

# The frequency, in Hz, at which the filter will output a position estimate. Note that the filter will not begin
# computation until it receives at least one message from one of the inputs. It will then run continuously at the
# frequency specified here, regardless of whether it receives more measurements. Defaults to 30 if unspecified.
        frequency: 15.0

# ekf_localization_node and ukf_localization_node both use a 3D omnidirectional motion model. If this parameter is
# set to true, no 3D information will be used in your state estimate. Use this if you are operating in a planar
# environment and want to ignore the effect of small variations in the ground plane that might otherwise be detected
# by, for example, an IMU. Defaults to false if unspecified.
        two_d_mode: true

# Whether to broadcast the transformation over the /tf topic. Defaults to true if unspecified.
        publish_tf: true

# REP-105 (http://www.ros.org/reps/rep-0105.html) specifies four principal coordinate frames: base_link, odom, map, and
# earth. base_link is the coordinate frame that is affixed to the robot. Both odom and map are world-fixed frames.
# The robot's position in the odom frame will drift over time, but is accurate in the short term and should be
# continuous. The odom frame is therefore the best frame for executing local motion plans. The map frame, like the odom
# frame, is a world-fixed coordinate frame, and while it contains the most globally accurate position estimate for your
# robot, it is subject to discrete jumps, e.g., due to the fusion of GPS data or a correction from a map-based
# localization node. The earth frame is used to relate multiple map frames by giving them a common reference frame.
# ekf_localization_node and ukf_localization_node are not concerned with the earth frame.
# Here is how to use the following settings:
# 1. Set the map_frame, odom_frame, and base_link frames to the appropriate frame names for your system.
#     1a. If your system does not have a map_frame, just remove it, and make sure "world_frame" is set to the value of
#         odom_frame.
# 2. If you are fusing continuous position data such as wheel encoder odometry, visual odometry, or IMU data, set
#   "world_frame" to your odom_frame value. This is the default behavior for robot_localization's state estimation nodes.
# 3. If you are fusing global absolute position data that is subject to discrete jumps (e.g., GPS or position updates
# from landmark observations) then:
#     3a. Set your "world_frame" to your map_frame value
#     3b. MAKE SURE something else is generating the odom->base_link transform. Note that this can even be another state
#         estimation node from robot_localization! However, that instance should *not* fuse the global data.
        map_frame: map                   # Defaults to "map" if unspecified
        odom_frame: odom                 # Defaults to "odom" if unspecified
        base_link_frame: base_footprint  # Defaults to "base_link" if unspecified
        world_frame: odom                # Defaults to the value of odom_frame if unspecified

# The filter accepts an arbitrary number of inputs from each input message type (nav_msgs/Odometry,
# geometry_msgs/PoseWithCovarianceStamped, geometry_msgs/TwistWithCovarianceStamped,
# sensor_msgs/Imu). To add an input, simply append the next number in the sequence to its "base" name, e.g., odom0,
# odom1, twist0, twist1, imu0, imu1, imu2, etc. The value should be the topic name. These parameters obviously have no
# default values, and must be specified.
        odom0: mecanum_drive_controller/odom

# Each sensor reading updates some or all of the filter's state. These options give you greater control over which
# values from each measurement are fed to the filter. For example, if you have an odometry message as input, but only
# want to use its Z position value, then set the entire vector to false, except for the third entry. The order of the
# values is x, y, z, roll, pitch, yaw, vx, vy, vz, vroll, vpitch, vyaw, ax, ay, az. Note that not some message types
# do not provide some of the state variables estimated by the filter. For example, a TwistWithCovarianceStamped message
# has no pose information, so the first six values would be meaningless in that case. Each vector defaults to all false
# if unspecified, effectively making this parameter required for each sensor.
        odom0_config: [false, false, false,
                       false, false, false,
                       true, true, false,
                       false, false, true,
                       false, false, false]

        #        [x_pos   , y_pos    , z_pos,
        #         roll    , pitch    , yaw,
        #         x_vel   , y_vel    , z_vel,
        #         roll_vel, pitch_vel, yaw_vel,
        #         x_accel , y_accel  , z_accel]


        imu0: imu/data
        imu0_config: [false, false, false,
                      false, false, false,
                      false, false, false,
                      false, false, true,
                      true, true, false]

Let’s walk through this file together.

First, let’s talk about the frequency parameter. This parameter determines how often the filter provides updated location data. In our case, it’s set to 15 Hz, which means the robot gets a new position estimate 15 times every second.

Next, we have the two_d_mode setting. By setting this to true, we’re telling the filter to focus only on the robot’s position in a flat, two-dimensional plane. This means it will ignore any small up and down movements, which is perfect for our robot since it has mecanum wheels that allow it to move smoothly on a flat surface.

Now, let’s discuss coordinate frames. The configuration file specifies three main frames: map_frame, odom_frame, and base_link_frame. These frames help the robot understand its location from different perspectives. 

The map_frame represents the robot’s position in the overall environment.

The odom_frame tracks the robot’s movement from its starting point

I like to set the base_link_frame to the base_footprint.

The world_frame is a special coordinate frame that serves as a reference for the other frames. In the configuration file, it’s set to the same value as the odom_frame. This means that the robot’s odometry frame, which tracks its movement from its starting point, is being used as the reference frame for the entire system.

Using the odom_frame as the world_frame is a common practice when the robot is primarily relying on continuous position data from sources like wheel encoders, visual odometry, or IMU data. By setting the world_frame to the odom_frame, we’re essentially telling the robot that its odometry data is the most reliable source for tracking its overall position and movement.

However, there are cases where you might want to set the world_frame to the map_frame instead. This is typically done when the robot is using global absolute position data that may be subject to sudden jumps or corrections, such as GPS data or position updates from recognizing landmarks. In such cases, setting the world_frame to the map_frame helps the robot maintain a more accurate global position estimate.

To estimate the robot’s position and movement, the ekf_filter_node needs data from the robot’s sensors. This is where the odom0 and imu0 parameters come into play. 

odom0 refers to the wheel odometry data, which is used to estimate the robot’s velocity in the x and y directions, as well as its rotation speed around the vertical axis (yaw). The odom0_config parameter specifies which aspects of the odometry data should be used by the filter.

Similarly, imu0 refers to the data from the Inertial Measurement Unit (IMU), which provides information about the robot’s acceleration and rotation. The imu0_config parameter defines which aspects of the IMU data should be used by the filter.

By putting all these settings together in the configuration file, we give our robot the tools it needs to understand its position and movement as it navigates through its environment on its mecanum wheels. This setup enables the robot to perform tasks more effectively and safely, as it has a clear understanding of its location and how it is moving.

Create Launch Files

Now let’s create a launch file.

Open up a new terminal window, and type:

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_localization/
mkdir -p launch/ && cd launch
touch ekf_gazebo.launch.py

Add this code.

#!/usr/bin/env python3
"""
Launch file for the Extended Kalman Filter (EKF) node in Gazebo simulation.

This script starts the robot_localization package's EKF node which combines (fuses)
data from wheel odometry and IMU sensors to better estimate the robot's position
and orientation.

:author: Addison Sears-Collins
:date: November 29, 2024
"""

import os
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare


def generate_launch_description():
    """
    Generate a launch description for the EKF node.

    This function creates and returns a LaunchDescription object that will start
    the EKF node from the robot_localization package. The node is configured
    using parameters from a YAML file.

    Returns:
        LaunchDescription: A complete launch description for the EKF node
    """
    # Constants for paths to different files and folders
    package_name = 'yahboom_rosmaster_localization'

    # Config file paths
    ekf_config_file_path = 'config/ekf.yaml'

    # Set the path to different packages
    pkg_share = FindPackageShare(package=package_name).find(package_name)

    # Set the path to config files
    default_ekf_config_path = os.path.join(pkg_share, ekf_config_file_path)

    # Launch configuration variables
    ekf_config_file = LaunchConfiguration('ekf_config_file')
    use_sim_time = LaunchConfiguration('use_sim_time')

    # Declare the launch arguments
    declare_ekf_config_file_cmd = DeclareLaunchArgument(
        name='ekf_config_file',
        default_value=default_ekf_config_path,
        description='Full path to the EKF configuration YAML file'
    )

    declare_use_sim_time_cmd = DeclareLaunchArgument(
        name='use_sim_time',
        default_value='true',
        description='Use simulation (Gazebo) clock if true'
    )

    # Specify the actions
    start_ekf_node_cmd = Node(
        package='robot_localization',
        executable='ekf_node',
        name='ekf_filter_node',
        output='screen',
        parameters=[
            ekf_config_file,
            {'use_sim_time': use_sim_time}
        ]
    )

    # Create the launch description and populate
    ld = LaunchDescription()

    # Add the declarations
    ld.add_action(declare_ekf_config_file_cmd)
    ld.add_action(declare_use_sim_time_cmd)

    # Add the actions
    ld.add_action(start_ekf_node_cmd)

    return ld

Save the file, and close it.

Open up a new terminal window, and type:

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_bringup/launch
touch rosmaster_x3_navigation_launch.py

Add this code.

#!/usr/bin/env python3
"""
Launch Nav2 for the Yahboom ROSMASTER X3 robot in Gazebo.

This launch file sets up a complete ROS 2 navigation environment.

:author: Addison Sears-Collins
:date: November 29, 2024
"""

import os
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration
from launch_ros.substitutions import FindPackageShare


def generate_launch_description():
    """
    Generate a launch description.

    Returns:
        LaunchDescription: A complete launch description for the robot.
    """
    # Constants for paths to different packages
    package_name_gazebo = 'yahboom_rosmaster_gazebo'
    package_name_localization = 'yahboom_rosmaster_localization'

    # Launch and config file paths
    gazebo_launch_file_path = 'launch/yahboom_rosmaster.gazebo.launch.py'
    ekf_launch_file_path = 'launch/ekf_gazebo.launch.py'
    ekf_config_file_path = 'config/ekf.yaml'
    rviz_config_file_path = 'rviz/yahboom_rosmaster_gazebo_sim.rviz'

    # Set the path to different packages
    pkg_share_gazebo = FindPackageShare(package=package_name_gazebo).find(package_name_gazebo)
    pkg_share_localization = FindPackageShare(
        package=package_name_localization).find(package_name_localization)

    # Set default paths
    default_gazebo_launch_path = os.path.join(pkg_share_gazebo, gazebo_launch_file_path)
    default_ekf_launch_path = os.path.join(pkg_share_localization, ekf_launch_file_path)
    default_ekf_config_path = os.path.join(pkg_share_localization, ekf_config_file_path)
    default_rviz_config_path = os.path.join(pkg_share_gazebo, rviz_config_file_path)

    # Launch configuration variables
    # Config and launch files
    enable_odom_tf = LaunchConfiguration('enable_odom_tf')
    ekf_config_file = LaunchConfiguration('ekf_config_file')
    ekf_launch_file = LaunchConfiguration('ekf_launch_file')
    gazebo_launch_file = LaunchConfiguration('gazebo_launch_file')
    rviz_config_file = LaunchConfiguration('rviz_config_file')

    # Robot configuration
    robot_name = LaunchConfiguration('robot_name')
    world_file = LaunchConfiguration('world_file')

    # Position and orientation
    x = LaunchConfiguration('x')
    y = LaunchConfiguration('y')
    z = LaunchConfiguration('z')
    roll = LaunchConfiguration('roll')
    pitch = LaunchConfiguration('pitch')
    yaw = LaunchConfiguration('yaw')

    # Feature flags
    headless = LaunchConfiguration('headless')
    jsp_gui = LaunchConfiguration('jsp_gui')
    load_controllers = LaunchConfiguration('load_controllers')
    use_gazebo = LaunchConfiguration('use_gazebo')
    use_robot_state_pub = LaunchConfiguration('use_robot_state_pub')
    use_rviz = LaunchConfiguration('use_rviz')
    use_sim_time = LaunchConfiguration('use_sim_time')

    # Declare all launch arguments
    # Config and launch files
    declare_ekf_config_file_cmd = DeclareLaunchArgument(
        name='ekf_config_file',
        default_value=default_ekf_config_path,
        description='Full path to the EKF configuration YAML file')

    declare_ekf_launch_file_cmd = DeclareLaunchArgument(
        name='ekf_launch_file',
        default_value=default_ekf_launch_path,
        description='Full path to the EKF launch file to use')

    declare_enable_odom_tf_cmd = DeclareLaunchArgument(
        name='enable_odom_tf',
        default_value='true',
        choices=['true', 'false'],
        description='Whether to enable odometry transform broadcasting via ROS 2 Control')

    declare_gazebo_launch_file_cmd = DeclareLaunchArgument(
        name='gazebo_launch_file',
        default_value=default_gazebo_launch_path,
        description='Full path to the Gazebo launch file to use')

    declare_rviz_config_file_cmd = DeclareLaunchArgument(
        name='rviz_config_file',
        default_value=default_rviz_config_path,
        description='Full path to the RVIZ config file to use')

    # Robot configuration
    declare_robot_name_cmd = DeclareLaunchArgument(
        name='robot_name',
        default_value='rosmaster_x3',
        description='The name for the robot')

    declare_world_cmd = DeclareLaunchArgument(
        name='world_file',
        default_value='empty.world',
        description='World file name (e.g., empty.world, house.world)')

    # Position arguments
    declare_x_cmd = DeclareLaunchArgument(
        name='x',
        default_value='0.0',
        description='x component of initial position, meters')

    declare_y_cmd = DeclareLaunchArgument(
        name='y',
        default_value='0.0',
        description='y component of initial position, meters')

    declare_z_cmd = DeclareLaunchArgument(
        name='z',
        default_value='0.05',
        description='z component of initial position, meters')

    # Orientation arguments
    declare_roll_cmd = DeclareLaunchArgument(
        name='roll',
        default_value='0.0',
        description='roll angle of initial orientation, radians')

    declare_pitch_cmd = DeclareLaunchArgument(
        name='pitch',
        default_value='0.0',
        description='pitch angle of initial orientation, radians')

    declare_yaw_cmd = DeclareLaunchArgument(
        name='yaw',
        default_value='0.0',
        description='yaw angle of initial orientation, radians')

    # Feature flags
    declare_headless_cmd = DeclareLaunchArgument(
        name='headless',
        default_value='False',
        description='Whether to execute gzclient (visualization)')

    declare_jsp_gui_cmd = DeclareLaunchArgument(
        name='jsp_gui',
        default_value='false',
        description='Flag to enable joint_state_publisher_gui')

    declare_load_controllers_cmd = DeclareLaunchArgument(
        name='load_controllers',
        default_value='true',
        description='Flag to enable loading of ROS 2 controllers')

    declare_use_gazebo_cmd = DeclareLaunchArgument(
        name='use_gazebo',
        default_value='true',
        description='Flag to enable Gazebo')

    declare_use_robot_state_pub_cmd = DeclareLaunchArgument(
        name='use_robot_state_pub',
        default_value='true',
        description='Flag to enable robot state publisher')

    declare_use_rviz_cmd = DeclareLaunchArgument(
        name='use_rviz',
        default_value='true',
        description='Flag to enable RViz')

    declare_use_sim_time_cmd = DeclareLaunchArgument(
        name='use_sim_time',
        default_value='true',
        description='Use simulation (Gazebo) clock if true')

    # Specify the actions
    # Start Gazebo
    start_gazebo_cmd = IncludeLaunchDescription(
        PythonLaunchDescriptionSource([gazebo_launch_file]),
        launch_arguments={
            'enable_odom_tf': enable_odom_tf,
            'headless': headless,
            'jsp_gui': jsp_gui,
            'load_controllers': load_controllers,
            'robot_name': robot_name,
            'rviz_config_file': rviz_config_file,
            'use_rviz': use_rviz,
            'use_gazebo': use_gazebo,
            'use_robot_state_pub': use_robot_state_pub,
            'use_sim_time': use_sim_time,
            'world_file': world_file,
            'x': x,
            'y': y,
            'z': z,
            'roll': roll,
            'pitch': pitch,
            'yaw': yaw
        }.items()
    )

    # Start EKF
    start_ekf_cmd = IncludeLaunchDescription(
        PythonLaunchDescriptionSource([ekf_launch_file]),
        launch_arguments={
            'ekf_config_file': ekf_config_file,
            'use_sim_time': use_sim_time
        }.items()
    )

    # Create the launch description and populate
    ld = LaunchDescription()

    # Add all launch arguments
    # Config and launch files
    ld.add_action(declare_enable_odom_tf_cmd)
    ld.add_action(declare_ekf_config_file_cmd)
    ld.add_action(declare_ekf_launch_file_cmd)
    ld.add_action(declare_gazebo_launch_file_cmd)
    ld.add_action(declare_rviz_config_file_cmd)

    # Robot configuration
    ld.add_action(declare_robot_name_cmd)
    ld.add_action(declare_world_cmd)

    # Position declarations
    ld.add_action(declare_x_cmd)
    ld.add_action(declare_y_cmd)
    ld.add_action(declare_z_cmd)

    # Orientation declarations
    ld.add_action(declare_roll_cmd)
    ld.add_action(declare_pitch_cmd)
    ld.add_action(declare_yaw_cmd)

    # Feature flags
    ld.add_action(declare_headless_cmd)
    ld.add_action(declare_jsp_gui_cmd)
    ld.add_action(declare_load_controllers_cmd)
    ld.add_action(declare_use_gazebo_cmd)
    ld.add_action(declare_use_robot_state_pub_cmd)
    ld.add_action(declare_use_rviz_cmd)
    ld.add_action(declare_use_sim_time_cmd)

    # Add any actions
    ld.add_action(start_ekf_cmd)
    ld.add_action(start_gazebo_cmd)

    return ld

Save the file, and close it.

Open up a new terminal window, and type:

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_bringup/scripts/
touch rosmaster_x3_navigation.sh

Add this code.

#!/bin/bash
# Single script to launch the Yahboom ROSMASTERX3 with Gazebo, Nav2 and ROS 2 Controllers

cleanup() {
    echo "Cleaning up..."
    sleep 5.0
    pkill -9 -f "ros2|gazebo|gz|nav2|amcl|bt_navigator|nav_to_pose|rviz2|assisted_teleop|cmd_vel_relay|robot_state_publisher|joint_state_publisher|move_to_free|mqtt|autodock|cliff_detection|moveit|move_group|basic_navigator"
}

# Set up cleanup trap
trap 'cleanup' SIGINT SIGTERM

# For cafe.world -> z:=0.20
# For house.world -> z:=0.05
# To change Gazebo camera pose: gz service -s /gui/move_to/pose --reqtype gz.msgs.GUICamera --reptype gz.msgs.Boolean --timeout 2000 --req "pose: {position: {x: 0.0, y: -2.0, z: 2.0} orientation: {x: -0.2706, y: 0.2706, z: 0.6533, w: 0.6533}}"

echo "Launching Gazebo simulation with Nav2..."
ros2 launch yahboom_rosmaster_bringup rosmaster_x3_navigation_launch.py \
   enable_odom_tf:=false \
   headless:=False \
   load_controllers:=true \
   world_file:=cafe.world \
   use_rviz:=true \
   use_robot_state_pub:=true \
   use_sim_time:=true \
   x:=0.0 \
   y:=0.0 \
   z:=0.20 \
   roll:=0.0 \
   pitch:=0.0 \
   yaw:=0.0 &

echo "Waiting 25 seconds for simulation to initialize..."
sleep 25

echo "Adjusting camera position..."
gz service -s /gui/move_to/pose --reqtype gz.msgs.GUICamera --reptype gz.msgs.Boolean --timeout 2000 --req "pose: {position: {x: 0.0, y: -2.0, z: 2.0} orientation: {x: -0.2706, y: 0.2706, z: 0.6533, w: 0.6533}}"

# Keep the script running until Ctrl+C
wait

Save the file, and close it.

Add RViz Configuration File

Open up a new terminal window, and type:

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_navigation/
mkdir -p rviz && cd rviz
touch nav2_default_view.rviz

Add this code.

Save the file, and close it.

Add Aliases

Let’s add an alias to make launching all this much easier.

Open the .bashrc file

gedit ~/.bashrc

Scroll to the bottom of the file or look for a section with other alias definitions. 

Add this line:

alias nav='bash ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_bringup/scripts/rosmaster_x3_navigation.sh'

Save the file, and close it.

Edit the ROS Bridge Configuration YAML File

To make the robot localization package work with Gazebo, we need to send the clock data from Gazebo over to ROS 2.

Open up a new terminal window, and type:

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_gazebo/config/

Open ros_gz_bridge.yaml.

Add this code.

# Clock configuration
- 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
  lazy: false

Save the file, and close it.

Edit CMakeLists.txt

Open a terminal and navigate to the yahboom_rosmaster_localization package directory:

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_localization/

Open the CMakeLists.txt file.

Add the following lines to the file:

install (
  DIRECTORY config launch 
  DESTINATION share/${PROJECT_NAME}
)

Save the file, and close it.

Open a terminal and navigate to the yahboom_rosmaster_navigation package directory:

cd ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_navigation/

Open the CMakeLists.txt file.

Add the following lines to the file:

install (
  DIRECTORY rviz
  DESTINATION share/${PROJECT_NAME}
)

Save the file, and close it.

Build the Package

Now we will build the package.

cd ~/ros2_ws/
colcon build && source ~/.bashrc

Launch the Robot

Let’s bring our robot to life. Open a terminal window, and use this command to launch the robot:

nav

or

bash ~/ros2_ws/src/yahboom_rosmaster/yahboom_rosmaster_bringup/scripts/rosmaster_x3_navigation.sh

To see the active topics, open a terminal window, and type:

ros2 topic list
1-ros2-topic-list

To see more information about the topics, execute:

ros2 topic info /imu/data -v
1-ros2-topic-info-imu

To see the data type:

ros2 topic echo /imu/data

Now check out the wheel odometry:

ros2 topic info /mecanum_drive_controller/odom -v
2-checkout-wheel-odometry
ros2 topic echo /mecanum_drive_controller/odom
3-ros2-topic-echo-wheel-odometry

Make sure the timestamp on the topic indicates simulation time.

To see the output of the robot localization package (i.e. the Extended Kalman Filter (EKF)), type:

ros2 topic echo /odometry/filtered
4-ros2-topic-echo-odometry-filtered

Make sure the timestamp on this odometry topic indicates simulation time.

Let’s see the simulation time:

ros2 topic echo /clock
5-ros2-topic-echo-clock

This clock data is coming from Gazebo via the ROS 2 Gazebo bridge YAML file we configured earlier in this tutorial.

I will move my robot around in a square shaped pattern:

ros2 run yahboom_rosmaster_system_tests square_mecanum_controller --ros-args -p use_sim_time:=true
6-move-robot-around

Remember we need to set use_sim_time to true in the command above so that the node gets the time from the /clock ROS 2 topic rather than the system time. 

We can see the output of the odom -> base_footprint transform by typing the following command:

ros2 run tf2_ros tf2_echo odom base_footprint
7-odom-to-base-footprint

Let’s see the active nodes.

ros2 node list
8-ros2-node-list

Let’s check out the ekf_node (named ekf_filter_node).

ros2 node info /ekf_filter_node
9-ekf-filter-node

Let’s check out the ROS node graph.

rqt_graph

Click the blue circular arrow in the upper left to refresh the node graph. Also select “Nodes/Topics (all)”.

10-rqt-graph
11-sensor-fusion-robot-localization-ekf-filter-node-ros2

To see the coordinate frames, type the following command in a terminal window.

cd ~/Downloads/
ros2 run tf2_tools view_frames

In the current working directory, you will have a file called frames_20XX.pdf. Open that file.

evince frames_20XX.pdf

Here is what my coordinate transform (i.e. tf) tree looks like:

12-view-frames

You can see that the parent frame is the odom frame. The odom frame is the initial position and orientation of the robot. Every other frame below that is a child of the odom frame.

Later, we will add a map frame. The map frame will be the parent frame of the odom frame.

That’s it! Keep building!