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 dd5d22130904..867dce5b1eac 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -41,6 +41,7 @@ import base64 from collections.abc import Sequence, Mapping +from typing import Protocol, runtime_checkable import functools import importlib import inspect @@ -2257,6 +2258,86 @@ def _init(self): self._isinit = True +@runtime_checkable +class Norm(Protocol): + + callbacks: cbook.CallbackRegistry + + @property + def vmin(self): + """Lower limit of the input data interval; maps to 0.""" + ... + + + @property + def vmax(self): + """Upper limit of the input data interval; maps to 1.""" + ... + + @property + def clip(self): + """ + Determines the behavior for mapping values outside the range ``[vmin, vmax]``. + + See the *clip* parameter in `.Normalize`. + """ + ... + + def _changed(self): + """ + Call this whenever the norm is changed to notify all the + callback listeners to the 'changed' signal. + """ + ... + + + 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)``. + """ + ... + + def inverse(self, value): + """ + Maps the normalized value (i.e., index in the colormap) back to image + data value. + + Parameters + ---------- + value + Normalized value. + """ + ... + + + def autoscale(self, A): + """Set *vmin*, *vmax* to min, max of *A*.""" + ... + + def autoscale_None(self, A): + """If *vmin* or *vmax* are not set, use the min/max of *A* to set them.""" + ... + + def scaled(self): + """Return whether *vmin* and *vmax* are both set.""" + ... + + class Normalize: """ A class which, when called, maps values within the interval diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index eadd759bcaa3..756202c5d960 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -2,7 +2,7 @@ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from matplotlib import cbook, scale import re -from typing import Any, Literal, overload +from typing import Any, Literal, overload, Protocol, runtime_checkable from .typing import ColorType import numpy as np @@ -249,6 +249,31 @@ class BivarColormapFromImage(BivarColormap): origin: Sequence[float] = ..., name: str = ... ) -> None: ... +@runtime_checkable +class Norm(Protocol): + callbacks: cbook.CallbackRegistry + @property + def vmin(self) -> float | tuple[float] | None: ... + @property + def vmax(self) -> float | tuple[float] | None: ... + @property + def clip(self) -> bool | tuple[bool]: ... + @overload + def __call__(self, value: float, clip: bool | None = ...) -> float: ... + @overload + def __call__(self, value: np.ndarray, clip: bool | None = ...) -> np.ma.MaskedArray: ... + @overload + def __call__(self, value: ArrayLike, clip: bool | None | list = ...) -> ArrayLike: ... + @overload + def inverse(self, value: float) -> float: ... + @overload + def inverse(self, value: np.ndarray) -> np.ma.MaskedArray: ... + @overload + def inverse(self, value: ArrayLike) -> ArrayLike: ... + def autoscale(self, A: ArrayLike) -> None: ... + def autoscale_None(self, A: ArrayLike) -> None: ... + def scaled(self) -> bool: ... + class Normalize: callbacks: cbook.CallbackRegistry def __init__( diff --git a/lib/matplotlib/tests/baseline_images/test_colors/test_norm_protocol.png b/lib/matplotlib/tests/baseline_images/test_colors/test_norm_protocol.png new file mode 100644 index 000000000000..b9627bc369fb Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_colors/test_norm_protocol.png differ diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index df3f65bdb2dc..e687c414d285 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 @@ -19,7 +20,7 @@ import matplotlib.pyplot as plt import matplotlib.scale as mscale from matplotlib.rcsetup import cycler -from matplotlib.testing.decorators import image_comparison, check_figures_equal +from matplotlib.testing.decorators import (image_comparison, check_figures_equal) from matplotlib.colors import is_color_like, to_rgba_array, ListedColormap @@ -1829,3 +1830,52 @@ 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_protocol.png'], remove_text=True, + tol=0 if platform.machine() == 'x86_64' else 0.05) +def test_norm_protocol(): + class CustomHalfNorm: + def __init__(self): + self.callbacks = mpl.cbook.CallbackRegistry(signals=["changed"]) + + @property + def vmin(self): + return 0 + + @property + def vmax(self): + return 1 + + @property + def clip(self): + return False + + def _changed(self): + self.callbacks.process('changed') + + def __call__(self, value, clip=None): + return value/2 + + def inverse(self, value): + return 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)