├── .flake8 ├── .github └── workflows │ ├── python-package.yml │ └── python-publish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.rst ├── LICENSE ├── README.rst ├── doc ├── examples.md └── tilematrix.md ├── pyproject.toml ├── pytest.ini ├── requirements.txt ├── test ├── conftest.py ├── requirements.txt ├── test_cli.py ├── test_dump_load.py ├── test_geometries.py ├── test_grids.py ├── test_helper_funcs.py ├── test_matrix_shapes.py ├── test_tile.py ├── test_tile_shapes.py └── test_tilepyramid.py └── tilematrix ├── __init__.py ├── _conf.py ├── _funcs.py ├── _grid.py ├── _tile.py ├── _tilepyramid.py ├── _types.py └── tmx ├── __init__.py └── main.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | ignore = E203, E266, E501, W503, F403, F401, E741 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 -------------------------------------------------------------------------------- /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python package test 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | test: 14 | runs-on: ${{ matrix.os }} 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | python-version: ["3.7", "3.8", "3.9", "3.10"] 20 | os: ["ubuntu-22.04"] 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v2 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - name: Install dependencies 31 | env: 32 | CURL_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt 33 | run: | 34 | python -m pip install --upgrade pip 35 | pip install -e .[test] 36 | 37 | - name: Lint with flake8 38 | run: | 39 | # stop the build if there are Python syntax errors or undefined names 40 | flake8 tilematrix --count --select=E9,F63,F7,F82 --show-source --statistics 41 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 42 | flake8 tilematrix --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 43 | 44 | - name: Test with pytest 45 | env: 46 | CURL_CA_BUNDLE: /etc/ssl/certs/ca-certificates.crt 47 | run: | 48 | pytest -v --cov tilematrix 49 | 50 | - name: Coveralls 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | run: | 54 | coveralls --service=github -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.x' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install hatch 25 | - name: Build and publish 26 | env: 27 | HATCH_INDEX_USER: ${{ secrets.PYPI_USERNAME }} 28 | HATCH_INDEX_AUTH: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | hatch build 31 | hatch publish 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | dist/ 3 | build/ 4 | testenv/ 5 | *.egg-info 6 | test/testdata/out/* 7 | venv/ 8 | .cache 9 | .eggs 10 | .coverage 11 | htmlcov 12 | .pytest* -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 23.3.0 4 | hooks: 5 | - id: black 6 | language_version: python3 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v1.2.3 9 | hooks: 10 | - id: flake8 11 | - repo: https://github.com/PyCQA/flake8 12 | rev: 6.0.0 13 | hooks: 14 | - id: flake8 15 | - repo: https://github.com/PyCQA/autoflake 16 | rev: v2.1.1 17 | hooks: 18 | - id: autoflake 19 | - repo: https://github.com/pycqa/isort 20 | rev: 5.12.0 21 | hooks: 22 | - id: isort 23 | name: isort (python) 24 | args: ["--profile", "black"] 25 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ######### 2 | Changelog 3 | ######### 4 | 5 | 2024.11.0 - 2024-11-11 6 | ---------------------- 7 | * fix error when getting intersecting tiles from inputs with differing metatiling settings on higher zoom levels (#64) 8 | 9 | 2023.12.0 - 2023-12-15 10 | ---------------------- 11 | * fix grid warning 12 | * add more pre-commit hooks 13 | 14 | 15 | 2022.12.0 - 2022-12-16 16 | ---------------------- 17 | * make fit for `Shapely 2.0` 18 | 19 | 20 | 2022.11.3 - 2022-11-08 21 | ---------------------- 22 | * replace `setuptools` with `hatch` 23 | 24 | 25 | 2022.11.2 - 2022-11-08 26 | ---------------------- 27 | * remove `conda` build files 28 | * `tilematrix-feedstock `_ `conda-forge feedstock` repository for releasing new versions on `conda-forge` 29 | 30 | 31 | 2022.11.1 - 2022-11-03 32 | ---------------------- 33 | * fix `conda` & `pip ` builds 34 | 35 | 36 | 2022.11.0 - 2022-11-03 37 | ---------------------- 38 | * test also for python `3.9` 39 | * renaming `master` branch to `main` 40 | * add ``conda`` publish to github actions (workflows) 41 | 42 | 43 | 2022.3.0 - 2022-03-15 44 | --------------------- 45 | * add option to exactly get intersection tiles using `TilePyramid.tiles_from_geom(exact=True)` 46 | 47 | 48 | 2021.11.0 - 2021-11-12 49 | ---------------------- 50 | * enable yielding tiles in batches by either row or column for following methods: 51 | * `TilePyramid.tiles_from_bounds()` 52 | * `TilePyramid.tiles_from_bbox()` 53 | * `TilePyramid.tiles_from_geom()` 54 | 55 | * convert TilePyramid arguments into keyword arguments 56 | 57 | 58 | 0.21 59 | ---- 60 | * allow metatiling up to 512 61 | * use GitHub actions instead of travis 62 | * use black and flake8 pre-commit checks 63 | 64 | 65 | 0.20 66 | ---- 67 | * fixed pixel size calculation on irregular grids with metatiling (closing #33) 68 | * ``TilePyramid.tile_x_size()``, ``TilePyramid.tile_y_size()``, ``TilePyramid.tile_height()``, ``TilePyramid.tile_width()`` are deprecated 69 | * metatiles are clipped to ``TilePyramid.bounds`` but ``pixelbuffer`` of edge tiles can exceed them unless it is a global grid 70 | 71 | 0.19 72 | ---- 73 | * Python 2 not supported anymore 74 | * ``TilePyramid.srid`` and ``TilePyramid.type`` are deprecated 75 | * ``GridDefinition`` can now be loaded from package root 76 | * ``GridDefinition`` got ``to_dict()`` and ``from_dict()`` methods 77 | 78 | 79 | 0.18 80 | ---- 81 | * order of ``Tile.shape`` swapped to ``(height, width)`` in order to match rasterio array interpretation 82 | 83 | 0.17 84 | ---- 85 | * make ``Tile`` iterable to enable ``tuple(Tile)`` return the tile index as tuple 86 | 87 | 0.16 88 | ---- 89 | * make ``Tile`` objects hashable & comparable 90 | 91 | 0.15 92 | ---- 93 | * add ``snap_bounds()`` function 94 | * add ``snap_bounds`` command to ``tmx`` 95 | * add ``snap_bbox`` command to ``tmx`` 96 | * in ``tile_from_xy()`` add ``on_edge_use`` option specify behavior when point hits grid edges 97 | * cleaned up ``_tiles_from_cleaned_bounds()`` and ``tile_from_xy()`` functions 98 | 99 | ------ 100 | 0.14.2 101 | ------ 102 | * attempt to fix ``tmx`` command when installing tilematrix via pip 103 | 104 | ---- 105 | 0.14 106 | ---- 107 | * add ``tmx`` CLI with subcommands: 108 | * `bounds`: Print bounds of given Tile. 109 | * `bbox`: Print bounding box geometry of given Tile. 110 | * `tile`: Print Tile covering given point. 111 | * `tiles`: Print Tiles covering given bounds. 112 | 113 | ---- 114 | 0.13 115 | ---- 116 | * fixed ``tiles_from_geom()`` bug when passing on a Point (#19) 117 | * add ``tile_from_xy()`` function 118 | 119 | ---- 120 | 0.12 121 | ---- 122 | * added better string representations for ``Tile`` and ``TilePyramid`` 123 | * added ``GridDefinition`` to better handle custom grid parameters 124 | * ``TilePyramid`` instances are now comparable by ``==`` and ``!=`` 125 | 126 | ---- 127 | 0.11 128 | ---- 129 | * custom grid defnitions enabled 130 | 131 | --- 132 | 0.10 133 | --- 134 | * new tag for last version to fix Python 3 build 135 | 136 | --- 137 | 0.9 138 | --- 139 | * added Python 3 support 140 | * use NamedTuple for Tile index 141 | 142 | --- 143 | 0.8 144 | --- 145 | * ``intersecting`` function fixed (rounding error caused return of wrong tiles) 146 | 147 | --- 148 | 0.7 149 | --- 150 | * converted tuples for bounds and shape attributes to namedtuples 151 | 152 | --- 153 | 0.6 154 | --- 155 | * added ``pytest`` and test cases 156 | * fixed metatiling shape error on low zoom levels 157 | * split up code into internal modules 158 | * travis CI and coveralls.io integration 159 | 160 | --- 161 | 0.5 162 | --- 163 | * ``intersection()`` doesn't return invalid tiles. 164 | * Moved copyright to EOX IT Services 165 | 166 | --- 167 | 0.4 168 | --- 169 | * Decision to remove ``MetaTilePyramid`` class (now returns a ``DeprecationWarning``). 170 | * TilePyramid now has its own ``metatiling`` parameter. 171 | * ``intersecting()`` function for ``Tile`` and ``TilePyramid`` to relate between ``TilePyramids`` with different ``metatiling`` settings. 172 | 173 | --- 174 | 0.3 175 | --- 176 | * fixed duplicate tile return in tiles_from_bounds() 177 | * rasterio's CRS() class replaced CRS dict 178 | 179 | --- 180 | 0.2 181 | --- 182 | * introduced handling of antimeridian: 183 | * ``get_neighbor()`` also gets tiles from other side 184 | * ``.shape()`` returns clipped tile shape 185 | * added ``tiles_from_bounds()`` 186 | * added ``clip_geometry_to_srs_bounds()`` 187 | 188 | --- 189 | 0.1 190 | --- 191 | * added Spherical Mercator support 192 | * removed IO module (moved to `mapchete `_) 193 | * removed deprecated ``OutputFormats`` 194 | * introduced ``get_parent()`` and ``get_children()`` functions for ``Tile`` 195 | 196 | ----- 197 | 0.0.4 198 | ----- 199 | * introduced ``Tile`` object 200 | * read_raster_window() is now a generator which returns only a numpy array 201 | * read_vector_window() is a generator which returns a GeoJSON-like object with a geometry clipped to tile boundaries 202 | * proper error handling (removed ``sys.exit(0)``) 203 | 204 | ----- 205 | 0.0.3 206 | ----- 207 | * rewrote io module 208 | * separated and enhanced OutputFormats 209 | 210 | ----- 211 | 0.0.2 212 | ----- 213 | * fixed wrong link to github repository 214 | 215 | ----- 216 | 0.0.1 217 | ----- 218 | * basic functionality 219 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 - 2019 EOX IT Services GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Tilematrix 3 | ========== 4 | 5 | Tilematrix handles geographic web tiles and tile pyramids. 6 | 7 | .. image:: https://badge.fury.io/py/tilematrix.svg 8 | :target: https://badge.fury.io/py/tilematrix 9 | 10 | .. image:: https://travis-ci.org/ungarj/tilematrix.svg?branch=master 11 | :target: https://travis-ci.org/ungarj/tilematrix 12 | 13 | .. image:: https://coveralls.io/repos/github/ungarj/tilematrix/badge.svg?branch=master 14 | :target: https://coveralls.io/github/ungarj/tilematrix?branch=master 15 | 16 | .. image:: https://anaconda.org/conda-forge/tilematrix/badges/version.svg 17 | :target: https://anaconda.org/conda-forge/tilematrix 18 | 19 | .. image:: https://img.shields.io/pypi/pyversions/mapchete.svg 20 | 21 | 22 | The module is designed to translate between tile indices (zoom, row, column = ZYX) and 23 | map coordinates (e.g. latitute, longitude). 24 | 25 | Tilematrix supports **metatiling** and **tile buffers**. Furthermore it makes 26 | heavy use of shapely_ and it can also generate ``Affine`` objects per tile which 27 | facilitates working with rasterio_ for tile based data reading and writing. 28 | 29 | It is very similar to mercantile_ but besides of supporting spherical mercator 30 | tile pyramids, it also supports geodetic (WGS84) tile pyramids. 31 | 32 | .. _shapely: http://toblerity.org/shapely/ 33 | .. _rasterio: https://github.com/mapbox/rasterio 34 | .. _mercantile: https://github.com/mapbox/mercantile 35 | 36 | ------------ 37 | Installation 38 | ------------ 39 | 40 | Use ``conda`` to install the latest stable version: 41 | 42 | .. code-block:: shell 43 | 44 | conda install -c conda-forge -y tilematrix 45 | 46 | Use ``pip`` to install the latest stable version: 47 | 48 | .. code-block:: shell 49 | 50 | pip install tilematrix 51 | 52 | Manually install the latest development version 53 | 54 | .. code-block:: shell 55 | 56 | pip install -r requirements.txt 57 | python setup.py install 58 | 59 | 60 | ------------- 61 | Documentation 62 | ------------- 63 | 64 | * `API documentation `_ 65 | * `examples `_ 66 | 67 | CLI 68 | --- 69 | 70 | This package ships with a command line tool ``tmx`` which provides the following 71 | subcommands: 72 | 73 | * ``bounds``: Print bounds of given Tile. 74 | * ``bbox``: Print bounding box geometry of given Tile. 75 | * ``tile``: Print Tile covering given point. 76 | * ``tiles``: Print Tiles covering given bounds. 77 | 78 | Geometry outputs can either be formatted as ``WKT`` or ``GeoJSON``. For example 79 | the following command will print a valid ``GeoJSON`` representing all tiles 80 | for zoom level 1 of the ``geodetic`` WMTS grid: 81 | 82 | .. code-block:: shell 83 | 84 | $ tmx -f GeoJSON tiles -- 1 -180 -90 180 90 85 | { 86 | "type": "FeatureCollection", 87 | "features": [ 88 | {"geometry": {"coordinates": [[[-90.0, 0.0], [-90.0, 90.0], [-180.0, 90.0], [-180.0, 0.0], [-90.0, 0.0]]], "type": "Polygon"}, "properties": {"col": 0, "row": 0, "zoom": 1}, "type": "Feature"}, 89 | {"geometry": {"coordinates": [[[0.0, 0.0], [0.0, 90.0], [-90.0, 90.0], [-90.0, 0.0], [0.0, 0.0]]], "type": "Polygon"}, "properties": {"col": 1, "row": 0, "zoom": 1}, "type": "Feature"}, 90 | {"geometry": {"coordinates": [[[90.0, 0.0], [90.0, 90.0], [0.0, 90.0], [0.0, 0.0], [90.0, 0.0]]], "type": "Polygon"}, "properties": {"col": 2, "row": 0, "zoom": 1}, "type": "Feature"}, 91 | {"geometry": {"coordinates": [[[180.0, 0.0], [180.0, 90.0], [90.0, 90.0], [90.0, 0.0], [180.0, 0.0]]], "type": "Polygon"}, "properties": {"col": 3, "row": 0, "zoom": 1}, "type": "Feature"}, 92 | {"geometry": {"coordinates": [[[-90.0, -90.0], [-90.0, 0.0], [-180.0, 0.0], [-180.0, -90.0], [-90.0, -90.0]]], "type": "Polygon"}, "properties": {"col": 0, "row": 1, "zoom": 1}, "type": "Feature"}, 93 | {"geometry": {"coordinates": [[[0.0, -90.0], [0.0, 0.0], [-90.0, 0.0], [-90.0, -90.0], [0.0, -90.0]]], "type": "Polygon"}, "properties": {"col": 1, "row": 1, "zoom": 1}, "type": "Feature"}, 94 | {"geometry": {"coordinates": [[[90.0, -90.0], [90.0, 0.0], [0.0, 0.0], [0.0, -90.0], [90.0, -90.0]]], "type": "Polygon"}, "properties": {"col": 2, "row": 1, "zoom": 1}, "type": "Feature"}, 95 | {"geometry": {"coordinates": [[[180.0, -90.0], [180.0, 0.0], [90.0, 0.0], [90.0, -90.0], [180.0, -90.0]]], "type": "Polygon"}, "properties": {"col": 3, "row": 1, "zoom": 1}, "type": "Feature"} 96 | ] 97 | } 98 | 99 | 100 | 101 | Print ``WKT`` representation of tile ``4 15 23``: 102 | 103 | .. code-block:: shell 104 | 105 | $ tmx bbox 4 15 23 106 | POLYGON ((90 -90, 90 -78.75, 78.75 -78.75, 78.75 -90, 90 -90)) 107 | 108 | 109 | Also, tiles can have buffers around called ``pixelbuffer``: 110 | 111 | .. code-block:: shell 112 | 113 | $ tmx --pixelbuffer 10 bbox 4 15 23 114 | POLYGON ((90.439453125 -90, 90.439453125 -78.310546875, 78.310546875 -78.310546875, 78.310546875 -90, 90.439453125 -90)) 115 | 116 | 117 | Print ``GeoJSON`` representation of tile ``4 15 23`` on a ``mercator`` tile 118 | pyramid: 119 | 120 | .. code-block:: shell 121 | 122 | $ tmx -output_format GeoJSON -grid mercator bbox 4 15 15 123 | {"type": "Polygon", "coordinates": [[[20037508.342789203, -20037508.3427892], [20037508.342789203, -17532819.799940553], [17532819.799940553, -17532819.799940553], [17532819.799940553, -20037508.3427892], [20037508.342789203, -20037508.3427892]]]} 124 | 125 | ---------------- 126 | Conda Publishing 127 | ---------------- 128 | 129 | Use bot pull requests generated with every release at tilematrix-feedstock_ repository for releasing new versions on ``conda-forge``. 130 | 131 | 132 | ------- 133 | License 134 | ------- 135 | 136 | MIT License 137 | 138 | Copyright (c) 2015-2022 `EOX IT Services`_ 139 | 140 | .. _`EOX IT Services`: https://eox.at/ 141 | 142 | .. _`tilematrix-feedstock`: https://github.com/conda-forge/tilematrix-feedstock 143 | -------------------------------------------------------------------------------- /doc/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ```python 4 | from tilematrix import TilePyramid 5 | from shapely.geometry import Polygon 6 | 7 | # initialize TilePyramid 8 | tile_pyramid = TilePyramid("geodetic") 9 | 10 | some_polygon = Polygon([(0, 0), (1, 1), (1, 0)]) 11 | zoom = 8 12 | 13 | ``` 14 | 15 | Now, let's get all tile IDs (zoom, row, col) of tiles intersecting with our 16 | example geometry: 17 | 18 | ```python 19 | for tile in tile_pyramid.tiles_from_geom(some_polygon, zoom): 20 | print tile.id 21 | 22 | ``` 23 | 24 | output: 25 | ```python 26 | (8, 126, 256) 27 | (8, 126, 257) 28 | (8, 127, 256) 29 | (8, 127, 257) 30 | 31 | ``` 32 | 33 | Use the ``bbox()`` method to get the tile bounding box geometries: 34 | ```python 35 | for tile in tile_pyramid.tiles_from_geom(some_polygon, zoom): 36 | print tile.bbox() 37 | ``` 38 | 39 | output: 40 | ```python 41 | POLYGON ((0 1.40625, 0.703125 1.40625, 0.703125 0.703125, 0 0.703125, 0 1.40625)) 42 | POLYGON ((0.703125 1.40625, 1.40625 1.40625, 1.40625 0.703125, 0.703125 0.703125, 0.703125 1.40625)) 43 | POLYGON ((0 0.703125, 0.703125 0.703125, 0.703125 0, 0 0, 0 0.703125)) 44 | POLYGON ((0.703125 0.703125, 1.40625 0.703125, 1.40625 0, 0.703125 0, 0.703125 0.703125)) 45 | ``` 46 | 47 | We can also create a buffer around the tiles, using the ``pixelbuffer`` argument. A value of 1 will create a 1 px buffer around the tile. This depends on the initial pixel "resolution" the ``TilePyramid`` was initialized with (default ``256``). 48 | ```python 49 | for tile in tile_pyramid.tiles_from_geom(some_polygon, zoom): 50 | print tile.bbox(pixelbuffer=1) 51 | ``` 52 | 53 | output: 54 | ```python 55 | POLYGON ((-0.00274658203125 1.40899658203125, 0.70587158203125 1.40899658203125, 0.70587158203125 0.70037841796875, -0.00274658203125 0.70037841796875, -0.00274658203125 1.40899658203125)) 56 | POLYGON ((0.70037841796875 1.40899658203125, 1.40899658203125 1.40899658203125, 1.40899658203125 0.70037841796875, 0.70037841796875 0.70037841796875, 0.70037841796875 1.40899658203125)) 57 | POLYGON ((-0.00274658203125 0.70587158203125, 0.70587158203125 0.70587158203125, 0.70587158203125 -0.00274658203125, -0.00274658203125 -0.00274658203125, -0.00274658203125 0.70587158203125)) 58 | POLYGON ((0.70037841796875 0.70587158203125, 1.40899658203125 0.70587158203125, 1.40899658203125 -0.00274658203125, 0.70037841796875 -0.00274658203125, 0.70037841796875 0.70587158203125)) 59 | ``` 60 | 61 | ## Metatiling 62 | 63 | Metatiling is often used to combine smaller ``(256, 256)`` tiles into bigger ones. The metatiling parameter describes how many small tiles are combines into one bigger metatile. For example a metatiling parameter of 2 would combine 2x2 smaller tiles into one metatile. 64 | 65 | You can activate metatiling by initializing a ``TilePyramid`` with a metatiling value. 66 | 67 | ```python 68 | # initialize TilePyramid 69 | tile_pyramid = TilePyramid("geodetic", metatiling=2) 70 | ``` 71 | 72 | As the tiles are now bigger, the code we used above: 73 | ```python 74 | some_polygon = Polygon([(0, 0), (1, 1), (1, 0)]) 75 | zoom = 8 76 | 77 | for tile in tile_pyramid.tiles_from_geom(some_polygon, zoom): 78 | print tile.bbox() 79 | ``` 80 | 81 | now returns just one but bigger metatile: 82 | output: 83 | ```python 84 | POLYGON ((1.40625 0, 1.40625 1.40625, 0 1.40625, 0 0, 1.40625 0)) 85 | ``` 86 | -------------------------------------------------------------------------------- /doc/tilematrix.md: -------------------------------------------------------------------------------- 1 | # tilematrix 2 | 3 | The two base classes are ``TilePyramid`` which defines tile matrices in various zoom levels and its members, the``Tile`` objects. ``TilePyramid`` 4 | 5 | ## TilePyramid 6 | 7 | ```python 8 | TilePyramid(grid_definition, tile_size=256) 9 | ``` 10 | * ``grid_definition``: Either one of the predefined grids (``geodetic`` or 11 | ``mercator``) or a custom grid definition in form of a dictionary. For example: 12 | ```python 13 | { 14 | "shape": (1, 1), 15 | "bounds": (2426378.0132, 1528101.2618, 6293974.6215, 5395697.8701), 16 | "is_global": False, 17 | "epsg": 3035 18 | } 19 | ``` 20 | * ``shape`` (height, width): Indicates the number of tiles per column and row at **zoom level 0**. 21 | * ``bounds`` (left, bottom, right, top): Units are CRS units. 22 | 23 | Please note that the aspect ratios of ``shape`` and ``bounds`` have to be the same. 24 | * Alternatively to ``epsg``, a custom ``proj`` string can be used: 25 | ```python 26 | "proj": """ 27 | +proj=ortho 28 | +lat_0=-90 29 | +lon_0=0 30 | +x_0=0 31 | +y_0=0 32 | +ellps=WGS84 33 | +units=m +no_defs 34 | """ 35 | ``` 36 | * ``is_global``: Indicates whether the grid covers the whole globe or just a region. 37 | 38 | * ``tile_size``: Optional, specifies the target resolution of each tile (i.e. each tile will have 256x256 px). Default is ``256``. 39 | 40 | ### Variables 41 | * ``type``: The projection it was initialized with (``geodetic``, ``mercator`` or ``custom``). 42 | * ``tile_size``: The pixelsize per tile it was initialized with (default ``256``). 43 | * ``left``: Left boundary of tile matrix. 44 | * ``top``: Top boundary of tile matrix. 45 | * ``right ``: Right boundary of tile matrix. 46 | * ``bottom ``: Bottom boundary of tile matrix. 47 | * ``x_size``: Horizontal size of tile matrix in map coordinates. 48 | * ``y_size``: Vertical size of tile matrix in map coordinates. 49 | * ``crs``: ``rasterio.crs.CRS()`` object. 50 | * ``srid``: Spatial reference ID (EPSG code) if available, else ``None``. 51 | 52 | ### Methods 53 | 54 | #### Matrix properties 55 | * ``matrix_width(zoom)``: Returns the number of columns in the current tile matrix. 56 | * ``zoom``: Zoom level. 57 | * ``matrix_height(zoom)``: Returns the number of rows in the current tile matrix. 58 | * ``zoom``: Zoom level. 59 | 60 | #### Geometry 61 | * ``tile_x_size(zoom)``: Returns a float, indicating the width of each tile at this zoom level in ``TilePyramid`` CRS units. 62 | * ``zoom``: Zoom level. 63 | * ``tile_y_size(zoom)``: Returns a floats, indicating the height of each tile at this zoom level in ``TilePyramid`` CRS units. 64 | * ``zoom``: Zoom level. 65 | * ``pixel_x_size(zoom)``: Returns a float, indicating the vertical pixel size in ``TilePyramid`` CRS units. 66 | * ``zoom``: Zoom level. 67 | * ``pixel_y_size(zoom)``: Returns a float, indicating the horizontal pixel size in ``TilePyramid`` CRS units. 68 | * ``zoom``: Zoom level. 69 | * ``top_left_tile_coords(zoom, row, col)``: Returns a tuple of two floats, indicating the top left coordinates of the given tile. 70 | * ``zoom``: Zoom level. 71 | * ``row``: Row in ``TilePyramid``. 72 | * ``col``: Column in ``TilePyramid``. 73 | * ``tile_bounds(zoom, row, col, pixelbuffer=None)``: Returns ``left``, ``bottom``, ``right``, ``top`` coordinates of given tile. 74 | * ``zoom``: Zoom level. 75 | * ``row``: Row in ``TilePyramid``. 76 | * ``col``: Column in ``TilePyramid``. 77 | * ``pixelbuffer``: Optional buffer around tile boundaries in pixels. 78 | * ``tile_bbox(zoom, row, col, pixelbuffer=None)``: Returns the bounding box for given tile as a ``Polygon``. 79 | * ``zoom``: Zoom level. 80 | * ``row``: Row in ``TilePyramid``. 81 | * ``col``: Column in ``TilePyramid``. 82 | * ``pixelbuffer``: Optional buffer around tile boundaries in pixels. 83 | 84 | 85 | #### Tiles 86 | * ``intersecting(tile)``: Return all tiles intersecting with tile. This helps translating between TilePyramids with different metatiling settings. 87 | * ``tile``: ``Tile`` object. 88 | * ``tiles_from_bbox(geometry, zoom)``: Returns tiles intersecting with the given bounding box at given zoom level. 89 | * ``geometry``: Must be a ``Polygon`` object. 90 | * ``zoom``: Zoom level. 91 | * ``tiles_from_geom(geometry, zoom)``: Returns tiles intersecting with the given geometry at given zoom level. 92 | * ``geometry``: Must be one out of ``Polygon``, ``MultiPolygon``, ``LineString``, ``MultiLineString``, ``Point``, ``MultiPoint``. 93 | * ``zoom``: Zoom level. 94 | 95 | 96 | ## Tile 97 | 98 | ```python 99 | Tile(tile_pyramid, zoom, row, col) 100 | ``` 101 | * ``tile_pyramid``: A ``TilePyramid`` this Tile belongs to. 102 | * ``zoom``: Tile zoom level. 103 | * ``row``: Tile row. 104 | * ``col``: Tile column. 105 | 106 | ### Variables 107 | After initializing, the object has the following properties: 108 | * ``tile_pyramid`` or ``tp``: ``TilePyramid`` this Tile belongs to. 109 | * ``crs``: ``rasterio.crs.CRS()`` object. 110 | * ``srid``: Spatial reference ID (EPSG code) if available, else ``None``. 111 | * ``zoom``: Zoom. 112 | * ``row``: Row. 113 | * ``col``: Col. 114 | * ``x_size``: Horizontal size in SRID units. 115 | * ``y_size``: Vertical size in SRID units. 116 | * ``index`` or ``id``: Tuple of ``(zoom, col, row)`` 117 | * ``pixel_x_size``: Horizontal pixel size in SRID units. 118 | * ``pixel_y_size``: Vertical pixel size in SRID units. 119 | * ``left``: Left coordinate. 120 | * ``top``: Top coordinate. 121 | * ``right``: Right coordinate. 122 | * ``bottom``: Bottom coordinate. 123 | * ``width``: Horizontal size in pixels. 124 | * ``height``: Vertical size in pixels. 125 | 126 | ### Methods 127 | 128 | #### Get other Tiles 129 | * ``get_parent()``: Returns parent Tile. 130 | * ``get_children()``: Returns children Tiles. 131 | * ``get_neighbors(connectedness=8)``: Returns a maximum of 8 valid neighbor Tiles. 132 | * ``connectedness``: ``4`` or ``8``. Direct neighbors (up to 4) or corner neighbors (up to 8). 133 | * ``intersecting(TilePyramid)``: Return all tiles from other TilePyramid intersecting with tile. This helps translating between TilePyramids with different metatiling 134 | settings. 135 | * ``tilepyramid``: ``TilePyramid`` object. 136 | 137 | 138 | #### Geometry 139 | * ``bounds(pixelbuffer=0)``: Returns a tuple of ``(left, bottom, right, top)`` coordinates. 140 | * ``pixelbuffer``: Optional buffer around tile boundaries in pixels. 141 | * ``bbox(pixelbuffer=0)``: Returns a ``Polygon`` geometry. 142 | * ``pixelbuffer``: Optional buffer around tile boundaries in pixels. 143 | * ``affine(pixelbuffer=0)``: Returns an [``Affine``](https://github.com/sgillies/affine) object. 144 | * ``pixelbuffer``: Optional buffer around tile boundaries in pixels. 145 | 146 | 147 | 148 | #### Other 149 | * ``shape(pixelbuffer=0)``: Returns a tuple with ``(height, width)``. 150 | * ``pixelbuffer``: Optional buffer around tile boundaries in pixels. 151 | * ``is_valid()``: Returns ``True`` if tile ID is valid in this tile matrix and ``False`` if it isn't. 152 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "tilematrix" 7 | dynamic = ["version"] 8 | description = "helps handling tile pyramids" 9 | readme = "README.rst" 10 | license = "MIT" 11 | authors = [ 12 | { name = "Joachim Ungar", email = "joachim.ungar@gmail.com" }, 13 | ] 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python :: 3.7", 19 | "Programming Language :: Python :: 3.8", 20 | "Programming Language :: Python :: 3.9", 21 | "Programming Language :: Python :: 3.10", 22 | "Topic :: Scientific/Engineering :: GIS", 23 | ] 24 | dependencies = [ 25 | "affine", 26 | "click", 27 | "geojson", 28 | "rasterio>=1.0.21", 29 | "shapely", 30 | ] 31 | 32 | [project.scripts] 33 | tmx = "tilematrix.tmx.main:tmx" 34 | 35 | [project.urls] 36 | Homepage = "https://github.com/ungarj/tilematrix" 37 | 38 | [project.optional-dependencies] 39 | test = [ 40 | "black", 41 | "coveralls", 42 | "flake8", 43 | "pre-commit", 44 | "pytest", 45 | "pytest-cov" 46 | ] 47 | 48 | [tool.hatch.version] 49 | path = "tilematrix/__init__.py" 50 | 51 | [tool.hatch.build.targets.sdist] 52 | include = [ 53 | "/tilematrix", 54 | ] 55 | 56 | [tool.black] 57 | include = '\.pyi?$' 58 | exclude = ''' 59 | /( 60 | \.git 61 | | \.hg 62 | | \.mypy_cache 63 | | \.tox 64 | | \.venv 65 | | _build 66 | | buck-out 67 | | build 68 | | dist 69 | )/ 70 | ''' 71 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --durations 20 --verbose --nf --cov=tilematrix -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | affine 2 | click>=7.1.1 3 | geojson 4 | rasterio>=1.0.21 5 | shapely -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | """Fixtures for test suite.""" 2 | 3 | import pytest 4 | from shapely.geometry import Point, Polygon, shape 5 | 6 | example_proj = """ 7 | +proj=ortho 8 | +lat_0=-90 9 | +lon_0=0 10 | +x_0=0 11 | +y_0=0 12 | +ellps=WGS84 13 | +units=m +no_defs 14 | """ 15 | 16 | 17 | @pytest.fixture 18 | def grid_definition_proj(): 19 | """Custom grid definition using a proj string.""" 20 | return { 21 | "shape": (1, 1), 22 | "bounds": (-4000000.0, -4000000.0, 4000000.0, 4000000.0), 23 | "is_global": False, 24 | "proj": example_proj, 25 | } 26 | 27 | 28 | @pytest.fixture 29 | def grid_definition_epsg(): 30 | """Custom grid definition using an EPSG code.""" 31 | return { 32 | "shape": (1, 1), 33 | "bounds": (2426378.0132, 1528101.2618, 6293974.6215, 5395697.8701), 34 | "is_global": False, 35 | "epsg": 3035, 36 | } 37 | 38 | 39 | @pytest.fixture 40 | def proj(): 41 | return example_proj 42 | 43 | 44 | @pytest.fixture 45 | def wkt(): 46 | return """ 47 | PROJCS["ETRS89 / LAEA Europe", 48 | GEOGCS["ETRS89",DATUM["European_Terrestrial_Reference_System_1989", 49 | SPHEROID["GRS 1980",6378137,298.257222101,AUTHORITY["EPSG","7019"]], 50 | TOWGS84[0,0,0,0,0,0,0], 51 | AUTHORITY["EPSG","6258"]], 52 | PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]], 53 | UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]], 54 | AUTHORITY["EPSG","4258"]], 55 | PROJECTION["Lambert_Azimuthal_Equal_Area"], 56 | PARAMETER["latitude_of_center",52], 57 | PARAMETER["longitude_of_center",10], 58 | PARAMETER["false_easting",4321000], 59 | PARAMETER["false_northing",3210000], 60 | UNIT["metre",1, 61 | AUTHORITY["EPSG","9001"]], 62 | AUTHORITY["EPSG","3035"]] 63 | """ 64 | 65 | 66 | @pytest.fixture 67 | def invalid_geom(): 68 | return Polygon( 69 | [ 70 | (0, 0), 71 | (0, 3), 72 | (3, 3), 73 | (3, 0), 74 | (2, 0), 75 | (2, 2), 76 | (1, 2), 77 | (1, 1), 78 | (2, 1), 79 | (2, 0), 80 | (0, 0), 81 | ] 82 | ) 83 | 84 | 85 | @pytest.fixture 86 | def grid_definition_irregular(): 87 | return { 88 | "shape": [161, 315], 89 | "bounds": [141920, 89840, 948320, 502000], 90 | "is_global": False, 91 | "srs": {"epsg": 31259}, 92 | } 93 | 94 | 95 | @pytest.fixture 96 | def tile_bbox(): 97 | return Polygon( 98 | [ 99 | [-163.125, 73.125], 100 | [-157.5, 73.125], 101 | [-157.5, 67.5], 102 | [-163.125, 67.5], 103 | [-163.125, 73.125], 104 | ] 105 | ) 106 | 107 | 108 | @pytest.fixture 109 | def tile_bbox_buffer(): 110 | return Polygon( 111 | [ 112 | [-163.14697265625, 73.14697265625], 113 | [-157.47802734375, 73.14697265625], 114 | [-157.47802734375, 67.47802734375], 115 | [-163.14697265625, 67.47802734375], 116 | [-163.14697265625, 73.14697265625], 117 | ] 118 | ) 119 | 120 | 121 | @pytest.fixture 122 | def tl_polygon(): 123 | return shape( 124 | { 125 | "type": "Polygon", 126 | "coordinates": ( 127 | ( 128 | (-174.35302734375, 84.35302734375), 129 | (-174.35302734375, 90.0), 130 | (-180.02197265625, 90.0), 131 | (-180.02197265625, 84.35302734375), 132 | (-174.35302734375, 84.35302734375), 133 | ), 134 | ), 135 | } 136 | ) 137 | 138 | 139 | @pytest.fixture 140 | def bl_polygon(): 141 | return shape( 142 | { 143 | "type": "Polygon", 144 | "coordinates": ( 145 | ( 146 | (-174.35302734375, -90.0), 147 | (-174.35302734375, -84.35302734375), 148 | (-180.02197265625, -84.35302734375), 149 | (-180.02197265625, -90.0), 150 | (-174.35302734375, -90.0), 151 | ), 152 | ), 153 | } 154 | ) 155 | 156 | 157 | @pytest.fixture 158 | def overflow_polygon(): 159 | return shape( 160 | { 161 | "type": "Polygon", 162 | "coordinates": ( 163 | ( 164 | (0.703125, -90.0), 165 | (0.703125, 90.0), 166 | (-180.703125, 90.0), 167 | (-180.703125, -90.0), 168 | (0.703125, -90.0), 169 | ), 170 | ), 171 | } 172 | ) 173 | 174 | 175 | @pytest.fixture 176 | def point(): 177 | return Point(16.36, 48.20) 178 | 179 | 180 | @pytest.fixture 181 | def multipoint(): 182 | return shape( 183 | { 184 | "type": "MultiPoint", 185 | "coordinates": [ 186 | (14.464033917048539, 50.08528287347832), 187 | (16.364693096743736, 48.20196113681686), 188 | ], 189 | } 190 | ) 191 | 192 | 193 | @pytest.fixture 194 | def linestring(): 195 | return shape( 196 | { 197 | "type": "LineString", 198 | "coordinates": [ 199 | (8.219788038779399, 48.04680919045518), 200 | (8.553359409223447, 47.98081838641845), 201 | (9.41408206547689, 48.13835399026023), 202 | (10.71989383306024, 48.64871043557477), 203 | (11.683555942439085, 48.794127916044104), 204 | (12.032991977596737, 49.02749868427421), 205 | ], 206 | } 207 | ) 208 | 209 | 210 | @pytest.fixture 211 | def multilinestring(): 212 | return shape( 213 | { 214 | "type": "MultiLineString", 215 | "coordinates": [ 216 | [ 217 | (8.219788038779399, 48.04680919045518), 218 | (8.553359409223447, 47.98081838641845), 219 | (9.41408206547689, 48.13835399026023), 220 | (10.71989383306024, 48.64871043557477), 221 | (11.683555942439085, 48.794127916044104), 222 | (12.032991977596737, 49.02749868427421), 223 | ], 224 | [ 225 | (33.206893344868945, 0.261534735511418), 226 | (33.18725630059802, 0.428191229652711), 227 | (32.8931140479927, 1.31144481038541), 228 | (32.80150465264725, 1.366544806316611), 229 | (32.62475833510098, 1.471712805584616), 230 | (32.51003665541302, 1.536754055177965), 231 | (32.36248752211165, 1.606878973798047), 232 | ], 233 | ], 234 | } 235 | ) 236 | 237 | 238 | @pytest.fixture 239 | def polygon(): 240 | return shape( 241 | { 242 | "type": "Polygon", 243 | "coordinates": [ 244 | [ 245 | (8.219788038779399, 48.04680919045518), 246 | (8.553359409223447, 47.98081838641845), 247 | (9.41408206547689, 48.13835399026023), 248 | (10.71989383306024, 48.64871043557477), 249 | (11.683555942439085, 48.794127916044104), 250 | (12.032991977596737, 49.02749868427421), 251 | (8.219788038779399, 48.04680919045518), 252 | ] 253 | ], 254 | } 255 | ) 256 | 257 | 258 | @pytest.fixture 259 | def multipolygon(): 260 | return shape( 261 | { 262 | "type": "MultiPolygon", 263 | "coordinates": [ 264 | [ 265 | [ 266 | (8.219788038779399, 48.04680919045518), 267 | (8.553359409223447, 47.98081838641845), 268 | (9.41408206547689, 48.13835399026023), 269 | (10.71989383306024, 48.64871043557477), 270 | (11.683555942439085, 48.794127916044104), 271 | (12.032991977596737, 49.02749868427421), 272 | ] 273 | ], 274 | [ 275 | [ 276 | (33.206893344868945, 0.261534735511418), 277 | (33.18725630059802, 0.428191229652711), 278 | (32.8931140479927, 1.31144481038541), 279 | (32.80150465264725, 1.366544806316611), 280 | (32.62475833510098, 1.471712805584616), 281 | (32.51003665541302, 1.536754055177965), 282 | (32.36248752211165, 1.606878973798047), 283 | ] 284 | ], 285 | ], 286 | } 287 | ) 288 | 289 | 290 | @pytest.fixture 291 | def tile_bounds_polygon(): 292 | return shape( 293 | { 294 | "type": "Polygon", 295 | "coordinates": [ 296 | [(0, 0), (0, 45), (22.5, 45), (22.5, 22.5), (45, 22.5), (45, 0), (0, 0)] 297 | ], 298 | } 299 | ) 300 | -------------------------------------------------------------------------------- /test/requirements.txt: -------------------------------------------------------------------------------- 1 | black 2 | coveralls 3 | flake8 4 | pre-commit 5 | pytest 6 | pytest-cov 7 | -------------------------------------------------------------------------------- /test/test_cli.py: -------------------------------------------------------------------------------- 1 | import geojson 2 | from click.testing import CliRunner 3 | from shapely import wkt 4 | from shapely.geometry import shape 5 | 6 | from tilematrix import TilePyramid, __version__ 7 | from tilematrix.tmx.main import tmx 8 | 9 | 10 | def test_version(): 11 | result = CliRunner().invoke(tmx, ["--version"]) 12 | assert result.exit_code == 0 13 | assert __version__ in result.output 14 | 15 | 16 | def test_bounds(): 17 | zoom, row, col = 12, 0, 0 18 | for grid in ["geodetic", "mercator"]: 19 | _run_bbox_bounds(zoom, row, col, "bounds", grid=grid) 20 | for metatiling in [1, 2, 4, 8, 16]: 21 | _run_bbox_bounds(zoom, row, col, "bounds", metatiling=metatiling) 22 | for pixelbuffer in [0, 1, 10]: 23 | _run_bbox_bounds(zoom, row, col, "bounds", pixelbuffer=pixelbuffer) 24 | for tile_size in [256, 512, 1024]: 25 | _run_bbox_bounds(zoom, row, col, "bounds", tile_size=tile_size) 26 | 27 | 28 | def test_bbox(): 29 | zoom, row, col = 12, 0, 0 30 | for grid in ["geodetic", "mercator"]: 31 | _run_bbox_bounds(zoom, row, col, "bbox", grid=grid) 32 | for metatiling in [1, 2, 4, 8, 16]: 33 | _run_bbox_bounds(zoom, row, col, "bbox", metatiling=metatiling) 34 | for pixelbuffer in [0, 1, 10]: 35 | _run_bbox_bounds(zoom, row, col, "bbox", pixelbuffer=pixelbuffer) 36 | for tile_size in [256, 512, 1024]: 37 | _run_bbox_bounds(zoom, row, col, "bbox", tile_size=tile_size) 38 | for output_format in ["WKT", "GeoJSON"]: 39 | _run_bbox_bounds(zoom, row, col, "bbox", output_format=output_format) 40 | 41 | 42 | def test_tile(): 43 | point = [10, 20] 44 | zoom = 5 45 | for grid in ["geodetic", "mercator"]: 46 | _run_tile(zoom, point, grid=grid) 47 | for metatiling in [1, 2, 4, 8, 16]: 48 | _run_tile(zoom, point, metatiling=metatiling) 49 | for pixelbuffer in [0, 1, 10]: 50 | _run_tile(zoom, point, pixelbuffer=pixelbuffer) 51 | for tile_size in [256, 512, 1024]: 52 | _run_tile(zoom, point, tile_size=tile_size) 53 | for output_format in ["Tile", "WKT", "GeoJSON"]: 54 | _run_tile(zoom, point, output_format=output_format) 55 | 56 | 57 | def test_tiles(): 58 | bounds = (10, 20, 30, 40) 59 | zoom = 6 60 | for grid in ["geodetic", "mercator"]: 61 | _run_tiles(zoom, bounds, grid=grid) 62 | for metatiling in [1, 2, 4, 8, 16]: 63 | _run_tiles(zoom, bounds, metatiling=metatiling) 64 | for pixelbuffer in [0, 1, 10]: 65 | _run_tiles(zoom, bounds, pixelbuffer=pixelbuffer) 66 | for tile_size in [256, 512, 1024]: 67 | _run_tiles(zoom, bounds, tile_size=tile_size) 68 | for output_format in ["Tile", "WKT", "GeoJSON"]: 69 | _run_tiles(zoom, bounds, output_format=output_format) 70 | 71 | 72 | def test_snap_bounds(): 73 | zoom = 6 74 | bounds = (10, 20, 30, 40) 75 | for grid in ["geodetic", "mercator"]: 76 | _run_snap_bounds(zoom, bounds, "snap-bounds", grid=grid) 77 | for metatiling in [1, 2, 4, 8, 16]: 78 | _run_snap_bounds(zoom, bounds, "snap-bounds", metatiling=metatiling) 79 | for pixelbuffer in [0, 1, 10]: 80 | _run_snap_bounds(zoom, bounds, "snap-bounds", pixelbuffer=pixelbuffer) 81 | for tile_size in [256, 512, 1024]: 82 | _run_snap_bounds(zoom, bounds, "snap-bounds", tile_size=tile_size) 83 | 84 | 85 | def test_snap_bbox(): 86 | zoom = 6 87 | bounds = (10, 20, 30, 40) 88 | for grid in ["geodetic", "mercator"]: 89 | _run_snap_bounds(zoom, bounds, "snap-bbox", grid=grid) 90 | for metatiling in [1, 2, 4, 8, 16]: 91 | _run_snap_bounds(zoom, bounds, "snap-bbox", metatiling=metatiling) 92 | for pixelbuffer in [0, 1, 10]: 93 | _run_snap_bounds(zoom, bounds, "snap-bbox", pixelbuffer=pixelbuffer) 94 | for tile_size in [256, 512, 1024]: 95 | _run_snap_bounds(zoom, bounds, "snap-bbox", tile_size=tile_size) 96 | 97 | 98 | def _run_bbox_bounds( 99 | zoom, 100 | row, 101 | col, 102 | command=None, 103 | grid="geodetic", 104 | metatiling=1, 105 | pixelbuffer=0, 106 | tile_size=256, 107 | output_format="WKT", 108 | ): 109 | tile = TilePyramid(grid, metatiling=metatiling, tile_size=tile_size).tile( 110 | zoom, row, col 111 | ) 112 | result = CliRunner().invoke( 113 | tmx, 114 | [ 115 | "--pixelbuffer", 116 | str(pixelbuffer), 117 | "--metatiling", 118 | str(metatiling), 119 | "--grid", 120 | grid, 121 | "--tile_size", 122 | str(tile_size), 123 | "--output_format", 124 | output_format, 125 | command, 126 | str(zoom), 127 | str(row), 128 | str(col), 129 | ], 130 | ) 131 | assert result.exit_code == 0 132 | if command == "bounds": 133 | assert result.output.strip() == " ".join(map(str, tile.bounds(pixelbuffer))) 134 | elif output_format == "WKT": 135 | assert wkt.loads(result.output.strip()).almost_equals(tile.bbox(pixelbuffer)) 136 | elif output_format == "GeoJSON": 137 | assert shape(geojson.loads(result.output.strip())).almost_equals( 138 | tile.bbox(pixelbuffer) 139 | ) 140 | 141 | 142 | def _run_tile( 143 | zoom, 144 | point, 145 | grid="geodetic", 146 | metatiling=1, 147 | pixelbuffer=0, 148 | tile_size=256, 149 | output_format="WKT", 150 | ): 151 | ( 152 | x, 153 | y, 154 | ) = point 155 | tile = TilePyramid(grid, metatiling=metatiling, tile_size=tile_size).tile_from_xy( 156 | x, y, zoom 157 | ) 158 | result = CliRunner().invoke( 159 | tmx, 160 | [ 161 | "--pixelbuffer", 162 | str(pixelbuffer), 163 | "--metatiling", 164 | str(metatiling), 165 | "--grid", 166 | grid, 167 | "--tile_size", 168 | str(tile_size), 169 | "--output_format", 170 | output_format, 171 | "tile", 172 | str(zoom), 173 | str(x), 174 | str(y), 175 | ], 176 | ) 177 | assert result.exit_code == 0 178 | if output_format == "Tile": 179 | assert result.output.strip() == " ".join(map(str, tile.id)) 180 | elif output_format == "WKT": 181 | assert wkt.loads(result.output.strip()).almost_equals(tile.bbox(pixelbuffer)) 182 | elif output_format == "GeoJSON": 183 | feature = geojson.loads(result.output.strip())["features"][0] 184 | assert shape(feature["geometry"]).almost_equals(tile.bbox(pixelbuffer)) 185 | 186 | 187 | def _run_tiles( 188 | zoom, 189 | bounds, 190 | grid="geodetic", 191 | metatiling=1, 192 | pixelbuffer=0, 193 | tile_size=256, 194 | output_format="WKT", 195 | ): 196 | left, bottom, right, top = bounds 197 | tiles = list( 198 | TilePyramid(grid, metatiling=metatiling, tile_size=tile_size).tiles_from_bounds( 199 | bounds, zoom 200 | ) 201 | ) 202 | result = CliRunner().invoke( 203 | tmx, 204 | [ 205 | "--pixelbuffer", 206 | str(pixelbuffer), 207 | "--metatiling", 208 | str(metatiling), 209 | "--grid", 210 | grid, 211 | "--tile_size", 212 | str(tile_size), 213 | "--output_format", 214 | output_format, 215 | "tiles", 216 | str(zoom), 217 | str(left), 218 | str(bottom), 219 | str(right), 220 | str(top), 221 | ], 222 | ) 223 | assert result.exit_code == 0 224 | if output_format == "Tile": 225 | assert result.output.count("\n") == len(tiles) 226 | elif output_format == "WKT": 227 | assert result.output.count("\n") == len(tiles) 228 | elif output_format == "GeoJSON": 229 | features = geojson.loads(result.output.strip())["features"] 230 | assert len(features) == len(tiles) 231 | 232 | 233 | def _run_snap_bounds( 234 | zoom, 235 | bounds, 236 | command=None, 237 | grid="geodetic", 238 | metatiling=1, 239 | pixelbuffer=0, 240 | tile_size=256, 241 | ): 242 | left, bottom, right, top = bounds 243 | result = CliRunner().invoke( 244 | tmx, 245 | [ 246 | "--pixelbuffer", 247 | str(pixelbuffer), 248 | "--metatiling", 249 | str(metatiling), 250 | "--grid", 251 | grid, 252 | "--tile_size", 253 | str(tile_size), 254 | command, 255 | str(zoom), 256 | str(left), 257 | str(bottom), 258 | str(right), 259 | str(top), 260 | ], 261 | ) 262 | assert result.exit_code == 0 263 | -------------------------------------------------------------------------------- /test/test_dump_load.py: -------------------------------------------------------------------------------- 1 | from tilematrix import TilePyramid 2 | 3 | 4 | def test_geodetic(): 5 | tp = TilePyramid("geodetic", metatiling=2) 6 | tp_dict = tp.to_dict() 7 | assert isinstance(tp_dict, dict) 8 | tp2 = TilePyramid.from_dict(tp_dict) 9 | assert tp == tp2 10 | 11 | 12 | def test_mercator(): 13 | tp = TilePyramid("mercator", metatiling=4) 14 | tp_dict = tp.to_dict() 15 | assert isinstance(tp_dict, dict) 16 | tp2 = TilePyramid.from_dict(tp_dict) 17 | assert tp == tp2 18 | 19 | 20 | def test_custom(grid_definition_proj, grid_definition_epsg): 21 | for grid_def in [grid_definition_proj, grid_definition_epsg]: 22 | tp = TilePyramid(grid_def, metatiling=8) 23 | tp_dict = tp.to_dict() 24 | assert isinstance(tp_dict, dict) 25 | tp2 = TilePyramid.from_dict(tp_dict) 26 | assert tp == tp2 27 | -------------------------------------------------------------------------------- /test/test_geometries.py: -------------------------------------------------------------------------------- 1 | """Tile geometries and tiles from geometries.""" 2 | 3 | from types import GeneratorType 4 | 5 | import pytest 6 | from shapely.geometry import Point, Polygon, shape 7 | 8 | from tilematrix import Tile, TilePyramid 9 | 10 | 11 | def test_top_left_coord(): 12 | """Top left coordinate.""" 13 | tp = TilePyramid("geodetic") 14 | tile = tp.tile(5, 3, 3) 15 | assert (tile.left, tile.top) == (-163.125, 73.125) 16 | 17 | 18 | def test_tile_bbox(tile_bbox): 19 | """Tile bounding box.""" 20 | tp = TilePyramid("geodetic") 21 | tile = tp.tile(5, 3, 3) 22 | assert tile.bbox().equals(tile_bbox) 23 | 24 | 25 | def test_tile_bbox_buffer(tile_bbox_buffer, tl_polygon, bl_polygon, overflow_polygon): 26 | """Tile bounding box with buffer.""" 27 | # default 28 | tp = TilePyramid("geodetic") 29 | tile = tp.tile(5, 3, 3) 30 | assert tile.bbox(1).equals(tile_bbox_buffer) 31 | 32 | # first row of tilematrix 33 | tile = tp.tile(5, 0, 0) 34 | assert tile.bbox(1) == tl_polygon 35 | 36 | # last row of tilematrix 37 | tile = tp.tile(5, 31, 0) 38 | assert tile.bbox(1) == bl_polygon 39 | 40 | # overflowing all tilepyramid bounds 41 | tile = tp.tile(0, 0, 0) 42 | assert tile.bbox(1) == overflow_polygon 43 | 44 | 45 | def test_tile_bounds(): 46 | """Tile bounds.""" 47 | tp = TilePyramid("geodetic") 48 | tile = tp.tile(5, 3, 3) 49 | assert tile.bounds() == (-163.125, 67.5, -157.5, 73.125) 50 | 51 | 52 | def test_tile_bounds_buffer(): 53 | """Tile bounds with buffer.""" 54 | tp = TilePyramid("geodetic") 55 | # default 56 | tile = tp.tile(5, 3, 3) 57 | testbounds = (-163.14697265625, 67.47802734375, -157.47802734375, 73.14697265625) 58 | assert tile.bounds(1) == testbounds 59 | 60 | # first row of tilematrix 61 | tile = tp.tile(5, 0, 0) 62 | testbounds = (-180.02197265625, 84.35302734375, -174.35302734375, 90.0) 63 | assert tile.bounds(1) == testbounds 64 | 65 | # last row of tilematrix 66 | tile = tp.tile(5, 31, 0) 67 | testbounds = (-180.02197265625, -90.0, -174.35302734375, -84.35302734375) 68 | assert tile.bounds(1) == testbounds 69 | 70 | # overflowing all tilepyramid bounds 71 | tile = tp.tile(0, 0, 0) 72 | testbounds = (-180.703125, -90.0, 0.703125, 90.0) 73 | assert tile.bounds(1) == testbounds 74 | 75 | 76 | def test_tiles_from_bounds(): 77 | """Get tiles intersecting with given bounds.""" 78 | tp = TilePyramid("geodetic") 79 | # valid bounds 80 | bounds = (-163.125, 67.5, -157.5, 73.125) 81 | control_tiles = {(5, 3, 3)} 82 | test_tiles = {tile.id for tile in tp.tiles_from_bounds(bounds, 5)} 83 | assert control_tiles == test_tiles 84 | # invalid bounds 85 | try: 86 | {tile.id for tile in tp.tiles_from_bounds((3, 5), 5)} 87 | raise Exception() 88 | except ValueError: 89 | pass 90 | # cross the antimeridian on the western side 91 | bounds = (-183.125, 67.5, -177.5, 73.125) 92 | control_tiles = {(5, 3, 0), (5, 3, 63)} 93 | test_tiles = {tile.id for tile in tp.tiles_from_bounds(bounds, 5)} 94 | assert control_tiles == test_tiles 95 | # cross the antimeridian on the eastern side 96 | bounds = (177.5, 67.5, 183.125, 73.125) 97 | control_tiles = {(5, 3, 0), (5, 3, 63)} 98 | test_tiles = {tile.id for tile in tp.tiles_from_bounds(bounds, 5)} 99 | assert control_tiles == test_tiles 100 | # cross the antimeridian on both sudes 101 | bounds = (-183, 67.5, 183.125, 73.125) 102 | control_tiles = { 103 | (3, 0, 0), 104 | (3, 0, 1), 105 | (3, 0, 2), 106 | (3, 0, 3), 107 | (3, 0, 4), 108 | (3, 0, 5), 109 | (3, 0, 6), 110 | (3, 0, 7), 111 | (3, 0, 8), 112 | (3, 0, 9), 113 | (3, 0, 10), 114 | (3, 0, 11), 115 | (3, 0, 12), 116 | (3, 0, 13), 117 | (3, 0, 14), 118 | (3, 0, 15), 119 | } 120 | test_tiles = {tile.id for tile in tp.tiles_from_bounds(bounds, 3)} 121 | assert control_tiles == test_tiles 122 | 123 | 124 | def test_tiles_from_bbox(): 125 | """Get tiles intersecting with bounding box.""" 126 | test_bbox = shape( 127 | { 128 | "type": "Polygon", 129 | "coordinates": [ 130 | [ 131 | (5.625, 61.875), 132 | (56.25, 61.875), 133 | (56.25, 28.125), 134 | (5.625, 28.125), 135 | (5.625, 28.125), 136 | (5.625, 61.875), 137 | ] 138 | ], 139 | } 140 | ) 141 | test_tiles = { 142 | (5, 5, 33), 143 | (5, 6, 33), 144 | (5, 7, 33), 145 | (5, 8, 33), 146 | (5, 9, 33), 147 | (5, 10, 33), 148 | (5, 5, 34), 149 | (5, 6, 34), 150 | (5, 7, 34), 151 | (5, 8, 34), 152 | (5, 9, 34), 153 | (5, 10, 34), 154 | (5, 5, 35), 155 | (5, 6, 35), 156 | (5, 7, 35), 157 | (5, 8, 35), 158 | (5, 9, 35), 159 | (5, 10, 35), 160 | (5, 5, 36), 161 | (5, 6, 36), 162 | (5, 7, 36), 163 | (5, 8, 36), 164 | (5, 9, 36), 165 | (5, 10, 36), 166 | (5, 5, 37), 167 | (5, 6, 37), 168 | (5, 7, 37), 169 | (5, 8, 37), 170 | (5, 9, 37), 171 | (5, 10, 37), 172 | (5, 5, 38), 173 | (5, 6, 38), 174 | (5, 7, 38), 175 | (5, 8, 38), 176 | (5, 9, 38), 177 | (5, 10, 38), 178 | (5, 5, 39), 179 | (5, 6, 39), 180 | (5, 7, 39), 181 | (5, 8, 39), 182 | (5, 9, 39), 183 | (5, 10, 39), 184 | (5, 5, 40), 185 | (5, 6, 40), 186 | (5, 7, 40), 187 | (5, 8, 40), 188 | (5, 9, 40), 189 | (5, 10, 40), 190 | (5, 5, 41), 191 | (5, 6, 41), 192 | (5, 7, 41), 193 | (5, 8, 41), 194 | (5, 9, 41), 195 | (5, 10, 41), 196 | } 197 | tp = TilePyramid("geodetic") 198 | bbox_tiles = {tile.id for tile in tp.tiles_from_bbox(test_bbox, 5)} 199 | assert test_tiles == bbox_tiles 200 | 201 | 202 | def test_tiles_from_empty_geom(): 203 | """Get tiles from empty geometry.""" 204 | test_geom = Polygon() 205 | tp = TilePyramid("geodetic") 206 | empty_tiles = {tile.id for tile in tp.tiles_from_geom(test_geom, 6)} 207 | assert empty_tiles == set([]) 208 | 209 | 210 | def test_tiles_from_invalid_geom(invalid_geom): 211 | """Get tiles from empty geometry.""" 212 | tp = TilePyramid("geodetic") 213 | with pytest.raises(ValueError): 214 | list(tp.tiles_from_geom(invalid_geom, 6)) 215 | 216 | 217 | def test_tiles_from_point(point): 218 | """Get tile from point.""" 219 | for metatiling in [1, 2, 4, 8, 16]: 220 | tp = TilePyramid("geodetic", metatiling=metatiling) 221 | tile_bbox = next(tp.tiles_from_geom(point, 6)).bbox() 222 | assert point.within(tile_bbox) 223 | tp = TilePyramid("geodetic") 224 | with pytest.raises(ValueError): 225 | next(tp.tiles_from_geom(Point(-300, 100), 6)) 226 | 227 | 228 | def test_tiles_from_multipoint(multipoint): 229 | """Get tiles from multiple points.""" 230 | test_tiles = {(9, 113, 553), (9, 118, 558)} 231 | tp = TilePyramid("geodetic") 232 | multipoint_tiles = {tile.id for tile in tp.tiles_from_geom(multipoint, 9)} 233 | assert multipoint_tiles == test_tiles 234 | 235 | 236 | def test_tiles_from_linestring(linestring): 237 | """Get tiles from LineString.""" 238 | test_tiles = { 239 | (8, 58, 270), 240 | (8, 58, 271), 241 | (8, 58, 272), 242 | (8, 58, 273), 243 | (8, 59, 267), 244 | (8, 59, 268), 245 | (8, 59, 269), 246 | (8, 59, 270), 247 | } 248 | tp = TilePyramid("geodetic") 249 | linestring_tiles = {tile.id for tile in tp.tiles_from_geom(linestring, 8)} 250 | assert linestring_tiles == test_tiles 251 | 252 | 253 | def test_tiles_from_multilinestring(multilinestring): 254 | """Get tiles from MultiLineString.""" 255 | test_tiles = { 256 | (8, 58, 270), 257 | (8, 58, 271), 258 | (8, 58, 272), 259 | (8, 58, 273), 260 | (8, 59, 267), 261 | (8, 59, 268), 262 | (8, 59, 269), 263 | (8, 59, 270), 264 | (8, 125, 302), 265 | (8, 126, 302), 266 | (8, 126, 303), 267 | (8, 127, 303), 268 | } 269 | tp = TilePyramid("geodetic") 270 | multilinestring_tiles = {tile.id for tile in tp.tiles_from_geom(multilinestring, 8)} 271 | assert multilinestring_tiles == test_tiles 272 | 273 | 274 | def test_tiles_from_polygon(polygon): 275 | """Get tiles from Polygon.""" 276 | test_tiles = { 277 | (9, 116, 544), 278 | (9, 116, 545), 279 | (9, 116, 546), 280 | (9, 117, 540), 281 | (9, 117, 541), 282 | (9, 117, 542), 283 | (9, 117, 543), 284 | (9, 117, 544), 285 | (9, 117, 545), 286 | (9, 118, 536), 287 | (9, 118, 537), 288 | (9, 118, 538), 289 | (9, 118, 539), 290 | (9, 118, 540), 291 | (9, 118, 541), 292 | (9, 119, 535), 293 | (9, 119, 536), 294 | (9, 119, 537), 295 | (9, 119, 538), 296 | } 297 | tp = TilePyramid("geodetic") 298 | polygon_tiles = {tile.id for tile in tp.tiles_from_geom(polygon, 9)} 299 | assert polygon_tiles == test_tiles 300 | 301 | 302 | def test_tiles_from_multipolygon(multipolygon): 303 | """Get tiles from MultiPolygon.""" 304 | test_tiles = { 305 | (9, 116, 544), 306 | (9, 116, 545), 307 | (9, 116, 546), 308 | (9, 117, 540), 309 | (9, 117, 541), 310 | (9, 117, 542), 311 | (9, 117, 543), 312 | (9, 117, 544), 313 | (9, 117, 545), 314 | (9, 118, 536), 315 | (9, 118, 537), 316 | (9, 118, 538), 317 | (9, 118, 539), 318 | (9, 118, 540), 319 | (9, 118, 541), 320 | (9, 119, 535), 321 | (9, 119, 536), 322 | (9, 119, 537), 323 | (9, 119, 538), 324 | (9, 251, 604), 325 | (9, 251, 605), 326 | (9, 252, 604), 327 | (9, 252, 605), 328 | (9, 253, 605), 329 | (9, 253, 606), 330 | (9, 254, 605), 331 | (9, 254, 606), 332 | (9, 255, 606), 333 | } 334 | tp = TilePyramid("geodetic") 335 | multipolygon_tiles = {tile.id for tile in tp.tiles_from_geom(multipolygon, 9)} 336 | assert multipolygon_tiles == test_tiles 337 | 338 | 339 | def test_tiles_from_point_batches(point): 340 | """Get tile from point.""" 341 | tp = TilePyramid("geodetic") 342 | zoom = 9 343 | tiles = 0 344 | gen = tp.tiles_from_geom(point, zoom, batch_by="row") 345 | assert isinstance(gen, GeneratorType) 346 | for row in gen: 347 | assert isinstance(row, GeneratorType) 348 | for tile in row: 349 | tiles += 1 350 | assert isinstance(tile, Tile) 351 | assert tiles 352 | assert tiles == len(list(tp.tiles_from_geom(point, zoom))) 353 | 354 | 355 | def test_tiles_from_multipoint_batches(multipoint): 356 | """Get tiles from multiple points.""" 357 | tp = TilePyramid("geodetic") 358 | zoom = 9 359 | tiles = 0 360 | gen = tp.tiles_from_geom(multipoint, zoom, batch_by="row") 361 | assert isinstance(gen, GeneratorType) 362 | for row in gen: 363 | assert isinstance(row, GeneratorType) 364 | for tile in row: 365 | tiles += 1 366 | assert isinstance(tile, Tile) 367 | assert tiles 368 | assert tiles == len(list(tp.tiles_from_geom(multipoint, zoom))) 369 | 370 | 371 | def test_tiles_from_linestring_batches(linestring): 372 | """Get tiles from LineString.""" 373 | tp = TilePyramid("geodetic") 374 | zoom = 9 375 | tiles = 0 376 | gen = tp.tiles_from_geom(linestring, zoom, batch_by="row") 377 | assert isinstance(gen, GeneratorType) 378 | for row in gen: 379 | assert isinstance(row, GeneratorType) 380 | for tile in row: 381 | tiles += 1 382 | assert isinstance(tile, Tile) 383 | assert tiles 384 | assert tiles == len(list(tp.tiles_from_geom(linestring, zoom))) 385 | 386 | 387 | def test_tiles_from_multilinestring_batches(multilinestring): 388 | """Get tiles from MultiLineString.""" 389 | tp = TilePyramid("geodetic") 390 | zoom = 9 391 | tiles = 0 392 | gen = tp.tiles_from_geom(multilinestring, zoom, batch_by="row") 393 | assert isinstance(gen, GeneratorType) 394 | for row in gen: 395 | assert isinstance(row, GeneratorType) 396 | for tile in row: 397 | tiles += 1 398 | assert isinstance(tile, Tile) 399 | assert tiles 400 | assert tiles == len(list(tp.tiles_from_geom(multilinestring, zoom))) 401 | 402 | 403 | def test_tiles_from_polygon_batches(polygon): 404 | """Get tiles from Polygon.""" 405 | tp = TilePyramid("geodetic") 406 | zoom = 9 407 | tiles = 0 408 | gen = tp.tiles_from_geom(polygon, zoom, batch_by="row") 409 | assert isinstance(gen, GeneratorType) 410 | for row in gen: 411 | assert isinstance(row, GeneratorType) 412 | for tile in row: 413 | tiles += 1 414 | assert isinstance(tile, Tile) 415 | assert tiles 416 | assert tiles == len(list(tp.tiles_from_geom(polygon, zoom))) 417 | 418 | 419 | def test_tiles_from_multipolygon_batches(multipolygon): 420 | """Get tiles from MultiPolygon.""" 421 | tp = TilePyramid("geodetic") 422 | zoom = 9 423 | tiles = 0 424 | gen = tp.tiles_from_geom(multipolygon, zoom, batch_by="row") 425 | assert isinstance(gen, GeneratorType) 426 | for row in gen: 427 | assert isinstance(row, GeneratorType) 428 | for tile in row: 429 | tiles += 1 430 | assert isinstance(tile, Tile) 431 | assert tiles 432 | assert tiles == len(list(tp.tiles_from_geom(multipolygon, zoom))) 433 | -------------------------------------------------------------------------------- /test/test_grids.py: -------------------------------------------------------------------------------- 1 | """TilePyramid creation.""" 2 | 3 | import math 4 | 5 | import pytest 6 | from shapely.geometry import box 7 | 8 | from tilematrix import PYRAMID_PARAMS, GridDefinition, TilePyramid 9 | 10 | 11 | def test_grid_init(grid_definition_proj): 12 | grid = GridDefinition(**dict(PYRAMID_PARAMS["geodetic"], grid="custom")) 13 | custom_grid = TilePyramid(grid_definition_proj).grid 14 | # make sure standard grid gets detected 15 | assert grid.type == "geodetic" 16 | # create grid from grid 17 | assert GridDefinition(grid) 18 | # create grid from dict 19 | assert GridDefinition.from_dict(grid.to_dict()) 20 | # __repr__ 21 | assert str(grid) 22 | assert str(custom_grid) 23 | # __hash__ 24 | assert hash(grid) 25 | assert hash(custom_grid) 26 | 27 | 28 | def test_deprecated(): 29 | grid = TilePyramid("geodetic").grid 30 | assert grid.srid 31 | 32 | 33 | def test_proj_str(grid_definition_proj): 34 | """Initialize with proj string.""" 35 | tp = TilePyramid(grid_definition_proj) 36 | assert tp.tile(0, 0, 0).bounds() == grid_definition_proj["bounds"] 37 | 38 | 39 | def test_epsg_code(grid_definition_epsg): 40 | """Initialize with EPSG code.""" 41 | tp = TilePyramid(grid_definition_epsg) 42 | assert tp.tile(0, 0, 0).bounds() == grid_definition_epsg["bounds"] 43 | 44 | 45 | def test_shape_error(grid_definition_epsg): 46 | """Raise error when shape aspect ratio is not bounds apsect ratio.""" 47 | grid_definition_epsg.update( 48 | bounds=(2426378.0132, 1528101.2618, 6293974.6215, 5446513.5222) 49 | ) 50 | with pytest.raises(ValueError): 51 | TilePyramid(grid_definition_epsg) 52 | 53 | 54 | def test_neighbors(grid_definition_epsg): 55 | """Initialize with EPSG code.""" 56 | tp = TilePyramid(grid_definition_epsg) 57 | neighbor_ids = set([t.id for t in tp.tile(1, 0, 0).get_neighbors()]) 58 | control_ids = set([(1, 1, 0), (1, 0, 1), (1, 1, 1)]) 59 | assert len(neighbor_ids.symmetric_difference(control_ids)) == 0 60 | 61 | 62 | def test_irregular_grids(grid_definition_irregular): 63 | for metatiling in [1, 2, 4, 8]: 64 | for pixelbuffer in [0, 100]: 65 | tp = TilePyramid(grid_definition_irregular, metatiling=metatiling) 66 | assert tp.matrix_height(0) == math.ceil(161 / metatiling) 67 | assert tp.matrix_width(0) == math.ceil(315 / metatiling) 68 | assert tp.pixel_x_size(0) == tp.pixel_y_size(0) 69 | for tile in [ 70 | tp.tile(0, 0, 0), 71 | tp.tile(0, 10, 0), 72 | tp.tile(0, tp.matrix_height(0) - 1, tp.matrix_width(0) - 1), 73 | ]: 74 | # pixel sizes have to be squares 75 | assert tile.pixel_x_size == tile.pixel_y_size 76 | assert tile.pixel_x_size == 10.0 77 | # pixelbuffer yields different shape and bounds 78 | assert tile.shape(10) != tile.shape() 79 | assert tile.bounds(10) != tile.bounds() 80 | # without pixelbuffers, tile bounds have to be inside TilePyramid bounds 81 | assert tile.left >= tp.left 82 | assert tile.bottom >= tp.bottom 83 | assert tile.right <= tp.right 84 | assert tile.top <= tp.top 85 | 86 | # with pixelbuffers, some tile bounds have to be outside TilePyramid bounds 87 | if pixelbuffer: 88 | # tile on top left corner 89 | tile = tp.tile(0, 0, 0) 90 | assert tile.bounds(pixelbuffer).left < tp.left 91 | assert tile.bounds(pixelbuffer).top > tp.top 92 | 93 | # tile on lower right corner 94 | tile = tp.tile(0, tp.matrix_height(0) - 1, tp.matrix_width(0) - 1) 95 | print(tp) 96 | assert tile.bounds(pixelbuffer).bottom < tp.bottom 97 | assert tile.bounds(pixelbuffer).right > tp.right 98 | 99 | 100 | def test_tiles_from_bounds(grid_definition_irregular): 101 | bounds = (755336.179, 300068.615, 791558.022, 319499.955) 102 | bbox = box(*bounds) 103 | tp = TilePyramid(grid_definition_irregular, metatiling=4) 104 | tiles_bounds = list(tp.tiles_from_bounds(bounds, 0)) 105 | tiles_bbox = list(tp.tiles_from_bbox(bbox, 0)) 106 | tiles_geom = list(tp.tiles_from_geom(bbox, 0)) 107 | 108 | assert set(tiles_bounds) == set(tiles_bbox) == set(tiles_geom) 109 | -------------------------------------------------------------------------------- /test/test_helper_funcs.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from rasterio.crs import CRS 3 | from shapely.geometry import box 4 | 5 | from tilematrix import TilePyramid, clip_geometry_to_srs_bounds, validate_zoom 6 | from tilematrix._funcs import _get_crs, _verify_shape_bounds 7 | 8 | 9 | def test_antimeridian_clip(invalid_geom): 10 | """Clip on antimeridian.""" 11 | tp = TilePyramid("geodetic") 12 | tp_bounds = box(tp.left, tp.bottom, tp.right, tp.top) 13 | 14 | # extends on the western side 15 | geometry = box(-183.125, 67.5, -177.5, 73.125) 16 | # get GeometryCollection 17 | out_geom = clip_geometry_to_srs_bounds(geometry, tp) 18 | assert out_geom.geom_type == "GeometryCollection" 19 | # get list 20 | out_geom = clip_geometry_to_srs_bounds(geometry, tp, multipart=True) 21 | assert isinstance(out_geom, list) 22 | for sub_geom in out_geom: 23 | assert sub_geom.within(tp_bounds) 24 | 25 | # extends on the eastern side 26 | geometry = box(177.5, 67.5, 183.125, 73.125) 27 | # get GeometryCollection 28 | out_geom = clip_geometry_to_srs_bounds(geometry, tp) 29 | assert out_geom.geom_type == "GeometryCollection" 30 | # get list 31 | out_geom = clip_geometry_to_srs_bounds(geometry, tp, multipart=True) 32 | assert isinstance(out_geom, list) 33 | for sub_geom in out_geom: 34 | assert sub_geom.within(tp_bounds) 35 | 36 | # extends on both sides 37 | geometry = box(-183.125, 67.5, 183.125, 73.125) 38 | # get GeometryCollection 39 | out_geom = clip_geometry_to_srs_bounds(geometry, tp) 40 | assert out_geom.geom_type == "GeometryCollection" 41 | # get list 42 | out_geom = clip_geometry_to_srs_bounds(geometry, tp, multipart=True) 43 | assert isinstance(out_geom, list) 44 | for sub_geom in out_geom: 45 | assert sub_geom.within(tp_bounds) 46 | assert len(out_geom) == 3 47 | 48 | # fail on invalid geometry 49 | with pytest.raises(ValueError): 50 | clip_geometry_to_srs_bounds(invalid_geom, tp) 51 | 52 | 53 | def test_no_clip(): 54 | """Geometry is within TilePyramid bounds.""" 55 | tp = TilePyramid("geodetic") 56 | geometry = box(177.5, 67.5, -177.5, 73.125) 57 | 58 | # no multipart 59 | out_geom = clip_geometry_to_srs_bounds(geometry, tp) 60 | assert geometry == out_geom 61 | 62 | # multipart 63 | out_geom = clip_geometry_to_srs_bounds(geometry, tp, multipart=True) 64 | assert isinstance(out_geom, list) 65 | assert len(out_geom) == 1 66 | assert geometry == out_geom[0] 67 | 68 | 69 | def test_validate_zoom(): 70 | with pytest.raises(TypeError): 71 | validate_zoom(5.0) 72 | with pytest.raises(ValueError): 73 | validate_zoom(-1) 74 | 75 | 76 | def test_verify_shape_bounds(): 77 | # invalid shape 78 | with pytest.raises(TypeError): 79 | _verify_shape_bounds((1, 2, 3), (1, 2, 3, 4)) 80 | # invalid bounds 81 | with pytest.raises(TypeError): 82 | _verify_shape_bounds((1, 2), (1, 2, 3, 4, 5)) 83 | 84 | _verify_shape_bounds((2, 1), (1, 2, 3, 6)) 85 | 86 | 87 | def test_get_crs(proj, wkt): 88 | # no dictionary 89 | with pytest.raises(TypeError): 90 | _get_crs("no dict") 91 | # valid WKT 92 | assert isinstance(_get_crs(dict(wkt=wkt)), CRS) 93 | # valid EPSG 94 | assert isinstance(_get_crs(dict(epsg=3857)), CRS) 95 | # valid PROJ 96 | assert isinstance(_get_crs(dict(proj=proj)), CRS) 97 | # none of above 98 | with pytest.raises(TypeError): 99 | _get_crs(dict(something_else=None)) 100 | -------------------------------------------------------------------------------- /test/test_matrix_shapes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Test tile matrix shapes.""" 3 | 4 | from tilematrix import TilePyramid 5 | 6 | 7 | def test_geodetic_matrix_shapes(): 8 | """Test shapes of geodetic tile matrices.""" 9 | # 1 metatiling 10 | matrix_shapes = { 11 | 0: (2, 1), 12 | 1: (4, 2), 13 | 2: (8, 4), 14 | 3: (16, 8), 15 | 4: (32, 16), 16 | 5: (64, 32), 17 | 6: (128, 64), 18 | } 19 | tp = TilePyramid("geodetic") 20 | for zoom, shape in matrix_shapes.items(): 21 | assert (tp.matrix_width(zoom), tp.matrix_height(zoom)) == shape 22 | 23 | # 2 metatiling 24 | matrix_shapes = { 25 | 0: (1, 1), 26 | 1: (2, 1), 27 | 2: (4, 2), 28 | 3: (8, 4), 29 | 4: (16, 8), 30 | 5: (32, 16), 31 | 6: (64, 32), 32 | } 33 | tp = TilePyramid("geodetic", metatiling=2) 34 | for zoom, shape in matrix_shapes.items(): 35 | assert (tp.matrix_width(zoom), tp.matrix_height(zoom)) == shape 36 | 37 | # 4 metatiling 38 | matrix_shapes = { 39 | 0: (1, 1), 40 | 1: (1, 1), 41 | 2: (2, 1), 42 | 3: (4, 2), 43 | 4: (8, 4), 44 | 5: (16, 8), 45 | 6: (32, 16), 46 | } 47 | tp = TilePyramid("geodetic", metatiling=4) 48 | for zoom, shape in matrix_shapes.items(): 49 | assert (tp.matrix_width(zoom), tp.matrix_height(zoom)) == shape 50 | 51 | # 8 metatiling 52 | matrix_shapes = { 53 | 0: (1, 1), 54 | 1: (1, 1), 55 | 2: (1, 1), 56 | 3: (2, 1), 57 | 4: (4, 2), 58 | 5: (8, 4), 59 | 6: (16, 8), 60 | } 61 | tp = TilePyramid("geodetic", metatiling=8) 62 | for zoom, shape in matrix_shapes.items(): 63 | assert (tp.matrix_width(zoom), tp.matrix_height(zoom)) == shape 64 | 65 | # 16 metatiling 66 | matrix_shapes = { 67 | 0: (1, 1), 68 | 1: (1, 1), 69 | 2: (1, 1), 70 | 3: (1, 1), 71 | 4: (2, 1), 72 | 5: (4, 2), 73 | 6: (8, 4), 74 | } 75 | tp = TilePyramid("geodetic", metatiling=16) 76 | for zoom, shape in matrix_shapes.items(): 77 | assert (tp.matrix_width(zoom), tp.matrix_height(zoom)) == shape 78 | 79 | 80 | def test_mercator_matrix_shapes(): 81 | """Test shapes of mercator tile matrices.""" 82 | # 1 metatiling 83 | matrix_shapes = { 84 | 0: (1, 1), 85 | 1: (2, 2), 86 | 2: (4, 4), 87 | 3: (8, 8), 88 | 4: (16, 16), 89 | 5: (32, 32), 90 | 6: (64, 64), 91 | } 92 | tp = TilePyramid("mercator") 93 | for zoom, shape in matrix_shapes.items(): 94 | assert (tp.matrix_width(zoom), tp.matrix_height(zoom)) == shape 95 | 96 | # 2 metatiling 97 | matrix_shapes = { 98 | 0: (1, 1), 99 | 1: (1, 1), 100 | 2: (2, 2), 101 | 3: (4, 4), 102 | 4: (8, 8), 103 | 5: (16, 16), 104 | 6: (32, 32), 105 | } 106 | tp = TilePyramid("mercator", metatiling=2) 107 | for zoom, shape in matrix_shapes.items(): 108 | assert (tp.matrix_width(zoom), tp.matrix_height(zoom)) == shape 109 | 110 | # 4 metatiling 111 | matrix_shapes = { 112 | 0: (1, 1), 113 | 1: (1, 1), 114 | 2: (1, 1), 115 | 3: (2, 2), 116 | 4: (4, 4), 117 | 5: (8, 8), 118 | 6: (16, 16), 119 | } 120 | tp = TilePyramid("mercator", metatiling=4) 121 | for zoom, shape in matrix_shapes.items(): 122 | assert (tp.matrix_width(zoom), tp.matrix_height(zoom)) == shape 123 | 124 | # 8 metatiling 125 | matrix_shapes = { 126 | 0: (1, 1), 127 | 1: (1, 1), 128 | 2: (1, 1), 129 | 3: (1, 1), 130 | 4: (2, 2), 131 | 5: (4, 4), 132 | 6: (8, 8), 133 | } 134 | tp = TilePyramid("mercator", metatiling=8) 135 | for zoom, shape in matrix_shapes.items(): 136 | assert (tp.matrix_width(zoom), tp.matrix_height(zoom)) == shape 137 | 138 | # 16 metatiling 139 | matrix_shapes = { 140 | 0: (1, 1), 141 | 1: (1, 1), 142 | 2: (1, 1), 143 | 3: (1, 1), 144 | 4: (1, 1), 145 | 5: (2, 2), 146 | 6: (4, 4), 147 | } 148 | tp = TilePyramid("mercator", metatiling=16) 149 | for zoom, shape in matrix_shapes.items(): 150 | assert (tp.matrix_width(zoom), tp.matrix_height(zoom)) == shape 151 | -------------------------------------------------------------------------------- /test/test_tile.py: -------------------------------------------------------------------------------- 1 | """Tile properties.""" 2 | 3 | import pytest 4 | from affine import Affine 5 | 6 | from tilematrix import TilePyramid 7 | 8 | 9 | def test_affine(): 10 | """Affine output.""" 11 | tp = TilePyramid("geodetic") 12 | test_tiles = [(0, 0, 0), (1, 1, 1), (2, 2, 2)] 13 | for tile_id in test_tiles: 14 | tile = tp.tile(*tile_id) 15 | test_affine = Affine( 16 | tile.pixel_x_size, 0, tile.left, 0, -tile.pixel_y_size, tile.top 17 | ) 18 | assert tile.affine() == test_affine 19 | # include pixelbuffer 20 | pixelbuffer = 10 21 | test_tiles = [(1, 1, 1), (2, 2, 2), (3, 3, 3)] 22 | for tile_id in test_tiles: 23 | tile = tp.tile(*tile_id) 24 | test_affine = Affine( 25 | tile.pixel_x_size, 26 | 0, 27 | tile.bounds(pixelbuffer).left, 28 | 0, 29 | -tile.pixel_y_size, 30 | tile.bounds(pixelbuffer).top, 31 | ) 32 | assert tile.affine(10) == test_affine 33 | 34 | 35 | def test_get_parent(): 36 | """Get parent Tile.""" 37 | tp = TilePyramid("geodetic") 38 | # default 39 | tile = tp.tile(8, 100, 100) 40 | assert tile.get_parent().id == (7, 50, 50) 41 | # from top of pyramid 42 | tile = tp.tile(0, 0, 0) 43 | assert tile.get_parent() is None 44 | 45 | 46 | def test_get_children(): 47 | """Get Tile children.""" 48 | # no metatiling 49 | tp = TilePyramid("geodetic") 50 | tile = tp.tile(8, 100, 100) 51 | test_children = {(9, 200, 200), (9, 201, 200), (9, 200, 201), (9, 201, 201)} 52 | children = {t.id for t in tile.get_children()} 53 | assert test_children == children 54 | 55 | # 2 metatiling 56 | tp = TilePyramid("geodetic", metatiling=2) 57 | tile = tp.tile(0, 0, 0) 58 | test_children = {(1, 0, 0), (1, 0, 1)} 59 | children = {t.id for t in tile.get_children()} 60 | assert test_children == children 61 | 62 | # 4 metatiling 63 | tp = TilePyramid("geodetic", metatiling=4) 64 | tile = tp.tile(0, 0, 0) 65 | test_children = {(1, 0, 0)} 66 | children = {t.id for t in tile.get_children()} 67 | assert test_children == children 68 | 69 | 70 | def test_get_neighbors(grid_definition_proj): 71 | """Get Tile neighbors.""" 72 | tp = TilePyramid("geodetic") 73 | 74 | # default 75 | tile = tp.tile(8, 100, 100) 76 | # 8 neighbors 77 | test_neighbors = { 78 | (8, 101, 100), 79 | (8, 100, 101), 80 | (8, 99, 100), 81 | (8, 100, 99), 82 | (8, 99, 101), 83 | (8, 101, 101), 84 | (8, 101, 99), 85 | (8, 99, 99), 86 | } 87 | neighbors = {t.id for t in tile.get_neighbors()} 88 | assert test_neighbors == neighbors 89 | # 4 neighbors 90 | test_neighbors = {(8, 101, 100), (8, 100, 101), (8, 99, 100), (8, 100, 99)} 91 | neighbors = {t.id for t in tile.get_neighbors(connectedness=4)} 92 | assert test_neighbors == neighbors 93 | 94 | # over antimeridian 95 | tile = tp.tile(3, 1, 0) 96 | # 8 neighbors 97 | test_neighbors = { 98 | (3, 0, 0), 99 | (3, 1, 1), 100 | (3, 2, 0), 101 | (3, 1, 15), 102 | (3, 0, 1), 103 | (3, 2, 1), 104 | (3, 2, 15), 105 | (3, 0, 15), 106 | } 107 | neighbors = {t.id for t in tile.get_neighbors()} 108 | assert test_neighbors == neighbors 109 | # 4 neighbors 110 | test_neighbors = {(3, 0, 0), (3, 1, 1), (3, 2, 0), (3, 1, 15)} 111 | neighbors = {t.id for t in tile.get_neighbors(connectedness=4)} 112 | assert test_neighbors == neighbors 113 | 114 | # tile has exactly two identical neighbors 115 | tile = tp.tile(0, 0, 0) 116 | test_tile = [(0, 0, 1)] 117 | neighbors = [t.id for t in tile.get_neighbors()] 118 | assert test_tile == neighbors 119 | 120 | # tile is alone at current zoom level 121 | tp = TilePyramid("geodetic", metatiling=2) 122 | tile = tp.tile(0, 0, 0) 123 | neighbors = [t.id for t in tile.get_neighbors()] 124 | assert neighbors == [] 125 | 126 | # wrong connectedness parameter 127 | try: 128 | tile.get_neighbors(connectedness="wrong_param") 129 | raise Exception() 130 | except ValueError: 131 | pass 132 | 133 | # neighbors on non-global tilepyramids 134 | tp = TilePyramid(grid_definition_proj) 135 | zoom = 5 136 | max_col = tp.matrix_width(zoom) - 1 137 | tile = tp.tile(zoom=zoom, row=3, col=max_col) 138 | # don't wrap around antimeridian, i.e. there are no tile neighbors 139 | assert len(tile.get_neighbors()) == 5 140 | 141 | 142 | def test_intersecting(): 143 | """Get intersecting Tiles from other TilePyramid.""" 144 | tp_source = TilePyramid("geodetic", metatiling=2) 145 | tp_target = TilePyramid("geodetic") 146 | tile = tp_source.tile(5, 2, 2) 147 | test_tiles = {(5, 4, 4), (5, 5, 4), (5, 4, 5), (5, 5, 5)} 148 | intersecting_tiles = {t.id for t in tile.intersecting(tp_target)} 149 | assert test_tiles == intersecting_tiles 150 | 151 | 152 | def test_tile_compare(): 153 | tp = TilePyramid("geodetic") 154 | a = tp.tile(5, 5, 5) 155 | b = tp.tile(5, 5, 5) 156 | c = tp.tile(5, 5, 6) 157 | assert a == b 158 | assert a != c 159 | assert b != c 160 | assert a != "invalid type" 161 | assert len(set([a, b, c])) == 2 162 | 163 | 164 | def test_tile_tuple(): 165 | tp = TilePyramid("geodetic") 166 | a = tp.tile(5, 5, 5) 167 | assert tuple(a) == ( 168 | 5, 169 | 5, 170 | 5, 171 | ) 172 | 173 | 174 | def test_deprecated(): 175 | tp = TilePyramid("geodetic") 176 | tile = tp.tile(5, 5, 5) 177 | assert tile.srid 178 | 179 | 180 | def test_invalid_id(): 181 | tp = TilePyramid("geodetic") 182 | # wrong id types 183 | with pytest.raises(TypeError): 184 | tp.tile(-1, 0, 0) 185 | with pytest.raises(TypeError): 186 | tp.tile(5, 0.7, 0) 187 | with pytest.raises(TypeError): 188 | tp.tile(10, 0, -11.56) 189 | # column exceeds 190 | with pytest.raises(ValueError): 191 | tp.tile(5, 500, 0) 192 | # row exceeds 193 | with pytest.raises(ValueError): 194 | tp.tile(5, 0, 500) 195 | 196 | 197 | def test_tile_sizes(): 198 | tp = TilePyramid("geodetic") 199 | tile = tp.tile(5, 5, 5) 200 | assert tile.x_size == tile.y_size 201 | -------------------------------------------------------------------------------- /test/test_tile_shapes.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Test Tile shapes.""" 3 | 4 | from tilematrix import TilePyramid 5 | 6 | 7 | def test_simple_shapes(): 8 | """Without metatiling & buffer.""" 9 | # default 10 | tile = TilePyramid("geodetic").tile(0, 0, 0) 11 | assert tile.width == tile.height == 256 12 | assert tile.shape() == (256, 256) 13 | # 512x512 14 | tile = TilePyramid("geodetic", tile_size=512).tile(0, 0, 0) 15 | assert tile.width == tile.height == 512 16 | assert tile.shape() == (512, 512) 17 | 18 | 19 | def test_geodetic_metatiling_shapes(): 20 | """Metatile shapes.""" 21 | # metatiling 2 22 | tp = TilePyramid("geodetic", metatiling=2) 23 | assert tp.metatile_size == 512 24 | tile_shapes = { 25 | (0, 0, 0): (256, 512), 26 | (1, 0, 0): (512, 512), 27 | (2, 0, 0): (512, 512), 28 | (3, 0, 0): (512, 512), 29 | (4, 0, 0): (512, 512), 30 | (5, 0, 0): (512, 512), 31 | } 32 | for tile_id, control_shape in tile_shapes.items(): 33 | tile = tp.tile(*tile_id) 34 | assert tile.height == control_shape[0] 35 | assert tile.width == control_shape[1] 36 | assert tile.shape() == control_shape 37 | 38 | # metatiling 4 39 | tp = TilePyramid("geodetic", metatiling=4) 40 | assert tp.metatile_size == 1024 41 | tile_shapes = { 42 | (0, 0, 0): (256, 512), 43 | (1, 0, 0): (512, 1024), 44 | (2, 0, 0): (1024, 1024), 45 | (3, 0, 0): (1024, 1024), 46 | (4, 0, 0): (1024, 1024), 47 | (5, 0, 0): (1024, 1024), 48 | } 49 | for tile_id, control_shape in tile_shapes.items(): 50 | tile = tp.tile(*tile_id) 51 | assert tile.height == control_shape[0] 52 | assert tile.width == control_shape[1] 53 | assert tile.shape() == control_shape 54 | 55 | # metatiling 8 56 | tp = TilePyramid("geodetic", metatiling=8) 57 | assert tp.metatile_size == 2048 58 | tile_shapes = { 59 | (0, 0, 0): (256, 512), 60 | (1, 0, 0): (512, 1024), 61 | (2, 0, 0): (1024, 2048), 62 | (3, 0, 0): (2048, 2048), 63 | (4, 0, 0): (2048, 2048), 64 | (5, 0, 0): (2048, 2048), 65 | } 66 | for tile_id, control_shape in tile_shapes.items(): 67 | tile = tp.tile(*tile_id) 68 | assert tile.height == control_shape[0] 69 | assert tile.width == control_shape[1] 70 | assert tile.shape() == control_shape 71 | 72 | # metatiling 16 73 | tp = TilePyramid("geodetic", metatiling=16) 74 | assert tp.metatile_size == 4096 75 | tile_shapes = { 76 | (0, 0, 0): (256, 512), 77 | (1, 0, 0): (512, 1024), 78 | (2, 0, 0): (1024, 2048), 79 | (3, 0, 0): (2048, 4096), 80 | (4, 0, 0): (4096, 4096), 81 | (5, 0, 0): (4096, 4096), 82 | } 83 | for tile_id, control_shape in tile_shapes.items(): 84 | tile = tp.tile(*tile_id) 85 | assert tile.height == control_shape[0] 86 | assert tile.width == control_shape[1] 87 | assert tile.shape() == control_shape 88 | 89 | 90 | def test_geodetic_pixelbuffer_shapes(): 91 | """Tile shapes using pixelbuffer.""" 92 | tp = TilePyramid("geodetic") 93 | pixelbuffer = 10 94 | tile_shapes = { 95 | (0, 0, 0): (256, 276), # single tile at zoom 0 96 | (1, 0, 0): (266, 276), # top left 97 | (2, 0, 0): (266, 276), # top left 98 | (2, 0, 2): (266, 276), # top middle 99 | (2, 0, 3): (266, 276), # top right 100 | (2, 3, 0): (266, 276), # bottom left 101 | (2, 3, 2): (266, 276), # bottom middle 102 | (2, 3, 7): (266, 276), # bottom right 103 | (3, 1, 0): (276, 276), # middle left 104 | (3, 1, 1): (276, 276), # middle middle 105 | (3, 1, 15): (276, 276), # middle right 106 | } 107 | for tile_id, control_shape in tile_shapes.items(): 108 | tile = tp.tile(*tile_id) 109 | assert tile.shape(pixelbuffer) == control_shape 110 | 111 | 112 | def test_geodetic_metatile_shapes(): 113 | """Metatile shapes.""" 114 | # metatiling 2 115 | tp = TilePyramid("geodetic", metatiling=2) 116 | pixelbuffer = 10 117 | assert tp.metatile_size == 512 118 | tile_shapes = { 119 | (0, 0, 0): (256, 532), 120 | (1, 0, 0): (512, 532), 121 | (2, 0, 0): (522, 532), 122 | (3, 0, 0): (522, 532), 123 | (4, 0, 0): (522, 532), 124 | (5, 0, 0): (522, 532), 125 | (5, 1, 1): (532, 532), 126 | } 127 | for tile_id, control_shape in tile_shapes.items(): 128 | tile = tp.tile(*tile_id) 129 | assert tile.shape(pixelbuffer) == control_shape 130 | 131 | # metatiling 4 132 | tp = TilePyramid("geodetic", metatiling=4) 133 | assert tp.metatile_size == 1024 134 | tile_shapes = { 135 | (0, 0, 0): (256, 532), 136 | (1, 0, 0): (512, 1044), 137 | (2, 0, 0): (1024, 1044), 138 | (3, 0, 0): (1034, 1044), 139 | (4, 0, 0): (1034, 1044), 140 | (5, 0, 0): (1034, 1044), 141 | (5, 1, 1): (1044, 1044), 142 | } 143 | for tile_id, control_shape in tile_shapes.items(): 144 | tile = tp.tile(*tile_id) 145 | assert tile.shape(pixelbuffer) == control_shape 146 | 147 | # metatiling 8 148 | tp = TilePyramid("geodetic", metatiling=8) 149 | assert tp.metatile_size == 2048 150 | tile_shapes = { 151 | (0, 0, 0): (256, 532), 152 | (1, 0, 0): (512, 1044), 153 | (2, 0, 0): (1024, 2068), 154 | (3, 0, 0): (2048, 2068), 155 | (4, 0, 0): (2058, 2068), 156 | (5, 0, 0): (2058, 2068), 157 | (5, 1, 1): (2068, 2068), 158 | } 159 | for tile_id, control_shape in tile_shapes.items(): 160 | tile = tp.tile(*tile_id) 161 | assert tile.shape(pixelbuffer) == control_shape 162 | 163 | # metatiling 16 164 | tp = TilePyramid("geodetic", metatiling=16) 165 | assert tp.metatile_size == 4096 166 | tile_shapes = { 167 | (0, 0, 0): (256, 532), 168 | (1, 0, 0): (512, 1044), 169 | (2, 0, 0): (1024, 2068), 170 | (3, 0, 0): (2048, 4116), 171 | (4, 0, 0): (4096, 4116), 172 | (5, 0, 0): (4106, 4116), 173 | (6, 1, 1): (4116, 4116), 174 | } 175 | for tile_id, control_shape in tile_shapes.items(): 176 | tile = tp.tile(*tile_id) 177 | assert tile.shape(pixelbuffer) == control_shape 178 | -------------------------------------------------------------------------------- /test/test_tilepyramid.py: -------------------------------------------------------------------------------- 1 | """TilePyramid creation.""" 2 | 3 | from types import GeneratorType 4 | 5 | import pytest 6 | from shapely.geometry import Point, box 7 | from shapely.ops import unary_union 8 | 9 | from tilematrix import TilePyramid, snap_bounds 10 | 11 | 12 | def test_init(): 13 | """Initialize TilePyramids.""" 14 | for tptype in ["geodetic", "mercator"]: 15 | assert TilePyramid(tptype) 16 | with pytest.raises(ValueError): 17 | TilePyramid("invalid") 18 | with pytest.raises(ValueError): 19 | TilePyramid() 20 | assert hash(TilePyramid(tptype)) 21 | 22 | 23 | def test_metatiling(): 24 | """Metatiling setting.""" 25 | for metatiling in [1, 2, 4, 8, 16]: 26 | assert TilePyramid("geodetic", metatiling=metatiling) 27 | try: 28 | TilePyramid("geodetic", metatiling=5) 29 | raise Exception() 30 | except ValueError: 31 | pass 32 | 33 | 34 | def test_tile_size(): 35 | """Tile sizes.""" 36 | for tile_size in [128, 256, 512, 1024]: 37 | tp = TilePyramid("geodetic", tile_size=tile_size) 38 | assert tp.tile_size == tile_size 39 | 40 | 41 | def test_intersect(): 42 | """Get intersecting Tiles.""" 43 | # same metatiling 44 | tp = TilePyramid("geodetic") 45 | intersect_tile = TilePyramid("geodetic").tile(5, 1, 1) 46 | control = {(5, 1, 1)} 47 | test_tiles = {tile.id for tile in tp.intersecting(intersect_tile)} 48 | assert control == test_tiles 49 | 50 | # smaller metatiling 51 | tp = TilePyramid("geodetic") 52 | intersect_tile = TilePyramid("geodetic", metatiling=2).tile(5, 1, 1) 53 | control = {(5, 2, 2), (5, 2, 3), (5, 3, 3), (5, 3, 2)} 54 | test_tiles = {tile.id for tile in tp.intersecting(intersect_tile)} 55 | assert control == test_tiles 56 | 57 | # bigger metatiling 58 | tp = TilePyramid("geodetic", metatiling=2) 59 | intersect_tile = TilePyramid("geodetic").tile(5, 1, 1) 60 | control = {(5, 0, 0)} 61 | test_tiles = {tile.id for tile in tp.intersecting(intersect_tile)} 62 | assert control == test_tiles 63 | intersect_tile = TilePyramid("geodetic").tile(4, 12, 31) 64 | control = {(4, 6, 15)} 65 | test_tiles = {tile.id for tile in tp.intersecting(intersect_tile)} 66 | assert control == test_tiles 67 | 68 | # different CRSes 69 | tp = TilePyramid("geodetic") 70 | intersect_tile = TilePyramid("mercator").tile(5, 1, 1) 71 | try: 72 | test_tiles = {tile.id for tile in tp.intersecting(intersect_tile)} 73 | raise Exception() 74 | except ValueError: 75 | pass 76 | 77 | 78 | def test_tilepyramid_compare(grid_definition_proj, grid_definition_epsg): 79 | """Comparison operators.""" 80 | gproj, gepsg = grid_definition_proj, grid_definition_epsg 81 | # predefined 82 | assert TilePyramid("geodetic") == TilePyramid("geodetic") 83 | assert TilePyramid("geodetic") != TilePyramid("geodetic", metatiling=2) 84 | assert TilePyramid("geodetic") != TilePyramid("geodetic", tile_size=512) 85 | assert TilePyramid("mercator") == TilePyramid("mercator") 86 | assert TilePyramid("mercator") != TilePyramid("mercator", metatiling=2) 87 | assert TilePyramid("mercator") != TilePyramid("mercator", tile_size=512) 88 | # epsg based 89 | assert TilePyramid(gepsg) == TilePyramid(gepsg) 90 | assert TilePyramid(gepsg) != TilePyramid(gepsg, metatiling=2) 91 | assert TilePyramid(gepsg) != TilePyramid(gepsg, tile_size=512) 92 | # proj based 93 | assert TilePyramid(gproj) == TilePyramid(gproj) 94 | assert TilePyramid(gproj) != TilePyramid(gproj, metatiling=2) 95 | assert TilePyramid(gproj) != TilePyramid(gproj, tile_size=512) 96 | # altered bounds 97 | abounds = dict(**gproj) 98 | abounds.update(bounds=(-5000000.0, -5000000.0, 5000000.0, 5000000.0)) 99 | assert TilePyramid(abounds) == TilePyramid(abounds) 100 | assert TilePyramid(gproj) != TilePyramid(abounds) 101 | # other type 102 | assert TilePyramid("geodetic") != "string" 103 | 104 | 105 | def test_grid_compare(grid_definition_proj, grid_definition_epsg): 106 | """Comparison operators.""" 107 | gproj, gepsg = grid_definition_proj, grid_definition_epsg 108 | # predefined 109 | assert TilePyramid("geodetic").grid == TilePyramid("geodetic").grid 110 | assert TilePyramid("geodetic").grid == TilePyramid("geodetic", metatiling=2).grid 111 | assert TilePyramid("geodetic").grid == TilePyramid("geodetic", tile_size=512).grid 112 | assert TilePyramid("mercator").grid == TilePyramid("mercator").grid 113 | assert TilePyramid("mercator").grid == TilePyramid("mercator", metatiling=2).grid 114 | assert TilePyramid("mercator").grid == TilePyramid("mercator", tile_size=512).grid 115 | # epsg based 116 | assert TilePyramid(gepsg).grid == TilePyramid(gepsg).grid 117 | assert TilePyramid(gepsg).grid == TilePyramid(gepsg, metatiling=2).grid 118 | assert TilePyramid(gepsg).grid == TilePyramid(gepsg, tile_size=512).grid 119 | # proj based 120 | assert TilePyramid(gproj).grid == TilePyramid(gproj).grid 121 | assert TilePyramid(gproj).grid == TilePyramid(gproj, metatiling=2).grid 122 | assert TilePyramid(gproj).grid == TilePyramid(gproj, tile_size=512).grid 123 | # altered bounds 124 | abounds = dict(**gproj) 125 | abounds.update(bounds=(-5000000.0, -5000000.0, 5000000.0, 5000000.0)) 126 | assert TilePyramid(abounds).grid == TilePyramid(abounds).grid 127 | assert TilePyramid(gproj).grid != TilePyramid(abounds).grid 128 | 129 | 130 | def test_tile_from_xy(): 131 | tp = TilePyramid("geodetic") 132 | zoom = 5 133 | 134 | # point inside tile 135 | p_in = (0.5, 0.5, zoom) 136 | control_in = [ 137 | ((5, 15, 32), "rb"), 138 | ((5, 15, 32), "lb"), 139 | ((5, 15, 32), "rt"), 140 | ((5, 15, 32), "lt"), 141 | ] 142 | for tile_id, on_edge_use in control_in: 143 | tile = tp.tile_from_xy(*p_in, on_edge_use=on_edge_use) 144 | assert tile.id == tile_id 145 | assert Point(p_in[0], p_in[1]).within(tile.bbox()) 146 | 147 | # point is on tile edge 148 | p_edge = (0, 0, zoom) 149 | control_edge = [ 150 | ((5, 16, 32), "rb"), 151 | ((5, 16, 31), "lb"), 152 | ((5, 15, 32), "rt"), 153 | ((5, 15, 31), "lt"), 154 | ] 155 | for tile_id, on_edge_use in control_edge: 156 | tile = tp.tile_from_xy(*p_edge, on_edge_use=on_edge_use) 157 | assert tile.id == tile_id 158 | assert Point(p_edge[0], p_edge[1]).touches(tile.bbox()) 159 | 160 | with pytest.raises(ValueError): 161 | tp.tile_from_xy(180, -90, zoom, on_edge_use="rb") 162 | with pytest.raises(ValueError): 163 | tp.tile_from_xy(180, -90, zoom, on_edge_use="lb") 164 | 165 | tile = tp.tile_from_xy(180, -90, zoom, on_edge_use="rt") 166 | assert tile.id == (5, 31, 0) 167 | tile = tp.tile_from_xy(180, -90, zoom, on_edge_use="lt") 168 | assert tile.id == (5, 31, 63) 169 | 170 | with pytest.raises(TypeError): 171 | tp.tile_from_xy(-180, 90, zoom, on_edge_use="lt") 172 | with pytest.raises(TypeError): 173 | tp.tile_from_xy(-180, 90, zoom, on_edge_use="rt") 174 | 175 | tile = tp.tile_from_xy(-180, 90, zoom, on_edge_use="rb") 176 | assert tile.id == (5, 0, 0) 177 | tile = tp.tile_from_xy(-180, 90, zoom, on_edge_use="lb") 178 | assert tile.id == (5, 0, 63) 179 | 180 | with pytest.raises(ValueError): 181 | tp.tile_from_xy(-180, 90, zoom, on_edge_use="invalid") 182 | 183 | 184 | def test_tiles_from_bounds(grid_definition_proj): 185 | # global pyramids 186 | tp = TilePyramid("geodetic") 187 | parent = tp.tile(8, 5, 5) 188 | from_bounds = set([t.id for t in tp.tiles_from_bounds(parent.bounds(), 9)]) 189 | children = set([t.id for t in parent.get_children()]) 190 | assert from_bounds == children 191 | # non-global pyramids 192 | tp = TilePyramid(grid_definition_proj) 193 | parent = tp.tile(8, 0, 0) 194 | from_bounds = set([t.id for t in tp.tiles_from_bounds(parent.bounds(), 9)]) 195 | children = set([t.id for t in parent.get_children()]) 196 | assert from_bounds == children 197 | 198 | 199 | def test_tiles_from_bounds_batch_by_row(): 200 | tp = TilePyramid("geodetic") 201 | bounds = (0, 0, 90, 90) 202 | zoom = 8 203 | 204 | tiles = tp.tiles_from_bounds(bounds, zoom, batch_by="row") 205 | assert isinstance(tiles, GeneratorType) 206 | assert list(tiles) 207 | 208 | previous_row = None 209 | tiles = 0 210 | for tile_row in tp.tiles_from_bounds(bounds, zoom, batch_by="row"): 211 | assert isinstance(tile_row, GeneratorType) 212 | previous_tile = None 213 | for tile in tile_row: 214 | tiles += 1 215 | if previous_row is None: 216 | if previous_tile is not None: 217 | assert tile.col == previous_tile.col + 1 218 | else: 219 | if previous_tile is not None: 220 | assert tile.col == previous_tile.col + 1 221 | assert tile.row == previous_tile.row 222 | assert tile.row == previous_row + 1 223 | 224 | previous_tile = tile 225 | 226 | previous_row = tile.row 227 | 228 | assert tiles == len(list(tp.tiles_from_bounds(bounds, zoom))) 229 | 230 | 231 | def test_tiles_from_bounds_batch_by_column(): 232 | tp = TilePyramid("geodetic") 233 | bounds = (0, 0, 90, 90) 234 | zoom = 8 235 | 236 | tiles = tp.tiles_from_bounds(bounds, zoom, batch_by="column") 237 | assert isinstance(tiles, GeneratorType) 238 | assert list(tiles) 239 | 240 | previous_column = None 241 | tiles = 0 242 | for tile_column in tp.tiles_from_bounds(bounds, zoom, batch_by="column"): 243 | assert isinstance(tile_column, GeneratorType) 244 | previous_tile = None 245 | for tile in tile_column: 246 | tiles += 1 247 | if previous_column is None: 248 | if previous_tile is not None: 249 | assert tile.row == previous_tile.row + 1 250 | else: 251 | if previous_tile is not None: 252 | assert tile.row == previous_tile.row + 1 253 | assert tile.col == previous_tile.col 254 | assert tile.col == previous_column + 1 255 | 256 | previous_tile = tile 257 | 258 | previous_column = tile.col 259 | 260 | assert tiles == len(list(tp.tiles_from_bounds(bounds, zoom))) 261 | 262 | 263 | def test_tiles_from_bounds_batch_by_row_antimeridian_bounds(): 264 | tp = TilePyramid("geodetic") 265 | bounds = (0, 0, 185, 95) 266 | zoom = 8 267 | 268 | tiles = tp.tiles_from_bounds(bounds, zoom, batch_by="row") 269 | assert isinstance(tiles, GeneratorType) 270 | assert list(tiles) 271 | 272 | previous_row = None 273 | tiles = 0 274 | for tile_row in tp.tiles_from_bounds(bounds, zoom, batch_by="row"): 275 | assert isinstance(tile_row, GeneratorType) 276 | previous_tile = None 277 | for tile in tile_row: 278 | tiles += 1 279 | if previous_row is None: 280 | if previous_tile is not None: 281 | assert tile.col > previous_tile.col 282 | else: 283 | if previous_tile is not None: 284 | assert tile.col > previous_tile.col 285 | assert tile.row == previous_tile.row 286 | assert tile.row > previous_row 287 | 288 | previous_tile = tile 289 | 290 | previous_row = tile.row 291 | 292 | assert tiles == len(list(tp.tiles_from_bounds(bounds, zoom))) 293 | 294 | 295 | def test_tiles_from_bounds_batch_by_row_both_antimeridian_bounds(): 296 | tp = TilePyramid("geodetic") 297 | bounds = (-185, 0, 185, 95) 298 | zoom = 8 299 | 300 | tiles = tp.tiles_from_bounds(bounds, zoom, batch_by="row") 301 | assert isinstance(tiles, GeneratorType) 302 | assert list(tiles) 303 | 304 | previous_row = None 305 | tiles = 0 306 | for tile_row in tp.tiles_from_bounds(bounds, zoom, batch_by="row"): 307 | assert isinstance(tile_row, GeneratorType) 308 | previous_tile = None 309 | for tile in tile_row: 310 | tiles += 1 311 | if previous_row is None: 312 | if previous_tile is not None: 313 | assert tile.col == previous_tile.col + 1 314 | else: 315 | if previous_tile is not None: 316 | assert tile.col == previous_tile.col + 1 317 | assert tile.row == previous_tile.row 318 | assert tile.row == previous_row + 1 319 | 320 | previous_tile = tile 321 | 322 | previous_row = tile.row 323 | 324 | assert tiles == len(list(tp.tiles_from_bounds(bounds, zoom))) 325 | 326 | 327 | def test_tiles_from_geom_exact(tile_bounds_polygon): 328 | tp = TilePyramid("geodetic") 329 | zoom = 3 330 | 331 | tiles = len(list(tp.tiles_from_geom(tile_bounds_polygon, zoom))) 332 | assert tiles == 4 333 | tiles = 0 334 | for batch in tp.tiles_from_geom(tile_bounds_polygon, zoom, batch_by="row"): 335 | tiles += len(list(batch)) 336 | assert tiles == 4 337 | 338 | exact_tiles = len(list(tp.tiles_from_geom(tile_bounds_polygon, zoom, exact=True))) 339 | assert exact_tiles == 3 340 | tiles = 0 341 | for batch in tp.tiles_from_geom( 342 | tile_bounds_polygon, zoom, batch_by="row", exact=True 343 | ): 344 | tiles += len(list(batch)) 345 | assert tiles == 3 346 | 347 | 348 | def test_snap_bounds(): 349 | bounds = (0, 1, 2, 3) 350 | tp = TilePyramid("geodetic") 351 | zoom = 8 352 | 353 | snapped = snap_bounds(bounds=bounds, tile_pyramid=tp, zoom=zoom) 354 | control = unary_union( 355 | [tile.bbox() for tile in tp.tiles_from_bounds(bounds, zoom)] 356 | ).bounds 357 | assert snapped == control 358 | 359 | pixelbuffer = 10 360 | snapped = snap_bounds( 361 | bounds=bounds, tile_pyramid=tp, zoom=zoom, pixelbuffer=pixelbuffer 362 | ) 363 | control = unary_union( 364 | [tile.bbox(pixelbuffer) for tile in tp.tiles_from_bounds(bounds, zoom)] 365 | ).bounds 366 | assert snapped == control 367 | 368 | 369 | def test_deprecated(): 370 | tp = TilePyramid("geodetic") 371 | assert tp.type 372 | assert tp.srid 373 | assert tp.tile_x_size(0) 374 | assert tp.tile_y_size(0) 375 | assert tp.tile_height(0) 376 | assert tp.tile_width(0) 377 | -------------------------------------------------------------------------------- /tilematrix/__init__.py: -------------------------------------------------------------------------------- 1 | """Main package entry point.""" 2 | 3 | from ._conf import PYRAMID_PARAMS 4 | from ._funcs import clip_geometry_to_srs_bounds, snap_bounds, validate_zoom 5 | from ._grid import GridDefinition 6 | from ._tile import Tile 7 | from ._tilepyramid import TilePyramid 8 | from ._types import Bounds, Shape, TileIndex 9 | 10 | __all__ = [ 11 | "Bounds", 12 | "clip_geometry_to_srs_bounds", 13 | "GridDefinition", 14 | "PYRAMID_PARAMS", 15 | "Shape", 16 | "snap_bounds", 17 | "TilePyramid", 18 | "Tile", 19 | "TileIndex", 20 | "validate_zoom", 21 | ] 22 | 23 | 24 | __version__ = "2024.11.0" 25 | -------------------------------------------------------------------------------- /tilematrix/_conf.py: -------------------------------------------------------------------------------- 1 | """Package configuration parameters.""" 2 | 3 | # round coordinates 4 | ROUND = 20 5 | 6 | # bounds ratio vs shape ratio uncertainty 7 | DELTA = 1e-6 8 | 9 | # supported pyramid types 10 | PYRAMID_PARAMS = { 11 | "geodetic": { 12 | "shape": (1, 2), # tile rows and columns at zoom level 0 13 | "bounds": (-180.0, -90.0, 180.0, 90.0), # pyramid bounds in pyramid CRS 14 | "is_global": True, # if false, no antimeridian handling 15 | "srs": {"epsg": 4326}, # EPSG code for CRS 16 | }, 17 | "mercator": { 18 | "shape": (1, 1), 19 | "bounds": ( 20 | -20037508.3427892, 21 | -20037508.3427892, 22 | 20037508.3427892, 23 | 20037508.3427892, 24 | ), 25 | "is_global": True, 26 | "srs": {"epsg": 3857}, 27 | }, 28 | } 29 | -------------------------------------------------------------------------------- /tilematrix/_funcs.py: -------------------------------------------------------------------------------- 1 | """Helper functions.""" 2 | 3 | from itertools import product 4 | 5 | from rasterio.crs import CRS 6 | from shapely.affinity import translate 7 | from shapely.geometry import GeometryCollection, Polygon, box 8 | from shapely.ops import unary_union 9 | from shapely.prepared import prep 10 | 11 | from ._conf import DELTA, ROUND 12 | from ._types import Bounds, Shape 13 | 14 | 15 | def validate_zoom(zoom): 16 | if not isinstance(zoom, int): 17 | raise TypeError("zoom must be an integer") 18 | if zoom < 0: 19 | raise ValueError("zoom must be greater or equal 0") 20 | 21 | 22 | def clip_geometry_to_srs_bounds(geometry, pyramid, multipart=False): 23 | """ 24 | Clip input geometry to SRS bounds of given TilePyramid. 25 | 26 | If geometry passes the antimeridian, it will be split up in a multipart 27 | geometry and shifted to within the SRS boundaries. 28 | Note: geometry SRS must be the TilePyramid SRS! 29 | 30 | - geometry: any shapely geometry 31 | - pyramid: a TilePyramid object 32 | - multipart: return list of geometries instead of a GeometryCollection 33 | """ 34 | if not geometry.is_valid: 35 | raise ValueError("invalid geometry given") 36 | pyramid_bbox = box(*pyramid.bounds) 37 | 38 | # Special case for global tile pyramids if geometry extends over tile 39 | # pyramid boundaries (such as the antimeridian). 40 | if pyramid.is_global and not geometry.within(pyramid_bbox): 41 | inside_geom = geometry.intersection(pyramid_bbox) 42 | outside_geom = geometry.difference(pyramid_bbox) 43 | # shift outside geometry so it lies within SRS bounds 44 | if hasattr(outside_geom, "geoms"): 45 | outside_geoms = outside_geom.geoms 46 | else: 47 | outside_geoms = [outside_geom] 48 | all_geoms = [inside_geom] 49 | for geom in outside_geoms: 50 | geom_bounds = Bounds(*geom.bounds) 51 | if geom_bounds.left < pyramid.left: 52 | geom = translate(geom, xoff=2 * pyramid.right) 53 | elif geom_bounds.right > pyramid.right: 54 | geom = translate(geom, xoff=-2 * pyramid.right) 55 | all_geoms.append(geom) 56 | if multipart: 57 | return all_geoms 58 | else: 59 | return GeometryCollection(all_geoms) 60 | 61 | else: 62 | if multipart: 63 | return [geometry] 64 | else: 65 | return geometry 66 | 67 | 68 | def snap_bounds(bounds=None, tile_pyramid=None, zoom=None, pixelbuffer=0): 69 | """ 70 | Extend bounds to be aligned with union of tile bboxes. 71 | 72 | - bounds: (left, bottom, right, top) 73 | - tile_pyramid: a TilePyramid object 74 | - zoom: target zoom level 75 | - pixelbuffer: apply pixelbuffer 76 | """ 77 | bounds = Bounds(*bounds) 78 | validate_zoom(zoom) 79 | lb = _tile_from_xy(tile_pyramid, bounds.left, bounds.bottom, zoom, on_edge_use="rt") 80 | rt = _tile_from_xy(tile_pyramid, bounds.right, bounds.top, zoom, on_edge_use="lb") 81 | left, bottom, _, _ = lb.bounds(pixelbuffer) 82 | _, _, right, top = rt.bounds(pixelbuffer) 83 | return Bounds(left, bottom, right, top) 84 | 85 | 86 | def _verify_shape_bounds(shape, bounds): 87 | """Verify that shape corresponds to bounds apect ratio.""" 88 | if not isinstance(shape, (tuple, list)) or len(shape) != 2: 89 | raise TypeError( 90 | "shape must be a tuple or list with two elements: %s" % str(shape) 91 | ) 92 | if not isinstance(bounds, (tuple, list)) or len(bounds) != 4: 93 | raise TypeError( 94 | "bounds must be a tuple or list with four elements: %s" % str(bounds) 95 | ) 96 | shape = Shape(*shape) 97 | bounds = Bounds(*bounds) 98 | shape_ratio = shape.width / shape.height 99 | bounds_ratio = (bounds.right - bounds.left) / (bounds.top - bounds.bottom) 100 | if abs(shape_ratio - bounds_ratio) > DELTA: 101 | min_length = min( 102 | [ 103 | (bounds.right - bounds.left) / shape.width, 104 | (bounds.top - bounds.bottom) / shape.height, 105 | ] 106 | ) 107 | proposed_bounds = Bounds( 108 | bounds.left, 109 | bounds.bottom, 110 | bounds.left + shape.width * min_length, 111 | bounds.bottom + shape.height * min_length, 112 | ) 113 | raise ValueError( 114 | "shape ratio (%s) must equal bounds ratio (%s); try %s" 115 | % (shape_ratio, bounds_ratio, proposed_bounds) 116 | ) 117 | 118 | 119 | def _get_crs(srs): 120 | if not isinstance(srs, dict): 121 | raise TypeError("'srs' must be a dictionary") 122 | if "wkt" in srs: 123 | return CRS().from_wkt(srs["wkt"]) 124 | elif "epsg" in srs: 125 | return CRS().from_epsg(srs["epsg"]) 126 | elif "proj" in srs: 127 | return CRS().from_string(srs["proj"]) 128 | else: 129 | raise TypeError("provide either 'wkt', 'epsg' or 'proj' definition") 130 | 131 | 132 | def _tile_intersecting_tilepyramid(tile, tp): 133 | """Return all tiles from tilepyramid intersecting with tile.""" 134 | if tile.tp.grid != tp.grid: 135 | raise ValueError("Tile and TilePyramid source grids must be the same.") 136 | tile_metatiling = tile.tile_pyramid.metatiling 137 | pyramid_metatiling = tp.metatiling 138 | multiplier = tile_metatiling / pyramid_metatiling 139 | if tile_metatiling > pyramid_metatiling: 140 | out = [] 141 | multiplier = int(multiplier) 142 | for row_offset, col_offset in product(range(multiplier), range(multiplier)): 143 | try: 144 | out.append( 145 | tp.tile( 146 | tile.zoom, 147 | multiplier * tile.row + row_offset, 148 | multiplier * tile.col + col_offset, 149 | ) 150 | ) 151 | except ValueError: 152 | pass 153 | return out 154 | elif tile_metatiling < pyramid_metatiling: 155 | return [ 156 | tp.tile(tile.zoom, int(multiplier * tile.row), int(multiplier * tile.col)) 157 | ] 158 | else: 159 | return [tp.tile(*tile.id)] 160 | 161 | 162 | def _global_tiles_from_bounds(tp, bounds, zoom, batch_by=None): 163 | """Return also Tiles if bounds cross the antimeridian.""" 164 | 165 | # clip to tilepyramid top and bottom bounds 166 | left, right = bounds.left, bounds.right 167 | top = min([tp.top, bounds.top]) 168 | bottom = max([tp.bottom, bounds.bottom]) 169 | 170 | # special case if bounds cross the antimeridian 171 | if left < tp.left or right > tp.right: 172 | # shift overlap to valid side of antimeridian 173 | # create MultiPolygon 174 | # yield tiles by 175 | bounds_geoms = [] 176 | 177 | # tiles west of antimeridian 178 | if left < tp.left: 179 | # move outside part of bounding box to the valid side of antimeridian 180 | bounds_geoms.append(box(left + (tp.right - tp.left), bottom, tp.right, top)) 181 | bounds_geoms.append( 182 | # remaining part of bounding box 183 | box(tp.left, bottom, min([right, tp.right]), top) 184 | ) 185 | 186 | # tiles east of antimeridian 187 | if right > tp.right: 188 | # move outside part of bounding box to the valid side of antimeridian 189 | bounds_geoms.append(box(tp.left, bottom, right - (tp.right - tp.left), top)) 190 | bounds_geoms.append(box(max([left, tp.left]), bottom, tp.right, top)) 191 | 192 | bounds_geom = unary_union(bounds_geoms).buffer(0) 193 | bounds_geom_prep = prep(bounds_geom) 194 | 195 | # if union of bounding boxes is a multipart geometry, do some costly checks to be able 196 | # to yield in batches 197 | if bounds_geom.geom_type.lower().startswith("multi"): 198 | if batch_by: 199 | for batch in _tiles_from_cleaned_bounds( 200 | tp, bounds_geom.bounds, zoom, batch_by 201 | ): 202 | yield ( 203 | tile 204 | for tile in batch 205 | if bounds_geom_prep.intersects(tile.bbox()) 206 | ) 207 | else: 208 | for tile in _tiles_from_cleaned_bounds(tp, bounds_geom.bounds, zoom): 209 | if bounds_geom_prep.intersects(tile.bbox()): 210 | yield tile 211 | return 212 | 213 | # else, continue with cleaned bounds 214 | bounds = bounds_geom.bounds 215 | 216 | # yield using cleaned bounds 217 | yield from _tiles_from_cleaned_bounds(tp, bounds, zoom, batch_by=batch_by) 218 | 219 | 220 | def _tiles_from_cleaned_bounds(tp, bounds, zoom, batch_by=None): 221 | """Return all tiles intersecting with bounds.""" 222 | bounds = Bounds(*bounds) 223 | lb = _tile_from_xy(tp, bounds.left, bounds.bottom, zoom, on_edge_use="rt") 224 | rt = _tile_from_xy(tp, bounds.right, bounds.top, zoom, on_edge_use="lb") 225 | row_range = range(rt.row, lb.row + 1) 226 | col_range = range(lb.col, rt.col + 1) 227 | if batch_by is None: 228 | for row, col in product(row_range, col_range): 229 | yield tp.tile(zoom, row, col) 230 | elif batch_by == "row": 231 | for row in row_range: 232 | yield (tp.tile(zoom, row, col) for col in col_range) 233 | elif batch_by == "column": 234 | for col in col_range: 235 | yield (tp.tile(zoom, row, col) for row in row_range) 236 | else: # pragma: no cover 237 | raise ValueError("'batch_by' must either be None, 'row' or 'column'.") 238 | 239 | 240 | def _tile_from_xy(tp, x, y, zoom, on_edge_use="rb"): 241 | # determine row 242 | tile_y_size = round(tp.pixel_y_size(zoom) * tp.tile_size * tp.metatiling, ROUND) 243 | row = int((tp.top - y) / tile_y_size) 244 | if on_edge_use in ["rt", "lt"] and (tp.top - y) % tile_y_size == 0.0: 245 | row -= 1 246 | 247 | # determine column 248 | tile_x_size = round(tp.pixel_x_size(zoom) * tp.tile_size * tp.metatiling, ROUND) 249 | col = int((x - tp.left) / tile_x_size) 250 | if on_edge_use in ["lb", "lt"] and (x - tp.left) % tile_x_size == 0.0: 251 | col -= 1 252 | 253 | # handle Antimeridian wrapping 254 | if tp.is_global: 255 | # left side 256 | if col == -1: 257 | col = tp.matrix_width(zoom) - 1 258 | # right side 259 | elif col >= tp.matrix_width(zoom): 260 | col = col % tp.matrix_width(zoom) 261 | 262 | try: 263 | return tp.tile(zoom, row, col) 264 | except ValueError as e: 265 | raise ValueError( 266 | "on_edge_use '%s' results in an invalid tile: %s" % (on_edge_use, e) 267 | ) 268 | -------------------------------------------------------------------------------- /tilematrix/_grid.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from ._conf import PYRAMID_PARAMS 4 | from ._funcs import _get_crs, _verify_shape_bounds 5 | from ._types import Bounds, Shape 6 | 7 | 8 | class GridDefinition(object): 9 | """Object representing the tile pyramid source grid.""" 10 | 11 | def __init__( 12 | self, grid=None, shape=None, bounds=None, srs=None, is_global=False, **kwargs 13 | ): 14 | if isinstance(grid, str) and grid in PYRAMID_PARAMS: 15 | self.type = grid 16 | self.shape = Shape(*PYRAMID_PARAMS[grid]["shape"]) 17 | self.bounds = Bounds(*PYRAMID_PARAMS[grid]["bounds"]) 18 | self.is_global = PYRAMID_PARAMS[grid]["is_global"] 19 | self.crs = _get_crs(PYRAMID_PARAMS[grid]["srs"]) 20 | self.left, self.bottom, self.right, self.top = self.bounds 21 | elif grid is None or grid == "custom": 22 | for i in ["proj", "epsg"]: 23 | if i in kwargs: 24 | srs = {i: kwargs[i]} if srs is None else srs 25 | warnings.warn( 26 | DeprecationWarning( 27 | "'%s' should be packed into a dictionary and passed to " 28 | "'srs'" % i 29 | ) 30 | ) 31 | self.type = "custom" 32 | _verify_shape_bounds(shape=shape, bounds=bounds) 33 | self.shape = Shape(*shape) 34 | self.bounds = Bounds(*bounds) 35 | self.is_global = is_global 36 | self.crs = _get_crs(srs) 37 | self.left, self.bottom, self.right, self.top = self.bounds 38 | # check if parameters match with default grid type 39 | for default_grid_name in PYRAMID_PARAMS: 40 | default_grid = GridDefinition(default_grid_name) 41 | if self.__eq__(default_grid): 42 | self.type = default_grid_name 43 | elif isinstance(grid, dict): 44 | if "type" in grid: # pragma: no cover 45 | warnings.warn( 46 | DeprecationWarning("'type' is deprecated and should be 'grid'") 47 | ) 48 | if "grid" not in grid: 49 | grid["grid"] = grid.pop("type") 50 | self.__init__(**grid) 51 | elif isinstance(grid, GridDefinition): 52 | self.__init__(**grid.to_dict()) 53 | else: 54 | raise ValueError("invalid grid definition: %s" % grid) 55 | 56 | @property 57 | def srid(self): 58 | warnings.warn(DeprecationWarning("'srid' attribute is deprecated")) 59 | return self.crs.to_epsg() 60 | 61 | def to_dict(self): 62 | return dict( 63 | bounds=self.bounds, 64 | is_global=self.is_global, 65 | shape=self.shape, 66 | srs=dict(wkt=self.crs.to_wkt()), 67 | grid=self.type, 68 | ) 69 | 70 | def from_dict(config_dict): 71 | return GridDefinition(**config_dict) 72 | 73 | def __eq__(self, other): 74 | return ( 75 | isinstance(other, self.__class__) 76 | and self.shape == other.shape 77 | and self.bounds == other.bounds 78 | and self.is_global == other.is_global 79 | and self.crs == other.crs 80 | ) 81 | 82 | def __ne__(self, other): 83 | return not self.__eq__(other) 84 | 85 | def __repr__(self): 86 | if self.type in PYRAMID_PARAMS: 87 | return 'GridDefinition("%s")' % self.type 88 | else: 89 | return ( 90 | "GridDefinition(" 91 | '"%s", ' 92 | "shape=%s, " 93 | "bounds=%s, " 94 | "is_global=%s, " 95 | "srs=%s" 96 | ")" 97 | % ( 98 | self.type, 99 | tuple(self.shape), 100 | tuple(self.bounds), 101 | self.is_global, 102 | self.crs, 103 | ) 104 | ) 105 | 106 | def __hash__(self): 107 | return hash(repr(self)) 108 | -------------------------------------------------------------------------------- /tilematrix/_tile.py: -------------------------------------------------------------------------------- 1 | """Tile class.""" 2 | 3 | import warnings 4 | 5 | from affine import Affine 6 | from shapely.geometry import box 7 | 8 | from ._conf import ROUND 9 | from ._funcs import _tile_intersecting_tilepyramid 10 | from ._types import Bounds, Shape, TileIndex 11 | 12 | 13 | class Tile(object): 14 | """ 15 | A Tile is a square somewhere on Earth. 16 | 17 | Each Tile can be identified with the zoom, row, column index in a 18 | TilePyramid. 19 | 20 | Some tile functions can accept a tile buffer in pixels (pixelbuffer). A 21 | pixelbuffer value of e.g. 1 will extend the tile boundaries by 1 pixel. 22 | """ 23 | 24 | def __init__(self, tile_pyramid, zoom, row, col): 25 | """Initialize Tile.""" 26 | self.tile_pyramid = tile_pyramid 27 | self.tp = tile_pyramid 28 | self.crs = tile_pyramid.crs 29 | self.zoom = zoom 30 | self.row = row 31 | self.col = col 32 | self.is_valid() 33 | self.index = self.id = TileIndex(zoom, row, col) 34 | self.pixel_x_size = self.tile_pyramid.pixel_x_size(self.zoom) 35 | self.pixel_y_size = self.tile_pyramid.pixel_y_size(self.zoom) 36 | # base SRID size without pixelbuffer 37 | self._base_srid_size = Shape( 38 | height=self.pixel_y_size * self.tp.tile_size * self.tp.metatiling, 39 | width=self.pixel_x_size * self.tp.tile_size * self.tp.metatiling, 40 | ) 41 | # base bounds not accounting for pixelbuffers but metatiles are clipped to 42 | # TilePyramid bounds 43 | self._top = round(self.tp.top - (self.row * self._base_srid_size.height), ROUND) 44 | self._bottom = max([self._top - self._base_srid_size.height, self.tp.bottom]) 45 | self._left = round( 46 | self.tp.left + (self.col * self._base_srid_size.width), ROUND 47 | ) 48 | self._right = min([self._left + self._base_srid_size.width, self.tp.right]) 49 | # base shape without pixelbuffer 50 | self._base_shape = Shape( 51 | height=int(round((self._top - self._bottom) / self.pixel_y_size, 0)), 52 | width=int(round((self._right - self._left) / self.pixel_x_size, 0)), 53 | ) 54 | 55 | @property 56 | def left(self): 57 | return self.bounds().left 58 | 59 | @property 60 | def bottom(self): 61 | return self.bounds().bottom 62 | 63 | @property 64 | def right(self): 65 | return self.bounds().right 66 | 67 | @property 68 | def top(self): 69 | return self.bounds().top 70 | 71 | @property 72 | def srid(self): 73 | warnings.warn(DeprecationWarning("'srid' attribute is deprecated")) 74 | return self.tp.grid.srid 75 | 76 | @property 77 | def width(self): 78 | """Calculate Tile width in pixels.""" 79 | return self.shape().width 80 | 81 | @property 82 | def height(self): 83 | """Calculate Tile height in pixels.""" 84 | return self.shape().height 85 | 86 | @property 87 | def x_size(self): 88 | """Width of tile in SRID units at zoom level.""" 89 | return self.right - self.left 90 | 91 | @property 92 | def y_size(self): 93 | """Height of tile in SRID units at zoom level.""" 94 | return self.top - self.bottom 95 | 96 | def bounds(self, pixelbuffer=0): 97 | """ 98 | Return Tile boundaries. 99 | 100 | - pixelbuffer: tile buffer in pixels 101 | """ 102 | left = self._left 103 | bottom = self._bottom 104 | right = self._right 105 | top = self._top 106 | if pixelbuffer: 107 | offset = self.pixel_x_size * float(pixelbuffer) 108 | left -= offset 109 | bottom -= offset 110 | right += offset 111 | top += offset 112 | # on global grids clip at northern and southern TilePyramid bound 113 | if self.tp.grid.is_global: 114 | top = min([top, self.tile_pyramid.top]) 115 | bottom = max([bottom, self.tile_pyramid.bottom]) 116 | return Bounds(left, bottom, right, top) 117 | 118 | def bbox(self, pixelbuffer=0): 119 | """ 120 | Return Tile bounding box. 121 | 122 | - pixelbuffer: tile buffer in pixels 123 | """ 124 | return box(*self.bounds(pixelbuffer=pixelbuffer)) 125 | 126 | def affine(self, pixelbuffer=0): 127 | """ 128 | Return an Affine object of tile. 129 | 130 | - pixelbuffer: tile buffer in pixels 131 | """ 132 | return Affine( 133 | self.pixel_x_size, 134 | 0, 135 | self.bounds(pixelbuffer).left, 136 | 0, 137 | -self.pixel_y_size, 138 | self.bounds(pixelbuffer).top, 139 | ) 140 | 141 | def shape(self, pixelbuffer=0): 142 | """ 143 | Return a tuple of tile height and width. 144 | 145 | - pixelbuffer: tile buffer in pixels 146 | """ 147 | # apply pixelbuffers 148 | height = self._base_shape.height + 2 * pixelbuffer 149 | width = self._base_shape.width + 2 * pixelbuffer 150 | if pixelbuffer and self.tp.grid.is_global: 151 | # on first and last row, remove pixelbuffer on top or bottom 152 | matrix_height = self.tile_pyramid.matrix_height(self.zoom) 153 | if matrix_height == 1: 154 | height = self._base_shape.height 155 | elif self.row in [0, matrix_height - 1]: 156 | height = self._base_shape.height + pixelbuffer 157 | return Shape(height=height, width=width) 158 | 159 | def is_valid(self): 160 | """Return True if tile is available in tile pyramid.""" 161 | if not all( 162 | [ 163 | isinstance(self.zoom, int), 164 | self.zoom >= 0, 165 | isinstance(self.row, int), 166 | self.row >= 0, 167 | isinstance(self.col, int), 168 | self.col >= 0, 169 | ] 170 | ): 171 | raise TypeError("zoom, col and row must be integers >= 0") 172 | cols = self.tile_pyramid.matrix_width(self.zoom) 173 | rows = self.tile_pyramid.matrix_height(self.zoom) 174 | if self.col >= cols: 175 | raise ValueError("col (%s) exceeds matrix width (%s)" % (self.col, cols)) 176 | if self.row >= rows: 177 | raise ValueError("row (%s) exceeds matrix height (%s)" % (self.row, rows)) 178 | return True 179 | 180 | def get_parent(self): 181 | """Return tile from previous zoom level.""" 182 | return ( 183 | None 184 | if self.zoom == 0 185 | else self.tile_pyramid.tile(self.zoom - 1, self.row // 2, self.col // 2) 186 | ) 187 | 188 | def get_children(self): 189 | """Return tiles from next zoom level.""" 190 | next_zoom = self.zoom + 1 191 | return [ 192 | self.tile_pyramid.tile( 193 | next_zoom, self.row * 2 + row_offset, self.col * 2 + col_offset 194 | ) 195 | for row_offset, col_offset in [ 196 | (0, 0), # top left 197 | (0, 1), # top right 198 | (1, 1), # bottom right 199 | (1, 0), # bottom left 200 | ] 201 | if all( 202 | [ 203 | self.row * 2 + row_offset < self.tp.matrix_height(next_zoom), 204 | self.col * 2 + col_offset < self.tp.matrix_width(next_zoom), 205 | ] 206 | ) 207 | ] 208 | 209 | def get_neighbors(self, connectedness=8): 210 | """ 211 | Return tile neighbors. 212 | 213 | Tile neighbors are unique, i.e. in some edge cases, where both the left 214 | and right neighbor wrapped around the antimeridian is the same. Also, 215 | neighbors ouside the northern and southern TilePyramid boundaries are 216 | excluded, because they are invalid. 217 | 218 | ------------- 219 | | 8 | 1 | 5 | 220 | ------------- 221 | | 4 | x | 2 | 222 | ------------- 223 | | 7 | 3 | 6 | 224 | ------------- 225 | 226 | - connectedness: [4 or 8] return four direct neighbors or all eight. 227 | """ 228 | if connectedness not in [4, 8]: 229 | raise ValueError("only connectedness values 8 or 4 are allowed") 230 | 231 | unique_neighbors = {} 232 | # 4-connected neighborsfor pyramid 233 | matrix_offsets = [ 234 | (-1, 0), # 1: above 235 | (0, 1), # 2: right 236 | (1, 0), # 3: below 237 | (0, -1), # 4: left 238 | ] 239 | if connectedness == 8: 240 | matrix_offsets.extend( 241 | [ 242 | (-1, 1), # 5: above right 243 | (1, 1), # 6: below right 244 | (1, -1), # 7: below left 245 | (-1, -1), # 8: above left 246 | ] 247 | ) 248 | 249 | for row_offset, col_offset in matrix_offsets: 250 | new_row = self.row + row_offset 251 | new_col = self.col + col_offset 252 | # omit if row is outside of tile matrix 253 | if new_row < 0 or new_row >= self.tp.matrix_height(self.zoom): 254 | continue 255 | # wrap around antimeridian if new column is outside of tile matrix 256 | if new_col < 0: 257 | if not self.tp.is_global: 258 | continue 259 | new_col = self.tp.matrix_width(self.zoom) + new_col 260 | elif new_col >= self.tp.matrix_width(self.zoom): 261 | if not self.tp.is_global: 262 | continue 263 | new_col -= self.tp.matrix_width(self.zoom) 264 | # omit if new tile is current tile 265 | if new_row == self.row and new_col == self.col: 266 | continue 267 | # create new tile 268 | unique_neighbors[(new_row, new_col)] = self.tp.tile( 269 | self.zoom, new_row, new_col 270 | ) 271 | 272 | return unique_neighbors.values() 273 | 274 | def intersecting(self, tilepyramid): 275 | """ 276 | Return all Tiles from intersecting TilePyramid. 277 | 278 | This helps translating between TilePyramids with different metatiling 279 | settings. 280 | 281 | - tilepyramid: a TilePyramid object 282 | """ 283 | return _tile_intersecting_tilepyramid(self, tilepyramid) 284 | 285 | def __eq__(self, other): 286 | return ( 287 | isinstance(other, self.__class__) 288 | and self.tp == other.tp 289 | and self.id == other.id 290 | ) 291 | 292 | def __ne__(self, other): 293 | return not self.__eq__(other) 294 | 295 | def __repr__(self): 296 | return "Tile(%s, %s)" % (self.id, self.tp) 297 | 298 | def __hash__(self): 299 | return hash(repr(self)) 300 | 301 | def __iter__(self): 302 | yield self.zoom 303 | yield self.row 304 | yield self.col 305 | -------------------------------------------------------------------------------- /tilematrix/_tilepyramid.py: -------------------------------------------------------------------------------- 1 | """Handling tile pyramids.""" 2 | 3 | import math 4 | import warnings 5 | 6 | from shapely.prepared import prep 7 | 8 | from ._conf import ROUND 9 | from ._funcs import ( 10 | _global_tiles_from_bounds, 11 | _tile_from_xy, 12 | _tile_intersecting_tilepyramid, 13 | _tiles_from_cleaned_bounds, 14 | clip_geometry_to_srs_bounds, 15 | validate_zoom, 16 | ) 17 | from ._grid import GridDefinition 18 | from ._tile import Tile 19 | from ._types import Bounds 20 | 21 | 22 | class TilePyramid(object): 23 | """ 24 | A Tile pyramid is a collection of tile matrices for different zoom levels. 25 | 26 | TilePyramids can be defined either for the Web Mercator or the geodetic 27 | projection. A TilePyramid holds tiles for different discrete zoom levels. 28 | Each zoom level is a 2D tile matrix, thus every Tile can be defined by 29 | providing the zoom level as well as the row and column of the tile matrix. 30 | 31 | - projection: one of "geodetic" or "mercator" 32 | - tile_size: target pixel size of each tile 33 | - metatiling: tile size mulipilcation factor, must be one of 1, 2, 4, 8 or 34 | 16. 35 | """ 36 | 37 | def __init__(self, grid=None, tile_size=256, metatiling=1): 38 | """Initialize TilePyramid.""" 39 | if grid is None: 40 | raise ValueError("grid definition required") 41 | _metatiling_opts = [2**x for x in range(10)] 42 | if metatiling not in _metatiling_opts: 43 | raise ValueError(f"metatling must be one of {_metatiling_opts}") 44 | # get source grid parameters 45 | self.grid = GridDefinition(grid) 46 | self.bounds = self.grid.bounds 47 | self.left, self.bottom, self.right, self.top = self.bounds 48 | self.crs = self.grid.crs 49 | self.is_global = self.grid.is_global 50 | self.metatiling = metatiling 51 | # size in pixels 52 | self.tile_size = tile_size 53 | self.metatile_size = tile_size * metatiling 54 | # size in map units 55 | self.x_size = float(round(self.right - self.left, ROUND)) 56 | self.y_size = float(round(self.top - self.bottom, ROUND)) 57 | 58 | @property 59 | def type(self): 60 | warnings.warn(DeprecationWarning("'type' attribute is deprecated")) 61 | return self.grid.type 62 | 63 | @property 64 | def srid(self): 65 | warnings.warn(DeprecationWarning("'srid' attribute is deprecated")) 66 | return self.grid.srid 67 | 68 | def tile(self, zoom=None, row=None, col=None): 69 | """ 70 | Return Tile object of this TilePyramid. 71 | 72 | - zoom: zoom level 73 | - row: tile matrix row 74 | - col: tile matrix column 75 | """ 76 | return Tile(self, zoom, row, col) 77 | 78 | def matrix_width(self, zoom): 79 | """ 80 | Tile matrix width (number of columns) at zoom level. 81 | 82 | - zoom: zoom level 83 | """ 84 | validate_zoom(zoom) 85 | width = int(math.ceil(self.grid.shape.width * 2 ** (zoom) / self.metatiling)) 86 | return 1 if width < 1 else width 87 | 88 | def matrix_height(self, zoom): 89 | """ 90 | Tile matrix height (number of rows) at zoom level. 91 | 92 | - zoom: zoom level 93 | """ 94 | validate_zoom(zoom) 95 | height = int(math.ceil(self.grid.shape.height * 2 ** (zoom) / self.metatiling)) 96 | return 1 if height < 1 else height 97 | 98 | def tile_x_size(self, zoom): 99 | """ 100 | Width of a tile in SRID units at zoom level. 101 | 102 | - zoom: zoom level 103 | """ 104 | warnings.warn(DeprecationWarning("tile_x_size is deprecated")) 105 | validate_zoom(zoom) 106 | return round(self.x_size / self.matrix_width(zoom), ROUND) 107 | 108 | def tile_y_size(self, zoom): 109 | """ 110 | Height of a tile in SRID units at zoom level. 111 | 112 | - zoom: zoom level 113 | """ 114 | warnings.warn(DeprecationWarning("tile_y_size is deprecated")) 115 | validate_zoom(zoom) 116 | return round(self.y_size / self.matrix_height(zoom), ROUND) 117 | 118 | def tile_width(self, zoom): 119 | """ 120 | Tile width in pixel. 121 | 122 | - zoom: zoom level 123 | """ 124 | warnings.warn(DeprecationWarning("tile_width is deprecated")) 125 | validate_zoom(zoom) 126 | matrix_pixel = 2 ** (zoom) * self.tile_size * self.grid.shape.width 127 | tile_pixel = self.tile_size * self.metatiling 128 | return matrix_pixel if tile_pixel > matrix_pixel else tile_pixel 129 | 130 | def tile_height(self, zoom): 131 | """ 132 | Tile height in pixel. 133 | 134 | - zoom: zoom level 135 | """ 136 | warnings.warn(DeprecationWarning("tile_height is deprecated")) 137 | validate_zoom(zoom) 138 | matrix_pixel = 2 ** (zoom) * self.tile_size * self.grid.shape.height 139 | tile_pixel = self.tile_size * self.metatiling 140 | return matrix_pixel if tile_pixel > matrix_pixel else tile_pixel 141 | 142 | def pixel_x_size(self, zoom): 143 | """ 144 | Width of a pixel in SRID units at zoom level. 145 | 146 | - zoom: zoom level 147 | """ 148 | validate_zoom(zoom) 149 | return round( 150 | (self.grid.right - self.grid.left) 151 | / (self.grid.shape.width * 2**zoom * self.tile_size), 152 | ROUND, 153 | ) 154 | 155 | def pixel_y_size(self, zoom): 156 | """ 157 | Height of a pixel in SRID units at zoom level. 158 | 159 | - zoom: zoom level 160 | """ 161 | validate_zoom(zoom) 162 | return round( 163 | (self.grid.top - self.grid.bottom) 164 | / (self.grid.shape.height * 2**zoom * self.tile_size), 165 | ROUND, 166 | ) 167 | 168 | def intersecting(self, tile): 169 | """ 170 | Return all tiles intersecting with tile. 171 | 172 | This helps translating between TilePyramids with different metatiling 173 | settings. 174 | 175 | - tile: a Tile object 176 | """ 177 | return _tile_intersecting_tilepyramid(tile, self) 178 | 179 | def tiles_from_bounds(self, bounds=None, zoom=None, batch_by=None): 180 | """ 181 | Return all tiles intersecting with bounds. 182 | 183 | Bounds values will be cleaned if they cross the antimeridian or are 184 | outside of the Northern or Southern tile pyramid bounds. 185 | 186 | - bounds: tuple of (left, bottom, right, top) bounding values in tile 187 | pyramid CRS 188 | - zoom: zoom level 189 | - batch_by: yield tiles in row or column batches if activated 190 | """ 191 | validate_zoom(zoom) 192 | if not isinstance(bounds, tuple) or len(bounds) != 4: 193 | raise ValueError( 194 | "bounds must be a tuple of left, bottom, right, top values" 195 | ) 196 | if not isinstance(bounds, Bounds): 197 | bounds = Bounds(*bounds) 198 | if self.is_global: 199 | yield from _global_tiles_from_bounds(self, bounds, zoom, batch_by=batch_by) 200 | else: 201 | yield from _tiles_from_cleaned_bounds(self, bounds, zoom, batch_by=batch_by) 202 | 203 | def tiles_from_bbox(self, geometry=None, zoom=None, batch_by=None): 204 | """ 205 | All metatiles intersecting with given bounding box. 206 | 207 | - geometry: shapely geometry 208 | - zoom: zoom level 209 | """ 210 | validate_zoom(zoom) 211 | yield from self.tiles_from_bounds(geometry.bounds, zoom, batch_by=batch_by) 212 | 213 | def tiles_from_geom(self, geometry=None, zoom=None, batch_by=None, exact=False): 214 | """ 215 | Return all tiles intersecting with input geometry. 216 | 217 | - geometry: shapely geometry 218 | - zoom: zoom level 219 | """ 220 | validate_zoom(zoom) 221 | if geometry.is_empty: 222 | return 223 | if not geometry.is_valid: 224 | raise ValueError("no valid geometry: %s" % geometry.type) 225 | if geometry.geom_type == "Point": 226 | if batch_by: 227 | yield ( 228 | self.tile_from_xy(geometry.x, geometry.y, zoom) for _ in range(1) 229 | ) 230 | else: 231 | yield self.tile_from_xy(geometry.x, geometry.y, zoom) 232 | elif geometry.geom_type in ( 233 | "MultiPoint", 234 | "LineString", 235 | "MultiLineString", 236 | "Polygon", 237 | "MultiPolygon", 238 | "GeometryCollection", 239 | ): 240 | if exact: 241 | geometry = clip_geometry_to_srs_bounds(geometry, self) 242 | if batch_by: 243 | for batch in self.tiles_from_bbox( 244 | geometry, zoom, batch_by=batch_by 245 | ): 246 | yield ( 247 | tile 248 | for tile in batch 249 | if geometry.intersection(tile.bbox()).area 250 | ) 251 | else: 252 | for tile in self.tiles_from_bbox(geometry, zoom, batch_by=batch_by): 253 | if geometry.intersection(tile.bbox()).area: 254 | yield tile 255 | else: 256 | prepared_geometry = prep(clip_geometry_to_srs_bounds(geometry, self)) 257 | if batch_by: 258 | for batch in self.tiles_from_bbox( 259 | geometry, zoom, batch_by=batch_by 260 | ): 261 | yield ( 262 | tile 263 | for tile in batch 264 | if prepared_geometry.intersects(tile.bbox()) 265 | ) 266 | else: 267 | for tile in self.tiles_from_bbox(geometry, zoom, batch_by=batch_by): 268 | if prepared_geometry.intersects(tile.bbox()): 269 | yield tile 270 | 271 | def tile_from_xy(self, x=None, y=None, zoom=None, on_edge_use="rb"): 272 | """ 273 | Return tile covering a point defined by x and y values. 274 | 275 | - x: x coordinate 276 | - y: y coordinate 277 | - zoom: zoom level 278 | - on_edge_use: determine which Tile to pick if Point hits a grid edge 279 | - rb: right bottom (default) 280 | - rt: right top 281 | - lt: left top 282 | - lb: left bottom 283 | """ 284 | validate_zoom(zoom) 285 | if x < self.left or x > self.right or y < self.bottom or y > self.top: 286 | raise ValueError("x or y are outside of grid bounds") 287 | if on_edge_use not in ["lb", "rb", "rt", "lt"]: 288 | raise ValueError("on_edge_use must be one of lb, rb, rt or lt") 289 | return _tile_from_xy(self, x, y, zoom, on_edge_use=on_edge_use) 290 | 291 | def to_dict(self): 292 | """ 293 | Return dictionary representation of pyramid parameters. 294 | """ 295 | return dict( 296 | grid=self.grid.to_dict(), 297 | metatiling=self.metatiling, 298 | tile_size=self.tile_size, 299 | ) 300 | 301 | def from_dict(config_dict): 302 | """ 303 | Initialize TilePyramid from configuration dictionary. 304 | """ 305 | return TilePyramid(**config_dict) 306 | 307 | def __eq__(self, other): 308 | return ( 309 | isinstance(other, self.__class__) 310 | and self.grid == other.grid 311 | and self.tile_size == other.tile_size 312 | and self.metatiling == other.metatiling 313 | ) 314 | 315 | def __ne__(self, other): 316 | return not self.__eq__(other) 317 | 318 | def __repr__(self): 319 | return "TilePyramid(%s, tile_size=%s, metatiling=%s)" % ( 320 | self.grid, 321 | self.tile_size, 322 | self.metatiling, 323 | ) 324 | 325 | def __hash__(self): 326 | return hash(repr(self) + repr(self.grid)) 327 | -------------------------------------------------------------------------------- /tilematrix/_types.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | 3 | Bounds = namedtuple("Bounds", "left bottom right top") 4 | Shape = namedtuple("Shape", "height width") 5 | TileIndex = namedtuple("TileIndex", "zoom row col") 6 | -------------------------------------------------------------------------------- /tilematrix/tmx/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ungarj/tilematrix/275b4d79ca5c4c7d3423a0c6e403735beb081cd8/tilematrix/tmx/__init__.py -------------------------------------------------------------------------------- /tilematrix/tmx/main.py: -------------------------------------------------------------------------------- 1 | import click 2 | import geojson 3 | from shapely.geometry import box 4 | 5 | import tilematrix 6 | from tilematrix import TilePyramid 7 | 8 | 9 | @click.version_option(version=tilematrix.__version__, message="%(version)s") 10 | @click.group() 11 | @click.option( 12 | "--pixelbuffer", 13 | "-p", 14 | nargs=1, 15 | type=click.INT, 16 | default=0, 17 | help="Tile bounding box buffer in pixels. (default: 0)", 18 | ) 19 | @click.option( 20 | "--tile_size", 21 | "-s", 22 | nargs=1, 23 | type=click.INT, 24 | default=256, 25 | help="Tile size in pixels. (default: 256)", 26 | ) 27 | @click.option( 28 | "--metatiling", 29 | "-m", 30 | nargs=1, 31 | type=click.INT, 32 | default=1, 33 | help="TilePyramid metatile size. (default: 1)", 34 | ) 35 | @click.option( 36 | "--grid", 37 | "-g", 38 | type=click.Choice(["geodetic", "mercator"]), 39 | default="geodetic", 40 | help="TilePyramid base grid. (default: geodetic)", 41 | ) 42 | @click.option( 43 | "--output_format", 44 | "-f", 45 | type=click.Choice(["Tile", "WKT", "GeoJSON"]), 46 | default="Tile", 47 | help="Print Tile ID or Tile bounding box as WKT or GeoJSON. (default: Tile)", 48 | ) 49 | @click.pass_context 50 | def tmx(ctx, **kwargs): 51 | ctx.obj = dict(**kwargs) 52 | 53 | 54 | @tmx.command(short_help="Tile bounds.") 55 | @click.argument("TILE", nargs=3, type=click.INT, required=True) 56 | @click.pass_context 57 | def bounds(ctx, tile): 58 | """Print Tile bounds.""" 59 | click.echo( 60 | "%s %s %s %s" 61 | % TilePyramid( 62 | ctx.obj["grid"], 63 | tile_size=ctx.obj["tile_size"], 64 | metatiling=ctx.obj["metatiling"], 65 | ) 66 | .tile(*tile) 67 | .bounds(pixelbuffer=ctx.obj["pixelbuffer"]) 68 | ) 69 | 70 | 71 | @tmx.command(short_help="Tile bounding box.") 72 | @click.argument("TILE", nargs=3, type=click.INT, required=True) 73 | @click.pass_context 74 | def bbox(ctx, tile): 75 | """Print Tile bounding box as geometry.""" 76 | geom = ( 77 | TilePyramid( 78 | ctx.obj["grid"], 79 | tile_size=ctx.obj["tile_size"], 80 | metatiling=ctx.obj["metatiling"], 81 | ) 82 | .tile(*tile) 83 | .bbox(pixelbuffer=ctx.obj["pixelbuffer"]) 84 | ) 85 | if ctx.obj["output_format"] in ["WKT", "Tile"]: 86 | click.echo(geom) 87 | elif ctx.obj["output_format"] == "GeoJSON": 88 | click.echo(geojson.dumps(geom)) 89 | 90 | 91 | @tmx.command(short_help="Tile from point.") 92 | @click.argument("ZOOM", nargs=1, type=click.INT, required=True) 93 | @click.argument("POINT", nargs=2, type=click.FLOAT, required=True) 94 | @click.pass_context 95 | def tile(ctx, point, zoom): 96 | """Print Tile containing POINT..""" 97 | tile = TilePyramid( 98 | ctx.obj["grid"], 99 | tile_size=ctx.obj["tile_size"], 100 | metatiling=ctx.obj["metatiling"], 101 | ).tile_from_xy(*point, zoom=zoom) 102 | if ctx.obj["output_format"] == "Tile": 103 | click.echo("%s %s %s" % tile.id) 104 | elif ctx.obj["output_format"] == "WKT": 105 | click.echo(tile.bbox(pixelbuffer=ctx.obj["pixelbuffer"])) 106 | elif ctx.obj["output_format"] == "GeoJSON": 107 | click.echo( 108 | geojson.dumps( 109 | geojson.FeatureCollection( 110 | [ 111 | geojson.Feature( 112 | geometry=tile.bbox(pixelbuffer=ctx.obj["pixelbuffer"]), 113 | properties=dict(zoom=tile.zoom, row=tile.row, col=tile.col), 114 | ) 115 | ] 116 | ) 117 | ) 118 | ) 119 | 120 | 121 | @tmx.command(short_help="Tiles from bounds.") 122 | @click.argument("ZOOM", nargs=1, type=click.INT, required=True) 123 | @click.argument("BOUNDS", nargs=4, type=click.FLOAT, required=True) 124 | @click.pass_context 125 | def tiles(ctx, bounds, zoom): 126 | """Print Tiles from bounds.""" 127 | tiles = TilePyramid( 128 | ctx.obj["grid"], 129 | tile_size=ctx.obj["tile_size"], 130 | metatiling=ctx.obj["metatiling"], 131 | ).tiles_from_bounds(bounds, zoom=zoom) 132 | if ctx.obj["output_format"] == "Tile": 133 | for tile in tiles: 134 | click.echo("%s %s %s" % tile.id) 135 | elif ctx.obj["output_format"] == "WKT": 136 | for tile in tiles: 137 | click.echo(tile.bbox(pixelbuffer=ctx.obj["pixelbuffer"])) 138 | elif ctx.obj["output_format"] == "GeoJSON": 139 | click.echo("{\n" ' "type": "FeatureCollection",\n' ' "features": [') 140 | # print tiles as they come and only add comma if there is a next tile 141 | try: 142 | tile = next(tiles) 143 | while True: 144 | gj = " %s" % geojson.Feature( 145 | geometry=tile.bbox(pixelbuffer=ctx.obj["pixelbuffer"]), 146 | properties=dict(zoom=tile.zoom, row=tile.row, col=tile.col), 147 | ) 148 | try: 149 | tile = next(tiles) 150 | click.echo(gj + ",") 151 | except StopIteration: 152 | click.echo(gj) 153 | raise 154 | except StopIteration: 155 | pass 156 | click.echo(" ]\n" "}") 157 | 158 | 159 | @tmx.command(short_help="Snap bounds to tile grid.") 160 | @click.argument("ZOOM", nargs=1, type=click.INT, required=True) 161 | @click.argument("BOUNDS", nargs=4, type=click.FLOAT, required=True) 162 | @click.pass_context 163 | def snap_bounds(ctx, bounds, zoom): 164 | """nap bounds to tile grid.""" 165 | click.echo( 166 | "%s %s %s %s" 167 | % tilematrix.snap_bounds( 168 | bounds=bounds, 169 | tile_pyramid=TilePyramid( 170 | ctx.obj["grid"], 171 | tile_size=ctx.obj["tile_size"], 172 | metatiling=ctx.obj["metatiling"], 173 | ), 174 | zoom=zoom, 175 | pixelbuffer=ctx.obj["pixelbuffer"], 176 | ) 177 | ) 178 | 179 | 180 | @tmx.command(short_help="Snap bbox to tile grid.") 181 | @click.argument("ZOOM", nargs=1, type=click.INT, required=True) 182 | @click.argument("BOUNDS", nargs=4, type=click.FLOAT, required=True) 183 | @click.pass_context 184 | def snap_bbox(ctx, bounds, zoom): 185 | """Snap bbox to tile grid.""" 186 | click.echo( 187 | box( 188 | *tilematrix.snap_bounds( 189 | bounds=bounds, 190 | tile_pyramid=TilePyramid( 191 | ctx.obj["grid"], 192 | tile_size=ctx.obj["tile_size"], 193 | metatiling=ctx.obj["metatiling"], 194 | ), 195 | zoom=zoom, 196 | pixelbuffer=ctx.obj["pixelbuffer"], 197 | ) 198 | ) 199 | ) 200 | --------------------------------------------------------------------------------