11import abc
2+ import logging
23import math
34from typing import Dict , List , Optional , Union
45
910
1011from openeo .util import BBoxDict , normalize_crs
1112
13+ _log = logging .getLogger (__name__ )
1214
13- def _to_geodataframe (geometries : list , * , epsg : int ) -> gpd .GeoDataFrame :
14- """Build a GeoDataFrame from a list of shapely geometries and an EPSG code."""
15- return gpd .GeoDataFrame (geometry = geometries , crs = f"EPSG:{ epsg } " )
1615
1716class JobSplittingFailure (Exception ):
1817 pass
@@ -22,12 +21,21 @@ class TileGridInterface(metaclass=abc.ABCMeta):
2221 """
2322 Interface for tile grid classes that split a geometry into tiles.
2423
25- Implementations must define :meth:`get_tiles`, which takes a geometry and
26- returns a :class:`~geopandas.GeoDataFrame` of tile geometries covering it.
24+ Implementations must define :meth:`get_tiles` and the :attr:`crs` property.
25+
26+ Shared helpers :meth:`_parse_input_geometry` and
27+ :meth:`_reproject_to_grid_crs` handle input normalisation and CRS
28+ reprojection so that subclasses don't need to duplicate that logic.
2729 """
2830
31+ @property
32+ @abc .abstractmethod
33+ def crs (self ) -> int :
34+ """EPSG code of the tile grid's coordinate reference system."""
35+ ...
36+
2937 @abc .abstractmethod
30- def get_tiles (self , geometry : Union [Dict , Polygon , MultiPolygon ]) -> " gpd.GeoDataFrame" :
38+ def get_tiles (self , geometry : Union [Dict , Polygon , MultiPolygon ]) -> gpd .GeoDataFrame :
3139 """
3240 Calculate tiles to cover the given geometry.
3341
@@ -38,101 +46,133 @@ def get_tiles(self, geometry: Union[Dict, Polygon, MultiPolygon]) -> "gpd.GeoDat
3846 """
3947 ...
4048
49+ @staticmethod
50+ def _parse_input_geometry (
51+ geometry : Union [Dict , Polygon , MultiPolygon ],
52+ ) -> tuple [Union [Polygon , MultiPolygon ], Optional [int ]]:
53+ """
54+ Normalise the user-supplied *geometry* into a shapely geometry and an
55+ optional EPSG code.
56+
57+ :return: ``(shapely_geom, source_epsg)`` where *source_epsg* is
58+ ``None`` when the input carries no CRS information (bare Polygon).
59+ :raises JobSplittingFailure: on unsupported input types.
60+ """
61+ if isinstance (geometry , dict ):
62+ bbox = BBoxDict .from_dict (geometry )
63+ raw_crs = bbox .get ("crs" )
64+ source_epsg = normalize_crs (raw_crs ) if raw_crs is not None else None
65+ return bbox .as_polygon (), source_epsg
66+ elif isinstance (geometry , (Polygon , MultiPolygon )):
67+ return geometry , None
68+ else :
69+ raise JobSplittingFailure (
70+ f"Expected a bounding-box dict, Polygon, or MultiPolygon, got { type (geometry ).__name__ } ."
71+ )
72+
73+ def _reproject_to_grid_crs (
74+ self ,
75+ geom : Union [Polygon , MultiPolygon ],
76+ source_epsg : Optional [int ],
77+ ) -> Union [Polygon , MultiPolygon ]:
78+ """
79+ Reproject *geom* from *source_epsg* to the tile grid's :attr:`crs`.
80+
81+ - If *source_epsg* is ``None`` the geometry is returned unchanged
82+ (assumed to already be in the grid's CRS).
83+ - If *source_epsg* equals the grid's CRS, no work is done.
84+ - Otherwise a geopandas reprojection is performed.
85+ """
86+ grid_epsg = self .crs
87+ if source_epsg is None :
88+ _log .warning (
89+ "Input geometry has no CRS information; assuming it is already in the tile grid's CRS (EPSG:%d)." ,
90+ grid_epsg ,
91+ )
92+ return geom
93+
94+ if source_epsg == grid_epsg :
95+ return geom
96+
97+ _log .info ("Reprojecting input geometry from EPSG:%d to EPSG:%d." , source_epsg , grid_epsg )
98+ src = gpd .GeoDataFrame (geometry = [geom ], crs = f"EPSG:{ source_epsg } " )
99+ return src .to_crs (f"EPSG:{ grid_epsg } " ).geometry [0 ]
100+
41101
42102class SizeBasedTileGrid (TileGridInterface ):
43103 """
44104 Tile grid that splits a geometry into regular tiles of a given size.
45105
46- The size is in meters for UTM and Web Mercator projections, or degrees for
47- WGS84 (EPSG:4326).
106+ Tiles are anchored at the AOI's own lower-left corner and never extend
107+ beyond the AOI boundary. Edge tiles may therefore be smaller than
108+ *size* x *size*.
109+
110+ The *size* is interpreted in the unit of the projection (meters for most
111+ projected CRSs, degrees for EPSG:4326).
48112
49113 :param epsg: EPSG code of the projection to use for tiling.
50- Supported values: ``4326`` (WGS84 degrees), ``3857`` (Web Mercator meters),
51- and UTM zones (``32601``–``32660``, ``32701``–``32760``).
52- :param size: tile size in the unit of measure of the projection.
114+ :param size: maximum tile edge length in the unit of the projection.
53115 """
54116
55- # EPSG ranges for UTM zones
56- _UTM_NORTH = range (32601 , 32661 )
57- _UTM_SOUTH = range (32701 , 32761 )
58-
59117 def __init__ (self , * , epsg : int , size : float ):
60118 try :
61119 epsg = normalize_crs (epsg )
62120 except (ValueError , TypeError ) as e :
63121 raise JobSplittingFailure (f"Failed to normalize EPSG code for tile grid splitting: { epsg !r} ." ) from e
64122 if not isinstance (epsg , int ):
65123 raise JobSplittingFailure (f"Only integer EPSG codes are supported for tile grid splitting, got { epsg !r} ." )
66- self .epsg = epsg
124+ self ._epsg = epsg
67125 self .size = size
68126
127+ @property
128+ def crs (self ) -> int :
129+ return self ._epsg
130+
69131 @classmethod
70132 def from_size_projection (cls , * , size : float , projection : str ) -> "SizeBasedTileGrid" :
71133 """Create a tile grid from size and projection"""
72134 # TODO: the constructor also does normalize_crs, so this factory looks like overkill at the moment
73135 return cls (epsg = normalize_crs (projection ), size = size )
74136
75- def _get_x_offset (self ) -> float :
137+ @staticmethod
138+ def _split_bounding_box (to_cover : BBoxDict , tile_size : float ) -> List [Polygon ]:
76139 """
77- Return the easting offset for the projection, used to align tiles to the
78- coordinate system's grid origin.
140+ Subdivide a bounding box into tiles of at most *tile_size*.
79141
80- - UTM zones have a false easting of 500 000 m.
81- - EPSG:3857 and EPSG:4326 have no offset.
142+ Tiles are anchored at the AOI's lower-left corner (``west``, ``south``)
143+ and clipped to the AOI boundary, so edge tiles can be smaller than
144+ *tile_size*.
82145
83- :raises JobSplittingFailure: for unsupported EPSG codes.
146+ :param to_cover: bounding box to subdivide.
147+ :param tile_size: maximum tile edge length.
148+ :return: list of tile polygons.
84149 """
85- if self .epsg in self ._UTM_NORTH or self .epsg in self ._UTM_SOUTH :
86- return 500_000.0
87- elif self .epsg in (3857 , 4326 ):
88- return 0.0
89- else :
90- raise JobSplittingFailure (
91- f"Unsupported EPSG code { self .epsg } for tile grid splitting. "
92- f"Supported codes: 4326, 3857, and UTM zones (32601-32660, 32701-32760)."
93- )
150+ west , south = to_cover ["west" ], to_cover ["south" ]
151+ east , north = to_cover ["east" ], to_cover ["north" ]
94152
95- @staticmethod
96- def _split_bounding_box (to_cover : BBoxDict , x_offset : float , tile_size : float ) -> List [Polygon ]:
97- """
98- Split a bounding box into tiles of given size and projection.
99- :param to_cover: bounding box dict with keys "west", "south", "east", "north", "crs"
100- :param x_offset: offset to apply to the west and east coordinates
101- :param tile_size: size of tiles in unit of measure of the projection
102- :return: list of tiles (polygons)
103- """
104- xmin = int (math .floor ((to_cover ["west" ] - x_offset ) / tile_size ))
105- xmax = int (math .ceil ((to_cover ["east" ] - x_offset ) / tile_size )) - 1
106- ymin = int (math .floor (to_cover ["south" ] / tile_size ))
107- ymax = int (math .ceil (to_cover ["north" ] / tile_size )) - 1
153+ n_cols = math .ceil (round ((east - west ) / tile_size , 10 ))
154+ n_rows = math .ceil (round ((north - south ) / tile_size , 10 ))
108155
109156 tiles = []
110- for x in range (xmin , xmax + 1 ):
111- for y in range (ymin , ymax + 1 ):
157+ for col in range (n_cols ):
158+ for row in range (n_rows ):
112159 tiles .append (
113160 BBoxDict (
114- west = max ( x * tile_size + x_offset , to_cover [ "west" ]) ,
115- south = max ( y * tile_size , to_cover [ "south" ]) ,
116- east = min (( x + 1 ) * tile_size + x_offset , to_cover [ " east" ] ),
117- north = min (( y + 1 ) * tile_size , to_cover [ " north" ] ),
161+ west = west + col * tile_size ,
162+ south = south + row * tile_size ,
163+ east = min (west + ( col + 1 ) * tile_size , east ),
164+ north = min (south + ( row + 1 ) * tile_size , north ),
118165 ).as_polygon ()
119166 )
120-
121167 return tiles
122168
123169 def get_tiles (self , geometry : Union [Dict , Polygon , MultiPolygon ]) -> gpd .GeoDataFrame :
124- if isinstance (geometry , dict ):
125- bbox = BBoxDict .from_dict (geometry )
126- elif isinstance (geometry , (Polygon , MultiPolygon )):
127- bbox = BBoxDict .from_any (geometry , crs = self .epsg )
128- else :
129- raise JobSplittingFailure (
130- f"Expected a bounding-box dict, Polygon, or MultiPolygon, got { type (geometry ).__name__ } ."
131- )
170+ geom , source_epsg = self ._parse_input_geometry (geometry )
171+ geom = self ._reproject_to_grid_crs (geom , source_epsg )
132172
133- x_offset = self . _get_x_offset ( )
134- polygons = self ._split_bounding_box (to_cover = bbox , x_offset = x_offset , tile_size = self .size )
135- return _to_geodataframe ( polygons , epsg = self .epsg )
173+ bbox = BBoxDict . from_any ( geom , crs = self . _epsg )
174+ polygons = self ._split_bounding_box (to_cover = bbox , tile_size = self .size )
175+ return gpd . GeoDataFrame ( geometry = polygons , crs = f"EPSG: { self ._epsg } " )
136176
137177
138178class PredefinedTileGrid (TileGridInterface ):
@@ -157,7 +197,6 @@ def __init__(
157197 tiles : Union [gpd .GeoDataFrame , gpd .GeoSeries , List [shapely .geometry .base .BaseGeometry ]],
158198 crs : Optional [int ] = None ,
159199 ):
160-
161200 if isinstance (tiles , gpd .GeoDataFrame ):
162201 self ._gdf = tiles .copy ()
163202 elif isinstance (tiles , gpd .GeoSeries ):
@@ -185,15 +224,13 @@ def __init__(
185224 )
186225 self ._gdf = self ._gdf .set_crs (f"EPSG:{ normalize_crs (crs )} " )
187226
227+ @property
228+ def crs (self ) -> int :
229+ return self ._gdf .crs .to_epsg ()
230+
188231 def get_tiles (self , geometry : Union [Dict , Polygon , MultiPolygon ]) -> gpd .GeoDataFrame :
189- if isinstance (geometry , dict ):
190- geom = BBoxDict .from_dict (geometry ).as_polygon ()
191- elif isinstance (geometry , (Polygon , MultiPolygon )):
192- geom = geometry
193- else :
194- raise JobSplittingFailure (
195- f"Expected a bounding-box dict, Polygon, or MultiPolygon, got { type (geometry ).__name__ } ."
196- )
232+ geom , source_epsg = self ._parse_input_geometry (geometry )
233+ geom = self ._reproject_to_grid_crs (geom , source_epsg )
197234
198235 mask = self ._gdf .intersects (geom )
199236 return self ._gdf .loc [mask ].copy ().reset_index (drop = True )
@@ -223,7 +260,7 @@ def split_area(
223260 a :class:`~shapely.geometry.Polygon`, or
224261 a :class:`~shapely.geometry.MultiPolygon`.
225262 :param projection: EPSG string (e.g. ``"EPSG:3857"``) for the tile grid.
226- Required when *tile_grid* is not supplied and the AOI has no ``crs`` field .
263+ Required when *tile_grid* is not supplied.
227264 :param tile_size: tile edge length in the unit of the projection.
228265 Required when *tile_grid* is not supplied.
229266 :param tile_grid: a :class:`TileGridInterface` instance or a
@@ -244,17 +281,10 @@ def split_area(
244281
245282 # --- Size-based splitting path ---
246283 if tile_size is None :
247- raise JobSplittingFailure (
248- "Either provide a 'tile_grid', or at least 'tile_size' (and optionally 'projection')."
249- )
284+ raise JobSplittingFailure ("Either provide a 'tile_grid', or provide both 'tile_size' and 'projection'." )
250285
251286 if projection is None :
252- if isinstance (aoi , dict ) and "crs" in aoi :
253- projection = aoi ["crs" ]
254- else :
255- raise JobSplittingFailure (
256- "'projection' is required when the area of interest does not contain a 'crs' field."
257- )
287+ raise JobSplittingFailure ("'projection' is required when using size-based tiling." )
258288
259289 grid = SizeBasedTileGrid (epsg = normalize_crs (projection ), size = tile_size )
260290 return grid .get_tiles (aoi )
0 commit comments