目录20

文章Matplotlib第 39 章

视角、投影与深度线索

Matplotlib · 第 39 / 43 章

view_init:视角就是信息本身

Python1 行
Axes3D.view_init(elev=None, azim=None, roll=None, vertical_axis='z', share=False)
参数 一句话 默认值的影响
elev 仰角(度),抬头为正 30,从斜上方俯视
azim 方位角(度),绕 z 轴转 -60,从右前方向后看
roll 画面自身旋转(度) 0;非零会让人误以为是数据倾斜
vertical_axis 哪根轴竖着 'z';设成 'y' 就把 y 立在画面中间
share 与其他 3D 轴共享视角 False

最小示例

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

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

fig = plt.figure(figsize=(4.4, 3.8))
ax = fig.add_subplot(projection='3d')
ax.plot_surface(X, Y, Z, cmap='viridis', linewidth=0)
ax.view_init(elev=26, azim=-58)

print(ax.elev, ax.azim, ax.roll)
# 输出: 26 -58 0
plt.close(fig)

同一曲面的四个视角

同一份数据,四个视角给出的信息完全不同:elev=0 时退化成一条投影带,elev=90 时等价于热力图。换视角不是“调好看”,是在换能看到什么——所以交付前至少看三个角度,确认没有因为遮挡而漏掉关键结构。

elev:仰角,且不做范围校验

Python15 行
import matplotlib.pyplot as plt

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.plot([0, 1], [0, 1], [0, 1])

for elev in [0, 45, 90, 100, 180]:
    ax.view_init(elev=elev, azim=-60)
    print(elev, '->', ax.elev)
# 输出: 0 -> 0
# 输出: 45 -> 45
# 输出: 90 -> 90
# 输出: 100 -> 100
# 输出: 180 -> 180
plt.close(fig)

elev 没有取值范围检查:给 180 也接受,然后整个画面上下颠倒(z 轴朝下)。规范范围是 -90 ~ 90,越界不报错但图会翻过去——这是“静默出错”,只能靠看图发现。

azim:方位角,也不取模

Python13 行
import matplotlib.pyplot as plt

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.plot([0, 1], [0, 1], [0, 1])

ax.view_init(elev=30, azim=400)
print(ax.azim)
# 输出: 400
ax.view_init(elev=30, azim=-60)
print(ax.azim)
# 输出: -60
plt.close(fig)

azim=400 读回来还是 400,不会归到 40。渲染时按 400 mod 360 = 40 处理,但属性值和实际角度不一致——如果你的代码靠 ax.azim 判断“当前转到哪了”,会算错。自己传之前先取模。

roll:绕视线转画面

Python14 行
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))

fig = plt.figure(figsize=(4.0, 3.4))
ax = fig.add_subplot(projection='3d')
ax.plot_surface(X, Y, Z, cmap='viridis', linewidth=0)
ax.view_init(elev=26, azim=-58, roll=30)
print(ax.roll)
# 输出: 30
plt.close(fig)

roll 转的是画布,不是数据。斜着放有时候能塞进窄栏,但坐标轴刻度也跟着斜,读者会先花两秒搞清方向——除非图窄到必须斜放,否则保持 0。

vertical_axis:把哪根轴立起来

view_init(vertical_axis='y') 让人从“y 朝上”的角度看,适合 y 是时间轴、想让它竖着长的图。它是参数不是属性,设完读不回来:

Python9 行
import matplotlib.pyplot as plt

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.plot([0, 1], [0, 1], [0, 1])
ax.view_init(elev=25, azim=-60, vertical_axis='y')
print(hasattr(ax, 'vertical_axis'))
# 输出: False
plt.close(fig)

交互旋转

plt.show() 的窗口里可以按住左键拖动旋转,走的是 axes3d.mouserotationstyle:

Python6 行
import matplotlib.pyplot as plt

print(plt.rcParams['axes3d.mouserotationstyle'])
# 输出: arcball
print(plt.rcParams['axes3d.trackballsize'], plt.rcParams['axes3d.trackballborder'])
# 输出: 0.667 0.2

'arcball' 是球面轨迹(转起来像抓着球),'trackball' 是平面轨迹(更像转盘)。静态导出时这两项完全不影响结果,只影响你调视角的手感。

注意事项

  • view_init() 不传参 = 恢复默认 30 / -60 / 0,不是“保持不动”。
  • 视角变了,3D 的自动排序会重算,透明图元的前后关系跟着变;同一份数据两个角度的观感差异可能很大。
  • 想让多个子图同步转,用 share=True 传一个已存在的 3D 轴。
  • elev / azim 的符号容易记反:azim 增大是逆时针(俯视看),elev 增大是往正上方抬。

投影与盒子:set_proj_type / set_box_aspect

Python2 行
Axes3D.set_proj_type(proj_type, focal_length=None)
Axes3D.set_box_aspect(aspect, *, zoom=1)
参数 一句话 默认值的影响
proj_type 'persp' 透视 / 'ortho' 正交 'persp',近大远小
focal_length 透视强度,越小越夸张 1;ortho 下被设成 inf
aspect 三根轴的相对长度 不设等价于 4:4:3
zoom 整体缩放(不是比例) 1

最小示例

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

fig = plt.figure(figsize=(4.2, 3.4))
ax = fig.add_subplot(projection='3d')
for y0, c in [(0, '#3266a8'), (4, '#4c9a5c'), (8, '#c98a2e')]:
    ax.bar3d([1.0], [y0], [0.0], 1.6, 1.6, 1.6, color=c, shade=False)
ax.set_proj_type('ortho')
ax.view_init(elev=18, azim=-72)
ax.set_box_aspect((1, 2.2, 0.55))

print(ax._focal_length)
# 输出: inf
plt.close(fig)

透视 vs 正交:三个等大的立方体

三个立方体实际边长完全一样。persp 下近的那个明显更大,ortho 下三个同大。判据:要“像照片”用 persp,要“比较大小”用 ortho——数据图里通常该用正交,透视会让读者误判远端的量。

focal_length:越小越夸张

Python17 行
import matplotlib.pyplot as plt

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
for f in [0.3, 1, 10]:
    ax.set_proj_type('persp', focal_length=f)
    print(f, '->', ax._focal_length)
# 输出: 0.3 -> 0.3
# 输出: 1 -> 1
# 输出: 10 -> 10

try:
    ax.set_proj_type('persp', focal_length=0)
except ValueError as e:
    print('ValueError', e)
# 输出: ValueError focal_length = 0 must be greater than 0
plt.close(fig)

focal_length 必须在 (0, ∞) 开区间。小于 0.3 的强透视在数据图里几乎总是错的——远端元素小到看不清刻度。

proj_type 只有两个取值

Python15 行
import matplotlib.pyplot as plt

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

for s in ['persp', 'ortho', 'fisheye']:
    try:
        ax.set_proj_type(s)
        print(s, 'ok')
    except ValueError as e:
        print(s, '->', str(e)[:78])
# 输出: persp ok
# 输出: ortho ok
# 输出: fisheye -> 'fisheye' is not a valid value for proj_type; supported values are
plt.close(fig)

'fisheye' 是 Axes3D 里没有的模式(有些教程把它和 figure 的相机投影混在一起)。大小写也敏感,'PERSP' 同样报 ValueError。

set_box_aspect:三维图的纵横比

这是 3D 里最该改、又最常被忘的一个参数。默认盒子等价于 4:4:3,z 方向被压到 0.75 倍——数据 z 的范围小的时候看起来还行,z 范围一大就顶到天花板。

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

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
print([round(float(v), 4) for v in ax.get_box_aspect()])
# 输出: [1.1905, 1.1905, 0.8929]

for asp in [(1, 1, 0.5), (4, 4, 3), (2, 1, 1)]:
    ax.set_box_aspect(asp)
    print(asp, '->', [round(float(v), 4) for v in ax.get_box_aspect()])
# 输出: (1, 1, 0.5) -> [1.2705, 1.2705, 0.6352]
# 输出: (4, 4, 3) -> [1.1905, 1.1905, 0.8929]
# 输出: (2, 1, 1) -> [1.556, 0.778, 0.778]

ax.set_box_aspect(None)
print([round(float(v), 4) for v in ax.get_box_aspect()])
# 输出: [1.1905, 1.1905, 0.8929]
plt.close(fig)

get_box_aspect() 返回的是 numpy 数组,直接 print 会打出 np.float64(1.1905) 这种长串,所以上面用 round(float(v), 4) 转成普通浮点数。

传进去的元组会被归一化(等比缩放到一个固定体积),所以读回来的值不等于你给的数——但比例是对的。给 (4, 4, 3) 得到的就是默认值,这也证实了默认盒子是 4:4:3。

不设 / 压扁 / 拉高

曲线数据用 (1, 1, 0.5) 上下的扁平盒子最舒服:既保留了立体感,又不让立方体的空角吃掉一半画布。柱子数据相反,(1, 1, 1.2) 之类让高度显出来。

注意事项

  • set_aspect('equal') 在 3D 上能调但不影响真实比例——它管的是二维后备层,3D 的纵横比只认 set_box_aspect:
Python14 行
import matplotlib.pyplot as plt

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
ax.set_aspect('equal')
print(ax.get_aspect())
# 输出: equal

try:
    ax.set_aspect('1:2')
except ValueError as e:
    print('ValueError', str(e)[:52])
# 输出: ValueError '1:2' is not a valid value for aspect; sup
plt.close(fig)
  • box_aspect 与 figsize 无关:3×3 和 6×2 的画布上 set_box_aspect((1, 1, 1)) 读回来都是 [1.1003, 1.1003, 1.1003]。想整体放大缩小用 zoom=,别改 figsize 硬凑。
  • zoom 只是把盒子乘一个系数,不改比例;zoom>1 会把轴标签推出画布外,bbox_inches='tight' 也救不回来。

颜色、光照与深度线索

3D 少了“前后”这条视觉通道,就要靠颜色、光照、透明度把它补回来。

cmap + norm / vmin / vmax

Python15 行
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import Normalize

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

fig = plt.figure(figsize=(4.4, 3.6))
ax = fig.add_subplot(projection='3d')
surf = ax.plot_surface(X, Y, Z, cmap='viridis',
                       norm=Normalize(vmin=-1, vmax=1))   # 固定色标范围
print(round(surf.get_clim()[0], 3), round(surf.get_clim()[1], 3))
# 输出: -1.0 1.0
plt.close(fig)

固定 vmin / vmax 是画多张 3D 图时的硬要求:不固定的话每张图按自己的极值映射,读者会把“这张更亮”误读成“这张值更大”。多图共用一个 Normalize 对象,或者统一 vmin=-1, vmax=1。

lightsource / shade

Python20 行
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.colors import LightSource

x = np.linspace(-3, 3, 40)
X, Y = np.meshgrid(x, x)
Z = np.sin(np.hypot(X, Y))
ls = LightSource(azdeg=315, altdeg=45)

fig = plt.figure(figsize=(4.4, 3.6))
ax = fig.add_subplot(projection='3d')
s1 = ax.plot_surface(X, Y, Z, cmap='viridis', lightsource=ls)
print(len(s1.get_facecolor()))
# 输出: 1521

ax2 = plt.figure().add_subplot(projection='3d')
ax2.plot_surface(X, Y, Z, color='#4c9a5c', shade=True)     # 单色 + 自动光照
print('shade ok')
# 输出: shade ok
plt.close('all')

shade=True 是 bar3d 的默认行为(所以柱子看着有明暗面),plot_surface 默认 shade=True 但只在给了 color= 时才看得见——给了 cmap 之后颜色由数据决定,光照会跟颜色映射打架,看起来脏。要光照就用单色 + lightsource。

depthshade:散点唯一的深度线索

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

rng = np.random.default_rng(0)
xs, ys, zs = rng.normal(size=(3, 40))

for flag in [True, False]:
    fig = plt.figure()
    ax = fig.add_subplot(projection='3d')
    ax.view_init(elev=20, azim=-60)
    sc = ax.scatter(xs, ys, zs, s=60, depthshade=flag)
    fig.canvas.draw()                                  # alpha 在 draw 时才写进去
    al = [round(float(c[-1]), 3) for c in sc.get_facecolors()]
    print(flag, min(al), max(al))
    plt.close(fig)
# 输出: True 0.3 1.0
# 输出: False 1.0 1.0

depthshade 的三种效果

True 时最远的点 alpha 压到 0.3、最近的 1.0,中间线性过渡。这个 0.3 是写死的,没有 rcParam、也没有参数能改:

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

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
try:
    ax.scatter([1], [2], [3], depthshade_minalpha=0.9)
except AttributeError as e:
    print('AttributeError', str(e)[:66])
# 输出: AttributeError PathCollection.set() got an unexpected keyword argument 'depthsh
plt.close(fig)

depthshade=False 之后所有点同色,读者完全没有距离感——除非你用颜色本身编码深度(c=zs),否则别关。

computed_zorder:3D 自己决定谁在前

Python11 行
import matplotlib.pyplot as plt

fig = plt.figure()
ax = fig.add_subplot(projection='3d')
print(ax.computed_zorder)
# 输出: True

ax.computed_zorder = False
print(ax.computed_zorder)
# 输出: False
plt.close(fig)

默认 True:每次绘制时按投影后的深度重新排队,zorder= 基本失效。设成 False 就退回“按 zorder 画”的二维逻辑——有时候能修好某个图元的遮挡错误,但通常会让整体看起来更糟。这是个调试开关,不是排版手段。

注意事项

  • 3D 的 zorder 在 computed_zorder=True 下不生效,想控制前后只能改数据或视角。
  • 透明曲面(alpha<1)很好看但排序会出错:plot_surface 的面片按单一深度值排序,互相穿插的曲面会画错。降低 rcount 让面片变大反而更稳。
  • 同一张图上放两个 plot_surface 时,两个都要给 alpha 才有“透过看”的效果,只给一个会显得像贴纸。
  • 色标(colorbar)不算 3D 对象,用 fig.colorbar(surf, ax=ax, shrink=0.6) 正常调用即可。