webgeods webGeoDs
  • Home
  • Tools
    • All tools

    • Inspect
    • Validate
    • Check Topology
    • CRS

    • Inspect
    • Calculate
  • Articles
    • All articles

    • Inspect
    • Validate
    • Check Topology
    • CRS

    • Inspect
    • Calculate
  • About

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.

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: 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;
}
autoInspect = async () => {
  await window.WebGeoDS.CodeCell.find("raster-calc-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("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;
}

sharedMapEl = sharedMap.element

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.