Skip to content

Commit a45a4e2

Browse files
_job_splitting: aoi is now always reprojected to underlying tile grid crs. SizebasedTileGrid doesn't follow a pre-defined grid centered at 0,0 but rather uses the AOI bottom left as anchor point #883
1 parent c666b27 commit a45a4e2

2 files changed

Lines changed: 169 additions & 103 deletions

File tree

Lines changed: 111 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import abc
2+
import logging
23
import math
34
from typing import Dict, List, Optional, Union
45

@@ -9,10 +10,8 @@
910

1011
from 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

1716
class 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

42102
class 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

138178
class 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

Comments
 (0)