"""Script to play RL agent with RSL-RL.""" import os import re import sys from dataclasses import asdict, dataclass from pathlib import Path from typing import Literal import torch import tyro from rsl_rl.runners import OnPolicyRunner from mjlab.envs import ManagerBasedRlEnv from mjlab.rl import RslRlVecEnvWrapper from mjlab.tasks.registry import list_tasks, load_env_cfg, load_rl_cfg, load_runner_cls from mjlab.tasks.tracking.mdp import MotionCommandCfg from mjlab.utils.os import get_checkpoint_path, get_wandb_checkpoint_path from mjlab.utils.torch import configure_torch_backends from mjlab.utils.wrappers import VideoRecorder from mjlab.viewer import NativeMujocoViewer, ViserPlayViewer @dataclass(frozen=True) class ExportConfig: onnx_file: str = "output.onnx" agent: Literal["zero", "random", "trained"] = "trained" registry_name: str | None = None wandb_run_path: str | None = None checkpoint: int | None = None # Select checkpoint by iteration number (e.g. 3000) checkpoint_file: str | None = None motion_file: str | None = None num_envs: int | None = None device: str | None = None video: bool = False video_length: int = 200 video_height: int | None = None video_width: int | None = None camera: int | str | None = None viewer: Literal["auto", "native", "viser"] = "auto" # Internal flag used by demo script. _demo_mode: tyro.conf.Suppress[bool] = False def run_export(task_id: str, cfg: ExportConfig): configure_torch_backends() device = cfg.device or ("cuda:0" if torch.cuda.is_available() else "cpu") env_cfg = load_env_cfg(task_id, play=True) agent_cfg = load_rl_cfg(task_id) DUMMY_MODE = cfg.agent in {"zero", "random"} TRAINED_MODE = not DUMMY_MODE # Check if this is a motion tracking task. is_motion_tracking = ( env_cfg.commands is not None and "motion" in env_cfg.commands and isinstance(env_cfg.commands["motion"], MotionCommandCfg) ) is_tracking_task = is_motion_tracking if is_tracking_task and cfg._demo_mode: # Demo mode: use uniform sampling to see more diversity with num_envs > 1. assert env_cfg.commands is not None motion_cmd = env_cfg.commands["motion"] assert isinstance(motion_cmd, MotionCommandCfg) motion_cmd.sampling_mode = "uniform" if is_tracking_task: assert env_cfg.commands is not None motion_cmd = env_cfg.commands["motion"] assert isinstance(motion_cmd, MotionCommandCfg) # Check if motion file is already set and exists motion_file_already_set = ( hasattr(motion_cmd, 'motion_file') and motion_cmd.motion_file is not None and Path(motion_cmd.motion_file).exists() ) if DUMMY_MODE: if not cfg.registry_name: raise ValueError( "Tracking tasks require `registry_name` when using dummy agents." ) # Check if the registry name includes alias, if not, append ":latest". registry_name = cfg.registry_name if ":" not in registry_name: registry_name = registry_name + ":latest" import wandb api = wandb.Api() artifact = api.artifact(registry_name) motion_cmd.motion_file = str(Path(artifact.download()) / "motion.npz") else: if cfg.motion_file is not None: print(f"[INFO]: Using motion file from CLI: {cfg.motion_file}") motion_cmd.motion_file = cfg.motion_file elif motion_file_already_set: print(f"[INFO]: Using motion file from env config: {motion_cmd.motion_file}") else: # Try to download from wandb artifacts import wandb api = wandb.Api() if cfg.wandb_run_path is None and cfg.checkpoint_file is not None: raise ValueError( "Tracking tasks require `motion_file` when using `checkpoint_file`, " "or provide `wandb_run_path` so the motion artifact can be resolved." ) if cfg.wandb_run_path is not None: wandb_run = api.run(str(cfg.wandb_run_path)) art = next( (a for a in wandb_run.used_artifacts() if a.type == "motions"), None, ) if art is None: raise RuntimeError("No motion artifact found in the run.") motion_cmd.motion_file = str(Path(art.download()) / "motion.npz") log_dir: Path | None = None resume_path: Path | None = None if TRAINED_MODE: log_root_path = (Path("logs") / "rsl_rl" / agent_cfg.experiment_name).resolve() if cfg.checkpoint_file is not None: resume_path = Path(cfg.checkpoint_file) if not resume_path.exists(): raise FileNotFoundError(f"Checkpoint file not found: {resume_path}") print(f"[INFO]: Loading checkpoint: {resume_path.name}") elif cfg.checkpoint is not None: # Select a specific checkpoint iteration, from wandb or local. checkpoint_filename = f"model_{cfg.checkpoint}.pt" if cfg.wandb_run_path is not None: import wandb api = wandb.Api() wandb_run = api.run(str(cfg.wandb_run_path)) run_id = cfg.wandb_run_path.split("/")[-1] download_dir = log_root_path / "wandb_checkpoints" / run_id resume_path = download_dir / checkpoint_filename if resume_path.exists(): print(f"[INFO]: Loading checkpoint: {checkpoint_filename} (run: {run_id}, cached)") else: available = [f.name for f in wandb_run.files() if "model" in f.name] if checkpoint_filename not in available: raise FileNotFoundError( f"Checkpoint '{checkpoint_filename}' not found in wandb run. " f"Available: {sorted(available)}" ) wandb_run.file(checkpoint_filename).download(str(download_dir), replace=True) print(f"[INFO]: Loading checkpoint: {checkpoint_filename} (run: {run_id}, downloaded)") else: resume_path = get_checkpoint_path( log_root_path, checkpoint=re.escape(checkpoint_filename) ) print(f"[INFO]: Loading checkpoint: {resume_path.name}") else: if cfg.wandb_run_path is None: raise ValueError( "`wandb_run_path` is required when `checkpoint_file` is not provided." ) resume_path, was_cached = get_wandb_checkpoint_path( log_root_path, Path(cfg.wandb_run_path) ) # Extract run_id and checkpoint name from path for display. run_id = resume_path.parent.name checkpoint_name = resume_path.name cached_str = "cached" if was_cached else "downloaded" print( f"[INFO]: Loading checkpoint: {checkpoint_name} (run: {run_id}, {cached_str})" ) log_dir = resume_path.parent if cfg.num_envs is not None: env_cfg.scene.num_envs = cfg.num_envs if cfg.video_height is not None: env_cfg.viewer.height = cfg.video_height if cfg.video_width is not None: env_cfg.viewer.width = cfg.video_width render_mode = "rgb_array" if (TRAINED_MODE and cfg.video) else None if cfg.video and DUMMY_MODE: print( "[WARN] Video recording with dummy agents is disabled (no checkpoint/log_dir)." ) env = ManagerBasedRlEnv(cfg=env_cfg, device=device, render_mode=render_mode) if TRAINED_MODE and cfg.video: print("[INFO] Recording videos during play") assert log_dir is not None # log_dir is set in TRAINED_MODE block env = VideoRecorder( env, video_folder=log_dir / "videos" / "play", step_trigger=lambda step: step == 0, video_length=cfg.video_length, disable_logger=True, ) env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions) if DUMMY_MODE: action_shape: tuple[int, ...] = env.unwrapped.action_space.shape # type: ignore if cfg.agent == "zero": class PolicyZero: def __call__(self, obs) -> torch.Tensor: del obs return torch.zeros(action_shape, device=env.unwrapped.device) policy = PolicyZero() else: class PolicyRandom: def __call__(self, obs) -> torch.Tensor: del obs return 2 * torch.rand(action_shape, device=env.unwrapped.device) - 1 policy = PolicyRandom() else: runner_cls = load_runner_cls(task_id) or OnPolicyRunner runner = runner_cls(env, asdict(agent_cfg), device=device) runner.load(str(resume_path), map_location=device) policy = runner.get_inference_policy(device=device) # mjlab 1.3.0: ONNX export + metadata moved to mjlab.rl.exporter_utils and # the runner's built-in export_policy_to_onnx. Observation normalization is # baked into the exported graph automatically — EmpiricalNormalization is a # submodule of the policy's MLPModel (obs_normalization=True in RslRlModelCfg), # so export_policy_to_onnx emits actor(normalizer(obs)). No manual normalizer # handling needed (the old export_velocity_policy_as_onnx path is gone). from mjlab.rl.exporter_utils import get_base_metadata, attach_metadata_to_onnx onnx_path = os.path.abspath(cfg.onnx_file) path = os.path.dirname(onnx_path) filename = os.path.basename(onnx_path) runner.export_policy_to_onnx(path, filename) metadata = get_base_metadata(runner.env.unwrapped, run_path=cfg.checkpoint_file) attach_metadata_to_onnx(onnx_path, metadata) print(f"Written {onnx_path}") env.close() def main(): # Parse first argument to choose the task. # Import tasks to populate the registry. import mjlab.tasks # noqa: F401 all_tasks = list_tasks() chosen_task, remaining_args = tyro.cli( tyro.extras.literal_type_from_choices(all_tasks), add_help=False, return_unknown_args=True, ) # Parse the rest of the arguments + allow overriding env_cfg and agent_cfg. agent_cfg = load_rl_cfg(chosen_task) args = tyro.cli( ExportConfig, args=remaining_args, default=ExportConfig(), prog=sys.argv[0] + f" {chosen_task}", config=( tyro.conf.AvoidSubcommands, tyro.conf.FlagConversionOff, ), ) del remaining_args, agent_cfg run_export(chosen_task, args) if __name__ == "__main__": main()