Histogram Matching Across Scenes
To make two adjacent scenes look continuous, fit a gain and offset from the pixels they share and apply it to one of them:
import numpy as np
# a and b are masked arrays over the overlap, cloud already excluded
gain = float(a.std() / b.std())
offset = float(a.mean() - gain * b.mean())
b_matched = b * gain + offset # b now shares a's brightness and contrast
This is the radiometric half of the problem in Seamless Mosaicking and Edge Blending; feathering is the geometric half.
Why This Arises in Remote Sensing Workflows
Two scenes acquired on different days differ before any surface change is involved. The sun is at a different elevation, so the same slope reflects differently. The atmosphere holds different aerosol, so contrast differs. The sensor may be a different platform in the same constellation with slightly different calibration. Put those two scenes side by side and the join is visible as a brightness step, even when both are correct.
Feathering does not fix this. Blending across the overlap turns a step into a ramp, which is less obvious but still visible, and it is worse for analysis because the ramp is now a spatial gradient in the data. The fix has to be radiometric: bring the two scenes onto a common brightness scale first, then blend the residual.
The critical constraint is that the correction must be estimated from ground the two scenes share. Statistics computed over each scene’s full extent describe different landscapes — one may contain a city and the other a lake — so matching them makes the difference worse, not better.
Environment & Setup
| Package | Version | Why |
|---|---|---|
rasterio |
≥1.3.0 | Reading the overlap windows |
numpy |
≥1.23 | Fitting and applying the correction |
scikit-image |
≥0.22 | Optional: full histogram matching |
shapely |
≥2.0 | Computing the overlap geometry |
pip install "rasterio>=1.3.0" "numpy>=1.23" "scikit-image>=0.22" "shapely>=2.0"
Complete Working Example
This function computes the overlap between two scenes, fits a per-band gain and offset on the shared valid pixels, and applies it — returning the coefficients so they can be recorded.
import numpy as np
import rasterio
from rasterio.windows import from_bounds
from shapely.geometry import box
def overlap_windows(src_a, src_b):
"""Matching windows over the shared extent of two datasets, or None."""
inter = box(*src_a.bounds).intersection(box(*src_b.bounds))
if inter.is_empty or inter.area == 0:
return None, None
b = inter.bounds
return (from_bounds(*b, transform=src_a.transform),
from_bounds(*b, transform=src_b.transform))
def fit_gain_offset(
path_reference: str,
path_adjust: str,
*,
band: int = 1,
valid_range: tuple[float, float] = (0.0, 1.0),
sample: int = 512,
) -> dict:
"""Fit a linear radiometric correction for `path_adjust` onto `path_reference`."""
with rasterio.open(path_reference) as ref, rasterio.open(path_adjust) as adj:
win_ref, win_adj = overlap_windows(ref, adj)
if win_ref is None:
raise ValueError("scenes do not overlap — match to a common reference instead")
a = ref.read(band, window=win_ref, out_shape=(1, sample, sample), masked=True)[0] \
if False else ref.read(band, window=win_ref, out_shape=(sample, sample), masked=True)
b = adj.read(band, window=win_adj, out_shape=(sample, sample), masked=True)
good = (~a.mask) & (~b.mask)
good &= (a >= valid_range[0]) & (a <= valid_range[1])
good &= (b >= valid_range[0]) & (b <= valid_range[1])
if good.sum() < 1000:
raise ValueError(f"only {int(good.sum())} shared valid pixels — correction unreliable")
av, bv = np.asarray(a[good], dtype="float64"), np.asarray(b[good], dtype="float64")
gain = float(av.std() / bv.std())
offset = float(av.mean() - gain * bv.mean())
residual_before = float(np.mean(av - bv))
residual_after = float(np.mean(av - (bv * gain + offset)))
return {"band": band, "gain": round(gain, 5), "offset": round(offset, 6),
"n_pixels": int(good.sum()),
"mean_residual_before": round(residual_before, 5),
"mean_residual_after": round(residual_after, 6)}
def apply_correction(path_in: str, path_out: str, coeffs: dict[int, dict]) -> None:
"""Apply per-band gain and offset, recording them in the output's tags."""
with rasterio.open(path_in) as src:
profile = src.profile | {"dtype": "float32", "driver": "COG", "compress": "DEFLATE"}
with rasterio.open(path_out, "w", **profile) as dst:
for band in range(1, src.count + 1):
arr = src.read(band, masked=True).astype("float32")
c = coeffs.get(band)
if c:
arr = arr * np.float32(c["gain"]) + np.float32(c["offset"])
dst.write(arr.filled(src.nodata if src.nodata is not None else 0), band)
if c:
dst.update_tags(band, gain=c["gain"], offset=c["offset"])
if __name__ == "__main__":
coeffs = {b: fit_gain_offset("scene_a.tif", "scene_b.tif", band=b) for b in (1, 2, 3, 4)}
for b, c in coeffs.items():
print(b, c["gain"], c["offset"], "residual:", c["mean_residual_before"], "→",
c["mean_residual_after"])
apply_correction("scene_b.tif", "scene_b_matched.tif", coeffs)
The residual before and after is the check that matters: a correction that does not reduce the mean difference over the overlap has not worked, usually because the overlap contains cloud or water that dominates the statistics.
Variant Patterns
1. Full histogram matching, and when it is too much
from skimage.exposure import match_histograms
b_matched = match_histograms(b_array, a_array) # reshapes B's distribution onto A's
This forces B’s histogram to equal A’s exactly, which removes the seam completely — and also removes any genuine difference in land-cover composition between the two scenes. It is appropriate for visual products where continuity is the goal, and inappropriate for analytical products where the values must remain measurements.
2. Matching a chain of scenes
3. Matching without an overlap
When scenes do not touch, fit on pseudo-invariant features instead: surfaces whose reflectance should be identical in both, such as deep water, bare rock, or paved areas. Extract their statistics from each scene independently and fit the correction between those, rather than between the full distributions.
pif = (ndvi < 0.15) & (ndwi < -0.1) # bare, non-water, non-vegetated
gain = float(a_values[pif_a].std() / b_values[pif_b].std())
The assumption is that the two scenes contain comparable amounts of such surface, which is worth checking before trusting the result.
Verifying the Correction
Two checks are enough, and both operate on the overlap.
The mean residual over shared pixels should fall by an order of magnitude after correction. If it barely moves, the fit was contaminated — usually by cloud in the overlap, or by water, whose near-zero reflectance in the near-infrared skews the standard deviation.
A transect across the join should be flat. Extract a line of pixels crossing the seam in the corrected mosaic and plot it: any remaining step is the residual the blending step will have to hide, and its size tells you how wide the feather needs to be, as discussed in Removing Seams in Multi-Scene Mosaics with Feathering.
It is also worth checking what the correction did outside the overlap. A gain far from 1.0 — beyond roughly 0.8 to 1.25 — usually indicates a bad fit rather than a genuinely different scene, and applying it will distort the whole raster to fix a strip.
Common Errors
The gain comes out enormous or negative
Water or cloud dominates the overlap sample. Mask them out and restrict the fit to a plausible reflectance range before fitting.
The join looks worse after matching
The correction was fitted on full-scene statistics rather than on the overlap, so it aligned two different landscapes. Always fit on shared pixels.
Indices change after matching
Expected, and a reason for care: a gain and offset applied to reflectance changes normalised indices unless the same correction is applied to both bands. Apply per-band corrections consistently, and prefer to compute indices before matching if the indices are the deliverable.
Frequently Asked Questions
Q: Linear gain-offset or full histogram matching? Linear first. A gain and offset fitted on the overlap fixes most illumination and atmospheric differences while preserving the shape of the distribution. Full histogram matching also reshapes the distribution, which can erase real land-cover differences between scenes.
Q: What if the scenes do not overlap? Match both to a common reference instead — a coarse-resolution product, or an earlier mosaic — using pseudo-invariant features such as bare soil and deep water that should look the same in both.
Q: Should I match before or after masking? After. Cloud and shadow pixels in the overlap will dominate the fitted statistics and produce a correction that compensates for weather rather than for radiometry.
Related
- Seamless Mosaicking and Edge Blending — the parent topic and the four causes of a visible seam.
- Merging Tiles with rasterio.merge — the assembly step that follows this correction.
- Removing Seams in Multi-Scene Mosaics with Feathering — hiding whatever residual remains.
- Cloud and Shadow Masking Strategies — why the overlap must be masked before fitting.