Skip to content

Commit 8ed4a63

Browse files
add support for predefinedtilegrid in split_area #883
1 parent afe9870 commit 8ed4a63

4 files changed

Lines changed: 452 additions & 234 deletions

File tree

Lines changed: 199 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,143 +1,260 @@
11
import abc
22
import math
3-
from typing import Dict, List, NamedTuple, Optional, Union
3+
from typing import Dict, List, Optional, Union
44

5+
import geopandas as gpd
56
import shapely
7+
import shapely.geometry.base
68
from shapely.geometry import MultiPolygon, Polygon
79

8-
from openeo.util import normalize_crs
10+
from openeo.util import BBoxDict, normalize_crs
911

1012

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}")
16+
1117
class JobSplittingFailure(Exception):
1218
pass
1319

1420

15-
class _BoundingBox(NamedTuple):
16-
"""Simple NamedTuple container for a bounding box"""
17-
18-
# TODO: this should be moved to more general utility module, and/or merged with existing BBoxDict
19-
20-
west: float
21-
south: float
22-
east: float
23-
north: float
24-
crs: int = 4326
25-
26-
@classmethod
27-
def from_dict(cls, d: Dict) -> "_BoundingBox":
28-
"""Create a bounding box from a dictionary"""
29-
if d.get("crs") is not None:
30-
d["crs"] = normalize_crs(d["crs"])
31-
return cls(**{k: d[k] for k in cls._fields if k not in cls._field_defaults or k in d})
32-
33-
@classmethod
34-
def from_polygon(cls, polygon: Union[MultiPolygon, Polygon], crs: Optional[int] = None) -> "_BoundingBox":
35-
"""Create a bounding box from a shapely Polygon or MultiPolygon"""
36-
crs = normalize_crs(crs)
37-
return cls(*polygon.bounds, crs=4326 if crs is None else crs)
38-
39-
def as_dict(self) -> Dict:
40-
return self._asdict()
41-
42-
def as_polygon(self) -> Polygon:
43-
"""Get bounding box as a shapely Polygon"""
44-
return shapely.geometry.box(minx=self.west, miny=self.south, maxx=self.east, maxy=self.north)
45-
21+
class TileGridInterface(metaclass=abc.ABCMeta):
22+
"""
23+
Interface for tile grid classes that split a geometry into tiles.
4624
47-
class _TileGridInterface(metaclass=abc.ABCMeta):
48-
"""Interface for tile grid classes"""
25+
Implementations must define :meth:`get_tiles`, which takes a geometry and
26+
returns a :class:`~geopandas.GeoDataFrame` of tile geometries covering it.
27+
"""
4928

5029
@abc.abstractmethod
51-
# TODO: is it intentional that this method returns a list of non-multi polygons even if the input can be multi-polygon?
52-
# TODO: typehint states that geometry can be a dict too, but that is very liberal, it's probably just about bounding box kind of dicts?
53-
def get_tiles(self, geometry: Union[Dict, MultiPolygon, Polygon]) -> List[Polygon]:
54-
"""Calculate tiles to cover given bounding box"""
30+
def get_tiles(self, geometry: Union[Dict, Polygon, MultiPolygon]) -> "gpd.GeoDataFrame":
31+
"""
32+
Calculate tiles to cover the given geometry.
33+
34+
:param geometry: area of interest as a bounding-box dict
35+
(with keys ``west``, ``south``, ``east``, ``north``, and optionally ``crs``),
36+
a :class:`~shapely.geometry.Polygon`, or a :class:`~shapely.geometry.MultiPolygon`.
37+
:return: GeoDataFrame with one row per tile and CRS set.
38+
"""
5539
...
5640

5741

58-
class _SizeBasedTileGrid(_TileGridInterface):
42+
class SizeBasedTileGrid(TileGridInterface):
5943
"""
60-
Specification of a tile grid, parsed from a size and a projection.
61-
The size is in m for UTM projections or degrees for WGS84.
44+
Tile grid that splits a geometry into regular tiles of a given size.
45+
46+
The size is in meters for UTM and Web Mercator projections, or degrees for
47+
WGS84 (EPSG:4326).
48+
49+
: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.
6253
"""
6354

55+
# EPSG ranges for UTM zones
56+
_UTM_NORTH = range(32601, 32661)
57+
_UTM_SOUTH = range(32701, 32761)
58+
6459
def __init__(self, *, epsg: int, size: float):
65-
# TODO: normalize_crs does not necessarily return an int (could also be a WKT2 string, or even None), but further logic seems to assume it's an int
66-
self.epsg = normalize_crs(epsg)
60+
try:
61+
epsg = normalize_crs(epsg)
62+
except (ValueError, TypeError) as e:
63+
raise JobSplittingFailure(f"Failed to normalize EPSG code for tile grid splitting: {epsg!r}.") from e
64+
if not isinstance(epsg, int):
65+
raise JobSplittingFailure(f"Only integer EPSG codes are supported for tile grid splitting, got {epsg!r}.")
66+
self.epsg = epsg
6767
self.size = size
6868

6969
@classmethod
70-
def from_size_projection(cls, *, size: float, projection: str) -> "_SizeBasedTileGrid":
70+
def from_size_projection(cls, *, size: float, projection: str) -> "SizeBasedTileGrid":
7171
"""Create a tile grid from size and projection"""
7272
# TODO: the constructor also does normalize_crs, so this factory looks like overkill at the moment
7373
return cls(epsg=normalize_crs(projection), size=size)
7474

75-
def _epsg_is_meters(self) -> bool:
76-
"""Check if the projection unit is in meters. (EPSG:3857 or UTM)"""
77-
# TODO: this is a bit misleading: this code just checks some EPSG ranges (UTM and 3857) and calls all the rest to be not in meters.
78-
# It would be better to raise an exception on unknown EPSG codes than claiming they're not in meter
79-
return 32601 <= self.epsg <= 32660 or 32701 <= self.epsg <= 32760 or self.epsg == 3857
75+
def _get_x_offset(self) -> float:
76+
"""
77+
Return the easting offset for the projection, used to align tiles to the
78+
coordinate system's grid origin.
79+
80+
- UTM zones have a false easting of 500 000 m.
81+
- EPSG:3857 and EPSG:4326 have no offset.
82+
83+
:raises JobSplittingFailure: for unsupported EPSG codes.
84+
"""
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+
)
8094

8195
@staticmethod
82-
def _split_bounding_box(to_cover: _BoundingBox, x_offset: float, tile_size: float) -> List[Polygon]:
96+
def _split_bounding_box(to_cover: BBoxDict, x_offset: float, tile_size: float) -> List[Polygon]:
8397
"""
8498
Split a bounding box into tiles of given size and projection.
8599
:param to_cover: bounding box dict with keys "west", "south", "east", "north", "crs"
86100
:param x_offset: offset to apply to the west and east coordinates
87101
:param tile_size: size of tiles in unit of measure of the projection
88102
:return: list of tiles (polygons)
89103
"""
90-
xmin = int(math.floor((to_cover.west - x_offset) / tile_size))
91-
xmax = int(math.ceil((to_cover.east - x_offset) / tile_size)) - 1
92-
ymin = int(math.floor(to_cover.south / tile_size))
93-
ymax = int(math.ceil(to_cover.north / tile_size)) - 1
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
94108

95109
tiles = []
96110
for x in range(xmin, xmax + 1):
97111
for y in range(ymin, ymax + 1):
98112
tiles.append(
99-
_BoundingBox(
100-
west=max(x * tile_size + x_offset, to_cover.west),
101-
south=max(y * tile_size, to_cover.south),
102-
east=min((x + 1) * tile_size + x_offset, to_cover.east),
103-
north=min((y + 1) * tile_size, to_cover.north),
113+
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"]),
104118
).as_polygon()
105119
)
106120

107121
return tiles
108122

109-
def get_tiles(self, geometry: Union[Dict, MultiPolygon, Polygon]) -> List[Polygon]:
123+
def get_tiles(self, geometry: Union[Dict, Polygon, MultiPolygon]) -> gpd.GeoDataFrame:
110124
if isinstance(geometry, dict):
111-
bbox = _BoundingBox.from_dict(geometry)
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+
)
132+
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)
136+
137+
138+
class PredefinedTileGrid(TileGridInterface):
139+
"""
140+
Tile grid based on a user-supplied collection of geometries.
112141
113-
elif isinstance(geometry, Polygon) or isinstance(geometry, MultiPolygon):
114-
bbox = _BoundingBox.from_polygon(geometry, crs=self.epsg)
142+
Only geometries that intersect the given area of interest are returned by
143+
:meth:`get_tiles`.
115144
145+
Geometries can be any shapely geometry type (Polygon, MultiPolygon, …).
146+
147+
:param tiles: pre-defined geometries as a :class:`~geopandas.GeoDataFrame`,
148+
a :class:`~geopandas.GeoSeries`, or a plain list of shapely geometries.
149+
:param crs: EPSG code of the geometry coordinate system.
150+
Required when *tiles* is a plain list (geometry objects carry no CRS).
151+
Ignored when *tiles* is a GeoDataFrame/GeoSeries that already has a CRS.
152+
"""
153+
154+
def __init__(
155+
self,
156+
*,
157+
tiles: Union[gpd.GeoDataFrame, gpd.GeoSeries, List[shapely.geometry.base.BaseGeometry]],
158+
crs: Optional[int] = None,
159+
):
160+
161+
if isinstance(tiles, gpd.GeoDataFrame):
162+
self._gdf = tiles.copy()
163+
elif isinstance(tiles, gpd.GeoSeries):
164+
self._gdf = gpd.GeoDataFrame(geometry=tiles)
165+
elif isinstance(tiles, list):
166+
if not tiles:
167+
raise JobSplittingFailure("At least one tile geometry must be provided.")
168+
if not all(isinstance(t, shapely.geometry.base.BaseGeometry) for t in tiles):
169+
raise JobSplittingFailure("All tiles must be shapely geometry instances.")
170+
if crs is None:
171+
raise JobSplittingFailure("'crs' is required when tiles are provided as a plain list of geometries.")
172+
self._gdf = gpd.GeoDataFrame(geometry=tiles, crs=f"EPSG:{normalize_crs(crs)}")
116173
else:
117-
raise JobSplittingFailure("geometry must be a dict or a shapely.geometry.Polygon or MultiPolygon")
174+
raise JobSplittingFailure(
175+
f"Expected a GeoDataFrame, GeoSeries, or list of geometries, got {type(tiles).__name__}."
176+
)
118177

119-
# TODO: being a meter based EPSG does not imply that offset should be 500_000
120-
x_offset = 500_000 if self._epsg_is_meters() else 0
178+
if self._gdf.empty:
179+
raise JobSplittingFailure("The tile GeoDataFrame must contain at least one row.")
121180

122-
tiles = _SizeBasedTileGrid._split_bounding_box(to_cover=bbox, x_offset=x_offset, tile_size=self.size)
181+
if self._gdf.crs is None:
182+
if crs is None:
183+
raise JobSplittingFailure(
184+
"The tile GeoDataFrame has no CRS set. " "Either set the CRS on the GeoDataFrame or pass 'crs'."
185+
)
186+
self._gdf = self._gdf.set_crs(f"EPSG:{normalize_crs(crs)}")
123187

124-
return tiles
188+
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+
)
197+
198+
mask = self._gdf.intersects(geom)
199+
return self._gdf.loc[mask].copy().reset_index(drop=True)
125200

126201

127202
def split_area(
128-
aoi: Union[Dict, MultiPolygon, Polygon], projection: str = "EPSG:3857", tile_size: float = 20_000.0
129-
) -> List[Polygon]:
203+
aoi: Union[Dict, MultiPolygon, Polygon],
204+
*,
205+
projection: Optional[str] = None,
206+
tile_size: Optional[float] = None,
207+
tile_grid: Optional[Union[TileGridInterface, gpd.GeoDataFrame]] = None,
208+
) -> gpd.GeoDataFrame:
130209
"""
131-
Split area of interest into tiles of given size and projection.
132-
:param aoi: area of interest (bounding box or shapely polygon)
133-
:param projection: projection to use for splitting. Default is web mercator (EPSG:3857)
134-
:param tile_size: size of tiles in unit of measure of the projection
135-
:return: list of tiles (polygons).
210+
Split an area of interest into tiles.
211+
212+
There are two ways to define how the area is tiled:
213+
214+
1. **By tile size and projection** — pass *projection* and *tile_size*.
215+
A :class:`SizeBasedTileGrid` is created under the hood.
216+
217+
2. **By pre-defined grid** — pass a :class:`TileGridInterface` instance
218+
(e.g. :class:`PredefinedTileGrid`) or a :class:`~geopandas.GeoDataFrame`
219+
as *tile_grid*.
220+
221+
:param aoi: area of interest as a bounding-box dict
222+
(keys ``west``, ``south``, ``east``, ``north``, optionally ``crs``),
223+
a :class:`~shapely.geometry.Polygon`, or
224+
a :class:`~shapely.geometry.MultiPolygon`.
225+
: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.
227+
:param tile_size: tile edge length in the unit of the projection.
228+
Required when *tile_grid* is not supplied.
229+
:param tile_grid: a :class:`TileGridInterface` instance or a
230+
:class:`~geopandas.GeoDataFrame` (with CRS set) that defines the tiling
231+
strategy. Mutually exclusive with *projection* / *tile_size*.
232+
:return: :class:`~geopandas.GeoDataFrame` with one row per tile and CRS set.
233+
:raises JobSplittingFailure: on invalid or contradictory arguments.
136234
"""
137-
# TODO EPSG 3857 is probably not a good default projection. Probably better to make it a required parameter
138-
if isinstance(aoi, dict):
139-
# TODO: this possibly overwrites the given projection without the user noticing, making usage confusing
140-
projection = aoi.get("crs", projection)
235+
if tile_grid is not None:
236+
if projection is not None or tile_size is not None:
237+
raise JobSplittingFailure(
238+
"Cannot combine 'tile_grid' with 'projection' or 'tile_size'. "
239+
"Either pass a TileGridInterface, or pass projection + tile_size."
240+
)
241+
if isinstance(tile_grid, gpd.GeoDataFrame):
242+
tile_grid = PredefinedTileGrid(tiles=tile_grid)
243+
return tile_grid.get_tiles(aoi)
244+
245+
# --- Size-based splitting path ---
246+
if tile_size is None:
247+
raise JobSplittingFailure(
248+
"Either provide a 'tile_grid', or at least 'tile_size' (and optionally 'projection')."
249+
)
250+
251+
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+
)
141258

142-
tile_grid = _SizeBasedTileGrid.from_size_projection(size=tile_size, projection=projection)
143-
return tile_grid.get_tiles(aoi)
259+
grid = SizeBasedTileGrid(epsg=normalize_crs(projection), size=tile_size)
260+
return grid.get_tiles(aoi)

openeo/util.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,10 @@ def __init__(self, *, west: float, south: float, east: float, north: float, crs:
534534

535535
# TODO: provide west, south, east, north, crs as @properties? Read-only or read-write?
536536

537+
def as_polygon(self) -> shapely.geometry.Polygon:
538+
"""Convert to a :class:`shapely.geometry.Polygon` representing the bounding box."""
539+
return shapely.geometry.box(minx=self["west"], miny=self["south"], maxx=self["east"], maxy=self["north"])
540+
537541
@classmethod
538542
def from_any(cls, x: Any, *, crs: Optional[str] = None) -> BBoxDict:
539543
if isinstance(x, dict):

setup.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,6 @@
2323
"httpretty>=1.1.4",
2424
"urllib3<2.3.0", # httpretty doesn't work properly with urllib3>=2.3.0. See #700 and https://github.com/gabrielfalcao/HTTPretty/issues/484
2525
"netCDF4>=1.7.0",
26-
# TODO #717 Simplify geopandas constraints when Python 3.8 support is dropped
27-
"geopandas>=0.14; python_version>='3.9'",
28-
"geopandas", # Best-effort geopandas dependency for Python 3.8
2926
"flake8>=5.0.0",
3027
"time_machine>=2.13.0",
3128
"pyproj>=3.2.0", # Pyproj is an optional, best-effort runtime dependency
@@ -90,6 +87,9 @@
9087
"deprecated>=1.2.12",
9188
'oschmod>=0.3.12; sys_platform == "win32"',
9289
"importlib_resources; python_version<'3.9'",
90+
# TODO #717 Simplify geopandas constraints when Python 3.8 support is dropped
91+
"geopandas>=0.14; python_version>='3.9'",
92+
"geopandas", # Best-effort geopandas dependency for Python 3.8
9393
],
9494
extras_require={
9595
"tests": tests_require + artifacts_require,

0 commit comments

Comments
 (0)