Skip to content

Commit b396481

Browse files
authored
Merge pull request #22289 from jklymak/enh-compress-layout
ENH: compressed layout
2 parents 8001579 + 44df2db commit b396481

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
@@ -61,7 +61,8 @@
6161

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

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

@@ -247,6 +262,43 @@ def check_no_collapsed_axes(layoutgrids, fig):
247262
return True
248263

249264

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

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
@@ -2234,7 +2234,7 @@ def __init__(self,
22342234
The use of this parameter is discouraged. Please use
22352235
``layout='constrained'`` instead.
22362236
2237-
layout : {'constrained', 'tight', `.LayoutEngine`, None}, optional
2237+
layout : {'constrained', 'compressed', 'tight', `.LayoutEngine`, None}
22382238
The layout mechanism for positioning of plot elements to avoid
22392239
overlapping Axes decorations (labels, ticks, etc). Note that
22402240
layout managers can have significant performance penalties.
@@ -2247,6 +2247,10 @@ def __init__(self,
22472247
See :doc:`/tutorials/intermediate/constrainedlayout_guide`
22482248
for examples.
22492249
2250+
- 'compressed': uses the same algorithm as 'constrained', but
2251+
removes extra space between fixed-aspect-ratio Axes. Best for
2252+
simple grids of axes.
2253+
22502254
- 'tight': Use the tight layout mechanism. This is a relatively
22512255
simple algorithm that adjusts the subplot parameters so that
22522256
decorations do not overlap. See `.Figure.set_tight_layout` for
@@ -2377,11 +2381,13 @@ def set_layout_engine(self, layout=None, **kwargs):
23772381
23782382
Parameters
23792383
----------
2380-
layout : {'constrained', 'tight'} or `~.LayoutEngine`
2381-
'constrained' will use `~.ConstrainedLayoutEngine`, 'tight' will
2382-
use `~.TightLayoutEngine`. Users and libraries can define their
2383-
own layout engines as well.
2384-
kwargs : dict
2384+
layout: {'constrained', 'compressed', 'tight'} or `~.LayoutEngine`
2385+
'constrained' will use `~.ConstrainedLayoutEngine`,
2386+
'compressed' will also use ConstrainedLayoutEngine, but with a
2387+
correction that attempts to make a good layout for fixed-aspect
2388+
ratio Axes. 'tight' uses `~.TightLayoutEngine`. Users and
2389+
libraries can define their own layout engines as well.
2390+
kwargs: dict
23852391
The keyword arguments are passed to the layout engine to set things
23862392
like padding and margin sizes. Only used if *layout* is a string.
23872393
"""
@@ -2397,6 +2403,9 @@ def set_layout_engine(self, layout=None, **kwargs):
23972403
new_layout_engine = TightLayoutEngine(**kwargs)
23982404
elif layout == 'constrained':
23992405
new_layout_engine = ConstrainedLayoutEngine(**kwargs)
2406+
elif layout == 'compressed':
2407+
new_layout_engine = ConstrainedLayoutEngine(compress=True,
2408+
**kwargs)
24002409
elif isinstance(layout, LayoutEngine):
24012410
new_layout_engine = layout
24022411
else:

lib/matplotlib/layout_engine.py

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

179179
def __init__(self, *, h_pad=None, w_pad=None,
180180
hspace=None, wspace=None, rect=(0, 0, 1, 1),
181-
**kwargs):
181+
compress=False, **kwargs):
182182
"""
183183
Initialize ``constrained_layout`` settings.
184184
@@ -199,6 +199,10 @@ def __init__(self, *, h_pad=None, w_pad=None,
199199
rect : tuple of 4 floats
200200
Rectangle in figure coordinates to perform constrained layout in
201201
(left, bottom, width, height), each from 0-1.
202+
compress : bool
203+
Whether to shift Axes so that white space in between them is
204+
removed. This is useful for simple grids of fixed-aspect Axes (e.g.
205+
a grid of images). See :ref:`compressed_layout`.
202206
"""
203207
super().__init__(**kwargs)
204208
# set the defaults:
@@ -210,6 +214,7 @@ def __init__(self, *, h_pad=None, w_pad=None,
210214
# set anything that was passed in (None will be ignored):
211215
self.set(w_pad=w_pad, h_pad=h_pad, wspace=wspace, hspace=hspace,
212216
rect=rect)
217+
self._compress = compress
213218

214219
def execute(self, fig):
215220
"""
@@ -227,7 +232,8 @@ def execute(self, fig):
227232
return do_constrained_layout(fig, w_pad=w_pad, h_pad=h_pad,
228233
wspace=self._params['wspace'],
229234
hspace=self._params['hspace'],
230-
rect=self._params['rect'])
235+
rect=self._params['rect'],
236+
compress=self._compress)
231237

232238
def set(self, *, h_pad=None, w_pad=None,
233239
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)