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.
Stage
Native
Managed
Speedup
CVA magnitude, 512×512×4
1.18 ms
1.72 ms
1.46×
Otsu threshold + histogram
0.24 ms
0.50 ms
2.11×
Morphological open, 2 iterations
2.90 ms
3.16 ms
1.09×
Connected components
0.62 ms
0.75 ms
1.20×
Compute total
4.93 ms
6.13 ms
1.24×
End-to-end DetectAsync, 512×512×4
7.74 ms
8.45 ms
1.09×
End-to-end DetectAsync, 256×256×4
1.51 ms
1.76 ms
1.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?