Source code for opentau.utils.ros2lerobot

# Copyright 2026 Tensor Auto Inc. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""ROS 2 to LeRobot dataset extractors and utilities.

This module provides feature extractors for converting ROS 2 topic messages
(e.g., joint_states, images) into LeRobot dataset features. Extractors are
keyed by enum value in EXTRACTORS and used by the convert_ros_to_lerobot script.
"""

import logging
from abc import ABC, abstractmethod
from typing import Any

import numpy as np

from opentau.configs.ros2lerobot import RosToLeRobotConfig


[docs] def get_nested_item(obj: Any, flattened_key: str, sep: str = ".") -> Any: """Get a nested item from an object using a flattened attribute path. Args: obj: Object with nested attributes to access (e.g., ROS message). flattened_key: Dot-separated path to the attribute (e.g., "a.b.c"). sep: Separator used in the flattened key. Defaults to ".". Returns: The value at the nested path specified by the flattened key. Example: >>> dct = {"a": {"b": {"c": 42}}} >>> get_nested_item(dct, "a.b.c") 42 """ split_keys = flattened_key.split(sep) getter = getattr(obj, split_keys[0]) if len(split_keys) == 1: return getter for key in split_keys[1:]: getter = getattr(getter, key) return getter
[docs] class FeatureExtractor(ABC): """Abstract base class for extracting a dataset feature from a ROS 2 message."""
[docs] def __init__(self, cfg: RosToLeRobotConfig): """Initialize the extractor with conversion config. Args: cfg: ROS to LeRobot conversion config (joint order, features, etc.). """ self.cfg = cfg
@abstractmethod def __call__(self, msg: Any, ros_topic: str, attribute: str) -> Any: """Extract the feature value from a single message. Args: msg: Deserialized ROS 2 message. ros_topic: Topic name the message was received on. attribute: Message attribute path to extract (e.g., "position", "data"). Returns: Extracted value (e.g., list of floats, numpy array), or None/empty on failure. """ pass
[docs] class StateExtractor(FeatureExtractor): """Extracts observation.state from ROS 2 joint_states (position + velocity).""" def __call__(self, msg: Any, ros_topic: str, attribute: str) -> Any: """Extract state as position and velocity ordered by config joint_order. Args: msg: Joint state message with name, position, velocity (e.g., sensor_msgs/JointState). ros_topic: Topic name the message was received on. attribute: Attribute path for values (e.g., "position"). Returns: List of floats: positions for each joint in joint_order, then velocities, or empty list if extraction fails. """ # Handle Joint Ordering if not self.cfg.joint_order: if hasattr(msg, "name"): self.cfg.joint_order = msg.name logging.info( f"Auto-detected joint order ({len(self.cfg.joint_order)} joints): {self.cfg.joint_order}" ) else: logging.warning("Message does not have 'name' attribute, cannot auto-detect joint order.") return [] # Create a map for this message {name: index} if hasattr(msg, "name"): current_map = {name: i for i, name in enumerate(msg.name)} else: # Fallback if msg doesn't have names but we have joint_order and data seems to match? # For now assume joint_states structure return [] extracted_values = [] extracted_velocities = [] try: # Check if attribute exists on msg (at top level or nested?) raw_values = get_nested_item(msg, attribute, sep=".") raw_velocities = get_nested_item(msg, "velocity", sep=".") for j_name in self.cfg.joint_order: if j_name in current_map: idx = current_map[j_name] if len(raw_values) > idx: extracted_values.append(raw_values[idx]) extracted_velocities.append(raw_velocities[idx]) else: extracted_values.append(0.0) extracted_velocities.append(0.0) else: # Joint missing in this message extracted_values.append(0.0) extracted_velocities.append(0.0) return extracted_values + extracted_velocities except (KeyError, AttributeError, TypeError) as e: logging.warning(f"Error extracting {attribute} from {ros_topic}: {e}") return []
[docs] class ActionExtractor(FeatureExtractor): """Extracts action (e.g., target joint positions) from ROS 2 control messages.""" def __call__(self, msg: Any, ros_topic: str, attribute: str) -> Any: """Extract action values ordered by config joint_order. Args: msg: Message with joint_names and attribute (e.g., trajectory point with .q). ros_topic: Topic name the message was received on. attribute: Attribute path to joint values (e.g., "points.positions" or similar). Returns: List of floats for each joint in joint_order, or empty list if extraction fails. """ # Handle Joint Ordering if not self.cfg.joint_order: if hasattr(msg, "joint_names"): self.cfg.joint_order = msg.joint_names logging.info( f"Auto-detected joint order ({len(self.cfg.joint_order)} joints): {self.cfg.joint_order}" ) else: logging.warning("Message does not have 'name' attribute, cannot auto-detect joint order.") return [] # Create a map for this message {name: index} if hasattr(msg, "joint_names"): current_map = {name: i for i, name in enumerate(msg.joint_names)} else: # Fallback if msg doesn't have names but we have joint_order and data seems to match? # For now assume joint_states structure return [] extracted_values = [] try: # Check if attribute exists on msg (at top level or nested?) raw_values = get_nested_item(msg, attribute, sep=".") raw_q = [raw_value.q for raw_value in raw_values] for j_name in self.cfg.joint_order: if j_name in current_map: idx = current_map[j_name] if len(raw_values) > idx: extracted_values.append(raw_q[idx]) else: extracted_values.append(0.0) else: # Joint missing in this message extracted_values.append(0.0) return extracted_values except (KeyError, AttributeError, TypeError) as e: logging.warning(f"Error extracting {attribute} from {ros_topic}: {e}") return []
[docs] class ImageExtractor(FeatureExtractor): """Extracts observation.image from ROS 2 compressed or raw image messages.""" def __call__(self, msg: Any, ros_topic: str, attribute: str) -> Any: """Decode image bytes to a numpy array (H, W, C) in RGB. Args: msg: Image message with .data (bytes), e.g., sensor_msgs/CompressedImage. ros_topic: Topic name the message was received on. attribute: Attribute path (e.g., "data"); typically "data" for image payload. Returns: numpy array of shape (H, W, 3) uint8 RGB, or None if decoding fails. RGBA is converted to RGB by dropping the alpha channel. """ try: import io from PIL import Image image = Image.open(io.BytesIO(msg.data)) # Convert to numpy array image_np = np.array(image) # Handle RGBA if necessary, or just ensure RGB if image_np.shape[-1] == 4: image_np = image_np[..., :3] return image_np except (KeyError, AttributeError, TypeError, Exception) as e: logging.warning(f"Error extracting {attribute} from {ros_topic}: {e}") return None
# Mapping of dataset feature enum values to extractor classes. # Used by convert_ros_to_lerobot to dispatch per-topic extraction. EXTRACTORS = { "state": StateExtractor, "action": ActionExtractor, "image": ImageExtractor, }