diff --git a/doc/api/colors_api.rst b/doc/api/colors_api.rst index 49a42c8f9601..18e7c43932a9 100644 --- a/doc/api/colors_api.rst +++ b/doc/api/colors_api.rst @@ -32,6 +32,7 @@ Color norms PowerNorm SymLogNorm TwoSlopeNorm + MultiNorm Univariate Colormaps -------------------- diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index a09b4f3d4f5c..a1569b8df31f 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -2337,6 +2337,17 @@ def _changed(self): """ self.callbacks.process('changed') + @property + @abstractmethod + def n_components(self): + """ + The number of normalized components. + + This is number of elements of the parameter to ``__call__`` and of + *vmin*, *vmax*. + """ + pass + class Normalize(Norm): """ @@ -2547,6 +2558,19 @@ def scaled(self): # docstring inherited return self.vmin is not None and self.vmax is not None + @property + def n_components(self): + """ + The number of distinct components supported (1). + + This is number of elements of the parameter to ``__call__`` and of + *vmin*, *vmax*. + + This class support only a single component, as opposed to `MultiNorm` + which supports multiple components. + """ + return 1 + class TwoSlopeNorm(Normalize): def __init__(self, vcenter, vmin=None, vmax=None): @@ -3272,6 +3296,371 @@ def inverse(self, value): return value +class MultiNorm(Norm): + """ + A class which contains multiple scalar norms. + """ + + def __init__(self, norms, vmin=None, vmax=None, clip=None): + """ + Parameters + ---------- + norms : list of (str or `Normalize`) + The constituent norms. The list must have a minimum length of 2. + vmin, vmax : None or list of (float or None) + Limits of the constituent norms. + If a list, one value is assigned to each of the constituent + norms. + If None, the limits of the constituent norms + are not changed. + clip : None or list of bools, default: None + Determines the behavior for mapping values outside the range + ``[vmin, vmax]`` for the constituent norms. + If a list, each value is assigned to each of the constituent + norms. + If None, the behaviour of the constituent norms is not changed. + """ + if cbook.is_scalar_or_string(norms): + raise ValueError( + "MultiNorm must be assigned multiple norms, where each norm " + f"is of type `str`, or `Normalize`, not {type(norms)}") + + if len(norms) < 2: + raise ValueError("MultiNorm must be assigned at least two norms") + + def resolve(norm): + if isinstance(norm, str): + scale_cls = _get_scale_cls_from_str(norm) + return mpl.colorizer._auto_norm_from_scale(scale_cls)() + elif isinstance(norm, Normalize): + return norm + else: + raise ValueError( + "MultiNorm must be assigned multiple norms, where each norm " + f"is of type `str`, or `Normalize`, not {type(norm)}") + + self._norms = tuple(resolve(norm) for norm in norms) + + self.callbacks = cbook.CallbackRegistry(signals=["changed"]) + + self.vmin = vmin + self.vmax = vmax + self.clip = clip + + for n in self._norms: + n.callbacks.connect('changed', self._changed) + + @property + def n_components(self): + """Number of norms held by this `MultiNorm`.""" + return len(self._norms) + + @property + def norms(self): + """The individual norms held by this `MultiNorm`.""" + return self._norms + + @property + def vmin(self): + """The lower limit of each constituent norm.""" + return tuple(n.vmin for n in self._norms) + + @vmin.setter + def vmin(self, values): + if values is None: + return + if not np.iterable(values) or len(values) != self.n_components: + raise ValueError("*vmin* must have one component for each norm. " + f"Expected an iterable of length {self.n_components}") + with self.callbacks.blocked(): + for norm, v in zip(self.norms, values): + norm.vmin = v + self._changed() + + @property + def vmax(self): + """The upper limit of each constituent norm.""" + return tuple(n.vmax for n in self._norms) + + @vmax.setter + def vmax(self, values): + if values is None: + return + if not np.iterable(values) or len(values) != self.n_components: + raise ValueError("*vmax* must have one component for each norm. " + f"Expected an iterable of length {self.n_components}") + with self.callbacks.blocked(): + for norm, v in zip(self.norms, values): + norm.vmax = v + self._changed() + + @property + def clip(self): + """The clip behaviour of each constituent norm.""" + return tuple(n.clip for n in self._norms) + + @clip.setter + def clip(self, values): + if values is None: + return + if not np.iterable(values) or len(values) != self.n_components: + raise ValueError("*clip* must have one component for each norm. " + f"Expected an iterable of length {self.n_components}") + with self.callbacks.blocked(): + for norm, v in zip(self.norms, values): + norm.clip = v + 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') + + def __call__(self, values, clip=None): + """ + Normalize the data and return the normalized data. + + Each component of the input is normalized via the constituent norm. + + Parameters + ---------- + values : array-like + The input data, as an iterable or a structured numpy array. + + - If iterable, must be of length `n_components`. Each element can be a + scalar or array-like and is normalized through the correspong norm. + - If structured array, must have `n_components` fields. Each field + is normalized through the corresponding norm. + + clip : list of bools or None, optional + Determines the behavior for mapping values outside the range + ``[vmin, vmax]``. See the description of the parameter *clip* in + `.Normalize`. + If ``None``, defaults to ``self.clip`` (which defaults to + ``False``). + + Returns + ------- + tuple + Normalized input values + + Notes + ----- + If not already initialized, ``self.vmin`` and ``self.vmax`` are + initialized using ``self.autoscale_None(values)``. + """ + if clip is None: + clip = self.clip + if not np.iterable(clip) or len(clip) != self.n_components: + raise ValueError("*clip* must have one component for each norm. " + f"Expected an iterable of length {self.n_components}") + + values = self._iterable_components_in_data(values, self.n_components) + result = tuple(n(v, clip=c) for n, v, c in zip(self.norms, values, clip)) + return result + + def inverse(self, values): + """ + Map the normalized values (i.e., index in the colormap) back to data values. + + Parameters + ---------- + values : array-like + The input data, as an iterable or a structured numpy array. + + - If iterable, must be of length `n_components`. Each element can be a + scalar or array-like and is mapped through the correspong norm. + - If structured array, must have `n_components` fields. Each field + is mapped through the the corresponding norm. + + """ + values = self._iterable_components_in_data(values, self.n_components) + result = [n.inverse(v) for n, v in zip(self.norms, values)] + return result + + def autoscale(self, A): + """ + For each constituent norm, set *vmin*, *vmax* to min, max of the corresponding + component in *A*. + + Parameters + ---------- + A : array-like + The input data, as an iterable or a structured numpy array. + + - If iterable, must be of length `n_components`. Each element + is used for the limits of one constituent norm. + - If structured array, must have `n_components` fields. Each field + is used for the limits of one constituent norm. + """ + with self.callbacks.blocked(): + A = self._iterable_components_in_data(A, self.n_components) + for n, a in zip(self.norms, A): + n.autoscale(a) + self._changed() + + def autoscale_None(self, A): + """ + If *vmin* or *vmax* are not set on any constituent norm, + use the min/max of the corresponding component in *A* to set them. + + Parameters + ---------- + A : array-like + The input data, as an iterable or a structured numpy array. + + - If iterable, must be of length `n_components`. Each element + is used for the limits of one constituent norm. + - If structured array, must have `n_components` fields. Each field + is used for the limits of one constituent norm. + """ + with self.callbacks.blocked(): + A = self._iterable_components_in_data(A, self.n_components) + for n, a in zip(self.norms, A): + n.autoscale_None(a) + self._changed() + + def scaled(self): + """Return whether both *vmin* and *vmax* are set on all constituent norms.""" + return all(n.scaled() for n in self.norms) + + @staticmethod + def _iterable_components_in_data(data, n_components): + """ + Provides an iterable over the components contained in the data. + + An input array with `n_components` fields is returned as a tuple of length n + referencing slices of the original array. + + Parameters + ---------- + data : array-like + The input data, as an iterable or a structured numpy array. + + - If iterable, must be of length `n_components` + - If structured array, must have `n_components` fields. + + Returns + ------- + tuple of np.ndarray + + """ + if isinstance(data, np.ndarray) and data.dtype.fields is not None: + # structured array + if len(data.dtype.fields) != n_components: + raise ValueError( + "Structured array inputs to MultiNorm must have the same " + "number of fields as components in the MultiNorm. Expected " + f"{n_components}, but got {len(data.dtype.fields)} fields" + ) + else: + return tuple(data[field] for field in data.dtype.names) + try: + n_elements = len(data) + except TypeError: + raise ValueError("MultiNorm expects a sequence with one element per " + f"component as input, but got {data!r} instead") + if n_elements != n_components: + if isinstance(data, np.ndarray) and data.shape[-1] == n_components: + if len(data.shape) == 2: + raise ValueError( + f"MultiNorm expects a sequence with one element per component. " + "You can use `data_transposed = data.T`" + "to convert the input data of shape " + f"{data.shape} to a compatible shape {data.shape[::-1]} ") + else: + raise ValueError( + f"MultiNorm expects a sequence with one element per component. " + "You can use `data_as_list = [data[..., i] for i in " + "range(data.shape[-1])]` to convert the input data of shape " + f" {data.shape} to a compatible list") + + raise ValueError( + "MultiNorm expects a sequence with one element per component. " + f"This MultiNorm has {n_components} components, but got a sequence " + f"with {n_elements} elements" + ) + + return tuple(data[i] for i in range(n_elements)) + + + @staticmethod + def _ensure_multicomponent_data(data, n_components): + """ + Ensure that the data has dtype with n_components. + Input data of shape (n_components, n, m) is converted to an array of shape + (n, m) with data type np.dtype(f'{data.dtype}, ' * n_components) + Complex data is returned as a view with dtype np.dtype('float64, float64') + or np.dtype('float32, float32') + If n_components is 1 and data is not of type np.ndarray (i.e. PIL.Image), + the data is returned unchanged. + If data is None, the function returns None + + Parameters + ---------- + n_components : int + Number of omponents in the data. + data : np.ndarray, PIL.Image or None + + Returns + ------- + np.ndarray, PIL.Image or None + """ + + if isinstance(data, np.ndarray): + if len(data.dtype.descr) == n_components: + # pass scalar data + # and already formatted data + return data + elif data.dtype in [np.complex64, np.complex128]: + # pass complex data + if data.dtype == np.complex128: + dt = np.dtype('float64, float64') + else: + dt = np.dtype('float32, float32') + reconstructed = np.ma.frombuffer(data.data, + dtype=dt).reshape(data.shape) + if np.ma.is_masked(data): + for descriptor in dt.descr: + reconstructed[descriptor[0]][data.mask] = np.ma.masked + return reconstructed + + if n_components > 1 and len(data) == n_components: + # convert data from shape (n_components, n, m) + # to (n,m) with a new dtype + data = [np.ma.array(part, copy=False) for part in data] + dt = np.dtype(', '.join([f'{part.dtype}' for part in data])) + fields = [descriptor[0] for descriptor in dt.descr] + reconstructed = np.ma.empty(data[0].shape, dtype=dt) + for i, f in enumerate(fields): + if data[i].shape != reconstructed.shape: + raise ValueError("For mutlicomponent data all components must " + f"have same shape, not {data[0].shape} " + f"and {data[i].shape}") + reconstructed[f] = data[i] + if np.ma.is_masked(data[i]): + reconstructed[f][data[i].mask] = np.ma.masked + return reconstructed + + if data is None: + return data + + if n_components == 1: + # PIL.Image also gets passed here + return data + + elif n_components == 2: + raise ValueError("Invalid data entry for mutlicomponent data. The data " + "must contain complex numbers, or have a first dimension " + "2, or be of a dtype with 2 fields") + else: + raise ValueError("Invalid data entry for mutlicomponent data. The shape " + f"of the data must have a first dimension {n_components} " + f"or be of a dtype with {n_components} fields") + + def rgb_to_hsv(arr): """ Convert an array of float RGB values (in the range [0, 1]) to HSV values. @@ -3909,3 +4298,34 @@ def from_levels_and_colors(levels, colors, extend='neither'): norm = BoundaryNorm(levels, ncolors=n_data_colors) return cmap, norm + + +def _get_scale_cls_from_str(scale_as_str): + """ + Returns the scale class from a string. + + Used in the creation of norms from a string to ensure a reasonable error + in the case where an invalid string is used. This would normally use + `_api.check_getitem()`, which would produce the error: + 'not_a_norm' is not a valid value for norm; supported values are + 'linear', 'log', 'symlog', 'asinh', 'logit', 'function', 'functionlog'. + which is misleading because the norm keyword also accepts `Normalize` objects. + + Parameters + ---------- + scale_as_str : string + A string corresponding to a scale + + Returns + ------- + A subclass of ScaleBase. + + """ + try: + scale_cls = scale._scale_mapping[scale_as_str] + except KeyError: + raise ValueError( + f"Invalid norm name {scale_as_str!r}; supported values are " + f"{', '.join(scale._scale_mapping)}" + ) from None + return scale_cls diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index cdc6e5e7d89f..3ebd08e6025e 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -270,6 +270,9 @@ class Norm(ABC): def autoscale_None(self, A: ArrayLike) -> None: ... @abstractmethod def scaled(self) -> bool: ... + @abstractmethod + @property + def n_components(self) -> int: ... class Normalize(Norm): @@ -305,6 +308,8 @@ class Normalize(Norm): def autoscale(self, A: ArrayLike) -> None: ... def autoscale_None(self, A: ArrayLike) -> None: ... def scaled(self) -> bool: ... + @property + def n_components(self) -> Literal[1]: ... class TwoSlopeNorm(Normalize): def __init__( @@ -409,6 +414,44 @@ class BoundaryNorm(Normalize): class NoNorm(Normalize): ... +class MultiNorm(Norm): + # Here "type: ignore[override]" is used for functions with a return type + # that differs from the function in the base class. + # i.e. where `MultiNorm` returns a tuple and Normalize returns a `float` etc. + def __init__( + self, + norms: ArrayLike, + vmin: ArrayLike | None = ..., + vmax: ArrayLike | None = ..., + clip: ArrayLike | None = ... + ) -> None: ... + @property + def norms(self) -> tuple[Normalize, ...]: ... + @property # type: ignore[override] + def vmin(self) -> tuple[float | None, ...]: ... + @vmin.setter + def vmin(self, values: ArrayLike | None) -> None: ... + @property # type: ignore[override] + def vmax(self) -> tuple[float | None, ...]: ... + @vmax.setter + def vmax(self, valued: ArrayLike | None) -> None: ... + @property # type: ignore[override] + def clip(self) -> tuple[bool, ...]: ... + @clip.setter + def clip(self, values: ArrayLike | None) -> None: ... + @overload + def __call__(self, values: tuple[np.ndarray, ...], clip: ArrayLike | bool | None = ...) -> tuple[np.ndarray]: ... + @overload + def __call__(self, values: tuple[float, ...], clip: ArrayLike | bool | None = ...) -> tuple[float]: ... + @overload + def __call__(self, values: ArrayLike, clip: ArrayLike | bool | None = ...) -> tuple: ... + def inverse(self, values: ArrayLike) -> list: ... # type: ignore[override] + def autoscale(self, A: ArrayLike) -> None: ... + def autoscale_None(self, A: ArrayLike) -> None: ... + def scaled(self) -> bool: ... + @property + def n_components(self) -> int: ... + def rgb_to_hsv(arr: ArrayLike) -> np.ndarray: ... def hsv_to_rgb(hsv: ArrayLike) -> np.ndarray: ... diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index f54ac46afea5..8a79a8b4b230 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -9,6 +9,7 @@ import base64 import platform +from numpy.lib import recfunctions as rfn from numpy.testing import assert_array_equal, assert_array_almost_equal from matplotlib import cbook, cm @@ -1867,6 +1868,9 @@ def autoscale_None(self, A): def scaled(self): return True + def n_components(self): + return 1 + fig, axes = plt.subplots(2,2) r = np.linspace(-1, 3, 16*16).reshape((16,16)) @@ -1886,3 +1890,171 @@ def test_close_error_name(): "Did you mean one of ['gray', 'Grays', 'gray_r']?" )): matplotlib.colormaps["grays"] + + +def test_multi_norm_creation(): + # tests for mcolors.MultiNorm + + # test wrong input + with pytest.raises(ValueError, + match="MultiNorm must be assigned multiple norms"): + mcolors.MultiNorm("linear") + with pytest.raises(ValueError, + match="MultiNorm must be assigned at least two"): + mcolors.MultiNorm(["linear"]) + with pytest.raises(ValueError, + match="MultiNorm must be assigned multiple norms, "): + mcolors.MultiNorm(None) + with pytest.raises(ValueError, + match="Invalid norm name "): + mcolors.MultiNorm(["linear", "bad_norm_name"]) + + norm = mpl.colors.MultiNorm(['linear', 'linear']) + + +def test_multi_norm_call_vmin_vmax(): + # test get vmin, vmax + norm = mpl.colors.MultiNorm(['linear', 'log']) + norm.vmin = (1, 1) + norm.vmax = (2, 2) + assert norm.vmin == (1, 1) + assert norm.vmax == (2, 2) + + with pytest.raises(ValueError, match="Expected an iterable of length 2"): + norm.vmin = 1 + with pytest.raises(ValueError, match="Expected an iterable of length 2"): + norm.vmax = 1 + with pytest.raises(ValueError, match="Expected an iterable of length 2"): + norm.vmin = (1, 2, 3) + with pytest.raises(ValueError, match="Expected an iterable of length 2"): + norm.vmax = (1, 2, 3) + + +def test_multi_norm_call_clip_inverse(): + # test get vmin, vmax + norm = mpl.colors.MultiNorm(['linear', 'log']) + norm.vmin = (1, 1) + norm.vmax = (2, 2) + + # test call with clip + assert_array_equal(norm([3, 3], clip=[False, False]), [2.0, 1.584962500721156]) + assert_array_equal(norm([3, 3], clip=[True, True]), [1.0, 1.0]) + assert_array_equal(norm([3, 3], clip=[True, False]), [1.0, 1.584962500721156]) + norm.clip = [False, False] + assert_array_equal(norm([3, 3]), [2.0, 1.584962500721156]) + norm.clip = [True, True] + assert_array_equal(norm([3, 3]), [1.0, 1.0]) + norm.clip = [True, False] + assert_array_equal(norm([3, 3]), [1.0, 1.584962500721156]) + norm.clip = [True, True] + + with pytest.raises(ValueError, match="Expected an iterable of length 2"): + norm.clip = True + with pytest.raises(ValueError, match="Expected an iterable of length 2"): + norm.clip = [True, False, True] + with pytest.raises(ValueError, match="Expected an iterable of length 2"): + norm([3, 3], clip=True) + with pytest.raises(ValueError, match="Expected an iterable of length 2"): + norm([3, 3], clip=[True, True, True]) + + # test inverse + assert_array_almost_equal(norm.inverse([0.5, 0.5849625007211562]), [1.5, 1.5]) + + +def test_multi_norm_autoscale(): + norm = mpl.colors.MultiNorm(['linear', 'log']) + # test autoscale + norm.autoscale([[0, 1, 2, 3], [0.1, 1, 2, 3]]) + assert_array_equal(norm.vmin, [0, 0.1]) + assert_array_equal(norm.vmax, [3, 3]) + + # test autoscale_none + norm0 = mcolors.TwoSlopeNorm(2, vmin=0, vmax=None) + norm = mcolors.MultiNorm([norm0, 'linear'], vmax=[None, 50]) + norm.autoscale_None([[1, 2, 3, 4, 5], [-50, 1, 0, 1, 500]]) + assert_array_equal(norm([5, 0]), [1, 0.5]) + assert_array_equal(norm.vmin, (0, -50)) + assert_array_equal(norm.vmax, (5, 50)) + + +def test_mult_norm_call_types(): + mn = mpl.colors.MultiNorm(['linear', 'linear']) + mn.vmin = (-2, -2) + mn.vmax = (2, 2) + + vals = np.arange(6).reshape((3,2)) + target = np.ma.array([(0.5, 0.75), + (1., 1.25), + (1.5, 1.75)]) + + # test structured array as input + structured_target = rfn.unstructured_to_structured(target) + from_mn= mn(rfn.unstructured_to_structured(vals)) + assert_array_almost_equal(from_mn, + target.T) + + # test list of arrays as input + assert_array_almost_equal(mn(list(vals.T)), + list(target.T)) + # test list of floats as input + assert_array_almost_equal(mn(list(vals[0])), + list(target[0])) + # test tuple of arrays as input + assert_array_almost_equal(mn(tuple(vals.T)), + list(target.T)) + + # np.arrays of shapes that are compatible + assert_array_almost_equal(mn(np.zeros(2)), + 0.5*np.ones(2)) + assert_array_almost_equal(mn(np.zeros((2, 3))), + 0.5*np.ones((2, 3))) + assert_array_almost_equal(mn(np.zeros((2, 3, 4))), + 0.5*np.ones((2, 3, 4))) + + # test with NoNorm, list as input + mn_no_norm = mpl.colors.MultiNorm(['linear', mcolors.NoNorm()]) + no_norm_out = mn_no_norm(list(vals.T)) + assert_array_almost_equal(no_norm_out, + [[0., 0.5, 1.], + [1, 3, 5]]) + assert no_norm_out[0].dtype == np.dtype('float64') + assert no_norm_out[1].dtype == np.dtype('int64') + + # test with NoNorm, structured array as input + mn_no_norm = mpl.colors.MultiNorm(['linear', mcolors.NoNorm()]) + no_norm_out = mn_no_norm(rfn.unstructured_to_structured(vals)) + assert_array_almost_equal(no_norm_out, + [[0., 0.5, 1.], + [1, 3, 5]]) + + # test single int as input + with pytest.raises(ValueError, + match="component as input, but got 1 instead"): + mn(1) + + # test list of incompatible size + with pytest.raises(ValueError, + match="but got a sequence with 3 elements"): + mn([3, 2, 1]) + + # last axis matches, len(data.shape) > 2 + with pytest.raises(ValueError, + match=(r"`data_as_list = \[data\[..., i\] for i in " + r"range\(data.shape\[-1\]\)\]`")): + mn(np.zeros((3, 3, 2))) + + # last axis matches, len(data.shape) == 2 + with pytest.raises(ValueError, + match=r"You can use `data_transposed = data.T`to convert"): + mn(np.zeros((3, 2))) + + # incompatible arrays where no relevant axis matches + for data in [np.zeros(3), np.zeros((3, 2, 3))]: + with pytest.raises(ValueError, + match=r"but got a sequence with 3 elements"): + mn(data) + + # test incompatible class + with pytest.raises(ValueError, + match="but got pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy