Skip to content

Commit 016dd2e

Browse files
First version of tile based job splitter #745
1 parent 5e17e18 commit 016dd2e

File tree

2 files changed

+378
-0
lines changed

2 files changed

+378
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import abc
2+
import math
3+
from typing import NamedTuple, Optional, Union
4+
5+
import pyproj
6+
import shapely
7+
from shapely.geometry import shape
8+
from shapely.ops import transform
9+
10+
11+
class JobSplittingFailure(Exception):
12+
pass
13+
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):
35+
"""Simple NamedTuple container for a bounding box"""
36+
37+
west: float
38+
south: float
39+
east: float
40+
north: float
41+
crs: str = "EPSG:4326"
42+
43+
@classmethod
44+
def from_dict(cls, d: dict) -> "BoundingBox":
45+
return cls(**{k: d[k] for k in cls._fields if k not in cls._field_defaults or k in d})
46+
47+
@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)
51+
52+
def as_dict(self) -> dict:
53+
return self._asdict()
54+
55+
def as_polygon(self) -> shapely.geometry.Polygon:
56+
"""Get bounding box as a shapely Polygon"""
57+
return shapely.geometry.box(minx=self.west, miny=self.south, maxx=self.east, maxy=self.north)
58+
59+
60+
class TileGridInterface(metaclass=abc.ABCMeta):
61+
"""Interface for tile grid classes"""
62+
63+
@abc.abstractmethod
64+
def get_tiles(self, geometry: Union[dict, shapely.geometry.Polygon]) -> list[Union[dict, shapely.geometry.Polygon]]:
65+
"""Calculate tiles to cover given bounding box"""
66+
...
67+
68+
69+
class SizeBasedTileGrid(TileGridInterface):
70+
"""
71+
Specification of a tile grid, parsed from a size and a projection.
72+
"""
73+
74+
def __init__(self, epsg: str, size: float):
75+
self.epsg = epsg
76+
self.size = size
77+
78+
@classmethod
79+
def from_size_projection(cls, size: float, projection: str) -> "SizeBasedTileGrid":
80+
"""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))
101+
xmin = int(math.floor((to_cover.west - x_offset) / tile_size))
102+
xmax = int(math.ceil((to_cover.east - x_offset) / tile_size)) - 1
103+
ymin = int(math.floor(to_cover.south / tile_size))
104+
ymax = int(math.ceil(to_cover.north / tile_size)) - 1
105+
106+
tiles = []
107+
for x in range(xmin, xmax + 1):
108+
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,
115+
)
116+
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())
121+
122+
return tiles
123+
124+
125+
def split_area(
126+
aoi: Union[dict, shapely.geometry.Polygon], projection="EPSG:326", tile_size: float = 20.0
127+
) -> list[Union[dict, shapely.geometry.Polygon]]:
128+
"""
129+
Split area of interest into tiles of given size and projection.
130+
:param aoi: area of interest (bounding box or shapely polygon)
131+
: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.
134+
"""
135+
tile_grid = SizeBasedTileGrid.from_size_projection(tile_size, projection)
136+
return tile_grid.get_tiles(aoi)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import pytest
2+
import shapely
3+
4+
from openeo.extra.job_management.job_splitting import (
5+
BoundingBox,
6+
JobSplittingFailure,
7+
SizeBasedTileGrid,
8+
reproject_bounding_box,
9+
split_area,
10+
)
11+
12+
13+
@pytest.fixture
14+
def mock_polygon_wgs():
15+
return shapely.geometry.box(0.0, 0.0, 1.0, 1.0)
16+
17+
18+
@pytest.fixture
19+
def mock_polygon_utm():
20+
return shapely.geometry.box(0.0, 0.0, 100_000.0, 100_000.0)
21+
22+
23+
@pytest.fixture
24+
def mock_dict_no_crs():
25+
return {
26+
"west": 0.0,
27+
"south": 0.0,
28+
"east": 1.0,
29+
"north": 1.0,
30+
}
31+
32+
33+
@pytest.fixture
34+
def mock_dict_with_crs_utm():
35+
return {
36+
"west": 0.0,
37+
"south": 0.0,
38+
"east": 100_000.0,
39+
"north": 100_000.0,
40+
"crs": "EPSG:3857",
41+
}
42+
43+
44+
@pytest.mark.parametrize(
45+
["crs", "bbox"],
46+
[
47+
(
48+
"EPSG:32631",
49+
{"west": 640800, "south": 5676000, "east": 642200, "north": 5677000},
50+
),
51+
("EPSG:4326", {"west": 5.01, "south": 51.2, "east": 5.1, "north": 51.5}),
52+
],
53+
)
54+
def test_reproject_bounding_box_same(crs, bbox):
55+
reprojected = reproject_bounding_box(bbox, from_crs=crs, to_crs=crs)
56+
assert reprojected == dict(crs=crs, **bbox)
57+
58+
59+
def test_reproject_bounding_box():
60+
bbox = {"west": 640800, "south": 5676000, "east": 642200.0, "north": 5677000.0}
61+
reprojected = reproject_bounding_box(bbox, from_crs="EPSG:32631", to_crs="EPSG:4326")
62+
assert reprojected == {
63+
"west": pytest.approx(5.016118467277098),
64+
"south": pytest.approx(51.217660146353246),
65+
"east": pytest.approx(5.036548264535997),
66+
"north": pytest.approx(51.22699369149726),
67+
"crs": "EPSG:4326",
68+
}
69+
70+
71+
class TestBoundingBox:
72+
def test_basic(self):
73+
bbox = BoundingBox(1, 2, 3, 4)
74+
assert bbox.west == 1
75+
assert bbox.south == 2
76+
assert bbox.east == 3
77+
assert bbox.north == 4
78+
assert bbox.crs == "EPSG:4326"
79+
80+
def test_from_dict(self):
81+
bbox = BoundingBox.from_dict({"west": 1, "south": 2, "east": 3, "north": 4, "crs": "epsg:32633"})
82+
assert (bbox.west, bbox.south, bbox.east, bbox.north) == (1, 2, 3, 4)
83+
assert bbox.crs == "epsg:32633"
84+
85+
def test_from_dict_defaults(self):
86+
bbox = BoundingBox.from_dict({"west": 1, "south": 2, "east": 3, "north": 4})
87+
assert (bbox.west, bbox.south, bbox.east, bbox.north) == (1, 2, 3, 4)
88+
assert bbox.crs == "EPSG:4326"
89+
90+
def test_from_dict_underspecified(self):
91+
with pytest.raises(KeyError):
92+
_ = BoundingBox.from_dict({"west": 1, "south": 2, "color": "red"})
93+
94+
def test_from_dict_overspecified(self):
95+
bbox = BoundingBox.from_dict({"west": 1, "south": 2, "east": 3, "north": 4, "crs": "EPSG:4326", "color": "red"})
96+
assert (bbox.west, bbox.south, bbox.east, bbox.north) == (1, 2, 3, 4)
97+
assert bbox.crs == "EPSG:4326"
98+
99+
def test_from_polygon(self):
100+
polygon = shapely.geometry.box(1, 2, 3, 4)
101+
bbox = BoundingBox.from_polygon(polygon, projection="EPSG:4326")
102+
assert (bbox.west, bbox.south, bbox.east, bbox.north) == (1, 2, 3, 4)
103+
assert bbox.crs == "EPSG:4326"
104+
105+
def test_as_dict(self):
106+
bbox = BoundingBox(1, 2, 3, 4)
107+
assert bbox.as_dict() == {"west": 1, "south": 2, "east": 3, "north": 4, "crs": "EPSG:4326"}
108+
109+
def test_as_polygon(self):
110+
bbox = BoundingBox(1, 2, 3, 4)
111+
polygon = bbox.as_polygon()
112+
assert isinstance(polygon, shapely.geometry.Polygon)
113+
assert set(polygon.exterior.coords) == {(1, 2), (3, 2), (3, 4), (1, 4)}
114+
115+
116+
class TestSizeBasedTileGrid:
117+
118+
def test_from_size_projection(self):
119+
splitter = SizeBasedTileGrid.from_size_projection(0.1, "EPSG:4326")
120+
assert splitter.epsg == "epsg:4326"
121+
assert splitter.size == 0.1
122+
123+
def test_get_tiles_raises_exception(self):
124+
"""test get_tiles when the input geometry is not a dict or shapely.geometry.Polygon"""
125+
tile_grid = SizeBasedTileGrid.from_size_projection(0.1, "EPSG:4326")
126+
with pytest.raises(JobSplittingFailure):
127+
tile_grid.get_tiles("invalid_geometry")
128+
129+
def test_get_tiles_dict_returns_dict(self, mock_dict_no_crs):
130+
"""test get_tiles when the input geometry dict returns a list of dicts"""
131+
tile_grid = SizeBasedTileGrid.from_size_projection(0.1, "EPSG:4326")
132+
tiles = tile_grid.get_tiles(mock_dict_no_crs)
133+
assert isinstance(tiles, list)
134+
assert all(isinstance(tile, dict) for tile in tiles)
135+
136+
def test_get_tiles_polygon_returns_polygon(self, mock_polygon_wgs):
137+
"""test get_tiles when the input geometry is a polygon and the tile grid is in wgs"""
138+
tile_grid = SizeBasedTileGrid.from_size_projection(0.1, "EPSG:4326")
139+
tiles = tile_grid.get_tiles(mock_polygon_wgs)
140+
assert isinstance(tiles, list)
141+
assert all(isinstance(tile, shapely.geometry.Polygon) for tile in tiles)
142+
143+
def test_get_tiles_dict_no_crs_utm(self, mock_dict_no_crs):
144+
"""test get_tiles when the input geometry dict has no crs and the tile grid is in utm"""
145+
tile_grid = SizeBasedTileGrid.from_size_projection(20.0, "EPSG:3857")
146+
tiles = tile_grid.get_tiles(mock_dict_no_crs)
147+
assert tiles[0].get("crs") == "EPSG:4326"
148+
assert len(tiles) == 36
149+
150+
def test_get_tiles_dict_no_crs_wgs(self, mock_dict_no_crs):
151+
"""test get_tiles when the input geometry dict has no crs and the tile grid is in wgs"""
152+
tile_grid = SizeBasedTileGrid.from_size_projection(0.1, "EPSG:4326")
153+
tiles = tile_grid.get_tiles(mock_dict_no_crs)
154+
assert tiles[0].get("crs") == "EPSG:4326"
155+
assert len(tiles) == 100
156+
157+
def test_get_tiles_dict_with_crs_same(self, mock_dict_with_crs_utm):
158+
"""test get_tiles when the input geometry dict and the tile grid have the same crs"""
159+
tile_grid = SizeBasedTileGrid.from_size_projection(20.0, "EPSG:3857")
160+
tiles = tile_grid.get_tiles(mock_dict_with_crs_utm)
161+
assert tiles[0].get("crs") == "EPSG:3857"
162+
assert len(tiles) == 25
163+
164+
def test_get_tiles_dict_with_crs_different(self, mock_dict_with_crs_utm):
165+
"""test get_tiles when the input geometry dict and the tile grid have different crs. The original crs from the geometry should be preserved."""
166+
tile_grid = SizeBasedTileGrid.from_size_projection(0.1, "EPSG:4326")
167+
tiles = tile_grid.get_tiles(mock_dict_with_crs_utm)
168+
assert tiles[0].get("crs") == "EPSG:3857"
169+
assert len(tiles) == 81
170+
171+
def test_simple_get_tiles_dict(self, mock_dict_with_crs_utm):
172+
"""test get_tiles when the the tile grid size is equal to the size of the input geometry. The original geometry should be returned."""
173+
tile_grid = SizeBasedTileGrid.from_size_projection(100, "EPSG:3857")
174+
tiles = tile_grid.get_tiles(mock_dict_with_crs_utm)
175+
assert len(tiles) == 1
176+
assert tiles[0] == mock_dict_with_crs_utm
177+
178+
def test_multiple_get_tile_dict(self, mock_dict_with_crs_utm):
179+
"""test get_tiles when the the tile grid size is smaller than the size of the input geometry. The input geometry should be split into multiple tiles."""
180+
tile_grid = SizeBasedTileGrid.from_size_projection(20, "EPSG:3857")
181+
tiles = tile_grid.get_tiles(mock_dict_with_crs_utm)
182+
assert len(tiles) == 25
183+
assert tiles[0].get("crs") == "EPSG:3857"
184+
assert tiles[0].get("west") == 0
185+
assert tiles[0].get("south") == 0
186+
assert tiles[0].get("east") == 20_000
187+
assert tiles[0].get("north") == 20_000
188+
189+
def test_larger_get_tile_dict(self, mock_dict_with_crs_utm):
190+
"""test get_tiles when the the tile grid size is larger than the size of the input geometry. The original geometry should be returned."""
191+
tile_grid = SizeBasedTileGrid.from_size_projection(200, "EPSG:3857")
192+
tiles = tile_grid.get_tiles(mock_dict_with_crs_utm)
193+
assert len(tiles) == 1
194+
assert tiles[0] == mock_dict_with_crs_utm
195+
196+
def test_get_tiles_polygon_utm(self, mock_polygon_utm):
197+
"""test get_tiles when the input geometry is a polygon in wgs and the tile grid is in utm"""
198+
tile_grid = SizeBasedTileGrid.from_size_projection(20.0, "EPSG:3857")
199+
tiles = tile_grid.get_tiles(mock_polygon_utm)
200+
assert isinstance(tiles, list)
201+
assert all(isinstance(tile, shapely.geometry.Polygon) for tile in tiles)
202+
assert len(tiles) == 25
203+
assert tiles[0] == shapely.geometry.box(0.0, 0.0, 20_000.0, 20_000.0)
204+
205+
def test_get_tiles_polygon_wgs(self, mock_polygon_wgs):
206+
"""test get_tiles when the input geometry is a polygon in wgs and the tile grid is in wgs"""
207+
tile_grid = SizeBasedTileGrid.from_size_projection(0.1, "EPSG:4326")
208+
tiles = tile_grid.get_tiles(mock_polygon_wgs)
209+
assert isinstance(tiles, list)
210+
assert all(isinstance(tile, shapely.geometry.Polygon) for tile in tiles)
211+
assert len(tiles) == 100
212+
assert tiles[0] == shapely.geometry.box(0.0, 0.0, 0.1, 0.1)
213+
214+
def test_simple_get_tiles_polygon(self, mock_polygon_utm):
215+
"""test get_tiles when the the tile grid size is equal to the size of the input geometry. The original geometry should be returned."""
216+
tile_grid = SizeBasedTileGrid.from_size_projection(100.0, "EPSG:3857")
217+
tiles = tile_grid.get_tiles(mock_polygon_utm)
218+
assert len(tiles) == 1
219+
assert tiles[0] == mock_polygon_utm
220+
221+
def test_larger_get_tiles_polygon(self, mock_polygon_utm):
222+
"""test get_tiles when the the tile grid size is larger than the size of the input geometry. The original geometry should be returned."""
223+
tile_grid = SizeBasedTileGrid.from_size_projection(200.0, "EPSG:3857")
224+
tiles = tile_grid.get_tiles(mock_polygon_utm)
225+
assert len(tiles) == 1
226+
assert tiles[0] == mock_polygon_utm
227+
228+
229+
def test_split_area_default():
230+
"""test split_area with default parameters"""
231+
aoi = {"west": 0.0, "south": 0.0, "east": 20_000.0, "north": 20_000.0, "crs": "EPSG:3857"}
232+
tiles = split_area(aoi)
233+
assert len(tiles) == 1
234+
assert tiles[0] == aoi
235+
236+
237+
def test_split_area_custom():
238+
"""test split_area with wgs projection"""
239+
aoi = {"west": 0.0, "south": 0.0, "east": 1.0, "north": 1.0, "crs": "EPSG:4326"}
240+
tiles = split_area(aoi, "EPSG:4326", 1.0)
241+
assert len(tiles) == 1
242+
assert tiles[0] == aoi

0 commit comments

Comments
 (0)