chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
<!--[metadata]
|
||||
title = "ROS node"
|
||||
tags = ["2D", "3D", "Pinhole camera", "ROS", "Time series", "URDF"]
|
||||
thumbnail = "https://static.rerun.io/ros_node_example_new/e15b81b183ccafd8ee2994a6abf0b06cbdf22741/480w.png"
|
||||
thumbnail_dimensions = [480, 318]
|
||||
-->
|
||||
|
||||
A minimal example of creating a ROS node that subscribes to topics and converts the messages to Rerun log calls.
|
||||
|
||||
The solution here is mostly a toy example to show how ROS concepts can be mapped to Rerun.
|
||||
|
||||
<picture>
|
||||
<img src="https://static.rerun.io/ros_node_example_new/e15b81b183ccafd8ee2994a6abf0b06cbdf22741/full.png" alt="Rerun viewer showing data streamed from the example ROS node">
|
||||
<source media="(max-width: 480px)" srcset="https://static.rerun.io/ros_node_example_new/e15b81b183ccafd8ee2994a6abf0b06cbdf22741/480w.png">
|
||||
<source media="(max-width: 768px)" srcset="https://static.rerun.io/ros_node_example_new/e15b81b183ccafd8ee2994a6abf0b06cbdf22741/768w.png">
|
||||
<source media="(max-width: 1024px)" srcset="https://static.rerun.io/ros_node_example_new/e15b81b183ccafd8ee2994a6abf0b06cbdf22741/1024w.png">
|
||||
<source media="(max-width: 1200px)" srcset="https://static.rerun.io/ros_node_example_new/e15b81b183ccafd8ee2994a6abf0b06cbdf22741/1200w.png">
|
||||
</picture>
|
||||
|
||||
## Used Rerun types
|
||||
[`Image`](https://www.rerun.io/docs/reference/types/archetypes/image), [`DepthImage`](https://rerun.io/docs/reference/types/archetypes/depth_image), [`Pinhole`](https://www.rerun.io/docs/reference/types/archetypes/pinhole), [`Transform3D`](https://www.rerun.io/docs/reference/types/archetypes/transform3d), [`GridMap`](https://www.rerun.io/docs/reference/types/archetypes/grid_map), [`Points3D`](https://www.rerun.io/docs/reference/types/archetypes/points3d), [`LineStrips3D`](https://www.rerun.io/docs/reference/types/archetypes/line_strips3d), [`Scalars`](https://www.rerun.io/docs/reference/types/archetypes/scalars)
|
||||
|
||||
## Background
|
||||
The [Robot Operating System (ROS)](https://www.ros.org) helps build robot applications through software libraries and tools.
|
||||
Although Rerun doesn't have native ROS support, you can easily create a basic ROS 2 Python node to subscribe to common ROS topics and log them to Rerun.
|
||||
In this example, Rerun visualizes simulation data, including robot pose, images, camera position, laser scans, point clouds, and velocities, as the robot navigates the environment.
|
||||
|
||||
## Logging and visualizing with Rerun
|
||||
|
||||
Find the detailed code walkthrough and explanation for visualizing this example here: [Using Rerun with ROS 2](https://www.rerun.io/docs/howto/integrations/ros2-nav-turtlebot).
|
||||
|
||||
For more information on future improved ROS support, see tracking issue: [#1527](https://github.com/rerun-io/rerun/issues/1537)
|
||||
|
||||
## Run the code
|
||||
|
||||
### Dependencies
|
||||
|
||||
> NOTE: Unlike many of the other examples, this example requires a system installation of ROS
|
||||
in addition to the packages from requirements.txt.
|
||||
>
|
||||
> The commands are focused on the official ROS installation path on an Ubuntu distro, but should also work analogously if you installed ROS through [Robostack](https://robostack.github.io/index.html).
|
||||
|
||||
This example was developed and tested on top of [ROS2 Kilted Kaiju](https://docs.ros.org/en/kilted/index.html), with [Nav2](https://docs.nav2.org/) and the Turtlebot 4 simulation example from the [nav2_bringup](https://github.com/ros-navigation/navigation2/tree/main/nav2_bringup) package.
|
||||
If you use another version of ROS, e.g. [Jazzy](https://docs.ros.org/en/jazzy/index.html), you should be able to just replace `kilted` with `jazzy` in the commands below.
|
||||
|
||||
Installing ROS is outside the scope of this example, but you will need the equivalent of the following packages:
|
||||
```
|
||||
sudo apt install ros-kilted-desktop ros-kilted-nav2-bringup
|
||||
```
|
||||
(`ros-kilted-nav2-bringup` pulls in all the navigation and simulation packages we need as dependencies, if not installed yet)
|
||||
|
||||
Then clone the Rerun repository to get the example code:
|
||||
```bash
|
||||
git clone https://github.com/rerun-io/rerun.git # Clone the repository
|
||||
cd rerun
|
||||
git checkout latest # Check out the commit matching the latest SDK release
|
||||
```
|
||||
|
||||
Make sure to use a Python virtual environment. Here, we use `venv` (`sudo apt install python3-venv`):
|
||||
```bash
|
||||
python3 -m venv --system-site-packages rerun-ros-example
|
||||
source rerun-ros-example/bin/activate
|
||||
```
|
||||
|
||||
Then install the latest Rerun SDK and the necessary libraries specified in the requirements file of this example:
|
||||
```bash
|
||||
pip install --upgrade rerun-sdk
|
||||
pip install -r examples/python/ros_node/requirements.txt
|
||||
```
|
||||
|
||||
In addition to installing the dependencies from `requirements.txt` into a venv you will also need to source the
|
||||
ROS setup script:
|
||||
```bash
|
||||
source /opt/ros/kilted/setup.bash
|
||||
```
|
||||
|
||||
### Run the code
|
||||
|
||||
First, in one terminal launch the Nav2 turtlebot demo:
|
||||
```bash
|
||||
source /opt/ros/kilted/setup.bash
|
||||
|
||||
ros2 launch nav2_bringup tb4_simulation_launch.py headless:=False
|
||||
```
|
||||
|
||||
This should open two windows for Gazebo and RViz. Use the RViz window to initialize the pose estimate to put the robot on the map, and set a navigation goal to let it move.
|
||||
|
||||
You can now connect to the running ROS system by running this in a separate terminal:
|
||||
```bash
|
||||
source /opt/ros/kilted/setup.bash
|
||||
|
||||
python examples/python/ros_node/main.py # run the example
|
||||
```
|
||||
|
||||
If you wish to customize it, or explore additional features, use the CLI with the `--help` option for guidance:
|
||||
```bash
|
||||
python examples/python/ros_node/main.py --help
|
||||
```
|
||||
Executable
+311
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple example of a ROS node that republishes some common types to Rerun.
|
||||
|
||||
The solution here is mostly a toy example to show how ROS concepts can be
|
||||
mapped to Rerun. For more information on future improved ROS support,
|
||||
see the tracking issue: <https://github.com/rerun-io/rerun/issues/1537>.
|
||||
|
||||
NOTE: Unlike many of the other examples, this example requires a system installation of ROS
|
||||
in addition to the packages from requirements.txt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
import numpy as np
|
||||
|
||||
import rerun as rr # pip install rerun-sdk
|
||||
from rerun.components import Colormap
|
||||
|
||||
try:
|
||||
import cv_bridge
|
||||
import laser_geometry
|
||||
import rclpy
|
||||
from image_geometry import PinholeCameraModel
|
||||
from nav_msgs.msg import OccupancyGrid, Odometry
|
||||
from numpy.lib.recfunctions import structured_to_unstructured
|
||||
from rclpy.callback_groups import ReentrantCallbackGroup
|
||||
from rclpy.node import Node
|
||||
from rclpy.qos import QoSDurabilityPolicy, QoSProfile
|
||||
from rclpy.time import Time
|
||||
from sensor_msgs.msg import CameraInfo, Image, LaserScan
|
||||
from sensor_msgs_py import point_cloud2
|
||||
from std_msgs.msg import String
|
||||
from tf2_msgs.msg import TFMessage
|
||||
|
||||
except ImportError:
|
||||
print(
|
||||
"""
|
||||
Could not import the required ROS2 packages.
|
||||
|
||||
Make sure you have installed ROS2 (https://docs.ros.org/en/kilted/index.html)
|
||||
and sourced /opt/ros/kilted/setup.bash
|
||||
|
||||
See: README.md for more details.
|
||||
""",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
class TurtleSubscriber(Node): # type: ignore[misc]
|
||||
def __init__(self) -> None:
|
||||
super().__init__("rr_turtlebot")
|
||||
|
||||
# Assorted helpers for data conversions
|
||||
self.pinhole_model = PinholeCameraModel()
|
||||
self.cv_bridge = cv_bridge.CvBridge()
|
||||
self.laser_proj = laser_geometry.laser_geometry.LaserProjection()
|
||||
self.subscribers: list[rclpy.Subscription] = []
|
||||
|
||||
# Subscribe to the topics we want to republish to Rerun.
|
||||
# See the callback methods below for how each message type is handled.
|
||||
self.subscribe("/tf", TFMessage, self.tf_callback)
|
||||
self.subscribe("/tf_static", TFMessage, self.tf_callback, latching=True)
|
||||
self.subscribe("/odom", Odometry, self.odom_callback)
|
||||
self.subscribe("/scan", LaserScan, self.scan_callback)
|
||||
self.subscribe("/rgbd_camera/camera_info", CameraInfo, self.cam_info_callback)
|
||||
self.subscribe("/rgbd_camera/image", Image, self.image_callback)
|
||||
self.subscribe("/rgbd_camera/depth_image", Image, self.depth_callback)
|
||||
self.subscribe("/robot_description", String, self.urdf_callback, latching=True)
|
||||
self.subscribe(
|
||||
"/map",
|
||||
OccupancyGrid,
|
||||
lambda grid: self.occupancy_grid_callback("/map", grid, Colormap.RvizMap, draw_order=1.0),
|
||||
latching=True,
|
||||
)
|
||||
self.subscribe(
|
||||
"/global_costmap/costmap",
|
||||
OccupancyGrid,
|
||||
lambda grid: self.occupancy_grid_callback(
|
||||
"/global_costmap_costmap", grid, Colormap.RvizCostmap, draw_order=2.0, opacity=0.75
|
||||
),
|
||||
)
|
||||
self.subscribe(
|
||||
"/local_costmap/costmap",
|
||||
OccupancyGrid,
|
||||
lambda grid: self.occupancy_grid_callback(
|
||||
"/local_costmap_costmap", grid, Colormap.RvizCostmap, draw_order=3.0, opacity=0.75
|
||||
),
|
||||
)
|
||||
|
||||
def subscribe(
|
||||
self, topic: str, msg_type: type, callback: Callable[[rclpy.MsgT], None], latching: bool = False
|
||||
) -> None:
|
||||
"""Adds a subscriber to a topic with the given message type and callback."""
|
||||
# `qos_profile` can either be an int (history depth) or a QoSProfile.
|
||||
# See: https://docs.ros.org/en/rolling/p/rclpy/rclpy.node.html#rclpy.node.Node.create_subscription
|
||||
qos_profile = QoSProfile(depth=1, durability=QoSDurabilityPolicy.TRANSIENT_LOCAL) if latching else 10
|
||||
sub = self.create_subscription(
|
||||
msg_type=msg_type,
|
||||
topic=topic,
|
||||
callback=callback,
|
||||
qos_profile=qos_profile,
|
||||
callback_group=ReentrantCallbackGroup(), # allow concurrent callbacks
|
||||
)
|
||||
self.subscribers.append(sub)
|
||||
|
||||
def cam_info_callback(self, info: CameraInfo) -> None:
|
||||
"""
|
||||
Logs CameraInfo as a Rerun Pinhole.
|
||||
"""
|
||||
time = Time.from_msg(info.header.stamp)
|
||||
self.pinhole_model.from_camera_info(info)
|
||||
rr.set_time("ros_time", timestamp=np.datetime64(time.nanoseconds, "ns"))
|
||||
rr.log(
|
||||
"rgbd_camera/camera_info",
|
||||
rr.Pinhole(
|
||||
resolution=[info.width, info.height],
|
||||
image_from_camera=self.pinhole_model.intrinsic_matrix(),
|
||||
image_plane_distance=1.0,
|
||||
parent_frame=info.header.frame_id,
|
||||
# Specifying a `child_frame` for the 2D image plane allows Rerun to
|
||||
# visualize the pinhole frustum together with the image in 3D views.
|
||||
# This has to match the coordinate frames used when logging images,
|
||||
# see `image_callback` below.
|
||||
child_frame=info.header.frame_id + "_image_plane",
|
||||
),
|
||||
)
|
||||
|
||||
def odom_callback(self, odom: Odometry) -> None:
|
||||
"""
|
||||
Logs data from Odometry as Rerun Scalars.
|
||||
"""
|
||||
time = Time.from_msg(odom.header.stamp)
|
||||
rr.set_time("ros_time", timestamp=np.datetime64(time.nanoseconds, "ns"))
|
||||
# Capture time-series data for the linear and angular velocities
|
||||
rr.log("odom/twist/linear/x", rr.Scalars(odom.twist.twist.linear.x))
|
||||
rr.log("odom/twist/angular/z", rr.Scalars(odom.twist.twist.angular.z))
|
||||
|
||||
def image_callback(self, img: Image) -> None:
|
||||
"""
|
||||
Logs an RGB image as a Rerun Image.
|
||||
"""
|
||||
time = Time.from_msg(img.header.stamp)
|
||||
rr.set_time("ros_time", timestamp=np.datetime64(time.nanoseconds, "ns"))
|
||||
rr.log("rgbd_camera/image", rr.Image(self.cv_bridge.imgmsg_to_cv2(img)))
|
||||
# Make sure the image plane frame matches what we set in `cam_info_callback`.
|
||||
rr.log("rgbd_camera/image", rr.CoordinateFrame(frame=img.header.frame_id + "_image_plane"))
|
||||
|
||||
def depth_callback(self, img: Image) -> None:
|
||||
"""
|
||||
Logs a depth image as a Rerun DepthImage.
|
||||
"""
|
||||
time = Time.from_msg(img.header.stamp)
|
||||
depth_image = rr.DepthImage(
|
||||
self.cv_bridge.imgmsg_to_cv2(img, desired_encoding="32FC1"),
|
||||
meter=1.0,
|
||||
colormap="viridis",
|
||||
)
|
||||
rr.set_time("ros_time", timestamp=np.datetime64(time.nanoseconds, "ns"))
|
||||
rr.log("rgbd_camera/depth_image", depth_image)
|
||||
rr.log("rgbd_camera/depth_image", rr.CoordinateFrame(frame=img.header.frame_id + "_image_plane"))
|
||||
|
||||
def occupancy_grid_callback(
|
||||
self,
|
||||
entity_path: str,
|
||||
grid: OccupancyGrid,
|
||||
colormap: rr.components.Colormap,
|
||||
draw_order: float | None = None,
|
||||
opacity: float | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Logs a ROS OccupancyGrid as a Rerun GridMap.
|
||||
"""
|
||||
time = Time.from_msg(grid.header.stamp)
|
||||
rr.set_time("ros_time", timestamp=np.datetime64(time.nanoseconds, "ns"))
|
||||
|
||||
# Log the coordinate frame ID of the map.
|
||||
# The local offset of the map frame within the grid is handled by the archetype (see below).
|
||||
rr.log(entity_path, rr.CoordinateFrame(frame=grid.header.frame_id))
|
||||
|
||||
# ROS maps start at the bottom-left cell; Rerun image buffers are top-row first.
|
||||
data = np.asarray(grid.data, dtype=np.int8).reshape((grid.info.height, grid.info.width))
|
||||
image_data = np.flipud(data).astype(np.uint8, copy=False)
|
||||
|
||||
rr.log(
|
||||
entity_path,
|
||||
rr.GridMap(
|
||||
data=image_data.tobytes(),
|
||||
format=rr.components.ImageFormat(
|
||||
width=grid.info.width,
|
||||
height=grid.info.height,
|
||||
color_model="L",
|
||||
channel_datatype="U8",
|
||||
),
|
||||
cell_size=grid.info.resolution,
|
||||
translation=[
|
||||
grid.info.origin.position.x,
|
||||
grid.info.origin.position.y,
|
||||
grid.info.origin.position.z,
|
||||
],
|
||||
quaternion=rr.Quaternion(
|
||||
xyzw=[
|
||||
grid.info.origin.orientation.x,
|
||||
grid.info.origin.orientation.y,
|
||||
grid.info.origin.orientation.z,
|
||||
grid.info.origin.orientation.w,
|
||||
]
|
||||
),
|
||||
colormap=colormap,
|
||||
draw_order=draw_order,
|
||||
opacity=opacity,
|
||||
),
|
||||
)
|
||||
|
||||
def scan_callback(self, scan: LaserScan) -> None:
|
||||
"""
|
||||
Logs a LaserScan after transforming it to line-segments.
|
||||
|
||||
Note: we do a client-side transformation of the LaserScan data into Rerun
|
||||
points / lines until Rerun has native support for LaserScan style projections:
|
||||
[#1534](https://github.com/rerun-io/rerun/issues/1534)
|
||||
"""
|
||||
time = Time.from_msg(scan.header.stamp)
|
||||
rr.set_time("ros_time", timestamp=np.datetime64(time.nanoseconds, "ns"))
|
||||
|
||||
# Project the laser scan to a collection of points
|
||||
points = self.laser_proj.projectLaser(scan)
|
||||
pts = point_cloud2.read_points(points, field_names=["x", "y", "z"], skip_nans=True)
|
||||
pts = structured_to_unstructured(pts)
|
||||
|
||||
# Turn every pt into a line-segment from the origin to the point.
|
||||
origin = (pts / np.linalg.norm(pts, axis=1).reshape(-1, 1)) * 0.3
|
||||
segs = np.hstack([origin, pts]).reshape(pts.shape[0] * 2, 3)
|
||||
|
||||
rr.log("scan", rr.LineStrips3D(segs, radii=0.0025, colors=[255, 165, 0]))
|
||||
rr.log("scan", rr.CoordinateFrame(frame=scan.header.frame_id))
|
||||
|
||||
def urdf_callback(self, urdf_msg: String) -> None:
|
||||
"""
|
||||
Forwards the robot description message to Rerun's built-in URDF loader.
|
||||
|
||||
Documentation about URDF support in Rerun can be found here:
|
||||
https://rerun.io/docs/howto/logging-and-ingestion/urdf
|
||||
"""
|
||||
# NOTE: file_path is not known here, robot.urdf is just a placeholder to let
|
||||
# Rerun know the file type. Since we run this example in a ROS environment,
|
||||
# Rerun can use AMENT_PREFIX_PATH etc to resolve asset paths of the URDF.
|
||||
rr.log_file_from_contents(
|
||||
file_path="robot.urdf",
|
||||
file_contents=urdf_msg.data.encode("utf-8"),
|
||||
entity_path_prefix="urdf",
|
||||
static=True,
|
||||
)
|
||||
|
||||
def tf_callback(self, tf_msg: TFMessage) -> None:
|
||||
"""
|
||||
Logs TF transforms to Rerun as Transform3D messages,
|
||||
with `parent_frame` and `child_frame` fields set.
|
||||
|
||||
Documentation about transforms in Rerun can be found here:
|
||||
https://rerun.io/docs/concepts/transforms
|
||||
"""
|
||||
for transform in tf_msg.transforms:
|
||||
time = Time.from_msg(transform.header.stamp)
|
||||
rr.set_time("ros_time", timestamp=np.datetime64(time.nanoseconds, "ns"))
|
||||
rr.log(
|
||||
"transforms",
|
||||
rr.Transform3D(
|
||||
translation=[
|
||||
transform.transform.translation.x,
|
||||
transform.transform.translation.y,
|
||||
transform.transform.translation.z,
|
||||
],
|
||||
rotation=rr.Quaternion(
|
||||
xyzw=[
|
||||
transform.transform.rotation.x,
|
||||
transform.transform.rotation.y,
|
||||
transform.transform.rotation.z,
|
||||
transform.transform.rotation.w,
|
||||
]
|
||||
),
|
||||
parent_frame=transform.header.frame_id,
|
||||
child_frame=transform.child_frame_id,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Simple example of a ROS node that republishes to Rerun.")
|
||||
rr.script_add_args(parser)
|
||||
args, unknownargs = parser.parse_known_args()
|
||||
rr.script_setup(args, "rerun_example_ros_node")
|
||||
|
||||
# Any remaining args go to rclpy
|
||||
rclpy.init(args=unknownargs)
|
||||
|
||||
turtle_subscriber = TurtleSubscriber()
|
||||
|
||||
# Use the MultiThreadedExecutor so that calls to `lookup_transform` don't block the other threads
|
||||
rclpy.spin(turtle_subscriber, executor=rclpy.executors.MultiThreadedExecutor())
|
||||
|
||||
turtle_subscriber.destroy_node()
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
# NOTE: numpy has to be downgraded to be compatible with ROS packages
|
||||
# that were built against the 1.x version of python3-numpy in Ubuntu 24.04.
|
||||
|
||||
numpy<2.0
|
||||
opencv-python
|
||||
pycollada
|
||||
pyyaml
|
||||
rerun-sdk
|
||||
scipy
|
||||
yourdfpy
|
||||
Reference in New Issue
Block a user