Best way to plot an angle between two lines in Matplotlib

Question:

I am fairly new to using matplotlib and cannot find any examples that show two lines with the angle between them plotted.

This is my current image:
enter image description here

And this is an example of what I want to achieve:

enter image description here

I usually take a look at the Matplotlib gallery to get an idea of how to perform certain tasks but there does not seem to be anything similar.

Asked By: Michael Aquilina

||

Answers:

You could use matplotlib.patches.Arc to plot an arc of the corresponding angle measure.

To draw the angle arc:

Define a function that could take 2 matplotlib.lines.Line2D objects, calculate the angle and return a matplotlib.patches.Arc object, which you can add to your plot along with the lines.

def get_angle_plot(line1, line2, offset = 1, color = None, origin = [0,0], len_x_axis = 1, len_y_axis = 1):

    l1xy = line1.get_xydata()

    # Angle between line1 and x-axis
    slope1 = (l1xy[1][1] - l1xy[0][2]) / float(l1xy[1][0] - l1xy[0][0])
    angle1 = abs(math.degrees(math.atan(slope1))) # Taking only the positive angle

    l2xy = line2.get_xydata()

    # Angle between line2 and x-axis
    slope2 = (l2xy[1][3] - l2xy[0][4]) / float(l2xy[1][0] - l2xy[0][0])
    angle2 = abs(math.degrees(math.atan(slope2)))

    theta1 = min(angle1, angle2)
    theta2 = max(angle1, angle2)

    angle = theta2 - theta1

    if color is None:
        color = line1.get_color() # Uses the color of line 1 if color parameter is not passed.

    return Arc(origin, len_x_axis*offset, len_y_axis*offset, 0, theta1, theta2, color=color, label = str(angle)+u"u00b0")

To print the angle values :

Incase you want the angle value to be displayed inline, refer this SO Question for how to print inline labels in matplotlib. Note that you must print the label for the arc.

I made a small function which extracts the vertices of the arc and tries to compute the coordinate of the angle text.

This may not be optimal and may not work well with all angle values.

def get_angle_text(angle_plot):
    angle = angle_plot.get_label()[:-1] # Excluding the degree symbol
    angle = "%0.2f"%float(angle)+u"u00b0" # Display angle upto 2 decimal places

    # Get the vertices of the angle arc
    vertices = angle_plot.get_verts()

    # Get the midpoint of the arc extremes
    x_width = (vertices[0][0] + vertices[-1][0]) / 2.0
    y_width = (vertices[0][5] + vertices[-1][6]) / 2.0

    #print x_width, y_width

    separation_radius = max(x_width/2.0, y_width/2.0)

    return [ x_width + separation_radius, y_width + separation_radius, angle]       

Or you could always precompute the label point manually and use text to display the angle value. You can get the angle value from the label of the Arc object using the get_label() method (Since we had set the label to the angle value + the unicode degree symbol).

Example usage of the above functions :

fig = plt.figure()

line_1 = Line2D([0,1], [0,4], linewidth=1, linestyle = "-", color="green")
line_2 = Line2D([0,4.5], [0,3], linewidth=1, linestyle = "-", color="red")

ax = fig.add_subplot(1,1,1)

ax.add_line(line_1)
ax.add_line(line_2)

angle_plot = get_angle_plot(line_1, line_2, 1)
angle_text = get_angle_text(angle_plot) 
# Gets the arguments to be passed to ax.text as a list to display the angle value besides the arc

ax.add_patch(angle_plot) # To display the angle arc
ax.text(*angle_text) # To display the angle value

ax.set_xlim(0,7)
ax.set_ylim(0,5)

If you do not care about inline placement of the angle text. You could use plt.legend() to print the angle value.

Finally :

plt.legend()
plt.show()

Angle plot with 2 Lines

The offset parameter in the function get_angle_plot is used to specify a psudo-radius value to the arc.

This will be useful when angle arcs may overlap with each other.

( In this figure, like I said, my get_angle_text function is not very optimal in placing the text value, but should give you an idea on how to compute the point )

Adding a third line :

line_3 = Line2D([0,7], [0,1], linewidth=1, linestyle = "-", color="brown")
ax.add_line(line_3)
angle_plot = get_angle_plot(line_1, line_3, 2, color="red") # Second angle arc will be red in color
angle_text = get_angle_text(angle_plot)

ax.add_patch(angle_plot) # To display the 2nd angle arc
ax.text(*angle_text) # To display the 2nd angle value

Angle plot with 3 Lines

Answered By: Raghav RV

Taking idea from @user3197452 here is what I use. This version combines text and also takes care of in-proportional axis ratios.

def add_corner_arc(ax, line, radius=.7, color=None, text=None, text_radius=.5, text_rotatation=0, **kwargs):
    ''' display an arc for p0p1p2 angle
    Inputs:
        ax     - axis to add arc to
        line   - MATPLOTLIB line consisting of 3 points of the corner
        radius - radius to add arc
        color  - color of the arc
        text   - text to show on corner
        text_radius     - radius to add text
        text_rotatation - extra rotation for text
        kwargs - other arguments to pass to Arc
    '''

    lxy = line.get_xydata()

    if len(lxy) < 3:
        raise ValueError('at least 3 points in line must be available')

    p0 = lxy[0]
    p1 = lxy[1]
    p2 = lxy[2]

    width = np.ptp([p0[0], p1[0], p2[0]])
    height = np.ptp([p0[1], p1[1], p2[1]])

    n = np.array([width, height]) * 1.0
    p0_ = (p0 - p1) / n
    p1_ = (p1 - p1)
    p2_ = (p2 - p1) / n 

    theta0 = -get_angle(p0_, p1_)
    theta1 = -get_angle(p2_, p1_)

    if color is None:
        # Uses the color line if color parameter is not passed.
        color = line.get_color() 
    arc = ax.add_patch(Arc(p1, width * radius, height * radius, 0, theta0, theta1, color=color, **kwargs))

    if text:
        v = p2_ / np.linalg.norm(p2_)
        if theta0 < 0:
            theta0 = theta0 + 360
        if theta1 < 0:
            theta1 = theta1 + 360
        theta = (theta0 - theta1) / 2 + text_rotatation
        pt = np.dot(rotation_transform(theta), v[:,None]).T * n * text_radius
        pt = pt + p1
        pt = pt.squeeze()
        ax.text(pt[0], pt[1], text,         
                horizontalalignment='left',
                verticalalignment='top',)

    return arc    

get_angle function is what I have posted here, but copied again for completeness.

def get_angle(p0, p1=np.array([0,0]), p2=None):
    ''' compute angle (in degrees) for p0p1p2 corner
    Inputs:
        p0,p1,p2 - points in the form of [x,y]
    '''
    if p2 is None:
        p2 = p1 + np.array([1, 0])
    v0 = np.array(p0) - np.array(p1)
    v1 = np.array(p2) - np.array(p1)

    angle = np.math.atan2(np.linalg.det([v0,v1]),np.dot(v0,v1))
    return np.degrees(angle)

def rotation_transform(theta):
    ''' rotation matrix given theta
    Inputs:
        theta    - theta (in degrees)
    '''
    theta = np.radians(theta)
    A = [[np.math.cos(theta), -np.math.sin(theta)],
         [np.math.sin(theta), np.math.cos(theta)]]
    return np.array(A)

To use it one can do this:

ax = gca()
line, = ax.plot([0, 0, 2], [-1, 0, 0], 'ro-', lw=2)
add_corner_arc(ax, line, text=u'%du00b0' % 90)
Answered By: dashesy

I’ve written a function to create a matplotlib Arc object that takes several helpful arguments. It also works on lines that do not intersect at the origin. For a given set of two lines, there are many possible arcs that the user may want to draw. This function lets one specify which one using the arguments. The text is drawn at the midpoint between the arc and the origin. Improvements are more than welcome in the comments, or on the gist containing this function.

import numpy as np
import matplotlib
import matplotlib.pyplot as plt
Arc = matplotlib.patches.Arc

def halfangle(a, b):
    "Gets the middle angle between a and b, when increasing from a to b"
    if b < a:
        b += 360
    return (a + b)/2 % 360

def get_arc_patch(lines, radius=None, flip=False, obtuse=False, reverse=False, dec=0, fontsize=8):
    """For two sets of two points, create a matplotlib Arc patch drawing 
    an arc between the two lines.
    
    lines: list of lines, of shape [[(x0, y0), (x1, y1)], [(x0, y0), (x1, y1)]]
    radius: None, float or tuple of floats. If None, is set to half the length
    of the shortest line
    orgio: If True, draws the arc around the point (0,0). If False, estimates 
    the intersection of the lines and uses that point.
    flip: If True, flips the arc to the opposite side by 180 degrees
    obtuse: If True, uses the other set of angles. Often used with reverse=True.
    reverse: If True, reverses the two angles so that the arc is drawn 
    "the opposite way around the circle"
    dec: The number of decimals to round to
    fontsize: fontsize of the angle label
    """
    import numpy as np
    from matplotlib.patches import Arc
    
    linedata = [np.array(line.T) for line in lines]
    scales = [np.diff(line).T[0] for line in linedata]
    scales = [s[1] / s[0] for s in scales]
    
    # Get angle to horizontal
    angles = np.array([np.rad2deg(np.arctan(s/1)) for s in scales])
    if obtuse:
        angles[1] = angles[1] + 180
    if flip:
        angles += 180
    if reverse:
        angles = angles[::-1]
        
    angle = abs(angles[1]-angles[0])
    
    if radius is None:
        lengths = np.linalg.norm(lines, axis=(0,1))
        radius = min(lengths)/2
    
    # Solve the point of intersection between the lines:
    t, s = np.linalg.solve(np.array([line1[1]-line1[0], line2[0]-line2[1]]).T, line2[0]-line1[0])
    intersection = np.array((1-t)*line1[0] + t*line1[1])
    # Check if radius is a single value or a tuple
    try:
        r1, r2 = radius
    except:
        r1 = r2 = radius
    arc = Arc(intersection, 2*r1, 2*r2, theta1=angles[1], theta2=angles[0])
    
    half = halfangle(*angles[::-1])
    sin = np.sin(np.deg2rad(half))
    cos = np.cos(np.deg2rad(half))

    r = r1*r2/(r1**2*sin**2+r2**2*cos**2)**0.5
    xy = np.array((r*cos, r*sin))
    xy =  intersection + xy/2
    
    textangle = half if half > 270 or half < 90 else 180 + half 
    textkwargs = {
        'x':xy[0],
        'y':xy[1],
        's':str(round(angle, dec)) + "°",
        'ha':'center',
        'va':'center',
        'fontsize':fontsize,
        'rotation':textangle
    }
    return arc, textkwargs

It creates arcs like in the following image, using the attached script:
Four different arc options

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Arc
# lines are formatted like this: [(x0, y0), (x1, y1)]
line1 = np.array([(1,-2), (3,2)])
line2 = np.array([(2,2), (2,-2)])
lines = [line1, line2]

fig, AX = plt.subplots(nrows=2, ncols=2)
for ax in AX.flatten():
    for line in lines:
        x,y = line.T
        ax.plot(x,y)
        ax.axis('equal')

ax1, ax2, ax3, ax4 = AX.flatten()

arc, angle_text = get_arc_patch(lines)
ax1.add_artist(arc)
ax1.set(title='Default')
ax1.text(**angle_text)

arc, angle_text = get_arc_patch(lines, flip=True)
ax2.add_artist(arc)
ax2.set(title='flip=True')
ax2.text(**angle_text)

arc, angle_text = get_arc_patch(lines, obtuse=True, reverse=True)
ax3.add_artist(arc)
ax3.set(title='obtuse=True, reverse=True')
ax3.text(**angle_text)

arc, angle_text = get_arc_patch(lines, radius=(2,1))
ax4.add_artist(arc)
ax4.set(title='radius=(2,1)')
ax4.text(**angle_text)
plt.tight_layout()
plt.show()
Answered By: TomNorway

I was looking for more of an all in one solution and found the AngleAnnotation class. I highly recommend it.

It is often useful to mark angles between lines or inside shapes with a circular arc. While Matplotlib provides an Arc, an inherent problem when directly using it for such purposes is that an arc being circular in data space is not necessarily circular in display space. Also, the arc’s radius is often best defined in a coordinate system which is independent of the actual data coordinates – at least if you want to be able to freely zoom into your plot without the annotation growing to infinity.

You can find it here https://matplotlib.org/stable/gallery/text_labels_and_annotations/angle_annotation.html
I save it as AngleAnnotation.py (of course you can name it differently) in my working directory and import it in my code with

from AngleAnnotation import AngleAnnotation

here is a snippet of how I use it:

...
#intersection of the two lines
center = (0.0,0.0)
#any point (other than center) on one line
p1 = (6,2)
# any point (other than center) on the other line
p2 = (6,0)
# you may need to switch around p1 and p2 if the arc is drawn enclosing the lines instead
# of between
# ax0 is the axes in which your lines exist
# size sets how large the arc will be
# text sets the label for your angle while textposition lets you rougly set where the label is, here "inside"
# you can pass kwargs to the textlabel using text_kw=dict(...)
# especially useful is the xytext argument which lets you customize the relative position of your label more precisely

am1 = AngleAnnotation(center, p1, p2, ax=ax0, size=130, text="some_label", textposition = "inside", text_kw=dict(fontsize=20, xytext = (10,-5)))

You can find many more details in the link above. It’s working for me on matplotlib 3.4.2 right now.

Answered By: WiseKouichi

I find TomNorway’s approach better, it has more flexibility in other cases than the accepted answer. I tested the code and made some quick fixes for even more applicability by creating a class.

import numpy as np
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.patches import Arc

class LinesAngles:
    def __init__(self, line1, line2, radius=None, flip=False, obtuse=False, reverse=False, dec=0, fontsize=8, title=""):
        """
        line1: list of two points, of shape [[x0, y0], [x1, y1]]
        line2: list of two points, of shape [[x0, y0], [x1, y1]]
        radius: None, float or tuple of floats. If None, is set to half the length
            of the shortest line orgio: If True, draws the arc around the point (0,0). If False, estimates 
            the intersection of the lines and uses that point.
        flip: If True, flips the arc to the opposite side by 180 degrees
        obtuse: If True, uses the other set of angles. Often used with reverse=True.
        reverse: If True, reverses the two angles so that the arc is drawn "the opposite way around the circle"
        dec: The number of decimals to round to
        fontsize: fontsize of the angle label
        title: Title of the plot
        """
        self.line1 = line1
        self.line2 = line2
        self.lines = [line1, line2]
        self.radius = radius
        self.flip = flip
        self.obtuse = obtuse
        self.reverse = reverse
        self.dec = dec
        self.fontsize = fontsize
        self.title = title

    def halfangle(self,a, b) -> float:
        """
        Gets the middle angle between a and b, when increasing from a to b
        a: float, angle in degrees
        b: float, angle in degrees
        returns: float, angle in degrees
        """
        if b < a:
            b += 360
        return (a + b)/2 % 360

    def get_arc_patch(self, lines: list):
        """
        For two sets of two points, create a matplotlib Arc patch drawing 
        an arc between the two lines. 
        lines: list of lines, of shape [[(x0, y0), (x1, y1)], [(x0, y0), (x1, y1)]]
        returns: Arc patch, and text for the angle label
        """
      
        linedata = [np.array(line.T) for line in lines]
        scales = [np.diff(line).T[0] for line in linedata]
        scales = [s[1] / s[0] for s in scales]
        
        # Get angle to horizontal
        angles = np.array([np.rad2deg(np.arctan(s/1)) for s in scales])
        if self.obtuse:
            angles[1] = angles[1] + 180
        if self.flip:
            angles += 180
        if self.reverse:
            angles = angles[::-1]
            
        angle = abs(angles[1]-angles[0])
        
        if self.radius is None:
            lengths = np.linalg.norm(lines, axis=(0,1))
            self.radius = min(lengths)/2
        
        # Solve the point of intersection between the lines:
        t, s = np.linalg.solve(np.array([line1[1]-line1[0], line2[0]-line2[1]]).T, line2[0]-line1[0])
        intersection = np.array((1-t)*line1[0] + t*line1[1])
        # Check if radius is a single value or a tuple
        try:
            r1, r2 = self.radius
        except:
            r1 = r2 = self.radius
        arc = Arc(intersection, 2*r1, 2*r2, theta1=angles[1], theta2=angles[0])
        
        half = self.halfangle(*angles[::-1])
        sin = np.sin(np.deg2rad(half))
        cos = np.cos(np.deg2rad(half))

        r = r1*r2/(r1**2*sin**2+r2**2*cos**2)**0.5
        xy = np.array((r*cos, r*sin))
        xy =  intersection + xy/2
        
        textangle = half if half > 270 or half < 90 else 180 + half 
        textkwargs = {
            'x':xy[0],
            'y':xy[1],
            's':str(round(angle, self.dec)) + "°",
            'ha':'center',
            'va':'center',
            'fontsize':self.fontsize,
            'rotation':textangle
        }
        return arc, textkwargs

    def plot(self) -> None:
        """!
        Plot the lines and the arc
        """

        fig = plt.figure()
        ax = fig.add_subplot(1,1,1)

        for line in self.lines:
            x,y = line.T
            ax.plot(x,y)
            ax.axis('equal')

        arc, angle_text = self.get_arc_patch(self.lines)
        ax.add_artist(arc)
        ax.set(title=self.title)
        ax.text(**angle_text)
        plt.show()

For using it you just create the instance and the plot function.

# lines are formatted like this: [(x0, y0), (x1, y1)]
line1 = np.array([(1,-2), (3,2)])
line2 = np.array([(2,2), (2,-2)])
default = LinesAngles(line1, line2, title="Default")
#Plot single pair of lines
default.plot()

IMAGE: Single plot pair of angles

If you still want to plot multiple cases, I created a function that accepts the instances and plots automatically to the subplots you need.

# lines are formatted like this: [(x0, y0), (x1, y1)]
line1 = np.array([(1,-2), (3,2)])
line2 = np.array([(2,2), (2,-2)])
default = LinesAngles(line1, line2, title="Default")
flip = LinesAngles(line1, line2, title='flip=True', flip=True)
obtuse = LinesAngles(line1, line2, title='obtuse=True, reverse=True', obtuse=True, reverse=True)
radius = LinesAngles(line1, line2, title='radius=(2,1)', radius=(2,1))

#Plot single pair of lines
default.plot()
#Plot multiple line pairs
multiple_plot(default, flip, obtuse, radius, num_subplots=4)

IMAGE: Plot multiple angles

Thanks to TomNorway for his answer, all credit to him I only made some modifications.

Answered By: Salomón Granada
Categories: questions Tags: ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.