Skip to content

Commit 05ccf7f

Browse files
pr review changes #745
1 parent 28e0de8 commit 05ccf7f

File tree

2 files changed

+109
-188
lines changed

2 files changed

+109
-188
lines changed

openeo/extra/job_management/job_splitting.py

+66-71
Original file line numberDiff line numberDiff line change
@@ -2,102 +2,82 @@
22
import math
33
from typing import Dict, List, NamedTuple, Optional, Union
44

5-
import pyproj
65
import shapely
7-
from shapely.geometry import shape
8-
from shapely.ops import transform
6+
from shapely.geometry import MultiPolygon, Polygon
7+
8+
from openeo.util import normalize_crs
99

1010

1111
class JobSplittingFailure(Exception):
1212
pass
1313

14-
15-
# TODO: This function is also defined in openeo-python-driver. But maybe we want to avoid a dependency on openeo-python-driver?
16-
def reproject_bounding_box(bbox: Dict, from_crs: Optional[str], to_crs: str) -> Dict:
17-
"""
18-
Reproject given bounding box dictionary
19-
20-
:param bbox: bbox dict with fields "west", "south", "east", "north"
21-
:param from_crs: source CRS. Specify `None` to use the "crs" field of input bbox dict
22-
:param to_crs: target CRS
23-
:return: bbox dict (fields "west", "south", "east", "north", "crs")
24-
"""
25-
box = shapely.geometry.box(bbox["west"], bbox["south"], bbox["east"], bbox["north"])
26-
if from_crs is None:
27-
from_crs = bbox["crs"]
28-
tranformer = pyproj.Transformer.from_crs(crs_from=from_crs, crs_to=to_crs, always_xy=True)
29-
reprojected = transform(tranformer.transform, box)
30-
return dict(zip(["west", "south", "east", "north"], reprojected.bounds), crs=to_crs)
31-
32-
33-
# TODO: This class is also defined in openeo-aggregator. But maybe we want to avoid a dependency on openeo-aggregator?
34-
class BoundingBox(NamedTuple):
14+
class _BoundingBox(NamedTuple):
3515
"""Simple NamedTuple container for a bounding box"""
3616

3717
west: float
3818
south: float
3919
east: float
4020
north: float
41-
crs: str = "EPSG:4326"
21+
crs: int = 4326
4222

4323
@classmethod
44-
def from_dict(cls, d: Dict) -> "BoundingBox":
24+
def from_dict(cls, d: Dict) -> "_BoundingBox":
25+
"""Create a bounding box from a dictionary"""
26+
if d.get("crs") is not None:
27+
d["crs"] = normalize_crs(d["crs"])
4528
return cls(**{k: d[k] for k in cls._fields if k not in cls._field_defaults or k in d})
4629

4730
@classmethod
48-
def from_polygon(cls, polygon: shapely.geometry.Polygon, projection: Optional[str] = None) -> "BoundingBox":
49-
"""Create a bounding box from a shapely Polygon"""
50-
return cls(*polygon.bounds, projection if projection is not None else cls.crs)
31+
def from_polygon(cls, polygon: Union[MultiPolygon, Polygon], crs: Optional[int] = None) -> "_BoundingBox":
32+
"""Create a bounding box from a shapely Polygon or MultiPolygon"""
33+
crs = normalize_crs(crs)
34+
return cls(*polygon.bounds, crs=4326 if crs is None else crs)
5135

5236
def as_dict(self) -> Dict:
5337
return self._asdict()
5438

55-
def as_polygon(self) -> shapely.geometry.Polygon:
39+
def as_polygon(self) -> Polygon:
5640
"""Get bounding box as a shapely Polygon"""
5741
return shapely.geometry.box(minx=self.west, miny=self.south, maxx=self.east, maxy=self.north)
5842

5943

60-
class TileGridInterface(metaclass=abc.ABCMeta):
44+
class _TileGridInterface(metaclass=abc.ABCMeta):
6145
"""Interface for tile grid classes"""
6246

6347
@abc.abstractmethod
64-
def get_tiles(self, geometry: Union[Dict, shapely.geometry.Polygon]) -> List[Union[Dict, shapely.geometry.Polygon]]:
48+
def get_tiles(self, geometry: Union[Dict, MultiPolygon, Polygon]) -> List[Polygon]:
6549
"""Calculate tiles to cover given bounding box"""
6650
...
6751

6852

69-
class SizeBasedTileGrid(TileGridInterface):
53+
class _SizeBasedTileGrid(_TileGridInterface):
7054
"""
7155
Specification of a tile grid, parsed from a size and a projection.
56+
The size is in m for UTM projections or degrees for WGS84.
7257
"""
7358

74-
def __init__(self, epsg: str, size: float):
75-
self.epsg = epsg
59+
def __init__(self, epsg: int, size: float):
60+
self.epsg = normalize_crs(epsg)
7661
self.size = size
7762

7863
@classmethod
79-
def from_size_projection(cls, size: float, projection: str) -> "SizeBasedTileGrid":
64+
def from_size_projection(cls, size: float, projection: str) -> "_SizeBasedTileGrid":
8065
"""Create a tile grid from size and projection"""
81-
return cls(projection.lower(), size)
82-
83-
def get_tiles(self, geometry: Union[Dict, shapely.geometry.Polygon]) -> List[Union[Dict, shapely.geometry.Polygon]]:
84-
if isinstance(geometry, dict):
85-
bbox = BoundingBox.from_dict(geometry)
86-
bbox_crs = bbox.crs
87-
elif isinstance(geometry, shapely.geometry.Polygon):
88-
bbox = BoundingBox.from_polygon(geometry, projection=self.epsg)
89-
bbox_crs = self.epsg
90-
else:
91-
raise JobSplittingFailure("geometry must be a dict or a shapely.geometry.Polygon")
92-
93-
if self.epsg == "epsg:4326":
94-
tile_size = self.size
95-
x_offset = 0
96-
else:
97-
tile_size = self.size * 1000
98-
x_offset = 500_000
99-
100-
to_cover = BoundingBox.from_dict(reproject_bounding_box(bbox.as_dict(), from_crs=bbox_crs, to_crs=self.epsg))
66+
return cls(normalize_crs(projection), size)
67+
68+
def _epsg_is_meters(self) -> bool:
69+
"""Check if the projection unit is in meters. (EPSG:3857 or UTM)"""
70+
return 32601 <= self.epsg <= 32660 or 32701 <= self.epsg <= 32760 or self.epsg == 3857
71+
72+
@staticmethod
73+
def _split_bounding_box(to_cover: _BoundingBox, x_offset: float, tile_size: float) -> List[Polygon]:
74+
"""
75+
Split a bounding box into tiles of given size and projection.
76+
:param to_cover: bounding box dict with keys "west", "south", "east", "north", "crs"
77+
:param x_offset: offset to apply to the west and east coordinates
78+
:param tile_size: size of tiles in unit of measure of the projection
79+
:return: list of tiles (polygons)
80+
"""
10181
xmin = int(math.floor((to_cover.west - x_offset) / tile_size))
10282
xmax = int(math.ceil((to_cover.east - x_offset) / tile_size)) - 1
10383
ymin = int(math.floor(to_cover.south / tile_size))
@@ -106,31 +86,46 @@ def get_tiles(self, geometry: Union[Dict, shapely.geometry.Polygon]) -> List[Uni
10686
tiles = []
10787
for x in range(xmin, xmax + 1):
10888
for y in range(ymin, ymax + 1):
109-
tile = BoundingBox(
110-
west=max(x * tile_size + x_offset, to_cover.west),
111-
south=max(y * tile_size, to_cover.south),
112-
east=min((x + 1) * tile_size + x_offset, to_cover.east),
113-
north=min((y + 1) * tile_size, to_cover.north),
114-
crs=self.epsg,
89+
tiles.append(
90+
_BoundingBox(
91+
west=max(x * tile_size + x_offset, to_cover.west),
92+
south=max(y * tile_size, to_cover.south),
93+
east=min((x + 1) * tile_size + x_offset, to_cover.east),
94+
north=min((y + 1) * tile_size, to_cover.north),
95+
).as_polygon()
11596
)
11697

117-
if isinstance(geometry, dict):
118-
tiles.append(reproject_bounding_box(tile.as_dict(), from_crs=self.epsg, to_crs=bbox_crs))
119-
else:
120-
tiles.append(tile.as_polygon())
98+
return tiles
99+
100+
def get_tiles(self, geometry: Union[Dict, MultiPolygon, Polygon]) -> List[Polygon]:
101+
if isinstance(geometry, dict):
102+
bbox = _BoundingBox.from_dict(geometry)
103+
104+
elif isinstance(geometry, Polygon) or isinstance(geometry, MultiPolygon):
105+
bbox = _BoundingBox.from_polygon(geometry, crs=self.epsg)
106+
107+
else:
108+
raise JobSplittingFailure("geometry must be a dict or a shapely.geometry.Polygon or MultiPolygon")
109+
110+
x_offset = 500_000 if self._epsg_is_meters() else 0
111+
112+
tiles = _SizeBasedTileGrid._split_bounding_box(bbox, x_offset, self.size)
121113

122114
return tiles
123115

124116

125117
def split_area(
126-
aoi: Union[Dict, shapely.geometry.Polygon], projection="EPSG:3857", tile_size: float = 20.0
127-
) -> List[Union[Dict, shapely.geometry.Polygon]]:
118+
aoi: Union[Dict, MultiPolygon, Polygon], projection: str = "EPSG:3857", tile_size: float = 20_000.0
119+
) -> List[Polygon]:
128120
"""
129121
Split area of interest into tiles of given size and projection.
130122
:param aoi: area of interest (bounding box or shapely polygon)
131123
:param projection: projection to use for splitting. Default is web mercator (EPSG:3857)
132-
:param tile_size: size of tiles in km for UTM projections or degrees for WGS84
133-
:return: list of tiles (dicts with keys "west", "south", "east", "north", "crs" or shapely polygons). For dicts the original crs is preserved. For polygons the projection is set to the given projection.
124+
:param tile_size: size of tiles in unit of measure of the projection
125+
:return: list of tiles (polygons).
134126
"""
135-
tile_grid = SizeBasedTileGrid.from_size_projection(tile_size, projection)
127+
if isinstance(aoi, dict):
128+
projection = aoi.get("crs", projection)
129+
130+
tile_grid = _SizeBasedTileGrid.from_size_projection(tile_size, projection)
136131
return tile_grid.get_tiles(aoi)

0 commit comments

Comments
 (0)