Skip to content
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

ENH: compressed layout #22289

Merged
merged 1 commit into from Jun 10, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
60 changes: 56 additions & 4 deletions lib/matplotlib/_constrained_layout.py
Expand Up @@ -62,7 +62,8 @@

######################################################
def do_constrained_layout(fig, h_pad, w_pad,
hspace=None, wspace=None, rect=(0, 0, 1, 1)):
hspace=None, wspace=None, rect=(0, 0, 1, 1),
compress=False):
"""
Do the constrained_layout. Called at draw time in
``figure.constrained_layout()``
Expand All @@ -89,6 +90,11 @@ def do_constrained_layout(fig, h_pad, w_pad,
Rectangle in figure coordinates to perform constrained layout in
[left, bottom, width, height], each from 0-1.

compress : bool
Whether to shift Axes so that white space in between them is
removed. This is useful for simple grids of fixed-aspect Axes (e.g.
a grid of images).

Returns
-------
layoutgrid : private debugging structure
Expand Down Expand Up @@ -124,13 +130,22 @@ def do_constrained_layout(fig, h_pad, w_pad,
# update all the variables in the layout.
layoutgrids[fig].update_variables()

warn_collapsed = ('constrained_layout not applied because '
'axes sizes collapsed to zero. Try making '
'figure larger or axes decorations smaller.')
if check_no_collapsed_axes(layoutgrids, fig):
reposition_axes(layoutgrids, fig, renderer, h_pad=h_pad,
w_pad=w_pad, hspace=hspace, wspace=wspace)
if compress:
layoutgrids = compress_fixed_aspect(layoutgrids, fig)
layoutgrids[fig].update_variables()
if check_no_collapsed_axes(layoutgrids, fig):
reposition_axes(layoutgrids, fig, renderer, h_pad=h_pad,
w_pad=w_pad, hspace=hspace, wspace=wspace)
else:
_api.warn_external(warn_collapsed)
else:
_api.warn_external('constrained_layout not applied because '
'axes sizes collapsed to zero. Try making '
'figure larger or axes decorations smaller.')
_api.warn_external(warn_collapsed)
reset_margins(layoutgrids, fig)
return layoutgrids

Expand Down Expand Up @@ -248,6 +263,43 @@ def check_no_collapsed_axes(layoutgrids, fig):
return True


def compress_fixed_aspect(layoutgrids, fig):
gs = None
for ax in fig.axes:
if not hasattr(ax, 'get_subplotspec'):
continue
ax.apply_aspect()
sub = ax.get_subplotspec()
_gs = sub.get_gridspec()
if gs is None:
gs = _gs
extraw = np.zeros(gs.ncols)
extrah = np.zeros(gs.nrows)
elif _gs != gs:
raise ValueError('Cannot do compressed layout if axes are not'
'all from the same gridspec')
orig = ax.get_position(original=True)
actual = ax.get_position(original=False)
dw = orig.width - actual.width
if dw > 0:
extraw[sub.colspan] = np.maximum(extraw[sub.colspan], dw)
dh = orig.height - actual.height
if dh > 0:
extrah[sub.rowspan] = np.maximum(extrah[sub.rowspan], dh)

if gs is None:
raise ValueError('Cannot do compressed layout if no axes '
'are part of a gridspec.')
w = np.sum(extraw) / 2
layoutgrids[fig].edit_margin_min('left', w)
layoutgrids[fig].edit_margin_min('right', w)

h = np.sum(extrah) / 2
layoutgrids[fig].edit_margin_min('top', h)
layoutgrids[fig].edit_margin_min('bottom', h)
return layoutgrids


def get_margin_from_padding(obj, *, w_pad=0, h_pad=0,
hspace=0, wspace=0):

Expand Down
2 changes: 1 addition & 1 deletion lib/matplotlib/_layoutgrid.py
Expand Up @@ -519,7 +519,7 @@ def plot_children(fig, lg=None, level=0, printit=False):
import matplotlib.patches as mpatches

if lg is None:
_layoutgrids = fig.execute_constrained_layout()
_layoutgrids = fig.get_layout_engine().execute(fig)
lg = _layoutgrids[fig]
colors = plt.rcParams["axes.prop_cycle"].by_key()["color"]
col = colors[level]
Expand Down
21 changes: 15 additions & 6 deletions lib/matplotlib/figure.py
Expand Up @@ -2245,7 +2245,7 @@ def __init__(self,
The use of this parameter is discouraged. Please use
``layout='constrained'`` instead.

layout : {'constrained', 'tight', `.LayoutEngine`, None}, optional
layout : {'constrained', 'compressed', 'tight', `.LayoutEngine`, None}
The layout mechanism for positioning of plot elements to avoid
overlapping Axes decorations (labels, ticks, etc). Note that
layout managers can have significant performance penalties.
Expand All @@ -2258,6 +2258,10 @@ def __init__(self,
See :doc:`/tutorials/intermediate/constrainedlayout_guide`
for examples.

- 'compressed': uses the same algorithm as 'constrained', but
removes extra space between fixed-aspect-ratio Axes. Best for
simple grids of axes.

- 'tight': Use the tight layout mechanism. This is a relatively
simple algorithm that adjusts the subplot parameters so that
decorations do not overlap. See `.Figure.set_tight_layout` for
Expand Down Expand Up @@ -2388,11 +2392,13 @@ def set_layout_engine(self, layout=None, **kwargs):

Parameters
----------
layout : {'constrained', 'tight'} or `~.LayoutEngine`
'constrained' will use `~.ConstrainedLayoutEngine`, 'tight' will
use `~.TightLayoutEngine`. Users and libraries can define their
own layout engines as well.
kwargs : dict
layout: {'constrained', 'compressed', 'tight'} or `~.LayoutEngine`
'constrained' will use `~.ConstrainedLayoutEngine`,
'compressed' will also use ConstrainedLayoutEngine, but with a
correction that attempts to make a good layout for fixed-aspect
ratio Axes. 'tight' uses `~.TightLayoutEngine`. Users and
libraries can define their own layout engines as well.
kwargs: dict
The keyword arguments are passed to the layout engine to set things
like padding and margin sizes. Only used if *layout* is a string.
"""
Expand All @@ -2408,6 +2414,9 @@ def set_layout_engine(self, layout=None, **kwargs):
new_layout_engine = TightLayoutEngine(**kwargs)
elif layout == 'constrained':
new_layout_engine = ConstrainedLayoutEngine(**kwargs)
elif layout == 'compressed':
new_layout_engine = ConstrainedLayoutEngine(compress=True,
**kwargs)
elif isinstance(layout, LayoutEngine):
new_layout_engine = layout
else:
Expand Down
10 changes: 8 additions & 2 deletions lib/matplotlib/layout_engine.py
Expand Up @@ -180,7 +180,7 @@ class ConstrainedLayoutEngine(LayoutEngine):

def __init__(self, *, h_pad=None, w_pad=None,
hspace=None, wspace=None, rect=(0, 0, 1, 1),
**kwargs):
compress=False, **kwargs):
"""
Initialize ``constrained_layout`` settings.

Expand All @@ -201,6 +201,10 @@ def __init__(self, *, h_pad=None, w_pad=None,
rect : tuple of 4 floats
Rectangle in figure coordinates to perform constrained layout in
(left, bottom, width, height), each from 0-1.
compress : bool
Whether to shift Axes so that white space in between them is
removed. This is useful for simple grids of fixed-aspect Axes (e.g.
a grid of images). See :ref:`compressed_layout`.
"""
super().__init__(**kwargs)
# set the defaults:
Expand All @@ -212,6 +216,7 @@ def __init__(self, *, h_pad=None, w_pad=None,
# set anything that was passed in (None will be ignored):
self.set(w_pad=w_pad, h_pad=h_pad, wspace=wspace, hspace=hspace,
rect=rect)
self._compress = compress

def execute(self, fig):
"""
Expand All @@ -229,7 +234,8 @@ def execute(self, fig):
return do_constrained_layout(fig, w_pad=w_pad, h_pad=h_pad,
wspace=self._params['wspace'],
hspace=self._params['hspace'],
rect=self._params['rect'])
rect=self._params['rect'],
compress=self._compress)

def set(self, *, h_pad=None, w_pad=None,
hspace=None, wspace=None, rect=None):
Expand Down
31 changes: 31 additions & 0 deletions lib/matplotlib/tests/test_constrainedlayout.py
Expand Up @@ -624,3 +624,34 @@ def test_rect():
assert ppos.y1 < 0.5
assert ppos.x0 > 0.2
assert ppos.y0 > 0.2


def test_compressed1():
fig, axs = plt.subplots(3, 2, layout='compressed',
sharex=True, sharey=True)
for ax in axs.flat:
pc = ax.imshow(np.random.randn(20, 20))

fig.colorbar(pc, ax=axs)
fig.draw_without_rendering()

pos = axs[0, 0].get_position()
np.testing.assert_allclose(pos.x0, 0.2344, atol=1e-3)
pos = axs[0, 1].get_position()
np.testing.assert_allclose(pos.x1, 0.7024, atol=1e-3)

# wider than tall
fig, axs = plt.subplots(2, 3, layout='compressed',
sharex=True, sharey=True, figsize=(5, 4))
for ax in axs.flat:
pc = ax.imshow(np.random.randn(20, 20))

fig.colorbar(pc, ax=axs)
fig.draw_without_rendering()

pos = axs[0, 0].get_position()
np.testing.assert_allclose(pos.x0, 0.06195, atol=1e-3)
np.testing.assert_allclose(pos.y1, 0.8537, atol=1e-3)
pos = axs[1, 2].get_position()
np.testing.assert_allclose(pos.x1, 0.8618, atol=1e-3)
np.testing.assert_allclose(pos.y0, 0.1934, atol=1e-3)
50 changes: 40 additions & 10 deletions tutorials/intermediate/arranging_axes.py
Expand Up @@ -100,7 +100,7 @@
import numpy as np

fig, axs = plt.subplots(ncols=2, nrows=2, figsize=(5.5, 3.5),
constrained_layout=True)
layout="constrained")
# add an artist, in this case a nice label in the middle...
for row in range(2):
for col in range(2):
Expand Down Expand Up @@ -129,11 +129,41 @@ def annotate_axes(ax, text, fontsize=18):

fig, axd = plt.subplot_mosaic([['upper left', 'upper right'],
['lower left', 'lower right']],
figsize=(5.5, 3.5), constrained_layout=True)
figsize=(5.5, 3.5), layout="constrained")
for k in axd:
annotate_axes(axd[k], f'axd["{k}"]', fontsize=14)
fig.suptitle('plt.subplot_mosaic()')

#############################################################################
#
# Grids of fixed-aspect ratio Axes
# --------------------------------
#
# Fixed-aspect ratio axes are common for images or maps. However, they
# present a challenge to layout because two sets of constraints are being
# imposed on the size of the Axes - that they fit in the figure and that they
# have a set aspect ratio. This leads to large gaps between Axes by default:
#

fig, axs = plt.subplots(2, 2, layout="constrained", figsize=(5.5, 3.5))
for ax in axs.flat:
ax.set_aspect(1)
fig.suptitle('Fixed aspect Axes')

############################################################################
# One way to address this is to change the aspect of the figure to be close
# to the aspect ratio of the Axes, however that requires trial and error.
# Matplotlib also supplies ``layout="compressed"``, which will work with
# simple grids to reduce the gaps between Axes. (The ``mpl_toolkits`` also
# provides `~.mpl_toolkits.axes_grid1.axes_grid.ImageGrid` to accomplish
# a similar effect, but with a non-standard Axes class).

fig, axs = plt.subplots(2, 2, layout="compressed", figsize=(5.5, 3.5))
for ax in axs.flat:
ax.set_aspect(1)
fig.suptitle('Fixed aspect Axes: compressed')


############################################################################
# Axes spanning rows or columns in a grid
# ---------------------------------------
Expand All @@ -145,7 +175,7 @@ def annotate_axes(ax, text, fontsize=18):

fig, axd = plt.subplot_mosaic([['upper left', 'right'],
['lower left', 'right']],
figsize=(5.5, 3.5), constrained_layout=True)
figsize=(5.5, 3.5), layout="constrained")
for k in axd:
annotate_axes(axd[k], f'axd["{k}"]', fontsize=14)
fig.suptitle('plt.subplot_mosaic()')
Expand All @@ -168,7 +198,7 @@ def annotate_axes(ax, text, fontsize=18):
fig, axd = plt.subplot_mosaic([['upper left', 'right'],
['lower left', 'right']],
gridspec_kw=gs_kw, figsize=(5.5, 3.5),
constrained_layout=True)
layout="constrained")
for k in axd:
annotate_axes(axd[k], f'axd["{k}"]', fontsize=14)
fig.suptitle('plt.subplot_mosaic()')
Expand All @@ -184,7 +214,7 @@ def annotate_axes(ax, text, fontsize=18):
# necessarily aligned. See below for a more verbose way to achieve the same
# effect with `~.gridspec.GridSpecFromSubplotSpec`.

fig = plt.figure(constrained_layout=True)
fig = plt.figure(layout="constrained")
subfigs = fig.subfigures(1, 2, wspace=0.07, width_ratios=[1.5, 1.])
axs0 = subfigs[0].subplots(2, 2)
subfigs[0].set_facecolor('0.9')
Expand All @@ -207,7 +237,7 @@ def annotate_axes(ax, text, fontsize=18):
outer = [['upper left', inner],
['lower left', 'lower right']]

fig, axd = plt.subplot_mosaic(outer, constrained_layout=True)
fig, axd = plt.subplot_mosaic(outer, layout="constrained")
for k in axd:
annotate_axes(axd[k], f'axd["{k}"]')

Expand All @@ -230,7 +260,7 @@ def annotate_axes(ax, text, fontsize=18):
# We can accomplish a 2x2 grid in the same manner as
# ``plt.subplots(2, 2)``:

fig = plt.figure(figsize=(5.5, 3.5), constrained_layout=True)
fig = plt.figure(figsize=(5.5, 3.5), layout="constrained")
spec = fig.add_gridspec(ncols=2, nrows=2)

ax0 = fig.add_subplot(spec[0, 0])
Expand All @@ -256,7 +286,7 @@ def annotate_axes(ax, text, fontsize=18):
# and the new Axes will span the slice. This would be the same
# as ``fig, axd = plt.subplot_mosaic([['ax0', 'ax0'], ['ax1', 'ax2']], ...)``:

fig = plt.figure(figsize=(5.5, 3.5), constrained_layout=True)
fig = plt.figure(figsize=(5.5, 3.5), layout="constrained")
spec = fig.add_gridspec(2, 2)

ax0 = fig.add_subplot(spec[0, :])
Expand Down Expand Up @@ -284,7 +314,7 @@ def annotate_axes(ax, text, fontsize=18):
# These spacing parameters can also be passed to `~.pyplot.subplots` and
# `~.pyplot.subplot_mosaic` as the *gridspec_kw* argument.

fig = plt.figure(constrained_layout=False, facecolor='0.9')
fig = plt.figure(layout=None, facecolor='0.9')
gs = fig.add_gridspec(nrows=3, ncols=3, left=0.05, right=0.75,
hspace=0.1, wspace=0.05)
ax0 = fig.add_subplot(gs[:-1, :])
Expand All @@ -306,7 +336,7 @@ def annotate_axes(ax, text, fontsize=18):
# Note this is also available from the more verbose
# `.gridspec.GridSpecFromSubplotSpec`.

fig = plt.figure(constrained_layout=True)
fig = plt.figure(layout="constrained")
gs0 = fig.add_gridspec(1, 2)

gs00 = gs0[0].subgridspec(2, 2)
Expand Down