Lab 31 - River Morphology

Objective: Extract a river mask from optical imagery, generate transects, and estimate river width statistics along a reach.

What You'll Learn

  • Build a water mask using MNDWI thresholding
  • Create reach-perpendicular transects from a centerline
  • Compute approximate river width statistics
  • Export transect measurements and masks for further analysis

Why This Matters

River morphology monitoring supports water management:

  • Flood modeling: Channel width is a key hydraulic parameter
  • Erosion/deposition: Track changes over time
  • Ecosystem health: River width affects habitat and temperature
  • Navigation: Shipping requires minimum channel widths

Before You Start

  • Prerequisites: Understand spectral indices and vector geometry operations.
  • Estimated time: 75 minutes
  • Materials: Earth Engine account and a river of interest.

Key Terms

MNDWI (Modified NDWI)
A water index using Green and SWIR bands: (Green - SWIR) / (Green + SWIR). Better at distinguishing water from built-up areas than NDWI.
Centerline
A line representing the center of a river channel, used as a reference for transects.
Transect
A line perpendicular to the centerline used to measure river width.
River Width
The distance across a river channel, typically measured perpendicular to flow direction.

Approach Overview

  1. Water Detection: Calculate MNDWI and threshold to create binary water mask
  2. Centerline: Draw or import a line representing the river channel
  3. Transects: Generate perpendicular lines at regular intervals
  4. Width Measurement: Measure water extent along each transect

Step 1: Create Water Mask with MNDWI

// AOI - Lower Mississippi River example
var aoi = ee.Geometry.Rectangle([-91.45, 30.35, -91.05, 30.60]);
Map.centerObject(aoi, 11);

// Load Landsat 8 and mask clouds
var L = ee.ImageCollection('LANDSAT/LC08/C02/T1_L2')
    .filterBounds(aoi)
    .filterDate('2021-07-01','2021-09-30')
    .filter(ee.Filter.lt('CLOUD_COVER', 20));

function maskL2(img){
  var qa = img.select('QA_PIXEL');
  var cloud = qa.bitwiseAnd(1 << 3).neq(0);
  var shadow = qa.bitwiseAnd(1 << 4).neq(0);
  return img.updateMask(cloud.or(shadow).not());
}

function toSR(img){
    var scale = 0.0000275; var offset = -0.2;
    var sr = maskL2(img).select(['SR_B3','SR_B6']).multiply(scale).add(offset)
        .rename(['green','swir1']);
    return sr.copyProperties(img, img.propertyNames());
}

var med = L.map(toSR).median().clip(aoi);

// Calculate MNDWI
var mndwi = med.expression('(g - s) / (g + s)', {
    g: med.select('green'), 
    s: med.select('swir1')
}).rename('MNDWI');

Map.addLayer(mndwi, {min:-0.5, max:0.5, palette:['#8c510a','#f6e8c3','#01665e']}, 'MNDWI');

// Create water mask
var water = mndwi.gt(0.2).selfMask();
Map.addLayer(water, {palette:['#2b83ba']}, 'Water mask');

Step 2: Create Centerline and Transects

Draw a centerline in the Code Editor or use a predefined one:

// Fallback centerline if none is drawn
var fallbackCenterline = ee.FeatureCollection([
    ee.Feature(ee.Geometry.LineString([[-91.40, 30.38], [-91.10, 30.58]]))
]);
var centerlineFc = (typeof centerline !== 'undefined') ? centerline : fallbackCenterline;

// Create points along the centerline every 200 meters
var spacing = 200; // meters between transects
var line = ee.Feature(centerlineFc.first()).geometry();
var length = line.length();
var pts = ee.FeatureCollection(ee.List.sequence(0, length, spacing).map(function(d){
    d = ee.Number(d);
    var p = line.coordinateAlong(d);
    return ee.Feature(ee.Geometry.Point(p), {d: d});
}));

// Calculate perpendicular angles at each point
var bearings = pts.map(function(f){
    var d = ee.Number(f.get('d'));
    var a = line.coordinateAlong(d);
    var b = line.coordinateAlong(d.add(10));
    var dx = ee.Number(ee.List(b).get(0)).subtract(ee.Number(ee.List(a).get(0)));
    var dy = ee.Number(ee.List(b).get(1)).subtract(ee.Number(ee.List(a).get(1)));
    var angle = ee.Number.atan2(dy, dx);
    var normal = angle.add(Math.PI/2.0);
    return f.set({normal: normal});
});

// Build transect lines (800m total width)
var half = 400; // half-length in meters
var transects = bearings.map(function(f){
    f = ee.Feature(f);
    var p = ee.Geometry.Point(f.geometry().coordinates());
    var ang = ee.Number(f.get('normal'));
    var dx = ee.Number(half).multiply(ee.Number(ang.cos()));
    var dy = ee.Number(half).multiply(ee.Number(ang.sin()));
    var p1 = p.translate(dx, dy, 'meters');
    var p2 = p.translate(dx.multiply(-1), dy.multiply(-1), 'meters');
    return ee.Feature(ee.Geometry.LineString([p1.coordinates(), p2.coordinates()]), f.toDictionary());
});

Map.addLayer(transects, {color: 'yellow'}, 'Transects');

Step 3: Estimate River Width

// Estimate width by measuring water fraction along each transect
var widths = transects.map(function(t){
    var geom = ee.Feature(t).geometry();
    var length = geom.length();
    var sampled = water.reduceRegion({
        reducer: ee.Reducer.mean(), 
        geometry: geom.buffer(15), 
        scale: 30, 
        bestEffort: true
    });
    var frac = ee.Number(sampled.get('MNDWI'));
    var w = ee.Algorithms.If(frac, ee.Number(frac).multiply(length), 0);
    return ee.Feature(geom, {approx_width_m: w});
});

print('Approximate widths (m)', widths.limit(10));

Limitation: This is an approximate method. For more accurate measurements, consider using the RivWidthCloud package or similar algorithms.

Step 4: Export Results

// Export transect widths as CSV
Export.table.toDrive({
    collection: widths,
    description: 'river_widths_transects',
    fileFormat: 'CSV'
});

// Export water mask as GeoTIFF
Export.image.toDrive({
    image: water.rename('water_mask'),
    description: 'water_mask_mndwi',
    region: aoi,
    scale: 30,
    maxPixels: 1e13
});

// Export transects as GeoJSON
Export.table.toDrive({
    collection: transects,
    description: 'transects_geojson',
    fileFormat: 'GeoJSON'
});

Check Your Understanding

  1. Why do we use MNDWI instead of NDWI for water detection?
  2. What challenges arise when measuring width on braided rivers?
  3. How would seasonal water level changes affect your width measurements?
  4. What would you need to add to track channel migration over time?

Troubleshooting

Problem: Water mask includes shadows or dark surfaces

Solution: Adjust the MNDWI threshold or add additional filtering (e.g., slope mask for hillshadows).

Problem: Transects are not perpendicular

Solution: This approach works best for straight reaches. For meandering rivers, compute local tangent more carefully.

Problem: Width values seem too large

Solution: Check transect length and ensure your water mask doesn't include floodplain lakes.

Pro Tips

  • Use RivWidthCloud: A GEE package specifically designed for river width measurement
  • JRC Global Surface Water: Pre-computed water occurrence can supplement Landsat
  • Multi-temporal: Compare low and high flow seasons for width variability
  • Validate with field data: Compare to gauging station cross-sections if available

?? Lab Submission

Subject: Lab 31 - River Morphology - [Your Name]

Submit:

  • Map: MNDWI, water mask, centerline, and transects
  • Table: approximate width (m) along transects (CSV export)
  • Short note: Limitations of this approach and how you'd improve it
  • EE script URL