Speckle explained: why radar images look grainy

On this page

Problem statement

A radar pixel is not a reading of one surface. It is the sum of the echoes from every scatterer inside the cell — stalks, ripples, stones — each arriving with its own phase, and because the radar is coherent those echoes reinforce or cancel according to path differences of a fraction of a wavelength. Neighbouring pixels of the same lake or field therefore return different intensities, scattered around the true value with known statistics. That scatter is speckle: not noise added by the instrument, but a property of how a coherent image is formed, and reduced only by averaging independent looks.

Knowing those statistics is what makes speckle manageable. On a patch of the Albufera lagoon south of Valencia, single Sentinel-1 pixels scatter with a standard deviation of 1.6 dB around the patch mean, and averaging a handful of pixels or dates shrinks that predictably.

Quick answer

Measure speckle on a homogeneous patch with the equivalent number of looks, ENL = mean² / variance, computed on linear intensity:

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

def patch(img, lon, lat, size=40):
    from pyproj import Transformer
    x, y = Transformer.from_crs(4326, g["crs"], always_xy=True).transform(lon, lat)
    col, row = ~g["transform"] * (x, y)
    r, c = int(row), int(col)
    return img[r - size // 2:r + size // 2, c - size // 2:c + size // 2]

lake = patch(vv, -0.352, 39.333)                # open water in the lagoon
enl = lake.mean() ** 2 / lake.var()
print(f"ENL {enl:.1f}; spread of single pixels {to_db(lake).std():.2f} dB")
ENL 4.4; spread of single pixels 1.60 dB

A higher ENL means less speckle. Single-look complex data has ENL 1; the GRD products Sentinel-1 RTC is made from are multi-looked to about 4–5.

Echoes from many scatterers in two identical pixels adding to a bright sum in one and a dark sum in the other. pixel A: phases agreebrightpixel B: phases canceldarkSame surface, different random arrangement of scatterers, very different sum spatialworkflow.io
Identical surfaces, different random arrangements of scatterers, and so different sums.

Recognise it, measure it, average it in linear units

1. Recognise it

Speckle is a granular texture at the scale of single pixels, strongest in bright areas because it is multiplicative: its size scales with the signal.

2. Measure it on something uniform

Choose a patch that should be homogeneous — calm water, a large bare field — and compute ENL on linear values. Do not compute it on decibels; the formula assumes intensity.

3. Reduce it by averaging

Averaging N independent samples raises ENL N-fold. The samples can be neighbouring pixels (spatial filtering), different dates (temporal averaging), or both — but neighbouring pixels in a terrain-corrected product are not independent. On the lagoon a 3×3 mean, nine pixels, raised ENL only from 4.4 to 6.5, and a 7×7 mean, forty-nine pixels, to 11.6.

4. Choose spatial or temporal

Spatial filters cost resolution and blur edges. Temporal averaging keeps resolution but assumes the surface did not change. A flood map needs spatial filtering of one date; a land-cover map can average a season.

5. Filter in linear units

Average intensities, not decibels. The mean of decibels is the log of the geometric mean, which is biased low — by 0.3 dB on the lagoon patch below, and by more on single-look data.

6. Do not expect it to vanish

Filters trade speckle for blur. After filtering, the remaining variation is a mix of real texture and residual speckle, and no filter separates them perfectly.

Equivalent number of looks over the Albufera lagoon for single pixels, spatial means and a nine-date mean. ENL over a 40 × 40 pixel lagoon patchsingle pixels4.4 (1.60 dB spread)3 × 3 mean6.5 (1.24 dB)7 × 7 mean11.6 (0.91 dB)9-date mean18.9 (0.84 dB)01020Nine pixels bought ×1.5, not ×9: terrain-corrected pixels are correlated spatialworkflow.io
Averaging raises ENL, but by far less than the sample count: neighbouring pixels are correlated and dates are not identical.

Spatial against temporal averaging

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 — spatial averaging

from scipy.ndimage import uniform_filter

def nan_mean_filter(img, k):
    """Box mean that ignores NaN: one missing pixel would otherwise blank k² neighbours."""
    ok = np.isfinite(img)
    total = uniform_filter(np.where(ok, img, 0), size=k)
    count = uniform_filter(ok.astype("float32"), size=k)
    return np.where(count > 0, total / np.maximum(count, 1e-9), np.nan)

print("missing pixels in the scene:", int(np.isnan(vv).sum()))
for k in (1, 3, 7):
    avg = nan_mean_filter(vv, k) if k > 1 else vv
    p = patch(avg, -0.352, 39.333)
    print(f"{k}x{k} mean: ENL {p.mean() ** 2 / p.var():6.1f}, spread {to_db(p).std():.2f} dB")
missing pixels in the scene: 49
1x1 mean: ENL    4.4, spread 1.60 dB
3x3 mean: ENL    6.5, spread 1.24 dB
7x7 mean: ENL   11.6, spread 0.91 dB

Example 2 — temporal averaging over nine dates

series = passes(search(bbox, "2024-07-01/2024-10-20", orbit=110))
stack = np.stack([read_pass(f, "vv", g) for f in list(series.values())[-9:]])
mean9 = np.nanmean(stack, axis=0)
p = patch(mean9, -0.352, 39.333)
print(f"{len(stack)} dates: ENL {p.mean() ** 2 / p.var():.1f}, spread {to_db(p).std():.2f} dB")
9 dates: ENL 18.9, spread 0.84 dB

Nine dates give an ENL of 18.9, not the 40 that nine independent looks of 4.4 would. The lagoon is not the same surface on every date — wind roughens it — so temporal averaging mixes real change with speckle, which is exactly the assumption step 4 warns about.

Example 3 — the bias of averaging in decibels

lin_mean = to_db(lake.mean())
db_mean = to_db(lake).mean()
print(f"mean of linear, in dB: {lin_mean:.2f}   mean of dB: {db_mean:.2f}   "
      f"bias {db_mean - lin_mean:.2f} dB")
mean of linear, in dB: -19.54   mean of dB: -19.87   bias -0.32 dB

Why coherent imaging produces speckle

Why coherent imaging produces speckle

Each pixel contains many small scatterers at slightly different distances from the sensor. Their echoes arrive with random relative phases and add as vectors. Sometimes they reinforce and the pixel is bright; sometimes they cancel and it is dark. Two pixels of the same surface have different random arrangements and so different sums, even though nothing about the surface differs.

Why intensity follows a known distribution

For a fully developed speckle field, single-look intensity is exponentially distributed: its standard deviation equals its mean, so ENL is 1. Averaging L independent looks gives a gamma distribution with ENL L. That is why ENL is the natural measure, and why the spread in decibels falls predictably as looks are added.

Why decibel averages are biased

The logarithm is concave, so the average of logs is less than the log of the average. For gamma-distributed intensity the gap depends only on the number of looks and is largest for single pixels. Averaging in decibels therefore darkens every speckled surface, and darkens it by different amounts depending on how much filtering has already been applied.

Spatial filtering against temporal averaging for speckle reduction. what speckle reduction costsspatial filter✓one date is enough✓good for events: floods, fires✕blurs edges and small featurestemporal average✓keeps full resolution✓good for stable land cover✕mixes in any real changeEvery reduction in speckle is paid for in resolution or in time spatialworkflow.io
Every reduction in speckle is paid for in resolution or in time.

Wind on water, point targets and resampling

  • Wind on water makes lakes non-homogeneous; pick calm patches for ENL.
  • Point targets such as ships and pylons are not speckled; they dominate any average they fall in.
  • Resampling during terrain correction correlates neighbouring pixels, so spatial averages gain fewer looks than their pixel count.
  • Coherence between dates is lost over vegetation, so temporal speckle is independent; over cities it is not.
  • Deep-learning despeckling exists; validate it on known homogeneous areas.
  • Report ENL with any filtered product so readers know how much averaging was done.

FAQ

What is speckle in SAR?

A granular texture caused by random interference between the echoes of many scatterers within each pixel.

Is speckle noise?

Not in the usual sense. It is deterministic for a given arrangement of scatterers and follows known statistics.

How do I measure speckle?

With the equivalent number of looks, mean² / variance of linear intensity over a homogeneous area.

How do I reduce it?

Average: spatially with a filter, or over several dates.

Should I filter in dB or linear?

Linear. Averaging decibels is biased low.

Why is speckle worse in bright areas?

It is multiplicative, so its absolute size scales with the signal.