97
votes

When I set up equal aspect ratio for 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: enter image description here

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 could find did not work. Thank you.

8

8 Answers

74
votes

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.gca(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

70
votes

I like the above 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..  This is one possible solution to Matplotlib's
    ax.set_aspect('equal') and ax.axis('equal') not working for 3D.

    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.gca(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)

set_axes_equal(ax)
plt.show()
53
votes

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.gca(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

24
votes

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.gca(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()
19
votes

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.gca(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')
7
votes

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])
7
votes

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))
0
votes

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 = plt.gca(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