From 5e0266ae98ec5e18e10f962b5598bced99d44775 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Sun, 6 Apr 2025 14:26:06 +0200 Subject: [PATCH 1/8] MultiNorm class This commit introduces the MultiNorm calss to prepare for the introduction of multivariate plotting methods --- lib/matplotlib/colors.py | 252 +++++++++++++++++++++++++++- lib/matplotlib/colors.pyi | 32 ++++ lib/matplotlib/scale.py | 29 ++++ lib/matplotlib/tests/test_colors.py | 41 +++++ 4 files changed, 346 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index e3c3b39e8bb2..039534bedf25 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1420,10 +1420,10 @@ def __init__(self, colormaps, combination_mode, name='multivariate colormap'): combination_mode: str, 'sRGB_add' or 'sRGB_sub' Describe how colormaps are combined in sRGB space - - If 'sRGB_add' -> Mixing produces brighter colors - `sRGB = sum(colors)` - - If 'sRGB_sub' -> Mixing produces darker colors - `sRGB = 1 - sum(1 - colors)` + - If 'sRGB_add': Mixing produces brighter colors + ``sRGB = sum(colors)`` + - If 'sRGB_sub': Mixing produces darker colors + ``sRGB = 1 - sum(1 - colors)`` name : str, optional The name of the colormap family. """ @@ -1598,12 +1598,12 @@ def with_extremes(self, *, bad=None, under=None, over=None): bad: :mpltype:`color`, default: None If Matplotlib color, the bad value is set accordingly in the copy - under tuple of :mpltype:`color`, default: None - If tuple, the `under` value of each component is set with the values + under: tuple of :mpltype:`color`, default: None + If tuple, the ``under`` value of each component is set with the values from the tuple. - over tuple of :mpltype:`color`, default: None - If tuple, the `over` value of each component is set with the values + over: tuple of :mpltype:`color`, default: None + If tuple, the ``over`` value of each component is set with the values from the tuple. Returns @@ -2320,6 +2320,16 @@ def __init__(self, vmin=None, vmax=None, clip=False): self._scale = None self.callbacks = cbook.CallbackRegistry(signals=["changed"]) + @property + def n_input(self): + # To be overridden by subclasses with multiple inputs + return 1 + + @property + def n_output(self): + # To be overridden by subclasses with multiple outputs + return 1 + @property def vmin(self): return self._vmin @@ -3219,6 +3229,232 @@ def inverse(self, value): return value +class MultiNorm(Normalize): + """ + A mixin class which contains multiple scalar norms + """ + + def __init__(self, norms, vmin=None, vmax=None, clip=False): + """ + Parameters + ---------- + norms : List of strings or `Normalize` objects + The constituent norms. The list must have a minimum length of 2. + vmin, vmax : float, None, or list of float or None + Limits of the constituent norms. + If a list, each each value is assigned to one 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 each value is assigned to one of the constituent + norms. Single values are repeated to form a list of appropriate size. + + """ + + if isinstance(norms, str) or not np.iterable(norms): + raise ValueError("A MultiNorm must be assigned multiple norms") + norms = [n for n in norms] + for i, n in enumerate(norms): + if n is None: + norms[i] = Normalize() + elif isinstance(n, str): + scale_cls = scale._get_scale_cls_from_str(n) + norms[i] = mpl.colorizer._auto_norm_from_scale(scale_cls)() + + # 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 + + self._id_norms = [n.callbacks.connect('changed', + self._changed) for n in self._norms] + + @property + def n_input(self): + return len(self._norms) + + @property + def n_output(self): + return len(self._norms) + + @property + def norms(self): + return self._norms + + @property + def vmin(self): + return tuple(n.vmin for n in self._norms) + + @vmin.setter + def vmin(self, value): + if not np.iterable(value): + value = [value]*self.n_input + if len(value) != self.n_input: + raise ValueError(f"Invalid vmin for `MultiNorm` with {self.n_input}" + " inputs.") + 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): + return tuple(n.vmax for n in self._norms) + + @vmax.setter + def vmax(self, value): + if not np.iterable(value): + value = [value]*self.n_input + if len(value) != self.n_input: + raise ValueError(f"Invalid vmax for `MultiNorm` with {self.n_input}" + " inputs.") + 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): + return tuple(n.clip for n in self._norms) + + @clip.setter + def clip(self, value): + if not np.iterable(value): + value = [value]*self.n_input + 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): + """ + Normalize the data and return the normalized data. + Each variate in the input is assigned to the a constituent norm. + + Parameters + ---------- + value + Data to normalize. Must be of length `n_input` or have a data type with + `n_input` fields. + clip : List of bools or bool, optional + See the description of the parameter *clip* in Normalize. + If ``None``, defaults to ``self.clip`` (which defaults to + ``False``). + + Returns + ------- + Data + Normalized input values as a list of length `n_input` + + 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_input + + value = self._iterable_variates_in_data(value, self.n_input) + result = [n(v, clip=c) for n, v, c in zip(self.norms, value, clip)] + return result + + def inverse(self, value): + """ + Maps the normalized value (i.e., index in the colormap) back to image + data value. + + Parameters + ---------- + value + Normalized value. Must be of length `n_input` or have a data type with + `n_input` fields. + """ + value = self._iterable_variates_in_data(value, self.n_input) + 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 + variate in *A*. + """ + with self.callbacks.blocked(): + # Pause callbacks while we are updating so we only get + # a single update signal at the end + A = self._iterable_variates_in_data(A, self.n_input) + 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 variate in *A* to set them. + + Parameters + ---------- + A + Data, must be of length `n_input` or be an np.ndarray type with + `n_input` fields. + """ + with self.callbacks.blocked(): + A = self._iterable_variates_in_data(A, self.n_input) + 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 constitient norms""" + return all([(n.vmin is not None and n.vmax is not None) for n in self.norms]) + + @staticmethod + def _iterable_variates_in_data(data, n_input): + """ + Provides an iterable over the variates contained in the data. + + An input array with n_input 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_input fields or have + a length (n_input) + + Returns + ------- + list of np.ndarray + + """ + if isinstance(data, np.ndarray) and data.dtype.fields is not None: + data = [data[descriptor[0]] for descriptor in data.dtype.descr] + if not len(data) == n_input: + raise ValueError("The input to this `MultiNorm` must be of shape " + f"({n_input}, ...), or have a data type with {n_input} " + "fields.") + return data + + 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/colors.pyi b/lib/matplotlib/colors.pyi index 3e761c949068..3f9e0c9d93e8 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -263,6 +263,10 @@ class Normalize: @vmax.setter def vmax(self, value: float | None) -> None: ... @property + def n_input(self) -> int: ... + @property + def n_output(self) -> int: ... + @property def clip(self) -> bool: ... @clip.setter def clip(self, value: bool) -> None: ... @@ -387,6 +391,34 @@ class BoundaryNorm(Normalize): class NoNorm(Normalize): ... +class MultiNorm(Normalize): + # 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: ... + @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: ... + def __call__(self, value: ArrayLike, clip: ArrayLike | bool | None = ...) -> list: ... # type: ignore[override] + def inverse(self, value: ArrayLike) -> list: ... # type: ignore[override] + def rgb_to_hsv(arr: ArrayLike) -> np.ndarray: ... def hsv_to_rgb(hsv: ArrayLike) -> np.ndarray: ... diff --git a/lib/matplotlib/scale.py b/lib/matplotlib/scale.py index 44fbe5209c4d..e1e5884a0617 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -715,6 +715,35 @@ def get_scale_names(): return sorted(_scale_mapping) +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 cannot use + `_api.check_getitem()`, because the norm keyword accepts arguments + other than strings. + + Parameters + ---------- + scale_as_str : string + A string corresponding to a scale + + Returns + ------- + A subclass of ScaleBase. + + """ + try: + scale_cls = _scale_mapping[scale_as_str] + except KeyError: + raise ValueError( + "Invalid norm str name; the following values are " + f"supported: {', '.join(_scale_mapping)}" + ) from None + return scale_cls + + def scale_factory(scale, axis, **kwargs): """ Return a scale class by name. diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 8d0f3467f045..4c295a557ccc 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1828,3 +1828,44 @@ def test_LinearSegmentedColormap_from_list_value_color_tuple(): cmap([value for value, _ in value_color_tuples]), to_rgba_array([color for _, color in value_color_tuples]), ) + + +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"]) + + # test get vmin, vmax + norm = mpl.colors.MultiNorm(['linear', 'log']) + norm.vmin = 1 + norm.vmax = 2 + assert norm.vmin[0] == 1 + assert norm.vmin[1] == 1 + assert norm.vmax[0] == 2 + assert norm.vmax[1] == 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]) From 6985111180e48aa98fb02bc7e0ede110fac18128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Thu, 10 Apr 2025 22:02:44 +0200 Subject: [PATCH 2/8] updates based on feedback from review, @oscargus, @anntzer --- lib/matplotlib/colors.py | 44 +++++++++++++++++++++++++++++++++------- lib/matplotlib/scale.py | 29 -------------------------- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 039534bedf25..a9a99fc0f5c8 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1595,14 +1595,14 @@ def with_extremes(self, *, bad=None, under=None, over=None): Parameters ---------- - bad: :mpltype:`color`, default: None + bad : :mpltype:`color`, default: None If Matplotlib color, the bad value is set accordingly in the copy - under: tuple of :mpltype:`color`, default: None + under : tuple of :mpltype:`color`, default: None If tuple, the ``under`` value of each component is set with the values from the tuple. - over: tuple of :mpltype:`color`, default: None + over : tuple of :mpltype:`color`, default: None If tuple, the ``over`` value of each component is set with the values from the tuple. @@ -3255,12 +3255,13 @@ def __init__(self, norms, vmin=None, vmax=None, clip=False): if isinstance(norms, str) or not np.iterable(norms): raise ValueError("A MultiNorm must be assigned multiple norms") - norms = [n for n in norms] + + norms = [*norms] for i, n in enumerate(norms): if n is None: norms[i] = Normalize() elif isinstance(n, str): - scale_cls = scale._get_scale_cls_from_str(n) + scale_cls = _get_scale_cls_from_str(n) norms[i] = mpl.colorizer._auto_norm_from_scale(scale_cls)() # Convert the list of norms to a tuple to make it immutable. @@ -3354,7 +3355,7 @@ def __call__(self, value, clip=None): value Data to normalize. Must be of length `n_input` or have a data type with `n_input` fields. - clip : List of bools or bool, optional + clip : list of bools or bool, optional See the description of the parameter *clip* in Normalize. If ``None``, defaults to ``self.clip`` (which defaults to ``False``). @@ -3424,7 +3425,7 @@ def autoscale_None(self, A): self._changed() def scaled(self): - """Return whether both *vmin* and *vmax* are set on all constitient norms""" + """Return whether both *vmin* and *vmax* are set on all constituent norms""" return all([(n.vmin is not None and n.vmax is not None) for n in self.norms]) @staticmethod @@ -4092,3 +4093,32 @@ 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 cannot use + `_api.check_getitem()`, because the norm keyword accepts arguments + other than strings. + + 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/scale.py b/lib/matplotlib/scale.py index e1e5884a0617..44fbe5209c4d 100644 --- a/lib/matplotlib/scale.py +++ b/lib/matplotlib/scale.py @@ -715,35 +715,6 @@ def get_scale_names(): return sorted(_scale_mapping) -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 cannot use - `_api.check_getitem()`, because the norm keyword accepts arguments - other than strings. - - Parameters - ---------- - scale_as_str : string - A string corresponding to a scale - - Returns - ------- - A subclass of ScaleBase. - - """ - try: - scale_cls = _scale_mapping[scale_as_str] - except KeyError: - raise ValueError( - "Invalid norm str name; the following values are " - f"supported: {', '.join(_scale_mapping)}" - ) from None - return scale_cls - - def scale_factory(scale, axis, **kwargs): """ Return a scale class by name. From f42d65b43f0783c94156c5cdd85041581f2964ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Thu, 17 Apr 2025 13:17:58 +0200 Subject: [PATCH 3/8] Apply suggestions from code review Thank you @QuLogic Co-authored-by: Elliott Sales de Andrade --- lib/matplotlib/colors.py | 27 ++++++++++++++++----------- lib/matplotlib/colors.pyi | 8 ++++---- lib/matplotlib/tests/test_colors.py | 4 ++++ 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index a9a99fc0f5c8..6b32ba3ec0d0 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3242,13 +3242,13 @@ def __init__(self, norms, vmin=None, vmax=None, clip=False): The constituent norms. The list must have a minimum length of 2. vmin, vmax : float, None, or list of float or None Limits of the constituent norms. - If a list, each each value is assigned to one of the constituent + 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 each value is assigned to one of the constituent + If a list, each value is assigned to each of the constituent norms. Single values are repeated to form a list of appropriate size. """ @@ -3263,6 +3263,10 @@ def __init__(self, norms, vmin=None, vmax=None, clip=False): 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 @@ -3275,8 +3279,7 @@ def __init__(self, norms, vmin=None, vmax=None, clip=False): self.vmax = vmax self.clip = clip - self._id_norms = [n.callbacks.connect('changed', - self._changed) for n in self._norms] + [n.callbacks.connect('changed', self._changed) for n in self._norms] @property def n_input(self): @@ -3348,7 +3351,8 @@ def _changed(self): def __call__(self, value, clip=None): """ Normalize the data and return the normalized data. - Each variate in the input is assigned to the a constituent norm. + + Each variate in the input is assigned to the constituent norm. Parameters ---------- @@ -3381,8 +3385,7 @@ def __call__(self, value, clip=None): def inverse(self, value): """ - Maps the normalized value (i.e., index in the colormap) back to image - data value. + Map the normalized value (i.e., index in the colormap) back to image data value. Parameters ---------- @@ -3449,7 +3452,7 @@ def _iterable_variates_in_data(data, n_input): """ if isinstance(data, np.ndarray) and data.dtype.fields is not None: data = [data[descriptor[0]] for descriptor in data.dtype.descr] - if not len(data) == n_input: + if len(data) != n_input: raise ValueError("The input to this `MultiNorm` must be of shape " f"({n_input}, ...), or have a data type with {n_input} " "fields.") @@ -4100,9 +4103,11 @@ 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 cannot use - `_api.check_getitem()`, because the norm keyword accepts arguments - other than strings. + 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 ---------- diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index 3f9e0c9d93e8..ef141f842b19 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -403,17 +403,17 @@ class MultiNorm(Normalize): clip: ArrayLike | bool = ... ) -> None: ... @property - def norms(self) -> tuple: ... + def norms(self) -> tuple[Normalize, ...]: ... @property # type: ignore[override] - def vmin(self) -> tuple[float | None]: ... + 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]: ... + 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]: ... + def clip(self) -> tuple[bool, ...]: ... @clip.setter def clip(self, value: ArrayLike | bool) -> None: ... def __call__(self, value: ArrayLike, clip: ArrayLike | bool | None = ...) -> list: ... # type: ignore[override] diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 4c295a557ccc..39583574b04f 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1840,6 +1840,10 @@ def test_multi_norm(): 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']) From 73713e7ea2c98a9c4d72c25c0cae37429788f209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Sun, 4 May 2025 11:12:30 +0200 Subject: [PATCH 4/8] Updates based on feedback from @anntzer --- lib/matplotlib/colors.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 6b32ba3ec0d0..a509feea65ee 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3279,7 +3279,8 @@ def __init__(self, norms, vmin=None, vmax=None, clip=False): self.vmax = vmax self.clip = clip - [n.callbacks.connect('changed', self._changed) for n in self._norms] + for n in self._norms: + n.callbacks.connect('changed', self._changed) @property def n_input(self): @@ -3299,11 +3300,7 @@ def vmin(self): @vmin.setter def vmin(self, value): - if not np.iterable(value): - value = [value]*self.n_input - if len(value) != self.n_input: - raise ValueError(f"Invalid vmin for `MultiNorm` with {self.n_input}" - " inputs.") + value = np.broadcast_to(value, self.n_input) with self.callbacks.blocked(): for i, v in enumerate(value): if v is not None: @@ -3316,11 +3313,7 @@ def vmax(self): @vmax.setter def vmax(self, value): - if not np.iterable(value): - value = [value]*self.n_input - if len(value) != self.n_input: - raise ValueError(f"Invalid vmax for `MultiNorm` with {self.n_input}" - " inputs.") + value = np.broadcast_to(value, self.n_input) with self.callbacks.blocked(): for i, v in enumerate(value): if v is not None: @@ -3333,8 +3326,7 @@ def clip(self): @clip.setter def clip(self, value): - if not np.iterable(value): - value = [value]*self.n_input + value = np.broadcast_to(value, self.n_input) with self.callbacks.blocked(): for i, v in enumerate(value): if v is not None: From 78b173e7315212e1456b07099808c16a64f785bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Wed, 7 May 2025 19:34:18 +0200 Subject: [PATCH 5/8] change MultiNorm.n_intput to n_variables --- lib/matplotlib/colors.py | 59 +++++++++++++++++---------------------- lib/matplotlib/colors.pyi | 4 +-- 2 files changed, 26 insertions(+), 37 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index a509feea65ee..d5ca9c959e29 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -2321,15 +2321,10 @@ def __init__(self, vmin=None, vmax=None, clip=False): self.callbacks = cbook.CallbackRegistry(signals=["changed"]) @property - def n_input(self): + def n_variables(self): # To be overridden by subclasses with multiple inputs return 1 - @property - def n_output(self): - # To be overridden by subclasses with multiple outputs - return 1 - @property def vmin(self): return self._vmin @@ -3283,11 +3278,7 @@ def __init__(self, norms, vmin=None, vmax=None, clip=False): n.callbacks.connect('changed', self._changed) @property - def n_input(self): - return len(self._norms) - - @property - def n_output(self): + def n_variables(self): return len(self._norms) @property @@ -3300,7 +3291,7 @@ def vmin(self): @vmin.setter def vmin(self, value): - value = np.broadcast_to(value, self.n_input) + value = np.broadcast_to(value, self.n_variables) with self.callbacks.blocked(): for i, v in enumerate(value): if v is not None: @@ -3313,7 +3304,7 @@ def vmax(self): @vmax.setter def vmax(self, value): - value = np.broadcast_to(value, self.n_input) + value = np.broadcast_to(value, self.n_variables) with self.callbacks.blocked(): for i, v in enumerate(value): if v is not None: @@ -3326,7 +3317,7 @@ def clip(self): @clip.setter def clip(self, value): - value = np.broadcast_to(value, self.n_input) + value = np.broadcast_to(value, self.n_variables) with self.callbacks.blocked(): for i, v in enumerate(value): if v is not None: @@ -3349,8 +3340,8 @@ def __call__(self, value, clip=None): Parameters ---------- value - Data to normalize. Must be of length `n_input` or have a data type with - `n_input` fields. + Data to normalize. Must be of length `n_variables` or have a data type with + `n_variables` fields. clip : list of bools or bool, optional See the description of the parameter *clip* in Normalize. If ``None``, defaults to ``self.clip`` (which defaults to @@ -3359,7 +3350,7 @@ def __call__(self, value, clip=None): Returns ------- Data - Normalized input values as a list of length `n_input` + Normalized input values as a list of length `n_variables` Notes ----- @@ -3369,9 +3360,9 @@ def __call__(self, value, clip=None): if clip is None: clip = self.clip elif not np.iterable(clip): - clip = [clip]*self.n_input + clip = [clip]*self.n_variables - value = self._iterable_variates_in_data(value, self.n_input) + value = self._iterable_variates_in_data(value, self.n_variables) result = [n(v, clip=c) for n, v, c in zip(self.norms, value, clip)] return result @@ -3382,10 +3373,10 @@ def inverse(self, value): Parameters ---------- value - Normalized value. Must be of length `n_input` or have a data type with - `n_input` fields. + Normalized value. Must be of length `n_variables` or have a data type with + `n_variables` fields. """ - value = self._iterable_variates_in_data(value, self.n_input) + value = self._iterable_variates_in_data(value, self.n_variables) result = [n.inverse(v) for n, v in zip(self.norms, value)] return result @@ -3397,7 +3388,7 @@ def autoscale(self, A): with self.callbacks.blocked(): # Pause callbacks while we are updating so we only get # a single update signal at the end - A = self._iterable_variates_in_data(A, self.n_input) + A = self._iterable_variates_in_data(A, self.n_variables) for n, a in zip(self.norms, A): n.autoscale(a) self._changed() @@ -3410,11 +3401,11 @@ def autoscale_None(self, A): Parameters ---------- A - Data, must be of length `n_input` or be an np.ndarray type with - `n_input` fields. + Data, must be of length `n_variables` or be an np.ndarray type with + `n_variables` fields. """ with self.callbacks.blocked(): - A = self._iterable_variates_in_data(A, self.n_input) + A = self._iterable_variates_in_data(A, self.n_variables) for n, a in zip(self.norms, A): n.autoscale_None(a) self._changed() @@ -3424,18 +3415,18 @@ def scaled(self): return all([(n.vmin is not None and n.vmax is not None) for n in self.norms]) @staticmethod - def _iterable_variates_in_data(data, n_input): + def _iterable_variates_in_data(data, n_variables): """ Provides an iterable over the variates contained in the data. - An input array with n_input fields is returned as a list of length n referencing - slices of the original array. + An input array with `n_variables` 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_input fields or have - a length (n_input) + The input array. It must either be an array with n_variables fields or have + a length (n_variables) Returns ------- @@ -3444,10 +3435,10 @@ def _iterable_variates_in_data(data, n_input): """ if isinstance(data, np.ndarray) and data.dtype.fields is not None: data = [data[descriptor[0]] for descriptor in data.dtype.descr] - if len(data) != n_input: + if len(data) != n_variables: raise ValueError("The input to this `MultiNorm` must be of shape " - f"({n_input}, ...), or have a data type with {n_input} " - "fields.") + f"({n_variables}, ...), or have a data type with " + f"{n_variables} fields.") return data diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index ef141f842b19..c7233e7da6fb 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -263,9 +263,7 @@ class Normalize: @vmax.setter def vmax(self, value: float | None) -> None: ... @property - def n_input(self) -> int: ... - @property - def n_output(self) -> int: ... + def n_variables(self) -> int: ... @property def clip(self) -> bool: ... @clip.setter From a0dd541828e03310285a1f3011d5e5e3b2fe8049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Sun, 1 Jun 2025 12:26:55 +0200 Subject: [PATCH 6/8] Updates from code review Thank you @QuLogic for the feedback --- lib/matplotlib/colors.py | 18 +++++++++--------- lib/matplotlib/tests/test_colors.py | 8 ++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index d5ca9c959e29..c64a2a7cc190 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3233,7 +3233,7 @@ def __init__(self, norms, vmin=None, vmax=None, clip=False): """ Parameters ---------- - norms : List of strings or `Normalize` objects + norms : List of (str, `Normalize` or None) The constituent norms. The list must have a minimum length of 2. vmin, vmax : float, None, or list of float or None Limits of the constituent norms. @@ -3248,7 +3248,7 @@ def __init__(self, norms, vmin=None, vmax=None, clip=False): """ - if isinstance(norms, str) or not np.iterable(norms): + if cbook.is_scalar_or_string(norms): raise ValueError("A MultiNorm must be assigned multiple norms") norms = [*norms] @@ -3349,7 +3349,7 @@ def __call__(self, value, clip=None): Returns ------- - Data + List Normalized input values as a list of length `n_variables` Notes @@ -3401,7 +3401,7 @@ def autoscale_None(self, A): Parameters ---------- A - Data, must be of length `n_variables` or be an np.ndarray type with + Data, must be of length `n_variables` or have a data type with `n_variables` fields. """ with self.callbacks.blocked(): @@ -3412,7 +3412,7 @@ def autoscale_None(self, A): def scaled(self): """Return whether both *vmin* and *vmax* are set on all constituent norms""" - return all([(n.vmin is not None and n.vmax is not None) for n in self.norms]) + return all([n.scaled() for n in self.norms]) @staticmethod def _iterable_variates_in_data(data, n_variables): @@ -3430,7 +3430,7 @@ def _iterable_variates_in_data(data, n_variables): Returns ------- - list of np.ndarray + list of np.ndarray """ if isinstance(data, np.ndarray) and data.dtype.fields is not None: @@ -4087,9 +4087,9 @@ def _get_scale_cls_from_str(scale_as_str): 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' + `_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 diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 39583574b04f..1d9ca1e66b0d 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1873,3 +1873,11 @@ def test_multi_norm(): 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 aeeba89deabed00adda1098db6414e85585e4fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Sun, 1 Jun 2025 12:44:38 +0200 Subject: [PATCH 7/8] update to conform to linter --- lib/matplotlib/colors.pyi | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index c7233e7da6fb..ee1a0b204903 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -402,20 +402,20 @@ class MultiNorm(Normalize): ) -> None: ... @property def norms(self) -> tuple[Normalize, ...]: ... - @property # type: ignore[override] + @property # type: ignore[override] def vmin(self) -> tuple[float | None, ...]: ... @vmin.setter def vmin(self, value: ArrayLike | float | None) -> None: ... - @property # type: ignore[override] + @property # type: ignore[override] def vmax(self) -> tuple[float | None, ...]: ... @vmax.setter def vmax(self, value: ArrayLike | float | None) -> None: ... - @property # type: ignore[override] + @property # type: ignore[override] def clip(self) -> tuple[bool, ...]: ... @clip.setter def clip(self, value: ArrayLike | bool) -> None: ... - def __call__(self, value: ArrayLike, clip: ArrayLike | bool | None = ...) -> list: ... # type: ignore[override] - def inverse(self, value: ArrayLike) -> list: ... # type: ignore[override] + def __call__(self, value: ArrayLike, clip: ArrayLike | bool | None = ...) -> list: ... # type: ignore[override] + def inverse(self, value: ArrayLike) -> list: ... # type: ignore[override] def rgb_to_hsv(arr: ArrayLike) -> np.ndarray: ... def hsv_to_rgb(hsv: ArrayLike) -> np.ndarray: ... From 32247f5c4fd903f6e8009b33ee210ef64d195ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Trygve=20Magnus=20R=C3=A6der?= Date: Wed, 4 Jun 2025 22:50:56 +0200 Subject: [PATCH 8/8] updates based on feedback from @timhoffm (and @QuLogic ) --- lib/matplotlib/colors.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index c64a2a7cc190..d619c44856ee 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -3226,16 +3226,16 @@ def inverse(self, value): class MultiNorm(Normalize): """ - A mixin class which contains multiple scalar norms + 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) + norms : list of (str, `Normalize` or None) The constituent norms. The list must have a minimum length of 2. - vmin, vmax : float, None, or list of float or None + 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. @@ -3279,14 +3279,17 @@ def __init__(self, norms, vmin=None, vmax=None, clip=False): @property def n_variables(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 @@ -3300,6 +3303,7 @@ def vmin(self, value): @property def vmax(self): + """The upper limit of each constituent norm.""" return tuple(n.vmax for n in self._norms) @vmax.setter @@ -3313,6 +3317,7 @@ def vmax(self, value): @property def clip(self): + """The clip behaviour of each constituent norm.""" return tuple(n.clip for n in self._norms) @clip.setter @@ -3339,9 +3344,9 @@ def __call__(self, value, clip=None): Parameters ---------- - value - Data to normalize. Must be of length `n_variables` or have a data type with - `n_variables` fields. + value : array-like + Data to normalize. Must be of length `n_variables` or be a structured + array or scalar with `n_variables` fields. clip : list of bools or bool, optional See the description of the parameter *clip* in Normalize. If ``None``, defaults to ``self.clip`` (which defaults to @@ -3349,7 +3354,7 @@ def __call__(self, value, clip=None): Returns ------- - List + list Normalized input values as a list of length `n_variables` Notes @@ -3373,8 +3378,8 @@ def inverse(self, value): Parameters ---------- value - Normalized value. Must be of length `n_variables` or have a data type with - `n_variables` fields. + Normalized value. Must be of length `n_variables` or be a structured array + or scalar with `n_variables` fields. """ value = self._iterable_variates_in_data(value, self.n_variables) result = [n.inverse(v) for n, v in zip(self.norms, value)] @@ -3401,8 +3406,8 @@ def autoscale_None(self, A): Parameters ---------- A - Data, must be of length `n_variables` or have a data type with - `n_variables` fields. + Data, must be of length `n_variables` or be a structured array or scalar + with `n_variables` fields. """ with self.callbacks.blocked(): A = self._iterable_variates_in_data(A, self.n_variables) @@ -3411,7 +3416,7 @@ def autoscale_None(self, A): self._changed() def scaled(self): - """Return whether both *vmin* and *vmax* are set on all constituent norms""" + """Return whether both *vmin* and *vmax* are set on all constituent norms.""" return all([n.scaled() for n in self.norms]) @staticmethod @@ -3437,8 +3442,8 @@ def _iterable_variates_in_data(data, n_variables): data = [data[descriptor[0]] for descriptor in data.dtype.descr] if len(data) != n_variables: raise ValueError("The input to this `MultiNorm` must be of shape " - f"({n_variables}, ...), or have a data type with " - f"{n_variables} fields.") + f"({n_variables}, ...), or be structured array or scalar " + f"with {n_variables} fields.") return data