Related
I'm trying to create some artistic "plots" like the ones below:
The color of the regions do not really matter, what I'm trying to achieve is the variable "thickness" of the edges along the Voronoi regions (espescially, how they look like a bigger rounded blob where they meet in corners, and thinner at their middle point).
I've tried by "painting manually" each pixel based on the minimum distance to each centroid (each associated with a color):
n_centroids = 10
centroids = [(random.randint(0, h), random.randint(0, w)) for _ in range(n_centroids)]
colors = np.array([np.random.choice(range(256), size=3) for _ in range(n_centroids)]) / 255
for x, y in it.product(range(h), range(w)):
distances = np.sqrt([(x - c[0])**2 + (y - c[1])**2 for c in centroids])
centroid_i = np.argmin(distances)
img[x, y] = colors[centroid_i]
plt.imshow(img, cmap='gray')
Or by scipy.spatial.Voronoi, that also gives me the vertices points, although I still can't see how I can draw a line through them with the desired variable thickness.
from scipy.spatial import Voronoi, voronoi_plot_2d
# make up data points
points = [(random.randint(0, 10), random.randint(0, 10)) for _ in range(10)]
# add 4 distant dummy points
points = np.append(points, [[999,999], [-999,999], [999,-999], [-999,-999]], axis = 0)
# compute Voronoi tesselation
vor = Voronoi(points)
# plot
voronoi_plot_2d(vor)
# colorize
for region in vor.regions:
if not -1 in region:
polygon = [vor.vertices[i] for i in region]
plt.fill(*zip(*polygon))
# fix the range of axes
plt.xlim([-2,12]), plt.ylim([-2,12])
plt.show()
Edit:
I've managed to get a somewhat satisfying result via erosion + corner smoothing (via median filter as suggested in the comments) on each individual region, then drawing it into a black background.
res = np.zeros((h,w,3))
for color in colors:
region = (img == color)[:,:,0]
region = region.astype(np.uint8) * 255
region = sg.medfilt2d(region, 15) # smooth corners
# make edges from eroding regions
region = cv2.erode(region, np.ones((3, 3), np.uint8))
region = region.astype(bool)
res[region] = color
plt.imshow(res)
But as you can see the "stretched" line along the boundaries/edges of the regions is not quite there. Any other suggestions?
This is what #JohanC suggestion looks like. IMO, it looks much better than my attempt with Bezier curves. However, there appears to be a small problem with the RoundedPolygon class, as there are sometimes small defects at the corners (e.g. between blue and purple in the image below).
Edit: I fixed the RoundedPolygon class.
#!/usr/bin/env python
# coding: utf-8
"""
https://stackoverflow.com/questions/72061965/create-voronoi-art-with-rounded-region-edges
"""
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import patches, path
from scipy.spatial import Voronoi, voronoi_plot_2d
def shrink(polygon, pad):
center = np.mean(polygon, axis=0)
resized = np.zeros_like(polygon)
for ii, point in enumerate(polygon):
vector = point - center
unit_vector = vector / np.linalg.norm(vector)
resized[ii] = point - pad * unit_vector
return resized
class RoundedPolygon(patches.PathPatch):
# https://stackoverflow.com/a/66279687/2912349
def __init__(self, xy, pad, **kwargs):
p = path.Path(*self.__round(xy=xy, pad=pad))
super().__init__(path=p, **kwargs)
def __round(self, xy, pad):
n = len(xy)
for i in range(0, n):
x0, x1, x2 = np.atleast_1d(xy[i - 1], xy[i], xy[(i + 1) % n])
d01, d12 = x1 - x0, x2 - x1
l01, l12 = np.linalg.norm(d01), np.linalg.norm(d12)
u01, u12 = d01 / l01, d12 / l12
x00 = x0 + min(pad, 0.5 * l01) * u01
x01 = x1 - min(pad, 0.5 * l01) * u01
x10 = x1 + min(pad, 0.5 * l12) * u12
x11 = x2 - min(pad, 0.5 * l12) * u12
if i == 0:
verts = [x00, x01, x1, x10]
else:
verts += [x01, x1, x10]
codes = [path.Path.MOVETO] + n*[path.Path.LINETO, path.Path.CURVE3, path.Path.CURVE3]
verts[0] = verts[-1]
return np.atleast_1d(verts, codes)
if __name__ == '__main__':
# make up data points
n = 100
max_x = 20
max_y = 10
points = np.c_[np.random.uniform(0, max_x, size=n),
np.random.uniform(0, max_y, size=n)]
# add 4 distant dummy points
points = np.append(points, [[2 * max_x, 2 * max_y],
[ -max_x, 2 * max_y],
[2 * max_x, -max_y],
[ -max_x, -max_y]], axis = 0)
# compute Voronoi tesselation
vor = Voronoi(points)
fig, ax = plt.subplots(figsize=(max_x, max_y))
for region in vor.regions:
if region and (not -1 in region):
polygon = np.array([vor.vertices[i] for i in region])
resized = shrink(polygon, 0.15)
ax.add_patch(RoundedPolygon(resized, 0.2, color=plt.cm.Reds(0.5 + 0.5*np.random.rand())))
ax.axis([0, max_x, 0, max_y])
ax.axis('off')
ax.set_facecolor('black')
ax.add_artist(ax.patch)
ax.patch.set_zorder(-1)
plt.show()
Could something like bezier polygon "approximations" help me with this?
An attempt using Bezier curves:
#!/usr/bin/env python
# coding: utf-8
"""
https://stackoverflow.com/questions/72061965/create-voronoi-art-with-rounded-region-edges
"""
import numpy as np
import matplotlib.pyplot as plt
from scipy.spatial import Voronoi, voronoi_plot_2d
from bezier.curve import Curve # https://bezier.readthedocs.io/en/stable/python/index.html
def get_bezier(polygon, n=10):
closed_polygon = np.concatenate([polygon, [polygon[0]]])
# Insert additional points lying along the edges of the polygon;
# this allows us to use higher order bezier curves.
augmented_polygon = np.array(augment(closed_polygon, n))
# The bezier package does not seem to support closed bezier curves;
# to simulate a closed bezier curve, we triplicate the polygon,
# and only evaluate the curve on the inner third.
triplicated_polygon = np.vstack([augmented_polygon, augmented_polygon, augmented_polygon])
bezier_curve = Curve(triplicated_polygon.T, degree=len(triplicated_polygon)-1)
return bezier_curve.evaluate_multi(np.linspace(1./3, 2./3, 100)).T
def augment(polygon, n=10):
new_points = []
for ii, (x0, y0) in enumerate(polygon[:-1]):
x1, y1 = polygon[ii+1]
x = np.linspace(x0, x1, n)
y = np.linspace(y0, y1, n)
new_points.extend(list(zip(x[:-1], y[:-1])))
new_points.append((x1, y1))
return new_points
if __name__ == '__main__':
# make up data points
points = np.random.randint(0, 11, size=(50, 2))
# add 4 distant dummy points
points = np.append(points, [[999,999], [-999,999], [999,-999], [-999,-999]], axis = 0)
# compute Voronoi tesselation
vor = Voronoi(points)
# voronoi_plot_2d(vor)
fig, ax = plt.subplots()
for region in vor.regions:
if region and (not -1 in region):
polygon = np.array([vor.vertices[i] for i in region])
bezier_curve_points = get_bezier(polygon, 40)
ax.fill(*zip(*bezier_curve_points))
ax.axis([1, 9, 1, 9])
ax.axis('off')
plt.show()
I am attempting to calculate the area of the blue region and the area of yellow region:
In this graph: y=blue, peak_line=green, thresh=orange.
I am using this code:
idx = np.argwhere(np.diff(np.sign(y - peak_line))).flatten()
bounds = [1077.912, 1078.26, 1078.336, 1078.468, 1078.612, 1078.78, 1078.828, 1078.88, 1079.856, 1079.86]
plt.plot(x, y, x, thresh, x, peak_line)
plt.fill_between(x, y, thresh, where=(y>=peak_line),interpolate=True, color='#fff8ba')
plt.fill_between(x, thresh, peak_line, where=(y<=peak_line),interpolate=True, color='#fff8ba')
plt.fill_between(x, y, peak_line, where=(y>=peak_line) & (x>=x[idx][0]) & (x<=bounds[-1]), interpolate=True, color='#CDEAFF')
plt.plot(x[idx], y[idx], 'ro')
plt.show()
estimated_y = interp1d(x, y, kind='cubic')
estimated_peak_line = interp1d(x, peak_line, kind='cubic')
estimated_thresh = interp1d(x, thresh, kind='cubic')
yellow_areas = []
blue_areas = []
for i in range(len(bounds) - 1):
midpoint = (bounds[i] + bounds[i+1]) / 2
if estimated_y(midpoint) < estimated_peak_line(midpoint):
above_peak_line = abs(integrate.quad(estimated_peak_line, bounds[i], bounds[i+1])[0])
above_thresh_line = abs(integrate.quad(estimated_thresh, bounds[i], bounds[i+1])[0])
yellow_areas.append(above_peak_line - above_thresh_line)
else:
above_peak_line = abs(integrate.quad(estimated_peak_line, bounds[i], bounds[i+1])[0])
above_y = abs(integrate.quad(estimated_y, bounds[i], bounds[i+1])[0])
blue_areas.append(above_peak_line - above_y)
print(sum(yellow_areas))
print(sum(blue_areas))
4.900000000000318
2.999654602006661
I thought I calculated the area of the blue region and the area of yellow region correct, until I calculated the area of the polygon:
bunch_of_xs = np.linspace(min(x), max(x), num=10000, endpoint=True)
final_curve = estimated_y(bunch_of_xs)
final_thresh = estimated_thresh(bunch_of_xs)
final_peak_line = estimated_peak_line(bunch_of_xs)
def PolygonArea(corners):
n = len(corners) # of corners
area = 0.0
for i in range(n):
j = (i + 1) % n
area += corners[i][0] * corners[j][1]
area -= corners[j][0] * corners[i][1]
area = abs(area) / 2.0
return area
vertex1 = (bunch_of_xs[0], final_thresh[0])
vertex2 = (bunch_of_xs[-1], final_thresh[-1])
vertex3 = (x[idx][-1], y[idx][-1])
vertex4 = (x[idx][0], y[idx][0])
coords = (vertex1,vertex2,vertex3,vertex4)
plt.plot(x, y, 'o', bunch_of_xs, final_curve, '--', bunch_of_xs, final_thresh, bunch_of_xs, final_peak_line)
x_val = [x[0] for x in coords]
y_val = [x[1] for x in coords]
plt.plot(x_val,y_val,'or')
print("Coordinates of total polygon:", coords)
print("Total polygon area:", PolygonArea(coords))
Coordinates of total polygon: ((1077.728, -41.30177170550451), (1079.96, -42.254314285935834), (1079.86, -49.207348695828706), (1077.912, -48.271572477115136))
Total polygon area: 14.509708069890621
The sum of the area of the blue region and the area of yellow region should equal the total polygon area.
4.900000000000318 + 2.999654602006661 ≠ 14.509708069890621
What am I doing wrong?
Edit: This code will be used for many different graphs. Not all graphs look the same. For example, this graph has 3 blue regions and so I have to calculate the area of all 3 blue regions and add them together to get the total blue area. Every graph has a different amount of blue regions (some only have 1 region). So, I have to make the code flexible to account for the possibility of a graph having multiple blue regions to add together to get the total blue region area.
Since I don't have all of your data I will give something between pseudo-code and implementation.
Say we have arrays x (x-axis), y1 (data), y2 (some line which bounds the parts over which we want to integrate).
First step: Iterate over your bounds array and see which parts we want to integrate over. I assume that you have the bounds array already, as your question suggests.
def get_pairs_of_idxs(x, y1, y2, bounds):
lst_pairs = []
for i in range(len(bounds)-1):
x0, x1 = bounds[i], bounds[i+1]
xc = 0.5 * (x0 + x1) # we want to see if the straight line y2 is above or below, so we take one x value and test it
indx_xc = np.searchsorted(x, xc) # this returns us the index at which xc is located
y1c, y2c = y1[indx_xc], y2[indx_xc]
if y2c < y1c: # then the line is below the curve, so we want to integrate
lst_pairs.append((x0, x1))
Now we have a list of pairs of indices, between which we want to integrate.
def solution(x, y1, y2, bounds):
tot_area = 0
lst_pairs = get_pairs_of_idxs(x, y1, y2, bounds)
for x0, x1 in lst_pairs:
mask = np.logical_and(x >= x0, x <= x1) # relevant places in x and y data
xs = x[mask] # the x values along which we integrate
ys = (y2 - y1)[mask] # we want to integrate the difference of the curves
tot_area += np.trapz(ys, xs)
return tot_area
That's what I was thinking about.
In general, the area between two curves f(x) and g(x) is integral(g(x) - f(x)).
So say we have two curves:
xvals = np.linspace(0, 1, 100)
yvals_1 = np.sin(xvals * 10)
yvals_2 = 0.5 - 0.5 * xvals
plt.plot(xvals, yvals_1, '-b')
plt.plot(xvals, yvals_2, '-g')
The "transformed" curve becomes:
yvals_3 = yvals_1 - yvals_2
plt.plot(xvals, yvals_3, '--r')
plt.plot(xvals, np.zeros(xvals.shape), '--k')
And since we want to ignore everything under the green line,
yvals_3[yvals_3 < 0] = 0
plt.plot(xvals, yvals_3, '-r')
Since you want to impose additional constraints, such as "only the area between the first and last intersections", do that now.
# Cheating a little bit -- but you already know how to get the intersections.
first_intersection_x = xvals[4]
last_intersection_x = xvals[94]
cfilter = np.logical_and(xvals >= first_intersection_x, xvals <= last_intersection_x)
xvals_calc = xvals[cfilter]
yvals_calc = yvals_3[cfilter]
The area under this curve is easily calculated using np.trapz
area_under_curve = np.trapz(yvals_calc, xvals_calc)
Of course, this answer assumes that yvals_1 and yvals_2 are available at the same xvals. If not, interpolation is easy.
I've been trying to push the boundaries of matplotlib's patches and instruct it to draw a rounded FancyArrowPatch with a directional arrow on its midpoint. This would prove incredibly useful in a network representation I am trying to create.
My coding hours with python are not yet in the double digit, so I can't say I have a clear understanding of matplotlib's patches.py, but I have narrowed down the solution to two possible strategies:
the smart, possibly pythonic way: create a custom arrowstyle class which further requires a modification of the _get_arrow_wedge() function to include a midpoint coordinates. This may be beyond my possibilities for now, or
the lazy way: extract the midpoint coordinates from an elicited FancyArrowPatch and draw the desired arrowstyle on such coordinates.
Of course, so far I've chosen the lazy way. I did some early experimenting with extracting the midpoint coordinates of a curved FancyArrowPatch using get_path() and get_path_in_displaycoord(), but I can't seem to predict the precise midpoint coordinates. Some help would be very appreciated.
My fiddling so far:
import matplotlib.pyplot as plt
from matplotlib.patches import FancyArrowPatch
n1 = (2,3)
n2 = (4,6)
# Try with multiple arc radius sizes, draw a separate plot each time
for rad in range(20):
#setup figure
figure = plt.figure()
ax = plt.subplot(111)
plt.annotate('rad:' + str(rad/25.),xy=(2,5))
# create rounded fancyarrowpatch
t = FancyArrowPatch(posA=n1,posB=n2,
connectionstyle='arc3,rad=%s'%float(rad/25.),
arrowstyle='->',
shrinkA=0,
shrinkB=0,
mutation_scale=0.5)
# extract vertices from get_path: points P#
path = t.get_path().vertices.tolist()
lab, px, py = ['P{0}'.format(i) for i in range(len(path))], [u[0] for u in path],[u[1] for u in path]
for i in range(len(path)):
plt.annotate(lab[i],xy=(px[i],py[i]))
# extract vertices from get_path_in_displaycoord (but they are useless) : points G#
newpath = t.get_path_in_displaycoord()
a,b = newpath[0][0].vertices.tolist(), newpath[0][1].vertices.tolist()
a.extend(b)
glab, gx, gy = ['G{0}'.format(i) for i in range(len(a))], [u[0] for u in a],[u[1] for u in a]
for i in range(len(a)):
plt.annotate(glab[i],xy=(gx[i],gy[i]))
#point A: start
x1, y1 = n1
plt.annotate('A',xy=(x1,y1))
#point B:end
x2, y2 = n2
plt.annotate('B',xy=(x2,y2))
#point M: the 'midpoint' as defined by class Arc3, specifically its connect() function
x12, y12 = (x1 + x2) / 2., (y1 + y2) / 2.
dx, dy = x2 - x1, y2 - y1
cx, cy = x12 + (rad/100.) * dy, y12 - (rad/100.) * dx
plt.annotate('M',xy=(cx,cy))
#point O : midpoint between M and P1, the second vertex from get_path
mx,my = (cx + px[1])/2., (cy + py[1])/2.
plt.annotate('O',xy=(mx,my))
ax.add_patch(t)
plt.scatter([x1,cx,x2,mx,gx].extend(px),[y1,cy,y2,my,gy].extend(py))
plt.show()
EDIT: taking onboard #cphlewis suggestions: I tried to reconstruct the Bezier curve:
def bezcurv(start,control,end,tau):
ans = []
for t in tau:
B = [(1-t)**2 * start[i] + 2*(1-t)*t*end[i] + (t**2)*control[i] for i in range(len(start))]
ans.append(tuple(B))
return ans
I thus add the generated line to the original plot:
tau = [time/100. for time in range(101)]
bezsim = bezcurv(n1,n2,(cx,cy),tau)
simx,simy = [b[0] for b in bezsim], [b[1] for b in bezsim]
The green line below is (should be?) the reconstructed bezier curve, though it's clearly not.
After much struggling, I convinced myself that to solve this I had to part away from the FancyArrowPatch suite and create something from scratch. Here is a working solution that, far from fulfilling any perfectionist spirit, satisfied me:
import matplotlib.pyplot as plt
import numpy as np
from numpy.random import seed, randint
# Build function that connects two points with a curved line,
# and an arrow on the middle of it
seed(1679)
narrow = 3
rad_one = 50
numpoints = 3
random_points = list(randint(1,20,[numpoints,4]))
rpoints = [[(a,b),(c,d)] for a,b,c,d in random_points]
def curvline(start,end,rad,t=100,arrows=1,push=0.8):
#Compute midpoint
rad = rad/100.
x1, y1 = start
x2, y2 = end
y12 = (y1 + y2) / 2
dy = (y2 - y1)
cy = y12 + (rad) * dy
#Prepare line
tau = np.linspace(0,1,t)
xsupport = np.linspace(x1,x2,t)
ysupport = [(1-i)**2 * y1 + 2*(1-i)*i*cy + (i**2)*y2 for i in tau]
#Create arrow data
arset = list(np.linspace(0,1,arrows+2))
c = zip([xsupport[int(t*a*push)] for a in arset[1:-1]],
[ysupport[int(t*a*push)] for a in arset[1:-1]])
dt = zip([xsupport[int(t*a*push)+1]-xsupport[int(t*a*push)] for a in arset[1:-1]],
[ysupport[int(t*a*push)+1]-ysupport[int(t*a*push)] for a in arset[1:-1]])
arrowpath = zip(c,dt)
return xsupport, ysupport, arrowpath
def plotcurv(start,end,rad,t=100,arrows=1,arwidth=.25):
x, y, c = curvline(start,end,rad,t,arrows)
plt.plot(x,y,'k-')
for d,dt in c:
plt.arrow(d[0],d[1],dt[0],dt[1], shape='full', lw=0,
length_includes_head=False, head_width=arwidth)
return c
#Create figure
figure = plt.figure()
ax = plt.subplot(111)
for n1,n2 in rpoints:
#First line
plotcurv(n1,n2,rad_one,200,narrow,0.5)
#Second line
plotcurv(n2,n1,rad_one,200,narrow,0.5)
ax.set_xlim(0,20)
ax.set_ylim(0,20)
plt.show
I have tested it with three random couple of points, plotting back and forth lines. Which gives the figure below:
The function allows for the user to set a number of desired arrow-heads, and it places them evenly on the plotted Bezier, making sure the appropriate direction is represented. However, because the Bezier curve is not exactly an 'arc', I heuristically push the start of the arrow-heads to make them look more centered. Any improvement to this solution will be greatly appreciated.
I am fairly new to using matplotlib and cannot find any examples that show two lines with the angle between them plotted.
This is my current image:
And this is an example of what I want to achieve:
I usually take a look at the Matplotlib gallery to get an idea of how to perform certain tasks but there does not seem to be anything similar.
You could use matplotlib.patches.Arc to plot an arc of the corresponding angle measure.
To draw the angle arc:
Define a function that could take 2 matplotlib.lines.Line2D objects, calculate the angle and return a matplotlib.patches.Arc object, which you can add to your plot along with the lines.
def get_angle_plot(line1, line2, offset = 1, color = None, origin = [0,0], len_x_axis = 1, len_y_axis = 1):
l1xy = line1.get_xydata()
# Angle between line1 and x-axis
slope1 = (l1xy[1][1] - l1xy[0][2]) / float(l1xy[1][0] - l1xy[0][0])
angle1 = abs(math.degrees(math.atan(slope1))) # Taking only the positive angle
l2xy = line2.get_xydata()
# Angle between line2 and x-axis
slope2 = (l2xy[1][3] - l2xy[0][4]) / float(l2xy[1][0] - l2xy[0][0])
angle2 = abs(math.degrees(math.atan(slope2)))
theta1 = min(angle1, angle2)
theta2 = max(angle1, angle2)
angle = theta2 - theta1
if color is None:
color = line1.get_color() # Uses the color of line 1 if color parameter is not passed.
return Arc(origin, len_x_axis*offset, len_y_axis*offset, 0, theta1, theta2, color=color, label = str(angle)+u"\u00b0")
To print the angle values :
Incase you want the angle value to be displayed inline, refer this SO Question for how to print inline labels in matplotlib. Note that you must print the label for the arc.
I made a small function which extracts the vertices of the arc and tries to compute the coordinate of the angle text.
This may not be optimal and may not work well with all angle values.
def get_angle_text(angle_plot):
angle = angle_plot.get_label()[:-1] # Excluding the degree symbol
angle = "%0.2f"%float(angle)+u"\u00b0" # Display angle upto 2 decimal places
# Get the vertices of the angle arc
vertices = angle_plot.get_verts()
# Get the midpoint of the arc extremes
x_width = (vertices[0][0] + vertices[-1][0]) / 2.0
y_width = (vertices[0][5] + vertices[-1][6]) / 2.0
#print x_width, y_width
separation_radius = max(x_width/2.0, y_width/2.0)
return [ x_width + separation_radius, y_width + separation_radius, angle]
Or you could always precompute the label point manually and use text to display the angle value. You can get the angle value from the label of the Arc object using the get_label() method (Since we had set the label to the angle value + the unicode degree symbol).
Example usage of the above functions :
fig = plt.figure()
line_1 = Line2D([0,1], [0,4], linewidth=1, linestyle = "-", color="green")
line_2 = Line2D([0,4.5], [0,3], linewidth=1, linestyle = "-", color="red")
ax = fig.add_subplot(1,1,1)
ax.add_line(line_1)
ax.add_line(line_2)
angle_plot = get_angle_plot(line_1, line_2, 1)
angle_text = get_angle_text(angle_plot)
# Gets the arguments to be passed to ax.text as a list to display the angle value besides the arc
ax.add_patch(angle_plot) # To display the angle arc
ax.text(*angle_text) # To display the angle value
ax.set_xlim(0,7)
ax.set_ylim(0,5)
If you do not care about inline placement of the angle text. You could use plt.legend() to print the angle value.
Finally :
plt.legend()
plt.show()
The offset parameter in the function get_angle_plot is used to specify a psudo-radius value to the arc.
This will be useful when angle arcs may overlap with each other.
( In this figure, like I said, my get_angle_text function is not very optimal in placing the text value, but should give you an idea on how to compute the point )
Adding a third line :
line_3 = Line2D([0,7], [0,1], linewidth=1, linestyle = "-", color="brown")
ax.add_line(line_3)
angle_plot = get_angle_plot(line_1, line_3, 2, color="red") # Second angle arc will be red in color
angle_text = get_angle_text(angle_plot)
ax.add_patch(angle_plot) # To display the 2nd angle arc
ax.text(*angle_text) # To display the 2nd angle value
Taking idea from #user3197452 here is what I use. This version combines text and also takes care of in-proportional axis ratios.
def add_corner_arc(ax, line, radius=.7, color=None, text=None, text_radius=.5, text_rotatation=0, **kwargs):
''' display an arc for p0p1p2 angle
Inputs:
ax - axis to add arc to
line - MATPLOTLIB line consisting of 3 points of the corner
radius - radius to add arc
color - color of the arc
text - text to show on corner
text_radius - radius to add text
text_rotatation - extra rotation for text
kwargs - other arguments to pass to Arc
'''
lxy = line.get_xydata()
if len(lxy) < 3:
raise ValueError('at least 3 points in line must be available')
p0 = lxy[0]
p1 = lxy[1]
p2 = lxy[2]
width = np.ptp([p0[0], p1[0], p2[0]])
height = np.ptp([p0[1], p1[1], p2[1]])
n = np.array([width, height]) * 1.0
p0_ = (p0 - p1) / n
p1_ = (p1 - p1)
p2_ = (p2 - p1) / n
theta0 = -get_angle(p0_, p1_)
theta1 = -get_angle(p2_, p1_)
if color is None:
# Uses the color line if color parameter is not passed.
color = line.get_color()
arc = ax.add_patch(Arc(p1, width * radius, height * radius, 0, theta0, theta1, color=color, **kwargs))
if text:
v = p2_ / np.linalg.norm(p2_)
if theta0 < 0:
theta0 = theta0 + 360
if theta1 < 0:
theta1 = theta1 + 360
theta = (theta0 - theta1) / 2 + text_rotatation
pt = np.dot(rotation_transform(theta), v[:,None]).T * n * text_radius
pt = pt + p1
pt = pt.squeeze()
ax.text(pt[0], pt[1], text,
horizontalalignment='left',
verticalalignment='top',)
return arc
get_angle function is what I have posted here, but copied again for completeness.
def get_angle(p0, p1=np.array([0,0]), p2=None):
''' compute angle (in degrees) for p0p1p2 corner
Inputs:
p0,p1,p2 - points in the form of [x,y]
'''
if p2 is None:
p2 = p1 + np.array([1, 0])
v0 = np.array(p0) - np.array(p1)
v1 = np.array(p2) - np.array(p1)
angle = np.math.atan2(np.linalg.det([v0,v1]),np.dot(v0,v1))
return np.degrees(angle)
def rotation_transform(theta):
''' rotation matrix given theta
Inputs:
theta - theta (in degrees)
'''
theta = np.radians(theta)
A = [[np.math.cos(theta), -np.math.sin(theta)],
[np.math.sin(theta), np.math.cos(theta)]]
return np.array(A)
To use it one can do this:
ax = gca()
line, = ax.plot([0, 0, 2], [-1, 0, 0], 'ro-', lw=2)
add_corner_arc(ax, line, text=u'%d\u00b0' % 90)
I've written a function to create a matplotlib Arc object that takes several helpful arguments. It also works on lines that do not intersect at the origin. For a given set of two lines, there are many possible arcs that the user may want to draw. This function lets one specify which one using the arguments. The text is drawn at the midpoint between the arc and the origin. Improvements are more than welcome in the comments, or on the gist containing this function.
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
Arc = matplotlib.patches.Arc
def halfangle(a, b):
"Gets the middle angle between a and b, when increasing from a to b"
if b < a:
b += 360
return (a + b)/2 % 360
def get_arc_patch(lines, radius=None, flip=False, obtuse=False, reverse=False, dec=0, fontsize=8):
"""For two sets of two points, create a matplotlib Arc patch drawing
an arc between the two lines.
lines: list of lines, of shape [[(x0, y0), (x1, y1)], [(x0, y0), (x1, y1)]]
radius: None, float or tuple of floats. If None, is set to half the length
of the shortest line
orgio: If True, draws the arc around the point (0,0). If False, estimates
the intersection of the lines and uses that point.
flip: If True, flips the arc to the opposite side by 180 degrees
obtuse: If True, uses the other set of angles. Often used with reverse=True.
reverse: If True, reverses the two angles so that the arc is drawn
"the opposite way around the circle"
dec: The number of decimals to round to
fontsize: fontsize of the angle label
"""
import numpy as np
from matplotlib.patches import Arc
linedata = [np.array(line.T) for line in lines]
scales = [np.diff(line).T[0] for line in linedata]
scales = [s[1] / s[0] for s in scales]
# Get angle to horizontal
angles = np.array([np.rad2deg(np.arctan(s/1)) for s in scales])
if obtuse:
angles[1] = angles[1] + 180
if flip:
angles += 180
if reverse:
angles = angles[::-1]
angle = abs(angles[1]-angles[0])
if radius is None:
lengths = np.linalg.norm(lines, axis=(0,1))
radius = min(lengths)/2
# Solve the point of intersection between the lines:
t, s = np.linalg.solve(np.array([line1[1]-line1[0], line2[0]-line2[1]]).T, line2[0]-line1[0])
intersection = np.array((1-t)*line1[0] + t*line1[1])
# Check if radius is a single value or a tuple
try:
r1, r2 = radius
except:
r1 = r2 = radius
arc = Arc(intersection, 2*r1, 2*r2, theta1=angles[1], theta2=angles[0])
half = halfangle(*angles[::-1])
sin = np.sin(np.deg2rad(half))
cos = np.cos(np.deg2rad(half))
r = r1*r2/(r1**2*sin**2+r2**2*cos**2)**0.5
xy = np.array((r*cos, r*sin))
xy = intersection + xy/2
textangle = half if half > 270 or half < 90 else 180 + half
textkwargs = {
'x':xy[0],
'y':xy[1],
's':str(round(angle, dec)) + "°",
'ha':'center',
'va':'center',
'fontsize':fontsize,
'rotation':textangle
}
return arc, textkwargs
It creates arcs like in the following image, using the attached script:
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Arc
# lines are formatted like this: [(x0, y0), (x1, y1)]
line1 = np.array([(1,-2), (3,2)])
line2 = np.array([(2,2), (2,-2)])
lines = [line1, line2]
fig, AX = plt.subplots(nrows=2, ncols=2)
for ax in AX.flatten():
for line in lines:
x,y = line.T
ax.plot(x,y)
ax.axis('equal')
ax1, ax2, ax3, ax4 = AX.flatten()
arc, angle_text = get_arc_patch(lines)
ax1.add_artist(arc)
ax1.set(title='Default')
ax1.text(**angle_text)
arc, angle_text = get_arc_patch(lines, flip=True)
ax2.add_artist(arc)
ax2.set(title='flip=True')
ax2.text(**angle_text)
arc, angle_text = get_arc_patch(lines, obtuse=True, reverse=True)
ax3.add_artist(arc)
ax3.set(title='obtuse=True, reverse=True')
ax3.text(**angle_text)
arc, angle_text = get_arc_patch(lines, radius=(2,1))
ax4.add_artist(arc)
ax4.set(title='radius=(2,1)')
ax4.text(**angle_text)
plt.tight_layout()
plt.show()
I was looking for more of an all in one solution and found the AngleAnnotation class. I highly recommend it.
It is often useful to mark angles between lines or inside shapes with a circular arc. While Matplotlib provides an Arc, an inherent problem when directly using it for such purposes is that an arc being circular in data space is not necessarily circular in display space. Also, the arc's radius is often best defined in a coordinate system which is independent of the actual data coordinates - at least if you want to be able to freely zoom into your plot without the annotation growing to infinity.
You can find it here https://matplotlib.org/stable/gallery/text_labels_and_annotations/angle_annotation.html
I save it as AngleAnnotation.py (of course you can name it differently) in my working directory and import it in my code with
from AngleAnnotation import AngleAnnotation
here is a snippet of how I use it:
...
#intersection of the two lines
center = (0.0,0.0)
#any point (other than center) on one line
p1 = (6,2)
# any point (other than center) on the other line
p2 = (6,0)
# you may need to switch around p1 and p2 if the arc is drawn enclosing the lines instead
# of between
# ax0 is the axes in which your lines exist
# size sets how large the arc will be
# text sets the label for your angle while textposition lets you rougly set where the label is, here "inside"
# you can pass kwargs to the textlabel using text_kw=dict(...)
# especially useful is the xytext argument which lets you customize the relative position of your label more precisely
am1 = AngleAnnotation(center, p1, p2, ax=ax0, size=130, text="some_label", textposition = "inside", text_kw=dict(fontsize=20, xytext = (10,-5)))
You can find many more details in the link above. It's working for me on matplotlib 3.4.2 right now.
I find TomNorway's approach better, it has more flexibility in other cases than the accepted answer. I tested the code and made some quick fixes for even more applicability by creating a class.
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.patches import Arc
class LinesAngles:
def __init__(self, line1, line2, radius=None, flip=False, obtuse=False, reverse=False, dec=0, fontsize=8, title=""):
"""
line1: list of two points, of shape [[x0, y0], [x1, y1]]
line2: list of two points, of shape [[x0, y0], [x1, y1]]
radius: None, float or tuple of floats. If None, is set to half the length
of the shortest line orgio: If True, draws the arc around the point (0,0). If False, estimates
the intersection of the lines and uses that point.
flip: If True, flips the arc to the opposite side by 180 degrees
obtuse: If True, uses the other set of angles. Often used with reverse=True.
reverse: If True, reverses the two angles so that the arc is drawn "the opposite way around the circle"
dec: The number of decimals to round to
fontsize: fontsize of the angle label
title: Title of the plot
"""
self.line1 = line1
self.line2 = line2
self.lines = [line1, line2]
self.radius = radius
self.flip = flip
self.obtuse = obtuse
self.reverse = reverse
self.dec = dec
self.fontsize = fontsize
self.title = title
def halfangle(self,a, b) -> float:
"""
Gets the middle angle between a and b, when increasing from a to b
a: float, angle in degrees
b: float, angle in degrees
returns: float, angle in degrees
"""
if b < a:
b += 360
return (a + b)/2 % 360
def get_arc_patch(self, lines: list):
"""
For two sets of two points, create a matplotlib Arc patch drawing
an arc between the two lines.
lines: list of lines, of shape [[(x0, y0), (x1, y1)], [(x0, y0), (x1, y1)]]
returns: Arc patch, and text for the angle label
"""
linedata = [np.array(line.T) for line in lines]
scales = [np.diff(line).T[0] for line in linedata]
scales = [s[1] / s[0] for s in scales]
# Get angle to horizontal
angles = np.array([np.rad2deg(np.arctan(s/1)) for s in scales])
if self.obtuse:
angles[1] = angles[1] + 180
if self.flip:
angles += 180
if self.reverse:
angles = angles[::-1]
angle = abs(angles[1]-angles[0])
if self.radius is None:
lengths = np.linalg.norm(lines, axis=(0,1))
self.radius = min(lengths)/2
# Solve the point of intersection between the lines:
t, s = np.linalg.solve(np.array([line1[1]-line1[0], line2[0]-line2[1]]).T, line2[0]-line1[0])
intersection = np.array((1-t)*line1[0] + t*line1[1])
# Check if radius is a single value or a tuple
try:
r1, r2 = self.radius
except:
r1 = r2 = self.radius
arc = Arc(intersection, 2*r1, 2*r2, theta1=angles[1], theta2=angles[0])
half = self.halfangle(*angles[::-1])
sin = np.sin(np.deg2rad(half))
cos = np.cos(np.deg2rad(half))
r = r1*r2/(r1**2*sin**2+r2**2*cos**2)**0.5
xy = np.array((r*cos, r*sin))
xy = intersection + xy/2
textangle = half if half > 270 or half < 90 else 180 + half
textkwargs = {
'x':xy[0],
'y':xy[1],
's':str(round(angle, self.dec)) + "°",
'ha':'center',
'va':'center',
'fontsize':self.fontsize,
'rotation':textangle
}
return arc, textkwargs
def plot(self) -> None:
"""!
Plot the lines and the arc
"""
fig = plt.figure()
ax = fig.add_subplot(1,1,1)
for line in self.lines:
x,y = line.T
ax.plot(x,y)
ax.axis('equal')
arc, angle_text = self.get_arc_patch(self.lines)
ax.add_artist(arc)
ax.set(title=self.title)
ax.text(**angle_text)
plt.show()
For using it you just create the instance and the plot function.
# lines are formatted like this: [(x0, y0), (x1, y1)]
line1 = np.array([(1,-2), (3,2)])
line2 = np.array([(2,2), (2,-2)])
default = LinesAngles(line1, line2, title="Default")
#Plot single pair of lines
default.plot()
If you still want to plot multiple cases, I created a function that accepts the instances and plots automatically to the subplots you need.
# lines are formatted like this: [(x0, y0), (x1, y1)]
line1 = np.array([(1,-2), (3,2)])
line2 = np.array([(2,2), (2,-2)])
default = LinesAngles(line1, line2, title="Default")
flip = LinesAngles(line1, line2, title='flip=True', flip=True)
obtuse = LinesAngles(line1, line2, title='obtuse=True, reverse=True', obtuse=True, reverse=True)
radius = LinesAngles(line1, line2, title='radius=(2,1)', radius=(2,1))
#Plot single pair of lines
default.plot()
#Plot multiple line pairs
multiple_plot(default, flip, obtuse, radius, num_subplots=4)
Thanks to TomNorway for his answer, all credit to him I only made some modifications.
I have 2 points on a circle and the angle between them and I would like to find the center of the circle that is thus defined (well, both centers preferably).
def find_center(p1,p2,angle):
# magic happens... What to do here?
return (center_x, center_y)
Here is my solution with the test code
from pylab import *
from numpy import *
def find_center(p1, p2, angle):
# End points of the chord
x1, y1 = p1
x2, y2 = p2
# Slope of the line through the chord
slope = (y1-y2)/(x1-x2)
# Slope of a line perpendicular to the chord
new_slope = -1/slope
# Point on the line perpendicular to the chord
# Note that this line also passes through the center of the circle
xm, ym = (x1+x2)/2, (y1+y2)/2
# Distance between p1 and p2
d_chord = sqrt((x1-x2)**2 + (y1-y2)**2)
# Distance between xm, ym and center of the circle (xc, yc)
d_perp = d_chord/(2*tan(angle))
# Equation of line perpendicular to the chord: y-ym = new_slope(x-xm)
# Distance between xm,ym and xc, yc: (yc-ym)^2 + (xc-xm)^2 = d_perp^2
# Substituting from 1st to 2nd equation for y,
# we get: (new_slope^2+1)(xc-xm)^2 = d^2
# Solve for xc:
xc = (d_perp)/sqrt(new_slope**2+1) + xm
# Solve for yc:
yc = (new_slope)*(xc-xm) + ym
return xc, yc
if __name__=='__main__':
p1 = [1., 2.]
p2 = [-3, 4.]
angle = pi/6
xc, yc = find_center(p1, p2,angle)
# Calculate the radius and draw a circle
r = sqrt((xc-p1[0])**2 + (yc-p1[1])**2)
cir = Circle((xc,yc), radius=r, fc='y')
gca().add_patch(cir)
# mark p1 and p2 and the center of the circle
plot(p1[0], p1[1], 'ro')
plot(p2[0], p2[1], 'ro')
plot(xc, yc, 'go')
show()
# Solve for xc:
xc = (d_perp)/sqrt(new_slope**2+1) +xm # looks like +xm got omitted!)
# Solve for yc:
yc = (new_slope)*(xc-xm)+ym
you also need to check for x1=x2
# Slope of the line through the chord
if x1==x2
slope = 999999
else
slope = (y1-y2)/(x1-x2)
I'm really rusty on this stuff, so this might be a bit off, but it should get you started. Also, I don't know python, so this is just the pseudocode:
//check to ensure...
//The two points aren't the same
//The angle isn't zero
//Other edge cases
//Get the distance between the points
x_dist = x2 - x1;
y_dist = y2 - y1;
//Find the length of the 'opposite' side of the right triangle
dist_opp = (sqrt((x_dist)^2 + (y_dist)^2)));
x_midpoint = (x1 - (x_dist / 2);
y_midpoint = (y1 - (y_dist / 2);
theta = the_angle / 2; //the right triangle's angle is half the starting angle
dist_adj = cotangent(theta) * dist_opp;//find the right triangle's length
epsilon = sqrt((-y_dist)^2 + x_dist^2);
segments = epsilon / dist_adj;
x_center = x_midpoint + (x_dist * segments);
y_center = y_midpoint + (y_dist * segments);
you have to solve the triangle p1 p2 c. You have one angle. The other two angles are (180-angle)/2 Calculate side p1 p2 (distance) Then calculate the side p1 c this gives you the radius r of the circle. The solutions are the two points that are the intersection of the circles with center p1 and center p2 and radius r.
Only it seems like it should be: d_perp = d_chord/(2*tan(angle/2)).
# Distance between xm, ym and center of the circle (xc, yc)
d_perp = d_chord/(2*tan(angle))