Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add customizable invalid geometry handling #46

Merged
merged 8 commits into from
May 6, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions mapbox_vector_tile/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ def decode(tile, y_coord_down=False):
return message


def encode(layers, quantize_bounds=None, y_coord_down=False, extents=4096):
vector_tile = encoder.VectorTile(extents)
def encode(layers, quantize_bounds=None, y_coord_down=False, extents=4096,
on_invalid_geometry=None):
vector_tile = encoder.VectorTile(extents, on_invalid_geometry)
if (isinstance(layers, list)):
for layer in layers:
vector_tile.addFeatures(layer['features'], layer['name'],
Expand Down
141 changes: 88 additions & 53 deletions mapbox_vector_tile/encoder.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from math import fabs
from past.builtins import long, unicode
from numbers import Number
from past.builtins import long
from past.builtins import unicode
from shapely.geometry.base import BaseGeometry
from shapely.geometry.multipolygon import MultiPolygon
from shapely.geometry.polygon import orient, Polygon
from shapely.geometry.polygon import orient
from shapely.geometry.polygon import Polygon
from shapely.ops import transform
from shapely.wkb import loads as load_wkb
from shapely.wkt import loads as load_wkt
import decimal
import sys

PY3 = sys.version_info[0] == 3
Expand Down Expand Up @@ -33,36 +37,37 @@ def apply_map(fn, x):
CMD_SEG_END = 7


def transform(shape, func):
''' Ported from TileStache'''
def on_invalid_geometry_raise(shape):
raise ValueError('Invalid geometry: %s' % shape.wkt)

construct = shape.__class__

if shape.type.startswith('Multi'):
parts = [transform(geom, func) for geom in shape.geoms]
return construct(parts)
def on_invalid_geometry_ignore(shape):
return None

if shape.type in ('Point', 'LineString'):
return construct(apply_map(func, shape.coords))

if shape.type == 'Polygon':
exterior = apply_map(func, shape.exterior.coords)
rings = [apply_map(func, ring.coords) for ring in shape.interiors]
return construct(exterior, rings)

if shape.type == 'GeometryCollection':
return construct()

raise ValueError('Unknown geometry type, "%s"' % shape.type)
def on_invalid_geometry_make_valid(shape):
if shape.type in ('Polygon', 'MultiPolygon'):
shape = shape.buffer(0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to assert shape.is_valid after this, just to make sure that we are actually making the shape valid. Alternatively, we could chain some of these together, e.g: make_valid.andThen(ignore) or something like that?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added an assertion in 6a1e8e0. A geometry validity monad could be an exercise for the reader 😜

Reading the shapely docs on buffer(0) (search for bowtie), it looks like it may generate a MultiPolygon to make the geometry valid. Could this affect the winding order?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I had to guess, I'd assume that it would preserve the winding order... But it's entirely possible that it doesn't, or resets it to the more usual clockwise. It probably doesn't stick to integer coordinates either. We should probably be taking the output and re-quantizing it, orienting it and re-checking its validity. 🙀

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, it does seem to preserve the winding order. I added a test for that behavior in 4e356b6.

It's also true that the values became floating point after the buffer. We do round them again, but like you hinted at, I suppose it is possible for this to alter the winding order again.

Would we need to re-quantize though? I would assume that we would just need to do the rest of the chain, namely round, ensure the correct winding order, and then handle invalid geometries again. And maybe we try this 5 times or so before dropping the feature?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, by "quantize" I meant "round" - that the buffer(0) process might introduce new points at non-integer coordinates.

I think we might also need to recurse into MULTI* types - it's possible that only one part of a multi-geometry is invalid and we wouldn't want to drop the whole thing. Yikes, this is getting complicated.

assert shape.is_valid, \
'buffer(0) did not make geometry valid: %s' % shape.wkt
return shape


class VectorTile:
"""
"""

def __init__(self, extents):
def __init__(self, extents, on_invalid_geometry=None,
max_geometry_validate_tries=5):
self.tile = vector_tile.tile()
self.extents = extents
self.on_invalid_geometry = on_invalid_geometry
self.max_geometry_validate_tries = max_geometry_validate_tries

def _round(self, val):
d = decimal.Decimal(val)
rounded = d.quantize(1, rounding=decimal.ROUND_HALF_EVEN)
return float(rounded)

def addFeatures(self, features, layer_name='',
quantize_bounds=None, y_coord_down=False):
Expand Down Expand Up @@ -96,68 +101,98 @@ def addFeatures(self, features, layer_name='',
if quantize_bounds:
shape = self.quantize(shape, quantize_bounds)

if shape.type == 'MultiPolygon':
# If we are a multipolygon, we need to ensure that the
# winding orders of the consituent polygons are
# correct. In particular, the winding order of the
# interior rings need to be the opposite of the
# exterior ones, and all interior rings need to follow
# the exterior one. This is how the end of one polygon
# and the beginning of another are signaled.
shape = self.enforce_multipolygon_winding_order(shape)

elif shape.type == 'Polygon':
# Ensure that polygons are also oriented with the
# appropriate winding order. Their exterior rings must
# have a clockwise order, which is translated into a
# clockwise order in MVT's tile-local coordinates with
# the Y axis in "screen" (i.e: +ve down) configuration.
# Note that while the Y axis flips, we also invert the
# Y coordinate to get the tile-local value, which means
# the clockwise orientation is unchanged.
shape = self.enforce_polygon_winding_order(shape)

self.addFeature(feature, shape, y_coord_down)
shape = self.enforce_winding_order(shape)

if shape is not None and not shape.is_empty:
self.addFeature(feature, shape, y_coord_down)

def enforce_winding_order(self, shape, n_try=1):
if shape.type == 'MultiPolygon':
# If we are a multipolygon, we need to ensure that the
# winding orders of the consituent polygons are
# correct. In particular, the winding order of the
# interior rings need to be the opposite of the
# exterior ones, and all interior rings need to follow
# the exterior one. This is how the end of one polygon
# and the beginning of another are signaled.
shape = self.enforce_multipolygon_winding_order(shape, n_try)

elif shape.type == 'Polygon':
# Ensure that polygons are also oriented with the
# appropriate winding order. Their exterior rings must
# have a clockwise order, which is translated into a
# clockwise order in MVT's tile-local coordinates with
# the Y axis in "screen" (i.e: +ve down) configuration.
# Note that while the Y axis flips, we also invert the
# Y coordinate to get the tile-local value, which means
# the clockwise orientation is unchanged.
shape = self.enforce_polygon_winding_order(shape, n_try)

# other shapes just get passed through
return shape

def quantize(self, shape, bounds):
minx, miny, maxx, maxy = bounds

def fn(point):
x, y = point
def fn(x, y, z=None):
xfac = self.extents / (maxx - minx)
yfac = self.extents / (maxy - miny)
x = xfac * (x - minx)
y = yfac * (y - miny)
return round(x), round(y)
return self._round(x), self._round(y)

return transform(fn, shape)

def handle_shape_validity(self, shape, n_try):
if shape.is_valid:
return shape

return transform(shape, fn)
if n_try >= self.max_geometry_validate_tries:
# ensure that we don't recurse indefinitely with an
# invalid geometry handler that doesn't validate
# geometries
return None

def enforce_multipolygon_winding_order(self, shape):
if self.on_invalid_geometry:
shape = self.on_invalid_geometry(shape)
if shape is not None and not shape.is_empty:
# this means that we have a handler that might have
# altered the geometry. We'll run through the process
# again, but keep track of which attempt we are on to
# terminate the recursion.
shape = self.enforce_winding_order(shape, n_try + 1)

return shape

def enforce_multipolygon_winding_order(self, shape, n_try):
assert shape.type == 'MultiPolygon'

parts = []
for part in shape.geoms:
# see comment in shape.type == 'Polygon' above about why
# the sign here has to be -1.
part = self.enforce_polygon_winding_order(part)
part = self.enforce_polygon_winding_order(part, n_try)
parts.append(part)
oriented_shape = MultiPolygon(parts)
oriented_shape = self.handle_shape_validity(oriented_shape, n_try)
return oriented_shape

def enforce_polygon_winding_order(self, shape):
def enforce_polygon_winding_order(self, shape, n_try):
assert shape.type == 'Polygon'

def fn(point):
x, y = point
return round(x), round(y)
return self._round(x), self._round(y)

exterior = apply_map(fn, shape.exterior.coords)
rings = None

if len(shape.interiors) > 0:
rings = [apply_map(fn, ring.coords) for ring in shape.interiors]

return orient(Polygon(exterior, rings), sign=-1.0)
oriented_shape = orient(Polygon(exterior, rings), sign=-1.0)
oriented_shape = self.handle_shape_validity(oriented_shape, n_try)
return oriented_shape

def _load_geometry(self, geometry_spec):
if isinstance(geometry_spec, BaseGeometry):
Expand Down Expand Up @@ -374,9 +409,9 @@ def _geo_encode(self, f, shape, y_coord_down):

# ensure that floating point values don't get truncated
if isinstance(x, float):
x = round(x)
x = self._round(x)
if isinstance(y, float):
y = round(y)
y = self._round(y)

x = int(x)
y = int(y)
Expand Down
88 changes: 88 additions & 0 deletions tests/test_encoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -334,3 +334,91 @@ def test_custom_extent(self):
act_geom = act_feature['geometry']
exp_geom = [[50, 50]]
self.assertEqual(exp_geom, act_geom)


class InvalidGeometryTest(unittest.TestCase):

def test_invalid_geometry_ignore(self):
from mapbox_vector_tile import encode
from mapbox_vector_tile.encoder import on_invalid_geometry_ignore
import shapely.wkt
geometry = 'POLYGON ((10 10, 20 10, 20 20, 15 15, 15 5, 10 10))'
shape = shapely.wkt.loads(geometry)
self.assertFalse(shape.is_valid)
feature = dict(geometry=shape, properties={})
source = dict(name='layername', features=[feature])
pbf = encode(source, on_invalid_geometry=on_invalid_geometry_ignore)
result = decode(pbf)
self.assertEqual(0, len(result['layername']['features']))

def test_invalid_geometry_raise(self):
from mapbox_vector_tile import encode
from mapbox_vector_tile.encoder import on_invalid_geometry_raise
import shapely.wkt
geometry = 'POLYGON ((10 10, 20 10, 20 20, 15 15, 15 5, 10 10))'
shape = shapely.wkt.loads(geometry)
self.assertFalse(shape.is_valid)
feature = dict(geometry=shape, properties={})
source = dict(name='layername', features=[feature])
with self.assertRaises(Exception):
encode(source, on_invalid_geometry=on_invalid_geometry_raise)

def test_invalid_geometry_make_valid(self):
from mapbox_vector_tile import encode
from mapbox_vector_tile.encoder import on_invalid_geometry_make_valid
import shapely.geometry
import shapely.wkt
geometry = 'POLYGON ((10 10, 20 10, 20 20, 15 15, 15 5, 10 10))'
shape = shapely.wkt.loads(geometry)
self.assertFalse(shape.is_valid)
feature = dict(geometry=shape, properties={})
source = dict(name='layername', features=[feature])
pbf = encode(source,
on_invalid_geometry=on_invalid_geometry_make_valid)
result = decode(pbf)
self.assertEqual(1, len(result['layername']['features']))
valid_geometry = result['layername']['features'][0]['geometry']
shape = shapely.geometry.Polygon(valid_geometry[0])
self.assertTrue(shape.is_valid)

def test_bowtie(self):
from mapbox_vector_tile import encode
from mapbox_vector_tile.encoder import on_invalid_geometry_make_valid
import shapely.geometry
import shapely.wkt
bowtie = ('POLYGON ((0 0, 0 2, 1 1, 2 2, 2 0, 1 1, 0 0))')
shape = shapely.wkt.loads(bowtie)
self.assertFalse(shape.is_valid)
feature = dict(geometry=shape, properties={})
source = dict(name='layername', features=[feature])
pbf = encode(source,
on_invalid_geometry=on_invalid_geometry_make_valid)
result = decode(pbf)
self.assertEqual(1, len(result['layername']['features']))
valid_geometries = result['layername']['features'][0]['geometry']
self.assertEqual(2, len(valid_geometries))
shape1, shape2 = [shapely.geometry.Polygon(x[0])
for x in valid_geometries]
self.assertTrue(shape1.is_valid)
self.assertTrue(shape2.is_valid)
self.assertGreater(shape1.area, 0)
self.assertGreater(shape2.area, 0)

def test_validate_generates_rounding_error(self):
from mapbox_vector_tile import encode
from mapbox_vector_tile.encoder import on_invalid_geometry_make_valid
import shapely.geometry
import shapely.wkt
bowtie = ('POLYGON((0 0, 1 1, 0 1, 1 0, 0 0))')
shape = shapely.wkt.loads(bowtie)
self.assertFalse(shape.is_valid)
feature = dict(geometry=shape, properties={})
source = dict(name='layername', features=[feature])
pbf = encode(source,
on_invalid_geometry=on_invalid_geometry_make_valid)
result = decode(pbf)
features = result['layername']['features']
self.assertEqual(1, len(features))
shape = shapely.geometry.Polygon(features[0]['geometry'][0])
self.assertTrue(shape.is_valid)
self.assertGreater(shape.area, 0)