A SAR image looks noisy after processing

On this page

Problem statement

The image arrives grainy, a classification made from it is peppered with single-pixel patches, and statistics computed over small areas jump from one date to the next. It looks like a processing fault, and it usually is not: it is speckle, the interference pattern inherent in coherent imaging, sometimes made worse by decisions taken during processing.

The distinction matters because the remedies differ. Speckle is reduced by averaging β€” spatially, temporally, or by coarsening the pixel. Noise that was added by processing β€” a dB average, a filter applied to the wrong units, a VH channel read below the instrument's noise floor β€” is fixed by changing the processing, and averaging only hides it.

Quick answer

Measure the equivalent number of looks on something that should be uniform. If it is close to the product's nominal value, the grain is ordinary speckle:

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")
vv = read_pass(next(iter(passes(search(bbox, "2024-10-20/2024-10-20", orbit=110)).values())), "vv", g)

from pyproj import Transformer

def enl(patch):
    return float(np.nanmean(patch) ** 2 / np.nanvar(patch))

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]

lagoon = patch(vv)                                   # open water in the Albufera lagoon
print(f"ENL {enl(lagoon):.1f} against a nominal 4.4; "
      f"single-pixel spread {np.nanstd(to_db(lagoon)):.2f} dB")
ENL 4.4 against a nominal 4.4; single-pixel spread 1.60 dB

An ENL near the nominal value means the data is as it should be and the grain is speckle. A much lower value means something in the chain added variance.

Causes of a noisy-looking SAR image and the fix for each. grain: speckle, or something you did?what it looks likewhat to doENL near nominal (4.4)ordinary speckle β€” average itclass means shift between datesaverage in linear, not dBVH water mask is randomuse VV: VH is at the noise floortime series jumps every passone relative orbit onlyOnly the first row is cured by filtering; the others are processing faults spatialworkflow.io
Only the first row is cured by averaging; the rest are processing faults.

Check ENL, averaging, channel, resampling and orbits

1. Check the ENL against the nominal value

Sentinel-1 IW products have an ENL of about 4.4, stated in sar:looks_equivalent_number. Measure yours on calm water or a bare field.

2. Check what you averaged

A mean of decibels is lower than the decibels of a mean and adds spread where classes are variable. Averages belong in linear units.

3. Check the channel

VH over water sits near the noise floor, so a VH water mask is largely noise. Use VV for water.

4. Check the resampling

Nearest-neighbour resampling of a speckled image keeps all the speckle while moving pixels around; bilinear or average resampling in linear units reduces it slightly.

5. Check for mixed orbits

A stack that mixes relative orbits looks noisy in time even when each date is clean, because backscatter differs by a decibel or more between geometries.

6. Then, and only then, filter

With the processing right, reduce the remaining speckle by temporal averaging if the ground was stable, or an adaptive spatial filter if it was not.

Spread over a 40 by 40 pixel patch of the Albufera lagoon under four readings. spread on water that should be uniformraw VV (ENL 4.4)1.60 dB β€” speckleraw VH (at noise floor)1.40 dB β€” noiseLee filtered (ENL 9.4)0.99 dB9-date mean (ENL 18.9)0.84 dB01 dB2 dBA lower spread is not always a better measurement: VH here is reading the receiver spatialworkflow.io
What each decision does to the spread on a patch that should be uniform.

Speckle or something you did

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 β€” is it speckle, or something you did?

from sar_filters import lee_filter

patches = {
    "raw VV": lagoon,
    "Lee filtered": patch(lee_filter(vv)),
    "raw VH": patch(read_pass(next(iter(passes(search(bbox, "2024-10-20/2024-10-20", orbit=110)).values())),
                              "vh", g)),
}
for name, p in patches.items():
    print(f"{name:14} mean {to_db(np.nanmean(p)):7.2f} dB   ENL {enl(p):5.1f}   "
          f"spread {np.nanstd(to_db(p)):4.2f} dB")
raw VV         mean  -19.54 dB   ENL   4.4   spread 1.60 dB
Lee filtered   mean  -19.55 dB   ENL   9.4   spread 0.99 dB
raw VH         mean  -25.79 dB   ENL   9.5   spread 1.40 dB

The lagoon's VV matches the nominal 4.4 looks exactly, so that grain is speckle and nothing else; the Lee filter doubles the looks. VH shows a higher ENL over the same water, which is not a better measurement: at βˆ’25.8 dB it is reading the receiver's noise, and noise is less variable than a real return.

Example 2 β€” the cost of mixing orbits in a stack

month = passes(search(bbox, "2024-07-01/2024-07-31"))
vals = {}
for (day, orbit), frames in month.items():
    vals.setdefault(orbit, []).append(np.nanmean(read_pass(frames, "vv", g)))
one = to_db(np.array(vals[110]))
allo = to_db(np.array([v for vs in vals.values() for v in vs]))
print(f"scene mean VV, orbit 110 only: sd {one.std():.2f} dB over {len(one)} passes")
print(f"scene mean VV, all orbits:     sd {allo.std():.2f} dB over {len(allo)} passes")
scene mean VV, orbit 110 only: sd 0.08 dB over 3 passes
scene mean VV, all orbits:     sd 0.87 dB over 10 passes

Example 3 β€” how far below the noise floor VH is over water

vh = read_pass(next(iter(passes(search(bbox, "2024-10-20/2024-10-20", orbit=110)).values())), "vh", g)
water = to_db(vv) < -18
print(f"over dark water: VV median {np.nanmedian(to_db(vv)[water]):.1f} dB, "
      f"VH median {np.nanmedian(to_db(vh)[water]):.1f} dB")
print(f"VH pixels below -24 dB there: {np.nanmean(to_db(vh)[water] < -24):.0%}")
over dark water: VV median -20.6 dB, VH median -25.8 dB
VH pixels below -24 dB there: 87%

Speckle can only be averaged

Why speckle is not removable, only averageable

Speckle comes from the coherent sum of many scatterers inside each resolution cell. There is no "true" noiseless image behind it to recover: the value you measure is the real return of that particular arrangement. Averaging independent samples β€” other pixels, other dates β€” estimates the underlying mean backscatter, and the more samples, the closer the estimate. That is the whole of speckle reduction.

Why a decibel average makes things look noisier

Converting first and averaging second turns the arithmetic mean into a geometric one, which sits lower and further from the bright values in the sample. Zones that differ in variability then shift by different amounts, so class means move relative to each other and the image looks less consistent than it is.

Why VH water masks look like noise

Over calm water the cross-polarised return falls to the instrument's noise level, so VH there measures the receiver rather than the scene. Thresholding it produces a pattern with no relationship to the water's extent, which is a good description of noise.

Checks before filtering a noisy SAR image. five checks before a filterENL measured on uniform groundagainst the nominal 4.4averages taken in linear powernot decibelspolarisation suits the targetVV for waterresampling done in linear unitsone relative orbit in the stack0.08 dB sd against 0.87a filter applied firsthides all of the aboveScene-mean VV varied by 0.87 dB across orbits in one month and 0.08 dB within one orbit spatialworkflow.io
Five checks before reaching for a filter, because a filter hides the other four.

Real texture, border noise and screenshots

  • Real texture β€” urban areas, rough lava, forest edges β€” is not speckle, and a low ENL there is information.
  • Border noise at frame edges in older GRD products looks like a stripe of noise.
  • Thermal noise removal leaves residual patterns in very dark areas of some products.
  • Downsampling to 20 m or 40 m by averaging is the cheapest speckle reduction there is.
  • Screenshots at low zoom exaggerate speckle; judge it at full resolution.
  • Report ENL with any product, so the next person can tell speckle from processing.

FAQ

Why is my Sentinel-1 image so grainy?

Speckle. Measure the equivalent number of looks: near 4.4 for IW products means the data is normal.

Is speckle a processing error?

No. It is inherent to coherent imaging. Processing errors add to it.

Why do my class statistics jump between dates?

Often because the stack mixes relative orbits, or because averages were taken in decibels.

Why is my VH water mask noise?

VH over calm water is at the instrument's noise floor. Use VV.

Does filtering fix everything?

No. It hides processing faults instead of correcting them.

What ENL should I expect?

About 4.4 for Sentinel-1 IW GRD and RTC; higher after any averaging.