-
-
Notifications
You must be signed in to change notification settings - Fork 7.9k
Enh better colorbar axes #20054
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Enh better colorbar axes #20054
Conversation
Can we turn this around: make a new parent axes and inject |
I don't know how we would make it so |
I see, but this is mostly |
My thinking is that the usual pattern is to not specify cb = fig.colorbar(im, ax=axs[0, 1]) # Note cb.ax is type ColorAxes that we control, cb.parent_axes is the usual outline axes
cb.ax.set_yticks()
cb.ax.set_position() # note this passes through to the parent axes, so work as before. So Currently we do not support most tick methods on colorbar axes: e.g.
returns a UserWarning: cbax.yaxis.set_major_locator(mticker.FixedLocator(np.arange(-2, 2, 0.1))) which will be a no-op. Of course the alternate works fine: cb.ax.yaxis.set_major_locator(mticker.FixedLocator(np.arange(-2, 2, 0.1))) Maybe we could make an Axis subclass that just warned when the y or xaxis is accessed and no-ops for everything. |
|
I don't see a clean way to cripple the parent axes ( While that is awkward, the benefit is that it allows a lot of things to work for
Personally I feel that the slight API annoyance (you must use |
4999aa7
to
6b61028
Compare
... about 30 minutes after the above, I came up with a reasonable way to do this in the latest commit: cb = fig.colorbar(im, cax=cax)
assert cb.ax == cax will return This is acceptable - |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Your last commit looks like a lot of extra code and keeping track of things to make it "functionally" the same without being exactly the same. I'm wondering if you can put something in the docstring to make it clear that you're overwriting the Axes passed in with a ColorbarAxes instead of adding a bunch of code to try and maintain half of the status quo.
Another option here is whether you need to create an entirely new ColorbarAxes or not? Could you just add an attribute to the Colorbar itself to store the inset axes instead (cb.inner_ax
)? I think that might simplify some areas to not have to access nested Axes attributes (cb.ax.inner_ax
). That might allow you to keep cb.ax
== cax
== cb.ax.parent_axes
(now removed). That is just a quick thought I had, so I might be missing something fundamental too.
@@ -458,6 +458,7 @@ def _get_pos_and_bbox(ax, renderer): | |||
bbox = pos | |||
else: | |||
bbox = tightbbox.transformed(fig.transFigure.inverted()) | |||
print('bbox', bbox) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove
@@ -0,0 +1,26 @@ | |||
Axes used to make colorbar now wrapped |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Change this API change note to the new PR 20054
lib/matplotlib/colorbar.py
Outdated
else: | ||
parent_ax = parent | ||
|
||
inner_ax = parent_ax.inset_axes([0, 0, 1, 1]) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You use inner
and parent
throughout, I think it would flow better as one of the pairs (inner
, outer
) or (child
, parent
) instead. I would vote in favor of inner/outer as that seems to signify this inset axes relationship a little better, but don't have a real strong preference at all.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure, did inner/outer....
self.parent_ax.set_yticks = self.inner_ax.set_yticks | ||
for attr in ["get_position", "set_position", "set_aspect"]: | ||
setattr(self, attr, getattr(self.parent_ax, attr)) | ||
if userax: |
There was a problem hiding this comment.
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 if statement is necessary? The other branch of this would just put this dict on a newly created axes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is necessary if we want the user's parent axes to be as much like the new axes as possible (short of being the same object). This is what locks out all the methods on the parent axes (not outer!) and points them at the inner axes. That way if the user does parent.set_xscale('log')
it is the same as cb.ax.set_xscale('log')
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still think you could do that to outer_ax
regardless of whether a userax
was passed in or not? In the other case it is just a new axis you've created so I don't think it hurts anything to assign the dict in that case.
I also think you missed the argument parent
change here too.
I guess I'd argue the new code is pretty straight forward though - basically, if the user passed the axes we need to do some extra bookkeeping, otherwise don't bother. I suppose its possible that the user could manually pass us a gridspec colorbar, but at that point they are tying then selves in knots.
The whole point of all this is to make |
Where you have: inner_ax = outer_ax.inset_axes([0, 0, 1, 1])
self.__dict__ = inner_ax.__dict__
self.outer_ax = outer_ax
self.inner_ax = inner_ax
self.outer_ax.xaxis.set_visible(False)
self.outer_ax.yaxis.set_visible(False)
self.outer_ax.set_facecolor('none')
self.outer_ax.tick_params = self.inner_ax.tick_params
self.outer_ax.set_xticks = self.inner_ax.set_xticks
self.outer_ax.set_yticks = self.inner_ax.set_yticks
for attr in ["get_position", "set_position", "set_aspect"]:
setattr(self, attr, getattr(self.outer_ax, attr)) I was wondering if you could create a new method instead that gets called on initialization to create your def _add_insest_axes(self):
# self.ax is outer_ax
inner_ax = self.ax.inset_axes([0, 0, 1, 1])
self.__dict__ = inner_ax.__dict__
self.inner_ax = inner_ax
self.ax.xaxis.set_visible(False)
self.ax.yaxis.set_visible(False)
self.ax.set_facecolor('none')
self.ax.tick_params = self.inner_ax.tick_params
self.ax.set_xticks = self.inner_ax.set_xticks
self.ax.set_yticks = self.inner_ax.set_yticks
for attr in ["get_position", "set_position", "set_aspect"]:
setattr(self, attr, getattr(self.ax, attr)) |
I guess stylistically I think that delineating it as a different type of axes would be helpful, and it may sprout its own methods. Technically, your suggestions above won't work as written because we will have lost the outer-axes altogether, and we need to keep it and its methods, just make them basically private. i.e. we need something to continue to hold those methods. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I do think the new Axes you created that is used for mapping between two other axes is a nice way to do it 👍.
I'm wondering if you want to default userax
to True instead of False... Let the make_axes
routines steal the space and set userax
to False there. I think that would keep it consistent if someone is calling colorbar.Colorbar(ax)
directly themselves rather than figure.colorbar()
.
lib/matplotlib/figure.py
Outdated
else: | ||
userax = True |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can get rid of some of the branches below if you populate the keywords here.
else: | |
userax = True | |
else: | |
kw['userax'] = True | |
current_ax = cax |
lib/matplotlib/figure.py
Outdated
cb = cbar.Colorbar(cax, mappable, **cb_kw) | ||
|
||
self.sca(current_ax) | ||
if not userax: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you take the above suggestion, then this can become:
if current_ax is not cax
or if not kw.get('userax', False)
Now ``cb.ax`` returns the parent ``ColorbarAxes``, and to get | ||
``cax`` back, you can do:: | ||
|
||
cb.ax.parent_axes |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
outer/inner in all of this documentation as well.
As discussed, this needs tests of the new monkeying around with the parent.... |
Having spent a bit of time looking at the side idea (discussed during the call) of using an axis wrapper to fix the locator clipping issue (and solely that -- I'm not claiming it fixes anything else), it turns out one of the issues with that is that we don't use locator return values "as is": there's an additional clip-to-viewlims step which is performed by Axis._update_ticks (that's why Axis.get_majorticklocs can sometimes (for normal axes) return an additional tick out of bound on either side of the viewlims, which is another issue which has been discussed elsewhere...), so that would also need to know about the axis wrapper faked lims, and various tests based on I may revisit this later, but probably will stash the idea for now. (See also polar._AxisWrapper, from where I stole the design.) |
cef9099
to
cf066f3
Compare
I'm a little torn about this. Users really not really supposed to do this, and if they do, they should expect the axes to get subsumed. It was a problem because |
cf066f3
to
95e58d7
Compare
I somewhat agree with you on the default choice being questionable, but since you've gone through the whole process of trying to make the original Would it be worth trying to deprecate the
(FYI I can't resolve any of my previous comments, so feel free to resolve them yourself) |
We discussed this on the dev call this week. Deprecating cax was definitely possible, and maybe what we will want to do. However in the meantime we would still need to do something with it, and having it do the wrong thing would be confusing. So something like the current setup is still necessary. Or poisoning the original cax. The subtlety here is that cax can have a _locatable_axes method on it, and that should still work. Actually I should probably add a test that this still works. |
Interestingly this example doesn't properly take into account the colorbar in the constrained layout. This will take a bit of investigation: import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots(constrained_layout=True)
pc = ax.imshow(np.arange(100).reshape(10, 10))
cax = ax.inset_axes([1.02, 0.1, 0.03, 0.8])
# cax.set_in_layout(True)
fig.colorbar(pc, cax=cax)
plt.show() |
800b32c
to
d144f43
Compare
lib/matplotlib/colorbar.py
Outdated
hatches = mappable.hatches | ||
else: | ||
hatches = [None] | ||
if self._extend_lower: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should be a function call.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh hmmm, interesting I guess those just always ran, but worked fine because elower
was set to 0...
lib/matplotlib/colorbar.py
Outdated
antialiased=False, transform=self.ax.outer_ax.transAxes, | ||
hatch=hatches[0]) | ||
self.ax.outer_ax.add_patch(patch) | ||
if self._extend_upper: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Also a function call.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some minor comments inline.
For the extra ticks in the get_ticklocs()
tests now, I'm curious if that is because the locator thinks it can place ticks for the entire outer_axes viewLim, so it is placing some in the extends region for now. So, if you got rid of the extends="both"
would the locator place the same as the old version? That seems like there may be some inner/outer dispatching mismatch that could be updated in the new ColorbarAxes class. Perhaps using the inner_ax._update_ticks()
call from the inner_ax explicitly in the update_ticks() call on colorbar?
self._get_ticker_locator_formatter() | ||
self._long_axis().set_major_locator(self.locator) | ||
self._long_axis().set_minor_locator(self.minorlocator) | ||
self._long_axis().set_major_formatter(self.formatter) | ||
|
||
def _get_ticker_locator_formatter(self): | ||
""" | ||
Return the ``locator`` and ``formatter`` of the colorbar. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They aren't returned though, they are just set on self?
What about removing this function and adding the logic to update_ticks instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🤷♂️ Maybe as a follow on. I'm not sure why this was made public in the first place, and I figure this will be invasive enough that I didn't want to try and deprecate things I didn't fully understand.
config_axis = _api.deprecate_privatize_attribute("3.3") | ||
def update_ticks(self): | ||
""" | ||
Setup the ticks and ticklabels. This should not be needed by users. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you want to start privatizing this function then?
expected = [r'$\mathdefault{10^{4}}$', | ||
r'$\mathdefault{2\times10^{4}}$', | ||
r'$\mathdefault{3\times10^{4}}$', | ||
r'$\mathdefault{4\times10^{4}}$'] | ||
for l, exp in zip(lb, expected): | ||
assert l.get_text() == exp | ||
for exp in expected: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like you should expand expected
rather than using an in
check because that doesn't say anything about where those ticks show up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This answers your question above as well: Before we used to manually clip the ticks on the colorbar axes in the private _ColorbarLocator
and _ColorbarLogLocator
. Before those, we manually added all the ticks and there were no minor log ticks. This PR is a further improvement in that we get rid of these locators, and just use whatever normal Locator the user wants. This will allow any norm with a scale to be accurate represented on the colorbar.
As a consequence, cb.ax.yaxis.get_ticklabels
behaves exactly the same as for any yaxis
and all these extra ticks are exactly what happens with normal axises. Thats a separate battle, and should/could probably be fixed, but it should be fixed for all axis, not here. Again the point is to remove as much special casing for colorbar axes as possible.
A long way of saying that the full list of ticks is very long because it includes many ticks out of sight, and I don't think its necessary for a more precise test here...
537946e
to
f739c48
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, all of my other suggestions can be addressed in follow-up PRs. This looks good to me. Do you want to squash the commits down to a few descriptive ones?
cd36868
to
146856b
Compare
Thanks a lot @greglucas I've squashed the whole thing - I probably should get better at curating commits, but I usually mash them all together as I fix things. |
Good work on this, @jklymak! |
@jklymak this has caused some issues downstream in astropy (astropy/astropy#11800) with custom sub-classes of |
I think we need a color-bar-con-fab in the next month or so (presumably by taking over one of our standing meetings). |
I mean this is why we have pre-releases... But, I will discuss on your astropy issue, because it looks like there is some confusion on astropy's end. But happy to open a new issue here. |
PR Summary
Redo of #18900 because GitHub was mad at me. PR is exactly the same...
closes #19543 and many other annoyances
PR Summary
This is a relatively major re-arrangement of how we create colorbars. This will simplify making colorbars for other norms if the norms are created with a scale. It also allows more normal manipulation of the colorbar axes. There are some negative consequences as well.
Issue
Currently most colorbars are drawn as a pcolormesh on a hidden axes. When
extend='both'/'upper'/'lower'
it makes the axes longer by a proportionextendlen
and draws the extend regions by distorting the upper and/or lower cell of the pcolormesh. This is great, except it means that all the usual tick locators need to have special cases that do not continue drawing the ticks into the region of the axis that has the extend triangles. We do that now with a few piecemeal wrapper classes:_ColorbarAutoLocator(ticker.MaxNLocator)
,_ColorbarAutoMinorLocator(ticker.AutoMinorLocator)
, etc. Needless to say that is clunky.The scale used for the colorbar also has to nicely invert past
vmin
andvmax
for the triangles to be drawn properly, despite the fact these regions are only meant for over/under colors that you wouldn't necessarily expect an arbitrary norm to be able to handle.Proposal
The proposed solution here is to draw the main pcolor ("solid") on a full axes that goes from vmin to vmax, and draw the extend triangles ("extends") as patches appended to the end of that axes. They are drawn in axes space, so the scale of the axes and the limits of the axes no longer matter.
The problem with this approach is that all the sizing and placement of axes is with an axes that is the size of the solid and the extends. i.e. if shrink=1 the whole axes, including the extends, is the height (or width) of the parent axes.
The solution proposed here is to draw the solid on an
inset_axes
that is shrunk from the parent axes if there are extends.Pros:
Cons:
manual axes placement is not the main colorbar axes object any more:
The biggest con is that for
the axes object we create is new so that
cb.ax
is no longercax
. In factcax == cb.ax.parent_axes
. So this breaks cases where the user hoped to do things tocax
after it was created. Of course they can accesscb.ax
. This failed one test where we didcax.tick_params
. We can pass these through as we find them (I did already fortick_params
). However, this also means thatcax.xaxis
points to an axis we make invisible now, and won't do anything (againcb.ax.xaxis
points to the right thing.)I will argue this breakage is worth it. Doing random things to
cax
failed much of the time anyway, whereas the new object stored oncb.ax
should be much more robust. Unfortunately, however, I cannot think of a reasonable way to deprecate this.Slight change in visibility of lines with extends
This is an obscure one, but lines are now partly cut off if they are drawn on the edge of the colorbar and there is an extend. The test that broke this didn't appear to have a reasonable reason to have an extend in that case anyhow (see
contour_colorbar.png
, andcontour_manual_colors_and_levels.png
)Other changes:
double_cbar.png
and a few other tests.double_cbar.png
)_internal.classic_mode
for the colorbars. It doesn't change that many tests, and it needlessly complicates the Locator code to keep it around.Test:
Before
After
PR Checklist
pytest
passes).flake8
on changed files to check).flake8-docstrings
and runflake8 --docstring-convention=all
).doc/users/next_whats_new/
(follow instructions in README.rst there).doc/api/next_api_changes/
(follow instructions in README.rst there).