Rate this Page

Source code for torchrl.render.artifacts

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
from __future__ import annotations

import json
import platform
import sys
from pathlib import Path
from typing import Any

import numpy as np
import torch

import torchrl
from tensordict import TensorDictBase
from torchrl.render.backends.pixels import _exclude_pixel_keys
from torchrl.render.checkpoint import checkpoint_hash
from torchrl.render.config import FrameBundle, key_to_string, RenderConfig, RenderResult
from torchrl.render.mujoco_wasm import write_mujoco_wasm_viewer
from torchrl.render.notebook import write_render_notebook
from torchrl.render.video import compose_frame_grid, encode_gif, encode_video, write_png

__all__ = ["write_render_artifact"]


[docs] def write_render_artifact(result: RenderResult, config: RenderConfig) -> RenderResult: """Writes the configured render artifact and sidecar metadata.""" out = _default_out(config) _ensure_writable(out, config) asset_dir = _asset_dir(out, config) asset_dir.mkdir(parents=True, exist_ok=True) checkpoint_digest = result.metadata.get("checkpoint", {}).get("sha256") result.metadata.update( _runtime_metadata(config, asset_dir, checkpoint_digest=checkpoint_digest) ) frame_paths: list[Path] = [] artifact_path: Path | None = out if config.format == "frames": artifact_path = out frame_paths.extend(_write_frames(result.frames, out, config)) elif config.format == "mp4": frame_paths.extend(_write_videos(result.frames, out, config)) elif config.format == "gif": frame_paths.extend(_write_gif(result.frames, out, config)) elif config.format == "npz": _write_npz(result, out, config) elif config.format == "jsonl": _write_jsonl(result, out) elif config.format == "ipynb": result.metadata["asset_dir"] = _relative_asset_dir(asset_dir, out.parent) _write_notebook_assets(result, config, asset_dir) _write_mujoco_wasm_assets(result, config, asset_dir) if _has_any_frames(result.frames): try: frame_paths.extend(_write_videos(result.frames, asset_dir, config)) except Exception as err: warning = f"Could not write notebook video preview: {err}" result.warnings.append(warning) result.metadata.setdefault("warnings", []).append(warning) result.frame_paths = frame_paths _write_json(asset_dir / "metadata.json", result.metadata) if config.metadata is not None: _write_json(Path(config.metadata), result.metadata) write_render_notebook(result, config, out) else: raise ValueError(f"Unsupported render format {config.format!r}.") if config.save_frames and config.format != "frames": frame_dir = ( Path(config.frame_dir) if config.frame_dir is not None else asset_dir / "frames" ) frame_paths.extend(_write_frames(result.frames, frame_dir, config)) if (config.save_rollout or config.save_tensordicts) and config.format != "ipynb": _write_rollout_assets(result, asset_dir, config) _write_json(asset_dir / "config.json", config.to_dict()) if config.format != "ipynb": result.frame_paths = frame_paths _write_json(_metadata_path(out, config), result.metadata) result.artifact_path = artifact_path return result
def _default_out(config: RenderConfig) -> Path: if config.out is not None: return Path(config.out) defaults = { "mp4": "render.mp4", "gif": "render.gif", "frames": "render_frames", "npz": "render_rollouts.npz", "jsonl": "render_events.jsonl", "ipynb": "render_report.ipynb", } base = Path(config.artifact_dir or ".") return base / defaults[config.format] def _asset_dir(out: Path, config: RenderConfig) -> Path: if config.artifact_dir is not None and config.format != "frames": return Path(config.artifact_dir) if config.format == "frames": return out return out.with_suffix("") if out.suffix else out / "assets" def _metadata_path(out: Path, config: RenderConfig) -> Path: if config.metadata is not None: return Path(config.metadata) if out.suffix: return out.with_suffix(out.suffix + ".metadata.json") return out / "metadata.json" def _ensure_writable(path: Path, config: RenderConfig) -> None: if path.exists() and not config.overwrite: raise FileExistsError( f"Refusing to overwrite existing path {path!s}; pass --overwrite." ) parent = path.parent if path.suffix else path parent.mkdir(parents=True, exist_ok=True) def _write_frames( frames: list[list[FrameBundle]], out: Path, config: RenderConfig ) -> list[Path]: out.mkdir(parents=True, exist_ok=True) paths: list[Path] = [] for traj_index, trajectory in enumerate(frames): for bundle in trajectory: for camera, frame in bundle.frames.items(): path = ( out / f"traj_{traj_index:03d}" / camera / f"{bundle.step:06d}.png" ) path.parent.mkdir(parents=True, exist_ok=True) paths.append(write_png(frame, path)) return paths def _write_videos( frames: list[list[FrameBundle]], out: Path, config: RenderConfig ) -> list[Path]: def encode(stream, path): return encode_video(stream, path, config.fps, video_codec=config.video_codec) return _write_stream_artifacts(frames, out, config, suffix=".mp4", encode=encode) def _write_gif( frames: list[list[FrameBundle]], out: Path, config: RenderConfig ) -> list[Path]: def encode(stream, path): return encode_gif(stream, path, config.fps) return _write_stream_artifacts(frames, out, config, suffix=".gif", encode=encode) def _write_stream_artifacts( frames: list[list[FrameBundle]], out: Path, config: RenderConfig, *, suffix: str, encode, ) -> list[Path]: streams = _streams(frames) if not streams: raise RuntimeError( f"No frames were captured, so no {suffix.lstrip('.')} can be written." ) single_file = out.suffix.lower() == suffix and config.camera_layout != "separate" if single_file and len(streams) == 1: key = next(iter(streams)) return [encode(streams[key], out)] if single_file: return [encode(_compose_streams(streams, config.camera_layout), out)] if out.suffix.lower() == suffix: base_dir, stem = out.parent, out.stem + "_" else: out.mkdir(parents=True, exist_ok=True) base_dir, stem = out, "" paths = [] for (traj_index, camera), stream in streams.items(): path = base_dir / f"{stem}traj_{traj_index:03d}_{camera}{suffix}" paths.append(encode(stream, path)) return paths def _write_npz(result: RenderResult, out: Path, config: RenderConfig) -> None: arrays: dict[str, Any] = { "metadata": np.asarray(json.dumps(result.metadata, sort_keys=True)), "config": np.asarray(json.dumps(config.to_dict(), sort_keys=True)), } for index, trajectory in enumerate(result.trajectories): for key, value in _tensor_items(trajectory): arrays[f"traj_{index:03d}/{key}"] = value.detach().cpu().numpy() np.savez_compressed(out, **arrays) def _write_jsonl(result: RenderResult, out: Path) -> None: with out.open("w", encoding="utf-8") as file: file.write(json.dumps({"type": "metadata", "metadata": result.metadata}) + "\n") for item in result.metadata.get("trajectories", []): file.write(json.dumps({"type": "trajectory", **item}) + "\n") for traj_index, trajectory in enumerate(result.frames): for bundle in trajectory: file.write( json.dumps( { "type": "frame", "trajectory_index": traj_index, "step": bundle.step, "cameras": sorted(bundle.frames), "metadata": bundle.metadata, } ) + "\n" ) def _write_notebook_assets( result: RenderResult, config: RenderConfig, asset_dir: Path ) -> None: if ( config.save_rollout or config.save_tensordicts or ( config.format == "ipynb" and config.notebook_rollout_mode in ("saved", "both") ) ): _write_rollout_assets(result, asset_dir, config) _write_json(asset_dir / "config.json", config.to_dict()) def _write_mujoco_wasm_assets( result: RenderResult, config: RenderConfig, asset_dir: Path ) -> None: if not _needs_mujoco_wasm_notebook(config): return if config.mujoco_model_path is None: _append_warning( result, "MuJoCo WASM notebook backend requested but no mujoco_model_path was " "provided; the notebook will include static media only.", ) return viewer_dir = write_mujoco_wasm_viewer( asset_dir / "mujoco_wasm", config.mujoco_model_path, asset_paths=list(config.mujoco_asset_paths), ) result.metadata["mujoco_wasm"] = { "viewer_dir": _relative_asset_dir(viewer_dir, asset_dir), "model_path": str(config.mujoco_model_path), "qpos_key": ( key_to_string(config.mujoco_qpos_key) if config.mujoco_qpos_key is not None else None ), "viewer_port": config.notebook_viewer_port, } def _needs_mujoco_wasm_notebook(config: RenderConfig) -> bool: if config.format != "ipynb": return False if config.notebook_render_backend == "mujoco_wasm": return True return ( config.notebook_render_backend == "auto" and config.mujoco_model_path is not None ) def _append_warning(result: RenderResult, warning: str) -> None: result.warnings.append(warning) result.metadata.setdefault("warnings", []).append(warning) def _write_rollout_assets( result: RenderResult, asset_dir: Path, config: RenderConfig ) -> None: rollouts_dir = asset_dir / "rollouts" rollouts_dir.mkdir(parents=True, exist_ok=True) for index, trajectory in enumerate(result.trajectories): if not config.save_tensordicts: trajectory = _exclude_pixel_keys(trajectory, config) torch.save(trajectory, rollouts_dir / f"traj_{index:03d}.pt") def _runtime_metadata( config: RenderConfig, asset_dir: Path, *, checkpoint_digest: str | None = None, ) -> dict[str, Any]: try: torchrl_version = torchrl.__version__ except Exception: torchrl_version = None checkpoint = {"path": str(config.ckpt), "sha256": checkpoint_digest} if checkpoint_digest is None and config.ckpt.is_file(): checkpoint["sha256"] = checkpoint_hash(config.ckpt) return { "config": config.to_dict(), "command": " ".join(sys.argv), "torchrl_version": torchrl_version, "torch_version": torch.__version__, "python_version": sys.version.split()[0], "platform": platform.platform(), "working_dir": str(Path.cwd()), "checkpoint": checkpoint, "policy": config.to_dict()["policy"], "env": config.to_dict()["env"], "seed": config.seed, "device": str(config.device), "keys": { "obs": key_to_string(config.obs_key), "action": key_to_string(config.action_key), "done": key_to_string(config.done_key), "reward": key_to_string(config.reward_key), "pixels": key_to_string(config.pixel_key), }, "asset_dir": str(asset_dir), "asset_dir_absolute": str(asset_dir.resolve()), } def _streams( frames: list[list[FrameBundle]], ) -> dict[tuple[int, str], list[np.ndarray]]: streams: dict[tuple[int, str], list[np.ndarray]] = {} for traj_index, trajectory in enumerate(frames): for bundle in trajectory: for camera, frame in bundle.frames.items(): streams.setdefault((traj_index, camera), []).append(frame) return streams def _compose_streams( streams: dict[tuple[int, str], list[np.ndarray]], layout: str = "grid", ) -> list[np.ndarray]: keys = sorted(streams) max_len = max(len(streams[key]) for key in keys) composed = [] for frame_index in range(max_len): frame_list = [] for key in keys: stream = streams[key] frame_list.append(stream[min(frame_index, len(stream) - 1)]) composed.append(compose_frame_grid(frame_list, layout)) return composed def _tensor_items(tensordict: TensorDictBase, prefix: str = ""): for key, value in tensordict.items(): name = f"{prefix}.{key}" if prefix else str(key) if isinstance(value, TensorDictBase): yield from _tensor_items(value, name) elif torch.is_tensor(value): yield name, value def _has_any_frames(frames: list[list[FrameBundle]]) -> bool: return any(bundle.frames for trajectory in frames for bundle in trajectory) def _relative_asset_dir(asset_dir: Path, base_dir: Path) -> str: try: return str(asset_dir.relative_to(base_dir)) except ValueError: return str(asset_dir) def _write_json(path: Path, payload: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8" )