Thanks to visit codestin.com
Credit goes to github.com

Skip to content

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

Merged
merged 3 commits into from
Sep 7, 2024
Merged

Conversation

rcomer
Copy link
Member

@rcomer rcomer commented Jul 12, 2024

PR summary

The Artist.figure attribute is inconsistent: on most artists, it is the figure the artist is attached to, which may be a SubFigure. OTOH SubFigure.figure is always the root Figure.

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 a Figure.

At #28177 (comment), @timhoffm said

Internally, we should not be using figure anymore.

so here is my attempt to remove that internal usage. If I comment out the figure property in both figure.py and artist.py, the tests still pass locally (except the one that explicitly tests the property).

get_figure parameter choices

Wherever I use get_figure in the main code, I made a choice about the root parameter. In many places, the end result will be the same whether root is True or False:

  • artist.get_figure(root=False) will be None if and only if artist.get_figure(root=True) is None
  • In many cases we are just accessing an attribute on the figure, which for a subfigure is the same as its parent, and therefore the same as the root.
    self._axstack = parent._axstack
    self.subplotpars = parent.subplotpars
    self.dpi_scale_trans = parent.dpi_scale_trans
    self._axobservers = parent._axobservers
    self.transFigure = parent.transFigure
    self.bbox_relative = Bbox.null()
    self._redo_transform_rel_fig()
    self.figbbox = self._parent.figbbox
  • or accessing a property which returns the equivalent property of the parent
    @property
    def canvas(self):
    return self._parent.canvas
    @property
    def dpi(self):
    return self._parent.dpi
  • or calling _get_renderer which calls _get_renderer on the parent
    def _get_renderer(self):
    return self._parent._get_renderer()

For the above cases, I set root=False as that avoids the call to _parent_figure.get_figure within Artist.get_figure, so seems more efficient for most cases.

if root and self._parent_figure is not None:
return self._parent_figure.get_figure(root=True)
return self._parent_figure

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

Copy link
Member Author

@rcomer rcomer left a 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):
Copy link
Member Author

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.

Copy link
Member

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 🤣 .

Copy link
Member Author

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.

Comment on lines +395 to +396
if (extents.width < self.get_figure(root=True).bbox.width
and extents.height < self.get_figure(root=True).bbox.height):
Copy link
Member Author

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.

Copy link
Member

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.

Comment on lines 201 to +202
mpl._blocking_input.blocking_input_loop(
self.axes.figure, ["button_press_event", "key_press_event"],
self.axes.get_figure(root=True),
Copy link
Member Author

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.

Comment on lines 948 to +981
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)
Copy link
Member Author

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

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.

Copy link
Member

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

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.

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

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...

Copy link
Member

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

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.

@rcomer rcomer force-pushed the remove-figure-use branch from 1610045 to 7c70bea Compare July 12, 2024 14:41
@@ -4754,6 +4759,9 @@ def __init__(self, figure, artists):
self.figure = figure
self.artists = artists

def get_figure(self, **kwargs):
Copy link
Member

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.

Copy link
Member Author

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

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

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.

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.

Take or leave the minor comment about the private/internal method signature, I don't actually think it is likely to be problematic

@rcomer rcomer force-pushed the remove-figure-use branch 2 times, most recently from 7b496fa to 8fc4464 Compare July 15, 2024 17:37
@rcomer
Copy link
Member Author

rcomer commented Jul 15, 2024

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.

@tacaswell
Copy link
Member

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.

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

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.

Suggested change
self.set_sizes(self._sizes, self.get_figure(root=False).dpi)
self.set_sizes(self._sizes, self.get_figure(root=True).dpi)

@@ -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)
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
self.set_sizes(self._sizes, self.get_figure(root=False).dpi)
self.set_sizes(self._sizes, self.get_figure(root=True).dpi)

@@ -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)
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
fig = self.get_figure(root=False)
fig = self.get_figure(root=True)

for the same reason as the rest of the DPI related comments.

@@ -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
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
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

@@ -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)
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
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.

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))
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
(x, bbox.y1 + self.labelpad * self.get_figure(root=False).dpi / 72))
(x, bbox.y1 + self.labelpad * self.get_figure(root=True).dpi / 72))

@@ -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
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
y = bottom - self.OFFSETTEXTPAD * self.get_figure(root=False).dpi / 72
y = bottom - self.OFFSETTEXTPAD * self.get_figure(root=True).dpi / 72

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
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
y = top + self.OFFSETTEXTPAD * self.get_figure(root=False).dpi / 72
y = top + self.OFFSETTEXTPAD * self.get_figure(root=True).dpi / 72

@tacaswell
Copy link
Member

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 SubFigure (to essentially avoid doing this work!) we added properties that forwarded to the parent. So in any case where you are accessing canvas, dpi, or _get_renderer (and a couple of the transforms that we hold a shared reference to in both the sub figure and the parent but I left those alone) it is not wrong to get it from the subfigure but going all the way to the root makes it a bit clearer to the future reader that this is a "figure global" type thing and is marginally faster to call (by skipping at least one property lookup).

I'm 👍 on this merging with or without my wall of suggestions going in.

@tacaswell
Copy link
Member

@rcomer or anyone can merge at their discretion.

@rcomer
Copy link
Member Author

rcomer commented Jul 19, 2024

Thanks @tacaswell!

My thinking for favouring root=False was that it should be more efficient in the case when there are no subfigures involved. E.g.

$ 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 root=True more efficient when we're already at the root Figure...

I think I need to think about this again when I'm less tired.

@rcomer
Copy link
Member Author

rcomer commented Jul 20, 2024

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 😢

@rcomer rcomer force-pushed the remove-figure-use branch from 18d884a to 9c50f07 Compare July 20, 2024 11:17
@rcomer
Copy link
Member Author

rcomer commented Jul 20, 2024

I decided to take @tacaswell's suggestions as it just smells better to get those Figure-specific properties directly from the Figure. Latest push has some more I found locally. I was hoping to remove SubFigure._get_renderer() but it's still used in at least one place. I do not know how to reliably find all the places it's used because we obviously are not testing every possible combination and I can only get so far with grep.

@rcomer rcomer force-pushed the remove-figure-use branch from 9c50f07 to 0c911b3 Compare July 20, 2024 13:01
@rcomer
Copy link
Member Author

rcomer commented Jul 20, 2024

Rebased to pick up #28597.

@tacaswell
Copy link
Member

@rcomer I'm sorry, my review on this appears to have been done in the optimally bad way for the tool!

@rcomer
Copy link
Member Author

rcomer commented Jul 26, 2024

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 pyplot changes are marginally less trivial than the rest.

@ksunden ksunden merged commit 7f3738d into matplotlib:main Sep 7, 2024
40 checks passed
@rcomer rcomer deleted the remove-figure-use branch September 7, 2024 15:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants