Separating Real Change from Noise with Detection Limits
The client looks at the difference map and asks which of the red areas matter. It is a fair question and, as produced, an unanswerable one: the map shows every cell where the two surveys disagreed by any amount, and most of those disagreements are the surveys rather than the site.
The missing piece is a number — the smallest change this pair of surveys could reliably have seen. With it, the map becomes two categories instead of a continuum, and every coloured cell is one the reader can trust. Without it, the reader is being asked to do the statistics by eye.
This page derives that number from the data, extends it to vary across a site where accuracy varies, and covers how to state it so a reviewer can check it. It is the thresholding step of change detection between survey epochs.
Why the limit must come from the data
A sensor datasheet gives a per-point precision under ideal conditions. A survey report gives an RMSE against checkpoints, usually measured on open hardstanding. Neither describes what the pipeline can resolve between two specific flights over one specific site, because the actual limit is set by everything that differed between them: lighting, vegetation state, flight geometry, control quality, processing parameters.
The data already contains the answer. Ground that the site confirms has not changed — a concrete apron, a road, a building roof — should difference to zero. It does not, and the spread of how much it misses by is the noise floor. Everything smaller than a couple of standard deviations of that spread is indistinguishable from the surveys disagreeing.
Two properties make this a good estimator. It is empirical, so it captures causes nobody anticipated. And it is checkable: a reviewer can be handed the stable polygon and reproduce the number.
Figure 1 — Three numbers that all sound authoritative. Only one of them is about this pair of surveys.
Minimal reproducible solution
import numpy as np
def detection_limit(diff: np.ndarray, stable_mask: np.ndarray,
*, confidence: float = 1.96,
min_cells: int = 500) -> dict:
"""Minimum detectable change, measured over ground known not to have moved.
Robust estimators throughout. A stable polygon drawn from a site walk
almost always includes a few cells that were not stable — a repainted
line, a parked vehicle that moved — and a mean and standard deviation
would let those set the threshold for the whole map.
"""
r = diff[stable_mask & np.isfinite(diff)]
if r.size < min_cells:
raise ValueError(f"{r.size} stable cells is too few for a stable estimate")
bias = float(np.median(r))
sigma = float(np.median(np.abs(r - bias)) * 1.4826)
limit = confidence * sigma
return {"detection_limit_m": limit,
"residual_bias_m": bias,
"residual_sigma_m": sigma,
"n_stable_cells": int(r.size),
"confidence": confidence,
"expected_false_positive_rate": 2 * (1 - _phi(confidence))}
def _phi(z: float) -> float:
"""Standard normal CDF, without pulling in scipy for one value."""
import math
return 0.5 * (1 + math.erf(z / math.sqrt(2)))
def apply_limit(diff: np.ndarray, limit: float, bias: float = 0.0) -> np.ndarray:
"""Mask everything the surveys could not distinguish from agreement."""
centred = diff - bias
return np.where(np.isfinite(centred) & (np.abs(centred) > limit),
centred, np.nan)
Subtracting the residual bias before thresholding is a small step with a large effect. If the two surveys sit 1.5 cm apart on stable ground after registration — which is common and not a failure — then leaving the bias in place makes the threshold asymmetric: change of one sign needs to exceed 6.3 cm and change of the other 9.3 cm. Centring first restores symmetry.
Sweeping the threshold
A single threshold applied once is a choice nobody can check. Sweeping it and plotting the result turns that choice into evidence.
Figure 3 — Sweep the threshold; the plateau is the defensible region.
Edge-case matrix
| Situation | Naive handling | Correct handling |
|---|---|---|
| Stable set includes ground that moved | Limit inflated, real change hidden | Robust statistics, and re-check the polygon |
| Stable set on hardstanding only | Limit optimistic for vegetated areas | Spatially varying limit, or state the restriction |
| Fewer than a few hundred stable cells | Unstable estimate | Refuse, and say why |
| Strongly non-normal residuals | ±1.96σ is not 95 % | Use empirical percentiles instead |
| Systematic tilt across the site | Single limit misleads | De-trend first, or vary the limit spatially |
| Change smaller than the limit but real | Reported as no change | Correct, and the report should say what was resolvable |
| Two surveys from the same processing run | Limit implausibly small | Flag as a likely duplicate input |
| Seasonal vegetation difference | Huge apparent change | Restrict to ground and structures before differencing |
The non-normal row deserves a note. Residuals from photogrammetric surfaces frequently have heavier tails than a Gaussian, so the 1.96σ figure under-covers. Where that matters, take the empirical 2.5th and 97.5th percentiles of the stable residual and use those directly as the limits — the arithmetic is simpler and makes no distributional assumption.
import numpy as np
def empirical_limits(diff: np.ndarray, stable_mask: np.ndarray,
lower_pct: float = 2.5, upper_pct: float = 97.5) -> dict:
"""Asymmetric limits straight from the stable distribution, no assumptions."""
r = diff[stable_mask & np.isfinite(diff)]
lo, hi = np.percentile(r, [lower_pct, upper_pct])
return {"lower_m": float(lo), "upper_m": float(hi),
"width_m": float(hi - lo), "n": int(r.size)}
A limit that varies across the site
A single number assumes the survey is equally good everywhere, and it never is. Accuracy degrades with distance from control, in areas of poor texture, on steep slopes and under partial vegetation. On a large site the difference between the best and worst areas is routinely a factor of three, which means one global limit is simultaneously too strict in the good areas and too loose in the bad.
The practical approach is to model the limit as a function of an available covariate — most usefully the local point density, which is high where reconstruction went well and low where it did not.
import numpy as np
def spatial_detection_limit(diff: np.ndarray, stable_mask: np.ndarray,
density: np.ndarray, *, bins: int = 5,
confidence: float = 1.96) -> np.ndarray:
"""Per-cell detection limit, binned by reconstruction point density.
Cells are grouped into density quantiles; each group's own stable
residual sets the limit for every cell in that group. Sparse areas
correctly get a looser threshold than dense ones.
"""
valid = np.isfinite(diff) & np.isfinite(density)
edges = np.nanpercentile(density[valid], np.linspace(0, 100, bins + 1))
out = np.full(diff.shape, np.nan, dtype="float32")
for i in range(bins):
in_bin = valid & (density >= edges[i]) & (density <= edges[i + 1])
r = diff[in_bin & stable_mask]
if r.size < 200:
continue # not enough stable cells in this bin
bias = np.median(r)
sigma = np.median(np.abs(r - bias)) * 1.4826
out[in_bin] = confidence * sigma
# Fall back to the global limit wherever a bin had too little stable ground.
global_r = diff[stable_mask & np.isfinite(diff)]
gb = np.median(global_r)
out[np.isnan(out) & valid] = confidence * np.median(np.abs(global_r - gb)) * 1.4826
return out
The payoff is concrete: on a site where the yard reconstructs at 400 points per square metre and the vegetated margin at 30, the yard gets a 4 cm limit and the margin a 12 cm one. Real 6 cm movement in the yard is then reported, where a global 12 cm limit would have suppressed it; and noise in the margin is not reported, where a global 4 cm limit would have filled it with false change.
Figure 2 — The cost of a single threshold, in the two directions it fails.
Verification snippet
import numpy as np
def verify_limit(diff: np.ndarray, stable_mask: np.ndarray,
limit: float, expected_rate: float = 0.05) -> dict:
"""The limit must flag stable ground at close to the design rate."""
s = diff[stable_mask & np.isfinite(diff)]
rate = float(np.count_nonzero(np.abs(s) > limit) / max(s.size, 1))
ratio = rate / expected_rate if expected_rate else float("inf")
verdict = ("as designed" if 0.5 < ratio < 2.0 else
"limit too tight — stable ground is being reported as change"
if ratio >= 2.0 else
"limit too loose — real change may be suppressed")
return {"observed_false_positive_rate": rate, "expected": expected_rate,
"verdict": verdict}
When to escalate
- No stable ground exists. On a site under complete reconstruction there is nothing to measure against, and the honest answer is that the comparison has no measurable detection limit. Establish a control area — even a small concrete pad — for future flights.
- The limit exceeds the change the client cares about. The survey cannot answer their question. Say so before delivering a map; more flights at the same quality will not help, but better control or a lower flying height might.
- Stable ground shows a spatial pattern rather than scatter. A tilt or a dome is a georeferencing problem, and thresholding around it will systematically mis-report one half of the site. Fix the survey before setting any limit.