はじめに
このチュートリアルでは、Matplotlib を使ってスケール不変な角度ラベルを作成する方法を学びます。角度の注釈は、線の間や円弧を持つ形状の内部の角度をマークするためによく使われます。Matplotlib は~.patches.Arcを提供していますが、この目的に直接使用する際の固有の問題は、データ空間で円形である弧が表示空間で必ずしも円形であるとは限らないことです。また、弧の半径は、少なくともプロットを自由にズームしても注釈が無限大にならないようにするために、実際のデータ座標とは独立した座標系で定義するのが最適です。これには、弧の中心がデータ空間で定義され、半径がポイントやピクセルなどの物理単位で、または Axes 寸法の比率として定義される解決策が必要です。
VM のヒント
VM の起動が完了したら、左上隅をクリックしてノートブックタブに切り替えて、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):
"""
表示空間で円形に見える 2 つのベクトル間の弧を描画します。
"""
def __init__(self, xy, p1, p2, size=75, unit="points", ax=None,
text="", textposition="inside", text_kw=None, **kwargs):
"""
パラメータ
----------
xy, p1, p2 : 2 つの浮動小数点数のタプルまたは配列
中心位置と 2 つの点。角度注釈は、それぞれ *p1* と *p2* を *xy* と接続する 2 つのベクトルの間に描画されます。
単位はデータ座標です。
size : 浮動小数点数
*unit* で指定された単位での角度注釈の直径。
unit : 文字列
*size* の単位を指定するための次の文字列の 1 つ:
* "pixels": ピクセル
* "points": ポイント。DPI に依存しないように、ピクセルの代わりにポイントを使用します
* "axes width", "axes height": Axes 幅、高さの相対単位
* "axes min", "axes max": 相対 Axes 幅、高さの最小値または最大値
ax : `matplotlib.axes.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)
## 以下の 2 つのメソッドは、テキスト位置を更新するために必要です。
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)
2 本の交差する線を描画し、それらの間の各角度に上記の AngleAnnotation ツールを使ってラベルを付ける。
fig, ax = plt.subplots()
fig.canvas.draw() ## レンダラーを定義するために図を描画する必要があります
ax.set_title("AngleLabel example")
## 2 本の交差する線を描画し、それらの間の各角度に上記の
## ``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 keyword arguments")
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", custom arrow']):
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 クラスの機能を使うと、弧にテキストで注釈を付けることができます。また、テキストラベルの位置やサイズ単位も変更できます。