Skip to content

Commit 44df2db

Browse files
committed
ENH: simple compressed layout
Adds compress=True to compressed layout engine. Works for compact axes grids.
1 parent d73ba9e commit 44df2db

File tree

7 files changed

+241
-60
lines changed

7 files changed

+241
-60
lines changed

lib/matplotlib/_constrained_layout.py

+56-4
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@
6262

6363
######################################################
6464
def do_constrained_layout(fig, h_pad, w_pad,
65-
hspace=None, wspace=None, rect=(0, 0, 1, 1)):
65+
hspace=None, wspace=None, rect=(0, 0, 1, 1),
66+
compress=False):
6667
"""
6768
Do the constrained_layout. Called at draw time in
6869
``figure.constrained_layout()``
@@ -89,6 +90,11 @@ def do_constrained_layout(fig, h_pad, w_pad,
8990
Rectangle in figure coordinates to perform constrained layout in
9091
[left, bottom, width, height], each from 0-1.
9192
93+
compress : bool
94+
Whether to shift Axes so that white space in between them is
95+
removed. This is useful for simple grids of fixed-aspect Axes (e.g.
96+
a grid of images).
97+
9298
Returns
9399
-------
94100
layoutgrid : private debugging structure
@@ -124,13 +130,22 @@ def do_constrained_layout(fig, h_pad, w_pad,
124130
# update all the variables in the layout.
125131
layoutgrids[fig].update_variables()
126132

133+
warn_collapsed = ('constrained_layout not applied because '
134+
'axes sizes collapsed to zero. Try making '
135+
'figure larger or axes decorations smaller.')
127136
if check_no_collapsed_axes(layoutgrids, fig):
128137
reposition_axes(layoutgrids, fig, renderer, h_pad=h_pad,
129138
w_pad=w_pad, hspace=hspace, wspace=wspace)
139+
if compress:
140+
layoutgrids = compress_fixed_aspect(layoutgrids, fig)
141+
layoutgrids[fig].update_variables()
142+
if check_no_collapsed_axes(layoutgrids, fig):
143+
reposition_axes(layoutgrids, fig, renderer, h_pad=h_pad,
144+
w_pad=w_pad, hspace=hspace, wspace=wspace)
145+
else:
146+
_api.warn_external(warn_collapsed)
130147
else:
131-
_api.warn_external('constrained_layout not applied because '
132-
'axes sizes collapsed to zero. Try making '
133-
'figure larger or axes decorations smaller.')
148+
_api.warn_external(warn_collapsed)
134149
reset_margins(layoutgrids, fig)
135150
return layoutgrids
136151

@@ -248,6 +263,43 @@ def check_no_collapsed_axes(layoutgrids, fig):
248263
return True
249264

250265

266+
def compress_fixed_aspect(layoutgrids, fig):
267+
gs = None
268+
for ax in fig.axes:
269+
if not hasattr(ax, 'get_subplotspec'):
270+
continue
271+
ax.apply_aspect()
272+
sub = ax.get_subplotspec()
273+
_gs = sub.get_gridspec()
274+
if gs is None:
275+
gs = _gs
276+
extraw = np.zeros(gs.ncols)
277+
extrah = np.zeros(gs.nrows)
278+
elif _gs != gs:
279+
raise ValueError('Cannot do compressed layout if axes are not'
280+
'all from the same gridspec')
281+
orig = ax.get_position(original=True)
282+
actual = ax.get_position(original=False)
283+
dw = orig.width - actual.width
284+
if dw > 0:
285+
extraw[sub.colspan] = np.maximum(extraw[sub.colspan], dw)
286+
dh = orig.height - actual.height
287+
if dh > 0:
288+
extrah[sub.rowspan] = np.maximum(extrah[sub.rowspan], dh)
289+
290+
if gs is None:
291+
raise ValueError('Cannot do compressed layout if no axes '
292+
'are part of a gridspec.')
293+
w = np.sum(extraw) / 2
294+
layoutgrids[fig].edit_margin_min('left', w)
295+
layoutgrids[fig].edit_margin_min('right', w)
296+
297+
h = np.sum(extrah) / 2
298+
layoutgrids[fig].edit_margin_min('top', h)
299+
layoutgrids[fig].edit_margin_min('bottom', h)
300+
return layoutgrids
301+
302+
251303
def get_margin_from_padding(obj, *, w_pad=0, h_pad=0,
252304
hspace=0, wspace=0):
253305

lib/matplotlib/_layoutgrid.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -519,7 +519,7 @@ def plot_children(fig, lg=None, level=0, printit=False):
519519
import matplotlib.patches as mpatches
520520

521521
if lg is None:
522-
_layoutgrids = fig.execute_constrained_layout()
522+
_layoutgrids = fig.get_layout_engine().execute(fig)
523523
lg = _layoutgrids[fig]
524524
colors = plt.rcParams["axes.prop_cycle"].by_key()["color"]
525525
col = colors[level]

lib/matplotlib/figure.py

+15-6
Original file line numberDiff line numberDiff line change
@@ -2245,7 +2245,7 @@ def __init__(self,
22452245
The use of this parameter is discouraged. Please use
22462246
``layout='constrained'`` instead.
22472247
2248-
layout : {'constrained', 'tight', `.LayoutEngine`, None}, optional
2248+
layout : {'constrained', 'compressed', 'tight', `.LayoutEngine`, None}
22492249
The layout mechanism for positioning of plot elements to avoid
22502250
overlapping Axes decorations (labels, ticks, etc). Note that
22512251
layout managers can have significant performance penalties.
@@ -2258,6 +2258,10 @@ def __init__(self,
22582258
See :doc:`/tutorials/intermediate/constrainedlayout_guide`
22592259
for examples.
22602260
2261+
- 'compressed': uses the same algorithm as 'constrained', but
2262+
removes extra space between fixed-aspect-ratio Axes. Best for
2263+
simple grids of axes.
2264+
22612265
- 'tight': Use the tight layout mechanism. This is a relatively
22622266
simple algorithm that adjusts the subplot parameters so that
22632267
decorations do not overlap. See `.Figure.set_tight_layout` for
@@ -2388,11 +2392,13 @@ def set_layout_engine(self, layout=None, **kwargs):
23882392
23892393
Parameters
23902394
----------
2391-
layout : {'constrained', 'tight'} or `~.LayoutEngine`
2392-
'constrained' will use `~.ConstrainedLayoutEngine`, 'tight' will
2393-
use `~.TightLayoutEngine`. Users and libraries can define their
2394-
own layout engines as well.
2395-
kwargs : dict
2395+
layout: {'constrained', 'compressed', 'tight'} or `~.LayoutEngine`
2396+
'constrained' will use `~.ConstrainedLayoutEngine`,
2397+
'compressed' will also use ConstrainedLayoutEngine, but with a
2398+
correction that attempts to make a good layout for fixed-aspect
2399+
ratio Axes. 'tight' uses `~.TightLayoutEngine`. Users and
2400+
libraries can define their own layout engines as well.
2401+
kwargs: dict
23962402
The keyword arguments are passed to the layout engine to set things
23972403
like padding and margin sizes. Only used if *layout* is a string.
23982404
"""
@@ -2408,6 +2414,9 @@ def set_layout_engine(self, layout=None, **kwargs):
24082414
new_layout_engine = TightLayoutEngine(**kwargs)
24092415
elif layout == 'constrained':
24102416
new_layout_engine = ConstrainedLayoutEngine(**kwargs)
2417+
elif layout == 'compressed':
2418+
new_layout_engine = ConstrainedLayoutEngine(compress=True,
2419+
**kwargs)
24112420
elif isinstance(layout, LayoutEngine):
24122421
new_layout_engine = layout
24132422
else:

lib/matplotlib/layout_engine.py

+8-2
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ class ConstrainedLayoutEngine(LayoutEngine):
180180

181181
def __init__(self, *, h_pad=None, w_pad=None,
182182
hspace=None, wspace=None, rect=(0, 0, 1, 1),
183-
**kwargs):
183+
compress=False, **kwargs):
184184
"""
185185
Initialize ``constrained_layout`` settings.
186186
@@ -201,6 +201,10 @@ def __init__(self, *, h_pad=None, w_pad=None,
201201
rect : tuple of 4 floats
202202
Rectangle in figure coordinates to perform constrained layout in
203203
(left, bottom, width, height), each from 0-1.
204+
compress : bool
205+
Whether to shift Axes so that white space in between them is
206+
removed. This is useful for simple grids of fixed-aspect Axes (e.g.
207+
a grid of images). See :ref:`compressed_layout`.
204208
"""
205209
super().__init__(**kwargs)
206210
# set the defaults:
@@ -212,6 +216,7 @@ def __init__(self, *, h_pad=None, w_pad=None,
212216
# set anything that was passed in (None will be ignored):
213217
self.set(w_pad=w_pad, h_pad=h_pad, wspace=wspace, hspace=hspace,
214218
rect=rect)
219+
self._compress = compress
215220

216221
def execute(self, fig):
217222
"""
@@ -229,7 +234,8 @@ def execute(self, fig):
229234
return do_constrained_layout(fig, w_pad=w_pad, h_pad=h_pad,
230235
wspace=self._params['wspace'],
231236
hspace=self._params['hspace'],
232-
rect=self._params['rect'])
237+
rect=self._params['rect'],
238+
compress=self._compress)
233239

234240
def set(self, *, h_pad=None, w_pad=None,
235241
hspace=None, wspace=None, rect=None):

lib/matplotlib/tests/test_constrainedlayout.py

+31
Original file line numberDiff line numberDiff line change
@@ -624,3 +624,34 @@ def test_rect():
624624
assert ppos.y1 < 0.5
625625
assert ppos.x0 > 0.2
626626
assert ppos.y0 > 0.2
627+
628+
629+
def test_compressed1():
630+
fig, axs = plt.subplots(3, 2, layout='compressed',
631+
sharex=True, sharey=True)
632+
for ax in axs.flat:
633+
pc = ax.imshow(np.random.randn(20, 20))
634+
635+
fig.colorbar(pc, ax=axs)
636+
fig.draw_without_rendering()
637+
638+
pos = axs[0, 0].get_position()
639+
np.testing.assert_allclose(pos.x0, 0.2344, atol=1e-3)
640+
pos = axs[0, 1].get_position()
641+
np.testing.assert_allclose(pos.x1, 0.7024, atol=1e-3)
642+
643+
# wider than tall
644+
fig, axs = plt.subplots(2, 3, layout='compressed',
645+
sharex=True, sharey=True, figsize=(5, 4))
646+
for ax in axs.flat:
647+
pc = ax.imshow(np.random.randn(20, 20))
648+
649+
fig.colorbar(pc, ax=axs)
650+
fig.draw_without_rendering()
651+
652+
pos = axs[0, 0].get_position()
653+
np.testing.assert_allclose(pos.x0, 0.06195, atol=1e-3)
654+
np.testing.assert_allclose(pos.y1, 0.8537, atol=1e-3)
655+
pos = axs[1, 2].get_position()
656+
np.testing.assert_allclose(pos.x1, 0.8618, atol=1e-3)
657+
np.testing.assert_allclose(pos.y0, 0.1934, atol=1e-3)

tutorials/intermediate/arranging_axes.py

+40-10
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100
import numpy as np
101101

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

130130
fig, axd = plt.subplot_mosaic([['upper left', 'upper right'],
131131
['lower left', 'lower right']],
132-
figsize=(5.5, 3.5), constrained_layout=True)
132+
figsize=(5.5, 3.5), layout="constrained")
133133
for k in axd:
134134
annotate_axes(axd[k], f'axd["{k}"]', fontsize=14)
135135
fig.suptitle('plt.subplot_mosaic()')
136136

137+
#############################################################################
138+
#
139+
# Grids of fixed-aspect ratio Axes
140+
# --------------------------------
141+
#
142+
# Fixed-aspect ratio axes are common for images or maps. However, they
143+
# present a challenge to layout because two sets of constraints are being
144+
# imposed on the size of the Axes - that they fit in the figure and that they
145+
# have a set aspect ratio. This leads to large gaps between Axes by default:
146+
#
147+
148+
fig, axs = plt.subplots(2, 2, layout="constrained", figsize=(5.5, 3.5))
149+
for ax in axs.flat:
150+
ax.set_aspect(1)
151+
fig.suptitle('Fixed aspect Axes')
152+
153+
############################################################################
154+
# One way to address this is to change the aspect of the figure to be close
155+
# to the aspect ratio of the Axes, however that requires trial and error.
156+
# Matplotlib also supplies ``layout="compressed"``, which will work with
157+
# simple grids to reduce the gaps between Axes. (The ``mpl_toolkits`` also
158+
# provides `~.mpl_toolkits.axes_grid1.axes_grid.ImageGrid` to accomplish
159+
# a similar effect, but with a non-standard Axes class).
160+
161+
fig, axs = plt.subplots(2, 2, layout="compressed", figsize=(5.5, 3.5))
162+
for ax in axs.flat:
163+
ax.set_aspect(1)
164+
fig.suptitle('Fixed aspect Axes: compressed')
165+
166+
137167
############################################################################
138168
# Axes spanning rows or columns in a grid
139169
# ---------------------------------------
@@ -145,7 +175,7 @@ def annotate_axes(ax, text, fontsize=18):
145175

146176
fig, axd = plt.subplot_mosaic([['upper left', 'right'],
147177
['lower left', 'right']],
148-
figsize=(5.5, 3.5), constrained_layout=True)
178+
figsize=(5.5, 3.5), layout="constrained")
149179
for k in axd:
150180
annotate_axes(axd[k], f'axd["{k}"]', fontsize=14)
151181
fig.suptitle('plt.subplot_mosaic()')
@@ -168,7 +198,7 @@ def annotate_axes(ax, text, fontsize=18):
168198
fig, axd = plt.subplot_mosaic([['upper left', 'right'],
169199
['lower left', 'right']],
170200
gridspec_kw=gs_kw, figsize=(5.5, 3.5),
171-
constrained_layout=True)
201+
layout="constrained")
172202
for k in axd:
173203
annotate_axes(axd[k], f'axd["{k}"]', fontsize=14)
174204
fig.suptitle('plt.subplot_mosaic()')
@@ -184,7 +214,7 @@ def annotate_axes(ax, text, fontsize=18):
184214
# necessarily aligned. See below for a more verbose way to achieve the same
185215
# effect with `~.gridspec.GridSpecFromSubplotSpec`.
186216

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

210-
fig, axd = plt.subplot_mosaic(outer, constrained_layout=True)
240+
fig, axd = plt.subplot_mosaic(outer, layout="constrained")
211241
for k in axd:
212242
annotate_axes(axd[k], f'axd["{k}"]')
213243

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

233-
fig = plt.figure(figsize=(5.5, 3.5), constrained_layout=True)
263+
fig = plt.figure(figsize=(5.5, 3.5), layout="constrained")
234264
spec = fig.add_gridspec(ncols=2, nrows=2)
235265

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

259-
fig = plt.figure(figsize=(5.5, 3.5), constrained_layout=True)
289+
fig = plt.figure(figsize=(5.5, 3.5), layout="constrained")
260290
spec = fig.add_gridspec(2, 2)
261291

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

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

309-
fig = plt.figure(constrained_layout=True)
339+
fig = plt.figure(layout="constrained")
310340
gs0 = fig.add_gridspec(1, 2)
311341

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

0 commit comments

Comments
 (0)