A classical computer-vision pipeline that locates river mouths along the
coastline from a single multispectral capture. Segments water with NDWI,
identifies the ocean as the largest border-touching water body, extracts
narrow channels via multi-scale erosion of the ocean mask, then measures
each mouth's width and lat/lon. No ML model required — runs anywhere
the AICube base image runs. Demonstrates the canonical
AICubeImageLoader usage pattern without any
GPU dependencies.
This sample is a Docker-packaged application built for the AICube platform (NVIDIA Orin NX, ARM64). It receives multispectral GeoTIFF captures from the AICube task pipeline and produces a JSON list of detected river mouths with classification (major / medium / minor), width in metres, area in km², and geographic coordinates.
It is intended both as a runnable example for hydrology / coastal monitoring use cases and as a lightweight reference implementation demonstrating:
- The
AICubeImageLoader.iter_images()task pattern - Per-band slicing of multispectral data by name (
loader.band(...)) - Pixel-to-lat/lon conversion via
loader.pixel_to_latlon(...) - Output writing through the loader's
write_json()/write_text()helpers - A non-trivial CV pipeline with no model file to ship — bz2 stays under 100 KB
Why river mouths matter: they are the primary entry points for land-based plastic pollution into the ocean. Identifying their locations and sizes from orbit enables targeted monitoring and cleanup efforts.
| Field | Value |
|---|---|
| Type | Docker container running Python (no ML) |
| Container image | tm2space/aicube-base:latest (extended via Dockerfile) |
| Entry point | river_mouth_detector.py |
| Required bands | blue, green, red, nir-2 |
| Required image format | tiff |
| Max execution | 5 minutes (per player.config) |
| Hardware | Any (CPU-only; no GPU needed) |
| Output | JSON results, annotated PNG with detection circles, text report |
ImageRecord (from loader.iter_images())
│
▼
┌────────────────────────────────────────────────────────────────────┐
│ PER-IMAGE PIPELINE │
│ │
│ 1. Water segmentation — NDWI = (G − NIR)/(G + NIR), Otsu thresh │
│ 2. Morphology cleanup — CLOSE small gaps + OPEN salt-and-pepper │
│ 3. Ocean identification — largest water body touching image edge │
│ 4. Channel extraction — multi-scale erosion of the ocean leaves │
│ narrow channels behind │
│ 5. Mouth localisation — coastline ∩ channel + skeleton outlet │
│ point + minor-axis width measurement │
│ 6. Pixel → lat/lon — loader.pixel_to_latlon() per detection │
└────────────────────────────────────────────────────────────────────┘
│
▼
JSON + annotated PNG + text report → loader.write_json/write_text
Mouth classification thresholds (from CONFIG, in metres):
| Class | Width |
|---|---|
major |
> 500 m |
medium |
100–500 m |
minor |
< 100 m |
| File | Purpose |
|---|---|
player.config |
Task config — bands, image_format, AOI guidance |
river_mouth_detector.py |
Main script (entry point) |
aicube_image_loader.py |
Single-file helper library — also available as a standalone download from the AICube Libraries tab |
Dockerfile |
Builds the runtime image on top of tm2space/aicube-base:latest |
requirements.txt |
Python deps installed on top of the base image |
README.md |
This file |
The bundled player.config already sets these correctly. If you create a new
task in the OrbitLab dashboard, mirror these settings:
{
"container": "docker",
"type": "app",
"entry": "river_mouth_detector.py",
"container_image": "tm2space/aicube-base:latest",
"bands": ["blue", "green", "red", "nir-2"],
"aoi_exec": [
{
"image_format": "tiff",
"guidance": [...],
...
}
]
}Why TIFF: the script does NDWI = (Green − NIR) / (Green + NIR) on raw reflectance values. JPEG/PNG would gamma-compress and silently truncate beyond 3 channels; raw is a header-less binary blob rasterio can't open. TIFF preserves both the bit depth and the per-band layout the loader expects.
Why all four bands: NDWI itself only needs green + nir-2. Red and blue
are used for the annotated RGB preview at the end of the run. The
require_bands() guard in main() insists on all four up front so the
preview path can't crash mid-run after a successful detection pass.
- Download
river_mouth_detector_task.bz2from the dashboard's Developer Examples & Templates → Docker tab. - Unpack:
tar -xjf river_mouth_detector_task.bz2 cd river_mouth_detector - Upload the unpacked folder via the OrbitLab dashboard's task creation flow. The dashboard packages it into a satellite uplink package and queues the task.
- Wait for the task to run on the AICube; results appear in the dashboard's task-detail view.
To test before submitting to OrbitLab, or to extend the app:
# 1. Build the image
docker build -t my-river-mouth-app .
# 2. Run it with mock input/output dirs
docker run --rm \
-v $(pwd)/data:/opt/ilc_player/data \
-v $(pwd)/results:/opt/ilc_player/results \
-e ILC_INPUT_DIR=/opt/ilc_player/data \
-e ILC_OUTPUT_DIR=/opt/ilc_player/results \
my-river-mouth-app
# 3. To create an uplink package for OrbitLab submission, use the helper
# script from the sample_apps repo:
./create-uplink-package.sh my-river-mouth-app river_mouth_uplink.tar.gzThe Dockerfile inherits from tm2space/aicube-base:latest, which already
includes Python 3, NumPy, OpenCV, scikit-image, SciPy, Rasterio, and PyProj
— everything this app needs. requirements.txt re-pins them for transparency.
All outputs land in {ILC_OUTPUT_DIR} (defaults to /opt/ilc_player/results).
{
"task": "river_mouth_detection",
"timestamp": "2026-05-08T12:34:56+00:00",
"images_processed": 4,
"image_names": ["img_0", "img_1", "img_2", "img_3"],
"resolution_m": 10.0,
"detection_mode": "ndwi",
"total_river_mouths": 7,
"summary": {"major": 2, "medium": 3, "minor": 2},
"river_mouths": [
{
"river_id": 1,
"source_image": "img_0",
"lat": 14.812345,
"lon": 74.123456,
"pixel_row": 1234,
"pixel_col": 567,
"width_m": 612.4,
"classification": "major",
"bearing_degrees": 142.3,
"area_km2": 1.847
},
...
]
}Field semantics:
| Field | Meaning |
|---|---|
lat, lon |
WGS84 coordinates of the mouth centroid (rounded to 6 dp) |
pixel_row, pixel_col |
Row/column in the source image |
width_m |
Mouth width in metres (minor-axis length of the local mouth blob) |
classification |
major (>500m), medium (100-500m), minor (<100m) |
bearing_degrees |
Direction the river flows toward (0° = north, 90° = east) |
area_km2 |
Approximate river surface area within the scene |
Upscaled true-colour RGB composite with one labelled circle per detection, colour-coded by classification (red = major, orange = medium, yellow = minor) and sized by mouth width. Built from the image with the most detections so the preview is informative.
Human-readable text report mirroring the JSON, suitable for tailing in the dashboard's task-detail view or pasting into operational reports.
Timestamped log file (mirrors what's printed to stdout). Configured by
AICubeImageLoader.get_logger("river_mouth").
The loader is a single-file helper that handles every interaction with the AICube task pipeline. This app uses the following surface — see the loader's own docstring for the full API.
Reads player.config, scans the data directory, sums guidanceTargets
across AOIs to know how many images to expect.
data_dir— defaults toILC_INPUT_DIRenv var, then/opt/ilc_player/dataoutput_dir— defaults toILC_OUTPUT_DIRenv var, then/opt/ilc_player/resultsmax_wait— total secondsiter_images()will wait between arrivals before giving uppoll_interval— seconds between filesystem polls insidewait_for_next_image()
Creates output_dir if it doesn't exist. Returns the path. Idempotent.
Dashboard band ids selected for this task, in the order saved by the dashboard
(e.g. ['blue', 'green', 'red', 'nir-2']). Falls back to RGB if player.config
has no bands field.
Verifies every band id in required is present. Returns a {band_id: axis_index}
map. Raises ValueError listing the missing bands if any are absent.
loader.require_bands(['blue', 'green', 'red', 'nir-2'])
# raises ValueError if any are missing — fails fast before processing startsThis app calls it in main() to bail out early on misconfigured tasks
rather than crashing inside process_image() later.
Generator that yields one ImageRecord per arriving capture. Replaces the
manual while not loader.all_images_received: ... loop. Internally:
- Calls
wait_for_next_image()(blocks up tomax_wait) to get the next path - If
validate_bandsis set, checks the sidecar'sbands_savedfield and skips images that don't include every required band - If
load=True, opens the GeoTIFF viaread_image()and decodes it - Logs and skips images that fail to decode
for record in loader.iter_images(validate_bands=['blue','green','red','nir-2']):
log.info(f"[{record.index}/{record.total}] {record.name}")
process(record.data, record.geo, record.metadata)| Field | Type | Description |
|---|---|---|
path |
str | Absolute path to the image file |
name |
str | Base filename without extension (e.g. img_3) |
aoi_name |
str | Immediate parent directory (the AOI subfolder) |
index |
int | 1-based, matches loader.processed_count |
total |
int | Total expected guidance targets (from player.config) |
metadata |
dict | None | Parsed sidecar JSON (bbox, polygon, capture_date, …) |
data |
np.ndarray | None | (bands, H, W) float32 raster (None if load=False) |
geo |
dict | None | {transform, crs, bounds, width, height} (None if load=False) |
Slice one band out of a multi-band array. Accepts both dashboard ids
('blue', 'green', 'nir-2') and Sentinel-2 names ('B02', 'B03',
'B08').
green = loader.band(record.data, 'green') # (H, W)
nir = loader.band(record.data, 'nir-2') # (H, W)Stack multiple bands. Default stack_axis=-1 returns (H, W, N) — what
OpenCV/PIL want for an RGB display image. Use stack_axis=0 to get
(N, H, W) if you're going to do channel-wise math.
rgb = loader.bands(image_data, ['red', 'green', 'blue']) # (H, W, 3) for cv2Reads the affine transform's pixel-width. Returns metres-per-pixel when the
CRS is projected (UTM, etc.); None otherwise (geographic EPSG:4326 — degrees,
not metres). This app uses it to set CONFIG["resolution_m"], which all
mouth-width and area calculations depend on for accurate physical units.
Convert pixel coordinates to (lat, lon) rounded to 6 decimal places.
Reprojects to WGS84 (EPSG:4326) when the source CRS is something else.
Returns (None, None) if geo is missing.
lat, lon = loader.pixel_to_latlon(centroid_row, centroid_col, record.geo)This app uses it to attach geographic coordinates to every detected river mouth so customers can map them in any GIS tool.
JSON-dump data to {output_dir}/{filename}. Creates output_dir if
needed. Built-in serialiser handles datetime and NumPy scalars/arrays.
Returns the full path written.
Plain-text write to {output_dir}/{filename}. Returns the full path.
The result directory path (no side effects). Used in this app as
os.path.join(loader.output_dir, "river_mouths_annotated.png") for the
OpenCV imwrite since cv2 can't go through write_text.
Returns a stdlib logger writing to stdout AND {output_dir}/cust_output.txt.
Idempotent — repeated calls with the same name return the configured logger
without duplicate handlers. Format matches what the dashboard's task-detail
view parses.
log = AICubeImageLoader.get_logger("river_mouth")
log.info("Starting...")Liquid water absorbs near-infrared (NIR) strongly while still reflecting
visible green, so water pixels have NIR < Green and end up with positive
NDWI. Vegetation and bare soil are the opposite — they reflect NIR strongly
— and yield negative NDWI. A threshold around 0 separates water from land
cleanly in most conditions. We pick the threshold automatically with Otsu's
method (which finds the cutoff that maximises between-class variance in the
NDWI histogram), floored at water_ndwi_threshold so a particularly cloudy
or hazy scene can't push the threshold so negative that land bleeds in.
In a coastal scene the ocean is (a) the largest water body AND (b) it
touches the image border. Inland lakes can be large but are fully
surrounded by land and never touch all the way to the edge, so requiring
border contact filters them out. The script returns "no ocean detected"
when no water region touches the border, or when the largest border-
touching region is implausibly small (min_ocean_fraction = 5%) — the
caller treats that as "this scene isn't coastal" and bails on river-mouth
extraction.
Erode the ocean mask with a structuring element larger than any river. The "open ocean" survives erosion; narrow river channels (estuaries, deltas) get eaten away. The difference between the original ocean and that eroded-then-dilated open ocean is the set of pixels that are "ocean-connected but channel-shaped" — those are the river mouth candidates. Repeating at multiple erosion sizes (15, 25, 40 px kernels) catches both skinny streams and wide deltas.
- Coastline — extract a thin band along the ocean edge by dilating and eroding the ocean mask and taking their XOR.
- Mouth zone — pixels of the river that fall on the coastline are the mouth.
- Outlet selection — skeletonise the river and pick the skeleton point closest to the open ocean: that's the channel's exit point.
- Width + classification — the minor-axis length of the local mouth
blob (in metres, scaled by
resolution_m) gives the mouth width; cutoffs inCONFIGput it into major / medium / minor.
Edit river_width_major_m and river_width_medium_m in CONFIG.
Drop min_ocean_fraction to detect smaller bays as "ocean"; raise it to
require larger water bodies.
morph_kernel_size controls how aggressively the water mask gets cleaned
up. Larger = more aggressive smoothing, may merge nearby small water
bodies. min_river_area_pixels drops connected components smaller than
this — useful if you're getting a lot of small false positives.
Swap NDWI for MNDWI (uses Green + SWIR instead of Green + NIR) if your captures include a SWIR band. MNDWI is more robust against built-up surfaces being confused with water.
The natural upgrade path: train a lightweight binary water-segmentation
U-Net on Sentinel-2 data, export to ONNX, and load via ONNX Runtime in
segment_water() (replacing the NDWI math). The rest of the pipeline
needs no changes — it consumes a binary mask either way.
FATAL: Required bands [...] are missing from player.config
The dashboard task wasn't configured with the bands this app needs. Re-create
the task with bands: ["blue", "green", "red", "nir-2"].
No ocean detected - may not be a coastal region
Either the scene is genuinely inland, or the ocean is smaller than 5% of
the image. The latter happens when a coastal scene clips just barely along
one edge — try a larger AOI or drop min_ocean_fraction in CONFIG.
No river channels detected
The ocean was found but no narrow channels survived the erosion step. The
scene may genuinely have no rivers, or the resolution is too coarse to
resolve them at the configured kernel sizes (try smaller kernels in
erosion_sizes inside find_river_mouths()).
Image has wrong band count / band 'X' is not in selected bands
The capture probably wasn't TIFF — JPEG/PNG truncate beyond 3 channels.
Confirm image_format: "tiff" in player.config.aoi_exec[*].
Detected mouths are at wrong lat/lon
The image's CRS metadata may be missing or wrong. Check the GeoTIFF with
gdalinfo <file>.tif — if it shows no projection or an unexpected one,
the satellite imagery pipeline upstream isn't tagging captures correctly.
loader.pixel_to_latlon() will return (None, None) rather than wrong
coordinates when the CRS is missing.
When you change anything in this directory, refresh the dashboard download:
# from the parent of this directory:
tar -cjf river_mouth_detector_task.bz2 river_mouth_detector/Drop the resulting bz2 into orbitlab-dashboard/public/sample-aic-apps/,
replacing the old file. The last_updated field in ExamplesDialog.tsx
should be bumped to today's date.