Skip to content

Commit 9ed6c9f

Browse files
Episode Recorder (#810)
## Summary Introduce an `EpisodeRecorderManager` that records per-episode data. ## Detailed description - Adds a per-episode data recorder to our `env` that accumulates per-episode data and optionally writes it to disk. - Users can register custom `EpisodeRecorderTermCfgs` to record their custom data. - Implemented 2 basic recorders. - core metadata recorder (env_id, episode_idx, success, etc.) - variations recorder (records samples for the variations) - Added the recorded meta data into the evaluation report. ## Note - Getting at the per environment success flag requires overriding internal an internal method of `IsaacLabManagerBasedRLEnv` ## Not done - Gracefully handle rebuilds and `run_in_chunks`. Will be handled in later MRs. --------- Signed-off-by: alex <amillane@nvidia.com> Co-authored-by: aiguldzh-nvidia <adzhumamurat@nvidia.com>
1 parent 0644724 commit 9ed6c9f

29 files changed

Lines changed: 910 additions & 154 deletions

docs/pages/quickstart/first_experiments/running_a_real_policy/gr00t.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ episode; the runner writes an ``index.html`` which is then served over HTTP.
153153
python isaaclab_arena/evaluation/eval_runner.py \
154154
--viz kit \
155155
--eval_jobs_config isaaclab_arena_environments/eval_jobs_configs/droid_pnp_srl_gr00t_jobs_config.json \
156-
--video_base_dir ./output \
156+
--output_base_dir ./output \
157157
--record_camera_video --serve_evaluation_report
158158
159159
You can also (re)build and serve a report later by pointing the standalone tool at the output

docs/pages/quickstart/first_experiments/running_a_real_policy/openpi.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ episode; the runner writes an ``index.html`` which is then served over HTTP.
162162
python isaaclab_arena/evaluation/eval_runner.py \
163163
--viz kit \
164164
--eval_jobs_config isaaclab_arena_environments/eval_jobs_configs/droid_pnp_srl_openpi_jobs_config.json \
165-
--video_base_dir ./output \
165+
--output_base_dir ./output \
166166
--record_camera_video --serve_evaluation_report
167167
168168
You can also (re)build and serve a report later by pointing the standalone tool at the output

isaaclab_arena/environments/arena_env_builder.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
make_progress_tracking_events_cfg,
3535
make_progress_tracking_recorder_cfg,
3636
)
37+
from isaaclab_arena.recording.common_terms import CoreEpisodeRecorderTermCfg, VariationEpisodeRecorderTermCfg
38+
from isaaclab_arena.recording.episode_recorder_manager import EpisodeRecorderTermCfg
3739
from isaaclab_arena.relations.placement_events import PLACEMENT_RESET_EVENT_NAME
3840
from isaaclab_arena.tasks.no_task import NoTask
3941
from isaaclab_arena.utils.configclass import combine_configclass_instances, make_configclass
@@ -152,14 +154,30 @@ def _modify_recorder_cfg_dataset_filename(self, recorder_cfg: RecorderManagerBas
152154
)
153155
return recorder_cfg
154156

155-
@staticmethod
156-
def _metrics_to_metrics_cfg(metrics: list[MetricBase] | None) -> object | None:
157+
def _compose_metrics_cfg(self, metrics: list[MetricBase] | None) -> object | None:
157158
"""Build a configclass container with one ``MetricTermCfg`` field per metric."""
158159
if not metrics:
159160
return None
160161
fields = [(m.name, MetricTermCfg, m.get_metric_term_cfg()) for m in metrics]
161162
return make_configclass("MetricsCfg", fields)()
162163

164+
def _compose_episode_recorders_cfg(self, extra_terms: dict[str, EpisodeRecorderTermCfg] | None = None) -> object:
165+
"""Build a configclass container with one EpisodeRecorderTermCfg field per episode recorder term.
166+
167+
Note that this function automatically adds the core and variations terms.
168+
"""
169+
fields = [
170+
("core", EpisodeRecorderTermCfg, CoreEpisodeRecorderTermCfg()),
171+
("variations", EpisodeRecorderTermCfg, VariationEpisodeRecorderTermCfg()),
172+
]
173+
for name, term_cfg in (extra_terms or {}).items():
174+
assert name not in (
175+
"core",
176+
"variations",
177+
), f"Episode recorder term name '{name}' collides with a built-in term."
178+
fields.append((name, EpisodeRecorderTermCfg, term_cfg))
179+
return make_configclass("EpisodeRecorderManagerCfg", fields)()
180+
163181
def compose_manager_cfg(self) -> tuple[IsaacLabArenaManagerBasedRLEnvCfg, dict[str, Any]]:
164182
"""Return the base ManagerBased cfg and the env kwargs (no registration).
165183
@@ -240,7 +258,7 @@ def compose_manager_cfg(self) -> tuple[IsaacLabArenaManagerBasedRLEnvCfg, dict[s
240258
elif isinstance(device_cfg, DeviceCfg):
241259
teleop_devices_cfg = DevicesCfg(devices={self.arena_env.teleop_device.name: device_cfg})
242260
metrics = task.get_metrics()
243-
metrics_cfg = self._metrics_to_metrics_cfg(metrics)
261+
metrics_cfg = self._compose_metrics_cfg(metrics)
244262
metrics_recorder_manager_cfg = metrics_to_recorder_manager_cfg(metrics)
245263
progress_tracking_recorder_cfg: Any = (
246264
make_progress_tracking_recorder_cfg(progress_objectives) if progress_objectives else None
@@ -278,11 +296,15 @@ def compose_manager_cfg(self) -> tuple[IsaacLabArenaManagerBasedRLEnvCfg, dict[s
278296
task.get_commands_cfg(),
279297
)
280298

299+
episode_recorders_cfg = self._compose_episode_recorders_cfg(self.arena_env.episode_recorder_terms)
300+
281301
viewer_cfg = task.get_viewer_cfg()
282302

283303
episode_length_s = task.get_episode_length_s()
284304

285-
task_description = task.get_task_description()
305+
# Language instruction is optionally overridden on the CLI.
306+
language_instruction = getattr(self.args, "language_instruction", None)
307+
task_description = language_instruction or task.get_task_description()
286308

287309
# Build the environment configuration
288310
if not self.args.mimic:
@@ -300,6 +322,7 @@ def compose_manager_cfg(self) -> tuple[IsaacLabArenaManagerBasedRLEnvCfg, dict[s
300322
teleop_devices=teleop_devices_cfg,
301323
recorders=recorder_manager_cfg,
302324
metrics=metrics_cfg,
325+
episode_recorders=episode_recorders_cfg,
303326
task_description=task_description,
304327
viewer=viewer_cfg,
305328
)

isaaclab_arena/environments/isaaclab_arena_environment.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from isaaclab_arena.assets.teleop_device_base import TeleopDeviceBase
1313
from isaaclab_arena.embodiments.embodiment_base import EmbodimentBase
1414
from isaaclab_arena.environments.isaaclab_arena_manager_based_env_cfg import IsaacLabArenaManagerBasedRLEnvCfg
15+
from isaaclab_arena.recording.episode_recorder_manager import EpisodeRecorderTermCfg
1516
from isaaclab_arena.scene.scene import Scene
1617
from isaaclab_arena.tasks.task_base import TaskBase
1718

@@ -29,6 +30,7 @@ def __init__(
2930
env_cfg_callback: Callable[IsaacLabArenaManagerBasedRLEnvCfg] | None = None,
3031
rl_framework_entry_point: str | None = None,
3132
rl_policy_cfg: str | None = None,
33+
episode_recorder_terms: dict[str, EpisodeRecorderTermCfg] | None = None,
3234
):
3335
"""
3436
Args:
@@ -46,6 +48,8 @@ def __init__(
4648
``rl_policy_cfg`` is set.
4749
rl_policy_cfg: Import path to the RL policy config class, e.g.
4850
``"my_module:RLPolicyCfg"``.
51+
episode_recorder_terms: Additional per-episode recorder terms to record alongside the
52+
built-in ones, keyed by name.
4953
"""
5054
self.name = name
5155
self.scene = scene
@@ -57,3 +61,4 @@ def __init__(
5761
raise ValueError("rl_framework_entry_point and rl_policy_cfg must both be set or both be None.")
5862
self.rl_framework_entry_point = rl_framework_entry_point
5963
self.rl_policy_cfg = rl_policy_cfg
64+
self.episode_recorder_terms = episode_recorder_terms or {}

isaaclab_arena/environments/isaaclab_arena_manager_based_env.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55

66
from __future__ import annotations
77

8+
from collections.abc import Sequence
9+
810
from isaaclab.envs import ManagerBasedRLEnv
911

1012
from isaaclab_arena.environments.isaaclab_arena_manager_based_env_cfg import IsaacLabArenaManagerBasedRLEnvCfg
1113
from isaaclab_arena.metrics.metric_data import MetricsDataCollection
1214
from isaaclab_arena.metrics.metrics_manager import MetricsManager
15+
from isaaclab_arena.recording.episode_recorder_manager import EpisodeRecorderManager
1316
from isaaclab_arena.variations.variation_recorder import VariationRecorder
1417

1518

@@ -26,6 +29,13 @@ def __init__(
2629
**kwargs,
2730
):
2831
self._variation_recorder = variation_recorder
32+
if variation_recorder is not None:
33+
# Bind so run-time variation draws can be attributed to the current episode index.
34+
variation_recorder.bind_env(self)
35+
# Per-env count of completed episodes; advanced in ``_reset_idx``.
36+
self._episode_counts: dict[int, int] = {}
37+
# The initial reset touches every env before any episode has run; skip it.
38+
self._first_reset = True
2939
super().__init__(cfg=cfg, render_mode=render_mode, **kwargs)
3040

3141
@property
@@ -38,9 +48,41 @@ def variation_recorder(self) -> VariationRecorder | None:
3848
)
3949
return self._variation_recorder
4050

51+
@property
52+
def episode_recorder(self) -> EpisodeRecorderManager:
53+
"""The per-episode recorder."""
54+
return self.episode_recorder_manager
55+
4156
def load_managers(self) -> None:
4257
super().load_managers()
4358
self.metrics_manager = MetricsManager(self.cfg.metrics, self)
59+
self.episode_recorder_manager = EpisodeRecorderManager(self.cfg.episode_recorders, self)
60+
61+
def get_language_instruction(self) -> str | None:
62+
"""Return the language instruction that is passed to the policy."""
63+
return self.cfg.task_description
64+
65+
def get_episode_index(self, env_id: int) -> int:
66+
"""Return the index of the current episode in ``env_id``."""
67+
return self._episode_counts.get(env_id, 0)
68+
69+
def _advance_episode_indices(self, env_ids: Sequence[int]) -> None:
70+
"""Advance the per-env episode counter for each episode in ``env_ids``."""
71+
for env_id in env_ids:
72+
env_id = int(env_id)
73+
self._episode_counts[env_id] = self._episode_counts.get(env_id, 0) + 1
74+
75+
def _reset_idx(self, env_ids: Sequence[int]) -> None:
76+
# The initial reset touches every env before any episode has run; nothing to record or count.
77+
if self._first_reset:
78+
self._first_reset = False
79+
super()._reset_idx(env_ids)
80+
return
81+
# Runs recorder before super() so the just-finished episode is still intact.
82+
self.episode_recorder_manager.record_pre_reset(env_ids)
83+
# Advance before super() so reset-mode variation draws are tagged with the episode they begin.
84+
self._advance_episode_indices(env_ids)
85+
super()._reset_idx(env_ids)
4486

4587
def compute_metrics(self) -> MetricsDataCollection:
4688
"""Compute all registered metrics.

isaaclab_arena/environments/isaaclab_arena_manager_based_env_cfg.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ class IsaacLabArenaManagerBasedRLEnvCfg(ManagerBasedRLEnvCfg):
6464

6565
metrics: object | None = None
6666

67+
episode_recorders: object | None = None
68+
6769
# Task language description
6870
task_description: str | None = None
6971

isaaclab_arena/evaluation/eval_runner.py

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,20 @@ def load_env(
3838
job_name: str,
3939
variations: list[str] | None = None,
4040
render_mode: str | None = None,
41+
language_instruction: str | None = None,
4142
):
4243

4344
args_parser = get_isaaclab_arena_environments_cli_parser()
4445

4546
arena_env_args_cli = args_parser.parse_args(arena_env_args)
47+
# Optionally override the language instruction.
48+
arena_env_args_cli.language_instruction = language_instruction
4649
arena_builder = get_arena_builder_from_cli(arena_env_args_cli, hydra_overrides=variations)
4750

48-
env_name, env_cfg, env_kwargs = arena_builder.build_registered()
51+
_, env_cfg, env_kwargs = arena_builder.build_registered()
4952

5053
# Set unique dataset filename for this job to avoid file locking conflicts
51-
if hasattr(env_cfg, "recorders") and env_cfg.recorders is not None:
54+
if env_cfg.recorders is not None:
5255
env_cfg.recorders.dataset_filename = f"dataset_{job_name}"
5356

5457
env = arena_builder.make_registered(env_cfg, env_kwargs, render_mode=render_mode)
@@ -262,11 +265,11 @@ def main():
262265
# Always dated so every run produces its own report dir, recording or not.
263266
# TODO(alexmillane): Currently each chunk produces its own output directory.
264267
# We should use the same output directory for all chunks in the future.
265-
run_video_dir = timestamped_run_dir(args_cli.video_base_dir)
268+
run_output_dir = timestamped_run_dir(args_cli.output_base_dir)
266269

267270
if args_cli.record_viewport_video:
268-
os.makedirs(run_video_dir, exist_ok=True)
269-
print(f"[INFO] Video recording enabled. Videos will be saved to: {run_video_dir}")
271+
os.makedirs(run_output_dir, exist_ok=True)
272+
print(f"[INFO] Video recording enabled. Videos will be saved to: {run_output_dir}")
270273

271274
for job in job_manager:
272275
if job is None:
@@ -283,17 +286,30 @@ def main():
283286
# aggregate the metrics across rebuilds into a single result.
284287
for rebuild_idx in range(job.num_rebuilds):
285288
try:
289+
job_output_dir = os.path.join(run_output_dir, job.name)
290+
286291
# Per-job video output directory; cameras are tagged with the rebuild index.
287292
video_cfg = VideoRecordingCfg(
288293
record_viewport_video=args_cli.record_viewport_video,
289294
record_camera_video=args_cli.record_camera_video,
290-
video_base_dir=os.path.join(run_video_dir, job.name),
295+
video_base_dir=job_output_dir,
291296
camera_name_prefix=f"robot-cam-rebuild{rebuild_idx}",
292297
)
293298
env = load_env(
294-
job.arena_env_args, job.name, variations=job.variations, render_mode=video_cfg.render_mode
299+
job.arena_env_args,
300+
job.name,
301+
variations=job.variations,
302+
render_mode=video_cfg.render_mode,
303+
language_instruction=job.language_instruction,
295304
)
296305

306+
# Write per-episode results to disk.
307+
# TODO: Aggregate the per-episode records across rebuilds into a single file,
308+
# as is done for the metrics below.
309+
results_path = os.path.join(job_output_dir, f"episode_results_rebuild{rebuild_idx}.jsonl")
310+
env.unwrapped.episode_recorder.set_job_name(job.name)
311+
env.unwrapped.episode_recorder.set_output_path(results_path)
312+
297313
policy = get_policy_from_job(job)
298314

299315
# Episodes allotted to this rebuild (None when the job is length-driven by steps).
@@ -314,7 +330,6 @@ def main():
314330
policy,
315331
num_steps=job.num_steps,
316332
num_episodes=num_episodes_this_rebuild,
317-
language_instruction=job.language_instruction,
318333
)
319334

320335
job_manager.complete_job(job, metrics=metrics, status=Status.COMPLETED)
@@ -347,7 +362,7 @@ def main():
347362
metrics_logger.print_metrics()
348363

349364
# Write HTML report.
350-
report_path = build_report(run_video_dir)
365+
report_path = build_report(run_output_dir)
351366
if args_cli.serve_evaluation_report:
352367
serve_until_ctrl_c(report_path.parent, args_cli.evaluation_report_port, report_path.name)
353368

isaaclab_arena/evaluation/eval_runner_cli.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,13 @@ def add_eval_runner_arguments(parser: argparse.ArgumentParser) -> None:
2727
help="Record one mp4 per (env, camera, episode) from obs['camera_obs'] for each eval job.",
2828
)
2929
parser.add_argument(
30-
"--video_base_dir",
30+
"--output_base_dir",
3131
type=str,
32-
default="/eval/videos",
33-
help="Base directory for recorded videos; a reverse-dated run subdirectory and per-job subdirectory are added.",
32+
default="/eval/output",
33+
help=(
34+
"Base directory for evaluation outputs (videos, per-episode results, report); a"
35+
" reverse-dated run subdirectory and per-job subdirectory are added."
36+
),
3437
)
3538
parser.add_argument(
3639
"--serve_evaluation_report",

isaaclab_arena/evaluation/policy_runner.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from __future__ import annotations
77

88
import argparse
9+
import os
910
import torch
1011
import tqdm
1112
from importlib import import_module
@@ -64,7 +65,6 @@ def rollout_policy(
6465
policy: PolicyBase,
6566
num_steps: int | None,
6667
num_episodes: int | None,
67-
language_instruction: str | None = None,
6868
) -> MetricsDataCollection | None:
6969
assert num_steps is not None or num_episodes is not None, "Either num_steps or num_episodes must be provided"
7070
assert num_steps is None or num_episodes is None, "Only one of num_steps or num_episodes must be provided"
@@ -73,10 +73,7 @@ def rollout_policy(
7373
try:
7474
obs, _ = env.reset()
7575
policy.reset()
76-
# Determine language instruction: CLI/job-level override takes precedence over the task's own
77-
# description. Use unwrapped to reach the base env through any gym wrappers (e.g. OrderEnforcing).
78-
task_description = language_instruction or env.unwrapped.cfg.task_description
79-
policy.set_task_description(task_description)
76+
policy.set_task_description(env.unwrapped.get_language_instruction())
8077

8178
# Setup progress bar based on num_steps or num_episodes
8279
if num_steps is not None:
@@ -192,12 +189,18 @@ def main():
192189
print(arena_builder.get_variations_catalogue_as_string())
193190
return
194191

192+
output_dir = timestamped_run_dir(args_cli.output_base_dir)
195193
video_cfg = VideoRecordingCfg(
196194
record_viewport_video=args_cli.record_viewport_video,
197195
record_camera_video=args_cli.record_camera_video,
198-
video_base_dir=timestamped_run_dir(args_cli.video_base_dir),
196+
video_base_dir=output_dir,
199197
)
200-
env, cfg = arena_builder.make_registered_and_return_cfg(render_mode=video_cfg.render_mode)
198+
env = arena_builder.make_registered(render_mode=video_cfg.render_mode)
199+
200+
# Write per-episode results to disk.
201+
results_path = os.path.join(output_dir, f"episode_results_rank{local_rank}.jsonl")
202+
env.unwrapped.episode_recorder.set_job_name("policy_runner")
203+
env.unwrapped.episode_recorder.set_output_path(results_path)
201204

202205
# Create the policy from the arguments
203206
policy = policy_cls.from_args(args_cli)
@@ -223,7 +226,7 @@ def main():
223226

224227
steps_str = f"{num_steps} steps" if num_steps is not None else f"{num_episodes} episodes"
225228
print(f"[Rank {local_rank}/{world_size}] Starting rollout ({steps_str})")
226-
metrics = rollout_policy(env, policy, num_steps, num_episodes, args_cli.language_instruction)
229+
metrics = rollout_policy(env, policy, num_steps, num_episodes)
227230

228231
if metrics is not None:
229232
print(f"[Rank {local_rank}/{world_size}] Metrics: {metrics_to_plain_python_types(metrics)}")
@@ -241,7 +244,7 @@ def main():
241244
# Write and serve the evaluation report.
242245
# Only the local rank 0 writes/serves it, to avoid races on a shared output dir.
243246
if get_local_rank() == 0:
244-
report_path = build_report(video_cfg.video_base_dir)
247+
report_path = build_report(output_dir)
245248
if args_cli.serve_evaluation_report:
246249
serve_until_ctrl_c(report_path.parent, args_cli.evaluation_report_port, report_path.name)
247250

isaaclab_arena/evaluation/policy_runner_cli.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,12 @@ def add_policy_runner_arguments(parser: argparse.ArgumentParser) -> None:
3939
help="Record an mp4 video of the rollout viewport (uses gymnasium.wrappers.RecordVideo).",
4040
)
4141
parser.add_argument(
42-
"--video_base_dir",
42+
"--output_base_dir",
4343
type=str,
44-
default="/eval/videos",
44+
default="/eval/output",
4545
help=(
46-
"Base directory for recorded videos; a reverse-dated run subdirectory is added per run."
47-
" Used with --record_viewport_video and/or --record_camera_video."
46+
"Base directory for evaluation outputs (videos, per-episode results, report); a"
47+
" reverse-dated run subdirectory is added per run."
4848
),
4949
)
5050
parser.add_argument(

0 commit comments

Comments
 (0)