Skip to content

the limits of axes are inexact with mplot3d #18052

Closed

Description

@ultimatile

I take an example from
https://stackoverflow.com/questions/23951230/python-axis-limit-in-matplotlib

In mplot3d, when you set, for example, set_xlim(0, 0.8), the outcome has an extra like set_xlim(0 - eps, 0.8 + eps), where eps is related to deltas.

deltas = (maxs - mins) / 12.

To show the exact lims, you have to change deltas to, for example, 0 * (maxs - mins) / 12.

You need something to set exact lims without touching the inside of matplotlib.

Code for reproduction

from mpl_toolkits.mplot3d import Axes3D
import matplotlib.pyplot as plt
fig = plt.figure()
ax = fig.gca(projection = '3d')
ax.set_xlim(0,0.8)
plt.show()

Actual outcome
fig

Matplotlib version

  • Operating system:macOS 10.14.6
  • Matplotlib version: 3.1.1
  • Python version: 3.7.4

The problem was discussed many times in STACK OVERFLOW:
https://stackoverflow.com/questions/23951230/python-axis-limit-in-matplotlib
https://stackoverflow.com/questions/46380464/matplotlib-3d-axis-bounds-always-too-large-doesnt-set-lims-correctly
https://stackoverflow.com/questions/16488182/removing-axes-margins-in-3d-plot

Activity

  1. timhoffm commented on Jul 25, 2020

    @timhoffm
    Member

    The relevant code is

    mins = mins - deltas / 4.

    which auto-expands the bounds. We actually want auto-expansion for automatically determined bounds, but not for user bounds.

    👉 Things to do:

    • Check how the expansion is done in 2D. (I assume it works slightly differently and the 3D code was added ad-hoc).
    • Implement a way to not expand the bounds if they were set by the user.
    • There seems to be an interference between the grid line and the axis line if the axis limits were set explicitly. For below image, I've deactivated the expansion code above and used set_xlim. Note that the xaxis line is blurry.
      image
  2. added
    Difficulty: Mediumhttps://matplotlib.org/devdocs/devel/contribute.html#good-first-issues
    API: changesChanges to the public API, typically requiring deprecation.
    API: consistencyConsistency of the matplotlib API, including naming, behavior, defaults, …
    on Jul 25, 2020
  3. neil-ptr commented on Aug 3, 2020

    @neil-ptr
    Contributor

    I'd be interested in looking into this

  4. added this to the v3.4.0 milestone on Aug 3, 2020
  5. tacaswell commented on Aug 3, 2020

    @tacaswell
    Member

    Great @Neilzon ! Please let us know if you have any questions (either here or on https://gitter.im/matplotlib/matplotlib )

  6. modified the milestones: v3.4.0, v3.5.0 on Jan 27, 2021
  7. kentcr commented on Mar 3, 2021

    @kentcr
    Contributor
    • Check how the expansion is done in 2D. (I assume it works slightly differently and the 3D code was added ad-hoc).

    A default plot immediately sets both _stale_viewlim_* variables to True:

    self._request_autoscale_view(scalex=scalex, scaley=scaley)

    def _request_autoscale_view(self, tight=None, scalex=True, scaley=True):
    if tight is not None:
    self._tight = tight
    if scalex:
    self._stale_viewlim_x = True # Else keep old state.
    if scaley:
    self._stale_viewlim_y = True

    A draw calls _unstale_viewLim():

    self._unstale_viewLim()

    The _stale_viewlim_* values cause it to get autoscaled:

    def _unstale_viewLim(self):
    # We should arrange to store this information once per share-group
    # instead of on every axis.
    scalex = any(ax._stale_viewlim_x
    for ax in self._shared_x_axes.get_siblings(self))
    scaley = any(ax._stale_viewlim_y
    for ax in self._shared_y_axes.get_siblings(self))
    if scalex or scaley:
    for ax in self._shared_x_axes.get_siblings(self):
    ax._stale_viewlim_x = False
    for ax in self._shared_y_axes.get_siblings(self):
    ax._stale_viewlim_y = False
    self.autoscale_view(scalex=scalex, scaley=scaley)

    This inner function is called for each, which does the (much more detailed) 2D bound calculations:

    def handle_single_axis(scale, autoscaleon, shared_axes, interval,
    minpos, axis, margin, stickies, set_bound):
    if not (scale and autoscaleon):
    return # nothing to do...
    shared = shared_axes.get_siblings(self)
    # Base autoscaling on finite data limits when there is at least one
    # finite data limit among all the shared_axes and intervals.
    # Also, find the minimum minpos for use in the margin calculation.
    x_values = []
    minimum_minpos = np.inf
    for ax in shared:
    x_values.extend(getattr(ax.dataLim, interval))
    minimum_minpos = min(minimum_minpos,
    getattr(ax.dataLim, minpos))
    x_values = np.extract(np.isfinite(x_values), x_values)
    if x_values.size >= 1:
    x0, x1 = (x_values.min(), x_values.max())
    else:
    x0, x1 = (-np.inf, np.inf)
    # If x0 and x1 are non finite, use the locator to figure out
    # default limits.
    locator = axis.get_major_locator()
    x0, x1 = locator.nonsingular(x0, x1)
    # Prevent margin addition from crossing a sticky value. A small
    # tolerance must be added due to floating point issues with
    # streamplot; it is defined relative to x0, x1, x1-x0 but has
    # no absolute term (e.g. "+1e-8") to avoid issues when working with
    # datasets where all values are tiny (less than 1e-8).
    tol = 1e-5 * max(abs(x0), abs(x1), abs(x1 - x0))
    # Index of largest element < x0 + tol, if any.
    i0 = stickies.searchsorted(x0 + tol) - 1
    x0bound = stickies[i0] if i0 != -1 else None
    # Index of smallest element > x1 - tol, if any.
    i1 = stickies.searchsorted(x1 - tol)
    x1bound = stickies[i1] if i1 != len(stickies) else None
    # Add the margin in figure space and then transform back, to handle
    # non-linear scales.
    transform = axis.get_transform()
    inverse_trans = transform.inverted()
    x0, x1 = axis._scale.limit_range_for_scale(x0, x1, minimum_minpos)
    x0t, x1t = transform.transform([x0, x1])
    delta = (x1t - x0t) * margin
    if not np.isfinite(delta):
    delta = 0 # If a bound isn't finite, set margin to zero.
    x0, x1 = inverse_trans.transform([x0t - delta, x1t + delta])
    # Apply sticky bounds.
    if x0bound is not None:
    x0 = max(x0, x0bound)
    if x1bound is not None:
    x1 = min(x1, x1bound)
    if not self._tight:
    x0, x1 = locator.view_limits(x0, x1)
    set_bound(x0, x1)
    # End of definition of internal function 'handle_single_axis'.
    handle_single_axis(
    scalex, self._autoscaleXon, self._shared_x_axes, 'intervalx',
    'minposx', self.xaxis, self._xmargin, x_stickies, self.set_xbound)
    handle_single_axis(
    scaley, self._autoscaleYon, self._shared_y_axes, 'intervaly',
    'minposy', self.yaxis, self._ymargin, y_stickies, self.set_ybound)

    If you call, e.g., set_xlim the _stale_viewlim_x gets set to False which prevents the above:

    # Mark viewlims as no longer stale without triggering an autoscale.
    for ax in self._shared_x_axes.get_siblings(self):
    ax._stale_viewlim_x = False

    Meanwhile, axes3d.py has an identical-but-for-z _unstale_viewLim function, but its autoscale_view is wildly different. Normally I'd think it'd be desirable to make the code as dimension-agnostic as possible and share code between them, but I haven't noticed a lot of precedence for that. Is it avoided for some reason?

  8. m4reko commented on Mar 7, 2021

    @m4reko
    Contributor

    We are a group of students that have looked into this for a while as part of a course.

    Thank you @kentcr for some excellent documentation of the 2D-case. It helped us a lot.


    • Implement a way to not expand the bounds if they were set by the user.

    Actually, it seems that this is already done in the autoscale_view and the only step needed to get the desired behaviour is to remove these lines:

    mins = mins - deltas / 4.
    maxs = maxs + deltas / 4.

    The autoscale_view adds margins (_xmargin, _ymargin, _zmargin) with to the same logic as in the 2D case. It's a bit of a mystery to us as to why these deltas where added in the beginning. We ran the whole test suite with those lines removed and looked through all the generated images in result_images and it seems to behave properly apart from this:

    • There seems to be an interference between the grid line and the axis line if the axis limits were set explicitly. For below image, I've deactivated the expansion code above and used set_xlim. Note that the xaxis line is blurry.

    This problem is not correlating with setting the xlim manually but it happens every time a gridline is aligned with the xaxis (only the xaxis however, not the other ones). We tried to solve this problem both by experimenting with the zorder and the order in which the different components of the axis (gridlines, ticks, spines) are drawn but could not find a solution. Does anybody have an idea of what is happening here?

  9. kentcr commented on Mar 8, 2021

    @kentcr
    Contributor

    Does anybody have an idea of what is happening here?

    It's a y-tick being drawn. If you clear the y-ticks, it disappears:

    import matplotlib.pyplot as plt
    from mpl_toolkits.mplot3d import Axes3D
    fig = plt.figure()
    ax = fig.gca(projection=Axes3D.name)
    ax.set_yticks([])
    ax.set_xlim(0, 0.8)
    plt.show()

    Figure_1

    Further, if you remove the first tick in each axis here...

    ticks = self._update_ticks()

    e.g.

            ticks = self._update_ticks()[1:]

    ...it fixes this problem, but you lose the gridline where the background planes intersect:
    Figure_2

    I'd initially hoped to just change _update_ticks() for the y case, but it's actually inherited from the 2D class:

    class Axis(maxis.XAxis):

    from matplotlib import (
    artist, lines as mlines, axis as maxis, patches as mpatches, rcParams)

    class XAxis(Axis):

    def _update_ticks(self):

    It does seem possible to just change the ticks just for the y-axis, but the obvious ways don't seem very elegant to me. Of course, it's also rather fishy that the 3D YAxis and ZAxis classes are inheriting from the 2D XAxis class, so maybe elegance is a lost cause without more significant changes.

    It may also be that the ticks are actually fine and it's some lower-level rendering issue, but I haven't dug that far yet.

    Edit: I should say it's actually clear that the ticks are fine, but the gridlines (which are generated from the ticks and put in the xyz0 variable) may also be fine. I haven't dug into whether or not they are drawn when overlapping with the axis on purpose.

  10. modified the milestones: v3.5.0, v3.6.0 on Sep 25, 2021
  11. modified the milestones: v3.6.0, v3.7.0 on Aug 24, 2022
  12. modified the milestones: v3.7.0, v3.7.1 on Feb 14, 2023
  13. modified the milestones: v3.7.1, future releases on Mar 4, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    API: changesChanges to the public API, typically requiring deprecation.API: consistencyConsistency of the matplotlib API, including naming, behavior, defaults, …Difficulty: Mediumhttps://matplotlib.org/devdocs/devel/contribute.html#good-first-issuesstatus: confirmed bugtopic: mplot3d

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions