Skip to content
4 changes: 4 additions & 0 deletions source/isaaclab/changelog.d/benchmark-camera-resolutions.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Added
^^^^^

* Added resolved camera config paths, image widths, and image heights to benchmark KPI metadata.
84 changes: 84 additions & 0 deletions source/isaaclab/isaaclab/benchmark/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,90 @@ def _backends_from_env_cfg(env_cfg: object) -> tuple[str | None, str | None]:
return physics, rendering


def _is_camera_cfg(node: object) -> bool:
"""Return whether *node* derives from a supported camera configuration class."""
camera_cfg_names = {"CameraCfg", "RayCasterCameraCfg"}
return any(base.__name__ in camera_cfg_names for base in type(node).__mro__)


def _camera_resolution(node: object) -> tuple[int, int] | None:
"""Return the resolved ``(width, height)`` for one camera configuration."""
width = getattr(node, "width", None)
height = getattr(node, "height", None)
if not isinstance(width, int) or isinstance(width, bool) or not isinstance(height, int) or isinstance(height, bool):
pattern_cfg = getattr(node, "pattern_cfg", None)
width = getattr(pattern_cfg, "width", None)
height = getattr(pattern_cfg, "height", None)
if (
not isinstance(width, int)
or isinstance(width, bool)
or width <= 0
or not isinstance(height, int)
or isinstance(height, bool)
or height <= 0
):
return None
return width, height


def camera_resolutions_from_env_cfg(env_cfg: object) -> dict[str, dict[str, int]]:
"""Collect resolved image dimensions from camera configurations in an environment config.

The returned keys are config paths rooted at ``env`` so benchmark results identify the
exact camera field that was resolved by Hydra. RTX camera configurations expose dimensions
directly, while ray-cast cameras expose them through their pattern configuration.

Args:
env_cfg: Concrete task environment configuration after command-line overrides are applied.

Returns:
Camera config paths mapped to their resolved image width and height in pixels.
"""
resolutions: dict[str, dict[str, int]] = {}
stack: list[tuple[str, object]] = [("env", env_cfg)]
visited: set[int] = set()

while stack:
path, node = stack.pop()
if node is None or isinstance(node, (str, bytes, int, float, bool, type)) or id(node) in visited:
continue
visited.add(id(node))

if _is_camera_cfg(node):
resolution = _camera_resolution(node)
if resolution is not None:
width, height = resolution
resolutions[path] = {"width": width, "height": height}
continue

if isinstance(node, dict):
children = [(f"{path}.{key}", value) for key, value in node.items()]
elif isinstance(node, (list, tuple)):
children = [(f"{path}[{index}]", value) for index, value in enumerate(node)]
else:
try:
children = [(f"{path}.{name}", value) for name, value in vars(node).items() if not name.startswith("_")]
except TypeError:
continue
stack.extend(reversed(children))

return dict(sorted(resolutions.items()))


def camera_resolution_metadata_from_env_cfg(env_cfg: object) -> list[dict[str, object]]:
"""Build workflow metadata containing resolved camera resolutions, when present.

Args:
env_cfg: Concrete task environment configuration after command-line overrides are applied.

Returns:
A workflow metadata entry for ``camera_resolutions``, or an empty list when the task has no
configured cameras with concrete image dimensions.
"""
resolutions = camera_resolutions_from_env_cfg(env_cfg)
return [{"name": "camera_resolutions", "data": resolutions}] if resolutions else []


def run_config_from_env_cfg(env_cfg: object) -> RunConfig:
"""Build a :class:`~isaaclab.benchmark.RunConfig` from a concrete task config.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ def run(argv: list[str]) -> BenchmarkResult:
"data": ("serialized_synchronized" if args_cli.measure_sync_step else "host_return"),
},
{"name": "environment_step_warmup_steps", "data": args_cli.warmup_steps},
*capture.camera_resolution_metadata_from_env_cfg(env_cfg),
]
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ def run(argv: list[str]) -> BenchmarkResult | None:
},
{"name": "environment_step_warmup_steps", "data": args_cli.warmup_steps},
{"name": "world_size", "data": distributed.world_size},
*capture.camera_resolution_metadata_from_env_cfg(env_cfg),
]
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ def run(argv: list[str]) -> BenchmarkResult:
"data": ("serialized_synchronized" if args.measure_sync_step else "host_return"),
},
{"name": "environment_step_warmup_steps", "data": args.warmup_steps},
*capture.camera_resolution_metadata_from_env_cfg(env_cfg),
]
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ def run(argv: list[str]) -> BenchmarkResult | None:
},
{"name": "environment_step_warmup_steps", "data": args_cli.warmup_steps},
{"name": "world_size", "data": distributed.world_size},
*capture.camera_resolution_metadata_from_env_cfg(env_cfg),
]
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ def run(argv: list[str]) -> BenchmarkResult:
"data": ("serialized_synchronized" if args_cli.measure_sync_step else "host_return"),
},
{"name": "environment_step_warmup_steps", "data": args_cli.warmup_steps},
*capture.camera_resolution_metadata_from_env_cfg(env_cfg),
]
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ def run(argv: list[str]) -> BenchmarkResult:
"data": ("serialized_synchronized" if args_cli.measure_sync_step else "host_return"),
},
{"name": "environment_step_warmup_steps", "data": args_cli.warmup_steps},
*capture.camera_resolution_metadata_from_env_cfg(env_cfg),
]
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ def run(argv: list[str]) -> BenchmarkResult:
"data": ("serialized_synchronized" if args_cli.measure_sync_step else "host_return"),
},
{"name": "environment_step_warmup_steps", "data": args_cli.warmup_steps},
*capture.camera_resolution_metadata_from_env_cfg(env_cfg),
]
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ def run(argv: list[str]) -> BenchmarkResult | None:
},
{"name": "environment_step_warmup_steps", "data": args_cli.warmup_steps},
{"name": "world_size", "data": distributed.world_size},
*capture.camera_resolution_metadata_from_env_cfg(env_cfg),
]
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ def run(argv: list[str]) -> BenchmarkResult | None:
"data": ("serialized_synchronized" if args.measure_sync_step else "host_return"),
},
{"name": "world_size", "data": distributed.world_size},
*capture.camera_resolution_metadata_from_env_cfg(env_cfg),
]
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ def run(argv: list[str]) -> BenchmarkResult | None:
{"name": "num_envs", "data": args.num_envs},
{"name": "top_n", "data": args.top_n},
{"name": "world_size", "data": distributed.world_size},
*capture.camera_resolution_metadata_from_env_cfg(env_cfg),
]
},
)
Expand Down
9 changes: 9 additions & 0 deletions source/isaaclab/test/benchmark/test_benchmark_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,14 @@ def test_benchmark_collects_metadata_measurements_and_writes_json(tmp_path):
output_path=str(output_path),
use_recorders=False,
output_prefix="test",
workflow_metadata={
"metadata": [
{
"name": "camera_resolutions",
"data": {"env.scene.tiled_camera": {"width": 64, "height": 48}},
}
]
},
)

benchmark.add_measurement(
Expand All @@ -191,6 +199,7 @@ def test_benchmark_collects_metadata_measurements_and_writes_json(tmp_path):
assert not hasattr(benchmark, "_manual_recorders") or benchmark._manual_recorders is None
assert data["benchmark_info"]["workflow_name"] == "my_workflow"
assert "timestamp" in data["benchmark_info"]
assert data["benchmark_info"]["camera_resolutions"] == {"env.scene.tiled_camera": {"width": 64, "height": 48}}
assert data["runtime"]["metric1"] == 10.0
assert data["runtime"]["metric2"] == 20.0
assert data["runtime"]["custom"] == "value"
Expand Down
40 changes: 40 additions & 0 deletions source/isaaclab/test/benchmark/test_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import pytest

from isaaclab.benchmark.capture import (
camera_resolution_metadata_from_env_cfg,
camera_resolutions_from_env_cfg,
capture_hardware,
capture_resources,
capture_versions,
Expand Down Expand Up @@ -228,6 +230,44 @@ def test_run_config_uses_concrete_backend_configuration():
run_config_from_env_cfg(SimpleNamespace(sim=SimpleNamespace(physics=object())))


def test_camera_resolutions_use_resolved_config_paths():
class CameraCfg:
def __init__(self, width: int, height: int):
self.width = width
self.height = height

class CartpoleCameraCfg(CameraCfg):
pass

class RayCasterCameraCfg:
def __init__(self, width: int, height: int):
self.pattern_cfg = SimpleNamespace(width=width, height=height)

shared_camera = CartpoleCameraCfg(width=64, height=48)
env_cfg = SimpleNamespace(
scene=SimpleNamespace(tiled_camera=shared_camera, duplicate=shared_camera),
ray_camera=RayCasterCameraCfg(width=32, height=24),
viewport=SimpleNamespace(width=1920, height=1080),
)

expected = {
"env.ray_camera": {"width": 32, "height": 24},
"env.scene.tiled_camera": {"width": 64, "height": 48},
}
assert camera_resolutions_from_env_cfg(env_cfg) == expected
assert camera_resolution_metadata_from_env_cfg(env_cfg) == [{"name": "camera_resolutions", "data": expected}]


def test_camera_resolutions_omit_unconfigured_dimensions():
class CameraCfg:
width = object()
height = object()

env_cfg = SimpleNamespace(camera=CameraCfg())
assert camera_resolutions_from_env_cfg(env_cfg) == {}
assert camera_resolution_metadata_from_env_cfg(env_cfg) == []


def test_capture_resources_peak_clamped_to_mean_when_peak_row_absent():
# Build a recorder that has mean/std rows but no peak rows.
# capture_resources must clamp peak to mean rather than leaving it at 0.0
Expand Down
Loading