Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Quadkey Support #56

Merged
merged 14 commits into from
Aug 20, 2021
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,6 @@ dmypy.json

# Pyre type checker
.pyre/

# PyCharm:
.idea
8 changes: 8 additions & 0 deletions morecantile/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
71 changes: 70 additions & 1 deletion morecantile/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,16 @@
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,
Expand Down Expand Up @@ -121,6 +127,7 @@ class TileMatrixSet(BaseModel):
wellKnownScaleSet: Optional[AnyHttpUrl] = None
boundingBox: Optional[TMSBoundingBox]
tileMatrix: List[TileMatrix]
is_quadtree: bool = False
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should use PrivateAttr https://pydantic-docs.helpmanual.io/usage/models/#private-model-attributes for this. is_quadtree is not something defined in the TMS spec so I think it would be better if we make it somehow private cc @adrian-knauer @geospatial-jeff

FYI: I didn't know about PrivateAttr until today, so I guess this is new to Pydantic

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes a lot of sense to me! I wasn't aware of PrivateAttr as well.


class Config:
"""Configure TileMatrixSet."""
Expand All @@ -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:
Expand Down Expand Up @@ -783,3 +795,60 @@ 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)
xtile, ytile, zoom = tile
qk = []
for z in range(zoom, self.minzoom, -1):
digit = 0
mask = 1 << (z - 1)
if xtile & mask:
digit += 1
if ytile & 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)
19 changes: 18 additions & 1 deletion morecantile/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""morecantile utils."""

import math
from typing import Dict, Tuple
from typing import Dict, List, Tuple

from rasterio.crs import CRS

Expand Down Expand Up @@ -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])
]
)
47 changes: 47 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

adrian-knauer marked this conversation as resolved.
Show resolved Hide resolved

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")


adrian-knauer marked this conversation as resolved.
Show resolved Hide resolved
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")
Expand Down
7 changes: 6 additions & 1 deletion tests/test_morecantile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 gdal_version, requires_gdal3, requires_gdal_lt_3

Expand Down Expand Up @@ -493,3 +493,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)