Source code for isaaclab_teleop.camera_feed

# 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

"""Lifecycle management for camera image panels in XR."""

from __future__ import annotations

import importlib
import logging
import math
import time
from collections import Counter
from contextlib import suppress
from copy import deepcopy
from dataclasses import dataclass
from functools import lru_cache
from typing import TYPE_CHECKING, Any

import torch

from isaaclab.utils.version import get_isaac_sim_version

from .isaac_teleop_cfg import XrCameraFeedCfg, XrCameraFeedLayoutCfg

if TYPE_CHECKING:
    from isaaclab.sensors import Camera

logger = logging.getLogger(__name__)

_DLSS_EXEC_MODES = frozenset({"performance", "balanced", "quality", "auto", "rtxaa", "manual"})


@lru_cache(maxsize=1)
def _camera_type() -> type[Camera]:
    """Load the concrete camera type only when feeds are presented."""
    from isaaclab.sensors import Camera

    return Camera


def _load_kit_scene_ui_presenter() -> Any | None:
    """Load the optional Kit SceneUI presenter without making Kit a package dependency."""
    try:
        module = importlib.import_module(".camera_feed_kit_scene_ui", __package__)
    except (ImportError, ModuleNotFoundError) as exc:
        logger.warning(
            "XR camera PiP is unavailable because Kit SceneUI could not be loaded (%s: %s). Continuing without PiP.",
            type(exc).__name__,
            exc,
        )
        return None
    return module._KitSceneUiCameraFeedPresenter()


def _prepare_camera_feed_cfgs(env_cfg: Any, cfgs: list[XrCameraFeedCfg]) -> list[XrCameraFeedCfg]:
    """Validate selected scene cameras."""
    from isaaclab.sensors import CameraCfg

    prepared: list[XrCameraFeedCfg] = []
    enabled_cfgs = deepcopy(cfgs)
    duplicates = sorted(name for name, count in Counter(cfg.camera_name for cfg in enabled_cfgs).items() if count > 1)
    if duplicates:
        raise ValueError(f"XR camera feeds must have unique camera names. Duplicates: {duplicates}.")

    scene = getattr(env_cfg, "scene", None)
    if scene is None:
        raise ValueError("XR camera feeds require an environment configuration with a scene.")
    for cfg in enabled_cfgs:
        if cfg.enable_dlss_ray_reconstruction is not None and type(cfg.enable_dlss_ray_reconstruction) is not bool:
            raise TypeError(
                f"enable_dlss_ray_reconstruction for XR camera feed {cfg.camera_name!r} must be bool or None."
            )
        if cfg.dlss_exec_mode is not None and (
            not isinstance(cfg.dlss_exec_mode, str) or cfg.dlss_exec_mode not in _DLSS_EXEC_MODES
        ):
            raise ValueError(
                f"dlss_exec_mode for XR camera feed {cfg.camera_name!r} must be one of "
                f"{sorted(_DLSS_EXEC_MODES)} or None."
            )
        camera_cfg = getattr(scene, cfg.camera_name, None)
        if camera_cfg is None:
            raise ValueError(f"XR camera feed {cfg.camera_name!r} is not present in the scene.")
        if not isinstance(camera_cfg, CameraCfg):
            raise TypeError(f"XR camera feed {cfg.camera_name!r} does not reference a CameraCfg.")
        if not any(data_type in {"rgb", "rgba"} for data_type in camera_cfg.data_types):
            raise ValueError(f"XR camera feed {cfg.camera_name!r} camera must provide RGB or RGBA output.")
        prepared.append(cfg)
    return prepared


def _apply_ray_reconstruction_compatibility(cfgs: list[XrCameraFeedCfg]) -> None:
    """Resolve the effective PiP Ray Reconstruction policy for this runtime."""
    if not any(cfg.enable_dlss_ray_reconstruction is True for cfg in cfgs):
        return
    isaac_sim_version = get_isaac_sim_version()
    if (isaac_sim_version.major, isaac_sim_version.minor) >= (6, 1):
        return
    for cfg in cfgs:
        if cfg.enable_dlss_ray_reconstruction is True:
            cfg.enable_dlss_ray_reconstruction = False
    logger.warning(
        "DLSS Ray Reconstruction was requested for XR camera PiP, but Isaac Sim %s predates responsive "
        "denoising. Falling back to classic DLSS for the selected feeds.",
        isaac_sim_version,
    )


[docs] class XrCameraFeedSession: """Manage the two-phase XR camera-feed lifecycle. Use :meth:`prepare` before constructing the environment, then call :meth:`bind` with the constructed environment before entering the session. """
[docs] def __init__( self, cfgs: list[XrCameraFeedCfg], layout_cfg: XrCameraFeedLayoutCfg | None, presenter: Any | None, *, requires_responsive_denoising: bool, ): self._cfgs = cfgs self._layout_cfg = layout_cfg self._presenter = presenter self._requires_responsive_denoising = requires_responsive_denoising self._manager = None self._bound = False
[docs] @classmethod def prepare( cls, env_cfg: Any, *, enabled: bool, camera_rendering_enabled: bool, ) -> XrCameraFeedSession: """Prepare task-configured camera feeds before constructing the environment. Args: env_cfg: Environment configuration containing the IsaacTeleop and scene settings. enabled: Whether XR camera feeds are enabled for this run. camera_rendering_enabled: Whether external camera rendering is enabled. Returns: A session ready to bind to the constructed environment. """ if type(enabled) is not bool or type(camera_rendering_enabled) is not bool: raise TypeError("enabled and camera_rendering_enabled must be bool values.") teleop_cfg = getattr(env_cfg, "isaac_teleop", None) if not enabled or teleop_cfg is None: return cls([], None, None, requires_responsive_denoising=False) requested = [cfg for cfg in teleop_cfg.xr_camera_feeds if cfg.enabled] if not camera_rendering_enabled: if requested: logger.warning("XR camera PiP is disabled because external camera rendering is disabled.") return cls([], None, None, requires_responsive_denoising=False) if not requested: return cls([], teleop_cfg.xr_camera_feed_layout, None, requires_responsive_denoising=False) _validate_layout_cfg(teleop_cfg.xr_camera_feed_layout) presenter = _load_kit_scene_ui_presenter() if presenter is None: return cls([], teleop_cfg.xr_camera_feed_layout, None, requires_responsive_denoising=False) if int(env_cfg.scene.num_envs) != 1: raise ValueError("XR camera PiP supports exactly one environment; set --num_envs 1 or disable PiP feeds.") cfgs = _prepare_camera_feed_cfgs(env_cfg, requested) _apply_ray_reconstruction_compatibility(cfgs) return cls( cfgs, teleop_cfg.xr_camera_feed_layout, presenter, requires_responsive_denoising=any(cfg.enable_dlss_ray_reconstruction is True for cfg in cfgs), )
@property def enabled(self) -> bool: """Whether the session has camera feeds to present.""" return bool(self._cfgs) @property def requires_responsive_denoising(self) -> bool: """Whether the selected feeds require responsive DLSS denoising.""" return self._requires_responsive_denoising
[docs] def bind(self, env: Any) -> XrCameraFeedSession: """Bind the prepared feeds to a constructed environment. Args: env: Constructed environment containing the selected scene cameras. Returns: This session, ready to use as a context manager. """ if self._bound: raise RuntimeError("XR camera feed session is already bound.") self._bound = True try: if self.enabled: self._manager = _XrCameraFeedManager(env, self._cfgs, self._layout_cfg, self._presenter) except Exception: self.close() raise return self
[docs] def refresh(self) -> None: """Refresh and publish feed buffers after the environment resets.""" if not self._bound: raise RuntimeError("XR camera feed session must be bound before refresh.") if self._manager is not None: self._manager.refresh()
[docs] def close(self) -> None: """Close feed display resources and allow the session to be rebound. Render-product policy authored while binding persists for the selected camera render product's lifetime. """ if self._manager is not None: self._manager.close() self._manager = None self._bound = False
def __enter__(self) -> XrCameraFeedSession: if not self._bound: raise RuntimeError("Call bind(env) before entering an XR camera feed session.") return self def __exit__(self, exc_type, exc_value, traceback) -> None: self.close()
def _panel_size_m(cfg: XrCameraFeedCfg, image_size: tuple[int, int]) -> tuple[float, float]: width, height = image_size if width <= 0 or height <= 0: raise ValueError(f"XR camera feed image dimensions must be positive, got {image_size}.") return cfg.panel_width_m, cfg.panel_width_m * height / width + (0.04 if cfg.label else 0.0) def _centered_positions(sizes: list[float], gap: float) -> list[float]: cursor = -0.5 * (sum(sizes) + gap * max(0, len(sizes) - 1)) positions = [] for size in sizes: positions.append(cursor + 0.5 * size) cursor += size + gap return positions def _validate_layout_cfg(layout_cfg: XrCameraFeedLayoutCfg) -> None: if layout_cfg.mode not in {"manual", "horizontal", "vertical", "grid"}: raise ValueError(f"Unknown XR camera feed layout mode {layout_cfg.mode!r}.") if layout_cfg.placement not in {"viewer_start", "head_locked", "world"}: raise ValueError(f"Unknown XR camera feed placement {layout_cfg.placement!r}.") if layout_cfg.placement != "world" and (not math.isfinite(layout_cfg.distance_m) or layout_cfg.distance_m <= 0.0): raise ValueError("XR camera feed layout distance_m must be finite and positive.") if not math.isfinite(layout_cfg.panel_gap_m) or layout_cfg.panel_gap_m < 0.0: raise ValueError("XR camera feed layout panel_gap_m must be finite and non-negative.") if len(layout_cfg.center_offset_m) != 2 or not all(math.isfinite(value) for value in layout_cfg.center_offset_m): raise ValueError("XR camera feed layout center_offset_m must contain two finite values.") if type(layout_cfg.max_columns) is not int or layout_cfg.max_columns <= 0: raise ValueError("XR camera feed layout max_columns must be positive.") if layout_cfg.placement == "world": position = layout_cfg.world_position_m if position is None or len(position) != 3 or not all(math.isfinite(value) for value in position): raise ValueError("XR camera feed layout world_position_m must contain three finite values.") orientation = layout_cfg.world_orientation_xyzw if len(orientation) != 4 or not all(math.isfinite(value) for value in orientation): raise ValueError("XR camera feed layout world_orientation_xyzw must contain four finite values.") if math.sqrt(sum(value * value for value in orientation)) <= 1.0e-8: raise ValueError("XR camera feed layout world_orientation_xyzw must be non-zero.") def _layout_feed_cfgs( cfgs: list[XrCameraFeedCfg], image_sizes: list[tuple[int, int]], layout_cfg: XrCameraFeedLayoutCfg, ) -> list[XrCameraFeedCfg]: if len(cfgs) != len(image_sizes): raise ValueError("XR camera feed configs and image sizes must have the same length.") _validate_layout_cfg(layout_cfg) resolved = deepcopy(cfgs) if layout_cfg.mode == "manual" or not resolved: return resolved panel_sizes = [_panel_size_m(cfg, size) for cfg, size in zip(resolved, image_sizes, strict=True)] center_x, center_y = layout_cfg.center_offset_m if layout_cfg.mode == "horizontal": xs = _centered_positions([width for width, _ in panel_sizes], layout_cfg.panel_gap_m) offsets = [(center_x + x, center_y) for x in xs] elif layout_cfg.mode == "vertical": ys = _centered_positions([height for _, height in panel_sizes], layout_cfg.panel_gap_m) offsets = [(center_x, center_y - y) for y in ys] else: columns = min(layout_cfg.max_columns, len(resolved)) rows = [list(range(start, min(start + columns, len(resolved)))) for start in range(0, len(resolved), columns)] row_heights = [max(panel_sizes[index][1] for index in row) for row in rows] row_ys = _centered_positions(row_heights, layout_cfg.panel_gap_m) offsets = [(0.0, 0.0)] * len(resolved) for row, row_y in zip(rows, row_ys, strict=True): row_xs = _centered_positions([panel_sizes[index][0] for index in row], layout_cfg.panel_gap_m) for index, row_x in zip(row, row_xs, strict=True): offsets[index] = (center_x + row_x, center_y - row_y) for cfg, offset in zip(resolved, offsets, strict=True): cfg.offset_m = offset cfg.distance_m = layout_cfg.distance_m return resolved @dataclass(frozen=True) class _PanelDescriptor: label: str | None width_m: float offset_m: tuple[float, float] distance_m: float placement: str world_position_m: tuple[float, float, float] | None world_orientation_xyzw: tuple[float, float, float, float] def _panel_descriptor(cfg: XrCameraFeedCfg, layout_cfg: XrCameraFeedLayoutCfg) -> _PanelDescriptor: return _PanelDescriptor( label=cfg.label, width_m=cfg.panel_width_m, offset_m=tuple(cfg.offset_m), distance_m=cfg.distance_m, placement=layout_cfg.placement, world_position_m=None if layout_cfg.world_position_m is None else tuple(layout_cfg.world_position_m), world_orientation_xyzw=tuple(layout_cfg.world_orientation_xyzw), ) @dataclass class _ActiveFeed: cfg: XrCameraFeedCfg camera: Camera fallback_image: torch.Tensor image_source: Any | None image: torch.Tensor upload_image: torch.Tensor panel: Any next_update_time: float = 0.0 class _XrCameraFeedManager: """Bind camera RGBA buffers to persistent Kit SceneUI panels.""" def __init__( self, env: Any, cfgs: list[XrCameraFeedCfg], layout_cfg: XrCameraFeedLayoutCfg | None, presenter: Any, ): self._env = env self._feeds: list[_ActiveFeed] = [] self._presenter = presenter self._layout_cfg = deepcopy(layout_cfg or XrCameraFeedLayoutCfg()) self._frame_subscription = None try: bound_feeds = [] for cfg in cfgs: self._validate_cfg(cfg, self._layout_cfg.placement) camera, image = self._bind_image(cfg) bound_feeds.append((camera, image)) image_sizes = [(int(image.shape[1]), int(image.shape[0])) for _, image in bound_feeds] resolved_cfgs = _layout_feed_cfgs(cfgs, image_sizes, self._layout_cfg) for cfg, (camera, fallback_image) in zip(resolved_cfgs, bound_feeds, strict=True): image_source = self._presenter.create_image_source(cfg.camera_name, camera, cfg) try: image = image_source.get_image(tuple(fallback_image.shape)) if image_source is not None else None if image is None: image = fallback_image upload_image = image panel = self._presenter.create_panel( _panel_descriptor(cfg, self._layout_cfg), width=int(fallback_image.shape[1]), height=int(fallback_image.shape[0]), ) except Exception: if image_source is not None: with suppress(Exception): image_source.close() raise self._feeds.append(_ActiveFeed(cfg, camera, fallback_image, image_source, image, upload_image, panel)) self._frame_subscription = self._presenter.subscribe_to_frame_updates(self._on_frame) except Exception: self.close() raise def _on_frame(self, _event: Any) -> None: self.update() @staticmethod def _validate_cfg(cfg: XrCameraFeedCfg, placement: str) -> None: if not math.isfinite(cfg.panel_width_m) or cfg.panel_width_m <= 0.0: raise ValueError(f"panel_width_m for XR camera feed {cfg.camera_name!r} must be positive.") if len(cfg.offset_m) != 2 or not all(math.isfinite(value) for value in cfg.offset_m): raise ValueError(f"offset_m for XR camera feed {cfg.camera_name!r} must contain two finite values.") if placement != "world" and (not math.isfinite(cfg.distance_m) or cfg.distance_m <= 0.0): raise ValueError(f"distance_m for XR camera feed {cfg.camera_name!r} must be positive.") if not math.isfinite(cfg.max_update_hz) or cfg.max_update_hz < 0.0: raise ValueError(f"max_update_hz for XR camera feed {cfg.camera_name!r} must be finite and non-negative.") def _bind_image(self, cfg: XrCameraFeedCfg) -> tuple[Camera, torch.Tensor]: sensors = getattr(getattr(self._env, "scene", None), "sensors", {}) if cfg.camera_name not in sensors: raise ValueError( f"XR camera feed {cfg.camera_name!r} is not present in the interactive scene. " f"Available sensors: {sorted(sensors.keys())}." ) camera = sensors[cfg.camera_name] if not isinstance(camera, _camera_type()): raise TypeError(f"XR camera feed {cfg.camera_name!r} did not resolve to an Isaac Lab Camera.") return camera, self._image_from_output(cfg, camera.data.output) def _image_from_output(self, cfg: XrCameraFeedCfg, output: Any) -> torch.Tensor: if output is None or "rgba" not in output: available = [] if output is None else sorted(output.keys()) raise ValueError(f"Camera {cfg.camera_name!r} has no RGBA buffer. Available outputs: {available}.") batch = output["rgba"].torch if batch.ndim != 4 or int(batch.shape[-1]) != 4: raise ValueError(f"Camera {cfg.camera_name!r} RGBA output must have shape (N, H, W, 4).") image = batch[0] if image.dtype != torch.uint8: raise TypeError(f"Camera {cfg.camera_name!r} RGBA buffer must be uint8, got {image.dtype}.") return image @staticmethod def _same_allocation(first: torch.Tensor, second: torch.Tensor) -> bool: return ( tuple(first.shape) == tuple(second.shape) and first.device == second.device and first.data_ptr() == second.data_ptr() ) def _rebind_feed(self, feed: _ActiveFeed, camera: Camera, fallback_image: torch.Tensor) -> None: camera_changed = feed.camera is not camera image_changed = not self._same_allocation(feed.fallback_image, fallback_image) if not camera_changed and not image_changed: return replacement_source = feed.image_source replacement_panel = feed.panel if camera_changed: replacement_source = self._presenter.create_image_source(feed.cfg.camera_name, camera, feed.cfg) try: if tuple(fallback_image.shape) != tuple(feed.fallback_image.shape): replacement_panel = self._presenter.create_panel( _panel_descriptor(feed.cfg, self._layout_cfg), width=int(fallback_image.shape[1]), height=int(fallback_image.shape[0]), ) except Exception: if camera_changed and replacement_source is not None: with suppress(Exception): replacement_source.close() raise if camera_changed: old_source = feed.image_source feed.image_source = replacement_source if old_source is not None: try: old_source.close() except Exception: logger.exception("Failed to close XR camera feed source %r.", feed.cfg.camera_name) if replacement_panel is not feed.panel: old_panel = feed.panel feed.panel = replacement_panel old_panel.close() feed.camera = camera feed.fallback_image = fallback_image def update(self) -> None: now = time.monotonic() for feed in self._feeds: if now < feed.next_update_time: continue self._publish_feed(feed) period = 0.0 if feed.cfg.max_update_hz == 0.0 else 1.0 / feed.cfg.max_update_hz feed.next_update_time = now + period def _publish_feed(self, feed: _ActiveFeed) -> None: image = feed.image_source.get_image(tuple(feed.fallback_image.shape)) if feed.image_source is not None else None if image is None: fallback_image = self._image_from_output(feed.cfg, feed.camera.data.output) self._rebind_feed(feed, feed.camera, fallback_image) image = feed.fallback_image upload_image = self._presenter.prepare_upload_image( feed.cfg.camera_name, image, previous_source=feed.image, previous_upload=feed.upload_image, ) self._presenter.stage_upload_image(image, upload_image) feed.panel.upload(upload_image) feed.image = image feed.upload_image = upload_image def refresh(self, *, publish: bool = True) -> None: for feed in self._feeds: feed.camera.update(0.0, force_recompute=True) camera, image = self._bind_image(feed.cfg) self._rebind_feed(feed, camera, image) if publish: self._publish_feed(feed) feed.next_update_time = 0.0 def close(self) -> None: if self._frame_subscription is not None: self._frame_subscription.close() self._frame_subscription = None for feed in reversed(self._feeds): if feed.image_source is not None: try: feed.image_source.close() except Exception: logger.exception("Failed to close XR camera feed source %r.", feed.cfg.camera_name) try: feed.panel.close() except Exception: logger.exception("Failed to close XR camera feed %r.", feed.cfg.camera_name) self._feeds.clear()