Created
April 14, 2024 16:59
-
-
Save Pocket-titan/a4f8c7c0531e4b27386cae3039f9d105 to your computer and use it in GitHub Desktop.
Improved 2d angle annotation for matplotlib
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import matplotlib.pyplot as plt | |
import numpy as np | |
from matplotlib.patches import Arc | |
from matplotlib.transforms import Bbox, IdentityTransform, TransformedBbox | |
class AngleAnnotation(Arc): | |
""" | |
Draws an arc between two vectors which appears circular in display space. | |
""" | |
def __init__( | |
self, | |
xy, | |
p1, | |
p2, | |
size=75, | |
unit="points", | |
ax=None, | |
text=None, | |
textposition="inside", | |
text_kw=None, | |
**kwargs, | |
): | |
""" | |
Parameters | |
---------- | |
xy, p1, p2 : tuple or array of two floats | |
Center position and two points. Angle annotation is drawn between | |
the two vectors connecting *p1* and *p2* with *xy*, respectively. | |
Units are data coordinates. | |
size : float | |
Diameter of the angle annotation in units specified by *unit*. | |
unit : str | |
One of the following strings to specify the unit of *size*: | |
* "pixels": pixels | |
* "points": points, use points instead of pixels to not have a dependence on the DPI | |
* "axes width", "axes height": relative units of Axes width, height | |
* "axes min", "axes max": minimum or maximum of relative Axes width, height | |
* "data width", "data height": relative units of data width, height | |
* "data min", "data max": minimum or maximum of relative data width, height | |
ax : `matplotlib.axes.Axes` | |
The Axes to add the angle annotation to. | |
text : str | |
The text to mark the angle with. | |
textposition : {"inside", "outside", "edge"} | |
Whether to show the text in- or outside the arc. "edge" can be used | |
for custom positions anchored at the arc's edge. | |
text_kw : dict | |
Dictionary of arguments passed to the Annotation. | |
**kwargs | |
Further parameters are passed to `matplotlib.patches.Arc`. Use this | |
to specify, color, linewidth etc. of the arc. | |
""" | |
self.ax = ax or plt.gca() | |
self._xydata = xy # in data coordinates | |
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) | |
if text is not None: | |
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 = self.ax.annotate(text, xy=self._center, **self.kw) | |
if self.unit[:4] == "data": | |
self.ax.get_figure().draw_without_rendering() | |
self.update_arc() | |
def transform_vec(self, vec): | |
return self.ax.transData.transform(vec) - self._center | |
def update_arc(self): | |
_size = self.get_size() | |
self._width = _size | |
self._height = _size | |
def get_size(self): | |
if self.unit == "pixels": | |
factor = 1.0 | |
if self.unit == "points": | |
factor = self.ax.figure.dpi / 72.0 | |
else: | |
if self.unit[:4] == "axes": | |
b = TransformedBbox(Bbox.unit(), self.ax.transAxes) | |
if self.unit[:4] == "data": | |
b = TransformedBbox(Bbox.unit(), self.ax.transData) | |
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 center in pixels""" | |
return self.ax.transData.transform(self._xydata) | |
def set_center(self, xy): | |
"""set center in data coordinates""" | |
self._xydata = xy | |
def get_theta(self, vec): | |
vec_in_pixels = self.transform_vec(vec) | |
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 | |
# Redefine attributes of the Arc to always give values in pixel space | |
_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) | |
# The following two methods are needed to update the text position. | |
def draw(self, renderer): | |
self.update_text() | |
self.update_arc() | |
super().draw(renderer) | |
def update_text(self): | |
if not hasattr(self, "text") or self.text is None: | |
return | |
c = self._center | |
s = self.get_size() | |
angle_span = (self.theta2 - self.theta1) % 360 | |
angle = np.deg2rad(self.theta1 + angle_span / 2) | |
if self.textposition == "inside": | |
r = s / np.interp(angle_span, [60, 90, 135, 180], [3.3, 3.5, 3.8, 4]) | |
else: | |
r = s / 2 | |
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) | |
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)]) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Added the option to specify the
size
in data coordinates and fixed some bugs with the original implementation.