diff --git a/doc/api/colors_api.rst b/doc/api/colors_api.rst index 6b02f723d74d..49a42c8f9601 100644 --- a/doc/api/colors_api.rst +++ b/doc/api/colors_api.rst @@ -21,6 +21,7 @@ Color norms :toctree: _as_gen/ :template: autosummary.rst + Norm Normalize NoNorm AsinhNorm diff --git a/lib/matplotlib/colorizer.py b/lib/matplotlib/colorizer.py index b4223f389804..92a6e4ea4c4f 100644 --- a/lib/matplotlib/colorizer.py +++ b/lib/matplotlib/colorizer.py @@ -90,7 +90,7 @@ def norm(self): @norm.setter def norm(self, norm): - _api.check_isinstance((colors.Normalize, str, None), norm=norm) + _api.check_isinstance((colors.Norm, str, None), norm=norm) if norm is None: norm = colors.Normalize() elif isinstance(norm, str): diff --git a/lib/matplotlib/colorizer.pyi b/lib/matplotlib/colorizer.pyi index f35ebe5295e4..9a5a73415d83 100644 --- a/lib/matplotlib/colorizer.pyi +++ b/lib/matplotlib/colorizer.pyi @@ -10,12 +10,12 @@ class Colorizer: def __init__( self, cmap: str | colors.Colormap | None = ..., - norm: str | colors.Normalize | None = ..., + norm: str | colors.Norm | None = ..., ) -> None: ... @property - def norm(self) -> colors.Normalize: ... + def norm(self) -> colors.Norm: ... @norm.setter - def norm(self, norm: colors.Normalize | str | None) -> None: ... + def norm(self, norm: colors.Norm | str | None) -> None: ... def to_rgba( self, x: np.ndarray, @@ -63,10 +63,10 @@ class _ColorizerInterface: def get_cmap(self) -> colors.Colormap: ... def set_cmap(self, cmap: str | colors.Colormap) -> None: ... @property - def norm(self) -> colors.Normalize: ... + def norm(self) -> colors.Norm: ... @norm.setter - def norm(self, norm: colors.Normalize | str | None) -> None: ... - def set_norm(self, norm: colors.Normalize | str | None) -> None: ... + def norm(self, norm: colors.Norm | str | None) -> None: ... + def set_norm(self, norm: colors.Norm | str | None) -> None: ... def autoscale(self) -> None: ... def autoscale_None(self) -> None: ... @@ -74,7 +74,7 @@ class _ColorizerInterface: class _ScalarMappable(_ColorizerInterface): def __init__( self, - norm: colors.Normalize | None = ..., + norm: colors.Norm | None = ..., cmap: str | colors.Colormap | None = ..., *, colorizer: Colorizer | None = ..., diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 254e2c1a203b..a09b4f3d4f5c 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -41,6 +41,7 @@ import base64 from collections.abc import Sequence, Mapping +from abc import ABC, abstractmethod import functools import importlib import inspect @@ -2257,7 +2258,87 @@ def _init(self): self._isinit = True -class Normalize: +class Norm(ABC): + """ + Abstract base class for normalizations. + + Subclasses include `Normalize` which maps from a scalar to + a scalar. However, this class makes no such requirement, and subclasses may + support the normalization of multiple variates simultaneously, with + separate normalization for each variate. + """ + + def __init__(self): + self.callbacks = cbook.CallbackRegistry(signals=["changed"]) + + @property + @abstractmethod + def vmin(self): + """Lower limit of the input data interval; maps to 0.""" + pass + + @property + @abstractmethod + def vmax(self): + """Upper limit of the input data interval; maps to 1.""" + pass + + @property + @abstractmethod + def clip(self): + """ + Determines the behavior for mapping values outside the range ``[vmin, vmax]``. + + See the *clip* parameter in `.Normalize`. + """ + pass + + @abstractmethod + def __call__(self, value, clip=None): + """ + Normalize the data and return the normalized data. + + Parameters + ---------- + value + Data to normalize. + clip : bool, optional + See the description of the parameter *clip* in `.Normalize`. + + If ``None``, defaults to ``self.clip`` (which defaults to + ``False``). + + Notes + ----- + If not already initialized, ``self.vmin`` and ``self.vmax`` are + initialized using ``self.autoscale_None(value)``. + """ + pass + + @abstractmethod + def autoscale(self, A): + """Set *vmin*, *vmax* to min, max of *A*.""" + pass + + @abstractmethod + def autoscale_None(self, A): + """If *vmin* or *vmax* are not set, use the min/max of *A* to set them.""" + pass + + @abstractmethod + def scaled(self): + """Return whether *vmin* and *vmax* are both set.""" + pass + + def _changed(self): + """ + Call this whenever the norm is changed to notify all the + callback listeners to the 'changed' signal. + """ + self.callbacks.process('changed') + + +class Normalize(Norm): """ A class which, when called, maps values within the interval ``[vmin, vmax]`` linearly to the interval ``[0.0, 1.0]``. The mapping of @@ -2307,15 +2388,15 @@ def __init__(self, vmin=None, vmax=None, clip=False): ----- If ``vmin == vmax``, input data will be mapped to 0. """ + super().__init__() self._vmin = _sanitize_extrema(vmin) self._vmax = _sanitize_extrema(vmax) self._clip = clip self._scale = None - self.callbacks = cbook.CallbackRegistry(signals=["changed"]) @property def vmin(self): - """Lower limit of the input data interval; maps to 0.""" + # docstring inherited return self._vmin @vmin.setter @@ -2327,7 +2408,7 @@ def vmin(self, value): @property def vmax(self): - """Upper limit of the input data interval; maps to 1.""" + # docstring inherited return self._vmax @vmax.setter @@ -2339,11 +2420,7 @@ def vmax(self, value): @property def clip(self): - """ - Determines the behavior for mapping values outside the range ``[vmin, vmax]``. - - See the *clip* parameter in `.Normalize`. - """ + # docstring inherited return self._clip @clip.setter @@ -2352,13 +2429,6 @@ def clip(self, value): self._clip = value self._changed() - def _changed(self): - """ - Call this whenever the norm is changed to notify all the - callback listeners to the 'changed' signal. - """ - self.callbacks.process('changed') - @staticmethod def process_value(value): """ @@ -2400,24 +2470,7 @@ def process_value(value): return result, is_scalar def __call__(self, value, clip=None): - """ - Normalize the data and return the normalized data. - - Parameters - ---------- - value - Data to normalize. - clip : bool, optional - See the description of the parameter *clip* in `.Normalize`. - - If ``None``, defaults to ``self.clip`` (which defaults to - ``False``). - - Notes - ----- - If not already initialized, ``self.vmin`` and ``self.vmax`` are - initialized using ``self.autoscale_None(value)``. - """ + # docstring inherited if clip is None: clip = self.clip @@ -2468,7 +2521,7 @@ def inverse(self, value): return vmin + value * (vmax - vmin) def autoscale(self, A): - """Set *vmin*, *vmax* to min, max of *A*.""" + # docstring inherited with self.callbacks.blocked(): # Pause callbacks while we are updating so we only get # a single update signal at the end @@ -2477,7 +2530,7 @@ def autoscale(self, A): self._changed() def autoscale_None(self, A): - """If *vmin* or *vmax* are not set, use the min/max of *A* to set them.""" + # docstring inherited A = np.asanyarray(A) if isinstance(A, np.ma.MaskedArray): @@ -2491,7 +2544,7 @@ def autoscale_None(self, A): self.vmax = A.max() def scaled(self): - """Return whether *vmin* and *vmax* are both set.""" + # docstring inherited return self.vmin is not None and self.vmax is not None @@ -2775,7 +2828,7 @@ def _make_norm_from_scale( unlike to arbitrary lambdas. """ - class Norm(base_norm_cls): + class ScaleNorm(base_norm_cls): def __reduce__(self): cls = type(self) # If the class is toplevel-accessible, it is possible to directly @@ -2855,15 +2908,15 @@ def autoscale_None(self, A): return super().autoscale_None(in_trf_domain) if base_norm_cls is Normalize: - Norm.__name__ = f"{scale_cls.__name__}Norm" - Norm.__qualname__ = f"{scale_cls.__qualname__}Norm" + ScaleNorm.__name__ = f"{scale_cls.__name__}Norm" + ScaleNorm.__qualname__ = f"{scale_cls.__qualname__}Norm" else: - Norm.__name__ = base_norm_cls.__name__ - Norm.__qualname__ = base_norm_cls.__qualname__ - Norm.__module__ = base_norm_cls.__module__ - Norm.__doc__ = base_norm_cls.__doc__ + ScaleNorm.__name__ = base_norm_cls.__name__ + ScaleNorm.__qualname__ = base_norm_cls.__qualname__ + ScaleNorm.__module__ = base_norm_cls.__module__ + ScaleNorm.__doc__ = base_norm_cls.__doc__ - return Norm + return ScaleNorm def _create_empty_object_of_class(cls): diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index eadd759bcaa3..cdc6e5e7d89f 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -1,4 +1,5 @@ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence +from abc import ABC, abstractmethod from matplotlib import cbook, scale import re @@ -249,8 +250,29 @@ class BivarColormapFromImage(BivarColormap): origin: Sequence[float] = ..., name: str = ... ) -> None: ... -class Normalize: +class Norm(ABC): callbacks: cbook.CallbackRegistry + def __init__(self) -> None: ... + @property + @abstractmethod + def vmin(self) -> float | tuple[float] | None: ... + @property + @abstractmethod + def vmax(self) -> float | tuple[float] | None: ... + @property + @abstractmethod + def clip(self) -> bool | tuple[bool]: ... + @abstractmethod + def __call__(self, value: np.ndarray, clip: bool | None = ...) -> ArrayLike: ... + @abstractmethod + def autoscale(self, A: ArrayLike) -> None: ... + @abstractmethod + def autoscale_None(self, A: ArrayLike) -> None: ... + @abstractmethod + def scaled(self) -> bool: ... + + +class Normalize(Norm): def __init__( self, vmin: float | None = ..., vmax: float | None = ..., clip: bool = ... ) -> None: ... diff --git a/lib/matplotlib/tests/baseline_images/test_colors/test_norm_abc.png b/lib/matplotlib/tests/baseline_images/test_colors/test_norm_abc.png new file mode 100644 index 000000000000..077365674ac2 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colors/test_norm_abc.png differ diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index df3f65bdb2dc..217cb3349b65 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -7,6 +7,7 @@ from PIL import Image import pytest import base64 +import platform from numpy.testing import assert_array_equal, assert_array_almost_equal @@ -1829,3 +1830,49 @@ def test_LinearSegmentedColormap_from_list_value_color_tuple(): cmap([value for value, _ in value_color_tuples]), to_rgba_array([color for _, color in value_color_tuples]), ) + + +@image_comparison(['test_norm_abc.png'], remove_text=True, + tol=0 if platform.machine() == 'x86_64' else 0.05) +def test_norm_abc(): + + class CustomHalfNorm(mcolors.Norm): + def __init__(self): + super().__init__() + + @property + def vmin(self): + return 0 + + @property + def vmax(self): + return 1 + + @property + def clip(self): + return False + + def __call__(self, value, clip=None): + return value / 2 + + def inverse(self, value): + return 2 * value + + def autoscale(self, A): + pass + + def autoscale_None(self, A): + pass + + def scaled(self): + return True + + fig, axes = plt.subplots(2,2) + + r = np.linspace(-1, 3, 16*16).reshape((16,16)) + norm = CustomHalfNorm() + colorizer = mpl.colorizer.Colorizer(cmap='viridis', norm=norm) + c = axes[0,0].imshow(r, colorizer=colorizer) + axes[0,1].pcolor(r, colorizer=colorizer) + axes[1,0].contour(r, colorizer=colorizer) + axes[1,1].contourf(r, colorizer=colorizer)