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.
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.
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.
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.
Internal links
- Speckle explained β the statistics behind the grain
- How to filter speckle in Sentinel-1 images β the filters
- How to convert SAR backscatter to decibels β averaging units
- SAR values come out negative, huge or averaged wrongly β the other unit problem
- SAR scenes from different dates do not line up β mixed orbits
- How to make a Sentinel-1 composite over a period β temporal averaging
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.