Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
939312d
added provider record
mileonai Apr 1, 2026
22df88b
added logo
mileonai Apr 1, 2026
f739814
updated logo
mileonai Apr 1, 2026
166653b
updated logo
mileonai Apr 1, 2026
a2a9d20
first commit
mileonai Apr 8, 2026
0f835a8
added descriptions
mileonai Apr 8, 2026
2cf2348
build graph for each method
mileonai Apr 8, 2026
e91d1d5
better docs
mileonai Apr 8, 2026
431bbf9
added benchmark scenario
mileonai Apr 8, 2026
0dc7b24
removed commented out code
mileonai Apr 9, 2026
415f7e0
readme
mileonai Apr 9, 2026
6d816bb
corrected typos
mileonai Apr 15, 2026
957b6b2
refactored waterliens in order to return vectorcube
mileonai Apr 15, 2026
a1ce6cf
docstring update
mileonai Apr 15, 2026
4aa4992
TODO about vectorisation added
mileonai Apr 15, 2026
824537b
modified _bin
mileonai Apr 15, 2026
df28957
Merge branch 'main' into argans_waterlines
mileonai Apr 15, 2026
0e90e98
using only one idex method as MVP
mileonai Apr 15, 2026
6ee5ffb
added links to Argans logo
mileonai Apr 15, 2026
f1644ee
updated description
mileonai Apr 15, 2026
c446fbe
updated process record
mileonai Apr 15, 2026
8d74115
addec Coastal keyword
mileonai Apr 15, 2026
1c9f08e
updated description
mileonai Apr 15, 2026
b413271
updadated benchmark scenario
mileonai Apr 15, 2026
689219e
removed commented out code
mileonai Apr 15, 2026
7397c79
changed platform and branch name
mileonai Apr 15, 2026
a2c6e25
changed branch name
mileonai Apr 15, 2026
d02de17
switched to main in url
mileonai Apr 15, 2026
61b393f
not main branch
mileonai Apr 15, 2026
8fcd2d3
fixed some characters
mileonai Apr 15, 2026
3fb9bd2
removed wrong url to code
mileonai Apr 16, 2026
cd46c60
fix ndwi thresholding by applying comparison element-wise
mileonai Apr 16, 2026
de9b00e
updated README
mileonai Apr 16, 2026
32eb35e
raise exception when records are empty
mileonai Apr 16, 2026
a669823
updated waterlines.json
mileonai Apr 16, 2026
f76a2e9
removed dot and updated update date
mileonai Apr 16, 2026
df2b284
ranamed to waterlines
mileonai Apr 16, 2026
d828bfd
changed default time range
mileonai Apr 16, 2026
0641d21
removing small water polygons, as they are crated during udf appy_ufu…
mileonai Apr 17, 2026
01c5ea7
Merge branch 'main' into argans_waterlines
mileonai Apr 17, 2026
c2bf671
regenerated process graph
mileonai Apr 17, 2026
defa6e1
using within and bigger buffer for edges removal
mileonai Apr 20, 2026
f7d3332
regenerated process graph
mileonai Apr 20, 2026
a9c30e1
modified way to provide context for udf's
mileonai Apr 20, 2026
59f1010
renamed upd
mileonai Apr 20, 2026
85775ec
simplify_tolerance hardcoded
mileonai Apr 20, 2026
8b19a51
Updated role
CamMackenzie98 Apr 20, 2026
8b7d320
removed import of unused libs
mileonai Apr 20, 2026
f554419
update udf
mileonai Apr 20, 2026
4fec070
using create_waterlines wrapper
mileonai Apr 20, 2026
2736baa
fixed udf
mileonai Apr 20, 2026
4818737
restored simplify_tolerance and refactored so parameters from udf con…
mileonai Apr 20, 2026
8637cd7
not throwing an exception
mileonai Apr 21, 2026
69ab21f
Merge branch 'main' into argans_waterlines
JanssenBrm May 26, 2026
77708f7
Merge branch 'main' into argans_waterlines
JanssenBrm Jun 10, 2026
f44add8
fix: record validation
JanssenBrm Jun 10, 2026
43d0b17
fix: fixed benchmark ephemeral link
JanssenBrm Jun 10, 2026
e34841c
fix: updated benchmark links
JanssenBrm Jun 10, 2026
436cc0d
fix: updated process_id based on the actual udp
JanssenBrm Jun 10, 2026
b4a23c0
Merge branch 'main' into argans_waterlines
JanssenBrm Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions algorithm_catalog/argans/record.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"id": "argans",
"type": "Feature",
"conformsTo": [
"https://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 specializes in satellite-based Earth observation, remote sensing, and GIS for monitoring marine, atmospheric, and land environments.",
"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"
}
],
"links": [
{
"href": "https://argans.co.uk",
"type": "text/html",
"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": "https://argans.co.uk/img/logo.png"
},
{
"rel": "logo-dark",
"type": "image/png",
"title": "Logo",
"href": "https://argans.co.uk/img/logos/argans_white_new.png"
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[
{
"id": "waterlines",
"type": "openeo",
"backend": "openeofed.dataspace.copernicus.eu",
"process_graph": {
"waterlines1": {
"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"],
"spatial_extent": {
"west": -95.13,
"south": 29.078,
"east": -95.12,
"north": 29.082,
"crs": "EPSG:4326"
},
"iterations": 2,
"max_cloud_coverage": 5,
"ndwi_threshold": 0.01,
"simplify_tolerance": 10
},
"result": true
}
},
"reference_data": {
}
}
]
48 changes: 48 additions & 0 deletions algorithm_catalog/argans/waterlines/openeo_udp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Waterlines openEO UDP
## Purpose
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 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 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.

*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 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 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.

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

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
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, openEO UDP) Argans Ltd
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
- NDWI might be less reliable in turbid waters
179 changes: 179 additions & 0 deletions algorithm_catalog/argans/waterlines/openeo_udp/generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import json
from pathlib import Path

import openeo
from openeo import UDF
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 s2_index import (
s2_index_mask,
DEFAULT_S2_COLLECTION,
DEFAULT_MAX_CLOUD_COVER,
WATERLAND_THRESHOLDS,
)


def apply_morphology(cube: DataCube, iterations: int) -> DataCube:
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, simplify_tolerance: float = 10) -> DataCube:
"""
Extract waterlines from a water/land mask using a UDF.

The input must remain a DataCube because the workflow relies on
`raster_to_vector()` before applying the vector-based waterline UDF.
"""
cube = cube.raster_to_vector()

@mileonai mileonai Apr 20, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have tested the steps used to build the process graph locally as follows and all works and produces expected results (an example of successful job cdse-j-260421124051497da99717d1a929472b):

bbox = {
        "west": 18.640943488750203, "south": 54.57342845938183,
        "east": 18.863083414279657, "north": 54.71972351389667, "crs": "EPSG:4326",}
time_range = ["2025-01-01", "2025-05-31"]

mask_cube = build_water_land_mask_cube(
    con=con,
    bbox=bbox,
    time_range=time_range,
    max_cloud_coverage=1,
    iterations=2,
    ndwi_threshold=0.01,
)

waterlines = create_waterlines(mask_cube)

# Export result
result = waterlines.save_result(format="GeoJSON")
job = result.create_job(title="waterlines_py_PL")
job.start_and_wait()

However, when I try to execute the UDP from openEO WebEditor with the same spatial extent, temporal extend and other parameters all my runs fail indicating the the produced vector cube is empty (example of failing job:
cdse-j-2604211221524d169c4d4c082193cea6).

@EmileSonneveld

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, there are a few differences in the parameters of those 2 jobs. Can you provide an example using the same?
image

@EmileSonneveld EmileSonneveld Apr 21, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-ran the UDP with more memory, and the job finished successfully:

  "driver-memory": "5G",
  "executor-memory": "5G",

Those are high values and need to be tweaked. There exists a feature where the UDP can specify what memory settings are needed

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi Emilie, I updated the comment with job ids that have the same spatial and temporal extends:
image
image

I since also push a commit to the code so the jobs run via UDP don't fail but they produce empty outputs.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a more minimal way to make the UDP work out-of-the-box:

  "default_job_options": {
    "python-memory": "4g"
  },
  "parameters": ...

Running a test ATM: j-26042114324240f2be3f326f1dc0a736

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I can check further tomorrow...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@EmileSonneveld have you had a chance to invest this issues further?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not see the cause directly. I made a ticket here to be sure to not lose track of it:
Open-EO/openeo-geopyspark-driver#1655
Is there a deadline for this project?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

End of the month, so it is quite urgent.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mileonai A temporary workaround can be to not parameterize ndwi_threshold, till this ticket is fixed.
Other parameters should work fine


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},
)


def build_water_land_mask_cube(
con: Connection,
bbox,
time_range,
max_cloud_coverage,
iterations,
ndwi_threshold,
) -> DataCube:
"""
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.
"""
_, 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,
)
return apply_morphology(cube, iterations)


def generate() -> dict:
"""
Create the MVP UDP for extracting waterlines from Sentinel-2 imagery.

Workflow:
1. Load Sentinel-2 data
2. Create water/land mask using S2_NDWI
3. Apply morphology
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.
"""

conn = openeo.connect(url="openeo.dataspace.copernicus.eu")

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.",
)

temporal_extent = Parameter.temporal_interval(
name="temporal_extent",
default=["2025-01-01", "2025-12-31"],
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.",
)

iterations = Parameter.integer(
name="iterations",
default=2,
description="Number of iterations for morphological operations.",
)

ndwi_threshold = Parameter.number(
name="ndwi_threshold",
default=WATERLAND_THRESHOLDS["S2_NDWI"].defaults["threshold"],
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,
time_range=temporal_extent,
max_cloud_coverage=max_cloud_coverage,
iterations=iterations,
ndwi_threshold=ndwi_threshold,
)

waterlines_cube = create_waterlines(water_land_mask, simplify_tolerance=simplify_tolerance)

return build_process_dict(
process_graph=waterlines_cube,
process_id="waterlines_v1",
summary="Waterlines extracted from Sentinel-2 using NDWI.",
description=(Path(__file__).parent / "README.md").read_text(),
parameters=[
spatial_extent,
temporal_extent,
max_cloud_coverage,
iterations,
ndwi_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)
Loading
Loading