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

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

ParameterValue
BandC-band (5.405 GHz, wavelength 5.55 cm)
Mode (default)IW (Interferometric Wide swath)
Resolution5 x 20m (range x azimuth)
Swath250 km
PolarizationVV + VH (dual-pol)
Revisit6 days (with constellation of 2)
Data accessCopernicus Data Space (free)
ProductsSLC, GRD, OCN

GRD vs SLC

ProductWhat It ContainsWhen To Use
GRD (Ground Range Detected)Amplitude only, multi-looked, georeferencedShip detection, backscatter analysis, most change detection
SLC (Single Look Complex)Complex (amplitude + phase), slant rangeInSAR, 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 rgb

Speckle 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 filtered

Demonstration 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

MechanismWhat It Looks LikeWhere It Occurs
Specular reflectionVery dark (energy reflects away)Calm water, smooth roads, runways
Surface scatteringMedium brightnessBare soil, rough ground, fields
Volume scatteringMedium-high (especially VH)Forests, dense vegetation
Double bounceVery brightBuildings at water/ground interface, corner reflectors

Interpreting Common Features

FeatureVV AppearanceVH AppearanceNotes
Calm waterVery dark (-25 to -20 dB)Very darkSpecular, no return
Rough seaMedium (-15 to -10 dB)Slightly brighterWind waves scatter
ShipsVery bright (0 to +10 dB)BrightMetal corner reflectors
Urban areaBright, texturedMedium-brightDouble bounce from buildings
ForestMedium (VV), medium-high (VH)Higher than VVVolume scattering in canopy
Bare soil (dry)Medium (-15 to -10 dB)LowSurface scattering
Bare soil (wet)Brighter than dryMediumHigher dielectric constant
Road/runwayDark (smooth)DarkSpecular

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_db

SAR 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, decrease

Coherence 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

  1. Download a Sentinel-1 GRD image covering a major port (search Copernicus Data Space)
  2. Load the VV band, apply Lee filter
  3. Run the CFAR ship detection
  4. 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

  1. Find temporally close Sentinel-1 and Sentinel-2 scenes of the same area
  2. Display both and compare: what features are visible in SAR but not optical? Vice versa?
  3. Identify buildings, water, vegetation, ships in both

Exercise 3: SAR Amplitude Change Detection

  1. Download two Sentinel-1 GRD scenes of a port area, 12 days apart
  2. Apply speckle filtering to both
  3. Compute amplitude ratio in dB
  4. Which ships are new? Which departed?
  5. Correlate with AIS data if available

Self-Test Questions

  1. Why does SAR use side-looking geometry instead of nadir?
  2. What is the physical cause of speckle, and why does it differ from noise?
  3. A building appears very bright in SAR. What scattering mechanism causes this?
  4. What is the advantage of coherence change detection over amplitude ratio?
  5. 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