Raster and Elevation Data Quality Checks

A digital elevation model (DEM) that fails quietly is more dangerous than a vector layer that fails loudly. Nothing raises an exception when a tile is shifted half a pixel, when a void sits in the middle of a floodplain, or when a product mixes ellipsoidal and orthometric heights — the raster opens, renders, and produces plausible contours that are wrong by a metre. This topic covers the checks that catch those defects automatically: grid and transform profiling, void accounting inside the footprint, horizontal and vertical reference validation, tile alignment, and the overview and compression checks that decide whether a raster is usable at scale. It sits alongside the vector work in Core Spatial QC Fundamentals & Standards and feeds the same severity model used everywhere else in the pipeline.

Prerequisites

  • GDAL 3.4+ and rasterio 1.3+ in the validation environment. Confirm with gdalinfo --version and python -c "import rasterio; print(rasterio.__version__)". Rasterio 1.3 is the first release with stable windowed reads over /vsicurl/ sources, which matters when the rasters live in object storage.
  • numpy 1.24+ for the array statistics, and optionally rio-cogeo 5.x if you validate cloud-optimized GeoTIFF (COG) structure.
  • A declared product specification. Every check below compares the file against an expectation: the target pixel size, the grid origin, the horizontal coordinate reference system (CRS), the vertical datum, the nodata value and the acceptable void percentage. Without a written specification these checks have nothing to assert against.
  • The data footprint, either supplied as a polygon or derivable from the valid-data mask. Void accounting is meaningless without it, because nodata outside the footprint is correct behaviour.
  • A canonical CRS decision for the programme, already made in Coordinate Reference System Precision Standards. Raster reprojection is lossy in a way vector reprojection is not — resampling changes pixel values — so the target CRS should be chosen once and enforced at ingestion.

Conceptual Foundation

A raster is three things at once, and each carries its own class of defect. It is an array of values with a data type and a nodata sentinel; it is a grid positioned in the world by an affine transform; and it is a product with a specification stating what the values mean. Most raster QC failures are a disagreement between those three layers rather than corruption of the pixels themselves.

A raster is three things, and each fails differentlyThree-layer stack describing a raster as an array of values with a nodata sentinel, a grid positioned by an affine transform, and a product with a specification stating what the values mean. Each layer carries its own defect class.Arrayvalues, data type, nodata sentinel — voids and implausible ranges live hereneeds pixel readsGridaffine transform: pixel size, origin, rotation — alignment defects live hereheader onlyProductspecification: CRS, vertical datum, tolerances — contract defects live hereheader + written specMost raster QC failures are a disagreement between two of these layers rather than corruption of the pixels themselves.
The three layers of a raster — and the cheapest layer to check is the one that catches the most publishing mistakes.

The affine transform is where the subtle errors live. Six numbers place the array in the world: pixel width, row rotation, upper-left x, column rotation, pixel height (negative for a north-up raster), and upper-left y. A tile whose origin is not an exact multiple of its pixel size is not aligned to the product grid, and every downstream mosaic, zonal statistic or tile join will resample it — introducing a half-pixel blur that nobody attributes to the transform three months later. Checking alignment costs two modulo operations against the declared grid origin.

Nodata is the second recurring source of confusion. The value is a sentinel, not a measurement, and it means “no observation here” — which is correct outside a survey footprint and a defect inside it. Distinguishing the two requires the footprint, so a void check is really a mask intersection: count cells that are nodata and inside the footprint, divide by the footprint cell count, and compare that ratio against the product tolerance. A raster reported as “12% nodata” tells you nothing; the same raster reported as “0.4% void inside footprint” is an acceptance decision.

Elevation adds a third axis and a third reference system. A height is meaningless without knowing whether it is measured from an ellipsoid or from a geoid model — the two differ by tens of metres in many parts of the world, and by enough to change a flood extent almost everywhere. The vertical datum is declared in a compound CRS when the producer bothers, and absent otherwise. Any product used for drainage, flood modelling or engineering design should treat a missing vertical datum the same way a cadastral pipeline treats a missing horizontal one: as a blocker, routed to the same dead-letter path described in Categorizing and Prioritizing Spatial Errors.

Step-by-Step Implementation

Step 1 — Profile the grid from the header

Every contract question that can be answered from the header should be answered there, before a single pixel is read. This is the cheapest stage in raster QC and it catches the majority of publishing mistakes.

What an unaligned origin does to a mosaicTwo panels. On the left, two tiles whose origins sit on the product grid meet exactly and need no resampling. On the right, the second tile is offset by half a pixel, so joining the two forces an interpolation and the seam becomes soft.Aligned: origins on the gridtiles join with no resamplinghalf-pixel offsetevery join resamples — the mosaic softensUnaligned: origin off the grid
Alignment costs two modulo operations to check and a re-processing project to discover late.
# raster_qc/profile.py — header-only grid profile
import rasterio
from rasterio.crs import CRS


def profile_raster(path: str) -> dict:
    """Read the contract-relevant properties of a raster without touching pixel data."""
    with rasterio.open(path) as src:
        t = src.transform
        return {
            "path": path,
            "driver": src.driver,
            "width": src.width,
            "height": src.height,
            "band_count": src.count,
            "dtype": src.dtypes[0],
            "nodata": src.nodata,
            "crs": src.crs.to_string() if src.crs else None,
            "epsg": src.crs.to_epsg() if src.crs else None,
            "pixel_size": (abs(t.a), abs(t.e)),
            "origin": (t.c, t.f),
            "rotation": (t.b, t.d),
            "overview_levels": src.overviews(1),
            "block_shape": src.block_shapes[0],
        }


if __name__ == "__main__":
    import json
    import sys
    print(json.dumps(profile_raster(sys.argv[1]), indent=2, default=str))

Verification: run the profiler against a known-good tile. rotation must be (0.0, 0.0) for a north-up product, nodata must not be None, and epsg must equal your canonical code. A None nodata on a product that clearly has empty margins means the sentinel is undeclared — every consumer will read those cells as real values, often 0, and a zero-elevation cliff will appear at the tile edge.

Step 2 — Assert the grid against the product specification

The profile is data; the specification turns it into a pass or fail. Keep the specification in version control next to the rules, as described in Defining Spatial Data Quality Policies.

# raster_qc/rules.py — assert a profile against the declared product spec
from dataclasses import dataclass


@dataclass(frozen=True)
class RasterSpec:
    epsg: int
    pixel_size: float          # metres, square pixels
    grid_origin: float         # product grid must be a multiple of this, in CRS units
    dtype: str
    require_nodata: bool = True
    require_overviews: bool = True


def check_profile(profile: dict, spec: RasterSpec) -> list[dict]:
    findings: list[dict] = []

    def fail(rule: str, severity: str, message: str) -> None:
        findings.append({"rule": rule, "severity": severity, "message": message})

    if profile["epsg"] != spec.epsg:
        fail("RAS_CRS_001", "blocker",
             f"declared EPSG:{profile['epsg']} != canonical EPSG:{spec.epsg}")

    px, py = profile["pixel_size"]
    if abs(px - spec.pixel_size) > 1e-6 or abs(py - spec.pixel_size) > 1e-6:
        fail("RAS_RES_001", "blocker",
             f"pixel size {px}x{py} != specified {spec.pixel_size}")

    ox, oy = profile["origin"]
    off_x = ox % spec.grid_origin
    off_y = oy % spec.grid_origin
    if min(off_x, spec.grid_origin - off_x) > 1e-6 or min(off_y, spec.grid_origin - off_y) > 1e-6:
        fail("RAS_ALIGN_001", "blocker",
             f"origin ({ox}, {oy}) is not on the {spec.grid_origin} m product grid")

    if profile["rotation"] != (0.0, 0.0):
        fail("RAS_ROT_001", "blocker", "raster is rotated; product requires a north-up grid")

    if spec.require_nodata and profile["nodata"] is None:
        fail("RAS_NODATA_001", "blocker", "no nodata value declared")

    if profile["dtype"] != spec.dtype:
        fail("RAS_TYPE_001", "warning",
             f"dtype {profile['dtype']} != specified {spec.dtype}")

    if spec.require_overviews and not profile["overview_levels"]:
        fail("RAS_OVR_001", "warning", "no overviews present; clients will read full resolution")

    return findings

Verification: feed the checker a deliberately shifted tile — add half a pixel to the origin — and confirm RAS_ALIGN_001 fires. Alignment is the rule most often missing from raster pipelines, and the one whose absence is hardest to notice after the fact.

Step 3 — Measure voids inside the footprint

This is the one check that needs pixel data, and it should be windowed so memory stays flat regardless of raster size.

# raster_qc/voids.py — void accounting inside a declared footprint
import numpy as np
import rasterio
from rasterio.features import geometry_mask


def void_stats(path: str, footprint_geom: dict) -> dict:
    """Fraction of the footprint that is nodata, computed window by window."""
    footprint_cells = 0
    void_cells = 0

    with rasterio.open(path) as src:
        nodata = src.nodata
        for _ij, window in src.block_windows(1):
            data = src.read(1, window=window)
            transform = src.window_transform(window)

            inside = ~geometry_mask(
                [footprint_geom], out_shape=data.shape,
                transform=transform, invert=False,
            )
            if not inside.any():
                continue

            missing = np.isnan(data) if np.issubdtype(data.dtype, np.floating) and nodata is None \
                else (data == nodata)

            footprint_cells += int(inside.sum())
            void_cells += int((missing & inside).sum())

    pct = (100.0 * void_cells / footprint_cells) if footprint_cells else 0.0
    return {
        "footprint_cells": footprint_cells,
        "void_cells": void_cells,
        "void_pct": round(pct, 4),
    }

Verification: run it twice — once with the true footprint and once with a footprint clipped to the data extent. The two void percentages should be close; a large divergence means the supplied footprint is wrong, not that the raster is bad. Void tolerance is a product decision, typically 0.1% for photogrammetric surfaces and up to 2% for lidar-derived models in dense vegetation.

Step 4 — Check statistics for physical plausibility

Statistics answer a different question from structure: not “is the file correct” but “are these values possible”. They are cheap when read from overviews.

# raster_qc/stats.py — plausibility bounds on an elevation surface
import numpy as np
import rasterio


def elevation_stats(path: str, min_valid: float, max_valid: float) -> dict:
    """Summary statistics from the coarsest overview, with plausibility flags."""
    with rasterio.open(path) as src:
        levels = src.overviews(1)
        factor = levels[-1] if levels else 1
        data = src.read(
            1, out_shape=(src.height // factor or 1, src.width // factor or 1),
        ).astype("float64")
        if src.nodata is not None:
            data[data == src.nodata] = np.nan

    valid = data[~np.isnan(data)]
    if valid.size == 0:
        return {"empty": True}

    stats = {
        "min": float(valid.min()),
        "max": float(valid.max()),
        "mean": float(valid.mean()),
        "p01": float(np.percentile(valid, 1)),
        "p99": float(np.percentile(valid, 99)),
        "sampled_from_overview": factor,
    }
    stats["below_min_valid"] = stats["min"] < min_valid
    stats["above_max_valid"] = stats["max"] > max_valid
    # A spike of exactly 0 in a terrain model usually means undeclared nodata.
    stats["suspect_zero_plateau"] = bool(((valid == 0).mean() > 0.02) and min_valid > 0)
    return stats

Verification: on a national terrain model the 1st and 99th percentiles should sit well inside the country’s real elevation range. A minimum of -9999 proves an undeclared nodata sentinel; a maximum of 32767 proves an integer overflow during a format conversion.

Step 5 — Compare adjacent tiles at their shared edge

Tiles are validated individually and consumed as a mosaic, so the seam is a first-class check.

# raster_qc/seams.py — compare the shared edge of two adjacent tiles
import numpy as np
import rasterio


def edge_mismatch(left_path: str, right_path: str, tolerance: float = 0.05) -> dict:
    """Mean absolute difference along the shared column of two horizontally adjacent tiles."""
    with rasterio.open(left_path) as left, rasterio.open(right_path) as right:
        if left.transform.a != right.transform.a:
            return {"comparable": False, "reason": "different pixel sizes"}
        if abs((left.bounds.right) - (right.bounds.left)) > 1e-6:
            return {"comparable": False, "reason": "tiles are not adjacent"}

        rows = min(left.height, right.height)
        left_edge = left.read(1, window=((0, rows), (left.width - 1, left.width)))
        right_edge = right.read(1, window=((0, rows), (0, 1)))

    a = left_edge.astype("float64").ravel()
    b = right_edge.astype("float64").ravel()
    diff = np.abs(a - b)
    return {
        "comparable": True,
        "mean_abs_diff": float(np.nanmean(diff)),
        "max_abs_diff": float(np.nanmax(diff)),
        "exceeds_tolerance": bool(np.nanmean(diff) > tolerance),
    }

Verification: neighbouring tiles from a single production run should differ by well under the vertical accuracy of the product. A systematic offset of a constant value across the whole seam is a datum or calibration difference between production batches, not noise.

Common Failure Modes & Fixes

Symptom Root cause Fix
Cliff of zero elevation at tile edges Nodata undeclared; consumers read the sentinel as a value Set the nodata value on write; re-issue the tile rather than patching consumers
Mosaic is blurry where two tiles meet Origins not on the product grid, forcing resampling on join Assert the grid alignment rule at ingestion; regenerate the offending tile with a snapped transform
Heights differ by 20–40 m between products Ellipsoidal versus orthometric heights Establish the vertical datum for each product; convert with a geoid model before comparison
void_pct near zero but visible holes Footprint polygon is smaller than the real survey extent Derive the footprint from the valid-data mask instead of using the tender boundary
Statistics differ between runs Reading a different overview level each time Pin the sampling level in the report; never compare statistics computed at different decimations
Reads are slow over object storage Raster is striped, not tiled, and has no overviews Convert to a tiled, overviewed COG; validate the structure on write
dtype changed after a conversion GDAL promoted or truncated the type during translation Pin -ot explicitly in the conversion, and assert the dtype rule after every format change

Performance & Scale Considerations

Header reads are constant time and effectively free — a full grid profile over ten thousand tiles takes minutes, and it answers the CRS, resolution, alignment, nodata and overview questions for the whole collection. Design the pipeline so this stage runs on every tile, every time, and only failures escalate to a pixel read.

Cost of each raster check per tileBar chart of milliseconds per tile for each raster check: header profile 4 milliseconds, CRS and datum assertion 4, statistics from an overview 180, void mapping inside the footprint 2,400 and a tile-seam comparison 620.header profile4 ms — run on everythingCRS + datum check4 ms — header onlystatistics (overview)180 ms — decimated readseam comparison620 ms — two tiles, one columnvoid mapping2.4 s — full-resolution windowed readOne 10,000 × 10,000 float32 tile, DEFLATE compressed, warm page cache. Header checks are three orders of magnitude cheaper than pixel work.
Order the pipeline by this chart: header checks on every tile, pixel work only on the ones that pass.

Pixel-level checks should always be windowed. Rasterio’s block_windows iterator yields the raster’s native tiling, which means each read touches exactly one compressed block and memory stays bounded by the block size rather than the raster size. A 40 GB elevation mosaic validates in constant memory this way; the same check written as src.read(1) fails on any machine you would want to pay for.

Statistical checks belong on the overviews. A level-8 overview is 1/64th the pixel count and answers plausibility questions with more than enough fidelity — provided the overview level is recorded with the numbers, because comparing statistics sampled at different levels produces drift that looks like data change.

Parallelism is per tile and needs no coordination, which puts raster QC firmly in the well-behaved half of the scaling model described in Batch Processing Large Spatial Datasets. The one exception is the seam check, which needs both neighbours — partition by tile-pair rather than by tile, or run it as a second pass over an index of adjacencies.

Integration with the Validation Pipeline

Raster checks slot into the same DAG as vector rules, at the ingestion and rule-evaluation stages defined in Validation Pipeline Architecture. The header profile runs at ingestion, where a wrong CRS or an unaligned grid should stop the tile before anything expensive happens. Void and statistical checks run in the rule stage, emitting the same result contract as every other rule — feature identifier (here, the tile identifier), rule identifier, severity, message and, where useful, the geometry of the defect as a footprint polygon of the void.

Because the results share a contract, they share the report, the dashboard and the alerting thresholds. A raster void rate belongs on the same quality scorecard as a vector overlap count, and both should feed the observability signals described in Observability and Lineage for Validation.

Frequently Asked Questions

Why do raster checks need different tooling from vector validation?

Vector validation asks questions about discrete features — is this ring closed, does this parcel overlap that one. Raster validation asks questions about a continuous grid: is the pixel grid aligned to the product origin, what fraction of the footprint is nodata, does the elevation range fall inside a physically plausible band, and do adjacent tiles meet without a seam. The predicates have no equivalent in the OGC simple features model, so the rules are expressed against the array and its affine transform instead of against geometry objects. The severity model, reporting contract and pipeline placement stay identical, which is what keeps raster and vector QC in one system.

Is nodata always a defect in an elevation model?

No. Nodata outside the data footprint is expected — a tile covering a coastline is mostly nodata over water by design. A void is different: nodata inside the footprint, where the sensor should have returned a value. The check that matters is not the raw nodata percentage but the void percentage measured inside the declared footprint, which is why the footprint has to be derived or supplied before the rule can be evaluated. Report both numbers; the raw percentage is useful context, but only the void percentage is an acceptance criterion.

How do I validate the vertical datum when the file does not declare one?

You cannot validate it from the file, and that is itself the finding. A GeoTIFF may carry a compound coordinate reference system naming both the horizontal and vertical reference, but many elevation products ship with only a horizontal code. Treat a missing vertical datum as a blocker for any product used in engineering, drainage or flood work, record the assumption explicitly in the metadata, and obtain the datum from the producer rather than inferring it from the value range. Inferring is tempting and unreliable: the ellipsoid–geoid separation is a smooth surface, so a plausible-looking elevation range proves nothing.

Should raster validation read every pixel?

Rarely. Header checks — transform, CRS, data type, nodata declaration, overview presence — answer most contract questions in milliseconds. Statistical checks can run on a decimated read or on the overviews. Reserve full-resolution reads for checks that genuinely need every pixel, such as void mapping inside the footprint, and run those windowed so memory stays bounded regardless of raster size. A good raster pipeline reads perhaps two percent of the pixels it validates.

How does raster QC interact with tiling schemes such as web mercator quadkeys?

A tiling scheme is a product grid with a fixed origin and a fixed resolution per zoom level, so the alignment rule applies unchanged — the grid origin and pixel size simply come from the scheme rather than from a local specification. The additional check worth adding is completeness: for a given extent and zoom level, the set of expected tile identifiers is computable, so a missing tile is a detectable omission rather than an empty area on a map. That check belongs with the completeness measures described in Aligning Spatial Metadata with ISO 19157.


Related

Back to Core Spatial QC Fundamentals & Standards