Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Guidelines for modifications:
* Dhananjay Shendre
* Dongxuan Fan
* Dorsa Rohani
* Ege Sekkin
* Emily Sturman
* Emmanuel Ferdman
* Fabian Jenelten
Expand Down
2 changes: 1 addition & 1 deletion source/isaaclab/config/extension.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]

# Note: Semantic Versioning is used: https://semver.org/
version = "4.5.25"
version = "4.5.26"
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe bump minor at least, since there is a breaking change and deprecation warning.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point — you're right. This PR removes render_product_paths, changes the info format from dict to list[dict], and deprecates TiledCamera/TiledCameraCfg. Per semver, that warrants at least a minor bump.

4.6.0 would be more appropriate than 4.5.26 here. 👍


# Description
title = "Isaac Lab framework for Robot Learning"
Expand Down
31 changes: 31 additions & 0 deletions source/isaaclab/docs/CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,37 @@
Changelog
---------

4.5.26 (2026-04-02)
~~~~~~~~~~~~~~~~~~~

Changed
^^^^^^^

* Unified :class:`~isaaclab.sensors.camera.Camera` and :class:`~isaaclab.sensors.camera.TiledCamera`
into a single implementation. :class:`Camera` now delegates all rendering to the
:class:`~isaaclab.renderers.Renderer` abstraction (same approach :class:`TiledCamera` used).
The public API is unchanged for :class:`Camera` users.
* **Breaking (TiledCamera users):** :attr:`~isaaclab.sensors.camera.CameraData.info` now correctly
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was a bug from before, fixed while moving TiledCamera impl into Camera

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense — the old TiledCamera returning a flat dict for info violated the CameraData contract (which documents list[dict] keyed per-camera). Fixing it during the unification is the right call. The changelog entry reads clearly: it calls out the migration path (info[data_type]info[cam_idx][data_type]) which is what downstream users need. 👍

returns ``list[dict[str, Any]]`` (per-camera, then per-data-type) as documented in
:class:`~isaaclab.sensors.camera.CameraData`. :class:`TiledCamera` previously returned a flat
``dict``, which violated the documented contract. Migration: replace
``camera.data.info[data_type]`` with ``camera.data.info[cam_idx][data_type]``.

Deprecated
^^^^^^^^^^

* :class:`~isaaclab.sensors.camera.TiledCamera` is deprecated. Use
:class:`~isaaclab.sensors.camera.Camera` directly — it now supports all renderer backends.
* :class:`~isaaclab.sensors.camera.TiledCameraCfg` is deprecated. Use
:class:`~isaaclab.sensors.camera.CameraCfg` directly.

Removed
^^^^^^^

* Removed :attr:`~isaaclab.sensors.camera.Camera.render_product_paths`. Render products are
now managed internally by the renderer backend and are not part of the public API.


4.5.25 (2026-04-01)
~~~~~~~~~~~~~~~~~~~

Expand Down
414 changes: 125 additions & 289 deletions source/isaaclab/isaaclab/sensors/camera/camera.py

Large diffs are not rendered by default.

363 changes: 19 additions & 344 deletions source/isaaclab/isaaclab/sensors/camera/tiled_camera.py

Large diffs are not rendered by default.

15 changes: 14 additions & 1 deletion source/isaaclab/isaaclab/sensors/camera/tiled_camera_cfg.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#
# SPDX-License-Identifier: BSD-3-Clause

import warnings
from typing import TYPE_CHECKING

from isaaclab.utils import configclass
Expand All @@ -15,6 +16,18 @@

@configclass
class TiledCameraCfg(CameraCfg):
"""Configuration for a tiled rendering-based camera sensor."""
"""Configuration for a tiled rendering-based camera sensor.

.. deprecated:: 4.5.26
:class:`TiledCameraCfg` is deprecated. Use :class:`CameraCfg` directly —
:class:`~isaaclab.sensors.camera.Camera` now uses the same renderer abstraction.
"""

class_type: type["TiledCamera"] | str = "{DIR}.tiled_camera:TiledCamera"

def __post_init__(self):
warnings.warn(
"TiledCameraCfg is deprecated. Use CameraCfg directly.",
DeprecationWarning,
stacklevel=2,
)
Comment on lines 26 to +33
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 __post_init__ does not chain to parent

TiledCameraCfg.__post_init__ emits the deprecation warning but never calls super().__post_init__(). @configclass wraps a user-defined __post_init__ with its own _custom_post_init for the current class only — the parent CameraCfg's combined __post_init__ is not automatically invoked. If CameraCfg or SensorBaseCfg ever gains a __post_init__ with validation logic, TiledCameraCfg instantiation will silently skip it.

Suggested change
class_type: type["TiledCamera"] | str = "{DIR}.tiled_camera:TiledCamera"
def __post_init__(self):
warnings.warn(
"TiledCameraCfg is deprecated. Use CameraCfg directly.",
DeprecationWarning,
stacklevel=2,
)
def __post_init__(self):
warnings.warn(
"TiledCameraCfg is deprecated. Use CameraCfg directly.",
DeprecationWarning,
stacklevel=2,
)
super().__post_init__()

283 changes: 283 additions & 0 deletions source/isaaclab/test/sensors/test_camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,289 @@ def test_sensor_print(setup_sim_camera):
print(sensor)


def setup_with_device(device) -> tuple[sim_utils.SimulationContext, CameraCfg, float]:
camera_cfg = CameraCfg(
height=128,
width=256,
offset=CameraCfg.OffsetCfg(pos=(0.0, 0.0, 4.0), rot=(0.0, 1.0, 0.0, 0.0), convention="ros"),
prim_path="/World/Camera",
update_period=0,
data_types=["rgb", "distance_to_camera"],
spawn=sim_utils.PinholeCameraCfg(
focal_length=24.0, focus_distance=400.0, horizontal_aperture=20.955, clipping_range=(0.1, 1.0e5)
),
)
sim_utils.create_new_stage()
dt = 0.01
sim_cfg = sim_utils.SimulationCfg(dt=dt, device=device)
sim = sim_utils.SimulationContext(sim_cfg)
_populate_scene()
sim_utils.update_stage()
return sim, camera_cfg, dt


@pytest.fixture(scope="function")
def setup_camera_device(device):
"""Fixture with explicit device parametrization for GPU/CPU testing."""
sim, camera_cfg, dt = setup_with_device(device)
yield sim, camera_cfg, dt
teardown(sim)


@pytest.mark.parametrize("device", ["cuda:0", "cpu"])
@pytest.mark.isaacsim_ci
def test_camera_multi_regex_init(setup_camera_device, device):
"""Test multi-camera initialization with regex prim paths and content validation."""
sim, camera_cfg, dt = setup_camera_device

num_cameras = 9
for i in range(num_cameras):
sim_utils.create_prim(f"/World/Origin_{i}", "Xform")

camera_cfg = copy.deepcopy(camera_cfg)
camera_cfg.prim_path = "/World/Origin_.*/CameraSensor"
camera = Camera(camera_cfg)

sim.reset()

assert camera.is_initialized
assert camera._sensor_prims[1].GetPath().pathString == "/World/Origin_1/CameraSensor"
assert isinstance(camera._sensor_prims[0], UsdGeom.Camera)

assert camera.data.pos_w.shape == (num_cameras, 3)
assert camera.data.quat_w_ros.shape == (num_cameras, 4)
assert camera.data.quat_w_world.shape == (num_cameras, 4)
assert camera.data.quat_w_opengl.shape == (num_cameras, 4)
assert camera.data.intrinsic_matrices.shape == (num_cameras, 3, 3)
assert camera.data.image_shape == (camera_cfg.height, camera_cfg.width)

for _ in range(10):
sim.step()
camera.update(dt)
for im_type, im_data in camera.data.output.items():
if im_type == "rgb":
assert im_data.shape == (num_cameras, camera_cfg.height, camera_cfg.width, 3)
for i in range(4):
assert (im_data[i] / 255.0).mean() > 0.0
elif im_type == "distance_to_camera":
assert im_data.shape == (num_cameras, camera_cfg.height, camera_cfg.width, 1)
for i in range(4):
assert im_data[i].mean() > 0.0
del camera


@pytest.mark.parametrize("device", ["cuda:0", "cpu"])
@pytest.mark.isaacsim_ci
def test_camera_all_annotators(setup_camera_device, device):
"""Test all supported annotators produce correct shapes, dtypes, content, and info."""
sim, camera_cfg, dt = setup_camera_device
all_annotator_types = [
"rgb",
"rgba",
"albedo",
"depth",
"distance_to_camera",
"distance_to_image_plane",
"normals",
"motion_vectors",
"semantic_segmentation",
"instance_segmentation_fast",
"instance_id_segmentation_fast",
]

num_cameras = 9
for i in range(num_cameras):
sim_utils.create_prim(f"/World/Origin_{i}", "Xform")

camera_cfg = copy.deepcopy(camera_cfg)
camera_cfg.data_types = all_annotator_types
camera_cfg.prim_path = "/World/Origin_.*/CameraSensor"
camera = Camera(camera_cfg)

sim.reset()

assert camera.is_initialized
assert sorted(camera.data.output.keys()) == sorted(all_annotator_types)

for _ in range(10):
sim.step()
camera.update(dt)
for data_type, im_data in camera.data.output.items():
if data_type in ["rgb", "normals"]:
assert im_data.shape == (num_cameras, camera_cfg.height, camera_cfg.width, 3)
elif data_type in [
"rgba",
"albedo",
"semantic_segmentation",
"instance_segmentation_fast",
"instance_id_segmentation_fast",
]:
assert im_data.shape == (num_cameras, camera_cfg.height, camera_cfg.width, 4)
for i in range(num_cameras):
assert (im_data[i] / 255.0).mean() > 0.0
elif data_type in ["motion_vectors"]:
assert im_data.shape == (num_cameras, camera_cfg.height, camera_cfg.width, 2)
for i in range(num_cameras):
assert im_data[i].mean() != 0.0
elif data_type in ["depth", "distance_to_camera", "distance_to_image_plane"]:
assert im_data.shape == (num_cameras, camera_cfg.height, camera_cfg.width, 1)
for i in range(num_cameras):
assert im_data[i].mean() > 0.0

output = camera.data.output
info = camera.data.info
assert output["rgb"].dtype == torch.uint8
assert output["rgba"].dtype == torch.uint8
assert output["albedo"].dtype == torch.uint8
assert output["depth"].dtype == torch.float
assert output["distance_to_camera"].dtype == torch.float
assert output["distance_to_image_plane"].dtype == torch.float
assert output["normals"].dtype == torch.float
assert output["motion_vectors"].dtype == torch.float
assert output["semantic_segmentation"].dtype == torch.uint8
assert output["instance_segmentation_fast"].dtype == torch.uint8
assert output["instance_id_segmentation_fast"].dtype == torch.uint8
assert isinstance(info[0]["semantic_segmentation"], dict)
assert isinstance(info[0]["instance_segmentation_fast"], dict)
assert isinstance(info[0]["instance_id_segmentation_fast"], dict)

del camera


@pytest.mark.parametrize("device", ["cuda:0", "cpu"])
@pytest.mark.isaacsim_ci
def test_camera_segmentation_non_colorize(setup_camera_device, device):
"""Test segmentation outputs with colorization disabled produce correct dtypes and info."""
sim, camera_cfg, dt = setup_camera_device
num_cameras = 9
for i in range(num_cameras):
sim_utils.create_prim(f"/World/Origin_{i}", "Xform")

camera_cfg = copy.deepcopy(camera_cfg)
camera_cfg.data_types = ["semantic_segmentation", "instance_segmentation_fast", "instance_id_segmentation_fast"]
camera_cfg.prim_path = "/World/Origin_.*/CameraSensor"
camera_cfg.colorize_semantic_segmentation = False
camera_cfg.colorize_instance_segmentation = False
camera_cfg.colorize_instance_id_segmentation = False
camera = Camera(camera_cfg)

sim.reset()

for _ in range(5):
sim.step()
camera.update(dt)

for seg_type in camera_cfg.data_types:
assert camera.data.output[seg_type].shape == (num_cameras, camera_cfg.height, camera_cfg.width, 1)
assert camera.data.output[seg_type].dtype == torch.int32
assert isinstance(camera.data.info[0][seg_type], dict)

del camera


@pytest.mark.parametrize("device", ["cuda:0", "cpu"])
@pytest.mark.isaacsim_ci
def test_camera_normals_unit_length(setup_camera_device, device):
"""Test that normals output vectors have approximately unit length."""
sim, camera_cfg, dt = setup_camera_device
num_cameras = 9
for i in range(num_cameras):
sim_utils.create_prim(f"/World/Origin_{i}", "Xform")

camera_cfg = copy.deepcopy(camera_cfg)
camera_cfg.data_types = ["normals"]
camera_cfg.prim_path = "/World/Origin_.*/CameraSensor"
camera = Camera(camera_cfg)

sim.reset()

for _ in range(10):
sim.step()
camera.update(dt)
im_data = camera.data.output["normals"]
assert im_data.shape == (num_cameras, camera_cfg.height, camera_cfg.width, 3)
for i in range(4):
assert im_data[i].mean() > 0.0
norms = torch.linalg.norm(im_data, dim=-1)
assert torch.allclose(norms, torch.ones_like(norms), atol=1e-9)

assert camera.data.output["normals"].dtype == torch.float
del camera


@pytest.mark.parametrize("device", ["cuda:0", "cpu"])
@pytest.mark.isaacsim_ci
def test_camera_data_types_ordering(setup_camera_device, device):
"""Test that requesting specific data types produces the expected output keys."""
sim, camera_cfg, dt = setup_camera_device
camera_cfg_distance = copy.deepcopy(camera_cfg)
camera_cfg_distance.data_types = ["distance_to_camera"]
camera_cfg_distance.prim_path = "/World/CameraDistance"
camera_distance = Camera(camera_cfg_distance)

camera_cfg_depth = copy.deepcopy(camera_cfg)
camera_cfg_depth.data_types = ["depth"]
camera_cfg_depth.prim_path = "/World/CameraDepth"
camera_depth = Camera(camera_cfg_depth)

camera_cfg_both = copy.deepcopy(camera_cfg)
camera_cfg_both.data_types = ["distance_to_camera", "depth"]
camera_cfg_both.prim_path = "/World/CameraBoth"
camera_both = Camera(camera_cfg_both)

sim.reset()

assert camera_distance.is_initialized
assert camera_depth.is_initialized
assert camera_both.is_initialized
assert list(camera_distance.data.output.keys()) == ["distance_to_camera"]
assert list(camera_depth.data.output.keys()) == ["depth"]
assert list(camera_both.data.output.keys()) == ["depth", "distance_to_camera"]

del camera_distance
del camera_depth
del camera_both


@pytest.mark.parametrize("device", ["cuda:0"])
@pytest.mark.isaacsim_ci
def test_camera_frame_offset(setup_camera_device, device):
"""Test that camera reflects scene color changes without frame-offset lag."""
sim, camera_cfg, dt = setup_camera_device
camera_cfg = copy.deepcopy(camera_cfg)
camera_cfg.height = 480
camera_cfg.width = 480
camera = Camera(camera_cfg)

stage = sim_utils.get_current_stage()
for i in range(10):
prim = stage.GetPrimAtPath(f"/World/Objects/Obj_{i:02d}")
color = Gf.Vec3f(1, 1, 1)
UsdGeom.Gprim(prim).GetDisplayColorAttr().Set([color])

sim.reset()

for _ in range(100):
sim.step()
camera.update(dt)

image_before = camera.data.output["rgb"].clone() / 255.0

for i in range(10):
prim = stage.GetPrimAtPath(f"/World/Objects/Obj_{i:02d}")
color = Gf.Vec3f(0, 0, 0)
UsdGeom.Gprim(prim).GetDisplayColorAttr().Set([color])

sim.step()
camera.update(dt)

image_after = camera.data.output["rgb"].clone() / 255.0

assert torch.abs(image_after - image_before).mean() > 0.01

del camera


def _populate_scene():
"""Add prims to the scene."""
# Ground-plane
Expand Down
10 changes: 7 additions & 3 deletions source/isaaclab/test/sensors/test_multi_tiled_camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
import isaaclab.sim as sim_utils
from isaaclab.sensors.camera import TiledCamera, TiledCameraCfg

# Deprecation warnings from TiledCamera/TiledCameraCfg are expected in this file;
# the deprecation mechanism itself is validated in test_tiled_camera.py.
pytestmark = pytest.mark.filterwarnings("ignore::DeprecationWarning")
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is needed otherwise, the TiledCamera gets noisy in tests

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense — since this test exercises TiledCamera behavior (not the deprecation path itself), suppressing the warnings here keeps the output clean. And the inline comment + the note that test_tiled_camera.py covers the deprecation mechanism makes the intent clear. 👍



@pytest.fixture()
def setup_camera():
Expand Down Expand Up @@ -241,9 +245,9 @@ def test_all_annotators_multi_tiled_camera(setup_camera):
assert output["semantic_segmentation"].dtype == torch.uint8
assert output["instance_segmentation_fast"].dtype == torch.uint8
assert output["instance_id_segmentation_fast"].dtype == torch.uint8
assert isinstance(info["semantic_segmentation"], dict)
assert isinstance(info["instance_segmentation_fast"], dict)
assert isinstance(info["instance_id_segmentation_fast"], dict)
assert isinstance(info[0]["semantic_segmentation"], dict)
assert isinstance(info[0]["instance_segmentation_fast"], dict)
assert isinstance(info[0]["instance_id_segmentation_fast"], dict)

for camera in tiled_cameras:
del camera
Expand Down
Loading