Store simplifed polygons in the SQLite database

This commit does two things:
- uses our new polygon-simplifying library to process the polygons
  before storing them, rather than processing them in real time
- stores only the polygons in the database, rather than the whole
  GeoJSON feature, because we don’t need any of the other information
  about the feature
This commit is contained in:
Chris Hill-Scott
2020-08-24 14:43:28 +01:00
parent 3470c5bb31
commit c49a6338af
8 changed files with 164 additions and 164 deletions

View File

@@ -1,6 +1,5 @@
import itertools import itertools
import geojson
from notifications_utils.serialised_model import SerialisedModelCollection from notifications_utils.serialised_model import SerialisedModelCollection
from werkzeug.utils import cached_property from werkzeug.utils import cached_property
@@ -34,59 +33,13 @@ class BroadcastArea(SortableMixin):
def __eq__(self, other): def __eq__(self, other):
return self.id == other.id return self.id == other.id
@property @cached_property
def _feature(self):
return BroadcastAreasRepository().get_feature_for_area(self.id)
@property
def _simple_feature(self):
return BroadcastAreasRepository().get_simple_feature_for_area(self.id)
def _polygons(self, feature):
if feature['geometry']['type'] == 'MultiPolygon':
return [
polygons[0]
for polygons in feature['geometry']['coordinates']
]
if feature['geometry']['type'] == 'Polygon':
return [
feature['geometry']['coordinates'][0]
]
raise TypeError(
f'Unknown geometry type {self.feature["geometry"]["type"]} '
f'in {self.__class__.__name} {self.name}'
)
def _unenclosed_polygons(self, feature):
# Some mapping tools require shapes to be unenclosed, i.e. the
# last point joins the first point implicitly
return [
coordinates[:-1] for coordinates in self._polygons(feature)
]
@property
def polygons(self): def polygons(self):
return self._polygons(self.feature) return BroadcastAreasRepository().get_polygons_for_area(self.id)
@property @cached_property
def unenclosed_polygons(self):
return self._unenclosed_polygons(self.feature)
@property
def simple_polygons(self): def simple_polygons(self):
return self._polygons(self.simple_feature) return BroadcastAreasRepository().get_simple_polygons_for_area(self.id)
@property
def simple_unenclosed_polygons(self):
return self._unenclosed_polygons(self.simple_feature)
@cached_property
def feature(self):
return geojson.loads(self._feature)
@cached_property
def simple_feature(self):
return geojson.loads(self._simple_feature)
@property @property
def sub_areas(self): def sub_areas(self):

View File

@@ -1,68 +1,51 @@
#!/usr/bin/env python #!/usr/bin/env python
from copy import deepcopy
from pathlib import Path from pathlib import Path
import geojson import geojson
import shapely.geometry as sgeom from notifications_utils.formatters import formatted_list
from polygons import Polygons
from repo import BroadcastAreasRepository from repo import BroadcastAreasRepository
package_path = Path(__file__).resolve().parent package_path = Path(__file__).resolve().parent
point_counts = []
def convert_shape_to_feature(shape):
return {
"type": "Feature",
"properties": {},
"geometry": sgeom.mapping(shape),
}
def simplify_polygon(series):
polygon, *_holes = series # discard holes
approx_metres_to_degree = 111320
# initially buffer (extend area past perimeter) by ~25m
buffer_degrees = 500 / approx_metres_to_degree
# initially simplify (snap to closest point) by ~25m
simplify_degrees = 50.0 / approx_metres_to_degree
starting_polygon = sgeom.LineString(polygon).buffer(buffer_degrees)
simplified_polygon = None
num_polys = len(polygon)
last_num_polys = []
while True:
simplified_polygon = starting_polygon.simplify(simplify_degrees)
simplified_polygon = [[c[0], c[1]] for c in simplified_polygon.exterior.coords]
num_polys = len(simplified_polygon)
simplify_degrees *= 2
if num_polys <= 99 or last_num_polys[-3:] == [num_polys, num_polys, num_polys]:
break
last_num_polys.append(num_polys)
print(".", end="", flush=True) # noqa: T001
return [simplified_polygon]
def simplify_geometry(feature): def simplify_geometry(feature):
if feature["type"] == "Polygon": if feature["type"] == "Polygon":
feature["coordinates"] = simplify_polygon(feature["coordinates"]) return [feature["coordinates"][0]]
return feature
elif feature["type"] == "MultiPolygon": elif feature["type"] == "MultiPolygon":
feature["coordinates"] = [ return [polygon for polygon, *_holes in feature["coordinates"]]
simplify_polygon(polygon)
for polygon in feature["coordinates"]
]
return feature
else: else:
raise Exception("Unknown type: {}".format(feature["type"])) raise Exception("Unknown type: {}".format(feature["type"]))
def polygons_and_simplified_polygons(feature):
polygons = Polygons(simplify_geometry(feature))
full_resolution = polygons.remove_too_small
smoothed = full_resolution.smooth
simplified = smoothed.simplify
print( # noqa: T001
f' Original:{full_resolution.point_count: >5} points'
f' Smoothed:{smoothed.point_count: >5} points'
f' Simplified:{simplified.point_count: >4} points'
)
point_counts.append(simplified.point_count)
if simplified.point_count >= 200:
raise RuntimeError(
'Too many points '
'(adjust Polygons.perimeter_to_simplification_ratio or '
'Polygons.perimeter_to_buffer_ratio)'
)
return full_resolution.as_coordinate_pairs, simplified.as_coordinate_pairs
repo = BroadcastAreasRepository() repo = BroadcastAreasRepository()
repo.delete_db() repo.delete_db()
@@ -88,10 +71,12 @@ for dataset_name, dataset_name_singular, id_field, name_field in simple_datasets
f_id = dataset_id + "-" + feature["properties"][id_field] f_id = dataset_id + "-" + feature["properties"][id_field]
f_name = feature["properties"][name_field] f_name = feature["properties"][name_field]
print() # noqa: T001
print(f_name) # noqa: T001 print(f_name) # noqa: T001
simple_feature = deepcopy(feature) feature, simple_feature = (
simple_feature["geometry"] = simplify_geometry(simple_feature["geometry"]) polygons_and_simplified_polygons(feature["geometry"])
)
repo.insert_broadcast_areas([[ repo.insert_broadcast_areas([[
f_id, f_name, f_id, f_name,
@@ -132,19 +117,21 @@ for f in geojson.loads(wards_filepath.read_text())["features"]:
ward_name = f["properties"]["wd20nm"] ward_name = f["properties"]["wd20nm"]
ward_id = "wd20-" + ward_code ward_id = "wd20-" + ward_code
print() # noqa: T001
print(ward_name) # noqa: T001 print(ward_name) # noqa: T001
try: try:
la_id = "lad20-" + ward_code_to_la_id_mapping[ward_code] la_id = "lad20-" + ward_code_to_la_id_mapping[ward_code]
la_name = ward_code_to_la_mapping[ward_code] la_name = ward_code_to_la_mapping[ward_code]
sf = deepcopy(f) feature, simple_feature = (
sf["geometry"] = simplify_geometry(sf["geometry"]) polygons_and_simplified_polygons(f["geometry"])
)
areas_to_add.append([ areas_to_add.append([
ward_id, ward_name, ward_id, ward_name,
dataset_id, la_id, dataset_id, la_id,
f, sf feature, simple_feature
]) ])
except KeyError: except KeyError:
@@ -159,12 +146,14 @@ for feature in geojson.loads(las_filepath.read_text())["features"]:
la_id = feature["properties"]["lad20cd"] la_id = feature["properties"]["lad20cd"]
group_name = feature["properties"]["lad20nm"] group_name = feature["properties"]["lad20nm"]
print() # noqa: T001
print(group_name) # noqa: T001 print(group_name) # noqa: T001
group_id = "lad20-" + la_id group_id = "lad20-" + la_id
simple_feature = deepcopy(feature) feature, simple_feature = (
simple_feature["geometry"] = simplify_geometry(simple_feature["geometry"]) polygons_and_simplified_polygons(feature["geometry"])
)
areas_to_add.append([ areas_to_add.append([
group_id, group_name, group_id, group_name,
@@ -173,3 +162,16 @@ for feature in geojson.loads(las_filepath.read_text())["features"]:
]) ])
repo.insert_broadcast_areas(areas_to_add) repo.insert_broadcast_areas(areas_to_add)
most_detailed_polygons = formatted_list(
sorted(point_counts, reverse=True)[:5],
before_each='',
after_each='',
)
print( # noqa: T001
'\n'
'DONE\n'
f' Processed {len(point_counts):,} polygons.\n'
f' Highest point counts once simplifed: {most_detailed_polygons}\n'
)

View File

@@ -1,3 +1,5 @@
import itertools
from shapely.geometry import ( from shapely.geometry import (
JOIN_STYLE, JOIN_STYLE,
GeometryCollection, GeometryCollection,
@@ -11,31 +13,32 @@ from werkzeug.utils import cached_property
class Polygons(): class Polygons():
approx_metres_to_degree = 111_320 approx_metres_to_degree = 111_320
approx_square_metres_to_square_degree = approx_metres_to_degree ** 2
# Estimated amount of bleed into neigbouring areas based on typical # Estimated amount of bleed into neigbouring areas based on typical
# range/separation of cell towers. # range/separation of cell towers.
approx_bleed_in_degrees = 1_500 / approx_metres_to_degree approx_bleed_in_degrees = 1_500 / approx_metres_to_degree
# Ratio of how much to buffer for a shape of a given perimeter. For # Controls how much buffer to add for a shape of a given perimeter.
# example `500` means 1m of buffer for every 500m of perimeter, or # Smaller number means more buffering and a smoother shape. For
# 40m of buffer for a 5km square. This gives us control over how # example `1000` means 1m of buffer for every 1km of perimeter, or
# 20m of buffer for a 5km square. This gives us control over how
# much we fill in very concave features like channels, harbours and # much we fill in very concave features like channels, harbours and
# zawns. # zawns.
perimeter_to_buffer_ratio = 500 perimeter_to_buffer_ratio = 360
# Ratio of how much detail a shape of a given perimeter has once # Ratio of how much detail a shape of a given perimeter has once
# simplified. Smaller number means more less detail. For example # simplified. Smaller number means less detail. For example `1000`
# `700` means that for a shape with a perimeter of 700m, the # means that for a shape with a perimeter of 1000m, the simplified
# simplified line will never deviate more than 1m from the original. # line will never deviate more than 1m from the original.
# Or for a 5km square, the line wont deviate more than 17m. This # Or for a 5km square, the line wont deviate more than 20m. This
# gives us approximate control over the total number of points. # gives us approximate control over the total number of points.
perimeter_to_simplification_ratio = 700 perimeter_to_simplification_ratio = 1_750
# The absolute smallest deviation (in metres) from the original we # The threshold for removing very small areas from the map. These
# allow no matter how big/small the shape is. Allows us to still # areas are likely glitches in the data where the shoreline hasnt
# remove a bit of detail even for small shapes, for example urban # been subtracted from the land properly
# electoral wards. minimum_area_size_square_metres = 50 ** 2
max_resolution = 5 / approx_metres_to_degree
def __init__(self, polygons): def __init__(self, polygons):
if not polygons: if not polygons:
@@ -56,30 +59,36 @@ class Polygons():
polygon.length for polygon in self polygon.length for polygon in self
) )
@property @cached_property
def buffer_outward_in_degrees(self): def buffer_outward_in_degrees(self):
return self.perimeter_length / self.perimeter_to_buffer_ratio return (
# If two areas are close enough that the distance between
@property # them is less than the minimum bleed of a cell
def buffer_inward_in_degrees(self): # broadcast then this joins them together. The aim is to
return self.buffer_outward_in_degrees - ( # reduce the total number of polygons in areas with many
# We dont want to buffer all the way back in because there # small shapes like Orkney or the Isles of Scilly.
# needs to be a bit off wiggle room for simplifying the self.approx_bleed_in_degrees / 3
# polygon. Theoretically we need ) + (
# `self.simplification_tolerance_in_degrees` wiggle room, self.perimeter_length / self.perimeter_to_buffer_ratio
# but in practice some fraction of it is enough.
self.simplification_tolerance_in_degrees * 2 / 3
) )
@property
def simplification_tolerance_in_degrees(self):
shape_size_adjusted_resolution = (
self.perimeter_length / self.perimeter_to_simplification_ratio
)
return self.max_resolution + shape_size_adjusted_resolution
@cached_property @cached_property
def buffer_and_debuffer(self): def buffer_inward_in_degrees(self):
return self.buffer_outward_in_degrees - (
# We should leave the shape expanded by at least the
# simplification tolerance in all places, so the
# simplification never moves a point inside the original
# shape. In practice half of the tolerance is enough to
# acheive this.
self.simplification_tolerance_in_degrees / 2
)
@cached_property
def simplification_tolerance_in_degrees(self):
return self.perimeter_length / self.perimeter_to_simplification_ratio
@cached_property
def smooth(self):
buffered = [ buffered = [
polygon.buffer( polygon.buffer(
self.buffer_outward_in_degrees, self.buffer_outward_in_degrees,
@@ -89,7 +98,7 @@ class Polygons():
for polygon in self for polygon in self
] ]
unioned = union_polygons(buffered) unioned = union_polygons(buffered)
polygons_debuffered = [ debuffered = [
polygon.buffer( polygon.buffer(
-1 * self.buffer_inward_in_degrees, -1 * self.buffer_inward_in_degrees,
resolution=1, resolution=1,
@@ -97,7 +106,10 @@ class Polygons():
) )
for polygon in unioned for polygon in unioned
] ]
return Polygons(polygons_debuffered) flattened = list(itertools.chain(*[
flatten_polygons(polygon) for polygon in debuffered
]))
return Polygons(flattened)
@cached_property @cached_property
def simplify(self): def simplify(self):
@@ -117,7 +129,18 @@ class Polygons():
for polygon in self for polygon in self
])) ]))
@property @cached_property
def remove_too_small(self):
return Polygons([
polygon for polygon in self
if (
polygon.area * self.approx_square_metres_to_square_degree
) > (
self.minimum_area_size_square_metres
)
])
@cached_property
def as_coordinate_pairs(self): def as_coordinate_pairs(self):
return [ return [
[ [
@@ -126,6 +149,18 @@ class Polygons():
for p in self for p in self
] ]
@cached_property
def as_unenclosed_coordinate_pairs(self):
# Some mapping tools require shapes to be unenclosed, i.e. the
# last point joins the first point implicitly
return [
coordinates[:-1] for coordinates in self.as_coordinate_pairs
]
@cached_property
def point_count(self):
return len(list(itertools.chain(*self.as_coordinate_pairs)))
def flatten_polygons(polygons): def flatten_polygons(polygons):
if isinstance(polygons, GeometryCollection): if isinstance(polygons, GeometryCollection):

View File

@@ -1,9 +1,8 @@
import json
import os import os
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
import geojson
class BroadcastAreasRepository(object): class BroadcastAreasRepository(object):
def __init__(self): def __init__(self):
@@ -47,10 +46,10 @@ class BroadcastAreasRepository(object):
)""") )""")
conn.execute(""" conn.execute("""
CREATE TABLE broadcast_area_features ( CREATE TABLE broadcast_area_polygons (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
feature_geojson TEXT NOT NULL, polygons TEXT NOT NULL,
simple_feature_geojson TEXT NOT NULL simple_polygons TEXT NOT NULL
)""") )""")
conn.execute(""" conn.execute("""
@@ -84,20 +83,20 @@ class BroadcastAreasRepository(object):
""" """
features_q = """ features_q = """
INSERT INTO broadcast_area_features ( INSERT INTO broadcast_area_polygons (
id, id,
feature_geojson, simple_feature_geojson polygons, simple_polygons
) )
VALUES (?, ?, ?) VALUES (?, ?, ?)
""" """
with self.conn() as conn: with self.conn() as conn:
for id, name, area_id, group, feature, simple_feature in areas: for id, name, area_id, group, polygons, simple_polygons in areas:
conn.execute(areas_q, ( conn.execute(areas_q, (
id, name, area_id, group, id, name, area_id, group,
)) ))
conn.execute(features_q, ( conn.execute(features_q, (
id, geojson.dumps(feature), geojson.dumps(simple_feature), id, json.dumps(polygons), json.dumps(simple_polygons),
)) ))
def query(self, sql, *args): def query(self, sql, *args):
@@ -206,24 +205,24 @@ class BroadcastAreasRepository(object):
return areas return areas
def get_feature_for_area(self, area_id): def get_polygons_for_area(self, area_id):
q = """ q = """
SELECT feature_geojson SELECT polygons
FROM broadcast_area_features FROM broadcast_area_polygons
WHERE id = ? WHERE id = ?
""" """
results = self.query(q, area_id) results = self.query(q, area_id)
return results[0][0] return json.loads(results[0][0])
def get_simple_feature_for_area(self, area_id): def get_simple_polygons_for_area(self, area_id):
q = """ q = """
SELECT simple_feature_geojson SELECT simple_polygons
FROM broadcast_area_features FROM broadcast_area_polygons
WHERE id = ? WHERE id = ?
""" """
results = self.query(q, area_id) results = self.query(q, area_id)
return results[0][0] return json.loads(results[0][0])

View File

@@ -79,6 +79,17 @@ class BroadcastMessage(JSONModel):
) )
) )
@cached_property
def simple_polygons(self):
polygons = Polygons(
broadcast_area_libraries.get_simple_polygons_for_areas_lat_long(
*self._dict['areas']
)
)
# If weve added multiple areas then we need to re-simplify the
# combined shapes to keep the point count down
return polygons.smooth.simplify if len(self.areas) > 1 else polygons
@property @property
def template(self): def template(self):
response = service_api_client.get_service_template( response = service_api_client.get_service_template(

View File

@@ -36,7 +36,7 @@
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors' attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(mymap); }).addTo(mymap);
{% for polygon in broadcast_message.polygons.buffer_and_debuffer.simplify.bleed.as_coordinate_pairs %} {% for polygon in broadcast_message.simple_polygons.bleed.as_coordinate_pairs %}
polygons.push( polygons.push(
L.polygon({{polygon}}, { L.polygon({{polygon}}, {
opacity: 0.5, opacity: 0.5,
@@ -48,7 +48,7 @@
); );
{% endfor %} {% endfor %}
{% for polygon in broadcast_message.polygons.buffer_and_debuffer.simplify.as_coordinate_pairs %} {% for polygon in broadcast_message.simple_polygons.as_coordinate_pairs %}
polygons.push( polygons.push(
L.polygon({{polygon}}, { L.polygon({{polygon}}, {
opacity: 0.1, opacity: 0.1,

View File

@@ -117,13 +117,13 @@ def test_has_polygons():
def test_polygons_are_enclosed_unless_asked_not_to_be(): def test_polygons_are_enclosed_unless_asked_not_to_be():
england = broadcast_area_libraries.get('ctry19').get('ctry19-E92000001') england = broadcast_area_libraries.get('ctry19').get('ctry19-E92000001')
assert len(england.polygons) == len(england.unenclosed_polygons) assert len(england.polygons) == len(england.polygons.as_unenclosed_coordinate_pairs)
first_polygon = england.polygons[0] first_polygon = england.polygons[0].as_coordinate_pairs
assert first_polygon[0] != first_polygon[1] != first_polygon[2] assert first_polygon[0] != first_polygon[1] != first_polygon[2]
assert first_polygon[0] == first_polygon[-1] assert first_polygon[0] == first_polygon[-1]
first_polygon_unenclosed = england.unenclosed_polygons[0] first_polygon_unenclosed = england.polygons[0].as_unenclosed_coordinate_pairs
assert first_polygon_unenclosed[0] == first_polygon[0] assert first_polygon_unenclosed[0] == first_polygon[0]
assert first_polygon_unenclosed[-1] != first_polygon[-1] assert first_polygon_unenclosed[-1] != first_polygon[-1]
assert first_polygon_unenclosed[-1] == first_polygon[-2] assert first_polygon_unenclosed[-1] == first_polygon[-2]