Have a plot with several diagonal lines with different slopes. I would like to annotate these lines with a text label that matches the slope of the lines.
Something like this:
Is there a robust way to do this?
I've tried both text's and annotate's rotation parameters, but those are in screen coordinates, not data coordinates (i.e. it's always x degrees on the screen no matter the xy ranges). My x and y ranges differ by orders of magnitude, and obviously the apparent slope is affected by viewport size among other variables, so a fixed-degree rotation doesn't do the trick. Any other ideas?
This is the exact same process and basic code as given by #Adam --- it's just restructured to be (hopefully) a little more convenient.
def label_line(line, label, x, y, color='0.5', size=12):
"""Add a label to a line, at the proper angle.
Arguments
---------
line : matplotlib.lines.Line2D object,
label : str
x : float
x-position to place center of text (in data coordinated
y : float
y-position to place center of text (in data coordinates)
color : str
size : float
"""
xdata, ydata = line.get_data()
x1 = xdata[0]
x2 = xdata[-1]
y1 = ydata[0]
y2 = ydata[-1]
ax = line.get_axes()
text = ax.annotate(label, xy=(x, y), xytext=(-10, 0),
textcoords='offset points',
size=size, color=color,
horizontalalignment='left',
verticalalignment='bottom')
sp1 = ax.transData.transform_point((x1, y1))
sp2 = ax.transData.transform_point((x2, y2))
rise = (sp2[1] - sp1[1])
run = (sp2[0] - sp1[0])
slope_degrees = np.degrees(np.arctan2(rise, run))
text.set_rotation(slope_degrees)
return text
Used like:
import numpy as np
import matplotlib.pyplot as plt
...
fig, axes = plt.subplots()
color = 'blue'
line, = axes.plot(xdata, ydata, '--', color=color)
...
label_line(line, "Some Label", x, y, color=color)
Edit: note that this method still needs to be called after the figure layout is finalized, otherwise things will be altered.
See: https://gist.github.com/lzkelley/0de9e8bf2a4fe96d2018f1b1bd5a0d3c
I came up with something that works for me. Note the grey dashed lines:
The rotation must be set manually, but this must be done AFTER draw() or layout. So my solution is to associate lines with annotations, then iterate through them and do this:
get line's data transform (i.e. goes from data coordinates to display coordinates)
transform two points along the line to display coordinates
find slope of displayed line
set text rotation to match this slope
This isn't perfect, because matplotlib's handling of rotated text is all wrong. It aligns by the bounding box and not by the text baseline.
Some font basics if you're interested about text rendering: http://docs.oracle.com/javase/tutorial/2d/text/fontconcepts.html
This example shows what matplotlib does: http://matplotlib.org/examples/pylab_examples/text_rotation.html
The only way I found to have a label properly next to the line is to align by center in both vertical and horizontal. I then offset the label by 10 points to the left to make it not overlap. Good enough for my application.
Here is my code. I draw the line however I want, then draw the annotation, then bind them with a helper function:
line, = fig.plot(xdata, ydata, '--', color=color)
# x,y appear on the midpoint of the line
t = fig.annotate("text", xy=(x, y), xytext=(-10, 0), textcoords='offset points', horizontalalignment='left', verticalalignment='bottom', color=color)
text_slope_match_line(t, x, y, line)
Then call another helper function after layout but before savefig (For interactive images I think you'll have to register for draw events and call update_text_slopes in the handler)
plt.tight_layout()
update_text_slopes()
The helpers:
rotated_labels = []
def text_slope_match_line(text, x, y, line):
global rotated_labels
# find the slope
xdata, ydata = line.get_data()
x1 = xdata[0]
x2 = xdata[-1]
y1 = ydata[0]
y2 = ydata[-1]
rotated_labels.append({"text":text, "line":line, "p1":numpy.array((x1, y1)), "p2":numpy.array((x2, y2))})
def update_text_slopes():
global rotated_labels
for label in rotated_labels:
# slope_degrees is in data coordinates, the text() and annotate() functions need it in screen coordinates
text, line = label["text"], label["line"]
p1, p2 = label["p1"], label["p2"]
# get the line's data transform
ax = line.get_axes()
sp1 = ax.transData.transform_point(p1)
sp2 = ax.transData.transform_point(p2)
rise = (sp2[1] - sp1[1])
run = (sp2[0] - sp1[0])
slope_degrees = math.degrees(math.atan(rise/run))
text.set_rotation(slope_degrees)
Even though this question is old, I keep coming across it and get frustrated, that it does not quite work. I reworked it into a class LineAnnotation and helper line_annotate such that it
uses the slope at a specific point x,
works with re-layouting and resizing, and
accepts a relative offset perpendicular to the slope.
x = np.linspace(np.pi, 2*np.pi)
line, = plt.plot(x, np.sin(x))
for x in [3.5, 4.0, 4.5, 5.0, 5.5, 6.0]:
line_annotate(str(x), line, x)
I originally put it into a public gist, but #Adam asked me to include it here.
import numpy as np
from matplotlib.text import Annotation
from matplotlib.transforms import Affine2D
class LineAnnotation(Annotation):
"""A sloped annotation to *line* at position *x* with *text*
Optionally an arrow pointing from the text to the graph at *x* can be drawn.
Usage
-----
fig, ax = subplots()
x = linspace(0, 2*pi)
line, = ax.plot(x, sin(x))
ax.add_artist(LineAnnotation("text", line, 1.5))
"""
def __init__(
self, text, line, x, xytext=(0, 5), textcoords="offset points", **kwargs
):
"""Annotate the point at *x* of the graph *line* with text *text*.
By default, the text is displayed with the same rotation as the slope of the
graph at a relative position *xytext* above it (perpendicularly above).
An arrow pointing from the text to the annotated point *xy* can
be added by defining *arrowprops*.
Parameters
----------
text : str
The text of the annotation.
line : Line2D
Matplotlib line object to annotate
x : float
The point *x* to annotate. y is calculated from the points on the line.
xytext : (float, float), default: (0, 5)
The position *(x, y)* relative to the point *x* on the *line* to place the
text at. The coordinate system is determined by *textcoords*.
**kwargs
Additional keyword arguments are passed on to `Annotation`.
See also
--------
`Annotation`
`line_annotate`
"""
assert textcoords.startswith(
"offset "
), "*textcoords* must be 'offset points' or 'offset pixels'"
self.line = line
self.xytext = xytext
# Determine points of line immediately to the left and right of x
xs, ys = line.get_data()
def neighbours(x, xs, ys, try_invert=True):
inds, = np.where((xs <= x)[:-1] & (xs > x)[1:])
if len(inds) == 0:
assert try_invert, "line must cross x"
return neighbours(x, xs[::-1], ys[::-1], try_invert=False)
i = inds[0]
return np.asarray([(xs[i], ys[i]), (xs[i+1], ys[i+1])])
self.neighbours = n1, n2 = neighbours(x, xs, ys)
# Calculate y by interpolating neighbouring points
y = n1[1] + ((x - n1[0]) * (n2[1] - n1[1]) / (n2[0] - n1[0]))
kwargs = {
"horizontalalignment": "center",
"rotation_mode": "anchor",
**kwargs,
}
super().__init__(text, (x, y), xytext=xytext, textcoords=textcoords, **kwargs)
def get_rotation(self):
"""Determines angle of the slope of the neighbours in display coordinate system
"""
transData = self.line.get_transform()
dx, dy = np.diff(transData.transform(self.neighbours), axis=0).squeeze()
return np.rad2deg(np.arctan2(dy, dx))
def update_positions(self, renderer):
"""Updates relative position of annotation text
Note
----
Called during annotation `draw` call
"""
xytext = Affine2D().rotate_deg(self.get_rotation()).transform(self.xytext)
self.set_position(xytext)
super().update_positions(renderer)
def line_annotate(text, line, x, *args, **kwargs):
"""Add a sloped annotation to *line* at position *x* with *text*
Optionally an arrow pointing from the text to the graph at *x* can be drawn.
Usage
-----
x = linspace(0, 2*pi)
line, = ax.plot(x, sin(x))
line_annotate("sin(x)", line, 1.5)
See also
--------
`LineAnnotation`
`plt.annotate`
"""
ax = line.axes
a = LineAnnotation(text, line, x, *args, **kwargs)
if "clip_on" in kwargs:
a.set_clip_path(ax.patch)
ax.add_artist(a)
return a
New in matplotlib 3.4.0
There is now a built-in parameter transform_rotates_text for rotating text relative to a line:
To rotate text with respect to a line, the correct angle won't be the angle of that line in the plot coordinate system, but the angle that the line appears in the screen coordinate system. This angle can be determined automatically by setting the new parameter transform_rotates_text.
So now we can just pass the raw data angle to plt.text and let matplotlib automatically transform it to the correct visual angle by setting transform_rotates_text=True:
# plot line from (1, 4) to (6, 10)
x = [1, 6]
y = [4, 10]
plt.plot(x, y, 'r.-')
# compute angle in raw data coordinates (no manual transforms)
dy = y[1] - y[0]
dx = x[1] - x[0]
angle = np.rad2deg(np.arctan2(dy, dx))
# annotate with transform_rotates_text to align text and line
plt.text(x[0], y[0], f'rotation={angle:.2f}', ha='left', va='bottom',
transform_rotates_text=True, rotation=angle, rotation_mode='anchor')
This approach is robust against the figure and axes scales. Even if we modify the figsize or xlim after placing the text, the rotation stays properly aligned:
# resizing the figure won't mess up the rotation
plt.gcf().set_size_inches(9, 4)
# rescaling the axes won't mess up the rotation
plt.xlim(0, 12)
Related
I want to add a coloured border to some subplots with a fixed width specified in pixels. I wrote the following function to do so by adding a Rectangle patch to the figure behind the axes.
def add_subplot_border(ax, width=0, color=None):
fig = ax.get_figure()
# Convert bottom-left and top-right to display coordinates
x0, y0 = ax.transAxes.transform((0, 0))
x1, y1 = ax.transAxes.transform((1, 1))
# Adjust margins
x0 -= width
x1 += width
y0 -= width
y1 += width
# Convert back to Axes coordinates
x0, y0 = ax.transAxes.inverted().transform((x0, y0))
x1, y1 = ax.transAxes.inverted().transform((x1, y1))
rect = plt.Rectangle((x0, y0), x1-x0, y1-y0,
color=color,
transform=ax.transAxes,
zorder=-1)
fig.patches.append(rect)
This appears to be a good starting point, but when the figure is resized the relative thickness of the border changes too. How can I specify a transform to scale and translate the patch to appear as a fixed-width border regardless of window scaling? Or, is there a better way to approach this?
Original figure
Scaled figure - uneven border
Instead of calculating a margin and drawing a Rectangle with that extra width (which then get's overlayed by the Axis, you can give the Rectangle a line width (in points) that is preserved upon re-scaling. Note though, that the line is always centred on the border of the Rectangle, so if you want, say, a 5 point frame around your axis, you should request a line width of 10 (or possibly 11).
I adjusted your function slightly and added a use case example:
from matplotlib import pyplot as plt
def add_subplot_border(ax, width=1, color=None ):
fig = ax.get_figure()
# Convert bottom-left and top-right to display coordinates
x0, y0 = ax.transAxes.transform((0, 0))
x1, y1 = ax.transAxes.transform((1, 1))
# Convert back to Axes coordinates
x0, y0 = ax.transAxes.inverted().transform((x0, y0))
x1, y1 = ax.transAxes.inverted().transform((x1, y1))
rect = plt.Rectangle(
(x0, y0), x1-x0, y1-y0,
color=color,
transform=ax.transAxes,
zorder=-1,
lw=2*width+1,
fill=None,
)
fig.patches.append(rect)
if __name__ == '__main__':
fig,axes = plt.subplots(ncols=2,nrows=2,figsize=(8,8))
colors = 'brgy'
widths = [1,2,4,8]
for ax,col,w in zip(axes.reshape(-1),colors, widths):
add_subplot_border(ax,w,col)
plt.show()
This is the original figure:
and this is the scaled figure (the lines look thinner because I increased the figure size):
Following on from my previous question I have the coordinates of the text label box in figure fraction coordinates and attempted to get the coordinates of the arrow patch in the same way.
But the coordinates I get do not correspond to the arrow, because when I plot a line over the same coordinates it doesn't lie on top of it:
import numpy as np
import matplotlib
matplotlib.use('agg')
import matplotlib.pyplot as plt
def f(x):
return 10 * np.sin(3*x)**4
x = np.linspace(0, 2*np.pi, 100)
y = f(x)
fig, ax = plt.subplots()
ax.plot(x,y)
xpt = 1.75
ypt = f(xpt)
xy = ax.transData.transform([xpt, ypt])
xy = fig.transFigure.inverted().transform(xy)
xytext = xy + [0.1, -0.1]
rdx, rdy = 0, 1
ann = ax.annotate('A point', xy=xy, xycoords='figure fraction',
xytext=xytext, textcoords='figure fraction',
arrowprops=dict(arrowstyle='->', connectionstyle="arc3",
relpos=(rdx, rdy)),
bbox=dict(fc='gray', edgecolor='k', alpha=0.5),
ha='left', va='top'
)
fig.canvas.draw()
leader_line_box = ann.arrow_patch.get_extents()
print(leader_line_box)
leader_line_box = fig.transFigure.inverted().transform(leader_line_box)
print(leader_line_box)
from matplotlib.lines import Line2D
line = Line2D(leader_line_box.T[0], leader_line_box.T[1],transform=fig.transFigure, lw=2, color='m')
ax.add_line(line)
plt.savefig('test.png')
How can I get the ((x0,y0), (x1,y1)) coordinates of the annotation arrow in figure fraction units and what has gone wrong in my attempt here?
The easiest way in this very specific case is to just draw the x-coordinates in reverse
line = Line2D(leader_line_box.T[0][::-1], leader_line_box.T[1],transform=fig.transFigure, lw=2, color='m')
If you need a more general solution,
verts = ann.arrow_patch.get_path()._vertices
tverts= fig.transFigure.inverted().transform(verts)
index = [0,2]
line = Line2D([tverts[index[0],0],tverts[index[1],0]], [tverts[index[0],1],tverts[index[1],1]],
transform=fig.transFigure, lw=2, color='m')
ax.add_line(line)
This will work for any arrow direction (pointing upwards or downwards, east or west) but is specific to the arrowprops arguments arrowstyle='->' and connectionstyle="arc3". Using different arrowstyle or connection style will require to set index to different values which can be found by chosing the appropriate indices from the array stored in verts.
In a very general case one can also look at the following:
box = ann.arrow_patch._posA_posB
tbox = fig.transFigure.inverted().transform(leader_line_box)
print tbox
line = Line2D(tbox.T[0], tbox.T[1],transform=fig.transFigure)
However this will get you the line between the annotated point and the text itself. In general this line might be different from the actual arrow, depending in the arrow style in use.
You're almost there, you have the coordinates of the bounding box of the arrow, which is the box drawn using the arrow as the diagonal. From that, we can find the head / tail coordinates.
The bounding box coordinates are given in the order [[left, bottom], [right, top]]. Here, the arrow head is at the top left, and tail is bottom right. So we can draw two lines to visually mark these. Replacing that section in your code with this:
from matplotlib.lines import Line2D
dl = 0.01 # some arbitrary length for the marker line
head = [leader_line_box.T[0][0], leader_line_box.T[1][1]]
line_head = Line2D([head[0],head[0]+dl], [head[1],head[1]+dl],
transform=fig.transFigure, lw=2, color='r') # mark head with red
ax.add_line(line_head)
tail = [leader_line_box.T[0][1], leader_line_box.T[1][0]]
line_tail = Line2D([tail[0],tail[0]+dl], [tail[1],tail[1]+dl],
transform=fig.transFigure, lw=2, color='g') # mark tail with green
ax.add_line(line_tail)
results in the following plot:
Have a plot with several diagonal lines with different slopes. I would like to annotate these lines with a text label that matches the slope of the lines.
Something like this:
Is there a robust way to do this?
I've tried both text's and annotate's rotation parameters, but those are in screen coordinates, not data coordinates (i.e. it's always x degrees on the screen no matter the xy ranges). My x and y ranges differ by orders of magnitude, and obviously the apparent slope is affected by viewport size among other variables, so a fixed-degree rotation doesn't do the trick. Any other ideas?
This is the exact same process and basic code as given by #Adam --- it's just restructured to be (hopefully) a little more convenient.
def label_line(line, label, x, y, color='0.5', size=12):
"""Add a label to a line, at the proper angle.
Arguments
---------
line : matplotlib.lines.Line2D object,
label : str
x : float
x-position to place center of text (in data coordinated
y : float
y-position to place center of text (in data coordinates)
color : str
size : float
"""
xdata, ydata = line.get_data()
x1 = xdata[0]
x2 = xdata[-1]
y1 = ydata[0]
y2 = ydata[-1]
ax = line.get_axes()
text = ax.annotate(label, xy=(x, y), xytext=(-10, 0),
textcoords='offset points',
size=size, color=color,
horizontalalignment='left',
verticalalignment='bottom')
sp1 = ax.transData.transform_point((x1, y1))
sp2 = ax.transData.transform_point((x2, y2))
rise = (sp2[1] - sp1[1])
run = (sp2[0] - sp1[0])
slope_degrees = np.degrees(np.arctan2(rise, run))
text.set_rotation(slope_degrees)
return text
Used like:
import numpy as np
import matplotlib.pyplot as plt
...
fig, axes = plt.subplots()
color = 'blue'
line, = axes.plot(xdata, ydata, '--', color=color)
...
label_line(line, "Some Label", x, y, color=color)
Edit: note that this method still needs to be called after the figure layout is finalized, otherwise things will be altered.
See: https://gist.github.com/lzkelley/0de9e8bf2a4fe96d2018f1b1bd5a0d3c
I came up with something that works for me. Note the grey dashed lines:
The rotation must be set manually, but this must be done AFTER draw() or layout. So my solution is to associate lines with annotations, then iterate through them and do this:
get line's data transform (i.e. goes from data coordinates to display coordinates)
transform two points along the line to display coordinates
find slope of displayed line
set text rotation to match this slope
This isn't perfect, because matplotlib's handling of rotated text is all wrong. It aligns by the bounding box and not by the text baseline.
Some font basics if you're interested about text rendering: http://docs.oracle.com/javase/tutorial/2d/text/fontconcepts.html
This example shows what matplotlib does: http://matplotlib.org/examples/pylab_examples/text_rotation.html
The only way I found to have a label properly next to the line is to align by center in both vertical and horizontal. I then offset the label by 10 points to the left to make it not overlap. Good enough for my application.
Here is my code. I draw the line however I want, then draw the annotation, then bind them with a helper function:
line, = fig.plot(xdata, ydata, '--', color=color)
# x,y appear on the midpoint of the line
t = fig.annotate("text", xy=(x, y), xytext=(-10, 0), textcoords='offset points', horizontalalignment='left', verticalalignment='bottom', color=color)
text_slope_match_line(t, x, y, line)
Then call another helper function after layout but before savefig (For interactive images I think you'll have to register for draw events and call update_text_slopes in the handler)
plt.tight_layout()
update_text_slopes()
The helpers:
rotated_labels = []
def text_slope_match_line(text, x, y, line):
global rotated_labels
# find the slope
xdata, ydata = line.get_data()
x1 = xdata[0]
x2 = xdata[-1]
y1 = ydata[0]
y2 = ydata[-1]
rotated_labels.append({"text":text, "line":line, "p1":numpy.array((x1, y1)), "p2":numpy.array((x2, y2))})
def update_text_slopes():
global rotated_labels
for label in rotated_labels:
# slope_degrees is in data coordinates, the text() and annotate() functions need it in screen coordinates
text, line = label["text"], label["line"]
p1, p2 = label["p1"], label["p2"]
# get the line's data transform
ax = line.get_axes()
sp1 = ax.transData.transform_point(p1)
sp2 = ax.transData.transform_point(p2)
rise = (sp2[1] - sp1[1])
run = (sp2[0] - sp1[0])
slope_degrees = math.degrees(math.atan(rise/run))
text.set_rotation(slope_degrees)
Even though this question is old, I keep coming across it and get frustrated, that it does not quite work. I reworked it into a class LineAnnotation and helper line_annotate such that it
uses the slope at a specific point x,
works with re-layouting and resizing, and
accepts a relative offset perpendicular to the slope.
x = np.linspace(np.pi, 2*np.pi)
line, = plt.plot(x, np.sin(x))
for x in [3.5, 4.0, 4.5, 5.0, 5.5, 6.0]:
line_annotate(str(x), line, x)
I originally put it into a public gist, but #Adam asked me to include it here.
import numpy as np
from matplotlib.text import Annotation
from matplotlib.transforms import Affine2D
class LineAnnotation(Annotation):
"""A sloped annotation to *line* at position *x* with *text*
Optionally an arrow pointing from the text to the graph at *x* can be drawn.
Usage
-----
fig, ax = subplots()
x = linspace(0, 2*pi)
line, = ax.plot(x, sin(x))
ax.add_artist(LineAnnotation("text", line, 1.5))
"""
def __init__(
self, text, line, x, xytext=(0, 5), textcoords="offset points", **kwargs
):
"""Annotate the point at *x* of the graph *line* with text *text*.
By default, the text is displayed with the same rotation as the slope of the
graph at a relative position *xytext* above it (perpendicularly above).
An arrow pointing from the text to the annotated point *xy* can
be added by defining *arrowprops*.
Parameters
----------
text : str
The text of the annotation.
line : Line2D
Matplotlib line object to annotate
x : float
The point *x* to annotate. y is calculated from the points on the line.
xytext : (float, float), default: (0, 5)
The position *(x, y)* relative to the point *x* on the *line* to place the
text at. The coordinate system is determined by *textcoords*.
**kwargs
Additional keyword arguments are passed on to `Annotation`.
See also
--------
`Annotation`
`line_annotate`
"""
assert textcoords.startswith(
"offset "
), "*textcoords* must be 'offset points' or 'offset pixels'"
self.line = line
self.xytext = xytext
# Determine points of line immediately to the left and right of x
xs, ys = line.get_data()
def neighbours(x, xs, ys, try_invert=True):
inds, = np.where((xs <= x)[:-1] & (xs > x)[1:])
if len(inds) == 0:
assert try_invert, "line must cross x"
return neighbours(x, xs[::-1], ys[::-1], try_invert=False)
i = inds[0]
return np.asarray([(xs[i], ys[i]), (xs[i+1], ys[i+1])])
self.neighbours = n1, n2 = neighbours(x, xs, ys)
# Calculate y by interpolating neighbouring points
y = n1[1] + ((x - n1[0]) * (n2[1] - n1[1]) / (n2[0] - n1[0]))
kwargs = {
"horizontalalignment": "center",
"rotation_mode": "anchor",
**kwargs,
}
super().__init__(text, (x, y), xytext=xytext, textcoords=textcoords, **kwargs)
def get_rotation(self):
"""Determines angle of the slope of the neighbours in display coordinate system
"""
transData = self.line.get_transform()
dx, dy = np.diff(transData.transform(self.neighbours), axis=0).squeeze()
return np.rad2deg(np.arctan2(dy, dx))
def update_positions(self, renderer):
"""Updates relative position of annotation text
Note
----
Called during annotation `draw` call
"""
xytext = Affine2D().rotate_deg(self.get_rotation()).transform(self.xytext)
self.set_position(xytext)
super().update_positions(renderer)
def line_annotate(text, line, x, *args, **kwargs):
"""Add a sloped annotation to *line* at position *x* with *text*
Optionally an arrow pointing from the text to the graph at *x* can be drawn.
Usage
-----
x = linspace(0, 2*pi)
line, = ax.plot(x, sin(x))
line_annotate("sin(x)", line, 1.5)
See also
--------
`LineAnnotation`
`plt.annotate`
"""
ax = line.axes
a = LineAnnotation(text, line, x, *args, **kwargs)
if "clip_on" in kwargs:
a.set_clip_path(ax.patch)
ax.add_artist(a)
return a
New in matplotlib 3.4.0
There is now a built-in parameter transform_rotates_text for rotating text relative to a line:
To rotate text with respect to a line, the correct angle won't be the angle of that line in the plot coordinate system, but the angle that the line appears in the screen coordinate system. This angle can be determined automatically by setting the new parameter transform_rotates_text.
So now we can just pass the raw data angle to plt.text and let matplotlib automatically transform it to the correct visual angle by setting transform_rotates_text=True:
# plot line from (1, 4) to (6, 10)
x = [1, 6]
y = [4, 10]
plt.plot(x, y, 'r.-')
# compute angle in raw data coordinates (no manual transforms)
dy = y[1] - y[0]
dx = x[1] - x[0]
angle = np.rad2deg(np.arctan2(dy, dx))
# annotate with transform_rotates_text to align text and line
plt.text(x[0], y[0], f'rotation={angle:.2f}', ha='left', va='bottom',
transform_rotates_text=True, rotation=angle, rotation_mode='anchor')
This approach is robust against the figure and axes scales. Even if we modify the figsize or xlim after placing the text, the rotation stays properly aligned:
# resizing the figure won't mess up the rotation
plt.gcf().set_size_inches(9, 4)
# rescaling the axes won't mess up the rotation
plt.xlim(0, 12)
I am working on an interactive plotting application which requires users to select data points from a matplotlib scatter plot. For clarity, I would like to be able to alter the colour and shape of a plotted point when it is clicked on (or selected by any means).
As the matplotlib.collections.PathCollection class has a set_facecolors method, altering the color of the points is relatively simple. However, I cannot see a similar way to update the marker shape.
Is there a way to do this?
A barebones illustration of the problem:
import numpy as np
import matplotlib.pyplot as plt
x = np.random.normal(0,1.0,100)
y = np.random.normal(0,1.0,100)
scatter_plot = plt.scatter(x, y, facecolor="b", marker="o")
#update the colour
new_facecolors = ["r","g"]*50
scatter_plot.set_facecolors(new_facecolors)
#update the marker?
#new_marker = ["o","s"]*50
#scatter_plot.???(new_marker) #<--how do I access the marker shapes?
plt.show()
Any ideas?
If what you are really after is highlighting the point selected by the user, then you could superimpose another dot (with dot = ax.scatter(...)) on top of the point selected. Later, in response to user clicks, you could then use dot.set_offsets((x, y)) to change the location of the dot.
Joe Kington has written a wonderful example (DataCursor) of how to add an annotation displaying the data coordinates when a user clicks on on artist (such as a scatter plot).
Here is a derivative example (FollowDotCursor) which highlights and annotates data points when a user hovers the mouse over a point.
With the DataCursor the data coordinates displayed are where the user clicks -- which might not be exactly the same coordinates as the underlying data.
With the FollowDotCursor the data coordinate displayed is always a point in the underlying data which is nearest the mouse.
import numpy as np
import matplotlib.pyplot as plt
import scipy.spatial as spatial
def fmt(x, y):
return 'x: {x:0.2f}\ny: {y:0.2f}'.format(x=x, y=y)
class FollowDotCursor(object):
"""Display the x,y location of the nearest data point.
"""
def __init__(self, ax, x, y, tolerance=5, formatter=fmt, offsets=(-20, 20)):
try:
x = np.asarray(x, dtype='float')
except (TypeError, ValueError):
x = np.asarray(mdates.date2num(x), dtype='float')
y = np.asarray(y, dtype='float')
self._points = np.column_stack((x, y))
self.offsets = offsets
self.scale = x.ptp()
self.scale = y.ptp() / self.scale if self.scale else 1
self.tree = spatial.cKDTree(self.scaled(self._points))
self.formatter = formatter
self.tolerance = tolerance
self.ax = ax
self.fig = ax.figure
self.ax.xaxis.set_label_position('top')
self.dot = ax.scatter(
[x.min()], [y.min()], s=130, color='green', alpha=0.7)
self.annotation = self.setup_annotation()
plt.connect('motion_notify_event', self)
def scaled(self, points):
points = np.asarray(points)
return points * (self.scale, 1)
def __call__(self, event):
ax = self.ax
# event.inaxes is always the current axis. If you use twinx, ax could be
# a different axis.
if event.inaxes == ax:
x, y = event.xdata, event.ydata
elif event.inaxes is None:
return
else:
inv = ax.transData.inverted()
x, y = inv.transform([(event.x, event.y)]).ravel()
annotation = self.annotation
x, y = self.snap(x, y)
annotation.xy = x, y
annotation.set_text(self.formatter(x, y))
self.dot.set_offsets((x, y))
bbox = ax.viewLim
event.canvas.draw()
def setup_annotation(self):
"""Draw and hide the annotation box."""
annotation = self.ax.annotate(
'', xy=(0, 0), ha = 'right',
xytext = self.offsets, textcoords = 'offset points', va = 'bottom',
bbox = dict(
boxstyle='round,pad=0.5', fc='yellow', alpha=0.75),
arrowprops = dict(
arrowstyle='->', connectionstyle='arc3,rad=0'))
return annotation
def snap(self, x, y):
"""Return the value in self.tree closest to x, y."""
dist, idx = self.tree.query(self.scaled((x, y)), k=1, p=1)
try:
return self._points[idx]
except IndexError:
# IndexError: index out of bounds
return self._points[0]
x = np.random.normal(0,1.0,100)
y = np.random.normal(0,1.0,100)
fig, ax = plt.subplots()
cursor = FollowDotCursor(ax, x, y, formatter=fmt, tolerance=20)
scatter_plot = plt.scatter(x, y, facecolor="b", marker="o")
#update the colour
new_facecolors = ["r","g"]*50
scatter_plot.set_facecolors(new_facecolors)
plt.show()
Pretty sure there is no way to do this. scatter has turned your data into a collection of paths and no longer has the meta-data you would need to do this (ie, it knows nothing about the semantics of why it is drawing a shape, it just has a list of shapes to draw).
You can also update the colors with set_array (as PathCollection is a sub-class of ScalerMappable).
If you want to do this (and have a reasonably small number of points) you can manage the paths by hand.
The other (simpler) option is to use two (or several, one for each shape/color combination you want) Line2D objects (as you are not in this example scaling the size of the markers) with linestyle='none'. The picker event on Line2D objects will give you back which point you were nearest.
Sorry this is rambley.
I have a numpy array that contains some image data. I would like to plot the 'profile' of a transect drawn across the image. The simplest case is a profile running parallel to the edge of the image, so if the image array is imdat, then the profile at a selected point (r,c) is simply imdat[r] (horizontal) or imdat[:,c] (vertical).
Now, I want to take as input two points (r1,c1) and (r2,c2), both lying inside imdat. I would like to plot the profile of the values along the line connecting these two points.
What is the best way to get values from a numpy array, along such a line? More generally, along a path/polygon?
I have used slicing and indexing before, but I can't seem to arrive at an elegant solution for such a where consecutive slice elements are not in the same row or column. Thanks for your help.
#Sven's answer is the easy way, but it's rather inefficient for large arrays. If you're dealing with a relatively small array, you won't notice the difference, if you're wanting a profile from a large (e.g. >50 MB) you may want to try a couple of other approaches. You'll need to work in "pixel" coordinates for these, though, so there's an extra layer of complexity.
There are two more memory-efficient ways. 1) use scipy.ndimage.map_coordinates if you need bilinear or cubic interpolation. 2) if you just want nearest neighbor sampling, then just use indexing directly.
As an example of the first:
import numpy as np
import scipy.ndimage
import matplotlib.pyplot as plt
#-- Generate some data...
x, y = np.mgrid[-5:5:0.1, -5:5:0.1]
z = np.sqrt(x**2 + y**2) + np.sin(x**2 + y**2)
#-- Extract the line...
# Make a line with "num" points...
x0, y0 = 5, 4.5 # These are in _pixel_ coordinates!!
x1, y1 = 60, 75
num = 1000
x, y = np.linspace(x0, x1, num), np.linspace(y0, y1, num)
# Extract the values along the line, using cubic interpolation
zi = scipy.ndimage.map_coordinates(z, np.vstack((x,y)))
#-- Plot...
fig, axes = plt.subplots(nrows=2)
axes[0].imshow(z)
axes[0].plot([x0, x1], [y0, y1], 'ro-')
axes[0].axis('image')
axes[1].plot(zi)
plt.show()
The equivalent using nearest-neighbor interpolation would look something like this:
import numpy as np
import matplotlib.pyplot as plt
#-- Generate some data...
x, y = np.mgrid[-5:5:0.1, -5:5:0.1]
z = np.sqrt(x**2 + y**2) + np.sin(x**2 + y**2)
#-- Extract the line...
# Make a line with "num" points...
x0, y0 = 5, 4.5 # These are in _pixel_ coordinates!!
x1, y1 = 60, 75
num = 1000
x, y = np.linspace(x0, x1, num), np.linspace(y0, y1, num)
# Extract the values along the line
zi = z[x.astype(np.int), y.astype(np.int)]
#-- Plot...
fig, axes = plt.subplots(nrows=2)
axes[0].imshow(z)
axes[0].plot([x0, x1], [y0, y1], 'ro-')
axes[0].axis('image')
axes[1].plot(zi)
plt.show()
However, if you're using nearest-neighbor, you probably would only want samples at each pixel, so you'd probably do something more like this, instead...
import numpy as np
import matplotlib.pyplot as plt
#-- Generate some data...
x, y = np.mgrid[-5:5:0.1, -5:5:0.1]
z = np.sqrt(x**2 + y**2) + np.sin(x**2 + y**2)
#-- Extract the line...
# Make a line with "num" points...
x0, y0 = 5, 4.5 # These are in _pixel_ coordinates!!
x1, y1 = 60, 75
length = int(np.hypot(x1-x0, y1-y0))
x, y = np.linspace(x0, x1, length), np.linspace(y0, y1, length)
# Extract the values along the line
zi = z[x.astype(np.int), y.astype(np.int)]
#-- Plot...
fig, axes = plt.subplots(nrows=2)
axes[0].imshow(z)
axes[0].plot([x0, x1], [y0, y1], 'ro-')
axes[0].axis('image')
axes[1].plot(zi)
plt.show()
Probably the easiest way to do this is to use scipy.interpolate.interp2d():
# construct interpolation function
# (assuming your data is in the 2-d array "data")
x = numpy.arange(data.shape[1])
y = numpy.arange(data.shape[0])
f = scipy.interpolate.interp2d(x, y, data)
# extract values on line from r1, c1 to r2, c2
num_points = 100
xvalues = numpy.linspace(c1, c2, num_points)
yvalues = numpy.linspace(r1, r2, num_points)
zvalues = f(xvalues, yvalues)
I've been testing the above routines with galaxy images and think I found a small error. I think a transpose needs to be added to the otherwise great solution provided by Joe. Here is a slightly modified version of his code that reveals the error. If you run it without the transpose, you can see the profile doesn't match up; with the transpose it looks okay. This isn't apparent in Joe's solution since he uses a symmetric image.
import numpy as np
import scipy.ndimage
import matplotlib.pyplot as plt
import scipy.misc # ADDED THIS LINE
#-- Generate some data...
x, y = np.mgrid[-5:5:0.1, -5:5:0.1]
z = np.sqrt(x**2 + y**2) + np.sin(x**2 + y**2)
lena = scipy.misc.lena() # ADDED THIS ASYMMETRIC IMAGE
z = lena[320:420,330:430] # ADDED THIS ASYMMETRIC IMAGE
#-- Extract the line...
# Make a line with "num" points...
x0, y0 = 5, 4.5 # These are in _pixel_ coordinates!!
x1, y1 = 60, 75
num = 500
x, y = np.linspace(x0, x1, num), np.linspace(y0, y1, num)
# Extract the values along the line, using cubic interpolation
zi = scipy.ndimage.map_coordinates(z, np.vstack((x,y))) # THIS DOESN'T WORK CORRECTLY
zi = scipy.ndimage.map_coordinates(np.transpose(z), np.vstack((x,y))) # THIS SEEMS TO WORK CORRECTLY
#-- Plot...
fig, axes = plt.subplots(nrows=2)
axes[0].imshow(z)
axes[0].plot([x0, x1], [y0, y1], 'ro-')
axes[0].axis('image')
axes[1].plot(zi)
plt.show()
Here's the version WITHOUT the transpose. Notice that only a small fraction on the left should be bright according to the image but the plot shows almost half of the plot as bright.
Here's the version WITH the transpose. In this image, the plot seems to match well with what you'd expect from the red line in the image.
For a canned solution look into scikit-image's measure.profile_line function.
It's built on top of scipy.ndimage.map_coordinates as in #Joe's answer and has some extra useful functionality baked in.
Combining this answer with the Event Handling example on MPL's documentation, here's the code to allow for GUI-based dragging to draw/update your slice, by dragging on the plot data (this is coded for pcolormesh plots):
import numpy as np
import matplotlib.pyplot as plt
# Handle mouse clicks on the plot:
class LineSlice:
'''Allow user to drag a line on a pcolor/pcolormesh plot, and plot the Z values from that line on a separate axis.
Example
-------
fig, (ax1, ax2) = plt.subplots( nrows=2 ) # one figure, two axes
img = ax1.pcolormesh( x, y, Z ) # pcolormesh on the 1st axis
lntr = LineSlice( img, ax2 ) # Connect the handler, plot LineSlice onto 2nd axis
Arguments
---------
img: the pcolormesh plot to extract data from and that the User's clicks will be recorded for.
ax2: the axis on which to plot the data values from the dragged line.
'''
def __init__(self, img, ax):
'''
img: the pcolormesh instance to get data from/that user should click on
ax: the axis to plot the line slice on
'''
self.img = img
self.ax = ax
self.data = img.get_array().reshape(img._meshWidth, img._meshHeight)
# register the event handlers:
self.cidclick = img.figure.canvas.mpl_connect('button_press_event', self)
self.cidrelease = img.figure.canvas.mpl_connect('button_release_event', self)
self.markers, self.arrow = None, None # the lineslice indicators on the pcolormesh plot
self.line = None # the lineslice values plotted in a line
#end __init__
def __call__(self, event):
'''Matplotlib will run this function whenever the user triggers an event on our figure'''
if event.inaxes != self.img.axes: return # exit if clicks weren't within the `img` axes
if self.img.figure.canvas.manager.toolbar._active is not None: return # exit if pyplot toolbar (zooming etc.) is active
if event.name == 'button_press_event':
self.p1 = (event.xdata, event.ydata) # save 1st point
elif event.name == 'button_release_event':
self.p2 = (event.xdata, event.ydata) # save 2nd point
self.drawLineSlice() # draw the Line Slice position & data
#end __call__
def drawLineSlice( self ):
''' Draw the region along which the Line Slice will be extracted, onto the original self.img pcolormesh plot. Also update the self.axis plot to show the line slice data.'''
'''Uses code from these hints:
http://stackoverflow.com/questions/7878398/how-to-extract-an-arbitrary-line-of-values-from-a-numpy-array
http://stackoverflow.com/questions/34840366/matplotlib-pcolor-get-array-returns-flattened-array-how-to-get-2d-data-ba
'''
x0,y0 = self.p1[0], self.p1[1] # get user's selected coordinates
x1,y1 = self.p2[0], self.p2[1]
length = int( np.hypot(x1-x0, y1-y0) )
x, y = np.linspace(x0, x1, length), np.linspace(y0, y1, length)
# Extract the values along the line with nearest-neighbor pixel value:
# get temp. data from the pcolor plot
zi = self.data[x.astype(np.int), y.astype(np.int)]
# Extract the values along the line, using cubic interpolation:
#import scipy.ndimage
#zi = scipy.ndimage.map_coordinates(self.data, np.vstack((x,y)))
# if plots exist, delete them:
if self.markers != None:
if isinstance(self.markers, list):
self.markers[0].remove()
else:
self.markers.remove()
if self.arrow != None:
self.arrow.remove()
# plot the endpoints
self.markers = self.img.axes.plot([x0, x1], [y0, y1], 'wo')
# plot an arrow:
self.arrow = self.img.axes.annotate("",
xy=(x0, y0), # start point
xycoords='data',
xytext=(x1, y1), # end point
textcoords='data',
arrowprops=dict(
arrowstyle="<-",
connectionstyle="arc3",
color='white',
alpha=0.7,
linewidth=3
),
)
# plot the data along the line on provided `ax`:
if self.line != None:
self.line[0].remove() # delete the plot
self.line = self.ax.plot(zi)
#end drawLineSlice()
#end class LineTrace
# load the data:
D = np.genfromtxt(DataFilePath, ...)
fig, ax1, ax2 = plt.subplots(nrows=2, ncols=1)
# plot the data
img = ax1.pcolormesh( np.arange( len(D[0,:]) ), np.arange(len(D[:,0])), D )
# register the event handler:
LnTr = LineSlice(img, ax2) # args: the pcolor plot (img) & the axis to plot the values on (ax2)
This results in the following (after adding axis labels etc.), after dragging on the pcolor plot:
Here is a method without using scipy package(s). It should run much faster and is easy to understand. Basically, any pair of coordinates between point 1 (pt1) and point 2 (pt2) can be converted to x- and y- pixel integers, so we don't need any interpolation.
import numpy as np
from PIL import Image
import matplotlib.pyplot as plt
def euclideanDistance(coord1,coord2):
return np.sqrt((coord1[0]-coord2[0])**2+(coord1[1]-coord2[1])**2)
def getLinecut(image,X,Y,pt1,pt2):
row_col_1, row_col_2 = getRowCol(pt1,X,Y), getRowCol(pt2,X,Y)
row1,col1 = np.asarray(row_col_1).astype(float)
row2,col2 = np.asarray(row_col_2).astype(float)
dist = np.sqrt((pt1[0]-pt2[0])**2+(pt1[1]-pt2[1])**2)
N = int(euclideanDistance(row_col_1,row_col_2))#int(np.sqrt((row1-row2)**2+(col1-col2)**2))
rowList = [int(row1 + (row2-row1)/N*ind) for ind in range(N)]
colList = [int(col1 + (col2-col1)/N*ind) for ind in range(N)]
distList = [dist/N*ind for ind in range(N)]
return distList,image[rowList,colList]#rowList,colList
def getRowCol(pt,X,Y):
if X.min()<=pt[0]<=X.max() and Y.min()<=pt[1]<=Y.max():
pass
else:
raise ValueError('The input center is not within the given scope.')
center_coord_rowCol = (np.argmin(abs(Y-pt[1])),np.argmin(abs(X-pt[0])))
return center_coord_rowCol
image = np.asarray(Image.open('./Picture1.png'))[:,:,1]
image_copy = image.copy().astype(float)
X = np.linspace(-27,27,np.shape(image)[1])#[::-1]
Y = np.linspace(-15,15,np.shape(image)[0])[::-1]
pt1, pt2 = (-12,-14), (20,13)
distList, linecut = getLinecut(image_copy,X,Y,pt1,pt2)
plt.plot(distList, linecut)
plt.figure()
plt.pcolormesh(X,Y,image_copy)
plt.plot([pt1[0],pt2[0]],[pt1[1],pt2[1]],color='red')
plt.gca().set_aspect(1)
Picture1.png figure used:
See here for more details:
https://github.com/xuejianma/fastLinecut_radialLinecut
There is another function of the code: taking an average of several angle-evenly-spaced lines.