Skip to content

ENH: box aspect for axes #14917

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
Oct 1, 2019

Conversation

ImportanceOfBeingErnest
Copy link
Member

@ImportanceOfBeingErnest ImportanceOfBeingErnest commented Jul 30, 2019

PR Summary

Matplotlib axes' aspect refers to the data, i.e. it defines the ratio of vertical vs. horizontal data units. However, it seems people often (mis)use it in order to set the aspect of the axes box, which is of course possible by knowing the limits of the data and using adjustable="box". But it inevitably leads to problems, e.g.

In short, sometimes people just want to make a square plot.

So it seems useful to introduce a box_aspect parameter, which sets the aspect (i.e. the ratio between height and width) of the axes box, independent of the data.

Some usecases:

A. A square axes, independent of data

fig1, ax = plt.subplots()

ax.set_xlim(300,400)
ax.set_box_aspect(1)

fig1

B. Shared square axes

fig2, (ax, ax2) = plt.subplots(ncols=2, sharey=True)

ax.plot([0,10])
ax2.plot([10,20])

ax.set_box_aspect(1)
ax2.set_box_aspect(1)

fig2

C. Square twin axes

fig3, ax = plt.subplots()

ax2 = ax.twinx()

ax.plot([0,10])
ax2.plot([12,10])

ax.set_box_aspect(1)

fig3

D. Normal plot next to image (works with constrained_layout)

fig4, (ax, ax2) = plt.subplots(ncols=2, constrained_layout=True)

im = np.random.rand(16,27)
ax.imshow(im)

ax2.plot([23,45])
ax2.set_box_aspect(im.shape[0]/im.shape[1])

fig4

E. Square joint/marginal plot

fig5, axs = plt.subplots(2,2, sharex="col", sharey="row", 
                        gridspec_kw = dict(height_ratios=[1,3], 
                                           width_ratios=[3,1]))
axs[0,1].set_visible(False)
axs[0,0].set_box_aspect(1/3)
axs[1,0].set_box_aspect(1)
axs[1,1].set_box_aspect(3/1)

x,y = np.random.randn(2,400) * np.array([[.5],[180]])
axs[1,0].scatter(x,y)
axs[0,0].hist(x)
axs[1,1].hist(y, orientation="horizontal")

fig5

F. Equal data aspect, unequal box aspect

fig6, ax = plt.subplots()

ax.add_patch(plt.Circle((5,3), 1))
ax.set_aspect("equal", adjustable="datalim")
ax.set_box_aspect(0.5)
ax.autoscale()

fig6

Issues:

  • As @jklymak already pointed out, there might be a problem with layout managers, and indeed at least case E. from above fails with contrained_layout. (fixed by FIX constrained_layout w/ hidden axes #14919)

  • Obviously a fixed box_aspect only makes sense with adjustable="datalim". Since the order in which box_aspect and adjustable are set is arbitrary, this still needs to define in which cases to error out, or silently fall back.

PR Checklist

  • Has Pytest style unit tests
  • Code is finally Flake 8 compliant
  • New features are documented, with examples if plot related
  • Documentation is sphinx and numpydoc compliant
  • Added an entry to doc/users/next_whats_new/ if major new feature (follow instructions in README.rst there)

@jklymak
Copy link
Member

jklymak commented Jul 30, 2019

Seems cool. But I think we need to sit down and think about all the axes sizing and sharing options a bit more holistically.

I think if you change the non-original position then you can keep constrained layout working.

@ImportanceOfBeingErnest
Copy link
Member Author

I think if you change the non-original position then you can keep constrained layout working.

I would have thought that is what I'm doing in line 1527. Will need to check this at some later point.

[..] we need to sit down and think about all the axes sizing and sharing options a bit more holistically.

Sure, that's why I put it out in its current state, to see what is possible and to define some working use cases. In general what's still missing is a sharing of geometries (as opposed to sharing of data); or maybe call it a twinning across subplots.

@jklymak
Copy link
Member

jklymak commented Jul 30, 2019

Great, then I'm surprised there are issues with constrained_layout since its no different than a data-aspect constrained axes. constrained_layout only cares about the "original" size.

@jklymak
Copy link
Member

jklymak commented Jul 30, 2019

Thanks - that was a bug: #14918, and solution: #14919

@anntzer
Copy link
Contributor

anntzer commented Jul 30, 2019

I think it's a great idea :)
I believe this is mutually exclusive with setting the old data aspect? If so, perhaps this should be exposed in the same API, e.g. set_aspect(aspect, "data"/"axes") (the second argument defaulting to "data" which maintains backcompat) and internally stored in the same attribute? The point being that after

ax.set_aspect(a, "data")
ax.set_aspect(b, "axes")

it's not too surprising if the second call overwrites the first one, whereas in

ax.set_aspect(a)
ax.set_box_aspect(b)

... do we want the second to overwrite the first one? do we want to add a check and error out? or whatnot.

@ImportanceOfBeingErnest
Copy link
Member Author

No, the usual (data-)aspect and the box aspect are not mutually exclusive. In fact using both may make perfect sense, as shown in case F.. (It's just that adjustable cannot be "box" in case of using a box aspect.)

@anntzer
Copy link
Contributor

anntzer commented Jul 30, 2019

Ah, thanks for the clarification, I missed that; ignore the nonsense I wrote above.

@jklymak
Copy link
Member

jklymak commented Jul 30, 2019

Some things to consider: What happens if the user manually sets the position and the box_aspect?

ax.set_position([0.1, 0.1, 0.9, 0.2])
ax.set_box_aspect(1.)

For constrained_layout, the set_position method takes the axes out of the automatic layout because we assume the user wants that position set. But I indicate that just by setting the layout boxes associated with the axes to None in ax.set_position, which you probably don't want to depend on here.

I'm not sure which behaviour you want here, but you'll need to decide which call trumps which here. Even more tricky is

ax.set_box_aspect(1.)
ax.set_position([0.1, 0.1, 0.9, 0.2])

@ImportanceOfBeingErnest
Copy link
Member Author

The order of setting position and box_aspect isn't relevant. But I added a test to make sure that's the case.

@andrzejnovak
Copy link
Contributor

Just so it doesn't get lost from #15010 , I would really like if it was possible to choose this behaviour as a default setting in rcParams.

@ImportanceOfBeingErnest
Copy link
Member Author

ImportanceOfBeingErnest commented Aug 9, 2019

I mentionned my problem with an rc parameter for this in #15001 (comment):

if it's to be set at init time of any axes, it would sure cause problems e.g. with colorbar axes [also: Widget axes, insets etc.] and also the interplay between the adjustable parameter would need to be sorted before the axes is drawn.

I could imagine though to let this be taken as argument by the axes' init, to allow for something like

fig, axs = plt.subplots(2,2, subplot_kw=dict(box_aspect=1))

@andrzejnovak
Copy link
Contributor

I think that's a good compromise. It could be made to work by only applying it to the first axes. append_axes, add_subplot and add_axes would check if there already is one in the figure and go back to auto, but I could see why that would not be very transparent.

@tacaswell
Copy link
Member

Power cycled to try to re-run against master and then belated realized this won't help with circle...

Half of the warnings were from the required target not being hit, pushed a commit making sure they are generated (we do not auto-doc Axes).

@tacaswell
Copy link
Member

ok, I understand the other half of the errors.

we auto-generate and insert

    .. table::
       :class: property-table

       =======================================================================================   =====================================================================================================
       Property                                                                                  Description                                                                                          
       =======================================================================================   =====================================================================================================
       :meth:`adjustable <matplotlib.axes._base._AxesBase.set_adjustable>`                       {'box', 'datalim'}                                                                                   
       :meth:`agg_filter <matplotlib.artist.Artist.set_agg_filter>`                              a filter function, which takes a (m, n, 3) float array and a dpi value, and returns a (m, n, 3) array
       :meth:`alpha <matplotlib.artist.Artist.set_alpha>`                                        float or None                                                                                        
       :meth:`anchor <matplotlib.axes._base._AxesBase.set_anchor>`                               2-tuple of floats or {'C', 'SW', 'S', 'SE', ...}                                                     
       :meth:`animated <matplotlib.artist.Artist.set_animated>`                                  bool                                                                                                 
       :meth:`aspect <matplotlib.axes._base._AxesBase.set_aspect>`                               {'auto', 'equal'} or num                                                                             
       :meth:`autoscale_on <matplotlib.axes._base._AxesBase.set_autoscale_on>`                   bool                                                                                                 
       :meth:`autoscalex_on <matplotlib.axes._base._AxesBase.set_autoscalex_on>`                 bool                                                                                                 
       :meth:`autoscaley_on <matplotlib.axes._base._AxesBase.set_autoscaley_on>`                 bool                                                                                                 
       :meth:`axes_locator <matplotlib.axes._base._AxesBase.set_axes_locator>`                   Callable[[Axes, Renderer], Bbox]                                                                     
       :meth:`axisbelow <matplotlib.axes._base._AxesBase.set_axisbelow>`                         bool or 'line'                                                                                       
       :meth:`box_aspect <matplotlib.axes._base._AxesBase.set_box_aspect>`                       None, or a number                                                                                    
       :meth:`clip_box <matplotlib.artist.Artist.set_clip_box>`                                  `.Bbox`                                                                                              
       :meth:`clip_on <matplotlib.artist.Artist.set_clip_on>`                                    bool                                                                                                 
       :meth:`clip_path <matplotlib.artist.Artist.set_clip_path>`                                [(`~matplotlib.path.Path`, `.Transform`) | `.Patch` | None]                                          
       :meth:`contains <matplotlib.artist.Artist.set_contains>`                                  callable                                                                                             
       :meth:`facecolor <matplotlib.axes._base._AxesBase.set_facecolor>`                         color                                                                                                
       :meth:`fc <matplotlib.axes._base._AxesBase.set_facecolor>`                                color                                                                                                
       :meth:`figure <matplotlib.axes._base._AxesBase.set_figure>`                               `.Figure`                                                                                            
       :meth:`frame_on <matplotlib.axes._base._AxesBase.set_frame_on>`                           bool                                                                                                 
       :meth:`gid <matplotlib.artist.Artist.set_gid>`                                            str                                                                                                  
       :meth:`in_layout <matplotlib.artist.Artist.set_in_layout>`                                bool                                                                                                 
       :meth:`label <matplotlib.artist.Artist.set_label>`                                        object                                                                                               
       :meth:`navigate <matplotlib.axes._base._AxesBase.set_navigate>`                           bool                                                                                                 
       :meth:`navigate_mode <matplotlib.axes._base._AxesBase.set_navigate_mode>`                 unknown                                                                                              
       :meth:`path_effects <matplotlib.artist.Artist.set_path_effects>`                          `.AbstractPathEffect`                                                                                
       :meth:`picker <matplotlib.artist.Artist.set_picker>`                                      None or bool or float or callable                                                                    
       :meth:`position <matplotlib.axes._base._AxesBase.set_position>`                           [left, bottom, width, height] or `~matplotlib.transforms.Bbox`                                       
       :meth:`prop_cycle <matplotlib.axes._base._AxesBase.set_prop_cycle>`                       unknown                                                                                              
       :meth:`rasterization_zorder <matplotlib.axes._base._AxesBase.set_rasterization_zorder>`   float or None                                                                                        
       :meth:`rasterized <matplotlib.artist.Artist.set_rasterized>`                              bool or None                                                                                         
       :meth:`sketch_params <matplotlib.artist.Artist.set_sketch_params>`                        (scale: float, length: float, randomness: float)                                                     
       :meth:`snap <matplotlib.artist.Artist.set_snap>`                                          bool or None                                                                                         
       :meth:`title <matplotlib.axes._axes.Axes.set_title>`                                      str                                                                                                  
       :meth:`transform <matplotlib.artist.Artist.set_transform>`                                `.Transform`                                                                                         
       :meth:`url <matplotlib.artist.Artist.set_url>`                                            str                                                                                                  
       :meth:`visible <matplotlib.artist.Artist.set_visible>`                                    bool                                                                                                 
       :meth:`xbound <matplotlib.axes._base._AxesBase.set_xbound>`                               unknown                                                                                              
       :meth:`xlabel <matplotlib.axes._axes.Axes.set_xlabel>`                                    str                                                                                                  
       :meth:`xlim <matplotlib.axes._base._AxesBase.set_xlim>`                                   (left: float, right: float)                                                                          
       :meth:`xmargin <matplotlib.axes._base._AxesBase.set_xmargin>`                             float greater than -0.5                                                                              
       :meth:`xscale <matplotlib.axes._base._AxesBase.set_xscale>`                               {"linear", "log", "symlog", "logit", ...}                                                            
       :meth:`xticklabels <matplotlib.axes._base._AxesBase.set_xticklabels>`                     List[str]                                                                                            
       :meth:`xticks <matplotlib.axes._base._AxesBase.set_xticks>`                               unknown                                                                                              
       :meth:`ybound <matplotlib.axes._base._AxesBase.set_ybound>`                               unknown                                                                                              
       :meth:`ylabel <matplotlib.axes._axes.Axes.set_ylabel>`                                    str                                                                                                  
       :meth:`ylim <matplotlib.axes._base._AxesBase.set_ylim>`                                   (bottom: float, top: float)                                                                          
       :meth:`ymargin <matplotlib.axes._base._AxesBase.set_ymargin>`                             float greater than -0.5                                                                              
       :meth:`yscale <matplotlib.axes._base._AxesBase.set_yscale>`                               {"linear", "log", "symlog", "logit", ...}                                                            
       :meth:`yticklabels <matplotlib.axes._base._AxesBase.set_yticklabels>`                     List[str]                                                                                            
       :meth:`yticks <matplotlib.axes._base._AxesBase.set_yticks>`                               unknown                                                                                              
       :meth:`zorder <matplotlib.artist.Artist.set_zorder>`                                      float                                                                                                
       =======================================================================================   =====================================================================================================


into a bunch of docstrings which points to the base class (because it asks the methods what class they belong to). Will push a commit 'fixing' this soon...

@anntzer
Copy link
Contributor

anntzer commented Sep 11, 2019

I think this is basically good to go.
Minor nit: perhaps name this "axes_aspect"? consistently with transAxes, with the places that accept "axes"/"data" as transform specifiers, etc. Obviously it's a bit(...) late to rename the classic "aspect" to "data_aspect", though...

@ImportanceOfBeingErnest
Copy link
Member Author

I would agree that if we had .set_data_aspect, naming this .set_axes_aspect would be ideal. But given that the data aspect is by now commonly known as "the aspect", it is important to distinguish clearly.

In general we have the semantics of object.set_property(...) throughout the code, which reads as "set the object's property to ...". In the case of the aspect, axes.set_aspect, needs to read "set the axes' aspect to ...". Following this, axes.set_axes_aspect would read "set the axes' axes aspect to" in which case there is little indication for that to be different from "set the axes' aspect to"; and that would be the data aspect.

I therefore find .set_box_aspect much clearer: The axes has an aspect (which is related to data), and it has a box-aspect (which is related to its box).

@jklymak
Copy link
Member

jklymak commented Sep 11, 2019

OK, I'll check later, but what if you set two axes to have different aspect ratio, and use a layout manager? I'm just a little concerned there are all sorts of edge cases here that will cause problems, but I may be completely incorrect.

Copy link
Member

@jklymak jklymak left a comment

Choose a reason for hiding this comment

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

Actually because this works on the active position of the axes, it works fine w/ the layout managers. Or at least I couldn't easily break it.

@jklymak
Copy link
Member

jklymak commented Sep 11, 2019

I'll merge tomorrow, if no one else does, or @ImportanceOfBeingErnest you can self-merge if we give this a day or so to make sure there are no further objections.

@jklymak jklymak merged commit 805cb72 into matplotlib:master Oct 1, 2019
@jklymak
Copy link
Member

jklymak commented Oct 1, 2019

Thanks @ImportanceOfBeingErnest I think this will be a pretty valuable tool in the axes shaping toolbox!

@ImportanceOfBeingErnest ImportanceOfBeingErnest deleted the box-aspect branch October 3, 2019 01:25
tacaswell added a commit to tacaswell/matplotlib that referenced this pull request May 30, 2020
The way that bbox_inches='tight' is implemented we need to ensure that
we do not try to adjust the aspect during the draw (because we have
temporarily de-coupled the reported figure size from the transforms
which results in the being distorted).  Previously we did not have a
way to fix the aspect ratio in screen space of the Axes (only the
aspect ratio in dataspace) however in 3.3 we gained this ability for
both Axes (matplotlib#14917) and Axes3D (matplotlib#8896 / matplotlib#16472).

Rather than add an aspect value to `set_aspect` to handle this case,
in the tight_bbox code we monkey-patch the `apply_aspect` method with
a no-op function and then restore it when we are done.  Previously we
would set the aspect to "auto" and restore it in the same places.

closes matplotlib#16463.
tacaswell added a commit to tacaswell/matplotlib that referenced this pull request Jun 4, 2020
The way that bbox_inches='tight' is implemented we need to ensure that
we do not try to adjust the aspect during the draw (because we have
temporarily de-coupled the reported figure size from the transforms
which results in the being distorted).  Previously we did not have a
way to fix the aspect ratio in screen space of the Axes (only the
aspect ratio in dataspace) however in 3.3 we gained this ability for
both Axes (matplotlib#14917) and Axes3D (matplotlib#8896 / matplotlib#16472).

Rather than add an aspect value to `set_aspect` to handle this case,
in the tight_bbox code we monkey-patch the `apply_aspect` method with
a no-op function and then restore it when we are done.  Previously we
would set the aspect to "auto" and restore it in the same places.

closes matplotlib#16463.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic: geometry manager LayoutEngine, Constrained layout, Tight layout topic: ticks axis labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants