I have a list of points (x,y). A line is drawn somewhere 'inside' these points (within the area they form). I have been trying different algorithms for ordering these points to create a polygon, with these constraints:
alphashape
concave hull algorithm, but I was not able to get it to work (it ignored some points, and I wasn't sure what to tune the alpha
value to)Some clarification for how the points are ordered: they roughly follow the profile of a contoured line, which is unknown and different from the drawn line. The drawn line will be intrinsically quite similar to this unspecified line however.
I have also tried the very simplistic angle sort algorithm, which unsurprisingly does not work. However, running the algorithm at differnt points along the line gave more promising results, but I am not sure how to use the data.
Above is a screenshot of the following test program I cobbled together:
import pygame
import numpy as np
from scipy.interpolate import splprep, splev
import sys
import random
# Pygame setup
pygame.init()
screen = pygame.display.set_mode((1000, 1000))
pygame.display.set_caption("Smoothed Curve with Draggable Points")
clock = pygame.time.Clock()
# Colors
WHITE = (255, 255, 255)
BLUE = (0, 0, 255)
RED = (255, 0, 0)
GREEN = (0, 200, 0)
BLACK = (0, 0, 0)
drawing = False
points = []
smoothed = []
normals = []
# Dot settings
DOT_RADIUS = 6
DRAG_RADIUS = 10
# Dot class for draggable points
class Dot:
def __init__(self, pos):
self.x, self.y = pos
self.dragging = False
def pos(self):
return (int(self.x), int(self.y))
def is_mouse_over(self, mx, my):
return (self.x - mx)**2 + (self.y - my)**2 <= DRAG_RADIUS**2
def start_drag(self):
self.dragging = True
def stop_drag(self):
self.dragging = False
def update_pos(self, mx, my):
if self.dragging:
self.x, self.y = mx, my
# crude test arrangement of dots.
random_dots = [Dot(tup) for tup in [(159, 459),(133, 193),(286, 481),(241, 345),(411, 404),(280, 349),(352, 471),(395, 361),(85, 390),(203, 321),(41, 281),(58, 348),(175, 275),(75, 185),(385, 443),(44, 219),(148, 229),(215, 477),(338, 339),(122, 430)]]
def downsample(points, step=2):
return points[::step]
def smooth_curve(points, smoothness=150):
if len(points) < 4:
return [], []
points = downsample(points, step=2)
x, y = zip(*points)
try:
tck, u = splprep([x, y], s=100.0, k=3)
u_new = np.linspace(0, 1, smoothness)
# Evaluate curve points
x_vals, y_vals = splev(u_new, tck)
# Derivatives: dx/du, dy/du
dx_du, dy_du = splev(u_new, tck, der=1)
gradients = np.array(dy_du) / np.array(dx_du) # dy/dx
return list(zip(x_vals, y_vals)), list(zip(dx_du, dy_du))
except:
return [], []
def draw_normals(screen, smoothed, derivatives, spacing=10, length=30):
for i in range(0, len(smoothed), spacing):
x, y = smoothed[i]
dx, dy = derivatives[i]
# Normal vector is (-dy, dx)
nx, ny = -dy, dx
norm = np.hypot(nx, ny)
if norm == 0:
continue
nx /= norm
ny /= norm
x1 = x - nx * length / 2
y1 = y - ny * length / 2
x2 = x + nx * length / 2
y2 = y + ny * length / 2
pygame.draw.line(screen, GREEN, (x1, y1), (x2, y2), 2)
def generate_random_dots(n=20, w=800, h=600):
return [Dot((random.randint(0, w), random.randint(0, h))) for _ in range(n)]
# Main loop
dragged_dot = None
running = True
while running:
screen.fill(WHITE)
mx, my = pygame.mouse.get_pos()
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN:
drawing = True
points = []
smoothed = []
normals = []
# Check for draggable dot
for dot in random_dots:
if dot.is_mouse_over(mx, my):
dragged_dot = dot
dot.start_drag()
drawing = False
break
elif event.type == pygame.MOUSEBUTTONUP:
drawing = False
if dragged_dot:
dragged_dot.stop_drag()
dragged_dot = None
else:
smoothed, normals = smooth_curve(points)
elif event.type == pygame.MOUSEMOTION:
if dragged_dot:
dragged_dot.update_pos(mx, my)
elif drawing:
points.append((mx, my))
elif event.type == pygame.KEYDOWN:
if event.key == pygame.K_p:
random_dots = generate_random_dots()
if event.key == pygame.K_l:
for i in random_dots: print(f"({i.x}, {i.y})")
# Draw random draggable dots
for dot in random_dots:
pygame.draw.circle(screen, BLACK, dot.pos(), DOT_RADIUS)
# Draw raw stroke
if len(points) > 1:
pygame.draw.lines(screen, BLUE, False, points, 1)
# Draw smoothed curve
if len(smoothed) > 1:
pygame.draw.lines(screen, RED, False, smoothed, 3)
# Draw normals
if smoothed and normals:
draw_normals(screen, smoothed, normals)
pygame.display.flip()
clock.tick(60)
pygame.quit()
sys.exit()
What algorithm could I use to accomplish this?
Some details about the diagram. The black dots are the input points to form the polygon. The red is the drawn line, which is a smoothed version of the blue one. The green lines show some equidistant test points on the drawn line. The required output order is the order in which the points have to be joined up to form the polygon, i.e. A,B,C,D for a rectangle. The input order of the points is not ordered in any meaningful way.
For completeness I show a geometric implementation that is written in vectorised Numpy. Your code is in Python so I also use Python. For a few reasons, this approach benefits from decimating the interior line down to a low point count.
import numpy as np
from matplotlib import pyplot as plt
from matplotlib.collections import LineCollection
def polysort(
dots: np.ndarray, line: np.ndarray,
) -> tuple[
np.ndarray, # order index
np.ndarray, # line segment centroids
np.ndarray, # line segment centroids for each dot
]:
# Between every line point and the next one, get segment centroids
bounds = np.lib.stride_tricks.sliding_window_view(line, window_shape=2, axis=0)
centroids = bounds.mean(axis=-1)
# Get basis vectors for each segment
basis = np.einsum(
# i: point index
# j: x,y
# k: prev bound, next bound
# m: u, v
# n: parallel/normal
'ijk,k,jmn->imn',
bounds,
# Difference between current and next bound, approximately normalised
(-1e-4, +1e-4),
# Transform to u, v space
(
((1, 0),
(0, -1)),
((0, 1),
(1, 0)),
),
)
# Deltas between each dot and each line point,
# the index of the closest centroid,
# and the closest centroid
diffs = dots[:, np.newaxis] - centroids[np.newaxis]
idx = np.vecdot(diffs, diffs, axis=-1).argmin(axis=-1)
chosen_centroids = centroids[idx]
# Projection of each dot into its segment's uv space
u, v = np.einsum('ik,ijk->ji', dots - chosen_centroids, basis[idx])
chirality = v > 0
# Sort by chirality, then segment index, then segment projection
keys = np.stack((u, idx, chirality))
keys[:2, ~chirality] *= -1
return np.lexsort(keys), centroids, chosen_centroids
def plot(
dots: np.ndarray, line: np.ndarray, order: np.ndarray,
centroids: np.ndarray, chosen_centroids: np.ndarray,
) -> plt.Figure:
fig, ax = plt.subplots()
ax.scatter(*dots.T)
ax.scatter(*centroids.T)
ax.plot(*line.T)
ax.add_collection(LineCollection(
np.stack((chosen_centroids, dots), axis=1)[order],
edgecolors='blue', alpha=0.2,
))
for o, (dx, dy) in enumerate(dots[order]):
ax.text(dx+5, dy+5, str(o))
return fig
def demo() -> None:
# Sample data
dots = np.array((
(159, 459), (133, 193), (286, 481), (241, 345),
(411, 404), (280, 349), (352, 471), (395, 361),
( 85, 390), (203, 321), ( 41, 281), ( 58, 348),
(175, 275), ( 75, 185), (385, 443), ( 44, 219),
(148, 229), (215, 477), (338, 339), (122, 430),
))
line = np.array((
( 94, 209),
(117, 329),
(200, 400),
(287, 419),
(375, 387),
))
order, centroids, chosen_centroids = polysort(dots=dots, line=line)
plot(
dots=dots, line=line, order=order,
centroids=centroids, chosen_centroids=chosen_centroids,
)
plt.show()
if __name__ == '__main__':
demo()