Computing DEM of Difference Rasters in Python

The difference raster came out covered in noise, with a faint diagonal corduroy pattern across the whole site and a rim of extreme values around every edge. The two input surfaces are both clean. Nothing is wrong with the survey; what is wrong is the subtraction, and specifically three things about it that a one-line b - a does not handle.

This page covers producing a difference raster that is worth looking at: putting both surfaces on genuinely the same grid, handling NoData so it propagates rather than poisons, attaching the uncertainty that makes the values interpretable, and writing the result so the threshold and provenance travel with the file. It is the raster half of change detection between survey epochs.

The three faults hiding in b - a

Grid mismatch. Two surveys gridded independently rarely share an origin. Their transforms differ by a fraction of a cell, and resampling one onto the other to make the subtraction possible introduces an interpolation error that is largest exactly where the terrain is steepest. The corduroy pattern is the resampling kernel beating against the grid offset.

NoData poisoning. If either surface uses a sentinel such as −9999 and the subtraction is done on raw arrays, every cell where one survey has no data becomes a difference of thousands of metres. Those cells then dominate every statistic computed afterward, including the ones used to choose a colour ramp, which is why the map often renders as two colours.

Edge effects. The two surveys almost never cover exactly the same extent. Where one has data and the other does not, the difference is undefined — but a naive subtraction over a union extent produces a rim of nonsense, and over an intersection extent silently drops real coverage.

Three faults in a naive raster subtraction Three panels. The first shows two grids offset by a fraction of a cell, with an arrow indicating that resampling one onto the other creates a periodic interpolation error that appears as a diagonal pattern. The second shows a NoData sentinel of minus nine thousand nine hundred and ninety-nine entering the subtraction and producing a difference of nine thousand nine hundred metres, which then dominates the colour ramp. The third shows two survey extents overlapping partially, with a rim where only one survey has data and the difference is undefined. Each panel names its remedy. grid mismatch offset by 0.3 of a cell fix: regrid both to a shared grid NoData poisoning 98.4 − (−9999) = 10 097.4 m one cell sets the colour ramp fix: mask before arithmetic extent mismatch fix: intersect, and record what was dropped All three produce a map that looks like data and is not. None of them raises an error, which is why they survive into delivered products. the subtraction is the easy part

Figure 1 — What a one-line subtraction quietly gets wrong.

Minimal reproducible solution

Regrid both surfaces onto one explicitly defined grid, mask properly, and subtract.

import numpy as np
import rasterio
from rasterio.warp import reproject, Resampling


def common_grid_difference(path_a: str, path_b: str, out_path: str,
                           *, resampling=Resampling.bilinear) -> dict:
    """Difference two surfaces on a shared grid, with honest NoData.

    Epoch A's grid is adopted as the reference rather than inventing a third,
    because it keeps the result comparable with any earlier difference that
    used the same reference.
    """
    with rasterio.open(path_a) as a, rasterio.open(path_b) as b:
        if a.crs != b.crs:
            raise ValueError(f"CRS mismatch: {a.crs} vs {b.crs}")
        za = a.read(1, masked=True).filled(np.nan).astype("float32")

        zb = np.full(za.shape, np.nan, dtype="float32")
        reproject(source=rasterio.band(b, 1), destination=zb,
                  src_transform=b.transform, src_crs=b.crs,
                  dst_transform=a.transform, dst_crs=a.crs,
                  src_nodata=b.nodata, dst_nodata=np.nan,
                  resampling=resampling)
        profile = a.profile

    both = np.isfinite(za) & np.isfinite(zb)
    diff = np.where(both, zb - za, np.nan).astype("float32")

    profile.update(dtype="float32", nodata=np.nan, count=1, compress="deflate")
    with rasterio.open(out_path, "w", **profile) as dst:
        dst.write(diff, 1)
        dst.update_tags(1,
                        EPOCH_A=path_a, EPOCH_B=path_b,
                        RESAMPLING=resampling.name,
                        COVERAGE_FRACTION=f"{both.mean():.4f}")

    return {"coverage_fraction": float(both.mean()),
            "a_only_cells": int(np.count_nonzero(np.isfinite(za) & ~np.isfinite(zb))),
            "b_only_cells": int(np.count_nonzero(np.isfinite(zb) & ~np.isfinite(za)))}

Using NaN as the nodata value throughout, rather than a sentinel, is what removes the poisoning class of bug entirely: NaN propagates through arithmetic by definition, and np.nanmean and friends ignore it without being told. The one cost is that integer rasters cannot carry it, which is not a constraint for elevation data.

Reporting a_only_cells and b_only_cells separately matters because they mean different things. Cells present in the older survey and missing from the newer one usually indicate a reconstruction that got worse — new vegetation, worse light. The reverse usually means coverage improved. Either way, a difference raster that silently lost a quarter of the site should say so.

What is actually in the difference raster

A DEM of difference is not a map of change. It is a map of change plus three other things, and separating them is the whole job.

What a DEM of difference actually contains Four contributions are stacked. Real surface change is the signal the survey exists to measure. Registration error is a smooth regional tilt or offset shared across the whole scene. Interpolation error appears wherever either epoch had sparse coverage and the grid was filled rather than measured. Per-cell noise is random and is the term a detection threshold is set against. A note states that only the first belongs in a reported volume and the other three have to be bounded before it can be. real surface change the signal — what the survey exists to measure registration error a smooth regional tilt or offset shared across the scene interpolation error wherever either epoch was sparse and the grid was filled, not measured per-cell noise random, and what a detection threshold is set against Only the first belongs in a reported volume. The other three have to be bounded before it can be.

Figure 3 — Four contributions, one raster.

Edge-case matrix

Input variant Naive result Correct handling
Different CRS Silent nonsense or empty Assert, do not auto-reproject elevations
Different grid origin Corduroy artefacts Regrid onto one reference explicitly
Sentinel NoData (−9999) Differences of thousands Read masked, fill with NaN
Different resolutions Fine detail invented Resample the coarser onto the finer, never the reverse
Partial overlap Rim of nonsense Intersect, and count what was dropped
Different vertical datums Uniform offset read as change Assert the vertical CRS matches
One survey in feet Difference scaled by 3.28 Assert units before arithmetic
Overlapping but rotated grids Heavy resampling loss Regrid both to a common north-up grid

Verification snippet

The check that matters is whether the difference behaves like a difference: near zero over stable ground, with a spread consistent with the two inputs’ own accuracy.

import numpy as np


def sanity_check_difference(diff: np.ndarray, stable_mask: np.ndarray,
                            expected_sigma: float) -> dict:
    """Does this difference raster look like two good surveys disagreeing?"""
    s = diff[stable_mask & np.isfinite(diff)]
    if s.size < 200:
        raise ValueError("not enough stable cells to judge the difference")

    bias = float(np.median(s))
    sigma = float(np.median(np.abs(s - bias)) * 1.4826)
    problems = []
    if abs(bias) > 0.02:
        problems.append(f"stable ground offset by {bias:+.3f} m — datum or registration")
    if sigma > 2.5 * expected_sigma:
        problems.append(f"stable scatter {sigma:.3f} m is far above the expected "
                        f"{expected_sigma:.3f} m — check the resampling and grids")
    if sigma < 0.3 * expected_sigma:
        problems.append("stable scatter is suspiciously low — the two surfaces "
                        "may share a source or be the same file")
    return {"stable_bias_m": bias, "stable_sigma_m": sigma, "problems": problems}

The suspiciously-low branch catches a real and embarrassing failure: differencing a survey against itself, or against a copy that was never actually reprocessed. It produces a beautiful, clean, entirely meaningless map, and without the check nobody notices until a client asks why nothing has changed in four months.

Coverage accounting for a pair of survey epochs A bar breakdown of one hundred percent of the reference grid. Seventy-eight percent has data in both epochs and is differenced. Nine percent has data only in the earlier epoch, indicating a reconstruction that got worse. Five percent has data only in the later epoch, indicating improved coverage. Eight percent has data in neither and lies outside both surveys. A note states that a difference raster reporting only the seventy-eight percent, without the rest, hides a change in coverage that can look like a change in the site. both epochs — 78 % A only 9 % B only 5 % neither 8 % Nine percent of the site was measured last time and is not measured now. That is a finding in itself, and it disappears if only the differenced cells are reported. Report coverage with every difference raster, in the file's own tags.

Figure 2 — Coverage is part of the result. A shrinking differenced area is often the most important thing a monitoring run has to say.

Choosing a resampling method that does not invent change

Resampling one surface onto the other’s grid is unavoidable, and the method chosen has a direct effect on the apparent change. Bilinear is the right default for elevation: it is continuous, cheap, and its error is proportional to local curvature. Cubic is smoother and overshoots at breaks, which manufactures paired positive and negative change along every kerb and bench edge — precisely the features a monitoring run cares about.

Nearest neighbour has one legitimate use here and it is worth knowing: when the two grids are offset by exactly zero and the resolutions match, nearest is a no-op and therefore lossless. Asserting that condition and taking the fast path avoids resampling error entirely on the common case of two surveys gridded from the same template.

import numpy as np


def grids_are_identical(a, b, tol: float = 1e-9) -> bool:
    """True when two rasters share a grid exactly, so no resampling is needed."""
    return (a.crs == b.crs and a.shape == b.shape
            and all(abs(x - y) < tol for x, y in zip(a.transform, b.transform)))

Making every survey of a site use one fixed grid template — origin, resolution and extent stored with the site, exactly as the base surface is in computing volumes and stockpiles in Python — turns this from a judgement into a check. Every subsequent difference is then exact, every monitoring point samples the same cells, and the corduroy artefact cannot occur.

When to escalate

  • The stable-ground scatter is far above the surveys’ own accuracy. Something other than resampling is wrong: most likely the two epochs used different control, which is a georeferencing problem rather than a raster one.
  • Coverage dropped sharply between epochs. The newer flight is worse. Investigate the flight rather than patching the raster; interpolating across the loss produces a surface that differences cleanly and means nothing.
  • The difference must be measured on steep faces. A vertical difference is the wrong quantity there; move to a cloud-to-cloud distance.

Change Detection Between Survey Epochs