22
33import math
44import warnings
5- from functools import cached_property
5+ from functools import cached_property , lru_cache
66from typing import Any , Dict , Iterator , List , Literal , Optional , Sequence , Tuple , Union
77
88import pyproj
3535 meters_per_unit ,
3636 point_in_bbox ,
3737 to_rasterio_crs ,
38+ truncate_coordinates ,
3839)
3940
4041NumType = Union [float , int ]
4445WGS84_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+
4753class 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 )
0 commit comments