├── .coveragerc ├── .github ├── dependabot.yml └── workflows │ ├── release_to_pypi.yml │ └── tests.yaml ├── .gitignore ├── Dockerfile ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── README_dev.md ├── ci └── envs │ ├── 310-conda-forge.yaml │ ├── 311-conda-forge.yaml │ ├── 312-latest-conda-forge.yaml │ └── 39-minimal.yaml ├── contextily ├── __init__.py ├── place.py ├── plotting.py └── tile.py ├── docs ├── Makefile ├── _static │ └── css │ │ └── custom.css ├── conf.py ├── environment.yml ├── index.rst ├── make.bat └── reference.rst ├── examples └── plot_map.py ├── notebooks ├── add_basemap_deepdive.ipynb ├── friends_cenpy_osmnx.ipynb ├── friends_gee.ipynb ├── intro_guide.ipynb ├── places_guide.ipynb ├── providers_deepdive.ipynb ├── warping_guide.ipynb └── working_with_local_files.ipynb ├── pyproject.toml ├── readthedocs.yml ├── tests ├── __init__.py ├── conftest.py ├── test_cx.py └── test_providers.py └── tiles.png /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | */tests/* 4 | */miniconda/* 5 | [report] 6 | omit = 7 | */tests/* 8 | */miniconda/* 9 | 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | # Check for updates to GitHub Actions every week 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /.github/workflows/release_to_pypi.yml: -------------------------------------------------------------------------------- 1 | name: Publish contextily to PyPI / GitHub 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build-n-publish: 10 | name: Build and publish contextily to PyPI 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout source 15 | uses: actions/checkout@v4 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.x" 21 | 22 | - name: Build a binary wheel and a source tarball 23 | run: | 24 | python -m pip install --upgrade build 25 | python -m build 26 | 27 | - name: Publish distribution to PyPI 28 | uses: pypa/gh-action-pypi-publish@release/v1 29 | with: 30 | user: __token__ 31 | password: ${{ secrets.PYPI_API_TOKEN }} 32 | 33 | - name: Create GitHub Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 38 | with: 39 | tag_name: ${{ github.ref }} 40 | release_name: ${{ github.ref }} 41 | draft: false 42 | prerelease: false 43 | 44 | - name: Get Asset name 45 | run: | 46 | export PKG=$(ls dist/ | grep tar) 47 | set -- $PKG 48 | echo "name=$1" >> $GITHUB_ENV 49 | 50 | - name: Upload Release Asset (sdist) to GitHub 51 | id: upload-release-asset 52 | uses: actions/upload-release-asset@v1 53 | env: 54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 55 | with: 56 | upload_url: ${{ steps.create_release.outputs.upload_url }} 57 | asset_path: dist/${{ env.name }} 58 | asset_name: ${{ env.name }} 59 | asset_content_type: application/zip 60 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | pull_request: 8 | branches: 9 | - "*" 10 | schedule: 11 | - cron: "59 23 * * 3" 12 | 13 | jobs: 14 | unittests: 15 | name: ${{ matrix.os }}, ${{ matrix.environment-file }} 16 | runs-on: ${{ matrix.os }} 17 | timeout-minutes: 30 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | os: [ubuntu-latest] 22 | environment-file: 23 | - ci/envs/39-minimal.yaml 24 | - ci/envs/310-conda-forge.yaml 25 | - ci/envs/311-conda-forge.yaml 26 | - ci/envs/312-latest-conda-forge.yaml 27 | include: 28 | - os: macos-13 29 | environment-file: ci/envs/312-latest-conda-forge.yaml 30 | - os: macos-latest # apple silicon 31 | environment-file: ci/envs/312-latest-conda-forge.yaml 32 | - os: windows-latest 33 | environment-file: ci/envs/312-latest-conda-forge.yaml 34 | defaults: 35 | run: 36 | shell: bash -l {0} 37 | 38 | steps: 39 | - name: checkout repo 40 | uses: actions/checkout@v4 41 | 42 | - name: setup micromamba 43 | uses: mamba-org/setup-micromamba@v2 44 | with: 45 | environment-file: ${{ matrix.environment-file }} 46 | micromamba-version: "latest" 47 | 48 | - name: Install contextily 49 | run: pip install . 50 | 51 | - name: run tests 52 | run: pytest -v . --cov=contextily --cov-append --cov-report term-missing --cov-report xml --color=yes 53 | 54 | - uses: codecov/codecov-action@v5 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | */.ipynb_checkpoints/ 3 | .ipynb_checkpoints/ 4 | */*.swp 5 | *.swp 6 | tx.tif 7 | test.tif 8 | test2.tif 9 | .DS_Store 10 | .cache 11 | build/ 12 | *.egg-info/ 13 | dist/ 14 | .coverage 15 | .pytest_cache/ 16 | notebooks/warp_tst.tif 17 | notebooks/*.tif 18 | 19 | # Sphinx documentation 20 | docs/_build/ 21 | docs/*.ipynb 22 | docs/README.rst 23 | docs/tiles.png 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM darribas/gds_py:4.1 2 | 3 | # Install contextily main 4 | RUN pip install -U earthengine-api git+https://github.com/geopandas/contextily.git@main 5 | # Add notebooks 6 | RUN rm -R work/ 7 | COPY ./README.md ${HOME}/README.md 8 | COPY ./notebooks ${HOME}/notebooks 9 | # Fix permissions 10 | USER root 11 | RUN chown -R ${NB_UID} ${HOME} 12 | USER ${NB_USER} 13 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Dani Arribas-Bel 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | * Neither the name of Dani Arribas-Bel nor the names of other contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 19 | CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 20 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 21 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 23 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF 26 | USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 27 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 28 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 29 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 30 | POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt README.md 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `contextily`: context geo tiles in Python 2 | 3 | `contextily` is a small Python 3 (3.9 and above) package to retrieve tile maps from the 4 | internet. It can add those tiles as basemap to matplotlib figures or write tile 5 | maps to disk into geospatial raster files. Bounding boxes can be passed in both 6 | WGS84 (`EPSG:4326`) and Spheric Mercator (`EPSG:3857`). See the notebook 7 | `contextily_guide.ipynb` for usage. 8 | 9 | [![Tests](https://github.com/geopandas/contextily/actions/workflows/tests.yaml/badge.svg)](https://github.com/geopandas/contextily/actions/workflows/tests.yaml) 10 | [![codecov](https://codecov.io/gh/geopandas/contextily/branch/main/graph/badge.svg?token=5Eu3L1peBb)](https://codecov.io/gh/geopandas/contextily) 11 | [![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/geopandas/contextily/main?urlpath=lab/tree/notebooks/intro_guide.ipynb) 12 | 13 | ![Tiles](tiles.png) 14 | 15 | The current tile providers that are available in contextily are the providers 16 | defined in the [`xyzservices`](https://xyzservices.readthedocs.io) 17 | package. This includes some popular tile maps, such as: 18 | 19 | * The standard [OpenStreetMap](http://openstreetmap.org) map tiles 20 | * Toner, Terrain and Watercolor map tiles by [Stamen Design](http://stamen.com) 21 | 22 | ## Dependencies 23 | 24 | * `mercantile` 25 | * `numpy` 26 | * `matplotlib` 27 | * `pillow` 28 | * `rasterio` 29 | * `requests` 30 | * `geopy` 31 | * `joblib` 32 | * `xyzservices` 33 | 34 | ## Installation 35 | 36 | **Python 3 only** (3.9 and above) 37 | 38 | [Latest released version](https://github.com/geopandas/contextily/releases/), using pip: 39 | 40 | ```sh 41 | pip3 install contextily 42 | ``` 43 | 44 | or conda: 45 | 46 | ```sh 47 | conda install contextily 48 | ``` 49 | 50 | ## Contributors 51 | 52 | `contextily` is developed by a community of enthusiastic volunteers. You can see a full list [here](https://github.com/geopandas/contextily/graphs/contributors). 53 | 54 | If you would like to contribute to the project, have a look at the list of [open issues](https://github.com/geopandas/contextily/issues), particularly those labeled as [good first contributions](https://github.com/geopandas/contextily/issues?q=is%3Aissue+is%3Aopen+label%3Agood-first-contribution). 55 | 56 | ## License 57 | 58 | BSD compatible. See `LICENSE.txt` 59 | -------------------------------------------------------------------------------- /README_dev.md: -------------------------------------------------------------------------------- 1 | # Development notes 2 | 3 | ## Testing 4 | 5 | Testing relies on `pytest` and `pytest-cov`. To run the test suite locally: 6 | 7 | ``` 8 | python -m pytest -v tests/ --cov contextily 9 | ``` 10 | 11 | This assumes you also have installed `pytest-cov`. 12 | 13 | ## Releasing 14 | 15 | Cutting a release and updating to `pypi` requires the following steps (from 16 | [here](https://packaging.python.org/tutorials/packaging-projects/)]): 17 | 18 | * Make sure you have installed the following libraries: 19 | * `twine` 20 | * `setuptools` 21 | * `wheel` 22 | * Make sure tests pass locally and on CI. 23 | * Update the version on `setup.py` and `__init__.py` 24 | * Commit those changes as `git commit 'RLS: v1.0.0'` 25 | * Tag the commit using an annotated tag. ``git tag -a v1.0.0 -m "Version 1.0.0"`` 26 | * Push the RLS commit ``git push upstream main`` 27 | * Also push the tag! ``git push upstream --tags`` 28 | * Create sdist and wheel: `python setup.py sdist bdist_wheel` 29 | * Make github release from the tag (also add the sdist as asset) 30 | * When ready to push up, run `twine upload dist/*`. 31 | 32 | -------------------------------------------------------------------------------- /ci/envs/310-conda-forge.yaml: -------------------------------------------------------------------------------- 1 | name: test-environment 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.10 6 | # required 7 | - geopy 8 | - matplotlib 9 | - mercantile 10 | - pillow 11 | - rasterio 12 | - requests 13 | - joblib 14 | - xyzservices 15 | # testing 16 | - pip 17 | - pytest 18 | - pytest-cov 19 | -------------------------------------------------------------------------------- /ci/envs/311-conda-forge.yaml: -------------------------------------------------------------------------------- 1 | name: test-environment 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.11 6 | # required 7 | - geopy 8 | - matplotlib 9 | - mercantile 10 | - pillow 11 | - rasterio 12 | - requests 13 | - joblib 14 | - xyzservices 15 | # testing 16 | - pip 17 | - pytest 18 | - pytest-cov 19 | -------------------------------------------------------------------------------- /ci/envs/312-latest-conda-forge.yaml: -------------------------------------------------------------------------------- 1 | name: test-environment 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.12 6 | # required 7 | - geopy 8 | - matplotlib 9 | - mercantile 10 | - pillow 11 | - rasterio 12 | - requests 13 | - joblib 14 | - xyzservices 15 | # testing 16 | - pip 17 | - pytest 18 | - pytest-cov 19 | -------------------------------------------------------------------------------- /ci/envs/39-minimal.yaml: -------------------------------------------------------------------------------- 1 | name: test-environment 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.9 6 | # required 7 | - geopy 8 | - matplotlib 9 | - mercantile 10 | - pillow 11 | - rasterio 12 | - requests 13 | - joblib 14 | - xyzservices 15 | # testing 16 | - pip 17 | - pytest 18 | - pytest-cov 19 | -------------------------------------------------------------------------------- /contextily/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | `contextily`: create context with map tiles in Python 3 | """ 4 | 5 | import xyzservices.providers as providers 6 | from .place import Place, plot_map 7 | from .tile import * 8 | from .plotting import add_basemap, add_attribution 9 | 10 | from importlib.metadata import PackageNotFoundError, version 11 | 12 | try: 13 | __version__ = version("contextily") 14 | except PackageNotFoundError: # noqa 15 | # package is not installed 16 | pass 17 | -------------------------------------------------------------------------------- /contextily/place.py: -------------------------------------------------------------------------------- 1 | """Tools for generating maps from a text search.""" 2 | import geopy as gp 3 | import numpy as np 4 | import matplotlib.pyplot as plt 5 | import warnings 6 | 7 | from .tile import howmany, bounds2raster, bounds2img, _sm2ll, _calculate_zoom 8 | from .plotting import INTERPOLATION, ZOOM, add_attribution 9 | from . import providers 10 | from xyzservices import TileProvider 11 | 12 | # Set user ID for Nominatim 13 | _val = np.random.randint(1000000) 14 | _default_user_agent = f"contextily_user_{_val}" 15 | 16 | 17 | class Place(object): 18 | """Geocode a place by name and get its map. 19 | 20 | This allows you to search for a name (e.g., city, street, country) and 21 | grab map and location data from the internet. 22 | 23 | Parameters 24 | ---------- 25 | search : string 26 | The location to be searched. 27 | zoom : int or None 28 | [Optional. Default: None] 29 | The level of detail to include in the map. Higher levels mean more 30 | tiles and thus longer download time. If None, the zoom level will be 31 | automatically determined. 32 | path : str or None 33 | [Optional. Default: None] 34 | Path to a raster file that will be created after getting the place map. 35 | If None, no raster file will be downloaded. 36 | zoom_adjust : int or None 37 | [Optional. Default: None] 38 | The amount to adjust a chosen zoom level if it is chosen automatically. 39 | source : xyzservices.providers object or str 40 | [Optional. Default: OpenStreetMap Humanitarian web tiles] 41 | The tile source: web tile provider or path to local file. The web tile 42 | provider can be in the form of a :class:`xyzservices.TileProvider` object or a 43 | URL. The placeholders for the XYZ in the URL need to be `{x}`, `{y}`, 44 | `{z}`, respectively. For local file paths, the file is read with 45 | `rasterio` and all bands are loaded into the basemap. 46 | IMPORTANT: tiles are assumed to be in the Spherical Mercator 47 | projection (EPSG:3857), unless the `crs` keyword is specified. 48 | geocoder : geopy.geocoders 49 | [Optional. Default: geopy.geocoders.Nominatim()] Geocoder method to process `search` 50 | 51 | Attributes 52 | ---------- 53 | geocode : geopy object 54 | The result of calling ``geopy.geocoders.Nominatim`` with ``search`` as input. 55 | s : float 56 | The southern bbox edge. 57 | n : float 58 | The northern bbox edge. 59 | e : float 60 | The eastern bbox edge. 61 | w : float 62 | The western bbox edge. 63 | im : ndarray 64 | The image corresponding to the map of ``search``. 65 | bbox : list 66 | The bounding box of the returned image, expressed in lon/lat, with the 67 | following order: [minX, minY, maxX, maxY] 68 | bbox_map : tuple 69 | The bounding box of the returned image, expressed in Web Mercator, with the 70 | following order: [minX, minY, maxX, maxY] 71 | """ 72 | 73 | def __init__( 74 | self, 75 | search, 76 | zoom=None, 77 | path=None, 78 | zoom_adjust=None, 79 | source=None, 80 | geocoder=gp.geocoders.Nominatim(user_agent=_default_user_agent), 81 | ): 82 | self.path = path 83 | if source is None: 84 | source = providers.OpenStreetMap.HOT 85 | self.source = source 86 | self.zoom_adjust = zoom_adjust 87 | 88 | # Get geocoded values 89 | resp = geocoder.geocode(search) 90 | bbox = np.array([float(ii) for ii in resp.raw["boundingbox"]]) 91 | 92 | if "display_name" in resp.raw.keys(): 93 | place = resp.raw["display_name"] 94 | elif "address" in resp.raw.keys(): 95 | place = resp.raw["address"] 96 | else: 97 | place = search 98 | self.place = place 99 | self.search = search 100 | self.s, self.n, self.w, self.e = bbox 101 | self.bbox = [self.w, self.s, self.e, self.n] # So bbox is standard 102 | self.latitude = resp.latitude 103 | self.longitude = resp.longitude 104 | self.geocode = resp 105 | 106 | # Get map params 107 | self.zoom = ( 108 | _calculate_zoom(self.w, self.s, self.e, self.n) if zoom is None else zoom 109 | ) 110 | self.zoom = int(self.zoom) 111 | if self.zoom_adjust is not None: 112 | self.zoom += zoom_adjust 113 | self.n_tiles = howmany(self.w, self.s, self.e, self.n, self.zoom, verbose=False) 114 | 115 | # Get the map 116 | self._get_map() 117 | 118 | def _get_map(self): 119 | kwargs = {"ll": True} 120 | if self.source is not None: 121 | kwargs["source"] = self.source 122 | 123 | try: 124 | if isinstance(self.path, str): 125 | im, bbox = bounds2raster( 126 | self.w, self.s, self.e, self.n, self.path, zoom=self.zoom, **kwargs 127 | ) 128 | else: 129 | im, bbox = bounds2img( 130 | self.w, self.s, self.e, self.n, self.zoom, **kwargs 131 | ) 132 | except Exception as err: 133 | raise ValueError( 134 | "Could not retrieve map with parameters: {}, {}, {}, {}, zoom={}\n{}\nError: {}".format( 135 | self.w, self.s, self.e, self.n, self.zoom, kwargs, err 136 | ) 137 | ) 138 | 139 | self.im = im 140 | self.bbox_map = bbox 141 | return im, bbox 142 | 143 | def plot(self, ax=None, zoom=ZOOM, interpolation=INTERPOLATION, attribution=None): 144 | """ 145 | Plot a `Place` object 146 | ... 147 | 148 | Parameters 149 | ---------- 150 | ax : AxesSubplot 151 | Matplotlib axis with `x_lim` and `y_lim` set in Web 152 | Mercator (EPSG=3857). If not provided, a new 153 | 12x12 figure will be set and the name of the place 154 | will be added as title 155 | zoom : int/'auto' 156 | [Optional. Default='auto'] Level of detail for the 157 | basemap. If 'auto', if calculates it automatically. 158 | Ignored if `source` is a local file. 159 | interpolation : str 160 | [Optional. Default='bilinear'] Interpolation 161 | algorithm to be passed to `imshow`. See 162 | `matplotlib.pyplot.imshow` for further details. 163 | attribution : str 164 | [Optional. Defaults to attribution specified by the source of the map tiles] 165 | Text to be added at the bottom of the axis. This 166 | defaults to the attribution of the provider specified 167 | in `source` if available. Specify False to not 168 | automatically add an attribution, or a string to pass 169 | a custom attribution. 170 | 171 | Returns 172 | ------- 173 | ax : AxesSubplot 174 | Matplotlib axis with `x_lim` and `y_lim` set in Web 175 | Mercator (EPSG=3857) containing the basemap 176 | 177 | Examples 178 | -------- 179 | 180 | >>> lvl = cx.Place('Liverpool') 181 | >>> lvl.plot() 182 | 183 | """ 184 | im = self.im 185 | bbox = self.bbox_map 186 | 187 | title = None 188 | axisoff = False 189 | if ax is None: 190 | fig, ax = plt.subplots(figsize=(12, 12)) 191 | title = self.place 192 | axisoff = True 193 | ax.imshow(im, extent=bbox, interpolation=interpolation) 194 | ax.set(xlabel="X", ylabel="Y") 195 | if isinstance(self.source, (dict, TileProvider)) and attribution is None: 196 | attribution = self.source.get("attribution") 197 | if attribution: 198 | add_attribution(ax, attribution) 199 | if title is not None: 200 | ax.set(title=title) 201 | if axisoff: 202 | ax.set_axis_off() 203 | return ax 204 | 205 | def __repr__(self): 206 | s = "Place : {} | n_tiles: {} | zoom : {} | im : {}".format( 207 | self.place, self.n_tiles, self.zoom, self.im.shape[:2] 208 | ) 209 | return s 210 | 211 | 212 | def plot_map( 213 | place, bbox=None, title=None, ax=None, axis_off=True, latlon=True, attribution=None 214 | ): 215 | """Plot a map of the given place. 216 | 217 | Parameters 218 | ---------- 219 | place : instance of Place or ndarray 220 | The map to plot. If an ndarray, this must be an image corresponding 221 | to a map. If an instance of ``Place``, the extent of the image and name 222 | will be inferred from the bounding box. 223 | ax : instance of matplotlib Axes object or None 224 | The axis on which to plot. If None, one will be created. 225 | axis_off : bool 226 | Whether to turn off the axis border and ticks before plotting. 227 | attribution : str 228 | [Optional. Default to standard `ATTRIBUTION`] Text to be added at the 229 | bottom of the axis. 230 | 231 | Returns 232 | ------- 233 | ax : instance of matplotlib Axes object or None 234 | The axis on the map is plotted. 235 | """ 236 | warnings.warn( 237 | ( 238 | "The method `plot_map` is deprecated and will be removed from the" 239 | " library in future versions. Please use either `add_basemap` or" 240 | " the internal method `Place.plot`" 241 | ), 242 | DeprecationWarning, 243 | ) 244 | if not isinstance(place, Place): 245 | im = place 246 | bbox = bbox 247 | title = title 248 | else: 249 | im = place.im 250 | if bbox is None: 251 | bbox = place.bbox_map 252 | if latlon is True: 253 | # Convert w, s, e, n into lon/lat 254 | w, e, s, n = bbox 255 | w, s = _sm2ll(w, s) 256 | e, n = _sm2ll(e, n) 257 | bbox = [w, e, s, n] 258 | 259 | title = place.place if title is None else title 260 | 261 | if ax is None: 262 | fig, ax = plt.subplots(figsize=(15, 15)) 263 | ax.imshow(im, extent=bbox) 264 | ax.set(xlabel="X", ylabel="Y") 265 | if title is not None: 266 | ax.set(title=title) 267 | if attribution: 268 | add_attribution(ax, attribution) 269 | if axis_off is True: 270 | ax.set_axis_off() 271 | return ax 272 | -------------------------------------------------------------------------------- /contextily/plotting.py: -------------------------------------------------------------------------------- 1 | """Tools to plot basemaps""" 2 | 3 | import warnings 4 | import numpy as np 5 | from . import providers 6 | from xyzservices import TileProvider 7 | from .tile import bounds2img, _sm2ll, warp_tiles, _warper 8 | from rasterio.enums import Resampling 9 | from rasterio.warp import transform_bounds 10 | from matplotlib import patheffects 11 | from matplotlib.pyplot import draw 12 | 13 | INTERPOLATION = "bilinear" 14 | ZOOM = "auto" 15 | ATTRIBUTION_SIZE = 8 16 | 17 | 18 | def add_basemap( 19 | ax, 20 | zoom=ZOOM, 21 | source=None, 22 | interpolation=INTERPOLATION, 23 | attribution=None, 24 | attribution_size=ATTRIBUTION_SIZE, 25 | reset_extent=True, 26 | crs=None, 27 | resampling=Resampling.bilinear, 28 | zoom_adjust=None, 29 | **extra_imshow_args, 30 | ): 31 | """ 32 | Add a (web/local) basemap to `ax`. 33 | 34 | Parameters 35 | ---------- 36 | ax : AxesSubplot 37 | Matplotlib axes object on which to add the basemap. The extent of the 38 | axes is assumed to be in Spherical Mercator (EPSG:3857), unless the `crs` 39 | keyword is specified. 40 | zoom : int or 'auto' 41 | [Optional. Default='auto'] Level of detail for the basemap. If 'auto', 42 | it is calculated automatically. Ignored if `source` is a local file. 43 | source : xyzservices.TileProvider object or str 44 | [Optional. Default: OpenStreetMap Humanitarian web tiles] 45 | The tile source: web tile provider, a valid input for a query of a 46 | :class:`xyzservices.TileProvider` by a name from ``xyzservices.providers`` or 47 | path to local file. The web tile provider can be in the form of a 48 | :class:`xyzservices.TileProvider` object or a URL. The placeholders for the XYZ 49 | in the URL need to be `{x}`, `{y}`, `{z}`, respectively. For local file paths, 50 | the file is read with `rasterio` and all bands are loaded into the basemap. 51 | IMPORTANT: tiles are assumed to be in the Spherical Mercator projection 52 | (EPSG:3857), unless the `crs` keyword is specified. 53 | interpolation : str 54 | [Optional. Default='bilinear'] Interpolation algorithm to be passed 55 | to `imshow`. See `matplotlib.pyplot.imshow` for further details. 56 | attribution : str 57 | [Optional. Defaults to attribution specified by the source] 58 | Text to be added at the bottom of the axis. This 59 | defaults to the attribution of the provider specified 60 | in `source` if available. Specify False to not 61 | automatically add an attribution, or a string to pass 62 | a custom attribution. 63 | attribution_size : int 64 | [Optional. Defaults to `ATTRIBUTION_SIZE`]. 65 | Font size to render attribution text with. 66 | reset_extent : bool 67 | [Optional. Default=True] If True, the extent of the 68 | basemap added is reset to the original extent (xlim, 69 | ylim) of `ax` 70 | crs : None or str or CRS 71 | [Optional. Default=None] coordinate reference system (CRS), 72 | expressed in any format permitted by rasterio, to use for the 73 | resulting basemap. If None (default), no warping is performed 74 | and the original Spherical Mercator (EPSG:3857) is used. 75 | resampling : 76 | [Optional. Default=Resampling.bilinear] Resampling 77 | method for executing warping, expressed as a 78 | `rasterio.enums.Resampling` method 79 | zoom_adjust : int or None 80 | [Optional. Default: None] 81 | The amount to adjust a chosen zoom level if it is chosen automatically. 82 | Values outside of -1 to 1 are not recommended as they can lead to slow execution. 83 | **extra_imshow_args : 84 | Other parameters to be passed to `imshow`. 85 | 86 | Examples 87 | -------- 88 | 89 | >>> import geopandas 90 | >>> import contextily as cx 91 | >>> db = geopandas.read_file(ps.examples.get_path('virginia.shp')) 92 | 93 | Ensure the data is in Spherical Mercator: 94 | 95 | >>> db = db.to_crs(epsg=3857) 96 | 97 | Add a web basemap: 98 | 99 | >>> ax = db.plot(alpha=0.5, color='k', figsize=(6, 6)) 100 | >>> cx.add_basemap(ax, source=url) 101 | >>> plt.show() 102 | 103 | Or download a basemap to a local file and then plot it: 104 | 105 | >>> source = 'virginia.tiff' 106 | >>> _ = cx.bounds2raster(*db.total_bounds, zoom=6, source=source) 107 | >>> ax = db.plot(alpha=0.5, color='k', figsize=(6, 6)) 108 | >>> cx.add_basemap(ax, source=source) 109 | >>> plt.show() 110 | 111 | """ 112 | xmin, xmax, ymin, ymax = ax.axis() 113 | 114 | if isinstance(source, str): 115 | try: 116 | source = providers.query_name(source) 117 | except ValueError: 118 | pass 119 | 120 | # If web source 121 | if ( 122 | source is None 123 | or isinstance(source, (dict, TileProvider)) 124 | or (isinstance(source, str) and source[:4] == "http") 125 | ): 126 | # Extent 127 | left, right, bottom, top = xmin, xmax, ymin, ymax 128 | # Convert extent from `crs` into WM for tile query 129 | if crs is not None: 130 | left, right, bottom, top = _reproj_bb( 131 | left, right, bottom, top, crs, "epsg:3857" 132 | ) 133 | # Download image 134 | image, extent = bounds2img( 135 | left, 136 | bottom, 137 | right, 138 | top, 139 | zoom=zoom, 140 | source=source, 141 | ll=False, 142 | zoom_adjust=zoom_adjust, 143 | ) 144 | # Warping 145 | if crs is not None: 146 | image, extent = warp_tiles(image, extent, t_crs=crs, resampling=resampling) 147 | # Check if overlay 148 | if _is_overlay(source) and "zorder" not in extra_imshow_args: 149 | # If zorder was not set then make it 9 otherwise leave it 150 | extra_imshow_args["zorder"] = 9 151 | # If local source 152 | else: 153 | import rasterio as rio 154 | 155 | # Read file 156 | with rio.open(source) as raster: 157 | if reset_extent: 158 | from rasterio.mask import mask as riomask 159 | 160 | # Read window 161 | if crs: 162 | left, bottom, right, top = rio.warp.transform_bounds( 163 | crs, raster.crs, xmin, ymin, xmax, ymax 164 | ) 165 | else: 166 | left, bottom, right, top = xmin, ymin, xmax, ymax 167 | window = [ 168 | { 169 | "type": "Polygon", 170 | "coordinates": ( 171 | ( 172 | (left, bottom), 173 | (right, bottom), 174 | (right, top), 175 | (left, top), 176 | (left, bottom), 177 | ), 178 | ), 179 | } 180 | ] 181 | image, img_transform = riomask(raster, window, crop=True) 182 | extent = left, right, bottom, top 183 | else: 184 | # Read full 185 | image = np.array([band for band in raster.read()]) 186 | img_transform = raster.transform 187 | bb = raster.bounds 188 | extent = bb.left, bb.right, bb.bottom, bb.top 189 | # Warp 190 | if (crs is not None) and (raster.crs != crs): 191 | image, bounds, _ = _warper( 192 | image, img_transform, raster.crs, crs, resampling 193 | ) 194 | extent = bounds.left, bounds.right, bounds.bottom, bounds.top 195 | image = image.transpose(1, 2, 0) 196 | 197 | # Plotting 198 | if image.shape[2] == 1: 199 | image = image[:, :, 0] 200 | _ = ax.imshow( 201 | image, 202 | extent=extent, 203 | interpolation=interpolation, 204 | aspect=ax.get_aspect(), # GH251 205 | **extra_imshow_args, 206 | ) 207 | 208 | if reset_extent: 209 | ax.axis((xmin, xmax, ymin, ymax)) 210 | else: 211 | max_bounds = ( 212 | min(xmin, extent[0]), 213 | max(xmax, extent[1]), 214 | min(ymin, extent[2]), 215 | max(ymax, extent[3]), 216 | ) 217 | ax.axis(max_bounds) 218 | 219 | # Add attribution text 220 | if source is None: 221 | source = providers.OpenStreetMap.HOT 222 | if isinstance(source, (dict, TileProvider)) and attribution is None: 223 | attribution = source.get("attribution") 224 | if attribution: 225 | add_attribution(ax, attribution, font_size=attribution_size) 226 | 227 | return 228 | 229 | 230 | def _reproj_bb(left, right, bottom, top, s_crs, t_crs): 231 | n_l, n_b, n_r, n_t = transform_bounds(s_crs, t_crs, left, bottom, right, top) 232 | return n_l, n_r, n_b, n_t 233 | 234 | 235 | def _is_overlay(source): 236 | """ 237 | Check if the identified source is an overlay (partially transparent) layer. 238 | 239 | Parameters 240 | ---------- 241 | source : dict 242 | The tile source: web tile provider. Must be preprocessed as 243 | into a dictionary, not just a string. 244 | 245 | Returns 246 | ------- 247 | bool 248 | 249 | Notes 250 | ----- 251 | This function is based on a very similar javascript version found in leaflet: 252 | https://github.com/leaflet-extras/leaflet-providers/blob/9eb968f8442ea492626c9c8f0dac8ede484e6905/preview/preview.js#L56-L70 253 | """ 254 | if not isinstance(source, dict): 255 | return False 256 | if source.get("opacity", 1.0) < 1.0: 257 | return True 258 | overlayPatterns = [ 259 | "^(OpenWeatherMap|OpenSeaMap)", 260 | "OpenMapSurfer.(Hybrid|AdminBounds|ContourLines|Hillshade|ElementsAtRisk)", 261 | "Stamen.Toner(Hybrid|Lines|Labels)", 262 | "CartoDB.(Positron|DarkMatter|Voyager)OnlyLabels", 263 | "Hydda.RoadsAndLabels", 264 | "^JusticeMap", 265 | "OpenPtMap", 266 | "OpenRailwayMap", 267 | "OpenFireMap", 268 | "SafeCast", 269 | ] 270 | import re 271 | 272 | return bool(re.match("(" + "|".join(overlayPatterns) + ")", source.get("name", ""))) 273 | 274 | 275 | def add_attribution(ax, text, font_size=ATTRIBUTION_SIZE, **kwargs): 276 | """ 277 | Utility to add attribution text. 278 | 279 | Parameters 280 | ---------- 281 | ax : AxesSubplot 282 | Matplotlib axes object on which to add the attribution text. 283 | text : str 284 | Text to be added at the bottom of the axis. 285 | font_size : int 286 | [Optional. Defaults to 8] Font size in which to render 287 | the attribution text. 288 | **kwargs : Additional keywords to pass to the matplotlib `text` method. 289 | 290 | Returns 291 | ------- 292 | matplotlib.text.Text 293 | Matplotlib Text object added to the plot. 294 | """ 295 | # Add draw() as it resizes the axis and allows the wrapping to work as 296 | # expected. See https://github.com/darribas/contextily/issues/95 for some 297 | # details on the issue 298 | draw() 299 | 300 | text_artist = ax.text( 301 | 0.005, 302 | 0.005, 303 | text, 304 | transform=ax.transAxes, 305 | size=font_size, 306 | path_effects=[patheffects.withStroke(linewidth=2, foreground="w")], 307 | wrap=True, 308 | **kwargs, 309 | ) 310 | # hack to have the text wrapped in the ax extent, for some explanation see 311 | # https://stackoverflow.com/questions/48079364/wrapping-text-not-working-in-matplotlib 312 | wrap_width = ax.get_window_extent().width * 0.99 313 | text_artist._get_wrap_line_width = lambda: wrap_width 314 | return text_artist 315 | -------------------------------------------------------------------------------- /contextily/tile.py: -------------------------------------------------------------------------------- 1 | """Tools for downloading map tiles from coordinates.""" 2 | 3 | from __future__ import absolute_import, division, print_function 4 | 5 | import uuid 6 | 7 | import mercantile as mt 8 | import requests 9 | import atexit 10 | import io 11 | import time 12 | import shutil 13 | import tempfile 14 | import warnings 15 | 16 | import numpy as np 17 | import rasterio as rio 18 | from PIL import Image, UnidentifiedImageError 19 | from joblib import Memory as _Memory 20 | from joblib import Parallel, delayed 21 | from rasterio.transform import from_origin 22 | from rasterio.io import MemoryFile 23 | from rasterio.vrt import WarpedVRT 24 | from rasterio.enums import Resampling 25 | from . import providers 26 | from xyzservices import TileProvider 27 | 28 | __all__ = [ 29 | "bounds2raster", 30 | "bounds2img", 31 | "warp_tiles", 32 | "warp_img_transform", 33 | "howmany", 34 | "set_cache_dir", 35 | ] 36 | 37 | 38 | USER_AGENT = "contextily-" + uuid.uuid4().hex 39 | 40 | tmpdir = tempfile.mkdtemp() 41 | memory = _Memory(tmpdir, verbose=0) 42 | 43 | 44 | def set_cache_dir(path): 45 | """ 46 | Set a cache directory to use in the current python session. 47 | 48 | By default, contextily caches downloaded tiles per python session, but 49 | will afterwards delete the cache directory. By setting it to a custom 50 | path, you can avoid this, and re-use the same cache a next time by 51 | again setting the cache dir to that directory. 52 | 53 | Parameters 54 | ---------- 55 | path : str 56 | Path to the cache directory. 57 | """ 58 | memory.store_backend.location = path 59 | 60 | 61 | def _clear_cache(): 62 | shutil.rmtree(tmpdir, ignore_errors=True) 63 | 64 | 65 | atexit.register(_clear_cache) 66 | 67 | 68 | def bounds2raster( 69 | w, 70 | s, 71 | e, 72 | n, 73 | path, 74 | zoom="auto", 75 | source=None, 76 | ll=False, 77 | wait=0, 78 | max_retries=2, 79 | n_connections=1, 80 | use_cache=True, 81 | ): 82 | """ 83 | Take bounding box and zoom, and write tiles into a raster file in 84 | the Spherical Mercator CRS (EPSG:3857) 85 | 86 | Parameters 87 | ---------- 88 | w : float 89 | West edge 90 | s : float 91 | South edge 92 | e : float 93 | East edge 94 | n : float 95 | North edge 96 | zoom : int 97 | Level of detail 98 | path : str 99 | Path to raster file to be written 100 | source : xyzservices.TileProvider object or str 101 | [Optional. Default: OpenStreetMap Humanitarian web tiles] 102 | The tile source: web tile provider or path to local file. The web tile 103 | provider can be in the form of a :class:`xyzservices.TileProvider` object or a 104 | URL. The placeholders for the XYZ in the URL need to be `{x}`, `{y}`, 105 | `{z}`, respectively. For local file paths, the file is read with 106 | `rasterio` and all bands are loaded into the basemap. 107 | IMPORTANT: tiles are assumed to be in the Spherical Mercator 108 | projection (EPSG:3857), unless the `crs` keyword is specified. 109 | ll : Boolean 110 | [Optional. Default: False] If True, `w`, `s`, `e`, `n` are 111 | assumed to be lon/lat as opposed to Spherical Mercator. 112 | wait : int 113 | [Optional. Default: 0] 114 | if the tile API is rate-limited, the number of seconds to wait 115 | between a failed request and the next try 116 | max_retries: int 117 | [Optional. Default: 2] 118 | total number of rejected requests allowed before contextily 119 | will stop trying to fetch more tiles from a rate-limited API. 120 | n_connections: int 121 | [Optional. Default: 1] 122 | Number of connections for downloading tiles in parallel. Be careful not to overload the tile server and to check 123 | the tile provider's terms of use before increasing this value. E.g., OpenStreetMap has a max. value of 2 124 | (https://operations.osmfoundation.org/policies/tiles/). If allowed to download in parallel, a recommended 125 | value for n_connections is 16, and should never be larger than 64. 126 | use_cache: bool 127 | [Optional. Default: True] 128 | If False, caching of the downloaded tiles will be disabled. This can be useful in resource constrained 129 | environments, especially when using n_connections > 1, or when a tile provider's terms of use don't allow 130 | caching. 131 | 132 | Returns 133 | ------- 134 | img : ndarray 135 | Image as a 3D array of RGB values 136 | extent : tuple 137 | Bounding box [minX, maxX, minY, maxY] of the returned image 138 | """ 139 | if not ll: 140 | # Convert w, s, e, n into lon/lat 141 | w, s = _sm2ll(w, s) 142 | e, n = _sm2ll(e, n) 143 | # Download 144 | Z, ext = bounds2img( 145 | w, 146 | s, 147 | e, 148 | n, 149 | zoom=zoom, 150 | source=source, 151 | ll=True, 152 | n_connections=n_connections, 153 | use_cache=use_cache, 154 | ) 155 | 156 | # Write 157 | # --- 158 | h, w, b = Z.shape 159 | # --- https://mapbox.github.io/rasterio/quickstart.html#opening-a-dataset-in-writing-mode 160 | minX, maxX, minY, maxY = ext 161 | x = np.linspace(minX, maxX, w) 162 | y = np.linspace(minY, maxY, h) 163 | resX = (x[-1] - x[0]) / w 164 | resY = (y[-1] - y[0]) / h 165 | transform = from_origin(x[0] - resX / 2, y[-1] + resY / 2, resX, resY) 166 | # --- 167 | with rio.open( 168 | path, 169 | "w", 170 | driver="GTiff", 171 | height=h, 172 | width=w, 173 | count=b, 174 | dtype=str(Z.dtype.name), 175 | crs="epsg:3857", 176 | transform=transform, 177 | ) as raster: 178 | for band in range(b): 179 | raster.write(Z[:, :, band], band + 1) 180 | return Z, ext 181 | 182 | 183 | def bounds2img( 184 | w, 185 | s, 186 | e, 187 | n, 188 | zoom="auto", 189 | source=None, 190 | ll=False, 191 | wait=0, 192 | max_retries=2, 193 | n_connections=1, 194 | use_cache=True, 195 | zoom_adjust=None, 196 | ): 197 | """ 198 | Take bounding box and zoom and return an image with all the tiles 199 | that compose the map and its Spherical Mercator extent. 200 | 201 | Parameters 202 | ---------- 203 | w : float 204 | West edge 205 | s : float 206 | South edge 207 | e : float 208 | East edge 209 | n : float 210 | North edge 211 | zoom : int 212 | Level of detail 213 | source : xyzservices.TileProvider object or str 214 | [Optional. Default: OpenStreetMap Humanitarian web tiles] 215 | The tile source: web tile provider or path to local file. The web tile 216 | provider can be in the form of a :class:`xyzservices.TileProvider` object or a 217 | URL. The placeholders for the XYZ in the URL need to be `{x}`, `{y}`, 218 | `{z}`, respectively. For local file paths, the file is read with 219 | `rasterio` and all bands are loaded into the basemap. 220 | IMPORTANT: tiles are assumed to be in the Spherical Mercator 221 | projection (EPSG:3857), unless the `crs` keyword is specified. 222 | ll : Boolean 223 | [Optional. Default: False] If True, `w`, `s`, `e`, `n` are 224 | assumed to be lon/lat as opposed to Spherical Mercator. 225 | wait : int 226 | [Optional. Default: 0] 227 | if the tile API is rate-limited, the number of seconds to wait 228 | between a failed request and the next try 229 | max_retries: int 230 | [Optional. Default: 2] 231 | total number of rejected requests allowed before contextily 232 | will stop trying to fetch more tiles from a rate-limited API. 233 | n_connections: int 234 | [Optional. Default: 1] 235 | Number of connections for downloading tiles in parallel. Be careful not to overload the tile server and to check 236 | the tile provider's terms of use before increasing this value. E.g., OpenStreetMap has a max. value of 2 237 | (https://operations.osmfoundation.org/policies/tiles/). If allowed to download in parallel, a recommended 238 | value for n_connections is 16, and should never be larger than 64. 239 | use_cache: bool 240 | [Optional. Default: True] 241 | If False, caching of the downloaded tiles will be disabled. This can be useful in resource constrained 242 | environments, especially when using n_connections > 1, or when a tile provider's terms of use don't allow 243 | caching. 244 | zoom_adjust : int or None 245 | [Optional. Default: None] 246 | The amount to adjust a chosen zoom level if it is chosen automatically. 247 | Values outside of -1 to 1 are not recommended as they can lead to slow execution. 248 | 249 | Returns 250 | ------- 251 | img : ndarray 252 | Image as a 3D array of RGB values 253 | extent : tuple 254 | Bounding box [minX, maxX, minY, maxY] of the returned image 255 | """ 256 | if not ll: 257 | # Convert w, s, e, n into lon/lat 258 | w, s = _sm2ll(w, s) 259 | e, n = _sm2ll(e, n) 260 | 261 | # get provider dict given the url 262 | provider = _process_source(source) 263 | # calculate and validate zoom level 264 | auto_zoom = zoom == "auto" 265 | if auto_zoom: 266 | zoom = _calculate_zoom(w, s, e, n) 267 | if zoom_adjust: 268 | zoom += zoom_adjust 269 | zoom = _validate_zoom(zoom, provider, auto=auto_zoom) 270 | # create list of tiles to download 271 | tiles = list(mt.tiles(w, s, e, n, [zoom])) 272 | tile_urls = [provider.build_url(x=tile.x, y=tile.y, z=tile.z) for tile in tiles] 273 | # download tiles 274 | if n_connections < 1 or not isinstance(n_connections, int): 275 | raise ValueError(f"n_connections must be a positive integer value.") 276 | # Use threads for a single connection to avoid the overhead of spawning a process. Use processes for multiple 277 | # connections if caching is enabled, as threads lead to memory issues when used in combination with the joblib 278 | # memory caching (used for the _fetch_tile() function). 279 | preferred_backend = ( 280 | "threads" if (n_connections == 1 or not use_cache) else "processes" 281 | ) 282 | fetch_tile_fn = memory.cache(_fetch_tile) if use_cache else _fetch_tile 283 | arrays = Parallel(n_jobs=n_connections, prefer=preferred_backend)( 284 | delayed(fetch_tile_fn)(tile_url, wait, max_retries) for tile_url in tile_urls 285 | ) 286 | # merge downloaded tiles 287 | merged, extent = _merge_tiles(tiles, arrays) 288 | # lon/lat extent --> Spheric Mercator 289 | west, south, east, north = extent 290 | left, bottom = mt.xy(west, south) 291 | right, top = mt.xy(east, north) 292 | extent = left, right, bottom, top 293 | return merged, extent 294 | 295 | 296 | def _process_source(source): 297 | if source is None: 298 | provider = providers.OpenStreetMap.HOT 299 | elif isinstance(source, str): 300 | provider = TileProvider(url=source, attribution="", name="url") 301 | elif not isinstance(source, dict): 302 | raise TypeError( 303 | "The 'url' needs to be a xyzservices.TileProvider object or string" 304 | ) 305 | elif "url" not in source: 306 | raise ValueError("The 'url' dict should at least contain a 'url' key") 307 | else: 308 | provider = source 309 | return provider 310 | 311 | 312 | def _fetch_tile(tile_url, wait, max_retries): 313 | array = _retryer(tile_url, wait, max_retries) 314 | return array 315 | 316 | 317 | def warp_tiles(img, extent, t_crs="EPSG:4326", resampling=Resampling.bilinear): 318 | """ 319 | Reproject (warp) a Web Mercator basemap into any CRS on-the-fly 320 | 321 | NOTE: this method works well with contextily's `bounds2img` approach to 322 | raster dimensions (h, w, b) 323 | 324 | Parameters 325 | ---------- 326 | img : ndarray 327 | Image as a 3D array (h, w, b) of RGB values (e.g. as 328 | returned from `contextily.bounds2img`) 329 | extent : tuple 330 | Bounding box [minX, maxX, minY, maxY] of the returned image, 331 | expressed in Web Mercator (`EPSG:3857`) 332 | t_crs : str/CRS 333 | [Optional. Default='EPSG:4326'] Target CRS, expressed in any 334 | format permitted by rasterio. Defaults to WGS84 (lon/lat) 335 | resampling : 336 | [Optional. Default=Resampling.bilinear] Resampling method for 337 | executing warping, expressed as a `rasterio.enums.Resampling` 338 | method 339 | 340 | Returns 341 | ------- 342 | img : ndarray 343 | Image as a 3D array (h, w, b) of RGB values (e.g. as 344 | returned from `contextily.bounds2img`) 345 | ext : tuple 346 | Bounding box [minX, maxX, minY, maxY] of the returned (warped) 347 | image 348 | """ 349 | h, w, b = img.shape 350 | # --- https://rasterio.readthedocs.io/en/latest/quickstart.html#opening-a-dataset-in-writing-mode 351 | minX, maxX, minY, maxY = extent 352 | x = np.linspace(minX, maxX, w) 353 | y = np.linspace(minY, maxY, h) 354 | resX = (x[-1] - x[0]) / w 355 | resY = (y[-1] - y[0]) / h 356 | transform = from_origin(x[0] - resX / 2, y[-1] + resY / 2, resX, resY) 357 | # --- 358 | w_img, bounds, _ = _warper( 359 | img.transpose(2, 0, 1), transform, "EPSG:3857", t_crs, resampling 360 | ) 361 | # --- 362 | extent = bounds.left, bounds.right, bounds.bottom, bounds.top 363 | return w_img.transpose(1, 2, 0), extent 364 | 365 | 366 | def warp_img_transform(img, transform, s_crs, t_crs, resampling=Resampling.bilinear): 367 | """ 368 | Reproject (warp) an `img` with a given `transform` and `s_crs` into a 369 | different `t_crs` 370 | 371 | NOTE: this method works well with rasterio's `.read()` approach to 372 | raster's dimensions (b, h, w) 373 | 374 | Parameters 375 | ---------- 376 | img : ndarray 377 | Image as a 3D array (b, h, w) of RGB values (e.g. as 378 | returned from rasterio's `.read()` method) 379 | transform : affine.Affine 380 | Transform of the input image as expressed by `rasterio` and 381 | the `affine` package 382 | s_crs : str/CRS 383 | Source CRS in which `img` is passed, expressed in any format 384 | permitted by rasterio. 385 | t_crs : str/CRS 386 | Target CRS, expressed in any format permitted by rasterio. 387 | resampling : 388 | [Optional. Default=Resampling.bilinear] Resampling method for 389 | executing warping, expressed as a `rasterio.enums.Resampling` 390 | method 391 | 392 | Returns 393 | ------- 394 | w_img : ndarray 395 | Warped image as a 3D array (b, h, w) of RGB values (e.g. as 396 | returned from rasterio's `.read()` method) 397 | w_transform : affine.Affine 398 | Transform of the input image as expressed by `rasterio` and 399 | the `affine` package 400 | """ 401 | w_img, _, w_transform = _warper(img, transform, s_crs, t_crs, resampling) 402 | return w_img, w_transform 403 | 404 | 405 | def _warper(img, transform, s_crs, t_crs, resampling): 406 | """ 407 | Warp an image. Returns the warped image and updated bounds and transform. 408 | """ 409 | b, h, w = img.shape 410 | with MemoryFile() as memfile: 411 | with memfile.open( 412 | driver="GTiff", 413 | height=h, 414 | width=w, 415 | count=b, 416 | dtype=str(img.dtype.name), 417 | crs=s_crs, 418 | transform=transform, 419 | ) as mraster: 420 | mraster.write(img) 421 | 422 | with memfile.open() as mraster: 423 | with WarpedVRT(mraster, crs=t_crs, resampling=resampling) as vrt: 424 | img = vrt.read() 425 | bounds = vrt.bounds 426 | transform = vrt.transform 427 | 428 | return img, bounds, transform 429 | 430 | 431 | def _retryer(tile_url, wait, max_retries): 432 | """ 433 | Retry a url many times in attempt to get a tile and read the image 434 | 435 | Arguments 436 | --------- 437 | tile_url : str 438 | string that is the target of the web request. Should be 439 | a properly-formatted url for a tile provider. 440 | wait : int 441 | if the tile API is rate-limited, the number of seconds to wait 442 | between a failed request and the next try 443 | max_retries : int 444 | total number of rejected requests allowed before contextily 445 | will stop trying to fetch more tiles from a rate-limited API. 446 | 447 | Returns 448 | ------- 449 | array of the tile 450 | """ 451 | try: 452 | request = requests.get(tile_url, headers={"user-agent": USER_AGENT}) 453 | request.raise_for_status() 454 | with io.BytesIO(request.content) as image_stream: 455 | image = Image.open(image_stream).convert("RGBA") 456 | array = np.asarray(image) 457 | image.close() 458 | 459 | return array 460 | 461 | except (requests.HTTPError, UnidentifiedImageError): 462 | if request.status_code == 404: 463 | raise requests.HTTPError( 464 | "Tile URL resulted in a 404 error. " 465 | "Double-check your tile url:\n{}".format(tile_url) 466 | ) 467 | else: 468 | if max_retries > 0: 469 | time.sleep(wait) 470 | max_retries -= 1 471 | request = _retryer(tile_url, wait, max_retries) 472 | else: 473 | raise requests.HTTPError("Connection reset by peer too many times. " 474 | f"Last message was: {request.status_code} " 475 | f"Error: {request.reason} for url: {request.url}") 476 | 477 | def howmany(w, s, e, n, zoom, verbose=True, ll=False): 478 | """ 479 | Number of tiles required for a given bounding box and a zoom level 480 | 481 | Parameters 482 | ---------- 483 | w : float 484 | West edge 485 | s : float 486 | South edge 487 | e : float 488 | East edge 489 | n : float 490 | North edge 491 | zoom : int 492 | Level of detail 493 | verbose : Boolean 494 | [Optional. Default=True] If True, print short message with 495 | number of tiles and zoom. 496 | ll : Boolean 497 | [Optional. Default: False] If True, `w`, `s`, `e`, `n` are 498 | assumed to be lon/lat as opposed to Spherical Mercator. 499 | """ 500 | if not ll: 501 | # Convert w, s, e, n into lon/lat 502 | w, s = _sm2ll(w, s) 503 | e, n = _sm2ll(e, n) 504 | if zoom == "auto": 505 | zoom = _calculate_zoom(w, s, e, n) 506 | tiles = len(list(mt.tiles(w, s, e, n, [zoom]))) 507 | if verbose: 508 | print("Using zoom level %i, this will download %i tiles" % (zoom, tiles)) 509 | return tiles 510 | 511 | 512 | def bb2wdw(bb, rtr): 513 | """ 514 | Convert XY bounding box into the window of the tile raster 515 | 516 | Parameters 517 | ---------- 518 | bb : tuple 519 | (left, bottom, right, top) in the CRS of `rtr` 520 | rtr : RasterReader 521 | Open rasterio raster from which the window will be extracted 522 | 523 | Returns 524 | ------- 525 | window : tuple 526 | ((row_start, row_stop), (col_start, col_stop)) 527 | """ 528 | rbb = rtr.bounds 529 | xi = np.linspace(rbb.left, rbb.right, rtr.shape[1]) 530 | yi = np.linspace(rbb.bottom, rbb.top, rtr.shape[0]) 531 | 532 | window = ( 533 | (rtr.shape[0] - yi.searchsorted(bb[3]), rtr.shape[0] - yi.searchsorted(bb[1])), 534 | (xi.searchsorted(bb[0]), xi.searchsorted(bb[2])), 535 | ) 536 | return window 537 | 538 | 539 | def _sm2ll(x, y): 540 | """ 541 | Transform Spherical Mercator coordinates point into lon/lat 542 | 543 | NOTE: Translated from the JS implementation in 544 | http://dotnetfollower.com/wordpress/2011/07/javascript-how-to-convert-mercator-sphere-coordinates-to-latitude-and-longitude/ 545 | ... 546 | 547 | Arguments 548 | --------- 549 | x : float 550 | Easting 551 | y : float 552 | Northing 553 | 554 | Returns 555 | ------- 556 | ll : tuple 557 | lon/lat coordinates 558 | """ 559 | rMajor = 6378137.0 # Equatorial Radius, QGS84 560 | shift = np.pi * rMajor 561 | lon = x / shift * 180.0 562 | lat = y / shift * 180.0 563 | lat = 180.0 / np.pi * (2.0 * np.arctan(np.exp(lat * np.pi / 180.0)) - np.pi / 2.0) 564 | return lon, lat 565 | 566 | 567 | def _calculate_zoom(w, s, e, n): 568 | """Automatically choose a zoom level given a desired number of tiles. 569 | 570 | .. note:: all values are interpreted as latitude / longitude. 571 | 572 | Parameters 573 | ---------- 574 | w : float 575 | The western bbox edge. 576 | s : float 577 | The southern bbox edge. 578 | e : float 579 | The eastern bbox edge. 580 | n : float 581 | The northern bbox edge. 582 | 583 | Returns 584 | ------- 585 | zoom : int 586 | The zoom level to use in order to download this number of tiles. 587 | """ 588 | # Calculate bounds of the bbox 589 | lon_range = np.sort([e, w])[::-1] 590 | lat_range = np.sort([s, n])[::-1] 591 | 592 | lon_length = np.subtract(*lon_range) 593 | lat_length = np.subtract(*lat_range) 594 | 595 | # Calculate the zoom 596 | zoom_lon = np.ceil(np.log2(360 * 2.0 / lon_length)) 597 | zoom_lat = np.ceil(np.log2(360 * 2.0 / lat_length)) 598 | zoom = np.min([zoom_lon, zoom_lat]) 599 | return int(zoom) 600 | 601 | 602 | def _validate_zoom(zoom, provider, auto=True): 603 | """ 604 | Validate the zoom level and if needed raise informative error message. 605 | Returns the validated zoom. 606 | 607 | Parameters 608 | ---------- 609 | zoom : int 610 | The specified or calculated zoom level 611 | provider : dict 612 | auto : bool 613 | Indicating if zoom was specified or calculated (to have specific 614 | error message for each case). 615 | 616 | Returns 617 | ------- 618 | int 619 | Validated zoom level. 620 | 621 | """ 622 | min_zoom = provider.get("min_zoom", 0) 623 | if "max_zoom" in provider: 624 | max_zoom = provider.get("max_zoom") 625 | max_zoom_known = True 626 | else: 627 | # 22 is known max in existing providers, taking some margin 628 | max_zoom = 30 629 | max_zoom_known = False 630 | 631 | if min_zoom <= zoom <= max_zoom: 632 | return zoom 633 | 634 | mode = "inferred" if auto else "specified" 635 | msg = "The {0} zoom level of {1} is not valid for the current tile provider".format( 636 | mode, zoom 637 | ) 638 | if max_zoom_known: 639 | msg += " (valid zooms: {0} - {1}).".format(min_zoom, max_zoom) 640 | else: 641 | msg += "." 642 | if auto: 643 | # automatically inferred zoom: clip to max zoom if that is known ... 644 | if zoom > max_zoom and max_zoom_known: 645 | warnings.warn(msg) 646 | return max_zoom 647 | # ... otherwise extend the error message with possible reasons 648 | msg += ( 649 | " This can indicate that the extent of your figure is wrong (e.g. too " 650 | "small extent, or in the wrong coordinate reference system)" 651 | ) 652 | raise ValueError(msg) 653 | 654 | 655 | def _merge_tiles(tiles, arrays): 656 | """ 657 | Merge a set of tiles into a single array. 658 | 659 | Parameters 660 | --------- 661 | tiles : list of mercantile.Tile objects 662 | The tiles to merge. 663 | arrays : list of numpy arrays 664 | The corresponding arrays (image pixels) of the tiles. This list 665 | has the same length and order as the `tiles` argument. 666 | 667 | Returns 668 | ------- 669 | img : np.ndarray 670 | Merged arrays. 671 | extent : tuple 672 | Bounding box [west, south, east, north] of the returned image 673 | in long/lat. 674 | """ 675 | # create (n_tiles x 2) array with column for x and y coordinates 676 | tile_xys = np.array([(t.x, t.y) for t in tiles]) 677 | 678 | # get indices starting at zero 679 | indices = tile_xys - tile_xys.min(axis=0) 680 | 681 | # the shape of individual tile images 682 | h, w, d = arrays[0].shape 683 | 684 | # number of rows and columns in the merged tile 685 | n_x, n_y = (indices + 1).max(axis=0) 686 | 687 | # empty merged tiles array to be filled in 688 | img = np.zeros((h * n_y, w * n_x, d), dtype=np.uint8) 689 | 690 | for ind, arr in zip(indices, arrays): 691 | x, y = ind 692 | img[y * h : (y + 1) * h, x * w : (x + 1) * w, :] = arr 693 | 694 | bounds = np.array([mt.bounds(t) for t in tiles]) 695 | west, south, east, north = ( 696 | min(bounds[:, 0]), 697 | min(bounds[:, 1]), 698 | max(bounds[:, 2]), 699 | max(bounds[:, 3]), 700 | ) 701 | 702 | return img, (west, south, east, north) 703 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/css/custom.css: -------------------------------------------------------------------------------- 1 | /* Override some aspects of the pydata-sphinx-theme */ 2 | 3 | :root { 4 | --pst-color-active-navigation: 0, 91, 129; 5 | /* Use normal text color (like h3, ..) instead of primary color */ 6 | --pst-color-h1: var(--pst-color-text-base); 7 | --pst-color-h2: var(--pst-color-text-base); 8 | --pst-header-height: 0px; 9 | } 10 | 11 | @media (min-width: 768px) { 12 | @supports (position: -webkit-sticky) or (position: sticky) { 13 | .bd-sidebar { 14 | padding-top: 3rem; 15 | } 16 | } 17 | } 18 | 19 | /* no pink for code */ 20 | code { 21 | color: #3b444b; 22 | } 23 | 24 | /* Larger font size for sidebar*/ 25 | .bd-sidebar .nav > li > a { 26 | font-size: 1em; 27 | } 28 | 29 | /* New element: brand text instead of logo */ 30 | 31 | /* .navbar-brand-text { 32 | padding: 1rem; 33 | } */ 34 | 35 | a.navbar-brand-text { 36 | color: #333; 37 | font-size: xx-large; 38 | } 39 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | import os 8 | import pathlib 9 | import shutil 10 | import subprocess 11 | 12 | 13 | # -- Path setup -------------------------------------------------------------- 14 | 15 | # If extensions (or modules to document with autodoc) are in another directory, 16 | # add these directories to sys.path here. If the directory is relative to the 17 | # documentation root, use os.path.abspath to make it absolute, like shown here. 18 | # 19 | # import os 20 | import sys 21 | sys.path.insert(0, os.path.abspath("..")) 22 | import contextily # noqa 23 | 24 | 25 | # -- Project information ----------------------------------------------------- 26 | 27 | project = "contextily" 28 | copyright = "2020, Dani Arribas-Bel & Contexily Contributors" 29 | author = "Dani Arribas-Bel & Contexily Contributors" 30 | 31 | # The full version, including alpha/beta/rc tags 32 | release = contextily.__version__ 33 | 34 | 35 | # -- General configuration --------------------------------------------------- 36 | 37 | # Add any Sphinx extension module names here, as strings. They can be 38 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 39 | # ones. 40 | extensions = [ 41 | "sphinx.ext.autodoc", 42 | "numpydoc", 43 | "nbsphinx", 44 | "sphinx.ext.intersphinx", 45 | ] 46 | 47 | # nbsphinx do not use requirejs (breaks bootstrap) 48 | nbsphinx_requirejs_path = "" 49 | 50 | # Add any paths that contain templates here, relative to this directory. 51 | templates_path = ["_templates"] 52 | 53 | # List of patterns, relative to source directory, that match files and 54 | # directories to ignore when looking for source files. 55 | # This pattern also affects html_static_path and html_extra_path. 56 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 57 | 58 | 59 | # -- Options for HTML output ------------------------------------------------- 60 | 61 | # The theme to use for HTML and HTML Help pages. See the documentation for 62 | # a list of builtin themes. 63 | # 64 | html_theme = "sphinx_book_theme" 65 | 66 | # Add any paths that contain custom static files (such as style sheets) here, 67 | # relative to this directory. They are copied after the builtin static files, 68 | # so a file named "default.css" will overwrite the builtin "default.css". 69 | html_static_path = ["_static"] 70 | 71 | html_css_files = [ 72 | "css/custom.css", 73 | ] 74 | 75 | html_theme_options = { 76 | "logo": { 77 | "text": "CONTEXTILY
Context geo tiles in Python", 78 | } 79 | } 80 | 81 | intersphinx_mapping = { 82 | "xyzservices": ("https://xyzservices.readthedocs.io/en/stable/", None), 83 | } 84 | 85 | 86 | # --------------------------------------------------------------------------- 87 | 88 | # Copy notebooks into the docs/ directory so sphinx sees them 89 | 90 | HERE = pathlib.Path(os.path.abspath(os.path.dirname(__file__))) 91 | 92 | 93 | files_to_copy = [ 94 | "notebooks/add_basemap_deepdive.ipynb", 95 | "notebooks/intro_guide.ipynb", 96 | "notebooks/places_guide.ipynb", 97 | "notebooks/providers_deepdive.ipynb", 98 | "notebooks/warping_guide.ipynb", 99 | "notebooks/working_with_local_files.ipynb", 100 | "notebooks/friends_gee.ipynb", 101 | "notebooks/friends_cenpy_osmnx.ipynb", 102 | "tiles.png", 103 | ] 104 | 105 | 106 | for filename in files_to_copy: 107 | shutil.copy(HERE / ".." / filename, HERE) 108 | 109 | 110 | # convert README to rst 111 | 112 | subprocess.check_output(["pandoc", "--to", "rst", "-o", "README.rst", "../README.md"]) 113 | -------------------------------------------------------------------------------- /docs/environment.yml: -------------------------------------------------------------------------------- 1 | name: contextily_docs 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.11 6 | # dependencies 7 | - numpy 8 | - geopy 9 | - matplotlib-base 10 | - mercantile 11 | - pillow 12 | - rasterio 13 | - requests 14 | - joblib 15 | - xyzservices 16 | - geodatasets 17 | # doc dependencies 18 | - sphinx 19 | - numpydoc 20 | - nbsphinx 21 | - pandoc 22 | - ipython 23 | - sphinx-book-theme 24 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. contextily documentation master file, created by 2 | sphinx-quickstart on Tue Apr 7 15:16:59 2020. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | 7 | .. include:: README.rst 8 | 9 | Contents 10 | -------- 11 | 12 | .. toctree:: 13 | :maxdepth: 1 14 | :caption: User Guide 15 | 16 | intro_guide 17 | places_guide 18 | warping_guide 19 | working_with_local_files 20 | providers_deepdive 21 | friends_gee 22 | friends_cenpy_osmnx 23 | 24 | .. toctree:: 25 | :maxdepth: 1 26 | :caption: Reference Guide 27 | 28 | reference 29 | 30 | 31 | Indices and tables 32 | ------------------ 33 | 34 | * :ref:`genindex` 35 | * :ref:`modindex` 36 | * :ref:`search` 37 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | .. _reference: 2 | 3 | Reference Guide 4 | =============== 5 | 6 | Plotting basemaps 7 | ----------------- 8 | 9 | .. autofunction:: contextily.add_basemap 10 | 11 | .. autofunction:: contextily.add_attribution 12 | 13 | 14 | Working with tiles 15 | ------------------ 16 | 17 | .. autofunction:: contextily.bounds2raster 18 | 19 | .. autofunction:: contextily.bounds2img 20 | 21 | .. autofunction:: contextily.warp_tiles 22 | 23 | .. autofunction:: contextily.warp_img_transform 24 | 25 | .. autofunction:: contextily.howmany 26 | 27 | 28 | Geocoding and plotting places 29 | ----------------------------- 30 | 31 | .. autoclass:: contextily.Place 32 | 33 | .. automethod:: contextily.Place.plot 34 | 35 | .. autofunction:: contextily.plot_map 36 | 37 | -------------------------------------------------------------------------------- /examples/plot_map.py: -------------------------------------------------------------------------------- 1 | """ 2 | Downloading and Plotting Maps 3 | ----------------------------- 4 | 5 | Plotting maps with Contextily. 6 | 7 | Contextily is designed to pull map tile information from the web. In many 8 | cases we want to go from a location to a map of that location as quickly 9 | as possible. There are two main ways to do this with Contextily. 10 | 11 | Searching for places with text 12 | ============================== 13 | 14 | The simplest approach is to search for a location with text. You can do 15 | this with the ``Place`` class. This will return an object that contains 16 | metadata about the place, such as its bounding box. It will also contain an 17 | image of the place. 18 | """ 19 | import numpy as np 20 | import matplotlib.pyplot as plt 21 | import contextily as cx 22 | 23 | loc = cx.Place("boulder", zoom_adjust=0) # zoom_adjust modifies the auto-zoom 24 | 25 | # Print some metadata 26 | for attr in ["w", "s", "e", "n", "place", "zoom", "n_tiles"]: 27 | print("{}: {}".format(attr, getattr(loc, attr))) 28 | 29 | # Show the map 30 | im1 = loc.im 31 | 32 | fig, axs = plt.subplots(1, 3, figsize=(15, 5)) 33 | cx.plot_map(loc, ax=axs[0]) 34 | 35 | ############################################################################### 36 | # The zoom level will be chosen for you by default, though you can specify 37 | # this manually as well: 38 | 39 | loc2 = cx.Place("boulder", zoom=11) 40 | cx.plot_map(loc2, ax=axs[1]) 41 | 42 | ############################################################################### 43 | # Downloading tiles from bounds 44 | # ============================= 45 | # 46 | # You can also grab tile information directly from a bounding box + zoom level. 47 | # This is demoed below: 48 | 49 | im2, bbox = cx.bounds2img(loc.w, loc.s, loc.e, loc.n, zoom=loc.zoom, ll=True) 50 | cx.plot_map(im2, bbox, ax=axs[2], title="Boulder, CO") 51 | 52 | plt.show() 53 | -------------------------------------------------------------------------------- /notebooks/add_basemap_deepdive.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# The in's and out's of `add_basemap`" 8 | ] 9 | } 10 | ], 11 | "metadata": { 12 | "kernelspec": { 13 | "display_name": "Python 3", 14 | "language": "python", 15 | "name": "python3" 16 | }, 17 | "language_info": { 18 | "codemirror_mode": { 19 | "name": "ipython", 20 | "version": 3 21 | }, 22 | "file_extension": ".py", 23 | "mimetype": "text/x-python", 24 | "name": "python", 25 | "nbconvert_exporter": "python", 26 | "pygments_lexer": "ipython3", 27 | "version": "3.7.6" 28 | } 29 | }, 30 | "nbformat": 4, 31 | "nbformat_minor": 4 32 | } 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | 7 | [project] 8 | name = "contextily" 9 | dynamic = ["version"] 10 | authors = [ 11 | {name = "Dani Arribas-Bel", email = "daniel.arribas.bel@gmail.com"}, 12 | ] 13 | maintainers = [ 14 | {name = "contextily contributors"}, 15 | ] 16 | license = {text = "3-Clause BSD"} 17 | description = "Context geo-tiles in Python" 18 | readme = "README.md" 19 | classifiers = [ 20 | "License :: OSI Approved :: BSD License", 21 | "Programming Language :: Python :: 3", 22 | "Framework :: Matplotlib", 23 | ] 24 | requires-python = ">=3.9" 25 | dependencies = [ 26 | "geopy", 27 | "matplotlib", 28 | "mercantile", 29 | "pillow", 30 | "rasterio", 31 | "requests", 32 | "joblib", 33 | "xyzservices" 34 | ] 35 | 36 | [project.urls] 37 | Home = "https://github.com/geopandas/contextily" 38 | Repository = "https://github.com/geopandas/contextily" 39 | 40 | [tool.setuptools.packages.find] 41 | include = [ 42 | "contextily", 43 | "contextily.*", 44 | ] 45 | 46 | [tool.coverage.run] 47 | omit = ["tests/*"] -------------------------------------------------------------------------------- /readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | build: 3 | os: ubuntu-22.04 4 | tools: 5 | python: mambaforge-latest 6 | python: 7 | install: 8 | - method: pip 9 | path: . 10 | conda: 11 | environment: docs/environment.yml 12 | formats: [] 13 | sphinx: 14 | # Path to your Sphinx configuration file. 15 | configuration: docs/conf.py -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/contextily/166c91ede28655b1c36369a011cade50b87d11c8/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | def pytest_configure(config): 2 | config.addinivalue_line("markers", 3 | "network: mark tests that use the network.") 4 | -------------------------------------------------------------------------------- /tests/test_cx.py: -------------------------------------------------------------------------------- 1 | import matplotlib 2 | 3 | matplotlib.use("agg") # To prevent plots from using display 4 | import contextily as cx 5 | import os 6 | import numpy as np 7 | import mercantile as mt 8 | import pytest 9 | import rasterio as rio 10 | from contextily.tile import _calculate_zoom 11 | from numpy.testing import assert_array_almost_equal 12 | import pytest 13 | 14 | TOL = 7 15 | SEARCH = "boulder" 16 | ADJUST = -3 # To save download size / time 17 | 18 | # Tile 19 | 20 | 21 | @pytest.mark.network 22 | def test_bounds2raster(): 23 | w, s, e, n = ( 24 | -106.6495132446289, 25 | 25.845197677612305, 26 | -93.50721740722656, 27 | 36.49387741088867, 28 | ) 29 | _ = cx.bounds2raster( 30 | w, s, e, n, "test.tif", zoom=4, ll=True, source=cx.providers.CartoDB.Positron 31 | ) 32 | rtr = rio.open("test.tif") 33 | img = np.array([band for band in rtr.read()]).transpose(1, 2, 0) 34 | solu = ( 35 | -12528334.684053527, 36 | 2509580.5126589066, 37 | -10023646.141204873, 38 | 5014269.05550756, 39 | ) 40 | for i, j in zip(rtr.bounds, solu): 41 | assert round(i - j, TOL) == 0 42 | assert img[0, 100, :].tolist() == [250, 250, 248, 255] 43 | assert img[20, 120, :].tolist() == [139, 153, 164, 255] 44 | assert img[200, 100, :].tolist() == [250, 250, 248, 255] 45 | assert img[:, :, :3].sum() == pytest.approx(47622796, rel=0.1) 46 | assert img.sum() == pytest.approx(64334476, rel=0.1) 47 | assert_array_almost_equal(img[:, :, :3].mean(), 242.2220662434896, decimal=0) 48 | assert_array_almost_equal(img.mean(), 245.4165496826172, decimal=0) 49 | 50 | # multiple tiles for which result is not square 51 | w, s, e, n = ( 52 | 2.5135730322461427, 53 | 49.529483547557504, 54 | 6.15665815595878, 55 | 51.47502370869813, 56 | ) 57 | img, ext = cx.bounds2raster( 58 | w, s, e, n, "test2.tif", zoom=7, ll=True, source=cx.providers.CartoDB.Positron 59 | ) 60 | rtr = rio.open("test2.tif") 61 | rimg = np.array([band for band in rtr.read()]).transpose(1, 2, 0) 62 | assert rimg.shape == img.shape 63 | assert rimg.sum() == img.sum() 64 | assert_array_almost_equal(rimg.mean(), img.mean()) 65 | assert_array_almost_equal( 66 | ext, (0.0, 939258.2035682457, 6261721.35712164, 6887893.492833804) 67 | ) 68 | rtr_bounds = [ 69 | -611.49622628141, 70 | 6262332.853347922, 71 | 938646.7073419644, 72 | 6888504.989060086, 73 | ] 74 | assert_array_almost_equal(list(rtr.bounds), rtr_bounds) 75 | 76 | 77 | @pytest.mark.parametrize("n_connections", [0, 1, 16]) 78 | @pytest.mark.network 79 | def test_bounds2img(n_connections): 80 | w, s, e, n = ( 81 | -106.6495132446289, 82 | 25.845197677612305, 83 | -93.50721740722656, 84 | 36.49387741088867, 85 | ) 86 | if n_connections in [ 87 | 1, 88 | 16, 89 | ]: # valid number of connections (test single and multiple connections) 90 | img, ext = cx.bounds2img( 91 | w, 92 | s, 93 | e, 94 | n, 95 | zoom=4, 96 | ll=True, 97 | n_connections=n_connections, 98 | source=cx.providers.CartoDB.Positron, 99 | ) 100 | solu = ( 101 | -12523442.714243276, 102 | -10018754.171394622, 103 | 2504688.5428486555, 104 | 5009377.085697309, 105 | ) 106 | for i, j in zip(ext, solu): 107 | assert round(i - j, TOL) == 0 108 | assert img[0, 100, :].tolist() == [250, 250, 248, 255] 109 | assert img[20, 120, :].tolist() == [139, 153, 164, 255] 110 | assert img[200, 100, :].tolist() == [250, 250, 248, 255] 111 | elif n_connections == 0: # no connections should raise an error 112 | with pytest.raises(ValueError): 113 | img, ext = cx.bounds2img( 114 | w, s, e, n, zoom=4, ll=True, n_connections=n_connections 115 | ) 116 | 117 | 118 | @pytest.mark.network 119 | def test_warp_tiles(): 120 | w, s, e, n = ( 121 | -106.6495132446289, 122 | 25.845197677612305, 123 | -93.50721740722656, 124 | 36.49387741088867, 125 | ) 126 | img, ext = cx.bounds2img( 127 | w, s, e, n, zoom=4, ll=True, source=cx.providers.CartoDB.Positron 128 | ) 129 | wimg, wext = cx.warp_tiles(img, ext) 130 | assert_array_almost_equal( 131 | np.array(wext), 132 | np.array( 133 | [ 134 | -112.54394531249996, 135 | -90.07903186397023, 136 | 21.966726124122374, 137 | 41.013065787006276, 138 | ] 139 | ), 140 | ) 141 | assert wimg[100, 100, :].tolist() == [249, 249, 247, 255] 142 | assert wimg[100, 200, :].tolist() == [250, 250, 248, 255] 143 | assert wimg[20, 120, :].tolist() == [250, 250, 248, 255] 144 | 145 | 146 | @pytest.mark.network 147 | def test_warp_img_transform(): 148 | w, s, e, n = ( 149 | -106.6495132446289, 150 | 25.845197677612305, 151 | -93.50721740722656, 152 | 36.49387741088867, 153 | ) 154 | _ = cx.bounds2raster( 155 | w, s, e, n, "test.tif", zoom=4, ll=True, source=cx.providers.CartoDB.Positron 156 | ) 157 | rtr = rio.open("test.tif") 158 | img = np.array([band for band in rtr.read()]) 159 | wimg, _ = cx.warp_img_transform(img, rtr.transform, rtr.crs, "epsg:4326") 160 | assert wimg[:, 100, 100].tolist() == [249, 249, 247, 255] 161 | assert wimg[:, 100, 200].tolist() == [250, 250, 248, 255] 162 | assert wimg[:, 20, 120].tolist() == [250, 250, 248, 255] 163 | 164 | 165 | def test_howmany(): 166 | w, s, e, n = ( 167 | -106.6495132446289, 168 | 25.845197677612305, 169 | -93.50721740722656, 170 | 36.49387741088867, 171 | ) 172 | zoom = 7 173 | expected = 25 174 | got = cx.howmany(w, s, e, n, zoom=zoom, verbose=False, ll=True) 175 | assert got == expected 176 | 177 | 178 | @pytest.mark.network 179 | def test_ll2wdw(): 180 | w, s, e, n = ( 181 | -106.6495132446289, 182 | 25.845197677612305, 183 | -93.50721740722656, 184 | 36.49387741088867, 185 | ) 186 | hou = (-10676650.69219051, 3441477.046670125, -10576977.7804825, 3523606.146650609) 187 | _ = cx.bounds2raster(w, s, e, n, "test.tif", zoom=4, ll=True) 188 | rtr = rio.open("test.tif") 189 | wdw = cx.tile.bb2wdw(hou, rtr) 190 | assert wdw == ((152, 161), (189, 199)) 191 | 192 | 193 | def test__sm2ll(): 194 | w, s, e, n = ( 195 | -106.6495132446289, 196 | 25.845197677612305, 197 | -93.50721740722656, 198 | 36.49387741088867, 199 | ) 200 | minX, minY = cx.tile._sm2ll(w, s) 201 | maxX, maxY = cx.tile._sm2ll(e, n) 202 | nw, ns = mt.xy(minX, minY) 203 | ne, nn = mt.xy(maxX, maxY) 204 | assert round(nw - w, TOL) == 0 205 | assert round(ns - s, TOL) == 0 206 | assert round(ne - e, TOL) == 0 207 | assert round(nn - n, TOL) == 0 208 | 209 | 210 | def test_autozoom(): 211 | w, s, e, n = (-105.3014509, 39.9643513, -105.1780988, 40.094409) 212 | expected_zoom = 13 213 | zoom = _calculate_zoom(w, s, e, n) 214 | assert zoom == expected_zoom 215 | 216 | 217 | @pytest.mark.network 218 | def test_validate_zoom(): 219 | # tiny extent to trigger large calculated zoom 220 | w, s, e, n = (0, 0, 0.001, 0.001) 221 | 222 | # automatically inferred -> set to known max but warn 223 | with pytest.warns(UserWarning, match="inferred zoom level"): 224 | cx.bounds2img(w, s, e, n) 225 | 226 | # specify manually -> raise an error 227 | with pytest.raises(ValueError): 228 | cx.bounds2img(w, s, e, n, zoom=23) 229 | 230 | # with specific string url (not dict) -> error when specified 231 | url = "https://a.tile.openstreetmap.org/{z}/{x}/{y}.png" 232 | with pytest.raises(ValueError): 233 | cx.bounds2img(w, s, e, n, zoom=33, source=url) 234 | 235 | # but also when inferred (no max zoom know to set to) 236 | with pytest.raises(ValueError): 237 | cx.bounds2img(w, s, e, n, source=url) 238 | 239 | 240 | # Place 241 | 242 | 243 | @pytest.mark.network 244 | def test_place(): 245 | expected_bbox = [-105.3014509, 39.9569362, -105.1780988, 40.0944658] 246 | expected_bbox_map = [ 247 | -11740727.544603072, 248 | -11701591.786121061, 249 | 4852834.0517692715, 250 | 4891969.810251278, 251 | ] 252 | expected_zoom = 10 253 | loc = cx.Place(SEARCH, zoom_adjust=ADJUST) 254 | assert loc.im.shape == (256, 256, 4) 255 | loc # Make sure repr works 256 | 257 | # Check auto picks are correct 258 | assert loc.search == SEARCH 259 | assert_array_almost_equal([loc.w, loc.s, loc.e, loc.n], expected_bbox) 260 | assert_array_almost_equal(loc.bbox_map, expected_bbox_map) 261 | assert loc.zoom == expected_zoom 262 | 263 | loc = cx.Place(SEARCH, path="./test2.tif", zoom_adjust=ADJUST) 264 | assert os.path.exists("./test2.tif") 265 | 266 | # .plot() method 267 | ax = loc.plot() 268 | assert_array_almost_equal(loc.bbox_map, ax.images[0].get_extent()) 269 | 270 | f, ax = matplotlib.pyplot.subplots(1) 271 | ax = loc.plot(ax=ax) 272 | assert_array_almost_equal(loc.bbox_map, ax.images[0].get_extent()) 273 | 274 | 275 | @pytest.mark.network 276 | def test_plot_map(): 277 | # Place as a search 278 | loc = cx.Place(SEARCH, zoom_adjust=ADJUST) 279 | w, e, s, n = loc.bbox_map 280 | ax = cx.plot_map(loc) 281 | 282 | assert ax.get_title() == loc.place 283 | ax = cx.plot_map(loc.im, loc.bbox) 284 | assert_array_almost_equal(loc.bbox, ax.images[0].get_extent()) 285 | 286 | # Place as an image 287 | img, ext = cx.bounds2img(w, s, e, n, zoom=10) 288 | ax = cx.plot_map(img, ext) 289 | assert_array_almost_equal(ext, ax.images[0].get_extent()) 290 | 291 | 292 | # Plotting 293 | 294 | 295 | @pytest.mark.network 296 | def test_add_basemap(): 297 | # Plot boulder bbox as in test_place 298 | x1, x2, y1, y2 = [ 299 | -11740727.544603072, 300 | -11701591.786121061, 301 | 4852834.0517692715, 302 | 4891969.810251278, 303 | ] 304 | 305 | # Test web basemap 306 | fig, ax = matplotlib.pyplot.subplots(1) 307 | ax.set_xlim(x1, x2) 308 | ax.set_ylim(y1, y2) 309 | cx.add_basemap(ax, zoom=10) 310 | 311 | # ensure add_basemap did not change the axis limits of ax 312 | ax_extent = (x1, x2, y1, y2) 313 | assert ax.axis() == ax_extent 314 | 315 | assert ax.images[0].get_array().sum() == pytest.approx(57095515, rel=0.1) 316 | assert ax.images[0].get_array().shape == (256, 256, 4) 317 | assert_array_almost_equal( 318 | ax.images[0].get_array()[:, :, :3].mean(), 205.4028065999349, decimal=0 319 | ) 320 | assert_array_almost_equal( 321 | ax.images[0].get_array().mean(), 217.80210494995117, decimal=0 322 | ) 323 | 324 | 325 | @pytest.mark.network 326 | def test_add_basemap_local_source(): 327 | # Test local source 328 | ## Windowed read 329 | subset = ( 330 | -11730803.981631357, 331 | -11711668.223149346, 332 | 4862910.488797557, 333 | 4882046.247279563, 334 | ) 335 | 336 | f, ax = matplotlib.pyplot.subplots(1) 337 | ax.set_xlim(subset[0], subset[1]) 338 | ax.set_ylim(subset[2], subset[3]) 339 | _ = cx.Place(SEARCH, path="./test2.tif", zoom_adjust=ADJUST) 340 | cx.add_basemap(ax, source="./test2.tif", reset_extent=True) 341 | 342 | assert_array_almost_equal(subset, ax.images[0].get_extent()) 343 | assert ax.images[0].get_array().sum() == pytest.approx(13758065, rel=0.1) 344 | assert ax.images[0].get_array()[:, :, :3].sum() == pytest.approx(9709685, rel=0.1) 345 | assert ax.images[0].get_array().shape == (126, 126, 4) 346 | assert_array_almost_equal( 347 | ax.images[0].get_array()[:, :, :3].mean(), 203.865058, decimal=0 348 | ) 349 | assert_array_almost_equal(ax.images[0].get_array().mean(), 216.64879377, decimal=0) 350 | 351 | 352 | @pytest.mark.network 353 | def test_add_basemap_query(): 354 | # Plot boulder bbox as in test_place 355 | x1, x2, y1, y2 = [ 356 | -11740727.544603072, 357 | -11701591.786121061, 358 | 4852834.0517692715, 359 | 4891969.810251278, 360 | ] 361 | 362 | # Test web basemap 363 | fig, ax = matplotlib.pyplot.subplots(1) 364 | ax.set_xlim(x1, x2) 365 | ax.set_ylim(y1, y2) 366 | cx.add_basemap(ax, zoom=10, source="cartodb positron") 367 | 368 | # ensure add_basemap did not change the axis limits of ax 369 | ax_extent = (x1, x2, y1, y2) 370 | assert ax.axis() == ax_extent 371 | 372 | assert ax.images[0].get_array().sum() == 64685390 373 | assert ax.images[0].get_array().shape == (256, 256, 4) 374 | assert_array_almost_equal( 375 | ax.images[0].get_array()[:, :, :3].mean(), 244.03656, decimal=0 376 | ) 377 | assert_array_almost_equal(ax.images[0].get_array().mean(), 246.77742, decimal=0) 378 | 379 | 380 | @pytest.mark.network 381 | def test_add_basemap_full_read(): 382 | ## Full read 383 | x1, x2, y1, y2 = [ 384 | -11740727.544603072, 385 | -11701591.786121061, 386 | 4852834.0517692715, 387 | 4891969.810251278, 388 | ] 389 | f, ax = matplotlib.pyplot.subplots(1) 390 | ax.set_xlim(x1, x2) 391 | ax.set_ylim(y1, y2) 392 | loc = cx.Place(SEARCH, path="./test2.tif", zoom_adjust=ADJUST) 393 | cx.add_basemap(ax, source="./test2.tif", reset_extent=False) 394 | 395 | raster_extent = ( 396 | -11740803.981631, 397 | -11701668.223149, 398 | 4852910.488798, 399 | 4892046.24728, 400 | ) 401 | assert_array_almost_equal(raster_extent, ax.images[0].get_extent()) 402 | assert ax.images[0].get_array()[:, :, :3].sum() == pytest.approx(40383835, rel=0.1) 403 | assert ax.images[0].get_array().sum() == pytest.approx(57095515, rel=0.1) 404 | assert ax.images[0].get_array().shape == (256, 256, 4) 405 | assert_array_almost_equal( 406 | ax.images[0].get_array()[:, :, :3].mean(), 205.4028065999, decimal=0 407 | ) 408 | assert_array_almost_equal(ax.images[0].get_array().mean(), 217.8021049, decimal=0) 409 | 410 | 411 | @pytest.mark.network 412 | def test_add_basemap_auto_zoom(): 413 | # Test with auto-zoom 414 | x1, x2, y1, y2 = [ 415 | -11740727.544603072, 416 | -11701591.786121061, 417 | 4852834.0517692715, 418 | 4891969.810251278, 419 | ] 420 | f, ax = matplotlib.pyplot.subplots(1) 421 | ax.set_xlim(x1, x2) 422 | ax.set_ylim(y1, y2) 423 | cx.add_basemap(ax, zoom="auto") 424 | 425 | ax_extent = ( 426 | -11740727.544603072, 427 | -11701591.786121061, 428 | 4852834.051769271, 429 | 4891969.810251278, 430 | ) 431 | assert_array_almost_equal(ax_extent, ax.images[0].get_extent()) 432 | assert ax.images[0].get_array()[:, :, :3].sum() == pytest.approx(160979279, rel=0.1) 433 | assert ax.images[0].get_array().sum() == pytest.approx(227825999, rel=0.1) 434 | assert ax.images[0].get_array().shape == (512, 512, 4) 435 | assert_array_almost_equal( 436 | ax.images[0].get_array()[:, :, :3].mean(), 204.695738, decimal=0 437 | ) 438 | assert_array_almost_equal(ax.images[0].get_array().mean(), 217.2718038, decimal=0) 439 | 440 | 441 | @pytest.mark.network 442 | @pytest.mark.parametrize( 443 | "zoom_adjust, expected_extent, expected_sum_1, expected_sum_2, expected_shape", 444 | [ 445 | # zoom_adjust and expected values where zoom_adjust == 1 446 | ( 447 | 1, 448 | ( 449 | -11740727.544603072, 450 | -11701591.786121061, 451 | 4852834.0517692715, 452 | 4891969.810251278, 453 | ), 454 | 763769618, 455 | 1031156498, 456 | (1024, 1024, 4), 457 | ), 458 | # zoom_adjust and expected values where zoom_adjust == -1 459 | ( 460 | -1, 461 | ( 462 | -11740727.544603072, 463 | -11701591.786121061, 464 | 4852834.0517692715, 465 | 4891969.810251278, 466 | ), 467 | 47973710, 468 | 64685390, 469 | (256, 256, 4), 470 | ), 471 | ], 472 | ) 473 | def test_add_basemap_zoom_adjust( 474 | zoom_adjust, expected_extent, expected_sum_1, expected_sum_2, expected_shape 475 | ): 476 | x1, x2, y1, y2 = [ 477 | -11740727.544603072, 478 | -11701591.786121061, 479 | 4852834.0517692715, 480 | 4891969.810251278, 481 | ] 482 | 483 | f, ax = matplotlib.pyplot.subplots(1) 484 | ax.set_xlim(x1, x2) 485 | ax.set_ylim(y1, y2) 486 | cx.add_basemap( 487 | ax, zoom="auto", zoom_adjust=zoom_adjust, source=cx.providers.CartoDB.Positron 488 | ) 489 | 490 | ax_extent = expected_extent 491 | assert_array_almost_equal(ax_extent, ax.images[0].get_extent()) 492 | 493 | assert ax.images[0].get_array()[:, :, :3].sum() == pytest.approx( 494 | expected_sum_1, rel=0.1 495 | ) 496 | assert ax.images[0].get_array().sum() == pytest.approx(expected_sum_2, rel=0.1) 497 | assert ax.images[0].get_array().shape == expected_shape 498 | assert_array_almost_equal( 499 | ax.images[0].get_array()[:, :, :3].mean(), 242.79582, decimal=0 500 | ) 501 | assert_array_almost_equal(ax.images[0].get_array().mean(), 245.8468, decimal=0) 502 | 503 | 504 | @pytest.mark.network 505 | def test_add_basemap_warping(): 506 | # Test on-th-fly warping 507 | x1, x2 = -105.5, -105.00 508 | y1, y2 = 39.56, 40.13 509 | f, ax = matplotlib.pyplot.subplots(1) 510 | ax.set_xlim(x1, x2) 511 | ax.set_ylim(y1, y2) 512 | cx.add_basemap( 513 | ax, crs="epsg:4326", attribution=None, source=cx.providers.CartoDB.Positron 514 | ) 515 | assert ax.get_xlim() == (x1, x2) 516 | assert ax.get_ylim() == (y1, y2) 517 | assert ax.images[0].get_array()[:, :, :3].sum() == pytest.approx(978096737, rel=0.1) 518 | assert ax.images[0].get_array().shape == (1135, 1183, 4) 519 | assert_array_almost_equal( 520 | ax.images[0].get_array()[:, :, :3].mean(), 242.8174808, decimal=0 521 | ) 522 | assert_array_almost_equal(ax.images[0].get_array().mean(), 245.8631, decimal=0) 523 | 524 | 525 | @pytest.mark.network 526 | def test_add_basemap_warping_local(): 527 | # Test local source warping 528 | x1, x2 = -105.5, -105.00 529 | y1, y2 = 39.56, 40.13 530 | _ = cx.bounds2raster( 531 | x1, y1, x2, y2, "./test2.tif", ll=True, source=cx.providers.CartoDB.Positron 532 | ) 533 | f, ax = matplotlib.pyplot.subplots(1) 534 | ax.set_xlim(x1, x2) 535 | ax.set_ylim(y1, y2) 536 | cx.add_basemap(ax, source="./test2.tif", crs="epsg:4326", attribution=None) 537 | assert ax.get_xlim() == (x1, x2) 538 | assert ax.get_ylim() == (y1, y2) 539 | 540 | assert ax.images[0].get_array()[:, :, :3].sum() == pytest.approx(613344449, rel=0.1) 541 | assert ax.images[0].get_array().shape == (980, 862, 4) 542 | assert_array_almost_equal( 543 | ax.images[0].get_array()[:, :, :3].mean(), 242.0192121, decimal=0 544 | ) 545 | 546 | assert ax.images[0].get_array().sum() == pytest.approx(827789504, rel=0.1) 547 | assert_array_almost_equal(ax.images[0].get_array().mean(), 244.9777167, decimal=0) 548 | 549 | 550 | @pytest.mark.network 551 | def test_add_basemap_overlay(): 552 | x1, x2, y1, y2 = [ 553 | -11740727.544603072, 554 | -11701591.786121061, 555 | 4852834.0517692715, 556 | 4891969.810251278, 557 | ] 558 | fig, ax = matplotlib.pyplot.subplots(1) 559 | ax.set_xlim(x1, x2) 560 | ax.set_ylim(y1, y2) 561 | 562 | # Draw two layers, the 2nd of which is an overlay. 563 | cx.add_basemap(ax, zoom=10) 564 | cx.add_basemap(ax, zoom=10, source=cx.providers.CartoDB.PositronOnlyLabels) 565 | 566 | # ensure add_basemap did not change the axis limits of ax 567 | ax_extent = (x1, x2, y1, y2) 568 | assert ax.axis() == ax_extent 569 | 570 | # check totals on lowest (opaque terrain) base layer 571 | assert_array_almost_equal(ax_extent, ax.images[0].get_extent()) 572 | assert ax.images[0].get_array()[:, :, :3].sum() == pytest.approx(40383835, rel=0.1) 573 | assert ax.images[0].get_array().sum() == pytest.approx(57095515, rel=0.1) 574 | assert ax.images[0].get_array().shape == (256, 256, 4) 575 | assert_array_almost_equal( 576 | ax.images[0].get_array()[:, :, :3].mean(), 205.402806, decimal=0 577 | ) 578 | assert_array_almost_equal(ax.images[0].get_array().mean(), 217.8021049, decimal=0) 579 | 580 | # check totals on overaly (mostly transparent labels) layer 581 | assert ax.images[1].get_array().sum() == pytest.approx(1677372, rel=0.1) 582 | assert ax.images[1].get_array().shape == (256, 256, 4) 583 | assert_array_almost_equal(ax.images[1].get_array().mean(), 6.1157760, decimal=0) 584 | 585 | # create a new map 586 | fig, ax = matplotlib.pyplot.subplots(1) 587 | ax.set_xlim(x1, x2) 588 | ax.set_ylim(y1, y2) 589 | 590 | # Draw two layers, the 1st of which is an overlay. 591 | cx.add_basemap(ax, zoom=10, source=cx.providers.CartoDB.PositronOnlyLabels) 592 | cx.add_basemap(ax, zoom=10) 593 | 594 | # check that z-order of overlay is higher than that of base layer 595 | assert ax.images[0].zorder > ax.images[1].zorder 596 | assert ax.images[0].get_array().sum() == pytest.approx(1677372, rel=0.1) 597 | assert ax.images[1].get_array().sum() == pytest.approx(57095515, rel=0.1) 598 | 599 | 600 | @pytest.mark.network 601 | def test_basemap_attribution(): 602 | extent = (-11945319, -10336026, 2910477, 4438236) 603 | 604 | def get_attr(ax): 605 | return [ 606 | c 607 | for c in ax.get_children() 608 | if isinstance(c, matplotlib.text.Text) and c.get_text() 609 | ] 610 | 611 | # default provider and attribution 612 | fig, ax = matplotlib.pyplot.subplots() 613 | ax.axis(extent) 614 | cx.add_basemap(ax) 615 | (txt,) = get_attr(ax) 616 | assert txt.get_text() == cx.providers.OpenStreetMap.HOT["attribution"] 617 | 618 | # override attribution 619 | fig, ax = matplotlib.pyplot.subplots() 620 | ax.axis(extent) 621 | cx.add_basemap(ax, attribution="custom text") 622 | (txt,) = get_attr(ax) 623 | assert txt.get_text() == "custom text" 624 | 625 | # disable attribution 626 | fig, ax = matplotlib.pyplot.subplots() 627 | ax.axis(extent) 628 | cx.add_basemap(ax, attribution=False) 629 | assert len(get_attr(ax)) == 0 630 | 631 | # specified provider 632 | fig, ax = matplotlib.pyplot.subplots() 633 | ax.axis(extent) 634 | cx.add_basemap(ax, source=cx.providers.OpenStreetMap.Mapnik) 635 | (txt,) = get_attr(ax) 636 | assert txt.get_text() == cx.providers.OpenStreetMap.Mapnik["attribution"] 637 | 638 | 639 | def test_attribution(): 640 | fig, ax = matplotlib.pyplot.subplots(1) 641 | txt = cx.add_attribution(ax, "Test") 642 | assert isinstance(txt, matplotlib.text.Text) 643 | assert txt.get_text() == "Test" 644 | 645 | # test passthrough font size and kwargs 646 | fig, ax = matplotlib.pyplot.subplots(1) 647 | txt = cx.add_attribution(ax, "Test", font_size=15, fontfamily="monospace") 648 | assert txt.get_size() == 15 649 | assert txt.get_fontfamily() == ["monospace"] 650 | 651 | 652 | @pytest.mark.network 653 | def test_set_cache_dir(tmpdir): 654 | # set cache directory manually 655 | path = str(tmpdir.mkdir("cache")) 656 | cx.set_cache_dir(path) 657 | 658 | # then check that plotting still works 659 | extent = (-11945319, -10336026, 2910477, 4438236) 660 | fig, ax = matplotlib.pyplot.subplots() 661 | ax.axis(extent) 662 | cx.add_basemap(ax) 663 | 664 | 665 | @pytest.mark.network 666 | def test_aspect(): 667 | """Test that contextily does not change set aspect""" 668 | # Plot boulder bbox as in test_place 669 | x1, x2, y1, y2 = [ 670 | -11740727.544603072, 671 | -11701591.786121061, 672 | 4852834.0517692715, 673 | 4891969.810251278, 674 | ] 675 | 676 | # Test web basemap 677 | fig, ax = matplotlib.pyplot.subplots(1) 678 | ax.set_xlim(x1, x2) 679 | ax.set_ylim(y1, y2) 680 | ax.set_aspect(2) 681 | cx.add_basemap(ax, zoom=10) 682 | 683 | assert ax.get_aspect() == 2 684 | -------------------------------------------------------------------------------- /tests/test_providers.py: -------------------------------------------------------------------------------- 1 | import contextily as cx 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.network 7 | def test_providers(): 8 | # NOTE: only tests they download, does not check pixel values 9 | w, s, e, n = ( 10 | -106.6495132446289, 11 | 25.845197677612305, 12 | -93.50721740722656, 13 | 36.49387741088867, 14 | ) 15 | for provider in [ 16 | cx.providers.OpenStreetMap.Mapnik, 17 | cx.providers.NASAGIBS.ViirsEarthAtNight2012, 18 | ]: 19 | cx.bounds2img(w, s, e, n, 4, source=provider, ll=True) 20 | 21 | def test_invalid_provider(): 22 | w, s, e, n = (-106.649, 25.845, -93.507, 36.494) 23 | with pytest.raises(ValueError, match="The 'url' dict should at least contain"): 24 | cx.bounds2img(w, s, e, n, 4, source={"missing": "url"}, ll=True) 25 | 26 | 27 | def test_provider_attribute_access(): 28 | provider = cx.providers.OpenStreetMap.Mapnik 29 | assert provider.name == "OpenStreetMap.Mapnik" 30 | with pytest.raises(AttributeError): 31 | provider.non_existing_key 32 | -------------------------------------------------------------------------------- /tiles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geopandas/contextily/166c91ede28655b1c36369a011cade50b87d11c8/tiles.png --------------------------------------------------------------------------------