# 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
"""PPISP configuration and USD parsing helpers.
The implementation follows the physically plausible ISP model described in
https://arxiv.org/abs/2601.18336.
"""
from __future__ import annotations
from dataclasses import field
from typing import Any
from isaaclab.utils.configclass import configclass
PPISP_ATTR_NAMESPACE = "ppisp:"
"""Namespace prefix for authoritative PPISP attributes authored on a USD camera."""
PPISP_CONTROLLER_WEIGHTS_CAMERA_ATTR = "controllerWeights"
"""Camera ``ppisp:*`` attribute name containing flattened controller weights."""
PPISP_CONTROLLER_EXPECTED_WEIGHTS_LEN = 241_961
"""Flattened element count of the camera-authored controller weight array exported by NRE.
This is a frozen architectural constant tied to the exported controller network
shape (see :mod:`isaaclab_ppisp.kernels` for the offset layout). USD parsing
and Warp execution validate against it and fail loudly on a mismatch.
"""
PPISP_FLOAT2_INPUTS = {
"vignettingCenterR",
"vignettingCenterG",
"vignettingCenterB",
"colorLatentBlue",
"colorLatentRed",
"colorLatentGreen",
"colorLatentNeutral",
}
PPISP_DEFAULT_INPUTS: dict[str, float | tuple[float, float]] = {
"responsivity": 1.0,
"exposureOffset": 0.0,
"vignettingCenterR": (0.0, 0.0),
"vignettingAlpha1R": 0.0,
"vignettingAlpha2R": 0.0,
"vignettingAlpha3R": 0.0,
"vignettingCenterG": (0.0, 0.0),
"vignettingAlpha1G": 0.0,
"vignettingAlpha2G": 0.0,
"vignettingAlpha3G": 0.0,
"vignettingCenterB": (0.0, 0.0),
"vignettingAlpha1B": 0.0,
"vignettingAlpha2B": 0.0,
"vignettingAlpha3B": 0.0,
"colorLatentBlue": (0.0, 0.0),
"colorLatentRed": (0.0, 0.0),
"colorLatentGreen": (0.0, 0.0),
"colorLatentNeutral": (0.0, 0.0),
"crfToeR": 0.013659,
"crfShoulderR": 0.013659,
"crfGammaR": 0.378165,
"crfCenterR": 0.0,
"crfToeG": 0.013659,
"crfShoulderG": 0.013659,
"crfGammaG": 0.378165,
"crfCenterG": 0.0,
"crfToeB": 0.013659,
"crfShoulderB": 0.013659,
"crfGammaB": 0.378165,
"crfCenterB": 0.0,
}
def default_ppisp_inputs() -> dict[str, float | tuple[float, float]]:
"""Return a copy of the PPISP identity/default input dictionary."""
return dict(PPISP_DEFAULT_INPUTS)
[docs]
@configclass
class PpispCfg:
"""Configuration for PPISP post-processing.
PPISP inputs are static in IsaacLab. NRE exports store the authoritative
values on a USD camera as ``ppisp:*`` attributes. If animated USD
attributes are imported, the first authored time sample is used and later
samples are ignored.
"""
camera_prim_path: str | None = None
"""Optional USD camera prim path used to import PPISP camera attributes."""
inputs: dict[str, float | tuple[float, float]] = field(default_factory=default_ppisp_inputs)
"""Flat PPISP values keyed by PPISP parameter name.
Coordinate conventions for spatial inputs:
* ``vignettingCenter{R,G,B}`` is a 2D offset in UV space normalised by
``max(width, height)`` with the image center at ``(0.0, 0.0)``. The
:data:`PPISP_DEFAULT_INPUTS` defaults place every channel's
optical center at the image center.
* Radial vignetting coefficients ``vignettingAlpha{1,2,3}{R,G,B}`` are
polynomial coefficients in the same normalised radius, applied as
``factor = clamp(1 + a1*r^2 + a2*r^4 + a3*r^6, 0, 1)``; with a square
frame the image corners sit at ``r^2 = 0.5``.
"""
controller_prior_exposure: float = 0.0
"""Controller prior exposure [EV] used by the native controller path."""
controller_responsivity: float | None = None
"""Controller feature-extraction responsivity [dimensionless].
When ``None``, the controller uses the static PPISP ``responsivity`` camera
attribute so feature extraction sees the same responsivity-scaled HDR
radiance as the image PPISP transform.
"""
controller_weights: tuple[float, ...] | None = None
"""Flattened controller weights.
USD imports read these from the camera's ``ppisp:controllerWeights``
attribute. When present, the native controller predicts ``exposureOffset``
and the four color latents from the HDR image each frame. Static PPISP
inputs still provide responsivity, vignetting, and CRF.
"""
def normalize_ppisp_cfg(
ppisp_cfg: PpispCfg | None,
stage: Any | None = None,
) -> PpispCfg | None:
"""Normalise a :class:`PpispCfg` for downstream consumption.
* If ``ppisp_cfg`` is ``None``, returns ``None``.
* If ``ppisp_cfg.camera_prim_path`` is set, requires ``stage`` and merges
camera-authored USD values with the cfg's explicit overrides (see
:func:`_merge_camera_attrs_with_cfg`).
* Otherwise validates ``ppisp_cfg.inputs`` and fills in defaults.
"""
if ppisp_cfg is None:
return None
if not isinstance(ppisp_cfg, PpispCfg):
raise TypeError(f"Unsupported PPISP configuration type: {type(ppisp_cfg)!r}")
input_overrides = dict(ppisp_cfg.inputs)
if ppisp_cfg.camera_prim_path:
if stage is None:
raise ValueError("PpispCfg.camera_prim_path requires a USD stage for normalization.")
return _merge_camera_attrs_with_cfg(ppisp_cfg, stage, input_overrides)
ppisp_cfg.inputs = _normalized_inputs(input_overrides)
_finalize_ppisp_cfg(ppisp_cfg)
return ppisp_cfg
def ppisp_cfg_from_usd_camera(camera_prim: Any) -> PpispCfg:
"""Create :class:`PpispCfg` from a USD camera prim.
PPISP values are read from camera ``ppisp:*`` attributes. Animated
attributes are collapsed to their first authored time sample.
"""
cfg = _ppisp_cfg_from_usd_camera(camera_prim)
_finalize_ppisp_cfg(cfg)
cfg.camera_prim_path = None
return cfg
def _ppisp_cfg_from_usd_camera(camera_prim: Any) -> PpispCfg:
values = _read_ppisp_inputs_from_camera(camera_prim)
controller_weights = _read_controller_weights_from_camera(camera_prim)
if values is None and controller_weights is None:
camera_path = str(camera_prim.GetPath()) if camera_prim and camera_prim.IsValid() else "<none>"
raise ValueError(
f"PPISP camera attributes were not found on camera {camera_path}; expected ppisp:* attributes."
)
cfg = PpispCfg(camera_prim_path=str(camera_prim.GetPath()))
if values is None:
values = default_ppisp_inputs()
cfg.inputs = values
if controller_weights is not None:
cfg.controller_weights = controller_weights
return cfg
def ppisp_cfg_from_usd_stage(stage: Any, camera_prim_path: str) -> PpispCfg:
"""Create :class:`PpispCfg` from a camera prim path in a USD stage."""
return ppisp_cfg_from_usd_camera(_get_camera_prim_at_path(stage, camera_prim_path))
def _get_camera_prim_at_path(stage: Any, camera_prim_path: str) -> Any:
camera_prim = stage.GetPrimAtPath(camera_prim_path)
if not camera_prim or not camera_prim.IsValid():
raise ValueError(f"PPISP camera prim not found at path: {camera_prim_path}")
if camera_prim.GetTypeName() != "Camera":
raise ValueError(f"PPISP prim is not a Camera: {camera_prim_path} ({camera_prim.GetTypeName()})")
return camera_prim
def _normalized_inputs(inputs: dict[str, Any]) -> dict[str, float | tuple[float, float]]:
values = default_ppisp_inputs()
for input_name, value in inputs.items():
if input_name not in values:
raise ValueError(f"Unknown PPISP input: {input_name}")
values[input_name] = _normalize_input_value(input_name, value)
return values
def _merge_camera_attrs_with_cfg(
ppisp_cfg: PpispCfg,
stage: Any,
input_overrides: dict[str, Any],
) -> PpispCfg:
assert ppisp_cfg.camera_prim_path is not None
parsed_cfg = _ppisp_cfg_from_usd_camera(_get_camera_prim_at_path(stage, ppisp_cfg.camera_prim_path))
normalized_overrides = _normalized_input_overrides(input_overrides)
if normalized_overrides != PPISP_DEFAULT_INPUTS:
parsed_cfg.inputs.update(normalized_overrides)
if ppisp_cfg.controller_weights is not None:
parsed_cfg.controller_prior_exposure = ppisp_cfg.controller_prior_exposure
parsed_cfg.controller_weights = ppisp_cfg.controller_weights
elif ppisp_cfg.controller_prior_exposure != 0.0:
parsed_cfg.controller_prior_exposure = ppisp_cfg.controller_prior_exposure
if ppisp_cfg.controller_responsivity is not None:
parsed_cfg.controller_responsivity = ppisp_cfg.controller_responsivity
_finalize_ppisp_cfg(parsed_cfg)
parsed_cfg.camera_prim_path = None
return parsed_cfg
def _normalized_input_overrides(inputs: dict[str, Any]) -> dict[str, float | tuple[float, float]]:
values = {}
for input_name, value in inputs.items():
if input_name not in PPISP_DEFAULT_INPUTS:
raise ValueError(f"Unknown PPISP input: {input_name}")
values[input_name] = _normalize_input_value(input_name, value)
return values
def _normalize_input_value(input_name: str, value: Any) -> float | tuple[float, float]:
if input_name in PPISP_FLOAT2_INPUTS:
if len(value) != 2:
raise ValueError(f"PPISP input '{input_name}' expects two values.")
return (float(value[0]), float(value[1]))
return float(value)
def _finalize_ppisp_cfg(ppisp_cfg: PpispCfg) -> None:
if ppisp_cfg.controller_responsivity is None:
ppisp_cfg.controller_responsivity = float(ppisp_cfg.inputs["responsivity"])
else:
ppisp_cfg.controller_responsivity = float(ppisp_cfg.controller_responsivity)
def _read_first_authored_value(attr: Any) -> Any:
time_samples = attr.GetTimeSamples()
if time_samples:
return attr.Get(time_samples[0])
return attr.Get()
def _read_ppisp_inputs_from_camera(camera_prim: Any | None) -> dict[str, float | tuple[float, float]] | None:
if camera_prim is None or not camera_prim.IsValid():
return None
values = default_ppisp_inputs()
found = False
for input_name in values:
attr = camera_prim.GetAttribute(f"{PPISP_ATTR_NAMESPACE}{input_name}")
if not attr or not attr.IsValid():
continue
value = _read_first_authored_value(attr)
if value is not None:
values[input_name] = _normalize_input_value(input_name, value)
found = True
return values if found else None
def _read_controller_weights_from_camera(camera_prim: Any | None) -> tuple[float, ...] | None:
if camera_prim is None or not camera_prim.IsValid():
return None
attr = camera_prim.GetAttribute(f"{PPISP_ATTR_NAMESPACE}{PPISP_CONTROLLER_WEIGHTS_CAMERA_ATTR}")
if not attr or not attr.IsValid():
return None
value = _read_first_authored_value(attr)
if value is None:
return None
weights = tuple(float(v) for v in value)
if len(weights) != PPISP_CONTROLLER_EXPECTED_WEIGHTS_LEN:
raise ValueError(
"Expected "
f"{PPISP_CONTROLLER_EXPECTED_WEIGHTS_LEN} PPISP controller weights on camera "
f"{camera_prim.GetPath()}, got {len(weights)}."
)
return weights
def _has_ppisp_camera_attrs(camera_prim: Any | None) -> bool:
if camera_prim is None or not camera_prim.IsValid() or camera_prim.GetTypeName() != "Camera":
return False
for input_name in PPISP_DEFAULT_INPUTS:
attr = camera_prim.GetAttribute(f"{PPISP_ATTR_NAMESPACE}{input_name}")
if attr and attr.IsValid() and _read_first_authored_value(attr) is not None:
return True
weights_attr = camera_prim.GetAttribute(f"{PPISP_ATTR_NAMESPACE}{PPISP_CONTROLLER_WEIGHTS_CAMERA_ATTR}")
return bool(weights_attr and weights_attr.IsValid() and _read_first_authored_value(weights_attr) is not None)
def has_ppisp_camera_attrs(camera_prim: Any | None) -> bool:
"""Return whether a USD camera prim contains recognized PPISP camera attributes.
Args:
camera_prim: USD prim to inspect.
Returns:
True when ``camera_prim`` is a camera with at least one recognized
``ppisp:*`` attribute, otherwise false.
"""
return _has_ppisp_camera_attrs(camera_prim)
def resolve_and_normalize(isp_cfg: Any, stage: Any, camera_prim_path: str | None = None) -> PpispCfg | None:
"""Resolve a Camera sensor batch's ``isp_cfg`` to a normalised cfg or ``None``.
Handles all three legal forms of :attr:`~isaaclab.sensors.camera.CameraCfg.isp_cfg`:
* ``None`` → returns ``None``.
* :class:`~isaaclab.sensors.camera.CameraISPMode` sentinel — checks the
target camera via :func:`auto_camera_ppisp_cfg` (and uses
:func:`auto_any_ppisp_cfg` for ``AUTO_ANY`` or when no camera path is
supplied) to discover a PPISP camera. Returns the parsed + normalised
:class:`PpispCfg`, or ``None`` if no PPISP camera matched.
* Concrete :class:`PpispCfg` — normalises in place (validates input keys,
fills defaults, and merges camera-authored USD values when
``camera_prim_path`` is set).
This is the single entry point renderer backends call inside their
``prepare_cameras`` hook so :mod:`isaaclab.sensors.camera` does not need
to know about PPISP types at all. The returned cfg applies to the whole
Camera sensor batch; callers pass the first matched camera prim path for
the camera-local discovery phase.
Args:
isp_cfg: The Camera sensor's :attr:`isp_cfg` value (``None``, ``CameraISPMode``, or :class:`PpispCfg`).
stage: USD stage used for sentinel discovery and camera-path resolution.
camera_prim_path: Optional absolute path of the first matched camera
prim in the Camera sensor batch. When omitted, discovery uses the
first camera on the stage with PPISP camera attributes.
Returns:
A fully-normalised :class:`PpispCfg`, or ``None`` if the batch has no ISP.
"""
# Local import avoids a top-of-module dep on isaaclab.sensors.
from isaaclab.sensors.camera.camera_isp import CameraISPMode
if isp_cfg is None:
return None
if isinstance(isp_cfg, CameraISPMode):
resolved = auto_camera_ppisp_cfg(stage, camera_prim_path) if camera_prim_path else None
if resolved is None and (isp_cfg == CameraISPMode.AUTO_ANY or not camera_prim_path):
resolved = auto_any_ppisp_cfg(stage)
if resolved is None:
return None
return normalize_ppisp_cfg(resolved)
return normalize_ppisp_cfg(isp_cfg, stage=stage)
def auto_camera_ppisp_cfg(stage: Any, camera_prim_path: str) -> PpispCfg | None:
"""Find PPISP camera attributes for ``camera_prim_path`` on ``stage``.
Checks only the target camera itself. Use :func:`auto_any_ppisp_cfg` when
the caller intentionally wants a stage-wide fallback.
Args:
stage: USD stage to search.
camera_prim_path: Absolute camera prim path to match.
Returns:
Parsed :class:`PpispCfg` if a matching camera was found, else ``None``.
"""
camera_prim = stage.GetPrimAtPath(camera_prim_path)
if not _has_ppisp_camera_attrs(camera_prim):
return None
cfg = ppisp_cfg_from_usd_camera(camera_prim)
cfg.camera_prim_path = None
return cfg
def auto_any_ppisp_cfg(stage: Any) -> PpispCfg | None:
"""Find the first camera with PPISP attributes anywhere on ``stage``.
Used as a fallback when no camera is provided, or when the caller requests
the first available PPISP camera attributes regardless of camera binding.
Args:
stage: USD stage to search.
Returns:
Parsed :class:`PpispCfg` for the first matching camera, else ``None``.
"""
for prim in stage.Traverse():
if _has_ppisp_camera_attrs(prim):
cfg = ppisp_cfg_from_usd_camera(prim)
cfg.camera_prim_path = None
return cfg
return None