Comparing Index Values Across Flights and Sensors

Two contractors survey the same field on the same morning, both with calibrated multispectral rigs, both producing NDVI. Their plot means differ by 0.06 — larger than the effect the trial is looking for. Neither is wrong.

An index is not a universal quantity like a temperature. It is a number produced by a specific pair of bands, at specific centre wavelengths, with specific bandwidths, through a specific processing chain. Change any of those and the number changes, even with perfect calibration on both sides.

This page sets out what can legitimately be compared with what, how large each difference is, and how to cross-calibrate two sensors when a comparison is required anyway. It follows the workflow discussion in calibrated vs uncalibrated multispectral workflows.

What makes two index values incomparable

Band centres. One sensor’s red band is centred at 668 nm, another’s at 650 nm. Vegetation reflectance changes steeply across that range, so the two measure genuinely different quantities. The resulting index difference is commonly 0.03 to 0.08 on a healthy canopy.

Bandwidths. A 10 nm band and a 40 nm band centred at the same wavelength integrate different portions of the spectrum. The wider band averages in more of the shoulder, which flattens the index.

Processing chain. A denominator floor, a soil mask, a different band pair for a nominally identical index. These are choices rather than hardware, and they are the easiest to reconcile — if they were recorded.

Sun angle and diffuse fraction. Canopy self-shadowing varies with sun elevation, and no calibration addresses it. Two flights three hours apart differ for reasons that are real and are not about the crop.

Typical index difference contributed by each source of incomparability Five bars showing the typical NDVI difference each factor contributes. Band centre differences of twenty nanometres contribute about zero point zero five. Bandwidth differences contribute about zero point zero two. Processing chain differences such as a different denominator floor or mask contribute about zero point zero three. A three-hour difference in sun angle contributes about zero point zero four. Calibration error on a well-run programme contributes about zero point zero one. A reference line marks a typical crop effect of zero point zero five, which several of the factors match or exceed. band centres 20 nm apart 0.05 bandwidths 0.02 processing chain 0.03 sun angle, three hours apart 0.04 calibration error, well run 0.01 a typical crop effect: 0.05 Calibration is the smallest term once it is being done properly. Which is why a well-calibrated cross-sensor comparison still fails without cross-calibration.

Figure 1 — Where the differences come from. The one people worry about is the smallest.

Minimal reproducible solution

Where two sensors must be compared, the reliable approach is empirical: fly both over the same ground on the same day, and fit the relationship.

import numpy as np


def cross_calibrate(values_a: np.ndarray, values_b: np.ndarray,
                    *, min_pairs: int = 30) -> dict:
    """Fit a linear relationship between two sensors' index values.

    Both sensors measured the same ground at the same time, so any
    relationship between them is instrumental. A linear fit is adequate over
    the index range a crop occupies, and its residual says whether it is.
    """
    ok = np.isfinite(values_a) & np.isfinite(values_b)
    a, b = values_a[ok], values_b[ok]
    if a.size < min_pairs:
        return {"note": f"only {a.size} paired observations; need {min_pairs}"}

    slope, intercept = np.polyfit(a, b, 1)
    predicted = slope * a + intercept
    resid = b - predicted

    return {"slope": float(slope), "intercept": float(intercept),
            "residual_sd": float(np.std(resid, ddof=1)),
            "r2": float(1 - np.var(resid) / np.var(b)),
            "range_fitted": (float(a.min()), float(a.max())),
            "usable": float(np.std(resid, ddof=1)) < 0.02}


def apply_cross_calibration(values: np.ndarray, fit: dict) -> np.ndarray:
    """Map one sensor's values onto the other's scale, within the fitted range."""
    lo, hi = fit["range_fitted"]
    out = fit["slope"] * values + fit["intercept"]
    return np.where((values >= lo) & (values <= hi), out, np.nan)

Masking outside the fitted range is the guard that stops the relationship being extrapolated. A fit established on a healthy canopy between 0.6 and 0.85 says nothing about bare soil at 0.1, and applying it there produces confident nonsense.

What must match before two flights' index values can be compared Four rows. The calibration method must be identical, because a panel-calibrated flight and an irradiance-corrected one carry different systematic offsets. The index formula and the bands feeding it must be identical, since two sensors' bands of the same name have different centres and widths. The masking must be identical, because a statistic over masked canopy and one over canopy plus soil are different quantities. And the sun elevation should be similar, because bidirectional effects change the measured reflectance of the same canopy by a few per cent between morning and midday. calibration method panel and irradiance corrections carry different systematic offsets index formula and bands same band name, different centre and width, different sensor the masking canopy alone and canopy plus soil are different quantities sun elevation bidirectional effects move the same canopy by a few per cent Any one of these differing makes a trend a comparison of methods rather than of fields.

Figure 3 — Four things to match, and the last is the one usually forgotten.

Edge-case matrix

Comparison Valid? Condition
Two plots, same flight Yes Always, calibrated or not
Two flights, same sensor, calibrated Yes Same processing chain and masks
Two flights, same sensor, uncalibrated No The light differed
Two sensors, both calibrated No Not without cross-calibration
Two sensors, cross-calibrated Yes Within the fitted range only
Against a published threshold Rarely Band centres must match the study
Same flight, two processing versions No Reprocess both with one version
Same sensor, firmware changed Caution Re-check with a permanent target

The firmware row is easy to overlook and has caught real programmes. A sensor update can change the reported gain convention or the default dark level, which shifts every value by a constant. A permanent target in every flight catches it in the month it happens.

Using a permanent target as the common reference

Where cross-calibration flights are impractical, a shared reference surface does much of the same work. If both sensors have observed the same unchanging surface, the offset between their readings of it is an estimate of the offset between their scales.

import numpy as np


def offset_from_shared_target(target_a: list[float],
                              target_b: list[float]) -> dict:
    """Scale offset between two sensors, from their readings of one surface.

    Weaker than a full cross-calibration — it gives an offset rather than a
    slope — but it needs no coordinated flight, and on a narrow index range
    an offset is often enough.
    """
    a = np.array(target_a, dtype=float)
    b = np.array(target_b, dtype=float)
    offset = float(np.median(b) - np.median(a))
    spread = float(np.hypot(np.std(a, ddof=1) if a.size > 1 else 0.0,
                            np.std(b, ddof=1) if b.size > 1 else 0.0))
    return {"offset": offset, "uncertainty": spread,
            "usable": spread < 0.02,
            "note": ("apply as a constant offset over a narrow index range"
                     if spread < 0.02 else
                     "target readings are too variable to define an offset")}

A single concrete pad photographed by both sensors, several times each, gives an offset with an uncertainty attached — which is a far better basis for a comparison than assuming the two agree.

Verification snippet

import numpy as np


def comparison_is_defensible(meta_a: dict, meta_b: dict,
                             *, wavelength_tolerance_nm: float = 5.0) -> dict:
    """Mechanical check on whether two index values may be compared at all."""
    problems = []
    for band in ("red", "nir"):
        wa = meta_a["wavelengths"].get(band)
        wb = meta_b["wavelengths"].get(band)
        if wa is None or wb is None:
            problems.append(f"missing {band} wavelength in one product")
        elif abs(wa - wb) > wavelength_tolerance_nm:
            problems.append(f"{band} centres differ by {abs(wa - wb):.0f} nm")

    for key in ("index_version", "min_sum", "mask_policy"):
        if meta_a.get(key) != meta_b.get(key):
            problems.append(f"{key} differs: {meta_a.get(key)} vs {meta_b.get(key)}")

    if meta_a.get("calibrated") != meta_b.get("calibrated"):
        problems.append("one product is calibrated and the other is not")

    return {"comparable": not problems, "problems": problems,
            "remedy": ("cross-calibrate, or reprocess both identically"
                       if problems else None)}

Running this before any two numbers are put side by side turns a judgement into a check, and the metadata it reads is exactly what the writers in this section have been recording all along.

Cross-calibration fit between two sensors flown over the same ground A scatter of paired index values from two sensors over the same plots on the same day. The points lie close to a line with a slope of zero point ninety-one and an intercept of zero point zero six, not on the one-to-one line, showing a consistent instrumental difference. The residual scatter about the fit is about zero point zero one. Shaded regions at each end mark the range outside which the fit was not established and must not be extrapolated. sensor A index sensor B index not fitted not fitted 1 : 1 B = 0.91 A + 0.06 Two calibrated sensors, same ground, same hour — and a consistent instrumental difference.

Figure 2 — What a cross-calibration looks like, and why the shaded regions matter.

Comparing shapes instead of values

Where values genuinely cannot be reconciled, a great deal of what a client wants can still be delivered by comparing patterns rather than magnitudes.

The spatial structure within a field — which corner is poorest, where the boundary between two soil types runs, how variable a plot is relative to its neighbours — survives most of the differences described above, because they are largely constant offsets and gains that do not reorder anything. Normalising each flight’s index to its own distribution makes this explicit.

import numpy as np


def normalise_within_flight(index: np.ndarray, mask: np.ndarray) -> np.ndarray:
    """Express each pixel as a position within its own flight's distribution.

    A z-score or a percentile rank removes any constant offset and gain,
    which is exactly what differs between sensors and between uncalibrated
    flights. What remains is the spatial pattern, which is comparable.
    """
    values = index[mask & np.isfinite(index)]
    if values.size < 500:
        return np.full_like(index, np.nan)
    med = np.median(values)
    spread = np.median(np.abs(values - med)) * 1.4826
    out = (index - med) / max(spread, 1e-9)
    return np.where(mask & np.isfinite(index), out, np.nan)

A normalised product answers “where is the problem in this field” across any pair of flights, and does not answer “has the field improved”. Being explicit about which of those the client is asking usually resolves the whole comparability question, because most operational questions are the first kind.

When to escalate

  • A comparison is required and no cross-calibration is possible. State the limitation numerically rather than qualitatively: “these products differ by up to 0.06 for instrumental reasons” is more useful than “results may not be directly comparable”.
  • The cross-calibration residual is large. The relationship is not linear over that range, or one of the products has a problem. Check both against a permanent target before fitting anything.
  • A client merges data from several contractors. Insist on a shared reference target flown by all of them. It is the only practical way to make a multi-contractor archive coherent.

Calibrated vs Uncalibrated Multispectral Workflows