目录13

文章Matplotlib第 38 章

三维坐标与基本图元

Matplotlib · 第 38 / 43 章

三维图是用二维投影骗眼睛。它骗到了“立体”的印象,同时丢掉了三条可读性:遮挡(前面的挡住后面的)、深度(读者分不清远近,除非有网格线和光照)、精度(读不出具体数值,只能看趋势)。所以先立判据,再讲 API:

  • 三个变量都连续、且第三个变量不能用颜色 + 等高线表达清楚时,才用 3D;
  • 能画成「2D 图 + colorbar」的,一律画 2D——读者读得出数值,你也省掉一半调试时间;
  • 一定要 3D 时,把视角、纵横比、深度线索三项当参数来调,别用默认值交付。

代价还有一条硬的:矢量文件会爆炸。同一张 50×50 曲面,png 48 KB、pdf 71 KB、svg 424 KB——因为每个面片都要写成路径。

projection='3d':怎么拿到一个三维 Axes

Python5 行
fig.add_subplot(projection='3d')                  # 最常用
fig.add_subplot(1, 2, 1, projection='3d')         # 带位置参数也照常
plt.axes(projection='3d')                         # 当前 figure 上铺满
plt.subplot(projection='3d')
plt.subplots(2, 2, subplot_kw={'projection': '3d'})   # 多子图唯一写法
参数 一句话 默认值的影响
projection 轴类型 不写拿到的是二维 Axes;写 '3d' 才拿到 Axes3D
subplot_kw 透传给 Axes 构造器的参数 多子图没法逐个传 projection,只能走这里
elev / azim / roll 初始视角 3D 轴专用,默认 30 / -60 / 0;也能在 add_subplot 里直接给
focal_length 透视强度 默认 1,只在 persp 下有区别

最小示例

Python21 行
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(-3, 3, 50)
X, Y = np.meshgrid(x, x)
Z = np.sin(np.hypot(X, Y))

fig = plt.figure(figsize=(4.6, 3.8))
ax = fig.add_subplot(projection='3d')
surf = ax.plot_surface(X, Y, Z, cmap='viridis', linewidth=0, antialiased=True)
ax.contour(X, Y, Z, zdir='z', offset=-1.6, cmap='viridis', levels=8)
ax.set_zlim(-1.6, 1.0)
ax.set_xlabel('x'); ax.set_ylabel('y'); ax.set_zlabel('z')
ax.view_init(elev=26, azim=-58)
ax.set_box_aspect((1, 1, 0.62))
fig.colorbar(surf, ax=ax, shrink=0.6, label='高度')
fig.savefig('X_3d_demo.png')

print(type(ax).__name__, ax.name, ax.elev, ax.azim, ax.roll)
# 输出: Axes3D 3d 26 -58 0
plt.close(fig)

曲面 + 底面等高线投影 + colorbar

ax.contour(..., zdir='z', offset=-1.6) 是 3D 最实用的一招:把等高线投到底面,等于在立体图上叠了一张热力图,读数值时不用只靠颜色。offset 要给一个明确的 z 值,再用 set_zlim 把它圈进来,否则等高线会贴在数据最低处、和曲面糊在一起。

三条创建路径拿到的是同一个类

Python15 行
import matplotlib.pyplot as plt

fig = plt.figure()
a = fig.add_subplot(projection='3d')
b = plt.axes(projection='3d')
c = plt.subplot(projection='3d')
d = plt.subplots(2, 1, subplot_kw={'projection': '3d'})[1]

print([type(x).__name__ for x in (a, b, c, *d)])
# 输出: ['Axes3D', 'Axes3D', 'Axes3D', 'Axes3D', 'Axes3D']
print([x.name for x in (a, b, c, *d)])
# 输出: ['3d', '3d', '3d', '3d', '3d']
print([type(x).__mro__[1].__name__ for x in (a, b, c, *d)][:1])
# 输出: ['Axes']
plt.close('all')

Axes3D 的继承链是 Axes3D → Axes → _AxesBase。它继承了整个二维 Axes——所有 2D 方法都在,hasattr 一律为真。这是本章最后一节「静默画错」的根源:能用不等于画对。

3D 轴多出来的方法

Python14 行
import matplotlib.pyplot as plt

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

mine = ['view_init', 'set_proj_type', 'set_box_aspect', 'set_zlabel',
        'set_zlim', 'get_zlim', 'invert_zaxis', 'text2D',
        'plot_surface', 'plot_wireframe', 'plot_trisurf',
        'bar3d', 'quiver']
print(all(hasattr(ax, n) for n in mine), len(mine))
# 输出: True 13
print(hasattr(ax, 'vertical_axis'), hasattr(ax, '_sort_zpos'))
# 输出: False False
plt.close(fig)

注意最后一行:view_init(vertical_axis='y') 是参数,不是属性,设完之后读不回来;_sort_zpos 这个老版本内部字段在 3.10 也不存在了(别照着旧博客写兼容代码)。

返回 ,不是 的子类特例,而是专用子类( 之上另铺一层)。所以:
  • 判断类型用 isinstance(ax, Axes3D)(from mpl_toolkits.mplot3d import Axes3D),或用 ax.name == '3d';
  • 二维 Axes 的属性(spines、patches、transAxes)大多数还在但不参与投影,碰它们通常没效果;
  • 3D 轴没有 spines 的可见边框,所谓“盒子”由 xaxis / yaxis / zaxis 三个 Axis 对象画出来的面板(pane)组成,颜色走 axes3d.*.panecolor。

注意事项

  • 3D 专属的 rcParams 只有 8 个,全在这里:
Python13 行
import matplotlib.pyplot as plt

for k in sorted(plt.rcParams):
    if '3d' in k.lower():
        print(k, '=', plt.rcParams[k])
# 输出: axes3d.automargin = False
# 输出: axes3d.grid = True
# 输出: axes3d.mouserotationstyle = arcball
# 输出: axes3d.trackballborder = 0.2
# 输出: axes3d.trackballsize = 0.667
# 输出: axes3d.xaxis.panecolor = (0.95, 0.95, 0.95, 0.5)
# 输出: axes3d.yaxis.panecolor = (0.9, 0.9, 0.9, 0.5)
# 输出: axes3d.zaxis.panecolor = (0.925, 0.925, 0.925, 0.5)
  • 网上常见的 axes3d.depthshade、axes3d.depthshade_minalpha 在本机 3.10.7 里不存在:
Python5 行
import matplotlib.pyplot as plt

print('axes3d.depthshade' in plt.rcParams,
      'axes3d.depthshade_minalpha' in plt.rcParams)
# 输出: False False
  • axes3d.grid=True 只控制“要不要画网格线”,网格线对象在 draw() 之前 get_visible() 是 False,拿它做断言会误判。
  • projection 只认小写 '3d';get_projection_names() 报的是全部可选投影:
Python4 行
import matplotlib

print(matplotlib.projections.get_projection_names())
# 输出: ['3d', 'aitoff', 'hammer', 'lambert', 'mollweide', 'polar', 'rectilinear']

六种基本图元

3D 的图元比 2D 少,就这六种(外加 quiver 箭头场、contour / contourf 投影):

Python43 行
import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(-3, 3, 30)
X, Y = np.meshgrid(x, x)
Z = np.sin(np.hypot(X, Y))
t = np.linspace(0, 4 * np.pi, 200)
rng = np.random.default_rng(0)

fig = plt.figure(figsize=(9.6, 6.0))

ax = fig.add_subplot(2, 3, 1, projection='3d')
ax.plot(np.cos(t), np.sin(t), t, lw=1.4)                       # 1 折线
ax.set_title('plot', fontsize=9)

ax = fig.add_subplot(2, 3, 2, projection='3d')
ax.scatter(*rng.normal(size=(3, 250)), s=9)                    # 2 散点
ax.set_title('scatter', fontsize=9)

ax = fig.add_subplot(2, 3, 3, projection='3d')
ax.plot_surface(X, Y, Z, cmap='viridis', linewidth=0)          # 3 曲面
ax.set_title('plot_surface', fontsize=9)

ax = fig.add_subplot(2, 3, 4, projection='3d')
ax.plot_wireframe(X, Y, Z, rstride=3, cstride=3, lw=0.5)       # 4 线框
ax.set_title('plot_wireframe', fontsize=9)

ax = fig.add_subplot(2, 3, 5, projection='3d')
bx, by = np.meshgrid(np.arange(4), np.arange(4))
bx, by = bx.ravel(), by.ravel()
h = rng.random(bx.size)
ax.bar3d(bx, by, np.zeros_like(bx), 0.6, 0.6, h, shade=True)   # 5 三维柱
ax.set_title('bar3d', fontsize=9)

ax = fig.add_subplot(2, 3, 6, projection='3d')
px, py = rng.random(300) * 6 - 3, rng.random(300) * 6 - 3
ax.plot_trisurf(px, py, np.sin(np.hypot(px, py)), linewidth=0) # 6 三角曲面
ax.set_title('plot_trisurf', fontsize=9)

print(len(fig.axes), [type(a).__name__ for a in fig.axes][:2])
# 输出: 6 ['Axes3D', 'Axes3D']
fig.savefig('X_3d_six.png')
plt.close(fig)

六种基本图元

plot(x, y, z):三维折线,也能贴平面

Python14 行
import matplotlib.pyplot as plt
import numpy as np

t = np.linspace(0, 4 * np.pi, 200)
fig = plt.figure(figsize=(4.2, 3.4))
ax = fig.add_subplot(projection='3d')

ax.plot(np.cos(t), np.sin(t), t, lw=1.5, label='三维螺旋')
ax.plot(np.cos(t), np.sin(t), zs=-1, zdir='z', lw=1, label='投到 z=-1 平面')

line = ax.plot([0, 1], [0, 1], [0, 1])[0]
print(type(line).__name__, type(line).__mro__[1].__name__)
# 输出: Line3D Line2D
plt.close(fig)

关键是 zs + zdir 这一对:给了 zs,就变成“把二维数据贴到某个平面上”。zdir='z' 贴在 z 平面、'x' 贴在 x 平面。做“投影影子”效果不用自己补第三维。

scatter(xs, ys, zs=0, zdir='z', ..., depthshade=True)

zs 有默认值 0,所以只给两维不报错——所有点会被压到 z=0:

Python14 行
import matplotlib.pyplot as plt
import numpy as np

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

s1 = ax.scatter([1, 2], [3, 4])                     # 忘了给 zs
print(np.asarray(s1._offsets3d).shape, np.asarray(s1._offsets3d)[2])
# 输出: (3, 2) [0. 0.]

s2 = ax.scatter([1, 2], [3, 4], zs=5, zdir='x')     # zs 变成 x 坐标
print(np.asarray(s2._offsets3d).tolist())
# 输出: [[5.0, 5.0], [1.0, 2.0], [3.0, 4.0]]
plt.close(fig)

第二段是个容易读错的语义:zs=5, zdir='x' 时,那个“5”是 x 方向的位置,不是 z。名字里的 zs 是“沿 zdir 的那个坐标”,跟具体轴无关。

plot_surface(X, Y, Z, *, norm, vmin, vmax, lightsource, **kwargs)

签名里看不到 rcount / ccount / rstride / cstride,它们在 **kwargs 里被单独拣出来处理。默认 rcount=ccount=50,超过就不画全:

Python24 行
import matplotlib.pyplot as plt
import numpy as np

def faces(n, **kw):
    g = np.linspace(-3, 3, n)
    GX, GY = np.meshgrid(g, g)
    fig = plt.figure()
    ax = fig.add_subplot(projection='3d')
    s = ax.plot_surface(GX, GY, np.sin(np.hypot(GX, GY)), **kw)
    n_face = len(s.get_facecolor())
    plt.close(fig)
    return n_face

for n in [49, 50, 51, 60, 100, 200]:
    print(n, faces(n))
# 输出: 49 2304
# 输出: 50 2401
# 输出: 51 625
# 输出: 60 900
# 输出: 100 2500
# 输出: 200 2500

print(faces(50), faces(50, rcount=50, ccount=50))
# 输出: 2401 2401

看清楚那道断崖:49×49 画 2304 个面片,51×51 只剩 625 个。50 是默认上限,一超就按 ceil(n/50) 跳行采样——51 跳 2 行取 1,199 行里只留 25 行。三列对照证明默认值就是 50:

Python14 行
import matplotlib.pyplot as plt
import numpy as np

def count(n, **kw):
    g = np.linspace(-3, 3, n)
    GX, GY = np.meshgrid(g, g)
    fig = plt.figure()
    ax = fig.add_subplot(projection='3d')
    c = len(ax.plot_surface(GX, GY, np.sin(np.hypot(GX, GY)), **kw).get_facecolor())
    plt.close(fig)
    return c

print(count(200), count(200, rcount=20, ccount=20), count(200, rstride=10, cstride=10))
# 输出: 2500 400 400

rcount=20 与 rstride=10 在 200×200 上等价(200/20 = 10),但两者不能同时给:

Python20 行
import matplotlib.pyplot as plt
import numpy as np

g = np.linspace(-3, 3, 60)
GX, GY = np.meshgrid(g, g)
fig = plt.figure()
ax = fig.add_subplot(projection='3d')

try:
    ax.plot_surface(GX, GY, np.sin(np.hypot(GX, GY)), rstride=3, rcount=10)
except ValueError as e:
    print('ValueError', e)
# 输出: ValueError Cannot specify both stride and count arguments

try:
    ax.plot_surface(GX, GY, np.sin(np.hypot(GX, GY)), rcount=0)
except ZeroDivisionError as e:
    print('ZeroDivisionError', e)
# 输出: ZeroDivisionError division by zero
plt.close(fig)

要画满整片网格就显式给 rcount=len(x),同时接受“存成 pdf/svg 会很大”这个后果。

plot_wireframe(X, Y, Z, *, axlim_clip=False, **kwargs)

默认每行每列都画,线段数 = 2n:

Python14 行
import matplotlib.pyplot as plt
import numpy as np

for n, kw in [(10, {}), (40, {}), (40, {'rstride': 4, 'cstride': 4})]:
    g = np.linspace(-3, 3, n)
    GX, GY = np.meshgrid(g, g)
    fig = plt.figure()
    ax = fig.add_subplot(projection='3d')
    w = ax.plot_wireframe(GX, GY, np.sin(np.hypot(GX, GY)), **kw)
    print(n, kw or '默认', len(w._segments3d))
    plt.close(fig)
# 输出: 10 默认 20
# 输出: 40 默认 80
# 输出: 40 {'rstride': 4, 'cstride': 4} 22

plot_wireframe 和 plot_surface 共用同一套 stride/count 逻辑,但没有 50 的默认上限——40×40 就是 80 条线全画。稠密网格上它比曲面更容易糊成一团黑,rstride / cstride 给 3~5 是常用档。

bar3d(x, y, z, dx, dy, dz, ..., zsort='average', shade=True)

六个位置参数:底面左下角 (x, y, z) + 三个方向的尺寸 (dx, dy, dz)。

Python15 行
import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(1)
bx, by = np.meshgrid(np.arange(4), np.arange(4))
bx, by = bx.ravel(), by.ravel()
h = rng.random(bx.size)

fig = plt.figure(figsize=(4.2, 3.6))
ax = fig.add_subplot(projection='3d')
bars = ax.bar3d(bx, by, np.zeros_like(bx), 0.7, 0.7, h,
                color=plt.cm.viridis(h / h.max()), shade=True)
print(type(bars).__name__, len(bars.get_facecolor()))
# 输出: Poly3DCollection 96
plt.close(fig)

96 = 16 根柱 × 6 个面。zsort 决定同一根柱的六个面按什么顺序绘制('average' 取面中心、'min' / 'max' 取极端值),影响透明柱子的前后关系:

Python15 行
import matplotlib.pyplot as plt
import numpy as np

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.bar3d([0], [0], [0], 0.5, 0.5, [1], zsort='max')
print('zsort max ok')
# 输出: zsort max ok

try:
    ax.bar3d([0], [0], [0], 0.5, 0.5, [1], zsort='bad')
except KeyError as e:
    print('KeyError', e)
# 输出: KeyError bad
plt.close(fig)

plot_trisurf(x, y, z, ...):不规则采样点

数据点不在网格上(实测坐标、随机撒点)时,plot_surface 用不了——它要求 X/Y 是规则 meshgrid。plot_trisurf 直接对散点做 Delaunay 三角剖分:

Python14 行
import matplotlib.pyplot as plt
import numpy as np

rng = np.random.default_rng(0)
px = rng.random(300) * 6 - 3
py = rng.random(300) * 6 - 3
pz = np.sin(np.hypot(px, py))

fig = plt.figure(figsize=(4.2, 3.6))
ax = fig.add_subplot(projection='3d')
tri = ax.plot_trisurf(px, py, pz, cmap='coolwarm', linewidth=0)
print(type(tri).__name__, len(tri.get_facecolor()))
# 输出: Poly3DCollection 582
plt.close(fig)

300 个点剖出 582 个三角形——点的密度不均时三角形会大小悬殊,看起来像“破洞”。想要均匀外观就先用 scipy.interpolate.griddata 插值到规则网格,再交给 plot_surface。

顺带一个注意点:三角剖分的结果依赖 qhull(scipy 的凸包实现),换个 scipy 版本这个 582 就可能变。所以别把面片数写进断言,它不像 plot_surface 的 (n-1)² 那样可预测。

注意事项

  • 六种图元里只有 plot_surface / plot_trisurf / bar3d 返回 Poly3DCollection,plot 返回 Line3D、scatter 返回 Path3DCollection。
  • linewidth=0 是曲面的标配:不写默认会画出每个面片的描边,密网格上看起来像一张灰网。
  • cmap 与 color 二选一。同时给 color= 和 cmap= 时 color 覆盖映射结果,颜色全一样。
  • plot_surface 的 Z 必须是二维数组;一维会被当成“单个截面”,画出来是一条带子。