17

In a project I'm doing, I have to take in a user input from a structured file (xml). The file contains road data of an area, which I have to plot on to the matplotlib canvas. The problem is that along with the road, I also have to render the road name, and most of the roads are curved. I know how to render text in an angle. But I was wondering whether it is possible to change the text angle midway through the string?

Something like this : Draw rotated text on curved path

But using matplotlib.

eyllanesc
  • 235,170
  • 19
  • 170
  • 241
deepak
  • 1,348
  • 1
  • 17
  • 25

2 Answers2

48

Here is my take on the problem: In order to make the text robust to figure adjustments after drawing, I derive a child class, CurvedText, from matplotlib.text. The CurvedText object takes a string and a curve in the form of x- and y-value arrays. The text to be displayed itself is cut into separate characters, which each are added to the plot at the appropriate position. As matplotlib.text draws nothing if the string is empty, I replace all spaces by invisible 'a's. Upon figure adjustment, the overloaded draw() calls the update_positions() function, which takes care that the character positions and orientations stay correct. To assure the calling order (each character's draw() function will be called as well) the CurvedText object also takes care that the zorder of each character is higher than its own zorder. Following my example here, the text can have any alignment. If the text cannot be fit to the curve at the current resolution, the rest will be hidden, but will appear upon resizing. Below is the code with an example of application.

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://stackoverflow.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()

The result looks like this:

curved text in matplotlib

There are still some problems, when the text follows the concave side of a sharply bending curve. This is because the characters are 'stitched together' along the curve without accounting for overlap. If I have time, I'll try to improve on that. Any comments are very welcome.

Tested on python 3.5 and 2.7

mueslo
  • 708
  • 1
  • 8
  • 20
Thomas Kühn
  • 9,412
  • 3
  • 47
  • 63
  • 2
    Hey, while I don't need the answer anymore, I really appreciate your answer! It was just what I was looking for - 4 years ago! Hope someone else finds it useful :) – deepak Jun 13 '17 at 13:44
  • @Thomas Kühn: Nice use of a derived class, very neat answer, +1 ! I suggested some edits to have full compatibility with python 2.7. They should be visible in the edit queue. – Daan Jun 15 '17 at 21:34
  • It worked, I have a question related, but not being solved yet. Could you please take a look, I don't know how to privately message you. Thanks.https://stackoverflow.com/q/48225888/2525479 – StayFoolish Jan 13 '18 at 09:47
  • 1
    `super(CurvedText, self).__init__(x[0],y[0],' ', axes, **kwargs)` should be `super(CurvedText, self).__init__(x[0],y[0],' ', **kwargs)`, otherwise `axes` gets passed as the color argument to `Text` – mueslo Jul 21 '18 at 11:14
  • I don't think the above works if the curve is a time series with datetime objects. Would someone be able to adapt the above class to handle it? – Sam Jun 06 '19 at 16:32
  • @Sam Can you give me some sample data to work with? – Thomas Kühn Jun 07 '19 at 07:49
  • @ThomasKühn sure `curve = [pd.date_range(end='1/1/2018', periods=100).values, np.sin(np.linspace(0,2*np.pi,100)) ] ` – Sam Jun 07 '19 at 08:22
  • thanks. is there any solution for put text at center of curve? – Peyman habibi Oct 07 '19 at 12:48
  • @Aminhabibi did you try `va='center'` instead of `va='bottom'`? See the [documentation](https://matplotlib.org/3.1.1/api/text_api.html#matplotlib.text.Text.set_verticalalignment) for vertical alignment for more help. – Thomas Kühn Oct 07 '19 at 14:48
  • @ThomasKühn thanks. I want to center text in the circle. such as http://goinkscape.com/wp-content/uploads/2014/10/curve-4.png. the text put symmetrically in the circle medium. – Peyman habibi Oct 07 '19 at 20:08
  • How do increase radius of circle? – Peyman habibi Oct 08 '19 at 05:50
  • There is no functionality to 'stretch' or 'squeeze' the text to a certain length. However, the size of the text is in points, so if you change the figure size, you change its _relative_ size -- you'll have to experiment a bit. The curve is parametric, so you can set up whatever function you want. If you just want a bigger circle, put a factor in the last two examples of `curves`. – Thomas Kühn Oct 10 '19 at 04:48
  • 1
    +1, I found this solution very useful! But can you explain why the text is positioned differently, if I save my figure as .pdf? In general, I find that I need to use a ~25% lower value of `rel_pos` when I save, in order to have the text in the same place. Moreover, if I change the `len` of the line while keeping the value of `rel_pos`, the position of the text changes, which I'd expect, but only _in some cases_. I don't get this? The value of `dpi` in the save statement doesn't change anything. – pela Jul 08 '21 at 10:29
  • This is wonderful, but won't work with math `$ things here$` or anything for which characters can't be processed one at a time, right? – innisfree Nov 22 '21 at 03:41
  • Awesome work, thanks! Have you implement horizontal align since then? – Nicolas Mar 06 '23 at 10:02
  • Unfortunately not, I haven't had much time lately to follow up on this. – Thomas Kühn Apr 23 '23 at 05:57
6

I found your problem quite interesting, so I made something which comes pretty close using the matplotlib text tool:

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

example output

The implementation is far from perfect, but it is a good starting point in my opinion.

Further, it seems that there is some development in matplotlib on having a scatterplot with rotation of the markers, which would be ideal for this case. However, my programming skills are nearly not as hardcore as they need to be to tackle this issue, so I cannot help here.

matplotlib on github: pull request

matplotlib on github: issue

Daan
  • 940
  • 10
  • 22