Harmonising Landsat and Sentinel-2 Reflectance
Pair bands by wavelength, put both on one grid, and apply a per-band linear adjustment to one sensor:
PAIRS = {"blue": ("SR_B2", "B02"), "green": ("SR_B3", "B03"), "red": ("SR_B4", "B04"),
"nir": ("SR_B5", "B8A"), "swir1": ("SR_B6", "B11"), "swir2": ("SR_B7", "B12")}
harmonised = {k: landsat[l] * slope[k] + intercept[k] for k, (l, _) in PAIRS.items()}
Band 8A rather than 8 for the near infrared is the detail that removes most of the residual. This page belongs to atmospheric correction and surface reflectance in Satellite Processing Workflows & Index Pipelines.
Why the Bands Need Pairing Carefully
The broad band 8 is the band most Sentinel-2 workflows use at 10 m, because 8A is 20 m. That is fine within Sentinel-2, but pairing it with Landsat’s narrow near-infrared band leaves a surface-dependent offset that no single linear coefficient fully removes. For harmonised 30 m products, use 8A.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
rioxarray |
>=0.15 |
Loading and aligning both sensors |
numpy |
>=1.23 |
Applying coefficients |
scikit-learn |
>=1.3 |
Fitting local coefficients robustly |
pip install "rioxarray>=0.15" "numpy>=1.23" "scikit-learn>=1.3"
Complete Working Example
import numpy as np
import xarray as xr
from sklearn.linear_model import HuberRegressor
PAIRS = {"blue": ("SR_B2", "B02"), "green": ("SR_B3", "B03"), "red": ("SR_B4", "B04"),
"nir": ("SR_B5", "B8A"), "swir1": ("SR_B6", "B11"), "swir2": ("SR_B7", "B12")}
def fit_coefficients(landsat: xr.Dataset, s2: xr.Dataset,
max_samples: int = 200_000, seed: int = 0) -> dict[str, tuple[float, float]]:
"""Per-band slope and intercept mapping Landsat onto Sentinel-2, from a same-day pair."""
rng = np.random.default_rng(seed)
coeffs = {}
for name, (l_band, s_band) in PAIRS.items():
x, y = landsat[l_band].values.ravel(), s2[s_band].values.ravel()
ok = np.isfinite(x) & np.isfinite(y) & (x > 0) & (y > 0)
idx = np.flatnonzero(ok)
idx = rng.choice(idx, min(max_samples, idx.size), replace=False)
m = HuberRegressor().fit(x[idx, None], y[idx])
coeffs[name] = (float(m.coef_[0]), float(m.intercept_))
return coeffs
def harmonise_landsat(landsat: xr.Dataset, coeffs: dict[str, tuple[float, float]]) -> xr.Dataset:
out = {}
for name, (l_band, _) in PAIRS.items():
slope, intercept = coeffs[name]
out[name] = (landsat[l_band] * slope + intercept).astype("float32")
ds = xr.Dataset(out)
ds.attrs.update(harmonised_to="sentinel-2", sensor="landsat")
return ds
Fitting on a same-day pair — two acquisitions within a day of each other over the same ground — is what isolates the sensor difference from surface change. Both inputs must already be on one grid; the alignment is covered in matching Landsat and Sentinel-2 grids. The robust regressor matters because residual cloud and shadow produce asymmetric outliers that pull an ordinary least-squares line.
Published or Local Coefficients
Published coefficients are a sound default and require nothing but the numbers. Local coefficients are worth fitting when the study area’s surfaces are unlike the global average — arid, snow-covered, or dominated by one crop — and when a few clear same-day pairs are available. Comparing the two is informative in itself: close agreement confirms the fit, while a large disagreement usually means the local pair was contaminated by cloud or surface change.
After Harmonisation
Harmonised reflectance lets the two sensors share one time series, which roughly doubles the observation density — valuable wherever cloud limits the usable dates. It does not make the sensors identical. Landsat’s 30 m pixels are mixtures of what Sentinel-2’s 10 m pixels see separately, so harmonised products are best built at 30 m; view angles differ between overpasses; and the acquisition times differ by tens of minutes, so fast-changing surfaces such as flood water can genuinely differ between a same-day pair.
Keeping a sensor identifier on every observation preserves the ability to check any of those effects later, and it lets a model or a composite treat the sensors differently if the analysis requires it. The model-side view of the same problem is covered in harmonising features across sensors for transfer.
Verification
import numpy as np
held_out = harmonise_landsat(landsat_pair2, coeffs)
for name, (_, s_band) in PAIRS.items():
before = np.nanmedian(np.abs(landsat_pair2[PAIRS[name][0]] - s2_pair2[s_band]))
after = np.nanmedian(np.abs(held_out[name] - s2_pair2[s_band]))
print(f"{name:<6} before {float(before):.4f} after {float(after):.4f}")
assert after <= before, f"harmonisation made {name} worse"
Validating on a pair the coefficients were not fitted from is the honest test, for the same reason a model is never evaluated on its training data. If residuals do not fall for a band, the pairing for that band is wrong or the two inputs are not properly aligned.
Common Errors
NIR residuals stay large after harmonisation
Sentinel-2 band 8 was paired with Landsat NIR. Use band 8A, resampled to the common 30 m grid alongside the other bands.
Coefficients vary wildly between pairs
The pairs include cloud, shadow or real surface change. Mask thoroughly and use pairs within a day of each other.
A sawtooth remains in the combined series
Coefficients were applied to the wrong sensor, or with slope and intercept swapped. Check that harmonised Landsat values move toward Sentinel-2 on a known pair.
Harmonised values fall outside 0–1
Coefficients fitted on scaled integers were applied to reflectance. Fit and apply on the same scale. Convert both sensors to physical reflectance first, then fit, then apply.
Frequently Asked Questions
Q: Which Sentinel-2 NIR band matches Landsat’s? Band 8A. Sentinel-2’s broad band 8 is much wider than Landsat’s near-infrared band and spans a water vapour feature, while the narrow band 8A is spectrally close to Landsat’s. Pairing band 8 with Landsat NIR leaves a systematic offset that coefficients only partly remove.
Q: Should Landsat be adjusted to Sentinel-2 or the reverse? Adjust whichever sensor contributes fewer observations to your series toward the one that contributes more, so most of the data is untouched. For recent periods that usually means adjusting Landsat to Sentinel-2.
Q: Is harmonisation enough to treat the sensors as one? For reflectance and indices at 30 metres, largely yes. Differences in spatial resolution, viewing geometry and revisit timing remain, so resample to a common resolution and record which sensor each observation came from.
Q: Are pre-harmonised products available? Harmonised Landsat and Sentinel-2 products exist and apply exactly these corrections, plus a common grid, at 30 metres. Where they cover your area and period, they save the work described here and are the natural choice for long, dense time series.
Q: Do the coefficients need refitting over time? Occasionally. Sensor calibration drifts slowly and providers issue collection updates, so coefficients fitted on pairs from one period may not suit another years later. Refit when a new collection or processing baseline is adopted, and keep the date range of the fitting pairs alongside the coefficients so their provenance is clear.
Related
- Atmospheric Correction and Surface Reflectance — the parent topic.
- Comparing L1C and L2A Sentinel-2 Products — making sure both sensors are at the same level first.
- Matching Landsat and Sentinel-2 Grids — the geometric half of harmonisation.
- Filling Gaps in NDVI Time Series with Interpolation — using the denser combined series.