Raster Change Detection – Technical Details

Back to Demo

High-Level System Overview

  • Purpose: Two satellite passes over the same ground, weeks apart — what changed? That question drives new-construction monitoring, deforestation tracking, flood-extent mapping, post-disaster damage assessment, and every tip-and-cue workflow in commercial GEOINT.
  • Domain: Multitemporal change detection over co-registered imagery. The chain is the classic remote-sensing one: CVA (Change Vector Analysis) magnitude, Otsu automatic thresholding, morphological open, connected-component labelling, ranked detections.
  • Dataset: A deterministic synthetic four-band, two-epoch stack — Red, Green, NIR, SWIR — over a foothill AOI with a sinuous drainage, a road grid, vegetated blocks and bare ground. Four changes are planted in epoch B: a new subdivision, a burn scar, a reservoir drawdown, and a new solar array.
  • Ground truth: Because the changes are planted, their bounding boxes are known. The page scores detections against them and reports "n of 4 recovered", which turns the demo into an evaluation rather than a picture.
  • Statelessness: No database and no persistence. The scene is generated from a fixed-seed Park-Miller LCG, so the same size and noise level always produce a byte-identical stack.
  • Scale: A 512×512 four-band stack is 2,097,152 doubles per epoch. The default 256×256 run is 65,536 pixels through four dense passes.

Algorithm

  • Change Vector Analysis: magnitude[i] = sqrt( Σb (epochB[b][i] − epochA[b][i])² ) — the Euclidean length of the per-pixel spectral difference vector. CVA beats differencing a single band because it does not need to know which band carries the change, and because a uniform illumination offset moves every band together rather than saturating one. Epoch B in the demo carries exactly such an offset, deliberately.
  • Otsu's method: Build a 128-bin histogram of the magnitude raster, then walk it once accumulating cumulative weight and cumulative weighted mean. Between-class variance for a split after bin b is w₀·w₁·(μ₀ − μ₁)²; the winning bin's upper edge is the threshold. No magic number, and the threshold visibly moves as sensor noise rises.
  • Degenerate histogram: When every magnitude is identical the threshold is set to that value, which classifies nothing as changed. Returning the minimum instead would classify everything as changed — the opposite failure, and much worse.
  • Morphological open: Erode then dilate with a 3×3 (8-connected) structuring element, which removes isolated speckle while preserving anything wider than the element. With iterations > 1 this is erode×n then dilate×n — one open with an n-scaled element — not n consecutive opens, which would remove far less.
  • Border convention: Pixels outside the raster read as background, so erosion eats inward from the edge and a blob touching the border loses its border row. That is the conservative choice: an edge-clipped detection is a partial detection, not a fabricated one.
  • Connected components: Two-pass union-find with path compression over a flat parent array, 8-connectivity. Pass one assigns provisional labels and unions the four already-visited neighbours (NW, N, NE, W); pass two resolves roots, compacts ids in first-appearance order, and accumulates area, centroid, mean magnitude and bounding box in the same sweep.
  • Ranking: Blobs under the minimum-area threshold are dropped (the pre-filter count is reported so the filtering is visible), the survivors are sorted by area descending with mean magnitude as the tie-break, and ids are renumbered from 1.
  • Confidence: Mean magnitude over the scene's maximum magnitude, clamped to 0–1. It is a relative ranking heuristic, not a calibrated probability, and it is labelled as such in the DTO, the API, and the UI.

API Layer

  • Endpoints: GET /api/v1/change/scene?width=256&height=256&noise=0.025 and POST /api/v1/change/detect.
  • Versioning: Asp.Versioning with the version in the route template, matching every other API in the portfolio.
  • Payload guards: [RequestSizeLimit(16_000_000)] on the POST — two four-band 512×512 double epochs is a genuinely large body — plus [EnableRateLimiting("expensive")] on the controller. Dimensions are capped at 512 and total pixels at 262,144.
  • Response size: The result carries the full magnitude raster and the full mask. At 256×256 that is 65,536 elements each; the client needs both to render the mask overlay and to let the user re-inspect without a round trip.
  • Error contract: Only ArgumentException is caught, returning 400 with { "error": "..." }. The service's validation messages are therefore the public API contract and are written as user-facing text.
  • Loud failure over silent truncation: A mask with more than 5,000 connected components is rejected with a message telling the caller to raise the threshold, and the native kernel returns status -6 for the same condition. A change detector that quietly drops detections is worse than one that fails.

Services and Native Boundary

  • Kernel: change_detection_kernel, a C++20 shared library exposing Change_ComputeCvaMagnitude, Change_OtsuThreshold, Change_MorphologicalOpen and Change_LabelComponents through a stable C ABI with status-code returns.
  • No structs at the boundary: Unlike every other kernel in the portfolio, this one marshals nothing but flat contiguous primitive buffers — double[], byte[], int[]. Raster work is the one place where the P/Invoke boundary is genuinely free of shape.
  • Where the boundary sits: The kernel returns raw arrays. Blob filtering, ranking, confidence, and DTO assembly are C#. Dense array arithmetic goes native; result shaping stays managed where it is readable and unit-testable.
  • Per-stage fallback: Each of the four stages falls back independently, and NativeAccelerated is only reported true when every stage took the native path. A partial native run is honestly reported as not accelerated.
  • Managed fallback: The C# implementation mirrors the kernel line for line — same histogram binning, same bin-centre means, same union-by-smaller-root, same border convention — so results are identical either way. The fallback is production behaviour, not a test convenience: the deployed container does not build the native libraries, so what runs in production today is the managed path.
  • Measured speedup: Benchmarked on a 512×512 four-band stack, warm, best-of-50 per stage, with the shared library present versus moved out of the probe path. Checksums over the magnitude raster, the mask, the threshold, and every blob statistic are bit-identical between the two paths.
    Native versus managed benchmark results for the change detection kernel, by pipeline stage.
    StageNativeManagedSpeedup
    CVA magnitude, 512×512×41.18 ms1.72 ms1.46×
    Otsu threshold + histogram0.24 ms0.50 ms2.11×
    Morphological open, 2 iterations2.90 ms3.16 ms1.09×
    Connected components0.62 ms0.75 ms1.20×
    Compute total4.93 ms6.13 ms1.24×
    End-to-end DetectAsync, 512×512×47.74 ms8.45 ms1.09×
    End-to-end DetectAsync, 256×256×41.51 ms1.76 ms1.17×
    For comparison, cat_risk_kernel measures ~1.1×. The dense, branch-free stages do measurably better — Otsu's single histogram pass is the biggest win at 2.1× — and the branch-heavy morphological open, which short-circuits on almost every pixel, does barely better than the JIT. That gradient is the actual finding: the flatter and more predictable the inner loop, the more native buys.
  • Why end-to-end is lower than compute: Roughly a third of DetectAsync is List<double>double[] conversion at the DTO edge, which is identical on both paths and dilutes the ratio. Nothing in this kernel uses SIMD intrinsics or threading; the gains above are plain scalar C++ against RyuJIT.
  • Known wart: IsNativeInvocationException includes InvalidOperationException, which is exactly what the negative-status check throws. A genuine native error — including the -6 too-many-components status — therefore logs one warning and silently degrades to managed rather than surfacing. This is consistent across all the kernels and is a deliberate availability-over-visibility trade, but it means native failures are invisible in production metrics.

Engineering Decisions and Tradeoffs

  • Canvas, not 65,536 divs: The Terrain Analyzer renders its raster with .raster-grid / .raster-cell DOM nodes, which is fine at its size. At 256×256 that is 65,536 elements and at 512×512 it is 262,144 — a layout and memory cost no browser absorbs gracefully. Every raster here is written as ImageData to a <canvas> instead, and only the 128-bin histogram stays SVG. That is the single most consequential frontend decision on the page.
  • Swipe over slider: Before/after comparison is a pointer-drag over two stacked canvases clipped with clip-path: inset(...), not a range input. The interaction is the explanation — dragging across a burn scar communicates more than any legend.
  • False-colour composite: Bands are rendered NIR/Red/Green, the standard vegetation-forward composite, in which healthy vegetation reads red and burned or cleared ground reads dark. Naming the composite correctly matters more than the pixels do.
  • Log-scaled histogram: The unchanged population outnumbers the changed one by two orders of magnitude. A linear count axis flattens the second mode to invisibility, which would hide the exact thing Otsu is reasoning about.
  • Defaults calibrated from the data, not from round numbers: σ = 0.025, one open iteration, minimum blob area 12. At those settings all four planted changes are recovered with zero false positives. At σ = 0.05 with the open disabled the mask yields 308 components, of which 304 are speckle — which is precisely the demonstration the open iteration control exists for.
  • Where the demo honestly fails: Two open iterations erase the reservoir drawdown. The drawdown is a five-pixel-wide crescent, and erode×2 is wider than that. The tool reports 3 of 4 recovered and does not pretend otherwise — morphological open trades small real changes for speckle suppression, and this is what that trade looks like.
  • Co-registration is assumed: The pipeline takes pixel-to-pixel alignment as given, which real imagery does not. A one-pixel misregistration lights up every high-contrast edge in the scene, and on this AOI that means the road grid and the river banks — a change mask that traces linear features is the signature of a registration problem, not of change.
  • Stateless synthetic scene: Real imagery would mean tiles, licensing, and a storage story. A deterministic generator gives reproducible ground truth instead, which is what makes the recovery KPI meaningful.

Interview Discussion Points

  • Why Change Vector Analysis instead of simply differencing one band? What does CVA give you when the two acquisitions have different illumination?
  • Otsu picks a threshold by maximizing between-class variance. What assumption does that make about the histogram, and what happens when only 0.5% of the scene actually changed?
  • The pipeline assumes the epochs are co-registered. What does a one-pixel misregistration do to the change mask, and how would you detect that it happened?
  • Connected-component labelling here is two-pass union-find. What is the alternative, and why is union-find the right call for a raster?
  • Morphological open removes speckle but also erodes real small changes. How do you choose the structuring element size, and what do you lose?
  • Confidence is mean magnitude over max magnitude. Why is that not a probability, and what would you need to produce a calibrated one?
  • At 512×512×4 bands this is 4 MB of doubles crossing the P/Invoke boundary per call. Where does that stop scaling, and what would you change — tiling, floats instead of doubles, memory-mapped rasters, or moving the compute to the client?
  • The dense CVA pass measures 1.46× native over managed while the branch-heavy morphological open measures 1.09×. What does that gradient tell you about when to reach for native code at all?
  • How would this change if you had 12 epochs instead of 2?