From e4f179f930d22f9060fc10a3b8974685fdf151bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Thu, 10 Jul 2025 21:17:44 +0200 Subject: [PATCH 01/13] MultiNorm class This commit merges a number of commits now contained in https://github.com/trygvrad/matplotlib/tree/multivariate-plot-prapare-backup , keeping only the MultiNorm class --- doc/api/colors_api.rst | 1 + lib/matplotlib/colors.py | 385 ++++++++++++++++++++++++++++ lib/matplotlib/colors.pyi | 51 ++++ lib/matplotlib/tests/test_colors.py | 54 ++++ 4 files changed, 491 insertions(+) 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..e7e0eb4c85ef 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,20 @@ 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 compoenent, as opposed to `MultiNorm` + which supports multiple components. + + """ + return 1 + class TwoSlopeNorm(Normalize): def __init__(self, vcenter, vmin=None, vmax=None): @@ -3272,6 +3297,335 @@ 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=False): + """ + Parameters + ---------- + norms : list of (str, `Normalize` or None) + The constituent norms. The list must have a minimum length of 2. + vmin, vmax : float or None or list of (float or None) + Limits of the constituent norms. + If a list, each value is assigned to each of the constituent + norms. Single values are repeated to form a list of appropriate size. + + clip : bool or list of bools, default: False + 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. Single values are repeated to form a list of appropriate size. + + """ + + if cbook.is_scalar_or_string(norms): + raise ValueError("A MultiNorm must be assigned multiple norms") + + norms = [*norms] + for i, n in enumerate(norms): + if n is None: + norms[i] = Normalize() + elif isinstance(n, str): + scale_cls = _get_scale_cls_from_str(n) + norms[i] = mpl.colorizer._auto_norm_from_scale(scale_cls)() + elif not isinstance(n, Normalize): + raise ValueError( + "MultiNorm must be assigned multiple norms, where each norm " + f"is of type `None` `str`, or `Normalize`, not {type(n)}") + + # Convert the list of norms to a tuple to make it immutable. + # If there is a use case for swapping a single norm, we can add support for + # that later + self._norms = tuple(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, value): + value = np.broadcast_to(value, self.n_components) + with self.callbacks.blocked(): + for i, v in enumerate(value): + if v is not None: + self.norms[i].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, value): + value = np.broadcast_to(value, self.n_components) + with self.callbacks.blocked(): + for i, v in enumerate(value): + if v is not None: + self.norms[i].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, value): + value = np.broadcast_to(value, self.n_components) + with self.callbacks.blocked(): + for i, v in enumerate(value): + if v is not None: + self.norms[i].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, value, clip=None, structured_output=None): + """ + Normalize the data and return the normalized data. + + Each component of the input is assigned to the constituent norm. + + Parameters + ---------- + value : array-like + Data to normalize, as tuple, scalar array or structured array. + + - If tuple, must be of length `n_components` + - If scalar array, the first axis must be of length `n_components` + - If structured array, must have `n_components` fields. + + clip : list of bools or bool 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``). + structured_output : bool, optional + + - If True, output is returned as a structured array + - If False, output is returned as a tuple of length `n_components` + - If None (default) output is returned in the same format as the input. + + Returns + ------- + tuple or `~numpy.ndarray` + Normalized input values` + + Notes + ----- + If not already initialized, ``self.vmin`` and ``self.vmax`` are + initialized using ``self.autoscale_None(value)``. + """ + if clip is None: + clip = self.clip + elif not np.iterable(clip): + clip = [clip]*self.n_components + + if structured_output is None: + if isinstance(value, np.ndarray) and value.dtype.fields is not None: + structured_output = True + else: + structured_output = False + + value = self._iterable_components_in_data(value, self.n_components) + + result = tuple(n(v, clip=c) for n, v, c in zip(self.norms, value, clip)) + + if structured_output: + result = self._ensure_multicomponent_data(result, self.n_components) + + return result + + def inverse(self, value): + """ + Map the normalized value (i.e., index in the colormap) back to image data value. + + Parameters + ---------- + value + Normalized value, as tuple, scalar array or structured array. + + - If tuple, must be of length `n_components` + - If scalar array, the first axis must be of length `n_components` + - If structured array, must have `n_components` fields. + + """ + value = self._iterable_components_in_data(value, self.n_components) + result = [n.inverse(v) for n, v in zip(self.norms, value)] + return result + + def autoscale(self, A): + """ + For each constituent norm, Set *vmin*, *vmax* to min, max of the corresponding + component in *A*. + + Parameters + ---------- + A + Data, must be of length `n_components` or be a structured array or scalar + with `n_components` fields. + """ + with self.callbacks.blocked(): + # Pause callbacks while we are updating so we only get + # a single update signal at the end + 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 + Data, must be of length `n_components` or be a structured array or scalar + with `n_components` fields. + """ + 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 list of length n + referencing slices of the original array. + + Parameters + ---------- + data : np.ndarray, tuple or list + The input array. It must either be an array with n_components fields or have + a length (n_components) + + Returns + ------- + tuple of np.ndarray + + """ + if isinstance(data, np.ndarray) and data.dtype.fields is not None: + data = tuple(data[descriptor[0]] for descriptor in data.dtype.descr) + if len(data) != n_components: + raise ValueError("The input to this `MultiNorm` must be of shape " + f"({n_components}, ...), or be structured array or scalar " + f"with {n_components} fields.") + return data + + @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 +4263,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( + "Invalid norm str name; the following values are " + f"supported: {', '.join(scale._scale_mapping)}" + ) from None + return scale_cls diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index cdc6e5e7d89f..75770b939dad 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,52 @@ 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 | float | None = ..., + vmax: ArrayLike | float | None = ..., + clip: ArrayLike | bool = ... + ) -> None: ... + @property + def norms(self) -> tuple[Normalize, ...]: ... + @property # type: ignore[override] + def vmin(self) -> tuple[float | None, ...]: ... + @vmin.setter + def vmin(self, value: ArrayLike | float | None) -> None: ... + @property # type: ignore[override] + def vmax(self) -> tuple[float | None, ...]: ... + @vmax.setter + def vmax(self, value: ArrayLike | float | None) -> None: ... + @property # type: ignore[override] + def clip(self) -> tuple[bool, ...]: ... + @clip.setter + def clip(self, value: ArrayLike | bool) -> None: ... + @overload + def __call__(self, value: tuple, clip: ArrayLike | bool | None = ..., structured_output: Literal[False] = ...) -> tuple: ... + @overload + def __call__(self, value: tuple, structured_output: Literal[True], clip: ArrayLike | bool | None = ...) -> np.ma.MaskedArray: ... + @overload + def __call__(self, value: np.ndarray, clip: ArrayLike | bool | None = ..., structured_output: None | Literal[True] = ...) -> np.ma.MaskedArray: ... + @overload + def __call__(self, value: np.ndarray, structured_output: Literal[False], clip: ArrayLike | bool | None = ...) -> tuple: ... + @overload + def __call__(self, value: ArrayLike, structured_output: Literal[False], clip: ArrayLike | bool | None = ...) -> tuple: ... + @overload + def __call__(self, value: ArrayLike, structured_output: Literal[True], clip: ArrayLike | bool | None = ...) -> np.ma.MaskedArray: ... + @overload + def __call__(self, value: ArrayLike, clip: ArrayLike | bool | None = ..., structured_output: None = ...) -> ArrayLike: ... + def inverse(self, value: 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..7c1c9bc6345b 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1867,6 +1867,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 +1889,54 @@ def test_close_error_name(): "Did you mean one of ['gray', 'Grays', 'gray_r']?" )): matplotlib.colormaps["grays"] + + +def test_multi_norm(): + # tests for mcolors.MultiNorm + + # test wrong input + with pytest.raises(ValueError, + match="A MultiNorm must be assigned multiple norms"): + mcolors.MultiNorm("bad_norm_name") + with pytest.raises(ValueError, + match="Invalid norm str name"): + mcolors.MultiNorm(["bad_norm_name"]) + with pytest.raises(ValueError, + match="MultiNorm must be assigned multiple norms, " + "where each norm is of type `None`"): + mcolors.MultiNorm([4]) + + # test get vmin, vmax + norm = mpl.colors.MultiNorm(['linear', 'log']) + norm.vmin = 1 + norm.vmax = 2 + assert norm.vmin == (1, 1) + assert norm.vmax == (2, 2) + + # test call with clip + assert_array_equal(norm([3, 3], clip=False), [2.0, 1.584962500721156]) + assert_array_equal(norm([3, 3], clip=True), [1.0, 1.0]) + assert_array_equal(norm([3, 3], clip=[True, False]), [1.0, 1.584962500721156]) + norm.clip = False + assert_array_equal(norm([3, 3]), [2.0, 1.584962500721156]) + norm.clip = 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 + + # test inverse + assert_array_almost_equal(norm.inverse([0.5, 0.5849625007211562]), [1.5, 1.5]) + + # 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, None], 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)) From a57146b20b3c12348f679ae9f5d68add75354399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Sat, 12 Jul 2025 10:36:14 +0200 Subject: [PATCH 02/13] Apply suggestions from code review Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/colors.py | 47 +++++++++++++---------------- lib/matplotlib/tests/test_colors.py | 15 ++++++--- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index e7e0eb4c85ef..1512b9c57ad5 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -2566,9 +2566,8 @@ def n_components(self): This is number of elements of the parameter to ``__call__`` and of *vmin*, *vmax*. - This class support only a single compoenent, as opposed to `MultiNorm` + This class support only a single component, as opposed to `MultiNorm` which supports multiple components. - """ return 1 @@ -3306,7 +3305,7 @@ def __init__(self, norms, vmin=None, vmax=None, clip=False): """ Parameters ---------- - norms : list of (str, `Normalize` or None) + norms : list of (str or `Normalize`) The constituent norms. The list must have a minimum length of 2. vmin, vmax : float or None or list of (float or None) Limits of the constituent norms. @@ -3318,28 +3317,24 @@ def __init__(self, norms, vmin=None, vmax=None, clip=False): ``[vmin, vmax]`` for the constituent norms. If a list, each value is assigned to each of the constituent norms. Single values are repeated to form a list of appropriate size. - """ - if cbook.is_scalar_or_string(norms): - raise ValueError("A MultiNorm must be assigned multiple norms") - - norms = [*norms] - for i, n in enumerate(norms): - if n is None: - norms[i] = Normalize() - elif isinstance(n, str): - scale_cls = _get_scale_cls_from_str(n) - norms[i] = mpl.colorizer._auto_norm_from_scale(scale_cls)() - elif not isinstance(n, Normalize): + raise ValueError( + "MultiNorm must be assigned multiple norms, where each norm " + f"is of type `str`, or `Normalize`, not {type(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 `None` `str`, or `Normalize`, not {type(n)}") + f"is of type `str`, or `Normalize`, not {type(norm)}") - # Convert the list of norms to a tuple to make it immutable. - # If there is a use case for swapping a single norm, we can add support for - # that later - self._norms = tuple(norms) + self._norms = tuple(resolve(norm) for norm in norms) self.callbacks = cbook.CallbackRegistry(signals=["changed"]) @@ -3369,9 +3364,9 @@ def vmin(self): def vmin(self, value): value = np.broadcast_to(value, self.n_components) with self.callbacks.blocked(): - for i, v in enumerate(value): + for norm, v in zip(self.norms, value): if v is not None: - self.norms[i].vmin = v + norm.vmin = v self._changed() @property @@ -3383,9 +3378,9 @@ def vmax(self): def vmax(self, value): value = np.broadcast_to(value, self.n_components) with self.callbacks.blocked(): - for i, v in enumerate(value): + for norm, v in zip(self.norms, value): if v is not None: - self.norms[i].vmax = v + norm.vmax = v self._changed() @property @@ -3397,9 +3392,9 @@ def clip(self): def clip(self, value): value = np.broadcast_to(value, self.n_components) with self.callbacks.blocked(): - for i, v in enumerate(value): + for norm, v in zip(self.norms, value): if v is not None: - self.norms[i].clip = v + norm.clip = v self._changed() def _changed(self): diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 7c1c9bc6345b..ce4832840234 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1896,15 +1896,20 @@ def test_multi_norm(): # test wrong input with pytest.raises(ValueError, - match="A MultiNorm must be assigned multiple norms"): + match="MultiNorm must be assigned multiple norms"): mcolors.MultiNorm("bad_norm_name") + with pytest.raises(ValueError, + match="MultiNorm must be assigned multiple norms, "): + mcolors.MultiNorm([4]) + with pytest.raises(ValueError, + match="MultiNorm must be assigned multiple norms, "): + mcolors.MultiNorm(None) with pytest.raises(ValueError, match="Invalid norm str name"): mcolors.MultiNorm(["bad_norm_name"]) with pytest.raises(ValueError, - match="MultiNorm must be assigned multiple norms, " - "where each norm is of type `None`"): - mcolors.MultiNorm([4]) + match="Invalid norm str name"): + mcolors.MultiNorm(["None"]) # test get vmin, vmax norm = mpl.colors.MultiNorm(['linear', 'log']) @@ -1935,7 +1940,7 @@ def test_multi_norm(): # test autoscale_none norm0 = mcolors.TwoSlopeNorm(2, vmin=0, vmax=None) - norm = mcolors.MultiNorm([norm0, None], vmax=[None, 50]) + 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)) From c6cf3215138e717cecb1fcdddbd7d19ef0a876f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Sun, 13 Jul 2025 11:38:42 +0200 Subject: [PATCH 03/13] Apply suggestions from code review Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/colors.py | 52 +++++++++++++++++++-------------------- lib/matplotlib/colors.pyi | 22 ++++++++--------- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 1512b9c57ad5..86d9c258e71f 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3352,7 +3352,7 @@ def n_components(self): @property def norms(self): - """The individual norms held by this `MultiNorm`""" + """The individual norms held by this `MultiNorm`.""" return self._norms @property @@ -3361,10 +3361,10 @@ def vmin(self): return tuple(n.vmin for n in self._norms) @vmin.setter - def vmin(self, value): - value = np.broadcast_to(value, self.n_components) + def vmin(self, values): + values = np.broadcast_to(values, self.n_components) with self.callbacks.blocked(): - for norm, v in zip(self.norms, value): + for norm, v in zip(self.norms, values): if v is not None: norm.vmin = v self._changed() @@ -3375,10 +3375,10 @@ def vmax(self): return tuple(n.vmax for n in self._norms) @vmax.setter - def vmax(self, value): - value = np.broadcast_to(value, self.n_components) + def vmax(self, values): + values = np.broadcast_to(values, self.n_components) with self.callbacks.blocked(): - for norm, v in zip(self.norms, value): + for norm, v in zip(self.norms, values): if v is not None: norm.vmax = v self._changed() @@ -3389,10 +3389,10 @@ def clip(self): return tuple(n.clip for n in self._norms) @clip.setter - def clip(self, value): - value = np.broadcast_to(value, self.n_components) + def clip(self, values): + values = np.broadcast_to(values, self.n_components) with self.callbacks.blocked(): - for norm, v in zip(self.norms, value): + for norm, v in zip(self.norms, values): if v is not None: norm.clip = v self._changed() @@ -3404,15 +3404,15 @@ def _changed(self): """ self.callbacks.process('changed') - def __call__(self, value, clip=None, structured_output=None): + def __call__(self, values, clip=None, structured_output=None): """ Normalize the data and return the normalized data. - Each component of the input is assigned to the constituent norm. + Each component of the input is normalized via the constituent norm. Parameters ---------- - value : array-like + values : array-like Data to normalize, as tuple, scalar array or structured array. - If tuple, must be of length `n_components` @@ -3439,7 +3439,7 @@ def __call__(self, value, clip=None, structured_output=None): Notes ----- If not already initialized, ``self.vmin`` and ``self.vmax`` are - initialized using ``self.autoscale_None(value)``. + initialized using ``self.autoscale_None(values)``. """ if clip is None: clip = self.clip @@ -3447,36 +3447,36 @@ def __call__(self, value, clip=None, structured_output=None): clip = [clip]*self.n_components if structured_output is None: - if isinstance(value, np.ndarray) and value.dtype.fields is not None: + if isinstance(values, np.ndarray) and values.dtype.fields is not None: structured_output = True else: structured_output = False - value = self._iterable_components_in_data(value, 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, value, clip)) + result = tuple(n(v, clip=c) for n, v, c in zip(self.norms, values, clip)) if structured_output: result = self._ensure_multicomponent_data(result, self.n_components) return result - def inverse(self, value): + def inverse(self, values): """ - Map the normalized value (i.e., index in the colormap) back to image data value. + Map the normalized values (i.e., index in the colormap) back to data values. Parameters ---------- - value - Normalized value, as tuple, scalar array or structured array. + values + Normalized values, as tuple, scalar array or structured array. - If tuple, must be of length `n_components` - If scalar array, the first axis must be of length `n_components` - If structured array, must have `n_components` fields. """ - value = self._iterable_components_in_data(value, self.n_components) - result = [n.inverse(v) for n, v in zip(self.norms, value)] + 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): @@ -3487,8 +3487,8 @@ def autoscale(self, A): Parameters ---------- A - Data, must be of length `n_components` or be a structured array or scalar - with `n_components` fields. + Data, must be of length `n_components` or be a structured scalar or + structured array with `n_components` fields. """ with self.callbacks.blocked(): # Pause callbacks while we are updating so we only get @@ -3561,7 +3561,7 @@ def _ensure_multicomponent_data(data, n_components): Parameters ---------- n_components : int - - number of omponents in the data + Number of omponents in the data. data : np.ndarray, PIL.Image or None Returns diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index 75770b939dad..e3055e00fcb2 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -430,30 +430,30 @@ class MultiNorm(Norm): @property # type: ignore[override] def vmin(self) -> tuple[float | None, ...]: ... @vmin.setter - def vmin(self, value: ArrayLike | float | None) -> None: ... + def vmin(self, values: ArrayLike | float | None) -> None: ... @property # type: ignore[override] def vmax(self) -> tuple[float | None, ...]: ... @vmax.setter - def vmax(self, value: ArrayLike | float | None) -> None: ... + def vmax(self, valued: ArrayLike | float | None) -> None: ... @property # type: ignore[override] def clip(self) -> tuple[bool, ...]: ... @clip.setter - def clip(self, value: ArrayLike | bool) -> None: ... + def clip(self, values: ArrayLike | bool) -> None: ... @overload - def __call__(self, value: tuple, clip: ArrayLike | bool | None = ..., structured_output: Literal[False] = ...) -> tuple: ... + def __call__(self, values: tuple, clip: ArrayLike | bool | None = ..., structured_output: Literal[False] = ...) -> tuple: ... @overload - def __call__(self, value: tuple, structured_output: Literal[True], clip: ArrayLike | bool | None = ...) -> np.ma.MaskedArray: ... + def __call__(self, values: tuple, structured_output: Literal[True], clip: ArrayLike | bool | None = ...) -> np.ma.MaskedArray: ... @overload - def __call__(self, value: np.ndarray, clip: ArrayLike | bool | None = ..., structured_output: None | Literal[True] = ...) -> np.ma.MaskedArray: ... + def __call__(self, values: np.ndarray, clip: ArrayLike | bool | None = ..., structured_output: None | Literal[True] = ...) -> np.ma.MaskedArray: ... @overload - def __call__(self, value: np.ndarray, structured_output: Literal[False], clip: ArrayLike | bool | None = ...) -> tuple: ... + def __call__(self, values: np.ndarray, structured_output: Literal[False], clip: ArrayLike | bool | None = ...) -> tuple: ... @overload - def __call__(self, value: ArrayLike, structured_output: Literal[False], clip: ArrayLike | bool | None = ...) -> tuple: ... + def __call__(self, values: ArrayLike, structured_output: Literal[False], clip: ArrayLike | bool | None = ...) -> tuple: ... @overload - def __call__(self, value: ArrayLike, structured_output: Literal[True], clip: ArrayLike | bool | None = ...) -> np.ma.MaskedArray: ... + def __call__(self, values: ArrayLike, structured_output: Literal[True], clip: ArrayLike | bool | None = ...) -> np.ma.MaskedArray: ... @overload - def __call__(self, value: ArrayLike, clip: ArrayLike | bool | None = ..., structured_output: None = ...) -> ArrayLike: ... - def inverse(self, value: ArrayLike) -> list: ... # type: ignore[override] + def __call__(self, values: ArrayLike, clip: ArrayLike | bool | None = ..., structured_output: None = ...) -> ArrayLike: ... + 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: ... From babbee0bad450dfca2e439bcadaa1bae439cb2d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Sun, 20 Jul 2025 12:27:57 +0200 Subject: [PATCH 04/13] Updated input types for MultiNorm.__call__() --- lib/matplotlib/colors.py | 71 +++++++++++++--- lib/matplotlib/tests/test_colors.py | 123 +++++++++++++++++++++++++++- 2 files changed, 182 insertions(+), 12 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 86d9c258e71f..2b52738288e2 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3413,10 +3413,9 @@ def __call__(self, values, clip=None, structured_output=None): Parameters ---------- values : array-like - Data to normalize, as tuple, scalar array or structured array. + Data to normalize, as tuple or list or structured array. - - If tuple, must be of length `n_components` - - If scalar array, the first axis must be of length `n_components` + - If tuple or list, must be of length `n_components` - If structured array, must have `n_components` fields. clip : list of bools or bool or None, optional @@ -3530,22 +3529,72 @@ def _iterable_components_in_data(data, n_components): Parameters ---------- data : np.ndarray, tuple or list - The input array. It must either be an array with n_components fields or have - a length (n_components) + The input data, as a tuple or list or structured array. + + - If tuple or list, 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: - data = tuple(data[descriptor[0]] for descriptor in data.dtype.descr) - if len(data) != n_components: - raise ValueError("The input to this `MultiNorm` must be of shape " - f"({n_components}, ...), or be structured array or scalar " - f"with {n_components} fields.") + if isinstance(data, np.ndarray): + if data.dtype.fields is not None: + data = tuple(data[descriptor[0]] for descriptor in data.dtype.descr) + if len(data) != n_components: + raise ValueError(f"{MultiNorm._get_input_err(n_components)}" + f". A structured array with " + f"{len(data)} fields is not compatible") + else: + # Input is a scalar array, which we do not support. + # try to give a hint as to how the data can be converted to + # an accepted format + if ((len(data.shape) == 1 and + data.shape[0] == n_components) or + (len(data.shape) > 1 and + data.shape[0] == n_components and + data.shape[-1] != n_components) + ): + raise ValueError(f"{MultiNorm._get_input_err(n_components)}" + ". You can use `list(data)` to convert" + f" the input data of shape {data.shape} to" + " a compatible list") + + elif (len(data.shape) > 1 and + data.shape[-1] == n_components and + data.shape[0] != n_components): + raise ValueError(f"{MultiNorm._get_input_err(n_components)}" + ". You can use " + "`rfn.unstructured_to_structured(data)` available " + "with `from numpy.lib import recfunctions as rfn` " + "to convert the input array of shape " + f"{data.shape} to a structured array") + else: + # Cannot give shape hint + # Either neither first nor last axis matches, or both do. + raise ValueError(f"{MultiNorm._get_input_err(n_components)}" + f". An np.ndarray of shape {data.shape} is" + " not compatible") + elif isinstance(data, (tuple, list)): + if len(data) != n_components: + raise ValueError(f"{MultiNorm._get_input_err(n_components)}" + f". A {type(data)} of length {len(data)} is" + " not compatible") + else: + raise ValueError(f"{MultiNorm._get_input_err(n_components)}" + f". Input of type {type(data)} is not supported") + return data + @staticmethod + def _get_input_err(n_components): + # returns the start of the error message given when a + # MultiNorm receives incompatible input + return ("The input to this `MultiNorm` must be a list or tuple " + f"of length {n_components}, or be structured array " + f"with {n_components} fields") + @staticmethod def _ensure_multicomponent_data(data, n_components): """ diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index ce4832840234..4d6fc62522ab 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 @@ -1891,7 +1892,7 @@ def test_close_error_name(): matplotlib.colormaps["grays"] -def test_multi_norm(): +def test_multi_norm_creation(): # tests for mcolors.MultiNorm # test wrong input @@ -1911,6 +1912,10 @@ def test_multi_norm(): match="Invalid norm str name"): mcolors.MultiNorm(["None"]) + 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 @@ -1918,6 +1923,13 @@ def test_multi_norm(): assert norm.vmin == (1, 1) assert norm.vmax == (2, 2) + +def test_multi_norm_call_clip_inverse(): + # test get vmin, vmax + norm = mpl.colors.MultiNorm(['linear', 'log']) + norm.vmin = 1 + norm.vmax = 2 + # test call with clip assert_array_equal(norm([3, 3], clip=False), [2.0, 1.584962500721156]) assert_array_equal(norm([3, 3], clip=True), [1.0, 1.0]) @@ -1933,6 +1945,9 @@ def test_multi_norm(): # 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]) @@ -1945,3 +1960,109 @@ def test_multi_norm(): 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 + mn.vmax = 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 from_mn.dtype == structured_target.dtype + assert_array_almost_equal(rfn.structured_to_unstructured(from_mn), + rfn.structured_to_unstructured(structured_target)) + + # 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)) + + + # test setting structured_output true/false: + # structured input, structured output + from_mn = mn(rfn.unstructured_to_structured(vals), structured_output=True) + assert from_mn.dtype == structured_target.dtype + assert_array_almost_equal(rfn.structured_to_unstructured(from_mn), + rfn.structured_to_unstructured(structured_target)) + # structured input, list as output + from_mn = mn(rfn.unstructured_to_structured(vals), structured_output=False) + assert_array_almost_equal(from_mn, + list(target.T)) + # list as input, structured output + from_mn= mn(list(vals.T), structured_output=True) + assert from_mn.dtype == structured_target.dtype + assert_array_almost_equal(rfn.structured_to_unstructured(from_mn), + rfn.structured_to_unstructured(structured_target)) + # list as input, list as output + from_mn = mn(list(vals.T), structured_output=False) + assert_array_almost_equal(from_mn, + list(target.T)) + + # 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(rfn.structured_to_unstructured(no_norm_out), + np.array(\ + [[0., 0.5, 1.], + [1, 3, 5]]).T) + assert no_norm_out.dtype['f0'] == np.dtype('float64') + assert no_norm_out.dtype['f1'] == np.dtype('int64') + + # test single int as input + with pytest.raises(ValueError, + match="Input of type is not supported"): + mn(1) + + # test list of incompatible size + with pytest.raises(ValueError, + match="A of length 3 is not compatible"): + mn([3, 2, 1]) + + # np.arrays of shapes that can be converted: + for data in [np.zeros(2), np.zeros((2,3)), np.zeros((2,3,3))]: + with pytest.raises(ValueError, + match=r"You can use `list\(data\)` to convert"): + mn(data) + + for data in [np.zeros((3, 2)), np.zeros((3, 3, 2))]: + with pytest.raises(ValueError, + match=r"You can use `rfn.unstructured_to_structured"): + mn(data) + + # np.ndarray that can be converted, but unclear if first or last axis + for data in [np.zeros((2, 2)), np.zeros((2, 3, 2))]: + with pytest.raises(ValueError, + match="An np.ndarray of shape"): + mn(data) + + # incompatible arrays where no relevant axis matches + for data in [np.zeros(3), np.zeros((3, 2, 3))]: + with pytest.raises(ValueError, + match=r"An np.ndarray of shape"): + mn(data) + + # test incompatible class + with pytest.raises(ValueError, + match="Input of type is not supported"): + mn("An object of incompatible class") From 8a59948dae06848d3e9fca61fe3690ef30c58c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Sun, 20 Jul 2025 23:47:13 +0200 Subject: [PATCH 05/13] improved error messages in MultiNorm --- lib/matplotlib/colors.py | 24 +++++++++++++++--------- lib/matplotlib/tests/test_colors.py | 16 +++++++++++----- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 2b52738288e2..cf0699428526 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3557,19 +3557,25 @@ def _iterable_components_in_data(data, n_components): data.shape[-1] != n_components) ): raise ValueError(f"{MultiNorm._get_input_err(n_components)}" - ". You can use `list(data)` to convert" - f" the input data of shape {data.shape} to" - " a compatible list") + ". You can use `data_as_list = list(data)`" + " to convert the input data of shape" + f" {data.shape} to a compatible list") elif (len(data.shape) > 1 and data.shape[-1] == n_components and data.shape[0] != n_components): - raise ValueError(f"{MultiNorm._get_input_err(n_components)}" - ". You can use " - "`rfn.unstructured_to_structured(data)` available " - "with `from numpy.lib import recfunctions as rfn` " - "to convert the input array of shape " - f"{data.shape} to a structured array") + if len(data.shape) == 2: + raise ValueError(f"{MultiNorm._get_input_err(n_components)}" + ". You can use `data_as_list = list(data.T)`" + " to convert the input data of shape" + f" {data.shape} to a compatible list") + else: + raise ValueError(f"{MultiNorm._get_input_err(n_components)}" + ". 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") + else: # Cannot give shape hint # Either neither first nor last axis matches, or both do. diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 4d6fc62522ab..805300a1b48d 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -2042,13 +2042,19 @@ def test_mult_norm_call_types(): # np.arrays of shapes that can be converted: for data in [np.zeros(2), np.zeros((2,3)), np.zeros((2,3,3))]: with pytest.raises(ValueError, - match=r"You can use `list\(data\)` to convert"): + match=r"You can use `data_as_list = list\(data\)`"): mn(data) - for data in [np.zeros((3, 2)), np.zeros((3, 3, 2))]: - with pytest.raises(ValueError, - match=r"You can use `rfn.unstructured_to_structured"): - mn(data) + # 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_as_list = list\(data.T\)`"): + mn(np.zeros((3, 2))) # np.ndarray that can be converted, but unclear if first or last axis for data in [np.zeros((2, 2)), np.zeros((2, 3, 2))]: From 270cb64a85dfa1352c47ad0721d8f375240b46b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Wed, 23 Jul 2025 23:42:19 +0200 Subject: [PATCH 06/13] updated types and errors for MultiNorm --- lib/matplotlib/colors.py | 116 ++++++++++++---------------- lib/matplotlib/tests/test_colors.py | 31 ++++---- 2 files changed, 61 insertions(+), 86 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index cf0699428526..37e6fb6cffc5 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3413,9 +3413,9 @@ def __call__(self, values, clip=None, structured_output=None): Parameters ---------- values : array-like - Data to normalize, as tuple or list or structured array. + The input data, as an iterable or a structured numpy array. - - If tuple or list, must be of length `n_components` + - If iterable, must be of length `n_components` - If structured array, must have `n_components` fields. clip : list of bools or bool or None, optional @@ -3424,11 +3424,13 @@ def __call__(self, values, clip=None, structured_output=None): `.Normalize`. If ``None``, defaults to ``self.clip`` (which defaults to ``False``). + structured_output : bool, optional - If True, output is returned as a structured array - If False, output is returned as a tuple of length `n_components` - - If None (default) output is returned in the same format as the input. + - If None (default) output is returned as a structured array for + structured input, and otherwise returned as a tuple Returns ------- @@ -3466,13 +3468,13 @@ def inverse(self, values): Parameters ---------- - values - Normalized values, as tuple, scalar array or structured array. + values : array-like + The input data, as an iterable or a structured numpy array. - - If tuple, must be of length `n_components` - - If scalar array, the first axis must be of length `n_components` + - If iterable, must be of length `n_components` - If structured array, must have `n_components` fields. + """ values = self._iterable_components_in_data(values, self.n_components) result = [n.inverse(v) for n, v in zip(self.norms, values)] @@ -3505,7 +3507,7 @@ def autoscale_None(self, A): Parameters ---------- A - Data, must be of length `n_components` or be a structured array or scalar + Data, must be of length `n_components` or be a structured array with `n_components` fields. """ with self.callbacks.blocked(): @@ -3528,78 +3530,56 @@ def _iterable_components_in_data(data, n_components): Parameters ---------- - data : np.ndarray, tuple or list - The input data, as a tuple or list or structured array. + data : array-like + The input data, as an iterable or a structured numpy array. - - If tuple or list, must be of length `n_components` + - 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): - if data.dtype.fields is not None: - data = tuple(data[descriptor[0]] for descriptor in data.dtype.descr) - if len(data) != n_components: - raise ValueError(f"{MultiNorm._get_input_err(n_components)}" - f". A structured array with " - f"{len(data)} fields is not compatible") + 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: - # Input is a scalar array, which we do not support. - # try to give a hint as to how the data can be converted to - # an accepted format - if ((len(data.shape) == 1 and - data.shape[0] == n_components) or - (len(data.shape) > 1 and - data.shape[0] == n_components and - data.shape[-1] != n_components) - ): - raise ValueError(f"{MultiNorm._get_input_err(n_components)}" - ". You can use `data_as_list = list(data)`" - " to convert the input data of shape" - f" {data.shape} to a compatible list") - - elif (len(data.shape) > 1 and - data.shape[-1] == n_components and - data.shape[0] != n_components): - if len(data.shape) == 2: - raise ValueError(f"{MultiNorm._get_input_err(n_components)}" - ". You can use `data_as_list = list(data.T)`" - " to convert the input data of shape" - f" {data.shape} to a compatible list") - else: - raise ValueError(f"{MultiNorm._get_input_err(n_components)}" - ". 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") - + 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: - # Cannot give shape hint - # Either neither first nor last axis matches, or both do. - raise ValueError(f"{MultiNorm._get_input_err(n_components)}" - f". An np.ndarray of shape {data.shape} is" - " not compatible") - elif isinstance(data, (tuple, list)): - if len(data) != n_components: - raise ValueError(f"{MultiNorm._get_input_err(n_components)}" - f". A {type(data)} of length {len(data)} is" - " not compatible") - else: - raise ValueError(f"{MultiNorm._get_input_err(n_components)}" - f". Input of type {type(data)} is not supported") + 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") - return data + 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 _get_input_err(n_components): - # returns the start of the error message given when a - # MultiNorm receives incompatible input - return ("The input to this `MultiNorm` must be a list or tuple " - f"of length {n_components}, or be structured array " - f"with {n_components} fields") @staticmethod def _ensure_multicomponent_data(data, n_components): diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 805300a1b48d..176d6bdf5b68 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1989,6 +1989,13 @@ def test_mult_norm_call_types(): 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 setting structured_output true/false: # structured input, structured output @@ -2031,20 +2038,14 @@ def test_mult_norm_call_types(): # test single int as input with pytest.raises(ValueError, - match="Input of type is not supported"): + match="component as input, but got 1 instead"): mn(1) # test list of incompatible size with pytest.raises(ValueError, - match="A of length 3 is not compatible"): + match="but got a sequence with 3 elements"): mn([3, 2, 1]) - # np.arrays of shapes that can be converted: - for data in [np.zeros(2), np.zeros((2,3)), np.zeros((2,3,3))]: - with pytest.raises(ValueError, - match=r"You can use `data_as_list = list\(data\)`"): - mn(data) - # last axis matches, len(data.shape) > 2 with pytest.raises(ValueError, match=(r"`data_as_list = \[data\[..., i\] for i in " @@ -2053,22 +2054,16 @@ def test_mult_norm_call_types(): # last axis matches, len(data.shape) == 2 with pytest.raises(ValueError, - match=r"You can use `data_as_list = list\(data.T\)`"): + match=r"You can use `data_transposed = data.T`to convert"): mn(np.zeros((3, 2))) - # np.ndarray that can be converted, but unclear if first or last axis - for data in [np.zeros((2, 2)), np.zeros((2, 3, 2))]: - with pytest.raises(ValueError, - match="An np.ndarray of shape"): - mn(data) - # incompatible arrays where no relevant axis matches for data in [np.zeros(3), np.zeros((3, 2, 3))]: with pytest.raises(ValueError, - match=r"An np.ndarray of shape"): + match=r"but got a sequence with 3 elements"): mn(data) # test incompatible class with pytest.raises(ValueError, - match="Input of type is not supported"): - mn("An object of incompatible class") + match="but got Date: Thu, 24 Jul 2025 22:16:34 +0200 Subject: [PATCH 07/13] Apply suggestions from code review Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/colors.py | 9 ++++++--- lib/matplotlib/tests/test_colors.py | 13 +++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 37e6fb6cffc5..83a27958990a 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3323,6 +3323,9 @@ def __init__(self, norms, vmin=None, vmax=None, clip=False): "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) @@ -3518,7 +3521,7 @@ def autoscale_None(self, A): def scaled(self): """Return whether both *vmin* and *vmax* are set on all constituent norms.""" - return all([n.scaled() for n in self.norms]) + return all(n.scaled() for n in self.norms) @staticmethod def _iterable_components_in_data(data, n_components): @@ -4320,7 +4323,7 @@ def _get_scale_cls_from_str(scale_as_str): scale_cls = scale._scale_mapping[scale_as_str] except KeyError: raise ValueError( - "Invalid norm str name; the following values are " - f"supported: {', '.join(scale._scale_mapping)}" + 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/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 176d6bdf5b68..b3601adce685 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1898,19 +1898,16 @@ def test_multi_norm_creation(): # test wrong input with pytest.raises(ValueError, match="MultiNorm must be assigned multiple norms"): - mcolors.MultiNorm("bad_norm_name") + mcolors.MultiNorm("linear") with pytest.raises(ValueError, - match="MultiNorm must be assigned multiple norms, "): - mcolors.MultiNorm([4]) + 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 str name"): - mcolors.MultiNorm(["bad_norm_name"]) - with pytest.raises(ValueError, - match="Invalid norm str name"): - mcolors.MultiNorm(["None"]) + match="Invalid norm name "): + mcolors.MultiNorm(["linear", "bad_norm_name"]) norm = mpl.colors.MultiNorm(['linear', 'linear']) From 347df5bdda96d8bac221cee19dcee65af086b9d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Fri, 25 Jul 2025 22:47:11 +0200 Subject: [PATCH 08/13] Updated return type of MultiNorm --- lib/matplotlib/colors.py | 21 +++---------------- lib/matplotlib/colors.pyi | 14 +++---------- lib/matplotlib/tests/test_colors.py | 32 ++++------------------------- 3 files changed, 10 insertions(+), 57 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 83a27958990a..85d2c5af81ff 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3407,7 +3407,7 @@ def _changed(self): """ self.callbacks.process('changed') - def __call__(self, values, clip=None, structured_output=None): + def __call__(self, values, clip=None): """ Normalize the data and return the normalized data. @@ -3428,17 +3428,11 @@ def __call__(self, values, clip=None, structured_output=None): If ``None``, defaults to ``self.clip`` (which defaults to ``False``). - structured_output : bool, optional - - - If True, output is returned as a structured array - - If False, output is returned as a tuple of length `n_components` - - If None (default) output is returned as a structured array for - structured input, and otherwise returned as a tuple Returns ------- - tuple or `~numpy.ndarray` - Normalized input values` + tuple + Normalized input values Notes ----- @@ -3450,19 +3444,10 @@ def __call__(self, values, clip=None, structured_output=None): elif not np.iterable(clip): clip = [clip]*self.n_components - if structured_output is None: - if isinstance(values, np.ndarray) and values.dtype.fields is not None: - structured_output = True - else: - structured_output = False - 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)) - if structured_output: - result = self._ensure_multicomponent_data(result, self.n_components) - return result def inverse(self, values): diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index e3055e00fcb2..72a9abb0928a 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -440,19 +440,11 @@ class MultiNorm(Norm): @clip.setter def clip(self, values: ArrayLike | bool) -> None: ... @overload - def __call__(self, values: tuple, clip: ArrayLike | bool | None = ..., structured_output: Literal[False] = ...) -> tuple: ... + def __call__(self, values: tuple[np.ndarray], clip: ArrayLike | bool | None = ...) -> tuple[np.ndarray]: ... @overload - def __call__(self, values: tuple, structured_output: Literal[True], clip: ArrayLike | bool | None = ...) -> np.ma.MaskedArray: ... + def __call__(self, values: tuple[float], clip: ArrayLike | bool | None = ...) -> tuple[float]: ... @overload - def __call__(self, values: np.ndarray, clip: ArrayLike | bool | None = ..., structured_output: None | Literal[True] = ...) -> np.ma.MaskedArray: ... - @overload - def __call__(self, values: np.ndarray, structured_output: Literal[False], clip: ArrayLike | bool | None = ...) -> tuple: ... - @overload - def __call__(self, values: ArrayLike, structured_output: Literal[False], clip: ArrayLike | bool | None = ...) -> tuple: ... - @overload - def __call__(self, values: ArrayLike, structured_output: Literal[True], clip: ArrayLike | bool | None = ...) -> np.ma.MaskedArray: ... - @overload - def __call__(self, values: ArrayLike, clip: ArrayLike | bool | None = ..., structured_output: None = ...) -> ArrayLike: ... + 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: ... diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index b3601adce685..1adc0f4c8118 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1972,9 +1972,8 @@ def test_mult_norm_call_types(): # test structured array as input structured_target = rfn.unstructured_to_structured(target) from_mn= mn(rfn.unstructured_to_structured(vals)) - assert from_mn.dtype == structured_target.dtype - assert_array_almost_equal(rfn.structured_to_unstructured(from_mn), - rfn.structured_to_unstructured(structured_target)) + assert_array_almost_equal(from_mn, + target.T) # test list of arrays as input assert_array_almost_equal(mn(list(vals.T)), @@ -1994,26 +1993,6 @@ def test_mult_norm_call_types(): assert_array_almost_equal(mn(np.zeros((2, 3, 4))), 0.5*np.ones((2, 3, 4))) - # test setting structured_output true/false: - # structured input, structured output - from_mn = mn(rfn.unstructured_to_structured(vals), structured_output=True) - assert from_mn.dtype == structured_target.dtype - assert_array_almost_equal(rfn.structured_to_unstructured(from_mn), - rfn.structured_to_unstructured(structured_target)) - # structured input, list as output - from_mn = mn(rfn.unstructured_to_structured(vals), structured_output=False) - assert_array_almost_equal(from_mn, - list(target.T)) - # list as input, structured output - from_mn= mn(list(vals.T), structured_output=True) - assert from_mn.dtype == structured_target.dtype - assert_array_almost_equal(rfn.structured_to_unstructured(from_mn), - rfn.structured_to_unstructured(structured_target)) - # list as input, list as output - from_mn = mn(list(vals.T), structured_output=False) - assert_array_almost_equal(from_mn, - list(target.T)) - # test with NoNorm, list as input mn_no_norm = mpl.colors.MultiNorm(['linear', mcolors.NoNorm()]) no_norm_out = mn_no_norm(list(vals.T)) @@ -2026,12 +2005,9 @@ def test_mult_norm_call_types(): # 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(rfn.structured_to_unstructured(no_norm_out), - np.array(\ + assert_array_almost_equal(no_norm_out, [[0., 0.5, 1.], - [1, 3, 5]]).T) - assert no_norm_out.dtype['f0'] == np.dtype('float64') - assert no_norm_out.dtype['f1'] == np.dtype('int64') + [1, 3, 5]]) # test single int as input with pytest.raises(ValueError, From 22e909370846ba429f0da7b7fd9cfb0d89e5fbc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Tue, 29 Jul 2025 19:46:05 +0200 Subject: [PATCH 09/13] Apply suggestions from code review Co-authored-by: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> --- lib/matplotlib/colors.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 85d2c5af81ff..000c51cd96d6 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3418,8 +3418,10 @@ def __call__(self, values, clip=None): values : 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. + - 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 the corresponding norm. clip : list of bools or bool or None, optional Determines the behavior for mapping values outside the range @@ -3445,9 +3447,7 @@ def __call__(self, values, clip=None): clip = [clip]*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): @@ -3462,7 +3462,6 @@ def inverse(self, values): - If iterable, must be of length `n_components` - If structured array, must have `n_components` fields. - """ values = self._iterable_components_in_data(values, self.n_components) result = [n.inverse(v) for n, v in zip(self.norms, values)] @@ -3475,7 +3474,7 @@ def autoscale(self, A): Parameters ---------- - A + A : array-like Data, must be of length `n_components` or be a structured scalar or structured array with `n_components` fields. """ From 81372c6322dfb00047393d3058028fa367cfdd2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Tue, 29 Jul 2025 19:53:44 +0200 Subject: [PATCH 10/13] updated docstrings in MultiNorm --- lib/matplotlib/colors.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 000c51cd96d6..cd5cde2407b3 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3459,8 +3459,10 @@ def inverse(self, values): values : 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. + - 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) @@ -3475,8 +3477,12 @@ def autoscale(self, A): Parameters ---------- A : array-like - Data, must be of length `n_components` or be a structured scalar or - structured array with `n_components` fields. + 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(): # Pause callbacks while we are updating so we only get @@ -3493,9 +3499,13 @@ def autoscale_None(self, A): Parameters ---------- - A - Data, must be of length `n_components` or be a structured array - with `n_components` fields. + 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) @@ -3512,7 +3522,7 @@ 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 list of length n + An input array with `n_components` fields is returned as a tuple of length n referencing slices of the original array. Parameters From f1cdc1069ab55d43d98c606d85eb4bc29388eedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Fri, 1 Aug 2025 12:54:28 +0200 Subject: [PATCH 11/13] Apply suggestions from code review Co-authored-by: Elliott Sales de Andrade --- lib/matplotlib/colors.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index cd5cde2407b3..9e6f74a6ee6d 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3298,7 +3298,7 @@ def inverse(self, value): class MultiNorm(Norm): """ - A class which contains multiple scalar norms + A class which contains multiple scalar norms. """ def __init__(self, norms, vmin=None, vmax=None, clip=False): @@ -3421,7 +3421,7 @@ def __call__(self, values, clip=None): - 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 the corresponding norm. + is normalized through the corresponding norm. clip : list of bools or bool or None, optional Determines the behavior for mapping values outside the range @@ -3430,7 +3430,6 @@ def __call__(self, values, clip=None): If ``None``, defaults to ``self.clip`` (which defaults to ``False``). - Returns ------- tuple @@ -3471,7 +3470,7 @@ def inverse(self, values): def autoscale(self, A): """ - For each constituent norm, Set *vmin*, *vmax* to min, max of the corresponding + For each constituent norm, set *vmin*, *vmax* to min, max of the corresponding component in *A*. Parameters @@ -3485,8 +3484,6 @@ def autoscale(self, A): is used for the limits of one constituent norm. """ with self.callbacks.blocked(): - # Pause callbacks while we are updating so we only get - # a single update signal at the end A = self._iterable_components_in_data(A, self.n_components) for n, a in zip(self.norms, A): n.autoscale(a) @@ -3533,7 +3530,6 @@ def _iterable_components_in_data(data, n_components): - If iterable, must be of length `n_components` - If structured array, must have `n_components` fields. - Returns ------- tuple of np.ndarray From e6d59edd7df2d1a7f323cadef5663956e156bf8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Fri, 1 Aug 2025 12:56:18 +0200 Subject: [PATCH 12/13] changed so that vmin/vmax must be iterable --- lib/matplotlib/colors.py | 50 ++++++++++++++++++----------- lib/matplotlib/colors.pyi | 12 +++---- lib/matplotlib/tests/test_colors.py | 40 ++++++++++++++++------- 3 files changed, 66 insertions(+), 36 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 9e6f74a6ee6d..a1569b8df31f 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3301,22 +3301,24 @@ class MultiNorm(Norm): A class which contains multiple scalar norms. """ - def __init__(self, norms, vmin=None, vmax=None, clip=False): + 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 : float or None or list of (float or None) + vmin, vmax : None or list of (float or None) Limits of the constituent norms. - If a list, each value is assigned to each of the constituent - norms. Single values are repeated to form a list of appropriate size. - - clip : bool or list of bools, default: False + 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. Single values are repeated to form a list of appropriate size. + norms. + If None, the behaviour of the constituent norms is not changed. """ if cbook.is_scalar_or_string(norms): raise ValueError( @@ -3365,11 +3367,14 @@ def vmin(self): @vmin.setter def vmin(self, values): - values = np.broadcast_to(values, self.n_components) + 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): - if v is not None: - norm.vmin = v + norm.vmin = v self._changed() @property @@ -3379,11 +3384,14 @@ def vmax(self): @vmax.setter def vmax(self, values): - values = np.broadcast_to(values, self.n_components) + 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): - if v is not None: - norm.vmax = v + norm.vmax = v self._changed() @property @@ -3393,11 +3401,14 @@ def clip(self): @clip.setter def clip(self, values): - values = np.broadcast_to(values, self.n_components) + 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): - if v is not None: - norm.clip = v + norm.clip = v self._changed() def _changed(self): @@ -3423,7 +3434,7 @@ def __call__(self, values, clip=None): - If structured array, must have `n_components` fields. Each field is normalized through the corresponding norm. - clip : list of bools or bool or None, optional + 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`. @@ -3442,8 +3453,9 @@ def __call__(self, values, clip=None): """ if clip is None: clip = self.clip - elif not np.iterable(clip): - clip = [clip]*self.n_components + 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)) diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index 72a9abb0928a..8618128b66f4 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -421,24 +421,24 @@ class MultiNorm(Norm): def __init__( self, norms: ArrayLike, - vmin: ArrayLike | float | None = ..., - vmax: ArrayLike | float | None = ..., - clip: ArrayLike | bool = ... + 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 | float | None) -> None: ... + def vmin(self, values: ArrayLike | None) -> None: ... @property # type: ignore[override] def vmax(self) -> tuple[float | None, ...]: ... @vmax.setter - def vmax(self, valued: ArrayLike | float | None) -> None: ... + def vmax(self, valued: ArrayLike | None) -> None: ... @property # type: ignore[override] def clip(self) -> tuple[bool, ...]: ... @clip.setter - def clip(self, values: ArrayLike | bool) -> None: ... + def clip(self, values: ArrayLike | None) -> None: ... @overload def __call__(self, values: tuple[np.ndarray], clip: ArrayLike | bool | None = ...) -> tuple[np.ndarray]: ... @overload diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 1adc0f4c8118..8a79a8b4b230 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1915,29 +1915,47 @@ def test_multi_norm_creation(): def test_multi_norm_call_vmin_vmax(): # test get vmin, vmax norm = mpl.colors.MultiNorm(['linear', 'log']) - norm.vmin = 1 - norm.vmax = 2 + 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 - norm.vmax = 2 + norm.vmin = (1, 1) + norm.vmax = (2, 2) # test call with clip - assert_array_equal(norm([3, 3], clip=False), [2.0, 1.584962500721156]) - assert_array_equal(norm([3, 3], clip=True), [1.0, 1.0]) + 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 + norm.clip = [False, False] assert_array_equal(norm([3, 3]), [2.0, 1.584962500721156]) - norm.clip = True + 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 + 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]) @@ -1961,8 +1979,8 @@ def test_multi_norm_autoscale(): def test_mult_norm_call_types(): mn = mpl.colors.MultiNorm(['linear', 'linear']) - mn.vmin = -2 - mn.vmax = 2 + mn.vmin = (-2, -2) + mn.vmax = (2, 2) vals = np.arange(6).reshape((3,2)) target = np.ma.array([(0.5, 0.75), From 2d9d00c3a7504d03f3e8fe0ef2d4f4849f1fb929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Fri, 1 Aug 2025 12:57:11 +0200 Subject: [PATCH 13/13] Update colors.pyi --- lib/matplotlib/colors.pyi | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index 8618128b66f4..3ebd08e6025e 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -440,9 +440,9 @@ class MultiNorm(Norm): @clip.setter def clip(self, values: ArrayLike | None) -> None: ... @overload - def __call__(self, values: tuple[np.ndarray], clip: ArrayLike | bool | None = ...) -> tuple[np.ndarray]: ... + 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]: ... + 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] 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