Skip to content

Multivar colorbar#31214

Open
trygvrad wants to merge 10 commits intomatplotlib:mainfrom
trygvrad:multivar_colorbar
Open

Multivar colorbar#31214
trygvrad wants to merge 10 commits intomatplotlib:mainfrom
trygvrad:multivar_colorbar

Conversation

@trygvrad
Copy link
Copy Markdown
Contributor

This PR relates to Bivariate and Multivariate Colormapping, specifically #30527 Bivariate and multivariate colorbar.

This draft includes only BivarColorbar (not MultivarColorbar) but I want to make the PR now so that we can start discussing some of the questions.


import matplotlib.pyplot as plt
import numpy as np

fig, axes = plt.subplots(2, 2, figsize = (10, 6))
axes = axes.ravel()
locs = ['left', 'right', 'top', 'bottom']
im = np.random.random((2, 3, 4))
for i, loc in enumerate(locs):
    cim = axes[i].imshow(im, cmap='BiOrangeBlue')
    fig.bivar_colorbar(cim, location=loc, use_gridspec=False, fraction=0.3)
image

Note that the first axis is the y-axis and the second is the x-axis when the bivariate colormap is shown, following the implementation in #28454

@story645, @timhoffm, @ksunden
The main questions I have now are:

  1. I am thinking this should have a unique call signature, i.e. fig.bivar_colorbar(mappable) rather than branching inside fig.colorbar()
  2. The name bivar_colorbar is slightly awkward because it is not a bar, but more like a stamp. What do you think about this name?
  3. The Colorbar class has functions for customizing the ticks. To me the logic here is that a 1D colorbar does not have an x and y axis in the traditional sense, but a single axis, so it makes sense to have another layer of abstraction. BivarColorbar on the other hand has an x-axis and y-axis in the normal sense, and in light of this, how do we want users to customize the ticks?
    a. Not have functions on BivarColorbar for customizing ticks, and instead expect users to use BivarColorbar.ax.set_xticks etc.
    b. Make it possible to call the ax functions via the BivarColorbar, i.e. BivarColorbar.set_xticks
    c. Make functions that reflect the fact that BivarColorbar has a first and second axis matching the axes in the input to the mappable, i.e BivarColorbar.set_ticks(axis_0, axis_1)

I would really appreciate some feedback on 3. before I start making tests and typing :)

The placement of the bivariate colorbar uses nearly identical code to the normal colorbar. I assume this is what we want. [we will need more complicated placement for MultivarColorbar]

If you want to look at the code, I advise you to look at the commit 3cf2df9, rather than the PR, as this is built on top of #30597 – Multivar imshow.

@jklymak
Copy link
Copy Markdown
Member

jklymak commented Feb 28, 2026

I think this should take an ax and a cax argument, just like normal colorbar. For layout we are going to want the option for the colorbar to steal space from a number of axes, not just the axes that holds the mappable (though it's probably fine to default to that)

This should also play well with constrained layout. Tight layout won't work with colorbars so that is not a concern. If you need help with that, please ping. However conceptually it should be the same as a normal colorbar, it'll just possibly be much larger.

@jklymak jklymak added the topic: geometry manager LayoutEngine, Constrained layout, Tight layout label Feb 28, 2026
@story645
Copy link
Copy Markdown
Member

story645 commented Mar 1, 2026

  1. Yes, agree on unique signature
  2. Especially since this is gonna be colorsquare/colorcircle/colorpane -> Esri just calls them legends far as I can tell (also this R library) but I think that'd maybe create too much collision here.

3. We might also/instead want the legend placement, especially for the geographic usecase. Which yes third parties can implement/might make more sense for cartopy to do.

@timhoffm
Copy link
Copy Markdown
Member

timhoffm commented Mar 2, 2026

  1. Yes

  2. bivar_colorbar seems ok. While it's a bit of a bulky "bar", I think the verbal connection to the 1D colorbar is still very valuable. All other names I can think of (color_square, color_patch, color_legend) all feel more off that stretching the meaning of "bar".

  3. (c) is out. AFAIK we don't have other cases where we lump dimensions together in such a way. It also makes it hard to set one axis only (would require some (None, yticks) acrobatics).
    (a) is appealing, because the API we'd have to define is really minimal. On the downside, it feels a bit too low-level to expose the full axes (yes Colorbar gives you access as well, but that's rather for advanced use cases). OTOH people know how to configure Axes, so much knowledge can ve transferred.
    (b) is appealing in that it has a strong analogy to Colorbar, but I'm afraid, we're putting in a lot of boilerplate for little gain.
    A fourth option (d) would be to expose properties colorbar.xaxis, colorbar.yaxis, which is still a quite minimal API, but gives access to all the axis configuration options, e.g. colorbar.xaxis.set_ticks(). The downside is that the Axis-level functions are less known than their high-level wrappers.

    Overall, I'm inclined to go with (d) - it's only a little more overhead than (a) but still quite minimal and feels cleaner. We could later still add some (b) functions if we see the need.

@story645
Copy link
Copy Markdown
Member

story645 commented Mar 2, 2026

Also apparently I didn't read 3 correctly 🤦‍♀️ but I agree w/ @timhoffm's proposal d)

@trygvrad trygvrad force-pushed the multivar_colorbar branch from 3cf2df9 to 701c4b2 Compare March 4, 2026 22:22
@github-actions github-actions bot removed the topic: geometry manager LayoutEngine, Constrained layout, Tight layout label Mar 4, 2026
@trygvrad
Copy link
Copy Markdown
Contributor Author

trygvrad commented Mar 5, 2026

This is progressing well, thank you for all the feedback so far.

In the draft, I am using

        self._image = self.ax.imshow(
            self.colorizer.cmap.lut,
            origin='lower',
            extent=(0, 1, 0, 1),
            transform=self.ax.transAxes,
            interpolation='nearest',
            alpha=self.alpha,
        )

to display the bivariate colorbar.

The transform=self.ax.transAxes is important because it allows us to use a different axis scale (i.e. log) without changing the image.
However, it has the side-effect that if one changes the limits on the axis, the axis changes while the norm stays the same.

This can for example be illustrated by:

norm = mpl.colors.MultiNorm(['log', 'log'])
ca = mpl.colorizer.Colorizer('BiOrangeBlue', norm)
fig, ax = plt.subplots()
cbar = fig.bivar_colorbar(ca, cax = ax, ticklocations=['left', 'bottom'])

norm.norms[1].vmin = 0.1
norm.norms[1].vmax = 10
cbar.xaxis.set_ticks([1, 10, 100])
print(ca.norm.vmin, ca.norm.vmax, cbar.ax.get_xlim() ) 
# (None, 0.1), (None, 10.0), (0.1, 100.0)

Where the xlim is changed but vmax on the norm is not.

My thinking is that any action taken by the user which would change the limits on the axis by operations on the axis, and not by setting the vmin or vmax, should raise an error of the form:

    raise ValueError("Changing the limits of this axis must be done "
            "using the norm attached to the bivariate colorbar.")

I can do this by connecting to "xlim_changed" and "ylim_changed".

Does this seem like a reasonable solution to you?


When ax.xaxis.set_ticks([1, 10, 100]) is called, it does not emit "xlim_changed". To me this seems like a bug. Should I make an issue?

@jklymak
Copy link
Copy Markdown
Member

jklymak commented Mar 6, 2026

Wouldn't it make sense to use pcolormesh instead of image so users can change the scale if they want to?

@trygvrad
Copy link
Copy Markdown
Contributor Author

trygvrad commented Mar 7, 2026

Wouldn't it make sense to use pcolormesh instead of image so users can change the scale if they want to?

Thank you for the feedback @jklymak, but I think we can work with imshow in this case.

The user should be able to change the scale on the norm, which will change the axis, but the bivariate colormap should render the same.

Consider these two figures:
image
image

Where the only change is that a LogNorm is used to when visualizing the GDP per capita in the lower plot.
This changes the way the data is mapped to color [the figure on the left], as well as the scale on the x-axis in the figure to the right, but the rendering of the colormap [in figure space] stays the same.

I do not think there is a need to set a scale different from that of the norm.

(additionally: I would like to avoid pcolormesh if possible, because if saved as an SVG, each tile in the mesh gets stored as a polygon, which makes it almost impossible to edit the svg later. I personally find the pipeline matplotlib→inkscape to be extremely powerful, and would like that to be possible here.)

The plots are based on https://trygvrad.github.io//mpl_docs/Bivariate%20colormaps.html

@jklymak
Copy link
Copy Markdown
Member

jklymak commented Mar 7, 2026

People do ask to set the scale differently from the norm and they will be able to, but will get incorrect scaling of the colors. Pcolormesh can be rasterized, so you can avoid the polygon problem in svg/pdf. Note normal colorbar uses pcolormesh.

@trygvrad
Copy link
Copy Markdown
Contributor Author

trygvrad commented Mar 7, 2026

@jklymak thank you for the specification, I will see if I can make pcolormesh behave :)

@trygvrad trygvrad force-pushed the multivar_colorbar branch from f19b42c to 22e122d Compare March 7, 2026 22:29
@trygvrad
Copy link
Copy Markdown
Contributor Author

@jklymak
I was able to implement pcolormesh in a suitable way, and I agree that this is a much better solution.

I am looking at what we need for layouts for multivar colorbar.
Fundamentally, I think we want something like this:

import matplotlib.pyplot as plt
import numpy as np

im_A = np.arange(200)[np.newaxis, :]*np.ones((200, 200))
im_B = np.arange(200)[:, np.newaxis]*np.ones((200, 200))
im_C = 0.9*im_A + 0.9*im_B

im_A = np.sin(im_A**0.5)**2
im_B = np.sin(im_B**0.5)**2
im_C = np.sin(im_C**0.5)**2

fig, axes = plt.subplots(2, 2, figsize = (10, 6))
axes = axes.ravel()
locs = ['left', 'right', 'top', 'bottom']

im = (im_A, im_B, im_C)
for i, loc in enumerate(locs):
    cim = axes[i].imshow(im, cmap='3VarAddA')
    cb = fig.multivar_colorbar(cim, location=loc, use_gridspec=False, fraction=0.3)
image

Where fig.multivar_colorbar returns a new class MultivarColorbar which is iterable, and the user can get [or iterate through] constituent Colorbar objects. All callbacks between the Artist [and Colorizer] and the colorbars is handled through the MultivarColorbar object.

In terms of layout, I want the user to have the option to organize the colorbars in a different way:

    cb = fig.multivar_colorbar(..., n_major=2)
image

or

    cb = fig.multivar_colorbar(..., n_major=1)
image

For the prototype I am basing this on bbox.splitx() and bbox.splity() [gridspec=False].

In the existing code there is make_axes and make_axes_gridspec. With this PR I think we will now need 6 versions:

make_axes
make_bivar_axes
make_multivar_axes
make_axes_gridspec
make_bivar_axes_gridspec
make_multivar_axes_gridspec [not yet implemented]

Because there is a lot of overlap, I have created a number of private helper functions, which (hopefully) avoids duplicate code.

@jklymak does this make sense to you in terms of the layout?
I would be happy to for any suggestions you have that can make this more elegant/suitable :)
My apologies that this PR is slightly difficult to read as it build upon #30597 rather than main.

I have not yet started looking at make_multivar_axes_gridspec. I suspect we can do something smart with the gridspec here. This is very much not my area of expertise, so some support here would be appreciated, but I think it would be best if we make sure we align on what is the intended behaviour first :)

@jklymak
Copy link
Copy Markdown
Member

jklymak commented Mar 12, 2026

I'll need to look at what you are proposing. Ideally I think your colorbars would be encapsulated inside something constrained layout thinks is a "colorbar" that is should put into the colorbar slot in the layout manager. After that the "colorbar" can look as you like.

If you want the colorbars to be placed inside of the allotted area by the layout manager I think that will be more difficult

Overall I would not suggest making a gridspec version of the colorbars. I suppose those allow a non optimal but still legible layout.

I can try and look closer this weekend. If you had some example scripts for me to look at that'd make things easier. Apologies if they are already in this PR

@jklymak
Copy link
Copy Markdown
Member

jklymak commented Mar 12, 2026

bivar_colorbar seems to work fine with constrained layout.

mulitvar_colorbar doesn't work with

    return do_constrained_layout(fig, w_pad=w_pad, h_pad=h_pad,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/jklymak/matplotlib/lib/matplotlib/_constrained_layout.py", line 116, in do_constrained_layout
    make_layout_margins(layoutgrids, fig, renderer, h_pad=h_pad,
  File "/Users/jklymak/matplotlib/lib/matplotlib/_constrained_layout.py", line 402, in make_layout_margins
    pad = colorbar_get_pad(layoutgrids, cbax)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/jklymak/matplotlib/lib/matplotlib/_constrained_layout.py", line 822, in colorbar_get_pad
    parents = cax._colorbar_info['parents']
              ^^^^^^^^^^^^^^^^^^
AttributeError: 'MultivarColorbar' object has no attribute '_colorbar_info'. Did you mean: '_set_colorbar_info'?

which looks fixable?

@story645
Copy link
Copy Markdown
Member

This might be the silliest thing but can we maybe flip the names to colorbar_{bi, multi}? I think would be especially easier for auto complete.

@trygvrad
Copy link
Copy Markdown
Contributor Author

trygvrad commented Mar 14, 2026

I'll need to look at what you are proposing.

:D

Ideally I think your colorbars would be encapsulated inside something constrained layout thinks is a "colorbar" that is should put into the colorbar slot in the layout manager. After that the "colorbar" can look as you like.

@jklymak This is a good idea, and I will try to make it happen, but I am not super familiar with this part of the code. What kind of object do you think that something should be, because I don't think it should be an axis in an of itself, but it should probably show up in Figure.get_children() [?]

    def get_children(self):
        """Get a list of artists contained in the figure."""
        return [self.patch,
                *self.artists,
                *self._localaxes,
                *self.lines,
                *self.patches,
                *self.texts,
                *self.images,
                *self.legends,
                *self.subfigs]

bivar_colorbar seems to work fine with constrained layout. mulitvar_colorbar doesn't work with

Thanks for checking this, I will fix this and add some tests that cover these cases.


This might be the silliest thing but can we maybe flip the names to colorbar_{bi, multi}? I think would be especially easier for auto complete.

@story645 I think there is significant merit to this argument :)

@jklymak
Copy link
Copy Markdown
Member

jklymak commented Mar 14, 2026

It's possible it will just work if you give each of the colorbars a _colorbar_info parameter properly filled out. But I'm a bit skeptical.

If not, if you make a container that has _colorbar_info and a get_tightbbox then I think constrained layout will work fine to place the colorbars.

@trygvrad
Copy link
Copy Markdown
Contributor Author

If not, if you make a container that has _colorbar_info and a get_tightbbox then I think constrained layout will work fine to place the colorbars.

I think you are right, but it has taken me a bit of time to come to that conclusion, as I have had to read up on how the constrained layout works.
I think we will also need a version of reposition_colorbar() that that support the container class [reposition_colorbars()?].

get_tightbbox has a keyword argument for_layout_only, which I think is relevant here.
We technically only need the version with for_layout_only=True.
image
[figure not using constrained layout]

My thinking is that get_tightbbox(for_layout_only=True) should function as follows:

  1. Find the maximum width & height among the constituent colorbars
  2. Multiply the maximum width & height by the size of the grid of colorbars (2×2 in the figure above), include padding between the colorbars
  3. Return a bbox that can fit the calculated space

My understanding is that the constrained layout will then make sure the space returned by get_tightbbox will then be reserved in the margin of the axes.

reposition_colorbars() should then use the same logic as get_tightbbox to place the colorbars at the correct locations.

Hopefully, I can make the code behave within reasonable time :)

@story645
Copy link
Copy Markdown
Member

I'm following maybe 20% of the layout discussion, but looking at those figures, we should probably allow a way for folks to specify nrows/ncols in the independent multivariate case.

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