Skip to content

Commit 21b60f9

Browse files
AntoineRichardisaaclab-bot[bot]
authored andcommitted
[Odin] Fix semantic_segmentation camera observations reaching the policy as integers (#7531)
## Description Every `semantic_segmentation` camera row in benchmark dispatch `20260901-153531` (image built from `origin/release/3.0.0` at `f88dbc59c82`, `rsl_rl`, all core tasks) failed at the first training step — **60 failed rows** total, with: ``` RuntimeError: Input type (unsigned char) and bias type (float) should be the same ``` The failure is renderer-independent: `isaacsim_rtx` 18 rows, `ovrtx` 18, `newton_renderer` 24. The segmentation output reaches the feature extractor's first convolution still as an integer tensor. ### Root cause Both Cartpole camera observation paths normalize only RGB-like and `depth` data types, so `semantic_segmentation` falls through unconverted: - `source/isaaclab_tasks/isaaclab_tasks/core/cartpole/mdp/observations.py` (`CameraImageStack.__call__`) - `source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py` (`CartpoleCameraEnv._get_observations`) Both files are byte-identical between `release/3.0.0` and `develop`, so the bug is present on `develop` as-is. ### Why the fix belongs in the observation term The renderer is producing exactly what its published contract says it should — `NewtonWarpRenderer.supported_output_types` deliberately emits RGBA `uint8` when colorized and a single `int32` id channel when not, *"matching the Isaac RTX / OVRTX contract so backend-independent consumers see the same dtype"*. Segmentation label ids are integers; making a renderer emit floats would break every consumer that reads ids (visualization, semantic id lookup, dataset export). Nor does it belong in the feature extractor: the extractor's contract is "float32 image in", and the observation pipeline already owns dtype/layout normalization for every other camera data type via `normalize_camera_image`. Making the CNN defensively cast would paper over the same gap for every future task and duplicate logic that already exists. So the fix goes where the gap is: the observation term, routed through the shared helper. ### The int32-vs-uint8 subtlety Segmentation has **two** dtypes depending on `colorize_semantic_segmentation`: - `colorize=True` (the `CameraCfg` default, and what the failing sweep used): RGBA `uint8`, 4 channels - `colorize=False`: a single `int32` label-id channel `normalize_camera_image` already handled the colorized `uint8` case correctly — it was simply never called for this data type. The non-colorized `int32` case was **not** handled: it fell through every branch and was returned unchanged, so a fix that only wired up the existing call would still break `colorize=False`. The `int32` half is not demonstrated by the sweep (see the caveat below), but it is not opportunistic scope creep: the caller now dispatches on the data type alone, so the helper is the single place that decides what segmentation means, and leaving it to silently return `int32` unchanged would ship a fix that reads as complete while still crashing under `colorize=False`. Handled by keying on the tensor dtype rather than on the `colorize` flag or a hardcoded uint8 assumption: - In `normalize_camera_image`, non-`uint8` segmentation is cast to `float32`. Label ids carry no meaningful scale, so they are cast and **not** rescaled — applying `(x / 255) - mean` to label ids would be inventing semantics. - In both Cartpole callers, the uint8 deferred-normalize fast path (which keeps the frame-stack ring buffer in `uint8` for cheaper per-step copies) is gated on `camera_data.dtype == torch.uint8`, so colorized segmentation rides it and `int32` label maps are normalized before entering the ring. ## Type of change - Bug fix (non-breaking change which fixes an issue) ## Test evidence Construction-only reproduction, no simulator needed. The bug fires when the extractor first sees an observation, so exercising the observation term directly on stub camera output is sufficient and much faster. Extended `source/isaaclab/test/utils/test_images.py` with the `int32` case next to the existing colorized case, and dropped `semantic_segmentation` from the "unknown type passthrough" parametrization — that class asserts `out is src` under the heading "Unknown data_types return the input unchanged", and segmentation is no longer unknown. It would still pass by the accident that `.float()` on a float32 tensor returns self, so leaving it would have left the suite documenting the opposite of the new behaviour. Added `source/isaaclab_tasks/test/core/test_cartpole_camera_observations.py` for the observation term itself; no sim-free test of it existed (the `test_rendering_cartpole*.py` neighbours need a renderer and golden images, and the `*_camera_presets.py` files only resolve configs). Two tests, each parametrized over `frame_stack` `[1, 2]` so both the immediate and the deferred-normalize branch are covered, and each asserting exact values rather than just dtype. Both dtypes are covered: colorized `uint8` RGBA and non-colorized `int32`. **Without the fix** (the three source files reverted to `origin/develop`, tests kept): ``` $ uv run --frozen --extra dev python -m pytest source/isaaclab/test/utils/test_images.py \ source/isaaclab_tasks/test/core/test_cartpole_camera_observations.py -q FAILED test_images.py::TestNormalizeCameraImageSegmentation::test_non_colorized_semantic_segmentation_is_cast_to_float[cpu] FAILED test_images.py::TestNormalizeCameraImageSegmentation::test_non_colorized_semantic_segmentation_is_cast_to_float[cuda:0] FAILED test_cartpole_camera_observations.py::test_colorized_segmentation_is_normalized_like_rgb[cpu-1] FAILED test_cartpole_camera_observations.py::test_colorized_segmentation_is_normalized_like_rgb[cpu-2] FAILED test_cartpole_camera_observations.py::test_colorized_segmentation_is_normalized_like_rgb[cuda:0-1] FAILED test_cartpole_camera_observations.py::test_colorized_segmentation_is_normalized_like_rgb[cuda:0-2] FAILED test_cartpole_camera_observations.py::test_non_colorized_segmentation_is_cast_to_float[cpu-1] FAILED test_cartpole_camera_observations.py::test_non_colorized_segmentation_is_cast_to_float[cpu-2] FAILED test_cartpole_camera_observations.py::test_non_colorized_segmentation_is_cast_to_float[cuda:0-1] FAILED test_cartpole_camera_observations.py::test_non_colorized_segmentation_is_cast_to_float[cuda:0-2] 10 failed, 62 passed in 8.56s ``` Note that the pre-existing colorized-`uint8` helper test passes on `develop`: `normalize_camera_image` always handled that case correctly, and the crash came from the Cartpole callers never invoking it. **With the fix:** ``` $ uv run --frozen --extra dev python -m pytest source/isaaclab/test/utils/test_images.py \ source/isaaclab_tasks/test/core/test_cartpole_camera_observations.py -q 72 passed in 3.53s ``` `uv run --frozen isaaclab -f` passes clean. ### Caveats a reviewer should know - The sweep exercised the **colorized `uint8` path**: `CameraCfg.colorize_semantic_segmentation` defaults to `True` and the Cartpole config declares `observation_space=[4, 96, 96]` (4 channels = RGBA). The `int32` path is reachable only with `colorize=False`; it was genuinely broken (the helper returned it unchanged) but the 60 rows do not prove it. - The **direct-environment edit is not covered by a test**. `CartpoleCameraEnv._get_observations` calls `super()._get_observations()`, which needs a constructed environment, so it cannot be exercised sim-free. The edit is line-for-line identical to the manager-term edit, which is tested. ## Relationship to #7440 #7440 touches `isaaclab/utils/images.py` and the shared `isaaclab/envs/mdp/observations.py::image` term, but **neither Cartpole file**, so it does not fix this. Its `images.py` work is a fused normalize+layout-conversion perf change that adds an `output_channel_dim` parameter; it leaves the segmentation dispatch condition semantically unchanged and does not add `int32` handling. This PR adds an early-return branch above that condition and leaves #7440's line untouched, so the two should merge cleanly in either order. ## Checklist - [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with `./isaaclab.sh --format` - [x] I have made corresponding changes to the documentation (docstrings for `normalize_camera_image`) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] I have updated the changelog and the corresponding version in the extension's `config/extension.toml` file (changelog fragments; `extension.toml` is generated) ## Release backport - [x] <!-- backport-active-release --> Backport this pull request to the active release branch after it merges into `develop` (cherry picked from commit d7d0976)
1 parent a4d4297 commit 21b60f9

7 files changed

Lines changed: 130 additions & 11 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Fixed
2+
^^^^^
3+
4+
* Fixed :func:`isaaclab.utils.images.normalize_camera_image` returning non-colorized
5+
``"semantic_segmentation"`` unchanged. Such output is an ``int32`` label map on every renderer,
6+
and feeding it to a convolution raised ``Input type (int) and bias type (float) should be the
7+
same``. It is now cast to ``float32``; label ids carry no scale, so they are not rescaled.
8+
Colorized (``uint8`` RGBA) segmentation keeps its existing ``(x / 255) - mean`` normalization.

source/isaaclab/isaaclab/utils/images.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def normalize_camera_image(
5252
5353
Dispatch (in order of check):
5454
55+
- non-colorized ``"semantic_segmentation"`` (any non-``uint8`` dtype, ``int32`` label ids in
56+
practice): cast to ``float32``. Label ids carry no scale, so no further normalization.
5557
- :func:`is_rgb_like` or colorized ``"semantic_segmentation"`` (``uint8``) and contiguous
5658
4D: routes to the
5759
fused Warp kernel via :func:`~isaaclab.utils.warp.ops.normalize_image_uint8`. ``out`` and
@@ -79,10 +81,15 @@ def normalize_camera_image(
7981
8082
Returns:
8183
The normalized tensor. For RGB-like and colorized semantic-segmentation input this is a
82-
fresh (or pre-allocated) float32 tensor; for depth-like input it is ``images`` itself
83-
(mutated in place); for normals-like input it is a new tensor; for anything else,
84-
``images`` unchanged.
84+
fresh (or pre-allocated) float32 tensor; for non-colorized semantic segmentation it is
85+
``images`` cast to float32; for depth-like input it is ``images`` itself (mutated in
86+
place); for normals-like input it is a new tensor; for anything else, ``images``
87+
unchanged.
8588
"""
89+
if data_type == "semantic_segmentation" and images.dtype != torch.uint8:
90+
# Non-colorized segmentation is an integer label map (``int32`` for every renderer).
91+
# Label ids carry no scale, so cast for the downstream convolutions without rescaling.
92+
return images.float()
8693
if is_rgb_like(data_type) or (data_type == "semantic_segmentation" and images.dtype == torch.uint8):
8794
if images.dtype == torch.uint8 and images.ndim == 4 and images.is_contiguous():
8895
return normalize_image_uint8(images, channel_dim=channel_dim, out=out)

source/isaaclab/test/utils/test_images.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,8 @@ def test_bchw_float_input_takes_pytorch_fallback(self, device):
146146
torch.testing.assert_close(out, expected)
147147

148148

149-
class TestNormalizeCameraImageColorizedSegmentation:
150-
"""Colorized segmentation dispatch."""
149+
class TestNormalizeCameraImageSegmentation:
150+
"""Segmentation dispatch, keyed on the tensor dtype rather than on the ``colorize`` flag."""
151151

152152
def test_colorized_semantic_segmentation_is_normalized(self, device):
153153
"""RGBA uint8 semantic segmentation produces a float32 normalized image."""
@@ -162,6 +162,21 @@ def test_colorized_semantic_segmentation_is_normalized(self, device):
162162
torch.testing.assert_close(out, expected, atol=1e-5, rtol=1e-5)
163163
assert out.dtype == torch.float32
164164

165+
def test_non_colorized_semantic_segmentation_is_cast_to_float(self, device):
166+
"""int32 label-id segmentation is cast to float32 with the ids left untouched.
167+
168+
Non-colorized segmentation is ``int32`` for every renderer, and feeding it to a
169+
convolution raises ``Input type (int) and bias type (float) should be the same``.
170+
"""
171+
from isaaclab.utils.images import normalize_camera_image
172+
173+
torch.manual_seed(0)
174+
src = torch.randint(0, 4, (2, 8, 8, 1), dtype=torch.int32, device=device)
175+
out = normalize_camera_image(src, "semantic_segmentation")
176+
177+
assert out.dtype == torch.float32
178+
torch.testing.assert_close(out, src.to(torch.float32))
179+
165180

166181
class TestNormalizeCameraImageDepth:
167182
"""Depth-like dispatch: in-place ``inf -> 0``."""
@@ -192,7 +207,7 @@ def test_range_remap(self, device):
192207
class TestNormalizeCameraImagePassthrough:
193208
"""Unknown data_types return the input unchanged."""
194209

195-
@pytest.mark.parametrize("data_type", ["semantic_segmentation", "instance_segmentation", "motion_vectors"])
210+
@pytest.mark.parametrize("data_type", ["instance_segmentation", "motion_vectors"])
196211
def test_unknown_type_passthrough(self, device, data_type):
197212
from isaaclab.utils.images import normalize_camera_image
198213

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Fixed
2+
^^^^^
3+
4+
* Fixed the Cartpole camera tasks crashing at the first training step with
5+
``RuntimeError: Input type (unsigned char) and bias type (float) should be the same`` when the
6+
``semantic_segmentation`` preset was selected. Both the manager-based observation term and the
7+
direct environment normalized only RGB-like and depth output, so segmentation reached the feature
8+
extractor as an integer tensor. Segmentation is now routed through
9+
:func:`isaaclab.utils.images.normalize_camera_image`, which keys on the tensor dtype and therefore
10+
handles both colorized (``uint8`` RGBA) and non-colorized (``int32`` label ids) output.

source/isaaclab_tasks/isaaclab_tasks/core/cartpole/cartpole_direct_camera_env.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from collections.abc import Sequence
99
from typing import TYPE_CHECKING
1010

11+
import torch
12+
1113
import isaaclab.sim as sim_utils
1214
from isaaclab import cloner
1315
from isaaclab.assets import Articulation
@@ -78,15 +80,17 @@ def _get_observations(self) -> dict:
7880
camera_data = self._tiled_camera.data.output[data_type]
7981

8082
rgb_like = is_rgb_like(data_type)
83+
segmentation = data_type == "semantic_segmentation"
8184
# Defer normalize past the ring buffer when stacking RGB-like data so the ring holds
8285
# uint8 (4x cheaper per-step copies). Math is identical -- K frames live in disjoint
83-
# channel slices of (B, K*C, H, W).
84-
defer_normalize = self._stack is not None and rgb_like
86+
# channel slices of (B, K*C, H, W). Colorized segmentation is uint8 RGBA and qualifies;
87+
# non-colorized segmentation is an int32 label map and does not.
88+
defer_normalize = self._stack is not None and (rgb_like or (segmentation and camera_data.dtype == torch.uint8))
8589

8690
if data_type == "albedo":
8791
# albedo carries an extra alpha channel that the policy does not use
8892
camera_data = camera_data[..., :3]
89-
if rgb_like and not defer_normalize:
93+
if (rgb_like or segmentation) and not defer_normalize:
9094
camera_data = normalize_camera_image(camera_data, data_type)
9195
elif data_type == "depth":
9296
camera_data[camera_data == float("inf")] = 0

source/isaaclab_tasks/isaaclab_tasks/core/cartpole/mdp/observations.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,13 @@ def __call__(self, env: ManagerBasedRLEnv, sensor_cfg: SceneEntityCfg, data_type
4646
camera_data = camera.data.output[data_type]
4747

4848
rgb_like = is_rgb_like(data_type)
49-
defer_normalize = self._stack is not None and rgb_like
49+
segmentation = data_type == "semantic_segmentation"
50+
# Colorized segmentation is uint8 RGBA, so it can ride the same deferred-normalize path as
51+
# the RGB-like types; non-colorized segmentation is an int32 label map that cannot.
52+
defer_normalize = self._stack is not None and (rgb_like or (segmentation and camera_data.dtype == torch.uint8))
5053
if data_type == "albedo":
5154
camera_data = camera_data[..., :3]
52-
if rgb_like and not defer_normalize:
55+
if (rgb_like or segmentation) and not defer_normalize:
5356
camera_data = normalize_camera_image(camera_data, data_type)
5457
elif data_type == "depth":
5558
camera_data[camera_data == float("inf")] = 0
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
2+
# All rights reserved.
3+
#
4+
# SPDX-License-Identifier: BSD-3-Clause
5+
6+
"""Sim-free tests for the Cartpole camera observation term.
7+
8+
Segmentation output is ``uint8`` RGBA when colorized and ``int32`` label ids when not. Either
9+
integer dtype crashes the feature extractor's first convolution, so the term must return float32
10+
for both. ``frame_stack`` is parametrized because the stacking path defers normalization.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from types import SimpleNamespace
16+
17+
import pytest
18+
import torch
19+
20+
from isaaclab.managers import ObservationTermCfg, SceneEntityCfg
21+
22+
from isaaclab_tasks.core.cartpole.mdp.observations import CameraImageStack
23+
24+
pytestmark = pytest.mark.unit
25+
26+
27+
@pytest.fixture(params=["cpu", "cuda:0"] if torch.cuda.is_available() else ["cpu"])
28+
def device(request):
29+
return request.param
30+
31+
32+
def _observe(images: torch.Tensor, frame_stack: int, device: str) -> torch.Tensor:
33+
"""Run the observation term over ``images`` using a minimal environment stub."""
34+
camera = SimpleNamespace(data=SimpleNamespace(output={"semantic_segmentation": images}))
35+
env = SimpleNamespace(
36+
cfg=SimpleNamespace(frame_stack=frame_stack),
37+
num_envs=images.shape[0],
38+
device=device,
39+
scene=SimpleNamespace(sensors={"tiled_camera": camera}),
40+
)
41+
term = CameraImageStack(ObservationTermCfg(func=CameraImageStack), env)
42+
return term(env, SceneEntityCfg("tiled_camera"), "semantic_segmentation")
43+
44+
45+
def _to_expected_layout(images: torch.Tensor, frame_stack: int) -> torch.Tensor:
46+
"""Convert BHWC to the channel-first layout, repeated as the ring buffer fills on first append."""
47+
return images.permute(0, 3, 1, 2).repeat(1, frame_stack, 1, 1)
48+
49+
50+
@pytest.mark.parametrize("frame_stack", [1, 2])
51+
def test_colorized_segmentation_is_normalized_like_rgb(device, frame_stack):
52+
"""Colorized uint8 RGBA segmentation gets the same ``(x / 255) - per-image mean`` as RGB."""
53+
torch.manual_seed(0)
54+
images = torch.randint(0, 255, (2, 8, 8, 4), dtype=torch.uint8, device=device)
55+
56+
observation = _observe(images, frame_stack, device)
57+
58+
expected = images.float() / 255.0
59+
expected = expected - torch.mean(expected, dim=(1, 2), keepdim=True)
60+
assert observation.dtype == torch.float32
61+
torch.testing.assert_close(observation, _to_expected_layout(expected, frame_stack), atol=1e-5, rtol=1e-5)
62+
63+
64+
@pytest.mark.parametrize("frame_stack", [1, 2])
65+
def test_non_colorized_segmentation_is_cast_to_float(device, frame_stack):
66+
"""Non-colorized int32 label ids are cast to float32 and, carrying no scale, left unrescaled."""
67+
images = torch.arange(2 * 8 * 8, dtype=torch.int32, device=device).reshape(2, 8, 8, 1) % 5
68+
69+
observation = _observe(images, frame_stack, device)
70+
71+
assert observation.dtype == torch.float32
72+
torch.testing.assert_close(observation, _to_expected_layout(images.float(), frame_stack))

0 commit comments

Comments
 (0)