Ulyssys Water Quality Viewer (UWQV)

//VERSION=3
const PARAMS = {
  // Indices
  chlIndex: 'default',
  tssIndex: 'default',
  watermaskIndices: ['ndwi', 'hol'],
  // Limits
  chlMin: -0.005,
  chlMax: 0.05,
  tssMin: 0.075,
  tssMax: 0.185,
  waterMax: 0,
  cloudMax: 0.02,
  // Graphics
  foreground: 'default',
  foregroundOpacity: 1.0,
  background: 'default',
  backgroundOpacity: 1.0
};
//* PARAMS END

/**
 * Returns indices object used for output calculation
 * The returned object is different for Sentinel-2 and Sentinel-3 satellites
  * Here only defined as strings and gets evaluated only when really needed
 * (Tip 4: Calculate as needed at https://medium.com/sentinel-hub/custom-scripts-faster-cheaper-better-83f73894658a)
 * natural: natural (rgb) color image
 * chl: chlorophyll indices
 * tss: sediment indices
 * watermask: watermask indices *
 *
 * @param {boolean} isSentinel3: is it Sentinel-3 or not (=Sentinel-2)
 */
function getIndices(isSentinel3) {
  return isSentinel3 ? {
    natural: "[1.0*B07+1.4*B09-0.1*B14,1.1*B05+1.4*B06-0.2*B14,2.6*B04-B14*0.6]",
    chl: {
      flh: "B10-1.005*(B08+(B11-B08)*((0.681-0.665)/(0.708-0.665)))",
      rlh: "B11-B10-(B18-B10*((0.70875-0.68125)*1000.0))/((0.885-0.68125)*1000.0)",
      mci: "B11-((0.75375-0.70875)/(0.75375-0.68125))*B10-(1.0-(0.75375-0.70875)/(0.75375-0.68125))*B12"
    },
    tss: {
      b07: "B07",
      b11: "B11"
    },
    watermask: {
      ndwi: "(B06-B17)/(B06+B17)"
    }
  } : {
      natural: "[2.5*B04,2.5*B03,2.5*B02]",
      chl: {
        rlh: "B05-B04-(B07-B04*((0.705-0.665)*1000.0))/((0.783-0.665)*1000.0)",
        mci: "B05-((0.74-0.705)/(0.74-0.665))*B04-(1.0-(0.74-0.705)/(0.74-0.665))*B06"
      },
      tss: {
        b05: "B05"
      },
      watermask: {
        ndwi: "(B03-B08)/(B03+B08)"
      }
    };
}

/**
 * Blends between two layers
 * Uses https://pierre-markuse.net/2019/03/26/sentinel-3-data-visualization-in-eo-browser-using-a-custom-script/
 *
 * @param {Object} layer1: first (top) layer
 * @param {Object} layer2: second (bottom) layer
 * @param {number} opacity1: first layer opacity
 * @param {number} opacity2: second layer opacity
 */
function blend(layer1, layer2, opacity1, opacity2) {
  return layer1.map(function (num, index) {
    return (num / 100) * opacity1 + (layer2[index] / 100) * opacity2;
  });
}

/**
 * Returns an opacity (alpha) value between 0 and 100 for a given index based on min and max values
 *
 * @param {Object} index: selected index
 * @param {number} min: user defined minimum value
 * @param {number} max: user defined maximum value
 */
function getAlpha(index, min, max) {
  if (min + (max - min) / 2 < index) {
    return 100;
  }
  return index <= min ?
    0 :
    index >= max ?
      1 :
      100 * ((index - min / 2) / (max - min));
}

/**
 * Returns a color palette for chlorophyll or sediment index
 *
 * @param {String} type: palette type ('chl' for chlorophyll, 'tss' for sediment)
 * @param {Object} index: user selected index
 * @param {number} min: user defined minimum value
 * @param {number} max: user defined maximum value
 * @param {boolean} isSentinel3Flh: is it Sentinel3 && is 'flh' is the user selected chlorophyll index (only for 'chl' type)
 */
function getColors(type, index, min, max, isSentinel3Flh) {
  let colors, palette;
  switch (type) {
    case 'chl':
      palette = [
        [0.0034, 0.0142, 0.163], // #01042A (almost black blue)
        [0, 0.416, 0.306], // #006A4E (bangladesh green)
        [0.486, 0.98, 0], //#7CFA00 (dark saturated chartreuse)
        [0.9465, 0.8431, 0.1048], //#F1D71B (light washed yellow)
        [1, 0, 0] // #FF0000 (red)
      ];
      // In case of Sentinel-3 && 'flh' the palette has to be reversed and min and max values also needed to be adjusted
      if (isSentinel3Flh) {
        palette = palette.reverse();
        min = min * 10;
        max = max / 10;
      }
      colors = colorBlend(
        index,
        [min, min + (max - min) / 3, (min + max) / 2, max - (max - min) / 3, max],
        palette
      );
      break;
    case 'tss':
      palette = [
        [0.961, 0.871, 0.702], // #F5DEB3 (wheat)
        [0.396, 0.263, 0.129] // #654321 (dark brown)
      ];
      colors = colorBlend(
        index,
        [min, max],
        palette
      );
      break;
    default:
      break;
  }
  return colors;
}

/**
 * Returns true if the pixel covers area of pure water without any cloud, shadow or snow, otherwise returns false
 * Based on the algorithm by Hollstein et al. at https://custom-scripts.sentinel-hub.com/custom-scripts/sentinel-2/hollstein/
 *
 * @param {boolean} isSentinel3: is it Sentinel-3 or not (=Sentinel-2)
 */
function isPureWater(isSentinel3) {
  return isSentinel3 ?
    B06 < 0.319 && B17 < 0.166 && B06 - B16 >= 0.027 && B20 - B21 < 0.021 :
    B03 < 0.319 && B8A < 0.166 && B03 - B07 >= 0.027 && B09 - B11 < 0.021;
}

/**
 * Returns whether the pixel is marked as cloud
 * Based on the algorithm by the Braaten-Cohen-Yang cloud detector at https://github.com/sentinel-hub/custom-scripts/tree/master/sentinel-2/cby_cloud_detection
 *
 * @param {number} limit: user defined cloud limit
 * @param {boolean} isSentinel3: is it Sentinel-3 or not (=Sentinel-2)
 */
function isCloud(limit, isSentinel3) {
  const bRatio = isSentinel3 ? (B04 - 0.175) / (0.39 - 0.175) : (B02 - 0.175) / (0.39 - 0.175);
  return bRatio > 1 || (bRatio > 0 && (B04 - B06) / (B04 + B06) > limit);
}

/**
 * Returns an evaluated code of a string
 * This was needed because functions with eval() won't make it through minification
 *
 * @param {String} s: input string to evaluate
 */
function getEval(s) {
  return eval(s);
}

/**
 * Returns whether the pixel is marked as water (not land, cloud or snow) based on the array of indices given by the user
 *
 * @param {Object} params: user defined parameters
 * @param {Array<String>} indices: array of water indices given by the user. Possible values: "ndwi", "hol", "bcy" and any of their combinations.
 * @param {boolean} isSentinel3: is it Sentinel-3 or not (=Sentinel-2)
 */
function isWater(availableWatermaskIndices, selectedWatermaskIndices, waterMax, cloudMax, isSentinel3) {
  if (selectedWatermaskIndices.length === 0) {
    return true;
  } else {
    let isItWater = true;
    for (let i = 0; i < selectedWatermaskIndices.length; i++) {
      const wm = selectedWatermaskIndices[i];
      if (wm == "ndwi" && getEval(availableWatermaskIndices.ndwi) < waterMax) {
        isItWater = false;
        break;
      } else if (wm == "hol" && !isPureWater(isSentinel3)) {
        isItWater = false;
        break;
      } else if (wm == "bcy" && isCloud(cloudMax, isSentinel3)) {
        isItWater = false;
        break;
      }
    }
    return isItWater;
  }
}

/**
 * Returns background layer
 *
 * @param {String | Array<number>} background: predefined or custom background color
 * @param {Array<numer>} naturalIndex: natural color index
 * @param {number} opacity: background opacity from 0 to 1 (floating value)
 */
function getBackground(background, naturalIndex, opacity) {
  let backgroundLayer;
  let isRgb = false;
  const alpha = parseInt(opacity * 100);
  // Default should be the natural layer
  if (background === 'default' || background === 'natural') {
    backgroundLayer = getEval(naturalIndex);
    isRgb = true;
  } else if (background === 'black') {
    // Black background
    backgroundLayer = [0, 0, 0];
  } else if (background === 'white') {
    // White background
    backgroundLayer = [1, 1, 1];
  } else {
    // Custom rgb colors array (eg. [255, 255, 0])
    backgroundLayer = getStaticColor(background);
  }
  // Only calculate alpha is really needed
  return isRgb || opacity === 1 ? backgroundLayer : blend(backgroundLayer, getEval(naturalIndex), alpha, 100 - alpha);
}

/**
 * Returns foreground layer
 *
 * @param {String | Array<number>} foreground: predefined or custom foreground color
 * @param {*} backgroundLayer: background layer (for blending)
 * @param {*} naturalIndex: natural layer
 * @param {*} opacity: foreground opacity from 0 to 1 (floating value)
 */
function getForeground(foreground, backgroundLayer, naturalIndex, opacity) {
  let layer;
  const alpha = parseInt(opacity * 100);
  if (foreground === 'natural') {
    layer = getEval(naturalIndex);
  } else {
    layer = getStaticColor(foreground);
  }
  return opacity === 1 ? layer : blend(layer, backgroundLayer, alpha, 100 - alpha);
}

/**
 * Transforms RGB 0-255 colors to 0.0-1.0
 *
 * @param {[number, number, number]} colorArray: 3-element array of RGB colors (0-255)
 */
function getStaticColor(colorArray) {
  return [colorArray[0] / 255, colorArray[1] / 255, colorArray[2] / 255];
}

/**
 * Runs the main calculation and returns the value for each pixel
 *
 * @param {Object} params: user defined parameters
 */
function getValue(params) {
  let chlIndex, chlLayer, tssIndex, tssLayer, tssAlpha;
  const chl = params.chlIndex;
  const tss = params.tssIndex;
  const background = params.background;
  const foreground = params.foreground;
  const foregroundOpacity = params.foregroundOpacity;
  // Decide whether the data is Sentinel-3 (otherwise it is assumed to be Sentinel-2)
  const isSentinel3 = typeof B18 !== "undefined";
  // Get the indices that could potentially be used
  const indices = getIndices(isSentinel3);
  // Define background layer
  const backgroundLayer = getBackground(background, indices.natural, params.backgroundOpacity);
  // Decide whether the pixel can be assumed as water
  // Return background layer if it is not water
  if (!isWater(indices.watermask, params.watermaskIndices, params.waterMax, params.cloudMax, isSentinel3)) {
    return backgroundLayer;
  }
  // Return a static color if set so with opacity
  if (foreground !== 'default') {
    return getForeground(foreground, backgroundLayer, indices.natural, foregroundOpacity);
  }
  let value;
  // Define the chlorophyll layer if needed
  if (chl !== null) {
    // In case of 'default' set proper algorighm
    const alg = chl === 'default' ? (isSentinel3 ? 'flh' : 'mci') : chl;
    chlIndex = getEval(indices.chl[alg]);
    chlLayer = getColors('chl', chlIndex, params.chlMin, params.chlMax, (isSentinel3 && alg === 'flh'));
  }
  // Define the sediment layer if needed
  if (tss !== null) {
    // In case of 'default' set proper algorighm
    const alg = tss === 'default' ? (isSentinel3 ? 'b11' : 'b05') : tss;
    tssIndex = getEval(indices.tss[alg]);
    tssLayer = getColors('tss', tssIndex, params.tssMin, params.tssMax);
    tssAlpha = getAlpha(tssIndex, params.tssMin, params.tssMax);
  }
  // Calculate output value
  if (chl !== null && tss !== null) {
    // Blend layers if both chlorophyll and sediment layers are requested
    // Put sediment layer on top of chlorophyll layer with alpha
    value = blend(tssLayer, chlLayer, tssAlpha, 100 - tssAlpha);
  } else if (chl !== null && tss === null) {
    // Chlorophyll layer only if sediment layer is null
    value = chlLayer;
  } else if (tss !== null && chl === null) {
    // Sediment layer only if chlorophyll layer is null
    // Put sediment layer on top of natural layer with alpha
    value = blend(tssLayer, backgroundLayer, tssAlpha, 100 - tssAlpha);
  } else {
    // Natural color layer if both chlorophyll and sediment layers are null (which does not make much sense)
    value = backgroundLayer;
  }
  // Return foreground (with opacity if needed on top of background)
  const foregroundAlpha = parseInt(foregroundOpacity * 100);
  return foregroundOpacity === 1 ? value : blend(value, backgroundLayer, foregroundAlpha, 100 - foregroundAlpha);
}

return getValue(PARAMS);
//VERSION=3
const PARAMS = {
  // Indices
  chlIndex: 'default',
  tssIndex: 'default',
  watermaskIndices: ['ndwi', 'hol'],
  // Limits
  chlMin: -0.005,
  chlMax: 0.05,
  tssMin: 0.075,
  tssMax: 0.185,
  waterMax: 0,
  cloudMax: 0.02,
  // Graphics
  foreground: 'default',
  foregroundOpacity: 1.0,
  background: 'default',
  backgroundOpacity: 1.0
};
const isSentinel3 = false;
//* PARAMS END

const nOut = (PARAMS.chlIndex === "default") + (PARAMS.tssIndex === "default");

function setup() {
  return {
    input: ["B02", "B03", "B04", "B05", "B06", "B07", "B08", "B8A", "B09", "B11", "dataMask"], // You need to add here all the bands you are going to use
    output: [
      { id: "default", bands: 3 },
      { id: "index", bands: nOut, sampleType: "FLOAT32" },
      { id: "eobrowserStats", bands: nOut + 1, sampleType: 'FLOAT32' },
      { id: "dataMask", bands: 1 }
    ]
  };
}

/**
 * Returns indices object used for output calculation
 * The returned object is different for Sentinel-2 and Sentinel-3 satellites
  * Here only defined as strings and gets evaluated only when really needed
 * (Tip 4: Calculate as needed at https://medium.com/sentinel-hub/custom-scripts-faster-cheaper-better-83f73894658a)
 * natural: natural (rgb) color image
 * chl: chlorophyll indices
 * tss: sediment indices
 * watermask: watermask indices *
 *
 * @param {boolean} isSentinel3: is it Sentinel-3 or not (=Sentinel-2)
 */
function getIndices(isSentinel3) {
  return isSentinel3 ? {
    natural: "[1.0*samples.B07+1.4*samples.B09-0.1*samples.B14,1.1*samples.B05+1.4*samples.B06-0.2*samples.B14,2.6*samples.B04-samples.B14*0.6]",
    chl: {
      flh: "samples.B10-1.005*(samples.B08+(samples.B11-samples.B08)*((0.681-0.665)/(0.708-0.665)))",
      rlh: "samples.B11-samples.B10-(samples.B18-samples.B10*((0.70875-0.68125)*1000.0))/((0.885-0.68125)*1000.0)",
      mci: "samples.B11-((0.75375-0.70875)/(0.75375-0.68125))*samples.B10-(1.0-(0.75375-0.70875)/(0.75375-0.68125))*samples.B12"
    },
    tss: {
      b07: "samples.B07",
      b11: "samples.B11"
    },
    watermask: {
      ndwi: "(samples.B06-samples.B17)/(samples.B06+samples.B17)"
    }
  } : {
    natural: "[2.5*samples.B04,2.5*samples.B03,2.5*samples.B02]",
    chl: {
      rlh: "samples.B05-samples.B04-(samples.B07-samples.B04*((0.705-0.665)*1000.0))/((0.783-0.665)*1000.0)",
      mci: "samples.B05-((0.74-0.705)/(0.74-0.665))*samples.B04-(1.0-(0.74-0.705)/(0.74-0.665))*samples.B06"
    },
    tss: {
      b05: "samples.B05"
    },
    watermask: {
      ndwi: "(samples.B03-samples.B08)/(samples.B03+samples.B08)"
    }
  };
}

/**
 * Blends between two layers
 * Uses https://pierre-markuse.net/2019/03/26/sentinel-3-data-visualization-in-eo-browser-using-a-custom-script/
 *
 * @param {Object} layer1: first (top) layer
 * @param {Object} layer2: second (bottom) layer
 * @param {number} opacity1: first layer opacity
 * @param {number} opacity2: second layer opacity
 */
function blend(layer1, layer2, opacity1, opacity2) {
  return layer1.map(function (num, index) {
    return (num / 100) * opacity1 + (layer2[index] / 100) * opacity2;
  });
}

/**
 * Returns an opacity (alpha) value between 0 and 100 for a given index based on min and max values
 *
 * @param {Object} index: selected index
 * @param {number} min: user defined minimum value
 * @param {number} max: user defined maximum value
 */
function getAlpha(index, min, max) {
  if (min + (max - min) / 2 < index) {
    return 100;
  }
  return index <= min ?
    0 :
    index >= max ?
      1 :
      100 * ((index - min / 2) / (max - min));
}

/**
 * Returns a color palette for chlorophyll or sediment index
 *
 * @param {String} type: palette type ('chl' for chlorophyll, 'tss' for sediment)
 * @param {Object} index: user selected index
 * @param {number} min: user defined minimum value
 * @param {number} max: user defined maximum value
 * @param {boolean} isSentinel3Flh: is it Sentinel3 && is 'flh' is the user selected chlorophyll index (only for 'chl' type)
 */
function getColors(type, index, min, max, isSentinel3Flh) {
  let colors, palette;
  switch (type) {
    case 'chl':
      palette = [
        [0.0034, 0.0142, 0.163], // #01042A (almost black blue)
        [0, 0.416, 0.306], // #006A4E (bangladesh green)
        [0.486, 0.98, 0], //#7CFA00 (dark saturated chartreuse)
        [0.9465, 0.8431, 0.1048], //#F1D71B (light washed yellow)
        [1, 0, 0] // #FF0000 (red)
      ];
      // In case of Sentinel-3 && 'flh' the palette has to be reversed and min and max values also needed to be adjusted
      if (isSentinel3Flh) {
        palette = palette.reverse();
        min = min * 10;
        max = max / 10;
      }
      colors = colorBlend(
        index,
        [min, min + (max - min) / 3, (min + max) / 2, max - (max - min) / 3, max],
        palette
      );
      break;
    case 'tss':
      palette = [
        [0.961, 0.871, 0.702], // #F5DEB3 (wheat)
        [0.396, 0.263, 0.129] // #654321 (dark brown)
      ];
      colors = colorBlend(
        index,
        [min, max],
        palette
      );
      break;
    default:
      break;
  }
  return colors;
}

/**
 * Returns true if the pixel covers area of pure water without any cloud, shadow or snow, otherwise returns false
 * Based on the algorithm by Hollstein et al. at https://custom-scripts.sentinel-hub.com/custom-scripts/sentinel-2/hollstein/
 *
 * @param {boolean} isSentinel3: is it Sentinel-3 or not (=Sentinel-2)
 */
function isPureWater(isSentinel3, samples) {
  return isSentinel3 ?
    samples.B06 < 0.319 && samples.B17 < 0.166 && samples.B06 - samples.B16 >= 0.027 && samples.B20 - samples.B21 < 0.021 :
    samples.B03 < 0.319 && samples.B8A < 0.166 && samples.B03 - samples.B07 >= 0.027 && samples.B09 - samples.B11 < 0.021;
}

/**
 * Returns whether the pixel is marked as cloud
 * Based on the algorithm by the Braaten-Cohen-Yang cloud detector at https://github.com/sentinel-hub/custom-scripts/tree/master/sentinel-2/cby_cloud_detection
 *
 * @param {number} limit: user defined cloud limit
 * @param {boolean} isSentinel3: is it Sentinel-3 or not (=Sentinel-2)
 */
function isCloud(limit, isSentinel3, samples) {
  const bRatio = isSentinel3 ? (samples.B04 - 0.175) / (0.39 - 0.175) : (samples.B02 - 0.175) / (0.39 - 0.175);
  return bRatio > 1 || (bRatio > 0 && (samples.B04 - samples.B06) / (samples.B04 + samples.B06) > limit);
}

/**
 * Returns an evaluated code of a string
 * This was needed because functions with eval() won't make it through minification
 *
 * @param {String} s: input string to evaluate
 */
function getEval(s, samples) {
  return eval(s);
}

/**
 * Returns whether the pixel is marked as water (not land, cloud or snow) based on the array of indices given by the user
 *
 * @param {Object} params: user defined parameters
 * @param {Array<String>} indices: array of water indices given by the user. Possible values: "ndwi", "hol", "bcy" and any of their combinations.
 * @param {boolean} isSentinel3: is it Sentinel-3 or not (=Sentinel-2)
 */
function isWater(availableWatermaskIndices, selectedWatermaskIndices, waterMax, cloudMax, isSentinel3, samples) {
  if (selectedWatermaskIndices.length === 0) {
    return true;
  } else {
    let isItWater = true;
    for (let i = 0; i < selectedWatermaskIndices.length; i++) {
      const wm = selectedWatermaskIndices[i];
      if (wm == "ndwi" && getEval(availableWatermaskIndices.ndwi, samples) < waterMax) {
        isItWater = false;
        break;
      } else if (wm == "hol" && !isPureWater(isSentinel3, samples)) {
        isItWater = false;
        break;
      } else if (wm == "bcy" && isCloud(cloudMax, isSentinel3, samples)) {
        isItWater = false;
        break;
      }
    }
    return isItWater;
  }
}

/**
 * Returns background layer
 *
 * @param {String | Array<number>} background: predefined or custom background color
 * @param {Array<numer>} naturalIndex: natural color index
 * @param {number} opacity: background opacity from 0 to 1 (floating value)
 */
function getBackground(background, naturalIndex, opacity, samples) {
  let backgroundLayer;
  let isRgb = false;
  const alpha = parseInt(opacity * 100);
  // Default should be the natural layer
  if (background === 'default' || background === 'natural') {
    backgroundLayer = getEval(naturalIndex, samples);
    isRgb = true;
  } else if (background === 'black') {
    // Black background
    backgroundLayer = [0, 0, 0];
  } else if (background === 'white') {
    // White background
    backgroundLayer = [1, 1, 1];
  } else {
    // Custom rgb colors array (eg. [255, 255, 0])
    backgroundLayer = getStaticColor(background);
  }
  // Only calculate alpha is really needed
  return isRgb || opacity === 1 ? backgroundLayer : blend(backgroundLayer, getEval(naturalIndex, samples), alpha, 100 - alpha);
}

/**
 * Returns foreground layer
 *
 * @param {String | Array<number>} foreground: predefined or custom foreground color
 * @param {*} backgroundLayer: background layer (for blending)
 * @param {*} naturalIndex: natural layer
 * @param {*} opacity: foreground opacity from 0 to 1 (floating value)
 */
function getForeground(foreground, backgroundLayer, naturalIndex, opacity, samples) {
  let layer;
  const alpha = parseInt(opacity * 100);
  if (foreground === 'natural') {
    layer = getEval(naturalIndex, samples);
  } else {
    layer = getStaticColor(foreground);
  }
  return opacity === 1 ? layer : blend(layer, backgroundLayer, alpha, 100 - alpha);
}

/**
 * Transforms RGB 0-255 colors to 0.0-1.0
 *
 * @param {[number, number, number]} colorArray: 3-element array of RGB colors (0-255)
 */
function getStaticColor(colorArray) {
  return [colorArray[0] / 255, colorArray[1] / 255, colorArray[2] / 255];
}

/**
 * Runs the main calculation and returns the value for each pixel
 *
 * @param {Object} params: user defined parameters
 */
function getValue(params, samples) {
  let chlIndex, chlLayer, tssIndex, tssLayer, tssAlpha;
  const chl = params.chlIndex;
  const tss = params.tssIndex;
  const background = params.background;
  const foreground = params.foreground;
  const foregroundOpacity = params.foregroundOpacity;
  // Get the indices that could potentially be used
  const indices = getIndices(isSentinel3);
  // Define background layer
  const backgroundLayer = getBackground(background, indices.natural, params.backgroundOpacity, samples);
  // Decide whether the pixel can be assumed as water
  // Return background layer if it is not water
  const waterMask = !isWater(indices.watermask, params.watermaskIndices, params.waterMax, params.cloudMax, isSentinel3, samples)
  const dummyOut = Array(nOut).fill(NaN);
  if (waterMask) {
    return {
      default: backgroundLayer,
      index: dummyOut,
      eobrowserStats: dummyOut.concat(0),
      dataMask: [0]
    };
  }
  // Return a static color if set so with opacity
  if (foreground !== 'default') {
    return {
      default: getForeground(foreground, backgroundLayer, indices.natural, foregroundOpacity, samples),
      index: dummyOut,
      eobrowserStats: dummyOut.concat(0),
      dataMask: [0]
    };
  }
  let value;
  let outIndices = [];
  // Define the chlorophyll layer if needed
  if (chl !== null) {
    // In case of 'default' set proper algorighm
    const alg = chl === 'default' ? (isSentinel3 ? 'flh' : 'mci') : chl;
    chlIndex = getEval(indices.chl[alg], samples);
    outIndices.push(chlIndex);
    chlLayer = getColors('chl', chlIndex, params.chlMin, params.chlMax, (isSentinel3 && alg === 'flh'));
  }
  // Define the sediment layer if needed
  if (tss !== null) {
    // In case of 'default' set proper algorighm
    const alg = tss === 'default' ? (isSentinel3 ? 'b11' : 'b05') : tss;
    tssIndex = getEval(indices.tss[alg], samples);
    outIndices.push(tssIndex);
    tssLayer = getColors('tss', tssIndex, params.tssMin, params.tssMax);
    tssAlpha = getAlpha(tssIndex, params.tssMin, params.tssMax);
  }
  // Calculate output value
  if (chl !== null && tss !== null) {
    // Blend layers if both chlorophyll and sediment layers are requested
    // Put sediment layer on top of chlorophyll layer with alpha
    value = blend(tssLayer, chlLayer, tssAlpha, 100 - tssAlpha);
  } else if (chl !== null && tss === null) {
    // Chlorophyll layer only if sediment layer is null
    value = chlLayer;
  } else if (tss !== null && chl === null) {
    // Sediment layer only if chlorophyll layer is null
    // Put sediment layer on top of natural layer with alpha
    value = blend(tssLayer, backgroundLayer, tssAlpha, 100 - tssAlpha);
  } else {
    // Natural color layer if both chlorophyll and sediment layers are null (which does not make much sense)
    value = backgroundLayer;
  }
  // Return foreground (with opacity if needed on top of background)
  const foregroundAlpha = parseInt(foregroundOpacity * 100);
  const imgVals = foregroundOpacity === 1 ? value : blend(value, backgroundLayer, foregroundAlpha, 100 - foregroundAlpha);
  return {
    default: imgVals,
    index: outIndices,
    eobrowserStats: outIndices.concat(isCloud(params.cloudMax, isSentinel3, samples)),
    dataMask: [samples.dataMask]
  };
}

function evaluatePixel(samples) {
  return getValue(PARAMS, samples);
}

Show minified, optimized script (recommended for usage).

The Sentinel 3 Version of this script can be found here.
Version 1 works for both Sentinel 2 and Sentinel 3 as is. Version 3 adds support for advanced EO-Browser features like statistics and point information.

Timelapse (2019) of Lake Balaton with Sentinel-2 and Sentinel-3 imagery side by side
'Timelapse of 2019'

Evaluate and visualize

  1. Visit one of the following example sites or find your own area of interest:
  2. Interpret what you see
  3. Modify the values in the PARAMS object according to your needs (for possible values see Understanding and fine tuning PARAMS)
  4. Hit Refresh after modifying any of the properties 'Refresh'

Description of the script

Ulyssys Water Quality Viewer (UWQV) is a custom script for the Sentinel-hub EO-Browser to dynamically visualize the chlorophyll and sediment conditions of water bodies on both Sentinel-2 and Sentinel-3 images.

The visualization you see is a product of two masking operations and two water quality parameter visualizations:

  • cloud masking
  • water masking

and

  • suspended sediment concentration visualization
  • chlorophyll concentration visualization

By default, all pixels identified as “not water” (cloud, snow or land) are shown in true colour. All pixels identifed as water are coloured with an algorithm that evaluates chlorophyll and suspended sediment concentration together. This visualization can be compared to a GIS map with two raster layers, sediment on top and chlorophyll below. The sediment “layer” is semi-transparent and can cover the chlorophyll “layer”. Just like clouds in the atmosphere, sediment in the water reduces transparency and obscures chlorophyll. Therefore water pixels with high sediment concentrations are coloured dark brown regardless of their chlorophyll concentration. Medium sediment concentrations are coloured wheat (light brown) with increasing transparency towards lower sediment concentrations. At low sediment concentrations the sediment “layer” is completely transparent. Below the semi-transparent sediment “layer”, the chlorophyll concentration is visualized. High chlorophyll concentrations are marked in red, medium concentrations green, and low concentrations dark blue (see palette image below).

Color legend of values

By changing input parameters of the script it is also possible to:

  • visualize only sediment or only chlorophyll concentrations
  • switch between various cloud and water masking algorithms (even switching masking off completely)
  • alter the default numeric thresholds (min/max values) to adjust visualization to fit local conditions better
  • render water (foreground) and/or non-water (background) pixels with the true colour image or a single constant colour with opacity

Understanding and fine tuning PARAMS

The visual output is controlled by the PARAMS object defined at the beginning of the script. It has three logical parts: Indices, Limits and Graphics. You may control the visualization by fine tuning the properties, setting them to predefined values (e.g. changing the selected chlorophyll index from 'default' to 'rlh') or modifying numeric values (e.g. chlorophyll maximum value from 0.05 to 0.03). Some of the properties are nullable (e.g. if you only wish to see sediment without chlorophyll just set chlIndex to null without quotation marks). You may see unwanted results if the properties are incorrectly set.

Structure and default values:

const PARAMS = {
  // Indices
  chlIndex: "default",
  tssIndex: "default",
  watermaskIndices: ["ndwi", "hol"],
  // Limits
  chlMin: -0.005,
  chlMax: 0.05,
  tssMin: 0.075,
  tssMax: 0.185,
  waterMax: 0,
  cloudMax: 0.02,
  // Graphics
  foreground: "default",
  foregroundOpacity: 1.0,
  background: "default",
  backgroundOpacity: 1.0
};

Possible values:

  • chlIndex: selected chlorophyll index (see Technical background for more information)
    • 'mci' or 'rlh' for Sentinel-2
    • 'flh' or 'rlh' or 'mci' for Sentinel-3
    • 'default' for default value ('mci' for Sentinel-2, 'flh' for Sentinel-3)
    • null (without quotation marks) if not set, in this case chlorophyll is not visualized
  • tssIndex: selected sediment index (see Technical background for more information)
    • 'b05' for Sentinel-2
    • 'b07' or 'b11' for Sentinel-3
    • 'default' for default value ('b05' for Sentinel-2, 'b11' for Sentinel-3)
    • null (without quotation marks) if not set, in this case sediment is not visualized
  • watermaskIndices: selected water/cloud mask indices (see Technical background for more information)
    • an array of 'ndwi', 'hol', 'bcy' or any combination of them
    • [] (empty array) if not set
  • chlMin: lower limit of chlIndex; decrease this for more sensitivity to low chlorophyll concentrations
  • chlMax: upper limit of chlIndex; decrease this for stronger highlighting of high chlorophyll concentrations
  • tssMin: lower limit of tssIndex; decrease this for more sensitivity to low sediment concentrations (but a higher chance of mistaking sediment for chlorophyll)
  • tssMax: upper limit of tssIndex; decrease this for stronger highlighting of high sediment concentrations
  • waterMax: upper limit of NDWI; if NDWI is above this limit the pixel is assumed to be water. Decrease this for more water but more commission errors
  • cloudMax: minimum value for BCY cloud detection. Only affects the output if 'bcy' is in watermaskIndices. Decrease this for higher sensitivity to clouds, increase for less clouds (and more water)
  • foreground: fill type of water areas
    • 'default': fill the water pixels with the UWQV visualization (combine chlorophyll and sediment)
    • 'natural': for natural color image
    • custom color with an array of RGB colors (e.g. [83, 109, 254] for blue).
  • foregroundOpacity: opacity of foreground layer over the background layer, between 0.0 (fully transparent) and 1.0 (fully opaque).
  • background: fill type of non-water (background) areas
    • 'natural' for natural color image
    • 'black' for black background
    • 'white' for white background
    • 'default' for default value ('natural')
    • custom color with an array of RGB colors (e.g. [255, 255, 0] for yellow)
  • backgroundOpacity: opacity of background fill over natural color image, between 0.0 (fully transparent), showing true colour non-water pixels and 1.0 (fully opaque), showing single colour background.

Details of the script

Scientific background

From an ecological perspective, the term “water quality” refers to the status of the most important abiotic and biotic properties of the water column in a given time and location. The most important water quality parameters from a habitat management perspective are chlorophyll concentration and suspended sediment concentration, and these are also strongly linked to bathing and drinking water quality for human use (see supplementary for further details). Chlorophyll concentration is a proxy for the amount of algae and therefore of energy and biomass input into the aquatic food web via photosynthesis. Since algae growth is often limited by the availability of nutrients, high chlorophyll concentrations are frequently a result of pollution from communal sewage or agricultural runoff. Suspended sediment can originate from currents or waves moving the sediment up from the bottom of the water (called resuspension), or from tributary rivers that carry sediment as they flow. The amount of sediment in the water column also depends strongly on grain size: fine-grained sediment can be picked up by slow currents and takes long to settle down again, while larger grained sediment is only moved by very strong currents and settles very quickly.

Technical background

The script uses pre-existing and relatively widely tested algorithms for masking and visualization. Water surfaces are masked by NDWI thresholding, (McFeeters 1996), and two existing cloud mask scripts in the Sentinel Hub repository are integrated, hol and bcy (Hollstein 2015; Cohen, Braaten and Young 2016). For suspended sediment, we use simple intensities of a single band near 700 nm or 620 nm (Nechad 2010, Sentinel 3 user guide). For chlorophyll concentration, various indices area available, all based on the reflectance line height (RLH) algorithm. RLH involves calculating the difference (“height”) of reflectance in one spectral band compared to a baseline calculated by linear interpolation between the values of two other bands (Yacobi 1995). Two specific spectral bands have been identified where chlorophyll flourescence produces a peak, at 685 nm and at 709 nm, and by calculating a RLH compared to the neighbouring bands defines two spectral indices, named Fluorescence Line Height (FLH) (Gitelson 1994) and Maximum Chlorophyll Index (MCI) respectively for studies with the MERIS satellite sensor (Gower 2005). Both of these indices can be calculated for Sentinel-3 data, and it has been shown that flh is more suitable for low chlorophyll concentrations while mci performs better for high chlorophyll concentrations. However, Sentinel-2 does not have a spectral band at 700 nm, so FLH cannot be used, but Band 05 at 705 nm is suitable for calculating mci. Additionally, for the sake of continuity we provide two RLH-based indices that were part of the functionality of Global Lake Watch (Zlinszky Supan Koma 2017), under the name rlh. These are both modified versions of the MCI with different baseline bands that we believe could be less sensitive to chlorophyll (Schalles 1998). Please see the References section for full citations and the supplementary material for full discussion of the methods selected.

Limitations and typical problems

UWQV is just a visualization, not a quantitative map. The colours produced by the script are a function of the satellite image pixel values which in turn are mainly, but not exclusively determined by the chlorophyll and sediment concentrations in the water column. The indices we use are tested to be positively correlated to the water quality parameter values they represent, but the exact correlation functions can vary considerably between different locations and different atmospheric and water conditions. Nevertheless, we believe that UWQV can help to understand the properties and dynamics of aquatic ecosystems in a new way by providing high spatial or temporal resolution from the Sentinel 2 and 3 satellites.

We are aware of some typical cases where the algorithm produces errors:

  • Cloud haze or thin clouds over water are sometimes missed by cloud masking, and these false water pixels will be shown to have have erroneously high suspended sediment concentrations. These cases are relatively easy to identify since the haze is visible in the true colour areas outside the water surface. To create a stricter cloud mask, one thing to try is to add 'bcy' to watermaskIndices and decrease the cloudMax parameter.
  • Similarly, cloud shadows are not always perfectly masked, and they can be mistaken as water areas with low suspended sediment. Cloud shadows can be identified based on the presence of clouds, which typically keep their natural colour at the default settings of our algorithm. For this problem, the first thing to try is to increase the waterMax parameter.
  • Very high concentrations of suspended sediment or chlorophyll may cause the Hollstein algorithm or the NDWI-based water masking algorithm to fail, mistaking water areas for land. In this case, the algorithm returns the background image (by default the natural color image). To correct this, try:
    • set watermaskIndices to ['ndwi'] (delete hol),
    • secrease the value of waterMax (e.g. to -0.2) or
    • completely disable water and cloud masking (set watermaskIndices to [])
  • Also, relatively dark unvegetated land pixels (such as bare soil areas or deciduous forests in winter) sometimes fool the NDWI water masking algorithm, showing large water areas. Our recommended solution is to try tuning the waterMax parameter, increasing e.g. up to 0.45 to decrease these effects.
  • The optical signal of chlorophyll and suspended sediment can not always be spectrally separated with these simple methods. This is especially problematic for Sentinel 2, where chlorophyll detection is based both for mci and for rlh on the peak of B05 above the neighbouring channels, and suspended sediment visualization is also based on B05 intensity. Therefore in some cases, low concentrations of bright sediment may influence the chlorophyll visualization. Similarly, in very transparent but shallow waters, the lake/sea bottom may be visible through the water and produce a sediment or chlorophyll signal. This effect can be especially prominent when only a single parameter is visualized by setting chlIndex or tssIndex to null (without quotation marks). It is always a good idea to take a look at the scene in true colour (by setting foreground to 'natural') to help you find the places influenced by this problem. Sentinel-3 has lower resolution and higher revisit frequency than Sentinel-2 but is also less sensitive to this problem since the bands used for visualizing chlorophyll and suspended sediment concentrations are different. The Sentinel3 OLCI sensor was designed specifically for water applications and is known to produce more accurate information on water quality than Sentinel2, therefore we recommend to look at your lake/river/sea and date of choice first on Sentinel-3 (unless it is a very small area). If you are in a cloud free period, take a look at Sentinel-3 images a few days before and after your date of interest to get an understanding for the currents and water quality processes forming the patterns you see. Then, if Sentinel-2 imagery is also available, you can take a more detailed look with higher resolution. Comparing between Sentinel-3 (higher accuracy, lower resolution) and Sentinel-2 (higher resolution, lower accuracy) will help you understand the limitations of each dataset.

Why minified

The dist/script.min.js file is a minified version of the script which means it is much smaller (~3.5x), runs faster but is less human readable. You can find the original, unminified source file with useful comments and meaningful variable names at src/script.js. The source file is too large to copy its contents to the script window (exceeds the maximum URL length). We recommend using the minified version in the script window, but you can also enter the URL of the unminified source file into the input field below the script window (in this case you won’t be able to modify the parameters).

Modify the script

If you want to include another water quality algorithm or make other major changes and you wish to alter the script itself, you have to (1) modify the unminified source file (2) re-create the minified version of the script. We recommend doing it the following way:

  1. Copy the contents of this folder on your machine (maybe the easiest way is to download the whole repository and unzip it somewhere on your machine).
  2. Install Node.js if you don’t have it yet.
  3. From the script root folder (where the package.json file can be found), run npm install in a terminal. This will install all necessary dependencies that are needed for the minifying locally.
  4. Modify the src/script.js file from a text editor.
  5. Also from the script root folder, run npm run minify. This will overwrite the dist/script.min.js file.
  6. You can also run npm run watch. In this case the minifying happens automatically whenever you alter the src/script.js file until you shut the command down. This is how we alter the code as well.

Authors of the script

András Zlinszky PhD (@azlinszky) and Gergely Padányi-Gulyás (@fegyi001) at Ulyssys Ltd, Budapest, Hungary, 2020.

How to cite

In order to make this script fully citeable, we obtained a doi via preprints.org and uploaded the supplementary document as a technical note, available under this link.

In a document bibliography, please use the following citation:

Zlinszky, A.; Padányi-Gulyás, G. Ulyssys Water Quality Viewer Technical Description Supplementary. Preprints 2020, 2020010386 (doi: 10.20944/preprints202001.0386.v1).

Ulyssys Logo

Description of representative images

Understanding the visualization

We illustrate the visualization of different chlorophyll concentrations on the example of an algae bloom on Lake Balaton, Hungary. The lake shows a clear gradient from the Southwest (high chlorophyll - up to 400 µg/l) to the Northeast (lower chlorophyll - about 20 µg/l). The red arrow marks a location where chlorophyll concentration was so high that the NDWI water masking mistook the water for land. The white arrow shows a location where chlorophyll concentration is high but also suspended sediment is present, here the transparent grey of the sediment is overlain on the colouring of the chlorophyll. The orange arrow shows a location where suspended sediment is locally relatively high but chlorophyll is low, while the blue arrow points to a place where both chlorophyll and suspended sediment are relatively low.

Lake Balaton, Sentinel-2A, 2019-09-05, True Colour. The true colour image shows the intensity of the algae bloom in the western part of the lake and also the relatively bright colour of the suspended sediment.

'2019-09-05_Sentinel-2A_Balaton'

Lake Balaton, Sentinel-2A, 2019-09-05, UWQV Default settings. The chlorophyll visualization shows the extremely high concentrations in the western basin of the lake. The Sentinel-2 algorithm we use is less sensitive to suspended sediment concentration, with only the small area marked by the orange arrow coloured for sediment.

'2019-09-05_Sentinel-3_Balaton'

Lake Balaton, Sentinel-3, 2019-09-05, UWQV Default settings. While Sentinel-3 has lower spatial resolution than Sentinel-2, the chlorophyll and suspended sediment algorithms are more selective than for Sentinel-2, therefore they discern chlorophyll and sediment more successfully. The Western basin of the lake is coloured transparent grey (with the chlorophyll visualization “underneath”), the small patch of sediment in the central part of the lake is well identified and the lower concentrations in the eastern basin are not mistaken for chlorophyll.

'2019-09-05_Sentinel-3_OLCI_L1C_Balaton'

Tuning the parameters

We show an example of selecting appropriate algorithms and limits in order to enhance the visualization to show more details on the example of an algae bloom on Lake Pontchartrain, Louisiana, United States. Optimum settings for visualizing this situation would suggest minimum and maximum limits defined by the minimum and maximum values observed in the scene. Water masking also has to be adjusted: similar to the previous example, chlorophyll and sediment concentrations are high enough in some small areas to cause problems.

Lake Pontchartrain, Sentinel-2A, 2020-01-06, True Colour. In the first step, the true colour image is visualized, in order to take a look at the general situation. High suspended sediment concentrations are evident in the Mississippi River, the bright green streak of a severe algae bloom along the southern third of the lake is also clear - these represent the maximum values while clear waters at the northern corner of the lake seem to hold the minimum.

'2020-01-06-Sentinel_2A_Lake_Pontchartrain'

Lake Pontchartrain, Sentinel-2A, 2020-01-06, UWQV Default settings. Using the default settings of UWQV, many more streaks of high chlorophlyll become visible, but the most affected area of the bloom is masked as non-water. Fluorescence Line Height is apparently sensitive to small amounts of chlorophyll but saturates at very high amounts, without reaching the values represented by the red colour. Along the southern shore of the lake, suspended sediment also has a strong influence on water colour, as marked by the grey transparent visualization. The Mississippi River is also visualized with a combination of greens and greys, as expected from water with relatively high sediment and chlorophyll content.

'2020-01-06-Sentinel_2A_Lake_Pontchartrain'

Lake Pontchartrain, Sentinel-2A, 2020-01-06, Modified water masking. Cloud masking is not necessary at all in this cloud-free scene, so the Hollstein cloud masking algorithm used as default is disabled by deleting 'hol' from watermaskIndices. However, NDWI-based water masking is problematic: within part of the area affected by the bloom, high chlorophyll and sediment content causes high reflectivity in near-infrared atypical for water, and therefore with the default waterMax value of 0, a masking error is caused. waterMax has to be lowered all the way to -0.62 if all pixels in the affected area are to be kept as water. To study the effect of changing the masking threshold, the parameter background was set to 'black'; this made all pixels labelled as non-water to appear black. The image shows that with such a low NDWI threshold most of the scene is mistaken as water. Note that Hollstein cloud masking could also have been used to identify water areas, but in our implementation the parameters of this algorithm are not tuneable.

'2020-01-06-Sentinel_2A_Lake_Pontchartrain'

Lake Pontchartrain, Sentinel-2A, 2020-01-06, Enhanced visualization. The next step is to optimize chlorophyll and sediment visualization. Accepting the compromise that some water pixels will be lost, but also false positives will be created, Hollstein cloud masking has been disabled, but waterMax was left at the default value. Instead of Fluorescence Line Height, the Reflectance Line Height algorithm was selected by setting the chlIndex parameter to 'rlh'. Reflectance Line Height seems to perform better for the high chlorophyll concentrations observed here, and is apparently less influenced by suspended sediment in the Mississippi river. In order to stretch the colouring scheme to the pixel values occurring within the image, chlMin was increased to 0.006 and chlMax was reduced slightly to 0.045. Suspended sediment visualization was left at the default values. Finally, in order to emphasize the water quality patterns further, the non-water pixels were slightly darkened by setting the background parameter to 'black' and the backgroundOpacity to 0.3.

'2020-01-06-Sentinel_2A_Lake_Pontchartrain'

References

  • Braaten-Cohen-Yang cloud detector (custom script): Braaten, Justin D., Warren B. Cohen, and Zhiqiang Yang. “Automated cloud and cloud shadow identification in Landsat MSS imagery for temperate ecosystems.” Remote Sensing of Environment 169 (2015): 128-138.
  • Gitelson 1994 (Fluorescence Line Height): Gitelson, A., M. Mayo, Y. Z. Yacobi, A. Parparov, and T. Berman. “The use of high-spectral-resolution radiometer data for detection of low chlorophyll concentrations in Lake Kinneret.” Journal of Plankton Research 16, no. 8 (1994): 993-1002.
  • Gower 2005 (Maximum Chlorophyll Index): Gower, J., S. King, G. Borstad, and L. Brown. “Detection of intense plankton blooms using the 709 nm band of the MERIS imaging spectrometer.” International Journal of Remote Sensing 26, no. 9 (2005): 2005-2012.
  • A. Hollstein et al., 2016 - Ready-to-Use Methods for the Detection of Clouds, Cirrus, Snow, Shadow, Water and Clear Sky Pixels in Sentinel-2 MSI Images: Hollstein, André, Karl Segl, Luis Guanter, Maximilian Brell, and Marta Enesco. “Ready-to-use methods for the detection of clouds, cirrus, snow, shadow, water and clear sky pixels in Sentinel-2 MSI images.” Remote Sensing 8, no. 8 (2016): 666.
  • Pierre Markuse, 2019 - Sentinel-3 data Visualization in EO Browser using a Custom Script
  • McFeeters 1996 (NDWI): McFeeters, Stuart K. “The use of the Normalized Difference Water Index (NDWI) in the delineation of open water features.” International journal of remote sensing 17, no. 7 (1996): 1425-1432.
  • Nechad 2010 (Single-band suspended sediment indices): Nechad, B., K. G. Ruddick, and Y. Park. “Calibration and validation of a generic multisensor algorithm for mapping of total suspended matter in turbid waters.” Remote Sensing of Environment 114, no. 4 (2010): 854-866
  • Schalles 1998 (RLH) Schalles, John F., Anatoly A. Gitelson, Yosef Z. Yacobi, and Amy E. Kroenke. “Estimation of chlorophyll a from time series measurements of high spectral resolution reflectance in an eutrophic lake.” Journal of Phycology 34, no. 2 (1998): 383-390.
  • Sentinel-3 User Guide, (Suspended sediment from 620 nm band)
  • Yacobi 1995 (Reflectance Line Height): Yacobi, Yosef Z., Anatoly Gitelson, and Meir Mayo. “Remote sensing of chlorophyll in Lake Kinneret using highspectral-resolution radiometer and Landsat TM: spectral features of reflectance and algorithm development.” Journal of Plankton Research 17, no. 11 (1995): 2155-2173.
  • Zlinszky Supan Koma 2017 (Global Lake Watch): Zlinszky, András, Peter Supan, and Zsófia Koma. “Near real-time qualitative monitoring of lake water chlorophyll globally using GoogleEarth Engine.” In EGU General Assembly Conference Abstracts, vol. 19, p. 18950. 2017.

Special thanks to

  • Most of the development and testing that allowed the preparation of this script was carried out for Global Lake Watch between 2016 and 2019, and we transferred most settings and algorithms from the Global Lake Watch source code, with permission of the authors: Peter Supan, Marc Zobel, Zsófia Koma, and András Zlinszky.
  • Balaton Limnological Institute of the Hungarian Centre for Ecological Research for hosting our studies of satellite water quality mapping.

License

Creative Commons License

This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License