Skip to content

Commit 25c45b2

Browse files
committed
ENH have ax.get_tightbbox have a bbox around all artists
1 parent 932fd81 commit 25c45b2

File tree

9 files changed

+260
-55
lines changed

9 files changed

+260
-55
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
`.matplotlib.Axes.get_tightbbox` now includes all artists
2+
---------------------------------------------------------
3+
4+
Layout tools like `.Figure.tight_layout`, ``constrained_layout``,
5+
and ``fig.savefig('fname.png', bbox_inches="tight")`` use
6+
`.matplotlib.Axes.get_tightbbox` to determine the bounds of each axes on
7+
a figure and adjust spacing between axes.
8+
9+
In Matplotlib 2.2 ``get_tightbbox`` started to include legends made on the
10+
axes, but still excluded some other artists, like text that may overspill an
11+
axes. For Matplotlib 3.0, *all* artists are now included in the bounding box.
12+
13+
This new default may be overridden in either of two ways:
14+
15+
1. Make the artist to be excluded a child of the figure, not the axes. E.g.,
16+
call ``fig.legend()`` instead of ``ax.legend()`` (perhaps using
17+
`~.matplotlib.Axes.get_legend_handles_labels` to gather handles and labels
18+
from the parent axes).
19+
2. If the artist is a child of the axes, set the artist property
20+
``artist.set_in_layout(False)``.

lib/matplotlib/artist.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def __init__(self):
114114
self._sketch = rcParams['path.sketch']
115115
self._path_effects = rcParams['path.effects']
116116
self._sticky_edges = _XYPair([], [])
117+
self._in_layout = True
117118

118119
def __getstate__(self):
119120
d = self.__dict__.copy()
@@ -251,6 +252,33 @@ def get_window_extent(self, renderer):
251252
"""
252253
return Bbox([[0, 0], [0, 0]])
253254

255+
def get_tightbbox(self, renderer):
256+
"""
257+
Like `Artist.get_window_extent`, but includes any clipping.
258+
259+
Parameters
260+
----------
261+
renderer : `.RendererBase` instance
262+
renderer that will be used to draw the figures (i.e.
263+
``fig.canvas.get_renderer()``)
264+
265+
Returns
266+
-------
267+
bbox : `.BboxBase`
268+
containing the bounding box (in figure pixel co-ordinates).
269+
"""
270+
271+
bbox = self.get_window_extent(renderer)
272+
if self.get_clip_on():
273+
clip_box = self.get_clip_box()
274+
if clip_box is not None:
275+
bbox = Bbox.intersection(bbox, clip_box)
276+
clip_path = self.get_clip_path()
277+
if clip_path is not None and bbox is not None:
278+
clip_path = clip_path.get_fully_transformed_path()
279+
bbox = Bbox.intersection(bbox, clip_path.get_extents())
280+
return bbox
281+
254282
def add_callback(self, func):
255283
"""
256284
Adds a callback function that will be called whenever one of
@@ -710,6 +738,17 @@ def get_animated(self):
710738
"Return the artist's animated state"
711739
return self._animated
712740

741+
def get_in_layout(self):
742+
"""
743+
Return boolean flag, ``True`` if artist is included in layout
744+
calculations.
745+
746+
E.g. :doc:`/tutorials/intermediate/constrainedlayout_guide`,
747+
`.Figure.tight_layout()`, and
748+
``fig.savefig(fname, bbox_inches='tight')``.
749+
"""
750+
return self._in_layout
751+
713752
def get_clip_on(self):
714753
'Return whether artist uses clipping'
715754
return self._clipon
@@ -845,6 +884,19 @@ def set_animated(self, b):
845884
self._animated = b
846885
self.pchanged()
847886

887+
def set_in_layout(self, in_layout):
888+
"""
889+
Set if artist is to be included in layout calculations,
890+
E.g. :doc:`/tutorials/intermediate/constrained_layout`,
891+
`.Figure.tight_layout()`, and
892+
``fig.savefig(fname, bbox_inches='tight')``.
893+
894+
Parameters
895+
----------
896+
in_layout : bool
897+
"""
898+
self._in_layout = in_layout
899+
848900
def update(self, props):
849901
"""
850902
Update this artist's properties from the dictionary *prop*.

lib/matplotlib/axes/_base.py

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4108,19 +4108,47 @@ def pick(self, *args):
41084108
martist.Artist.pick(self, args[0])
41094109

41104110
def get_default_bbox_extra_artists(self):
4111+
"""
4112+
Return a default list of artists that are used for the bounding box
4113+
calculation.
4114+
4115+
Artists are excluded either by not being visible or
4116+
``artist.set_in_layout(False)``.
4117+
"""
41114118
return [artist for artist in self.get_children()
4112-
if artist.get_visible()]
4119+
if (artist.get_visible() and artist.get_in_layout())]
41134120

4114-
def get_tightbbox(self, renderer, call_axes_locator=True):
4121+
def get_tightbbox(self, renderer, call_axes_locator=True,
4122+
bbox_extra_artists=None):
41154123
"""
4116-
Return the tight bounding box of the axes.
4117-
The dimension of the Bbox in canvas coordinate.
4124+
Return the tight bounding box of the axes, including axis and their
4125+
decorators (xlabel, title, etc).
4126+
4127+
Artists that have ``artist.set_in_layout(False)`` are not included
4128+
in the bbox.
4129+
4130+
Parameters
4131+
----------
4132+
renderer : `.RendererBase` instance
4133+
renderer that will be used to draw the figures (i.e.
4134+
``fig.canvas.get_renderer()``)
4135+
4136+
bbox_extra_artists : list of `.Artist` or ``None``
4137+
List of artists to include in the tight bounding box. If
4138+
``None`` (default), then all artist children of the axes are
4139+
included in the tight bounding box.
4140+
4141+
call_axes_locator : boolean (default ``True``)
4142+
If *call_axes_locator* is ``False``, it does not call the
4143+
``_axes_locator`` attribute, which is necessary to get the correct
4144+
bounding box. ``call_axes_locator=False`` can be used if the
4145+
caller is only interested in the relative size of the tightbbox
4146+
compared to the axes bbox.
41184147
4119-
If *call_axes_locator* is *False*, it does not call the
4120-
_axes_locator attribute, which is necessary to get the correct
4121-
bounding box. ``call_axes_locator==False`` can be used if the
4122-
caller is only intereted in the relative size of the tightbbox
4123-
compared to the axes bbox.
4148+
Returns
4149+
-------
4150+
bbox : `.BboxBase`
4151+
bounding box in figure pixel coordinates.
41244152
"""
41254153

41264154
bb = []
@@ -4153,11 +4181,14 @@ def get_tightbbox(self, renderer, call_axes_locator=True):
41534181
if bb_yaxis:
41544182
bb.append(bb_yaxis)
41554183

4156-
for child in self.get_children():
4157-
if isinstance(child, OffsetBox) and child.get_visible():
4158-
bb.append(child.get_window_extent(renderer))
4159-
elif isinstance(child, Legend) and child.get_visible():
4160-
bb.append(child._legend_box.get_window_extent(renderer))
4184+
bbox_artists = bbox_extra_artists
4185+
if bbox_artists is None:
4186+
bbox_artists = self.get_default_bbox_extra_artists()
4187+
4188+
for a in bbox_artists:
4189+
bbox = a.get_tightbbox(renderer)
4190+
if bbox is not None and (bbox.width != 0 or bbox.height != 0):
4191+
bb.append(bbox)
41614192

41624193
_bbox = mtransforms.Bbox.union(
41634194
[b for b in bb if b.width != 0 or b.height != 0])

lib/matplotlib/backend_bases.py

Lines changed: 2 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2060,36 +2060,9 @@ def print_figure(self, filename, dpi=None, facecolor=None, edgecolor=None,
20602060
dryrun=True,
20612061
**kwargs)
20622062
renderer = self.figure._cachedRenderer
2063-
bbox_inches = self.figure.get_tightbbox(renderer)
2064-
20652063
bbox_artists = kwargs.pop("bbox_extra_artists", None)
2066-
if bbox_artists is None:
2067-
bbox_artists = \
2068-
self.figure.get_default_bbox_extra_artists()
2069-
2070-
bbox_filtered = []
2071-
for a in bbox_artists:
2072-
bbox = a.get_window_extent(renderer)
2073-
if a.get_clip_on():
2074-
clip_box = a.get_clip_box()
2075-
if clip_box is not None:
2076-
bbox = Bbox.intersection(bbox, clip_box)
2077-
clip_path = a.get_clip_path()
2078-
if clip_path is not None and bbox is not None:
2079-
clip_path = \
2080-
clip_path.get_fully_transformed_path()
2081-
bbox = Bbox.intersection(
2082-
bbox, clip_path.get_extents())
2083-
if bbox is not None and (
2084-
bbox.width != 0 or bbox.height != 0):
2085-
bbox_filtered.append(bbox)
2086-
2087-
if bbox_filtered:
2088-
_bbox = Bbox.union(bbox_filtered)
2089-
trans = Affine2D().scale(1.0 / self.figure.dpi)
2090-
bbox_extra = TransformedBbox(_bbox, trans)
2091-
bbox_inches = Bbox.union([bbox_inches, bbox_extra])
2092-
2064+
bbox_inches = self.figure.get_tightbbox(renderer,
2065+
bbox_extra_artists=bbox_artists)
20932066
pad = kwargs.pop("pad_inches", None)
20942067
if pad is None:
20952068
pad = rcParams['savefig.pad_inches']

lib/matplotlib/figure.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,10 +1464,7 @@ def draw(self, renderer):
14641464
try:
14651465
renderer.open_group('figure')
14661466
if self.get_constrained_layout() and self.axes:
1467-
if True:
1468-
self.execute_constrained_layout(renderer)
1469-
else:
1470-
pass
1467+
self.execute_constrained_layout(renderer)
14711468
if self.get_tight_layout() and self.axes:
14721469
try:
14731470
self.tight_layout(renderer,
@@ -2008,26 +2005,52 @@ def waitforbuttonpress(self, timeout=-1):
20082005

20092006
def get_default_bbox_extra_artists(self):
20102007
bbox_artists = [artist for artist in self.get_children()
2011-
if artist.get_visible()]
2008+
if (artist.get_visible() and artist.get_in_layout())]
20122009
for ax in self.axes:
20132010
if ax.get_visible():
20142011
bbox_artists.extend(ax.get_default_bbox_extra_artists())
20152012
# we don't want the figure's patch to influence the bbox calculation
20162013
bbox_artists.remove(self.patch)
20172014
return bbox_artists
20182015

2019-
def get_tightbbox(self, renderer):
2016+
def get_tightbbox(self, renderer, bbox_extra_artists=None):
20202017
"""
20212018
Return a (tight) bounding box of the figure in inches.
20222019
2023-
Currently, this takes only axes title, axis labels, and axis
2024-
ticklabels into account. Needs improvement.
2020+
Artists that have ``artist.set_in_layout(False)`` are not included
2021+
in the bbox.
2022+
2023+
Parameters
2024+
----------
2025+
renderer : `.RendererBase` instance
2026+
renderer that will be used to draw the figures (i.e.
2027+
``fig.canvas.get_renderer()``)
2028+
2029+
bbox_extra_artists : list of `.Artist` or ``None``
2030+
List of artists to include in the tight bounding box. If
2031+
``None`` (default), then all artist children of each axes are
2032+
included in the tight bounding box.
2033+
2034+
Returns
2035+
-------
2036+
bbox : `.BboxBase`
2037+
containing the bounding box (in figure inches).
20252038
"""
20262039

20272040
bb = []
2041+
if bbox_extra_artists is None:
2042+
artists = self.get_default_bbox_extra_artists()
2043+
else:
2044+
artists = bbox_extra_artists
2045+
2046+
for a in artists:
2047+
bbox = a.get_tightbbox(renderer)
2048+
if bbox is not None and (bbox.width != 0 or bbox.height != 0):
2049+
bb.append(bbox)
2050+
20282051
for ax in self.axes:
20292052
if ax.get_visible():
2030-
bb.append(ax.get_tightbbox(renderer))
2053+
bb.append(ax.get_tightbbox(renderer, bbox_extra_artists))
20312054

20322055
if len(bb) == 0:
20332056
return self.bbox_inches
@@ -2079,6 +2102,10 @@ def tight_layout(self, renderer=None, pad=1.08, h_pad=None, w_pad=None,
20792102
"""
20802103
Automatically adjust subplot parameters to give specified padding.
20812104
2105+
To exclude an artist on the axes from the bounding box calculation
2106+
that determines the subplot parameters (i.e. legend, or annotation),
2107+
then set `a.set_in_layout(False)` for that artist.
2108+
20822109
Parameters
20832110
----------
20842111
pad : float

lib/matplotlib/legend.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,22 @@ def get_window_extent(self, *args, **kwargs):
980980
'Return extent of the legend.'
981981
return self._legend_box.get_window_extent(*args, **kwargs)
982982

983+
def get_tightbbox(self, renderer):
984+
"""
985+
Like `.Legend.get_window_extent`, but uses the box for the legend.
986+
987+
Parameters
988+
----------
989+
renderer : `.RendererBase` instance
990+
renderer that will be used to draw the figures (i.e.
991+
``fig.canvas.get_renderer()``)
992+
993+
Returns
994+
-------
995+
`.BboxBase` : containing the bounding box in figure pixel co-ordinates.
996+
"""
997+
return self._legend_box.get_window_extent(renderer)
998+
983999
def get_frame_on(self):
9841000
"""Get whether the legend box patch is drawn."""
9851001
return self._drawFrame

lib/matplotlib/tests/test_figure.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,3 +383,28 @@ def test_fspath(fmt, tmpdir):
383383
# All the supported formats include the format name (case-insensitive)
384384
# in the first 100 bytes.
385385
assert fmt.encode("ascii") in file.read(100).lower()
386+
387+
388+
def test_tightbbox():
389+
fig, ax = plt.subplots()
390+
ax.set_xlim(0, 1)
391+
t = ax.text(1., 0.5, 'This dangles over end')
392+
renderer = fig.canvas.get_renderer()
393+
x1Nom0 = 9.035 # inches
394+
assert np.abs(t.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2
395+
assert np.abs(ax.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2
396+
assert np.abs(fig.get_tightbbox(renderer).x1 - x1Nom0) < 0.05
397+
assert np.abs(fig.get_tightbbox(renderer).x0 - 0.679) < 0.05
398+
# now exclude t from the tight bbox so now the bbox is quite a bit
399+
# smaller
400+
t.set_in_layout(False)
401+
x1Nom = 7.333
402+
assert np.abs(ax.get_tightbbox(renderer).x1 - x1Nom * fig.dpi) < 2
403+
assert np.abs(fig.get_tightbbox(renderer).x1 - x1Nom) < 0.05
404+
405+
t.set_in_layout(True)
406+
x1Nom = 7.333
407+
assert np.abs(ax.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2
408+
# test bbox_extra_artists method...
409+
assert np.abs(ax.get_tightbbox(renderer,
410+
bbox_extra_artists=[]).x1 - x1Nom * fig.dpi) < 2

tutorials/intermediate/constrainedlayout_guide.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ def example_plot(ax, fontsize=12, nodec=False):
189189

190190
fig, ax = plt.subplots(constrained_layout=True)
191191
ax.plot(np.arange(10), label='This is a plot')
192-
ax.legend(loc='center left', bbox_to_anchor=(0.9, 0.5))
192+
ax.legend(loc='center left', bbox_to_anchor=(0.8, 0.5))
193193

194194
#############################################
195195
# However, this will steal space from a subplot layout:
@@ -198,7 +198,39 @@ def example_plot(ax, fontsize=12, nodec=False):
198198
for ax in axs.flatten()[:-1]:
199199
ax.plot(np.arange(10))
200200
axs[1, 1].plot(np.arange(10), label='This is a plot')
201-
axs[1, 1].legend(loc='center left', bbox_to_anchor=(0.9, 0.5))
201+
axs[1, 1].legend(loc='center left', bbox_to_anchor=(0.8, 0.5))
202+
203+
#############################################
204+
# In order for a legend or other artist to *not* steal space
205+
# from the subplot layout, we can ``leg.set_in_layout(False)``.
206+
# Of course this can mean the legend ends up
207+
# cropped, but can be useful if the plot is subsequently called
208+
# with ``fig.savefig('outname.png', bbox_inches='tight')``. Note,
209+
# however, that the legend's ``get_in_layout`` status will have to be
210+
# toggled again to make the saved file work:
211+
212+
fig, axs = plt.subplots(2, 2, constrained_layout=True)
213+
for ax in axs.flatten()[:-1]:
214+
ax.plot(np.arange(10))
215+
axs[1, 1].plot(np.arange(10), label='This is a plot')
216+
leg = axs[1, 1].legend(loc='center left', bbox_to_anchor=(0.8, 0.5))
217+
leg.set_in_layout(False)
218+
wanttoprint = False
219+
if wanttoprint:
220+
leg.set_in_layout(True)
221+
fig.do_constrained_layout(False)
222+
fig.savefig('outname.png', bbox_inches='tight')
223+
224+
#############################################
225+
# A better way to get around this awkwardness is to simply
226+
# use a legend for the figure:
227+
fig, axs = plt.subplots(2, 2, constrained_layout=True)
228+
for ax in axs.flatten()[:-1]:
229+
ax.plot(np.arange(10))
230+
lines = axs[1, 1].plot(np.arange(10), label='This is a plot')
231+
labels = [l.get_label() for l in lines]
232+
leg = fig.legend(lines, labels, loc='center left',
233+
bbox_to_anchor=(0.8, 0.5), bbox_transform=axs[1, 1].transAxes)
202234

203235
###############################################################################
204236
# Padding and Spacing

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy