17

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:

  1. The polygon is not self-intersecting.
  2. The resulting polygon is not necessarily convex; a convex hull will not suffice
  3. The polygon needs to go through all of the points. I have tried the 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)
  4. The resulting polygon will not intersect with the drawn line. I think this is relevant to remove a lot of the ambiguity for concave polygons

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.

Example of problem

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?

EDIT:

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.

9
  • 1
    Is the line always contained in the convex hull? Are we guaranteed that this polygon exists? Commented Jul 9 at 19:13
  • @btilly the polygon will always exist, in fact in the full program the points are chosen partly based on the line. As for whether the line is in the convex hull, it is likely but may not happen all the time; if it is necessary, then an algorithm requiring this would be okay as well. Commented Jul 9 at 19:35
  • This sounds like it has something to do with the interpolating polynomial ( en.wikipedia.org/wiki/Polynomial_interpolation ) with some added details which are not clearly explained. Please clarify: especially the details of re-ordering the points. What is the input order? What is the required output order? Commented Jul 9 at 19:51
  • @ravenspoint Sorry for not specifying. The black marks are the dots to form the polygon. The red is the drawn line which is inside the polygon, 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 is not ordered in any meaningful way Commented Jul 9 at 20:12
  • 2
    If the drawn red line is something that the algorithm has access to, you could compute for each black point the point on the red line that's closest to it, and use that to express the black points in a new coordinate system: each black point has coordinates (x = linelength coordinate along the red line; y = signed distance from the red line (positive for left side of red line, negative for right side)). And then you just order the points according to their coordinates in this new system. Commented Jul 10 at 14:43

3 Answers 3

14

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

diagram

Sign up to request clarification or add additional context in comments.

Comments

11

I would suggest:

  • For each point, detect the closest point on the line,

  • measure how far along the line this is, distance =d (max length of line =D)

  • Detect whether the point is to the left (L) or the right (R) of the line (even though this is subjective at the 2 ends)

  • combine these to give each point a side and distance combination Ld or Rd , eg L0, L0.2, R0, R3.3.... RD

  • enter image description here

  • sort the points L0 to LD followed by RD to R0.

  • enter image description here

  • There are likely to be multiple L0, R0, LD and RD points because multiple points are closest to the ends of the line. For these (and other tied points), introduce a tie-breaker which measures the angle (from tangent to the line) more precisely than just left and right.

  • enter image description here

This algorithm will work best if the points follow the line reasonably well

It will be poor if the points are uncorrelated with the line.

Comments

3
  • Select an input point at random
  • LOOP
    • Find the nearest other point to the selected in the input, that has not already been connected
    • Draw a line from the selected point to the nearest.
    • Select the nearest
    • IF all points connected
      • BREAK out of LOOP
  • END LOOP

Here is some demo code in C++ to show how this algorithm works

void cSolver::gen1() { // Example from https://stackoverflow.com/q/79696095/16582 // (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)]] myInputPoints = {{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}}; } void cSolver::connect() { // working copy of input auto work = myInputPoints; mySortedPoints.clear(); // select first point cxy p = work[0]; // loop unitl all points connected while (mySortedPoints.size() < work.size()) { // output selected point mySortedPoints.push_back(p); // find nearest unconnected point double mind = INT_MAX; int mindex; // index of nearest point for (int i = 1; i < work.size(); i++) { // ignore connected and self points if (p.isValid() && ( ! ( p == work[i] ))) { double d = p.dist2(work[i]); if (d < mind) { mind = d; mindex = i; } } } // select nearest point p = work[mindex]; // prevemt it being reconsidered work[mindex].invalidate(); } } 

Running on your sample data this produces

enter image description here

Complete app code at https://codeberg.org/JamesBremner/Polygonify

As @stef pointed out, if the polygon has a narrow neck like a sand timer then this algorithm may become confused by jumping across the narrow neck. This could be prevented by checking that all connections between dots do not intersect the 'drawn line' in the middle of the polygon.

enter image description here

I took a look at your code, but cannot spot where the drawn line specification are. So I guessed.

void cSolver::gen1() { // Verices of polygon in random order // Example from https://stackoverflow.com/q/79696095/16582 // (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)]] myInputPoints = {{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}}; // polygon middle line // my guess myMiddleLine = {{100, 300}, {200, 400}, {300, 450}}; } void cSolver::connect() { // working copy of input auto work = myInputPoints; mySortedPoints.clear(); // select first point cxy p = work[0]; // loop unitl all points connected while (mySortedPoints.size() < work.size()) { // output selected point mySortedPoints.push_back(p); // find nearest unconnected point double mind = INT_MAX; int mindex; // index of nearest point for (int i = 1; i < work.size(); i++) { // ignore previously connected points if (!p.isValid()) continue; // ignore self points if (p == work[i]) continue; // ignore connections that cross the middle line bool cross = false; cxy prev; for (cxy &mp : myMiddleLine) { if (!prev.isValid()) { prev = mp; continue; } cxy intersect; if (cxy::isIntersection( intersect, p, work[i], // test connection prev, mp)) // middle line segment { cross = true; break; } } if( cross ) continue; double d = p.dist2(work[i]); if (d < mind) { mind = d; mindex = i; } } // select nearest point p = work[mindex]; // prevent it being reconsidered work[mindex].invalidate(); } 

Minor notes:

  • This will produce clockwise and anticlockwise vertex orders at random - here is a test to find out which https://stackoverflow.com/a/1165943/16582

  • The code produces open polygon listings. To close simply add the first vertex to the end of the sorted list

3 Comments

Upvoted. But worth mentioning the one weakness of this algorithm: if the polygon has a "sandtimer" or "squeeze" shape somewhere, it will get confused.
Because the algorithm may find a point on the other side of the narrow neck? That might be a use for the "drawn line" in the middle. Do not connect dots if the connection intersects the "drawn line"?
Added to answer to deal with this.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.