Source code: SAR speckle filtering in Python: Lee, refined Lee, Frost and Gamma-MAP on Sentinel-1

Runnable Python scripts, requirements and console output for the tutorial “SAR speckle filtering in Python: Lee, refined Lee, Frost and Gamma-MAP on…

Last updated

These are the exact scripts used to produce every figure and number in SAR speckle filtering in Python: Lee, refined Lee, Frost and Gamma-MAP on Sentinel-1. Download them, install the requirements into a fresh virtual environment and run them from this folder.

Run it
python -m venv .venv
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
python speckle_filters.py

speckle_filters.py

speckle_filters.py
"""SAR speckle filtering in Python: boxcar, Lee, refined Lee, enhanced Lee, Frost and Gamma-MAP on Sentinel-1.
Streams a 13 x 11 km chip of Sentinel-1 RTC (VV, gamma0) over Rutland Water, Oakham and the surrounding
farmland in England from the Microsoft Planetary Computer (no account or key), checks the multiplicative
speckle model against the gamma distribution, implements six speckle filters in NumPy/SciPy and scores them
with the equivalent number of looks (ENL) in homogeneous areas, an edge-preservation index (EPI) against a
29-date multi-temporal reference, the ratio image and runtime.
Run with: python speckle_filters.py
"""
import platform
import time
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import planetary_computer
import pystac_client
import rasterio
import scipy
from matplotlib.patches import Rectangle
from pyproj import Transformer
from rasterio.windows import from_bounds
from scipy import ndimage, special, stats
HERE = Path(__file__).parent
OUT, DATA = HERE / "output", HERE / "data"
for d in (OUT, DATA):
d.mkdir(exist_ok=True)
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
pd.set_option("display.width", 140)
print("=== versions ===")
print(f"python {platform.python_version()} | numpy {np.__version__} | scipy {scipy.__version__} | "
f"rasterio {rasterio.__version__} | pystac-client {pystac_client.__version__}")
def section(title: str) -> None:
print(f"\n=== {title} ===", flush=True)
# --- Step 1: Sentinel-1 RTC chips from the Planetary Computer -------------------------
BBOX = (-0.76, 52.61, -0.56, 52.71) # lon/lat: Rutland Water, Oakham and farmland, East Midlands, England
ORBIT = 154 # one relative orbit (descending): identical geometry on every date
SEASON = "2025-10-01/2026-03-31"
TARGET = "2026-01-10" # the single date we filter
section("Step 1: search and read Sentinel-1 RTC")
catalog = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1",
modifier=planetary_computer.sign_inplace)
items = sorted(catalog.search(collections=["sentinel-1-rtc"], bbox=BBOX, datetime=SEASON,
query={"sat:relative_orbit": {"eq": ORBIT}}).items(), key=lambda i: i.datetime)
print(f"{len(items)} scenes from relative orbit {ORBIT}, {items[0].datetime:%d %b %Y} to {items[-1].datetime:%d %b %Y}")
target = next(i for i in items if i.datetime.strftime("%Y-%m-%d") == TARGET)
ENL_NOMINAL = target.properties["sar:looks_equivalent_number"]
print(f"target: {target.id} | {target.properties['platform']} | looks {target.properties['sar:looks_range']} x "
f"{target.properties['sar:looks_azimuth']} | nominal ENL {ENL_NOMINAL} | {target.properties['proj:code']}")
to_utm = Transformer.from_crs(4326, target.properties["proj:code"], always_xy=True)
xs, ys = to_utm.transform([BBOX[0], BBOX[2]], [BBOX[1], BBOX[3]])
def read_vv(item) -> np.ndarray:
"""Read the chip of linear gamma0 VV at the native 10 m grid (only the needed COG tiles are fetched)."""
cache = DATA / f"{item.datetime:%Y-%m-%d}_vv.npy"
if not cache.exists():
with rasterio.open(item.assets["vv"].href) as src:
win = from_bounds(xs[0], ys[0], xs[1], ys[1], src.transform).round_offsets().round_lengths()
a = src.read(1, window=win).astype("float32")
a[a == src.nodata] = np.nan
np.save(cache, a)
return np.load(cache)
t0 = time.perf_counter()
stack = np.stack([read_vv(i) for i in items])
img = stack[[i.id for i in items].index(target.id)]
ref = np.nanmean(stack, axis=0) # multi-temporal multilook: nearly speckle-free, edges kept
print(f"read {len(items)} chips of {img.shape[0]} x {img.shape[1]} pixels (10 m) in {time.perf_counter() - t0:.0f} s; "
f"NaN pixels in target: {int(np.isnan(img).sum())}")
print(f"target gamma0 VV: min {np.nanmin(img):.4f}, median {np.nanmedian(img):.4f}, max {np.nanmax(img):.2f} (linear)")
def db(x):
with np.errstate(divide="ignore", invalid="ignore"):
return 10 * np.log10(x)
# --- Step 2: find homogeneous areas in the reference and test the speckle model ---------
section("Step 2: the multiplicative speckle model")
W = 21 # 210 m homogeneous windows
m = ndimage.uniform_filter(ref, W)
cv_ref = np.sqrt(np.maximum(ndimage.uniform_filter(ref * ref, W) - m * m, 0)) / m
cv_ref[: W, :] = cv_ref[-W:, :] = cv_ref[:, :W] = cv_ref[:, -W:] = np.inf
m_db = db(m)
windows, kinds = [], []
taken = np.zeros_like(img, bool)
# the four most uniform windows on open water (wind-roughened, well above the noise floor) and four on land
for kind, ok in (("water", m_db < -13), ("land", m_db > -11)):
n = 0
for flat in np.argsort(np.where(ok, cv_ref, np.inf), axis=None):
r, c = np.unravel_index(flat, img.shape)
if taken[r - W:r + W + 1, c - W:c + W + 1].any():
continue
windows.append((r - W // 2, c - W // 2))
kinds.append(kind)
taken[r - W // 2:r + W // 2 + 1, c - W // 2:c + W // 2 + 1] = True
n += 1
if n == 4:
break
sl = [np.s_[r:r + W, c:c + W] for r, c in windows]
def enl(x: np.ndarray, kind: str | None = None) -> float:
"""Equivalent number of looks: mean^2 / variance of intensity, averaged over the homogeneous windows."""
return float(np.mean([np.nanmean(x[s]) ** 2 / np.nanvar(x[s]) for s, k in zip(sl, kinds) if kind in (None, k)]))
for k, s in enumerate(sl):
print(f"window {k + 1} ({kinds[k]:5s}): rows {s[0].start}-{s[0].stop - 1}, cols {s[1].start}-{s[1].stop - 1} | reference CV "
f"{cv_ref[windows[k][0] + W // 2, windows[k][1] + W // 2]:.3f} | mean {db(np.nanmean(img[s])):.1f} dB | "
f"ENL {np.nanmean(img[s]) ** 2 / np.nanvar(img[s]):.2f}")
enl_raw = enl(img)
print(f"ENL of the raw target image: {enl_raw:.2f} (nominal {ENL_NOMINAL}); speckle CV {1 / np.sqrt(enl_raw):.3f}; "
f"reference ENL {enl(ref):.0f}")
norm = np.concatenate([(img[s] / np.nanmean(img[s])).ravel() for s in sl])
L_fit, _, scale_fit = stats.gamma.fit(norm, floc=0)
print(f"normalised intensity in windows: mean {norm.mean():.3f}, variance {norm.var():.3f}; "
f"gamma fit shape L = {L_fit:.2f}, scale {scale_fit:.3f}")
ks_fit = stats.kstest(norm, "gamma", args=(L_fit, 0, 1 / L_fit)).statistic
ks_exp = stats.kstest(norm, "expon").statistic
print(f"Kolmogorov-Smirnov distance: gamma(L={L_fit:.2f}) {ks_fit:.3f}; single-look exponential {ks_exp:.3f}")
mean_db_of_px = np.mean([np.nanmean(db(img[s])) for s in sl])
db_of_mean = np.mean([db(np.nanmean(img[s])) for s in sl])
theory = 10 / np.log(10) * (special.digamma(L_fit) - np.log(L_fit))
print(f"mean of dB pixels {mean_db_of_px:.2f} dB vs dB of mean power {db_of_mean:.2f} dB: "
f"bias {mean_db_of_px - db_of_mean:+.2f} dB (theory for L={L_fit:.2f}: {theory:+.2f} dB)")
# --- Step 3: the filters ----------------------------------------------------------------
section("Step 3: speckle filters")
def moments(x, size):
mean = ndimage.uniform_filter(x, size)
var = np.maximum(ndimage.uniform_filter(x * x, size) - mean * mean, 0)
return mean, var
def fill_nan(x):
return np.where(np.isnan(x), np.nanmedian(x), x)
def boxcar(x, size=7, enl=None):
return ndimage.uniform_filter(fill_nan(x), size)
def lee(x, size=7, enl=4.4):
"""Lee (1980): out = mean + k (x - mean), k from the local variance in excess of pure speckle."""
x = fill_nan(x)
mean, var = moments(x, size)
cu2 = 1 / enl # speckle variance (coefficient of variation squared)
var_x = np.maximum((var - mean ** 2 * cu2) / (1 + cu2), 0)
k = var_x / np.maximum(var, 1e-12)
return mean + k * (x - mean)
def enhanced_lee(x, size=7, enl=4.4, damping=1.0):
"""Enhanced Lee (Lopes et al. 1990): full smoothing below Cu, none above Cmax, exponential in between."""
x = fill_nan(x)
mean, var = moments(x, size)
ci = np.sqrt(var) / np.maximum(mean, 1e-12)
cu, cmax = 1 / np.sqrt(enl), np.sqrt(1 + 2 / enl)
c = np.clip(ci, cu, cmax - 1e-6)
w = np.exp(-damping * (c - cu) / (cmax - c)) # 1 in homogeneous areas, 0 at point targets
out = mean * w + x * (1 - w)
return np.where(ci >= cmax, x, out)
def gamma_map(x, size=7, enl=4.4):
"""Gamma-MAP (Lopes et al. 1990): maximum a posteriori estimate with a gamma-distributed scene."""
x = fill_nan(x)
mean, var = moments(x, size)
ci2 = var / np.maximum(mean ** 2, 1e-12)
cu2, cmax2 = 1 / enl, 1 + 2 / enl
with np.errstate(divide="ignore", invalid="ignore"):
alpha = (1 + cu2) / (ci2 - cu2)
b = alpha - enl - 1
out = (b * mean + np.sqrt(np.maximum(mean ** 2 * b ** 2 + 4 * alpha * enl * mean * x, 0))) / (2 * alpha)
return np.where(ci2 <= cu2, mean, np.where(ci2 >= cmax2, x, out))
def frost(x, size=7, enl=None, damping=2.0):
"""Frost (1982): exponentially weighted mean, with a decay that grows with local heterogeneity."""
x = fill_nan(x)
mean, var = moments(x, size)
a = damping * var / np.maximum(mean ** 2, 1e-12)
h = size // 2
pad = np.pad(x, h, mode="reflect")
num, den = np.zeros_like(x), np.zeros_like(x)
for dy in range(-h, h + 1):
for dx in range(-h, h + 1):
w = np.exp(-a * np.hypot(dy, dx))
num += w * pad[h + dy:h + dy + x.shape[0], h + dx:h + dx + x.shape[1]]
den += w
return num / den
def _refined_masks():
i, j = np.mgrid[0:7, 0:7]
return {"N": i <= 3, "S": i >= 3, "W": j <= 3, "E": j >= 3,
"NW": i + j <= 6, "SE": i + j >= 6, "NE": j >= i, "SW": j <= i}
def refined_lee(x, enl=4.4, size=7):
"""Refined Lee (Lee 1981): Lee statistics from the edge-aligned half of a 7 x 7 window."""
x = fill_nan(x)
m3 = ndimage.uniform_filter(x, 3)
pad = np.pad(m3, 2, mode="reflect")
s = [[pad[2 + 2 * (r - 1):2 + 2 * (r - 1) + x.shape[0], 2 + 2 * (c - 1):2 + 2 * (c - 1) + x.shape[1]]
for c in range(3)] for r in range(3)] # 3 x 3 grid of 3 x 3 sub-window means
grads = np.stack([abs(s[0][1] - s[2][1]), abs(s[1][0] - s[1][2]), # vertical, horizontal
abs(s[0][0] - s[2][2]), abs(s[0][2] - s[2][0])]) # two diagonals
pairs = [("N", "S", s[0][1], s[2][1]), ("W", "E", s[1][0], s[1][2]),
("NW", "SE", s[0][0], s[2][2]), ("NE", "SW", s[0][2], s[2][0])]
best = np.argmax(grads, axis=0)
masks = _refined_masks()
mean, var = np.empty_like(x), np.empty_like(x)
for d, (a, b, sa, sb) in enumerate(pairs):
side_a = abs(s[1][1] - sa) <= abs(s[1][1] - sb) # the centre belongs to the closer side
for name, sel in ((a, side_a), (b, ~side_a)):
k = masks[name].astype("float32") / masks[name].sum()
mu = ndimage.correlate(x, k, mode="reflect")
v = np.maximum(ndimage.correlate(x * x, k, mode="reflect") - mu * mu, 0)
pick = (best == d) & sel
mean[pick], var[pick] = mu[pick], v[pick]
cu2 = 1 / enl
var_x = np.maximum((var - mean ** 2 * cu2) / (1 + cu2), 0)
return mean + var_x / np.maximum(var, 1e-12) * (x - mean)
FILTERS = {"Boxcar 7×7": boxcar, "Lee": lee, "Refined Lee": refined_lee, "Enhanced Lee": enhanced_lee,
"Frost": frost, "Gamma-MAP": gamma_map}
ENL_IN = round(enl_raw, 1) # use the measured ENL of this image, not the nominal value
print(f"filtering with a 7 x 7 window and ENL = {ENL_IN}")
results, times = {}, {}
for name, f in FILTERS.items():
f(img[:64, :64], enl=ENL_IN) # warm-up
best_t = np.inf
for _ in range(3):
t0 = time.perf_counter()
out = f(img, enl=ENL_IN)
best_t = min(best_t, time.perf_counter() - t0)
out[np.isnan(img)] = np.nan
results[name], times[name] = out.astype("float32"), best_t
print(f"{name:13s} {best_t * 1000:7.0f} ms ({img.size / best_t / 1e6:5.1f} Mpixel/s)")
# --- Step 4: score the filters ---------------------------------------------------------
section("Step 4: ENL, edge preservation and the ratio image")
ref_db = db(ref)
gy, gx = ndimage.sobel(ref_db, 0), ndimage.sobel(ref_db, 1)
edges = np.hypot(gx, gy) > np.nanpercentile(np.hypot(gx, gy), 90) # strongest 10% of reference edges
print(f"edge pixels (top 10% Sobel gradient of the reference): {int(edges.sum()):,}")
def highpass(x_db: np.ndarray) -> np.ndarray:
x_db = np.nan_to_num(x_db)
return x_db - ndimage.gaussian_filter(x_db, 3)
def epi(x: np.ndarray) -> float:
"""Edge-preservation index (after Sattar et al. 1997): correlation of the high-pass detail of the image
and of the speckle-free reference, over the edge pixels."""
a, b = highpass(db(x))[edges], highpass(ref_db)[edges]
a, b = a - a.mean(), b - b.mean()
return float((a * b).sum() / np.sqrt((a * a).sum() * (b * b).sum()))
rows = [{"filter": "None (raw)", "ENL": enl_raw, "ENL water": enl(img, "water"), "ENL land": enl(img, "land"), "EPI": epi(img), "ratio mean": 1.0, "ratio std": 0.0,
"RMSE vs ref (dB)": float(np.sqrt(np.nanmean((db(img) - ref_db) ** 2))), "ms": 0.0}]
for name, out in results.items():
ratio = img / out
valid = np.isfinite(ratio)
rows.append({"filter": name, "ENL": enl(out), "ENL water": enl(out, "water"), "ENL land": enl(out, "land"), "EPI": epi(out), "ratio mean": float(np.mean(ratio[valid])),
"ratio std": float(np.std(ratio[valid])),
"RMSE vs ref (dB)": float(np.sqrt(np.nanmean((db(out) - ref_db) ** 2))), "ms": times[name] * 1000})
score = pd.DataFrame(rows).set_index("filter")
print(score.round({"ENL": 1, "ENL water": 1, "ENL land": 1, "EPI": 3, "ratio mean": 3, "ratio std": 3, "RMSE vs ref (dB)": 2, "ms": 0}).to_string())
print(f"(pure speckle would give a ratio image with mean 1 and std {1 / np.sqrt(enl_raw):.3f})")
# Filtering in dB instead of linear power: the same boxcar, two answers
box_db = 10 ** (boxcar(db(img)) / 10)
for lab, x in [("raw image", img), ("boxcar in linear", results["Boxcar 7×7"]), ("boxcar in dB", box_db)]:
print(f"{lab:18s}: mean over homogeneous windows {np.mean([db(np.nanmean(x[s])) for s in sl]):6.2f} dB; "
f"whole-chip mean power {db(np.nanmean(x)):6.2f} dB")
# --- Figures --------------------------------------------------------------------------
section("Figures")
VMIN, VMAX = -22, 2
ZOOM = np.s_[300:620, 40:560] # Oakham, the western lagoons and the reservoir shore
TRANSECT_ROW, TRANSECT_COLS = 545, (330, 700) # west to east across a lagoon, the reservoir edge and open water
def show(ax, x, title):
ax.imshow(db(x), cmap="gray", vmin=VMIN, vmax=VMAX, interpolation="nearest")
ax.set_title(title, fontsize=9)
ax.set_axis_off()
# 1. Study area
fig, axes = plt.subplots(1, 2, figsize=(11, 4.9))
show(axes[0], img, f"Sentinel-1 VV γ⁰, {target.datetime:%d %b %Y} (raw, 10 m)")
show(axes[1], ref, f"Mean of {len(items)} dates, Oct 2025 – Mar 2026 (reference)")
for ax in axes:
for k, (r, c) in enumerate(windows):
ax.add_patch(Rectangle((c, r), W, W, fill=False, ec="#fdae61", lw=1.2))
ax.text(c + W + 3, r + W // 2, str(k + 1), color="#fdae61", fontsize=7, va="center", fontweight="bold")
z = ZOOM
ax.add_patch(Rectangle((z[1].start, z[0].start), z[1].stop - z[1].start, z[0].stop - z[0].start,
fill=False, ec="#2b83ba", lw=1.2, ls="--"))
ax.plot(TRANSECT_COLS, [TRANSECT_ROW] * 2, color="#d7191c", lw=1.2)
axes[0].text(0.01, 0.01, "orange: homogeneous windows | blue: zoom | red: transect", transform=axes[0].transAxes,
color="white", fontsize=7, va="bottom", bbox=dict(fc="black", alpha=0.5, lw=0))
fig.savefig(OUT / "01-study-area-raw-and-reference.png")
plt.close(fig)
# 2. Speckle statistics
fig, axes = plt.subplots(1, 2, figsize=(10, 3.8))
ax = axes[0]
xg = np.linspace(0, 4, 400)
ax.hist(norm, bins=120, range=(0, 4), density=True, color="#abd9e9", label="pixels / window mean")
ax.plot(xg, stats.gamma.pdf(xg, L_fit, scale=1 / L_fit), color="#2c7bb6", lw=1.8, label=f"gamma, L = {L_fit:.2f} (fit)")
ax.plot(xg, stats.expon.pdf(xg), color="#d7191c", lw=1.2, ls="--", label="exponential (single look)")
ax.set(xlabel="Normalised intensity I / mean", ylabel="Density", title="Speckle in homogeneous windows: linear power")
ax.legend(fontsize=8)
ax = axes[1]
ndb = db(norm)
ax.hist(ndb, bins=100, range=(-8, 6), density=True, color="#fdae61")
ax.axvline(0, color="black", lw=1, label="mean power (0 dB)")
ax.axvline(ndb.mean(), color="#d7191c", lw=1.5, ls="--", label=f"mean of dB values ({ndb.mean():+.2f} dB)")
ax.set(xlabel="Normalised intensity (dB)", ylabel="Density", title="The same pixels in decibels: skewed and biased")
ax.legend(fontsize=8)
fig.savefig(OUT / "02-speckle-statistics.png")
plt.close(fig)
# 3. Filter comparison (zoom)
fig, axes = plt.subplots(2, 4, figsize=(12, 4.6))
panels = [("Raw", img)] + list(results.items()) + [("29-date reference", ref)]
for ax, (name, x) in zip(axes.ravel(), panels):
sub = f"ENL {score.loc[name, 'ENL']:.0f}, EPI {score.loc[name, 'EPI']:.2f}" if name in score.index else ""
show(ax, x[ZOOM], f"{name}\n{sub}" if sub else f"{name}\n(ENL {enl(x):.0f})")
fig.subplots_adjust(wspace=0.03, hspace=0.25)
fig.savefig(OUT / "03-filter-comparison.png")
plt.close(fig)
# 4. Transect across edges
fig, ax = plt.subplots(figsize=(9, 3.8))
cols = np.arange(*TRANSECT_COLS)
dist = (cols - cols[0]) * 10 / 1000
ax.plot(dist, db(img[TRANSECT_ROW, cols]), color="#bbb", lw=0.8, label="Raw")
colours = {"Boxcar 7×7": "#fdae61", "Lee": "#abdda4", "Refined Lee": "#2b83ba", "Gamma-MAP": "#d7191c"}
for name, col in colours.items():
ax.plot(dist, db(results[name][TRANSECT_ROW, cols]), color=col, lw=1.4, label=name)
ax.plot(dist, ref_db[TRANSECT_ROW, cols], color="black", lw=1.4, ls="--", label="29-date reference")
ax.set(xlabel="Distance along transect, west to east (km)", ylabel="VV γ⁰ (dB)",
title="A transect across a lagoon, the reservoir shore and open water")
ax.set_ylim(top=0)
ax.legend(fontsize=7, ncol=3, loc="upper right")
ax.grid(alpha=0.3)
fig.savefig(OUT / "04-edge-transect.png")
plt.close(fig)
# 5. Trade-off: ENL vs EPI, marker size = runtime
fig, ax = plt.subplots(figsize=(6.5, 4.4))
pal = ["#555555", "#fdae61", "#abdda4", "#2b83ba", "#5e3c99", "#1a9641", "#d7191c"]
for (name, r), col in zip(score.iterrows(), pal):
ax.scatter(r["ENL"], r["EPI"], s=30 + r["ms"] / 4, color=col, edgecolor="black", lw=0.5, zorder=3)
off = {"Frost": (-75, -40), "Gamma-MAP": (-35, -70), "Lee": (-60, 22), "Enhanced Lee": (5, -100)}.get(name)
ax.annotate(f"{name} ({r['ms']:.0f} ms)" if r["ms"] else name, (r["ENL"], r["EPI"]), xytext=off or (6, 4),
textcoords="offset points", fontsize=8,
arrowprops=dict(arrowstyle="-", color="#888", lw=0.6) if off else None)
ax.set(xlabel="ENL in homogeneous windows (higher = smoother)", ylabel="Edge-preservation index (higher = sharper)",
title="Speckle reduction against edge preservation (7 × 7 window)")
ax.set_xlim(0, score["ENL"].max() * 1.35)
ax.set_ylim(score["EPI"].min() - 0.05, score["EPI"].max() + 0.06)
ax.grid(alpha=0.3)
fig.savefig(OUT / "05-enl-vs-epi.png")
plt.close(fig)
# 6. Ratio images: structure that a filter removed shows up as visible edges
fig, axes = plt.subplots(2, 3, figsize=(11, 5))
for ax, (name, out) in zip(axes.ravel(), results.items()):
ax.imshow((img / out)[ZOOM], cmap="gray", vmin=0, vmax=2.5, interpolation="nearest")
ax.set_title(f"{name}: raw / filtered (std {score.loc[name, 'ratio std']:.2f})", fontsize=9)
ax.set_axis_off()
fig.subplots_adjust(wspace=0.03, hspace=0.18)
fig.savefig(OUT / "06-ratio-images.png")
plt.close(fig)
print("figures written:", ", ".join(sorted(p.name for p in OUT.glob("*.png"))))