Skip to content

Commit 78b149b

Browse files
authored
Add support for geojson labels (#109)
Add support for geojson labels
1 parent 30402f1 commit 78b149b

File tree

13 files changed

+145
-18
lines changed

13 files changed

+145
-18
lines changed

.circleci/config.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ jobs:
1515
sudo pip install numpy==1.13.3
1616
sudo pip install -r requirements.txt
1717
sudo pip install -r requirements-dev.txt
18+
git clone [email protected]:mapbox/tippecanoe.git
19+
cd tippecanoe
20+
sudo make -j
21+
sudo make install
22+
1823
- run:
1924
name: Pylint
2025
command: pylint ~/label-maker/label_maker --rcfile=~/label-maker/.config/pylintrc

docs/parameters.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ Here is the full list of configuration parameters you can specify in a ``config.
88
**bounding_box**: list of floats
99
The bounding box to create images from. This should be given in the form: ``[xmin, ymin, xmax, ymax]`` as longitude and latitude values between ``[-180, 180]`` and ``[-90, 90]``, respectively. Values should use the WGS84 datum, with longitude and latitude units in decimal degrees.
1010

11+
**geojson**: string
12+
An input ``GeoJSON`` file containing labels. Adding this parameter will override the values in the ``country`` and ``bounding_box`` parameters. The ``GeoJSON`` should only contain `Polygon` and not `Multipolygon` or a `GeometryCollection`.
13+
1114
**zoom**: int
1215
The `zoom level <http://wiki.openstreetmap.org/wiki/Zoom_levels>`_ used to create images. This functions as a rough proxy for resolution. Value should be given as an int on the interval [0, 19].
1316

label_maker/label.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ def make_labels(dest_folder, zoom, country, classes, ml_type, bounding_box, spar
5858
as longitude and latitude values between `[-180, 180]` and `[-90, 90]` respectively
5959
sparse: boolean
6060
Limit the total background tiles to write based on `background_ratio` kwarg.
61+
geojson: str
62+
Filepath to optional geojson label input
6163
**kwargs: dict
6264
Other properties from CLI config passed as keywords to other utility functions
6365
"""
@@ -66,15 +68,18 @@ def make_labels(dest_folder, zoom, country, classes, ml_type, bounding_box, spar
6668
mbtiles_file_zoomed = op.join(dest_folder, '{}-z{!s}.mbtiles'.format(country, zoom))
6769

6870
if not op.exists(mbtiles_file_zoomed):
69-
print('Retiling QA Tiles to zoom level {} (takes a bit)'.format(zoom))
70-
filtered_geo = op.join(dest_folder, '{}.geojson'.format(country))
71-
ps = Popen(['tippecanoe-decode', '-c', '-f', mbtiles_file], stdout=PIPE)
72-
stream_filter_fpath = op.join(op.dirname(label_maker.__file__), 'stream_filter.py')
73-
run(['python', stream_filter_fpath, json.dumps(bounding_box)],
74-
stdin=ps.stdout, stdout=open(filtered_geo, 'w'))
75-
ps.wait()
76-
run(['tippecanoe', '--no-feature-limit', '--no-tile-size-limit', '-P',
77-
'-l', 'osm', '-f', '-z', str(zoom), '-Z', str(zoom), '-o',
71+
filtered_geo = kwargs.get('geojson') or op.join(dest_folder, '{}.geojson'.format(country))
72+
fast_parse = []
73+
if not op.exists(filtered_geo):
74+
fast_parse = ['-P']
75+
print('Retiling QA Tiles to zoom level {} (takes a bit)'.format(zoom))
76+
ps = Popen(['tippecanoe-decode', '-c', '-f', mbtiles_file], stdout=PIPE)
77+
stream_filter_fpath = op.join(op.dirname(label_maker.__file__), 'stream_filter.py')
78+
run(['python', stream_filter_fpath, json.dumps(bounding_box)],
79+
stdin=ps.stdout, stdout=open(filtered_geo, 'w'))
80+
ps.wait()
81+
run(['tippecanoe', '--no-feature-limit', '--no-tile-size-limit'] + fast_parse +
82+
['-l', 'osm', '-f', '-z', str(zoom), '-Z', str(zoom), '-o',
7883
mbtiles_file_zoomed, filtered_geo])
7984

8085
# Call tilereduce

label_maker/main.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from os import makedirs, path as op
88

99
from cerberus import Validator
10+
from shapely.geometry import MultiPolygon, Polygon
1011

1112
from label_maker.version import __version__
1213
from label_maker.download import download_mbtiles
@@ -18,6 +19,11 @@
1819

1920
logger = logging.getLogger(__name__)
2021

22+
def get_bounds(feature_collection):
23+
"""Get a bounding box for a FeatureCollection of Polygon Features"""
24+
features = [f for f in feature_collection['features'] if f['geometry']['type'] in ['Polygon']]
25+
return MultiPolygon(list(map(lambda x: Polygon(x['geometry']['coordinates'][0]), features))).bounds
26+
2127

2228
def parse_args(args):
2329
"""Create an argument parser with subcommands"""
@@ -77,6 +83,16 @@ def cli():
7783
if not valid:
7884
raise Exception(v.errors)
7985

86+
# custom validation for top level keys
87+
# require either: country & bounding_box or geojson
88+
if 'geojson' not in config.keys() and not ('country' in config.keys() and 'bounding_box' in config.keys()):
89+
raise Exception('either "geojson" or "country" and "bounding_box" must be present in the configuration JSON')
90+
91+
# for geojson, overwrite other config keys to correct labeling
92+
if 'geojson' in config.keys():
93+
config['country'] = op.splitext(op.basename(config.get('geojson')))[0]
94+
config['bounding_box'] = get_bounds(json.load(open(config.get('geojson'), 'r')))
95+
8096
if cmd == 'download':
8197
download_mbtiles(dest_folder=dest_folder, **config)
8298
elif cmd == 'labels':

label_maker/validate.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
lon_schema = {'type': 'float', 'min': -180, 'max': 180}
2222

2323
schema = {
24-
'country': {'type': 'string', 'allowed': countries, 'required': True},
25-
'bounding_box': {'type': 'list', 'items': [lon_schema, lat_schema, lon_schema, lat_schema], 'required': True},
24+
'geojson': {'type': 'string'},
25+
'country': {'type': 'string', 'allowed': countries},
26+
'bounding_box': {'type': 'list', 'items': [lon_schema, lat_schema, lon_schema, lat_schema]},
2627
'zoom': {'type': 'integer', 'required': True},
2728
'classes': {'type': 'list', 'schema': class_schema, 'required': True},
2829
'imagery': {'type': 'string', 'required': True},
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"geojson": "integration-cl/labels.geojson",
3+
"zoom": 17,
4+
"classes": [
5+
{ "name": "Water Tower", "filter": ["==", "man_made", "water_tower"] },
6+
{ "name": "Building", "filter": ["has", "building"] },
7+
{ "name": "Farmland", "filter": ["==", "landuse", "farmland"] },
8+
{ "name": "Ruins", "filter": ["==", "historic", "ruins"] },
9+
{ "name": "Parking", "filter": ["==", "amenity", "parking"] },
10+
{ "name": "Roads", "filter": ["has", "highway"] }
11+
],
12+
"imagery": "https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.jpg?access_token=ACCESS_TOKEN",
13+
"background_ratio": 1,
14+
"ml_type": "classification",
15+
"seed": 19
16+
}

test/fixtures/integration/labels.geojson

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

test/fixtures/validation/geojson.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"geojson": "test.geojson",
3+
"zoom": 16,
4+
"classes": [
5+
{ "name": "Substation", "filter": ["==", "power", "substation"] }
6+
],
7+
"imagery": "http://a.tiles.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}.jpg",
8+
"background_ratio": 1,
9+
"ml_type": "classification"
10+
}

test/integration/test_classification_labels.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,4 @@ def test_cli(self):
6363
expected_geojson = json.load(fixture)
6464
geojson = json.load(geojson_file)
6565

66-
for feature in geojson['features']:
67-
self.assertTrue(feature in expected_geojson['features'])
66+
self.assertCountEqual(expected_geojson, geojson)
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Test that the following CLI command returns the expected outputs
2+
label-maker labels --dest integration-cl --config test/fixtures/integration/config.geojson.json"""
3+
import unittest
4+
import json
5+
from os import makedirs
6+
from shutil import copyfile, rmtree
7+
import subprocess
8+
9+
import numpy as np
10+
11+
class TestClassificationLabelGeoJSON(unittest.TestCase):
12+
"""Tests for classification label creation"""
13+
@classmethod
14+
def setUpClass(cls):
15+
makedirs('integration-cl')
16+
copyfile('test/fixtures/integration/labels.geojson', 'integration-cl/labels.geojson')
17+
18+
@classmethod
19+
def tearDownClass(cls):
20+
rmtree('integration-cl')
21+
22+
def test_cli(self):
23+
"""Verify stdout, geojson, and labels.npz produced by CLI"""
24+
# our command line output should look like this
25+
expected_output = """Determining labels for each tile
26+
---
27+
Water Tower: 1 tiles
28+
Building: 1 tiles
29+
Farmland: 0 tiles
30+
Ruins: 1 tiles
31+
Parking: 1 tiles
32+
Roads: 8 tiles
33+
Total tiles: 9
34+
Writing out labels to integration-cl/labels.npz
35+
"""
36+
37+
cmd = 'label-maker labels --dest integration-cl --config test/fixtures/integration/config.geojson.json'
38+
cmd = cmd.split(' ')
39+
with subprocess.Popen(cmd, universal_newlines=True, stdout=subprocess.PIPE) as p:
40+
self.assertEqual(expected_output, p.stdout.read())
41+
42+
# our labels should look like this
43+
expected_labels = {
44+
'62092-50162-17': np.array([1, 0, 0, 0, 0, 0, 0]),
45+
'62092-50163-17': np.array([0, 0, 0, 0, 0, 0, 1]),
46+
'62092-50164-17': np.array([0, 0, 0, 0, 0, 0, 1]),
47+
'62093-50162-17': np.array([0, 0, 0, 0, 0, 0, 1]),
48+
'62093-50164-17': np.array([0, 0, 0, 0, 0, 0, 1]),
49+
'62094-50162-17': np.array([0, 0, 0, 0, 0, 0, 1]),
50+
'62094-50164-17': np.array([0, 0, 0, 0, 0, 0, 1]),
51+
'62094-50163-17': np.array([0, 1, 1, 0, 0, 0, 1]),
52+
'62093-50163-17': np.array([0, 0, 0, 0, 1, 1, 1])
53+
}
54+
55+
labels = np.load('integration-cl/labels.npz')
56+
self.assertEqual(len(labels.files), len(expected_labels.keys())) # First check number of tiles
57+
for tile in labels.files:
58+
self.assertTrue(np.array_equal(expected_labels[tile], labels[tile])) # Now, content
59+
60+
# our GeoJSON looks like the fixture
61+
with open('test/fixtures/integration/classification.geojson') as fixture:
62+
with open('integration-cl/classification.geojson') as geojson_file:
63+
expected_geojson = json.load(fixture)
64+
geojson = json.load(geojson_file)
65+
66+
self.assertCountEqual(expected_geojson, geojson)

test/integration/test_classification_labels_sparse.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,4 @@ def test_cli(self):
6363
expected_geojson = json.load(fixture)
6464
geojson = json.load(geojson_file)
6565

66-
for feature in geojson['features']:
67-
self.assertTrue(feature in expected_geojson['features'])
66+
self.assertCountEqual(expected_geojson, geojson)

test/integration/test_directory_move.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,4 @@ def test_cli(self):
5858
expected_geojson = json.load(fixture)
5959
geojson = json.load(geojson_file)
6060

61-
for feature in geojson['features']:
62-
self.assertTrue(feature in expected_geojson['features'])
61+
self.assertCountEqual(expected_geojson, geojson)

test/unit/test_validate.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,19 @@ def test_required(self):
2121
"""Test for all required keys"""
2222
with open('test/fixtures/validation/passing.json') as config_file:
2323
config = json.load(config_file)
24-
for key in ['country', 'bounding_box', 'zoom', 'classes', 'imagery', 'ml_type']:
24+
for key in ['zoom', 'classes', 'imagery', 'ml_type']:
2525
bad_config = copy.deepcopy(config)
2626
bad_config.pop(key)
2727
valid = v.validate(bad_config)
2828
self.assertFalse(valid)
2929

30+
def test_geojson(self):
31+
"""Test an alternate configuration with geojson input"""
32+
with open('test/fixtures/validation/geojson.json') as config_file:
33+
config = json.load(config_file)
34+
valid = v.validate(config)
35+
self.assertTrue(valid)
36+
3037
def test_country(self):
3138
"""Test country not in list fails"""
3239
with open('test/fixtures/validation/passing.json') as config_file:

0 commit comments

Comments
 (0)