How to detect change between two Sentinel-1 images

On this page

Problem statement

Comparing two radar images looks like subtracting them. Done that way it produces a map where every pixel has changed, because speckle alone makes the same field differ by several decibels from one date to the next. The standard remedy, the log-ratio, turns the multiplicative speckle into something additive with a known spread, and a threshold on it separates real change from noise — as long as the threshold is set from the noise, and the reference is more than one image.

On two Sentinel-1 passes of the area south of Valencia twelve days apart, before the October 2024 flood, a ±3 dB log-ratio threshold flags 21.2% of pixels as changed. Speckle filtering halves that to 10.1%, and replacing the single reference date with the mean of three earlier dates brings it to 7.1% without touching the threshold.

Quick answer

Compute the log-ratio in decibels between speckle-filtered images, and threshold it:

import numpy as np
from sar_io import search, passes, grid, read_pass, to_db
from sar_filters import lee_filter

bbox = [-0.46, 39.25, -0.33, 39.45]
g = grid(bbox, "EPSG:25830")
P = passes(search(bbox, "2024-09-01/2024-10-20", orbit=110))
dates = list(P)
print("passes:", [str(d) for d, _ in dates])

def img(key):
    return lee_filter(read_pass(P[key], "vv", g))

ref, new = img(dates[-2]), img(dates[-1])          # 8 Oct and 20 Oct: no known event
log_ratio = to_db(new) - to_db(ref)
changed = np.abs(log_ratio) > 3
print(f"log-ratio sd {np.nanstd(log_ratio):.2f} dB; |change| > 3 dB on {np.nanmean(changed):.1%} of pixels")
passes: ['2024-09-02', '2024-09-14', '2024-09-26', '2024-10-08', '2024-10-20']
log-ratio sd 2.16 dB; |change| > 3 dB on 10.1% of pixels

A positive log-ratio means brighter on the later date: new buildings, crops growing, rougher ground. A negative one means darker: water, harvest, smoothing.

Log-ratio change detection between two Sentinel-1 images. log-ratio change detectionsame orbitidentical geometryLee filtereachlinear unitsdB(new) −dB(ref)a ratio of powersthresholdfrom a no-changepairbrighter /darkerkeep the signLogs turn multiplicative speckle into an additive term with one spread everywhere spatialworkflow.io
A difference of decibels is a ratio of powers, which is what makes speckle behave.

Same orbit, filtered, log-ratio, noise-based threshold

1. Use the same relative orbit

Different orbits see the ground at different angles; the log-ratio between them is mostly geometry.

2. Filter speckle first

A 5 × 5 Lee filter on each image before the ratio narrows the no-change distribution — from 2.76 to 2.16 dB standard deviation here — and halves the share of pixels a 3 dB threshold flags.

3. Take the log-ratio

In decibels it is a subtraction. Speckle, which multiplies the signal, becomes an additive term whose spread depends on the number of looks, not on how bright the surface is.

4. Set the threshold from the noise

Measure the spread of the log-ratio over an area known not to have changed, and threshold at two or three standard deviations — or use a fixed 3 dB, which is a factor of two in power, and report the false-alarm rate it implies.

5. Use several dates as the reference

The mean of several earlier dates, in linear units, is a better estimate of "before" than any single date. It reduces both speckle and ordinary short-term fluctuation from moisture and wind.

6. Keep the sign

Brighter and darker change mean different things. Keep them as separate classes rather than taking the absolute value in the final map.

Share of pixels flagged as changed between two pre-flood passes under three settings. |log-ratio| > 3 dB, 8 Oct → 20 Oct 2024 (no event)raw, one date21.2% (sd 2.76 dB)Lee, one date10.1% (sd 2.16 dB)Lee, 3-date reference7.1% (sd 1.99 dB)010%20%Same threshold throughout; only the speckle handling and the reference change spatialworkflow.io
On a pair with no event, everything flagged is noise or ordinary change — and the settings decide how much.

Filtering and references across a flood

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

lee_filter comes from the speckle-filter guide, saved as sar_filters.py:

%%writefile sar_filters.py
"""The Lee speckle filter from the speckle-filter guide."""
import numpy as np
from scipy.ndimage import uniform_filter

def lee_filter(img, size=5, enl=4.4):
    """Lee filter on linear intensity. Smooths homogeneous areas, keeps edges.
    enl: equivalent number of looks of the input (4.4 for Sentinel-1 IW GRD/RTC)."""
    ok = np.isfinite(img)
    x = np.where(ok, img, 0.0)
    n = uniform_filter(ok.astype("float64"), size)
    mean = uniform_filter(x, size) / np.maximum(n, 1e-9)
    sq = uniform_filter(x * x, size) / np.maximum(n, 1e-9)
    var = np.maximum(sq - mean ** 2, 0)
    noise = (mean ** 2) / enl                    # variance speckle alone would produce
    weight = np.clip((var - noise) / np.maximum(var, 1e-12), 0, 1)
    out = mean + weight * (x - mean)
    return np.where(ok, out, np.nan)
Writing sar_filters.py

Example 1 — the effect of filtering and of the reference

raw_ref, raw_new = read_pass(P[dates[-2]], "vv", g), read_pass(P[dates[-1]], "vv", g)
multi_ref = np.nanmean(np.stack([img(k) for k in dates[-4:-1]]), axis=0)   # three earlier dates

for name, a, b in [("raw, one date", raw_ref, raw_new),
                   ("Lee, one date", ref, new),
                   ("Lee, three-date mean", multi_ref, new)]:
    lr = to_db(b) - to_db(a)
    print(f"{name:21} sd {np.nanstd(lr):4.2f} dB   |lr| > 3 dB: {np.nanmean(np.abs(lr) > 3):5.1%}")
raw, one date         sd 2.76 dB   |lr| > 3 dB: 21.2%
Lee, one date         sd 2.16 dB   |lr| > 3 dB: 10.1%
Lee, three-date mean  sd 1.99 dB   |lr| > 3 dB:  7.1%

Example 2 — the same method across the flood

post_frames = next(iter(passes(search(bbox, "2024-11-01/2024-11-01", orbit=110)).values()))
post = lee_filter(read_pass(post_frames, "vv", g))
lr_event = to_db(post) - to_db(multi_ref)
print(f"darker by > 3 dB: {np.nanmean(lr_event < -3):.1%}   brighter by > 3 dB: {np.nanmean(lr_event > 3):.1%}")
lr_quiet = to_db(new) - to_db(multi_ref)
print(f"for comparison, 20 Oct: darker {np.nanmean(lr_quiet < -3):.1%}, brighter {np.nanmean(lr_quiet > 3):.1%}")
darker by > 3 dB: 21.1%   brighter by > 3 dB: 8.0%
for comparison, 20 Oct: darker 5.6%, brighter 1.4%

A ratio, not a difference

Why a ratio and not a difference

Speckle multiplies the underlying backscatter by a random factor. In linear units, a difference between two dates therefore has a spread proportional to the brightness: bright towns look changeable and dark water looks stable, whatever actually happened. Taking logs turns the random factor into an added term with the same spread everywhere, so a single threshold means the same thing in a town as in a lake.

Why several reference dates help so much

A single reference date carries its own speckle and its own weather — a wet field, a windy lake. Averaging three dates in linear units reduces the speckle and averages out the passing conditions, so the log-ratio measures departure from a typical state rather than from one particular day.

Why the flood stands out in the sign

Floods, harvests and ploughing all darken. Construction, crop growth and debris brighten. Between the quiet pair and the flood pair the darkening share nearly quadruples, from 5.6% to 21.1% — the signature of new open water. The brightening share rises too, from 1.4% to 8.0%, and the log-ratio alone cannot say why: debris, mud and wet surfaces are all candidates. Keeping the two directions separate is what lets you ask.

What makes a SAR pixel brighter or darker between dates. the direction is informationdirectiontypical causesheredarker (−)flood, harvest, smoothing5.6% → 21.1%brighter (+)building, growth, debris1.4% → 8.0%shares flagged vs a three-date reference: 20 Oct (quiet) → 1 Nov (after the flood)An absolute value would merge two stories into one number spatialworkflow.io
The direction of change is half the information; do not discard it with an absolute value.

Moisture, crops and false-alarm rates

  • Moisture changes backscatter by several decibels after rain; check the weather on both dates.
  • Crops change continuously through a season; a twelve-day log-ratio over farmland is rarely zero.
  • Coherence change from SLC pairs detects subtler changes, such as building damage, that intensity misses.
  • Multi-temporal tests such as the omnibus test generalise the log-ratio to many dates at once.
  • Orbit mixing makes change everywhere on slopes; do not do it.
  • Report the false-alarm rate from a no-change pair or area alongside any change map.

FAQ

How do I detect change with Sentinel-1?

Filter speckle, compute the log-ratio in decibels between two dates from the same orbit, and threshold it.

What threshold should I use?

Set it from the spread of the log-ratio over unchanged land; 3 dB is a common starting point.

Why does everything look changed?

Speckle. Filter first, and use a multi-date reference.

Should I use VV or VH?

VV for water and surfaces, VH for vegetation change; both work with the same method.

How do I tell a flood from construction?

By the sign: floods darken, construction brightens.

How do I estimate false alarms?

Run the same method on two dates with no known event and report the share flagged.