diff --git a/.gitignore b/.gitignore index b6e4761..23077b4 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# PyCharm: +.idea diff --git a/CHANGES.md b/CHANGES.md index 18489ba..10b3093 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,21 @@ ## 2.1.3 (TBD) * add **NZTM2000Quad** tile matrix set from LINZ (author @blacha, https://github.com/developmentseed/morecantile/pull/57) +* add **quadkey** supports (@author adrian-knauer, https://github.com/developmentseed/morecantile/pull/56) + + ```python + import morecantile + + tms = morecantile.tms.get("WebMercatorQuad") + + # Tile to Quadkey + tms.quadkey(486, 332, 10) + >>> "0313102310" + + # Quadkey to Tile + tms.quadkey_to_tile("0313102310") + >>> Tile(486, 332, 10) + ``` ## 2.1.2 (2021-05-18) diff --git a/morecantile/errors.py b/morecantile/errors.py index e32626f..ee67d47 100644 --- a/morecantile/errors.py +++ b/morecantile/errors.py @@ -19,3 +19,11 @@ class TileArgParsingError(MorecantileError): class PointOutsideTMSBounds(UserWarning): """Point is outside TMS bounds.""" + + +class NoQuadkeySupport(MorecantileError): + """Raised when a custom TileMatrixSet doesn't support quadkeys""" + + +class QuadKeyError(MorecantileError): + """Raised when errors occur in computing or parsing quad keys""" diff --git a/morecantile/models.py b/morecantile/models.py index 00db746..94bd7f3 100644 --- a/morecantile/models.py +++ b/morecantile/models.py @@ -4,16 +4,22 @@ import warnings from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, Union -from pydantic import AnyHttpUrl, BaseModel, Field, validator +from pydantic import AnyHttpUrl, BaseModel, Field, PrivateAttr, validator from rasterio.crs import CRS, epsg_treats_as_latlong, epsg_treats_as_northingeasting from rasterio.features import bounds as feature_bounds from rasterio.warp import transform, transform_bounds, transform_geom from .commons import BoundingBox, Coords, Tile -from .errors import InvalidIdentifier, PointOutsideTMSBounds +from .errors import ( + InvalidIdentifier, + NoQuadkeySupport, + PointOutsideTMSBounds, + QuadKeyError, +) from .utils import ( _parse_tile_arg, bbox_to_feature, + check_quadkey_support, meters_per_unit, point_in_bbox, truncate_lnglat, @@ -121,6 +127,7 @@ class TileMatrixSet(BaseModel): wellKnownScaleSet: Optional[AnyHttpUrl] = None boundingBox: Optional[TMSBoundingBox] tileMatrix: List[TileMatrix] + _is_quadtree: bool = PrivateAttr() class Config: """Configure TileMatrixSet.""" @@ -133,6 +140,11 @@ def sort_tile_matrices(cls, v): """Sort matrices by identifier""" return sorted(v, key=lambda m: int(m.identifier)) + def __init__(self, **kwargs): + """Check if TileMatrixSet supports quadkeys""" + super().__init__(**kwargs) + self._is_quadtree = check_quadkey_support(self.tileMatrix) + def __iter__(self): """Iterate over matrices""" for matrix in self.tileMatrix: @@ -783,3 +795,61 @@ def feature( feat["id"] = fid return feat + + def quadkey(self, *tile: Tile) -> str: + """Get the quadkey of a tile + Parameters + ---------- + tile : Tile or sequence of int + May be be either an instance of Tile or 3 ints, X, Y, Z. + Returns + ------- + str + """ + if not self._is_quadtree: + raise NoQuadkeySupport( + "This Tile Matrix Set doesn't support 2 x 2 quadkeys." + ) + + tile = _parse_tile_arg(*tile) + qk = [] + for z in range(tile.z, self.minzoom, -1): + digit = 0 + mask = 1 << (z - 1) + if tile.x & mask: + digit += 1 + if tile.y & mask: + digit += 2 + qk.append(str(digit)) + + return "".join(qk) + + def quadkey_to_tile(self, qk: str) -> Tile: + """Get the tile corresponding to a quadkey + Parameters + ---------- + qk : str + A quadkey string. + Returns + ------- + Tile + """ + if not self._is_quadtree: + raise NoQuadkeySupport( + "This Tile Matrix Set doesn't support 2 x 2 quadkeys." + ) + if len(qk) == 0: + return Tile(0, 0, 0) + xtile, ytile = 0, 0 + for i, digit in enumerate(reversed(qk)): + mask = 1 << i + if digit == "1": + xtile = xtile | mask + elif digit == "2": + ytile = ytile | mask + elif digit == "3": + xtile = xtile | mask + ytile = ytile | mask + elif digit != "0": + raise QuadKeyError("Unexpected quadkey digit: %r", digit) + return Tile(xtile, ytile, i + 1) diff --git a/morecantile/utils.py b/morecantile/utils.py index 2dd87b8..582f925 100644 --- a/morecantile/utils.py +++ b/morecantile/utils.py @@ -1,7 +1,7 @@ """morecantile utils.""" import math -from typing import Dict, Tuple +from typing import Dict, List, Tuple from rasterio.crs import CRS @@ -92,3 +92,20 @@ def point_in_bbox(point: Coords, bbox: BoundingBox, precision: int = 5) -> bool: and round(point.y, precision) >= round(bbox.bottom, precision) and round(point.y, precision) <= round(bbox.top, precision) ) + + +def is_power_of_two(number: int) -> bool: + """Check if a number is a power of 2""" + return (number & (number - 1) == 0) and number != 0 + + +def check_quadkey_support(tms: List) -> bool: + """Check if a Tile Matrix Set supports quadkeys""" + return all( + [ + (t.matrixWidth == t.matrixHeight) + and is_power_of_two(t.matrixWidth) + and ((t.matrixWidth * 2) == tms[i + 1].matrixWidth) + for i, t in enumerate(tms[:-1]) + ] + ) diff --git a/tests/test_models.py b/tests/test_models.py index 8b3116e..d9b5f72 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -10,6 +10,7 @@ from rasterio.crs import CRS import morecantile +from morecantile.commons import Tile from morecantile.errors import InvalidIdentifier from morecantile.models import TileMatrix, TileMatrixSet @@ -99,6 +100,52 @@ def test_load(): TileMatrixSet.load("ANotValidName") +def test_quadkey_support(): + tms = TileMatrixSet.load("CanadianNAD83_LCC") + assert not tms._is_quadtree + + tms = TileMatrixSet.load("UPSArcticWGS84Quad") + assert tms._is_quadtree + + +def test_quadkey(): + tms = morecantile.tms.get("WebMercatorQuad") + expected = "0313102310" + assert tms.quadkey(486, 332, 10) == expected + + +def test_quadkey_to_tile(): + tms = morecantile.tms.get("WebMercatorQuad") + qk = "0313102310" + expected = Tile(486, 332, 10) + assert tms.quadkey_to_tile(qk) == expected + + +def test_empty_quadkey_to_tile(): + tms = morecantile.tms.get("WebMercatorQuad") + qk = "" + expected = Tile(0, 0, 0) + assert tms.quadkey_to_tile(qk) == expected + + +def test_quadkey_failure(): + tms = morecantile.tms.get("WebMercatorQuad") + with pytest.raises(morecantile.errors.QuadKeyError): + tms.quadkey_to_tile("lolwut") + + +def test_quadkey_not_supported_failure(): + tms = TileMatrixSet.load("NZTM2000") + with pytest.raises(morecantile.errors.NoQuadkeySupport): + tms.quadkey(1, 1, 1) + + +def test_quadkey_to_tile_not_supported_failure(): + tms = TileMatrixSet.load("NZTM2000") + with pytest.raises(morecantile.errors.NoQuadkeySupport): + tms.quadkey_to_tile("3") + + def test_findMatrix(): """Should raise an error when TileMatrix is not found.""" tms = morecantile.tms.get("WebMercatorQuad") diff --git a/tests/test_morecantile.py b/tests/test_morecantile.py index 07729e8..26c21c0 100644 --- a/tests/test_morecantile.py +++ b/tests/test_morecantile.py @@ -6,7 +6,7 @@ import morecantile from morecantile.errors import InvalidIdentifier, PointOutsideTMSBounds -from morecantile.utils import meters_per_unit +from morecantile.utils import is_power_of_two, meters_per_unit from .conftest import requires_gdal3, requires_gdal_lt_3 @@ -467,3 +467,8 @@ def test_extend_zoom(): more = tms.xy_bounds(2000, 2000, 30) for a, b in zip(more, merc): assert round(a - b, 7) == 0 + + +def test_is_power_of_two(): + assert is_power_of_two(8) + assert not is_power_of_two(7)