| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import math |
| import time |
| from dataclasses import dataclass |
| from typing import TYPE_CHECKING, Any, Protocol, TypeVar, runtime_checkable |
|
|
| import numpy as np |
| import torch |
| import torchvision.transforms.functional as F |
|
|
| from lerobot.configs import PipelineFeatureType, PolicyFeature |
| from lerobot.teleoperators.utils import TeleopEvents |
|
|
| if TYPE_CHECKING: |
| from lerobot.teleoperators.teleoperator import Teleoperator |
|
|
| from lerobot.types import EnvTransition, PolicyAction, TransitionKey |
|
|
| from .pipeline import ( |
| ComplementaryDataProcessorStep, |
| InfoProcessorStep, |
| ObservationProcessorStep, |
| ProcessorStep, |
| ProcessorStepRegistry, |
| TruncatedProcessorStep, |
| ) |
|
|
| GRIPPER_KEY = "gripper" |
| DISCRETE_PENALTY_KEY = "discrete_penalty" |
| TELEOP_ACTION_KEY = "teleop_action" |
|
|
|
|
| @runtime_checkable |
| class HasTeleopEvents(Protocol): |
| """ |
| Minimal protocol for objects that provide teleoperation events. |
| |
| This protocol defines the `get_teleop_events()` method, allowing processor |
| steps to interact with teleoperators that support event-based controls |
| (like episode termination or success flagging) without needing to know the |
| teleoperator's specific class. |
| """ |
|
|
| def get_teleop_events(self) -> dict[str, Any]: |
| """ |
| Get extra control events from the teleoperator. |
| |
| Returns: |
| A dictionary containing control events such as: |
| - `is_intervention`: bool - Whether the human is currently intervening. |
| - `terminate_episode`: bool - Whether to terminate the current episode. |
| - `success`: bool - Whether the episode was successful. |
| - `rerecord_episode`: bool - Whether to rerecord the episode. |
| """ |
| ... |
|
|
|
|
| |
| TeleopWithEvents = TypeVar("TeleopWithEvents", bound="Teleoperator") |
|
|
|
|
| def _check_teleop_with_events(teleop: "Teleoperator") -> None: |
| """ |
| Runtime check that a teleoperator implements the `HasTeleopEvents` protocol. |
| |
| Args: |
| teleop: The teleoperator instance to check. |
| |
| Raises: |
| TypeError: If the teleoperator does not have a `get_teleop_events` method. |
| """ |
| if not isinstance(teleop, HasTeleopEvents): |
| raise TypeError( |
| f"Teleoperator {type(teleop).__name__} must implement get_teleop_events() method. " |
| f"Compatible teleoperators: GamepadTeleop, KeyboardEndEffectorTeleop" |
| ) |
|
|
|
|
| @ProcessorStepRegistry.register("add_teleop_action_as_complementary_data") |
| @dataclass |
| class AddTeleopActionAsComplimentaryDataStep(ComplementaryDataProcessorStep): |
| """ |
| Adds the raw action from a teleoperator to the transition's complementary data. |
| |
| This is useful for human-in-the-loop scenarios where the human's input needs to |
| be available to downstream processors, for example, to override a policy's action |
| during an intervention. |
| |
| Attributes: |
| teleop_device: The teleoperator instance to get the action from. |
| """ |
|
|
| teleop_device: "Teleoperator" |
|
|
| def complementary_data(self, complementary_data: dict) -> dict: |
| """ |
| Retrieves the teleoperator's action and adds it to the complementary data. |
| |
| Args: |
| complementary_data: The incoming complementary data dictionary. |
| |
| Returns: |
| A new dictionary with the teleoperator action added under the |
| `teleop_action` key. |
| """ |
| new_complementary_data = dict(complementary_data) |
| new_complementary_data[TELEOP_ACTION_KEY] = self.teleop_device.get_action() |
| return new_complementary_data |
|
|
| def transform_features( |
| self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]] |
| ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]: |
| return features |
|
|
|
|
| @ProcessorStepRegistry.register("add_teleop_action_as_info") |
| @dataclass |
| class AddTeleopEventsAsInfoStep(InfoProcessorStep): |
| """ |
| Adds teleoperator control events (e.g., terminate, success) to the transition's info. |
| |
| This step extracts control events from teleoperators that support event-based |
| interaction, making these signals available to other parts of the system. |
| |
| Attributes: |
| teleop_device: An instance of a teleoperator that implements the |
| `HasTeleopEvents` protocol. |
| """ |
|
|
| teleop_device: TeleopWithEvents |
|
|
| def __post_init__(self): |
| """Validates that the provided teleoperator supports events after initialization.""" |
| _check_teleop_with_events(self.teleop_device) |
|
|
| def info(self, info: dict) -> dict: |
| """ |
| Retrieves teleoperator events and updates the info dictionary. |
| |
| Args: |
| info: The incoming info dictionary. |
| |
| Returns: |
| A new dictionary including the teleoperator events. |
| """ |
| new_info = dict(info) |
|
|
| teleop_events = self.teleop_device.get_teleop_events() |
| new_info.update(teleop_events) |
| return new_info |
|
|
| def transform_features( |
| self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]] |
| ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]: |
| return features |
|
|
|
|
| @ProcessorStepRegistry.register("image_crop_resize_processor") |
| @dataclass |
| class ImageCropResizeProcessorStep(ObservationProcessorStep): |
| """ |
| Crops and/or resizes image observations. |
| |
| This step iterates through all image keys in an observation dictionary and applies |
| the specified transformations. It handles device placement, moving tensors to the |
| CPU if necessary for operations not supported on certain accelerators like MPS. |
| |
| Attributes: |
| crop_params_dict: A dictionary mapping image keys to cropping parameters |
| (top, left, height, width). |
| resize_size: A tuple (height, width) to resize all images to. |
| """ |
|
|
| crop_params_dict: dict[str, tuple[int, int, int, int]] | None = None |
| resize_size: tuple[int, int] | None = None |
|
|
| def observation(self, observation: dict) -> dict: |
| """ |
| Applies cropping and resizing to all images in the observation dictionary. |
| |
| Args: |
| observation: The observation dictionary, potentially containing image tensors. |
| |
| Returns: |
| A new observation dictionary with transformed images. |
| """ |
| if self.resize_size is None and not self.crop_params_dict: |
| return observation |
|
|
| new_observation = dict(observation) |
|
|
| |
| for key in observation: |
| if "image" not in key: |
| continue |
|
|
| image = observation[key] |
| device = image.device |
| |
| if device.type == "mps": |
| image = image.cpu() |
| |
| if self.crop_params_dict is not None and key in self.crop_params_dict: |
| crop_params = self.crop_params_dict[key] |
| image = F.crop(image, *crop_params) |
| if self.resize_size is not None: |
| image = F.resize(image, self.resize_size) |
| image = image.clamp(0.0, 1.0) |
| new_observation[key] = image.to(device) |
|
|
| return new_observation |
|
|
| def get_config(self) -> dict[str, Any]: |
| """ |
| Returns the configuration of the step for serialization. |
| |
| Returns: |
| A dictionary with the crop parameters and resize dimensions. |
| """ |
| return { |
| "crop_params_dict": self.crop_params_dict, |
| "resize_size": self.resize_size, |
| } |
|
|
| def transform_features( |
| self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]] |
| ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]: |
| """ |
| Updates the image feature shapes in the policy features dictionary if resizing is applied. |
| |
| Args: |
| features: The policy features dictionary. |
| |
| Returns: |
| The updated policy features dictionary with new image shapes. |
| """ |
| if self.resize_size is None: |
| return features |
| for key in features[PipelineFeatureType.OBSERVATION]: |
| if "image" in key: |
| nb_channel = features[PipelineFeatureType.OBSERVATION][key].shape[0] |
| features[PipelineFeatureType.OBSERVATION][key] = PolicyFeature( |
| type=features[PipelineFeatureType.OBSERVATION][key].type, |
| shape=(nb_channel, *self.resize_size), |
| ) |
| return features |
|
|
|
|
| @dataclass |
| @ProcessorStepRegistry.register("time_limit_processor") |
| class TimeLimitProcessorStep(TruncatedProcessorStep): |
| """ |
| Tracks episode steps and enforces a time limit by truncating the episode. |
| |
| Attributes: |
| max_episode_steps: The maximum number of steps allowed per episode. |
| current_step: The current step count for the active episode. |
| """ |
|
|
| max_episode_steps: int |
| current_step: int = 0 |
|
|
| def truncated(self, truncated: bool) -> bool: |
| """ |
| Increments the step counter and sets the truncated flag if the time limit is reached. |
| |
| Args: |
| truncated: The incoming truncated flag. |
| |
| Returns: |
| True if the episode step limit is reached, otherwise the incoming value. |
| """ |
| self.current_step += 1 |
| if self.current_step >= self.max_episode_steps: |
| truncated = True |
| |
| return truncated |
|
|
| def get_config(self) -> dict[str, Any]: |
| """ |
| Returns the configuration of the step for serialization. |
| |
| Returns: |
| A dictionary containing the `max_episode_steps`. |
| """ |
| return { |
| "max_episode_steps": self.max_episode_steps, |
| } |
|
|
| def reset(self) -> None: |
| """Resets the step counter, typically called at the start of a new episode.""" |
| self.current_step = 0 |
|
|
| def transform_features( |
| self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]] |
| ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]: |
| return features |
|
|
|
|
| @ProcessorStepRegistry.register("gym_hil_adapter_processor") |
| class GymHILAdapterProcessorStep(ProcessorStep): |
| """ |
| Adapts the output of the `gym-hil` environment to the format expected by `lerobot` processors. |
| |
| This step normalizes the `transition` object by: |
| 1. Copying `teleop_action` from `info` to `complementary_data`. |
| 2. Copying `is_intervention` from `info` (using the string key) to `info` (using the enum key). |
| 3. Copying `discrete_penalty` from `info` to `complementary_data`. |
| """ |
|
|
| def __call__(self, transition: EnvTransition) -> EnvTransition: |
| info = transition.get(TransitionKey.INFO, {}) |
| complementary_data = transition.get(TransitionKey.COMPLEMENTARY_DATA, {}) |
|
|
| if TELEOP_ACTION_KEY in info: |
| complementary_data[TELEOP_ACTION_KEY] = info[TELEOP_ACTION_KEY] |
|
|
| if DISCRETE_PENALTY_KEY in info: |
| complementary_data[DISCRETE_PENALTY_KEY] = info[DISCRETE_PENALTY_KEY] |
|
|
| if "is_intervention" in info: |
| info[TeleopEvents.IS_INTERVENTION] = info["is_intervention"] |
|
|
| transition[TransitionKey.INFO] = info |
| transition[TransitionKey.COMPLEMENTARY_DATA] = complementary_data |
|
|
| return transition |
|
|
| def transform_features( |
| self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]] |
| ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]: |
| return features |
|
|
|
|
| @dataclass |
| @ProcessorStepRegistry.register("gripper_penalty_processor") |
| class GripperPenaltyProcessorStep(ProcessorStep): |
| """ |
| Applies a small per-transition cost on the discrete gripper action. |
| |
| Fires only when the commanded action would actually transition the gripper |
| from one extreme to the other (close-while-open or open-while-closed). |
| This discourages gripper oscillation while leaving "stay" and saturating-further |
| commands unpenalized. |
| |
| Attributes: |
| penalty: The negative reward value to apply. |
| max_gripper_pos: The maximum position value for the gripper, used for normalization. |
| open_threshold: Normalized state below which the gripper is considered "open". |
| closed_threshold: Normalized state above which the gripper is considered "closed". |
| """ |
|
|
| penalty: float = -0.02 |
| max_gripper_pos: float = 30.0 |
| open_threshold: float = 0.1 |
| closed_threshold: float = 0.9 |
|
|
| def __call__(self, transition: EnvTransition) -> EnvTransition: |
| """ |
| Calculates the gripper penalty and adds it to the complementary data. |
| |
| Args: |
| transition: The incoming environment transition. |
| |
| Returns: |
| The modified transition with the penalty added to complementary data. |
| """ |
| new_transition = transition.copy() |
| action = new_transition.get(TransitionKey.ACTION) |
| complementary_data = new_transition.get(TransitionKey.COMPLEMENTARY_DATA, {}) |
|
|
| raw_joint_positions = complementary_data.get("raw_joint_positions") |
| if raw_joint_positions is None: |
| return new_transition |
|
|
| current_gripper_pos = raw_joint_positions.get(f"{GRIPPER_KEY}.pos", None) |
| if current_gripper_pos is None: |
| return new_transition |
|
|
| |
| if action is None: |
| return new_transition |
|
|
| |
| gripper_action = action[-1].item() |
| gripper_action_normalized = gripper_action / self.max_gripper_pos |
|
|
| |
| gripper_state_normalized = current_gripper_pos / self.max_gripper_pos |
|
|
| |
| |
| |
| is_open = gripper_state_normalized < self.open_threshold |
| is_closed = gripper_state_normalized > self.closed_threshold |
| cmd_close = gripper_action_normalized > self.closed_threshold |
| cmd_open = gripper_action_normalized < self.open_threshold |
| gripper_penalty_bool = (is_open and cmd_close) or (is_closed and cmd_open) |
|
|
| gripper_penalty = self.penalty * int(gripper_penalty_bool) |
|
|
| |
| new_complementary_data = dict(complementary_data) |
| new_complementary_data[DISCRETE_PENALTY_KEY] = gripper_penalty |
| new_transition[TransitionKey.COMPLEMENTARY_DATA] = new_complementary_data |
|
|
| return new_transition |
|
|
| def get_config(self) -> dict[str, Any]: |
| """ |
| Returns the configuration of the step for serialization. |
| |
| Returns: |
| A dictionary containing the penalty value, max gripper position, |
| and the open/closed thresholds. |
| """ |
| return { |
| "penalty": self.penalty, |
| "max_gripper_pos": self.max_gripper_pos, |
| "open_threshold": self.open_threshold, |
| "closed_threshold": self.closed_threshold, |
| } |
|
|
| def reset(self) -> None: |
| """Resets the processor's internal state.""" |
| pass |
|
|
| def transform_features( |
| self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]] |
| ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]: |
| return features |
|
|
|
|
| @dataclass |
| @ProcessorStepRegistry.register("intervention_action_processor") |
| class InterventionActionProcessorStep(ProcessorStep): |
| """ |
| Handles human intervention, overriding policy actions and managing episode termination. |
| |
| When an intervention is detected (via teleoperator events in the `info` dict), |
| this step replaces the policy's action with the human's teleoperated action. |
| It also processes signals to terminate the episode or flag success. |
| |
| Attributes: |
| use_gripper: Whether to include the gripper in the teleoperated action. |
| terminate_on_success: If True, automatically sets the `done` flag when a |
| `success` event is received. |
| """ |
|
|
| use_gripper: bool = False |
| terminate_on_success: bool = True |
|
|
| def __call__(self, transition: EnvTransition) -> EnvTransition: |
| """ |
| Processes the transition to handle interventions. |
| |
| Args: |
| transition: The incoming environment transition. |
| |
| Returns: |
| The modified transition, potentially with an overridden action, updated |
| reward, and termination status. |
| """ |
| action = transition.get(TransitionKey.ACTION) |
| if not isinstance(action, PolicyAction): |
| raise ValueError(f"Action should be a PolicyAction type got {type(action)}") |
|
|
| |
| info = transition.get(TransitionKey.INFO, {}) |
| complementary_data = transition.get(TransitionKey.COMPLEMENTARY_DATA, {}) |
| teleop_action = complementary_data.get(TELEOP_ACTION_KEY, {}) |
| is_intervention = info.get(TeleopEvents.IS_INTERVENTION, False) |
| terminate_episode = info.get(TeleopEvents.TERMINATE_EPISODE, False) |
| success = info.get(TeleopEvents.SUCCESS, False) |
| rerecord_episode = info.get(TeleopEvents.RERECORD_EPISODE, False) |
|
|
| new_transition = transition.copy() |
|
|
| |
| if is_intervention and teleop_action is not None: |
| if isinstance(teleop_action, dict): |
| |
| action_list = [ |
| teleop_action.get("delta_x", 0.0), |
| teleop_action.get("delta_y", 0.0), |
| teleop_action.get("delta_z", 0.0), |
| ] |
| if self.use_gripper: |
| action_list.append(teleop_action.get(GRIPPER_KEY, 1.0)) |
| elif isinstance(teleop_action, np.ndarray): |
| action_list = teleop_action.tolist() |
| else: |
| action_list = teleop_action |
|
|
| teleop_action_tensor = torch.tensor(action_list, dtype=action.dtype, device=action.device) |
| new_transition[TransitionKey.ACTION] = teleop_action_tensor |
|
|
| |
| new_transition[TransitionKey.DONE] = bool(terminate_episode) or ( |
| self.terminate_on_success and success |
| ) |
| new_transition[TransitionKey.REWARD] = float(success) |
|
|
| |
| info = new_transition.get(TransitionKey.INFO, {}) |
| info[TeleopEvents.IS_INTERVENTION] = is_intervention |
| info[TeleopEvents.RERECORD_EPISODE] = rerecord_episode |
| info[TeleopEvents.SUCCESS] = success |
| new_transition[TransitionKey.INFO] = info |
|
|
| |
| complementary_data = new_transition.get(TransitionKey.COMPLEMENTARY_DATA, {}) |
| complementary_data[TELEOP_ACTION_KEY] = new_transition.get(TransitionKey.ACTION) |
| new_transition[TransitionKey.COMPLEMENTARY_DATA] = complementary_data |
|
|
| return new_transition |
|
|
| def get_config(self) -> dict[str, Any]: |
| """ |
| Returns the configuration of the step for serialization. |
| |
| Returns: |
| A dictionary containing the step's configuration attributes. |
| """ |
| return { |
| "use_gripper": self.use_gripper, |
| "terminate_on_success": self.terminate_on_success, |
| } |
|
|
| def transform_features( |
| self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]] |
| ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]: |
| return features |
|
|
|
|
| @dataclass |
| @ProcessorStepRegistry.register("reward_classifier_processor") |
| class RewardClassifierProcessorStep(ProcessorStep): |
| """ |
| Applies a pretrained reward classifier to image observations to predict success. |
| |
| This step uses a model to determine if the current state is successful, updating |
| the reward and potentially terminating the episode. |
| |
| Attributes: |
| pretrained_path: Path to the pretrained reward classifier model. |
| device: The device to run the classifier on. |
| success_threshold: The probability threshold to consider a prediction as successful. |
| success_reward: The reward value to assign on success. |
| terminate_on_success: If True, terminates the episode upon successful classification. |
| reward_classifier: The loaded classifier model instance. |
| """ |
|
|
| pretrained_path: str | None = None |
| device: str = "cpu" |
| success_threshold: float = 0.5 |
| success_reward: float = 1.0 |
| terminate_on_success: bool = True |
|
|
| reward_classifier: Any = None |
|
|
| def __post_init__(self): |
| """Initializes the reward classifier model after the dataclass is created.""" |
| if self.pretrained_path is not None: |
| from lerobot.rewards.classifier.modeling_classifier import Classifier |
|
|
| self.reward_classifier = Classifier.from_pretrained(self.pretrained_path) |
| self.reward_classifier.to(self.device) |
| self.reward_classifier.eval() |
|
|
| def __call__(self, transition: EnvTransition) -> EnvTransition: |
| """ |
| Processes a transition, applying the reward classifier to its image observations. |
| |
| Args: |
| transition: The incoming environment transition. |
| |
| Returns: |
| The modified transition with an updated reward and done flag based on the |
| classifier's prediction. |
| """ |
| new_transition = transition.copy() |
| observation = new_transition.get(TransitionKey.OBSERVATION) |
| if observation is None or self.reward_classifier is None: |
| return new_transition |
|
|
| |
| images = {key: value for key, value in observation.items() if "image" in key} |
|
|
| if not images: |
| return new_transition |
|
|
| |
| start_time = time.perf_counter() |
| with torch.inference_mode(): |
| success = self.reward_classifier.predict_reward(images, threshold=self.success_threshold) |
|
|
| classifier_frequency = 1 / (time.perf_counter() - start_time) |
|
|
| |
| reward = new_transition.get(TransitionKey.REWARD, 0.0) |
| terminated = new_transition.get(TransitionKey.DONE, False) |
|
|
| if math.isclose(success, 1, abs_tol=1e-2): |
| reward = self.success_reward |
| if self.terminate_on_success: |
| terminated = True |
|
|
| |
| new_transition[TransitionKey.REWARD] = reward |
| new_transition[TransitionKey.DONE] = terminated |
|
|
| |
| info = new_transition.get(TransitionKey.INFO, {}) |
| info["reward_classifier_frequency"] = classifier_frequency |
| new_transition[TransitionKey.INFO] = info |
|
|
| return new_transition |
|
|
| def get_config(self) -> dict[str, Any]: |
| """ |
| Returns the configuration of the step for serialization. |
| |
| Returns: |
| A dictionary containing the step's configuration attributes. |
| """ |
| return { |
| "device": self.device, |
| "success_threshold": self.success_threshold, |
| "success_reward": self.success_reward, |
| "terminate_on_success": self.terminate_on_success, |
| } |
|
|
| def transform_features( |
| self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]] |
| ) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]: |
| return features |
|
|