shapely: Getting rid of points between 'vertices' of polygons that simplify() doesn't always remove (possible alternative?)

This is kind of related to issue #939 that was previously marked invalid. And I wanted to highlight it a bit more with example pictures and also ask if there is any interest in having a similar feature implemented in Shapely?

So I find that sometimes the simplify() method with polygons doesn’t remove points that lie along the edges between vertices which can lead me into a heuristic battle trying out different tolerance values with no success (and lead me to having a similar issue as in #939, of only wanting the ‘obvious’ corners).

One of the suggestions in that issue thread though was the use of some trig along with the exterior.coords attribute. So far I’ve found this approach really handy! And it has yet to fail me (by no means robust though, I’ve only been using it on simple, non-intersecting polygons)

Example

Here is an simple example with a hexagon with a bunch of points along the edges between vertices

hex1_degen

Below is an attempt using the simplify() method with preserve_topology = False

tolerance values: [1e-10, 0.1, 1, 10, 100] (left to right, respectively)

hex_simple_notopology

So in this example, either there was still a persistent point or too many points were removed (in the case of 100, it was completely removed). In some shapes, there could be a value between 1 and 10 which gives the correct shape as desired, but this leads me back to my previous point about getting into a heuristic battle. (and for this example however, there was no value between 1 and 10 which gave the ‘right’ result)

Repeated again, but with preserve_topology = True

hex_simple_topology

Same deal as above

But with the suggested approach of using the exterior coordinates + trigonometry, I am able to get the ideal result

hex1_interior

Code

so the basic idea is the function simplify_by_angle cycles through the coordinate sequence, constructs a vector of differences between sequential points, and then computes the angles between these vectors and eliminates anything to satisfy the degree tolerance (I use 1 degree here as an example, since I only just want to eliminate only points that lie on the straight edges between vertices).

import numpy as np
import shapely.geometry as sg

def get_angles(vec_1,vec_2):
    """
    return the angle, in degrees, between two vectors
    """
    
    dot = np.dot(vec_1, vec_2)
    det = np.cross(vec_1,vec_2)
    angle_in_rad = np.arctan2(det,dot)
    return np.degrees(angle_in_rad)


def simplify_by_angle(poly_in, deg_tol = 1):
    """
    try to remove persistent coordinate points that remain after
    simplify, convex hull, or something, etc. with some trig instead
    params:
    poly_in: shapely Polygon 
    deg_tol: degree tolerance for comparison between successive vectors
    return: 'simplified' polygon
    
    """
    
    ext_poly_coords = poly_in.exterior.coords[:]
    vector_rep = np.diff(ext_poly_coords,axis = 0)
    angles_list = []
    for i in range(0,len(vector_rep) -1 ):
        angles_list.append(np.abs(get_angles(vector_rep[i],vector_rep[i+1])))
    
#   get mask satisfying tolerance
    thresh_vals_by_deg = np.where(np.array(angles_list) > deg_tol)
    
#   gotta be a better way to do this next part
#   sandwich betweens first and last points
    new_idx = [0] + (thresh_vals_by_deg[0] + 1).tolist() + [0]
    new_vertices = [ext_poly_coords[idx] for idx in new_idx]
    

    return sg.Polygon(new_vertices)

As mentioned I only tested it with Polygon types. In terms of efficiencies it could use some work, as well as possibly removing the NumPy dependency?

by the way if anyone wanted to play around with the hexagon, here is a wkt of it

from shapely import wkt
test_hex_wkt = 'POLYGON ((30 10, 29.95 10.08660254037844, 28.7 12.25166604983954, 25.05 18.57365149746594, 24.6 19.35307436087194, 23.4 21.43153532995459, 20 27.32050807568877, 18.1 27.32050807568877, 16 27.32050807568877, 14.5 27.32050807568877, 11.3 27.32050807568877, 6.800000000000001 27.32050807568877, 3.552713678800501e-15 27.32050807568877, -4.999999999999998 18.66025403784439, -5.549999999999999 17.7076260936815, -7.749999999999999 13.89711431702998, -8.5 12.59807621135332, -9.100000000000001 11.55884572681199, -10 10, -7.650000000000001 5.929680602213139, -5.600000000000002 2.378976446696941, -3.550000000000004 -1.171727708819258, -3.000000000000004 -2.124355652982141, -0.6500000000000057 -6.194675050769005, -8.881784197001252e-15 -7.320508075688771, 6.499999999999991 -7.320508075688771, 9.19999999999999 -7.320508075688771, 9.69999999999999 -7.320508075688771, 11.49999999999999 -7.320508075688771, 14.19999999999999 -7.320508075688771, 20 -7.320508075688771, 23.3 -1.604740410711477, 23.65 -0.9985226280623696, 24.55 0.5603230987496204, 25.95 2.985194229346048, 27.15 5.063655198428702, 30 10))'
test_hex_poly = wkt.loads(test_hex_wkt)

Anyway, this became longer than I expected… so would something like this be of benefit to Shapely?

(also sorry, not sure if there’s a better place to post Feature Requests like this?)

Shapely version

using version 1.7.1

About this issue

  • Original URL
  • State: open
  • Created 4 years ago
  • Reactions: 7
  • Comments: 26 (6 by maintainers)

Most upvoted comments

@92kns Well done. This should be a new Feature for Shapely.

This is (almost completely) fixed by https://github.com/libgeos/geos/pull/784 and https://github.com/libgeos/geos/pull/773

The current code still leaves one vertex that should be simplified. Further work should fix this.

I just stumbled on this solution, so thank you for doing the hard work. I did find a few bugs to fix, though.

  1. The initial for loop misses one of the angles.
  2. By including [0] in new_idx, you’re always including the first vertex, even if it should be removed.
  3. This assumes the polygon has no holes. I dealt with this using an external loop. Here’s my version with those issues 1&2 corrected:
def simplify_by_angle(poly_in, deg_tol=1):
    '''Try to remove persistent coordinate points that remain after
    simplify, convex hull, or something, etc. with some trig instead

    poly_in: shapely Polygon
    deg_tol: degree tolerance for comparison between successive vectors
    '''
    ext_poly_coords = poly_in.exterior.coords[:]
    vector_rep = np.diff(ext_poly_coords, axis=0)
    num_vectors = len(vector_rep)
    angles_list = []
    for i in range(0, num_vectors):
        angles_list.append(np.abs(get_angles(vector_rep[i], vector_rep[(i + 1) % num_vectors])))

    #   get mask satisfying tolerance
    thresh_vals_by_deg = np.where(np.array(angles_list) > deg_tol)

    new_idx = list(thresh_vals_by_deg[0] + 1)
    new_vertices = [ext_poly_coords[idx] for idx in new_idx]

    return Polygon(new_vertices)

Again, nice work @92kns. I agree with adding this method to shapely.

Here is my code, which is based on the above code. It worked for me without error for around 1000 quadrilaterals.

def collinear(p0, p1, p2, point_closeness_threshold = 1, angle_closeness_threshold = .3):
    x1, y1 = p1[0] - p0[0], p1[1] - p0[1]
    x2, y2 = p2[0] - p1[0], p2[1] - p1[1]
    #check if points are close enough to be considered co-linear
    if (abs(x1) < point_closeness_threshold and abs(y1) < point_closeness_threshold) or (abs(x2) < point_closeness_threshold and abs(y2) < point_closeness_threshold):
        return True
    m1 = y1/(x1+1e-12)
    m2 = y2/(x2+1e-12)
    delta_The = math.atan((m2-m1)/(1+m1*m2))
    #check if the angle between the lines is low enough to be considered co-linear
    return abs(delta_The) < angle_closeness_threshold

def simplify_by_angle(poly_in):
    coords = poly_in.exterior.coords[:]
    #loop through every point, checking if it is colinear with its neighbors, and removing if it is
    i=1
    while i < len(coords)+1:
        n = len(coords)
        if collinear(coords[(i-1)%n],coords[i%n],coords[(i+1)%n]):
            del coords[i%n]
            i-=1
        i+=1

    return coords

Thanks a lot! The simplify_by_angle function provided by @92kns and @drStacky actually works for this example, and I will use it as a workaround for now. 😃

The upstream ticket seems to be https://github.com/locationtech/jts/issues/679 which appears to be a lowish priority for @dr-jts to handle. (Generally, fixes to JTS make their way to GEOS and shapely). Essentially SimplifyDP (and SimplifyTP) would need evaluate LinearRing geoms as rings, which shouldn’t have a start/end coordinate dependency, which is what “simplify2” sort-of does. It seems normalize() is a decent work-around.

As for new simplify approaches and algorithms (e.g. an angle-based threshold), it’s up to you to decide. My personal take is that there are already several simplify algorithms, which could be adapted and refined. And I would like to see more than two algorithms in shapely eventually.

Polygon([(0, 0), (1, 0), (0, 1), (-1, 0)]) addresses 2: simplify doesn’t remove the unnecessary base point but simplify_by_angle does by not forcing index [0] to be kept.

By rotating the point to be removed to the end of the list (Polygon([(1, 0), (0, 1), (-1, 0), (0, 0)])), you get a case where just fixing the index line results in a crash: ValueError: A LinearRing must have at least 3 coordinate tuples because the resulting coordinates don’t form a polygon due to a missed angle/point.

To deal with holes, I made polygons out of the shell and hole and simplified them separately. Here’s the code:

shell = Polygon(poly.exterior.coords)
holes = [Polygon(ip.coords) for ip in poly.interiors]
simple_shell = simplify_by_angle(shell)
simple_holes = [simplify_by_angle(hole) for hole in holes]
simple_poly = simple_shell.difference(ops.unary_union(simple_holes))

in case you’re wondering why I use the last line instead of just instantiating the Polygon class, e.g.

simple_poly = Polygon(shell=simple_shell, holes=simple_holes)

It’s because I’ve run across rare cases with a newly simplified shell and holes, and one of the holes is not contained within the new shell. In this case, Polygon simply ignores the hole (a bug in my opinion, should at least raise a warning). Using difference guarantees the holes get cut out regardless.