How to set the 'equal' aspect ratio for all axes (x, y, z)
Asked Answered
E

11

117

When I set up an equal aspect ratio for a 3d graph, the z-axis does not change to 'equal'. So this:

fig = pylab.figure()
mesFig = fig.gca(projection='3d', adjustable='box')
mesFig.axis('equal')
mesFig.plot(xC, yC, zC, 'r.')
mesFig.plot(xO, yO, zO, 'b.')
pyplot.show()

Gives me the following:

img1

Where obviously the unit length of z-axis is not equal to x- and y- units.

How can I make the unit length of all three axes equal? All the solutions I found did not work.

Ezechiel answered 3/12, 2012 at 14:33 Comment(2)
As of matplotlib 3.3.0, it is recommended to set_box_aspect(). See the newer answers below.Plum
With a single (functional) line edit I just repaired the top answer to use set_box_aspect so that it works with matplotlib 3.3.0 and later.Kussell
C
81

I believe matplotlib does not yet set correctly equal axis in 3D... But I found a trick some times ago (I don't remember where) that I've adapted using it. The concept is to create a fake cubic bounding box around your data. You can test it with the following code:

from mpl_toolkits.mplot3d import Axes3D
from matplotlib import cm
import matplotlib.pyplot as plt
import numpy as np

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.set_aspect('equal')

X = np.random.rand(100)*10+5
Y = np.random.rand(100)*5+2.5
Z = np.random.rand(100)*50+25

scat = ax.scatter(X, Y, Z)

# Create cubic bounding box to simulate equal aspect ratio
max_range = np.array([X.max()-X.min(), Y.max()-Y.min(), Z.max()-Z.min()]).max()
Xb = 0.5*max_range*np.mgrid[-1:2:2,-1:2:2,-1:2:2][0].flatten() + 0.5*(X.max()+X.min())
Yb = 0.5*max_range*np.mgrid[-1:2:2,-1:2:2,-1:2:2][1].flatten() + 0.5*(Y.max()+Y.min())
Zb = 0.5*max_range*np.mgrid[-1:2:2,-1:2:2,-1:2:2][2].flatten() + 0.5*(Z.max()+Z.min())
# Comment or uncomment following both lines to test the fake bounding box:
for xb, yb, zb in zip(Xb, Yb, Zb):
   ax.plot([xb], [yb], [zb], 'w')

plt.grid()
plt.show()

z data are about an order of magnitude larger than x and y, but even with equal axis option, matplotlib autoscale z axis:

bad

But if you add the bounding box, you obtain a correct scaling:

enter image description here

Charcot answered 4/12, 2012 at 11:21 Comment(6)
In this case you do not even need the equal statement - it will be always equal.Ezechiel
This works fine if you are plotting only one set of data but what about when there are more data sets all on the same 3d plot? In question, there were 2 data sets so it's a simple thing to combine them but that could get unreasonable quickly if plotting several different data sets.Forewarn
@stvn66, I was plotting up to five data sets in one graph with this solutions and it worked fine for me.Ezechiel
This works perfectly. For those who want this in function form, which takes an axis object and performs the operations above, I encourage them to check out @Kussell answer below. It is a slightly cleaner solution.Antipole
@Ezechiel -- I found this did not work for me without the equal statement.Fruitage
After I updated anaconda, ax.set_aspect("equal") reported error: NotImplementedError: It is not currently possible to manually set the aspect on 3D axesBritnibrito
K
87

I like some of the previously posted solutions, but they do have the drawback that you need to keep track of the ranges and means over all your data. This could be cumbersome if you have multiple data sets that will be plotted together. To fix this, I made use of the ax.get_[xyz]lim3d() methods and put the whole thing into a standalone function that can be called just once before you call plt.show(). Here is the new version:

from mpl_toolkits.mplot3d import Axes3D
from matplotlib import cm
import matplotlib.pyplot as plt
import numpy as np

def set_axes_equal(ax):
    """
    Make axes of 3D plot have equal scale so that spheres appear as spheres,
    cubes as cubes, etc.

    Input
      ax: a matplotlib axis, e.g., as output from plt.gca().
    """

    x_limits = ax.get_xlim3d()
    y_limits = ax.get_ylim3d()
    z_limits = ax.get_zlim3d()

    x_range = abs(x_limits[1] - x_limits[0])
    x_middle = np.mean(x_limits)
    y_range = abs(y_limits[1] - y_limits[0])
    y_middle = np.mean(y_limits)
    z_range = abs(z_limits[1] - z_limits[0])
    z_middle = np.mean(z_limits)

    # The plot bounding box is a sphere in the sense of the infinity
    # norm, hence I call half the max range the plot radius.
    plot_radius = 0.5*max([x_range, y_range, z_range])

    ax.set_xlim3d([x_middle - plot_radius, x_middle + plot_radius])
    ax.set_ylim3d([y_middle - plot_radius, y_middle + plot_radius])
    ax.set_zlim3d([z_middle - plot_radius, z_middle + plot_radius])

fig = plt.figure()
ax = fig.add_subplot(projection="3d")

# Use this for matplotlib prior to 3.3.0 only.
#ax.set_aspect("equal'")
#
# Use this for matplotlib 3.3.0 and later.
# https://github.com/matplotlib/matplotlib/pull/17515
ax.set_box_aspect([1.0, 1.0, 1.0])

X = np.random.rand(100)*10+5
Y = np.random.rand(100)*5+2.5
Z = np.random.rand(100)*50+25

scat = ax.scatter(X, Y, Z)

set_axes_equal(ax)
plt.show()
Kussell answered 12/7, 2015 at 4:13 Comment(4)
Be aware that using means as the center point won't work in all cases, you should use midpoints. See my comment on tauran's answer.Hannelorehanner
My code above does not take the mean of the data, it takes the mean of the existing plot limits. My function is thus guaranteed to keep in view any points that were in view according to the plot limits set before it was called. If the user has already set plot limits too restrictively to see all data points, that is a separate issue. My function allows more flexibility because you may want to view only a subset of the data. All I do is expand axis limits so the aspect ratio is 1:1:1.Kussell
Another way to put it: if you take a mean of only 2 points, namely the bounds on a single axis, then that mean IS the midpoint. So, as far as I can tell, Dalum's function below should be mathematically equivalent to mine and there was nothing to ``fix''.Kussell
Doesn't work in my case,. Obviously x and z scales are not identical. I get the same result even if I plot nothing. I suspect this is because matplotlib adds unequal padding to the subplot.Lot
C
81

I believe matplotlib does not yet set correctly equal axis in 3D... But I found a trick some times ago (I don't remember where) that I've adapted using it. The concept is to create a fake cubic bounding box around your data. You can test it with the following code:

from mpl_toolkits.mplot3d import Axes3D
from matplotlib import cm
import matplotlib.pyplot as plt
import numpy as np

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.set_aspect('equal')

X = np.random.rand(100)*10+5
Y = np.random.rand(100)*5+2.5
Z = np.random.rand(100)*50+25

scat = ax.scatter(X, Y, Z)

# Create cubic bounding box to simulate equal aspect ratio
max_range = np.array([X.max()-X.min(), Y.max()-Y.min(), Z.max()-Z.min()]).max()
Xb = 0.5*max_range*np.mgrid[-1:2:2,-1:2:2,-1:2:2][0].flatten() + 0.5*(X.max()+X.min())
Yb = 0.5*max_range*np.mgrid[-1:2:2,-1:2:2,-1:2:2][1].flatten() + 0.5*(Y.max()+Y.min())
Zb = 0.5*max_range*np.mgrid[-1:2:2,-1:2:2,-1:2:2][2].flatten() + 0.5*(Z.max()+Z.min())
# Comment or uncomment following both lines to test the fake bounding box:
for xb, yb, zb in zip(Xb, Yb, Zb):
   ax.plot([xb], [yb], [zb], 'w')

plt.grid()
plt.show()

z data are about an order of magnitude larger than x and y, but even with equal axis option, matplotlib autoscale z axis:

bad

But if you add the bounding box, you obtain a correct scaling:

enter image description here

Charcot answered 4/12, 2012 at 11:21 Comment(6)
In this case you do not even need the equal statement - it will be always equal.Ezechiel
This works fine if you are plotting only one set of data but what about when there are more data sets all on the same 3d plot? In question, there were 2 data sets so it's a simple thing to combine them but that could get unreasonable quickly if plotting several different data sets.Forewarn
@stvn66, I was plotting up to five data sets in one graph with this solutions and it worked fine for me.Ezechiel
This works perfectly. For those who want this in function form, which takes an axis object and performs the operations above, I encourage them to check out @Kussell answer below. It is a slightly cleaner solution.Antipole
@Ezechiel -- I found this did not work for me without the equal statement.Fruitage
After I updated anaconda, ax.set_aspect("equal") reported error: NotImplementedError: It is not currently possible to manually set the aspect on 3D axesBritnibrito
R
69

Simple fix!

I've managed to get this working in version 3.3.1.

It looks like this issue has perhaps been resolved in PR#17172; You can use the ax.set_box_aspect([1,1,1]) function to ensure the aspect is correct (see the notes for the set_aspect function). When used in conjunction with the bounding box function(s) provided by @karlo and/or @Matee Ulhaq, the plots now look correct in 3D!

matplotlib 3d plot with equal axes

Minimum Working Example

import matplotlib.pyplot as plt
import mpl_toolkits.mplot3d
import numpy as np

# Functions from @Mateen Ulhaq and @karlo
def set_axes_equal(ax: plt.Axes):
    """Set 3D plot axes to equal scale.

    Make axes of 3D plot have equal scale so that spheres appear as
    spheres and cubes as cubes.  Required since `ax.axis('equal')`
    and `ax.set_aspect('equal')` don't work on 3D.
    """
    limits = np.array([
        ax.get_xlim3d(),
        ax.get_ylim3d(),
        ax.get_zlim3d(),
    ])
    origin = np.mean(limits, axis=1)
    radius = 0.5 * np.max(np.abs(limits[:, 1] - limits[:, 0]))
    _set_axes_radius(ax, origin, radius)

def _set_axes_radius(ax, origin, radius):
    x, y, z = origin
    ax.set_xlim3d([x - radius, x + radius])
    ax.set_ylim3d([y - radius, y + radius])
    ax.set_zlim3d([z - radius, z + radius])

# Generate and plot a unit sphere
u = np.linspace(0, 2*np.pi, 100)
v = np.linspace(0, np.pi, 100)
x = np.outer(np.cos(u), np.sin(v)) # np.outer() -> outer vector product
y = np.outer(np.sin(u), np.sin(v))
z = np.outer(np.ones(np.size(u)), np.cos(v))

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.plot_surface(x, y, z)

ax.set_box_aspect([1,1,1]) # IMPORTANT - this is the new, key line
# ax.set_proj_type('ortho') # OPTIONAL - default is perspective (shown in image above)
set_axes_equal(ax) # IMPORTANT - this is also required
plt.show()
Raeleneraf answered 27/8, 2020 at 23:0 Comment(2)
ax.set_box_aspect([np.ptp(i) for i in data]) # equal aspect ratioPapua
AttributeError: 'Axes3DSubplot' object has no attribute 'set_box_aspect' as of matplotlib==3.2.2, but works on later versions including at least matplotlib==3.6.3Khoury
M
57

I simplified Remy F's solution by using the set_x/y/zlim functions.

from mpl_toolkits.mplot3d import Axes3D
from matplotlib import cm
import matplotlib.pyplot as plt
import numpy as np

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.set_aspect('equal')

X = np.random.rand(100)*10+5
Y = np.random.rand(100)*5+2.5
Z = np.random.rand(100)*50+25

scat = ax.scatter(X, Y, Z)

max_range = np.array([X.max()-X.min(), Y.max()-Y.min(), Z.max()-Z.min()]).max() / 2.0

mid_x = (X.max()+X.min()) * 0.5
mid_y = (Y.max()+Y.min()) * 0.5
mid_z = (Z.max()+Z.min()) * 0.5
ax.set_xlim(mid_x - max_range, mid_x + max_range)
ax.set_ylim(mid_y - max_range, mid_y + max_range)
ax.set_zlim(mid_z - max_range, mid_z + max_range)

plt.show()

enter image description here

Mooned answered 13/2, 2014 at 20:44 Comment(4)
I like the simplified code. Just be aware that some (very few) data points may not get plotted. For example, suppose that X=[0, 0, 0, 100] so that X.mean()=25. If max_range comes out to be 100 (from X), then you're x-range will be 25 +- 50, so [-25, 75] and you'll miss the X[3] data point. The idea is very nice though, and easy to modify to make sure you get all the points.Mozell
Beware that using means as the center is not correct. You should use something like midpoint_x = np.mean([X.max(),X.min()]) and then set the limits to midpoint_x +/- max_range. Using the mean only works if the mean is located at the midpoint of the dataset, which is not always true. Also, a tip: you can scale max_range to make the graph look nicer if there are points near or on the boundaries.Hannelorehanner
After I updated anaconda, ax.set_aspect("equal") reported error: NotImplementedError: It is not currently possible to manually set the aspect on 3D axesBritnibrito
Rather than calling set_aspect('equal'), use set_box_aspect([1,1,1]), as described in my answer below. It's working for me in matplotlib version 3.3.1!Raeleneraf
M
27

As of matplotlib 3.3.0, Axes3D.set_box_aspect seems to be the recommended approach.

import numpy as np

xs, ys, zs = <your data>
ax = <your axes>

# Option 1: aspect ratio is 1:1:1 in data space
ax.set_box_aspect((np.ptp(xs), np.ptp(ys), np.ptp(zs)))

# Option 2: aspect ratio 1:1:1 in view space
ax.set_box_aspect((1, 1, 1))
Markmarkdown answered 22/10, 2020 at 17:14 Comment(0)
S
23

Adapted from @karlo's answer to make things even cleaner:

def set_axes_equal(ax: plt.Axes):
    """Set 3D plot axes to equal scale.

    Make axes of 3D plot have equal scale so that spheres appear as
    spheres and cubes as cubes.  Required since `ax.axis('equal')`
    and `ax.set_aspect('equal')` don't work on 3D.
    """
    limits = np.array([
        ax.get_xlim3d(),
        ax.get_ylim3d(),
        ax.get_zlim3d(),
    ])
    origin = np.mean(limits, axis=1)
    radius = 0.5 * np.max(np.abs(limits[:, 1] - limits[:, 0]))
    _set_axes_radius(ax, origin, radius)

def _set_axes_radius(ax, origin, radius):
    x, y, z = origin
    ax.set_xlim3d([x - radius, x + radius])
    ax.set_ylim3d([y - radius, y + radius])
    ax.set_zlim3d([z - radius, z + radius])

Usage:

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.set_aspect('equal')         # important!

# ...draw here...

set_axes_equal(ax)             # important!
plt.show()

EDIT: This answer does not work on more recent versions of Matplotlib due to the changes merged in pull-request #13474, which is tracked in issue #17172 and issue #1077. As a temporary workaround to this, one can remove the newly added lines in lib/matplotlib/axes/_base.py:

  class _AxesBase(martist.Artist):
      ...

      def set_aspect(self, aspect, adjustable=None, anchor=None, share=False):
          ...

+         if (not cbook._str_equal(aspect, 'auto')) and self.name == '3d':
+             raise NotImplementedError(
+                 'It is not currently possible to manually set the aspect '
+                 'on 3D axes')
Sociable answered 3/6, 2018 at 7:55 Comment(4)
Love this, but after I updated anaconda, ax.set_aspect("equal") reported error: NotImplementedError: It is not currently possible to manually set the aspect on 3D axesBritnibrito
@Britnibrito I added some links at the bottom of my answer to help in investigation. It looks as if the MPL folks are breaking workarounds without properly fixing the issue for some reason. ¯\\_(ツ)_/¯Sociable
I think I found a workaround (that doesn't require modifying the source code) for the NotImplementedError (full description in my answer below); basically add ax.set_box_aspect([1,1,1]) before calling set_axes_equalRaeleneraf
Just found this post and tried, failed on ax.set_aspect('equal'). Not an issue though if you just remove ax.set_aspect('equal') from your script but keep the two custom functions set_axes_equal and _set_axes_radius...making sure to call them before the plt.show(). Great solution for me! I've been searching for some time over a couple of years, finally. I've always reverted to python's vtk module for 3D plotting, especially when the number of things gets extreme.Signorelli
H
9

As of matplotlib 3.6.0, this feature has been added with the command ax.set_aspect('equal'). Other options are 'equalxy', 'equalxz', and 'equalyz', to set only two directions to equal aspect ratios. This changes the data limits, example below.

In the upcoming 3.7.0, you will be able to change the plot box aspect ratios rather than the data limits via the command ax.set_aspect('equal', adjustable='box'). To get the original behavior, use adjustable='datalim'.

enter image description here

Hooper answered 16/9, 2022 at 19:13 Comment(1)
3.7.0 is now released, so adjustable='datalim' and adjustable='box' are now both valid options.Hooper
B
7

EDIT: user2525140's code should work perfectly fine, although this answer supposedly attempted to fix a non--existant error. The answer below is just a duplicate (alternative) implementation:

def set_aspect_equal_3d(ax):
    """Fix equal aspect bug for 3D plots."""

    xlim = ax.get_xlim3d()
    ylim = ax.get_ylim3d()
    zlim = ax.get_zlim3d()

    from numpy import mean
    xmean = mean(xlim)
    ymean = mean(ylim)
    zmean = mean(zlim)

    plot_radius = max([abs(lim - mean_)
                       for lims, mean_ in ((xlim, xmean),
                                           (ylim, ymean),
                                           (zlim, zmean))
                       for lim in lims])

    ax.set_xlim3d([xmean - plot_radius, xmean + plot_radius])
    ax.set_ylim3d([ymean - plot_radius, ymean + plot_radius])
    ax.set_zlim3d([zmean - plot_radius, zmean + plot_radius])
Bonhomie answered 1/2, 2016 at 9:14 Comment(1)
You still need to do: ax.set_aspect('equal') or the tick values may be screwed up. Otherwise good solution. Thanks,Jelena
A
1

I think this feature has been added to matplotlib since these answers have been posted. In case anyone is still searching a solution this is how I do it:

import matplotlib.pyplot as plt 
import numpy as np
    
fig = plt.figure(figsize=plt.figaspect(1)*2)
ax = fig.add_subplot(projection='3d', proj_type='ortho')
    
X = np.random.rand(100)
Y = np.random.rand(100)
Z = np.random.rand(100)
    
ax.scatter(X, Y, Z, color='b')

The key bit of code is figsize=plt.figaspect(1) which sets the aspect ratio of the figure to 1 by 1. The *2 after figaspect(1) scales the figure by a factor of two. You can set this scaling factor to whatever you want.

NOTE: This only works for figures with one plot.

Random 3D scatter Plot

Airy answered 30/4, 2021 at 2:20 Comment(0)
L
1

It appears:

ax.set_aspect('equal')

works, but be careful to execute this instruction only when all plots are done. The code modifies the limits of the plot based on the ranges that have been used, and therefore would not calculate accurate limits if the subplot is not complete.

Documentation: mpl_toolkits.mplot3d.axes3d.Axes3D.set_aspect

enter image description here

The three unit vectors in black have an equal length, in spite of the very different ranges for each axis.

Code used

It is interesting to look at the code.

  • The ranges used by the plots are obtained by calling get_view_interval()

  • The new aspect is set by calling set_box_aspect(box_aspect)


view_intervals = np.array([self.xaxis.get_view_interval(),
                           self.yaxis.get_view_interval(),
                           self.zaxis.get_view_interval()])
ptp = np.ptp(view_intervals, axis=1)
if self._adjustable == 'datalim':
...
else:  # 'box'
    # Change the box aspect such that the ratio of the length of
    # the unmodified axis to the length of the diagonal
    # perpendicular to it remains unchanged.
    box_aspect = np.array(self._box_aspect)
    box_aspect[ax_indices] = ptp[ax_indices]
    remaining_ax_indices = {0, 1, 2}.difference(ax_indices)
    if remaining_ax_indices:
        remaining = remaining_ax_indices.pop()
        old_diag = np.linalg.norm(self._box_aspect[ax_indices])
        new_diag = np.linalg.norm(box_aspect[ax_indices])
        box_aspect[remaining] *= new_diag / old_diag
    self.set_box_aspect(box_aspect)
    

Example

The code corresponding to the figure above.

import numpy as np
import matplotlib.pyplot as plt


# Convert a point array to arrays of x, y and z coords
def split_xyz(points):
    _p = np.asarray(points)
    return _p[:,0], _p[:,1], _p[:,2]

# Origin
o = np.array([0,0,0])

# Vectors
u = np.array([3,3,1])
v = np.array([0,0.2,1.2])

# Projection of v onto u
n = np.linalg.norm(u)
p = u.dot(v) / n**2 * u

# Projection of v onto plane normal to u
w = v - p

# Figure
kw = dict(figsize=(7,7), layout='constrained')
fig = plt.figure(**kw)
mosaic = [['3d']]
kw = {'3d': dict(projection = '3d')}
axs = fig.subplot_mosaic(mosaic, per_subplot_kw=kw)
ax = axs['3d']

# Plot origin
ax.scatter([0],[0],[0], s=50, c='k')
ax.set_xlabel('x')
ax.set_ylabel('y')
ax.set_zlabel('z')

# Plot unit vectors
units = [(1,0,0), (0,1,0),(0,0,1)]
for _v in units: ax.plot(*split_xyz([o, _v]), c='k')

# Plot vectors
for _v, label in zip([u,v], ['u','v']):
    ax.plot(*split_xyz([o, _v]), label=label, lw=2)

# Plot projections
for _v, label in zip([p,w], ['p','w']):
    ax.plot(*split_xyz([o, _v]), label=label, lw=4, alpha=0.5)

# Plot projection lines
for _v in [p, w]: ax.plot(*split_xyz([v, _v]), c='darkgray', ls='--')

# Add legend
ax.legend()

ax.set_aspect('equal')
Lot answered 12/10, 2023 at 15:2 Comment(0)
D
0
  • for the time beeing ax.set_aspect('equal') araises an error (version 3.5.1 with Anaconda).

  • ax.set_aspect('auto',adjustable='datalim') did not give a convincing solution either.

  • a lean work-aorund with ax.set_box_aspect((asx,asy,asz)) and asx, asy, asz = np.ptp(X), np.ptp(Y), np.ptp(Z) seems to be feasible (see my code snippet)

  • Let's hope that version 3.7 with the features @Scott mentioned will be successful soon.

    import numpy as np
    import matplotlib.pyplot as plt
    from mpl_toolkits.mplot3d import Axes3D
    
    #---- generate data
    nn = 100
    X = np.random.randn(nn)*20 +  0
    Y = np.random.randn(nn)*50 + 30
    Z = np.random.randn(nn)*10 + -5
    
    #---- check aspect ratio
    asx, asy, asz = np.ptp(X), np.ptp(Y), np.ptp(Z)
    
    fig = plt.figure(figsize=(15,15))
    ax = fig.add_subplot(projection='3d')
    
    #---- set box aspect ratio
    ax.set_box_aspect((asx,asy,asz))
    scat = ax.scatter(X, Y, Z, c=X+Y+Z, s=500, alpha=0.8)
    
    ax.set_xlabel('X-axis'); ax.set_ylabel('Y-axis'); ax.set_zlabel('Z-axis')
    plt.show()
    

enter image description here

Desiree answered 2/12, 2022 at 17:56 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.