From 931802aca049ccf4ed94eba106f6c5783b0b42ae Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Mon, 26 Jan 2026 13:36:20 -0500 Subject: [PATCH 01/24] Added local envionment for Go2 --- .gitignore | 1 + .vscode/.gitignore | 3 + NOTES.md | 86 +++++++++++++++++++ .../reinforcement_learning/rsl_rl/train.py | 35 ++++++++ .../velocity/config/go2/__init__.py | 22 +++++ .../velocity/config/go2/local_flat_env_cfg.py | 76 ++++++++++++++++ 6 files changed, 223 insertions(+) create mode 100644 NOTES.md create mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/local_flat_env_cfg.py diff --git a/.gitignore b/.gitignore index 7afb58e9ee0..dbcc20ae3db 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ tests/ # TacSL sensor **/tactile_record/* **/gelsight_r15_data/* +local_assets/ diff --git a/.vscode/.gitignore b/.vscode/.gitignore index 10b0af342ce..b5210f5de4c 100644 --- a/.vscode/.gitignore +++ b/.vscode/.gitignore @@ -8,3 +8,6 @@ # Ignore all other files .python.env *.json + +# Ignore Local Assets +local_assets/ diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 00000000000..fbb2ac80a50 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,86 @@ +conda deactivate + + +``` +./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ + --headless \ + --num_envs 128 +``` + + +``` +./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ + --num_envs 128 \ + --local +``` + +``` +./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Velocity-Flat-H1-v0 \ + --num_envs 128 \ + --local +``` + + + +``` +./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ + --num_envs 4096 \ + --resume +``` + + +``` +./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ + --headless \ + --num_envs 4096 \ + --resume \ + --load_run 2026-01-21_14-09-41 \ + --checkpoint model_50.pt +``` + + +``` +./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ + --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ + --num_envs 128 \ + --checkpoint logs/rsl_rl/unitree_go2_flat/2026-01-21_14-38-05/model_299.pt +``` + + +``` +./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ + --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ + --num_envs 1 \ + --checkpoint logs/rsl_rl/velocity_flat_unitree_go2/*/model_200.pt \ + --video \ + --video_length 1000 \ + --video_interval 1 +``` + + +``` +./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ + --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ + --num_envs 1 \ + --load_run 2025-11-29_11-15-51 \ + --checkpoint model_350.pt \ + --video \ + --video_length 500 +``` + +``` +./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ + --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ + --num_envs 1 \ + --load_run 2025-11-29_11-15-51 \ + --checkpoint model_350.pt \ + --video \ + --video_length 1000 \ + --video_interval 2 +``` + diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index 0cce12d7eba..20fb1014f84 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -34,12 +34,32 @@ parser.add_argument( "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) +parser.add_argument( + "--local", + action="store_true", + default=False, + help="Use local assets and configurations (offline mode)" +) + # append RSL-RL cli arguments cli_args.add_rsl_rl_args(parser) # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) args_cli, hydra_args = parser.parse_known_args() +if args_cli.local: + import os + print("[INFO] Running in LOCAL/OFFLINE mode") + + # Set environment variables for offline mode + os.environ["ISAACLAB_OFFLINE"] = "1" + os.environ["CARB_APP_RUN_OFFLINE"] = "1" + + # Transform task name to use local variant + if "Flat" in args_cli.task: + args_cli.task = args_cli.task.replace("Flat", "LocalFlat") + print(f"[INFO] Using local task: {args_cli.task}") + # always enable cameras to record video if args_cli.video: args_cli.enable_cameras = True @@ -114,6 +134,8 @@ @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): """Train with RSL-RL agent.""" + import os + # override configurations with non-hydra CLI arguments agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs @@ -121,6 +143,19 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen args_cli.max_iterations if args_cli.max_iterations is not None else agent_cfg.max_iterations ) + # Handle local mode - modify config to use local assets + if args_cli.local: + import os + print("[INFO] Configuring local assets") + + # Override robot USD path to use local assets + isaaclab_path = os.environ.get('ISAACLAB_PATH', os.getcwd()) + local_usd_path = f"{isaaclab_path}/local_assets/unitree_model/Go2/usd/go2.usd" + + if hasattr(env_cfg.scene, 'robot') and hasattr(env_cfg.scene.robot, 'spawn'): + env_cfg.scene.robot.spawn.usd_path = local_usd_path + print(f"[INFO] Using local robot USD: {local_usd_path}") + # set the environment seed # note: certain randomizations occur in the environment initialization so we set the seed here env_cfg.seed = agent_cfg.seed diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/__init__.py index 4ea7d3fce71..6f9858b2c58 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/__init__.py @@ -33,6 +33,28 @@ }, ) +gym.register( + id="Isaac-Velocity-LocalFlat-Unitree-Go2-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.local_flat_env_cfg:UnitreeGo2FlatEnvCfg", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:UnitreeGo2FlatPPORunnerCfg", + "skrl_cfg_entry_point": f"{agents.__name__}:skrl_flat_ppo_cfg.yaml", + }, +) + +gym.register( + id="Isaac-Velocity-LocalFlat-Unitree-Go2-Play-v0", + entry_point="isaaclab.envs:ManagerBasedRLEnv", + disable_env_checker=True, + kwargs={ + "env_cfg_entry_point": f"{__name__}.local_flat_env_cfg:UnitreeGo2FlatEnvCfg_PLAY", + "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:UnitreeGo2FlatPPORunnerCfg", + "skrl_cfg_entry_point": f"{agents.__name__}:skrl_flat_ppo_cfg.yaml", + }, +) + gym.register( id="Isaac-Velocity-Rough-Unitree-Go2-v0", entry_point="isaaclab.envs:ManagerBasedRLEnv", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/local_flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/local_flat_env_cfg.py new file mode 100644 index 00000000000..da9dcbd9c1c --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/local_flat_env_cfg.py @@ -0,0 +1,76 @@ +"""Local offline configuration for Unitree Go2 flat terrain training.""" + +import os +from isaaclab.utils import configclass +import isaaclab.sim as sim_utils +from isaaclab.terrains.trimesh.mesh_terrains_cfg import MeshPlaneTerrainCfg +from isaaclab.terrains.config.rough import ROUGH_TERRAINS_CFG + +from .rough_env_cfg import UnitreeGo2RoughEnvCfg + + +@configclass +class UnitreeGo2FlatEnvCfg(UnitreeGo2RoughEnvCfg): + """Configuration for Unitree Go2 locomotion on flat terrain using local assets.""" + + def __post_init__(self): + super().__post_init__() + + # Get IsaacLab root path for local assets + isaaclab_path = os.environ.get('ISAACLAB_PATH', os.getcwd()) + + # Reward tuning for flat terrain + self.rewards.flat_orientation_l2.weight = -2.5 + self.rewards.feet_air_time.weight = 0.25 + + # Create flat terrain with grid pattern + self.scene.terrain.terrain_type = "generator" + self.scene.terrain.terrain_generator = ROUGH_TERRAINS_CFG.copy() + self.scene.terrain.terrain_generator.sub_terrains = { + "flat": MeshPlaneTerrainCfg(size=(8.0, 8.0)) + } + self.scene.terrain.terrain_generator.proportion = [1.0] + self.scene.terrain.terrain_generator.curriculum = False + + # Grid-like visual material (light gray with darker grid lines) + self.scene.terrain.visual_material = sim_utils.PreviewSurfaceCfg( + diffuse_color=(0.8, 0.8, 0.8), # Light gray base + metallic=0.0, + roughness=0.5, + ) + + # Apply grid texture overlay + # Note: For true grid lines, you'd need a grid texture file + # For now, this creates a clean, flat appearance + self.scene.terrain.physics_material = sim_utils.RigidBodyMaterialCfg( + static_friction=1.0, + dynamic_friction=1.0, + restitution=0.0, + ) + + # Local asset paths + self.scene.sky_light.spawn.texture_file = f"{isaaclab_path}/local_assets/Textures/kloofendal_43d_clear_puresky_4k.hdr" + self.commands.base_velocity.goal_vel_visualizer_cfg.markers["arrow"].usd_path = f"{isaaclab_path}/local_assets/Props/arrow_x.usd" + self.commands.base_velocity.current_vel_visualizer_cfg.markers["arrow"].usd_path = f"{isaaclab_path}/local_assets/Props/arrow_x.usd" + + # Disable height scanning and terrain curriculum + self.scene.height_scanner = None + self.observations.policy.height_scan = None + self.curriculum.terrain_levels = None + + +@configclass +class UnitreeGo2FlatEnvCfg_PLAY(UnitreeGo2FlatEnvCfg): + """Play configuration with smaller scene and no randomization.""" + + def __post_init__(self) -> None: + super().__post_init__() + + # Smaller scene for visualization + self.scene.num_envs = 50 + self.scene.env_spacing = 2.5 + + # Disable randomization + self.observations.policy.enable_corruption = False + self.events.base_external_force_torque = None + self.events.push_robot = None \ No newline at end of file From 58bb6a4bc7454a78c2483bea3d91de7ad4f7b570 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 12:06:24 -0500 Subject: [PATCH 02/24] Add generic local asset resolution system for offline training --- NOTES.md | 2 + .../reinforcement_learning/rsl_rl/train.py | 42 +-- scripts/setup/download_assets.py | 309 ++++++++++++++++ .../velocity/config/go2/__init__.py | 22 -- .../velocity/config/go2/local_flat_env_cfg.py | 76 ---- .../isaaclab_tasks/utils/__init__.py | 21 ++ .../utils/local_asset_resolver.py | 345 ++++++++++++++++++ 7 files changed, 685 insertions(+), 132 deletions(-) create mode 100644 scripts/setup/download_assets.py delete mode 100644 source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/local_flat_env_cfg.py create mode 100644 source/isaaclab_tasks/isaaclab_tasks/utils/local_asset_resolver.py diff --git a/NOTES.md b/NOTES.md index fbb2ac80a50..c860073e006 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1,5 +1,7 @@ conda deactivate +./isaaclab.sh -p scripts/setup/download_assets.py --categories Props Robots Environments Materials Controllers ActuatorNets Policies Mimic + ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index 20fb1014f84..48d631d4d00 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -34,31 +34,13 @@ parser.add_argument( "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) -parser.add_argument( - "--local", - action="store_true", - default=False, - help="Use local assets and configurations (offline mode)" -) +parser.add_argument( "--local", action="store_true", default=False, help="Use local assets and configurations (offline mode)") # append RSL-RL cli arguments cli_args.add_rsl_rl_args(parser) # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) args_cli, hydra_args = parser.parse_known_args() - -if args_cli.local: - import os - print("[INFO] Running in LOCAL/OFFLINE mode") - - # Set environment variables for offline mode - os.environ["ISAACLAB_OFFLINE"] = "1" - os.environ["CARB_APP_RUN_OFFLINE"] = "1" - - # Transform task name to use local variant - if "Flat" in args_cli.task: - args_cli.task = args_cli.task.replace("Flat", "LocalFlat") - print(f"[INFO] Using local task: {args_cli.task}") # always enable cameras to record video if args_cli.video: @@ -95,8 +77,8 @@ """Rest everything follows.""" -import logging import os +import logging import time from datetime import datetime @@ -134,8 +116,7 @@ @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): """Train with RSL-RL agent.""" - import os - + # override configurations with non-hydra CLI arguments agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs @@ -143,19 +124,12 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen args_cli.max_iterations if args_cli.max_iterations is not None else agent_cfg.max_iterations ) - # Handle local mode - modify config to use local assets + # Handle local config to use local assets if args_cli.local: - import os - print("[INFO] Configuring local assets") - - # Override robot USD path to use local assets - isaaclab_path = os.environ.get('ISAACLAB_PATH', os.getcwd()) - local_usd_path = f"{isaaclab_path}/local_assets/unitree_model/Go2/usd/go2.usd" - - if hasattr(env_cfg.scene, 'robot') and hasattr(env_cfg.scene.robot, 'spawn'): - env_cfg.scene.robot.spawn.usd_path = local_usd_path - print(f"[INFO] Using local robot USD: {local_usd_path}") - + from isaaclab_tasks.utils.local_asset_resolver import setup_local_mode, patch_config_for_local_mode + setup_local_mode() + patch_config_for_local_mode(env_cfg) + # set the environment seed # note: certain randomizations occur in the environment initialization so we set the seed here env_cfg.seed = agent_cfg.seed diff --git a/scripts/setup/download_assets.py b/scripts/setup/download_assets.py new file mode 100644 index 00000000000..dd2294cdb84 --- /dev/null +++ b/scripts/setup/download_assets.py @@ -0,0 +1,309 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +""" +This script downloads all assets from Nucleus server (ISAACLAB_NUCLEUS_DIR) and mirrors +the directory structure locally in the local_assets folder. This enables offline/local training +without requiring S3/Nucleus connectivity. Must be connected to the internet to use! + +Usage: + ./isaaclab.sh -p scripts/setup/download_assets.py --categories Props Robots Environments Materials Controllers ActuatorNets Policies Mimic + ./isaaclab.sh -p scripts/setup/download_assets.py [--subset ROBOT_NAME] +""" + +import argparse +import os +from pathlib import Path + +# Initialize Isaac Sim app first to get access to omni modules +from isaaclab.app import AppLauncher + +# Create minimal app launcher to initialize Isaac Sim environment +app_launcher = AppLauncher(headless=True) +simulation_app = app_launcher.app + +import carb +import omni.client + +try: + from tqdm import tqdm + HAS_TQDM = True +except ImportError: + HAS_TQDM = False + print("Note: Install tqdm for progress bars: pip install tqdm") + +# Get Isaac Lab paths +ISAACLAB_PATH = os.environ.get("ISAACLAB_PATH", os.getcwd()) +LOCAL_ASSETS_DIR = os.path.join(ISAACLAB_PATH, "local_assets") + +# Get the Nucleus directory from settings +settings = carb.settings.get_settings() +NUCLEUS_ASSET_ROOT = settings.get("/persistent/isaac/asset_root/default") +ISAAC_NUCLEUS_DIR = f"{NUCLEUS_ASSET_ROOT}/Isaac" # General Isaac Sim assets +ISAACLAB_NUCLEUS_DIR = f"{NUCLEUS_ASSET_ROOT}/Isaac/IsaacLab" # Isaac Lab specific assets + +# Asset categories and their locations +ASSET_CATEGORIES = { + # General Isaac Sim assets (Isaac/) + "Props": {"desc": "Props, objects, markers, and mounts", "base": ISAAC_NUCLEUS_DIR}, + "Robots": {"desc": "Robot USD files and configurations", "base": ISAAC_NUCLEUS_DIR}, + "Environments": {"desc": "Environment assets and terrains", "base": ISAAC_NUCLEUS_DIR}, + "Materials": {"desc": "Materials and textures including sky HDRs", "base": ISAAC_NUCLEUS_DIR}, + + # Isaac Lab specific assets (Isaac/IsaacLab/) + "Controllers": {"desc": "IK controllers and kinematics assets", "base": ISAACLAB_NUCLEUS_DIR}, + "ActuatorNets": {"desc": "Actuator network models", "base": ISAACLAB_NUCLEUS_DIR}, + "Policies": {"desc": "Pre-trained policy checkpoints", "base": ISAACLAB_NUCLEUS_DIR}, + "Mimic": {"desc": "Demonstration and imitation learning assets", "base": ISAACLAB_NUCLEUS_DIR}, +} + + +def ensure_local_directory(local_path: str) -> None: + """Create local directory if it doesn't exist.""" + os.makedirs(local_path, exist_ok=True) + + +def download_file(remote_path: str, local_path: str) -> bool: + """ + Download a single file from Nucleus to local storage. + + Args: + remote_path: Full Nucleus URL (e.g., omniverse://...) + local_path: Local file system path + + Returns: + True if successful, False otherwise + """ + try: + # Create parent directory if needed + os.makedirs(os.path.dirname(local_path), exist_ok=True) + + # Copy file from Nucleus to local + result = omni.client.copy(remote_path, local_path) + return result == omni.client.Result.OK + except Exception as e: + print(f"Error downloading {remote_path}: {e}") + return False + + +def list_nucleus_directory(remote_path: str) -> list[tuple[str, bool]]: + """ + List all files and directories in a Nucleus path. + + Args: + remote_path: Nucleus directory URL + + Returns: + List of (item_name, is_directory) tuples + """ + result, entries = omni.client.list(remote_path) + if result != omni.client.Result.OK: + return [] + + items = [] + for entry in entries: + is_dir = entry.flags & omni.client.ItemFlags.CAN_HAVE_CHILDREN + items.append((entry.relative_path, bool(is_dir))) + return items + + +def download_directory_recursive(remote_path: str, local_base: str, progress_bar=None) -> None: + """ + Recursively download a directory from Nucleus to local storage. + + Args: + remote_path: Nucleus directory URL + local_base: Local directory to mirror structure + progress_bar: Optional tqdm progress bar (or None) + """ + items = list_nucleus_directory(remote_path) + + for item_name, is_directory in items: + remote_item = f"{remote_path}/{item_name}" + local_item = os.path.join(local_base, item_name) + + if is_directory: + # Recursively download subdirectory + ensure_local_directory(local_item) + download_directory_recursive(remote_item, local_item, progress_bar) + else: + # Download file + if progress_bar is not None: + progress_bar.set_description(f"Downloading {item_name}") + else: + print(f" Downloading: {item_name}") + download_file(remote_item, local_item) + if progress_bar is not None: + progress_bar.update(1) + + +def count_files_recursive(remote_path: str) -> int: + """Count total files in a directory tree for progress tracking.""" + count = 0 + items = list_nucleus_directory(remote_path) + + for item_name, is_directory in items: + if is_directory: + count += count_files_recursive(f"{remote_path}/{item_name}") + else: + count += 1 + return count + + +def download_asset_category(category: str, subset: str = None) -> None: + """ + Download all assets in a specific category. + + Args: + category: Asset category (e.g., "Robots", "Props") + subset: Optional subset filter (e.g., specific robot name) + """ + category_info = ASSET_CATEGORIES[category] + base_path = category_info["base"] + description = category_info["desc"] + + remote_dir = f"{base_path}/{category}" + local_dir = os.path.join(LOCAL_ASSETS_DIR, category) + + print(f"\n{'='*60}") + print(f"Downloading {category}: {description}") + print(f"From: {remote_dir}") + print(f"To: {local_dir}") + print(f"{'='*60}") + + # If subset is specified, only download that subset + if subset and category == "Robots": + remote_dir = f"{remote_dir}/{subset}" + local_dir = os.path.join(local_dir, subset) + print(f"Filtering to subset: {subset}") + + # Check if remote directory exists + result, _ = omni.client.stat(remote_dir) + if result != omni.client.Result.OK: + print(f"⚠️ Directory not found: {remote_dir}") + print(f" This category may not be available or may be in a different location.") + return + + # Count files for progress bar + print("Counting files...") + total_files = count_files_recursive(remote_dir) + print(f"Found {total_files} files to download") + + if total_files == 0: + print("No files to download") + return + + # Download with progress bar + ensure_local_directory(local_dir) + if HAS_TQDM: + with tqdm(total=total_files, unit="file") as pbar: + download_directory_recursive(remote_dir, local_dir, pbar) + else: + print(f"Downloading {total_files} files (install tqdm for progress bars)...") + download_directory_recursive(remote_dir, local_dir, None) + + print(f"✓ Completed {category}") + + +def verify_downloads(category: str = None) -> None: + """Verify that local assets directory has expected structure.""" + print("\n" + "="*60) + print("Verifying local assets...") + print("="*60) + + categories_to_check = [category] if category else ASSET_CATEGORIES.keys() + + for cat in categories_to_check: + local_dir = os.path.join(LOCAL_ASSETS_DIR, cat) + if os.path.exists(local_dir): + file_count = sum(1 for _ in Path(local_dir).rglob("*") if _.is_file()) + print(f"✓ {cat}: {file_count} files") + else: + print(f"✗ {cat}: Not found") + + +def main(): + parser = argparse.ArgumentParser(description="Download Isaac Lab assets from Nucleus to local storage") + parser.add_argument( + "--categories", + nargs="+", + choices=list(ASSET_CATEGORIES.keys()) + ["all"], + default=["all"], + help="Asset categories to download (default: all)" + ) + parser.add_argument( + "--subset", + type=str, + help="Download only specific subset (e.g., 'ANYbotics' or 'Unitree' for robots)" + ) + parser.add_argument( + "--verify-only", + action="store_true", + help="Only verify existing downloads without downloading" + ) + + args = parser.parse_args() + + try: + print("\n" + "="*60) + print("Isaac Lab Asset Downloader") + print("="*60) + print(f"Isaac Sim assets: {ISAAC_NUCLEUS_DIR}") + print(f"Isaac Lab assets: {ISAACLAB_NUCLEUS_DIR}") + print(f"Local target: {LOCAL_ASSETS_DIR}") + print("="*60) + + if args.verify_only: + verify_downloads() + return + + # Determine which categories to download + categories = ( + list(ASSET_CATEGORIES.keys()) + if "all" in args.categories + else args.categories + ) + + print(f"\nWill download: {', '.join(categories)}") + if args.subset: + print(f"Subset filter: {args.subset}") + + # Confirm before proceeding + response = input("\nProceed with download? [y/N]: ") + if response.lower() not in ["y", "yes"]: + print("Download cancelled") + return + + # Download each category + for category in categories: + try: + download_asset_category(category, args.subset) + except KeyboardInterrupt: + print("\n\nDownload interrupted by user") + raise + except Exception as e: + print(f"\n❌ Error downloading {category}: {e}") + continue + + # Verify downloads + verify_downloads() + + print("\n" + "="*60) + print("✓ Download complete!") + print("="*60) + print(f"\nLocal assets are now available in: {LOCAL_ASSETS_DIR}") + print("\nYou can now use the --local flag in training:") + print(" ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \\") + print(" --task Isaac-Velocity-Flat-Unitree-Go2-v0 \\") + print(" --num_envs 128 \\") + print(" --local") + + finally: + # Always clean up simulation app + simulation_app.close() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/__init__.py index 6f9858b2c58..4ea7d3fce71 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/__init__.py @@ -33,28 +33,6 @@ }, ) -gym.register( - id="Isaac-Velocity-LocalFlat-Unitree-Go2-v0", - entry_point="isaaclab.envs:ManagerBasedRLEnv", - disable_env_checker=True, - kwargs={ - "env_cfg_entry_point": f"{__name__}.local_flat_env_cfg:UnitreeGo2FlatEnvCfg", - "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:UnitreeGo2FlatPPORunnerCfg", - "skrl_cfg_entry_point": f"{agents.__name__}:skrl_flat_ppo_cfg.yaml", - }, -) - -gym.register( - id="Isaac-Velocity-LocalFlat-Unitree-Go2-Play-v0", - entry_point="isaaclab.envs:ManagerBasedRLEnv", - disable_env_checker=True, - kwargs={ - "env_cfg_entry_point": f"{__name__}.local_flat_env_cfg:UnitreeGo2FlatEnvCfg_PLAY", - "rsl_rl_cfg_entry_point": f"{agents.__name__}.rsl_rl_ppo_cfg:UnitreeGo2FlatPPORunnerCfg", - "skrl_cfg_entry_point": f"{agents.__name__}:skrl_flat_ppo_cfg.yaml", - }, -) - gym.register( id="Isaac-Velocity-Rough-Unitree-Go2-v0", entry_point="isaaclab.envs:ManagerBasedRLEnv", diff --git a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/local_flat_env_cfg.py b/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/local_flat_env_cfg.py deleted file mode 100644 index da9dcbd9c1c..00000000000 --- a/source/isaaclab_tasks/isaaclab_tasks/manager_based/locomotion/velocity/config/go2/local_flat_env_cfg.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Local offline configuration for Unitree Go2 flat terrain training.""" - -import os -from isaaclab.utils import configclass -import isaaclab.sim as sim_utils -from isaaclab.terrains.trimesh.mesh_terrains_cfg import MeshPlaneTerrainCfg -from isaaclab.terrains.config.rough import ROUGH_TERRAINS_CFG - -from .rough_env_cfg import UnitreeGo2RoughEnvCfg - - -@configclass -class UnitreeGo2FlatEnvCfg(UnitreeGo2RoughEnvCfg): - """Configuration for Unitree Go2 locomotion on flat terrain using local assets.""" - - def __post_init__(self): - super().__post_init__() - - # Get IsaacLab root path for local assets - isaaclab_path = os.environ.get('ISAACLAB_PATH', os.getcwd()) - - # Reward tuning for flat terrain - self.rewards.flat_orientation_l2.weight = -2.5 - self.rewards.feet_air_time.weight = 0.25 - - # Create flat terrain with grid pattern - self.scene.terrain.terrain_type = "generator" - self.scene.terrain.terrain_generator = ROUGH_TERRAINS_CFG.copy() - self.scene.terrain.terrain_generator.sub_terrains = { - "flat": MeshPlaneTerrainCfg(size=(8.0, 8.0)) - } - self.scene.terrain.terrain_generator.proportion = [1.0] - self.scene.terrain.terrain_generator.curriculum = False - - # Grid-like visual material (light gray with darker grid lines) - self.scene.terrain.visual_material = sim_utils.PreviewSurfaceCfg( - diffuse_color=(0.8, 0.8, 0.8), # Light gray base - metallic=0.0, - roughness=0.5, - ) - - # Apply grid texture overlay - # Note: For true grid lines, you'd need a grid texture file - # For now, this creates a clean, flat appearance - self.scene.terrain.physics_material = sim_utils.RigidBodyMaterialCfg( - static_friction=1.0, - dynamic_friction=1.0, - restitution=0.0, - ) - - # Local asset paths - self.scene.sky_light.spawn.texture_file = f"{isaaclab_path}/local_assets/Textures/kloofendal_43d_clear_puresky_4k.hdr" - self.commands.base_velocity.goal_vel_visualizer_cfg.markers["arrow"].usd_path = f"{isaaclab_path}/local_assets/Props/arrow_x.usd" - self.commands.base_velocity.current_vel_visualizer_cfg.markers["arrow"].usd_path = f"{isaaclab_path}/local_assets/Props/arrow_x.usd" - - # Disable height scanning and terrain curriculum - self.scene.height_scanner = None - self.observations.policy.height_scan = None - self.curriculum.terrain_levels = None - - -@configclass -class UnitreeGo2FlatEnvCfg_PLAY(UnitreeGo2FlatEnvCfg): - """Play configuration with smaller scene and no randomization.""" - - def __post_init__(self) -> None: - super().__post_init__() - - # Smaller scene for visualization - self.scene.num_envs = 50 - self.scene.env_spacing = 2.5 - - # Disable randomization - self.observations.policy.enable_corruption = False - self.events.base_external_force_torque = None - self.events.push_robot = None \ No newline at end of file diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.py index 495b207c319..b087909d5a7 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.py @@ -7,3 +7,24 @@ from .importer import import_packages from .parse_cfg import get_checkpoint_path, load_cfg_from_registry, parse_env_cfg +from isaaclab_tasks.utils.local_asset_resolver import ( + enable_local_mode, + disable_local_mode, + is_local_mode_enabled, + resolve_asset_path, + get_local_assets_dir, + patch_config_for_local_mode, + install_path_hooks, + setup_local_mode, +) + +__all__ = [ + "enable_local_mode", + "disable_local_mode", + "is_local_mode_enabled", + "resolve_asset_path", + "get_local_assets_dir", + "patch_config_for_local_mode", + "install_path_hooks", + "setup_local_mode", +] \ No newline at end of file diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/local_asset_resolver.py b/source/isaaclab_tasks/isaaclab_tasks/utils/local_asset_resolver.py new file mode 100644 index 00000000000..aaadddf7838 --- /dev/null +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/local_asset_resolver.py @@ -0,0 +1,345 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +""" +This module provides utilities to transparently redirect asset paths from Nucleus +to local storage when running in offline mode. It maintains the same directory structure +so that configs require no changes. + +Usage: + enable_offline_mode() + + All subsequent asset paths will be resolved to local_assets/ + path = resolve_asset_path(ISAACLAB_NUCLEUS_DIR + "/Robots/...") + Returns: /path/to/isaaclab/local_assets/Robots/... +""" + +import os +from typing import Optional + +import carb.settings + + +class LocalAssetResolver: + """ + Singleton class to manage local asset path resolution. + + When enabled, this resolver intercepts asset paths that point to Nucleus + and redirects them to the local_assets directory. + """ + + _instance: Optional['LocalAssetResolver'] = None + _enabled: bool = False + _local_assets_dir: Optional[str] = None + _nucleus_dir: Optional[str] = None + _isaac_nucleus_dir: Optional[str] = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super(LocalAssetResolver, cls).__new__(cls) + cls._instance._initialize() + return cls._instance + + def _initialize(self): + """Initialize the resolver with environment paths.""" + # Get Isaac Lab root path + self.isaaclab_path = os.environ.get('ISAACLAB_PATH', os.getcwd()) + + # Set local assets directory + self._local_assets_dir = os.path.join(self.isaaclab_path, "local_assets") + + # Get Nucleus directories from settings + settings = carb.settings.get_settings() + nucleus_root = settings.get("/persistent/isaac/asset_root/default") + + if nucleus_root: + self._nucleus_dir = nucleus_root + self._isaac_nucleus_dir = f"{nucleus_root}/Isaac" + self._isaaclab_nucleus_dir = f"{nucleus_root}/Isaac/IsaacLab" + + print(f"[LocalAssetResolver] Initialized") + print(f" Local assets dir: {self._local_assets_dir}") + if self._isaaclab_nucleus_dir: + print(f" Nucleus dir: {self._isaaclab_nucleus_dir}") + + def enable(self): + """Enable local asset resolution.""" + self._enabled = True + print(f"[LocalAssetResolver] Local mode ENABLED") + print(f" All assets will be loaded from: {self._local_assets_dir}") + + # Verify local assets directory exists + if not os.path.exists(self._local_assets_dir): + print(f"[LocalAssetResolver] ⚠️ WARNING: Local assets directory not found!") + print(f" Please run: ./isaaclab.sh -p scripts/setup/download_assets.py") + + def disable(self): + """Disable local asset resolution.""" + self._enabled = False + print(f"[LocalAssetResolver] Local mode DISABLED") + + def is_enabled(self) -> bool: + """Check if local mode is enabled.""" + return self._enabled + + def resolve_path(self, asset_path: str) -> str: + """ + Resolve an asset path to either Nucleus or local storage. + + Args: + asset_path: Original asset path (may contain Nucleus URL) + + Returns: + Resolved path (local if enabled, otherwise original) + """ + if not self._enabled: + return asset_path + + # Skip if not a string or empty + if not isinstance(asset_path, str) or not asset_path: + return asset_path + + # Check if this is a Nucleus path we should redirect + path_to_convert = None + + # Handle versioned paths like: .../Assets/Isaac/5.1/Isaac/IsaacLab/... + import re + + # Pattern 1: Isaac Lab assets with version (e.g., .../Assets/Isaac/5.1/Isaac/IsaacLab/Robots/...) + match = re.search(r'/Assets/Isaac/[\d.]+/Isaac/IsaacLab/(.+)$', asset_path) + if match: + path_to_convert = match.group(1) + + # Pattern 2: General Isaac assets with version (e.g., .../Assets/Isaac/5.1/Isaac/Props/...) + if not path_to_convert: + match = re.search(r'/Assets/Isaac/[\d.]+/Isaac/(?!IsaacLab)(.+)$', asset_path) + if match: + path_to_convert = match.group(1) + + # Pattern 3: Without version - IsaacLab specific (older format) + if not path_to_convert and self._isaaclab_nucleus_dir and asset_path.startswith(self._isaaclab_nucleus_dir): + path_to_convert = asset_path[len(self._isaaclab_nucleus_dir):].lstrip("/") + + # Pattern 4: Without version - General Isaac (older format) + if not path_to_convert and self._isaac_nucleus_dir and asset_path.startswith(self._isaac_nucleus_dir): + isaac_relative = asset_path[len(self._isaac_nucleus_dir):].lstrip("/") + path_to_convert = isaac_relative + + # If we identified a path to convert, create the local path + if path_to_convert: + local_path = os.path.join(self._local_assets_dir, path_to_convert) + + # Verify the local file exists + if os.path.exists(local_path): + print(f"[LocalAssetResolver] ✓ Using local: {path_to_convert}") + return local_path + else: + print(f"[LocalAssetResolver] ⚠️ Not found locally: {path_to_convert}") + print(f"[LocalAssetResolver] Falling back to Nucleus") + return asset_path + + # If not a Nucleus path, return original + return asset_path + + def get_local_assets_dir(self) -> str: + """Get the local assets directory path.""" + return self._local_assets_dir + + +# Global resolver instance +_resolver = LocalAssetResolver() + + +def enable_local_mode(): + """Enable local asset resolution globally.""" + _resolver.enable() + + +def disable_local_mode(): + """Disable local asset resolution globally.""" + _resolver.disable() + + +def is_local_mode_enabled() -> bool: + """Check if local mode is currently enabled.""" + return _resolver.is_enabled() + + +def resolve_asset_path(asset_path: str) -> str: + """ + Resolve an asset path, redirecting to local storage if enabled. + + Args: + asset_path: Original asset path (may contain Nucleus URL) + + Returns: + Resolved path (local if mode is enabled and file exists, otherwise original) + """ + return _resolver.resolve_path(asset_path) + + +def get_local_assets_dir() -> str: + """Get the local assets directory path.""" + return _resolver.get_local_assets_dir() + + +def patch_config_for_local_mode(env_cfg): + """ + Patch specific known paths in the environment config. + + Args: + env_cfg: Environment configuration object + """ + if not is_local_mode_enabled(): + return + + print("[LocalAssetResolver] Patching configuration...") + patches_made = 0 + + # Patch robot USD path + if hasattr(env_cfg, 'scene') and hasattr(env_cfg.scene, 'robot'): + if hasattr(env_cfg.scene.robot, 'spawn') and hasattr(env_cfg.scene.robot.spawn, 'usd_path'): + original = env_cfg.scene.robot.spawn.usd_path + resolved = resolve_asset_path(original) + if resolved != original: + env_cfg.scene.robot.spawn.usd_path = resolved + patches_made += 1 + print(f"[LocalAssetResolver] ✓ Patched robot USD path") + + # Patch terrain/ground plane paths + if hasattr(env_cfg, 'scene') and hasattr(env_cfg.scene, 'terrain'): + terrain_cfg = env_cfg.scene.terrain + + # Check for terrain_generator ground plane + if hasattr(terrain_cfg, 'terrain_generator'): + # This is typically procedural, no USD files needed + pass + + # Check for direct ground plane USD + if hasattr(terrain_cfg, 'usd_path'): + original = terrain_cfg.usd_path + resolved = resolve_asset_path(original) + if resolved != original: + terrain_cfg.usd_path = resolved + patches_made += 1 + print(f"[LocalAssetResolver] ✓ Patched terrain USD path") + + # Patch sky light textures + if hasattr(env_cfg, 'scene') and hasattr(env_cfg.scene, 'sky_light'): + if hasattr(env_cfg.scene.sky_light, 'spawn') and hasattr(env_cfg.scene.sky_light.spawn, 'texture_file'): + if env_cfg.scene.sky_light.spawn.texture_file: + original = env_cfg.scene.sky_light.spawn.texture_file + resolved = resolve_asset_path(original) + if resolved != original: + env_cfg.scene.sky_light.spawn.texture_file = resolved + patches_made += 1 + print(f"[LocalAssetResolver] ✓ Patched sky light texture") + + # Patch visualization markers (arrows, etc.) + if hasattr(env_cfg, 'commands'): + for command_name in dir(env_cfg.commands): + if command_name.startswith('_'): + continue + command = getattr(env_cfg.commands, command_name, None) + if command and hasattr(command, 'goal_vel_visualizer_cfg'): + visualizer = command.goal_vel_visualizer_cfg + if hasattr(visualizer, 'markers') and isinstance(visualizer.markers, dict): + for marker_name, marker_cfg in visualizer.markers.items(): + if hasattr(marker_cfg, 'usd_path'): + original = marker_cfg.usd_path + resolved = resolve_asset_path(original) + if resolved != original: + marker_cfg.usd_path = resolved + patches_made += 1 + print(f"[LocalAssetResolver] ✓ Patched {marker_name} marker") + + if hasattr(env_cfg, 'commands'): + for command_name in dir(env_cfg.commands): + if command_name.startswith('_'): + continue + command = getattr(env_cfg.commands, command_name, None) + if command: + # Patch BOTH current and goal visualizers + for viz_name in ['current_vel_visualizer_cfg', 'goal_vel_visualizer_cfg']: + if hasattr(command, viz_name): + visualizer = getattr(command, viz_name) + if hasattr(visualizer, 'markers') and isinstance(visualizer.markers, dict): + for marker_name, marker_cfg in visualizer.markers.items(): + if hasattr(marker_cfg, 'usd_path'): + original = marker_cfg.usd_path + resolved = resolve_asset_path(original) + if resolved != original: + marker_cfg.usd_path = resolved + patches_made += 1 + print(f"[LocalAssetResolver] ✓ Patched {marker_name} in {viz_name}") + + if patches_made > 0: + print(f"[LocalAssetResolver] Patched {patches_made} asset paths") + else: + print(f"[LocalAssetResolver] No paths needed patching (already correct)") + +# Monkey patch common Isaac Lab modules to use local resolver +def install_path_hooks(): + """ + Install hooks into Isaac Lab's asset loading to automatically resolve paths. + + This patches the spawn configs to automatically resolve paths when local mode is enabled. + """ + try: + import isaaclab.sim as sim_utils + + # Patch UsdFileCfg + if hasattr(sim_utils, 'UsdFileCfg'): + original_usd_init = sim_utils.UsdFileCfg.__init__ + + def patched_usd_init(self, *args, **kwargs): + # Call original init + original_usd_init(self, *args, **kwargs) + # Resolve the usd_path if local mode is enabled + if hasattr(self, 'usd_path') and is_local_mode_enabled(): + self.usd_path = resolve_asset_path(self.usd_path) + + sim_utils.UsdFileCfg.__init__ = patched_usd_init + print("[LocalAssetResolver] Installed UsdFileCfg path hook") + + # Patch GroundPlaneCfg (for terrain) + if hasattr(sim_utils, 'GroundPlaneCfg'): + original_ground_init = sim_utils.GroundPlaneCfg.__init__ + + def patched_ground_init(self, *args, **kwargs): + # Call original init + original_ground_init(self, *args, **kwargs) + # Resolve the usd_path if local mode is enabled + if hasattr(self, 'usd_path') and is_local_mode_enabled(): + original_path = self.usd_path + self.usd_path = resolve_asset_path(self.usd_path) + if self.usd_path != original_path: + print(f"[LocalAssetResolver] ✓ Resolved ground plane: {os.path.basename(self.usd_path)}") + + sim_utils.GroundPlaneCfg.__init__ = patched_ground_init + print("[LocalAssetResolver] Installed GroundPlaneCfg path hook") + + # Patch PreviewSurfaceCfg for textures + if hasattr(sim_utils, 'PreviewSurfaceCfg'): + original_surface_init = sim_utils.PreviewSurfaceCfg.__init__ + + def patched_surface_init(self, *args, **kwargs): + original_surface_init(self, *args, **kwargs) + if hasattr(self, 'texture_file') and is_local_mode_enabled(): + if self.texture_file: + self.texture_file = resolve_asset_path(self.texture_file) + + sim_utils.PreviewSurfaceCfg.__init__ = patched_surface_init + print("[LocalAssetResolver] Installed PreviewSurfaceCfg path hook") + + except ImportError: + print("[LocalAssetResolver] Could not install path hooks - isaaclab.sim not available") + + +# Set up local mode with all hooks +def setup_local_mode(): + enable_local_mode() + install_path_hooks() + print("[LocalAssetResolver] Local mode fully configured") \ No newline at end of file From 94f10b4554cd02924e14ef2abd58928637d7fbb6 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 13:38:47 -0500 Subject: [PATCH 03/24] minor refactors for offline mode --- .gitignore | 4 +- .vscode/.gitignore | 4 +- NOTES.md | 6 +- .../download_assets.py | 72 ++++----- .../reinforcement_learning/rsl_rl/train.py | 14 +- source/isaaclab/isaaclab/utils/__init__.py | 1 + .../isaaclab/utils/asset_resolver.py} | 150 +++++++++--------- .../isaaclab_tasks/utils/__init__.py | 23 +-- 8 files changed, 128 insertions(+), 146 deletions(-) rename scripts/{setup => offline_setup}/download_assets.py (78%) rename source/{isaaclab_tasks/isaaclab_tasks/utils/local_asset_resolver.py => isaaclab/isaaclab/utils/asset_resolver.py} (68%) diff --git a/.gitignore b/.gitignore index dbcc20ae3db..178e3aafddb 100644 --- a/.gitignore +++ b/.gitignore @@ -73,4 +73,6 @@ tests/ # TacSL sensor **/tactile_record/* **/gelsight_r15_data/* -local_assets/ + +# Offline assets +offline_assets/* diff --git a/.vscode/.gitignore b/.vscode/.gitignore index b5210f5de4c..717d31bf056 100644 --- a/.vscode/.gitignore +++ b/.vscode/.gitignore @@ -9,5 +9,5 @@ .python.env *.json -# Ignore Local Assets -local_assets/ +# Offline assets +offline_assets/* \ No newline at end of file diff --git a/NOTES.md b/NOTES.md index c860073e006..18d123d594a 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1,6 +1,6 @@ conda deactivate -./isaaclab.sh -p scripts/setup/download_assets.py --categories Props Robots Environments Materials Controllers ActuatorNets Policies Mimic +./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories Props Robots Environments Materials Controllers ActuatorNets Policies Mimic ``` @@ -15,14 +15,14 @@ conda deactivate ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ --num_envs 128 \ - --local + --offline ``` ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ --task Isaac-Velocity-Flat-H1-v0 \ --num_envs 128 \ - --local + --offline ``` diff --git a/scripts/setup/download_assets.py b/scripts/offline_setup/download_assets.py similarity index 78% rename from scripts/setup/download_assets.py rename to scripts/offline_setup/download_assets.py index dd2294cdb84..81f953fd64f 100644 --- a/scripts/setup/download_assets.py +++ b/scripts/offline_setup/download_assets.py @@ -5,12 +5,12 @@ """ This script downloads all assets from Nucleus server (ISAACLAB_NUCLEUS_DIR) and mirrors -the directory structure locally in the local_assets folder. This enables offline/local training +the directory structure locally in the offline_assets folder. This enables offline/local training without requiring S3/Nucleus connectivity. Must be connected to the internet to use! Usage: - ./isaaclab.sh -p scripts/setup/download_assets.py --categories Props Robots Environments Materials Controllers ActuatorNets Policies Mimic - ./isaaclab.sh -p scripts/setup/download_assets.py [--subset ROBOT_NAME] + ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories Props Robots Environments Materials Controllers ActuatorNets Policies Mimic + ./isaaclab.sh -p scripts/offline_setup/download_assets.py [--subset ROBOT_NAME] """ import argparse @@ -36,7 +36,7 @@ # Get Isaac Lab paths ISAACLAB_PATH = os.environ.get("ISAACLAB_PATH", os.getcwd()) -LOCAL_ASSETS_DIR = os.path.join(ISAACLAB_PATH, "local_assets") +offline_assets_DIR = os.path.join(ISAACLAB_PATH, "offline_assets") # Get the Nucleus directory from settings settings = carb.settings.get_settings() @@ -60,28 +60,28 @@ } -def ensure_local_directory(local_path: str) -> None: - """Create local directory if it doesn't exist.""" - os.makedirs(local_path, exist_ok=True) +def ensure_offline_directory(offline_path: str) -> None: + """Create offline directory if it doesn't exist.""" + os.makedirs(offline_path, exist_ok=True) -def download_file(remote_path: str, local_path: str) -> bool: +def download_file(remote_path: str, offline_path: str) -> bool: """ - Download a single file from Nucleus to local storage. + Download a single file from Nucleus to offline storage. Args: remote_path: Full Nucleus URL (e.g., omniverse://...) - local_path: Local file system path + offline_path: Offline file system path Returns: True if successful, False otherwise """ try: # Create parent directory if needed - os.makedirs(os.path.dirname(local_path), exist_ok=True) + os.makedirs(os.path.dirname(offline_path), exist_ok=True) - # Copy file from Nucleus to local - result = omni.client.copy(remote_path, local_path) + # Copy file from Nucleus to offline + result = omni.client.copy(remote_path, offline_path) return result == omni.client.Result.OK except Exception as e: print(f"Error downloading {remote_path}: {e}") @@ -109,32 +109,32 @@ def list_nucleus_directory(remote_path: str) -> list[tuple[str, bool]]: return items -def download_directory_recursive(remote_path: str, local_base: str, progress_bar=None) -> None: +def download_directory_recursive(remote_path: str, offline_base: str, progress_bar=None) -> None: """ - Recursively download a directory from Nucleus to local storage. + Recursively download a directory from Nucleus to offline storage. Args: remote_path: Nucleus directory URL - local_base: Local directory to mirror structure + offline_base: Offline directory to mirror structure progress_bar: Optional tqdm progress bar (or None) """ items = list_nucleus_directory(remote_path) for item_name, is_directory in items: remote_item = f"{remote_path}/{item_name}" - local_item = os.path.join(local_base, item_name) + offline_item = os.path.join(offline_base, item_name) if is_directory: # Recursively download subdirectory - ensure_local_directory(local_item) - download_directory_recursive(remote_item, local_item, progress_bar) + ensure_offline_directory(offline_item) + download_directory_recursive(remote_item, offline_item, progress_bar) else: # Download file if progress_bar is not None: progress_bar.set_description(f"Downloading {item_name}") else: print(f" Downloading: {item_name}") - download_file(remote_item, local_item) + download_file(remote_item, offline_item) if progress_bar is not None: progress_bar.update(1) @@ -165,18 +165,18 @@ def download_asset_category(category: str, subset: str = None) -> None: description = category_info["desc"] remote_dir = f"{base_path}/{category}" - local_dir = os.path.join(LOCAL_ASSETS_DIR, category) + offline_dir = os.path.join(offline_assets_DIR, category) print(f"\n{'='*60}") print(f"Downloading {category}: {description}") print(f"From: {remote_dir}") - print(f"To: {local_dir}") + print(f"To: {offline_dir}") print(f"{'='*60}") # If subset is specified, only download that subset if subset and category == "Robots": remote_dir = f"{remote_dir}/{subset}" - local_dir = os.path.join(local_dir, subset) + offline_dir = os.path.join(offline_dir, subset) print(f"Filtering to subset: {subset}") # Check if remote directory exists @@ -196,36 +196,36 @@ def download_asset_category(category: str, subset: str = None) -> None: return # Download with progress bar - ensure_local_directory(local_dir) + ensure_offline_directory(offline_dir) if HAS_TQDM: with tqdm(total=total_files, unit="file") as pbar: - download_directory_recursive(remote_dir, local_dir, pbar) + download_directory_recursive(remote_dir, offline_dir, pbar) else: print(f"Downloading {total_files} files (install tqdm for progress bars)...") - download_directory_recursive(remote_dir, local_dir, None) + download_directory_recursive(remote_dir, offline_dir, None) print(f"✓ Completed {category}") def verify_downloads(category: str = None) -> None: - """Verify that local assets directory has expected structure.""" + """Verify that offline assets directory has expected structure.""" print("\n" + "="*60) - print("Verifying local assets...") + print("Verifying offline assets...") print("="*60) categories_to_check = [category] if category else ASSET_CATEGORIES.keys() for cat in categories_to_check: - local_dir = os.path.join(LOCAL_ASSETS_DIR, cat) - if os.path.exists(local_dir): - file_count = sum(1 for _ in Path(local_dir).rglob("*") if _.is_file()) + offline_dir = os.path.join(offline_assets_DIR, cat) + if os.path.exists(offline_dir): + file_count = sum(1 for _ in Path(offline_dir).rglob("*") if _.is_file()) print(f"✓ {cat}: {file_count} files") else: print(f"✗ {cat}: Not found") def main(): - parser = argparse.ArgumentParser(description="Download Isaac Lab assets from Nucleus to local storage") + parser = argparse.ArgumentParser(description="Download Isaac Lab assets from Nucleus to offline storage") parser.add_argument( "--categories", nargs="+", @@ -252,7 +252,7 @@ def main(): print("="*60) print(f"Isaac Sim assets: {ISAAC_NUCLEUS_DIR}") print(f"Isaac Lab assets: {ISAACLAB_NUCLEUS_DIR}") - print(f"Local target: {LOCAL_ASSETS_DIR}") + print(f"Offline target: {offline_assets_DIR}") print("="*60) if args.verify_only: @@ -293,12 +293,12 @@ def main(): print("\n" + "="*60) print("✓ Download complete!") print("="*60) - print(f"\nLocal assets are now available in: {LOCAL_ASSETS_DIR}") - print("\nYou can now use the --local flag in training:") + print(f"\Offline assets are now available in: {offline_assets_DIR}") + print("\nYou can now use the --offline flag in training:") print(" ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \\") print(" --task Isaac-Velocity-Flat-Unitree-Go2-v0 \\") print(" --num_envs 128 \\") - print(" --local") + print(" --offline") finally: # Always clean up simulation app diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index 48d631d4d00..e002924fb0d 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -34,7 +34,7 @@ parser.add_argument( "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) -parser.add_argument( "--local", action="store_true", default=False, help="Use local assets and configurations (offline mode)") +parser.add_argument( "--offline", action="store_true", default=False, help="Use local assets and configurations (offline mode)") # append RSL-RL cli arguments cli_args.add_rsl_rl_args(parser) @@ -77,8 +77,8 @@ """Rest everything follows.""" -import os import logging +import os import time from datetime import datetime @@ -95,6 +95,7 @@ ) from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml +from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode from isaaclab_rl.rsl_rl import RslRlBaseRunnerCfg, RslRlVecEnvWrapper @@ -124,11 +125,10 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen args_cli.max_iterations if args_cli.max_iterations is not None else agent_cfg.max_iterations ) - # Handle local config to use local assets - if args_cli.local: - from isaaclab_tasks.utils.local_asset_resolver import setup_local_mode, patch_config_for_local_mode - setup_local_mode() - patch_config_for_local_mode(env_cfg) + # Handle config to use offline_assets + if args_cli.offline: + setup_offline_mode() + patch_config_for_offline_mode(env_cfg) # set the environment seed # note: certain randomizations occur in the environment initialization so we set the seed here diff --git a/source/isaaclab/isaaclab/utils/__init__.py b/source/isaaclab/isaaclab/utils/__init__.py index 1295715857f..552b5ccc24d 100644 --- a/source/isaaclab/isaaclab/utils/__init__.py +++ b/source/isaaclab/isaaclab/utils/__init__.py @@ -17,3 +17,4 @@ from .timer import Timer from .types import * from .version import * +from .asset_resolver import * \ No newline at end of file diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/local_asset_resolver.py b/source/isaaclab/isaaclab/utils/asset_resolver.py similarity index 68% rename from source/isaaclab_tasks/isaaclab_tasks/utils/local_asset_resolver.py rename to source/isaaclab/isaaclab/utils/asset_resolver.py index aaadddf7838..06c8558e054 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/local_asset_resolver.py +++ b/source/isaaclab/isaaclab/utils/asset_resolver.py @@ -5,15 +5,15 @@ """ This module provides utilities to transparently redirect asset paths from Nucleus -to local storage when running in offline mode. It maintains the same directory structure -so that configs require no changes. +to offline storage when running in --offline mode. It maintains the same directory +structure so that configs require no changes. Usage: enable_offline_mode() - All subsequent asset paths will be resolved to local_assets/ + All subsequent asset paths will be resolved to offline_assets/ path = resolve_asset_path(ISAACLAB_NUCLEUS_DIR + "/Robots/...") - Returns: /path/to/isaaclab/local_assets/Robots/... + Returns: /path/to/isaaclab/offline_assets/Robots/... """ import os @@ -22,23 +22,23 @@ import carb.settings -class LocalAssetResolver: +class OfflineAssetResolver: """ - Singleton class to manage local asset path resolution. + Singleton class to manage offline asset path resolution. When enabled, this resolver intercepts asset paths that point to Nucleus - and redirects them to the local_assets directory. + and redirects them to the offline_assets directory. """ - _instance: Optional['LocalAssetResolver'] = None + _instance: Optional['OfflineAssetResolver'] = None _enabled: bool = False - _local_assets_dir: Optional[str] = None + _offline_assets_dir: Optional[str] = None _nucleus_dir: Optional[str] = None _isaac_nucleus_dir: Optional[str] = None def __new__(cls): if cls._instance is None: - cls._instance = super(LocalAssetResolver, cls).__new__(cls) + cls._instance = super(OfflineAssetResolver, cls).__new__(cls) cls._instance._initialize() return cls._instance @@ -47,8 +47,8 @@ def _initialize(self): # Get Isaac Lab root path self.isaaclab_path = os.environ.get('ISAACLAB_PATH', os.getcwd()) - # Set local assets directory - self._local_assets_dir = os.path.join(self.isaaclab_path, "local_assets") + # Set offline assets directory + self._offline_assets_dir = os.path.join(self.isaaclab_path, "offline_assets") # Get Nucleus directories from settings settings = carb.settings.get_settings() @@ -59,40 +59,40 @@ def _initialize(self): self._isaac_nucleus_dir = f"{nucleus_root}/Isaac" self._isaaclab_nucleus_dir = f"{nucleus_root}/Isaac/IsaacLab" - print(f"[LocalAssetResolver] Initialized") - print(f" Local assets dir: {self._local_assets_dir}") + print(f"[OfflineAssetResolver] Initialized") + print(f" Offline assets dir: {self._offline_assets_dir}") if self._isaaclab_nucleus_dir: print(f" Nucleus dir: {self._isaaclab_nucleus_dir}") def enable(self): - """Enable local asset resolution.""" + """Enable offline asset resolution.""" self._enabled = True - print(f"[LocalAssetResolver] Local mode ENABLED") - print(f" All assets will be loaded from: {self._local_assets_dir}") + print(f"[OfflineAssetResolver] Offline mode ENABLED") + print(f" All assets will be loaded from: {self._offline_assets_dir}") - # Verify local assets directory exists - if not os.path.exists(self._local_assets_dir): - print(f"[LocalAssetResolver] ⚠️ WARNING: Local assets directory not found!") - print(f" Please run: ./isaaclab.sh -p scripts/setup/download_assets.py") + # Verify offline assets directory exists + if not os.path.exists(self._offline_assets_dir): + print(f"[OfflineAssetResolver] ⚠️ WARNING: Offline assets directory not found!") + print(f" Please run: ./isaaclab.sh -p scripts/offline_setup/download_assets.py") def disable(self): - """Disable local asset resolution.""" + """Disable offline asset resolution.""" self._enabled = False - print(f"[LocalAssetResolver] Local mode DISABLED") + print(f"[OfflineAssetResolver] Offline mode DISABLED") def is_enabled(self) -> bool: - """Check if local mode is enabled.""" + """Check if offline mode is enabled.""" return self._enabled def resolve_path(self, asset_path: str) -> str: """ - Resolve an asset path to either Nucleus or local storage. + Resolve an asset path to either Nucleus or offline storage. Args: asset_path: Original asset path (may contain Nucleus URL) Returns: - Resolved path (local if enabled, otherwise original) + Resolved path (offline if enabled, otherwise original) """ if not self._enabled: return asset_path @@ -127,75 +127,75 @@ def resolve_path(self, asset_path: str) -> str: isaac_relative = asset_path[len(self._isaac_nucleus_dir):].lstrip("/") path_to_convert = isaac_relative - # If we identified a path to convert, create the local path + # If we identified a path to convert, create the offline path if path_to_convert: - local_path = os.path.join(self._local_assets_dir, path_to_convert) + offline_path = os.path.join(self._offline_assets_dir, path_to_convert) - # Verify the local file exists - if os.path.exists(local_path): - print(f"[LocalAssetResolver] ✓ Using local: {path_to_convert}") - return local_path + # Verify the offline file exists + if os.path.exists(offline_path): + print(f"[OfflineAssetResolver] ✓ Using offline: {path_to_convert}") + return offline_path else: - print(f"[LocalAssetResolver] ⚠️ Not found locally: {path_to_convert}") - print(f"[LocalAssetResolver] Falling back to Nucleus") + print(f"[OfflineAssetResolver] ⚠️ Not found locally: {path_to_convert}") + print(f"[OfflineAssetResolver] Falling back to Nucleus") return asset_path # If not a Nucleus path, return original return asset_path - def get_local_assets_dir(self) -> str: - """Get the local assets directory path.""" - return self._local_assets_dir + def get_offline_assets_dir(self) -> str: + """Get the offline assets directory path.""" + return self._offline_assets_dir # Global resolver instance -_resolver = LocalAssetResolver() +_resolver = OfflineAssetResolver() -def enable_local_mode(): - """Enable local asset resolution globally.""" +def enable_offline_mode(): + """Enable offline asset resolution globally.""" _resolver.enable() -def disable_local_mode(): - """Disable local asset resolution globally.""" +def disable_offline_mode(): + """Disable offline asset resolution globally.""" _resolver.disable() -def is_local_mode_enabled() -> bool: - """Check if local mode is currently enabled.""" +def is_offline_mode_enabled() -> bool: + """Check if offline mode is currently enabled.""" return _resolver.is_enabled() def resolve_asset_path(asset_path: str) -> str: """ - Resolve an asset path, redirecting to local storage if enabled. + Resolve an asset path, redirecting to offline storage if enabled. Args: asset_path: Original asset path (may contain Nucleus URL) Returns: - Resolved path (local if mode is enabled and file exists, otherwise original) + Resolved path (offline if mode is enabled and file exists, otherwise original) """ return _resolver.resolve_path(asset_path) -def get_local_assets_dir() -> str: - """Get the local assets directory path.""" - return _resolver.get_local_assets_dir() +def get_offline_assets_dir() -> str: + """Get the offline assets directory path.""" + return _resolver.get_offline_assets_dir() -def patch_config_for_local_mode(env_cfg): +def patch_config_for_offline_mode(env_cfg): """ Patch specific known paths in the environment config. Args: env_cfg: Environment configuration object """ - if not is_local_mode_enabled(): + if not is_offline_mode_enabled(): return - print("[LocalAssetResolver] Patching configuration...") + print("[OfflineAssetResolver] Patching configuration...") patches_made = 0 # Patch robot USD path @@ -206,7 +206,7 @@ def patch_config_for_local_mode(env_cfg): if resolved != original: env_cfg.scene.robot.spawn.usd_path = resolved patches_made += 1 - print(f"[LocalAssetResolver] ✓ Patched robot USD path") + print(f"[OfflineAssetResolver] ✓ Patched robot USD path") # Patch terrain/ground plane paths if hasattr(env_cfg, 'scene') and hasattr(env_cfg.scene, 'terrain'): @@ -224,7 +224,7 @@ def patch_config_for_local_mode(env_cfg): if resolved != original: terrain_cfg.usd_path = resolved patches_made += 1 - print(f"[LocalAssetResolver] ✓ Patched terrain USD path") + print(f"[OfflineAssetResolver] ✓ Patched terrain USD path") # Patch sky light textures if hasattr(env_cfg, 'scene') and hasattr(env_cfg.scene, 'sky_light'): @@ -235,7 +235,7 @@ def patch_config_for_local_mode(env_cfg): if resolved != original: env_cfg.scene.sky_light.spawn.texture_file = resolved patches_made += 1 - print(f"[LocalAssetResolver] ✓ Patched sky light texture") + print(f"[OfflineAssetResolver] ✓ Patched sky light texture") # Patch visualization markers (arrows, etc.) if hasattr(env_cfg, 'commands'): @@ -253,7 +253,7 @@ def patch_config_for_local_mode(env_cfg): if resolved != original: marker_cfg.usd_path = resolved patches_made += 1 - print(f"[LocalAssetResolver] ✓ Patched {marker_name} marker") + print(f"[OfflineAssetResolver] ✓ Patched {marker_name} marker") if hasattr(env_cfg, 'commands'): for command_name in dir(env_cfg.commands): @@ -273,19 +273,19 @@ def patch_config_for_local_mode(env_cfg): if resolved != original: marker_cfg.usd_path = resolved patches_made += 1 - print(f"[LocalAssetResolver] ✓ Patched {marker_name} in {viz_name}") + print(f"[OfflineAssetResolver] ✓ Patched {marker_name} in {viz_name}") if patches_made > 0: - print(f"[LocalAssetResolver] Patched {patches_made} asset paths") + print(f"[OfflineAssetResolver] Patched {patches_made} asset paths") else: - print(f"[LocalAssetResolver] No paths needed patching (already correct)") + print(f"[OfflineAssetResolver] No paths needed patching (already correct)") -# Monkey patch common Isaac Lab modules to use local resolver +# Monkey patch common Isaac Lab modules to use offline resolver def install_path_hooks(): """ Install hooks into Isaac Lab's asset loading to automatically resolve paths. - This patches the spawn configs to automatically resolve paths when local mode is enabled. + This patches the spawn configs to automatically resolve paths when offline mode is enabled. """ try: import isaaclab.sim as sim_utils @@ -297,12 +297,12 @@ def install_path_hooks(): def patched_usd_init(self, *args, **kwargs): # Call original init original_usd_init(self, *args, **kwargs) - # Resolve the usd_path if local mode is enabled - if hasattr(self, 'usd_path') and is_local_mode_enabled(): + # Resolve the usd_path if offline mode is enabled + if hasattr(self, 'usd_path') and is_offline_mode_enabled(): self.usd_path = resolve_asset_path(self.usd_path) sim_utils.UsdFileCfg.__init__ = patched_usd_init - print("[LocalAssetResolver] Installed UsdFileCfg path hook") + print("[OfflineAssetResolver] Installed UsdFileCfg path hook") # Patch GroundPlaneCfg (for terrain) if hasattr(sim_utils, 'GroundPlaneCfg'): @@ -311,15 +311,15 @@ def patched_usd_init(self, *args, **kwargs): def patched_ground_init(self, *args, **kwargs): # Call original init original_ground_init(self, *args, **kwargs) - # Resolve the usd_path if local mode is enabled - if hasattr(self, 'usd_path') and is_local_mode_enabled(): + # Resolve the usd_path if offline mode is enabled + if hasattr(self, 'usd_path') and is_offline_mode_enabled(): original_path = self.usd_path self.usd_path = resolve_asset_path(self.usd_path) if self.usd_path != original_path: - print(f"[LocalAssetResolver] ✓ Resolved ground plane: {os.path.basename(self.usd_path)}") + print(f"[OfflineAssetResolver] ✓ Resolved ground plane: {os.path.basename(self.usd_path)}") sim_utils.GroundPlaneCfg.__init__ = patched_ground_init - print("[LocalAssetResolver] Installed GroundPlaneCfg path hook") + print("[OfflineAssetResolver] Installed GroundPlaneCfg path hook") # Patch PreviewSurfaceCfg for textures if hasattr(sim_utils, 'PreviewSurfaceCfg'): @@ -327,19 +327,19 @@ def patched_ground_init(self, *args, **kwargs): def patched_surface_init(self, *args, **kwargs): original_surface_init(self, *args, **kwargs) - if hasattr(self, 'texture_file') and is_local_mode_enabled(): + if hasattr(self, 'texture_file') and is_offline_mode_enabled(): if self.texture_file: self.texture_file = resolve_asset_path(self.texture_file) sim_utils.PreviewSurfaceCfg.__init__ = patched_surface_init - print("[LocalAssetResolver] Installed PreviewSurfaceCfg path hook") + print("[OfflineAssetResolver] Installed PreviewSurfaceCfg path hook") except ImportError: - print("[LocalAssetResolver] Could not install path hooks - isaaclab.sim not available") + print("[OfflineAssetResolver] Could not install path hooks - isaaclab.sim not available") -# Set up local mode with all hooks -def setup_local_mode(): - enable_local_mode() +# Set up offline mode with all hooks +def setup_offline_mode(): + enable_offline_mode() install_path_hooks() - print("[LocalAssetResolver] Local mode fully configured") \ No newline at end of file + print("[OfflineAssetResolver] Offline mode fully configured") \ No newline at end of file diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.py index b087909d5a7..a5a340dd373 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.py @@ -6,25 +6,4 @@ """Sub-package with utilities, data collectors and environment wrappers.""" from .importer import import_packages -from .parse_cfg import get_checkpoint_path, load_cfg_from_registry, parse_env_cfg -from isaaclab_tasks.utils.local_asset_resolver import ( - enable_local_mode, - disable_local_mode, - is_local_mode_enabled, - resolve_asset_path, - get_local_assets_dir, - patch_config_for_local_mode, - install_path_hooks, - setup_local_mode, -) - -__all__ = [ - "enable_local_mode", - "disable_local_mode", - "is_local_mode_enabled", - "resolve_asset_path", - "get_local_assets_dir", - "patch_config_for_local_mode", - "install_path_hooks", - "setup_local_mode", -] \ No newline at end of file +from .parse_cfg import get_checkpoint_path, load_cfg_from_registry, parse_env_cfg \ No newline at end of file From d335006fd55f580dd177b5fa9f7273ecef1b2676 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 14:52:28 -0500 Subject: [PATCH 04/24] Added offline asset documentation --- NOTES.md | 2 - scripts/offline_setup/README.md | 70 +++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 scripts/offline_setup/README.md diff --git a/NOTES.md b/NOTES.md index 18d123d594a..45659559fae 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1,7 +1,5 @@ conda deactivate -./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories Props Robots Environments Materials Controllers ActuatorNets Policies Mimic - ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ diff --git a/scripts/offline_setup/README.md b/scripts/offline_setup/README.md new file mode 100644 index 00000000000..f0fd693717a --- /dev/null +++ b/scripts/offline_setup/README.md @@ -0,0 +1,70 @@ +# Isaac Lab Offline Training Setup +> Complete guide for training Isaac Lab environments offline using locally downloaded assets. + +## 🎯 Overview +#### The offline training system enables you to train Isaac Lab environments without internet connectivity by using locally downloaded assets. This system: +- ✅ Works with any robot - No hardcoded paths needed +- ✅ Single flag - Just add --offline to your training command +- ✅ Automatic fallback - Uses Nucleus if local asset is missing +- ✅ Maintains structure - Mirrors Nucleus directory organization locally + +## 📦 Requirements +- Isaac Lab installed and working +- Isaac Sim 5.0 or later +- 2-20 GB free disk space (depending on assets downloaded) +- Internet connection for initial asset download + +## 🚀 Quick Start +### 1. Download essential assets (one-time, ~2-4 GB) +#### Assets download to the `~/IsaacLab/offline_assets` directory: `cd ~/IsaacLab` +``` +./isaaclab.sh -p scripts/offline_setup/download_assets.py \ + --categories all +``` +#### _Optional Note: Specific category fields can be specified separately_ +``` +./isaaclab.sh -p scripts/offline_setup/download_assets.py \ + --categories Props Robots Environments Materials Controllers ActuatorNets Policies Mimic +``` +### 2. Train completely offline with any robot via the `--offline` flag +``` +./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ + --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ + --num_envs 128 \ + --offline +``` +#### _Note: For offline training, assets that cannot be found in `offline_assets` will be fetched from the [Nucleus Server](https://docs.omniverse.nvidia.com/nucleus/latest/index.html)._ + +## 📁 Asset Layout +#### Offline assets are organized to mirror Nucleus (`ISAAC_NUCLEUS_DIR` & `ISAACLAB_NUCLEUS_DIR`) meaning that no code changes are required! + +``` +IsaacLab/ +├── source/isaaclab/isaaclab/utils/ +│ └── asset_resolver.py # Core resolver +├── scripts/setup/ +│ └── download_assets.py # Asset downloader +└── offline_assets/ + ├── ActuatorNets/ + ├── Controllers/ + ├── Environments/ # Ground planes + │ └── Grid/ + │ └── default_environment.usd + ├── Materials/ # Textures and HDRs + │ └── Textures/ + │ └── Skies/ + ├── Mimic/ + ├── Plocies/ + ├── Props/ # Markers and objects + │ └── UIElements/ + │ └── arrow_x.usd + └── Robots/ # Robot USD files + ├── Unitree/ + │ ├── Go2/ + │ │ └── go2.usd + │ └── H1/ + │ └── h1.usd + └── ANYbotics/ + └── ANYmal-D/ + └── anymal_d.usd +``` From 14b7e50d40f2172ccf29dda65d812e77d928996c Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 15:39:20 -0500 Subject: [PATCH 05/24] offline mode to /play --- NOTES.md | 18 +++++++++--------- scripts/reinforcement_learning/rsl_rl/play.py | 7 +++++++ scripts/reinforcement_learning/rsl_rl/train.py | 1 - 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/NOTES.md b/NOTES.md index 45659559fae..10eddd62690 100644 --- a/NOTES.md +++ b/NOTES.md @@ -19,7 +19,7 @@ conda deactivate ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ --task Isaac-Velocity-Flat-H1-v0 \ - --num_envs 128 \ + --num_envs 496 \ --offline ``` @@ -46,11 +46,11 @@ conda deactivate ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ - --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ - --num_envs 128 \ - --checkpoint logs/rsl_rl/unitree_go2_flat/2026-01-21_14-38-05/model_299.pt -``` - + --task Isaac-Velocity-Flat-H1-v0 \ + --num_envs 1 \ + --checkpoint logs/rsl_rl/h1_flat/2026-01-27_14-58-33/model_800.pt \ + --offline + ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ @@ -65,10 +65,10 @@ conda deactivate ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ - --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ + --task Isaac-Velocity-Flat-H1-v0 \ --num_envs 1 \ - --load_run 2025-11-29_11-15-51 \ - --checkpoint model_350.pt \ + --load_run 2026-01-27_14-58-33 \ + --checkpoint model_800.pt \ --video \ --video_length 500 ``` diff --git a/scripts/reinforcement_learning/rsl_rl/play.py b/scripts/reinforcement_learning/rsl_rl/play.py index beb92072173..aa463822d64 100644 --- a/scripts/reinforcement_learning/rsl_rl/play.py +++ b/scripts/reinforcement_learning/rsl_rl/play.py @@ -34,6 +34,7 @@ help="Use the pre-trained checkpoint from Nucleus.", ) parser.add_argument("--real-time", action="store_true", default=False, help="Run in real-time, if possible.") +parser.add_argument( "--offline", action="store_true", default=False, help="Use local assets and configurations (offline mode)") # append RSL-RL cli arguments cli_args.add_rsl_rl_args(parser) # append AppLauncher cli args @@ -69,6 +70,7 @@ ) from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict +from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode from isaaclab_rl.rsl_rl import RslRlBaseRunnerCfg, RslRlVecEnvWrapper, export_policy_as_jit, export_policy_as_onnx from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint @@ -91,6 +93,11 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen agent_cfg: RslRlBaseRunnerCfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs + # Handle config to use offline_assets + if args_cli.offline: + setup_offline_mode() + patch_config_for_offline_mode(env_cfg) + # set the environment seed # note: certain randomizations occur in the environment initialization so we set the seed here env_cfg.seed = agent_cfg.seed diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index e002924fb0d..4aa44005d9e 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -35,7 +35,6 @@ "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) parser.add_argument( "--offline", action="store_true", default=False, help="Use local assets and configurations (offline mode)") - # append RSL-RL cli arguments cli_args.add_rsl_rl_args(parser) # append AppLauncher cli args From e98441f9066081aec5defc6e2a032d96038d66ae Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 16:24:50 -0500 Subject: [PATCH 06/24] offline to shared command-line arguments --- NOTES.md | 39 ++++--------------- scripts/offline_setup/README.md | 10 ++++- .../reinforcement_learning/rsl_rl/cli_args.py | 2 +- scripts/reinforcement_learning/rsl_rl/play.py | 1 - .../reinforcement_learning/rsl_rl/train.py | 1 - 5 files changed, 18 insertions(+), 35 deletions(-) diff --git a/NOTES.md b/NOTES.md index 10eddd62690..95f78e4a4f3 100644 --- a/NOTES.md +++ b/NOTES.md @@ -11,15 +11,15 @@ conda deactivate ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ - --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ - --num_envs 128 \ + --task Isaac-Velocity-Flat-H1-v0 \ + --num_envs 496 \ --offline ``` ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ - --task Isaac-Velocity-Flat-H1-v0 \ - --num_envs 496 \ + --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ + --num_envs 128 \ --offline ``` @@ -36,7 +36,6 @@ conda deactivate ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ - --headless \ --num_envs 4096 \ --resume \ --load_run 2026-01-21_14-09-41 \ @@ -50,37 +49,15 @@ conda deactivate --num_envs 1 \ --checkpoint logs/rsl_rl/h1_flat/2026-01-27_14-58-33/model_800.pt \ --offline - - -``` -./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ - --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ - --num_envs 1 \ - --checkpoint logs/rsl_rl/velocity_flat_unitree_go2/*/model_200.pt \ - --video \ - --video_length 1000 \ - --video_interval 1 ``` - + ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ --task Isaac-Velocity-Flat-H1-v0 \ --num_envs 1 \ - --load_run 2026-01-27_14-58-33 \ - --checkpoint model_800.pt \ - --video \ - --video_length 500 -``` - -``` -./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ - --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ - --num_envs 1 \ - --load_run 2025-11-29_11-15-51 \ - --checkpoint model_350.pt \ + --checkpoint logs/rsl_rl/h1_flat/2026-01-27_14-58-33/model_800.pt \ --video \ --video_length 1000 \ - --video_interval 2 -``` - + --offline +``` \ No newline at end of file diff --git a/scripts/offline_setup/README.md b/scripts/offline_setup/README.md index f0fd693717a..35b866f6a12 100644 --- a/scripts/offline_setup/README.md +++ b/scripts/offline_setup/README.md @@ -26,12 +26,20 @@ ./isaaclab.sh -p scripts/offline_setup/download_assets.py \ --categories Props Robots Environments Materials Controllers ActuatorNets Policies Mimic ``` -### 2. Train completely offline with any robot via the `--offline` flag +### 2. Train completely offline with any robot via the `--offline` flag (also works with `/play`) ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ --num_envs 128 \ --offline + +./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ + --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ + --num_envs 1 \ + --checkpoint logs/rsl_rl/unitree_go2_flat/2026-01-27_14-58-33/model_800.pt \ + --video \ + --video_length 1000 + --offline ``` #### _Note: For offline training, assets that cannot be found in `offline_assets` will be fetched from the [Nucleus Server](https://docs.omniverse.nvidia.com/nucleus/latest/index.html)._ diff --git a/scripts/reinforcement_learning/rsl_rl/cli_args.py b/scripts/reinforcement_learning/rsl_rl/cli_args.py index 51cf868b5cd..9d3233e8868 100644 --- a/scripts/reinforcement_learning/rsl_rl/cli_args.py +++ b/scripts/reinforcement_learning/rsl_rl/cli_args.py @@ -37,7 +37,7 @@ def add_rsl_rl_args(parser: argparse.ArgumentParser): arg_group.add_argument( "--log_project_name", type=str, default=None, help="Name of the logging project when using wandb or neptune." ) - + arg_group.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") def parse_rsl_rl_cfg(task_name: str, args_cli: argparse.Namespace) -> RslRlBaseRunnerCfg: """Parse configuration for RSL-RL agent based on inputs. diff --git a/scripts/reinforcement_learning/rsl_rl/play.py b/scripts/reinforcement_learning/rsl_rl/play.py index aa463822d64..b475c4002c2 100644 --- a/scripts/reinforcement_learning/rsl_rl/play.py +++ b/scripts/reinforcement_learning/rsl_rl/play.py @@ -34,7 +34,6 @@ help="Use the pre-trained checkpoint from Nucleus.", ) parser.add_argument("--real-time", action="store_true", default=False, help="Run in real-time, if possible.") -parser.add_argument( "--offline", action="store_true", default=False, help="Use local assets and configurations (offline mode)") # append RSL-RL cli arguments cli_args.add_rsl_rl_args(parser) # append AppLauncher cli args diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index 4aa44005d9e..1b9b8262e6e 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -34,7 +34,6 @@ parser.add_argument( "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) -parser.add_argument( "--offline", action="store_true", default=False, help="Use local assets and configurations (offline mode)") # append RSL-RL cli arguments cli_args.add_rsl_rl_args(parser) # append AppLauncher cli args From 94d82d58c652df836ab4d82d04475820e40bc6f5 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 17:04:27 -0500 Subject: [PATCH 07/24] offline mode for all (sb3, SKRL, RL Games, Sim2Sim) --- scripts/offline_setup/README.md | 3 ++- scripts/reinforcement_learning/rsl_rl/play.py | 12 ++++++------ scripts/reinforcement_learning/rsl_rl/train.py | 11 +++++------ scripts/reinforcement_learning/sb3/play.py | 8 ++++++++ scripts/reinforcement_learning/sb3/train.py | 7 +++++++ scripts/reinforcement_learning/skrl/play.py | 9 ++++++++- scripts/reinforcement_learning/skrl/train.py | 8 ++++++++ scripts/sim2sim_transfer/rsl_rl_transfer.py | 5 +++++ 8 files changed, 49 insertions(+), 14 deletions(-) diff --git a/scripts/offline_setup/README.md b/scripts/offline_setup/README.md index 35b866f6a12..4401214bcc4 100644 --- a/scripts/offline_setup/README.md +++ b/scripts/offline_setup/README.md @@ -27,6 +27,7 @@ --categories Props Robots Environments Materials Controllers ActuatorNets Policies Mimic ``` ### 2. Train completely offline with any robot via the `--offline` flag (also works with `/play`) +#### Supported for: `rl_games`, `rsl_rl`, `sb3`, `skrl`, and `sim2transfer` ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ @@ -35,7 +36,7 @@ ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ - --num_envs 1 \ + --num_envs 128 \ --checkpoint logs/rsl_rl/unitree_go2_flat/2026-01-27_14-58-33/model_800.pt \ --video \ --video_length 1000 diff --git a/scripts/reinforcement_learning/rsl_rl/play.py b/scripts/reinforcement_learning/rsl_rl/play.py index b475c4002c2..1d229626be8 100644 --- a/scripts/reinforcement_learning/rsl_rl/play.py +++ b/scripts/reinforcement_learning/rsl_rl/play.py @@ -67,9 +67,9 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) +from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict -from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode from isaaclab_rl.rsl_rl import RslRlBaseRunnerCfg, RslRlVecEnvWrapper, export_policy_as_jit, export_policy_as_onnx from isaaclab_rl.utils.pretrained_checkpoint import get_published_pretrained_checkpoint @@ -84,6 +84,11 @@ @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): """Play with RSL-RL agent.""" + # Handle config to use offline_assets + if args_cli.offline: + setup_offline_mode() + patch_config_for_offline_mode(env_cfg) + # grab task name for checkpoint path task_name = args_cli.task.split(":")[-1] train_task_name = task_name.replace("-Play", "") @@ -92,11 +97,6 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen agent_cfg: RslRlBaseRunnerCfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs - # Handle config to use offline_assets - if args_cli.offline: - setup_offline_mode() - patch_config_for_offline_mode(env_cfg) - # set the environment seed # note: certain randomizations occur in the environment initialization so we set the seed here env_cfg.seed = agent_cfg.seed diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index 1b9b8262e6e..e3c1093fdae 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -91,9 +91,9 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) +from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml -from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode from isaaclab_rl.rsl_rl import RslRlBaseRunnerCfg, RslRlVecEnvWrapper @@ -115,6 +115,10 @@ @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): """Train with RSL-RL agent.""" + # Handle config to use offline_assets + if args_cli.offline: + setup_offline_mode() + patch_config_for_offline_mode(env_cfg) # override configurations with non-hydra CLI arguments agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) @@ -123,11 +127,6 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen args_cli.max_iterations if args_cli.max_iterations is not None else agent_cfg.max_iterations ) - # Handle config to use offline_assets - if args_cli.offline: - setup_offline_mode() - patch_config_for_offline_mode(env_cfg) - # set the environment seed # note: certain randomizations occur in the environment initialization so we set the seed here env_cfg.seed = agent_cfg.seed diff --git a/scripts/reinforcement_learning/sb3/play.py b/scripts/reinforcement_learning/sb3/play.py index 4afe943f62f..d9e3792fa9a 100644 --- a/scripts/reinforcement_learning/sb3/play.py +++ b/scripts/reinforcement_learning/sb3/play.py @@ -44,6 +44,8 @@ default=False, help="Use a slower SB3 wrapper but keep all the extra training info.", ) +parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") + # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # parse the arguments @@ -77,6 +79,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) +from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode from isaaclab.utils.dict import print_dict from isaaclab_rl.sb3 import Sb3VecEnvWrapper, process_sb3_cfg @@ -92,6 +95,11 @@ @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: dict): """Play with stable-baselines agent.""" + # Handle config to use offline_assets + if args_cli.offline: + setup_offline_mode() + patch_config_for_offline_mode(env_cfg) + # grab task name for checkpoint path task_name = args_cli.task.split(":")[-1] train_task_name = task_name.replace("-Play", "") diff --git a/scripts/reinforcement_learning/sb3/train.py b/scripts/reinforcement_learning/sb3/train.py index 32549dcd4ea..e7d632969d6 100644 --- a/scripts/reinforcement_learning/sb3/train.py +++ b/scripts/reinforcement_learning/sb3/train.py @@ -40,6 +40,7 @@ parser.add_argument( "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) +parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # parse the arguments @@ -94,6 +95,7 @@ def cleanup_pbar(*args): ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) +from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml @@ -110,6 +112,11 @@ def cleanup_pbar(*args): @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: dict): """Train with stable-baselines agent.""" + # Handle config to use offline_assets + if args_cli.offline: + setup_offline_mode() + patch_config_for_offline_mode(env_cfg) + # randomly sample a seed if seed = -1 if args_cli.seed == -1: args_cli.seed = random.randint(0, 10000) diff --git a/scripts/reinforcement_learning/skrl/play.py b/scripts/reinforcement_learning/skrl/play.py index 089ec756197..f17e94f7133 100644 --- a/scripts/reinforcement_learning/skrl/play.py +++ b/scripts/reinforcement_learning/skrl/play.py @@ -57,6 +57,7 @@ help="The RL algorithm used for training the skrl agent.", ) parser.add_argument("--real-time", action="store_true", default=False, help="Run in real-time, if possible.") +parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) @@ -104,6 +105,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) +from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode from isaaclab.utils.dict import print_dict from isaaclab_rl.skrl import SkrlVecEnvWrapper @@ -127,6 +129,11 @@ @hydra_task_config(args_cli.task, agent_cfg_entry_point) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, experiment_cfg: dict): """Play with skrl agent.""" + # Handle config to use offline_assets + if args_cli.offline: + setup_offline_mode() + patch_config_for_offline_mode(env_cfg) + # grab task name for checkpoint path task_name = args_cli.task.split(":")[-1] train_task_name = task_name.replace("-Play", "") @@ -139,7 +146,7 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, expe if args_cli.ml_framework.startswith("jax"): skrl.config.jax.backend = "jax" if args_cli.ml_framework == "jax" else "numpy" - # randomly sample a seed if seed = -1 + # randomly sample a seed if seed = -1 if args_cli.seed == -1: args_cli.seed = random.randint(0, 10000) diff --git a/scripts/reinforcement_learning/skrl/train.py b/scripts/reinforcement_learning/skrl/train.py index cf2edce4743..4b9639df6b8 100644 --- a/scripts/reinforcement_learning/skrl/train.py +++ b/scripts/reinforcement_learning/skrl/train.py @@ -57,6 +57,8 @@ parser.add_argument( "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) +parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") + # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # parse the arguments @@ -105,6 +107,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) +from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml @@ -131,6 +134,11 @@ @hydra_task_config(args_cli.task, agent_cfg_entry_point) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: dict): """Train with skrl agent.""" + # Handle config to use offline_assets + if args_cli.offline: + setup_offline_mode() + patch_config_for_offline_mode(env_cfg) + # override configurations with non-hydra CLI arguments env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device diff --git a/scripts/sim2sim_transfer/rsl_rl_transfer.py b/scripts/sim2sim_transfer/rsl_rl_transfer.py index 0ec1b389879..38acd3132b6 100644 --- a/scripts/sim2sim_transfer/rsl_rl_transfer.py +++ b/scripts/sim2sim_transfer/rsl_rl_transfer.py @@ -72,6 +72,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) +from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict @@ -148,6 +149,10 @@ def get_joint_mappings(args_cli, action_space_dim): @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): """Play with RSL-RL agent with policy transfer capabilities.""" + # Handle config to use offline_assets + if args_cli.offline: + setup_offline_mode() + patch_config_for_offline_mode(env_cfg) # override configurations with non-hydra CLI arguments agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) From a52df7ca264ed7361a8e0ae7aa5945f0f7eecaad Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 18:15:41 -0500 Subject: [PATCH 08/24] better asset downloader feedback and resolver --- scripts/offline_setup/README.md | 8 +- scripts/offline_setup/download_assets.py | 332 +++++++++++------- .../isaaclab/isaaclab/utils/asset_resolver.py | 252 +++++++------ 3 files changed, 352 insertions(+), 240 deletions(-) diff --git a/scripts/offline_setup/README.md b/scripts/offline_setup/README.md index 4401214bcc4..4d21606e82e 100644 --- a/scripts/offline_setup/README.md +++ b/scripts/offline_setup/README.md @@ -15,16 +15,16 @@ - Internet connection for initial asset download ## 🚀 Quick Start -### 1. Download essential assets (one-time, ~2-4 GB) +### 1. Download essential assets (one-time, `all` ~30 GB) #### Assets download to the `~/IsaacLab/offline_assets` directory: `cd ~/IsaacLab` ``` ./isaaclab.sh -p scripts/offline_setup/download_assets.py \ --categories all ``` -#### _Optional Note: Specific category fields can be specified separately_ +#### _Optional Note: Category fields can be specified separately_ ``` ./isaaclab.sh -p scripts/offline_setup/download_assets.py \ - --categories Props Robots Environments Materials Controllers ActuatorNets Policies Mimic + --categories Robots --subset Unitree ``` ### 2. Train completely offline with any robot via the `--offline` flag (also works with `/play`) #### Supported for: `rl_games`, `rsl_rl`, `sb3`, `skrl`, and `sim2transfer` @@ -42,7 +42,7 @@ --video_length 1000 --offline ``` -#### _Note: For offline training, assets that cannot be found in `offline_assets` will be fetched from the [Nucleus Server](https://docs.omniverse.nvidia.com/nucleus/latest/index.html)._ +#### _Note: For offline training, assets that cannot be found in `offline_assets` will attempted to be fetched from the [Nucleus Server](https://docs.omniverse.nvidia.com/nucleus/latest/index.html)._ ## 📁 Asset Layout #### Offline assets are organized to mirror Nucleus (`ISAAC_NUCLEUS_DIR` & `ISAACLAB_NUCLEUS_DIR`) meaning that no code changes are required! diff --git a/scripts/offline_setup/download_assets.py b/scripts/offline_setup/download_assets.py index 81f953fd64f..31c74389678 100644 --- a/scripts/offline_setup/download_assets.py +++ b/scripts/offline_setup/download_assets.py @@ -4,39 +4,46 @@ # SPDX-License-Identifier: BSD-3-Clause """ -This script downloads all assets from Nucleus server (ISAACLAB_NUCLEUS_DIR) and mirrors -the directory structure locally in the offline_assets folder. This enables offline/local training -without requiring S3/Nucleus connectivity. Must be connected to the internet to use! +Download Isaac Lab assets from Nucleus server for offline training. + +This script downloads assets from the Nucleus server and mirrors the directory structure +locally in the offline_assets folder. This enables offline training without requiring +internet connectivity. + +Requirements: + pip install tqdm Usage: - ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories Props Robots Environments Materials Controllers ActuatorNets Policies Mimic - ./isaaclab.sh -p scripts/offline_setup/download_assets.py [--subset ROBOT_NAME] + # Download all assets + ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories all + + # Download specific categories + ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories Robots Props + + # Download specific robot subset + ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories Robots --subset Unitree + +Available Categories: + Props, Robots, Environments, Materials, Controllers, ActuatorNets, Policies, Mimic """ import argparse import os from pathlib import Path -# Initialize Isaac Sim app first to get access to omni modules +from tqdm import tqdm from isaaclab.app import AppLauncher -# Create minimal app launcher to initialize Isaac Sim environment +# Initialize Isaac Sim environment app_launcher = AppLauncher(headless=True) simulation_app = app_launcher.app import carb import omni.client -try: - from tqdm import tqdm - HAS_TQDM = True -except ImportError: - HAS_TQDM = False - print("Note: Install tqdm for progress bars: pip install tqdm") - # Get Isaac Lab paths ISAACLAB_PATH = os.environ.get("ISAACLAB_PATH", os.getcwd()) -offline_assets_DIR = os.path.join(ISAACLAB_PATH, "offline_assets") +OFFLINE_ASSETS_DIR = os.path.join(ISAACLAB_PATH, "offline_assets") # Get the Nucleus directory from settings settings = carb.settings.get_settings() @@ -51,7 +58,6 @@ "Robots": {"desc": "Robot USD files and configurations", "base": ISAAC_NUCLEUS_DIR}, "Environments": {"desc": "Environment assets and terrains", "base": ISAAC_NUCLEUS_DIR}, "Materials": {"desc": "Materials and textures including sky HDRs", "base": ISAAC_NUCLEUS_DIR}, - # Isaac Lab specific assets (Isaac/IsaacLab/) "Controllers": {"desc": "IK controllers and kinematics assets", "base": ISAACLAB_NUCLEUS_DIR}, "ActuatorNets": {"desc": "Actuator network models", "base": ISAACLAB_NUCLEUS_DIR}, @@ -60,96 +66,129 @@ } -def ensure_offline_directory(offline_path: str) -> None: - """Create offline directory if it doesn't exist.""" - os.makedirs(offline_path, exist_ok=True) +def format_size(bytes_size: int) -> str: + """ + Format bytes into human-readable size. + + Args: + bytes_size: Size in bytes + + Returns: + Human-readable size string (e.g., "1.5 GB") + """ + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if bytes_size < 1024.0: + return f"{bytes_size:.1f} {unit}" + bytes_size /= 1024.0 + return f"{bytes_size:.1f} PB" -def download_file(remote_path: str, offline_path: str) -> bool: +def get_local_directory_size(path: str) -> int: """ - Download a single file from Nucleus to offline storage. + Calculate total size of a local directory. Args: - remote_path: Full Nucleus URL (e.g., omniverse://...) - offline_path: Offline file system path + path: Local directory path Returns: - True if successful, False otherwise + Total size in bytes """ - try: - # Create parent directory if needed - os.makedirs(os.path.dirname(offline_path), exist_ok=True) - - # Copy file from Nucleus to offline - result = omni.client.copy(remote_path, offline_path) - return result == omni.client.Result.OK - except Exception as e: - print(f"Error downloading {remote_path}: {e}") - return False + total_size = 0 + if os.path.exists(path): + for dirpath, dirnames, filenames in os.walk(path): + for filename in filenames: + filepath = os.path.join(dirpath, filename) + if os.path.exists(filepath): + total_size += os.path.getsize(filepath) + return total_size -def list_nucleus_directory(remote_path: str) -> list[tuple[str, bool]]: +def get_remote_directory_info(remote_path: str) -> tuple[int, int]: """ - List all files and directories in a Nucleus path. + Get file count and total size of a remote Nucleus directory. Args: remote_path: Nucleus directory URL Returns: - List of (item_name, is_directory) tuples + Tuple of (file_count, total_size_bytes) """ + file_count = 0 + total_size = 0 + result, entries = omni.client.list(remote_path) if result != omni.client.Result.OK: - return [] + return 0, 0 - items = [] for entry in entries: is_dir = entry.flags & omni.client.ItemFlags.CAN_HAVE_CHILDREN - items.append((entry.relative_path, bool(is_dir))) - return items + remote_item = f"{remote_path}/{entry.relative_path}" + + if is_dir: + # Recursively get info from subdirectory + sub_count, sub_size = get_remote_directory_info(remote_item) + file_count += sub_count + total_size += sub_size + else: + # Get file size + file_count += 1 + stat_result, stat_entry = omni.client.stat(remote_item) + if stat_result == omni.client.Result.OK: + total_size += stat_entry.size + + return file_count, total_size + + +def ensure_directory(path: str) -> None: + """Create directory if it doesn't exist.""" + os.makedirs(path, exist_ok=True) + + +def download_file(remote_path: str, local_path: str) -> bool: + """ + Download a single file from Nucleus to local storage. + + Args: + remote_path: Full Nucleus URL + local_path: Local file system path + + Returns: + True if successful, False otherwise + """ + try: + os.makedirs(os.path.dirname(local_path), exist_ok=True) + result = omni.client.copy(remote_path, local_path) + return result == omni.client.Result.OK + except Exception as e: + print(f"Error downloading {remote_path}: {e}") + return False -def download_directory_recursive(remote_path: str, offline_base: str, progress_bar=None) -> None: +def download_directory_recursive(remote_path: str, local_base: str, progress_bar) -> None: """ - Recursively download a directory from Nucleus to offline storage. + Recursively download a directory from Nucleus to local storage. Args: remote_path: Nucleus directory URL - offline_base: Offline directory to mirror structure - progress_bar: Optional tqdm progress bar (or None) + local_base: Local directory to mirror structure + progress_bar: tqdm progress bar instance """ - items = list_nucleus_directory(remote_path) + result, entries = omni.client.list(remote_path) + if result != omni.client.Result.OK: + return - for item_name, is_directory in items: - remote_item = f"{remote_path}/{item_name}" - offline_item = os.path.join(offline_base, item_name) + for entry in entries: + is_dir = entry.flags & omni.client.ItemFlags.CAN_HAVE_CHILDREN + remote_item = f"{remote_path}/{entry.relative_path}" + local_item = os.path.join(local_base, entry.relative_path) - if is_directory: - # Recursively download subdirectory - ensure_offline_directory(offline_item) - download_directory_recursive(remote_item, offline_item, progress_bar) + if is_dir: + ensure_directory(local_item) + download_directory_recursive(remote_item, local_item, progress_bar) else: - # Download file - if progress_bar is not None: - progress_bar.set_description(f"Downloading {item_name}") - else: - print(f" Downloading: {item_name}") - download_file(remote_item, offline_item) - if progress_bar is not None: - progress_bar.update(1) - - -def count_files_recursive(remote_path: str) -> int: - """Count total files in a directory tree for progress tracking.""" - count = 0 - items = list_nucleus_directory(remote_path) - - for item_name, is_directory in items: - if is_directory: - count += count_files_recursive(f"{remote_path}/{item_name}") - else: - count += 1 - return count + progress_bar.set_description(f"Downloading {entry.relative_path[:50]}") + download_file(remote_item, local_item) + progress_bar.update(1) def download_asset_category(category: str, subset: str = None) -> None: @@ -165,19 +204,18 @@ def download_asset_category(category: str, subset: str = None) -> None: description = category_info["desc"] remote_dir = f"{base_path}/{category}" - offline_dir = os.path.join(offline_assets_DIR, category) - - print(f"\n{'='*60}") - print(f"Downloading {category}: {description}") - print(f"From: {remote_dir}") - print(f"To: {offline_dir}") - print(f"{'='*60}") + local_dir = os.path.join(OFFLINE_ASSETS_DIR, category) - # If subset is specified, only download that subset + # Apply subset filter if specified if subset and category == "Robots": remote_dir = f"{remote_dir}/{subset}" - offline_dir = os.path.join(offline_dir, subset) - print(f"Filtering to subset: {subset}") + local_dir = os.path.join(local_dir, subset) + + print(f"\n{'='*70}") + print(f"📦 {category}: {description}") + print(f"{'='*70}") + print(f"Source: {remote_dir}") + print(f"Target: {local_dir}") # Check if remote directory exists result, _ = omni.client.stat(remote_dir) @@ -186,46 +224,55 @@ def download_asset_category(category: str, subset: str = None) -> None: print(f" This category may not be available or may be in a different location.") return - # Count files for progress bar - print("Counting files...") - total_files = count_files_recursive(remote_dir) - print(f"Found {total_files} files to download") + # Count files and get size + print("📊 Analyzing remote directory...") + file_count, total_size = get_remote_directory_info(remote_dir) - if total_files == 0: - print("No files to download") + if file_count == 0: + print("✓ No files to download") return + print(f" Files: {file_count:,}") + print(f" Size: {format_size(total_size)}") + # Download with progress bar - ensure_offline_directory(offline_dir) - if HAS_TQDM: - with tqdm(total=total_files, unit="file") as pbar: - download_directory_recursive(remote_dir, offline_dir, pbar) - else: - print(f"Downloading {total_files} files (install tqdm for progress bars)...") - download_directory_recursive(remote_dir, offline_dir, None) + ensure_directory(local_dir) + with tqdm(total=file_count, unit="file", desc="Progress") as pbar: + download_directory_recursive(remote_dir, local_dir, pbar) print(f"✓ Completed {category}") -def verify_downloads(category: str = None) -> None: - """Verify that offline assets directory has expected structure.""" - print("\n" + "="*60) - print("Verifying offline assets...") - print("="*60) +def verify_downloads() -> None: + """Display summary of downloaded assets.""" + print("\n" + "="*70) + print("📊 Downloaded Assets Summary") + print("="*70) - categories_to_check = [category] if category else ASSET_CATEGORIES.keys() + total_size = 0 + total_files = 0 - for cat in categories_to_check: - offline_dir = os.path.join(offline_assets_DIR, cat) - if os.path.exists(offline_dir): - file_count = sum(1 for _ in Path(offline_dir).rglob("*") if _.is_file()) - print(f"✓ {cat}: {file_count} files") + for category in ASSET_CATEGORIES.keys(): + local_dir = os.path.join(OFFLINE_ASSETS_DIR, category) + if os.path.exists(local_dir): + size = get_local_directory_size(local_dir) + files = sum(1 for _ in Path(local_dir).rglob("*") if _.is_file()) + total_size += size + total_files += files + print(f"✓ {category:<15} {files:>6,} files {format_size(size):>10}") else: - print(f"✗ {cat}: Not found") + print(f"✗ {category:<15} Not found") + + print("=" * 70) + print(f"{'TOTAL':<15} {total_files:>6,} files {format_size(total_size):>10}") + print("=" * 70) def main(): - parser = argparse.ArgumentParser(description="Download Isaac Lab assets from Nucleus to offline storage") + parser = argparse.ArgumentParser( + description="Download Isaac Lab assets from Nucleus to local storage for offline training", + formatter_class=argparse.RawDescriptionHelpFormatter + ) parser.add_argument( "--categories", nargs="+", @@ -247,13 +294,13 @@ def main(): args = parser.parse_args() try: - print("\n" + "="*60) - print("Isaac Lab Asset Downloader") - print("="*60) - print(f"Isaac Sim assets: {ISAAC_NUCLEUS_DIR}") - print(f"Isaac Lab assets: {ISAACLAB_NUCLEUS_DIR}") - print(f"Offline target: {offline_assets_DIR}") - print("="*60) + print("\n" + "="*70) + print("🚀 Isaac Lab Offline Asset Downloader") + print("="*70) + print(f"Isaac Sim Assets: {ISAAC_NUCLEUS_DIR}") + print(f"Isaac Lab Assets: {ISAACLAB_NUCLEUS_DIR}") + print(f"Local Target: {OFFLINE_ASSETS_DIR}") + print("="*70) if args.verify_only: verify_downloads() @@ -266,42 +313,69 @@ def main(): else args.categories ) - print(f"\nWill download: {', '.join(categories)}") + print(f"\n📋 Selected Categories: {', '.join(categories)}") if args.subset: - print(f"Subset filter: {args.subset}") + print(f"🔍 Subset Filter: {args.subset}") + + # Calculate total download size + print("\n📊 Calculating download size...") + total_files = 0 + total_size = 0 + + for category in categories: + category_info = ASSET_CATEGORIES[category] + base_path = category_info["base"] + remote_dir = f"{base_path}/{category}" + + # Apply subset filter + if args.subset and category == "Robots": + remote_dir = f"{remote_dir}/{args.subset}" + + # Check if directory exists + result, _ = omni.client.stat(remote_dir) + if result == omni.client.Result.OK: + files, size = get_remote_directory_info(remote_dir) + total_files += files + total_size += size + print(f" {category}: {files:,} files ({format_size(size)})") + + print("\n" + "="*70) + print(f"📦 Total Download: {total_files:,} files ({format_size(total_size)})") + print("="*70) # Confirm before proceeding response = input("\nProceed with download? [y/N]: ") if response.lower() not in ["y", "yes"]: - print("Download cancelled") + print("❌ Download cancelled") return # Download each category + print("\n🔽 Starting download...") for category in categories: try: download_asset_category(category, args.subset) except KeyboardInterrupt: - print("\n\nDownload interrupted by user") + print("\n\n⚠️ Download interrupted by user") raise except Exception as e: print(f"\n❌ Error downloading {category}: {e}") continue - # Verify downloads + # Show final summary verify_downloads() - print("\n" + "="*60) - print("✓ Download complete!") - print("="*60) - print(f"\Offline assets are now available in: {offline_assets_DIR}") - print("\nYou can now use the --offline flag in training:") + print("\n" + "="*70) + print("✅ Download Complete!") + print("="*70) + print(f"\nOffline assets are available in: {OFFLINE_ASSETS_DIR}") + print("\n💡 Usage: Add --offline flag to your training commands") + print("\nExample:") print(" ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \\") print(" --task Isaac-Velocity-Flat-Unitree-Go2-v0 \\") print(" --num_envs 128 \\") - print(" --offline") + print(" --offline\n") finally: - # Always clean up simulation app simulation_app.close() diff --git a/source/isaaclab/isaaclab/utils/asset_resolver.py b/source/isaaclab/isaaclab/utils/asset_resolver.py index 06c8558e054..506dad35dce 100644 --- a/source/isaaclab/isaaclab/utils/asset_resolver.py +++ b/source/isaaclab/isaaclab/utils/asset_resolver.py @@ -4,19 +4,32 @@ # SPDX-License-Identifier: BSD-3-Clause """ -This module provides utilities to transparently redirect asset paths from Nucleus -to offline storage when running in --offline mode. It maintains the same directory -structure so that configs require no changes. +Offline Asset Resolver for Isaac Lab. + +This module provides utilities to transparently redirect asset paths from Nucleus/S3 +to local storage when running in offline mode. It maintains the same directory structure +so that environment configs require no changes. + +Key Features: + - Automatic path resolution from Nucleus URLs to local filesystem + - Transparent fallback to Nucleus if local asset missing + - Monkey-patching of Isaac Lab spawn configs for automatic resolution + - Support for versioned Nucleus URLs Usage: - enable_offline_mode() + from isaaclab.utils import setup_offline_mode, patch_config_for_offline_mode - All subsequent asset paths will be resolved to offline_assets/ - path = resolve_asset_path(ISAACLAB_NUCLEUS_DIR + "/Robots/...") - Returns: /path/to/isaaclab/offline_assets/Robots/... + # Enable offline mode at start of script + setup_offline_mode() + + # Patch environment config after loading + patch_config_for_offline_mode(env_cfg) + + # All asset paths will now resolve to offline_assets/ """ import os +import re from typing import Optional import carb.settings @@ -26,15 +39,16 @@ class OfflineAssetResolver: """ Singleton class to manage offline asset path resolution. - When enabled, this resolver intercepts asset paths that point to Nucleus - and redirects them to the offline_assets directory. + When enabled, this resolver intercepts asset paths pointing to Nucleus servers + and redirects them to the local offline_assets directory while maintaining the + same directory structure. """ _instance: Optional['OfflineAssetResolver'] = None _enabled: bool = False _offline_assets_dir: Optional[str] = None - _nucleus_dir: Optional[str] = None _isaac_nucleus_dir: Optional[str] = None + _isaaclab_nucleus_dir: Optional[str] = None def __new__(cls): if cls._instance is None: @@ -55,14 +69,13 @@ def _initialize(self): nucleus_root = settings.get("/persistent/isaac/asset_root/default") if nucleus_root: - self._nucleus_dir = nucleus_root self._isaac_nucleus_dir = f"{nucleus_root}/Isaac" self._isaaclab_nucleus_dir = f"{nucleus_root}/Isaac/IsaacLab" print(f"[OfflineAssetResolver] Initialized") print(f" Offline assets dir: {self._offline_assets_dir}") if self._isaaclab_nucleus_dir: - print(f" Nucleus dir: {self._isaaclab_nucleus_dir}") + print(f" Nucleus base: {self._isaaclab_nucleus_dir}") def enable(self): """Enable offline asset resolution.""" @@ -73,7 +86,7 @@ def enable(self): # Verify offline assets directory exists if not os.path.exists(self._offline_assets_dir): print(f"[OfflineAssetResolver] ⚠️ WARNING: Offline assets directory not found!") - print(f" Please run: ./isaaclab.sh -p scripts/offline_setup/download_assets.py") + print(f" Run: ./isaaclab.sh -p scripts/offline_setup/download_assets.py") def disable(self): """Disable offline asset resolution.""" @@ -88,61 +101,73 @@ def resolve_path(self, asset_path: str) -> str: """ Resolve an asset path to either Nucleus or offline storage. + This method handles multiple Nucleus URL formats including versioned URLs + (e.g., .../Assets/Isaac/5.1/Isaac/...) and falls back to Nucleus if the + local asset doesn't exist. + Args: asset_path: Original asset path (may contain Nucleus URL) Returns: - Resolved path (offline if enabled, otherwise original) + Resolved path (offline if enabled and exists, otherwise original) """ - if not self._enabled: + if not self._enabled or not isinstance(asset_path, str) or not asset_path: return asset_path - # Skip if not a string or empty - if not isinstance(asset_path, str) or not asset_path: - return asset_path - - # Check if this is a Nucleus path we should redirect - path_to_convert = None - - # Handle versioned paths like: .../Assets/Isaac/5.1/Isaac/IsaacLab/... - import re - - # Pattern 1: Isaac Lab assets with version (e.g., .../Assets/Isaac/5.1/Isaac/IsaacLab/Robots/...) - match = re.search(r'/Assets/Isaac/[\d.]+/Isaac/IsaacLab/(.+)$', asset_path) - if match: - path_to_convert = match.group(1) - - # Pattern 2: General Isaac assets with version (e.g., .../Assets/Isaac/5.1/Isaac/Props/...) - if not path_to_convert: - match = re.search(r'/Assets/Isaac/[\d.]+/Isaac/(?!IsaacLab)(.+)$', asset_path) - if match: - path_to_convert = match.group(1) - - # Pattern 3: Without version - IsaacLab specific (older format) - if not path_to_convert and self._isaaclab_nucleus_dir and asset_path.startswith(self._isaaclab_nucleus_dir): - path_to_convert = asset_path[len(self._isaaclab_nucleus_dir):].lstrip("/") - - # Pattern 4: Without version - General Isaac (older format) - if not path_to_convert and self._isaac_nucleus_dir and asset_path.startswith(self._isaac_nucleus_dir): - isaac_relative = asset_path[len(self._isaac_nucleus_dir):].lstrip("/") - path_to_convert = isaac_relative + # Try to extract the relative path from various Nucleus URL formats + path_to_convert = self._extract_relative_path(asset_path) - # If we identified a path to convert, create the offline path if path_to_convert: offline_path = os.path.join(self._offline_assets_dir, path_to_convert) - # Verify the offline file exists + # Return offline path if file exists, otherwise fall back to Nucleus if os.path.exists(offline_path): print(f"[OfflineAssetResolver] ✓ Using offline: {path_to_convert}") return offline_path else: print(f"[OfflineAssetResolver] ⚠️ Not found locally: {path_to_convert}") print(f"[OfflineAssetResolver] Falling back to Nucleus") - return asset_path - # If not a Nucleus path, return original return asset_path + def _extract_relative_path(self, asset_path: str) -> Optional[str]: + """ + Extract relative path from various Nucleus URL formats. + + Handles: + - Versioned URLs: .../Assets/Isaac/5.1/Isaac/IsaacLab/Robots/... + - Versioned URLs: .../Assets/Isaac/5.1/Isaac/Props/... + - Non-versioned: .../Isaac/IsaacLab/Robots/... + - Non-versioned: .../Isaac/Props/... + + Args: + asset_path: Full Nucleus URL + + Returns: + Relative path (e.g., "Robots/Unitree/Go2/go2.usd") or None if not a Nucleus path + """ + # Pattern 1: Isaac Lab assets with version + # e.g., .../Assets/Isaac/5.1/Isaac/IsaacLab/Robots/... + match = re.search(r'/Assets/Isaac/[\d.]+/Isaac/IsaacLab/(.+)$', asset_path) + if match: + return match.group(1) + + # Pattern 2: General Isaac assets with version + # e.g., .../Assets/Isaac/5.1/Isaac/Props/... + match = re.search(r'/Assets/Isaac/[\d.]+/Isaac/(?!IsaacLab)(.+)$', asset_path) + if match: + return match.group(1) + + # Pattern 3: Isaac Lab assets without version (older format) + if self._isaaclab_nucleus_dir and asset_path.startswith(self._isaaclab_nucleus_dir): + return asset_path[len(self._isaaclab_nucleus_dir):].lstrip("/") + + # Pattern 4: General Isaac assets without version (older format) + if self._isaac_nucleus_dir and asset_path.startswith(self._isaac_nucleus_dir): + return asset_path[len(self._isaac_nucleus_dir):].lstrip("/") + + return None + def get_offline_assets_dir(self) -> str: """Get the offline assets directory path.""" return self._offline_assets_dir @@ -152,6 +177,7 @@ def get_offline_assets_dir(self) -> str: _resolver = OfflineAssetResolver() +# Public API functions def enable_offline_mode(): """Enable offline asset resolution globally.""" _resolver.enable() @@ -175,7 +201,7 @@ def resolve_asset_path(asset_path: str) -> str: asset_path: Original asset path (may contain Nucleus URL) Returns: - Resolved path (offline if mode is enabled and file exists, otherwise original) + Resolved path (offline if mode enabled and file exists, otherwise original) """ return _resolver.resolve_path(asset_path) @@ -187,10 +213,17 @@ def get_offline_assets_dir() -> str: def patch_config_for_offline_mode(env_cfg): """ - Patch specific known paths in the environment config. + Patch environment configuration to use offline assets. + + This function walks through the environment config and patches known asset paths + to use local storage when offline mode is enabled. It handles: + - Robot USD paths + - Terrain/ground plane paths + - Sky light textures + - Visualization markers Args: - env_cfg: Environment configuration object + env_cfg: Environment configuration object (typically ManagerBasedRLEnvCfg) """ if not is_offline_mode_enabled(): return @@ -208,25 +241,17 @@ def patch_config_for_offline_mode(env_cfg): patches_made += 1 print(f"[OfflineAssetResolver] ✓ Patched robot USD path") - # Patch terrain/ground plane paths + # Patch terrain USD path (if present) if hasattr(env_cfg, 'scene') and hasattr(env_cfg.scene, 'terrain'): - terrain_cfg = env_cfg.scene.terrain - - # Check for terrain_generator ground plane - if hasattr(terrain_cfg, 'terrain_generator'): - # This is typically procedural, no USD files needed - pass - - # Check for direct ground plane USD - if hasattr(terrain_cfg, 'usd_path'): - original = terrain_cfg.usd_path + if hasattr(env_cfg.scene.terrain, 'usd_path'): + original = env_cfg.scene.terrain.usd_path resolved = resolve_asset_path(original) if resolved != original: - terrain_cfg.usd_path = resolved + env_cfg.scene.terrain.usd_path = resolved patches_made += 1 print(f"[OfflineAssetResolver] ✓ Patched terrain USD path") - # Patch sky light textures + # Patch sky light texture if hasattr(env_cfg, 'scene') and hasattr(env_cfg.scene, 'sky_light'): if hasattr(env_cfg.scene.sky_light, 'spawn') and hasattr(env_cfg.scene.sky_light.spawn, 'texture_file'): if env_cfg.scene.sky_light.spawn.texture_file: @@ -237,81 +262,70 @@ def patch_config_for_offline_mode(env_cfg): patches_made += 1 print(f"[OfflineAssetResolver] ✓ Patched sky light texture") - # Patch visualization markers (arrows, etc.) + # Patch visualization markers if hasattr(env_cfg, 'commands'): for command_name in dir(env_cfg.commands): if command_name.startswith('_'): continue + command = getattr(env_cfg.commands, command_name, None) - if command and hasattr(command, 'goal_vel_visualizer_cfg'): - visualizer = command.goal_vel_visualizer_cfg - if hasattr(visualizer, 'markers') and isinstance(visualizer.markers, dict): - for marker_name, marker_cfg in visualizer.markers.items(): - if hasattr(marker_cfg, 'usd_path'): - original = marker_cfg.usd_path - resolved = resolve_asset_path(original) - if resolved != original: - marker_cfg.usd_path = resolved - patches_made += 1 - print(f"[OfflineAssetResolver] ✓ Patched {marker_name} marker") - - if hasattr(env_cfg, 'commands'): - for command_name in dir(env_cfg.commands): - if command_name.startswith('_'): + if not command: continue - command = getattr(env_cfg.commands, command_name, None) - if command: - # Patch BOTH current and goal visualizers - for viz_name in ['current_vel_visualizer_cfg', 'goal_vel_visualizer_cfg']: - if hasattr(command, viz_name): - visualizer = getattr(command, viz_name) - if hasattr(visualizer, 'markers') and isinstance(visualizer.markers, dict): - for marker_name, marker_cfg in visualizer.markers.items(): - if hasattr(marker_cfg, 'usd_path'): - original = marker_cfg.usd_path - resolved = resolve_asset_path(original) - if resolved != original: - marker_cfg.usd_path = resolved - patches_made += 1 - print(f"[OfflineAssetResolver] ✓ Patched {marker_name} in {viz_name}") + + # Patch both current and goal velocity visualizers + for viz_name in ['current_vel_visualizer_cfg', 'goal_vel_visualizer_cfg']: + if not hasattr(command, viz_name): + continue + + visualizer = getattr(command, viz_name) + if not hasattr(visualizer, 'markers') or not isinstance(visualizer.markers, dict): + continue + + for marker_name, marker_cfg in visualizer.markers.items(): + if hasattr(marker_cfg, 'usd_path'): + original = marker_cfg.usd_path + resolved = resolve_asset_path(original) + if resolved != original: + marker_cfg.usd_path = resolved + patches_made += 1 + print(f"[OfflineAssetResolver] ✓ Patched {marker_name} in {viz_name}") if patches_made > 0: print(f"[OfflineAssetResolver] Patched {patches_made} asset paths") else: print(f"[OfflineAssetResolver] No paths needed patching (already correct)") -# Monkey patch common Isaac Lab modules to use offline resolver + def install_path_hooks(): """ - Install hooks into Isaac Lab's asset loading to automatically resolve paths. + Install hooks into Isaac Lab's spawn configs for automatic path resolution. - This patches the spawn configs to automatically resolve paths when offline mode is enabled. + This function monkey-patches Isaac Lab's UsdFileCfg, GroundPlaneCfg, and + PreviewSurfaceCfg classes to automatically resolve asset paths when they're + instantiated. This provides transparent offline support without modifying + environment configs. """ try: import isaaclab.sim as sim_utils - # Patch UsdFileCfg + # Patch UsdFileCfg for general USD file spawning if hasattr(sim_utils, 'UsdFileCfg'): original_usd_init = sim_utils.UsdFileCfg.__init__ def patched_usd_init(self, *args, **kwargs): - # Call original init original_usd_init(self, *args, **kwargs) - # Resolve the usd_path if offline mode is enabled if hasattr(self, 'usd_path') and is_offline_mode_enabled(): self.usd_path = resolve_asset_path(self.usd_path) sim_utils.UsdFileCfg.__init__ = patched_usd_init print("[OfflineAssetResolver] Installed UsdFileCfg path hook") - # Patch GroundPlaneCfg (for terrain) + # Patch GroundPlaneCfg for terrain/ground plane spawning if hasattr(sim_utils, 'GroundPlaneCfg'): original_ground_init = sim_utils.GroundPlaneCfg.__init__ def patched_ground_init(self, *args, **kwargs): - # Call original init original_ground_init(self, *args, **kwargs) - # Resolve the usd_path if offline mode is enabled if hasattr(self, 'usd_path') and is_offline_mode_enabled(): original_path = self.usd_path self.usd_path = resolve_asset_path(self.usd_path) @@ -321,7 +335,7 @@ def patched_ground_init(self, *args, **kwargs): sim_utils.GroundPlaneCfg.__init__ = patched_ground_init print("[OfflineAssetResolver] Installed GroundPlaneCfg path hook") - # Patch PreviewSurfaceCfg for textures + # Patch PreviewSurfaceCfg for texture file resolution if hasattr(sim_utils, 'PreviewSurfaceCfg'): original_surface_init = sim_utils.PreviewSurfaceCfg.__init__ @@ -338,8 +352,32 @@ def patched_surface_init(self, *args, **kwargs): print("[OfflineAssetResolver] Could not install path hooks - isaaclab.sim not available") -# Set up offline mode with all hooks def setup_offline_mode(): + """ + Set up offline mode with all hooks and path resolution. + + This is the main entry point for enabling offline training. Call this function + at the start of your training script when the --offline flag is set. + + Example: + if args_cli.offline: + from isaaclab.utils import setup_offline_mode, patch_config_for_offline_mode + setup_offline_mode() + patch_config_for_offline_mode(env_cfg) + """ enable_offline_mode() install_path_hooks() - print("[OfflineAssetResolver] Offline mode fully configured") \ No newline at end of file + print("[OfflineAssetResolver] Offline mode fully configured") + + +# Export public API +__all__ = [ + "enable_offline_mode", + "disable_offline_mode", + "is_offline_mode_enabled", + "resolve_asset_path", + "get_offline_assets_dir", + "patch_config_for_offline_mode", + "install_path_hooks", + "setup_offline_mode", +] \ No newline at end of file From 52a1dcd15ebf4afc1c3a801ebb1902f5e7bd5f62 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 18:41:56 -0500 Subject: [PATCH 09/24] documentation clarifications --- NOTES.md | 63 ------------------------ scripts/offline_setup/README.md | 24 ++++----- scripts/offline_setup/download_assets.py | 3 -- 3 files changed, 12 insertions(+), 78 deletions(-) delete mode 100644 NOTES.md diff --git a/NOTES.md b/NOTES.md deleted file mode 100644 index 95f78e4a4f3..00000000000 --- a/NOTES.md +++ /dev/null @@ -1,63 +0,0 @@ -conda deactivate - - -``` -./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ - --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ - --headless \ - --num_envs 128 -``` - - -``` -./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ - --task Isaac-Velocity-Flat-H1-v0 \ - --num_envs 496 \ - --offline -``` - -``` -./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ - --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ - --num_envs 128 \ - --offline -``` - - - -``` -./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ - --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ - --num_envs 4096 \ - --resume -``` - - -``` -./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ - --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ - --num_envs 4096 \ - --resume \ - --load_run 2026-01-21_14-09-41 \ - --checkpoint model_50.pt -``` - - -``` -./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ - --task Isaac-Velocity-Flat-H1-v0 \ - --num_envs 1 \ - --checkpoint logs/rsl_rl/h1_flat/2026-01-27_14-58-33/model_800.pt \ - --offline -``` - - -``` -./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ - --task Isaac-Velocity-Flat-H1-v0 \ - --num_envs 1 \ - --checkpoint logs/rsl_rl/h1_flat/2026-01-27_14-58-33/model_800.pt \ - --video \ - --video_length 1000 \ - --offline -``` \ No newline at end of file diff --git a/scripts/offline_setup/README.md b/scripts/offline_setup/README.md index 4d21606e82e..ce4120e03c5 100644 --- a/scripts/offline_setup/README.md +++ b/scripts/offline_setup/README.md @@ -21,7 +21,7 @@ ./isaaclab.sh -p scripts/offline_setup/download_assets.py \ --categories all ``` -#### _Optional Note: Category fields can be specified separately_ +#### _Alternative Note: Category fields can be specified separately_ ``` ./isaaclab.sh -p scripts/offline_setup/download_assets.py \ --categories Robots --subset Unitree @@ -37,15 +37,15 @@ ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ --num_envs 128 \ - --checkpoint logs/rsl_rl/unitree_go2_flat/2026-01-27_14-58-33/model_800.pt \ + --checkpoint logs/rsl_rl/_flat//model_.pt \ --video \ - --video_length 1000 + --video_length 1000 \ --offline ``` #### _Note: For offline training, assets that cannot be found in `offline_assets` will attempted to be fetched from the [Nucleus Server](https://docs.omniverse.nvidia.com/nucleus/latest/index.html)._ ## 📁 Asset Layout -#### Offline assets are organized to mirror Nucleus (`ISAAC_NUCLEUS_DIR` & `ISAACLAB_NUCLEUS_DIR`) meaning that no code changes are required! +#### Offline assets are organized to mirror Nucleus (`ISAAC_NUCLEUS_DIR` & `ISAACLAB_NUCLEUS_DIR`) under the `offline_assets` directory, meaning that no code changes are required for offline running! We flatten `Isaac/IsaacLab/` to just the category names (`Robots/`, `Controllers/`, etc.) for cleaner local structure. This happens in `asset_resolver.py`, where the resolver maintains a 1:1 mapping between Nucleus and local storage. ``` IsaacLab/ @@ -68,12 +68,12 @@ IsaacLab/ │ └── UIElements/ │ └── arrow_x.usd └── Robots/ # Robot USD files - ├── Unitree/ - │ ├── Go2/ - │ │ └── go2.usd - │ └── H1/ - │ └── h1.usd - └── ANYbotics/ - └── ANYmal-D/ - └── anymal_d.usd + ├── BostonDynamics/ + │ └── spot/ + │ └── spot.usd + └── Unitree/ + ├── Go2/ + │ └── go2.usd + └── H1/ + └── h1.usd ``` diff --git a/scripts/offline_setup/download_assets.py b/scripts/offline_setup/download_assets.py index 31c74389678..0c3ecb532d3 100644 --- a/scripts/offline_setup/download_assets.py +++ b/scripts/offline_setup/download_assets.py @@ -10,9 +10,6 @@ locally in the offline_assets folder. This enables offline training without requiring internet connectivity. -Requirements: - pip install tqdm - Usage: # Download all assets ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories all From e45aae2fe86aa88e5ebf91991d46f0569bca44a2 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 19:02:25 -0500 Subject: [PATCH 10/24] minor cleanup --- .gitignore | 2 +- .vscode/.gitignore | 5 +---- scripts/offline_setup/README.md | 2 +- source/isaaclab_tasks/isaaclab_tasks/utils/__init__.py | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 178e3aafddb..51eec8148e9 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,4 @@ tests/ **/gelsight_r15_data/* # Offline assets -offline_assets/* +offline_assets/* \ No newline at end of file diff --git a/.vscode/.gitignore b/.vscode/.gitignore index 717d31bf056..a3e3854954d 100644 --- a/.vscode/.gitignore +++ b/.vscode/.gitignore @@ -7,7 +7,4 @@ # Ignore all other files .python.env -*.json - -# Offline assets -offline_assets/* \ No newline at end of file +*.json \ No newline at end of file diff --git a/scripts/offline_setup/README.md b/scripts/offline_setup/README.md index ce4120e03c5..18c5f422a2a 100644 --- a/scripts/offline_setup/README.md +++ b/scripts/offline_setup/README.md @@ -76,4 +76,4 @@ IsaacLab/ │ └── go2.usd └── H1/ └── h1.usd -``` +``` \ No newline at end of file diff --git a/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.py b/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.py index a5a340dd373..495b207c319 100644 --- a/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.py +++ b/source/isaaclab_tasks/isaaclab_tasks/utils/__init__.py @@ -6,4 +6,4 @@ """Sub-package with utilities, data collectors and environment wrappers.""" from .importer import import_packages -from .parse_cfg import get_checkpoint_path, load_cfg_from_registry, parse_env_cfg \ No newline at end of file +from .parse_cfg import get_checkpoint_path, load_cfg_from_registry, parse_env_cfg From b792c50388390dce92ed1ea02fd806656dc46c69 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 19:03:45 -0500 Subject: [PATCH 11/24] gitignored --- .gitignore | 2 +- .vscode/.gitignore | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 51eec8148e9..178e3aafddb 100644 --- a/.gitignore +++ b/.gitignore @@ -75,4 +75,4 @@ tests/ **/gelsight_r15_data/* # Offline assets -offline_assets/* \ No newline at end of file +offline_assets/* diff --git a/.vscode/.gitignore b/.vscode/.gitignore index a3e3854954d..10b0af342ce 100644 --- a/.vscode/.gitignore +++ b/.vscode/.gitignore @@ -7,4 +7,4 @@ # Ignore all other files .python.env -*.json \ No newline at end of file +*.json From 3a27bcb8d0df7552a198c2a9cb57d9aa013b7232 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 19:04:31 -0500 Subject: [PATCH 12/24] more useful comment --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 178e3aafddb..3ba0d9789e3 100644 --- a/.gitignore +++ b/.gitignore @@ -74,5 +74,5 @@ tests/ **/tactile_record/* **/gelsight_r15_data/* -# Offline assets +# Downloaded assets offline_assets/* From 325d9910eb68377375be0dfb654e6abbc1ed4c67 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 19:08:36 -0500 Subject: [PATCH 13/24] formatting --- scripts/reinforcement_learning/rsl_rl/cli_args.py | 1 + scripts/reinforcement_learning/rsl_rl/train.py | 2 +- scripts/reinforcement_learning/skrl/train.py | 1 - source/isaaclab/isaaclab/utils/__init__.py | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/reinforcement_learning/rsl_rl/cli_args.py b/scripts/reinforcement_learning/rsl_rl/cli_args.py index 9d3233e8868..c3975190d08 100644 --- a/scripts/reinforcement_learning/rsl_rl/cli_args.py +++ b/scripts/reinforcement_learning/rsl_rl/cli_args.py @@ -39,6 +39,7 @@ def add_rsl_rl_args(parser: argparse.ArgumentParser): ) arg_group.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") + def parse_rsl_rl_cfg(task_name: str, args_cli: argparse.Namespace) -> RslRlBaseRunnerCfg: """Parse configuration for RSL-RL agent based on inputs. diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index e3c1093fdae..79233aa01d2 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -39,7 +39,7 @@ # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) args_cli, hydra_args = parser.parse_known_args() - + # always enable cameras to record video if args_cli.video: args_cli.enable_cameras = True diff --git a/scripts/reinforcement_learning/skrl/train.py b/scripts/reinforcement_learning/skrl/train.py index 4b9639df6b8..f41b2fbc2ac 100644 --- a/scripts/reinforcement_learning/skrl/train.py +++ b/scripts/reinforcement_learning/skrl/train.py @@ -58,7 +58,6 @@ "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") - # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # parse the arguments diff --git a/source/isaaclab/isaaclab/utils/__init__.py b/source/isaaclab/isaaclab/utils/__init__.py index 552b5ccc24d..58ab0cc3f2f 100644 --- a/source/isaaclab/isaaclab/utils/__init__.py +++ b/source/isaaclab/isaaclab/utils/__init__.py @@ -17,4 +17,4 @@ from .timer import Timer from .types import * from .version import * -from .asset_resolver import * \ No newline at end of file +from .asset_resolver import * From 32059f94ff36b7a1d850889f2399d053a415ee2d Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 19:12:19 -0500 Subject: [PATCH 14/24] more formatting --- scripts/reinforcement_learning/sb3/play.py | 1 - source/isaaclab/isaaclab/utils/asset_resolver.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/reinforcement_learning/sb3/play.py b/scripts/reinforcement_learning/sb3/play.py index d9e3792fa9a..d962e657840 100644 --- a/scripts/reinforcement_learning/sb3/play.py +++ b/scripts/reinforcement_learning/sb3/play.py @@ -45,7 +45,6 @@ help="Use a slower SB3 wrapper but keep all the extra training info.", ) parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") - # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # parse the arguments diff --git a/source/isaaclab/isaaclab/utils/asset_resolver.py b/source/isaaclab/isaaclab/utils/asset_resolver.py index 506dad35dce..4b31012f808 100644 --- a/source/isaaclab/isaaclab/utils/asset_resolver.py +++ b/source/isaaclab/isaaclab/utils/asset_resolver.py @@ -380,4 +380,4 @@ def setup_offline_mode(): "patch_config_for_offline_mode", "install_path_hooks", "setup_offline_mode", -] \ No newline at end of file +] From 3f403e1559d42978593161059d2a9ccdd1a6023c Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 22:06:46 -0500 Subject: [PATCH 15/24] I have added my name to the CONTRIBUTORS.md --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 9fbfe7f1bf3..4bcc82be991 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -59,6 +59,7 @@ Guidelines for modifications: * Cathy Y. Li * Cheng-Rong Lai * Chenyu Yang +* Clayton Littlejohn * Connor Smith * CY (Chien-Ying) Chen * David Yang From 9f7b50b2dcd62577cdeedc1f71d7ec816eaeef0f Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Tue, 27 Jan 2026 22:36:14 -0500 Subject: [PATCH 16/24] linting and formatting --- scripts/offline_setup/README.md | 2 +- scripts/offline_setup/download_assets.py | 139 ++++++------- .../reinforcement_learning/rsl_rl/cli_args.py | 4 +- scripts/reinforcement_learning/rsl_rl/play.py | 2 +- .../reinforcement_learning/rsl_rl/train.py | 2 +- scripts/reinforcement_learning/sb3/play.py | 2 +- scripts/reinforcement_learning/sb3/train.py | 4 +- scripts/reinforcement_learning/skrl/play.py | 2 +- scripts/reinforcement_learning/skrl/train.py | 2 +- scripts/sim2sim_transfer/rsl_rl_transfer.py | 2 +- .../isaaclab/isaaclab/utils/asset_resolver.py | 194 +++++++++--------- 11 files changed, 174 insertions(+), 181 deletions(-) diff --git a/scripts/offline_setup/README.md b/scripts/offline_setup/README.md index 18c5f422a2a..ce4120e03c5 100644 --- a/scripts/offline_setup/README.md +++ b/scripts/offline_setup/README.md @@ -76,4 +76,4 @@ IsaacLab/ │ └── go2.usd └── H1/ └── h1.usd -``` \ No newline at end of file +``` diff --git a/scripts/offline_setup/download_assets.py b/scripts/offline_setup/download_assets.py index 0c3ecb532d3..747422b940a 100644 --- a/scripts/offline_setup/download_assets.py +++ b/scripts/offline_setup/download_assets.py @@ -13,10 +13,10 @@ Usage: # Download all assets ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories all - + # Download specific categories ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories Robots Props - + # Download specific robot subset ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories Robots --subset Unitree @@ -29,6 +29,7 @@ from pathlib import Path from tqdm import tqdm + from isaaclab.app import AppLauncher # Initialize Isaac Sim environment @@ -66,14 +67,14 @@ def format_size(bytes_size: int) -> str: """ Format bytes into human-readable size. - + Args: bytes_size: Size in bytes - + Returns: Human-readable size string (e.g., "1.5 GB") """ - for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + for unit in ["B", "KB", "MB", "GB", "TB"]: if bytes_size < 1024.0: return f"{bytes_size:.1f} {unit}" bytes_size /= 1024.0 @@ -83,10 +84,10 @@ def format_size(bytes_size: int) -> str: def get_local_directory_size(path: str) -> int: """ Calculate total size of a local directory. - + Args: path: Local directory path - + Returns: Total size in bytes """ @@ -103,24 +104,24 @@ def get_local_directory_size(path: str) -> int: def get_remote_directory_info(remote_path: str) -> tuple[int, int]: """ Get file count and total size of a remote Nucleus directory. - + Args: remote_path: Nucleus directory URL - + Returns: Tuple of (file_count, total_size_bytes) """ file_count = 0 total_size = 0 - + result, entries = omni.client.list(remote_path) if result != omni.client.Result.OK: return 0, 0 - + for entry in entries: is_dir = entry.flags & omni.client.ItemFlags.CAN_HAVE_CHILDREN remote_item = f"{remote_path}/{entry.relative_path}" - + if is_dir: # Recursively get info from subdirectory sub_count, sub_size = get_remote_directory_info(remote_item) @@ -132,7 +133,7 @@ def get_remote_directory_info(remote_path: str) -> tuple[int, int]: stat_result, stat_entry = omni.client.stat(remote_item) if stat_result == omni.client.Result.OK: total_size += stat_entry.size - + return file_count, total_size @@ -144,11 +145,11 @@ def ensure_directory(path: str) -> None: def download_file(remote_path: str, local_path: str) -> bool: """ Download a single file from Nucleus to local storage. - + Args: remote_path: Full Nucleus URL local_path: Local file system path - + Returns: True if successful, False otherwise """ @@ -164,7 +165,7 @@ def download_file(remote_path: str, local_path: str) -> bool: def download_directory_recursive(remote_path: str, local_base: str, progress_bar) -> None: """ Recursively download a directory from Nucleus to local storage. - + Args: remote_path: Nucleus directory URL local_base: Local directory to mirror structure @@ -173,12 +174,12 @@ def download_directory_recursive(remote_path: str, local_base: str, progress_bar result, entries = omni.client.list(remote_path) if result != omni.client.Result.OK: return - + for entry in entries: is_dir = entry.flags & omni.client.ItemFlags.CAN_HAVE_CHILDREN remote_item = f"{remote_path}/{entry.relative_path}" local_item = os.path.join(local_base, entry.relative_path) - + if is_dir: ensure_directory(local_item) download_directory_recursive(remote_item, local_item, progress_bar) @@ -191,7 +192,7 @@ def download_directory_recursive(remote_path: str, local_base: str, progress_bar def download_asset_category(category: str, subset: str = None) -> None: """ Download all assets in a specific category. - + Args: category: Asset category (e.g., "Robots", "Props") subset: Optional subset filter (e.g., specific robot name) @@ -199,56 +200,56 @@ def download_asset_category(category: str, subset: str = None) -> None: category_info = ASSET_CATEGORIES[category] base_path = category_info["base"] description = category_info["desc"] - + remote_dir = f"{base_path}/{category}" local_dir = os.path.join(OFFLINE_ASSETS_DIR, category) - + # Apply subset filter if specified if subset and category == "Robots": remote_dir = f"{remote_dir}/{subset}" local_dir = os.path.join(local_dir, subset) - - print(f"\n{'='*70}") + + print(f"\n{'=' * 70}") print(f"📦 {category}: {description}") - print(f"{'='*70}") + print(f"{'=' * 70}") print(f"Source: {remote_dir}") print(f"Target: {local_dir}") - + # Check if remote directory exists result, _ = omni.client.stat(remote_dir) if result != omni.client.Result.OK: print(f"⚠️ Directory not found: {remote_dir}") - print(f" This category may not be available or may be in a different location.") + print(" This category may not be available or may be in a different location.") return - + # Count files and get size print("📊 Analyzing remote directory...") file_count, total_size = get_remote_directory_info(remote_dir) - + if file_count == 0: print("✓ No files to download") return - + print(f" Files: {file_count:,}") print(f" Size: {format_size(total_size)}") - + # Download with progress bar ensure_directory(local_dir) with tqdm(total=file_count, unit="file", desc="Progress") as pbar: download_directory_recursive(remote_dir, local_dir, pbar) - + print(f"✓ Completed {category}") def verify_downloads() -> None: """Display summary of downloaded assets.""" - print("\n" + "="*70) + print("\n" + "=" * 70) print("📊 Downloaded Assets Summary") - print("="*70) - + print("=" * 70) + total_size = 0 total_files = 0 - + for category in ASSET_CATEGORIES.keys(): local_dir = os.path.join(OFFLINE_ASSETS_DIR, category) if os.path.exists(local_dir): @@ -259,7 +260,7 @@ def verify_downloads() -> None: print(f"✓ {category:<15} {files:>6,} files {format_size(size):>10}") else: print(f"✗ {category:<15} Not found") - + print("=" * 70) print(f"{'TOTAL':<15} {total_files:>6,} files {format_size(total_size):>10}") print("=" * 70) @@ -268,66 +269,56 @@ def verify_downloads() -> None: def main(): parser = argparse.ArgumentParser( description="Download Isaac Lab assets from Nucleus to local storage for offline training", - formatter_class=argparse.RawDescriptionHelpFormatter + formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--categories", nargs="+", choices=list(ASSET_CATEGORIES.keys()) + ["all"], default=["all"], - help="Asset categories to download (default: all)" - ) - parser.add_argument( - "--subset", - type=str, - help="Download only specific subset (e.g., 'ANYbotics' or 'Unitree' for robots)" + help="Asset categories to download (default: all)", ) parser.add_argument( - "--verify-only", - action="store_true", - help="Only verify existing downloads without downloading" + "--subset", type=str, help="Download only specific subset (e.g., 'ANYbotics' or 'Unitree' for robots)" ) - + parser.add_argument("--verify-only", action="store_true", help="Only verify existing downloads without downloading") + args = parser.parse_args() - + try: - print("\n" + "="*70) + print("\n" + "=" * 70) print("🚀 Isaac Lab Offline Asset Downloader") - print("="*70) + print("=" * 70) print(f"Isaac Sim Assets: {ISAAC_NUCLEUS_DIR}") print(f"Isaac Lab Assets: {ISAACLAB_NUCLEUS_DIR}") print(f"Local Target: {OFFLINE_ASSETS_DIR}") - print("="*70) - + print("=" * 70) + if args.verify_only: verify_downloads() return - + # Determine which categories to download - categories = ( - list(ASSET_CATEGORIES.keys()) - if "all" in args.categories - else args.categories - ) - + categories = list(ASSET_CATEGORIES.keys()) if "all" in args.categories else args.categories + print(f"\n📋 Selected Categories: {', '.join(categories)}") if args.subset: print(f"🔍 Subset Filter: {args.subset}") - + # Calculate total download size print("\n📊 Calculating download size...") total_files = 0 total_size = 0 - + for category in categories: category_info = ASSET_CATEGORIES[category] base_path = category_info["base"] remote_dir = f"{base_path}/{category}" - + # Apply subset filter if args.subset and category == "Robots": remote_dir = f"{remote_dir}/{args.subset}" - + # Check if directory exists result, _ = omni.client.stat(remote_dir) if result == omni.client.Result.OK: @@ -335,17 +326,17 @@ def main(): total_files += files total_size += size print(f" {category}: {files:,} files ({format_size(size)})") - - print("\n" + "="*70) + + print("\n" + "=" * 70) print(f"📦 Total Download: {total_files:,} files ({format_size(total_size)})") - print("="*70) - + print("=" * 70) + # Confirm before proceeding response = input("\nProceed with download? [y/N]: ") if response.lower() not in ["y", "yes"]: print("❌ Download cancelled") return - + # Download each category print("\n🔽 Starting download...") for category in categories: @@ -357,13 +348,13 @@ def main(): except Exception as e: print(f"\n❌ Error downloading {category}: {e}") continue - + # Show final summary verify_downloads() - - print("\n" + "="*70) + + print("\n" + "=" * 70) print("✅ Download Complete!") - print("="*70) + print("=" * 70) print(f"\nOffline assets are available in: {OFFLINE_ASSETS_DIR}") print("\n💡 Usage: Add --offline flag to your training commands") print("\nExample:") @@ -371,10 +362,10 @@ def main(): print(" --task Isaac-Velocity-Flat-Unitree-Go2-v0 \\") print(" --num_envs 128 \\") print(" --offline\n") - + finally: simulation_app.close() if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/scripts/reinforcement_learning/rsl_rl/cli_args.py b/scripts/reinforcement_learning/rsl_rl/cli_args.py index c3975190d08..78ee2d59be9 100644 --- a/scripts/reinforcement_learning/rsl_rl/cli_args.py +++ b/scripts/reinforcement_learning/rsl_rl/cli_args.py @@ -37,7 +37,9 @@ def add_rsl_rl_args(parser: argparse.ArgumentParser): arg_group.add_argument( "--log_project_name", type=str, default=None, help="Name of the logging project when using wandb or neptune." ) - arg_group.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") + arg_group.add_argument( + "--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)" + ) def parse_rsl_rl_cfg(task_name: str, args_cli: argparse.Namespace) -> RslRlBaseRunnerCfg: diff --git a/scripts/reinforcement_learning/rsl_rl/play.py b/scripts/reinforcement_learning/rsl_rl/play.py index 1d229626be8..dd1f8c2214b 100644 --- a/scripts/reinforcement_learning/rsl_rl/play.py +++ b/scripts/reinforcement_learning/rsl_rl/play.py @@ -67,7 +67,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode +from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index 79233aa01d2..2b208ae4858 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -91,7 +91,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode +from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml diff --git a/scripts/reinforcement_learning/sb3/play.py b/scripts/reinforcement_learning/sb3/play.py index d962e657840..d1548b878d5 100644 --- a/scripts/reinforcement_learning/sb3/play.py +++ b/scripts/reinforcement_learning/sb3/play.py @@ -78,7 +78,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode +from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.dict import print_dict from isaaclab_rl.sb3 import Sb3VecEnvWrapper, process_sb3_cfg diff --git a/scripts/reinforcement_learning/sb3/train.py b/scripts/reinforcement_learning/sb3/train.py index e7d632969d6..021176fcd8f 100644 --- a/scripts/reinforcement_learning/sb3/train.py +++ b/scripts/reinforcement_learning/sb3/train.py @@ -95,7 +95,7 @@ def cleanup_pbar(*args): ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode +from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml @@ -116,7 +116,7 @@ def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agen if args_cli.offline: setup_offline_mode() patch_config_for_offline_mode(env_cfg) - + # randomly sample a seed if seed = -1 if args_cli.seed == -1: args_cli.seed = random.randint(0, 10000) diff --git a/scripts/reinforcement_learning/skrl/play.py b/scripts/reinforcement_learning/skrl/play.py index f17e94f7133..65c6f1e5940 100644 --- a/scripts/reinforcement_learning/skrl/play.py +++ b/scripts/reinforcement_learning/skrl/play.py @@ -105,7 +105,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode +from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.dict import print_dict from isaaclab_rl.skrl import SkrlVecEnvWrapper diff --git a/scripts/reinforcement_learning/skrl/train.py b/scripts/reinforcement_learning/skrl/train.py index f41b2fbc2ac..10d1751a58d 100644 --- a/scripts/reinforcement_learning/skrl/train.py +++ b/scripts/reinforcement_learning/skrl/train.py @@ -106,7 +106,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode +from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml diff --git a/scripts/sim2sim_transfer/rsl_rl_transfer.py b/scripts/sim2sim_transfer/rsl_rl_transfer.py index 38acd3132b6..e9b00a17c64 100644 --- a/scripts/sim2sim_transfer/rsl_rl_transfer.py +++ b/scripts/sim2sim_transfer/rsl_rl_transfer.py @@ -72,7 +72,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import setup_offline_mode, patch_config_for_offline_mode +from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict diff --git a/source/isaaclab/isaaclab/utils/asset_resolver.py b/source/isaaclab/isaaclab/utils/asset_resolver.py index 4b31012f808..138ff56d7dd 100644 --- a/source/isaaclab/isaaclab/utils/asset_resolver.py +++ b/source/isaaclab/isaaclab/utils/asset_resolver.py @@ -18,13 +18,13 @@ Usage: from isaaclab.utils import setup_offline_mode, patch_config_for_offline_mode - + # Enable offline mode at start of script setup_offline_mode() - + # Patch environment config after loading patch_config_for_offline_mode(env_cfg) - + # All asset paths will now resolve to offline_assets/ """ @@ -38,136 +38,136 @@ class OfflineAssetResolver: """ Singleton class to manage offline asset path resolution. - + When enabled, this resolver intercepts asset paths pointing to Nucleus servers and redirects them to the local offline_assets directory while maintaining the same directory structure. """ - - _instance: Optional['OfflineAssetResolver'] = None + + _instance: Optional["OfflineAssetResolver"] = None _enabled: bool = False - _offline_assets_dir: Optional[str] = None - _isaac_nucleus_dir: Optional[str] = None - _isaaclab_nucleus_dir: Optional[str] = None - + _offline_assets_dir: str | None = None + _isaac_nucleus_dir: str | None = None + _isaaclab_nucleus_dir: str | None = None + def __new__(cls): if cls._instance is None: - cls._instance = super(OfflineAssetResolver, cls).__new__(cls) + cls._instance = super().__new__(cls) cls._instance._initialize() return cls._instance - + def _initialize(self): """Initialize the resolver with environment paths.""" # Get Isaac Lab root path - self.isaaclab_path = os.environ.get('ISAACLAB_PATH', os.getcwd()) - + self.isaaclab_path = os.environ.get("ISAACLAB_PATH", os.getcwd()) + # Set offline assets directory self._offline_assets_dir = os.path.join(self.isaaclab_path, "offline_assets") - + # Get Nucleus directories from settings settings = carb.settings.get_settings() nucleus_root = settings.get("/persistent/isaac/asset_root/default") - + if nucleus_root: self._isaac_nucleus_dir = f"{nucleus_root}/Isaac" self._isaaclab_nucleus_dir = f"{nucleus_root}/Isaac/IsaacLab" - - print(f"[OfflineAssetResolver] Initialized") + + print("[OfflineAssetResolver] Initialized") print(f" Offline assets dir: {self._offline_assets_dir}") if self._isaaclab_nucleus_dir: print(f" Nucleus base: {self._isaaclab_nucleus_dir}") - + def enable(self): """Enable offline asset resolution.""" self._enabled = True - print(f"[OfflineAssetResolver] Offline mode ENABLED") + print("[OfflineAssetResolver] Offline mode ENABLED") print(f" All assets will be loaded from: {self._offline_assets_dir}") - + # Verify offline assets directory exists if not os.path.exists(self._offline_assets_dir): - print(f"[OfflineAssetResolver] ⚠️ WARNING: Offline assets directory not found!") - print(f" Run: ./isaaclab.sh -p scripts/offline_setup/download_assets.py") - + print("[OfflineAssetResolver] ⚠️ WARNING: Offline assets directory not found!") + print(" Run: ./isaaclab.sh -p scripts/offline_setup/download_assets.py") + def disable(self): """Disable offline asset resolution.""" self._enabled = False - print(f"[OfflineAssetResolver] Offline mode DISABLED") - + print("[OfflineAssetResolver] Offline mode DISABLED") + def is_enabled(self) -> bool: """Check if offline mode is enabled.""" return self._enabled - + def resolve_path(self, asset_path: str) -> str: """ Resolve an asset path to either Nucleus or offline storage. - + This method handles multiple Nucleus URL formats including versioned URLs (e.g., .../Assets/Isaac/5.1/Isaac/...) and falls back to Nucleus if the local asset doesn't exist. - + Args: asset_path: Original asset path (may contain Nucleus URL) - + Returns: Resolved path (offline if enabled and exists, otherwise original) """ if not self._enabled or not isinstance(asset_path, str) or not asset_path: return asset_path - + # Try to extract the relative path from various Nucleus URL formats path_to_convert = self._extract_relative_path(asset_path) - + if path_to_convert: offline_path = os.path.join(self._offline_assets_dir, path_to_convert) - + # Return offline path if file exists, otherwise fall back to Nucleus if os.path.exists(offline_path): print(f"[OfflineAssetResolver] ✓ Using offline: {path_to_convert}") return offline_path else: print(f"[OfflineAssetResolver] ⚠️ Not found locally: {path_to_convert}") - print(f"[OfflineAssetResolver] Falling back to Nucleus") - + print("[OfflineAssetResolver] Falling back to Nucleus") + return asset_path - - def _extract_relative_path(self, asset_path: str) -> Optional[str]: + + def _extract_relative_path(self, asset_path: str) -> str | None: """ Extract relative path from various Nucleus URL formats. - + Handles: - Versioned URLs: .../Assets/Isaac/5.1/Isaac/IsaacLab/Robots/... - Versioned URLs: .../Assets/Isaac/5.1/Isaac/Props/... - Non-versioned: .../Isaac/IsaacLab/Robots/... - Non-versioned: .../Isaac/Props/... - + Args: asset_path: Full Nucleus URL - + Returns: Relative path (e.g., "Robots/Unitree/Go2/go2.usd") or None if not a Nucleus path """ # Pattern 1: Isaac Lab assets with version # e.g., .../Assets/Isaac/5.1/Isaac/IsaacLab/Robots/... - match = re.search(r'/Assets/Isaac/[\d.]+/Isaac/IsaacLab/(.+)$', asset_path) + match = re.search(r"/Assets/Isaac/[\d.]+/Isaac/IsaacLab/(.+)$", asset_path) if match: return match.group(1) - + # Pattern 2: General Isaac assets with version # e.g., .../Assets/Isaac/5.1/Isaac/Props/... - match = re.search(r'/Assets/Isaac/[\d.]+/Isaac/(?!IsaacLab)(.+)$', asset_path) + match = re.search(r"/Assets/Isaac/[\d.]+/Isaac/(?!IsaacLab)(.+)$", asset_path) if match: return match.group(1) - + # Pattern 3: Isaac Lab assets without version (older format) if self._isaaclab_nucleus_dir and asset_path.startswith(self._isaaclab_nucleus_dir): - return asset_path[len(self._isaaclab_nucleus_dir):].lstrip("/") - + return asset_path[len(self._isaaclab_nucleus_dir) :].lstrip("/") + # Pattern 4: General Isaac assets without version (older format) if self._isaac_nucleus_dir and asset_path.startswith(self._isaac_nucleus_dir): - return asset_path[len(self._isaac_nucleus_dir):].lstrip("/") - + return asset_path[len(self._isaac_nucleus_dir) :].lstrip("/") + return None - + def get_offline_assets_dir(self) -> str: """Get the offline assets directory path.""" return self._offline_assets_dir @@ -196,10 +196,10 @@ def is_offline_mode_enabled() -> bool: def resolve_asset_path(asset_path: str) -> str: """ Resolve an asset path, redirecting to offline storage if enabled. - + Args: asset_path: Original asset path (may contain Nucleus URL) - + Returns: Resolved path (offline if mode enabled and file exists, otherwise original) """ @@ -214,92 +214,92 @@ def get_offline_assets_dir() -> str: def patch_config_for_offline_mode(env_cfg): """ Patch environment configuration to use offline assets. - + This function walks through the environment config and patches known asset paths to use local storage when offline mode is enabled. It handles: - Robot USD paths - Terrain/ground plane paths - Sky light textures - Visualization markers - + Args: env_cfg: Environment configuration object (typically ManagerBasedRLEnvCfg) """ if not is_offline_mode_enabled(): return - + print("[OfflineAssetResolver] Patching configuration...") patches_made = 0 - + # Patch robot USD path - if hasattr(env_cfg, 'scene') and hasattr(env_cfg.scene, 'robot'): - if hasattr(env_cfg.scene.robot, 'spawn') and hasattr(env_cfg.scene.robot.spawn, 'usd_path'): + if hasattr(env_cfg, "scene") and hasattr(env_cfg.scene, "robot"): + if hasattr(env_cfg.scene.robot, "spawn") and hasattr(env_cfg.scene.robot.spawn, "usd_path"): original = env_cfg.scene.robot.spawn.usd_path resolved = resolve_asset_path(original) if resolved != original: env_cfg.scene.robot.spawn.usd_path = resolved patches_made += 1 - print(f"[OfflineAssetResolver] ✓ Patched robot USD path") - + print("[OfflineAssetResolver] ✓ Patched robot USD path") + # Patch terrain USD path (if present) - if hasattr(env_cfg, 'scene') and hasattr(env_cfg.scene, 'terrain'): - if hasattr(env_cfg.scene.terrain, 'usd_path'): + if hasattr(env_cfg, "scene") and hasattr(env_cfg.scene, "terrain"): + if hasattr(env_cfg.scene.terrain, "usd_path"): original = env_cfg.scene.terrain.usd_path resolved = resolve_asset_path(original) if resolved != original: env_cfg.scene.terrain.usd_path = resolved patches_made += 1 - print(f"[OfflineAssetResolver] ✓ Patched terrain USD path") - + print("[OfflineAssetResolver] ✓ Patched terrain USD path") + # Patch sky light texture - if hasattr(env_cfg, 'scene') and hasattr(env_cfg.scene, 'sky_light'): - if hasattr(env_cfg.scene.sky_light, 'spawn') and hasattr(env_cfg.scene.sky_light.spawn, 'texture_file'): + if hasattr(env_cfg, "scene") and hasattr(env_cfg.scene, "sky_light"): + if hasattr(env_cfg.scene.sky_light, "spawn") and hasattr(env_cfg.scene.sky_light.spawn, "texture_file"): if env_cfg.scene.sky_light.spawn.texture_file: original = env_cfg.scene.sky_light.spawn.texture_file resolved = resolve_asset_path(original) if resolved != original: env_cfg.scene.sky_light.spawn.texture_file = resolved patches_made += 1 - print(f"[OfflineAssetResolver] ✓ Patched sky light texture") - + print("[OfflineAssetResolver] ✓ Patched sky light texture") + # Patch visualization markers - if hasattr(env_cfg, 'commands'): + if hasattr(env_cfg, "commands"): for command_name in dir(env_cfg.commands): - if command_name.startswith('_'): + if command_name.startswith("_"): continue - + command = getattr(env_cfg.commands, command_name, None) if not command: continue - + # Patch both current and goal velocity visualizers - for viz_name in ['current_vel_visualizer_cfg', 'goal_vel_visualizer_cfg']: + for viz_name in ["current_vel_visualizer_cfg", "goal_vel_visualizer_cfg"]: if not hasattr(command, viz_name): continue - + visualizer = getattr(command, viz_name) - if not hasattr(visualizer, 'markers') or not isinstance(visualizer.markers, dict): + if not hasattr(visualizer, "markers") or not isinstance(visualizer.markers, dict): continue - + for marker_name, marker_cfg in visualizer.markers.items(): - if hasattr(marker_cfg, 'usd_path'): + if hasattr(marker_cfg, "usd_path"): original = marker_cfg.usd_path resolved = resolve_asset_path(original) if resolved != original: marker_cfg.usd_path = resolved patches_made += 1 print(f"[OfflineAssetResolver] ✓ Patched {marker_name} in {viz_name}") - + if patches_made > 0: print(f"[OfflineAssetResolver] Patched {patches_made} asset paths") else: - print(f"[OfflineAssetResolver] No paths needed patching (already correct)") + print("[OfflineAssetResolver] No paths needed patching (already correct)") def install_path_hooks(): """ Install hooks into Isaac Lab's spawn configs for automatic path resolution. - + This function monkey-patches Isaac Lab's UsdFileCfg, GroundPlaneCfg, and PreviewSurfaceCfg classes to automatically resolve asset paths when they're instantiated. This provides transparent offline support without modifying @@ -307,47 +307,47 @@ def install_path_hooks(): """ try: import isaaclab.sim as sim_utils - + # Patch UsdFileCfg for general USD file spawning - if hasattr(sim_utils, 'UsdFileCfg'): + if hasattr(sim_utils, "UsdFileCfg"): original_usd_init = sim_utils.UsdFileCfg.__init__ - + def patched_usd_init(self, *args, **kwargs): original_usd_init(self, *args, **kwargs) - if hasattr(self, 'usd_path') and is_offline_mode_enabled(): + if hasattr(self, "usd_path") and is_offline_mode_enabled(): self.usd_path = resolve_asset_path(self.usd_path) - + sim_utils.UsdFileCfg.__init__ = patched_usd_init print("[OfflineAssetResolver] Installed UsdFileCfg path hook") - + # Patch GroundPlaneCfg for terrain/ground plane spawning - if hasattr(sim_utils, 'GroundPlaneCfg'): + if hasattr(sim_utils, "GroundPlaneCfg"): original_ground_init = sim_utils.GroundPlaneCfg.__init__ - + def patched_ground_init(self, *args, **kwargs): original_ground_init(self, *args, **kwargs) - if hasattr(self, 'usd_path') and is_offline_mode_enabled(): + if hasattr(self, "usd_path") and is_offline_mode_enabled(): original_path = self.usd_path self.usd_path = resolve_asset_path(self.usd_path) if self.usd_path != original_path: print(f"[OfflineAssetResolver] ✓ Resolved ground plane: {os.path.basename(self.usd_path)}") - + sim_utils.GroundPlaneCfg.__init__ = patched_ground_init print("[OfflineAssetResolver] Installed GroundPlaneCfg path hook") - + # Patch PreviewSurfaceCfg for texture file resolution - if hasattr(sim_utils, 'PreviewSurfaceCfg'): + if hasattr(sim_utils, "PreviewSurfaceCfg"): original_surface_init = sim_utils.PreviewSurfaceCfg.__init__ - + def patched_surface_init(self, *args, **kwargs): original_surface_init(self, *args, **kwargs) - if hasattr(self, 'texture_file') and is_offline_mode_enabled(): + if hasattr(self, "texture_file") and is_offline_mode_enabled(): if self.texture_file: self.texture_file = resolve_asset_path(self.texture_file) - + sim_utils.PreviewSurfaceCfg.__init__ = patched_surface_init print("[OfflineAssetResolver] Installed PreviewSurfaceCfg path hook") - + except ImportError: print("[OfflineAssetResolver] Could not install path hooks - isaaclab.sim not available") @@ -355,10 +355,10 @@ def patched_surface_init(self, *args, **kwargs): def setup_offline_mode(): """ Set up offline mode with all hooks and path resolution. - + This is the main entry point for enabling offline training. Call this function at the start of your training script when the --offline flag is set. - + Example: if args_cli.offline: from isaaclab.utils import setup_offline_mode, patch_config_for_offline_mode From ed00a5c920111505814ca18d3cefa71ce0f5e871 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Wed, 28 Jan 2026 00:18:14 -0500 Subject: [PATCH 17/24] import directly from asset_resolver --- scripts/offline_setup/README.md | 158 +++++++++++++++++- .../reinforcement_learning/rl_games/play.py | 7 + .../reinforcement_learning/rl_games/train.py | 7 + scripts/reinforcement_learning/rsl_rl/play.py | 2 +- .../reinforcement_learning/rsl_rl/train.py | 2 +- scripts/reinforcement_learning/sb3/play.py | 2 +- scripts/reinforcement_learning/sb3/train.py | 2 +- scripts/reinforcement_learning/skrl/play.py | 2 +- scripts/reinforcement_learning/skrl/train.py | 2 +- scripts/sim2sim_transfer/rsl_rl_transfer.py | 2 +- 10 files changed, 178 insertions(+), 8 deletions(-) diff --git a/scripts/offline_setup/README.md b/scripts/offline_setup/README.md index ce4120e03c5..901302d5c86 100644 --- a/scripts/offline_setup/README.md +++ b/scripts/offline_setup/README.md @@ -63,7 +63,7 @@ IsaacLab/ │ └── Textures/ │ └── Skies/ ├── Mimic/ - ├── Plocies/ + ├── Policies/ ├── Props/ # Markers and objects │ └── UIElements/ │ └── arrow_x.usd @@ -77,3 +77,159 @@ IsaacLab/ └── H1/ └── h1.usd ``` + + + + + + + + + +### Proposal + +Add offline training support to Isaac Lab that enables training environments without internet connectivity. The feature should automatically redirect asset paths from Nucleus/S3 servers to local storage while maintaining the same directory structure, requiring zero configuration changes to existing environments. + +Core capabilities: +- One-time asset download script that mirrors Nucleus directory structure locally +- Automatic path resolution from Nucleus URLs to local filesystem +- Single `--offline` flag for all training scripts +- Graceful fallback to Nucleus if local asset is missing +- Works with any robot and environment without hardcoded paths + +### Motivation + +**Current Problem:** +Training Isaac Lab environments requires constant internet connectivity to load assets from Nucleus/S3 servers. This creates several critical issues: + +1. **Airgapped Environments**: Impossible to train in secure/classified facilities that prohibit internet access +2. **Network Reliability**: Training fails or becomes extremely slow on unstable networks +3. **Reproducibility**: Repeated downloads slow iteration and depend on external server availability +4. **Development Workflow**: Researchers waste time waiting for assets during rapid prototyping +5. **DGX Spark is Rooted**: I should be able to take my Spark anywhere regardless of internet connectivity and live out Isaac Lab and Isaac Sim to it's fullest + +**User Story:** +"I'm always frustrated when I need to train in an airgapped lab environment or on an unstable network connection. I have to create custom configs with hardcoded local paths for each robot, which results in unmaintainable code duplication and can break when I switch robots or update Isaac Lab. I would like to take Isaac Lab on the go and quickly demo anything in an independent localized ecosystem without nit-picking configurations and dealing with asset management." + +**Core User Needs:** +- Train without internet connectivity +- Avoid hardcoded paths in environment configs +- Work seamlessly with any robot +- Maintain same configs for both online and offline modes + +### Alternatives + +**Alternative 1: Hardcoded Local Paths** (Current workaround) +```python +# Separate config file per robot +UNITREE_GO2_CFG = ArticulationCfg( + spawn=sim_utils.UsdFileCfg( + usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Robots/Unitree/Go2/go2.usd" +``` +**Problems:** +- ❌ Requires separate config for each robot +- ❌ ~30 lines of boilerplate per robot +- ❌ Breaks when Isaac Lab updates +- ❌ Not maintainable + +**Alternative 2: Environment Variables** +```bash +export ISAAC_ASSETS_DIR="/local/path" +``` +**Problems:** +- ❌ Doesn't work with S3 URLs +- ❌ Requires Isaac Sim core changes +- ❌ Can't have fallback to Nucleus +- ❌ Not transparent to users + +**Alternative 3: Manual Asset Copying** +Copy assets manually and update configs. +**Problems:** +- ❌ Error-prone manual process +- ❌ No directory structure guidelines +- ❌ Still requires config changes + +**Proposed Solution Benefits:** +- ✅ Zero code or config changes +- ✅ Works with all robots and environments automatically +- ✅ Single flag: `--offline` for all training environments (`rl_games`, `rsl_rl`, `sb3`, `skrl`, and `sim2transfer`) +- ✅ Automatic fallback +- ✅ Easy `offline_asset` plug and play following Nucleus structure +- ✅ 90% code reduction + +### Build Info + +- Isaac Lab Version: main branch (as of January 2026) +- Isaac Sim Version: 5.1.0 + +### Additional Context + +**Use Cases:** +1. **Defense/Aerospace**: Training in classified airgapped facilities +2. **Remote Locations**: Field robotics research with limited connectivity +3. **Development**: Rapid iteration without network delays and firewall interruptions +4. **CI/CD**: Reproducible builds without external dependencies +5. **Workshops/Tutorials**: Teaching without relying on conference WiFi (i.e. localized everything on DGX Spark) + +**Technical Approach:** +The implementation uses a dual-layer strategy: +- **Monkey patching**: Intercepts asset loads at spawn config instantiation (90% coverage) +- **Config patching**: Explicitly modifies pre-loaded configs (10% coverage) + +This ensures ~100% asset coverage without modifying environment configs. + +**Expected Directory Structure:** +``` +IsaacLab/ +├── source/isaaclab/isaaclab/utils/ +│ └── asset_resolver.py # Core resolver +├── scripts/setup/ +│ └── download_assets.py # Asset downloader +└── offline_assets/ + ├── ActuatorNets/... + ├── Controllers/... + ├── Environments/ # Ground planes + │ └── Grid/ + │ └── default_environment.usd + ├── Materials/ # Textures and HDRs + │ └── Textures/ + │ └── Skies/ + ├── Mimic/... + ├── Plocies/... + ├── Props/ # Markers and objects + │ └── UIElements/ + │ └── arrow_x.usd + └── Robots/ # Robot USD files + ├── BostonDynamics/ + │ └── spot/ + │ └── spot.usd + └── Unitree/ + ├── Go2/ + │ └── go2.usd + └── H1/ + └── h1.usd +``` + +Dynamically pulls and mirrors Nucleus structure for seamless path resolution. + +### Checklist + +- [x] I have checked that there is no similar issue in the repo (**required**) + +### Acceptance Criteria + +- [x] Asset download script that mirrors Nucleus directory structure to local storage (offline_assets) +- [x] Automatic path resolver that redirects Nucleus URLs to local filesystem +- [x] Optional `--offline` flag added to all training scripts (RSL-RL, SB3, SKRL, RL Games) +- [x] Monkey patching of Isaac Lab spawn configs (UsdFileCfg, GroundPlaneCfg, PreviewSurfaceCfg) +- [x] Config patching for pre-loaded environment configs +- [x] Graceful fallback to Nucleus for missing assets +- [x] Support for versioned Nucleus URLs (e.g., `/Assets/Isaac/5.1/...`) +- [x] Documentation including setup guide, usage examples, and troubleshooting +- [x] Works with any robot without hardcoded paths +- [x] Zero breaking changes - existing code continues to work +- [x] Manual testing completed across multiple robots (Go2, H1, ANYmal) +- [x] Verification in complete offline mode (no internet connectivity) + +**Definition of Done:** +A user can download all, or select assets by running `./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories all`, then train any robot completely offline by simply adding `--offline` to their training command, with no code or config changes required. diff --git a/scripts/reinforcement_learning/rl_games/play.py b/scripts/reinforcement_learning/rl_games/play.py index ee2dbcdbb14..7482a09179c 100644 --- a/scripts/reinforcement_learning/rl_games/play.py +++ b/scripts/reinforcement_learning/rl_games/play.py @@ -37,6 +37,7 @@ help="When no checkpoint provided, use the last saved model. Otherwise use the best saved model.", ) parser.add_argument("--real-time", action="store_true", default=False, help="Run in real-time, if possible.") +parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # parse the arguments @@ -72,6 +73,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) +from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict @@ -88,6 +90,11 @@ @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: dict): """Play with RL-Games agent.""" + # Handle config to use offline_assets + if args_cli.offline: + setup_offline_mode() + patch_config_for_offline_mode(env_cfg) + # grab task name for checkpoint path task_name = args_cli.task.split(":")[-1] train_task_name = task_name.replace("-Play", "") diff --git a/scripts/reinforcement_learning/rl_games/train.py b/scripts/reinforcement_learning/rl_games/train.py index 5b85ba5b429..21ef3b1286a 100644 --- a/scripts/reinforcement_learning/rl_games/train.py +++ b/scripts/reinforcement_learning/rl_games/train.py @@ -45,6 +45,7 @@ parser.add_argument( "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) +parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # parse the arguments @@ -81,6 +82,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) +from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml @@ -99,6 +101,11 @@ @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: dict): """Train with RL-Games agent.""" + # Handle config to use offline_assets + if args_cli.offline: + setup_offline_mode() + patch_config_for_offline_mode(env_cfg) + # override configurations with non-hydra CLI arguments env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device diff --git a/scripts/reinforcement_learning/rsl_rl/play.py b/scripts/reinforcement_learning/rsl_rl/play.py index dd1f8c2214b..a741e08e8b7 100644 --- a/scripts/reinforcement_learning/rsl_rl/play.py +++ b/scripts/reinforcement_learning/rsl_rl/play.py @@ -67,7 +67,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode +from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index 2b208ae4858..e8577fb4304 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -91,7 +91,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode +from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml diff --git a/scripts/reinforcement_learning/sb3/play.py b/scripts/reinforcement_learning/sb3/play.py index d1548b878d5..28e99174369 100644 --- a/scripts/reinforcement_learning/sb3/play.py +++ b/scripts/reinforcement_learning/sb3/play.py @@ -78,7 +78,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode +from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.dict import print_dict from isaaclab_rl.sb3 import Sb3VecEnvWrapper, process_sb3_cfg diff --git a/scripts/reinforcement_learning/sb3/train.py b/scripts/reinforcement_learning/sb3/train.py index 021176fcd8f..947d5e40693 100644 --- a/scripts/reinforcement_learning/sb3/train.py +++ b/scripts/reinforcement_learning/sb3/train.py @@ -95,7 +95,7 @@ def cleanup_pbar(*args): ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode +from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml diff --git a/scripts/reinforcement_learning/skrl/play.py b/scripts/reinforcement_learning/skrl/play.py index 65c6f1e5940..221b96bc0fd 100644 --- a/scripts/reinforcement_learning/skrl/play.py +++ b/scripts/reinforcement_learning/skrl/play.py @@ -105,7 +105,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode +from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.dict import print_dict from isaaclab_rl.skrl import SkrlVecEnvWrapper diff --git a/scripts/reinforcement_learning/skrl/train.py b/scripts/reinforcement_learning/skrl/train.py index 10d1751a58d..a307df25015 100644 --- a/scripts/reinforcement_learning/skrl/train.py +++ b/scripts/reinforcement_learning/skrl/train.py @@ -106,7 +106,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode +from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml diff --git a/scripts/sim2sim_transfer/rsl_rl_transfer.py b/scripts/sim2sim_transfer/rsl_rl_transfer.py index e9b00a17c64..8a77a65b18e 100644 --- a/scripts/sim2sim_transfer/rsl_rl_transfer.py +++ b/scripts/sim2sim_transfer/rsl_rl_transfer.py @@ -72,7 +72,7 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils.asset_resolver import patch_config_for_offline_mode, setup_offline_mode +from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict From 1adaa3339ffee17fa7eac87544bba2b3d9a341a0 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Wed, 28 Jan 2026 00:20:50 -0500 Subject: [PATCH 18/24] Optional[str] for better compatibility --- source/isaaclab/isaaclab/utils/asset_resolver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/isaaclab/isaaclab/utils/asset_resolver.py b/source/isaaclab/isaaclab/utils/asset_resolver.py index 138ff56d7dd..e34cacd048d 100644 --- a/source/isaaclab/isaaclab/utils/asset_resolver.py +++ b/source/isaaclab/isaaclab/utils/asset_resolver.py @@ -130,7 +130,7 @@ def resolve_path(self, asset_path: str) -> str: return asset_path - def _extract_relative_path(self, asset_path: str) -> str | None: + def _extract_relative_path(self, asset_path: str) -> Optional[str]: """ Extract relative path from various Nucleus URL formats. From e87260080f141047adf613893460d230d097785c Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Wed, 28 Jan 2026 00:25:45 -0500 Subject: [PATCH 19/24] forgot some Optional[str] --- source/isaaclab/isaaclab/utils/asset_resolver.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/source/isaaclab/isaaclab/utils/asset_resolver.py b/source/isaaclab/isaaclab/utils/asset_resolver.py index e34cacd048d..b9146003d26 100644 --- a/source/isaaclab/isaaclab/utils/asset_resolver.py +++ b/source/isaaclab/isaaclab/utils/asset_resolver.py @@ -46,9 +46,9 @@ class OfflineAssetResolver: _instance: Optional["OfflineAssetResolver"] = None _enabled: bool = False - _offline_assets_dir: str | None = None - _isaac_nucleus_dir: str | None = None - _isaaclab_nucleus_dir: str | None = None + _offline_assets_dir: Optional[str] = None + _isaac_nucleus_dir: Optional[str] = None + _isaaclab_nucleus_dir: Optional[str] = None def __new__(cls): if cls._instance is None: From 09b5bb45a2b570919e0f520dd381f86125c35595 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Wed, 28 Jan 2026 00:40:37 -0500 Subject: [PATCH 20/24] Keep the str | None syntax --- source/isaaclab/isaaclab/utils/asset_resolver.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/source/isaaclab/isaaclab/utils/asset_resolver.py b/source/isaaclab/isaaclab/utils/asset_resolver.py index b9146003d26..138ff56d7dd 100644 --- a/source/isaaclab/isaaclab/utils/asset_resolver.py +++ b/source/isaaclab/isaaclab/utils/asset_resolver.py @@ -46,9 +46,9 @@ class OfflineAssetResolver: _instance: Optional["OfflineAssetResolver"] = None _enabled: bool = False - _offline_assets_dir: Optional[str] = None - _isaac_nucleus_dir: Optional[str] = None - _isaaclab_nucleus_dir: Optional[str] = None + _offline_assets_dir: str | None = None + _isaac_nucleus_dir: str | None = None + _isaaclab_nucleus_dir: str | None = None def __new__(cls): if cls._instance is None: @@ -130,7 +130,7 @@ def resolve_path(self, asset_path: str) -> str: return asset_path - def _extract_relative_path(self, asset_path: str) -> Optional[str]: + def _extract_relative_path(self, asset_path: str) -> str | None: """ Extract relative path from various Nucleus URL formats. From f7253d3ca979c97d5d6b6fcb07fc0cf489b01766 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Wed, 28 Jan 2026 14:57:29 -0500 Subject: [PATCH 21/24] Added global offline to AppLauncher, propogates to all --- scripts/offline_setup/README.md | 162 +---- .../reinforcement_learning/rl_games/play.py | 7 - .../reinforcement_learning/rl_games/train.py | 7 - .../reinforcement_learning/rsl_rl/cli_args.py | 3 - scripts/reinforcement_learning/rsl_rl/play.py | 6 - .../reinforcement_learning/rsl_rl/train.py | 6 - scripts/reinforcement_learning/sb3/play.py | 7 - scripts/reinforcement_learning/sb3/train.py | 7 - scripts/reinforcement_learning/skrl/play.py | 7 - scripts/reinforcement_learning/skrl/train.py | 7 - scripts/sim2sim_transfer/rsl_rl_transfer.py | 6 - source/isaaclab/isaaclab/app/app_launcher.py | 63 ++ .../isaaclab/isaaclab/utils/asset_resolver.py | 557 ++++++++++++------ .../isaacsim/check_rep_texture_randomizer.py | 2 +- 14 files changed, 442 insertions(+), 405 deletions(-) diff --git a/scripts/offline_setup/README.md b/scripts/offline_setup/README.md index 901302d5c86..9bb3f31c79c 100644 --- a/scripts/offline_setup/README.md +++ b/scripts/offline_setup/README.md @@ -42,6 +42,12 @@ --video_length 1000 \ --offline ``` +### 3. Run various demos and tutorials with `--offline` flag + +``` +./isaaclab.sh -p scripts/tutorials/01_assets/run_deformable_object.py --offline +``` + #### _Note: For offline training, assets that cannot be found in `offline_assets` will attempted to be fetched from the [Nucleus Server](https://docs.omniverse.nvidia.com/nucleus/latest/index.html)._ ## 📁 Asset Layout @@ -77,159 +83,3 @@ IsaacLab/ └── H1/ └── h1.usd ``` - - - - - - - - - -### Proposal - -Add offline training support to Isaac Lab that enables training environments without internet connectivity. The feature should automatically redirect asset paths from Nucleus/S3 servers to local storage while maintaining the same directory structure, requiring zero configuration changes to existing environments. - -Core capabilities: -- One-time asset download script that mirrors Nucleus directory structure locally -- Automatic path resolution from Nucleus URLs to local filesystem -- Single `--offline` flag for all training scripts -- Graceful fallback to Nucleus if local asset is missing -- Works with any robot and environment without hardcoded paths - -### Motivation - -**Current Problem:** -Training Isaac Lab environments requires constant internet connectivity to load assets from Nucleus/S3 servers. This creates several critical issues: - -1. **Airgapped Environments**: Impossible to train in secure/classified facilities that prohibit internet access -2. **Network Reliability**: Training fails or becomes extremely slow on unstable networks -3. **Reproducibility**: Repeated downloads slow iteration and depend on external server availability -4. **Development Workflow**: Researchers waste time waiting for assets during rapid prototyping -5. **DGX Spark is Rooted**: I should be able to take my Spark anywhere regardless of internet connectivity and live out Isaac Lab and Isaac Sim to it's fullest - -**User Story:** -"I'm always frustrated when I need to train in an airgapped lab environment or on an unstable network connection. I have to create custom configs with hardcoded local paths for each robot, which results in unmaintainable code duplication and can break when I switch robots or update Isaac Lab. I would like to take Isaac Lab on the go and quickly demo anything in an independent localized ecosystem without nit-picking configurations and dealing with asset management." - -**Core User Needs:** -- Train without internet connectivity -- Avoid hardcoded paths in environment configs -- Work seamlessly with any robot -- Maintain same configs for both online and offline modes - -### Alternatives - -**Alternative 1: Hardcoded Local Paths** (Current workaround) -```python -# Separate config file per robot -UNITREE_GO2_CFG = ArticulationCfg( - spawn=sim_utils.UsdFileCfg( - usd_path=f"{ISAACLAB_NUCLEUS_DIR}/Robots/Unitree/Go2/go2.usd" -``` -**Problems:** -- ❌ Requires separate config for each robot -- ❌ ~30 lines of boilerplate per robot -- ❌ Breaks when Isaac Lab updates -- ❌ Not maintainable - -**Alternative 2: Environment Variables** -```bash -export ISAAC_ASSETS_DIR="/local/path" -``` -**Problems:** -- ❌ Doesn't work with S3 URLs -- ❌ Requires Isaac Sim core changes -- ❌ Can't have fallback to Nucleus -- ❌ Not transparent to users - -**Alternative 3: Manual Asset Copying** -Copy assets manually and update configs. -**Problems:** -- ❌ Error-prone manual process -- ❌ No directory structure guidelines -- ❌ Still requires config changes - -**Proposed Solution Benefits:** -- ✅ Zero code or config changes -- ✅ Works with all robots and environments automatically -- ✅ Single flag: `--offline` for all training environments (`rl_games`, `rsl_rl`, `sb3`, `skrl`, and `sim2transfer`) -- ✅ Automatic fallback -- ✅ Easy `offline_asset` plug and play following Nucleus structure -- ✅ 90% code reduction - -### Build Info - -- Isaac Lab Version: main branch (as of January 2026) -- Isaac Sim Version: 5.1.0 - -### Additional Context - -**Use Cases:** -1. **Defense/Aerospace**: Training in classified airgapped facilities -2. **Remote Locations**: Field robotics research with limited connectivity -3. **Development**: Rapid iteration without network delays and firewall interruptions -4. **CI/CD**: Reproducible builds without external dependencies -5. **Workshops/Tutorials**: Teaching without relying on conference WiFi (i.e. localized everything on DGX Spark) - -**Technical Approach:** -The implementation uses a dual-layer strategy: -- **Monkey patching**: Intercepts asset loads at spawn config instantiation (90% coverage) -- **Config patching**: Explicitly modifies pre-loaded configs (10% coverage) - -This ensures ~100% asset coverage without modifying environment configs. - -**Expected Directory Structure:** -``` -IsaacLab/ -├── source/isaaclab/isaaclab/utils/ -│ └── asset_resolver.py # Core resolver -├── scripts/setup/ -│ └── download_assets.py # Asset downloader -└── offline_assets/ - ├── ActuatorNets/... - ├── Controllers/... - ├── Environments/ # Ground planes - │ └── Grid/ - │ └── default_environment.usd - ├── Materials/ # Textures and HDRs - │ └── Textures/ - │ └── Skies/ - ├── Mimic/... - ├── Plocies/... - ├── Props/ # Markers and objects - │ └── UIElements/ - │ └── arrow_x.usd - └── Robots/ # Robot USD files - ├── BostonDynamics/ - │ └── spot/ - │ └── spot.usd - └── Unitree/ - ├── Go2/ - │ └── go2.usd - └── H1/ - └── h1.usd -``` - -Dynamically pulls and mirrors Nucleus structure for seamless path resolution. - -### Checklist - -- [x] I have checked that there is no similar issue in the repo (**required**) - -### Acceptance Criteria - -- [x] Asset download script that mirrors Nucleus directory structure to local storage (offline_assets) -- [x] Automatic path resolver that redirects Nucleus URLs to local filesystem -- [x] Optional `--offline` flag added to all training scripts (RSL-RL, SB3, SKRL, RL Games) -- [x] Monkey patching of Isaac Lab spawn configs (UsdFileCfg, GroundPlaneCfg, PreviewSurfaceCfg) -- [x] Config patching for pre-loaded environment configs -- [x] Graceful fallback to Nucleus for missing assets -- [x] Support for versioned Nucleus URLs (e.g., `/Assets/Isaac/5.1/...`) -- [x] Documentation including setup guide, usage examples, and troubleshooting -- [x] Works with any robot without hardcoded paths -- [x] Zero breaking changes - existing code continues to work -- [x] Manual testing completed across multiple robots (Go2, H1, ANYmal) -- [x] Verification in complete offline mode (no internet connectivity) - -**Definition of Done:** -A user can download all, or select assets by running `./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories all`, then train any robot completely offline by simply adding `--offline` to their training command, with no code or config changes required. diff --git a/scripts/reinforcement_learning/rl_games/play.py b/scripts/reinforcement_learning/rl_games/play.py index 7482a09179c..ee2dbcdbb14 100644 --- a/scripts/reinforcement_learning/rl_games/play.py +++ b/scripts/reinforcement_learning/rl_games/play.py @@ -37,7 +37,6 @@ help="When no checkpoint provided, use the last saved model. Otherwise use the best saved model.", ) parser.add_argument("--real-time", action="store_true", default=False, help="Run in real-time, if possible.") -parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # parse the arguments @@ -73,7 +72,6 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict @@ -90,11 +88,6 @@ @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: dict): """Play with RL-Games agent.""" - # Handle config to use offline_assets - if args_cli.offline: - setup_offline_mode() - patch_config_for_offline_mode(env_cfg) - # grab task name for checkpoint path task_name = args_cli.task.split(":")[-1] train_task_name = task_name.replace("-Play", "") diff --git a/scripts/reinforcement_learning/rl_games/train.py b/scripts/reinforcement_learning/rl_games/train.py index 21ef3b1286a..5b85ba5b429 100644 --- a/scripts/reinforcement_learning/rl_games/train.py +++ b/scripts/reinforcement_learning/rl_games/train.py @@ -45,7 +45,6 @@ parser.add_argument( "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) -parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # parse the arguments @@ -82,7 +81,6 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml @@ -101,11 +99,6 @@ @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: dict): """Train with RL-Games agent.""" - # Handle config to use offline_assets - if args_cli.offline: - setup_offline_mode() - patch_config_for_offline_mode(env_cfg) - # override configurations with non-hydra CLI arguments env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device diff --git a/scripts/reinforcement_learning/rsl_rl/cli_args.py b/scripts/reinforcement_learning/rsl_rl/cli_args.py index 78ee2d59be9..51cf868b5cd 100644 --- a/scripts/reinforcement_learning/rsl_rl/cli_args.py +++ b/scripts/reinforcement_learning/rsl_rl/cli_args.py @@ -37,9 +37,6 @@ def add_rsl_rl_args(parser: argparse.ArgumentParser): arg_group.add_argument( "--log_project_name", type=str, default=None, help="Name of the logging project when using wandb or neptune." ) - arg_group.add_argument( - "--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)" - ) def parse_rsl_rl_cfg(task_name: str, args_cli: argparse.Namespace) -> RslRlBaseRunnerCfg: diff --git a/scripts/reinforcement_learning/rsl_rl/play.py b/scripts/reinforcement_learning/rsl_rl/play.py index a741e08e8b7..beb92072173 100644 --- a/scripts/reinforcement_learning/rsl_rl/play.py +++ b/scripts/reinforcement_learning/rsl_rl/play.py @@ -67,7 +67,6 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict @@ -84,11 +83,6 @@ @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): """Play with RSL-RL agent.""" - # Handle config to use offline_assets - if args_cli.offline: - setup_offline_mode() - patch_config_for_offline_mode(env_cfg) - # grab task name for checkpoint path task_name = args_cli.task.split(":")[-1] train_task_name = task_name.replace("-Play", "") diff --git a/scripts/reinforcement_learning/rsl_rl/train.py b/scripts/reinforcement_learning/rsl_rl/train.py index e8577fb4304..0cce12d7eba 100644 --- a/scripts/reinforcement_learning/rsl_rl/train.py +++ b/scripts/reinforcement_learning/rsl_rl/train.py @@ -91,7 +91,6 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml @@ -115,11 +114,6 @@ @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): """Train with RSL-RL agent.""" - # Handle config to use offline_assets - if args_cli.offline: - setup_offline_mode() - patch_config_for_offline_mode(env_cfg) - # override configurations with non-hydra CLI arguments agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs diff --git a/scripts/reinforcement_learning/sb3/play.py b/scripts/reinforcement_learning/sb3/play.py index 28e99174369..4afe943f62f 100644 --- a/scripts/reinforcement_learning/sb3/play.py +++ b/scripts/reinforcement_learning/sb3/play.py @@ -44,7 +44,6 @@ default=False, help="Use a slower SB3 wrapper but keep all the extra training info.", ) -parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # parse the arguments @@ -78,7 +77,6 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.dict import print_dict from isaaclab_rl.sb3 import Sb3VecEnvWrapper, process_sb3_cfg @@ -94,11 +92,6 @@ @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: dict): """Play with stable-baselines agent.""" - # Handle config to use offline_assets - if args_cli.offline: - setup_offline_mode() - patch_config_for_offline_mode(env_cfg) - # grab task name for checkpoint path task_name = args_cli.task.split(":")[-1] train_task_name = task_name.replace("-Play", "") diff --git a/scripts/reinforcement_learning/sb3/train.py b/scripts/reinforcement_learning/sb3/train.py index 947d5e40693..32549dcd4ea 100644 --- a/scripts/reinforcement_learning/sb3/train.py +++ b/scripts/reinforcement_learning/sb3/train.py @@ -40,7 +40,6 @@ parser.add_argument( "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) -parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # parse the arguments @@ -95,7 +94,6 @@ def cleanup_pbar(*args): ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml @@ -112,11 +110,6 @@ def cleanup_pbar(*args): @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: dict): """Train with stable-baselines agent.""" - # Handle config to use offline_assets - if args_cli.offline: - setup_offline_mode() - patch_config_for_offline_mode(env_cfg) - # randomly sample a seed if seed = -1 if args_cli.seed == -1: args_cli.seed = random.randint(0, 10000) diff --git a/scripts/reinforcement_learning/skrl/play.py b/scripts/reinforcement_learning/skrl/play.py index 221b96bc0fd..3ad95406366 100644 --- a/scripts/reinforcement_learning/skrl/play.py +++ b/scripts/reinforcement_learning/skrl/play.py @@ -57,7 +57,6 @@ help="The RL algorithm used for training the skrl agent.", ) parser.add_argument("--real-time", action="store_true", default=False, help="Run in real-time, if possible.") -parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) @@ -105,7 +104,6 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.dict import print_dict from isaaclab_rl.skrl import SkrlVecEnvWrapper @@ -129,11 +127,6 @@ @hydra_task_config(args_cli.task, agent_cfg_entry_point) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, experiment_cfg: dict): """Play with skrl agent.""" - # Handle config to use offline_assets - if args_cli.offline: - setup_offline_mode() - patch_config_for_offline_mode(env_cfg) - # grab task name for checkpoint path task_name = args_cli.task.split(":")[-1] train_task_name = task_name.replace("-Play", "") diff --git a/scripts/reinforcement_learning/skrl/train.py b/scripts/reinforcement_learning/skrl/train.py index a307df25015..cf2edce4743 100644 --- a/scripts/reinforcement_learning/skrl/train.py +++ b/scripts/reinforcement_learning/skrl/train.py @@ -57,7 +57,6 @@ parser.add_argument( "--ray-proc-id", "-rid", type=int, default=None, help="Automatically configured by Ray integration, otherwise None." ) -parser.add_argument("--offline", action="store_true", default=False, help="Enable offline mode (use offline_assets)") # append AppLauncher cli args AppLauncher.add_app_launcher_args(parser) # parse the arguments @@ -106,7 +105,6 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict from isaaclab.utils.io import dump_yaml @@ -133,11 +131,6 @@ @hydra_task_config(args_cli.task, agent_cfg_entry_point) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: dict): """Train with skrl agent.""" - # Handle config to use offline_assets - if args_cli.offline: - setup_offline_mode() - patch_config_for_offline_mode(env_cfg) - # override configurations with non-hydra CLI arguments env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs env_cfg.sim.device = args_cli.device if args_cli.device is not None else env_cfg.sim.device diff --git a/scripts/sim2sim_transfer/rsl_rl_transfer.py b/scripts/sim2sim_transfer/rsl_rl_transfer.py index 8a77a65b18e..63036e2408d 100644 --- a/scripts/sim2sim_transfer/rsl_rl_transfer.py +++ b/scripts/sim2sim_transfer/rsl_rl_transfer.py @@ -72,7 +72,6 @@ ManagerBasedRLEnvCfg, multi_agent_to_single_agent, ) -from isaaclab.utils import patch_config_for_offline_mode, setup_offline_mode from isaaclab.utils.assets import retrieve_file_path from isaaclab.utils.dict import print_dict @@ -149,11 +148,6 @@ def get_joint_mappings(args_cli, action_space_dim): @hydra_task_config(args_cli.task, args_cli.agent) def main(env_cfg: ManagerBasedRLEnvCfg | DirectRLEnvCfg | DirectMARLEnvCfg, agent_cfg: RslRlBaseRunnerCfg): """Play with RSL-RL agent with policy transfer capabilities.""" - # Handle config to use offline_assets - if args_cli.offline: - setup_offline_mode() - patch_config_for_offline_mode(env_cfg) - # override configurations with non-hydra CLI arguments agent_cfg = cli_args.update_rsl_rl_cfg(agent_cfg, args_cli) env_cfg.scene.num_envs = args_cli.num_envs if args_cli.num_envs is not None else env_cfg.scene.num_envs diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index e986d4b664a..16f352dd29e 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -115,6 +115,7 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwa self._livestream: Literal[0, 1, 2] # 0: Disabled, 1: WebRTC public, 2: WebRTC private self._offscreen_render: bool # 0: Disabled, 1: Enabled self._sim_experience_file: str # Experience file to load + self._offline: bool # 0: Disabled, 1: Enabled # Exposed to train scripts self.device_id: int # device ID for GPU simulation (defaults to 0) @@ -138,6 +139,10 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwa # Set animation recording settings self._set_animation_recording_settings(launcher_args) + # Set up offline mode if enabled + if self._offline: + self._setup_offline_mode() + # Hide play button callback if the timeline is stopped import omni.timeline @@ -174,6 +179,11 @@ def app(self) -> SimulationApp: else: raise RuntimeError("The `AppLauncher.app` member cannot be retrieved until the class is initialized.") + @property + def offline(self) -> bool: + """Whether offline asset resolution is enabled.""" + return self._offline + """ Operations. """ @@ -369,6 +379,16 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: " exceeded, then the animation is not recorded." ), ) + arg_group.add_argument( + "--offline", + action="store_true", + default=AppLauncher._APPLAUNCHER_CFG_INFO["offline"][1], + help=( + "Enable offline asset resolution. When enabled, asset paths from Nucleus/S3 " + "servers are automatically redirected to local storage (offline_assets/). " + "Can also be enabled via the OFFLINE environment variable." + ), + ) # special flag for backwards compatibility # Corresponding to the beginning of the function, @@ -389,6 +409,7 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: "device": ([str], "cuda:0"), "experience": ([str], ""), "rendering_mode": ([str], "balanced"), + "offline": ([bool], False), } """A dictionary of arguments added manually by the :meth:`AppLauncher.add_app_launcher_args` method. @@ -493,6 +514,9 @@ def _config_resolution(self, launcher_args: dict): # Handle device and distributed settings self._resolve_device_settings(launcher_args) + # Handle offline mode settings + self._resolve_offline_settings(launcher_args) + # Handle experience file settings self._resolve_experience_file(launcher_args) @@ -704,6 +728,30 @@ def _resolve_device_settings(self, launcher_args: dict): print(f"[INFO][AppLauncher]: Using device: {device}") + def _resolve_offline_settings(self, launcher_args: dict): + """Resolve offline mode related settings. + + This method checks both the --offline CLI argument and the OFFLINE + environment variable. CLI argument takes precedence. + """ + # Check environment variable + offline_env = os.environ.get("OFFLINE", "0").lower() in ("1", "true", "yes") + + # Check CLI argument (pop it so it doesn't get passed to SimulationApp) + offline_arg = launcher_args.pop("offline", False) + + # CLI argument takes precedence over environment variable + self._offline = offline_arg or offline_env + + if self._offline: + print("[INFO][AppLauncher]: Offline mode ENABLED") + if offline_arg and offline_env: + print("[INFO][AppLauncher]: Both --offline flag and OFFLINE env var are set") + elif offline_arg: + print("[INFO][AppLauncher]: Enabled via --offline flag") + else: + print("[INFO][AppLauncher]: Enabled via OFFLINE environment variable") + def _resolve_experience_file(self, launcher_args: dict): """Resolve experience file related settings.""" # Check if input keywords contain an 'experience' file setting @@ -763,6 +811,21 @@ def _resolve_experience_file(self, launcher_args: dict): self._sim_experience_file = os.path.abspath(self._sim_experience_file) print(f"[INFO][AppLauncher]: Loading experience file: {self._sim_experience_file}") + def _setup_offline_mode(self): + """Configure offline asset resolution. + + This method is called after the SimulationApp is created to ensure + all necessary modules are available. + """ + try: + from isaaclab.utils import setup_offline_mode + + setup_offline_mode() + print("[INFO][AppLauncher]: Offline asset resolution configured") + except ImportError as e: + print(f"[WARN][AppLauncher]: Could not enable offline mode: {e}") + print("[WARN][AppLauncher]: Please ensure isaaclab.utils.asset_resolver is available") + def _resolve_anim_recording_settings(self, launcher_args: dict): """Resolve animation recording settings.""" diff --git a/source/isaaclab/isaaclab/utils/asset_resolver.py b/source/isaaclab/isaaclab/utils/asset_resolver.py index 138ff56d7dd..b096a0c84a2 100644 --- a/source/isaaclab/isaaclab/utils/asset_resolver.py +++ b/source/isaaclab/isaaclab/utils/asset_resolver.py @@ -7,45 +7,27 @@ Offline Asset Resolver for Isaac Lab. This module provides utilities to transparently redirect asset paths from Nucleus/S3 -to local storage when running in offline mode. It maintains the same directory structure -so that environment configs require no changes. - -Key Features: - - Automatic path resolution from Nucleus URLs to local filesystem - - Transparent fallback to Nucleus if local asset missing - - Monkey-patching of Isaac Lab spawn configs for automatic resolution - - Support for versioned Nucleus URLs - -Usage: - from isaaclab.utils import setup_offline_mode, patch_config_for_offline_mode - - # Enable offline mode at start of script - setup_offline_mode() - - # Patch environment config after loading - patch_config_for_offline_mode(env_cfg) - - # All asset paths will now resolve to offline_assets/ +to local storage when running in offline mode. """ +from __future__ import annotations + import os import re -from typing import Optional +from typing import TYPE_CHECKING -import carb.settings +if TYPE_CHECKING: + pass class OfflineAssetResolver: - """ - Singleton class to manage offline asset path resolution. + """Singleton class to manage offline asset path resolution.""" - When enabled, this resolver intercepts asset paths pointing to Nucleus servers - and redirects them to the local offline_assets directory while maintaining the - same directory structure. - """ - - _instance: Optional["OfflineAssetResolver"] = None + _instance: OfflineAssetResolver | None = None _enabled: bool = False + _initialized: bool = False + _spawn_hooks_installed: bool = False + _env_hooks_installed: bool = False _offline_assets_dir: str | None = None _isaac_nucleus_dir: str | None = None _isaaclab_nucleus_dir: str | None = None @@ -53,25 +35,30 @@ class OfflineAssetResolver: def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) - cls._instance._initialize() return cls._instance def _initialize(self): """Initialize the resolver with environment paths.""" - # Get Isaac Lab root path - self.isaaclab_path = os.environ.get("ISAACLAB_PATH", os.getcwd()) + if self._initialized: + return - # Set offline assets directory - self._offline_assets_dir = os.path.join(self.isaaclab_path, "offline_assets") + try: + import carb.settings - # Get Nucleus directories from settings - settings = carb.settings.get_settings() - nucleus_root = settings.get("/persistent/isaac/asset_root/default") + settings = carb.settings.get_settings() + nucleus_root = settings.get("/persistent/isaac/asset_root/default") + except (ImportError, RuntimeError): + nucleus_root = None + + self.isaaclab_path = os.environ.get("ISAACLAB_PATH", os.getcwd()) + self._offline_assets_dir = os.path.join(self.isaaclab_path, "offline_assets") if nucleus_root: self._isaac_nucleus_dir = f"{nucleus_root}/Isaac" self._isaaclab_nucleus_dir = f"{nucleus_root}/Isaac/IsaacLab" + self._initialized = True + print("[OfflineAssetResolver] Initialized") print(f" Offline assets dir: {self._offline_assets_dir}") if self._isaaclab_nucleus_dir: @@ -79,14 +66,14 @@ def _initialize(self): def enable(self): """Enable offline asset resolution.""" + self._initialize() self._enabled = True print("[OfflineAssetResolver] Offline mode ENABLED") print(f" All assets will be loaded from: {self._offline_assets_dir}") - # Verify offline assets directory exists if not os.path.exists(self._offline_assets_dir): print("[OfflineAssetResolver] ⚠️ WARNING: Offline assets directory not found!") - print(" Run: ./isaaclab.sh -p scripts/offline_setup/download_assets.py") + print(" Run: ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories all") def disable(self): """Disable offline asset resolution.""" @@ -94,82 +81,132 @@ def disable(self): print("[OfflineAssetResolver] Offline mode DISABLED") def is_enabled(self) -> bool: - """Check if offline mode is enabled.""" return self._enabled - def resolve_path(self, asset_path: str) -> str: - """ - Resolve an asset path to either Nucleus or offline storage. + def are_spawn_hooks_installed(self) -> bool: + return self._spawn_hooks_installed - This method handles multiple Nucleus URL formats including versioned URLs - (e.g., .../Assets/Isaac/5.1/Isaac/...) and falls back to Nucleus if the - local asset doesn't exist. + def set_spawn_hooks_installed(self, value: bool = True): + self._spawn_hooks_installed = value - Args: - asset_path: Original asset path (may contain Nucleus URL) + def are_env_hooks_installed(self) -> bool: + return self._env_hooks_installed - Returns: - Resolved path (offline if enabled and exists, otherwise original) - """ + def set_env_hooks_installed(self, value: bool = True): + self._env_hooks_installed = value + + def resolve_path(self, asset_path: str) -> str: + """Resolve an asset path to offline storage if available.""" if not self._enabled or not isinstance(asset_path, str) or not asset_path: return asset_path - # Try to extract the relative path from various Nucleus URL formats path_to_convert = self._extract_relative_path(asset_path) if path_to_convert: offline_path = os.path.join(self._offline_assets_dir, path_to_convert) - # Return offline path if file exists, otherwise fall back to Nucleus + # Try exact path first if os.path.exists(offline_path): print(f"[OfflineAssetResolver] ✓ Using offline: {path_to_convert}") return offline_path - else: - print(f"[OfflineAssetResolver] ⚠️ Not found locally: {path_to_convert}") - print("[OfflineAssetResolver] Falling back to Nucleus") + + # Try case-insensitive fallback (handles ANYmal-D vs anymal_d mismatches) + resolved = self._find_case_insensitive(path_to_convert) + if resolved: + print(f"[OfflineAssetResolver] ✓ Using offline (case-adjusted): {resolved}") + return os.path.join(self._offline_assets_dir, resolved) + + print(f"[OfflineAssetResolver] ⚠️ Not found locally: {path_to_convert}") + print("[OfflineAssetResolver] Falling back to Nucleus") return asset_path - def _extract_relative_path(self, asset_path: str) -> str | None: + def _find_case_insensitive(self, relative_path: str) -> str | None: """ - Extract relative path from various Nucleus URL formats. + Find a file using case-insensitive matching. - Handles: - - Versioned URLs: .../Assets/Isaac/5.1/Isaac/IsaacLab/Robots/... - - Versioned URLs: .../Assets/Isaac/5.1/Isaac/Props/... - - Non-versioned: .../Isaac/IsaacLab/Robots/... - - Non-versioned: .../Isaac/Props/... + Handles mismatches like: + - ANYmal-D vs anymal_d + - ANYmal-B vs anymal_b Args: - asset_path: Full Nucleus URL + relative_path: The relative path to find (e.g., "Robots/ANYbotics/ANYmal-D/anymal_d.usd") Returns: - Relative path (e.g., "Robots/Unitree/Go2/go2.usd") or None if not a Nucleus path + The actual relative path if found, None otherwise """ + parts = relative_path.replace("\\", "/").split("/") + current_dir = self._offline_assets_dir + resolved_parts = [] + + for i, part in enumerate(parts): + if not os.path.exists(current_dir): + return None + + # For the last part (filename), do exact match first then case-insensitive + # For directories, try to find a case-insensitive match + try: + entries = os.listdir(current_dir) + except (OSError, PermissionError): + return None + + # First try exact match + if part in entries: + resolved_parts.append(part) + current_dir = os.path.join(current_dir, part) + continue + + # Try case-insensitive match + part_lower = part.lower() + # Also try with common substitutions (hyphen <-> underscore) + part_normalized = part_lower.replace("-", "_") + + found = None + for entry in entries: + entry_lower = entry.lower() + entry_normalized = entry_lower.replace("-", "_") + + if entry_lower == part_lower or entry_normalized == part_normalized: + found = entry + break + + if found: + resolved_parts.append(found) + current_dir = os.path.join(current_dir, found) + else: + return None + + # Verify the final path exists + final_path = os.path.join(self._offline_assets_dir, *resolved_parts) + if os.path.exists(final_path): + return "/".join(resolved_parts) + + return None + + def _extract_relative_path(self, asset_path: str) -> str | None: + """Extract relative path from various Nucleus URL formats.""" # Pattern 1: Isaac Lab assets with version - # e.g., .../Assets/Isaac/5.1/Isaac/IsaacLab/Robots/... match = re.search(r"/Assets/Isaac/[\d.]+/Isaac/IsaacLab/(.+)$", asset_path) if match: return match.group(1) # Pattern 2: General Isaac assets with version - # e.g., .../Assets/Isaac/5.1/Isaac/Props/... match = re.search(r"/Assets/Isaac/[\d.]+/Isaac/(?!IsaacLab)(.+)$", asset_path) if match: return match.group(1) - # Pattern 3: Isaac Lab assets without version (older format) + # Pattern 3: Isaac Lab assets without version if self._isaaclab_nucleus_dir and asset_path.startswith(self._isaaclab_nucleus_dir): return asset_path[len(self._isaaclab_nucleus_dir) :].lstrip("/") - # Pattern 4: General Isaac assets without version (older format) + # Pattern 4: General Isaac assets without version if self._isaac_nucleus_dir and asset_path.startswith(self._isaac_nucleus_dir): return asset_path[len(self._isaac_nucleus_dir) :].lstrip("/") return None def get_offline_assets_dir(self) -> str: - """Get the offline assets directory path.""" + self._initialize() return self._offline_assets_dir @@ -177,7 +214,11 @@ def get_offline_assets_dir(self) -> str: _resolver = OfflineAssetResolver() -# Public API functions +# ============================================================================= +# Public API Functions +# ============================================================================= + + def enable_offline_mode(): """Enable offline asset resolution globally.""" _resolver.enable() @@ -194,15 +235,7 @@ def is_offline_mode_enabled() -> bool: def resolve_asset_path(asset_path: str) -> str: - """ - Resolve an asset path, redirecting to offline storage if enabled. - - Args: - asset_path: Original asset path (may contain Nucleus URL) - - Returns: - Resolved path (offline if mode enabled and file exists, otherwise original) - """ + """Resolve an asset path, redirecting to offline storage if enabled.""" return _resolver.resolve_path(asset_path) @@ -211,104 +244,15 @@ def get_offline_assets_dir() -> str: return _resolver.get_offline_assets_dir() -def patch_config_for_offline_mode(env_cfg): - """ - Patch environment configuration to use offline assets. - - This function walks through the environment config and patches known asset paths - to use local storage when offline mode is enabled. It handles: - - Robot USD paths - - Terrain/ground plane paths - - Sky light textures - - Visualization markers - - Args: - env_cfg: Environment configuration object (typically ManagerBasedRLEnvCfg) - """ - if not is_offline_mode_enabled(): +def install_spawn_hooks(): + """Install path resolution hooks on Isaac Lab spawn config classes.""" + if _resolver.are_spawn_hooks_installed(): return - print("[OfflineAssetResolver] Patching configuration...") - patches_made = 0 - - # Patch robot USD path - if hasattr(env_cfg, "scene") and hasattr(env_cfg.scene, "robot"): - if hasattr(env_cfg.scene.robot, "spawn") and hasattr(env_cfg.scene.robot.spawn, "usd_path"): - original = env_cfg.scene.robot.spawn.usd_path - resolved = resolve_asset_path(original) - if resolved != original: - env_cfg.scene.robot.spawn.usd_path = resolved - patches_made += 1 - print("[OfflineAssetResolver] ✓ Patched robot USD path") - - # Patch terrain USD path (if present) - if hasattr(env_cfg, "scene") and hasattr(env_cfg.scene, "terrain"): - if hasattr(env_cfg.scene.terrain, "usd_path"): - original = env_cfg.scene.terrain.usd_path - resolved = resolve_asset_path(original) - if resolved != original: - env_cfg.scene.terrain.usd_path = resolved - patches_made += 1 - print("[OfflineAssetResolver] ✓ Patched terrain USD path") - - # Patch sky light texture - if hasattr(env_cfg, "scene") and hasattr(env_cfg.scene, "sky_light"): - if hasattr(env_cfg.scene.sky_light, "spawn") and hasattr(env_cfg.scene.sky_light.spawn, "texture_file"): - if env_cfg.scene.sky_light.spawn.texture_file: - original = env_cfg.scene.sky_light.spawn.texture_file - resolved = resolve_asset_path(original) - if resolved != original: - env_cfg.scene.sky_light.spawn.texture_file = resolved - patches_made += 1 - print("[OfflineAssetResolver] ✓ Patched sky light texture") - - # Patch visualization markers - if hasattr(env_cfg, "commands"): - for command_name in dir(env_cfg.commands): - if command_name.startswith("_"): - continue - - command = getattr(env_cfg.commands, command_name, None) - if not command: - continue - - # Patch both current and goal velocity visualizers - for viz_name in ["current_vel_visualizer_cfg", "goal_vel_visualizer_cfg"]: - if not hasattr(command, viz_name): - continue - - visualizer = getattr(command, viz_name) - if not hasattr(visualizer, "markers") or not isinstance(visualizer.markers, dict): - continue - - for marker_name, marker_cfg in visualizer.markers.items(): - if hasattr(marker_cfg, "usd_path"): - original = marker_cfg.usd_path - resolved = resolve_asset_path(original) - if resolved != original: - marker_cfg.usd_path = resolved - patches_made += 1 - print(f"[OfflineAssetResolver] ✓ Patched {marker_name} in {viz_name}") - - if patches_made > 0: - print(f"[OfflineAssetResolver] Patched {patches_made} asset paths") - else: - print("[OfflineAssetResolver] No paths needed patching (already correct)") - - -def install_path_hooks(): - """ - Install hooks into Isaac Lab's spawn configs for automatic path resolution. - - This function monkey-patches Isaac Lab's UsdFileCfg, GroundPlaneCfg, and - PreviewSurfaceCfg classes to automatically resolve asset paths when they're - instantiated. This provides transparent offline support without modifying - environment configs. - """ try: import isaaclab.sim as sim_utils - # Patch UsdFileCfg for general USD file spawning + # Patch UsdFileCfg if hasattr(sim_utils, "UsdFileCfg"): original_usd_init = sim_utils.UsdFileCfg.__init__ @@ -320,7 +264,7 @@ def patched_usd_init(self, *args, **kwargs): sim_utils.UsdFileCfg.__init__ = patched_usd_init print("[OfflineAssetResolver] Installed UsdFileCfg path hook") - # Patch GroundPlaneCfg for terrain/ground plane spawning + # Patch GroundPlaneCfg if hasattr(sim_utils, "GroundPlaneCfg"): original_ground_init = sim_utils.GroundPlaneCfg.__init__ @@ -335,7 +279,7 @@ def patched_ground_init(self, *args, **kwargs): sim_utils.GroundPlaneCfg.__init__ = patched_ground_init print("[OfflineAssetResolver] Installed GroundPlaneCfg path hook") - # Patch PreviewSurfaceCfg for texture file resolution + # Patch PreviewSurfaceCfg if hasattr(sim_utils, "PreviewSurfaceCfg"): original_surface_init = sim_utils.PreviewSurfaceCfg.__init__ @@ -348,26 +292,267 @@ def patched_surface_init(self, *args, **kwargs): sim_utils.PreviewSurfaceCfg.__init__ = patched_surface_init print("[OfflineAssetResolver] Installed PreviewSurfaceCfg path hook") + _resolver.set_spawn_hooks_installed(True) + except ImportError: - print("[OfflineAssetResolver] Could not install path hooks - isaaclab.sim not available") + print("[OfflineAssetResolver] Could not install spawn hooks - isaaclab.sim not available") + + # Hook read_file() in isaaclab.utils.assets - this is where actuator nets are loaded + try: + import isaaclab.utils.assets as assets_module + + if hasattr(assets_module, "read_file"): + original_read_file = assets_module.read_file + + def patched_read_file(path: str): + if is_offline_mode_enabled(): + resolved_path = resolve_asset_path(path) + return original_read_file(resolved_path) + return original_read_file(path) + + assets_module.read_file = patched_read_file + print("[OfflineAssetResolver] Installed read_file path hook") + + except ImportError: + pass + + # Hook retrieve_file_path() - another common entry point for file loading + try: + import isaaclab.utils.assets as assets_module + + if hasattr(assets_module, "retrieve_file_path"): + original_retrieve = assets_module.retrieve_file_path + + def patched_retrieve_file_path(path: str, *args, **kwargs): + if is_offline_mode_enabled(): + resolved_path = resolve_asset_path(path) + return original_retrieve(resolved_path, *args, **kwargs) + return original_retrieve(path, *args, **kwargs) + + assets_module.retrieve_file_path = patched_retrieve_file_path + print("[OfflineAssetResolver] Installed retrieve_file_path hook") + + except ImportError: + pass + + +def install_env_hooks(): + """Install hooks on environment base classes to auto-patch configs.""" + if _resolver.are_env_hooks_installed(): + return + + try: + from isaaclab.envs import ManagerBasedEnv + + original_manager_init = ManagerBasedEnv.__init__ + + def patched_manager_init(self, cfg, *args, **kwargs): + if is_offline_mode_enabled(): + patch_config_for_offline_mode(cfg) + original_manager_init(self, cfg, *args, **kwargs) + + ManagerBasedEnv.__init__ = patched_manager_init + print("[OfflineAssetResolver] Installed ManagerBasedEnv config hook") + + except ImportError: + pass + + try: + from isaaclab.envs import DirectRLEnv + + original_direct_init = DirectRLEnv.__init__ + + def patched_direct_init(self, cfg, *args, **kwargs): + if is_offline_mode_enabled(): + patch_config_for_offline_mode(cfg) + original_direct_init(self, cfg, *args, **kwargs) + + DirectRLEnv.__init__ = patched_direct_init + print("[OfflineAssetResolver] Installed DirectRLEnv config hook") + + except ImportError: + pass + + try: + from isaaclab.envs import DirectMARLEnv + + original_marl_init = DirectMARLEnv.__init__ + + def patched_marl_init(self, cfg, *args, **kwargs): + if is_offline_mode_enabled(): + patch_config_for_offline_mode(cfg) + original_marl_init(self, cfg, *args, **kwargs) + + DirectMARLEnv.__init__ = patched_marl_init + print("[OfflineAssetResolver] Installed DirectMARLEnv config hook") + + except ImportError: + pass + + _resolver.set_env_hooks_installed(True) + + +def _is_nucleus_path(path: str) -> bool: + """Check if a path is a Nucleus/S3 URL that needs resolution.""" + if not isinstance(path, str): + return False + return ( + "omniverse-content-production" in path + or "nucleus" in path.lower() + or path.startswith("omniverse://") + or "/Assets/Isaac/" in path + ) + + +def _patch_object_recursive(obj, visited: set, depth: int = 0, max_depth: int = 15) -> int: + """ + Recursively walk through an object and patch any asset paths. + + Args: + obj: Object to patch (config, dataclass, etc.) + visited: Set of already-visited object IDs to prevent infinite loops + depth: Current recursion depth + max_depth: Maximum recursion depth to prevent stack overflow + + Returns: + Number of paths patched + """ + if depth > max_depth: + return 0 + + obj_id = id(obj) + if obj_id in visited: + return 0 + visited.add(obj_id) + + patches = 0 + + # Skip primitive types and None + if obj is None or isinstance(obj, (str, int, float, bool, bytes)): + return 0 + + # Handle dictionaries + if isinstance(obj, dict): + for key, value in obj.items(): + if isinstance(value, str) and _is_nucleus_path(value): + resolved = resolve_asset_path(value) + if resolved != value: + obj[key] = resolved + patches += 1 + else: + patches += _patch_object_recursive(value, visited, depth + 1, max_depth) + return patches + + # Handle lists/tuples + if isinstance(obj, (list, tuple)): + for i, item in enumerate(obj): + if isinstance(item, str) and _is_nucleus_path(item): + resolved = resolve_asset_path(item) + if resolved != item: + if isinstance(obj, list): + obj[i] = resolved + patches += 1 + else: + patches += _patch_object_recursive(item, visited, depth + 1, max_depth) + return patches + + # Handle objects with __dict__ (dataclasses, configclasses, regular objects) + if hasattr(obj, "__dict__"): + # Known asset path attributes to check directly + asset_attrs = [ + "usd_path", + "texture_file", + "asset_path", + "file_path", + "mesh_file", + "network_file", # ActuatorNet LSTM/MLP files + "policy_path", # Policy checkpoint files + "checkpoint_path", # Model checkpoints + ] + + for attr in asset_attrs: + if hasattr(obj, attr): + value = getattr(obj, attr) + if isinstance(value, str) and _is_nucleus_path(value): + resolved = resolve_asset_path(value) + if resolved != value: + try: + setattr(obj, attr, resolved) + patches += 1 + except (AttributeError, TypeError): + pass # Read-only attribute + + # Recursively process all attributes + try: + for attr_name in dir(obj): + # Skip private/magic attributes and methods + if attr_name.startswith("_"): + continue + + try: + attr_value = getattr(obj, attr_name) + + # Skip callables (methods, functions) + if callable(attr_value) and not hasattr(attr_value, "__dict__"): + continue + + # Recurse into the attribute + patches += _patch_object_recursive(attr_value, visited, depth + 1, max_depth) + + except (AttributeError, TypeError, RuntimeError): + continue # Skip attributes that can't be accessed + + except (TypeError, RuntimeError): + pass # Some objects don't support dir() + + return patches + + +def patch_config_for_offline_mode(env_cfg): + """ + Patch environment configuration to use offline assets. + + This function recursively walks through the ENTIRE environment config tree + and patches ANY asset paths it finds (usd_path, texture_file, etc.). + + This handles: + - Robot USD paths (env_cfg.scene.robot.spawn.usd_path) + - Sky light textures (env_cfg.scene.sky_light.spawn.texture_file) + - Ground planes (env_cfg.scene.terrain.terrain_generator.ground_plane_cfg.usd_path) + - Visualizer markers (env_cfg.commands.*.goal_vel_visualizer_cfg.markers.*.usd_path) + - ANY other nested asset paths + + Args: + env_cfg: Environment configuration object + """ + if not is_offline_mode_enabled(): + return + + visited = set() + patches = _patch_object_recursive(env_cfg, visited) + + if patches > 0: + print(f"[OfflineAssetResolver] Patched {patches} pre-loaded config paths") def setup_offline_mode(): """ Set up offline mode with all hooks and path resolution. - This is the main entry point for enabling offline training. Call this function - at the start of your training script when the --offline flag is set. - - Example: - if args_cli.offline: - from isaaclab.utils import setup_offline_mode, patch_config_for_offline_mode - setup_offline_mode() - patch_config_for_offline_mode(env_cfg) + This is the main entry point for enabling offline training. It is called + automatically by AppLauncher when the --offline flag is set. """ enable_offline_mode() - install_path_hooks() + install_spawn_hooks() + install_env_hooks() print("[OfflineAssetResolver] Offline mode fully configured") + print("[OfflineAssetResolver] Environment configs will be auto-patched at creation time") + + +# For backwards compatibility +def install_path_hooks(): + """Alias for install_spawn_hooks() for backwards compatibility.""" + install_spawn_hooks() # Export public API @@ -378,6 +563,8 @@ def setup_offline_mode(): "resolve_asset_path", "get_offline_assets_dir", "patch_config_for_offline_mode", + "install_spawn_hooks", + "install_env_hooks", "install_path_hooks", "setup_offline_mode", ] diff --git a/source/isaaclab/test/deps/isaacsim/check_rep_texture_randomizer.py b/source/isaaclab/test/deps/isaacsim/check_rep_texture_randomizer.py index 30aba2c1ffe..170bf1b9683 100644 --- a/source/isaaclab/test/deps/isaacsim/check_rep_texture_randomizer.py +++ b/source/isaaclab/test/deps/isaacsim/check_rep_texture_randomizer.py @@ -47,9 +47,9 @@ import isaacsim.core.utils.prims as prim_utils import omni.replicator.core as rep +from isaacsim.core.api.objects import DynamicSphere from isaacsim.core.api.simulation_context import SimulationContext from isaacsim.core.cloner import GridCloner -from isaacsim.core.api.objects import DynamicSphere from isaacsim.core.prims import RigidPrim from isaacsim.core.utils.viewports import set_camera_view From aa252fcad128151ba1f49e31f9d137a010fe2322 Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Wed, 28 Jan 2026 15:08:40 -0500 Subject: [PATCH 22/24] Documentation updates --- scripts/offline_setup/README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/offline_setup/README.md b/scripts/offline_setup/README.md index 9bb3f31c79c..6051b3cbe17 100644 --- a/scripts/offline_setup/README.md +++ b/scripts/offline_setup/README.md @@ -4,14 +4,14 @@ ## 🎯 Overview #### The offline training system enables you to train Isaac Lab environments without internet connectivity by using locally downloaded assets. This system: - ✅ Works with any robot - No hardcoded paths needed -- ✅ Single flag - Just add --offline to your training command +- ✅ Single flag - Just add `--offline` to your training, tutorial, and demos commands - ✅ Automatic fallback - Uses Nucleus if local asset is missing - ✅ Maintains structure - Mirrors Nucleus directory organization locally ## 📦 Requirements - Isaac Lab installed and working - Isaac Sim 5.0 or later -- 2-20 GB free disk space (depending on assets downloaded) +- 2-30 GB free disk space (depending on assets downloaded) - Internet connection for initial asset download ## 🚀 Quick Start @@ -30,8 +30,15 @@ #### Supported for: `rl_games`, `rsl_rl`, `sb3`, `skrl`, and `sim2transfer` ``` ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \ - --task Isaac-Velocity-Flat-Unitree-Go2-v0 \ - --num_envs 128 \ + --task Isaac-Velocity-Rough-Unitree-Go2-v0 \ + --num_envs 64 \ + --max_iterations 10 \ + --offline + +./isaaclab.sh -p scripts/reinforcement_learning/skrl/train.py \ + --task Isaac-Velocity-Flat-H1-v0 \ + --num_envs 64 \ + --max_iterations 10 \ --offline ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/play.py \ From d52286fc8518f74055cbeb3668623203f6638f3b Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Wed, 28 Jan 2026 15:09:36 -0500 Subject: [PATCH 23/24] clarification --- scripts/offline_setup/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/offline_setup/README.md b/scripts/offline_setup/README.md index 6051b3cbe17..fd3a3fa5094 100644 --- a/scripts/offline_setup/README.md +++ b/scripts/offline_setup/README.md @@ -4,7 +4,7 @@ ## 🎯 Overview #### The offline training system enables you to train Isaac Lab environments without internet connectivity by using locally downloaded assets. This system: - ✅ Works with any robot - No hardcoded paths needed -- ✅ Single flag - Just add `--offline` to your training, tutorial, and demos commands +- ✅ Single flag - Just add `--offline` to your training, tutorial, and demo commands - ✅ Automatic fallback - Uses Nucleus if local asset is missing - ✅ Maintains structure - Mirrors Nucleus directory organization locally From 6c05a804f7b4e1e130e3aa84770b81a7edd167ee Mon Sep 17 00:00:00 2001 From: Clayton Littlejohn Date: Wed, 28 Jan 2026 22:55:27 -0500 Subject: [PATCH 24/24] nucleus mirror patches & offline strict/permissive flag --- scripts/offline_setup/README.md | 115 +++- scripts/offline_setup/download_assets.py | 284 ++++----- source/isaaclab/isaaclab/app/app_launcher.py | 54 +- .../isaaclab/isaaclab/utils/asset_resolver.py | 569 +++++++++++------- 4 files changed, 575 insertions(+), 447 deletions(-) diff --git a/scripts/offline_setup/README.md b/scripts/offline_setup/README.md index fd3a3fa5094..cbf3361d267 100644 --- a/scripts/offline_setup/README.md +++ b/scripts/offline_setup/README.md @@ -4,18 +4,18 @@ ## 🎯 Overview #### The offline training system enables you to train Isaac Lab environments without internet connectivity by using locally downloaded assets. This system: - ✅ Works with any robot - No hardcoded paths needed -- ✅ Single flag - Just add `--offline` to your training, tutorial, and demo commands -- ✅ Automatic fallback - Uses Nucleus if local asset is missing +- ✅ Single flag - Add `--offline` to your training, tutorial, and demo commands +- ✅ Fallback flag - Add `--offline-permissive` to your commands to fallback to Nucleus for asset not found in `offline_assets` - ✅ Maintains structure - Mirrors Nucleus directory organization locally ## 📦 Requirements - Isaac Lab installed and working - Isaac Sim 5.0 or later -- 2-30 GB free disk space (depending on assets downloaded) +- 2-60 GB free disk space (depending on assets downloaded) - Internet connection for initial asset download ## 🚀 Quick Start -### 1. Download essential assets (one-time, `all` ~30 GB) +### 1. Download essential assets (one-time, `all` ~60 GB) #### Assets download to the `~/IsaacLab/offline_assets` directory: `cd ~/IsaacLab` ``` ./isaaclab.sh -p scripts/offline_setup/download_assets.py \ @@ -26,6 +26,31 @@ ./isaaclab.sh -p scripts/offline_setup/download_assets.py \ --categories Robots --subset Unitree ``` + +### Successful Downloads +``` +====================================================================== +📊 Downloaded Assets Summary +====================================================================== +Location: ~/IsaacLab/offline_assets + +✓ Environments 34,437 files 17.0 GB +✓ IsaacLab 4,197 files 9.3 GB +✓ Materials 1,918 files 537.0 MB +✓ People 3,085 files 9.4 GB +✓ Props 2,507 files 4.2 GB +✓ Robots 4,687 files 5.3 GB +✓ Samples 6,601 files 10.4 GB +✓ Sensors 772 files 256.7 MB +====================================================================== +TOTAL 58,204 files 56.3 GB +====================================================================== + +✅ Complete! Use --offline flag with Isaac Lab commands. + +Offline mirror: ~/IsaacLab/offline_assets +``` + ### 2. Train completely offline with any robot via the `--offline` flag (also works with `/play`) #### Supported for: `rl_games`, `rsl_rl`, `sb3`, `skrl`, and `sim2transfer` ``` @@ -34,7 +59,7 @@ --num_envs 64 \ --max_iterations 10 \ --offline - + ./isaaclab.sh -p scripts/reinforcement_learning/skrl/train.py \ --task Isaac-Velocity-Flat-H1-v0 \ --num_envs 64 \ @@ -49,13 +74,45 @@ --video_length 1000 \ --offline ``` -### 3. Run various demos and tutorials with `--offline` flag +#### Run various demos and tutorials with `--offline` flag + +``` +./isaaclab.sh -p scripts/demos/quadrupeds.py --offline + +./isaaclab.sh -p scripts/demos/arms.py --offline +./isaaclab.sh -p scripts/tutorials/01_assets/run_articulation.py --offline ``` -./isaaclab.sh -p scripts/tutorials/01_assets/run_deformable_object.py --offline + +#### Strict mode (default) - fails immediately if asset not found locally +``` +./isaaclab.sh -p train.py --task Go2 --offline +``` + +#### Permissive mode - warns and falls back to Nucleus if asset not found +``` +./isaaclab.sh -p train.py --task Go2 --offline-permissive +``` + +#### Environment variables work too +``` +OFFLINE=1 ./isaaclab.sh -p train.py --task Go2 +OFFLINE_PERMISSIVE=1 ./isaaclab.sh -p train.py --task Go2 +``` + +### Missing Assets +``` +====================================================================== +[OfflineAssetResolver] ✗ ASSET NOT FOUND (offline mode) +====================================================================== +Missing: IsaacLab/Robots/Unitree/Go2/go2.usd +Expected: ~/IsaacLab/offline_assets/IsaacLab/Robots/Unitree/Go2/go2.usd + +To download this asset, run: + ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories IsaacLab/Robots ``` -#### _Note: For offline training, assets that cannot be found in `offline_assets` will attempted to be fetched from the [Nucleus Server](https://docs.omniverse.nvidia.com/nucleus/latest/index.html)._ +#### _Note: For offline training, assets that cannot be found in `offline_assets` will attempted to be fetched from the [Nucleus Server](https://docs.omniverse.nvidia.com/nucleus/latest/index.html)_ when using the `--offline-permissive` flag. ## 📁 Asset Layout #### Offline assets are organized to mirror Nucleus (`ISAAC_NUCLEUS_DIR` & `ISAACLAB_NUCLEUS_DIR`) under the `offline_assets` directory, meaning that no code changes are required for offline running! We flatten `Isaac/IsaacLab/` to just the category names (`Robots/`, `Controllers/`, etc.) for cleaner local structure. This happens in `asset_resolver.py`, where the resolver maintains a 1:1 mapping between Nucleus and local storage. @@ -66,27 +123,23 @@ IsaacLab/ │ └── asset_resolver.py # Core resolver ├── scripts/setup/ │ └── download_assets.py # Asset downloader -└── offline_assets/ - ├── ActuatorNets/ - ├── Controllers/ - ├── Environments/ # Ground planes - │ └── Grid/ - │ └── default_environment.usd - ├── Materials/ # Textures and HDRs - │ └── Textures/ - │ └── Skies/ - ├── Mimic/ - ├── Policies/ - ├── Props/ # Markers and objects - │ └── UIElements/ - │ └── arrow_x.usd - └── Robots/ # Robot USD files - ├── BostonDynamics/ - │ └── spot/ - │ └── spot.usd - └── Unitree/ - ├── Go2/ - │ └── go2.usd - └── H1/ - └── h1.usd +└── offline_assets/ ← Mirror of Isaac/ +├── IsaacLab/ +│ ├── Robots/ +│ │ ├── ANYbotics/ +│ │ │ ├── ANYmal-B/ +│ │ │ ├── ANYmal-C/ +│ │ │ └── ANYmal-D/ +│ │ ├── Unitree/ +│ │ └── FrankaEmika/ +│ ├── ActuatorNets/ +│ ├── Controllers/ +│ └── Policies/ +├── Props/ +│ └── UIElements/ +├── Environments/ +│ └── Grid/ +├── Materials/ +│ └── Textures/ +└── Robots/ ← Isaac Sim robots (different from IsaacLab/Robots) ``` diff --git a/scripts/offline_setup/download_assets.py b/scripts/offline_setup/download_assets.py index 747422b940a..d285d41f199 100644 --- a/scripts/offline_setup/download_assets.py +++ b/scripts/offline_setup/download_assets.py @@ -4,24 +4,25 @@ # SPDX-License-Identifier: BSD-3-Clause """ -Download Isaac Lab assets from Nucleus server for offline training. +Download Isaac assets from Nucleus server for offline use. -This script downloads assets from the Nucleus server and mirrors the directory structure -locally in the offline_assets folder. This enables offline training without requiring -internet connectivity. +This script mirrors the Nucleus Isaac/ directory structure locally: + + Nucleus: .../Assets/Isaac/5.1/Isaac/ + Local: offline_assets/ Usage: - # Download all assets - ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories all + # Download Isaac Lab essentials (default) + ./isaaclab.sh -p scripts/offline_setup/download_assets.py - # Download specific categories - ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories Robots Props + # Download everything from Isaac/ + ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories all - # Download specific robot subset - ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories Robots --subset Unitree + # Download specific directories + ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories IsaacLab Props -Available Categories: - Props, Robots, Environments, Materials, Controllers, ActuatorNets, Policies, Mimic + # Download specific subdirectory + ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories IsaacLab/Robots --subset Unitree """ import argparse @@ -32,48 +33,32 @@ from isaaclab.app import AppLauncher -# Initialize Isaac Sim environment app_launcher = AppLauncher(headless=True) simulation_app = app_launcher.app import carb import omni.client -# Get Isaac Lab paths ISAACLAB_PATH = os.environ.get("ISAACLAB_PATH", os.getcwd()) OFFLINE_ASSETS_DIR = os.path.join(ISAACLAB_PATH, "offline_assets") -# Get the Nucleus directory from settings settings = carb.settings.get_settings() NUCLEUS_ASSET_ROOT = settings.get("/persistent/isaac/asset_root/default") -ISAAC_NUCLEUS_DIR = f"{NUCLEUS_ASSET_ROOT}/Isaac" # General Isaac Sim assets -ISAACLAB_NUCLEUS_DIR = f"{NUCLEUS_ASSET_ROOT}/Isaac/IsaacLab" # Isaac Lab specific assets - -# Asset categories and their locations -ASSET_CATEGORIES = { - # General Isaac Sim assets (Isaac/) - "Props": {"desc": "Props, objects, markers, and mounts", "base": ISAAC_NUCLEUS_DIR}, - "Robots": {"desc": "Robot USD files and configurations", "base": ISAAC_NUCLEUS_DIR}, - "Environments": {"desc": "Environment assets and terrains", "base": ISAAC_NUCLEUS_DIR}, - "Materials": {"desc": "Materials and textures including sky HDRs", "base": ISAAC_NUCLEUS_DIR}, - # Isaac Lab specific assets (Isaac/IsaacLab/) - "Controllers": {"desc": "IK controllers and kinematics assets", "base": ISAACLAB_NUCLEUS_DIR}, - "ActuatorNets": {"desc": "Actuator network models", "base": ISAACLAB_NUCLEUS_DIR}, - "Policies": {"desc": "Pre-trained policy checkpoints", "base": ISAACLAB_NUCLEUS_DIR}, - "Mimic": {"desc": "Demonstration and imitation learning assets", "base": ISAACLAB_NUCLEUS_DIR}, +ISAAC_NUCLEUS_DIR = f"{NUCLEUS_ASSET_ROOT}/Isaac" + +KNOWN_CATEGORIES = { + "IsaacLab": "Isaac Lab assets (Robots, ActuatorNets, Controllers, Policies, etc.)", + "Props": "Props, markers, UI elements", + "Environments": "Environment assets, ground planes", + "Materials": "Materials, textures, HDRs", + "Robots": "Isaac Sim robots (separate from IsaacLab/Robots)", + "Sensors": "Sensor assets", + "People": "Human assets", + "Samples": "Sample scenes", } def format_size(bytes_size: int) -> str: - """ - Format bytes into human-readable size. - - Args: - bytes_size: Size in bytes - - Returns: - Human-readable size string (e.g., "1.5 GB") - """ for unit in ["B", "KB", "MB", "GB", "TB"]: if bytes_size < 1024.0: return f"{bytes_size:.1f} {unit}" @@ -82,18 +67,9 @@ def format_size(bytes_size: int) -> str: def get_local_directory_size(path: str) -> int: - """ - Calculate total size of a local directory. - - Args: - path: Local directory path - - Returns: - Total size in bytes - """ total_size = 0 if os.path.exists(path): - for dirpath, dirnames, filenames in os.walk(path): + for dirpath, _, filenames in os.walk(path): for filename in filenames: filepath = os.path.join(dirpath, filename) if os.path.exists(filepath): @@ -102,15 +78,6 @@ def get_local_directory_size(path: str) -> int: def get_remote_directory_info(remote_path: str) -> tuple[int, int]: - """ - Get file count and total size of a remote Nucleus directory. - - Args: - remote_path: Nucleus directory URL - - Returns: - Tuple of (file_count, total_size_bytes) - """ file_count = 0 total_size = 0 @@ -123,12 +90,10 @@ def get_remote_directory_info(remote_path: str) -> tuple[int, int]: remote_item = f"{remote_path}/{entry.relative_path}" if is_dir: - # Recursively get info from subdirectory sub_count, sub_size = get_remote_directory_info(remote_item) file_count += sub_count total_size += sub_size else: - # Get file size file_count += 1 stat_result, stat_entry = omni.client.stat(remote_item) if stat_result == omni.client.Result.OK: @@ -137,23 +102,17 @@ def get_remote_directory_info(remote_path: str) -> tuple[int, int]: return file_count, total_size -def ensure_directory(path: str) -> None: - """Create directory if it doesn't exist.""" - os.makedirs(path, exist_ok=True) - - -def download_file(remote_path: str, local_path: str) -> bool: - """ - Download a single file from Nucleus to local storage. +def list_remote_directories(remote_path: str) -> list[str]: + result, entries = omni.client.list(remote_path) + if result != omni.client.Result.OK: + return [] + return sorted([e.relative_path for e in entries if e.flags & omni.client.ItemFlags.CAN_HAVE_CHILDREN]) - Args: - remote_path: Full Nucleus URL - local_path: Local file system path - Returns: - True if successful, False otherwise - """ +def download_file(remote_path: str, local_path: str, overwrite: bool = False) -> bool: try: + if os.path.exists(local_path) and not overwrite: + return True os.makedirs(os.path.dirname(local_path), exist_ok=True) result = omni.client.copy(remote_path, local_path) return result == omni.client.Result.OK @@ -162,18 +121,11 @@ def download_file(remote_path: str, local_path: str) -> bool: return False -def download_directory_recursive(remote_path: str, local_base: str, progress_bar) -> None: - """ - Recursively download a directory from Nucleus to local storage. - - Args: - remote_path: Nucleus directory URL - local_base: Local directory to mirror structure - progress_bar: tqdm progress bar instance - """ +def download_directory_recursive(remote_path: str, local_base: str, progress_bar, overwrite: bool = False) -> int: + downloaded = 0 result, entries = omni.client.list(remote_path) if result != omni.client.Result.OK: - return + return downloaded for entry in entries: is_dir = entry.flags & omni.client.ItemFlags.CAN_HAVE_CHILDREN @@ -181,107 +133,96 @@ def download_directory_recursive(remote_path: str, local_base: str, progress_bar local_item = os.path.join(local_base, entry.relative_path) if is_dir: - ensure_directory(local_item) - download_directory_recursive(remote_item, local_item, progress_bar) + os.makedirs(local_item, exist_ok=True) + downloaded += download_directory_recursive(remote_item, local_item, progress_bar, overwrite) else: progress_bar.set_description(f"Downloading {entry.relative_path[:50]}") - download_file(remote_item, local_item) + if download_file(remote_item, local_item, overwrite): + downloaded += 1 progress_bar.update(1) + return downloaded -def download_asset_category(category: str, subset: str = None) -> None: - """ - Download all assets in a specific category. - - Args: - category: Asset category (e.g., "Robots", "Props") - subset: Optional subset filter (e.g., specific robot name) - """ - category_info = ASSET_CATEGORIES[category] - base_path = category_info["base"] - description = category_info["desc"] - remote_dir = f"{base_path}/{category}" +def download_category(category: str, subset: str = None, overwrite: bool = False) -> None: + remote_dir = f"{ISAAC_NUCLEUS_DIR}/{category}" local_dir = os.path.join(OFFLINE_ASSETS_DIR, category) - # Apply subset filter if specified - if subset and category == "Robots": + if subset: remote_dir = f"{remote_dir}/{subset}" local_dir = os.path.join(local_dir, subset) + base_category = category.split("/")[0] + desc = KNOWN_CATEGORIES.get(base_category, "Assets") + print(f"\n{'=' * 70}") - print(f"📦 {category}: {description}") + print(f"📦 {category}: {desc}") print(f"{'=' * 70}") print(f"Source: {remote_dir}") print(f"Target: {local_dir}") - # Check if remote directory exists result, _ = omni.client.stat(remote_dir) if result != omni.client.Result.OK: print(f"⚠️ Directory not found: {remote_dir}") - print(" This category may not be available or may be in a different location.") return - # Count files and get size - print("📊 Analyzing remote directory...") + print("📊 Analyzing...") file_count, total_size = get_remote_directory_info(remote_dir) if file_count == 0: - print("✓ No files to download") + print("✓ No files") return - print(f" Files: {file_count:,}") - print(f" Size: {format_size(total_size)}") + print(f" Files: {file_count:,} | Size: {format_size(total_size)}") - # Download with progress bar - ensure_directory(local_dir) + os.makedirs(local_dir, exist_ok=True) with tqdm(total=file_count, unit="file", desc="Progress") as pbar: - download_directory_recursive(remote_dir, local_dir, pbar) + download_directory_recursive(remote_dir, local_dir, pbar, overwrite) print(f"✓ Completed {category}") def verify_downloads() -> None: - """Display summary of downloaded assets.""" print("\n" + "=" * 70) print("📊 Downloaded Assets Summary") print("=" * 70) + print(f"Location: {OFFLINE_ASSETS_DIR}\n") total_size = 0 total_files = 0 - for category in ASSET_CATEGORIES.keys(): - local_dir = os.path.join(OFFLINE_ASSETS_DIR, category) - if os.path.exists(local_dir): - size = get_local_directory_size(local_dir) - files = sum(1 for _ in Path(local_dir).rglob("*") if _.is_file()) + if not os.path.exists(OFFLINE_ASSETS_DIR): + print("❌ Offline assets directory not found") + return + + for item in sorted(os.listdir(OFFLINE_ASSETS_DIR)): + item_path = os.path.join(OFFLINE_ASSETS_DIR, item) + if os.path.isdir(item_path): + size = get_local_directory_size(item_path) + files = sum(1 for _ in Path(item_path).rglob("*") if _.is_file()) total_size += size total_files += files - print(f"✓ {category:<15} {files:>6,} files {format_size(size):>10}") - else: - print(f"✗ {category:<15} Not found") + print(f"✓ {item:<20} {files:>8,} files {format_size(size):>10}") print("=" * 70) - print(f"{'TOTAL':<15} {total_files:>6,} files {format_size(total_size):>10}") + print(f"{'TOTAL':<20} {total_files:>8,} files {format_size(total_size):>10}") print("=" * 70) +def get_isaaclab_essential_categories() -> list[str]: + return ["IsaacLab", "Props", "Environments", "Materials"] + + def main(): parser = argparse.ArgumentParser( - description="Download Isaac Lab assets from Nucleus to local storage for offline training", + description="Download Isaac assets for offline use", formatter_class=argparse.RawDescriptionHelpFormatter, ) - parser.add_argument( - "--categories", - nargs="+", - choices=list(ASSET_CATEGORIES.keys()) + ["all"], - default=["all"], - help="Asset categories to download (default: all)", - ) - parser.add_argument( - "--subset", type=str, help="Download only specific subset (e.g., 'ANYbotics' or 'Unitree' for robots)" - ) - parser.add_argument("--verify-only", action="store_true", help="Only verify existing downloads without downloading") + parser.add_argument("--categories", nargs="+", default=["isaaclab-essentials"]) + parser.add_argument("--subset", type=str, help="Subset within category") + parser.add_argument("--verify-only", action="store_true") + parser.add_argument("--overwrite", action="store_true") + parser.add_argument("--list", action="store_true") args = parser.parse_args() @@ -289,37 +230,44 @@ def main(): print("\n" + "=" * 70) print("🚀 Isaac Lab Offline Asset Downloader") print("=" * 70) - print(f"Isaac Sim Assets: {ISAAC_NUCLEUS_DIR}") - print(f"Isaac Lab Assets: {ISAACLAB_NUCLEUS_DIR}") - print(f"Local Target: {OFFLINE_ASSETS_DIR}") + print(f"Nucleus: {ISAAC_NUCLEUS_DIR}") + print(f"Local: {OFFLINE_ASSETS_DIR}") print("=" * 70) + if args.list: + categories = list_remote_directories(ISAAC_NUCLEUS_DIR) + print("\n📂 Available under Isaac/:") + for cat in categories: + desc = KNOWN_CATEGORIES.get(cat, "") + print(f" • {cat:<20} {desc}") + return + if args.verify_only: verify_downloads() return - # Determine which categories to download - categories = list(ASSET_CATEGORIES.keys()) if "all" in args.categories else args.categories + if "all" in args.categories: + categories = list_remote_directories(ISAAC_NUCLEUS_DIR) + elif "isaaclab-essentials" in args.categories: + categories = get_isaaclab_essential_categories() + print("\n📋 Isaac Lab essentials:") + for cat in categories: + print(f" • {cat}") + else: + categories = args.categories - print(f"\n📋 Selected Categories: {', '.join(categories)}") if args.subset: - print(f"🔍 Subset Filter: {args.subset}") + print(f"🔍 Subset: {args.subset}") - # Calculate total download size - print("\n📊 Calculating download size...") + print("\n📊 Calculating size...") total_files = 0 total_size = 0 for category in categories: - category_info = ASSET_CATEGORIES[category] - base_path = category_info["base"] - remote_dir = f"{base_path}/{category}" - - # Apply subset filter - if args.subset and category == "Robots": + remote_dir = f"{ISAAC_NUCLEUS_DIR}/{category}" + if args.subset: remote_dir = f"{remote_dir}/{args.subset}" - # Check if directory exists result, _ = omni.client.stat(remote_dir) if result == omni.client.Result.OK: files, size = get_remote_directory_info(remote_dir) @@ -327,41 +275,29 @@ def main(): total_size += size print(f" {category}: {files:,} files ({format_size(size)})") - print("\n" + "=" * 70) - print(f"📦 Total Download: {total_files:,} files ({format_size(total_size)})") - print("=" * 70) + if total_files == 0: + print("\n❌ No files to download") + return - # Confirm before proceeding - response = input("\nProceed with download? [y/N]: ") + print(f"\n📦 Total: {total_files:,} files ({format_size(total_size)})") + + response = input("\nProceed? [y/N]: ") if response.lower() not in ["y", "yes"]: - print("❌ Download cancelled") + print("❌ Cancelled") return - # Download each category - print("\n🔽 Starting download...") + print("\n🔽 Downloading...") for category in categories: try: - download_asset_category(category, args.subset) + download_category(category, args.subset, args.overwrite) except KeyboardInterrupt: - print("\n\n⚠️ Download interrupted by user") + print("\n⚠️ Interrupted") raise except Exception as e: - print(f"\n❌ Error downloading {category}: {e}") - continue + print(f"❌ Error: {category}: {e}") - # Show final summary verify_downloads() - - print("\n" + "=" * 70) - print("✅ Download Complete!") - print("=" * 70) - print(f"\nOffline assets are available in: {OFFLINE_ASSETS_DIR}") - print("\n💡 Usage: Add --offline flag to your training commands") - print("\nExample:") - print(" ./isaaclab.sh -p scripts/reinforcement_learning/rsl_rl/train.py \\") - print(" --task Isaac-Velocity-Flat-Unitree-Go2-v0 \\") - print(" --num_envs 128 \\") - print(" --offline\n") + print("\n✅ Complete! Use --offline flag with Isaac Lab commands.\n") finally: simulation_app.close() diff --git a/source/isaaclab/isaaclab/app/app_launcher.py b/source/isaaclab/isaaclab/app/app_launcher.py index 16f352dd29e..885af0d221c 100644 --- a/source/isaaclab/isaaclab/app/app_launcher.py +++ b/source/isaaclab/isaaclab/app/app_launcher.py @@ -116,6 +116,7 @@ def __init__(self, launcher_args: argparse.Namespace | dict | None = None, **kwa self._offscreen_render: bool # 0: Disabled, 1: Enabled self._sim_experience_file: str # Experience file to load self._offline: bool # 0: Disabled, 1: Enabled + self._offline_strict: bool # True: fail on missing assets, False: fall back to Nucleus # Exposed to train scripts self.device_id: int # device ID for GPU simulation (defaults to 0) @@ -184,6 +185,11 @@ def offline(self) -> bool: """Whether offline asset resolution is enabled.""" return self._offline + @property + def offline_strict(self) -> bool: + """Whether offline mode is strict (fails on missing assets) or permissive (falls back to Nucleus).""" + return self._offline_strict + """ Operations. """ @@ -384,11 +390,22 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: action="store_true", default=AppLauncher._APPLAUNCHER_CFG_INFO["offline"][1], help=( - "Enable offline asset resolution. When enabled, asset paths from Nucleus/S3 " + "Enable offline asset resolution (strict mode). When enabled, asset paths from Nucleus/S3 " "servers are automatically redirected to local storage (offline_assets/). " + "Fails immediately if an asset is not found locally. " "Can also be enabled via the OFFLINE environment variable." ), ) + arg_group.add_argument( + "--offline-permissive", + action="store_true", + default=AppLauncher._APPLAUNCHER_CFG_INFO["offline_permissive"][1], + help=( + "Enable offline asset resolution (permissive mode). Same as --offline but falls back " + "to Nucleus if an asset is not found locally (may timeout if truly offline). " + "Can also be enabled via the OFFLINE_PERMISSIVE environment variable." + ), + ) # special flag for backwards compatibility # Corresponding to the beginning of the function, @@ -410,6 +427,7 @@ def add_app_launcher_args(parser: argparse.ArgumentParser) -> None: "experience": ([str], ""), "rendering_mode": ([str], "balanced"), "offline": ([bool], False), + "offline_permissive": ([bool], False), } """A dictionary of arguments added manually by the :meth:`AppLauncher.add_app_launcher_args` method. @@ -731,26 +749,34 @@ def _resolve_device_settings(self, launcher_args: dict): def _resolve_offline_settings(self, launcher_args: dict): """Resolve offline mode related settings. - This method checks both the --offline CLI argument and the OFFLINE - environment variable. CLI argument takes precedence. + This method checks both the --offline/--offline-permissive CLI arguments + and the OFFLINE/OFFLINE_PERMISSIVE environment variables. + CLI arguments take precedence. """ - # Check environment variable + # Check environment variables offline_env = os.environ.get("OFFLINE", "0").lower() in ("1", "true", "yes") + offline_permissive_env = os.environ.get("OFFLINE_PERMISSIVE", "0").lower() in ("1", "true", "yes") - # Check CLI argument (pop it so it doesn't get passed to SimulationApp) + # Check CLI arguments (pop them so they don't get passed to SimulationApp) offline_arg = launcher_args.pop("offline", False) + offline_permissive_arg = launcher_args.pop("offline_permissive", False) - # CLI argument takes precedence over environment variable - self._offline = offline_arg or offline_env + # Determine if offline mode is enabled (any flag/env var) + self._offline = offline_arg or offline_permissive_arg or offline_env or offline_permissive_env + + # Determine strict mode: strict unless permissive is explicitly requested + # --offline or OFFLINE=1 -> strict mode (fail on missing assets) + # --offline-permissive or OFFLINE_PERMISSIVE=1 -> permissive mode (fall back to Nucleus) + self._offline_strict = not (offline_permissive_arg or offline_permissive_env) if self._offline: - print("[INFO][AppLauncher]: Offline mode ENABLED") - if offline_arg and offline_env: - print("[INFO][AppLauncher]: Both --offline flag and OFFLINE env var are set") - elif offline_arg: - print("[INFO][AppLauncher]: Enabled via --offline flag") + mode = "STRICT" if self._offline_strict else "PERMISSIVE" + print(f"[INFO][AppLauncher]: Offline mode ENABLED ({mode})") + + if self._offline_strict: + print("[INFO][AppLauncher]: Missing assets will cause an error (no Nucleus fallback)") else: - print("[INFO][AppLauncher]: Enabled via OFFLINE environment variable") + print("[INFO][AppLauncher]: Missing assets will fall back to Nucleus (may timeout if offline)") def _resolve_experience_file(self, launcher_args: dict): """Resolve experience file related settings.""" @@ -820,7 +846,7 @@ def _setup_offline_mode(self): try: from isaaclab.utils import setup_offline_mode - setup_offline_mode() + setup_offline_mode(strict=self._offline_strict) print("[INFO][AppLauncher]: Offline asset resolution configured") except ImportError as e: print(f"[WARN][AppLauncher]: Could not enable offline mode: {e}") diff --git a/source/isaaclab/isaaclab/utils/asset_resolver.py b/source/isaaclab/isaaclab/utils/asset_resolver.py index b096a0c84a2..6d8384abae5 100644 --- a/source/isaaclab/isaaclab/utils/asset_resolver.py +++ b/source/isaaclab/isaaclab/utils/asset_resolver.py @@ -6,18 +6,27 @@ """ Offline Asset Resolver for Isaac Lab. -This module provides utilities to transparently redirect asset paths from Nucleus/S3 -to local storage when running in offline mode. +This module redirects Nucleus asset paths to a local mirror of the Isaac/ directory. + +Path Resolution: + Nucleus URL: .../Assets/Isaac/5.1/Isaac/{path} + Local Path: offline_assets/{path} + + Examples: + - /Isaac/IsaacLab/Robots/Unitree/Go2/go2.usd → offline_assets/IsaacLab/Robots/Unitree/Go2/go2.usd + - /Isaac/Props/UIElements/arrow_x.usd → offline_assets/Props/UIElements/arrow_x.usd + +Usage: + Automatically enabled via AppLauncher --offline flag, or manually: + + from isaaclab.utils import setup_offline_mode + setup_offline_mode() """ from __future__ import annotations import os import re -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - pass class OfflineAssetResolver: @@ -25,55 +34,60 @@ class OfflineAssetResolver: _instance: OfflineAssetResolver | None = None _enabled: bool = False + _strict: bool = True # If True, fail immediately when asset not found locally _initialized: bool = False - _spawn_hooks_installed: bool = False - _env_hooks_installed: bool = False + _hooks_installed: bool = False _offline_assets_dir: str | None = None - _isaac_nucleus_dir: str | None = None - _isaaclab_nucleus_dir: str | None = None + _failed_assets: list[tuple[str, str]] = [] + _warned_assets: set[str] = set() # Track assets we've already warned about def __new__(cls): if cls._instance is None: cls._instance = super().__new__(cls) + cls._instance._failed_assets = [] + cls._instance._warned_assets = set() return cls._instance def _initialize(self): - """Initialize the resolver with environment paths.""" + """Initialize the resolver.""" if self._initialized: return - try: - import carb.settings - - settings = carb.settings.get_settings() - nucleus_root = settings.get("/persistent/isaac/asset_root/default") - except (ImportError, RuntimeError): - nucleus_root = None - self.isaaclab_path = os.environ.get("ISAACLAB_PATH", os.getcwd()) self._offline_assets_dir = os.path.join(self.isaaclab_path, "offline_assets") - - if nucleus_root: - self._isaac_nucleus_dir = f"{nucleus_root}/Isaac" - self._isaaclab_nucleus_dir = f"{nucleus_root}/Isaac/IsaacLab" - + self._failed_assets = [] + self._warned_assets = set() self._initialized = True print("[OfflineAssetResolver] Initialized") - print(f" Offline assets dir: {self._offline_assets_dir}") - if self._isaaclab_nucleus_dir: - print(f" Nucleus base: {self._isaaclab_nucleus_dir}") + print(f" Offline assets: {self._offline_assets_dir}") + + def enable(self, strict: bool = True): + """ + Enable offline asset resolution. - def enable(self): - """Enable offline asset resolution.""" + Args: + strict: If True (default), raise an error when an asset is not found locally. + If False, fall back to Nucleus (may cause timeouts if offline). + """ self._initialize() self._enabled = True - print("[OfflineAssetResolver] Offline mode ENABLED") - print(f" All assets will be loaded from: {self._offline_assets_dir}") + self._strict = strict + self._failed_assets = [] + self._warned_assets = set() + + mode = "STRICT" if strict else "permissive" + print(f"[OfflineAssetResolver] Offline mode ENABLED ({mode})") + print(f" Local mirror of Isaac/ at: {self._offline_assets_dir}") + + if strict: + print(" Missing assets will cause an error (no Nucleus fallback)") + else: + print(" Missing assets will fall back to Nucleus (may timeout if offline)") if not os.path.exists(self._offline_assets_dir): - print("[OfflineAssetResolver] ⚠️ WARNING: Offline assets directory not found!") - print(" Run: ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories all") + print("[OfflineAssetResolver] ⚠️ WARNING: offline_assets directory not found!") + print(" Run: ./isaaclab.sh -p scripts/offline_setup/download_assets.py") def disable(self): """Disable offline asset resolution.""" @@ -83,82 +97,124 @@ def disable(self): def is_enabled(self) -> bool: return self._enabled - def are_spawn_hooks_installed(self) -> bool: - return self._spawn_hooks_installed + def is_strict(self) -> bool: + return self._strict + + def are_hooks_installed(self) -> bool: + return self._hooks_installed - def set_spawn_hooks_installed(self, value: bool = True): - self._spawn_hooks_installed = value + def set_hooks_installed(self, value: bool = True): + self._hooks_installed = value - def are_env_hooks_installed(self) -> bool: - return self._env_hooks_installed + def add_failed_asset(self, original_path: str, relative_path: str): + """Track a failed asset resolution.""" + for _, rel in self._failed_assets: + if rel == relative_path: + return + self._failed_assets.append((original_path, relative_path)) - def set_env_hooks_installed(self, value: bool = True): - self._env_hooks_installed = value + def get_failed_assets(self) -> list[tuple[str, str]]: + """Get list of (original_path, relative_path) for failed resolutions.""" + return self._failed_assets.copy() def resolve_path(self, asset_path: str) -> str: - """Resolve an asset path to offline storage if available.""" + """ + Resolve an asset path to offline storage. + """ if not self._enabled or not isinstance(asset_path, str) or not asset_path: return asset_path - path_to_convert = self._extract_relative_path(asset_path) + # Skip if already a local path + if os.path.exists(asset_path): + return asset_path + + # Extract the path relative to /Isaac/ + relative_path = self._extract_relative_path(asset_path) - if path_to_convert: - offline_path = os.path.join(self._offline_assets_dir, path_to_convert) + if relative_path: + offline_path = os.path.join(self._offline_assets_dir, relative_path) # Try exact path first if os.path.exists(offline_path): - print(f"[OfflineAssetResolver] ✓ Using offline: {path_to_convert}") + print(f"[OfflineAssetResolver] ✓ Using offline: {relative_path}") return offline_path - # Try case-insensitive fallback (handles ANYmal-D vs anymal_d mismatches) - resolved = self._find_case_insensitive(path_to_convert) + # Try case-insensitive fallback + resolved = self._find_case_insensitive(relative_path) if resolved: print(f"[OfflineAssetResolver] ✓ Using offline (case-adjusted): {resolved}") return os.path.join(self._offline_assets_dir, resolved) - print(f"[OfflineAssetResolver] ⚠️ Not found locally: {path_to_convert}") - print("[OfflineAssetResolver] Falling back to Nucleus") + # Asset not found locally + self._handle_missing_asset(asset_path, relative_path) return asset_path - def _find_case_insensitive(self, relative_path: str) -> str | None: - """ - Find a file using case-insensitive matching. - - Handles mismatches like: - - ANYmal-D vs anymal_d - - ANYmal-B vs anymal_b + def _handle_missing_asset(self, original_path: str, relative_path: str): + """Handle a missing asset - either error (strict) or warn (permissive).""" + # Only warn once per asset + if relative_path in self._warned_assets: + return + self._warned_assets.add(relative_path) + self.add_failed_asset(original_path, relative_path) + + # Build download command + parts = relative_path.split("/") + if len(parts) >= 2 and parts[0] == "IsaacLab": + download_cmd = f"./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories IsaacLab/{parts[1]}" + else: + download_cmd = f"./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories {parts[0]}" + + if self._strict: + # Strict mode: raise an error + error_msg = f""" +{"=" * 70} +[OfflineAssetResolver] ✗ ASSET NOT FOUND (offline mode) +{"=" * 70} +Missing: {relative_path} +Expected: {os.path.join(self._offline_assets_dir, relative_path)} + +To download this asset, run: + {download_cmd} + +Or to allow Nucleus fallback (may timeout if offline), use: + --offline-permissive instead of --offline +{"=" * 70} +""" + raise FileNotFoundError(error_msg) + # Permissive mode: warn and fall back to Nucleus + print(f"\n[OfflineAssetResolver] ⚠️ MISSING: {relative_path}") + print("[OfflineAssetResolver] Falling back to Nucleus (may timeout if offline)") + print(f"[OfflineAssetResolver] Download with: {download_cmd}\n") - Args: - relative_path: The relative path to find (e.g., "Robots/ANYbotics/ANYmal-D/anymal_d.usd") + def _extract_relative_path(self, asset_path: str) -> str | None: + """Extract the path relative to /Isaac/ from a Nucleus URL.""" + match = re.search(r"/Assets/Isaac/[\d.]+/Isaac/(.+)$", asset_path) + if match: + return match.group(1) + return None - Returns: - The actual relative path if found, None otherwise - """ + def _find_case_insensitive(self, relative_path: str) -> str | None: + """Find a file using case-insensitive matching.""" parts = relative_path.replace("\\", "/").split("/") current_dir = self._offline_assets_dir resolved_parts = [] - for i, part in enumerate(parts): + for part in parts: if not os.path.exists(current_dir): return None - # For the last part (filename), do exact match first then case-insensitive - # For directories, try to find a case-insensitive match try: entries = os.listdir(current_dir) except (OSError, PermissionError): return None - # First try exact match if part in entries: resolved_parts.append(part) current_dir = os.path.join(current_dir, part) continue - # Try case-insensitive match part_lower = part.lower() - # Also try with common substitutions (hyphen <-> underscore) part_normalized = part_lower.replace("-", "_") found = None @@ -176,35 +232,12 @@ def _find_case_insensitive(self, relative_path: str) -> str | None: else: return None - # Verify the final path exists final_path = os.path.join(self._offline_assets_dir, *resolved_parts) if os.path.exists(final_path): return "/".join(resolved_parts) return None - def _extract_relative_path(self, asset_path: str) -> str | None: - """Extract relative path from various Nucleus URL formats.""" - # Pattern 1: Isaac Lab assets with version - match = re.search(r"/Assets/Isaac/[\d.]+/Isaac/IsaacLab/(.+)$", asset_path) - if match: - return match.group(1) - - # Pattern 2: General Isaac assets with version - match = re.search(r"/Assets/Isaac/[\d.]+/Isaac/(?!IsaacLab)(.+)$", asset_path) - if match: - return match.group(1) - - # Pattern 3: Isaac Lab assets without version - if self._isaaclab_nucleus_dir and asset_path.startswith(self._isaaclab_nucleus_dir): - return asset_path[len(self._isaaclab_nucleus_dir) :].lstrip("/") - - # Pattern 4: General Isaac assets without version - if self._isaac_nucleus_dir and asset_path.startswith(self._isaac_nucleus_dir): - return asset_path[len(self._isaac_nucleus_dir) :].lstrip("/") - - return None - def get_offline_assets_dir(self) -> str: self._initialize() return self._offline_assets_dir @@ -219,85 +252,74 @@ def get_offline_assets_dir(self) -> str: # ============================================================================= -def enable_offline_mode(): - """Enable offline asset resolution globally.""" - _resolver.enable() +def enable_offline_mode(strict: bool = True): + """ + Enable offline asset resolution. + + Args: + strict: If True (default), raise an error when an asset is not found locally. + If False, fall back to Nucleus (may cause timeouts if offline). + """ + _resolver.enable(strict=strict) def disable_offline_mode(): - """Disable offline asset resolution globally.""" _resolver.disable() def is_offline_mode_enabled() -> bool: - """Check if offline mode is currently enabled.""" return _resolver.is_enabled() +def is_offline_mode_strict() -> bool: + return _resolver.is_strict() + + def resolve_asset_path(asset_path: str) -> str: - """Resolve an asset path, redirecting to offline storage if enabled.""" return _resolver.resolve_path(asset_path) def get_offline_assets_dir() -> str: - """Get the offline assets directory path.""" return _resolver.get_offline_assets_dir() -def install_spawn_hooks(): - """Install path resolution hooks on Isaac Lab spawn config classes.""" - if _resolver.are_spawn_hooks_installed(): - return +def get_failed_assets() -> list[tuple[str, str]]: + return _resolver.get_failed_assets() + +def _install_spawn_hooks(): + """Install hooks on spawn functions (Level 1).""" try: - import isaaclab.sim as sim_utils + from isaaclab.sim.spawners.from_files import from_files as from_files_module - # Patch UsdFileCfg - if hasattr(sim_utils, "UsdFileCfg"): - original_usd_init = sim_utils.UsdFileCfg.__init__ + if hasattr(from_files_module, "spawn_from_usd"): + original_spawn_from_usd = from_files_module.spawn_from_usd - def patched_usd_init(self, *args, **kwargs): - original_usd_init(self, *args, **kwargs) - if hasattr(self, "usd_path") and is_offline_mode_enabled(): - self.usd_path = resolve_asset_path(self.usd_path) + def patched_spawn_from_usd(prim_path, cfg, *args, **kwargs): + if is_offline_mode_enabled() and hasattr(cfg, "usd_path"): + cfg.usd_path = resolve_asset_path(cfg.usd_path) + return original_spawn_from_usd(prim_path, cfg, *args, **kwargs) - sim_utils.UsdFileCfg.__init__ = patched_usd_init - print("[OfflineAssetResolver] Installed UsdFileCfg path hook") + from_files_module.spawn_from_usd = patched_spawn_from_usd + print("[OfflineAssetResolver] Installed spawn_from_usd hook") - # Patch GroundPlaneCfg - if hasattr(sim_utils, "GroundPlaneCfg"): - original_ground_init = sim_utils.GroundPlaneCfg.__init__ + if hasattr(from_files_module, "spawn_from_urdf"): + original_spawn_from_urdf = from_files_module.spawn_from_urdf - def patched_ground_init(self, *args, **kwargs): - original_ground_init(self, *args, **kwargs) - if hasattr(self, "usd_path") and is_offline_mode_enabled(): - original_path = self.usd_path - self.usd_path = resolve_asset_path(self.usd_path) - if self.usd_path != original_path: - print(f"[OfflineAssetResolver] ✓ Resolved ground plane: {os.path.basename(self.usd_path)}") + def patched_spawn_from_urdf(prim_path, cfg, *args, **kwargs): + if is_offline_mode_enabled() and hasattr(cfg, "asset_path"): + cfg.asset_path = resolve_asset_path(cfg.asset_path) + return original_spawn_from_urdf(prim_path, cfg, *args, **kwargs) - sim_utils.GroundPlaneCfg.__init__ = patched_ground_init - print("[OfflineAssetResolver] Installed GroundPlaneCfg path hook") + from_files_module.spawn_from_urdf = patched_spawn_from_urdf + print("[OfflineAssetResolver] Installed spawn_from_urdf hook") - # Patch PreviewSurfaceCfg - if hasattr(sim_utils, "PreviewSurfaceCfg"): - original_surface_init = sim_utils.PreviewSurfaceCfg.__init__ - - def patched_surface_init(self, *args, **kwargs): - original_surface_init(self, *args, **kwargs) - if hasattr(self, "texture_file") and is_offline_mode_enabled(): - if self.texture_file: - self.texture_file = resolve_asset_path(self.texture_file) - - sim_utils.PreviewSurfaceCfg.__init__ = patched_surface_init - print("[OfflineAssetResolver] Installed PreviewSurfaceCfg path hook") + except ImportError as e: + print(f"[OfflineAssetResolver] Could not install spawn hooks: {e}") - _resolver.set_spawn_hooks_installed(True) - except ImportError: - print("[OfflineAssetResolver] Could not install spawn hooks - isaaclab.sim not available") - - # Hook read_file() in isaaclab.utils.assets - this is where actuator nets are loaded +def _install_file_hooks(): + """Install hooks on file loading functions (Level 2).""" try: import isaaclab.utils.assets as assets_module @@ -306,41 +328,82 @@ def patched_surface_init(self, *args, **kwargs): def patched_read_file(path: str): if is_offline_mode_enabled(): - resolved_path = resolve_asset_path(path) - return original_read_file(resolved_path) + return original_read_file(resolve_asset_path(path)) return original_read_file(path) assets_module.read_file = patched_read_file - print("[OfflineAssetResolver] Installed read_file path hook") - - except ImportError: - pass - - # Hook retrieve_file_path() - another common entry point for file loading - try: - import isaaclab.utils.assets as assets_module + print("[OfflineAssetResolver] Installed read_file hook") if hasattr(assets_module, "retrieve_file_path"): original_retrieve = assets_module.retrieve_file_path def patched_retrieve_file_path(path: str, *args, **kwargs): if is_offline_mode_enabled(): - resolved_path = resolve_asset_path(path) - return original_retrieve(resolved_path, *args, **kwargs) + return original_retrieve(resolve_asset_path(path), *args, **kwargs) return original_retrieve(path, *args, **kwargs) assets_module.retrieve_file_path = patched_retrieve_file_path print("[OfflineAssetResolver] Installed retrieve_file_path hook") + if hasattr(assets_module, "check_file_path"): + original_check_file_path = assets_module.check_file_path + + def patched_check_file_path(path: str, *args, **kwargs): + if is_offline_mode_enabled(): + return original_check_file_path(resolve_asset_path(path), *args, **kwargs) + return original_check_file_path(path, *args, **kwargs) + + assets_module.check_file_path = patched_check_file_path + print("[OfflineAssetResolver] Installed check_file_path hook") + except ImportError: pass -def install_env_hooks(): - """Install hooks on environment base classes to auto-patch configs.""" - if _resolver.are_env_hooks_installed(): - return +def _install_config_hooks(): + """Install hooks on config classes (Level 3).""" + try: + import isaaclab.sim as sim_utils + if hasattr(sim_utils, "UsdFileCfg"): + original_usd_init = sim_utils.UsdFileCfg.__init__ + + def patched_usd_init(self, *args, **kwargs): + original_usd_init(self, *args, **kwargs) + if hasattr(self, "usd_path") and is_offline_mode_enabled(): + self.usd_path = resolve_asset_path(self.usd_path) + + sim_utils.UsdFileCfg.__init__ = patched_usd_init + print("[OfflineAssetResolver] Installed UsdFileCfg hook") + + if hasattr(sim_utils, "GroundPlaneCfg"): + original_ground_init = sim_utils.GroundPlaneCfg.__init__ + + def patched_ground_init(self, *args, **kwargs): + original_ground_init(self, *args, **kwargs) + if hasattr(self, "usd_path") and is_offline_mode_enabled(): + self.usd_path = resolve_asset_path(self.usd_path) + + sim_utils.GroundPlaneCfg.__init__ = patched_ground_init + print("[OfflineAssetResolver] Installed GroundPlaneCfg hook") + + if hasattr(sim_utils, "PreviewSurfaceCfg"): + original_surface_init = sim_utils.PreviewSurfaceCfg.__init__ + + def patched_surface_init(self, *args, **kwargs): + original_surface_init(self, *args, **kwargs) + if hasattr(self, "texture_file") and is_offline_mode_enabled() and self.texture_file: + self.texture_file = resolve_asset_path(self.texture_file) + + sim_utils.PreviewSurfaceCfg.__init__ = patched_surface_init + print("[OfflineAssetResolver] Installed PreviewSurfaceCfg hook") + + except ImportError: + pass + + +def _install_env_hooks(): + """Install hooks on environment classes (Level 4).""" try: from isaaclab.envs import ManagerBasedEnv @@ -352,8 +415,7 @@ def patched_manager_init(self, cfg, *args, **kwargs): original_manager_init(self, cfg, *args, **kwargs) ManagerBasedEnv.__init__ = patched_manager_init - print("[OfflineAssetResolver] Installed ManagerBasedEnv config hook") - + print("[OfflineAssetResolver] Installed ManagerBasedEnv hook") except ImportError: pass @@ -368,8 +430,7 @@ def patched_direct_init(self, cfg, *args, **kwargs): original_direct_init(self, cfg, *args, **kwargs) DirectRLEnv.__init__ = patched_direct_init - print("[OfflineAssetResolver] Installed DirectRLEnv config hook") - + print("[OfflineAssetResolver] Installed DirectRLEnv hook") except ImportError: pass @@ -384,16 +445,60 @@ def patched_marl_init(self, cfg, *args, **kwargs): original_marl_init(self, cfg, *args, **kwargs) DirectMARLEnv.__init__ = patched_marl_init - print("[OfflineAssetResolver] Installed DirectMARLEnv config hook") + print("[OfflineAssetResolver] Installed DirectMARLEnv hook") + except ImportError: + pass + + +def _install_asset_hooks(): + """Install hooks on asset classes (Level 5).""" + try: + from isaaclab.assets import Articulation + + original_articulation_init = Articulation.__init__ + + def patched_articulation_init(self, cfg, *args, **kwargs): + if is_offline_mode_enabled(): + patch_config_for_offline_mode(cfg) + original_articulation_init(self, cfg, *args, **kwargs) + + Articulation.__init__ = patched_articulation_init + print("[OfflineAssetResolver] Installed Articulation hook") + except ImportError: + pass + + try: + from isaaclab.assets import RigidObject + + original_rigid_init = RigidObject.__init__ + + def patched_rigid_init(self, cfg, *args, **kwargs): + if is_offline_mode_enabled(): + patch_config_for_offline_mode(cfg) + original_rigid_init(self, cfg, *args, **kwargs) + RigidObject.__init__ = patched_rigid_init + print("[OfflineAssetResolver] Installed RigidObject hook") except ImportError: pass - _resolver.set_env_hooks_installed(True) + +def install_hooks(): + """Install ALL path resolution hooks at multiple levels.""" + if _resolver.are_hooks_installed(): + return + + _install_spawn_hooks() + _install_file_hooks() + _install_config_hooks() + _install_env_hooks() + _install_asset_hooks() + + _resolver.set_hooks_installed(True) def _is_nucleus_path(path: str) -> bool: - """Check if a path is a Nucleus/S3 URL that needs resolution.""" + """Check if a path is a Nucleus/S3 URL.""" if not isinstance(path, str): return False return ( @@ -405,18 +510,7 @@ def _is_nucleus_path(path: str) -> bool: def _patch_object_recursive(obj, visited: set, depth: int = 0, max_depth: int = 15) -> int: - """ - Recursively walk through an object and patch any asset paths. - - Args: - obj: Object to patch (config, dataclass, etc.) - visited: Set of already-visited object IDs to prevent infinite loops - depth: Current recursion depth - max_depth: Maximum recursion depth to prevent stack overflow - - Returns: - Number of paths patched - """ + """Recursively patch asset paths in an object.""" if depth > max_depth: return 0 @@ -427,11 +521,9 @@ def _patch_object_recursive(obj, visited: set, depth: int = 0, max_depth: int = patches = 0 - # Skip primitive types and None if obj is None or isinstance(obj, (str, int, float, bool, bytes)): return 0 - # Handle dictionaries if isinstance(obj, dict): for key, value in obj.items(): if isinstance(value, str) and _is_nucleus_path(value): @@ -443,7 +535,6 @@ def _patch_object_recursive(obj, visited: set, depth: int = 0, max_depth: int = patches += _patch_object_recursive(value, visited, depth + 1, max_depth) return patches - # Handle lists/tuples if isinstance(obj, (list, tuple)): for i, item in enumerate(obj): if isinstance(item, str) and _is_nucleus_path(item): @@ -456,18 +547,16 @@ def _patch_object_recursive(obj, visited: set, depth: int = 0, max_depth: int = patches += _patch_object_recursive(item, visited, depth + 1, max_depth) return patches - # Handle objects with __dict__ (dataclasses, configclasses, regular objects) if hasattr(obj, "__dict__"): - # Known asset path attributes to check directly asset_attrs = [ "usd_path", "texture_file", "asset_path", "file_path", "mesh_file", - "network_file", # ActuatorNet LSTM/MLP files - "policy_path", # Policy checkpoint files - "checkpoint_path", # Model checkpoints + "network_file", + "policy_path", + "checkpoint_path", ] for attr in asset_attrs: @@ -480,91 +569,115 @@ def _patch_object_recursive(obj, visited: set, depth: int = 0, max_depth: int = setattr(obj, attr, resolved) patches += 1 except (AttributeError, TypeError): - pass # Read-only attribute + pass - # Recursively process all attributes try: for attr_name in dir(obj): - # Skip private/magic attributes and methods if attr_name.startswith("_"): continue - try: attr_value = getattr(obj, attr_name) - - # Skip callables (methods, functions) if callable(attr_value) and not hasattr(attr_value, "__dict__"): continue - - # Recurse into the attribute patches += _patch_object_recursive(attr_value, visited, depth + 1, max_depth) - except (AttributeError, TypeError, RuntimeError): - continue # Skip attributes that can't be accessed - + continue except (TypeError, RuntimeError): - pass # Some objects don't support dir() + pass return patches -def patch_config_for_offline_mode(env_cfg): - """ - Patch environment configuration to use offline assets. - - This function recursively walks through the ENTIRE environment config tree - and patches ANY asset paths it finds (usd_path, texture_file, etc.). - - This handles: - - Robot USD paths (env_cfg.scene.robot.spawn.usd_path) - - Sky light textures (env_cfg.scene.sky_light.spawn.texture_file) - - Ground planes (env_cfg.scene.terrain.terrain_generator.ground_plane_cfg.usd_path) - - Visualizer markers (env_cfg.commands.*.goal_vel_visualizer_cfg.markers.*.usd_path) - - ANY other nested asset paths - - Args: - env_cfg: Environment configuration object - """ +def patch_config_for_offline_mode(cfg): + """Patch a configuration object to use offline assets.""" if not is_offline_mode_enabled(): return visited = set() - patches = _patch_object_recursive(env_cfg, visited) + patches = _patch_object_recursive(cfg, visited) if patches > 0: - print(f"[OfflineAssetResolver] Patched {patches} pre-loaded config paths") + print(f"[OfflineAssetResolver] Patched {patches} config paths") -def setup_offline_mode(): +def setup_offline_mode(strict: bool = True): """ - Set up offline mode with all hooks and path resolution. + Set up offline mode with all hooks. - This is the main entry point for enabling offline training. It is called - automatically by AppLauncher when the --offline flag is set. + Args: + strict: If True (default), raise an error when an asset is not found locally. + If False, fall back to Nucleus (may cause timeouts if offline). """ - enable_offline_mode() - install_spawn_hooks() - install_env_hooks() + enable_offline_mode(strict=strict) + install_hooks() print("[OfflineAssetResolver] Offline mode fully configured") - print("[OfflineAssetResolver] Environment configs will be auto-patched at creation time") -# For backwards compatibility +def print_summary(): + """Print summary of any failed asset resolutions.""" + failed = get_failed_assets() + if failed: + print("\n" + "=" * 70) + print("[OfflineAssetResolver] ⚠️ MISSING ASSETS SUMMARY") + print("=" * 70) + print(f"The following {len(failed)} asset(s) were not found locally:\n") + + categories = {} + for original, relative in failed: + parts = relative.split("/") + if len(parts) >= 2 and parts[0] == "IsaacLab": + cat = f"IsaacLab/{parts[1]}" + else: + cat = parts[0] if parts else "Unknown" + + if cat not in categories: + categories[cat] = [] + categories[cat].append(relative) + + for cat, assets in sorted(categories.items()): + print(f" {cat}:") + for asset in assets: + print(f" - {asset}") + + print("\nTo download missing assets, run:") + for cat in sorted(categories.keys()): + print(f" ./isaaclab.sh -p scripts/offline_setup/download_assets.py --categories {cat}") + print("=" * 70 + "\n") + else: + print("[OfflineAssetResolver] ✓ All assets resolved successfully") + + +# Legacy aliases +def install_spawn_hooks(): + install_hooks() + + +def install_file_hooks(): + install_hooks() + + +def install_env_hooks(): + install_hooks() + + def install_path_hooks(): - """Alias for install_spawn_hooks() for backwards compatibility.""" - install_spawn_hooks() + install_hooks() -# Export public API __all__ = [ "enable_offline_mode", "disable_offline_mode", "is_offline_mode_enabled", + "is_offline_mode_strict", "resolve_asset_path", "get_offline_assets_dir", + "get_failed_assets", "patch_config_for_offline_mode", + "install_hooks", + "setup_offline_mode", + "print_summary", "install_spawn_hooks", + "install_file_hooks", "install_env_hooks", "install_path_hooks", - "setup_offline_mode", ]