How to Build a Cloud-Free Composite from Many Satellite Scenes
Problem statement
Over a cloudy region, no single satellite date shows you the ground. Across 120 Sentinel-2 scenes covering a 10 km window in Snowdonia between June and December 2025:
median usable fraction per scene 0.4%
best single scene 100.0%
scenes at least 50% usable 8 of 120
One scene in fifteen was half usable. A composite is not an optimisation for these conditions β it is the only way to get a complete picture at all.
The good news is on the other axis. Stacked, those same 120 scenes gave every pixel between 4 and 30 clear observations, median 14. There is plenty of data; it is just never all on the same day.
Quick answer
Mask, then take a per-pixel median over time:
import numpy as np
# stack: (time, y, x) reflectance arrays, already masked to NaN
red = np.stack(red_scenes)
nir = np.stack(nir_scenes)
with np.errstate(all="ignore"):
red_c = np.nanmedian(red, axis=0)
nir_c = np.nanmedian(nir, axis=0)
total = nir_c + red_c
ndvi = np.where(np.abs(total) < 1e-6, np.nan, (nir_c - red_c) / total)
n_obs = np.isfinite(red).sum(axis=0)
print(f"observations per pixel: min {n_obs.min()}, "
f"median {np.median(n_obs):.0f}, max {n_obs.max()}")
observations per pixel: min 4, median 14, max 30
The masking is what makes this work. Measured on the same stack:
| composite | NDVI median |
|---|---|
| mean, unmasked | 0.063 |
| median, unmasked | 0.042 |
| mean, masked | 0.506 |
| median, masked | 0.658 |
Step-by-step solution
1. Select scenes on clarity over your area, not on scene cloud cover
Read only the 20 m classification band for each candidate β a few kilobytes each β and rank on the fraction of your window that is usable:
clear_fraction = np.isin(scl, [4, 5, 6, 7, 11]).mean()
Scene-level eo:cloud_cover correlated only β0.654 with this over the 120-scene stack, and the second-clearest scene over the window advertised 55.9% cloud.
Do not throw away partly cloudy scenes. A scene that is 30% usable over your window still contributes those pixels, and in a stack that is exactly what you need.
2. Put every scene on one grid
Every scene must share a CRS, shape and transform before stacking. Read each band with out_shape against a single reference, and assert it:
assert all(m["transform"] == reference["transform"] for m in metas)
A scene from a neighbouring UTM zone will have the same date and a different CRS. Stacking it silently produces a smear.
3. Mask every scene before stacking
usable = ~np.isin(scl, (0, 1, 3, 8, 9, 10))
red = np.where(usable, red, np.nan)
This is the step that produces the 0.6 NDVI difference in the table above. Without it, the typical observation of a pixel is cloud, and a median over all observations returns cloud.
4. Reduce over time, and prefer the median
mean, masked NDVI median 0.506 p5 0.150 p95 0.719
median, masked NDVI median 0.658 p5 0.239 p95 0.816
The masked mean sits 0.15 below the masked median. Both are computed over the same clear observations, so the gap is residual contamination β cloud edges and unflagged thin cirrus that survived the mask. The median ignores them; the mean averages them in.
Use the mean only when you have already established that the mask is essentially perfect, which is rarely.
5. Return the observation count with the composite
n_obs = np.isfinite(red).sum(axis=0)
composite = np.where(n_obs >= 3, composite, np.nan)
A pixel with 4 observations and a pixel with 30 look identical in the output raster. They are not the same measurement, and any user of the composite needs to know which they have.
6. Understand what a composite is not
The best single scene here gave an NDVI median of 0.761; the June-to-December composite gave 0.658. Neither is wrong β they answer different questions. The single scene is one moment in July at peak growth; the composite is a summary across a season that includes senescence.
A composite has no date. It is a statistic over a period, and it must be labelled with that period.
Code examples
Example 1 β a composite with the bookkeeping included
import numpy as np
def composite(scenes, method="median", min_obs=3):
"""Per-pixel reduction over time with the observation count returned.
scenes: list of dicts of masked float arrays, all on the same grid.
"""
bands = sorted(set().union(*(s.keys() for s in scenes)))
stacks = {b: np.stack([s[b] for s in scenes]) for b in bands}
reference = stacks[bands[0]]
n_obs = np.isfinite(reference).sum(axis=0)
reducer = {"median": np.nanmedian, "mean": np.nanmean,
"max": np.nanmax, "min": np.nanmin}[method]
out = {}
with np.errstate(all="ignore"):
for band, stack in stacks.items():
values = reducer(stack, axis=0)
out[band] = np.where(n_obs >= min_obs, values, np.nan)
meta = {
"method": method, "n_scenes": len(scenes), "min_obs": min_obs,
"obs_min": int(n_obs.min()), "obs_median": float(np.median(n_obs)),
"obs_max": int(n_obs.max()),
"pixels_below_min_obs": int((n_obs < min_obs).sum()),
}
print(f" {method} of {len(scenes)} scenes, "
f"observations {meta['obs_min']}-{meta['obs_max']} "
f"(median {meta['obs_median']:.0f}), "
f"{meta['pixels_below_min_obs']:,} pixels below the minimum")
return out, n_obs, meta
median of 120 scenes, observations 4-30 (median 14), 0 pixels below the minimum
Example 2 β a "best pixel" composite instead of a statistic
import numpy as np
def best_pixel_composite(stacks, score, bands):
"""Pick one whole observation per pixel rather than mixing them.
score: (time, y, x) array, higher is better β e.g. NDVI, or negative
cloud distance. Keeps the bands spectrally consistent per pixel.
"""
score = np.where(np.isfinite(score), score, -np.inf)
best = np.argmax(score, axis=0) # (y, x)
valid = np.isfinite(score).any(axis=0)
ys, xs = np.indices(best.shape)
out = {b: np.where(valid, stacks[b][best, ys, xs], np.nan) for b in bands}
chosen, counts = np.unique(best[valid], return_counts=True)
print(f" {len(chosen)} of {score.shape[0]} scenes contribute pixels")
print(f" largest single contribution: {counts.max() / valid.sum():.1%}")
return out, best
A per-band median mixes bands from different dates in one pixel, which can produce a spectrum no real surface ever had β a red from July and a near-infrared from October. A best-pixel composite keeps each pixel spectrally coherent, at the cost of visible seams where the source date changes.
Choose per-band median for index maps and best-pixel for anything that feeds a classifier trained on real spectra.
Example 3 β a seasonal composite, which is usually what you meant
import numpy as np
from collections import defaultdict
def seasonal_composites(scenes, dates, season_of, min_obs=3):
"""One composite per season rather than one for the whole archive."""
groups = defaultdict(list)
for scene, date in zip(scenes, dates):
groups[season_of(date)].append(scene)
out = {}
for season, members in sorted(groups.items()):
stack = np.stack([m["ndvi"] for m in members])
n_obs = np.isfinite(stack).sum(axis=0)
with np.errstate(all="ignore"):
values = np.nanmedian(stack, axis=0)
out[season] = np.where(n_obs >= min_obs, values, np.nan)
print(f" {season}: {len(members):3d} scenes, "
f"median NDVI {np.nanmedian(out[season]):.3f}, "
f"{(n_obs < min_obs).mean():.1%} below min_obs")
return out
summer: 48 scenes, median NDVI 0.734, 7.4% below min_obs
autumn: 54 scenes, median NDVI 0.535, 0.5% below min_obs
winter: 18 scenes, median NDVI 0.420, 59.5% below min_obs
Splitting by season recovers the signal that a six-month composite averages away: 0.734 in summer against 0.420 in winter, where the single all-period composite reported 0.658.
It also shows the trade-off immediately. Winter has 18 scenes and a median of two clear looks per pixel, so 59.5% of pixels fall below a three-observation minimum. The winter number is real for the 40% of the map that has support and absent for the rest β which is exactly what you want a composite to admit.
Explanation
Why the median beats the mean here
Both were computed over the same masked observations, and they differ by 0.15 NDVI. That gap is contamination: cloud-edge and thin-cirrus pixels that the mask did not catch.
Those survivors are not symmetric. Cloud is bright in both red and near-infrared, and brighter in red relative to a vegetated surface, so contaminated pixels pull NDVI down. A mean incorporates them proportionally; a median ignores anything that is not near the middle.
Given that no cloud mask is perfect, the median is the safer default. The cost is that it discards information: with only 4 clear observations, a median is a much noisier estimator than a mean would be.
Why a composite has no date
This is the most commonly misused property of composites. The output raster looks exactly like a scene β same grid, same bands, same file format β and it is not one. Each pixel is a statistic over a different subset of dates, and neighbouring pixels can be summarising different months.
Consequences worth stating in the metadata:
- It cannot date an event. A change visible in a composite happened somewhere in the window.
- Neighbouring pixels are not synchronous. A boundary in the output can be a boundary in observation dates rather than on the ground.
- Two composites over different windows are not directly comparable unless both windows are stated and similar.
Why every pixel got at least four looks
With a median usable fraction of 0.4% per scene, you might expect large permanently hidden areas. The measurement says otherwise: minimum 4 observations, no unobserved pixel.
Cloud moves. Over six months, the probability that a given pixel is under cloud on every one of 120 overpasses is small even when each individual overpass is mostly cloudy. That is the whole statistical argument for compositing, and it is why "how cloudy is this region" is a much less useful question than "how many overpasses does this region get".
Why to keep the observation count
The composite and the count are one product. A user who wants a conservative map masks the composite where n_obs is low; a user who wants full coverage keeps it and knows which parts are thin.
Distributing the composite without the count discards the only information about its own reliability.
Edge cases or notes
np.nanmedianover an all-NaN column warns and returns NaN. Wrap stack reductions innp.errstate(all="ignore")and use the observation count to mask.- Memory grows with the stack. 120 scenes of one 509 Γ 543 band as
float32is 133 MB; at 10 m over a full tile it is hundreds of gigabytes. Composite in tiles or chunk with dask. - A per-band median mixes dates within a pixel. Fine for indices, wrong for anything needing a real spectrum.
- Composite the bands, then compute the index β not the other way round β if you want the output to be a plausible spectrum.
- Seasonality dominates long windows. A June-to-December composite is not "the landscape"; it is a six-month average that includes senescence.
- Sensor differences matter. Sentinel-2A, 2B and 2C have slightly different spectral responses; mixing them in a composite adds a small step.
- Do not fill remaining gaps by interpolation without labelling it. An interpolated composite pixel is a model output, not an observation.
- Record the mask rule, the window and the reducer with the output. Without them the composite is uninterpretable.
Internal links
- How to mask clouds in Sentinel-2 imagery with Python β the step that makes this work
- Cloud masking explained β why the median scene is 0.4% usable
- My composite has holes, stripes or grey patches β diagnosing a composite that went wrong
- How to calculate NDVI from Sentinel-2 in Python β the index this composite feeds
- How to extract a vegetation index time series for a polygon β the alternative to compositing away time
- How to load Sentinel-2 bands into Python as an analysis-ready array β getting every scene on one grid
- How to turn a STAC search into an xarray data cube β the labelled-array version of the stack
- How to detect change between two satellite images in Python β why a dateless composite cannot do this
FAQ
How do I make a cloud-free composite in Python?
Mask each scene with its classification band, put every scene on one grid, stack them, and take a per-pixel np.nanmedian over time. Return the per-pixel observation count with it.
Median or mean?
Median. On the stack measured here the masked mean sat 0.15 NDVI below the masked median, because residual cloud edges pull the mean down and the median ignores them.
How many scenes do I need?
Enough that every pixel gets several clear looks. Over Snowdonia, 120 scenes across six months gave every pixel between 4 and 30 β but the median individual scene was only 0.4% usable.
Should I filter out cloudy scenes before compositing?
Only extremely cloudy ones. A 30%-usable scene still contributes those pixels, and in a cloudy region you cannot afford to discard them.
Why is my composite darker or greener than a single date?
Because it summarises a period. The best single scene here gave an NDVI median of 0.761 in July; the six-month composite gave 0.658, including autumn senescence.
Can I use a composite to detect change?
Not to date it. A composite has no date β each pixel summarises a different subset of the window. Use two composites over two clearly stated periods, and say what the periods were.
What is a best-pixel composite?
One that picks a single whole observation per pixel by some score, instead of mixing bands from different dates. It keeps each pixel spectrally coherent at the cost of visible seams.