文章/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
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 下有区别 |
最小示例
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)
ax.contour(..., zdir='z', offset=-1.6) 是 3D 最实用的一招:把等高线投到底面,等于在立体图上叠了一张热力图,读数值时不用只靠颜色。offset 要给一个明确的 z 值,再用 set_zlim 把它圈进来,否则等高线会贴在数据最低处、和曲面糊在一起。
三条创建路径拿到的是同一个类
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 轴多出来的方法
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 个,全在这里:
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 里不存在:
import matplotlib.pyplot as plt
print('axes3d.depthshade' in plt.rcParams,
'axes3d.depthshade_minalpha' in plt.rcParams)
# 输出: False Falseaxes3d.grid=True只控制“要不要画网格线”,网格线对象在draw()之前get_visible()是False,拿它做断言会误判。projection只认小写'3d';get_projection_names()报的是全部可选投影:
import matplotlib
print(matplotlib.projections.get_projection_names())
# 输出: ['3d', 'aitoff', 'hammer', 'lambert', 'mollweide', 'polar', 'rectilinear']六种基本图元
3D 的图元比 2D 少,就这六种(外加 quiver 箭头场、contour / contourf 投影):
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):三维折线,也能贴平面
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:
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,超过就不画全:
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:
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 400rcount=20 与 rstride=10 在 200×200 上等价(200/20 = 10),但两者不能同时给:
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:
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} 22plot_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)。
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' 取极端值),影响透明柱子的前后关系:
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 三角剖分:
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必须是二维数组;一维会被当成“单个截面”,画出来是一条带子。