Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

earth and related environmental sciences

Sentinel-1 SLC Burst GCP Geolocation with EOPFZARR Driver

Ground Control Point (GCP) based geolocation for Sentinel-1 SLC burst subdatasets using the EOPFZARR GDAL driver

European Space Agency
ESA EOPF Zarr Logo

Sentinel-1 SLC Burst GCP Geolocation with EOPFZARR Driver

🚀 Launch in JupyterHub

Run this notebook interactively with all dependencies pre-installed

Introduction

Sentinel-1 SLC (Single Look Complex) products in IW (Interferometric Wide) mode store data as individual bursts — short, overlapping strips within three subswaths (IW1, IW2, IW3). Each burst subdataset carries its own Ground Control Points (GCPs) for geolocation.

GCPs are a sparse grid of pixel/line → longitude/latitude/height correspondences stored in conditions/gcp/ arrays within the EOPF Zarr structure:

  • pixel and line define image-space positions

  • latitude, longitude, height define geographic positions

The EOPFZARR driver reads these arrays and automatically attaches them as GDAL GCPs when you open a burst subdataset with the BURST open option. This enables:

  • Pixel-to-geographic coordinate transformation via GDAL’s GCP transformer

  • Reprojection to WGS 84 (or any CRS) using gdal.Warp()

  • Correct display in GIS tools like QGIS

See also: Sentinel-1 SLC Burst Selection for burst structure exploration, and Sentinel-1 GRD GCP Geolocation for the equivalent GCP workflow on GRD products.

Setup

import numpy as np
import matplotlib.pyplot as plt
from osgeo import gdal, osr

gdal.UseExceptions()

print(f"GDAL version: {gdal.__version__}")

drv = gdal.GetDriverByName("EOPFZARR")
if drv:
    print("EOPFZARR driver: Registered")
else:
    print("WARNING: EOPFZARR driver not found!")
GDAL version: 3.12.0dev-209c099c56
EOPFZARR driver: Registered
/home/yadagale/.local/lib/python3.10/site-packages/matplotlib/projections/__init__.py:63: UserWarning: Unable to import Axes3D. This may be due to multiple versions of Matplotlib being installed (e.g. as a system package and as a pip package). As a result, the 3D projection is not available.
  warnings.warn("Unable to import Axes3D. This may be due to multiple versions of "

Dataset Configuration

We use a Sentinel-1C IW SLC product with dual VV/VH polarization, stored in EOPF Zarr format. The product is hosted on the EODC notebook-data stable bucket.

SLC_URL = (
    "/vsicurl/https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:"
    "notebook-data/tutorial_data/cpm_v262/"
    "S1C_IW_SLC__1SDV_20251016T165627_20251016T165654_004590_00913B_30C4.zarr"
)

zarr_path = f"EOPFZARR:'{SLC_URL}'"

print("Dataset: S1C_IW_SLC__1SDV")
print("  Platform:      Sentinel-1C")
print("  Mode:          IW (Interferometric Wide)")
print("  Level:         L1 SLC")
print("  Polarizations: VV, VH")
print("  Date:          2025-10-16")
Dataset: S1C_IW_SLC__1SDV
  Platform:      Sentinel-1C
  Mode:          IW (Interferometric Wide)
  Level:         L1 SLC
  Polarizations: VV, VH
  Date:          2025-10-16

Open an SLC Burst Subdataset

Use the BURST open option to directly open a single burst by its friendly name: {subswath}_{polarization}_{index} (e.g. IW1_VV_001).

GCPs are automatically attached to the opened burst dataset.

# Open burst IW1_VV_001 using the BURST open option
ds = gdal.OpenEx(
    zarr_path,
    gdal.OF_RASTER | gdal.OF_READONLY,
    open_options=["BURST=IW1_VV_001"],
)

assert ds is not None, "Failed to open SLC burst"

print("Burst:     IW1_VV_001")
print(f"Size:      {ds.RasterXSize} x {ds.RasterYSize} px")
print(f"Bands:     {ds.RasterCount}")
print(f"Data type: {gdal.GetDataTypeName(ds.GetRasterBand(1).DataType)}")
print(f"GCP count: {ds.GetGCPCount()}")

srs = ds.GetGCPSpatialRef()
if srs:
    print(f"GCP SRS:   {srs.GetName()} (EPSG:{srs.GetAuthorityCode(None)})")
Burst:     IW1_VV_001
Size:      23261 x 1501 px
Bands:     1
Data type: CFloat32
GCP count: 210
GCP SRS:   WGS 84 (EPSG:4326)

Inspect GCPs

The driver automatically reads GCPs from the burst’s conditions/gcp/ arrays.

gcp_count = ds.GetGCPCount()
print(f"GCP Count: {gcp_count}")

gcp_srs = ds.GetGCPSpatialRef()
if gcp_srs:
    print(f"GCP SRS:   {gcp_srs.GetName()} (EPSG:{gcp_srs.GetAuthorityCode(None)})")

gcps = ds.GetGCPs()
lons = [g.GCPX for g in gcps]
lats = [g.GCPY for g in gcps]
print(f"Lon range: [{min(lons):.4f}, {max(lons):.4f}]")
print(f"Lat range: [{min(lats):.4f}, {max(lats):.4f}]")

print("\nFirst 10 GCPs:")
print(f"  {'ID':<6} {'Pixel':>8} {'Line':>8} {'Lon':>10} {'Lat':>10} {'Height':>8}")
for gcp in gcps[:10]:
    print(
        f"  {gcp.Id:<6} {gcp.GCPPixel:>8.1f} {gcp.GCPLine:>8.1f} "
        f"{gcp.GCPX:>10.4f} {gcp.GCPY:>10.4f} {gcp.GCPZ:>8.1f}"
    )
GCP Count: 210
GCP SRS:   WGS 84 (EPSG:4326)
Lon range: [12.9673, 14.4821]
Lat range: [40.0726, 41.7453]

First 10 GCPs:
  ID        Pixel     Line        Lon        Lat   Height
  GCP_1       0.5      0.5    13.3555    40.0726      0.0
  GCP_2    1163.5      0.5    13.4168    40.0816      0.0
  GCP_3    2326.5      0.5    13.4774    40.0905      0.0
  GCP_4    3489.5      0.5    13.5374    40.0993      0.0
  GCP_5    4652.5      0.5    13.5969    40.1080      0.0
  GCP_6    5815.5      0.5    13.6557    40.1166      0.0
  GCP_7    6978.5      0.5    13.7140    40.1250      0.0
  GCP_8    8141.5      0.5    13.7717    40.1334      0.0
  GCP_9    9304.5      0.5    13.8290    40.1416      0.0
  GCP_10  10467.5      0.5    13.8858    40.1497      0.0

Visualize GCP Grid

The GCPs form a sparse grid across the burst. Let’s visualize their distribution in both image space (pixel/line) and geographic space (lon/lat).

pixels = [gcp.GCPPixel for gcp in gcps]
lines = [gcp.GCPLine for gcp in gcps]

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Image space
axes[0].scatter(pixels, lines, c="red", s=20, alpha=0.8)
axes[0].set_xlim(0, ds.RasterXSize)
axes[0].set_ylim(ds.RasterYSize, 0)
axes[0].set_xlabel("Pixel (column)")
axes[0].set_ylabel("Line (row)")
axes[0].set_title("GCP Positions — Image Space")
axes[0].set_aspect("auto")

# Geographic space
axes[1].scatter(lons, lats, c="blue", s=20, alpha=0.8)
axes[1].set_xlabel("Longitude (°)")
axes[1].set_ylabel("Latitude (°)")
axes[1].set_title("GCP Positions — Geographic Space (WGS 84)")
axes[1].grid(True, alpha=0.3)

plt.suptitle(f"SLC Burst IW1_VV_001 — {gcp_count} GCPs", fontweight="bold")
plt.tight_layout()
plt.show()
<Figure size 1400x500 with 2 Axes>

Pixel-to-Geographic Coordinate Transformation

With GCPs attached, GDAL can transform between pixel/line coordinates and geographic coordinates using a polynomial GCP transformer.

# Create a GCP-based transformer
transformer = gdal.Transformer(ds, None, ["METHOD=GCP_POLYNOMIAL", "MAX_GCP_ORDER=3"])

# Transform the four image corners to geographic coordinates
corners = [
    ("Top-Left", 0, 0),
    ("Top-Right", ds.RasterXSize, 0),
    ("Bottom-Left", 0, ds.RasterYSize),
    ("Bottom-Right", ds.RasterXSize, ds.RasterYSize),
]

print(f"{'Corner':<14} {'Pixel':>8} {'Line':>8} {'Lon':>10} {'Lat':>10}")
print("-" * 55)
for name, px, ln in corners:
    ok, result = transformer.TransformPoint(0, px, ln, 0)
    if ok:
        print(f"{name:<14} {px:>8} {ln:>8} {result[0]:>10.4f} {result[1]:>10.4f}")

transformer = None
Corner            Pixel     Line        Lon        Lat
-------------------------------------------------------
Top-Left              0        0    13.3555    40.0710
Top-Right         23261        0    14.4823    40.2318
Bottom-Left           0     1501    13.3135    40.2397
Bottom-Right      23261     1501    14.4423    40.4005

Warp Burst to Geographic Projection

Using the GCPs, we can reproject the SLC amplitude to WGS 84. SLC data is complex-valued (CFloat32); we compute amplitude for visualization.

# Read the complex burst data and compute amplitude
raw = ds.GetRasterBand(1).ReadAsArray(buf_xsize=512, buf_ysize=0)
amplitude = np.abs(raw).astype(np.float32)

# Warp to WGS 84 using GCPs (reduced resolution to keep remote I/O fast)
warped = gdal.Warp(
    "",
    ds,
    format="MEM",
    dstSRS="EPSG:4326",
    width=512,
    height=0,
    resampleAlg="near",
    outputType=gdal.GDT_CFloat32,
)

assert warped is not None, "Warp failed"
gt = warped.GetGeoTransform()
print(f"Warped size:  {warped.RasterXSize} x {warped.RasterYSize} px")
print(f"Origin:       lon={gt[0]:.4f}  lat={gt[3]:.4f}")
print(f"Pixel size:   {gt[1]:.6f}° x {abs(gt[5]):.6f}°")

# Compute amplitude in dB for display
raw_warped = warped.GetRasterBand(1).ReadAsArray()
amp = np.abs(raw_warped).astype(np.float32)
amp_db = 20 * np.log10(np.where(amp > 0, amp, np.nan))

extent = [
    gt[0],
    gt[0] + gt[1] * warped.RasterXSize,
    gt[3] + gt[5] * warped.RasterYSize,
    gt[3],
]

fig, ax = plt.subplots(figsize=(10, 6))
vmin, vmax = np.nanpercentile(amp_db, [2, 98])
im = ax.imshow(amp_db, cmap="gray", vmin=vmin, vmax=vmax, extent=extent, aspect="auto")
plt.colorbar(im, ax=ax, label="Amplitude (dB)")
ax.set_title(
    "Sentinel-1 SLC Burst IW1_VV_001 — GCP-warped to WGS 84", fontweight="bold"
)
ax.set_xlabel("Longitude (°)")
ax.set_ylabel("Latitude (°)")
ax.grid(True, alpha=0.3, color="yellow", linewidth=0.5)
plt.suptitle(
    f"Extent: lon [{extent[0]:.2f}, {extent[1]:.2f}]  lat [{extent[2]:.2f}, {extent[3]:.2f}]",
    fontsize=9,
)
plt.tight_layout()
plt.show()

warped = None
Warped size:  512 x 142 px
Origin:       lon=13.3147  lat=40.3989
Pixel size:   0.002277° x 0.002277°
<Figure size 1000x600 with 2 Axes>

Compare GCPs Across Subswaths

Each subswath (IW1, IW2, IW3) covers a different range of incidence angles and a different geographic footprint. Let’s check GCP counts and extents for the three VV bursts.

bursts = ["IW1_VV_001", "IW2_VV_001", "IW3_VV_001"]

print(f"{'Burst':<14} {'Size':^18} {'GCPs':>5}  {'Lon range':^20}  {'Lat range':^20}")
print("-" * 82)

burst_extents = {}
for burst_name in bursts:
    ds_b = gdal.OpenEx(zarr_path, gdal.OF_RASTER, open_options=[f"BURST={burst_name}"])
    if ds_b is None:
        print(f"{burst_name:<14}  NOT FOUND")
        continue
    gcps_b = ds_b.GetGCPs()
    n = len(gcps_b)
    lons_b = [g.GCPX for g in gcps_b]
    lats_b = [g.GCPY for g in gcps_b]
    size_str = f"{ds_b.RasterXSize}x{ds_b.RasterYSize}"
    lon_str = f"[{min(lons_b):.2f}, {max(lons_b):.2f}]"
    lat_str = f"[{min(lats_b):.2f}, {max(lats_b):.2f}]"
    print(f"{burst_name:<14} {size_str:^18} {n:>5}  {lon_str:^20}  {lat_str:^20}")
    burst_extents[burst_name] = (lons_b, lats_b)
    ds_b = None

# Plot geographic footprints of all three bursts
fig, ax = plt.subplots(figsize=(10, 6))
colors = {"IW1_VV_001": "blue", "IW2_VV_001": "orange", "IW3_VV_001": "green"}
for burst_name, (lons_b, lats_b) in burst_extents.items():
    ax.scatter(
        lons_b,
        lats_b,
        s=8,
        alpha=0.5,
        label=burst_name,
        color=colors.get(burst_name, "gray"),
    )

ax.set_xlabel("Longitude (°)")
ax.set_ylabel("Latitude (°)")
ax.set_title(
    "GCP Geographic Footprints — IW1, IW2, IW3 (VV burst 001)", fontweight="bold"
)
ax.legend()
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Burst                 Size         GCPs       Lon range             Lat range      
----------------------------------------------------------------------------------
IW1_VV_001         23261x1501       210     [12.97, 14.48]        [40.07, 41.75]   
IW2_VV_001         27012x1511       210     [13.98, 15.49]        [40.27, 41.93]   
IW3_VV_001         25954x1516       210     [14.99, 16.36]        [40.47, 42.10]   
<Figure size 1000x600 with 1 Axes>
from scipy.spatial import ConvexHull

fig, ax = plt.subplots(figsize=(8, 7))
ax.set_facecolor("#c8d8e8")  # ocean fallback

try:
    import geopandas as gpd

    world = gpd.read_file(
        "https://naciscdn.org/naturalearth/110m/cultural/ne_110m_admin_0_countries.zip"
    )
    world.plot(ax=ax, color="#e8e0d0", edgecolor="gray", linewidth=0.5)
except Exception:
    pass  # plain ocean background is fine

colors = {
    "IW1_VV_001": "tab:blue",
    "IW2_VV_001": "tab:orange",
    "IW3_VV_001": "tab:green",
}

for burst_name, (lons_b, lats_b) in burst_extents.items():
    pts = np.column_stack([lons_b, lats_b])
    hull = ConvexHull(pts)
    hull_pts = pts[hull.vertices]
    hull_pts = np.vstack([hull_pts, hull_pts[0]])
    color = colors.get(burst_name, "gray")
    ax.fill(hull_pts[:, 0], hull_pts[:, 1], alpha=0.4, color=color, label=burst_name)
    ax.plot(hull_pts[:, 0], hull_pts[:, 1], color=color, linewidth=2)

# Auto-zoom to scene region with margin
all_lons = [lon for lons_b, _ in burst_extents.values() for lon in lons_b]
all_lats = [lat for _, lats_b in burst_extents.values() for lat in lats_b]
margin = 3
ax.set_xlim(min(all_lons) - margin, max(all_lons) + margin)
ax.set_ylim(min(all_lats) - margin, max(all_lats) + margin)

ax.set_xlabel("Longitude (°)")
ax.set_ylabel("Latitude (°)")
ax.set_title("Burst Footprints — IW1, IW2, IW3 (VV burst 001)", fontweight="bold")
ax.legend(loc="upper right")
ax.grid(True, alpha=0.3)

plt.suptitle(
    f"lon [{min(all_lons):.2f}, {max(all_lons):.2f}]  "
    f"lat [{min(all_lats):.2f}, {max(all_lats):.2f}]",
    fontsize=9,
)
plt.tight_layout()
plt.show()
/usr/lib/python3/dist-packages/scipy/__init__.py:146: UserWarning: A NumPy version >=1.17.3 and <1.25.0 is required for this version of SciPy (detected version 1.26.4
  warnings.warn(f"A NumPy version >={np_minversion} and <{np_maxversion}"
<Figure size 1000x600 with 1 Axes>

Command Line Usage

GCPs are visible via gdalinfo when you open a specific burst subdataset.

print("Inspect GCPs on an SLC burst:\n")
print(f"  gdalinfo \"EOPFZARR:'{SLC_URL}'\" -oo BURST=IW1_VV_001\n")

print("Warp burst to WGS 84:\n")
print(
    f"  gdalwarp -t_srs EPSG:4326 -ts 512 0 \\\n"
    f"    \"EOPFZARR:'{SLC_URL}' -oo BURST=IW1_VV_001\" \\\n"
    f"    output_burst_wgs84.tif\n"
)
Inspect GCPs on an SLC burst:

  gdalinfo "EOPFZARR:'/vsicurl/https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v262/S1C_IW_SLC__1SDV_20251016T165627_20251016T165654_004590_00913B_30C4.zarr'" -oo BURST=IW1_VV_001

Warp burst to WGS 84:

  gdalwarp -t_srs EPSG:4326 -ts 512 0 \
    "EOPFZARR:'/vsicurl/https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:notebook-data/tutorial_data/cpm_v262/S1C_IW_SLC__1SDV_20251016T165627_20251016T165654_004590_00913B_30C4.zarr' -oo BURST=IW1_VV_001" \
    output_burst_wgs84.tif

Summary

GCP Geolocation on SLC Bursts

FeatureDescription
Automatic ExtractionGCPs read from conditions/gcp/ arrays (pixel, line, latitude, longitude, height)
Burst AccessUse BURST=IW1_VV_001 open option to select a specific burst
GCP SRSWGS 84 (EPSG:4326) with traditional GIS axis ordering (lon, lat)
Reprojectiongdal.Warp() uses GCPs via polynomial transformer
Per-SubswathEach of IW1/IW2/IW3 has its own GCP grid with distinct geographic extent

Cleanup

ds = None
print("All datasets closed")