Skip to content

Commit b18d16c

Browse files
committed
ENH have ax.get_tightbbox have a bbox around all artists
1 parent ff059c0 commit b18d16c

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
@@ -1593,10 +1593,7 @@ def draw(self, renderer):
15931593
try:
15941594
renderer.open_group('figure')
15951595
if self.get_constrained_layout() and self.axes:
1596-
if True:
1597-
self.execute_constrained_layout(renderer)
1598-
else:
1599-
pass
1596+
self.execute_constrained_layout(renderer)
16001597
if self.get_tight_layout() and self.axes:
16011598
try:
16021599
self.tight_layout(renderer,
@@ -2181,26 +2178,52 @@ def waitforbuttonpress(self, timeout=-1):
21812178

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

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

22002213
bb = []
2214+
if bbox_extra_artists is None:
2215+
artists = self.get_default_bbox_extra_artists()
2216+
else:
2217+
artists = bbox_extra_artists
2218+
2219+
for a in artists:
2220+
bbox = a.get_tightbbox(renderer)
2221+
if bbox is not None and (bbox.width != 0 or bbox.height != 0):
2222+
bb.append(bbox)
2223+
22012224
for ax in self.axes:
22022225
if ax.get_visible():
2203-
bb.append(ax.get_tightbbox(renderer))
2226+
bb.append(ax.get_tightbbox(renderer, bbox_extra_artists))
22042227

22052228
if len(bb) == 0:
22062229
return self.bbox_inches
@@ -2252,6 +2275,10 @@ def tight_layout(self, renderer=None, pad=1.08, h_pad=None, w_pad=None,
22522275
"""
22532276
Automatically adjust subplot parameters to give specified padding.
22542277
2278+
To exclude an artist on the axes from the bounding box calculation
2279+
that determines the subplot parameters (i.e. legend, or annotation),
2280+
then set `a.set_in_layout(False)` for that artist.
2281+
22552282
Parameters
22562283
----------
22572284
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)
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