4.2 Transformations 坐标变换 本节摘要:坐标变换是 Matplotlib 把数据点送到屏幕上的那条管道。同一个点,在数据坐标里是一对原始数值,在轴坐标里是相对子图的百分比,在图形坐标里是相对整张画布的百分比,最后在显示坐标里才变成一个真正的像素。四套坐标系各有各的用途,变换对象负责在它们之间换算。本节先讲清这四套坐标系的区别,再讲 Affine2D 的平移、缩放、旋转、剪切怎么用,最后落到组合变换的顺序问题——这是最容易翻车的地方。 上手前先明确 阅读完本节,你应当能够: 说清数据坐标、轴坐标、图形坐标、显示坐标各自的基准与用途。 用 transData、transAxes、transFigure 把对象定位到正确的坐标系。
本节摘要:坐标变换是 Matplotlib 把数据点送到屏幕上的那条管道。同一个点,在数据坐标里是一对原始数值,在轴坐标里是相对子图的百分比,在图形坐标里是相对整张画布的百分比,最后在显示坐标里才变成一个真正的像素。四套坐标系各有各的用途,变换对象负责在它们之间换算。本节先讲清这四套坐标系的区别,再讲 Affine2D 的平移、缩放、旋转、剪切怎么用,最后落到组合变换的顺序问题——这是最容易翻车的地方。
阅读完本节,你应当能够:
画图时你给的 (x, y) 是数据坐标——它跟你数据的量纲一致,横轴是年份、纵轴是销售额,那 (2020, 300) 就是字面意思。但屏幕不认识"2020 年",屏幕只认识像素。从 (2020, 300) 到屏幕上的那个亮点,中间要经过不止一次换算。
Matplotlib 把这些中间态整理成四套坐标系。我用"地图"来类比:数据坐标是"经纬度",说的是真实世界的位置;轴坐标和图形坐标是"这张纸上的相对位置",说的是在纸的百分之几处;显示坐标是"印刷机的墨点",说的是最终落在哪个像素上。同一件事,四个说法。
先看一张变换链的全景图:

注意最后一步:图形坐标到显示坐标,不是简单的比例换算,还牵扯画布的物理尺寸和 DPI。你在一台高分屏上把图放大两倍,数据坐标一点没变,像素坐标全变了。这也是为什么"同一个脚本,两台电脑截图出来的点位置不一样"——显示的像素变了,但数据的语义没变。
四套坐标系不是平起平坐的,各有各的主场。数据坐标用来放你的真实数据;轴坐标用来在子图内部做相对定位,比如把文字固定在子图的正中间;图形坐标用来在整张画布上定位,比如给整张图加一个总标题;显示坐标是渲染的终点,你几乎不需要直接碰它。
它们的区别,一张表说清:
| 坐标系 | 基准 | 范围 | 典型用途 | 对应变换 |
|---|---|---|---|---|
| 数据坐标 | 数据量纲 | 随数据和轴设置变化 | 放折线、散点的真实位置 | transData |
| 轴坐标 | 单个子图 | 左下 0,0 到右上 1,1 | 子图内相对定位注解 | transAxes |
| 图形坐标 | 整张画布 | 左下 0,0 到右上 1,1 | 总标题、跨子图元素 | transFigure |
| 显示坐标 | 像素 | 随尺寸与 DPI 变化 | 渲染终点,一般不手写 | 无常用入口 |
怎么用?给 text、annotate 这些函数传一个 transform 参数,指定坐标属于哪套系:
ax.text(0.5, 0.5, '居中', transform=ax.transAxes) # 子图正中 fig.text(0.5, 0.02, '底部说明', transform=fig.transFigure) # 画布底部
第一条代码里,0.5, 0.5 不再是数据坐标,而是"子图横竖各一半的位置"。无论你的数据范围是 0 到 1 还是 0 到一百万,这行字都稳稳待在子图正中间。这就是变换的威力:它把"位置"从数据的束缚里解放出来。
另外提一句:对数轴、极坐标这些"非线性"的东西,底层也是坐标变换。对数轴就是把数据坐标先取对数再映射,极坐标是把直角坐标换到角度和半径。你平时用 ax.set_xscale('log') 时,其实是在给 x 轴换一套变换。这也是为什么同一个 Affine2D 挂在不同 scale 的轴上,视觉结果会不一样——变换是叠在轴自己的缩放之上的。
坐标变换的底层,多数是仿射变换。它干的事很朴素:把点平移、缩放、旋转、剪切,而且变换前后,直线还是直线、平行线还是平行线。对应到 Matplotlib,就是 Affine2D 这个类。它有四个工厂方法,正好对应四种基本操作:
先看平移。它把一个图形整体挪动一段距离:
import matplotlib.transforms as mtransforms trans = mtransforms.Affine2D().translate(2, 3)
translate(2, 3) 的意思是:横轴方向挪 2 个单位,纵轴方向挪 3 个单位。这个"单位"是数据坐标的单位——因为平移通常作用在 transData 之上,2 就是横轴的 2 个数据单位。
缩放、旋转、剪切,写法同样直白:
scale = mtransforms.Affine2D().scale(1.5, 0.8) # 横放大 1.5 倍,纵缩到 0.8 倍 rotate = mtransforms.Affine2D().rotate_deg(30) # 逆时针转 30 度 shear = mtransforms.Affine2D().shear_x(0.3) # 横轴方向剪切 0.3 弧度
真正用到图上,要把这些变换"挂"到数据坐标上。写法是让自定义变换和 ax.transData 相加:
line, = ax.plot(x, y) line.set_transform(scale + ax.transData)
这里有个容易误会的点:scale + ax.transData 里,先执行的是靠右的 ax.transData,再执行靠左的 scale。也就是说,数据点先被换算成轴坐标,再在这个基础上做缩放。顺序千万别反——我们下一节专门讲这个。
为什么这四个操作能共用 Affine2D 一个类?因为它们本质上是同一种东西——一个三乘三矩阵在二维平面上的作用。平移、缩放、旋转、剪切,只是这个矩阵的不同取值。矩阵的左上四格管线性部分,缩放和旋转都藏在这里,靠边两格管平移。你不需要手写矩阵,translate、rotate 这些工厂方法已经把矩阵填好了;但知道背后是矩阵,你就明白为什么组合变换其实就是矩阵乘法、为什么顺序不能随便换——矩阵乘法本来就不满足交换律。
⚠️ 常见坑:旋转一个矩形时忘了设
ax.set_aspect('equal')。x 轴和 y 轴刻度比例不一致时,旋转 90 度的正方形会变成歪斜的平行四边形。这不是变换错了,是坐标轴的"尺子"横竖不一样长,旋转自然把形状带歪。
单个变换不够用,就要组合。Matplotlib 用加号 + 把变换串起来,规则很反直觉:写在左边的变换后执行,写在右边的变换先执行。这个顺序和数学里函数复合的写法一致,却跟很多人"从左到右"的直觉相反。
看一个具体的差别。假设我们要对一个图形"先平移再旋转",和"先旋转再平移",结果是两个完全不同的位置:
t = mtransforms.Affine2D().translate(3, 0) r = mtransforms.Affine2D().rotate_deg(90) combo_a = r + t # 先平移,再旋转 combo_b = t + r # 先旋转,再平移
在 combo_a 里,靠右的 t 先执行——图形先向右平移 3 个单位,然后整体绕原点旋转 90 度,结果落到了纵轴方向。在 combo_b 里,靠右的 r 先执行——图形先旋转 90 度(已经转到纵轴),再向右平移,结果跑到横轴负方向去了。同样的两个操作,换个顺序,终点差着十万八千里。
为什么要设计成"从右到左"?因为它和函数复合的写法 f(g(x)) 对齐——g 先作用,f 后作用。Matplotlib 内部把变换串成一串时,也是这么读的。你只要记住一句话:加号右边的先动手,就不会踩坑。
旋转还有个常被忽略的参数:绕哪个点转。默认绕原点转,图形在原点附近无所谓,但图形在 (10, 10) 时,绕原点旋转会把整个图形甩出视野。要绕图形自身中心转,得先平移把中心移到原点、再旋转、再平移回去。这个"平移-旋转-平移"的三段式,是旋转任意图形的标准套路,写一次就记住。
💡 关键直觉:把变换串想象成一条流水线,数据点从最右边进,往左依次经过每道工序。
a + b就是"先过 b 这道工序,再过 a 这道工序"。想清楚数据点先经历什么,顺序自然就对了。
不是所有定位都要自己手写变换。多数时候,你只要在 text、annotate、scatter 里传对 transform 参数就够了。真正需要 Affine2D 的场景,通常是这三类:
第一类,给现有对象叠加偏移。比如让一条线整体上移几个单位,或者给散点加一点抖动避免重叠。第二类,画内置函数画不出的几何图案,比如旋转的文字、倾斜的矩形、分形图案。第三类,做动画——每一帧改一点旋转角度或平移量,图形就动起来了。
举一个完整的例子:把 Matplotlib 预置的单位五角星,放到数据坐标的 (3, 4) 位置,并放大 1.5 倍。
from matplotlib.path import Path from matplotlib.patches import PathPatch import matplotlib.transforms as mtransforms star = PathPatch(Path.unit_regular_star(5), facecolor='gold', edgecolor='black') trans = mtransforms.Affine2D().scale(1.5).translate(3, 4) star.set_transform(trans + ax.transData) ax.add_patch(star)
注意变换的写法:.scale(1.5).translate(3, 4) 是链式调用,靠右的 translate 会先执行。单位五角星默认在原点附近、半径大约为 1,先放大 1.5 倍,再平移到 (3, 4),就得到一个位置尺寸都合心意的星星。这个"先缩放后平移"的固定套路,比直接写顶点坐标快得多,也更不容易数错点。
其余时候,别硬上变换。能用 transAxes 定位就定位,能调 xlim 就调 xlim。变换是精密工具,不是万能钥匙,过度使用只会让代码更难读。
正向变换把数据送到屏幕,逆向变换反过来,把屏幕上的点送回数据坐标。什么时候用?交互拾取——用户鼠标点了一下,你只知道像素位置,要把它换算成数据坐标,才能知道点中了哪个数据点。
inv = ax.transData.inverted() x_data, y_data = inv.transform((x_pixel, y_pixel))
inverted() 拿到的是反向变换,把像素坐标变回数据坐标。交互式缩放、十字光标读取数值,底层都是这一招。
还有一种"混合坐标",一半用数据坐标、一半用轴坐标。比如你想让一条竖线"横坐标固定在数据里的某一天,纵坐标永远撑满子图高度",就用 blended_transform_factory:
from matplotlib.transforms import blended_transform_factory trans = blended_transform_factory(ax.transData, ax.transAxes) ax.plot([2020, 2020], [0, 1], transform=trans)
这条线的横坐标走数据坐标(2020 这个位置),纵坐标走轴坐标(0 到 1,正好撑满子图)。单个 transData 或 transAxes 都做不到"两套坐标混用",混合变换就是为这种需求生的。它的两个参数,第一个管横轴,第二个管纵轴,各用各的坐标系,互不干扰。
为什么我传的 transform 没生效?
多半是顺序问题。my_transform + ax.transData 是对的,写反成 ax.transData + my_transform,语义就变了——先做你的变换,再换算到屏幕,多半不是你要的效果。记住加号右边先执行。
transAxes 和 transFigure 长得像,怎么选?
看你要"相对谁"定位。相对单个子图,用 transAxes;相对整张画布、要跨多个子图对齐,用 transFigure。同一个 0.5,0.5,前者是子图中心,后者是画布中心。
Affine2D 能做非线性变换吗?
不能。仿射变换本质是线性加平移,直线还是直线、平行线还是平行线。要扭曲、弯曲、非线性映射,得用更底层的复合变换或自定义变换函数,但那是很少用到的进阶场景,多数需求用 Affine2D 就够。
数据坐标和轴坐标,我该信哪个?
放数据本身信数据坐标,做布局定位信轴坐标。二者不要混用:你想把注解放在"销售额等于 500 的点"旁边,用数据坐标;你想把注解固定在"子图右上角"不动,用轴坐标。混用是坐标变换最常见的翻车来源。
transform 和 transData 是什么关系?
transData 是 Axes 已经算好的一个变换对象,把数据坐标映射到显示坐标。你给 plot 传的 transform 参数,默认就是它。自己叠变换时,通常是在它前面再加一层,所以写成"我的变换 + ax.transData"。可以把 transData 想成最后一站,你的自定义变换是中间站,数据点先过中间站、再到最后一站。
自定义变换 + ax.transData 的方式作用到数据。下一节我们下到最底层:一个形状到底由什么拼出来。你会发现,无论是内置的矩形圆,还是你自己画的任意图形,都逃不出"顶点加代码"这套规则——那就是 Path 和 Patch。