Skip to content

Conversation

@trygvrad
Copy link
Contributor

@trygvrad trygvrad commented Sep 24, 2025

Exposes the functionality of MultiNorm, BivarColormap and MultivarColormap to the top level plotting functions ax.imshow(), ax.pcolor() and ax.pcolormesh(). This coloses #30526, see Bivariate and Multivariate Colormapping
As a side-effect of the pcolor/pcolormesh implementation, Collection also gets the new functionality.

In short, this PR allows you to plot multivariate data more easily, but it does not:

  • Create equivalents to fig.colorbar() for BivarColormap and MultivarColormap to work with ColorizingArtist
  • Select bivariate and multivariate colormaps to include in matplotlib
  • Examples demonstrating the new functionality

These will come in later PRs. See Bivariate and Multivariate Colormapping

Examples demonstrating new functionality:

import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
cmap = mpl.bivar_colormaps['BiPeak']
x_0 = np.arange(25, dtype='float32').reshape(5, 5) % 5
x_1 = np.arange(25, dtype='float32').reshape(5, 5).T % 5
x_0, x_1 = x_0 + 0.3*x_1, x_0*-0.3 + x_1, 

fig, axes = plt.subplots(1, 3, figsize=(6, 2))
axes[0].imshow(x_0, cmap=cmap[0])
axes[1].imshow(x_1, cmap=cmap[1])
axes[2].imshow((x_0, x_1), cmap=cmap)
axes[0].set_title('data 0')
axes[1].set_title('data 1')
axes[2].set_title('data 0 and 1')
image
fig, axes = plt.subplots(1, 6, figsize=(10, 2.3))
axes[0].imshow((x_0, x_1), cmap='BiPeak', interpolation='nearest')
axes[1].matshow((x_0, x_1), cmap='BiPeak')
axes[2].pcolor((x_0, x_1), cmap='BiPeak')
axes[3].pcolormesh((x_0, x_1), cmap='BiPeak')

x = np.arange(5)
y = np.arange(5)
X, Y = np.meshgrid(x, y)
axes[4].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[5].add_collection(p)
axes[0].set_title('imshow')
axes[1].set_title('matshow')
axes[2].set_title('pcolor')
axes[3].set_title('pcolormesh (C)')
axes[4].set_title('pcolormesh (X, Y, C)')
axes[5].set_title('PatchCollection')
fig.tight_layout()
image

@trygvrad
Copy link
Contributor Author

trygvrad commented Nov 2, 2025

I fixed the circleci doc error for this.
It would be great if someone could take a look :)
@QuLogic @story645 @ksunden @timhoffm

Comment on lines 697 to 712
if len(x.dtype.descr) == 1:
# Arrays with dtype 'object' get returned here.
# For example the 'c' kwarg of scatter, which supports multiple types.
# `plt.scatter([3, 4], [2, 5], c=[(1, 0, 0), 'y'])`
return x
else:
# In case of a dtype with multiple fields
# for example image data using a MultiNorm
try:
mask = np.empty(x.shape, dtype=np.dtype('bool, '*len(x.dtype.descr)))
for dd, dm in zip(x.dtype.descr, mask.dtype.descr):
mask[dm[0]] = ~np.isfinite(x[dd[0]])
xm = np.ma.array(x, mask=mask, copy=False)
except TypeError:
return x
return xm
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this get recycled in other places and if so should it be factored into it;s own private function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is used anywhere else

Comment on lines 6161 to 6162
Only 'data' is available when using `~matplotlib.colors.BivarColormap`
or `~matplotlib.colors.MultivarColormap`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you mean by data?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'data' refers to the keyword argument interpolation_stage

        interpolation_stage : {'auto', 'data', 'rgba'}, default: 'auto'
            Supported values:

            - 'data': Interpolation is carried out on the data provided by the user
              This is useful if interpolating between pixels during upsampling.
            - 'rgba': The interpolation is carried out in RGBA-space after the
              color-mapping has been applied. This is useful if downsampling and
              combining pixels visually.
            - 'auto': Select a suitable interpolation stage automatically. This uses
              'rgba' when downsampling, or upsampling at a rate less than 3, and
              'data' when upsampling at a higher rate.

            See :doc:`/gallery/images_contours_and_fields/image_antialiasing` for
            a discussion of image antialiasing.

            Only 'data' is available when using `~matplotlib.colors.BivarColormap`
            or `~matplotlib.colors.MultivarColormap`

I'm changing the last lines to:

            When using a `~matplotlib.colors.BivarColormap` or 
            `~matplotlib.colors.MultivarColormap`, 'data' is the only valid
            interpolation_stage.

in an attempt to increase clarity

Comment on lines 6425 to 6429
C : 2D or 3D 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 v channels, where v is the size along the first axis.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think (?, ?, ?) notation might help make this clearer?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea. Does the following work for you?

        Parameters
        ----------
        C : 2D (I, J) or 3D (v, I, J) array-like
            The color-mapped values.  Color-mapping is controlled by *cmap*,
            *norm*, *vmin*, and *vmax*. 3D arrays are supported only if the
            cmap supports v channels.

        X, Y : array-like, optional
            The coordinates of the corners of quadrilaterals of a pcolormesh::

                (X[i+1, j], Y[i+1, j])       (X[i+1, j+1], Y[i+1, j+1])
                                      ●╶───╴●
                                      │     │
                                      ●╶───╴●
                    (X[i, j], Y[i, j])       (X[i, j+1], Y[i, j+1])

            Note that the column index corresponds to the x-coordinate, and
            the row index corresponds to y. For details, see the
            :ref:`Notes <axes-pcolormesh-grid-orientation>` section below.

            If ``shading='flat'`` the dimensions of *X* and *Y* should be one
            greater than those of *C*, and the quadrilateral is colored due
            to the value at ``C[i, j]``.  If *X*, *Y* and *C* have equal
            dimensions, a warning will be raised and the last row and column
            of *C* will be ignored.

I'm using uppercase I and J in C : 2D (I, J) or 3D (v, I, J) array-like as these refer to the length, while the lowercase versions are used further down for the corresponding indexes.

Comment on lines 6555 to 6560
if colorizer is None:
cmap = mcolorizer._ensure_cmap(cmap, accept_multivariate=True)
C = mcolorizer._ensure_multivariate_data(args[-1], cmap.n_variates)
else:
C = mcolorizer._ensure_multivariate_data(args[-1],
colorizer.cmap.n_variates)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if colorizer is None:
cmap = mcolorizer._ensure_cmap(cmap, accept_multivariate=True)
C = mcolorizer._ensure_multivariate_data(args[-1], cmap.n_variates)
else:
C = mcolorizer._ensure_multivariate_data(args[-1],
colorizer.cmap.n_variates)
if colorizer is None:
cmap = mcolorizer._ensure_cmap(cmap, accept_multivariate=True)
colorizer =
C = mcolorizer._ensure_multivariate_data(args[-1],
colorizer.cmap.n_variates)

is there a reason to not instantiate the colorizer here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it was designed this way to ensure error handling was done in the same way as previously, but I was able to rework it to a cleaner state by doing

mcolorizer.ColorizingArtist._check_exclusionary_keywords(colorizer, 
                                                         vmin=vmin, vmax=vmax, 
                                                         norm=norm, cmap=cmap)

before the creation of the QuadMesh rather than

collection._check_exclusionary_keywords(colorizer, vmin=vmin, vmax=vmax)

after.

Comment on lines 6803 to 6899
if colorizer is None:
cmap = mcolorizer._ensure_cmap(cmap, accept_multivariate=True)
C = mcolorizer._ensure_multivariate_data(args[-1], cmap.n_variates)
else:
C = mcolorizer._ensure_multivariate_data(args[-1],
colorizer.cmap.n_variates)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

potentially turn into a helper function if it repeats?

Copy link
Contributor Author

@trygvrad trygvrad Nov 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following lines are repeated for pcolor and pcolormesh:

        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)

We could make this a helper function :)
If so, should it be part of the Axes class? and do you have a suggestion for a name?

EDIT: this is after modifying it in accordance with one of your comments above :)

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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are the other interpolations tested?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope!,
I'm changing one of tests so that it is :)

Comment on lines 273 to 275
raise ValueError("'data' is the only valid interpolation_stage "
"when using multiple color channels, not "
f"{interpolation_stage}")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flip this around to start with the error then reason then solution

Comment on lines 477 to 479
A_resampled = [_resample(self, a.astype(_get_scaled_dtype(a)),
out_shape, t)
for a in arrs]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
A_resampled = [_resample(self, a.astype(_get_scaled_dtype(a)),
out_shape, t)
for a in arrs]
A_resampled = [_resample(self,
a.astype(_get_scaled_dtype(a)), out_shape, t)
for a in arrs]

just that the line break here is weird

Comment on lines 490 to 491
mask = (np.where(self._getmaskarray(A), np.float32(np.nan),
np.float32(1))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
mask = (np.where(self._getmaskarray(A), np.float32(np.nan),
np.float32(1))
mask = (np.where(self._getmaskarray(A), np.float32(np.nan), np.float32(1))

return fig


def _get_scaled_dtype(A):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make it an inline function inside _make_image

Copy link
Member

@ksunden ksunden left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General thoughts on return types:

Doing things like float | tuple[float, ...] as is done for several things here (vmin/vmax, clip, etc) is potentially problematic.

Humans may easily work with that, but type checkers will likely yell that they didn't check for all possible outcomes

None is a bit of a special case in being more acceptable (easier to check, etc)

Consider moving these in new code to always return a tuple (even if single element) This keeps the branching needed to a minimum and is not too cumbersome to work for in the single variable case.

Obviously, existing APIs need to maintain back-compat, so this is limited to new code.

Consider whether conceptually an empty tuple is what is truly meant by the None case, but if it is not, retain None

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: ...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tuple[str, ...]

@trygvrad
Copy link
Contributor Author

trygvrad commented Dec 1, 2025

General thoughts on return types:
Doing things like float | tuple[float, ...] as is done for several things here (vmin/vmax, clip, etc) is potentially problematic.
Humans may easily work with that, but type checkers will likely yell that they didn't check for all possible outcomes

We discussed change the behaviour of colorizer to always return tuples on the call last week.

The relevant moving parts here are:

  1. Norm (Normalize, MultiNorm): members: vmin, vmax, clip
  2. Colorizer: members: get/set_clim, get/set_clip, vmin, vmax, clip
  3. _ColorizingInterface: members: get/set_clim, get/set_clip

The Norm ABC must be typed as follows for backwards compatibility:
def vmin(self) -> float | tuple[float | None, ...] | None: ...


For the Colorizer, I think it makes sense to force tuples on the getter, but allow both on the setter:

    def get_clim(self) -> tuple[tuple[float | None, ...], tuple[float | None, ...]]: ...
    def set_clim(self, vmin: float | tuple[float, ...] | None = ..., vmax: float | tuple[float, ...] | None = ...) -> None: ...

For the _ColorizingInterface we have two options.
A: allow both
def set_clim(self, vmin: float | tuple[float, float] | tuple[float | None, ...] | None, vmax: float | tuple[float | None, ...] | None = ...) -> None: ...
B: Allow get/set_clim only when using scalar data, and otherwise encourage the user to use the colorizer interface:
def set_clim(self, vmin: float | tuple[float, float] | None = ..., vmax: float | None = ...) -> None: ...

    def get_clim(self):
        """
        Return the values (min, max) that are mapped to the colormap limits.

        This function is not available for multivariate data.
        """
        if self._colorizer.norm.n_components > 1:
            raise AttributeError("`.get_clim()` is unavailable when using a colormap "
                                 "with multiple components. Use "
                                 "`.colorizer.get_clim()` instead.")
        return self.colorizer.norm.vmin, self.colorizer.norm.vmax

One reason why I favor option B, is that set_clim is already sufficiently complicated, because for scalar data it allows both signatures:
.set_clim(vmin=vmin, vmax=vmax)
.set_clim((vmin, vmax))
and the 2nd option is ambiguous if there are two colors

@ksunden @story645 Could you let me know what you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Development

Successfully merging this pull request may close these issues.

4 participants