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.

Equivalent number of looks on a lagoon patch for composites of one, three, twelve and twenty-seven passes. ENL against number of passes averagedone date1.6July (3 passes)8.6May–Sep (12)18.7the year (27)47.002550No blur at any step: every pass is the same ground seen again spatialworkflow.io
Speckle falls with the number of passes, and nothing is blurred — as long as the ground held still.

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.

A summer composite, the same composite extended across the flood, and the difference between them. May–Sep, 12 passesmedian −9.29 dBMay–Nov, 16 passesmedian −9.25 dBdifferenceflooded fieldsonly 0.6% moved > 2 dBA 10 dB drop on two dates in sixteen moves the mean by about 1 dB spatialworkflow.io
Extending the period across an event does not show the event; it dilutes it into the background.

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.

The choices behind a Sentinel-1 composite and what each one does. four choices, all of them metadatachoiceoptionseffectstatisticmean / median / percentilemedian 0.3 dB darker hereperiodseason / before an eventdecides what is averaged awayorbitone onlymixing adds false textureextra bandscount, 10–90 spreadsays where to trust it16.3% of pixels here varied by more than 6 dB across the 12 summer passesA composite is a claim that a period was stable; the spread band tests the claim spatialworkflow.io
Four choices, all of which belong in the product's metadata.

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.

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.