Spatial Classifier
tool
geoml
Upload a labeled point dataset and train a Random Forest classifier on it — pick which column holds the class, then see a full classification surface predicted across the whole area, not just an accuracy number. Runs entirely in your browser, powered by Python.
Published
September 11, 2026
Upload a point file with a column that labels each point’s class — .geojson, or a shapefile as a single .zip or as .shp/.dbf/.shx selected together — pick that column, set a number of trees, and train a Random Forest on the points’ locations. The model is then applied to a regular grid across the whole area, so the result is a full classification surface, not just an accuracy score on data you already labeled yourself.
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 (see the article for the same computation shown
// in R too, including a real divergence between the two languages):
// skip writing to R's filesystem entirely, so selecting a file
// doesn't also load webR in the background for no reason.
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.45, 41.9], zoom: 4, height: "480px" });
await map.ready();
window.WebGeoDS.track?.("tool_loaded", { tool: "spatial-classifier" });
return map;
}The code itself isn’t shown here — it’s a fixed, non-editable computation using scikit-learn’s RandomForestClassifier underneath. Want to see it built up step by step, in Python and R — including a real gotcha where the two languages disagree on classification vs. regression? Read Predicting Spatial Classes with Random Forest.
autoInspect = async () => {
await window.WebGeoDS.CodeCell.find("spatial-classifier-inspect-py").run();
}// Deterministic synthetic dataset -- two groups, cleanly separated in
// space, each carrying a fixed "class" label. Reuses the exact same
// coordinates as the first and third groups of the clustering tool's
// own EXAMPLE_POINTS (already verified well-separated), minus the
// scattered noise points -- classification wants clean labeled data,
// not noise to explain away.
EXAMPLE_ROWS = [
...[
[12.450066, 41.900016], [12.450332, 41.900159], [12.450202, 41.900152],
[12.450017, 41.899721], [12.449639, 41.899781], [12.44983, 41.900398],
[12.450168, 41.900137], [12.44989, 41.899805], [12.449684, 41.90032],
[12.45028, 41.900272], [12.449961, 41.899825], [12.450273, 41.899636]
].map(coord => ({ coord, cls: "north" })),
...[
[12.455277, 41.884832], [12.454614, 41.884991], [12.454621, 41.884723],
[12.45535, 41.884793], [12.45486, 41.885091], [12.455187, 41.885068],
[12.455246, 41.885086], [12.455096, 41.884671], [12.454816, 41.884664],
[12.45532, 41.885381]
].map(coord => ({ coord, cls: "south" }))
]EXAMPLE_GEOJSON = JSON.stringify({
type: "FeatureCollection",
features: EXAMPLE_ROWS.map((row, i) => ({
type: "Feature",
properties: { name: `point ${i + 1}`, class: row.cls },
geometry: { type: "Point", coordinates: row.coord }
}))
})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 {
const bytes = new TextEncoder().encode(EXAMPLE_GEOJSON);
await window.WebGeoDS.Python.writeFile("/uploaded.geojson", bytes);
mutable uploadStatus = "⌛ Inspecting...";
await autoInspect();
mutable uploadStatus = "✓ example data loaded — pick the class column and Compute below.";
} finally {
mutable uploadBusy = false;
}
};
return button;
}pyInspect = WebGeoDS.getCellValue("spatial-classifier-inspect-py", Generators)
{
if (pyInspect) {
// No paint override here -- the default circle style (shared/map.js's
// _defaultPaint) is exactly right for "uploaded, not yet classified".
// Class colors only appear once Compute has actually run.
await sharedMap.setGeoJSON("spatial-classifier-training-py", pyInspect.features);
await sharedMap.fitToData(pyInspect.features);
mutable inspectSummary = pyInspect.summary;
mutable crsWarning = pyInspect.crsWarning;
// A fresh upload/example invalidates any previously computed
// classification -- same reasoning as clicking Reset.
mutable resultSummary = null;
mutable exportFeatures = null;
await sharedMap.removeGeoJSON("spatial-classifier-grid-py").catch(() => {});
}
}// Warm-palette hues from shared/_brand.yml (terracotta, muschio, info,
// attenzione, errore) -- same 5 non-neutral hues the clustering tool
// uses, keyed here on a class LABEL string instead of a cluster
// integer. Cycles if there are more than 5 classes.
CLASS_PALETTE = ["#ab502b", "#42583c", "#3d5a73", "#c48a2e", "#8b2f24"]// A function, not a static constant: the number/names of classes
// depend on what's actually in the uploaded column -- same pattern as
// the clustering tool's own clusterPaint. `field` differs by layer:
// the training layer colors by the TRUE class ("class"), the grid
// layer by the PREDICTED class ("predictedClass") -- same palette, so
// the two are directly comparable on the map.
classPaint = (classLabels, field, opts = {}) => {
const expr = ["match", ["get", field]];
classLabels.forEach((label, i) => {
expr.push(label, CLASS_PALETTE[i % CLASS_PALETTE.length]);
});
expr.push("#766851"); // fallback neutral, shouldn't normally show
if (opts.fill) {
return { "fill-color": expr, "fill-opacity": 0.45, "fill-outline-color": "rgba(0,0,0,0)" };
}
return {
"circle-color": expr,
"circle-radius": 6,
"circle-stroke-width": 1.5,
"circle-stroke-color": "#2a2117"
};
}// The class-column dropdown + trees/resolution sliders + Compute
// button, rebuilt whenever inspectSummary changes -- same
// "disabled with an explanatory message until there's data" pattern
// as the clustering explorer's own controls row.
classifierControlsRow = {
const s = inspectSummary;
const row = document.createElement("div");
row.className = "webgeods-panel-row";
if (!s) {
const status = document.createElement("span");
status.className = "webgeods-panel-status";
status.textContent = "Upload a point file or load the example to set parameters.";
row.append(status);
return row;
}
if (!s.columns || s.columns.length === 0) {
const status = document.createElement("span");
status.className = "webgeods-panel-status";
status.textContent = "This file has no attribute columns to classify on.";
row.append(status);
return row;
}
const label = (text) => {
const span = document.createElement("span");
span.className = "webgeods-panel-status";
span.textContent = text;
return span;
};
const columnSelect = document.createElement("select");
columnSelect.id = "class-column-select";
columnSelect.className = "webgeods-panel-status";
s.columns.forEach((c) => columnSelect.appendChild(new Option(c, c)));
if (s.columns.includes("class")) columnSelect.value = "class";
const treesSlider = WebGeoDS.createSlider([10, 300], {
value: 100, step: 10, label: "Trees", id: "trees-control"
});
const resolutionSlider = WebGeoDS.createSlider([10, 40], {
value: 20, step: 5, label: "Grid resolution", id: "grid-resolution-control"
});
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 = "⌛ Training...";
try {
const classColumn = columnSelect.value;
const nTrees = Number(document.getElementById("trees-control").value);
const gridResolution = Number(document.getElementById("grid-resolution-control").value);
window.classColumn = classColumn;
window.nTrees = nTrees;
window.gridResolution = gridResolution;
await window.WebGeoDS.CodeCell.find("spatial-classifier-compute-py").run();
const result = document.getElementById("spatial-classifier-compute-py").value;
const paintOpts = { classLabels: result.summary.classLabels };
await sharedMap.setGeoJSON("spatial-classifier-grid-py", result.gridFeatures, {
paint: classPaint(result.summary.classLabels, "predictedClass", { fill: true })
});
await sharedMap.setGeoJSON("spatial-classifier-training-py", result.trainingFeatures, {
paint: classPaint(result.summary.classLabels, "class")
});
mutable exportFeatures = result.originalCrsFeatures;
mutable resultSummary = result.summary;
window.WebGeoDS.track?.("validation_completed", { tool: "spatial-classifier" });
} catch (err) {
console.error("spatial-classifier: Compute failed", err);
mutable uploadStatus = `⚠️ Compute failed: ${err.message}`;
} finally {
computeButton.disabled = false;
computeButton.textContent = originalText;
}
};
row.append(
label("Class column:"), columnSelect,
treesSlider, resolutionSlider,
computeButton
);
return row;
}resetClassifier = async () => {
if (sharedMap.getGeoJSON("spatial-classifier-training-py")) {
await sharedMap.setGeoJSON("spatial-classifier-training-py", { type: "FeatureCollection", features: [] });
}
await sharedMap.removeGeoJSON("spatial-classifier-grid-py").catch(() => {});
mutable inspectSummary = null;
mutable resultSummary = null;
mutable exportFeatures = null;
mutable crsWarning = null;
}resetMapButton = {
const button = document.createElement("button");
button.className = "webgeods-panel-btn";
button.dataset.variant = "outline";
button.textContent = "🔄 Reset";
button.onclick = () => resetClassifier();
return button;
}// Gated on resultSummary (a completed Compute), not just an upload --
// downloading an unclassified file wouldn't be this tool's own output.
downloadButton = {
const button = document.createElement("button");
button.className = "webgeods-panel-btn";
button.dataset.variant = "outline";
button.textContent = "⬇ Download";
button.disabled = !resultSummary;
button.onclick = async () => {
if (!exportFeatures) return;
button.disabled = true;
const originalText = button.textContent;
button.textContent = "⌛ Preparing...";
try {
const base = WebGeoDS.Upload.baseName(uploadedFiles);
WebGeoDS.downloadBlob(
JSON.stringify(exportFeatures, null, 2),
base ? `${base}-classified-surface.geojson` : "classified-surface.geojson",
"application/geo+json",
{ tool: "spatial-classifier" }
);
} finally {
button.disabled = false;
button.textContent = originalText;
}
};
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("Points", "—");
row("—", "Upload a point GeoJSON/Shapefile or load the example above");
return box;
}
row("Points", s.count.toLocaleString());
row("Original CRS", s.crs === "None" || !s.crs ? "Unknown" : s.crs);
if (r) {
row("Calculation CRS", r.calculationCrs);
row("Class column", r.classColumn);
row("Classes found", r.classLabels.join(", "));
row("Labeled points", r.labeledCount.toLocaleString());
if (r.excludedCount > 0) {
row("Excluded from training", `${r.excludedCount} (too far from the calculation UTM zone to project)`);
}
row("Train / test split", r.testCount > 0 ? `${r.trainCount} / ${r.testCount}` : `${r.trainCount} / — (too few points to hold out)`);
// Python's None crosses over as JS `undefined`, not `null` (a
// known site-wide ambiguity -- see the JS-conversion lesson's own
// callout on this) -- loose equality catches both.
row("Held-out accuracy", r.accuracy == null ? "N/A" : `${(r.accuracy * 100).toFixed(1)}%`);
row("Trees", r.nTrees);
row("Grid cells", r.gridCellCount.toLocaleString());
}
return box;
}
legendEl = {
const r = resultSummary;
const wrap = document.createElement("div");
wrap.className = "webgeods-legend";
if (!r) {
wrap.hidden = true;
return wrap;
}
wrap.style.flexWrap = "wrap";
wrap.style.rowGap = "8px";
const addSwatch = (color, text) => {
const swatch = document.createElement("div");
swatch.className = "webgeods-legend-bar";
swatch.style.flex = "0 0 24px";
swatch.style.background = color;
const label = document.createElement("span");
label.className = "webgeods-legend-label";
label.textContent = text;
wrap.append(swatch, label);
};
r.classLabels.forEach((label, i) => {
addSwatch(CLASS_PALETTE[i % CLASS_PALETTE.length], label);
});
return wrap;
}
The filled grid is the model’s prediction; the dots on top are your own labeled points, colored the same way — where a dot’s color doesn’t match the patch under it, that’s a training error, the same one the held-out accuracy above is measuring.
classifierTable = sharedMap.tableCell(["spatial-classifier-training-py"], null, Generators, {
emptyMessage: "No results yet"
})Second tool in the GeoML family. Want to see the actual code, or the real gotcha where R’s randomForest() silently switches from classification to regression if you forget one step? Read the full article.