将任意旋转的文本注释相对于文本对齐,而不是边界框

时间:2017-05-23 19:23:49

标签: python python-3.x matplotlib

在尝试回答old, unanswered question时,我在matplotlib中遇到了一些关于文本注释的问题:将旋转文本添加到某个位置的图形时,文本相对于边界框对齐文本,而不是保存文本本身的(虚构的)旋转框。这可能是一个小例子最好的解释: example of text alignment in matplotlib

该图显示了具有不同旋转角度和不同对齐选项的文本片段。对于每个文本对象,红点表示给予ax.text()函数的坐标。蓝色框是文本周围的旋转框,黑框是文本的近似边界框(它有点太大了,但应该得到这个想法)。很容易看到,对于边缘(左,右,顶部,底部)对齐的情况,红点位于边界框的边或边缘,而不是文本框。文本以直观方式对齐的唯一对齐选项是水平和垂直对齐都设置为“中心”。现在,这不是一个错误,而是按照here概述的预期行为。但是,在某些情况下,它不是很实用,因为必须手动调整位置'要使文本位于所需位置,如果旋转角度发生变化或者图形重新缩放,则此调整会发生变化。

问题是,是否有一种强大的方法来生成与文本框架对齐的文本,而不是边界框。我已经找到了问题的解决方案,但弄清楚这一点非常繁琐,所以我想我会分享它。

2 个答案:

答案 0 :(得分:5)

经过一些搜索并深入研究matplotlib代码本身,并从herehere获得了一些灵感,我提出了以下解决方案:

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()

该示例有点冗长,因为我必须编写一个派生自matplotlib.text.Text类的类,以便在重绘时正确更新文本对象(例如,如果图形被重新缩放)。如果水平和垂直对齐都设置为“中心”,则代码依赖于始终与其中心点对齐的文本。它采用文本的边界框与中心对齐和请求对齐之间的差异来预测文本在旋转后需要移动的偏移量。示例的输出如下所示: example showing results TextTrueAlign 由于考虑了graphaxesfigure的宽高比,这种方法对于重新调整图形大小也很有用。

我认为,通过按照我的方式处理方法set_ha()set_va()set_rotation()set_position(),我可能会打破一些原始功能matplotlib.text.Text,但通过重载这些功能并将self替换为super(),这应该相对容易修复。

如何改进这一点的任何意见或建议将受到高度赞赏。此外,如果你碰巧测试这个并发现任何错误或缺陷,请告诉我,我会尝试解决它们。希望这对某人有用:)

答案 1 :(得分:2)

新解决方案rotation_mode="anchor"

实际上有rotation_modematplotlib.text.Text的参数,它完全控制所请求的功能。默认值为rotation_mode="default",可以从问题中重新创建不需要的行为,而 rotation_mode="anchor" 根据文本本身而不是其边界框来固定旋转点。

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

另见the demo_text_rotation_mode example

通过这种方式,可以轻松创建问题中的示例,而无需子类化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()

旧解决方案,继承Text

如果一个人仍感兴趣,solution given by @ThomasKühn当然工作正常,但在非笛卡尔系统中使用文本时有一些缺点,因为它计算数据坐标中所需的偏移量。

以下是使用转换来偏移显示坐标中文本的代码版本,该转换在绘制文本时临时附加。因此它也可以用于例如在极地地块。

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()