diff --git a/doc/api/colors_api.rst b/doc/api/colors_api.rst index 18e7c43932a9..147762d0152b 100644 --- a/doc/api/colors_api.rst +++ b/doc/api/colors_api.rst @@ -55,6 +55,7 @@ Multivariate Colormaps BivarColormap SegmentedBivarColormap BivarColormapFromImage + MultivarColormap Other classes ------------- diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index 66770e426386..bedb88df21fa 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -6175,6 +6175,7 @@ def imshow(self, X, cmap=None, norm=None, *, aspect=None, - (M, N): an image with scalar data. The values are mapped to colors using normalization and a colormap. See parameters *norm*, *cmap*, *vmin*, *vmax*. + - (K, M, N): if coupled with a cmap that supports K scalars - (M, N, 3): an image with RGB values (0-1 float or 0-255 int). - (M, N, 4): an image with RGBA values (0-1 float or 0-255 int), i.e. including transparency. @@ -6184,15 +6185,16 @@ def imshow(self, X, cmap=None, norm=None, *, aspect=None, Out-of-range RGB(A) values are clipped. - %(cmap_doc)s + + %(multi_cmap_doc)s This parameter is ignored if *X* is RGB(A). - %(norm_doc)s + %(multi_norm_doc)s This parameter is ignored if *X* is RGB(A). - %(vmin_vmax_doc)s + %(multi_vmin_vmax_doc)s This parameter is ignored if *X* is RGB(A). @@ -6271,6 +6273,10 @@ def imshow(self, X, cmap=None, norm=None, *, aspect=None, See :doc:`/gallery/images_contours_and_fields/image_antialiasing` for a discussion of image antialiasing. + When using a `~matplotlib.colors.BivarColormap` or + `~matplotlib.colors.MultivarColormap`, 'data' is the only valid + interpolation_stage. + alpha : float or array-like, optional The alpha blending value, between 0 (transparent) and 1 (opaque). If *alpha* is an array, the alpha blending values are applied pixel @@ -6376,6 +6382,7 @@ def imshow(self, X, cmap=None, norm=None, *, aspect=None, if aspect is not None: self.set_aspect(aspect) + X = mcolorizer._ensure_multivariate_data(X, im.norm.n_components) im.set_data(X) im.set_alpha(alpha) if im.get_clip_path() is None: @@ -6531,9 +6538,10 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, Parameters ---------- - C : 2D array-like + C : 2D (M, N) or 3D (K, M, N) array-like The color-mapped values. Color-mapping is controlled by *cmap*, - *norm*, *vmin*, and *vmax*. + *norm*, *vmin*, and *vmax*. 3D arrays are supported only if the + cmap supports K channels. X, Y : array-like, optional The coordinates of the corners of quadrilaterals of a pcolormesh:: @@ -6578,11 +6586,11 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, See :doc:`/gallery/images_contours_and_fields/pcolormesh_grids` for more description. - %(cmap_doc)s + %(multi_cmap_doc)s - %(norm_doc)s + %(multi_norm_doc)s - %(vmin_vmax_doc)s + %(multi_vmin_vmax_doc)s %(colorizer_doc)s @@ -6657,8 +6665,19 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, if shading is None: shading = mpl.rcParams['pcolor.shading'] shading = shading.lower() - X, Y, C, shading = self._pcolorargs('pcolor', *args, shading=shading, - kwargs=kwargs) + + mcolorizer.ColorizingArtist._check_exclusionary_keywords(colorizer, + vmin=vmin, vmax=vmax, + norm=norm, cmap=cmap) + if colorizer is None: + colorizer = mcolorizer.Colorizer(cmap=cmap, norm=norm) + + C = mcolorizer._ensure_multivariate_data(args[-1], + colorizer.cmap.n_variates) + + X, Y, C, shading = self._pcolorargs('pcolor', *args[:-1], C, + shading=shading, kwargs=kwargs) + linewidths = (0.25,) if 'linewidth' in kwargs: kwargs['linewidths'] = kwargs.pop('linewidth') @@ -6693,9 +6712,7 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None, coords = stack([X, Y], axis=-1) collection = mcoll.PolyQuadMesh( - coords, array=C, cmap=cmap, norm=norm, colorizer=colorizer, - alpha=alpha, **kwargs) - collection._check_exclusionary_keywords(colorizer, vmin=vmin, vmax=vmax) + coords, array=C, colorizer=colorizer, alpha=alpha, **kwargs) collection._scale_norm(norm, vmin, vmax) coords = coords.reshape(-1, 2) # flatten the grid structure; keep x, y @@ -6733,6 +6750,7 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, - (M, N) or M*N: a mesh with scalar data. The values are mapped to colors using normalization and a colormap. See parameters *norm*, *cmap*, *vmin*, *vmax*. + - (K, M, N): if coupled with a cmap that supports K scalars - (M, N, 3): an image with RGB values (0-1 float or 0-255 int). - (M, N, 4): an image with RGBA values (0-1 float or 0-255 int), i.e. including transparency. @@ -6767,11 +6785,11 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, expanded as needed into the appropriate 2D arrays, making a rectangular grid. - %(cmap_doc)s + %(multi_cmap_doc)s - %(norm_doc)s + %(multi_norm_doc)s - %(vmin_vmax_doc)s + %(multi_vmin_vmax_doc)s %(colorizer_doc)s @@ -6897,7 +6915,16 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, shading = mpl._val_or_rc(shading, 'pcolor.shading').lower() kwargs.setdefault('edgecolors', 'none') - X, Y, C, shading = self._pcolorargs('pcolormesh', *args, + mcolorizer.ColorizingArtist._check_exclusionary_keywords(colorizer, + vmin=vmin, vmax=vmax, + norm=norm, cmap=cmap) + if colorizer is None: + colorizer = mcolorizer.Colorizer(cmap=cmap, norm=norm) + + C = mcolorizer._ensure_multivariate_data(args[-1], + colorizer.cmap.n_variates) + + X, Y, C, shading = self._pcolorargs('pcolormesh', *args[:-1], C, shading=shading, kwargs=kwargs) coords = np.stack([X, Y], axis=-1) @@ -6905,8 +6932,7 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None, collection = mcoll.QuadMesh( coords, antialiased=antialiased, shading=shading, - array=C, cmap=cmap, norm=norm, colorizer=colorizer, alpha=alpha, **kwargs) - collection._check_exclusionary_keywords(colorizer, vmin=vmin, vmax=vmax) + array=C, colorizer=colorizer, alpha=alpha, **kwargs) collection._scale_norm(norm, vmin, vmax) coords = coords.reshape(-1, 2) # flatten the grid structure; keep x, y @@ -8848,6 +8874,9 @@ def matshow(self, Z, **kwargs): """ Z = np.asanyarray(Z) + if Z.ndim != 2: + if Z.ndim != 3 or Z.shape[2] not in (1, 3, 4): + raise TypeError(f"Invalid shape {Z.shape} for image data") kw = {'origin': 'upper', 'interpolation': 'nearest', 'aspect': 'equal', # (already the imshow default) diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi index 1c3e1e560d07..880523112041 100644 --- a/lib/matplotlib/axes/_axes.pyi +++ b/lib/matplotlib/axes/_axes.pyi @@ -12,7 +12,13 @@ from matplotlib.collections import ( QuadMesh, ) from matplotlib.colorizer import Colorizer -from matplotlib.colors import Colormap, Normalize +from matplotlib.colors import ( + Colormap, + BivarColormap, + MultivarColormap, + Norm, + Normalize, +) from matplotlib.container import ( BarContainer, PieContainer, ErrorbarContainer, StemContainer) from matplotlib.contour import ContourSet, QuadContourSet @@ -500,14 +506,14 @@ class Axes(_AxesBase): def imshow( self, X: ArrayLike | PIL.Image.Image, - cmap: str | Colormap | None = ..., - norm: str | Normalize | None = ..., + cmap: str | Colormap | BivarColormap | MultivarColormap | None = ..., + norm: str | Norm | None = ..., *, aspect: Literal["equal", "auto"] | float | None = ..., interpolation: str | None = ..., alpha: float | ArrayLike | None = ..., - vmin: float | None = ..., - vmax: float | None = ..., + vmin: float | tuple[float, ...] | None = ..., + vmax: float | tuple[float, ...] | None = ..., colorizer: Colorizer | None = ..., origin: Literal["upper", "lower"] | None = ..., extent: tuple[float, float, float, float] | None = ..., @@ -524,10 +530,10 @@ class Axes(_AxesBase): *args: ArrayLike, shading: Literal["flat", "nearest", "auto"] | None = ..., alpha: float | None = ..., - norm: str | Normalize | None = ..., - cmap: str | Colormap | None = ..., - vmin: float | None = ..., - vmax: float | None = ..., + norm: str | Norm | None = ..., + cmap: str | Colormap | BivarColormap | MultivarColormap | None = ..., + vmin: float | tuple[float, ...] | None = ..., + vmax: float | tuple[float, ...] | None = ..., colorizer: Colorizer | None = ..., data=..., **kwargs @@ -536,10 +542,10 @@ class Axes(_AxesBase): self, *args: ArrayLike, alpha: float | None = ..., - norm: str | Normalize | None = ..., - cmap: str | Colormap | None = ..., - vmin: float | None = ..., - vmax: float | None = ..., + norm: str | Norm | None = ..., + cmap: str | Colormap | BivarColormap | MultivarColormap | None = ..., + vmin: float | tuple[float, ...] | None = ..., + vmax: float | tuple[float, ...] | None = ..., colorizer: Colorizer | None = ..., shading: Literal["flat", "nearest", "gouraud", "auto"] | None = ..., antialiased: bool = ..., diff --git a/lib/matplotlib/collections.py b/lib/matplotlib/collections.py index c9e04a70b356..95150115fc56 100644 --- a/lib/matplotlib/collections.py +++ b/lib/matplotlib/collections.py @@ -2386,6 +2386,8 @@ def set_array(self, A): h, w = height, width ok_shapes = [(h, w, 3), (h, w, 4), (h, w), (h * w,)] if A is not None: + if hasattr(self, 'norm'): + A = mcolorizer._ensure_multivariate_data(A, self.norm.n_components) shape = np.shape(A) if shape not in ok_shapes: raise ValueError( @@ -2643,7 +2645,7 @@ def _get_unmasked_polys(self): mask = (mask[0:-1, 0:-1] | mask[1:, 1:] | mask[0:-1, 1:] | mask[1:, 0:-1]) arr = self.get_array() if arr is not None: - arr = np.ma.getmaskarray(arr) + arr = self._getmaskarray(arr) if arr.ndim == 3: # RGB(A) case mask |= np.any(arr, axis=-1) diff --git a/lib/matplotlib/collections.pyi b/lib/matplotlib/collections.pyi index ecd969cfacc6..e30ed77cafc9 100644 --- a/lib/matplotlib/collections.pyi +++ b/lib/matplotlib/collections.pyi @@ -7,7 +7,12 @@ from numpy.typing import ArrayLike, NDArray from . import colorizer, transforms from .backend_bases import MouseEvent from .artist import Artist -from .colors import Normalize, Colormap +from .colors import ( + Colormap, + BivarColormap, + MultivarColormap, + Norm, +) from .lines import Line2D from .path import Path from .patches import Patch @@ -29,8 +34,8 @@ class Collection(colorizer.ColorizingArtist): antialiaseds: bool | Sequence[bool] | None = ..., offsets: tuple[float, float] | Sequence[tuple[float, float]] | None = ..., offset_transform: transforms.Transform | None = ..., - norm: Normalize | None = ..., - cmap: Colormap | None = ..., + norm: Norm | None = ..., + cmap: Colormap | BivarColormap | MultivarColormap | None = ..., colorizer: colorizer.Colorizer | None = ..., pickradius: float = ..., hatch: str | None = ..., diff --git a/lib/matplotlib/colorizer.py b/lib/matplotlib/colorizer.py index 095b93ccfe85..70c669d02379 100644 --- a/lib/matplotlib/colorizer.py +++ b/lib/matplotlib/colorizer.py @@ -74,7 +74,7 @@ def _scale_norm(self, norm, vmin, vmax, A): """ if vmin is not None or vmax is not None: self.set_clim(vmin, vmax) - if isinstance(norm, colors.Normalize): + if isinstance(norm, colors.Norm): raise ValueError( "Passing a Normalize instance simultaneously with " "vmin/vmax is not supported. Please pass vmin/vmax " @@ -277,8 +277,16 @@ def set_clim(self, vmin=None, vmax=None): def get_clim(self): """ Return the values (min, max) that are mapped to the colormap limits. + + This function always returns min and max as tuples to ensure type consistency + when working with both scalar and multivariate color mapping. + See also `.ColorizingArtist.get_clim()` which returns scalars but is unavailable + for multivariate color mapping. """ - return self.norm.vmin, self.norm.vmax + if self.norm.n_components == 1: + return (self.norm.vmin, ), (self.norm.vmax, ) + else: + return self.norm.vmin, self.norm.vmax def changed(self): """ @@ -306,7 +314,10 @@ def vmax(self, vmax): @property def clip(self): - return self.norm.clip + if self.norm.n_components == 1: + return (self.norm.clip, ) + else: + return self.norm.clip @clip.setter def clip(self, clip): @@ -360,8 +371,14 @@ def to_rgba(self, x, alpha=None, bytes=False, norm=True): def get_clim(self): """ Return the values (min, max) that are mapped to the colormap limits. + + This function is not available for multivariate data. + Use `.Colorizer.get_clim` via the .colorizer property instead. """ - return self._colorizer.get_clim() + if self._colorizer.norm.n_components > 1: + raise AttributeError("get_clim() cannot be used with a multi-component " + "colormap. Use .colorizer.get_clim() instead") + return self.colorizer.norm.vmin, self.colorizer.norm.vmax def set_clim(self, vmin=None, vmax=None): """ @@ -376,9 +393,14 @@ def set_clim(self, vmin=None, vmax=None): tuple (*vmin*, *vmax*) as a single positional argument. .. ACCEPTS: (vmin: float, vmax: float) + + This function is not available for multivariate data. """ # If the norm's limits are updated self.changed() will be called # through the callbacks attached to the norm + if self._colorizer.norm.n_components > 1: + raise AttributeError("set_clim() cannot be used with a multi-component " + "colormap. Use .colorizer.set_clim() instead") self._colorizer.set_clim(vmin, vmax) def get_alpha(self): @@ -600,6 +622,18 @@ def get_array(self): """ return self._A + def _getmaskarray(self, A): + """ + Similar to np.ma.getmaskarray but also handles the case where + the data has multiple fields. + + The return array always has the same shape as the input, and dtype bool + """ + mask = np.ma.getmaskarray(A) + if isinstance(self.norm, colors.MultiNorm): + mask = np.any(mask.view('bool').reshape((*A.shape, -1)), axis=-1) + return mask + def changed(self): """ Call this whenever the mappable is changed to notify all the diff --git a/lib/matplotlib/colorizer.pyi b/lib/matplotlib/colorizer.pyi index 9a5a73415d83..3350709faf01 100644 --- a/lib/matplotlib/colorizer.pyi +++ b/lib/matplotlib/colorizer.pyi @@ -9,13 +9,13 @@ class Colorizer: callbacks: cbook.CallbackRegistry def __init__( self, - cmap: str | colors.Colormap | None = ..., + cmap: str | colors.Colormap | colors.BivarColormap | colors.MultivarColormap | None = ..., norm: str | colors.Norm | None = ..., ) -> None: ... @property def norm(self) -> colors.Norm: ... @norm.setter - def norm(self, norm: colors.Norm | str | None) -> None: ... + def norm(self, norm: colors.Norm | str | tuple[str, ...] | None) -> None: ... def to_rgba( self, x: np.ndarray, @@ -26,28 +26,28 @@ class Colorizer: def autoscale(self, A: ArrayLike) -> None: ... def autoscale_None(self, A: ArrayLike) -> None: ... @property - def cmap(self) -> colors.Colormap: ... + def cmap(self) -> colors.Colormap | colors.BivarColormap | colors.MultivarColormap: ... @cmap.setter - def cmap(self, cmap: colors.Colormap | str | None) -> None: ... - def get_clim(self) -> tuple[float, float]: ... - def set_clim(self, vmin: float | tuple[float, float] | None = ..., vmax: float | None = ...) -> None: ... + def cmap(self, cmap: colors.Colormap | colors.BivarColormap | colors.MultivarColormap | str | None) -> None: ... + def get_clim(self) -> tuple[tuple[float | None, ...], tuple[float | None, ...]]: ... + def set_clim(self, vmin: float | tuple[float | None, ...] | None = ..., vmax: float | tuple[float | None, ...] | None = ...) -> None: ... def changed(self) -> None: ... @property - def vmin(self) -> float | None: ... + def vmin(self) -> tuple[float | None, ...] | None: ... @vmin.setter - def vmin(self, value: float | None) -> None: ... + def vmin(self, value: tuple[float | None, ...] | None) -> None: ... @property - def vmax(self) -> float | None: ... + def vmax(self) -> tuple[float | None, ...] | None: ... @vmax.setter - def vmax(self, value: float | None) -> None: ... + def vmax(self, value: tuple[float | None, ...] | None) -> None: ... @property - def clip(self) -> bool: ... + def clip(self) -> tuple[bool, ...]: ... @clip.setter - def clip(self, value: bool) -> None: ... + def clip(self, value: ArrayLike | bool | tuple[bool, ...]) -> None: ... class _ColorizerInterface: - cmap: colors.Colormap + cmap: colors.Colormap | colors.BivarColormap | colors.MultivarColormap colorbar: colorbar.Colorbar | None callbacks: cbook.CallbackRegistry def to_rgba( @@ -57,11 +57,11 @@ class _ColorizerInterface: bytes: bool = ..., norm: bool = ..., ) -> np.ndarray: ... - def get_clim(self) -> tuple[float, float]: ... - def set_clim(self, vmin: float | tuple[float, float] | None = ..., vmax: float | None = ...) -> None: ... + def get_clim(self) -> tuple[float, float] | tuple[tuple[float, ...], tuple[float, ...]]: ... + def set_clim(self, vmin: float | tuple[float, float] | tuple[float | None, ...] | None = ..., vmax: float | tuple[float | None, ...] | None = ...) -> None: ... def get_alpha(self) -> float | None: ... - def get_cmap(self) -> colors.Colormap: ... - def set_cmap(self, cmap: str | colors.Colormap) -> None: ... + def get_cmap(self) -> colors.Colormap | colors.BivarColormap | colors.MultivarColormap: ... + def set_cmap(self, cmap: str | colors.Colormap | colors.BivarColormap | colors.MultivarColormap) -> None: ... @property def norm(self) -> colors.Norm: ... @norm.setter @@ -75,7 +75,7 @@ class _ScalarMappable(_ColorizerInterface): def __init__( self, norm: colors.Norm | None = ..., - cmap: str | colors.Colormap | None = ..., + cmap: str | colors.Colormap | colors.BivarColormap | colors.MultivarColormap | None = ..., *, colorizer: Colorizer | None = ..., **kwargs diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py index 02ea1d3723e9..6c0d3cb4c491 100644 --- a/lib/matplotlib/colors.py +++ b/lib/matplotlib/colors.py @@ -1432,9 +1432,9 @@ def __init__(self, colormaps, combination_mode, name='multivariate colormap'): Describe how colormaps are combined in sRGB space - If 'sRGB_add' -> Mixing produces brighter colors - `sRGB = sum(colors)` + ``sRGB = sum(colors)`` - If 'sRGB_sub' -> Mixing produces darker colors - `sRGB = 1 - sum(1 - colors)` + ``sRGB = 1 - sum(1 - colors)`` name : str, optional The name of the colormap family. """ @@ -1606,15 +1606,15 @@ 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 - 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 diff --git a/lib/matplotlib/colors.pyi b/lib/matplotlib/colors.pyi index d7fbbf181272..ddeb19975cf9 100644 --- a/lib/matplotlib/colors.pyi +++ b/lib/matplotlib/colors.pyi @@ -255,13 +255,13 @@ class Norm(ABC): def __init__(self) -> None: ... @property @abstractmethod - def vmin(self) -> float | tuple[float] | None: ... + def vmin(self) -> float | tuple[float | None, ...] | None: ... @property @abstractmethod - def vmax(self) -> float | tuple[float] | None: ... + def vmax(self) -> float | tuple[float | None, ...] | None: ... @property @abstractmethod - def clip(self) -> bool | tuple[bool]: ... + def clip(self) -> bool | tuple[bool, ...]: ... @abstractmethod def __call__(self, value: np.ndarray, clip: bool | None = ...) -> ArrayLike: ... @abstractmethod diff --git a/lib/matplotlib/image.py b/lib/matplotlib/image.py index 25e6a3bd5ee8..100a0920e2e5 100644 --- a/lib/matplotlib/image.py +++ b/lib/matplotlib/image.py @@ -281,7 +281,14 @@ def __init__(self, ax, self.set_filternorm(filternorm) self.set_filterrad(filterrad) self.set_interpolation(interpolation) - self.set_interpolation_stage(interpolation_stage) + if isinstance(self.norm, mcolors.MultiNorm): + if interpolation_stage not in [None, 'data', 'auto']: + raise ValueError("when using multivariate color mapping 'data' " + "is the only valid interpolation_stage, but got " + f"{interpolation_stage}") + self.set_interpolation_stage('data') + else: + self.set_interpolation_stage(interpolation_stage) self.set_resample(resample) self.axes = ax @@ -471,30 +478,45 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0, # input data is not going to match the size on the screen so we # have to resample to the correct number of pixels - if A.dtype.kind == 'f': # Float dtype: scale to same dtype. - scaled_dtype = np.dtype("f8" if A.dtype.itemsize > 4 else "f4") - if scaled_dtype.itemsize < A.dtype.itemsize: - _api.warn_external(f"Casting input data from {A.dtype}" - f" to {scaled_dtype} for imshow.") - else: # Int dtype, likely. - # TODO slice input array first - # Scale to appropriately sized float: use float32 if the - # dynamic range is small, to limit the memory footprint. - da = A.max().astype("f8") - A.min().astype("f8") - scaled_dtype = "f8" if da > 1e8 else "f4" - - # resample the input data to the correct resolution and shape - A_resampled = _resample(self, A.astype(scaled_dtype), out_shape, t) + if A.dtype.fields is None: # scalar data and colormap + arrs, norms, dtypes = [A], [self.norm], [A.dtype] + else: # using a multivariate colormap + arrs = [A[f] for f in A.dtype.fields] + norms = self.norm.norms + dtypes = [A.dtype.fields[f][0] for f in A.dtype.fields] + + def get_scaled_dtype(A): + # gets the scaled dtype + if A.dtype.kind == 'f': # Float dtype: scale to same dtype. + scaled_dtype = np.dtype('f8' if A.dtype.itemsize > 4 else 'f4') + if scaled_dtype.itemsize < A.dtype.itemsize: + _api.warn_external(f"Casting input data from {A.dtype}" + f" to {scaled_dtype} for imshow.") + else: # Int dtype, likely. + # TODO slice input array first + # Scale to appropriately sized float: use float32 if the + # dynamic range is small, to limit the memory footprint. + da = A.max().astype("f8") - A.min().astype("f8") + scaled_dtype = "f8" if da > 1e8 else "f4" + + return scaled_dtype + + A_resampled = [_resample(self, + a.astype(get_scaled_dtype(a)), + out_shape, t) + for a in arrs] # if using NoNorm, cast back to the original datatype - if isinstance(self.norm, mcolors.NoNorm): - A_resampled = A_resampled.astype(A.dtype) + for i, n in enumerate(norms): + if isinstance(n, mcolors.NoNorm): + A_resampled[i] = A_resampled[i].astype(dtypes[i]) # Compute out_mask (what screen pixels include "bad" data # pixels) and out_alpha (to what extent screen pixels are # covered by data pixels: 0 outside the data extent, 1 inside # (even for bad data), and intermediate values at the edges). - mask = (np.where(A.mask, np.float32(np.nan), np.float32(1)) + mask = (np.where(self._getmaskarray(A), + np.float32(np.nan), np.float32(1)) if A.mask.shape == A.shape # nontrivial mask else np.ones_like(A, np.float32)) # we always have to interpolate the mask to account for @@ -507,8 +529,13 @@ def _make_image(self, A, in_bbox, out_bbox, clip_bbox, magnification=1.0, alpha = self.get_alpha() if alpha is not None and np.ndim(alpha) > 0: out_alpha *= _resample(self, alpha, out_shape, t, resample=True) - # mask and run through the norm - resampled_masked = np.ma.masked_array(A_resampled, out_mask) + + # mask + resampled_masked = [np.ma.masked_array(r, out_mask) + for r in A_resampled] + + if A.dtype.fields is None: + resampled_masked = resampled_masked[0] res = self.norm(resampled_masked) else: if A.ndim == 2: # interpolation_stage = 'rgba' @@ -669,8 +696,15 @@ def _normalize_image_array(A): """ A = cbook.safe_masked_invalid(A, copy=True) if A.dtype != np.uint8 and not np.can_cast(A.dtype, float, "same_kind"): - raise TypeError(f"Image data of dtype {A.dtype} cannot be " - f"converted to float") + if A.dtype.fields is None: + raise TypeError(f"Image data of dtype {A.dtype} cannot be " + f"converted to float") + else: + for key in A.dtype.fields: + if not np.can_cast(A[key].dtype, float, "same_kind"): + raise TypeError(f"Image data of dtype {A.dtype} cannot be " + f"converted to a sequence of floats") + if A.ndim == 3 and A.shape[-1] == 1: A = A.squeeze(-1) # If just (M, N, 1), assume scalar and apply colormap. if not (A.ndim == 2 or A.ndim == 3 and A.shape[-1] in [3, 4]): diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index dd80da45e332..9d336f81e309 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -83,7 +83,11 @@ from matplotlib.scale import get_scale_names # noqa: F401 from matplotlib.cm import _colormaps -from matplotlib.colors import _color_sequences, Colormap +from matplotlib.colors import (_color_sequences, + Colormap, + BivarColormap, + MultivarColormap, + ) import numpy as np @@ -160,7 +164,7 @@ # We may not need the following imports here: -from matplotlib.colors import Normalize +from matplotlib.colors import Norm, Normalize from matplotlib.lines import Line2D, AxLine from matplotlib.text import Text, Annotation from matplotlib.patches import Arrow, Circle, Rectangle # noqa: F401 @@ -3759,14 +3763,14 @@ def hlines( @_copy_docstring_and_deprecators(Axes.imshow) def imshow( X: ArrayLike | PIL.Image.Image, - cmap: str | Colormap | None = None, - norm: str | Normalize | None = None, + cmap: str | Colormap | BivarColormap | MultivarColormap | None = None, + norm: str | Norm | None = None, *, aspect: Literal["equal", "auto"] | float | None = None, interpolation: str | None = None, alpha: float | ArrayLike | None = None, - vmin: float | None = None, - vmax: float | None = None, + vmin: float | tuple[float, ...] | None = None, + vmax: float | tuple[float, ...] | None = None, colorizer: Colorizer | None = None, origin: Literal["upper", "lower"] | None = None, extent: tuple[float, float, float, float] | None = None, @@ -3878,10 +3882,10 @@ def pcolor( *args: ArrayLike, shading: Literal["flat", "nearest", "auto"] | None = None, alpha: float | None = None, - norm: str | Normalize | None = None, - cmap: str | Colormap | None = None, - vmin: float | None = None, - vmax: float | None = None, + norm: str | Norm | None = None, + cmap: str | Colormap | BivarColormap | MultivarColormap | None = None, + vmin: float | tuple[float, ...] | None = None, + vmax: float | tuple[float, ...] | None = None, colorizer: Colorizer | None = None, data=None, **kwargs, @@ -3907,10 +3911,10 @@ def pcolor( def pcolormesh( *args: ArrayLike, alpha: float | None = None, - norm: str | Normalize | None = None, - cmap: str | Colormap | None = None, - vmin: float | None = None, - vmax: float | None = None, + norm: str | Norm | None = None, + cmap: str | Colormap | BivarColormap | MultivarColormap | None = None, + vmin: float | tuple[float, ...] | None = None, + vmax: float | tuple[float, ...] | None = None, colorizer: Colorizer | None = None, shading: Literal["flat", "nearest", "gouraud", "auto"] | None = None, antialiased: bool = False, diff --git a/lib/matplotlib/tests/baseline_images/test_axes/bivariate_cmap_shapes.png b/lib/matplotlib/tests/baseline_images/test_axes/bivariate_cmap_shapes.png new file mode 100644 index 000000000000..a3339afa7545 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/bivariate_cmap_shapes.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/bivariate_visualizations.png b/lib/matplotlib/tests/baseline_images/test_axes/bivariate_visualizations.png new file mode 100644 index 000000000000..501a87436e0d Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/bivariate_visualizations.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/multivariate_imshow_alpha.png b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_imshow_alpha.png new file mode 100644 index 000000000000..03420bfb2473 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_imshow_alpha.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/multivariate_imshow_norm.png b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_imshow_norm.png new file mode 100644 index 000000000000..134c73f1ca85 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_imshow_norm.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/multivariate_pcolormesh_alpha.png b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_pcolormesh_alpha.png new file mode 100644 index 000000000000..a5748573dbe2 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_pcolormesh_alpha.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/multivariate_pcolormesh_norm.png b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_pcolormesh_norm.png new file mode 100644 index 000000000000..e85e27fd183d Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_pcolormesh_norm.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_axes/multivariate_visualizations.png b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_visualizations.png new file mode 100644 index 000000000000..16843b0dd775 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/multivariate_visualizations.png differ diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 6751666360b1..ba91ec377884 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -10366,3 +10366,248 @@ def test_errorbar_uses_rcparams(): assert_allclose([cap.get_markeredgewidth() for cap in caplines], 2.5) for barcol in barlinecols: assert_allclose(barcol.get_linewidths(), 1.75) + + +def test_matshow_not_multivariate(): + """ + matshow() currently does not support multivariate/bivariate colormaps. + This test is to ensure coverage for the if-statement that checks for this. + + This test should be removed if matshow() is updated to support + multivariate/bivariate colormaps. + """ + fig, axes = plt.subplots() + arr = np.arange(24).reshape((-1, 4, 2)) + with pytest.raises(TypeError, match="Invalid shape"): + axes.matshow(arr) + + +@image_comparison(["bivariate_visualizations.png"], style='mpl20') +def test_bivariate_visualizations(): + x_0 = np.arange(25, dtype='float32').reshape(5, 5) % 5 + x_1 = np.arange(25, dtype='float32').reshape(5, 5).T % 5 + + fig, axes = plt.subplots(1, 5, figsize=(8, 2)) + + axes[0].imshow((x_0, x_1), cmap='BiPeak', interpolation='nearest') + axes[1].pcolor((x_0, x_1), cmap='BiPeak') + axes[2].pcolormesh((x_0, x_1), cmap='BiPeak') + + x = np.arange(5) + y = np.arange(5) + X, Y = np.meshgrid(x, y) + axes[3].pcolormesh(X, Y, (x_0, x_1), cmap='BiPeak') + + patches = [ + mpl.patches.Wedge((.3, .7), .1, 0, 360), # Full circle + mpl.patches.Wedge((.7, .8), .2, 0, 360, width=0.05), # Full ring + mpl.patches.Wedge((.8, .3), .2, 0, 45), # Full sector + mpl.patches.Wedge((.8, .3), .2, 22.5, 90, width=0.10), # Ring sector + ] + colors_0 = np.arange(len(patches)) // 2 + colors_1 = np.arange(len(patches)) % 2 + p = mpl.collections.PatchCollection(patches, cmap='BiPeak', alpha=0.5) + p.set_array((colors_0, colors_1)) + axes[4].add_collection(p) + remove_ticks_and_titles(fig) + + +@image_comparison(["multivariate_visualizations.png"], style='mpl20') +def test_multivariate_visualizations(): + x_0 = np.arange(25, dtype='float32').reshape(5, 5) % 5 + x_1 = np.arange(25, dtype='float32').reshape(5, 5).T % 5 + x_2 = np.arange(25, dtype='float32').reshape(5, 5) % 6 + + fig, axes = plt.subplots(1, 5, figsize=(8, 2)) + + axes[0].imshow((x_0, x_1, x_2), cmap='3VarAddA') + axes[1].pcolor((x_0, x_1, x_2), cmap='3VarAddA') + axes[2].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA') + + x = np.arange(5) + y = np.arange(5) + X, Y = np.meshgrid(x, y) + axes[3].pcolormesh(X, Y, (x_0, x_1, x_2), cmap='3VarAddA') + + patches = [ + mpl.patches.Wedge((.3, .7), .1, 0, 360), # Full circle + mpl.patches.Wedge((.7, .8), .2, 0, 360, width=0.05), # Full ring + mpl.patches.Wedge((.8, .3), .2, 0, 45), # Full sector + mpl.patches.Wedge((.8, .3), .2, 22.5, 90, width=0.10), # Ring sector + ] + colors_0 = np.arange(len(patches)) // 2 + colors_1 = np.arange(len(patches)) % 2 + colors_2 = np.arange(len(patches)) % 3 + p = mpl.collections.PatchCollection(patches, cmap='3VarAddA', alpha=0.5) + p.set_array((colors_0, colors_1, colors_2)) + axes[4].add_collection(p) + remove_ticks_and_titles(fig) + + +@image_comparison(["multivariate_pcolormesh_alpha.png"], style='mpl20') +def test_multivariate_pcolormesh_alpha(): + """ + Check that the the alpha keyword works for pcolormesh + This test covers all plotting modes that use the same pipeline + (inherit from Collection). + """ + x_0 = np.arange(25, dtype='float32').reshape(5, 5) % 5 + x_1 = np.arange(25, dtype='float32').reshape(5, 5).T % 5 + x_2 = np.arange(25, dtype='float32').reshape(5, 5) % 6 + + fig, axes = plt.subplots(2, 3) + + axes[0, 0].pcolormesh(x_1, alpha=0.5) + axes[0, 1].pcolormesh((x_0, x_1), cmap='BiPeak', alpha=0.5) + axes[0, 2].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA', alpha=0.5) + + al = np.arange(25, dtype='float32').reshape(5, 5)[::-1].T % 6 / 5 + + axes[1, 0].pcolormesh(x_1, alpha=al) + axes[1, 1].pcolormesh((x_0, x_1), cmap='BiPeak', alpha=al) + axes[1, 2].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA', alpha=al) + remove_ticks_and_titles(fig) + + +@image_comparison(["multivariate_imshow_alpha.png"], style='mpl20') +def test_multivariate_imshow_alpha(): + """ + Check that the the alpha keyword works for imshow. + """ + x_0 = np.arange(25, dtype='float32').reshape(5, 5) % 5 + x_1 = np.arange(25, dtype='float32').reshape(5, 5).T % 5 + x_2 = np.arange(25, dtype='float32').reshape(5, 5) % 6 + + fig, axes = plt.subplots(2, 3) + + # interpolation='nearest' to reduce size of baseline image + axes[0, 0].imshow(x_1, interpolation='nearest', alpha=0.5) + axes[0, 1].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak', alpha=0.5) + axes[0, 2].imshow((x_0, x_1, x_2), interpolation='nearest', + cmap='3VarAddA', alpha=0.5) + + al = np.arange(25, dtype='float32').reshape(5, 5)[::-1].T % 6 / 5 + + axes[1, 0].imshow(x_1, interpolation='nearest', alpha=al) + axes[1, 1].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak', alpha=al) + axes[1, 2].imshow((x_0, x_1, x_2), interpolation='nearest', + cmap='3VarAddA', alpha=al) + remove_ticks_and_titles(fig) + + +@image_comparison(["multivariate_pcolormesh_norm.png"], style='mpl20') +def test_multivariate_pcolormesh_norm(): + """ + Test vmin, vmax and norm + Norm is checked via a LogNorm, as this converts the input to a masked array, + masking for X <= 0. By using a LogNorm, this functionality is also tested. + This test covers all plotting modes that use the same pipeline + (inherit from Collection). + """ + x_0 = np.arange(25, dtype='float32').reshape(5, 5) % 5 + x_1 = np.arange(25, dtype='float32').reshape(5, 5).T % 5 + x_2 = np.arange(25, dtype='float32').reshape(5, 5) % 6 + + fig, axes = plt.subplots(3, 5) + + axes[0, 0].pcolormesh(x_1) + axes[0, 1].pcolormesh((x_0, x_1), cmap='BiPeak') + axes[0, 2].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA') + axes[0, 3].pcolormesh((x_0, x_1), cmap='BiPeak') # repeated for visual consistency + axes[0, 4].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA') # repeated + + vmin = 1 + vmax = 3 + axes[1, 0].pcolormesh(x_1, vmin=vmin, vmax=vmax) + axes[1, 1].pcolormesh((x_0, x_1), cmap='BiPeak', vmin=[vmin]*2, vmax=[vmax]*2) + axes[1, 2].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA', + vmin=[vmin]*3, vmax=[vmax]*3) + axes[1, 3].pcolormesh((x_0, x_1), cmap='BiPeak', + vmin=(None, vmin), vmax=(None, vmax)) + axes[1, 4].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA', + vmin=(None, vmin, None), vmax=(None, vmax, None)) + + norm = mcolors.LogNorm(vmin=1, vmax=5) + axes[2, 0].pcolormesh(x_1, norm=norm) + axes[2, 1].pcolormesh((x_0, x_1), cmap='BiPeak', norm=(norm, norm)) + axes[2, 2].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA', norm=(norm, norm, norm)) + axes[2, 3].pcolormesh((x_0, x_1), cmap='BiPeak', norm=('linear', norm)) + axes[2, 4].pcolormesh((x_0, x_1, x_2), cmap='3VarAddA', + norm=('linear', norm, 'linear')) + + remove_ticks_and_titles(fig) + + +@image_comparison(["multivariate_imshow_norm.png"], style='mpl20') +def test_multivariate_imshow_norm(): + """ + Test vmin, vmax and norm + Norm is checked via a LogNorm. + A LogNorm converts the input to a masked array, masking for X <= 0 + By using a LogNorm, this functionality is also tested. + """ + x_0 = np.arange(25, dtype='float32').reshape(5, 5) % 5 + x_1 = np.arange(25, dtype='float32').reshape(5, 5).T % 5 + x_2 = np.arange(25, dtype='float32').reshape(5, 5) % 6 + + fig, axes = plt.subplots(3, 5) + + # interpolation='nearest' to reduce size of baseline image and + # removes ambiguity when using masked array (from LogNorm) + axes[0, 0].imshow(x_1, interpolation='nearest') + axes[0, 1].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak') + axes[0, 2].imshow((x_0, x_1, x_2), interpolation='nearest', cmap='3VarAddA') + axes[0, 3].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak') + axes[0, 4].imshow((x_0, x_1, x_2), interpolation='nearest', cmap='3VarAddA') + + vmin = 1 + vmax = 3 + axes[1, 0].imshow(x_1, interpolation='nearest', vmin=vmin, vmax=vmax) + axes[1, 1].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak', + vmin=[vmin]*2, vmax=[vmax]*2) + axes[1, 2].imshow((x_0, x_1, x_2), interpolation='nearest', cmap='3VarAddA', + vmin=[vmin]*3, vmax=[vmax]*3) + axes[1, 3].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak', + vmin=(None, vmin), vmax=(None, vmax)) + axes[1, 4].imshow((x_0, x_1, x_2), interpolation='nearest', cmap='3VarAddA', + vmin=(None, vmin, None), vmax=(None, vmax, None)) + + n = mcolors.LogNorm(vmin=1, vmax=5) + axes[2, 0].imshow(x_1, interpolation='nearest', norm=n) + axes[2, 1].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak', norm=(n, n)) + axes[2, 2].imshow((x_0, x_1, x_2), interpolation='nearest', cmap='3VarAddA', + norm=(n, n, n)) + axes[2, 3].imshow((x_0, x_1), interpolation='nearest', cmap='BiPeak', + norm=('linear', n)) + axes[2, 4].imshow((x_0, x_1, x_2), interpolation='nearest', cmap='3VarAddA', + norm=('linear', n, 'linear')) + + remove_ticks_and_titles(fig) + + +@image_comparison(["bivariate_cmap_shapes.png"], style='mpl20') +def test_bivariate_cmap_shapes(): + x_0 = np.arange(100, dtype='float32').reshape(10, 10) % 10 + x_1 = np.arange(100, dtype='float32').reshape(10, 10).T % 10 + + fig, axes = plt.subplots(1, 4, figsize=(10, 2)) + + # shape = square + axes[0].imshow((x_0, x_1), cmap='BiPeak', vmin=(1, 1), vmax=(8, 8), + interpolation='nearest') + # shape = cone + axes[1].imshow((x_0, x_1), cmap='BiCone', vmin=(0.5, 0.5), vmax=(8.5, 8.5), + interpolation='nearest') + + # shape = ignore + cmap = mpl.bivar_colormaps['BiPeak'] + cmap = cmap.with_extremes(shape='ignore') + axes[2].imshow((x_0, x_1), cmap=cmap, vmin=(1, 1), vmax=(8, 8), + interpolation='nearest') + + # shape = circleignore + cmap = mpl.bivar_colormaps['BiCone'] + cmap = cmap.with_extremes(shape='circleignore') + axes[3].imshow((x_0, x_1), cmap=cmap, vmin=(0.5, 0.5), vmax=(8.5, 8.5), + interpolation='nearest') + remove_ticks_and_titles(fig) diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py index 5bc1f8aea973..a17b2bd26df5 100644 --- a/lib/matplotlib/tests/test_colors.py +++ b/lib/matplotlib/tests/test_colors.py @@ -1785,16 +1785,30 @@ def test_is_color_like(input, expected): assert is_color_like(input) is expected -def test_colorizer_vmin_vmax(): +def test_colorizer_vmin_vmax_clip(): ca = mcolorizer.Colorizer() - assert ca.vmin is None - assert ca.vmax is None + assert len(ca.vmin) == 1 + assert len(ca.vmax) == 1 + assert ca.vmin[0] is None + assert ca.vmax[0] is None ca.vmin = 1 ca.vmax = 3 - assert ca.vmin == 1.0 - assert ca.vmax == 3.0 + assert ca.vmin == (1.0, ) + assert ca.vmax == (3.0, ) assert ca.norm.vmin == 1.0 assert ca.norm.vmax == 3.0 + assert ca.clip == (False, ) + + ca = mcolorizer.Colorizer('BiOrangeBlue') + assert len(ca.vmin) == 2 + assert len(ca.vmax) == 2 + ca.vmin = (1, 2) + ca.vmax = (3, 4) + assert ca.vmin == (1.0, 2.0) + assert ca.vmax == (3.0, 4.0) + assert ca.norm.vmin == (1.0, 2.0) + assert ca.norm.vmax == (3.0, 4.0) + assert ca.clip == (False, False) def test_LinearSegmentedColormap_from_list_color_alpha_tuple(): diff --git a/lib/matplotlib/tests/test_image.py b/lib/matplotlib/tests/test_image.py index e193e0d9fd3e..20a7ca7b042c 100644 --- a/lib/matplotlib/tests/test_image.py +++ b/lib/matplotlib/tests/test_image.py @@ -2055,3 +2055,12 @@ def test_affine_fill_to_edges(): axs[i, j].vlines([-0.5, N - 0.5], N - 3, N, lw=0.5, color='red') axs[i, j].hlines([-0.5, N - 0.5], -1, 2, lw=0.5, color='red') axs[i, j].hlines([-0.5, N - 0.5], N - 3, N, lw=0.5, color='red') + + +def test_invalid_interpolation_stage_multinorm(): + fig, ax = plt.subplots() + data = np.arange(24).reshape((2, 3, 4)) + + with pytest.raises(ValueError, + match="'data' is the only valid interpolation_stage"): + ax.imshow(data, cmap='2VarAddA', interpolation_stage='rgba')