How to map floods from Sentinel-1 in Python

On this page

Problem statement

Floods come with cloud, which is why radar is the standard tool for mapping them: calm open water is the darkest surface in a Sentinel-1 image, and a pass a day or two after the event sees it through whatever weather is overhead. The simple version — threshold the post-event image — maps every lake, every river and every smooth dark surface as flood. A usable flood map needs a pre-event image to subtract what was already water, a threshold chosen from the data rather than copied, a filter against speckle, and an honest statement of what radar cannot see.

South of Valencia, after the flood of 29 October 2024, the Sentinel-1 pass of 1 November shows about 42 km² of new open water around the Albufera lagoon, against a pass on the same orbit twelve days earlier. How much of that is the flood and how much is the rice fields that are flooded every autumn is a question the radar alone cannot answer — and the towns where the flood did most damage barely register.

Quick answer

Compare a filtered post-event image with a filtered pre-event image from the same orbit, and call new water what is dark after and was not dark before:

import numpy as np
from skimage.filters import threshold_otsu
from sar_io import search, passes, grid, read_pass, to_db
from sar_filters import lee_filter                  # from the speckle-filtering guide

bbox = [-0.46, 39.25, -0.33, 39.45]
g = grid(bbox, "EPSG:25830")
P = passes(search(bbox, "2024-10-20/2024-11-01", orbit=110))
(pre_day, _), (post_day, _) = list(P)
pre = to_db(lee_filter(read_pass(P[(pre_day, 110)], "vv", g)))
post = to_db(lee_filter(read_pass(P[(post_day, 110)], "vv", g)))

thr = threshold_otsu(post[np.isfinite(post)])
new_water = (post < thr) & (pre >= thr) & (post - pre < -3)
print(f"{pre_day} -> {post_day}: threshold {thr:.1f} dB, new water {new_water.sum() * 100 / 1e6:.1f} km²")
2024-10-20 -> 2024-11-01: threshold -13.0 dB, new water 41.8 km²

The last condition — a drop of at least 3 dB — stops pixels that sat just above the threshold before and just below it after from being counted as flooding.

Map south of Valencia with the Sentinel-1 flood result: newly flooded land in bright blue around the Albufera lagoon and to its north and south, the lagoon and sea in dark blue, and dry land in grey. Map south of Valencia with the Sentinel-1 flood result: newly flooded land in bright blue around the Albufera lagoon and to its north and south, the lagoon and sea in dark blue, and dry land in grey.
The result of the code above. The bright blue is the 41.8 km² of new water: below the −13.0 dB threshold after the flood, above it before, and at least 3 dB darker. The dark blue was already below the threshold on 20 October — the lagoon, the sea, and fields that were wet anyway — which is why the pre-event image does more work than the threshold.
Steps of a Sentinel-1 flood map from two passes to cleaned new water. two passes to a flood mappre + post,one orbit20 Oct, 1 NovLee filterlinear, ENL 4.4Otsu threshold−13.0 dB herechange testdark now, notbeforeclean≥ 0.1 ha, not alake588 patches of new water before clean-up, 191 after; 41.5 km² in total spatialworkflow.io
Six steps; the pre-event image does more work than the threshold.

Matched pre- and post-event passes, a data-driven threshold

1. Find a post-event pass and a matching pre-event pass

Same relative orbit, as close in time as possible before the event. Different orbits change incidence angle and look direction, and with them the backscatter of everything that did not flood.

2. Filter speckle

A 5 × 5 Lee filter on linear intensity, before converting to decibels. An unfiltered threshold map is salt and pepper.

3. Choose the threshold from the data

Otsu's method finds the value that best separates two classes in a histogram. It needs both classes present in reasonable proportions; if the scene is mostly land, compute it on tiles that contain water and land, not on the whole image.

4. Test for change

New water is below the threshold after, above it before, and darker by a margin. The margin guards against noise near the threshold.

5. Remove permanent water and small patches

Anything that was water before is not flood. Patches of a handful of pixels are more likely speckle or a single dark field than water; a minimum mapping unit of about 0.1 ha removes them.

6. Say what the map cannot show

Flooded towns, flooded forests and water under standing crops do not appear dark to C-band radar. A SAR flood map is a map of open-water flooding.

New-water area south of Valencia between 20 October and 1 November 2024 for four thresholds. new water for four thresholdsOtsu (−13.0 dB)41.8 km²−15 dB39.1 km²−18 dB31.6 km²−20 dB18.7 km²025 km²50 km²Same images, same change test: only the threshold differs spatialworkflow.io
The threshold alone moves the flooded area from 41.8 to 18.7 km².

Threshold sensitivity, clean-up and the towns

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)
Overwriting sar_filters.py

Example 1 — how much the threshold matters

for t in (thr, -15, -18, -20):
    nw = (post < t) & (pre >= t) & (post - pre < -3)
    print(f"threshold {t:6.1f} dB: new water {nw.sum() * 100 / 1e6:5.1f} km²")
threshold  -13.0 dB: new water  41.8 km²
threshold  -15.0 dB: new water  39.1 km²
threshold  -18.0 dB: new water  31.6 km²
threshold  -20.0 dB: new water  18.7 km²

Example 2 — clean up and measure

from scipy.ndimage import label
import pystac_client, planetary_computer, rasterio
from rasterio.enums import Resampling
from rasterio.vrt import WarpedVRT

cat = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1",
                                modifier=planetary_computer.sign_inplace)
wc_item = next(cat.search(collections=["esa-worldcover"], bbox=bbox).items())
with rasterio.open(wc_item.assets["map"].href) as src, WarpedVRT(
        src, crs=g["crs"], transform=g["transform"], width=g["width"], height=g["height"],
        resampling=Resampling.nearest) as vrt:
    wc = vrt.read(1)

flood = new_water & (wc != 80)                          # not mapped as permanent water
lab, n = label(flood)
sizes = np.bincount(lab.ravel())
keep = sizes >= 10                                      # 10 pixels = 0.1 ha at 10 m
keep[0] = False
flood = keep[lab]
print(f"{n:,} patches before, {int(keep.sum()):,} kept; flood {flood.sum() * 100 / 1e6:.1f} km²")
for code, name in {40: "cropland", 90: "wetland", 30: "grassland", 50: "built-up", 10: "tree cover"}.items():
    print(f"  on {name:10} {np.sum(flood & (wc == code)) * 100 / 1e6:5.2f} km²")
588 patches before, 191 kept; flood 41.5 km²
  on cropland   29.05 km²
  on wetland    11.41 km²
  on grassland   0.74 km²
  on built-up    0.15 km²
  on tree cover  0.06 km²

Of the 41.5 km² that survive the clean-up, 29 km² are on land WorldCover maps as cropland and 11 km² on herbaceous wetland — the paddies and marsh around the lagoon. Built-up land contributes 0.15 km².

Example 3 — the towns

built = wc == 50
d = post - pre
print(f"built-up pixels: median change {np.nanmedian(d[built]):+.2f} dB; "
      f"darker by 3 dB or more: {np.nanmean(d[built] < -3):.1%}")
print(f"cropland pixels: median change {np.nanmedian(d[wc == 40]):+.2f} dB; "
      f"darker by 3 dB or more: {np.nanmean(d[wc == 40] < -3):.1%}")
built-up pixels: median change +0.93 dB; darker by 3 dB or more: 2.5%
cropland pixels: median change +0.21 dB; darker by 3 dB or more: 34.4%

The towns west of the lagoon, where the flood caused most of its damage, show almost no darkening: built-up pixels got brighter on median, and only 2.5% of them darkened by 3 dB, against 34.4% of cropland. There are two reasons, and these two images cannot separate them: three days after the flood much of the water had drained from the streets, and where water does stand between buildings, it reflects the pulse into the walls and back to the sensor, keeping flooded streets as bright as dry ones or brighter.

Why the pre-event image matters most

Why the pre-event image matters more than the threshold

A single post-event image cannot tell a flood from a lake, a reservoir, a rice paddy, wet tarmac or radar shadow; all are dark. Every one of them except the flood is dark before the event too. Change detection removes them all at once, which no choice of threshold can do.

Why the rice fields are ambiguous

The paddies around the Albufera are flooded deliberately every autumn and winter, and they turn from land to water in radar at roughly the time of year this flood happened. The change detection is correct that they became water between 20 October and 1 November; attributing that to the flood needs local knowledge, a third date, or an independent source such as the Copernicus Emergency Management Service's delineation.

Why cities are missing

Flooded urban areas are one of the known blind spots of C-band flood mapping, even when the image is taken while the water is still there. Specular reflection off the water surface into building walls strengthens the double-bounce return, so streets under water can become brighter, not darker. Detecting urban flooding needs coherence from interferometric pairs, very high-resolution imagery, or other data entirely.

What a Sentinel-1 flood map misses or mislabels, and what to do about each. what the map cannot showblind spotwhat to doflooded towns: 2.5% darkenedcoherence, VHR imagery, reportsrice paddies flooded on purposethird date, local calendar, EMSwater under trees or tall cropsL-band, or state the gapdark shadow and smooth tarmacpre-event image, HAND maskBuilt-up pixels got brighter on median (+0.93 dB) between the two passes spatialworkflow.io
A SAR flood map is a map of open water that appeared; everything else needs saying.

Wind, timing and validation

  • Wind roughens water and can hide it; check the post-event weather.
  • Timing. Floods recede; a pass three days after the peak maps what was left.
  • VV over VH for water: the contrast is larger and VH is near the noise floor.
  • Hand-picked thresholds from other studies rarely transfer; compute your own.
  • HAND or slope masks remove dark pixels on hillsides that cannot hold water.
  • Validate against an independent delineation before publishing an area.

FAQ

How do I map a flood with Sentinel-1?

Take a post-event and a pre-event image from the same orbit, filter speckle, threshold both, and map pixels that became dark.

What threshold should I use for water?

Compute one from your data, for example with Otsu's method on a region with both land and water. Values between −15 and −20 dB in VV are typical but not universal.

Why does my flood map include lakes and rivers?

Without a pre-event image, permanent water is indistinguishable from flood.

Can Sentinel-1 see flooded cities?

Poorly. Double bounce makes flooded streets bright, not dark.

Should I use VV or VH?

VV. It gives stronger water contrast.

How soon after a flood is data available?

Within hours to a day of the pass for near-real-time products; passes over a given area occur every few days.