From 58a5c858148843a7fe2eb5e2f0c9013252b64016 Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Tue, 16 Dec 2025 15:59:27 +0800 Subject: [PATCH 01/26] implementation of lerobot data handler --- configs/gym/pour_water/gym_config.json | 4 + embodichain/data/enum.py | 164 ++++++++ .../data/handler/lerobot_data_handler.py | 395 ++++++++++++++++++ embodichain/lab/gym/envs/embodied_env.py | 73 +++- embodichain/lab/gym/utils/misc.py | 14 + embodichain/lab/scripts/run_env.py | 9 +- 6 files changed, 651 insertions(+), 8 deletions(-) create mode 100644 embodichain/data/handler/lerobot_data_handler.py diff --git a/configs/gym/pour_water/gym_config.json b/configs/gym/pour_water/gym_config.json index 9048f817..907cf4cb 100644 --- a/configs/gym/pour_water/gym_config.json +++ b/configs/gym/pour_water/gym_config.json @@ -259,10 +259,14 @@ }, "dataset": { "robot_meta": { + "robot_type": "CobotMagic", "arm_dofs": 12, "control_freq": 25, "control_parts": ["left_arm", "left_eef", "right_arm", "right_eef"], "min_len_steps": 5 + }, + "instruction": { + "lang": "Pour water from bottle to cup" } } }, diff --git a/embodichain/data/enum.py b/embodichain/data/enum.py index 3716b840..529935b3 100644 --- a/embodichain/data/enum.py +++ b/embodichain/data/enum.py @@ -15,6 +15,8 @@ # ---------------------------------------------------------------------------- from enum import Enum, IntEnum +import torch +import numpy as np class SemanticMask(IntEnum): @@ -59,3 +61,165 @@ class Hints(Enum): EndEffector.DEXTROUSHAND.value, ) ARM = (ControlParts.LEFT_ARM.value, ControlParts.RIGHT_ARM.value) + +class Modality(Enum): + STATES = "states" + STATE_INDICATOR = "state_indicator" + ACTIONS = "actions" + ACTION_INDICATOR = "action_indicator" + IMAGES = "images" + LANG = "lang" + LANG_INDICATOR = "lang_indicator" + GEOMAP = "geomap" # e.g., depth, point cloud, etc. + VISION_LANGUAGE = "vision_language" # e.g., image + lang + + +class JointType(Enum): + QPOS = "qpos" + + +class EefType(Enum): + POSE = "eef_pose" + + +class ActionMode(Enum): + ABSOLUTE = "" + RELATIVE = "delta_" # This indicates the action is relative change with respect to last state. + +SUPPORTED_PROPRIO_TYPES = [ + ControlParts.LEFT_ARM.value + EefType.POSE.value, + ControlParts.RIGHT_ARM.value + EefType.POSE.value, + ControlParts.LEFT_ARM.value + JointType.QPOS.value, + ControlParts.RIGHT_ARM.value + JointType.QPOS.value, + ControlParts.LEFT_EEF.value + EndEffector.DEXTROUSHAND.value, + ControlParts.RIGHT_EEF.value + EndEffector.DEXTROUSHAND.value, + ControlParts.LEFT_EEF.value + EndEffector.GRIPPER.value, + ControlParts.RIGHT_EEF.value + EndEffector.GRIPPER.value, +] +SUPPORTED_ACTION_TYPES = SUPPORTED_PROPRIO_TYPES + [ + ControlParts.LEFT_ARM.value + ActionMode.RELATIVE.value + JointType.QPOS.value, + ControlParts.RIGHT_ARM.value + ActionMode.RELATIVE.value + JointType.QPOS.value, +] + +class HandQposNormalizer: + """ + A class for normalizing and denormalizing dexterous hand qpos data. + """ + + def __init__(self): + pass + + @staticmethod + def normalize_hand_qpos( + qpos_data: np.ndarray, + key: str, + agent=None, + robot=None, + ) -> np.ndarray: + """ + Clip and normalize dexterous hand qpos data. + + Args: + qpos_data: Raw qpos data + key: Control part key + agent: LearnableRobot instance (for V2 API) + robot: Robot instance (for V3 API) + + Returns: + Normalized qpos data in range [0, 1] + """ + if isinstance(qpos_data, torch.Tensor): + qpos_data = qpos_data.cpu().numpy() + + if agent is not None: + if key not in [ + ControlParts.LEFT_EEF.value + EndEffector.DEXTROUSHAND.value, + ControlParts.RIGHT_EEF.value + EndEffector.DEXTROUSHAND.value, + ]: + return qpos_data + indices = agent.get_data_index(key, warning=False) + full_limits = agent.get_joint_limits(agent.uid) + limits = full_limits[indices] # shape: [num_joints, 2] + elif robot is not None: + if key not in [ + ControlParts.LEFT_EEF.value + EndEffector.DEXTROUSHAND.value, + ControlParts.RIGHT_EEF.value + EndEffector.DEXTROUSHAND.value, + ]: + if key in [ControlParts.LEFT_EEF.value, ControlParts.RIGHT_EEF.value]: + # Note: In V3, robot does not distinguish between GRIPPER EEF and HAND EEF in uid, + # _data_key_to_control_part maps both to EEF. Under current conditions, normalization + # will not be performed. Please confirm if this is intended. + pass + return qpos_data + indices = robot.get_joint_ids(key, remove_mimic=True) + limits = robot.body_data.qpos_limits[0][indices] # shape: [num_joints, 2] + else: + raise ValueError("Either agent or robot must be provided") + + if isinstance(limits, torch.Tensor): + limits = limits.cpu().numpy() + + qpos_min = limits[:, 0] # Lower limits + qpos_max = limits[:, 1] # Upper limits + + # Step 1: Clip to valid range + qpos_clipped = np.clip(qpos_data, qpos_min, qpos_max) + + # Step 2: Normalize to [0, 1] + qpos_normalized = (qpos_clipped - qpos_min) / (qpos_max - qpos_min + 1e-8) + + return qpos_normalized + + @staticmethod + def denormalize_hand_qpos( + normalized_qpos: torch.Tensor, + key: str, # "left" or "right" + agent=None, + robot=None, + ) -> torch.Tensor: + """ + Denormalize normalized dexterous hand qpos back to actual angle values + + Args: + normalized_qpos: Normalized qpos in range [0, 1] + key: Control part key + robot: Robot instance + + Returns: + Denormalized actual qpos values + """ + + if agent is not None: + if key not in [ + ControlParts.LEFT_EEF.value + EndEffector.DEXTROUSHAND.value, + ControlParts.RIGHT_EEF.value + EndEffector.DEXTROUSHAND.value, + ]: + return normalized_qpos + indices = agent.get_data_index(key, warning=False) + full_limits = agent.get_joint_limits(agent.uid) + limits = full_limits[indices] # shape: [num_joints, 2] + elif robot is not None: + if key not in [ + ControlParts.LEFT_EEF.value + EndEffector.DEXTROUSHAND.value, + ControlParts.RIGHT_EEF.value + EndEffector.DEXTROUSHAND.value, + ]: + if key in [ControlParts.LEFT_EEF.value, ControlParts.RIGHT_EEF.value]: + # Note: In V3, robot does not distinguish between GRIPPER EEF and HAND EEF in uid, + # _data_key_to_control_part maps both to EEF. Under current conditions, denormalization + # will not be performed. Please confirm if this is intended. + pass + return normalized_qpos + indices = robot.get_joint_ids(key, remove_mimic=True) + limits = robot.body_data.qpos_limits[0][indices] # shape: [num_joints, 2] + else: + raise ValueError("Either agent or robot must be provided") + + qpos_min = limits[:, 0].cpu().numpy() # Lower limits + qpos_max = limits[:, 1].cpu().numpy() # Upper limits + + if isinstance(normalized_qpos, torch.Tensor): + normalized_qpos = normalized_qpos.cpu().numpy() + + denormalized_qpos = normalized_qpos * (qpos_max - qpos_min) + qpos_min + + return denormalized_qpos diff --git a/embodichain/data/handler/lerobot_data_handler.py b/embodichain/data/handler/lerobot_data_handler.py new file mode 100644 index 00000000..71147baf --- /dev/null +++ b/embodichain/data/handler/lerobot_data_handler.py @@ -0,0 +1,395 @@ + +from typing import Dict, Any, List, Union, Optional +from copy import deepcopy +from pathlib import Path +import traceback + +import numpy as np +import torch + +from embodichain.lab.gym.envs import EmbodiedEnv +from embodichain.data.enum import ( + HandQposNormalizer, + Modality, + JointType, +) +from embodichain.utils.utility import get_right_name +from embodichain.lab.gym.utils.misc import is_stereocam, data_key_to_control_part +from embodichain.utils import logger +from embodichain.data.enum import ( + SUPPORTED_PROPRIO_TYPES, + SUPPORTED_ACTION_TYPES, +) +from tqdm import tqdm + +# Optional LeRobot imports (for convert to lerobot format functionality) +try: + from lerobot.common.datasets.lerobot_dataset import LeRobotDataset, HF_LEROBOT_HOME + HAS_LEROBOT = True +except ImportError: + LeRobotDataset = None + HF_LEROBOT_HOME = None + HAS_LEROBOT = False + + +class LerobotDataHandler: + def __init__( + self, + env: EmbodiedEnv, + save_path: str = None, + compression_opts: int = 9, + ): + self.env = env + self.save_path = save_path + self.data = {} + + # save all supported proprio and action types. + robot_meta_config = deepcopy(self.env.metadata["dataset"]["robot_meta"]) + robot_meta_config["observation"][ + Modality.STATES.value + ] = SUPPORTED_PROPRIO_TYPES + robot_meta_config[Modality.ACTIONS.value] = SUPPORTED_ACTION_TYPES + + self.compression_opts = compression_opts + + + def extract_to_lerobot( + self, + obs_list: List[Dict[str, Any]], + action_list: List[Dict[str, Any]], + repo_id: str, + fps: int = 30, + use_videos: bool = True, + image_writer_threads: int = 4, + image_writer_processes: int = 0, + ) -> "LeRobotDataset": + """ + Extract data and save in LeRobot format. + + Args: + obs_list (List[Dict]): List of observation dicts. + action_list (List[Dict]): List of action dicts. + repo_id (str): Repository ID for the LeRobot dataset. + fps (int): Frames per second. Defaults to 30. + use_videos (bool): Whether to use video encoding. Defaults to True. + image_writer_threads (int): Number of threads for image writing. Defaults to 4. + image_writer_processes (int): Number of processes for image writing. Defaults to 0. + + Returns: + LeRobotDataset: The created LeRobot dataset instance. + """ + if not HAS_LEROBOT: + raise ImportError( + "LeRobot not installed. Please install it with: pip install lerobot" + ) + + # Build features dict from environment metadata + features = self._build_lerobot_features(use_videos=use_videos) + + # Get robot type + robot_type = self.env.metadata["dataset"]["robot_meta"].get("robot_type", "unknown") + + # Create LeRobot dataset + dataset = LeRobotDataset.create( + repo_id=repo_id, + robot_type=robot_type, + fps=fps, + features=features, + use_videos=use_videos, + image_writer_threads=image_writer_threads, + image_writer_processes=image_writer_processes, + ) + + # Get task/instruction + task = self.env.metadata["dataset"]["instruction"].get("lang", "unknown_task") + + # Convert and add frames + logger.log_info(f"Converting {len(obs_list)} frames to LeRobot format...") + for i, (obs, action) in enumerate(zip(obs_list, action_list)): + frame = self._convert_frame_to_lerobot(obs, action, task) + dataset.add_frame(frame) + + return dataset + + def _build_lerobot_features(self, use_videos: bool = True) -> Dict: + """ + Build LeRobot features dict from environment metadata. + + Args: + use_videos (bool): Whether to use video encoding. Defaults to True. + + Returns: + Dict: Features dictionary compatible with LeRobot format. + """ + features = {} + robot_meta_config = self.env.metadata["dataset"]["robot_meta"] + extra_vision_config = robot_meta_config["observation"]["vision"] + + # Add image features + for camera_name in extra_vision_config.keys(): + sensor = self.env.get_sensor(camera_name) + is_stereo = is_stereocam(sensor) + + # Get image shape from sensor + img_shape = ( + sensor.camera_cfg.height, + sensor.camera_cfg.width, + 3, + ) + + features[camera_name] = { + "dtype": "video" if use_videos else "image", + "shape": img_shape, + "names": ["height", "width", "channel"], + } + + if is_stereo: + features[get_right_name(camera_name)] = { + "dtype": "video" if use_videos else "image", + "shape": img_shape, + "names": ["height", "width", "channel"], + } + + # Add state features (proprio) + state_dim = 0 + for proprio_name in SUPPORTED_PROPRIO_TYPES: + robot = self.env.robot + part = data_key_to_control_part( + robot=robot, + control_parts=robot_meta_config.get("control_parts", []), + data_key=proprio_name, + ) + if part: + indices = robot.get_joint_ids(part, remove_mimic=True) + state_dim += len(indices) + + if state_dim > 0: + features["observation.state"] = { + "dtype": "float32", + "shape": (state_dim,), + "names": ["state"], + } + + # Add action features + action_dim = robot_meta_config.get("arm_dofs", 7) + features["action"] = { + "dtype": "float32", + "shape": (action_dim,), + "names": ["action"], + } + + return features + + def _convert_frame_to_lerobot( + self, obs: Dict[str, Any], action: Dict[str, Any], task: str + ) -> Dict: + """ + Convert a single frame to LeRobot format. + + Args: + obs (Dict): Observation dict from environment. + action (Dict): Action dict from environment. + task (str): Task description. + + Returns: + Dict: Frame in LeRobot format. + """ + frame = {"task": task} + robot_meta_config = self.env.metadata["dataset"]["robot_meta"] + extra_vision_config = robot_meta_config["observation"]["vision"] + + # Add images + for camera_name in extra_vision_config.keys(): + if camera_name in obs["sensor"]: + is_stereo = is_stereocam(self.env.get_sensor(camera_name)) + + # Process left/main camera image + color_data = obs["sensor"][camera_name]["color"] + if isinstance(color_data, torch.Tensor): + color_img = color_data.squeeze(0)[:, :, :3].cpu().numpy() + else: + color_img = np.array(color_data).squeeze(0)[:, :, :3] + + # Ensure uint8 format (0-255 range) + if color_img.dtype == np.float32 or color_img.dtype == np.float64: + color_img = (color_img * 255).astype(np.uint8) + + frame[camera_name] = color_img + + # Process right camera image if stereo + if is_stereo: + color_right_data = obs["sensor"][camera_name]["color_right"] + if isinstance(color_right_data, torch.Tensor): + color_right_img = color_right_data.squeeze(0)[:, :, :3].cpu().numpy() + else: + color_right_img = np.array(color_right_data).squeeze(0)[:, :, :3] + + # Ensure uint8 format + if color_right_img.dtype == np.float32 or color_right_img.dtype == np.float64: + color_right_img = (color_right_img * 255).astype(np.uint8) + + frame[get_right_name(camera_name)] = color_right_img + + # Add state (proprio) + state_list = [] + robot = self.env.robot + qpos = obs["robot"][JointType.QPOS.value] + for proprio_name in SUPPORTED_PROPRIO_TYPES: + part = data_key_to_control_part( + robot=robot, + control_parts=robot_meta_config.get("control_parts", []), + data_key=proprio_name, + ) + if part: + indices = robot.get_joint_ids(part, remove_mimic=True) + qpos_data = qpos[0][indices].cpu().numpy() + qpos_data = HandQposNormalizer.normalize_hand_qpos( + qpos_data, part, robot=robot + ) + state_list.append(qpos_data) + + if state_list: + frame["observation.state"] = np.concatenate(state_list) + + # Add actions + robot = self.env.robot + arm_dofs = robot_meta_config.get("arm_dofs", 7) + + # Handle different action types + if isinstance(action, torch.Tensor): + action_data = action[0, :arm_dofs].cpu().numpy() + elif isinstance(action, np.ndarray): + action_data = action[0, :arm_dofs] + elif isinstance(action, dict): + # If action is a dict, try to extract the actual action data + # This depends on your action dict structure + action_data = action.get("action", action.get("arm_action", action)) + if isinstance(action_data, torch.Tensor): + action_data = action_data[0, :arm_dofs].cpu().numpy() + elif isinstance(action_data, np.ndarray): + action_data = action_data[0, :arm_dofs] + else: + # Fallback: try to convert to numpy + action_data = np.array(action)[0, :arm_dofs] + + frame["action"] = action_data + + return frame + +def save_to_lerobot_format( + env: EmbodiedEnv, + obs_list: List[Dict[str, Any]], + action_list: List[Dict[str, Any]], + repo_id: str, + fps: int = 30, + use_videos: bool = True, + push_to_hub: bool = False, + image_writer_threads: int = 4, + image_writer_processes: int = 0, +) -> Optional[str]: + """ + Save episode data to LeRobot format. + + Args: + env (EmbodiedEnv): Environment instance. + obs_list (List[Dict]): List of observation dicts (without last obs). + action_list (List[Dict]): List of action dicts. + repo_id (str): Repository ID for LeRobot dataset (e.g., "username/dataset_name"). + fps (int): Frames per second. Defaults to 30. + use_videos (bool): Whether to encode images as videos. Defaults to True. + push_to_hub (bool): Whether to push to Hugging Face Hub. Defaults to False. + image_writer_threads (int): Number of threads for image writing. Defaults to 4. + image_writer_processes (int): Number of processes for image writing. Defaults to 0. + + Returns: + Optional[str]: Path to saved dataset, or None if failed. + + Example: + >>> save_to_lerobot_format( + ... env=env, + ... obs_list=env.episode_obs_list[:-1], + ... action_list=env.episode_action_list, + ... repo_id="my_username/my_robot_dataset", + ... fps=30, + ... use_videos=True, + ... push_to_hub=False, + ... ) + """ + if not HAS_LEROBOT: + logger.log_error( + "LeRobot not installed. Please install it with: pip install lerobot" + ) + return None + + if len(obs_list) == 0 or len(action_list) == 0: + logger.log_error("obs_list and action_list cannot be empty") + return None + + if len(obs_list) != len(action_list): + logger.log_error( + f"obs_list and action_list must have same length, got {len(obs_list)} and {len(action_list)}" + ) + return None + + try: + extractor = LerobotDataHandler(env) + + # Build features + features = extractor._build_lerobot_features(use_videos=use_videos) + + # Get robot type + robot_type = env.metadata["dataset"]["robot_meta"].get("robot_type", "unknown") + + # Get or create dataset + if HF_LEROBOT_HOME is not None: + dataset_path = Path(HF_LEROBOT_HOME) / repo_id + else: + # Fallback to default path + dataset_path = Path.home() / ".cache" / "huggingface" / "lerobot" / repo_id + + # Check if dataset already exists + if dataset_path.exists(): + logger.log_info(f"Loading existing LeRobot dataset from {dataset_path}") + dataset = LeRobotDataset(repo_id=repo_id) + else: + logger.log_info(f"Creating new LeRobot dataset at {dataset_path}") + dataset = LeRobotDataset.create( + repo_id=repo_id, + robot_type=robot_type, + fps=fps, + features=features, + use_videos=use_videos, + image_writer_threads=image_writer_threads, + image_writer_processes=image_writer_processes, + ) + + # Get task + task = env.metadata["dataset"]["instruction"].get("lang", "unknown_task") + + # Add frames + logger.log_info(f"Adding {len(obs_list)} frames to LeRobot dataset...") + for obs, action in tqdm(zip(obs_list, action_list), total=len(obs_list)): + frame = extractor._convert_frame_to_lerobot(obs, action, task) + dataset.add_frame(frame) + + # Save episode + logger.log_info("Saving episode to LeRobot dataset...") + dataset.save_episode() + + # Optionally push to hub + if push_to_hub: + logger.log_info(f"Pushing dataset to Hugging Face Hub: {repo_id}") + dataset.push_to_hub( + tags=[robot_type, "imitation"], + private=False, + push_videos=use_videos, + license="apache-2.0", + ) + + logger.log_info(f"Successfully saved episode to {dataset_path}") + return str(dataset_path) + + except Exception as e: + logger.log_error(f"Failed to save to LeRobot format: {e}") + traceback.print_exc() + return None diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index afb01a0f..d62d9bc5 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -452,20 +452,81 @@ def create_demo_action_list(self, *args, **kwargs) -> Sequence[EnvAction] | None "The method 'create_demo_action_list' must be implemented in subclasses." ) - def to_dataset(self, id: str, save_path: str = None) -> str | None: - """Convert the recorded episode data to a dataset format. + def to_dataset( + self, + repo_id: str, + fps: int = 30, + use_videos: bool = True, + push_to_hub: bool = False, + image_writer_threads: int = 4, + image_writer_processes: int = 0, + ) -> str | None: + """Convert the recorded episode data to LeRobot dataset format. Args: - id (str): Unique identifier for the dataset. - save_path (str, optional): Path to save the dataset. If None, use config or default. + repo_id (str): Repository ID for LeRobot dataset (e.g., "username/dataset_name"). + fps (int): Frames per second for video encoding. Defaults to 30. + use_videos (bool): Whether to encode images as videos. Defaults to True. + push_to_hub (bool): Whether to push to Hugging Face Hub. Defaults to False. + image_writer_threads (int): Number of threads for image writing. Defaults to 4. + image_writer_processes (int): Number of processes for image writing. Defaults to 0. Returns: str | None: The path to the saved dataset, or None if failed. """ - raise NotImplementedError( - "The method 'to_dataset' will be implemented in the near future." + if not hasattr(self, "episode_obs_list") or not hasattr( + self, "episode_action_list" + ): + logger.log_error( + "Episode data not available. Make sure dataset configuration is set in the environment config." + ) + return None + + if len(self.episode_obs_list) == 0: + logger.log_error("No episode data to save. Episode observation list is empty.") + return None + + try: + # Import the handler - use try-except to catch import errors + from embodichain.data.handler.lerobot_data_handler import ( + save_to_lerobot_format, + ) + except ImportError as e: + logger.log_error(f"Failed to import lerobot_data_handler: {e}") + logger.log_error("Make sure all dependencies are installed: pip install lerobot") + return None + except Exception as e: + logger.log_error(f"Unexpected error importing lerobot_data_handler: {e}") + return None + + # Prepare obs_list and action_list + # Remove the last observation as it doesn't have a corresponding action + obs_list = self.episode_obs_list[:-1] if len(self.episode_obs_list) > len(self.episode_action_list) else self.episode_obs_list + action_list = self.episode_action_list + + logger.log_info(f"Saving episode with {len(obs_list)} frames to LeRobot format...") + + # Save to LeRobot format + dataset_path = save_to_lerobot_format( + env=self, + obs_list=obs_list, + action_list=action_list, + repo_id=repo_id, + fps=fps, + use_videos=use_videos, + push_to_hub=push_to_hub, + image_writer_threads=image_writer_threads, + image_writer_processes=image_writer_processes, ) + if dataset_path: + logger.log_info( + f"Successfully saved episode {self.curr_episode} to {dataset_path}" + ) + self.curr_episode += 1 + + return dataset_path + def is_task_success(self, **kwargs) -> torch.Tensor: """Determine if the task is successfully completed. This is mainly used in the data generation process of the imitation learning. diff --git a/embodichain/lab/gym/utils/misc.py b/embodichain/lab/gym/utils/misc.py index b669e6cf..beb9bac0 100644 --- a/embodichain/lab/gym/utils/misc.py +++ b/embodichain/lab/gym/utils/misc.py @@ -1367,3 +1367,17 @@ def is_eef_hand(robot, control_parts) -> bool: if "gripper" in data_key and is_eef_hand(robot, control_parts) is False: return "right_eef" return None + + +def is_stereocam(sensor) -> bool: + """ + Check if a sensor is a StereoCamera (binocular camera). + + Args: + sensor: The sensor instance to check. + + Returns: + bool: True if the sensor is a StereoCamera, False otherwise. + """ + from embodichain.lab.sim.sensors import StereoCamera + return isinstance(sensor, StereoCamera) diff --git a/embodichain/lab/scripts/run_env.py b/embodichain/lab/scripts/run_env.py index 1ad5318f..626f23fe 100644 --- a/embodichain/lab/scripts/run_env.py +++ b/embodichain/lab/scripts/run_env.py @@ -85,7 +85,8 @@ def generate_function( valid = True while True: _, _ = env.reset() - + + ret = [] for trajectory_idx in range(num_traj): valid = generate_and_execute_action_list(env, trajectory_idx, debug_mode) @@ -93,7 +94,11 @@ def generate_function( break if not debug_mode and env.is_task_success().item(): - pass + dataset_id = f"time_{time_id}_trajectory_{trajectory_idx}" + data_dict = env.to_dataset( + repo_id=dataset_id, + ) + ret.append(data_dict) # TODO: Add data saving and online data streaming logic here. From 9b42a3ebbf54d5f583b743dfc110aff4cdc6af71 Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Wed, 17 Dec 2025 16:15:32 +0800 Subject: [PATCH 02/26] implementation of lerobot data handler --- configs/gym/pour_water/gym_config.json | 10 +++ embodichain/data/enum.py | 3 + .../data/handler/lerobot_data_handler.py | 64 ++++++++----------- embodichain/lab/gym/envs/embodied_env.py | 18 ++++-- embodichain/lab/gym/utils/misc.py | 5 +- embodichain/lab/scripts/run_env.py | 2 +- embodichain/lab/sim/objects/articulation.py | 14 ++-- 7 files changed, 68 insertions(+), 48 deletions(-) diff --git a/configs/gym/pour_water/gym_config.json b/configs/gym/pour_water/gym_config.json index 907cf4cb..5873c1fd 100644 --- a/configs/gym/pour_water/gym_config.json +++ b/configs/gym/pour_water/gym_config.json @@ -263,6 +263,16 @@ "arm_dofs": 12, "control_freq": 25, "control_parts": ["left_arm", "left_eef", "right_arm", "right_eef"], + "observation": { + "vision": { + "cam_high": ["mask"], + "cam_right_wrist": ["mask"], + "cam_left_wrist": ["mask"] + }, + "states": ["qpos"], + "exteroception": ["cam_high", "cam_right_wrist", "cam_left_wrist"] + }, + "action": "qpos_with_eef_pose", "min_len_steps": 5 }, "instruction": { diff --git a/embodichain/data/enum.py b/embodichain/data/enum.py index 529935b3..61637892 100644 --- a/embodichain/data/enum.py +++ b/embodichain/data/enum.py @@ -62,6 +62,7 @@ class Hints(Enum): ) ARM = (ControlParts.LEFT_ARM.value, ControlParts.RIGHT_ARM.value) + class Modality(Enum): STATES = "states" STATE_INDICATOR = "state_indicator" @@ -86,6 +87,7 @@ class ActionMode(Enum): ABSOLUTE = "" RELATIVE = "delta_" # This indicates the action is relative change with respect to last state. + SUPPORTED_PROPRIO_TYPES = [ ControlParts.LEFT_ARM.value + EefType.POSE.value, ControlParts.RIGHT_ARM.value + EefType.POSE.value, @@ -101,6 +103,7 @@ class ActionMode(Enum): ControlParts.RIGHT_ARM.value + ActionMode.RELATIVE.value + JointType.QPOS.value, ] + class HandQposNormalizer: """ A class for normalizing and denormalizing dexterous hand qpos data. diff --git a/embodichain/data/handler/lerobot_data_handler.py b/embodichain/data/handler/lerobot_data_handler.py index 71147baf..f9096c51 100644 --- a/embodichain/data/handler/lerobot_data_handler.py +++ b/embodichain/data/handler/lerobot_data_handler.py @@ -1,4 +1,3 @@ - from typing import Dict, Any, List, Union, Optional from copy import deepcopy from pathlib import Path @@ -23,13 +22,7 @@ from tqdm import tqdm # Optional LeRobot imports (for convert to lerobot format functionality) -try: - from lerobot.common.datasets.lerobot_dataset import LeRobotDataset, HF_LEROBOT_HOME - HAS_LEROBOT = True -except ImportError: - LeRobotDataset = None - HF_LEROBOT_HOME = None - HAS_LEROBOT = False +from lerobot.datasets.lerobot_dataset import LeRobotDataset, HF_LEROBOT_HOME class LerobotDataHandler: @@ -52,7 +45,6 @@ def __init__( self.compression_opts = compression_opts - def extract_to_lerobot( self, obs_list: List[Dict[str, Any]], @@ -78,16 +70,13 @@ def extract_to_lerobot( Returns: LeRobotDataset: The created LeRobot dataset instance. """ - if not HAS_LEROBOT: - raise ImportError( - "LeRobot not installed. Please install it with: pip install lerobot" - ) - # Build features dict from environment metadata features = self._build_lerobot_features(use_videos=use_videos) # Get robot type - robot_type = self.env.metadata["dataset"]["robot_meta"].get("robot_type", "unknown") + robot_type = self.env.metadata["dataset"]["robot_meta"].get( + "robot_type", "unknown" + ) # Create LeRobot dataset dataset = LeRobotDataset.create( @@ -132,8 +121,8 @@ def _build_lerobot_features(self, use_videos: bool = True) -> Dict: # Get image shape from sensor img_shape = ( - sensor.camera_cfg.height, - sensor.camera_cfg.width, + sensor.cfg.height, + sensor.cfg.width, 3, ) @@ -209,25 +198,32 @@ def _convert_frame_to_lerobot( color_img = color_data.squeeze(0)[:, :, :3].cpu().numpy() else: color_img = np.array(color_data).squeeze(0)[:, :, :3] - + # Ensure uint8 format (0-255 range) if color_img.dtype == np.float32 or color_img.dtype == np.float64: color_img = (color_img * 255).astype(np.uint8) - + frame[camera_name] = color_img - + # Process right camera image if stereo if is_stereo: color_right_data = obs["sensor"][camera_name]["color_right"] if isinstance(color_right_data, torch.Tensor): - color_right_img = color_right_data.squeeze(0)[:, :, :3].cpu().numpy() + color_right_img = ( + color_right_data.squeeze(0)[:, :, :3].cpu().numpy() + ) else: - color_right_img = np.array(color_right_data).squeeze(0)[:, :, :3] - + color_right_img = np.array(color_right_data).squeeze(0)[ + :, :, :3 + ] + # Ensure uint8 format - if color_right_img.dtype == np.float32 or color_right_img.dtype == np.float64: + if ( + color_right_img.dtype == np.float32 + or color_right_img.dtype == np.float64 + ): color_right_img = (color_right_img * 255).astype(np.uint8) - + frame[get_right_name(camera_name)] = color_right_img # Add state (proprio) @@ -254,7 +250,7 @@ def _convert_frame_to_lerobot( # Add actions robot = self.env.robot arm_dofs = robot_meta_config.get("arm_dofs", 7) - + # Handle different action types if isinstance(action, torch.Tensor): action_data = action[0, :arm_dofs].cpu().numpy() @@ -271,11 +267,12 @@ def _convert_frame_to_lerobot( else: # Fallback: try to convert to numpy action_data = np.array(action)[0, :arm_dofs] - + frame["action"] = action_data return frame + def save_to_lerobot_format( env: EmbodiedEnv, obs_list: List[Dict[str, Any]], @@ -315,11 +312,6 @@ def save_to_lerobot_format( ... push_to_hub=False, ... ) """ - if not HAS_LEROBOT: - logger.log_error( - "LeRobot not installed. Please install it with: pip install lerobot" - ) - return None if len(obs_list) == 0 or len(action_list) == 0: logger.log_error("obs_list and action_list cannot be empty") @@ -333,20 +325,20 @@ def save_to_lerobot_format( try: extractor = LerobotDataHandler(env) - + # Build features features = extractor._build_lerobot_features(use_videos=use_videos) - + # Get robot type robot_type = env.metadata["dataset"]["robot_meta"].get("robot_type", "unknown") - + # Get or create dataset if HF_LEROBOT_HOME is not None: dataset_path = Path(HF_LEROBOT_HOME) / repo_id else: # Fallback to default path dataset_path = Path.home() / ".cache" / "huggingface" / "lerobot" / repo_id - + # Check if dataset already exists if dataset_path.exists(): logger.log_info(f"Loading existing LeRobot dataset from {dataset_path}") diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index d62d9bc5..3e24ee68 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -483,7 +483,9 @@ def to_dataset( return None if len(self.episode_obs_list) == 0: - logger.log_error("No episode data to save. Episode observation list is empty.") + logger.log_error( + "No episode data to save. Episode observation list is empty." + ) return None try: @@ -493,7 +495,9 @@ def to_dataset( ) except ImportError as e: logger.log_error(f"Failed to import lerobot_data_handler: {e}") - logger.log_error("Make sure all dependencies are installed: pip install lerobot") + logger.log_error( + "Make sure all dependencies are installed: pip install lerobot" + ) return None except Exception as e: logger.log_error(f"Unexpected error importing lerobot_data_handler: {e}") @@ -501,10 +505,16 @@ def to_dataset( # Prepare obs_list and action_list # Remove the last observation as it doesn't have a corresponding action - obs_list = self.episode_obs_list[:-1] if len(self.episode_obs_list) > len(self.episode_action_list) else self.episode_obs_list + obs_list = ( + self.episode_obs_list[:-1] + if len(self.episode_obs_list) > len(self.episode_action_list) + else self.episode_obs_list + ) action_list = self.episode_action_list - logger.log_info(f"Saving episode with {len(obs_list)} frames to LeRobot format...") + logger.log_info( + f"Saving episode with {len(obs_list)} frames to LeRobot format..." + ) # Save to LeRobot format dataset_path = save_to_lerobot_format( diff --git a/embodichain/lab/gym/utils/misc.py b/embodichain/lab/gym/utils/misc.py index beb9bac0..b75b70af 100644 --- a/embodichain/lab/gym/utils/misc.py +++ b/embodichain/lab/gym/utils/misc.py @@ -1372,12 +1372,13 @@ def is_eef_hand(robot, control_parts) -> bool: def is_stereocam(sensor) -> bool: """ Check if a sensor is a StereoCamera (binocular camera). - + Args: sensor: The sensor instance to check. - + Returns: bool: True if the sensor is a StereoCamera, False otherwise. """ from embodichain.lab.sim.sensors import StereoCamera + return isinstance(sensor, StereoCamera) diff --git a/embodichain/lab/scripts/run_env.py b/embodichain/lab/scripts/run_env.py index 626f23fe..1700a673 100644 --- a/embodichain/lab/scripts/run_env.py +++ b/embodichain/lab/scripts/run_env.py @@ -85,7 +85,7 @@ def generate_function( valid = True while True: _, _ = env.reset() - + ret = [] for trajectory_idx in range(num_traj): valid = generate_and_execute_action_list(env, trajectory_idx, debug_mode) diff --git a/embodichain/lab/sim/objects/articulation.py b/embodichain/lab/sim/objects/articulation.py index f1325a6c..d64971db 100644 --- a/embodichain/lab/sim/objects/articulation.py +++ b/embodichain/lab/sim/objects/articulation.py @@ -71,11 +71,15 @@ def __init__( # get gpu indices for the entities. # only meaningful when using GPU physics. - self.gpu_indices = torch.as_tensor( - [np.int32(entity.get_gpu_index()) for entity in self.entities], - dtype=torch.int32, - device=self.device, - ) + + if self.device.type == "cpu": + self.gpu_indices = None + else: + self.gpu_indices = torch.as_tensor( + [np.int32(entity.get_gpu_index()) for entity in self.entities], + dtype=torch.int32, + device=self.device, + ) self.dof = self.entities[0].get_dof() self.num_links = self.entities[0].get_links_num() From 8abd4a486b45d3878a67a46e439fa43b363c0dd2 Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Mon, 22 Dec 2025 16:12:54 +0800 Subject: [PATCH 03/26] save multiple episodes in one file --- .../data/handler/lerobot_data_handler.py | 182 ----------------- embodichain/lab/gym/envs/embodied_env.py | 184 +++++++++++++----- embodichain/lab/scripts/run_env.py | 4 +- 3 files changed, 140 insertions(+), 230 deletions(-) diff --git a/embodichain/data/handler/lerobot_data_handler.py b/embodichain/data/handler/lerobot_data_handler.py index f9096c51..10a62581 100644 --- a/embodichain/data/handler/lerobot_data_handler.py +++ b/embodichain/data/handler/lerobot_data_handler.py @@ -29,76 +29,8 @@ class LerobotDataHandler: def __init__( self, env: EmbodiedEnv, - save_path: str = None, - compression_opts: int = 9, ): self.env = env - self.save_path = save_path - self.data = {} - - # save all supported proprio and action types. - robot_meta_config = deepcopy(self.env.metadata["dataset"]["robot_meta"]) - robot_meta_config["observation"][ - Modality.STATES.value - ] = SUPPORTED_PROPRIO_TYPES - robot_meta_config[Modality.ACTIONS.value] = SUPPORTED_ACTION_TYPES - - self.compression_opts = compression_opts - - def extract_to_lerobot( - self, - obs_list: List[Dict[str, Any]], - action_list: List[Dict[str, Any]], - repo_id: str, - fps: int = 30, - use_videos: bool = True, - image_writer_threads: int = 4, - image_writer_processes: int = 0, - ) -> "LeRobotDataset": - """ - Extract data and save in LeRobot format. - - Args: - obs_list (List[Dict]): List of observation dicts. - action_list (List[Dict]): List of action dicts. - repo_id (str): Repository ID for the LeRobot dataset. - fps (int): Frames per second. Defaults to 30. - use_videos (bool): Whether to use video encoding. Defaults to True. - image_writer_threads (int): Number of threads for image writing. Defaults to 4. - image_writer_processes (int): Number of processes for image writing. Defaults to 0. - - Returns: - LeRobotDataset: The created LeRobot dataset instance. - """ - # Build features dict from environment metadata - features = self._build_lerobot_features(use_videos=use_videos) - - # Get robot type - robot_type = self.env.metadata["dataset"]["robot_meta"].get( - "robot_type", "unknown" - ) - - # Create LeRobot dataset - dataset = LeRobotDataset.create( - repo_id=repo_id, - robot_type=robot_type, - fps=fps, - features=features, - use_videos=use_videos, - image_writer_threads=image_writer_threads, - image_writer_processes=image_writer_processes, - ) - - # Get task/instruction - task = self.env.metadata["dataset"]["instruction"].get("lang", "unknown_task") - - # Convert and add frames - logger.log_info(f"Converting {len(obs_list)} frames to LeRobot format...") - for i, (obs, action) in enumerate(zip(obs_list, action_list)): - frame = self._convert_frame_to_lerobot(obs, action, task) - dataset.add_frame(frame) - - return dataset def _build_lerobot_features(self, use_videos: bool = True) -> Dict: """ @@ -271,117 +203,3 @@ def _convert_frame_to_lerobot( frame["action"] = action_data return frame - - -def save_to_lerobot_format( - env: EmbodiedEnv, - obs_list: List[Dict[str, Any]], - action_list: List[Dict[str, Any]], - repo_id: str, - fps: int = 30, - use_videos: bool = True, - push_to_hub: bool = False, - image_writer_threads: int = 4, - image_writer_processes: int = 0, -) -> Optional[str]: - """ - Save episode data to LeRobot format. - - Args: - env (EmbodiedEnv): Environment instance. - obs_list (List[Dict]): List of observation dicts (without last obs). - action_list (List[Dict]): List of action dicts. - repo_id (str): Repository ID for LeRobot dataset (e.g., "username/dataset_name"). - fps (int): Frames per second. Defaults to 30. - use_videos (bool): Whether to encode images as videos. Defaults to True. - push_to_hub (bool): Whether to push to Hugging Face Hub. Defaults to False. - image_writer_threads (int): Number of threads for image writing. Defaults to 4. - image_writer_processes (int): Number of processes for image writing. Defaults to 0. - - Returns: - Optional[str]: Path to saved dataset, or None if failed. - - Example: - >>> save_to_lerobot_format( - ... env=env, - ... obs_list=env.episode_obs_list[:-1], - ... action_list=env.episode_action_list, - ... repo_id="my_username/my_robot_dataset", - ... fps=30, - ... use_videos=True, - ... push_to_hub=False, - ... ) - """ - - if len(obs_list) == 0 or len(action_list) == 0: - logger.log_error("obs_list and action_list cannot be empty") - return None - - if len(obs_list) != len(action_list): - logger.log_error( - f"obs_list and action_list must have same length, got {len(obs_list)} and {len(action_list)}" - ) - return None - - try: - extractor = LerobotDataHandler(env) - - # Build features - features = extractor._build_lerobot_features(use_videos=use_videos) - - # Get robot type - robot_type = env.metadata["dataset"]["robot_meta"].get("robot_type", "unknown") - - # Get or create dataset - if HF_LEROBOT_HOME is not None: - dataset_path = Path(HF_LEROBOT_HOME) / repo_id - else: - # Fallback to default path - dataset_path = Path.home() / ".cache" / "huggingface" / "lerobot" / repo_id - - # Check if dataset already exists - if dataset_path.exists(): - logger.log_info(f"Loading existing LeRobot dataset from {dataset_path}") - dataset = LeRobotDataset(repo_id=repo_id) - else: - logger.log_info(f"Creating new LeRobot dataset at {dataset_path}") - dataset = LeRobotDataset.create( - repo_id=repo_id, - robot_type=robot_type, - fps=fps, - features=features, - use_videos=use_videos, - image_writer_threads=image_writer_threads, - image_writer_processes=image_writer_processes, - ) - - # Get task - task = env.metadata["dataset"]["instruction"].get("lang", "unknown_task") - - # Add frames - logger.log_info(f"Adding {len(obs_list)} frames to LeRobot dataset...") - for obs, action in tqdm(zip(obs_list, action_list), total=len(obs_list)): - frame = extractor._convert_frame_to_lerobot(obs, action, task) - dataset.add_frame(frame) - - # Save episode - logger.log_info("Saving episode to LeRobot dataset...") - dataset.save_episode() - - # Optionally push to hub - if push_to_hub: - logger.log_info(f"Pushing dataset to Hugging Face Hub: {repo_id}") - dataset.push_to_hub( - tags=[robot_type, "imitation"], - private=False, - push_videos=use_videos, - license="apache-2.0", - ) - - logger.log_info(f"Successfully saved episode to {dataset_path}") - return str(dataset_path) - - except Exception as e: - logger.log_error(f"Failed to save to LeRobot format: {e}") - traceback.print_exc() - return None diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index 3e24ee68..0cd5915b 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -131,6 +131,8 @@ class EmbodiedEnv(BaseEnv): def __init__(self, cfg: EmbodiedEnvCfg, **kwargs): self.affordance_datas = {} self.action_bank = None + self.dataset = None # LeRobotDataset instance for data management + self.data_handler = None # LerobotDataHandler instance for data conversion extensions = getattr(cfg, "extensions", {}) or {} @@ -171,9 +173,11 @@ def _init_sim_state(self, **kwargs): self.metadata["dataset"] = self.cfg.dataset self.episode_obs_list = [] self.episode_action_list = [] - self.curr_episode = 0 + # Initialize LeRobotDataset if dataset config is provided + self._initialize_lerobot_dataset() + def _apply_functor_filter(self) -> None: """Apply functor filters to the environment components based on configuration. @@ -198,6 +202,68 @@ def _apply_functor_filter(self) -> None: ) setattr(self.cfg.events, attr_name, None) + def _initialize_lerobot_dataset(self) -> None: + """Initialize LeRobotDataset for episode recording. + + This method creates a LeRobotDataset instance that will be used throughout + the environment's lifetime for recording and managing episode data. + """ + try: + from lerobot.datasets.lerobot_dataset import LeRobotDataset + from embodichain.data.handler.lerobot_data_handler import LerobotDataHandler + except ImportError as e: + logger.log_warning( + f"Failed to import LeRobot dependencies: {e}. " + "Dataset recording will be disabled. Install with: pip install lerobot" + ) + return + + # Get dataset configuration + dataset_cfg = self.cfg.dataset + repo_id = dataset_cfg.get("repo_id", "embodichain/default_dataset") + fps = dataset_cfg.get("fps", 30) + use_videos = dataset_cfg.get("use_videos", True) + image_writer_threads = dataset_cfg.get("image_writer_threads", 4) + image_writer_processes = dataset_cfg.get("image_writer_processes", 0) + + # Create handler instance (reusable for all episodes) + self.data_handler = LerobotDataHandler(self) + + # Build features using handler + features = self.data_handler._build_lerobot_features(use_videos=use_videos) + + # Get robot type + robot_type = self.metadata["dataset"]["robot_meta"].get("robot_type", "unknown") + + # Check if dataset already exists + from lerobot.datasets.lerobot_dataset import HF_LEROBOT_HOME + from pathlib import Path + + if HF_LEROBOT_HOME is not None: + dataset_path = Path(HF_LEROBOT_HOME) / repo_id + else: + dataset_path = Path.home() / ".cache" / "huggingface" / "lerobot" / repo_id + + try: + if dataset_path.exists(): + logger.log_info(f"Loading existing LeRobot dataset from {dataset_path}") + self.dataset = LeRobotDataset(repo_id=repo_id) + else: + logger.log_info(f"Creating new LeRobot dataset at {dataset_path}") + self.dataset = LeRobotDataset.create( + repo_id=repo_id, + robot_type=robot_type, + fps=fps, + features=features, + use_videos=use_videos, + image_writer_threads=image_writer_threads, + image_writer_processes=image_writer_processes, + ) + logger.log_info(f"LeRobotDataset initialized successfully: {repo_id}") + except Exception as e: + logger.log_error(f"Failed to initialize LeRobotDataset: {e}") + self.dataset = None + def _init_action_bank( self, action_bank_cls: ActionBank, action_config: Dict[str, Any] ): @@ -452,24 +518,11 @@ def create_demo_action_list(self, *args, **kwargs) -> Sequence[EnvAction] | None "The method 'create_demo_action_list' must be implemented in subclasses." ) - def to_dataset( - self, - repo_id: str, - fps: int = 30, - use_videos: bool = True, - push_to_hub: bool = False, - image_writer_threads: int = 4, - image_writer_processes: int = 0, - ) -> str | None: + def to_dataset(self, push_to_hub: bool = False) -> str | None: """Convert the recorded episode data to LeRobot dataset format. Args: - repo_id (str): Repository ID for LeRobot dataset (e.g., "username/dataset_name"). - fps (int): Frames per second for video encoding. Defaults to 30. - use_videos (bool): Whether to encode images as videos. Defaults to True. push_to_hub (bool): Whether to push to Hugging Face Hub. Defaults to False. - image_writer_threads (int): Number of threads for image writing. Defaults to 4. - image_writer_processes (int): Number of processes for image writing. Defaults to 0. Returns: str | None: The path to the saved dataset, or None if failed. @@ -488,23 +541,21 @@ def to_dataset( ) return None - try: - # Import the handler - use try-except to catch import errors - from embodichain.data.handler.lerobot_data_handler import ( - save_to_lerobot_format, - ) - except ImportError as e: - logger.log_error(f"Failed to import lerobot_data_handler: {e}") + # Check if dataset was initialized + if self.dataset is None: logger.log_error( - "Make sure all dependencies are installed: pip install lerobot" + "LeRobotDataset not initialized. Make sure dataset configuration is properly set." ) return None - except Exception as e: - logger.log_error(f"Unexpected error importing lerobot_data_handler: {e}") + + # Check if data handler was initialized + if self.data_handler is None: + logger.log_error( + "Data handler not initialized. Make sure dataset configuration is properly set." + ) return None - # Prepare obs_list and action_list - # Remove the last observation as it doesn't have a corresponding action + # Prepare obs_list and action_list (remove last obs as it has no corresponding action) obs_list = ( self.episode_obs_list[:-1] if len(self.episode_obs_list) > len(self.episode_action_list) @@ -512,31 +563,74 @@ def to_dataset( ) action_list = self.episode_action_list - logger.log_info( - f"Saving episode with {len(obs_list)} frames to LeRobot format..." - ) + logger.log_info(f"Saving episode with {len(obs_list)} frames...") - # Save to LeRobot format - dataset_path = save_to_lerobot_format( - env=self, - obs_list=obs_list, - action_list=action_list, - repo_id=repo_id, - fps=fps, - use_videos=use_videos, - push_to_hub=push_to_hub, - image_writer_threads=image_writer_threads, - image_writer_processes=image_writer_processes, - ) + # Get task instruction + task = self.metadata["dataset"]["instruction"].get("lang", "unknown_task") + + # Add frames to dataset + for obs, action in zip(obs_list, action_list): + frame = self.data_handler._convert_frame_to_lerobot(obs, action, task) + self.dataset.add_frame(frame) + + # Save episode + self.dataset.save_episode() - if dataset_path: + # Optionally push to hub + if push_to_hub: logger.log_info( - f"Successfully saved episode {self.curr_episode} to {dataset_path}" + f"Pushing dataset to Hugging Face Hub: {self.dataset.repo_id}" ) - self.curr_episode += 1 + self.dataset.push_to_hub( + tags=[self.dataset.meta.info.get("robot_type", "unknown"), "imitation"], + private=False, + push_videos=True, + license="apache-2.0", + ) + + dataset_path = str(self.dataset.root) + logger.log_info( + f"Successfully saved episode {self.curr_episode} to {dataset_path}" + ) + self.curr_episode += 1 return dataset_path + def update_dataset_info(self, updates: dict) -> bool: + """Update the LeRobot dataset's meta.info with custom key-value pairs. + + Args: + updates (dict): Dictionary of key-value pairs to add or update in meta.info. + + Returns: + bool: True if successful, False otherwise. + + Example: + >>> env.update_dataset_info({ + ... "author": "DexForce", + ... "date_collected": "2025-12-22", + ... "custom_key": "custom_value" + ... }) + """ + if self.dataset is None: + logger.log_error( + "LeRobotDataset not initialized. Cannot update dataset info." + ) + return False + + try: + from lerobot.datasets.utils import write_info + + self.dataset.meta.info.update(updates) + write_info(self.dataset.meta.info, self.dataset.meta.root) + logger.log_info( + f"Successfully updated dataset info with keys: {list(updates.keys())}" + ) + return True + except Exception as e: + logger.log_error(f"Failed to update dataset info: {e}") + return False + def is_task_success(self, **kwargs) -> torch.Tensor: """Determine if the task is successfully completed. This is mainly used in the data generation process of the imitation learning. diff --git a/embodichain/lab/scripts/run_env.py b/embodichain/lab/scripts/run_env.py index 1700a673..d5c3b647 100644 --- a/embodichain/lab/scripts/run_env.py +++ b/embodichain/lab/scripts/run_env.py @@ -95,9 +95,7 @@ def generate_function( if not debug_mode and env.is_task_success().item(): dataset_id = f"time_{time_id}_trajectory_{trajectory_idx}" - data_dict = env.to_dataset( - repo_id=dataset_id, - ) + data_dict = env.to_dataset() ret.append(data_dict) # TODO: Add data saving and online data streaming logic here. From aaf64dd0d8ce46166e830f07c9eec62aa9c08be7 Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Mon, 22 Dec 2025 19:01:44 +0800 Subject: [PATCH 04/26] add meta info --- configs/gym/pour_water/gym_config.json | 4 ++ embodichain/lab/gym/envs/embodied_env.py | 76 ++++++++++++++++-------- 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/configs/gym/pour_water/gym_config.json b/configs/gym/pour_water/gym_config.json index 5873c1fd..24b0e2e2 100644 --- a/configs/gym/pour_water/gym_config.json +++ b/configs/gym/pour_water/gym_config.json @@ -277,6 +277,10 @@ }, "instruction": { "lang": "Pour water from bottle to cup" + }, + "extra": { + "scene_type": "Commercial", + "task_description": "Pour water" } } }, diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index 0cd5915b..819a7cce 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -170,6 +170,31 @@ def _init_sim_state(self, **kwargs): # TODO: A workaround for handling dataset saving, which need history data of obs-action pairs. # We may improve this by implementing a data manager to handle data saving and online streaming. if self.cfg.dataset is not None: + robot_type = self.cfg.dataset.get("robot_meta", {}).get( + "robot_type", "robot" + ) + scene_type = self.cfg.dataset.get("extra", {}).get("scene_type", "scene") + task_description = self.cfg.dataset.get("extra", {}).get( + "task_description", "task" + ) + + task_description = str(task_description).lower().replace(" ", "_") + + lerobot_data_path = os.path.join(os.getcwd(), "outputs") + + # Auto-increment id until the repo_id subdirectory does not exist + base_id = int(self.cfg.dataset.get("id", "1")) + while True: + dataset_id = f"{base_id:03d}" + repo_id = f"{scene_type}_{robot_type}_{task_description}_{dataset_id}" + repo_path = os.path.join(lerobot_data_path, repo_id) + if not os.path.exists(repo_path): + break + base_id += 1 + self.cfg.dataset["repo_id"] = repo_id + self.cfg.dataset["id"] = dataset_id + self.cfg.dataset["lerobot_data_path"] = str(lerobot_data_path) + self.metadata["dataset"] = self.cfg.dataset self.episode_obs_list = [] self.episode_action_list = [] @@ -232,33 +257,24 @@ def _initialize_lerobot_dataset(self) -> None: # Build features using handler features = self.data_handler._build_lerobot_features(use_videos=use_videos) - # Get robot type robot_type = self.metadata["dataset"]["robot_meta"].get("robot_type", "unknown") - # Check if dataset already exists - from lerobot.datasets.lerobot_dataset import HF_LEROBOT_HOME - from pathlib import Path - - if HF_LEROBOT_HOME is not None: - dataset_path = Path(HF_LEROBOT_HOME) / repo_id - else: - dataset_path = Path.home() / ".cache" / "huggingface" / "lerobot" / repo_id + lerobot_data_path = self.cfg.dataset.get("lerobot_data_path") + repo_id = self.cfg.dataset.get("repo_id") + dataset_dir = os.path.join(lerobot_data_path, repo_id) try: - if dataset_path.exists(): - logger.log_info(f"Loading existing LeRobot dataset from {dataset_path}") - self.dataset = LeRobotDataset(repo_id=repo_id) - else: - logger.log_info(f"Creating new LeRobot dataset at {dataset_path}") - self.dataset = LeRobotDataset.create( - repo_id=repo_id, - robot_type=robot_type, - fps=fps, - features=features, - use_videos=use_videos, - image_writer_threads=image_writer_threads, - image_writer_processes=image_writer_processes, - ) + logger.log_info(f"Creating new LeRobot dataset at {dataset_dir}") + self.dataset = LeRobotDataset.create( + repo_id=repo_id, + robot_type=robot_type, + fps=fps, + features=features, + use_videos=use_videos, + image_writer_threads=image_writer_threads, + image_writer_processes=image_writer_processes, + root=str(dataset_dir), + ) logger.log_info(f"LeRobotDataset initialized successfully: {repo_id}") except Exception as e: logger.log_error(f"Failed to initialize LeRobotDataset: {e}") @@ -574,6 +590,17 @@ def to_dataset(self, push_to_hub: bool = False) -> str | None: self.dataset.add_frame(frame) # Save episode + extra_info = self.cfg.dataset.get("extra", {}) + total_frames = self.dataset.meta.info.get("total_frames", 0) + fps = self.dataset.meta.info.get("fps", 30) + total_time = total_frames / fps if fps > 0 else 0 + + extra_info = self.cfg.dataset.get("extra", {}) + extra_info["total_time"] = total_time + extra_info["data_type"] = "sim" + + self.update_dataset_info({"extra": extra_info}) + self.dataset.save_episode() # Optionally push to hub @@ -619,10 +646,7 @@ def update_dataset_info(self, updates: dict) -> bool: return False try: - from lerobot.datasets.utils import write_info - self.dataset.meta.info.update(updates) - write_info(self.dataset.meta.info, self.dataset.meta.root) logger.log_info( f"Successfully updated dataset info with keys: {list(updates.keys())}" ) From c0bf7897ea22ec00c2c3ae6f6a31bc7d733782bd Mon Sep 17 00:00:00 2001 From: Yueci Deng Date: Tue, 23 Dec 2025 03:53:54 +0000 Subject: [PATCH 05/26] Add data generation template (#49) --- configs/gym/pour_water/gym_config_simple.json | 317 ++++++++++++++++++ embodichain/lab/gym/envs/embodied_env.py | 25 +- pyproject.toml | 7 +- scripts/data_gen.sh | 13 + 4 files changed, 353 insertions(+), 9 deletions(-) create mode 100644 configs/gym/pour_water/gym_config_simple.json create mode 100755 scripts/data_gen.sh diff --git a/configs/gym/pour_water/gym_config_simple.json b/configs/gym/pour_water/gym_config_simple.json new file mode 100644 index 00000000..8ef6e49b --- /dev/null +++ b/configs/gym/pour_water/gym_config_simple.json @@ -0,0 +1,317 @@ +{ + "id": "PourWater-v3", + "max_episodes": 5, + "env": { + "events": { + "random_light": { + "func": "randomize_light", + "mode": "interval", + "interval_step": 10, + "params": { + "entity_cfg": {"uid": "light_1"}, + "position_range": [[-0.5, -0.5, 2], [0.5, 0.5, 2]], + "color_range": [[0.6, 0.6, 0.6], [1, 1, 1]], + "intensity_range": [50.0, 100.0] + } + }, + "init_bottle_pose": { + "func": "randomize_rigid_object_pose", + "mode": "reset", + "params": { + "entity_cfg": {"uid": "bottle"}, + "position_range": [[-0.08, -0.12, 0.0], [0.08, 0.04, 0.0]], + "relative_position": true + } + }, + "init_cup_pose": { + "func": "randomize_rigid_object_pose", + "mode": "reset", + "params": { + "entity_cfg": {"uid": "cup"}, + "position_range": [[-0.08, -0.04, 0.0], [0.08, 0.12, 0.0]], + "relative_position": true + } + }, + "prepare_extra_attr": { + "func": "prepare_extra_attr", + "mode": "reset", + "params": { + "attrs": [ + { + "name": "object_lengths", + "mode": "callable", + "entity_uids": "all_objects", + "func_name": "compute_object_length", + "func_kwargs": { + "is_svd_frame": true, + "sample_points": 5000 + } + }, + { + "name": "grasp_pose_object", + "mode": "static", + "entity_cfg": { + "uid": "bottle" + }, + "value": [[ + [0.32243, 0.03245, 0.94604, 0.025], + [0.00706, -0.99947, 0.03188, -0.0 ], + [0.94657, -0.0036 , -0.32249, 0.0 ], + [0.0 , 0.0 , 0.0 , 1.0 ] + ]] + }, + { + "name": "left_arm_base_pose", + "mode": "callable", + "entity_cfg": { + "uid": "CobotMagic" + }, + "func_name": "get_link_pose", + "func_kwargs": { + "link_name": "left_arm_base", + "to_matrix": true + } + }, + { + "name": "right_arm_base_pose", + "mode": "callable", + "entity_cfg": { + "uid": "CobotMagic" + }, + "func_name": "get_link_pose", + "func_kwargs": { + "link_name": "right_arm_base", + "to_matrix": true + } + } + ] + } + }, + "register_info_to_env": { + "func": "register_info_to_env", + "mode": "reset", + "params": { + "registry": [ + { + "entity_cfg": { + "uid": "bottle" + }, + "pose_register_params": { + "compute_relative": false, + "compute_pose_object_to_arena": true, + "to_matrix": true + } + }, + { + "entity_cfg": { + "uid": "cup" + }, + "pose_register_params": { + "compute_relative": false, + "compute_pose_object_to_arena": true, + "to_matrix": true + } + }, + { + "entity_cfg": { + "uid": "CobotMagic", + "control_parts": ["left_arm"] + }, + "attrs": ["left_arm_base_pose"], + "pose_register_params": { + "compute_relative": "cup", + "compute_pose_object_to_arena": false, + "to_matrix": true + }, + "prefix": false + }, + { + "entity_cfg": { + "uid": "CobotMagic", + "control_parts": ["right_arm"] + }, + "attrs": ["right_arm_base_pose"], + "pose_register_params": { + "compute_relative": "bottle", + "compute_pose_object_to_arena": false, + "to_matrix": true + }, + "prefix": false + } + ], + "registration": "affordance_datas", + "sim_update": true + } + }, + "random_material": { + "func": "randomize_visual_material", + "mode": "interval", + "interval_step": 10, + "params": { + "entity_cfg": {"uid": "table"}, + "random_texture_prob": 0.5, + "texture_path": "CocoBackground/coco", + "base_color_range": [[0.2, 0.2, 0.2], [1.0, 1.0, 1.0]] + } + }, + "random_cup_material": { + "func": "randomize_visual_material", + "mode": "interval", + "interval_step": 10, + "params": { + "entity_cfg": {"uid": "cup"}, + "random_texture_prob": 0.5, + "texture_path": "CocoBackground/coco", + "base_color_range": [[0.2, 0.2, 0.2], [1.0, 1.0, 1.0]] + } + }, + "random_bottle_material": { + "func": "randomize_visual_material", + "mode": "interval", + "interval_step": 10, + "params": { + "entity_cfg": {"uid": "bottle"}, + "random_texture_prob": 0.5, + "texture_path": "CocoBackground/coco", + "base_color_range": [[0.2, 0.2, 0.2], [1.0, 1.0, 1.0]] + } + }, + "random_robot_init_eef_pose": { + "func": "randomize_robot_eef_pose", + "mode": "reset", + "params": { + "entity_cfg": {"uid": "CobotMagic", "control_parts": ["left_arm", "right_arm"]}, + "position_range": [[-0.01, -0.01, -0.01], [0.01, 0.01, 0]] + } + } + }, + "observations": { + "norm_robot_eef_joint": { + "func": "normalize_robot_joint_data", + "mode": "modify", + "name": "robot/qpos", + "params": { + "joint_ids": [12, 13, 14, 15] + } + } + }, + "dataset": { + "save_path": "/root/workspace/datahub", + "extra": { + "scene_type": "commercial", + "task_description": "Pour water" + }, + "robot_meta": { + "robot_type": "CobotMagic", + "arm_dofs": 12, + "control_freq": 5, + "control_parts": ["left_arm", "left_eef", "right_arm", "right_eef"], + "observation": { + "vision": { + "cam_high": [] + }, + "states": ["qpos"] + }, + "action": "qpos_with_eef_pose", + "min_len_steps": 5 + }, + "instruction": { + "lang": "Pour water from bottle to cup" + } + } + }, + "robot": { + "uid": "CobotMagic", + "robot_type": "CobotMagic", + "init_pos": [0.0, 0.0, 0.7775], + "init_qpos": [-0.3,0.3,1.0,1.0,-1.2,-1.2,0.0,0.0,0.6,0.6,0.0,0.0,0.05,0.05,0.05,0.05] + }, + "sensor": [ + { + "sensor_type": "Camera", + "uid": "cam_high", + "width": 960, + "height": 540, + "intrinsics": [488.1665344238281, 488.1665344238281, 480, 270], + "extrinsics": { + "eye": [0.35368482807598, 0.014695524383058989, 1.4517046071614774], + "target": [0.8586357573287919, 0, 0.5232553674540066], + "up": [0.9306678549330372, -0.0005600064212467153, 0.3658647703553347] + } + } + ], + "light": { + "direct": [ + { + "uid": "light_1", + "light_type": "point", + "color": [1.0, 1.0, 1.0], + "intensity": 50.0, + "init_pos": [2, 0, 2], + "radius": 10.0 + } + ] + }, + "background": [ + { + "uid": "table", + "shape": { + "shape_type": "Mesh", + "fpath": "CircleTableSimple/circle_table_simple.ply", + "compute_uv": true + }, + "attrs" : { + "mass": 10.0, + "static_friction": 0.95, + "dynamic_friction": 0.9, + "restitution": 0.01 + }, + "body_scale": [1, 1, 1], + "body_type": "kinematic", + "init_pos": [0.725, 0.0, 0.825], + "init_rot": [0, 90, 0] + } + ], + "rigid_object": [ + { + "uid":"cup", + "shape": { + "shape_type": "Mesh", + "fpath": "PaperCup/paper_cup.ply", + "compute_uv": true + }, + "attrs" : { + "mass": 0.01, + "contact_offset": 0.003, + "rest_offset": 0.001, + "restitution": 0.01, + "max_depenetration_velocity": 1e1, + "min_position_iters": 32, + "min_velocity_iters":8 + }, + "init_pos": [0.75, 0.1, 0.9], + "body_scale":[0.75, 0.75, 1.0], + "max_convex_hull_num": 8 + }, + { + "uid":"bottle", + "shape": { + "shape_type": "Mesh", + "fpath": "ScannedBottle/kashijia_processed.ply", + "compute_uv": true + }, + "attrs" : { + "mass": 0.01, + "contact_offset": 0.003, + "rest_offset": 0.001, + "restitution": 0.01, + "max_depenetration_velocity": 1e1, + "min_position_iters": 32, + "min_velocity_iters":8 + }, + "init_pos": [0.75, -0.1, 0.932], + "body_scale":[1, 1, 1], + "max_convex_hull_num": 8 + } + ] +} \ No newline at end of file diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index 819a7cce..0d00057d 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -178,22 +178,27 @@ def _init_sim_state(self, **kwargs): "task_description", "task" ) + robot_type = str(robot_type).lower().replace(" ", "_") task_description = str(task_description).lower().replace(" ", "_") - lerobot_data_path = os.path.join(os.getcwd(), "outputs") + lerobot_data_root = self.cfg.dataset.get("save_path", None) + if lerobot_data_root is None: + from lerobot.utils.constants import HF_LEROBOT_HOME + + lerobot_data_root = HF_LEROBOT_HOME # Auto-increment id until the repo_id subdirectory does not exist base_id = int(self.cfg.dataset.get("id", "1")) while True: dataset_id = f"{base_id:03d}" repo_id = f"{scene_type}_{robot_type}_{task_description}_{dataset_id}" - repo_path = os.path.join(lerobot_data_path, repo_id) + repo_path = os.path.join(lerobot_data_root, repo_id) if not os.path.exists(repo_path): break base_id += 1 self.cfg.dataset["repo_id"] = repo_id self.cfg.dataset["id"] = dataset_id - self.cfg.dataset["lerobot_data_path"] = str(lerobot_data_path) + self.cfg.dataset["lerobot_data_root"] = str(lerobot_data_root) self.metadata["dataset"] = self.cfg.dataset self.episode_obs_list = [] @@ -245,8 +250,7 @@ def _initialize_lerobot_dataset(self) -> None: # Get dataset configuration dataset_cfg = self.cfg.dataset - repo_id = dataset_cfg.get("repo_id", "embodichain/default_dataset") - fps = dataset_cfg.get("fps", 30) + fps = dataset_cfg["robot_meta"].get("control_freq", 30) use_videos = dataset_cfg.get("use_videos", True) image_writer_threads = dataset_cfg.get("image_writer_threads", 4) image_writer_processes = dataset_cfg.get("image_writer_processes", 0) @@ -259,9 +263,14 @@ def _initialize_lerobot_dataset(self) -> None: robot_type = self.metadata["dataset"]["robot_meta"].get("robot_type", "unknown") - lerobot_data_path = self.cfg.dataset.get("lerobot_data_path") + lerobot_data_root = self.cfg.dataset.get("lerobot_data_root") repo_id = self.cfg.dataset.get("repo_id") - dataset_dir = os.path.join(lerobot_data_path, repo_id) + dataset_dir = os.path.join(lerobot_data_root, repo_id) + + # User can override repo_id from dataset config + default_repo_id = dataset_cfg.get("repo_id", None) + if default_repo_id: + repo_id = default_repo_id try: logger.log_info(f"Creating new LeRobot dataset at {dataset_dir}") @@ -591,7 +600,7 @@ def to_dataset(self, push_to_hub: bool = False) -> str | None: # Save episode extra_info = self.cfg.dataset.get("extra", {}) - total_frames = self.dataset.meta.info.get("total_frames", 0) + total_frames = self.dataset.meta.info.get("total_frames", 0) + len(obs_list) fps = self.dataset.meta.info.get("fps", 30) total_time = total_frames / fps if fps > 0 else 0 diff --git a/pyproject.toml b/pyproject.toml index 7ce70560..07fbd834 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = [ "pytorch_kinematics==0.7.6", "polars==1.31.0", "PyYAML>=6.0", - "accelerate==1.2.1", + "accelerate>=1.10.0", "wandb==0.20.1", "tensorboard", "transformers>=4.53.0", @@ -51,6 +51,11 @@ dependencies = [ "h5py", ] +[project.optional-dependencies] +lerobot = [ + "lerobot==0.4.2" +] + [tool.setuptools.dynamic] version = { file = ["VERSION"] } diff --git a/scripts/data_gen.sh b/scripts/data_gen.sh new file mode 100755 index 00000000..48f8ebb7 --- /dev/null +++ b/scripts/data_gen.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +NUM_PROCESSES=3 # Set this to the number of parallel processes you want + +for ((i=0; i Date: Tue, 23 Dec 2025 11:54:33 +0800 Subject: [PATCH 06/26] replay dataset in V3 env --- embodichain/lab/scripts/replay_dataset.py | 292 ++++++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 embodichain/lab/scripts/replay_dataset.py diff --git a/embodichain/lab/scripts/replay_dataset.py b/embodichain/lab/scripts/replay_dataset.py new file mode 100644 index 00000000..9a4b6681 --- /dev/null +++ b/embodichain/lab/scripts/replay_dataset.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2025 DexForce Technology Co., Ltd. +# +# 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. +# ---------------------------------------------------------------------------- + +""" +Script to replay LeRobot dataset trajectories in EmbodiedEnv. + +This script loads a LeRobot dataset and replays the recorded trajectories +in the EmbodiedEnv environment. It focuses on trajectory replay and uses +sensor configurations from the environment config file. + +Usage: + python replay_dataset.py --dataset_path /path/to/dataset --config /path/to/gym_config.json + python replay_dataset.py --dataset_path outputs/commercial_cobotmagic_pour_water_001 --config configs/gym/pour_water/gym_config.json --episode 0 +""" + +import os +import argparse +import gymnasium +import torch +import numpy as np +from pathlib import Path + +from embodichain.utils.logger import log_warning, log_info, log_error +from embodichain.utils.utility import load_json +from embodichain.lab.gym.envs import EmbodiedEnvCfg +from embodichain.lab.gym.utils.gym_utils import ( + config_to_cfg, +) + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description="Replay LeRobot dataset in EmbodiedEnv") + parser.add_argument( + "--dataset_path", + type=str, + required=True, + help="Path to the LeRobot dataset directory" + ) + parser.add_argument( + "--config", + type=str, + required=True, + help="Path to the gym config JSON file (for environment setup)" + ) + parser.add_argument( + "--episode", + type=int, + default=None, + help="Specific episode index to replay (default: replay all episodes)" + ) + parser.add_argument( + "--headless", + action="store_true", + help="Run in headless mode without rendering" + ) + parser.add_argument( + "--fps", + type=int, + default=None, + help="Frames per second for replay (default: use dataset fps)" + ) + parser.add_argument( + "--save_video", + action="store_true", + help="Save replay as video" + ) + parser.add_argument( + "--video_path", + type=str, + default="./replay_videos", + help="Path to save replay videos" + ) + return parser.parse_args() + + +def load_lerobot_dataset(dataset_path): + """Load LeRobot dataset from the given path. + + Args: + dataset_path: Path to the LeRobot dataset directory + + Returns: + LeRobotDataset instance + """ + try: + from lerobot.datasets.lerobot_dataset import LeRobotDataset + except ImportError as e: + log_error( + f"Failed to import LeRobot: {e}. " + "Please install lerobot: pip install lerobot" + ) + return None + + dataset_path = Path(dataset_path) + if not dataset_path.exists(): + log_error(f"Dataset path does not exist: {dataset_path}") + return None + + # Get repo_id from the dataset path (last directory name) + repo_id = dataset_path.name + # root = str(dataset_path.parent) + + log_info(f"Loading LeRobot dataset: {repo_id} from {dataset_path}") + + try: + dataset = LeRobotDataset(repo_id=repo_id, root=dataset_path) + log_info(f"Dataset loaded successfully:") + log_info(f" - Total episodes: {dataset.meta.info.get('total_episodes', 'N/A')}") + log_info(f" - Total frames: {dataset.meta.info.get('total_frames', 'N/A')}") + log_info(f" - FPS: {dataset.meta.info.get('fps', 'N/A')}") + log_info(f" - Robot type: {dataset.meta.info.get('robot_type', 'N/A')}") + return dataset + except Exception as e: + log_error(f"Failed to load dataset: {e}") + return None + + +def create_replay_env(config_path, headless=False): + """Create EmbodiedEnv for replay based on config. + + Args: + config_path: Path to the gym config JSON file + headless: Whether to run in headless mode + + Returns: + Gymnasium environment instance + """ + # Load configuration + gym_config = load_json(config_path) + + # Disable dataset recording during replay + if "dataset" in gym_config.get("env", {}): + gym_config["env"]["dataset"] = None + + # Convert config to dataclass + cfg: EmbodiedEnvCfg = config_to_cfg(gym_config) + + # Set render mode + if not headless: + cfg.render_mode = "human" + else: + cfg.render_mode = None + + # Create environment + log_info(f"Creating environment: {gym_config['id']}") + env = gymnasium.make(id=gym_config["id"], cfg=cfg) + + return env + + +def replay_episode(env, dataset, episode_idx, fps=None, save_video=False, video_path=None): + """Replay a single episode from the dataset. + + Args: + env: EmbodiedEnv instance + dataset: LeRobotDataset instance + episode_idx: Episode index to replay + fps: Frames per second for replay + save_video: Whether to save replay as video + video_path: Path to save video + + Returns: + True if replay was successful, False otherwise + """ + # Get episode data + try: + ep_meta = dataset.meta.episodes[episode_idx] + start_idx = ep_meta["dataset_from_index"] + end_idx = ep_meta["dataset_to_index"] + episode_data = [dataset[i] for i in range(start_idx, end_idx)] + log_info(f"Replaying episode {episode_idx} with {len(episode_data)} frames") + except Exception as e: + log_error(f"Failed to load episode {episode_idx}: {e}") + return False + + # Reset environment + obs, info = env.reset() + + # Setup video recording if needed + if save_video and video_path: + os.makedirs(video_path, exist_ok=True) + video_file = os.path.join(video_path, f"episode_{episode_idx:04d}.mp4") + # TODO: Implement video recording + log_warning("Video recording is not yet implemented") + + # Replay trajectory + for frame_idx in range(len(episode_data)): + # Get action from dataset + frame = episode_data[frame_idx] + + # Extract action based on dataset action space + # The action format depends on the dataset's robot configuration + if "action" in frame: + action = frame["action"] + if isinstance(action, torch.Tensor): + action = action.cpu().numpy() + else: + log_warning(f"No action found in frame {frame_idx}, skipping") + continue + + # Step environment with recorded action + obs, reward, done, truncated, info = env.step(action) + + # Optional: Add delay to match FPS + if fps: + import time + time.sleep(1.0 / fps) + + # Check if episode ended + if done or truncated: + log_info(f"Episode ended at frame {frame_idx}/{len(episode_data)}") + break + + log_info(f"Successfully replayed episode {episode_idx}") + return True + + +def main(): + """Main function to replay LeRobot dataset.""" + args = parse_args() + + # Load dataset + dataset = load_lerobot_dataset(args.dataset_path) + if dataset is None: + return + + # Create replay environment + env = create_replay_env(args.config, headless=args.headless) + + # Determine FPS + fps = args.fps if args.fps else dataset.meta.info.get("fps", 30) + log_info(f"Replay FPS: {fps}") + + # Replay episodes + if args.episode is not None: + # Replay single episode + log_info(f"Replaying single episode: {args.episode}") + success = replay_episode( + env, + dataset, + args.episode, + fps=fps, + save_video=args.save_video, + video_path=args.video_path + ) + if not success: + log_error(f"Failed to replay episode {args.episode}") + else: + # Replay all episodes + total_episodes = dataset.meta.info.get("total_episodes", 0) + log_info(f"Replaying all {total_episodes} episodes") + + for episode_idx in range(total_episodes): + log_info(f"\n{'='*60}") + log_info(f"Episode {episode_idx + 1}/{total_episodes}") + log_info(f"{'='*60}") + + success = replay_episode( + env, + dataset, + episode_idx, + fps=fps, + save_video=args.save_video, + video_path=args.video_path + ) + + if not success: + log_warning(f"Skipping episode {episode_idx} due to errors") + continue + + # Cleanup + env.close() + log_info("Replay completed successfully!") + + +if __name__ == "__main__": + main() From e21505c94e257843102397ad8f2dbafaaef1ed54 Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Tue, 23 Dec 2025 18:21:44 +0800 Subject: [PATCH 07/26] dataset.finalize() --- embodichain/lab/gym/envs/embodied_env.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index 0d00057d..1e36ea25 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -676,3 +676,8 @@ def is_task_success(self, **kwargs) -> torch.Tensor: """ return torch.ones(self.num_envs, dtype=torch.bool, device=self.device) + + def close(self) -> None: + """Close the environment and release resources.""" + self.sim.destroy() + self.dataset.finalize() \ No newline at end of file From d944871b324a3becc85173b1a26be561ed7acf86 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Wed, 24 Dec 2025 08:29:26 +0000 Subject: [PATCH 08/26] wip --- configs/gym/pour_water/gym_config_simple.json | 6 +++--- scripts/data_gen.sh | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/configs/gym/pour_water/gym_config_simple.json b/configs/gym/pour_water/gym_config_simple.json index 8ef6e49b..d7527ee6 100644 --- a/configs/gym/pour_water/gym_config_simple.json +++ b/configs/gym/pour_water/gym_config_simple.json @@ -149,7 +149,7 @@ "interval_step": 10, "params": { "entity_cfg": {"uid": "table"}, - "random_texture_prob": 0.5, + "random_texture_prob": 0.0, "texture_path": "CocoBackground/coco", "base_color_range": [[0.2, 0.2, 0.2], [1.0, 1.0, 1.0]] } @@ -160,7 +160,7 @@ "interval_step": 10, "params": { "entity_cfg": {"uid": "cup"}, - "random_texture_prob": 0.5, + "random_texture_prob": 0.0, "texture_path": "CocoBackground/coco", "base_color_range": [[0.2, 0.2, 0.2], [1.0, 1.0, 1.0]] } @@ -171,7 +171,7 @@ "interval_step": 10, "params": { "entity_cfg": {"uid": "bottle"}, - "random_texture_prob": 0.5, + "random_texture_prob": 0.0, "texture_path": "CocoBackground/coco", "base_color_range": [[0.2, 0.2, 0.2], [1.0, 1.0, 1.0]] } diff --git a/scripts/data_gen.sh b/scripts/data_gen.sh index 48f8ebb7..598afb80 100755 --- a/scripts/data_gen.sh +++ b/scripts/data_gen.sh @@ -6,7 +6,7 @@ for ((i=0; i Date: Thu, 25 Dec 2025 15:03:32 +0800 Subject: [PATCH 09/26] wip --- embodichain/lab/gym/envs/embodied_env.py | 2 +- .../lab/gym/envs/managers/object/geometry.py | 4 +- embodichain/lab/scripts/replay_dataset.py | 91 ++++++++++--------- 3 files changed, 50 insertions(+), 47 deletions(-) diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index 1e36ea25..9d64ec19 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -680,4 +680,4 @@ def is_task_success(self, **kwargs) -> torch.Tensor: def close(self) -> None: """Close the environment and release resources.""" self.sim.destroy() - self.dataset.finalize() \ No newline at end of file + self.dataset.finalize() diff --git a/embodichain/lab/gym/envs/managers/object/geometry.py b/embodichain/lab/gym/envs/managers/object/geometry.py index db26b18f..5ede860b 100644 --- a/embodichain/lab/gym/envs/managers/object/geometry.py +++ b/embodichain/lab/gym/envs/managers/object/geometry.py @@ -47,7 +47,7 @@ def get_pcd_svd_frame(pc: torch.Tensor) -> torch.Tensor: pc_centered = pc - pc_center u, s, vt = torch.linalg.svd(pc_centered) rotation = vt.T - pc_pose = torch.eye(4, dtype=torch.float32) + pc_pose = torch.eye(4, dtype=torch.float32, device=pc.device) pc_pose[:3, :3] = rotation pc_pose[:3, 3] = pc_center return pc_pose @@ -90,7 +90,7 @@ def apply_svd_transfer_pcd( standard_verts = [] for object_verts in verts: pc_svd_frame = get_pcd_svd_frame(object_verts) - inv_svd_frame = inv_transform(pc_svd_frame) + inv_svd_frame = torch.linalg.inv(pc_svd_frame) standard_object_verts = ( object_verts @ inv_svd_frame[:3, :3].T + inv_svd_frame[:3, 3] ) diff --git a/embodichain/lab/scripts/replay_dataset.py b/embodichain/lab/scripts/replay_dataset.py index 9a4b6681..96de834d 100644 --- a/embodichain/lab/scripts/replay_dataset.py +++ b/embodichain/lab/scripts/replay_dataset.py @@ -44,56 +44,54 @@ def parse_args(): """Parse command line arguments.""" - parser = argparse.ArgumentParser(description="Replay LeRobot dataset in EmbodiedEnv") + parser = argparse.ArgumentParser( + description="Replay LeRobot dataset in EmbodiedEnv" + ) parser.add_argument( "--dataset_path", type=str, required=True, - help="Path to the LeRobot dataset directory" + help="Path to the LeRobot dataset directory", ) parser.add_argument( "--config", type=str, required=True, - help="Path to the gym config JSON file (for environment setup)" + help="Path to the gym config JSON file (for environment setup)", ) parser.add_argument( "--episode", type=int, default=None, - help="Specific episode index to replay (default: replay all episodes)" + help="Specific episode index to replay (default: replay all episodes)", ) parser.add_argument( - "--headless", - action="store_true", - help="Run in headless mode without rendering" + "--headless", action="store_true", help="Run in headless mode without rendering" ) parser.add_argument( "--fps", type=int, default=None, - help="Frames per second for replay (default: use dataset fps)" + help="Frames per second for replay (default: use dataset fps)", ) parser.add_argument( - "--save_video", - action="store_true", - help="Save replay as video" + "--save_video", action="store_true", help="Save replay as video" ) parser.add_argument( "--video_path", type=str, default="./replay_videos", - help="Path to save replay videos" + help="Path to save replay videos", ) return parser.parse_args() def load_lerobot_dataset(dataset_path): """Load LeRobot dataset from the given path. - + Args: dataset_path: Path to the LeRobot dataset directory - + Returns: LeRobotDataset instance """ @@ -105,7 +103,7 @@ def load_lerobot_dataset(dataset_path): "Please install lerobot: pip install lerobot" ) return None - + dataset_path = Path(dataset_path) if not dataset_path.exists(): log_error(f"Dataset path does not exist: {dataset_path}") @@ -120,7 +118,9 @@ def load_lerobot_dataset(dataset_path): try: dataset = LeRobotDataset(repo_id=repo_id, root=dataset_path) log_info(f"Dataset loaded successfully:") - log_info(f" - Total episodes: {dataset.meta.info.get('total_episodes', 'N/A')}") + log_info( + f" - Total episodes: {dataset.meta.info.get('total_episodes', 'N/A')}" + ) log_info(f" - Total frames: {dataset.meta.info.get('total_frames', 'N/A')}") log_info(f" - FPS: {dataset.meta.info.get('fps', 'N/A')}") log_info(f" - Robot type: {dataset.meta.info.get('robot_type', 'N/A')}") @@ -132,40 +132,42 @@ def load_lerobot_dataset(dataset_path): def create_replay_env(config_path, headless=False): """Create EmbodiedEnv for replay based on config. - + Args: config_path: Path to the gym config JSON file headless: Whether to run in headless mode - + Returns: Gymnasium environment instance """ # Load configuration gym_config = load_json(config_path) - + # Disable dataset recording during replay if "dataset" in gym_config.get("env", {}): gym_config["env"]["dataset"] = None - + # Convert config to dataclass cfg: EmbodiedEnvCfg = config_to_cfg(gym_config) - + # Set render mode if not headless: cfg.render_mode = "human" else: cfg.render_mode = None - + # Create environment log_info(f"Creating environment: {gym_config['id']}") env = gymnasium.make(id=gym_config["id"], cfg=cfg) - + return env -def replay_episode(env, dataset, episode_idx, fps=None, save_video=False, video_path=None): +def replay_episode( + env, dataset, episode_idx, fps=None, save_video=False, video_path=None +): """Replay a single episode from the dataset. - + Args: env: EmbodiedEnv instance dataset: LeRobotDataset instance @@ -173,7 +175,7 @@ def replay_episode(env, dataset, episode_idx, fps=None, save_video=False, video_ fps: Frames per second for replay save_video: Whether to save replay as video video_path: Path to save video - + Returns: True if replay was successful, False otherwise """ @@ -187,22 +189,22 @@ def replay_episode(env, dataset, episode_idx, fps=None, save_video=False, video_ except Exception as e: log_error(f"Failed to load episode {episode_idx}: {e}") return False - + # Reset environment obs, info = env.reset() - + # Setup video recording if needed if save_video and video_path: os.makedirs(video_path, exist_ok=True) video_file = os.path.join(video_path, f"episode_{episode_idx:04d}.mp4") # TODO: Implement video recording log_warning("Video recording is not yet implemented") - + # Replay trajectory for frame_idx in range(len(episode_data)): # Get action from dataset frame = episode_data[frame_idx] - + # Extract action based on dataset action space # The action format depends on the dataset's robot configuration if "action" in frame: @@ -212,15 +214,16 @@ def replay_episode(env, dataset, episode_idx, fps=None, save_video=False, video_ else: log_warning(f"No action found in frame {frame_idx}, skipping") continue - + # Step environment with recorded action obs, reward, done, truncated, info = env.step(action) - + # Optional: Add delay to match FPS if fps: import time + time.sleep(1.0 / fps) - + # Check if episode ended if done or truncated: log_info(f"Episode ended at frame {frame_idx}/{len(episode_data)}") @@ -233,30 +236,30 @@ def replay_episode(env, dataset, episode_idx, fps=None, save_video=False, video_ def main(): """Main function to replay LeRobot dataset.""" args = parse_args() - + # Load dataset dataset = load_lerobot_dataset(args.dataset_path) if dataset is None: return - + # Create replay environment env = create_replay_env(args.config, headless=args.headless) - + # Determine FPS fps = args.fps if args.fps else dataset.meta.info.get("fps", 30) log_info(f"Replay FPS: {fps}") - + # Replay episodes if args.episode is not None: # Replay single episode log_info(f"Replaying single episode: {args.episode}") success = replay_episode( - env, - dataset, - args.episode, + env, + dataset, + args.episode, fps=fps, save_video=args.save_video, - video_path=args.video_path + video_path=args.video_path, ) if not success: log_error(f"Failed to replay episode {args.episode}") @@ -276,13 +279,13 @@ def main(): episode_idx, fps=fps, save_video=args.save_video, - video_path=args.video_path + video_path=args.video_path, ) - + if not success: log_warning(f"Skipping episode {episode_idx} due to errors") continue - + # Cleanup env.close() log_info("Replay completed successfully!") From 05c639e1077c972c7e8056eb856703e01aa4f5f8 Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Thu, 25 Dec 2025 15:58:20 +0800 Subject: [PATCH 10/26] reabse --- configs/gym/pour_water/gym_config_simple.json | 1 + embodichain/lab/gym/envs/embodied_env.py | 126 ++++++++++++------ 2 files changed, 89 insertions(+), 38 deletions(-) diff --git a/configs/gym/pour_water/gym_config_simple.json b/configs/gym/pour_water/gym_config_simple.json index d7527ee6..d446664d 100644 --- a/configs/gym/pour_water/gym_config_simple.json +++ b/configs/gym/pour_water/gym_config_simple.json @@ -196,6 +196,7 @@ } }, "dataset": { + "format": "lerobot", "save_path": "/root/workspace/datahub", "extra": { "scene_type": "commercial", diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index 9d64ec19..5d49e3cb 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -134,6 +134,19 @@ def __init__(self, cfg: EmbodiedEnvCfg, **kwargs): self.dataset = None # LeRobotDataset instance for data management self.data_handler = None # LerobotDataHandler instance for data conversion + # Check if lerobot is available and set default format accordingly + try: + import lerobot + + self._default_dataset_format = "lerobot" + except ImportError: + self.cfg.dataset["format"] = "hdf5" + self._default_dataset_format = "hdf5" + logger.log_warning( + "LeRobot not available. Default dataset format set to 'hdf5'. " + "Install lerobot to enable lerobot format: pip install lerobot" + ) + extensions = getattr(cfg, "extensions", {}) or {} defaults = { @@ -181,32 +194,61 @@ def _init_sim_state(self, **kwargs): robot_type = str(robot_type).lower().replace(" ", "_") task_description = str(task_description).lower().replace(" ", "_") - lerobot_data_root = self.cfg.dataset.get("save_path", None) - if lerobot_data_root is None: - from lerobot.utils.constants import HF_LEROBOT_HOME - - lerobot_data_root = HF_LEROBOT_HOME - - # Auto-increment id until the repo_id subdirectory does not exist - base_id = int(self.cfg.dataset.get("id", "1")) - while True: - dataset_id = f"{base_id:03d}" - repo_id = f"{scene_type}_{robot_type}_{task_description}_{dataset_id}" - repo_path = os.path.join(lerobot_data_root, repo_id) - if not os.path.exists(repo_path): - break - base_id += 1 - self.cfg.dataset["repo_id"] = repo_id - self.cfg.dataset["id"] = dataset_id - self.cfg.dataset["lerobot_data_root"] = str(lerobot_data_root) + # Get dataset format from dataset config, use instance default if not specified + dataset_format = self.cfg.dataset.get( + "format", self._default_dataset_format + ) + + # Initialize based on dataset format + if dataset_format == "lerobot": + lerobot_data_root = self.cfg.dataset.get("save_path", None) + if lerobot_data_root is None: + try: + from lerobot.utils.constants import HF_LEROBOT_HOME + + lerobot_data_root = HF_LEROBOT_HOME + except ImportError: + logger.log_error("LeRobot not installed.") + + # Auto-increment id until the repo_id subdirectory does not exist + base_id = int(self.cfg.dataset.get("id", "1")) + while True: + dataset_id = f"{base_id:03d}" + repo_id = ( + f"{scene_type}_{robot_type}_{task_description}_{dataset_id}" + ) + repo_path = os.path.join(lerobot_data_root, repo_id) + if not os.path.exists(repo_path): + break + base_id += 1 + self.cfg.dataset["repo_id"] = repo_id + self.cfg.dataset["id"] = dataset_id + self.cfg.dataset["lerobot_data_root"] = str(lerobot_data_root) self.metadata["dataset"] = self.cfg.dataset self.episode_obs_list = [] self.episode_action_list = [] self.curr_episode = 0 - # Initialize LeRobotDataset if dataset config is provided - self._initialize_lerobot_dataset() + # Initialize folder name for hdf5 format + if dataset_format == "hdf5": + from embodichain.lab.gym.utils.misc import camel_to_snake + + save_path = self.cfg.dataset.get("save_path", None) + if save_path is None: + from embodichain.data import database_demo_dir + + save_path = database_demo_dir + + self.folder_name = f"{camel_to_snake(self.__class__.__name__)}_{camel_to_snake(self.robot.cfg.uid)}" + if os.path.exists(os.path.join(save_path, self.folder_name)): + self.folder_name = ( + f"{self.folder_name}_{np.random.randint(0, 1000)}" + ) + + # Initialize LeRobotDataset if dataset format is lerobot + if dataset_format == "lerobot": + self._initialize_lerobot_dataset() def _apply_functor_filter(self) -> None: """Apply functor filters to the environment components based on configuration. @@ -242,11 +284,10 @@ def _initialize_lerobot_dataset(self) -> None: from lerobot.datasets.lerobot_dataset import LeRobotDataset from embodichain.data.handler.lerobot_data_handler import LerobotDataHandler except ImportError as e: - logger.log_warning( + logger.log_error( f"Failed to import LeRobot dependencies: {e}. " "Dataset recording will be disabled. Install with: pip install lerobot" ) - return # Get dataset configuration dataset_cfg = self.cfg.dataset @@ -543,11 +584,14 @@ def create_demo_action_list(self, *args, **kwargs) -> Sequence[EnvAction] | None "The method 'create_demo_action_list' must be implemented in subclasses." ) - def to_dataset(self, push_to_hub: bool = False) -> str | None: - """Convert the recorded episode data to LeRobot dataset format. + def to_dataset(self, id: str = None) -> str | None: + """Convert the recorded episode data to dataset format. + + This method can be overridden in subclasses to support different formats (e.g., hdf5). + The base class only supports LeRobot format. Args: - push_to_hub (bool): Whether to push to Hugging Face Hub. Defaults to False. + id (str, optional): Unique identifier for the dataset (may be used by subclasses). Returns: str | None: The path to the saved dataset, or None if failed. @@ -566,6 +610,20 @@ def to_dataset(self, push_to_hub: bool = False) -> str | None: ) return None + # Route to appropriate save method based on dataset format + dataset_format = self.cfg.dataset.get("format", self._default_dataset_format) + if dataset_format == "lerobot": + return self._to_lerobot_dataset() + else: + logger.log_error(f"Unsupported dataset format: {dataset_format}") + return None + + def _to_lerobot_dataset(self) -> str | None: + """Convert the recorded episode data to LeRobot dataset format. + + Returns: + str | None: The path to the saved dataset, or None if failed. + """ # Check if dataset was initialized if self.dataset is None: logger.log_error( @@ -612,18 +670,6 @@ def to_dataset(self, push_to_hub: bool = False) -> str | None: self.dataset.save_episode() - # Optionally push to hub - if push_to_hub: - logger.log_info( - f"Pushing dataset to Hugging Face Hub: {self.dataset.repo_id}" - ) - self.dataset.push_to_hub( - tags=[self.dataset.meta.info.get("robot_type", "unknown"), "imitation"], - private=False, - push_videos=True, - license="apache-2.0", - ) - dataset_path = str(self.dataset.root) logger.log_info( f"Successfully saved episode {self.curr_episode} to {dataset_path}" @@ -680,4 +726,8 @@ def is_task_success(self, **kwargs) -> torch.Tensor: def close(self) -> None: """Close the environment and release resources.""" self.sim.destroy() - self.dataset.finalize() + + # Only finalize dataset if using lerobot format + dataset_format = self.cfg.dataset.get("format", self._default_dataset_format) + if dataset_format == "lerobot": + self.dataset.finalize() From b9fdaa3a9f73ae9532a846ed25b58b9b45d4bbd7 Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Thu, 25 Dec 2025 16:28:28 +0800 Subject: [PATCH 11/26] simplify init sim stat --- .../data/chunk-000/file-000.parquet | Bin 0 -> 110815 bytes .../meta/info.json | 100 +++ .../meta/stats.json | 600 ++++++++++++++++++ .../meta/tasks.parquet | Bin 0 -> 2241 bytes configs/gym/pour_water/gym_config_simple.json | 2 +- embodichain/lab/gym/envs/embodied_env.py | 120 ++-- 6 files changed, 759 insertions(+), 63 deletions(-) create mode 100644 commercial_cobotmagic_pour_water_001/data/chunk-000/file-000.parquet create mode 100644 commercial_cobotmagic_pour_water_001/meta/info.json create mode 100644 commercial_cobotmagic_pour_water_001/meta/stats.json create mode 100644 commercial_cobotmagic_pour_water_001/meta/tasks.parquet diff --git a/commercial_cobotmagic_pour_water_001/data/chunk-000/file-000.parquet b/commercial_cobotmagic_pour_water_001/data/chunk-000/file-000.parquet new file mode 100644 index 0000000000000000000000000000000000000000..a9a550bde36f4cfef4d9b1a70845e3198db7a238 GIT binary patch literal 110815 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 z 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 Q?f)?}v>A*kJR6h$11I8u4FCWD literal 0 HcmV?d00001 diff --git a/commercial_cobotmagic_pour_water_001/meta/info.json b/commercial_cobotmagic_pour_water_001/meta/info.json new file mode 100644 index 00000000..3f21bbdd --- /dev/null +++ b/commercial_cobotmagic_pour_water_001/meta/info.json @@ -0,0 +1,100 @@ +{ + "codebase_version": "v3.0", + "robot_type": "CobotMagic", + "total_episodes": 5, + "total_frames": 1000, + "total_tasks": 1, + "chunks_size": 1000, + "data_files_size_in_mb": 100, + "video_files_size_in_mb": 200, + "fps": 5, + "splits": { + "train": "0:5" + }, + "data_path": "data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet", + "video_path": "videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4", + "features": { + "cam_high": { + "dtype": "video", + "shape": [ + 540, + 960, + 3 + ], + "names": [ + "height", + "width", + "channel" + ], + "info": { + "video.height": 540, + "video.width": 960, + "video.codec": "av1", + "video.pix_fmt": "yuv420p", + "video.is_depth_map": false, + "video.fps": 5, + "video.channels": 3, + "has_audio": false + } + }, + "observation.state": { + "dtype": "float32", + "shape": [ + 14 + ], + "names": [ + "state" + ] + }, + "action": { + "dtype": "float32", + "shape": [ + 12 + ], + "names": [ + "action" + ] + }, + "timestamp": { + "dtype": "float32", + "shape": [ + 1 + ], + "names": null + }, + "frame_index": { + "dtype": "int64", + "shape": [ + 1 + ], + "names": null + }, + "episode_index": { + "dtype": "int64", + "shape": [ + 1 + ], + "names": null + }, + "index": { + "dtype": "int64", + "shape": [ + 1 + ], + "names": null + }, + "task_index": { + "dtype": "int64", + "shape": [ + 1 + ], + "names": null + } + }, + "extra": { + "scene_type": "commercial", + "task_description": "Pour water", + "total_time": 200.0, + "data_type": "sim" + } +} \ No newline at end of file diff --git a/commercial_cobotmagic_pour_water_001/meta/stats.json b/commercial_cobotmagic_pour_water_001/meta/stats.json new file mode 100644 index 00000000..168edd47 --- /dev/null +++ b/commercial_cobotmagic_pour_water_001/meta/stats.json @@ -0,0 +1,600 @@ +{ + "task_index": { + "min": [ + 0 + ], + "max": [ + 0 + ], + "mean": [ + 0.0 + ], + "std": [ + 0.0 + ], + "count": [ + 1000 + ], + "q01": [ + 3.9999999999994176e-16 + ], + "q10": [ + 3.999999999999417e-15 + ], + "q50": [ + 1.9999999999997088e-14 + ], + "q90": [ + 3.5999999999994766e-14 + ], + "q99": [ + 3.9599999999994235e-14 + ] + }, + "frame_index": { + "min": [ + 0 + ], + "max": [ + 199 + ], + "mean": [ + 99.5 + ], + "std": [ + 57.73430522661548 + ], + "count": [ + 1000 + ], + "q01": [ + 1.0347999999010402 + ], + "q10": [ + 19.024399999919122 + ], + "q50": [ + 99.0223999999995 + ], + "q90": [ + 179.0204000000799 + ], + "q99": [ + 197.01000000009802 + ] + }, + "cam_high": { + "min": [ + [ + [ + 0.0 + ] + ], + [ + [ + 0.0 + ] + ], + [ + [ + 0.0 + ] + ] + ], + "max": [ + [ + [ + 1.0 + ] + ], + [ + [ + 0.9294117647058824 + ] + ], + [ + [ + 0.9019607843137255 + ] + ] + ], + "mean": [ + [ + [ + 0.3985294950980392 + ] + ], + [ + [ + 0.38797757516339865 + ] + ], + [ + [ + 0.43035563453159037 + ] + ] + ], + "std": [ + [ + [ + 0.03251200880523185 + ] + ], + [ + [ + 0.007787433625849482 + ] + ], + [ + [ + 0.02552926559258953 + ] + ] + ], + "count": [ + 500 + ], + "q01": [ + [ + [ + 0.009410266985849697 + ] + ], + [ + [ + 0.01637673311059146 + ] + ], + [ + [ + 0.031317586917081267 + ] + ] + ], + "q10": [ + [ + [ + 0.04156336048031809 + ] + ], + [ + [ + 0.04940983425376585 + ] + ], + [ + [ + 0.07373002059356268 + ] + ] + ], + "q50": [ + [ + [ + 0.30356077818719357 + ] + ], + [ + [ + 0.29880606231549695 + ] + ], + [ + [ + 0.36703990274282766 + ] + ] + ], + "q90": [ + [ + [ + 0.8062741207199178 + ] + ], + [ + [ + 0.7968448068462433 + ] + ], + [ + [ + 0.8015525180088414 + ] + ] + ], + "q99": [ + [ + [ + 0.9106135503054051 + ] + ], + [ + [ + 0.8541084413614853 + ] + ], + [ + [ + 0.8462733937156217 + ] + ] + ] + }, + "observation.state": { + "min": [ + -0.6000045537948608, + 0.9647369980812073, + -1.199783205986023, + -0.15934430062770844, + 0.5708353519439697, + -0.09621219336986542, + 0.9999939203262329, + 0.23389677703380585, + 0.9577004909515381, + -2.4802279472351074, + -0.6022202372550964, + -0.3639858663082123, + -1.3255220651626587, + 0.5736659169197083 + ], + "max": [ + -0.20982179045677185, + 1.0058447122573853, + -1.1496601104736328, + 0.0951765850186348, + 0.6045608520507812, + 0.16208483278751373, + 1.0005700588226318, + 0.7359699010848999, + 2.3201870918273926, + -0.7884759306907654, + 0.48161184787750244, + 0.6688546538352966, + 0.6330729722976685, + 1.0005741119384766 + ], + "mean": [ + -0.5636306285858155, + 0.9986380219459534, + -1.1960057258605956, + -0.003527960181236267, + 0.5808733344078064, + 0.003535684058442712, + 1.0000562429428101, + 0.4270786583423615, + 1.7760903120040894, + -1.4908808469772339, + -0.2046621173620224, + 0.13482133001089097, + 0.049182109907269476, + 0.7900478839874268 + ], + "std": [ + 0.08934397307505688, + 0.0058743413551314225, + 0.009364926265463864, + 0.02875297774085957, + 0.004510281665015711, + 0.02915827979987213, + 4.393246457916828e-06, + 0.1218367220094254, + 0.38061580817146057, + 0.48136203965570495, + 0.2511771168067263, + 0.33827448257280834, + 0.374234231461447, + 0.20750412140135052 + ], + "count": [ + 1000 + ], + "q01": [ + -0.6000045538948608, + 0.9854510744680408, + -1.199783206086023, + -0.056976502831905605, + 0.5769098658301843, + -0.02697192680567574, + 0.9999993490346963, + 0.29720800527350194, + 0.9797500210716134, + -2.1308526278542113, + -0.5168572844074798, + -0.34135901937948, + -0.9727047743686177, + 0.5768780111266542 + ], + "q10": [ + -0.6000045538948608, + 0.990419417011293, + -1.199783206086023, + -0.03791946047606978, + 0.577957417014659, + -0.01785618054333617, + 1.0000016381650705, + 0.32350082427367455, + 1.102337663110997, + -2.1308526278542113, + -0.5111839843862328, + -0.3006431216638455, + -0.5113111353376598, + 0.5779861529786031 + ], + "q50": [ + -0.6000045538948608, + 1.0000914537308014, + -1.199783206086023, + -3.4329247647301886e-05, + 0.5800087648826312, + -3.3543904477842523e-06, + 1.0000017170745856, + 0.4056306650608562, + 1.8865134707483626, + -1.3735003626615783, + -0.23256055956244115, + 0.14501233489323762, + 0.11152741335114427, + 0.8102437481529672 + ], + "q90": [ + -0.4109259844943276, + 1.00105737734118, + -1.1809339937181298, + 0.016130089992709925, + 0.5863726146593526, + 0.03438128663395128, + 1.0002886921172727, + 0.563115476230079, + 2.1618577532010286, + -0.9571413196609116, + 0.1171039634218064, + 0.5156097053266468, + 0.4402901633523664, + 1.0000699462168845 + ], + "q99": [ + -0.2880810873849596, + 1.001422558737521, + -1.1694902580608053, + 0.026297286693875297, + 0.5904841644340179, + 0.056819575809148884, + 1.000513069413421, + 0.5631354438826705, + 2.1769390463537386, + -0.9210189315934152, + 0.1344212994408031, + 0.6032395180754248, + 0.511387397721545, + 1.0002034260308326 + ] + }, + "index": { + "min": [ + 0 + ], + "max": [ + 999 + ], + "mean": [ + 499.5 + ], + "std": [ + 288.6749902572095 + ], + "count": [ + 1000 + ], + "q01": [ + 401.03479999990105 + ], + "q10": [ + 419.0243999999191 + ], + "q50": [ + 499.02239999999944 + ], + "q90": [ + 579.02040000008 + ], + "q99": [ + 597.0100000000979 + ] + }, + "timestamp": { + "min": [ + 0.0 + ], + "max": [ + 39.8 + ], + "mean": [ + 19.899999999999995 + ], + "std": [ + 11.546861045323102 + ], + "count": [ + 1000 + ], + "q01": [ + 0.20695999990103997 + ], + "q10": [ + 3.8048799999191196 + ], + "q50": [ + 19.80447999999952 + ], + "q90": [ + 35.804080000079914 + ], + "q99": [ + 39.40200000009799 + ] + }, + "episode_index": { + "min": [ + 0 + ], + "max": [ + 4 + ], + "mean": [ + 2.0 + ], + "std": [ + 1.4142135623730951 + ], + "count": [ + 1000 + ], + "q01": [ + 2.0000000000000004 + ], + "q10": [ + 2.000000000000004 + ], + "q50": [ + 2.00000000000002 + ], + "q90": [ + 2.0000000000000355 + ], + "q99": [ + 2.0000000000000395 + ] + }, + "action": { + "min": [ + -0.6000000238418579, + 0.2338757961988449, + 0.9647369980812073, + 0.9577004909515381, + -1.2000000476837158, + -2.4805831909179688, + -0.1592746376991272, + -0.602020263671875, + 0.5708353519439697, + -0.36357495188713074, + -0.09621219336986542, + -1.3254481554031372 + ], + "max": [ + -0.20983155071735382, + 0.7359408736228943, + 1.00546395778656, + 2.3196258544921875, + -1.150509238243103, + -0.7892619967460632, + 0.0951765850186348, + 0.48004472255706787, + 0.6045506000518799, + 0.6683455109596252, + 0.16208483278751373, + 0.6326847076416016 + ], + "mean": [ + -0.5637349009513855, + 0.42707592248916626, + 0.9984262108802795, + 1.7757627487182617, + -1.1965131521224976, + -1.4914217710494995, + -0.0034639409743249415, + -0.20416707247495652, + 0.5808621525764466, + 0.13481861352920532, + 0.003525051986798644, + 0.049111776798963544 + ], + "std": [ + 0.08902961247355279, + 0.12184573172072853, + 0.00585234312056078, + 0.3806082358785286, + 0.009534261738707457, + 0.4813096541707787, + 0.028641239727498533, + 0.2508439296836177, + 0.0042939467308396075, + 0.33839069480636563, + 0.029053901216501313, + 0.3744164131101949 + ], + "count": [ + 1000 + ], + "q01": [ + -0.6000000239418579, + 0.29719068099989165, + 0.9853945978214512, + 0.9794887541724608, + -1.2000000477837158, + -2.131175994973047, + -0.05593278578405749, + -0.5160705507801605, + 0.5769541465345082, + -0.3412577332066132, + -0.026591941257031477, + -0.9748561621712281 + ], + "q10": [ + -0.6000000239418579, + 0.3234159384749338, + 0.9901094234691407, + 1.10169909873643, + -1.2000000477837158, + -2.131175994973047, + -0.03795401712441438, + -0.5105177905729777, + 0.577934054444418, + -0.2987960692438602, + -0.017979457002296428, + -0.5151452751961235 + ], + "q50": [ + -0.6000000239418579, + 0.4056390579859615, + 0.9999981795264269, + 1.8860821496030684, + -1.2000000477837158, + -1.3742644453592763, + -6.986080538475636e-06, + -0.23236538911830804, + 0.5799996030203695, + 0.14591152821133577, + -3.309453455424848e-06, + 0.11069981296551767 + ], + "q90": [ + -0.40996899483588073, + 0.5630983637495905, + 1.0006553109116725, + 2.1608486681309422, + -1.1817374086180001, + -0.9571167805155766, + 0.01601432286084132, + 0.11705497327425997, + 0.5863731868301147, + 0.5139330870234888, + 0.034664862654310465, + 0.4401477517043115 + ], + "q99": [ + -0.3031303669339447, + 0.5631175106344994, + 1.0010199753262596, + 2.1763910994757656, + -1.1714800708466797, + -0.9214468654123629, + 0.025192485847936003, + 0.13360121716824805, + 0.5899946500873195, + 0.6030239127172098, + 0.05393037297357354, + 0.511402690488865 + ] + } +} \ No newline at end of file diff --git a/commercial_cobotmagic_pour_water_001/meta/tasks.parquet b/commercial_cobotmagic_pour_water_001/meta/tasks.parquet new file mode 100644 index 0000000000000000000000000000000000000000..b867343a19b758660650976f17356d6516fa0211 GIT binary patch literal 2241 zcmb_e-EQJW6n4B3%~rWcRy6`CE4h{yZ6%Uo6VfHxy%^X4rul;v=(dR6tZ*L{RON6*+KfskEkrH;ffbIv#CeCHb1m(GKIgG+I#t<2i3=YExDUOr;o+yvxPo`soRSz#H-P~mE~8NUAM zIXF(op~p|P!1VZmH@0~^4gzBG!I;-W_hITi=H>HG9>{#!y-XXhvhV<;&54G)A!p+S+nMD@D{nJ3>EUcKbx#Pns?B%i6beOowj~LM`~v@jXJj zs4_$~goda(jBS<5f`3Yl2Y3F1VKv#q97l=$GD1@X=ioaIGNfOyhv^9LmG5MmguS}5 z+mVhZxS<5tIkHZy3YJNEpsA&cUS~AGbw%o_mxH5Pc_5R5FISuASh^IkTDrv$J(Bn2 zS^)7%Bdng$7@?tF`iE6y^=v|Ly*& z>Tt2sl8zHUXdzBbrCcyO6+-h;-Ho^1M{B^2;^PKzZOn1I*UZ*b#qZV1VOJHCSRcD9 zs-IIC0nd>x&gFKLP*bT!XBNF5$QS4hGJgk?uI-X;v6c8$rO;K|BcpzBcds)oM@FL^ zX=+n6>PoDqSxhw6Z3Xb+--7DEC&^|$^F{_h4_H+-O0|WX4q3D$M7c5$5ac4kt z(M>$l>3}Zb*?#PKr+(-ch!f*msM12vICX?{L>qSIq$_~PF{qhr;+aly|2E(bq+phV z5**U=(3d)mHya7*Wb@_?li^*>`w*JJw(h#y@e7Bay-@h%Rg=xb&oBK2{__j?3ju## F{sVyQf0Y0L literal 0 HcmV?d00001 diff --git a/configs/gym/pour_water/gym_config_simple.json b/configs/gym/pour_water/gym_config_simple.json index d446664d..bdaf1b7d 100644 --- a/configs/gym/pour_water/gym_config_simple.json +++ b/configs/gym/pour_water/gym_config_simple.json @@ -197,7 +197,7 @@ }, "dataset": { "format": "lerobot", - "save_path": "/root/workspace/datahub", + "save_path": "./", "extra": { "scene_type": "commercial", "task_description": "Pour water" diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index 5d49e3cb..a11823a9 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -183,72 +183,21 @@ def _init_sim_state(self, **kwargs): # TODO: A workaround for handling dataset saving, which need history data of obs-action pairs. # We may improve this by implementing a data manager to handle data saving and online streaming. if self.cfg.dataset is not None: - robot_type = self.cfg.dataset.get("robot_meta", {}).get( - "robot_type", "robot" - ) - scene_type = self.cfg.dataset.get("extra", {}).get("scene_type", "scene") - task_description = self.cfg.dataset.get("extra", {}).get( - "task_description", "task" - ) - - robot_type = str(robot_type).lower().replace(" ", "_") - task_description = str(task_description).lower().replace(" ", "_") - # Get dataset format from dataset config, use instance default if not specified dataset_format = self.cfg.dataset.get( "format", self._default_dataset_format ) - # Initialize based on dataset format - if dataset_format == "lerobot": - lerobot_data_root = self.cfg.dataset.get("save_path", None) - if lerobot_data_root is None: - try: - from lerobot.utils.constants import HF_LEROBOT_HOME - - lerobot_data_root = HF_LEROBOT_HOME - except ImportError: - logger.log_error("LeRobot not installed.") - - # Auto-increment id until the repo_id subdirectory does not exist - base_id = int(self.cfg.dataset.get("id", "1")) - while True: - dataset_id = f"{base_id:03d}" - repo_id = ( - f"{scene_type}_{robot_type}_{task_description}_{dataset_id}" - ) - repo_path = os.path.join(lerobot_data_root, repo_id) - if not os.path.exists(repo_path): - break - base_id += 1 - self.cfg.dataset["repo_id"] = repo_id - self.cfg.dataset["id"] = dataset_id - self.cfg.dataset["lerobot_data_root"] = str(lerobot_data_root) - self.metadata["dataset"] = self.cfg.dataset self.episode_obs_list = [] self.episode_action_list = [] self.curr_episode = 0 - # Initialize folder name for hdf5 format - if dataset_format == "hdf5": - from embodichain.lab.gym.utils.misc import camel_to_snake - - save_path = self.cfg.dataset.get("save_path", None) - if save_path is None: - from embodichain.data import database_demo_dir - - save_path = database_demo_dir - - self.folder_name = f"{camel_to_snake(self.__class__.__name__)}_{camel_to_snake(self.robot.cfg.uid)}" - if os.path.exists(os.path.join(save_path, self.folder_name)): - self.folder_name = ( - f"{self.folder_name}_{np.random.randint(0, 1000)}" - ) - - # Initialize LeRobotDataset if dataset format is lerobot + # Initialize based on dataset format if dataset_format == "lerobot": self._initialize_lerobot_dataset() + elif dataset_format == "hdf5": + self._initialize_hdf5_dataset() def _apply_functor_filter(self) -> None: """Apply functor filters to the environment components based on configuration. @@ -288,6 +237,43 @@ def _initialize_lerobot_dataset(self) -> None: f"Failed to import LeRobot dependencies: {e}. " "Dataset recording will be disabled. Install with: pip install lerobot" ) + return + + # Extract naming components from config + robot_type = self.cfg.dataset.get("robot_meta", {}).get("robot_type", "robot") + scene_type = self.cfg.dataset.get("extra", {}).get("scene_type", "scene") + task_description = self.cfg.dataset.get("extra", {}).get( + "task_description", "task" + ) + + robot_type = str(robot_type).lower().replace(" ", "_") + task_description = str(task_description).lower().replace(" ", "_") + + # Determine lerobot data root directory + lerobot_data_root = self.cfg.dataset.get("save_path", None) + if lerobot_data_root is None: + try: + from lerobot.utils.constants import HF_LEROBOT_HOME + + lerobot_data_root = HF_LEROBOT_HOME + except ImportError: + logger.log_error("LeRobot not installed.") + return + + # Auto-increment id until the repo_id subdirectory does not exist + base_id = int(self.cfg.dataset.get("id", "1")) + while True: + dataset_id = f"{base_id:03d}" + repo_id = f"{scene_type}_{robot_type}_{task_description}_{dataset_id}" + repo_path = os.path.join(lerobot_data_root, repo_id) + if not os.path.exists(repo_path): + break + base_id += 1 + + # Store computed values back to config + self.cfg.dataset["repo_id"] = repo_id + self.cfg.dataset["id"] = dataset_id + self.cfg.dataset["lerobot_data_root"] = str(lerobot_data_root) # Get dataset configuration dataset_cfg = self.cfg.dataset @@ -302,17 +288,10 @@ def _initialize_lerobot_dataset(self) -> None: # Build features using handler features = self.data_handler._build_lerobot_features(use_videos=use_videos) - robot_type = self.metadata["dataset"]["robot_meta"].get("robot_type", "unknown") + robot_type = self.cfg.dataset.get("robot_meta", {}).get("robot_type", "robot") - lerobot_data_root = self.cfg.dataset.get("lerobot_data_root") - repo_id = self.cfg.dataset.get("repo_id") dataset_dir = os.path.join(lerobot_data_root, repo_id) - # User can override repo_id from dataset config - default_repo_id = dataset_cfg.get("repo_id", None) - if default_repo_id: - repo_id = default_repo_id - try: logger.log_info(f"Creating new LeRobot dataset at {dataset_dir}") self.dataset = LeRobotDataset.create( @@ -330,6 +309,23 @@ def _initialize_lerobot_dataset(self) -> None: logger.log_error(f"Failed to initialize LeRobotDataset: {e}") self.dataset = None + def _initialize_hdf5_dataset(self) -> None: + """Initialize HDF5 dataset folder structure. + + This method sets up the folder structure for HDF5 dataset recording. + """ + from embodichain.lab.gym.utils.misc import camel_to_snake + + save_path = self.cfg.dataset.get("save_path", None) + if save_path is None: + from embodichain.data import database_demo_dir + + save_path = database_demo_dir + + self.folder_name = f"{camel_to_snake(self.__class__.__name__)}_{camel_to_snake(self.robot.cfg.uid)}" + if os.path.exists(os.path.join(save_path, self.folder_name)): + self.folder_name = f"{self.folder_name}_{np.random.randint(0, 1000)}" + def _init_action_bank( self, action_bank_cls: ActionBank, action_config: Dict[str, Any] ): From 119cfa0329261790423dd51aaa673d72576b0b10 Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Thu, 25 Dec 2025 16:28:55 +0800 Subject: [PATCH 12/26] delete unnecessary --- .../data/chunk-000/file-000.parquet | Bin 110815 -> 0 bytes .../meta/info.json | 100 --- .../meta/stats.json | 600 ------------------ .../meta/tasks.parquet | Bin 2241 -> 0 bytes 4 files changed, 700 deletions(-) delete mode 100644 commercial_cobotmagic_pour_water_001/data/chunk-000/file-000.parquet delete mode 100644 commercial_cobotmagic_pour_water_001/meta/info.json delete mode 100644 commercial_cobotmagic_pour_water_001/meta/stats.json delete mode 100644 commercial_cobotmagic_pour_water_001/meta/tasks.parquet diff --git a/commercial_cobotmagic_pour_water_001/data/chunk-000/file-000.parquet b/commercial_cobotmagic_pour_water_001/data/chunk-000/file-000.parquet deleted file mode 100644 index a9a550bde36f4cfef4d9b1a70845e3198db7a238..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 110815 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 z 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 Q?f)?}v>A*kJR6h$11I8u4FCWD diff --git a/commercial_cobotmagic_pour_water_001/meta/info.json b/commercial_cobotmagic_pour_water_001/meta/info.json deleted file mode 100644 index 3f21bbdd..00000000 --- a/commercial_cobotmagic_pour_water_001/meta/info.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "codebase_version": "v3.0", - "robot_type": "CobotMagic", - "total_episodes": 5, - "total_frames": 1000, - "total_tasks": 1, - "chunks_size": 1000, - "data_files_size_in_mb": 100, - "video_files_size_in_mb": 200, - "fps": 5, - "splits": { - "train": "0:5" - }, - "data_path": "data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet", - "video_path": "videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4", - "features": { - "cam_high": { - "dtype": "video", - "shape": [ - 540, - 960, - 3 - ], - "names": [ - "height", - "width", - "channel" - ], - "info": { - "video.height": 540, - "video.width": 960, - "video.codec": "av1", - "video.pix_fmt": "yuv420p", - "video.is_depth_map": false, - "video.fps": 5, - "video.channels": 3, - "has_audio": false - } - }, - "observation.state": { - "dtype": "float32", - "shape": [ - 14 - ], - "names": [ - "state" - ] - }, - "action": { - "dtype": "float32", - "shape": [ - 12 - ], - "names": [ - "action" - ] - }, - "timestamp": { - "dtype": "float32", - "shape": [ - 1 - ], - "names": null - }, - "frame_index": { - "dtype": "int64", - "shape": [ - 1 - ], - "names": null - }, - "episode_index": { - "dtype": "int64", - "shape": [ - 1 - ], - "names": null - }, - "index": { - "dtype": "int64", - "shape": [ - 1 - ], - "names": null - }, - "task_index": { - "dtype": "int64", - "shape": [ - 1 - ], - "names": null - } - }, - "extra": { - "scene_type": "commercial", - "task_description": "Pour water", - "total_time": 200.0, - "data_type": "sim" - } -} \ No newline at end of file diff --git a/commercial_cobotmagic_pour_water_001/meta/stats.json b/commercial_cobotmagic_pour_water_001/meta/stats.json deleted file mode 100644 index 168edd47..00000000 --- a/commercial_cobotmagic_pour_water_001/meta/stats.json +++ /dev/null @@ -1,600 +0,0 @@ -{ - "task_index": { - "min": [ - 0 - ], - "max": [ - 0 - ], - "mean": [ - 0.0 - ], - "std": [ - 0.0 - ], - "count": [ - 1000 - ], - "q01": [ - 3.9999999999994176e-16 - ], - "q10": [ - 3.999999999999417e-15 - ], - "q50": [ - 1.9999999999997088e-14 - ], - "q90": [ - 3.5999999999994766e-14 - ], - "q99": [ - 3.9599999999994235e-14 - ] - }, - "frame_index": { - "min": [ - 0 - ], - "max": [ - 199 - ], - "mean": [ - 99.5 - ], - "std": [ - 57.73430522661548 - ], - "count": [ - 1000 - ], - "q01": [ - 1.0347999999010402 - ], - "q10": [ - 19.024399999919122 - ], - "q50": [ - 99.0223999999995 - ], - "q90": [ - 179.0204000000799 - ], - "q99": [ - 197.01000000009802 - ] - }, - "cam_high": { - "min": [ - [ - [ - 0.0 - ] - ], - [ - [ - 0.0 - ] - ], - [ - [ - 0.0 - ] - ] - ], - "max": [ - [ - [ - 1.0 - ] - ], - [ - [ - 0.9294117647058824 - ] - ], - [ - [ - 0.9019607843137255 - ] - ] - ], - "mean": [ - [ - [ - 0.3985294950980392 - ] - ], - [ - [ - 0.38797757516339865 - ] - ], - [ - [ - 0.43035563453159037 - ] - ] - ], - "std": [ - [ - [ - 0.03251200880523185 - ] - ], - [ - [ - 0.007787433625849482 - ] - ], - [ - [ - 0.02552926559258953 - ] - ] - ], - "count": [ - 500 - ], - "q01": [ - [ - [ - 0.009410266985849697 - ] - ], - [ - [ - 0.01637673311059146 - ] - ], - [ - [ - 0.031317586917081267 - ] - ] - ], - "q10": [ - [ - [ - 0.04156336048031809 - ] - ], - [ - [ - 0.04940983425376585 - ] - ], - [ - [ - 0.07373002059356268 - ] - ] - ], - "q50": [ - [ - [ - 0.30356077818719357 - ] - ], - [ - [ - 0.29880606231549695 - ] - ], - [ - [ - 0.36703990274282766 - ] - ] - ], - "q90": [ - [ - [ - 0.8062741207199178 - ] - ], - [ - [ - 0.7968448068462433 - ] - ], - [ - [ - 0.8015525180088414 - ] - ] - ], - "q99": [ - [ - [ - 0.9106135503054051 - ] - ], - [ - [ - 0.8541084413614853 - ] - ], - [ - [ - 0.8462733937156217 - ] - ] - ] - }, - "observation.state": { - "min": [ - -0.6000045537948608, - 0.9647369980812073, - -1.199783205986023, - -0.15934430062770844, - 0.5708353519439697, - -0.09621219336986542, - 0.9999939203262329, - 0.23389677703380585, - 0.9577004909515381, - -2.4802279472351074, - -0.6022202372550964, - -0.3639858663082123, - -1.3255220651626587, - 0.5736659169197083 - ], - "max": [ - -0.20982179045677185, - 1.0058447122573853, - -1.1496601104736328, - 0.0951765850186348, - 0.6045608520507812, - 0.16208483278751373, - 1.0005700588226318, - 0.7359699010848999, - 2.3201870918273926, - -0.7884759306907654, - 0.48161184787750244, - 0.6688546538352966, - 0.6330729722976685, - 1.0005741119384766 - ], - "mean": [ - -0.5636306285858155, - 0.9986380219459534, - -1.1960057258605956, - -0.003527960181236267, - 0.5808733344078064, - 0.003535684058442712, - 1.0000562429428101, - 0.4270786583423615, - 1.7760903120040894, - -1.4908808469772339, - -0.2046621173620224, - 0.13482133001089097, - 0.049182109907269476, - 0.7900478839874268 - ], - "std": [ - 0.08934397307505688, - 0.0058743413551314225, - 0.009364926265463864, - 0.02875297774085957, - 0.004510281665015711, - 0.02915827979987213, - 4.393246457916828e-06, - 0.1218367220094254, - 0.38061580817146057, - 0.48136203965570495, - 0.2511771168067263, - 0.33827448257280834, - 0.374234231461447, - 0.20750412140135052 - ], - "count": [ - 1000 - ], - "q01": [ - -0.6000045538948608, - 0.9854510744680408, - -1.199783206086023, - -0.056976502831905605, - 0.5769098658301843, - -0.02697192680567574, - 0.9999993490346963, - 0.29720800527350194, - 0.9797500210716134, - -2.1308526278542113, - -0.5168572844074798, - -0.34135901937948, - -0.9727047743686177, - 0.5768780111266542 - ], - "q10": [ - -0.6000045538948608, - 0.990419417011293, - -1.199783206086023, - -0.03791946047606978, - 0.577957417014659, - -0.01785618054333617, - 1.0000016381650705, - 0.32350082427367455, - 1.102337663110997, - -2.1308526278542113, - -0.5111839843862328, - -0.3006431216638455, - -0.5113111353376598, - 0.5779861529786031 - ], - "q50": [ - -0.6000045538948608, - 1.0000914537308014, - -1.199783206086023, - -3.4329247647301886e-05, - 0.5800087648826312, - -3.3543904477842523e-06, - 1.0000017170745856, - 0.4056306650608562, - 1.8865134707483626, - -1.3735003626615783, - -0.23256055956244115, - 0.14501233489323762, - 0.11152741335114427, - 0.8102437481529672 - ], - "q90": [ - -0.4109259844943276, - 1.00105737734118, - -1.1809339937181298, - 0.016130089992709925, - 0.5863726146593526, - 0.03438128663395128, - 1.0002886921172727, - 0.563115476230079, - 2.1618577532010286, - -0.9571413196609116, - 0.1171039634218064, - 0.5156097053266468, - 0.4402901633523664, - 1.0000699462168845 - ], - "q99": [ - -0.2880810873849596, - 1.001422558737521, - -1.1694902580608053, - 0.026297286693875297, - 0.5904841644340179, - 0.056819575809148884, - 1.000513069413421, - 0.5631354438826705, - 2.1769390463537386, - -0.9210189315934152, - 0.1344212994408031, - 0.6032395180754248, - 0.511387397721545, - 1.0002034260308326 - ] - }, - "index": { - "min": [ - 0 - ], - "max": [ - 999 - ], - "mean": [ - 499.5 - ], - "std": [ - 288.6749902572095 - ], - "count": [ - 1000 - ], - "q01": [ - 401.03479999990105 - ], - "q10": [ - 419.0243999999191 - ], - "q50": [ - 499.02239999999944 - ], - "q90": [ - 579.02040000008 - ], - "q99": [ - 597.0100000000979 - ] - }, - "timestamp": { - "min": [ - 0.0 - ], - "max": [ - 39.8 - ], - "mean": [ - 19.899999999999995 - ], - "std": [ - 11.546861045323102 - ], - "count": [ - 1000 - ], - "q01": [ - 0.20695999990103997 - ], - "q10": [ - 3.8048799999191196 - ], - "q50": [ - 19.80447999999952 - ], - "q90": [ - 35.804080000079914 - ], - "q99": [ - 39.40200000009799 - ] - }, - "episode_index": { - "min": [ - 0 - ], - "max": [ - 4 - ], - "mean": [ - 2.0 - ], - "std": [ - 1.4142135623730951 - ], - "count": [ - 1000 - ], - "q01": [ - 2.0000000000000004 - ], - "q10": [ - 2.000000000000004 - ], - "q50": [ - 2.00000000000002 - ], - "q90": [ - 2.0000000000000355 - ], - "q99": [ - 2.0000000000000395 - ] - }, - "action": { - "min": [ - -0.6000000238418579, - 0.2338757961988449, - 0.9647369980812073, - 0.9577004909515381, - -1.2000000476837158, - -2.4805831909179688, - -0.1592746376991272, - -0.602020263671875, - 0.5708353519439697, - -0.36357495188713074, - -0.09621219336986542, - -1.3254481554031372 - ], - "max": [ - -0.20983155071735382, - 0.7359408736228943, - 1.00546395778656, - 2.3196258544921875, - -1.150509238243103, - -0.7892619967460632, - 0.0951765850186348, - 0.48004472255706787, - 0.6045506000518799, - 0.6683455109596252, - 0.16208483278751373, - 0.6326847076416016 - ], - "mean": [ - -0.5637349009513855, - 0.42707592248916626, - 0.9984262108802795, - 1.7757627487182617, - -1.1965131521224976, - -1.4914217710494995, - -0.0034639409743249415, - -0.20416707247495652, - 0.5808621525764466, - 0.13481861352920532, - 0.003525051986798644, - 0.049111776798963544 - ], - "std": [ - 0.08902961247355279, - 0.12184573172072853, - 0.00585234312056078, - 0.3806082358785286, - 0.009534261738707457, - 0.4813096541707787, - 0.028641239727498533, - 0.2508439296836177, - 0.0042939467308396075, - 0.33839069480636563, - 0.029053901216501313, - 0.3744164131101949 - ], - "count": [ - 1000 - ], - "q01": [ - -0.6000000239418579, - 0.29719068099989165, - 0.9853945978214512, - 0.9794887541724608, - -1.2000000477837158, - -2.131175994973047, - -0.05593278578405749, - -0.5160705507801605, - 0.5769541465345082, - -0.3412577332066132, - -0.026591941257031477, - -0.9748561621712281 - ], - "q10": [ - -0.6000000239418579, - 0.3234159384749338, - 0.9901094234691407, - 1.10169909873643, - -1.2000000477837158, - -2.131175994973047, - -0.03795401712441438, - -0.5105177905729777, - 0.577934054444418, - -0.2987960692438602, - -0.017979457002296428, - -0.5151452751961235 - ], - "q50": [ - -0.6000000239418579, - 0.4056390579859615, - 0.9999981795264269, - 1.8860821496030684, - -1.2000000477837158, - -1.3742644453592763, - -6.986080538475636e-06, - -0.23236538911830804, - 0.5799996030203695, - 0.14591152821133577, - -3.309453455424848e-06, - 0.11069981296551767 - ], - "q90": [ - -0.40996899483588073, - 0.5630983637495905, - 1.0006553109116725, - 2.1608486681309422, - -1.1817374086180001, - -0.9571167805155766, - 0.01601432286084132, - 0.11705497327425997, - 0.5863731868301147, - 0.5139330870234888, - 0.034664862654310465, - 0.4401477517043115 - ], - "q99": [ - -0.3031303669339447, - 0.5631175106344994, - 1.0010199753262596, - 2.1763910994757656, - -1.1714800708466797, - -0.9214468654123629, - 0.025192485847936003, - 0.13360121716824805, - 0.5899946500873195, - 0.6030239127172098, - 0.05393037297357354, - 0.511402690488865 - ] - } -} \ No newline at end of file diff --git a/commercial_cobotmagic_pour_water_001/meta/tasks.parquet b/commercial_cobotmagic_pour_water_001/meta/tasks.parquet deleted file mode 100644 index b867343a19b758660650976f17356d6516fa0211..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2241 zcmb_e-EQJW6n4B3%~rWcRy6`CE4h{yZ6%Uo6VfHxy%^X4rul;v=(dR6tZ*L{RON6*+KfskEkrH;ffbIv#CeCHb1m(GKIgG+I#t<2i3=YExDUOr;o+yvxPo`soRSz#H-P~mE~8NUAM zIXF(op~p|P!1VZmH@0~^4gzBG!I;-W_hITi=H>HG9>{#!y-XXhvhV<;&54G)A!p+S+nMD@D{nJ3>EUcKbx#Pns?B%i6beOowj~LM`~v@jXJj zs4_$~goda(jBS<5f`3Yl2Y3F1VKv#q97l=$GD1@X=ioaIGNfOyhv^9LmG5MmguS}5 z+mVhZxS<5tIkHZy3YJNEpsA&cUS~AGbw%o_mxH5Pc_5R5FISuASh^IkTDrv$J(Bn2 zS^)7%Bdng$7@?tF`iE6y^=v|Ly*& z>Tt2sl8zHUXdzBbrCcyO6+-h;-Ho^1M{B^2;^PKzZOn1I*UZ*b#qZV1VOJHCSRcD9 zs-IIC0nd>x&gFKLP*bT!XBNF5$QS4hGJgk?uI-X;v6c8$rO;K|BcpzBcds)oM@FL^ zX=+n6>PoDqSxhw6Z3Xb+--7DEC&^|$^F{_h4_H+-O0|WX4q3D$M7c5$5ac4kt z(M>$l>3}Zb*?#PKr+(-ch!f*msM12vICX?{L>qSIq$_~PF{qhr;+aly|2E(bq+phV z5**U=(3d)mHya7*Wb@_?li^*>`w*JJw(h#y@e7Bay-@h%Rg=xb&oBK2{__j?3ju## F{sVyQf0Y0L From 3c3b2bca3ab938a55efe05fe7dd6d01f54c7b2ff Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Thu, 25 Dec 2025 16:30:46 +0800 Subject: [PATCH 13/26] index start from 0 --- configs/gym/pour_water/gym_config_simple.json | 2 +- embodichain/lab/gym/envs/embodied_env.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/configs/gym/pour_water/gym_config_simple.json b/configs/gym/pour_water/gym_config_simple.json index bdaf1b7d..d446664d 100644 --- a/configs/gym/pour_water/gym_config_simple.json +++ b/configs/gym/pour_water/gym_config_simple.json @@ -197,7 +197,7 @@ }, "dataset": { "format": "lerobot", - "save_path": "./", + "save_path": "/root/workspace/datahub", "extra": { "scene_type": "commercial", "task_description": "Pour water" diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index a11823a9..d54a66cd 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -261,7 +261,7 @@ def _initialize_lerobot_dataset(self) -> None: return # Auto-increment id until the repo_id subdirectory does not exist - base_id = int(self.cfg.dataset.get("id", "1")) + base_id = int(self.cfg.dataset.get("id", "0")) while True: dataset_id = f"{base_id:03d}" repo_id = f"{scene_type}_{robot_type}_{task_description}_{dataset_id}" From 0fe1894a1df496162cf2b8850b5a7fca94625ff8 Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Thu, 25 Dec 2025 16:43:20 +0800 Subject: [PATCH 14/26] delete unnecessary --- embodichain/lab/gym/envs/embodied_env.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index d54a66cd..a32b7e23 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -196,8 +196,6 @@ def _init_sim_state(self, **kwargs): # Initialize based on dataset format if dataset_format == "lerobot": self._initialize_lerobot_dataset() - elif dataset_format == "hdf5": - self._initialize_hdf5_dataset() def _apply_functor_filter(self) -> None: """Apply functor filters to the environment components based on configuration. @@ -309,23 +307,6 @@ def _initialize_lerobot_dataset(self) -> None: logger.log_error(f"Failed to initialize LeRobotDataset: {e}") self.dataset = None - def _initialize_hdf5_dataset(self) -> None: - """Initialize HDF5 dataset folder structure. - - This method sets up the folder structure for HDF5 dataset recording. - """ - from embodichain.lab.gym.utils.misc import camel_to_snake - - save_path = self.cfg.dataset.get("save_path", None) - if save_path is None: - from embodichain.data import database_demo_dir - - save_path = database_demo_dir - - self.folder_name = f"{camel_to_snake(self.__class__.__name__)}_{camel_to_snake(self.robot.cfg.uid)}" - if os.path.exists(os.path.join(save_path, self.folder_name)): - self.folder_name = f"{self.folder_name}_{np.random.randint(0, 1000)}" - def _init_action_bank( self, action_bank_cls: ActionBank, action_config: Dict[str, Any] ): From 421c1247ec1b814c4dbb2c7c0557e032cfcee529 Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Thu, 25 Dec 2025 18:37:13 +0800 Subject: [PATCH 15/26] parallel save mutiple envs --- configs/gym/pour_water/gym_config_simple.json | 2 +- .../data/handler/lerobot_data_handler.py | 179 +++++++++--------- embodichain/lab/gym/envs/embodied_env.py | 52 ++--- embodichain/lab/scripts/run_env.py | 4 +- 4 files changed, 120 insertions(+), 117 deletions(-) diff --git a/configs/gym/pour_water/gym_config_simple.json b/configs/gym/pour_water/gym_config_simple.json index d446664d..bdaf1b7d 100644 --- a/configs/gym/pour_water/gym_config_simple.json +++ b/configs/gym/pour_water/gym_config_simple.json @@ -197,7 +197,7 @@ }, "dataset": { "format": "lerobot", - "save_path": "/root/workspace/datahub", + "save_path": "./", "extra": { "scene_type": "commercial", "task_description": "Pour water" diff --git a/embodichain/data/handler/lerobot_data_handler.py b/embodichain/data/handler/lerobot_data_handler.py index 10a62581..fa04152d 100644 --- a/embodichain/data/handler/lerobot_data_handler.py +++ b/embodichain/data/handler/lerobot_data_handler.py @@ -103,103 +103,106 @@ def _build_lerobot_features(self, use_videos: bool = True) -> Dict: def _convert_frame_to_lerobot( self, obs: Dict[str, Any], action: Dict[str, Any], task: str - ) -> Dict: + ) -> List[Dict]: """ - Convert a single frame to LeRobot format. + Convert frames from all environments to LeRobot format. Args: - obs (Dict): Observation dict from environment. - action (Dict): Action dict from environment. + obs (Dict): Observation dict from environment (batched). + action (Dict): Action dict from environment (batched). task (str): Task description. Returns: - Dict: Frame in LeRobot format. + List[Dict]: List of frames in LeRobot format, one per environment. """ - frame = {"task": task} robot_meta_config = self.env.metadata["dataset"]["robot_meta"] extra_vision_config = robot_meta_config["observation"]["vision"] - - # Add images - for camera_name in extra_vision_config.keys(): - if camera_name in obs["sensor"]: - is_stereo = is_stereocam(self.env.get_sensor(camera_name)) - - # Process left/main camera image - color_data = obs["sensor"][camera_name]["color"] - if isinstance(color_data, torch.Tensor): - color_img = color_data.squeeze(0)[:, :, :3].cpu().numpy() - else: - color_img = np.array(color_data).squeeze(0)[:, :, :3] - - # Ensure uint8 format (0-255 range) - if color_img.dtype == np.float32 or color_img.dtype == np.float64: - color_img = (color_img * 255).astype(np.uint8) - - frame[camera_name] = color_img - - # Process right camera image if stereo - if is_stereo: - color_right_data = obs["sensor"][camera_name]["color_right"] - if isinstance(color_right_data, torch.Tensor): - color_right_img = ( - color_right_data.squeeze(0)[:, :, :3].cpu().numpy() - ) - else: - color_right_img = np.array(color_right_data).squeeze(0)[ - :, :, :3 - ] - - # Ensure uint8 format - if ( - color_right_img.dtype == np.float32 - or color_right_img.dtype == np.float64 - ): - color_right_img = (color_right_img * 255).astype(np.uint8) - - frame[get_right_name(camera_name)] = color_right_img - - # Add state (proprio) - state_list = [] - robot = self.env.robot - qpos = obs["robot"][JointType.QPOS.value] - for proprio_name in SUPPORTED_PROPRIO_TYPES: - part = data_key_to_control_part( - robot=robot, - control_parts=robot_meta_config.get("control_parts", []), - data_key=proprio_name, - ) - if part: - indices = robot.get_joint_ids(part, remove_mimic=True) - qpos_data = qpos[0][indices].cpu().numpy() - qpos_data = HandQposNormalizer.normalize_hand_qpos( - qpos_data, part, robot=robot - ) - state_list.append(qpos_data) - - if state_list: - frame["observation.state"] = np.concatenate(state_list) - - # Add actions robot = self.env.robot arm_dofs = robot_meta_config.get("arm_dofs", 7) - # Handle different action types - if isinstance(action, torch.Tensor): - action_data = action[0, :arm_dofs].cpu().numpy() - elif isinstance(action, np.ndarray): - action_data = action[0, :arm_dofs] - elif isinstance(action, dict): - # If action is a dict, try to extract the actual action data - # This depends on your action dict structure - action_data = action.get("action", action.get("arm_action", action)) - if isinstance(action_data, torch.Tensor): - action_data = action_data[0, :arm_dofs].cpu().numpy() - elif isinstance(action_data, np.ndarray): - action_data = action_data[0, :arm_dofs] - else: - # Fallback: try to convert to numpy - action_data = np.array(action)[0, :arm_dofs] - - frame["action"] = action_data - - return frame + # Determine batch size from qpos + qpos = obs["robot"][JointType.QPOS.value] + num_envs = qpos.shape[0] + + frames = [] + + # Process each environment + for env_idx in range(num_envs): + frame = {"task": task} + + # Add images + for camera_name in extra_vision_config.keys(): + if camera_name in obs["sensor"]: + is_stereo = is_stereocam(self.env.get_sensor(camera_name)) + + # Process left/main camera image + color_data = obs["sensor"][camera_name]["color"] + if isinstance(color_data, torch.Tensor): + color_img = color_data[env_idx][:, :, :3].cpu().numpy() + else: + color_img = np.array(color_data)[env_idx][:, :, :3] + + # Ensure uint8 format (0-255 range) + if color_img.dtype == np.float32 or color_img.dtype == np.float64: + color_img = (color_img * 255).astype(np.uint8) + + frame[camera_name] = color_img + + # Process right camera image if stereo + if is_stereo: + color_right_data = obs["sensor"][camera_name]["color_right"] + if isinstance(color_right_data, torch.Tensor): + color_right_img = color_right_data[env_idx][:, :, :3].cpu().numpy() + else: + color_right_img = np.array(color_right_data)[env_idx][:, :, :3] + + # Ensure uint8 format + if ( + color_right_img.dtype == np.float32 + or color_right_img.dtype == np.float64 + ): + color_right_img = (color_right_img * 255).astype(np.uint8) + + frame[get_right_name(camera_name)] = color_right_img + + # Add state (proprio) + state_list = [] + for proprio_name in SUPPORTED_PROPRIO_TYPES: + part = data_key_to_control_part( + robot=robot, + control_parts=robot_meta_config.get("control_parts", []), + data_key=proprio_name, + ) + if part: + indices = robot.get_joint_ids(part, remove_mimic=True) + qpos_data = qpos[env_idx][indices].cpu().numpy() + qpos_data = HandQposNormalizer.normalize_hand_qpos( + qpos_data, part, robot=robot + ) + state_list.append(qpos_data) + + if state_list: + frame["observation.state"] = np.concatenate(state_list) + + # Add actions + # Handle different action types + if isinstance(action, torch.Tensor): + action_data = action[env_idx, :arm_dofs].cpu().numpy() + elif isinstance(action, np.ndarray): + action_data = action[env_idx, :arm_dofs] + elif isinstance(action, dict): + # If action is a dict, try to extract the actual action data + action_data = action.get("action", action.get("arm_action", action)) + if isinstance(action_data, torch.Tensor): + action_data = action_data[env_idx, :arm_dofs].cpu().numpy() + elif isinstance(action_data, np.ndarray): + action_data = action_data[env_idx, :arm_dofs] + else: + # Fallback: try to convert to numpy + action_data = np.array(action)[env_idx, :arm_dofs] + + frame["action"] = action_data + + frames.append(frame) + + return frames diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index a32b7e23..25485571 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -365,7 +365,6 @@ def reset( self, seed: int | None = None, options: dict | None = None ) -> Tuple[EnvObs, Dict]: obs, info = super().reset(seed=seed, options=options) - if hasattr(self, "episode_obs_list"): self.episode_obs_list = [obs] self.episode_action_list = [] @@ -377,9 +376,7 @@ def step( ) -> Tuple[EnvObs, torch.Tensor, torch.Tensor, torch.Tensor, Dict[str, Any]]: # TODO: Maybe add action preprocessing manager and its functors. obs, reward, done, truncated, info = super().step(action, **kwargs) - if hasattr(self, "episode_action_list"): - self.episode_obs_list.append(obs) self.episode_action_list.append(action) @@ -623,35 +620,38 @@ def _to_lerobot_dataset(self) -> str | None: ) action_list = self.episode_action_list - logger.log_info(f"Saving episode with {len(obs_list)} frames...") + logger.log_info(f"Saving {self.num_envs} episodes with {len(obs_list)} frames each...") # Get task instruction task = self.metadata["dataset"]["instruction"].get("lang", "unknown_task") - # Add frames to dataset - for obs, action in zip(obs_list, action_list): - frame = self.data_handler._convert_frame_to_lerobot(obs, action, task) - self.dataset.add_frame(frame) - - # Save episode - extra_info = self.cfg.dataset.get("extra", {}) - total_frames = self.dataset.meta.info.get("total_frames", 0) + len(obs_list) - fps = self.dataset.meta.info.get("fps", 30) - total_time = total_frames / fps if fps > 0 else 0 - - extra_info = self.cfg.dataset.get("extra", {}) - extra_info["total_time"] = total_time - extra_info["data_type"] = "sim" - - self.update_dataset_info({"extra": extra_info}) - - self.dataset.save_episode() + # Process each environment as a separate episode + for env_idx in range(self.num_envs): + # Add frames for this specific environment + for obs, action in zip(obs_list, action_list): + frames = self.data_handler._convert_frame_to_lerobot(obs, action, task) + # Only add the frame for this specific environment + self.dataset.add_frame(frames[env_idx]) + + # Save episode for this environment + extra_info = self.cfg.dataset.get("extra", {}) + fps = self.dataset.meta.info.get("fps", 30) + total_time = len(obs_list) / fps if fps > 0 else 0 + + episode_extra_info = extra_info.copy() + episode_extra_info["total_time"] = total_time + episode_extra_info["data_type"] = "sim" + episode_extra_info["env_index"] = env_idx + + self.update_dataset_info({"extra": episode_extra_info}) + self.dataset.save_episode() + + logger.log_info( + f"Saved episode {self.curr_episode} for environment {env_idx} with {len(obs_list)} frames" + ) + self.curr_episode += 1 dataset_path = str(self.dataset.root) - logger.log_info( - f"Successfully saved episode {self.curr_episode} to {dataset_path}" - ) - self.curr_episode += 1 return dataset_path diff --git a/embodichain/lab/scripts/run_env.py b/embodichain/lab/scripts/run_env.py index d5c3b647..3864ede2 100644 --- a/embodichain/lab/scripts/run_env.py +++ b/embodichain/lab/scripts/run_env.py @@ -191,8 +191,8 @@ def main(args, env, gym_config): args = parser.parse_args() - if args.num_envs != 1: - log_error(f"Currently only support num_envs=1, but got {args.num_envs}.") + # if args.num_envs != 1: + # log_error(f"Currently only support num_envs=1, but got {args.num_envs}.") gym_config = load_json(args.gym_config) cfg: EmbodiedEnvCfg = config_to_cfg(gym_config) From 820d1194176571e2d972e204ffef76bb529f6bf2 Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Wed, 7 Jan 2026 17:53:15 +0800 Subject: [PATCH 16/26] dataset manager --- configs/gym/pour_water/gym_config.json | 1 + embodichain/lab/gym/envs/base_env.py | 4 +- embodichain/lab/gym/envs/embodied_env.py | 374 ++++--------- embodichain/lab/gym/envs/managers/__init__.py | 3 +- embodichain/lab/gym/envs/managers/cfg.py | 34 ++ .../lab/gym/envs/managers/dataset_manager.py | 504 ++++++++++++++++++ embodichain/lab/scripts/run_env.py | 19 +- 7 files changed, 661 insertions(+), 278 deletions(-) create mode 100644 embodichain/lab/gym/envs/managers/dataset_manager.py diff --git a/configs/gym/pour_water/gym_config.json b/configs/gym/pour_water/gym_config.json index 24b0e2e2..34c14fb6 100644 --- a/configs/gym/pour_water/gym_config.json +++ b/configs/gym/pour_water/gym_config.json @@ -258,6 +258,7 @@ } }, "dataset": { + "save_path": "/home/dex/datasets/embodichain/outputs/data_example", "robot_meta": { "robot_type": "CobotMagic", "arm_dofs": 12, diff --git a/embodichain/lab/gym/envs/base_env.py b/embodichain/lab/gym/envs/base_env.py index 1b2e5b5a..201d03bc 100644 --- a/embodichain/lab/gym/envs/base_env.py +++ b/embodichain/lab/gym/envs/base_env.py @@ -380,7 +380,7 @@ def get_info(self, **kwargs) -> Dict[str, Any]: info.update(self.evaluate(**kwargs)) return info - def check_truncated(self, obs: EnvObs, info: Dict[str, Any]) -> bool: + def check_truncated(self, obs: EnvObs, info: Dict[str, Any]) -> torch.Tensor: """Check if the episode is truncated. Args: @@ -388,7 +388,7 @@ def check_truncated(self, obs: EnvObs, info: Dict[str, Any]) -> bool: info: The info dictionary. Returns: - True if the episode is truncated, False otherwise. + A boolean tensor indicating truncation for each environment in the batch. """ return torch.zeros(self.num_envs, dtype=torch.bool, device=self.device) diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index 8552c0fc..e12d2505 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -20,7 +20,7 @@ import gymnasium as gym from dataclasses import MISSING -from typing import Dict, Union, Sequence, Tuple, Any, List +from typing import Dict, Union, Sequence, Tuple, Any, List, Optional from embodichain.lab.sim.cfg import ( RobotCfg, @@ -42,6 +42,7 @@ from embodichain.lab.gym.envs.managers import ( EventManager, ObservationManager, + DatasetManager, ) from embodichain.lab.gym.utils.registration import register_env from embodichain.utils import configclass, logger @@ -90,9 +91,10 @@ class EnvLightCfg: Please refer to the :class:`embodichain.lab.gym.managers.ObservationManager` class for more details. """ - # TODO: This would be changed to a more generic data pipeline configuration. - dataset: Union[Dict[str, Any], None] = None - """Data pipeline configuration. Defaults to None. + dataset: Union[object, None] = None + """Dataset settings. Defaults to None, in which case no dataset collection is performed. + + Please refer to the :class:`embodichain.lab.gym.managers.DatasetManager` class for more details. """ # Some helper attributes @@ -131,21 +133,7 @@ class EmbodiedEnv(BaseEnv): def __init__(self, cfg: EmbodiedEnvCfg, **kwargs): self.affordance_datas = {} self.action_bank = None - self.dataset = None # LeRobotDataset instance for data management - self.data_handler = None # LerobotDataHandler instance for data conversion - - # Check if lerobot is available and set default format accordingly - try: - import lerobot - - self._default_dataset_format = "lerobot" - except ImportError: - self.cfg.dataset["format"] = "hdf5" - self._default_dataset_format = "hdf5" - logger.log_warning( - "LeRobot not available. Default dataset format set to 'hdf5'. " - "Install lerobot to enable lerobot format: pip install lerobot" - ) + self._force_truncated: bool = False extensions = getattr(cfg, "extensions", {}) or {} @@ -179,23 +167,21 @@ def _init_sim_state(self, **kwargs): if self.cfg.observations: self.observation_manager = ObservationManager(self.cfg.observations, self) + + # create dataset manager + if self.cfg.dataset: + from embodichain.lab.gym.envs.managers.cfg import DatasetCfg + + # Convert config dict to DatasetCfg if needed + if isinstance(self.cfg.dataset, dict): + dataset_cfg = DatasetCfg(**self.cfg.dataset) + else: + dataset_cfg = self.cfg.dataset + + self.dataset_manager = DatasetManager(dataset_cfg, self) + logger.log_info("DatasetManager initialized for episode recording") - # TODO: A workaround for handling dataset saving, which need history data of obs-action pairs. - # We may improve this by implementing a data manager to handle data saving and online streaming. - if self.cfg.dataset is not None: - # Get dataset format from dataset config, use instance default if not specified - dataset_format = self.cfg.dataset.get( - "format", self._default_dataset_format - ) - - self.metadata["dataset"] = self.cfg.dataset - self.episode_obs_list = [] - self.episode_action_list = [] - self.curr_episode = 0 - - # Initialize based on dataset format - if dataset_format == "lerobot": - self._initialize_lerobot_dataset() + self.metadata["dataset"] = dataset_cfg.__dict__ def _apply_functor_filter(self) -> None: """Apply functor filters to the environment components based on configuration. @@ -221,92 +207,6 @@ def _apply_functor_filter(self) -> None: ) setattr(self.cfg.events, attr_name, None) - def _initialize_lerobot_dataset(self) -> None: - """Initialize LeRobotDataset for episode recording. - - This method creates a LeRobotDataset instance that will be used throughout - the environment's lifetime for recording and managing episode data. - """ - try: - from lerobot.datasets.lerobot_dataset import LeRobotDataset - from embodichain.data.handler.lerobot_data_handler import LerobotDataHandler - except ImportError as e: - logger.log_error( - f"Failed to import LeRobot dependencies: {e}. " - "Dataset recording will be disabled. Install with: pip install lerobot" - ) - return - - # Extract naming components from config - robot_type = self.cfg.dataset.get("robot_meta", {}).get("robot_type", "robot") - scene_type = self.cfg.dataset.get("extra", {}).get("scene_type", "scene") - task_description = self.cfg.dataset.get("extra", {}).get( - "task_description", "task" - ) - - robot_type = str(robot_type).lower().replace(" ", "_") - task_description = str(task_description).lower().replace(" ", "_") - - # Determine lerobot data root directory - lerobot_data_root = self.cfg.dataset.get("save_path", None) - if lerobot_data_root is None: - try: - from lerobot.utils.constants import HF_LEROBOT_HOME - - lerobot_data_root = HF_LEROBOT_HOME - except ImportError: - logger.log_error("LeRobot not installed.") - return - - # Auto-increment id until the repo_id subdirectory does not exist - base_id = int(self.cfg.dataset.get("id", "0")) - while True: - dataset_id = f"{base_id:03d}" - repo_id = f"{scene_type}_{robot_type}_{task_description}_{dataset_id}" - repo_path = os.path.join(lerobot_data_root, repo_id) - if not os.path.exists(repo_path): - break - base_id += 1 - - # Store computed values back to config - self.cfg.dataset["repo_id"] = repo_id - self.cfg.dataset["id"] = dataset_id - self.cfg.dataset["lerobot_data_root"] = str(lerobot_data_root) - - # Get dataset configuration - dataset_cfg = self.cfg.dataset - fps = dataset_cfg["robot_meta"].get("control_freq", 30) - use_videos = dataset_cfg.get("use_videos", True) - image_writer_threads = dataset_cfg.get("image_writer_threads", 4) - image_writer_processes = dataset_cfg.get("image_writer_processes", 0) - - # Create handler instance (reusable for all episodes) - self.data_handler = LerobotDataHandler(self) - - # Build features using handler - features = self.data_handler._build_lerobot_features(use_videos=use_videos) - - robot_type = self.cfg.dataset.get("robot_meta", {}).get("robot_type", "robot") - - dataset_dir = os.path.join(lerobot_data_root, repo_id) - - try: - logger.log_info(f"Creating new LeRobot dataset at {dataset_dir}") - self.dataset = LeRobotDataset.create( - repo_id=repo_id, - robot_type=robot_type, - fps=fps, - features=features, - use_videos=use_videos, - image_writer_threads=image_writer_threads, - image_writer_processes=image_writer_processes, - root=str(dataset_dir), - ) - logger.log_info(f"LeRobotDataset initialized successfully: {repo_id}") - except Exception as e: - logger.log_error(f"Failed to initialize LeRobotDataset: {e}") - self.dataset = None - def _init_action_bank( self, action_bank_cls: ActionBank, action_config: Dict[str, Any] ): @@ -361,26 +261,85 @@ def get_affordance(self, key: str, default: Any = None): """ return self.affordance_datas.get(key, default) + def set_force_truncated(self, value: bool = True): + """ + Set force_truncated flag to trigger episode truncation. + """ + self._force_truncated = value + def reset( self, seed: int | None = None, options: dict | None = None ) -> Tuple[EnvObs, Dict]: obs, info = super().reset(seed=seed, options=options) - if hasattr(self, "episode_obs_list"): - self.episode_obs_list = [obs] - self.episode_action_list = [] + self._force_truncated: bool = False + + # Reset dataset manager if present + if hasattr(self, 'dataset_manager'): + reset_ids = options.get("reset_ids", None) if options else None + self.dataset_manager.reset(reset_ids) return obs, info def step( self, action: EnvAction, **kwargs ) -> Tuple[EnvObs, torch.Tensor, torch.Tensor, torch.Tensor, Dict[str, Any]]: - # TODO: Maybe add action preprocessing manager and its functors. - obs, reward, done, truncated, info = super().step(action, **kwargs) - if hasattr(self, "episode_action_list"): - self.episode_obs_list.append(obs) - self.episode_action_list.append(action) + """Step the environment with the given action. + + Extends BaseEnv.step() to integrate with DatasetManager for automatic + data collection and saving. The key is to: + 1. Record obs-action pairs as they happen + 2. Detect episode completion + 3. Auto-save episodes BEFORE reset + 4. Then perform the actual reset + """ + self._elapsed_steps += 1 + + action = self._step_action(action=action) + self.sim.update(self.sim_cfg.physics_dt, self.cfg.sim_steps_per_control) + self._update_sim_state(**kwargs) + + obs = self.get_obs(**kwargs) + info = self.get_info(**kwargs) + rewards = self.get_reward(obs=obs, action=action, info=info) + + # Record step in dataset manager BEFORE checking termination + if hasattr(self, 'dataset_manager'): + self.dataset_manager.record_step(obs, action) + + # Check termination conditions + terminateds = torch.logical_or( + info.get( + "success", + torch.zeros(self.num_envs, dtype=torch.bool, device=self.device), + ), + info.get( + "fail", torch.zeros(self.num_envs, dtype=torch.bool, device=self.device) + ), + ) + truncateds = self.check_truncated(obs=obs, info=info) + if self.cfg.ignore_terminations: + terminateds[:] = False + + # Detect which environments need reset + dones = torch.logical_or(terminateds, truncateds) + reset_env_ids = dones.nonzero(as_tuple=False).squeeze(-1) + + # AUTO-SAVE: Trigger dataset manager to save completed episodes + if len(reset_env_ids) > 0 and hasattr(self, 'dataset_manager'): + logger.log_info(f"Calling dataset_manager.on_episode_end() for {reset_env_ids.cpu().tolist()}") + self.dataset_manager.on_episode_end( + reset_env_ids, + terminateds, + info + ) + elif len(reset_env_ids) > 0: + logger.log_warning("Episode completed but no dataset_manager found!") + + # Now perform reset for completed environments + if len(reset_env_ids) > 0: + obs, _ = self.reset(options={"reset_ids": reset_env_ids}) - return obs, reward, done, truncated, info + return obs, rewards, terminateds, truncateds, info def _extend_obs(self, obs: EnvObs, **kwargs) -> EnvObs: if self.observation_manager: @@ -558,153 +517,38 @@ def create_demo_action_list(self, *args, **kwargs) -> Sequence[EnvAction] | None "The method 'create_demo_action_list' must be implemented in subclasses." ) - def to_dataset(self, id: str = None) -> str | None: - """Convert the recorded episode data to dataset format. - - This method can be overridden in subclasses to support different formats (e.g., hdf5). - The base class only supports LeRobot format. - - Args: - id (str, optional): Unique identifier for the dataset (may be used by subclasses). - - Returns: - str | None: The path to the saved dataset, or None if failed. - """ - if not hasattr(self, "episode_obs_list") or not hasattr( - self, "episode_action_list" - ): - logger.log_error( - "Episode data not available. Make sure dataset configuration is set in the environment config." - ) - return None - - if len(self.episode_obs_list) == 0: - logger.log_error( - "No episode data to save. Episode observation list is empty." - ) - return None - - # Route to appropriate save method based on dataset format - dataset_format = self.cfg.dataset.get("format", self._default_dataset_format) - if dataset_format == "lerobot": - return self._to_lerobot_dataset() - else: - logger.log_error(f"Unsupported dataset format: {dataset_format}") - return None - - def _to_lerobot_dataset(self) -> str | None: - """Convert the recorded episode data to LeRobot dataset format. - - Returns: - str | None: The path to the saved dataset, or None if failed. + def is_task_success(self, **kwargs) -> torch.Tensor: """ - # Check if dataset was initialized - if self.dataset is None: - logger.log_error( - "LeRobotDataset not initialized. Make sure dataset configuration is properly set." - ) - return None - - # Check if data handler was initialized - if self.data_handler is None: - logger.log_error( - "Data handler not initialized. Make sure dataset configuration is properly set." - ) - return None - - # Prepare obs_list and action_list (remove last obs as it has no corresponding action) - obs_list = ( - self.episode_obs_list[:-1] - if len(self.episode_obs_list) > len(self.episode_action_list) - else self.episode_obs_list - ) - action_list = self.episode_action_list - - logger.log_info(f"Saving {self.num_envs} episodes with {len(obs_list)} frames each...") - - # Get task instruction - task = self.metadata["dataset"]["instruction"].get("lang", "unknown_task") - - # Process each environment as a separate episode - for env_idx in range(self.num_envs): - # Add frames for this specific environment - for obs, action in zip(obs_list, action_list): - frames = self.data_handler._convert_frame_to_lerobot(obs, action, task) - # Only add the frame for this specific environment - self.dataset.add_frame(frames[env_idx]) - - # Save episode for this environment - extra_info = self.cfg.dataset.get("extra", {}) - fps = self.dataset.meta.info.get("fps", 30) - total_time = len(obs_list) / fps if fps > 0 else 0 - - episode_extra_info = extra_info.copy() - episode_extra_info["total_time"] = total_time - episode_extra_info["data_type"] = "sim" - episode_extra_info["env_index"] = env_idx - - self.update_dataset_info({"extra": episode_extra_info}) - self.dataset.save_episode() - - logger.log_info( - f"Saved episode {self.curr_episode} for environment {env_idx} with {len(obs_list)} frames" - ) - self.curr_episode += 1 - - dataset_path = str(self.dataset.root) - - return dataset_path - - def update_dataset_info(self, updates: dict) -> bool: - """Update the LeRobot dataset's meta.info with custom key-value pairs. + Determine if the task is successfully completed. This is mainly used in the data generation process + of the imitation learning. Args: - updates (dict): Dictionary of key-value pairs to add or update in meta.info. + **kwargs: Additional arguments for task-specific success criteria. Returns: - bool: True if successful, False otherwise. - - Example: - >>> env.update_dataset_info({ - ... "author": "DexForce", - ... "date_collected": "2025-12-22", - ... "custom_key": "custom_value" - ... }) + torch.Tensor: A boolean tensor indicating success for each environment in the batch. """ - if self.dataset is None: - logger.log_error( - "LeRobotDataset not initialized. Cannot update dataset info." - ) - return False - - try: - self.dataset.meta.info.update(updates) - logger.log_info( - f"Successfully updated dataset info with keys: {list(updates.keys())}" - ) - return True - except Exception as e: - logger.log_error(f"Failed to update dataset info: {e}") - return False - def is_task_success(self, **kwargs) -> torch.Tensor: - """Determine if the task is successfully completed. This is mainly used in the data generation process - of the imitation learning. + return torch.ones(self.num_envs, dtype=torch.bool, device=self.device) + + def check_truncated(self, obs: EnvObs, info: Dict[str, Any]) -> torch.Tensor: + """Check if the episode is truncated. Args: - **kwargs: Additional arguments for task-specific success criteria. + obs: The observation from the environment. + info: The info dictionary. Returns: - torch.Tensor: A boolean tensor indicating success for each environment in the batch. + A boolean tensor indicating truncation for each environment in the batch. """ - - return torch.ones(self.num_envs, dtype=torch.bool, device=self.device) + if self._force_truncated: + return torch.ones(self.num_envs, dtype=torch.bool, device=self.device) + return super().check_truncated(obs, info) def close(self) -> None: """Close the environment and release resources.""" - self.sim.destroy() - - # Only finalize dataset if using lerobot format - dataset_format = self.cfg.dataset.get("format", self._default_dataset_format) - if dataset_format == "lerobot": - self.dataset.finalize() + # Finalize dataset if present + if hasattr(self, 'dataset_manager'): + self.dataset_manager.finalize() + + self.sim.destroy() \ No newline at end of file diff --git a/embodichain/lab/gym/envs/managers/__init__.py b/embodichain/lab/gym/envs/managers/__init__.py index 946165a8..164c594a 100644 --- a/embodichain/lab/gym/envs/managers/__init__.py +++ b/embodichain/lab/gym/envs/managers/__init__.py @@ -14,7 +14,8 @@ # limitations under the License. # ---------------------------------------------------------------------------- -from .cfg import FunctorCfg, SceneEntityCfg, EventCfg, ObservationCfg +from .cfg import FunctorCfg, SceneEntityCfg, EventCfg, ObservationCfg, DatasetCfg from .manager_base import Functor, ManagerBase from .event_manager import EventManager from .observation_manager import ObservationManager +from .dataset_manager import DatasetManager diff --git a/embodichain/lab/gym/envs/managers/cfg.py b/embodichain/lab/gym/envs/managers/cfg.py index 3f5c8da6..2b46c078 100644 --- a/embodichain/lab/gym/envs/managers/cfg.py +++ b/embodichain/lab/gym/envs/managers/cfg.py @@ -309,3 +309,37 @@ def _resolve_body_names(self, scene: SimulationManager): if isinstance(self.body_ids, int): self.body_ids = [self.body_ids] self.body_names = [entity.body_names[i] for i in self.body_ids] + + +@configclass +class DatasetCfg: + """Configuration for dataset manager. + + Attributes: + save_path: Root directory for saving datasets. If None, uses default location. + id: Dataset ID/version number. Auto-increments if directory exists. + robot_meta: Robot metadata configuration (robot type, control freq, etc.). + instruction: Task instruction configuration (lang, task description). + extra: Extra metadata (scene type, etc.). + use_videos: Whether to use video encoding for images. Defaults to True. + image_writer_threads: Number of threads for image writing. Defaults to 4. + image_writer_processes: Number of processes for image writing. Defaults to 0. + export_success_only: Whether to export only successful episodes. Defaults to False. + """ + save_path: str | None = None + id: int = 0 + robot_meta: dict[str, Any] = None + instruction: dict[str, Any] = None + extra: dict[str, Any] = None + use_videos: bool = True + image_writer_threads: int = 4 + image_writer_processes: int = 0 + export_success_only: bool = False + + def __post_init__(self): + if self.robot_meta is None: + self.robot_meta = {} + if self.instruction is None: + self.instruction = {"lang": "demo task"} + if self.extra is None: + self.extra = {"scene_type": "demo", "task_description": "demo"} diff --git a/embodichain/lab/gym/envs/managers/dataset_manager.py b/embodichain/lab/gym/envs/managers/dataset_manager.py new file mode 100644 index 00000000..2ca8a8da --- /dev/null +++ b/embodichain/lab/gym/envs/managers/dataset_manager.py @@ -0,0 +1,504 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2025 DexForce Technology Co., Ltd. +# +# 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. +# ---------------------------------------------------------------------------- + +"""Dataset manager for collecting and saving episode data.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +import numpy as np +import torch + +from embodichain.utils import logger +from embodichain.lab.sim.types import EnvObs, EnvAction +from embodichain.lab.gym.utils.misc import is_stereocam +from embodichain.utils.utility import get_right_name +from embodichain.data.enum import JointType +from .manager_base import ManagerBase +from .cfg import DatasetCfg + +if TYPE_CHECKING: + from embodichain.lab.gym.envs import EmbodiedEnv + +try: + from lerobot.datasets.lerobot_dataset import LeRobotDataset, HF_LEROBOT_HOME + LEROBOT_AVAILABLE = True +except ImportError: + LEROBOT_AVAILABLE = False + + +class DatasetManager(ManagerBase): + """Manager for collecting and saving episode data in LeRobot format. + + The dataset manager is responsible for: + - Recording observations and actions during episode rollouts + - Converting data to LeRobot format + - **Automatically saving episodes when they complete (via on_episode_end)** + - Managing dataset metadata + + The manager automatically saves episodes when: + 1. Episode completes (done=True) via on_episode_end() + 2. Environment is closed via finalize() + + Episodes are NOT saved on reset() - only on explicit episode completion. + """ + + _env: EmbodiedEnv + """The environment instance.""" + + def __init__(self, cfg: DatasetCfg, env: EmbodiedEnv): + """Initialize the dataset manager. + + Args: + cfg: Configuration for the dataset manager. + env: The environment instance. + """ + if not LEROBOT_AVAILABLE: + raise ImportError( + "LeRobot is not available. Install it with: pip install lerobot" + ) + + super().__init__(cfg, env) + + # Episode data buffers - store batched data (shared across all envs) + self.episode_obs_list: List[Dict] = [] # Single list for batched obs + self.episode_action_list: List[Any] = [] # Single list for batched actions + + # LeRobot dataset instance + self.dataset: Optional[LeRobotDataset] = None + + # Track total time across all episodes + self.total_time: float = 0.0 + + # Track current episode count + self.curr_episode: int = 0 + + # Initialize the dataset + self._initialize_dataset() + + logger.log_info( + f"DatasetManager initialized with LeRobot format at: {self.dataset_path}" + ) + + """ + Properties. + """ + + @property + def active_functors(self) -> list[str]: + """Name of active functors. + + DatasetManager doesn't use functors like other managers, + so this returns an empty list. + """ + return [] + + @property + def __str__(self) -> str: + """Returns: A string representation for dataset manager.""" + msg = " for LeRobot format\n" + msg += f" Dataset path: {self.dataset_path}\n" + msg += f" Robot type: {self.cfg.robot_meta.get('robot_type', 'unknown')}\n" + msg += f" Control freq: {self.cfg.robot_meta.get('control_freq', 30)} Hz\n" + msg += f" Export success only: {self.cfg.export_success_only}\n" + return msg + + @property + def dataset_path(self) -> str: + """Path to the dataset directory.""" + return str(Path(self.lerobot_data_root) / self.repo_id) + + def record_step(self, obs: EnvObs, action: EnvAction) -> None: + """Record a step (observation-action pair) for all environments. + + Called by the environment's step() method to record data. + Stores batched data efficiently (one copy for all environments). + + Args: + obs: Observation from the environment (batched for all envs). + action: Action applied to the environment (batched for all envs). + """ + self.episode_obs_list.append(obs) + self.episode_action_list.append(action) + + def on_episode_end( + self, + env_ids: torch.Tensor, + terminateds: torch.Tensor, + info: Dict[str, Any] + ) -> None: + """Handle episode completion and automatically save data. + + Called by the environment when episodes complete (done=True). + This is the key method that makes saving automatic! + + Args: + env_ids: Environment IDs that have completed episodes. + terminateds: Termination flags (success/fail). + info: Info dict containing success/fail information. + """ + if len(env_ids) == 0: + return + + logger.log_info(f"DatasetManager.on_episode_end() called for env_ids: {env_ids.cpu().tolist()}") + + # Auto-save completed episodes + self._auto_save_episodes( + env_ids, + terminateds=terminateds, + info=info + ) + + def finalize(self) -> Optional[str]: + """Finalize the dataset and return the save path. + + Called when the environment is closed. Saves any remaining episodes + and finalizes the dataset. + + Returns: + Path to the saved dataset, or None if failed. + """ + # Save any remaining episodes if there's data + if len(self.episode_obs_list) > 0: + # Create dummy env_ids for all environments + active_env_ids = torch.arange(self.num_envs, device=self.device) + self._auto_save_episodes(active_env_ids) + + try: + if self.dataset is not None: + self.dataset.finalize() + logger.log_info(f"Dataset finalized at: {self.dataset_path}") + return self.dataset_path + except Exception as e: + logger.log_error(f"Failed to finalize dataset: {e}") + + return None + + def _reset_buffer(self) -> None: + """(Internal) Reset episode buffers (clears all batched data).""" + self.episode_obs_list.clear() + self.episode_action_list.clear() + logger.log_info("Reset buffers (cleared all batched data)") + + def _auto_save_episodes( + self, + env_ids: torch.Tensor, + terminateds: Optional[torch.Tensor] = None, + info: Optional[Dict[str, Any]] = None + ) -> None: + """Automatically save episodes for specified environments. + + This is the core auto-save logic that runs without manual intervention. + Processes batched data and saves each environment as a separate episode. + + Args: + env_ids: Environment IDs to save. + terminateds: Termination flags (for determining success). + info: Info dict containing success information. + """ + # Check if episode has data + if len(self.episode_obs_list) == 0: + logger.log_warning("No episode data to save") + return + + obs_list = self.episode_obs_list + action_list = self.episode_action_list + + # Align obs and action (remove last obs if needed) + if len(obs_list) > len(action_list): + obs_list = obs_list[:-1] + + # Get task description + task = self.cfg.instruction.get("lang", "unknown_task") + + # Prepare extra info (calculate total time for all episodes) + extra_info = self.cfg.extra.copy() if self.cfg.extra else {} + fps = self.dataset.meta.info.get("fps", 30) + current_episode_time = (len(obs_list) * len(env_ids)) / fps if fps > 0 else 0 + + episode_extra_info = extra_info.copy() + self.total_time += current_episode_time + episode_extra_info["total_time"] = self.total_time + episode_extra_info["data_type"] = "sim" + self.update_dataset_info({"extra": episode_extra_info}) + + # Process each environment as a separate episode + for env_id in env_ids.cpu().tolist(): + # Determine if episode was successful + is_success = False + if info is not None and 'success' in info: + success_tensor = info['success'] + if isinstance(success_tensor, torch.Tensor): + is_success = success_tensor[env_id].item() + else: + is_success = success_tensor + elif terminateds is not None: + is_success = terminateds[env_id].item() + + # Skip failed episodes if configured + logger.log_info(f"Episode {env_id} success: {is_success}") + if self.cfg.export_success_only and not is_success: + logger.log_info(f"Skipping failed episode for env {env_id}") + continue + + # Convert and save episode + try: + # Add frames for this specific environment + for obs, action in zip(obs_list, action_list): + frame = self._convert_frame_to_lerobot(obs, action, task, env_id) + self.dataset.add_frame(frame) + + # Save episode for this environment + self.dataset.save_episode() + + status = "successful" if is_success else "failed" + logger.log_info( + f"Auto-saved {status} episode {self.curr_episode} for env {env_id} with {len(obs_list)} frames" + ) + self.curr_episode += 1 + + except Exception as e: + logger.log_error(f"Failed to auto-save episode {env_id}: {e}") + + # Clear buffer after saving all episodes + self._reset_buffer() + + def _initialize_dataset(self) -> None: + """Initialize the LeRobot dataset.""" + # Extract naming components from config + robot_type = self.cfg.robot_meta.get("robot_type", "robot") + scene_type = self.cfg.extra.get("scene_type", "scene") + task_description = self.cfg.extra.get("task_description", "task") + + robot_type = str(robot_type).lower().replace(" ", "_") + task_description = str(task_description).lower().replace(" ", "_") + + # Determine lerobot data root directory + lerobot_data_root = self.cfg.save_path + if lerobot_data_root is None: + lerobot_data_root = Path(HF_LEROBOT_HOME) + else: + lerobot_data_root = Path(lerobot_data_root) + + # Auto-increment id until the repo_id subdirectory does not exist + dataset_id = self.cfg.id + while True: + repo_id = f"{robot_type}_{scene_type}_{task_description}_v{dataset_id}" + dataset_dir = lerobot_data_root / repo_id + if not dataset_dir.exists(): + break + dataset_id += 1 + + # Store computed values + self.repo_id = repo_id + self.dataset_id = dataset_id + self.lerobot_data_root = str(lerobot_data_root) + + # Get dataset configuration + fps = self.cfg.robot_meta.get("control_freq", 30) + use_videos = self.cfg.use_videos + image_writer_threads = self.cfg.image_writer_threads + image_writer_processes = self.cfg.image_writer_processes + + # Build features + features = self._build_features() + + try: + # Try to create new dataset + self.dataset = LeRobotDataset.create( + repo_id=repo_id, + fps=fps, + root=str(lerobot_data_root), + robot_type=robot_type, + features=features, + use_videos=use_videos, + image_writer_threads=image_writer_threads, + image_writer_processes=image_writer_processes, + ) + logger.log_info(f"Created LeRobot dataset at: {lerobot_data_root / repo_id}") + except FileExistsError: + # Dataset already exists, load it instead + logger.log_info(f"Dataset {repo_id} already exists at {lerobot_data_root}, loading it...") + self.dataset = LeRobotDataset( + repo_id=repo_id, + root=str(lerobot_data_root), + ) + logger.log_info(f"Loaded existing LeRobot dataset at: {lerobot_data_root / repo_id}") + except Exception as e: + logger.log_error(f"Failed to create/load LeRobot dataset: {e}") + raise + + def _build_features(self) -> Dict: + """Build LeRobot features dict from environment metadata.""" + features = {} + extra_vision_config = self.cfg.robot_meta.get("observation", {}).get("vision", {}) + + # Add image features + for camera_name in extra_vision_config.keys(): + sensor = self._env.get_sensor(camera_name) + is_stereo = is_stereocam(sensor) + + # Get image shape from sensor + img_shape = (sensor.cfg.height, sensor.cfg.width, 3) + + features[camera_name] = { + "dtype": "video" if self.cfg.use_videos else "image", + "shape": img_shape, + "names": ["height", "width", "channel"], + } + + if is_stereo: + features[get_right_name(camera_name)] = { + "dtype": "video" if self.cfg.use_videos else "image", + "shape": img_shape, + "names": ["height", "width", "channel"], + } + + # Add state features (proprio) + qpos = self._env.robot.get_qpos() + state_dim = qpos.shape[1] + + if state_dim > 0: + features["observation.state"] = { + "dtype": "float32", + "shape": (state_dim,), + "names": ["state"], + } + + # Add action features + action_dim = self.cfg.robot_meta.get("arm_dofs", 7) + features["action"] = { + "dtype": "float32", + "shape": (action_dim,), + "names": ["action"], + } + + return features + + def _convert_frame_to_lerobot( + self, obs: Dict[str, Any], action: Any, task: str, env_id: int + ) -> Dict: + """Convert a single frame from one environment to LeRobot format. + + Args: + obs: Observation dict from environment (batched). + action: Action from environment (batched). + task: Task description. + env_id: Environment index to extract data for. + + Returns: + Single frame in LeRobot format for the specified environment. + """ + frame = {"task": task} + + extra_vision_config = self.cfg.robot_meta.get("observation", {}).get("vision", {}) + arm_dofs = self.cfg.robot_meta.get("arm_dofs", 7) + + # Add images + for camera_name in extra_vision_config.keys(): + if camera_name in obs.get("sensor", {}): + sensor = self._env.get_sensor(camera_name) + is_stereo = is_stereocam(sensor) + + # Process left/main camera image + color_data = obs["sensor"][camera_name]["color"] + if isinstance(color_data, torch.Tensor): + color_img = color_data[env_id][:, :, :3].cpu().numpy() + else: + color_img = np.array(color_data)[env_id][:, :, :3] + + # Ensure uint8 format (0-255 range) + if color_img.dtype in [np.float32, np.float64]: + color_img = (color_img * 255).astype(np.uint8) + + frame[camera_name] = color_img + + # Process right camera image if stereo + if is_stereo: + color_right_data = obs["sensor"][camera_name]["color_right"] + if isinstance(color_right_data, torch.Tensor): + color_right_img = color_right_data[env_id][:, :, :3].cpu().numpy() + else: + color_right_img = np.array(color_right_data)[env_id][:, :, :3] + + if color_right_img.dtype in [np.float32, np.float64]: + color_right_img = (color_right_img * 255).astype(np.uint8) + + frame[get_right_name(camera_name)] = color_right_img + + # Add state (proprio) + qpos = obs["robot"][JointType.QPOS.value] + if isinstance(qpos, torch.Tensor): + state_data = qpos[env_id].cpu().numpy().astype(np.float32) + else: + state_data = np.array(qpos)[env_id].astype(np.float32) + + frame["observation.state"] = state_data + + # Add action + if isinstance(action, torch.Tensor): + action_data = action[env_id, :arm_dofs].cpu().numpy() + elif isinstance(action, np.ndarray): + action_data = action[env_id, :arm_dofs] + elif isinstance(action, dict): + action_data = action.get("action", action.get("arm_action", action)) + if isinstance(action_data, torch.Tensor): + action_data = action_data[env_id, :arm_dofs].cpu().numpy() + elif isinstance(action_data, np.ndarray): + action_data = action_data[env_id, :arm_dofs] + else: + action_data = np.array(action)[env_id, :arm_dofs] + + frame["action"] = action_data + + return frame + + def update_dataset_info(self, updates: dict) -> bool: + """Update the LeRobot dataset's meta.info with custom key-value pairs. + + Args: + updates: Dictionary of key-value pairs to add or update in meta.info. + + Returns: + True if successful, False otherwise. + + Example: + >>> dataset_manager.update_dataset_info({ + ... "author": "DexForce", + ... "date_collected": "2025-12-22", + ... "custom_key": "custom_value" + ... }) + """ + if self.dataset is None: + logger.log_error( + "LeRobotDataset not initialized. Cannot update dataset info." + ) + return False + + try: + self.dataset.meta.info.update(updates) + logger.log_info( + f"Successfully updated dataset info with keys: {list(updates.keys())}" + ) + return True + except Exception as e: + logger.log_error(f"Failed to update dataset info: {e}") + return False + + def _prepare_functors(self): + pass diff --git a/embodichain/lab/scripts/run_env.py b/embodichain/lab/scripts/run_env.py index 3864ede2..10203eb6 100644 --- a/embodichain/lab/scripts/run_env.py +++ b/embodichain/lab/scripts/run_env.py @@ -40,14 +40,16 @@ def generate_and_execute_action_list(env, idx, debug_mode): log_warning("Action is invalid. Skip to next generation.") return False - for action in tqdm.tqdm( + for idx_action, action in enumerate(tqdm.tqdm( action_list, desc=f"Executing action list #{idx}", unit="step" - ): + )): + if idx_action == len(action_list) - 1: + log_info(f"Setting force_truncated before final step at action index: {idx_action}") + env.set_force_truncated(True) + # Step the environment with the current action obs, reward, terminated, truncated, info = env.step(action) - # TODO: May be add some functions for debug_mode - # TODO: We may assume in export demonstration rollout, there is no truncation from the env. # but truncation is useful to improve the generation efficiency. @@ -84,22 +86,19 @@ def generate_function( valid = True while True: - _, _ = env.reset() + # _, _ = env.reset() ret = [] for trajectory_idx in range(num_traj): valid = generate_and_execute_action_list(env, trajectory_idx, debug_mode) if not valid: + _, _ = env.reset() break if not debug_mode and env.is_task_success().item(): - dataset_id = f"time_{time_id}_trajectory_{trajectory_idx}" - data_dict = env.to_dataset() - ret.append(data_dict) - + pass # TODO: Add data saving and online data streaming logic here. - else: log_warning(f"Task fail, Skip to next generation.") valid = False From 463c8d483dad17918800c83741c22c51ba7f69a5 Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Thu, 8 Jan 2026 11:37:19 +0800 Subject: [PATCH 17/26] refactor dataset functor --- configs/gym/pour_water/gym_config.json | 58 +- configs/gym/pour_water/gym_config_simple.json | 54 +- .../data/handler/lerobot_data_handler.py | 208 ------ embodichain/lab/gym/envs/embodied_env.py | 62 +- embodichain/lab/gym/envs/managers/__init__.py | 9 +- embodichain/lab/gym/envs/managers/cfg.py | 38 +- .../lab/gym/envs/managers/dataset_manager.py | 675 +++++++----------- embodichain/lab/gym/envs/managers/datasets.py | 429 +++++++++++ embodichain/lab/gym/utils/gym_utils.py | 28 +- embodichain/lab/scripts/run_env.py | 12 +- 10 files changed, 813 insertions(+), 760 deletions(-) delete mode 100644 embodichain/data/handler/lerobot_data_handler.py create mode 100644 embodichain/lab/gym/envs/managers/datasets.py diff --git a/configs/gym/pour_water/gym_config.json b/configs/gym/pour_water/gym_config.json index 34c14fb6..b9cd4a46 100644 --- a/configs/gym/pour_water/gym_config.json +++ b/configs/gym/pour_water/gym_config.json @@ -1,6 +1,6 @@ { "id": "PourWater-v3", - "max_episodes": 5, + "max_episodes": 10, "env": { "events": { "random_light": { @@ -258,30 +258,40 @@ } }, "dataset": { - "save_path": "/home/dex/datasets/embodichain/outputs/data_example", - "robot_meta": { - "robot_type": "CobotMagic", - "arm_dofs": 12, - "control_freq": 25, - "control_parts": ["left_arm", "left_eef", "right_arm", "right_eef"], - "observation": { - "vision": { - "cam_high": ["mask"], - "cam_right_wrist": ["mask"], - "cam_left_wrist": ["mask"] + "lerobot": { + "func": "LeRobotRecorder", + "mode": "save", + "params": { + "save_path": "/home/dex/projects/yuanhaonan/embodichain/outputs/data_example", + "id": 0, + "robot_meta": { + "robot_type": "CobotMagic", + "arm_dofs": 12, + "control_freq": 25, + "control_parts": ["left_arm", "left_eef", "right_arm", "right_eef"], + "observation": { + "vision": { + "cam_high": ["mask"], + "cam_right_wrist": ["mask"], + "cam_left_wrist": ["mask"] + }, + "states": ["qpos"], + "exteroception": ["cam_high", "cam_right_wrist", "cam_left_wrist"] + }, + "action": "qpos_with_eef_pose", + "min_len_steps": 5 }, - "states": ["qpos"], - "exteroception": ["cam_high", "cam_right_wrist", "cam_left_wrist"] - }, - "action": "qpos_with_eef_pose", - "min_len_steps": 5 - }, - "instruction": { - "lang": "Pour water from bottle to cup" - }, - "extra": { - "scene_type": "Commercial", - "task_description": "Pour water" + "instruction": { + "lang": "Pour water from bottle to cup" + }, + "extra": { + "scene_type": "Commercial", + "task_description": "Pour water", + "data_type": "sim" + }, + "use_videos": true, + "export_success_only": false + } } } }, diff --git a/configs/gym/pour_water/gym_config_simple.json b/configs/gym/pour_water/gym_config_simple.json index bdaf1b7d..1ddbad9a 100644 --- a/configs/gym/pour_water/gym_config_simple.json +++ b/configs/gym/pour_water/gym_config_simple.json @@ -196,28 +196,40 @@ } }, "dataset": { - "format": "lerobot", - "save_path": "./", - "extra": { - "scene_type": "commercial", - "task_description": "Pour water" - }, - "robot_meta": { - "robot_type": "CobotMagic", - "arm_dofs": 12, - "control_freq": 5, - "control_parts": ["left_arm", "left_eef", "right_arm", "right_eef"], - "observation": { - "vision": { - "cam_high": [] + "lerobot": { + "func": "LeRobotRecorder", + "mode": "save", + "params": { + "save_path": "/home/dex/projects/yuanhaonan/embodichain/outputs/data_example", + "id": 0, + "robot_meta": { + "robot_type": "CobotMagic", + "arm_dofs": 12, + "control_freq": 25, + "control_parts": ["left_arm", "left_eef", "right_arm", "right_eef"], + "observation": { + "vision": { + "cam_high": ["mask"], + "cam_right_wrist": ["mask"], + "cam_left_wrist": ["mask"] + }, + "states": ["qpos"], + "exteroception": ["cam_high", "cam_right_wrist", "cam_left_wrist"] + }, + "action": "qpos_with_eef_pose", + "min_len_steps": 5 }, - "states": ["qpos"] - }, - "action": "qpos_with_eef_pose", - "min_len_steps": 5 - }, - "instruction": { - "lang": "Pour water from bottle to cup" + "instruction": { + "lang": "Pour water from bottle to cup" + }, + "extra": { + "scene_type": "Commercial", + "task_description": "Pour water", + "data_type": "sim" + }, + "use_videos": true, + "export_success_only": false + } } } }, diff --git a/embodichain/data/handler/lerobot_data_handler.py b/embodichain/data/handler/lerobot_data_handler.py deleted file mode 100644 index fa04152d..00000000 --- a/embodichain/data/handler/lerobot_data_handler.py +++ /dev/null @@ -1,208 +0,0 @@ -from typing import Dict, Any, List, Union, Optional -from copy import deepcopy -from pathlib import Path -import traceback - -import numpy as np -import torch - -from embodichain.lab.gym.envs import EmbodiedEnv -from embodichain.data.enum import ( - HandQposNormalizer, - Modality, - JointType, -) -from embodichain.utils.utility import get_right_name -from embodichain.lab.gym.utils.misc import is_stereocam, data_key_to_control_part -from embodichain.utils import logger -from embodichain.data.enum import ( - SUPPORTED_PROPRIO_TYPES, - SUPPORTED_ACTION_TYPES, -) -from tqdm import tqdm - -# Optional LeRobot imports (for convert to lerobot format functionality) -from lerobot.datasets.lerobot_dataset import LeRobotDataset, HF_LEROBOT_HOME - - -class LerobotDataHandler: - def __init__( - self, - env: EmbodiedEnv, - ): - self.env = env - - def _build_lerobot_features(self, use_videos: bool = True) -> Dict: - """ - Build LeRobot features dict from environment metadata. - - Args: - use_videos (bool): Whether to use video encoding. Defaults to True. - - Returns: - Dict: Features dictionary compatible with LeRobot format. - """ - features = {} - robot_meta_config = self.env.metadata["dataset"]["robot_meta"] - extra_vision_config = robot_meta_config["observation"]["vision"] - - # Add image features - for camera_name in extra_vision_config.keys(): - sensor = self.env.get_sensor(camera_name) - is_stereo = is_stereocam(sensor) - - # Get image shape from sensor - img_shape = ( - sensor.cfg.height, - sensor.cfg.width, - 3, - ) - - features[camera_name] = { - "dtype": "video" if use_videos else "image", - "shape": img_shape, - "names": ["height", "width", "channel"], - } - - if is_stereo: - features[get_right_name(camera_name)] = { - "dtype": "video" if use_videos else "image", - "shape": img_shape, - "names": ["height", "width", "channel"], - } - - # Add state features (proprio) - state_dim = 0 - for proprio_name in SUPPORTED_PROPRIO_TYPES: - robot = self.env.robot - part = data_key_to_control_part( - robot=robot, - control_parts=robot_meta_config.get("control_parts", []), - data_key=proprio_name, - ) - if part: - indices = robot.get_joint_ids(part, remove_mimic=True) - state_dim += len(indices) - - if state_dim > 0: - features["observation.state"] = { - "dtype": "float32", - "shape": (state_dim,), - "names": ["state"], - } - - # Add action features - action_dim = robot_meta_config.get("arm_dofs", 7) - features["action"] = { - "dtype": "float32", - "shape": (action_dim,), - "names": ["action"], - } - - return features - - def _convert_frame_to_lerobot( - self, obs: Dict[str, Any], action: Dict[str, Any], task: str - ) -> List[Dict]: - """ - Convert frames from all environments to LeRobot format. - - Args: - obs (Dict): Observation dict from environment (batched). - action (Dict): Action dict from environment (batched). - task (str): Task description. - - Returns: - List[Dict]: List of frames in LeRobot format, one per environment. - """ - robot_meta_config = self.env.metadata["dataset"]["robot_meta"] - extra_vision_config = robot_meta_config["observation"]["vision"] - robot = self.env.robot - arm_dofs = robot_meta_config.get("arm_dofs", 7) - - # Determine batch size from qpos - qpos = obs["robot"][JointType.QPOS.value] - num_envs = qpos.shape[0] - - frames = [] - - # Process each environment - for env_idx in range(num_envs): - frame = {"task": task} - - # Add images - for camera_name in extra_vision_config.keys(): - if camera_name in obs["sensor"]: - is_stereo = is_stereocam(self.env.get_sensor(camera_name)) - - # Process left/main camera image - color_data = obs["sensor"][camera_name]["color"] - if isinstance(color_data, torch.Tensor): - color_img = color_data[env_idx][:, :, :3].cpu().numpy() - else: - color_img = np.array(color_data)[env_idx][:, :, :3] - - # Ensure uint8 format (0-255 range) - if color_img.dtype == np.float32 or color_img.dtype == np.float64: - color_img = (color_img * 255).astype(np.uint8) - - frame[camera_name] = color_img - - # Process right camera image if stereo - if is_stereo: - color_right_data = obs["sensor"][camera_name]["color_right"] - if isinstance(color_right_data, torch.Tensor): - color_right_img = color_right_data[env_idx][:, :, :3].cpu().numpy() - else: - color_right_img = np.array(color_right_data)[env_idx][:, :, :3] - - # Ensure uint8 format - if ( - color_right_img.dtype == np.float32 - or color_right_img.dtype == np.float64 - ): - color_right_img = (color_right_img * 255).astype(np.uint8) - - frame[get_right_name(camera_name)] = color_right_img - - # Add state (proprio) - state_list = [] - for proprio_name in SUPPORTED_PROPRIO_TYPES: - part = data_key_to_control_part( - robot=robot, - control_parts=robot_meta_config.get("control_parts", []), - data_key=proprio_name, - ) - if part: - indices = robot.get_joint_ids(part, remove_mimic=True) - qpos_data = qpos[env_idx][indices].cpu().numpy() - qpos_data = HandQposNormalizer.normalize_hand_qpos( - qpos_data, part, robot=robot - ) - state_list.append(qpos_data) - - if state_list: - frame["observation.state"] = np.concatenate(state_list) - - # Add actions - # Handle different action types - if isinstance(action, torch.Tensor): - action_data = action[env_idx, :arm_dofs].cpu().numpy() - elif isinstance(action, np.ndarray): - action_data = action[env_idx, :arm_dofs] - elif isinstance(action, dict): - # If action is a dict, try to extract the actual action data - action_data = action.get("action", action.get("arm_action", action)) - if isinstance(action_data, torch.Tensor): - action_data = action_data[env_idx, :arm_dofs].cpu().numpy() - elif isinstance(action_data, np.ndarray): - action_data = action_data[env_idx, :arm_dofs] - else: - # Fallback: try to convert to numpy - action_data = np.array(action)[env_idx, :arm_dofs] - - frame["action"] = action_data - - frames.append(frame) - - return frames diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index e12d2505..730178f0 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -167,21 +167,9 @@ def _init_sim_state(self, **kwargs): if self.cfg.observations: self.observation_manager = ObservationManager(self.cfg.observations, self) - - # create dataset manager + if self.cfg.dataset: - from embodichain.lab.gym.envs.managers.cfg import DatasetCfg - - # Convert config dict to DatasetCfg if needed - if isinstance(self.cfg.dataset, dict): - dataset_cfg = DatasetCfg(**self.cfg.dataset) - else: - dataset_cfg = self.cfg.dataset - - self.dataset_manager = DatasetManager(dataset_cfg, self) - logger.log_info("DatasetManager initialized for episode recording") - - self.metadata["dataset"] = dataset_cfg.__dict__ + self.dataset_manager = DatasetManager(self.cfg.dataset, self) def _apply_functor_filter(self) -> None: """Apply functor filters to the environment components based on configuration. @@ -272,19 +260,13 @@ def reset( ) -> Tuple[EnvObs, Dict]: obs, info = super().reset(seed=seed, options=options) self._force_truncated: bool = False - - # Reset dataset manager if present - if hasattr(self, 'dataset_manager'): - reset_ids = options.get("reset_ids", None) if options else None - self.dataset_manager.reset(reset_ids) - return obs, info def step( self, action: EnvAction, **kwargs ) -> Tuple[EnvObs, torch.Tensor, torch.Tensor, torch.Tensor, Dict[str, Any]]: """Step the environment with the given action. - + Extends BaseEnv.step() to integrate with DatasetManager for automatic data collection and saving. The key is to: 1. Record obs-action pairs as they happen @@ -302,10 +284,6 @@ def step( info = self.get_info(**kwargs) rewards = self.get_reward(obs=obs, action=action, info=info) - # Record step in dataset manager BEFORE checking termination - if hasattr(self, 'dataset_manager'): - self.dataset_manager.record_step(obs, action) - # Check termination conditions terminateds = torch.logical_or( info.get( @@ -323,18 +301,20 @@ def step( # Detect which environments need reset dones = torch.logical_or(terminateds, truncateds) reset_env_ids = dones.nonzero(as_tuple=False).squeeze(-1) - - # AUTO-SAVE: Trigger dataset manager to save completed episodes - if len(reset_env_ids) > 0 and hasattr(self, 'dataset_manager'): - logger.log_info(f"Calling dataset_manager.on_episode_end() for {reset_env_ids.cpu().tolist()}") - self.dataset_manager.on_episode_end( - reset_env_ids, - terminateds, - info - ) - elif len(reset_env_ids) > 0: - logger.log_warning("Episode completed but no dataset_manager found!") - + + # Call dataset manager with mode="save": it will record and auto-save if dones=True + if self.cfg.dataset: + if "save" in self.dataset_manager.available_modes: + self.dataset_manager.apply( + mode="save", + env_ids=None, + obs=obs, + action=action, + dones=dones, + terminateds=terminateds, + info=info, + ) + # Now perform reset for completed environments if len(reset_env_ids) > 0: obs, _ = self.reset(options={"reset_ids": reset_env_ids}) @@ -530,7 +510,7 @@ def is_task_success(self, **kwargs) -> torch.Tensor: """ return torch.ones(self.num_envs, dtype=torch.bool, device=self.device) - + def check_truncated(self, obs: EnvObs, info: Dict[str, Any]) -> torch.Tensor: """Check if the episode is truncated. @@ -548,7 +528,7 @@ def check_truncated(self, obs: EnvObs, info: Dict[str, Any]) -> torch.Tensor: def close(self) -> None: """Close the environment and release resources.""" # Finalize dataset if present - if hasattr(self, 'dataset_manager'): + if self.cfg.dataset: self.dataset_manager.finalize() - - self.sim.destroy() \ No newline at end of file + + self.sim.destroy() diff --git a/embodichain/lab/gym/envs/managers/__init__.py b/embodichain/lab/gym/envs/managers/__init__.py index 164c594a..b7825cc9 100644 --- a/embodichain/lab/gym/envs/managers/__init__.py +++ b/embodichain/lab/gym/envs/managers/__init__.py @@ -14,8 +14,15 @@ # limitations under the License. # ---------------------------------------------------------------------------- -from .cfg import FunctorCfg, SceneEntityCfg, EventCfg, ObservationCfg, DatasetCfg +from .cfg import ( + FunctorCfg, + SceneEntityCfg, + EventCfg, + ObservationCfg, + DatasetFunctorCfg, +) from .manager_base import Functor, ManagerBase from .event_manager import EventManager from .observation_manager import ObservationManager from .dataset_manager import DatasetManager +from .datasets import LeRobotRecorder diff --git a/embodichain/lab/gym/envs/managers/cfg.py b/embodichain/lab/gym/envs/managers/cfg.py index 2b46c078..07888c9f 100644 --- a/embodichain/lab/gym/envs/managers/cfg.py +++ b/embodichain/lab/gym/envs/managers/cfg.py @@ -312,34 +312,12 @@ def _resolve_body_names(self, scene: SimulationManager): @configclass -class DatasetCfg: - """Configuration for dataset manager. - - Attributes: - save_path: Root directory for saving datasets. If None, uses default location. - id: Dataset ID/version number. Auto-increments if directory exists. - robot_meta: Robot metadata configuration (robot type, control freq, etc.). - instruction: Task instruction configuration (lang, task description). - extra: Extra metadata (scene type, etc.). - use_videos: Whether to use video encoding for images. Defaults to True. - image_writer_threads: Number of threads for image writing. Defaults to 4. - image_writer_processes: Number of processes for image writing. Defaults to 0. - export_success_only: Whether to export only successful episodes. Defaults to False. +class DatasetFunctorCfg(FunctorCfg): + """Configuration for dataset collection functors. + + Dataset functors are called with mode="save" which handles both: + - Recording observation-action pairs on every step + - Auto-saving episodes when dones=True """ - save_path: str | None = None - id: int = 0 - robot_meta: dict[str, Any] = None - instruction: dict[str, Any] = None - extra: dict[str, Any] = None - use_videos: bool = True - image_writer_threads: int = 4 - image_writer_processes: int = 0 - export_success_only: bool = False - - def __post_init__(self): - if self.robot_meta is None: - self.robot_meta = {} - if self.instruction is None: - self.instruction = {"lang": "demo task"} - if self.extra is None: - self.extra = {"scene_type": "demo", "task_description": "demo"} + + mode: Literal["save"] = "save" diff --git a/embodichain/lab/gym/envs/managers/dataset_manager.py b/embodichain/lab/gym/envs/managers/dataset_manager.py index 2ca8a8da..0a8e9d49 100644 --- a/embodichain/lab/gym/envs/managers/dataset_manager.py +++ b/embodichain/lab/gym/envs/managers/dataset_manager.py @@ -14,491 +14,308 @@ # limitations under the License. # ---------------------------------------------------------------------------- -"""Dataset manager for collecting and saving episode data.""" +"""Dataset manager for orchestrating dataset collection functors.""" from __future__ import annotations -from pathlib import Path -from typing import TYPE_CHECKING, Any, Dict, List, Optional +import inspect +from typing import TYPE_CHECKING, Any, Dict, Optional, Union +from collections.abc import Sequence -import numpy as np import torch +from prettytable import PrettyTable from embodichain.utils import logger from embodichain.lab.sim.types import EnvObs, EnvAction -from embodichain.lab.gym.utils.misc import is_stereocam -from embodichain.utils.utility import get_right_name -from embodichain.data.enum import JointType from .manager_base import ManagerBase -from .cfg import DatasetCfg +from .cfg import DatasetFunctorCfg if TYPE_CHECKING: from embodichain.lab.gym.envs import EmbodiedEnv -try: - from lerobot.datasets.lerobot_dataset import LeRobotDataset, HF_LEROBOT_HOME - LEROBOT_AVAILABLE = True -except ImportError: - LEROBOT_AVAILABLE = False - class DatasetManager(ManagerBase): - """Manager for collecting and saving episode data in LeRobot format. - - The dataset manager is responsible for: - - Recording observations and actions during episode rollouts - - Converting data to LeRobot format - - **Automatically saving episodes when they complete (via on_episode_end)** - - Managing dataset metadata - - The manager automatically saves episodes when: - 1. Episode completes (done=True) via on_episode_end() - 2. Environment is closed via finalize() - - Episodes are NOT saved on reset() - only on explicit episode completion. + """Manager for orchestrating dataset collection and saving using functors. + + The dataset manager supports multiple dataset formats through a functor system: + - LeRobot format (via LeRobotRecorder) + - HDF5 format (via HDF5Recorder) + - Zarr format (via ZarrRecorder) + - Custom formats (via user-defined functors) + + Each functor's step() method is called once per environment step and handles: + - Recording observation-action pairs + - Detecting episode completion (dones=True) + - Auto-saving completed episodes + + Example configuration: + >>> from embodichain.lab.gym.envs.managers.cfg import DatasetFunctorCfg + >>> from embodichain.lab.gym.envs.managers.datasets import LeRobotRecorder + >>> + >>> @configclass + >>> class MyEnvCfg: + >>> dataset: dict = { + >>> "lerobot": DatasetFunctorCfg( + >>> func=LeRobotRecorder, + >>> params={ + >>> "robot_meta": {...}, + >>> "instruction": {"lang": "pick and place"}, + >>> "extra": {"scene_type": "kitchen"}, + >>> "save_path": "/data/datasets", + >>> "export_success_only": True, + >>> } + >>> ) + >>> } """ _env: EmbodiedEnv """The environment instance.""" - def __init__(self, cfg: DatasetCfg, env: EmbodiedEnv): + def __init__(self, cfg: object, env: EmbodiedEnv): """Initialize the dataset manager. - + Args: - cfg: Configuration for the dataset manager. + cfg: Configuration object containing dataset functor configurations. env: The environment instance. """ - if not LEROBOT_AVAILABLE: - raise ImportError( - "LeRobot is not available. Install it with: pip install lerobot" - ) - + # Store functors by mode (similar to EventManager) + self._mode_functor_names: dict[str, list[str]] = {} + self._mode_functor_cfgs: dict[str, list[DatasetFunctorCfg]] = {} + self._mode_class_functor_cfgs: dict[str, list[DatasetFunctorCfg]] = {} + + # Call base class to parse functors super().__init__(cfg, env) - - # Episode data buffers - store batched data (shared across all envs) - self.episode_obs_list: List[Dict] = [] # Single list for batched obs - self.episode_action_list: List[Any] = [] # Single list for batched actions - - # LeRobot dataset instance - self.dataset: Optional[LeRobotDataset] = None - - # Track total time across all episodes - self.total_time: float = 0.0 - - # Track current episode count - self.curr_episode: int = 0 - - # Initialize the dataset - self._initialize_dataset() - + + ## TODO: fix configurable_action.py to avoid getting env.metadata['dataset'] + # Extract robot_meta from first functor and add to env.metadata for backward compatibility + # This allows legacy code (like action_bank) to access robot_meta via env.metadata["dataset"]["robot_meta"] + for mode_cfgs in self._mode_functor_cfgs.values(): + for functor_cfg in mode_cfgs: + if "robot_meta" in functor_cfg.params: + if not hasattr(env, "metadata"): + env.metadata = {} + if "dataset" not in env.metadata: + env.metadata["dataset"] = {} + env.metadata["dataset"]["robot_meta"] = functor_cfg.params[ + "robot_meta" + ] + logger.log_info( + "Added robot_meta to env.metadata for backward compatibility" + ) + break + else: + continue + break + logger.log_info( - f"DatasetManager initialized with LeRobot format at: {self.dataset_path}" + f"DatasetManager initialized with {sum(len(v) for v in self._mode_functor_names.values())} functors" ) + def __str__(self) -> str: + """Returns: A string representation for dataset manager.""" + msg = f" contains {len(self._functor_names)} active functors.\n" + + table = PrettyTable() + table.title = "Active Dataset Functors" + table.field_names = ["Index", "Name", "Type"] + table.align["Name"] = "l" + + for index, name in enumerate(self._functor_names): + functor_cfg = self._functor_cfgs[index] + functor_type = ( + functor_cfg.func.__class__.__name__ + if hasattr(functor_cfg.func, "__class__") + else str(functor_cfg.func) + ) + table.add_row([index, name, functor_type]) + + msg += table.get_string() + msg += "\n" + + return msg + """ Properties. """ @property - def active_functors(self) -> list[str]: - """Name of active functors. - - DatasetManager doesn't use functors like other managers, - so this returns an empty list. + def active_functors(self) -> dict[str, list[str]]: + """Name of active dataset functors by mode. + + The keys are the modes and the values are the names of the dataset functors. """ - return [] + return self._mode_functor_names @property - def __str__(self) -> str: - """Returns: A string representation for dataset manager.""" - msg = " for LeRobot format\n" - msg += f" Dataset path: {self.dataset_path}\n" - msg += f" Robot type: {self.cfg.robot_meta.get('robot_type', 'unknown')}\n" - msg += f" Control freq: {self.cfg.robot_meta.get('control_freq', 30)} Hz\n" - msg += f" Export success only: {self.cfg.export_success_only}\n" - return msg + def available_modes(self) -> list[str]: + """List of available modes for the dataset manager.""" + return list(self._mode_functor_names.keys()) + + """ + Operations. + """ + + def reset( + self, env_ids: Union[Sequence[int], torch.Tensor, None] = None + ) -> dict[str, float]: + """Reset all dataset functors. - @property - def dataset_path(self) -> str: - """Path to the dataset directory.""" - return str(Path(self.lerobot_data_root) / self.repo_id) - - def record_step(self, obs: EnvObs, action: EnvAction) -> None: - """Record a step (observation-action pair) for all environments. - - Called by the environment's step() method to record data. - Stores batched data efficiently (one copy for all environments). - Args: - obs: Observation from the environment (batched for all envs). - action: Action applied to the environment (batched for all envs). + env_ids: The environment ids. Defaults to None. + + Returns: + Empty dict (no logging info). """ - self.episode_obs_list.append(obs) - self.episode_action_list.append(action) - - def on_episode_end( + # Call reset on all class functors across all modes + for mode_cfgs in self._mode_class_functor_cfgs.values(): + for functor_cfg in mode_cfgs: + functor_cfg.func.reset(env_ids=env_ids) + + return {} + + def apply( self, - env_ids: torch.Tensor, - terminateds: torch.Tensor, - info: Dict[str, Any] + mode: str, + env_ids: Union[Sequence[int], torch.Tensor, None] = None, + obs: Optional[EnvObs] = None, + action: Optional[EnvAction] = None, + dones: Optional[torch.Tensor] = None, + terminateds: Optional[torch.Tensor] = None, + info: Optional[Dict[str, Any]] = None, ) -> None: - """Handle episode completion and automatically save data. - - Called by the environment when episodes complete (done=True). - This is the key method that makes saving automatic! - + """Apply dataset functors for the specified mode. + + This method follows the same pattern as EventManager.apply() for consistency. + Currently only supports mode="save" which handles both recording and auto-saving. + Args: - env_ids: Environment IDs that have completed episodes. - terminateds: Termination flags (success/fail). + mode: The mode to apply (currently only "save" is supported). + env_ids: The indices of the environments to apply the functor to. + Defaults to None, in which case the functor is applied to all environments. + obs: Observation from the environment (batched for all envs). + action: Action applied to the environment (batched for all envs). + dones: Boolean tensor indicating which envs completed episodes. + terminateds: Boolean tensor indicating termination (success/fail). info: Info dict containing success/fail information. """ - if len(env_ids) == 0: + # check if mode is valid + if mode not in self._mode_functor_names: + logger.log_warning( + f"Dataset mode '{mode}' is not defined. Skipping dataset operation." + ) return - - logger.log_info(f"DatasetManager.on_episode_end() called for env_ids: {env_ids.cpu().tolist()}") - - # Auto-save completed episodes - self._auto_save_episodes( - env_ids, - terminateds=terminateds, - info=info - ) + + # iterate over all the dataset functors for this mode + for functor_cfg in self._mode_functor_cfgs[mode]: + functor_cfg.func( + self._env, + env_ids, + obs, + action, + dones, + terminateds, + info, + **functor_cfg.params, + ) def finalize(self) -> Optional[str]: - """Finalize the dataset and return the save path. - + """Finalize all dataset functors. + Called when the environment is closed. Saves any remaining episodes - and finalizes the dataset. - + and finalizes all datasets. + Returns: - Path to the saved dataset, or None if failed. + Path to the first saved dataset, or None if failed. """ - # Save any remaining episodes if there's data - if len(self.episode_obs_list) > 0: - # Create dummy env_ids for all environments - active_env_ids = torch.arange(self.num_envs, device=self.device) - self._auto_save_episodes(active_env_ids) - - try: - if self.dataset is not None: - self.dataset.finalize() - logger.log_info(f"Dataset finalized at: {self.dataset_path}") - return self.dataset_path - except Exception as e: - logger.log_error(f"Failed to finalize dataset: {e}") - + dataset_paths = [] + + # Call finalize on all class functors across all modes + for mode_cfgs in self._mode_class_functor_cfgs.values(): + for functor_cfg in mode_cfgs: + if hasattr(functor_cfg.func, "finalize"): + try: + path = functor_cfg.func.finalize() + if path: + dataset_paths.append(path) + except Exception as e: + logger.log_error(f"Failed to finalize functor: {e}") + + if dataset_paths: + logger.log_info(f"Finalized {len(dataset_paths)} datasets") + return dataset_paths[0] + return None - def _reset_buffer(self) -> None: - """(Internal) Reset episode buffers (clears all batched data).""" - self.episode_obs_list.clear() - self.episode_action_list.clear() - logger.log_info("Reset buffers (cleared all batched data)") + """ + Operations - Functor settings. + """ + + def get_functor_cfg(self, functor_name: str) -> DatasetFunctorCfg: + """Gets the configuration for the specified functor. - def _auto_save_episodes( - self, - env_ids: torch.Tensor, - terminateds: Optional[torch.Tensor] = None, - info: Optional[Dict[str, Any]] = None - ) -> None: - """Automatically save episodes for specified environments. - - This is the core auto-save logic that runs without manual intervention. - Processes batched data and saves each environment as a separate episode. - - Args: - env_ids: Environment IDs to save. - terminateds: Termination flags (for determining success). - info: Info dict containing success information. - """ - # Check if episode has data - if len(self.episode_obs_list) == 0: - logger.log_warning("No episode data to save") - return - - obs_list = self.episode_obs_list - action_list = self.episode_action_list - - # Align obs and action (remove last obs if needed) - if len(obs_list) > len(action_list): - obs_list = obs_list[:-1] - - # Get task description - task = self.cfg.instruction.get("lang", "unknown_task") - - # Prepare extra info (calculate total time for all episodes) - extra_info = self.cfg.extra.copy() if self.cfg.extra else {} - fps = self.dataset.meta.info.get("fps", 30) - current_episode_time = (len(obs_list) * len(env_ids)) / fps if fps > 0 else 0 - - episode_extra_info = extra_info.copy() - self.total_time += current_episode_time - episode_extra_info["total_time"] = self.total_time - episode_extra_info["data_type"] = "sim" - self.update_dataset_info({"extra": episode_extra_info}) - - # Process each environment as a separate episode - for env_id in env_ids.cpu().tolist(): - # Determine if episode was successful - is_success = False - if info is not None and 'success' in info: - success_tensor = info['success'] - if isinstance(success_tensor, torch.Tensor): - is_success = success_tensor[env_id].item() - else: - is_success = success_tensor - elif terminateds is not None: - is_success = terminateds[env_id].item() - - # Skip failed episodes if configured - logger.log_info(f"Episode {env_id} success: {is_success}") - if self.cfg.export_success_only and not is_success: - logger.log_info(f"Skipping failed episode for env {env_id}") - continue - - # Convert and save episode - try: - # Add frames for this specific environment - for obs, action in zip(obs_list, action_list): - frame = self._convert_frame_to_lerobot(obs, action, task, env_id) - self.dataset.add_frame(frame) - - # Save episode for this environment - self.dataset.save_episode() - - status = "successful" if is_success else "failed" - logger.log_info( - f"Auto-saved {status} episode {self.curr_episode} for env {env_id} with {len(obs_list)} frames" - ) - self.curr_episode += 1 - - except Exception as e: - logger.log_error(f"Failed to auto-save episode {env_id}: {e}") - - # Clear buffer after saving all episodes - self._reset_buffer() - - def _initialize_dataset(self) -> None: - """Initialize the LeRobot dataset.""" - # Extract naming components from config - robot_type = self.cfg.robot_meta.get("robot_type", "robot") - scene_type = self.cfg.extra.get("scene_type", "scene") - task_description = self.cfg.extra.get("task_description", "task") - - robot_type = str(robot_type).lower().replace(" ", "_") - task_description = str(task_description).lower().replace(" ", "_") - - # Determine lerobot data root directory - lerobot_data_root = self.cfg.save_path - if lerobot_data_root is None: - lerobot_data_root = Path(HF_LEROBOT_HOME) - else: - lerobot_data_root = Path(lerobot_data_root) - - # Auto-increment id until the repo_id subdirectory does not exist - dataset_id = self.cfg.id - while True: - repo_id = f"{robot_type}_{scene_type}_{task_description}_v{dataset_id}" - dataset_dir = lerobot_data_root / repo_id - if not dataset_dir.exists(): - break - dataset_id += 1 - - # Store computed values - self.repo_id = repo_id - self.dataset_id = dataset_id - self.lerobot_data_root = str(lerobot_data_root) - - # Get dataset configuration - fps = self.cfg.robot_meta.get("control_freq", 30) - use_videos = self.cfg.use_videos - image_writer_threads = self.cfg.image_writer_threads - image_writer_processes = self.cfg.image_writer_processes - - # Build features - features = self._build_features() - - try: - # Try to create new dataset - self.dataset = LeRobotDataset.create( - repo_id=repo_id, - fps=fps, - root=str(lerobot_data_root), - robot_type=robot_type, - features=features, - use_videos=use_videos, - image_writer_threads=image_writer_threads, - image_writer_processes=image_writer_processes, - ) - logger.log_info(f"Created LeRobot dataset at: {lerobot_data_root / repo_id}") - except FileExistsError: - # Dataset already exists, load it instead - logger.log_info(f"Dataset {repo_id} already exists at {lerobot_data_root}, loading it...") - self.dataset = LeRobotDataset( - repo_id=repo_id, - root=str(lerobot_data_root), - ) - logger.log_info(f"Loaded existing LeRobot dataset at: {lerobot_data_root / repo_id}") - except Exception as e: - logger.log_error(f"Failed to create/load LeRobot dataset: {e}") - raise - - def _build_features(self) -> Dict: - """Build LeRobot features dict from environment metadata.""" - features = {} - extra_vision_config = self.cfg.robot_meta.get("observation", {}).get("vision", {}) - - # Add image features - for camera_name in extra_vision_config.keys(): - sensor = self._env.get_sensor(camera_name) - is_stereo = is_stereocam(sensor) - - # Get image shape from sensor - img_shape = (sensor.cfg.height, sensor.cfg.width, 3) - - features[camera_name] = { - "dtype": "video" if self.cfg.use_videos else "image", - "shape": img_shape, - "names": ["height", "width", "channel"], - } - - if is_stereo: - features[get_right_name(camera_name)] = { - "dtype": "video" if self.cfg.use_videos else "image", - "shape": img_shape, - "names": ["height", "width", "channel"], - } - - # Add state features (proprio) - qpos = self._env.robot.get_qpos() - state_dim = qpos.shape[1] - - if state_dim > 0: - features["observation.state"] = { - "dtype": "float32", - "shape": (state_dim,), - "names": ["state"], - } - - # Add action features - action_dim = self.cfg.robot_meta.get("arm_dofs", 7) - features["action"] = { - "dtype": "float32", - "shape": (action_dim,), - "names": ["action"], - } - - return features - - def _convert_frame_to_lerobot( - self, obs: Dict[str, Any], action: Any, task: str, env_id: int - ) -> Dict: - """Convert a single frame from one environment to LeRobot format. - - Args: - obs: Observation dict from environment (batched). - action: Action from environment (batched). - task: Task description. - env_id: Environment index to extract data for. - - Returns: - Single frame in LeRobot format for the specified environment. - """ - frame = {"task": task} - - extra_vision_config = self.cfg.robot_meta.get("observation", {}).get("vision", {}) - arm_dofs = self.cfg.robot_meta.get("arm_dofs", 7) - - # Add images - for camera_name in extra_vision_config.keys(): - if camera_name in obs.get("sensor", {}): - sensor = self._env.get_sensor(camera_name) - is_stereo = is_stereocam(sensor) - - # Process left/main camera image - color_data = obs["sensor"][camera_name]["color"] - if isinstance(color_data, torch.Tensor): - color_img = color_data[env_id][:, :, :3].cpu().numpy() - else: - color_img = np.array(color_data)[env_id][:, :, :3] - - # Ensure uint8 format (0-255 range) - if color_img.dtype in [np.float32, np.float64]: - color_img = (color_img * 255).astype(np.uint8) - - frame[camera_name] = color_img - - # Process right camera image if stereo - if is_stereo: - color_right_data = obs["sensor"][camera_name]["color_right"] - if isinstance(color_right_data, torch.Tensor): - color_right_img = color_right_data[env_id][:, :, :3].cpu().numpy() - else: - color_right_img = np.array(color_right_data)[env_id][:, :, :3] - - if color_right_img.dtype in [np.float32, np.float64]: - color_right_img = (color_right_img * 255).astype(np.uint8) - - frame[get_right_name(camera_name)] = color_right_img - - # Add state (proprio) - qpos = obs["robot"][JointType.QPOS.value] - if isinstance(qpos, torch.Tensor): - state_data = qpos[env_id].cpu().numpy().astype(np.float32) - else: - state_data = np.array(qpos)[env_id].astype(np.float32) - - frame["observation.state"] = state_data - - # Add action - if isinstance(action, torch.Tensor): - action_data = action[env_id, :arm_dofs].cpu().numpy() - elif isinstance(action, np.ndarray): - action_data = action[env_id, :arm_dofs] - elif isinstance(action, dict): - action_data = action.get("action", action.get("arm_action", action)) - if isinstance(action_data, torch.Tensor): - action_data = action_data[env_id, :arm_dofs].cpu().numpy() - elif isinstance(action_data, np.ndarray): - action_data = action_data[env_id, :arm_dofs] - else: - action_data = np.array(action)[env_id, :arm_dofs] - - frame["action"] = action_data - - return frame - - def update_dataset_info(self, updates: dict) -> bool: - """Update the LeRobot dataset's meta.info with custom key-value pairs. - Args: - updates: Dictionary of key-value pairs to add or update in meta.info. - + functor_name: The name of the dataset functor. + Returns: - True if successful, False otherwise. - - Example: - >>> dataset_manager.update_dataset_info({ - ... "author": "DexForce", - ... "date_collected": "2025-12-22", - ... "custom_key": "custom_value" - ... }) + The configuration of the dataset functor. + + Raises: + ValueError: If the functor name is not found. """ - if self.dataset is None: - logger.log_error( - "LeRobotDataset not initialized. Cannot update dataset info." - ) - return False + for mode, functors in self._mode_functor_names.items(): + if functor_name in functors: + return self._mode_functor_cfgs[mode][functors.index(functor_name)] + logger.log_error(f"Dataset functor '{functor_name}' not found.") - try: - self.dataset.meta.info.update(updates) - logger.log_info( - f"Successfully updated dataset info with keys: {list(updates.keys())}" - ) - return True - except Exception as e: - logger.log_error(f"Failed to update dataset info: {e}") - return False + """ + Helper functions. + """ def _prepare_functors(self): - pass + """Prepare dataset functors from configuration. + + This method parses the configuration and initializes all dataset functors, + organizing them by mode (similar to EventManager). + """ + # Check if config is dict already + if isinstance(self.cfg, dict): + cfg_items = self.cfg.items() + else: + cfg_items = self.cfg.__dict__.items() + + # Iterate over all the functors + for functor_name, functor_cfg in cfg_items: + # Check for non config + if functor_cfg is None: + continue + + # Convert dict to DatasetFunctorCfg if needed (for JSON configs) + if isinstance(functor_cfg, dict): + functor_cfg = DatasetFunctorCfg(**functor_cfg) + + # Check for valid config type + if not isinstance(functor_cfg, DatasetFunctorCfg): + raise TypeError( + f"Configuration for '{functor_name}' is not of type DatasetFunctorCfg." + f" Received: '{type(functor_cfg)}'." + ) + + # Resolve common parameters + # min_argc=7 to skip: env, env_ids, obs, action, dones, terminateds, info + # These are runtime positional arguments, not config parameters + self._resolve_common_functor_cfg(functor_name, functor_cfg, min_argc=7) + + # Check if mode is a new mode + if functor_cfg.mode not in self._mode_functor_names: + # add new mode + self._mode_functor_names[functor_cfg.mode] = [] + self._mode_functor_cfgs[functor_cfg.mode] = [] + self._mode_class_functor_cfgs[functor_cfg.mode] = [] + + # Add functor name and parameters + self._mode_functor_names[functor_cfg.mode].append(functor_name) + self._mode_functor_cfgs[functor_cfg.mode].append(functor_cfg) + + # Check if the functor is a class + if inspect.isclass(functor_cfg.func): + self._mode_class_functor_cfgs[functor_cfg.mode].append(functor_cfg) diff --git a/embodichain/lab/gym/envs/managers/datasets.py b/embodichain/lab/gym/envs/managers/datasets.py new file mode 100644 index 00000000..32294ec0 --- /dev/null +++ b/embodichain/lab/gym/envs/managers/datasets.py @@ -0,0 +1,429 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2025 DexForce Technology Co., Ltd. +# +# 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. +# ---------------------------------------------------------------------------- + +"""Dataset functors for collecting and saving episode data.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union + +import numpy as np +import torch + +from embodichain.utils import logger +from embodichain.lab.sim.types import EnvObs, EnvAction +from embodichain.lab.gym.utils.misc import is_stereocam +from embodichain.utils.utility import get_right_name +from embodichain.data.enum import JointType +from .manager_base import Functor +from .cfg import DatasetFunctorCfg + +if TYPE_CHECKING: + from embodichain.lab.gym.envs import EmbodiedEnv + +try: + from lerobot.datasets.lerobot_dataset import LeRobotDataset, HF_LEROBOT_HOME + + LEROBOT_AVAILABLE = True +except ImportError: + LEROBOT_AVAILABLE = False + + +class LeRobotRecorder(Functor): + """Functor for recording episodes in LeRobot format. + + This functor handles: + - Recording observation-action pairs during episodes + - Converting data to LeRobot format + - Saving episodes when they complete + """ + + def __init__(self, cfg: DatasetFunctorCfg, env: EmbodiedEnv): + """Initialize the LeRobot dataset recorder. + + Args: + cfg: Functor configuration containing params: + - save_path: Root directory for saving datasets + - id: Dataset identifier (repo_id) + - robot_meta: Robot metadata for dataset + - instruction: Optional task instruction + - extra: Optional extra metadata + - use_videos: Whether to save videos + - image_writer_threads: Number of threads for image writing + - image_writer_processes: Number of processes for image writing + - export_success_only: Whether to export only successful episodes + env: The environment instance + """ + super().__init__(cfg, env) + + # Extract parameters from cfg.params + params = cfg.params + + # Required parameters + self.lerobot_data_root = params.get("save_path", "/tmp/lerobot_data") + self.repo_id = params.get( + "id", 0 + ) # Can be int (version counter) or str (dataset name) + self.robot_meta = params.get("robot_meta", {}) + + # Optional parameters + self.instruction = params.get("instruction", None) + self.extra = params.get("extra", {}) + self.use_videos = params.get("use_videos", False) + self.export_success_only = params.get("export_success_only", False) + + # Episode data buffers + self.episode_obs_list: List[Dict] = [] + self.episode_action_list: List[Any] = [] + + # LeRobot dataset instance + self.dataset: Optional[LeRobotDataset] = None + self.dataset_id: int = 0 # Will be set in _initialize_dataset + + # Tracking + self.total_time: float = 0.0 + self.curr_episode: int = 0 + + # Initialize dataset + self._initialize_dataset() + + logger.log_info(f"LeRobotRecorder initialized at: {self.dataset_path}") + + @property + def dataset_path(self) -> str: + """Path to the dataset directory.""" + return str(Path(self.lerobot_data_root) / self.repo_id) + + def reset(self, env_ids: Optional[torch.Tensor] = None) -> None: + """Reset the recorder buffers. + + Args: + env_ids: Environment IDs to reset (currently clears all data). + """ + self._reset_buffer() + + def __call__( + self, + env: EmbodiedEnv, + env_ids: Union[torch.Tensor, None], + obs: EnvObs, + action: EnvAction, + dones: torch.Tensor, + terminateds: torch.Tensor, + info: Dict[str, Any], + save_path: Optional[str] = None, + id: Optional[str] = None, + robot_meta: Optional[Dict] = None, + instruction: Optional[str] = None, + extra: Optional[Dict] = None, + use_videos: bool = False, + export_success_only: bool = False, + ) -> None: + """Main entry point for the recorder functor. + + This method is called by DatasetManager.apply(mode="save") with runtime arguments + as positional parameters and configuration parameters from cfg.params. + + Args: + env: The environment instance. + env_ids: Environment IDs (for consistency with EventManager pattern). + obs: Observation from the environment. + action: Action applied to the environment. + dones: Boolean tensor indicating which envs completed episodes. + terminateds: Termination flags (success/fail). + info: Info dict containing success/fail information. + save_path: Root directory (already set in __init__). + id: Dataset identifier (already set in __init__). + robot_meta: Robot metadata (already set in __init__). + instruction: Task instruction (already set in __init__). + extra: Extra metadata (already set in __init__). + use_videos: Whether to save videos (already set in __init__). + export_success_only: Whether to export only successful episodes (already set in __init__). + """ + # Always record the step + self._record_step(obs, action) + + # Check if any episodes are done and save them + done_env_ids = dones.nonzero(as_tuple=False).squeeze(-1) + if len(done_env_ids) > 0: + # Save completed episodes + self._save_episodes(done_env_ids, terminateds, info) + + def _record_step(self, obs: EnvObs, action: EnvAction) -> None: + """Record a single step.""" + self.episode_obs_list.append(obs) + self.episode_action_list.append(action) + + def _save_episodes( + self, + env_ids: torch.Tensor, + terminateds: Optional[torch.Tensor] = None, + info: Optional[Dict[str, Any]] = None, + ) -> None: + """Save completed episodes.""" + if len(self.episode_obs_list) == 0: + logger.log_warning("No episode data to save") + return + + obs_list = self.episode_obs_list + action_list = self.episode_action_list + + # Align obs and action + if len(obs_list) > len(action_list): + obs_list = obs_list[:-1] + + task = self.instruction.get("lang", "unknown_task") + + # Update metadata + extra_info = self.extra.copy() if self.extra else {} + fps = self.dataset.meta.info.get("fps", 30) + current_episode_time = (len(obs_list) * len(env_ids)) / fps if fps > 0 else 0 + + episode_extra_info = extra_info.copy() + self.total_time += current_episode_time + episode_extra_info["total_time"] = self.total_time + self._update_dataset_info({"extra": episode_extra_info}) + + # Process each environment + for env_id in env_ids.cpu().tolist(): + is_success = False + if info is not None and "success" in info: + success_tensor = info["success"] + if isinstance(success_tensor, torch.Tensor): + is_success = success_tensor[env_id].item() + else: + is_success = success_tensor + elif terminateds is not None: + is_success = terminateds[env_id].item() + + logger.log_info(f"Episode {env_id} success: {is_success}") + if self.export_success_only and not is_success: + logger.log_info(f"Skipping failed episode for env {env_id}") + continue + + try: + for obs, action in zip(obs_list, action_list): + frame = self._convert_frame_to_lerobot(obs, action, task, env_id) + self.dataset.add_frame(frame) + + self.dataset.save_episode() + logger.log_info( + f"Auto-saved {'successful' if is_success else 'failed'} " + f"episode {self.curr_episode} for env {env_id} with {len(obs_list)} frames" + ) + self.curr_episode += 1 + except Exception as e: + logger.log_error(f"Failed to save episode {env_id}: {e}") + + self._reset_buffer() + + def finalize(self) -> Optional[str]: + """Finalize the dataset.""" + if len(self.episode_obs_list) > 0: + active_env_ids = torch.arange(self.num_envs, device=self.device) + self._save_episodes(active_env_ids) + + try: + if self.dataset is not None: + self.dataset.finalize() + logger.log_info(f"Dataset finalized at: {self.dataset_path}") + return self.dataset_path + except Exception as e: + logger.log_error(f"Failed to finalize dataset: {e}") + + return None + + def _reset_buffer(self) -> None: + """Reset episode buffers.""" + self.episode_obs_list.clear() + self.episode_action_list.clear() + logger.log_info("Reset buffers (cleared all batched data)") + + def _initialize_dataset(self) -> None: + """Initialize the LeRobot dataset.""" + robot_type = self.robot_meta.get("robot_type", "robot") + scene_type = self.extra.get("scene_type", "scene") + task_description = self.extra.get("task_description", "task") + + robot_type = str(robot_type).lower().replace(" ", "_") + task_description = str(task_description).lower().replace(" ", "_") + + # Use lerobot_data_root from __init__ + lerobot_data_root = Path(self.lerobot_data_root) + + # repo_id from config or generate one + if isinstance(self.repo_id, int): + # If repo_id is an integer, generate a name + dataset_id = self.repo_id + while True: + repo_id = f"{robot_type}_{scene_type}_{task_description}_v{dataset_id}" + dataset_dir = lerobot_data_root / repo_id + if not dataset_dir.exists(): + break + dataset_id += 1 + self.repo_id = repo_id + self.dataset_id = dataset_id + else: + # repo_id is already a string, use it directly + self.dataset_id = 0 + + fps = self.robot_meta.get("control_freq", 30) + features = self._build_features() + + try: + self.dataset = LeRobotDataset.create( + repo_id=self.repo_id, + fps=fps, + root=str(lerobot_data_root), + robot_type=robot_type, + features=features, + use_videos=self.use_videos, + ) + logger.log_info( + f"Created LeRobot dataset at: {lerobot_data_root / self.repo_id}" + ) + except FileExistsError: + self.dataset = LeRobotDataset( + repo_id=self.repo_id, root=str(lerobot_data_root) + ) + logger.log_info( + f"Loaded existing LeRobot dataset at: {lerobot_data_root / self.repo_id}" + ) + except Exception as e: + logger.log_error(f"Failed to create/load LeRobot dataset: {e}") + raise + + def _build_features(self) -> Dict: + """Build LeRobot features dict.""" + features = {} + extra_vision_config = self.robot_meta.get("observation", {}).get("vision", {}) + + for camera_name in extra_vision_config.keys(): + sensor = self._env.get_sensor(camera_name) + is_stereo = is_stereocam(sensor) + img_shape = (sensor.cfg.height, sensor.cfg.width, 3) + + features[camera_name] = { + "dtype": "video" if self.use_videos else "image", + "shape": img_shape, + "names": ["height", "width", "channel"], + } + + if is_stereo: + features[get_right_name(camera_name)] = { + "dtype": "video" if self.use_videos else "image", + "shape": img_shape, + "names": ["height", "width", "channel"], + } + + qpos = self._env.robot.get_qpos() + state_dim = qpos.shape[1] + + if state_dim > 0: + features["observation.state"] = { + "dtype": "float32", + "shape": (state_dim,), + "names": ["state"], + } + + action_dim = self.robot_meta.get("arm_dofs", 7) + features["action"] = { + "dtype": "float32", + "shape": (action_dim,), + "names": ["action"], + } + + return features + + def _convert_frame_to_lerobot( + self, obs: Dict[str, Any], action: Any, task: str, env_id: int + ) -> Dict: + """Convert a single frame to LeRobot format.""" + frame = {"task": task} + extra_vision_config = self.robot_meta.get("observation", {}).get("vision", {}) + arm_dofs = self.robot_meta.get("arm_dofs", 7) + + # Add images + for camera_name in extra_vision_config.keys(): + if camera_name in obs.get("sensor", {}): + sensor = self._env.get_sensor(camera_name) + is_stereo = is_stereocam(sensor) + + color_data = obs["sensor"][camera_name]["color"] + if isinstance(color_data, torch.Tensor): + color_img = color_data[env_id][:, :, :3].cpu().numpy() + else: + color_img = np.array(color_data)[env_id][:, :, :3] + + if color_img.dtype in [np.float32, np.float64]: + color_img = (color_img * 255).astype(np.uint8) + + frame[camera_name] = color_img + + if is_stereo: + color_right_data = obs["sensor"][camera_name]["color_right"] + if isinstance(color_right_data, torch.Tensor): + color_right_img = ( + color_right_data[env_id][:, :, :3].cpu().numpy() + ) + else: + color_right_img = np.array(color_right_data)[env_id][:, :, :3] + + if color_right_img.dtype in [np.float32, np.float64]: + color_right_img = (color_right_img * 255).astype(np.uint8) + + frame[get_right_name(camera_name)] = color_right_img + + # Add state + qpos = obs["robot"][JointType.QPOS.value] + if isinstance(qpos, torch.Tensor): + state_data = qpos[env_id].cpu().numpy().astype(np.float32) + else: + state_data = np.array(qpos)[env_id].astype(np.float32) + + frame["observation.state"] = state_data + + # Add action + if isinstance(action, torch.Tensor): + action_data = action[env_id, :arm_dofs].cpu().numpy() + elif isinstance(action, np.ndarray): + action_data = action[env_id, :arm_dofs] + elif isinstance(action, dict): + action_data = action.get("action", action.get("arm_action", action)) + if isinstance(action_data, torch.Tensor): + action_data = action_data[env_id, :arm_dofs].cpu().numpy() + elif isinstance(action_data, np.ndarray): + action_data = action_data[env_id, :arm_dofs] + else: + action_data = np.array(action)[env_id, :arm_dofs] + + frame["action"] = action_data + + return frame + + def _update_dataset_info(self, updates: dict) -> bool: + """Update dataset metadata.""" + if self.dataset is None: + logger.log_error("LeRobotDataset not initialized.") + return False + + try: + self.dataset.meta.info.update(updates) + return True + except Exception as e: + logger.log_error(f"Failed to update dataset info: {e}") + return False diff --git a/embodichain/lab/gym/utils/gym_utils.py b/embodichain/lab/gym/utils/gym_utils.py index 0f92abb7..ebe06d6e 100644 --- a/embodichain/lab/gym/utils/gym_utils.py +++ b/embodichain/lab/gym/utils/gym_utils.py @@ -364,6 +364,7 @@ def config_to_cfg(config: dict) -> "EmbodiedEnvCfg": SceneEntityCfg, EventCfg, ObservationCfg, + DatasetFunctorCfg, ) from embodichain.utils import configclass from embodichain.data import get_data_path @@ -453,7 +454,32 @@ class ComponentCfg: env_cfg.sim_steps_per_control = config["env"].get("sim_steps_per_control", 4) # load dataset config - env_cfg.dataset = config["env"].get("dataset", None) + env_cfg.dataset = ComponentCfg() + if "dataset" in config["env"]: + # Define modules to search for dataset functions + dataset_modules = [ + "embodichain.lab.gym.envs.managers.datasets", + ] + + for dataset_name, dataset_params in config["env"]["dataset"].items(): + dataset_params_modified = deepcopy(dataset_params) + + # Find the function from multiple modules using the utility function + dataset_func = find_function_from_modules( + dataset_params["func"], + dataset_modules, + raise_if_not_found=True, + ) + + from embodichain.lab.gym.envs.managers import DatasetFunctorCfg + + dataset = DatasetFunctorCfg( + func=dataset_func, + mode=dataset_params_modified["mode"], + params=dataset_params_modified["params"], + ) + + setattr(env_cfg.dataset, dataset_name, dataset) # TODO: support more env events, eg, grasp pose generation, mesh preprocessing, etc. diff --git a/embodichain/lab/scripts/run_env.py b/embodichain/lab/scripts/run_env.py index 10203eb6..70a4b85d 100644 --- a/embodichain/lab/scripts/run_env.py +++ b/embodichain/lab/scripts/run_env.py @@ -40,13 +40,15 @@ def generate_and_execute_action_list(env, idx, debug_mode): log_warning("Action is invalid. Skip to next generation.") return False - for idx_action, action in enumerate(tqdm.tqdm( - action_list, desc=f"Executing action list #{idx}", unit="step" - )): + for idx_action, action in enumerate( + tqdm.tqdm(action_list, desc=f"Executing action list #{idx}", unit="step") + ): if idx_action == len(action_list) - 1: - log_info(f"Setting force_truncated before final step at action index: {idx_action}") + log_info( + f"Setting force_truncated before final step at action index: {idx_action}" + ) env.set_force_truncated(True) - + # Step the environment with the current action obs, reward, terminated, truncated, info = env.step(action) From 7355334e08966c9d9e3d6287ef49e5a5861694cf Mon Sep 17 00:00:00 2001 From: yuecideng Date: Sun, 11 Jan 2026 16:33:35 +0800 Subject: [PATCH 18/26] wip --- configs/gym/pour_water/gym_config.json | 1 - configs/gym/pour_water/gym_config_simple.json | 5 +---- embodichain/data/constants.py | 1 + embodichain/lab/gym/envs/base_env.py | 15 ++++++++++----- embodichain/lab/gym/envs/managers/datasets.py | 5 ++++- embodichain/lab/gym/utils/registration.py | 10 ++++++++++ embodichain/lab/scripts/run_env.py | 6 +++--- .../workspace_analyzer/constraints/__init__.py | 2 -- pyproject.toml | 2 +- scripts/tutorials/gym/random_reach.py | 2 +- tests/gym/envs/test_base_env.py | 2 +- tests/gym/envs/test_embodied_env.py | 16 +++++++++------- 12 files changed, 41 insertions(+), 26 deletions(-) diff --git a/configs/gym/pour_water/gym_config.json b/configs/gym/pour_water/gym_config.json index b9cd4a46..30463e3a 100644 --- a/configs/gym/pour_water/gym_config.json +++ b/configs/gym/pour_water/gym_config.json @@ -262,7 +262,6 @@ "func": "LeRobotRecorder", "mode": "save", "params": { - "save_path": "/home/dex/projects/yuanhaonan/embodichain/outputs/data_example", "id": 0, "robot_meta": { "robot_type": "CobotMagic", diff --git a/configs/gym/pour_water/gym_config_simple.json b/configs/gym/pour_water/gym_config_simple.json index 1ddbad9a..7aa672af 100644 --- a/configs/gym/pour_water/gym_config_simple.json +++ b/configs/gym/pour_water/gym_config_simple.json @@ -200,7 +200,6 @@ "func": "LeRobotRecorder", "mode": "save", "params": { - "save_path": "/home/dex/projects/yuanhaonan/embodichain/outputs/data_example", "id": 0, "robot_meta": { "robot_type": "CobotMagic", @@ -209,9 +208,7 @@ "control_parts": ["left_arm", "left_eef", "right_arm", "right_eef"], "observation": { "vision": { - "cam_high": ["mask"], - "cam_right_wrist": ["mask"], - "cam_left_wrist": ["mask"] + "cam_high": ["mask"] }, "states": ["qpos"], "exteroception": ["cam_high", "cam_right_wrist", "cam_left_wrist"] diff --git a/embodichain/data/constants.py b/embodichain/data/constants.py index 17264a80..8409d89a 100644 --- a/embodichain/data/constants.py +++ b/embodichain/data/constants.py @@ -21,3 +21,4 @@ "https://huggingface.co/datasets/dexforce/embodichain_data/resolve/main/" ) EMBODICHAIN_DEFAULT_DATA_ROOT = str(Path.home() / ".cache" / "embodichain_data") +EMBODICHAIN_DEFAULT_DATASET_ROOT = str(Path.home() / ".cache" / "embodichain_datasets") diff --git a/embodichain/lab/gym/envs/base_env.py b/embodichain/lab/gym/envs/base_env.py index 201d03bc..15f5d3be 100644 --- a/embodichain/lab/gym/envs/base_env.py +++ b/embodichain/lab/gym/envs/base_env.py @@ -103,13 +103,13 @@ def __init__( self.cfg = cfg # the number of envs to be simulated in parallel. - self.num_envs = self.cfg.num_envs + self._num_envs = self.cfg.num_envs if self.cfg.sim_cfg is None: self.sim_cfg = SimulationManagerCfg(headless=True) else: self.sim_cfg = self.cfg.sim_cfg - self.sim_cfg.num_envs = self.num_envs + self.sim_cfg.num_envs = self._num_envs if self.cfg.seed is not None: self.cfg.seed = set_seed(self.cfg.seed) @@ -129,7 +129,7 @@ def __init__( self.sim.open_window() self._elapsed_steps = torch.zeros( - self.num_envs, dtype=torch.int32, device=self.sim_cfg.sim_device + self._num_envs, dtype=torch.int32, device=self.sim_cfg.sim_device ) self._init_sim_state(**kwargs) @@ -138,7 +138,7 @@ def __init__( logger.log_info("[INFO]: Initialized environment:") logger.log_info(f"\tEnvironment device : {self.sim.device}") - logger.log_info(f"\tNumber of environments: {self.num_envs}") + logger.log_info(f"\tNumber of environments: {self._num_envs}") logger.log_info(f"\tEnvironment seed : {self.cfg.seed}") logger.log_info(f"\tPhysics dt : {self.sim_cfg.physics_dt}") logger.log_info( @@ -146,7 +146,12 @@ def __init__( ) @property - def device(self) -> torch.Tensor: + def num_envs(self) -> int: + """Return the number of environments simulated in parallel.""" + return self._num_envs + + @property + def device(self) -> torch.device: """Return the device used by the environment.""" return self.sim.device diff --git a/embodichain/lab/gym/envs/managers/datasets.py b/embodichain/lab/gym/envs/managers/datasets.py index 32294ec0..10f5e99b 100644 --- a/embodichain/lab/gym/envs/managers/datasets.py +++ b/embodichain/lab/gym/envs/managers/datasets.py @@ -25,6 +25,7 @@ import torch from embodichain.utils import logger +from embodichain.data.constants import EMBODICHAIN_DEFAULT_DATASET_ROOT from embodichain.lab.sim.types import EnvObs, EnvAction from embodichain.lab.gym.utils.misc import is_stereocam from embodichain.utils.utility import get_right_name @@ -74,7 +75,9 @@ def __init__(self, cfg: DatasetFunctorCfg, env: EmbodiedEnv): params = cfg.params # Required parameters - self.lerobot_data_root = params.get("save_path", "/tmp/lerobot_data") + self.lerobot_data_root = params.get( + "save_path", EMBODICHAIN_DEFAULT_DATASET_ROOT + ) self.repo_id = params.get( "id", 0 ) # Can be int (version counter) or str (dataset name) diff --git a/embodichain/lab/gym/utils/registration.py b/embodichain/lab/gym/utils/registration.py index b2fe5b62..9c52e103 100644 --- a/embodichain/lab/gym/utils/registration.py +++ b/embodichain/lab/gym/utils/registration.py @@ -18,6 +18,8 @@ import json import sys +import torch + from copy import deepcopy from functools import partial from typing import TYPE_CHECKING, Dict, Type @@ -107,6 +109,14 @@ def __init__(self, env: gym.Env, max_episode_steps: int): def base_env(self) -> BaseEnv: return self.env.unwrapped + @property + def device(self) -> torch.device: + return self.base_env.device + + @property + def num_envs(self) -> int: + return self.base_env.num_envs + def step(self, action): observation, reward, terminated, truncated, info = self.env.step(action) truncated = truncated | (self.base_env.elapsed_steps >= self._max_episode_steps) diff --git a/embodichain/lab/scripts/run_env.py b/embodichain/lab/scripts/run_env.py index 70a4b85d..57911e2b 100644 --- a/embodichain/lab/scripts/run_env.py +++ b/embodichain/lab/scripts/run_env.py @@ -34,7 +34,7 @@ def generate_and_execute_action_list(env, idx, debug_mode): - action_list = env.create_demo_action_list(action_sentence=idx) + action_list = env.get_wrapper_attr("create_demo_action_list")(action_sentence=idx) if action_list is None or len(action_list) == 0: log_warning("Action is invalid. Skip to next generation.") @@ -47,7 +47,7 @@ def generate_and_execute_action_list(env, idx, debug_mode): log_info( f"Setting force_truncated before final step at action index: {idx_action}" ) - env.set_force_truncated(True) + env.get_wrapper_attr("set_force_truncated")(True) # Step the environment with the current action obs, reward, terminated, truncated, info = env.step(action) @@ -98,7 +98,7 @@ def generate_function( _, _ = env.reset() break - if not debug_mode and env.is_task_success().item(): + if not debug_mode and env.get_wrapper_attr("is_task_success")().item(): pass # TODO: Add data saving and online data streaming logic here. else: diff --git a/embodichain/lab/sim/utility/workspace_analyzer/constraints/__init__.py b/embodichain/lab/sim/utility/workspace_analyzer/constraints/__init__.py index b6df4e52..9c02f032 100644 --- a/embodichain/lab/sim/utility/workspace_analyzer/constraints/__init__.py +++ b/embodichain/lab/sim/utility/workspace_analyzer/constraints/__init__.py @@ -16,11 +16,9 @@ from .base_constraint import BaseConstraintChecker, IConstraintChecker from .workspace_constraint import WorkspaceConstraintChecker -from .circular_constraint import CircularConstraintChecker __all__ = [ "BaseConstraintChecker", "IConstraintChecker", "WorkspaceConstraintChecker", - "CircularConstraintChecker", ] diff --git a/pyproject.toml b/pyproject.toml index 043ee609..f1381090 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dynamic = ["version"] dependencies = [ "dexsim_engine==0.3.8", "setuptools>=78.1.1", - "gymnasium==0.29.1", + "gymnasium>=0.29.1", "casadi==3.7.1", "pin==2.7.0", "toppra==0.6.3", diff --git a/scripts/tutorials/gym/random_reach.py b/scripts/tutorials/gym/random_reach.py index a6e4ed0a..d9912237 100644 --- a/scripts/tutorials/gym/random_reach.py +++ b/scripts/tutorials/gym/random_reach.py @@ -144,7 +144,7 @@ def _extend_obs(self, obs: EnvObs, **kwargs) -> EnvObs: action = env.action_space.sample() action = torch.as_tensor(action, dtype=torch.float32, device=env.device) - init_pose = env.robot_init_qpos + init_pose = env.unwrapped.robot_init_qpos init_pose = ( torch.as_tensor(init_pose, dtype=torch.float32, device=env.device) .unsqueeze_(0) diff --git a/tests/gym/envs/test_base_env.py b/tests/gym/envs/test_base_env.py index e9d1f3ab..bed26a71 100644 --- a/tests/gym/envs/test_base_env.py +++ b/tests/gym/envs/test_base_env.py @@ -136,7 +136,7 @@ def test_env_rollout(self): action, dtype=torch.float32, device=self.env.device ) - init_pose = self.env.robot_init_qpos + init_pose = self.env.get_wrapper_attr("robot_init_qpos") init_pose = ( torch.as_tensor( init_pose, dtype=torch.float32, device=self.env.device diff --git a/tests/gym/envs/test_embodied_env.py b/tests/gym/envs/test_embodied_env.py index 29ea0d85..574fd60c 100644 --- a/tests/gym/envs/test_embodied_env.py +++ b/tests/gym/envs/test_embodied_env.py @@ -135,20 +135,22 @@ def test_env_rollout(self): for i in range(2): action = self.env.action_space.sample() action = torch.as_tensor( - action, dtype=torch.float32, device=self.env.device + action, + dtype=torch.float32, + device=self.env.get_wrapper_attr("device"), ) obs, reward, done, truncated, info = self.env.step(action) assert reward.shape == ( - self.env.num_envs, - ), f"Expected reward shape ({self.env.num_envs},), got {reward.shape}" + self.env.get_wrapper_attr("num_envs"), + ), f"Expected reward shape ({self.env.get_wrapper_attr('num_envs')},), got {reward.shape}" assert done.shape == ( - self.env.num_envs, - ), f"Expected done shape ({self.env.num_envs},), got {done.shape}" + self.env.get_wrapper_attr("num_envs"), + ), f"Expected done shape ({self.env.get_wrapper_attr('num_envs')},), got {done.shape}" assert truncated.shape == ( - self.env.num_envs, - ), f"Expected truncated shape ({self.env.num_envs},), got {truncated.shape}" + self.env.get_wrapper_attr("num_envs"), + ), f"Expected truncated shape ({self.env.get_wrapper_attr('num_envs')},), got {truncated.shape}" assert obs.get("robot") is not None, "Expected 'robot' info in the info dict" def teardown_method(self): From 80c653f4e3f762197ea83b5dd2137dedb335f023 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Mon, 12 Jan 2026 10:21:04 +0800 Subject: [PATCH 19/26] wip --- embodichain/lab/sim/utility/workspace_analyzer/__init__.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/embodichain/lab/sim/utility/workspace_analyzer/__init__.py b/embodichain/lab/sim/utility/workspace_analyzer/__init__.py index 57074086..a2b3e80a 100644 --- a/embodichain/lab/sim/utility/workspace_analyzer/__init__.py +++ b/embodichain/lab/sim/utility/workspace_analyzer/__init__.py @@ -53,9 +53,6 @@ from embodichain.lab.sim.utility.workspace_analyzer import visualizers from embodichain.lab.sim.utility.workspace_analyzer import metrics from embodichain.lab.sim.utility.workspace_analyzer import constraints -from embodichain.lab.sim.utility.workspace_analyzer.workspace_sampler import ( - sample_circular_plane_reachability, -) __all__ = [ "WorkspaceAnalyzer", @@ -67,5 +64,4 @@ "visualizers", "metrics", "constraints", - "sample_circular_plane_reachability", ] From 80980de72f7c865850a0aee4f6c599780d47b31f Mon Sep 17 00:00:00 2001 From: yuecideng Date: Mon, 12 Jan 2026 14:02:29 +0800 Subject: [PATCH 20/26] wip --- embodichain/lab/sim/utility/workspace_analyzer/__init__.py | 5 +---- scripts/tutorials/sim/create_sensor.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/embodichain/lab/sim/utility/workspace_analyzer/__init__.py b/embodichain/lab/sim/utility/workspace_analyzer/__init__.py index 57074086..92b6a77a 100644 --- a/embodichain/lab/sim/utility/workspace_analyzer/__init__.py +++ b/embodichain/lab/sim/utility/workspace_analyzer/__init__.py @@ -53,9 +53,7 @@ from embodichain.lab.sim.utility.workspace_analyzer import visualizers from embodichain.lab.sim.utility.workspace_analyzer import metrics from embodichain.lab.sim.utility.workspace_analyzer import constraints -from embodichain.lab.sim.utility.workspace_analyzer.workspace_sampler import ( - sample_circular_plane_reachability, -) + __all__ = [ "WorkspaceAnalyzer", @@ -67,5 +65,4 @@ "visualizers", "metrics", "constraints", - "sample_circular_plane_reachability", ] diff --git a/scripts/tutorials/sim/create_sensor.py b/scripts/tutorials/sim/create_sensor.py index 52db7b87..1ae8f5b6 100644 --- a/scripts/tutorials/sim/create_sensor.py +++ b/scripts/tutorials/sim/create_sensor.py @@ -247,7 +247,7 @@ def get_sensor_image(camera: Camera, headless=False, step_count=0): normals = data["normal"].cpu().numpy()[0] # (H, W, 3) # Normalize for visualization - depth_vis = (depth - depth.min()) / (depth.ptp() + 1e-8) + depth_vis = (depth - depth.min()) / (np.ptp(depth) + 1e-8) depth_vis = (depth_vis * 255).astype("uint8") mask_vis = mask_to_color_map(mask, user_ids=np.unique(mask)) normals_vis = ((normals + 1) / 2 * 255).astype("uint8") From ec439326510abde8d9fe161622c7289f30af6fbe Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Mon, 12 Jan 2026 14:31:13 +0800 Subject: [PATCH 21/26] add action_length && support multiple envs --- configs/gym/special/simple_task_ur10.json | 116 ++++++++++++++++++ embodichain/lab/gym/envs/__init__.py | 2 + .../envs/action_bank/configurable_action.py | 13 +- embodichain/lab/gym/envs/embodied_env.py | 24 ++-- embodichain/lab/gym/envs/managers/datasets.py | 79 ++++++------ .../lab/gym/envs/managers/object/geometry.py | 2 +- .../lab/gym/envs/managers/observations.py | 4 + .../envs/tasks/special/simple_task_ur10.py | 88 +++++++++++++ .../tasks/tableware/pour_water/pour_water.py | 1 + embodichain/lab/scripts/run_env.py | 32 ++--- scripts/data_gen.sh | 13 -- 11 files changed, 293 insertions(+), 81 deletions(-) create mode 100644 configs/gym/special/simple_task_ur10.json create mode 100644 embodichain/lab/gym/envs/tasks/special/simple_task_ur10.py delete mode 100755 scripts/data_gen.sh diff --git a/configs/gym/special/simple_task_ur10.json b/configs/gym/special/simple_task_ur10.json new file mode 100644 index 00000000..49e739c5 --- /dev/null +++ b/configs/gym/special/simple_task_ur10.json @@ -0,0 +1,116 @@ +{ + "id": "simple_task_1", + "max_episodes": 24, + "env": { + "events": { + "random_light": { + "func": "randomize_light", + "mode": "interval", + "interval_step": 20, + "params": { + "entity_cfg": {"uid": "light_1"}, + "position_range": [[-0.5, -0.5, 2], [0.5, 0.5, 2]], + "color_range": [[0.6, 0.6, 0.6], [1, 1, 1]], + "intensity_range": [50.0, 100.0] + } + }, + "random_material": { + "func": "randomize_visual_material", + "mode": "interval", + "interval_step": 50, + "params": { + "entity_cfg": {"uid": "table"}, + "random_texture_prob": 0.0, + "texture_path": "CocoBackground/coco", + "base_color_range": [[0.2, 0.2, 0.2], [1.0, 1.0, 1.0]] + } + } + }, + "dataset": { + "lerobot": { + "func": "LeRobotRecorder", + "mode": "save", + "params": { + "save_path": "/home/dex/projects/yuanhaonan/embodichain/outputs/data_example", + "id": 0, + "robot_meta": { + "robot_type": "UR10", + "arm_dofs": 6, + "control_freq": 3, + "observation": { + "vision": { + "cam_high": [] + }, + "states": ["qpos"] + }, + "action": "qpos", + "min_len_steps": 5 + }, + "instruction": { + "lang": "Acting with Oscillatory motion" + }, + "extra": { + "scene_type": "commercial", + "task_description": "Oscillatory motion", + "data_type": "sim" + }, + "use_videos": false, + "export_success_only": false + } + } + } + }, + "robot": { + "uid": "UR10", + "fpath": "UniversalRobots/UR10/UR10.urdf", + "init_pos": [0.0, 0.0, 0.7775], + "init_qpos": [1.57079, -1.57079, 1.57079, -1.57079, -1.57079, -3.14159] + }, + "sensor": [ + { + "sensor_type": "Camera", + "uid": "cam_high", + "width": 640, + "height": 480, + "intrinsics": [488.1665344238281, 488.1665344238281, 320.0, 240.0], + "extrinsics": { + "eye": [1, 0, 3], + "target": [0, 0, 1] + } + } + ], + "light": { + "direct": [ + { + "uid": "light_1", + "light_type": "point", + "color": [1.0, 1.0, 1.0], + "intensity": 50.0, + "init_pos": [2, 0, 2], + "radius": 10.0 + } + ] + }, + "background": [ + { + "uid": "table", + "shape": { + "shape_type": "Mesh", + "fpath": "CircleTableSimple/circle_table_simple.ply", + "compute_uv": true + }, + "attrs" : { + "mass": 10.0, + "static_friction": 0.95, + "dynamic_friction": 0.9, + "restitution": 0.01 + }, + "body_scale": [1, 1, 1], + "body_type": "kinematic", + "init_pos": [0.8, 0.0, 0.825], + "init_rot": [0, 90, 0] + } + ], + "rigid_object": [ + ] +} \ No newline at end of file diff --git a/embodichain/lab/gym/envs/__init__.py b/embodichain/lab/gym/envs/__init__.py index 286d7824..82130c07 100644 --- a/embodichain/lab/gym/envs/__init__.py +++ b/embodichain/lab/gym/envs/__init__.py @@ -29,3 +29,5 @@ # Reinforcement learning environments from embodichain.lab.gym.envs.tasks.rl.push_cube import PushCubeEnv + +from embodichain.lab.gym.envs.tasks.special.simple_task_ur10 import SimpleTaskEnv diff --git a/embodichain/lab/gym/envs/action_bank/configurable_action.py b/embodichain/lab/gym/envs/action_bank/configurable_action.py index 62144cfc..3dd649e9 100644 --- a/embodichain/lab/gym/envs/action_bank/configurable_action.py +++ b/embodichain/lab/gym/envs/action_bank/configurable_action.py @@ -616,9 +616,16 @@ def initialize_with_current_qpos( # TODO: Hard to get current qpos for multi-agent env current_qpos = env.robot.get_qpos() joint_ids = env.robot.get_joint_ids(name=get_control_part(env, executor)) - if current_qpos.ndim == 2 and current_qpos.shape[0] == 1: - current_qpos = current_qpos[0] - current_qpos = current_qpos[joint_ids].cpu() + + # Handle multi-environment case + if current_qpos.ndim == 2: + # current_qpos shape: [num_envs, num_joints] + # Take first environment and then select joints + current_qpos = current_qpos[0, joint_ids].cpu() + else: + # Single environment case + # current_qpos shape: [num_joints] + current_qpos = current_qpos[joint_ids].cpu() executor_qpos_dim = action_list[executor].shape[0] diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index f486be25..8400346c 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -146,7 +146,8 @@ class EmbodiedEnv(BaseEnv): def __init__(self, cfg: EmbodiedEnvCfg, **kwargs): self.affordance_datas = {} self.action_bank = None - self._force_truncated: bool = False + self.action_length: int = 0 # Set by create_demo_action_list + self._action_step_counter: int = 0 # Track steps within current action sequence extensions = getattr(cfg, "extensions", {}) or {} @@ -254,17 +255,11 @@ def get_affordance(self, key: str, default: Any = None): """ return self.affordance_datas.get(key, default) - def set_force_truncated(self, value: bool = True): - """ - Set force_truncated flag to trigger episode truncation. - """ - self._force_truncated = value - def reset( self, seed: int | None = None, options: dict | None = None ) -> Tuple[EnvObs, Dict]: obs, info = super().reset(seed=seed, options=options) - self._force_truncated: bool = False + self._action_step_counter = 0 # Reset action step counter return obs, info def step( @@ -280,6 +275,7 @@ def step( 4. Then perform the actual reset """ self._elapsed_steps += 1 + self._action_step_counter += 1 # Increment action sequence counter action = self._step_action(action=action) self.sim.update(self.sim_cfg.physics_dt, self.cfg.sim_steps_per_control) @@ -495,6 +491,14 @@ def create_demo_action_list(self, *args, **kwargs) -> Sequence[EnvAction] | None This function should be implemented in subclasses to generate a sequence of actions that demonstrate a specific task or behavior within the environment. + Important: + Subclasses MUST set `self.action_length` to the length of the returned action list. + This is used by the environment to automatically detect episode truncation. + Example: + action_list = [...] # Generate actions + self.action_length = len(action_list) + return action_list + Returns: Sequence[EnvAction] | None: A list of actions if a demonstration is available, otherwise None. """ @@ -526,8 +530,10 @@ def check_truncated(self, obs: EnvObs, info: Dict[str, Any]) -> torch.Tensor: Returns: A boolean tensor indicating truncation for each environment in the batch. """ - if self._force_truncated: + # Check if action sequence has reached its end + if self.action_length > 0 and self._action_step_counter >= self.action_length: return torch.ones(self.num_envs, dtype=torch.bool, device=self.device) + return super().check_truncated(obs, info) def close(self) -> None: diff --git a/embodichain/lab/gym/envs/managers/datasets.py b/embodichain/lab/gym/envs/managers/datasets.py index 10f5e99b..c707b976 100644 --- a/embodichain/lab/gym/envs/managers/datasets.py +++ b/embodichain/lab/gym/envs/managers/datasets.py @@ -78,9 +78,6 @@ def __init__(self, cfg: DatasetFunctorCfg, env: EmbodiedEnv): self.lerobot_data_root = params.get( "save_path", EMBODICHAIN_DEFAULT_DATASET_ROOT ) - self.repo_id = params.get( - "id", 0 - ) # Can be int (version counter) or str (dataset name) self.robot_meta = params.get("robot_meta", {}) # Optional parameters @@ -95,7 +92,7 @@ def __init__(self, cfg: DatasetFunctorCfg, env: EmbodiedEnv): # LeRobot dataset instance self.dataset: Optional[LeRobotDataset] = None - self.dataset_id: int = 0 # Will be set in _initialize_dataset + self.dataset_full_path: Optional[Path] = None # Tracking self.total_time: float = 0.0 @@ -109,7 +106,9 @@ def __init__(self, cfg: DatasetFunctorCfg, env: EmbodiedEnv): @property def dataset_path(self) -> str: """Path to the dataset directory.""" - return str(Path(self.lerobot_data_root) / self.repo_id) + return ( + str(self.dataset_full_path) if self.dataset_full_path else "Not initialized" + ) def reset(self, env_ids: Optional[torch.Tensor] = None) -> None: """Reset the recorder buffers. @@ -268,47 +267,45 @@ def _initialize_dataset(self) -> None: # Use lerobot_data_root from __init__ lerobot_data_root = Path(self.lerobot_data_root) - # repo_id from config or generate one - if isinstance(self.repo_id, int): - # If repo_id is an integer, generate a name - dataset_id = self.repo_id - while True: - repo_id = f"{robot_type}_{scene_type}_{task_description}_v{dataset_id}" - dataset_dir = lerobot_data_root / repo_id - if not dataset_dir.exists(): - break - dataset_id += 1 - self.repo_id = repo_id - self.dataset_id = dataset_id + # Generate dataset folder name with auto-incrementing suffix + base_name = f"{robot_type}_{scene_type}_{task_description}" + + # Find the next available sequence number by checking existing folders + existing_dirs = list(lerobot_data_root.glob(f"{base_name}_*")) + if not existing_dirs: + dataset_id = 0 else: - # repo_id is already a string, use it directly - self.dataset_id = 0 + # Extract sequence numbers from existing directories + max_id = -1 + for dir_path in existing_dirs: + suffix = dir_path.name[len(base_name) + 1 :] # +1 for underscore + if suffix.isdigit(): + max_id = max(max_id, int(suffix)) + dataset_id = max_id + 1 + + # Format dataset name with zero-padding (3 digits: 000, 001, 002, ...) + dataset_name = f"{base_name}_{dataset_id:03d}" + + # LeRobot's root parameter is the COMPLETE dataset path (not parent directory) + self.dataset_full_path = lerobot_data_root / dataset_name fps = self.robot_meta.get("control_freq", 30) features = self._build_features() - try: - self.dataset = LeRobotDataset.create( - repo_id=self.repo_id, - fps=fps, - root=str(lerobot_data_root), - robot_type=robot_type, - features=features, - use_videos=self.use_videos, - ) - logger.log_info( - f"Created LeRobot dataset at: {lerobot_data_root / self.repo_id}" - ) - except FileExistsError: - self.dataset = LeRobotDataset( - repo_id=self.repo_id, root=str(lerobot_data_root) - ) - logger.log_info( - f"Loaded existing LeRobot dataset at: {lerobot_data_root / self.repo_id}" - ) - except Exception as e: - logger.log_error(f"Failed to create/load LeRobot dataset: {e}") - raise + logger.log_info("------------------------------------------") + logger.log_info(f"Building dataset: {dataset_name}") + logger.log_info(f"Parent directory: {lerobot_data_root}") + logger.log_info(f"Full path: {self.dataset_full_path}") + + self.dataset = LeRobotDataset.create( + repo_id=dataset_name, + fps=fps, + root=str(self.dataset_full_path), + robot_type=robot_type, + features=features, + use_videos=self.use_videos, + ) + logger.log_info(f"Created LeRobot dataset at: {self.dataset_full_path}") def _build_features(self) -> Dict: """Build LeRobot features dict.""" diff --git a/embodichain/lab/gym/envs/managers/object/geometry.py b/embodichain/lab/gym/envs/managers/object/geometry.py index 5ede860b..1201dfb0 100644 --- a/embodichain/lab/gym/envs/managers/object/geometry.py +++ b/embodichain/lab/gym/envs/managers/object/geometry.py @@ -175,7 +175,7 @@ def compute_object_length( ) pcs = rigid_object.get_vertices(env_ids) body_scale = rigid_object.get_body_scale(env_ids) - scaled_pcs = pcs * body_scale + scaled_pcs = pcs * body_scale.unsqueeze(1) if is_svd_frame: scaled_pcs = apply_svd_transfer_pcd(scaled_pcs, sample_points) diff --git a/embodichain/lab/gym/envs/managers/observations.py b/embodichain/lab/gym/envs/managers/observations.py index d7ae016d..6f52afbb 100644 --- a/embodichain/lab/gym/envs/managers/observations.py +++ b/embodichain/lab/gym/envs/managers/observations.py @@ -207,6 +207,10 @@ def compute_semantic_mask( robot_uids = env.robot.get_user_ids() + # Ensure mask is 3D (num_envs, H, W) by squeezing last dim if it's already 4D + # if mask.dim() == 4 and mask.shape[-1] == 1: + # mask = mask.squeeze(-1) + mask_exp = mask.unsqueeze(-1) robot_uids_exp = robot_uids.unsqueeze_(1).unsqueeze_(1) diff --git a/embodichain/lab/gym/envs/tasks/special/simple_task_ur10.py b/embodichain/lab/gym/envs/tasks/special/simple_task_ur10.py new file mode 100644 index 00000000..d216704f --- /dev/null +++ b/embodichain/lab/gym/envs/tasks/special/simple_task_ur10.py @@ -0,0 +1,88 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2025 DexForce Technology Co., Ltd. +# +# 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. +# ---------------------------------------------------------------------------- + +import torch +import math +from embodichain.lab.gym.envs import EmbodiedEnv, EmbodiedEnvCfg +from embodichain.lab.gym.utils.registration import register_env +from embodichain.utils import logger + +__all__ = ["SimpleTaskEnv"] + + +@register_env("simple_task_1", max_episode_steps=600) +class SimpleTaskEnv(EmbodiedEnv): + """A demo environment with sinusoidal trajectory + + Args: + EmbodiedEnv (_type_): _description_ + """ + + def __init__(self, cfg: EmbodiedEnvCfg = None, **kwargs): + super().__init__(cfg, **kwargs) + + def create_demo_action_list(self, *args, **kwargs): + """ + Create a demonstration action list for the current task. + + This demo creates a simple sinusoidal trajectory for the robot joints. + + Returns: + list: A list of demo actions generated by the task. + """ + action_list = [] + num_steps = 100 + + # Get initial pose + init_pose = self.robot.get_qpos() # shape: (num_envs, num_joints) + + # Create a sinusoidal trajectory + for i in range(num_steps): + # Calculate phase for sinusoidal motion + t = i / num_steps # 0 to 1 + phase = torch.full( + (init_pose.shape[0],), t * 2 * torch.pi, device=self.device + ) # repeat for num_envs + + # Create sinusoidal offsets for each joint + # Joint 0: horizontal movement + # Joint 1: vertical movement + # Other joints: smaller oscillations + offset = torch.zeros_like( + init_pose, dtype=torch.float32, device=self.device + ) + offset[:, 0] = torch.sin(phase) * 0.3 # ±0.3 rad + offset[:, 1] = torch.cos(phase) * 0.2 # ±0.2 rad + offset[:, 2] = torch.sin(phase * 2) * 0.1 # ±0.1 rad, double frequency + + # Add small random variation to make it more natural + noise = (torch.rand_like(init_pose, device=self.device) - 0.5) * 0.02 + + # Compute action + action = init_pose + offset + noise + + # Clamp to joint limits if available + if hasattr(self.robot.body_data, "qpos_limits"): + qpos_limits = self.robot.body_data.qpos_limits[0] # (num_joints, 2) + action = torch.clamp(action, qpos_limits[:, 0], qpos_limits[:, 1]) + + action_list.append(action) + + logger.log_info( + f"Generated {len(action_list)} demo actions with sinusoidal trajectory" + ) + self.action_length = len(action_list) + return action_list diff --git a/embodichain/lab/gym/envs/tasks/tableware/pour_water/pour_water.py b/embodichain/lab/gym/envs/tasks/tableware/pour_water/pour_water.py index 433b76de..4d575b93 100644 --- a/embodichain/lab/gym/envs/tasks/tableware/pour_water/pour_water.py +++ b/embodichain/lab/gym/envs/tasks/tableware/pour_water/pour_water.py @@ -57,6 +57,7 @@ def create_demo_action_list(self, *args, **kwargs): logger.log_info( f"Demo action list created with {len(action_list)} steps.", color="green" ) + self.action_length = len(action_list) return action_list def create_expert_demo_action_list(self, **kwargs): diff --git a/embodichain/lab/scripts/run_env.py b/embodichain/lab/scripts/run_env.py index 57911e2b..4273e8d2 100644 --- a/embodichain/lab/scripts/run_env.py +++ b/embodichain/lab/scripts/run_env.py @@ -43,13 +43,8 @@ def generate_and_execute_action_list(env, idx, debug_mode): for idx_action, action in enumerate( tqdm.tqdm(action_list, desc=f"Executing action list #{idx}", unit="step") ): - if idx_action == len(action_list) - 1: - log_info( - f"Setting force_truncated before final step at action index: {idx_action}" - ) - env.get_wrapper_attr("set_force_truncated")(True) - # Step the environment with the current action + # The environment will automatically detect truncation based on action_length obs, reward, terminated, truncated, info = env.step(action) # TODO: We may assume in export demonstration rollout, there is no truncation from the env. @@ -87,9 +82,8 @@ def generate_function( """ valid = True + _, _ = env.reset() while True: - # _, _ = env.reset() - ret = [] for trajectory_idx in range(num_traj): valid = generate_and_execute_action_list(env, trajectory_idx, debug_mode) @@ -98,13 +92,23 @@ def generate_function( _, _ = env.reset() break - if not debug_mode and env.get_wrapper_attr("is_task_success")().item(): - pass - # TODO: Add data saving and online data streaming logic here. + # Check task success for all environments + if not debug_mode: + success = env.is_task_success() + # For multiple environments, check if all succeeded + all_success = ( + success.all().item() if success.numel() > 1 else success.item() + ) + if all_success: + pass + # TODO: Add data saving and online data streaming logic here. + else: + log_warning(f"Task fail, Skip to next generation.") + valid = False + break else: - log_warning(f"Task fail, Skip to next generation.") - valid = False - break + # In debug mode, skip success check + pass if valid: break diff --git a/scripts/data_gen.sh b/scripts/data_gen.sh deleted file mode 100755 index 598afb80..00000000 --- a/scripts/data_gen.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -NUM_PROCESSES=3 # Set this to the number of parallel processes you want - -for ((i=0; i Date: Mon, 12 Jan 2026 14:47:12 +0800 Subject: [PATCH 22/26] fixed unwrap --- embodichain/lab/gym/envs/managers/observations.py | 4 ---- embodichain/lab/scripts/run_env.py | 6 +++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/embodichain/lab/gym/envs/managers/observations.py b/embodichain/lab/gym/envs/managers/observations.py index 6f52afbb..d7ae016d 100644 --- a/embodichain/lab/gym/envs/managers/observations.py +++ b/embodichain/lab/gym/envs/managers/observations.py @@ -207,10 +207,6 @@ def compute_semantic_mask( robot_uids = env.robot.get_user_ids() - # Ensure mask is 3D (num_envs, H, W) by squeezing last dim if it's already 4D - # if mask.dim() == 4 and mask.shape[-1] == 1: - # mask = mask.squeeze(-1) - mask_exp = mask.unsqueeze(-1) robot_uids_exp = robot_uids.unsqueeze_(1).unsqueeze_(1) diff --git a/embodichain/lab/scripts/run_env.py b/embodichain/lab/scripts/run_env.py index 4273e8d2..2268be0f 100644 --- a/embodichain/lab/scripts/run_env.py +++ b/embodichain/lab/scripts/run_env.py @@ -40,8 +40,8 @@ def generate_and_execute_action_list(env, idx, debug_mode): log_warning("Action is invalid. Skip to next generation.") return False - for idx_action, action in enumerate( - tqdm.tqdm(action_list, desc=f"Executing action list #{idx}", unit="step") + for action in tqdm.tqdm( + action_list, desc=f"Executing action list #{idx}", unit="step" ): # Step the environment with the current action # The environment will automatically detect truncation based on action_length @@ -94,7 +94,7 @@ def generate_function( # Check task success for all environments if not debug_mode: - success = env.is_task_success() + success = env.get_wrapper_attr("is_task_success")() # For multiple environments, check if all succeeded all_success = ( success.all().item() if success.numel() > 1 else success.item() From b1d80f92e20e819cdec4babaa6dbcb23caa1630f Mon Sep 17 00:00:00 2001 From: yuanhaonan Date: Mon, 12 Jan 2026 14:57:45 +0800 Subject: [PATCH 23/26] remove repo_id --- configs/gym/pour_water/gym_config.json | 1 - configs/gym/pour_water/gym_config_simple.json | 1 - configs/gym/special/simple_task_ur10.json | 1 - embodichain/lab/gym/envs/managers/datasets.py | 1 - embodichain/lab/scripts/replay_dataset.py | 295 ------------------ 5 files changed, 299 deletions(-) delete mode 100644 embodichain/lab/scripts/replay_dataset.py diff --git a/configs/gym/pour_water/gym_config.json b/configs/gym/pour_water/gym_config.json index 30463e3a..04c73b1b 100644 --- a/configs/gym/pour_water/gym_config.json +++ b/configs/gym/pour_water/gym_config.json @@ -262,7 +262,6 @@ "func": "LeRobotRecorder", "mode": "save", "params": { - "id": 0, "robot_meta": { "robot_type": "CobotMagic", "arm_dofs": 12, diff --git a/configs/gym/pour_water/gym_config_simple.json b/configs/gym/pour_water/gym_config_simple.json index 7aa672af..f116e0f9 100644 --- a/configs/gym/pour_water/gym_config_simple.json +++ b/configs/gym/pour_water/gym_config_simple.json @@ -200,7 +200,6 @@ "func": "LeRobotRecorder", "mode": "save", "params": { - "id": 0, "robot_meta": { "robot_type": "CobotMagic", "arm_dofs": 12, diff --git a/configs/gym/special/simple_task_ur10.json b/configs/gym/special/simple_task_ur10.json index 49e739c5..83c8219e 100644 --- a/configs/gym/special/simple_task_ur10.json +++ b/configs/gym/special/simple_task_ur10.json @@ -32,7 +32,6 @@ "mode": "save", "params": { "save_path": "/home/dex/projects/yuanhaonan/embodichain/outputs/data_example", - "id": 0, "robot_meta": { "robot_type": "UR10", "arm_dofs": 6, diff --git a/embodichain/lab/gym/envs/managers/datasets.py b/embodichain/lab/gym/envs/managers/datasets.py index c707b976..dcd2d5ef 100644 --- a/embodichain/lab/gym/envs/managers/datasets.py +++ b/embodichain/lab/gym/envs/managers/datasets.py @@ -59,7 +59,6 @@ def __init__(self, cfg: DatasetFunctorCfg, env: EmbodiedEnv): Args: cfg: Functor configuration containing params: - save_path: Root directory for saving datasets - - id: Dataset identifier (repo_id) - robot_meta: Robot metadata for dataset - instruction: Optional task instruction - extra: Optional extra metadata diff --git a/embodichain/lab/scripts/replay_dataset.py b/embodichain/lab/scripts/replay_dataset.py deleted file mode 100644 index 96de834d..00000000 --- a/embodichain/lab/scripts/replay_dataset.py +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env python3 -# ---------------------------------------------------------------------------- -# Copyright (c) 2021-2025 DexForce Technology Co., Ltd. -# -# 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. -# ---------------------------------------------------------------------------- - -""" -Script to replay LeRobot dataset trajectories in EmbodiedEnv. - -This script loads a LeRobot dataset and replays the recorded trajectories -in the EmbodiedEnv environment. It focuses on trajectory replay and uses -sensor configurations from the environment config file. - -Usage: - python replay_dataset.py --dataset_path /path/to/dataset --config /path/to/gym_config.json - python replay_dataset.py --dataset_path outputs/commercial_cobotmagic_pour_water_001 --config configs/gym/pour_water/gym_config.json --episode 0 -""" - -import os -import argparse -import gymnasium -import torch -import numpy as np -from pathlib import Path - -from embodichain.utils.logger import log_warning, log_info, log_error -from embodichain.utils.utility import load_json -from embodichain.lab.gym.envs import EmbodiedEnvCfg -from embodichain.lab.gym.utils.gym_utils import ( - config_to_cfg, -) - - -def parse_args(): - """Parse command line arguments.""" - parser = argparse.ArgumentParser( - description="Replay LeRobot dataset in EmbodiedEnv" - ) - parser.add_argument( - "--dataset_path", - type=str, - required=True, - help="Path to the LeRobot dataset directory", - ) - parser.add_argument( - "--config", - type=str, - required=True, - help="Path to the gym config JSON file (for environment setup)", - ) - parser.add_argument( - "--episode", - type=int, - default=None, - help="Specific episode index to replay (default: replay all episodes)", - ) - parser.add_argument( - "--headless", action="store_true", help="Run in headless mode without rendering" - ) - parser.add_argument( - "--fps", - type=int, - default=None, - help="Frames per second for replay (default: use dataset fps)", - ) - parser.add_argument( - "--save_video", action="store_true", help="Save replay as video" - ) - parser.add_argument( - "--video_path", - type=str, - default="./replay_videos", - help="Path to save replay videos", - ) - return parser.parse_args() - - -def load_lerobot_dataset(dataset_path): - """Load LeRobot dataset from the given path. - - Args: - dataset_path: Path to the LeRobot dataset directory - - Returns: - LeRobotDataset instance - """ - try: - from lerobot.datasets.lerobot_dataset import LeRobotDataset - except ImportError as e: - log_error( - f"Failed to import LeRobot: {e}. " - "Please install lerobot: pip install lerobot" - ) - return None - - dataset_path = Path(dataset_path) - if not dataset_path.exists(): - log_error(f"Dataset path does not exist: {dataset_path}") - return None - - # Get repo_id from the dataset path (last directory name) - repo_id = dataset_path.name - # root = str(dataset_path.parent) - - log_info(f"Loading LeRobot dataset: {repo_id} from {dataset_path}") - - try: - dataset = LeRobotDataset(repo_id=repo_id, root=dataset_path) - log_info(f"Dataset loaded successfully:") - log_info( - f" - Total episodes: {dataset.meta.info.get('total_episodes', 'N/A')}" - ) - log_info(f" - Total frames: {dataset.meta.info.get('total_frames', 'N/A')}") - log_info(f" - FPS: {dataset.meta.info.get('fps', 'N/A')}") - log_info(f" - Robot type: {dataset.meta.info.get('robot_type', 'N/A')}") - return dataset - except Exception as e: - log_error(f"Failed to load dataset: {e}") - return None - - -def create_replay_env(config_path, headless=False): - """Create EmbodiedEnv for replay based on config. - - Args: - config_path: Path to the gym config JSON file - headless: Whether to run in headless mode - - Returns: - Gymnasium environment instance - """ - # Load configuration - gym_config = load_json(config_path) - - # Disable dataset recording during replay - if "dataset" in gym_config.get("env", {}): - gym_config["env"]["dataset"] = None - - # Convert config to dataclass - cfg: EmbodiedEnvCfg = config_to_cfg(gym_config) - - # Set render mode - if not headless: - cfg.render_mode = "human" - else: - cfg.render_mode = None - - # Create environment - log_info(f"Creating environment: {gym_config['id']}") - env = gymnasium.make(id=gym_config["id"], cfg=cfg) - - return env - - -def replay_episode( - env, dataset, episode_idx, fps=None, save_video=False, video_path=None -): - """Replay a single episode from the dataset. - - Args: - env: EmbodiedEnv instance - dataset: LeRobotDataset instance - episode_idx: Episode index to replay - fps: Frames per second for replay - save_video: Whether to save replay as video - video_path: Path to save video - - Returns: - True if replay was successful, False otherwise - """ - # Get episode data - try: - ep_meta = dataset.meta.episodes[episode_idx] - start_idx = ep_meta["dataset_from_index"] - end_idx = ep_meta["dataset_to_index"] - episode_data = [dataset[i] for i in range(start_idx, end_idx)] - log_info(f"Replaying episode {episode_idx} with {len(episode_data)} frames") - except Exception as e: - log_error(f"Failed to load episode {episode_idx}: {e}") - return False - - # Reset environment - obs, info = env.reset() - - # Setup video recording if needed - if save_video and video_path: - os.makedirs(video_path, exist_ok=True) - video_file = os.path.join(video_path, f"episode_{episode_idx:04d}.mp4") - # TODO: Implement video recording - log_warning("Video recording is not yet implemented") - - # Replay trajectory - for frame_idx in range(len(episode_data)): - # Get action from dataset - frame = episode_data[frame_idx] - - # Extract action based on dataset action space - # The action format depends on the dataset's robot configuration - if "action" in frame: - action = frame["action"] - if isinstance(action, torch.Tensor): - action = action.cpu().numpy() - else: - log_warning(f"No action found in frame {frame_idx}, skipping") - continue - - # Step environment with recorded action - obs, reward, done, truncated, info = env.step(action) - - # Optional: Add delay to match FPS - if fps: - import time - - time.sleep(1.0 / fps) - - # Check if episode ended - if done or truncated: - log_info(f"Episode ended at frame {frame_idx}/{len(episode_data)}") - break - - log_info(f"Successfully replayed episode {episode_idx}") - return True - - -def main(): - """Main function to replay LeRobot dataset.""" - args = parse_args() - - # Load dataset - dataset = load_lerobot_dataset(args.dataset_path) - if dataset is None: - return - - # Create replay environment - env = create_replay_env(args.config, headless=args.headless) - - # Determine FPS - fps = args.fps if args.fps else dataset.meta.info.get("fps", 30) - log_info(f"Replay FPS: {fps}") - - # Replay episodes - if args.episode is not None: - # Replay single episode - log_info(f"Replaying single episode: {args.episode}") - success = replay_episode( - env, - dataset, - args.episode, - fps=fps, - save_video=args.save_video, - video_path=args.video_path, - ) - if not success: - log_error(f"Failed to replay episode {args.episode}") - else: - # Replay all episodes - total_episodes = dataset.meta.info.get("total_episodes", 0) - log_info(f"Replaying all {total_episodes} episodes") - - for episode_idx in range(total_episodes): - log_info(f"\n{'='*60}") - log_info(f"Episode {episode_idx + 1}/{total_episodes}") - log_info(f"{'='*60}") - - success = replay_episode( - env, - dataset, - episode_idx, - fps=fps, - save_video=args.save_video, - video_path=args.video_path, - ) - - if not success: - log_warning(f"Skipping episode {episode_idx} due to errors") - continue - - # Cleanup - env.close() - log_info("Replay completed successfully!") - - -if __name__ == "__main__": - main() From fabefee45f6f5ecbf837a8171f01bf3a808e82f1 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Mon, 12 Jan 2026 16:29:23 +0800 Subject: [PATCH 24/26] wip --- configs/gym/special/simple_task_ur10.json | 3 +- embodichain/data/enum.py | 152 ------------------ embodichain/lab/gym/envs/__init__.py | 2 +- embodichain/lab/gym/envs/embodied_env.py | 2 + embodichain/lab/gym/envs/managers/__init__.py | 2 +- embodichain/lab/gym/envs/managers/datasets.py | 4 + .../{simple_task_ur10.py => simple_task.py} | 4 +- 7 files changed, 11 insertions(+), 158 deletions(-) rename embodichain/lab/gym/envs/tasks/special/{simple_task_ur10.py => simple_task.py} (98%) diff --git a/configs/gym/special/simple_task_ur10.json b/configs/gym/special/simple_task_ur10.json index 83c8219e..ee84c5ff 100644 --- a/configs/gym/special/simple_task_ur10.json +++ b/configs/gym/special/simple_task_ur10.json @@ -1,5 +1,5 @@ { - "id": "simple_task_1", + "id": "SimpleTask-v1", "max_episodes": 24, "env": { "events": { @@ -31,7 +31,6 @@ "func": "LeRobotRecorder", "mode": "save", "params": { - "save_path": "/home/dex/projects/yuanhaonan/embodichain/outputs/data_example", "robot_meta": { "robot_type": "UR10", "arm_dofs": 6, diff --git a/embodichain/data/enum.py b/embodichain/data/enum.py index 61637892..cf758192 100644 --- a/embodichain/data/enum.py +++ b/embodichain/data/enum.py @@ -63,18 +63,6 @@ class Hints(Enum): ARM = (ControlParts.LEFT_ARM.value, ControlParts.RIGHT_ARM.value) -class Modality(Enum): - STATES = "states" - STATE_INDICATOR = "state_indicator" - ACTIONS = "actions" - ACTION_INDICATOR = "action_indicator" - IMAGES = "images" - LANG = "lang" - LANG_INDICATOR = "lang_indicator" - GEOMAP = "geomap" # e.g., depth, point cloud, etc. - VISION_LANGUAGE = "vision_language" # e.g., image + lang - - class JointType(Enum): QPOS = "qpos" @@ -86,143 +74,3 @@ class EefType(Enum): class ActionMode(Enum): ABSOLUTE = "" RELATIVE = "delta_" # This indicates the action is relative change with respect to last state. - - -SUPPORTED_PROPRIO_TYPES = [ - ControlParts.LEFT_ARM.value + EefType.POSE.value, - ControlParts.RIGHT_ARM.value + EefType.POSE.value, - ControlParts.LEFT_ARM.value + JointType.QPOS.value, - ControlParts.RIGHT_ARM.value + JointType.QPOS.value, - ControlParts.LEFT_EEF.value + EndEffector.DEXTROUSHAND.value, - ControlParts.RIGHT_EEF.value + EndEffector.DEXTROUSHAND.value, - ControlParts.LEFT_EEF.value + EndEffector.GRIPPER.value, - ControlParts.RIGHT_EEF.value + EndEffector.GRIPPER.value, -] -SUPPORTED_ACTION_TYPES = SUPPORTED_PROPRIO_TYPES + [ - ControlParts.LEFT_ARM.value + ActionMode.RELATIVE.value + JointType.QPOS.value, - ControlParts.RIGHT_ARM.value + ActionMode.RELATIVE.value + JointType.QPOS.value, -] - - -class HandQposNormalizer: - """ - A class for normalizing and denormalizing dexterous hand qpos data. - """ - - def __init__(self): - pass - - @staticmethod - def normalize_hand_qpos( - qpos_data: np.ndarray, - key: str, - agent=None, - robot=None, - ) -> np.ndarray: - """ - Clip and normalize dexterous hand qpos data. - - Args: - qpos_data: Raw qpos data - key: Control part key - agent: LearnableRobot instance (for V2 API) - robot: Robot instance (for V3 API) - - Returns: - Normalized qpos data in range [0, 1] - """ - if isinstance(qpos_data, torch.Tensor): - qpos_data = qpos_data.cpu().numpy() - - if agent is not None: - if key not in [ - ControlParts.LEFT_EEF.value + EndEffector.DEXTROUSHAND.value, - ControlParts.RIGHT_EEF.value + EndEffector.DEXTROUSHAND.value, - ]: - return qpos_data - indices = agent.get_data_index(key, warning=False) - full_limits = agent.get_joint_limits(agent.uid) - limits = full_limits[indices] # shape: [num_joints, 2] - elif robot is not None: - if key not in [ - ControlParts.LEFT_EEF.value + EndEffector.DEXTROUSHAND.value, - ControlParts.RIGHT_EEF.value + EndEffector.DEXTROUSHAND.value, - ]: - if key in [ControlParts.LEFT_EEF.value, ControlParts.RIGHT_EEF.value]: - # Note: In V3, robot does not distinguish between GRIPPER EEF and HAND EEF in uid, - # _data_key_to_control_part maps both to EEF. Under current conditions, normalization - # will not be performed. Please confirm if this is intended. - pass - return qpos_data - indices = robot.get_joint_ids(key, remove_mimic=True) - limits = robot.body_data.qpos_limits[0][indices] # shape: [num_joints, 2] - else: - raise ValueError("Either agent or robot must be provided") - - if isinstance(limits, torch.Tensor): - limits = limits.cpu().numpy() - - qpos_min = limits[:, 0] # Lower limits - qpos_max = limits[:, 1] # Upper limits - - # Step 1: Clip to valid range - qpos_clipped = np.clip(qpos_data, qpos_min, qpos_max) - - # Step 2: Normalize to [0, 1] - qpos_normalized = (qpos_clipped - qpos_min) / (qpos_max - qpos_min + 1e-8) - - return qpos_normalized - - @staticmethod - def denormalize_hand_qpos( - normalized_qpos: torch.Tensor, - key: str, # "left" or "right" - agent=None, - robot=None, - ) -> torch.Tensor: - """ - Denormalize normalized dexterous hand qpos back to actual angle values - - Args: - normalized_qpos: Normalized qpos in range [0, 1] - key: Control part key - robot: Robot instance - - Returns: - Denormalized actual qpos values - """ - - if agent is not None: - if key not in [ - ControlParts.LEFT_EEF.value + EndEffector.DEXTROUSHAND.value, - ControlParts.RIGHT_EEF.value + EndEffector.DEXTROUSHAND.value, - ]: - return normalized_qpos - indices = agent.get_data_index(key, warning=False) - full_limits = agent.get_joint_limits(agent.uid) - limits = full_limits[indices] # shape: [num_joints, 2] - elif robot is not None: - if key not in [ - ControlParts.LEFT_EEF.value + EndEffector.DEXTROUSHAND.value, - ControlParts.RIGHT_EEF.value + EndEffector.DEXTROUSHAND.value, - ]: - if key in [ControlParts.LEFT_EEF.value, ControlParts.RIGHT_EEF.value]: - # Note: In V3, robot does not distinguish between GRIPPER EEF and HAND EEF in uid, - # _data_key_to_control_part maps both to EEF. Under current conditions, denormalization - # will not be performed. Please confirm if this is intended. - pass - return normalized_qpos - indices = robot.get_joint_ids(key, remove_mimic=True) - limits = robot.body_data.qpos_limits[0][indices] # shape: [num_joints, 2] - else: - raise ValueError("Either agent or robot must be provided") - - qpos_min = limits[:, 0].cpu().numpy() # Lower limits - qpos_max = limits[:, 1].cpu().numpy() # Upper limits - - if isinstance(normalized_qpos, torch.Tensor): - normalized_qpos = normalized_qpos.cpu().numpy() - - denormalized_qpos = normalized_qpos * (qpos_max - qpos_min) + qpos_min - - return denormalized_qpos diff --git a/embodichain/lab/gym/envs/__init__.py b/embodichain/lab/gym/envs/__init__.py index 82130c07..88257690 100644 --- a/embodichain/lab/gym/envs/__init__.py +++ b/embodichain/lab/gym/envs/__init__.py @@ -30,4 +30,4 @@ # Reinforcement learning environments from embodichain.lab.gym.envs.tasks.rl.push_cube import PushCubeEnv -from embodichain.lab.gym.envs.tasks.special.simple_task_ur10 import SimpleTaskEnv +from embodichain.lab.gym.envs.tasks.special.simple_task import SimpleTaskEnv diff --git a/embodichain/lab/gym/envs/embodied_env.py b/embodichain/lab/gym/envs/embodied_env.py index 8400346c..8ce31346 100644 --- a/embodichain/lab/gym/envs/embodied_env.py +++ b/embodichain/lab/gym/envs/embodied_env.py @@ -146,6 +146,8 @@ class EmbodiedEnv(BaseEnv): def __init__(self, cfg: EmbodiedEnvCfg, **kwargs): self.affordance_datas = {} self.action_bank = None + + # TODO: Change to array like data structure to handle different demo action list length for across different arena. self.action_length: int = 0 # Set by create_demo_action_list self._action_step_counter: int = 0 # Track steps within current action sequence diff --git a/embodichain/lab/gym/envs/managers/__init__.py b/embodichain/lab/gym/envs/managers/__init__.py index b7825cc9..e38f4f22 100644 --- a/embodichain/lab/gym/envs/managers/__init__.py +++ b/embodichain/lab/gym/envs/managers/__init__.py @@ -25,4 +25,4 @@ from .event_manager import EventManager from .observation_manager import ObservationManager from .dataset_manager import DatasetManager -from .datasets import LeRobotRecorder +from .datasets import * diff --git a/embodichain/lab/gym/envs/managers/datasets.py b/embodichain/lab/gym/envs/managers/datasets.py index dcd2d5ef..09c0c1e2 100644 --- a/embodichain/lab/gym/envs/managers/datasets.py +++ b/embodichain/lab/gym/envs/managers/datasets.py @@ -40,9 +40,13 @@ from lerobot.datasets.lerobot_dataset import LeRobotDataset, HF_LEROBOT_HOME LEROBOT_AVAILABLE = True + + __all__ = ["LeRobotRecorder"] except ImportError: LEROBOT_AVAILABLE = False + __all__ = [] + class LeRobotRecorder(Functor): """Functor for recording episodes in LeRobot format. diff --git a/embodichain/lab/gym/envs/tasks/special/simple_task_ur10.py b/embodichain/lab/gym/envs/tasks/special/simple_task.py similarity index 98% rename from embodichain/lab/gym/envs/tasks/special/simple_task_ur10.py rename to embodichain/lab/gym/envs/tasks/special/simple_task.py index d216704f..5c9c879d 100644 --- a/embodichain/lab/gym/envs/tasks/special/simple_task_ur10.py +++ b/embodichain/lab/gym/envs/tasks/special/simple_task.py @@ -15,7 +15,7 @@ # ---------------------------------------------------------------------------- import torch -import math + from embodichain.lab.gym.envs import EmbodiedEnv, EmbodiedEnvCfg from embodichain.lab.gym.utils.registration import register_env from embodichain.utils import logger @@ -23,7 +23,7 @@ __all__ = ["SimpleTaskEnv"] -@register_env("simple_task_1", max_episode_steps=600) +@register_env("SimpleTask-v1", max_episode_steps=600) class SimpleTaskEnv(EmbodiedEnv): """A demo environment with sinusoidal trajectory From ec226703388fedea80e6cc1a6be3e166d90b0c1a Mon Sep 17 00:00:00 2001 From: yuecideng Date: Mon, 12 Jan 2026 16:34:31 +0800 Subject: [PATCH 25/26] update install docs --- docs/source/quick_start/install.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/source/quick_start/install.md b/docs/source/quick_start/install.md index fd00abef..7bb9bf5f 100644 --- a/docs/source/quick_start/install.md +++ b/docs/source/quick_start/install.md @@ -53,8 +53,14 @@ Install the project in development mode: ```bash pip install -e . --extra-index-url http://pyp.open3dv.site:2345/simple/ --trusted-host pyp.open3dv.site + +# Or install with the lerobot extras: +pip install -e .[lerobot] --extra-index-url http://pyp.open3dv.site:2345/simple/ --trusted-host pyp.open3dv.site ``` +> [!NOTE] +> * [LeRobot](https://huggingface.co/docs/lerobot/installation) is an optional module for EmbodiChain that provides data saving and loading functionalities for robot learning tasks. Installing with the `lerobot` extras will include this module and its dependencies. + ### Verify Installation To verify that EmbodiChain is installed correctly, run a simple demo script to create a simulation scene: From 2e05ca077f5a9cd5761edd8e547039815dc00512 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Mon, 12 Jan 2026 18:52:06 +0800 Subject: [PATCH 26/26] wip --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7d819218..4cb35e58 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -80,6 +80,7 @@ jobs: export HF_ENDPOINT=https://hf-mirror.com pip uninstall pymeshlab -y pip install pymeshlab==2023.12.post3 + pip install numpy==1.26.4 pytest tests publish: