Skip to content

Commit ff29568

Browse files
committed
feat: added example implementation for tiles
1 parent 95dde20 commit ff29568

File tree

8 files changed

+226
-13
lines changed

8 files changed

+226
-13
lines changed

app/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from app.platforms.dispatcher import load_processing_platforms
44
from .config.logger import setup_logging
55
from .config.settings import settings
6-
from .routers import jobs_status, unit_jobs, health
6+
from .routers import jobs_status, unit_jobs, health, tiles
77

88
setup_logging()
99

@@ -19,6 +19,7 @@
1919
# keycloak.register(app, prefix="/auth") # mounts OIDC endpoints for login if needed
2020

2121
# include routers
22+
app.include_router(tiles.router)
2223
app.include_router(jobs_status.router)
2324
app.include_router(unit_jobs.router)
2425
app.include_router(health.router)

app/routers/tiles.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import logging
2+
from fastapi import APIRouter, HTTPException, status
3+
from geojson_pydantic import GeometryCollection
4+
5+
from app.schemas.tiles import TileRequest
6+
from app.services.tiles import split_polygon_by_grid
7+
8+
9+
router = APIRouter()
10+
logger = logging.getLogger(__name__)
11+
12+
13+
@router.post(
14+
"/tiles",
15+
status_code=status.HTTP_201_CREATED,
16+
tags=["Upscale Tasks"],
17+
summary="Split an area of interest in a list of tiles.",
18+
description="Given a certain area of interest and a tiling grid definition (from the"
19+
"service’s Max AOI capacity), calculate the number of tiles to be"
20+
"processed by the upscaling service.",
21+
)
22+
def split_in_tiles(payload: TileRequest) -> GeometryCollection:
23+
try:
24+
logger.debug(f"Splitting tiles in a {payload.grid} formation")
25+
return split_polygon_by_grid(payload.aoi, payload.grid)
26+
except Exception as e:
27+
logger.exception(
28+
f"An error occurred while calculating tiles for {payload.grid}"
29+
)
30+
raise HTTPException(
31+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
32+
detail=f"An error occurred while calculating tiles for {payload.grid}: {e}",
33+
)

app/schemas/tiles.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1-
# class TileRequest(BaseModel):
2-
# aoi: dict
3-
# grid: str
1+
from enum import Enum
2+
from pydantic import BaseModel
3+
from geojson_pydantic import Polygon
4+
5+
6+
class GridTypeEnum(str, Enum):
7+
KM_20 = "20x20km"
8+
9+
10+
class TileRequest(BaseModel):
11+
aoi: Polygon
12+
grid: GridTypeEnum
413

514

615
# class TileResponse(BaseModel):

app/services/tiles.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import logging
2+
from typing import Callable, Dict, List
3+
4+
import pyproj
5+
from geojson_pydantic import GeometryCollection, Polygon
6+
from geojson_pydantic.geometries import Geometry, parse_geometry_obj
7+
from shapely import box
8+
from shapely.geometry import shape
9+
from shapely.ops import transform
10+
11+
from app.schemas.tiles import GridTypeEnum
12+
13+
logger = logging.getLogger(__name__)
14+
15+
GRID_REGISTRY: Dict[GridTypeEnum, Callable[[Polygon], GeometryCollection]] = {}
16+
17+
18+
def split_polygon_by_grid(polygon: Polygon, grid: GridTypeEnum) -> GeometryCollection:
19+
"""
20+
Split a GeoJSON Polygon into smaller polygons according to the specified grid type.
21+
22+
:param polygon: The GeoJSON Polygon to split.
23+
:param grid: The grid type to use for splitting.
24+
:return: A list of GeoJSON Polygons.
25+
:raises ValueError: If the grid type is unknown.
26+
"""
27+
if grid.lower() not in GRID_REGISTRY:
28+
logger.error(f"An unknown grid was requested: {grid}")
29+
raise ValueError(f"Unknown grid: {grid}")
30+
31+
split_func = GRID_REGISTRY[grid]
32+
return split_func(polygon)
33+
34+
35+
def split_by_20x20_km_grid(polygon: Polygon) -> GeometryCollection:
36+
"""
37+
Split polygon into 20x20 km tiles.
38+
39+
:param polygon: The GeoJSON Polygon to split.
40+
:return: A list of GeoJSON Polygons.
41+
"""
42+
logger.debug("Splitting polygon in a 20x20km grid")
43+
44+
return GeometryCollection(
45+
type="GeometryCollection", geometries=_split_by_km_grid(polygon, 20.0)
46+
)
47+
48+
49+
def _split_by_km_grid(aoi: Polygon, cell_size_km: float) -> List[Geometry]:
50+
"""
51+
Splits a polygon into a list of smaller polygons based on a square grid of given size in km.
52+
53+
:param aoi: Polygon in GeoJSON format.
54+
:param cell_size_km: Size of the grid cell in kilometers (default 20km).
55+
:return: List of polygons as GeoJSON dicts.
56+
"""
57+
# Load the polygon
58+
polygon = shape(aoi)
59+
60+
# Project to a local projection (meters) for accurate distance calculations
61+
proj_wgs84 = pyproj.CRS("EPSG:4326")
62+
proj_meters = pyproj.CRS("EPSG:3857")
63+
project_to_meters = pyproj.Transformer.from_crs(
64+
proj_wgs84, proj_meters, always_xy=True
65+
).transform
66+
project_to_wgs84 = pyproj.Transformer.from_crs(
67+
proj_meters, proj_wgs84, always_xy=True
68+
).transform
69+
70+
polygon_m = transform(project_to_meters, polygon)
71+
min_x, min_y, max_x, max_y = polygon_m.bounds
72+
cell_size_m = cell_size_km * 1000 # convert km to meters
73+
74+
result_polygons: List[Geometry] = []
75+
76+
x = min_x
77+
while x < max_x:
78+
y = min_y
79+
while y < max_y:
80+
cell = box(x, y, x + cell_size_m, y + cell_size_m)
81+
intersection = polygon_m.intersection(cell)
82+
if not intersection.is_empty:
83+
# Transform back to WGS84
84+
intersection_wgs84 = transform(project_to_wgs84, intersection)
85+
result_polygons.append(
86+
parse_geometry_obj(intersection_wgs84.__geo_interface__)
87+
)
88+
y += cell_size_m
89+
x += cell_size_m
90+
return result_polygons
91+
92+
93+
GRID_REGISTRY[GridTypeEnum.KM_20] = split_by_20x20_km_grid

mypy.ini

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +0,0 @@
1-
[mypy-openeo]
2-
ignore_missing_imports = True
3-
4-
[mypy-fastapi_keycloak]
5-
ignore_missing_imports = True
6-
7-
[mypy-requests]
8-
ignore_missing_imports = True

requirements.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
fastapi
22
fastapi_keycloak
33
flake8
4+
geojson_pydantic
45
httpx
56
mypy
67
mypy_extensions
@@ -9,9 +10,11 @@ psycopg2-binary
910
pydantic
1011
pydantic-settings
1112
pydantic_core
13+
pyproj
1214
pytest
1315
pytest-asyncio
1416
pytest-cov
1517
python-dotenv
1618
requests
17-
SQLAlchemy
19+
SQLAlchemy
20+
types-shapely

tests/routers/test_tiles.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from unittest.mock import patch
2+
import pytest
3+
from app.schemas.tiles import GridTypeEnum
4+
5+
6+
@pytest.fixture
7+
def dummy_payload():
8+
coords = [[(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]]
9+
return {
10+
"aoi": {
11+
"type": "Polygon",
12+
"coordinates": coords,
13+
},
14+
"grid": GridTypeEnum.KM_20,
15+
}
16+
17+
18+
def test_split_in_tiles_success(client, dummy_payload):
19+
response = client.post("/tiles", json=dummy_payload)
20+
assert response.status_code == 201
21+
data = response.json()
22+
assert "geometries" in data
23+
assert len(data["geometries"]) == 36
24+
assert data["type"] == "GeometryCollection"
25+
26+
27+
@patch("app.routers.tiles.split_polygon_by_grid")
28+
def test_split_in_tiles_unknown_grid(mock_split, client, dummy_payload):
29+
mock_split.side_effect = ValueError("Unknown grid: INVALID_GRID")
30+
response = client.post("/tiles", json=dummy_payload)
31+
assert response.status_code == 500
32+
assert "Unknown grid" in response.json()["detail"]
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from geojson_pydantic import GeometryCollection, Polygon
2+
import pytest
3+
4+
from app.schemas.tiles import GridTypeEnum
5+
from app.services.tiles import (
6+
_split_by_km_grid,
7+
split_by_20x20_km_grid,
8+
split_polygon_by_grid,
9+
)
10+
11+
12+
def test_split_polygon_by_grid_known_grid():
13+
coords = [[(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]]
14+
polygon = Polygon(type="Polygon", coordinates=coords)
15+
16+
# Should work with known grid
17+
result = split_polygon_by_grid(polygon, GridTypeEnum.KM_20)
18+
assert isinstance(result, GeometryCollection)
19+
assert len(result.geometries) >= 36
20+
21+
22+
def test_split_polygon_by_grid_unknown_grid_raises():
23+
coords = [[(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]]
24+
polygon = Polygon(type="Polygon", coordinates=coords)
25+
26+
with pytest.raises(ValueError):
27+
split_polygon_by_grid(polygon, "UNKNOWN_GRID")
28+
29+
30+
def test__split_by_km_grid_creates_multiple_cells():
31+
coords = [[(0, 0), (0.36, 0), (0.36, 0.36), (0, 0.36), (0, 0)]]
32+
polygon = Polygon(type="Polygon", coordinates=coords)
33+
34+
result = _split_by_km_grid(polygon, 10.0)
35+
36+
assert len(result) == 25
37+
for geom in result:
38+
assert geom.type == "Polygon"
39+
40+
41+
def test_split_by_20x20_km_grid_returns_geometry_collection():
42+
coords = [[(0, 0), (1, 0), (1, 1), (0, 1), (0, 0)]]
43+
polygon = Polygon(type="Polygon", coordinates=coords)
44+
45+
result = split_by_20x20_km_grid(polygon)
46+
47+
assert isinstance(result, GeometryCollection)
48+
assert len(result.geometries) == 36
49+
for geom in result.geometries:
50+
assert geom.type == "Polygon"

0 commit comments

Comments
 (0)