Module 9: SLAM
SLAM
SLAM (Simultaneous Localization And Mapping) is a large
family of techniques for simultaneously determing a robot’s position and
a map of its environment. In this module, we will use the BitSLAM ROS2 node
to supply our robots with both a map and a corrected pose based on the map.
The node subscribes to a topic, bitslam_obstacles, that contains
information about the robot’s poses and the obstacles it encounters. The
topic is posed in the robot’s namespace; for example, if the robot’s name
is elrond, the topic would be /elrond/bitslam_obstacles.
Topics where this node publishes useful information include:
bitslam_pose
- Current estimated pose for the robot, correcting the
odom topic
readings with the map
bitslam_status
Mapping if the SLAM algorithm is in the process of creating a map
Finished if the SLAM algorithm has completed a map
Failed if the SLAM algorithm is unable to create a map
bitslam_occupancy_grid
- Note: All of the
bitslam topic messages are ROS2 String objects,
except for bitslam_occupancy_grid, which is a ROS2 OccupancyGrid object.
JSON
JSON (JavaScript Object Notation) is a standardized format
for enconding objects as strings. It is a useful format for sending ROS2
messages in a language-independent manner. We will be using JSON objects to
communicate on the bitslam_obstacles topic.
In Python, we can build JSON objects as dictionaries. We can then import json
and use json.dumps() to encode a JSON object as a string. All of these
objects have two top-level fields: header and data. The header field
will contain a copy of the header of the source message. The data field
will contain the main JSON object.
To convert a Header to JSON, we can use this function:
def header_json(header: Header) -> dict:
return {"stamp": {"sec": header.stamp.sec, "nanosec": header.stamp.nanosec}, "frame_id": header.frame_id}
To build a Pose message from an Odometry message:
- Convert the
Odometry message to a RobotPose
object using odom2pose
- Imagine a
RobotPose object at (1.0, 2.0) with heading 1.57. Its JSON
representation will be:
{"header": header_json(odom.header), "data": "(pose,1.0,2.0,1.57)"}
- Based on the above structure, how would you encode a
RobotPose object
named pose? Hint: I recommend using an f-string.
To build a Collision message from a HazardDetection message:
- The sensor headings are:
- Front bump sensor: 0.0
- Front left bump sensor: π/4
- Front right bump sensor: -π/4
- Left bump sensor: π/2
- Right bump sensor: -π/2
- The left and right bump sensor headings are π/2 and -π/2
- Other information includes:
- Distance to the collision: we will use
0.2032, which is the robot’s radius in meters
- Distance noise: we will estimate the noise for the collision distance as +/- 1 cm
- Heading noise: we will estimate the noise for the collision heading as +/-
0.39269908169872414 radians
- The JSON object for the front bump sensor is:
{"header": header_json(h.header), "data": f"(collision,0.2032,0.0,0.01,0.39269908169872414)"}
- What would the JSON objects look like for the other four bump sensors?
- When you are ready to encode a JSON object as a string, use
json.dumps() and pass
in the JSON object.
Exploring
Make a copy of your favorite obstacle-avoiding program (e.g. bump_turn_45.py) as
slam_bump_turn.py. Augment it as follows:
- Add the import
from std_msgs.msg import String in order to be able to
publish String messages.
- Add a publisher for
bitslam_obstacles that publishes String messages.
- Make sure it subscribes to
odom and hazard_detection at a minimum.
- In the callback for
odom, publish a pose message, as defined
above, to bitslam_obstacles
- In the callback for
hazard_detection, publish a collision message,
as defined above, to bitslam_obstacles
- As it publishes each message to
bitslam_obstacles, it will also append
the message to a file. To ensure a unique filename for each run, use
import time to use the time module and then use time.time() as part
of the filename. (The function time.time() returns the number of seconds
since January 1, 1970, and is useful for creating unique identifiers.)
Remember that to append to a file in Python, you can use the following syntax:
with open(filename, 'a') as f:
f.write("An additional line.\n")
- Reset the odometry:
ros2 service call /robot_name/reset_pose irobot_create_msgs/srv/ResetPose
- Run your program for a while.
- Echo the
bitslam_obstacles topic to see what it is publishing, and make sure
those values make sense.
Incorporating IR sensors (optional)
- If you would also like to incorporate obstacles observed by the IR sensors,
you can use this diagram
to obtain their headings.
Publishing the observations
Create a new file called slam_transcript_node.py. This node will read
the transcript of messages saved in the previous step and publish them to
bitslam_obstacles, thus enabling us to simulate the robot’s exploration
without actively driving the robot. Below is a skeleton of the implementation:
import sys, curses, traceback
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from std_msgs.msg import String
class TranscriptNode(Node):
def __init__(self, robot_name: str, transcript_filename: str, stdscr):
super().__init__(f"{robot_name}_TranscriptNode")
self.running = True
# YOUR CODE HERE
#
# Create a publisher for bitslam_obstacles
# Create a timer
# Create a list of messages to publish
# Open the transcript file and load all the messages into that list.
# Create a means of tracking where we are in the list
def timer_callback(self):
# YOUR CODE HERE
#
# Identify and publish the next message.
# Also display the message on the screen using curses.
# If we are out of messages, end the program.
def handle_key(self, k: str):
if k == 'q':
self.running = False
def main(stdscr):
rclpy.init()
node = TranscriptNode(sys.argv[1], sys.argv[2], stdscr)
curses.cbreak()
stdscr.nodelay(True)
stdscr.clear()
while node.running:
try:
k = stdscr.getch()
if k != -1:
k = chr(k)
node.handle_key(k)
rclpy.spin_once(node, timeout_sec=0.0)
except curses.error as e:
if str(e) != 'no input':
stdscr.addstr(0, 0, traceback.format_exc())
rclpy.shutdown()
node.destroy_node()
curses.nocbreak()
curses.echo()
stdscr.refresh()
if __name__ == '__main__':
if len(sys.argv) < 3:
print("Usage: python3 slam_transcript_node.py robot_name transcript_filename")
else:
curses.wrapper(main)
Run this program and make sure it is publishing the messages correctly. Make
sure it reaches the end of your file.
Visualizing the map
Write another Python program called curses_map.py. This program will
subscribe to bitslam_occupancy_grid and display the occupancy grid on
the screen using curses. Carefully study
the OccupancyGrid data type
as well as MapMetaData.
- How is a map represented as an occupancy grid?
- To visualize the map, we need to determine which occupancy grid cell to
use for each curses cell. How should we determine this?
Here is a skeleton for curses_map.py:
import sys, curses, traceback
import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data
from nav_msgs.msg import OccupancyGrid
from nav_msgs.msg import MapMetaData
class ShowGridNode(Node):
def __init__(self, robot_name: str, stdscr):
super().__init__(f"{robot_name}_ShowGridNode")
self.running = True
self.stdscr = stdscr
self.create_subscription(OccupancyGrid, f"/{robot_name}/bitslam_occupancy_grid", self.grid_callback, qos_profile_sensor_data)
def grid_callback(self, grid: OccupancyGrid):
# YOUR CODE HERE
#
# Use your answers above to write this method
def handle_key(self, k: str):
if k == 'q':
self.running = False
def main(stdscr):
rclpy.init()
node = ShowGridNode(sys.argv[1], stdscr)
curses.cbreak()
stdscr.nodelay(True)
stdscr.clear()
while node.running:
try:
k = stdscr.getch()
if k != -1:
k = chr(k)
node.handle_key(k)
rclpy.spin_once(node, timeout_sec=0.0)
except curses.error as e:
if str(e) != 'no input':
stdscr.addstr(0, 0, traceback.format_exc())
rclpy.shutdown()
node.destroy_node()
curses.nocbreak()
curses.echo()
stdscr.refresh()
if __name__ == '__main__':
if len(sys.argv) < 2:
print("Usage: python3 curses_map.py robot_name")
else:
curses.wrapper(main)
To test this out, you need to do the following:
- Reset the odometry:
ros2 service call /robot_name/reset_pose irobot_create_msgs/srv/ResetPose
- Run
bit_slam_node: bit_slam_node --robot=robot_name
- Run
curses_map.py
- Run
slam_transcript_node.py
Final testing
To see the robot mapping live:
- Reset the odometry:
ros2 service call /robot_name/reset_pose irobot_create_msgs/srv/ResetPose
- Run
bit_slam_node: bit_slam_node --robot=robot_name
- Run
curses_map.py
- Run
slam_bump_turn.py
Final thoughts
- In your journal, write down three idea about how you might use these maps in a larger project.