Source code: Sentinel-1 ship detection in Python: a CA-CFAR detector off the Port of Rotterdam
Runnable Python scripts, requirements and console output for the tutorial “Sentinel-1 ship detection in Python: a CA-CFAR detector off the Port of…
Last updated
These are the exact scripts used to produce every figure and number in Sentinel-1 ship detection in Python: a CA-CFAR detector off the Port of Rotterdam. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- ship_detection_cfar.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython ship_detection_cfar.pyship_detection_cfar.py
"""Ship detection in Sentinel-1 SAR imagery with a CA-CFAR detector, off the Port of Rotterdam.
Searches the Microsoft Planetary Computer for Sentinel-1 RTC scenes from one orbit, streams VV andVH backscatter for the Maasmond approaches and anchorages onto a 10 m grid, masks land with ESAWorldCover, runs a cell-averaging constant false alarm rate (CA-CFAR) detector written withNumPy and SciPy, clusters the detections into targets, separates fixed structures (wind turbines,platforms) from vessels by checking which targets persist across six dates, flags possibleazimuth ambiguities and writes the vessels to GeoJSON.Run with: python ship_detection_cfar.py"""import jsonimport platformfrom importlib.metadata import versionfrom pathlib import Path
import geopandas as gpdimport matplotlib
matplotlib.use("Agg")import matplotlib.pyplot as pltimport numpy as npimport pandas as pdimport planetary_computerimport pystac_clientimport rasterioimport requestsfrom pyproj import Transformerfrom rasterio.enums import Resamplingfrom rasterio.transform import from_originfrom rasterio.vrt import WarpedVRTfrom scipy import ndimage, statsfrom scipy.sparse import coo_matrixfrom scipy.sparse.csgraph import connected_componentsfrom scipy.spatial import cKDTreefrom shapely.geometry import Point, box, shapefrom shapely.ops import unary_union
HERE = Path(__file__).parentOUT, DATA = HERE / "output", HERE / "data"for d in (OUT, DATA): d.mkdir(exist_ok=True)plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})pd.set_option("display.width", 130)
print("=== versions ===")print(f"python {platform.python_version()} | pystac-client {pystac_client.__version__} | " f"planetary-computer {version('planetary-computer')} | rasterio {rasterio.__version__} | " f"numpy {np.__version__} | scipy {version('scipy')} | geopandas {gpd.__version__}")
def section(title: str) -> None: print(f"\n=== {title} ===")
STAC_URL = "https://planetarycomputer.microsoft.com/api/stac/v1"BBOX = (3.55, 51.85, 4.25, 52.30) # lon/lat: Maasmond, the Rotterdam anchorages and Hollandse Kust ZuidORBIT = 110 # descending; covers the whole box every 6 days (Sentinel-1C and 1D)WINDOW = "2026-09-01/2026-10-16"MAIN_DATE = "2026-10-11"RES, CRS = 10, "EPSG:32631" # 10 m pixels in UTM zone 31N, the native grid of these scenesPFA = 1e-6 # design probability of false alarm per pixelGUARD, BACKGROUND = 41, 101 # CFAR window sizes in pixels (410 m guard, 1,010 m background)MIN_PIXELS = 4 # smallest target kept: 4 pixels = 400 m²MERGE = 250 # blobs closer than this (m) are one targetFIXED_M = 100 # within this distance on another pass = the same objectAMB_KM = (4.0, 6.5) # azimuth ambiguity offset band for IW mode
to_utm = Transformer.from_crs("EPSG:4326", CRS, always_xy=True)to_lonlat = Transformer.from_crs(CRS, "EPSG:4326", always_xy=True)xs, ys = to_utm.transform([BBOX[0], BBOX[2], BBOX[0], BBOX[2]], [BBOX[1], BBOX[1], BBOX[3], BBOX[3]])x0, x1 = np.floor(min(xs) / RES) * RES, np.ceil(max(xs) / RES) * RESy0, y1 = np.floor(min(ys) / RES) * RES, np.ceil(max(ys) / RES) * RESWIDTH, HEIGHT = int((x1 - x0) / RES), int((y1 - y0) / RES)TRANSFORM = from_origin(x0, y1, RES, RES)
def warp(href: str, resampling=Resampling.nearest, nodata=np.nan, dtype="float32") -> np.ndarray: """Stream a remote COG onto the 10 m analysis grid.""" with rasterio.open(href) as src, WarpedVRT(src, crs=CRS, transform=TRANSFORM, width=WIDTH, height=HEIGHT, resampling=resampling, src_nodata=src.nodata, nodata=nodata, dtype=dtype) as vrt: return vrt.read(1)
def to_db(power): with np.errstate(divide="ignore", invalid="ignore"): return 10 * np.log10(np.where(power > 0, power, np.nan))
# --- Step 1: find the scenes ---------------------------------------------------------------catalog = pystac_client.Client.open(STAC_URL, modifier=planetary_computer.sign_inplace)items = catalog.search(collections=["sentinel-1-rtc"], bbox=BBOX, datetime=WINDOW, query={"sat:relative_orbit": {"eq": ORBIT}}).item_collection()aoi = box(*BBOX)by_date = {}for it in items: by_date.setdefault(it.datetime.strftime("%Y-%m-%d"), []).append(it)cover = {d: unary_union([shape(i.geometry) for i in its]).intersection(aoi).area / aoi.area for d, its in by_date.items()}DATES = sorted(d for d, c in cover.items() if c > 0.99)section("STAC search")print(f"{len(items)} scene slices from relative orbit {ORBIT}; dates covering the whole box: {', '.join(DATES)}")main = by_date[MAIN_DATE][0]p = main.propertiesprint(f"{MAIN_DATE}: {', '.join(i.id for i in by_date[MAIN_DATE])}")print(f"platform {p['platform']} | {p['sat:orbit_state']} | polarisations {p['sar:polarizations']} | " f"ENL {p['sar:looks_equivalent_number']} | {p['proj:code']}")ENL = p["sar:looks_equivalent_number"]
def read_date(date: str, pol: str) -> np.ndarray: cache = DATA / f"{date}_{pol}.npy" if cache.exists(): return np.load(cache) img = np.full((HEIGHT, WIDTH), np.nan, "float32") for it in by_date[date]: part = warp(it.assets[pol].href) img = np.where(np.isnan(img), part, img) np.save(cache, img) return img
vv, vh = read_date(MAIN_DATE, "vv"), read_date(MAIN_DATE, "vh")
# --- Step 2: land mask from ESA WorldCover ----------------------------------------------------wc = catalog.search(collections=["esa-worldcover"], bbox=BBOX, query={"esa_worldcover:product_version": {"eq": "2.0.0"}}).item_collection()landcover = np.zeros((HEIGHT, WIDTH), "uint8")for it in wc: part = warp(it.assets["map"].href, nodata=0, dtype="uint8") landcover = np.where(landcover == 0, part, landcover)land = (landcover != 0) & (landcover != 80) # 80 = permanent water, 0 = no data (open sea)land = ndimage.binary_dilation(land, iterations=25) # 250 m buffer: quays, breakwaters, moored shipslab, n = ndimage.label(~land & np.isfinite(vv))sizes = ndimage.sum(np.ones_like(lab), lab, index=np.arange(1, n + 1))sea = lab == 1 + int(np.argmax(sizes)) # keep the open sea, drop harbour basins and lakesPIXEL_KM2 = RES * RES / 1e6section("land mask")print(f"grid {WIDTH} x {HEIGHT} pixels at {RES} m ({WIDTH * RES / 1000:.1f} x {HEIGHT * RES / 1000:.1f} km)")print(f"WorldCover land {((landcover != 0) & (landcover != 80)).mean():.1%}; " f"open sea after a 250 m coastal buffer: {sea.sum() * PIXEL_KM2:,.0f} km² ({sea.mean():.1%})")
# --- Step 3: CA-CFAR --------------------------------------------------------------------------def ca_cfar(img: np.ndarray, sea: np.ndarray, pfa: float = PFA, enl: float = ENL): """Cell-averaging CFAR. The clutter mean comes from a ring of sea pixels between the guard and background windows; the multiplier comes from a gamma model of multilook speckle.""" x = np.where(sea, np.nan_to_num(img), 0).astype("float64") m = sea.astype("float64") box_sum = lambda a, k: ndimage.uniform_filter(a, k, mode="constant") * k * k n_ring = box_sum(m, BACKGROUND) - box_sum(m, GUARD) with np.errstate(divide="ignore", invalid="ignore"): clutter = (box_sum(x, BACKGROUND) - box_sum(x, GUARD)) / n_ring factor = stats.gamma.isf(pfa, a=enl, scale=1 / enl) # P(I > factor * mean) = pfa for gamma clutter ok = sea & (n_ring > 0.5 * (BACKGROUND ** 2 - GUARD ** 2)) return ok & (img > factor * clutter), clutter, factor
det_vv, clutter_vv, factor = ca_cfar(vv, sea)det_vh, clutter_vh, _ = ca_cfar(vh, sea)section("CA-CFAR")print(f"Pfa {PFA:g}, ENL {ENL}: threshold = {factor:.2f} x local clutter mean ({10 * np.log10(factor):.1f} dB above it)")print(f"background ring {GUARD * RES} m to {BACKGROUND * RES} m wide; " f"expected false-alarm pixels over pure gamma speckle: {PFA * sea.sum():.0f}")print(f"detected pixels: VV {det_vv.sum():,}, VH {det_vh.sum():,}")for name, img, det in (("VV", vv, det_vv), ("VH", vh, det_vh)): lab, n = ndimage.label(det) lone = np.isin(lab, 1 + np.flatnonzero(ndimage.sum(det, lab, np.arange(1, n + 1)) < MIN_PIXELS)) print(f"{name}: sea clutter median {np.nanmedian(to_db(img[sea & ~det])):.1f} dB; isolated speckle hits " f"(clusters < {MIN_PIXELS} px) {lone.sum():,} = {lone.sum() / sea.sum():.1e} of sea pixels")
# --- Step 4: cluster detections into targets -------------------------------------------------------def targets(det_a, det_b, img_a, img_b, clut_a, clut_b) -> pd.DataFrame: """Label connected detections in either polarisation, then merge blobs closer than MERGE metres.""" det = det_a | det_b lab, n = ndimage.label(det, structure=np.ones((3, 3))) idx = np.arange(1, n + 1) rows, cols = np.indices(det.shape) ratio = np.where(det, np.nan_to_num(img_a / clut_a) + np.nan_to_num(img_b / clut_b), 0) tot = ndimage.sum(ratio, lab, idx) sl = ndimage.find_objects(lab) blobs = pd.DataFrame({ "row": ndimage.sum(ratio * rows, lab, idx) / tot, "col": ndimage.sum(ratio * cols, lab, idx) / tot, "pixels": ndimage.sum(det, lab, idx), "in_a": ndimage.maximum(det_a, lab, idx).astype(bool), "in_b": ndimage.maximum(det_b, lab, idx).astype(bool), "extent_m": [RES * np.hypot(s[0].stop - s[0].start, s[1].stop - s[1].start) for s in sl], "tcr_a": to_db(ndimage.maximum(np.where(det, img_a / clut_a, 0), lab, idx)), "tcr_b": to_db(ndimage.maximum(np.where(det, img_b / clut_b, 0), lab, idx)), "peak_a": to_db(ndimage.maximum(np.where(det, img_a, 0), lab, idx)), "peak_b": to_db(ndimage.maximum(np.where(det, img_b, 0), lab, idx))}) links = cKDTree(blobs[["row", "col"]] * RES).query_pairs(MERGE, output_type="ndarray") graph = coo_matrix((np.ones(len(links)), (links[:, 0], links[:, 1])), shape=(n, n)) blobs["target"] = connected_components(graph, directed=False)[1] blobs["score"] = blobs[["tcr_a", "tcr_b"]].max(axis=1) first = blobs.sort_values("score", ascending=False).groupby("target") df = first.first() df["pixels"] = first.pixels.sum() df["blobs"] = first.size() df["in_a"], df["in_b"] = first.in_a.any(), first.in_b.any() df = df[df.pixels >= MIN_PIXELS].reset_index(drop=True) df["x"], df["y"] = x0 + (df.col + 0.5) * RES, y1 - (df.row + 0.5) * RES return df.rename(columns={"in_a": "in_vv", "in_b": "in_vh", "tcr_a": "tcr_vv_db", "tcr_b": "tcr_vh_db", "peak_a": "peak_vv_db", "peak_b": "peak_vh_db"}).drop(columns=["score"])
t = targets(det_vv, det_vh, vv, vh, clutter_vv, clutter_vh)section("targets")n_blobs = ndimage.label(det_vv | det_vh, structure=np.ones((3, 3)))[1]print(f"{n_blobs:,} connected blobs in VV or VH -> {len(t)} targets after merging blobs within {MERGE} m " f"and dropping those under {MIN_PIXELS} pixels")print(f"seen in both {(t.in_vv & t.in_vh).sum()}, VV only {(t.in_vv & ~t.in_vh).sum()}, " f"VH only {(~t.in_vv & t.in_vh).sum()}")both = t[t.in_vv & t.in_vh]print(f"median target-to-clutter ratio of targets seen in both: VV {both.tcr_vv_db.median():.1f} dB, " f"VH {both.tcr_vh_db.median():.1f} dB")
# --- Step 5: sidelobes and azimuth ambiguities -------------------------------------------------------def arm_angle(img: np.ndarray, r: float, c: float) -> float: """Bearing (degrees clockwise from grid north) of the brightest straight arm through pixel (r, c).""" db = np.nan_to_num(to_db(img), nan=-40) s = np.arange(150, 2500, RES) / RES # sample 150 m to 2.5 km either side, in pixels angles = np.arange(0, 180, 0.25) score = [ndimage.map_coordinates(db, [np.r_[r - s * np.cos(a), r + s * np.cos(a)], np.r_[c + s * np.sin(a), c - s * np.sin(a)]], order=1).mean() for a in np.radians(angles)] return float(angles[int(np.argmax(score))])
bright = t.loc[t.tcr_vh_db.idxmax()]a1 = arm_angle(vh, bright.row, bright.col)AZ = min((a1, (a1 + 90) % 180), key=lambda a: min(a, 180 - a)) # the arm closest to north-southu_az = np.array([np.sin(np.radians(AZ)), np.cos(np.radians(AZ))])u_rg = np.array([u_az[1], -u_az[0]])section("sidelobes and ambiguities")print(f"brightest target: VH {bright.tcr_vh_db:.1f} dB above clutter at row {bright.row:.0f}, col {bright.col:.0f}")print(f"its sidelobe cross gives azimuth {AZ:.1f}° and range {(AZ + 90) % 180:.1f}° (grid bearings)")print(f"azimuth ambiguity offset lambda*R*PRF/(2V) for IW: {AMB_KM[0]}-{AMB_KM[1]} km")
def flag_artefacts(df: pd.DataFrame) -> pd.DataFrame: xy = df[["x", "y"]].to_numpy() pk = df[["peak_vv_db", "peak_vh_db"]].to_numpy() side, amb = np.zeros(len(df), bool), np.zeros(len(df), bool) for k in range(len(df)): d = xy - xy[k] along, across = np.abs(d @ u_az), np.abs(d @ u_rg) brighter = np.any(pk > pk[k] + 10, axis=1) # 10 dB brighter in VV or VH on_arm = ((along < 3000) & (across < 60)) | ((across < 3000) & (along < 60)) side[k] = np.any(brighter & on_arm) amb[k] = np.any(brighter & (along > AMB_KM[0] * 1000) & (along < AMB_KM[1] * 1000) & (across < 300)) return df.assign(sidelobe=side, ambiguity=amb & ~side)
t = flag_artefacts(t)print(f"targets on the sidelobe cross of one >= 10 dB brighter: {t.sidelobe.sum()}")print(f"targets at the ambiguity offset of one >= 10 dB brighter: {t.ambiguity.sum()}")
# --- Step 6: fixed structures persist, ships move ----------------------------------------------------per_date = {MAIN_DATE: t}for d in DATES: if d not in per_date: a, b = read_date(d, "vv"), read_date(d, "vh") ok = sea & np.isfinite(a) & np.isfinite(b) (da, ca, _), (db_, cb, _) = ca_cfar(a, ok), ca_cfar(b, ok) per_date[d] = flag_artefacts(targets(da, db_, a, b, ca, cb))others = [d for d in DATES if d != MAIN_DATE]t["dates_seen"] = sum((cKDTree(per_date[d][["x", "y"]]).query(t[["x", "y"]], distance_upper_bound=FIXED_M)[0] < FIXED_M).astype(int) for d in others)t["fixed"] = t.dates_seen >= len(others) - 1 # there on (almost) every other passt["class"] = np.select([t.sidelobe, t.ambiguity, t.fixed], ["sidelobe", "possible ambiguity", "fixed structure"], "vessel")section("persistence across dates")print(f"{len(others)} other passes; main-date targets also found within {FIXED_M} m on n other passes: " f"{t.dates_seen.value_counts().sort_index().to_dict()}")print(t["class"].value_counts().to_string())
turbines = Nonetry: # optional sanity check: offshore wind turbines mapped in OpenStreetMap inside the box cache = DATA / "osm_turbines.json" if not cache.exists(): q = (f"[out:json][timeout:60];node[\"generator:source\"=\"wind\"]" f"({BBOX[1]},{BBOX[0]},{BBOX[3]},{BBOX[2]});out;") r = requests.post("https://overpass-api.de/api/interpreter", data={"data": q}, timeout=90, headers={"User-Agent": "TechnicalGIS tutorial (https://technicalgis.com)"}) r.raise_for_status() cache.write_text(r.text, encoding="utf-8") nodes = json.loads(cache.read_text(encoding="utf-8"))["elements"] tx, ty = to_utm.transform([e["lon"] for e in nodes], [e["lat"] for e in nodes]) tc, tr = (np.array(tx) - x0) / RES, (y1 - np.array(ty)) / RES keep = (tc >= 0) & (tc < WIDTH) & (tr >= 0) & (tr < HEIGHT) keep[keep] = sea[tr[keep].astype(int), tc[keep].astype(int)] turbines = pd.DataFrame({"x": np.array(tx)[keep], "y": np.array(ty)[keep]}) dist = cKDTree(t[["x", "y"]]).query(turbines[["x", "y"]])[0] fx = t[t["class"] == "fixed structure"] dist_fx = cKDTree(turbines[["x", "y"]]).query(fx[["x", "y"]])[0] section("OpenStreetMap check") print(f"{len(turbines)} OSM wind turbines over open sea in the box; {np.mean(dist < 100):.0%} have a " f"target within 100 m (median offset {np.median(dist):.0f} m)") print(f"fixed structures within 100 m of an OSM turbine: {np.sum(dist_fx < 100)} of {len(fx)}")except Exception as exc: print(f"OpenStreetMap check skipped: {exc}")
# --- Step 7: outputs ---------------------------------------------------------------------------------lon, lat = to_lonlat.transform(t.x.to_numpy(), t.y.to_numpy())cols = ["class", "pixels", "blobs", "extent_m", "tcr_vv_db", "tcr_vh_db", "in_vv", "in_vh", "dates_seen"]gdf = gpd.GeoDataFrame(t[cols].round(1), geometry=gpd.points_from_xy(np.round(lon, 5), np.round(lat, 5)), crs="EPSG:4326")gdf.to_file(OUT / f"ship_detections_{MAIN_DATE}.geojson", driver="GeoJSON")ships = gdf[gdf["class"] == "vessel"]section(f"result {MAIN_DATE}")print(f"{len(ships)} vessels written to output/ship_detections_{MAIN_DATE}.geojson (all {len(gdf)} targets kept, " f"with a class column)")print(f"vessel extent median {ships.extent_m.median():.0f} m, largest {ships.extent_m.max():.0f} m; " f"seen in VV {ships.in_vv.mean():.0%}, in VH {ships.in_vh.mean():.0%}, in both {(ships.in_vv & ships.in_vh).mean():.0%}")print(ships.sort_values("tcr_vv_db", ascending=False).head(5) .assign(lon=ships.geometry.x, lat=ships.geometry.y).drop(columns="geometry").to_string(index=False))
fixed_tree = cKDTree(t.loc[t.fixed & ~t.sidelobe, ["x", "y"]])rows_out = []for d, td in per_date.items(): near_fixed = fixed_tree.query(td[["x", "y"]], distance_upper_bound=FIXED_M)[0] < FIXED_M art = td.sidelobe | td.ambiguity rows_out.append({"date": d, "platform": by_date[d][0].properties["platform"], "targets": len(td), "fixed": int((near_fixed & ~art).sum()), "artefacts": int(art.sum()), "vessels": int((~near_fixed & ~art).sum())})counts = pd.DataFrame(rows_out).sort_values("date")section("vessel counts per pass")print(counts.to_string(index=False))print(f"mean {counts.vessels.mean():.0f} vessels per pass, range {counts.vessels.min()}-{counts.vessels.max()}")
# --- Step 8: figures -----------------------------------------------------------------------------------EXTENT = [0, WIDTH * RES / 1000, 0, HEIGHT * RES / 1000] # km from the lower-left cornerkm = lambda df: ((df.col + 0.5) * RES / 1000, HEIGHT * RES / 1000 - (df.row + 0.5) * RES / 1000)STYLE = {"vessel": ("o", "#e8590c", 22), "fixed structure": ("s", "#1d6fa3", 10), "sidelobe": ("x", "#868e96", 14), "possible ambiguity": ("^", "#9c36b5", 22)}
def save(fig, name): fig.savefig(OUT / name, facecolor="white") plt.close(fig) print(f"saved output/{name}")
section("figures")fig, axs = plt.subplots(1, 2, figsize=(11, 5.6), sharey=True)for ax, img, name, lim in ((axs[0], vv, "VV", (-22, 0)), (axs[1], vh, "VH", (-32, -10))): ax.imshow(to_db(img), cmap="gray", vmin=lim[0], vmax=lim[1], extent=EXTENT) ax.contour(np.flipud(sea), levels=[0.5], colors="#e8590c", linewidths=0.7, extent=EXTENT, origin="lower") ax.set_title(f"Sentinel-1 {name} backscatter (dB), {MAIN_DATE}") ax.set_xlabel("km east")axs[0].set_ylabel("km north")axs[1].annotate("Hollandse Kust Zuid\nwind farm", (17, 46), color="white", fontsize=8)axs[1].annotate("Maasvlakte /\nPort of Rotterdam", (37, 2), color="black", fontsize=8)fig.suptitle("Orange line: open-sea mask (ESA WorldCover water, 250 m coastal buffer)", y=0.04, fontsize=8.5)save(fig, "01-scene-vv-vh-land-mask.png")
ship = t[t["class"] == "vessel"].sort_values("tcr_vv_db").iloc[len(t[t["class"] == "vessel"]) // 2]r0, c0 = int(ship.row), int(ship.col)h = 70fig, axs = plt.subplots(1, 2, figsize=(11, 4.6), gridspec_kw={"width_ratios": [1, 1.25]})axs[0].imshow(to_db(vv[r0 - h:r0 + h, c0 - h:c0 + h]), cmap="gray", vmin=-22, vmax=5, extent=[-h * RES, h * RES, -h * RES, h * RES])for size, color, label in ((GUARD, "#1d6fa3", "guard window"), (BACKGROUND, "#e8590c", "background window")): half = size * RES / 2 axs[0].add_patch(plt.Rectangle((-half, -half), 2 * half, 2 * half, fill=False, color=color, lw=1.4, label=label))axs[0].plot(0, 0, "+", color="yellow", ms=10, label="cell under test")axs[0].legend(loc="lower left", fontsize=7.5)axs[0].set_title(f"CA-CFAR windows around a typical vessel (VV)")axs[0].set_xlabel("metres")z = (vv / clutter_vv)[sea]z = z[np.isfinite(z)]bins = np.logspace(-1.5, 2.5, 110)axs[1].hist(z, bins=bins, density=True, color="#adb5bd", label="sea pixels, VV (I / local clutter mean)")xx = np.logspace(-1.5, 2.5, 400)axs[1].plot(xx, stats.gamma.pdf(xx, a=ENL, scale=1 / ENL), color="#1d6fa3", lw=1.5, label=f"gamma model, ENL {ENL}")axs[1].axvline(factor, color="#e8590c", lw=1.5, label=f"threshold {factor:.2f} (Pfa {PFA:g})")axs[1].set_xscale("log")axs[1].set_yscale("log")axs[1].set_ylim(1e-8, 5)axs[1].set_xlabel("intensity / local clutter mean")axs[1].set_ylabel("probability density")axs[1].set_title("Speckle model and threshold")axs[1].legend(fontsize=7.5, loc="lower left")save(fig, "02-cfar-window-and-threshold.png")
sel = t[t["class"] == "vessel"]h = 250 # 5 x 5 km window on the busiest patch of seabusy, re, ce = np.histogram2d(sel.row, sel.col, bins=[np.arange(0, HEIGHT, 2 * h), np.arange(0, WIDTH, 2 * h)])i, j = np.unravel_index(np.argmax(busy), busy.shape)r0, c0 = int(re[i] + h), int(ce[j] + h)fig, axs = plt.subplots(1, 2, figsize=(11, 5.4), sharey=True)win = (slice(r0 - h, r0 + h), slice(c0 - h, c0 + h))ext = [(c0 - h) * RES / 1000, (c0 + h) * RES / 1000, HEIGHT * RES / 1000 - (r0 + h) * RES / 1000, HEIGHT * RES / 1000 - (r0 - h) * RES / 1000]for ax, img, det, name, lim in ((axs[0], vv, det_vv, "VV", (-22, 0)), (axs[1], vh, det_vh, "VH", (-32, -10))): ax.imshow(to_db(img[win]), cmap="gray", vmin=lim[0], vmax=lim[1], extent=ext) ax.imshow(np.ma.masked_where(~det[win], det[win]), cmap="autumn", alpha=0.9, extent=ext, interpolation="nearest") ax.set_title(f"{name}: {det[win].sum():,} detected pixels in this 5 x 5 km window") ax.set_xlabel("km east")axs[0].set_ylabel("km north")save(fig, "03-vv-vs-vh-anchorage.png")
fig, ax = plt.subplots(figsize=(8.6, 8.4))ax.imshow(to_db(vv), cmap="gray", vmin=-24, vmax=2, extent=EXTENT, alpha=0.85)for cls, (m, color, s) in STYLE.items(): sub = t[t["class"] == cls] ax.scatter(*km(sub), marker=m, s=s, facecolor="none" if m in "os^" else color, edgecolor=color, color=color if m == "x" else None, lw=1, label=f"{cls} ({len(sub)})")if turbines is not None: ax.scatter((turbines.x - x0) / 1000, (turbines.y - y0) / 1000, s=2, color="#74c0fc", label=f"OSM wind turbines ({len(turbines)})")ax.legend(loc="lower left", fontsize=8)ax.set_title(f"CA-CFAR targets off Rotterdam, Sentinel-1 VV + VH, {MAIN_DATE}")ax.set_xlabel("km east")ax.set_ylabel("km north")save(fig, "04-detections-map.png")
r0, c0, h = int(bright.row), int(bright.col), 260win = (slice(max(r0 - h, 0), r0 + h), slice(c0 - h, min(c0 + h, WIDTH)))fig, ax = plt.subplots(figsize=(7.4, 6.4))ax.imshow(to_db(vh[win]), cmap="gray", vmin=-32, vmax=-5, extent=[win[1].start * RES / 1000, win[1].stop * RES / 1000, HEIGHT * RES / 1000 - win[0].stop * RES / 1000, HEIGHT * RES / 1000 - win[0].start * RES / 1000])near = t[(t.row.between(win[0].start, win[0].stop)) & (t.col.between(win[1].start, win[1].stop))]for cls, (m, color, s) in STYLE.items(): sub = near[near["class"] == cls] ax.scatter(*km(sub), marker=m, s=s * 2, facecolor="none" if m in "os^" else color, edgecolor=color, color=color if m == "x" else None, lw=1.2, label=f"{cls} ({len(sub)})")bx, by = km(bright.to_frame().T)for ang, name in ((AZ, "azimuth"), (AZ + 90, "range")): dx, dy = 2.4 * np.sin(np.radians(ang)), 2.4 * np.cos(np.radians(ang)) ax.plot([float(bx.iloc[0]) - dx, float(bx.iloc[0]) + dx], [float(by.iloc[0]) - dy, float(by.iloc[0]) + dy], ":", color="#ffd43b", lw=0.8, alpha=0.6) ax.annotate(name, (float(bx.iloc[0]) + dx, float(by.iloc[0]) + dy), color="#ffd43b", fontsize=8)ax.legend(loc="lower left", fontsize=7.5)ax.set_title("Brightest target (VH): sidelobe cross and nearby turbines")ax.set_xlabel("km east")ax.set_ylabel("km north")save(fig, "05-sidelobes-and-turbines.png")
fig, axs = plt.subplots(1, 2, figsize=(11, 3.8))x = np.arange(len(counts))axs[0].bar(x, counts.vessels, color="#e8590c", label="vessels")axs[0].bar(x, counts.fixed, bottom=counts.vessels, color="#1d6fa3", label="fixed structures")axs[0].bar(x, counts.artefacts, bottom=counts.vessels + counts.fixed, color="#adb5bd", label="sidelobes / ambiguities")axs[0].set_xticks(x, [f"{d[5:]}\n{p[-2:].upper()}" for d, p in zip(counts.date, counts.platform)])axs[0].set_ylabel("targets")axs[0].set_title("Targets per pass, relative orbit 110")axs[0].legend(fontsize=7.5)v = t[t["class"] == "vessel"]axs[1].hist(v.extent_m, bins=np.arange(0, 560, 20), color="#e8590c", label="vessels")axs[1].hist(t[t["class"] == "fixed structure"].extent_m, bins=np.arange(0, 560, 20), color="#1d6fa3", alpha=0.6, label="fixed structures")axs[1].set_xlabel("brightest blob extent (bounding-box diagonal, m)")axs[1].set_ylabel("count")axs[1].set_title(f"Target sizes, {MAIN_DATE}")axs[1].legend(fontsize=7.5)save(fig, "06-counts-and-sizes.png")