Skip to content

Commit e5407f3

Browse files
committed
Don't ignore invalid polygon shapes in geo interface
Interpret orphan holes as exteriors. If only interior holes found, assume wrong winding order and return as exteriors.
1 parent ad78236 commit e5407f3

File tree

2 files changed

+61
-17
lines changed

2 files changed

+61
-17
lines changed

shapefile.py

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ def organize_polygon_rings(rings):
281281
if bbox_overlap(hole_bbox, ext_bbox):
282282
hole_exteriors[hole_i].append( ext_i )
283283

284-
# then, for holes with more than one possible exterior, do more detailed hole-in-ring test
284+
# then, for holes with still more than one possible exterior, do more detailed hole-in-ring test
285285
for hole_i,exterior_candidates in hole_exteriors.items():
286286

287287
if len(exterior_candidates) > 1:
@@ -305,37 +305,42 @@ def organize_polygon_rings(rings):
305305
ext_i = sorted(exterior_candidates, key=lambda x: abs(signed_area(exteriors[x])))[0]
306306
hole_exteriors[hole_i] = [ext_i]
307307

308+
# check for holes that are orphaned (not contained by any exterior)
309+
orphan_holes = []
310+
for hole_i,exterior_candidates in list(hole_exteriors.items()):
311+
if not exterior_candidates:
312+
warnings.warn('Shapefile shape has invalid polygon: found orphan hole (not contained by any of the exteriors); interpreting as exterior.')
313+
orphan_holes.append( hole_i )
314+
del hole_exteriors[hole_i]
315+
continue
316+
308317
# each hole should now only belong to one exterior, group into exterior-holes polygons
309318
polys = []
310319
for ext_i,ext in enumerate(exteriors):
311320
poly = [ext]
312321
# find relevant holes
313322
poly_holes = []
314323
for hole_i,exterior_candidates in list(hole_exteriors.items()):
315-
# ignore any hole that is orphaned (not contained by an exterior)
316-
if not exterior_candidates:
317-
warnings.warn('Shapefile shape has invalid polygon: found orphan hole (not contained by any of the exteriors); ignoring.')
318-
del hole_exteriors[hole_i]
319-
continue
320-
# ignore any hole that is ambiguous (more than one possible exteriors)
321-
# this shouldn't happen however, since ambiguous exteriors are resolved
322-
# in the previous stage as the one with the smallest area.
323-
if len(exterior_candidates) > 1:
324-
warnings.warn('Algorithm error: algorithm was unable to resolve hole exterior among multiple possible candidates; ignoring.')
325-
del hole_exteriors[hole_i]
326-
continue
324+
327325
# hole is relevant if previously matched with this exterior
328326
if exterior_candidates[0] == ext_i:
329327
poly_holes.append( holes[hole_i] )
330328
poly += poly_holes
331329
polys.append(poly)
332330

331+
# add orphan holes as exteriors
332+
for hole_i in orphan_holes:
333+
poly = [holes[hole_i]]
334+
polys.append(poly)
335+
333336
return polys
334337

335338
# no exteriors, bug?
336339
else:
337-
raise Exception('Shapefile shape has invalid polygon: no exterior rings found (must have clockwise orientation)')
338-
340+
warnings.warn('Shapefile shape has invalid polygon: no exterior rings found (must have clockwise orientation); interpreting holes as exteriors.')
341+
exteriors = holes # could potentially reverse their order, but in geojson winding order doesn't matter
342+
polys = [[ext] for ext in exteriors]
343+
return polys
339344

340345
class Shape(object):
341346
def __init__(self, shapeType=NULL, points=None, parts=None, partTypes=None):

test_shapefile.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
# our imports
1111
import shapefile
1212

13-
# define valid shape tuples of (type, points, parts indexes, and expected geo interface output)
14-
# test all sorts, incl no holes, hole in singlepoly, hole in multipoly, nested holes and polys
13+
# define various test shape tuples of (type, points, parts indexes, and expected geo interface output)
1514
geo_interface_tests = [ (shapefile.POINT, # point
1615
[(1,1)],
1716
[],
@@ -127,6 +126,46 @@
127126
],
128127
]}
129128
),
129+
(shapefile.POLYGON, # multi polygon, holes incl orphaned holes (unordered), should raise warning
130+
[(1,1),(1,9),(9,9),(9,1),(1,1), # exterior 1
131+
(11,11),(11,19),(19,19),(19,11),(11,11), # exterior 2
132+
(12,12),(14,12),(14,14),(12,14),(12,12), # hole 2.1
133+
(15,15),(17,15),(17,17),(15,17),(15,15), # hole 2.2
134+
(95,95),(97,95),(97,97),(95,97),(95,95), # hole x.1 (orphaned hole, should be interpreted as exterior)
135+
(2,2),(4,2),(4,4),(2,4),(2,2), # hole 1.1
136+
(5,5),(7,5),(7,7),(5,7),(5,5), # hole 1.2
137+
],
138+
[0,5,10,15,20,25,30],
139+
{'type':'MultiPolygon','coordinates':[
140+
[ # poly 1
141+
[(1,1),(1,9),(9,9),(9,1),(1,1)], # exterior
142+
[(2,2),(4,2),(4,4),(2,4),(2,2)], # hole 1
143+
[(5,5),(7,5),(7,7),(5,7),(5,5)], # hole 2
144+
],
145+
[ # poly 2
146+
[(11,11),(11,19),(19,19),(19,11),(11,11)], # exterior
147+
[(12,12),(14,12),(14,14),(12,14),(12,12)], # hole 1
148+
[(15,15),(17,15),(17,17),(15,17),(15,15)], # hole 2
149+
],
150+
[ # poly 3 (orphaned hole)
151+
[(95,95),(97,95),(97,97),(95,97),(95,95)], # exterior
152+
],
153+
]}
154+
),
155+
(shapefile.POLYGON, # multi polygon, exteriors with wrong orientation (be nice and interpret as such)
156+
[(1,1),(9,1),(9,9),(1,9),(1,1), # exterior with hole-orientation
157+
(11,11),(19,11),(19,19),(11,19),(11,11), # exterior with hole-orientation
158+
],
159+
[0,5],
160+
{'type':'MultiPolygon','coordinates':[
161+
[ # poly 1
162+
[(1,1),(9,1),(9,9),(1,9),(1,1)],
163+
],
164+
[ # poly 2
165+
[(11,11),(19,11),(19,19),(11,19),(11,11)],
166+
],
167+
]}
168+
),
130169
]
131170

132171
def test_empty_shape_geo_interface():

0 commit comments

Comments
 (0)