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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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] From 68c5527bed32e4f28e2834f76036e0de8d5f129f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Tue, 5 Aug 2025 17:00:43 +0200 Subject: [PATCH 14/17] change to handling of bad norm names in MultiNorm --- lib/matplotlib/colors.py | 33 +---------------------------- lib/matplotlib/tests/test_colors.py | 2 +- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index a1569b8df31f..84c70aec53a8 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3330,7 +3330,7 @@ def __init__(self, norms, vmin=None, vmax=None, clip=None): def resolve(norm): if isinstance(norm, str): - scale_cls = _get_scale_cls_from_str(norm) + scale_cls = _api.check_getitem(scale._scale_mapping, norm=norm) return mpl.colorizer._auto_norm_from_scale(scale_cls)() elif isinstance(norm, Normalize): return norm @@ -4298,34 +4298,3 @@ 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/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 8a79a8b4b230..cf4679706348 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1906,7 +1906,7 @@ def test_multi_norm_creation(): match="MultiNorm must be assigned multiple norms, "): mcolors.MultiNorm(None) with pytest.raises(ValueError, - match="Invalid norm name "): + match="not a valid"): mcolors.MultiNorm(["linear", "bad_norm_name"]) norm = mpl.colors.MultiNorm(['linear', 'linear']) From aa99db3534c6378a0929204b67aa6bc981084112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Tue, 5 Aug 2025 17:06:29 +0200 Subject: [PATCH 15/17] Update colors.py --- lib/matplotlib/colors.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 84c70aec53a8..4394807a88ca 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3371,7 +3371,8 @@ def vmin(self, values): 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}") + f"Expected an iterable of length {self.n_components}, " + f"but got {values!r}") with self.callbacks.blocked(): for norm, v in zip(self.norms, values): norm.vmin = v @@ -3388,7 +3389,8 @@ def vmax(self, values): 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}") + f"Expected an iterable of length {self.n_components}, " + f"but got {values!r}") with self.callbacks.blocked(): for norm, v in zip(self.norms, values): norm.vmax = v @@ -3405,7 +3407,8 @@ def clip(self, values): 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}") + f"Expected an iterable of length {self.n_components}, " + f"but got {values!r}") with self.callbacks.blocked(): for norm, v in zip(self.norms, values): norm.clip = v @@ -3455,7 +3458,8 @@ def __call__(self, values, clip=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}") + f"Expected an iterable of length {self.n_components}, " + f"but got {clip!r}") 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)) From f61c06b49f3497bbf468e7fc397f4528030557d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Thu, 7 Aug 2025 23:19:04 +0200 Subject: [PATCH 16/17] Apply suggestions from code review Co-authored-by: Elliott Sales de Andrade --- lib/matplotlib/colors.py | 4 ++-- lib/matplotlib/colors.pyi | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 4394807a88ca..4c9d937cdea9 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3605,12 +3605,12 @@ def _ensure_multicomponent_data(data, n_components): Parameters ---------- n_components : int - Number of omponents in the data. + Number of components in the data. data : np.ndarray, PIL.Image or None Returns ------- - np.ndarray, PIL.Image or None + np.ndarray, PIL.Image or None """ if isinstance(data, np.ndarray): diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index 3ebd08e6025e..03effa8d7a2f 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] From c0a0d6d2d9af7c87ca09f0e175b99300d4478138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Fri, 15 Aug 2025 11:02:52 +0200 Subject: [PATCH 17/17] change minimum length of multiNorm to 1. --- lib/matplotlib/colors.py | 89 +++-------------------------- lib/matplotlib/tests/test_colors.py | 20 ++++--- 2 files changed, 18 insertions(+), 91 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 4c9d937cdea9..e775210e321e 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3306,7 +3306,7 @@ 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. + The constituent norms. The list must have a minimum length of 1. 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 @@ -3322,11 +3322,11 @@ def __init__(self, norms, vmin=None, vmax=None, clip=None): """ 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)}") + "MultiNorm must be assigned an iterable of norms, where each " + f"norm is of type `str`, or `Normalize`, not {type(norms)}") - if len(norms) < 2: - raise ValueError("MultiNorm must be assigned at least two norms") + if len(norms) < 1: + raise ValueError("MultiNorm must be assigned at least one norm") def resolve(norm): if isinstance(norm, str): @@ -3336,8 +3336,8 @@ def resolve(norm): return norm else: raise ValueError( - "MultiNorm must be assigned multiple norms, where each norm " - f"is of type `str`, or `Normalize`, not {type(norm)}") + "Each norm assgned to MultiNorm must be " + f"of type `str`, or `Normalize`, not {type(norm)}") self._norms = tuple(resolve(norm) for norm in norms) @@ -3590,81 +3590,6 @@ def _iterable_components_in_data(data, n_components): 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 components 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. diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index cf4679706348..ae2deb567c3b 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1897,17 +1897,20 @@ def test_multi_norm_creation(): # test wrong input with pytest.raises(ValueError, - match="MultiNorm must be assigned multiple norms"): + match="MultiNorm must be assigned an iterable"): mcolors.MultiNorm("linear") with pytest.raises(ValueError, - match="MultiNorm must be assigned at least two"): - mcolors.MultiNorm(["linear"]) + match="MultiNorm must be assigned at least one"): + mcolors.MultiNorm([]) with pytest.raises(ValueError, - match="MultiNorm must be assigned multiple norms, "): + match="MultiNorm must be assigned an iterable"): mcolors.MultiNorm(None) with pytest.raises(ValueError, match="not a valid"): mcolors.MultiNorm(["linear", "bad_norm_name"]) + with pytest.raises(ValueError, + match="Each norm assgned to MultiNorm"): + mcolors.MultiNorm(["linear", object()]) norm = mpl.colors.MultiNorm(['linear', 'linear']) @@ -1988,8 +1991,7 @@ def test_mult_norm_call_types(): (1.5, 1.75)]) # test structured array as input - structured_target = rfn.unstructured_to_structured(target) - from_mn= mn(rfn.unstructured_to_structured(vals)) + from_mn = mn(rfn.unstructured_to_structured(vals)) assert_array_almost_equal(from_mn, target.T) @@ -2024,8 +2026,8 @@ def test_mult_norm_call_types(): 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]]) + [[0., 0.5, 1.], + [1, 3, 5]]) # test single int as input with pytest.raises(ValueError, @@ -2040,7 +2042,7 @@ def test_mult_norm_call_types(): # 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\]\)\]`")): + r"range\(data.shape\[-1\]\)\]`")): mn(np.zeros((3, 3, 2))) # last axis matches, len(data.shape) == 2