kernel NDVI

//VERSION = 3
//by András Zlinszky @azlinszky - based on https://www.sentinel-hub.com/faq/how-get-s2a-scene-classification-sentinel-2/ and https://www.science.org/doi/10.1126/sciadv.abc7447

function setup() {
    return {
        input: ["B04", "B08", "SCL", "dataMask"],
        output: [
            { bands: 4 }
        ]
    }
}

const kndvi_ramp = [
    [-1.1, [0, 0, 0]],
    [-0.1, [0.86, 0.86, 0.86]],
    [0, [1, 1, 0.88]],
    [-0.2, [0.75, 0.75, 0.75]],
    [0.025, [1, 0.98, 0.8]],
    [0.05, [0.93, 0.91, 0.71]],
    [0.075, [0.87, 0.85, 0.61]],
    [0.1, [0.8, 0.78, 0.51]],
    [0.125, [0.74, 0.72, 0.42]],
    [0.15, [0.69, 0.76, 0.38]],
    [0.175, [0.64, 0.8, 0.35]],
    [0.2, [0.57, 0.75, 0.32]],
    [0.25, [0.5, 0.7, 0.28]],
    [0.3, [0.44, 0.64, 0.25]],
    [0.35, [0.38, 0.59, 0.21]],
    [0.4, [0.31, 0.54, 0.18]],
    [0.45, [0.25, 0.49, 0.14]],
    [0.5, [0.19, 0.43, 0.11]],
    [0.55, [0.13, 0.38, 0.07]],
    [0.6, [0.06, 0.33, 0.04]]
]
visualizer = new ColorRampVisualizer(kndvi_ramp);
const cloud_palette = {
    0: [0, 0, 0], // No Data (Missing data) - black  
    1: [1, 0, 0.016], // Saturated or defective pixel - red 
    2: [0.525, 0.525, 0.525], // Topographic casted shadows ("Dark features/Shadows" for data before 2022-01-25) - very dark grey
    3: [0.467, 0.298, 0.043], // Cloud shadows - dark brown
    6: [0, 0, 1], // Water (dark and bright) - blue
    7: [0.506, 0.506, 0.506], // Unclassified - dark grey
    8: [0.753, 0.753, 0.753], // Cloud medium probability - grey
    9: [0.949, 0.949, 0.949], // Cloud high probability - white
    10: [0.733, 0.773, 0.925], // Thin cirrus - very bright blue
    11: [0.325, 1, 0.980], // Snow or ice - very bright pink
}

function evaluatePixel(sample) {
    let kndvi = Math.tanh(Math.pow(((sample.B08 - sample.B04) / (sample.B08 + sample.B04)), 2));
    let imgVals = kndvi <= 0.6 ? visualizer.process(kndvi) : [0, 0.27, 0];
    let is_clouds = Object.keys(cloud_palette).includes(sample.SCL.toString())
    imgVals = is_clouds ? cloud_palette[sample.SCL] : imgVals;
    return imgVals.concat(sample.dataMask)
}
//VERSION = 3
//by András Zlinszky @azlinszky - based on https://www.sentinel-hub.com/faq/how-get-s2a-scene-classification-sentinel-2/ and https://www.science.org/doi/10.1126/sciadv.abc7447

function setup() {
    return {
        input: ["B04", "B08", "SCL", "dataMask"],
        output: [
            { id: "default", bands: 4 },
            { id: "index", bands: 1, sampleType: "FLOAT32" },
            { id: "eobrowserStats", bands: 1, sampleType: "FLOAT32" },
            { id: "dataMask", bands: 1 }
        ]
    }
}

const kndvi_ramp = [
    [-1.1, [0, 0, 0]],
    [-0.1, [0.86, 0.86, 0.86]],
    [0, [1, 1, 0.88]],
    [-0.2, [0.75, 0.75, 0.75]],
    [0.025, [1, 0.98, 0.8]],
    [0.05, [0.93, 0.91, 0.71]],
    [0.075, [0.87, 0.85, 0.61]],
    [0.1, [0.8, 0.78, 0.51]],
    [0.125, [0.74, 0.72, 0.42]],
    [0.15, [0.69, 0.76, 0.38]],
    [0.175, [0.64, 0.8, 0.35]],
    [0.2, [0.57, 0.75, 0.32]],
    [0.25, [0.5, 0.7, 0.28]],
    [0.3, [0.44, 0.64, 0.25]],
    [0.35, [0.38, 0.59, 0.21]],
    [0.4, [0.31, 0.54, 0.18]],
    [0.45, [0.25, 0.49, 0.14]],
    [0.5, [0.19, 0.43, 0.11]],
    [0.55, [0.13, 0.38, 0.07]],
    [0.6, [0.06, 0.33, 0.04]]
]
visualizer = new ColorRampVisualizer(kndvi_ramp);
const cloud_palette = {
    0: [0, 0, 0], // No Data (Missing data) - black  
    1: [1, 0, 0.016], // Saturated or defective pixel - red 
    2: [0.525, 0.525, 0.525], // Topographic casted shadows ("Dark features/Shadows" for data before 2022-01-25) - very dark grey
    3: [0.467, 0.298, 0.043], // Cloud shadows - dark brown
    6: [0, 0, 1], // Water (dark and bright) - blue
    7: [0.506, 0.506, 0.506], // Unclassified - dark grey
    8: [0.753, 0.753, 0.753], // Cloud medium probability - grey
    9: [0.949, 0.949, 0.949], // Cloud high probability - white
    10: [0.733, 0.773, 0.925], // Thin cirrus - very bright blue
    11: [0.325, 1, 0.980], // Snow or ice - very bright pink
}

function evaluatePixel(sample) {
    let kndvi = Math.tanh(Math.pow(((sample.B08 - sample.B04) / (sample.B08 + sample.B04)), 2));
    let imgVals = kndvi <= 0.6 ? visualizer.process(kndvi) : [0, 0.27, 0];
    let is_clouds = Object.keys(cloud_palette).includes(sample.SCL.toString())
    imgVals = is_clouds ? cloud_palette[sample.SCL] : imgVals;
    return {
        default: imgVals.concat(sample.dataMask),
        index: [is_clouds ? NaN : kndvi],
        eobrowserStats: [is_clouds ? NaN : kndvi],
        dataMask: [sample.dataMask],
    }
}
//VERSION = 3
//by András Zlinszky @azlinszky - based on https://www.sentinel-hub.com/faq/how-get-s2a-scene-classification-sentinel-2/ and https://www.science.org/doi/10.1126/sciadv.abc7447

function setup() {
    return {
        input: ["B04", "B08", "SCL", "dataMask"],
        output:
            { 
                bands: 1, 
                sampleType: "FLOAT32" 
            },
    }
}

const cloud_palette = {
    0: [0, 0, 0], // No Data (Missing data) - black  
    1: [1, 0, 0.016], // Saturated or defective pixel - red 
    2: [0.525, 0.525, 0.525], // Topographic casted shadows ("Dark features/Shadows" for data before 2022-01-25) - very dark grey
    3: [0.467, 0.298, 0.043], // Cloud shadows - dark brown
    6: [0, 0, 1], // Water (dark and bright) - blue
    7: [0.506, 0.506, 0.506], // Unclassified - dark grey
    8: [0.753, 0.753, 0.753], // Cloud medium probability - grey
    9: [0.949, 0.949, 0.949], // Cloud high probability - white
    10: [0.733, 0.773, 0.925], // Thin cirrus - very bright blue
    11: [0.325, 1, 0.980], // Snow or ice - very bright pink
}

function evaluatePixel(sample) {
    let kndvi = Math.tanh(Math.pow(((sample.B08 - sample.B04) / (sample.B08 + sample.B04)), 2));
    let is_clouds = Object.keys(cloud_palette).includes(sample.SCL.toString())
return [(is_clouds ? null : kndvi)]
}

Evaluate and Visualize

Interlaken, Switzerland (to show green vegetation, bare rock, snow and ice, and clouds together with kNDVI)


Description of the Script

kNDVI (Kernel NDVI) is a recently proposed vegetation index (Camps-Valls 2021) based on a nonlinear generalization of the popular Normalized Differential Vegetation Index (NDVI). kNDVI works by re-scaling the relation between the difference in Red and Near Infrared (NIR) from a simple linear difference to a more complex relationship. Here we coded the simplest definition of kNDVI with a Radial Basis Function kernel as proposed in Camps-Valls (2021). It seems based on the cited literature that kNDVI provides better correlation with field biomass or crop yield and provides higher accuracy for classification than NDVI. Note that for some machine learning applications, since this is “just” a rescaling of the spectral index, kNDVI might not perform differently than NDVI. However, due to the second-order relationship with the difference of Red and NIR, kNDVI can produce high values when the difference is negative. Such cases include non-vegetated surfaces, which may thus be incorrectly displayed. Therefore, kNDVI is highly suitable for following vegetation patterns and processes, but not at all suitable on its own for separating vegetated surfaces from water, ice or clouds. This was solved by embedding the kNDVI script into the simple scene classification available in Sentinel-2 L2A data based on Sen2Cor outputs. The resulting script now provides shades of green, yellow or white for vegetation; and blue for water, gray for clouds, brown for cloud shadows, cyan for snow and ice and red for defective pixels. I trust that by adding this classification functionality to the script kNDVI can unfold its potential for visualizing vegetation processes.

The script has 4 different outputs:

  • Default has 4 bands (Red, Green, Blue and Transparency, for visualization in Copernicus Browser)
  • Index is the value of kndvi for the purpose of generating histograms in the Browser, unless the image is cloudy - then it is null.
  • eobrowserStats is the value of kndvi for the purpose of generating Statistics API outputs such as time series, unless the image is cloudy - then it is null.
  • dataMask is a simple mask for valid/invalid pixels - note that cloudy pixels will also have valid values!

Description of representative images

kNDVI is highly sensitive to the typical range of vegetation greenness, and is less sensitive to saturation at high biomass levels. Therefore it is useful for visualizing fine-scale patterns in crops or vegetation.

Grasslands and crop fields near Püspökladány, Hungary, Sentinel-2A, 2019-05-19, kNDVI. The image highlights the fine patterns in vegetation greenness and biomass governed by microtopography. The meandering lines across the grassland are old river channels that are somewhat lower and therefore wetter than their surroundings.

'Sentinel-2 05 May 2023, Püspökladány, Hungary'

For comparison, here is a visualization of the same image with the default NDVI script available in Copernicus Browser. Sentinel-2A, 2019-05-19, NDVI. This visualization is saturated for large parts of the image, not showing the patterns of the grassland.

'Sentinel-2 05 May 2023, Püspökladány, Hungary'

Finally, for orientation, here is a true colour image. Sentinel-2A, 2019-05-19, True Color. Here you can see the various land cover categories, the haze and clouds affecting the area and the wide variety of grassland biomass.

'Sentinel-2 05 May 2023, Püspökladány, Hungary'

References

  • Camps-Valls, Gustau, et al. “A unified vegetation index for quantifying the terrestrial biosphere.” Science Advances 7.9 (2021): eabc7447. link