Universal Code Hub Upgrade
Script Library
ResearchVegetationAdvanced

Harmonized Landsat 5–9 NDVI Trend (1984–today, Mann-Kendall + Sen Slope)

40-year per-pixel greening/browning trend with significance map, ready for publication Fig. 1.

Cite: Gorelick et al. 2017; Sen 1968; Kendall 1975
Pipeline (5 steps)
  1. Harmonize Landsat 5/7/8/9 surface reflectance to common bands
  2. Mask clouds/shadows via QA_PIXEL bitmask
  3. Compute annual NDVI maxima (anti-cloud composite)
  4. Per-pixel Mann-Kendall tau + Sen slope
  5. Threshold p<0.05; export Cloud-Optimised GeoTIFF + CSV
Datasets
  • landsat5
  • landsat7
  • landsat8
  • landsat9
AOI modes
adminassetdraw
Simulated previewPaste the script in Earth Engine for live results
Classification legend
Significant greening
No change
Significant browning
Outputs the script produces
map NDVI Sen slope
map Mann-Kendall p<0.05 mask
chart Annual NDVI line chart
table Zonal trend table
export COG export to Drive + Asset
Open Code Editor ↗
adv-ndvi-harmonized-trend.js · 87 lines
// ============================================================
// Harmonized Landsat NDVI Trend (1984 -> today)
// Mann-Kendall + Sen slope per pixel, p<0.05 significance
// ============================================================
var startDate = '1984-01-01';
var endDate   = ee.Date(Date.now());

// --- Cloud / shadow mask via QA_PIXEL bitmask
function maskL2(img) {
  var qa = img.select('QA_PIXEL');
  var dilatedCloud = 1 << 1, cloud = 1 << 3, shadow = 1 << 4;
  var mask = qa.bitwiseAnd(dilatedCloud).eq(0)
              .and(qa.bitwiseAnd(cloud).eq(0))
              .and(qa.bitwiseAnd(shadow).eq(0));
  return img.updateMask(mask)
            .multiply(0.0000275).add(-0.2)
            .copyProperties(img, ['system:time_start']);
}

// --- Harmonize each sensor to a common (red, nir) pair
function harmonize(coll, redB, nirB) {
  return coll.map(maskL2).map(function(i){
    return i.normalizedDifference([nirB, redB]).rename('NDVI')
            .copyProperties(i, ['system:time_start']);
  });
}
var L5 = harmonize(ee.ImageCollection('LANDSAT/LT05/C02/T1_L2').filterBounds(aoi), 'SR_B3', 'SR_B4');
var L7 = harmonize(ee.ImageCollection('LANDSAT/LE07/C02/T1_L2').filterBounds(aoi), 'SR_B3', 'SR_B4');
var L8 = harmonize(ee.ImageCollection('LANDSAT/LC08/C02/T1_L2').filterBounds(aoi), 'SR_B4', 'SR_B5');
var L9 = harmonize(ee.ImageCollection('LANDSAT/LC09/C02/T1_L2').filterBounds(aoi), 'SR_B4', 'SR_B5');
var ndviAll = L5.merge(L7).merge(L8).merge(L9).filterDate(startDate, endDate);

// --- Annual maxima (anti-cloud composite)
var years = ee.List.sequence(1984, ee.Date(endDate).get('year'));
var annual = ee.ImageCollection(years.map(function(y){
  y = ee.Number(y);
  var img = ndviAll
    .filter(ee.Filter.calendarRange(y, y, 'year'))
    .max().rename('NDVI');
  return img.set('year', y).set('system:time_start', ee.Date.fromYMD(y,7,1).millis());
}));

// --- Mann-Kendall tau + Sen slope per pixel
var afterFilter = ee.Filter.lessThan({leftField:'system:time_start', rightField:'system:time_start'});
var joined = ee.ImageCollection(ee.Join.saveAll('after').apply({
  primary: annual, secondary: annual, condition: afterFilter
}));
var sign = ee.ImageCollection(joined.map(function(curr){
  var afters = ee.ImageCollection.fromImages(curr.get('after'));
  return afters.map(function(a){
    return ee.Image(a).neq(curr).multiply(ee.Image(a).subtract(curr).where(a.eq(curr), 0).divide(ee.Image(a).subtract(curr).abs()));
  });
}).flatten());
var kendall = sign.reduce('sum', 2).rename('kendall');
var slope = annual.reduce(ee.Reducer.sensSlope()).select('slope').rename('sen_slope');

// --- Significance (n=count, var = n(n-1)(2n+5)/18)
var n = annual.count();
var varS = n.multiply(n.subtract(1)).multiply(n.multiply(2).add(5)).divide(18);
var z = kendall.divide(varS.sqrt());
var pSig = z.abs().gt(1.96);   // ~p<0.05 two-tailed

// --- Visualisation
Map.centerObject(aoi, 7);
Map.addLayer(slope.updateMask(pSig), {min:-0.01, max:0.01, palette:['#dc2626','#f5f5f5','#16a34a']}, 'NDVI Sen slope (sig)');
Map.addLayer(pSig.updateMask(pSig), {palette:['#000000']}, 'p<0.05 mask', false);

// --- Charts + zonal stats
print(ui.Chart.image.series({
  imageCollection: annual, region: aoi, reducer: ee.Reducer.mean(),
  scale: 1000, xProperty: 'system:time_start'
}).setOptions({title: 'Annual NDVI maxima (AOI mean)', vAxis: {title: 'NDVI'}}));

var zonal = slope.addBands(pSig.rename('sig')).reduceRegions({
  collection: aoi.geometry().bounds().coveringGrid('EPSG:4326', 10000),
  reducer: ee.Reducer.mean(), scale: 1000, tileScale: 4
});

// --- Exports
Export.image.toDrive({image: slope.clip(aoi), description: 'NDVI_SenSlope',
  folder: 'gee_exports', region: aoi, scale: 30, maxPixels: 1e13,
  fileFormat: 'GeoTIFF', formatOptions: {cloudOptimized: true}});
Export.image.toDrive({image: pSig.clip(aoi),  description: 'NDVI_pSig',
  folder: 'gee_exports', region: aoi, scale: 30, maxPixels: 1e13,
  fileFormat: 'GeoTIFF', formatOptions: {cloudOptimized: true}});
Export.table.toDrive({collection: zonal, description: 'NDVI_Trend_Zonal_CSV',
  folder: 'gee_exports', fileFormat: 'CSV'});
#NDVI#trend#Mann-Kendall#Landsat#publication