Skip to content

Commit 74e8f4c

Browse files
Merge pull request #178 from developmentseed/feature/add-geographic-crs-options-in-tiles/tile
add geographic_crs option in tile and tiles methods
2 parents 383c2b3 + 7c95a11 commit 74e8f4c

File tree

3 files changed

+104
-63
lines changed

3 files changed

+104
-63
lines changed

CHANGES.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,8 @@
2828
}
2929
```
3030

31-
* rename `grid_name -> tms` and `grid_crs -> tms_crs` property names in `TileMatrixSet.feature` GeoJSON response
32-
* remove python 3.8 support
33-
* check tile's zoom against TMS's `maxzoom` in `TileMatrixSet.is_valid` and add `strict=True|False` options
31+
* rename `grid_name -> tms` and `grid_crs -> tms_crs` property names in `TileMatrixSet.feature` GeoJSON response **breaking change**
32+
* check tile's zoom against TMS's `maxzoom` in `TileMatrixSet.is_valid` and add `strict=True|False` options **breaking change**
3433

3534
```python
3635
import morecantile
@@ -48,6 +47,10 @@
4847
>> UserWarning: TileMatrix not found for level: 25 - Creating values from TMS Scale.
4948
```
5049

50+
* remove `truncate_lnglat` from TileMatrixSet class **breaking change**
51+
* remove python 3.8 support
52+
* add `geographic_crs` option in `TileMatrixSet.tiles` and `TileMatrixSet.tile` methods
53+
5154
## 6.2.0 (2024-12-19)
5255

5356
* add python 3.13 support

morecantile/models.py

Lines changed: 75 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import math
44
import warnings
5-
from functools import cached_property
5+
from functools import cached_property, lru_cache
66
from typing import Any, Dict, Iterator, List, Literal, Optional, Sequence, Tuple, Union
77

88
import pyproj
@@ -35,6 +35,7 @@
3535
meters_per_unit,
3636
point_in_bbox,
3737
to_rasterio_crs,
38+
truncate_coordinates,
3839
)
3940

4041
NumType = Union[float, int]
@@ -44,6 +45,11 @@
4445
WGS84_CRS = pyproj.CRS.from_epsg(4326)
4546

4647

48+
@lru_cache
49+
def _get_transformer(crs_from: pyproj.CRS, crs_to: pyproj.CRS) -> pyproj.Transformer:
50+
return pyproj.Transformer.from_crs(crs_from, crs_to, always_xy=True)
51+
52+
4753
class CRSUri(BaseModel):
4854
"""Coordinate Reference System (CRS) from URI."""
4955

@@ -888,7 +894,17 @@ def zoom_for_res(
888894

889895
return zoom_level
890896

891-
def lnglat(self, x: float, y: float, truncate=False) -> Coords:
897+
def intersect_tms(self, bbox: BoundingBox) -> bool:
898+
"""Check if a bounds intersects with the TMS bounds."""
899+
tms_bounds = self.xy_bbox
900+
return (
901+
(bbox[0] < tms_bounds[2])
902+
and (bbox[2] > tms_bounds[0])
903+
and (bbox[3] > tms_bounds[1])
904+
and (bbox[1] < tms_bounds[3])
905+
)
906+
907+
def lnglat(self, x: float, y: float, truncate: bool = False) -> Coords:
892908
"""Transform point(x,y) to geographic longitude and latitude."""
893909
inside = point_in_bbox(Coords(x, y), self.xy_bbox)
894910
if not inside:
@@ -901,14 +917,14 @@ def lnglat(self, x: float, y: float, truncate=False) -> Coords:
901917
lng, lat = self._to_geographic.transform(x, y)
902918

903919
if truncate:
904-
lng, lat = self.truncate_lnglat(lng, lat)
920+
lng, lat = truncate_coordinates(lng, lat, self.bbox)
905921

906922
return Coords(lng, lat)
907923

908-
def xy(self, lng: float, lat: float, truncate=False) -> Coords:
924+
def xy(self, lng: float, lat: float, truncate: bool = False) -> Coords:
909925
"""Transform geographic longitude and latitude coordinates to TMS CRS."""
910926
if truncate:
911-
lng, lat = self.truncate_lnglat(lng, lat)
927+
lng, lat = truncate_coordinates(lng, lat, self.bbox)
912928

913929
inside = point_in_bbox(Coords(lng, lat), self.bbox)
914930
if not inside:
@@ -922,25 +938,6 @@ def xy(self, lng: float, lat: float, truncate=False) -> Coords:
922938

923939
return Coords(x, y)
924940

925-
def truncate_lnglat(self, lng: float, lat: float) -> Tuple[float, float]:
926-
"""
927-
Truncate geographic coordinates to TMS geographic bbox.
928-
929-
Adapted from https://github.com/mapbox/mercantile/blob/master/mercantile/__init__.py
930-
931-
"""
932-
if lng > self.bbox.right:
933-
lng = self.bbox.right
934-
elif lng < self.bbox.left:
935-
lng = self.bbox.left
936-
937-
if lat > self.bbox.top:
938-
lat = self.bbox.top
939-
elif lat < self.bbox.bottom:
940-
lat = self.bbox.bottom
941-
942-
return lng, lat
943-
944941
def _tile(
945942
self,
946943
xcoord: float,
@@ -1008,6 +1005,7 @@ def tile(
10081005
zoom: int,
10091006
truncate=False,
10101007
ignore_coalescence: bool = False,
1008+
geographic_crs: Optional[CRS] = None,
10111009
) -> Tile:
10121010
"""
10131011
Get the tile for a given geographic longitude and latitude pair.
@@ -1020,13 +1018,36 @@ def tile(
10201018
The zoom level.
10211019
truncate : bool
10221020
Whether or not to truncate inputs to limits of TMS geographic bounds.
1021+
ignore_coalescence : bool
1022+
Whether or not to ignore coalescence factor for TMS with variable matrix width.
1023+
geographic_crs: pyproj.CRS, optional
1024+
Geographic CRS of the given coordinates. Default to TMS's Geographic CRS.
10231025
10241026
Returns
10251027
-------
10261028
Tile
10271029
10281030
"""
1029-
x, y = self.xy(lng, lat, truncate=truncate)
1031+
geographic_crs = (
1032+
geographic_crs or self.crs._pyproj_crs.geodetic_crs or WGS84_CRS
1033+
)
1034+
_from_geographic = _get_transformer(geographic_crs, self.crs._pyproj_crs)
1035+
_to_geographic = _get_transformer(self.crs._pyproj_crs, geographic_crs)
1036+
1037+
if truncate:
1038+
bbox = BoundingBox(
1039+
*_to_geographic.transform_bounds(*self.xy_bbox, densify_pts=21),
1040+
)
1041+
lng, lat = truncate_coordinates(lng, lat, bbox)
1042+
1043+
x, y = _from_geographic.transform(lng, lat)
1044+
if not point_in_bbox(Coords(x, y), self.xy_bbox):
1045+
warnings.warn(
1046+
f"Point ({lng}, {lat}) is outside TMS bounds.",
1047+
PointOutsideTMSBounds,
1048+
stacklevel=1,
1049+
)
1050+
10301051
return self._tile(x, y, zoom, ignore_coalescence=ignore_coalescence)
10311052

10321053
def _ul(self, *tile: Tile) -> Coords:
@@ -1175,7 +1196,7 @@ def bounds(self, *tile: Tile) -> BoundingBox:
11751196

11761197
return BoundingBox(left, bottom, right, top)
11771198

1178-
@property
1199+
@cached_property
11791200
def xy_bbox(self):
11801201
"""Return TMS bounding box in TileMatrixSet's CRS."""
11811202
zoom = self.minzoom
@@ -1201,16 +1222,6 @@ def bbox(self):
12011222
)
12021223
)
12031224

1204-
def intersect_tms(self, bbox: BoundingBox) -> bool:
1205-
"""Check if a bounds intersects with the TMS bounds."""
1206-
tms_bounds = self.xy_bbox
1207-
return (
1208-
(bbox[0] < tms_bounds[2])
1209-
and (bbox[2] > tms_bounds[0])
1210-
and (bbox[3] > tms_bounds[1])
1211-
and (bbox[1] < tms_bounds[3])
1212-
)
1213-
12141225
def tiles( # noqa: C901
12151226
self,
12161227
west: float,
@@ -1219,6 +1230,7 @@ def tiles( # noqa: C901
12191230
north: float,
12201231
zooms: Sequence[int],
12211232
truncate: bool = False,
1233+
geographic_crs: Optional[CRS] = None,
12221234
) -> Iterator[Tile]:
12231235
"""
12241236
Get the tiles overlapped by a geographic bounding box
@@ -1233,6 +1245,8 @@ def tiles( # noqa: C901
12331245
One or more zoom levels.
12341246
truncate : bool, optional
12351247
Whether or not to truncate inputs to TMS limits.
1248+
geographic_crs: pyproj.CRS, optional
1249+
Geographic CRS of the given coordinates. Default to TMS's Geographic CRS
12361250
12371251
Yields
12381252
------
@@ -1250,39 +1264,41 @@ def tiles( # noqa: C901
12501264
if isinstance(zooms, int):
12511265
zooms = (zooms,)
12521266

1267+
geographic_crs = (
1268+
geographic_crs or self.crs._pyproj_crs.geodetic_crs or WGS84_CRS
1269+
)
1270+
_from_geographic = _get_transformer(geographic_crs, self.crs._pyproj_crs)
1271+
_to_geographic = _get_transformer(self.crs._pyproj_crs, geographic_crs)
1272+
1273+
# TMS bbox
1274+
bbox = BoundingBox(
1275+
*_to_geographic.transform_bounds(*self.xy_bbox, densify_pts=21),
1276+
)
1277+
12531278
if truncate:
1254-
west, south = self.truncate_lnglat(west, south)
1255-
east, north = self.truncate_lnglat(east, north)
1279+
west, south = truncate_coordinates(west, south, bbox)
1280+
east, north = truncate_coordinates(east, north, bbox)
12561281

12571282
if west > east:
1258-
bbox_west = (self.bbox.left, south, east, north)
1259-
bbox_east = (west, south, self.bbox.right, north)
1283+
bbox_west = (bbox.left, south, east, north)
1284+
bbox_east = (west, south, bbox.right, north)
12601285
bboxes = [bbox_west, bbox_east]
12611286
else:
12621287
bboxes = [(west, south, east, north)]
12631288

12641289
for w, s, e, n in bboxes:
12651290
# Clamp bounding values.
1266-
es_contain_180th = lons_contain_antimeridian(e, self.bbox.right)
1267-
w = max(self.bbox.left, w)
1268-
s = max(self.bbox.bottom, s)
1269-
e = max(self.bbox.right, e) if es_contain_180th else min(self.bbox.right, e)
1270-
n = min(self.bbox.top, n)
1271-
1291+
es_contain_180th = lons_contain_antimeridian(e, bbox.right)
1292+
w = max(bbox.left, w)
1293+
s = max(bbox.bottom, s)
1294+
e = max(bbox.right, e) if es_contain_180th else min(bbox.right, e)
1295+
n = min(bbox.top, n)
1296+
1297+
w, n = _from_geographic.transform(w + LL_EPSILON, n - LL_EPSILON)
1298+
e, s = _from_geographic.transform(e - LL_EPSILON, s + LL_EPSILON)
12721299
for z in zooms:
1273-
nw_tile = self.tile(
1274-
w + LL_EPSILON,
1275-
n - LL_EPSILON,
1276-
z,
1277-
ignore_coalescence=True,
1278-
) # Not in mercantile
1279-
se_tile = self.tile(
1280-
e - LL_EPSILON,
1281-
s + LL_EPSILON,
1282-
z,
1283-
ignore_coalescence=True,
1284-
)
1285-
1300+
nw_tile = self._tile(w, n, z, ignore_coalescence=True)
1301+
se_tile = self._tile(e, s, z, ignore_coalescence=True)
12861302
minx = min(nw_tile.x, se_tile.x)
12871303
maxx = max(nw_tile.x, se_tile.x)
12881304
miny = min(nw_tile.y, se_tile.y)

morecantile/utils.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""morecantile utils."""
22

33
import math
4-
from typing import Dict, List
4+
from typing import Dict, List, Tuple
55

66
from pyproj import CRS
77
from pyproj.enums import WktVersion
@@ -110,6 +110,28 @@ def point_in_bbox(point: Coords, bbox: BoundingBox, precision: int = 5) -> bool:
110110
)
111111

112112

113+
def truncate_coordinates(
114+
lng: float, lat: float, bbox: BoundingBox
115+
) -> Tuple[float, float]:
116+
"""
117+
Truncate coordinates to a given bbox.
118+
119+
Adapted from https://github.com/mapbox/mercantile/blob/master/mercantile/__init__.py
120+
121+
"""
122+
if lng > bbox.right:
123+
lng = bbox.right
124+
elif lng < bbox.left:
125+
lng = bbox.left
126+
127+
if lat > bbox.top:
128+
lat = bbox.top
129+
elif lat < bbox.bottom:
130+
lat = bbox.bottom
131+
132+
return lng, lat
133+
134+
113135
def is_power_of_two(number: int) -> bool:
114136
"""Check if a number is a power of 2"""
115137
return (number & (number - 1) == 0) and number != 0

0 commit comments

Comments
 (0)