Рендеринг криволинейного текста в matplotlib

В проекте, который я делаю, я должен принять пользовательский ввод из структурированного файла (xml). Файл содержит дорожные данные области, которую я должен нанести на холст matplotlib. Проблема в том, что наряду с дорогой мне также приходится отображать название дороги, и большинство дорог изогнутые. Я знаю, как сделать текст под углом. Но мне было интересно, можно ли изменить угол текста посередине строки?

Примерно так: нарисуйте повернутый текст по изогнутой траектории

Но с помощью matplotlib.

2 ответа

Решение

Вот мой взгляд на проблему: чтобы сделать текст устойчивым к корректировкам фигуры после рисования, я получил дочерний класс, CurvedText, от matplotlib.text, CurvedText Объект принимает строку и кривую в виде x- а также yмассивы Текст, который должен отображаться сам, разрезается на отдельные символы, каждый из которых добавляется к графику в соответствующей позиции. Как matplotlib.text Ничего не рисует, если строка пуста, я заменяю все пробелы невидимыми "а". на figure регулировка, перегружен draw() вызывает update_positions() функция, которая заботится о том, чтобы положения и ориентации персонажей оставались правильными. Чтобы обеспечить порядок вызова (каждый персонаж draw() функция также будет вызвана) CurvedText Объект также заботится о том, чтобы zorder каждого персонажа выше его собственного zorder, Следуя моему примеру здесь, текст может иметь любое выравнивание. Если текст не может быть вписан в кривую с текущим разрешением, остальное будет скрыто, но появится при изменении размера. Ниже приведен код с примером приложения.

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

class CurvedText(mtext.Text):
    """
    A text object that follows an arbitrary curve.
    """
    def __init__(self, x, y, text, axes, **kwargs):
        super(CurvedText, self).__init__(x[0],y[0],' ', **kwargs)

        axes.add_artist(self)

        ##saving the curve:
        self.__x = x
        self.__y = y
        self.__zorder = self.get_zorder()

        ##creating the text objects
        self.__Characters = []
        for c in text:
            if c == ' ':
                ##make this an invisible 'a':
                t = mtext.Text(0,0,'a')
                t.set_alpha(0.0)
            else:
                t = mtext.Text(0,0,c, **kwargs)

            #resetting unnecessary arguments
            t.set_ha('center')
            t.set_rotation(0)
            t.set_zorder(self.__zorder +1)

            self.__Characters.append((c,t))
            axes.add_artist(t)


    ##overloading some member functions, to assure correct functionality
    ##on update
    def set_zorder(self, zorder):
        super(CurvedText, self).set_zorder(zorder)
        self.__zorder = self.get_zorder()
        for c,t in self.__Characters:
            t.set_zorder(self.__zorder+1)

    def draw(self, renderer, *args, **kwargs):
        """
        Overload of the Text.draw() function. Do not do
        do any drawing, but update the positions and rotation
        angles of self.__Characters.
        """
        self.update_positions(renderer)

    def update_positions(self,renderer):
        """
        Update positions and rotations of the individual text elements.
        """

        #preparations

        ##determining the aspect ratio:
        ##from https://stackru.com/a/42014041/2454357

        ##data limits
        xlim = self.axes.get_xlim()
        ylim = self.axes.get_ylim()
        ## Axis size on figure
        figW, figH = self.axes.get_figure().get_size_inches()
        ## Ratio of display units
        _, _, w, h = self.axes.get_position().bounds
        ##final aspect ratio
        aspect = ((figW * w)/(figH * h))*(ylim[1]-ylim[0])/(xlim[1]-xlim[0])

        #points of the curve in figure coordinates:
        x_fig,y_fig = (
            np.array(l) for l in zip(*self.axes.transData.transform([
            (i,j) for i,j in zip(self.__x,self.__y)
            ]))
        )

        #point distances in figure coordinates
        x_fig_dist = (x_fig[1:]-x_fig[:-1])
        y_fig_dist = (y_fig[1:]-y_fig[:-1])
        r_fig_dist = np.sqrt(x_fig_dist**2+y_fig_dist**2)

        #arc length in figure coordinates
        l_fig = np.insert(np.cumsum(r_fig_dist),0,0)

        #angles in figure coordinates
        rads = np.arctan2((y_fig[1:] - y_fig[:-1]),(x_fig[1:] - x_fig[:-1]))
        degs = np.rad2deg(rads)


        rel_pos = 10
        for c,t in self.__Characters:
            #finding the width of c:
            t.set_rotation(0)
            t.set_va('center')
            bbox1  = t.get_window_extent(renderer=renderer)
            w = bbox1.width
            h = bbox1.height

            #ignore all letters that don't fit:
            if rel_pos+w/2 > l_fig[-1]:
                t.set_alpha(0.0)
                rel_pos += w
                continue

            elif c != ' ':
                t.set_alpha(1.0)

            #finding the two data points between which the horizontal
            #center point of the character will be situated
            #left and right indices:
            il = np.where(rel_pos+w/2 >= l_fig)[0][-1]
            ir = np.where(rel_pos+w/2 <= l_fig)[0][0]

            #if we exactly hit a data point:
            if ir == il:
                ir += 1

            #how much of the letter width was needed to find il:
            used = l_fig[il]-rel_pos
            rel_pos = l_fig[il]

            #relative distance between il and ir where the center
            #of the character will be
            fraction = (w/2-used)/r_fig_dist[il]

            ##setting the character position in data coordinates:
            ##interpolate between the two points:
            x = self.__x[il]+fraction*(self.__x[ir]-self.__x[il])
            y = self.__y[il]+fraction*(self.__y[ir]-self.__y[il])

            #getting the offset when setting correct vertical alignment
            #in data coordinates
            t.set_va(self.get_va())
            bbox2  = t.get_window_extent(renderer=renderer)

            bbox1d = self.axes.transData.inverted().transform(bbox1)
            bbox2d = self.axes.transData.inverted().transform(bbox2)
            dr = np.array(bbox2d[0]-bbox1d[0])

            #the rotation/stretch matrix
            rad = rads[il]
            rot_mat = np.array([
                [math.cos(rad), math.sin(rad)*aspect],
                [-math.sin(rad)/aspect, math.cos(rad)]
            ])

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

            #setting final position and rotation:
            t.set_position(np.array([x,y])+drp)
            t.set_rotation(degs[il])

            t.set_va('center')
            t.set_ha('center')

            #updating rel_pos to right edge of character
            rel_pos += w-used




if __name__ == '__main__':
    Figure, Axes = plt.subplots(2,2, figsize=(7,7), dpi=100)


    N = 100

    curves = [
        [
            np.linspace(0,1,N),
            np.linspace(0,1,N),
        ],
        [
            np.linspace(0,2*np.pi,N),
            np.sin(np.linspace(0,2*np.pi,N)),
        ],
        [
            -np.cos(np.linspace(0,2*np.pi,N)),
            np.sin(np.linspace(0,2*np.pi,N)),
        ],
        [
            np.cos(np.linspace(0,2*np.pi,N)),
            np.sin(np.linspace(0,2*np.pi,N)),
        ],
    ]

    texts = [
        'straight lines work the same as rotated text',
        'wavy curves work well on the convex side',
        'you even can annotate parametric curves',
        'changing the plotting direction also changes text orientation',
    ]

    for ax, curve, text in zip(Axes.reshape(-1), curves, texts):
        #plotting the curve
        ax.plot(*curve, color='b')

        #adjusting plot limits
        stretch = 0.2
        xlim = ax.get_xlim()
        w = xlim[1] - xlim[0]
        ax.set_xlim([xlim[0]-stretch*w, xlim[1]+stretch*w])
        ylim = ax.get_ylim()
        h = ylim[1] - ylim[0]
        ax.set_ylim([ylim[0]-stretch*h, ylim[1]+stretch*h])

        #adding the text
        text = CurvedText(
            x = curve[0],
            y = curve[1],
            text=text,#'this this is a very, very long text',
            va = 'bottom',
            axes = ax, ##calls ax.add_artist in __init__
        )

    plt.show()

Результат выглядит так:

изогнутый текст в matplotlib

Есть все еще некоторые проблемы, когда текст следует за вогнутой стороной резко изгибающейся кривой. Это связано с тем, что символы "сшиты" вдоль кривой без учета наложения. Если у меня будет время, я постараюсь улучшить это. Любые комментарии очень приветствуются.

Проверено на python 3,5 и 2,7

Я нашел вашу проблему довольно интересной, поэтому я сделал кое-что, что довольно близко, используя текстовый инструмент matplotlib:

from __future__ import division
import itertools
import matplotlib.pyplot as plt
import numpy as np
%matplotlib inline

# define figure and axes properties
fig, ax = plt.subplots(figsize=(8,6))
ax.set_xlim(left=0, right=10)
ax.set_ylim(bottom=-1.5, top=1.5)
(xmin, xmax), (ymin, ymax) = ax.get_xlim(), ax.get_ylim()

# calculate a shape factor, more explanation on usage further
# it is a representation of the distortion of the actual image compared to a 
# cartesian space:
fshape = abs(fig.get_figwidth()*(xmax - xmin)/(ymax - ymin)/fig.get_figheight())

# the text you want to plot along your line
thetext = 'the text is flowing      '

# generate a cycler, so that the string is cycled through
lettercycler = itertools.cycle(tuple(thetext))

# generate dummy river coordinates
xvals = np.linspace(1, 10, 300)
yvals = np.sin(xvals)**3

# every XX datapoints, a character is printed
markerevery = 10

# calculate the rotation angle for the labels (in degrees)
# the angle is calculated as the slope between two datapoints.
# it is then multiplied by a shape factor to get from the angles in a
# cartesian space to the angles in this figure
# first calculate the slope between two consecutive points, multiply with the
# shape factor, get the angle in radians with the arctangens functions, and
# convert to degrees
angles = np.rad2deg(np.arctan((yvals[1:]-yvals[:-1])/(xvals[1:]-xvals[:-1])*fshape))

# plot the 'river'
ax.plot(xvals, yvals, 'b', linewidth=3)

# loop over the data points, but only plot a character every XX steps
for counter in np.arange(0, len(xvals)-1, step=markerevery):
    # plot the character in between two datapoints
    xcoord = (xvals[counter] + xvals[counter+1])/2.
    ycoord = (yvals[counter] + yvals[counter+1])/2.

    # plot using the text method, set the rotation so it follows the line,
    # aling in the center for a nicer look, optionally, a box can be drawn
    # around the letter
    ax.text(xcoord, ycoord, lettercycler.next(),
            fontsize=25, rotation=angles[counter],
            horizontalalignment='center', verticalalignment='center',
            bbox=dict(facecolor='white', edgecolor='white', alpha=0.5))

пример вывода

Реализация далеко не идеальна, но, на мой взгляд, является хорошей отправной точкой.

Кроме того, кажется, что в matplotlib есть некоторые разработки по созданию диаграммы рассеяния с вращением маркеров, что было бы идеально для этого случая. Тем не менее, мои навыки программирования почти не так хардкора, как они должны быть для решения этой проблемы, поэтому я не могу здесь помочь.

matplotlib на github: запрос на получение доступа

matplotlib на github: выпуск

Другие вопросы по тегам