How to rotate matplotlib annotation to match a line?

后端 未结 3 1344
予麋鹿
予麋鹿 2020-12-15 05:41

Have a plot with several diagonal lines with different slopes. I would like to annotate these lines with a text label that matches the slope of the lines.

Something

相关标签:
3条回答
  • 2020-12-15 05:46

    I came up with something that works for me. Note the grey dashed lines:

    annotated lines

    The rotation must be set manually, but this must be done AFTER draw() or layout. So my solution is to associate lines with annotations, then iterate through them and do this:

    1. get line's data transform (i.e. goes from data coordinates to display coordinates)
    2. transform two points along the line to display coordinates
    3. find slope of displayed line
    4. set text rotation to match this slope

    This isn't perfect, because matplotlib's handling of rotated text is all wrong. It aligns by the bounding box and not by the text baseline.

    Some font basics if you're interested about text rendering: http://docs.oracle.com/javase/tutorial/2d/text/fontconcepts.html

    This example shows what matplotlib does: http://matplotlib.org/examples/pylab_examples/text_rotation.html

    The only way I found to have a label properly next to the line is to align by center in both vertical and horizontal. I then offset the label by 10 points to the left to make it not overlap. Good enough for my application.

    Here is my code. I draw the line however I want, then draw the annotation, then bind them with a helper function:

    line, = fig.plot(xdata, ydata, '--', color=color)
    
    # x,y appear on the midpoint of the line
    
    t = fig.annotate("text", xy=(x, y), xytext=(-10, 0), textcoords='offset points', horizontalalignment='left', verticalalignment='bottom', color=color)
    text_slope_match_line(t, x, y, line)
    

    Then call another helper function after layout but before savefig (For interactive images I think you'll have to register for draw events and call update_text_slopes in the handler)

    plt.tight_layout()
    update_text_slopes()
    

    The helpers:

    rotated_labels = []
    def text_slope_match_line(text, x, y, line):
        global rotated_labels
    
        # find the slope
        xdata, ydata = line.get_data()
    
        x1 = xdata[0]
        x2 = xdata[-1]
        y1 = ydata[0]
        y2 = ydata[-1]
    
        rotated_labels.append({"text":text, "line":line, "p1":numpy.array((x1, y1)), "p2":numpy.array((x2, y2))})
    
    def update_text_slopes():
        global rotated_labels
    
        for label in rotated_labels:
            # slope_degrees is in data coordinates, the text() and annotate() functions need it in screen coordinates
            text, line = label["text"], label["line"]
            p1, p2 = label["p1"], label["p2"]
    
            # get the line's data transform
            ax = line.get_axes()
    
            sp1 = ax.transData.transform_point(p1)
            sp2 = ax.transData.transform_point(p2)
    
            rise = (sp2[1] - sp1[1])
            run = (sp2[0] - sp1[0])
    
            slope_degrees = math.degrees(math.atan(rise/run))
    
            text.set_rotation(slope_degrees)
    
    0 讨论(0)
  • 2020-12-15 05:50

    Even though this question is old, I keep coming across it and get frustrated, that it does not quite work. I reworked it into a class LineAnnotation and helper line_annotate such that it

    1. uses the slope at a specific point x,
    2. works with re-layouting and resizing, and
    3. accepts a relative offset perpendicular to the slope.
    x = np.linspace(np.pi, 2*np.pi)
    line, = plt.plot(x, np.sin(x))
    
    for x in [3.5, 4.0, 4.5, 5.0, 5.5, 6.0]:
        line_annotate(str(x), line, x)
    

    I originally put it into a public gist, but @Adam asked me to include it here.

    import numpy as np
    from matplotlib.text import Annotation
    from matplotlib.transforms import Affine2D
    
    
    class LineAnnotation(Annotation):
        """A sloped annotation to *line* at position *x* with *text*
        Optionally an arrow pointing from the text to the graph at *x* can be drawn.
        Usage
        -----
        fig, ax = subplots()
        x = linspace(0, 2*pi)
        line, = ax.plot(x, sin(x))
        ax.add_artist(LineAnnotation("text", line, 1.5))
        """
    
        def __init__(
            self, text, line, x, xytext=(0, 5), textcoords="offset points", **kwargs
        ):
            """Annotate the point at *x* of the graph *line* with text *text*.
    
            By default, the text is displayed with the same rotation as the slope of the
            graph at a relative position *xytext* above it (perpendicularly above).
    
            An arrow pointing from the text to the annotated point *xy* can
            be added by defining *arrowprops*.
    
            Parameters
            ----------
            text : str
                The text of the annotation.
            line : Line2D
                Matplotlib line object to annotate
            x : float
                The point *x* to annotate. y is calculated from the points on the line.
            xytext : (float, float), default: (0, 5)
                The position *(x, y)* relative to the point *x* on the *line* to place the
                text at. The coordinate system is determined by *textcoords*.
            **kwargs
                Additional keyword arguments are passed on to `Annotation`.
    
            See also
            --------
            `Annotation`
            `line_annotate`
            """
            assert textcoords.startswith(
                "offset "
            ), "*textcoords* must be 'offset points' or 'offset pixels'"
    
            self.line = line
            self.xytext = xytext
    
            # Determine points of line immediately to the left and right of x
            xs, ys = line.get_data()
    
            def neighbours(x, xs, ys, try_invert=True):
                inds, = np.where((xs <= x)[:-1] & (xs > x)[1:])
                if len(inds) == 0:
                    assert try_invert, "line must cross x"
                    return neighbours(x, xs[::-1], ys[::-1], try_invert=False)
    
                i = inds[0]
                return np.asarray([(xs[i], ys[i]), (xs[i+1], ys[i+1])])
            
            self.neighbours = n1, n2 = neighbours(x, xs, ys)
            
            # Calculate y by interpolating neighbouring points
            y = n1[1] + ((x - n1[0]) * (n2[1] - n1[1]) / (n2[0] - n1[0]))
    
            kwargs = {
                "horizontalalignment": "center",
                "rotation_mode": "anchor",
                **kwargs,
            }
            super().__init__(text, (x, y), xytext=xytext, textcoords=textcoords, **kwargs)
    
        def get_rotation(self):
            """Determines angle of the slope of the neighbours in display coordinate system
            """
            transData = self.line.get_transform()
            dx, dy = np.diff(transData.transform(self.neighbours), axis=0).squeeze()
            return np.rad2deg(np.arctan2(dy, dx))
    
        def update_positions(self, renderer):
            """Updates relative position of annotation text
            Note
            ----
            Called during annotation `draw` call
            """
            xytext = Affine2D().rotate_deg(self.get_rotation()).transform(self.xytext)
            self.set_position(xytext)
            super().update_positions(renderer)
    
    
    def line_annotate(text, line, x, *args, **kwargs):
        """Add a sloped annotation to *line* at position *x* with *text*
    
        Optionally an arrow pointing from the text to the graph at *x* can be drawn.
    
        Usage
        -----
        x = linspace(0, 2*pi)
        line, = ax.plot(x, sin(x))
        line_annotate("sin(x)", line, 1.5)
    
        See also
        --------
        `LineAnnotation`
        `plt.annotate`
        """
        ax = line.axes
        a = LineAnnotation(text, line, x, *args, **kwargs)
        if "clip_on" in kwargs:
            a.set_clip_path(ax.patch)
        ax.add_artist(a)
        return a
    
    0 讨论(0)
  • 2020-12-15 06:07

    This is the exact same process and basic code as given by @Adam --- it's just restructured to be (hopefully) a little more convenient.

    def label_line(line, label, x, y, color='0.5', size=12):
        """Add a label to a line, at the proper angle.
    
        Arguments
        ---------
        line : matplotlib.lines.Line2D object,
        label : str
        x : float
            x-position to place center of text (in data coordinated
        y : float
            y-position to place center of text (in data coordinates)
        color : str
        size : float
        """
        xdata, ydata = line.get_data()
        x1 = xdata[0]
        x2 = xdata[-1]
        y1 = ydata[0]
        y2 = ydata[-1]
    
        ax = line.get_axes()
        text = ax.annotate(label, xy=(x, y), xytext=(-10, 0),
                           textcoords='offset points',
                           size=size, color=color,
                           horizontalalignment='left',
                           verticalalignment='bottom')
    
        sp1 = ax.transData.transform_point((x1, y1))
        sp2 = ax.transData.transform_point((x2, y2))
    
        rise = (sp2[1] - sp1[1])
        run = (sp2[0] - sp1[0])
    
        slope_degrees = np.degrees(np.arctan2(rise, run))
        text.set_rotation(slope_degrees)
        return text
    

    Used like:

    import numpy as np
    import matplotlib.pyplot as plt
    
    ...
    fig, axes = plt.subplots()
    color = 'blue'
    line, = axes.plot(xdata, ydata, '--', color=color)
    ...
    label_line(line, "Some Label", x, y, color=color)
    

    Edit: note that this method still needs to be called after the figure layout is finalized, otherwise things will be altered.

    See: https://gist.github.com/lzkelley/0de9e8bf2a4fe96d2018f1b1bd5a0d3c

    0 讨论(0)
提交回复
热议问题