|
1 | 1 | import abc |
2 | 2 | import math |
3 | | -from typing import Dict, List, NamedTuple, Optional, Union |
| 3 | +from typing import Dict, List, Optional, Union |
4 | 4 |
|
| 5 | +import geopandas as gpd |
5 | 6 | import shapely |
| 7 | +import shapely.geometry.base |
6 | 8 | from shapely.geometry import MultiPolygon, Polygon |
7 | 9 |
|
8 | | -from openeo.util import normalize_crs |
| 10 | +from openeo.util import BBoxDict, normalize_crs |
9 | 11 |
|
10 | 12 |
|
| 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 | + |
11 | 17 | class JobSplittingFailure(Exception): |
12 | 18 | pass |
13 | 19 |
|
14 | 20 |
|
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. |
46 | 24 |
|
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 | + """ |
49 | 28 |
|
50 | 29 | @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 | + """ |
55 | 39 | ... |
56 | 40 |
|
57 | 41 |
|
58 | | -class _SizeBasedTileGrid(_TileGridInterface): |
| 42 | +class SizeBasedTileGrid(TileGridInterface): |
59 | 43 | """ |
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. |
62 | 53 | """ |
63 | 54 |
|
| 55 | + # EPSG ranges for UTM zones |
| 56 | + _UTM_NORTH = range(32601, 32661) |
| 57 | + _UTM_SOUTH = range(32701, 32761) |
| 58 | + |
64 | 59 | 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 |
67 | 67 | self.size = size |
68 | 68 |
|
69 | 69 | @classmethod |
70 | | - def from_size_projection(cls, *, size: float, projection: str) -> "_SizeBasedTileGrid": |
| 70 | + def from_size_projection(cls, *, size: float, projection: str) -> "SizeBasedTileGrid": |
71 | 71 | """Create a tile grid from size and projection""" |
72 | 72 | # TODO: the constructor also does normalize_crs, so this factory looks like overkill at the moment |
73 | 73 | return cls(epsg=normalize_crs(projection), size=size) |
74 | 74 |
|
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 | + ) |
80 | 94 |
|
81 | 95 | @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]: |
83 | 97 | """ |
84 | 98 | Split a bounding box into tiles of given size and projection. |
85 | 99 | :param to_cover: bounding box dict with keys "west", "south", "east", "north", "crs" |
86 | 100 | :param x_offset: offset to apply to the west and east coordinates |
87 | 101 | :param tile_size: size of tiles in unit of measure of the projection |
88 | 102 | :return: list of tiles (polygons) |
89 | 103 | """ |
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 |
94 | 108 |
|
95 | 109 | tiles = [] |
96 | 110 | for x in range(xmin, xmax + 1): |
97 | 111 | for y in range(ymin, ymax + 1): |
98 | 112 | 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"]), |
104 | 118 | ).as_polygon() |
105 | 119 | ) |
106 | 120 |
|
107 | 121 | return tiles |
108 | 122 |
|
109 | | - def get_tiles(self, geometry: Union[Dict, MultiPolygon, Polygon]) -> List[Polygon]: |
| 123 | + def get_tiles(self, geometry: Union[Dict, Polygon, MultiPolygon]) -> gpd.GeoDataFrame: |
110 | 124 | 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. |
112 | 141 |
|
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`. |
115 | 144 |
|
| 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)}") |
116 | 173 | 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 | + ) |
118 | 177 |
|
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.") |
121 | 180 |
|
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)}") |
123 | 187 |
|
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) |
125 | 200 |
|
126 | 201 |
|
127 | 202 | 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: |
130 | 209 | """ |
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. |
136 | 234 | """ |
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 | + ) |
141 | 258 |
|
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) |
0 commit comments