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.
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.
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.
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.