webgeods webGeoDs
  • Home
  • Tools
    • All tools

    • Inspect
    • Validate
    • Check Topology
    • CRS

    • Inspect
    • Calculate
    • NDVI
  • Articles
    • All articles

    • Inspect
    • Validate
    • Check Topology
    • CRS

    • Inspect
    • Calculate
    • NDVI
  • About

NDVI Calculator

tool
raster
Upload a multi-band GeoTIFF, pick your Red and near-infrared bands, and get a vegetation-health map (NDVI) as a colored overlay and a downloadable GeoTIFF. Free, runs entirely in your browser.
Published

September 7, 2026

Upload a .tif/.tiff file with a Red and a near-infrared (NIR) band, pick which band is which, and get the Normalized Difference Vegetation Index (NDVI) back — as a colored map and a downloadable GeoTIFF.

Tip

Private by design. Your file is processed entirely in your browser (Python via WebAssembly) — it’s never uploaded to any server, including ours. Nothing to configure, nothing to trust: the code never gets the chance to see your data.

mutable uploadStatus = WebGeoDS.Upload.defaultStatus
mutable uploadBusy = false
mutable uploadedFiles = null
uploadControl = WebGeoDS.Upload.createControl({
  label: "📁 Upload",
  onChange: (files) => { mutable uploadedFiles = files; }
})
{
  // Python-only tool, same reasoning as the Inspector/Calculator:
  // skip writing to R's filesystem entirely so selecting a file
  // doesn't also boot webR in the background for nothing.
  mutable uploadBusy = true;
  mutable uploadStatus = "⌛ Uploading...";
  try {
    const result = await WebGeoDS.Upload.load(uploadedFiles, { languages: ["python"] });
    if (result.ok) {
      mutable uploadStatus = "⌛ Inspecting...";
      await autoInspect();
      mutable uploadStatus = result.message;
    } else {
      mutable uploadStatus = result.message;
    }
  } finally {
    mutable uploadBusy = false;
  }
}
uploadStatusEl = {
  const span = document.createElement("span");
  span.className = "webgeods-panel-status" + (uploadBusy ? " webgeods-btn-loading" : "");
  span.textContent = uploadStatus;
  return span;
}
sharedMap = {
  const map = new window.WebGeoDS.Map({ center: [12.5, 41.9], zoom: 4, height: "480px" });
  await map.ready();
  window.WebGeoDS.track?.("tool_loaded", { tool: "ndvi-calculator" });
  return map;
}

The code itself isn’t shown here — it’s a fixed, non-editable computation using rasterio/numpy underneath, the same Normalized Difference from Raster Calculator with the formula and bands fixed to Red/NIR. Want to see it built up step by step, in Python and R? Read NDVI: Measuring Vegetation from Two Bands.

// "Load example" writes a small synthetic two-band raster shaped like
// a real Red/NIR pair: a Gaussian "vegetation patch" where NIR is
// high and Red is low (the actual physical signature — leaves reflect
// strongly in NIR, chlorophyll absorbs red light), fading to a
// bare-soil-like background where the opposite holds. Produces a
// believable NDVI range (roughly -0.4 to +0.85), not a coincidental
// near-zero result the way an arbitrary pair of bumps would.
generateExample = async () => {
  await window.WebGeoDS.CodeCell.find("ndvi-generate-example-py").run();
}
loadExampleButton = {
  const button = document.createElement("button");
  button.className = "webgeods-panel-btn";
  button.dataset.variant = "outline";
  button.textContent = "📋 Load example";
  button.onclick = async () => {
    mutable uploadBusy = true;
    mutable uploadStatus = "⌛ Loading example...";
    try {
      await generateExample();
      mutable uploadStatus = "⌛ Inspecting...";
      await autoInspect();
      mutable uploadStatus = "✓ example data loaded — pick Red/NIR and Compute below.";
    } finally {
      mutable uploadBusy = false;
    }
  };
  return button;
}
autoInspect = async () => {
  await window.WebGeoDS.CodeCell.find("ndvi-inspect-py").run();
}
FOOTPRINT_PAINT = ({
  "fill-color": "#42583c",
  "fill-opacity": 0.35,
  "fill-outline-color": "#2a2117"
})
mutable inspectSummary = null
mutable resultSummary = null
pyInspect = WebGeoDS.getCellValue("ndvi-inspect-py", Generators)
{
  if (pyInspect && pyInspect.summary) {
    await sharedMap.setGeoJSON("ndvi-footprint", pyInspect.footprint, { paint: FOOTPRINT_PAINT });
    await sharedMap.fitToData(pyInspect.footprint);
    mutable inspectSummary = pyInspect.summary;
    // A fresh upload/example invalidates any previously computed
    // result — same reasoning as clicking Reset.
    await sharedMap.removeRasterImage("ndvi-result");
    mutable resultSummary = null;
  }
}
// base64 -> raw bytes, same pattern as the rest of the site (binary
// data can only cross the Pyodide->JS bridge as a cell value via a
// text encoding).
base64ToBytes = (base64Str) => {
  const binary = atob(base64Str);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return bytes;
}
// A pure convenience: precompiles the NIR/Red selects with commonly
// documented band numbers for a couple of well-known sensors. It
// does NOT verify anything against the actual uploaded file (a
// GeoTIFF never declares which sensor produced it) -- purely a
// starting guess the user is expected to confirm or correct, worded
// as such in the row's own label below.
SENSOR_PRESETS = ({
  custom: null,
  landsat: { red: 4, nir: 5, label: "Landsat 8/9 (Red=B4, NIR=B5)" },
  sentinel2: { red: 4, nir: 8, label: "Sentinel-2 (Red=B4, NIR=B8)" }
})
// The band pickers + preset + Compute button, rebuilt together
// whenever inspectSummary changes -- same reasoning as
// raster-calculator.qmd's bandMathRow (option counts depend on the
// current file's real band count).
ndviRow = {
  const s = inspectSummary;
  const row = document.createElement("div");
  row.className = "webgeods-panel-row";

  const bandCount = s ? s.bandCount : 0;

  if (bandCount < 2) {
    const status = document.createElement("span");
    status.className = "webgeods-panel-status";
    status.textContent = s
      ? `This file has only ${bandCount} band — NDVI needs at least 2 (a Red and a NIR band).`
      : "Upload a file or load the example to pick bands.";
    row.append(status);
    return row;
  }

  const makeBandSelect = (id, label, defaultBand) => {
    const wrap = document.createElement("span");
    const span = document.createElement("span");
    span.className = "webgeods-panel-status";
    span.textContent = label;
    const select = document.createElement("select");
    select.id = id;
    select.className = "webgeods-panel-status";
    for (let i = 1; i <= bandCount; i++) {
      const opt = document.createElement("option");
      opt.value = String(i);
      opt.textContent = `Band ${i}`;
      if (i === defaultBand) opt.selected = true;
      select.appendChild(opt);
    }
    wrap.append(span, select);
    return wrap;
  };

  const redWrap = makeBandSelect("red-band-select", "Red:", 1);
  const nirWrap = makeBandSelect("nir-band-select", "NIR:", Math.min(2, bandCount));

  const presetLabel = document.createElement("span");
  presetLabel.className = "webgeods-panel-status";
  presetLabel.textContent = "Common sensor (starting guess):";
  const presetSelect = document.createElement("select");
  presetSelect.className = "webgeods-panel-status";
  presetSelect.appendChild(new Option("Custom / manual", "custom"));
  presetSelect.appendChild(new Option(SENSOR_PRESETS.landsat.label, "landsat"));
  presetSelect.appendChild(new Option(SENSOR_PRESETS.sentinel2.label, "sentinel2"));
  presetSelect.onchange = () => {
    const preset = SENSOR_PRESETS[presetSelect.value];
    if (!preset) return;
    if (preset.red > bandCount || preset.nir > bandCount) {
      mutable uploadStatus = `⚠️ ${presetSelect.options[presetSelect.selectedIndex].text} needs at least band ${Math.max(preset.red, preset.nir)} — this file only has ${bandCount}.`;
      presetSelect.value = "custom";
      return;
    }
    document.getElementById("red-band-select").value = String(preset.red);
    document.getElementById("nir-band-select").value = String(preset.nir);
  };

  const computeButton = document.createElement("button");
  computeButton.className = "webgeods-panel-btn";
  computeButton.textContent = "▶ Compute";
  computeButton.onclick = async () => {
    computeButton.disabled = true;
    const originalText = computeButton.textContent;
    computeButton.textContent = "⌛ Computing...";
    try {
      const nirBand = Number(document.getElementById("nir-band-select").value);
      const redBand = Number(document.getElementById("red-band-select").value);
      window.nirBand = nirBand;
      window.redBand = redBand;
      await window.WebGeoDS.CodeCell.find("ndvi-py").run();
      const result = document.getElementById("ndvi-py").value;
      // Fixed -1..1 scale, not this file's own min/max -- NDVI is a
      // bounded index by convention, and a fixed scale keeps maps
      // comparable across different files (the stat card below still
      // reports the REAL min/max/mean for this particular result).
      await sharedMap.setRasterImage("ndvi-result", {
        bounds: result.bounds,
        width: result.width,
        height: result.height,
        values: new Float32Array(base64ToBytes(result.data).buffer),
        min: -1,
        max: 1,
        colorRamp: NDVI_RAMP
      });
      mutable resultSummary = { ...result, nirBand, redBand };
      window.WebGeoDS.track?.("validation_completed", { tool: "ndvi-calculator" });
    } catch (err) {
      console.error("ndvi-calculator: Compute failed", err);
      mutable uploadStatus = `⚠️ Compute failed: ${err.message}`;
    } finally {
      computeButton.disabled = false;
      computeButton.textContent = originalText;
    }
  };

  row.append(presetLabel, presetSelect, redWrap, nirWrap, computeButton);

  return row;
}
// Brown (bare soil/water, low NDVI) -> pale yellow (sparse
// vegetation, near zero) -> green (dense vegetation, high NDVI) --
// the conventional NDVI color scheme, deliberately different from
// setRasterImage()'s generic viridis default (WebGeoDS.Map.
// DEFAULT_RASTER_RAMP, used by Raster Calculator) since this tool's
// whole point is a domain-appropriate map, not a neutral one. Stops
// are [t, r, g, b] with t in [0, 1] mapping onto the FIXED -1..1
// scale above (t=0 -> NDVI -1, t=0.5 -> NDVI 0, t=1 -> NDVI +1).
NDVI_RAMP = [
  [0.00, 140, 90, 45],
  [0.30, 210, 190, 130],
  [0.50, 230, 220, 140],
  [0.75, 130, 190, 80],
  [1.00, 20, 90, 30]
]
downloadButton = {
  const button = document.createElement("button");
  button.className = "webgeods-panel-btn";
  button.dataset.variant = "outline";
  button.textContent = "⬇ Download NDVI";
  button.disabled = !resultSummary;
  button.onclick = async () => {
    if (!resultSummary) return;
    button.disabled = true;
    const originalText = button.textContent;
    button.textContent = "⌛ Preparing...";
    try {
      window.nirBand = resultSummary.nirBand;
      window.redBand = resultSummary.redBand;
      await window.WebGeoDS.CodeCell.find("ndvi-export-py").run();
      const b64 = document.getElementById("ndvi-export-py").value;
      const bytes = base64ToBytes(b64);
      const base = WebGeoDS.Upload.baseName(uploadedFiles);
      WebGeoDS.downloadBlob(
        bytes,
        base ? `${base}-ndvi.tif` : "ndvi.tif",
        "image/tiff",
        { tool: "ndvi-calculator" }
      );
    } finally {
      button.disabled = false;
      button.textContent = originalText;
    }
  };
  return button;
}
resetCalculator = async () => {
  await sharedMap.removeRasterImage("ndvi-result");
  if (sharedMap.getGeoJSON("ndvi-footprint")) {
    await sharedMap.setGeoJSON("ndvi-footprint", { type: "FeatureCollection", features: [] });
  }
  mutable inspectSummary = null;
  mutable resultSummary = null;
}
resetMapButton = {
  const button = document.createElement("button");
  button.className = "webgeods-panel-btn";
  button.dataset.variant = "outline";
  button.textContent = "🔄 Reset";
  button.onclick = () => resetCalculator();
  return button;
}
controlPanelRow = {
  const row = document.createElement("div");
  row.className = "webgeods-panel-row";
  row.append(uploadControl, loadExampleButton, downloadButton, resetMapButton);
  return row;
}

First run loads the Python engine — expect 30-40 seconds. Instant after that, and again each time you come back to this page.

statCard = {
  const s = inspectSummary;
  const r = resultSummary;
  const box = document.createElement("div");
  box.className = "webgeods-stat-grid";

  const row = (label, value) => {
    const dt = document.createElement("div");
    dt.className = "webgeods-stat-label";
    dt.textContent = label;
    const dd = document.createElement("div");
    dd.className = "webgeods-stat-value";
    dd.textContent = value;
    box.append(dt, dd);
  };

  if (!s) {
    row("Dimensions", "—");
    row("—", "Upload a .tif/.tiff file or load the example above");
    return box;
  }

  row("Dimensions", `${s.width} × ${s.height} px`);
  row("Bands", String(s.bandCount));
  row("CRS", s.crs === null || s.crs === undefined ? `Unknown ⚠️ ${s.crsWarning}` : s.crs);
  if (s.displayCrsAssumption) {
    row("Display assumption", `${s.displayCrsAssumption} — map only, not a fact about the file`);
  }
  row("Bounds", s.bounds.join(", "));

  if (r) {
    row("Formula", `(Band ${r.nirBand} − Band ${r.redBand}) / (Band ${r.nirBand} + Band ${r.redBand})`);
    row("NDVI range (this file)", r.min !== null ? `${r.min} to ${r.max} (mean ${r.mean})` : "all NoData");
    row("NoData pixels", `${r.nodataCount.toLocaleString()} / ${r.totalPixels.toLocaleString()}`);
  }

  return box;
}

legendEl = {
  const r = resultSummary;
  const wrap = document.createElement("div");
  wrap.className = "webgeods-legend";
  if (!r) {
    wrap.hidden = true;
    return wrap;
  }
  const minLabel = document.createElement("span");
  minLabel.className = "webgeods-legend-label";
  minLabel.textContent = "−1 (bare soil / water)";
  const bar = document.createElement("div");
  bar.className = "webgeods-legend-bar";
  const stops = NDVI_RAMP
    .map(([t, red, green, blue]) => `rgb(${red},${green},${blue}) ${t * 100}%`)
    .join(", ");
  bar.style.background = `linear-gradient(to right, ${stops})`;
  const maxLabel = document.createElement("span");
  maxLabel.className = "webgeods-legend-label";
  maxLabel.textContent = "1 (dense vegetation)";
  wrap.append(minLabel, bar, maxLabel);
  return wrap;
}

sharedMapEl = sharedMap.element

Third tool in the Raster family. Read the companion article for the same computation in Python and R, one cell at a time — or Raster Calculator for the general two-band arithmetic this tool specializes (any operator, any two bands, no vegetation assumption).