Validating Nodata and Band Integrity After Warp
Reprojecting a raster is the one operation in a coverage pipeline that can produce a technically perfect output which is completely wrong. The warp succeeds, the file is valid, every tile renders, the pyramid is complete — and the imagery is in the wrong bands, or three quarters of the scene has quietly become nodata because the target grid clipped it, or the nodata value itself was dropped so that a sentinel like -9999 is now being averaged into overviews as if it were an elevation. None of these raise an error. This guide covers the assertions to run immediately after a warp so that a coverage which changed shape is rejected rather than published, and it belongs to raster and tile pipeline observability under geospatial observability architecture fundamentals.
Problem framing: why a warp changes shape silently
Reprojection is a resampling operation onto a new grid, and three things can shift without producing a failure.
Footprint against grid. The target grid is defined by an extent and a resolution. If the source scene does not tile neatly onto it — because the extent was inherited from a different scene, or the resolution was rounded — the warp fills the difference with nodata. The output is a valid raster of the requested shape; it simply contains less data than it should. This is by far the most common silent defect, and it is invisible unless you compare nodata ratios before and after.
Band identity. Band order is positional, not named, in most raster formats. A source that changes band order, or a warp invoked with an explicit band list that no longer matches, produces a file with the correct band count and the wrong band content. Rendering succeeds; colours are wrong; nothing counts an error.
Nodata declaration. The nodata value is metadata, not data. If it is not carried through the warp, the sentinel value survives as an ordinary sample. Overviews then average it in, statistics are poisoned, and a terrain layer acquires a plateau at minus nine thousand nine hundred and ninety-nine metres. The nodata ratio reads zero, which looks like the healthiest possible value.
All three are contract violations against the source, which means the right check compares the output to the input, not to an absolute threshold.
Implementation: assert the output against the source
Run the assertions in the warp worker, before the output is promoted. Each is cheap relative to the warp itself.
# warp_assertions.py — reject a coverage whose shape changed during reprojection.
from dataclasses import dataclass
from osgeo import gdal
from opentelemetry import metrics
gdal.UseExceptions()
meter = metrics.get_meter("gis.raster")
nodata_ratio = meter.create_observable_gauge("gis.raster.nodata_ratio", callbacks=[])
warp_rejected = meter.create_counter("gis.raster.warp_rejected_total")
@dataclass
class BandProfile:
index: int
nodata: float | None
valid_ratio: float
minimum: float
maximum: float
mean: float
def profile(path: str) -> list[BandProfile]:
ds = gdal.Open(path)
out = []
for i in range(1, ds.RasterCount + 1):
band = ds.GetRasterBand(i)
# approx_ok=False so the stats reflect real pixels, not a decimated read;
# a decimated read can miss a nodata border entirely on a large scene.
mn, mx, mean, _std = band.ComputeStatistics(False)
# GetMaskBand gives valid-pixel coverage whether nodata is declared
# by value or by an alpha/mask band, which the raw value test misses.
mask = band.GetMaskBand().ComputeBandStats()[0] / 255.0
out.append(BandProfile(i, band.GetNoDataValue(), mask, mn, mx, mean))
return out
def assert_warp_integrity(src: str, dst: str, layer: str,
nodata_tolerance: float = 0.05) -> None:
before, after = profile(src), profile(dst)
# 1. Band count is a hard contract — never "close enough".
if len(before) != len(after):
warp_rejected.add(1, {"layer": layer, "reason": "band_count"})
raise ValueError(f"{layer}: band count {len(before)} → {len(after)}")
for b0, b1 in zip(before, after):
# 2. The nodata DECLARATION must survive, or the sentinel becomes data.
if (b0.nodata is None) != (b1.nodata is None):
warp_rejected.add(1, {"layer": layer, "reason": "nodata_declaration"})
raise ValueError(f"{layer} band {b1.index}: nodata declaration lost")
# 3. Valid coverage must not collapse — this catches grid clipping.
lost = b0.valid_ratio - b1.valid_ratio
if lost > nodata_tolerance:
warp_rejected.add(1, {"layer": layer, "reason": "nodata_expansion"})
raise ValueError(
f"{layer} band {b1.index}: valid coverage {b0.valid_ratio:.3f} "
f"→ {b1.valid_ratio:.3f}")
# 4. Band identity by statistical fingerprint — a reorder changes which
# band carries which distribution even though the count is intact.
if b0.mean > 0 and abs(b1.mean - b0.mean) / b0.mean > 0.15:
warp_rejected.add(1, {"layer": layer, "reason": "band_identity"})
raise ValueError(
f"{layer} band {b1.index}: mean {b0.mean:.1f} → {b1.mean:.1f} "
"— band order or scaling changed")
Two details carry most of the value. Using the mask band rather than testing pixels against the nodata value covers coverages that express validity through an alpha or mask band instead of a sentinel — a distinction that silently defeats value-based checks on exactly the datasets most likely to use masks. And computing exact statistics rather than approximate ones matters because a decimated read samples the interior of a scene and can miss a nodata border entirely, which is the defect you are hunting.
The band-identity check via mean comparison is deliberately loose. Resampling shifts means slightly; a fifteen percent tolerance passes ordinary resampling and fails a swap between spectrally distinct bands. For datasets where two bands have genuinely similar distributions, add a correlation check against the source band instead, or carry band descriptions in metadata and assert on those.
Verification: manufacture each defect and confirm rejection
The assertions are only trustworthy if you have seen them fire. Each defect is easy to manufacture from a known-good scene.
Produce nodata expansion by warping into a target extent deliberately offset from the source footprint; the valid-coverage assertion must reject it, with reason="nodata_expansion" on the counter. Produce band reordering by warping with an explicit band list in reversed order; the identity assertion must reject it. Produce a dropped declaration by warping with the nodata value unset in the output creation options; the declaration assertion must reject it and, importantly, must do so before the coverage assertion, since a missing declaration makes the coverage figure meaningless.
Then run the assertions against a normal daily warp and confirm they pass with margin. A tolerance that only just passes ordinary operation will reject a legitimately cloudy scene next week.
Gotchas
Comparing against a fixed nodata threshold. “Reject if nodata exceeds twenty percent” fails on a scene that is legitimately mostly ocean and passes on a scene that lost half its data but started at five percent. Always compare against the source’s own ratio.
Approximate statistics. Fast statistics decimate the read and can entirely miss a nodata border, which is the shape most warp clipping produces. Use exact computation for the assertion even if approximate values are fine for dashboards.
Assuming nodata is a value. Coverages using alpha or mask bands have no nodata value at all, and a value-based check reports zero nodata on a scene that is three-quarters transparent. Read validity through the mask band.
Running the assertions after publication. These checks are a gate, not a monitor. Running them on the published coverage tells you about a problem your users already have, and the whole point of asserting against the source is that the source is still available at warp time — the trust-boundary discipline described in defining spatial data trust boundaries.
FAQ
What tolerance should valid-coverage loss use?
Start at five percent and calibrate against a month of normal runs. Resampling at a different resolution genuinely changes edge coverage slightly, so a zero tolerance flaps. If your warps are grid-aligned and same-resolution, tighten to one percent — a tighter bound on a stable pipeline is strictly more useful.
Can I use band descriptions instead of statistics for identity?
Yes, and prefer it where the format and toolchain preserve descriptions reliably. Asserting that band three is still described as nir is far more direct than inferring identity from a mean. The statistical fingerprint is the fallback for formats and pipelines that drop descriptions, which is many of them.
Should a rejected warp halt the whole layer?
Halt that scene, not the layer. A single bad source scene should be quarantined and reported while other scenes continue, since coverage pipelines routinely process hundreds of scenes per run. If the rejection rate across scenes crosses a threshold, that is a layer-level incident and belongs on the pager.
How do these metrics feed the pyramid checks?
A scene rejected at warp never enters the pyramid, so completeness will show the corresponding tiles as missing. That is the correct outcome and the two signals should be read together: a completeness gap coinciding with a spike in gis.raster.warp_rejected_total is a diagnosed incident rather than a mystery, which is why both are described in raster and tile pipeline observability.
Does this apply to vector tiles?
Only partly. Vector tiles have no bands or nodata, but the analogous assertion — that the feature count and attribute schema of the tile match the source layer — serves the same purpose, and the schema half of it is the subject of schema and attribute drift detection.
Related
- Raster and tile pipeline observability — the parent topic and its metric taxonomy.
- Detecting tile pyramid gaps at high zoom — where a rejected scene shows up downstream.
- Coordinate reference system validation — the projection contract a warp is executing against.