Does matplotlib offer a feature to spread multiple figures evenly on the screen? Or does anyone know of a toolbox that is able to achieve this? I'm getting tired of doing this by hand.
import matplotlib.pyplot as plt
for i in range(5):
plt.figure()
plt.show()
This creates five figures that are staying on top of each other. To check what is on figure 1, I have to move the other 4 figures to the side.
On MacOS, I could use the Ctrl+↓ shortcut just to get a glimpse on all the figures. Alternatively, I could write the plots to files and inspect the images in a gallery. But I wondered if there is a custom window manager for matplotlib out there that possibly offers some more flexibility.
In Matlab, I got used to tools such as spreadfigure or autoArrangeFigures.
You can control the position of the plot window using the figure manager like so:
import matplotlib.pyplot as plt
start_x, start_y, dx, dy = (0, 0, 640, 550)
for i in range(5):
if i%3 == 0:
x = start_x
y = start_y + (dy * (i//3) )
plt.figure()
mngr = plt.get_current_fig_manager()
mngr.window.setGeometry(x, y, dx, dy)
x += dx
plt.show()
This will result in five graphs shown beside each other like so:
Hopefully, this is what you're looking for!
It appears that matplotlib does not offer such a feature out-of-the-box. In addition, there is no "backend-agnostic" way to control the figure geometry, as discussed here.
I therefore wrote tile_figures() to implement this mini-feature extending Anwarvic's suggestion by some tiling logic and a simple backend abstraction. It currently supports only Qt- or Tk-backends, but it certainly can be extended to other backends as well.
Happy tiling!
Usage
tile_figures(cols=3, rows=2, screen_rect=None, tile_offsets=None)
# You may have to adjust the available screen area and a tile offset
# for nice results. This works well for my MacOS.
tile_figure(screen_rect=(0,22,1440,740), tile_offsets=(0,22))
# Run a test with 10 figures. Note that you cannot switch the backend dynamically.
# It's best to set mpl.use(<backend>) at the very beginning of your script.
# https://matplotlib.org/faq/usage_faq.html#what-is-a-backend
test(n_figs=10, backend="Qt5Agg", screen_rect=(0,22,1440,750), tile_offsets=(0,22))
Result
Implementation
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
def screen_geometry(monitor=0):
try:
from screeninfo import get_monitors
sizes = [(s.x, s.y, s.width, s.height) for s in get_monitors()]
return sizes[monitor]
except ModuleNotFoundError:
default = (0, 0, 900, 600)
print("screen_geometry: module screeninfo is no available.")
print("Returning default: %s" % (default,))
return default
def set_figure_geometry(fig, backend, x, y, w, h):
if backend in ("Qt5Agg", "Qt4Agg"):
fig.canvas.manager.window.setGeometry(x, y, w, h)
#fig.canvas.manager.window.statusBar().setVisible(False)
#fig.canvas.toolbar.setVisible(True)
elif backend in ("TkAgg",):
fig.canvas.manager.window.wm_geometry("%dx%d+%d+%d" % (w,h,x,y))
else:
print("This backend is not supported yet.")
print("Set the backend with matplotlib.use(<name>).")
return
def tile_figures(cols=3, rows=2, screen_rect=None, tile_offsets=None):
"""
Tile figures. If more than cols*rows figures are present, cols and
rows are adjusted. For now, a Qt- or Tk-backend is required.
import matplotlib
matplotlib.use('Qt5Agg')
matplotlib.use('TkAgg')
Arguments:
cols, rows: Number of cols, rows shown. Will be adjusted if the
number of figures is larger than cols*rows.
screen_rect: A 4-tuple specifying the geometry (x,y,w,h) of the
screen area used for tiling (in pixels). If None, the
system's screen is queried using the screeninfo module.
tile_offsets: A 2-tuple specifying the offsets in x- and y- direction.
Can be used to compensate the title bar height.
"""
assert(isinstance(cols, int) and cols>0)
assert(isinstance(rows, int) and rows>0)
assert(screen_rect is None or len(screen_rect)==4)
backend = mpl.get_backend()
if screen_rect is None:
screen_rect = screen_geometry()
if tile_offsets is None:
tile_offsets = (0,0)
sx, sy, sw, sh = screen_rect
sx += tile_offsets[0]
sy += tile_offsets[1]
fig_ids = plt.get_fignums()
# Adjust tiles if necessary.
tile_aspect = cols/rows
while len(fig_ids) > cols*rows:
cols += 1
rows = max(np.round(cols/tile_aspect), rows)
# Apply geometry per figure.
w = int(sw/cols)
h = int(sh/rows)
for i, num in enumerate(fig_ids):
fig = plt.figure(num)
x = (i%cols) *(w+tile_offsets[0])+sx
y = (i//cols)*(h+tile_offsets[1])+sy
set_figure_geometry(fig, backend, x, y, w, h)
def test(n_figs=10, backend="Qt5Agg", **kwargs):
mpl.use(backend)
plt.close("all")
for i in range(n_figs):
plt.figure()
tile_figures(**kwargs)
plt.show()
The tile-offset in y-direction is best chosen as the height of the title bar. On my MacOS it is 22. This value can be queried programmatically using for example Qt.
from PyQt5 import QtWidgets as qtw
enum = qtw.QStyle.PM_TitleBarHeight
style = qtw.QApplication.style()
tile_offset_y = style.pixelMetric(enum)
Related
I would like to plot a 2D spectrum where the x coordinate is the spectral dimension (wavelength) and the y coordinate is the spatial dimension (in arcseconds of the sky) using pyqtgraph.
I've been able to do this using an ImageItem() but I cannot seem to figure out how to display the x and y axes in the right coordinates.
I don't want to just change the labels or the ticks but indeed the coordinates of the plot because I later need to perform operations using these values (wavelength and arcsecs).
Here is a minimal working example:
import pyqtgraph as pg
import numpy as np
# The fake data
wvlg = np.linspace(300, 600, 5000)
arcsec = np.linspace(-5, 5, 100)
flux = np.ones((wvlg.shape[0], arcsec.shape[0])) * np.exp(-(arcsec)**2/0.1)
flux += np.random.normal(0, 0.1, size=(wvlg.shape[0], arcsec.shape[0]))
# The plotting
win = pg.GraphicsLayoutWidget(show=True)
ax2D = win.addPlot(title='2D spectrum', row=0, col=0)
img = pg.ImageItem()
img.setImage(flux)
ax2D.addItem(img)
# Some line converting the x and y values to wvlg and arcsec
This gives an image where the x and y axis show the index value, whereas I would like to show the corresponding wavelength and arcsec values.
Is there an easy way to do this that I just grossly overlooked in the documentation?
You can use the setRect method of the ImageItem class to set the extent of the data. See my example below.
Note that I moved the image by half a pixel so that the pixel centers match the exact coordinates. Otherwise the coordinates would align with one of the pixel's corner points.
import pyqtgraph as pg
import numpy as np
from PyQt5 import QtCore, QtWidgets
def main():
app = QtWidgets.QApplication([])
# The fake data
wvlg = np.linspace(300, 600, 5000)
arcsec = np.linspace(-5, 5, 100)
flux = np.ones((wvlg.shape[0], arcsec.shape[0])) * np.exp(-(arcsec)**2/0.1)
flux += np.random.normal(0, 0.1, size=(wvlg.shape[0], arcsec.shape[0]))
# The plotting
win = pg.GraphicsLayoutWidget()
ax2D = win.addPlot(title='2D spectrum', row=0, col=0)
img = pg.ImageItem()
img.setImage(flux)
ax2D.addItem(img)
print(flux.shape)
# Move the image by half a pixel so that the center of the pixels are
# located at the coordinate values
dx = wvlg[1]-wvlg[0]
dy = arcsec[1]-arcsec[0]
print("pixel size x: {}, pixel size y: {}".format(dx, dy))
rect = QtCore.QRectF(wvlg[0] - dx/2, arcsec[0] - dy/2,
wvlg[-1] - wvlg[0], arcsec[-1] - arcsec[0])
print(rect)
img.setRect(rect)
ax2D.setLabels(left='arcsec', bottom='wvlg')
win.show()
win.raise_()
app.exec_()
if __name__ == "__main__":
main()
EDIT: I figured out that the Problem always occours if one tries to plot to two different lists of figures. Does that mean that one can not do plots to different figure-lists in the same loop? See latest code for much simpler sample of a problem.
I try to analyze a complex set of data which consists basically about measurements of electric devices under different conditions. Hence, the code is a bit more complex but I tried to strip it down to a working example - however it is still pretty long. Hence, let me explain what you see: You see 3 classes with Transistor representing an electronic device. It's attribute Y represents the measurement data - consisting of 2 sets of measurements. Each Transistor belongs to a group - 2 in this example. And some groups belong to the same series - one series where both groups are included in this example.
The aim is now to plot all measurement data for each Transistor (not shown), then to also plot all data belonging to the same group in one plot each and all data of the same series to one plot. In order to program it in an efficent way without having a lot of loops my idea was to use the object orientated nature of matplotlib - I will have figures and subplots for each level of plotting (initialized in initGrpPlt and initSeriesPlt) which are then filled with only one loop over all Transistors (in MainPlt: toGPlt and toSPlt). In the end it should only be printed / saved to a file / whatever (PltGrp and PltSeries).
The Problem: Even though I specify where to plot, python plots the series plots into the group plots. You can check this yourself by running the code with the line 'toSPlt(trans,j)' and without. I have no clue why python does this because in the function toSPlt I explicetly say that python should use the subplots from the series-subplot-list. Would anyone have an idea to why this is like this and how to solve this problem in an elegent way?
Read the code from the bottom to the top, that should help with understanding.
Kind regards
# -*- coding: utf-8 -*-
import matplotlib.pyplot as plt
import matplotlib.cm as cm
import numpy as np
maxNrVdrain = 2
X = np.linspace(-np.pi, np.pi, 256,endpoint=True)
A = [[1*np.cos(X),2*np.cos(X),3*np.cos(X),4*np.cos(X)],[1*np.tan(X),2*np.tan(X),3*np.tan(X),4*np.tan(X)]]
B = [[2* np.sin(X),4* np.sin(X),6* np.sin(X),8* np.sin(X)],[2*np.cos(X),4*np.cos(X),6*np.cos(X),8*np.cos(X)]]
class Transistor(object):
_TransRegistry = []
def __init__(self,y1,y2):
self._TransRegistry.append(self)
self.X = X
self.Y = [y1,y2]
self.group = ''
class Groups():
_GroupRegistry = []
def __init__(self,trans):
self._GroupRegistry.append(self)
self.transistors = [trans]
self.figlist = []
self.axlist = []
class Series():
_SeriesRegistry = []
def __init__(self,group):
self._SeriesRegistry.append(self)
self.groups = [group]
self.figlist = []
self.axlist = []
def initGrpPlt():
for group in Groups._GroupRegistry:
for j in range(maxNrVdrain):
group.figlist.append(plt.figure(j))
group.axlist.append(group.figlist[j].add_subplot(111))
return
def initSeriesPlt():
for series in Series._SeriesRegistry:
for j in range(maxNrVdrain):
series.figlist.append(plt.figure(j))
series.axlist.append(series.figlist[j].add_subplot(111))
return
def toGPlt(trans,j):
colour = cm.rainbow(np.linspace(0, 1, 4))
group = trans.group
group.axlist[j].plot(trans.X,trans.Y[j], color=colour[group.transistors.index(trans)], linewidth=1.5, linestyle="-")
return
def toSPlt(trans,j):
colour = cm.rainbow(np.linspace(0, 1, 2))
series = Series._SeriesRegistry[0]
group = trans.group
if group.transistors.index(trans) == 0:
series.axlist[j].plot(trans.X,trans.Y[j],color=colour[series.groups.index(group)], linewidth=1.5, linestyle="-", label = 'T = nan, RH = nan' )
else:
series.axlist[j].plot(trans.X,trans.Y[j],color=colour[series.groups.index(group)], linewidth=1.5, linestyle="-")
return
def PltGrp(group,j):
ax = group.axlist[j]
ax.set_title('Test Grp')
return
def PltSeries(series,j):
ax = series.axlist[j]
ax.legend(loc='upper right', frameon=False)
ax.set_title('Test Series')
return
def MainPlt():
initGrpPlt()
initSeriesPlt()
for trans in Transistor._TransRegistry:
for j in range(maxNrVdrain):
toGPlt(trans,j)
toSPlt(trans,j)#plots to group plot for some reason
for j in range(maxNrVdrain):
for group in Groups._GroupRegistry:
PltGrp(group,j)
plt.show()
return
def Init():
for j in range(4):
trans = Transistor(A[0][j],A[1][j])
if j == 0:
Groups(trans)
else:
Groups._GroupRegistry[0].transistors.append(trans)
trans.group = Groups._GroupRegistry[0]
Series(Groups._GroupRegistry[0])
for j in range(4):
trans = Transistor(B[0][j],B[1][j])
if j == 0:
Groups(trans)
else:
Groups._GroupRegistry[1].transistors.append(trans)
trans.group = Groups._GroupRegistry[1]
Series._SeriesRegistry[0].groups.append(Groups._GroupRegistry[1])
return
def main():
Init()
MainPlt()
return
main()
latest example that does not work:
import matplotlib.pyplot as plt
import matplotlib.cm as cm
import numpy as np
X = np.linspace(-np.pi, np.pi, 256,endpoint=True)
Y1 = np.cos(X)
Y2 = np.sin(X)
figlist1 = []
figlist2 = []
axlist1 = []
axlist2 = []
for j in range(4):
figlist1.append(plt.figure(j))
axlist1.append(figlist1[j].add_subplot(111))
figlist2.append(plt.figure(j))#this should be a new set of figures!
axlist2.append(figlist2[j].add_subplot(111))
colour = cm.rainbow(np.linspace(0, 1, 4))
axlist1[j].plot(X,j*Y1, color=colour[j], linewidth=1.5, linestyle="-")
axlist1[j].set_title('Test Grp 1')
colour = cm.rainbow(np.linspace(0, 1, 4))
axlist2[j].plot(X,j*Y2, color=colour[int(j/2)], linewidth=1.5, linestyle="-")
axlist2[j].set_title('Test Grp 2')
plt.show()
Ok, stupid mistake if one thinks of the Background but maybe someone has a similar Problem and is unable to see the cause as I was first. So here is the solution:
The Problem is that the Name of the listobjects like figlist1[j] do not define the figure - they are just pointers to the actual figure object. and if such an object is created by plt.figure(j) one has to make sure that j is different for each figure - hence, in a Loop where multiple figures shall be initialized one Needs to somehow Change the number of the figure or the first object will be overwritten. Hope that helps! Cheers.
I want to fill a bunch of polygons with line hatch. The lines must have a specific angle with respect to x-axis. I found that matplotlib already suppots some hatch classes and one can define a custom class (like How to fill a polygon with a custom hatch in matplotlib?). I tried to generate a custom hatch but when I append it to the list of hatches the init function doesn't know the angle. I tried with the following class:
class AngularHatch(HatchPatternBase):
def __init__(self, hatch, density, angle):
self.num_lines = int((hatch.count('{'))*density*3)
self.num_vertices = self.num_lines * 2
self.R = np.array([[np.cos(angle), -np.sin(angle)],
[np.sin(angle), np.cos(angle)]])
def set_vertices_and_codes(self, vertices, codes):
steps, stepsize = np.linspace(0.0, 1.0, self.num_lines, False,
retstep=True)
steps += stepsize / 2.
vertices[0::2, 0] = 0
vertices[0::2, 1] = steps
vertices[1::2, 0] = 1
vertices[1::2, 1] = steps
for i, v in enumerate(vertices):
vertices[i] = self.R.dot(v)
codes[0::2] = Path.MOVETO
codes[1::2] = Path.LINETO
Then I add this class to the list of available classes for hatching. However this will not generate the correct lines since the code is modified from the HorizontalHatch source code here and I think this generates lines in the unit square. Moreover I need to generate this patch for a specific angle for each polygon I want to render. ¿Any ideas on how to give the correct angle to this class per polygon?
The following does not solve this issue. It just solves part of the problem and shows at which point the approach fails. I am currently convinced that hatching with arbitrary angles is not possible with matplotlib, because the size of the unit cell is fixed.
To overcome the problem of setting the angle, one may define a custom format from which to take the angle information. E.g. "{angle}{factor}", such that "{45}{2}" would produce a hatching with an angle of 45° and a density factor of 2.
I then do not completely understand the attempt of calculating the vertices. To replicate the behaviour of the hatches which are built-in, one may rotate them directly.
The problem is that this way the line hatches work only for angles of 45°. This is because the lines at the edges of the unit cell do not align well. See the following:
import numpy as np
import matplotlib.hatch
import matplotlib.path
import matplotlib.pyplot as plt
from matplotlib.patches import Ellipse, Rectangle
class AngularHatch(matplotlib.hatch.HatchPatternBase):
def __init__(self, hatch, density):
self.num_lines=0
self.num_vertices=0
if hatch[0] == "{":
h = hatch.strip("{}").split("}{")
angle = np.deg2rad(float(h[0])-45)
d = float(h[1])
self.num_lines = int(density*d)
self.num_vertices = (self.num_lines + 1) * 2
self.R = np.array([[np.cos(angle), -np.sin(angle)],
[np.sin(angle), np.cos(angle)]])
def set_vertices_and_codes(self, vertices, codes):
steps = np.linspace(-0.5, 0.5, self.num_lines + 1, True)
vertices[0::2, 0] = 0.0 + steps
vertices[0::2, 1] = 0.0 - steps
vertices[1::2, 0] = 1.0 + steps
vertices[1::2, 1] = 1.0 - steps
codes[0::2] = matplotlib.path.Path.MOVETO
codes[1::2] = matplotlib.path.Path.LINETO
vertices[:,:] = np.dot((vertices-0.5),self.R)+0.5
matplotlib.hatch._hatch_types.append(AngularHatch)
fig = plt.figure()
ax = fig.add_subplot(111)
ellipse = ax.add_patch(Rectangle((0.1, 0.1), 0.4, 0.8, fill=False))
ellipse.set_hatch('{45}{1}')
ellipse.set_color('red')
ellipse = ax.add_patch(Rectangle((0.55, 0.1), 0.4, 0.8, fill=False))
ellipse.set_hatch('{22}{1}')
ellipse.set_color('blue')
plt.show()
I have a big number of screenshots that need to be cropped. All the images look similar - there is a rectangular window with blue border, containing some graphical elements inside. This window is contained inside another one but I need to crop only the inner window. Across all images the dimensions of the inner window are different and so is the content. The content in most cases includes elements with rectangular form and sometimes - blue border, the same border as the inner window. I am mentioning this because I am thinking of the following flow:
A script that goes through all images in the target directory. For each of them:
Find the area to be cropped (inner window)
Crop the area
Save the file
How can this be done? Python is not compulsory, can be any other too also.
It's not straightforward but this is a possible recipe:
import matplotlib.pyplot as plt
import numpy as np
def synthimage():
w,h = 300,200
im = np.random.randint(0,255,(w,h,3))/255
xa = np.random.randint(50,w-60)
xb = xa + np.random.randint(50,90)
ya = np.random.randint(50,h-60)
yb = ya + np.random.randint(20,50)
im[xa:xb,ya] = np.array([1,0,0])
im[xa:xb,yb] = np.array([1,0,0])
im[xa,ya:yb] = np.array([1,0,0])
im[xb,ya:yb] = np.array([1,0,0])
return im
def getRectPoints(im):
x,y = [],[]
for i in range(im.shape[0]):
for j in range(im.shape[1]):
if (im[i,j]-np.array([1,0,0])).sum()==0:
x.append(i)
y.append(j)
return np.array(x),np.array(y)
def denoise(x,y):
nx,ny = [],[]
for i in range(x.shape[0]):
d = np.sqrt((x[i]-x)**2+(y[i]-y)**2)
m = d<2
if len(m.nonzero()[0])>2:
nx.append(x[i])
ny.append(y[i])
return np.array(nx),np.array(ny)
im = synthimage()
plt.imshow(np.swapaxes(im,0,1),origin='lower',interpolation='nearest')
plt.show()
x,y = getRectPoints(im)
plt.scatter(x,y,c='red')
plt.xlim(0,300)
plt.ylim(0,200)
plt.show()
nx,ny = denoise(x,y)
plt.scatter(nx,ny,c='red')
plt.xlim(0,300)
plt.ylim(0,200)
plt.show()
#Assuming rectangle has no rotation (otherwise check Scipy ConveHull)
xmi = nx.min()
xma = nx.max()
ymi = ny.min()
yma = ny.max()
new = np.ones(im.shape)
new[xmi:xma,ymi:yma] = im[xmi:xma,ymi:yma]
plt.imshow(np.swapaxes(new,0,1),origin='lower',interpolation='nearest')
plt.show()
, the name of the functions should be self-explaining. Synthetic data was generated for the purpose of this exercise. The results are (in order):
Obviously each one of this steps can be changed depending on the requirements but this would be a functional solution for the majority of case-studies.
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.
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:
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
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))
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