From 6fa3fa93921ac1b0fba724955cb5f3c7c9da841b Mon Sep 17 00:00:00 2001 From: Jody Klymak Date: Sun, 18 Apr 2021 17:45:02 -0700 Subject: [PATCH 1/2] ENH: constrained_layout simple compress axes --- lib/matplotlib/_constrained_layout.py | 55 ++++++++++++++++++++++++--- lib/matplotlib/_layoutgrid.py | 4 +- lib/matplotlib/figure.py | 14 +++++-- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/lib/matplotlib/_constrained_layout.py b/lib/matplotlib/_constrained_layout.py index 245679394bc2..fe7b5055673a 100644 --- a/lib/matplotlib/_constrained_layout.py +++ b/lib/matplotlib/_constrained_layout.py @@ -61,7 +61,7 @@ ###################################################### def do_constrained_layout(fig, renderer, h_pad, w_pad, - hspace=None, wspace=None): + hspace=None, wspace=None, compress=False): """ Do the constrained_layout. Called at draw time in ``figure.constrained_layout()`` @@ -83,6 +83,11 @@ def do_constrained_layout(fig, renderer, h_pad, w_pad, A value of 0.2 for a three-column layout would have a space of 0.1 of the figure width between each column. If h/wspace < h/w_pad, then the pads are used instead. + + compress : boolean, False + Whether to try and push axes together if their aspect ratios + make it so that the they will have lots of extra white space + between them. Useful for grids of images or maps. """ # list of unique gridspecs that contain child axes: @@ -98,7 +103,7 @@ def do_constrained_layout(fig, renderer, h_pad, w_pad, 'Possibly did not call parent GridSpec with the' ' "figure" keyword') - for _ in range(2): + for nn in range(2): # do the algorithm twice. This has to be done because decorations # change size after the first re-position (i.e. x/yticklabels get # larger/smaller). This second reposition tends to be much milder, @@ -118,16 +123,56 @@ def do_constrained_layout(fig, renderer, h_pad, w_pad, # update all the variables in the layout. fig._layoutgrid.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(fig): _reposition_axes(fig, renderer, h_pad=h_pad, w_pad=w_pad, hspace=hspace, wspace=wspace) + if compress: + _compress_fixed_aspect(fig) + # update all the variables in the layout. + fig._layoutgrid.update_variables() + if _check_no_collapsed_axes(fig): + _reposition_axes(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(fig) +def _compress_fixed_aspect(fig): + extraw = dict() + extrah = dict() + for ax in fig.axes: + if hasattr(ax, 'get_subplotspec'): + actual = ax.get_position(original=False) + ax.apply_aspect() + sub = ax.get_subplotspec() + gs = sub.get_gridspec() + if gs not in extraw.keys(): + extraw[gs] = np.zeros(gs.ncols) + extrah[gs] = np.zeros(gs.nrows) + orig = ax.get_position(original=True) + actual = ax.get_position(original=False) + dw = orig.width - actual.width + if dw > 0: + for i in sub.colspan: + extraw[gs][i] = max(extraw[gs][i], dw) + dh = orig.height - actual.height + if dh > 0: + for i in sub.rowspan: + extrah[gs][i] = max(extrah[gs][i], dh) + + fig._layoutgrid.edit_margin_min('left', np.sum(extraw[gs]) / 2) + fig._layoutgrid.edit_margin_min('right', np.sum(extraw[gs]) / 2) + + fig._layoutgrid.edit_margin_min('top', np.sum(extrah[gs]) / 2) + fig._layoutgrid.edit_margin_min('bottom', np.sum(extrah[gs]) / 2) + + def _check_no_collapsed_axes(fig): """ Check that no axes have collapsed to zero size. diff --git a/lib/matplotlib/_layoutgrid.py b/lib/matplotlib/_layoutgrid.py index e46b3fe8c062..dd0da310acca 100644 --- a/lib/matplotlib/_layoutgrid.py +++ b/lib/matplotlib/_layoutgrid.py @@ -120,7 +120,9 @@ def __repr__(self): f'innerW{self.inner_widths[j].value():1.3f}, ' \ f'innerH{self.inner_heights[i].value():1.3f}, ' \ f'ML{self.margins["left"][j].value():1.3f}, ' \ - f'MR{self.margins["right"][j].value():1.3f}, \n' + f'MR{self.margins["right"][j].value():1.3f}, ' \ + f'MB{self.margins["bottom"][j].value():1.3f}, ' \ + f'MT{self.margins["top"][j].value():1.3f}, \n' return str def reset_margins(self): diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index fdfcd24de1e8..f505a4587bec 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -2449,6 +2449,10 @@ def set_constrained_layout_pads(self, **kwargs): Height padding between subplots, expressed as a fraction of the subplot width. The total padding ends up being h_pad + hspace. + compress : boolean + Try to compress axes in constrained layout. Useful when + axes aspect ratios make it so that there is substantial + white space between them. """ todo = ['w_pad', 'h_pad', 'wspace', 'hspace'] @@ -2458,6 +2462,8 @@ def set_constrained_layout_pads(self, **kwargs): else: self._constrained_layout_pads[td] = ( mpl.rcParams['figure.constrained_layout.' + td]) + self._constrained_layout_pads['compress'] = ( + kwargs.get('compress', False)) def get_constrained_layout_pads(self, relative=False): """ @@ -2477,6 +2483,7 @@ def get_constrained_layout_pads(self, relative=False): h_pad = self._constrained_layout_pads['h_pad'] wspace = self._constrained_layout_pads['wspace'] hspace = self._constrained_layout_pads['hspace'] + compress = self._constrained_layout_pads['compress'] if relative and (w_pad is not None or h_pad is not None): renderer0 = layoutgrid.get_renderer(self) @@ -2484,7 +2491,7 @@ def get_constrained_layout_pads(self, relative=False): w_pad = w_pad * dpi / renderer0.width h_pad = h_pad * dpi / renderer0.height - return w_pad, h_pad, wspace, hspace + return w_pad, h_pad, wspace, hspace, compress def set_canvas(self, canvas): """ @@ -3073,7 +3080,7 @@ def execute_constrained_layout(self, renderer=None): "or you need to call figure or subplots " "with the constrained_layout=True kwarg.") return - w_pad, h_pad, wspace, hspace = self.get_constrained_layout_pads() + w_pad, h_pad, wspace, hspace, comp = self.get_constrained_layout_pads() # convert to unit-relative lengths fig = self width, height = fig.get_size_inches() @@ -3081,7 +3088,8 @@ def execute_constrained_layout(self, renderer=None): h_pad = h_pad / height if renderer is None: renderer = _get_renderer(fig) - do_constrained_layout(fig, renderer, h_pad, w_pad, hspace, wspace) + do_constrained_layout(fig, renderer, h_pad, w_pad, hspace, wspace, + compress=comp) def tight_layout(self, *, pad=1.08, h_pad=None, w_pad=None, rect=None): """ From a85440effe898582f9cae31d649c8f85c4450df5 Mon Sep 17 00:00:00 2001 From: Jody Klymak Date: Wed, 21 Apr 2021 22:26:09 -0700 Subject: [PATCH 2/2] TST + DOC --- .../tests/test_constrainedlayout.py | 40 ++++++++++-- .../intermediate/constrainedlayout_guide.py | 62 ++++++++++++++++++- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/tests/test_constrainedlayout.py b/lib/matplotlib/tests/test_constrainedlayout.py index d8bd4cffde94..dcbe52775373 100644 --- a/lib/matplotlib/tests/test_constrainedlayout.py +++ b/lib/matplotlib/tests/test_constrainedlayout.py @@ -20,16 +20,17 @@ def example_plot(ax, fontsize=12, nodec=False): ax.set_yticklabels('') -def example_pcolor(ax, fontsize=12): +def example_pcolor(ax, fontsize=12, hide_labels=False): dx, dy = 0.6, 0.6 y, x = np.mgrid[slice(-3, 3 + dy, dy), slice(-3, 3 + dx, dx)] z = (1 - x / 2. + x ** 5 + y ** 3) * np.exp(-x ** 2 - y ** 2) pcm = ax.pcolormesh(x, y, z[:-1, :-1], cmap='RdBu_r', vmin=-1., vmax=1., rasterized=True) - ax.set_xlabel('x-label', fontsize=fontsize) - ax.set_ylabel('y-label', fontsize=fontsize) - ax.set_title('Title', fontsize=fontsize) + if not hide_labels: + ax.set_xlabel('x-label', fontsize=fontsize) + ax.set_ylabel('y-label', fontsize=fontsize) + ax.set_title('Title', fontsize=fontsize) return pcm @@ -555,3 +556,34 @@ def test_align_labels(): after_align[1].x0, rtol=0, atol=1e-05) # ensure labels do not go off the edge assert after_align[0].x0 >= 1 + + +def test_compressed1(): + fig, axs = plt.subplots(3, 2, constrained_layout={'compress': True}, + sharex=True, sharey=True) + for ax in axs.flat: + pc = ax.imshow(np.random.randn(20, 20)) + + fig.colorbar(pc, ax=axs) + fig.draw_no_output() + + pos = axs[0, 0].get_position() + np.testing.assert_allclose(pos.x0, 0.2244, atol=1e-3) + pos = axs[0, 1].get_position() + np.testing.assert_allclose(pos.x1, 0.6925, atol=1e-3) + + # wider than tall + fig, axs = plt.subplots(2, 3, constrained_layout={'compress': True}, + 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_no_output() + + pos = axs[0, 0].get_position() + np.testing.assert_allclose(pos.x0, 0.06195, atol=1e-3) + np.testing.assert_allclose(pos.y1, 0.8413, atol=1e-3) + pos = axs[1, 2].get_position() + np.testing.assert_allclose(pos.x1, 0.832587, atol=1e-3) + np.testing.assert_allclose(pos.y0, 0.205377, atol=1e-3) diff --git a/tutorials/intermediate/constrainedlayout_guide.py b/tutorials/intermediate/constrainedlayout_guide.py index 3458667aef4e..c4f74aeb660a 100644 --- a/tutorials/intermediate/constrainedlayout_guide.py +++ b/tutorials/intermediate/constrainedlayout_guide.py @@ -52,6 +52,7 @@ import matplotlib.pyplot as plt import matplotlib.colors as mcolors import matplotlib.gridspec as gridspec +from matplotlib.tests.test_constrainedlayout import example_pcolor import numpy as np plt.rcParams['savefig.facecolor'] = "0.8" @@ -230,8 +231,67 @@ def example_plot(ax, fontsize=12, hide_labels=False): # :align: center # +############################################################################## +# Grids of fixed-aspect axes +# ========================== +# +# Often we want to layout axes with fixed-aspect ratios. This adds an extra +# constraint to the layout problem, which by default is solved by leaving +# one dimension with large white space between axes: + +fig, axs = plt.subplots(2, 2, constrained_layout=True, figsize=(6, 3)) +for ax in axs.flat: + pc = example_pcolor(ax, hide_labels=True) + ax.set_aspect(1) +fig.colorbar(pc, ax=axs) +fig.suptitle('Fixed-aspect axes') + +################################## +# Now, we could change the size of the figure manually to improve the +# whitespace, but that requires manual intervention. +# To address this, we can set ``constrained_layout`` to "compress" the +# axes: +fig, axs = plt.subplots(2, 2, constrained_layout={'compress': True}, + figsize=(6, 3), sharex=True, sharey=True) +for ax in axs.flat: + pc = example_pcolor(ax, hide_labels=True) + ax.set_aspect(1) +fig.colorbar(pc, ax=axs) +fig.suptitle('Fixed-aspect axes') + +################################### +# Note this works in the vertical direction as well, though the +# suptitle stays at the top of the plot: +fig, axs = plt.subplots(2, 2, constrained_layout={'compress': True}, + figsize=(3, 5), sharex=True, sharey=True) +for ax in axs.flat: + pc = example_pcolor(ax, hide_labels=True) + ax.set_aspect(1) +fig.colorbar(pc, ax=axs) +fig.suptitle('Fixed-aspect axes') + +################################### +# Note if only one row of axes have a fixed aspect, there can still be +# the need for manually adjusting the figure size, however, in this case +# widening the figure will make the layout look good again (not shown here) + +fig, axs = plt.subplots(2, 2, constrained_layout={'compress': True}, + figsize=(4, 6), sharex=True, sharey=True) +for i in range(2): + for j in range(2): + ax = axs[i, j] + pc = example_pcolor(ax, hide_labels=True) + if i == 0: + ax.set_title('asp=1') + ax.set_aspect(1) + else: + ax.set_title('asp="auto"') +fig.colorbar(pc, ax=axs) +fig.suptitle('Fixed-aspect axes') +plt.show() + ############################################################################### -# Padding and Spacing +# Padding and spacing # =================== # # Padding between axes is controlled in the horizontal by *w_pad* and