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.
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.
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.
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.
Internal links
- How to map floods from Sentinel-1 in Python — a change detection with one direction
- How to filter speckle in Sentinel-1 images — the filter used here
- How to convert SAR backscatter to decibels — ratios as subtractions
- SAR scenes from different dates do not line up — why one orbit
- How to extract a Sentinel-1 time series for an area — change over many dates
- How to detect change between satellite images in Python — the optical version
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.