Fixing Missing or Mismatched Band Metadata

A reflectance stack arrives from a subcontractor with five bands and no descriptions. The pipeline needs red and near-infrared. Band 3 and band 5 look about right, and an NDVI computed from them produces a plausible map of the field.

It may well be an NDVI. It may also be a normalised difference between red edge and near-infrared, which is NDRE and has different values, a different saturation point and different agronomic meaning. Nothing in the raster distinguishes them, and the map looks the same either way.

This page covers recovering band identity from the data when the metadata is absent, detecting a mismatch when it is present but wrong, and writing it back so the question is asked once. It is part of the diagnostic sequence in troubleshooting multispectral and thermal failures.

Why band identity is not obvious from the data

The temptation is to identify bands by their statistics — near-infrared is the brightest over vegetation, red the darkest — and that works until it does not.

Over a field of healthy crop the ordering is reliable: near-infrared is far brighter than red edge, which is brighter than green, which is brighter than red and blue. Over bare soil the ordering compresses and can invert between adjacent bands. Over a site that is mostly hardstanding, all five bands are similar and the ordering is close to arbitrary.

Correlation between bands is more robust. Adjacent bands are strongly correlated because the underlying reflectance spectrum is smooth, so the correlation matrix has a characteristic band structure: each band correlates most with its neighbours in wavelength. That structure survives a wide range of scenes and recovers the ordering even when it cannot pin an absolute identity.

Correlation structure of a five-band stack recovers the wavelength ordering A five by five correlation matrix between the bands of an unlabelled stack. The values fall away from the diagonal in a regular pattern: adjacent bands correlate above zero point nine, bands two apart around zero point seven, and the extremes around zero point four. That monotonic decay identifies the wavelength ordering of the bands even when their absolute identity is unknown. A note adds that the red edge to near-infrared step breaks the pattern slightly, which is itself a useful marker. b1b2b3 b4b5 b1b2b3 b4b5 Correlation decays monotonically from the diagonal — the bands are in wavelength order. The step between red edge and near-infrared breaks the pattern slightly, which marks it.

Figure 1 — Structure that survives most scenes, and recovers the ordering when the labels are gone.

Minimal reproducible solution

import numpy as np
import rasterio


CANONICAL = [("blue", 475.0), ("green", 560.0), ("red", 668.0),
             ("red_edge", 717.0), ("nir", 840.0)]


def infer_band_order(stack_path: str, *, sample: int = 200_000) -> dict:
    """Recover the wavelength ordering of an unlabelled stack.

    The correlation matrix of a smooth spectrum decays monotonically from the
    diagonal, so the permutation that makes it do so is the wavelength order.
    With five bands there are only 120 permutations, so an exhaustive search
    is cheaper and more reliable than any heuristic.
    """
    import itertools

    with rasterio.open(stack_path) as src:
        n = src.count
        arr = src.read(masked=True).filled(np.nan).reshape(n, -1)

    good = np.isfinite(arr).all(axis=0)
    cols = np.flatnonzero(good)
    if cols.size > sample:
        cols = np.random.default_rng(0).choice(cols, size=sample, replace=False)
    corr = np.corrcoef(arr[:, cols])

    def monotonic_score(order):
        c = corr[np.ix_(order, order)]
        score = 0.0
        for i in range(n):
            for j in range(i + 2, n):
                score += float(c[i, j - 1] - c[i, j])   # should decay with distance
        return score

    best = max(itertools.permutations(range(n)), key=monotonic_score)
    # The ordering is recovered up to reversal; near-infrared is the brightest
    # band over vegetation, which breaks the tie.
    means = np.nanmean(arr, axis=1)
    if means[best[0]] > means[best[-1]]:
        best = tuple(reversed(best))

    return {"order_indices": [int(i) + 1 for i in best],
            "suggested_names": [name for name, _ in CANONICAL][:n],
            "confidence": float(monotonic_score(best)),
            "note": "ordering inferred from correlation structure; verify before use"}

The reversal tie-break uses the one statistic that is reliable across almost every scene: over any vegetated area, near-infrared is the brightest band by a wide margin. Over a site with no vegetation at all the inference should not be trusted, and the function’s note says so.

Detecting a mismatch when metadata is present

Wrong metadata is worse than absent metadata, because nothing prompts a check. The test is whether the declared identities are consistent with the data.

import numpy as np
import rasterio


def validate_band_labels(stack_path: str) -> dict:
    """Are the declared band names consistent with what the pixels show?"""
    with rasterio.open(stack_path) as src:
        names = [d or "" for d in src.descriptions]
        arr = src.read(masked=True).filled(np.nan).reshape(src.count, -1)

    means = {n: float(np.nanmean(arr[i])) for i, n in enumerate(names) if n}
    problems = []

    if "nir" in means and "red" in means and means["nir"] <= means["red"]:
        problems.append("the band labelled nir is not brighter than the one labelled "
                        "red — the labels are probably wrong")
    if "red_edge" in means and "nir" in means and means["red_edge"] > means["nir"]:
        problems.append("red_edge is brighter than nir, which does not happen over "
                        "vegetation")
    if "blue" in means and "green" in means and means["blue"] > means["green"] * 1.3:
        problems.append("blue is much brighter than green — check the ordering")

    return {"means": means, "problems": problems,
            "verdict": "labels consistent with the data" if not problems else
                       "labels disagree with the pixel statistics"}
Three metadata failures and what each produces downstream Three rows. Bands in an unexpected order, with no descriptions to detect it, produces an index computed from the wrong two bands, which yields a plausible raster with an inverted or meaningless pattern. A missing nodata declaration produces statistics that silently include the border, biasing every plot mean toward whatever the fill value happens to be. A missing or wrong scale factor produces reflectance values in the thousands or in the thousandths, which is obvious when someone looks and invisible to any automated threshold expressed in the wrong units. unexpected band order an index from the wrong two bands — plausible raster, meaningless pattern missing nodata statistics include the border, biasing every plot toward the fill value missing or wrong scale reflectance in the thousands — invisible to a threshold in wrong units All three pass every structural check the file format can perform.

Figure 3 — Three failures no format validator will catch.

Edge-case matrix

Situation Handling
No descriptions at all Infer the ordering, verify, then write them
Descriptions present but wrong Validate against statistics; trust the data
Scene with no vegetation Inference unreliable; obtain the sensor’s spec
Fewer than five bands Inference still works; fewer permutations
Duplicate descriptions A writer error; re-derive and re-label
Wavelength tags but no names Sort by wavelength and name canonically
Bands from two sensors merged Ordering is not monotonic; do not infer
A thermal band in the stack Excluded from the correlation structure

The merged-sensor row is the one where inference must be refused rather than attempted. A stack assembled from two rigs has no single smooth spectrum underlying it, so the correlation structure the method relies on is not there, and a confident wrong answer is the likely output.

Verification snippet

import numpy as np


def verify_with_known_surface(bands: dict[str, np.ndarray],
                              vegetation_mask: np.ndarray) -> dict:
    """Confirm band identity against the known spectrum of healthy vegetation.

    A vegetated surface has a characteristic shape: low in blue and red,
    moderate in green, rising steeply through red edge to a high plateau in
    near-infrared. Any labelling that does not reproduce it is wrong.
    """
    profile = {name: float(np.nanmedian(arr[vegetation_mask & np.isfinite(arr)]))
               for name, arr in bands.items()}
    expected_order = ["blue", "red", "green", "red_edge", "nir"]
    present = [b for b in expected_order if b in profile]
    values = [profile[b] for b in present]

    ascending = all(values[i] <= values[i + 1] * 1.15 for i in range(len(values) - 1))
    return {"profile": profile, "expected_order": present,
            "matches_vegetation_spectrum": ascending,
            "note": ("labels reproduce the vegetation spectrum" if ascending else
                     "the labelled bands do not form a vegetation spectrum")}

Checking against the vegetation spectrum is the strongest available verification because it uses physics rather than convention: the shape is a property of chlorophyll and leaf structure, not of any sensor’s naming.

The vegetation spectrum as a check on band labelling Median reflectance of healthy vegetation across five bands, plotted in wavelength order. Blue is low at about zero point zero four, green rises to zero point zero nine, red falls back to zero point zero four, red edge rises steeply to zero point two seven, and near-infrared reaches a plateau at zero point four eight. The green peak and the steep red edge rise are marked as the two features that make the shape unmistakable. A note states that any labelling that does not reproduce this shape is wrong. bluegreenred red edgeNIR band, in wavelength order reflectance green peak red edge rise A property of chlorophyll and leaf structure, not of any sensor's naming convention.

Figure 2 — The shape every correct labelling reproduces.

Writing the metadata back

import rasterio


def relabel_stack(path: str, names: list[str], wavelengths: dict[str, float]) -> None:
    """Write band descriptions and wavelength tags into an existing stack in place."""
    with rasterio.open(path, "r+") as dst:
        if len(names) != dst.count:
            raise ValueError(f"{len(names)} names for {dst.count} bands")
        for i, name in enumerate(names, start=1):
            dst.set_band_description(i, name)
            dst.update_tags(i, WAVELENGTH_NM=f"{wavelengths[name]:.1f}",
                            UNITS="reflectance")
        dst.update_tags(BAND_ORDER=",".join(names),
                        BAND_LABELS_SOURCE="inferred and verified against "
                                           "the vegetation spectrum")

Recording how the labels were established matters as much as the labels themselves. A stack labelled from the sensor’s documentation and one labelled by inference deserve different levels of trust, and the tag says which this is.

When to escalate

  • The inference and the declared labels disagree. Get the sensor’s specification before overriding either. The declared labels may be right and the scene unusual.
  • The stack contains bands from more than one sensor. Do not infer; the method’s assumption does not hold. Split the stack and label each source separately.
  • No vegetation anywhere in the scene. The verification cannot run. Use a known surface of any kind whose spectrum is documented, or obtain the specification.

Troubleshooting Multispectral and Thermal Failures