Harmonising Features across Sensors for Transfer
Map bands by wavelength, adjust with a linear fit, and check the result on overlapping acquisitions:
import numpy as np
# Per-band slope and intercept that put Landsat 8 onto the Sentinel-2 scale
COEFF = {"red": (0.982, 0.0012), "nir": (1.021, -0.0031), "swir1": (0.996, 0.0008)}
def harmonise(band: np.ndarray, name: str) -> np.ndarray:
slope, intercept = COEFF[name]
return (band * slope + intercept).astype("float32")
Without this step a model trained on one sensor learns to detect the sensor. This page belongs to feature engineering for pixel-based models in Raster Machine Learning & Model Inference.
Equivalent Bands Are Not Identical Bands
Two sensors described as having a “near infrared band” can differ by tens of nanometres in centre wavelength and by a factor of two in width. The surface is the same; the integral over the bandpass is not.
Typical differences between Landsat 8 and Sentinel-2 surface reflectance are a few per cent per band — small in absolute terms and large relative to the decision boundaries a fitted model uses. An NDVI threshold tuned on one sensor can sit on the wrong side of a class boundary on the other.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
numpy |
>=1.23 |
Coefficient application and the regression |
scikit-learn |
>=1.3 |
Robust linear fits for locally derived coefficients |
xarray |
>=2023.12 |
Keeping band names attached through the adjustment |
rioxarray |
>=0.15 |
Loading matched pairs onto a common grid |
pip install "numpy>=1.23" "scikit-learn>=1.3" "xarray>=2023.12" "rioxarray>=0.15"
Complete Working Example
from dataclasses import dataclass
import numpy as np
import xarray as xr
from sklearn.linear_model import HuberRegressor
BAND_MAP = { # secondary sensor band -> reference sensor band
"SR_B4": "B04", # red
"SR_B5": "B08", # near infrared
"SR_B6": "B11", # shortwave infrared 1
"SR_B7": "B12", # shortwave infrared 2
}
@dataclass
class Harmoniser:
coefficients: dict[str, tuple[float, float]]
def apply(self, ds: xr.Dataset) -> xr.Dataset:
out = {}
for src_name, ref_name in BAND_MAP.items():
if src_name not in ds:
continue
slope, intercept = self.coefficients[ref_name]
out[ref_name] = (ds[src_name] * slope + intercept).astype("float32")
return xr.Dataset(out)
@classmethod
def fit(cls, secondary: xr.Dataset, reference: xr.Dataset,
*, max_samples: int = 200_000, seed: int = 0) -> "Harmoniser":
"""Fit per-band coefficients from a near-simultaneous pair."""
rng = np.random.default_rng(seed)
coeffs: dict[str, tuple[float, float]] = {}
for src_name, ref_name in BAND_MAP.items():
x = secondary[src_name].values.ravel()
y = reference[ref_name].values.ravel()
ok = np.isfinite(x) & np.isfinite(y) & (x > 0) & (y > 0)
idx = np.flatnonzero(ok)
if idx.size > max_samples:
idx = rng.choice(idx, max_samples, replace=False)
# Huber rather than least squares: residual cloud is an outlier problem
model = HuberRegressor(epsilon=1.35).fit(x[idx, None], y[idx])
coeffs[ref_name] = (float(model.coef_[0]), float(model.intercept_))
return cls(coeffs)
Two decisions in that fit matter. A robust regressor rather than ordinary least squares, because residual cloud and shadow are asymmetric outliers that drag a least-squares line noticeably. And fitting on a pair — two acquisitions within a day or two over the same ground — because anything longer apart mixes real surface change into the sensor difference.
Where Harmonisation Is Not Enough
The resolution column is the one that surprises people. A model trained at 10 m on Sentinel-2 and applied to 30 m Landsat sees pixels that are spectral mixtures rather than pure surfaces, so its decision boundaries — tuned on relatively pure pixels — sit in the wrong place regardless of how well the reflectance was harmonised. The honest options are to train at the coarser resolution, or to keep separate models. Resampling Landsat up to 10 m does not create information, though it does at least make the grids match; the mechanics are in matching Landsat and Sentinel-2 grids.
A pragmatic middle path is to build the feature stack entirely from indices and temporal statistics rather than raw reflectance. Indices cancel much of the gain difference, and seasonal percentiles are robust to the residual, so a model built that way transfers far better than one keyed to absolute band values — at the cost of discarding whatever absolute brightness was telling you.
Verification
Transfer is a claim that has to be tested, and the test is not the training score.
import numpy as np
# 1. Distribution overlap: harmonised secondary should match the reference
for band in ("B04", "B08", "B11"):
a = np.nanpercentile(reference[band], [10, 50, 90])
b = np.nanpercentile(harmonised[band], [10, 50, 90])
print(f"{band}: reference {a.round(4)} harmonised {b.round(4)}")
assert np.allclose(a, b, atol=0.02), f"{band} still offset after harmonisation"
# 2. Decision agreement on the overlap
agree = (model.predict(features_ref) == model.predict(features_harmonised)).mean()
print(f"class agreement across sensors: {agree:.1%}")
Anything below about 90% class agreement on a same-day overlap means the transfer is not working, and the cause is almost always either a band mapped to the wrong wavelength or a resolution mismatch that no coefficient can fix. Run this check on a held-out pair rather than on the pair the coefficients were fitted from, for the same reason any other model is not evaluated on its training data.
Common Errors
Coefficients near 1.0 but predictions still differ wildly
The bands were mapped by index rather than by wavelength — Landsat’s SR_B5 is near infrared while Sentinel-2’s band 5 is a red-edge band. Map by spectral position, always.
Harmonised values drift outside 0 to 1
The intercept was fitted on scaled integers and applied to reflectance, or vice versa. Fit and apply on the same scale, and assert the output range afterwards.
Transfer works in one region and fails in another
Globally published coefficients were fitted over different surfaces from yours. Refit locally on your own overlapping pairs, which is usually a few hours of work and worth it.
Frequently Asked Questions
Q: Why can a model not just be applied to another sensor directly? Because equivalent bands are not identical. Landsat 8’s near infrared is narrower than Sentinel-2’s and avoids a water vapour absorption feature, so the same surface returns a measurably different reflectance. A model with a threshold learned on one sensor applies that threshold to a shifted distribution on the other.
Q: Are indices more transferable than raw bands? Generally yes, because a ratio cancels multiplicative differences in gain and much of the illumination effect. It does not cancel bandpass differences, so an index still shifts between sensors — just less than the bands it is built from.
Q: How do I get harmonisation coefficients for my own data? Fit them on near-simultaneous acquisitions over your own study area: pair pixels from both sensors within a day or two, filter to cloud-free stable targets, and regress one on the other per band. Locally fitted coefficients beat published global ones when the landscape is unusual.
Related
- Feature Engineering for Pixel-Based Models — the stack this adjustment protects.
- Harmonising Landsat and Sentinel-2 Reflectance — the reflectance-level treatment with published coefficients.
- Matching Landsat and Sentinel-2 Grids — the geometric half of cross-sensor work.
- Designing Spatial Cross-Validation for Raster Models — how to measure transfer honestly.