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

Skip to content

ENH have ax.get_tightbbox have a bbox around all artists attached to axes. #10682

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 1 commit into from
Jul 9, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions doc/api/next_api_changes/2018-04-29-JMK.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
`.matplotlib.Axes.get_tightbbox` now includes all artists
---------------------------------------------------------

Layout tools like `.Figure.tight_layout`, ``constrained_layout``,
and ``fig.savefig('fname.png', bbox_inches="tight")`` use
`.matplotlib.Axes.get_tightbbox` to determine the bounds of each axes on
a figure and adjust spacing between axes.

In Matplotlib 2.2 ``get_tightbbox`` started to include legends made on the
axes, but still excluded some other artists, like text that may overspill an
axes. For Matplotlib 3.0, *all* artists are now included in the bounding box.

This new default may be overridden in either of two ways:

1. Make the artist to be excluded a child of the figure, not the axes. E.g.,
call ``fig.legend()`` instead of ``ax.legend()`` (perhaps using
`~.matplotlib.Axes.get_legend_handles_labels` to gather handles and labels
from the parent axes).
2. If the artist is a child of the axes, set the artist property
``artist.set_in_layout(False)``.
52 changes: 52 additions & 0 deletions lib/matplotlib/artist.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ def __init__(self):
self._sketch = rcParams['path.sketch']
self._path_effects = rcParams['path.effects']
self._sticky_edges = _XYPair([], [])
self._in_layout = True

def __getstate__(self):
d = self.__dict__.copy()
Expand Down Expand Up @@ -251,6 +252,33 @@ def get_window_extent(self, renderer):
"""
return Bbox([[0, 0], [0, 0]])

def get_tightbbox(self, renderer):
"""
Like `Artist.get_window_extent`, but includes any clipping.

Parameters
----------
renderer : `.RendererBase` instance
renderer that will be used to draw the figures (i.e.
``fig.canvas.get_renderer()``)

Returns
-------
bbox : `.BboxBase`
containing the bounding box (in figure pixel co-ordinates).
"""

bbox = self.get_window_extent(renderer)
if self.get_clip_on():
clip_box = self.get_clip_box()
if clip_box is not None:
bbox = Bbox.intersection(bbox, clip_box)
clip_path = self.get_clip_path()
if clip_path is not None and bbox is not None:
clip_path = clip_path.get_fully_transformed_path()
bbox = Bbox.intersection(bbox, clip_path.get_extents())
return bbox

def add_callback(self, func):
"""
Adds a callback function that will be called whenever one of
Expand Down Expand Up @@ -701,6 +729,17 @@ def get_animated(self):
"Return the artist's animated state"
return self._animated

def get_in_layout(self):
"""
Return boolean flag, ``True`` if artist is included in layout
calculations.

E.g. :doc:`/tutorials/intermediate/constrainedlayout_guide`,
`.Figure.tight_layout()`, and
``fig.savefig(fname, bbox_inches='tight')``.
"""
return self._in_layout

def get_clip_on(self):
'Return whether artist uses clipping'
return self._clipon
Expand Down Expand Up @@ -830,6 +869,19 @@ def set_animated(self, b):
self._animated = b
self.pchanged()

def set_in_layout(self, in_layout):
"""
Set if artist is to be included in layout calculations,
E.g. :doc:`/tutorials/intermediate/constrainedlayout_guide`,
`.Figure.tight_layout()`, and
``fig.savefig(fname, bbox_inches='tight')``.

Parameters
----------
in_layout : bool
"""
self._in_layout = in_layout

def update(self, props):
"""
Update this artist's properties from the dictionary *prop*.
Expand Down
59 changes: 45 additions & 14 deletions lib/matplotlib/axes/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4110,19 +4110,47 @@ def pick(self, *args):
martist.Artist.pick(self, args[0])

def get_default_bbox_extra_artists(self):
"""
Return a default list of artists that are used for the bounding box
calculation.

Artists are excluded either by not being visible or
``artist.set_in_layout(False)``.
"""
return [artist for artist in self.get_children()
if artist.get_visible()]
if (artist.get_visible() and artist.get_in_layout())]

def get_tightbbox(self, renderer, call_axes_locator=True):
def get_tightbbox(self, renderer, call_axes_locator=True,
bbox_extra_artists=None):
"""
Return the tight bounding box of the axes.
The dimension of the Bbox in canvas coordinate.
Return the tight bounding box of the axes, including axis and their
decorators (xlabel, title, etc).

Artists that have ``artist.set_in_layout(False)`` are not included
in the bbox.

Parameters
----------
renderer : `.RendererBase` instance
renderer that will be used to draw the figures (i.e.
``fig.canvas.get_renderer()``)

bbox_extra_artists : list of `.Artist` or ``None``
List of artists to include in the tight bounding box. If
``None`` (default), then all artist children of the axes are
included in the tight bounding box.

call_axes_locator : boolean (default ``True``)
If *call_axes_locator* is ``False``, it does not call the
``_axes_locator`` attribute, which is necessary to get the correct
bounding box. ``call_axes_locator=False`` can be used if the
caller is only interested in the relative size of the tightbbox
compared to the axes bbox.

If *call_axes_locator* is *False*, it does not call the
_axes_locator attribute, which is necessary to get the correct
bounding box. ``call_axes_locator==False`` can be used if the
caller is only intereted in the relative size of the tightbbox
compared to the axes bbox.
Returns
-------
bbox : `.BboxBase`
bounding box in figure pixel coordinates.
"""

bb = []
Expand Down Expand Up @@ -4155,11 +4183,14 @@ def get_tightbbox(self, renderer, call_axes_locator=True):
if bb_yaxis:
bb.append(bb_yaxis)

for child in self.get_children():
if isinstance(child, OffsetBox) and child.get_visible():
bb.append(child.get_window_extent(renderer))
elif isinstance(child, Legend) and child.get_visible():
bb.append(child._legend_box.get_window_extent(renderer))
bbox_artists = bbox_extra_artists
if bbox_artists is None:
bbox_artists = self.get_default_bbox_extra_artists()

for a in bbox_artists:
bbox = a.get_tightbbox(renderer)
if bbox is not None and (bbox.width != 0 or bbox.height != 0):
bb.append(bbox)

_bbox = mtransforms.Bbox.union(
[b for b in bb if b.width != 0 or b.height != 0])
Expand Down
31 changes: 2 additions & 29 deletions lib/matplotlib/backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -2048,36 +2048,9 @@ def print_figure(self, filename, dpi=None, facecolor=None, edgecolor=None,
dryrun=True,
**kwargs)
renderer = self.figure._cachedRenderer
bbox_inches = self.figure.get_tightbbox(renderer)

bbox_artists = kwargs.pop("bbox_extra_artists", None)
if bbox_artists is None:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All this logic was moved into artist.get_tightbbox...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good move.

bbox_artists = \
self.figure.get_default_bbox_extra_artists()

bbox_filtered = []
for a in bbox_artists:
bbox = a.get_window_extent(renderer)
if a.get_clip_on():
clip_box = a.get_clip_box()
if clip_box is not None:
bbox = Bbox.intersection(bbox, clip_box)
clip_path = a.get_clip_path()
if clip_path is not None and bbox is not None:
clip_path = \
clip_path.get_fully_transformed_path()
bbox = Bbox.intersection(
bbox, clip_path.get_extents())
if bbox is not None and (
bbox.width != 0 or bbox.height != 0):
bbox_filtered.append(bbox)

if bbox_filtered:
_bbox = Bbox.union(bbox_filtered)
trans = Affine2D().scale(1.0 / self.figure.dpi)
bbox_extra = TransformedBbox(_bbox, trans)
bbox_inches = Bbox.union([bbox_inches, bbox_extra])

bbox_inches = self.figure.get_tightbbox(renderer,
bbox_extra_artists=bbox_artists)
pad = kwargs.pop("pad_inches", None)
if pad is None:
pad = rcParams['savefig.pad_inches']
Expand Down
45 changes: 36 additions & 9 deletions lib/matplotlib/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -1593,10 +1593,7 @@ def draw(self, renderer):
try:
renderer.open_group('figure')
if self.get_constrained_layout() and self.axes:
if True:
self.execute_constrained_layout(renderer)
else:
pass
self.execute_constrained_layout(renderer)
if self.get_tight_layout() and self.axes:
try:
self.tight_layout(renderer,
Expand Down Expand Up @@ -2181,26 +2178,52 @@ def waitforbuttonpress(self, timeout=-1):

def get_default_bbox_extra_artists(self):
bbox_artists = [artist for artist in self.get_children()
if artist.get_visible()]
if (artist.get_visible() and artist.get_in_layout())]
for ax in self.axes:
if ax.get_visible():
bbox_artists.extend(ax.get_default_bbox_extra_artists())
# we don't want the figure's patch to influence the bbox calculation
bbox_artists.remove(self.patch)
return bbox_artists

def get_tightbbox(self, renderer):
def get_tightbbox(self, renderer, bbox_extra_artists=None):
"""
Return a (tight) bounding box of the figure in inches.

Currently, this takes only axes title, axis labels, and axis
ticklabels into account. Needs improvement.
Artists that have ``artist.set_in_layout(False)`` are not included
in the bbox.

Parameters
----------
renderer : `.RendererBase` instance
renderer that will be used to draw the figures (i.e.
``fig.canvas.get_renderer()``)

bbox_extra_artists : list of `.Artist` or ``None``
List of artists to include in the tight bounding box. If
``None`` (default), then all artist children of each axes are
included in the tight bounding box.

Returns
-------
bbox : `.BboxBase`
containing the bounding box (in figure inches).
"""

bb = []
if bbox_extra_artists is None:
artists = self.get_default_bbox_extra_artists()
else:
artists = bbox_extra_artists

for a in artists:
bbox = a.get_tightbbox(renderer)
if bbox is not None and (bbox.width != 0 or bbox.height != 0):
bb.append(bbox)

for ax in self.axes:
if ax.get_visible():
bb.append(ax.get_tightbbox(renderer))
bb.append(ax.get_tightbbox(renderer, bbox_extra_artists))

if len(bb) == 0:
return self.bbox_inches
Expand Down Expand Up @@ -2252,6 +2275,10 @@ def tight_layout(self, renderer=None, pad=1.08, h_pad=None, w_pad=None,
"""
Automatically adjust subplot parameters to give specified padding.

To exclude an artist on the axes from the bounding box calculation
that determines the subplot parameters (i.e. legend, or annotation),
then set `a.set_in_layout(False)` for that artist.

Parameters
----------
pad : float
Expand Down
16 changes: 16 additions & 0 deletions lib/matplotlib/legend.py
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,22 @@ def get_window_extent(self, *args, **kwargs):
'Return extent of the legend.'
return self._legend_box.get_window_extent(*args, **kwargs)

def get_tightbbox(self, renderer):
"""
Like `.Legend.get_window_extent`, but uses the box for the legend.

Parameters
----------
renderer : `.RendererBase` instance
renderer that will be used to draw the figures (i.e.
``fig.canvas.get_renderer()``)

Returns
-------
`.BboxBase` : containing the bounding box in figure pixel co-ordinates.
"""
return self._legend_box.get_window_extent(renderer)

def get_frame_on(self):
"""Get whether the legend box patch is drawn."""
return self._drawFrame
Expand Down
25 changes: 25 additions & 0 deletions lib/matplotlib/tests/test_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,3 +391,28 @@ def test_fspath(fmt, tmpdir):
# All the supported formats include the format name (case-insensitive)
# in the first 100 bytes.
assert fmt.encode("ascii") in file.read(100).lower()


def test_tightbbox():
fig, ax = plt.subplots()
ax.set_xlim(0, 1)
t = ax.text(1., 0.5, 'This dangles over end')
renderer = fig.canvas.get_renderer()
x1Nom0 = 9.035 # inches
assert np.abs(t.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2
assert np.abs(ax.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2
assert np.abs(fig.get_tightbbox(renderer).x1 - x1Nom0) < 0.05
assert np.abs(fig.get_tightbbox(renderer).x0 - 0.679) < 0.05
# now exclude t from the tight bbox so now the bbox is quite a bit
# smaller
t.set_in_layout(False)
x1Nom = 7.333
assert np.abs(ax.get_tightbbox(renderer).x1 - x1Nom * fig.dpi) < 2
assert np.abs(fig.get_tightbbox(renderer).x1 - x1Nom) < 0.05

t.set_in_layout(True)
x1Nom = 7.333
assert np.abs(ax.get_tightbbox(renderer).x1 - x1Nom0 * fig.dpi) < 2
# test bbox_extra_artists method...
assert np.abs(ax.get_tightbbox(renderer,
bbox_extra_artists=[]).x1 - x1Nom * fig.dpi) < 2
36 changes: 34 additions & 2 deletions tutorials/intermediate/constrainedlayout_guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ def example_plot(ax, fontsize=12, nodec=False):

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

#############################################
# However, this will steal space from a subplot layout:
Expand All @@ -198,7 +198,39 @@ def example_plot(ax, fontsize=12, nodec=False):
for ax in axs.flatten()[:-1]:
ax.plot(np.arange(10))
axs[1, 1].plot(np.arange(10), label='This is a plot')
axs[1, 1].legend(loc='center left', bbox_to_anchor=(0.9, 0.5))
axs[1, 1].legend(loc='center left', bbox_to_anchor=(0.8, 0.5))

#############################################
# In order for a legend or other artist to *not* steal space
# from the subplot layout, we can ``leg.set_in_layout(False)``.
# Of course this can mean the legend ends up
# cropped, but can be useful if the plot is subsequently called
# with ``fig.savefig('outname.png', bbox_inches='tight')``. Note,
# however, that the legend's ``get_in_layout`` status will have to be
# toggled again to make the saved file work:

fig, axs = plt.subplots(2, 2, constrained_layout=True)
for ax in axs.flatten()[:-1]:
ax.plot(np.arange(10))
axs[1, 1].plot(np.arange(10), label='This is a plot')
leg = axs[1, 1].legend(loc='center left', bbox_to_anchor=(0.8, 0.5))
leg.set_in_layout(False)
wanttoprint = False
if wanttoprint:
leg.set_in_layout(True)
fig.do_constrained_layout(False)
fig.savefig('outname.png', bbox_inches='tight')

#############################################
# A better way to get around this awkwardness is to simply
# use a legend for the figure:
fig, axs = plt.subplots(2, 2, constrained_layout=True)
for ax in axs.flatten()[:-1]:
ax.plot(np.arange(10))
lines = axs[1, 1].plot(np.arange(10), label='This is a plot')
labels = [l.get_label() for l in lines]
leg = fig.legend(lines, labels, loc='center left',
bbox_to_anchor=(0.8, 0.5), bbox_transform=axs[1, 1].transAxes)

###############################################################################
# Padding and Spacing
Expand Down
Loading