Skip to content

Commit 8d787da

Browse files
authored
Merge pull request #35 from ljwolf/retryer
Retryer
2 parents 8e5039b + 508da93 commit 8d787da

File tree

2 files changed

+101
-41
lines changed

2 files changed

+101
-41
lines changed

contextily/tile.py

Lines changed: 100 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,26 @@
11
"""Tools for downloading map tiles from coordinates."""
2-
import io
3-
from urllib.request import urlopen
4-
5-
import numpy as np
2+
from __future__ import (absolute_import, division, print_function)
63

74
import mercantile as mt
5+
import requests
6+
import io
7+
import os
8+
import numpy as np
89
import pandas as pd
910
import rasterio as rio
10-
from cartopy.io.img_tiles import _merge_tiles as merge_tiles
1111
from PIL import Image
12+
from cartopy.io.img_tiles import _merge_tiles as merge_tiles
1213
from rasterio.transform import from_origin
13-
1414
from . import tile_providers as sources
1515

16+
1617
__all__ = ['bounds2raster', 'bounds2img', 'howmany']
1718

18-
def bounds2raster(w, s, e, n, path, zoom='auto',
19-
url=sources.ST_TERRAIN, ll=False):
20-
"""
19+
20+
def bounds2raster(w, s, e, n, path, zoom='auto',
21+
url=sources.ST_TERRAIN, ll=False,
22+
wait=0, max_retries=2):
23+
'''
2124
Take bounding box and zoom, and write tiles into a raster file in
2225
the Spherical Mercator CRS (EPSG:3857)
2326
@@ -45,14 +48,22 @@ def bounds2raster(w, s, e, n, path, zoom='auto',
4548
ll : Boolean
4649
[Optional. Default: False] If True, `w`, `s`, `e`, `n` are
4750
assumed to be lon/lat as opposed to Spherical Mercator.
51+
wait : int
52+
[Optional. Default: 0]
53+
if the tile API is rate-limited, the number of seconds to wait
54+
between a failed request and the next try
55+
max_retries: int
56+
[Optional. Default: 2]
57+
total number of rejected requests allowed before contextily
58+
will stop trying to fetch more tiles from a rate-limited API.
4859
4960
Returns
5061
-------
5162
img : ndarray
5263
Image as a 3D array of RGB values
5364
extent : tuple
5465
Bounding box [minX, maxX, minY, maxY] of the returned image
55-
"""
66+
'''
5667
if not ll:
5768
# Convert w, s, e, n into lon/lat
5869
w, s = _sm2ll(w, s)
@@ -62,29 +73,31 @@ def bounds2raster(w, s, e, n, path, zoom='auto',
6273
# Download
6374
Z, ext = bounds2img(w, s, e, n, zoom=zoom, url=url, ll=True)
6475
# Write
65-
#---
76+
# ---
6677
h, w, b = Z.shape
67-
#--- https://mapbox.github.io/rasterio/quickstart.html#opening-a-dataset-in-writing-mode
78+
# --- https://mapbox.github.io/rasterio/quickstart.html#opening-a-dataset-in-writing-mode
6879
minX, maxX, minY, maxY = ext
6980
x = np.linspace(minX, maxX, w)
7081
y = np.linspace(minY, maxY, h)
7182
resX = (x[-1] - x[0]) / w
7283
resY = (y[-1] - y[0]) / h
7384
transform = from_origin(x[0] - resX / 2,
7485
y[-1] + resY / 2, resX, resY)
75-
#---
86+
# ---
7687
raster = rio.open(path, 'w',
7788
driver='GTiff', height=h, width=w,
7889
count=b, dtype=str(Z.dtype.name),
7990
crs='epsg:3857', transform=transform)
8091
for band in range(b):
81-
raster.write(Z[:, :, band], band+1)
92+
raster.write(Z[:, :, band], band + 1)
8293
raster.close()
8394
return Z, ext
8495

96+
8597
def bounds2img(w, s, e, n, zoom='auto',
86-
url=sources.ST_TERRAIN, ll=False):
87-
"""
98+
url=sources.ST_TERRAIN, ll=False,
99+
wait=0, max_retries=2):
100+
'''
88101
Take bounding box and zoom and return an image with all the tiles
89102
that compose the map and its Spherical Mercator extent.
90103
@@ -110,14 +123,22 @@ def bounds2img(w, s, e, n, zoom='auto',
110123
ll : Boolean
111124
[Optional. Default: False] If True, `w`, `s`, `e`, `n` are
112125
assumed to be lon/lat as opposed to Spherical Mercator.
126+
wait : int
127+
[Optional. Default: 0]
128+
if the tile API is rate-limited, the number of seconds to wait
129+
between a failed request and the next try
130+
max_retries: int
131+
[Optional. Default: 2]
132+
total number of rejected requests allowed before contextily
133+
will stop trying to fetch more tiles from a rate-limited API.
113134
114135
Returns
115136
-------
116137
img : ndarray
117138
Image as a 3D array of RGB values
118139
extent : tuple
119140
Bounding box [minX, maxX, minY, maxY] of the returned image
120-
"""
141+
'''
121142
if not ll:
122143
# Convert w, s, e, n into lon/lat
123144
w, s = _sm2ll(w, s)
@@ -128,18 +149,16 @@ def bounds2img(w, s, e, n, zoom='auto',
128149
for t in mt.tiles(w, s, e, n, [zoom]):
129150
x, y, z = t.x, t.y, t.z
130151
tile_url = url.replace('tileX', str(x)).replace('tileY', str(y)).replace('tileZ', str(z))
131-
#---
132-
fh = urlopen(tile_url)
133-
im_data = io.BytesIO(fh.read())
134-
fh.close()
135-
imgr = Image.open(im_data)
136-
imgr = imgr.convert('RGB')
137-
#---
138-
img = np.array(imgr)
152+
# ---
153+
request = _retryer(tile_url, wait, max_retries)
154+
with io.BytesIO(request.content) as image_stream:
155+
image = Image.open(image_stream).convert('RGB')
156+
image = np.asarray(image)
157+
# ---
139158
wt, st, et, nt = mt.bounds(t)
140-
xr = np.linspace(wt, et, img.shape[0])
141-
yr = np.linspace(st, nt, img.shape[1])
142-
tiles.append([img, xr, yr, 'lower'])
159+
xr = np.linspace(wt, et, image.shape[0])
160+
yr = np.linspace(st, nt, image.shape[1])
161+
tiles.append([image, xr, yr, 'lower'])
143162
merged, extent = merge_tiles(tiles)[:2]
144163
# lon/lat extent --> Spheric Mercator
145164
minX, maxX, minY, maxY = extent
@@ -148,8 +167,46 @@ def bounds2img(w, s, e, n, zoom='auto',
148167
extent = w, e, s, n
149168
return merged[::-1], extent
150169

151-
def howmany(w, s, e, n, zoom, verbose=True, ll=False):
170+
171+
def _retryer(tile_url, wait, max_retries):
152172
"""
173+
Retry a url many times in attempt to get a tile
174+
175+
Arguments
176+
---------
177+
tile_url: str
178+
string that is the target of the web request. Should be
179+
a properly-formatted url for a tile provider.
180+
wait : int
181+
if the tile API is rate-limited, the number of seconds to wait
182+
between a failed request and the next try
183+
max_retries: int
184+
total number of rejected requests allowed before contextily
185+
will stop trying to fetch more tiles from a rate-limited API.
186+
187+
Returns
188+
-------
189+
request object containing the web response.
190+
"""
191+
try:
192+
request = requests.get(tile_url)
193+
request.raise_for_status()
194+
except requests.HTTPError:
195+
if request.status_code == 404:
196+
raise requests.HTTPError('Tile URL resulted in a 404 error. '
197+
'Double-check your tile url:\n{}'.format(tile_url))
198+
elif request.status_code == 104:
199+
if max_retries > 0:
200+
os.wait(wait)
201+
max_retries -= 1
202+
request = _retryer(tile_url, wait, max_retries)
203+
else:
204+
raise requests.HTTPError('Connection reset by peer too many times.')
205+
return request
206+
207+
208+
def howmany(w, s, e, n, zoom, verbose=True, ll=False):
209+
'''
153210
Number of tiles required for a given bounding box and a zoom level
154211
...
155212
@@ -171,7 +228,7 @@ def howmany(w, s, e, n, zoom, verbose=True, ll=False):
171228
ll : Boolean
172229
[Optional. Default: False] If True, `w`, `s`, `e`, `n` are
173230
assumed to be lon/lat as opposed to Spherical Mercator.
174-
"""
231+
'''
175232
if not ll:
176233
# Convert w, s, e, n into lon/lat
177234
w, s = _sm2ll(w, s)
@@ -180,12 +237,13 @@ def howmany(w, s, e, n, zoom, verbose=True, ll=False):
180237
zoom = _calculate_zoom(w, s, e, n)
181238
tiles = len(list(mt.tiles(w, s, e, n, [zoom])))
182239
if verbose:
183-
print("Using zoom level %i, this will download %i tiles"%(zoom,
184-
tiles))
240+
print("Using zoom level %i, this will download %i tiles" % (zoom,
241+
tiles))
185242
return tiles
186243

244+
187245
def bb2wdw(bb, rtr):
188-
"""
246+
'''
189247
Convert XY bounding box into the window of the tile raster
190248
...
191249
@@ -200,20 +258,21 @@ def bb2wdw(bb, rtr):
200258
-------
201259
window : tuple
202260
((row_start, row_stop), (col_start, col_stop))
203-
"""
261+
'''
204262
rbb = rtr.bounds
205263
xi = pd.Series(np.linspace(rbb.left, rbb.right, rtr.shape[1]))
206264
yi = pd.Series(np.linspace(rbb.bottom, rbb.top, rtr.shape[0]))
207265

208266
window = ((rtr.shape[0] - yi.searchsorted(bb[3])[0],
209267
rtr.shape[0] - yi.searchsorted(bb[1])[0]),
210-
(xi.searchsorted(bb[0])[0],
268+
(xi.searchsorted(bb[0])[0],
211269
xi.searchsorted(bb[2])[0])
212-
)
270+
)
213271
return window
214272

273+
215274
def _sm2ll(x, y):
216-
"""
275+
'''
217276
Transform Spherical Mercator coordinates point into lon/lat
218277
219278
NOTE: Translated from the JS implementation in
@@ -231,15 +290,15 @@ def _sm2ll(x, y):
231290
-------
232291
ll : tuple
233292
lon/lat coordinates
234-
"""
235-
rMajor = 6378137. # Equatorial Radius, QGS84
293+
'''
294+
rMajor = 6378137. # Equatorial Radius, QGS84
236295
shift = np.pi * rMajor
237296
lon = x / shift * 180.
238297
lat = y / shift * 180.
239-
lat = 180. / np.pi * (2. * np.arctan( np.exp( lat * np.pi / 180.) ) -
240-
np.pi / 2.)
298+
lat = 180. / np.pi * (2. * np.arctan(np.exp(lat * np.pi / 180.)) - np.pi / 2.)
241299
return lon, lat
242300

301+
243302
def _calculate_zoom(w, s, e, n):
244303
"""Automatically choose a zoom level given a desired number of tiles.
245304

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ pandas
55
pillow
66
pytest
77
rasterio
8+
requests

0 commit comments

Comments
 (0)