How to convert SAR backscatter to decibels, and average it correctly
On this page
Problem statement
Radar backscatter comes in linear power units: small positive numbers, mostly between 0.001 and 1, with a long tail of very bright targets. Plotted as they are, they show a black image with a few white dots. Converted to decibels they become a readable range from about −25 to +5, and every threshold, colour scale and published value you will compare against is in decibels.
The conversion is one line. What goes wrong is everything done after it: averages, resampling and zonal statistics computed on decibels instead of power. Those are biased, and by an amount that depends on how speckled the data is. On a Sentinel-1 scene south of Valencia, a zonal mean computed in decibels is between 0.9 and 8.6 dB darker than the correct one, depending on the land cover.
Quick answer
Convert to decibels for display and thresholds, but do arithmetic in linear units:
import numpy as np
def to_db(linear):
with np.errstate(divide="ignore", invalid="ignore"):
return np.where(linear > 0, 10 * np.log10(linear), np.nan)
def from_db(db):
return 10 ** (db / 10)
def mean_db(linear, axis=None):
"""The right way to average backscatter: mean in linear, then convert."""
return to_db(np.nanmean(linear, axis=axis))
print(to_db(np.array([1.0, 0.1, 0.01, 0.0, -32768.0])))
print(from_db(np.array([0.0, -10.0, -20.0])))
[ 0. -10. -20. nan nan]
[1. 0.1 0.01]
Zero and the nodata value become NaN rather than -inf or an error. A difference of 10 dB is a factor of ten in power; 3 dB is a factor of two.
Mask, keep linear values, average and resample in power
1. Mask before you convert
Nodata (-32768 in Sentinel-1 RTC) and zeros must become NaN first. The logarithm of zero is minus infinity and of a negative number is undefined, and both silently spread through later statistics.
2. Keep a linear copy
Keep the linear array alongside the decibel one, or convert back with 10 ** (db / 10) before any arithmetic.
3. Average in linear units
Means over zones, over time and over neighbourhoods are computed on power and converted to decibels at the end. The mean of decibels is the geometric mean, which is always lower.
4. Resample in linear units
Downsampling with average resampling is an average; do it on linear values. Nearest-neighbour resampling does not average and can be done in either.
5. Subtract decibels for ratios
A ratio of two powers — post over pre, VH over VV — is a difference in decibels. That is the one arithmetic operation decibels make easier.
6. Label the units
Name files and variables with the unit: vv_db, vv_lin. The two look nothing alike numerically, which is some protection, but not enough when an average of the wrong one is 2 dB off.
Zonal means and downsampling done both ways
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 — zonal means done both ways
import pystac_client, planetary_computer, rasterio
from rasterio.enums import Resampling
from rasterio.vrt import WarpedVRT
from sar_io import STAC, search, passes, grid, read_pass
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)
cat = pystac_client.Client.open(STAC, 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)
for code, name in {80: "open water", 40: "cropland", 30: "grassland", 10: "tree cover", 50: "built-up"}.items():
m = (wc == code) & np.isfinite(vv)
right, wrong = mean_db(vv[m]), np.nanmean(to_db(vv[m]))
print(f"{name:10} linear mean {right:6.2f} dB mean of dB {wrong:6.2f} dB error {wrong - right:+.2f}")
open water linear mean -10.80 dB mean of dB -19.37 dB error -8.57
cropland linear mean -8.71 dB mean of dB -9.62 dB error -0.91
grassland linear mean -7.69 dB mean of dB -9.76 dB error -2.07
tree cover linear mean -7.67 dB mean of dB -9.03 dB error -1.36
built-up linear mean 1.02 dB mean of dB -3.92 dB error -4.94
Every class comes out darker, from 0.9 dB for cropland to 8.6 dB for open water. The water figure is the reminder that a correct mean can still be the wrong statistic: a few very bright pixels in the class — boats, reed beds, the lagoon's edges, one of them at +33 dB — dominate its linear mean. For skewed zones report the median, which is the same whichever scale it is taken in.
Example 2 — downsampling to 40 m both ways
def block_mean(a, k):
h, w = (a.shape[0] // k) * k, (a.shape[1] // k) * k
return np.nanmean(a[:h, :w].reshape(h // k, k, w // k, k), axis=(1, 3))
good = to_db(block_mean(vv, 4))
bad = block_mean(to_db(vv), 4)
d = bad - good
print(f"40 m cells: mean error {np.nanmean(d):+.2f} dB, worst {np.nanmin(d):+.2f} dB")
40 m cells: mean error -0.37 dB, worst -9.37 dB
A 0.37 dB average error looks small until the worst cell: 9.4 dB, in a block mixing very bright and very dark pixels — the only way the two averages can drift that far apart.
Example 3 — the ratio is a subtraction
vh = read_pass(next(iter(passes(search(bbox, "2024-10-20/2024-10-20", orbit=110)).values())), "vh", g)
a = to_db(vh) - to_db(vv)
b = to_db(vh / vv)
print(f"largest difference between the two: {np.nanmax(np.abs(a - b)):.1e} dB (float32 rounding)")
largest difference between the two: 7.6e-06 dB (float32 rounding)
Why the mean of decibels is too low
Why decibels at all
Backscatter spans several orders of magnitude within one scene: calm water returns a thousandth of what a building corner does. A logarithmic scale compresses that into a range that fits a colour ramp and turns multiplicative effects — speckle, calibration factors, ratios — into additive ones. Thresholds are therefore quoted in decibels almost universally.
Why the mean of decibels is too low
The logarithm is concave, so by Jensen's inequality the average of logs is at most the log of the average, with equality only if every value is the same. Speckle guarantees they are not. The more variable the backscatter within the zone, the larger the gap — which is why the built-up and water classes, the most variable, show the largest errors.
Why the error is not a constant you can subtract
The bias depends on the distribution within each zone: on speckle, on how many looks the data has, and on the real texture of the surface. It differs by land cover, by date and by how much filtering came before. Correcting it after the fact is harder than averaging correctly in the first place.
Amplitude versus power, medians and integer storage
- Amplitude vs power. Some products store amplitude; decibels are then 20·log₁₀, not 10·log₁₀.
- Medians commute with the logarithm, so a median in decibels equals the decibels of the median.
- Positive decibels are legitimate for corner reflectors, ships and some buildings.
- Integer storage. Some archives store decibels scaled to integers; check the scale factor.
- Colour ramps usually span −25 to 0 dB for VV and −30 to −5 dB for VH.
- Standard deviations in dB and in linear units describe different things; say which.
Internal links
- How to read Sentinel-1 RTC data in Python — getting the linear values
- Speckle explained — why values within a zone vary
- SAR values come out negative, huge or averaged wrongly — the fix guide
- How to make a Sentinel-1 composite over a period — temporal averages
- How to calculate zonal statistics in Python — zonal means in general
- SAR polarisation explained — the VH/VV ratio
FAQ
How do I convert Sentinel-1 backscatter to dB?
10 * log10(value) for power values, after setting nodata and zeros to NaN.
Should I average in dB?
No. Average in linear units and convert the result to decibels.
Why is my dB mean lower than expected?
The mean of decibels is the geometric mean, which is always below the arithmetic mean on speckled data.
Is the median affected?
No. The median is the same whichever scale you take it in.
How do I compute a ratio of two SAR images?
Subtract their decibel values, or divide their linear values.
What does a positive dB value mean?
More power returned than a perfect diffuse reflector would give, typical of corner reflectors like buildings and ships.