Skip to content

Commit f66d46e

Browse files
committed
Add functions for WMTS tile services in coordinate systems other than WebMercator
1 parent 013307d commit f66d46e

File tree

3 files changed

+239
-3
lines changed

3 files changed

+239
-3
lines changed

contextily/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,6 @@
66
from ._providers import providers
77
from .place import Place, plot_map
88
from .tile import *
9-
from .plotting import add_basemap, add_attribution
9+
from .plotting import add_basemap, add_attribution, add_basemap_wmts
1010

1111
__version__ = "1.0rc2"

contextily/plotting.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import numpy as np
44
from . import tile_providers as sources
55
from . import providers
6-
from .tile import _calculate_zoom, bounds2img, _sm2ll, warp_tiles, _warper
6+
from .tile import _calculate_zoom, bounds2img, _sm2ll, warp_tiles, _warper, bounds2img_wmts
77
from rasterio.enums import Resampling
88
from rasterio.warp import transform_bounds
99
from matplotlib import patheffects
@@ -202,3 +202,40 @@ def add_attribution(ax, text, font_size=ATTRIBUTION_SIZE, **kwargs):
202202
wrap_width = ax.get_window_extent().width * 0.99
203203
text_artist._get_wrap_line_width = lambda: wrap_width
204204
return text_artist
205+
206+
207+
def add_basemap_wmts(
208+
ax,
209+
url,
210+
zoom=ZOOM,
211+
interpolation=INTERPOLATION,
212+
attribution=None,
213+
attribution_size=ATTRIBUTION_SIZE,
214+
reset_extent=True,
215+
resampling=Resampling.bilinear,
216+
**extra_imshow_args
217+
):
218+
xmin, xmax, ymin, ymax = ax.axis()
219+
# Assume web source for now
220+
# Extent
221+
left, right, bottom, top = xmin, xmax, ymin, ymax
222+
# Assume crs is consistent
223+
# Zoom
224+
image, extent = bounds2img_wmts(
225+
left, bottom, right, top, url, zoom
226+
)
227+
# Plotting
228+
img = ax.imshow(
229+
image, extent=extent, interpolation=interpolation, **extra_imshow_args
230+
)
231+
232+
if reset_extent:
233+
ax.axis((xmin, xmax, ymin, ymax))
234+
235+
# Add attribution text
236+
if attribution is None:
237+
attribution = url.get("attribution")
238+
if attribution:
239+
add_attribution(ax, attribution, font_size=attribution_size)
240+
241+
return image

contextily/tile.py

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@
2323
from . import tile_providers as sources
2424
from . import providers
2525

26-
__all__ = ["bounds2raster", "bounds2img", "warp_tiles", "warp_img_transform", "howmany"]
26+
import projecttile as pt
27+
28+
29+
__all__ = ["bounds2raster", "bounds2img", "warp_tiles", "warp_img_transform", "howmany", "bounds2raster_wmts", "bounds2img_wmts"]
2730

2831

2932
USER_AGENT = "contextily-" + uuid.uuid4().hex
@@ -560,3 +563,199 @@ def _merge_tiles(tiles, arrays):
560563
)
561564

562565
return img, (west, south, east, north)
566+
567+
568+
def _merge_tiles_wmts(tiles, arrays):
569+
"""
570+
Merge a set of tiles into a single array.
571+
572+
Parameters
573+
---------
574+
tiles : list of mercantile.Tile objects
575+
The tiles to merge.
576+
arrays : list of numpy arrays
577+
The corresponding arrays (image pixels) of the tiles. This list
578+
has the same length and order as the `tiles` argument.
579+
580+
Returns
581+
-------
582+
img : np.ndarray
583+
Merged arrays.
584+
extent : tuple
585+
Bounding box [west, south, east, north] of the returned image
586+
in long/lat.
587+
"""
588+
# create (n_tiles x 2) array with column for x and y coordinates
589+
tile_xys = np.array([(t.x, t.y) for t in tiles])
590+
591+
# get indices starting at zero
592+
indices = tile_xys - tile_xys.min(axis=0)
593+
594+
# the shape of individual tile images
595+
h, w, d = arrays[0].shape
596+
597+
# number of rows and columns in the merged tile
598+
n_x, n_y = (indices + 1).max(axis=0)
599+
600+
# empty merged tiles array to be filled in
601+
img = np.zeros((h * n_y, w * n_x, d), dtype=np.uint8)
602+
603+
for ind, arr in zip(indices, arrays):
604+
x, y = ind
605+
img[y * h : (y + 1) * h, x * w : (x + 1) * w, :] = arr
606+
607+
bounds = np.array([pt.bounds(t) for t in tiles]) # mt => pt
608+
west, south, east, north = (
609+
min(bounds[:, 0]),
610+
min(bounds[:, 1]),
611+
max(bounds[:, 2]),
612+
max(bounds[:, 3]),
613+
)
614+
615+
return img, (west, south, east, north)
616+
617+
618+
def _calculate_zoom_wmts(w, s, e, n, provider_bounds):
619+
"""Automatically choose a zoom level given a desired number of tiles.
620+
621+
Parameters
622+
----------
623+
w : float
624+
The western bbox edge.
625+
s : float
626+
The southern bbox edge.
627+
e : float
628+
The eastern bbox edge.
629+
n : float
630+
The northern bbox edge.
631+
provider_bounds : tuple of float
632+
Bounding values in cartesian coordinates: left, bottom, right, top
633+
634+
Returns
635+
-------
636+
zoom : int
637+
The zoom level to use in order to download this number of tiles.
638+
"""
639+
# Calculate bounds of the bbox
640+
lon_range = np.sort([e, w])[::-1]
641+
lat_range = np.sort([s, n])[::-1]
642+
643+
lon_length = np.subtract(*lon_range)
644+
lat_length = np.subtract(*lat_range)
645+
646+
left, bottom, right, top = provider_bounds
647+
648+
# Calculate the zoom
649+
zoom_lon = np.ceil(np.log2((right - left) / lon_length))
650+
zoom_lat = np.ceil(np.log2((top - bottom) / lat_length))
651+
zoom = np.max([zoom_lon, zoom_lat])
652+
return int(zoom)
653+
654+
655+
def _clamp_zoom_wmts(zoom, provider):
656+
msg = "Zoom level is outside of available levels. Fetching nearest available instead."
657+
if zoom < provider["min_zoom"]:
658+
warnings.warn(msg)
659+
return provider["min_zoom"]
660+
elif zoom > provider["max_zoom"]:
661+
warnings.warn(msg)
662+
return provider["max_zoom"]
663+
else:
664+
return zoom
665+
666+
667+
def bounds2img_wmts(left, bottom, right, top, url, zoom="auto", wait=0, max_retries=2):
668+
"""
669+
Arguments
670+
---------
671+
left : float
672+
West edge
673+
bottom : float
674+
South edge
675+
right : float
676+
East edge
677+
top : float
678+
North edge
679+
url : contextily.TileProvider
680+
zoom : int
681+
Level of detail
682+
683+
Returns
684+
-------
685+
img : ndarray
686+
Image as a 3D array of RGB values
687+
extent : tuple
688+
Bounding box [minX, maxX, minY, maxY] of the returned image
689+
"""
690+
(_left, _bottom), (_right, _top) = url["bounds"]
691+
provider_bounds = (_left, _bottom, _right, _top)
692+
if zoom == "auto":
693+
zoom = _calculate_zoom_wmts(left, bottom, right, top, provider_bounds)
694+
zoom = _clamp_zoom_wmts(zoom, url)
695+
tiles = []
696+
arrays = []
697+
for t in pt.tiles(left, bottom, right, top, [zoom], provider_bounds):
698+
x, y, z = t.x, t.y, t.z
699+
tile_url = _construct_tile_url(url, x, y, z)
700+
image = _fetch_tile(tile_url, wait, max_retries)
701+
tiles.append(t)
702+
arrays.append(image)
703+
merged, (left, bottom, right, top) = _merge_tiles_wmts(tiles, arrays)
704+
# Matplotlib expents them in a different order ...
705+
extent = (left, right, bottom, top)
706+
return merged, extent
707+
708+
709+
def bounds2raster_wmts(
710+
left, bottom, right, top, path, url, zoom="auto", wait=0, max_retries=2,
711+
):
712+
"""
713+
Arguments
714+
---------
715+
left : float
716+
West edge
717+
bottom : float
718+
South edge
719+
right : float
720+
East edge
721+
top : float
722+
North edge
723+
path : str
724+
Path to raster file to be written
725+
url : contextily.TileProvider
726+
zoom : int
727+
Level of detail
728+
729+
Returns
730+
-------
731+
img : ndarray
732+
Image as a 3D array of RGB values
733+
extent : tuple
734+
Bounding box [minX, maxX, minY, maxY] of the returned image
735+
"""
736+
# Download
737+
Z, ext = bounds2img_wmts(left, bottom, right, top, url, zoom, wait, max_retries)
738+
# Write
739+
# ---
740+
h, w, b = Z.shape
741+
# --- https://mapbox.github.io/rasterio/quickstart.html#opening-a-dataset-in-writing-mode
742+
minX, maxX, minY, maxY = ext
743+
x = np.linspace(minX, maxX, w)
744+
y = np.linspace(minY, maxY, h)
745+
resX = (x[-1] - x[0]) / w
746+
resY = (y[-1] - y[0]) / h
747+
transform = from_origin(x[0] - resX / 2, y[-1] + resY / 2, resX, resY)
748+
# ---
749+
with rio.open(
750+
path,
751+
"w",
752+
driver="GTiff",
753+
height=h,
754+
width=w,
755+
count=b,
756+
dtype=str(Z.dtype.name),
757+
transform=transform,
758+
) as raster:
759+
for band in range(b):
760+
raster.write(Z[:, :, band], band + 1)
761+
return Z, ext

0 commit comments

Comments
 (0)