简介
在本教程中,你将学习如何使用 Matplotlib 创建一个尺度不变的角度标签。角度注释通常用于用圆弧标记线之间或形状内部的角度。虽然 Matplotlib 提供了一个 ~.patches.Arc,但直接将其用于此类目的时存在一个固有问题,即在数据空间中为圆形的弧在显示空间中不一定是圆形的。此外,弧的半径通常最好在一个独立于实际数据坐标的坐标系中定义——至少如果你想能够自由缩放绘图而注释不会增长到无穷大的话。这就需要一种解决方案,其中弧的中心在数据空间中定义,但其半径以像点或像素这样的物理单位定义,或者作为轴维度的比例。
虚拟机提示
虚拟机启动完成后,点击左上角切换到笔记本标签以访问 Jupyter Notebook 进行练习。
有时,你可能需要等待几秒钟让 Jupyter Notebook 完成加载。由于 Jupyter Notebook 的限制,操作的验证无法自动化。
如果你在学习过程中遇到问题,随时向 Labby 提问。课程结束后提供反馈,我们将立即为你解决问题。
导入所需库
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Arc
from matplotlib.transforms import Bbox, IdentityTransform, TransformedBbox
定义 AngleAnnotation 类
class AngleAnnotation(Arc):
"""
绘制两条向量之间的弧,该弧在显示空间中呈现为圆形。
"""
def __init__(self, xy, p1, p2, size=75, unit="points", ax=None,
text="", textposition="inside", text_kw=None, **kwargs):
"""
参数
----------
xy, p1, p2 : 两个浮点数组成的元组或数组
中心位置和两个点。角度注释绘制在分别连接 *p1* 和 *p2* 与 *xy* 的两条向量之间。
单位是数据坐标。
size : 浮点数
角度注释的直径,单位由 *unit* 指定。
unit : 字符串
以下字符串之一,用于指定 *size* 的单位:
* "pixels":像素
* "points":点,使用点而不是像素,以避免依赖 DPI
* "axes width", "axes height":轴宽度、高度的相对单位
* "axes min", "axes max":相对轴宽度、高度的最小值或最大值
ax : `matplotlib.axes.Axes`
添加角度注释的轴。
text : 字符串
用于标记角度的文本。
textposition : {"inside", "outside", "edge"}
是否在弧内或弧外显示文本。"edge" 可用于在弧边缘锚定的自定义位置。
text_kw : 字典
传递给注释的参数字典。
**kwargs
其他参数传递给 `matplotlib.patches.Arc`。用于指定弧的颜色、线宽等。
"""
self.ax = ax or plt.gca()
self._xydata = xy ## 数据坐标
self.vec1 = p1
self.vec2 = p2
self.size = size
self.unit = unit
self.textposition = textposition
super().__init__(self._xydata, size, size, angle=0.0,
theta1=self.theta1, theta2=self.theta2, **kwargs)
self.set_transform(IdentityTransform())
self.ax.add_patch(self)
self.kw = dict(ha="center", va="center",
xycoords=IdentityTransform(),
xytext=(0, 0), textcoords="offset points",
annotation_clip=True)
self.kw.update(text_kw or {})
self.text = ax.annotate(text, xy=self._center, **self.kw)
def get_size(self):
factor = 1.
if self.unit == "points":
factor = self.ax.figure.dpi / 72.
elif self.unit[:4] == "axes":
b = TransformedBbox(Bbox.unit(), self.ax.transAxes)
dic = {"max": max(b.width, b.height),
"min": min(b.width, b.height),
"width": b.width, "height": b.height}
factor = dic[self.unit[5:]]
return self.size * factor
def set_size(self, size):
self.size = size
def get_center_in_pixels(self):
"""返回像素空间中的中心"""
return self.ax.transData.transform(self._xydata)
def set_center(self, xy):
"""在数据坐标中设置中心"""
self._xydata = xy
def get_theta(self, vec):
vec_in_pixels = self.ax.transData.transform(vec) - self._center
return np.rad2deg(np.arctan2(vec_in_pixels[1], vec_in_pixels[0]))
def get_theta1(self):
return self.get_theta(self.vec1)
def get_theta2(self):
return self.get_theta(self.vec2)
def set_theta(self, angle):
pass
## 重新定义 Arc 的属性,始终在像素空间中给出值
_center = property(get_center_in_pixels, set_center)
theta1 = property(get_theta1, set_theta)
theta2 = property(get_theta2, set_theta)
width = property(get_size, set_size)
height = property(get_size, set_size)
## 以下两个方法用于更新文本位置。
def draw(self, renderer):
self.update_text()
super().draw(renderer)
def update_text(self):
c = self._center
s = self.get_size()
angle_span = (self.theta2 - self.theta1) % 360
angle = np.deg2rad(self.theta1 + angle_span / 2)
r = s / 2
if self.textposition == "inside":
r = s / np.interp(angle_span, [60, 90, 135, 180],
[3.3, 3.5, 3.8, 4])
self.text.xy = c + r * np.array([np.cos(angle), np.sin(angle)])
if self.textposition == "outside":
def R90(a, r, w, h):
if a < np.arctan(h/2/(r+w/2)):
return np.sqrt((r+w/2)**2 + (np.tan(a)*(r+w/2))**2)
else:
c = np.sqrt((w/2)**2+(h/2)**2)
T = np.arcsin(c * np.cos(np.pi/2 - a + np.arcsin(h/2/c))/r)
xy = r * np.array([np.cos(a + T), np.sin(a + T)])
xy += np.array([w/2, h/2])
return np.sqrt(np.sum(xy**2))
def R(a, r, w, h):
aa = (a % (np.pi/4))*((a % (np.pi/2)) <= np.pi/4) + \
(np.pi/4 - (a % (np.pi/4)))*((a % (np.pi/2)) >= np.pi/4)
return R90(aa, r, *[w, h][::int(np.sign(np.cos(2*a)))])
bbox = self.text.get_window_extent()
X = R(angle, r, bbox.width, bbox.height)
trans = self.ax.figure.dpi_scale_trans.inverted()
offs = trans.transform(((X-s/2), 0))[0] * 72
self.text.set_position([offs*np.cos(angle), offs*np.sin(angle)])
定义辅助函数 plot_angle
def plot_angle(ax, pos, angle, length=0.95, acol="C0", **kwargs):
vec2 = np.array([np.cos(np.deg2rad(angle)), np.sin(np.deg2rad(angle))])
xy = np.c_[[length, 0], [0, 0], vec2*length].T + np.array(pos)
ax.plot(*xy.T, color=acol)
return AngleAnnotation(pos, xy[0], xy[2], ax=ax, **kwargs)
绘制两条相交直线,并使用上述 AngleAnnotation 工具标记它们之间的每个角度。
fig, ax = plt.subplots()
fig.canvas.draw() ## 需要绘制图形以定义渲染器
ax.set_title("AngleLabel 示例")
## 绘制两条相交直线,并使用上述
## ``AngleAnnotation`` 工具标记它们之间的每个角度。
center = (4.5, 650)
p1 = [(2.5, 710), (6.0, 605)]
p2 = [(3.0, 275), (5.5, 900)]
line1, = ax.plot(*zip(*p1))
line2, = ax.plot(*zip(*p2))
point, = ax.plot(*center, marker="o")
am1 = AngleAnnotation(center, p1[1], p2[1], ax=ax, size=75, text=r"$\alpha$")
am2 = AngleAnnotation(center, p2[1], p1[0], ax=ax, size=35, text=r"$\beta$")
am3 = AngleAnnotation(center, p1[0], p2[0], ax=ax, size=75, text=r"$\gamma$")
am4 = AngleAnnotation(center, p2[0], p1[1], ax=ax, size=35, text=r"$\theta$")
## 展示角度弧以及文本的一些样式选项。
p = [(6.0, 400), (5.3, 410), (5.6, 300)]
ax.plot(*zip(*p))
am5 = AngleAnnotation(p[1], p[0], p[2], ax=ax, size=40, text=r"$\Phi$",
linestyle="--", color="gray", textposition="outside",
text_kw=dict(fontsize=16, color="gray"))
plt.show()
展示不同的文本位置和尺寸单位
fig, (ax1, ax2) = plt.subplots(nrows=2, sharex=True)
fig.suptitle("AngleLabel 关键字参数")
fig.canvas.draw() ## 需要绘制图形以定义渲染器
## 展示不同的文本位置。
ax1.margins(y=0.4)
ax1.set_title("textposition")
kw = dict(size=75, unit="points", text=r"$60°$")
am6 = plot_angle(ax1, (2.0, 0), 60, textposition="inside", **kw)
am7 = plot_angle(ax1, (3.5, 0), 60, textposition="outside", **kw)
am8 = plot_angle(ax1, (5.0, 0), 60, textposition="edge",
text_kw=dict(bbox=dict(boxstyle="round", fc="w")), **kw)
am9 = plot_angle(ax1, (6.5, 0), 60, textposition="edge",
text_kw=dict(xytext=(30, 20), arrowprops=dict(arrowstyle="->",
connectionstyle="arc3,rad=-0.2")), **kw)
for x, text in zip([2.0, 3.5, 5.0, 6.5], ['"inside"', '"outside"', '"edge"',
'"edge", 自定义箭头']):
ax1.annotate(text, xy=(x, 0), xycoords=ax1.get_xaxis_transform(),
bbox=dict(boxstyle="round", fc="w"), ha="left", fontsize=8,
annotation_clip=True)
## 展示不同的尺寸单位。通过交互式更改图形大小可以最好地观察到这种效果
ax2.margins(y=0.4)
ax2.set_title("unit")
kw = dict(text=r"$60°$", textposition="outside")
am10 = plot_angle(ax2, (2.0, 0), 60, size=50, unit="pixels", **kw)
am11 = plot_angle(ax2, (3.5, 0), 60, size=50, unit="points", **kw)
am12 = plot_angle(ax2, (5.0, 0), 60, size=0.25, unit="axes min", **kw)
am13 = plot_angle(ax2, (6.5, 0), 60, size=0.25, unit="axes max", **kw)
for x, text in zip([2.0, 3.5, 5.0, 6.5], ['"pixels"', '"points"',
'"axes min"', '"axes max"']):
ax2.annotate(text, xy=(x, 0), xycoords=ax2.get_xaxis_transform(),
bbox=dict(boxstyle="round", fc="w"), ha="left", fontsize=8,
annotation_clip=True)
plt.show()
总结
在本教程中,你学习了如何使用 Matplotlib 创建一个尺度不变的角度标签。AngleAnnotation 类的功能允许你用文本注释弧。你还可以修改文本标签的位置以及尺寸单位。