简体   繁体   中英

Align arbitrarily rotated text annotations relative to the text, not the bounding box

While trying to answer an old, unanswered question , I encountered a little problem concerning text annotations in matplotlib : When adding rotated text to a figure at a certain position, the text is aligned relative to the bounding box of the text, not the (imaginary) rotated box that holds the text itself. This is maybe best explained with a little example: matplotlib中文本对齐的示例

The figure shows pieces of text with different rotation angles and different alignment options. For each text object, the red point denotes the coordinate given to the ax.text() function. The blue box is the rotated frame around the text, and the black box is the approximate bounding box of the text (it's a bit too big, but one should get the idea). It's easily visible that, for the cases where alignment is at the edges (left, right, top, bottom), the red dot is on the sides or edges of the bounding box, not the text frame. The only alignment option, where the text is aligned in an intuitive way is if both horizontal and vertical alignments are set to 'center'. Now, this is not a bug, but intended behaviour as outlined here . However, in some situations, it's not very practical, as the position has to be adjusted 'manually' for the text to be in the desired place, and this adjustment changes if the rotation angle changes or if the figure is re-scaled.

The question is, is there a robust way to generate text that is aligned with the text frame rather with the bounding box. I already have a solution to the problem, but it was quite tedious to figure out, so I thought I'd share it.

After some searching and digging into the matplotlib code itself, and with some inspiration from here and here , I have come up with the following solution:

from matplotlib import pyplot as plt
from matplotlib import patches, text
import numpy as np
import math


class TextTrueAlign(text.Text):
    """
    A Text object that always aligns relative to the text, not
    to the bounding box; also when the text is rotated.
    """
    def __init__(self, x, y, text, **kwargs):
        super().__init__(x,y,text, **kwargs)
        self.__Ha = self.get_ha()
        self.__Va = self.get_va()
        self.__Rotation = self.get_rotation()
        self.__Position = self.get_position()

    def draw(self, renderer, *args, **kwargs):
        """
        Overload of the Text.draw() function
        """
        self.update_position()
        super().draw(renderer, *args, **kwargs)

    def update_position(self):
        """
        As the (center/center) alignment always aligns to the center of the
        text, even upon rotation, we make use of this here. The algorithm
        first computes the (x,y) offset for the un-rotated text between
        centered alignment and the alignment requested by the user. This offset
        is then transformed according to the requested rotation angle and the
        aspect ratio of the graph. Finally the transformed offset is used to
        shift the text such that the alignment point coincides with the
        requested coordinate also when the text is rotated.
        """

        #resetting to the original state:
        self.set_rotation(0)
        self.set_va(self.__Va)
        self.set_ha(self.__Ha)
        self.set_position(self.__Position)

        ax = self.axes
        xy = self.__Position

        ##determining the aspect ratio:
        ##from https://stackoverflow.com/questions/41597177/get-aspect-ratio-of-axes
        ##data limits
        xlim = ax.get_xlim()
        ylim = ax.get_ylim()
        ## Axis size on figure
        figW, figH = ax.get_figure().get_size_inches()
        ## Ratio of display units
        _, _, w, h = ax.get_position().bounds
        ##final aspect ratio
        aspect = ((figW * w)/(figH * h))*(ylim[1]-ylim[0])/(xlim[1]-xlim[0])


        ##from https://stackoverflow.com/questions/5320205/matplotlib-text-dimensions
        ##getting the current renderer, so that
        ##get_window_extent() works
        renderer = ax.figure.canvas.get_renderer()

        ##computing the bounding box for the un-rotated text
        ##aligned as requested by the user
        bbox1  = self.get_window_extent(renderer=renderer)
        bbox1d = ax.transData.inverted().transform(bbox1)

        width  = bbox1d[1,0]-bbox1d[0,0]
        height = bbox1d[1,1]-bbox1d[0,1]

        ##re-aligning text to (center,center) as here rotations
        ##do what is intuitively expected
        self.set_va('center')
        self.set_ha('center')

        ##computing the bounding box for the un-rotated text
        ##aligned to (center,center)
        bbox2 = self.get_window_extent(renderer=renderer)
        bbox2d = ax.transData.inverted().transform(bbox2)

        ##computing the difference vector between the two
        ##alignments
        dr = np.array(bbox2d[0]-bbox1d[0])

        ##computing the rotation matrix, which also accounts for
        ##the aspect ratio of the figure, to stretch squeeze
        ##dimensions as needed
        rad = np.deg2rad(self.__Rotation)
        rot_mat = np.array([
            [math.cos(rad), math.sin(rad)*aspect],
            [-math.sin(rad)/aspect, math.cos(rad)]
        ])

        ##computing the offset vector
        drp = np.dot(dr,rot_mat)

        ##setting new position
        self.set_position((xy[0]-drp[0],xy[1]-drp[1]))

        ##setting rotation value back to the one requested by the user
        self.set_rotation(self.__Rotation)




if __name__ == '__main__':
    fig, axes = plt.subplots(3,3, figsize=(10,10),dpi=100)
    aligns = [ (va,ha) for va in ('top', 'center', 'bottom')
               for ha in ('left', 'center', 'right')]

    xys = [[i,j] for j in np.linspace(0.9,0.1,5) for i in np.linspace(0.1,0.9,5)]
    degs = np.linspace(0,360,25)

    for ax, align in zip(axes.reshape(-1), aligns):

        ax.set_xlim([-0.1,1.1])
        ax.set_ylim([-0.1,1.1])

        for deg,xy in zip(degs,xys):
            ax.plot(*xy,'r.')
            text = TextTrueAlign(
                x = xy[0],
                y = xy[1],
                text='test',
                axes = ax,
                rotation = deg,
                va = align[0],
                ha = align[1],
                bbox=dict(facecolor='none', edgecolor='blue', pad=0.0),
            )
            ax.add_artist(text)
            ax.set_title('alignment = {}'.format(align))

    fig.tight_layout()
    plt.show()

The example is somewhat lengthy, because I had to write a class that is derived from the matplotlib.text.Text class in order to properly update the text object upon redraw (for instance if the figure is re-scaled). The code relies on the text always aligning to its center point, if both horizontal and vertical alignments are set to 'center'. It takes the difference between the bounding boxes of the text with center alignment and with requested alignment to predict an offset by which the text needs to be shifted after rotation. The output of the example looks like this: 显示结果的示例TextTrueAlign As the aspect ratio of the graph , axes , and figure are taken into account, this approach is also robust to re-sizing of the figure.

I think that, by treating the methods set_ha() , set_va() , set_rotation() , and set_position() the way I do, I might have broken some of the original functionality of matplotlib.text.Text , but that should be relatively easy to fix by overloading these functions and replacing a few self with super() .

Any comments or suggestions how to improve this would be highly appreciated. Also, if you happen to test this and find any bugs or flaws, please let me know and I will try to fix them. Hope this is useful to someone :)

New solution rotation_mode="anchor"

There is actually an argument rotation_mode to matplotlib.text.Text , which steers exactly the requested functionality. The default is rotation_mode="default" which recreates the unwanted behaviour from the question, while rotation_mode="anchor" anchors the point of revolution according to the text itself and not its bounding box.

ax.text(x,y,'test', rotation = deg, rotation_mode="anchor")

Also see the demo_text_rotation_mode example .

With this, the example from the question can be created easily without the need to subclass Text .

from matplotlib import pyplot as plt
import numpy as np

fig, axes = plt.subplots(3,3, figsize=(10,10),dpi=100)
aligns = [ (va,ha) for va in ('top', 'center', 'bottom')
           for ha in ('left', 'center', 'right')]

xys = [[i,j] for j in np.linspace(0.9,0.1,5) for i in np.linspace(0.1,0.9,5)]
degs = np.linspace(0,360,25)

for ax, align in zip(axes.reshape(-1), aligns):

    ax.set_xlim([-0.1,1.1])
    ax.set_ylim([-0.1,1.1])

    for deg,xy in zip(degs,xys):
        x,y = xy
        ax.plot(x,y,'r.')
        text = ax.text(x,y,'test',
            rotation = deg,
            rotation_mode="anchor",  ### <--- this is the key
            va = align[0],
            ha = align[1],
            bbox=dict(facecolor='none', edgecolor='blue', pad=0.0),
        )
        ax.set_title('alignment = {}'.format(align))

fig.tight_layout()
plt.show()

old solution, subclassing Text

In case one is still interested, the solution given by @ThomasKühn is of course working fine, but has some drawbacks when text is used in a non-cartesian system, because it calculates the offset needed in Data coordinates.

The following would be a version of the code which offsets the text in display coordinates by using a transformation, which is temporarily attached while drawing the text. It can therefore also be used eg in polar plots.

from matplotlib import pyplot as plt
from matplotlib import patches, text
import matplotlib.transforms
import numpy as np

class TextTrueAlign(text.Text):
    """
    A Text object that always aligns relative to the text, not
    to the bounding box; also when the text is rotated.
    """
    def __init__(self, x, y, text, **kwargs):
        super(TextTrueAlign, self).__init__(x,y,text, **kwargs)
        self.__Ha = self.get_ha()
        self.__Va = self.get_va()


    def draw(self, renderer, *args, **kwargs):
        """
        Overload of the Text.draw() function
        """
        trans = self.get_transform()
        offset = self.update_position()
        # while drawing, set a transform which is offset
        self.set_transform(trans + offset)
        super(TextTrueAlign, self).draw(renderer, *args, **kwargs)
        # reset to original transform
        self.set_transform(trans)

    def update_position(self):
        """
        As the (center/center) alignment always aligns to the center of the
        text, even upon rotation, we make use of this here. The algorithm
        first computes the (x,y) offset for the un-rotated text between
        centered alignment and the alignment requested by the user. This offset
        is then rotated by the given rotation angle.
        Finally a translation of the negative offset is returned.
        """
        #resetting to the original state:
        rotation = self.get_rotation()
        self.set_rotation(0)
        self.set_va(self.__Va)
        self.set_ha(self.__Ha)
        ##from https://stackoverflow.com/questions/5320205/matplotlib-text-dimensions
        ##getting the current renderer, so that
        ##get_window_extent() works
        renderer = self.axes.figure.canvas.get_renderer()
        ##computing the bounding box for the un-rotated text
        ##aligned as requested by the user
        bbox1  = self.get_window_extent(renderer=renderer)
        ##re-aligning text to (center,center) as here rotations
        ##do what is intuitively expected
        self.set_va('center')
        self.set_ha('center')
        ##computing the bounding box for the un-rotated text
        ##aligned to (center,center)
        bbox2 = self.get_window_extent(renderer=renderer)
        ##computing the difference vector between the two alignments
        dr = np.array(bbox2.get_points()[0]-bbox1.get_points()[0])
        ##computing the rotation matrix, which also accounts for
        ##the aspect ratio of the figure, to stretch squeeze
        ##dimensions as needed
        rad = np.deg2rad(rotation)
        rot_mat = np.array([
            [np.cos(rad), np.sin(rad)],
            [-np.sin(rad), np.cos(rad)]
        ])
        ##computing the offset vector
        drp = np.dot(dr,rot_mat)        
        # transform to translate by the negative offset vector
        offset = matplotlib.transforms.Affine2D().translate(-drp[0],-drp[1])
        ##setting rotation value back to the one requested by the user
        self.set_rotation(rotation)
        return offset

if __name__ == '__main__':
    fig, axes = plt.subplots(3,3, figsize=(10,10),dpi=100)
    aligns = [ (va,ha) for va in ('top', 'center', 'bottom')
               for ha in ('left', 'center', 'right')]

    xys = [[i,j] for j in np.linspace(0.9,0.1,5) for i in np.linspace(0.1,0.9,5)]
    degs = np.linspace(0,360,25)

    for ax, align in zip(axes.reshape(-1), aligns):

        ax.set_xlim([-0.1,1.1])
        ax.set_ylim([-0.1,1.1])

        for deg,xy in zip(degs,xys):
            x,y = xy
            ax.plot(x,y,'r.')
            text = TextTrueAlign(
                x = x,
                y = y,
                text='test',
                axes = ax,
                rotation = deg,
                va = align[0],
                ha = align[1],
                bbox=dict(facecolor='none', edgecolor='blue', pad=0.0),
            )
            ax.add_artist(text)
            ax.set_title('alignment = {}'.format(align))

    fig.tight_layout()
    plt.show()

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM