Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
24343d2
Add OdinV2 skeleton on a fresh branch off develop
AntoineRichard Jul 29, 2026
fc32692
Add OdinV2 config loader
AntoineRichard Jul 29, 2026
50a3a8d
Add OdinV2 dispatch state
AntoineRichard Jul 29, 2026
e3d911c
Add OdinV2 row planning and chunking
AntoineRichard Jul 29, 2026
72e7df0
Add OdinV2 workflow rendering
AntoineRichard Jul 29, 2026
5c89483
Trim the osmo client for OdinV2
AntoineRichard Jul 29, 2026
6b1bafc
Add OdinV2 poller
AntoineRichard Jul 29, 2026
f8eccb2
Add OdinV2 results seam
AntoineRichard Jul 29, 2026
3811c6b
Add OdinV2 image builder
AntoineRichard Jul 29, 2026
e5bcf65
Add OdinV2 CLI
AntoineRichard Jul 29, 2026
e0a983e
Add OdinV2 metadata harvesting
AntoineRichard Jul 29, 2026
9d16256
Document OdinV2
AntoineRichard Jul 29, 2026
715b413
Fix uv profile selection for the real preset vocabulary
AntoineRichard Jul 29, 2026
73fea57
Publish results via OSMO outputs instead of a shell upload
AntoineRichard Jul 29, 2026
40536d0
Select uv extras by profile, not by preset name
AntoineRichard Jul 29, 2026
c36d462
Widen the packaging cap so OvPhysX and Isaac Sim co-resolve
AntoineRichard Jul 29, 2026
4c4ee8e
Make the image build survive a flaky link
AntoineRichard Jul 29, 2026
ef99be5
Generate the task list from the registry
AntoineRichard Jul 29, 2026
a582ca5
Rename Odin CLI flags to snake_case and policy to selection
AntoineRichard Jul 29, 2026
7c184f5
Return legal physics/renderer modes from discovery
AntoineRichard Jul 29, 2026
4f89ad7
Discover domain presets; let the executor combine them
AntoineRichard Jul 29, 2026
c1d66c0
Fix stale flag name in the generated task-list header
AntoineRichard Jul 29, 2026
b1edf9a
Record play videos on the four benchmark adapters
AntoineRichard Jul 29, 2026
9b7dbb6
Sync the packaging override into the published copies
AntoineRichard Jul 29, 2026
a782ccc
Wire --resume and --retry_failed
AntoineRichard Jul 29, 2026
aa5968d
Chain a play rollout after training in one OSMO task
AntoineRichard Jul 29, 2026
d477491
Record which steps ran, and stop calling a missing interpreter
AntoineRichard Jul 29, 2026
a76433e
Retain checkpoints, and stop harvest mixing A/B sides
AntoineRichard Jul 29, 2026
0369de7
Fix five dispatch-losing bugs in the OdinV2 pipeline
AntoineRichard Jul 29, 2026
44d4a0e
Make retries, overlays, and polling match their contracts
AntoineRichard Jul 29, 2026
6a1cee7
Pin rendered artefacts with goldens and drop dead code
AntoineRichard Jul 29, 2026
3701b89
Trim narrative and rotting detail from comments and docs
AntoineRichard Jul 29, 2026
3493b39
Stop shipping the generated task list
AntoineRichard Jul 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,6 @@ source/isaaclab_tasks/test/golden_images/**/*.png filter=lfs diff=lfs merge=lfs

*.bat text eol=crlf
*.sh text eol=lf

# Rendered fixtures; collapsed in diffs so review lands on the renderer.
tools/odin/tests/golden/** linguist-generated=true
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,7 @@ _build

# Superpowers (Claude Code plugin artifacts)
docs/superpowers/

# Generated by `odin discover` / `odin harvest`; regenerate rather than commit.
tools/odin/config/tasks.yaml
tools/odin/config/task_metadata.yaml
7 changes: 5 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ repos:
rev: v6.0.0
hooks:
- id: trailing-whitespace
# Golden files are byte-exact renderer output; reformatting them makes
# the tests that compare against them fail.
exclude: "^tools/odin/tests/golden/"
- id: check-symlinks
- id: destroyed-symlinks
- id: check-added-large-files
Expand All @@ -28,7 +31,7 @@ repos:
- id: check-executables-have-shebangs
- id: check-toml
- id: end-of-file-fixer
exclude: "^(.agents/skills/|.claude/skills$)"
exclude: "^(.agents/skills/|.claude/skills$|tools/odin/tests/golden/)"
- id: check-shebang-scripts-are-executable
- id: detect-private-key
- id: debug-statements
Expand All @@ -54,7 +57,7 @@ repos:
- --license-filepath
- .github/LICENSE_HEADER.txt
- --use-current-year
exclude: "source/isaaclab_mimic/|scripts/imitation_learning/isaaclab_mimic/"
exclude: "source/isaaclab_mimic/|scripts/imitation_learning/isaaclab_mimic/|^tools/odin/tests/golden/"
# Apache 2.0 license for mimic files
- repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.5.5
Expand Down
10 changes: 8 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -363,16 +363,22 @@ override-dependencies = [
"torchaudio==2.11.0",
# Isaac Sim 6.0 pins 4.12.2, which lacks Sentinel required by the Newton viewer stack.
"typing-extensions>=4.15.0",
# ovphysx caps packaging at <24 while isaacsim-core pins it to ==26.0, which
# made the two extras unresolvable together. Both caps are stricter than the
# code needs; widening to <27 lets them co-resolve at 26.0 and keeps OvPhysX
# usable alongside Isaac Sim in one environment.
"packaging>=20,<27",
]
# teleop (lxml>=5.2.2) and mimic (lxml<5.0.0, also in ``all``) cannot co-resolve;
# declaring the conflict lets uv fork the resolution instead of failing.
# isaacsim pins clash with teleop/ovphysx/viser/mimic/test (and ``all`` aggregates
# isaacsim pins clash with teleop/viser/mimic/test (and ``all`` aggregates
# several of these); forking keeps ``--extra isaacsim`` resolvable on its own.
# ovphysx is no longer listed: its only clash with isaacsim was the packaging
# cap, resolved by the override above.
conflicts = [
[{ extra = "teleop" }, { extra = "mimic" }],
[{ extra = "teleop" }, { extra = "all" }],
[{ extra = "isaacsim" }, { extra = "teleop" }],
[{ extra = "isaacsim" }, { extra = "ovphysx" }],
[{ extra = "isaacsim" }, { extra = "viser" }],
[{ extra = "isaacsim" }, { extra = "mimic" }],
[{ extra = "isaacsim" }, { extra = "all" }],
Expand Down
22 changes: 22 additions & 0 deletions source/isaaclab/changelog.d/antoiner-odin-v2-play-video.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Added
^^^^^

* Added ``--video`` and ``--video_length`` to the RSL-RL, RL-Games, SKRL, and SB3 play
benchmark adapters, recording the rollout and populating
:attr:`~isaaclab.benchmark.schema.PlayBundle.video_path`. The field and
:func:`~isaaclab.benchmark.builders.build_play_bundle`'s ``video_path`` argument already
existed, but no adapter could set them, so camera tasks were benchmarked headless.
* Added :func:`~isaaclab_rl.entrypoints.common.add_video_args`,
:func:`~isaaclab_rl.entrypoints.common.wrap_record_video_play`, and
:func:`~isaaclab_rl.entrypoints.common.play_video_dir`. Recording starts at the first
step rather than on a periodic trigger, since a play run is a single bounded rollout;
``--video_interval`` therefore stays training-only.

Fixed
^^^^^

* Fixed :attr:`~isaaclab.benchmark.schema.TrainingBundle.checkpoint_path` being reported as
``None`` by the RSL-RL, RL-Games, and SKRL training benchmarks. The field is now populated
from the checkpoint the run actually wrote, so downstream play workflows can roll out a
freshly trained policy without reconstructing the path. The search matches every library's
naming, including SKRL's ``agent_<tag>.pt`` and ``best_agent.pt``.
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,11 @@ def _parse_args(argv: list[str]):
" Example: 'schema,omniperf'."
),
)
_common.add_video_args(parser, include_interval=False)
add_launcher_args(parser)

args_cli, remaining_args = setup_preset_cli(parser, argv)
_common.enable_cameras_for_video(args_cli)
sys.argv = [sys.argv[0]] + remaining_args

return args_cli, remaining_args
Expand Down Expand Up @@ -189,7 +191,8 @@ def run(argv: list[str]) -> BenchmarkResult:
concate_obs_groups = agent_cfg["params"]["env"].get("concate_obs_groups", True)

env_t0 = time.perf_counter_ns()
env = gym.make(args_cli.task, cfg=env_cfg)
env = gym.make(args_cli.task, cfg=env_cfg, render_mode="rgb_array" if args_cli.video else None)
env = _common.wrap_record_video_play(env, args_cli.output_path, args_cli)
cleanup.callback(lambda: env.close())
env_t1 = time.perf_counter_ns()

Expand Down Expand Up @@ -302,6 +305,7 @@ def policy(obs):
reward=reward,
ep_length=ep_length,
checkpoint_path=resume_path,
video_path=_common.play_video_dir(args_cli.output_path, args_cli),
)

benchmark.attach_bundle(bundle)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ def run(argv: list[str]) -> BenchmarkResult:
max_iterations=agent_cfg["params"]["config"].get("max_epochs"),
)

checkpoint_path = None
checkpoint_path = _common.latest_checkpoint_path(run_log_dir)
video_path = os.path.join(run_log_dir, "videos") if getattr(args_cli, "video", False) else None

bundle = builders.build_training_bundle(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,11 @@ def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]:
" Example: 'schema,omniperf'."
),
)
_common.add_video_args(parser, include_interval=False)
add_launcher_args(parser)

args, remaining = setup_preset_cli(parser, argv)
_common.enable_cameras_for_video(args)
sys.argv = [sys.argv[0]] + remaining
return args, remaining

Expand Down Expand Up @@ -179,7 +181,8 @@ def run(argv: list[str]) -> BenchmarkResult:
)

env_t0 = time.perf_counter_ns()
env = gym.make(args.task, cfg=env_cfg)
env = gym.make(args.task, cfg=env_cfg, render_mode="rgb_array" if args.video else None)
env = _common.wrap_record_video_play(env, args.output_path, args)
cleanup.callback(lambda: env.close())
env_t1 = time.perf_counter_ns()

Expand Down Expand Up @@ -259,6 +262,7 @@ def run(argv: list[str]) -> BenchmarkResult:
reward=reward,
ep_length=ep_length,
checkpoint_path=resume_path,
video_path=_common.play_video_dir(args.output_path, args),
)

benchmark.attach_bundle(bundle)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ def run(argv: list[str]) -> BenchmarkResult:
max_iterations=agent_cfg.max_iterations,
)

checkpoint_path = None
checkpoint_path = _common.latest_checkpoint_path(log_dir)
video_path = os.path.join(log_dir, "videos") if getattr(args_cli, "video", False) else None

bundle = builders.build_training_bundle(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,11 @@ def _parse_args(argv: list[str]):
" Example: 'schema,omniperf'."
),
)
_common.add_video_args(parser, include_interval=False)
add_launcher_args(parser)

args_cli, remaining_args = setup_preset_cli(parser, argv)
_common.enable_cameras_for_video(args_cli)
sys.argv = [sys.argv[0]] + remaining_args

return args_cli, remaining_args
Expand Down Expand Up @@ -182,7 +184,8 @@ def run(argv: list[str]) -> BenchmarkResult:
)

env_t0 = time.perf_counter_ns()
env = gym.make(args_cli.task, cfg=env_cfg)
env = gym.make(args_cli.task, cfg=env_cfg, render_mode="rgb_array" if args_cli.video else None)
env = _common.wrap_record_video_play(env, args_cli.output_path, args_cli)
cleanup.callback(lambda: env.close())
env_t1 = time.perf_counter_ns()

Expand Down Expand Up @@ -289,6 +292,7 @@ def policy(obs):
reward=reward,
ep_length=ep_length,
checkpoint_path=resume_path,
video_path=_common.play_video_dir(args_cli.output_path, args_cli),
)

benchmark.attach_bundle(bundle)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@ def _parse_args(argv: list[str]):
" Example: 'schema,omniperf'."
),
)
_common.add_video_args(parser, include_interval=False)
add_launcher_args(parser)

args_cli, remaining_args = setup_preset_cli(parser, argv)
_common.enable_cameras_for_video(args_cli)
sys.argv = [sys.argv[0]] + remaining_args

return args_cli, remaining_args
Expand Down Expand Up @@ -211,7 +213,8 @@ def run(argv: list[str]) -> BenchmarkResult:
)

env_t0 = time.perf_counter_ns()
env = gym.make(args_cli.task, cfg=env_cfg)
env = gym.make(args_cli.task, cfg=env_cfg, render_mode="rgb_array" if args_cli.video else None)
env = _common.wrap_record_video_play(env, args_cli.output_path, args_cli)
cleanup.callback(lambda: env.close())
env_t1 = time.perf_counter_ns()

Expand Down Expand Up @@ -311,6 +314,7 @@ def policy(obs):
reward=reward,
ep_length=ep_length,
checkpoint_path=resume_path,
video_path=_common.play_video_dir(args_cli.output_path, args_cli),
)

benchmark.attach_bundle(bundle)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ def _generate_trainer(self, env, cfg, agent):
resources=resources,
learning=learning,
success_rate=success_rate,
checkpoint_path=None,
checkpoint_path=_common.latest_checkpoint_path(log_dir),
video_path=os.path.join(log_dir, "videos") if args_cli.video else None,
)

Expand Down
123 changes: 123 additions & 0 deletions source/isaaclab/test/benchmark/test_play_video.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# 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

"""Video recording on the play benchmark adapters.

Pins the wiring that lets an adapter populate ``PlayBundle.video_path``: without
it a camera task is benchmarked headless and the schema field stays empty.
"""

from __future__ import annotations

import argparse
from pathlib import Path

import pytest

from isaaclab_rl.entrypoints.common import add_video_args, latest_checkpoint_path, play_video_dir

_BACKENDS = ("rsl_rl", "rl_games", "skrl", "sb3")
# <repo>/source/isaaclab/test/benchmark/ -> <repo>/source/isaaclab/isaaclab/...
_ADAPTER_ROOT = Path(__file__).resolve().parents[2] / "isaaclab" / "benchmark" / "entrypoints" / "backends"


def _adapter_source(backend: str) -> str:
return (_ADAPTER_ROOT / backend / f"benchmark_play_{backend}.py").read_text()


def test_play_video_args_omit_the_training_interval() -> None:
parser = argparse.ArgumentParser()
add_video_args(parser, include_interval=False)
args = parser.parse_args([])

assert args.video is False
assert args.video_length == 200
assert not hasattr(args, "video_interval")


def test_training_video_args_keep_the_interval() -> None:
parser = argparse.ArgumentParser()
add_video_args(parser, include_interval=True)
assert parser.parse_args([]).video_interval == 2000


def test_video_dir_is_none_without_the_flag() -> None:
args = argparse.Namespace(video=False)
assert play_video_dir("/out", args) is None


def test_video_dir_is_reported_when_requested() -> None:
args = argparse.Namespace(video=True)
assert play_video_dir("/out", args) == "/out/videos/play"


def test_video_dir_tolerates_a_namespace_without_the_flag() -> None:
# Callers that never added the argument must not raise.
assert play_video_dir("/out", argparse.Namespace()) is None


@pytest.mark.parametrize("backend", _BACKENDS)
def test_adapter_wires_video(backend: str) -> None:
source = _adapter_source(backend)
assert "add_video_args(parser, include_interval=False)" in source
# Without enabled cameras the env renders nothing and the recording is blank.
assert "enable_cameras_for_video" in source
# RecordVideo needs a render mode; gym.make defaults to None.
assert 'render_mode="rgb_array"' in source
assert "wrap_record_video_play" in source
# The schema field is useless if nothing ever sets it.
assert "video_path=_common.play_video_dir(" in source


@pytest.mark.parametrize("backend", ("rsl_rl", "rl_games", "skrl"))
def test_training_reports_the_checkpoint_it_wrote(backend: str) -> None:
# A chained play step reads this field; a hardcoded None leaves it nothing.
source = (_ADAPTER_ROOT / backend / f"benchmark_train_{backend}.py").read_text()
assert "latest_checkpoint_path(" in source
assert "checkpoint_path = None" not in source
assert "checkpoint_path=None" not in source


def test_latest_checkpoint_path_finds_the_newest(tmp_path: Path) -> None:
from isaaclab_rl.entrypoints.common import latest_checkpoint_path

nested = tmp_path / "nn"
nested.mkdir()
(nested / "model_100.pt").write_text("x")
older = nested / "model_100.pt"
newer = nested / "model_200.pt"
newer.write_text("x")
import os

os.utime(older, (1, 1))

assert latest_checkpoint_path(str(tmp_path)) == str(newer.resolve())


def test_latest_checkpoint_path_is_none_when_nothing_was_written(tmp_path: Path) -> None:
from isaaclab_rl.entrypoints.common import latest_checkpoint_path

assert latest_checkpoint_path(str(tmp_path)) is None


@pytest.mark.parametrize(
"filename",
[
"model_100.pt", # rsl_rl
"agent_4800.pt", # skrl
"best_agent.pt", # skrl
"Isaac-Cartpole-Direct_4800.pt", # skrl, named after the experiment
"last_Isaac_ep_50.pth", # rl_games
"model.zip", # sb3
],
)
def test_latest_checkpoint_path_matches_every_library_naming(tmp_path: Path, filename: str) -> None:
# A `model_*.pt` glob matched rsl_rl only, so every SKRL row reported no
# checkpoint and a chained play step had nothing to roll out.
checkpoints = tmp_path / "checkpoints"
checkpoints.mkdir()
(checkpoints / filename).write_text("x")

assert latest_checkpoint_path(str(tmp_path)) == str((checkpoints / filename).resolve())
7 changes: 6 additions & 1 deletion source/isaaclab/test/cli/test_uv_run_pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,14 @@ def test_uv_run_isaacsim_extra_is_conflict_forked():

# isaacsim is forked away from every extra whose pins clash with it.
conflict_groups = [{entry["extra"] for entry in group} for group in pyproject["tool"]["uv"]["conflicts"]]
for extra in ("teleop", "ovphysx", "viser", "mimic", "all", "test"):
for extra in ("teleop", "viser", "mimic", "all", "test"):
assert {"isaacsim", extra} in conflict_groups, f"isaacsim must declare a conflict with '{extra}'"

# ovphysx is deliberately absent: its only clash with isaacsim was the
# packaging cap (ovphysx pinned <24, isaacsim-core ==26.0), resolved by the
# packaging>=20,<27 override, so the two now co-resolve in one environment.
assert {"isaacsim", "ovphysx"} not in conflict_groups


def test_uv_run_base_dependencies_cover_newton_rsl_rl_training():
"""The documented bare ``uv run isaaclab train`` command needs Newton and RSL-RL in core."""
Expand Down
1 change: 1 addition & 0 deletions source/isaaclab/test/install_ci/uv_pip/uv-overrides.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ torch==2.11.0
torchvision==0.26.0
torchaudio==2.11.0
typing-extensions>=4.15.0
packaging>=20,<27
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Added
^^^^^

* Added :func:`~isaaclab_rl.entrypoints.common.add_video_args`,
:func:`~isaaclab_rl.entrypoints.common.wrap_record_video_play`, and
:func:`~isaaclab_rl.entrypoints.common.play_video_dir` so play workflows can record a
rollout video. ``add_common_train_args`` now delegates its video arguments to the
shared helper.
Loading