Source code for visualization.coloring.scale

"""Color Scale Module

This module contains the ColorScale class, which maps scalar values to colors and
produces the matching 2D colorbar (matplotlib) or 3D scalar bar (VTK).

Kramer Harrison, 2026
"""

from __future__ import annotations

from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Protocol

import matplotlib as mpl
import numpy as np
from matplotlib import cm
from matplotlib.colors import LogNorm, Normalize

if TYPE_CHECKING:
    from matplotlib.axes import Axes
    from matplotlib.colorbar import Colorbar
    from matplotlib.colors import Colormap
    from matplotlib.figure import Figure
    from numpy.typing import ArrayLike

RGBA = tuple[float, float, float, float]

_VTK_TABLE_SIZE = 256


[docs] @dataclass(frozen=True) class ColorScale: """Maps scalar values to colors through a colormap. Limits that are left as ``None`` are filled from the data by :meth:`resolved`. Limits set by the user are always kept. Args: cmap (str | Colormap): Matplotlib colormap or its name. Defaults to ``"viridis"``. vmin (float | None): Value mapped to the low end of the colormap. vmax (float | None): Value mapped to the high end of the colormap. log (bool): If True, values are mapped on a base-10 logarithmic axis. label (str): Colorbar / scalar bar title. """ cmap: str | Colormap = "viridis" vmin: float | None = None vmax: float | None = None log: bool = False label: str = ""
[docs] def resolved(self, values: ArrayLike) -> ColorScale: """Return a copy whose missing limits are filled from ``values``. Args: values (ArrayLike): Scalar values the scale will be applied to. Returns: ColorScale: A scale with both limits set. Raises: ValueError: If ``values`` has no finite entry, or if ``log`` is set and any finite value or user limit is not positive. """ finite = self._finite(values) if finite.size == 0: raise ValueError("ColorScale: no finite values to scale") vmin = finite.min() if self.vmin is None else self.vmin vmax = finite.max() if self.vmax is None else self.vmax if self.log and (finite.min() <= 0 or vmin <= 0 or vmax <= 0): raise ValueError("ColorScale: log scale requires positive values") return replace(self, vmin=float(vmin), vmax=float(vmax))
[docs] def to_rgba(self, values: ArrayLike) -> np.ndarray: """Map values to RGBA rows. Args: values (ArrayLike): Scalar values; any shape is flattened. Returns: np.ndarray: Array of shape ``(N, 4)``. Entries that are not finite (or, for a log scale, not positive) give a row of NaN, which callers treat as "use the default color". Raises: ValueError: If the scale is not resolved. """ self._require_limits() flat = np.asarray(values, dtype=float).ravel() usable = np.isfinite(flat) if self.log: usable &= flat > 0 rgba = np.full((flat.size, 4), np.nan) rgba[usable] = self._colormap()(self._fraction(flat[usable])) return rgba
[docs] def add_colorbar(self, fig: Figure, ax: Axes) -> Colorbar: """Add a colorbar for this scale to a matplotlib figure. Args: fig (Figure): Figure that receives the colorbar. ax (Axes): Axes the colorbar steals space from. Returns: Colorbar: The new colorbar. Raises: ValueError: If the scale is not resolved. """ self._require_limits() mappable = cm.ScalarMappable(norm=self._norm(), cmap=self._colormap()) return fig.colorbar(mappable, ax=ax, label=self.label)
[docs] def scalar_bar_actor(self) -> vtkScalarBarActor: # noqa: F821 """Build a VTK scalar bar for this scale. Returns: vtkScalarBarActor: Actor titled with :attr:`label`. Position and size are left to the caller. Raises: ValueError: If the scale is not resolved. """ import vtk self._require_limits() table = vtk.vtkLookupTable() table.SetNumberOfTableValues(_VTK_TABLE_SIZE) # Table entries are evenly spaced; VTK's log mapping makes that # log-spaced in value, matching the 2D log norm. if self.log: table.SetScaleToLog10() table.SetTableRange(self.vmin, self.vmax) samples = self._colormap()(np.linspace(0.0, 1.0, _VTK_TABLE_SIZE)) for index, color in enumerate(samples): table.SetTableValue(index, *color) table.Build() bar = vtk.vtkScalarBarActor() bar.SetLookupTable(table) bar.SetTitle(self.label) return bar
def _require_limits(self) -> None: if self.vmin is None or self.vmax is None: raise ValueError( "ColorScale: limits are unset; call resolved(values) first" ) def _colormap(self) -> Colormap: return mpl.colormaps[self.cmap] if isinstance(self.cmap, str) else self.cmap def _fraction(self, values: np.ndarray) -> np.ndarray: """Position of each value in [0, 1] along the colormap.""" if self.vmin == self.vmax: return np.full(values.shape, 0.5) if self.log: values, low, high = ( np.log10(values), np.log10(self.vmin), np.log10(self.vmax), ) else: low, high = self.vmin, self.vmax return np.clip((values - low) / (high - low), 0.0, 1.0) def _norm(self) -> Normalize: vmin, vmax = self.vmin, self.vmax if vmin == vmax: # matplotlib cannot draw an empty colorbar range vmin, vmax = (vmin / 2, vmax * 2) if self.log else (vmin - 0.5, vmax + 0.5) return LogNorm(vmin, vmax) if self.log else Normalize(vmin, vmax) @staticmethod def _finite(values: ArrayLike) -> np.ndarray: flat = np.asarray(values, dtype=float).ravel() return flat[np.isfinite(flat)]
class _HasScale(Protocol): scale: ColorScale | None
[docs] def collect_scales(*colorizers: _HasScale | None) -> list[ColorScale]: """Gather the resolved scales to display from colorizers, skipping absent ones. Args: *colorizers: Component and/or ray colorizers, or ``None``. Returns: list[ColorScale]: The scales of the colorizers that expose one, in order. """ return [c.scale for c in colorizers if c is not None and c.scale is not None]