How to make a Sentinel-1 composite over a period
On this page
Problem statement
A single Sentinel-1 image is speckled, and any map made from one date inherits that grain. Averaging the passes over a season removes most of it without the blurring a spatial filter costs — which is why seasonal composites are the usual input to radar land-cover mapping. The catch is that a composite is a claim about a period being stable, and a period is never entirely stable. Average across a flood, a harvest or a change of orbit and the composite records a surface that never existed.
Over the area south of Valencia, twelve passes from one orbit between May and September 2024 raise the equivalent number of looks on a patch of the Albufera lagoon from 1.6 on a single date to 18.7. Extending the same composite into November, across the flood, moves only 0.6% of pixels by more than 2 dB — the event is diluted, not shown.
Quick answer
Average linear values across the passes of one relative orbit, and convert at the end:
import numpy as np
from sar_io import search, passes, grid, read_pass, to_db
bbox = [-0.46, 39.25, -0.33, 39.45]
g = grid(bbox, "EPSG:25830")
def composite(dates, orbit=110, band="vv", how="mean"):
stack = np.stack([read_pass(f, band, g)
for f in passes(search(bbox, dates, orbit=orbit)).values()])
with np.errstate(invalid="ignore"):
out = np.nanmedian(stack, axis=0) if how == "median" else np.nanmean(stack, axis=0)
return out, len(stack)
summer, n = composite("2024-05-01/2024-09-30")
print(f"{n} passes; median of the composite {np.nanmedian(to_db(summer)):.2f} dB")
12 passes; median of the composite -9.29 dB
A mean of linear power is the right default: it is what "average backscatter" means, and it is unbiased. A median is more robust to a single odd date and slightly noisier.
A stable period, one orbit, one grid, linear averages
1. Choose a period the ground was stable in
A composite describes the whole period as one surface. For land cover, a season; for a baseline, the months before an event.
2. Keep to one relative orbit
Mixing geometries shifts backscatter on slopes and in cities, and the shift lands in the composite as texture that is not there.
3. Stack on one grid
Every pass read onto the same grid, as in the reading guide, so the arrays align exactly.
4. Average in linear units
nanmean on linear power. Converting each date to decibels first biases the result, as the decibel guide shows.
5. Count the passes per pixel
Frames differ slightly between passes, so edge pixels may be averaged from fewer dates. Store the count as a band.
6. Check what the period hid
Compare the composite with its own dates: the spread across dates per pixel says whether the period was stable. Large spread means the composite is an average of different things.
How much speckle a composite removes
The data used on this page
Sentinel-1 radiometrically terrain-corrected backscatter (sentinel-1-rtc) from the Microsoft Planetary Computer, read through sar_io.py — the small module from the reading guide that searches, groups frames into passes and mosaics each pass onto a fixed grid.
%%writefile sar_io.py
"""Find, read and mosaic Sentinel-1 RTC scenes from Microsoft Planetary Computer."""
from collections import defaultdict
import numpy as np
import planetary_computer
import pystac_client
import rasterio
from rasterio.enums import Resampling
from rasterio.transform import from_origin
from rasterio.vrt import WarpedVRT
from rasterio.warp import transform_bounds
STAC = "https://planetarycomputer.microsoft.com/api/stac/v1"
def search(bbox, dates, orbit=None, direction=None, collection="sentinel-1-rtc"):
"""Items over bbox (lon/lat) in a date range, filtered by relative orbit and pass direction."""
cat = pystac_client.Client.open(STAC, modifier=planetary_computer.sign_inplace)
items = cat.search(collections=[collection], bbox=bbox, datetime=dates).item_collection()
keep = [i for i in items
if (orbit is None or i.properties["sat:relative_orbit"] == orbit)
and (direction is None or i.properties["sat:orbit_state"] == direction)]
return sorted(keep, key=lambda i: i.datetime)
def passes(items):
"""Group frames into passes: same day, same relative orbit."""
groups = defaultdict(list)
for i in items:
groups[(i.datetime.date(), i.properties["sat:relative_orbit"])].append(i)
return dict(sorted(groups.items()))
def grid(bbox, crs, res=10):
"""A fixed target grid: every pass is read onto this, so arrays line up."""
left, bottom, right, top = transform_bounds(4326, crs, *bbox)
width, height = int((right - left) // res), int((top - bottom) // res)
return {"crs": crs, "transform": from_origin(left, top, res, res),
"width": width, "height": height}
def read_pass(frames, band, g):
"""Mosaic one pass's frames for one band onto grid g. Linear power; NaN where no data."""
out = np.full((g["height"], g["width"]), np.nan, dtype="float32")
for item in frames:
with rasterio.open(item.assets[band].href) as src, WarpedVRT(
src, crs=g["crs"], transform=g["transform"], width=g["width"],
height=g["height"], resampling=Resampling.bilinear, nodata=np.nan) as vrt:
a = vrt.read(1)
a[a <= 0] = np.nan # -32768 nodata and zero-padding
fill = np.isnan(out) & np.isfinite(a)
out[fill] = a[fill]
return out
def to_db(linear):
return 10 * np.log10(linear)
Overwriting sar_io.py
Example 1 — how much speckle a composite removes
from pyproj import Transformer
def patch(img, lon=-0.352, lat=39.333, size=40):
x, y = Transformer.from_crs(4326, g["crs"], always_xy=True).transform(lon, lat)
c, r = ~g["transform"] * (x, y)
r, c = int(r), int(c)
return img[r - size // 2:r + size // 2, c - size // 2:c + size // 2]
for label, dates in [("one date", "2024-07-04/2024-07-04"), ("July", "2024-07-01/2024-07-31"),
("May–Sep", "2024-05-01/2024-09-30"), ("the year", "2024-01-01/2024-12-31")]:
comp, n = composite(dates)
p = patch(comp)
print(f"{label:10} {n:2d} passes ENL {np.nanmean(p) ** 2 / np.nanvar(p):5.1f}")
one date 1 passes ENL 1.6
July 3 passes ENL 8.6
May–Sep 12 passes ENL 18.7
the year 27 passes ENL 47.0
Example 2 — mean against median, and what a flood does to both
before_mean, n1 = composite("2024-05-01/2024-09-30", how="mean")
across_mean, n2 = composite("2024-05-01/2024-11-30", how="mean")
across_median, _ = composite("2024-05-01/2024-11-30", how="median")
print(f"{n1} passes to September, {n2} to November")
for name, comp in [("May–Sep mean", before_mean), ("May–Nov mean", across_mean),
("May–Nov median", across_median)]:
d = to_db(comp) - to_db(before_mean)
print(f"{name:16} median {np.nanmedian(to_db(comp)):6.2f} dB "
f"pixels moved > 2 dB from the summer composite: {np.nanmean(np.abs(d) > 2):5.1%}")
12 passes to September, 16 to November
May–Sep mean median -9.29 dB pixels moved > 2 dB from the summer composite: 0.0%
May–Nov mean median -9.25 dB pixels moved > 2 dB from the summer composite: 0.6%
May–Nov median median -9.62 dB pixels moved > 2 dB from the summer composite: 2.1%
Example 3 — the spread across dates, as a quality band
stack = np.stack([read_pass(f, "vv", g) for f in passes(search(bbox, "2024-05-01/2024-09-30", orbit=110)).values()])
spread = to_db(np.nanpercentile(stack, 90, axis=0)) - to_db(np.nanpercentile(stack, 10, axis=0))
print(f"10th–90th percentile spread across {len(stack)} dates: median {np.nanmedian(spread):.1f} dB, "
f"90th percentile {np.nanpercentile(spread, 90):.1f} dB")
print(f"pixels whose spread exceeds 6 dB: {np.nanmean(spread > 6):.1%}")
10th–90th percentile spread across 12 dates: median 3.8 dB, 90th percentile 7.0 dB
pixels whose spread exceeds 6 dB: 16.3%
A large spread marks pixels the composite should not be trusted for: fields that were harvested, water that came and went, or a bright target that moved.
Temporal composites beat spatial filters
Why a temporal composite beats a spatial filter
Both reduce speckle by averaging, but a spatial filter averages neighbours, which are different places, while a composite averages dates, which are the same place. Edges stay exactly where they are, and small features survive. The cost is that the surface must not have changed — and the resolution in time is gone.
Why the mean, not the median, by default
Backscatter distributions are skewed, and the median of a gamma distribution is below its mean, so a median composite is darker than the mean of the same data. For a stable surface the mean is the estimate of its backscatter; the median is a robust estimate that trades a little bias for insensitivity to one strange date. Choose deliberately, and say which one the product is.
Why the flood barely shows in the extended composite
Adding the post-flood passes to the summer ones moves each affected pixel by roughly the size of the change divided by the number of dates. A 10 dB drop on two dates out of sixteen shifts the mean by about 1 dB — invisible next to natural variability, but enough to bias any classification trained on the composite.
Per-pixel counts, percentiles and seasons
- Per-pixel counts differ at frame edges; keep them.
- Percentile composites (for example the 10th percentile of VV) are used to map water that appears at any time in the period.
- Seasonal composites for classification usually use two or four periods rather than one.
- Mixing orbits can be done if each orbit is composited separately and the results are used as separate features.
- Memory. A year of passes at 10 m over a city is tens of gigabytes; use windows or dask.
- Name the period in the file:
vv_mean_2024-05_2024-09_o110.tif.
Internal links
- Speckle explained — why averaging works
- How to read Sentinel-1 RTC data in Python — the stacking grid
- How to convert SAR backscatter to decibels — averaging in the right units
- How to detect change between two Sentinel-1 images — what a composite hides
- How to build a cloud-free composite from Sentinel-2 — the optical equivalent
- How to extract a Sentinel-1 time series for an area — keeping the time dimension
FAQ
How do I make a Sentinel-1 composite?
Stack the passes of one relative orbit onto one grid, average the linear values, and convert to decibels at the end.
Mean or median?
Mean by default; median if one odd date would otherwise dominate. Say which you used.
How many passes do I need?
Speckle falls roughly with the number of independent dates; twelve passes took a lagoon patch from 1.6 looks to 18.7, and a full year of 27 passes to 47.
Can I mix ascending and descending passes?
Not in one composite. Composite each separately.
Does a composite hide events?
Yes. A one-date change is diluted by the number of dates; use change detection for events.
How do I know the period was stable?
Keep the spread across dates per pixel and inspect it.