Skip to content

tm2space/orbitlab-sample_app-river-mouth

Repository files navigation

River Mouth Detector — AICube Docker Sample

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.


Overview

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.


Application Details

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

Pipeline

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

Files in this Package

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

Required Task Configuration

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.


Quick Start

  1. Download river_mouth_detector_task.bz2 from the dashboard's Developer Examples & Templates → Docker tab.
  2. Unpack:
    tar -xjf river_mouth_detector_task.bz2
    cd river_mouth_detector
  3. 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.
  4. Wait for the task to run on the AICube; results appear in the dashboard's task-detail view.

Building the Docker Image Locally

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

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


Output Format

All outputs land in {ILC_OUTPUT_DIR} (defaults to /opt/ilc_player/results).

river_mouth_results.json

{
  "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

river_mouths_annotated.png

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.

river_mouth_report.txt

Human-readable text report mirroring the JSON, suitable for tailing in the dashboard's task-detail view or pasting into operational reports.

cust_output.txt

Timestamped log file (mirrors what's printed to stdout). Configured by AICubeImageLoader.get_logger("river_mouth").


AICubeImageLoader API Reference (methods used by this app)

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.

Construction & lifecycle

AICubeImageLoader(data_dir=None, output_dir=None, config_path=None, max_wait=300, poll_interval=5)

Reads player.config, scans the data directory, sums guidanceTargets across AOIs to know how many images to expect.

  • data_dir — defaults to ILC_INPUT_DIR env var, then /opt/ilc_player/data
  • output_dir — defaults to ILC_OUTPUT_DIR env var, then /opt/ilc_player/results
  • max_wait — total seconds iter_images() will wait between arrivals before giving up
  • poll_interval — seconds between filesystem polls inside wait_for_next_image()

loader.ensure_output_dir() → str

Creates output_dir if it doesn't exist. Returns the path. Idempotent.

Band introspection

loader.selected_bands → list[str]

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.

loader.require_bands(required) → dict[str, int]

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 starts

This app calls it in main() to bail out early on misconfigured tasks rather than crashing inside process_image() later.

Iteration

loader.iter_images(load=True, validate_bands=None, dtype="float32") → Iterator[ImageRecord]

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 to max_wait) to get the next path
  • If validate_bands is set, checks the sidecar's bands_saved field and skips images that don't include every required band
  • If load=True, opens the GeoTIFF via read_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)

ImageRecord dataclass

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)

Image / band helpers

loader.band(data, band_id, axis=0) → np.ndarray

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)

loader.bands(data, band_ids, axis=0, stack_axis=-1) → np.ndarray

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 cv2

loader.estimate_gsd_meters(geo) → float | None

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

loader.pixel_to_latlon(row, col, geo) → tuple[float, float]

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.

Output writers

loader.write_json(filename, data, indent=2) → str

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.

loader.write_text(filename, text) → str

Plain-text write to {output_dir}/{filename}. Returns the full path.

loader.output_dir → str

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.

Logger

AICubeImageLoader.get_logger(name="aicube", log_file=None, output_dir=None) → logging.Logger

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

Algorithmic Background

NDWI — why this works

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.

Ocean detection — the heuristic

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.

Channel extraction — multi-scale erosion

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.

Mouth localisation — four phases

  1. Coastline — extract a thin band along the ocean edge by dilating and eroding the ocean mask and taking their XOR.
  2. Mouth zone — pixels of the river that fall on the coastline are the mouth.
  3. Outlet selection — skeletonise the river and pick the skeleton point closest to the open ocean: that's the channel's exit point.
  4. Width + classification — the minor-axis length of the local mouth blob (in metres, scaled by resolution_m) gives the mouth width; cutoffs in CONFIG put it into major / medium / minor.

Customising / Extending

Adjust mouth-size cutoffs

Edit river_width_major_m and river_width_medium_m in CONFIG.

Adjust ocean detection sensitivity

Drop min_ocean_fraction to detect smaller bays as "ocean"; raise it to require larger water bodies.

Tune morphology

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.

Use a different water index

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.

Add deep-learning water segmentation

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.


Troubleshooting

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.


Repackaging After Modifications

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.

About

River Mouth Detector Sample App for OrbitLab

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors