Skip to content

Commit e344c4c

Browse files
committed
Issue #204: Raises warning when using a CRS for the geometry of aggregate_spatial and mask_polygon
Harmonize geometry handling in `aggregate_spatial` and `mask_polygon`, including handling of non-standard crs and whitelisting of allowed GeoJSON types Raises warning when using a CRS for the geometry of `aggregate_spatial` and `mask_polygon` Also mark this more clearly in the docs Related: #202, Open-EO/openeo-processes#235, Open-EO/openeo-processes#53
1 parent 451695f commit e344c4c

File tree

5 files changed

+239
-57
lines changed

5 files changed

+239
-57
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Allow, but raise warning when specifying a CRS for the geometry passed to `aggregate_spatial` and `mask_polygon`,
13+
which is non-standard/experimental feature, only supported by specific back-ends
14+
([#204](https://github.com/Open-EO/openeo-python-client/issues/204))
15+
1216
### Changed
1317

1418
### Removed

openeo/rest/datacube.py

Lines changed: 58 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ def load_collection(
161161
temporal_extent = cls._get_temporal_extent(extent=temporal_extent)
162162
arguments = {
163163
'id': collection_id,
164+
# TODO: spatial_extent could also be a "geojson" subtype object, so we might want to allow (and convert) shapely shapes as well here.
164165
'spatial_extent': spatial_extent,
165166
'temporal_extent': temporal_extent,
166167
}
@@ -634,35 +635,63 @@ def _merge_operator_binary_cubes(self, operator: str, other: 'DataCube', left_ar
634635
}
635636
))
636637

638+
def _get_geometry_argument(
639+
self,
640+
geometry: Union[shapely.geometry.base.BaseGeometry, dict, str, pathlib.Path, Parameter],
641+
valid_geojson_types: List[str],
642+
crs: str = None,
643+
) -> Union[dict, Parameter, PGNode]:
644+
"""
645+
Convert input to a geometry as "geojson" subtype object.
646+
"""
647+
if isinstance(geometry, (str, pathlib.Path)):
648+
# Assumption: `geometry` is path to polygon is a path to vector file at backend.
649+
# TODO #104: `read_vector` is non-standard process.
650+
# TODO: If path exists client side: load it client side?
651+
return PGNode(process_id="read_vector", arguments={"filename": str(geometry)})
652+
elif isinstance(geometry, Parameter):
653+
return geometry
654+
655+
if isinstance(geometry, shapely.geometry.base.BaseGeometry):
656+
geometry = mapping(geometry)
657+
if not isinstance(geometry, dict):
658+
raise OpenEoClientException("Invalid geometry argument: {g!r}".format(g=geometry))
659+
660+
if geometry.get("type") not in valid_geojson_types:
661+
raise OpenEoClientException("Invalid geometry type {t!r}, must be one of {s}".format(
662+
t=geometry.get("type"), s=valid_geojson_types
663+
))
664+
if crs:
665+
# TODO: don't warn when the crs is Lon-Lat like EPSG:4326?
666+
warnings.warn("Geometry with non-Lon-Lat CRS {c!r} is only supported by specific back-ends.".format(c=crs))
667+
# TODO #204 alternative for non-standard CRS in GeoJSON object?
668+
geometry["crs"] = {"type": "name", "properties": {"name": crs}}
669+
return geometry
670+
637671
def aggregate_spatial(
638672
self,
639673
geometries: Union[shapely.geometry.base.BaseGeometry, dict, str, pathlib.Path, Parameter],
640-
reducer: Union[str, PGNode, typing.Callable]
674+
reducer: Union[str, PGNode, typing.Callable],
675+
crs: str = None,
676+
# TODO arguments: target dimension, context
641677
) -> 'DataCube':
642678
"""
643679
Aggregates statistics for one or more geometries (e.g. zonal statistics for polygons)
644680
over the spatial dimensions.
645681
646682
:param geometries: shapely geometry, GeoJSON dictionary or path to GeoJSON file
647683
:param reducer: a callback function that creates a process graph, see :ref:`callbackfunctions`
648-
:return:
684+
:param crs: The spatial reference system of the provided polygon.
685+
By default longitude-latitude (EPSG:4326) is assumed.
686+
687+
.. note:: this ``crs`` argument is a non-standard/experimental feature, only supported by specific back-ends.
688+
See https://github.com/Open-EO/openeo-processes/issues/235 for details.
649689
"""
650-
# TODO #125 arguments: target dimension, context
651-
if isinstance(geometries, (str, pathlib.Path)):
652-
# polygon is a path to vector file
653-
# TODO this is non-standard process: check capabilities? #104 #40. Load polygon client side otherwise
654-
geometries = PGNode(process_id="read_vector", arguments={"filename": str(geometries)})
655-
elif isinstance(geometries, shapely.geometry.base.BaseGeometry):
656-
geometries = mapping(geometries)
657-
elif isinstance(geometries, dict) and geometries["type"] in (
658-
# TODO: support more geometry types?
659-
"Polygon", "MultiPolygon", "GeometryCollection", 'FeatureCollection'
660-
):
661-
pass
662-
elif isinstance(geometries, Parameter):
663-
pass
664-
else:
665-
raise OpenEoClientException("Invalid `geometries`: {g!r}".format(g=geometries))
690+
valid_geojson_types = [
691+
"Point", "MultiPoint", "LineString", "MultiLineString",
692+
"Polygon", "MultiPolygon", "GeometryCollection", "FeatureCollection"
693+
]
694+
geometries = self._get_geometry_argument(geometries, valid_geojson_types=valid_geojson_types, crs=crs)
666695
reducer = self._get_callback(reducer, parent_parameters=["data"])
667696
return self.process(process_id="aggregate_spatial", data=THIS, geometries=geometries, reducer=reducer)
668697

@@ -1111,8 +1140,10 @@ def mask(self, mask: 'DataCube' = None, replacement=None) -> 'DataCube':
11111140
)
11121141

11131142
def mask_polygon(
1114-
self, mask: Union[Polygon, MultiPolygon, str, pathlib.Path] = None,
1115-
srs="EPSG:4326", replacement=None, inside: bool = None
1143+
self,
1144+
mask: Union[shapely.geometry.base.BaseGeometry, dict, str, pathlib.Path, Parameter],
1145+
srs: str = None,
1146+
replacement=None, inside: bool = None
11161147
) -> 'DataCube':
11171148
"""
11181149
Applies a polygon mask to a raster data cube. To apply a raster mask use `mask`.
@@ -1125,31 +1156,15 @@ def mask_polygon(
11251156
which defaults to `no data`.
11261157
11271158
:param mask: A polygon, provided as a :class:`shapely.geometry.Polygon` or :class:`shapely.geometry.MultiPolygon`, or a filename pointing to a valid vector file
1128-
:param srs: The reference system of the provided polygon, by default this is Lat Lon (EPSG:4326).
1159+
:param srs: The spatial reference system of the provided polygon.
1160+
By default longitude-latitude (EPSG:4326) is assumed.
1161+
1162+
.. note:: this ``srs`` argument is a non-standard/experimental feature, only supported by specific back-ends.
1163+
See https://github.com/Open-EO/openeo-processes/issues/235 for details.
11291164
:param replacement: the value to replace the masked pixels with
11301165
"""
1131-
if isinstance(mask, (str, pathlib.Path)):
1132-
# TODO: default to loading file client side?
1133-
# TODO: change read_vector to load_uploaded_files https://github.com/Open-EO/openeo-processes/pull/106
1134-
read_vector = self.process(
1135-
process_id='read_vector',
1136-
arguments={'filename': str(mask)}
1137-
)
1138-
mask = {'from_node': read_vector._pg}
1139-
elif isinstance(mask, shapely.geometry.base.BaseGeometry):
1140-
if mask.area == 0:
1141-
raise ValueError("Mask {m!s} has an area of {a!r}".format(m=mask, a=mask.area))
1142-
mask = shapely.geometry.mapping(mask)
1143-
mask['crs'] = {
1144-
'type': 'name',
1145-
'properties': {'name': srs}
1146-
}
1147-
elif isinstance(mask, Parameter):
1148-
pass
1149-
else:
1150-
# Assume mask is already a valid GeoJSON object
1151-
assert "type" in mask
1152-
1166+
valid_geojson_types = ["Polygon", "MultiPolygon", "GeometryCollection", "Feature", "FeatureCollection"]
1167+
mask = self._get_geometry_argument(mask, valid_geojson_types=valid_geojson_types, crs=srs)
11531168
return self.process(
11541169
process_id="mask_polygon",
11551170
arguments=dict_no_none(

openeo/rest/imagecollectionclient.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,7 @@ def linear_scale_range(self, input_min, input_max, output_min, output_max) -> 'I
763763
}
764764
return self.graph_add_process(process_id, args)
765765

766-
def mask(self, polygon: Union[Polygon, MultiPolygon,str]=None, srs="EPSG:4326", rastermask: 'ImageCollection'=None,
766+
def mask(self, polygon: Union[Polygon, MultiPolygon,str]=None, srs=None, rastermask: 'ImageCollection'=None,
767767
replacement=None) -> 'ImageCollection':
768768
"""
769769
Mask the image collection using either a polygon or a raster mask.
@@ -805,12 +805,8 @@ def mask(self, polygon: Union[Polygon, MultiPolygon,str]=None, srs="EPSG:4326",
805805
raise ValueError("Mask {m!s} has an area of {a!r}".format(m=polygon, a=polygon.area))
806806

807807
geojson = mapping(polygon)
808-
geojson['crs'] = {
809-
'type': 'name',
810-
'properties': {
811-
'name': srs
812-
}
813-
}
808+
if srs:
809+
geojson['crs'] = {'type': 'name', 'properties': {'name': srs}}
814810
mask = geojson
815811
new_collection = self
816812
elif rastermask is not None:

tests/rest/datacube/test_datacube.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,9 +320,8 @@ def test_mask_polygon(s2cube, api_version):
320320
assert graph["arguments"] == {
321321
"data": {'from_node': 'loadcollection1'},
322322
"mask": {
323+
'type': 'Polygon',
323324
'coordinates': (((0.0, 0.0), (1.9, 0.0), (1.9, 1.9), (0.0, 1.9), (0.0, 0.0)),),
324-
'crs': {'properties': {'name': 'EPSG:4326'}, 'type': 'name'},
325-
'type': 'Polygon'
326325
}
327326
}
328327

tests/rest/datacube/test_datacube100.py

Lines changed: 173 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,19 +156,187 @@ def test_filter_bbox_args_and_kwargs_conflict(con100: Connection, args, kwargs,
156156
con100.load_collection("S2").filter_bbox(*args, **kwargs)
157157

158158

159-
def test_mask_polygon(con100: Connection):
159+
def test_aggregate_spatial_basic(con100: Connection):
160160
img = con100.load_collection("S2")
161161
polygon = shapely.geometry.box(0, 0, 1, 1)
162+
masked = img.aggregate_spatial(geometries=polygon, reducer="mean")
163+
assert sorted(masked.graph.keys()) == ["aggregatespatial1", "loadcollection1"]
164+
assert masked.graph["aggregatespatial1"] == {
165+
"process_id": "aggregate_spatial",
166+
"arguments": {
167+
"data": {"from_node": "loadcollection1"},
168+
"geometries": {
169+
"type": "Polygon",
170+
"coordinates": (((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)),),
171+
},
172+
"reducer": {"process_graph": {
173+
"mean1": {"process_id": "mean", "arguments": {"data": {"from_parameter": "data"}}, "result": True}
174+
}}
175+
},
176+
"result": True
177+
}
178+
179+
180+
@pytest.mark.parametrize(["polygon", "expected_geometries"], [
181+
(
182+
shapely.geometry.box(0, 0, 1, 1),
183+
{"type": "Polygon", "coordinates": (((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)),)},
184+
),
185+
(
186+
{"type": "Polygon", "coordinates": (((1, 0), (1, 1), (0, 1), (0, 0), (1, 0)),)},
187+
{"type": "Polygon", "coordinates": (((1, 0), (1, 1), (0, 1), (0, 0), (1, 0)),)},
188+
),
189+
(
190+
shapely.geometry.MultiPolygon([shapely.geometry.box(0, 0, 1, 1)]),
191+
{"type": "MultiPolygon", "coordinates": [(((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)),)]},
192+
),
193+
(
194+
shapely.geometry.GeometryCollection([shapely.geometry.box(0, 0, 1, 1)]),
195+
{"type": "GeometryCollection", "geometries": [
196+
{"type": "Polygon", "coordinates": (((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)),)}
197+
]},
198+
),
199+
(
200+
{
201+
"type": "FeatureCollection",
202+
"features": [
203+
{
204+
"type": "Feature", "properties": {},
205+
"geometry": {"type": "Polygon", "coordinates": (((1, 0), (1, 1), (0, 1), (0, 0), (1, 0)),)},
206+
},
207+
208+
]
209+
},
210+
{
211+
"type": "FeatureCollection",
212+
"features": [
213+
{
214+
"type": "Feature", "properties": {},
215+
"geometry": {"type": "Polygon", "coordinates": (((1, 0), (1, 1), (0, 1), (0, 0), (1, 0)),)},
216+
},
217+
218+
]
219+
},
220+
),
221+
])
222+
def test_aggregate_spatial_types(con100: Connection, polygon, expected_geometries):
223+
img = con100.load_collection("S2")
224+
masked = img.aggregate_spatial(geometries=polygon, reducer="mean")
225+
assert sorted(masked.graph.keys()) == ["aggregatespatial1", "loadcollection1"]
226+
assert masked.graph["aggregatespatial1"] == {
227+
"process_id": "aggregate_spatial",
228+
"arguments": {
229+
"data": {"from_node": "loadcollection1"},
230+
"geometries": expected_geometries,
231+
"reducer": {"process_graph": {
232+
"mean1": {"process_id": "mean", "arguments": {"data": {"from_parameter": "data"}}, "result": True}
233+
}}
234+
},
235+
"result": True
236+
}
237+
238+
239+
def test_aggregate_spatial_with_crs(con100: Connection, recwarn):
240+
img = con100.load_collection("S2")
241+
polygon = shapely.geometry.box(0, 0, 1, 1)
242+
masked = img.aggregate_spatial(geometries=polygon, reducer="mean", crs="EPSG:32631")
243+
warnings = [str(w.message) for w in recwarn]
244+
assert "Geometry with non-Lon-Lat CRS 'EPSG:32631' is only supported by specific back-ends." in warnings
245+
assert sorted(masked.graph.keys()) == ["aggregatespatial1", "loadcollection1"]
246+
assert masked.graph["aggregatespatial1"] == {
247+
"process_id": "aggregate_spatial",
248+
"arguments": {
249+
"data": {"from_node": "loadcollection1"},
250+
"geometries": {
251+
"type": "Polygon",
252+
"coordinates": (((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)),),
253+
"crs": {"properties": {"name": "EPSG:32631"}, "type": "name"},
254+
},
255+
"reducer": {"process_graph": {
256+
"mean1": {"process_id": "mean", "arguments": {"data": {"from_parameter": "data"}}, "result": True}
257+
}}
258+
},
259+
"result": True
260+
}
261+
262+
263+
def test_mask_polygon_basic(con100: Connection):
264+
img = con100.load_collection("S2")
265+
polygon = shapely.geometry.box(0, 0, 1, 1)
266+
masked = img.mask_polygon(mask=polygon)
267+
assert sorted(masked.graph.keys()) == ["loadcollection1", "maskpolygon1"]
268+
assert masked.graph["maskpolygon1"] == {
269+
"process_id": "mask_polygon",
270+
"arguments": {
271+
"data": {"from_node": "loadcollection1"},
272+
"mask": {
273+
"type": "Polygon",
274+
"coordinates": (((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)),),
275+
}
276+
},
277+
"result": True
278+
}
279+
280+
281+
@pytest.mark.parametrize(["polygon", "expected_mask"], [
282+
(
283+
shapely.geometry.box(0, 0, 1, 1),
284+
{"type": "Polygon", "coordinates": (((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)),)},
285+
),
286+
(
287+
{"type": "Polygon", "coordinates": (((1, 0), (1, 1), (0, 1), (0, 0), (1, 0)),)},
288+
{"type": "Polygon", "coordinates": (((1, 0), (1, 1), (0, 1), (0, 0), (1, 0)),)},
289+
),
290+
(
291+
shapely.geometry.MultiPolygon([shapely.geometry.box(0, 0, 1, 1)]),
292+
{"type": "MultiPolygon", "coordinates": [(((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)),)]},
293+
),
294+
(
295+
shapely.geometry.GeometryCollection([shapely.geometry.box(0, 0, 1, 1)]),
296+
{"type": "GeometryCollection", "geometries": [
297+
{"type": "Polygon", "coordinates": (((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)),)}
298+
]},
299+
),
300+
(
301+
{
302+
"type": "Feature", "properties": {},
303+
"geometry": {"type": "Polygon", "coordinates": (((1, 0), (1, 1), (0, 1), (0, 0), (1, 0)),)},
304+
},
305+
{
306+
"type": "Feature", "properties": {},
307+
"geometry": {"type": "Polygon", "coordinates": (((1, 0), (1, 1), (0, 1), (0, 0), (1, 0)),)},
308+
},
309+
),
310+
])
311+
def test_mask_polygon_types(con100: Connection, polygon, expected_mask):
312+
img = con100.load_collection("S2")
162313
masked = img.mask_polygon(mask=polygon)
163314
assert sorted(masked.graph.keys()) == ["loadcollection1", "maskpolygon1"]
164315
assert masked.graph["maskpolygon1"] == {
165316
"process_id": "mask_polygon",
166317
"arguments": {
167318
"data": {"from_node": "loadcollection1"},
168-
'mask': {
169-
'coordinates': (((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)),),
170-
'crs': {'properties': {'name': 'EPSG:4326'}, 'type': 'name'},
171-
'type': 'Polygon'}
319+
"mask": expected_mask
320+
},
321+
"result": True
322+
}
323+
324+
325+
def test_mask_polygon_with_crs(con100: Connection, recwarn):
326+
img = con100.load_collection("S2")
327+
polygon = shapely.geometry.box(0, 0, 1, 1)
328+
masked = img.mask_polygon(mask=polygon, srs="EPSG:32631")
329+
warnings = [str(w.message) for w in recwarn]
330+
assert "Geometry with non-Lon-Lat CRS 'EPSG:32631' is only supported by specific back-ends." in warnings
331+
assert sorted(masked.graph.keys()) == ["loadcollection1", "maskpolygon1"]
332+
assert masked.graph["maskpolygon1"] == {
333+
"process_id": "mask_polygon",
334+
"arguments": {
335+
"data": {"from_node": "loadcollection1"},
336+
"mask": {
337+
"type": "Polygon", "coordinates": (((1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0), (1.0, 0.0)),),
338+
"crs": {"type": "name", "properties": {"name": "EPSG:32631"}},
339+
},
172340
},
173341
"result": True
174342
}

0 commit comments

Comments
 (0)