From 939312d9485a1540a09a45c3c05d21a11124deb3 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 1 Apr 2026 11:41:17 +0100 Subject: [PATCH 01/55] added provider record --- algorithm_catalog/argans/record.json | 54 ++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 algorithm_catalog/argans/record.json diff --git a/algorithm_catalog/argans/record.json b/algorithm_catalog/argans/record.json new file mode 100644 index 000000000..725f58ff8 --- /dev/null +++ b/algorithm_catalog/argans/record.json @@ -0,0 +1,54 @@ +{ + "id": "argans", + "type": "Feature", + "conformsTo": [ + "http://www.opengis.net/spec/ogcapi-records-1/1.0/req/record-core" + ], + "properties": { + "created": "2026-04-01T11:00:00Z", + "updated": "2026-04-01T11:00:00Z", + "type": "algorithm_provider", + "title": "Argans Ltd.", + "description": "Argans specializes in EO applications and services. ", + "keywords": [], + "language": { + "code": "en-US", + "name": "English (United States)" + }, + "languages": [ + { + "code": "en-US", + "name": "English (United States)" + } + ], + "contacts": [], + "themes": [], + "license": "other", + "acl": { + "admin": [ + "@argans.co.uk" + ] + } + }, + "linkTemplates": [], + "links": [ + { + "rel": "website", + "type": "text/html", + "title": "Argans Ltd.", + "href": "https://argans.co.uk/" + }, + { + "rel": "logo-dark", + "type": "image/png", + "title": "Logo", + "href": "" + }, + { + "rel": "logo-light", + "type": "image/png", + "title": "Logo", + "href": "" + } + ] +} From 22df88bd60f17c95d7a0d3fb420678b10bb86ee8 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 1 Apr 2026 11:51:45 +0100 Subject: [PATCH 02/55] added logo --- algorithm_catalog/argans/record.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/record.json b/algorithm_catalog/argans/record.json index 725f58ff8..73cdbdef5 100644 --- a/algorithm_catalog/argans/record.json +++ b/algorithm_catalog/argans/record.json @@ -42,13 +42,13 @@ "rel": "logo-dark", "type": "image/png", "title": "Logo", - "href": "" + "href": "https://argans.co.uk/img/logo.png" }, { "rel": "logo-light", "type": "image/png", "title": "Logo", - "href": "" + "href": "https://argans.co.uk/img/logo.png" } ] } From f7398143f9ca48575fbc46b87975e7fcc4d1a6a7 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 1 Apr 2026 11:53:44 +0100 Subject: [PATCH 03/55] updated logo --- algorithm_catalog/argans/record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algorithm_catalog/argans/record.json b/algorithm_catalog/argans/record.json index 73cdbdef5..fe33f5956 100644 --- a/algorithm_catalog/argans/record.json +++ b/algorithm_catalog/argans/record.json @@ -48,7 +48,7 @@ "rel": "logo-light", "type": "image/png", "title": "Logo", - "href": "https://argans.co.uk/img/logo.png" + "href": "https://argans.co.uk/img/logos/argans_white_new.png" } ] } From 166653bca46b40d8e769b6a6e3269f8988ee6143 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 1 Apr 2026 11:54:50 +0100 Subject: [PATCH 04/55] updated logo --- algorithm_catalog/argans/record.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/record.json b/algorithm_catalog/argans/record.json index fe33f5956..6bf9911b5 100644 --- a/algorithm_catalog/argans/record.json +++ b/algorithm_catalog/argans/record.json @@ -42,13 +42,13 @@ "rel": "logo-dark", "type": "image/png", "title": "Logo", - "href": "https://argans.co.uk/img/logo.png" + "href": "https://argans.co.uk/img/logos/argans_white_new.png" }, { "rel": "logo-light", "type": "image/png", "title": "Logo", - "href": "https://argans.co.uk/img/logos/argans_white_new.png" + "href": "https://argans.co.uk/img/logo.png" } ] } From a2a9d201f72b0ab200098eed47756ee25feb26da Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 8 Apr 2026 14:16:25 +0100 Subject: [PATCH 05/55] first commit --- algorithm_catalog/argans/record.json | 112 ++--- .../argans/waterlines/openeo_udp/README.md | 0 .../argans/waterlines/openeo_udp/generate.py | 117 ++++++ .../argans/waterlines/openeo_udp/s2_index.py | 369 +++++++++++++++++ .../openeo_udp/udf_morph_operations.py | 82 ++++ .../udf_waterlines_from_water_land_mask.py | 390 ++++++++++++++++++ .../argans/waterlines/records/waterlines.json | 119 ++++++ 7 files changed, 1140 insertions(+), 49 deletions(-) create mode 100644 algorithm_catalog/argans/waterlines/openeo_udp/README.md create mode 100644 algorithm_catalog/argans/waterlines/openeo_udp/generate.py create mode 100644 algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py create mode 100644 algorithm_catalog/argans/waterlines/openeo_udp/udf_morph_operations.py create mode 100644 algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py create mode 100644 algorithm_catalog/argans/waterlines/records/waterlines.json diff --git a/algorithm_catalog/argans/record.json b/algorithm_catalog/argans/record.json index 6bf9911b5..d1fab8b7d 100644 --- a/algorithm_catalog/argans/record.json +++ b/algorithm_catalog/argans/record.json @@ -1,54 +1,68 @@ { - "id": "argans", - "type": "Feature", - "conformsTo": [ - "http://www.opengis.net/spec/ogcapi-records-1/1.0/req/record-core" + "id": "argans", + "type": "Feature", + "conformsTo": [ + "http://www.opengis.net/spec/ogcapi-records-1/1.0/req/record-core" + ], + "properties": { + "created": "2026-04-01T00:00:00Z", + "updated": "2026-04-01T00:00:00Z", + "type": "algorithm_provider", + "title": "Argans Ltd", + "description": "Argans Ltd builds open source EO tools.", + "keywords": [], + "language": { + "code": "en-US", + "name": "English (United States)" + }, + "languages": [ + { + "code": "en-US", + "name": "English (United States)" + } ], - "properties": { - "created": "2026-04-01T11:00:00Z", - "updated": "2026-04-01T11:00:00Z", - "type": "algorithm_provider", - "title": "Argans Ltd.", - "description": "Argans specializes in EO applications and services. ", - "keywords": [], - "language": { - "code": "en-US", - "name": "English (United States)" - }, - "languages": [ - { - "code": "en-US", - "name": "English (United States)" - } + "contacts": [ + { + "name": "Argans Ltd", + "emails": [ + { + "value": "enquiries@argans.co.uk" + } ], - "contacts": [], - "themes": [], - "license": "other", - "acl": { - "admin": [ - "@argans.co.uk" - ] - } - }, - "linkTemplates": [], - "links": [ - { - "rel": "website", + "links": [ + { + "href": "https://argans.co.uk", "type": "text/html", - "title": "Argans Ltd.", - "href": "https://argans.co.uk/" - }, - { - "rel": "logo-dark", - "type": "image/png", - "title": "Logo", - "href": "https://argans.co.uk/img/logos/argans_white_new.png" - }, - { - "rel": "logo-light", - "type": "image/png", - "title": "Logo", - "href": "https://argans.co.uk/img/logo.png" - } - ] + "title": "Argans Ltd", + "rel": "website" + } + ] + } + ], + "themes": [], + "acl": { + "admin": ["@argans.co.uk"] + } + }, + "linkTemplates": [], + "links": [ + { + "rel": "website", + "type": "text/html", + "title": "Argans Ltd", + "href": "https://argans.co.uk" + }, + { + "rel": "logo-light", + "type": "image/png", + "title": "Logo", + "href": "" + }, + { + "rel": "logo-dark", + "type": "image/png", + "title": "Logo", + "href": "" + } + ] } diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/README.md b/algorithm_catalog/argans/waterlines/openeo_udp/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py new file mode 100644 index 000000000..40b289819 --- /dev/null +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -0,0 +1,117 @@ +import json +import sys +from pathlib import Path +import openeo +from openeo.api.process import Parameter +from openeo.rest.connection import Connection +from openeo.rest.datacube import DataCube +from openeo.rest.udp import build_process_dict +from openeo import UDF +from s2_index import ( + WATERLAND_THRESHOLDS, + s2_scl, + s2_index_mask, + DEFAULT_S2_COLLECTION, + Reducer, + DEFAULT_MAX_CLOUD_COVER, +) + + +def build_water_land_mask_cube( + con: Connection, + bbox, + time_range, + method, + threshold, + iterations, +) -> DataCube: + """Build an openEO processing cube for the given water/land mask run specification.""" + spec = WATERLAND_THRESHOLDS.get(method) + + if spec is None: + raise ValueError(f"Unsupported water/land mask method: {method}") + + if method == "S2_SCL": + _, water_land_mask_cube = s2_scl( + con, DEFAULT_S2_COLLECTION, bbox, time_range, Reducer.NONE + ) + + else: + # If user doesn't specify, always take the default. + threshold = threshold if threshold is not None else spec.defaults["threshold"] + + _, water_land_mask_cube = s2_index_mask( + con=con, + collection_id=DEFAULT_S2_COLLECTION, + bbox=bbox, + time_range=time_range, + reducer=Reducer.NONE, + index_name=method, + threshold=threshold, + mode=spec.mode.value, # "gt" or "lt" + max_cloud_coverage=DEFAULT_MAX_CLOUD_COVER, + ) + + udf = UDF.from_file( + Path(__file__).parent / "udf_morph_operations.py", + context={"from_parameter": "context"}, + ) + water_land_mask_cube = water_land_mask_cube.apply_dimension( + process=udf, dimension="t", context={"iterations": iterations} + ) + + return water_land_mask_cube + + +def generate() -> dict: + + # 1. Connection + conn = openeo.connect(url="openeo.dataspace.copernicus.eu") + + # 2. Define parameters + spatial_extent = Parameter.bounding_box( + name="spatial_extent", + default={"west": 5.0, "south": 51.2, "east": 5.1, "north": 51.3}, + ) + temporal_extent = Parameter.temporal_interval( + name="temporal_extent", default=["2025-01-01", "2025-12-31"] + ) + + #method = Parameter.string(name="s2_method", default="S2_NDWI") + + #threshold = Parameter.number(name="threshold", default=None) + + iterations = Parameter.integer(name="iterations", default=2) + + water_land_mask = build_water_land_mask_cube( + con=conn, + bbox=spatial_extent, + time_range=temporal_extent, + method="S2_NDWI", + threshold=None, + iterations=iterations, + ) + + udf = UDF.from_file( + Path(__file__).parent / "udf_waterlines_from_water_land_mask.py", + context={"from_parameter": "context"}, + ) + waterlines_cube = water_land_mask.apply_dimension( + process=udf, dimension="t", context={"crs": "EPSG:3857", "time_dim": "t"} + ) + + return build_process_dict( + process_graph=waterlines_cube, + process_id="waterlines", + summary="Waterlines extracted from Sentinel-2.", + description=(Path(__file__).parent / "README.md").read_text(), + parameters=[ + spatial_extent, + temporal_extent, + iterations, + ], + categories=["sentinel-2", "coastline", "waterlines"], + ) + +if __name__ == "__main__": + json.dump(generate(), sys.stdout, indent=2) \ No newline at end of file diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py b/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py new file mode 100644 index 000000000..981741d0b --- /dev/null +++ b/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py @@ -0,0 +1,369 @@ +"""Land/water mask extraction methods using openEO.""" + +from __future__ import annotations +from functools import reduce +from operator import or_ +from enum import Enum +from typing import Mapping, Optional +from dataclasses import dataclass +from openeo import collection_property + +from openeo.rest.connection import Connection +from openeo.rest.datacube import DataCube + + +# region defaults + +DEFAULT_S2_COLLECTION = "SENTINEL2_L2A" +DEFAULT_TARGET_EPSG: int = 3857 +DEFAULT_MAX_CLOUD_COVER = 10 + +# endregion + + +# region types + + +class ThresholdMode(str, Enum): + """Comparison direction used when applying a threshold to an index.""" + + GT = "gt" + LT = "lt" + + +@dataclass(frozen=True) +class ThresholdSpec: + """Default threshold(s) and help text for UI/CLI usage.""" + + defaults: Mapping[ + str, Optional[float] + ] # keys are method-arg names, e.g. {"threshold": 0.1} + mode: ThresholdMode + description: str + + +# endregion + + +# region registries + + +WATERLAND_THRESHOLDS: dict[str, ThresholdSpec] = { + "S2_NDWI": ThresholdSpec( + defaults={"threshold": 0.01}, + mode=ThresholdMode.GT, + description="NDWI threshold (water if NDWI > threshold).", + ), + "S2_MNDWI": ThresholdSpec( + defaults={"threshold": 0.1}, + mode=ThresholdMode.GT, + description="MNDWI threshold (water if MNDWI > threshold).", + ), + "S2_SCL": ThresholdSpec( + defaults={}, + mode=ThresholdMode.GT, # unused (no threshold), kept for consistency + description="No threshold (SCL class 6 = water).", + ), + "S2_NDVI": ThresholdSpec( + defaults={"threshold": 0.03}, + mode=ThresholdMode.LT, + description="NDVI threshold (water if NDVI < threshold).", + ), + "S2_BNDVI": ThresholdSpec( + defaults={"threshold": 0.03}, + mode=ThresholdMode.LT, + description="BNDVI threshold (water if BNDVI < threshold).", + ), + "S2_GNDVI": ThresholdSpec( + defaults={"threshold": 0.03}, + mode=ThresholdMode.LT, + description="GNDVI threshold (water if GNDVI < threshold).", + ), +} + +# endregion + + +@dataclass(frozen=True) +class SpatialExtent: + """Spatial extent.""" + + west: float + south: float + east: float + north: float + + def to_openeo(self) -> dict[str, float]: + """Converts spatial extent to openeo format.""" + return { + "west": self.west, + "south": self.south, + "east": self.east, + "north": self.north, + } + + +class Reducer(str, Enum): + """Reducer method for cubes' temporal composites.""" + + MEDIAN = "median" + MEAN = "mean" + MIN = "min" + MAX = "max" + NONE = "none" + SUM = "sum" + + +_NORMDIFF_S2: dict[str, tuple[str, str]] = { + # index_name: (band_pos, band_neg) used in (pos - neg) / (pos + neg) + "ndwi": ("B03", "B08"), + "mndwi": ("B03", "B11"), + "ndvi": ("B08", "B04"), + "bndvi": ("B08", "B02"), + "gndvi": ("B08", "B03"), +} + + +def load_collection( + con: Connection, + collection_id: str, + bbox: SpatialExtent, + time_range: list[str] | None, + bands: list[str] | None = None, + max_cloud_cover: float | None = None, + target_epsg: int | None = None, + resolution: float | tuple[float, float] | None = None, + method: str = "near", + grid_ids: list[str] | None = None, +) -> DataCube: + """Generic openEO collection loader. + + max_cloud_cover is only applied to Sentinel-2 collections. + + Args: + target_epsg: Reproject output to this EPSG code. + resolution: Optional output resolution (single value or (x, y)). + method: Resampling method for reprojection. + """ + + load_kwargs = { + "collection_id": collection_id, + "spatial_extent": bbox, + "temporal_extent": time_range, + "bands": bands, + } + + properties = [] + if grid_ids: + grid_prop = collection_property("grid:code") + grid_filter = reduce(or_, [(grid_prop == gid) for gid in grid_ids]) + properties.append(grid_filter) + + if properties: + load_kwargs["properties"] = properties + + # Apply cloud cover filter only for Sentinel-2 + if "SENTINEL2" in collection_id.upper() and max_cloud_cover is not None: + load_kwargs["max_cloud_cover"] = max_cloud_cover + + cube = con.load_collection(**load_kwargs) + + # Optional reprojection + if target_epsg is not None: + reproj_kwargs = {"projection": target_epsg, "method": method} + if resolution is not None: + reproj_kwargs["resolution"] = resolution + + cube = cube.process("resample_spatial", data=cube, **reproj_kwargs) + + return cube + + +def maybe_reduce_time(cube: DataCube, reducer: Reducer) -> DataCube: + """Optionally reduce a cube over the time dimension using the given reducer.""" + if reducer == Reducer.NONE: + return cube + return cube.reduce_dimension(dimension="t", reducer=reducer.value) + + +def load_reduce( + con: Connection, + collection_id: str, + bbox: SpatialExtent, + time_range: list[str], + bands: list[str], + reducer: Reducer, + target_epsg: int | None = None, + resolution: float | tuple[float, float] | None = None, + method: str = "near", +) -> DataCube: + """Load a collection and optionally reduce it over time.""" + cube = load_collection( + con, + collection_id, + bbox, + time_range, + bands, + target_epsg=target_epsg, + resolution=resolution, + method=method, + ) + return maybe_reduce_time(cube, reducer) + + +def s2_clear_mask_from_scl(cube: DataCube) -> DataCube: + """Create a boolean clear-pixel mask from the Sentinel-2 SCL band.""" + scl = cube.band("SCL") + # SCL codes: 3 - cloud shadows, 8 - cloud medium prob, 9 - cloud high prob + return (scl == 3 - 1000) | (scl == 8 - 1000) | (scl == 9 - 1000) + + +def load_s2( + con: Connection, + collection_id: str, + bbox: SpatialExtent, + time_range: list[str], + bands: list[str], + reducer: Reducer, + max_cloud_coverage: float | None = DEFAULT_MAX_CLOUD_COVER, + target_epsg: int | None = DEFAULT_TARGET_EPSG, + grid_ids: list[str] | None = None, +) -> tuple[DataCube, DataCube]: + """Load Sentinel-2 with SCL-based cloud masking before optional temporal reduction.""" + bands_with_scl = list(dict.fromkeys(bands + ["SCL"])) # keep order, unique + + # Load S2 bands + SCL + cube = load_collection( + con, + collection_id, + bbox, + time_range, + bands_with_scl, + max_cloud_cover=max_cloud_coverage, + target_epsg=target_epsg, + grid_ids=grid_ids, + ) + + # Cloud masking + clear = s2_clear_mask_from_scl(cube) + cube = cube.mask(clear) + + # Remove SCL band + cube = cube.filter_bands(bands) + + # Reduce now if specified + cube = maybe_reduce_time(cube, reducer) + clear = maybe_reduce_time(clear, reducer) + + return cube, clear + + +# region private (processing helpers) + + +def _bin(cube: DataCube) -> DataCube: + """Convert a boolean condition cube to a 0/1 cube using an openEO `if` process.""" + return cube.apply( + lambda x: x.process("if", arguments={"value": x, "accept": 1, "reject": 0}) + ) + + +# endregion + + +# region public +def s2_scl( + con: Connection, + collection_id: str, + bbox: SpatialExtent, + time_range: list[str], + reducer: Reducer, +) -> tuple[DataCube, DataCube]: + """Load Sentinel-2 SCL and return a boolean mask selecting SCL class 6. + + Args: + con: openEO connection object. + collection_id: Sentinel-2 collection identifier to load. + bbox: Spatial extent (bounding box) to load. + time_range: Temporal extent as `[start, end]`. + reducer: Temporal reducer to apply (e.g. median/mean) or `Reducer.none`. + + Returns: + Sentinel-2 cube and + Boolean cube where pixels equal to SCL class 6 are True. + """ + s2_cube = load_reduce(con, collection_id, bbox, time_range, ["SCL"], reducer) + scl_water_mask = s2_cube.band("SCL") == 6 + return s2_cube, scl_water_mask + + +def s2_index_mask( + con: Connection, + collection_id: str, + bbox: SpatialExtent, + time_range: list[str], + reducer: Reducer, + index_name: str, + threshold: float | None, + mode: str = "gt", + max_cloud_coverage: float | None = None, + grid_ids: list[str] | None = None, +) -> tuple[DataCube, DataCube]: + """Compute a supported Sentinel-2 norm-diff index and return a binary mask using a threshold. + + Args: + con: openEO connection object. + collection_id: Sentinel-2 collection identifier to load. + bbox: Spatial extent (bounding box) to load. + time_range: Temporal extent as `[start, end]`. + reducer: Temporal reducer to apply (e.g. median/mean) or `Reducer.none`. + index_name: Name of the index to compute. Supported values: `ndwi`, `mndwi`, + `ndvi`, `bndvi`, `gndvi`. + threshold: Threshold applied to the index. + mode: Threshold mode: + - `"gt"`: pixels where index > threshold become 1 + - `"lt"`: pixels where index < threshold become 1 + max_cloud_coverage: Max allowed cloud coverage. + grid_ids: Filter collection output to these grid IDs. + + Returns: + Sentinel-2 cube and + 0/1 cube representing the index threshold mask. The land pixels + are labelled with 0 and the water pixels are labelled with 1. + + Raises: + ValueError: If `index_name` is not supported or `mode` is not `gt`/`lt`. + """ + key = index_name.lower().split("_")[1] + if key not in _NORMDIFF_S2: + raise ValueError( + f"Unsupported index_name={index_name!r}. Supported: {sorted(_NORMDIFF_S2)}" + ) + + band_pos, band_neg = _NORMDIFF_S2[key] + s2_cube, clear = load_s2( + con, + collection_id, + bbox, + time_range, + [band_pos, band_neg], + reducer, + max_cloud_coverage, + grid_ids=grid_ids, + ) + + pos = s2_cube.band(band_pos) + neg = s2_cube.band(band_neg) + + idx = (pos - neg) / (pos + neg) + idx = idx.mask(clear) + + if mode == "gt": + return s2_cube, _bin(idx > threshold) + if mode == "lt": + return s2_cube, _bin(idx < threshold) + + raise ValueError(f"Unsupported mode={mode!r}. Use 'gt' or 'lt'.") + + +# endregion diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_morph_operations.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_morph_operations.py new file mode 100644 index 000000000..ad73574f4 --- /dev/null +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_morph_operations.py @@ -0,0 +1,82 @@ +# /// script +# dependencies = [ +# "scikit-image", +# "scipy", +# ] +# /// + +from openeo.udf import XarrayDataCube, inspect +import numpy as np +import xarray as xr +from scipy.ndimage import binary_fill_holes, binary_opening + +DEFAULT_WATER_VALUE = 1 + + +def _build_coastal_water_mask( + arr: np.ndarray, + water_value: int = DEFAULT_WATER_VALUE, + nodata: float | None = 999, + iterations: int = 1, +) -> np.ndarray: + """ + Build coastal-water-only mask from land/water mask. + Inland water is filled. + + Returns: + 0/1 mask where 1 is coastal water and 0 is land + """ + water = arr == water_value + if nodata is not None: + water = water & (arr != nodata) + + # Remove small estuaries + water = binary_opening(water, iterations=iterations) + + # Remove bridges + land = ~water + land = binary_opening(land, iterations=iterations) + + land_filled = binary_fill_holes(land) + water_filled = ~land_filled + return water_filled.astype(np.uint8) + + +def apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube: + """Apply morphological algorithms on DataCube""" + + cube_array: xr.DataArray = cube.get_array() + inspect(data=[cube_array.shape], message="Input UDF cube_array shape") + + cube_array = cube_array.astype(np.uint8) + + cube_array_3d = cube_array.squeeze(dim="bands") + + modified = xr.apply_ufunc( + _build_coastal_water_mask, + cube_array_3d, + input_core_dims=[["y", "x"]], + output_core_dims=[["y", "x"]], + vectorize=True, + dask="parallelized", + output_dtypes=[np.uint8], + kwargs={ + "water_value": DEFAULT_WATER_VALUE, + "nodata": 999, + "iterations": context["iterations"], + }, + ) + + modified_da = xr.DataArray( + modified, + coords={ + "t": cube_array.coords["t"], + "y": cube_array.coords["y"], + "x": cube_array.coords["x"], + }, + dims=["t", "y", "x"], + ) + modified_da = modified_da.expand_dims(dim={"bands": cube_array.coords["bands"]}) + modified_da = modified_da.transpose("t", "bands", "y", "x") + + return XarrayDataCube(modified_da) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py new file mode 100644 index 000000000..8cc8a140d --- /dev/null +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -0,0 +1,390 @@ +# /// script +# dependencies = [ +# "rioxarray", +# ] +# /// + +from typing import Iterable, Union, Any +import numpy as np +import xarray as xr +import geopandas as gpd +from rasterio.features import shapes, Affine +from shapely.geometry import ( + box, + shape, + LineString, + MultiLineString, + Polygon, + Point, + GeometryCollection, +) +import json +from shapely.ops import unary_union +from openeo.udf import inspect +import rioxarray +from openeo.udf.feature_collection import FeatureCollection +from openeo.udf.udf_data import UdfData + +GeometryLike = Union[LineString, MultiLineString, GeometryCollection] + +DEFAULT_OUT_LAYER = "waterline" +DEFAULT_TIME_DIM = "time" +DEFAULT_VAR_NAME = "var" +DEFAULT_SEA_DIRECTION_8_COLUMN = "sea_direction_8" +DEFAULT_SEA_AZIMUTH_DEG_COLUMN = "sea_azimuth_deg" +DEFAULT_MIN_DANGLING_LENGTH = 10000 +DEFAULT_MIN_HOLE_AREA = 1000000 + + +def _iter_lines(geom: GeometryLike) -> Iterable[LineString]: + """Recursively yield all LineString objects contained in a geometry.""" + if geom.is_empty: + return + + if isinstance(geom, LineString): + yield geom + elif isinstance(geom, (MultiLineString, GeometryCollection)): + for subgeom in geom.geoms: + yield from _iter_lines(subgeom) + + +def split_into_segments(geom: GeometryLike) -> list[LineString]: + """ + Split a geometry into 2-point LineStrings representing individual segments + between consecutive vertices. + + Args: + geom: Input geometry. + + Returns: List of non-zero-length segments. + """ + segments: list[LineString] = [] + + for line in _iter_lines(geom): + coords = list(line.coords) + for start, end in zip(coords[:-1], coords[1:]): + if start != end: # Avoid zero-length segments + segments.append(LineString([start, end])) + + return segments + + +def _remove_small_interiors( + geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA +) -> Polygon: + """Remove small interior rings from polygon.""" + if geom.is_empty: + return geom + + kept_holes = [] + for ring in geom.interiors: + if Polygon(ring).area >= min_hole_area: + kept_holes.append(ring) + + return Polygon(geom.exterior, holes=kept_holes) + + +def _vectorize_water_polygons( + water_mask: np.ndarray, + transform: Affine, + water_value: int = 1, +) -> list[Polygon]: + """Create water polygons from a 0/1 coastal-water mask.""" + polys: list[Polygon] = [] + mask = water_mask.astype(bool) + + for geom, val in shapes(water_mask, mask=mask, transform=transform): + if int(val) == int(water_value): + shp = shape(geom) + shp = _remove_small_interiors(shp) + polys.append(shp) + + return polys + + +def _remove_extent_intersections( + waterline: LineString, bounds, buffer: float = 0.0001 +) -> list[LineString]: + """Return 2-point segments that do NOT intersect the raster extent boundary.""" + extent_edge = box(*bounds).boundary + edges = split_into_segments(waterline) + return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)] + + +def _remove_short_dangling_segments( + segments: list[LineString], + min_dangling_length: float = 0.0, +) -> list[LineString]: + """Remove short isolated segments (both endpoints occur only once).""" + if not segments or min_dangling_length <= 0: + return segments + + endpoint_counts: dict[Any, int] = {} + for seg in segments: + a, b = seg.coords[0], seg.coords[-1] + endpoint_counts[a] = endpoint_counts.get(a, 0) + 1 + endpoint_counts[b] = endpoint_counts.get(b, 0) + 1 + + kept: list[LineString] = [] + for seg in segments: + if seg.length >= min_dangling_length: + kept.append(seg) + continue + + a, b = seg.coords[0], seg.coords[-1] + if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1: + kept.append(seg) + + return kept + + +def _clean_waterline_segments( + waterline: LineString, + bounds, + min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH, +) -> list[LineString]: + """ + Clean waterline and return as a *list of 2-point segments* (one per edge). + """ + segments = _remove_extent_intersections(waterline, bounds) + if not segments: + return [] + + segments = _remove_short_dangling_segments( + segments, + min_dangling_length=min_dangling_length, + ) + if not segments: + return [] + + return segments + + +def _get_sea_direction_for_segment( + water_poly: Polygon, seg: LineString +) -> tuple[str, float | None]: + """ + Determine where the sea (water polygon side) lies relative to a segment. + + Returns: + sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees. + """ + if seg.is_empty or seg.length == 0: + return "unknown", None + + # Get first and last coordinates of the segment + a = np.asarray(seg.coords[0], dtype=float) + b = np.asarray(seg.coords[-1], dtype=float) + + # Compute direction vector and length + v = b - a + norm = np.linalg.norm(v) + if norm == 0: + return "unknown", None + + # Unit tangent and left normal vector + t = v / norm + n_left = np.array([-t[1], t[0]], dtype=float) + + # Segment midpoint + mid = (a + b) / 2.0 + + # How far to step away from the segment (1 perc of segment len.) + eps = max(0.5, min(5.0, float(seg.length) * 0.01)) + + # Probe points + left_pt = mid + eps * n_left + right_pt = mid - eps * n_left + + left_in = water_poly.contains(Point(left_pt)) + right_in = water_poly.contains(Point(right_pt)) + + if left_in and not right_in: + sea_vec = n_left + elif right_in and not left_in: + sea_vec = -n_left + else: + return "unknown", None + + # Map-based 8-way direction from sea_vec (x=east, y=north) + x, y = float(sea_vec[0]), float(sea_vec[1]) + + # Compute angle + angle = np.degrees(np.arctan2(y, x)) + angle = (angle + 360.0) % 360.0 + + # 8-sector compass, centered on E=0°, NE=45°, N=90°, ... + dirs = ["E", "NE", "N", "NW", "W", "SW", "S", "SE"] + idx = int(((angle + 22.5) % 360) // 45) + + return dirs[idx], float(angle) + + +def _segments_for_water_mask( + water_mask_2d: np.ndarray, + transform: Affine, + bounds, + simplify_tolerance: float | None = None, +) -> tuple[list[LineString], Polygon] | None: + """Converts water land mask for single timestamp to cleaned waterline segments.""" + + # Here water_value is 1 because _build_coastal_water_mask returns 0/1 + water_polys = _vectorize_water_polygons(water_mask_2d, transform, water_value=1) + if not water_polys: + return None + + water_poly = unary_union(water_polys) + + if simplify_tolerance is not None: + water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True) + + boundary = water_poly.boundary + + # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish + if isinstance(boundary, LineString): + cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds) + elif isinstance(boundary, MultiLineString): + cleaned_segments = [] + for part in boundary.geoms: + cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds)) + else: + return None + + return cleaned_segments, water_poly + + +def waterline_from_land_water_raster( + da: xr.DataArray, + crs: str | None = None, + simplify_tolerance: float | None = None, + time_dim: str = DEFAULT_TIME_DIM, +) -> gpd.GeoDataFrame: + """ + Generate waterline segments for each time step from a land/water mask raster. + + Args: + da: DataArray containing a land/water mask with a time dimension. + crs: DataArray projection. If None it will be read from da. However in some + situations this information might not be stored in da (for example when + reading dataset from netCDF) so option to provide it is given. + simplify_tolerance: Optional tolerance for geometry simplification. + If provided, resulting geometries will be simplified. + time_dim: Name of the time dimension in the raster dataset. + + Returns: + A GeoDataFrame with columns: + - time: Time step associated with each geometry. + - type: Feature classification. + - sea_direction_8: Direction toward the sea expressed as one of + eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW). + - sea_azimuth_deg: Direction toward the sea in degrees (azimuth, + typically measured clockwise from north). + - geometry: Waterline geometry (LineString or MultiLineString). + """ + + if crs is None: + if da.rio.crs is not None: + crs = da.rio.crs + elif "crs" in da.attrs: + crs = da.attrs["crs"] + if crs is None: + raise ValueError("CRS needed to perform vectorization.") + + records: list[dict[str, Any]] = [] + + if time_dim not in da.dims: + raise KeyError( + f"No {time_dim} in input array. Use one of the following: {da.dims}" + ) + + transform = da.rio.transform() + bounds = da.rio.bounds() + + for i in range(da.sizes[time_dim]): + slice2d = da.isel({time_dim: i}) + tval = slice2d[time_dim].values + inspect(data=[tval], message="Extracting waterlines for timestamp") + res = _segments_for_water_mask( + slice2d.values, + transform=transform, + bounds=bounds, + simplify_tolerance=simplify_tolerance, + ) + if res is None: + continue + + segments, water_poly = res + inspect(data=[tval], message="Calculating sea direction for timestamp") + for seg in segments: + sea_direction = _get_sea_direction_for_segment(water_poly, seg) + records.append( + { + "time": tval, + "type": "waterline_segment", + DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0], + DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1], + "geometry": seg, + } + ) + inspect(data=[records], message="Converting records to geodataframe") + gdf = gpd.GeoDataFrame(records, geometry="geometry", crs=crs) + gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True) + return gdf + + +# def apply_datacube(cube: xr.DataArray, context: dict) -> xr.DataArray: +# inspect(data=[cube.shape], message="Input UDF cube shape") +# gdf = waterline_from_land_water_raster( +# da=cube, +# crs=context.get("crs"), +# simplify_tolerance=context.get("simplify_tolerance"), +# time_dim=context.get("time_dim", "time"), +# ) +# inspect(data=[len(gdf)], message="Output gdf len") + +# geojson = json.dumps(gdf.__geo_interface__, default=str) +# inspect(data=[geojson], message="Output geojson") +# #return xr.DataArray(geojson) +# return xr.DataArray.from_series(gdf.geometry) + +# def apply_vectorcube(geometries: gpd.geodataframe.GeoDataFrame, +# cube: xr.DataArray, +# context: dict) -> tuple[gpd.GeoDataFrame, xr.DataArray]: +# inspect(data=[geometries], message="Input UDF geometries") +# inspect(data=[cube.shape], message="Input UDF cube shape") +# gdf = waterline_from_land_water_raster( +# da=cube, +# crs=context.get("crs"), +# simplify_tolerance=context.get("simplify_tolerance"), +# time_dim=context.get("time_dim", "time"), +# ) +# inspect(data=[gdf], message="Output geojson") + +# return gdf, cube + +def apply_udf_data(data: UdfData) -> UdfData: + + inspect(data=[data], message="Input UDFData inspection") + + cube = data.get_datacube_list()[0].get_array() + inspect(data=[list(cube.dims), list(cube.shape)], message="Input UDF cube dims/shape") + + gdf = waterline_from_land_water_raster( + da=cube, + crs=data.user_context.get("crs"), + simplify_tolerance=data.user_context.get("simplify_tolerance"), + time_dim=data.user_context.get("time_dim", "time"), + ) + + inspect(data=[gdf], message="Output gdf") + + feature_collection = FeatureCollection( + id=DEFAULT_OUT_LAYER, + data=gdf, + ) + + data.set_feature_collection_list([feature_collection]) + + inspect(data=[data], message="Output UDFData inspection") + + return data diff --git a/algorithm_catalog/argans/waterlines/records/waterlines.json b/algorithm_catalog/argans/waterlines/records/waterlines.json new file mode 100644 index 000000000..4b1381ff5 --- /dev/null +++ b/algorithm_catalog/argans/waterlines/records/waterlines.json @@ -0,0 +1,119 @@ +{ + "id": "waterlines", + "type": "Feature", + "conformsTo": [ + "http://www.opengis.net/spec/ogcapi-records-1/1.0/req/record-core", + "https://apex.esa.int/core/openeo-udp" + ], + "geometry": null, + "properties": { + "created": "2026-04-01T00:00:00Z", + "updated": "2026-04-01T00:00:00Z", + "type": "service", + "title": "Waterlines from Sentinel-2.", + "description": "Extracts coastal waterlines from timeseries of Sentinel-2.", + "keywords": [ + "Waterlines", + "Coastline", + "Sentinel-2" + ], + "language": { + "code": "en-US", + "name": "English (United States)" + }, + "languages": [ + { + "code": "en-US", + "name": "English (United States)" + } + ], + "contacts": [ + { + "name": "Argans Ltd", + "organization": "Argans Ltd", + "links": [ + { + "href": "https://argans.co.uk", + "title": "Argans Ltd", + "rel": "about", + "type": "text/html" + } + ], + "contactInstructions": "Contact via Argans Ltd", + "roles": [ + "processor" + ] + } + ], + "themes": [ + { + "concepts": [ + { + "id": "COASTAL PROCESSES" + }, + { + "id": "REMOTE SENSING" + }, + { + "id": "Sentinel-2 MSI" + } + ], + "scheme": "https://gcmd.earthdata.nasa.gov/kms/concepts/concept_scheme/sciencekeywords" + } + ], + "formats": [ + { + "name": "GeoTiff" + }, + { + "name": "PNG" + } + ], + "license": "CC-BY-4.0" + }, + "linkTemplates": [], + "links": [ + { + "rel": "application", + "type": "application/vnd.openeo+json;type=process", + "title": "openEO Process Definition", + "href": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/main/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json" + }, + { + "rel": "code", + "type": "text/html", + "title": "openeo-udp repository", + "href": "https://github.com/developmentseed/openeo-udp" + }, + { + "rel": "webapp", + "type": "text/html", + "title": "OpenEO Web Editor", + "href": "https://editor.openeo.org/?wizard=UDP&wizard%7Eprocess=waterlines&wizard%7EprocessUrl=https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/main/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json&server=openeofed.dataspace.copernicus.eu" + }, + { + "rel": "service", + "type": "application/json", + "title": "CDSE openEO Federation", + "href": "https://openeofed.dataspace.copernicus.eu" + }, + { + "rel": "platform", + "type": "application/json", + "title": "TiTiler openEO", + "href": "../../../../platform_catalog/titiler_openeo.json" + }, + { + "rel": "provider", + "type": "application/json", + "title": "Argans Ltd", + "href": "../../record.json" + }, + { + "rel": "thumbnail", + "type": "image/png", + "title": "Thumbnail image", + "href": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/main/algorithm_catalog/argans/waterlines/records/thumbnail.png" + } + ] +} From 0f835a87e01cad23c1d285502f8e4889360874f8 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 8 Apr 2026 14:43:33 +0100 Subject: [PATCH 06/55] added descriptions --- .../argans/waterlines/openeo_udp/README.md | 3 + .../argans/waterlines/openeo_udp/generate.py | 61 +++++++++++++------ 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/README.md b/algorithm_catalog/argans/waterlines/openeo_udp/README.md index e69de29bb..42d228110 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/README.md +++ b/algorithm_catalog/argans/waterlines/openeo_udp/README.md @@ -0,0 +1,3 @@ +# Waterlines extraction from Sentinel-2 + +Extract waterlines from timeseries of Sentinel-2 imagery. diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index 40b289819..e82949f2d 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -19,11 +19,11 @@ def build_water_land_mask_cube( con: Connection, - bbox, - time_range, - method, - threshold, - iterations, + bbox: Parameter, + time_range: Parameter, + method: Parameter, + threshold: Parameter, + iterations: Parameter, ) -> DataCube: """Build an openEO processing cube for the given water/land mask run specification.""" spec = WATERLAND_THRESHOLDS.get(method) @@ -32,9 +32,7 @@ def build_water_land_mask_cube( raise ValueError(f"Unsupported water/land mask method: {method}") if method == "S2_SCL": - _, water_land_mask_cube = s2_scl( - con, DEFAULT_S2_COLLECTION, bbox, time_range, Reducer.NONE - ) + _, water_land_mask_cube = s2_scl(con, DEFAULT_S2_COLLECTION, bbox, time_range, Reducer.NONE) else: # If user doesn't specify, always take the default. @@ -65,33 +63,57 @@ def build_water_land_mask_cube( def generate() -> dict: - # 1. Connection + ### 1. Connection conn = openeo.connect(url="openeo.dataspace.copernicus.eu") - # 2. Define parameters + ### 2. Define parameters spatial_extent = Parameter.bounding_box( name="spatial_extent", - default={"west": 5.0, "south": 51.2, "east": 5.1, "north": 51.3}, + description=( + "Bounding box of the area of interest to extract waterlines for. " + "Defined as 'west', 'south', 'east', 'north' keys (EPSG:4326)." + ), ) temporal_extent = Parameter.temporal_interval( - name="temporal_extent", default=["2025-01-01", "2025-12-31"] + name="temporal_extent", + default=["2015-06-23", "2025-12-31"], + description=("Date range over which to extract waterlines. "), ) - - #method = Parameter.string(name="s2_method", default="S2_NDWI") - - #threshold = Parameter.number(name="threshold", default=None) - iterations = Parameter.integer(name="iterations", default=2) + # TODO: How user can set these? + method = Parameter.string( + name="s2_method", + default="S2_NDWI", + values=["S2_NDWI", "S2_MNDWI", "S2_SCL", "S2_NDVI", "S2_BNDVI", "S2_GNDVI"], + description=( + "Method name to create water/land mask from Sentinel-2 imagery." + "Water/land mask is an immediate step to extract waterlines." + ), + ) + # threshold = Parameter.number(name="threshold", default=None) + + iterations = Parameter.integer( + name="iterations", + default=2, + description=( + "Number of iterations for morphological operations on water/land raster." + "Morphological operations help to remove small objects and holes as " + "well as bridges and estuaries leaving more proper mask for coastline " + "waterlines extraction" + ), + ) + ### 3. Generate water/land mask from Sentinel-2 using selected method. water_land_mask = build_water_land_mask_cube( con=conn, bbox=spatial_extent, time_range=temporal_extent, - method="S2_NDWI", + method="S2_NDWI", # this doesn't work with Parameter threshold=None, iterations=iterations, ) + ### 4. Extract waterlines from water/land mask using UDF. udf = UDF.from_file( Path(__file__).parent / "udf_waterlines_from_water_land_mask.py", context={"from_parameter": "context"}, @@ -113,5 +135,6 @@ def generate() -> dict: categories=["sentinel-2", "coastline", "waterlines"], ) + if __name__ == "__main__": - json.dump(generate(), sys.stdout, indent=2) \ No newline at end of file + json.dump(generate(), sys.stdout, indent=2) From 2cf234839214993349e634bb3f8b7abf45016342 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 8 Apr 2026 15:36:07 +0100 Subject: [PATCH 07/55] build graph for each method --- .../argans/waterlines/openeo_udp/generate.py | 277 +++++++++++++----- .../argans/waterlines/openeo_udp/s2_index.py | 79 +---- 2 files changed, 222 insertions(+), 134 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index e82949f2d..8d4e359fd 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -1,126 +1,262 @@ import json import sys from pathlib import Path + import openeo +from openeo import UDF from openeo.api.process import Parameter +from openeo.processes import if_, eq from openeo.rest.connection import Connection from openeo.rest.datacube import DataCube from openeo.rest.udp import build_process_dict -from openeo import UDF + from s2_index import ( - WATERLAND_THRESHOLDS, s2_scl, s2_index_mask, DEFAULT_S2_COLLECTION, - Reducer, DEFAULT_MAX_CLOUD_COVER, + WATERLAND_THRESHOLDS, ) -def build_water_land_mask_cube( - con: Connection, - bbox: Parameter, - time_range: Parameter, - method: Parameter, - threshold: Parameter, - iterations: Parameter, -) -> DataCube: - """Build an openEO processing cube for the given water/land mask run specification.""" - spec = WATERLAND_THRESHOLDS.get(method) - - if spec is None: - raise ValueError(f"Unsupported water/land mask method: {method}") - - if method == "S2_SCL": - _, water_land_mask_cube = s2_scl(con, DEFAULT_S2_COLLECTION, bbox, time_range, Reducer.NONE) - - else: - # If user doesn't specify, always take the default. - threshold = threshold if threshold is not None else spec.defaults["threshold"] - - _, water_land_mask_cube = s2_index_mask( - con=con, - collection_id=DEFAULT_S2_COLLECTION, - bbox=bbox, - time_range=time_range, - reducer=Reducer.NONE, - index_name=method, - threshold=threshold, - mode=spec.mode.value, # "gt" or "lt" - max_cloud_coverage=DEFAULT_MAX_CLOUD_COVER, - ) +def apply_morphology(cube: DataCube, iterations: int) -> DataCube: + """ + Apply morphological operations to each time slice of a water/land mask. + Used to clean the mask (remove noise, fill gaps, smooth shapes) + before extracting waterlines. + """ udf = UDF.from_file( Path(__file__).parent / "udf_morph_operations.py", context={"from_parameter": "context"}, ) - water_land_mask_cube = water_land_mask_cube.apply_dimension( - process=udf, dimension="t", context={"iterations": iterations} + return cube.apply_dimension( + process=udf, + dimension="t", + context={"iterations": iterations}, + ) + + +def create_waterlines(cube: DataCube, crs: str = "EPSG:3857") -> DataCube: + """ + Extract waterlines from a water/land mask using a UDF. + + Runs per time slice and outputs waterline geometries. + """ + udf = UDF.from_file( + Path(__file__).parent / "udf_waterlines_from_water_land_mask.py", + context={"from_parameter": "context"}, + ) + return cube.apply_dimension( + process=udf, + dimension="t", + context={"crs": crs, "time_dim": "t"}, + ) + + +def build_water_land_mask_cube( + con: Connection, + bbox, + time_range, + max_cloud_coverage, + method, + iterations, + ndwi_threshold, + mndwi_threshold, + ndvi_threshold, + bndvi_threshold, + gndvi_threshold, +): + """ + Build a water/land mask using multiple selectable Sentinel-2 methods. + + All method branches are constructed and the selected one is chosen + using openEO graph logic (since 'method' is a UDP parameter). + """ + # Build all candidate branches + + _, scl_cube = s2_scl( + con, + DEFAULT_S2_COLLECTION, + bbox, + time_range, + max_cloud_coverage=max_cloud_coverage + ) + scl_cube = apply_morphology(scl_cube, iterations) + + _, ndwi_cube = s2_index_mask( + con=con, + collection_id=DEFAULT_S2_COLLECTION, + bbox=bbox, + time_range=time_range, + index_name="S2_NDWI", + threshold=ndwi_threshold, + mode="gt", + max_cloud_coverage=max_cloud_coverage, + ) + ndwi_cube = apply_morphology(ndwi_cube, iterations) + + _, mndwi_cube = s2_index_mask( + con=con, + collection_id=DEFAULT_S2_COLLECTION, + bbox=bbox, + time_range=time_range, + index_name="S2_MNDWI", + threshold=mndwi_threshold, + mode="gt", + max_cloud_coverage=max_cloud_coverage, + ) + mndwi_cube = apply_morphology(mndwi_cube, iterations) + + _, ndvi_cube = s2_index_mask( + con=con, + collection_id=DEFAULT_S2_COLLECTION, + bbox=bbox, + time_range=time_range, + index_name="S2_NDVI", + threshold=ndvi_threshold, + mode="lt", + max_cloud_coverage=max_cloud_coverage, + ) + ndvi_cube = apply_morphology(ndvi_cube, iterations) + + _, bndvi_cube = s2_index_mask( + con=con, + collection_id=DEFAULT_S2_COLLECTION, + bbox=bbox, + time_range=time_range, + index_name="S2_BNDVI", + threshold=bndvi_threshold, + mode="lt", + max_cloud_coverage=max_cloud_coverage, + ) + bndvi_cube = apply_morphology(bndvi_cube, iterations) + + _, gndvi_cube = s2_index_mask( + con=con, + collection_id=DEFAULT_S2_COLLECTION, + bbox=bbox, + time_range=time_range, + index_name="S2_GNDVI", + threshold=gndvi_threshold, + mode="lt", + max_cloud_coverage=max_cloud_coverage, + ) + gndvi_cube = apply_morphology(gndvi_cube, iterations) + + # Select branch in the process graph. + selected = if_( + eq(method, "S2_SCL"), + scl_cube, + if_( + eq(method, "S2_MNDWI"), + mndwi_cube, + if_( + eq(method, "S2_NDVI"), + ndvi_cube, + if_( + eq(method, "S2_BNDVI"), + bndvi_cube, + if_( + eq(method, "S2_GNDVI"), + gndvi_cube, + ndwi_cube, # default fallback + ), + ), + ), + ), ) - return water_land_mask_cube + return selected def generate() -> dict: + """ + Create the UDP for extracting waterlines from Sentinel-2 imagery. + + Workflow: + 1. Load data + 2. Create water/land mask (selectable method) + 3. Apply morphology + 4. Extract waterlines + """ ### 1. Connection conn = openeo.connect(url="openeo.dataspace.copernicus.eu") - ### 2. Define parameters + ### 2. Parameters spatial_extent = Parameter.bounding_box( name="spatial_extent", - description=( - "Bounding box of the area of interest to extract waterlines for. " - "Defined as 'west', 'south', 'east', 'north' keys (EPSG:4326)." - ), + description=("Bounding box of the area of interest. " "Defined as west, south, east, north in EPSG:4326."), ) + temporal_extent = Parameter.temporal_interval( name="temporal_extent", default=["2015-06-23", "2025-12-31"], - description=("Date range over which to extract waterlines. "), + description="Date range over which to extract waterlines.", + ) + + max_cloud_coverage = Parameter.number( + name="max_cloud_coverage", + default=DEFAULT_MAX_CLOUD_COVER, + description=("Maximum allowed cloud coverage.") ) - # TODO: How user can set these? method = Parameter.string( name="s2_method", default="S2_NDWI", values=["S2_NDWI", "S2_MNDWI", "S2_SCL", "S2_NDVI", "S2_BNDVI", "S2_GNDVI"], - description=( - "Method name to create water/land mask from Sentinel-2 imagery." - "Water/land mask is an immediate step to extract waterlines." - ), + description="Method used to create the water/land mask from Sentinel-2 imagery.", ) - # threshold = Parameter.number(name="threshold", default=None) iterations = Parameter.integer( name="iterations", default=2, - description=( - "Number of iterations for morphological operations on water/land raster." - "Morphological operations help to remove small objects and holes as " - "well as bridges and estuaries leaving more proper mask for coastline " - "waterlines extraction" - ), + description="Number of iterations for morphological operations.", + ) + + # Separate threshold params are easiest in a single UDP. + ndwi_threshold = Parameter.number( + name="ndwi_threshold", + default=WATERLAND_THRESHOLDS["S2_NDWI"].defaults["threshold"], + description=WATERLAND_THRESHOLDS["S2_NDWI"].description, + ) + mndwi_threshold = Parameter.number( + name="mndwi_threshold", + default=WATERLAND_THRESHOLDS["S2_MNDWI"].defaults["threshold"], + description=WATERLAND_THRESHOLDS["S2_MNDWI"].description, + ) + ndvi_threshold = Parameter.number( + name="ndvi_threshold", + default=WATERLAND_THRESHOLDS["S2_NDVI"].defaults["threshold"], + description=WATERLAND_THRESHOLDS["S2_NDVI"].description, + ) + bndvi_threshold = Parameter.number( + name="bndvi_threshold", + default=WATERLAND_THRESHOLDS["S2_BNDVI"].defaults["threshold"], + description=WATERLAND_THRESHOLDS["S2_BNDVI"].description, + ) + gndvi_threshold = Parameter.number( + name="gndvi_threshold", + default=WATERLAND_THRESHOLDS["S2_GNDVI"].defaults["threshold"], + description=WATERLAND_THRESHOLDS["S2_GNDVI"].description, ) - ### 3. Generate water/land mask from Sentinel-2 using selected method. water_land_mask = build_water_land_mask_cube( con=conn, bbox=spatial_extent, time_range=temporal_extent, - method="S2_NDWI", # this doesn't work with Parameter - threshold=None, + max_cloud_coverage=max_cloud_coverage, + method=method, iterations=iterations, + ndwi_threshold=ndwi_threshold, + mndwi_threshold=mndwi_threshold, + ndvi_threshold=ndvi_threshold, + bndvi_threshold=bndvi_threshold, + gndvi_threshold=gndvi_threshold, ) - ### 4. Extract waterlines from water/land mask using UDF. - udf = UDF.from_file( - Path(__file__).parent / "udf_waterlines_from_water_land_mask.py", - context={"from_parameter": "context"}, - ) - waterlines_cube = water_land_mask.apply_dimension( - process=udf, dimension="t", context={"crs": "EPSG:3857", "time_dim": "t"} - ) + waterlines_cube = create_waterlines(water_land_mask) return build_process_dict( process_graph=waterlines_cube, @@ -130,7 +266,14 @@ def generate() -> dict: parameters=[ spatial_extent, temporal_extent, + max_cloud_coverage, + method, iterations, + ndwi_threshold, + mndwi_threshold, + ndvi_threshold, + bndvi_threshold, + gndvi_threshold, ], categories=["sentinel-2", "coastline", "waterlines"], ) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py b/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py index 981741d0b..f8596964b 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py @@ -10,6 +10,7 @@ from openeo.rest.connection import Connection from openeo.rest.datacube import DataCube +from openeo.processes import gt, lt # region defaults @@ -35,9 +36,7 @@ class ThresholdMode(str, Enum): class ThresholdSpec: """Default threshold(s) and help text for UI/CLI usage.""" - defaults: Mapping[ - str, Optional[float] - ] # keys are method-arg names, e.g. {"threshold": 0.1} + defaults: Mapping[str, Optional[float]] # keys are method-arg names, e.g. {"threshold": 0.1} mode: ThresholdMode description: str @@ -103,17 +102,6 @@ def to_openeo(self) -> dict[str, float]: } -class Reducer(str, Enum): - """Reducer method for cubes' temporal composites.""" - - MEDIAN = "median" - MEAN = "mean" - MIN = "min" - MAX = "max" - NONE = "none" - SUM = "sum" - - _NORMDIFF_S2: dict[str, tuple[str, str]] = { # index_name: (band_pos, band_neg) used in (pos - neg) / (pos + neg) "ndwi": ("B03", "B08"), @@ -179,38 +167,6 @@ def load_collection( return cube -def maybe_reduce_time(cube: DataCube, reducer: Reducer) -> DataCube: - """Optionally reduce a cube over the time dimension using the given reducer.""" - if reducer == Reducer.NONE: - return cube - return cube.reduce_dimension(dimension="t", reducer=reducer.value) - - -def load_reduce( - con: Connection, - collection_id: str, - bbox: SpatialExtent, - time_range: list[str], - bands: list[str], - reducer: Reducer, - target_epsg: int | None = None, - resolution: float | tuple[float, float] | None = None, - method: str = "near", -) -> DataCube: - """Load a collection and optionally reduce it over time.""" - cube = load_collection( - con, - collection_id, - bbox, - time_range, - bands, - target_epsg=target_epsg, - resolution=resolution, - method=method, - ) - return maybe_reduce_time(cube, reducer) - - def s2_clear_mask_from_scl(cube: DataCube) -> DataCube: """Create a boolean clear-pixel mask from the Sentinel-2 SCL band.""" scl = cube.band("SCL") @@ -224,7 +180,6 @@ def load_s2( bbox: SpatialExtent, time_range: list[str], bands: list[str], - reducer: Reducer, max_cloud_coverage: float | None = DEFAULT_MAX_CLOUD_COVER, target_epsg: int | None = DEFAULT_TARGET_EPSG, grid_ids: list[str] | None = None, @@ -251,10 +206,6 @@ def load_s2( # Remove SCL band cube = cube.filter_bands(bands) - # Reduce now if specified - cube = maybe_reduce_time(cube, reducer) - clear = maybe_reduce_time(clear, reducer) - return cube, clear @@ -263,9 +214,7 @@ def load_s2( def _bin(cube: DataCube) -> DataCube: """Convert a boolean condition cube to a 0/1 cube using an openEO `if` process.""" - return cube.apply( - lambda x: x.process("if", arguments={"value": x, "accept": 1, "reject": 0}) - ) + return cube.apply(lambda x: x.process("if", arguments={"value": x, "accept": 1, "reject": 0})) # endregion @@ -277,7 +226,7 @@ def s2_scl( collection_id: str, bbox: SpatialExtent, time_range: list[str], - reducer: Reducer, + max_cloud_coverage: float, ) -> tuple[DataCube, DataCube]: """Load Sentinel-2 SCL and return a boolean mask selecting SCL class 6. @@ -286,13 +235,12 @@ def s2_scl( collection_id: Sentinel-2 collection identifier to load. bbox: Spatial extent (bounding box) to load. time_range: Temporal extent as `[start, end]`. - reducer: Temporal reducer to apply (e.g. median/mean) or `Reducer.none`. Returns: Sentinel-2 cube and Boolean cube where pixels equal to SCL class 6 are True. """ - s2_cube = load_reduce(con, collection_id, bbox, time_range, ["SCL"], reducer) + s2_cube, _ = load_s2(con, collection_id, bbox, time_range, bands=["SCL"], max_cloud_coverage=max_cloud_coverage) scl_water_mask = s2_cube.band("SCL") == 6 return s2_cube, scl_water_mask @@ -302,7 +250,6 @@ def s2_index_mask( collection_id: str, bbox: SpatialExtent, time_range: list[str], - reducer: Reducer, index_name: str, threshold: float | None, mode: str = "gt", @@ -316,7 +263,6 @@ def s2_index_mask( collection_id: Sentinel-2 collection identifier to load. bbox: Spatial extent (bounding box) to load. time_range: Temporal extent as `[start, end]`. - reducer: Temporal reducer to apply (e.g. median/mean) or `Reducer.none`. index_name: Name of the index to compute. Supported values: `ndwi`, `mndwi`, `ndvi`, `bndvi`, `gndvi`. threshold: Threshold applied to the index. @@ -336,9 +282,7 @@ def s2_index_mask( """ key = index_name.lower().split("_")[1] if key not in _NORMDIFF_S2: - raise ValueError( - f"Unsupported index_name={index_name!r}. Supported: {sorted(_NORMDIFF_S2)}" - ) + raise ValueError(f"Unsupported index_name={index_name!r}. Supported: {sorted(_NORMDIFF_S2)}") band_pos, band_neg = _NORMDIFF_S2[key] s2_cube, clear = load_s2( @@ -347,7 +291,6 @@ def s2_index_mask( bbox, time_range, [band_pos, band_neg], - reducer, max_cloud_coverage, grid_ids=grid_ids, ) @@ -359,11 +302,13 @@ def s2_index_mask( idx = idx.mask(clear) if mode == "gt": - return s2_cube, _bin(idx > threshold) - if mode == "lt": - return s2_cube, _bin(idx < threshold) + mask = gt(idx, threshold) + elif mode == "lt": + mask = lt(idx, threshold) + else: + raise ValueError(f"Unsupported mode: {mode}") - raise ValueError(f"Unsupported mode={mode!r}. Use 'gt' or 'lt'.") + return s2_cube, _bin(mask) # endregion From e91d1d5a6ce03282a83f291e944b0ff08791d9a0 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 8 Apr 2026 15:43:04 +0100 Subject: [PATCH 08/55] better docs --- algorithm_catalog/argans/waterlines/openeo_udp/README.md | 2 +- .../argans/waterlines/openeo_udp/generate.py | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/README.md b/algorithm_catalog/argans/waterlines/openeo_udp/README.md index 42d228110..b40ec8726 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/README.md +++ b/algorithm_catalog/argans/waterlines/openeo_udp/README.md @@ -1,3 +1,3 @@ # Waterlines extraction from Sentinel-2 -Extract waterlines from timeseries of Sentinel-2 imagery. +Extracts waterlines from Sentinel-2 imagery using a selectable water/land classification method, morphological cleaning, and UDF-based vectorisation. diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index 8d4e359fd..df60a6dfc 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -181,10 +181,10 @@ def generate() -> dict: 4. Extract waterlines """ - ### 1. Connection + ### 1. Create backend connection conn = openeo.connect(url="openeo.dataspace.copernicus.eu") - ### 2. Parameters + ### 2. Define UDP input parameters spatial_extent = Parameter.bounding_box( name="spatial_extent", description=("Bounding box of the area of interest. " "Defined as west, south, east, north in EPSG:4326."), @@ -215,7 +215,7 @@ def generate() -> dict: description="Number of iterations for morphological operations.", ) - # Separate threshold params are easiest in a single UDP. + ### 3. Define threshold parameters ndwi_threshold = Parameter.number( name="ndwi_threshold", default=WATERLAND_THRESHOLDS["S2_NDWI"].defaults["threshold"], @@ -242,6 +242,7 @@ def generate() -> dict: description=WATERLAND_THRESHOLDS["S2_GNDVI"].description, ) + ### 4. Build the water/land mask graph water_land_mask = build_water_land_mask_cube( con=conn, bbox=spatial_extent, @@ -256,6 +257,7 @@ def generate() -> dict: gndvi_threshold=gndvi_threshold, ) + ### 5. Extract waterlines from the cleaned water/land mask waterlines_cube = create_waterlines(water_land_mask) return build_process_dict( From 431bbf9c67edecca6adfd23ec0a8e00924dee01c Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 8 Apr 2026 15:58:39 +0100 Subject: [PATCH 09/55] added benchmark scenario --- .../waterlines_s2_ndwi.json | 34 + .../argans/waterlines/openeo_udp/generate.py | 4 +- .../argans/waterlines/openeo_udp/s2_index.py | 27 +- .../waterlines/openeo_udp/waterlines.json | 1934 +++++++++++++++++ 4 files changed, 1974 insertions(+), 25 deletions(-) create mode 100644 algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json create mode 100644 algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json diff --git a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json new file mode 100644 index 000000000..25a5cf55b --- /dev/null +++ b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json @@ -0,0 +1,34 @@ +[ + { + "id": "waterlines_s2_ndwi", + "type": "openeo", + "backend": "openeofed.dataspace.copernicus.eu", + "process_graph": { + "waterlines1": { + "process_id": "waterlines", + "namespace": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/main/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json", + "arguments": { + "temporal_extent": ["2024-06-01", "2024-06-30"], + "spatial_extent": { + "west": -95.13, + "south": 29.078, + "east": -95.12, + "north": 29.082, + "crs": "EPSG:4326" + }, + "s2_method": "S2_NDWI", + "iterations": 2, + "max_cloud_coverage": 5, + "ndwi_threshold": 0.01, + "mndwi_threshold": 0.1, + "ndvi_threshold": 0.03, + "bndvi_threshold": 0.03, + "gndvi_threshold": 0.03 + }, + "result": true + } + }, + "reference_data": { + } + } +] \ No newline at end of file diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index df60a6dfc..baf567577 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -1,5 +1,4 @@ import json -import sys from pathlib import Path import openeo @@ -282,4 +281,5 @@ def generate() -> dict: if __name__ == "__main__": - json.dump(generate(), sys.stdout, indent=2) + with open(Path(__file__).parent / "waterlines.json", "w") as f: + json.dump(generate(), f, indent=2) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py b/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py index f8596964b..2b923a2d3 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py @@ -83,25 +83,6 @@ class ThresholdSpec: # endregion -@dataclass(frozen=True) -class SpatialExtent: - """Spatial extent.""" - - west: float - south: float - east: float - north: float - - def to_openeo(self) -> dict[str, float]: - """Converts spatial extent to openeo format.""" - return { - "west": self.west, - "south": self.south, - "east": self.east, - "north": self.north, - } - - _NORMDIFF_S2: dict[str, tuple[str, str]] = { # index_name: (band_pos, band_neg) used in (pos - neg) / (pos + neg) "ndwi": ("B03", "B08"), @@ -115,7 +96,7 @@ def to_openeo(self) -> dict[str, float]: def load_collection( con: Connection, collection_id: str, - bbox: SpatialExtent, + bbox: dict, time_range: list[str] | None, bands: list[str] | None = None, max_cloud_cover: float | None = None, @@ -177,7 +158,7 @@ def s2_clear_mask_from_scl(cube: DataCube) -> DataCube: def load_s2( con: Connection, collection_id: str, - bbox: SpatialExtent, + bbox: dict, time_range: list[str], bands: list[str], max_cloud_coverage: float | None = DEFAULT_MAX_CLOUD_COVER, @@ -224,7 +205,7 @@ def _bin(cube: DataCube) -> DataCube: def s2_scl( con: Connection, collection_id: str, - bbox: SpatialExtent, + bbox: dict, time_range: list[str], max_cloud_coverage: float, ) -> tuple[DataCube, DataCube]: @@ -248,7 +229,7 @@ def s2_scl( def s2_index_mask( con: Connection, collection_id: str, - bbox: SpatialExtent, + bbox: dict, time_range: list[str], index_name: str, threshold: float | None, diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json new file mode 100644 index 000000000..d1b4b4694 --- /dev/null +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -0,0 +1,1934 @@ +{ + "process_graph": { + "loadcollection1": { + "process_id": "load_collection", + "arguments": { + "bands": [ + "SCL" + ], + "id": "SENTINEL2_L2A", + "properties": { + "eo:cloud_cover": { + "process_graph": { + "lte1": { + "process_id": "lte", + "arguments": { + "x": { + "from_parameter": "value" + }, + "y": { + "from_parameter": "max_cloud_coverage" + } + }, + "result": true + } + } + } + }, + "spatial_extent": { + "from_parameter": "spatial_extent" + }, + "temporal_extent": { + "from_parameter": "temporal_extent" + } + } + }, + "resamplespatial1": { + "process_id": "resample_spatial", + "arguments": { + "data": { + "from_node": "loadcollection1" + }, + "method": "near", + "projection": 3857 + } + }, + "reducedimension1": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "resamplespatial1" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement1": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 0 + } + }, + "eq1": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement1" + }, + "y": -997 + } + }, + "eq2": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement1" + }, + "y": -992 + } + }, + "or1": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "eq1" + }, + "y": { + "from_node": "eq2" + } + } + }, + "eq3": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement1" + }, + "y": -991 + } + }, + "or2": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "or1" + }, + "y": { + "from_node": "eq3" + } + }, + "result": true + } + } + } + } + }, + "mask1": { + "process_id": "mask", + "arguments": { + "data": { + "from_node": "resamplespatial1" + }, + "mask": { + "from_node": "reducedimension1" + } + } + }, + "filterbands1": { + "process_id": "filter_bands", + "arguments": { + "bands": [ + "SCL" + ], + "data": { + "from_node": "mask1" + } + } + }, + "reducedimension2": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "filterbands1" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement2": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 0 + } + }, + "eq4": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement2" + }, + "y": 6 + }, + "result": true + } + } + } + } + }, + "applydimension1": { + "process_id": "apply_dimension", + "arguments": { + "context": { + "iterations": { + "from_parameter": "iterations" + } + }, + "data": { + "from_node": "reducedimension2" + }, + "dimension": "t", + "process": { + "process_graph": { + "runudf1": { + "process_id": "run_udf", + "arguments": { + "context": { + "from_parameter": "context" + }, + "data": { + "from_parameter": "data" + }, + "runtime": "Python", + "udf": "# /// script\n# dependencies = [\n# \"scikit-image\",\n# \"scipy\",\n# ]\n# ///\n\nfrom openeo.udf import XarrayDataCube, inspect\nimport numpy as np\nimport xarray as xr\nfrom scipy.ndimage import binary_fill_holes, binary_opening\n\nDEFAULT_WATER_VALUE = 1\n\n\ndef _build_coastal_water_mask(\n arr: np.ndarray,\n water_value: int = DEFAULT_WATER_VALUE,\n nodata: float | None = 999,\n iterations: int = 1,\n) -> np.ndarray:\n \"\"\"\n Build coastal-water-only mask from land/water mask.\n Inland water is filled.\n\n Returns:\n 0/1 mask where 1 is coastal water and 0 is land\n \"\"\"\n water = arr == water_value\n if nodata is not None:\n water = water & (arr != nodata)\n\n # Remove small estuaries\n water = binary_opening(water, iterations=iterations)\n\n # Remove bridges\n land = ~water\n land = binary_opening(land, iterations=iterations)\n\n land_filled = binary_fill_holes(land)\n water_filled = ~land_filled\n return water_filled.astype(np.uint8)\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"Apply morphological algorithms on DataCube\"\"\"\n\n cube_array: xr.DataArray = cube.get_array()\n inspect(data=[cube_array.shape], message=\"Input UDF cube_array shape\")\n\n cube_array = cube_array.astype(np.uint8)\n\n cube_array_3d = cube_array.squeeze(dim=\"bands\")\n\n modified = xr.apply_ufunc(\n _build_coastal_water_mask,\n cube_array_3d,\n input_core_dims=[[\"y\", \"x\"]],\n output_core_dims=[[\"y\", \"x\"]],\n vectorize=True,\n dask=\"parallelized\",\n output_dtypes=[np.uint8],\n kwargs={\n \"water_value\": DEFAULT_WATER_VALUE,\n \"nodata\": 999,\n \"iterations\": context[\"iterations\"],\n },\n )\n\n modified_da = xr.DataArray(\n modified,\n coords={\n \"t\": cube_array.coords[\"t\"],\n \"y\": cube_array.coords[\"y\"],\n \"x\": cube_array.coords[\"x\"],\n },\n dims=[\"t\", \"y\", \"x\"],\n )\n modified_da = modified_da.expand_dims(dim={\"bands\": cube_array.coords[\"bands\"]})\n modified_da = modified_da.transpose(\"t\", \"bands\", \"y\", \"x\")\n\n return XarrayDataCube(modified_da)\n" + }, + "result": true + } + } + } + } + }, + "loadcollection2": { + "process_id": "load_collection", + "arguments": { + "bands": [ + "B03", + "B11", + "SCL" + ], + "id": "SENTINEL2_L2A", + "properties": { + "eo:cloud_cover": { + "process_graph": { + "lte2": { + "process_id": "lte", + "arguments": { + "x": { + "from_parameter": "value" + }, + "y": { + "from_parameter": "max_cloud_coverage" + } + }, + "result": true + } + } + } + }, + "spatial_extent": { + "from_parameter": "spatial_extent" + }, + "temporal_extent": { + "from_parameter": "temporal_extent" + } + } + }, + "resamplespatial2": { + "process_id": "resample_spatial", + "arguments": { + "data": { + "from_node": "loadcollection2" + }, + "method": "near", + "projection": 3857 + } + }, + "reducedimension3": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "resamplespatial2" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement3": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 2 + } + }, + "eq5": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement3" + }, + "y": -997 + } + }, + "eq6": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement3" + }, + "y": -992 + } + }, + "or3": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "eq5" + }, + "y": { + "from_node": "eq6" + } + } + }, + "eq7": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement3" + }, + "y": -991 + } + }, + "or4": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "or3" + }, + "y": { + "from_node": "eq7" + } + }, + "result": true + } + } + } + } + }, + "mask2": { + "process_id": "mask", + "arguments": { + "data": { + "from_node": "resamplespatial2" + }, + "mask": { + "from_node": "reducedimension3" + } + } + }, + "filterbands2": { + "process_id": "filter_bands", + "arguments": { + "bands": [ + "B03", + "B11" + ], + "data": { + "from_node": "mask2" + } + } + }, + "reducedimension4": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "filterbands2" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement4": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 0 + } + }, + "arrayelement5": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 1 + } + }, + "subtract1": { + "process_id": "subtract", + "arguments": { + "x": { + "from_node": "arrayelement4" + }, + "y": { + "from_node": "arrayelement5" + } + } + }, + "add1": { + "process_id": "add", + "arguments": { + "x": { + "from_node": "arrayelement4" + }, + "y": { + "from_node": "arrayelement5" + } + } + }, + "divide1": { + "process_id": "divide", + "arguments": { + "x": { + "from_node": "subtract1" + }, + "y": { + "from_node": "add1" + } + }, + "result": true + } + } + } + } + }, + "mask3": { + "process_id": "mask", + "arguments": { + "data": { + "from_node": "reducedimension4" + }, + "mask": { + "from_node": "reducedimension3" + } + } + }, + "gt1": { + "process_id": "gt", + "arguments": { + "x": { + "from_node": "mask3" + }, + "y": { + "from_parameter": "mndwi_threshold" + } + } + }, + "apply1": { + "process_id": "apply", + "arguments": { + "data": { + "from_node": "gt1" + }, + "process": { + "process_graph": { + "if1": { + "process_id": "if", + "arguments": { + "accept": 1, + "reject": 0, + "value": { + "from_parameter": "x" + } + }, + "result": true + } + } + } + } + }, + "applydimension2": { + "process_id": "apply_dimension", + "arguments": { + "context": { + "iterations": { + "from_parameter": "iterations" + } + }, + "data": { + "from_node": "apply1" + }, + "dimension": "t", + "process": { + "process_graph": { + "runudf2": { + "process_id": "run_udf", + "arguments": { + "context": { + "from_parameter": "context" + }, + "data": { + "from_parameter": "data" + }, + "runtime": "Python", + "udf": "# /// script\n# dependencies = [\n# \"scikit-image\",\n# \"scipy\",\n# ]\n# ///\n\nfrom openeo.udf import XarrayDataCube, inspect\nimport numpy as np\nimport xarray as xr\nfrom scipy.ndimage import binary_fill_holes, binary_opening\n\nDEFAULT_WATER_VALUE = 1\n\n\ndef _build_coastal_water_mask(\n arr: np.ndarray,\n water_value: int = DEFAULT_WATER_VALUE,\n nodata: float | None = 999,\n iterations: int = 1,\n) -> np.ndarray:\n \"\"\"\n Build coastal-water-only mask from land/water mask.\n Inland water is filled.\n\n Returns:\n 0/1 mask where 1 is coastal water and 0 is land\n \"\"\"\n water = arr == water_value\n if nodata is not None:\n water = water & (arr != nodata)\n\n # Remove small estuaries\n water = binary_opening(water, iterations=iterations)\n\n # Remove bridges\n land = ~water\n land = binary_opening(land, iterations=iterations)\n\n land_filled = binary_fill_holes(land)\n water_filled = ~land_filled\n return water_filled.astype(np.uint8)\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"Apply morphological algorithms on DataCube\"\"\"\n\n cube_array: xr.DataArray = cube.get_array()\n inspect(data=[cube_array.shape], message=\"Input UDF cube_array shape\")\n\n cube_array = cube_array.astype(np.uint8)\n\n cube_array_3d = cube_array.squeeze(dim=\"bands\")\n\n modified = xr.apply_ufunc(\n _build_coastal_water_mask,\n cube_array_3d,\n input_core_dims=[[\"y\", \"x\"]],\n output_core_dims=[[\"y\", \"x\"]],\n vectorize=True,\n dask=\"parallelized\",\n output_dtypes=[np.uint8],\n kwargs={\n \"water_value\": DEFAULT_WATER_VALUE,\n \"nodata\": 999,\n \"iterations\": context[\"iterations\"],\n },\n )\n\n modified_da = xr.DataArray(\n modified,\n coords={\n \"t\": cube_array.coords[\"t\"],\n \"y\": cube_array.coords[\"y\"],\n \"x\": cube_array.coords[\"x\"],\n },\n dims=[\"t\", \"y\", \"x\"],\n )\n modified_da = modified_da.expand_dims(dim={\"bands\": cube_array.coords[\"bands\"]})\n modified_da = modified_da.transpose(\"t\", \"bands\", \"y\", \"x\")\n\n return XarrayDataCube(modified_da)\n" + }, + "result": true + } + } + } + } + }, + "loadcollection3": { + "process_id": "load_collection", + "arguments": { + "bands": [ + "B08", + "B04", + "SCL" + ], + "id": "SENTINEL2_L2A", + "properties": { + "eo:cloud_cover": { + "process_graph": { + "lte3": { + "process_id": "lte", + "arguments": { + "x": { + "from_parameter": "value" + }, + "y": { + "from_parameter": "max_cloud_coverage" + } + }, + "result": true + } + } + } + }, + "spatial_extent": { + "from_parameter": "spatial_extent" + }, + "temporal_extent": { + "from_parameter": "temporal_extent" + } + } + }, + "resamplespatial3": { + "process_id": "resample_spatial", + "arguments": { + "data": { + "from_node": "loadcollection3" + }, + "method": "near", + "projection": 3857 + } + }, + "reducedimension5": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "resamplespatial3" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement6": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 2 + } + }, + "eq8": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement6" + }, + "y": -997 + } + }, + "eq9": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement6" + }, + "y": -992 + } + }, + "or5": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "eq8" + }, + "y": { + "from_node": "eq9" + } + } + }, + "eq10": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement6" + }, + "y": -991 + } + }, + "or6": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "or5" + }, + "y": { + "from_node": "eq10" + } + }, + "result": true + } + } + } + } + }, + "mask4": { + "process_id": "mask", + "arguments": { + "data": { + "from_node": "resamplespatial3" + }, + "mask": { + "from_node": "reducedimension5" + } + } + }, + "filterbands3": { + "process_id": "filter_bands", + "arguments": { + "bands": [ + "B08", + "B04" + ], + "data": { + "from_node": "mask4" + } + } + }, + "reducedimension6": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "filterbands3" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement7": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 0 + } + }, + "arrayelement8": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 1 + } + }, + "subtract2": { + "process_id": "subtract", + "arguments": { + "x": { + "from_node": "arrayelement7" + }, + "y": { + "from_node": "arrayelement8" + } + } + }, + "add2": { + "process_id": "add", + "arguments": { + "x": { + "from_node": "arrayelement7" + }, + "y": { + "from_node": "arrayelement8" + } + } + }, + "divide2": { + "process_id": "divide", + "arguments": { + "x": { + "from_node": "subtract2" + }, + "y": { + "from_node": "add2" + } + }, + "result": true + } + } + } + } + }, + "mask5": { + "process_id": "mask", + "arguments": { + "data": { + "from_node": "reducedimension6" + }, + "mask": { + "from_node": "reducedimension5" + } + } + }, + "lt1": { + "process_id": "lt", + "arguments": { + "x": { + "from_node": "mask5" + }, + "y": { + "from_parameter": "ndvi_threshold" + } + } + }, + "apply2": { + "process_id": "apply", + "arguments": { + "data": { + "from_node": "lt1" + }, + "process": { + "process_graph": { + "if2": { + "process_id": "if", + "arguments": { + "accept": 1, + "reject": 0, + "value": { + "from_parameter": "x" + } + }, + "result": true + } + } + } + } + }, + "applydimension3": { + "process_id": "apply_dimension", + "arguments": { + "context": { + "iterations": { + "from_parameter": "iterations" + } + }, + "data": { + "from_node": "apply2" + }, + "dimension": "t", + "process": { + "process_graph": { + "runudf3": { + "process_id": "run_udf", + "arguments": { + "context": { + "from_parameter": "context" + }, + "data": { + "from_parameter": "data" + }, + "runtime": "Python", + "udf": "# /// script\n# dependencies = [\n# \"scikit-image\",\n# \"scipy\",\n# ]\n# ///\n\nfrom openeo.udf import XarrayDataCube, inspect\nimport numpy as np\nimport xarray as xr\nfrom scipy.ndimage import binary_fill_holes, binary_opening\n\nDEFAULT_WATER_VALUE = 1\n\n\ndef _build_coastal_water_mask(\n arr: np.ndarray,\n water_value: int = DEFAULT_WATER_VALUE,\n nodata: float | None = 999,\n iterations: int = 1,\n) -> np.ndarray:\n \"\"\"\n Build coastal-water-only mask from land/water mask.\n Inland water is filled.\n\n Returns:\n 0/1 mask where 1 is coastal water and 0 is land\n \"\"\"\n water = arr == water_value\n if nodata is not None:\n water = water & (arr != nodata)\n\n # Remove small estuaries\n water = binary_opening(water, iterations=iterations)\n\n # Remove bridges\n land = ~water\n land = binary_opening(land, iterations=iterations)\n\n land_filled = binary_fill_holes(land)\n water_filled = ~land_filled\n return water_filled.astype(np.uint8)\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"Apply morphological algorithms on DataCube\"\"\"\n\n cube_array: xr.DataArray = cube.get_array()\n inspect(data=[cube_array.shape], message=\"Input UDF cube_array shape\")\n\n cube_array = cube_array.astype(np.uint8)\n\n cube_array_3d = cube_array.squeeze(dim=\"bands\")\n\n modified = xr.apply_ufunc(\n _build_coastal_water_mask,\n cube_array_3d,\n input_core_dims=[[\"y\", \"x\"]],\n output_core_dims=[[\"y\", \"x\"]],\n vectorize=True,\n dask=\"parallelized\",\n output_dtypes=[np.uint8],\n kwargs={\n \"water_value\": DEFAULT_WATER_VALUE,\n \"nodata\": 999,\n \"iterations\": context[\"iterations\"],\n },\n )\n\n modified_da = xr.DataArray(\n modified,\n coords={\n \"t\": cube_array.coords[\"t\"],\n \"y\": cube_array.coords[\"y\"],\n \"x\": cube_array.coords[\"x\"],\n },\n dims=[\"t\", \"y\", \"x\"],\n )\n modified_da = modified_da.expand_dims(dim={\"bands\": cube_array.coords[\"bands\"]})\n modified_da = modified_da.transpose(\"t\", \"bands\", \"y\", \"x\")\n\n return XarrayDataCube(modified_da)\n" + }, + "result": true + } + } + } + } + }, + "loadcollection4": { + "process_id": "load_collection", + "arguments": { + "bands": [ + "B08", + "B02", + "SCL" + ], + "id": "SENTINEL2_L2A", + "properties": { + "eo:cloud_cover": { + "process_graph": { + "lte4": { + "process_id": "lte", + "arguments": { + "x": { + "from_parameter": "value" + }, + "y": { + "from_parameter": "max_cloud_coverage" + } + }, + "result": true + } + } + } + }, + "spatial_extent": { + "from_parameter": "spatial_extent" + }, + "temporal_extent": { + "from_parameter": "temporal_extent" + } + } + }, + "resamplespatial4": { + "process_id": "resample_spatial", + "arguments": { + "data": { + "from_node": "loadcollection4" + }, + "method": "near", + "projection": 3857 + } + }, + "reducedimension7": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "resamplespatial4" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement9": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 2 + } + }, + "eq11": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement9" + }, + "y": -997 + } + }, + "eq12": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement9" + }, + "y": -992 + } + }, + "or7": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "eq11" + }, + "y": { + "from_node": "eq12" + } + } + }, + "eq13": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement9" + }, + "y": -991 + } + }, + "or8": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "or7" + }, + "y": { + "from_node": "eq13" + } + }, + "result": true + } + } + } + } + }, + "mask6": { + "process_id": "mask", + "arguments": { + "data": { + "from_node": "resamplespatial4" + }, + "mask": { + "from_node": "reducedimension7" + } + } + }, + "filterbands4": { + "process_id": "filter_bands", + "arguments": { + "bands": [ + "B08", + "B02" + ], + "data": { + "from_node": "mask6" + } + } + }, + "reducedimension8": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "filterbands4" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement10": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 0 + } + }, + "arrayelement11": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 1 + } + }, + "subtract3": { + "process_id": "subtract", + "arguments": { + "x": { + "from_node": "arrayelement10" + }, + "y": { + "from_node": "arrayelement11" + } + } + }, + "add3": { + "process_id": "add", + "arguments": { + "x": { + "from_node": "arrayelement10" + }, + "y": { + "from_node": "arrayelement11" + } + } + }, + "divide3": { + "process_id": "divide", + "arguments": { + "x": { + "from_node": "subtract3" + }, + "y": { + "from_node": "add3" + } + }, + "result": true + } + } + } + } + }, + "mask7": { + "process_id": "mask", + "arguments": { + "data": { + "from_node": "reducedimension8" + }, + "mask": { + "from_node": "reducedimension7" + } + } + }, + "lt2": { + "process_id": "lt", + "arguments": { + "x": { + "from_node": "mask7" + }, + "y": { + "from_parameter": "bndvi_threshold" + } + } + }, + "apply3": { + "process_id": "apply", + "arguments": { + "data": { + "from_node": "lt2" + }, + "process": { + "process_graph": { + "if3": { + "process_id": "if", + "arguments": { + "accept": 1, + "reject": 0, + "value": { + "from_parameter": "x" + } + }, + "result": true + } + } + } + } + }, + "applydimension4": { + "process_id": "apply_dimension", + "arguments": { + "context": { + "iterations": { + "from_parameter": "iterations" + } + }, + "data": { + "from_node": "apply3" + }, + "dimension": "t", + "process": { + "process_graph": { + "runudf4": { + "process_id": "run_udf", + "arguments": { + "context": { + "from_parameter": "context" + }, + "data": { + "from_parameter": "data" + }, + "runtime": "Python", + "udf": "# /// script\n# dependencies = [\n# \"scikit-image\",\n# \"scipy\",\n# ]\n# ///\n\nfrom openeo.udf import XarrayDataCube, inspect\nimport numpy as np\nimport xarray as xr\nfrom scipy.ndimage import binary_fill_holes, binary_opening\n\nDEFAULT_WATER_VALUE = 1\n\n\ndef _build_coastal_water_mask(\n arr: np.ndarray,\n water_value: int = DEFAULT_WATER_VALUE,\n nodata: float | None = 999,\n iterations: int = 1,\n) -> np.ndarray:\n \"\"\"\n Build coastal-water-only mask from land/water mask.\n Inland water is filled.\n\n Returns:\n 0/1 mask where 1 is coastal water and 0 is land\n \"\"\"\n water = arr == water_value\n if nodata is not None:\n water = water & (arr != nodata)\n\n # Remove small estuaries\n water = binary_opening(water, iterations=iterations)\n\n # Remove bridges\n land = ~water\n land = binary_opening(land, iterations=iterations)\n\n land_filled = binary_fill_holes(land)\n water_filled = ~land_filled\n return water_filled.astype(np.uint8)\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"Apply morphological algorithms on DataCube\"\"\"\n\n cube_array: xr.DataArray = cube.get_array()\n inspect(data=[cube_array.shape], message=\"Input UDF cube_array shape\")\n\n cube_array = cube_array.astype(np.uint8)\n\n cube_array_3d = cube_array.squeeze(dim=\"bands\")\n\n modified = xr.apply_ufunc(\n _build_coastal_water_mask,\n cube_array_3d,\n input_core_dims=[[\"y\", \"x\"]],\n output_core_dims=[[\"y\", \"x\"]],\n vectorize=True,\n dask=\"parallelized\",\n output_dtypes=[np.uint8],\n kwargs={\n \"water_value\": DEFAULT_WATER_VALUE,\n \"nodata\": 999,\n \"iterations\": context[\"iterations\"],\n },\n )\n\n modified_da = xr.DataArray(\n modified,\n coords={\n \"t\": cube_array.coords[\"t\"],\n \"y\": cube_array.coords[\"y\"],\n \"x\": cube_array.coords[\"x\"],\n },\n dims=[\"t\", \"y\", \"x\"],\n )\n modified_da = modified_da.expand_dims(dim={\"bands\": cube_array.coords[\"bands\"]})\n modified_da = modified_da.transpose(\"t\", \"bands\", \"y\", \"x\")\n\n return XarrayDataCube(modified_da)\n" + }, + "result": true + } + } + } + } + }, + "loadcollection5": { + "process_id": "load_collection", + "arguments": { + "bands": [ + "B08", + "B03", + "SCL" + ], + "id": "SENTINEL2_L2A", + "properties": { + "eo:cloud_cover": { + "process_graph": { + "lte5": { + "process_id": "lte", + "arguments": { + "x": { + "from_parameter": "value" + }, + "y": { + "from_parameter": "max_cloud_coverage" + } + }, + "result": true + } + } + } + }, + "spatial_extent": { + "from_parameter": "spatial_extent" + }, + "temporal_extent": { + "from_parameter": "temporal_extent" + } + } + }, + "resamplespatial5": { + "process_id": "resample_spatial", + "arguments": { + "data": { + "from_node": "loadcollection5" + }, + "method": "near", + "projection": 3857 + } + }, + "reducedimension9": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "resamplespatial5" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement12": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 2 + } + }, + "eq14": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement12" + }, + "y": -997 + } + }, + "eq15": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement12" + }, + "y": -992 + } + }, + "or9": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "eq14" + }, + "y": { + "from_node": "eq15" + } + } + }, + "eq16": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement12" + }, + "y": -991 + } + }, + "or10": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "or9" + }, + "y": { + "from_node": "eq16" + } + }, + "result": true + } + } + } + } + }, + "mask8": { + "process_id": "mask", + "arguments": { + "data": { + "from_node": "resamplespatial5" + }, + "mask": { + "from_node": "reducedimension9" + } + } + }, + "filterbands5": { + "process_id": "filter_bands", + "arguments": { + "bands": [ + "B08", + "B03" + ], + "data": { + "from_node": "mask8" + } + } + }, + "reducedimension10": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "filterbands5" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement13": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 0 + } + }, + "arrayelement14": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 1 + } + }, + "subtract4": { + "process_id": "subtract", + "arguments": { + "x": { + "from_node": "arrayelement13" + }, + "y": { + "from_node": "arrayelement14" + } + } + }, + "add4": { + "process_id": "add", + "arguments": { + "x": { + "from_node": "arrayelement13" + }, + "y": { + "from_node": "arrayelement14" + } + } + }, + "divide4": { + "process_id": "divide", + "arguments": { + "x": { + "from_node": "subtract4" + }, + "y": { + "from_node": "add4" + } + }, + "result": true + } + } + } + } + }, + "mask9": { + "process_id": "mask", + "arguments": { + "data": { + "from_node": "reducedimension10" + }, + "mask": { + "from_node": "reducedimension9" + } + } + }, + "lt3": { + "process_id": "lt", + "arguments": { + "x": { + "from_node": "mask9" + }, + "y": { + "from_parameter": "gndvi_threshold" + } + } + }, + "apply4": { + "process_id": "apply", + "arguments": { + "data": { + "from_node": "lt3" + }, + "process": { + "process_graph": { + "if4": { + "process_id": "if", + "arguments": { + "accept": 1, + "reject": 0, + "value": { + "from_parameter": "x" + } + }, + "result": true + } + } + } + } + }, + "applydimension5": { + "process_id": "apply_dimension", + "arguments": { + "context": { + "iterations": { + "from_parameter": "iterations" + } + }, + "data": { + "from_node": "apply4" + }, + "dimension": "t", + "process": { + "process_graph": { + "runudf5": { + "process_id": "run_udf", + "arguments": { + "context": { + "from_parameter": "context" + }, + "data": { + "from_parameter": "data" + }, + "runtime": "Python", + "udf": "# /// script\n# dependencies = [\n# \"scikit-image\",\n# \"scipy\",\n# ]\n# ///\n\nfrom openeo.udf import XarrayDataCube, inspect\nimport numpy as np\nimport xarray as xr\nfrom scipy.ndimage import binary_fill_holes, binary_opening\n\nDEFAULT_WATER_VALUE = 1\n\n\ndef _build_coastal_water_mask(\n arr: np.ndarray,\n water_value: int = DEFAULT_WATER_VALUE,\n nodata: float | None = 999,\n iterations: int = 1,\n) -> np.ndarray:\n \"\"\"\n Build coastal-water-only mask from land/water mask.\n Inland water is filled.\n\n Returns:\n 0/1 mask where 1 is coastal water and 0 is land\n \"\"\"\n water = arr == water_value\n if nodata is not None:\n water = water & (arr != nodata)\n\n # Remove small estuaries\n water = binary_opening(water, iterations=iterations)\n\n # Remove bridges\n land = ~water\n land = binary_opening(land, iterations=iterations)\n\n land_filled = binary_fill_holes(land)\n water_filled = ~land_filled\n return water_filled.astype(np.uint8)\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"Apply morphological algorithms on DataCube\"\"\"\n\n cube_array: xr.DataArray = cube.get_array()\n inspect(data=[cube_array.shape], message=\"Input UDF cube_array shape\")\n\n cube_array = cube_array.astype(np.uint8)\n\n cube_array_3d = cube_array.squeeze(dim=\"bands\")\n\n modified = xr.apply_ufunc(\n _build_coastal_water_mask,\n cube_array_3d,\n input_core_dims=[[\"y\", \"x\"]],\n output_core_dims=[[\"y\", \"x\"]],\n vectorize=True,\n dask=\"parallelized\",\n output_dtypes=[np.uint8],\n kwargs={\n \"water_value\": DEFAULT_WATER_VALUE,\n \"nodata\": 999,\n \"iterations\": context[\"iterations\"],\n },\n )\n\n modified_da = xr.DataArray(\n modified,\n coords={\n \"t\": cube_array.coords[\"t\"],\n \"y\": cube_array.coords[\"y\"],\n \"x\": cube_array.coords[\"x\"],\n },\n dims=[\"t\", \"y\", \"x\"],\n )\n modified_da = modified_da.expand_dims(dim={\"bands\": cube_array.coords[\"bands\"]})\n modified_da = modified_da.transpose(\"t\", \"bands\", \"y\", \"x\")\n\n return XarrayDataCube(modified_da)\n" + }, + "result": true + } + } + } + } + }, + "loadcollection6": { + "process_id": "load_collection", + "arguments": { + "bands": [ + "B03", + "B08", + "SCL" + ], + "id": "SENTINEL2_L2A", + "properties": { + "eo:cloud_cover": { + "process_graph": { + "lte6": { + "process_id": "lte", + "arguments": { + "x": { + "from_parameter": "value" + }, + "y": { + "from_parameter": "max_cloud_coverage" + } + }, + "result": true + } + } + } + }, + "spatial_extent": { + "from_parameter": "spatial_extent" + }, + "temporal_extent": { + "from_parameter": "temporal_extent" + } + } + }, + "resamplespatial6": { + "process_id": "resample_spatial", + "arguments": { + "data": { + "from_node": "loadcollection6" + }, + "method": "near", + "projection": 3857 + } + }, + "reducedimension11": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "resamplespatial6" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement15": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 2 + } + }, + "eq17": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement15" + }, + "y": -997 + } + }, + "eq18": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement15" + }, + "y": -992 + } + }, + "or11": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "eq17" + }, + "y": { + "from_node": "eq18" + } + } + }, + "eq19": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement15" + }, + "y": -991 + } + }, + "or12": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "or11" + }, + "y": { + "from_node": "eq19" + } + }, + "result": true + } + } + } + } + }, + "mask10": { + "process_id": "mask", + "arguments": { + "data": { + "from_node": "resamplespatial6" + }, + "mask": { + "from_node": "reducedimension11" + } + } + }, + "filterbands6": { + "process_id": "filter_bands", + "arguments": { + "bands": [ + "B03", + "B08" + ], + "data": { + "from_node": "mask10" + } + } + }, + "reducedimension12": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "filterbands6" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement16": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 0 + } + }, + "arrayelement17": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 1 + } + }, + "subtract5": { + "process_id": "subtract", + "arguments": { + "x": { + "from_node": "arrayelement16" + }, + "y": { + "from_node": "arrayelement17" + } + } + }, + "add5": { + "process_id": "add", + "arguments": { + "x": { + "from_node": "arrayelement16" + }, + "y": { + "from_node": "arrayelement17" + } + } + }, + "divide5": { + "process_id": "divide", + "arguments": { + "x": { + "from_node": "subtract5" + }, + "y": { + "from_node": "add5" + } + }, + "result": true + } + } + } + } + }, + "mask11": { + "process_id": "mask", + "arguments": { + "data": { + "from_node": "reducedimension12" + }, + "mask": { + "from_node": "reducedimension11" + } + } + }, + "gt2": { + "process_id": "gt", + "arguments": { + "x": { + "from_node": "mask11" + }, + "y": { + "from_parameter": "ndwi_threshold" + } + } + }, + "apply5": { + "process_id": "apply", + "arguments": { + "data": { + "from_node": "gt2" + }, + "process": { + "process_graph": { + "if5": { + "process_id": "if", + "arguments": { + "accept": 1, + "reject": 0, + "value": { + "from_parameter": "x" + } + }, + "result": true + } + } + } + } + }, + "applydimension6": { + "process_id": "apply_dimension", + "arguments": { + "context": { + "iterations": { + "from_parameter": "iterations" + } + }, + "data": { + "from_node": "apply5" + }, + "dimension": "t", + "process": { + "process_graph": { + "runudf6": { + "process_id": "run_udf", + "arguments": { + "context": { + "from_parameter": "context" + }, + "data": { + "from_parameter": "data" + }, + "runtime": "Python", + "udf": "# /// script\n# dependencies = [\n# \"scikit-image\",\n# \"scipy\",\n# ]\n# ///\n\nfrom openeo.udf import XarrayDataCube, inspect\nimport numpy as np\nimport xarray as xr\nfrom scipy.ndimage import binary_fill_holes, binary_opening\n\nDEFAULT_WATER_VALUE = 1\n\n\ndef _build_coastal_water_mask(\n arr: np.ndarray,\n water_value: int = DEFAULT_WATER_VALUE,\n nodata: float | None = 999,\n iterations: int = 1,\n) -> np.ndarray:\n \"\"\"\n Build coastal-water-only mask from land/water mask.\n Inland water is filled.\n\n Returns:\n 0/1 mask where 1 is coastal water and 0 is land\n \"\"\"\n water = arr == water_value\n if nodata is not None:\n water = water & (arr != nodata)\n\n # Remove small estuaries\n water = binary_opening(water, iterations=iterations)\n\n # Remove bridges\n land = ~water\n land = binary_opening(land, iterations=iterations)\n\n land_filled = binary_fill_holes(land)\n water_filled = ~land_filled\n return water_filled.astype(np.uint8)\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"Apply morphological algorithms on DataCube\"\"\"\n\n cube_array: xr.DataArray = cube.get_array()\n inspect(data=[cube_array.shape], message=\"Input UDF cube_array shape\")\n\n cube_array = cube_array.astype(np.uint8)\n\n cube_array_3d = cube_array.squeeze(dim=\"bands\")\n\n modified = xr.apply_ufunc(\n _build_coastal_water_mask,\n cube_array_3d,\n input_core_dims=[[\"y\", \"x\"]],\n output_core_dims=[[\"y\", \"x\"]],\n vectorize=True,\n dask=\"parallelized\",\n output_dtypes=[np.uint8],\n kwargs={\n \"water_value\": DEFAULT_WATER_VALUE,\n \"nodata\": 999,\n \"iterations\": context[\"iterations\"],\n },\n )\n\n modified_da = xr.DataArray(\n modified,\n coords={\n \"t\": cube_array.coords[\"t\"],\n \"y\": cube_array.coords[\"y\"],\n \"x\": cube_array.coords[\"x\"],\n },\n dims=[\"t\", \"y\", \"x\"],\n )\n modified_da = modified_da.expand_dims(dim={\"bands\": cube_array.coords[\"bands\"]})\n modified_da = modified_da.transpose(\"t\", \"bands\", \"y\", \"x\")\n\n return XarrayDataCube(modified_da)\n" + }, + "result": true + } + } + } + } + }, + "eq20": { + "process_id": "eq", + "arguments": { + "x": { + "from_parameter": "s2_method" + }, + "y": "S2_GNDVI" + } + }, + "if6": { + "process_id": "if", + "arguments": { + "accept": { + "from_node": "applydimension5" + }, + "reject": { + "from_node": "applydimension6" + }, + "value": { + "from_node": "eq20" + } + } + }, + "eq21": { + "process_id": "eq", + "arguments": { + "x": { + "from_parameter": "s2_method" + }, + "y": "S2_BNDVI" + } + }, + "if7": { + "process_id": "if", + "arguments": { + "accept": { + "from_node": "applydimension4" + }, + "reject": { + "from_node": "if6" + }, + "value": { + "from_node": "eq21" + } + } + }, + "eq22": { + "process_id": "eq", + "arguments": { + "x": { + "from_parameter": "s2_method" + }, + "y": "S2_NDVI" + } + }, + "if8": { + "process_id": "if", + "arguments": { + "accept": { + "from_node": "applydimension3" + }, + "reject": { + "from_node": "if7" + }, + "value": { + "from_node": "eq22" + } + } + }, + "eq23": { + "process_id": "eq", + "arguments": { + "x": { + "from_parameter": "s2_method" + }, + "y": "S2_MNDWI" + } + }, + "if9": { + "process_id": "if", + "arguments": { + "accept": { + "from_node": "applydimension2" + }, + "reject": { + "from_node": "if8" + }, + "value": { + "from_node": "eq23" + } + } + }, + "eq24": { + "process_id": "eq", + "arguments": { + "x": { + "from_parameter": "s2_method" + }, + "y": "S2_SCL" + } + }, + "if10": { + "process_id": "if", + "arguments": { + "accept": { + "from_node": "applydimension1" + }, + "reject": { + "from_node": "if9" + }, + "value": { + "from_node": "eq24" + } + } + }, + "applydimension7": { + "process_id": "apply_dimension", + "arguments": { + "context": { + "crs": "EPSG:3857", + "time_dim": "t" + }, + "data": { + "from_node": "if10" + }, + "dimension": "t", + "process": { + "process_graph": { + "runudf7": { + "process_id": "run_udf", + "arguments": { + "context": { + "from_parameter": "context" + }, + "data": { + "from_parameter": "data" + }, + "runtime": "Python", + "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n shape,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nimport json\nfrom shapely.ops import unary_union\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(\n geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA\n) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _vectorize_water_polygons(\n water_mask: np.ndarray,\n transform: Affine,\n water_value: int = 1,\n) -> list[Polygon]:\n \"\"\"Create water polygons from a 0/1 coastal-water mask.\"\"\"\n polys: list[Polygon] = []\n mask = water_mask.astype(bool)\n\n for geom, val in shapes(water_mask, mask=mask, transform=transform):\n if int(val) == int(water_value):\n shp = shape(geom)\n shp = _remove_small_interiors(shp)\n polys.append(shp)\n\n return polys\n\n\ndef _remove_extent_intersections(\n waterline: LineString, bounds, buffer: float = 0.0001\n) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(\n water_poly: Polygon, seg: LineString\n) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n water_mask_2d: np.ndarray,\n transform: Affine,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Here water_value is 1 because _build_coastal_water_mask returns 0/1\n water_polys = _vectorize_water_polygons(water_mask_2d, transform, water_value=1)\n if not water_polys:\n return None\n\n water_poly = unary_union(water_polys)\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_land_water_raster(\n da: xr.DataArray,\n crs: str | None = None,\n simplify_tolerance: float | None = None,\n time_dim: str = DEFAULT_TIME_DIM,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a land/water mask raster.\n\n Args:\n da: DataArray containing a land/water mask with a time dimension.\n crs: DataArray projection. If None it will be read from da. However in some\n situations this information might not be stored in da (for example when\n reading dataset from netCDF) so option to provide it is given.\n simplify_tolerance: Optional tolerance for geometry simplification.\n If provided, resulting geometries will be simplified.\n time_dim: Name of the time dimension in the raster dataset.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n \"\"\"\n\n if crs is None:\n if da.rio.crs is not None:\n crs = da.rio.crs\n elif \"crs\" in da.attrs:\n crs = da.attrs[\"crs\"]\n if crs is None:\n raise ValueError(\"CRS needed to perform vectorization.\")\n\n records: list[dict[str, Any]] = []\n\n if time_dim not in da.dims:\n raise KeyError(\n f\"No {time_dim} in input array. Use one of the following: {da.dims}\"\n )\n\n transform = da.rio.transform()\n bounds = da.rio.bounds()\n\n for i in range(da.sizes[time_dim]):\n slice2d = da.isel({time_dim: i})\n tval = slice2d[time_dim].values\n inspect(data=[tval], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n slice2d.values,\n transform=transform,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[tval], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": tval,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\n# def apply_datacube(cube: xr.DataArray, context: dict) -> xr.DataArray:\n# inspect(data=[cube.shape], message=\"Input UDF cube shape\")\n# gdf = waterline_from_land_water_raster(\n# da=cube,\n# crs=context.get(\"crs\"),\n# simplify_tolerance=context.get(\"simplify_tolerance\"),\n# time_dim=context.get(\"time_dim\", \"time\"),\n# )\n# inspect(data=[len(gdf)], message=\"Output gdf len\")\n\n# geojson = json.dumps(gdf.__geo_interface__, default=str)\n# inspect(data=[geojson], message=\"Output geojson\")\n# #return xr.DataArray(geojson)\n# return xr.DataArray.from_series(gdf.geometry)\n\n# def apply_vectorcube(geometries: gpd.geodataframe.GeoDataFrame,\n# cube: xr.DataArray,\n# context: dict) -> tuple[gpd.GeoDataFrame, xr.DataArray]:\n# inspect(data=[geometries], message=\"Input UDF geometries\")\n# inspect(data=[cube.shape], message=\"Input UDF cube shape\")\n# gdf = waterline_from_land_water_raster(\n# da=cube,\n# crs=context.get(\"crs\"),\n# simplify_tolerance=context.get(\"simplify_tolerance\"),\n# time_dim=context.get(\"time_dim\", \"time\"),\n# )\n# inspect(data=[gdf], message=\"Output geojson\")\n\n# return gdf, cube\n\ndef apply_udf_data(data: UdfData) -> UdfData:\n\n inspect(data=[data], message=\"Input UDFData inspection\")\n\n cube = data.get_datacube_list()[0].get_array()\n inspect(data=[list(cube.dims), list(cube.shape)], message=\"Input UDF cube dims/shape\")\n\n gdf = waterline_from_land_water_raster(\n da=cube,\n crs=data.user_context.get(\"crs\"),\n simplify_tolerance=data.user_context.get(\"simplify_tolerance\"),\n time_dim=data.user_context.get(\"time_dim\", \"time\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n feature_collection = FeatureCollection(\n id=DEFAULT_OUT_LAYER,\n data=gdf,\n )\n\n data.set_feature_collection_list([feature_collection])\n\n inspect(data=[data], message=\"Output UDFData inspection\")\n\n return data\n" + }, + "result": true + } + } + } + }, + "result": true + } + }, + "id": "waterlines", + "summary": "Waterlines extracted from Sentinel-2.", + "description": "# Waterlines extraction from Sentinel-2\n\nExtracts waterlines from Sentinel-2 imagery using a selectable water/land classification method, morphological cleaning, and UDF-based vectorisation.\n", + "categories": [ + "sentinel-2", + "coastline", + "waterlines" + ], + "parameters": [ + { + "name": "spatial_extent", + "description": "Bounding box of the area of interest. Defined as west, south, east, north in EPSG:4326.", + "schema": { + "type": "object", + "subtype": "bounding-box", + "required": [ + "west", + "south", + "east", + "north" + ], + "properties": { + "west": { + "type": "number", + "description": "West (lower left corner, coordinate axis 1)." + }, + "south": { + "type": "number", + "description": "South (lower left corner, coordinate axis 2)." + }, + "east": { + "type": "number", + "description": "East (upper right corner, coordinate axis 1)." + }, + "north": { + "type": "number", + "description": "North (upper right corner, coordinate axis 2)." + }, + "crs": { + "description": "Coordinate reference system of the extent, specified as as [EPSG code](http://www.epsg-registry.org/) or [WKT2 CRS string](http://docs.opengeospatial.org/is/18-010r7/18-010r7.html). Defaults to `4326` (EPSG code 4326) unless the client explicitly requests a different coordinate reference system.", + "anyOf": [ + { + "type": "integer", + "subtype": "epsg-code", + "title": "EPSG Code", + "minimum": 1000 + }, + { + "type": "string", + "subtype": "wkt2-definition", + "title": "WKT2 definition" + } + ], + "default": 4326 + } + } + } + }, + { + "name": "temporal_extent", + "description": "Date range over which to extract waterlines.", + "schema": { + "type": "array", + "subtype": "temporal-interval", + "uniqueItems": true, + "minItems": 2, + "maxItems": 2, + "items": { + "anyOf": [ + { + "type": "string", + "subtype": "date-time", + "format": "date-time" + }, + { + "type": "string", + "subtype": "date", + "format": "date" + }, + { + "type": "null" + } + ] + } + }, + "default": [ + "2015-06-23", + "2025-12-31" + ], + "optional": true + }, + { + "name": "max_cloud_coverage", + "description": "Maximum allowed cloud coverage.", + "schema": { + "type": "number" + }, + "default": 10, + "optional": true + }, + { + "name": "s2_method", + "description": "Method used to create the water/land mask from Sentinel-2 imagery.", + "schema": { + "type": "string", + "enum": [ + "S2_NDWI", + "S2_MNDWI", + "S2_SCL", + "S2_NDVI", + "S2_BNDVI", + "S2_GNDVI" + ] + }, + "default": "S2_NDWI", + "optional": true + }, + { + "name": "iterations", + "description": "Number of iterations for morphological operations.", + "schema": { + "type": "integer" + }, + "default": 2, + "optional": true + }, + { + "name": "ndwi_threshold", + "description": "NDWI threshold (water if NDWI > threshold).", + "schema": { + "type": "number" + }, + "default": 0.01, + "optional": true + }, + { + "name": "mndwi_threshold", + "description": "MNDWI threshold (water if MNDWI > threshold).", + "schema": { + "type": "number" + }, + "default": 0.1, + "optional": true + }, + { + "name": "ndvi_threshold", + "description": "NDVI threshold (water if NDVI < threshold).", + "schema": { + "type": "number" + }, + "default": 0.03, + "optional": true + }, + { + "name": "bndvi_threshold", + "description": "BNDVI threshold (water if BNDVI < threshold).", + "schema": { + "type": "number" + }, + "default": 0.03, + "optional": true + }, + { + "name": "gndvi_threshold", + "description": "GNDVI threshold (water if GNDVI < threshold).", + "schema": { + "type": "number" + }, + "default": 0.03, + "optional": true + } + ] +} \ No newline at end of file From 0dc7b24cc644af8e53c061c301c9b3d65e06b8db Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Thu, 9 Apr 2026 07:48:55 +0100 Subject: [PATCH 10/55] removed commented out code --- .../udf_waterlines_from_water_land_mask.py | 48 ++----------------- 1 file changed, 5 insertions(+), 43 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index 8cc8a140d..2843e203e 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -69,9 +69,7 @@ def split_into_segments(geom: GeometryLike) -> list[LineString]: return segments -def _remove_small_interiors( - geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA -) -> Polygon: +def _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon: """Remove small interior rings from polygon.""" if geom.is_empty: return geom @@ -102,9 +100,7 @@ def _vectorize_water_polygons( return polys -def _remove_extent_intersections( - waterline: LineString, bounds, buffer: float = 0.0001 -) -> list[LineString]: +def _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 0.0001) -> list[LineString]: """Return 2-point segments that do NOT intersect the raster extent boundary.""" extent_edge = box(*bounds).boundary edges = split_into_segments(waterline) @@ -160,9 +156,7 @@ def _clean_waterline_segments( return segments -def _get_sea_direction_for_segment( - water_poly: Polygon, seg: LineString -) -> tuple[str, float | None]: +def _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]: """ Determine where the sea (water polygon side) lies relative to a segment. @@ -293,9 +287,7 @@ def waterline_from_land_water_raster( records: list[dict[str, Any]] = [] if time_dim not in da.dims: - raise KeyError( - f"No {time_dim} in input array. Use one of the following: {da.dims}" - ) + raise KeyError(f"No {time_dim} in input array. Use one of the following: {da.dims}") transform = da.rio.transform() bounds = da.rio.bounds() @@ -332,36 +324,6 @@ def waterline_from_land_water_raster( return gdf -# def apply_datacube(cube: xr.DataArray, context: dict) -> xr.DataArray: -# inspect(data=[cube.shape], message="Input UDF cube shape") -# gdf = waterline_from_land_water_raster( -# da=cube, -# crs=context.get("crs"), -# simplify_tolerance=context.get("simplify_tolerance"), -# time_dim=context.get("time_dim", "time"), -# ) -# inspect(data=[len(gdf)], message="Output gdf len") - -# geojson = json.dumps(gdf.__geo_interface__, default=str) -# inspect(data=[geojson], message="Output geojson") -# #return xr.DataArray(geojson) -# return xr.DataArray.from_series(gdf.geometry) - -# def apply_vectorcube(geometries: gpd.geodataframe.GeoDataFrame, -# cube: xr.DataArray, -# context: dict) -> tuple[gpd.GeoDataFrame, xr.DataArray]: -# inspect(data=[geometries], message="Input UDF geometries") -# inspect(data=[cube.shape], message="Input UDF cube shape") -# gdf = waterline_from_land_water_raster( -# da=cube, -# crs=context.get("crs"), -# simplify_tolerance=context.get("simplify_tolerance"), -# time_dim=context.get("time_dim", "time"), -# ) -# inspect(data=[gdf], message="Output geojson") - -# return gdf, cube - def apply_udf_data(data: UdfData) -> UdfData: inspect(data=[data], message="Input UDFData inspection") @@ -386,5 +348,5 @@ def apply_udf_data(data: UdfData) -> UdfData: data.set_feature_collection_list([feature_collection]) inspect(data=[data], message="Output UDFData inspection") - + return data From 415f7e03e402245cf19c34674659f0ddb1922c9c Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Thu, 9 Apr 2026 08:30:40 +0100 Subject: [PATCH 11/55] readme --- .../argans/waterlines/openeo_udp/README.md | 133 +++++++++++++++++- .../udf_waterlines_from_water_land_mask.py | 1 - 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/README.md b/algorithm_catalog/argans/waterlines/openeo_udp/README.md index b40ec8726..10c18eff8 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/README.md +++ b/algorithm_catalog/argans/waterlines/openeo_udp/README.md @@ -1,3 +1,132 @@ -# Waterlines extraction from Sentinel-2 +# Waterlines openEO UDP -Extracts waterlines from Sentinel-2 imagery using a selectable water/land classification method, morphological cleaning, and UDF-based vectorisation. +## Purpose +Extract coastline waterlines from Sentinel-2 imagery using a selectable water/land classification method, morphological refinement, and UDF-based vectorisation. + +--- + +## Methodology + +The algorithm processes Sentinel-2 time series data to generate water/land masks and extract waterlines. + +### Water/Land Classification + +Water/land masks are derived using one of the following methods: + +#### Index-based methods (thresholding) + +Water is identified based on spectral indices computed from Sentinel-2 bands: + +- **NDWI (Normalized Difference Water Index)** + $$ + NDWI = \frac{G - NIR}{G + NIR} + $$ + +- **MNDWI (Modified NDWI)** + $$ + MNDWI = \frac{G - SWIR}{G + SWIR} + $$ + +- **NDVI (Normalized Difference Vegetation Index)** + $$ + NDVI = \frac{NIR - R}{NIR + R} + $$ + +- **GNDVI (Green NDVI)** + $$ + GNDVI = \frac{NIR - G}{NIR + G} + $$ + +- **BNDVI (Blue NDVI)** + $$ + BNDVI = \frac{NIR - B}{NIR + B} + $$ + +Where: +- **B** = Blue band (S2 B02) +- **G** = Green band (S2 B03) +- **R** = Red band (S2 B04) +- **NIR** = Near Infrared (S2 B08) +- **SWIR** = Shortwave Infrared (S2 B11) + +Water classification rules: +- NDWI, MNDWI → water if index > threshold +- NDVI, GNDVI, BNDVI → water if index < threshold + +Default thresholds are provided for each index but can be overridden via parameters. + +--- + +#### SCL-based method + +- Uses the Sentinel-2 Scene Classification Layer (SCL) +- Water is identified as class `6` + +--- + +### Morphological Processing + +For each timestamp, the water/land mask is refined using morphological operations to: + +- remove small isolated objects +- fill small holes +- smooth boundaries +- reduce artifacts such as narrow bridges and estuaries + +This improves the quality and stability of the resulting waterlines. + +--- + +### Waterline Extraction + +The cleaned masks are converted into vector waterlines using a UDF, producing geometries for each time step. + +--- + +## Output + +The process outputs **FeatureCollections** of coastline waterlines with the following properties: + +- **time** – Acquisition timestamp (Sentinel-2 datetime) +- **type** – Feature type (`waterline_segment`) +- **sea_direction_8** – Sea direction (N, NE, E, SE, S, SW, W, NW) +- **sea_azimuth_deg** – Sea direction in degrees (azimuth, clockwise from north) +- **geometry** – Waterline geometry (LineString or MultiLineString) in EPSG:3857 + +--- + +## Usage + +See the APEx documentation and repository: + +- [UDP Writer Guide](https://esa-apex.github.io/apex_documentation/guides/udp_writer_guide.html) +- [APEx Algorithms GitHub](https://github.com/ESA-APEx/apex_algorithms) + +--- + +## Authors / Contact + +- **Milena Napiorkowska** (openEO UDP) – Argans Ltd + mnapiorkowska@argans.co.uk + +- **Martin Jones** (Project Manager) – Argans Ltd + mjones@argans.co.uk + +- **Holly Baxter** (Methodology) – Argans Ltd + hbaxtar@argans.co.uk + +- **Cameron Mackenzie** (Methodology) + cmackenzie@argans.co.uk + +--- + +## Acknowledgments + +This work was developed as part of an ESA-funded Fast Track project. + +--- + +## Known Limitations + +- Results are most reliable for scenes with low cloud coverage +- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels \ No newline at end of file diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index 2843e203e..edf9644f8 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -18,7 +18,6 @@ Point, GeometryCollection, ) -import json from shapely.ops import unary_union from openeo.udf import inspect import rioxarray From 6d816bb7f51e59bf2dfaab4c527939f53f4095e5 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 11:05:36 +0100 Subject: [PATCH 12/55] corrected typos --- algorithm_catalog/argans/waterlines/openeo_udp/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/README.md b/algorithm_catalog/argans/waterlines/openeo_udp/README.md index 10c18eff8..6f8c1d973 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/README.md +++ b/algorithm_catalog/argans/waterlines/openeo_udp/README.md @@ -1,7 +1,7 @@ # Waterlines openEO UDP ## Purpose -Extract coastline waterlines from Sentinel-2 imagery using a selectable water/land classification method, morphological refinement, and UDF-based vectorisation. +Extract coastline waterlines from Sentinel-2 imagery using a selectable water/land classification method, morphological refinement, and UDF-based vectorization. --- @@ -13,7 +13,7 @@ The algorithm processes Sentinel-2 time series data to generate water/land masks Water/land masks are derived using one of the following methods: -#### Index-based methods (thresholding) +#### Index-based methods Water is identified based on spectral indices computed from Sentinel-2 bands: From 957b6b2cb3e23efd690c03c2bbbd8d06eb1a6024 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 11:07:00 +0100 Subject: [PATCH 13/55] refactored waterliens in order to return vectorcube --- .../argans/waterlines/openeo_udp/generate.py | 8 +- .../udf_waterlines_from_water_land_mask.py | 114 ++++++------------ .../waterlines/openeo_udp/waterlines.json | 7 +- 3 files changed, 49 insertions(+), 80 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index baf567577..c1053b331 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -36,12 +36,16 @@ def apply_morphology(cube: DataCube, iterations: int) -> DataCube: ) -def create_waterlines(cube: DataCube, crs: str = "EPSG:3857") -> DataCube: +def create_waterlines(cube: DataCube, simplify_tolerance: float = 10) -> DataCube: """ Extract waterlines from a water/land mask using a UDF. Runs per time slice and outputs waterline geometries. """ + + # Vectorize cube + #cube = cube.raster_to_vector() + udf = UDF.from_file( Path(__file__).parent / "udf_waterlines_from_water_land_mask.py", context={"from_parameter": "context"}, @@ -49,7 +53,7 @@ def create_waterlines(cube: DataCube, crs: str = "EPSG:3857") -> DataCube: return cube.apply_dimension( process=udf, dimension="t", - context={"crs": crs, "time_dim": "t"}, + context={"simplify_tolerance": simplify_tolerance}, ) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index edf9644f8..8af8d399d 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -81,24 +81,6 @@ def _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HO return Polygon(geom.exterior, holes=kept_holes) -def _vectorize_water_polygons( - water_mask: np.ndarray, - transform: Affine, - water_value: int = 1, -) -> list[Polygon]: - """Create water polygons from a 0/1 coastal-water mask.""" - polys: list[Polygon] = [] - mask = water_mask.astype(bool) - - for geom, val in shapes(water_mask, mask=mask, transform=transform): - if int(val) == int(water_value): - shp = shape(geom) - shp = _remove_small_interiors(shp) - polys.append(shp) - - return polys - - def _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 0.0001) -> list[LineString]: """Return 2-point segments that do NOT intersect the raster extent boundary.""" extent_edge = box(*bounds).boundary @@ -214,19 +196,17 @@ def _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tupl def _segments_for_water_mask( - water_mask_2d: np.ndarray, - transform: Affine, + gdf_water_one_timestamp, bounds, simplify_tolerance: float | None = None, ) -> tuple[list[LineString], Polygon] | None: """Converts water land mask for single timestamp to cleaned waterline segments.""" - # Here water_value is 1 because _build_coastal_water_mask returns 0/1 - water_polys = _vectorize_water_polygons(water_mask_2d, transform, water_value=1) - if not water_polys: - return None + # Remove small interiors + gdf_water_one_timestamp["geometry"] = gdf_water_one_timestamp["geometry"].apply(_remove_small_interiors) - water_poly = unary_union(water_polys) + # Merge all polygons + water_poly = gdf_water_one_timestamp.union_all() if simplify_tolerance is not None: water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True) @@ -246,23 +226,20 @@ def _segments_for_water_mask( return cleaned_segments, water_poly -def waterline_from_land_water_raster( - da: xr.DataArray, - crs: str | None = None, +def waterline_from_vectorized_water_raster( + gdf: gpd.GeoDataFrame, simplify_tolerance: float | None = None, - time_dim: str = DEFAULT_TIME_DIM, ) -> gpd.GeoDataFrame: """ - Generate waterline segments for each time step from a land/water mask raster. + Generate waterline segments for each time step from a vectorized land/water mask raster. Args: - da: DataArray containing a land/water mask with a time dimension. - crs: DataArray projection. If None it will be read from da. However in some - situations this information might not be stored in da (for example when - reading dataset from netCDF) so option to provide it is given. + gdf: Vectorized water/land mask with polygons geometries. This GeoDataFrame contains + separate field for each time stamp. Each row contains either NULL, 0, or 1 value, + where NULL means the polygons is not for the given time stamp, 0 is land polygon + and 1 is water polygon. simplify_tolerance: Optional tolerance for geometry simplification. If provided, resulting geometries will be simplified. - time_dim: Name of the time dimension in the raster dataset. Returns: A GeoDataFrame with columns: @@ -275,29 +252,17 @@ def waterline_from_land_water_raster( - geometry: Waterline geometry (LineString or MultiLineString). """ - if crs is None: - if da.rio.crs is not None: - crs = da.rio.crs - elif "crs" in da.attrs: - crs = da.attrs["crs"] - if crs is None: - raise ValueError("CRS needed to perform vectorization.") - records: list[dict[str, Any]] = [] - if time_dim not in da.dims: - raise KeyError(f"No {time_dim} in input array. Use one of the following: {da.dims}") - - transform = da.rio.transform() - bounds = da.rio.bounds() - - for i in range(da.sizes[time_dim]): - slice2d = da.isel({time_dim: i}) - tval = slice2d[time_dim].values - inspect(data=[tval], message="Extracting waterlines for timestamp") + # Get time dimensions + time_stamps = gdf.loc[:, gdf.columns != "geometry"].columns.to_list() + bounds = gdf.total_bounds + for time_stamp in time_stamps: + one_time_stamp_gdf = gdf[[time_stamp, "geometry"]].dropna(subset=[time_stamp]) + one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0] + inspect(data=[time_stamp], message="Extracting waterlines for timestamp") res = _segments_for_water_mask( - slice2d.values, - transform=transform, + one_time_stamp_gdf_water_only, bounds=bounds, simplify_tolerance=simplify_tolerance, ) @@ -305,12 +270,12 @@ def waterline_from_land_water_raster( continue segments, water_poly = res - inspect(data=[tval], message="Calculating sea direction for timestamp") + inspect(data=[time_stamp], message="Calculating sea direction for timestamp") for seg in segments: sea_direction = _get_sea_direction_for_segment(water_poly, seg) records.append( { - "time": tval, + "time": time_stamp, "type": "waterline_segment", DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0], DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1], @@ -318,34 +283,35 @@ def waterline_from_land_water_raster( } ) inspect(data=[records], message="Converting records to geodataframe") - gdf = gpd.GeoDataFrame(records, geometry="geometry", crs=crs) + gdf = gpd.GeoDataFrame(records, geometry="geometry", crs=gdf.crs) gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True) return gdf -def apply_udf_data(data: UdfData) -> UdfData: +def apply_udf_data(udf_data: UdfData) -> UdfData: - inspect(data=[data], message="Input UDFData inspection") + inspect(data=[udf_data], message="Input UDFData inspection") - cube = data.get_datacube_list()[0].get_array() - inspect(data=[list(cube.dims), list(cube.shape)], message="Input UDF cube dims/shape") + [feature_collection] = udf_data.get_feature_collection_list() + gdf = feature_collection.data - gdf = waterline_from_land_water_raster( - da=cube, - crs=data.user_context.get("crs"), - simplify_tolerance=data.user_context.get("simplify_tolerance"), - time_dim=data.user_context.get("time_dim", "time"), + gdf = waterline_from_vectorized_water_raster( + gdf=gdf, + simplify_tolerance=udf_data.user_context.get("simplify_tolerance"), ) inspect(data=[gdf], message="Output gdf") - feature_collection = FeatureCollection( - id=DEFAULT_OUT_LAYER, - data=gdf, - ) + udf_data.set_feature_collection_list([FeatureCollection(id="_", data=gdf)]) + + inspect(data=[udf_data], message="Output UDFData inspection") - data.set_feature_collection_list([feature_collection]) + return udf_data - inspect(data=[data], message="Output UDFData inspection") - return data +# from pathlib import Path +# files_dir = Path("P:/FastTrack/DAP10/openeo") +# polygons_path = files_dir / "vectorcube.geojson" +# gdf = gpd.read_file(polygons_path) +# waterlines = waterline_from_vectorized_water_raster(gdf, simplify_tolerance=10) +# waterlines.to_file(files_dir / "vatercube_waterlines.geojson") diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index d1b4b4694..cf40345f2 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -1730,8 +1730,7 @@ "process_id": "apply_dimension", "arguments": { "context": { - "crs": "EPSG:3857", - "time_dim": "t" + "simplify_tolerance": 10 }, "data": { "from_node": "if10" @@ -1749,7 +1748,7 @@ "from_parameter": "data" }, "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n shape,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nimport json\nfrom shapely.ops import unary_union\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(\n geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA\n) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _vectorize_water_polygons(\n water_mask: np.ndarray,\n transform: Affine,\n water_value: int = 1,\n) -> list[Polygon]:\n \"\"\"Create water polygons from a 0/1 coastal-water mask.\"\"\"\n polys: list[Polygon] = []\n mask = water_mask.astype(bool)\n\n for geom, val in shapes(water_mask, mask=mask, transform=transform):\n if int(val) == int(water_value):\n shp = shape(geom)\n shp = _remove_small_interiors(shp)\n polys.append(shp)\n\n return polys\n\n\ndef _remove_extent_intersections(\n waterline: LineString, bounds, buffer: float = 0.0001\n) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(\n water_poly: Polygon, seg: LineString\n) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n water_mask_2d: np.ndarray,\n transform: Affine,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Here water_value is 1 because _build_coastal_water_mask returns 0/1\n water_polys = _vectorize_water_polygons(water_mask_2d, transform, water_value=1)\n if not water_polys:\n return None\n\n water_poly = unary_union(water_polys)\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_land_water_raster(\n da: xr.DataArray,\n crs: str | None = None,\n simplify_tolerance: float | None = None,\n time_dim: str = DEFAULT_TIME_DIM,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a land/water mask raster.\n\n Args:\n da: DataArray containing a land/water mask with a time dimension.\n crs: DataArray projection. If None it will be read from da. However in some\n situations this information might not be stored in da (for example when\n reading dataset from netCDF) so option to provide it is given.\n simplify_tolerance: Optional tolerance for geometry simplification.\n If provided, resulting geometries will be simplified.\n time_dim: Name of the time dimension in the raster dataset.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n \"\"\"\n\n if crs is None:\n if da.rio.crs is not None:\n crs = da.rio.crs\n elif \"crs\" in da.attrs:\n crs = da.attrs[\"crs\"]\n if crs is None:\n raise ValueError(\"CRS needed to perform vectorization.\")\n\n records: list[dict[str, Any]] = []\n\n if time_dim not in da.dims:\n raise KeyError(\n f\"No {time_dim} in input array. Use one of the following: {da.dims}\"\n )\n\n transform = da.rio.transform()\n bounds = da.rio.bounds()\n\n for i in range(da.sizes[time_dim]):\n slice2d = da.isel({time_dim: i})\n tval = slice2d[time_dim].values\n inspect(data=[tval], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n slice2d.values,\n transform=transform,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[tval], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": tval,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\n# def apply_datacube(cube: xr.DataArray, context: dict) -> xr.DataArray:\n# inspect(data=[cube.shape], message=\"Input UDF cube shape\")\n# gdf = waterline_from_land_water_raster(\n# da=cube,\n# crs=context.get(\"crs\"),\n# simplify_tolerance=context.get(\"simplify_tolerance\"),\n# time_dim=context.get(\"time_dim\", \"time\"),\n# )\n# inspect(data=[len(gdf)], message=\"Output gdf len\")\n\n# geojson = json.dumps(gdf.__geo_interface__, default=str)\n# inspect(data=[geojson], message=\"Output geojson\")\n# #return xr.DataArray(geojson)\n# return xr.DataArray.from_series(gdf.geometry)\n\n# def apply_vectorcube(geometries: gpd.geodataframe.GeoDataFrame,\n# cube: xr.DataArray,\n# context: dict) -> tuple[gpd.GeoDataFrame, xr.DataArray]:\n# inspect(data=[geometries], message=\"Input UDF geometries\")\n# inspect(data=[cube.shape], message=\"Input UDF cube shape\")\n# gdf = waterline_from_land_water_raster(\n# da=cube,\n# crs=context.get(\"crs\"),\n# simplify_tolerance=context.get(\"simplify_tolerance\"),\n# time_dim=context.get(\"time_dim\", \"time\"),\n# )\n# inspect(data=[gdf], message=\"Output geojson\")\n\n# return gdf, cube\n\ndef apply_udf_data(data: UdfData) -> UdfData:\n\n inspect(data=[data], message=\"Input UDFData inspection\")\n\n cube = data.get_datacube_list()[0].get_array()\n inspect(data=[list(cube.dims), list(cube.shape)], message=\"Input UDF cube dims/shape\")\n\n gdf = waterline_from_land_water_raster(\n da=cube,\n crs=data.user_context.get(\"crs\"),\n simplify_tolerance=data.user_context.get(\"simplify_tolerance\"),\n time_dim=data.user_context.get(\"time_dim\", \"time\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n feature_collection = FeatureCollection(\n id=DEFAULT_OUT_LAYER,\n data=gdf,\n )\n\n data.set_feature_collection_list([feature_collection])\n\n inspect(data=[data], message=\"Output UDFData inspection\")\n\n return data\n" + "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n shape,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom shapely.ops import unary_union\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _vectorize_water_polygons(\n water_mask: np.ndarray,\n transform: Affine,\n water_value: int = 1,\n) -> list[Polygon]:\n \"\"\"Create water polygons from a 0/1 coastal-water mask.\"\"\"\n polys: list[Polygon] = []\n mask = water_mask.astype(bool)\n\n for geom, val in shapes(water_mask, mask=mask, transform=transform):\n if int(val) == int(water_value):\n shp = shape(geom)\n shp = _remove_small_interiors(shp)\n polys.append(shp)\n\n return polys\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 0.0001) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Merge all polygons\n water_poly = one_time_stamp_gdf_water_only.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask raster.\n\n Args:\n gdf: Vectorized water/land mask with polygons geometries. This GeoDataFrame contains\n separate field for each time stamp. Each row contains either NULL, 0, or 1 value,\n where NULL means the polygons is not for the given time stamp, 0 is land polygon\n and 1 is water polygon.\n simplify_tolerance: Optional tolerance for geometry simplification.\n If provided, resulting geometries will be simplified.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != 'geometry'].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n [feature_collection] = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=udf_data.user_context.get(\"simplify_tolerance\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([FeatureCollection(id=\"_\", data=gdf)])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n\n# from pathlib import Path\n# files_dir = Path(\"P:/FastTrack/DAP10/openeo\")\n# polygons_path = files_dir / \"vectorcube.geojson\"\n# gdf = gpd.read_file(polygons_path)\n# waterlines = waterline_from_vectorized_water_raster(gdf, simplify_tolerance=30)\n# waterlines.to_file(files_dir / \"vatercube_waterlines.geojson\")" }, "result": true } @@ -1761,7 +1760,7 @@ }, "id": "waterlines", "summary": "Waterlines extracted from Sentinel-2.", - "description": "# Waterlines extraction from Sentinel-2\n\nExtracts waterlines from Sentinel-2 imagery using a selectable water/land classification method, morphological cleaning, and UDF-based vectorisation.\n", + "description": "# Waterlines openEO UDP\n\n## Purpose\nExtract coastline waterlines from Sentinel-2 imagery using a selectable water/land classification method, morphological refinement, and UDF-based vectorisation.\n\n---\n\n## Methodology\n\nThe algorithm processes Sentinel-2 time series data to generate water/land masks and extract waterlines.\n\n### Water/Land Classification\n\nWater/land masks are derived using one of the following methods:\n\n#### Index-based methods (thresholding)\n\nWater is identified based on spectral indices computed from Sentinel-2 bands:\n\n- **NDWI (Normalized Difference Water Index)** \n $$\n NDWI = \\frac{G - NIR}{G + NIR}\n $$\n\n- **MNDWI (Modified NDWI)** \n $$\n MNDWI = \\frac{G - SWIR}{G + SWIR}\n $$\n\n- **NDVI (Normalized Difference Vegetation Index)** \n $$\n NDVI = \\frac{NIR - R}{NIR + R}\n $$\n\n- **GNDVI (Green NDVI)** \n $$\n GNDVI = \\frac{NIR - G}{NIR + G}\n $$\n\n- **BNDVI (Blue NDVI)** \n $$\n BNDVI = \\frac{NIR - B}{NIR + B}\n $$\n\nWhere:\n- **B** = Blue band (S2 B02) \n- **G** = Green band (S2 B03) \n- **R** = Red band (S2 B04) \n- **NIR** = Near Infrared (S2 B08) \n- **SWIR** = Shortwave Infrared (S2 B11)\n\nWater classification rules:\n- NDWI, MNDWI \u00e2\u2020\u2019 water if index > threshold \n- NDVI, GNDVI, BNDVI \u00e2\u2020\u2019 water if index < threshold \n\nDefault thresholds are provided for each index but can be overridden via parameters.\n\n---\n\n#### SCL-based method\n\n- Uses the Sentinel-2 Scene Classification Layer (SCL) \n- Water is identified as class `6` \n\n---\n\n### Morphological Processing\n\nFor each timestamp, the water/land mask is refined using morphological operations to:\n\n- remove small isolated objects \n- fill small holes \n- smooth boundaries \n- reduce artifacts such as narrow bridges and estuaries \n\nThis improves the quality and stability of the resulting waterlines.\n\n---\n\n### Waterline Extraction\n\nThe cleaned masks are converted into vector waterlines using a UDF, producing geometries for each time step.\n\n---\n\n## Output\n\nThe process outputs **FeatureCollections** of coastline waterlines with the following properties:\n\n- **time** \u00e2\u20ac\u201c Acquisition timestamp (Sentinel-2 datetime) \n- **type** \u00e2\u20ac\u201c Feature type (`waterline_segment`) \n- **sea_direction_8** \u00e2\u20ac\u201c Sea direction (N, NE, E, SE, S, SW, W, NW) \n- **sea_azimuth_deg** \u00e2\u20ac\u201c Sea direction in degrees (azimuth, clockwise from north) \n- **geometry** \u00e2\u20ac\u201c Waterline geometry (LineString or MultiLineString) in EPSG:3857 \n\n---\n\n## Usage\n\nSee the APEx documentation and repository:\n\n- [UDP Writer Guide](https://esa-apex.github.io/apex_documentation/guides/udp_writer_guide.html) \n- [APEx Algorithms GitHub](https://github.com/ESA-APEx/apex_algorithms)\n\n---\n\n## Authors / Contact\n\n- **Milena Napiorkowska** (openEO UDP) \u00e2\u20ac\u201c Argans Ltd \n mnapiorkowska@argans.co.uk \n\n- **Martin Jones** (Project Manager) \u00e2\u20ac\u201c Argans Ltd \n mjones@argans.co.uk \n\n- **Holly Baxter** (Methodology) \u00e2\u20ac\u201c Argans Ltd \n hbaxtar@argans.co.uk \n\n- **Cameron Mackenzie** (Methodology) \n cmackenzie@argans.co.uk \n\n---\n\n## Acknowledgments\n\nThis work was developed as part of an ESA-funded Fast Track project.\n\n---\n\n## Known Limitations\n\n- Results are most reliable for scenes with low cloud coverage \n- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels ", "categories": [ "sentinel-2", "coastline", From a1ce6cfc0407f1849617d700b08ee6844dab6194 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 12:19:14 +0100 Subject: [PATCH 14/55] docstring update --- .../udf_waterlines_from_water_land_mask.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index 8af8d399d..a3b0779c3 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -231,15 +231,18 @@ def waterline_from_vectorized_water_raster( simplify_tolerance: float | None = None, ) -> gpd.GeoDataFrame: """ - Generate waterline segments for each time step from a vectorized land/water mask raster. - + Generate waterline segments for each time step from a vectorized land/water mask. + The input gdf is the output of openEO raster_to_vector() process. Args: - gdf: Vectorized water/land mask with polygons geometries. This GeoDataFrame contains - separate field for each time stamp. Each row contains either NULL, 0, or 1 value, - where NULL means the polygons is not for the given time stamp, 0 is land polygon - and 1 is water polygon. - simplify_tolerance: Optional tolerance for geometry simplification. - If provided, resulting geometries will be simplified. + gdf: GeoDataFrame containing polygon geometries and one non-geometry column + per timestamp. For each timestamp column, values indicate whether a + polygon belongs to water or land at that time: + - null: polygon not present for that timestamp + - 0: land polygon + - non-zero: water polygon + simplify_tolerance: Optional tolerance for geometry simplification. If + provided, merged water geometries are simplified before extracting + boundary segments. Returns: A GeoDataFrame with columns: @@ -314,4 +317,4 @@ def apply_udf_data(udf_data: UdfData) -> UdfData: # polygons_path = files_dir / "vectorcube.geojson" # gdf = gpd.read_file(polygons_path) # waterlines = waterline_from_vectorized_water_raster(gdf, simplify_tolerance=10) -# waterlines.to_file(files_dir / "vatercube_waterlines.geojson") +# waterlines.to_file(files_dir / "vatercube_waterlines.geojson") \ No newline at end of file From 4aa4992909725eab8b713b99f4615b3c610c523a Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 12:52:41 +0100 Subject: [PATCH 15/55] TODO about vectorisation added --- algorithm_catalog/argans/waterlines/openeo_udp/generate.py | 4 +++- .../argans/waterlines/openeo_udp/waterlines.json | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index c1053b331..89339c920 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -44,6 +44,8 @@ def create_waterlines(cube: DataCube, simplify_tolerance: float = 10) -> DataCub """ # Vectorize cube + # TODO: The waterlines UDF required vectorized cube, however this fails + # when building graph: AttributeError: 'ProcessBuilder' object has no attribute 'raster_to_vector' #cube = cube.raster_to_vector() udf = UDF.from_file( @@ -52,7 +54,7 @@ def create_waterlines(cube: DataCube, simplify_tolerance: float = 10) -> DataCub ) return cube.apply_dimension( process=udf, - dimension="t", + dimension="geometry", context={"simplify_tolerance": simplify_tolerance}, ) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index cf40345f2..5c61dbd50 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -1735,7 +1735,7 @@ "data": { "from_node": "if10" }, - "dimension": "t", + "dimension": "geometry", "process": { "process_graph": { "runudf7": { @@ -1748,7 +1748,7 @@ "from_parameter": "data" }, "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n shape,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom shapely.ops import unary_union\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _vectorize_water_polygons(\n water_mask: np.ndarray,\n transform: Affine,\n water_value: int = 1,\n) -> list[Polygon]:\n \"\"\"Create water polygons from a 0/1 coastal-water mask.\"\"\"\n polys: list[Polygon] = []\n mask = water_mask.astype(bool)\n\n for geom, val in shapes(water_mask, mask=mask, transform=transform):\n if int(val) == int(water_value):\n shp = shape(geom)\n shp = _remove_small_interiors(shp)\n polys.append(shp)\n\n return polys\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 0.0001) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Merge all polygons\n water_poly = one_time_stamp_gdf_water_only.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask raster.\n\n Args:\n gdf: Vectorized water/land mask with polygons geometries. This GeoDataFrame contains\n separate field for each time stamp. Each row contains either NULL, 0, or 1 value,\n where NULL means the polygons is not for the given time stamp, 0 is land polygon\n and 1 is water polygon.\n simplify_tolerance: Optional tolerance for geometry simplification.\n If provided, resulting geometries will be simplified.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != 'geometry'].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n [feature_collection] = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=udf_data.user_context.get(\"simplify_tolerance\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([FeatureCollection(id=\"_\", data=gdf)])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n\n# from pathlib import Path\n# files_dir = Path(\"P:/FastTrack/DAP10/openeo\")\n# polygons_path = files_dir / \"vectorcube.geojson\"\n# gdf = gpd.read_file(polygons_path)\n# waterlines = waterline_from_vectorized_water_raster(gdf, simplify_tolerance=30)\n# waterlines.to_file(files_dir / \"vatercube_waterlines.geojson\")" + "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n shape,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom shapely.ops import unary_union\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 0.0001) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n [feature_collection] = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=udf_data.user_context.get(\"simplify_tolerance\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([FeatureCollection(id=\"_\", data=gdf)])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n\n\n# from pathlib import Path\n# files_dir = Path(\"P:/FastTrack/DAP10/openeo\")\n# polygons_path = files_dir / \"vectorcube.geojson\"\n# gdf = gpd.read_file(polygons_path)\n# waterlines = waterline_from_vectorized_water_raster(gdf, simplify_tolerance=10)\n# waterlines.to_file(files_dir / \"vatercube_waterlines.geojson\")" }, "result": true } @@ -1760,7 +1760,7 @@ }, "id": "waterlines", "summary": "Waterlines extracted from Sentinel-2.", - "description": "# Waterlines openEO UDP\n\n## Purpose\nExtract coastline waterlines from Sentinel-2 imagery using a selectable water/land classification method, morphological refinement, and UDF-based vectorisation.\n\n---\n\n## Methodology\n\nThe algorithm processes Sentinel-2 time series data to generate water/land masks and extract waterlines.\n\n### Water/Land Classification\n\nWater/land masks are derived using one of the following methods:\n\n#### Index-based methods (thresholding)\n\nWater is identified based on spectral indices computed from Sentinel-2 bands:\n\n- **NDWI (Normalized Difference Water Index)** \n $$\n NDWI = \\frac{G - NIR}{G + NIR}\n $$\n\n- **MNDWI (Modified NDWI)** \n $$\n MNDWI = \\frac{G - SWIR}{G + SWIR}\n $$\n\n- **NDVI (Normalized Difference Vegetation Index)** \n $$\n NDVI = \\frac{NIR - R}{NIR + R}\n $$\n\n- **GNDVI (Green NDVI)** \n $$\n GNDVI = \\frac{NIR - G}{NIR + G}\n $$\n\n- **BNDVI (Blue NDVI)** \n $$\n BNDVI = \\frac{NIR - B}{NIR + B}\n $$\n\nWhere:\n- **B** = Blue band (S2 B02) \n- **G** = Green band (S2 B03) \n- **R** = Red band (S2 B04) \n- **NIR** = Near Infrared (S2 B08) \n- **SWIR** = Shortwave Infrared (S2 B11)\n\nWater classification rules:\n- NDWI, MNDWI \u00e2\u2020\u2019 water if index > threshold \n- NDVI, GNDVI, BNDVI \u00e2\u2020\u2019 water if index < threshold \n\nDefault thresholds are provided for each index but can be overridden via parameters.\n\n---\n\n#### SCL-based method\n\n- Uses the Sentinel-2 Scene Classification Layer (SCL) \n- Water is identified as class `6` \n\n---\n\n### Morphological Processing\n\nFor each timestamp, the water/land mask is refined using morphological operations to:\n\n- remove small isolated objects \n- fill small holes \n- smooth boundaries \n- reduce artifacts such as narrow bridges and estuaries \n\nThis improves the quality and stability of the resulting waterlines.\n\n---\n\n### Waterline Extraction\n\nThe cleaned masks are converted into vector waterlines using a UDF, producing geometries for each time step.\n\n---\n\n## Output\n\nThe process outputs **FeatureCollections** of coastline waterlines with the following properties:\n\n- **time** \u00e2\u20ac\u201c Acquisition timestamp (Sentinel-2 datetime) \n- **type** \u00e2\u20ac\u201c Feature type (`waterline_segment`) \n- **sea_direction_8** \u00e2\u20ac\u201c Sea direction (N, NE, E, SE, S, SW, W, NW) \n- **sea_azimuth_deg** \u00e2\u20ac\u201c Sea direction in degrees (azimuth, clockwise from north) \n- **geometry** \u00e2\u20ac\u201c Waterline geometry (LineString or MultiLineString) in EPSG:3857 \n\n---\n\n## Usage\n\nSee the APEx documentation and repository:\n\n- [UDP Writer Guide](https://esa-apex.github.io/apex_documentation/guides/udp_writer_guide.html) \n- [APEx Algorithms GitHub](https://github.com/ESA-APEx/apex_algorithms)\n\n---\n\n## Authors / Contact\n\n- **Milena Napiorkowska** (openEO UDP) \u00e2\u20ac\u201c Argans Ltd \n mnapiorkowska@argans.co.uk \n\n- **Martin Jones** (Project Manager) \u00e2\u20ac\u201c Argans Ltd \n mjones@argans.co.uk \n\n- **Holly Baxter** (Methodology) \u00e2\u20ac\u201c Argans Ltd \n hbaxtar@argans.co.uk \n\n- **Cameron Mackenzie** (Methodology) \n cmackenzie@argans.co.uk \n\n---\n\n## Acknowledgments\n\nThis work was developed as part of an ESA-funded Fast Track project.\n\n---\n\n## Known Limitations\n\n- Results are most reliable for scenes with low cloud coverage \n- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels ", + "description": "# Waterlines openEO UDP\n\n## Purpose\nExtract coastline waterlines from Sentinel-2 imagery using a selectable water/land classification method, morphological refinement, and UDF-based vectorization.\n\n---\n\n## Methodology\n\nThe algorithm processes Sentinel-2 time series data to generate water/land masks and extract waterlines.\n\n### Water/Land Classification\n\nWater/land masks are derived using one of the following methods:\n\n#### Index-based methods\n\nWater is identified based on spectral indices computed from Sentinel-2 bands:\n\n- **NDWI (Normalized Difference Water Index)** \n $$\n NDWI = \\frac{G - NIR}{G + NIR}\n $$\n\n- **MNDWI (Modified NDWI)** \n $$\n MNDWI = \\frac{G - SWIR}{G + SWIR}\n $$\n\n- **NDVI (Normalized Difference Vegetation Index)** \n $$\n NDVI = \\frac{NIR - R}{NIR + R}\n $$\n\n- **GNDVI (Green NDVI)** \n $$\n GNDVI = \\frac{NIR - G}{NIR + G}\n $$\n\n- **BNDVI (Blue NDVI)** \n $$\n BNDVI = \\frac{NIR - B}{NIR + B}\n $$\n\nWhere:\n- **B** = Blue band (S2 B02) \n- **G** = Green band (S2 B03) \n- **R** = Red band (S2 B04) \n- **NIR** = Near Infrared (S2 B08) \n- **SWIR** = Shortwave Infrared (S2 B11)\n\nWater classification rules:\n- NDWI, MNDWI \u00e2\u2020\u2019 water if index > threshold \n- NDVI, GNDVI, BNDVI \u00e2\u2020\u2019 water if index < threshold \n\nDefault thresholds are provided for each index but can be overridden via parameters.\n\n---\n\n#### SCL-based method\n\n- Uses the Sentinel-2 Scene Classification Layer (SCL) \n- Water is identified as class `6` \n\n---\n\n### Morphological Processing\n\nFor each timestamp, the water/land mask is refined using morphological operations to:\n\n- remove small isolated objects \n- fill small holes \n- smooth boundaries \n- reduce artifacts such as narrow bridges and estuaries \n\nThis improves the quality and stability of the resulting waterlines.\n\n---\n\n### Waterline Extraction\n\nThe cleaned masks are converted into vector waterlines using a UDF, producing geometries for each time step.\n\n---\n\n## Output\n\nThe process outputs **FeatureCollections** of coastline waterlines with the following properties:\n\n- **time** \u00e2\u20ac\u201c Acquisition timestamp (Sentinel-2 datetime) \n- **type** \u00e2\u20ac\u201c Feature type (`waterline_segment`) \n- **sea_direction_8** \u00e2\u20ac\u201c Sea direction (N, NE, E, SE, S, SW, W, NW) \n- **sea_azimuth_deg** \u00e2\u20ac\u201c Sea direction in degrees (azimuth, clockwise from north) \n- **geometry** \u00e2\u20ac\u201c Waterline geometry (LineString or MultiLineString) in EPSG:3857 \n\n---\n\n## Usage\n\nSee the APEx documentation and repository:\n\n- [UDP Writer Guide](https://esa-apex.github.io/apex_documentation/guides/udp_writer_guide.html) \n- [APEx Algorithms GitHub](https://github.com/ESA-APEx/apex_algorithms)\n\n---\n\n## Authors / Contact\n\n- **Milena Napiorkowska** (openEO UDP) \u00e2\u20ac\u201c Argans Ltd \n mnapiorkowska@argans.co.uk \n\n- **Martin Jones** (Project Manager) \u00e2\u20ac\u201c Argans Ltd \n mjones@argans.co.uk \n\n- **Holly Baxter** (Methodology) \u00e2\u20ac\u201c Argans Ltd \n hbaxtar@argans.co.uk \n\n- **Cameron Mackenzie** (Methodology) \n cmackenzie@argans.co.uk \n\n---\n\n## Acknowledgments\n\nThis work was developed as part of an ESA-funded Fast Track project.\n\n---\n\n## Known Limitations\n\n- Results are most reliable for scenes with low cloud coverage \n- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels ", "categories": [ "sentinel-2", "coastline", From 824537bb3ed43a002233d060eda8290479765298 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 14:50:25 +0100 Subject: [PATCH 16/55] modified _bin --- algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py b/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py index 2b923a2d3..55411b4a0 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py @@ -283,9 +283,9 @@ def s2_index_mask( idx = idx.mask(clear) if mode == "gt": - mask = gt(idx, threshold) + mask = idx.process("gt", x=idx, y=threshold) elif mode == "lt": - mask = lt(idx, threshold) + mask = idx.process("lt", x=idx, y=threshold) else: raise ValueError(f"Unsupported mode: {mode}") From 0e90e9876b0a4aa8dc3ed1804e6428c0aba92688 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 15:26:06 +0100 Subject: [PATCH 17/55] using only one idex method as MVP --- .../argans/waterlines/openeo_udp/README.md | 51 +- .../argans/waterlines/openeo_udp/generate.py | 209 +- .../waterlines/openeo_udp/waterlines.json | 1933 ----------------- .../openeo_udp/waterlines_s2_ndwi.json | 455 ++++ 4 files changed, 519 insertions(+), 2129 deletions(-) delete mode 100644 algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json create mode 100644 algorithm_catalog/argans/waterlines/openeo_udp/waterlines_s2_ndwi.json diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/README.md b/algorithm_catalog/argans/waterlines/openeo_udp/README.md index 6f8c1d973..e5e56f195 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/README.md +++ b/algorithm_catalog/argans/waterlines/openeo_udp/README.md @@ -1,68 +1,38 @@ # Waterlines openEO UDP ## Purpose -Extract coastline waterlines from Sentinel-2 imagery using a selectable water/land classification method, morphological refinement, and UDF-based vectorization. +Extract coastline waterlines from Sentinel-2 imagery using NDWI-based water detection, morphological refinement, and UDF-based vectorization. --- ## Methodology -The algorithm processes Sentinel-2 time series data to generate water/land masks and extract waterlines. - ### Water/Land Classification -Water/land masks are derived using one of the following methods: - -#### Index-based methods - -Water is identified based on spectral indices computed from Sentinel-2 bands: +Water masks are derived using the NDWI (Normalized Difference Water Index): - **NDWI (Normalized Difference Water Index)** $$ NDWI = \frac{G - NIR}{G + NIR} $$ -- **MNDWI (Modified NDWI)** - $$ - MNDWI = \frac{G - SWIR}{G + SWIR} - $$ - -- **NDVI (Normalized Difference Vegetation Index)** - $$ - NDVI = \frac{NIR - R}{NIR + R} - $$ - -- **GNDVI (Green NDVI)** - $$ - GNDVI = \frac{NIR - G}{NIR + G} - $$ - -- **BNDVI (Blue NDVI)** - $$ - BNDVI = \frac{NIR - B}{NIR + B} - $$ +Where water is classified as: +- **NDWI > threshold** Where: -- **B** = Blue band (S2 B02) - **G** = Green band (S2 B03) -- **R** = Red band (S2 B04) - **NIR** = Near Infrared (S2 B08) -- **SWIR** = Shortwave Infrared (S2 B11) -Water classification rules: -- NDWI, MNDWI → water if index > threshold -- NDVI, GNDVI, BNDVI → water if index < threshold - -Default thresholds are provided for each index but can be overridden via parameters. +Default threshold is equal to **0.01** but can be overridden via parameters. --- -#### SCL-based method +### Why only NDWI? +This MVP supports only one method (**S2_NDWI**). -- Uses the Sentinel-2 Scene Classification Layer (SCL) -- Water is identified as class `6` +Originally, multiple methods were selectable via a parameter, but this required openEO `if_()` logic, which converts the result into a `ProcessBuilder` instead of a `DataCube`. +This breaks the `raster_to_vector()` step needed for waterline extraction. ---- ### Morphological Processing @@ -129,4 +99,5 @@ This work was developed as part of an ESA-funded Fast Track project. ## Known Limitations - Results are most reliable for scenes with low cloud coverage -- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels \ No newline at end of file +- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels +- NDWI might be less reliable in turbid waters \ No newline at end of file diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index 89339c920..8c6b5774d 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -4,13 +4,11 @@ import openeo from openeo import UDF from openeo.api.process import Parameter -from openeo.processes import if_, eq from openeo.rest.connection import Connection from openeo.rest.datacube import DataCube from openeo.rest.udp import build_process_dict from s2_index import ( - s2_scl, s2_index_mask, DEFAULT_S2_COLLECTION, DEFAULT_MAX_CLOUD_COVER, @@ -40,13 +38,10 @@ def create_waterlines(cube: DataCube, simplify_tolerance: float = 10) -> DataCub """ Extract waterlines from a water/land mask using a UDF. - Runs per time slice and outputs waterline geometries. + The input must remain a DataCube because the workflow relies on + `raster_to_vector()` before applying the vector-based waterline UDF. """ - - # Vectorize cube - # TODO: The waterlines UDF required vectorized cube, however this fails - # when building graph: AttributeError: 'ProcessBuilder' object has no attribute 'raster_to_vector' - #cube = cube.raster_to_vector() + cube = cube.raster_to_vector() udf = UDF.from_file( Path(__file__).parent / "udf_waterlines_from_water_land_mask.py", @@ -64,32 +59,28 @@ def build_water_land_mask_cube( bbox, time_range, max_cloud_coverage, - method, iterations, ndwi_threshold, - mndwi_threshold, - ndvi_threshold, - bndvi_threshold, - gndvi_threshold, -): +) -> DataCube: """ - Build a water/land mask using multiple selectable Sentinel-2 methods. - - All method branches are constructed and the selected one is chosen - using openEO graph logic (since 'method' is a UDP parameter). + Build a water/land mask using Sentinel-2 NDWI only. + + MVP rationale: + Multiple selectable methods were intentionally removed from this UDP. + Selecting between whole DataCubes through nested openEO `if_()` expressions + turns the selected result into a ProcessBuilder rather than a DataCube. + That breaks the next step of the workflow, because `raster_to_vector()` is + used as a DataCube method when preparing input for the vector-based + waterline UDF. + + S2_NDWI was chosen for the MVP because it is a standard water-detection + index, fits the existing index-mask pipeline, and allows validation of the + complete end-to-end workflow without introducing graph-selection complexity. + + Future extensions can add the other methods either as separate UDPs or by + refactoring the waterline UDF to work directly on raster input. """ - # Build all candidate branches - - _, scl_cube = s2_scl( - con, - DEFAULT_S2_COLLECTION, - bbox, - time_range, - max_cloud_coverage=max_cloud_coverage - ) - scl_cube = apply_morphology(scl_cube, iterations) - - _, ndwi_cube = s2_index_mask( + _, cube = s2_index_mask( con=con, collection_id=DEFAULT_S2_COLLECTION, bbox=bbox, @@ -99,100 +90,36 @@ def build_water_land_mask_cube( mode="gt", max_cloud_coverage=max_cloud_coverage, ) - ndwi_cube = apply_morphology(ndwi_cube, iterations) - - _, mndwi_cube = s2_index_mask( - con=con, - collection_id=DEFAULT_S2_COLLECTION, - bbox=bbox, - time_range=time_range, - index_name="S2_MNDWI", - threshold=mndwi_threshold, - mode="gt", - max_cloud_coverage=max_cloud_coverage, - ) - mndwi_cube = apply_morphology(mndwi_cube, iterations) - - _, ndvi_cube = s2_index_mask( - con=con, - collection_id=DEFAULT_S2_COLLECTION, - bbox=bbox, - time_range=time_range, - index_name="S2_NDVI", - threshold=ndvi_threshold, - mode="lt", - max_cloud_coverage=max_cloud_coverage, - ) - ndvi_cube = apply_morphology(ndvi_cube, iterations) - - _, bndvi_cube = s2_index_mask( - con=con, - collection_id=DEFAULT_S2_COLLECTION, - bbox=bbox, - time_range=time_range, - index_name="S2_BNDVI", - threshold=bndvi_threshold, - mode="lt", - max_cloud_coverage=max_cloud_coverage, - ) - bndvi_cube = apply_morphology(bndvi_cube, iterations) - - _, gndvi_cube = s2_index_mask( - con=con, - collection_id=DEFAULT_S2_COLLECTION, - bbox=bbox, - time_range=time_range, - index_name="S2_GNDVI", - threshold=gndvi_threshold, - mode="lt", - max_cloud_coverage=max_cloud_coverage, - ) - gndvi_cube = apply_morphology(gndvi_cube, iterations) - - # Select branch in the process graph. - selected = if_( - eq(method, "S2_SCL"), - scl_cube, - if_( - eq(method, "S2_MNDWI"), - mndwi_cube, - if_( - eq(method, "S2_NDVI"), - ndvi_cube, - if_( - eq(method, "S2_BNDVI"), - bndvi_cube, - if_( - eq(method, "S2_GNDVI"), - gndvi_cube, - ndwi_cube, # default fallback - ), - ), - ), - ), - ) - - return selected + return apply_morphology(cube, iterations) def generate() -> dict: """ - Create the UDP for extracting waterlines from Sentinel-2 imagery. + Create the MVP UDP for extracting waterlines from Sentinel-2 imagery. Workflow: - 1. Load data - 2. Create water/land mask (selectable method) + 1. Load Sentinel-2 data + 2. Create water/land mask using S2_NDWI 3. Apply morphology - 4. Extract waterlines + 4. Vectorize the mask + 5. Extract waterlines + + Why only S2_NDWI? + The original multi-method design used a runtime UDP parameter to choose + between several masking methods. In practice, selecting between whole cubes + with openEO graph logic (`if_`) produced a ProcessBuilder instead of a + DataCube. Because the downstream workflow needs `raster_to_vector()`, that + design blocked the current implementation. + + Restricting the MVP to a single method keeps the graph in DataCube form and + allows the existing vector-based waterline UDF to work unchanged. """ - ### 1. Create backend connection conn = openeo.connect(url="openeo.dataspace.copernicus.eu") - ### 2. Define UDP input parameters spatial_extent = Parameter.bounding_box( name="spatial_extent", - description=("Bounding box of the area of interest. " "Defined as west, south, east, north in EPSG:4326."), + description="Bounding box of the area of interest. Defined as west, south, east, north in EPSG:4326.", ) temporal_extent = Parameter.temporal_interval( @@ -204,14 +131,7 @@ def generate() -> dict: max_cloud_coverage = Parameter.number( name="max_cloud_coverage", default=DEFAULT_MAX_CLOUD_COVER, - description=("Maximum allowed cloud coverage.") - ) - - method = Parameter.string( - name="s2_method", - default="S2_NDWI", - values=["S2_NDWI", "S2_MNDWI", "S2_SCL", "S2_NDVI", "S2_BNDVI", "S2_GNDVI"], - description="Method used to create the water/land mask from Sentinel-2 imagery.", + description="Maximum allowed cloud coverage.", ) iterations = Parameter.integer( @@ -220,72 +140,49 @@ def generate() -> dict: description="Number of iterations for morphological operations.", ) - ### 3. Define threshold parameters ndwi_threshold = Parameter.number( name="ndwi_threshold", default=WATERLAND_THRESHOLDS["S2_NDWI"].defaults["threshold"], description=WATERLAND_THRESHOLDS["S2_NDWI"].description, ) - mndwi_threshold = Parameter.number( - name="mndwi_threshold", - default=WATERLAND_THRESHOLDS["S2_MNDWI"].defaults["threshold"], - description=WATERLAND_THRESHOLDS["S2_MNDWI"].description, - ) - ndvi_threshold = Parameter.number( - name="ndvi_threshold", - default=WATERLAND_THRESHOLDS["S2_NDVI"].defaults["threshold"], - description=WATERLAND_THRESHOLDS["S2_NDVI"].description, - ) - bndvi_threshold = Parameter.number( - name="bndvi_threshold", - default=WATERLAND_THRESHOLDS["S2_BNDVI"].defaults["threshold"], - description=WATERLAND_THRESHOLDS["S2_BNDVI"].description, - ) - gndvi_threshold = Parameter.number( - name="gndvi_threshold", - default=WATERLAND_THRESHOLDS["S2_GNDVI"].defaults["threshold"], - description=WATERLAND_THRESHOLDS["S2_GNDVI"].description, + + simplify_tolerance = Parameter.number( + name="simplify_tolerance", + default=10, + description="Tolerance used to simplify vectorized water polygons before extracting waterlines.", ) - ### 4. Build the water/land mask graph water_land_mask = build_water_land_mask_cube( con=conn, bbox=spatial_extent, time_range=temporal_extent, max_cloud_coverage=max_cloud_coverage, - method=method, iterations=iterations, ndwi_threshold=ndwi_threshold, - mndwi_threshold=mndwi_threshold, - ndvi_threshold=ndvi_threshold, - bndvi_threshold=bndvi_threshold, - gndvi_threshold=gndvi_threshold, ) - ### 5. Extract waterlines from the cleaned water/land mask - waterlines_cube = create_waterlines(water_land_mask) + waterlines_cube = create_waterlines( + water_land_mask, + simplify_tolerance=simplify_tolerance, + ) return build_process_dict( process_graph=waterlines_cube, - process_id="waterlines", - summary="Waterlines extracted from Sentinel-2.", + process_id="waterlines_s2_ndwi", + summary="Waterlines extracted from Sentinel-2 using NDWI.", description=(Path(__file__).parent / "README.md").read_text(), parameters=[ spatial_extent, temporal_extent, max_cloud_coverage, - method, iterations, ndwi_threshold, - mndwi_threshold, - ndvi_threshold, - bndvi_threshold, - gndvi_threshold, + simplify_tolerance, ], categories=["sentinel-2", "coastline", "waterlines"], ) if __name__ == "__main__": - with open(Path(__file__).parent / "waterlines.json", "w") as f: - json.dump(generate(), f, indent=2) + with open(Path(__file__).parent / "waterlines_s2_ndwi.json", "w") as f: + json.dump(generate(), f, indent=2) \ No newline at end of file diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json deleted file mode 100644 index 5c61dbd50..000000000 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ /dev/null @@ -1,1933 +0,0 @@ -{ - "process_graph": { - "loadcollection1": { - "process_id": "load_collection", - "arguments": { - "bands": [ - "SCL" - ], - "id": "SENTINEL2_L2A", - "properties": { - "eo:cloud_cover": { - "process_graph": { - "lte1": { - "process_id": "lte", - "arguments": { - "x": { - "from_parameter": "value" - }, - "y": { - "from_parameter": "max_cloud_coverage" - } - }, - "result": true - } - } - } - }, - "spatial_extent": { - "from_parameter": "spatial_extent" - }, - "temporal_extent": { - "from_parameter": "temporal_extent" - } - } - }, - "resamplespatial1": { - "process_id": "resample_spatial", - "arguments": { - "data": { - "from_node": "loadcollection1" - }, - "method": "near", - "projection": 3857 - } - }, - "reducedimension1": { - "process_id": "reduce_dimension", - "arguments": { - "data": { - "from_node": "resamplespatial1" - }, - "dimension": "bands", - "reducer": { - "process_graph": { - "arrayelement1": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 0 - } - }, - "eq1": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement1" - }, - "y": -997 - } - }, - "eq2": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement1" - }, - "y": -992 - } - }, - "or1": { - "process_id": "or", - "arguments": { - "x": { - "from_node": "eq1" - }, - "y": { - "from_node": "eq2" - } - } - }, - "eq3": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement1" - }, - "y": -991 - } - }, - "or2": { - "process_id": "or", - "arguments": { - "x": { - "from_node": "or1" - }, - "y": { - "from_node": "eq3" - } - }, - "result": true - } - } - } - } - }, - "mask1": { - "process_id": "mask", - "arguments": { - "data": { - "from_node": "resamplespatial1" - }, - "mask": { - "from_node": "reducedimension1" - } - } - }, - "filterbands1": { - "process_id": "filter_bands", - "arguments": { - "bands": [ - "SCL" - ], - "data": { - "from_node": "mask1" - } - } - }, - "reducedimension2": { - "process_id": "reduce_dimension", - "arguments": { - "data": { - "from_node": "filterbands1" - }, - "dimension": "bands", - "reducer": { - "process_graph": { - "arrayelement2": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 0 - } - }, - "eq4": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement2" - }, - "y": 6 - }, - "result": true - } - } - } - } - }, - "applydimension1": { - "process_id": "apply_dimension", - "arguments": { - "context": { - "iterations": { - "from_parameter": "iterations" - } - }, - "data": { - "from_node": "reducedimension2" - }, - "dimension": "t", - "process": { - "process_graph": { - "runudf1": { - "process_id": "run_udf", - "arguments": { - "context": { - "from_parameter": "context" - }, - "data": { - "from_parameter": "data" - }, - "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"scikit-image\",\n# \"scipy\",\n# ]\n# ///\n\nfrom openeo.udf import XarrayDataCube, inspect\nimport numpy as np\nimport xarray as xr\nfrom scipy.ndimage import binary_fill_holes, binary_opening\n\nDEFAULT_WATER_VALUE = 1\n\n\ndef _build_coastal_water_mask(\n arr: np.ndarray,\n water_value: int = DEFAULT_WATER_VALUE,\n nodata: float | None = 999,\n iterations: int = 1,\n) -> np.ndarray:\n \"\"\"\n Build coastal-water-only mask from land/water mask.\n Inland water is filled.\n\n Returns:\n 0/1 mask where 1 is coastal water and 0 is land\n \"\"\"\n water = arr == water_value\n if nodata is not None:\n water = water & (arr != nodata)\n\n # Remove small estuaries\n water = binary_opening(water, iterations=iterations)\n\n # Remove bridges\n land = ~water\n land = binary_opening(land, iterations=iterations)\n\n land_filled = binary_fill_holes(land)\n water_filled = ~land_filled\n return water_filled.astype(np.uint8)\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"Apply morphological algorithms on DataCube\"\"\"\n\n cube_array: xr.DataArray = cube.get_array()\n inspect(data=[cube_array.shape], message=\"Input UDF cube_array shape\")\n\n cube_array = cube_array.astype(np.uint8)\n\n cube_array_3d = cube_array.squeeze(dim=\"bands\")\n\n modified = xr.apply_ufunc(\n _build_coastal_water_mask,\n cube_array_3d,\n input_core_dims=[[\"y\", \"x\"]],\n output_core_dims=[[\"y\", \"x\"]],\n vectorize=True,\n dask=\"parallelized\",\n output_dtypes=[np.uint8],\n kwargs={\n \"water_value\": DEFAULT_WATER_VALUE,\n \"nodata\": 999,\n \"iterations\": context[\"iterations\"],\n },\n )\n\n modified_da = xr.DataArray(\n modified,\n coords={\n \"t\": cube_array.coords[\"t\"],\n \"y\": cube_array.coords[\"y\"],\n \"x\": cube_array.coords[\"x\"],\n },\n dims=[\"t\", \"y\", \"x\"],\n )\n modified_da = modified_da.expand_dims(dim={\"bands\": cube_array.coords[\"bands\"]})\n modified_da = modified_da.transpose(\"t\", \"bands\", \"y\", \"x\")\n\n return XarrayDataCube(modified_da)\n" - }, - "result": true - } - } - } - } - }, - "loadcollection2": { - "process_id": "load_collection", - "arguments": { - "bands": [ - "B03", - "B11", - "SCL" - ], - "id": "SENTINEL2_L2A", - "properties": { - "eo:cloud_cover": { - "process_graph": { - "lte2": { - "process_id": "lte", - "arguments": { - "x": { - "from_parameter": "value" - }, - "y": { - "from_parameter": "max_cloud_coverage" - } - }, - "result": true - } - } - } - }, - "spatial_extent": { - "from_parameter": "spatial_extent" - }, - "temporal_extent": { - "from_parameter": "temporal_extent" - } - } - }, - "resamplespatial2": { - "process_id": "resample_spatial", - "arguments": { - "data": { - "from_node": "loadcollection2" - }, - "method": "near", - "projection": 3857 - } - }, - "reducedimension3": { - "process_id": "reduce_dimension", - "arguments": { - "data": { - "from_node": "resamplespatial2" - }, - "dimension": "bands", - "reducer": { - "process_graph": { - "arrayelement3": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 2 - } - }, - "eq5": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement3" - }, - "y": -997 - } - }, - "eq6": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement3" - }, - "y": -992 - } - }, - "or3": { - "process_id": "or", - "arguments": { - "x": { - "from_node": "eq5" - }, - "y": { - "from_node": "eq6" - } - } - }, - "eq7": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement3" - }, - "y": -991 - } - }, - "or4": { - "process_id": "or", - "arguments": { - "x": { - "from_node": "or3" - }, - "y": { - "from_node": "eq7" - } - }, - "result": true - } - } - } - } - }, - "mask2": { - "process_id": "mask", - "arguments": { - "data": { - "from_node": "resamplespatial2" - }, - "mask": { - "from_node": "reducedimension3" - } - } - }, - "filterbands2": { - "process_id": "filter_bands", - "arguments": { - "bands": [ - "B03", - "B11" - ], - "data": { - "from_node": "mask2" - } - } - }, - "reducedimension4": { - "process_id": "reduce_dimension", - "arguments": { - "data": { - "from_node": "filterbands2" - }, - "dimension": "bands", - "reducer": { - "process_graph": { - "arrayelement4": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 0 - } - }, - "arrayelement5": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 1 - } - }, - "subtract1": { - "process_id": "subtract", - "arguments": { - "x": { - "from_node": "arrayelement4" - }, - "y": { - "from_node": "arrayelement5" - } - } - }, - "add1": { - "process_id": "add", - "arguments": { - "x": { - "from_node": "arrayelement4" - }, - "y": { - "from_node": "arrayelement5" - } - } - }, - "divide1": { - "process_id": "divide", - "arguments": { - "x": { - "from_node": "subtract1" - }, - "y": { - "from_node": "add1" - } - }, - "result": true - } - } - } - } - }, - "mask3": { - "process_id": "mask", - "arguments": { - "data": { - "from_node": "reducedimension4" - }, - "mask": { - "from_node": "reducedimension3" - } - } - }, - "gt1": { - "process_id": "gt", - "arguments": { - "x": { - "from_node": "mask3" - }, - "y": { - "from_parameter": "mndwi_threshold" - } - } - }, - "apply1": { - "process_id": "apply", - "arguments": { - "data": { - "from_node": "gt1" - }, - "process": { - "process_graph": { - "if1": { - "process_id": "if", - "arguments": { - "accept": 1, - "reject": 0, - "value": { - "from_parameter": "x" - } - }, - "result": true - } - } - } - } - }, - "applydimension2": { - "process_id": "apply_dimension", - "arguments": { - "context": { - "iterations": { - "from_parameter": "iterations" - } - }, - "data": { - "from_node": "apply1" - }, - "dimension": "t", - "process": { - "process_graph": { - "runudf2": { - "process_id": "run_udf", - "arguments": { - "context": { - "from_parameter": "context" - }, - "data": { - "from_parameter": "data" - }, - "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"scikit-image\",\n# \"scipy\",\n# ]\n# ///\n\nfrom openeo.udf import XarrayDataCube, inspect\nimport numpy as np\nimport xarray as xr\nfrom scipy.ndimage import binary_fill_holes, binary_opening\n\nDEFAULT_WATER_VALUE = 1\n\n\ndef _build_coastal_water_mask(\n arr: np.ndarray,\n water_value: int = DEFAULT_WATER_VALUE,\n nodata: float | None = 999,\n iterations: int = 1,\n) -> np.ndarray:\n \"\"\"\n Build coastal-water-only mask from land/water mask.\n Inland water is filled.\n\n Returns:\n 0/1 mask where 1 is coastal water and 0 is land\n \"\"\"\n water = arr == water_value\n if nodata is not None:\n water = water & (arr != nodata)\n\n # Remove small estuaries\n water = binary_opening(water, iterations=iterations)\n\n # Remove bridges\n land = ~water\n land = binary_opening(land, iterations=iterations)\n\n land_filled = binary_fill_holes(land)\n water_filled = ~land_filled\n return water_filled.astype(np.uint8)\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"Apply morphological algorithms on DataCube\"\"\"\n\n cube_array: xr.DataArray = cube.get_array()\n inspect(data=[cube_array.shape], message=\"Input UDF cube_array shape\")\n\n cube_array = cube_array.astype(np.uint8)\n\n cube_array_3d = cube_array.squeeze(dim=\"bands\")\n\n modified = xr.apply_ufunc(\n _build_coastal_water_mask,\n cube_array_3d,\n input_core_dims=[[\"y\", \"x\"]],\n output_core_dims=[[\"y\", \"x\"]],\n vectorize=True,\n dask=\"parallelized\",\n output_dtypes=[np.uint8],\n kwargs={\n \"water_value\": DEFAULT_WATER_VALUE,\n \"nodata\": 999,\n \"iterations\": context[\"iterations\"],\n },\n )\n\n modified_da = xr.DataArray(\n modified,\n coords={\n \"t\": cube_array.coords[\"t\"],\n \"y\": cube_array.coords[\"y\"],\n \"x\": cube_array.coords[\"x\"],\n },\n dims=[\"t\", \"y\", \"x\"],\n )\n modified_da = modified_da.expand_dims(dim={\"bands\": cube_array.coords[\"bands\"]})\n modified_da = modified_da.transpose(\"t\", \"bands\", \"y\", \"x\")\n\n return XarrayDataCube(modified_da)\n" - }, - "result": true - } - } - } - } - }, - "loadcollection3": { - "process_id": "load_collection", - "arguments": { - "bands": [ - "B08", - "B04", - "SCL" - ], - "id": "SENTINEL2_L2A", - "properties": { - "eo:cloud_cover": { - "process_graph": { - "lte3": { - "process_id": "lte", - "arguments": { - "x": { - "from_parameter": "value" - }, - "y": { - "from_parameter": "max_cloud_coverage" - } - }, - "result": true - } - } - } - }, - "spatial_extent": { - "from_parameter": "spatial_extent" - }, - "temporal_extent": { - "from_parameter": "temporal_extent" - } - } - }, - "resamplespatial3": { - "process_id": "resample_spatial", - "arguments": { - "data": { - "from_node": "loadcollection3" - }, - "method": "near", - "projection": 3857 - } - }, - "reducedimension5": { - "process_id": "reduce_dimension", - "arguments": { - "data": { - "from_node": "resamplespatial3" - }, - "dimension": "bands", - "reducer": { - "process_graph": { - "arrayelement6": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 2 - } - }, - "eq8": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement6" - }, - "y": -997 - } - }, - "eq9": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement6" - }, - "y": -992 - } - }, - "or5": { - "process_id": "or", - "arguments": { - "x": { - "from_node": "eq8" - }, - "y": { - "from_node": "eq9" - } - } - }, - "eq10": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement6" - }, - "y": -991 - } - }, - "or6": { - "process_id": "or", - "arguments": { - "x": { - "from_node": "or5" - }, - "y": { - "from_node": "eq10" - } - }, - "result": true - } - } - } - } - }, - "mask4": { - "process_id": "mask", - "arguments": { - "data": { - "from_node": "resamplespatial3" - }, - "mask": { - "from_node": "reducedimension5" - } - } - }, - "filterbands3": { - "process_id": "filter_bands", - "arguments": { - "bands": [ - "B08", - "B04" - ], - "data": { - "from_node": "mask4" - } - } - }, - "reducedimension6": { - "process_id": "reduce_dimension", - "arguments": { - "data": { - "from_node": "filterbands3" - }, - "dimension": "bands", - "reducer": { - "process_graph": { - "arrayelement7": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 0 - } - }, - "arrayelement8": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 1 - } - }, - "subtract2": { - "process_id": "subtract", - "arguments": { - "x": { - "from_node": "arrayelement7" - }, - "y": { - "from_node": "arrayelement8" - } - } - }, - "add2": { - "process_id": "add", - "arguments": { - "x": { - "from_node": "arrayelement7" - }, - "y": { - "from_node": "arrayelement8" - } - } - }, - "divide2": { - "process_id": "divide", - "arguments": { - "x": { - "from_node": "subtract2" - }, - "y": { - "from_node": "add2" - } - }, - "result": true - } - } - } - } - }, - "mask5": { - "process_id": "mask", - "arguments": { - "data": { - "from_node": "reducedimension6" - }, - "mask": { - "from_node": "reducedimension5" - } - } - }, - "lt1": { - "process_id": "lt", - "arguments": { - "x": { - "from_node": "mask5" - }, - "y": { - "from_parameter": "ndvi_threshold" - } - } - }, - "apply2": { - "process_id": "apply", - "arguments": { - "data": { - "from_node": "lt1" - }, - "process": { - "process_graph": { - "if2": { - "process_id": "if", - "arguments": { - "accept": 1, - "reject": 0, - "value": { - "from_parameter": "x" - } - }, - "result": true - } - } - } - } - }, - "applydimension3": { - "process_id": "apply_dimension", - "arguments": { - "context": { - "iterations": { - "from_parameter": "iterations" - } - }, - "data": { - "from_node": "apply2" - }, - "dimension": "t", - "process": { - "process_graph": { - "runudf3": { - "process_id": "run_udf", - "arguments": { - "context": { - "from_parameter": "context" - }, - "data": { - "from_parameter": "data" - }, - "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"scikit-image\",\n# \"scipy\",\n# ]\n# ///\n\nfrom openeo.udf import XarrayDataCube, inspect\nimport numpy as np\nimport xarray as xr\nfrom scipy.ndimage import binary_fill_holes, binary_opening\n\nDEFAULT_WATER_VALUE = 1\n\n\ndef _build_coastal_water_mask(\n arr: np.ndarray,\n water_value: int = DEFAULT_WATER_VALUE,\n nodata: float | None = 999,\n iterations: int = 1,\n) -> np.ndarray:\n \"\"\"\n Build coastal-water-only mask from land/water mask.\n Inland water is filled.\n\n Returns:\n 0/1 mask where 1 is coastal water and 0 is land\n \"\"\"\n water = arr == water_value\n if nodata is not None:\n water = water & (arr != nodata)\n\n # Remove small estuaries\n water = binary_opening(water, iterations=iterations)\n\n # Remove bridges\n land = ~water\n land = binary_opening(land, iterations=iterations)\n\n land_filled = binary_fill_holes(land)\n water_filled = ~land_filled\n return water_filled.astype(np.uint8)\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"Apply morphological algorithms on DataCube\"\"\"\n\n cube_array: xr.DataArray = cube.get_array()\n inspect(data=[cube_array.shape], message=\"Input UDF cube_array shape\")\n\n cube_array = cube_array.astype(np.uint8)\n\n cube_array_3d = cube_array.squeeze(dim=\"bands\")\n\n modified = xr.apply_ufunc(\n _build_coastal_water_mask,\n cube_array_3d,\n input_core_dims=[[\"y\", \"x\"]],\n output_core_dims=[[\"y\", \"x\"]],\n vectorize=True,\n dask=\"parallelized\",\n output_dtypes=[np.uint8],\n kwargs={\n \"water_value\": DEFAULT_WATER_VALUE,\n \"nodata\": 999,\n \"iterations\": context[\"iterations\"],\n },\n )\n\n modified_da = xr.DataArray(\n modified,\n coords={\n \"t\": cube_array.coords[\"t\"],\n \"y\": cube_array.coords[\"y\"],\n \"x\": cube_array.coords[\"x\"],\n },\n dims=[\"t\", \"y\", \"x\"],\n )\n modified_da = modified_da.expand_dims(dim={\"bands\": cube_array.coords[\"bands\"]})\n modified_da = modified_da.transpose(\"t\", \"bands\", \"y\", \"x\")\n\n return XarrayDataCube(modified_da)\n" - }, - "result": true - } - } - } - } - }, - "loadcollection4": { - "process_id": "load_collection", - "arguments": { - "bands": [ - "B08", - "B02", - "SCL" - ], - "id": "SENTINEL2_L2A", - "properties": { - "eo:cloud_cover": { - "process_graph": { - "lte4": { - "process_id": "lte", - "arguments": { - "x": { - "from_parameter": "value" - }, - "y": { - "from_parameter": "max_cloud_coverage" - } - }, - "result": true - } - } - } - }, - "spatial_extent": { - "from_parameter": "spatial_extent" - }, - "temporal_extent": { - "from_parameter": "temporal_extent" - } - } - }, - "resamplespatial4": { - "process_id": "resample_spatial", - "arguments": { - "data": { - "from_node": "loadcollection4" - }, - "method": "near", - "projection": 3857 - } - }, - "reducedimension7": { - "process_id": "reduce_dimension", - "arguments": { - "data": { - "from_node": "resamplespatial4" - }, - "dimension": "bands", - "reducer": { - "process_graph": { - "arrayelement9": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 2 - } - }, - "eq11": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement9" - }, - "y": -997 - } - }, - "eq12": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement9" - }, - "y": -992 - } - }, - "or7": { - "process_id": "or", - "arguments": { - "x": { - "from_node": "eq11" - }, - "y": { - "from_node": "eq12" - } - } - }, - "eq13": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement9" - }, - "y": -991 - } - }, - "or8": { - "process_id": "or", - "arguments": { - "x": { - "from_node": "or7" - }, - "y": { - "from_node": "eq13" - } - }, - "result": true - } - } - } - } - }, - "mask6": { - "process_id": "mask", - "arguments": { - "data": { - "from_node": "resamplespatial4" - }, - "mask": { - "from_node": "reducedimension7" - } - } - }, - "filterbands4": { - "process_id": "filter_bands", - "arguments": { - "bands": [ - "B08", - "B02" - ], - "data": { - "from_node": "mask6" - } - } - }, - "reducedimension8": { - "process_id": "reduce_dimension", - "arguments": { - "data": { - "from_node": "filterbands4" - }, - "dimension": "bands", - "reducer": { - "process_graph": { - "arrayelement10": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 0 - } - }, - "arrayelement11": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 1 - } - }, - "subtract3": { - "process_id": "subtract", - "arguments": { - "x": { - "from_node": "arrayelement10" - }, - "y": { - "from_node": "arrayelement11" - } - } - }, - "add3": { - "process_id": "add", - "arguments": { - "x": { - "from_node": "arrayelement10" - }, - "y": { - "from_node": "arrayelement11" - } - } - }, - "divide3": { - "process_id": "divide", - "arguments": { - "x": { - "from_node": "subtract3" - }, - "y": { - "from_node": "add3" - } - }, - "result": true - } - } - } - } - }, - "mask7": { - "process_id": "mask", - "arguments": { - "data": { - "from_node": "reducedimension8" - }, - "mask": { - "from_node": "reducedimension7" - } - } - }, - "lt2": { - "process_id": "lt", - "arguments": { - "x": { - "from_node": "mask7" - }, - "y": { - "from_parameter": "bndvi_threshold" - } - } - }, - "apply3": { - "process_id": "apply", - "arguments": { - "data": { - "from_node": "lt2" - }, - "process": { - "process_graph": { - "if3": { - "process_id": "if", - "arguments": { - "accept": 1, - "reject": 0, - "value": { - "from_parameter": "x" - } - }, - "result": true - } - } - } - } - }, - "applydimension4": { - "process_id": "apply_dimension", - "arguments": { - "context": { - "iterations": { - "from_parameter": "iterations" - } - }, - "data": { - "from_node": "apply3" - }, - "dimension": "t", - "process": { - "process_graph": { - "runudf4": { - "process_id": "run_udf", - "arguments": { - "context": { - "from_parameter": "context" - }, - "data": { - "from_parameter": "data" - }, - "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"scikit-image\",\n# \"scipy\",\n# ]\n# ///\n\nfrom openeo.udf import XarrayDataCube, inspect\nimport numpy as np\nimport xarray as xr\nfrom scipy.ndimage import binary_fill_holes, binary_opening\n\nDEFAULT_WATER_VALUE = 1\n\n\ndef _build_coastal_water_mask(\n arr: np.ndarray,\n water_value: int = DEFAULT_WATER_VALUE,\n nodata: float | None = 999,\n iterations: int = 1,\n) -> np.ndarray:\n \"\"\"\n Build coastal-water-only mask from land/water mask.\n Inland water is filled.\n\n Returns:\n 0/1 mask where 1 is coastal water and 0 is land\n \"\"\"\n water = arr == water_value\n if nodata is not None:\n water = water & (arr != nodata)\n\n # Remove small estuaries\n water = binary_opening(water, iterations=iterations)\n\n # Remove bridges\n land = ~water\n land = binary_opening(land, iterations=iterations)\n\n land_filled = binary_fill_holes(land)\n water_filled = ~land_filled\n return water_filled.astype(np.uint8)\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"Apply morphological algorithms on DataCube\"\"\"\n\n cube_array: xr.DataArray = cube.get_array()\n inspect(data=[cube_array.shape], message=\"Input UDF cube_array shape\")\n\n cube_array = cube_array.astype(np.uint8)\n\n cube_array_3d = cube_array.squeeze(dim=\"bands\")\n\n modified = xr.apply_ufunc(\n _build_coastal_water_mask,\n cube_array_3d,\n input_core_dims=[[\"y\", \"x\"]],\n output_core_dims=[[\"y\", \"x\"]],\n vectorize=True,\n dask=\"parallelized\",\n output_dtypes=[np.uint8],\n kwargs={\n \"water_value\": DEFAULT_WATER_VALUE,\n \"nodata\": 999,\n \"iterations\": context[\"iterations\"],\n },\n )\n\n modified_da = xr.DataArray(\n modified,\n coords={\n \"t\": cube_array.coords[\"t\"],\n \"y\": cube_array.coords[\"y\"],\n \"x\": cube_array.coords[\"x\"],\n },\n dims=[\"t\", \"y\", \"x\"],\n )\n modified_da = modified_da.expand_dims(dim={\"bands\": cube_array.coords[\"bands\"]})\n modified_da = modified_da.transpose(\"t\", \"bands\", \"y\", \"x\")\n\n return XarrayDataCube(modified_da)\n" - }, - "result": true - } - } - } - } - }, - "loadcollection5": { - "process_id": "load_collection", - "arguments": { - "bands": [ - "B08", - "B03", - "SCL" - ], - "id": "SENTINEL2_L2A", - "properties": { - "eo:cloud_cover": { - "process_graph": { - "lte5": { - "process_id": "lte", - "arguments": { - "x": { - "from_parameter": "value" - }, - "y": { - "from_parameter": "max_cloud_coverage" - } - }, - "result": true - } - } - } - }, - "spatial_extent": { - "from_parameter": "spatial_extent" - }, - "temporal_extent": { - "from_parameter": "temporal_extent" - } - } - }, - "resamplespatial5": { - "process_id": "resample_spatial", - "arguments": { - "data": { - "from_node": "loadcollection5" - }, - "method": "near", - "projection": 3857 - } - }, - "reducedimension9": { - "process_id": "reduce_dimension", - "arguments": { - "data": { - "from_node": "resamplespatial5" - }, - "dimension": "bands", - "reducer": { - "process_graph": { - "arrayelement12": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 2 - } - }, - "eq14": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement12" - }, - "y": -997 - } - }, - "eq15": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement12" - }, - "y": -992 - } - }, - "or9": { - "process_id": "or", - "arguments": { - "x": { - "from_node": "eq14" - }, - "y": { - "from_node": "eq15" - } - } - }, - "eq16": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement12" - }, - "y": -991 - } - }, - "or10": { - "process_id": "or", - "arguments": { - "x": { - "from_node": "or9" - }, - "y": { - "from_node": "eq16" - } - }, - "result": true - } - } - } - } - }, - "mask8": { - "process_id": "mask", - "arguments": { - "data": { - "from_node": "resamplespatial5" - }, - "mask": { - "from_node": "reducedimension9" - } - } - }, - "filterbands5": { - "process_id": "filter_bands", - "arguments": { - "bands": [ - "B08", - "B03" - ], - "data": { - "from_node": "mask8" - } - } - }, - "reducedimension10": { - "process_id": "reduce_dimension", - "arguments": { - "data": { - "from_node": "filterbands5" - }, - "dimension": "bands", - "reducer": { - "process_graph": { - "arrayelement13": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 0 - } - }, - "arrayelement14": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 1 - } - }, - "subtract4": { - "process_id": "subtract", - "arguments": { - "x": { - "from_node": "arrayelement13" - }, - "y": { - "from_node": "arrayelement14" - } - } - }, - "add4": { - "process_id": "add", - "arguments": { - "x": { - "from_node": "arrayelement13" - }, - "y": { - "from_node": "arrayelement14" - } - } - }, - "divide4": { - "process_id": "divide", - "arguments": { - "x": { - "from_node": "subtract4" - }, - "y": { - "from_node": "add4" - } - }, - "result": true - } - } - } - } - }, - "mask9": { - "process_id": "mask", - "arguments": { - "data": { - "from_node": "reducedimension10" - }, - "mask": { - "from_node": "reducedimension9" - } - } - }, - "lt3": { - "process_id": "lt", - "arguments": { - "x": { - "from_node": "mask9" - }, - "y": { - "from_parameter": "gndvi_threshold" - } - } - }, - "apply4": { - "process_id": "apply", - "arguments": { - "data": { - "from_node": "lt3" - }, - "process": { - "process_graph": { - "if4": { - "process_id": "if", - "arguments": { - "accept": 1, - "reject": 0, - "value": { - "from_parameter": "x" - } - }, - "result": true - } - } - } - } - }, - "applydimension5": { - "process_id": "apply_dimension", - "arguments": { - "context": { - "iterations": { - "from_parameter": "iterations" - } - }, - "data": { - "from_node": "apply4" - }, - "dimension": "t", - "process": { - "process_graph": { - "runudf5": { - "process_id": "run_udf", - "arguments": { - "context": { - "from_parameter": "context" - }, - "data": { - "from_parameter": "data" - }, - "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"scikit-image\",\n# \"scipy\",\n# ]\n# ///\n\nfrom openeo.udf import XarrayDataCube, inspect\nimport numpy as np\nimport xarray as xr\nfrom scipy.ndimage import binary_fill_holes, binary_opening\n\nDEFAULT_WATER_VALUE = 1\n\n\ndef _build_coastal_water_mask(\n arr: np.ndarray,\n water_value: int = DEFAULT_WATER_VALUE,\n nodata: float | None = 999,\n iterations: int = 1,\n) -> np.ndarray:\n \"\"\"\n Build coastal-water-only mask from land/water mask.\n Inland water is filled.\n\n Returns:\n 0/1 mask where 1 is coastal water and 0 is land\n \"\"\"\n water = arr == water_value\n if nodata is not None:\n water = water & (arr != nodata)\n\n # Remove small estuaries\n water = binary_opening(water, iterations=iterations)\n\n # Remove bridges\n land = ~water\n land = binary_opening(land, iterations=iterations)\n\n land_filled = binary_fill_holes(land)\n water_filled = ~land_filled\n return water_filled.astype(np.uint8)\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"Apply morphological algorithms on DataCube\"\"\"\n\n cube_array: xr.DataArray = cube.get_array()\n inspect(data=[cube_array.shape], message=\"Input UDF cube_array shape\")\n\n cube_array = cube_array.astype(np.uint8)\n\n cube_array_3d = cube_array.squeeze(dim=\"bands\")\n\n modified = xr.apply_ufunc(\n _build_coastal_water_mask,\n cube_array_3d,\n input_core_dims=[[\"y\", \"x\"]],\n output_core_dims=[[\"y\", \"x\"]],\n vectorize=True,\n dask=\"parallelized\",\n output_dtypes=[np.uint8],\n kwargs={\n \"water_value\": DEFAULT_WATER_VALUE,\n \"nodata\": 999,\n \"iterations\": context[\"iterations\"],\n },\n )\n\n modified_da = xr.DataArray(\n modified,\n coords={\n \"t\": cube_array.coords[\"t\"],\n \"y\": cube_array.coords[\"y\"],\n \"x\": cube_array.coords[\"x\"],\n },\n dims=[\"t\", \"y\", \"x\"],\n )\n modified_da = modified_da.expand_dims(dim={\"bands\": cube_array.coords[\"bands\"]})\n modified_da = modified_da.transpose(\"t\", \"bands\", \"y\", \"x\")\n\n return XarrayDataCube(modified_da)\n" - }, - "result": true - } - } - } - } - }, - "loadcollection6": { - "process_id": "load_collection", - "arguments": { - "bands": [ - "B03", - "B08", - "SCL" - ], - "id": "SENTINEL2_L2A", - "properties": { - "eo:cloud_cover": { - "process_graph": { - "lte6": { - "process_id": "lte", - "arguments": { - "x": { - "from_parameter": "value" - }, - "y": { - "from_parameter": "max_cloud_coverage" - } - }, - "result": true - } - } - } - }, - "spatial_extent": { - "from_parameter": "spatial_extent" - }, - "temporal_extent": { - "from_parameter": "temporal_extent" - } - } - }, - "resamplespatial6": { - "process_id": "resample_spatial", - "arguments": { - "data": { - "from_node": "loadcollection6" - }, - "method": "near", - "projection": 3857 - } - }, - "reducedimension11": { - "process_id": "reduce_dimension", - "arguments": { - "data": { - "from_node": "resamplespatial6" - }, - "dimension": "bands", - "reducer": { - "process_graph": { - "arrayelement15": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 2 - } - }, - "eq17": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement15" - }, - "y": -997 - } - }, - "eq18": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement15" - }, - "y": -992 - } - }, - "or11": { - "process_id": "or", - "arguments": { - "x": { - "from_node": "eq17" - }, - "y": { - "from_node": "eq18" - } - } - }, - "eq19": { - "process_id": "eq", - "arguments": { - "x": { - "from_node": "arrayelement15" - }, - "y": -991 - } - }, - "or12": { - "process_id": "or", - "arguments": { - "x": { - "from_node": "or11" - }, - "y": { - "from_node": "eq19" - } - }, - "result": true - } - } - } - } - }, - "mask10": { - "process_id": "mask", - "arguments": { - "data": { - "from_node": "resamplespatial6" - }, - "mask": { - "from_node": "reducedimension11" - } - } - }, - "filterbands6": { - "process_id": "filter_bands", - "arguments": { - "bands": [ - "B03", - "B08" - ], - "data": { - "from_node": "mask10" - } - } - }, - "reducedimension12": { - "process_id": "reduce_dimension", - "arguments": { - "data": { - "from_node": "filterbands6" - }, - "dimension": "bands", - "reducer": { - "process_graph": { - "arrayelement16": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 0 - } - }, - "arrayelement17": { - "process_id": "array_element", - "arguments": { - "data": { - "from_parameter": "data" - }, - "index": 1 - } - }, - "subtract5": { - "process_id": "subtract", - "arguments": { - "x": { - "from_node": "arrayelement16" - }, - "y": { - "from_node": "arrayelement17" - } - } - }, - "add5": { - "process_id": "add", - "arguments": { - "x": { - "from_node": "arrayelement16" - }, - "y": { - "from_node": "arrayelement17" - } - } - }, - "divide5": { - "process_id": "divide", - "arguments": { - "x": { - "from_node": "subtract5" - }, - "y": { - "from_node": "add5" - } - }, - "result": true - } - } - } - } - }, - "mask11": { - "process_id": "mask", - "arguments": { - "data": { - "from_node": "reducedimension12" - }, - "mask": { - "from_node": "reducedimension11" - } - } - }, - "gt2": { - "process_id": "gt", - "arguments": { - "x": { - "from_node": "mask11" - }, - "y": { - "from_parameter": "ndwi_threshold" - } - } - }, - "apply5": { - "process_id": "apply", - "arguments": { - "data": { - "from_node": "gt2" - }, - "process": { - "process_graph": { - "if5": { - "process_id": "if", - "arguments": { - "accept": 1, - "reject": 0, - "value": { - "from_parameter": "x" - } - }, - "result": true - } - } - } - } - }, - "applydimension6": { - "process_id": "apply_dimension", - "arguments": { - "context": { - "iterations": { - "from_parameter": "iterations" - } - }, - "data": { - "from_node": "apply5" - }, - "dimension": "t", - "process": { - "process_graph": { - "runudf6": { - "process_id": "run_udf", - "arguments": { - "context": { - "from_parameter": "context" - }, - "data": { - "from_parameter": "data" - }, - "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"scikit-image\",\n# \"scipy\",\n# ]\n# ///\n\nfrom openeo.udf import XarrayDataCube, inspect\nimport numpy as np\nimport xarray as xr\nfrom scipy.ndimage import binary_fill_holes, binary_opening\n\nDEFAULT_WATER_VALUE = 1\n\n\ndef _build_coastal_water_mask(\n arr: np.ndarray,\n water_value: int = DEFAULT_WATER_VALUE,\n nodata: float | None = 999,\n iterations: int = 1,\n) -> np.ndarray:\n \"\"\"\n Build coastal-water-only mask from land/water mask.\n Inland water is filled.\n\n Returns:\n 0/1 mask where 1 is coastal water and 0 is land\n \"\"\"\n water = arr == water_value\n if nodata is not None:\n water = water & (arr != nodata)\n\n # Remove small estuaries\n water = binary_opening(water, iterations=iterations)\n\n # Remove bridges\n land = ~water\n land = binary_opening(land, iterations=iterations)\n\n land_filled = binary_fill_holes(land)\n water_filled = ~land_filled\n return water_filled.astype(np.uint8)\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"Apply morphological algorithms on DataCube\"\"\"\n\n cube_array: xr.DataArray = cube.get_array()\n inspect(data=[cube_array.shape], message=\"Input UDF cube_array shape\")\n\n cube_array = cube_array.astype(np.uint8)\n\n cube_array_3d = cube_array.squeeze(dim=\"bands\")\n\n modified = xr.apply_ufunc(\n _build_coastal_water_mask,\n cube_array_3d,\n input_core_dims=[[\"y\", \"x\"]],\n output_core_dims=[[\"y\", \"x\"]],\n vectorize=True,\n dask=\"parallelized\",\n output_dtypes=[np.uint8],\n kwargs={\n \"water_value\": DEFAULT_WATER_VALUE,\n \"nodata\": 999,\n \"iterations\": context[\"iterations\"],\n },\n )\n\n modified_da = xr.DataArray(\n modified,\n coords={\n \"t\": cube_array.coords[\"t\"],\n \"y\": cube_array.coords[\"y\"],\n \"x\": cube_array.coords[\"x\"],\n },\n dims=[\"t\", \"y\", \"x\"],\n )\n modified_da = modified_da.expand_dims(dim={\"bands\": cube_array.coords[\"bands\"]})\n modified_da = modified_da.transpose(\"t\", \"bands\", \"y\", \"x\")\n\n return XarrayDataCube(modified_da)\n" - }, - "result": true - } - } - } - } - }, - "eq20": { - "process_id": "eq", - "arguments": { - "x": { - "from_parameter": "s2_method" - }, - "y": "S2_GNDVI" - } - }, - "if6": { - "process_id": "if", - "arguments": { - "accept": { - "from_node": "applydimension5" - }, - "reject": { - "from_node": "applydimension6" - }, - "value": { - "from_node": "eq20" - } - } - }, - "eq21": { - "process_id": "eq", - "arguments": { - "x": { - "from_parameter": "s2_method" - }, - "y": "S2_BNDVI" - } - }, - "if7": { - "process_id": "if", - "arguments": { - "accept": { - "from_node": "applydimension4" - }, - "reject": { - "from_node": "if6" - }, - "value": { - "from_node": "eq21" - } - } - }, - "eq22": { - "process_id": "eq", - "arguments": { - "x": { - "from_parameter": "s2_method" - }, - "y": "S2_NDVI" - } - }, - "if8": { - "process_id": "if", - "arguments": { - "accept": { - "from_node": "applydimension3" - }, - "reject": { - "from_node": "if7" - }, - "value": { - "from_node": "eq22" - } - } - }, - "eq23": { - "process_id": "eq", - "arguments": { - "x": { - "from_parameter": "s2_method" - }, - "y": "S2_MNDWI" - } - }, - "if9": { - "process_id": "if", - "arguments": { - "accept": { - "from_node": "applydimension2" - }, - "reject": { - "from_node": "if8" - }, - "value": { - "from_node": "eq23" - } - } - }, - "eq24": { - "process_id": "eq", - "arguments": { - "x": { - "from_parameter": "s2_method" - }, - "y": "S2_SCL" - } - }, - "if10": { - "process_id": "if", - "arguments": { - "accept": { - "from_node": "applydimension1" - }, - "reject": { - "from_node": "if9" - }, - "value": { - "from_node": "eq24" - } - } - }, - "applydimension7": { - "process_id": "apply_dimension", - "arguments": { - "context": { - "simplify_tolerance": 10 - }, - "data": { - "from_node": "if10" - }, - "dimension": "geometry", - "process": { - "process_graph": { - "runudf7": { - "process_id": "run_udf", - "arguments": { - "context": { - "from_parameter": "context" - }, - "data": { - "from_parameter": "data" - }, - "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n shape,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom shapely.ops import unary_union\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 0.0001) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n [feature_collection] = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=udf_data.user_context.get(\"simplify_tolerance\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([FeatureCollection(id=\"_\", data=gdf)])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n\n\n# from pathlib import Path\n# files_dir = Path(\"P:/FastTrack/DAP10/openeo\")\n# polygons_path = files_dir / \"vectorcube.geojson\"\n# gdf = gpd.read_file(polygons_path)\n# waterlines = waterline_from_vectorized_water_raster(gdf, simplify_tolerance=10)\n# waterlines.to_file(files_dir / \"vatercube_waterlines.geojson\")" - }, - "result": true - } - } - } - }, - "result": true - } - }, - "id": "waterlines", - "summary": "Waterlines extracted from Sentinel-2.", - "description": "# Waterlines openEO UDP\n\n## Purpose\nExtract coastline waterlines from Sentinel-2 imagery using a selectable water/land classification method, morphological refinement, and UDF-based vectorization.\n\n---\n\n## Methodology\n\nThe algorithm processes Sentinel-2 time series data to generate water/land masks and extract waterlines.\n\n### Water/Land Classification\n\nWater/land masks are derived using one of the following methods:\n\n#### Index-based methods\n\nWater is identified based on spectral indices computed from Sentinel-2 bands:\n\n- **NDWI (Normalized Difference Water Index)** \n $$\n NDWI = \\frac{G - NIR}{G + NIR}\n $$\n\n- **MNDWI (Modified NDWI)** \n $$\n MNDWI = \\frac{G - SWIR}{G + SWIR}\n $$\n\n- **NDVI (Normalized Difference Vegetation Index)** \n $$\n NDVI = \\frac{NIR - R}{NIR + R}\n $$\n\n- **GNDVI (Green NDVI)** \n $$\n GNDVI = \\frac{NIR - G}{NIR + G}\n $$\n\n- **BNDVI (Blue NDVI)** \n $$\n BNDVI = \\frac{NIR - B}{NIR + B}\n $$\n\nWhere:\n- **B** = Blue band (S2 B02) \n- **G** = Green band (S2 B03) \n- **R** = Red band (S2 B04) \n- **NIR** = Near Infrared (S2 B08) \n- **SWIR** = Shortwave Infrared (S2 B11)\n\nWater classification rules:\n- NDWI, MNDWI \u00e2\u2020\u2019 water if index > threshold \n- NDVI, GNDVI, BNDVI \u00e2\u2020\u2019 water if index < threshold \n\nDefault thresholds are provided for each index but can be overridden via parameters.\n\n---\n\n#### SCL-based method\n\n- Uses the Sentinel-2 Scene Classification Layer (SCL) \n- Water is identified as class `6` \n\n---\n\n### Morphological Processing\n\nFor each timestamp, the water/land mask is refined using morphological operations to:\n\n- remove small isolated objects \n- fill small holes \n- smooth boundaries \n- reduce artifacts such as narrow bridges and estuaries \n\nThis improves the quality and stability of the resulting waterlines.\n\n---\n\n### Waterline Extraction\n\nThe cleaned masks are converted into vector waterlines using a UDF, producing geometries for each time step.\n\n---\n\n## Output\n\nThe process outputs **FeatureCollections** of coastline waterlines with the following properties:\n\n- **time** \u00e2\u20ac\u201c Acquisition timestamp (Sentinel-2 datetime) \n- **type** \u00e2\u20ac\u201c Feature type (`waterline_segment`) \n- **sea_direction_8** \u00e2\u20ac\u201c Sea direction (N, NE, E, SE, S, SW, W, NW) \n- **sea_azimuth_deg** \u00e2\u20ac\u201c Sea direction in degrees (azimuth, clockwise from north) \n- **geometry** \u00e2\u20ac\u201c Waterline geometry (LineString or MultiLineString) in EPSG:3857 \n\n---\n\n## Usage\n\nSee the APEx documentation and repository:\n\n- [UDP Writer Guide](https://esa-apex.github.io/apex_documentation/guides/udp_writer_guide.html) \n- [APEx Algorithms GitHub](https://github.com/ESA-APEx/apex_algorithms)\n\n---\n\n## Authors / Contact\n\n- **Milena Napiorkowska** (openEO UDP) \u00e2\u20ac\u201c Argans Ltd \n mnapiorkowska@argans.co.uk \n\n- **Martin Jones** (Project Manager) \u00e2\u20ac\u201c Argans Ltd \n mjones@argans.co.uk \n\n- **Holly Baxter** (Methodology) \u00e2\u20ac\u201c Argans Ltd \n hbaxtar@argans.co.uk \n\n- **Cameron Mackenzie** (Methodology) \n cmackenzie@argans.co.uk \n\n---\n\n## Acknowledgments\n\nThis work was developed as part of an ESA-funded Fast Track project.\n\n---\n\n## Known Limitations\n\n- Results are most reliable for scenes with low cloud coverage \n- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels ", - "categories": [ - "sentinel-2", - "coastline", - "waterlines" - ], - "parameters": [ - { - "name": "spatial_extent", - "description": "Bounding box of the area of interest. Defined as west, south, east, north in EPSG:4326.", - "schema": { - "type": "object", - "subtype": "bounding-box", - "required": [ - "west", - "south", - "east", - "north" - ], - "properties": { - "west": { - "type": "number", - "description": "West (lower left corner, coordinate axis 1)." - }, - "south": { - "type": "number", - "description": "South (lower left corner, coordinate axis 2)." - }, - "east": { - "type": "number", - "description": "East (upper right corner, coordinate axis 1)." - }, - "north": { - "type": "number", - "description": "North (upper right corner, coordinate axis 2)." - }, - "crs": { - "description": "Coordinate reference system of the extent, specified as as [EPSG code](http://www.epsg-registry.org/) or [WKT2 CRS string](http://docs.opengeospatial.org/is/18-010r7/18-010r7.html). Defaults to `4326` (EPSG code 4326) unless the client explicitly requests a different coordinate reference system.", - "anyOf": [ - { - "type": "integer", - "subtype": "epsg-code", - "title": "EPSG Code", - "minimum": 1000 - }, - { - "type": "string", - "subtype": "wkt2-definition", - "title": "WKT2 definition" - } - ], - "default": 4326 - } - } - } - }, - { - "name": "temporal_extent", - "description": "Date range over which to extract waterlines.", - "schema": { - "type": "array", - "subtype": "temporal-interval", - "uniqueItems": true, - "minItems": 2, - "maxItems": 2, - "items": { - "anyOf": [ - { - "type": "string", - "subtype": "date-time", - "format": "date-time" - }, - { - "type": "string", - "subtype": "date", - "format": "date" - }, - { - "type": "null" - } - ] - } - }, - "default": [ - "2015-06-23", - "2025-12-31" - ], - "optional": true - }, - { - "name": "max_cloud_coverage", - "description": "Maximum allowed cloud coverage.", - "schema": { - "type": "number" - }, - "default": 10, - "optional": true - }, - { - "name": "s2_method", - "description": "Method used to create the water/land mask from Sentinel-2 imagery.", - "schema": { - "type": "string", - "enum": [ - "S2_NDWI", - "S2_MNDWI", - "S2_SCL", - "S2_NDVI", - "S2_BNDVI", - "S2_GNDVI" - ] - }, - "default": "S2_NDWI", - "optional": true - }, - { - "name": "iterations", - "description": "Number of iterations for morphological operations.", - "schema": { - "type": "integer" - }, - "default": 2, - "optional": true - }, - { - "name": "ndwi_threshold", - "description": "NDWI threshold (water if NDWI > threshold).", - "schema": { - "type": "number" - }, - "default": 0.01, - "optional": true - }, - { - "name": "mndwi_threshold", - "description": "MNDWI threshold (water if MNDWI > threshold).", - "schema": { - "type": "number" - }, - "default": 0.1, - "optional": true - }, - { - "name": "ndvi_threshold", - "description": "NDVI threshold (water if NDVI < threshold).", - "schema": { - "type": "number" - }, - "default": 0.03, - "optional": true - }, - { - "name": "bndvi_threshold", - "description": "BNDVI threshold (water if BNDVI < threshold).", - "schema": { - "type": "number" - }, - "default": 0.03, - "optional": true - }, - { - "name": "gndvi_threshold", - "description": "GNDVI threshold (water if GNDVI < threshold).", - "schema": { - "type": "number" - }, - "default": 0.03, - "optional": true - } - ] -} \ No newline at end of file diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines_s2_ndwi.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines_s2_ndwi.json new file mode 100644 index 000000000..86fbd6087 --- /dev/null +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines_s2_ndwi.json @@ -0,0 +1,455 @@ +{ + "process_graph": { + "loadcollection1": { + "process_id": "load_collection", + "arguments": { + "bands": [ + "B03", + "B08", + "SCL" + ], + "id": "SENTINEL2_L2A", + "properties": { + "eo:cloud_cover": { + "process_graph": { + "lte1": { + "process_id": "lte", + "arguments": { + "x": { + "from_parameter": "value" + }, + "y": { + "from_parameter": "max_cloud_coverage" + } + }, + "result": true + } + } + } + }, + "spatial_extent": { + "from_parameter": "spatial_extent" + }, + "temporal_extent": { + "from_parameter": "temporal_extent" + } + } + }, + "resamplespatial1": { + "process_id": "resample_spatial", + "arguments": { + "data": { + "from_node": "loadcollection1" + }, + "method": "near", + "projection": 3857 + } + }, + "reducedimension1": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "resamplespatial1" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement1": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 2 + } + }, + "eq1": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement1" + }, + "y": -997 + } + }, + "eq2": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement1" + }, + "y": -992 + } + }, + "or1": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "eq1" + }, + "y": { + "from_node": "eq2" + } + } + }, + "eq3": { + "process_id": "eq", + "arguments": { + "x": { + "from_node": "arrayelement1" + }, + "y": -991 + } + }, + "or2": { + "process_id": "or", + "arguments": { + "x": { + "from_node": "or1" + }, + "y": { + "from_node": "eq3" + } + }, + "result": true + } + } + } + } + }, + "mask1": { + "process_id": "mask", + "arguments": { + "data": { + "from_node": "resamplespatial1" + }, + "mask": { + "from_node": "reducedimension1" + } + } + }, + "filterbands1": { + "process_id": "filter_bands", + "arguments": { + "bands": [ + "B03", + "B08" + ], + "data": { + "from_node": "mask1" + } + } + }, + "reducedimension2": { + "process_id": "reduce_dimension", + "arguments": { + "data": { + "from_node": "filterbands1" + }, + "dimension": "bands", + "reducer": { + "process_graph": { + "arrayelement2": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 0 + } + }, + "arrayelement3": { + "process_id": "array_element", + "arguments": { + "data": { + "from_parameter": "data" + }, + "index": 1 + } + }, + "subtract1": { + "process_id": "subtract", + "arguments": { + "x": { + "from_node": "arrayelement2" + }, + "y": { + "from_node": "arrayelement3" + } + } + }, + "add1": { + "process_id": "add", + "arguments": { + "x": { + "from_node": "arrayelement2" + }, + "y": { + "from_node": "arrayelement3" + } + } + }, + "divide1": { + "process_id": "divide", + "arguments": { + "x": { + "from_node": "subtract1" + }, + "y": { + "from_node": "add1" + } + }, + "result": true + } + } + } + } + }, + "mask2": { + "process_id": "mask", + "arguments": { + "data": { + "from_node": "reducedimension2" + }, + "mask": { + "from_node": "reducedimension1" + } + } + }, + "gt1": { + "process_id": "gt", + "arguments": { + "x": { + "from_node": "mask2" + }, + "y": { + "from_parameter": "ndwi_threshold" + } + } + }, + "apply1": { + "process_id": "apply", + "arguments": { + "data": { + "from_node": "gt1" + }, + "process": { + "process_graph": { + "if1": { + "process_id": "if", + "arguments": { + "accept": 1, + "reject": 0, + "value": { + "from_parameter": "x" + } + }, + "result": true + } + } + } + } + }, + "applydimension1": { + "process_id": "apply_dimension", + "arguments": { + "context": { + "iterations": { + "from_parameter": "iterations" + } + }, + "data": { + "from_node": "apply1" + }, + "dimension": "t", + "process": { + "process_graph": { + "runudf1": { + "process_id": "run_udf", + "arguments": { + "context": { + "from_parameter": "context" + }, + "data": { + "from_parameter": "data" + }, + "runtime": "Python", + "udf": "# /// script\n# dependencies = [\n# \"scikit-image\",\n# \"scipy\",\n# ]\n# ///\n\nfrom openeo.udf import XarrayDataCube, inspect\nimport numpy as np\nimport xarray as xr\nfrom scipy.ndimage import binary_fill_holes, binary_opening\n\nDEFAULT_WATER_VALUE = 1\n\n\ndef _build_coastal_water_mask(\n arr: np.ndarray,\n water_value: int = DEFAULT_WATER_VALUE,\n nodata: float | None = 999,\n iterations: int = 1,\n) -> np.ndarray:\n \"\"\"\n Build coastal-water-only mask from land/water mask.\n Inland water is filled.\n\n Returns:\n 0/1 mask where 1 is coastal water and 0 is land\n \"\"\"\n water = arr == water_value\n if nodata is not None:\n water = water & (arr != nodata)\n\n # Remove small estuaries\n water = binary_opening(water, iterations=iterations)\n\n # Remove bridges\n land = ~water\n land = binary_opening(land, iterations=iterations)\n\n land_filled = binary_fill_holes(land)\n water_filled = ~land_filled\n return water_filled.astype(np.uint8)\n\n\ndef apply_datacube(cube: XarrayDataCube, context: dict) -> XarrayDataCube:\n \"\"\"Apply morphological algorithms on DataCube\"\"\"\n\n cube_array: xr.DataArray = cube.get_array()\n inspect(data=[cube_array.shape], message=\"Input UDF cube_array shape\")\n\n cube_array = cube_array.astype(np.uint8)\n\n cube_array_3d = cube_array.squeeze(dim=\"bands\")\n\n modified = xr.apply_ufunc(\n _build_coastal_water_mask,\n cube_array_3d,\n input_core_dims=[[\"y\", \"x\"]],\n output_core_dims=[[\"y\", \"x\"]],\n vectorize=True,\n dask=\"parallelized\",\n output_dtypes=[np.uint8],\n kwargs={\n \"water_value\": DEFAULT_WATER_VALUE,\n \"nodata\": 999,\n \"iterations\": context[\"iterations\"],\n },\n )\n\n modified_da = xr.DataArray(\n modified,\n coords={\n \"t\": cube_array.coords[\"t\"],\n \"y\": cube_array.coords[\"y\"],\n \"x\": cube_array.coords[\"x\"],\n },\n dims=[\"t\", \"y\", \"x\"],\n )\n modified_da = modified_da.expand_dims(dim={\"bands\": cube_array.coords[\"bands\"]})\n modified_da = modified_da.transpose(\"t\", \"bands\", \"y\", \"x\")\n\n return XarrayDataCube(modified_da)\n" + }, + "result": true + } + } + } + } + }, + "rastertovector1": { + "process_id": "raster_to_vector", + "arguments": { + "data": { + "from_node": "applydimension1" + } + } + }, + "applydimension2": { + "process_id": "apply_dimension", + "arguments": { + "context": { + "simplify_tolerance": { + "from_parameter": "simplify_tolerance" + } + }, + "data": { + "from_node": "rastertovector1" + }, + "dimension": "geometry", + "process": { + "process_graph": { + "runudf2": { + "process_id": "run_udf", + "arguments": { + "context": { + "from_parameter": "context" + }, + "data": { + "from_parameter": "data" + }, + "runtime": "Python", + "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n shape,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom shapely.ops import unary_union\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 0.0001) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n [feature_collection] = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=udf_data.user_context.get(\"simplify_tolerance\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([FeatureCollection(id=\"_\", data=gdf)])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n\n\n# from pathlib import Path\n# files_dir = Path(\"P:/FastTrack/DAP10/openeo\")\n# polygons_path = files_dir / \"vectorcube.geojson\"\n# gdf = gpd.read_file(polygons_path)\n# waterlines = waterline_from_vectorized_water_raster(gdf, simplify_tolerance=10)\n# waterlines.to_file(files_dir / \"vatercube_waterlines.geojson\")" + }, + "result": true + } + } + } + }, + "result": true + } + }, + "id": "waterlines_s2_ndwi", + "summary": "Waterlines extracted from Sentinel-2 using NDWI.", + "description": "# Waterlines openEO UDP\n\n## Purpose\nExtract coastline waterlines from Sentinel-2 imagery using NDWI-based water detection, morphological refinement, and UDF-based vectorization.\n\n---\n\n## Methodology\n\n### Water/Land Classification\n\nWater masks are derived using the NDWI (Normalized Difference Water Index):\n\n- **NDWI (Normalized Difference Water Index)** \n $$\n NDWI = \\frac{G - NIR}{G + NIR}\n $$\n\nWhere water is classified as:\n- **NDWI > threshold**\n\nWhere:\n- **G** = Green band (S2 B03) \n- **NIR** = Near Infrared (S2 B08) \n\nDefault threshold is equal to **0.01** but can be overridden via parameters.\n\n---\n\n### Why only NDWI?\nThis MVP supports only one method (**S2_NDWI**).\n\nOriginally, multiple methods were selectable via a parameter, but this required openEO `if_()` logic, which converts the result into a `ProcessBuilder` instead of a `DataCube`.\nThis breaks the `raster_to_vector()` step needed for waterline extraction.\n\n\n### Morphological Processing\n\nFor each timestamp, the water/land mask is refined using morphological operations to:\n\n- remove small isolated objects \n- fill small holes \n- smooth boundaries \n- reduce artifacts such as narrow bridges and estuaries \n\nThis improves the quality and stability of the resulting waterlines.\n\n---\n\n### Waterline Extraction\n\nThe cleaned masks are converted into vector waterlines using a UDF, producing geometries for each time step.\n\n---\n\n## Output\n\nThe process outputs **FeatureCollections** of coastline waterlines with the following properties:\n\n- **time** \u00e2\u20ac\u201c Acquisition timestamp (Sentinel-2 datetime) \n- **type** \u00e2\u20ac\u201c Feature type (`waterline_segment`) \n- **sea_direction_8** \u00e2\u20ac\u201c Sea direction (N, NE, E, SE, S, SW, W, NW) \n- **sea_azimuth_deg** \u00e2\u20ac\u201c Sea direction in degrees (azimuth, clockwise from north) \n- **geometry** \u00e2\u20ac\u201c Waterline geometry (LineString or MultiLineString) in EPSG:3857 \n\n---\n\n## Usage\n\nSee the APEx documentation and repository:\n\n- [UDP Writer Guide](https://esa-apex.github.io/apex_documentation/guides/udp_writer_guide.html) \n- [APEx Algorithms GitHub](https://github.com/ESA-APEx/apex_algorithms)\n\n---\n\n## Authors / Contact\n\n- **Milena Napiorkowska** (openEO UDP) \u00e2\u20ac\u201c Argans Ltd \n mnapiorkowska@argans.co.uk \n\n- **Martin Jones** (Project Manager) \u00e2\u20ac\u201c Argans Ltd \n mjones@argans.co.uk \n\n- **Holly Baxter** (Methodology) \u00e2\u20ac\u201c Argans Ltd \n hbaxtar@argans.co.uk \n\n- **Cameron Mackenzie** (Methodology) \n cmackenzie@argans.co.uk \n\n---\n\n## Acknowledgments\n\nThis work was developed as part of an ESA-funded Fast Track project.\n\n---\n\n## Known Limitations\n\n- Results are most reliable for scenes with low cloud coverage \n- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels \n- NDWI might be less reliable in turbid waters", + "categories": [ + "sentinel-2", + "coastline", + "waterlines" + ], + "parameters": [ + { + "name": "spatial_extent", + "description": "Bounding box of the area of interest. Defined as west, south, east, north in EPSG:4326.", + "schema": { + "type": "object", + "subtype": "bounding-box", + "required": [ + "west", + "south", + "east", + "north" + ], + "properties": { + "west": { + "type": "number", + "description": "West (lower left corner, coordinate axis 1)." + }, + "south": { + "type": "number", + "description": "South (lower left corner, coordinate axis 2)." + }, + "east": { + "type": "number", + "description": "East (upper right corner, coordinate axis 1)." + }, + "north": { + "type": "number", + "description": "North (upper right corner, coordinate axis 2)." + }, + "crs": { + "description": "Coordinate reference system of the extent, specified as as [EPSG code](http://www.epsg-registry.org/) or [WKT2 CRS string](http://docs.opengeospatial.org/is/18-010r7/18-010r7.html). Defaults to `4326` (EPSG code 4326) unless the client explicitly requests a different coordinate reference system.", + "anyOf": [ + { + "type": "integer", + "subtype": "epsg-code", + "title": "EPSG Code", + "minimum": 1000 + }, + { + "type": "string", + "subtype": "wkt2-definition", + "title": "WKT2 definition" + } + ], + "default": 4326 + } + } + } + }, + { + "name": "temporal_extent", + "description": "Date range over which to extract waterlines.", + "schema": { + "type": "array", + "subtype": "temporal-interval", + "uniqueItems": true, + "minItems": 2, + "maxItems": 2, + "items": { + "anyOf": [ + { + "type": "string", + "subtype": "date-time", + "format": "date-time" + }, + { + "type": "string", + "subtype": "date", + "format": "date" + }, + { + "type": "null" + } + ] + } + }, + "default": [ + "2015-06-23", + "2025-12-31" + ], + "optional": true + }, + { + "name": "max_cloud_coverage", + "description": "Maximum allowed cloud coverage.", + "schema": { + "type": "number" + }, + "default": 10, + "optional": true + }, + { + "name": "iterations", + "description": "Number of iterations for morphological operations.", + "schema": { + "type": "integer" + }, + "default": 2, + "optional": true + }, + { + "name": "ndwi_threshold", + "description": "NDWI threshold (water if NDWI > threshold).", + "schema": { + "type": "number" + }, + "default": 0.01, + "optional": true + }, + { + "name": "simplify_tolerance", + "description": "Tolerance used to simplify vectorized water polygons before extracting waterlines.", + "schema": { + "type": "number" + }, + "default": 10, + "optional": true + } + ] +} \ No newline at end of file From 6ee5ffbba8ed420ef75c1e67b465dd21b50e6c8a Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 15:28:07 +0100 Subject: [PATCH 18/55] added links to Argans logo --- algorithm_catalog/argans/record.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/record.json b/algorithm_catalog/argans/record.json index d1fab8b7d..d6c5e5687 100644 --- a/algorithm_catalog/argans/record.json +++ b/algorithm_catalog/argans/record.json @@ -56,13 +56,13 @@ "rel": "logo-light", "type": "image/png", "title": "Logo", - "href": "" + "href": "https://argans.co.uk/img/logo.png" }, { "rel": "logo-dark", "type": "image/png", "title": "Logo", - "href": "" + "href": "https://argans.co.uk/img/logo.png" } ] } From f1644eef84c17d659ec091f226fbfbab0e06ce57 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 15:40:31 +0100 Subject: [PATCH 19/55] updated description --- algorithm_catalog/argans/record.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/record.json b/algorithm_catalog/argans/record.json index d6c5e5687..e551d6c95 100644 --- a/algorithm_catalog/argans/record.json +++ b/algorithm_catalog/argans/record.json @@ -9,7 +9,7 @@ "updated": "2026-04-01T00:00:00Z", "type": "algorithm_provider", "title": "Argans Ltd", - "description": "Argans Ltd builds open source EO tools.", + "description": "Argans specializes in satellite-based Earth observation, remote sensing, and GIS for monitoring marine, atmospheric, and land environments.", "keywords": [], "language": { "code": "en-US", @@ -62,7 +62,7 @@ "rel": "logo-dark", "type": "image/png", "title": "Logo", - "href": "https://argans.co.uk/img/logo.png" + "href": "https://argans.co.uk/img/logos/argans_white_new.png" } ] } From c446fbea016dace4c6770d95964a590a1db8f136 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 16:02:01 +0100 Subject: [PATCH 20/55] updated process record --- ...waterlines_s2_ndwi.json => waterlines.json} | 0 .../argans/waterlines/records/thumbnail.png | Bin 0 -> 337951 bytes .../argans/waterlines/records/waterlines.json | 7 +++++-- 3 files changed, 5 insertions(+), 2 deletions(-) rename algorithm_catalog/argans/waterlines/openeo_udp/{waterlines_s2_ndwi.json => waterlines.json} (100%) create mode 100644 algorithm_catalog/argans/waterlines/records/thumbnail.png diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines_s2_ndwi.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json similarity index 100% rename from algorithm_catalog/argans/waterlines/openeo_udp/waterlines_s2_ndwi.json rename to algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json diff --git a/algorithm_catalog/argans/waterlines/records/thumbnail.png b/algorithm_catalog/argans/waterlines/records/thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..4b69c2ee9a02438a88c3961519afe14ecd6a779d GIT binary patch literal 337951 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

$U#14665KUIJy8f+GGG9%XqG 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�L3)%$uusL#dG;P)8hyNJ8n*^a|K;~^ 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

V!kY+`bhR^3W9#j-QUgtSyyy=SvLak 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

4c}?Zu7fPSzkRr{ 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%0DDAqj@?)p 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

>?fe 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 Date: Wed, 15 Apr 2026 16:05:33 +0100 Subject: [PATCH 21/55] addec Coastal keyword --- .../argans/waterlines/records/waterlines.json | 7 ++++--- schemas/record.json | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/records/waterlines.json b/algorithm_catalog/argans/waterlines/records/waterlines.json index 8e30e1ac8..42c7fa5f6 100644 --- a/algorithm_catalog/argans/waterlines/records/waterlines.json +++ b/algorithm_catalog/argans/waterlines/records/waterlines.json @@ -13,9 +13,10 @@ "title": "Waterlines from Sentinel-2.", "description": "Extracts coastal waterlines from timeseries of Sentinel-2.", "keywords": [ - "Waterlines", - "Coastline", - "Sentinel-2" + "Normalized Difference Water Index (NDWI)", + "Natural Hazards", + "Sentinel-2", + "Coastal" ], "language": { "code": "en-US", diff --git a/schemas/record.json b/schemas/record.json index 89514f501..64d89ae4a 100644 --- a/schemas/record.json +++ b/schemas/record.json @@ -133,7 +133,8 @@ "Land Surface Temperature", "Climate", "Soils", - "Radar" + "Radar", + "Coastal" ] }, "minItems": 1, From 1c9f08e50d56961b3a1af06817af2aae3e27205d Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 16:07:33 +0100 Subject: [PATCH 22/55] updated description --- algorithm_catalog/argans/waterlines/records/waterlines.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algorithm_catalog/argans/waterlines/records/waterlines.json b/algorithm_catalog/argans/waterlines/records/waterlines.json index 42c7fa5f6..8163190ae 100644 --- a/algorithm_catalog/argans/waterlines/records/waterlines.json +++ b/algorithm_catalog/argans/waterlines/records/waterlines.json @@ -11,7 +11,7 @@ "updated": "2026-04-01T00:00:00Z", "type": "service", "title": "Waterlines from Sentinel-2.", - "description": "Extracts coastal waterlines from timeseries of Sentinel-2.", + "description": "Extracts coastal waterlines from Sentinel-2 time series by generating NDWI-based land-water masks, vectorising them, and converting them into waterlines.", "keywords": [ "Normalized Difference Water Index (NDWI)", "Natural Hazards", From b413271d77a7f871845b405c1dd6c2b1c78e5f89 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 16:11:03 +0100 Subject: [PATCH 23/55] updadated benchmark scenario --- .../benchmark_scenarios/waterlines_s2_ndwi.json | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json index 25a5cf55b..2da7c29b5 100644 --- a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json +++ b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json @@ -16,14 +16,10 @@ "north": 29.082, "crs": "EPSG:4326" }, - "s2_method": "S2_NDWI", "iterations": 2, "max_cloud_coverage": 5, "ndwi_threshold": 0.01, - "mndwi_threshold": 0.1, - "ndvi_threshold": 0.03, - "bndvi_threshold": 0.03, - "gndvi_threshold": 0.03 + "simplify_tolerance": 10 }, "result": true } @@ -31,4 +27,4 @@ "reference_data": { } } -] \ No newline at end of file +] From 689219e952abf787e6816ddcbe31e0602c645167 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 16:16:05 +0100 Subject: [PATCH 24/55] removed commented out code --- .../openeo_udp/udf_waterlines_from_water_land_mask.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index a3b0779c3..cfd42ee07 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -310,11 +310,3 @@ def apply_udf_data(udf_data: UdfData) -> UdfData: inspect(data=[udf_data], message="Output UDFData inspection") return udf_data - - -# from pathlib import Path -# files_dir = Path("P:/FastTrack/DAP10/openeo") -# polygons_path = files_dir / "vectorcube.geojson" -# gdf = gpd.read_file(polygons_path) -# waterlines = waterline_from_vectorized_water_raster(gdf, simplify_tolerance=10) -# waterlines.to_file(files_dir / "vatercube_waterlines.geojson") \ No newline at end of file From 7397c79c4b05784a697f922deee82513a593e863 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 16:32:45 +0100 Subject: [PATCH 25/55] changed platform and branch name --- .../argans/waterlines/records/waterlines.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/records/waterlines.json b/algorithm_catalog/argans/waterlines/records/waterlines.json index 8163190ae..a917328c7 100644 --- a/algorithm_catalog/argans/waterlines/records/waterlines.json +++ b/algorithm_catalog/argans/waterlines/records/waterlines.json @@ -81,7 +81,7 @@ "rel": "application", "type": "application/vnd.openeo+json;type=process", "title": "openEO Process Definition", - "href": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/main/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json" + "href": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/argans_waterlines/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json" }, { "rel": "code", @@ -102,10 +102,10 @@ "href": "https://openeofed.dataspace.copernicus.eu" }, { - "rel": "platform", - "type": "application/json", - "title": "TiTiler openEO", - "href": "../../../../platform_catalog/titiler_openeo.json" + "rel": "platform", + "type": "application/json", + "title": "CDSE openEO federation", + "href": "../../../../platform_catalog/cdse_openeo_federation.json" }, { "rel": "provider", @@ -117,7 +117,7 @@ "rel": "thumbnail", "type": "image/png", "title": "Thumbnail image", - "href": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/main/algorithm_catalog/argans/waterlines/records/thumbnail.png" + "href": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/argans_waterlines/algorithm_catalog/argans/waterlines/records/thumbnail.png" } ] } From a2c6e2548a4d3cc6126efae8589d5e39ba339ce1 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 16:37:42 +0100 Subject: [PATCH 26/55] changed branch name --- .../waterlines/benchmark_scenarios/waterlines_s2_ndwi.json | 2 +- algorithm_catalog/argans/waterlines/records/waterlines.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json index 2da7c29b5..e2e69602c 100644 --- a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json +++ b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json @@ -6,7 +6,7 @@ "process_graph": { "waterlines1": { "process_id": "waterlines", - "namespace": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/main/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json", + "namespace": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/argans_waterlines/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json", "arguments": { "temporal_extent": ["2024-06-01", "2024-06-30"], "spatial_extent": { diff --git a/algorithm_catalog/argans/waterlines/records/waterlines.json b/algorithm_catalog/argans/waterlines/records/waterlines.json index a917328c7..87c4b13bf 100644 --- a/algorithm_catalog/argans/waterlines/records/waterlines.json +++ b/algorithm_catalog/argans/waterlines/records/waterlines.json @@ -93,7 +93,7 @@ "rel": "webapp", "type": "text/html", "title": "OpenEO Web Editor", - "href": "https://editor.openeo.org/?wizard=UDP&wizard%7Eprocess=waterlines&wizard%7EprocessUrl=https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/main/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json&server=openeofed.dataspace.copernicus.eu" + "href": "https://editor.openeo.org/?wizard=UDP&wizard%7Eprocess=waterlines&wizard%7EprocessUrl=https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/argans_waterlines/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json&server=openeofed.dataspace.copernicus.eu" }, { "rel": "service", From d02de178f4dea632c63f44c3c4ee20451fb9d0dd Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 16:45:24 +0100 Subject: [PATCH 27/55] switched to main in url --- .../waterlines/benchmark_scenarios/waterlines_s2_ndwi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json index e2e69602c..2da7c29b5 100644 --- a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json +++ b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json @@ -6,7 +6,7 @@ "process_graph": { "waterlines1": { "process_id": "waterlines", - "namespace": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/argans_waterlines/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json", + "namespace": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/main/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json", "arguments": { "temporal_extent": ["2024-06-01", "2024-06-30"], "spatial_extent": { From 61b393ff94063993a1432614c8cecb8785bfa083 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 16:59:13 +0100 Subject: [PATCH 28/55] not main branch --- .../waterlines/benchmark_scenarios/waterlines_s2_ndwi.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json index 2da7c29b5..e2e69602c 100644 --- a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json +++ b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json @@ -6,7 +6,7 @@ "process_graph": { "waterlines1": { "process_id": "waterlines", - "namespace": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/main/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json", + "namespace": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/argans_waterlines/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json", "arguments": { "temporal_extent": ["2024-06-01", "2024-06-30"], "spatial_extent": { From 8fcd2d3e80d5cec232405dfb4e1fa9588f9e5de7 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Wed, 15 Apr 2026 17:03:55 +0100 Subject: [PATCH 29/55] fixed some characters --- .../argans/waterlines/openeo_udp/README.md | 50 ++++--------------- 1 file changed, 11 insertions(+), 39 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/README.md b/algorithm_catalog/argans/waterlines/openeo_udp/README.md index e5e56f195..2ef34490d 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/README.md +++ b/algorithm_catalog/argans/waterlines/openeo_udp/README.md @@ -1,14 +1,9 @@ # Waterlines openEO UDP - ## Purpose Extract coastline waterlines from Sentinel-2 imagery using NDWI-based water detection, morphological refinement, and UDF-based vectorization. ---- - ## Methodology - ### Water/Land Classification - Water masks are derived using the NDWI (Normalized Difference Water Index): - **NDWI (Normalized Difference Water Index)** @@ -25,17 +20,13 @@ Where: Default threshold is equal to **0.01** but can be overridden via parameters. ---- - ### Why only NDWI? This MVP supports only one method (**S2_NDWI**). Originally, multiple methods were selectable via a parameter, but this required openEO `if_()` logic, which converts the result into a `ProcessBuilder` instead of a `DataCube`. This breaks the `raster_to_vector()` step needed for waterline extraction. - ### Morphological Processing - For each timestamp, the water/land mask is refined using morphological operations to: - remove small isolated objects @@ -45,59 +36,40 @@ For each timestamp, the water/land mask is refined using morphological operation This improves the quality and stability of the resulting waterlines. ---- - ### Waterline Extraction - The cleaned masks are converted into vector waterlines using a UDF, producing geometries for each time step. ---- - ## Output - -The process outputs **FeatureCollections** of coastline waterlines with the following properties: - -- **time** – Acquisition timestamp (Sentinel-2 datetime) -- **type** – Feature type (`waterline_segment`) -- **sea_direction_8** – Sea direction (N, NE, E, SE, S, SW, W, NW) -- **sea_azimuth_deg** – Sea direction in degrees (azimuth, clockwise from north) -- **geometry** – Waterline geometry (LineString or MultiLineString) in EPSG:3857 - ---- +The process outputs Vector cube of coastline waterlines with the following properties: +- **time**: Acquisition timestamp (Sentinel-2 datetime) +- **type**: Feature type (`waterline_segment`) +- **sea_direction_8**: Sea direction (N, NE, E, SE, S, SW, W, NW) +- **sea_azimuth_deg**: Sea direction in degrees (azimuth, clockwise from north) +- **geometry**: Waterline geometry (LineString or MultiLineString) in EPSG:3857 ## Usage - See the APEx documentation and repository: - [UDP Writer Guide](https://esa-apex.github.io/apex_documentation/guides/udp_writer_guide.html) - [APEx Algorithms GitHub](https://github.com/ESA-APEx/apex_algorithms) ---- - ## Authors / Contact - -- **Milena Napiorkowska** (openEO UDP) – Argans Ltd +- **Milena Napiorkowska** (openEO UDP) Argans Ltd mnapiorkowska@argans.co.uk -- **Martin Jones** (Project Manager) – Argans Ltd +- **Martin Jones** (Project Manager) Argans Ltd mjones@argans.co.uk -- **Holly Baxter** (Methodology) – Argans Ltd +- **Holly Baxter** (Methodology) Argans Ltd hbaxtar@argans.co.uk -- **Cameron Mackenzie** (Methodology) +- **Cameron Mackenzie** (Methodology) Argans Ltd cmackenzie@argans.co.uk ---- - ## Acknowledgments - -This work was developed as part of an ESA-funded Fast Track project. - ---- +This work was developed as part of an ESA-funded **Fast Track** project. ## Known Limitations - - Results are most reliable for scenes with low cloud coverage - NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels - NDWI might be less reliable in turbid waters \ No newline at end of file From 3fb9bd268d3a5aa1ee298fc3e19dd8c990e395f6 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Thu, 16 Apr 2026 07:59:43 +0100 Subject: [PATCH 30/55] removed wrong url to code --- algorithm_catalog/argans/waterlines/records/waterlines.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algorithm_catalog/argans/waterlines/records/waterlines.json b/algorithm_catalog/argans/waterlines/records/waterlines.json index 87c4b13bf..4181330b6 100644 --- a/algorithm_catalog/argans/waterlines/records/waterlines.json +++ b/algorithm_catalog/argans/waterlines/records/waterlines.json @@ -87,7 +87,7 @@ "rel": "code", "type": "text/html", "title": "openeo-udp repository", - "href": "https://github.com/developmentseed/openeo-udp" + "href": "" }, { "rel": "webapp", From cd46c60940ac45ad4d6307ff17fce399c89b8fda Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Thu, 16 Apr 2026 08:12:35 +0100 Subject: [PATCH 31/55] fix ndwi thresholding by applying comparison element-wise --- algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py b/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py index 55411b4a0..0f2c6d531 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/s2_index.py @@ -283,9 +283,9 @@ def s2_index_mask( idx = idx.mask(clear) if mode == "gt": - mask = idx.process("gt", x=idx, y=threshold) + mask = idx.apply(lambda x: x > threshold) elif mode == "lt": - mask = idx.process("lt", x=idx, y=threshold) + mask = idx.apply(lambda x: x < threshold) else: raise ValueError(f"Unsupported mode: {mode}") From de9b00ea2aea9be50a57e4cae32cab943586f73b Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Thu, 16 Apr 2026 09:00:20 +0100 Subject: [PATCH 32/55] updated README --- .../argans/waterlines/openeo_udp/README.md | 45 ++++--------------- 1 file changed, 9 insertions(+), 36 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/README.md b/algorithm_catalog/argans/waterlines/openeo_udp/README.md index 2ef34490d..16d017c1a 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/README.md +++ b/algorithm_catalog/argans/waterlines/openeo_udp/README.md @@ -1,57 +1,30 @@ # Waterlines openEO UDP ## Purpose -Extract coastline waterlines from Sentinel-2 imagery using NDWI-based water detection, morphological refinement, and UDF-based vectorization. +Extract coastline waterlines from Sentinel-2 imagery using NDWI-based water detection, morphological refinement, and UDF-based conversion from water polygons to coast waterlines. ## Methodology ### Water/Land Classification -Water masks are derived using the NDWI (Normalized Difference Water Index): +Water masks are generated using the **Normalized Difference Water Index (NDWI)**, where pixels are classified as water when **NDWI > threshold**. The default threshold is **0.01**, but it can be adjusted using the `ndwi_threshold` parameter. -- **NDWI (Normalized Difference Water Index)** - $$ - NDWI = \frac{G - NIR}{G + NIR} - $$ +NDWI is computed as the normalized difference between the Sentinel-2 **Green** band (B03) and **Near-Infrared** band (B08), defined as the difference between these bands divided by their sum. -Where water is classified as: -- **NDWI > threshold** - -Where: -- **G** = Green band (S2 B03) -- **NIR** = Near Infrared (S2 B08) - -Default threshold is equal to **0.01** but can be overridden via parameters. - -### Why only NDWI? -This MVP supports only one method (**S2_NDWI**). - -Originally, multiple methods were selectable via a parameter, but this required openEO `if_()` logic, which converts the result into a `ProcessBuilder` instead of a `DataCube`. -This breaks the `raster_to_vector()` step needed for waterline extraction. +*This MVP supports only one method (**S2_NDWI**). Originally, multiple methods were selectable via a parameter, but this required openEO `if_()` logic, which converts the result into a `ProcessBuilder` instead of a `DataCube`. +This breaks the `raster_to_vector()` step needed for waterline extraction.* ### Morphological Processing -For each timestamp, the water/land mask is refined using morphological operations to: - -- remove small isolated objects -- fill small holes -- smooth boundaries -- reduce artifacts such as narrow bridges and estuaries - -This improves the quality and stability of the resulting waterlines. +For each timestamp, the water/land mask is refined using morphological operations to remove small isolated objects, fill small holes, smooth boundaries and reduce artifacts such as narrow bridges and estuaries. This improves the quality and stability of the resulting waterlines. ### Waterline Extraction -The cleaned masks are converted into vector waterlines using a UDF, producing geometries for each time step. +The cleaned masks are vectorized using the built-in openEO function `raster_to_vector()`. The resulting water polygons are then transformed into waterlines via a UDF, producing time-resolved geometries for each timestep. -## Output -The process outputs Vector cube of coastline waterlines with the following properties: +The output is a vector cube of coastline waterlines with the following properties: - **time**: Acquisition timestamp (Sentinel-2 datetime) - **type**: Feature type (`waterline_segment`) - **sea_direction_8**: Sea direction (N, NE, E, SE, S, SW, W, NW) - **sea_azimuth_deg**: Sea direction in degrees (azimuth, clockwise from north) - **geometry**: Waterline geometry (LineString or MultiLineString) in EPSG:3857 -## Usage -See the APEx documentation and repository: - -- [UDP Writer Guide](https://esa-apex.github.io/apex_documentation/guides/udp_writer_guide.html) -- [APEx Algorithms GitHub](https://github.com/ESA-APEx/apex_algorithms) +The **sea_azimuth_deg** property is particularly useful for downstream processing, as it can be used to shift the waterline and derive a shoreline (*a waterline normalized for beach slope and tidal conditions*). ## Authors / Contact - **Milena Napiorkowska** (openEO UDP) Argans Ltd From 32eb35e74a46615411b4b1a20134d4e7a8cae368 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Thu, 16 Apr 2026 09:03:00 +0100 Subject: [PATCH 33/55] raise exception when records are empty --- .../openeo_udp/udf_waterlines_from_water_land_mask.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index cfd42ee07..076d221b1 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -11,14 +11,12 @@ from rasterio.features import shapes, Affine from shapely.geometry import ( box, - shape, LineString, MultiLineString, Polygon, Point, GeometryCollection, ) -from shapely.ops import unary_union from openeo.udf import inspect import rioxarray from openeo.udf.feature_collection import FeatureCollection @@ -253,6 +251,8 @@ def waterline_from_vectorized_water_raster( - sea_azimuth_deg: Direction toward the sea in degrees (azimuth, typically measured clockwise from north). - geometry: Waterline geometry (LineString or MultiLineString). + Raises: + ValueError if no waterlines segments extracted. """ records: list[dict[str, Any]] = [] @@ -285,6 +285,10 @@ def waterline_from_vectorized_water_raster( "geometry": seg, } ) + if len(records) == 0: + raise ValueError( + "No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid." + ) inspect(data=[records], message="Converting records to geodataframe") gdf = gpd.GeoDataFrame(records, geometry="geometry", crs=gdf.crs) gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True) From a6698231bd5f3ca21c08b24bc674009203078207 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Thu, 16 Apr 2026 09:17:41 +0100 Subject: [PATCH 34/55] updated waterlines.json --- .../argans/waterlines/openeo_udp/generate.py | 2 +- .../waterlines/openeo_udp/waterlines.json | 33 +++++++++++++------ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index 8c6b5774d..652cf9536 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -184,5 +184,5 @@ def generate() -> dict: if __name__ == "__main__": - with open(Path(__file__).parent / "waterlines_s2_ndwi.json", "w") as f: + with open(Path(__file__).parent / "waterlines.json", "w") as f: json.dump(generate(), f, indent=2) \ No newline at end of file diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index 86fbd6087..a8cecf7c4 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -216,22 +216,35 @@ } } }, - "gt1": { - "process_id": "gt", + "apply1": { + "process_id": "apply", "arguments": { - "x": { + "data": { "from_node": "mask2" }, - "y": { - "from_parameter": "ndwi_threshold" + "process": { + "process_graph": { + "gt1": { + "process_id": "gt", + "arguments": { + "x": { + "from_parameter": "x" + }, + "y": { + "from_parameter": "ndwi_threshold" + } + }, + "result": true + } + } } } }, - "apply1": { + "apply2": { "process_id": "apply", "arguments": { "data": { - "from_node": "gt1" + "from_node": "apply1" }, "process": { "process_graph": { @@ -259,7 +272,7 @@ } }, "data": { - "from_node": "apply1" + "from_node": "apply2" }, "dimension": "t", "process": { @@ -314,7 +327,7 @@ "from_parameter": "data" }, "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n shape,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom shapely.ops import unary_union\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 0.0001) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n [feature_collection] = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=udf_data.user_context.get(\"simplify_tolerance\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([FeatureCollection(id=\"_\", data=gdf)])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n\n\n# from pathlib import Path\n# files_dir = Path(\"P:/FastTrack/DAP10/openeo\")\n# polygons_path = files_dir / \"vectorcube.geojson\"\n# gdf = gpd.read_file(polygons_path)\n# waterlines = waterline_from_vectorized_water_raster(gdf, simplify_tolerance=10)\n# waterlines.to_file(files_dir / \"vatercube_waterlines.geojson\")" + "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 0.0001) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n [feature_collection] = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=udf_data.user_context.get(\"simplify_tolerance\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([FeatureCollection(id=\"_\", data=gdf)])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n" }, "result": true } @@ -326,7 +339,7 @@ }, "id": "waterlines_s2_ndwi", "summary": "Waterlines extracted from Sentinel-2 using NDWI.", - "description": "# Waterlines openEO UDP\n\n## Purpose\nExtract coastline waterlines from Sentinel-2 imagery using NDWI-based water detection, morphological refinement, and UDF-based vectorization.\n\n---\n\n## Methodology\n\n### Water/Land Classification\n\nWater masks are derived using the NDWI (Normalized Difference Water Index):\n\n- **NDWI (Normalized Difference Water Index)** \n $$\n NDWI = \\frac{G - NIR}{G + NIR}\n $$\n\nWhere water is classified as:\n- **NDWI > threshold**\n\nWhere:\n- **G** = Green band (S2 B03) \n- **NIR** = Near Infrared (S2 B08) \n\nDefault threshold is equal to **0.01** but can be overridden via parameters.\n\n---\n\n### Why only NDWI?\nThis MVP supports only one method (**S2_NDWI**).\n\nOriginally, multiple methods were selectable via a parameter, but this required openEO `if_()` logic, which converts the result into a `ProcessBuilder` instead of a `DataCube`.\nThis breaks the `raster_to_vector()` step needed for waterline extraction.\n\n\n### Morphological Processing\n\nFor each timestamp, the water/land mask is refined using morphological operations to:\n\n- remove small isolated objects \n- fill small holes \n- smooth boundaries \n- reduce artifacts such as narrow bridges and estuaries \n\nThis improves the quality and stability of the resulting waterlines.\n\n---\n\n### Waterline Extraction\n\nThe cleaned masks are converted into vector waterlines using a UDF, producing geometries for each time step.\n\n---\n\n## Output\n\nThe process outputs **FeatureCollections** of coastline waterlines with the following properties:\n\n- **time** \u00e2\u20ac\u201c Acquisition timestamp (Sentinel-2 datetime) \n- **type** \u00e2\u20ac\u201c Feature type (`waterline_segment`) \n- **sea_direction_8** \u00e2\u20ac\u201c Sea direction (N, NE, E, SE, S, SW, W, NW) \n- **sea_azimuth_deg** \u00e2\u20ac\u201c Sea direction in degrees (azimuth, clockwise from north) \n- **geometry** \u00e2\u20ac\u201c Waterline geometry (LineString or MultiLineString) in EPSG:3857 \n\n---\n\n## Usage\n\nSee the APEx documentation and repository:\n\n- [UDP Writer Guide](https://esa-apex.github.io/apex_documentation/guides/udp_writer_guide.html) \n- [APEx Algorithms GitHub](https://github.com/ESA-APEx/apex_algorithms)\n\n---\n\n## Authors / Contact\n\n- **Milena Napiorkowska** (openEO UDP) \u00e2\u20ac\u201c Argans Ltd \n mnapiorkowska@argans.co.uk \n\n- **Martin Jones** (Project Manager) \u00e2\u20ac\u201c Argans Ltd \n mjones@argans.co.uk \n\n- **Holly Baxter** (Methodology) \u00e2\u20ac\u201c Argans Ltd \n hbaxtar@argans.co.uk \n\n- **Cameron Mackenzie** (Methodology) \n cmackenzie@argans.co.uk \n\n---\n\n## Acknowledgments\n\nThis work was developed as part of an ESA-funded Fast Track project.\n\n---\n\n## Known Limitations\n\n- Results are most reliable for scenes with low cloud coverage \n- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels \n- NDWI might be less reliable in turbid waters", + "description": "# Waterlines openEO UDP\n## Purpose\nExtract coastline waterlines from Sentinel-2 imagery using NDWI-based water detection, morphological refinement, and UDF-based conversion from water polygons to coast waterlines.\n\n## Methodology\n### Water/Land Classification\nWater masks are generated using the **Normalized Difference Water Index (NDWI)**, where pixels are classified as water when **NDWI > threshold**. The default threshold is **0.01**, but it can be adjusted using the `ndwi_threshold` parameter.\n\nNDWI is computed as the normalized difference between the Sentinel-2 **Green** band (B03) and **Near-Infrared** band (B08), defined as the difference between these bands divided by their sum.\n\n*This MVP supports only one method (**S2_NDWI**). Originally, multiple methods were selectable via a parameter, but this required openEO `if_()` logic, which converts the result into a `ProcessBuilder` instead of a `DataCube`.\nThis breaks the `raster_to_vector()` step needed for waterline extraction.*\n\n### Morphological Processing\nFor each timestamp, the water/land mask is refined using morphological operations to remove small isolated objects, fill small holes, smooth boundaries and reduce artifacts such as narrow bridges and estuaries. This improves the quality and stability of the resulting waterlines.\n\n### Waterline Extraction\nThe cleaned masks are vectorized using the built-in openEO function `raster_to_vector()`. The resulting water polygons are then transformed into waterlines via a UDF, producing time-resolved geometries for each timestep.\n\nThe output is a vector cube of coastline waterlines with the following properties:\n- **time**: Acquisition timestamp (Sentinel-2 datetime) \n- **type**: Feature type (`waterline_segment`) \n- **sea_direction_8**: Sea direction (N, NE, E, SE, S, SW, W, NW) \n- **sea_azimuth_deg**: Sea direction in degrees (azimuth, clockwise from north) \n- **geometry**: Waterline geometry (LineString or MultiLineString) in EPSG:3857 \n\nThe **sea_azimuth_deg** property is particularly useful for downstream processing, as it can be used to shift the waterline and derive a shoreline (*a waterline normalized for beach slope and tidal conditions*).\n\n## Authors / Contact\n- **Milena Napiorkowska** (openEO UDP) Argans Ltd \n mnapiorkowska@argans.co.uk \n\n- **Martin Jones** (Project Manager) Argans Ltd \n mjones@argans.co.uk \n\n- **Holly Baxter** (Methodology) Argans Ltd \n hbaxtar@argans.co.uk \n\n- **Cameron Mackenzie** (Methodology) Argans Ltd\n cmackenzie@argans.co.uk \n\n## Acknowledgments\nThis work was developed as part of an ESA-funded **Fast Track** project.\n\n## Known Limitations\n- Results are most reliable for scenes with low cloud coverage \n- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels \n- NDWI might be less reliable in turbid waters", "categories": [ "sentinel-2", "coastline", From f76a2e967c05c107853f6c4ba850a412d88073b3 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Thu, 16 Apr 2026 09:36:57 +0100 Subject: [PATCH 35/55] removed dot and updated update date --- algorithm_catalog/argans/waterlines/records/waterlines.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/records/waterlines.json b/algorithm_catalog/argans/waterlines/records/waterlines.json index 4181330b6..8ac2c5182 100644 --- a/algorithm_catalog/argans/waterlines/records/waterlines.json +++ b/algorithm_catalog/argans/waterlines/records/waterlines.json @@ -8,9 +8,9 @@ "geometry": null, "properties": { "created": "2026-04-01T00:00:00Z", - "updated": "2026-04-01T00:00:00Z", + "updated": "2026-04-16T00:00:00Z", "type": "service", - "title": "Waterlines from Sentinel-2.", + "title": "Waterlines from Sentinel-2", "description": "Extracts coastal waterlines from Sentinel-2 time series by generating NDWI-based land-water masks, vectorising them, and converting them into waterlines.", "keywords": [ "Normalized Difference Water Index (NDWI)", From df2b284ad9277659c2ad49998c3d490f119895c5 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Thu, 16 Apr 2026 10:37:20 +0100 Subject: [PATCH 36/55] ranamed to waterlines --- .../{waterlines_s2_ndwi.json => waterlines.json} | 2 +- algorithm_catalog/argans/waterlines/openeo_udp/generate.py | 2 +- algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename algorithm_catalog/argans/waterlines/benchmark_scenarios/{waterlines_s2_ndwi.json => waterlines.json} (96%) diff --git a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json similarity index 96% rename from algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json rename to algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json index e2e69602c..77e25ca44 100644 --- a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines_s2_ndwi.json +++ b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json @@ -1,6 +1,6 @@ [ { - "id": "waterlines_s2_ndwi", + "id": "waterlines", "type": "openeo", "backend": "openeofed.dataspace.copernicus.eu", "process_graph": { diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index 652cf9536..853cd49a5 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -168,7 +168,7 @@ def generate() -> dict: return build_process_dict( process_graph=waterlines_cube, - process_id="waterlines_s2_ndwi", + process_id="waterlines", summary="Waterlines extracted from Sentinel-2 using NDWI.", description=(Path(__file__).parent / "README.md").read_text(), parameters=[ diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index a8cecf7c4..7b8082f08 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -337,7 +337,7 @@ "result": true } }, - "id": "waterlines_s2_ndwi", + "id": "waterlines", "summary": "Waterlines extracted from Sentinel-2 using NDWI.", "description": "# Waterlines openEO UDP\n## Purpose\nExtract coastline waterlines from Sentinel-2 imagery using NDWI-based water detection, morphological refinement, and UDF-based conversion from water polygons to coast waterlines.\n\n## Methodology\n### Water/Land Classification\nWater masks are generated using the **Normalized Difference Water Index (NDWI)**, where pixels are classified as water when **NDWI > threshold**. The default threshold is **0.01**, but it can be adjusted using the `ndwi_threshold` parameter.\n\nNDWI is computed as the normalized difference between the Sentinel-2 **Green** band (B03) and **Near-Infrared** band (B08), defined as the difference between these bands divided by their sum.\n\n*This MVP supports only one method (**S2_NDWI**). Originally, multiple methods were selectable via a parameter, but this required openEO `if_()` logic, which converts the result into a `ProcessBuilder` instead of a `DataCube`.\nThis breaks the `raster_to_vector()` step needed for waterline extraction.*\n\n### Morphological Processing\nFor each timestamp, the water/land mask is refined using morphological operations to remove small isolated objects, fill small holes, smooth boundaries and reduce artifacts such as narrow bridges and estuaries. This improves the quality and stability of the resulting waterlines.\n\n### Waterline Extraction\nThe cleaned masks are vectorized using the built-in openEO function `raster_to_vector()`. The resulting water polygons are then transformed into waterlines via a UDF, producing time-resolved geometries for each timestep.\n\nThe output is a vector cube of coastline waterlines with the following properties:\n- **time**: Acquisition timestamp (Sentinel-2 datetime) \n- **type**: Feature type (`waterline_segment`) \n- **sea_direction_8**: Sea direction (N, NE, E, SE, S, SW, W, NW) \n- **sea_azimuth_deg**: Sea direction in degrees (azimuth, clockwise from north) \n- **geometry**: Waterline geometry (LineString or MultiLineString) in EPSG:3857 \n\nThe **sea_azimuth_deg** property is particularly useful for downstream processing, as it can be used to shift the waterline and derive a shoreline (*a waterline normalized for beach slope and tidal conditions*).\n\n## Authors / Contact\n- **Milena Napiorkowska** (openEO UDP) Argans Ltd \n mnapiorkowska@argans.co.uk \n\n- **Martin Jones** (Project Manager) Argans Ltd \n mjones@argans.co.uk \n\n- **Holly Baxter** (Methodology) Argans Ltd \n hbaxtar@argans.co.uk \n\n- **Cameron Mackenzie** (Methodology) Argans Ltd\n cmackenzie@argans.co.uk \n\n## Acknowledgments\nThis work was developed as part of an ESA-funded **Fast Track** project.\n\n## Known Limitations\n- Results are most reliable for scenes with low cloud coverage \n- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels \n- NDWI might be less reliable in turbid waters", "categories": [ From d828bfde8dbc6e272bf2984743724630d4114505 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Thu, 16 Apr 2026 15:03:26 +0100 Subject: [PATCH 37/55] changed default time range --- algorithm_catalog/argans/waterlines/openeo_udp/generate.py | 2 +- algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index 853cd49a5..90b8e18e7 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -124,7 +124,7 @@ def generate() -> dict: temporal_extent = Parameter.temporal_interval( name="temporal_extent", - default=["2015-06-23", "2025-12-31"], + default=["2025-01-01", "2025-12-31"], description="Date range over which to extract waterlines.", ) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index 7b8082f08..93d798e79 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -423,7 +423,7 @@ } }, "default": [ - "2015-06-23", + "2025-01-01", "2025-12-31" ], "optional": true From 0641d2195c97169bf3a6d74293a308210bb51bb3 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Fri, 17 Apr 2026 11:51:01 +0100 Subject: [PATCH 38/55] removing small water polygons, as they are crated during udf appy_ufunc as artifacts --- .../openeo_udp/udf_waterlines_from_water_land_mask.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index 076d221b1..af403f48f 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -203,6 +203,9 @@ def _segments_for_water_mask( # Remove small interiors gdf_water_one_timestamp["geometry"] = gdf_water_one_timestamp["geometry"].apply(_remove_small_interiors) + # Remove small polygons + gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp["geometry"].area > DEFAULT_MIN_HOLE_AREA] + # Merge all polygons water_poly = gdf_water_one_timestamp.union_all() From c2bf671b0faef12cf2afdf973c0aa1f7fe516da2 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Fri, 17 Apr 2026 15:08:35 +0100 Subject: [PATCH 39/55] regenerated process graph --- algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index 93d798e79..eab46dea7 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -327,7 +327,7 @@ "from_parameter": "data" }, "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 0.0001) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n [feature_collection] = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=udf_data.user_context.get(\"simplify_tolerance\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([FeatureCollection(id=\"_\", data=gdf)])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n" + "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 0.0001) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n [feature_collection] = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=udf_data.user_context.get(\"simplify_tolerance\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([FeatureCollection(id=\"_\", data=gdf)])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n" }, "result": true } From defa6e1280ea11762d7cbb68d1528a7dba5f8783 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Mon, 20 Apr 2026 13:57:37 +0100 Subject: [PATCH 40/55] using within and bigger buffer for edges removal --- .../openeo_udp/udf_waterlines_from_water_land_mask.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index af403f48f..1a6273bc7 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -79,11 +79,12 @@ def _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HO return Polygon(geom.exterior, holes=kept_holes) -def _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 0.0001) -> list[LineString]: +def _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]: """Return 2-point segments that do NOT intersect the raster extent boundary.""" extent_edge = box(*bounds).boundary edges = split_into_segments(waterline) - return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)] + extent_edge_buffered = extent_edge.buffer(buffer) + return [e for e in edges if not e.within(extent_edge_buffered)] def _remove_short_dangling_segments( @@ -305,6 +306,7 @@ def apply_udf_data(udf_data: UdfData) -> UdfData: [feature_collection] = udf_data.get_feature_collection_list() gdf = feature_collection.data + inspect(data=[udf_data.user_context], message="Input UDFData user context inspection") gdf = waterline_from_vectorized_water_raster( gdf=gdf, simplify_tolerance=udf_data.user_context.get("simplify_tolerance"), From f7d33324ac2347f054aa58b5d9e35b68c3a159b6 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Mon, 20 Apr 2026 13:58:01 +0100 Subject: [PATCH 41/55] regenerated process graph --- algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index eab46dea7..46df5f62f 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -327,7 +327,7 @@ "from_parameter": "data" }, "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 0.0001) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n return [e for e in edges if not e.buffer(buffer).intersects(extent_edge)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n [feature_collection] = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=udf_data.user_context.get(\"simplify_tolerance\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([FeatureCollection(id=\"_\", data=gdf)])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n" + "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n [feature_collection] = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n\n inspect(data=[udf_data.user_context], message=\"Input UDFData user context inspection\")\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=udf_data.user_context.get(\"simplify_tolerance\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([FeatureCollection(id=\"_\", data=gdf)])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n" }, "result": true } From a9c30e1d1885f386139187a78532fdde962a7309 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Mon, 20 Apr 2026 14:28:45 +0100 Subject: [PATCH 42/55] modified way to provide context for udf's --- .../argans/waterlines/openeo_udp/generate.py | 24 ++++--------------- .../waterlines/openeo_udp/waterlines.json | 20 ++++++---------- 2 files changed, 11 insertions(+), 33 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index 90b8e18e7..28a26d8dd 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -17,21 +17,9 @@ def apply_morphology(cube: DataCube, iterations: int) -> DataCube: - """ - Apply morphological operations to each time slice of a water/land mask. + udf = UDF.from_file(Path(__file__).parent / "udf_morph_operations.py", context={"iterations": iterations}) - Used to clean the mask (remove noise, fill gaps, smooth shapes) - before extracting waterlines. - """ - udf = UDF.from_file( - Path(__file__).parent / "udf_morph_operations.py", - context={"from_parameter": "context"}, - ) - return cube.apply_dimension( - process=udf, - dimension="t", - context={"iterations": iterations}, - ) + return cube.apply_dimension(process=udf, dimension="t") def create_waterlines(cube: DataCube, simplify_tolerance: float = 10) -> DataCube: @@ -45,13 +33,9 @@ def create_waterlines(cube: DataCube, simplify_tolerance: float = 10) -> DataCub udf = UDF.from_file( Path(__file__).parent / "udf_waterlines_from_water_land_mask.py", - context={"from_parameter": "context"}, - ) - return cube.apply_dimension( - process=udf, - dimension="geometry", context={"simplify_tolerance": simplify_tolerance}, ) + return cube.apply_dimension(process=udf, dimension="geometry") def build_water_land_mask_cube( @@ -185,4 +169,4 @@ def generate() -> dict: if __name__ == "__main__": with open(Path(__file__).parent / "waterlines.json", "w") as f: - json.dump(generate(), f, indent=2) \ No newline at end of file + json.dump(generate(), f, indent=2) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index 46df5f62f..9a4a5e426 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -266,11 +266,6 @@ "applydimension1": { "process_id": "apply_dimension", "arguments": { - "context": { - "iterations": { - "from_parameter": "iterations" - } - }, "data": { "from_node": "apply2" }, @@ -281,7 +276,9 @@ "process_id": "run_udf", "arguments": { "context": { - "from_parameter": "context" + "iterations": { + "from_parameter": "iterations" + } }, "data": { "from_parameter": "data" @@ -306,11 +303,6 @@ "applydimension2": { "process_id": "apply_dimension", "arguments": { - "context": { - "simplify_tolerance": { - "from_parameter": "simplify_tolerance" - } - }, "data": { "from_node": "rastertovector1" }, @@ -321,13 +313,15 @@ "process_id": "run_udf", "arguments": { "context": { - "from_parameter": "context" + "simplify_tolerance": { + "from_parameter": "simplify_tolerance" + } }, "data": { "from_parameter": "data" }, "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n [feature_collection] = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n\n inspect(data=[udf_data.user_context], message=\"Input UDFData user context inspection\")\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=udf_data.user_context.get(\"simplify_tolerance\"),\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([FeatureCollection(id=\"_\", data=gdf)])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n" + "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n feature_collection = udf_data.get_feature_collection_list()[0]\n gdf = feature_collection.data\n\n user_context = udf_data.user_context or {}\n\n simplify_tolerance = user_context.get(\"simplify_tolerance\", 10)\n\n inspect(\n data=[simplify_tolerance],\n message=\"Simplify tolerance resolved\"\n )\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=simplify_tolerance,\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n" }, "result": true } From 59f1010e3ec20ab1db63a053d1b27f103b664a5b Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Mon, 20 Apr 2026 14:53:17 +0100 Subject: [PATCH 43/55] renamed upd --- .../argans/waterlines/openeo_udp/generate.py | 2 +- .../udf_waterlines_from_water_land_mask.py | 18 ++++++++++++++---- .../waterlines/openeo_udp/waterlines.json | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index 28a26d8dd..0f02bdd3b 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -152,7 +152,7 @@ def generate() -> dict: return build_process_dict( process_graph=waterlines_cube, - process_id="waterlines", + process_id="waterlines_v1", summary="Waterlines extracted from Sentinel-2 using NDWI.", description=(Path(__file__).parent / "README.md").read_text(), parameters=[ diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index 1a6273bc7..f4cfe5d6c 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -303,18 +303,28 @@ def apply_udf_data(udf_data: UdfData) -> UdfData: inspect(data=[udf_data], message="Input UDFData inspection") - [feature_collection] = udf_data.get_feature_collection_list() + feature_collection = udf_data.get_feature_collection_list()[0] gdf = feature_collection.data - inspect(data=[udf_data.user_context], message="Input UDFData user context inspection") + user_context = udf_data.user_context or {} + + simplify_tolerance = user_context.get("simplify_tolerance", 10) + + inspect( + data=[simplify_tolerance], + message="Simplify tolerance resolved" + ) + gdf = waterline_from_vectorized_water_raster( gdf=gdf, - simplify_tolerance=udf_data.user_context.get("simplify_tolerance"), + simplify_tolerance=simplify_tolerance, ) inspect(data=[gdf], message="Output gdf") - udf_data.set_feature_collection_list([FeatureCollection(id="_", data=gdf)]) + udf_data.set_feature_collection_list([ + FeatureCollection(id="_", data=gdf) + ]) inspect(data=[udf_data], message="Output UDFData inspection") diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index 9a4a5e426..1ff2e3488 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -331,7 +331,7 @@ "result": true } }, - "id": "waterlines", + "id": "waterlines_v1", "summary": "Waterlines extracted from Sentinel-2 using NDWI.", "description": "# Waterlines openEO UDP\n## Purpose\nExtract coastline waterlines from Sentinel-2 imagery using NDWI-based water detection, morphological refinement, and UDF-based conversion from water polygons to coast waterlines.\n\n## Methodology\n### Water/Land Classification\nWater masks are generated using the **Normalized Difference Water Index (NDWI)**, where pixels are classified as water when **NDWI > threshold**. The default threshold is **0.01**, but it can be adjusted using the `ndwi_threshold` parameter.\n\nNDWI is computed as the normalized difference between the Sentinel-2 **Green** band (B03) and **Near-Infrared** band (B08), defined as the difference between these bands divided by their sum.\n\n*This MVP supports only one method (**S2_NDWI**). Originally, multiple methods were selectable via a parameter, but this required openEO `if_()` logic, which converts the result into a `ProcessBuilder` instead of a `DataCube`.\nThis breaks the `raster_to_vector()` step needed for waterline extraction.*\n\n### Morphological Processing\nFor each timestamp, the water/land mask is refined using morphological operations to remove small isolated objects, fill small holes, smooth boundaries and reduce artifacts such as narrow bridges and estuaries. This improves the quality and stability of the resulting waterlines.\n\n### Waterline Extraction\nThe cleaned masks are vectorized using the built-in openEO function `raster_to_vector()`. The resulting water polygons are then transformed into waterlines via a UDF, producing time-resolved geometries for each timestep.\n\nThe output is a vector cube of coastline waterlines with the following properties:\n- **time**: Acquisition timestamp (Sentinel-2 datetime) \n- **type**: Feature type (`waterline_segment`) \n- **sea_direction_8**: Sea direction (N, NE, E, SE, S, SW, W, NW) \n- **sea_azimuth_deg**: Sea direction in degrees (azimuth, clockwise from north) \n- **geometry**: Waterline geometry (LineString or MultiLineString) in EPSG:3857 \n\nThe **sea_azimuth_deg** property is particularly useful for downstream processing, as it can be used to shift the waterline and derive a shoreline (*a waterline normalized for beach slope and tidal conditions*).\n\n## Authors / Contact\n- **Milena Napiorkowska** (openEO UDP) Argans Ltd \n mnapiorkowska@argans.co.uk \n\n- **Martin Jones** (Project Manager) Argans Ltd \n mjones@argans.co.uk \n\n- **Holly Baxter** (Methodology) Argans Ltd \n hbaxtar@argans.co.uk \n\n- **Cameron Mackenzie** (Methodology) Argans Ltd\n cmackenzie@argans.co.uk \n\n## Acknowledgments\nThis work was developed as part of an ESA-funded **Fast Track** project.\n\n## Known Limitations\n- Results are most reliable for scenes with low cloud coverage \n- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels \n- NDWI might be less reliable in turbid waters", "categories": [ From 85775ec5a96e8b7be0d139a7a3e5d02999558c56 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Mon, 20 Apr 2026 15:19:06 +0100 Subject: [PATCH 44/55] simplify_tolerance hardcoded --- .../argans/waterlines/openeo_udp/generate.py | 15 +++++---------- .../udf_waterlines_from_water_land_mask.py | 2 +- .../argans/waterlines/openeo_udp/waterlines.json | 16 +--------------- 3 files changed, 7 insertions(+), 26 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index 0f02bdd3b..82ff2ffaa 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -130,12 +130,6 @@ def generate() -> dict: description=WATERLAND_THRESHOLDS["S2_NDWI"].description, ) - simplify_tolerance = Parameter.number( - name="simplify_tolerance", - default=10, - description="Tolerance used to simplify vectorized water polygons before extracting waterlines.", - ) - water_land_mask = build_water_land_mask_cube( con=conn, bbox=spatial_extent, @@ -145,10 +139,12 @@ def generate() -> dict: ndwi_threshold=ndwi_threshold, ) - waterlines_cube = create_waterlines( - water_land_mask, - simplify_tolerance=simplify_tolerance, + water_land_mask_vector_cube = water_land_mask.raster_to_vector() + + udf = UDF.from_file( + Path(__file__).parent / "udf_waterlines_from_water_land_mask.py", ) + waterlines_cube = water_land_mask_vector_cube.apply_dimension(process=udf, dimension="geometry") return build_process_dict( process_graph=waterlines_cube, @@ -161,7 +157,6 @@ def generate() -> dict: max_cloud_coverage, iterations, ndwi_threshold, - simplify_tolerance, ], categories=["sentinel-2", "coastline", "waterlines"], ) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index f4cfe5d6c..23252f6bd 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -308,7 +308,7 @@ def apply_udf_data(udf_data: UdfData) -> UdfData: user_context = udf_data.user_context or {} - simplify_tolerance = user_context.get("simplify_tolerance", 10) + simplify_tolerance = user_context.get("simplify_tolerance", 15) inspect( data=[simplify_tolerance], diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index 1ff2e3488..bf3030190 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -312,16 +312,11 @@ "runudf2": { "process_id": "run_udf", "arguments": { - "context": { - "simplify_tolerance": { - "from_parameter": "simplify_tolerance" - } - }, "data": { "from_parameter": "data" }, "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n feature_collection = udf_data.get_feature_collection_list()[0]\n gdf = feature_collection.data\n\n user_context = udf_data.user_context or {}\n\n simplify_tolerance = user_context.get(\"simplify_tolerance\", 10)\n\n inspect(\n data=[simplify_tolerance],\n message=\"Simplify tolerance resolved\"\n )\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=simplify_tolerance,\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n" + "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n feature_collection = udf_data.get_feature_collection_list()[0]\n gdf = feature_collection.data\n\n user_context = udf_data.user_context or {}\n\n simplify_tolerance = user_context.get(\"simplify_tolerance\", 15)\n\n inspect(\n data=[simplify_tolerance],\n message=\"Simplify tolerance resolved\"\n )\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=simplify_tolerance,\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n" }, "result": true } @@ -448,15 +443,6 @@ }, "default": 0.01, "optional": true - }, - { - "name": "simplify_tolerance", - "description": "Tolerance used to simplify vectorized water polygons before extracting waterlines.", - "schema": { - "type": "number" - }, - "default": 10, - "optional": true } ] } \ No newline at end of file From 8b19a513ed976a98eb623e0b3993c2d29647bed4 Mon Sep 17 00:00:00 2001 From: C Mackenzie Date: Mon, 20 Apr 2026 14:22:25 +0000 Subject: [PATCH 45/55] Updated role --- algorithm_catalog/argans/waterlines/openeo_udp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/README.md b/algorithm_catalog/argans/waterlines/openeo_udp/README.md index 16d017c1a..1ab61cf2f 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/README.md +++ b/algorithm_catalog/argans/waterlines/openeo_udp/README.md @@ -36,7 +36,7 @@ The **sea_azimuth_deg** property is particularly useful for downstream processin - **Holly Baxter** (Methodology) Argans Ltd hbaxtar@argans.co.uk -- **Cameron Mackenzie** (Methodology) Argans Ltd +- **Cameron Mackenzie** (Methodology, openEO UDP) Argans Ltd cmackenzie@argans.co.uk ## Acknowledgments From 8b7d320934eb34c225d686fa731a0e60b226da1f Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Mon, 20 Apr 2026 15:41:59 +0100 Subject: [PATCH 46/55] removed import of unused libs --- .../udf_waterlines_from_water_land_mask.py | 13 ++----------- .../argans/waterlines/openeo_udp/waterlines.json | 4 ++-- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index 23252f6bd..c02479028 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -1,14 +1,6 @@ -# /// script -# dependencies = [ -# "rioxarray", -# ] -# /// - from typing import Iterable, Union, Any import numpy as np -import xarray as xr import geopandas as gpd -from rasterio.features import shapes, Affine from shapely.geometry import ( box, LineString, @@ -18,7 +10,6 @@ GeometryCollection, ) from openeo.udf import inspect -import rioxarray from openeo.udf.feature_collection import FeatureCollection from openeo.udf.udf_data import UdfData @@ -263,6 +254,7 @@ def waterline_from_vectorized_water_raster( # Get time dimensions time_stamps = gdf.loc[:, gdf.columns != "geometry"].columns.to_list() + inspect(data=[time_stamps], message="Input time stamps.") bounds = gdf.total_bounds for time_stamp in time_stamps: one_time_stamp_gdf = gdf[[time_stamp, "geometry"]].dropna(subset=[time_stamp]) @@ -301,10 +293,9 @@ def waterline_from_vectorized_water_raster( def apply_udf_data(udf_data: UdfData) -> UdfData: - inspect(data=[udf_data], message="Input UDFData inspection") - feature_collection = udf_data.get_feature_collection_list()[0] gdf = feature_collection.data + inspect(data=[gdf], message="Input gdf data inspection") user_context = udf_data.user_context or {} diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index bf3030190..e2c1aff65 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -316,7 +316,7 @@ "from_parameter": "data" }, "runtime": "Python", - "udf": "# /// script\n# dependencies = [\n# \"rioxarray\",\n# ]\n# ///\n\nfrom typing import Iterable, Union, Any\nimport numpy as np\nimport xarray as xr\nimport geopandas as gpd\nfrom rasterio.features import shapes, Affine\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nimport rioxarray\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n inspect(data=[udf_data], message=\"Input UDFData inspection\")\n\n feature_collection = udf_data.get_feature_collection_list()[0]\n gdf = feature_collection.data\n\n user_context = udf_data.user_context or {}\n\n simplify_tolerance = user_context.get(\"simplify_tolerance\", 15)\n\n inspect(\n data=[simplify_tolerance],\n message=\"Simplify tolerance resolved\"\n )\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=simplify_tolerance,\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n" + "udf": "from typing import Iterable, Union, Any\nimport numpy as np\nimport geopandas as gpd\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n inspect(data=[time_stamps], message=\"Input time stamps.\")\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n feature_collection = udf_data.get_feature_collection_list()[0]\n gdf = feature_collection.data\n inspect(data=[gdf], message=\"Input gdf data inspection\")\n\n user_context = udf_data.user_context or {}\n\n simplify_tolerance = user_context.get(\"simplify_tolerance\", 15)\n\n inspect(\n data=[simplify_tolerance],\n message=\"Simplify tolerance resolved\"\n )\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=simplify_tolerance,\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n" }, "result": true } @@ -328,7 +328,7 @@ }, "id": "waterlines_v1", "summary": "Waterlines extracted from Sentinel-2 using NDWI.", - "description": "# Waterlines openEO UDP\n## Purpose\nExtract coastline waterlines from Sentinel-2 imagery using NDWI-based water detection, morphological refinement, and UDF-based conversion from water polygons to coast waterlines.\n\n## Methodology\n### Water/Land Classification\nWater masks are generated using the **Normalized Difference Water Index (NDWI)**, where pixels are classified as water when **NDWI > threshold**. The default threshold is **0.01**, but it can be adjusted using the `ndwi_threshold` parameter.\n\nNDWI is computed as the normalized difference between the Sentinel-2 **Green** band (B03) and **Near-Infrared** band (B08), defined as the difference between these bands divided by their sum.\n\n*This MVP supports only one method (**S2_NDWI**). Originally, multiple methods were selectable via a parameter, but this required openEO `if_()` logic, which converts the result into a `ProcessBuilder` instead of a `DataCube`.\nThis breaks the `raster_to_vector()` step needed for waterline extraction.*\n\n### Morphological Processing\nFor each timestamp, the water/land mask is refined using morphological operations to remove small isolated objects, fill small holes, smooth boundaries and reduce artifacts such as narrow bridges and estuaries. This improves the quality and stability of the resulting waterlines.\n\n### Waterline Extraction\nThe cleaned masks are vectorized using the built-in openEO function `raster_to_vector()`. The resulting water polygons are then transformed into waterlines via a UDF, producing time-resolved geometries for each timestep.\n\nThe output is a vector cube of coastline waterlines with the following properties:\n- **time**: Acquisition timestamp (Sentinel-2 datetime) \n- **type**: Feature type (`waterline_segment`) \n- **sea_direction_8**: Sea direction (N, NE, E, SE, S, SW, W, NW) \n- **sea_azimuth_deg**: Sea direction in degrees (azimuth, clockwise from north) \n- **geometry**: Waterline geometry (LineString or MultiLineString) in EPSG:3857 \n\nThe **sea_azimuth_deg** property is particularly useful for downstream processing, as it can be used to shift the waterline and derive a shoreline (*a waterline normalized for beach slope and tidal conditions*).\n\n## Authors / Contact\n- **Milena Napiorkowska** (openEO UDP) Argans Ltd \n mnapiorkowska@argans.co.uk \n\n- **Martin Jones** (Project Manager) Argans Ltd \n mjones@argans.co.uk \n\n- **Holly Baxter** (Methodology) Argans Ltd \n hbaxtar@argans.co.uk \n\n- **Cameron Mackenzie** (Methodology) Argans Ltd\n cmackenzie@argans.co.uk \n\n## Acknowledgments\nThis work was developed as part of an ESA-funded **Fast Track** project.\n\n## Known Limitations\n- Results are most reliable for scenes with low cloud coverage \n- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels \n- NDWI might be less reliable in turbid waters", + "description": "# Waterlines openEO UDP\n## Purpose\nExtract coastline waterlines from Sentinel-2 imagery using NDWI-based water detection, morphological refinement, and UDF-based conversion from water polygons to coast waterlines.\n\n## Methodology\n### Water/Land Classification\nWater masks are generated using the **Normalized Difference Water Index (NDWI)**, where pixels are classified as water when **NDWI > threshold**. The default threshold is **0.01**, but it can be adjusted using the `ndwi_threshold` parameter.\n\nNDWI is computed as the normalized difference between the Sentinel-2 **Green** band (B03) and **Near-Infrared** band (B08), defined as the difference between these bands divided by their sum.\n\n*This MVP supports only one method (**S2_NDWI**). Originally, multiple methods were selectable via a parameter, but this required openEO `if_()` logic, which converts the result into a `ProcessBuilder` instead of a `DataCube`.\nThis breaks the `raster_to_vector()` step needed for waterline extraction.*\n\n### Morphological Processing\nFor each timestamp, the water/land mask is refined using morphological operations to remove small isolated objects, fill small holes, smooth boundaries and reduce artifacts such as narrow bridges and estuaries. This improves the quality and stability of the resulting waterlines.\n\n### Waterline Extraction\nThe cleaned masks are vectorized using the built-in openEO function `raster_to_vector()`. The resulting water polygons are then transformed into waterlines via a UDF, producing time-resolved geometries for each timestep.\n\nThe output is a vector cube of coastline waterlines with the following properties:\n- **time**: Acquisition timestamp (Sentinel-2 datetime) \n- **type**: Feature type (`waterline_segment`) \n- **sea_direction_8**: Sea direction (N, NE, E, SE, S, SW, W, NW) \n- **sea_azimuth_deg**: Sea direction in degrees (azimuth, clockwise from north) \n- **geometry**: Waterline geometry (LineString or MultiLineString) in EPSG:3857 \n\nThe **sea_azimuth_deg** property is particularly useful for downstream processing, as it can be used to shift the waterline and derive a shoreline (*a waterline normalized for beach slope and tidal conditions*).\n\n## Authors / Contact\n- **Milena Napiorkowska** (openEO UDP) Argans Ltd \n mnapiorkowska@argans.co.uk \n\n- **Martin Jones** (Project Manager) Argans Ltd \n mjones@argans.co.uk \n\n- **Holly Baxter** (Methodology) Argans Ltd \n hbaxtar@argans.co.uk \n\n- **Cameron Mackenzie** (Methodology, openEO UDP) Argans Ltd\n cmackenzie@argans.co.uk \n\n## Acknowledgments\nThis work was developed as part of an ESA-funded **Fast Track** project.\n\n## Known Limitations\n- Results are most reliable for scenes with low cloud coverage \n- NoData areas may introduce artifacts, particularly along boundaries between valid and invalid pixels \n- NDWI might be less reliable in turbid waters", "categories": [ "sentinel-2", "coastline", From f5544197e0528e198036621b6391abc42f9a616a Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Mon, 20 Apr 2026 15:50:18 +0100 Subject: [PATCH 47/55] update udf --- .../udf_waterlines_from_water_land_mask.py | 17 +++-------------- .../waterlines/openeo_udp/waterlines.json | 2 +- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index c02479028..5bc2de796 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -293,30 +293,19 @@ def waterline_from_vectorized_water_raster( def apply_udf_data(udf_data: UdfData) -> UdfData: - feature_collection = udf_data.get_feature_collection_list()[0] + feature_collection = udf_data.get_feature_collection_list() gdf = feature_collection.data inspect(data=[gdf], message="Input gdf data inspection") - user_context = udf_data.user_context or {} - - simplify_tolerance = user_context.get("simplify_tolerance", 15) - - inspect( - data=[simplify_tolerance], - message="Simplify tolerance resolved" - ) - gdf = waterline_from_vectorized_water_raster( gdf=gdf, - simplify_tolerance=simplify_tolerance, + simplify_tolerance=15, ) - inspect(data=[gdf], message="Output gdf") - udf_data.set_feature_collection_list([ FeatureCollection(id="_", data=gdf) ]) - inspect(data=[udf_data], message="Output UDFData inspection") + inspect(data=[gdf], message="Output gdf") return udf_data diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index e2c1aff65..cd0fa323c 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -316,7 +316,7 @@ "from_parameter": "data" }, "runtime": "Python", - "udf": "from typing import Iterable, Union, Any\nimport numpy as np\nimport geopandas as gpd\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n inspect(data=[time_stamps], message=\"Input time stamps.\")\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n feature_collection = udf_data.get_feature_collection_list()[0]\n gdf = feature_collection.data\n inspect(data=[gdf], message=\"Input gdf data inspection\")\n\n user_context = udf_data.user_context or {}\n\n simplify_tolerance = user_context.get(\"simplify_tolerance\", 15)\n\n inspect(\n data=[simplify_tolerance],\n message=\"Simplify tolerance resolved\"\n )\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=simplify_tolerance,\n )\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n inspect(data=[udf_data], message=\"Output UDFData inspection\")\n\n return udf_data\n" + "udf": "from typing import Iterable, Union, Any\nimport numpy as np\nimport geopandas as gpd\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n inspect(data=[time_stamps], message=\"Input time stamps.\")\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n feature_collection = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n inspect(data=[gdf], message=\"Input gdf data inspection\")\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=15,\n )\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n return udf_data\n" }, "result": true } From 4fec070ccd271e223fbad2f5a5c88cf91f2747f1 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Mon, 20 Apr 2026 15:59:41 +0100 Subject: [PATCH 48/55] using create_waterlines wrapper --- .../argans/waterlines/openeo_udp/generate.py | 10 ++-------- .../openeo_udp/udf_waterlines_from_water_land_mask.py | 4 ++-- .../argans/waterlines/openeo_udp/waterlines.json | 2 +- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index 82ff2ffaa..5a54bf5b0 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -22,7 +22,7 @@ def apply_morphology(cube: DataCube, iterations: int) -> DataCube: return cube.apply_dimension(process=udf, dimension="t") -def create_waterlines(cube: DataCube, simplify_tolerance: float = 10) -> DataCube: +def create_waterlines(cube: DataCube) -> DataCube: """ Extract waterlines from a water/land mask using a UDF. @@ -33,7 +33,6 @@ def create_waterlines(cube: DataCube, simplify_tolerance: float = 10) -> DataCub udf = UDF.from_file( Path(__file__).parent / "udf_waterlines_from_water_land_mask.py", - context={"simplify_tolerance": simplify_tolerance}, ) return cube.apply_dimension(process=udf, dimension="geometry") @@ -139,12 +138,7 @@ def generate() -> dict: ndwi_threshold=ndwi_threshold, ) - water_land_mask_vector_cube = water_land_mask.raster_to_vector() - - udf = UDF.from_file( - Path(__file__).parent / "udf_waterlines_from_water_land_mask.py", - ) - waterlines_cube = water_land_mask_vector_cube.apply_dimension(process=udf, dimension="geometry") + waterlines_cube = create_waterlines(water_land_mask) return build_process_dict( process_graph=waterlines_cube, diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index 5bc2de796..a0795640c 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -302,10 +302,10 @@ def apply_udf_data(udf_data: UdfData) -> UdfData: simplify_tolerance=15, ) + inspect(data=[gdf], message="Output gdf data inspection") + udf_data.set_feature_collection_list([ FeatureCollection(id="_", data=gdf) ]) - inspect(data=[gdf], message="Output gdf") - return udf_data diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index cd0fa323c..0e0cec71e 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -316,7 +316,7 @@ "from_parameter": "data" }, "runtime": "Python", - "udf": "from typing import Iterable, Union, Any\nimport numpy as np\nimport geopandas as gpd\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n inspect(data=[time_stamps], message=\"Input time stamps.\")\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n feature_collection = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n inspect(data=[gdf], message=\"Input gdf data inspection\")\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=15,\n )\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n inspect(data=[gdf], message=\"Output gdf\")\n\n return udf_data\n" + "udf": "from typing import Iterable, Union, Any\nimport numpy as np\nimport geopandas as gpd\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n inspect(data=[time_stamps], message=\"Input time stamps.\")\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n feature_collection = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n inspect(data=[gdf], message=\"Input gdf data inspection\")\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=15,\n )\n\n inspect(data=[gdf], message=\"Output gdf data inspection\")\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n return udf_data\n" }, "result": true } From 2736baaad66968125e175330469280f4b9559219 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Mon, 20 Apr 2026 16:11:25 +0100 Subject: [PATCH 49/55] fixed udf --- .../openeo_udp/udf_waterlines_from_water_land_mask.py | 2 +- algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index a0795640c..b2666ae53 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -293,7 +293,7 @@ def waterline_from_vectorized_water_raster( def apply_udf_data(udf_data: UdfData) -> UdfData: - feature_collection = udf_data.get_feature_collection_list() + feature_collection = udf_data.get_feature_collection_list()[0] gdf = feature_collection.data inspect(data=[gdf], message="Input gdf data inspection") diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index 0e0cec71e..90abe8997 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -316,7 +316,7 @@ "from_parameter": "data" }, "runtime": "Python", - "udf": "from typing import Iterable, Union, Any\nimport numpy as np\nimport geopandas as gpd\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n inspect(data=[time_stamps], message=\"Input time stamps.\")\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n feature_collection = udf_data.get_feature_collection_list()\n gdf = feature_collection.data\n inspect(data=[gdf], message=\"Input gdf data inspection\")\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=15,\n )\n\n inspect(data=[gdf], message=\"Output gdf data inspection\")\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n return udf_data\n" + "udf": "from typing import Iterable, Union, Any\nimport numpy as np\nimport geopandas as gpd\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n inspect(data=[time_stamps], message=\"Input time stamps.\")\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n feature_collection = udf_data.get_feature_collection_list()[0]\n gdf = feature_collection.data\n inspect(data=[gdf], message=\"Input gdf data inspection\")\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=15,\n )\n\n inspect(data=[gdf], message=\"Output gdf data inspection\")\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n return udf_data\n" }, "result": true } From 4818737a52b82f1f0fb63be894caef83cacb1a6b Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Mon, 20 Apr 2026 16:42:48 +0100 Subject: [PATCH 50/55] restored simplify_tolerance and refactored so parameters from udf context are actually used --- .../argans/waterlines/openeo_udp/generate.py | 30 +++++++++++++++---- .../udf_waterlines_from_water_land_mask.py | 10 ++++++- .../waterlines/openeo_udp/waterlines.json | 28 ++++++++++++++--- 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py index 5a54bf5b0..e5c796c90 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/generate.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/generate.py @@ -17,12 +17,18 @@ def apply_morphology(cube: DataCube, iterations: int) -> DataCube: - udf = UDF.from_file(Path(__file__).parent / "udf_morph_operations.py", context={"iterations": iterations}) - - return cube.apply_dimension(process=udf, dimension="t") + udf = UDF.from_file( + Path(__file__).parent / "udf_morph_operations.py", + context={"from_parameter": "context"}, + ) + return cube.apply_dimension( + process=udf, + dimension="t", + context={"iterations": iterations}, + ) -def create_waterlines(cube: DataCube) -> DataCube: +def create_waterlines(cube: DataCube, simplify_tolerance: float = 10) -> DataCube: """ Extract waterlines from a water/land mask using a UDF. @@ -33,8 +39,13 @@ def create_waterlines(cube: DataCube) -> DataCube: udf = UDF.from_file( Path(__file__).parent / "udf_waterlines_from_water_land_mask.py", + context={"from_parameter": "context"}, + ) + return cube.apply_dimension( + process=udf, + dimension="geometry", + context={"simplify_tolerance": simplify_tolerance}, ) - return cube.apply_dimension(process=udf, dimension="geometry") def build_water_land_mask_cube( @@ -129,6 +140,12 @@ def generate() -> dict: description=WATERLAND_THRESHOLDS["S2_NDWI"].description, ) + simplify_tolerance = Parameter.number( + name="simplify_tolerance", + default=10, + description="Tolerance used to simplify vectorized water polygons before extracting waterlines.", + ) + water_land_mask = build_water_land_mask_cube( con=conn, bbox=spatial_extent, @@ -138,7 +155,7 @@ def generate() -> dict: ndwi_threshold=ndwi_threshold, ) - waterlines_cube = create_waterlines(water_land_mask) + waterlines_cube = create_waterlines(water_land_mask, simplify_tolerance=simplify_tolerance) return build_process_dict( process_graph=waterlines_cube, @@ -151,6 +168,7 @@ def generate() -> dict: max_cloud_coverage, iterations, ndwi_threshold, + simplify_tolerance, ], categories=["sentinel-2", "coastline", "waterlines"], ) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index b2666ae53..9037d2658 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -297,9 +297,17 @@ def apply_udf_data(udf_data: UdfData) -> UdfData: gdf = feature_collection.data inspect(data=[gdf], message="Input gdf data inspection") + user_context = udf_data.user_context or {} + simplify_tolerance = user_context.get("simplify_tolerance", 10) + + inspect( + data=[simplify_tolerance], + message="Simplify tolerance resolved" + ) + gdf = waterline_from_vectorized_water_raster( gdf=gdf, - simplify_tolerance=15, + simplify_tolerance=simplify_tolerance, ) inspect(data=[gdf], message="Output gdf data inspection") diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index 90abe8997..7e3905fda 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -266,6 +266,11 @@ "applydimension1": { "process_id": "apply_dimension", "arguments": { + "context": { + "iterations": { + "from_parameter": "iterations" + } + }, "data": { "from_node": "apply2" }, @@ -276,9 +281,7 @@ "process_id": "run_udf", "arguments": { "context": { - "iterations": { - "from_parameter": "iterations" - } + "from_parameter": "context" }, "data": { "from_parameter": "data" @@ -303,6 +306,11 @@ "applydimension2": { "process_id": "apply_dimension", "arguments": { + "context": { + "simplify_tolerance": { + "from_parameter": "simplify_tolerance" + } + }, "data": { "from_node": "rastertovector1" }, @@ -312,11 +320,14 @@ "runudf2": { "process_id": "run_udf", "arguments": { + "context": { + "from_parameter": "context" + }, "data": { "from_parameter": "data" }, "runtime": "Python", - "udf": "from typing import Iterable, Union, Any\nimport numpy as np\nimport geopandas as gpd\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n inspect(data=[time_stamps], message=\"Input time stamps.\")\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n feature_collection = udf_data.get_feature_collection_list()[0]\n gdf = feature_collection.data\n inspect(data=[gdf], message=\"Input gdf data inspection\")\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=15,\n )\n\n inspect(data=[gdf], message=\"Output gdf data inspection\")\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n return udf_data\n" + "udf": "from typing import Iterable, Union, Any\nimport numpy as np\nimport geopandas as gpd\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n inspect(data=[time_stamps], message=\"Input time stamps.\")\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n feature_collection = udf_data.get_feature_collection_list()[0]\n gdf = feature_collection.data\n inspect(data=[gdf], message=\"Input gdf data inspection\")\n\n user_context = udf_data.user_context or {}\n simplify_tolerance = user_context.get(\"simplify_tolerance\", 10)\n\n inspect(\n data=[simplify_tolerance],\n message=\"Simplify tolerance resolved\"\n )\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=simplify_tolerance,\n )\n\n inspect(data=[gdf], message=\"Output gdf data inspection\")\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n return udf_data\n" }, "result": true } @@ -443,6 +454,15 @@ }, "default": 0.01, "optional": true + }, + { + "name": "simplify_tolerance", + "description": "Tolerance used to simplify vectorized water polygons before extracting waterlines.", + "schema": { + "type": "number" + }, + "default": 10, + "optional": true } ] } \ No newline at end of file From 8637cd7600ff1abe2bebb6d7a1a322833f986ff5 Mon Sep 17 00:00:00 2001 From: Milena Napiorkowska Date: Tue, 21 Apr 2026 14:32:21 +0100 Subject: [PATCH 51/55] not throwing an exception --- .../udf_waterlines_from_water_land_mask.py | 84 ++++++++++++++++--- .../waterlines/openeo_udp/waterlines.json | 2 +- 2 files changed, 72 insertions(+), 14 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py index 9037d2658..fd7241dfb 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py +++ b/algorithm_catalog/argans/waterlines/openeo_udp/udf_waterlines_from_water_land_mask.py @@ -70,7 +70,7 @@ def _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HO return Polygon(geom.exterior, holes=kept_holes) -def _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]: +def _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 600) -> list[LineString]: """Return 2-point segments that do NOT intersect the raster extent boundary.""" extent_edge = box(*bounds).boundary edges = split_into_segments(waterline) @@ -218,7 +218,7 @@ def _segments_for_water_mask( return cleaned_segments, water_poly - + def waterline_from_vectorized_water_raster( gdf: gpd.GeoDataFrame, simplify_tolerance: float | None = None, @@ -252,26 +252,65 @@ def waterline_from_vectorized_water_raster( records: list[dict[str, Any]] = [] - # Get time dimensions + # Identify time columns time_stamps = gdf.loc[:, gdf.columns != "geometry"].columns.to_list() inspect(data=[time_stamps], message="Input time stamps.") + + if not time_stamps: + inspect(data=["No time columns found"], message="Empty input") + return gpd.GeoDataFrame(geometry=[], crs=gdf.crs) + bounds = gdf.total_bounds + for time_stamp in time_stamps: + inspect(data=[time_stamp], message="Processing timestamp") + + # Drop NaNs one_time_stamp_gdf = gdf[[time_stamp, "geometry"]].dropna(subset=[time_stamp]) - one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0] - inspect(data=[time_stamp], message="Extracting waterlines for timestamp") + + if one_time_stamp_gdf.empty: + inspect(data=[time_stamp], message="All values NaN, skipping") + continue + + # Keep only water polygons + water_gdf = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0] + + if water_gdf.empty: + inspect(data=[time_stamp], message="No water polygons, skipping") + continue + + # Remove invalid geometries early + water_gdf = water_gdf[~water_gdf.geometry.is_empty & water_gdf.geometry.notna()] + + if water_gdf.empty: + inspect(data=[time_stamp], message="No valid geometries after cleaning") + continue + + # Process water mask → segments res = _segments_for_water_mask( - one_time_stamp_gdf_water_only, + water_gdf.copy(), bounds=bounds, simplify_tolerance=simplify_tolerance, ) + if res is None: + inspect(data=[time_stamp], message="No boundary extracted (None), skipping") continue segments, water_poly = res - inspect(data=[time_stamp], message="Calculating sea direction for timestamp") + + if not segments: + inspect(data=[time_stamp], message="No segments found, skipping") + continue + + inspect(data=[len(segments)], message="Segments found") + for seg in segments: + if seg.is_empty or seg.length == 0: + continue + sea_direction = _get_sea_direction_for_segment(water_poly, seg) + records.append( { "time": time_stamp, @@ -281,15 +320,34 @@ def waterline_from_vectorized_water_raster( "geometry": seg, } ) + + # 🔑 CRITICAL CHANGE: never crash on empty result if len(records) == 0: - raise ValueError( - "No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid." + inspect( + data=["No waterline segments found for any timestamp"], + message="Returning empty GeoDataFrame", + ) + + return gpd.GeoDataFrame( + columns=[ + "time", + "type", + DEFAULT_SEA_DIRECTION_8_COLUMN, + DEFAULT_SEA_AZIMUTH_DEG_COLUMN, + "geometry", + ], + geometry="geometry", + crs=gdf.crs, ) - inspect(data=[records], message="Converting records to geodataframe") - gdf = gpd.GeoDataFrame(records, geometry="geometry", crs=gdf.crs) - gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True) - return gdf + inspect(data=[len(records)], message="Total segments created") + + result_gdf = gpd.GeoDataFrame(records, geometry="geometry", crs=gdf.crs) + result_gdf = result_gdf[ + ~result_gdf.geometry.isna() & ~result_gdf.geometry.is_empty + ].reset_index(drop=True) + + return result_gdf def apply_udf_data(udf_data: UdfData) -> UdfData: diff --git a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json index 7e3905fda..0e075d3dd 100644 --- a/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json +++ b/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json @@ -327,7 +327,7 @@ "from_parameter": "data" }, "runtime": "Python", - "udf": "from typing import Iterable, Union, Any\nimport numpy as np\nimport geopandas as gpd\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 500) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Get time dimensions\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n inspect(data=[time_stamps], message=\"Input time stamps.\")\n bounds = gdf.total_bounds\n for time_stamp in time_stamps:\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n one_time_stamp_gdf_water_only = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n inspect(data=[time_stamp], message=\"Extracting waterlines for timestamp\")\n res = _segments_for_water_mask(\n one_time_stamp_gdf_water_only,\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n if res is None:\n continue\n\n segments, water_poly = res\n inspect(data=[time_stamp], message=\"Calculating sea direction for timestamp\")\n for seg in segments:\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n if len(records) == 0:\n raise ValueError(\n \"No waterline segments found within the specified area of interest. Check that the area overlaps with known water bodies and that the input data is valid.\"\n )\n inspect(data=[records], message=\"Converting records to geodataframe\")\n gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty].reset_index(drop=True)\n return gdf\n\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n feature_collection = udf_data.get_feature_collection_list()[0]\n gdf = feature_collection.data\n inspect(data=[gdf], message=\"Input gdf data inspection\")\n\n user_context = udf_data.user_context or {}\n simplify_tolerance = user_context.get(\"simplify_tolerance\", 10)\n\n inspect(\n data=[simplify_tolerance],\n message=\"Simplify tolerance resolved\"\n )\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=simplify_tolerance,\n )\n\n inspect(data=[gdf], message=\"Output gdf data inspection\")\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n return udf_data\n" + "udf": "from typing import Iterable, Union, Any\nimport numpy as np\nimport geopandas as gpd\nfrom shapely.geometry import (\n box,\n LineString,\n MultiLineString,\n Polygon,\n Point,\n GeometryCollection,\n)\nfrom openeo.udf import inspect\nfrom openeo.udf.feature_collection import FeatureCollection\nfrom openeo.udf.udf_data import UdfData\n\nGeometryLike = Union[LineString, MultiLineString, GeometryCollection]\n\nDEFAULT_OUT_LAYER = \"waterline\"\nDEFAULT_TIME_DIM = \"time\"\nDEFAULT_VAR_NAME = \"var\"\nDEFAULT_SEA_DIRECTION_8_COLUMN = \"sea_direction_8\"\nDEFAULT_SEA_AZIMUTH_DEG_COLUMN = \"sea_azimuth_deg\"\nDEFAULT_MIN_DANGLING_LENGTH = 10000\nDEFAULT_MIN_HOLE_AREA = 1000000\n\n\ndef _iter_lines(geom: GeometryLike) -> Iterable[LineString]:\n \"\"\"Recursively yield all LineString objects contained in a geometry.\"\"\"\n if geom.is_empty:\n return\n\n if isinstance(geom, LineString):\n yield geom\n elif isinstance(geom, (MultiLineString, GeometryCollection)):\n for subgeom in geom.geoms:\n yield from _iter_lines(subgeom)\n\n\ndef split_into_segments(geom: GeometryLike) -> list[LineString]:\n \"\"\"\n Split a geometry into 2-point LineStrings representing individual segments\n between consecutive vertices.\n\n Args:\n geom: Input geometry.\n\n Returns: List of non-zero-length segments.\n \"\"\"\n segments: list[LineString] = []\n\n for line in _iter_lines(geom):\n coords = list(line.coords)\n for start, end in zip(coords[:-1], coords[1:]):\n if start != end: # Avoid zero-length segments\n segments.append(LineString([start, end]))\n\n return segments\n\n\ndef _remove_small_interiors(geom: Polygon, min_hole_area: float = DEFAULT_MIN_HOLE_AREA) -> Polygon:\n \"\"\"Remove small interior rings from polygon.\"\"\"\n if geom.is_empty:\n return geom\n\n kept_holes = []\n for ring in geom.interiors:\n if Polygon(ring).area >= min_hole_area:\n kept_holes.append(ring)\n\n return Polygon(geom.exterior, holes=kept_holes)\n\n\ndef _remove_extent_intersections(waterline: LineString, bounds, buffer: float = 600) -> list[LineString]:\n \"\"\"Return 2-point segments that do NOT intersect the raster extent boundary.\"\"\"\n extent_edge = box(*bounds).boundary\n edges = split_into_segments(waterline)\n extent_edge_buffered = extent_edge.buffer(buffer)\n return [e for e in edges if not e.within(extent_edge_buffered)]\n\n\ndef _remove_short_dangling_segments(\n segments: list[LineString],\n min_dangling_length: float = 0.0,\n) -> list[LineString]:\n \"\"\"Remove short isolated segments (both endpoints occur only once).\"\"\"\n if not segments or min_dangling_length <= 0:\n return segments\n\n endpoint_counts: dict[Any, int] = {}\n for seg in segments:\n a, b = seg.coords[0], seg.coords[-1]\n endpoint_counts[a] = endpoint_counts.get(a, 0) + 1\n endpoint_counts[b] = endpoint_counts.get(b, 0) + 1\n\n kept: list[LineString] = []\n for seg in segments:\n if seg.length >= min_dangling_length:\n kept.append(seg)\n continue\n\n a, b = seg.coords[0], seg.coords[-1]\n if endpoint_counts.get(a, 0) > 1 or endpoint_counts.get(b, 0) > 1:\n kept.append(seg)\n\n return kept\n\n\ndef _clean_waterline_segments(\n waterline: LineString,\n bounds,\n min_dangling_length: float = DEFAULT_MIN_DANGLING_LENGTH,\n) -> list[LineString]:\n \"\"\"\n Clean waterline and return as a *list of 2-point segments* (one per edge).\n \"\"\"\n segments = _remove_extent_intersections(waterline, bounds)\n if not segments:\n return []\n\n segments = _remove_short_dangling_segments(\n segments,\n min_dangling_length=min_dangling_length,\n )\n if not segments:\n return []\n\n return segments\n\n\ndef _get_sea_direction_for_segment(water_poly: Polygon, seg: LineString) -> tuple[str, float | None]:\n \"\"\"\n Determine where the sea (water polygon side) lies relative to a segment.\n\n Returns:\n sea_dir: General sea direction (N, S, NS etc) and detailed sea dir in degrees.\n \"\"\"\n if seg.is_empty or seg.length == 0:\n return \"unknown\", None\n\n # Get first and last coordinates of the segment\n a = np.asarray(seg.coords[0], dtype=float)\n b = np.asarray(seg.coords[-1], dtype=float)\n\n # Compute direction vector and length\n v = b - a\n norm = np.linalg.norm(v)\n if norm == 0:\n return \"unknown\", None\n\n # Unit tangent and left normal vector\n t = v / norm\n n_left = np.array([-t[1], t[0]], dtype=float)\n\n # Segment midpoint\n mid = (a + b) / 2.0\n\n # How far to step away from the segment (1 perc of segment len.)\n eps = max(0.5, min(5.0, float(seg.length) * 0.01))\n\n # Probe points\n left_pt = mid + eps * n_left\n right_pt = mid - eps * n_left\n\n left_in = water_poly.contains(Point(left_pt))\n right_in = water_poly.contains(Point(right_pt))\n\n if left_in and not right_in:\n sea_vec = n_left\n elif right_in and not left_in:\n sea_vec = -n_left\n else:\n return \"unknown\", None\n\n # Map-based 8-way direction from sea_vec (x=east, y=north)\n x, y = float(sea_vec[0]), float(sea_vec[1])\n\n # Compute angle\n angle = np.degrees(np.arctan2(y, x))\n angle = (angle + 360.0) % 360.0\n\n # 8-sector compass, centered on E=0\u00b0, NE=45\u00b0, N=90\u00b0, ...\n dirs = [\"E\", \"NE\", \"N\", \"NW\", \"W\", \"SW\", \"S\", \"SE\"]\n idx = int(((angle + 22.5) % 360) // 45)\n\n return dirs[idx], float(angle)\n\n\ndef _segments_for_water_mask(\n gdf_water_one_timestamp,\n bounds,\n simplify_tolerance: float | None = None,\n) -> tuple[list[LineString], Polygon] | None:\n \"\"\"Converts water land mask for single timestamp to cleaned waterline segments.\"\"\"\n\n # Remove small interiors\n gdf_water_one_timestamp[\"geometry\"] = gdf_water_one_timestamp[\"geometry\"].apply(_remove_small_interiors)\n\n # Remove small polygons\n gdf_water_one_timestamp = gdf_water_one_timestamp[gdf_water_one_timestamp[\"geometry\"].area > DEFAULT_MIN_HOLE_AREA]\n\n # Merge all polygons\n water_poly = gdf_water_one_timestamp.union_all()\n\n if simplify_tolerance is not None:\n water_poly = water_poly.simplify(simplify_tolerance, preserve_topology=True)\n\n boundary = water_poly.boundary\n\n # boundary can be MultiLineString/LineString -> convert to a single multilinestring-ish\n if isinstance(boundary, LineString):\n cleaned_segments = _clean_waterline_segments(boundary, bounds=bounds)\n elif isinstance(boundary, MultiLineString):\n cleaned_segments = []\n for part in boundary.geoms:\n cleaned_segments.extend(_clean_waterline_segments(part, bounds=bounds))\n else:\n return None\n\n return cleaned_segments, water_poly\n\n\ndef waterline_from_vectorized_water_raster(\n gdf: gpd.GeoDataFrame,\n simplify_tolerance: float | None = None,\n) -> gpd.GeoDataFrame:\n \"\"\"\n Generate waterline segments for each time step from a vectorized land/water mask.\n The input gdf is the output of openEO raster_to_vector() process.\n Args:\n gdf: GeoDataFrame containing polygon geometries and one non-geometry column\n per timestamp. For each timestamp column, values indicate whether a\n polygon belongs to water or land at that time:\n - null: polygon not present for that timestamp\n - 0: land polygon\n - non-zero: water polygon\n simplify_tolerance: Optional tolerance for geometry simplification. If\n provided, merged water geometries are simplified before extracting\n boundary segments.\n\n Returns:\n A GeoDataFrame with columns:\n - time: Time step associated with each geometry.\n - type: Feature classification.\n - sea_direction_8: Direction toward the sea expressed as one of\n eight cardinal/inter-cardinal directions (N, NE, E, SE, S, SW, W, NW).\n - sea_azimuth_deg: Direction toward the sea in degrees (azimuth,\n typically measured clockwise from north).\n - geometry: Waterline geometry (LineString or MultiLineString).\n Raises:\n ValueError if no waterlines segments extracted.\n \"\"\"\n\n records: list[dict[str, Any]] = []\n\n # Identify time columns\n time_stamps = gdf.loc[:, gdf.columns != \"geometry\"].columns.to_list()\n inspect(data=[time_stamps], message=\"Input time stamps.\")\n\n if not time_stamps:\n inspect(data=[\"No time columns found\"], message=\"Empty input\")\n return gpd.GeoDataFrame(geometry=[], crs=gdf.crs)\n\n bounds = gdf.total_bounds\n\n for time_stamp in time_stamps:\n inspect(data=[time_stamp], message=\"Processing timestamp\")\n\n # Drop NaNs\n one_time_stamp_gdf = gdf[[time_stamp, \"geometry\"]].dropna(subset=[time_stamp])\n\n if one_time_stamp_gdf.empty:\n inspect(data=[time_stamp], message=\"All values NaN, skipping\")\n continue\n\n # Keep only water polygons\n water_gdf = one_time_stamp_gdf[one_time_stamp_gdf[time_stamp] != 0]\n\n if water_gdf.empty:\n inspect(data=[time_stamp], message=\"No water polygons, skipping\")\n continue\n\n # Remove invalid geometries early\n water_gdf = water_gdf[~water_gdf.geometry.is_empty & water_gdf.geometry.notna()]\n\n if water_gdf.empty:\n inspect(data=[time_stamp], message=\"No valid geometries after cleaning\")\n continue\n\n # Process water mask \u2192 segments\n res = _segments_for_water_mask(\n water_gdf.copy(),\n bounds=bounds,\n simplify_tolerance=simplify_tolerance,\n )\n\n if res is None:\n inspect(data=[time_stamp], message=\"No boundary extracted (None), skipping\")\n continue\n\n segments, water_poly = res\n\n if not segments:\n inspect(data=[time_stamp], message=\"No segments found, skipping\")\n continue\n\n inspect(data=[len(segments)], message=\"Segments found\")\n\n for seg in segments:\n if seg.is_empty or seg.length == 0:\n continue\n\n sea_direction = _get_sea_direction_for_segment(water_poly, seg)\n\n records.append(\n {\n \"time\": time_stamp,\n \"type\": \"waterline_segment\",\n DEFAULT_SEA_DIRECTION_8_COLUMN: sea_direction[0],\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN: sea_direction[1],\n \"geometry\": seg,\n }\n )\n\n # \ud83d\udd11 CRITICAL CHANGE: never crash on empty result\n if len(records) == 0:\n inspect(\n data=[\"No waterline segments found for any timestamp\"],\n message=\"Returning empty GeoDataFrame\",\n )\n\n return gpd.GeoDataFrame(\n columns=[\n \"time\",\n \"type\",\n DEFAULT_SEA_DIRECTION_8_COLUMN,\n DEFAULT_SEA_AZIMUTH_DEG_COLUMN,\n \"geometry\",\n ],\n geometry=\"geometry\",\n crs=gdf.crs,\n )\n\n inspect(data=[len(records)], message=\"Total segments created\")\n\n result_gdf = gpd.GeoDataFrame(records, geometry=\"geometry\", crs=gdf.crs)\n result_gdf = result_gdf[\n ~result_gdf.geometry.isna() & ~result_gdf.geometry.is_empty\n ].reset_index(drop=True)\n\n return result_gdf\n\ndef apply_udf_data(udf_data: UdfData) -> UdfData:\n\n feature_collection = udf_data.get_feature_collection_list()[0]\n gdf = feature_collection.data\n inspect(data=[gdf], message=\"Input gdf data inspection\")\n\n user_context = udf_data.user_context or {}\n simplify_tolerance = user_context.get(\"simplify_tolerance\", 10)\n\n inspect(\n data=[simplify_tolerance],\n message=\"Simplify tolerance resolved\"\n )\n\n gdf = waterline_from_vectorized_water_raster(\n gdf=gdf,\n simplify_tolerance=simplify_tolerance,\n )\n\n inspect(data=[gdf], message=\"Output gdf data inspection\")\n\n udf_data.set_feature_collection_list([\n FeatureCollection(id=\"_\", data=gdf)\n ])\n\n return udf_data\n" }, "result": true } From f44add812f1c22427c19150815c0efc58628d63f Mon Sep 17 00:00:00 2001 From: bramjanssen Date: Wed, 10 Jun 2026 09:44:36 +0200 Subject: [PATCH 52/55] fix: record validation --- algorithm_catalog/argans/record.json | 2 +- algorithm_catalog/argans/waterlines/records/waterlines.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/record.json b/algorithm_catalog/argans/record.json index e551d6c95..cd1b846f7 100644 --- a/algorithm_catalog/argans/record.json +++ b/algorithm_catalog/argans/record.json @@ -2,7 +2,7 @@ "id": "argans", "type": "Feature", "conformsTo": [ - "http://www.opengis.net/spec/ogcapi-records-1/1.0/req/record-core" + "https://www.opengis.net/spec/ogcapi-records-1/1.0/req/record-core" ], "properties": { "created": "2026-04-01T00:00:00Z", diff --git a/algorithm_catalog/argans/waterlines/records/waterlines.json b/algorithm_catalog/argans/waterlines/records/waterlines.json index 8ac2c5182..d091ae9a2 100644 --- a/algorithm_catalog/argans/waterlines/records/waterlines.json +++ b/algorithm_catalog/argans/waterlines/records/waterlines.json @@ -2,7 +2,7 @@ "id": "waterlines", "type": "Feature", "conformsTo": [ - "http://www.opengis.net/spec/ogcapi-records-1/1.0/req/record-core", + "https://www.opengis.net/spec/ogcapi-records-1/1.0/req/record-core", "https://apex.esa.int/core/openeo-udp" ], "geometry": null, From 43d0b17c4ae5bed9db5f9cc5df38efcdcdfb7ecf Mon Sep 17 00:00:00 2001 From: bramjanssen Date: Wed, 10 Jun 2026 09:56:59 +0200 Subject: [PATCH 53/55] fix: fixed benchmark ephemeral link --- .../argans/waterlines/benchmark_scenarios/waterlines.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json index 77e25ca44..91cec2a27 100644 --- a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json +++ b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json @@ -6,7 +6,7 @@ "process_graph": { "waterlines1": { "process_id": "waterlines", - "namespace": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/argans_waterlines/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json", + "namespace": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/main/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json", "arguments": { "temporal_extent": ["2024-06-01", "2024-06-30"], "spatial_extent": { From e34841c7d995ef2add131720b8c22dca4da37642 Mon Sep 17 00:00:00 2001 From: bramjanssen Date: Wed, 10 Jun 2026 10:02:26 +0200 Subject: [PATCH 54/55] fix: updated benchmark links --- .../argans/waterlines/benchmark_scenarios/waterlines.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json index 91cec2a27..91bc89290 100644 --- a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json +++ b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json @@ -6,7 +6,7 @@ "process_graph": { "waterlines1": { "process_id": "waterlines", - "namespace": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/main/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json", + "namespace": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/43d0b17c4ae5bed9db5f9cc5df38efcdcdfb7ecf/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json", "arguments": { "temporal_extent": ["2024-06-01", "2024-06-30"], "spatial_extent": { From 436cc0d0ee0a12db0f0c0a75adc7aec2f52e000c Mon Sep 17 00:00:00 2001 From: bramjanssen Date: Wed, 10 Jun 2026 10:14:56 +0200 Subject: [PATCH 55/55] fix: updated process_id based on the actual udp --- .../argans/waterlines/benchmark_scenarios/waterlines.json | 2 +- algorithm_catalog/argans/waterlines/records/waterlines.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json index 91bc89290..1e58e59cd 100644 --- a/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json +++ b/algorithm_catalog/argans/waterlines/benchmark_scenarios/waterlines.json @@ -5,7 +5,7 @@ "backend": "openeofed.dataspace.copernicus.eu", "process_graph": { "waterlines1": { - "process_id": "waterlines", + "process_id": "waterlines_v1", "namespace": "https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/43d0b17c4ae5bed9db5f9cc5df38efcdcdfb7ecf/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json", "arguments": { "temporal_extent": ["2024-06-01", "2024-06-30"], diff --git a/algorithm_catalog/argans/waterlines/records/waterlines.json b/algorithm_catalog/argans/waterlines/records/waterlines.json index d091ae9a2..7205ee9fd 100644 --- a/algorithm_catalog/argans/waterlines/records/waterlines.json +++ b/algorithm_catalog/argans/waterlines/records/waterlines.json @@ -93,7 +93,7 @@ "rel": "webapp", "type": "text/html", "title": "OpenEO Web Editor", - "href": "https://editor.openeo.org/?wizard=UDP&wizard%7Eprocess=waterlines&wizard%7EprocessUrl=https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/argans_waterlines/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json&server=openeofed.dataspace.copernicus.eu" + "href": "https://editor.openeo.org/?wizard=UDP&wizard%7Eprocess=waterlines_v1&wizard%7EprocessUrl=https://raw.githubusercontent.com/ESA-APEx/apex_algorithms/refs/heads/argans_waterlines/algorithm_catalog/argans/waterlines/openeo_udp/waterlines.json&server=openeofed.dataspace.copernicus.eu" }, { "rel": "service",