Showcase — vessel traffic in the Strait of Hormuz (Sentinel-1 SAR)¶
The Strait of Hormuz carries roughly a fifth of global oil shipping. Sentinel-1 SAR is the right sensor — calm sea is dark, metal ships return brightly, in any weather. This notebook renders a VV pass with pyramids' Dataset.plot(), animates the passes with DatasetCollection.plot(), and detects/counts vessels with a CFAR detector.
What this notebook does¶
- Fetch six Sentinel-1 VV passes; render one with
Dataset.plot(). - Animate them with
DatasetCollection.plot(). - Detect & count vessels (CFAR), with a size distribution.
Needs
GEE_SERVICE_ACCOUNT/GEE_SERVICE_KEYandpyramids-gis[viz].
Setup¶
First the imports and a bit of noise-suppression. pyramids provides Dataset /
DatasetCollection (reading + plotting); earthlens provides the unified EarthLens
entry point. scipy.ndimage powers the CFAR vessel detector further down.
import os
import shutil
import tempfile
import warnings
import matplotlib.pyplot as plt
import numpy as np
from IPython.display import Image, display
from loguru import logger
from pyramids.dataset import Dataset, DatasetCollection # pyramids' own IO + plotting
from scipy import ndimage
from earthlens import EarthLens
warnings.filterwarnings("ignore")
logger.remove()
2026-06-13 18:16:48 | INFO | pyramids.base.config | Logging is configured.
A tiny read() helper loads a GeoTIFF off disk and returns its first band as a
float array — used both when collecting the passes and when running the detector.
def read(path):
return np.asarray(Dataset.read_file(str(path)).read_array(), dtype="float32")
Credentials¶
This notebook reads its credentials from a .env file at the repository root
(loaded with python-dotenv; your PATH is left untouched). Create that .env
with at least:
GEE_SERVICE_ACCOUNT="your-sa@your-project.iam.gserviceaccount.com"
GEE_SERVICE_KEY="/path/to/your/service-account-key.json"
from dotenv import find_dotenv, load_dotenv
load_dotenv(find_dotenv(usecwd=True))
SERVICE_ACCOUNT = os.environ["GEE_SERVICE_ACCOUNT"] # Earth Engine service-account email (.env)
SERVICE_KEY = os.environ["GEE_SERVICE_KEY"] # path to the service-account JSON key (.env)
1 · Fetch six Sentinel-1 VV passes¶
Sentinel-1 is radar: a single VV backscatter channel (dB), so it is shown in grey — there is no "true colour". Bright points are ships; dark fabric is calm water. We request six passes over a small box in the strait at 20 m scale.
# Sentinel-1 VV over a small box in the strait; one date per pass.
AOI = [56.30, 26.50, 56.50, 26.66]
C = "COPERNICUS/S1_GRD"
DATES = [
"2024-05-01",
"2024-05-13",
"2024-05-25",
"2024-06-06",
"2024-06-18",
"2024-06-30",
]
Download each pass¶
For every date we build a GEE request, authenticate, and download() the VV
composite — the construct → authenticate → download steps are kept on separate
lines, and the written path is pulled off the result on its own line. Each GeoTIFF
is copied into one shared pass_dir so it can be read back as a collection.
pass_dir = tempfile.mkdtemp()
vv, labels = [], []
for d in DATES:
scene = EarthLens(
data_source="gee",
cadence="raw",
path=tempfile.mkdtemp(),
dataset=C,
variables=["VV"],
aoi=AOI,
start=d,
end=d,
scale=20,
reducer="mean",
)
scene.authenticate(service_account=SERVICE_ACCOUNT, service_key=SERVICE_KEY)
paths = scene.download(progress_bar=False)
p = str(paths[0])
if not p:
continue
shutil.copy(p, os.path.join(pass_dir, f"vv_{d.replace('-', '.')}.tif"))
a = read(p)
vv.append(a if a.ndim == 2 else a[0])
labels.append(d)
Read the passes as a collection¶
DatasetCollection.read_multiple_files() loads the whole pass_dir folder into a
single collection that pyramids can plot and animate.
dc = DatasetCollection.read_multiple_files(pass_dir, date=False) if labels else None
print(f"{len(vv)} passes:", labels)
6 passes: ['2024-05-01', '2024-05-13', '2024-05-25', '2024-06-06', '2024-06-18', '2024-06-30']
2 · One VV pass — Dataset.plot()¶
pyramids renders the SAR backscatter (dB) in grey; bright points are vessels.
if dc:
dc.iloc(0).plot(band=0, cmap="gray", title=f"Sentinel-1 VV - {labels[0]}")
plt.show()
3 · Animated traffic — DatasetCollection.plot()¶
DatasetCollection animates the VV channel across the six passes (single-band SAR)
and saves the result to a GIF in a temporary directory.
if dc:
gif = os.path.join(tempfile.mkdtemp(), "vv.gif")
dc.plot(band=0, cmap="gray", title="Strait of Hormuz - Sentinel-1 VV").save_animation(gif, fps=1)
plt.close("all")
display(Image(filename=gif))
else:
print("no passes to animate")
<IPython.core.display.Image object>
4 · Detect and count vessels (CFAR)¶
A temporal-median background marks open water; CFAR flags points far brighter than
their local surroundings. The detection uses matplotlib (the red circles are
pixel-aligned overlays, which need image pixel coordinates rather than
Dataset.plot's geographic axes).
The water mask and the CFAR detector¶
The per-pixel temporal median across all passes gives a stable backscatter
background; pixels below -16 dB are open water. detect() then flags bright
outliers against a local mean/std window and keeps blobs in a plausible size range.
bg = np.nanmedian(np.dstack(vv), axis=2) if vv else None
water = (bg < -16) if bg is not None else None
def detect(a, win=21, k=3.5, lo=2, hi=120):
z = np.nan_to_num(a, nan=-30)
lm = ndimage.uniform_filter(z, size=win)
ls = np.sqrt(np.maximum(ndimage.uniform_filter(z * z, size=win) - lm * lm, 1e-3))
lbl, n = ndimage.label(water & (z > -12) & ((z - lm) > k * ls))
out = []
for i in range(1, n + 1):
ys, xs = np.where(lbl == i)
if lo <= ys.size <= hi:
out.append((xs.mean(), ys.mean(), ys.size))
return out
Run the detector and show the busiest pass¶
Run detect() on every pass, then render the pass with the most detections,
overlaying a red circle on each vessel.
dets = [detect(a) for a in vv] if vv else []
counts = [len(d) for d in dets]
if vv:
k = int(np.argmax(counts))
a = vv[k]
lo, hi = np.nanpercentile(a, 40), np.nanpercentile(a, 99.8)
fig, ax = plt.subplots(figsize=(8, 7))
ax.imshow(np.clip((a - lo) / (hi - lo), 0, 1), cmap="gray")
if dets[k]:
xs, ys, _ = zip(*dets[k])
ax.scatter(xs, ys, s=80, facecolors="none", edgecolors="red", linewidths=1.3)
ax.set_title(f"{labels[k]} - {len(dets[k])} vessels detected")
ax.axis("off")
plt.show()
5 · Vessels per pass and size distribution¶
The per-pass counts show how traffic varies across the six dates; converting blob pixel counts to an approximate footprint (each pixel is 20 m × 20 m) gives a vessel size distribution.
if vv:
fig, ax = plt.subplots(1, 2, figsize=(13, 4))
ax[0].bar(range(len(counts)), counts, color="steelblue")
ax[0].set_xticks(range(len(labels)))
ax[0].set_xticklabels(labels, rotation=30, ha="right")
ax[0].set(ylabel="vessels", title="Vessels per Sentinel-1 pass")
sizes = np.array([s for d in dets for (_, _, s) in d]) * (20 * 20)
if sizes.size:
ax[1].hist(sizes, bins=20, color="darkslategray")
ax[1].set(
xlabel="approx. footprint (m^2)",
ylabel="vessels",
title="Vessel size distribution",
)
plt.tight_layout()
plt.show()
print(f"total {sum(counts)} detections across {len(vv)} passes")
total 35 detections across 6 passes
6 · Traffic-density map¶
Stacking every vessel detection from all six passes onto one grid reveals the busiest part of the lane — the de-facto shipping corridor through the strait.
# Stack all detections (all passes) into a 2-D density (hexbin) map.
if vv and any(dets):
allx = [x for d in dets for (x, _, _) in d]
ally = [y for d in dets for (_, y, _) in d]
fig, ax = plt.subplots(figsize=(8, 7))
hb = ax.hexbin(allx, ally, gridsize=25, cmap='magma', mincnt=1)
ax.invert_yaxis()
ax.set_aspect('equal')
ax.set_title('Vessel density - 6 Sentinel-1 passes stacked')
ax.axis('off')
fig.colorbar(hb, ax=ax, label='detections per cell')
plt.show()
print(f'{sum(len(d) for d in dets)} detections stacked across {len(vv)} passes')
else:
print('no detections to map')
35 detections stacked across 6 passes
Recap¶
The SAR still and the traffic animation use pyramids' Dataset.plot /
DatasetCollection.plot; the CFAR detection overlay and the size charts use matplotlib.
Try it yourself¶
- Tune the CFAR
k/window, or add moreDATES. - See the GEE backend reference.