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.
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;
}FOOTPRINT_PAINT = ({
"fill-color": "#42583c",
"fill-opacity": 0.35,
"fill-outline-color": "#2a2117"
})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;
}
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).