Skip to content

Commit 2d207e2

Browse files
committed
Switch to much faster hole in exterior tests during geo interface
Previously did point in exterior test for every hole vertex, but got very slow for complex real-world multi polygons. Instead, efficiently calculate a point guaranteed to be inside the hole, and then test only that one hole sample point. Additionally, limit exterior candidates even further as only those whose bbox fully contains the hole bbox (ie no point in testing nested exteriors inside the hole itself) Add tests for exterior-hole edge cases
1 parent a8b3b4a commit 2d207e2

15 files changed

+76
-7
lines changed

shapefile.py

+53-6
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,14 @@ def bbox_overlap(bbox1, bbox2):
182182
overlap = (xmin1 <= xmax2 and xmax1 >= xmin2 and ymin1 <= ymax2 and ymax1 >= ymin2)
183183
return overlap
184184

185+
def bbox_contains(bbox1, bbox2):
186+
"""Tests whether bbox1 fully contains bbox2, returning a boolean
187+
"""
188+
xmin1,ymin1,xmax1,ymax1 = bbox1
189+
xmin2,ymin2,xmax2,ymax2 = bbox2
190+
contains = (xmin1 < xmin2 and xmax1 > xmax2 and ymin1 < ymin2 and ymax1 > ymax2)
191+
return contains
192+
185193
def ring_contains_point(coords, p):
186194
"""Fast point-in-polygon crossings algorithm, MacMartin optimization.
187195
@@ -224,6 +232,44 @@ def ring_contains_point(coords, p):
224232

225233
return inside_flag
226234

235+
def ring_sample(coords, ccw=False):
236+
"""Return a sample point guaranteed to be within a ring, by efficiently
237+
finding the first centroid of a coordinate triplet whose orientation
238+
matches the orientation of the ring and passes the point-in-ring test.
239+
The orientation of the ring is assumed to be clockwise, unless ccw
240+
(counter-clockwise) is set to True.
241+
"""
242+
coords = tuple(coords) + (coords[1],) # add the second coordinate to the end to allow checking the last triplet
243+
triplet = []
244+
for p in coords:
245+
# add point to triplet (but not if duplicate)
246+
if p not in triplet:
247+
triplet.append(p)
248+
249+
# new triplet, try to get sample
250+
if len(triplet) == 3:
251+
# check that triplet does not form a straight line (not a triangle)
252+
is_straight_line = (triplet[0][1] - triplet[1][1]) * (triplet[0][0] - triplet[2][0]) == (triplet[0][1] - triplet[2][1]) * (triplet[0][0] - triplet[1][0])
253+
if not is_straight_line:
254+
# get triplet orientation
255+
closed_triplet = triplet + [triplet[0]]
256+
triplet_ccw = signed_area(closed_triplet) >= 0
257+
# check that triplet has the same orientation as the ring (means triangle is inside the ring)
258+
if ccw == triplet_ccw:
259+
# get triplet centroid
260+
xs,ys = zip(*triplet)
261+
xmean,ymean = sum(xs) / 3.0, sum(ys) / 3.0
262+
# check that triplet centroid is truly inside the ring
263+
if ring_contains_point(coords, (xmean,ymean)):
264+
return xmean,ymean
265+
266+
# failed to get sample point from this triplet
267+
# remove oldest triplet coord to allow iterating to next triplet
268+
triplet.pop(0)
269+
270+
else:
271+
raise Exception('Unexpected error: Unable to find a ring sample point.')
272+
227273
def ring_contains_ring(coords1, coords2):
228274
'''Returns True if all vertexes in coords2 are fully inside coords1.
229275
'''
@@ -272,25 +318,26 @@ def organize_polygon_rings(rings):
272318
polys.append(poly)
273319
return polys
274320

275-
# first determine each hole's candidate exteriors based on simple bbox overlap test
321+
# first determine each hole's candidate exteriors based on simple bbox contains test
276322
hole_exteriors = dict([(hole_i,[]) for hole_i in xrange(len(holes))])
277323
exterior_bboxes = [ring_bbox(ring) for ring in exteriors]
278324
for hole_i in hole_exteriors.keys():
279325
hole_bbox = ring_bbox(holes[hole_i])
280326
for ext_i,ext_bbox in enumerate(exterior_bboxes):
281-
if bbox_overlap(hole_bbox, ext_bbox):
327+
if bbox_contains(ext_bbox, hole_bbox):
282328
hole_exteriors[hole_i].append( ext_i )
283329

284330
# then, for holes with still more than one possible exterior, do more detailed hole-in-ring test
285331
for hole_i,exterior_candidates in hole_exteriors.items():
286332

287333
if len(exterior_candidates) > 1:
288-
# get new exterior candidates
289-
hole = holes[hole_i]
334+
# get hole sample point
335+
hole_sample = ring_sample(holes[hole_i], ccw=True)
336+
# collect new exterior candidates
290337
new_exterior_candidates = []
291338
for ext_i in exterior_candidates:
292-
ext = exteriors[ext_i]
293-
hole_in_exterior = ring_contains_ring(ext, hole)
339+
# check that hole sample point is inside exterior
340+
hole_in_exterior = ring_contains_point(exteriors[ext_i], hole_sample)
294341
if hole_in_exterior:
295342
new_exterior_candidates.append(ext_i)
296343

shapefiles/test/balancing.dbf

0 Bytes
Binary file not shown.

shapefiles/test/contextwriter.dbf

0 Bytes
Binary file not shown.

shapefiles/test/dtype.dbf

0 Bytes
Binary file not shown.

shapefiles/test/line.dbf

0 Bytes
Binary file not shown.

shapefiles/test/linem.dbf

0 Bytes
Binary file not shown.

shapefiles/test/linez.dbf

0 Bytes
Binary file not shown.

shapefiles/test/multipatch.dbf

0 Bytes
Binary file not shown.

shapefiles/test/multipoint.dbf

0 Bytes
Binary file not shown.

shapefiles/test/onlydbf.dbf

0 Bytes
Binary file not shown.

shapefiles/test/point.dbf

0 Bytes
Binary file not shown.

shapefiles/test/polygon.dbf

0 Bytes
Binary file not shown.

shapefiles/test/shapetype.dbf

0 Bytes
Binary file not shown.

shapefiles/test/testfile.dbf

0 Bytes
Binary file not shown.

test_shapefile.py

+23-1
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,28 @@
126126
],
127127
]}
128128
),
129+
(shapefile.POLYGON, # multi polygon, nested exteriors with holes (unordered and tricky holes designed to throw off ring_sample() test)
130+
[(1,1),(1,9),(9,9),(9,1),(1,1), # exterior 1
131+
(3,3),(3,7),(7,7),(7,3),(3,3), # exterior 2
132+
(4.5,4.5),(4.5,5.5),(5.5,5.5),(5.5,4.5),(4.5,4.5), # exterior 3
133+
(4,4),(4,4),(6,4),(6,4),(6,4),(6,6),(4,6),(4,4), # hole 2.1 (hole has duplicate coords)
134+
(2,2),(3,3),(4,2),(8,2),(8,8),(4,8),(2,8),(2,4),(2,2), # hole 1.1 (hole coords form straight line and starts in concave orientation)
135+
],
136+
[0,5,10,15,20+3],
137+
{'type':'MultiPolygon','coordinates':[
138+
[ # poly 1
139+
[(1,1),(1,9),(9,9),(9,1),(1,1)], # exterior 1
140+
[(2,2),(3,3),(4,2),(8,2),(8,8),(4,8),(2,8),(2,4),(2,2)], # hole 1.1
141+
],
142+
[ # poly 2
143+
[(3,3),(3,7),(7,7),(7,3),(3,3)], # exterior 2
144+
[(4,4),(4,4),(6,4),(6,4),(6,4),(6,6),(4,6),(4,4)], # hole 2.1
145+
],
146+
[ # poly 3
147+
[(4.5,4.5),(4.5,5.5),(5.5,5.5),(5.5,4.5),(4.5,4.5)], # exterior 3
148+
],
149+
]}
150+
),
129151
(shapefile.POLYGON, # multi polygon, holes incl orphaned holes (unordered), should raise warning
130152
[(1,1),(1,9),(9,9),(9,1),(1,1), # exterior 1
131153
(11,11),(11,19),(19,19),(19,11),(11,11), # exterior 2
@@ -152,7 +174,7 @@
152174
],
153175
]}
154176
),
155-
(shapefile.POLYGON, # multi polygon, exteriors with wrong orientation (be nice and interpret as such)
177+
(shapefile.POLYGON, # multi polygon, exteriors with wrong orientation (be nice and interpret as such), should raise warning
156178
[(1,1),(9,1),(9,9),(1,9),(1,1), # exterior with hole-orientation
157179
(11,11),(19,11),(19,19),(11,19),(11,11), # exterior with hole-orientation
158180
],

0 commit comments

Comments
 (0)