Raster Calculator & Band Math
tool
raster
Upload a multi-band GeoTIFF, pick two bands and an operation, and see the computed result as a colored map overlay. Free, runs entirely in your browser: the file never leaves your computer.
Published
September 7, 2026
Upload a .tif/.tiff file with two or more bands, pick Band A, Band B, and an operation, and get the computed band back — as a colored overlay on the map and as 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: 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: "raster-calculator" });
return map;
}The code itself isn’t shown here — it’s a fixed, non-editable computation using rasterio/numpy underneath. Want to see the same computation built up step by step, in Python and R? Read Raster Band Math.
// "Load example" writes a small synthetic TWO-band raster directly
// (unlike the Inspector's own fallback, which is a single band — a
// 1-band file can't demonstrate band math at all). Two different
// Gaussian bumps, close enough that every operator produces a
// non-trivial pattern, far enough apart that Normalized Difference
// isn't ~0 everywhere.
generateExample = async () => {
await window.WebGeoDS.CodeCell.find("raster-calc-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 two bands 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("raster-calc-inspect-py", Generators)
{
if (pyInspect && pyInspect.summary) {
await sharedMap.setGeoJSON("raster-calc-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, just triggered by
// loading new data instead of an explicit click.
await sharedMap.removeRasterImage("raster-calc-result");
mutable resultSummary = null;
}
}// base64 -> raw bytes, same pattern as crs-inspector.qmd's
// base64ToBytes (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;
}// Two separate label sets, not one string-substituted from the other:
// the dropdown's own option text has no band numbers yet to
// substitute in (generic "A + B"), while the stat card's "Operation"
// row needs the ACTUAL band numbers picked — a naive .replace("A", …)
// on a template string breaks on Normalized Difference anyway, whose
// label contains "A" and "B" twice each.
OPERATOR_OPTIONS = ({
add: "A + B",
subtract: "A − B",
multiply: "A × B",
divide: "A ÷ B",
normalized_difference: "Normalized Difference (A−B)/(A+B)"
})OPERATOR_DESCRIBE = ({
add: (a, b) => `Band ${a} + Band ${b}`,
subtract: (a, b) => `Band ${a} − Band ${b}`,
multiply: (a, b) => `Band ${a} × Band ${b}`,
divide: (a, b) => `Band ${a} ÷ Band ${b}`,
normalized_difference: (a, b) => `Normalized Difference: (Band ${a} − Band ${b}) / (Band ${a} + Band ${b})`
})// The band pickers + operator + Compute button, rebuilt together
// whenever inspectSummary changes — the option counts (band A/B
// dropdowns) depend on the current file's actual band count, so a
// static cell referencing a stale option list would drift out of sync
// with a newly uploaded file. Disabled entirely (with an explanatory
// message instead of a dropdown pair) for a file with fewer than 2
// bands: band math has nothing to compute on a single band.
bandMathRow = {
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 — Band Math needs at least 2.`
: "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 operatorSelect = document.createElement("select");
operatorSelect.id = "operator-select";
operatorSelect.className = "webgeods-panel-status";
Object.entries(OPERATOR_OPTIONS).forEach(([value, label]) => {
const opt = document.createElement("option");
opt.value = value;
opt.textContent = label;
operatorSelect.appendChild(opt);
});
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 bandA = Number(document.getElementById("band-a-select").value);
const bandB = Number(document.getElementById("band-b-select").value);
const operator = document.getElementById("operator-select").value;
window.bandA = bandA;
window.bandB = bandB;
window.operator = operator;
await window.WebGeoDS.CodeCell.find("raster-calc-py").run();
const result = document.getElementById("raster-calc-py").value;
await sharedMap.setRasterImage("raster-calc-result", {
bounds: result.bounds,
width: result.width,
height: result.height,
values: new Float32Array(base64ToBytes(result.data).buffer),
min: result.min ?? 0,
max: result.max ?? 1
});
mutable resultSummary = { ...result, bandA, bandB, operator };
window.WebGeoDS.track?.("validation_completed", { tool: "raster-calculator" });
} finally {
computeButton.disabled = false;
computeButton.textContent = originalText;
}
};
row.append(
makeBandSelect("band-a-select", "A:", 1),
makeBandSelect("band-b-select", "B:", Math.min(2, bandCount)),
operatorSelect,
computeButton
);
return row;
}downloadButton = {
const button = document.createElement("button");
button.className = "webgeods-panel-btn";
button.dataset.variant = "outline";
button.textContent = "⬇ Download result";
button.disabled = !resultSummary;
button.onclick = async () => {
if (!resultSummary) return;
button.disabled = true;
const originalText = button.textContent;
button.textContent = "⌛ Preparing...";
try {
window.bandA = resultSummary.bandA;
window.bandB = resultSummary.bandB;
window.operator = resultSummary.operator;
await window.WebGeoDS.CodeCell.find("raster-calc-export-py").run();
const b64 = document.getElementById("raster-calc-export-py").value;
const bytes = base64ToBytes(b64);
const base = WebGeoDS.Upload.baseName(uploadedFiles);
WebGeoDS.downloadBlob(
bytes,
base ? `${base}-${resultSummary.operator}.tif` : `band-math-${resultSummary.operator}.tif`,
"image/tiff",
{ tool: "raster-calculator" }
);
} finally {
button.disabled = false;
button.textContent = originalText;
}
};
return button;
}resetCalculator = async () => {
await sharedMap.removeRasterImage("raster-calc-result");
if (sharedMap.getGeoJSON("raster-calc-footprint")) {
await sharedMap.setGeoJSON("raster-calc-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("Operation", OPERATOR_DESCRIBE[r.operator](r.bandA, r.bandB));
row("Output range", 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 || r.min === null) {
wrap.hidden = true;
return wrap;
}
const minLabel = document.createElement("span");
minLabel.className = "webgeods-legend-label";
minLabel.textContent = String(r.min);
const bar = document.createElement("div");
bar.className = "webgeods-legend-bar";
const stops = WebGeoDS.Map.DEFAULT_RASTER_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 = String(r.max);
wrap.append(minLabel, bar, maxLabel);
return wrap;
}
Second tool in the Raster family. Read the companion article for the same computation in Python and R, one cell at a time, or start from Raster Inspector if you just need to see what’s in a file first. An NDVI Calculator (the same Normalized Difference operation above, with red/near-infrared band presets) is planned as the next tool in this family.