SAR Fundamentals and Analysis
SAR (Synthetic Aperture Radar) is the second pillar of satellite remote sensing. While optical imagery gives you color and texture in clear weather, SAR penetrates clouds, works at night, and reveals surface properties invisible to the eye. In Northern Europe where cloud cover averages 60-70%, SAR is not a supplement to optical — it is often the only data you have.
Key References
- Cumming & Wong — Digital Processing of Synthetic Aperture Radar Data: Algorithms and Implementation (Artech House, 2005) — The definitive SAR processing text
- Bamler & Hartl — “Synthetic aperture radar interferometry” (Inverse Problems, 1998) — InSAR theory
- Sentinel-1 Product Specification: https://sentinel.esa.int/documents/247904/685163/Sentinel-1-Product-Specification
- ASF Sentinel-1 Handbook: https://asf.alaska.edu/sar-information/sentinel-1-datascope/
Connection to EW-Recon: SAR uses the same radar principles from your EW course — transmit power, antenna gain, pulse compression, Doppler processing. The difference: SAR uses the satellite’s motion to synthesize a very large antenna aperture.
Connection to DSP: SAR image formation is matched filtering in 2D. Range compression (your pulse compression topic) and azimuth compression (Doppler filtering) produce the focused image from raw phase-history data.
SAR Imaging Recap
Side-Looking Geometry
SAR looks sideways, not straight down. The antenna points to one side of the flight path at an incidence angle (typically 20-45 degrees from vertical).
Why side-looking? If the radar pointed straight down (nadir), you couldn’t distinguish targets on the left from targets on the right at the same range. Side-looking creates a one-to-one mapping between time delay and ground position.
Resolution
Range resolution — determined by bandwidth (not antenna size):
Range_resolution = c / (2 * bandwidth)
Sentinel-1 C-band bandwidth ~56 MHz → range resolution ~2.7m in slant range, ~5m on ground.
Azimuth resolution — determined by synthetic aperture length:
Azimuth_resolution = antenna_length / 2
Real antenna ~12m → azimuth resolution ~6m. This is independent of range — same resolution whether the target is 10km or 1000km away.
Sentinel-1: Your Free SAR Source
| Parameter | Value |
|---|---|
| Band | C-band (5.405 GHz, wavelength 5.55 cm) |
| Mode (default) | IW (Interferometric Wide swath) |
| Resolution | 5 x 20m (range x azimuth) |
| Swath | 250 km |
| Polarization | VV + VH (dual-pol) |
| Revisit | 6 days (with constellation of 2) |
| Data access | Copernicus Data Space (free) |
| Products | SLC, GRD, OCN |
GRD vs SLC
| Product | What It Contains | When To Use |
|---|---|---|
| GRD (Ground Range Detected) | Amplitude only, multi-looked, georeferenced | Ship detection, backscatter analysis, most change detection |
| SLC (Single Look Complex) | Complex (amplitude + phase), slant range | InSAR, coherence, polarimetric analysis |
Start with GRD. It’s simpler, already ground-projected, and sufficient for most intelligence applications.
Loading and Displaying SAR Data
import rasterio
import numpy as np
import matplotlib.pyplot as plt
def load_sentinel1_grd(vv_path, vh_path=None):
"""
Load Sentinel-1 GRD data.
Returns calibrated backscatter in dB.
"""
with rasterio.open(vv_path) as src:
vv = src.read(1).astype(np.float32)
transform = src.transform
crs = src.crs
# Sentinel-1 GRD values are already in linear power
# (if from SNAP or Copernicus preprocessed data).
# Raw products need radiometric calibration: sigma0 = DN^2 / A^2
# where A is from the calibration LUT in the product.
# Convert to dB for display
vv_db = 10 * np.log10(np.maximum(vv, 1e-10))
result = {"vv": vv, "vv_db": vv_db, "transform": transform, "crs": crs}
if vh_path:
with rasterio.open(vh_path) as src:
vh = src.read(1).astype(np.float32)
vh_db = 10 * np.log10(np.maximum(vh, 1e-10))
result["vh"] = vh
result["vh_db"] = vh_db
return result
def display_sar(data, title="SAR", vmin=-25, vmax=5, cmap="gray"):
"""Display SAR amplitude in dB scale."""
fig, ax = plt.subplots(figsize=(10, 10))
im = ax.imshow(data, cmap=cmap, vmin=vmin, vmax=vmax)
ax.set_title(title)
plt.colorbar(im, ax=ax, label="Backscatter (dB)")
ax.axis("off")
plt.tight_layout()
return fig
# Usage:
# data = load_sentinel1_grd("s1_vv.tif", "s1_vh.tif")
# display_sar(data["vv_db"], "Sentinel-1 VV")
# plt.show()SAR Visualization Tips
SAR data needs different treatment than optical:
def sar_rgb_composite(vv, vh):
"""
Create false-color RGB from dual-pol SAR.
Red = VV, Green = VH, Blue = VV/VH ratio.
Reveals: urban (bright VV) vs vegetation (high VH) vs water (dark all).
"""
vv_db = 10 * np.log10(np.maximum(vv, 1e-10))
vh_db = 10 * np.log10(np.maximum(vh, 1e-10))
ratio_db = vv_db - vh_db # VV/VH ratio in dB
# Normalize to 0-1 for display
def normalize(arr, lo, hi):
return np.clip((arr - lo) / (hi - lo), 0, 1)
rgb = np.stack([
normalize(vv_db, -25, 0), # Red = VV
normalize(vh_db, -30, -5), # Green = VH
normalize(ratio_db, 0, 15), # Blue = VV/VH ratio
], axis=-1)
return rgbSpeckle Filtering
Speckle is inherent to coherent imaging. Every SAR image has it. You must filter it before analysis.
import numpy as np
from scipy.ndimage import uniform_filter, generic_filter
def lee_filter(img, size=7):
"""
Lee filter — standard SAR speckle filter.
Preserves edges better than simple averaging.
"""
img = img.astype(np.float64)
img_mean = uniform_filter(img, size)
img_sqr_mean = uniform_filter(img ** 2, size)
img_var = img_sqr_mean - img_mean ** 2
img_var = np.maximum(img_var, 0)
overall_var = np.var(img)
noise_var = overall_var / (size * size)
weight = img_var / (img_var + noise_var + 1e-10)
weight = np.clip(weight, 0, 1)
return img_mean + weight * (img - img_mean)
def multilook(img, n_looks_range=2, n_looks_azimuth=2):
"""
Spatial multilooking: average neighboring pixels.
Reduces speckle by sqrt(N_looks) factor.
Trades resolution for noise reduction.
"""
h, w = img.shape
new_h = h // n_looks_azimuth
new_w = w // n_looks_range
cropped = img[:new_h * n_looks_azimuth, :new_w * n_looks_range]
reshaped = cropped.reshape(new_h, n_looks_azimuth, new_w, n_looks_range)
return reshaped.mean(axis=(1, 3))
def gamma_map_filter(img, size=7):
"""
Gamma MAP filter — maximum a posteriori filter assuming
gamma-distributed speckle (more accurate for SAR than Lee).
"""
img = img.astype(np.float64)
n_looks = 4.4 # approximate ENL for Sentinel-1 IW GRD
img_mean = uniform_filter(img, size)
img_sqr_mean = uniform_filter(img ** 2, size)
img_var = img_sqr_mean - img_mean ** 2
img_var = np.maximum(img_var, 0)
# Coefficient of variation
ci_sq = img_var / (img_mean ** 2 + 1e-10)
cu_sq = 1.0 / n_looks # speckle-only CV^2
# Gamma MAP weight
alpha = (1 + cu_sq) / (ci_sq - cu_sq + 1e-10)
alpha = np.clip(alpha, 0, size * size)
filtered = img_mean + (img - img_mean) * np.maximum(0, 1 - cu_sq / (ci_sq + 1e-10))
return filteredDemonstration with Synthetic Speckle
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
size = 300
# Create truth image (in linear power units)
truth = np.ones((size, size)) * 0.1 # background
truth[50:120, 50:120] = 1.0 # building (high backscatter)
truth[150:250, 100:200] = 0.01 # water (low backscatter)
truth[80:90, 180:280] = 0.5 # road
# Apply multiplicative speckle (exponential distribution for single-look)
n_looks = 4
speckled = truth * np.random.gamma(n_looks, 1.0/n_looks, (size, size))
# Filter
filtered_lee = lee_filter(speckled, size=7)
filtered_multi = multilook(speckled, 3, 3)
fig, axes = plt.subplots(1, 4, figsize=(20, 5))
for ax, data, title in zip(axes,
[10*np.log10(truth+1e-10), 10*np.log10(speckled+1e-10),
10*np.log10(filtered_lee+1e-10), 10*np.log10(filtered_multi+1e-10)],
["Truth", "Speckled", "Lee Filter (7x7)", "Multilook (3x3)"]):
ax.imshow(data, cmap="gray", vmin=-25, vmax=5)
ax.set_title(title)
ax.axis("off")
plt.suptitle("SAR Speckle Filtering Comparison", fontsize=14)
plt.tight_layout()
plt.savefig("speckle_filtering.png", dpi=150)
plt.show()Backscatter Interpretation
What appears bright or dark in SAR depends on surface roughness, geometry, and moisture.
Backscatter Mechanisms
| Mechanism | What It Looks Like | Where It Occurs |
|---|---|---|
| Specular reflection | Very dark (energy reflects away) | Calm water, smooth roads, runways |
| Surface scattering | Medium brightness | Bare soil, rough ground, fields |
| Volume scattering | Medium-high (especially VH) | Forests, dense vegetation |
| Double bounce | Very bright | Buildings at water/ground interface, corner reflectors |
Interpreting Common Features
| Feature | VV Appearance | VH Appearance | Notes |
|---|---|---|---|
| Calm water | Very dark (-25 to -20 dB) | Very dark | Specular, no return |
| Rough sea | Medium (-15 to -10 dB) | Slightly brighter | Wind waves scatter |
| Ships | Very bright (0 to +10 dB) | Bright | Metal corner reflectors |
| Urban area | Bright, textured | Medium-bright | Double bounce from buildings |
| Forest | Medium (VV), medium-high (VH) | Higher than VV | Volume scattering in canopy |
| Bare soil (dry) | Medium (-15 to -10 dB) | Low | Surface scattering |
| Bare soil (wet) | Brighter than dry | Medium | Higher dielectric constant |
| Road/runway | Dark (smooth) | Dark | Specular |
Polarimetric Ratio
def polarimetric_ratio(vv, vh):
"""
VH/VV ratio in dB. Useful for:
- High ratio (> -6 dB): vegetation (volume scattering)
- Low ratio (< -12 dB): urban/metal (double bounce in VV)
- Very low ratio: water (both low, but VH lower)
"""
vv_db = 10 * np.log10(np.maximum(vv, 1e-10))
vh_db = 10 * np.log10(np.maximum(vh, 1e-10))
return vh_db - vv_dbSAR Change Detection
Amplitude Ratio
Instead of subtracting (which doesn’t work well with multiplicative speckle), use the ratio:
import numpy as np
def sar_amplitude_ratio(before, after, threshold_db=3.0):
"""
SAR change detection using amplitude ratio.
Ratio > threshold: new bright features (construction, ship arrival)
Ratio < -threshold: features disappeared (ship departure, water flooding)
"""
before = np.maximum(before, 1e-10).astype(np.float32)
after = np.maximum(after, 1e-10).astype(np.float32)
ratio_db = 10 * np.log10(after / before)
increase = ratio_db > threshold_db
decrease = ratio_db < -threshold_db
return ratio_db, increase, decreaseCoherence Change Detection
InSAR coherence measures how similar the radar signal is between two acquisitions. If the surface changes, coherence drops.
def compute_coherence(slc1, slc2, window_size=5):
"""
Compute InSAR coherence between two SLC images.
slc1, slc2: complex-valued 2D arrays (SLC data)
Returns: coherence magnitude (0-1)
High coherence (>0.7): surface unchanged
Low coherence (<0.3): surface changed significantly
"""
from scipy.ndimage import uniform_filter
# Cross-correlation
cross = slc1 * np.conj(slc2)
cross_smooth = uniform_filter(cross.real, window_size) + \
1j * uniform_filter(cross.imag, window_size)
# Power of each
power1 = uniform_filter(np.abs(slc1)**2, window_size)
power2 = uniform_filter(np.abs(slc2)**2, window_size)
# Coherence
coherence = np.abs(cross_smooth) / np.sqrt(power1 * power2 + 1e-10)
return np.clip(coherence, 0, 1)Intelligence value of coherence:
- Persistent scatterers (buildings, infrastructure) maintain high coherence over months
- If coherence drops in a normally stable area → something changed on the ground
- Advantage over amplitude: coherence detects SUBTLE changes (disturbed soil, new paint, moved equipment) that don’t necessarily change brightness
InSAR Basics
InSAR (Interferometric SAR) uses the phase difference between two SAR acquisitions to measure elevation or surface displacement.
DEM Generation
Two SAR images from slightly different positions → phase difference proportional to topographic height.
Deformation Measurement
Two SAR images from the same orbit but different dates → phase difference reveals ground movement (subsidence, uplift, earthquake displacement) with millimeter precision.
import numpy as np
import matplotlib.pyplot as plt
# Simulate interferogram
np.random.seed(42)
size = 300
# Topographic phase (simulated terrain)
x, y = np.meshgrid(np.linspace(-1, 1, size), np.linspace(-1, 1, size))
topo_phase = 20 * np.exp(-(x**2 + y**2) / 0.3) # hill
# Deformation phase (simulated subsidence bowl)
deformation = -8 * np.exp(-((x-0.3)**2 + (y+0.2)**2) / 0.05)
# Total interferometric phase
total_phase = topo_phase + deformation
# Wrap to [-pi, pi] (what you actually measure)
wrapped_phase = np.angle(np.exp(1j * total_phase))
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
axes[0].imshow(topo_phase, cmap="hsv")
axes[0].set_title("Topographic Phase (unwrapped)")
axes[1].imshow(wrapped_phase, cmap="hsv")
axes[1].set_title("Wrapped Interferogram (measured)")
axes[2].imshow(deformation, cmap="RdBu", vmin=-10, vmax=2)
axes[2].set_title("Deformation Phase (what we want)")
plt.colorbar(axes[2].images[0], ax=axes[2], label="radians")
for ax in axes:
ax.axis("off")
plt.suptitle("InSAR: Separating Topography from Deformation", fontsize=14)
plt.tight_layout()
plt.savefig("insar_demo.png", dpi=150)
plt.show()
# Phase to displacement conversion
wavelength = 0.0555 # m (Sentinel-1 C-band)
# For line-of-sight deformation:
# displacement = (phase * wavelength) / (4 * pi)
max_deformation_rad = np.min(deformation)
displacement_m = (max_deformation_rad * wavelength) / (4 * np.pi)
print(f"Max simulated subsidence: {displacement_m*1000:.1f} mm")Ship Detection in SAR
Ships are one of the most reliably detectable targets in SAR. Metal hulls act as corner reflectors, producing very bright returns against dark water.
import numpy as np
from scipy.ndimage import label, binary_opening
def detect_ships_sar(sar_amplitude, water_mask=None, cfar_guard=10,
cfar_window=30, threshold_factor=5):
"""
CFAR-like ship detection in SAR imagery.
Constant False Alarm Rate: pixel is a target if it exceeds
the local background by threshold_factor * local_std.
"""
from scipy.ndimage import uniform_filter
sar = sar_amplitude.astype(np.float64)
# If no water mask provided, estimate from backscatter
if water_mask is None:
sar_db = 10 * np.log10(sar + 1e-10)
water_mask = sar_db < -18 # water threshold
# Local statistics (background estimation)
# Use a ring: outer window - inner guard window
local_mean_outer = uniform_filter(sar, cfar_window)
local_mean_inner = uniform_filter(sar, cfar_guard)
n_outer = cfar_window ** 2
n_inner = cfar_guard ** 2
n_ring = n_outer - n_inner
# Background mean (ring only)
bg_mean = (local_mean_outer * n_outer - local_mean_inner * n_inner) / n_ring
local_sqr_outer = uniform_filter(sar ** 2, cfar_window)
local_sqr_inner = uniform_filter(sar ** 2, cfar_guard)
bg_sqr = (local_sqr_outer * n_outer - local_sqr_inner * n_inner) / n_ring
bg_std = np.sqrt(np.maximum(bg_sqr - bg_mean ** 2, 0))
# Detection: pixel exceeds background + threshold
threshold = bg_mean + threshold_factor * bg_std
detections = (sar > threshold) & water_mask
# Cleanup: remove isolated pixels
detections = binary_opening(detections, iterations=1)
# Label connected components
labeled, n_ships = label(detections)
ships = []
for i in range(1, n_ships + 1):
ship_pixels = np.argwhere(labeled == i)
if len(ship_pixels) < 3: # too small
continue
centroid = ship_pixels.mean(axis=0)
area = len(ship_pixels)
max_amplitude = sar[labeled == i].max()
ships.append({
"id": i,
"centroid_rowcol": centroid,
"area_pixels": area,
"max_amplitude_db": 10 * np.log10(max_amplitude + 1e-10),
})
return detections, ships
# Demo with synthetic data
np.random.seed(42)
size = 400
# Sea background (low backscatter + speckle)
sea = np.random.exponential(0.01, (size, size))
# Add ships (bright point targets)
ship_positions = [(100, 150), (200, 80), (250, 300), (350, 200), (50, 350)]
for row, col in ship_positions:
# Ship: ~10x30 pixel footprint at 10m resolution = 100x300m
ship_len = np.random.randint(5, 15)
ship_wid = np.random.randint(2, 5)
sea[row:row+ship_wid, col:col+ship_len] = np.random.exponential(
2.0, (ship_wid, ship_len)
)
# Detect
water = np.ones((size, size), dtype=bool) # all water in this example
detections, ships = detect_ships_sar(sea, water_mask=water)
fig, axes = plt.subplots(1, 3, figsize=(15, 5))
axes[0].imshow(10*np.log10(sea+1e-10), cmap="gray", vmin=-25, vmax=5)
axes[0].set_title("SAR Image (dB)")
axes[1].imshow(detections, cmap="Reds")
axes[1].set_title(f"Detections ({len(ships)} ships)")
# Overlay
overlay = np.stack([10*np.log10(sea+1e-10)]*3, axis=-1)
overlay = (overlay - (-25)) / 30
overlay = np.clip(overlay, 0, 1)
overlay[detections, 0] = 1
overlay[detections, 1] = 0
overlay[detections, 2] = 0
axes[2].imshow(overlay)
axes[2].set_title("SAR + Detections Overlay")
for ax in axes:
ax.axis("off")
plt.tight_layout()
plt.savefig("sar_ship_detection.png", dpi=150)
plt.show()
for s in ships:
print(f"Ship {s['id']}: center=({s['centroid_rowcol'][0]:.0f}, "
f"{s['centroid_rowcol'][1]:.0f}), {s['area_pixels']} pixels, "
f"{s['max_amplitude_db']:.1f} dB")Exercises
Exercise 1: Detect Ships in a Harbor
- Download a Sentinel-1 GRD image covering a major port (search Copernicus Data Space)
- Load the VV band, apply Lee filter
- Run the CFAR ship detection
- How many ships detected? How does the result compare with visual inspection in Copernicus Browser?
Exercise 2: Compare SAR and Optical of the Same Area
- Find temporally close Sentinel-1 and Sentinel-2 scenes of the same area
- Display both and compare: what features are visible in SAR but not optical? Vice versa?
- Identify buildings, water, vegetation, ships in both
Exercise 3: SAR Amplitude Change Detection
- Download two Sentinel-1 GRD scenes of a port area, 12 days apart
- Apply speckle filtering to both
- Compute amplitude ratio in dB
- Which ships are new? Which departed?
- Correlate with AIS data if available
Self-Test Questions
- Why does SAR use side-looking geometry instead of nadir?
- What is the physical cause of speckle, and why does it differ from noise?
- A building appears very bright in SAR. What scattering mechanism causes this?
- What is the advantage of coherence change detection over amplitude ratio?
- Sentinel-1 C-band has wavelength 5.55cm. Does it penetrate vegetation canopy? Under what conditions?
See also: Sensor Types and Imagery | Change Detection | Case Study - Maritime Domain Awareness Next: Tutorial - Object Detection in Satellite Imagery