-
-
Notifications
You must be signed in to change notification settings - Fork 7.9k
Remove internal use of Artist.figure
#28550
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
Conversation
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.
Added some comment about what I think are less obvious choices.
if (renderer._rasterizing and artist.figure and | ||
artist.figure.suppressComposite): | ||
if (renderer._rasterizing and (fig := artist.get_figure(root=True)) and | ||
fig.suppressComposite): |
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 have not really understood what it does, but suppressComposite
appears to only exist on Figure
.
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.
In vector outputs if you have multiple images or things you are pre-rasterizing you may want to rasterize them all into one image in the final output (which is good because it saves space if they overlap, the pathological example is 100 4kx4k images with alpha that are all exactly on top of each other, by pre-merging them you get a 100x size win!). On the other hand, if you have a bunch of images that only slightly overlap or you want to be able to re-extract the individual layers later you do not want to merge them. As both are reasonable we have an (esoteric) flag to control it 🤣 .
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.
Thank you for the explanation.
if (extents.width < self.get_figure(root=True).bbox.width | ||
and extents.height < self.get_figure(root=True).bbox.height): |
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 think this should be root figure because it is about what is possible for the renderer, but I am definitely out of my depth here.
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'm also not sure why we are looking at the total size not what it might be clipped to, but it has been like this from 2014 so 👍 on leaving it unless we understand why it should be changed to the subfigure.
mpl._blocking_input.blocking_input_loop( | ||
self.axes.figure, ["button_press_event", "key_press_event"], | ||
self.axes.get_figure(root=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.
blocking_input_loop
calls figure.show
and subfigures do not have show
- so this needs to be the root Figure
I think.
clip = ((self.get_clip_box() or self.axes.bbox) if self.get_clip_on() | ||
else self.figure.bbox) | ||
else self.get_figure(root=True).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.
I think this is saying clip the image to the figure if it's not clipped to anything else. I think that implies it should be the root Figure
: if the user wants to clip to a subfigure they can presumably pass the subfigure's bbox in set_clip_box
.
@@ -1385,14 +1385,15 @@ def get_extent(self): | |||
|
|||
def make_image(self, renderer, magnification=1.0, unsampled=False): | |||
# docstring inherited | |||
fac = renderer.dpi/self.figure.dpi | |||
fig = self.get_figure(root=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.
I'm not too sure about this one. The use of get_size_inches
below means that this needs to be Figure
, but it's not obvious to me that this code could ever involve a subfigure: I think it's only called within figimage
and there is no SubFigure.figimage
.
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.
as noted above, the subfigures .dpi
is a property that gets the root figures dpi so this is fine.
to match regions with a figure saved with a custom dpi value. | ||
""" | ||
if not self.get_visible(): | ||
return Bbox.unit() | ||
|
||
fig = self.get_figure(root=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.
I think this needs to be the root Figure
since below within the context manager we set its dpi and calculate display coordinate for the empty text string.
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 think anything with dpi we should be hitting the root figure.
@@ -410,7 +410,7 @@ def _init_locators(self): | |||
self._colorbar_pad = self._vert_pad_size.fixed_size | |||
self.cbar_axes = [ | |||
_cbaraxes_class_factory(self._defaultAxesClass)( | |||
self.axes_all[0].figure, self._divider.get_position(), | |||
self.axes_all[0].get_figure(root=False), self._divider.get_position(), |
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 am completely lost in here...
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.
Agree, but working at the sub-figure level seems correct....
@@ -667,8 +667,6 @@ def test_surface3d_label_offset_tick_position(): | |||
ax.set_ylabel("Y label") | |||
ax.set_zlabel("Z label") | |||
|
|||
ax.figure.canvas.draw() |
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.
Think this was redundant since it's an image comparison test, so the draw will happen anyway.
1610045
to
7c70bea
Compare
lib/matplotlib/axes/_base.py
Outdated
@@ -4754,6 +4759,9 @@ def __init__(self, figure, artists): | |||
self.figure = figure | |||
self.artists = artists | |||
|
|||
def get_figure(self, **kwargs): |
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 we want to do **kwargs
here?
The version elsewhere will also accept root
as a positional arg, so it is not fully compatible with that signature (of course, it isn't actually changing the behavior here)
That said, this is an internal class inside of of a private function, which is only called in one place, and I'm not even convinced calls get_figure
ever... so perhaps not worth being overly prescriptive here.
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.
Hmmm. If we remove this method then this test
lib/matplotlib/tests/test_axes.py::test_zorder_and_explicit_rasterization
Fails here
matplotlib/lib/matplotlib/artist.py
Lines 78 to 79 in 7c70bea
if (renderer._rasterizing and (fig := artist.get_figure(root=True)) and | |
fig.suppressComposite): |
Since that uses the root figure, I think we should also be getting the root figure here
matplotlib/lib/matplotlib/axes/_base.py
Lines 3149 to 3150 in 7c70bea
if artists_rasterized: | |
_draw_rasterized(self.get_figure(root=False), artists_rasterized, renderer) |
As for the **kwargs
, I don't really remember why I chose that, so happy to switch it to just the root keyword.
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.
Take or leave the minor comment about the private/internal method signature, I don't actually think it is likely to be problematic
7b496fa
to
8fc4464
Compare
Rebased to fix Circle, which was trying and failing to build with python3.9. It must be looking at the branch for the environment spec but the merged result for the mpl install requirement. |
Yes, circle builds on the PR branch not the proposed merge (like most other CI services) so we do the merge in our setup steps. Mostly works, but cases like this are broken exactly the way you describe. |
lib/matplotlib/collections.py
Outdated
@@ -1001,7 +1001,7 @@ def set_sizes(self, sizes, dpi=72.0): | |||
|
|||
@artist.allow_rasterization | |||
def draw(self, renderer): | |||
self.set_sizes(self._sizes, self.figure.dpi) | |||
self.set_sizes(self._sizes, self.get_figure(root=False).dpi) |
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.
Subfigures ask their parent for the dpi anyway so just go straight there and save a property call.
self.set_sizes(self._sizes, self.get_figure(root=False).dpi) | |
self.set_sizes(self._sizes, self.get_figure(root=True).dpi) |
lib/matplotlib/collections.py
Outdated
@@ -1310,7 +1310,7 @@ def get_rotation(self): | |||
|
|||
@artist.allow_rasterization | |||
def draw(self, renderer): | |||
self.set_sizes(self._sizes, self.figure.dpi) | |||
self.set_sizes(self._sizes, self.get_figure(root=False).dpi) |
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.
self.set_sizes(self._sizes, self.get_figure(root=False).dpi) | |
self.set_sizes(self._sizes, self.get_figure(root=True).dpi) |
lib/matplotlib/lines.py
Outdated
@@ -467,11 +467,12 @@ def contains(self, mouseevent): | |||
yt = xy[:, 1] | |||
|
|||
# Convert pick radius from points to pixels | |||
if self.figure is None: | |||
fig = self.get_figure(root=False) |
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.
fig = self.get_figure(root=False) | |
fig = self.get_figure(root=True) |
for the same reason as the rest of the DPI related comments.
lib/matplotlib/lines.py
Outdated
@@ -640,7 +641,7 @@ def get_window_extent(self, renderer=None): | |||
ignore=True) | |||
# correct for marker size, if any | |||
if self._marker: | |||
ms = (self._markersize / 72.0 * self.figure.dpi) * 0.5 | |||
ms = (self._markersize / 72.0 * self.get_figure(root=False).dpi) * 0.5 |
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.
ms = (self._markersize / 72.0 * self.get_figure(root=False).dpi) * 0.5 | |
ms = (self._markersize / 72.0 * self.get_figure(root=True).dpi) * 0.5 |
lib/matplotlib/lines.py
Outdated
@@ -1648,7 +1649,7 @@ def __init__(self, line): | |||
'pick_event', self.onpick) | |||
self.ind = set() | |||
|
|||
canvas = property(lambda self: self.axes.figure.canvas) | |||
canvas = property(lambda self: self.axes.get_figure(root=False).canvas) |
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.
canvas = property(lambda self: self.axes.get_figure(root=False).canvas) | |
canvas = property(lambda self: self.axes.get_figure(root=True).canvas) |
Similarly to dpi, canvas always falls back to the root.
lib/matplotlib/axis.py
Outdated
else: | ||
# Union with extents of the top spine if present, of the axes otherwise. | ||
bbox = mtransforms.Bbox.union([ | ||
*bboxes2, self.axes.spines.get("top", self.axes).get_window_extent()]) | ||
self.label.set_position((x, bbox.y1 + self.labelpad * self.figure.dpi / 72)) | ||
self.label.set_position( | ||
(x, bbox.y1 + self.labelpad * self.get_figure(root=False).dpi / 72)) |
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.
(x, bbox.y1 + self.labelpad * self.get_figure(root=False).dpi / 72)) | |
(x, bbox.y1 + self.labelpad * self.get_figure(root=True).dpi / 72)) |
lib/matplotlib/axis.py
Outdated
@@ -2429,14 +2432,14 @@ def _update_offset_text_position(self, bboxes, bboxes2): | |||
else: | |||
bbox = mtransforms.Bbox.union(bboxes) | |||
bottom = bbox.y0 | |||
y = bottom - self.OFFSETTEXTPAD * self.figure.dpi / 72 | |||
y = bottom - self.OFFSETTEXTPAD * self.get_figure(root=False).dpi / 72 |
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.
y = bottom - self.OFFSETTEXTPAD * self.get_figure(root=False).dpi / 72 | |
y = bottom - self.OFFSETTEXTPAD * self.get_figure(root=True).dpi / 72 |
lib/matplotlib/axis.py
Outdated
else: | ||
if not len(bboxes2): | ||
top = self.axes.bbox.ymax | ||
else: | ||
bbox = mtransforms.Bbox.union(bboxes2) | ||
top = bbox.y1 | ||
y = top + self.OFFSETTEXTPAD * self.figure.dpi / 72 | ||
y = top + self.OFFSETTEXTPAD * self.get_figure(root=False).dpi / 72 |
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.
y = top + self.OFFSETTEXTPAD * self.get_figure(root=False).dpi / 72 | |
y = top + self.OFFSETTEXTPAD * self.get_figure(root=True).dpi / 72 |
Sigh, sorry about the far too many comments. I thought it would not be too many to do and then fell into the sunk-cost fallacy of being "almost done" (I was not) until I got to the point where I realized it might be easier for Ruth to do find-and-replace herself than to click "accept" a zillion times an finally gave up... For dpi and canvas these things only make sense to be defined on the root figure, but to make the transition easier when we added I'm 👍 on this merging with or without my wall of suggestions going in. |
@rcomer or anyone can merge at their discretion. |
Thanks @tacaswell! My thinking for favouring $ ipython
Python 3.9.19 | packaged by conda-forge | (main, Mar 20 2024, 12:50:21)
Type 'copyright', 'credits' or 'license' for more information
IPython 8.18.1 -- An enhanced Interactive Python. Type '?' for help.
In [1]: import matplotlib.pyplot as plt
In [2]: l, = plt.plot([0, 1])
In [3]: %timeit l.get_figure(root=False)
68.8 ns ± 0.657 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
In [4]: %timeit l.get_figure(root=True)
156 ns ± 1.9 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each) But maybe these numbers are too small to worry about, or maybe there's a change that could make I think I need to think about this again when I'm less tired. |
Pro tip: if you use GitHub's "Add suggestion to batch" feature, do not navigate away from the page before you commit the batch the batch will be lost and you have to add all the suggestions again 😢 |
18d884a
to
9c50f07
Compare
I decided to take @tacaswell's suggestions as it just smells better to get those |
Co-authored-by: Thomas A Caswell <[email protected]>
Co-authored-by: Thomas A Caswell <[email protected]>
9c50f07
to
0c911b3
Compare
Rebased to pick up #28597. |
@rcomer I'm sorry, my review on this appears to have been done in the optimally bad way for the tool! |
Although I had permission to self-merge this one, I'm not sure I should because I made a bunch more changes after accepting Tom's suggestions. The |
PR summary
The
Artist.figure
attribute is inconsistent: on most artists, it is the figure the artist is attached to, which may be aSubFigure
. OTOHSubFigure.figure
is always the rootFigure
.A lot of the internal usage of this attribute was written before subfigures existed, so there would not have been any decision to make about which figure is required. This means that in places we could be getting hold of a
SubFigure
when we really need aFigure
.At #28177 (comment), @timhoffm said
so here is my attempt to remove that internal usage. If I comment out the
figure
property in bothfigure.py
andartist.py
, the tests still pass locally (except the one that explicitly tests the property).get_figure
parameter choicesWherever I use
get_figure
in the main code, I made a choice about theroot
parameter. In many places, the end result will be the same whetherroot
isTrue
orFalse
:artist.get_figure(root=False)
will beNone
if and only ifartist.get_figure(root=True)
isNone
matplotlib/lib/matplotlib/figure.py
Lines 2290 to 2297 in 830361d
matplotlib/lib/matplotlib/figure.py
Lines 2310 to 2316 in 830361d
_get_renderer
which calls_get_renderer
on the parentmatplotlib/lib/matplotlib/figure.py
Lines 2339 to 2340 in 830361d
For the above cases, I set
root=False
as that avoids the call to_parent_figure.get_figure
withinArtist.get_figure
, so seems more efficient for most cases.matplotlib/lib/matplotlib/artist.py
Lines 733 to 736 in 830361d
For tests that do not involve subfigures, I left the
root
parameter out, because there is no ambiguity about what we are getting hold of and it would seem like unnecessary clutter.PR checklist