Skip to content

Commit 632305e

Browse files
authored
Merge pull request #10682 from jklymak/enh-ax-get-tightbbox
ENH have ax.get_tightbbox have a bbox around all artists attached to axes.
2 parents c4aebac + b18d16c commit 632305e

File tree

9 files changed

+259
-54
lines changed

9 files changed

+259
-54
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
@@ -701,6 +729,17 @@ def get_animated(self):
701729
"Return the artist's animated state"
702730
return self._animated
703731

732+
def get_in_layout(self):
733+
"""
734+
Return boolean flag, ``True`` if artist is included in layout
735+
calculations.
736+
737+
E.g. :doc:`/tutorials/intermediate/constrainedlayout_guide`,
738+
`.Figure.tight_layout()`, and
739+
``fig.savefig(fname, bbox_inches='tight')``.
740+
"""
741+
return self._in_layout
742+
704743
def get_clip_on(self):
705744
'Return whether artist uses clipping'
706745
return self._clipon
@@ -830,6 +869,19 @@ def set_animated(self, b):
830869
self._animated = b
831870
self.pchanged()
832871

872+
def set_in_layout(self, in_layout):
873+
"""
874+
Set if artist is to be included in layout calculations,
875+
E.g. :doc:`/tutorials/intermediate/constrainedlayout_guide`,
876+
`.Figure.tight_layout()`, and
877+
``fig.savefig(fname, bbox_inches='tight')``.
878+
879+
Parameters
880+
----------
881+
in_layout : bool
882+
"""
883+
self._in_layout = in_layout
884+
833885
def update(self, props):
834886
"""
835887
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
@@ -4110,19 +4110,47 @@ def pick(self, *args):
41104110
martist.Artist.pick(self, args[0])
41114111

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

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

41284156
bb = []
@@ -4155,11 +4183,14 @@ def get_tightbbox(self, renderer, call_axes_locator=True):
41554183
if bb_yaxis:
41564184
bb.append(bb_yaxis)
41574185

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

41644195
_bbox = mtransforms.Bbox.union(
41654196
[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
@@ -2048,36 +2048,9 @@ def print_figure(self, filename, dpi=None, facecolor=None, edgecolor=None,
20482048
dryrun=True,
20492049
**kwargs)
20502050
renderer = self.figure._cachedRenderer
2051-
bbox_inches = self.figure.get_tightbbox(renderer)
2052-
20532051
bbox_artists = kwargs.pop("bbox_extra_artists", None)
2054-
if bbox_artists is None:
2055-
bbox_artists = \
2056-
self.figure.get_default_bbox_extra_artists()
2057-
2058-
bbox_filtered = []
2059-
for a in bbox_artists:
2060-
bbox = a.get_window_extent(renderer)
2061-
if a.get_clip_on():
2062-
clip_box = a.get_clip_box()
2063-
if clip_box is not None:
2064-
bbox = Bbox.intersection(bbox, clip_box)
2065-
clip_path = a.get_clip_path()
2066-
if clip_path is not None and bbox is not None:
2067-
clip_path = \
2068-
clip_path.get_fully_transformed_path()
2069-
bbox = Bbox.intersection(
2070-
bbox, clip_path.get_extents())
2071-
if bbox is not None and (
2072-
bbox.width != 0 or bbox.height != 0):
2073-
bbox_filtered.append(bbox)
2074-
2075-
if bbox_filtered:
2076-
_bbox = Bbox.union(bbox_filtered)
2077-
trans = Affine2D().scale(1.0 / self.figure.dpi)
2078-
bbox_extra = TransformedBbox(_bbox, trans)
2079-
bbox_inches = Bbox.union([bbox_inches, bbox_extra])
2080-
2052+
bbox_inches = self.figure.get_tightbbox(renderer,
2053+
bbox_extra_artists=bbox_artists)
20812054
pad = kwargs.pop("pad_inches", None)
20822055
if pad is None:
20832056
pad = rcParams['savefig.pad_inches']

lib/matplotlib/figure.py

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1596,10 +1596,7 @@ def draw(self, renderer):
15961596
try:
15971597
renderer.open_group('figure')
15981598
if self.get_constrained_layout() and self.axes:
1599-
if True:
1600-
self.execute_constrained_layout(renderer)
1601-
else:
1602-
pass
1599+
self.execute_constrained_layout(renderer)
16031600
if self.get_tight_layout() and self.axes:
16041601
try:
16051602
self.tight_layout(renderer,
@@ -2184,26 +2181,52 @@ def waitforbuttonpress(self, timeout=-1):
21842181

21852182
def get_default_bbox_extra_artists(self):
21862183
bbox_artists = [artist for artist in self.get_children()
2187-
if artist.get_visible()]
2184+
if (artist.get_visible() and artist.get_in_layout())]
21882185
for ax in self.axes:
21892186
if ax.get_visible():
21902187
bbox_artists.extend(ax.get_default_bbox_extra_artists())
21912188
# we don't want the figure's patch to influence the bbox calculation
21922189
bbox_artists.remove(self.patch)
21932190
return bbox_artists
21942191

2195-
def get_tightbbox(self, renderer):
2192+
def get_tightbbox(self, renderer, bbox_extra_artists=None):
21962193
"""
21972194
Return a (tight) bounding box of the figure in inches.
21982195
2199-
Currently, this takes only axes title, axis labels, and axis
2200-
ticklabels into account. Needs improvement.
2196+
Artists that have ``artist.set_in_layout(False)`` are not included
2197+
in the bbox.
2198+
2199+
Parameters
2200+
----------
2201+
renderer : `.RendererBase` instance
2202+
renderer that will be used to draw the figures (i.e.
2203+
``fig.canvas.get_renderer()``)
2204+
2205+
bbox_extra_artists : list of `.Artist` or ``None``
2206+
List of artists to include in the tight bounding box. If
2207+
``None`` (default), then all artist children of each axes are
2208+
included in the tight bounding box.
2209+
2210+
Returns
2211+
-------
2212+
bbox : `.BboxBase`
2213+
containing the bounding box (in figure inches).
22012214
"""
22022215

22032216
bb = []
2217+
if bbox_extra_artists is None:
2218+
artists = self.get_default_bbox_extra_artists()
2219+
else:
2220+
artists = bbox_extra_artists
2221+
2222+
for a in artists:
2223+
bbox = a.get_tightbbox(renderer)
2224+
if bbox is not None and (bbox.width != 0 or bbox.height != 0):
2225+
bb.append(bbox)
2226+
22042227
for ax in self.axes:
22052228
if ax.get_visible():
2206-
bb.append(ax.get_tightbbox(renderer))
2229+
bb.append(ax.get_tightbbox(renderer, bbox_extra_artists))
22072230

22082231
if len(bb) == 0:
22092232
return self.bbox_inches
@@ -2255,6 +2278,10 @@ def tight_layout(self, renderer=None, pad=1.08, h_pad=None, w_pad=None,
22552278
"""
22562279
Automatically adjust subplot parameters to give specified padding.
22572280
2281+
To exclude an artist on the axes from the bounding box calculation
2282+
that determines the subplot parameters (i.e. legend, or annotation),
2283+
then set `a.set_in_layout(False)` for that artist.
2284+
22582285
Parameters
22592286
----------
22602287
pad : float

lib/matplotlib/legend.py

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

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

lib/matplotlib/tests/test_figure.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,3 +391,28 @@ def test_fspath(fmt, tmpdir):
391391
# All the supported formats include the format name (case-insensitive)
392392
# in the first 100 bytes.
393393
assert fmt.encode("ascii") in file.read(100).lower()
394+
395+
396+
def test_tightbbox():
397+
fig, ax = plt.subplots()
398+
ax.set_xlim(0, 1)
399+
t = ax.text(1., 0.5, 'This dangles over end')
400+
renderer = fig.canvas.get_renderer()
401+
x1Nom0 = 9.035 # inches
402+
assert np.abs(t.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2
403+
assert np.abs(ax.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2
404+
assert np.abs(fig.get_tightbbox(renderer).x1 - x1Nom0) < 0.05
405+
assert np.abs(fig.get_tightbbox(renderer).x0 - 0.679) < 0.05
406+
# now exclude t from the tight bbox so now the bbox is quite a bit
407+
# smaller
408+
t.set_in_layout(False)
409+
x1Nom = 7.333
410+
assert np.abs(ax.get_tightbbox(renderer).x1 - x1Nom * fig.dpi) < 2
411+
assert np.abs(fig.get_tightbbox(renderer).x1 - x1Nom) < 0.05
412+
413+
t.set_in_layout(True)
414+
x1Nom = 7.333
415+
assert np.abs(ax.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2
416+
# test bbox_extra_artists method...
417+
assert np.abs(ax.get_tightbbox(renderer,
418+
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)