diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5a2515..6a4bb6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 @@ -30,9 +30,9 @@ jobs: - name: Run Tox run: tox -e py - # Run pre-commit (only for python-3.7) + # Run pre-commit (only for python-3.8) - name: run pre-commit - if: matrix.python-version == 3.7 + if: matrix.python-version == 3.8 run: pre-commit run --all-files - name: Upload Results @@ -44,32 +44,8 @@ jobs: name: ${{ matrix.platform }}-${{ matrix.tox-env }} fail_ci_if_error: false - gdal: - needs: [tests] - runs-on: ubuntu-latest - strategy: - matrix: - # Rasterio 1.1.8 wheels come with GDAL 2 - # Rasterio 1.2.3 wheels come with GDAL 3.2 - rasterio-version: ["==1.1.8", ">=1.2"] - steps: - - uses: actions/checkout@v2 - - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: "3.8" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install .["test"] rasterio${{ matrix.rasterio-version }} - - - name: Run Test - run: python -m pytest --cov morecantile --cov-report term-missing --ignore=venv - publish: - needs: [tests, gdal] + needs: [tests] runs-on: ubuntu-latest if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' steps: diff --git a/CHANGES.md b/CHANGES.md index c22a6b2..a5a57f5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,4 +1,14 @@ +## 3.0.0a0 + +* add `.rasterio_crs` properties to TMS for compatibility with rasterio (https://github.com/developmentseed/morecantile/pull/58) + +**breaking changes** + +* switch from rasterio to PyProj for CRS definition and projection transformation (https://github.com/developmentseed/morecantile/pull/58) +* remove python 3.6 supports (because of pyproj) + + ## 2.1.4 (2021-08-20) * add **NZTM2000Quad** tile matrix set from LINZ (author @blacha, https://github.com/developmentseed/morecantile/pull/57) @@ -20,7 +30,6 @@ * update `NZTM2000*` CRS uri from `https://www.opengis.net/def/crs/EPSG/0/2193` to `urn:ogc:def:crs:EPSG:2193` (https://github.com/developmentseed/morecantile/pull/61) - ## 2.1.3 - Doesn't exists ## 2.1.2 (2021-05-18) diff --git a/morecantile/__init__.py b/morecantile/__init__.py index 018f972..79ef3c1 100644 --- a/morecantile/__init__.py +++ b/morecantile/__init__.py @@ -8,7 +8,7 @@ """ -__version__ = "2.1.4" +__version__ = "3.0.0a0" from .commons import BoundingBox, Coords, Tile # noqa from .defaults import tms # noqa diff --git a/morecantile/commons.py b/morecantile/commons.py index 6a183c4..d9e804e 100644 --- a/morecantile/commons.py +++ b/morecantile/commons.py @@ -1,14 +1,29 @@ """Morecantile commons.""" -from collections import OrderedDict, namedtuple +from typing import NamedTuple -from rasterio.coords import BoundingBox # noqa -_Tile = namedtuple("_Tile", ["x", "y", "z"]) -_Coords = namedtuple("_Coords", ["x", "y"]) +class BoundingBox(NamedTuple): + """A xmin,ymin,xmax,ymax coordinates tuple. + + Args: + left (number): min horizontal coordinate. + bottom (number):min vertical coordinate. + right (number): max horizontal coordinate. + top (number): max vertical coordinate. + + Examples: + >>> BoundingBox(-180.0, -90.0, 180.0, 90.0) + + """ + + left: float + bottom: float + right: float + top: float -class Coords(_Coords): +class Coords(NamedTuple): """A x,y Coordinates pair. Args: @@ -20,11 +35,11 @@ class Coords(_Coords): """ - def _asdict(self): - return OrderedDict(zip(self._fields, self)) + x: float + y: float -class Tile(_Tile): +class Tile(NamedTuple): """TileMatrixSet X,Y,Z tile indices. Args: @@ -37,5 +52,6 @@ class Tile(_Tile): """ - def _asdict(self): - return OrderedDict(zip(self._fields, self)) + x: int + y: int + z: int diff --git a/morecantile/data/CanadianNAD83_LCC.json b/morecantile/data/CanadianNAD83_LCC.json index a6db991..e71ac4c 100644 --- a/morecantile/data/CanadianNAD83_LCC.json +++ b/morecantile/data/CanadianNAD83_LCC.json @@ -4,7 +4,7 @@ "identifier": "CanadianNAD83_LCC", "boundingBox": { "type": "BoundingBoxType", - "crs": "http://www.opengis.net/def/crs/EPSG/0/3978", + "crs": "urn:ogc:def:crs:EPSG::3978", "lowerCorner": [ -7786476.885838887, -5153821.09213678 @@ -14,7 +14,7 @@ 7928343.534071138 ] }, - "supportedCRS": "http://www.opengis.net/def/crs/EPSG/0/3978", + "supportedCRS": "urn:ogc:def:crs:EPSG::3978", "tileMatrix": [ { "type": "TileMatrixType", @@ -355,4 +355,4 @@ "matrixHeight": 2625811 } ] -} \ No newline at end of file +} diff --git a/morecantile/data/EuropeanETRS89_LAEAQuad.json b/morecantile/data/EuropeanETRS89_LAEAQuad.json index b8c234e..ec56d52 100755 --- a/morecantile/data/EuropeanETRS89_LAEAQuad.json +++ b/morecantile/data/EuropeanETRS89_LAEAQuad.json @@ -4,7 +4,7 @@ "identifier": "EuropeanETRS89_LAEAQuad", "boundingBox": { "type": "BoundingBoxType", - "crs": "http://www.opengis.net/def/crs/EPSG/0/3035", + "crs": "urn:ogc:def:crs:EPSG::3035", "lowerCorner": [ 1000000.0, 2000000.0 @@ -14,7 +14,7 @@ 5500000.0 ] }, - "supportedCRS": "http://www.opengis.net/def/crs/EPSG/0/3035", + "supportedCRS": "urn:ogc:def:crs:EPSG::3035", "tileMatrix": [ { "type": "TileMatrixType", @@ -225,4 +225,4 @@ "matrixHeight": 32768 } ] -} \ No newline at end of file +} diff --git a/morecantile/data/LINZAntarticaMapTilegrid.json b/morecantile/data/LINZAntarticaMapTilegrid.json index 2eacaa2..594249b 100644 --- a/morecantile/data/LINZAntarticaMapTilegrid.json +++ b/morecantile/data/LINZAntarticaMapTilegrid.json @@ -2,7 +2,7 @@ "type": "TileMatrixSetType", "title": "LINZ Antarctic Map Tile Grid (Ross Sea Region)", "identifier": "LINZAntarticaMapTilegrid", - "supportedCRS": "http://www.opengis.net/def/crs/EPSG/0/5482", + "supportedCRS": "urn:ogc:def:crs:EPSG::5482", "tileMatrix": [ { "type": "TileMatrixType", @@ -187,4 +187,4 @@ "matrixHeight": 3303 } ] -} \ No newline at end of file +} diff --git a/morecantile/data/NZTM2000.json b/morecantile/data/NZTM2000.json index 507394a..62eb1a6 100644 --- a/morecantile/data/NZTM2000.json +++ b/morecantile/data/NZTM2000.json @@ -3,10 +3,10 @@ "title": "LINZ NZTM2000 Map Tile Grid", "abstract": "See https://www.linz.govt.nz/data/linz-data-service/guides-and-documentation/nztm2000-map-tile-service-schema", "identifier": "NZTM2000", - "supportedCRS": "urn:ogc:def:crs:EPSG:2193", + "supportedCRS": "urn:ogc:def:crs:EPSG::2193", "boundingBox": { "type": "BoundingBoxType", - "crs": "urn:ogc:def:crs:EPSG:2193", + "crs": "urn:ogc:def:crs:EPSG::2193", "lowerCorner": [ 3087000, 274000 diff --git a/morecantile/data/NZTM2000Quad.json b/morecantile/data/NZTM2000Quad.json index 3e2f3de..d8f01da 100644 --- a/morecantile/data/NZTM2000Quad.json +++ b/morecantile/data/NZTM2000Quad.json @@ -3,10 +3,10 @@ "title": "LINZ NZTM2000Quad Map Tile Grid", "abstract": "See https://github.com/linz/NZTM2000TileMatrixSet", "identifier": "NZTM2000Quad", - "supportedCRS": "urn:ogc:def:crs:EPSG:2193", + "supportedCRS": "urn:ogc:def:crs:EPSG::2193", "boundingBox": { "type": "BoundingBoxType", - "crs": "urn:ogc:def:crs:EPSG:2193", + "crs": "urn:ogc:def:crs:EPSG::2193", "lowerCorner": [ 419435.9938, -3260586.7284 diff --git a/morecantile/data/UPSAntarcticWGS84Quad.json b/morecantile/data/UPSAntarcticWGS84Quad.json index 0764774..3c45dd4 100644 --- a/morecantile/data/UPSAntarcticWGS84Quad.json +++ b/morecantile/data/UPSAntarcticWGS84Quad.json @@ -4,7 +4,7 @@ "identifier": "UPSAntarcticWGS84Quad", "boundingBox": { "type": "BoundingBoxType", - "crs": "http://www.opengis.net/def/crs/EPSG/0/5042", + "crs": "urn:ogc:def:crs:EPSG::5042", "lowerCorner": [ -14440759.350252, -14440759.350252 @@ -14,7 +14,7 @@ 18440759.350252 ] }, - "supportedCRS": "http://www.opengis.net/def/crs/EPSG/0/5042", + "supportedCRS": "urn:ogc:def:crs:EPSG::5042", "tileMatrix": [ { "type": "TileMatrixType", @@ -342,4 +342,4 @@ "matrixHeight": 16777216 } ] -} \ No newline at end of file +} diff --git a/morecantile/data/UPSArcticWGS84Quad.json b/morecantile/data/UPSArcticWGS84Quad.json index d9a13ae..b86fb88 100644 --- a/morecantile/data/UPSArcticWGS84Quad.json +++ b/morecantile/data/UPSArcticWGS84Quad.json @@ -4,7 +4,7 @@ "identifier": "UPSArcticWGS84Quad", "boundingBox": { "type": "BoundingBoxType", - "crs": "http://www.opengis.net/def/crs/EPSG/0/5041", + "crs": "urn:ogc:def:crs:EPSG::5041", "lowerCorner": [ -14440759.350252, -14440759.350252 @@ -14,7 +14,7 @@ 18440759.350252 ] }, - "supportedCRS": "http://www.opengis.net/def/crs/EPSG/0/5041", + "supportedCRS": "urn:ogc:def:crs:EPSG::5041", "tileMatrix": [ { "type": "TileMatrixType", @@ -342,4 +342,4 @@ "matrixHeight": 16777216 } ] -} \ No newline at end of file +} diff --git a/morecantile/data/UTM31WGS84Quad.json b/morecantile/data/UTM31WGS84Quad.json index 90a37dc..1bfe0ed 100644 --- a/morecantile/data/UTM31WGS84Quad.json +++ b/morecantile/data/UTM31WGS84Quad.json @@ -4,7 +4,7 @@ "identifier": "UTM31WGS84Quad", "boundingBox": { "type": "BoundingBoxType", - "crs": "http://www.opengis.net/def/crs/EPSG/0/32631", + "crs": "urn:ogc:def:crs:EPSG::32631", "lowerCorner": [ -9501965.72931276, -20003931.4586255 @@ -14,7 +14,7 @@ 20003931.4586255 ] }, - "supportedCRS": "http://www.opengis.net/def/crs/EPSG/0/32631", + "supportedCRS": "urn:ogc:def:crs:EPSG::32631", "tileMatrix": [ { "type": "TileMatrixType", @@ -329,4 +329,4 @@ "matrixHeight": 16777216 } ] -} \ No newline at end of file +} diff --git a/morecantile/data/WebMercatorQuad.json b/morecantile/data/WebMercatorQuad.json index 2da06e4..8952431 100755 --- a/morecantile/data/WebMercatorQuad.json +++ b/morecantile/data/WebMercatorQuad.json @@ -4,7 +4,7 @@ "identifier": "WebMercatorQuad", "boundingBox": { "type": "BoundingBoxType", - "crs": "http://www.opengis.net/def/crs/EPSG/0/3857", + "crs": "urn:ogc:def:crs:EPSG::3857", "lowerCorner": [ -20037508.3427892, -20037508.3427892 @@ -14,7 +14,7 @@ 20037508.3427892 ] }, - "supportedCRS": "http://www.opengis.net/def/crs/EPSG/0/3857", + "supportedCRS": "urn:ogc:def:crs:EPSG::3857", "wellKnownScaleSet": "http://www.opengis.net/def/wkss/OGC/1.0/GoogleMapsCompatible", "tileMatrix": [ { @@ -343,4 +343,4 @@ "matrixHeight": 16777216 } ] -} \ No newline at end of file +} diff --git a/morecantile/data/WorldCRS84Quad.json b/morecantile/data/WorldCRS84Quad.json index 9af4ed8..99eb93f 100644 --- a/morecantile/data/WorldCRS84Quad.json +++ b/morecantile/data/WorldCRS84Quad.json @@ -4,7 +4,7 @@ "identifier": "WorldCRS84Quad", "boundingBox": { "type": "BoundingBoxType", - "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "crs": "urn:ogc:def:crs:OGC::CRS84", "lowerCorner": [ -180, -90 @@ -14,7 +14,7 @@ 90 ] }, - "supportedCRS": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "supportedCRS": "urn:ogc:def:crs:OGC::CRS84", "wellKnownScaleSet": "http://www.opengis.net/def/wkss/OGC/1.0/GoogleCRS84Quad", "tileMatrix": [ { @@ -252,4 +252,4 @@ "matrixHeight": 131072 } ] -} \ No newline at end of file +} diff --git a/morecantile/data/WorldMercatorWGS84Quad.json b/morecantile/data/WorldMercatorWGS84Quad.json index 5fe9444..187c0f2 100755 --- a/morecantile/data/WorldMercatorWGS84Quad.json +++ b/morecantile/data/WorldMercatorWGS84Quad.json @@ -4,7 +4,7 @@ "identifier": "WorldMercatorWGS84Quad", "boundingBox": { "type": "BoundingBoxType", - "crs": "http://www.opengis.net/def/crs/EPSG/0/3395", + "crs": "urn:ogc:def:crs:EPSG::3395", "lowerCorner": [ -20037508.3427892, -20037508.3427892 @@ -14,7 +14,7 @@ 20037508.3427892 ] }, - "supportedCRS": "http://www.opengis.net/def/crs/EPSG/0/3395", + "supportedCRS": "urn:ogc:def:crs:EPSG::3395", "wellKnownScaleSet": "http://www.opengis.net/def/wkss/OGC/1.0/WorldMercatorWGS84", "tileMatrix": [ { @@ -252,4 +252,4 @@ "matrixHeight": 131072 } ] -} \ No newline at end of file +} diff --git a/morecantile/defaults.py b/morecantile/defaults.py index 1104dc8..11d13be 100644 --- a/morecantile/defaults.py +++ b/morecantile/defaults.py @@ -2,7 +2,7 @@ import os import pathlib -from copy import deepcopy +from copy import copy from typing import Dict, List, Sequence, Union import attr @@ -56,4 +56,4 @@ def register( return TileMatrixSets({**self.tms, **new_tms}) -tms = TileMatrixSets(deepcopy(default_tms)) # noqa +tms = TileMatrixSets(copy(default_tms)) # noqa diff --git a/morecantile/models.py b/morecantile/models.py index 94bd7f3..1108ceb 100644 --- a/morecantile/models.py +++ b/morecantile/models.py @@ -1,21 +1,15 @@ """Pydantic modules for OGC TileMatrixSets (https://www.ogc.org/standards/tms)""" import math -import os import warnings from typing import Any, Dict, Iterator, List, Optional, Sequence, Tuple, Union 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 pyproj import CRS, Transformer +from pyproj.enums import WktVersion +from pyproj.exceptions import ProjError from .commons import BoundingBox, Coords, Tile -from .errors import ( - InvalidIdentifier, - NoQuadkeySupport, - PointOutsideTMSBounds, - QuadKeyError, -) +from .errors import NoQuadkeySupport, PointOutsideTMSBounds, QuadKeyError from .utils import ( _parse_tile_arg, bbox_to_feature, @@ -25,13 +19,20 @@ truncate_lnglat, ) +try: + from rasterio.crs import CRS as rasterioCRS + from rasterio.env import GDALVersion +except ModuleNotFoundError: + rasterioCRS = None + GDALVersion = None + NumType = Union[float, int] BoundsType = Tuple[NumType, NumType] LL_EPSILON = 1e-11 WGS84_CRS = CRS.from_epsg(4326) -class CRSType(CRS, AnyHttpUrl): +class CRSType(CRS, str): """ A geographic or projected coordinate reference system. """ @@ -42,11 +43,12 @@ def __get_validators__(cls): yield cls.validate @classmethod - def validate(cls, value: Union[CRS, AnyHttpUrl]): + def validate(cls, value: Union[CRS, str]) -> CRS: """Validate CRS.""" # If input is a string we tranlate it to CRS if not isinstance(value, CRS): return CRS.from_user_input(value) + return value @classmethod @@ -54,12 +56,13 @@ def __modify_schema__(cls, field_schema): """Update default schema.""" field_schema.update( anyOf=[ - {"type": "rasterio.crs.CRS"}, - {"type": "string", "minLength": 1, "maxLength": 65536, "format": "uri"}, + {"type": "pyproj.CRS"}, + {"type": "string", "minLength": 1, "maxLength": 65536}, ], examples=[ "CRS.from_epsg(4326)", "http://www.opengis.net/def/crs/EPSG/0/3978", + "urn:ogc:def:crs:EPSG::2193", ], ) @@ -76,7 +79,7 @@ def CRS_to_uri(crs: CRS) -> str: def crs_axis_inverted(crs: CRS) -> bool: """Check if CRS has inverted AXIS (lat,lon) instead of (lon,lat).""" - return epsg_treats_as_latlong(crs) or epsg_treats_as_northingeasting(crs) + return crs.is_geographic or crs.axis_info[0].name == "Northing" class TMSBoundingBox(BaseModel): @@ -128,6 +131,8 @@ class TileMatrixSet(BaseModel): boundingBox: Optional[TMSBoundingBox] tileMatrix: List[TileMatrix] _is_quadtree: bool = PrivateAttr() + _to_wgs84: Transformer = PrivateAttr() + _from_wgs84: Transformer = PrivateAttr() class Config: """Configure TileMatrixSet.""" @@ -135,16 +140,31 @@ class Config: arbitrary_types_allowed = True json_encoders = {CRS: lambda v: CRS_to_uri(v)} + def __init__(self, **data): + """Create PyProj transforms and check if TileMatrixSet supports quadkeys.""" + super().__init__(**data) + self._is_quadtree = check_quadkey_support(self.tileMatrix) + try: + self._to_wgs84 = Transformer.from_crs( + self.supportedCRS, WGS84_CRS, always_xy=True + ) + self._from_wgs84 = Transformer.from_crs( + WGS84_CRS, self.supportedCRS, always_xy=True + ) + except ProjError: + warnings.warn( + "Could not create coordinate Transformer from input CRS to WGS84" + "tms methods might not be available.", + UserWarning, + ) + self._to_wgs84 = None + self._from_wgs84 = None + @validator("tileMatrix") 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: @@ -159,6 +179,19 @@ def crs(self) -> CRS: """Fetch CRS from epsg""" return self.supportedCRS + @property + def rasterio_crs(self) -> rasterioCRS: + """Return rasterio CRS.""" + if not rasterioCRS: + raise ModuleNotFoundError( + "Rasterio has to be installed to use `rasterio_crs` method." + ) + + if GDALVersion.runtime().major < 3: + return rasterioCRS.from_wkt(self.crs.to_wkt(WktVersion.WKT1_GDAL)) + else: + return rasterioCRS.from_wkt(self.crs.to_wkt()) + @property def minzoom(self) -> int: """TileMatrixSet minimum TileMatrix identifier""" @@ -174,18 +207,6 @@ def _invert_axis(self) -> bool: """Check if CRS has inverted AXIS (lat,lon) instead of (lon,lat).""" return crs_axis_inverted(self.crs) - @classmethod - def load(cls, name: str): - """Load default TileMatrixSet.""" - warnings.warn( - "TileMatrixSet.load will be deprecated in version 2.0.0", DeprecationWarning - ) - try: - data_dir = os.path.join(os.path.dirname(__file__), "data") - return cls.parse_file(os.path.join(data_dir, f"{name}.json")) - except FileNotFoundError: - raise InvalidIdentifier(f"'{name}' is not a valid default TileMatrixSet.") - @classmethod def custom( cls, @@ -205,7 +226,7 @@ def custom( Attributes ---------- - crs: rasterio.crs.CRS + crs: pyproj.CRS Tile Matrix Set coordinate reference system extent: list Bounding box of the Tile Matrix Set, (left, bottom, right, top). @@ -217,8 +238,8 @@ def custom( Tiling schema coalescence coefficient (default: [1, 1] for EPSG:3857). Should be set to [2, 1] for EPSG:4326. see: http://docs.opengeospatial.org/is/17-083r2/17-083r2.html#14 - extent_crs: rasterio.crs.CRS - Extent's coordinate reference system, as a rasterio CRS object. + extent_crs: pyproj.CRS + Extent's coordinate reference system, as a pyproj CRS object. (default: same as input crs) minzoom: int Tile Matrix Set minimum zoom level (default is 0). @@ -241,11 +262,7 @@ def custom( "tileMatrix": [], } - is_inverted = False - if crs.to_epsg(): - # We use URI because with EPSG code it doesn't work in GDAL 2 - crs_uri = CRS_to_uri(crs) - is_inverted = crs_axis_inverted(CRS.from_user_input(crs_uri)) + is_inverted = crs_axis_inverted(crs) if is_inverted: tms["boundingBox"] = TMSBoundingBox( @@ -261,8 +278,10 @@ def custom( ) if extent_crs: + transform = Transformer.from_crs(extent_crs, crs, always_xy=True) + left, bottom, right, top = extent bbox = BoundingBox( - *transform_bounds(extent_crs, crs, *extent, densify_pts=21) + *transform.transform_bounds(left, bottom, right, top, densify_pts=21) ) else: bbox = BoundingBox(*extent) @@ -408,8 +427,7 @@ def lnglat(self, x: float, y: float, truncate=False) -> Coords: PointOutsideTMSBounds, ) - xs, ys = transform(self.crs, WGS84_CRS, [x], [y]) - lng, lat = xs[0], ys[0] + lng, lat = self._to_wgs84.transform(x, y) if truncate: lng, lat = truncate_lnglat(lng, lat) @@ -428,14 +446,7 @@ def xy(self, lng: float, lat: float, truncate=False) -> Coords: PointOutsideTMSBounds, ) - xs, ys = transform(WGS84_CRS, self.crs, [lng], [lat]) - x, y = xs[0], ys[0] - - # https://github.com/mapbox/mercantile/blob/master/mercantile/__init__.py#L232-L237 - if lat <= -90: - y = float("-inf") - elif lat >= 90: - y = float("inf") + x, y = self._from_wgs84.transform(lng, lat) return Coords(x, y) @@ -525,8 +536,9 @@ def _ul(self, *tile: Tile) -> Coords: The upper left geospatial coordiantes of the input tile. """ - tile = _parse_tile_arg(*tile) - matrix = self.matrix(tile.z) + t = _parse_tile_arg(*tile) + + matrix = self.matrix(t.z) res = self._resolution(matrix) origin_x = ( @@ -536,8 +548,8 @@ def _ul(self, *tile: Tile) -> Coords: matrix.topLeftCorner[0] if self._invert_axis else matrix.topLeftCorner[1] ) - xcoord = origin_x + tile.x * res * matrix.tileWidth - ycoord = origin_y - tile.y * res * matrix.tileHeight + xcoord = origin_x + t.x * res * matrix.tileWidth + ycoord = origin_y - t.y * res * matrix.tileHeight return Coords(xcoord, ycoord) def xy_bounds(self, *tile: Tile) -> BoundingBox: @@ -553,9 +565,10 @@ def xy_bounds(self, *tile: Tile) -> BoundingBox: The bounding box of the input tile. """ - tile = _parse_tile_arg(*tile) - left, top = self._ul(*tile) - right, bottom = self._ul(tile.x + 1, tile.y + 1, tile.z) + t = _parse_tile_arg(*tile) + + left, top = self._ul(t) + right, bottom = self._ul(Tile(t.x + 1, t.y + 1, t.z)) return BoundingBox(left, bottom, right, top) def ul(self, *tile: Tile) -> Coords: @@ -571,7 +584,9 @@ def ul(self, *tile: Tile) -> Coords: The upper left geospatial coordiantes of the input tile. """ - x, y = self._ul(*tile) + t = _parse_tile_arg(*tile) + + x, y = self._ul(t) return Coords(*self.lnglat(x, y)) def bounds(self, *tile: Tile) -> BoundingBox: @@ -587,9 +602,10 @@ def bounds(self, *tile: Tile) -> BoundingBox: The bounding box of the input tile. """ - tile = _parse_tile_arg(*tile) - left, top = self.ul(tile.x, tile.y, tile.z) - right, bottom = self.ul(tile.x + 1, tile.y + 1, tile.z) + t = _parse_tile_arg(*tile) + + left, top = self.ul(t) + right, bottom = self.ul(Tile(t.x + 1, t.y + 1, t.z)) return BoundingBox(left, bottom, right, top) @property @@ -617,31 +633,30 @@ def xy_bbox(self): else self.boundingBox.upperCorner[1] ) if self.boundingBox.crs != self.crs: - left, bottom, right, top = transform_bounds( - self.boundingBox.crs, - self.crs, - left, - bottom, - right, - top, - densify_pts=21, + transform = Transformer.from_crs( + self.boundingBox.crs, self.crs, always_xy=True + ) + left, bottom, right, top = transform.transform_bounds( + left, bottom, right, top, densify_pts=21, ) else: zoom = self.minzoom matrix = self.matrix(zoom) - left, top = self._ul(0, 0, zoom) - right, bottom = self._ul(matrix.matrixWidth, matrix.matrixHeight, zoom) + left, top = self._ul(Tile(0, 0, zoom)) + right, bottom = self._ul( + Tile(matrix.matrixWidth, matrix.matrixHeight, zoom) + ) return BoundingBox(left, bottom, right, top) @property def bbox(self): """Return TMS bounding box in WGS84.""" - left, bottom, right, top = transform_bounds( - self.crs, WGS84_CRS, *self.xy_bbox, densify_pts=21 + left, bottom, right, top = self.xy_bbox + return BoundingBox( + *self._to_wgs84.transform_bounds(left, bottom, right, top, densify_pts=21,) ) - return BoundingBox(left, bottom, right, top) def intersect_tms(self, bbox: BoundingBox) -> bool: """Check if a bounds intersects with the TMS bounds.""" @@ -747,9 +762,9 @@ def feature( west, south, east, north = self.xy_bounds(tile) if not projected: - geom = bbox_to_feature(west, south, east, north) - geom = transform_geom(self.crs, WGS84_CRS, geom) - west, south, east, north = feature_bounds(geom) + west, south, east, north = self._to_wgs84.transform_bounds( + west, south, east, north, densify_pts=21 + ) if buffer: west -= buffer @@ -798,27 +813,31 @@ def feature( 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) + t = _parse_tile_arg(*tile) + qk = [] - for z in range(tile.z, self.minzoom, -1): + for z in range(t.z, self.minzoom, -1): digit = 0 mask = 1 << (z - 1) - if tile.x & mask: + if t.x & mask: digit += 1 - if tile.y & mask: + if t.y & mask: digit += 2 qk.append(str(digit)) @@ -826,20 +845,25 @@ def quadkey(self, *tile: Tile) -> str: 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 @@ -852,4 +876,5 @@ def quadkey_to_tile(self, qk: str) -> Tile: ytile = ytile | mask elif digit != "0": raise QuadKeyError("Unexpected quadkey digit: %r", digit) + return Tile(xtile, ytile, i + 1) diff --git a/morecantile/scripts/cli.py b/morecantile/scripts/cli.py index 24283f0..880065f 100644 --- a/morecantile/scripts/cli.py +++ b/morecantile/scripts/cli.py @@ -5,8 +5,7 @@ import sys import click -from rasterio.crs import CRS -from rasterio.rio.helpers import coords +from pyproj import CRS import morecantile @@ -78,6 +77,28 @@ def feature_gen(): return feature_gen() +def coords(obj): + """Yield all coordinate coordinate tuples from a geometry or feature. + From python-geojson package. + + Original code from https://github.com/mapbox/rasterio/blob/3910956d6cfadd55ea085dd60790246c167967cd/rasterio/rio/helpers.py + License: Copyright (c) 2013, MapBox + """ + if isinstance(obj, (tuple, list)): + coordinates = obj + elif "geometry" in obj: + coordinates = obj["geometry"]["coordinates"] + else: + coordinates = obj.get("coordinates", obj) + for e in coordinates: + if isinstance(e, (float, int)): + yield tuple(coordinates) + break + else: + for f in coords(e): + yield f + + # The CLI command group. @click.group(help="Command line interface for the Morecantile Python package.") @click.option("--verbose", "-v", count=True, help="Increase verbosity.") diff --git a/morecantile/utils.py b/morecantile/utils.py index 582f925..bf51d14 100644 --- a/morecantile/utils.py +++ b/morecantile/utils.py @@ -3,7 +3,7 @@ import math from typing import Dict, List, Tuple -from rasterio.crs import CRS +from pyproj import CRS from .commons import BoundingBox, Coords, Tile from .errors import TileArgParsingError @@ -51,7 +51,9 @@ def meters_per_unit(crs: CRS) -> float: """ # crs.linear_units_factor[1] GDAL 3.0 - return 1.0 if crs.linear_units == "metre" else 2 * math.pi * 6378137 / 360.0 + return ( + 1.0 if crs.axis_info[0].unit_name == "metre" else 2 * math.pi * 6378137 / 360.0 + ) def truncate_lnglat(lng: float, lat: float) -> Tuple[float, float]: diff --git a/requirements.txt b/requirements.txt index 596b5dd..b0a37f9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -rasterio>=1.1 +attrs +pyproj~=3.1 pydantic -mercantile diff --git a/setup.cfg b/setup.cfg index d5d6f26..70f9029 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,10 +1,11 @@ + [bumpversion] current_version = 2.1.4 commit = True tag = True tag_name = {new_version} parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\.(?P.+))? -serialize = +serialize = {major}.{minor}.{patch}.{suffix} {major}.{minor}.{patch} @@ -19,7 +20,7 @@ replace = __version__ = "{new_version}" [isort] profile = black known_first_party = morecantile -known_third_party = rasterio,pydantic,mercantile +known_third_party = rasterio,pydantic,pyproj,mercantile default_section = THIRDPARTY [flake8] diff --git a/setup.py b/setup.py index c6a5469..9559a50 100644 --- a/setup.py +++ b/setup.py @@ -5,9 +5,10 @@ with open("README.md") as f: long_description = f.read() -inst_reqs = ["rasterio>=1.1.7", "pydantic"] +inst_reqs = ["attrs", "pyproj~=3.1", "pydantic"] extra_reqs = { + "rasterio": ["rasterio>=1.2.1"], "test": ["mercantile", "pytest", "pytest-cov"], "dev": ["pytest", "pytest-cov", "pre-commit"], "docs": ["mkdocs", "mkdocs-material", "pygments"], @@ -15,8 +16,8 @@ setup( name="morecantile", - version="2.1.4", - python_requires=">=3", + version="3.0.0a0", + python_requires=">=3.7", description=u"""Construct and use map tile grids (a.k.a TileMatrixSet / TMS).""", long_description=long_description, long_description_content_type="text/markdown", @@ -24,7 +25,6 @@ "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "License :: OSI Approved :: BSD License", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", @@ -35,7 +35,7 @@ author_email="vincent@developmentseed.org", url="https://github.com/developmentseed/morecantile", license="MIT", - packages=find_packages(exclude=["ez_setup", "examples", "tests"]), + packages=find_packages(exclude=["tests"]), include_package_data=True, package_data={"morecantile": ["data/*.json"]}, zip_safe=False, diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 177f66e..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,15 +0,0 @@ -"""``pytest`` configuration.""" - -import pytest -from rasterio.env import GDALVersion - -# Define helpers to skip tests based on GDAL version -gdal_version = GDALVersion.runtime() - -requires_gdal_lt_3 = pytest.mark.skipif( - gdal_version.__lt__("3.0"), reason="Requires GDAL 1.x/2.x" -) - -requires_gdal3 = pytest.mark.skipif( - not gdal_version.at_least("3.0"), reason="Requires GDAL 3.0.x" -) diff --git a/tests/test_cli.py b/tests/test_cli.py index ad28b1f..a29b8ae 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -96,13 +96,13 @@ def test_cli_shapesWGS84(): runner = CliRunner() result = runner.invoke( cli, - ["shapes", "--precision", "6", "--identifier", "WorldCRS84Quad"], + ["shapes", "--precision", "6", "--identifier", "WorldMercatorWGS84Quad"], "[106, 193, 9]", ) assert result.exit_code == 0 assert ( result.output - == '{"bbox": [-142.734375, 21.796875, -142.382813, 22.148438], "geometry": {"coordinates": [[[-142.734375, 21.796875], [-142.734375, 22.148438], [-142.382813, 22.148438], [-142.382813, 21.796875], [-142.734375, 21.796875]]], "type": "Polygon"}, "id": "(106, 193, 9)", "properties": {"grid_crs": "EPSG:4326", "grid_name": "WorldCRS84Quad", "title": "XYZ tile (106, 193, 9)"}, "type": "Feature"}\n' + == '{"bbox": [37.264008, -56.347191, 37.615571, -56.151464], "geometry": {"coordinates": [[[37.264008, -56.347191], [37.264008, -56.151464], [37.615571, -56.151464], [37.615571, -56.347191], [37.264008, -56.347191]]], "type": "Polygon"}, "id": "(106, 193, 9)", "properties": {"grid_crs": "EPSG:3395", "grid_name": "WorldMercatorWGS84Quad", "title": "XYZ tile (106, 193, 9)"}, "type": "Feature"}\n' ) diff --git a/tests/test_models.py b/tests/test_models.py index d9b5f72..b66780f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,15 +7,13 @@ import pytest from pydantic import ValidationError -from rasterio.crs import CRS +from pyproj import CRS import morecantile from morecantile.commons import Tile from morecantile.errors import InvalidIdentifier from morecantile.models import TileMatrix, TileMatrixSet -from .conftest import gdal_version - data_dir = os.path.join(os.path.dirname(__file__), "../morecantile/data") tilesets = [ os.path.join(data_dir, f) for f in os.listdir(data_dir) if f.endswith(".json") @@ -91,20 +89,17 @@ def test_tile_matrix(): TileMatrix(**variable_matrix) -def test_load(): - """Should raise an error when file not found.""" - with pytest.warns(DeprecationWarning): - TileMatrixSet.load("WebMercatorQuad") - +def test_invalid_tms(): + """should raise an error when tms name is not found.""" with pytest.raises(InvalidIdentifier): - TileMatrixSet.load("ANotValidName") + morecantile.tms.get("ANotValidName") def test_quadkey_support(): - tms = TileMatrixSet.load("CanadianNAD83_LCC") + tms = morecantile.tms.get("CanadianNAD83_LCC") assert not tms._is_quadtree - tms = TileMatrixSet.load("UPSArcticWGS84Quad") + tms = morecantile.tms.get("UPSArcticWGS84Quad") assert tms._is_quadtree @@ -135,13 +130,13 @@ def test_quadkey_failure(): def test_quadkey_not_supported_failure(): - tms = TileMatrixSet.load("NZTM2000") + tms = morecantile.tms.get("NZTM2000") with pytest.raises(morecantile.errors.NoQuadkeySupport): tms.quadkey(1, 1, 1) def test_quadkey_to_tile_not_supported_failure(): - tms = TileMatrixSet.load("NZTM2000") + tms = morecantile.tms.get("NZTM2000") with pytest.raises(morecantile.errors.NoQuadkeySupport): tms.quadkey_to_tile("3") @@ -188,13 +183,6 @@ def test_Custom(): assert round(wmMat.topLeftCorner[0], 6) == round(cusMat.topLeftCorner[0], 6) -# Before GDAL3, `morecantile.models.crs_axis_inverted` will always return False for -# CRS defined with `epsg`, which is why this test should fail in GDAL 2. -# ref https://github.com/mapbox/rasterio/blob/8cb216ca83e57284f8a56bafbe8eda4334a34db6/rasterio/crs.py#L509-L538 -@pytest.mark.xfail( - gdal_version.major < 3, - reason="In GDAL < 3.0, CRS defined with EPSG will always return False in morecantile.models.crs_axis_inverted", -) def test_custom_tms_bounds_epsg4326(): """Check bounds with epsg4326.""" custom_tms = TileMatrixSet.custom((-120, 30, -110, 40), CRS.from_epsg(4326)) @@ -207,10 +195,7 @@ def test_custom_tms_bounds_epsg4326(): # When using `from_user_input`, `morecantile.models.crs_axis_inverted` should return the valid result. def test_custom_tms_bounds_user_crs(): """Check bounds with epsg4326.""" - custom_tms = TileMatrixSet.custom( - (-120, 30, -110, 40), - CRS.from_user_input("http://www.opengis.net/def/crs/EPSG/0/4326"), - ) + custom_tms = TileMatrixSet.custom((-120, 30, -110, 40), CRS.from_epsg(4326),) assert custom_tms.xy_bbox == (-120, 30, -110, 40) assert custom_tms.bbox == (-120, 30, -110, 40) assert custom_tms.xy_bounds(0, 0, 0) == (-120, 30, -110, 40) @@ -229,8 +214,6 @@ def test_nztm_quad_is_quad(): def test_nztm_quad_scales(): nztm_tms = morecantile.tms.get("NZTM2000Quad") google_tms = morecantile.tms.get("WebMercatorQuad") - print(dir(google_tms)) - for z in range(2, nztm_tms.maxzoom + 2): assert ( round( @@ -290,7 +273,10 @@ def test_schema(): "+proj=stere +lat_0=90 +lon_0=0 +k=2 +x_0=0 +y_0=0 +R=3396190 +units=m +no_defs" ) extent = [-13584760.000, -13585240.000, 13585240.000, 13584760.000] - tms = morecantile.TileMatrixSet.custom(extent, crs, identifier="MarsNPolek2MOLA5k") + with pytest.warns(UserWarning): + tms = morecantile.TileMatrixSet.custom( + extent, crs, identifier="MarsNPolek2MOLA5k" + ) assert tms.schema() assert tms.schema_json() assert tms.dict(exclude_none=True) diff --git a/tests/test_morecantile.py b/tests/test_morecantile.py index 26c21c0..dc21e91 100644 --- a/tests/test_morecantile.py +++ b/tests/test_morecantile.py @@ -2,14 +2,12 @@ import mercantile import pytest -from rasterio.crs import CRS +from pyproj import CRS import morecantile from morecantile.errors import InvalidIdentifier, PointOutsideTMSBounds from morecantile.utils import is_power_of_two, meters_per_unit -from .conftest import requires_gdal3, requires_gdal_lt_3 - DEFAULT_GRID_COUNT = 11 @@ -75,10 +73,6 @@ def test_TMSproperties(): assert tms.minzoom == 0 assert tms.maxzoom == 24 - tms = morecantile.tms.get("WorldCRS84Quad") - assert tms.crs == CRS.from_epsg(4326) - assert meters_per_unit(tms.crs) == 111319.49079327358 - def test_tile_coordinates(): """Test coordinates to tile index utils.""" @@ -89,9 +83,6 @@ def test_tile_coordinates(): # wlon, wlat = mercantile.xy(20.0, 15.0) assert tms.tile(20.0, 15.0, 5) == mercantile.tile(20.0, 15.0, 5) - tms = morecantile.tms.get("WorldCRS84Quad") - assert tms.tile(-39.8, 74.2, 4) == morecantile.Tile(12, 1, 4) - @pytest.mark.parametrize( "args", [(486, 332, 10), [(486, 332, 10)], [morecantile.Tile(486, 332, 10)]] @@ -251,17 +242,23 @@ def test_xy_null_island(): @pytest.mark.xfail def test_xy_south_pole(): - """Return -inf for y at South Pole - Same as mercantile.""" + """Return -inf for y at South Pole + + Note: mercantile returns (0.0, inf) + """ tms = morecantile.tms.get("WebMercatorQuad") with pytest.warns(PointOutsideTMSBounds): xy = tms.xy(0.0, -90) assert xy.x == 0.0 - assert xy.y == float("-inf") + assert xy.y == float("inf") @pytest.mark.xfail def test_xy_north_pole(): - """Return inf for y at North Pole - Same as mercantile.""" + """Return inf for y at North Pole. + + Note: mercantile returns (0.0, -inf) + """ tms = morecantile.tms.get("WebMercatorQuad") with pytest.warns(PointOutsideTMSBounds): xy = tms.xy(0.0, 90) @@ -290,26 +287,13 @@ def test_lnglat(): with pytest.warns(PointOutsideTMSBounds): xy = (-28366731.739810849, -1655181.9927159143) lnglat = tms.lnglat(*xy, truncate=True) - assert round(lnglat.x, 5) == -180.0 # in Mercantile + assert round(lnglat.x, 5) == -180.0 # in Mercantile (105.17731 in Morecantile) assert round(lnglat.y, 5) == -14.70462 # in Mercantile -@requires_gdal_lt_3 -def test_lnglat_gdal2(): - """test lnglat.""" - # GDAL2 returns ('inf', 'inf') and then inf is translated to 180,90 by truncate_lnglat - tms = morecantile.tms.get("WebMercatorQuad") - with pytest.warns(PointOutsideTMSBounds): - xy = (-28366731.739810849, -1655181.9927159143) - lnglat = tms.lnglat(*xy, truncate=True) - assert round(lnglat.x, 5) == 180.0 - assert round(lnglat.y, 5) == 90 - - -@requires_gdal3 def test_lnglat_gdal3(): """test lnglat.""" - # GDAL3 returns (105.17731317609572, -14.704620000000013) + # PROJ>=7 returns (105.17731317609572, -14.704620000000013) tms = morecantile.tms.get("WebMercatorQuad") with pytest.warns(PointOutsideTMSBounds): xy = (-28366731.739810849, -1655181.9927159143) diff --git a/tox.ini b/tox.ini index 7c49303..f7069e4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,12 +1,10 @@ [tox] -envlist = py36,py37,py38 +envlist = py37,py38,py39 [testenv] extras = test commands= python -m pytest --cov morecantile --cov-report term-missing --cov-report xml --ignore=venv -deps= - numpy # Release tooling [testenv:build]