Source code for isaaclab.visualizers.base_visualizer

# 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

"""Base class for visualizers."""

from __future__ import annotations

import logging
import math
import random
import re
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse

if TYPE_CHECKING:
    from collections.abc import Callable

    from isaaclab.managers import ManagerBase
    from isaaclab.renderers.base_renderer import VisualMaterialBatch
    from isaaclab.scene_data import SceneDataProvider

    from .visualizer_cfg import VisualizerCfg


logger = logging.getLogger(__name__)

_USD_DEFAULT_VERTICAL_APERTURE_MM = 15.2908


[docs] class BaseVisualizer(ABC): """Base class for all visualizer backends. Lifecycle: __init__() -> initialize() -> step() (repeated) -> close() """
[docs] def __init__(self, cfg: VisualizerCfg): """Initialize visualizer with config. Args: cfg: Visualizer configuration. """ self.cfg = cfg self._scene_data_provider = None self._is_initialized = False self._is_closed = False self._env_ids: list[int] | None = None self._deferred_startup_messages: list[str] = [] self._live_plot_sources: list = [] self._live_plot_env_idx: int = 0 self._live_plots_step_counter: int = 0 self._reset_requested: bool = False
@property def visual_material_writer(self) -> Callable[[tuple[VisualMaterialBatch, ...]], Any] | None: """Return the backend's shared material-writer factory, if supported. Its writer accepts ``None`` for a full sync or channel-to-material-offset device arrays plus one environment-id device array for partial writes, and provides an idempotent ``close()``. """ return None @abstractmethod def initialize(self, scene_data_provider: SceneDataProvider) -> None: """Initialize visualizer resources. Args: scene_data_provider: Scene data provider used by the visualizer. """ raise NotImplementedError def _set_scene_data_provider(self, scene_data_provider: SceneDataProvider) -> SceneDataProvider: """Store the scene data provider shared by all visualizer backends.""" if scene_data_provider is None: raise RuntimeError(f"{self.__class__.__name__} requires a scene_data_provider.") self._scene_data_provider = scene_data_provider return scene_data_provider @abstractmethod def step(self, dt: float) -> None: """Update visualization for one step. Args: dt: Time step in seconds. """ raise NotImplementedError @abstractmethod def close(self) -> None: """Clean up resources.""" raise NotImplementedError @abstractmethod def is_running(self) -> bool: """Check if visualizer is still running (e.g., window not closed). Returns: ``True`` if the visualizer is running, otherwise ``False``. """ raise NotImplementedError def is_training_paused(self) -> bool: """Check if training is paused by visualizer controls. Returns: ``True`` if training is paused, otherwise ``False``. """ return False def is_rendering_paused(self) -> bool: """Check if rendering is paused by visualizer controls. Returns: ``True`` if rendering is paused, otherwise ``False``. """ return False def is_reset_requested(self) -> bool: """Check if an episode reset was requested from visualizer controls. Returns: ``True`` if a reset was requested, otherwise ``False``. """ return self._reset_requested def consume_reset_request(self) -> bool: """Return whether an episode reset was requested and clear the flag. Returns: ``True`` once when a reset was requested, then ``False`` until the next request. """ requested = self._reset_requested self._reset_requested = False return requested @property def is_initialized(self) -> bool: """Check if initialize() has been called.""" return self._is_initialized @property def is_closed(self) -> bool: """Check if close() has been called.""" return self._is_closed @property def physics_backend(self) -> str | None: """Return the active physics backend name (e.g. ``'newton'``, ``'physx'``, ``'ovphysx'``). Returns: Backend name string, or ``None`` when no simulation context is active yet. """ try: from isaaclab.sim.simulation_context import SimulationContext from isaaclab.utils.backend_utils import FactoryBase if SimulationContext.instance() is None: return None return FactoryBase._get_backend() except Exception: return None def supports_markers(self) -> bool: """Check if visualizer supports VisualizationMarkers. Returns: ``True`` if marker rendering is supported, otherwise ``False``. """ return False def supports_live_plots(self) -> bool: """Check if visualizer supports live plots. Returns: ``True`` if live plots are supported, otherwise ``False``. """ return False def add_live_plots( self, managers: dict[str, ManagerBase], scalars: dict[str, dict[str, Any]] | None = None, term_names: dict[str, list[str]] | None = None, env_idx: int = 0, ) -> None: """Register environment managers and direct scalars as live-plot data sources. Creates one :class:`~isaaclab.ui.live_plots.ManagerLivePlots` per manager and one :class:`~isaaclab.ui.live_plots.DirectScalarLivePlots` per scalar group, storing all sources for use inside :meth:`_render_live_plots`. Does nothing when :meth:`supports_live_plots` returns ``False`` or ``cfg.enable_live_plots`` is ``False``. Args: managers: Mapping of manager name to manager instance. scalars: Optional mapping of group name to a dict of ``{term_name: callable}``. Each callable must take no arguments and return a numeric value. Used to plot non-manager metrics such as episode reward or episode length. term_names: Optional per-manager allowlists of term names to include. ``None`` (default) collects all terms for every manager. env_idx: Environment index to sample each step. Defaults to ``0``. """ if not self.supports_live_plots(): return if not getattr(self.cfg, "enable_live_plots", True): return import os if os.environ.get("ISAACLAB_DISABLE_LIVE_PLOTS", "0") == "1": return from isaaclab.ui.live_plots.manager_live_plots import DirectScalarLivePlots, ManagerLivePlots # Scalar groups (e.g. episode metrics) are placed first so they appear at # the top of every visualizer's plot list regardless of backend ordering. self._live_plot_sources = [] if scalars: for group_name, scalar_dict in scalars.items(): self._live_plot_sources.append(DirectScalarLivePlots(group_name, scalar_dict)) for name, mgr in managers.items(): # Skip managers that have no active terms — they contribute nothing to plots # and would create empty panels in Rerun, Viser, and the Kit live-plot window. active = getattr(mgr, "active_terms", None) if active is not None: has_terms = bool(active) if not isinstance(active, dict) else any(v for v in active.values()) if not has_terms: continue self._live_plot_sources.append(ManagerLivePlots(name, mgr, (term_names or {}).get(name))) self._live_plot_env_idx = env_idx def _render_live_plots(self) -> None: """Push live-plot data to the backend for the current step. Called from each backend's :meth:`step` implementation when live plots are active. The default implementation is a no-op; backends that support live plots override this method to forward collected term values to their native plotting API (e.g. ``viewer.log_scalar``). """ pass def requires_forward_before_step(self) -> bool: """Whether simulation should run forward() before step(). Returns: ``True`` when forward kinematics should run before stepping. """ return False def pumps_app_update(self) -> bool: """Whether this visualizer calls omni.kit.app.get_app().update() in step(). Returns True for visualizers (e.g. KitVisualizer) that already pump the Kit app loop, so SimulationContext.render() can skip its own app.update() call and avoid double-rendering. """ return False def get_visualized_env_ids(self) -> list[int] | None: """Return env IDs this visualizer is displaying, if any. Returns: Visualized environment ids, or ``None`` for all environments. """ return self._env_ids def _compute_visualized_env_ids(self) -> list[int] | None: """Compute which environment indices to visualize from config. Returns: Selected environment ids, or ``None`` to visualize all environments. """ if self._scene_data_provider is None: return None cfg = self.cfg num_envs = self._scene_data_provider.num_envs if num_envs <= 0: logger.debug("[Visualizer] num_envs is 0 or missing from provider; env selection disabled.") return None # Explicit list wins; never combine with random cap-only mode. if cfg.visible_env_indices is not None: return [i for i in cfg.visible_env_indices if 0 <= i < num_envs] max_visible = getattr(cfg, "max_visible_envs", None) # Random subset only for cap-only mode: needs a cap and no explicit indices (see VisualizerCfg). if max_visible is not None and getattr(cfg, "randomly_sample_visible_envs", True) and int(max_visible) >= 0: k = min(int(max_visible), num_envs) # k == 0: sample(range(n), 0) is []; contiguous resolver used the same convention. return sorted(random.sample(range(num_envs), k)) return None def get_rendering_dt(self) -> float | None: """Get rendering time step. Returns: Rendering time step override, or ``None`` to use interface default. """ return None def set_camera_view(self, eye: tuple, target: tuple) -> None: """Set camera view position. Args: eye: Camera eye position. target: Camera target position. """ pass def _resolve_cfg_camera_pose( self, _visualizer_name: str ) -> tuple[tuple[float, float, float], tuple[float, float, float]]: """Resolve camera pose from cfg eye/lookat fields.""" eye = tuple(float(v) for v in self.cfg.eye) lookat = tuple(float(v) for v in self.cfg.lookat) return eye, lookat def _focal_length_to_vertical_fov_degrees(self) -> float: """Convert cfg focal length to vertical FOV using USD's default aperture.""" focal_length = float(self.cfg.focal_length) if focal_length <= 0.0: raise ValueError("VisualizerCfg.focal_length must be positive.") return math.degrees(2.0 * math.atan(_USD_DEFAULT_VERTICAL_APERTURE_MM / (2.0 * focal_length))) def _resolve_camera_pose_from_usd_path( self, usd_path: str ) -> tuple[tuple[float, float, float], tuple[float, float, float]] | None: """Resolve camera pose/target from provider camera transforms. Args: usd_path: Concrete USD camera path. Returns: Eye/target tuple when available, otherwise ``None``. """ if self._scene_data_provider is None: return None transforms = self._scene_data_provider.get_camera_transforms() if not transforms: return None env_id, template_path = self._resolve_template_camera_path(usd_path) camera_transform = self._lookup_camera_transform(transforms, template_path, env_id) if camera_transform is None: return None pos, ori = camera_transform pos_t = (float(pos[0]), float(pos[1]), float(pos[2])) ori_t = (float(ori[0]), float(ori[1]), float(ori[2]), float(ori[3])) forward = self._quat_rotate_vec(ori_t, (0.0, 0.0, -1.0)) target = (pos_t[0] + forward[0], pos_t[1] + forward[1], pos_t[2] + forward[2]) return pos_t, target def _resolve_template_camera_path(self, usd_path: str) -> tuple[int, str]: """Normalize concrete env camera path to templated camera path. Args: usd_path: Concrete USD camera path. Returns: Tuple of environment id and templated camera path. """ env_pattern = re.compile(r"(?P<root>/World/envs/env_)(?P<id>\d+)(?P<path>/.*)") if match := env_pattern.match(usd_path): return int(match.group("id")), match.group("root") + "%d" + match.group("path") return 0, usd_path def _lookup_camera_transform( self, transforms: dict[str, Any], template_path: str, env_id: int ) -> tuple[list[float], list[float]] | None: """Fetch camera position/orientation for a templated path and environment. Args: transforms: Camera transform dictionary from provider. template_path: Templated camera path. env_id: Environment id to query. Returns: Position/orientation tuple when available, otherwise ``None``. """ order = transforms.get("order", []) positions = transforms.get("positions", []) orientations = transforms.get("orientations", []) if template_path not in order: return None idx = order.index(template_path) if idx >= len(positions) or idx >= len(orientations): return None if env_id < 0 or env_id >= len(positions[idx]): return None pos = positions[idx][env_id] ori = orientations[idx][env_id] if pos is None or ori is None: return None return pos, ori @staticmethod def _quat_rotate_vec( quat_xyzw: tuple[float, float, float, float], vec: tuple[float, float, float] ) -> tuple[float, float, float]: """Rotate a vector by a quaternion. Args: quat_xyzw: Quaternion in xyzw order. vec: Input vector. Returns: Rotated vector. """ import torch from isaaclab.utils.math import quat_apply quat = torch.tensor(quat_xyzw, dtype=torch.float32).unsqueeze(0) vector = torch.tensor(vec, dtype=torch.float32).unsqueeze(0) rotated = quat_apply(quat, vector)[0] return (float(rotated[0]), float(rotated[1]), float(rotated[2])) def reset(self, soft: bool = False) -> None: """Reset visualizer state. Args: soft: Whether to perform a soft reset. """ pass def _log_initialization_table(self, logger: logging.Logger, title: str, rows: list[tuple[str, Any]]) -> None: """Log a compact initialization table for a visualizer. Args: logger: Logger used to emit the table. title: Table title. rows: Table row key/value pairs. """ from prettytable import PrettyTable table = PrettyTable() table.title = title table.field_names = ["Field", "Value"] table.align["Field"] = "l" table.align["Value"] = "l" for key, value in rows: table.add_row([key, value]) logger.debug("Visualizer initialization:\n%s", table.get_string()) def _log_viewer_url( self, visualizer_name: str, viewer_url: str, ) -> None: """Queue a visible browser URL block for web-based visualizers. Args: visualizer_name: Name of the visualizer exposing the URL. viewer_url: Browser URL for the visualizer. """ parsed_url = urlparse(viewer_url) visualizer_label = visualizer_name.removesuffix("Visualizer").lower() title = f" {visualizer_label} (listening *:{parsed_url.port}) " if parsed_url.port else f" {visualizer_label} " label = "URL" label_width = len(label) value_width = max(len(viewer_url), len(title) + 2, 21) inner_width = label_width + value_width + 9 left_rule_width = max((inner_width - len(title)) // 2, 1) right_rule_width = max(inner_width - len(title) - left_rule_width, 1) lines = [ f"╭{'─' * left_rule_width}{title}{'─' * right_rule_width}╮", f"│{' ' * (label_width + 4)}{' ' * (value_width + 4)}│", f"│ {label:<{label_width}}{viewer_url:<{value_width}} │", f"│{' ' * (label_width + 4)}{' ' * (value_width + 4)}│", f"╰{'─' * inner_width}╯", ] self._deferred_startup_messages.append("\n" + "\n".join(lines) + "\n") def flush_startup_messages(self) -> None: """Print deferred startup messages immediately before the workflow update loop starts.""" for message in self._deferred_startup_messages: print(message, flush=True) self._deferred_startup_messages.clear() def play(self) -> None: """Handle simulation play/start. No-op by default.""" pass def pause(self) -> None: """Handle simulation pause. No-op by default.""" pass def stop(self) -> None: """Handle simulation stop. No-op by default.""" pass