<?xml version="1.0" encoding="UTF-8"?>
<rss  xmlns:atom="http://www.w3.org/2005/Atom" 
      xmlns:media="http://search.yahoo.com/mrss/" 
      xmlns:content="http://purl.org/rss/1.0/modules/content/" 
      xmlns:dc="http://purl.org/dc/elements/1.1/" 
      version="2.0">
<channel>
<title>webGeoDS</title>
<link>https://webgeods.com/articles.html</link>
<atom:link href="https://webgeods.com/articles.xml" rel="self" type="application/rss+xml"/>
<description>Interactive articles on spatial data science, with runnable Python and R examples built in, not just diagrams. The [standalone tools](/tools/index.html) distill the same checks without the reading.</description>
<generator>quarto-1.7.29</generator>
<lastBuildDate>Sun, 20 Sep 2026 00:00:00 GMT</lastBuildDate>
<item>
  <title>Filling Raster Gaps with Cokriging</title>
  <link>https://webgeods.com/posts/cokriging-raster-gap-filling.html</link>
  <description><![CDATA[ 




<p>The <a href="../posts/kriging-soil-sampling.html">kriging article</a> on this site predicts a value everywhere from scattered points, using only how far apart those points are. But a raster with gaps is a different problem: you already have a dense grid, most of it valid, and a second band of the <em>same file</em> that’s correlated with it and valid where it isn’t, at least in part (elevation next to a cloud-gapped spectral band, say). Ignoring that second band and kriging the gaps from the target’s own neighbours alone throws away real information. Cokriging is kriging that uses it: fit a model of how the <em>two</em> bands vary together, not just how one varies with distance, then predict the missing values from wherever the covariate is available.</p>
<p>Prefer to skip straight to a result? The <a href="../tools/raster-gap-filler.html">standalone tool</a> runs the same computation: upload your own multi-band raster, pick the two bands, and it fills the gaps for you.</p>
<section id="upload-your-raster" class="level2">
<h2 class="anchored" data-anchor-id="upload-your-raster">1. Upload your raster</h2>
<p>A multi-band GeoTIFF (<code>.tif</code>/<code>.tiff</code>) with at least two bands: one you want filled (has real gaps, NoData cells), one that doesn’t. That second band is the covariate: elevation, another spectral band, anything correlated with the first.</p>
<div id="lab-upload">

</div>
</section>
<section id="why-a-covariate-band-helps" class="level2">
<h2 class="anchored" data-anchor-id="why-a-covariate-band-helps">2. Why a covariate band helps</h2>
<p>If you’ve read the <a href="../posts/kriging-soil-sampling.html">kriging article</a>, you’ve already met the variogram: bin every pair of points by distance, average their squared difference within each bin (half of it, actually, which is why the axis says semivariance, not variance), fit a smooth curve to those bins. That curve’s nugget, sill, and range describe how one variable varies with itself over distance. Cokriging needs one more piece: the <strong>cross-variogram</strong>, the same idea applied to <em>two</em> variables at once. It asks how much the target’s value at one location tends to differ from the covariate’s value at <em>another</em> location, as a function of the distance between them.</p>
<p>Fitting a target’s variogram, a covariate’s variogram, and their cross-variogram together, consistently enough that the combined system stays mathematically valid (technically: positive definite), is a <strong>linear model of coregionalization</strong> (LMC). Once fit, predicting the target at a gap uses both: the target’s own nearby known values, <em>and</em> the covariate’s value right there, weighted by how strongly the two actually move together. Where the covariate is missing too, there’s nothing extra to weight in. Cokriging can’t fill that pixel any differently than plain kriging could, which is why this article (and the standalone tool) leaves those cells empty rather than guessing.</p>
</section>
<section id="shared-map" class="level2">
<h2 class="anchored" data-anchor-id="shared-map">3. Shared map</h2>
<div id="lab-map" style="min-height: 500px;">

</div>
<div id="lab-legend">

</div>
</section>
<section id="compute-fit-the-model-in-r-runnable-here-python-reference-only" class="level2">
<h2 class="anchored" data-anchor-id="compute-fit-the-model-in-r-runnable-here-python-reference-only">4. Compute: fit the model in R — runnable here; Python — reference only</h2>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">R — runnable in this browser</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">Python — reference (a different technique, not this one)</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="cokriging-fit-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("cokriging-fit-r", {
    language: "r",
    initialCode: "library(terra)\nlibrary(sf)\nlibrary(sp)\nlibrary(gstat)\n\nr <- tryCatch(rast(\"/uploaded.tif\"), error = function(e) NULL)\n\nif (is.null(r)) {\n  # Same synthetic 2-band raster the standalone tool's own example\n  # uses: a Gaussian-hill trend, a covariate that's a scaled, shifted,\n  # independently-noised version of the SAME trend (real correlation,\n  # not a copy), and two DIFFERENT gaps -- the target's is bigger, the\n  # covariate's is smaller and only partly inside it. That last detail\n  # matters: it's what makes \"some pixels stay empty\" a real,\n  # observable outcome below, not a hypothetical.\n  set.seed(42)\n  n <- 25\n  xs <- 0:(n - 1)\n  ys <- 0:(n - 1)\n  trend <- outer(ys, xs, function(y, x) 100 + 40 * exp(-((x - 12)^2 + (y - 12)^2) / 40))\n  target_m <- trend + matrix(rnorm(n * n, sd = 1.5), nrow = n)\n  covariate_m <- 0.6 * trend + 20 + matrix(rnorm(n * n, sd = 2), nrow = n)\n  target_m[9:17, 9:17] <- NA\n  covariate_m[12:14, 12:14] <- NA\n  band_target <- rast(target_m, crs = \"EPSG:4326\")\n  band_covariate <- rast(covariate_m, crs = \"EPSG:4326\")\n  ext(band_target) <- c(12.40, 12.40 + n * 0.004, 41.90, 41.90 + n * 0.004)\n  ext(band_covariate) <- ext(band_target)\n  names(band_target) <- \"target\"\n  names(band_covariate) <- \"covariate\"\n  r <- c(band_target, band_covariate)\n  target_i <- 1\n  covariate_i <- 2\n} else {\n  # No band picker in this article (that lives in the standalone\n  # tool) -- uses band 1 as the target, band 2 as the covariate.\n  if (nlyr(r) < 2) {\n    stop(\"Need a raster with at least 2 bands -- this file only has 1.\")\n  }\n  target_i <- 1\n  covariate_i <- 2\n}\n\n# The terra -> sp bridge: gstat's own variogram()/krige()/fit.lmc()\n# take sp point objects (coordinates(df) <- ~x+y), not a terra\n# SpatRaster -- terra doesn't provide that replacement function\n# itself. Cell centers and per-band values, aligned by row, in the\n# file's OWN crs -- no reprojection, unlike the point-survey case in\n# the kriging article: a raster's pixels already sit on a grid with a\n# real resolution, not raw lon/lat survey coordinates that need a\n# working CRS before distance means anything.\ncell_xy <- crds(r, na.rm = FALSE)\ndf <- data.frame(\n  x = cell_xy[, 1], y = cell_xy[, 2],\n  target = as.numeric(values(r[[target_i]])),\n  covariate = as.numeric(values(r[[covariate_i]]))\n)\n\n# Two disjoint pixel sets: data_fit (BOTH bands valid -- fitting only)\n# and data_pred (covariate valid, regardless of target -- this is\n# where prediction happens in the next cell). data_pred must contain\n# data_fit's own locations too, for the cokriging system's local\n# context at prediction time -- it does automatically here, since\n# data_fit already requires covariate valid, a strict subset of\n# data_pred's own condition. Narrowing data_pred to just the target's\n# own gaps instead would silently break that.\ndata_fit <- df[!is.na(df$target) & !is.na(df$covariate), ]\ndata_pred <- df[!is.na(df$covariate), ]\n\nif (nrow(data_fit) < 5) {\n  stop(paste0(\"Need at least 5 pixels where both bands are valid to fit a coregionalization model -- found \", nrow(data_fit), \".\"))\n}\n\nfit_pts <- data_fit\ncoordinates(fit_pts) <- ~x + y\n\n# The target's own EMPIRICAL variogram -- method-independent (just\n# binned squared differences), so this is computed once here and reused\n# below both for the joint fit's own initial guess and for the chart's\n# scatter points.\nv_emp <- variogram(target ~ 1, fit_pts)\ninit_psill <- var(data_fit$target)\n\n# Both variables registered on the SAME point object (fit_pts, same\n# rows, same order) -- registering them on mismatched location sets\n# instead forces gstat to compute a genuine zero-distance bin between\n# the two variables' own locations, which fit.method=7 (Cressie's\n# weights, gstat's own default) divides by. correct.diagonal fixes a\n# separate, related near-singular-matrix issue found the same way.\nv_cross <- variogram(gstat(gstat(NULL, id = \"target\", formula = target ~ 1, data = fit_pts), id = \"covariate\", formula = covariate ~ 1, data = fit_pts))\n\n# fit.lmc()'s optimizer is LOCAL, not global, and a single starting\n# range is a real gamble for a joint fit: passing it the classic\n# single-variable rule of thumb (max distance / 3) trapped it in a\n# clearly worse solution on this exact data -- confirmed live, trying\n# several starting ranges: sum-of-squared-error against the target's\n# own empirical points ranged from 1878 (at max/3) down to 173 (at the\n# FULL max distance), roughly a 10x difference, with the fitted sill\n# and range moving enough to visibly change the chart below, not just\n# the decimals. So instead of committing to one guess, this tries a\n# small spread of them and keeps whichever lands closest to the\n# target's own empirical variogram -- the same fit.method=7-then-1\n# fallback runs for each one (Cressie's weights first, N-only weights\n# only if that specific starting point makes them singular).\nsse_against_target <- function(model_df) {\n  vl <- variogramLine(model_df, dist_vector = v_emp$dist)\n  sum((v_emp$gamma - vl$gamma)^2)\n}\nfit_lmc_candidate <- function(init_range) {\n  lmc_model <- vgm(psill = init_psill, model = \"Sph\", range = init_range, nugget = init_psill / 4)\n  fresh_g <- function() gstat(gstat(NULL, id = \"target\", formula = target ~ 1, data = fit_pts), id = \"covariate\", formula = covariate ~ 1, data = fit_pts)\n  gf <- tryCatch(fit.lmc(v_cross, fresh_g(), model = lmc_model, fit.method = 7, correct.diagonal = 1.01), error = function(e) NULL)\n  if (is.null(gf)) gf <- tryCatch(fit.lmc(v_cross, fresh_g(), model = lmc_model, fit.method = 1, correct.diagonal = 1.01), error = function(e) NULL)\n  if (is.null(gf)) return(NULL)\n  list(g_fit = gf, sse = sse_against_target(gf$model[[\"target\"]]))\n}\n\ncandidates <- Filter(Negate(is.null), lapply(max(v_cross$dist) * c(1 / 6, 1 / 3, 2 / 3, 1), fit_lmc_candidate))\nif (length(candidates) == 0) {\n  stop(\"Could not fit a coregionalization model between these two bands -- often too few co-located pixels, or the bands aren't actually correlated.\")\n}\ng_fit <- candidates[[which.min(sapply(candidates, function(c) c$sse))]]$g_fit\n\n# nugget/partialSill/range AND the chart's fitted curve both come from\n# the TARGET's own component of this joint fit -- not a separately-fit\n# standalone variogram. The two can differ noticeably (fit.lmc() must\n# satisfy one shared range across target, covariate AND their cross-\n# variogram at once, a real constraint a lone fit.variogram() on target\n# doesn't have), so showing the standalone version here would draw a\n# curve that isn't actually the one cokriging below uses.\ntarget_model <- g_fit$model[[\"target\"]]\nnugget_val <- target_model$psill[1]\npartial_sill_val <- target_model$psill[2]\nrange_val <- target_model$range[2]\nsample_mean <- mean(data_fit$target)\nsample_variance <- var(data_fit$target)\nv_line <- variogramLine(target_model, maxdist = max(v_emp$dist), n = 100)\n\n# The raw target band, gaps and all, base64-encoded the same way the\n# filled result will be in the next cell -- lets the map below show\n# the actual PROBLEM (band 1, real hole visible) before showing the\n# fix.\nraw_target <- as.numeric(values(r[[target_i]]))\ncon <- rawConnection(raw(0), \"wb\")\nwriteBin(raw_target, con, size = 4, endian = \"little\")\nraw_bytes <- rawConnectionValue(con)\nclose(con)\nraw_b64 <- jsonlite::base64_enc(raw_bytes)\n\nto_minx_miny_maxx_maxy <- function(spat_extent) {\n  b <- as.vector(spat_extent)\n  c(as.numeric(b[\"xmin\"]), as.numeric(b[\"ymin\"]), as.numeric(b[\"xmax\"]), as.numeric(b[\"ymax\"]))\n}\nwgs84_bounds <- tryCatch({\n  poly_wgs84 <- project(as.polygons(ext(r), crs = crs(r)), \"EPSG:4326\")\n  to_minx_miny_maxx_maxy(ext(poly_wgs84))\n}, error = function(e) to_minx_miny_maxx_maxy(ext(r)))\n\nvalid_raw <- raw_target[!is.na(raw_target)]\n\nresult_out <- list(\n  summary = list(\n    targetBand = jsonlite::unbox(target_i),\n    covariateBand = jsonlite::unbox(covariate_i),\n    coFitPixels = jsonlite::unbox(nrow(data_fit)),\n    nugget = jsonlite::unbox(round(nugget_val, 4)),\n    partialSill = jsonlite::unbox(round(partial_sill_val, 4)),\n    range = jsonlite::unbox(round(range_val, 3)),\n    sampleMean = jsonlite::unbox(round(sample_mean, 4)),\n    sampleVariance = jsonlite::unbox(round(sample_variance, 4))\n  ),\n  # dist rounded to 6dp, not 2 -- this raster's own coordinates are\n  # degree-scale (a range around 0.01-0.05), so 2dp collapsed most of\n  # variogramLine()'s 100 points onto a handful of duplicate x values\n  # while gamma kept rising between them -- found live: a fitted\n  # \"curve\" that plotted as a staircase (flat runs and vertical jumps)\n  # instead of the smooth line it's supposed to be.\n  variogram = list(\n    empirical = list(dist = round(v_emp$dist, 6), gamma = round(v_emp$gamma, 5), npairs = v_emp$np),\n    fitted = list(dist = round(v_line$dist, 6), gamma = round(v_line$gamma, 5))\n  ),\n  raster = list(\n    width = jsonlite::unbox(ncol(r)),\n    height = jsonlite::unbox(nrow(r)),\n    data = jsonlite::unbox(raw_b64),\n    wgs84Bounds = round(wgs84_bounds, 6),\n    min = jsonlite::unbox(round(min(valid_raw), 4)),\n    max = jsonlite::unbox(round(max(valid_raw), 4))\n  )\n)\n\n# r, df, data_fit, data_pred, g_fit and target_i stay in this R\n# session's global environment after this cell finishes -- the next\n# cell reuses them directly instead of re-reading the file and\n# re-fitting, same cross-cell reuse the kriging article's own two\n# cells already rely on.\njsonlite::fromJSON(jsonlite::toJSON(result_out, null = \"null\"), simplifyVector = FALSE)",
    preload: { packages: ["terra","sf","sp","gstat","jsonlite"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["terra","sf","sp","gstat","jsonlite"] }));
    }
  });
});
</script>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div class="callout callout-style-default callout-warning callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Warning
</div>
</div>
<div class="callout-body-container callout-body">
<p><strong>This code doesn’t run on this page, and it isn’t the same algorithm.</strong> See why Python takes a different approach here.</p>
</div>
</div>
<div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># Reference only -- not executable in this browser tool, and NOT a</span></span>
<span id="cb1-2"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># port of the R cell to the left. GSTools' "collocated cokriging"</span></span>
<span id="cb1-3"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># assumes the covariate is known at every point you're predicting</span></span>
<span id="cb1-4"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># (true here -- the covariate band has no NoData either, at any pixel</span></span>
<span id="cb1-5"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># the target needs filling), and uses that single co-located value</span></span>
<span id="cb1-6"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># directly instead of fitting a full cross-variogram between the two</span></span>
<span id="cb1-7"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># variables. Simpler, and it asks less of the data, but it's a</span></span>
<span id="cb1-8"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># genuinely different method from the fit.lmc() coregionalization</span></span>
<span id="cb1-9"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># model the R cell above fits -- see this article's own note on why</span></span>
<span id="cb1-10"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># that distinction matters before treating the two as interchangeable.</span></span></code></pre></div>
</div>
</div>
</div>
<div id="lab-fit">

</div>
</section>
<section id="the-targets-own-variogram" class="level2">
<h2 class="anchored" data-anchor-id="the-targets-own-variogram">5. The target’s own variogram</h2>
<p>This is the target band’s direct variogram, the same shape as the <a href="../posts/kriging-soil-sampling.html">kriging article’s</a> own chart: half the average squared difference within each distance bin, with the fitted curve’s nugget, sill, and range marked on it. It’s not the whole story. The actual cokriging fit above also needs the covariate’s own variogram and the cross-variogram between the two, neither shown separately here. But it’s the piece that carries over directly from ordinary kriging, and the one place this article’s chart can show something the reader already knows how to read.</p>
<div id="lab-variogram">

</div>
<p>Once the model above looks right, predict the gaps themselves:</p>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-2-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-2-1" aria-controls="tabset-2-1" aria-selected="true">R — runnable in this browser</a></li><li class="nav-item"><a class="nav-link" id="tabset-2-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-2-2" aria-controls="tabset-2-2" aria-selected="false">Python — reference (a different technique, not this one)</a></li></ul>
<div class="tab-content">
<div id="tabset-2-1" class="tab-pane active" aria-labelledby="tabset-2-1-tab">
<div id="cokriging-predict-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("cokriging-predict-r", {
    language: "r",
    initialCode: "library(terra)\nlibrary(sf)\nlibrary(sp)\nlibrary(gstat)\n\nif (!exists(\"g_fit\") || !exists(\"data_pred\") || !exists(\"data_fit\")) {\n  stop(\"Run the cell in section 4 first -- this step needs its fitted model.\")\n}\n\npred_pts <- data_pred\ncoordinates(pred_pts) <- ~x + y\n\n# Target on its own known locations (fit_pts, reused from the cell\n# above), covariate on ALL of data_pred -- then attach the\n# already-fitted model as a SEPARATE step. Passing model= directly\n# into this gstat() call throws \"argument 'formula' is missing\" (it\n# reads it as a brand-new variable, not the existing fit); this plain\n# assignment after the fact is the version that actually works.\ng_pred <- gstat(NULL, id = \"target\", formula = target ~ 1, data = fit_pts)\ng_pred <- gstat(g_pred, id = \"covariate\", formula = covariate ~ 1, data = pred_pts)\ng_pred$model <- g_fit$model\n\nk <- predict(g_pred, newdata = pred_pts, nmax = 24, debug.level = 0)\n\nfilled <- df$target\npred_index <- which(!is.na(df$covariate))\ncokriged <- is.na(df$target[pred_index])\nfilled[pred_index[cokriged]] <- as.numeric(k$target.pred[cokriged])\n\nn_filled <- sum(cokriged & !is.na(filled[pred_index]))\nn_still_na <- sum(is.na(filled))\n\n# Cross-validation on the FIT data -- same gstat.cv() diagnostic used\n# on the standalone tool. Its residual column is just \"residual\", not\n# \"target.residual\" -- found empirically (a multivariate gstat.cv()\n# result names target.pred/target.var with the id prefix, but\n# residual/observed/zscore/fold without it).\n# Fixed seed: the folds are random, so the RMSE would otherwise\n# change a little on every run.\nset.seed(1)\ncv <- tryCatch(gstat.cv(g_fit, nfold = 5, verbose = FALSE), error = function(e) NULL)\nrmse <- if (!is.null(cv)) sqrt(mean(cv$residual^2, na.rm = TRUE)) else NA\n\nfilled_valid <- filled[!is.na(filled)]\n\nresult_out <- list(\n  summary = list(\n    pixelsFilled = jsonlite::unbox(n_filled),\n    pixelsStillEmpty = jsonlite::unbox(n_still_na),\n    crossValidationRmse = if (is.na(rmse)) NULL else jsonlite::unbox(round(rmse, 4))\n  ),\n  raster = list(\n    width = jsonlite::unbox(ncol(r)),\n    height = jsonlite::unbox(nrow(r)),\n    data = jsonlite::unbox({\n      con <- rawConnection(raw(0), \"wb\")\n      writeBin(as.numeric(filled), con, size = 4, endian = \"little\")\n      b <- rawConnectionValue(con)\n      close(con)\n      jsonlite::base64_enc(b)\n    }),\n    wgs84Bounds = round(wgs84_bounds, 6),\n    min = jsonlite::unbox(round(min(filled_valid), 4)),\n    max = jsonlite::unbox(round(max(filled_valid), 4))\n  )\n)\n\njsonlite::fromJSON(jsonlite::toJSON(result_out, null = \"null\"), simplifyVector = FALSE)",
    preload: { packages: ["terra","sf","sp","gstat","jsonlite"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["terra","sf","sp","gstat","jsonlite"] }));
    }
  });
});
</script>
</div>
<div id="tabset-2-2" class="tab-pane" aria-labelledby="tabset-2-2-tab">
<div class="callout callout-style-default callout-warning callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Warning
</div>
</div>
<div class="callout-body-container callout-body">
<p><strong>Still a different method, continued.</strong> If GSTools’ collocated cokriging were used instead of the R cell’s LMC, this is the step that would actually predict. Same caveat as above.</p>
</div>
</div>
<div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb2-1"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># Reference only, for the same reason as the fit step above: GSTools'</span></span>
<span id="cb2-2"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># collocated cokriging predicts directly from the fitted model and the</span></span>
<span id="cb2-3"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># covariate's own values at the target locations, no separate</span></span>
<span id="cb2-4"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># "prediction dataset" the way gstat's predict() needs one built by</span></span>
<span id="cb2-5"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># hand -- a real structural difference from the R cell to the left,</span></span>
<span id="cb2-6"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># not just a syntax one.</span></span></code></pre></div>
</div>
</div>
</div>
<div id="lab-prediction">

</div>
</section>
<section id="why-python-takes-a-different-approach-here" class="level2">
<h2 class="anchored" data-anchor-id="why-python-takes-a-different-approach-here">6. Why Python takes a different approach here</h2>
<p>R’s <code>gstat</code> (built on <code>sp</code>/<code>sf</code>) has no WebAssembly build of its closest Python equivalent, <code>pykrige</code>. That’s the same gap the <a href="../posts/kriging-soil-sampling.html#why-python-is-reference-only-here">kriging article</a> already documents, and it’s true again here for the same reason: no <code>pykrige</code> wheel published for Pyodide. But cokriging specifically has a second, more interesting wrinkle worth being precise about instead of glossed over: Python’s own <a href="https://geostat-framework.readthedocs.io/projects/gstools/en/stable/examples/05_kriging/index.html">GSTools</a> library <em>does</em> offer cokriging (Simple and Intrinsic <strong>Collocated</strong> Cokriging), but that’s a genuinely different technique from the <code>fit.lmc()</code> linear model of coregionalization the R cell above fits, not just a different implementation of the same idea. Collocated cokriging assumes the covariate is known at every location you’re predicting, and uses that one co-located value directly, without fitting a full cross-variogram between the two variables the way an LMC does. It asks less of the data and skips a real modeling step, which is reasonable when its own assumption holds but not a drop-in substitute for what this article and the standalone tool actually compute. No verified reference code for it sits on this page: writing some without running it against a real GSTools install would risk teaching a technique this project hasn’t actually checked.</p>
</section>
<section id="where-to-next" class="level2">
<h2 class="anchored" data-anchor-id="where-to-next">7. Where to next</h2>
<ul>
<li><strong>Just needed the map?</strong> The <a href="../tools/raster-gap-filler.html">standalone Raster Gap Filler</a>: upload your own multi-band file, pick the two bands, done.</li>
<li><strong>Only have one band, no covariate?</strong> Ordinary kriging on the raster’s own valid pixels still works: the standalone tool falls back to it automatically when you leave the covariate on “None.” See the <a href="../posts/kriging-soil-sampling.html">kriging article</a> for how that half of the pipeline works on its own.</li>
</ul>
<p>Second tool in the GeoStatistics family.</p>
<div id="lab-root"></div>
<script src="../vega.min.js"></script>
<script src="../vega-lite.min.js"></script>
<script src="../vega-embed.min.js"></script>
<script src="../vega-chart.js"></script>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("cokriging-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>

 ]]></description>
  <category>tool</category>
  <category>geostatistics</category>
  <guid>https://webgeods.com/posts/cokriging-raster-gap-filling.html</guid>
  <pubDate>Sun, 20 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Interpolating Soil Moisture with Kriging</title>
  <link>https://webgeods.com/posts/kriging-soil-sampling.html</link>
  <description><![CDATA[ 




<p>A soil probe gives you a number at one point. A field has thousands of points you didn’t probe. Kriging is the answer to “what’s the value everywhere else, and how sure am I.” It’s named after Danie Krige, a South African mining engineer who developed it in the 1950s to estimate ore grade between drill holes. Today it’s used just as often for soil moisture, pH, and pollutant concentration: anything measured at scattered locations, where a full map matters more than the individual readings.</p>
<p>This is also the first article on this site where Python doesn’t run live. Read on for why, and what it means for how this page is built.</p>
<p>Prefer to skip straight to a result? The <a href="../tools/kriging-interpolator.html">standalone tool</a> runs the same computation: upload your own file, pick the column, and it predicts the surface for you.</p>
<section id="upload-your-file" class="level2">
<h2 class="anchored" data-anchor-id="upload-your-file">1. Upload your file</h2>
<p>A point file (<code>.geojson</code>, or a shapefile as a single <code>.zip</code> or as <code>.shp</code>/<code>.dbf</code>/<code>.shx</code> selected together) with a numeric column: soil moisture, pH, anything measured at scattered locations.</p>
<div id="lab-upload">

</div>
</section>
<section id="why-fit-a-variogram-instead-of-just-averaging-nearby-points" class="level2">
<h2 class="anchored" data-anchor-id="why-fit-a-variogram-instead-of-just-averaging-nearby-points">2. Why fit a variogram, instead of just averaging nearby points?</h2>
<p>The simplest way to fill in the gaps is Inverse Distance Weighting (IDW): average the nearby points, weighting closer ones more, with the weight falling off by some fixed power of distance (usually 2). It’s fast and easy to reason about, and this site’s <a href="../tools/buffer-proximity.html">Buffer/Proximity tool</a> family uses distance the same simple way. It has a real limitation, though: <strong>the decay rate is assumed, not measured.</strong> Whether moisture actually changes fast or slowly with distance in <em>this</em> field, at <em>this</em> time, is never asked.</p>
<p>Kriging asks it, in three steps this article builds up one at a time:</p>
<ol type="1">
<li><strong>The empirical variogram</strong>: for every pair of sample points, compute the squared difference in their values and the distance between them, then average those squared differences within distance bins. This directly measures how different two points tend to be as a function of how far apart they are. Pairs close together should differ less than pairs far apart, if there’s any spatial structure to find at all.</li>
<li><strong>Fitting a model</strong>: a smooth curve (spherical, exponential, or Gaussian; this article uses spherical) is fit to those binned points by nonlinear least squares. The fitted curve has three numbers worth knowing:
<ul>
<li><strong>Nugget</strong>: the curve’s value at distance zero. Two points right on top of each other should be identical, so a nugget above zero means measurement noise or small-scale variability the model can’t (and shouldn’t try to) explain away.</li>
<li><strong>Partial sill</strong>: how much <em>more</em> the curve rises as distance grows. Nugget plus partial sill gives the <em>total</em> sill: the variance you’d expect between two points so far apart they’re basically unrelated.</li>
<li><strong>Range</strong>: the distance at which the curve flattens out and reaches that total sill. Beyond the range, two points tell you nothing about each other; within it, they do, more so the closer they are.</li>
</ul></li>
<li><strong>Kriging itself</strong>: using that fitted model as the actual weighting rule instead of an assumed power law, it predicts a value at every point on a grid, as a weighted combination of the sample points where the weights come from the model. Solving for those weights also produces a <strong>prediction variance</strong> at every grid point: a real “how sure am I,” which IDW has no equivalent for at all.</li>
</ol>
</section>
<section id="shared-map" class="level2">
<h2 class="anchored" data-anchor-id="shared-map">3. Shared map</h2>
<div id="lab-map" style="min-height: 500px;">

</div>
<div id="lab-legend">

</div>
</section>
<section id="compute-fit-the-model-in-r-runnable-here-python-reference-only" class="level2">
<h2 class="anchored" data-anchor-id="compute-fit-the-model-in-r-runnable-here-python-reference-only">4. Compute: fit the model in R — runnable here; Python — reference only</h2>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">R — runnable in this browser</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">Python — reference (desktop/server)</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="kriging-model-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("kriging-model-r", {
    language: "r",
    initialCode: "library(sf)\nlibrary(gstat)\nlibrary(geojsonsf)\n\npts <- NULL\nfor (candidate in c(\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\")) {\n  pts <- tryCatch(st_read(candidate, quiet = TRUE), error = function(e) NULL)\n  if (!is.null(pts)) break\n}\n\nif (is.null(pts)) {\n  # Deterministic synthetic soil-moisture survey: 45 points scattered\n  # across a roughly 800m x 600m field, with a real spatial trend (a\n  # band of higher moisture, as if following a shallow drainage line,\n  # plus a smaller ripple) and measurement noise on top -- gives the\n  # variogram real spatial structure to find, not just white noise a\n  # model could never fit meaningfully.\n  set.seed(42)\n  n <- 45\n  lon0 <- 12.4450\n  lat0 <- 41.8850\n  dlon <- 800 / (111320 * cos(lat0 * pi / 180))\n  dlat <- 600 / 110540\n  x <- runif(n, 0, dlon)\n  y <- runif(n, 0, dlat)\n  trend <- 22 + 10 * exp(-((x / dlon - y / dlat)^2) / 0.08) + 3 * sin(4 * pi * x / dlon)\n  moisture <- round(trend + rnorm(n, sd = 1.5), 1)\n  pts <- st_as_sf(\n    data.frame(soil_moisture_pct = moisture, lon = lon0 + x, lat = lat0 + y),\n    coords = c(\"lon\", \"lat\"), crs = 4326\n  )\n  value_col <- \"soil_moisture_pct\"\n} else {\n  if (is.na(st_crs(pts))) {\n    pts <- st_set_crs(pts, 4326)\n  } else if (is.na(st_crs(pts)$epsg) || st_crs(pts)$epsg != 4326) {\n    pts <- st_transform(pts, 4326)\n  }\n  # No column picker in this article (that lives in the standalone\n  # tool) -- uses the first numeric, non-geometry column found.\n  geom_col <- attr(pts, \"sf_column\")\n  candidate_cols <- setdiff(names(pts), geom_col)\n  is_num <- sapply(candidate_cols, function(c) is.numeric(pts[[c]]))\n  if (!any(is_num)) {\n    stop(\"No numeric column found in the uploaded file to interpolate.\")\n  }\n  value_col <- candidate_cols[which(is_num)[1]]\n}\n\nvalues_raw <- as.numeric(sf::st_drop_geometry(pts)[[value_col]])\nvalue_mask <- !is.na(values_raw) & is.finite(values_raw)\npts <- pts[value_mask, ]\nvalues_raw <- values_raw[value_mask]\n\nif (nrow(pts) < 5) {\n  stop(paste0(\"Need at least 5 points with a valid value in \\\"\", value_col, \"\\\" -- found \", nrow(pts), \".\"))\n}\n\n# Working/calculation CRS: a variogram fit on raw degrees would treat 1\n# degree of longitude and 1 degree of latitude as the same real\n# distance everywhere on Earth, which they aren't -- same UTM auto-pick\n# (bounding-box center) as this project's other vector tools.\nbbox <- st_bbox(pts)\ncenter_lon <- as.numeric((bbox[\"xmin\"] + bbox[\"xmax\"]) / 2)\ncenter_lat <- as.numeric((bbox[\"ymin\"] + bbox[\"ymax\"]) / 2)\nutm_zone <- floor((center_lon + 180) / 6) + 1\ncalc_epsg <- if (center_lat >= 0) 32600 + utm_zone else 32700 + utm_zone\ncalc_crs_label <- paste0(\"EPSG:\", calc_epsg)\n\n# pts_calc, value_col and v_model (below) stay in this R session's\n# global environment after this cell finishes -- the next cell reuses\n# them directly instead of re-reading the file and re-fitting, the\n# same cross-cell reuse this project's other multi-cell tools already\n# rely on (e.g. spatial-classification.qmd's compute cell reusing the\n# inspect cell's own `gdf`).\npts_calc <- st_transform(pts, calc_epsg)\npts_calc$.krige_value <- values_raw\n\n# Step 1: the empirical variogram -- average squared difference\n# between every pair of points, binned by distance.\nv_emp <- variogram(.krige_value ~ 1, pts_calc)\n\n# Step 2: fit a spherical model by nonlinear least squares. The\n# starting guesses (range = a third of the max observed distance,\n# nugget = a quarter of the data's own variance) are just a reasonable\n# place to start the optimizer, not the answer -- fit.variogram()\n# refines them against v_emp above.\ninit_range <- max(v_emp$dist) / 3\ninit_psill <- var(pts_calc$.krige_value)\nv_model <- fit.variogram(\n  v_emp,\n  vgm(psill = init_psill, model = \"Sph\", range = init_range, nugget = init_psill / 4)\n)\n\nif (isTRUE(attr(v_model, \"singular\"))) {\n  stop(\"Could not fit a variogram model to this data -- often too few points, or no real spatial structure to find.\")\n}\n\n# The fitted curve itself, sampled at 100 distances, for the chart\n# below.\nv_line <- variogramLine(v_model, maxdist = max(v_emp$dist), n = 100)\n\npts_wgs84 <- st_transform(pts_calc, 4326)\npts_wgs84$value <- pts_wgs84$.krige_value\npts_out <- pts_wgs84[, \"value\"]\n\nresult_out <- list(\n  summary = list(\n    count = jsonlite::unbox(nrow(pts_calc)),\n    calculationCrs = jsonlite::unbox(calc_crs_label),\n    valueColumn = jsonlite::unbox(value_col),\n    valueMin = jsonlite::unbox(round(min(values_raw), 2)),\n    valueMax = jsonlite::unbox(round(max(values_raw), 2)),\n    nugget = jsonlite::unbox(round(v_model$psill[1], 4)),\n    partialSill = jsonlite::unbox(round(v_model$psill[2], 4)),\n    range = jsonlite::unbox(round(v_model$range[2], 1))\n  ),\n  variogram = list(\n    empirical = list(dist = round(v_emp$dist, 2), gamma = round(v_emp$gamma, 5), npairs = v_emp$np),\n    fitted = list(dist = round(v_line$dist, 2), gamma = round(v_line$gamma, 5))\n  )\n)\n\nout <- jsonlite::fromJSON(jsonlite::toJSON(result_out, null = \"null\"), simplifyVector = FALSE)\n# Attached AFTER the round-trip, not inside it: sf_geojson() already\n# returns finished JSON, and a second toJSON() pass would turn every\n# scalar inside it back into a one-element array.\nout$samplePoints <- jsonlite::fromJSON(sf_geojson(pts_out), simplifyVector = FALSE)\nout",
    preload: { packages: ["sf","gstat","geojsonsf","jsonlite"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["sf","gstat","geojsonsf","jsonlite"] }));
    }
  });
});
</script>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div class="callout callout-style-default callout-warning callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Warning
</div>
</div>
<div class="callout-body-container callout-body">
<p><strong>This code doesn’t run on this page.</strong> It’s real, working <code>pykrige</code>, the desktop/server equivalent of the R cell to the left. See why Python can’t run here.</p>
</div>
</div>
<div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> numpy <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">as</span> np</span>
<span id="cb1-2"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">from</span> pykrige.ok <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> OrdinaryKriging</span>
<span id="cb1-3"></span>
<span id="cb1-4"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># Reference implementation only -- not executable in this browser</span></span>
<span id="cb1-5"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># tool. Constructing OrdinaryKriging() already does BOTH R steps</span></span>
<span id="cb1-6"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># above in one call: it computes the empirical variogram internally,</span></span>
<span id="cb1-7"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># then fits the model to it.</span></span>
<span id="cb1-8">ok <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> OrdinaryKriging(</span>
<span id="cb1-9">    lon, lat, values,</span>
<span id="cb1-10">    variogram_model<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"spherical"</span>,</span>
<span id="cb1-11">    verbose<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">False</span>,</span>
<span id="cb1-12">    enable_plotting<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">False</span>,</span>
<span id="cb1-13">)</span>
<span id="cb1-14"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">print</span>(ok.variogram_model_parameters)  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># [sill, range, nugget]</span></span></code></pre></div>
</div>
</div>
</div>
<div id="lab-model">

</div>
</section>
<section id="the-variogram-and-predicting-the-surface" class="level2">
<h2 class="anchored" data-anchor-id="the-variogram-and-predicting-the-surface">5. The variogram, and predicting the surface</h2>
<p>The empirical bins below are the direct measurement from step 1 above: half the average squared difference within each distance bin (that halving is why the axis says <em>semivariance</em>, not just <em>variance</em>). The line is the spherical model fit to them, with its own nugget, sill, and range marked directly on the chart.</p>
<div id="lab-variogram">

</div>
<p>Once the model above looks right, predict the surface itself:</p>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-2-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-2-1" aria-controls="tabset-2-1" aria-selected="true">R — runnable in this browser</a></li><li class="nav-item"><a class="nav-link" id="tabset-2-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-2-2" aria-controls="tabset-2-2" aria-selected="false">Python — reference (desktop/server)</a></li></ul>
<div class="tab-content">
<div id="tabset-2-1" class="tab-pane active" aria-labelledby="tabset-2-1-tab">
<div id="kriging-predict-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("kriging-predict-r", {
    language: "r",
    initialCode: "library(sf)\nlibrary(gstat)\n\nif (!exists(\"v_model\") || !exists(\"pts_calc\")) {\n  stop(\"Run the cell in section 4 first -- this step needs its fitted variogram model.\")\n}\n\n# Step 3: krige onto a regular prediction grid over the points' own\n# extent, padded 5% on each side. Cell count is capped by construction\n# (25 cells along the longer side) regardless of how large the\n# uploaded extent is -- same discipline this project's other\n# grid-building tools already use.\nbbc <- st_bbox(pts_calc)\npad_x <- as.numeric(bbc[\"xmax\"] - bbc[\"xmin\"]) * 0.05\npad_y <- as.numeric(bbc[\"ymax\"] - bbc[\"ymin\"]) * 0.05\ngxmin <- as.numeric(bbc[\"xmin\"]) - pad_x\ngxmax <- as.numeric(bbc[\"xmax\"]) + pad_x\ngymin <- as.numeric(bbc[\"ymin\"]) - pad_y\ngymax <- as.numeric(bbc[\"ymax\"]) + pad_y\n\ngrid_resolution <- 25\nlonger_side <- max(gxmax - gxmin, gymax - gymin, 1e-6)\ncell_size <- longer_side / grid_resolution\nnx <- max(2, ceiling((gxmax - gxmin) / cell_size))\nny <- max(2, ceiling((gymax - gymin) / cell_size))\n\nxs <- seq(gxmin, gxmax, length.out = nx)\n# Decreasing -- north (max) first: setRasterImage's own row-major\n# convention puts row 0 at the TOP of the image, i.e. the northernmost\n# row, so the grid has to be built in that same order.\nys <- seq(gymax, gymin, length.out = ny)\n# expand.grid varies its FIRST argument fastest, matching row-major\n# order with x as the column index and y as the row index.\ngrid_df <- expand.grid(x = xs, y = ys)\ngrid_sf <- st_as_sf(grid_df, coords = c(\"x\", \"y\"), crs = st_crs(pts_calc))\n\nk <- krige(.krige_value ~ 1, pts_calc, grid_sf, model = v_model, debug.level = 0)\npred <- as.numeric(k$var1.pred)\nvar_pred <- as.numeric(k$var1.var)\n\ngrid_bbox_calc <- st_sfc(\n  st_polygon(list(matrix(\n    c(gxmin, gymin, gxmax, gymin, gxmax, gymax, gxmin, gymax, gxmin, gymin),\n    ncol = 2, byrow = TRUE\n  ))),\n  crs = st_crs(pts_calc)\n)\nbb_wgs84 <- st_bbox(st_transform(grid_bbox_calc, 4326))\n\ncon <- rawConnection(raw(0), \"wb\")\nwriteBin(pred, con, size = 4, endian = \"little\")\nraw_bytes <- rawConnectionValue(con)\nclose(con)\npred_b64 <- jsonlite::base64_enc(raw_bytes)\n\n# The uncertainty of each prediction: the kriging standard error, the\n# square root of the prediction variance, in the same units as the data.\nse_pred <- sqrt(pmax(var_pred, 0))\ncon <- rawConnection(raw(0), \"wb\")\nwriteBin(se_pred, con, size = 4, endian = \"little\")\nse_b64 <- jsonlite::base64_enc(rawConnectionValue(con))\nclose(con)\n\nresult_out <- list(\n  summary = list(\n    gridCellCount = jsonlite::unbox(length(pred)),\n    meanVariance = jsonlite::unbox(round(mean(var_pred, na.rm = TRUE), 4))\n  ),\n  uncertainty = list(\n    data = jsonlite::unbox(se_b64),\n    min = jsonlite::unbox(round(min(se_pred, na.rm = TRUE), 3)),\n    max = jsonlite::unbox(round(max(se_pred, na.rm = TRUE), 3))\n  ),\n  raster = list(\n    width = jsonlite::unbox(nx),\n    height = jsonlite::unbox(ny),\n    data = jsonlite::unbox(pred_b64),\n    wgs84Bounds = round(c(bb_wgs84[\"xmin\"], bb_wgs84[\"ymin\"], bb_wgs84[\"xmax\"], bb_wgs84[\"ymax\"]), 6),\n    min = jsonlite::unbox(round(min(pred, na.rm = TRUE), 3)),\n    max = jsonlite::unbox(round(max(pred, na.rm = TRUE), 3))\n  )\n)\n\njsonlite::fromJSON(jsonlite::toJSON(result_out, null = \"null\"), simplifyVector = FALSE)",
    preload: { packages: ["sf","gstat","jsonlite"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["sf","gstat","jsonlite"] }));
    }
  });
});
</script>
</div>
<div id="tabset-2-2" class="tab-pane" aria-labelledby="tabset-2-2-tab">
<div class="callout callout-style-default callout-warning callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Warning
</div>
</div>
<div class="callout-body-container callout-body">
<p><strong>Also reference only.</strong> Continues the same <code>pykrige</code> object fitted in the step above: <code>.execute()</code> is what actually predicts the grid.</p>
</div>
</div>
<div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb2-1"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> numpy <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">as</span> np</span>
<span id="cb2-2"></span>
<span id="cb2-3"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># Reference implementation only -- not executable in this browser</span></span>
<span id="cb2-4"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># tool. `ok` is the OrdinaryKriging object fitted in the step above;</span></span>
<span id="cb2-5"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># .execute() is the equivalent of this step's krige() call.</span></span>
<span id="cb2-6">grid_lon <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> np.linspace(lon.<span class="bu" style="color: null;
background-color: null;
font-style: inherit;">min</span>(), lon.<span class="bu" style="color: null;
background-color: null;
font-style: inherit;">max</span>(), <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">25</span>)</span>
<span id="cb2-7">grid_lat <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> np.linspace(lat.<span class="bu" style="color: null;
background-color: null;
font-style: inherit;">min</span>(), lat.<span class="bu" style="color: null;
background-color: null;
font-style: inherit;">max</span>(), <span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">25</span>)</span>
<span id="cb2-8">z, variance <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> ok.execute(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"grid"</span>, grid_lon, grid_lat)</span></code></pre></div>
</div>
</div>
</div>
<div id="lab-prediction">

</div>
<section id="how-sure-is-the-map" class="level3">
<h3 class="anchored" data-anchor-id="how-sure-is-the-map">How sure is the map?</h3>
<p>Kriging doesn’t only predict a value at every cell: it also gives its <strong>standard error</strong>, the square root of the prediction variance. Switch on the <em>Uncertainty</em> layer in the map above to see it. It is lowest right next to the samples and grows with distance from them, fastest where samples are sparse, until it levels off once the nearest sample is farther than the variogram’s range: from there on the samples say nothing about the cell. It depends only on where the samples are and on the variogram, not on the values measured.</p>
<p>That makes it practical as well as honest: it shows where the surface can be trusted, and where one more sample would help most.</p>
</section>
</section>
<section id="why-python-is-reference-only-here" class="level2">
<h2 class="anchored" data-anchor-id="why-python-is-reference-only-here">6. Why Python is reference-only here</h2>
<p>R’s geostatistics ecosystem (<code>gstat</code>, built on <code>sp</code>/<code>sf</code>) has been the standard tool for kriging since long before either language ran in a browser. Python’s closest equivalent, <code>pykrige</code>, has no WebAssembly build anywhere. That’s confirmed directly against this project, not assumed from general knowledge: there is no <code>pykrige</code> wheel published for Pyodide, and none of the usual pure-Python fallbacks (<code>scikit-gstat</code>, hand-rolled <code>numpy</code>) reach feature parity with <code>gstat</code>’s fitting routines without becoming a second, untested implementation of the exact algorithm this article is trying to teach correctly.</p>
<p>This is a different situation from <a href="../posts/raster-viewshed.html">Viewshed</a>, this site’s other R-only article. There, the Python <em>library</em> (GDAL) exists in principle but isn’t in this specific browser runtime’s package index. Here, there is no WebAssembly build of <code>pykrige</code> to load in the first place. That’s a harder, more permanent gap than a narrower one.</p>
<p>The snippet above is real, working <code>pykrige</code> code for a desktop or server Python environment; it just has nowhere to run inside this page.</p>
</section>
<section id="where-to-next" class="level2">
<h2 class="anchored" data-anchor-id="where-to-next">7. Where to next</h2>
<ul>
<li><strong>Just needed the map?</strong> The <a href="../tools/kriging-interpolator.html">standalone Kriging Interpolator</a>: upload your own file, pick the column, choose a model, done.</li>
<li><strong>Want the simpler distance-only version first?</strong> See <a href="../tools/buffer-proximity.html">Buffer/Proximity</a>, which uses no fitted model, just a fixed distance.</li>
<li><strong>Got a raster with gaps instead of scattered points?</strong> <a href="../posts/cokriging-raster-gap-filling.html">Filling Raster Gaps with Cokriging</a> builds on the same variogram fitting to patch an existing grid’s own holes, optionally using a second band as a covariate.</li>
</ul>
<p>First tool in the GeoStatistics family; more are planned.</p>
<div id="lab-root"></div>
<script src="../vega.min.js"></script>
<script src="../vega-lite.min.js"></script>
<script src="../vega-embed.min.js"></script>
<script src="../vega-chart.js"></script>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("kriging-soil-sampling-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>

 ]]></description>
  <category>tool</category>
  <category>geostatistics</category>
  <guid>https://webgeods.com/posts/kriging-soil-sampling.html</guid>
  <pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Network from Lines: How Connected Is Connected</title>
  <link>https://webgeods.com/posts/network-from-lines.html</link>
  <description><![CDATA[ 




<p>A road network, a river system, a set of pipelines: any layer of lines is only a <em>network</em> once you decide which endpoints count as the same junction. Two segments that were meant to meet but miss by a few centimeters (a common noding gap in real data) become two separate, disconnected pieces of the graph unless you tell the algorithm how close is “close enough.” That single decision, a snap tolerance in meters, can change how many disconnected pieces a network has without changing a single coordinate.</p>
<p>Want to explore this interactively, with an adjustable tolerance and the graph drawn directly on the map? Use the <a href="../tools/network-from-lines.html">standalone tool</a>: same idea, adjustable live. This article fixes two tolerance values (0m and 5m) side by side, in both languages, to make the underlying algorithm easy to read from top to bottom, and draws the resulting graph too, as a diagram below the map.</p>
<section id="upload-your-data" class="level2">
<h2 class="anchored" data-anchor-id="upload-your-data">1. Upload your data</h2>
<div id="lab-upload">

</div>
<p>No file uploaded? Both cells below fall back to the same eight-line example: a closed block (four segments forming one connected loop), a fully separate cluster elsewhere on the map, and a two-segment “spur” whose first endpoint sits about 4m from the block’s corner, not exactly on it. That 4m gap is deliberately part of the example, not a mistake in it: it’s what section 3 below is about.</p>
</section>
<section id="same-lines-two-languages" class="level2">
<h2 class="anchored" data-anchor-id="same-lines-two-languages">2. Same lines, two languages</h2>
<div id="lab-map" style="min-height: 440px;">

</div>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="network-from-lines-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("network-from-lines-py", {
    language: "python",
    initialCode: "import numpy as np\nimport geopandas as gpd\nimport igraph as ig\nfrom shapely.geometry import LineString, Point\n\n# Eight lines: a closed block, a separate cluster, and a two-segment\n# \"spur\" whose endpoint is deliberately offset (see the intro above\n# for why).\nFALLBACK_GEOMETRIES = [\n    LineString([(12.4500, 41.9000), (12.4520, 41.9000)]),\n    LineString([(12.4520, 41.9000), (12.4520, 41.9020)]),\n    LineString([(12.4520, 41.9020), (12.4500, 41.9020)]),\n    LineString([(12.4500, 41.9020), (12.4500, 41.9000)]),\n    LineString([(12.45205, 41.9000), (12.4540, 41.9000)]),\n    LineString([(12.4540, 41.9000), (12.4540, 41.9020)]),\n    LineString([(12.4700, 41.9100), (12.4720, 41.9100)]),\n    LineString([(12.4720, 41.9100), (12.4720, 41.9120)])\n]\n\ngdf = None\nfor path in [\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\"]:\n    try:\n        gdf = gpd.read_file(path)\n        break\n    except Exception:\n        pass\n\ncrs_warning = None\nif gdf is None:\n    gdf = gpd.GeoDataFrame(geometry=FALLBACK_GEOMETRIES, crs=\"EPSG:4326\")\nelse:\n    if gdf.crs is None:\n        gdf = gdf.set_crs(\"EPSG:4326\")\n        crs_warning = \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\n    elif str(gdf.crs) != \"EPSG:4326\":\n        gdf = gdf.to_crs(\"EPSG:4326\")\n    gdf = gdf[gdf.geometry.geom_type.isin([\"LineString\", \"MultiLineString\"])]\n\ngdf = gdf.explode(index_parts=False).reset_index(drop=True)\n\n# UTM auto-pick from the bounding-box center: a snap tolerance in\n# meters needs a projected CRS to mean an actual distance.\nminx, miny, maxx, maxy = gdf.total_bounds\ncenter_lon = float((minx + maxx) / 2)\ncenter_lat = float((miny + maxy) / 2)\nutm_zone = int((center_lon + 180) // 6) + 1\ncalc_epsg = (32600 if center_lat >= 0 else 32700) + utm_zone\ngdf_proj = gdf.to_crs(epsg=calc_epsg)\n\n# Endpoints only: interior vertices are a line's SHAPE, not its\n# topology. Two per line -- ends[2*i] and ends[2*i+1] are line i's own\n# start and end.\nends = np.array([\n    c for geom in gdf_proj.geometry\n    for c in (geom.coords[0][:2], geom.coords[-1][:2])\n], dtype=float)\n\n\ndef build_graph(tolerance):\n    \"\"\"Snap endpoints within `tolerance` meters of each other into the\n    same node, then build the graph. Same function, called twice below\n    with two different tolerances -- the only thing that changes\n    between the two results is this one number.\"\"\"\n\n    tol = max(tolerance, 1e-9)  # a tiny floor, not a literal 0, absorbs\n                                 # floating-point noise between\n                                 # coordinates meant to be identical.\n\n    # Pairwise distances via plain numpy broadcasting, not a spatial\n    # index -- fine at this scale, and keeps the two languages doing\n    # the same recognizable thing line for line.\n    diff = ends[:, None, :] - ends[None, :, :]\n    dist = np.sqrt((diff ** 2).sum(axis=2))\n    upper = np.triu(np.ones(dist.shape, dtype=bool), k=1)\n    close_pairs = np.argwhere((dist <= tol) & upper)\n\n    # Union-find: merges endpoints that are close enough into one\n    # shared node id.\n    parent = list(range(len(ends)))\n\n    def find(i):\n        while parent[i] != i:\n            parent[i] = parent[parent[i]]\n            i = parent[i]\n        return i\n\n    for a, b in close_pairs:\n        ra, rb = find(int(a)), find(int(b))\n        if ra != rb:\n            parent[rb] = ra\n\n    node_of = [find(i) for i in range(len(ends))]\n    unique_nodes = sorted(set(node_of))\n    node_index = {n: i for i, n in enumerate(unique_nodes)}\n\n    edges = [\n        (node_index[node_of[2 * i]], node_index[node_of[2 * i + 1]])\n        for i in range(len(gdf_proj))\n    ]\n\n    g = ig.Graph(n=len(unique_nodes), edges=edges, directed=False)\n    membership = list(g.connected_components().membership)\n\n    # Everything below builds the graph's own geometry, for the diagram\n    # panel further down -- kept inside this same function (not a\n    # second computation) so the diagram always draws exactly the graph\n    # these counts describe. Node representative = the MEAN of its\n    # snapped members, so a snapped junction sits in their middle\n    # instead of jumping to whichever line happened to be read first.\n    reps = {}\n    for i, n in enumerate(node_of):\n        reps.setdefault(node_index[n], []).append(ends[i])\n\n    node_gdf = gpd.GeoDataFrame(\n        {\"node\": list(range(len(unique_nodes))), \"component\": membership},\n        geometry=[Point(np.mean(reps[i], axis=0)) for i in range(len(unique_nodes))],\n        crs=gdf_proj.crs\n    ).to_crs(\"EPSG:4326\")\n\n    edge_gdf = gpd.GeoDataFrame(\n        {\n            \"source\": [u for u, v in edges],\n            \"target\": [v for u, v in edges],\n            \"component\": [membership[u] for u, v in edges]\n        },\n        geometry=list(gdf_proj.geometry),\n        crs=gdf_proj.crs\n    ).to_crs(\"EPSG:4326\")\n\n    return {\n        \"nodes\": g.vcount(),\n        \"edges\": g.ecount(),\n        \"components\": len(g.connected_components()),\n        \"nodeFeatures\": node_gdf.__geo_interface__,\n        \"edgeFeatures\": edge_gdf.__geo_interface__\n    }\n\n\n{\n    \"features\": gdf.__geo_interface__,\n    \"crsWarning\": crs_warning,\n    \"calculationCrs\": f\"EPSG:{calc_epsg}\",\n    \"at0m\": build_graph(0),\n    \"at5m\": build_graph(5)\n}",
    preload: { packages: ["geopandas","igraph"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, { packages: ["geopandas","igraph"] }));
    }
  });
});
</script>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div id="network-from-lines-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("network-from-lines-r", {
    language: "r",
    initialCode: "library(sf)\nlibrary(igraph)\nlibrary(geojsonsf)\n\n# Same eight lines as the Python tab: a closed block, a separate\n# cluster, and a two-segment \"spur\" whose endpoint is deliberately\n# offset (see the intro above for why).\nfallback_geom <- st_sfc(\n  st_linestring(rbind(c(12.4500, 41.9000), c(12.4520, 41.9000))),\n  st_linestring(rbind(c(12.4520, 41.9000), c(12.4520, 41.9020))),\n  st_linestring(rbind(c(12.4520, 41.9020), c(12.4500, 41.9020))),\n  st_linestring(rbind(c(12.4500, 41.9020), c(12.4500, 41.9000))),\n  st_linestring(rbind(c(12.45205, 41.9000), c(12.4540, 41.9000))),\n  st_linestring(rbind(c(12.4540, 41.9000), c(12.4540, 41.9020))),\n  st_linestring(rbind(c(12.4700, 41.9100), c(12.4720, 41.9100))),\n  st_linestring(rbind(c(12.4720, 41.9100), c(12.4720, 41.9120))),\n  crs = 4326\n)\n\ndata <- NULL\nfor (path in c(\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\")) {\n  data <- tryCatch(st_read(path, quiet = TRUE), error = function(e) NULL)\n  if (!is.null(data)) break\n}\n\ncrs_warning <- NULL\nif (is.null(data)) {\n  data <- st_sf(geometry = fallback_geom)\n} else if (is.na(st_crs(data))) {\n  st_crs(data) <- 4326\n  crs_warning <- \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\n} else if (!isTRUE(st_crs(data) == st_crs(4326))) {\n  data <- st_transform(data, 4326)\n}\ndata <- data[st_geometry_type(data) %in% c(\"LINESTRING\", \"MULTILINESTRING\"), ]\ndata <- st_cast(st_cast(data, \"MULTILINESTRING\"), \"LINESTRING\", warn = FALSE)\n\n# UTM auto-pick from the bounding-box center: a snap tolerance in\n# meters needs a projected CRS to mean an actual distance.\nbnd <- st_bbox(data)\ncenter_lon <- (bnd[[\"xmin\"]] + bnd[[\"xmax\"]]) / 2\ncenter_lat <- (bnd[[\"ymin\"]] + bnd[[\"ymax\"]]) / 2\nutm_zone <- floor((center_lon + 180) / 6) + 1\ncalc_epsg <- if (center_lat >= 0) 32600 + utm_zone else 32700 + utm_zone\ndata_proj <- st_transform(data, calc_epsg)\n\n# Endpoints only: interior vertices are a line's SHAPE, not its\n# topology. Two rows per line -- row 2*i-1 and row 2*i are line i's\n# own start and end (1-indexed, R-style).\nends <- do.call(rbind, lapply(st_geometry(data_proj), function(geom) {\n  coords <- st_coordinates(geom)[, c(\"X\", \"Y\"), drop = FALSE]\n  rbind(coords[1, ], coords[nrow(coords), ])\n}))\n\nbuild_graph <- function(tolerance) {\n  # Snap endpoints within `tolerance` meters of each other into the\n  # same node, then build the graph. Same function, called twice below\n  # with two different tolerances -- the only thing that changes\n  # between the two results is this one number.\n\n  tol <- max(tolerance, 1e-9)  # a tiny floor, not a literal 0, absorbs\n                                # floating-point noise between\n                                # coordinates meant to be identical.\n\n  # Pairwise distances via a plain distance matrix, not a spatial\n  # index -- fine at this scale, and keeps the two languages doing the\n  # same recognizable thing line for line.\n  n <- nrow(ends)\n  dist_mat <- as.matrix(dist(ends))\n\n  # Union-find: merges endpoints that are close enough into one shared\n  # node id. Named uf_parent, not parent -- plain `parent` silently\n  # resolved to an unrelated function from one of the loaded packages\n  # instead of this vector, confirmed empirically (a real \"object of\n  # type 'closure' is not subsettable\" error, not a hypothetical risk).\n  # Only the nested find() needs <<- (it writes into build_graph's\n  # scope from one level down); the loop below already runs in that\n  # same scope, so it assigns with a plain <-.\n  uf_parent <- seq_len(n)\n  find <- function(i) {\n    while (uf_parent[i] != i) {\n      uf_parent[i] <<- uf_parent[uf_parent[i]]\n      i <- uf_parent[i]\n    }\n    i\n  }\n  for (i in seq_len(n - 1)) {\n    for (j in seq((i + 1), n)) {\n      if (dist_mat[i, j] <= tol) {\n        ri <- find(i)\n        rj <- find(j)\n        if (ri != rj) uf_parent[rj] <- ri\n      }\n    }\n  }\n\n  node_of <- vapply(seq_len(n), find, integer(1))\n  unique_nodes <- sort(unique(node_of))\n  node_index <- setNames(seq_along(unique_nodes), unique_nodes)\n\n  n_lines <- nrow(data_proj)\n  edges <- integer(0)\n  for (i in seq_len(n_lines)) {\n    edges <- c(\n      edges,\n      node_index[[as.character(node_of[2 * i - 1])]],\n      node_index[[as.character(node_of[2 * i])]]\n    )\n  }\n\n  g <- make_graph(edges, n = length(unique_nodes), directed = FALSE)\n  membership <- components(g)$membership\n\n  # Everything below builds the graph's own geometry, for the diagram\n  # panel further down -- kept inside this same function (not a second\n  # computation) so the diagram always draws exactly the graph these\n  # counts describe. Node representative = the MEAN of its snapped\n  # members, so a snapped junction sits in their middle instead of\n  # jumping to whichever line happened to be read first. `edges` above\n  # is already a flat (source1, target1, source2, target2, ...) vector\n  # of graph vertex ids, so line i's source/target are just its two\n  # entries -- no need to look node_of/node_index up a second time.\n  node_points <- lapply(seq_along(unique_nodes), function(i) {\n    members <- ends[node_of == unique_nodes[i], , drop = FALSE]\n    st_point(colMeans(members))\n  })\n  node_sf <- st_transform(\n    st_sf(\n      node = seq_along(unique_nodes),\n      component = membership,\n      geometry = st_sfc(node_points, crs = calc_epsg)\n    ),\n    4326\n  )\n\n  edge_source <- edges[seq(1, 2 * n_lines, by = 2)]\n  edge_target <- edges[seq(2, 2 * n_lines, by = 2)]\n  edge_sf <- st_transform(\n    st_sf(\n      edge = seq_len(n_lines),\n      source = edge_source,\n      target = edge_target,\n      component = membership[edge_source],\n      geometry = st_geometry(data_proj)\n    ),\n    4326\n  )\n\n  list(\n    nodes = jsonlite::unbox(vcount(g)),\n    edges = jsonlite::unbox(ecount(g)),\n    components = jsonlite::unbox(components(g)$no),\n    nodeFeatures = jsonlite::fromJSON(sf_geojson(node_sf, simplify = FALSE), simplifyVector = FALSE),\n    edgeFeatures = jsonlite::fromJSON(sf_geojson(edge_sf, simplify = FALSE), simplifyVector = FALSE)\n  )\n}\n\nresult <- list(\n  # simplify = FALSE: sf_geojson()'s default (TRUE) returns a bare\n  # vector of individual GeoJSON geometries instead of one\n  # FeatureCollection when the sf object has no attribute columns --\n  # exactly this article's fallback data (geometry only, no other\n  # columns) -- forced off so the shape matches Python's\n  # __geo_interface__ (always a FeatureCollection) regardless.\n  features = jsonlite::fromJSON(sf_geojson(data, simplify = FALSE), simplifyVector = FALSE),\n  crsWarning = if (is.null(crs_warning)) NULL else jsonlite::unbox(crs_warning),\n  calculationCrs = jsonlite::unbox(paste0(\"EPSG:\", calc_epsg)),\n  at0m = build_graph(0),\n  at5m = build_graph(5)\n)\n# auto_unbox: without it this round trip turns every single value into a\n# one-element array -- \"type\": [\"FeatureCollection\"] -- which MapLibre\n# rejects, leaving the map empty whenever only this R cell had run.\njsonlite::fromJSON(jsonlite::toJSON(result, null = \"null\", auto_unbox = TRUE), simplifyVector = FALSE)",
    preload: { packages: ["sf","igraph","geojsonsf","jsonlite"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["sf","igraph","geojsonsf","jsonlite"] }));
    }
  });
});
</script>
</div>
</div>
</div>
<div id="lab-crs">

</div>
<div id="lab-stats">

</div>
<p>The map above shows <em>where</em> the lines are; the two diagrams below show <em>how they’re connected</em>: same graph the counts above describe, laid out by force instead of geography, colored by connected component. Drawn from whichever language you ran (if you run both, the second one just redraws the same picture, since Python and R agree on the graph, so there’s nothing a second drawing would show differently).</p>
<div id="lab-graphs" style="min-height: 250px;">

</div>
<div id="lab-reset">

</div>
</section>
<section id="same-lines-two-different-answers" class="level2">
<h2 class="anchored" data-anchor-id="same-lines-two-different-answers">3. Same lines, two different answers</h2>
<p>The numbers above describe the built-in fallback specifically. If you uploaded your own file, the counts will differ, but the same effect is what to look for. Both languages ran the exact same two tolerances (0m and 5m) on the exact same eight lines, and got the same shape of answer:</p>
<ul>
<li><strong>At 0m</strong>, the “spur” (two segments starting about 4m from the block’s corner) counts as its own piece: three components in total, the block, the spur, and the fully separate cluster.</li>
<li><strong>At 5m</strong>, that 4m gap is within tolerance, so the spur’s near endpoint merges into the block’s corner: two components, the now-joined block+spur, and the still-separate cluster.</li>
</ul>
<p>Nothing about the geometry changed between the two runs: same coordinates, same lines. Only the <em>definition of “the same point”</em> changed, and that alone was enough to change how many pieces the network has. This is why real-world network analysis (road networks, utility networks, hydrology) always involves a noding/snapping step before anything else: the raw geometry rarely already agrees with itself down to the last centimeter, and the tolerance you pick is a real analytical decision, not a technicality to skip past.</p>
</section>
<section id="where-to-next" class="level2">
<h2 class="anchored" data-anchor-id="where-to-next">4. Where to next</h2>
<ul>
<li><strong>Want an adjustable tolerance, the graph drawn directly on the map itself, and a way to click a node and see it highlighted in both views at once?</strong> → The <a href="../tools/network-from-lines.html">standalone Network from Lines tool</a> runs the same algorithm with a tolerance you control live, and lets you download the result.</li>
<li><strong>Wondering why the coordinate system needed picking at all?</strong> → <a href="../posts/crs-mismatch.html">Coordinate Reference Systems</a> covers the geographic/projected distinction this article leaned on in section 2.</li>
</ul>
<div id="lab-root"></div>
<script src="../d3.min.js"></script>
<script src="../graph-diagram.js"></script>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("network-from-lines-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>

 ]]></description>
  <category>article</category>
  <category>network</category>
  <category>bilingual</category>
  <guid>https://webgeods.com/posts/network-from-lines.html</guid>
  <pubDate>Sun, 13 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Buffer &amp; Proximity: How Far Is Within Reach</title>
  <link>https://webgeods.com/posts/buffer-proximity.html</link>
  <description><![CDATA[ 




<p>“What’s within 300 meters of this store?” and “what falls within 50 meters of this road?” are the same question underneath: draw a zone at a fixed distance around a feature, then check what’s inside it, a buffer. The distance is fixed, but what comes out depends entirely on what went in.</p>
<p>Just want to buffer a file with an adjustable distance, rings, and a download button, without the rest of the reading? Use the <a href="../tools/buffer-proximity.html">standalone tool</a> — same engine, less prose.</p>
<section id="why-the-coordinate-system-matters-here" class="level2">
<h2 class="anchored" data-anchor-id="why-the-coordinate-system-matters-here">1. Why the coordinate system matters here</h2>
<p>A buffer distance is a real distance: meters, not degrees of longitude/latitude, where the same number of degrees covers very different real distances depending on where on Earth you are. Both languages below pick a working UTM zone from the data’s own bounding-box center and reproject into it first, same reasoning, same formula, as this site’s <a href="../posts/raster-viewshed.html">Viewshed</a> and <a href="../posts/spatial-clustering.html">Spatial Clustering</a> tools: a reasonable default for one local dataset, not a universally correct choice for data spanning multiple UTM zones or very large areas.</p>
</section>
<section id="upload-your-data" class="level2">
<h2 class="anchored" data-anchor-id="upload-your-data">2. Upload your data</h2>
<div id="lab-upload">

</div>
<p>No file uploaded? Both cells below fall back to the same three fixed features: a point (a shop), a line (a road segment), and a polygon (a building footprint), deliberately different geometry types, chosen to show all three buffer shapes in section 4 with one run. Upload your own point, line, or polygon file to see the same computation on real data instead.</p>
</section>
<section id="same-spatial-question-two-languages" class="level2">
<h2 class="anchored" data-anchor-id="same-spatial-question-two-languages">3. Same spatial question, two languages</h2>
<div id="lab-map" style="min-height: 440px;">

</div>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="buffer-proximity-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("buffer-proximity-py", {
    language: "python",
    initialCode: "import geopandas as gpd\nimport numpy as np\nfrom shapely.geometry import Point, LineString, Polygon\n\n# Same three-feature layout as the article's original fixed example --\n# used only when nothing was uploaded, chosen specifically to show all\n# three buffer shapes (point/line/polygon) in one run.\nFALLBACK_NAMES = [\"shop\", \"road segment\", \"building\"]\nFALLBACK_GEOMETRIES = [\n    Point(12.4700, 41.9000),\n    LineString([(12.4720, 41.9010), (12.4740, 41.9005)]),\n    Polygon([(12.4678, 41.8988), (12.4682, 41.8988), (12.4682, 41.8992), (12.4678, 41.8992)])\n]\n\ngdf = None\nfor path in [\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\"]:\n    try:\n        gdf = gpd.read_file(path)\n        break\n    except Exception:\n        pass\n\ncrs_warning = None\nif gdf is None:\n    gdf = gpd.GeoDataFrame({\"name\": FALLBACK_NAMES}, geometry=FALLBACK_GEOMETRIES, crs=\"EPSG:4326\")\nelse:\n    if gdf.crs is None:\n        gdf = gdf.set_crs(\"EPSG:4326\")\n        crs_warning = \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\n    elif str(gdf.crs) != \"EPSG:4326\":\n        gdf = gdf.to_crs(\"EPSG:4326\")\n    if \"name\" not in gdf.columns:\n        gdf[\"name\"] = [f\"feature {i + 1}\" for i in range(len(gdf))]\n\n# UTM auto-pick from the bounding-box center -- see section 1 above.\nminx, miny, maxx, maxy = gdf.total_bounds\ncenter_lon = float((minx + maxx) / 2)\ncenter_lat = float((miny + maxy) / 2)\nutm_zone = int((center_lon + 180) // 6) + 1\ncalc_epsg = 32600 + utm_zone if center_lat >= 0 else 32700 + utm_zone\n\ngdf_proj = gdf.to_crs(epsg=calc_epsg)\n\n# A single UTM zone can map far-away features to infinity. Shapely\n# tolerates it without crashing (an inf-coordinate buffer() just comes\n# back empty), but an empty buffer is still a feature that silently\n# produced nothing -- excluded here instead, with a clear count.\nfinite_mask = gdf_proj.geometry.bounds.apply(\n    lambda row: all(np.isfinite(v) for v in row), axis=1\n)\nexcluded_count = int((~finite_mask).sum())\ngdf_proj = gdf_proj[finite_mask]\ngdf_kept = gdf[finite_mask.values]\nif len(gdf_proj) == 0:\n    raise ValueError(\n        f\"None of the {len(gdf)} features could be projected into a single \"\n        f\"UTM zone (EPSG:{calc_epsg}) -- this dataset spans too wide an \"\n        \"area for this article's single-zone approach. Try a smaller, more \"\n        \"local file.\"\n    )\n\nbuffered_proj = gdf_proj.buffer(30)\n\nbuffered_wgs84 = gpd.GeoDataFrame({\"name\": gdf_kept[\"name\"].values}, geometry=buffered_proj, crs=calc_epsg).to_crs(\"EPSG:4326\")\n\n{\n    \"features\": buffered_wgs84.__geo_interface__,\n    \"originalFeatures\": gdf.__geo_interface__,\n    \"calculationCrs\": f\"EPSG:{calc_epsg}\",\n    \"geometryTypes\": list(gdf.geometry.geom_type),\n    \"bufferGeometryTypes\": list(buffered_proj.geom_type),\n    \"bufferAreas\": [round(a, 1) for a in buffered_proj.area],\n    \"excludedCount\": excluded_count,\n    \"crsWarning\": crs_warning\n}",
    preload: { packages: ["geopandas"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, { packages: ["geopandas"] }));
    }
  });
});
</script>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div id="buffer-proximity-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("buffer-proximity-r", {
    language: "r",
    initialCode: "library(sf)\n\nFALLBACK_NAMES <- c(\"shop\", \"road segment\", \"building\")\nfallback_geom <- st_sfc(\n  st_point(c(12.4700, 41.9000)),\n  st_linestring(rbind(c(12.4720, 41.9010), c(12.4740, 41.9005))),\n  st_polygon(list(rbind(\n    c(12.4678, 41.8988), c(12.4682, 41.8988),\n    c(12.4682, 41.8992), c(12.4678, 41.8992), c(12.4678, 41.8988)\n  ))),\n  crs = 4326\n)\n\ndata <- NULL\nfor (path in c(\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\")) {\n  data <- tryCatch(st_read(path, quiet = TRUE), error = function(e) NULL)\n  if (!is.null(data)) break\n}\n\ncrs_warning <- NULL\nif (is.null(data)) {\n  data <- st_sf(name = FALLBACK_NAMES, geometry = fallback_geom)\n} else if (is.na(st_crs(data))) {\n  st_crs(data) <- 4326\n  crs_warning <- \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\n} else if (!isTRUE(st_crs(data) == st_crs(4326))) {\n  data <- st_transform(data, 4326)\n}\nif (!(\"name\" %in% names(data))) {\n  data$name <- paste(\"feature\", seq_len(nrow(data)))\n}\n\n# UTM auto-pick from the bounding-box center -- see section 1 above.\nbnd <- st_bbox(data)\ncenter_lon <- (bnd[[\"xmin\"]] + bnd[[\"xmax\"]]) / 2\ncenter_lat <- (bnd[[\"ymin\"]] + bnd[[\"ymax\"]]) / 2\nutm_zone <- floor((center_lon + 180) / 6) + 1\ncalc_epsg <- if (center_lat >= 0) 32600 + utm_zone else 32700 + utm_zone\n\ndata_proj <- st_transform(data, calc_epsg)\nbuffered <- st_buffer(data_proj, 30)\n\n# A single UTM zone can map far-away features to infinity. sf\n# tolerates it without erroring -- an inf-coordinate st_buffer() just\n# comes back empty -- but an empty buffer is still a feature that\n# silently produced nothing, so it's excluded here instead, with a\n# clear count.\nfinite_mask <- !st_is_empty(buffered)\nexcluded_count <- as.integer(sum(!finite_mask))\nif (!any(finite_mask)) {\n  stop(paste0(\n    \"None of the \", nrow(data), \" features could be projected into a \",\n    \"single UTM zone (EPSG:\", calc_epsg, \") -- this dataset spans too wide \",\n    \"an area for this article's single-zone approach. Try a smaller, more \",\n    \"local file.\"\n  ))\n}\nbuffered <- buffered[finite_mask, ]\n\nresult <- list(\n  calculationCrs = jsonlite::unbox(paste0(\"EPSG:\", calc_epsg)),\n  geometryTypes = as.character(st_geometry_type(data)),\n  bufferGeometryTypes = as.character(st_geometry_type(buffered)),\n  bufferAreas = round(as.numeric(st_area(buffered)), 1),\n  excludedCount = jsonlite::unbox(excluded_count),\n  crsWarning = if (is.null(crs_warning)) NULL else jsonlite::unbox(crs_warning)\n)\njsonlite::fromJSON(jsonlite::toJSON(result, null = \"null\"), simplifyVector = FALSE)",
    preload: { packages: ["sf","jsonlite"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["sf","jsonlite"] }));
    }
  });
});
</script>
</div>
</div>
</div>
<div id="lab-crs">

</div>
<div id="lab-stats">

</div>
<div id="lab-reset">

</div>
</section>
<section id="one-distance-three-different-shapes" class="level2">
<h2 class="anchored" data-anchor-id="one-distance-three-different-shapes">4. One distance, three different shapes</h2>
<p>The numbers below describe the built-in fallback specifically (a point, a line, and a polygon, buffered 30m). If you uploaded your own file instead, the areas will differ, but the same three rules still explain whatever shape came out. Both cards above ran the exact same 30m buffer on the exact same three features. The shapes agree exactly; the areas don’t, by a small, fully explainable margin, not a disagreement about the answer:</p>
<ul>
<li>The <strong>point’s</strong> buffer is a circle: every direction from a single location is equally “within reach.”</li>
<li>The <strong>line’s</strong> buffer is a stadium shape (a rectangle with two rounded ends): every point along its length gets its own circle of reach, and they merge into one continuous zone.</li>
<li>The <strong>polygon’s</strong> buffer is the original shape, expanded outward, with its corners rounded off: everywhere already inside stays inside, plus a 30m halo all the way around the boundary.</li>
</ul>
<div class="callout callout-style-default callout-note callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Note
</div>
</div>
<div class="callout-body-container callout-body">
<p><strong>Why the point buffer’s area isn’t quite identical.</strong> A circle can’t be drawn exactly with straight line segments: every buffer implementation approximates it with a many-sided polygon, and <em>how</em> closely depends on how many segments it uses per quarter-circle. GeoPandas’ <code>.buffer()</code> defaults to <code>resolution=16</code>; <code>sf::st_buffer()</code> defaults to <code>nQuadSegs=30</code>, almost double. A true 30m-radius circle has an area of 2827.4 m² (<code>π × 30²</code>). Python’s 16-segment approximation lands at 2822.9 m²; R’s 30-segment one at 2826.1 m², closer to the true value, because it’s a finer approximation of the <em>same</em> circle, not a different formula. Passing <code>resolution=30</code> in Python reproduces R’s default result almost exactly (2826.1416 m², verified); the defaults just don’t happen to agree out of the box.</p>
</div>
</div>
<p><strong>Every geometry type buffers into a polygon</strong>, regardless of what it started as: there’s no such thing as a “buffered point” that’s still a point. That’s also why dissolving several overlapping buffers into one shape (the standalone tool’s own checkbox) is meaningful: once everything is already a polygon, merging overlapping ones is a single, well-defined operation, the same one used to answer “what’s the total area within reach of <em>any</em> of these features,” not “within reach of each one separately.”</p>
</section>
<section id="where-to-next" class="level2">
<h2 class="anchored" data-anchor-id="where-to-next">5. Where to next</h2>
<ul>
<li><strong>Want adjustable settings, rings, and a download button?</strong> → The <a href="../tools/buffer-proximity.html">standalone Buffer &amp; Proximity tool</a> runs the same computation with a distance you control, optional concentric rings, and an optional dissolve, colored on the map, downloadable.</li>
<li><strong>Wondering why the coordinate system needed picking at all?</strong> → <a href="../posts/crs-mismatch.html">Coordinate Reference Systems</a> covers the geographic/projected distinction this article leaned on in section 1.</li>
</ul>
<div id="lab-root"></div>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("buffer-proximity-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>

 ]]></description>
  <category>tool</category>
  <category>vector</category>
  <category>bilingual</category>
  <guid>https://webgeods.com/posts/buffer-proximity.html</guid>
  <pubDate>Fri, 11 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Predicting Spatial Classes with Random Forest</title>
  <link>https://webgeods.com/posts/spatial-classification.html</link>
  <description><![CDATA[ 




<p>A few labeled points on their own only answer questions about themselves: “what class is point #7?” is a question you can already answer by looking at the file. The useful question is the one you <em>can’t</em> already answer: “what’s the likely class everywhere else, including where I have no data at all?” A Random Forest trained on labeled points, then applied to a regular grid across the whole area, turns a sparse set of samples into a full classification surface, the same shift in usefulness a sparse rain-gauge network gets from interpolating a rainfall map between the gauges.</p>
<p>Whether that surface is worth trusting is a separate question, and one this article measures rather than assumes, two ways: holding out points at random, the textbook default, and holding out whole blocks of the map. On spatial data the two give very different answers, and section 4 shows why the second is the one to believe.</p>
<p>A <code>.geojson</code>, or a shapefile (either as a single <code>.zip</code>, or as <code>.shp</code>/<code>.dbf</code>/<code>.shx</code> selected together): see the upload section below.</p>
<p>Just want to classify a file with adjustable settings, any class column, and a download button, without the rest of the reading? Use the <a href="../tools/spatial-classifier.html">standalone tool</a> — same engine, less prose.</p>
<section id="why-the-coordinate-system-matters-here" class="level2">
<h2 class="anchored" data-anchor-id="why-the-coordinate-system-matters-here">1. Why the coordinate system matters here</h2>
<p>A Random Forest trained directly on longitude/latitude would treat 1 degree of separation as the same “distance” everywhere on Earth, which it isn’t. Both languages below pick a working UTM zone from the data’s own bounding-box center and reproject into it first, same reasoning, same formula, as this site’s <a href="../posts/raster-viewshed.html">Viewshed</a> and <a href="../posts/spatial-clustering.html">Spatial Clustering</a> tools: a reasonable default for one local dataset, not a universally correct choice for points spanning multiple UTM zones or very large areas.</p>
</section>
<section id="upload-your-points" class="level2">
<h2 class="anchored" data-anchor-id="upload-your-points">2. Upload your points</h2>
<div id="lab-upload">

</div>
<p>No file uploaded, or your file has no column literally named <code>class</code>? Both cells below fall back to the same built-in survey: 30 sites, 5 points recorded close together at each, across three zones (<code>south</code>, <code>centre</code>, <code>north</code>) whose boundaries undulate rather than running straight. One site in five sits on a local patch of a different class, the kind of small inclusion a real survey keeps running into: a wet hollow in a dry field, a stand of a different species. Both tabs build it from the same arithmetic rule instead of a list of coordinates, so the points are identical and the two languages are comparable.</p>
<p>Both details are there on purpose. Surveys really are collected in sites like this, a handful of readings wherever someone stopped. And the patches are what keep the scores honest: with tidy zones and nothing unexpected inside them, any classifier would score close to 100%, which teaches nothing except misplaced confidence. Number of trees and grid resolution are fixed here (100 trees, a 10-cell grid) to keep the focus off tuning; the <a href="../tools/spatial-classifier.html">standalone tool</a> exposes both as adjustable controls and lets you pick any column as the class.</p>
</section>
<section id="same-spatial-question-two-languages" class="level2">
<h2 class="anchored" data-anchor-id="same-spatial-question-two-languages">3. Same spatial question, two languages</h2>
<div id="lab-map" style="min-height: 440px;">

</div>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="spatial-classification-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("spatial-classification-py", {
    language: "python",
    initialCode: "import geopandas as gpd\nimport numpy as np\nfrom sklearn.ensemble import RandomForestClassifier\nfrom sklearn.model_selection import StratifiedKFold\nfrom sklearn.neighbors import NearestNeighbors\nfrom shapely.geometry import Polygon\n\n# The example survey, used only when nothing was uploaded (or the\n# upload has no `class` column). Written as a rule rather than a list\n# of coordinates, so the structure is visible instead of buried in 150\n# pairs of numbers: 30 survey sites, 5 points recorded close together\n# at each, three bands whose boundaries undulate across the area, and\n# one site in five sitting on a local patch of a different class.\n# Plain arithmetic only (no random numbers, no trigonometry), so the R\n# tab further down builds exactly the same points.\nFALLBACK_NEXT_CLASS = {\"south\": \"centre\", \"centre\": \"north\", \"north\": \"south\"}\nSITE_OFFSETS = [(0, 0), (1, 0.4), (-0.6, 0.9), (-0.8, -0.7), (0.5, -1)]\n\ndef fallback_points():\n    points = []\n    for s in range(30):\n        # Site centers on a 6 x 5 lattice, nudged off it by a fixed\n        # arithmetic jitter so they don't line up in rows.\n        col, row = s % 6, s // 6\n        cx = (col + 0.5) / 6 + ((s * 37) % 11 - 5) / 110\n        cy = (row + 0.5) / 5 + ((s * 53) % 13 - 6) / 130\n        for dx, dy in SITE_OFFSETS:\n            x, y = cx + dx * 0.02, cy + dy * 0.02\n            # Triangular wave along x: the two band boundaries rise and\n            # fall across the area instead of running straight, so no\n            # single horizontal cut separates the classes.\n            u, v = x * 15, y * 10\n            tri = abs((2 * u) % 8 - 4) - 2\n            cls = \"south\" if v < 3 + tri else (\"centre\" if v < 6 + tri else \"north\")\n            # A local patch: every point at this site belongs to the\n            # next class along. Unlike noise on single points, all five\n            # site-mates share it, which is what section 4 is about.\n            if s % 5 == 2:\n                cls = FALLBACK_NEXT_CLASS[cls]\n            points.append(((12.4450 + x * 0.0150, 41.8850 + y * 0.0120), cls))\n    return points\n\ngdf = None\nfor path in [\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\"]:\n    try:\n        gdf = gpd.read_file(path)\n        break\n    except Exception:\n        pass\n\ncrs_warning = None\nif gdf is not None and gdf.crs is None:\n    gdf = gdf.set_crs(\"EPSG:4326\")\n    crs_warning = \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\nelif gdf is not None and str(gdf.crs) != \"EPSG:4326\":\n    gdf = gdf.to_crs(\"EPSG:4326\")\n\nif gdf is None or \"class\" not in gdf.columns:\n    fallback = fallback_points()\n    gdf = gpd.GeoDataFrame(\n        {\"class\": [cls for _, cls in fallback]},\n        geometry=gpd.points_from_xy(\n            [pt[0] for pt, _ in fallback],\n            [pt[1] for pt, _ in fallback]\n        ),\n        crs=\"EPSG:4326\"\n    )\n    crs_warning = None\n\nlabeled = gdf[gdf[\"class\"].notna()].copy()\nlabeled[\"class\"] = labeled[\"class\"].astype(str)\n\n# UTM auto-pick from the bounding-box center -- see section 1 above.\nminx, miny, maxx, maxy = gdf.total_bounds\ncenter_lon = float((minx + maxx) / 2)\ncenter_lat = float((miny + maxy) / 2)\nutm_zone = int((center_lon + 180) // 6) + 1\ncalc_epsg = 32600 + utm_zone if center_lat >= 0 else 32700 + utm_zone\n\nlabeled_proj = labeled.to_crs(epsg=calc_epsg)\nproj_x = labeled_proj.geometry.centroid.x.to_numpy()\nproj_y = labeled_proj.geometry.centroid.y.to_numpy()\n\n# A single UTM zone distorts badly, to the point of returning inf,\n# for points far enough from it. Dropped here rather than failing:\n# nothing in this article compares points by position across\n# languages, so partial exclusion doesn't desync anything.\nfinite_mask = np.isfinite(proj_x) & np.isfinite(proj_y)\nlabeled_proj = labeled_proj[finite_mask]\nX = np.column_stack([proj_x[finite_mask], proj_y[finite_mask]])\ny = labeled_proj[\"class\"].values\n\nif len(y) == 0:\n    raise ValueError(\n        f\"None of the labeled points could be projected into a single UTM \"\n        f\"zone (EPSG:{calc_epsg}) -- this dataset spans too wide an area \"\n        \"for this article's single-zone approach. Try a smaller, more \"\n        \"local file.\"\n    )\n\nclass_labels = sorted(set(y.tolist()))\n\n# Two ways of splitting the points into 5 folds, for 5-fold\n# cross-validation: each fold is held out in turn, a forest is trained\n# on the other four, and every point ends up predicted exactly once by\n# a model that never saw it.\n#\n# Random folds: points assigned at random, stratify keeping each\n# class's share the same in every fold.\n#\n# Block folds: a 4 x 4 grid over the area, each block assigned to a\n# fold by (bx + 2 * by) % 5, a diagonal pattern that spreads every fold\n# across the whole map. Whole blocks go out together, so a held-out\n# point's close neighbours are held out with it. No random numbers\n# here, so the R tab gets exactly these same folds.\nBLOCKS_PER_SIDE = 4\nbmin = X.min(axis=0)\nblock_size = max(float((X.max(axis=0) - bmin).max()), 1e-6) / BLOCKS_PER_SIDE\nblock_xy = np.minimum(((X - bmin) // block_size).astype(int), BLOCKS_PER_SIDE - 1)\nblock_folds = (block_xy[:, 0] + 2 * block_xy[:, 1]) % 5\n\nclass_counts = {c: int((y == c).sum()) for c in class_labels}\n# Too little to cross-validate (an uploaded file can have a class with\n# one or two members): no accuracy is then reported as exactly that,\n# rather than as a score of 0.\ncan_validate = len(class_labels) >= 2 and min(class_counts.values()) >= 5 and len(y) >= 10\n\ndef cross_validated(folds):\n    predicted = np.empty(len(y), dtype=object)\n    for f in np.unique(folds):\n        train, test = folds != f, folds == f\n        fold_model = RandomForestClassifier(n_estimators=100, random_state=0)\n        fold_model.fit(X[train], y[train])\n        predicted[test] = fold_model.predict(X[test])\n    accuracy = round(float((predicted == y).mean()), 3)\n    # Per class as well: one overall number can hide a model that is\n    # perfect on the largest class and useless on another.\n    by_class = {\n        c: round(float((predicted[y == c] == c).mean()), 3) for c in class_labels\n    }\n    return accuracy, by_class\n\naccuracy_random = accuracy_blocks = None\nclass_accuracy_random = class_accuracy_blocks = {}\nif can_validate:\n    random_folds = np.empty(len(y), dtype=int)\n    splitter = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)\n    for f, (_, test) in enumerate(splitter.split(X, y)):\n        random_folds[test] = f\n    accuracy_random, class_accuracy_random = cross_validated(random_folds)\n    accuracy_blocks, class_accuracy_blocks = cross_validated(block_folds)\n\n# How far each scheme's test points sit from the nearest point their\n# forest trained on (median, in metres). Section 4 compares these with\n# the same distance for the map's own grid cells, computed further down.\ndef nearest_distances(train_pts, query_pts):\n    return NearestNeighbors(n_neighbors=1).fit(train_pts).kneighbors(query_pts)[0][:, 0]\n\ndef median_test_distance(folds):\n    return float(np.median(np.concatenate([\n        nearest_distances(X[folds != f], X[folds == f]) for f in np.unique(folds)\n    ])))\n\ndistance_random = round(median_test_distance(random_folds)) if can_validate else None\ndistance_blocks = round(median_test_distance(block_folds)) if can_validate else None\n\n# X is (x, y) coordinates only: position is the ONLY predictor here.\n# This demonstrates how a Random Forest turns labeled points into a\n# surface, not a general land-cover classifier. Real covariates\n# (spectral bands, elevation, soil type) would change what the model\n# is actually learning. The map itself uses a forest trained on every\n# point: the folds above were only for scoring it.\nmodel = RandomForestClassifier(n_estimators=100, random_state=0)\nmodel.fit(X, y)\n\n# Regular 10x10-ish square-cell grid over the full extent, at a fixed\n# resolution: keeps the focus on how the two languages disagree,\n# rather than on tuning parameters.\ngdf_proj = gdf.to_crs(epsg=calc_epsg)\nfull_x = gdf_proj.geometry.centroid.x.to_numpy()\nfull_y = gdf_proj.geometry.centroid.y.to_numpy()\nfull_finite_mask = np.isfinite(full_x) & np.isfinite(full_y)\ngminx, gmaxx = float(full_x[full_finite_mask].min()), float(full_x[full_finite_mask].max())\ngminy, gmaxy = float(full_y[full_finite_mask].min()), float(full_y[full_finite_mask].max())\nwidth, height = gmaxx - gminx, gmaxy - gminy\ncell_size = max(width, height, 1e-6) / 10\nn_cols = max(1, int(np.ceil(width / cell_size)))\nn_rows = max(1, int(np.ceil(height / cell_size)))\n\ncenters_x, centers_y = [], []\nfor row in range(n_rows):\n    for col in range(n_cols):\n        centers_x.append(gminx + (col + 0.5) * cell_size)\n        centers_y.append(gminy + (row + 0.5) * cell_size)\ncenters = np.column_stack([centers_x, centers_y])\npredicted = model.predict(centers)\ndistance_map = round(float(np.median(nearest_distances(X, centers))))\n\nhalf = cell_size / 2\ncells = [\n    Polygon([\n        (cx - half, cy - half), (cx + half, cy - half),\n        (cx + half, cy + half), (cx - half, cy + half),\n        (cx - half, cy - half)\n    ])\n    for cx, cy in centers\n]\ngrid_wgs84 = gpd.GeoDataFrame(\n    {\"predictedClass\": predicted}, geometry=cells, crs=calc_epsg\n).to_crs(\"EPSG:4326\")\ntraining_wgs84 = labeled[[\"class\", \"geometry\"]]\n\n{\n    \"trainingFeatures\": training_wgs84.__geo_interface__,\n    \"gridFeatures\": grid_wgs84.__geo_interface__,\n    \"calculationCrs\": f\"EPSG:{calc_epsg}\",\n    \"classLabels\": class_labels,\n    \"labeledCount\": int(len(labeled)),\n    \"accuracyRandom\": accuracy_random,\n    \"accuracyBlocks\": accuracy_blocks,\n    \"classAccuracyRandom\": class_accuracy_random,\n    \"classAccuracyBlocks\": class_accuracy_blocks,\n    \"distanceRandom\": distance_random,\n    \"distanceBlocks\": distance_blocks,\n    \"distanceMap\": distance_map,\n    \"gridCellCount\": int(len(cells)),\n    \"crsWarning\": crs_warning\n}",
    preload: { packages: ["geopandas","scikit-learn"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, { packages: ["geopandas","scikit-learn"] }));
    }
  });
});
</script>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div id="spatial-classification-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("spatial-classification-r", {
    language: "r",
    initialCode: "library(sf)\nlibrary(geojsonsf)\nlibrary(randomForest)\n\n# The same example survey the Python tab builds, from the same rule:\n# 30 sites of 5 close-together points, three bands with undulating\n# boundaries, one site in five on a local patch of the next class.\n# Same arithmetic in the same order, so both languages produce\n# identical points -- which is what makes the two tabs comparable at\n# all.\nfallback_points <- function() {\n  site <- rep(0:29, each = 5)\n  k <- rep(1:5, times = 30)\n  dx <- c(0, 1, -0.6, -0.8, 0.5)[k]\n  dy <- c(0, 0.4, 0.9, -0.7, -1)[k]\n  cx <- (site %% 6 + 0.5) / 6 + ((site * 37) %% 11 - 5) / 110\n  cy <- (site %/% 6 + 0.5) / 5 + ((site * 53) %% 13 - 6) / 130\n  x <- cx + dx * 0.02\n  y <- cy + dy * 0.02\n  u <- x * 15\n  v <- y * 10\n  tri <- abs((2 * u) %% 8 - 4) - 2\n  cls <- ifelse(v < 3 + tri, \"south\", ifelse(v < 6 + tri, \"centre\", \"north\"))\n  next_class <- c(south = \"centre\", centre = \"north\", north = \"south\")\n  patch <- site %% 5 == 2\n  cls[patch] <- unname(next_class[cls[patch]])\n  data.frame(lon = 12.4450 + x * 0.0150, lat = 41.8850 + y * 0.0120,\n             class = cls, stringsAsFactors = FALSE)\n}\n\ndata <- NULL\nfor (path in c(\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\")) {\n  data <- tryCatch(st_read(path, quiet = TRUE), error = function(e) NULL)\n  if (!is.null(data)) break\n}\n\ncrs_warning <- NULL\nif (!is.null(data) && is.na(st_crs(data))) {\n  st_crs(data) <- 4326\n  crs_warning <- \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\n} else if (!is.null(data) && !isTRUE(st_crs(data) == st_crs(4326))) {\n  data <- st_transform(data, 4326)\n}\n\nif (is.null(data) || !(\"class\" %in% names(data))) {\n  data <- st_as_sf(fallback_points(), coords = c(\"lon\", \"lat\"), crs = 4326)\n  crs_warning <- NULL\n}\n\nlabeled <- data[!is.na(data$class), ]\n# The one step that decides classification vs. regression -- see\n# section 5 below. Without this, randomForest() below would silently\n# fit a REGRESSION on `class` instead.\nlabeled$class <- factor(as.character(labeled$class))\n\n# UTM auto-pick from the bounding-box center -- see section 1 above.\nbnd <- st_bbox(data)\ncenter_lon <- (bnd[[\"xmin\"]] + bnd[[\"xmax\"]]) / 2\ncenter_lat <- (bnd[[\"ymin\"]] + bnd[[\"ymax\"]]) / 2\nutm_zone <- floor((center_lon + 180) / 6) + 1\ncalc_epsg <- if (center_lat >= 0) 32600 + utm_zone else 32700 + utm_zone\n\nlabeled_proj <- st_transform(labeled, calc_epsg)\ncoords_train <- st_coordinates(labeled_proj)\n\n# A single UTM zone can map far-away points to infinity. Dropped\n# here rather than failing outright: this article never compares\n# points by position across languages, so partial exclusion doesn't\n# desync anything.\nfinite_rows <- is.finite(coords_train[, 1]) & is.finite(coords_train[, 2])\nif (!any(finite_rows)) {\n  stop(paste0(\n    \"None of the labeled points could be projected into a single UTM zone \",\n    \"(EPSG:\", calc_epsg, \") -- this dataset spans too wide an area for \",\n    \"this article's single-zone approach. Try a smaller, more local file.\"\n  ))\n}\ncoords_train <- coords_train[finite_rows, , drop = FALSE]\nlabeled_proj <- labeled_proj[finite_rows, ]\n\nall_df <- data.frame(x = coords_train[, 1], y = coords_train[, 2], class = labeled_proj$class)\n\n# Both the random folds below and the forest itself draw random\n# numbers, so without a seed two runs of this cell give different\n# answers. The Python tab passes random_state=0 for the same reason.\nset.seed(0)\n\n# 5-fold cross-validation two ways, as in the Python tab. Random folds:\n# assigned within each class so every class keeps its share in every\n# fold (base R has no stratified-fold function, but split() + sample()\n# is the whole idea). Block folds: the same 4 x 4 grid and the same\n# (bx + 2 * by) %% 5 rule as Python, no random numbers involved, so\n# these folds are identical in both tabs.\nn <- nrow(all_df)\nrandom_folds <- integer(n)\nfor (idx in split(seq_len(n), all_df$class)) {\n  random_folds[idx] <- sample(rep(0:4, length.out = length(idx)))\n}\n\nblocks_per_side <- 4\nbmin_x <- min(all_df$x)\nbmin_y <- min(all_df$y)\nblock_size <- max(max(all_df$x) - bmin_x, max(all_df$y) - bmin_y, 1e-6) / blocks_per_side\nblock_x <- pmin((all_df$x - bmin_x) %/% block_size, blocks_per_side - 1)\nblock_y <- pmin((all_df$y - bmin_y) %/% block_size, blocks_per_side - 1)\nblock_folds <- (block_x + 2 * block_y) %% 5\n\nclass_counts <- table(all_df$class)\ncan_validate <- length(class_counts) >= 2 && min(class_counts) >= 5 && n >= 10\n\ncross_validated <- function(folds) {\n  predicted <- character(n)\n  for (f in unique(folds)) {\n    test <- folds == f\n    # droplevels(): randomForest() refuses a factor level with no rows,\n    # which a block fold can leave behind by holding out every member\n    # of a class that lives in one corner of the map.\n    train_df <- droplevels(all_df[!test, ])\n    fold_model <- randomForest(class ~ x + y, data = train_df, ntree = 100)\n    predicted[test] <- as.character(predict(fold_model, newdata = all_df[test, ]))\n  }\n  truth <- as.character(all_df$class)\n  # Overall and per class: one overall number can hide a model that is\n  # perfect on the largest class and useless on another.\n  by_class <- lapply(levels(all_df$class), function(cl) {\n    jsonlite::unbox(round(mean(predicted[truth == cl] == cl), 3))\n  })\n  names(by_class) <- levels(all_df$class)\n  list(accuracy = round(mean(predicted == truth), 3), by_class = by_class)\n}\n\ncv_random <- if (can_validate) cross_validated(random_folds) else NULL\ncv_blocks <- if (can_validate) cross_validated(block_folds) else NULL\n\n# How far each scheme's test points sit from the nearest point their\n# forest trained on (median, in metres), as in the Python tab. A plain\n# loop over every pair is fine at this size; a spatial index only pays\n# off on far larger files.\nnearest_distances <- function(train_xy, query_xy) {\n  apply(query_xy, 1, function(p) sqrt(min((train_xy[, 1] - p[1])^2 + (train_xy[, 2] - p[2])^2)))\n}\nmedian_test_distance <- function(folds) {\n  xy <- as.matrix(all_df[, c(\"x\", \"y\")])\n  median(unlist(lapply(unique(folds), function(f) {\n    nearest_distances(xy[folds != f, , drop = FALSE], xy[folds == f, , drop = FALSE])\n  })))\n}\ndistance_random <- if (can_validate) round(median_test_distance(random_folds)) else NULL\ndistance_blocks <- if (can_validate) round(median_test_distance(block_folds)) else NULL\n\n# Position is the ONLY predictor here (class ~ x + y): this\n# demonstrates how a Random Forest turns labeled points into a\n# surface, not a general land-cover classifier. Real covariates\n# (spectral bands, elevation, soil type) would change what the model\n# is actually learning. The map uses a forest trained on every point:\n# the folds above were only for scoring it.\nmodel <- randomForest(class ~ x + y, data = all_df, ntree = 100)\n\n# The prediction surface: a regular grid covering the full extent, at\n# the same fixed resolution the Python tab uses, so the two results\n# are directly comparable. st_make_grid() builds it from an origin, a\n# cell size and a cell count in one call.\ncoords_all <- st_coordinates(st_centroid(st_geometry(st_transform(data, calc_epsg))))\nfinite_all <- is.finite(coords_all[, 1]) & is.finite(coords_all[, 2])\ngminx <- min(coords_all[finite_all, 1])\ngmaxx <- max(coords_all[finite_all, 1])\ngminy <- min(coords_all[finite_all, 2])\ngmaxy <- max(coords_all[finite_all, 2])\ncell_size <- max(gmaxx - gminx, gmaxy - gminy, 1e-6) / 10\nn_cols <- max(1, ceiling((gmaxx - gminx) / cell_size))\nn_rows <- max(1, ceiling((gmaxy - gminy) / cell_size))\n\ngrid <- st_make_grid(\n  cellsize = cell_size,\n  offset = c(gminx, gminy),\n  n = c(n_cols, n_rows),\n  crs = st_crs(calc_epsg)\n)\n\n# Each cell is classified by the point at its center: the model was\n# trained on point coordinates, so that is the only thing it can be\n# asked about.\ngrid_centers <- st_coordinates(st_centroid(grid))\npredicted <- predict(model, newdata = data.frame(x = grid_centers[, 1], y = grid_centers[, 2]))\ndistance_map <- round(median(nearest_distances(as.matrix(all_df[, c(\"x\", \"y\")]), grid_centers[, 1:2])))\n\ngrid_wgs84 <- st_transform(\n  st_sf(predictedClass = as.character(predicted), geometry = grid),\n  4326\n)\ntraining_wgs84 <- st_sf(\n  class = as.character(labeled$class),\n  geometry = st_geometry(labeled)\n)\n\nresult <- list(\n  calculationCrs = jsonlite::unbox(paste0(\"EPSG:\", calc_epsg)),\n  classLabels = sort(levels(labeled_proj$class)),\n  labeledCount = jsonlite::unbox(as.integer(nrow(labeled))),\n  accuracyRandom = if (is.null(cv_random)) NULL else jsonlite::unbox(cv_random$accuracy),\n  accuracyBlocks = if (is.null(cv_blocks)) NULL else jsonlite::unbox(cv_blocks$accuracy),\n  classAccuracyRandom = if (is.null(cv_random)) NULL else cv_random$by_class,\n  classAccuracyBlocks = if (is.null(cv_blocks)) NULL else cv_blocks$by_class,\n  distanceRandom = if (is.null(distance_random)) NULL else jsonlite::unbox(distance_random),\n  distanceBlocks = if (is.null(distance_blocks)) NULL else jsonlite::unbox(distance_blocks),\n  distanceMap = jsonlite::unbox(distance_map),\n  gridCellCount = jsonlite::unbox(as.integer(length(grid))),\n  modelType = jsonlite::unbox(model$type),\n  crsWarning = if (is.null(crs_warning)) NULL else jsonlite::unbox(crs_warning)\n)\nout <- jsonlite::fromJSON(jsonlite::toJSON(result, null = \"null\"), simplifyVector = FALSE)\n\n# The two GeoJSON payloads are attached AFTER that round-trip, not\n# inside it: sf_geojson() already returns finished JSON, and passing\n# it through toJSON() a second time would turn every scalar inside it\n# (\"type\": \"Feature\") back into a one-element array.\nout$gridFeatures <- jsonlite::fromJSON(sf_geojson(grid_wgs84), simplifyVector = FALSE)\nout$trainingFeatures <- jsonlite::fromJSON(sf_geojson(training_wgs84), simplifyVector = FALSE)\nout",
    preload: { packages: ["sf","randomForest","geojsonsf","jsonlite"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["sf","randomForest","geojsonsf","jsonlite"] }));
    }
  });
});
</script>
</div>
</div>
</div>
<div id="lab-crs">

</div>
<div id="lab-stats">

</div>
<div id="lab-reset">

</div>
<p>The filled grid is the predicted surface; the dots on top are the labeled training points, colored the same way. Each language draws onto its own map sources, so running either one on its own is enough to see a result.</p>
<p>Run both and the two surfaces won’t match everywhere. They cover exactly the same ground and start from identical points (the two cells build the survey from the same arithmetic rule), but they don’t label every cell the same way. It’s tempting to read that as Python disagreeing with R. It isn’t: both cells fix a seed so their output is reproducible, and changing that number moves the surface just as much as switching language does. A forest retrained disagrees with itself about as much as the two languages disagree with each other.</p>
</section>
<section id="how-good-is-that-surface" class="level2">
<h2 class="anchored" data-anchor-id="how-good-is-that-surface">4. How good is that surface?</h2>
<p>Both cells score the model with 5-fold cross-validation: the points are split into five groups, each group is held out in turn while a forest trains on the other four, and in the end every point has been predicted once by a model that never saw it. The only difference between the two rows of each stat card is <em>how</em> the points are split into groups.</p>
<p><strong>Random folds</strong> scatter the held-out points across the whole map. On the built-in survey that scores about <strong>0.76–0.81</strong>, in either language, against 0.33 for guessing among three classes. It looks like a usable map.</p>
<p><strong>Spatial blocks</strong> cut the area into a 4 × 4 grid and hold out whole blocks at a time. Same points, same forest, same number of trees: about <strong>0.54–0.59</strong>. Twenty points lower, in every one of the five seeds tried per language.</p>
<p>Nothing about the model changed between the two rows, only the question being asked. A random fold almost never holds out a whole site: four of a test point’s five site-mates, a few metres away, are still in training, and the forest can simply copy them. Even a point on one of the patches comes out right, because its site-mates on the same patch were seen. Hold out a whole block and the patch goes out with it: now the model has to predict it from the sites around it, which say something else.</p>
<p>So which number describes the map? Neither exactly, and the stat cards’ last row says why. A cross-validated score only holds for predictions made about as far from the data as its test points were. Random test points sit a median <strong>27 m</strong> from the nearest training point: a site-mate. Block test points sit about <strong>193 m</strong> away. The map’s own grid cells, the places it is actually asked about, sit about <strong>73 m</strong> from the nearest sample, in between. The random score answers an easier question than the map faces, the blocks score a harder one.</p>
<p>In a separate run (in Python, on the same points, not on this page), the block grids whose test distances came closest to the map’s, 10 × 10 and 12 × 12 (blocks about 100–120 m across instead of 300), scored <strong>0.61–0.65</strong>. That’s roughly the honest figure for this map: the random score overstates it by 10 to 20 points, the 4 × 4 blocks understate it by up to about 10. Other grid sizes in the same run landed anywhere from 0.52 to 0.72, so a fixed grid is a crude way to match distances; dedicated methods for spatial validation (nearest-neighbour distance matching, NNDM and its variants) do it with more care. The 4 × 4 blocks are the safer number to quote, not the exact one.</p>
<p>The per-class numbers show where the gap comes from. Under random folds <code>centre</code>, the narrow middle band squeezed between the other two, looks nearly as well learned as the rest, around 0.7. Under blocks it drops to about <strong>0.35</strong>, barely above chance: with its own neighbourhood removed, the forest can’t tell the thin middle band from the wide ones on either side. One overall number, from either scheme, would have hidden that the model has essentially learned two of the three classes.</p>
<p>Both numbers move from run to run, too. The block folds are fixed (the same arithmetic rule in both tabs, so they’re identical), but the forest itself draws random numbers, and the random folds are drawn by each language’s own generator, so Python and R hold out <em>different</em> points there. The ranges above overlap between languages, and five seeds each are nowhere near enough to rank scikit-learn against randomForest. What doesn’t move is the gap: random folds beat blocks by a wide margin every time, in both.</p>
<p>One more thing the folds don’t do: change the map. Every forest trained inside the cross-validation is thrown away once it has been scored. The map comes from a separate forest trained on all 150 points, the usual practice, since cross-validation scores the <em>way</em> the model is fitted rather than one particular fitted forest. The choice of scheme would start changing the map only if it were used to choose something, a smoothing setting say, and random folds would then reward the forest that copies its neighbours most faithfully.</p>
</section>
<section id="a-silent-switch-classification-vs.-regression" class="level2">
<h2 class="anchored" data-anchor-id="a-silent-switch-classification-vs.-regression">5. A silent switch: classification vs.&nbsp;regression</h2>
<p>The disagreements above are a model being unsure. This one is different: a model quietly solving a different problem.</p>
<p>scikit-learn’s <code>RandomForestClassifier</code> and R’s general-purpose <code>randomForest()</code> decide what to do very differently. scikit-learn is unambiguous: you asked for a <em>Classifier</em>, so it classifies, whatever the labels look like. R’s <code>randomForest()</code> instead looks at the <em>type</em> of the target column, and the two ways of getting this wrong fail very differently:</p>
<ul>
<li>A plain <strong>character</strong> column (<code>"north"</code>/<code>"south"</code>, never wrapped in <code>factor()</code>) fails loudly. R can’t compute a regression mean on text, so the fit errors out immediately with something like <code>non-numeric argument to binary operator</code>. Annoying, but safe: you find out right away.</li>
<li>A column of <strong>numbers standing in for classes</strong> (<code>0</code>/<code>1</code>, or a status code read straight out of a file) is the dangerous one: it fits without complaint and silently runs a <strong>regression</strong> instead of a classification. No error, no warning, just a different kind of model whose predictions no longer look like class labels at all.</li>
</ul>
<p>The R cell above already does this correctly (<code>factor(as.character(...))</code> before the fit): <code>modelType</code> in its stat card reads <code>classification</code>. Here’s the realistic failure case: the exact same points, with <code>class</code> read in as <code>0</code>/<code>1</code>/<code>2</code> instead of <code>"south"</code>/<code>"centre"</code>/<code>"north"</code> (exactly what happens if a class column arrives as a numeric status code), and that one <code>factor()</code> step skipped, run live:</p>
<div id="spatial-classification-r-nofactor"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("spatial-classification-r-nofactor", {
    language: "r",
    initialCode: "library(randomForest)\n\n# The same survey as the cell above, built from the same rule.\nsite <- rep(0:29, each = 5)\nk <- rep(1:5, times = 30)\nx <- (site %% 6 + 0.5) / 6 + ((site * 37) %% 11 - 5) / 110 + c(0, 1, -0.6, -0.8, 0.5)[k] * 0.02\ny <- (site %/% 6 + 0.5) / 5 + ((site * 53) %% 13 - 6) / 130 + c(0, 0.4, 0.9, -0.7, -1)[k] * 0.02\ntri <- abs((2 * (x * 15)) %% 8 - 4) - 2\ncls <- ifelse(y * 10 < 3 + tri, \"south\", ifelse(y * 10 < 6 + tri, \"centre\", \"north\"))\nnext_class <- c(south = \"centre\", centre = \"north\", north = \"south\")\npatch <- site %% 5 == 2\ncls[patch] <- unname(next_class[cls[patch]])\nlon <- 12.4450 + x * 0.0150\nlat <- 41.8850 + y * 0.0120\n\n# The one difference: `class` arrives as a NUMERIC status code\n# (0 = south, 1 = centre, 2 = north) and is NOT wrapped in factor(),\n# unlike the cell above. A character column here would error\n# immediately (see the prose above); a numeric one fits silently.\nclass_code <- c(south = 0, centre = 1, north = 2)[cls]\n\ntrain_df <- data.frame(x = lon, y = lat, class = unname(class_code))\nmodel <- randomForest(class ~ x + y, data = train_df, ntree = 100)\n\nresult <- list(\n  modelType = jsonlite::unbox(model$type),\n  sampleTrueClass = jsonlite::unbox(train_df$class[1]),\n  samplePrediction = jsonlite::unbox(as.numeric(predict(model, train_df[1, ])))\n)\njsonlite::fromJSON(jsonlite::toJSON(result, null = \"null\"), simplifyVector = FALSE)",
    preload: { packages: ["sf","randomForest","jsonlite"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["sf","randomForest","jsonlite"] }));
    }
  });
});
</script>
<div id="lab-nofactor">

</div>
<p><code>model$type</code> reads <code>regression</code>, and <code>predict()</code> on a point whose true class was <code>0</code> (<code>south</code>) returns something like <code>0.044</code>, not the class <code>0</code> itself: a <em>regression estimate</em>, an average of the training codes, that happens to land near it. On a point near a boundary it would land somewhere in between, a <code>0.7</code> that is no class at all. Nothing in the output says a switch happened; the only way to notice is checking <code>model$type</code> yourself, which is exactly what production code should do.</p>
<div class="callout callout-style-default callout-note callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Note
</div>
</div>
<div class="callout-body-container callout-body">
<p>This is real, verified behavior of <code>randomForest()</code>, run live on this page, not an edge case invented for the article: the R cell in section 3 above does the correct <code>factor()</code> coercion, one line different from the cell above. scikit-learn’s separate <code>Classifier</code>/ <code>Regressor</code> classes make the same mistake structurally impossible: the class you instantiate <em>is</em> the decision, made once, up front.</p>
</div>
</div>
</section>
<section id="where-to-next" class="level2">
<h2 class="anchored" data-anchor-id="where-to-next">6. Where to next</h2>
<ul>
<li><strong>Just needed to classify a file, with adjustable settings and a download button?</strong> → The <a href="../tools/spatial-classifier.html">standalone Spatial Classifier</a> runs the same computation on an uploaded point file (or its own example), with any column as the class, adjustable tree count and grid resolution, the same random-vs-blocks comparison with the held-out blocks drawn on the map, and a downloadable classification surface.</li>
<li><strong>Wondering why the coordinate system needed picking at all?</strong> → <a href="../posts/crs-mismatch.html">Coordinate Reference Systems</a> covers the geographic/projected distinction this article leaned on in section 1.</li>
<li><strong>Curious about the other side of GeoML: finding groups instead of predicting labels?</strong> → <a href="../posts/spatial-clustering.html">Finding Spatial Clusters with DBSCAN</a> is this family’s entry point, unsupervised rather than supervised.</li>
</ul>
<div id="lab-root"></div>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("spatial-classification-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>

 ]]></description>
  <category>tool</category>
  <category>geoml</category>
  <category>bilingual</category>
  <guid>https://webgeods.com/posts/spatial-classification.html</guid>
  <pubDate>Fri, 11 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Finding Spatial Clusters with DBSCAN</title>
  <link>https://webgeods.com/posts/spatial-clustering.html</link>
  <description><![CDATA[ 




<p>Some questions aren’t “is this one point inside that polygon”: they’re “which of these points belong together, and which don’t belong anywhere.” DBSCAN (Density-Based Spatial Clustering of Applications with Noise) answers that by density: a point belongs to a cluster if enough other points sit close enough to it, with no need to decide the number of clusters in advance, and no need to force every point into a group. A point too far from everything else stays <em>noise</em>, not a cluster of one.</p>
<p>A <code>.geojson</code>, or a shapefile (either as a single <code>.zip</code>, or as <code>.shp</code>/<code>.dbf</code>/<code>.shx</code> selected together): see the upload section below.</p>
<p>Just want to cluster a file with adjustable settings and a download button, without the rest of the reading? Use the <a href="../tools/spatial-clustering-explorer.html">standalone tool</a> — same engine, less prose.</p>
<section id="why-the-coordinate-system-matters-here" class="level2">
<h2 class="anchored" data-anchor-id="why-the-coordinate-system-matters-here">1. Why the coordinate system matters here</h2>
<p>DBSCAN takes one distance, ε (<code>eps</code>): how close two points need to be to count as neighbors. For that distance to mean anything, it has to be in real units (meters), not degrees of longitude/latitude, where the same number of degrees covers very different real distances depending on where on Earth you are. Both languages below pick a working UTM zone from the data’s own bounding-box center and reproject into it first, same reasoning, same formula, as this site’s <a href="../posts/raster-viewshed.html">Viewshed</a> tool: a reasonable default for one local dataset, not a universally correct choice for points spanning multiple UTM zones or very large areas.</p>
</section>
<section id="upload-your-points" class="level2">
<h2 class="anchored" data-anchor-id="upload-your-points">2. Upload your points</h2>
<div id="lab-upload">

</div>
<p>No file uploaded? Both cells below fall back to the same small built-in example: two tight groups of six points, and two points far from everything, including each other, chosen to keep the point-by-point label comparison in section 4 readable. ε and the minimum group size are fixed at 100m/4 points here to keep the focus on <em>how the two languages report a result</em>, not on tuning. The <a href="../tools/spatial-clustering-explorer.html">standalone tool</a> exposes both as adjustable controls.</p>
</section>
<section id="same-spatial-question-two-languages" class="level2">
<h2 class="anchored" data-anchor-id="same-spatial-question-two-languages">3. Same spatial question, two languages</h2>
<div id="lab-map" style="min-height: 440px;">

</div>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="spatial-clustering-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("spatial-clustering-py", {
    language: "python",
    initialCode: "import geopandas as gpd\nimport numpy as np\nfrom sklearn.cluster import DBSCAN\n\n# Same 14-point layout as the article's original fixed dataset -- used\n# only when nothing was uploaded, so the point-by-point table in\n# section 4 stays readable by default.\nFALLBACK_LONS = [12.4500, 12.4503, 12.4498, 12.4501, 12.4499, 12.4502,\n                  12.4700, 12.4703, 12.4698, 12.4701, 12.4699, 12.4702,\n                  12.4300, 12.5000]\nFALLBACK_LATS = [41.9000, 41.9002, 41.8998, 41.9001, 41.8999, 41.9003,\n                  41.9050, 41.9052, 41.9048, 41.9051, 41.9049, 41.9053,\n                  41.8800, 41.9300]\n\ngdf = None\nfor path in [\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\"]:\n    try:\n        gdf = gpd.read_file(path)\n        break\n    except Exception:\n        pass\n\ncrs_warning = None\nif gdf is None:\n    gdf = gpd.GeoDataFrame(geometry=gpd.points_from_xy(FALLBACK_LONS, FALLBACK_LATS), crs=\"EPSG:4326\")\nelse:\n    if gdf.crs is None:\n        gdf = gdf.set_crs(\"EPSG:4326\")\n        crs_warning = \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\n    elif str(gdf.crs) != \"EPSG:4326\":\n        gdf = gdf.to_crs(\"EPSG:4326\")\n\n# UTM auto-pick from the bounding-box center -- see section 1 above.\nminx, miny, maxx, maxy = gdf.total_bounds\ncenter_lon = float((minx + maxx) / 2)\ncenter_lat = float((miny + maxy) / 2)\nutm_zone = int((center_lon + 180) // 6) + 1\ncalc_epsg = 32600 + utm_zone if center_lat >= 0 else 32700 + utm_zone\n\n# Centroid computed AFTER reprojecting into a projected CRS on\n# purpose: the same call on the geographic gdf above would trigger a\n# real GeoPandas warning (\"Results from 'centroid' are likely\n# incorrect\").\ngdf_proj = gdf.to_crs(epsg=calc_epsg)\ncoords = np.column_stack([gdf_proj.geometry.centroid.x, gdf_proj.geometry.centroid.y])\n\n# A single UTM zone distorts badly, to the point of returning inf,\n# for points far enough from it. This article fails loudly instead of\n# dropping those points, on purpose: section 4 below compares Python's\n# and R's labels BY POSITION, point for point, so silently dropping a\n# different subset in each language would desync that comparison\n# rather than just shrink it.\nif not np.isfinite(coords).all():\n    raise ValueError(\n        f\"This file spans too wide an area to project into a single UTM \"\n        f\"zone ({calc_epsg}) -- some points map to infinity. Try a smaller, \"\n        \"more local file, or use the standalone tool, which handles this by \"\n        \"excluding the unprojectable points instead of failing.\"\n    )\n\n# scikit-learn's own convention: noise is -1, real clusters start at 0.\nlabels = DBSCAN(eps=100, min_samples=4).fit(coords).labels_\ngdf[\"cluster\"] = [int(l) for l in labels]\n\n{\n    \"features\": gdf.__geo_interface__,\n    \"calculationCrs\": f\"EPSG:{calc_epsg}\",\n    \"labels\": [int(l) for l in labels],\n    \"uniqueLabels\": sorted(int(l) for l in set(labels)),\n    \"noiseCount\": int((labels == -1).sum()),\n    \"crsWarning\": crs_warning\n}",
    preload: { packages: ["geopandas","scikit-learn"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, { packages: ["geopandas","scikit-learn"] }));
    }
  });
});
</script>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div id="spatial-clustering-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("spatial-clustering-r", {
    language: "r",
    initialCode: "library(dbscan)\nlibrary(sf)\n\nFALLBACK_LONS <- c(12.4500, 12.4503, 12.4498, 12.4501, 12.4499, 12.4502,\n                    12.4700, 12.4703, 12.4698, 12.4701, 12.4699, 12.4702,\n                    12.4300, 12.5000)\nFALLBACK_LATS <- c(41.9000, 41.9002, 41.8998, 41.9001, 41.8999, 41.9003,\n                    41.9050, 41.9052, 41.9048, 41.9051, 41.9049, 41.9053,\n                    41.8800, 41.9300)\n\ndata <- NULL\nfor (path in c(\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\")) {\n  data <- tryCatch(st_read(path, quiet = TRUE), error = function(e) NULL)\n  if (!is.null(data)) break\n}\n\ncrs_warning <- NULL\nif (is.null(data)) {\n  data <- st_as_sf(data.frame(lon = FALLBACK_LONS, lat = FALLBACK_LATS), coords = c(\"lon\", \"lat\"), crs = 4326)\n} else if (is.na(st_crs(data))) {\n  st_crs(data) <- 4326\n  crs_warning <- \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\n} else if (!isTRUE(st_crs(data) == st_crs(4326))) {\n  data <- st_transform(data, 4326)\n}\n\n# UTM auto-pick from the bounding-box center -- see section 1 above.\nbnd <- st_bbox(data)\ncenter_lon <- (bnd[[\"xmin\"]] + bnd[[\"xmax\"]]) / 2\ncenter_lat <- (bnd[[\"ymin\"]] + bnd[[\"ymax\"]]) / 2\nutm_zone <- floor((center_lon + 180) / 6) + 1\ncalc_epsg <- if (center_lat >= 0) 32600 + utm_zone else 32700 + utm_zone\n\ndata_proj <- st_transform(data, calc_epsg)\ncoords_calc <- st_coordinates(st_centroid(st_geometry(data_proj)))\n\n# A single UTM zone can map far-away points to infinity. This article\n# fails loudly rather than dropping points, to keep the by-position\n# label comparison in section 4 meaningful.\nif (!all(is.finite(coords_calc))) {\n  stop(paste0(\n    \"This file spans too wide an area to project into a single UTM zone (\",\n    calc_epsg, \") -- some points map to infinity. Try a smaller, more \",\n    \"local file, or use the standalone tool, which handles this by \",\n    \"excluding the unprojectable points instead of failing.\"\n  ))\n}\n\n# dbscan's own convention: noise is 0, real clusters start at 1 -- a\n# real point of divergence from scikit-learn, not a formatting detail.\n# See section 4 below.\nres <- dbscan(coords_calc, eps = 100, minPts = 4)\n\nresult <- list(\n  calculationCrs = jsonlite::unbox(paste0(\"EPSG:\", calc_epsg)),\n  labels = as.integer(res$cluster),\n  uniqueLabels = sort(unique(as.integer(res$cluster))),\n  noiseCount = jsonlite::unbox(as.integer(sum(res$cluster == 0))),\n  crsWarning = if (is.null(crs_warning)) NULL else jsonlite::unbox(crs_warning)\n)\njsonlite::fromJSON(jsonlite::toJSON(result, null = \"null\"), simplifyVector = FALSE)",
    preload: { packages: ["dbscan","sf","jsonlite"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["dbscan","sf","jsonlite"] }));
    }
  });
});
</script>
</div>
</div>
</div>
<div id="lab-crs">

</div>
<div id="lab-stats">

</div>
<div id="lab-reset">

</div>
</section>
<section id="a-label-is-not-a-name" class="level2">
<h2 class="anchored" data-anchor-id="a-label-is-not-a-name">4. A label is not a name</h2>
<p>Both cards above found the same thing: two groups of six, and two noise points (with the built-in example; an upload of your own may find something different). But the <em>numbers</em> don’t match, and that’s not a bug in either language.</p>
<p>Two separate reasons, easy to conflate:</p>
<ul>
<li><strong>DBSCAN’s own cluster numbering is arbitrary.</strong> Nothing about “cluster 0” or “cluster 1” is meaningful on its own: it’s just the order the algorithm happened to visit points in. Run the same algorithm again with the points in a different order (or on a different implementation entirely) and the <em>groupings</em> can come out identical while the <em>numbers</em> attached to them differ.</li>
<li><strong>scikit-learn and <code>dbscan</code> also disagree on noise, specifically.</strong> This one isn’t arbitrary: it’s a real, documented difference between the two libraries: scikit-learn labels noise <code>-1</code> and starts real clusters at <code>0</code>; R’s <code>dbscan</code> package labels noise <code>0</code> and starts real clusters at <code>1</code>. Every non-noise label is shifted by exactly one.</li>
</ul>
<div id="lab-labels">

</div>
<p>Read down the table: every point Python calls <code>0</code>, R calls <code>1</code>; every point Python calls <code>1</code>, R calls <code>2</code>; every point Python calls <code>-1</code> (noise), R calls <code>0</code>. The <em>partition</em> (which points end up together) is identical. The <em>labels</em> attached to it aren’t, and never had to be.</p>
<div class="callout callout-style-default callout-note callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Note
</div>
</div>
<div class="callout-body-container callout-body">
<p>This is why comparing two clustering results, across languages, or across two runs of the same algorithm, should compare <strong>who’s grouped with whom</strong>, never raw label equality. In practice: pick any two points, and ask “does language A say they’re in the same group, and does language B agree?” Repeat for every pair, and you’re comparing partitions, not numbers that were never meant to match.</p>
</div>
</div>
</section>
<section id="where-to-next" class="level2">
<h2 class="anchored" data-anchor-id="where-to-next">5. Where to next</h2>
<ul>
<li><strong>Just needed to cluster a file, with adjustable settings and a download button?</strong> → The <a href="../tools/spatial-clustering-explorer.html">standalone Spatial Clustering Explorer</a> runs the same detection on an uploaded point file (or its own example), with adjustable ε and minimum group size, colored on the map, downloadable.</li>
<li><strong>Wondering why the coordinate system needed picking at all?</strong> → <a href="../posts/crs-mismatch.html">Coordinate Reference Systems</a> covers the geographic/projected distinction this article leaned on in section 1.</li>
<li><strong>Have labels already, and want to predict them somewhere new instead of finding groups from scratch?</strong> → <a href="../posts/spatial-classification.html">Predicting Spatial Classes with Random Forest</a> is this family’s supervised counterpart to DBSCAN.</li>
</ul>
<div id="lab-root"></div>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("spatial-clustering-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>

 ]]></description>
  <category>tool</category>
  <category>geoml</category>
  <category>bilingual</category>
  <guid>https://webgeods.com/posts/spatial-clustering.html</guid>
  <pubDate>Wed, 09 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Raster Band Math: Combining Two Bands Into One</title>
  <link>https://webgeods.com/posts/raster-band-math.html</link>
  <description><![CDATA[ 




<p>A single band answers one question per pixel: elevation, reflectance in one wavelength, a single sensor reading. Most useful raster questions need two: how much did this change between two dates, how different is band A from band B, is this pixel more one thing than another. Band math is the term for exactly that: pixel-by-pixel arithmetic between two bands of the same raster, producing a new single-band raster the same size as the inputs.</p>
<p>Just want the computed result, without the rest of the reading? Use the <a href="../tools/raster-calculator.html">standalone tool</a> — same engine, pick your bands and operator directly.</p>
<section id="upload-your-file" class="level2">
<h2 class="anchored" data-anchor-id="upload-your-file">1. Upload your file</h2>
<p>A single <code>.tif</code>/<code>.tiff</code> with two or more bands: band math has nothing to compute on a single-band file.</p>
<div id="lab-upload">

</div>
</section>
<section id="why-band-math" class="level2">
<h2 class="anchored" data-anchor-id="why-band-math">2. Why band math?</h2>
<p>Four things worth knowing before the first computed raster:</p>
<ul>
<li><strong>It’s pixel-by-pixel, not band-by-band.</strong> Every output pixel comes from exactly the two input pixels at the same row/column: a computed raster is always the same width, height, and (usually) resolution as its inputs, never a summary or a reduction.</li>
<li><strong>NoData has to propagate, not get treated as a real number.</strong> If either input pixel is NoData, the output pixel has to be NoData too, otherwise a border of sentinel values (often <code>-9999</code> or <code>0</code>) quietly drags a mean or a color ramp toward a value that was never a real measurement.</li>
<li><strong>Division needs a plan for zero.</strong> <code>A ÷ B</code> on a pixel where <code>B</code> is <code>0</code> isn’t a crash waiting to happen: it’s <code>Inf</code> (or <code>NaN</code> for <code>0 ÷ 0</code>) in both Python and R, and both are just as “not a real value” as NoData is. Left unhandled, a single such pixel can make an entire color ramp or <code>min</code>/<code>max</code> computation meaningless.</li>
<li><strong>A very common form has a name</strong>: <code>(A − B) / (A + B)</code>, the <em>Normalized Difference</em>, bounds the result to roughly <code>-1..1</code> regardless of the two bands’ original units, which is exactly why it shows up everywhere from vegetation indices to change detection. This article computes a plain difference (<code>A − B</code>) as the running example; try changing the formula in the code below to see how the normalized form differs.</li>
</ul>
</section>
<section id="shared-map-and-result" class="level2">
<h2 class="anchored" data-anchor-id="shared-map-and-result">3. Shared map and result</h2>
<div id="lab-map" style="min-height: 500px;">

</div>
<div id="lab-stats">

</div>
<div id="lab-legend">

</div>
<div id="lab-reset">

</div>
</section>
<section id="compute-run-the-same-band-math-in-python-or-r" class="level2">
<h2 class="anchored" data-anchor-id="compute-run-the-same-band-math-in-python-or-r">4. Compute: run the same band math in Python or R</h2>
<p>Run the same computation on the current data (your file uploaded above, or the built-in example below). Edit <code>band_a</code>/<code>band_b</code>, or the formula itself, and re-run to see the map update.</p>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="bandmath-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("bandmath-py", {
    language: "python",
    initialCode: "import base64\nimport rasterio\nfrom rasterio.transform import from_origin\nfrom rasterio.io import MemoryFile\nfrom rasterio.warp import transform_bounds\nimport numpy as np\n\npath = None\nfor candidate in [\"/uploaded.tif\"]:\n    try:\n        with rasterio.open(candidate):\n            path = candidate\n            break\n    except Exception:\n        pass\n\n# No file uploaded yet: two small synthetic hills built directly in\n# memory, close enough together that A - B is a non-trivial pattern\n# rather than ~0 everywhere.\nmemfile = None\nif path is None:\n    n = 20\n    xs = np.arange(n)\n    ys = np.arange(n)\n    X, Y = np.meshgrid(xs, ys)\n    band1 = (100 + 40 * np.exp(-((X - 10) ** 2 + (Y - 10) ** 2) / 30)).astype(\"float32\")\n    band2 = (100 + 25 * np.exp(-((X - 6) ** 2 + (Y - 14) ** 2) / 40)).astype(\"float32\")\n    band1[0, 0] = -9999\n    band2[0, 0] = -9999\n    transform = from_origin(12.40, 41.95, 0.005, 0.005)\n    memfile = MemoryFile()\n    with memfile.open(\n        driver=\"GTiff\", height=n, width=n, count=2,\n        dtype=\"float32\", crs=\"EPSG:4326\", transform=transform, nodata=-9999\n    ) as dst:\n        dst.write(band1, 1)\n        dst.write(band2, 2)\n    path = memfile.name\n\nband_a = 1\nband_b = 2\n\nwith rasterio.open(path) as src:\n    # NoData pixels come back masked; filling them with NaN (instead of\n    # leaving a masked array) means NoData propagates through ordinary\n    # arithmetic below on its own -- any pixel where either input is\n    # NaN stays NaN, with no explicit NoData-handling logic needed.\n    a = src.read(band_a, masked=True).astype(\"float32\").filled(np.nan)\n    b = src.read(band_b, masked=True).astype(\"float32\").filled(np.nan)\n\n    # Plain difference — try (a - b) / (a + b) (Normalized Difference)\n    # instead, and re-run, to see how the result and its color range\n    # change.\n    with np.errstate(divide=\"ignore\", invalid=\"ignore\"):\n        result = a - b\n\n    # Inf/NaN (from a formula that divides, on a zero pixel) folded\n    # into NoData the same way — the map/legend/stats below only ever\n    # have to reason about \"finite\" vs \"not\".\n    result[~np.isfinite(result)] = np.nan\n\n    valid = result[np.isfinite(result)]\n    has_valid = valid.size > 0\n\n    # Same missing-CRS convention as the other Raster tools: unknown\n    # stays unknown in \"crs\", never silently relabeled as WGS84.\n    crs_warning = None\n    display_crs_assumption = None\n    if src.crs is not None:\n        crs_for_transform = src.crs\n        crs_label = str(src.crs)\n    else:\n        crs_for_transform = \"EPSG:4326\"\n        crs_label = None\n        crs_warning = \"No CRS declared — spatial location cannot be reliably interpreted.\"\n        display_crs_assumption = \"EPSG:4326\"\n\n    try:\n        wgs84_bounds = transform_bounds(crs_for_transform, \"EPSG:4326\", *src.bounds)\n    except Exception:\n        wgs84_bounds = src.bounds\n\n    minx, miny, maxx, maxy = wgs84_bounds\n    footprint = {\n        \"type\": \"FeatureCollection\",\n        \"features\": [{\n            \"type\": \"Feature\",\n            \"properties\": {\"name\": \"band math footprint\"},\n            \"geometry\": {\n                \"type\": \"Polygon\",\n                \"coordinates\": [[[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy], [minx, miny]]]\n            }\n        }]\n    }\n\n    data_b64 = base64.b64encode(result.astype(\"float32\").tobytes()).decode(\"ascii\")\n\n    summary = {\n        \"width\": src.width,\n        \"height\": src.height,\n        \"data\": data_b64,\n        \"wgs84Bounds\": [round(float(v), 6) for v in wgs84_bounds],\n        \"crs\": crs_label,\n        \"crsWarning\": crs_warning,\n        \"displayCrsAssumption\": display_crs_assumption,\n        \"min\": round(float(valid.min()), 4) if has_valid else None,\n        \"max\": round(float(valid.max()), 4) if has_valid else None,\n        \"mean\": round(float(valid.mean()), 4) if has_valid else None,\n        \"nodataCount\": int(result.size - valid.size),\n        \"totalPixels\": int(result.size),\n    }\n\nif memfile is not None:\n    memfile.close()\n\n{\n    \"summary\": summary,\n    \"footprint\": footprint\n}",
    preload: { packages: ["rasterio","numpy"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, { packages: ["rasterio","numpy"] }));
    }
  });
});
</script>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div id="bandmath-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("bandmath-r", {
    language: "r",
    initialCode: "library(terra)\n\npath <- NULL\nfor (candidate in c(\"/uploaded.tif\")) {\n  ok <- tryCatch({ rast(candidate); TRUE }, error = function(e) FALSE)\n  if (ok) { path <- candidate; break }\n}\n\nif (is.null(path)) {\n  # Same synthetic pair as the Python fallback.\n  n <- 20\n  xs <- 0:(n - 1)\n  ys <- 0:(n - 1)\n  band1 <- outer(ys, xs, function(y, x) 100 + 40 * exp(-((x - 10)^2 + (y - 10)^2) / 30))\n  band2 <- outer(ys, xs, function(y, x) 100 + 25 * exp(-((x - 6)^2 + (y - 14)^2) / 40))\n  band1[1, 1] <- NA\n  band2[1, 1] <- NA\n  r <- c(\n    rast(band1, crs = \"EPSG:4326\"),\n    rast(band2, crs = \"EPSG:4326\")\n  )\n  ext(r) <- c(12.40, 12.40 + n * 0.005, 41.95 - n * 0.005, 41.95)\n} else {\n  r <- rast(path)\n}\n\nband_a <- 1\nband_b <- 2\n\na <- r[[band_a]]\nb <- r[[band_b]]\n\n# Plain difference — try (a - b) / (a + b) (Normalized Difference)\n# instead, and re-run.\nresult <- a - b\n\n# terra's SpatRaster supports Ops/Math group generics directly, so\n# is.finite() works elementwise here exactly like it does on a plain\n# numeric vector; ifel() is terra's own vectorized if/else, replacing\n# every non-finite cell (from a divide, if you try Normalized\n# Difference above) with NA.\nresult <- ifel(is.finite(result), result, NA)\n\n# terra reports a missing CRS as an empty string, not NA.\nwas_missing_crs <- crs(r) == \"\"\ncrs_warning <- NULL\ndisplay_crs_assumption <- NULL\nif (was_missing_crs) {\n  crs_warning <- \"No CRS declared — spatial location cannot be reliably interpreted.\"\n  display_crs_assumption <- \"EPSG:4326\"\n  crs(r) <- \"EPSG:4326\"\n}\ncrs_label <- if (was_missing_crs) {\n  NULL\n} else {\n  crs_desc <- crs(r, describe = TRUE)\n  if (!is.na(crs_desc$authority) && !is.na(crs_desc$code)) {\n    paste0(crs_desc$authority, \":\", crs_desc$code)\n  } else {\n    crs(r, proj = TRUE)\n  }\n}\n\ne <- ext(r)\nbounds <- c(e$xmin, e$ymin, e$xmax, e$ymax)\n\nbbox_wkt <- paste0(\n  \"POLYGON((\",\n  e$xmin, \" \", e$ymin, \", \", e$xmax, \" \", e$ymin, \", \",\n  e$xmax, \" \", e$ymax, \", \", e$xmin, \" \", e$ymax, \", \",\n  e$xmin, \" \", e$ymin, \"))\"\n)\nbbox_vect <- vect(bbox_wkt, crs = crs(r))\nbbox_wgs84 <- tryCatch(project(bbox_vect, \"EPSG:4326\"), error = function(err) bbox_vect)\nbbox_geom <- geom(bbox_wgs84)\nfootprint_coords <- lapply(seq_len(nrow(bbox_geom)), function(i) c(bbox_geom[i, \"x\"], bbox_geom[i, \"y\"]))\ne_wgs84 <- ext(bbox_wgs84)\n\nfootprint <- list(\n  type = jsonlite::unbox(\"FeatureCollection\"),\n  features = list(list(\n    type = jsonlite::unbox(\"Feature\"),\n    properties = list(name = jsonlite::unbox(\"band math footprint\")),\n    geometry = list(\n      type = jsonlite::unbox(\"Polygon\"),\n      coordinates = list(footprint_coords)\n    )\n  ))\n)\n\n# Row-major flat vector, same cell order terra itself reads/writes a\n# raster in (matches rasterio's own row-major convention on the\n# Python side) -- values(mat = FALSE) returns exactly that order.\nvals <- values(result, mat = FALSE)\nvalid <- vals[is.finite(vals)]\nhas_valid <- length(valid) > 0\n\ncon <- rawConnection(raw(0), \"wb\")\nwriteBin(as.numeric(ifelse(is.finite(vals), vals, NaN)), con, size = 4, endian = \"little\")\nraw_bytes <- rawConnectionValue(con)\nclose(con)\ndata_b64 <- jsonlite::base64_enc(raw_bytes)\n\n# jsonlite::toJSON() treats every length-1 vector as a single-element\n# array by default -- a genuine scalar (a width, a CRS string) needs\n# an explicit jsonlite::unbox() to serialize as one. ub()/ub_if()\n# below are just that, plus a NULL passthrough for optional fields.\n# Safer than toJSON(auto_unbox = TRUE), which guesses from length\n# alone and can silently flatten a real one-item array into a bare\n# scalar.\nub <- function(x) if (is.null(x)) NULL else jsonlite::unbox(x)\nub_if <- function(cond, x) if (cond) jsonlite::unbox(x) else NULL\n\nsummary_out <- list(\n  width = jsonlite::unbox(ncol(r)),\n  height = jsonlite::unbox(nrow(r)),\n  data = jsonlite::unbox(data_b64),\n  wgs84Bounds = round(c(e_wgs84$xmin, e_wgs84$ymin, e_wgs84$xmax, e_wgs84$ymax), 6),\n  crs = ub(crs_label),\n  crsWarning = ub(crs_warning),\n  displayCrsAssumption = ub(display_crs_assumption),\n  min = ub_if(has_valid, round(min(valid), 4)),\n  max = ub_if(has_valid, round(max(valid), 4)),\n  mean = ub_if(has_valid, round(mean(valid), 4)),\n  nodataCount = jsonlite::unbox(length(vals) - length(valid)),\n  totalPixels = jsonlite::unbox(length(vals))\n)\n\nresult_out <- list(summary = summary_out, footprint = footprint)\njsonlite::fromJSON(jsonlite::toJSON(result_out, null = \"null\"), simplifyVector = FALSE)",
    preload: { packages: ["terra","jsonlite","codetools"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["terra","jsonlite","codetools"] }));
    }
  });
});
</script>
</div>
</div>
</div>
</section>
<section id="same-spatial-question-two-languages" class="level2">
<h2 class="anchored" data-anchor-id="same-spatial-question-two-languages">5. Same spatial question, two languages</h2>
<p>Both engines agree on the arithmetic itself (elementwise subtraction is elementwise subtraction), but differ in how NoData and non-finite results are handled:</p>
<p><strong>Python</strong> (rasterio + numpy)</p>
<div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1">src.read(i, masked<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>).astype(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"float32"</span>).filled(np.nan)  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># NoData -&gt; NaN up front</span></span>
<span id="cb1-2">a <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-</span> b                                                        <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># plain numpy arithmetic</span></span>
<span id="cb1-3">result[<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">~</span>np.isfinite(result)] <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> np.nan                        <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># Inf/NaN from division -&gt; NaN</span></span></code></pre></div>
<p><strong>R</strong> (terra)</p>
<div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode r code-with-copy"><code class="sourceCode r"><span id="cb2-1">r[[i]]                              <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># one band as its own SpatRaster</span></span>
<span id="cb2-2">a <span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">-</span> b                                <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># terra overloads +-*/ directly on SpatRaster</span></span>
<span id="cb2-3"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">ifel</span>(<span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">is.finite</span>(result), result, <span class="cn" style="color: #8f5902;
background-color: null;
font-style: inherit;">NA</span>)  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># Inf/NaN from division -&gt; NA</span></span></code></pre></div>
<p>The shape of the fix is identical in both languages: mask out non-finite values after the arithmetic, don’t try to prevent them from occurring. But the <em>mechanism</em> differs: numpy needs an explicit <code>np.errstate</code> context to suppress the runtime warning a division by zero would otherwise print (the computation itself still produces <code>Inf</code>/<code>NaN</code> either way), while terra’s <code>ifel()</code> is a single vectorized call with no equivalent warning to suppress.</p>
</section>
<section id="a-raster-with-no-declared-crs" class="level2 page-columns page-full">
<h2 class="anchored" data-anchor-id="a-raster-with-no-declared-crs">6. A raster with no declared CRS</h2>
<p>The reusable cell pair above already builds two small synthetic hills whenever nothing has been uploaded. Click <strong>▶ Run</strong> on both languages in section 4 to see it. This button swaps in the same pair, minus the CRS, to see the CRS row switch to “Unknown” and a separate “Display assumption” row appear below it, same convention as every other Raster tool on this site.<sup>1</sup></p>
<div class="no-row-height column-margin column-container"><div id="fn1"><p><sup>1</sup>&nbsp;Band math doesn’t change anything about this: the two input bands either both carry a real CRS or don’t, and the computed output inherits whichever is true. A missing CRS says nothing about whether the arithmetic itself is trustworthy, only about where the result sits on the earth.</p></div></div><div id="webgeods-example-1"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-1").appendChild(
    window.WebGeoDS.loadExampleButton("📋 Load a no-CRS example", { pyId: "bandmath-py", pyCode: "import base64\nimport rasterio\nfrom rasterio.transform import from_origin\nfrom rasterio.io import MemoryFile\nimport numpy as np\n\nn = 20\nxs = np.arange(n)\nys = np.arange(n)\nX, Y = np.meshgrid(xs, ys)\nband1 = (100 + 40 * np.exp(-((X - 10) ** 2 + (Y - 10) ** 2) / 30)).astype(\"float32\")\nband2 = (100 + 25 * np.exp(-((X - 6) ** 2 + (Y - 14) ** 2) / 40)).astype(\"float32\")\nband1[0, 0] = -9999\nband2[0, 0] = -9999\ntransform = from_origin(12.40, 41.95, 0.005, 0.005)\n\n# crs deliberately omitted -- the whole point of this example.\nmemfile = MemoryFile()\nwith memfile.open(\n    driver=\"GTiff\", height=n, width=n, count=2,\n    dtype=\"float32\", transform=transform, nodata=-9999\n) as dst:\n    dst.write(band1, 1)\n    dst.write(band2, 2)\n\nband_a, band_b = 1, 2\n\nwith rasterio.open(memfile.name) as src:\n    # NoData filled with NaN so it propagates through the arithmetic\n    # below on its own, with no explicit NoData handling.\n    a = src.read(band_a, masked=True).astype(\"float32\").filled(np.nan)\n    b = src.read(band_b, masked=True).astype(\"float32\").filled(np.nan)\n    with np.errstate(divide=\"ignore\", invalid=\"ignore\"):\n        result = a - b\n    result[~np.isfinite(result)] = np.nan\n\n    valid = result[np.isfinite(result)]\n    has_valid = valid.size > 0\n\n    # The point of this example: no CRS was written above, so \"crs\"\n    # stays unknown instead of being quietly relabeled as WGS84, and\n    # the WGS84 used to place the footprint is reported separately as\n    # a display assumption.\n    crs_warning = None\n    display_crs_assumption = None\n    if src.crs is not None:\n        crs_label = str(src.crs)\n    else:\n        crs_label = None\n        crs_warning = \"No CRS declared — spatial location cannot be reliably interpreted.\"\n        display_crs_assumption = \"EPSG:4326\"\n\n    minx, miny, maxx, maxy = src.bounds\n    footprint = {\n        \"type\": \"FeatureCollection\",\n        \"features\": [{\n            \"type\": \"Feature\",\n            \"properties\": {\"name\": \"band math footprint\"},\n            \"geometry\": {\n                \"type\": \"Polygon\",\n                \"coordinates\": [[[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy], [minx, miny]]]\n            }\n        }]\n    }\n\n    data_b64 = base64.b64encode(result.astype(\"float32\").tobytes()).decode(\"ascii\")\n\n    summary = {\n        \"width\": src.width, \"height\": src.height, \"data\": data_b64,\n        \"wgs84Bounds\": [round(float(v), 6) for v in src.bounds],\n        \"crs\": crs_label, \"crsWarning\": crs_warning,\n        \"displayCrsAssumption\": display_crs_assumption,\n        \"min\": round(float(valid.min()), 4) if has_valid else None,\n        \"max\": round(float(valid.max()), 4) if has_valid else None,\n        \"mean\": round(float(valid.mean()), 4) if has_valid else None,\n        \"nodataCount\": int(result.size - valid.size), \"totalPixels\": int(result.size),\n    }\n\nmemfile.close()\n\n{\"summary\": summary, \"footprint\": footprint}", rId: "bandmath-r", rCode: "library(terra)\n\nn <- 20\nxs <- 0:(n - 1)\nys <- 0:(n - 1)\nband1 <- outer(ys, xs, function(y, x) 100 + 40 * exp(-((x - 10)^2 + (y - 10)^2) / 30))\nband2 <- outer(ys, xs, function(y, x) 100 + 25 * exp(-((x - 6)^2 + (y - 14)^2) / 40))\nband1[1, 1] <- NA\nband2[1, 1] <- NA\n# crs deliberately omitted from rast() -- the whole point of this example.\nr <- c(rast(band1), rast(band2))\next(r) <- c(12.40, 12.40 + n * 0.005, 41.95 - n * 0.005, 41.95)\n\nband_a <- 1\nband_b <- 2\na <- r[[band_a]]\nb <- r[[band_b]]\nresult <- a - b\n# ifel() replaces the Inf/NaN a division can produce with NA.\nresult <- ifel(is.finite(result), result, NA)\n\n# The point of this example: terra reports the missing CRS as an empty\n# string, so \"crs\" stays unknown instead of being quietly relabeled,\n# and the WGS84 assigned below (only so the footprint can be placed)\n# is reported separately as a display assumption.\ncrs_warning <- NULL\ndisplay_crs_assumption <- NULL\nif (crs(r) == \"\") {\n  crs_warning <- \"No CRS declared — spatial location cannot be reliably interpreted.\"\n  display_crs_assumption <- \"EPSG:4326\"\n  crs(r) <- \"EPSG:4326\"\n}\n\ne <- ext(r)\nbbox_wkt <- paste0(\n  \"POLYGON((\",\n  e$xmin, \" \", e$ymin, \", \", e$xmax, \" \", e$ymin, \", \",\n  e$xmax, \" \", e$ymax, \", \", e$xmin, \" \", e$ymax, \", \",\n  e$xmin, \" \", e$ymin, \"))\"\n)\nbbox_vect <- vect(bbox_wkt, crs = crs(r))\nbbox_geom <- geom(bbox_vect)\nfootprint_coords <- lapply(seq_len(nrow(bbox_geom)), function(i) c(bbox_geom[i, \"x\"], bbox_geom[i, \"y\"]))\n\nfootprint <- list(\n  type = jsonlite::unbox(\"FeatureCollection\"),\n  features = list(list(\n    type = jsonlite::unbox(\"Feature\"),\n    properties = list(name = jsonlite::unbox(\"band math footprint\")),\n    geometry = list(type = jsonlite::unbox(\"Polygon\"), coordinates = list(footprint_coords))\n  ))\n)\n\nvals <- values(result, mat = FALSE)\nvalid <- vals[is.finite(vals)]\nhas_valid <- length(valid) > 0\n\ncon <- rawConnection(raw(0), \"wb\")\nwriteBin(as.numeric(ifelse(is.finite(vals), vals, NaN)), con, size = 4, endian = \"little\")\nraw_bytes <- rawConnectionValue(con)\nclose(con)\ndata_b64 <- jsonlite::base64_enc(raw_bytes)\n\nsummary_out <- list(\n  width = jsonlite::unbox(ncol(r)), height = jsonlite::unbox(nrow(r)), data = jsonlite::unbox(data_b64),\n  wgs84Bounds = round(c(e$xmin, e$ymin, e$xmax, e$ymax), 6),\n  crs = NULL, crsWarning = jsonlite::unbox(crs_warning),\n  displayCrsAssumption = jsonlite::unbox(display_crs_assumption),\n  min = if (has_valid) jsonlite::unbox(round(min(valid), 4)) else NULL,\n  max = if (has_valid) jsonlite::unbox(round(max(valid), 4)) else NULL,\n  mean = if (has_valid) jsonlite::unbox(round(mean(valid), 4)) else NULL,\n  nodataCount = jsonlite::unbox(length(vals) - length(valid)), totalPixels = jsonlite::unbox(length(vals))\n)\n\nresult_out <- list(summary = summary_out, footprint = footprint)\njsonlite::fromJSON(jsonlite::toJSON(result_out, null = \"null\"), simplifyVector = FALSE)" }));
});
</script>
</section>
<section id="where-to-next" class="level2">
<h2 class="anchored" data-anchor-id="where-to-next">7. Where to next</h2>
<ul>
<li><strong>Just needed the computed raster?</strong> The <a href="../tools/raster-calculator.html">standalone Raster Calculator</a> picks bands and an operator from a dropdown, no editing required.</li>
<li><strong>Not sure what’s in the file yet?</strong> Start with <a href="../tools/raster-inspector.html">Raster Inspector</a>: dimensions, bands, CRS, NoData, before computing anything.</li>
<li><strong>Working with vector data instead?</strong> See <a href="../posts/geospatial-file-inspection.html">First Look at a Geospatial File</a>.</li>
<li><strong>Vegetation index specifically?</strong> See <a href="../posts/raster-ndvi.html">NDVI: Measuring Vegetation from Two Bands</a>, or the standalone <a href="../tools/ndvi-calculator.html">NDVI Calculator</a>: this same Normalized Difference operation, with red/near-infrared band presets and a vegetation color scale.</li>
</ul>
<div id="lab-root"></div>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("raster-band-math-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>


 ]]></description>
  <category>tool</category>
  <category>raster</category>
  <category>bilingual</category>
  <guid>https://webgeods.com/posts/raster-band-math.html</guid>
  <pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>NDVI: Measuring Vegetation from Two Bands</title>
  <link>https://webgeods.com/posts/raster-ndvi.html</link>
  <description><![CDATA[ 




<p>Plants reflect near-infrared (NIR) light strongly and absorb most red light for photosynthesis: a signature no bare soil, water, or built surface shares. The Normalized Difference Vegetation Index, NDVI, turns that one physical fact into a single number per pixel:</p>
<p><img src="https://latex.codecogs.com/png.latex?%5Ctext%7BNDVI%7D%20=%20%5Cfrac%7B%5Ctext%7BNIR%7D%20-%20%5Ctext%7BRed%7D%7D%7B%5Ctext%7BNIR%7D%20+%20%5Ctext%7BRed%7D%7D"></p>
<p>Same formula as <a href="../posts/raster-band-math.html">Raster Band Math</a>’s Normalized Difference operator: this article fixes it to a specific pair of bands and gives the result a name, a conventional range, and a color scheme, because that’s what turns “a computed raster” into “a vegetation map.”</p>
<p>Just want the map, without the rest of the reading? Use the <a href="../tools/ndvi-calculator.html">standalone tool</a> — same engine, pick your bands directly.</p>
<section id="upload-your-file" class="level2">
<h2 class="anchored" data-anchor-id="upload-your-file">1. Upload your file</h2>
<p>A <code>.tif</code>/<code>.tiff</code> with a Red and a near-infrared band.</p>
<div id="lab-upload">

</div>
</section>
<section id="why-ndvi-and-its-most-common-real-world-mistake" class="level2">
<h2 class="anchored" data-anchor-id="why-ndvi-and-its-most-common-real-world-mistake">2. Why NDVI, and its most common real-world mistake</h2>
<p>Three things worth knowing before the first computed map:</p>
<ul>
<li><strong>It’s bounded, by construction.</strong> Normalizing by <code>NIR + Red</code> (rather than a plain difference) squeezes the result into roughly <code>-1..1</code> regardless of the sensor’s raw value range: a Landsat scene in reflectance units and a raw-DN Sentinel-2 tile produce NDVI on the <em>same</em> scale, which is exactly why a fixed color scale (not a per-file min/max) makes sense here, unlike the generic Band Math tool.</li>
<li><strong>Roughly</strong>: negative values are usually water, values near zero are bare soil or built surfaces, and increasingly positive values track increasingly dense, healthy vegetation. “Roughly” is doing real work in that sentence: season, sensor, and atmospheric correction all shift the exact thresholds; treat it as a ranking, not a lab measurement.</li>
<li><strong>The single most common real-world mistake: Red and NIR swapped, or the wrong band picked entirely.</strong> A GeoTIFF’s band order isn’t standardized. It’s whatever the sensor or processing pipeline put there, and the file itself never declares “band 4 is Red.” Swap the two bands and NDVI doesn’t error, it just silently inverts: vegetation reads as water-like and vice versa, a wrong answer that <em>looks</em> like a plausible map. Worth a specific check (<code>0.2 &lt;= NDVI   &lt;= 0.9</code> somewhere near obvious vegetation in the image) before trusting a result on an unfamiliar file, not just a general disclaimer to keep in mind.</li>
</ul>
</section>
<section id="shared-map-and-result" class="level2">
<h2 class="anchored" data-anchor-id="shared-map-and-result">3. Shared map and result</h2>
<div id="lab-map" style="min-height: 500px;">

</div>
<div id="lab-stats">

</div>
<div id="lab-legend">

</div>
<div id="lab-reset">

</div>
</section>
<section id="compute-run-ndvi-in-python-or-r" class="level2">
<h2 class="anchored" data-anchor-id="compute-run-ndvi-in-python-or-r">4. Compute: run NDVI in Python or R</h2>
<p>Run the same computation on the current data (your file uploaded above, or the built-in example below). Edit <code>band_red</code>/<code>band_nir</code> to match your file (common conventions: Landsat 8/9 Red=band 4, NIR=band 5; Sentinel-2 Red=band 4, NIR=band 8, but always confirm against the file at hand, not just the sensor name) and re-run.</p>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="ndvi-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("ndvi-py", {
    language: "python",
    initialCode: "import base64\nimport rasterio\nfrom rasterio.transform import from_origin\nfrom rasterio.io import MemoryFile\nfrom rasterio.warp import transform_bounds\nimport numpy as np\n\npath = None\nfor candidate in [\"/uploaded.tif\"]:\n    try:\n        with rasterio.open(candidate):\n            path = candidate\n            break\n    except Exception:\n        pass\n\n# No file uploaded yet: a synthetic Red/NIR pair shaped like a real\n# vegetation signature (NIR up, Red down where \"vegetation\" is\n# present) rather than two arbitrary bands -- see section 2.\nmemfile = None\nif path is None:\n    n = 20\n    xs = np.arange(n)\n    ys = np.arange(n)\n    X, Y = np.meshgrid(xs, ys)\n    vegetation = np.exp(-((X - 10) ** 2 + (Y - 10) ** 2) / 30)\n    red_band = (0.25 - 0.20 * vegetation).astype(\"float32\")\n    nir_band = (0.10 + 0.50 * vegetation).astype(\"float32\")\n    red_band[0, 0] = -9999\n    nir_band[0, 0] = -9999\n    transform = from_origin(12.40, 41.95, 0.005, 0.005)\n    memfile = MemoryFile()\n    with memfile.open(\n        driver=\"GTiff\", height=n, width=n, count=2,\n        dtype=\"float32\", crs=\"EPSG:4326\", transform=transform, nodata=-9999\n    ) as dst:\n        dst.write(red_band, 1)\n        dst.write(nir_band, 2)\n    path = memfile.name\n\nband_red = 1\nband_nir = 2\n\nwith rasterio.open(path) as src:\n    # NoData pixels come back masked; filling them with NaN (instead of\n    # leaving a masked array) means NoData propagates through ordinary\n    # arithmetic below on its own -- any pixel where either band is\n    # NaN stays NaN, with no explicit NoData-handling logic needed.\n    red = src.read(band_red, masked=True).astype(\"float32\").filled(np.nan)\n    nir = src.read(band_nir, masked=True).astype(\"float32\").filled(np.nan)\n\n    with np.errstate(divide=\"ignore\", invalid=\"ignore\"):\n        result = (nir - red) / (nir + red)\n\n    # Inf/NaN (from an all-zero pixel pair) folded into NoData.\n    result[~np.isfinite(result)] = np.nan\n\n    valid = result[np.isfinite(result)]\n    has_valid = valid.size > 0\n\n    crs_warning = None\n    display_crs_assumption = None\n    if src.crs is not None:\n        crs_for_transform = src.crs\n        crs_label = str(src.crs)\n    else:\n        crs_for_transform = \"EPSG:4326\"\n        crs_label = None\n        crs_warning = \"No CRS declared — spatial location cannot be reliably interpreted.\"\n        display_crs_assumption = \"EPSG:4326\"\n\n    try:\n        wgs84_bounds = transform_bounds(crs_for_transform, \"EPSG:4326\", *src.bounds)\n    except Exception:\n        wgs84_bounds = src.bounds\n\n    minx, miny, maxx, maxy = wgs84_bounds\n    footprint = {\n        \"type\": \"FeatureCollection\",\n        \"features\": [{\n            \"type\": \"Feature\",\n            \"properties\": {\"name\": \"NDVI footprint\"},\n            \"geometry\": {\n                \"type\": \"Polygon\",\n                \"coordinates\": [[[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy], [minx, miny]]]\n            }\n        }]\n    }\n\n    data_b64 = base64.b64encode(result.astype(\"float32\").tobytes()).decode(\"ascii\")\n\n    summary = {\n        \"width\": src.width,\n        \"height\": src.height,\n        \"data\": data_b64,\n        \"wgs84Bounds\": [round(float(v), 6) for v in wgs84_bounds],\n        \"crs\": crs_label,\n        \"crsWarning\": crs_warning,\n        \"displayCrsAssumption\": display_crs_assumption,\n        \"min\": round(float(valid.min()), 4) if has_valid else None,\n        \"max\": round(float(valid.max()), 4) if has_valid else None,\n        \"mean\": round(float(valid.mean()), 4) if has_valid else None,\n        \"nodataCount\": int(result.size - valid.size),\n        \"totalPixels\": int(result.size),\n    }\n\nif memfile is not None:\n    memfile.close()\n\n{\n    \"summary\": summary,\n    \"footprint\": footprint\n}",
    preload: { packages: ["rasterio","numpy"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, { packages: ["rasterio","numpy"] }));
    }
  });
});
</script>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div id="ndvi-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("ndvi-r", {
    language: "r",
    initialCode: "library(terra)\n\npath <- NULL\nfor (candidate in c(\"/uploaded.tif\")) {\n  ok <- tryCatch({ rast(candidate); TRUE }, error = function(e) FALSE)\n  if (ok) { path <- candidate; break }\n}\n\nif (is.null(path)) {\n  # Same synthetic Red/NIR pair as the Python fallback.\n  n <- 20\n  xs <- 0:(n - 1)\n  ys <- 0:(n - 1)\n  vegetation <- outer(ys, xs, function(y, x) exp(-((x - 10)^2 + (y - 10)^2) / 30))\n  red_band <- 0.25 - 0.20 * vegetation\n  nir_band <- 0.10 + 0.50 * vegetation\n  red_band[1, 1] <- NA\n  nir_band[1, 1] <- NA\n  r <- c(\n    rast(red_band, crs = \"EPSG:4326\"),\n    rast(nir_band, crs = \"EPSG:4326\")\n  )\n  ext(r) <- c(12.40, 12.40 + n * 0.005, 41.95 - n * 0.005, 41.95)\n} else {\n  r <- rast(path)\n}\n\nband_red <- 1\nband_nir <- 2\n\nred <- r[[band_red]]\nnir <- r[[band_nir]]\n\nresult <- (nir - red) / (nir + red)\n\n# ifel() is terra's own vectorized if/else, used here to clean up\n# Inf/NaN produced by a division.\nresult <- ifel(is.finite(result), result, NA)\n\nwas_missing_crs <- crs(r) == \"\"\ncrs_warning <- NULL\ndisplay_crs_assumption <- NULL\nif (was_missing_crs) {\n  crs_warning <- \"No CRS declared — spatial location cannot be reliably interpreted.\"\n  display_crs_assumption <- \"EPSG:4326\"\n  crs(r) <- \"EPSG:4326\"\n}\ncrs_label <- if (was_missing_crs) {\n  NULL\n} else {\n  crs_desc <- crs(r, describe = TRUE)\n  if (!is.na(crs_desc$authority) && !is.na(crs_desc$code)) {\n    paste0(crs_desc$authority, \":\", crs_desc$code)\n  } else {\n    crs(r, proj = TRUE)\n  }\n}\n\ne <- ext(r)\nbbox_wkt <- paste0(\n  \"POLYGON((\",\n  e$xmin, \" \", e$ymin, \", \", e$xmax, \" \", e$ymin, \", \",\n  e$xmax, \" \", e$ymax, \", \", e$xmin, \" \", e$ymax, \", \",\n  e$xmin, \" \", e$ymin, \"))\"\n)\nbbox_vect <- vect(bbox_wkt, crs = crs(r))\nbbox_wgs84 <- tryCatch(project(bbox_vect, \"EPSG:4326\"), error = function(err) bbox_vect)\nbbox_geom <- geom(bbox_wgs84)\nfootprint_coords <- lapply(seq_len(nrow(bbox_geom)), function(i) c(bbox_geom[i, \"x\"], bbox_geom[i, \"y\"]))\ne_wgs84 <- ext(bbox_wgs84)\n\nfootprint <- list(\n  type = jsonlite::unbox(\"FeatureCollection\"),\n  features = list(list(\n    type = jsonlite::unbox(\"Feature\"),\n    properties = list(name = jsonlite::unbox(\"NDVI footprint\")),\n    geometry = list(\n      type = jsonlite::unbox(\"Polygon\"),\n      coordinates = list(footprint_coords)\n    )\n  ))\n)\n\nvals <- values(result, mat = FALSE)\nvalid <- vals[is.finite(vals)]\nhas_valid <- length(valid) > 0\n\ncon <- rawConnection(raw(0), \"wb\")\nwriteBin(as.numeric(ifelse(is.finite(vals), vals, NaN)), con, size = 4, endian = \"little\")\nraw_bytes <- rawConnectionValue(con)\nclose(con)\ndata_b64 <- jsonlite::base64_enc(raw_bytes)\n\n# jsonlite::toJSON() treats every length-1 vector as a single-element\n# array by default -- a genuine scalar (a width, a CRS string) needs\n# an explicit jsonlite::unbox() to serialize as one. ub()/ub_if()\n# below are just that, plus a NULL passthrough for optional fields.\n# Safer than toJSON(auto_unbox = TRUE), which guesses from length\n# alone and can silently flatten a real one-item array into a bare\n# scalar.\nub <- function(x) if (is.null(x)) NULL else jsonlite::unbox(x)\nub_if <- function(cond, x) if (cond) jsonlite::unbox(x) else NULL\n\nsummary_out <- list(\n  width = jsonlite::unbox(ncol(r)),\n  height = jsonlite::unbox(nrow(r)),\n  data = jsonlite::unbox(data_b64),\n  wgs84Bounds = round(c(e_wgs84$xmin, e_wgs84$ymin, e_wgs84$xmax, e_wgs84$ymax), 6),\n  crs = ub(crs_label),\n  crsWarning = ub(crs_warning),\n  displayCrsAssumption = ub(display_crs_assumption),\n  min = ub_if(has_valid, round(min(valid), 4)),\n  max = ub_if(has_valid, round(max(valid), 4)),\n  mean = ub_if(has_valid, round(mean(valid), 4)),\n  nodataCount = jsonlite::unbox(length(vals) - length(valid)),\n  totalPixels = jsonlite::unbox(length(vals))\n)\n\nresult_out <- list(summary = summary_out, footprint = footprint)\njsonlite::fromJSON(jsonlite::toJSON(result_out, null = \"null\"), simplifyVector = FALSE)",
    preload: { packages: ["terra","jsonlite","codetools"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["terra","jsonlite","codetools"] }));
    }
  });
});
</script>
</div>
</div>
</div>
</section>
<section id="same-spatial-question-two-languages" class="level2">
<h2 class="anchored" data-anchor-id="same-spatial-question-two-languages">5. Same spatial question, two languages</h2>
<p>Same divergence already documented in <a href="../posts/raster-band-math.html#same-spatial-question-two-languages">Raster Band Math</a>: numpy needs an explicit <code>np.errstate</code> context to suppress the runtime warning a division by zero would otherwise print, while terra’s <code>ifel()</code> is a single vectorized call with no equivalent warning. Nothing about NDVI specifically changes that comparison; the formula is just a Normalized Difference with the bands fixed to Red/NIR.</p>
</section>
<section id="a-raster-with-no-declared-crs" class="level2 page-columns page-full">
<h2 class="anchored" data-anchor-id="a-raster-with-no-declared-crs">6. A raster with no declared CRS</h2>
<p>Same convention as every other Raster tool on this site: a missing CRS shows “Unknown” plus a separate “Display assumption” row, never silently relabeled as WGS84. NDVI itself doesn’t change based on CRS (the arithmetic is per-pixel regardless of where the pixel sits on the earth), but the footprint on the map still needs <em>some</em> projection to draw.<sup>1</sup></p>
<div class="no-row-height column-margin column-container"><div id="fn1"><p><sup>1</sup>&nbsp;The reusable cell pair above already builds the synthetic Red/NIR pair whenever nothing has been uploaded. Click <strong>▶ Run</strong> on both languages in section 4 to see it with a real CRS. This button swaps in the same pair, minus the CRS.</p></div></div><div id="webgeods-example-1"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-1").appendChild(
    window.WebGeoDS.loadExampleButton("📋 Load a no-CRS example", { pyId: "ndvi-py", pyCode: "import base64\nimport rasterio\nfrom rasterio.transform import from_origin\nfrom rasterio.io import MemoryFile\nimport numpy as np\n\nn = 20\nxs = np.arange(n)\nys = np.arange(n)\nX, Y = np.meshgrid(xs, ys)\nvegetation = np.exp(-((X - 10) ** 2 + (Y - 10) ** 2) / 30)\nred_band = (0.25 - 0.20 * vegetation).astype(\"float32\")\nnir_band = (0.10 + 0.50 * vegetation).astype(\"float32\")\nred_band[0, 0] = -9999\nnir_band[0, 0] = -9999\ntransform = from_origin(12.40, 41.95, 0.005, 0.005)\n\n# crs deliberately omitted -- the whole point of this example.\nmemfile = MemoryFile()\nwith memfile.open(\n    driver=\"GTiff\", height=n, width=n, count=2,\n    dtype=\"float32\", transform=transform, nodata=-9999\n) as dst:\n    dst.write(red_band, 1)\n    dst.write(nir_band, 2)\n\nband_red, band_nir = 1, 2\n\nwith rasterio.open(memfile.name) as src:\n    # NoData filled with NaN so it propagates through the arithmetic\n    # below on its own, with no explicit NoData handling.\n    red = src.read(band_red, masked=True).astype(\"float32\").filled(np.nan)\n    nir = src.read(band_nir, masked=True).astype(\"float32\").filled(np.nan)\n    with np.errstate(divide=\"ignore\", invalid=\"ignore\"):\n        result = (nir - red) / (nir + red)\n    result[~np.isfinite(result)] = np.nan\n\n    valid = result[np.isfinite(result)]\n    has_valid = valid.size > 0\n\n    # The point of this example: no CRS was written above, so \"crs\"\n    # stays unknown instead of being quietly relabeled as WGS84, and\n    # the WGS84 used to place the footprint is reported separately as\n    # a display assumption.\n    crs_warning = None\n    display_crs_assumption = None\n    if src.crs is not None:\n        crs_label = str(src.crs)\n    else:\n        crs_label = None\n        crs_warning = \"No CRS declared — spatial location cannot be reliably interpreted.\"\n        display_crs_assumption = \"EPSG:4326\"\n\n    minx, miny, maxx, maxy = src.bounds\n    footprint = {\n        \"type\": \"FeatureCollection\",\n        \"features\": [{\n            \"type\": \"Feature\",\n            \"properties\": {\"name\": \"NDVI footprint\"},\n            \"geometry\": {\n                \"type\": \"Polygon\",\n                \"coordinates\": [[[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy], [minx, miny]]]\n            }\n        }]\n    }\n\n    data_b64 = base64.b64encode(result.astype(\"float32\").tobytes()).decode(\"ascii\")\n\n    summary = {\n        \"width\": src.width, \"height\": src.height, \"data\": data_b64,\n        \"wgs84Bounds\": [round(float(v), 6) for v in src.bounds],\n        \"crs\": crs_label, \"crsWarning\": crs_warning,\n        \"displayCrsAssumption\": display_crs_assumption,\n        \"min\": round(float(valid.min()), 4) if has_valid else None,\n        \"max\": round(float(valid.max()), 4) if has_valid else None,\n        \"mean\": round(float(valid.mean()), 4) if has_valid else None,\n        \"nodataCount\": int(result.size - valid.size), \"totalPixels\": int(result.size),\n    }\n\nmemfile.close()\n\n{\"summary\": summary, \"footprint\": footprint}", rId: "ndvi-r", rCode: "library(terra)\n\nn <- 20\nxs <- 0:(n - 1)\nys <- 0:(n - 1)\nvegetation <- outer(ys, xs, function(y, x) exp(-((x - 10)^2 + (y - 10)^2) / 30))\nred_band <- 0.25 - 0.20 * vegetation\nnir_band <- 0.10 + 0.50 * vegetation\nred_band[1, 1] <- NA\nnir_band[1, 1] <- NA\n# crs deliberately omitted from rast() -- the whole point of this example.\nr <- c(rast(red_band), rast(nir_band))\next(r) <- c(12.40, 12.40 + n * 0.005, 41.95 - n * 0.005, 41.95)\n\nband_red <- 1\nband_nir <- 2\nred <- r[[band_red]]\nnir <- r[[band_nir]]\nresult <- (nir - red) / (nir + red)\n# ifel() replaces the Inf/NaN a division can produce with NA.\nresult <- ifel(is.finite(result), result, NA)\n\n# The point of this example: terra reports the missing CRS as an empty\n# string, so \"crs\" stays unknown instead of being quietly relabeled,\n# and the WGS84 assigned below (only so the footprint can be placed)\n# is reported separately as a display assumption.\ncrs_warning <- NULL\ndisplay_crs_assumption <- NULL\nif (crs(r) == \"\") {\n  crs_warning <- \"No CRS declared — spatial location cannot be reliably interpreted.\"\n  display_crs_assumption <- \"EPSG:4326\"\n  crs(r) <- \"EPSG:4326\"\n}\n\ne <- ext(r)\nbbox_wkt <- paste0(\n  \"POLYGON((\",\n  e$xmin, \" \", e$ymin, \", \", e$xmax, \" \", e$ymin, \", \",\n  e$xmax, \" \", e$ymax, \", \", e$xmin, \" \", e$ymax, \", \",\n  e$xmin, \" \", e$ymin, \"))\"\n)\nbbox_vect <- vect(bbox_wkt, crs = crs(r))\nbbox_geom <- geom(bbox_vect)\nfootprint_coords <- lapply(seq_len(nrow(bbox_geom)), function(i) c(bbox_geom[i, \"x\"], bbox_geom[i, \"y\"]))\n\nfootprint <- list(\n  type = jsonlite::unbox(\"FeatureCollection\"),\n  features = list(list(\n    type = jsonlite::unbox(\"Feature\"),\n    properties = list(name = jsonlite::unbox(\"NDVI footprint\")),\n    geometry = list(type = jsonlite::unbox(\"Polygon\"), coordinates = list(footprint_coords))\n  ))\n)\n\nvals <- values(result, mat = FALSE)\nvalid <- vals[is.finite(vals)]\nhas_valid <- length(valid) > 0\n\ncon <- rawConnection(raw(0), \"wb\")\nwriteBin(as.numeric(ifelse(is.finite(vals), vals, NaN)), con, size = 4, endian = \"little\")\nraw_bytes <- rawConnectionValue(con)\nclose(con)\ndata_b64 <- jsonlite::base64_enc(raw_bytes)\n\nsummary_out <- list(\n  width = jsonlite::unbox(ncol(r)), height = jsonlite::unbox(nrow(r)), data = jsonlite::unbox(data_b64),\n  wgs84Bounds = round(c(e$xmin, e$ymin, e$xmax, e$ymax), 6),\n  crs = NULL, crsWarning = jsonlite::unbox(crs_warning),\n  displayCrsAssumption = jsonlite::unbox(display_crs_assumption),\n  min = if (has_valid) jsonlite::unbox(round(min(valid), 4)) else NULL,\n  max = if (has_valid) jsonlite::unbox(round(max(valid), 4)) else NULL,\n  mean = if (has_valid) jsonlite::unbox(round(mean(valid), 4)) else NULL,\n  nodataCount = jsonlite::unbox(length(vals) - length(valid)), totalPixels = jsonlite::unbox(length(vals))\n)\n\nresult_out <- list(summary = summary_out, footprint = footprint)\njsonlite::fromJSON(jsonlite::toJSON(result_out, null = \"null\"), simplifyVector = FALSE)" }));
});
</script>
</section>
<section id="where-to-next" class="level2">
<h2 class="anchored" data-anchor-id="where-to-next">7. Where to next</h2>
<ul>
<li><strong>Just needed the map?</strong> The <a href="../tools/ndvi-calculator.html">standalone NDVI Calculator</a> picks Red and NIR from a dropdown, no editing required.</li>
<li><strong>Different two bands, or a different operation?</strong> <a href="../posts/raster-band-math.html">Raster Band Math</a> is the general version of this same computation: add/subtract/multiply/divide, any two bands, no vegetation assumption.</li>
<li><strong>Not sure what’s in the file yet?</strong> Start with <a href="../tools/raster-inspector.html">Raster Inspector</a>: dimensions, bands, CRS, NoData, before computing anything.</li>
<li><strong>Working with terrain instead of imagery?</strong> See <a href="../posts/raster-viewshed.html">Viewshed</a>: what’s visible from a point, accounting for the ground in between.</li>
</ul>
<div id="lab-root"></div>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("raster-ndvi-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>


 ]]></description>
  <category>tool</category>
  <category>raster</category>
  <category>bilingual</category>
  <guid>https://webgeods.com/posts/raster-ndvi.html</guid>
  <pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Viewshed: What’s Visible from Here</title>
  <link>https://webgeods.com/posts/raster-viewshed.html</link>
  <description><![CDATA[ 




<p>Elevation alone answers “how high.” A viewshed answers a different question: from one point on the terrain, looking outward, which other points can you actually see? A hill in between blocks the view behind it the same way a building would: this is exactly the computation behind cell-tower placement, wind-turbine visual-impact studies, and “does this property really have the panoramic view listed.”</p>
<p>Every other article in this Raster family shows the same computation running live in Python <em>and</em> R, side by side, agreeing on the answer. This one doesn’t, not because the idea changed, but because the tooling did. Read on for why, and what it means for how this page is built.</p>
<p>Just want the map, without the rest of the reading? Use the <a href="../tools/viewshed-calculator.html">standalone tool</a> — same engine, click a point directly.</p>
<section id="upload-your-file" class="level2">
<h2 class="anchored" data-anchor-id="upload-your-file">1. Upload your file</h2>
<p>A <code>.tif</code>/<code>.tiff</code> digital elevation model (DEM), one band of elevation values.</p>
<div id="lab-upload">

</div>
</section>
<section id="why-viewshed-and-what-visible-actually-means-here" class="level2">
<h2 class="anchored" data-anchor-id="why-viewshed-and-what-visible-actually-means-here">2. Why viewshed, and what “visible” actually means here</h2>
<p>Four things worth knowing before the first computed map:</p>
<ul>
<li><strong>It’s occlusion, not distance.</strong> A cell right next to the observer can be invisible (hidden behind a ridge two cells away) while a cell much farther off is perfectly visible across open ground. The algorithm traces the line of sight to every other cell and checks whether the terrain in between rises above that line anywhere along the way.</li>
<li><strong>A working projection, not a correction.</strong> <code>terra::viewshed()</code> requires projected (linear-unit) coordinates. It refuses geographic lon/lat data outright, and rightly so: the algorithm reasons in real distances and angles, which degrees can’t give it directly. A geographic DEM (SRTM tiles, for instance, ship this way) is reprojected internally for the calculation, to a UTM zone picked from the raster’s own centre. The <strong>original file is never modified</strong>: this is a working projection for the computation, not a claim that webGeoDS found “the correct CRS.” A DEM spanning multiple UTM zones, sitting near the poles, or covering a very large area would need a deliberate projection choice instead; the automatic pick here is a reasonable default for a single local DEM, not a universal answer.</li>
<li><strong>“Visible” means visible <em>under this terrain model</em>, not “a human would see it.”</strong> The result accounts for terrain height, observer and target height above ground, and a curvature/refraction coefficient (<code>terra::viewshed()</code>’s <code>curvcoef</code>, default ≈0.857). It does not account for vegetation, buildings, or atmospheric haze. It answers “is the ground itself in the way,” a real and useful question, just not the complete one.</li>
<li><strong>The output is boolean, not a gradient.</strong> Every other computed map in this Raster family (Band Math, NDVI) is a continuous value shown on a color ramp. A viewshed is yes/no per cell: visible cells are highlighted, everything else is left transparent so the base map shows through underneath.</li>
</ul>
</section>
<section id="shared-map-and-result" class="level2">
<h2 class="anchored" data-anchor-id="shared-map-and-result">3. Shared map and result</h2>
<div id="lab-map" style="min-height: 500px;">

</div>
<div id="lab-result">

</div>
<div id="lab-legend">

</div>
<div id="lab-reset">

</div>
</section>
<section id="compute-viewshed-in-r-runnable-here-python-reference-only" class="level2">
<h2 class="anchored" data-anchor-id="compute-viewshed-in-r-runnable-here-python-reference-only">4. Compute: viewshed in R — runnable here; Python — reference only</h2>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">R — runnable in this browser</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">Python — reference (desktop/server)</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="viewshed-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("viewshed-r", {
    language: "r",
    initialCode: "library(terra)\n\npath <- NULL\nfor (candidate in c(\"/uploaded.tif\")) {\n  ok <- tryCatch({ rast(candidate); TRUE }, error = function(e) FALSE)\n  if (ok) { path <- candidate; break }\n}\n\nif (is.null(path)) {\n  # Synthetic hill DEM, deliberately in a GEOGRAPHIC CRS (EPSG:4326) --\n  # the case terra::viewshed() itself refuses to compute on directly\n  # (see section 2), so the fallback exercises the same reprojection\n  # path a real geographic DEM would.\n  n <- 20\n  xs <- 0:(n - 1)\n  ys <- 0:(n - 1)\n  elevation_vals <- outer(ys, xs, function(y, x) 100 + 40 * exp(-((x - 10)^2 + (y - 10)^2) / 30))\n  r <- rast(elevation_vals, crs = \"EPSG:4326\")\n  ext(r) <- c(12.40, 12.40 + n * 0.005, 41.95 - n * 0.005, 41.95)\n} else {\n  r <- rast(path)\n}\n\nelevation <- r[[1]]\n\nobserver_lon <- 12.45\nobserver_lat <- 41.90\nobserver_height <- 1.8\n\n# Same missing-CRS convention as the rest of the Raster family, but\n# here the stakes are higher than a display nuance: viewshed cannot\n# pick a working projection or place an observer without knowing\n# where on Earth the file actually is, so this refuses outright\n# rather than silently assuming WGS84 (try the no-CRS example in\n# section 6 to see this happen).\nif (crs(elevation) == \"\") {\n  stop(\"This file has no declared CRS — a real-world location is required to compute a viewshed (to choose a working projection and place the observer correctly). Upload a georeferenced DEM.\")\n}\n\noriginal_crs_label <- {\n  crs_desc <- crs(elevation, describe = TRUE)\n  if (!is.na(crs_desc$authority) && !is.na(crs_desc$code)) {\n    paste0(crs_desc$authority, \":\", crs_desc$code)\n  } else {\n    crs(elevation, proj = TRUE)\n  }\n}\n\n# Working/calculation CRS -- see section 2. The original raster (and\n# original_crs_label above) is never modified.\nif (is.lonlat(elevation)) {\n  e <- ext(elevation)\n  center_lon <- (e$xmin + e$xmax) / 2\n  center_lat <- (e$ymin + e$ymax) / 2\n  utm_zone <- floor((center_lon + 180) / 6) + 1\n  calc_epsg <- if (center_lat >= 0) 32600 + utm_zone else 32700 + utm_zone\n  elevation_calc <- project(elevation, paste0(\"EPSG:\", calc_epsg), method = \"bilinear\")\n} else {\n  elevation_calc <- elevation\n}\n\ncalc_crs_label <- {\n  crs_desc <- crs(elevation_calc, describe = TRUE)\n  if (!is.na(crs_desc$authority) && !is.na(crs_desc$code)) {\n    paste0(crs_desc$authority, \":\", crs_desc$code)\n  } else {\n    crs(elevation_calc, proj = TRUE)\n  }\n}\n\n# The observer arrives as lon/lat and is reprojected into the same\n# calculation CRS the raster itself is in.\nobs_pt <- vect(matrix(c(observer_lon, observer_lat), ncol = 2), crs = \"EPSG:4326\")\nobs_pt_calc <- project(obs_pt, crs(elevation_calc))\nobs_xy <- as.numeric(crds(obs_pt_calc)[1, ])\n\nvs <- viewshed(elevation_calc, loc = obs_xy, observer = observer_height, target = 0)\n\n# NaN renders transparent on the map, so encoding \"not visible\" as\n# NaN (instead of FALSE/0) means only the visible area actually\n# paints.\nvs_num <- as.numeric(values(vs, mat = FALSE))\nvisible_mask <- vs_num == 1\nvs_num[!visible_mask] <- NaN\nvs_num[visible_mask] <- 1\n\nn_visible <- sum(visible_mask, na.rm = TRUE)\nn_total <- length(vs_num)\n\ne_calc <- ext(elevation_calc)\nbbox_wkt <- paste0(\n  \"POLYGON((\",\n  e_calc$xmin, \" \", e_calc$ymin, \", \", e_calc$xmax, \" \", e_calc$ymin, \", \",\n  e_calc$xmax, \" \", e_calc$ymax, \", \", e_calc$xmin, \" \", e_calc$ymax, \", \",\n  e_calc$xmin, \" \", e_calc$ymin, \"))\"\n)\nbbox_vect <- vect(bbox_wkt, crs = crs(elevation_calc))\nbbox_wgs84 <- tryCatch(project(bbox_vect, \"EPSG:4326\"), error = function(err) bbox_vect)\nbbox_geom <- geom(bbox_wgs84)\nfootprint_coords <- lapply(seq_len(nrow(bbox_geom)), function(i) c(bbox_geom[i, \"x\"], bbox_geom[i, \"y\"]))\ne_wgs84 <- ext(bbox_wgs84)\n\nfootprint <- list(\n  type = jsonlite::unbox(\"FeatureCollection\"),\n  features = list(list(\n    type = jsonlite::unbox(\"Feature\"),\n    properties = list(name = jsonlite::unbox(\"viewshed footprint\")),\n    geometry = list(type = jsonlite::unbox(\"Polygon\"), coordinates = list(footprint_coords))\n  ))\n)\n\ncon <- rawConnection(raw(0), \"wb\")\nwriteBin(vs_num, con, size = 4, endian = \"little\")\nraw_bytes <- rawConnectionValue(con)\nclose(con)\ndata_b64 <- jsonlite::base64_enc(raw_bytes)\n\nsummary_out <- list(\n  width = jsonlite::unbox(ncol(vs)),\n  height = jsonlite::unbox(nrow(vs)),\n  data = jsonlite::unbox(data_b64),\n  wgs84Bounds = round(c(e_wgs84$xmin, e_wgs84$ymin, e_wgs84$xmax, e_wgs84$ymax), 6),\n  originalCrs = jsonlite::unbox(original_crs_label),\n  calculationCrs = jsonlite::unbox(calc_crs_label),\n  observerHeight = jsonlite::unbox(observer_height),\n  visiblePixels = jsonlite::unbox(n_visible),\n  totalPixels = jsonlite::unbox(n_total),\n  visiblePercent = jsonlite::unbox(round(100 * n_visible / n_total, 1))\n)\n\nresult_out <- list(summary = summary_out, footprint = footprint)\njsonlite::fromJSON(jsonlite::toJSON(result_out, null = \"null\"), simplifyVector = FALSE)",
    preload: { packages: ["terra","jsonlite","codetools"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["terra","jsonlite","codetools"] }));
    }
  });
});
</script>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div class="callout callout-style-default callout-warning callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Warning
</div>
</div>
<div class="callout-body-container callout-body">
<p><strong>Not runnable in this browser tool.</strong> This is valid Python/GDAL, the equivalent desktop/server workflow, shown for reference. See why below.</p>
</div>
</div>
<div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1"><span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">from</span> osgeo <span class="im" style="color: #00769E;
background-color: null;
font-style: inherit;">import</span> gdal</span>
<span id="cb1-2"></span>
<span id="cb1-3"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># Reference implementation only -- not executable in this browser</span></span>
<span id="cb1-4"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># tool. Exact keyword names can vary slightly by GDAL version; this</span></span>
<span id="cb1-5"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># matches the GDAL 3.x osgeo.gdal.ViewshedGenerate() API.</span></span>
<span id="cb1-6">src_ds <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> gdal.Open(<span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"dem.tif"</span>)</span>
<span id="cb1-7">src_band <span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span> src_ds.GetRasterBand(<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>)</span>
<span id="cb1-8"></span>
<span id="cb1-9">gdal.ViewshedGenerate(</span>
<span id="cb1-10">    srcBand<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>src_band,</span>
<span id="cb1-11">    driverName<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"GTiff"</span>,</span>
<span id="cb1-12">    targetRasterName<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"viewshed.tif"</span>,</span>
<span id="cb1-13">    creationOptions<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>[],</span>
<span id="cb1-14">    observerX<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">500100.0</span>,       <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># in the DEM's own (projected) CRS units</span></span>
<span id="cb1-15">    observerY<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">4600100.0</span>,</span>
<span id="cb1-16">    observerHeight<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">1.8</span>,</span>
<span id="cb1-17">    targetHeight<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">0.0</span>,</span>
<span id="cb1-18">    visibleVal<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">1.0</span>,</span>
<span id="cb1-19">    invisibleVal<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">0.0</span>,</span>
<span id="cb1-20">    outOfRangeVal<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">0.0</span>,</span>
<span id="cb1-21">    noDataVal<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=-</span><span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">1.0</span>,</span>
<span id="cb1-22">    dfCurvCoeff<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">0.85714</span>,       <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># same default terra::viewshed() uses</span></span>
<span id="cb1-23">    mode<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span>gdal.GVM_Edge,</span>
<span id="cb1-24">    maxDistance<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="fl" style="color: #AD0000;
background-color: null;
font-style: inherit;">0.0</span>,           <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># 0 = unlimited</span></span>
<span id="cb1-25">)</span></code></pre></div>
</div>
</div>
</div>
</section>
<section id="why-python-is-reference-only-here" class="level2">
<h2 class="anchored" data-anchor-id="why-python-is-reference-only-here">5. Why Python is reference-only here</h2>
<p>Every other article in this Raster family runs Python and R side by side and confirms they agree on the numbers. This one can’t, and it’s worth being precise about why rather than waving at “GDAL isn’t supported”:</p>
<p>The GDAL Python bindings expose <code>ViewshedGenerate()</code>, which provides the corresponding GDAL implementation: the same underlying algorithm <code>terra::viewshed()</code> uses on the R side. However, GDAL is not available in the curated Pyodide package index used by this browser runtime. Attempting to load it returns <code>No known package with name 'gdal'</code>, tested directly against this project, not assumed from general knowledge. <code>rasterio</code> itself, this site’s usual Python raster engine, has never had a viewshed function of its own; it’s a thin I/O layer over GDAL, not an analysis library. No actively-maintained pure-Python alternative exists either: the closest candidates (<code>whitebox</code>/WhiteboxTools, <code>richdem</code>) are a standalone compiled executable and a C++ extension respectively, neither usable inside Pyodide.</p>
<p>The Python example above shows the equivalent desktop/server workflow but is not executable in this browser tool.</p>
<p>The rest of this site’s implicit promise has been “Python and R, side by side.” This article refines that to “Python and R, when the browser runtime allows it”: a statement of what’s actually true, not an exception being quietly worked around. Forcing a from-scratch numpy reimplementation just to keep two live panels would have traded a real, tested algorithm for an untested one, on a page whose whole point is trustworthy answers.</p>
</section>
<section id="a-dem-with-no-declared-crs" class="level2">
<h2 class="anchored" data-anchor-id="a-dem-with-no-declared-crs">6. A DEM with no declared CRS</h2>
<p>Unlike the rest of the Raster family, this isn’t a cosmetic “Unknown ⚠️” row: the R cell above refuses to compute at all on a CRS-less file (<code>stop()</code>, not a graceful fallback), because a viewshed genuinely cannot be placed on Earth without knowing where the file already is. Click below to load a DEM with the CRS deliberately omitted, then run the R cell in section 4 to see the refusal itself, surfaced as a normal R error in the cell’s output.</p>
<div id="webgeods-example-1"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-1").appendChild(
    window.WebGeoDS.loadExampleButton("📋 Load a no-CRS example", { rId: "viewshed-r", rCode: "library(terra)\n\nn <- 20\nxs <- 0:(n - 1)\nys <- 0:(n - 1)\nelevation_vals <- outer(ys, xs, function(y, x) 100 + 40 * exp(-((x - 10)^2 + (y - 10)^2) / 30))\n# crs deliberately omitted -- the whole point of this example.\nr <- rast(elevation_vals)\next(r) <- c(12.40, 12.40 + n * 0.005, 41.95 - n * 0.005, 41.95)\n\nelevation <- r[[1]]\n\n# Unlike the other raster tools, a missing CRS is fatal here rather\n# than a cosmetic \"Unknown\" row: without a real-world location there\n# is no way to pick a working projection or place the observer, so\n# there is nothing meaningful left to compute.\nif (crs(elevation) == \"\") {\n  stop(\"This file has no declared CRS — a real-world location is required to compute a viewshed (to choose a working projection and place the observer correctly). Upload a georeferenced DEM.\")\n}\n\n\"unreachable -- the stop() above always fires for this example\"" }));
});
</script>
</section>
<section id="where-to-next" class="level2">
<h2 class="anchored" data-anchor-id="where-to-next">7. Where to next</h2>
<ul>
<li><strong>Just needed the map?</strong> The <a href="../tools/viewshed-calculator.html">standalone Viewshed Calculator</a> click a point, no editing required.</li>
<li><strong>Combining two bands instead</strong> (a difference map, NDVI)? See <a href="../posts/raster-band-math.html">Raster Band Math</a> or <a href="../posts/raster-ndvi.html">NDVI</a>: both run live in Python and R.</li>
<li><strong>Not sure what’s in the file yet?</strong> Start with <a href="../tools/raster-inspector.html">Raster Inspector</a>.</li>
</ul>
<p>This closes the Raster family at four tools (Inspect, Calculate, NDVI, Viewshed), covering understand, compute, analyze, model.</p>
<div id="lab-root"></div>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("raster-viewshed-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>

 ]]></description>
  <category>tool</category>
  <category>raster</category>
  <category>bilingual</category>
  <guid>https://webgeods.com/posts/raster-viewshed.html</guid>
  <pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>First Look at a Raster File: Dimensions, Bands, and NoData</title>
  <link>https://webgeods.com/posts/raster-file-inspection.html</link>
  <description><![CDATA[ 




<p>A vector file’s first look answers “how many features, what shape, what CRS.” A raster’s is a different set of numbers entirely: how big is the grid, how many bands does each pixel carry, what does one pixel cover on the ground, and which value means “no data here” rather than a real measurement.</p>
<p>Get those wrong and everything downstream is wrong too: a slope calculation that treats -9999 as a very steep cliff, a mean elevation dragged down by a border of NoData pixels, a band mistaken for degrees Celsius when it’s really reflectance times 10,000.</p>
<p>Just want the summary, without the rest of the reading? Use the <a href="../tools/raster-inspector.html">standalone tool</a> — same engine, less prose.</p>
<section id="upload-your-file" class="level2">
<h2 class="anchored" data-anchor-id="upload-your-file">1. Upload your file</h2>
<p>A single <code>.tif</code> or <code>.tiff</code>, no sidecars, unlike a shapefile.</p>
<div id="lab-upload">

</div>
</section>
<section id="why-start-with-a-summary" class="level2">
<h2 class="anchored" data-anchor-id="why-start-with-a-summary">2. Why start with a summary?</h2>
<p>Eight numbers, each answering a question that’s otherwise easy to get wrong by assumption:</p>
<ul>
<li><strong>Dimensions</strong>: width × height in pixels. Determines how much work any pixel-by-pixel operation actually is, and whether a “small” file is small in bytes but enormous in pixel count (or the reverse).</li>
<li><strong>Bands</strong>: one grid of numbers, or several stacked together (RGB, a multispectral stack, a time series). Some checks and most visualizations only make sense once you know how many there are.</li>
<li><strong>CRS</strong>: same coordinate reference system concept as a vector file. A missing CRS is reported as genuinely unknown, not silently relabeled as WGS84. The map still needs <em>some</em> projection to place the footprint, so that assumption is shown separately, labeled as a display choice rather than folded into the CRS field itself.</li>
<li><strong>Resolution</strong>: the ground distance one pixel actually covers, in the file’s own CRS units. A 30 (Landsat-scale) versus a 0.001 (sub-meter) changes what questions the data can even answer.</li>
<li><strong>Data type</strong>: <code>uint8</code>, <code>int16</code>, <code>float32</code>, … . The single most common source of a “why are all my values 0 or wildly wrong” surprise: an integer type storing values that were meant to be divided by a scale factor, or a float column silently truncated by being written back as an integer type.</li>
<li><strong>NoData</strong>: the sentinel value marking “no measurement here” (often <code>-9999</code>, <code>0</code>, or a type’s max value), as fundamental to a raster as a null is to a table column, and just as easy to accidentally treat as a real number if it isn’t checked first.</li>
<li><strong>Bounds</strong>: the same bounding-box sanity check as a vector file: numbers wildly outside -180..180 / -90..90 are the CRS-mismatch tell, here too.</li>
<li><strong>Value range</strong>: the per-band min/max/mean, with NoData pixels correctly excluded. The fastest way to catch a unit mistake (a “temperature in Celsius” band whose values run from 200 to 320 is actually Kelvin) before it reaches an analysis.</li>
</ul>
</section>
<section id="shared-map-and-summary" class="level2">
<h2 class="anchored" data-anchor-id="shared-map-and-summary">3. Shared map and summary</h2>
<div id="lab-map" style="min-height: 500px;">

</div>
<div id="lab-stats">

</div>
<div id="lab-reset">

</div>
</section>
<section id="inspect-summarize-your-file" class="level2">
<h2 class="anchored" data-anchor-id="inspect-summarize-your-file">4. Inspect: summarize your file</h2>
<p>Run the same summary in Python or R on the current data (your file uploaded above, or the built-in example below).</p>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="raster-inspect-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("raster-inspect-py", {
    language: "python",
    initialCode: "import rasterio\nfrom rasterio.transform import from_origin\nfrom rasterio.io import MemoryFile\nfrom rasterio.warp import transform_bounds\nimport numpy as np\n\npath = None\nfor candidate in [\"/uploaded.tif\"]:\n    try:\n        with rasterio.open(candidate):\n            path = candidate\n            break\n    except Exception:\n        pass\n\n# No file uploaded yet: a small synthetic hill built directly in\n# memory (rasterio.io.MemoryFile — no virtual-filesystem path needed\n# for a fallback), with one deliberately-NoData corner pixel so the\n# NoData row below has something real to show.\nmemfile = None\nif path is None:\n    n = 20\n    xs = np.arange(n)\n    ys = np.arange(n)\n    X, Y = np.meshgrid(xs, ys)\n    elevation = (100 + 40 * np.exp(-((X - 10) ** 2 + (Y - 10) ** 2) / 30)).astype(\"float32\")\n    elevation[0, 0] = -9999\n    transform = from_origin(12.40, 41.95, 0.005, 0.005)\n    memfile = MemoryFile()\n    with memfile.open(\n        driver=\"GTiff\", height=n, width=n, count=1,\n        dtype=\"float32\", crs=\"EPSG:4326\", transform=transform, nodata=-9999\n    ) as dst:\n        dst.write(elevation, 1)\n    path = memfile.name\n\nwith rasterio.open(path) as src:\n    # A missing CRS is reported as genuinely unknown, not silently\n    # relabeled as WGS84 — \"no CRS\" and \"WGS84\" are different facts,\n    # and blurring them defeats the point of a tool whose whole job is\n    # telling you what's actually true about the file. EPSG:4326 is\n    # still used internally (crs_for_transform) to place the footprint\n    # on the map — that's a display convenience, reported separately\n    # below as display_crs_assumption, never folded into the \"crs\"\n    # field itself.\n    crs_warning = None\n    display_crs_assumption = None\n    if src.crs is not None:\n        crs_for_transform = src.crs\n        crs_label = str(src.crs)\n    else:\n        crs_for_transform = \"EPSG:4326\"\n        crs_label = None\n        crs_warning = \"No CRS declared — spatial location cannot be reliably interpreted.\"\n        display_crs_assumption = \"EPSG:4326\"\n\n    # Per-band min/max/mean, NoData pixels excluded via masked=True —\n    # a band that's entirely NoData reports nulls instead of a bogus\n    # min/max computed from an empty array.\n    band_stats = []\n    for i in range(1, src.count + 1):\n        band = src.read(i, masked=True)\n        has_valid = band.count() > 0\n        band_stats.append({\n            \"band\": i,\n            \"min\": round(float(band.min()), 4) if has_valid else None,\n            \"max\": round(float(band.max()), 4) if has_valid else None,\n            \"mean\": round(float(band.mean()), 4) if has_valid else None,\n        })\n\n    bounds = [round(float(b), 6) for b in src.bounds]\n    try:\n        wgs84_bounds = transform_bounds(crs_for_transform, \"EPSG:4326\", *src.bounds)\n    except Exception:\n        wgs84_bounds = src.bounds\n\n    minx, miny, maxx, maxy = wgs84_bounds\n    footprint = {\n        \"type\": \"FeatureCollection\",\n        \"features\": [{\n            \"type\": \"Feature\",\n            \"properties\": {\"name\": \"raster footprint\"},\n            \"geometry\": {\n                \"type\": \"Polygon\",\n                \"coordinates\": [[[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy], [minx, miny]]]\n            }\n        }]\n    }\n\n    summary = {\n        \"width\": src.width,\n        \"height\": src.height,\n        \"bandCount\": src.count,\n        \"crs\": crs_label,\n        \"crsWarning\": crs_warning,\n        \"displayCrsAssumption\": display_crs_assumption,\n        \"nodata\": src.nodata,\n        \"resolution\": [round(float(r), 6) for r in src.res],\n        \"dtype\": src.dtypes[0],\n        \"bounds\": bounds,\n        \"bandStats\": band_stats,\n    }\n\nif memfile is not None:\n    memfile.close()\n\n{\n    \"summary\": summary,\n    \"footprint\": footprint\n}",
    preload: { packages: ["rasterio","numpy"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, { packages: ["rasterio","numpy"] }));
    }
  });
});
</script>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div id="raster-inspect-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("raster-inspect-r", {
    language: "r",
    initialCode: "library(terra)\n\npath <- NULL\nfor (candidate in c(\"/uploaded.tif\")) {\n  ok <- tryCatch({ rast(candidate); TRUE }, error = function(e) FALSE)\n  if (ok) { path <- candidate; break }\n}\n\nif (is.null(path)) {\n  # Same synthetic hill as the Python fallback — built directly as a\n  # SpatRaster from a plain matrix via rast(), with the extent set\n  # explicitly afterward, rather than through a file: no virtual\n  # filesystem needed for this side either.\n  n <- 20\n  xs <- 0:(n - 1)\n  ys <- 0:(n - 1)\n  elevation <- outer(ys, xs, function(y, x) 100 + 40 * exp(-((x - 10)^2 + (y - 10)^2) / 30))\n  elevation[1, 1] <- NA\n  r <- rast(elevation, crs = \"EPSG:4326\")\n  ext(r) <- c(12.40, 12.40 + n * 0.005, 41.95 - n * 0.005, 41.95)\n} else {\n  r <- rast(path)\n}\n\n# A missing CRS is reported as genuinely unknown, not silently\n# relabeled as WGS84. terra reports a missing CRS as an empty string,\n# not NA — checked directly\n# rather than through an sf-style is.na() test (this cell no longer\n# depends on sf at all, see the footprint construction below).\n# was_missing_crs is captured BEFORE crs(r) is mutated below, since\n# that mutation is only there to give the footprint reprojection\n# something to work with — the label itself must stay NULL regardless\n# of what crs(r) reports afterward.\nwas_missing_crs <- crs(r) == \"\"\ncrs_warning <- NULL\ndisplay_crs_assumption <- NULL\nif (was_missing_crs) {\n  crs_warning <- \"No CRS declared — spatial location cannot be reliably interpreted.\"\n  display_crs_assumption <- \"EPSG:4326\"\n  crs(r) <- \"EPSG:4326\"\n}\n\n# crs(r, describe = TRUE) resolves to an EPSG code when the CRS is one\n# GDAL recognizes (the common case); crs(r, proj = TRUE) is the\n# fallback for anything else. See section 5 — this is one of the\n# genuine format divergences from Python's str(src.crs). Skipped\n# entirely when the CRS was missing — describing the EPSG:4326 just\n# assigned above would misreport it as the file's own CRS.\ncrs_label <- if (was_missing_crs) {\n  NULL\n} else {\n  crs_desc <- crs(r, describe = TRUE)\n  if (!is.na(crs_desc$authority) && !is.na(crs_desc$code)) {\n    paste0(crs_desc$authority, \":\", crs_desc$code)\n  } else {\n    crs(r, proj = TRUE)\n  }\n}\n\nband_count <- nlyr(r)\nres_xy <- res(r)\ne <- ext(r)\nbounds <- c(e$xmin, e$ymin, e$xmax, e$ymax)\n\n# jsonlite::toJSON() treats every length-1 vector as a single-element\n# array by default -- a genuine scalar (a width, a CRS string) needs\n# an explicit jsonlite::unbox() to serialize as one. ub()/ub_if()\n# below are just that, plus a NULL passthrough for optional fields.\n# Safer than toJSON(auto_unbox = TRUE), which guesses from length\n# alone and can silently flatten a real one-item array into a bare\n# scalar.\nub <- function(x) if (is.null(x)) NULL else jsonlite::unbox(x)\nub_if <- function(cond, x) if (cond) jsonlite::unbox(x) else NULL\n\n# global(..., na.rm = TRUE) computes per-band stats directly — terra\n# reads the file's declared NoData value and excludes those pixels\n# automatically (confirmed: a -9999 sentinel pixel never pollutes the\n# min below), the same way rasterio's masked=True does. Unlike\n# rasterio, though, the sentinel value itself isn't exposed anywhere\n# afterward either — see section 5.\nstats <- global(r, c(\"min\", \"max\", \"mean\"), na.rm = TRUE)\nband_stats <- lapply(seq_len(band_count), function(i) {\n  has_valid <- is.finite(stats$min[i])\n  list(\n    band = jsonlite::unbox(i),\n    min = ub_if(has_valid, round(stats$min[i], 4)),\n    max = ub_if(has_valid, round(stats$max[i], 4)),\n    mean = ub_if(has_valid, round(stats$mean[i], 4))\n  )\n})\n\n# MapLibre/GeoJSON require WGS84 — the bounding box is reprojected via\n# terra's own vect()/project() (no sf needed), while the summary's own\n# \"bounds\" field stays in the file's native CRS. geom() reads the\n# reprojected polygon's coordinates back out as a plain matrix.\nbbox_wkt <- paste0(\n  \"POLYGON((\",\n  e$xmin, \" \", e$ymin, \", \", e$xmax, \" \", e$ymin, \", \",\n  e$xmax, \" \", e$ymax, \", \", e$xmin, \" \", e$ymax, \", \",\n  e$xmin, \" \", e$ymin, \"))\"\n)\nbbox_vect <- vect(bbox_wkt, crs = crs(r))\nbbox_wgs84 <- tryCatch(project(bbox_vect, \"EPSG:4326\"), error = function(err) bbox_vect)\nbbox_geom <- geom(bbox_wgs84)\nfootprint_coords <- lapply(seq_len(nrow(bbox_geom)), function(i) c(bbox_geom[i, \"x\"], bbox_geom[i, \"y\"]))\n\nfootprint <- list(\n  type = jsonlite::unbox(\"FeatureCollection\"),\n  features = list(list(\n    type = jsonlite::unbox(\"Feature\"),\n    properties = list(name = jsonlite::unbox(\"raster footprint\")),\n    geometry = list(\n      type = jsonlite::unbox(\"Polygon\"),\n      coordinates = list(footprint_coords)\n    )\n  ))\n)\n\nsummary_out <- list(\n  width = jsonlite::unbox(ncol(r)),\n  height = jsonlite::unbox(nrow(r)),\n  bandCount = jsonlite::unbox(band_count),\n  crs = ub(crs_label),\n  crsWarning = ub(crs_warning),\n  displayCrsAssumption = ub(display_crs_assumption),\n  nodata = NULL,\n  resolution = c(round(res_xy[1], 6), round(res_xy[2], 6)),\n  # datatype(r) returns one type PER BAND (terra allows different bands\n  # to carry different types, even if real files rarely do) -- a plain\n  # unbox() on a multi-band raster throws \"Tried to unbox a vector of\n  # length N\". [1] takes the first band's type, same convention as\n  # rasterio's own src.dtypes[0] on the Python side.\n  dtype = jsonlite::unbox(datatype(r)[1]),\n  bounds = unname(round(bounds, 6)),\n  bandStats = band_stats\n)\n\n# Round-tripped through JSON once more, whole-object: R's atomic\n# vectors don't serialize to plain JSON arrays on their own the way a\n# jsonlite-parsed list does.\nresult <- list(summary = summary_out, footprint = footprint)\njsonlite::fromJSON(jsonlite::toJSON(result, null = \"null\"), simplifyVector = FALSE)",
    preload: { packages: ["terra","jsonlite","codetools"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["terra","jsonlite","codetools"] }));
    }
  });
});
</script>
</div>
</div>
</div>
</section>
<section id="same-spatial-question-two-languages" class="level2 page-columns page-full">
<h2 class="anchored" data-anchor-id="same-spatial-question-two-languages">5. Same spatial question, two languages</h2>
<p>Python and R read the same file through the same underlying GDAL, but the two engines disagree in ways a vector file’s <code>sf</code>/GeoPandas split never surfaces:</p>
<p><strong>Python</strong> (rasterio)</p>
<div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1">src.width, src.height     <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># dimensions</span></span>
<span id="cb1-2">src.count                 <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># band count</span></span>
<span id="cb1-3">src.crs                   <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># CRS</span></span>
<span id="cb1-4">src.res                   <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># pixel resolution</span></span>
<span id="cb1-5">src.dtypes[<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">0</span>]              <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># data type, as declared in the file</span></span>
<span id="cb1-6">src.nodata                 <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># the NoData sentinel value itself</span></span>
<span id="cb1-7">src.read(i, masked<span class="op" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">=</span><span class="va" style="color: #111111;
background-color: null;
font-style: inherit;">True</span>)  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># one band, NoData pixels masked out</span></span></code></pre></div>
<p><strong>R</strong> (terra)</p>
<div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode r code-with-copy"><code class="sourceCode r"><span id="cb2-1"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">ncol</span>(r), <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">nrow</span>(r), <span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">nlyr</span>(r)   <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># dimensions + band count</span></span>
<span id="cb2-2"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">crs</span>(r, <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">describe =</span> <span class="cn" style="color: #8f5902;
background-color: null;
font-style: inherit;">TRUE</span>)     <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># CRS, resolved to an EPSG code when GDAL recognizes one</span></span>
<span id="cb2-3"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">res</span>(r)                      <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># pixel resolution</span></span>
<span id="cb2-4"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">datatype</span>(r)[<span class="dv" style="color: #AD0000;
background-color: null;
font-style: inherit;">1</span>]               <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># terra's own type code, one per band -- [1] mirrors dtypes[0]</span></span>
<span id="cb2-5"><span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># no direct equivalent — see below</span></span>
<span id="cb2-6"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">global</span>(r, <span class="st" style="color: #20794D;
background-color: null;
font-style: inherit;">"mean"</span>, <span class="at" style="color: #657422;
background-color: null;
font-style: inherit;">na.rm =</span> <span class="cn" style="color: #8f5902;
background-color: null;
font-style: inherit;">TRUE</span>)  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># NoData pixels already excluded</span></span></code></pre></div>
<p>Three genuine divergences, not just naming differences:</p>
<ul>
<li><strong>CRS formatting</strong>: same issue as the vector article’s <code>st_crs()</code> vs GeoPandas footnote. When GDAL can resolve the file’s CRS to a known EPSG code, <code>crs(r, describe = TRUE)</code> gives exactly that (<code>"EPSG:4326"</code>, matching Python here), but for anything it can’t match to a registry entry, the fallback is a <code>+proj=...</code> string, a different format entirely from <code>str(src.crs)</code>.<sup>1</sup></li>
<li><strong>Data type</strong>: closer to Python than <code>stars</code> was. <code>datatype(r)</code> does reflect the file’s actual storage type, just under terra’s own naming convention (<code>"FLT4S"</code> for a 4-byte signed float, <code>"INT2U"</code> for an unsigned 16-bit integer, …) rather than GDAL’s own descriptive names (<code>"float32"</code>, <code>"uint16"</code>) the way rasterio’s <code>dtypes[0]</code> reports them. A real format difference, not a loss of information the way it was with <code>stars</code>, though <code>datatype(r)</code> itself returns one type <em>per band</em> (terra allows different bands to carry different types), so both sides here report only the first band’s type, <code>dtypes[0]</code> and <code>datatype(r)[1]</code> respectively.</li>
<li><strong>NoData retention</strong>: <code>rast()</code> reads the file’s declared NoData value and excludes those pixels automatically wherever a reduction is computed (confirmed: a <code>-9999</code> sentinel pixel never pollutes <code>global(..., "min")</code> above), the same way rasterio’s <code>masked=True</code> does, but the sentinel value itself isn’t exposed anywhere afterward. rasterio keeps it directly on <code>src.nodata</code>; terra doesn’t have an equally direct equivalent for a file already open. That’s why the R card’s “NoData” row never shows anything but “None declared,” even on a file that clearly has one: it isn’t wrong, the exclusion already happened, there’s just no simple accessor left to report the raw number by.</li>
</ul>
<div class="no-row-height column-margin column-container"><div id="fn1"><p><sup>1</sup>&nbsp;A GeoTIFF’s own embedded CRS often resolves to a human-readable name in GDAL’s metadata (e.g.&nbsp;<code>"WGS 84"</code>), which <code>crs(r, describe = TRUE)</code> still maps back to its EPSG code when one exists, while an unusual or locally-defined CRS with no registry match falls back to a PROJ string on the R side, with no equivalent fallback needed on Python’s, since <code>str(src.crs)</code> already prints whatever GDAL gave it either way. Worth checking which form you’re looking at before assuming a mismatch means an actual problem.</p></div></div></section>
<section id="a-raster-with-no-declared-crs" class="level2 page-columns page-full">
<h2 class="anchored" data-anchor-id="a-raster-with-no-declared-crs">6. A raster with no declared CRS</h2>
<p>The reusable cell pair above already builds a small synthetic hill with one NoData pixel whenever nothing has been uploaded. Click <strong>▶ Run</strong> on both languages in section 4 to see it. This button swaps in the same raster, minus the CRS, to see the CRS row switch to “Unknown” and a separate “Display assumption” row appear below it.<sup>2</sup></p>
<div class="no-row-height column-margin column-container"><div id="fn2"><p><sup>2</sup>&nbsp;An unreferenced raster isn’t rare in practice: a sensor’s raw output, or a file exported by a tool that never wrote a CRS tag at all. Nothing about the pixel <em>values</em> is wrong in that case; only where they sit on the earth is unknown until someone supplies it, which is exactly the situation the warning exists to flag before a map silently plots it in the wrong place (or, for <code>sf</code>/GeoPandas, refuses to reproject it at all).</p></div></div><div id="webgeods-example-1"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-1").appendChild(
    window.WebGeoDS.loadExampleButton("📋 Load a no-CRS example", { pyId: "raster-inspect-py", pyCode: "import rasterio\nfrom rasterio.transform import from_origin\nfrom rasterio.io import MemoryFile\nimport numpy as np\n\nn = 20\nxs = np.arange(n)\nys = np.arange(n)\nX, Y = np.meshgrid(xs, ys)\nelevation = (100 + 40 * np.exp(-((X - 10) ** 2 + (Y - 10) ** 2) / 30)).astype(\"float32\")\nelevation[0, 0] = -9999\ntransform = from_origin(12.40, 41.95, 0.005, 0.005)\n\n# crs deliberately omitted — the whole point of this example.\nmemfile = MemoryFile()\nwith memfile.open(\n    driver=\"GTiff\", height=n, width=n, count=1,\n    dtype=\"float32\", transform=transform, nodata=-9999\n) as dst:\n    dst.write(elevation, 1)\n\nwith rasterio.open(memfile.name) as src:\n    # The point of this example: no CRS was written above, so \"crs\"\n    # stays unknown instead of being quietly relabeled as WGS84, and\n    # the WGS84 used to place the footprint is reported separately as\n    # a display assumption.\n    crs_warning = None\n    display_crs_assumption = None\n    if src.crs is not None:\n        crs_label = str(src.crs)\n    else:\n        crs_label = None\n        crs_warning = \"No CRS declared — spatial location cannot be reliably interpreted.\"\n        display_crs_assumption = \"EPSG:4326\"\n\n    # masked=True excludes the NoData pixels from min/max/mean, so the\n    # -9999 sentinel written into one corner never reaches the stats.\n    band_stats = []\n    for i in range(1, src.count + 1):\n        band = src.read(i, masked=True)\n        has_valid = band.count() > 0\n        band_stats.append({\n            \"band\": i,\n            \"min\": round(float(band.min()), 4) if has_valid else None,\n            \"max\": round(float(band.max()), 4) if has_valid else None,\n            \"mean\": round(float(band.mean()), 4) if has_valid else None,\n        })\n\n    minx, miny, maxx, maxy = src.bounds\n    footprint = {\n        \"type\": \"FeatureCollection\",\n        \"features\": [{\n            \"type\": \"Feature\",\n            \"properties\": {\"name\": \"raster footprint\"},\n            \"geometry\": {\n                \"type\": \"Polygon\",\n                \"coordinates\": [[[minx, miny], [maxx, miny], [maxx, maxy], [minx, maxy], [minx, miny]]]\n            }\n        }]\n    }\n\n    summary = {\n        \"width\": src.width, \"height\": src.height, \"bandCount\": src.count,\n        \"crs\": crs_label, \"crsWarning\": crs_warning,\n        \"displayCrsAssumption\": display_crs_assumption, \"nodata\": src.nodata,\n        \"resolution\": [round(float(r), 6) for r in src.res], \"dtype\": src.dtypes[0],\n        \"bounds\": [round(float(b), 6) for b in src.bounds], \"bandStats\": band_stats,\n    }\n\nmemfile.close()\n\n{\"summary\": summary, \"footprint\": footprint}", rId: "raster-inspect-r", rCode: "library(terra)\n\nn <- 20\nxs <- 0:(n - 1)\nys <- 0:(n - 1)\nelevation <- outer(ys, xs, function(y, x) 100 + 40 * exp(-((x - 10)^2 + (y - 10)^2) / 30))\nelevation[1, 1] <- NA\n# crs deliberately omitted from rast() -- the whole point of this example.\nr <- rast(elevation)\next(r) <- c(12.40, 12.40 + n * 0.005, 41.95 - n * 0.005, 41.95)\n\n# The point of this example: terra reports the missing CRS as an empty\n# string, captured here BEFORE the WGS84 below is assigned (that one\n# exists only so the footprint can be placed on the map). The label\n# itself stays NULL, so \"unknown\" is never quietly relabeled.\nwas_missing_crs <- crs(r) == \"\"\ncrs_warning <- NULL\ndisplay_crs_assumption <- NULL\nif (was_missing_crs) {\n  crs_warning <- \"No CRS declared — spatial location cannot be reliably interpreted.\"\n  display_crs_assumption <- \"EPSG:4326\"\n  crs(r) <- \"EPSG:4326\"\n}\ncrs_label <- if (was_missing_crs) {\n  NULL\n} else {\n  crs_desc <- crs(r, describe = TRUE)\n  if (!is.na(crs_desc$authority) && !is.na(crs_desc$code)) {\n    paste0(crs_desc$authority, \":\", crs_desc$code)\n  } else {\n    crs(r, proj = TRUE)\n  }\n}\n\nres_xy <- res(r)\ne <- ext(r)\nbounds <- c(e$xmin, e$ymin, e$xmax, e$ymax)\n\n# na.rm = TRUE excludes the NA cell written into one corner above, so\n# it never reaches the reported min/max/mean.\nstats <- global(r, c(\"min\", \"max\", \"mean\"), na.rm = TRUE)\nband_stats <- list(list(\n  band = jsonlite::unbox(1),\n  min = jsonlite::unbox(round(stats$min[1], 4)),\n  max = jsonlite::unbox(round(stats$max[1], 4)),\n  mean = jsonlite::unbox(round(stats$mean[1], 4))\n))\n\nbbox_wkt <- paste0(\n  \"POLYGON((\",\n  e$xmin, \" \", e$ymin, \", \", e$xmax, \" \", e$ymin, \", \",\n  e$xmax, \" \", e$ymax, \", \", e$xmin, \" \", e$ymax, \", \",\n  e$xmin, \" \", e$ymin, \"))\"\n)\nbbox_vect <- vect(bbox_wkt, crs = crs(r))\nbbox_wgs84 <- tryCatch(project(bbox_vect, \"EPSG:4326\"), error = function(err) bbox_vect)\nbbox_geom <- geom(bbox_wgs84)\nfootprint_coords <- lapply(seq_len(nrow(bbox_geom)), function(i) c(bbox_geom[i, \"x\"], bbox_geom[i, \"y\"]))\n\nfootprint <- list(\n  type = jsonlite::unbox(\"FeatureCollection\"),\n  features = list(list(\n    type = jsonlite::unbox(\"Feature\"),\n    properties = list(name = jsonlite::unbox(\"raster footprint\")),\n    geometry = list(\n      type = jsonlite::unbox(\"Polygon\"),\n      coordinates = list(footprint_coords)\n    )\n  ))\n)\n\nsummary_out <- list(\n  width = jsonlite::unbox(ncol(r)), height = jsonlite::unbox(nrow(r)), bandCount = jsonlite::unbox(1),\n  crs = NULL, crsWarning = jsonlite::unbox(crs_warning),\n  displayCrsAssumption = jsonlite::unbox(display_crs_assumption), nodata = NULL,\n  resolution = c(round(res_xy[1], 6), round(res_xy[2], 6)), dtype = jsonlite::unbox(datatype(r)[1]),\n  bounds = unname(round(bounds, 6)), bandStats = band_stats\n)\n\nresult <- list(summary = summary_out, footprint = footprint)\njsonlite::fromJSON(jsonlite::toJSON(result, null = \"null\"), simplifyVector = FALSE)" }));
});
</script>
</section>
<section id="where-to-next" class="level2">
<h2 class="anchored" data-anchor-id="where-to-next">7. Where to next</h2>
<p>A summary is a starting point:</p>
<ul>
<li><strong>Just needed the numbers?</strong> The <a href="../tools/raster-inspector.html">standalone Raster Inspector</a> has the same summary, no reading required.</li>
<li><strong>Working with vector data instead?</strong> Start with <a href="../posts/geospatial-file-inspection.html">First Look at a Geospatial File</a>, the same idea, for GeoJSON and Shapefile.</li>
<li><strong>Need to combine two bands into a result</strong> (a difference map, a Normalized Difference like NDVI)? See <a href="../posts/raster-band-math.html">Raster Band Math</a>, or the standalone <a href="../tools/raster-calculator.html">Raster Calculator</a>.</li>
</ul>
<div id="lab-root"></div>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("raster-file-inspection-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>


 ]]></description>
  <category>tool</category>
  <category>raster</category>
  <category>bilingual</category>
  <guid>https://webgeods.com/posts/raster-file-inspection.html</guid>
  <pubDate>Sun, 06 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Coordinate Reference Systems: Why Your Map Looks Wrong</title>
  <link>https://webgeods.com/posts/crs-mismatch.html</link>
  <description><![CDATA[ 




<p>Before a geometry can be invalid, or two features can overlap, a file has to actually be <em>where it says it is</em>. A coordinate reference system (CRS) is what makes a pair of numbers mean a specific place on Earth, and it’s easy to get wrong in a way that neither <a href="../posts/geometry-validity.html">geometry validity</a> nor <a href="../posts/topology-errors.html">topology checks</a> would ever catch, because both of those only look at the shape, never at where it actually sits.</p>
<p>Just want to quickly check a file, without the rest of the reading? Use the <a href="../tools/crs-inspector.html">standalone tool</a> — same engine, less prose.</p>
<section id="upload-your-file" class="level2">
<h2 class="anchored" data-anchor-id="upload-your-file">1. Upload your file</h2>
<p>A <code>.geojson</code>, or a shapefile: either as a single <code>.zip</code>, or as the individual <code>.shp</code>/<code>.dbf</code>/<code>.shx</code> files (and optionally <code>.prj</code>) selected together.</p>
<div id="lab-upload">

</div>
</section>
<section id="why-a-crs-problem-is-different" class="level2">
<h2 class="anchored" data-anchor-id="why-a-crs-problem-is-different">2. Why a CRS problem is different</h2>
<p>A geometry can be perfectly valid, and every feature can be perfectly consistent with every other, and the whole file can still land thousands of kilometers from where it should. GeoJSON, and MapLibre, the map library this whole site draws with, assume every coordinate is WGS84 (plain longitude/latitude). Neither reprojects anything on its own: whatever numbers you hand them, that’s where they land.</p>
<ul>
<li><strong>CRS</strong>: the coordinate reference system a file <em>declares</em> it’s in, read from its own metadata (a <code>.prj</code> file for a shapefile, a <code>crs</code> object for GeoPandas/<code>sf</code>). Some files declare none at all.</li>
<li><strong>Geographic vs.&nbsp;projected</strong>: a geographic CRS (WGS84 is the common one) uses degrees, roughly -180..180 longitude and -90..90 latitude. A projected CRS (UTM zones, Web Mercator, national grids) uses linear units, usually meters, and the numbers involved are routinely in the hundreds of thousands or millions.</li>
<li><strong>The mismatch</strong>: a file can declare one kind of CRS while its actual coordinate values look like the other. That disagreement is the single most common reason a map “looks wrong” before you’ve touched a single geometry.</li>
</ul>
<div>
<svg viewbox="0 0 460 200" style="max-width: 460px; height: auto; display: block; margin: 1.5rem auto;" aria-label="The same point shown two ways: small numbers in a degrees grid on the left, numbers in the millions in a meters grid on the right">
<rect x="1" y="1" width="210" height="178" fill="#f3ede1" stroke="#5a4f3e" stroke-width="1"></rect> <line x1="1" y1="45" x2="211" y2="45" stroke="#ddd1b8" stroke-width="1"></line> <line x1="1" y1="90" x2="211" y2="90" stroke="#ddd1b8" stroke-width="1"></line> <line x1="1" y1="135" x2="211" y2="135" stroke="#ddd1b8" stroke-width="1"></line> <line x1="53" y1="1" x2="53" y2="179" stroke="#ddd1b8" stroke-width="1"></line> <line x1="106" y1="1" x2="106" y2="179" stroke="#ddd1b8" stroke-width="1"></line> <line x1="159" y1="1" x2="159" y2="179" stroke="#ddd1b8" stroke-width="1"></line> <circle cx="106" cy="90" r="6" fill="#42583c" stroke="#2a2117" stroke-width="1.5"></circle> <text x="106" y="20" text-anchor="middle" font-family="monospace" font-size="12" fill="#2a2117">12.5, 41.9</text> <text x="106" y="165" text-anchor="middle" font-family="monospace" font-size="11" fill="#5a4f3e">geographic: degrees</text> <rect x="250" y="1" width="210" height="178" fill="#f3ede1" stroke="#5a4f3e" stroke-width="1"></rect> <line x1="250" y1="45" x2="460" y2="45" stroke="#ddd1b8" stroke-width="1"></line> <line x1="250" y1="90" x2="460" y2="90" stroke="#ddd1b8" stroke-width="1"></line> <line x1="250" y1="135" x2="460" y2="135" stroke="#ddd1b8" stroke-width="1"></line> <line x1="302" y1="1" x2="302" y2="179" stroke="#ddd1b8" stroke-width="1"></line> <line x1="355" y1="1" x2="355" y2="179" stroke="#ddd1b8" stroke-width="1"></line> <line x1="408" y1="1" x2="408" y2="179" stroke="#ddd1b8" stroke-width="1"></line> <circle cx="355" cy="90" r="6" fill="#42583c" stroke="#2a2117" stroke-width="1.5"></circle> <text x="355" y="15" text-anchor="middle" font-family="monospace" font-size="10" fill="#2a2117">1,391,000</text> <text x="355" y="27" text-anchor="middle" font-family="monospace" font-size="10" fill="#2a2117">5,140,000</text> <text x="355" y="165" text-anchor="middle" font-family="monospace" font-size="11" fill="#5a4f3e">projected: meters</text>
</svg>
</div>
</section>
<section id="shared-map-and-detection" class="level2">
<h2 class="anchored" data-anchor-id="shared-map-and-detection">3. Shared map and detection</h2>
<div id="lab-map" style="min-height: 500px;">

</div>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="crs-diagnose-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("crs-diagnose-py", {
    language: "python",
    initialCode: "import geopandas as gpd\nfrom shapely.geometry import Polygon\n\ngdf = None\nfor path in [\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\"]:\n    try:\n        gdf = gpd.read_file(path)\n        break\n    except Exception:\n        pass\n\nif gdf is None:\n    # Deliberately mismatched: no CRS declared, so GeoPandas assumes\n    # WGS84 (per the GeoJSON spec) — but these coordinates are Web\n    # Mercator magnitude, not degrees. That disagreement IS the\n    # example.\n    poly = Polygon([(1390000, 5140000), (1391000, 5140000), (1391000, 5141000), (1390000, 5141000), (1390000, 5140000)])\n    gdf = gpd.GeoDataFrame({\"name\": [\"mismatched-example\"]}, geometry=[poly])\n\nif \"name\" not in gdf.columns:\n    gdf[\"name\"] = [f\"geometry {i+1}\" for i in range(len(gdf))]\n\ncrs_warning = None\nif gdf.crs is None:\n    gdf = gdf.set_crs(\"EPSG:4326\")\n    crs_warning = \"No CRS found in the file — assuming WGS84 (EPSG:4326).\"\n\noriginal_crs = gdf.crs\nis_geographic = bool(original_crs.is_geographic)\nbounds = [round(float(b), 6) for b in gdf.total_bounds]\n\n# The actual check: does the CRS's OWN claim (geographic vs projected)\n# agree with the magnitude of the coordinates themselves?\nlooks_geographic = (\n    -180 <= bounds[0] <= 180 and -180 <= bounds[2] <= 180 and\n    -90 <= bounds[1] <= 90 and -90 <= bounds[3] <= 90\n)\nmismatch_warning = None\nif is_geographic and not looks_geographic:\n    mismatch_warning = f\"Declared as geographic ({original_crs.name}), but the coordinates are far outside -180..180 / -90..90 — likely actually projected.\"\nelif not is_geographic and looks_geographic:\n    mismatch_warning = f\"Declared as projected ({original_crs.name}), but the coordinates already look like lon/lat — likely actually geographic.\"\n\n# MapLibre/GeoJSON require WGS84 — the summary keeps the file's own\n# native CRS on purpose (that mismatch is the whole point), so the map\n# gets its own throwaway reprojected copy.\n#\n# When there's no mismatch this is just the declared CRS, correctly\n# reprojected: straightforward. When there IS a mismatch, drawing the\n# raw numbers as if they were already WGS84 degrees produces nonsense\n# the map can't meaningfully show (e.g. \"longitude\" 1390000): a blank\n# map communicates nothing about the mismatch this whole example\n# exists to show. Best-effort fix, not a certainty: EPSG:3857 (Web\n# Mercator) is the single most common \"large meter-scale numbers\" CRS\n# for web-published data, a reasonable default guess, not a claim\n# about what the file's real CRS actually is (which nothing in the\n# file itself can settle) -- map_crs_note records that explicitly\n# wherever this guess is used.\nmap_crs_note = None\nif mismatch_warning and is_geographic and not looks_geographic:\n    map_gdf = gdf.set_crs(\"EPSG:3857\", allow_override=True).to_crs(\"EPSG:4326\")\n    map_crs_note = \"Shown on the map assuming EPSG:3857 (Web Mercator) — the mismatch means the real CRS can't be known for certain.\"\nelif mismatch_warning and not is_geographic and looks_geographic:\n    map_gdf = gdf.set_crs(\"EPSG:4326\", allow_override=True)\n    map_crs_note = \"Shown on the map treating the coordinates directly as WGS84 lon/lat, since they already look like degrees.\"\nelse:\n    map_gdf = gdf if original_crs == \"EPSG:4326\" else gdf.to_crs(\"EPSG:4326\")\n\n{\n    \"features\": map_gdf.__geo_interface__,\n    \"summary\": {\n        \"crs\": original_crs.to_string(),\n        \"isGeographic\": is_geographic,\n        \"bounds\": bounds,\n        \"crsWarning\": crs_warning,\n        \"mismatchWarning\": mismatch_warning,\n        \"mapCrsNote\": map_crs_note\n    }\n}",
    preload: { packages: ["geopandas","shapely"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, { packages: ["geopandas","shapely"] }));
    }
  });
});
</script>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div id="crs-diagnose-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("crs-diagnose-r", {
    language: "r",
    initialCode: "library(sf)\nlibrary(geojsonsf)\ndata <- NULL\nfor (path in c(\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\")) {\n  data <- tryCatch(st_read(path, quiet = TRUE), error = function(e) NULL)\n  if (!is.null(data)) break\n}\n\nif (is.null(data)) {\n  wkt <- \"POLYGON((1390000 5140000, 1391000 5140000, 1391000 5141000, 1390000 5141000, 1390000 5140000))\"\n  data <- st_sf(name = \"mismatched-example\", geometry = st_as_sfc(wkt))\n}\n\nif (!(\"name\" %in% names(data))) {\n  data$name <- paste(\"geometry\", seq_len(nrow(data)))\n}\n\ncrs_warning <- NULL\nif (is.na(st_crs(data))) {\n  st_crs(data) <- 4326\n  crs_warning <- \"No CRS found in the file — assuming WGS84 (EPSG:4326).\"\n}\n\noriginal_crs <- st_crs(data)\nis_geographic <- st_is_longlat(data)\nbnd <- st_bbox(data)\nbounds <- unname(round(as.numeric(bnd), 6))\n\nlooks_geographic <- (\n  bounds[1] >= -180 && bounds[1] <= 180 && bounds[3] >= -180 && bounds[3] <= 180 &&\n  bounds[2] >= -90 && bounds[2] <= 90 && bounds[4] >= -90 && bounds[4] <= 90\n)\nmismatch_warning <- NULL\nif (isTRUE(is_geographic) && !looks_geographic) {\n  mismatch_warning <- paste0(\"Declared as geographic (\", original_crs$input, \"), but the coordinates are far outside -180..180 / -90..90 -- likely actually projected.\")\n} else if (!isTRUE(is_geographic) && looks_geographic) {\n  mismatch_warning <- paste0(\"Declared as projected (\", original_crs$input, \"), but the coordinates already look like lon/lat -- likely actually geographic.\")\n}\n\n# Drawing the raw numbers as WGS84 when there's a mismatch produces\n# nonsense the map can't show, so a mismatch instead gets a\n# best-effort guess (EPSG:3857, the most common projection for web\n# basemaps) -- not a claim about the file's real CRS, which nothing\n# in the file itself can settle.\nmap_crs_note <- NULL\nif (!is.null(mismatch_warning) && isTRUE(is_geographic) && !looks_geographic) {\n  relabeled <- data\n  st_crs(relabeled) <- 3857\n  map_data <- st_transform(relabeled, 4326)\n  map_crs_note <- \"Shown on the map assuming EPSG:3857 (Web Mercator) -- the mismatch means the real CRS can't be known for certain.\"\n} else if (!is.null(mismatch_warning) && !isTRUE(is_geographic) && looks_geographic) {\n  relabeled <- data\n  st_crs(relabeled) <- 4326\n  map_data <- relabeled\n  map_crs_note <- \"Shown on the map treating the coordinates directly as WGS84 lon/lat, since they already look like degrees.\"\n} else {\n  map_data <- if (isTRUE(original_crs == st_crs(4326))) data else st_transform(data, 4326)\n}\n\n# jsonlite::unbox() marks a value as a genuine scalar, explicitly,\n# field by field. Safer than toJSON(auto_unbox = TRUE), which guesses\n# from length alone and can silently flatten a real one-item array\n# into a bare scalar.\nresult <- list(\n  summary = list(\n    crs = jsonlite::unbox(original_crs$input),\n    isGeographic = jsonlite::unbox(is_geographic),\n    bounds = bounds,\n    crsWarning = if (is.null(crs_warning)) NULL else jsonlite::unbox(crs_warning),\n    mismatchWarning = if (is.null(mismatch_warning)) NULL else jsonlite::unbox(mismatch_warning),\n    mapCrsNote = if (is.null(map_crs_note)) NULL else jsonlite::unbox(map_crs_note)\n  )\n)\nout <- jsonlite::fromJSON(jsonlite::toJSON(result, null = \"null\"), simplifyVector = FALSE)\n\n# The GeoJSON is attached AFTER the round trip, not inside it: once\n# parsed, toJSON() would turn every scalar in it (\"type\": \"Feature\")\n# into a one-element array, which MapLibre rejects -- the map stayed\n# empty whenever only this R cell had run.\nout$features <- jsonlite::fromJSON(sf_geojson(map_data), simplifyVector = FALSE)\nout",
    preload: { packages: ["sf","geojsonsf","jsonlite"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["sf","geojsonsf","jsonlite"] }));
    }
  });
});
</script>
</div>
</div>
</div>
<div id="lab-stats">

</div>
<div id="lab-reset">

</div>
</section>
<section id="same-spatial-question-two-languages" class="level2 page-columns page-full">
<h2 class="anchored" data-anchor-id="same-spatial-question-two-languages">4. Same spatial question, two languages</h2>
<p>Python and R answer “is this CRS geographic or projected?” with one line each:</p>
<p><strong>Python</strong></p>
<div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1">gdf.crs.is_geographic</span></code></pre></div>
<p><strong>R</strong></p>
<div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode r code-with-copy"><code class="sourceCode r"><span id="cb2-1">sf<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">::</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">st_is_longlat</span>(data)</span></code></pre></div>
<p>Both ultimately ask PROJ (the library underneath both PyPROJ and <code>sf</code>’s CRS handling) the same question.<sup>1</sup></p>
<div class="no-row-height column-margin column-container"><div id="fn1"><p><sup>1</sup>&nbsp;The accessor and the formatted CRS string differ, though: <code>original_crs.to_string()</code> in Python routinely returns <code>"EPSG:4326"</code>, while <code>st_crs(data)$input</code> in R returns whatever string the file’s own CRS definition resolved to, sometimes a full human-readable name instead of an EPSG code. Same underlying CRS, not always the same displayed text: worth knowing before assuming a mismatch between the two cards above means an actual data problem, rather than a formatting one.</p></div></div></section>
<section id="convert-to-a-different-crs" class="level2">
<h2 class="anchored" data-anchor-id="convert-to-a-different-crs">5. Convert to a different CRS</h2>
<p>Once you know the CRS, changing it is one call: <code>to_crs()</code> in GeoPandas, <code>st_transform()</code> in <code>sf</code>. Reuses exactly the data just diagnosed above (<code>gdf</code> in Python, <code>data</code> in R stay in the session between one cell and the next), no new reading of the file.</p>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-2-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-2-1" aria-controls="tabset-2-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-2-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-2-2" aria-controls="tabset-2-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-2-1" class="tab-pane active" aria-labelledby="tabset-2-1-tab">
<div id="crs-convert-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("crs-convert-py", {
    language: "python",
    initialCode: "if \"gdf\" not in globals():\n    raise RuntimeError(\"Run the diagnosis cell above (section 3, Python) first — this reuses its data.\")\n\n# A common target: Web Mercator (EPSG:3857), the projection most\n# online basemap tiles use. Printed as text, not drawn on the shared\n# map above — MapLibre/GeoJSON require WGS84, so a genuinely converted\n# result (unless the target IS 4326) can't be shown there directly.\nconverted = gdf.to_crs(\"EPSG:3857\")\nf\"{len(converted)} feature(s) now in {converted.crs.to_string()}, bounds {[round(b, 2) for b in converted.total_bounds]}\"",
    preload: {},
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, {}));
    }
  });
});
</script>
</div>
<div id="tabset-2-2" class="tab-pane" aria-labelledby="tabset-2-2-tab">
<div id="crs-convert-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("crs-convert-r", {
    language: "r",
    initialCode: "# inherits = FALSE: \"data\" is ALSO the name of base R's own data()\n# function, so a plain exists(\"data\") would always find that and\n# never detect the missing-session-variable case this guard exists\n# for. Restricting the search to the current environment (where the\n# diagnosis cell would have assigned it) avoids that false positive.\nif (!exists(\"data\", inherits = FALSE)) {\n  stop(\"Run the diagnosis cell above (section 3, R) first -- this reuses its data.\")\n}\n\nlibrary(sf)\nconverted <- st_transform(data, 3857)\nsprintf(\"%d feature(s) now in %s, bounds %s\", nrow(converted), st_crs(converted)$input, paste(round(as.numeric(st_bbox(converted)), 2), collapse = \", \"))",
    preload: {},
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, {}));
    }
  });
});
</script>
</div>
</div>
</div>
<div class="callout callout-style-default callout-note callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Note
</div>
</div>
<div class="callout-body-container callout-body">
<p>Converting <em>to</em> a CRS is only correct when the CRS you’re converting <em>from</em> is right in the first place. If the mismatch check above found a problem, <code>to_crs()</code>/<code>st_transform()</code> would mathematically transform numbers that were never really in the declared CRS to begin with, producing a confident-looking but wrong result. Fixing the file’s own CRS label (something only you can determine, from where the data actually came from) has to come first.</p>
</div>
</div>
</section>
<section id="where-to-next" class="level2">
<h2 class="anchored" data-anchor-id="where-to-next">6. Where to next</h2>
<ul>
<li><strong>Geometry looks fine, dataset doesn’t?</strong> → <a href="../posts/topology-errors.html">Topology errors</a> covers overlaps, gaps, slivers, duplicates and dangles: relationships <em>between</em> features, a different problem from CRS entirely.</li>
<li><strong>Not sure where to start?</strong> → The <a href="../posts/geospatial-file-inspection.html">GeoSpatial File Inspector</a> gives a five-second summary of a file (feature count, geometry type, CRS, bounds, attributes) before you dig into any one check.</li>
<li><strong>Just needed the check?</strong> → The <a href="../tools/crs-inspector.html">standalone CRS Inspector &amp; Converter</a> runs the same detection and lets you convert and download directly.</li>
</ul>
<div id="lab-root"></div>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("crs-mismatch-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>


 ]]></description>
  <category>tool</category>
  <category>geometry</category>
  <category>bilingual</category>
  <guid>https://webgeods.com/posts/crs-mismatch.html</guid>
  <pubDate>Sun, 06 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>First Look at a Geospatial File: What to Check Before You Trust It</title>
  <link>https://webgeods.com/posts/geospatial-file-inspection.html</link>
  <description><![CDATA[ 




<p>Before you validate, fix, or check topology, it helps to know what you’re actually holding. A feature count in the thousands changes what checks are even practical. A CRS you didn’t expect explains a map that looks wrong before you’ve touched a single geometry. A handful of exact duplicates might be an import artifact, not a data-quality problem worth chasing.</p>
<p>This isn’t a repair tool and it isn’t a topology report: it’s the five-second first look that tells you which of those tools you actually need next.</p>
<p>Just want the summary, without the rest of the reading? Use the <a href="../tools/geospatial-file-inspector.html">standalone tool</a> — same engine, less prose.</p>
<section id="upload-your-file" class="level2">
<h2 class="anchored" data-anchor-id="upload-your-file">1. Upload your file</h2>
<p>A <code>.geojson</code>, or a shapefile: either as a single <code>.zip</code>, or as the individual <code>.shp</code>/<code>.dbf</code>/<code>.shx</code> files (and optionally <code>.prj</code>) selected together.</p>
<div id="lab-upload">

</div>
</section>
<section id="why-start-with-a-summary" class="level2">
<h2 class="anchored" data-anchor-id="why-start-with-a-summary">2. Why start with a summary?</h2>
<p>Eight numbers, each answering a question you’d otherwise have to dig for:</p>
<ul>
<li><strong>Features</strong>: how many rows. Determines whether an O(n²) check (like the <a href="../posts/topology-errors.html">Topology Checker’s</a> pairwise overlap/gap detection) is even practical to run.</li>
<li><strong>Geometry type</strong>: Point, Line, Polygon, or a mix. Some checks only apply to one family; a “mixed” result is itself useful information.</li>
<li><strong>CRS</strong>: the coordinate reference system the file claims to be in. Not verified against the actual coordinates here: that mismatch detection is a distinct, more involved check, covered in <a href="../posts/crs-mismatch.html">Coordinate Reference Systems</a> and the <a href="../tools/crs-inspector.html">standalone CRS Inspector</a>.</li>
<li><strong>Bounds</strong>: the bounding box, in the file’s own CRS units. A bounding box in the thousands or millions instead of the -180..180 / -90..90 range is usually the first hint of a CRS problem, before any dedicated check runs.</li>
<li><strong>Attributes</strong>: how many non-geometry columns, and their names. How much there is to a feature beyond its shape.</li>
<li><strong>Invalid</strong>: geometries that fail the Simple Features validity rules. See <a href="../posts/geometry-validity.html">Geometry validity</a> for what that actually means and how to fix it.</li>
<li><strong>Empty</strong>: geometries with no coordinates at all. Easy to miss in a raw file, and something naive code (yours or a library’s) can choke on silently.</li>
<li><strong>Duplicates</strong>: exact repeats (identical coordinates), detected by comparing binary geometry representations, not a full topological equality check. Fast enough for a large file; the <a href="../posts/topology-errors.html">Topology Checker</a> has the slower, more thorough version if you need it.</li>
</ul>
</section>
<section id="shared-map-and-summary" class="level2">
<h2 class="anchored" data-anchor-id="shared-map-and-summary">3. Shared map and summary</h2>
<div id="lab-map" style="min-height: 500px;">

</div>
<div id="lab-stats">

</div>
<div id="lab-reset">

</div>
</section>
<section id="inspect-summarize-your-file" class="level2">
<h2 class="anchored" data-anchor-id="inspect-summarize-your-file">4. Inspect: summarize your file</h2>
<p>Run the same summary in Python or R on the current data (your file uploaded above, or the example already loaded below).</p>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="inspect-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("inspect-py", {
    language: "python",
    initialCode: "import geopandas as gpd\nfrom shapely.geometry import shape\n\ngdf = None\nfor path in [\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\"]:\n    try:\n        gdf = gpd.read_file(path)\n        break\n    except Exception:\n        pass\n\nif gdf is None:\n    # Four fixed features, chosen to exercise every check at once: a\n    # square, an exact duplicate of it, a genuinely distinct valid\n    # triangle, and a self-intersecting \"bowtie\" for the invalid case.\n    a = {\"type\": \"Polygon\", \"coordinates\": [[[12.40, 41.80], [12.50, 41.80], [12.50, 41.90], [12.40, 41.90], [12.40, 41.80]]]}\n    b = {\"type\": \"Polygon\", \"coordinates\": [[[12.60, 41.80], [12.70, 41.80], [12.65, 41.90], [12.60, 41.80]]]}\n    c = {\"type\": \"Polygon\", \"coordinates\": [[[12.75, 41.80], [12.85, 41.90], [12.75, 41.90], [12.85, 41.80], [12.75, 41.80]]]}\n    gdf = gpd.GeoDataFrame(\n        {\"name\": [\"square-1\", \"square-1-copy\", \"triangle\", \"bowtie (invalid)\"], \"population\": [1200, 1200, 340, None]},\n        geometry=[shape(a), shape(a), shape(b), shape(c)],\n        crs=\"EPSG:4326\"\n    )\n\n# CRS is reported in the file's OWN units below (see section 2/6) — a\n# bounding box in the hundreds of thousands is itself the \"something's\n# off\" signal, which reprojecting here would erase. Only a MISSING CRS\n# gets corrected (assumed WGS84, flagged) — there's nothing native to\n# report otherwise.\ncrs_warning = None\nif gdf.crs is None:\n    gdf = gdf.set_crs(\"EPSG:4326\")\n    crs_warning = \"No CRS found in the file — assuming WGS84 (EPSG:4326).\"\n\nif \"name\" not in gdf.columns:\n    gdf[\"name\"] = [f\"geometry {i+1}\" for i in range(len(gdf))]\n\n# Three per-feature checks, each cheap and vectorized (no O(n²)\n# pairwise comparisons — unlike the Topology Checker's check_gap()/\n# check_overlap(), this is a fast first look, not an authoritative\n# topology report).\ngdf[\"valid\"] = gdf.geometry.apply(lambda g: g is not None and g.is_valid)\ngdf[\"empty\"] = gdf.geometry.apply(lambda g: g is None or g.is_empty)\n# Exact-match only (same WKB bytes) — catches a copy-pasted row, not a\n# topologically-identical polygon traced with different vertex order.\ngdf[\"duplicate\"] = gdf.geometry.to_wkb().duplicated()\n\nfor col in gdf.columns:\n    if col != \"geometry\":\n        gdf[col] = gdf[col].fillna(\"\")\n\nattribute_columns = [c for c in gdf.columns if c not in (\"geometry\", \"valid\", \"empty\", \"duplicate\")]\ngeometry_types = sorted(set(gdf.geometry.geom_type))\nbounds = [round(float(b), 6) for b in gdf.total_bounds]\ncrs = gdf.crs.to_string() if gdf.crs is not None else None\n\n# MapLibre/GeoJSON require WGS84 — the summary above intentionally\n# stays in the file's own native CRS (see comment above), so the map\n# gets its own throwaway reprojected copy instead of reusing gdf\n# directly. gdf.crs is never None here (a missing CRS was already\n# assumed WGS84 above). Only mapFeatures is returned — this cell has\n# no download button, so a copy in the native CRS would just be unused\n# output. Section 6's CRS-mismatch example is the one place a native-\n# CRS \"features\" (with no mapFeatures) matters, and returns it on its\n# own, deliberately un-reprojected.\nmap_gdf = gdf if gdf.crs == \"EPSG:4326\" else gdf.to_crs(\"EPSG:4326\")\n\n{\n    \"mapFeatures\": map_gdf.__geo_interface__,\n    \"summary\": {\n        \"total\": len(gdf),\n        \"geometryTypes\": geometry_types,\n        \"crs\": crs,\n        \"crsWarning\": crs_warning,\n        \"bounds\": bounds,\n        \"attributeNames\": attribute_columns,\n        \"invalid\": int((~gdf[\"valid\"]).sum()),\n        \"empty\": int(gdf[\"empty\"].sum()),\n        \"duplicates\": int(gdf[\"duplicate\"].sum())\n    }\n}",
    preload: { packages: ["geopandas","shapely"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, { packages: ["geopandas","shapely"] }));
    }
  });
});
</script>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div id="inspect-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("inspect-r", {
    language: "r",
    initialCode: "library(sf)\nlibrary(geojsonsf)\ndata <- NULL\nfor (path in c(\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\")) {\n  data <- tryCatch(st_read(path, quiet = TRUE), error = function(e) NULL)\n  if (!is.null(data)) break\n}\n\nif (is.null(data)) {\n  # Same four features as the Python cell: a square, an exact\n  # duplicate of it, a valid triangle, and a self-intersecting\n  # \"bowtie\" for the invalid case.\n  wkt_a <- \"POLYGON((12.40 41.80, 12.50 41.80, 12.50 41.90, 12.40 41.90, 12.40 41.80))\"\n  wkt_b <- \"POLYGON((12.60 41.80, 12.70 41.80, 12.65 41.90, 12.60 41.80))\"\n  wkt_c <- \"POLYGON((12.75 41.80, 12.85 41.90, 12.75 41.90, 12.85 41.80, 12.75 41.80))\"\n  geoms <- st_as_sfc(c(wkt_a, wkt_a, wkt_b, wkt_c), crs = 4326)\n  data <- st_sf(name = c(\"square-1\", \"square-1-copy\", \"triangle\", \"bowtie (invalid)\"), population = c(1200, 1200, 340, NA), geometry = geoms)\n}\n\n# CRS is reported in the file's OWN units below (see section 2/6) — a\n# bounding box in the hundreds of thousands is itself the \"something's\n# off\" signal, which reprojecting here would erase. Only a MISSING CRS\n# gets corrected (assumed WGS84, flagged) — there's nothing native to\n# report otherwise.\ncrs_warning <- NULL\nif (is.na(st_crs(data))) {\n  st_crs(data) <- 4326\n  crs_warning <- \"No CRS found in the file — assuming WGS84 (EPSG:4326).\"\n}\n\nif (!(\"name\" %in% names(data))) {\n  data$name <- paste(\"geometry\", seq_len(nrow(data)))\n}\n\ndata$valid <- st_is_valid(data)\ndata$empty <- st_is_empty(data)\n# Exact-match only, via WKT text comparison (st_as_binary() also\n# exists but WKT is the more commonly relied-on round-trip in sf):\n# identical coordinates, not a topological equality check.\ndata$duplicate <- duplicated(st_as_text(st_geometry(data)))\n\nattribute_names <- setdiff(names(data), c(\"geometry\", \"valid\", \"empty\", \"duplicate\"))\ngeometry_types <- sort(unique(as.character(st_geometry_type(data))))\nbnd <- st_bbox(data)\ncrs_input <- st_crs(data)$input\n\n# MapLibre/GeoJSON require WGS84 — the summary above intentionally\n# stays in the file's own native CRS (see comment above), so the map\n# gets its own throwaway reprojected copy instead of reusing `data`\n# directly. st_crs(data) is never NA here (a missing CRS was already\n# assumed WGS84 above). Only mapFeatures is returned — this cell has\n# no download button, so a copy in the native CRS would just be\n# unused output.\nmap_data <- if (isTRUE(st_crs(data) == st_crs(4326))) data else st_transform(data, 4326)\n\n# jsonlite::toJSON() treats every length-1 vector as a single-element\n# array by default — a genuine scalar (a count, a CRS string) needs an\n# explicit jsonlite::unbox() to serialize as one. ub() below is just\n# that, plus a NULL passthrough for the two optional fields. The\n# alternative, toJSON(auto_unbox = TRUE), flips the default the other\n# way and asks the OPPOSITE question (which fields must stay arrays),\n# which is worse here: geometryTypes is length 1 for the overwhelming\n# majority of files (a single geometry type), so auto_unbox would\n# silently serialize it as bare \"POLYGON\" instead of [\"POLYGON\"] in\n# the common case, not a rare one. Marking scalars explicitly, the\n# way this cell does, fails loudly instead (an unmarked scalar becomes\n# a 1-item array, easy to notice) rather than silently, for whichever\n# field is forgotten next.\nub <- function(x) if (is.null(x)) NULL else jsonlite::unbox(x)\n\nresult <- list(\n  summary = list(\n    total = ub(nrow(data)),\n    geometryTypes = geometry_types,\n    crs = ub(crs_input),\n    crsWarning = ub(crs_warning),\n    bounds = unname(round(as.numeric(bnd), 6)),\n    attributeNames = attribute_names,\n    invalid = ub(sum(!data$valid)),\n    empty = ub(sum(data$empty)),\n    duplicates = ub(sum(data$duplicate))\n  )\n)\n\n# Round-tripped through JSON once more, whole-object: R's atomic\n# vectors (geometry_types, bounds, attribute_names) don't serialize\n# to plain JSON arrays on their own the way a jsonlite-parsed list\n# does.\nout <- jsonlite::fromJSON(jsonlite::toJSON(result, null = \"null\"), simplifyVector = FALSE)\n\n# The GeoJSON is attached AFTER the round trip, not inside it: once\n# parsed, toJSON() would turn every scalar in it (\"type\": \"Feature\")\n# into a one-element array, which MapLibre rejects -- the map stayed\n# empty whenever only this R cell had run.\nout$mapFeatures <- jsonlite::fromJSON(sf_geojson(map_data), simplifyVector = FALSE)\nout",
    preload: { packages: ["sf","geojsonsf","jsonlite"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["sf","geojsonsf","jsonlite"] }));
    }
  });
});
</script>
</div>
</div>
</div>
</section>
<section id="same-spatial-question-two-languages" class="level2 page-columns page-full">
<h2 class="anchored" data-anchor-id="same-spatial-question-two-languages">5. Same spatial question, two languages</h2>
<p>Python and R read the same numbers off the same engines, but the accessors differ:</p>
<p><strong>Python</strong> (GeoPandas)</p>
<div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1"><span class="bu" style="color: null;
background-color: null;
font-style: inherit;">len</span>(gdf)                  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># feature count</span></span>
<span id="cb1-2">gdf.geometry.geom_type    <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># per-feature geometry type</span></span>
<span id="cb1-3">gdf.crs.to_string()       <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># CRS</span></span>
<span id="cb1-4">gdf.total_bounds          <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># [minx, miny, maxx, maxy]</span></span>
<span id="cb1-5">gdf.geometry.to_wkb()     <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># exact-match key for duplicate detection</span></span></code></pre></div>
<p><strong>R</strong> (sf)</p>
<div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode r code-with-copy"><code class="sourceCode r"><span id="cb2-1"><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">nrow</span>(data)                        <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># feature count</span></span>
<span id="cb2-2">sf<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">::</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">st_geometry_type</span>(data)        <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># per-feature geometry type</span></span>
<span id="cb2-3">sf<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">::</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">st_crs</span>(data)<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">$</span>input            <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># CRS</span></span>
<span id="cb2-4">sf<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">::</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">st_bbox</span>(data)                 <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># xmin, ymin, xmax, ymax</span></span>
<span id="cb2-5">sf<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">::</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">st_as_text</span>(sf<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">::</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">st_geometry</span>(data))  <span class="co" style="color: #5E5E5E;
background-color: null;
font-style: inherit;"># exact-match key</span></span></code></pre></div>
<p>The CRS string is the one place they can genuinely disagree in formatting, not just vocabulary.<sup>1</sup></p>
<div class="no-row-height column-margin column-container"><div id="fn1"><p><sup>1</sup>&nbsp;<code>st_crs()$input</code> returns whatever string the file’s own CRS definition resolved to (often <code>"EPSG:4326"</code> for a clean case, but sometimes a full WKT string for a less common one), while GeoPandas’ <code>.crs.to_string()</code> goes through PyPROJ’s own formatting: same underlying CRS, not always the same displayed text. Worth knowing before assuming a mismatch between the two summary cards above means an actual data problem.</p></div></div></section>
<section id="load-an-example" class="level2 page-columns page-full">
<h2 class="anchored" data-anchor-id="load-an-example">6. Load an example</h2>
<p>The reusable cell pair above already loads a small dataset with one exact duplicate and one invalid geometry whenever nothing has been uploaded. Click <strong>▶ Run</strong> on both languages above to see it. This button swaps in a dataset with a deliberately unusual CRS instead, to see the bounds stat do its job.<sup>2</sup></p>
<div class="no-row-height column-margin column-container"><div id="fn2"><p><sup>2</sup>&nbsp;Web Mercator coordinates plotted at the same zoom level as geographic coordinates land far outside any sensible map view: that’s the “bounds in the hundreds of thousands” tell in practice, not just in the abstract. Detecting exactly this kind of mismatch, and converting a file to a different CRS, is what <a href="../posts/crs-mismatch.html">Coordinate Reference Systems</a> and its <a href="../tools/crs-inspector.html">standalone tool</a> do.</p></div></div><div id="webgeods-example-1"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-1").appendChild(
    window.WebGeoDS.loadExampleButton("📋 Load a CRS-mismatch example", { pyId: "inspect-py", pyCode: "import geopandas as gpd\nfrom shapely.geometry import shape\n\n# The same square as the default example, but reprojected to Web\n# Mercator (EPSG:3857) — coordinates in the hundreds of thousands to\n# millions instead of -180..180, exactly the tell described in\n# section 2 above.\ngdf = gpd.GeoDataFrame(\n    {\"name\": [\"square\"]},\n    geometry=[shape({\"type\": \"Polygon\", \"coordinates\": [[[12.40, 41.80], [12.50, 41.80], [12.50, 41.90], [12.40, 41.90], [12.40, 41.80]]]})],\n    crs=\"EPSG:4326\"\n).to_crs(\"EPSG:3857\")\n\nif \"name\" not in gdf.columns:\n    gdf[\"name\"] = [f\"geometry {i+1}\" for i in range(len(gdf))]\n\ngdf[\"valid\"] = gdf.geometry.apply(lambda g: g is not None and g.is_valid)\ngdf[\"empty\"] = gdf.geometry.apply(lambda g: g is None or g.is_empty)\ngdf[\"duplicate\"] = gdf.geometry.to_wkb().duplicated()\n\nfor col in gdf.columns:\n    if col != \"geometry\":\n        gdf[col] = gdf[col].fillna(\"\")\n\nattribute_columns = [c for c in gdf.columns if c not in (\"geometry\", \"valid\", \"empty\", \"duplicate\")]\ngeometry_types = sorted(set(gdf.geometry.geom_type))\nbounds = [round(float(b), 6) for b in gdf.total_bounds]\ncrs = gdf.crs.to_string() if gdf.crs is not None else None\n\n# \"features\"/bounds/crs stay in the file's own EPSG:3857: that's the\n# whole point of this example (see the callout above). The map still\n# needs WGS84 to draw anything at all -- treating Web-Mercator-scale\n# numbers (hundreds of thousands) as if they were degrees would put\n# the geometry far outside any sensible view, invisible on the map.\nmap_gdf = gdf.to_crs(\"EPSG:4326\")\n\n{\n    \"features\": gdf.__geo_interface__,\n    \"mapFeatures\": map_gdf.__geo_interface__,\n    \"summary\": {\n        \"total\": len(gdf), \"geometryTypes\": geometry_types, \"crs\": crs, \"bounds\": bounds,\n        \"attributeNames\": attribute_columns,\n        \"invalid\": int((~gdf[\"valid\"]).sum()), \"empty\": int(gdf[\"empty\"].sum()), \"duplicates\": int(gdf[\"duplicate\"].sum())\n    }\n}", rId: "inspect-r", rCode: "library(sf)\nlibrary(geojsonsf)\nwkt <- \"POLYGON((12.40 41.80, 12.50 41.80, 12.50 41.90, 12.40 41.90, 12.40 41.80))\"\ndata <- st_sf(name = \"square\", geometry = st_as_sfc(wkt, crs = 4326))\ndata <- st_transform(data, 3857)\n\ndata$valid <- st_is_valid(data)\ndata$empty <- st_is_empty(data)\ndata$duplicate <- duplicated(st_as_text(st_geometry(data)))\n\nattribute_names <- setdiff(names(data), c(\"geometry\", \"valid\", \"empty\", \"duplicate\"))\ngeometry_types <- sort(unique(as.character(st_geometry_type(data))))\nbnd <- st_bbox(data)\n\n# \"features\"/bounds/crs stay in EPSG:3857 (the whole point of this\n# example), but the map needs a WGS84 copy to draw anything at all.\nmap_data <- st_transform(data, 4326)\n\nresult <- list(\n  summary = list(\n    total = jsonlite::unbox(nrow(data)), geometryTypes = geometry_types,\n    crs = jsonlite::unbox(st_crs(data)$input),\n    bounds = unname(round(as.numeric(bnd), 6)), attributeNames = attribute_names,\n    invalid = jsonlite::unbox(sum(!data$valid)), empty = jsonlite::unbox(sum(data$empty)),\n    duplicates = jsonlite::unbox(sum(data$duplicate))\n  )\n)\n# GeoJSON attached after the round trip, which would turn its scalars into arrays.\nout <- jsonlite::fromJSON(jsonlite::toJSON(result, null = \"null\"), simplifyVector = FALSE)\nout$features <- jsonlite::fromJSON(sf_geojson(data), simplifyVector = FALSE)\nout$mapFeatures <- jsonlite::fromJSON(sf_geojson(map_data), simplifyVector = FALSE)\nout" }));
});
</script>
</section>
<section id="where-to-next" class="level2">
<h2 class="anchored" data-anchor-id="where-to-next">7. Where to next</h2>
<p>A summary is a starting point, not an endpoint:</p>
<ul>
<li><strong>CRS looks off?</strong> → <a href="../posts/crs-mismatch.html">Coordinate Reference Systems</a> covers the mismatch heuristic above in depth, with conversion, or use the <a href="../tools/crs-inspector.html">standalone CRS Inspector</a> directly.</li>
<li><strong>Invalid geometries?</strong> → <a href="../posts/geometry-validity.html">Geometry validity</a> explains why, and how <code>make_valid()</code>/<code>st_make_valid()</code> fix it, or use the <a href="../tools/geojson-shapefile-validator.html">standalone Validator</a> directly.</li>
<li><strong>Everything valid, but the dataset still looks wrong?</strong> → <a href="../posts/topology-errors.html">Topology errors</a> covers overlaps, gaps, slivers, duplicates, and dangles: relationships <em>between</em> features, not properties of any one of them, or use the <a href="../tools/topology-checker.html">standalone Topology Checker</a>.</li>
<li><strong>Just needed the numbers?</strong> The <a href="../tools/geospatial-file-inspector.html">standalone Inspector</a> has the same summary, no reading required.</li>
</ul>
<div id="lab-root"></div>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("file-inspection-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>


 ]]></description>
  <category>tool</category>
  <category>geometry</category>
  <category>bilingual</category>
  <guid>https://webgeods.com/posts/geospatial-file-inspection.html</guid>
  <pubDate>Sat, 05 Sep 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Topology Errors in a GeoJSON: Detect Without Fixing</title>
  <link>https://webgeods.com/posts/topology-errors.html</link>
  <description><![CDATA[ 




<p>In <a href="../posts/geometry-validity.html">Geometry validity of a GeoJSON</a> we repaired polygons that were <strong>individually</strong> invalid with <code>make_valid()</code>/<code>st_make_valid()</code>. Here the problem is different: every single feature’s geometry can be perfectly valid, and the dataset can still be <strong>broken</strong>: two polygons that overlap, a gap between two areas that should touch, a line that ends a millimeter from another without connecting.</p>
<p><strong>This tool doesn’t fix anything.</strong> It detects, describes and locates the errors. Closing a gap or assigning an overlap is a semantic decision (which polygon is the “correct” one?) that no algorithm can make for you: it’s a data-quality report, not an automatic repair tool.</p>
<p>On load, the code detects whether your data is <strong>polygons</strong> or <strong>lines</strong> and runs only the checks that make sense for that type:</p>
<ul>
<li><strong>Polygons</strong> → overlap, gap, sliver, duplicates</li>
<li><strong>Lines</strong> → dangle, duplicates</li>
</ul>
<p>Just want to quickly check a file, without the rest of the reading? Use the <a href="../tools/topology-checker.html">standalone tool</a> — same engine, less prose.</p>
<section id="upload-your-file" class="level2">
<h2 class="anchored" data-anchor-id="upload-your-file">1. Upload your file</h2>
<p>A <code>.geojson</code>, or a shapefile: either as a single <code>.zip</code>, or as the individual <code>.shp</code>/<code>.dbf</code>/<code>.shx</code> files (and optionally <code>.prj</code>) selected together.</p>
<div id="lab-upload">

</div>
</section>
<section id="what-we-look-for" class="level2">
<h2 class="anchored" data-anchor-id="what-we-look-for">2. What we look for</h2>
<p>Five recurring categories, drawn from the <a href="https://www.usgs.gov/ngp-standards-and-specifications/topology-and-network-errors">USGS</a> catalog and from the <a href="https://docs.qgis.org/3.44/en/docs/user_manual/plugins/core_plugins/plugins_topology_checker.html">QGIS Topology Checker</a>:</p>
<div style="display: flex; flex-direction: column; gap: 0.9rem; margin: 1.2rem 0;">
<div style="display: flex; align-items: center; gap: 1rem;">
<svg viewbox="0 0 110 90" style="width: 80px; height: auto; flex: none;" aria-label="Two overlapping polygons">
<rect x="10" y="20" width="55" height="50" fill="#42583c" fill-opacity="0.25" stroke="#2a2117" stroke-width="2"></rect> <rect x="45" y="35" width="55" height="50" fill="#42583c" fill-opacity="0.25" stroke="#2a2117" stroke-width="2"></rect> <rect x="45" y="35" width="20" height="35" fill="#e05252" fill-opacity="0.75"></rect>
</svg>
<div>
<strong>Overlap</strong>: two polygons that should be adjacent instead overlap over a portion of surface.
</div>
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
<svg viewbox="0 0 110 90" style="width: 80px; height: auto; flex: none;" aria-label="A gap between two polygons">
<rect x="8" y="20" width="35" height="50" fill="#42583c" fill-opacity="0.3" stroke="#2a2117" stroke-width="2"></rect> <rect x="67" y="20" width="35" height="50" fill="#42583c" fill-opacity="0.3" stroke="#2a2117" stroke-width="2"></rect> <rect x="43" y="20" width="24" height="50" fill="#c48a2e" fill-opacity="0.5" stroke="#c48a2e" stroke-width="1.5" stroke-dasharray="3 2"></rect>
</svg>
<div>
<strong>Gap</strong>: a gap remains between polygons that should continuously cover an area.
</div>
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
<svg viewbox="0 0 110 90" style="width: 80px; height: auto; flex: none;" aria-label="A regular polygon next to a thin sliver">
<rect x="8" y="20" width="45" height="50" fill="#42583c" fill-opacity="0.3" stroke="#2a2117" stroke-width="2"></rect> <rect x="62" y="40" width="42" height="7" fill="#e05252" fill-opacity="0.85" stroke="#2a2117" stroke-width="1.2"></rect>
</svg>
<div>
<strong>Sliver</strong>: a leftover polygon, thin and with no real geographic meaning, often the result of an overlay.
</div>
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
<svg viewbox="0 0 110 90" style="width: 80px; height: auto; flex: none;" aria-label="Two identical, overlapping polygons">
<rect x="25" y="18" width="55" height="50" fill="#e05252" fill-opacity="0.25" stroke="#2a2117" stroke-width="2"></rect> <rect x="31" y="24" width="55" height="50" fill="#e05252" fill-opacity="0.5" stroke="#2a2117" stroke-width="2" stroke-dasharray="4 3"></rect>
</svg>
<div>
<strong>Duplicates</strong>: two features with identical geometry (applies to both polygons and lines).
</div>
</div>
<div style="display: flex; align-items: center; gap: 1rem;">
<svg viewbox="0 0 110 90" style="width: 80px; height: auto; flex: none;" aria-label="A line network with one unconnected end">
<path d="M 15 70 L 55 30 L 95 70" fill="none" stroke="#2a2117" stroke-width="3"></path> <path d="M 55 30 L 55 8" fill="none" stroke="#e05252" stroke-width="3"></path> <circle cx="15" cy="70" r="3.5" fill="#2a2117"></circle> <circle cx="95" cy="70" r="3.5" fill="#2a2117"></circle> <circle cx="55" cy="8" r="4.5" fill="#e05252" stroke="#2a2117" stroke-width="1.2"></circle>
</svg>
<div>
<strong>Dangle</strong>: the end of a line that should connect to another line stays isolated.
</div>
</div>
</div>
</section>
<section id="shared-map-and-table" class="level2">
<h2 class="anchored" data-anchor-id="shared-map-and-table">3. Shared map and table</h2>
<p>Python and R draw to separate layers here too, and both stay visible at once (in their own language tab below), so you can compare them directly. Running one language doesn’t clear or “correct” the other’s result; if the default example below looks unchanged after running the second language, that’s usually because both languages are checking the exact same coordinates and landing on the exact same shapes, not because anything failed.</p>
<div id="lab-map" style="min-height: 500px;">

</div>
<p>Pick a language, then what to look at: the original geometries (with errors highlighted) or just the gaps found between them.</p>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-3-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-3-1" aria-controls="tabset-3-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-3-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-3-2" aria-controls="tabset-3-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-3-1" class="tab-pane active" aria-labelledby="tabset-3-1-tab">
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">Original geometries</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">Gaps</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="lab-features-py">

</div>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div id="lab-gaps-py">

</div>
</div>
</div>
</div>
</div>
<div id="tabset-3-2" class="tab-pane" aria-labelledby="tabset-3-2-tab">
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-2-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-2-1" aria-controls="tabset-2-1" aria-selected="true">Original geometries</a></li><li class="nav-item"><a class="nav-link" id="tabset-2-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-2-2" aria-controls="tabset-2-2" aria-selected="false">Gaps</a></li></ul>
<div class="tab-content">
<div id="tabset-2-1" class="tab-pane active" aria-labelledby="tabset-2-1-tab">
<div id="lab-features-r">

</div>
</div>
<div id="tabset-2-2" class="tab-pane" aria-labelledby="tabset-2-2-tab">
<div id="lab-gaps-r">

</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="lab-status">

</div>
<div id="lab-reset">

</div>
<div id="lab-download">

</div>
<section id="first-run-once-only" class="level3">
<h3 class="anchored" data-anchor-id="first-run-once-only">3.1 First run (once only)</h3>
<p>Defines all the detection functions. There’s no Python/R map here, that’s already ready above and knows nothing about these calculations. Run it once before the examples below.</p>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-4-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-4-1" aria-controls="tabset-4-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-4-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-4-2" aria-controls="tabset-4-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-4-1" class="tab-pane active" aria-labelledby="tabset-4-1-tab">
<div id="topology-errors-setup-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("topology-errors-setup-py", {
    language: "python",
    initialCode: "import math\nimport geopandas as gpd\nfrom shapely.geometry import mapping\n\n# Real square meters, not raw square degrees: shapely's own .area on\n# WGS84-degree coordinates is a \"square degrees\" number with no\n# physical meaning (its magnitude depends on latitude and carries no\n# real-world unit), while R's st_area() computes a proper geographic\n# area by default. The exact same overlap reported 0.001 in Python\n# and 9203648.85 (m²) in R -- not a bug in either engine, but wildly\n# inconsistent units that make the two languages' numbers look\n# unrelated instead of directly comparable. The fix is the same\n# principle as projecting before buffering or clustering: measure\n# distance and area in a local, linear-unit CRS, never in raw degrees.\ndef _project_area(geom):\n    lon, lat = geom.centroid.x, geom.centroid.y\n    zone = int((lon + 180) // 6) + 1\n    epsg = (32600 if lat >= 0 else 32700) + zone\n    return gpd.GeoSeries([geom], crs=\"EPSG:4326\").to_crs(epsg).iloc[0].area\n\ndef geom_family(gdf):\n    types = set(gdf.geometry.geom_type)\n    if types <= {\"Polygon\", \"MultiPolygon\"}:\n        return \"polygon\"\n    if types <= {\"LineString\", \"MultiLineString\"}:\n        return \"line\"\n    if types <= {\"Point\", \"MultiPoint\"}:\n        return \"point\"\n    return \"mixed\"\n\ndef check_overlap(gdf):\n    n = len(gdf)\n    results = [[] for _ in range(n)]\n    for i in range(n):\n        for j in range(i + 1, n):\n            gi, gj = gdf.geometry.iloc[i], gdf.geometry.iloc[j]\n            if gi.overlaps(gj):\n                area = _project_area(gi.intersection(gj))\n                results[i].append((\"overlap\", j, area))\n                results[j].append((\"overlap\", i, area))\n    return results\n\ndef check_duplicate(gdf):\n    n = len(gdf)\n    results = [[] for _ in range(n)]\n    for i in range(n):\n        for j in range(i + 1, n):\n            if gdf.geometry.iloc[i].equals(gdf.geometry.iloc[j]):\n                results[i].append((\"duplicate\", j, 0))\n                results[j].append((\"duplicate\", i, 0))\n    return results\n\ndef check_sliver(gdf, compactness_threshold=0.15):\n    results = [[] for _ in range(len(gdf))]\n    for i, geom in enumerate(gdf.geometry):\n        perim = geom.length\n        area = geom.area\n        if perim > 0:\n            # Polsby-Popper compactness index: bounded at 1 for a\n            # circle (the most compact shape for a given perimeter),\n            # so a value near 0 means a long, thin shape -- the same\n            # metric used outside GIS to flag oddly-shaped districts\n            # in redistricting analysis.\n            compactness = 4 * math.pi * area / (perim ** 2)\n            if compactness < compactness_threshold:\n                results[i].append((\"sliver\", None, compactness))\n    return results\n\ndef check_gap(gdf, min_area=1.0, proximity=0.06):\n    # `min_area` is in SQUARE METERS (via _project_area below), not in\n    # the raw square degrees shapely would return on WGS84 coordinates:\n    # 1e-6 square degrees is about 9,000 m² at this latitude, so a\n    # threshold that looks tiny would silently discard real gaps.\n    #\n    # Per pair, not on the whole dataset: a single convex hull over\n    # all features would flag as \"gap\" even the simple fact that two\n    # unaligned shapes leave space outside their union. Here we\n    # only look at pairs that are CLOSE (within `proximity`) and not\n    # already overlapping, and use the convex hull of the pair alone.\n    n = len(gdf)\n    gaps = []\n    for i in range(n):\n        for j in range(i + 1, n):\n            gi, gj = gdf.geometry.iloc[i], gdf.geometry.iloc[j]\n            if gi.overlaps(gj):\n                continue\n            if gi.equals(gj):\n                # Identical geometries (e.g. a duplicate): difference\n                # always empty, and some GEOS convex_hull/difference\n                # combinations on a degenerate pair can raise an error\n                # instead of returning a clean empty result, so this\n                # case is skipped before it gets there.\n                continue\n            if gi.distance(gj) > proximity:\n                continue\n            pair_union = gi.union(gj)\n            gap_geom = pair_union.convex_hull.difference(pair_union)\n            if not gap_geom.is_empty:\n                parts = gap_geom.geoms if hasattr(gap_geom, \"geoms\") else [gap_geom]\n                for part in parts:\n                    if _project_area(part) > min_area:\n                        gaps.append(part)\n    return gaps\n\ndef check_dangle(gdf, tolerance=1e-9):\n    endpoints = []\n    for i, geom in enumerate(gdf.geometry):\n        coords = list(geom.coords)\n        endpoints.append((i, \"start\", coords[0]))\n        endpoints.append((i, \"end\", coords[-1]))\n    results = [[] for _ in range(len(gdf))]\n    for i, which, (x, y) in endpoints:\n        connected = False\n        for j, which2, (x2, y2) in endpoints:\n            if j == i:\n                continue\n            if ((x - x2) ** 2 + (y - y2) ** 2) ** 0.5 <= tolerance:\n                connected = True\n                break\n        if not connected:\n            results[i].append((\"dangle\", which, 0))\n    return results\n\ndef detect_and_check(gdf):\n    family = geom_family(gdf)\n    n = len(gdf)\n    per_feature = [[] for _ in range(n)]\n    extra_features = []\n\n    dup = check_duplicate(gdf)\n    for i in range(n):\n        per_feature[i].extend(dup[i])\n\n    if family == \"polygon\":\n        ov = check_overlap(gdf)\n        sl = check_sliver(gdf)\n        for i in range(n):\n            per_feature[i].extend(ov[i])\n            per_feature[i].extend(sl[i])\n        for gap_geom in check_gap(gdf):\n            extra_features.append({\n                \"type\": \"Feature\",\n                \"properties\": {\n                    \"name\": \"gap\", \"error_type\": \"gap\", \"has_error\": True,\n                    \"partner\": \"\", \"metric\": round(_project_area(gap_geom), 6), \"severity\": \"warning\"\n                },\n                \"geometry\": mapping(gap_geom)\n            })\n    elif family == \"line\":\n        dg = check_dangle(gdf)\n        for i in range(n):\n            per_feature[i].extend(dg[i])\n\n    # Report columns assigned onto gdf itself (then gdf.__geo_interface__\n    # below), instead of building each feature's properties dict from\n    # scratch: that would discard every other column the uploaded file\n    # had, keeping only \"name\".\n    if \"name\" not in gdf.columns:\n        gdf[\"name\"] = [f\"geometry {i+1}\" for i in range(n)]\n    names = gdf[\"name\"]\n\n    error_types, has_errors, partners, metrics, severities = [], [], [], [], []\n    for i in range(n):\n        errs = per_feature[i]\n        has_error = len(errs) > 0\n        partner_idx = errs[0][1] if has_error and isinstance(errs[0][1], int) else None\n        error_types.append(errs[0][0] if has_error else \"\")\n        has_errors.append(has_error)\n        partners.append(str(names.iloc[partner_idx]) if partner_idx is not None else \"\")\n        metrics.append(round(errs[0][2], 6) if has_error else 0)\n        severities.append(\"error\" if has_error else \"\")\n\n    gdf[\"error_type\"] = error_types\n    gdf[\"has_error\"] = has_errors\n    gdf[\"partner\"] = partners\n    gdf[\"metric\"] = metrics\n    gdf[\"severity\"] = severities\n\n    for col in gdf.columns:\n        if col != \"geometry\":\n            gdf[col] = gdf[col].fillna(\"\")\n\n    # Kept as two separate collections instead of one concatenated\n    # list: a gap is a synthetic feature (no corresponding row in the\n    # uploaded file), and merging it in would inflate the feature\n    # count (a real 10-feature file would be reported as 54, from 44\n    # pairwise \"gap\" candidates). JS puts each collection on its own\n    # map source (topology-errors-py / topology-errors-gaps-py, same\n    # per language for R).\n    return {\n        \"features\": gdf.__geo_interface__,\n        \"gaps\": {\"type\": \"FeatureCollection\", \"features\": extra_features}\n    }\n\n\"detect_and_check() ready — run the examples below\"",
    preload: { packages: ["geopandas","shapely"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, { packages: ["geopandas","shapely"] }));
    }
  });
});
</script>
</div>
<div id="tabset-4-2" class="tab-pane" aria-labelledby="tabset-4-2-tab">
<div id="topology-errors-setup-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("topology-errors-setup-r", {
    language: "r",
    initialCode: "library(sf)\nlibrary(geojsonsf)\n\ngeom_family <- function(data) {\n  types <- unique(as.character(st_geometry_type(data)))\n  if (all(types %in% c(\"POLYGON\", \"MULTIPOLYGON\"))) return(\"polygon\")\n  if (all(types %in% c(\"LINESTRING\", \"MULTILINESTRING\"))) return(\"line\")\n  if (all(types %in% c(\"POINT\", \"MULTIPOINT\"))) return(\"point\")\n  \"mixed\"\n}\n\ncheck_overlap <- function(data) {\n  n <- nrow(data)\n  results <- vector(\"list\", n)\n  for (i in seq_len(n)) results[[i]] <- list()\n  if (n > 1) {\n    for (i in 1:(n - 1)) {\n      for (j in (i + 1):n) {\n        if (isTRUE(st_overlaps(data[i, ], data[j, ], sparse = FALSE)[1, 1])) {\n          inter <- st_intersection(st_geometry(data)[i], st_geometry(data)[j])\n          area <- as.numeric(st_area(inter))\n          results[[i]] <- append(results[[i]], list(list(type = \"overlap\", partner = j, metric = area)))\n          results[[j]] <- append(results[[j]], list(list(type = \"overlap\", partner = i, metric = area)))\n        }\n      }\n    }\n  }\n  results\n}\n\ncheck_duplicate <- function(data) {\n  n <- nrow(data)\n  results <- vector(\"list\", n)\n  for (i in seq_len(n)) results[[i]] <- list()\n  if (n > 1) {\n    for (i in 1:(n - 1)) {\n      for (j in (i + 1):n) {\n        if (isTRUE(st_equals(data[i, ], data[j, ], sparse = FALSE)[1, 1])) {\n          results[[i]] <- append(results[[i]], list(list(type = \"duplicate\", partner = j, metric = 0)))\n          results[[j]] <- append(results[[j]], list(list(type = \"duplicate\", partner = i, metric = 0)))\n        }\n      }\n    }\n  }\n  results\n}\n\ncheck_sliver <- function(data, compactness_threshold = 0.15) {\n  # Per feature, with tryCatch: st_area()/st_length() on a\n  # geometrically INVALID geometry (self-intersecting: this check\n  # doesn't assume valid input, only correct relationships between\n  # valid features) can raise a real GEOS/s2 error (\"Loop 0 is not\n  # valid\") instead of returning a number. A feature like that is\n  # skipped for this check instead of crashing the whole cell.\n  n <- nrow(data)\n  results <- vector(\"list\", n)\n  for (i in seq_len(n)) {\n    results[[i]] <- list()\n    metrics <- tryCatch({\n      area <- as.numeric(st_area(data[i, ]))\n      perim <- as.numeric(st_length(st_boundary(data[i, ])))\n      list(area = area, perim = perim)\n    }, error = function(e) NULL)\n    if (is.null(metrics) || metrics$perim <= 0) next\n    # Polsby-Popper compactness index: bounded at 1 for a circle\n    # (the most compact shape for a given perimeter), so a value near\n    # 0 means a long, thin shape.\n    compactness <- 4 * pi * metrics$area / (metrics$perim^2)\n    if (compactness < compactness_threshold) {\n      results[[i]] <- append(results[[i]], list(list(type = \"sliver\", partner = NA, metric = compactness)))\n    }\n  }\n  results\n}\n\ncheck_gap <- function(data, min_area = 1.0, proximity = 8000) {\n  # Per pair, not on the whole dataset — same reason as the Python\n  # side: a single convex hull over all features gives false positives\n  # for pairs that are simply not aligned with each other (overlap,\n  # sliver).\n  #\n  # `min_area` is in SQUARE METERS, the unit st_area() already returns\n  # here, and matches the Python side's own threshold.\n  #\n  # `proximity` here is in METERS, not degrees: unlike shapely (which\n  # operates \"planar\" on raw lon/lat coordinates, in degrees), sf with\n  # s2 active (the default for a geographic CRS) computes st_distance\n  # geodetically, in meters — different thresholds per language, same\n  # practical meaning (\"close enough to look like an oversight\").\n  n <- nrow(data)\n  gaps <- list()\n  if (n > 1) {\n    for (i in 1:(n - 1)) {\n      for (j in (i + 1):n) {\n        if (isTRUE(st_overlaps(data[i, ], data[j, ], sparse = FALSE)[1, 1])) next\n        if (isTRUE(st_equals(data[i, ], data[j, ], sparse = FALSE)[1, 1])) next\n        # Identical geometries (e.g. a duplicate): difference always\n        # empty, and the st_convex_hull/st_difference combination on a\n        # degenerate pair can raise a GEOS error (\"Edge is\n        # degenerate\") instead of returning a clean empty result, so\n        # this case is skipped before it gets there.\n        d <- as.numeric(st_distance(data[i, ], data[j, ]))\n        if (d > proximity) next\n        # BARE geometries (sf::st_geometry(...)[i], not data[i, ]) for\n        # union/convex_hull/difference: calling these functions on sf\n        # objects (data.frame) instead of plain sfc triggers the *.sf\n        # methods, which try to rebuild the attribute columns and fail\n        # here with a row-realignment error. Same pattern already\n        # used correctly in check_overlap() above.\n        gi <- st_geometry(data)[i]\n        gj <- st_geometry(data)[j]\n        pair_union <- st_union(gi, gj)\n        hull <- st_convex_hull(pair_union)\n        gap_geom <- st_difference(hull, pair_union)\n        if (length(gap_geom) > 0 && !isTRUE(st_is_empty(gap_geom)[1])) {\n          # st_difference between a hull and a union with nearly\n          # coincident edges can return a mixed GEOMETRYCOLLECTION (a\n          # tiny linear residue alongside the real polygon, due to\n          # GEOS imprecision) — st_collection_extract reliably isolates\n          # only the polygonal part, unlike st_cast (verified\n          # empirically: st_cast here produced a mixed-type object\n          # that broke the following assignment).\n          polys <- st_collection_extract(st_sfc(gap_geom, crs = st_crs(data)), \"POLYGON\", warn = FALSE)\n          for (k in seq_along(polys)) {\n            a <- as.numeric(st_area(polys[k]))\n            if (a > min_area) gaps[[length(gaps) + 1]] <- polys[k]\n          }\n        }\n      }\n    }\n  }\n  gaps\n}\n\ncheck_dangle <- function(data, tolerance = 1e-9) {\n  n <- nrow(data)\n  results <- vector(\"list\", n)\n  for (i in seq_len(n)) results[[i]] <- list()\n  endpoints <- list()\n  for (i in seq_len(n)) {\n    p <- st_cast(st_geometry(data)[i], \"POINT\", warn = FALSE)\n    coords <- st_coordinates(p)\n    endpoints[[length(endpoints) + 1]] <- list(feature = i, which = \"start\", x = coords[1, 1], y = coords[1, 2])\n    endpoints[[length(endpoints) + 1]] <- list(feature = i, which = \"end\", x = coords[nrow(coords), 1], y = coords[nrow(coords), 2])\n  }\n  for (a in endpoints) {\n    connected <- FALSE\n    for (b in endpoints) {\n      if (b$feature == a$feature) next\n      d <- sqrt((a$x - b$x)^2 + (a$y - b$y)^2)\n      if (d <= tolerance) { connected <- TRUE; break }\n    }\n    if (!connected) {\n      results[[a$feature]] <- append(results[[a$feature]], list(list(type = \"dangle\", partner = a$which, metric = 0)))\n    }\n  }\n  results\n}\n\ndetect_and_check <- function(data) {\n  family <- geom_family(data)\n  n <- nrow(data)\n  per_feature <- vector(\"list\", n)\n  for (i in seq_len(n)) per_feature[[i]] <- list()\n  extra_features <- list()\n\n  dup <- check_duplicate(data)\n  for (i in seq_len(n)) per_feature[[i]] <- append(per_feature[[i]], dup[[i]])\n\n  if (family == \"polygon\") {\n    ov <- check_overlap(data)\n    sl <- check_sliver(data)\n    for (i in seq_len(n)) {\n      per_feature[[i]] <- append(per_feature[[i]], ov[[i]])\n      per_feature[[i]] <- append(per_feature[[i]], sl[[i]])\n    }\n    for (gap_geom in check_gap(data)) {\n      extra_features[[length(extra_features) + 1]] <- list(\n        type = \"Feature\",\n        properties = list(\n          name = \"gap\", error_type = \"gap\", has_error = TRUE,\n          partner = \"\", metric = round(as.numeric(st_area(gap_geom)), 6), severity = \"warning\"\n        ),\n        geometry = jsonlite::fromJSON(sf_geojson(st_sf(geometry = gap_geom)), simplifyVector = FALSE)$features[[1]]$geometry\n      )\n    }\n  } else if (family == \"line\") {\n    dg <- check_dangle(data)\n    for (i in seq_len(n)) per_feature[[i]] <- append(per_feature[[i]], dg[[i]])\n  }\n\n  # Report columns assigned onto `data` itself (then a single\n  # geojsonsf::sf_geojson(data) below), instead of building each\n  # feature's properties list from scratch per row: that would\n  # discard every other column the uploaded file had, keeping only\n  # \"name\".\n  if (!(\"name\" %in% names(data))) {\n    data$name <- paste(\"geometry\", seq_len(n))\n  }\n  names_col <- data$name\n\n  error_type_col <- character(n)\n  has_error_col <- logical(n)\n  partner_col <- character(n)\n  metric_col <- numeric(n)\n  severity_col <- character(n)\n  for (i in seq_len(n)) {\n    errs <- per_feature[[i]]\n    has_error <- length(errs) > 0\n    first <- if (has_error) errs[[1]] else NULL\n    error_type_col[i] <- if (has_error) first$type else \"\"\n    partner_col[i] <- if (has_error && is.numeric(first$partner)) as.character(names_col[first$partner]) else \"\"\n    metric_col[i] <- if (has_error) round(first$metric, 6) else 0\n    has_error_col[i] <- has_error\n    severity_col[i] <- if (has_error) \"error\" else \"\"\n  }\n\n  data$error_type <- error_type_col\n  data$has_error <- has_error_col\n  data$partner <- partner_col\n  data$metric <- metric_col\n  data$severity <- severity_col\n\n  # Kept as two separate collections instead of one concatenated\n  # list: a gap is a synthetic feature (no corresponding row in the\n  # uploaded file), and merging it in would inflate the feature count.\n  list(\n    features = jsonlite::fromJSON(sf_geojson(data), simplifyVector = FALSE),\n    gaps = list(type = \"FeatureCollection\", features = extra_features)\n  )\n}\n\n\"detect_and_check() ready — run the examples below\"",
    preload: { packages: ["sf","geojsonsf","jsonlite"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["sf","geojsonsf","jsonlite"] }));
    }
  });
});
</script>
</div>
</div>
</div>
</section>
<section id="reusable-cell-pair" class="level3">
<h3 class="anchored" data-anchor-id="reusable-cell-pair">3.2 Reusable cell pair</h3>
<p>The code below changes with every “Load this example” button further down.</p>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-5-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-5-1" aria-controls="tabset-5-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-5-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-5-2" aria-controls="tabset-5-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-5-1" class="tab-pane active" aria-labelledby="tabset-5-1-tab">
<div id="topology-errors-example-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("topology-errors-example-py", {
    language: "python",
    initialCode: "if \"detect_and_check\" not in globals():\n    raise RuntimeError(\"Run the setup cell above (section 3.1) first — it defines detect_and_check().\")\n\nimport geopandas as gpd\nfrom shapely.geometry import shape\n\na = {\"type\": \"Polygon\", \"coordinates\": [[[12.45, 41.85], [12.52, 41.85], [12.52, 41.92], [12.45, 41.92], [12.45, 41.85]]]}\nb = {\"type\": \"Polygon\", \"coordinates\": [[[12.50, 41.87], [12.57, 41.87], [12.57, 41.94], [12.50, 41.94], [12.50, 41.87]]]}\ngdf = gpd.GeoDataFrame({\"name\": [\"A\", \"B\"]}, geometry=[shape(a), shape(b)], crs=\"EPSG:4326\")\n\ndetect_and_check(gdf)",
    preload: {},
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, {}));
    }
  });
});
</script>
</div>
<div id="tabset-5-2" class="tab-pane" aria-labelledby="tabset-5-2-tab">
<div id="topology-errors-example-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("topology-errors-example-r", {
    language: "r",
    initialCode: "if (!exists(\"detect_and_check\")) {\n  stop(\"Run the setup cell above (section 3.1) first -- it defines detect_and_check().\")\n}\n\na <- st_polygon(list(rbind(c(12.45, 41.85), c(12.52, 41.85), c(12.52, 41.92), c(12.45, 41.92), c(12.45, 41.85))))\nb <- st_polygon(list(rbind(c(12.50, 41.87), c(12.57, 41.87), c(12.57, 41.94), c(12.50, 41.94), c(12.50, 41.87))))\ndata <- st_sf(name = c(\"A\", \"B\"), geometry = st_sfc(a, b, crs = 4326))\n\ndetect_and_check(data)",
    preload: {},
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, {}));
    }
  });
});
</script>
</div>
</div>
</div>
</section>
</section>
<section id="five-errors-five-examples" class="level2 page-columns page-full">
<h2 class="anchored" data-anchor-id="five-errors-five-examples">4. Five errors, five examples</h2>
<section id="overlap" class="level3">
<h3 class="anchored" data-anchor-id="overlap">4.1 Overlap</h3>
<p>Two polygons that should be adjacent instead overlap over a portion of surface: the example already loaded by default in the cell pair above.</p>
<div id="webgeods-example-1"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-1").appendChild(
    window.WebGeoDS.loadExampleButton("Load the overlap example", { pyId: "topology-errors-example-py", pyCode: "import geopandas as gpd\nfrom shapely.geometry import shape\n\na = {\"type\": \"Polygon\", \"coordinates\": [[[12.45, 41.85], [12.52, 41.85], [12.52, 41.92], [12.45, 41.92], [12.45, 41.85]]]}\nb = {\"type\": \"Polygon\", \"coordinates\": [[[12.50, 41.87], [12.57, 41.87], [12.57, 41.94], [12.50, 41.94], [12.50, 41.87]]]}\ngdf = gpd.GeoDataFrame({\"name\": [\"A\", \"B\"]}, geometry=[shape(a), shape(b)], crs=\"EPSG:4326\")\n\ndetect_and_check(gdf)", rId: "topology-errors-example-r", rCode: "a <- st_polygon(list(rbind(c(12.45, 41.85), c(12.52, 41.85), c(12.52, 41.92), c(12.45, 41.92), c(12.45, 41.85))))\nb <- st_polygon(list(rbind(c(12.50, 41.87), c(12.57, 41.87), c(12.57, 41.94), c(12.50, 41.94), c(12.50, 41.87))))\ndata <- st_sf(name = c(\"A\", \"B\"), geometry = st_sfc(a, b, crs = 4326))\n\ndetect_and_check(data)" }));
});
</script>
</section>
<section id="gap" class="level3 page-columns page-full">
<h3 class="anchored" data-anchor-id="gap">4.2 Gap</h3>
<p>Two aligned polygons that should cover a single continuous area instead leave a gap between them. It’s detected by comparing the union of the polygons with the convex hull containing both.<sup>1</sup></p>
<div class="no-row-height column-margin column-container"><div id="fn1"><p><sup>1</sup>&nbsp;The gap is shown as its own feature (a synthetic geometry, not one of yours), colored like the other error rows. The check only looks at polygon pairs that are <strong>close and not already overlapping</strong>, using the convex hull of the single pair, not of the whole dataset (that would flag as “gap” even the simple fact that two distant or overlapping shapes aren’t aligned with each other). For a real dataset with a known study area, that area is still the more correct reference, not a convex hull.</p></div></div><div id="webgeods-example-2"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-2").appendChild(
    window.WebGeoDS.loadExampleButton("Load the gap example", { pyId: "topology-errors-example-py", pyCode: "import geopandas as gpd\nfrom shapely.geometry import shape\n\na = {\"type\": \"Polygon\", \"coordinates\": [[[12.40, 41.85], [12.46, 41.85], [12.46, 41.92], [12.40, 41.92], [12.40, 41.85]]]}\nb = {\"type\": \"Polygon\", \"coordinates\": [[[12.50, 41.85], [12.56, 41.85], [12.56, 41.92], [12.50, 41.92], [12.50, 41.85]]]}\ngdf = gpd.GeoDataFrame({\"name\": [\"A\", \"B\"]}, geometry=[shape(a), shape(b)], crs=\"EPSG:4326\")\n\ndetect_and_check(gdf)", rId: "topology-errors-example-r", rCode: "a <- st_polygon(list(rbind(c(12.40, 41.85), c(12.46, 41.85), c(12.46, 41.92), c(12.40, 41.92), c(12.40, 41.85))))\nb <- st_polygon(list(rbind(c(12.50, 41.85), c(12.56, 41.85), c(12.56, 41.92), c(12.50, 41.92), c(12.50, 41.85))))\ndata <- st_sf(name = c(\"A\", \"B\"), geometry = st_sfc(a, b, crs = 4326))\n\ndetect_and_check(data)" }));
});
</script>
</section>
<section id="sliver" class="level3 page-columns page-full">
<h3 class="anchored" data-anchor-id="sliver">4.3 Sliver</h3>
<p>An extremely thin polygon (0.1° wide, 0.001° tall), a typical leftover from an overlay between nearly coincident boundaries. Detected with the Polsby-Popper compactness index (4π·area/perimeter²): below 0.15 is considered a sliver.<sup>2</sup></p>
<div class="no-row-height column-margin column-container"><div id="fn2"><p><sup>2</sup>&nbsp;The index is bounded at the <em>most</em> compact shape possible for a given perimeter (a circle scores exactly 1), so 0.15 means “less than a sixth as compact as the roundest shape with the same perimeter would be,” not an arbitrary cutoff. A perfect square scores about 0.785, for a sense of scale between the two.</p></div></div><div id="webgeods-example-3"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-3").appendChild(
    window.WebGeoDS.loadExampleButton("Load the sliver example", { pyId: "topology-errors-example-py", pyCode: "import geopandas as gpd\nfrom shapely.geometry import shape\n\nclean = {\"type\": \"Polygon\", \"coordinates\": [[[12.45, 41.85], [12.50, 41.85], [12.50, 41.90], [12.45, 41.90], [12.45, 41.85]]]}\nsliver = {\"type\": \"Polygon\", \"coordinates\": [[[12.75, 41.87], [12.85, 41.87], [12.85, 41.871], [12.75, 41.871], [12.75, 41.87]]]}\ngdf = gpd.GeoDataFrame({\"name\": [\"regular polygon\", \"sliver\"]}, geometry=[shape(clean), shape(sliver)], crs=\"EPSG:4326\")\n\ndetect_and_check(gdf)", rId: "topology-errors-example-r", rCode: "clean <- st_polygon(list(rbind(c(12.45, 41.85), c(12.50, 41.85), c(12.50, 41.90), c(12.45, 41.90), c(12.45, 41.85))))\nsliver <- st_polygon(list(rbind(c(12.75, 41.87), c(12.85, 41.87), c(12.85, 41.871), c(12.75, 41.871), c(12.75, 41.87))))\ndata <- st_sf(name = c(\"regular polygon\", \"sliver\"), geometry = st_sfc(clean, sliver, crs = 4326))\n\ndetect_and_check(data)" }));
});
</script>
</section>
<section id="duplicates" class="level3">
<h3 class="anchored" data-anchor-id="duplicates">4.4 Duplicates</h3>
<p>Two features with identical geometry, something that happens often during merges or repeated imports. The check applies the same way to polygons and lines (topological equality), shown here on polygons.</p>
<div id="webgeods-example-4"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-4").appendChild(
    window.WebGeoDS.loadExampleButton("Load the duplicates example", { pyId: "topology-errors-example-py", pyCode: "import geopandas as gpd\nfrom shapely.geometry import shape\n\na = {\"type\": \"Polygon\", \"coordinates\": [[[12.45, 41.85], [12.50, 41.85], [12.50, 41.90], [12.45, 41.90], [12.45, 41.85]]]}\nc = {\"type\": \"Polygon\", \"coordinates\": [[[12.75, 41.85], [12.80, 41.85], [12.80, 41.90], [12.75, 41.90], [12.75, 41.85]]]}\ngdf = gpd.GeoDataFrame({\"name\": [\"A\", \"copy of A\", \"C\"]}, geometry=[shape(a), shape(a), shape(c)], crs=\"EPSG:4326\")\n\ndetect_and_check(gdf)", rId: "topology-errors-example-r", rCode: "a <- st_polygon(list(rbind(c(12.45, 41.85), c(12.50, 41.85), c(12.50, 41.90), c(12.45, 41.90), c(12.45, 41.85))))\nc <- st_polygon(list(rbind(c(12.75, 41.85), c(12.80, 41.85), c(12.80, 41.90), c(12.75, 41.90), c(12.75, 41.85))))\ndata <- st_sf(name = c(\"A\", \"copy of A\", \"C\"), geometry = st_sfc(a, a, c, crs = 4326))\n\ndetect_and_check(data)" }));
});
</script>
</section>
<section id="dangle" class="level3">
<h3 class="anchored" data-anchor-id="dangle">4.5 Dangle</h3>
<p>A line network where three segments form a closed loop (each end touches exactly the next segment’s end, no dangle) and a fourth stays isolated, with both ends “dangling”. Notice how the layer on the map switches from fill to line: the code itself decides that, based on the geometry type you feed it.</p>
<div id="webgeods-example-5"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-5").appendChild(
    window.WebGeoDS.loadExampleButton("Load the dangle example", { pyId: "topology-errors-example-py", pyCode: "import geopandas as gpd\nfrom shapely.geometry import shape\n\n# L1-L2-L3 form a closed loop (each end touches the next one): no\n# dangle. An OPEN path would still have two free ends by definition —\n# it wouldn't be a clean example of \"everything connected\".\nl1 = {\"type\": \"LineString\", \"coordinates\": [[12.45, 41.85], [12.50, 41.85]]}\nl2 = {\"type\": \"LineString\", \"coordinates\": [[12.50, 41.85], [12.50, 41.90]]}\nl3 = {\"type\": \"LineString\", \"coordinates\": [[12.50, 41.90], [12.45, 41.85]]}\nl4 = {\"type\": \"LineString\", \"coordinates\": [[12.52, 41.87], [12.56, 41.87]]}\ngdf = gpd.GeoDataFrame({\"name\": [\"L1\", \"L2\", \"L3\", \"L4 isolated\"]}, geometry=[shape(l1), shape(l2), shape(l3), shape(l4)], crs=\"EPSG:4326\")\n\ndetect_and_check(gdf)", rId: "topology-errors-example-r", rCode: "l1 <- st_linestring(rbind(c(12.45, 41.85), c(12.50, 41.85)))\nl2 <- st_linestring(rbind(c(12.50, 41.85), c(12.50, 41.90)))\nl3 <- st_linestring(rbind(c(12.50, 41.90), c(12.45, 41.85)))\nl4 <- st_linestring(rbind(c(12.52, 41.87), c(12.56, 41.87)))\ndata <- st_sf(name = c(\"L1\", \"L2\", \"L3\", \"L4 isolated\"), geometry = st_sfc(l1, l2, l3, l4, crs = 4326))\n\ndetect_and_check(data)" }));
});
</script>
</section>
</section>
<section id="apply-to-your-file" class="level2">
<h2 class="anchored" data-anchor-id="apply-to-your-file">5. Apply to your file</h2>
<p>The cells below try to read <code>/uploaded.geojson</code>, then an uploaded shapefile (<code>/uploaded.shp</code>), then a zipped shapefile (or, without an upload, use the overlap example as test data), detect the geometry type and automatically apply only the relevant checks — same <code>detect_and_check()</code> function as every example above, no duplicated logic.</p>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-6-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-6-1" aria-controls="tabset-6-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-6-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-6-2" aria-controls="tabset-6-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-6-1" class="tab-pane active" aria-labelledby="tabset-6-1-tab">
<div id="topology-errors-uploaded-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("topology-errors-uploaded-py", {
    language: "python",
    initialCode: "if \"detect_and_check\" not in globals():\n    raise RuntimeError(\"Run the setup cell in section 3.1 first — it defines detect_and_check().\")\n\nimport geopandas as gpd\nfrom shapely.geometry import shape\n\ngdf = None\nfor path in [\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\"]:\n    try:\n        gdf = gpd.read_file(path)\n        break\n    except Exception:\n        pass\n\n# check_gap()'s proximity/check_sliver()'s compactness above work in\n# gdf's OWN coordinate units — reprojected to WGS84 here (same units\n# every example on this page already uses) before detect_and_check()\n# runs, not just before the map draws it.\ncrs_warning = None\noriginal_crs = None\nif gdf is not None:\n    if gdf.crs is None:\n        gdf = gdf.set_crs(\"EPSG:4326\")\n        crs_warning = \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\n    original_crs = gdf.crs\n    if original_crs != \"EPSG:4326\":\n        gdf = gdf.to_crs(\"EPSG:4326\")\n\nif gdf is not None and \"name\" not in gdf.columns:\n    gdf[\"name\"] = [f\"geometry {i+1}\" for i in range(len(gdf))]\n\nif gdf is None:\n    a = {\"type\": \"Polygon\", \"coordinates\": [[[12.45, 41.85], [12.52, 41.85], [12.52, 41.92], [12.45, 41.92], [12.45, 41.85]]]}\n    b = {\"type\": \"Polygon\", \"coordinates\": [[[12.50, 41.87], [12.57, 41.87], [12.57, 41.94], [12.50, 41.94], [12.50, 41.87]]]}\n    gdf = gpd.GeoDataFrame({\"name\": [\"A (example)\", \"B (example)\"]}, geometry=[shape(a), shape(b)], crs=\"EPSG:4326\")\n    original_crs = gdf.crs\n\nresult = detect_and_check(gdf)\n# Reprojects the now report-annotated gdf back to the CRS it was\n# uploaded in — used by the download button further up the page, so\n# what comes out matches what went in even though the checks above\n# always run in WGS84.\nresult[\"originalCrsFeatures\"] = (\n    gdf.to_crs(original_crs).__geo_interface__\n    if original_crs is not None and original_crs != \"EPSG:4326\"\n    else result[\"features\"]\n)\nresult[\"crsWarning\"] = crs_warning\nresult",
    preload: {},
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, {}));
    }
  });
});
</script>
</div>
<div id="tabset-6-2" class="tab-pane" aria-labelledby="tabset-6-2-tab">
<div id="topology-errors-uploaded-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("topology-errors-uploaded-r", {
    language: "r",
    initialCode: "if (!exists(\"detect_and_check\")) {\n  stop(\"Run the setup cell in section 3.1 first -- it defines detect_and_check().\")\n}\n\ndata <- NULL\nfor (path in c(\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\")) {\n  data <- tryCatch(st_read(path, quiet = TRUE), error = function(e) NULL)\n  if (!is.null(data)) break\n}\n\n# check_gap()'s proximity/check_sliver()'s compactness above work in\n# data's OWN coordinate units — reprojected to WGS84 here (same units\n# every example on this page already uses) before detect_and_check()\n# runs, not just before the map draws it.\ncrs_warning <- NULL\noriginal_crs <- NULL\nif (!is.null(data)) {\n  if (is.na(st_crs(data))) {\n    st_crs(data) <- 4326\n    crs_warning <- \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\n  }\n  original_crs <- st_crs(data)\n  if (!isTRUE(original_crs == st_crs(4326))) {\n    data <- st_transform(data, 4326)\n  }\n}\n\nif (!is.null(data) && !(\"name\" %in% names(data))) {\n  data$name <- paste(\"geometry\", seq_len(nrow(data)))\n}\n\nif (is.null(data)) {\n  a <- st_polygon(list(rbind(c(12.45, 41.85), c(12.52, 41.85), c(12.52, 41.92), c(12.45, 41.92), c(12.45, 41.85))))\n  b <- st_polygon(list(rbind(c(12.50, 41.87), c(12.57, 41.87), c(12.57, 41.94), c(12.50, 41.94), c(12.50, 41.87))))\n  data <- st_sf(name = c(\"A (example)\", \"B (example)\"), geometry = st_sfc(a, b, crs = 4326))\n  original_crs <- st_crs(data)\n}\n\nresult <- detect_and_check(data)\n# Reprojects the now report-annotated `data` back to the CRS it was\n# uploaded in — used by the download button further up the page, so\n# what comes out matches what went in even though the checks above\n# always run in WGS84.\nresult$originalCrsFeatures <- if (!isTRUE(original_crs == st_crs(4326))) {\n  jsonlite::fromJSON(sf_geojson(st_transform(data, original_crs)), simplifyVector = FALSE)\n} else {\n  result$features\n}\nresult$crsWarning <- crs_warning\nresult",
    preload: {},
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, {}));
    }
  });
});
</script>
</div>
</div>
</div>
</section>
<section id="knowing-whats-broken-is-already-a-result" class="level2">
<h2 class="anchored" data-anchor-id="knowing-whats-broken-is-already-a-result">6. Knowing what’s broken is already a result</h2>
<p>A report that says “23 overlaps, 8 gaps, 4 slivers” isn’t half a tool: it’s a legitimate, complete quality-assurance step in its own right. Fixing these errors almost always requires a human decision: which polygon to move, who to assign a gap to, whether a 2 m² sliver is noise or a real island. A future article may cover repair strategies; this one deliberately stops before that, where geometry alone can no longer answer the question.</p>
<div id="lab-root"></div>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("topology-errors-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>


 ]]></description>
  <category>tool</category>
  <category>geometry</category>
  <category>bilingual</category>
  <guid>https://webgeods.com/posts/topology-errors.html</guid>
  <pubDate>Mon, 31 Aug 2026 00:00:00 GMT</pubDate>
</item>
<item>
  <title>Geometry validity: find and understand invalid geometries</title>
  <link>https://webgeods.com/posts/geometry-validity.html</link>
  <description><![CDATA[ 




<p>Upload a GeoJSON, find out which geometries are invalid and why, and run the same diagnosis in Python and in R on the same map. Repair (<code>make_valid()</code>/<code>st_make_valid()</code>) is a separate, optional step, at the bottom of the page.</p>
<p>A <code>.geojson</code>, or a shapefile (either as a single <code>.zip</code>, or as <code>.shp</code>/<code>.dbf</code>/<code>.shx</code> selected together): see the section below.</p>
<p>Just want to quickly validate or fix a file, without the rest of the reading? Use the <a href="../tools/geojson-shapefile-validator.html">standalone tool</a> — same engine, less prose.</p>
<div id="lab-upload">

</div>
<section id="can-a-polygon-look-normal-and-still-be-invalid" class="level2">
<h2 class="anchored" data-anchor-id="can-a-polygon-look-normal-and-still-be-invalid">Can a polygon look normal and still be invalid?</h2>
<div class="cell" data-layout-align="default">
<div class="cell-output-display">
<div>
<p></p><figure class="figure"><p></p>
<div>
<pre class="mermaid mermaid-js">flowchart LR
    A["🗺️ Looks fine on the map"] --&gt; B{"Validity check"}
    B --&gt; C["❌ INVALID: why?"]
</pre>
</div>
<p></p></figure><p></p>
</div>
</div>
</div>
<p>Yes: a polygon can have a boundary that crosses itself, a hole that extends outside the outer boundary, or two holes that overlap, and it’s not uncommon for the shape on the map to look perfectly fine. Below you can find out exactly what to look for, in your own file or in one of the six ready-made examples further down.</p>
</section>
<section id="shared-map-and-table" class="level2">
<h2 class="anchored" data-anchor-id="shared-map-and-table">Shared map and table</h2>
<p>A single map and table, shared by every example on the page: run the same diagnosis in Python or R and compare the result here. The two languages draw to two separate layers: running one doesn’t clear or change what the other already drew, so both stay visible together (and in their own tab in the table below) for a direct comparison. Running R doesn’t “correct” or replace Python’s result, and vice versa. If you want to see a language’s result change, re-run that language’s own cell.</p>
<div id="lab-map" style="min-height: 500px;">

</div>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-1-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-1" aria-controls="tabset-1-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-1-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-1-2" aria-controls="tabset-1-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-1-1" class="tab-pane active" aria-labelledby="tabset-1-1-tab">
<div id="lab-table-py">

</div>
</div>
<div id="tabset-1-2" class="tab-pane" aria-labelledby="tabset-1-2-tab">
<div id="lab-table-r">

</div>
</div>
</div>
</div>
<div id="lab-validity">

</div>
<div id="lab-controls">

</div>
</section>
<section id="diagnose-validate-your-geometry" class="level2">
<h2 class="anchored" data-anchor-id="diagnose-validate-your-geometry">Diagnose: validate your geometry</h2>
<p>Run the same check in Python or R on the current data: your file uploaded above, or, if you haven’t uploaded one, the bowtie example below (the diagnosis cells load it automatically when there’s no upload). No repair yet, just diagnosis: is the geometry valid? If not, why?</p>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-2-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-2-1" aria-controls="tabset-2-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-2-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-2-2" aria-controls="tabset-2-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-2-1" class="tab-pane active" aria-labelledby="tabset-2-1-tab">
<div id="geometry-diagnose-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("geometry-diagnose-py", {
    language: "python",
    initialCode: "import re\nimport geopandas as gpd\nfrom shapely.geometry import shape\nfrom shapely.validation import explain_validity\n\ngdf = None\nfor path in [\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\"]:\n    try:\n        gdf = gpd.read_file(path)\n        break\n    except Exception:\n        pass\n\nif gdf is None:\n    bowtie = {\"type\": \"Polygon\", \"coordinates\": [[[12.45, 41.85], [12.55, 41.95], [12.45, 41.95], [12.55, 41.85], [12.45, 41.85]]]}\n    gdf = gpd.GeoDataFrame({\"name\": [\"bowtie\"]}, geometry=[shape(bowtie)], crs=\"EPSG:4326\")\n\n# Diagnosis and the map both need WGS84 (GeoJSON/MapLibre assume it) —\n# original_crs/export_crs stay in the session as globals, reused by\n# geometry-repair-py below to reproject BACK before a download.\ncrs_warning = None\noriginal_crs = gdf.crs\nif original_crs is None:\n    gdf = gdf.set_crs(\"EPSG:4326\")\n    original_crs = gdf.crs\n    crs_warning = \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\nelif original_crs != \"EPSG:4326\":\n    gdf = gdf.to_crs(\"EPSG:4326\")\n\ndef export_crs(g):\n    if g is None or original_crs is None or original_crs == \"EPSG:4326\":\n        return g\n    return g.to_crs(original_crs)\n\nif \"name\" not in gdf.columns:\n    gdf[\"name\"] = [f\"geometry {i+1}\" for i in range(len(gdf))]\n\n# explain_validity() sometimes appends the failure coordinates in\n# brackets (e.g. \"Self-intersection[12.5 41.9]\") — split that into its\n# own \"position\" column instead of leaving it inside the message.\nPOSITION_RE = re.compile(r\"^(.*?)\\[([-\\d.]+)\\s+([-\\d.]+)\\]$\")\n\n# GEOS reports one of a small, CLOSED set of validity failures --\n# verified against TopologyValidationError.cpp in the GEOS source\n# itself (see \"Gallery of geometric errors\" below), not guessed from\n# whichever examples this article happens to show. The raw message\n# always starts with one of these exact names before any bracketed\n# position, so \"category\" is that name itself; with the position split\n# off, the rest of the message never says anything more, which is why\n# there's no separate \"reason\" column here (R's own, below, does say\n# something different).\nGEOS_CATEGORIES = [\n    \"Ring Self-intersection\",\n    \"Self-intersection\",\n    \"Hole lies outside shell\",\n    \"Holes are nested\",\n    \"Interior is disconnected\",\n    \"Nested shells\",\n    \"Duplicate Rings\",\n    \"Too few points\",\n    \"Invalid Coordinate\",\n    \"Repeated Point\",\n    \"Ring is not closed\",\n]\n\ndef classify_reason(message):\n    for category in GEOS_CATEGORIES:\n        if message.startswith(category):\n            return category\n    return \"Other\"\n\ndef diagnose(geom):\n    if geom.is_valid:\n        return True, \"\", \"\"\n    message = explain_validity(geom)\n    m = POSITION_RE.match(message)\n    reason = m.group(1).strip() if m else message\n    position = f\"{m.group(2)} {m.group(3)}\" if m else \"\"\n    return False, classify_reason(reason), position\n\ndiagnosis = gdf.geometry.apply(diagnose)\ngdf[\"valid\"] = diagnosis.apply(lambda t: t[0])\ngdf[\"category\"] = diagnosis.apply(lambda t: t[1])\ngdf[\"position\"] = diagnosis.apply(lambda t: t[2])\n\n# Keep every original column from the uploaded file, not just \"name\":\n# a missing value (None) in one of them would otherwise vanish\n# silently once this result is serialized to JSON for the browser,\n# instead of showing up as an empty value.\nfor col in gdf.columns:\n    if col != \"geometry\":\n        gdf[col] = gdf[col].fillna(\"\")\n\n{\"features\": gdf.__geo_interface__, \"originalCrsFeatures\": export_crs(gdf).__geo_interface__, \"crsWarning\": crs_warning}",
    preload: { packages: ["geopandas","shapely"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, { packages: ["geopandas","shapely"] }));
    }
  });
});
</script>
</div>
<div id="tabset-2-2" class="tab-pane" aria-labelledby="tabset-2-2-tab">
<div id="geometry-diagnose-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("geometry-diagnose-r", {
    language: "r",
    initialCode: "library(sf)\nlibrary(geojsonsf)\ndata <- NULL\nfor (path in c(\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\")) {\n  data <- tryCatch(st_read(path, quiet = TRUE), error = function(e) NULL)\n  if (!is.null(data)) break\n}\nif (is.null(data)) {\n  wkt <- \"POLYGON((12.45 41.85, 12.55 41.95, 12.45 41.95, 12.55 41.85, 12.45 41.85))\"\n  data <- st_sf(name = \"bowtie\", geometry = st_as_sfc(wkt, crs = 4326))\n}\n\n# Diagnosis and the map both need WGS84 (GeoJSON/MapLibre assume it) —\n# original_crs/export_crs stay in the session as globals, reused by\n# geometry-repair-r below to reproject BACK before a download.\ncrs_warning <- NULL\noriginal_crs <- st_crs(data)\nif (is.na(original_crs)) {\n  st_crs(data) <- 4326\n  original_crs <- st_crs(data)\n  crs_warning <- \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\n} else if (!isTRUE(original_crs == st_crs(4326))) {\n  data <- st_transform(data, 4326)\n}\n\nexport_crs <- function(d) {\n  if (is.null(d) || isTRUE(original_crs == st_crs(4326))) return(d)\n  st_transform(d, original_crs)\n}\n\nif (!(\"name\" %in% names(data))) {\n  data$name <- paste(\"geometry\", seq_len(nrow(data)))\n}\n\ndata$valid <- st_is_valid(data)\ndata$reason <- st_is_valid(data, reason = TRUE)\ndata$reason[data$valid] <- NA\n\n# GEOS reports one of a small, CLOSED set of validity failures --\n# verified against TopologyValidationError.cpp in the GEOS source\n# itself (see \"Gallery of geometric errors\" below). st_is_valid(reason\n# = TRUE) uses those SAME exact strings when GEOS is the engine\n# actually running (sf_use_s2(FALSE)) -- but not with S2, sf's own\n# DEFAULT for geographic coordinates like this article's examples:\n# S2 reports WHICH edges cross, not WHICH of GEOS's named categories\n# the crossing represents, because its own validity model has no such\n# categories (see \"Valid according to whom?\" above). Classified only\n# when GEOS is actually in use; labeled \"S2 (no GEOS category)\"\n# otherwise, with the raw S2 message still shown in \"reason\" -- forcing\n# it into a GEOS category it was never reported against would be a\n# guess, not a classification.\nGEOS_CATEGORIES <- c(\n  \"Ring Self-intersection\",\n  \"Self-intersection\",\n  \"Hole lies outside shell\",\n  \"Holes are nested\",\n  \"Interior is disconnected\",\n  \"Nested shells\",\n  \"Duplicate Rings\",\n  \"Too few points\",\n  \"Invalid Coordinate\",\n  \"Repeated Point\",\n  \"Ring is not closed\"\n)\nclassify_reason <- function(message, uses_s2) {\n  if (is.na(message)) return(NA_character_)\n  if (uses_s2) return(\"S2 (no GEOS category)\")\n  for (category in GEOS_CATEGORIES) {\n    if (startsWith(message, category)) return(category)\n  }\n  \"Other\"\n}\nuses_s2 <- sf_use_s2()\ndata$category <- vapply(data$reason, classify_reason, character(1), uses_s2 = uses_s2)\n\nlist(\n  features = jsonlite::fromJSON(sf_geojson(data), simplifyVector = FALSE),\n  originalCrsFeatures = jsonlite::fromJSON(sf_geojson(export_crs(data)), simplifyVector = FALSE),\n  crsWarning = crs_warning\n)",
    preload: { packages: ["sf","geojsonsf","jsonlite"] },
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, { packages: ["sf","geojsonsf","jsonlite"] }));
    }
  });
});
</script>
</div>
</div>
</div>
</section>
<section id="same-spatial-question-two-languages" class="level2 page-columns page-full">
<h2 class="anchored" data-anchor-id="same-spatial-question-two-languages">Same spatial question, two languages</h2>
<p>Python and R answer the same question with one line each:</p>
<p><strong>Python</strong></p>
<div class="sourceCode" id="cb1" style="background: #f1f3f5;"><pre class="sourceCode python code-with-copy"><code class="sourceCode python"><span id="cb1-1">gdf.geometry.is_valid</span></code></pre></div>
<p><strong>R</strong></p>
<div class="sourceCode" id="cb2" style="background: #f1f3f5;"><pre class="sourceCode r code-with-copy"><code class="sourceCode r"><span id="cb2-1">sf<span class="sc" style="color: #5E5E5E;
background-color: null;
font-style: inherit;">::</span><span class="fu" style="color: #4758AB;
background-color: null;
font-style: inherit;">st_is_valid</span>(data)</span></code></pre></div>
<p>Both go through the same geometry engine, GEOS, for this calculation.<sup>1</sup></p>
<div class="no-row-height column-margin column-container"><div id="fn1"><p><sup>1</sup>&nbsp;Don’t expect the exact same text when you ask <em>why</em> a geometry is invalid, though: <code>explain_validity()</code> in Python includes the coordinates of the problem (e.g.&nbsp;<code>"Self-intersection[12.5 41.9]"</code>), while <code>st_is_valid(reason = TRUE)</code> in R describes the edges involved without coordinates (e.g.&nbsp;<code>"Edge 0 crosses edge 2"</code>): same calculation, different vocabulary. Not a bug in either one, just a detail that distinguishes one spatial ecosystem from the other.</p></div></div></section>
<section id="valid-according-to-whom" class="level2">
<h2 class="anchored" data-anchor-id="valid-according-to-whom">Valid according to whom?</h2>
<p>“Valid” isn’t absolute: it depends on the geometric model and the engine checking it.</p>
<ul>
<li><strong>Python</strong> → <code>shapely</code> → always GEOS.</li>
<li><strong>R</strong> → <code>sf</code> → GEOS or S2, depending on <code>sf::sf_use_s2()</code>.</li>
</ul>
<p>For the diagnosis above the difference doesn’t show. It becomes concrete in the repair, below:</p>
<div class="callout callout-style-default callout-important callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Important
</div>
</div>
<div class="callout-body-container callout-body">
<p><strong>Why <code>sf_use_s2(FALSE)</code> before <code>st_make_valid()</code>?</strong> With geographic coordinates (lon/lat) and <code>sf_use_s2(TRUE)</code> (the default), <code>st_make_valid()</code> in R dispatches to <code>s2::s2_rebuild()</code> instead of GEOS: a different algorithm that, on these cases, <strong>fixes nothing</strong>, returning the geometry unchanged without an error. Verified not just on webR but also on real R 4.3.3 with sf 1.0.15: this isn’t a bug in this project, it’s documented <code>sf</code> behavior (see issues #1985 and #1771 on <a href="https://github.com/r-spatial/sf/issues/1985">r-spatial/sf</a>). Forcing <code>sf_use_s2(FALSE)</code> routes the call through GEOS, which works correctly here; it’s turned back on right after, because other operations (distances, areas) on geographic coordinates benefit from S2. <code>shapely</code> in Python doesn’t have this problem because it has no alternative S2 engine: it always goes through GEOS.</p>
</div>
</div>
</section>
<section id="gallery-of-geometric-errors" class="level2">
<h2 class="anchored" data-anchor-id="gallery-of-geometric-errors">Gallery of geometric errors</h2>
<p>GEOS reports one of a small, closed set of validity failures, not a free-text description: an exact list, verified against <a href="https://github.com/libgeos/geos/blob/main/src/operation/valid/TopologyValidationError.cpp">TopologyValidationError.cpp</a> in the GEOS source itself. The diagnose cells above match every message to one of these names, shown in the “category” column; a message that matches none of them shows up as “Other” rather than being forced into the closest one.</p>
<div class="table-responsive">
<table class="caption-top table">
<colgroup>
<col style="width: 33%">
<col style="width: 33%">
<col style="width: 33%">
</colgroup>
<thead>
<tr class="header">
<th>Category</th>
<th>What happens</th>
<th style="text-align: center;">Example below</th>
</tr>
</thead>
<tbody>
<tr class="odd">
<td>Self-intersection</td>
<td>The boundary crosses itself</td>
<td style="text-align: center;">✓</td>
</tr>
<tr class="even">
<td>Ring Self-intersection</td>
<td>The boundary touches itself at a point, without crossing</td>
<td style="text-align: center;">✓</td>
</tr>
<tr class="odd">
<td>Hole lies outside shell</td>
<td>A hole sits entirely outside the outer boundary*</td>
<td style="text-align: center;">✓</td>
</tr>
<tr class="even">
<td>Holes are nested</td>
<td>A hole contains its own shell*</td>
<td style="text-align: center;">✓</td>
</tr>
<tr class="odd">
<td>Interior is disconnected</td>
<td>A hole splits the interior into pieces that don’t connect</td>
<td style="text-align: center;">—</td>
</tr>
<tr class="even">
<td>Nested shells</td>
<td>One polygon’s shell sits entirely inside another’s, in a <code>MultiPolygon</code></td>
<td style="text-align: center;">—</td>
</tr>
<tr class="odd">
<td>Too few points</td>
<td>A ring has fewer than the 4 points a closed ring needs</td>
<td style="text-align: center;">—</td>
</tr>
<tr class="even">
<td>Invalid Coordinate</td>
<td>A coordinate is <code>NaN</code> or infinite</td>
<td style="text-align: center;">—</td>
</tr>
<tr class="odd">
<td>Repeated Point</td>
<td>The same point twice in a row</td>
<td style="text-align: center;">—</td>
</tr>
<tr class="even">
<td>Duplicate Rings</td>
<td>Two rings describe the exact same boundary</td>
<td style="text-align: center;">—</td>
</tr>
<tr class="odd">
<td>Ring is not closed</td>
<td>A ring’s first and last point don’t match</td>
<td style="text-align: center;">—</td>
</tr>
</tbody>
</table>
</div>
<p>* Valid under R’s default S2 engine, invalid under GEOS: see Where R and Python actually disagree, past Repair.</p>
<p>Of the rows without an example, “Interior is disconnected” belongs to the same family as the crossing cases below, “Nested shells” needs two full polygons to set up, and “Too few points” and “Invalid Coordinate” are rarely produced by a real file. The last three are worth being honest about: they’re real GEOS categories the classifier still recognizes if they ever show up, but none of them could be reproduced through an actual upload here. As of GEOS 3.11+, a repeated point is reported as “Too few points” instead (verified live, not assumed from the changelog). Every duplicate-ring construction tried got caught as “Self-intersection” first, before that check ever ran. And both GeoJSON and Shapefile close every ring at the file format level, before geopandas or sf ever see it, so an unclosed ring never reaches GEOS.</p>
<p>Each example below loads its own data into the diagnosis cell pair above. No repair here, that’s in the Repair section further down.</p>
<section id="self-intersection-bowtie" class="level3">
<h3 class="anchored" data-anchor-id="self-intersection-bowtie">Self-intersection (“bowtie”)</h3>
<svg viewbox="0 0 220 200" style="max-width: 240px; height: auto; display: block; margin: 1.5rem auto;" aria-label="Bowtie polygon: the boundary crosses itself at the center, without any special vertex there">
<rect x="1" y="1" width="218" height="198" fill="#f3ede1" stroke="#5a4f3e" stroke-width="1"></rect> <path d="M 20 140 L 200 20 L 20 20 L 200 140 Z" fill="#e05252" fill-opacity="0.35" stroke="#2a2117" stroke-width="2"></path> <text x="110" y="176" text-anchor="middle" font-family="monospace" font-size="12" fill="#2a2117">self-intersection</text>
</svg>
<p>The polygon’s boundary crosses itself, forming a bowtie shape instead of a single closed area. It’s the most common case in practice, often the result of imprecise manual digitizing.</p>
<div id="webgeods-example-1"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-1").appendChild(
    window.WebGeoDS.loadExampleButton("📋 Load this example", { pyId: "geometry-diagnose-py", pyCode: "import re\nimport geopandas as gpd\nfrom shapely.geometry import shape\nfrom shapely.validation import explain_validity\n\ngdf = None\nfor path in [\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\"]:\n    try:\n        gdf = gpd.read_file(path)\n        break\n    except Exception:\n        pass\n\nif gdf is None:\n    bowtie = {\"type\": \"Polygon\", \"coordinates\": [[[12.45, 41.85], [12.55, 41.95], [12.45, 41.95], [12.55, 41.85], [12.45, 41.85]]]}\n    gdf = gpd.GeoDataFrame({\"name\": [\"bowtie\"]}, geometry=[shape(bowtie)], crs=\"EPSG:4326\")\n\n# Diagnosis and the map both need WGS84 (GeoJSON/MapLibre assume it) —\n# original_crs/export_crs stay in the session as globals, reused by\n# geometry-repair-py below to reproject BACK before a download.\ncrs_warning = None\noriginal_crs = gdf.crs\nif original_crs is None:\n    gdf = gdf.set_crs(\"EPSG:4326\")\n    original_crs = gdf.crs\n    crs_warning = \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\nelif original_crs != \"EPSG:4326\":\n    gdf = gdf.to_crs(\"EPSG:4326\")\n\ndef export_crs(g):\n    if g is None or original_crs is None or original_crs == \"EPSG:4326\":\n        return g\n    return g.to_crs(original_crs)\n\nif \"name\" not in gdf.columns:\n    gdf[\"name\"] = [f\"geometry {i+1}\" for i in range(len(gdf))]\n\nPOSITION_RE = re.compile(r\"^(.*?)\\[([-\\d.]+)\\s+([-\\d.]+)\\]$\")\n\nGEOS_CATEGORIES = [\n    \"Ring Self-intersection\",\n    \"Self-intersection\",\n    \"Hole lies outside shell\",\n    \"Holes are nested\",\n    \"Interior is disconnected\",\n    \"Nested shells\",\n    \"Duplicate Rings\",\n    \"Too few points\",\n    \"Invalid Coordinate\",\n    \"Repeated Point\",\n    \"Ring is not closed\",\n]\n\ndef classify_reason(message):\n    for category in GEOS_CATEGORIES:\n        if message.startswith(category):\n            return category\n    return \"Other\"\n\ndef diagnose(geom):\n    if geom.is_valid:\n        return True, \"\", \"\"\n    message = explain_validity(geom)\n    m = POSITION_RE.match(message)\n    reason = m.group(1).strip() if m else message\n    position = f\"{m.group(2)} {m.group(3)}\" if m else \"\"\n    return False, classify_reason(reason), position\n\ndiagnosis = gdf.geometry.apply(diagnose)\ngdf[\"valid\"] = diagnosis.apply(lambda t: t[0])\ngdf[\"category\"] = diagnosis.apply(lambda t: t[1])\ngdf[\"position\"] = diagnosis.apply(lambda t: t[2])\n\nfor col in gdf.columns:\n    if col != \"geometry\":\n        gdf[col] = gdf[col].fillna(\"\")\n\n{\"features\": gdf.__geo_interface__, \"originalCrsFeatures\": export_crs(gdf).__geo_interface__, \"crsWarning\": crs_warning}", rId: "geometry-diagnose-r", rCode: "library(sf)\nlibrary(geojsonsf)\ndata <- NULL\nfor (path in c(\"/uploaded.geojson\", \"/uploaded.shp\", \"/vsizip//uploaded.zip\")) {\n  data <- tryCatch(st_read(path, quiet = TRUE), error = function(e) NULL)\n  if (!is.null(data)) break\n}\nif (is.null(data)) {\n  wkt <- \"POLYGON((12.45 41.85, 12.55 41.95, 12.45 41.95, 12.55 41.85, 12.45 41.85))\"\n  data <- st_sf(name = \"bowtie\", geometry = st_as_sfc(wkt, crs = 4326))\n}\n\ncrs_warning <- NULL\noriginal_crs <- st_crs(data)\nif (is.na(original_crs)) {\n  st_crs(data) <- 4326\n  original_crs <- st_crs(data)\n  crs_warning <- \"No CRS found in the uploaded file — assuming WGS84 (EPSG:4326).\"\n} else if (!isTRUE(original_crs == st_crs(4326))) {\n  data <- st_transform(data, 4326)\n}\n\nexport_crs <- function(d) {\n  if (is.null(d) || isTRUE(original_crs == st_crs(4326))) return(d)\n  st_transform(d, original_crs)\n}\n\nif (!(\"name\" %in% names(data))) {\n  data$name <- paste(\"geometry\", seq_len(nrow(data)))\n}\n\ndata$valid <- st_is_valid(data)\ndata$reason <- st_is_valid(data, reason = TRUE)\ndata$reason[data$valid] <- NA\n\n# Same classify_reason() as the main diagnose cell above -- see its\n# own comment for why this is \"S2 (no GEOS category)\" by\n# default: S2 reports which edges cross, not which named GEOS failure\n# that represents, since its own validity model has no such names.\nGEOS_CATEGORIES <- c(\n  \"Ring Self-intersection\",\n  \"Self-intersection\",\n  \"Hole lies outside shell\",\n  \"Holes are nested\",\n  \"Interior is disconnected\",\n  \"Nested shells\",\n  \"Duplicate Rings\",\n  \"Too few points\",\n  \"Invalid Coordinate\",\n  \"Repeated Point\",\n  \"Ring is not closed\"\n)\nclassify_reason <- function(message, uses_s2) {\n  if (is.na(message)) return(NA_character_)\n  if (uses_s2) return(\"S2 (no GEOS category)\")\n  for (category in GEOS_CATEGORIES) {\n    if (startsWith(message, category)) return(category)\n  }\n  \"Other\"\n}\nuses_s2 <- sf_use_s2()\ndata$category <- vapply(data$reason, classify_reason, character(1), uses_s2 = uses_s2)\n\nlist(\n  features = jsonlite::fromJSON(sf_geojson(data), simplifyVector = FALSE),\n  originalCrsFeatures = jsonlite::fromJSON(sf_geojson(export_crs(data)), simplifyVector = FALSE),\n  crsWarning = crs_warning\n)" }));
});
</script>
<p>Scroll to the <strong>Repair</strong> section at the bottom of the page to see how it’s fixed.</p>
</section>
<section id="a-hole-that-crosses-the-boundary" class="level3">
<h3 class="anchored" data-anchor-id="a-hole-that-crosses-the-boundary">A hole that crosses the boundary</h3>
<svg viewbox="0 0 260 250" style="max-width: 240px; height: auto; display: block; margin: 1.5rem auto;" aria-label="Polygon with a hole extending outside the outer boundary">
<rect x="1" y="1" width="258" height="248" fill="#f3ede1" stroke="#5a4f3e" stroke-width="1"></rect> <rect x="40" y="40" width="160" height="160" fill="#42583c" fill-opacity="0.3" stroke="#2a2117" stroke-width="2"></rect> <rect x="140" y="40" width="60" height="80" fill="#f3ede1"></rect> <rect x="140" y="20" width="100" height="20" fill="#e05252" fill-opacity="0.7"></rect> <rect x="200" y="40" width="40" height="80" fill="#e05252" fill-opacity="0.7"></rect> <rect x="140" y="20" width="100" height="100" fill="none" stroke="#2a2117" stroke-width="2" stroke-dasharray="4 3"></rect> <text x="130" y="228" text-anchor="middle" font-family="monospace" font-size="11" fill="#2a2117">hole extends past the boundary</text>
</svg>
<p>A “hole” (inner ring, e.g.&nbsp;a lake inside a land polygon) must lie entirely within the outer boundary. Here it doesn’t: one corner sticks out past the shell’s own edge.</p>
<div class="callout callout-style-default callout-note callout-titled">
<div class="callout-header d-flex align-content-center">
<div class="callout-icon-container">
<i class="callout-icon"></i>
</div>
<div class="callout-title-container flex-fill">
Note
</div>
</div>
<div class="callout-body-container callout-body">
<p><strong>This is “Self-intersection”, not “Hole lies outside shell”.</strong> Load the example and check the category column: GEOS classifies it as a self-intersection, because the hole’s ring literally <em>crosses</em> the shell’s ring at that corner: a crossing is a crossing, whichever ring it happens between. GEOS’s own “Hole lies outside shell” category needs the hole to be entirely disjoint from the shell, with no crossing at all. That’s the next example.</p>
</div>
</div>
<div id="webgeods-example-2"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-2").appendChild(
    window.WebGeoDS.loadExampleButton("📋 Load this example", { pyId: "geometry-diagnose-py", pyCode: "import re\nimport geopandas as gpd\nfrom shapely.geometry import shape\nfrom shapely.validation import explain_validity\n\ngeom = shape({\n  \"type\": \"Polygon\",\n  \"coordinates\": [\n    [[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]],\n    [[3, 3], [5, 3], [5, 5], [3, 5], [3, 3]]\n  ]\n})\ngdf = gpd.GeoDataFrame({\"name\": [\"hole-outside-boundary\"]}, geometry=[geom], crs=\"EPSG:4326\")\n\n# Same session-globals contract as the diagnose cell above (gdf/\n# original_crs/export_crs stay in the session, reused by Repair\n# further down) — this example is already WGS84, so export_crs() is a\n# no-op passthrough here, kept for the contract rather than because\n# this particular shape needs reprojecting.\ncrs_warning = None\noriginal_crs = gdf.crs\n\ndef export_crs(g):\n    if g is None or original_crs is None or original_crs == \"EPSG:4326\":\n        return g\n    return g.to_crs(original_crs)\n\n# Same diagnose() as the main diagnose cell above.\nPOSITION_RE = re.compile(r\"^(.*?)\\[([-\\d.]+)\\s+([-\\d.]+)\\]$\")\n\nGEOS_CATEGORIES = [\n    \"Ring Self-intersection\",\n    \"Self-intersection\",\n    \"Hole lies outside shell\",\n    \"Holes are nested\",\n    \"Interior is disconnected\",\n    \"Nested shells\",\n    \"Duplicate Rings\",\n    \"Too few points\",\n    \"Invalid Coordinate\",\n    \"Repeated Point\",\n    \"Ring is not closed\",\n]\n\ndef classify_reason(message):\n    for category in GEOS_CATEGORIES:\n        if message.startswith(category):\n            return category\n    return \"Other\"\n\ndef diagnose(geom):\n    if geom.is_valid:\n        return True, \"\", \"\"\n    message = explain_validity(geom)\n    m = POSITION_RE.match(message)\n    reason = m.group(1).strip() if m else message\n    position = f\"{m.group(2)} {m.group(3)}\" if m else \"\"\n    return False, classify_reason(reason), position\n\ndiagnosis = gdf.geometry.apply(diagnose)\ngdf[\"valid\"] = diagnosis.apply(lambda t: t[0])\ngdf[\"category\"] = diagnosis.apply(lambda t: t[1])\ngdf[\"position\"] = diagnosis.apply(lambda t: t[2])\n\n{\"features\": gdf.__geo_interface__, \"originalCrsFeatures\": export_crs(gdf).__geo_interface__, \"crsWarning\": crs_warning}", rId: "geometry-diagnose-r", rCode: "library(sf)\nlibrary(geojsonsf)\nwkt <- \"POLYGON((0 0, 4 0, 4 4, 0 4, 0 0), (3 3, 5 3, 5 5, 3 5, 3 3))\"\ndata <- st_sf(name = \"hole-outside-boundary\", geometry = st_as_sfc(wkt, crs = 4326))\n\n# Same session-globals contract as the diagnose cell above (data/\n# original_crs/export_crs stay in the session, reused by Repair\n# further down) — this example is already WGS84, so export_crs() is a\n# no-op passthrough here, kept for the contract rather than because\n# this particular shape needs reprojecting.\ncrs_warning <- NULL\noriginal_crs <- st_crs(data)\n\nexport_crs <- function(d) {\n  if (is.null(d) || isTRUE(original_crs == st_crs(4326))) return(d)\n  st_transform(d, original_crs)\n}\n\ndata$valid <- st_is_valid(data)\ndata$reason <- st_is_valid(data, reason = TRUE)\ndata$reason[data$valid] <- NA\n\n# Same classify_reason() as the main diagnose cell above -- see its\n# own comment for why this is \"S2 (no GEOS category)\" by\n# default: S2 reports which edges cross, not which named GEOS failure\n# that represents, since its own validity model has no such names.\nGEOS_CATEGORIES <- c(\n  \"Ring Self-intersection\",\n  \"Self-intersection\",\n  \"Hole lies outside shell\",\n  \"Holes are nested\",\n  \"Interior is disconnected\",\n  \"Nested shells\",\n  \"Duplicate Rings\",\n  \"Too few points\",\n  \"Invalid Coordinate\",\n  \"Repeated Point\",\n  \"Ring is not closed\"\n)\nclassify_reason <- function(message, uses_s2) {\n  if (is.na(message)) return(NA_character_)\n  if (uses_s2) return(\"S2 (no GEOS category)\")\n  for (category in GEOS_CATEGORIES) {\n    if (startsWith(message, category)) return(category)\n  }\n  \"Other\"\n}\nuses_s2 <- sf_use_s2()\ndata$category <- vapply(data$reason, classify_reason, character(1), uses_s2 = uses_s2)\n\nlist(\n  features = jsonlite::fromJSON(sf_geojson(data), simplifyVector = FALSE),\n  originalCrsFeatures = jsonlite::fromJSON(sf_geojson(export_crs(data)), simplifyVector = FALSE),\n  crsWarning = crs_warning\n)" }));
});
</script>
<p>Scroll to the <strong>Repair</strong> section at the bottom of the page to see how it’s fixed.</p>
</section>
<section id="a-hole-with-no-crossing-at-all" class="level3">
<h3 class="anchored" data-anchor-id="a-hole-with-no-crossing-at-all">A hole with no crossing at all</h3>
<svg viewbox="0 0 260 200" style="max-width: 240px; height: auto; display: block; margin: 1.5rem auto;" aria-label="Polygon with a hole positioned entirely outside the outer boundary, not touching it">
<rect x="1" y="1" width="258" height="198" fill="#f3ede1" stroke="#5a4f3e" stroke-width="1"></rect> <rect x="30" y="30" width="120" height="140" fill="#42583c" fill-opacity="0.3" stroke="#2a2117" stroke-width="2"></rect> <rect x="190" y="60" width="50" height="50" fill="none" stroke="#e05252" stroke-width="2" stroke-dasharray="4 3"></rect> <text x="140" y="188" text-anchor="middle" font-family="monospace" font-size="11" fill="#2a2117">hole sits entirely outside the shell</text>
</svg>
<p>Same rule as above (a hole must lie entirely within the shell), but this time nothing crosses: the “hole” ring sits well clear of the shell’s own boundary, disjoint from it. This is what GEOS’s own “Hole lies outside shell” category actually needs. A hole that merely <em>crosses</em> the boundary (previous example) is a self-intersection instead, a different failure with a different name.</p>
<p>Load this example and run it in both languages: R’s “valid” column shows ✓ where Python’s shows ✗. That’s not a quirk of this one case; see Where R and Python actually disagree near the end of the page for why.</p>
<div id="webgeods-example-3"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-3").appendChild(
    window.WebGeoDS.loadExampleButton("📋 Load this example", { pyId: "geometry-diagnose-py", pyCode: "import re\nimport geopandas as gpd\nfrom shapely.geometry import shape\nfrom shapely.validation import explain_validity\n\ngeom = shape({\n  \"type\": \"Polygon\",\n  \"coordinates\": [\n    [[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]],\n    [[6, 2], [7, 2], [7, 3], [6, 3], [6, 2]]\n  ]\n})\ngdf = gpd.GeoDataFrame({\"name\": [\"hole-lies-outside-shell\"]}, geometry=[geom], crs=\"EPSG:4326\")\n\n# Same session-globals contract as the diagnose cell above.\ncrs_warning = None\noriginal_crs = gdf.crs\n\ndef export_crs(g):\n    if g is None or original_crs is None or original_crs == \"EPSG:4326\":\n        return g\n    return g.to_crs(original_crs)\n\n# Same diagnose() as the main diagnose cell above.\nPOSITION_RE = re.compile(r\"^(.*?)\\[([-\\d.]+)\\s+([-\\d.]+)\\]$\")\n\nGEOS_CATEGORIES = [\n    \"Ring Self-intersection\",\n    \"Self-intersection\",\n    \"Hole lies outside shell\",\n    \"Holes are nested\",\n    \"Interior is disconnected\",\n    \"Nested shells\",\n    \"Duplicate Rings\",\n    \"Too few points\",\n    \"Invalid Coordinate\",\n    \"Repeated Point\",\n    \"Ring is not closed\",\n]\n\ndef classify_reason(message):\n    for category in GEOS_CATEGORIES:\n        if message.startswith(category):\n            return category\n    return \"Other\"\n\ndef diagnose(geom):\n    if geom.is_valid:\n        return True, \"\", \"\"\n    message = explain_validity(geom)\n    m = POSITION_RE.match(message)\n    reason = m.group(1).strip() if m else message\n    position = f\"{m.group(2)} {m.group(3)}\" if m else \"\"\n    return False, classify_reason(reason), position\n\ndiagnosis = gdf.geometry.apply(diagnose)\ngdf[\"valid\"] = diagnosis.apply(lambda t: t[0])\ngdf[\"category\"] = diagnosis.apply(lambda t: t[1])\ngdf[\"position\"] = diagnosis.apply(lambda t: t[2])\n\n{\"features\": gdf.__geo_interface__, \"originalCrsFeatures\": export_crs(gdf).__geo_interface__, \"crsWarning\": crs_warning}", rId: "geometry-diagnose-r", rCode: "library(sf)\nlibrary(geojsonsf)\nwkt <- \"POLYGON((0 0, 4 0, 4 4, 0 4, 0 0), (6 2, 7 2, 7 3, 6 3, 6 2))\"\ndata <- st_sf(name = \"hole-lies-outside-shell\", geometry = st_as_sfc(wkt, crs = 4326))\n\n# Same session-globals contract as the diagnose cell above.\ncrs_warning <- NULL\noriginal_crs <- st_crs(data)\n\nexport_crs <- function(d) {\n  if (is.null(d) || isTRUE(original_crs == st_crs(4326))) return(d)\n  st_transform(d, original_crs)\n}\n\ndata$valid <- st_is_valid(data)\ndata$reason <- st_is_valid(data, reason = TRUE)\ndata$reason[data$valid] <- NA\n\n# Same classify_reason() as the main diagnose cell above.\nGEOS_CATEGORIES <- c(\n  \"Ring Self-intersection\",\n  \"Self-intersection\",\n  \"Hole lies outside shell\",\n  \"Holes are nested\",\n  \"Interior is disconnected\",\n  \"Nested shells\",\n  \"Duplicate Rings\",\n  \"Too few points\",\n  \"Invalid Coordinate\",\n  \"Repeated Point\",\n  \"Ring is not closed\"\n)\nclassify_reason <- function(message, uses_s2) {\n  if (is.na(message)) return(NA_character_)\n  if (uses_s2) return(\"S2 (no GEOS category)\")\n  for (category in GEOS_CATEGORIES) {\n    if (startsWith(message, category)) return(category)\n  }\n  \"Other\"\n}\nuses_s2 <- sf_use_s2()\ndata$category <- vapply(data$reason, classify_reason, character(1), uses_s2 = uses_s2)\n\nlist(\n  features = jsonlite::fromJSON(sf_geojson(data), simplifyVector = FALSE),\n  originalCrsFeatures = jsonlite::fromJSON(sf_geojson(export_crs(data)), simplifyVector = FALSE),\n  crsWarning = crs_warning\n)" }));
});
</script>
<p>Scroll to the <strong>Repair</strong> section at the bottom of the page to see how it’s fixed.</p>
</section>
<section id="ring-touching-itself-hourglass" class="level3">
<h3 class="anchored" data-anchor-id="ring-touching-itself-hourglass">Ring touching itself (hourglass)</h3>
<svg viewbox="0 0 220 250" style="max-width: 240px; height: auto; display: block; margin: 1.5rem auto;" aria-label="Two lobes of a polygon touching at a single vertex, without crossing">
<rect x="1" y="1" width="218" height="248" fill="#f3ede1" stroke="#5a4f3e" stroke-width="1"></rect> <path d="M 20 20 L 200 20 L 110 110 Z" fill="#e05252" fill-opacity="0.35" stroke="#2a2117" stroke-width="2"></path> <path d="M 110 110 L 20 200 L 200 200 Z" fill="#e05252" fill-opacity="0.35" stroke="#2a2117" stroke-width="2"></path> <circle cx="110" cy="110" r="5" fill="#e05252" stroke="#2a2117" stroke-width="1.5"></circle> <text x="110" y="228" text-anchor="middle" font-family="monospace" font-size="12" fill="#2a2117">a single shared vertex</text>
</svg>
<p>The polygon’s boundary touches itself at one point, without crossing, effectively splitting the shape into two lobes joined by a single vertex: not cleanly connected, so invalid.</p>
<div id="webgeods-example-4"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-4").appendChild(
    window.WebGeoDS.loadExampleButton("📋 Load this example", { pyId: "geometry-diagnose-py", pyCode: "import re\nimport geopandas as gpd\nfrom shapely.geometry import shape\nfrom shapely.validation import explain_validity\n\ngeom = shape({\"type\": \"Polygon\", \"coordinates\": [[[0, 0], [2, 2], [4, 0], [4, 4], [2, 2], [0, 4], [0, 0]]]})\ngdf = gpd.GeoDataFrame({\"name\": [\"hourglass\"]}, geometry=[geom], crs=\"EPSG:4326\")\n\n# Same session-globals contract as the diagnose cell above (gdf/\n# original_crs/export_crs stay in the session, reused by Repair\n# further down) — this example is already WGS84, so export_crs() is a\n# no-op passthrough here, kept for the contract rather than because\n# this particular shape needs reprojecting.\ncrs_warning = None\noriginal_crs = gdf.crs\n\ndef export_crs(g):\n    if g is None or original_crs is None or original_crs == \"EPSG:4326\":\n        return g\n    return g.to_crs(original_crs)\n\n# Same diagnose() as the main diagnose cell above.\nPOSITION_RE = re.compile(r\"^(.*?)\\[([-\\d.]+)\\s+([-\\d.]+)\\]$\")\n\nGEOS_CATEGORIES = [\n    \"Ring Self-intersection\",\n    \"Self-intersection\",\n    \"Hole lies outside shell\",\n    \"Holes are nested\",\n    \"Interior is disconnected\",\n    \"Nested shells\",\n    \"Duplicate Rings\",\n    \"Too few points\",\n    \"Invalid Coordinate\",\n    \"Repeated Point\",\n    \"Ring is not closed\",\n]\n\ndef classify_reason(message):\n    for category in GEOS_CATEGORIES:\n        if message.startswith(category):\n            return category\n    return \"Other\"\n\ndef diagnose(geom):\n    if geom.is_valid:\n        return True, \"\", \"\"\n    message = explain_validity(geom)\n    m = POSITION_RE.match(message)\n    reason = m.group(1).strip() if m else message\n    position = f\"{m.group(2)} {m.group(3)}\" if m else \"\"\n    return False, classify_reason(reason), position\n\ndiagnosis = gdf.geometry.apply(diagnose)\ngdf[\"valid\"] = diagnosis.apply(lambda t: t[0])\ngdf[\"category\"] = diagnosis.apply(lambda t: t[1])\ngdf[\"position\"] = diagnosis.apply(lambda t: t[2])\n\n{\"features\": gdf.__geo_interface__, \"originalCrsFeatures\": export_crs(gdf).__geo_interface__, \"crsWarning\": crs_warning}", rId: "geometry-diagnose-r", rCode: "library(sf)\nlibrary(geojsonsf)\nwkt <- \"POLYGON((0 0, 2 2, 4 0, 4 4, 2 2, 0 4, 0 0))\"\ndata <- st_sf(name = \"hourglass\", geometry = st_as_sfc(wkt, crs = 4326))\n\n# Same session-globals contract as the diagnose cell above (data/\n# original_crs/export_crs stay in the session, reused by Repair\n# further down) — this example is already WGS84, so export_crs() is a\n# no-op passthrough here, kept for the contract rather than because\n# this particular shape needs reprojecting.\ncrs_warning <- NULL\noriginal_crs <- st_crs(data)\n\nexport_crs <- function(d) {\n  if (is.null(d) || isTRUE(original_crs == st_crs(4326))) return(d)\n  st_transform(d, original_crs)\n}\n\ndata$valid <- st_is_valid(data)\ndata$reason <- st_is_valid(data, reason = TRUE)\ndata$reason[data$valid] <- NA\n\n# Same classify_reason() as the main diagnose cell above -- see its\n# own comment for why this is \"S2 (no GEOS category)\" by\n# default: S2 reports which edges cross, not which named GEOS failure\n# that represents, since its own validity model has no such names.\nGEOS_CATEGORIES <- c(\n  \"Ring Self-intersection\",\n  \"Self-intersection\",\n  \"Hole lies outside shell\",\n  \"Holes are nested\",\n  \"Interior is disconnected\",\n  \"Nested shells\",\n  \"Duplicate Rings\",\n  \"Too few points\",\n  \"Invalid Coordinate\",\n  \"Repeated Point\",\n  \"Ring is not closed\"\n)\nclassify_reason <- function(message, uses_s2) {\n  if (is.na(message)) return(NA_character_)\n  if (uses_s2) return(\"S2 (no GEOS category)\")\n  for (category in GEOS_CATEGORIES) {\n    if (startsWith(message, category)) return(category)\n  }\n  \"Other\"\n}\nuses_s2 <- sf_use_s2()\ndata$category <- vapply(data$reason, classify_reason, character(1), uses_s2 = uses_s2)\n\nlist(\n  features = jsonlite::fromJSON(sf_geojson(data), simplifyVector = FALSE),\n  originalCrsFeatures = jsonlite::fromJSON(sf_geojson(export_crs(data)), simplifyVector = FALSE),\n  crsWarning = crs_warning\n)" }));
});
</script>
<p>Scroll to the <strong>Repair</strong> section at the bottom of the page to see how it’s fixed.</p>
</section>
<section id="overlapping-holes" class="level3">
<h3 class="anchored" data-anchor-id="overlapping-holes">Overlapping holes</h3>
<svg viewbox="0 0 260 290" style="max-width: 240px; height: auto; display: block; margin: 1.5rem auto;" aria-label="Two holes overlapping inside the same polygon, like a Venn diagram">
<defs> <clippath id="holeA-clip"><circle cx="105" cy="130" r="55"></circle></clippath> </defs> <rect x="1" y="1" width="258" height="288" fill="#f3ede1" stroke="#5a4f3e" stroke-width="1"></rect> <rect x="20" y="20" width="220" height="220" fill="#42583c" fill-opacity="0.15" stroke="#2a2117" stroke-width="2"></rect> <circle cx="105" cy="130" r="55" fill="#42583c" fill-opacity="0.3" stroke="#2a2117" stroke-width="2"></circle> <circle cx="155" cy="130" r="55" fill="#42583c" fill-opacity="0.3" stroke="#2a2117" stroke-width="2"></circle> <circle cx="155" cy="130" r="55" fill="#e05252" fill-opacity="0.85" clip-path="url(#holeA-clip)"></circle> <text x="130" y="268" text-anchor="middle" font-family="monospace" font-size="12" fill="#2a2117">the two holes overlap</text>
</svg>
<p>If a polygon has more than one hole, they can’t overlap each other: two intersecting holes create an ambiguous area (inside or outside the polygon?) that the model doesn’t allow.</p>
<div id="webgeods-example-5"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-5").appendChild(
    window.WebGeoDS.loadExampleButton("📋 Load this example", { pyId: "geometry-diagnose-py", pyCode: "import re\nimport geopandas as gpd\nfrom shapely.geometry import shape\nfrom shapely.validation import explain_validity\n\ngeom = shape({\n  \"type\": \"Polygon\",\n  \"coordinates\": [\n    [[0, 0], [6, 0], [6, 6], [0, 6], [0, 0]],\n    [[1, 1], [3, 1], [3, 3], [1, 3], [1, 1]],\n    [[2, 2], [4, 2], [4, 4], [2, 4], [2, 2]]\n  ]\n})\ngdf = gpd.GeoDataFrame({\"name\": [\"overlapping-holes\"]}, geometry=[geom], crs=\"EPSG:4326\")\n\n# Same session-globals contract as the diagnose cell above (gdf/\n# original_crs/export_crs stay in the session, reused by Repair\n# further down) — this example is already WGS84, so export_crs() is a\n# no-op passthrough here, kept for the contract rather than because\n# this particular shape needs reprojecting.\ncrs_warning = None\noriginal_crs = gdf.crs\n\ndef export_crs(g):\n    if g is None or original_crs is None or original_crs == \"EPSG:4326\":\n        return g\n    return g.to_crs(original_crs)\n\n# Same diagnose() as the main diagnose cell above.\nPOSITION_RE = re.compile(r\"^(.*?)\\[([-\\d.]+)\\s+([-\\d.]+)\\]$\")\n\nGEOS_CATEGORIES = [\n    \"Ring Self-intersection\",\n    \"Self-intersection\",\n    \"Hole lies outside shell\",\n    \"Holes are nested\",\n    \"Interior is disconnected\",\n    \"Nested shells\",\n    \"Duplicate Rings\",\n    \"Too few points\",\n    \"Invalid Coordinate\",\n    \"Repeated Point\",\n    \"Ring is not closed\",\n]\n\ndef classify_reason(message):\n    for category in GEOS_CATEGORIES:\n        if message.startswith(category):\n            return category\n    return \"Other\"\n\ndef diagnose(geom):\n    if geom.is_valid:\n        return True, \"\", \"\"\n    message = explain_validity(geom)\n    m = POSITION_RE.match(message)\n    reason = m.group(1).strip() if m else message\n    position = f\"{m.group(2)} {m.group(3)}\" if m else \"\"\n    return False, classify_reason(reason), position\n\ndiagnosis = gdf.geometry.apply(diagnose)\ngdf[\"valid\"] = diagnosis.apply(lambda t: t[0])\ngdf[\"category\"] = diagnosis.apply(lambda t: t[1])\ngdf[\"position\"] = diagnosis.apply(lambda t: t[2])\n\n{\"features\": gdf.__geo_interface__, \"originalCrsFeatures\": export_crs(gdf).__geo_interface__, \"crsWarning\": crs_warning}", rId: "geometry-diagnose-r", rCode: "library(sf)\nlibrary(geojsonsf)\nwkt <- \"POLYGON((0 0, 6 0, 6 6, 0 6, 0 0), (1 1, 3 1, 3 3, 1 3, 1 1), (2 2, 4 2, 4 4, 2 4, 2 2))\"\ndata <- st_sf(name = \"overlapping-holes\", geometry = st_as_sfc(wkt, crs = 4326))\n\n# Same session-globals contract as the diagnose cell above (data/\n# original_crs/export_crs stay in the session, reused by Repair\n# further down) — this example is already WGS84, so export_crs() is a\n# no-op passthrough here, kept for the contract rather than because\n# this particular shape needs reprojecting.\ncrs_warning <- NULL\noriginal_crs <- st_crs(data)\n\nexport_crs <- function(d) {\n  if (is.null(d) || isTRUE(original_crs == st_crs(4326))) return(d)\n  st_transform(d, original_crs)\n}\n\ndata$valid <- st_is_valid(data)\ndata$reason <- st_is_valid(data, reason = TRUE)\ndata$reason[data$valid] <- NA\n\n# Same classify_reason() as the main diagnose cell above -- see its\n# own comment for why this is \"S2 (no GEOS category)\" by\n# default: S2 reports which edges cross, not which named GEOS failure\n# that represents, since its own validity model has no such names.\nGEOS_CATEGORIES <- c(\n  \"Ring Self-intersection\",\n  \"Self-intersection\",\n  \"Hole lies outside shell\",\n  \"Holes are nested\",\n  \"Interior is disconnected\",\n  \"Nested shells\",\n  \"Duplicate Rings\",\n  \"Too few points\",\n  \"Invalid Coordinate\",\n  \"Repeated Point\",\n  \"Ring is not closed\"\n)\nclassify_reason <- function(message, uses_s2) {\n  if (is.na(message)) return(NA_character_)\n  if (uses_s2) return(\"S2 (no GEOS category)\")\n  for (category in GEOS_CATEGORIES) {\n    if (startsWith(message, category)) return(category)\n  }\n  \"Other\"\n}\nuses_s2 <- sf_use_s2()\ndata$category <- vapply(data$reason, classify_reason, character(1), uses_s2 = uses_s2)\n\nlist(\n  features = jsonlite::fromJSON(sf_geojson(data), simplifyVector = FALSE),\n  originalCrsFeatures = jsonlite::fromJSON(sf_geojson(export_crs(data)), simplifyVector = FALSE),\n  crsWarning = crs_warning\n)" }));
});
</script>
<p>Scroll to the <strong>Repair</strong> section below to see how it’s fixed.</p>
</section>
<section id="holes-are-nested" class="level3">
<h3 class="anchored" data-anchor-id="holes-are-nested">Holes are nested</h3>
<svg viewbox="0 0 220 220" style="max-width: 220px; height: auto; display: block; margin: 1.5rem auto;" aria-label="A polygon with a hole, and inside that hole a smaller filled island">
<rect x="1" y="1" width="218" height="218" fill="#f3ede1" stroke="#5a4f3e" stroke-width="1"></rect> <rect x="20" y="20" width="180" height="180" fill="#42583c" fill-opacity="0.3" stroke="#2a2117" stroke-width="2"></rect> <rect x="60" y="60" width="100" height="100" fill="#f3ede1" stroke="#2a2117" stroke-width="2" stroke-dasharray="4 3"></rect> <rect x="90" y="90" width="40" height="40" fill="#42583c" fill-opacity="0.3" stroke="#2a2117" stroke-width="2"></rect> <text x="110" y="205" text-anchor="middle" font-family="monospace" font-size="11" fill="#2a2117">a hole, with an island inside it</text>
</svg>
<p>A hole with a smaller shape sitting inside <em>that</em> hole, like an island in a lake inside a larger landmass. The Simple Features model doesn’t allow this as one <code>Polygon</code>: a hole containing its own shell is supposed to be two separate <code>Polygon</code>s (the landmass-with-lake, and the island) combined into a <code>MultiPolygon</code>, not nested rings in one.</p>
<p>Load the example and run it in both languages: Python marks it ✗ with category “Holes are nested”. R’s default marks it ✓, valid, so it has no category at all. That’s not a wording difference; see Where R and Python actually disagree near the end of the page for why, and why it’s not shown in the Repair section below (there’s nothing to repair under R’s default, since it isn’t flagged invalid there to begin with).</p>
<div id="webgeods-example-6"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  document.getElementById("webgeods-example-6").appendChild(
    window.WebGeoDS.loadExampleButton("📋 Load this example", { pyId: "geometry-diagnose-py", pyCode: "import re\nimport geopandas as gpd\nfrom shapely.geometry import shape\nfrom shapely.validation import explain_validity\n\ngeom = shape({\n  \"type\": \"Polygon\",\n  \"coordinates\": [\n    [[0, 0], [6, 0], [6, 6], [0, 6], [0, 0]],\n    [[1, 1], [4, 1], [4, 4], [1, 4], [1, 1]],\n    [[2, 2], [3, 2], [3, 3], [2, 3], [2, 2]]\n  ]\n})\ngdf = gpd.GeoDataFrame({\"name\": [\"nested-holes\"]}, geometry=[geom], crs=\"EPSG:4326\")\n\n# Same session-globals contract as the diagnose cell above.\ncrs_warning = None\noriginal_crs = gdf.crs\n\ndef export_crs(g):\n    if g is None or original_crs is None or original_crs == \"EPSG:4326\":\n        return g\n    return g.to_crs(original_crs)\n\n# Same diagnose() as the main diagnose cell above.\nPOSITION_RE = re.compile(r\"^(.*?)\\[([-\\d.]+)\\s+([-\\d.]+)\\]$\")\n\nGEOS_CATEGORIES = [\n    \"Ring Self-intersection\",\n    \"Self-intersection\",\n    \"Hole lies outside shell\",\n    \"Holes are nested\",\n    \"Interior is disconnected\",\n    \"Nested shells\",\n    \"Duplicate Rings\",\n    \"Too few points\",\n    \"Invalid Coordinate\",\n    \"Repeated Point\",\n    \"Ring is not closed\",\n]\n\ndef classify_reason(message):\n    for category in GEOS_CATEGORIES:\n        if message.startswith(category):\n            return category\n    return \"Other\"\n\ndef diagnose(geom):\n    if geom.is_valid:\n        return True, \"\", \"\"\n    message = explain_validity(geom)\n    m = POSITION_RE.match(message)\n    reason = m.group(1).strip() if m else message\n    position = f\"{m.group(2)} {m.group(3)}\" if m else \"\"\n    return False, classify_reason(reason), position\n\ndiagnosis = gdf.geometry.apply(diagnose)\ngdf[\"valid\"] = diagnosis.apply(lambda t: t[0])\ngdf[\"category\"] = diagnosis.apply(lambda t: t[1])\ngdf[\"position\"] = diagnosis.apply(lambda t: t[2])\n\n{\"features\": gdf.__geo_interface__, \"originalCrsFeatures\": export_crs(gdf).__geo_interface__, \"crsWarning\": crs_warning}", rId: "geometry-diagnose-r", rCode: "library(sf)\nlibrary(geojsonsf)\nwkt <- \"POLYGON((0 0, 6 0, 6 6, 0 6, 0 0), (1 1, 4 1, 4 4, 1 4, 1 1), (2 2, 3 2, 3 3, 2 3, 2 2))\"\ndata <- st_sf(name = \"nested-holes\", geometry = st_as_sfc(wkt, crs = 4326))\n\n# Same session-globals contract as the diagnose cell above.\ncrs_warning <- NULL\noriginal_crs <- st_crs(data)\n\nexport_crs <- function(d) {\n  if (is.null(d) || isTRUE(original_crs == st_crs(4326))) return(d)\n  st_transform(d, original_crs)\n}\n\ndata$valid <- st_is_valid(data)\ndata$reason <- st_is_valid(data, reason = TRUE)\ndata$reason[data$valid] <- NA\n\n# Same classify_reason() as the main diagnose cell above.\nGEOS_CATEGORIES <- c(\n  \"Ring Self-intersection\",\n  \"Self-intersection\",\n  \"Hole lies outside shell\",\n  \"Holes are nested\",\n  \"Interior is disconnected\",\n  \"Nested shells\",\n  \"Duplicate Rings\",\n  \"Too few points\",\n  \"Invalid Coordinate\",\n  \"Repeated Point\",\n  \"Ring is not closed\"\n)\nclassify_reason <- function(message, uses_s2) {\n  if (is.na(message)) return(NA_character_)\n  if (uses_s2) return(\"S2 (no GEOS category)\")\n  for (category in GEOS_CATEGORIES) {\n    if (startsWith(message, category)) return(category)\n  }\n  \"Other\"\n}\nuses_s2 <- sf_use_s2()\ndata$category <- vapply(data$reason, classify_reason, character(1), uses_s2 = uses_s2)\n\nlist(\n  features = jsonlite::fromJSON(sf_geojson(data), simplifyVector = FALSE),\n  originalCrsFeatures = jsonlite::fromJSON(sf_geojson(export_crs(data)), simplifyVector = FALSE),\n  crsWarning = crs_warning\n)" }));
});
</script>
</section>
</section>
<section id="repair-optional" class="level2">
<h2 class="anchored" data-anchor-id="repair-optional">Repair (optional)</h2>
<p>The diagnosis tells you what’s wrong. Repair changes the data: <code>make_valid()</code>/<code>st_make_valid()</code> rebuild the geometry into an equivalent, valid form. <strong>Requires having run the diagnosis above at least once</strong>: it reuses exactly the same data just diagnosed (<code>gdf</code> in Python, <code>data</code> in R stay in the session between one cell and the next, like in a notebook), with no new reading of the file.</p>
<div class="tabset-margin-container"></div><div class="panel-tabset">
<ul class="nav nav-tabs"><li class="nav-item"><a class="nav-link active" id="tabset-3-1-tab" data-bs-toggle="tab" data-bs-target="#tabset-3-1" aria-controls="tabset-3-1" aria-selected="true">Python</a></li><li class="nav-item"><a class="nav-link" id="tabset-3-2-tab" data-bs-toggle="tab" data-bs-target="#tabset-3-2" aria-controls="tabset-3-2" aria-selected="false">R</a></li></ul>
<div class="tab-content">
<div id="tabset-3-1" class="tab-pane active" aria-labelledby="tabset-3-1-tab">
<div id="geometry-repair-py"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("geometry-repair-py", {
    language: "python",
    initialCode: "if pyWasReset:\n    raise RuntimeError(\"The map was reset since the last diagnosis — run Diagnose (Python) again before Repair.\")\n\nfrom shapely.validation import make_valid\n\ngdf[\"valid_before\"] = gdf[\"valid\"]\ngdf[\"geometry\"] = gdf.geometry.apply(lambda g: make_valid(g) if not g.is_valid else g)\ngdf[\"valid_after\"] = gdf.geometry.is_valid\n\n# Same reasoning as the diagnose cell above: keep every original\n# column, fillna(\"\") so a None doesn't silently drop that property.\nfor col in gdf.columns:\n    if col != \"geometry\":\n        gdf[col] = gdf[col].fillna(\"\")\n\n{\"features\": gdf.__geo_interface__, \"originalCrsFeatures\": export_crs(gdf).__geo_interface__}",
    inject: ["pyWasReset"],
    preload: {},
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.Python.run(code, {}));
    }
  });
});
</script>
</div>
<div id="tabset-3-2" class="tab-pane" aria-labelledby="tabset-3-2-tab">
<div id="geometry-repair-r"></div>
<script>
document.addEventListener("DOMContentLoaded", () => {
  new window.WebGeoDS.CodeCell("geometry-repair-r", {
    language: "r",
    initialCode: "if (isTRUE(rWasReset)) {\n  stop(\"The map was reset since the last diagnosis -- run Diagnose (R) again before Repair.\")\n}\n\nlibrary(sf)\nlibrary(geojsonsf)\ndata$valid_before <- data$valid\n\n# With geographic coordinates, sf routes st_make_valid() through S2\n# by default -- a different algorithm from GEOS that, on some of\n# these exact cases, fixes nothing and returns the geometry unchanged\n# without an error. Forcing GEOS here (sf_use_s2(FALSE)) is what\n# actually repairs them; turned back on right after, since other\n# operations (distances, areas) benefit from S2 on geographic data.\nsf_use_s2(FALSE)\ndata <- st_make_valid(data)\nsf_use_s2(TRUE)\n\ndata$valid_after <- st_is_valid(data)\n\nlist(\n  features = jsonlite::fromJSON(sf_geojson(data), simplifyVector = FALSE),\n  originalCrsFeatures = jsonlite::fromJSON(sf_geojson(export_crs(data)), simplifyVector = FALSE)\n)",
    inject: ["rWasReset"],
    preload: {},
    onRun: async (code, output) => {
      output.waiting("Running...");
      return output.result(await window.WebGeoDS.R.run(code, {}));
    }
  });
});
</script>
</div>
</div>
</div>
<p>The table and the report above update themselves: the <code>valid_before</code>/<code>valid_after</code> columns automatically appear next to the diagnosis ones.</p>
</section>
<section id="where-r-and-python-actually-disagree" class="level2 page-columns page-full">
<h2 class="anchored" data-anchor-id="where-r-and-python-actually-disagree">Where R and Python actually disagree</h2>
<p>Two examples in the gallery above (“A hole with no crossing at all” and “Holes are nested”) show R’s default marking valid (✓) a geometry Python marks invalid (✗). Not a wording difference like the rest of the gallery: a genuine disagreement on whether the geometry is even invalid, worth its own explanation instead of a quick aside inside those two sections.</p>
<p><strong>Neither engine is wrong.</strong> <code>S2Polygon</code>’s own validity rule for a set of loops is just “no pair of loops that cross”; a hole that doesn’t touch the shell trivially satisfies that, whatever else it is. GEOS enforces the stricter OGC Simple Features rule instead: a <code>Polygon</code>’s hole is supposed to describe a piece cut out of its shell, so one that doesn’t touch the shell at all can’t be describing that, crossing or not. Same story with nested holes: <code>S2Polygon</code>’s own model builds a polygon’s interior from an odd/even rule over nested loop <em>depth</em>: shells at depth 0, holes at depth 1, and a <strong>shell at depth 2</strong> is just as legitimate a structure as depth 0, not a violation.<sup>2</sup> GEOS’s OGC rule says a single <code>Polygon</code> may have a shell and holes, full stop; a shell-within-a-hole has to be a separate <code>Polygon</code>, combined into a <code>MultiPolygon</code> instead. Two different definitions of “valid polygon,” not one calculation read two ways.</p>
<div class="no-row-height column-margin column-container"><div id="fn2"><p><sup>2</sup>&nbsp;From Google’s own S2 documentation: “the given loops are automatically converted into a canonical form consisting of ‘shells’ and ‘holes’ … nested hierarchically. ‘Outer shell’ loops have depth 0, holes within those loops have depth 1, shells within those holes have depth 2, etc.” (see <a href="http://s2geometry.io/devguide/basic_types.html">s2geometry.io/devguide/basic_types</a>). Verified live against the nested-holes example, not just read off the docs: reversing every ring’s winding order in all four combinations still leaves R’s default S2 check reporting “Valid Geometry”, so it isn’t a ring-orientation artifact either; it’s the nesting itself that S2 accepts and GEOS doesn’t.</p></div></div><p><code>sf_use_s2(FALSE)</code> (see “Valid according to whom?” above) makes R’s answer match Python’s in both cases.</p>
<p>Neither case is shown in the Repair section above: there’s nothing to repair under R’s default (neither is flagged invalid there to begin with), and Python’s own <code>make_valid()</code> would just restructure them into a <code>MultiPolygon</code>/simplified shape the same way it already does for the other examples. What matters here is the disagreement itself, not the fix.</p>
</section>
<section id="what-about-relationships-between-geometries" class="level2">
<h2 class="anchored" data-anchor-id="what-about-relationships-between-geometries">What about relationships between geometries?</h2>
<p>A geometry can be perfectly valid on its own and still be wrong in relation to other geometries: two valid polygons overlapping when they shouldn’t, a gap between areas that should touch. That’s a different problem: topology. It’s covered in <a href="../posts/topology-errors.html">Topology Errors in a GeoJSON</a>.</p>
<div id="lab-root"></div>
<script src="../webgeods-preact.js"></script>
<script>
document.addEventListener("DOMContentLoaded", () =>
  WebGeoDS.Preact.mount("geometry-validity-lab", "#lab-root"));
</script>
<script async="" data-uid="31051e3ca1" src="https://webgeods.kit.com/31051e3ca1/index.js"></script>


</section>


 ]]></description>
  <category>tool</category>
  <category>geometry</category>
  <category>bilingual</category>
  <guid>https://webgeods.com/posts/geometry-validity.html</guid>
  <pubDate>Sat, 29 Aug 2026 00:00:00 GMT</pubDate>
</item>
</channel>
</rss>
