Source code: Sentinel-2 cloud and cloud-shadow masking in Python: SCL vs s2cloudless vs OmniCloudMask

Runnable Python scripts, requirements and console output for the tutorial “Sentinel-2 cloud and cloud-shadow masking in Python: SCL vs s2cloudless vs…

Last updated

These are the exact scripts used to produce every figure and number in Sentinel-2 cloud and cloud-shadow masking in Python: SCL vs s2cloudless vs OmniCloudMask. Download them, install the requirements into a fresh virtual environment and run them from this folder.

Run it
python -m venv .venv
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
python cloud_masking.py

cloud_masking.py

cloud_masking.py
"""Sentinel-2 cloud and cloud-shadow masking in Python: SCL vs s2cloudless vs OmniCloudMask vs a
simple threshold baseline over the Lake District, plus a cloud-free median composite.
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/sentinel-2-cloud-masking-python/
Data: Copernicus Sentinel-2 L2A (Microsoft Planetary Computer) and L1C (Google Cloud public bucket).
"""
import os
import sys
import time
import warnings
from importlib.metadata import version
from pathlib import Path
for var in ("PROJ_LIB", "PROJ_DATA", "GDAL_DATA"): # stray installs (e.g. PostGIS) break the wheels' own copies
os.environ.pop(var, None)
import numpy as np
import pandas as pd
import requests
import rasterio
from rasterio.enums import Resampling
from rasterio.transform import from_origin
from rasterio.windows import from_bounds
from scipy import ndimage
import pystac_client
import planetary_computer as pc
import odc.stac
from odc.geo.geobox import GeoBox
import matplotlib
matplotlib.use("Agg") # render to files only
import matplotlib.pyplot as plt
from matplotlib.colors import ListedColormap
from matplotlib.patches import Patch
from s2cloudless import S2PixelCloudDetector
from omnicloudmask import predict_from_array
warnings.filterwarnings("ignore")
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
OUT = Path("output"); OUT.mkdir(exist_ok=True)
CHIPS = OUT / "_chips"; CHIPS.mkdir(exist_ok=True)
STAC_URL = "https://planetarycomputer.microsoft.com/api/stac/v1"
GCS = "https://storage.googleapis.com"
L1C_BUCKET = "gcp-public-data-sentinel-2"
CRS = "EPSG:32630" # UTM 30N, the native grid of tile 30UVF
AOI = (478000, 6030000, 498000, 6050000) # 20 x 20 km: Keswick, Derwentwater, Borrowdale, Buttermere
RES = 20 # analysis grid in metres (SCL and s2cloudless are 20 m products)
H = W = int((AOI[2] - AOI[0]) / RES)
TILE = "30UVF"
BBOX_LL = [-3.35, 54.41, -3.02, 54.60]
EVAL_DATES = ["2026-05-26", "2026-08-08", "2026-09-23"] # three cloudy-but-not-overcast dates
COMPOSITE_PERIOD = "2026-05-01/2026-09-30"
CLEAR_DATE = "2026-07-12" # the clearest summer date, used to check the composites
N_POINTS = 80 # reference points per evaluation date
SEED = 42
# Every mask uses the same codes: 0 clear, 1 cloud, 2 cloud shadow
CLEAR, CLOUD, SHADOW = 0, 1, 2
MASK_CMAP = ListedColormap(["#00000000", "#ffffbf", "#5e4fa2"])
METHODS = ["SCL", "s2cloudless", "OmniCloudMask", "Threshold"]
COLOURS = {"SCL": "#3288bd", "s2cloudless": "#66c2a5", "OmniCloudMask": "#d53e4f", "Threshold": "#fdae61"}
def section(title):
print(f"\n=== {title} ===")
def read_window(href, out_shape=(H, W), resampling=Resampling.average):
"""Read the AOI from a COG or JP2 over HTTP, resampled onto the analysis grid."""
with rasterio.Env(GDAL_DISABLE_READDIR_ON_OPEN="EMPTY_DIR", GDAL_HTTP_MAX_RETRY="4", GDAL_HTTP_RETRY_DELAY="2"):
with rasterio.open(href) as src:
win = from_bounds(*AOI, src.transform)
return src.read(1, window=win, out_shape=out_shape, resampling=resampling)
def to_reflectance(dn):
"""Processing baseline 04.00+ adds 1000 to every DN, so reflectance = (DN - 1000) / 10000. DN 0 is no data."""
return np.where(dn == 0, np.nan, (dn.astype("float32") - 1000) / 10000)
def rgb(stack, gain=3.2):
"""True-colour display image with a fixed gain, so that dates and composites can be compared."""
return np.clip(np.nan_to_num(np.dstack(stack)) * gain, 0, 1) ** 0.85
def kappa(a, b):
po = np.mean(a == b)
pe = np.mean(a) * np.mean(b) + np.mean(~a) * np.mean(~b)
return (po - pe) / (1 - pe)
section("versions")
print(f"python {sys.version.split()[0]} | rasterio {version('rasterio')} | odc-stac {version('odc-stac')} | "
f"s2cloudless {version('s2cloudless')} | omnicloudmask {version('omnicloudmask')} | torch {version('torch')}")
# ---------------------------------------------------------------- Step 1: find the scenes
section("Step 1: Sentinel-2 L2A scenes for tile 30UVF")
catalog = pystac_client.Client.open(STAC_URL, modifier=pc.sign_inplace)
def l2a_item(date):
items = catalog.search(collections=["sentinel-2-l2a"], bbox=BBOX_LL, datetime=date,
query={"s2:mgrs_tile": {"eq": TILE}}).item_collection()
return sorted(items, key=lambda i: i.properties["s2:generation_time"])[-1] # latest processing
eval_items = {d: l2a_item(d) for d in EVAL_DATES}
for d, it in eval_items.items():
p = it.properties
print(f"{d} {it.id} tile cloud {p['eo:cloud_cover']:.1f}% sun zenith {p['s2:mean_solar_zenith']:.1f} "
f"azimuth {p['s2:mean_solar_azimuth']:.1f}")
# ---------------------------------------------------------------- Step 2: load the bands
section("Step 2: load L2A bands on a 20 m grid")
scenes = {}
for d, it in eval_items.items():
t0 = time.time()
b = {k: to_reflectance(read_window(it.assets[k].href)) for k in ["B02", "B03", "B04", "B08", "B11"]}
b["SCL"] = read_window(it.assets["SCL"].href, resampling=Resampling.nearest)
# 10 m true colour for the figures and the reference chips
b["rgb10"] = [to_reflectance(read_window(it.assets[k].href, out_shape=(2 * H, 2 * W))) for k in ["B04", "B03", "B02"]]
b["item"] = it
scenes[d] = b
print(f"{d}: {H} x {W} px, median B02 {np.nanmedian(b['B02']):.3f}, B08 {np.nanmedian(b['B08']):.3f} "
f"({time.time() - t0:.0f} s)")
# ---------------------------------------------------------------- Step 3: SCL mask
section("Step 3: Scene Classification Layer (SCL)")
SCL_NAMES = {0: "no data", 1: "saturated/defective", 2: "dark area", 3: "cloud shadow", 4: "vegetation",
5: "not vegetated", 6: "water", 7: "unclassified", 8: "cloud medium prob.", 9: "cloud high prob.",
10: "thin cirrus", 11: "snow/ice"}
def scl_mask(scl):
m = np.zeros(scl.shape, "uint8")
m[np.isin(scl, [8, 9, 10])] = CLOUD
m[scl == 3] = SHADOW
return m
for d, b in scenes.items():
vals, counts = np.unique(b["SCL"], return_counts=True)
print(d, " | ".join(f"{SCL_NAMES[v]} {c / b['SCL'].size * 100:.1f}%" for v, c in zip(vals, counts)
if c / b["SCL"].size >= 0.005))
b["SCL_mask"] = scl_mask(b["SCL"])
# ---------------------------------------------------------------- Step 4: s2cloudless on L1C top-of-atmosphere bands
section("Step 4: s2cloudless (L1C from the Google Cloud public bucket)")
S2C_BANDS = ["B01", "B02", "B04", "B05", "B08", "B8A", "B09", "B10", "B11", "B12"]
def l1c_band_urls(item):
"""Find the L1C product with the same sensing time and orbit in the public Google Cloud bucket."""
_, _, sensing, orbit, _, _ = item.id.split("_") # e.g. S2C_MSIL2A_20260526T113309_R080_T30UVF_...
prefix = f"tiles/{TILE[:2]}/{TILE[2]}/{TILE[3:]}/{item.id[:3]}_MSIL1C_{sensing}"
api = f"{GCS}/storage/v1/b/{L1C_BUCKET}/o"
r = requests.get(api, params={"prefix": prefix, "delimiter": "/"}, timeout=60)
safe = sorted(s for s in r.json().get("prefixes", []) if f"_{orbit}_" in s)[-1]
r = requests.get(api, params={"prefix": safe + "GRANULE/", "fields": "items(name)"}, timeout=60)
names = [i["name"] for i in r.json()["items"] if "/IMG_DATA/" in i["name"] and i["name"].endswith(".jp2")]
urls = {band: f"/vsicurl/{GCS}/{L1C_BUCKET}/{n}" for band in S2C_BANDS for n in names if n.endswith(f"_{band}.jp2")}
return safe.split("/")[-2], urls
def project_shadows(cloud, dark, sun_azimuth, sun_zenith, heights_m=range(200, 3001, 200)):
"""Cast the cloud mask away from the sun for cloud heights of 0.2-3 km; keep the dark pixels it lands on."""
az = np.radians(sun_azimuth)
projected = np.zeros_like(cloud)
for h in heights_m:
dist = h * np.tan(np.radians(sun_zenith)) / RES # horizontal offset in pixels
dx, dy = -np.sin(az) * dist, np.cos(az) * dist # image rows increase southwards
projected |= ndimage.shift(cloud.astype("uint8"), (dy, dx), order=0, cval=0).astype(bool)
return projected & dark & ~cloud
detector = S2PixelCloudDetector(threshold=0.4, average_over=4, dilation_size=2, all_bands=False)
for d, b in scenes.items():
t0 = time.time()
safe, urls = l1c_band_urls(b["item"])
toa = np.dstack([to_reflectance(read_window(urls[k])) for k in S2C_BANDS])
prob = detector.get_cloud_probability_maps(np.nan_to_num(toa)[np.newaxis])[0]
cloud = detector.get_mask_from_prob(prob[np.newaxis])[0].astype(bool)
p = b["item"].properties
mndwi = (b["B03"] - b["B11"]) / (b["B03"] + b["B11"])
dark = (b["B08"] < 0.15) & (mndwi < 0) # dark, but not water
shadow = project_shadows(cloud, dark, p["s2:mean_solar_azimuth"], p["s2:mean_solar_zenith"])
m = np.zeros((H, W), "uint8"); m[shadow] = SHADOW; m[cloud] = CLOUD
b["s2c_prob"], b["s2cloudless_mask"] = prob, m
print(f"{d}: {safe}\n cloud {cloud.mean() * 100:.1f}% projected shadow {shadow.mean() * 100:.1f}% "
f"({time.time() - t0:.0f} s)")
# ---------------------------------------------------------------- Step 5: OmniCloudMask
section("Step 5: OmniCloudMask (red, green and NIR)")
def omnicloudmask(red, green, nir):
arr = np.nan_to_num(np.stack([red, green, nir]), nan=0.0).astype("float32")
return predict_from_array(arr, inference_device="cpu", mosaic_device="cpu", no_data_value=-9999)[0]
for d, b in scenes.items():
t0 = time.time()
pred = omnicloudmask(b["B04"], b["B03"], b["B08"])
# OmniCloudMask classes: 0 clear, 1 thick cloud, 2 thin cloud, 3 cloud shadow
m = np.zeros((H, W), "uint8"); m[np.isin(pred, [1, 2])] = CLOUD; m[pred == 3] = SHADOW
b["ocm_raw"], b["OmniCloudMask_mask"] = pred, m
print(f"{d}: thick {np.mean(pred == 1) * 100:.1f}% thin {np.mean(pred == 2) * 100:.1f}% "
f"shadow {np.mean(pred == 3) * 100:.1f}% ({time.time() - t0:.0f} s on CPU)")
# ---------------------------------------------------------------- Step 6: simple threshold baseline
section("Step 6: threshold baseline")
BLUE_CLOUD = 0.18 # clear land in summer has B02 reflectance of about 0.02-0.08
NIR_SHADOW = 0.12 # sunlit vegetation has B08 of 0.2-0.4; in shadow it drops below about 0.12
def threshold_mask(b):
mndwi = (b["B03"] - b["B11"]) / (b["B03"] + b["B11"])
cloud = b["B02"] > BLUE_CLOUD
shadow = (b["B08"] < NIR_SHADOW) & (mndwi < 0) & ~cloud
m = np.zeros((H, W), "uint8"); m[shadow] = SHADOW; m[cloud] = CLOUD
return m
for d, b in scenes.items():
b["Threshold_mask"] = threshold_mask(b)
print(f"{d}: cloud {np.mean(b['Threshold_mask'] == CLOUD) * 100:.1f}% "
f"shadow {np.mean(b['Threshold_mask'] == SHADOW) * 100:.1f}%")
# ---------------------------------------------------------------- Step 7: how much do the methods agree?
section("Step 7: agreement between methods")
rows = []
for d, b in scenes.items():
for m in METHODS:
mk = b[f"{m}_mask"]
rows.append({"date": d, "method": m, "cloud_%": np.mean(mk == CLOUD) * 100,
"shadow_%": np.mean(mk == SHADOW) * 100, "masked_%": np.mean(mk > 0) * 100})
cover = pd.DataFrame(rows)
for col in ["masked_%", "cloud_%", "shadow_%"]:
print(f"\n{col.replace('_%', '')} (% of the area):")
print(cover.pivot(index="method", columns="date", values=col).round(1).loc[METHODS].to_string())
agree = np.zeros((len(METHODS), len(METHODS)))
kap = np.zeros_like(agree)
for i, mi in enumerate(METHODS):
for j, mj in enumerate(METHODS):
a = np.concatenate([(scenes[d][f"{mi}_mask"] > 0).ravel() for d in EVAL_DATES])
c = np.concatenate([(scenes[d][f"{mj}_mask"] > 0).ravel() for d in EVAL_DATES])
agree[i, j] = np.mean(a == c) * 100
kap[i, j] = kappa(a, c)
print("\npixel agreement, masked vs not masked, all three dates (%):")
print(pd.DataFrame(agree, index=METHODS, columns=METHODS).round(1).to_string())
print("\nCohen's kappa:")
print(pd.DataFrame(kap, index=METHODS, columns=METHODS).round(2).to_string())
all_agree = np.concatenate([np.all([scenes[d][f"{m}_mask"] > 0 for m in METHODS], axis=0).ravel() |
np.all([scenes[d][f"{m}_mask"] == 0 for m in METHODS], axis=0).ravel()
for d in EVAL_DATES])
print(f"\nall four methods agree on {all_agree.mean() * 100:.1f}% of pixels")
# ---------------------------------------------------------------- Step 8: reference points
section("Step 8: check against visually interpreted reference points")
rng = np.random.default_rng(SEED)
pts = []
for d in EVAL_DATES:
rr, cc = rng.integers(16, H - 16, N_POINTS), rng.integers(16, W - 16, N_POINTS)
pts += [{"point_id": f"{d[5:7]}{d[8:10]}-{k:02d}", "date": d, "row": int(r), "col": int(c)}
for k, (r, c) in enumerate(zip(rr, cc))]
pts = pd.DataFrame(pts)
# Contact sheets of 10 m true-colour chips (640 m across, centre 20 m pixel boxed), blind to every mask
HALF = 32
for d in EVAL_DATES:
img = rgb(scenes[d]["rgb10"])
sub = pts[pts.date == d].reset_index(drop=True)
for s in range(0, len(sub), 20):
fig, axes = plt.subplots(4, 5, figsize=(10, 8.4))
for ax, p in zip(axes.ravel(), sub.iloc[s:s + 20].itertuples()):
r10, c10 = 2 * p.row, 2 * p.col
ax.imshow(img[r10 - HALF:r10 + HALF, c10 - HALF:c10 + HALF], interpolation="nearest")
ax.add_patch(plt.Rectangle((HALF - 0.5, HALF - 0.5), 2, 2, fill=False, ec="#ff00ff", lw=1.2))
ax.set_title(p.point_id, fontsize=8); ax.set_xticks([]); ax.set_yticks([])
fig.savefig(CHIPS / f"chips_{d}_{s // 20 + 1}.png", dpi=110)
plt.close(fig)
labels_csv = Path("reference_points.csv")
if not labels_csv.exists():
pts.assign(label="").to_csv(labels_csv, index=False)
sys.exit(f"Label the points in {labels_csv} from the contact sheets in {CHIPS}, then run again.")
ref = pts.merge(pd.read_csv(labels_csv)[["point_id", "label"]], on="point_id")
print("reference labels:", ref.label.value_counts().to_dict())
ref = ref[ref.label.isin(["clear", "cloud", "shadow"])].copy()
ref["truth"] = ref.label.map({"clear": CLEAR, "cloud": CLOUD, "shadow": SHADOW})
scores = []
for m in METHODS:
pred = np.array([scenes[p.date][f"{m}_mask"][p.row, p.col] for p in ref.itertuples()])
ref[m] = pred
t_bad, p_bad = ref.truth.values > 0, pred > 0
tp, fp, fn = np.sum(t_bad & p_bad), np.sum(~t_bad & p_bad), np.sum(t_bad & ~p_bad)
scores.append({"method": m, "overall_acc_%": np.mean(t_bad == p_bad) * 100, "kappa": kappa(t_bad, p_bad),
"omission_%": fn / (tp + fn) * 100, # cloud or shadow the method left in
"commission_%": fp / max(tp + fp, 1) * 100, # masked pixels that were really clear
"cloud_found_%": np.mean(pred[ref.truth == CLOUD] > 0) * 100,
"shadow_found_%": np.mean(pred[ref.truth == SHADOW] > 0) * 100,
"clear_kept_%": np.mean(pred[ref.truth == CLEAR] == 0) * 100})
scores = pd.DataFrame(scores).set_index("method")
print(f"\n{len(ref)} usable points (clear {np.sum(ref.truth == CLEAR)}, cloud {np.sum(ref.truth == CLOUD)}, "
f"shadow {np.sum(ref.truth == SHADOW)}); masked = cloud or shadow")
print(scores.round(2).to_string())
print("\nconfusion matrices (rows: reference, columns: method)")
for m in METHODS:
cm = pd.crosstab(ref.label, ref[m].map({CLEAR: "clear", CLOUD: "cloud", SHADOW: "shadow"}))
print(f"-- {m}\n{cm.to_string()}")
ref.to_csv(OUT / "reference_point_results.csv", index=False)
# ---------------------------------------------------------------- figures for the evaluation dates
section("figures: masks")
EXTENT = [0, (AOI[2] - AOI[0]) / 1000, 0, (AOI[3] - AOI[1]) / 1000]
d0 = EVAL_DATES[0]
scl_cols = {0: "#000000", 1: "#ff0000", 2: "#404040", 3: "#5e4fa2", 4: "#66bd63", 5: "#d9b365", 6: "#3288bd",
7: "#999999", 8: "#bdbdbd", 9: "#ffffbf", 10: "#9ecae1", 11: "#e0f3f8"}
fig, axes = plt.subplots(1, 2, figsize=(11.5, 5.4))
axes[0].imshow(rgb(scenes[d0]["rgb10"]), extent=EXTENT)
axes[0].set_title(f"True colour, {d0}")
present = [v for v in range(12) if np.mean(scenes[d0]["SCL"] == v) > 0.0005]
axes[1].imshow(scenes[d0]["SCL"], cmap=ListedColormap([scl_cols[v] for v in range(12)]), vmin=-0.5, vmax=11.5,
extent=EXTENT, interpolation="nearest")
axes[1].set_title("Scene Classification Layer (SCL)")
axes[1].legend(handles=[Patch(fc=scl_cols[v], ec="#444444", lw=0.5, label=f"{v} {SCL_NAMES[v]}") for v in present],
loc="upper left", bbox_to_anchor=(1.02, 1), frameon=False, fontsize=8)
for ax in axes:
ax.set_xlabel("km east"); ax.set_ylabel("km north")
fig.savefig(OUT / "01-scene-and-scl.png")
plt.close(fig)
for d in EVAL_DATES:
fig, axes = plt.subplots(1, 5, figsize=(16, 3.9), layout="constrained")
axes[0].imshow(rgb(scenes[d]["rgb10"]), extent=EXTENT)
axes[0].set_title(f"True colour {d}")
grey = np.nanmean(np.dstack([scenes[d]["B04"], scenes[d]["B03"], scenes[d]["B02"]]), axis=2)
for ax, m in zip(axes[1:], METHODS):
ax.imshow(grey, cmap="gray", vmin=0, vmax=0.35, extent=EXTENT)
ax.imshow(scenes[d][f"{m}_mask"], cmap=MASK_CMAP, vmin=0, vmax=2, extent=EXTENT, interpolation="nearest")
ax.set_title(f"{m}: {np.mean(scenes[d][f'{m}_mask'] > 0) * 100:.0f}% masked")
for ax in axes:
ax.set_xticks([]); ax.set_yticks([])
fig.legend(handles=[Patch(fc="#ffffbf", ec="#444444", label="cloud"),
Patch(fc="#5e4fa2", label="cloud shadow")],
loc="lower center", ncol=2, frameon=False, bbox_to_anchor=(0.5, -0.05))
fig.savefig(OUT / ("02-four-masks.png" if d == d0 else f"_masks-{d}.png"))
plt.close(fig)
fig, axes = plt.subplots(1, 2, figsize=(12.5, 4.6), layout="constrained", gridspec_kw={"width_ratios": [1.15, 1]})
x = np.arange(len(EVAL_DATES)); wbar = 0.2
for k, m in enumerate(METHODS):
sub = cover[cover.method == m]
axes[0].bar(x + (k - 1.5) * wbar, sub["cloud_%"], wbar, color=COLOURS[m], label=m)
axes[0].bar(x + (k - 1.5) * wbar, sub["shadow_%"], wbar, bottom=sub["cloud_%"], color=COLOURS[m], alpha=0.5,
hatch="///", edgecolor="white", lw=0)
axes[0].set_xticks(x, EVAL_DATES); axes[0].set_ylabel("% of the 20 x 20 km area")
axes[0].set_title("Masked area: cloud (solid) and shadow (hatched)")
axes[0].legend(frameon=False, fontsize=8)
im = axes[1].imshow(kap, cmap="Spectral", vmin=0, vmax=1)
axes[1].set_xticks(range(4), METHODS, rotation=20); axes[1].set_yticks(range(4), METHODS)
for i in range(4):
for j in range(4):
axes[1].text(j, i, f"{kap[i, j]:.2f}\n{agree[i, j]:.0f}%", ha="center", va="center", fontsize=8)
axes[1].set_title("Pairwise Cohen's kappa and % agreement")
fig.colorbar(im, ax=axes[1], shrink=0.8, label="kappa")
fig.savefig(OUT / "03-agreement.png")
plt.close(fig)
fig, ax = plt.subplots(figsize=(8.5, 3.8))
metrics = ["overall_acc_%", "clear_kept_%", "cloud_found_%", "shadow_found_%"]
names = ["Overall accuracy", "Clear points kept", "Cloud points masked", "Shadow points masked"]
x = np.arange(len(metrics))
for k, m in enumerate(METHODS):
bars = ax.bar(x + (k - 1.5) * wbar, scores.loc[m, metrics], wbar, color=COLOURS[m], label=m)
ax.bar_label(bars, fmt="%.0f", fontsize=7, padding=1)
ax.set_xticks(x, names); ax.set_ylim(0, 110); ax.set_ylabel("%")
ax.set_title(f"Against {len(ref)} visually interpreted reference points on three dates")
ax.legend(frameon=False, ncol=4, fontsize=8, loc="lower center", bbox_to_anchor=(0.5, -0.3))
fig.savefig(OUT / "04-reference-accuracy.png")
plt.close(fig)
# ---------------------------------------------------------------- Step 9: cloud-free median composite
section("Step 9: cloud-free median composite, May-September 2026")
items = catalog.search(collections=["sentinel-2-l2a"], bbox=BBOX_LL, datetime=COMPOSITE_PERIOD,
query={"s2:mgrs_tile": {"eq": TILE}, "eo:cloud_cover": {"lt": 90}}).item_collection()
latest = {} # one item per sensing time: the latest processing
for it in items:
key = it.id.split("_")[2]
if key not in latest or it.properties["s2:generation_time"] > latest[key].properties["s2:generation_time"]:
latest[key] = it
items = sorted(latest.values(), key=lambda i: i.datetime)
print(f"{len(items)} scenes with tile cloud cover < 90%")
t0 = time.time()
geobox = GeoBox.from_bbox(AOI, crs=CRS, resolution=RES)
ds = odc.stac.load(items, bands=["B02", "B03", "B04", "B08", "SCL"], geobox=geobox, groupby="solar_day",
resampling={"SCL": "nearest", "*": "average"})
print(f"loaded {ds.sizes['time']} dates x {ds.sizes['y']} x {ds.sizes['x']} px in {time.time() - t0:.0f} s")
refl = {k: to_reflectance(ds[k].values) for k in ["B02", "B03", "B04", "B08"]}
nodata = ds["SCL"].values == 0
scl_bad = np.isin(ds["SCL"].values, [3, 8, 9, 10])
t0 = time.time()
ocm_bad = np.zeros_like(scl_bad)
for i in range(ds.sizes["time"]):
if not nodata[i].all():
ocm_bad[i] = omnicloudmask(refl["B04"][i], refl["B03"][i], refl["B08"][i]) > 0
print(f"OmniCloudMask on {ds.sizes['time']} dates: {time.time() - t0:.0f} s")
valid = (~nodata).sum(axis=(1, 2))
frac_scl = (scl_bad & ~nodata).sum(axis=(1, 2)) / np.maximum(valid, 1)
frac_ocm = (ocm_bad & ~nodata).sum(axis=(1, 2)) / np.maximum(valid, 1)
dates = pd.to_datetime(ds.time.values).strftime("%Y-%m-%d")
tab = pd.DataFrame({"date": dates, "SCL_%": frac_scl * 100, "OCM_%": frac_ocm * 100}).round(1)
print(tab.to_string(index=False))
print(f"dates at least 90% clear: SCL {np.sum(frac_scl < 0.1)}, OmniCloudMask {np.sum(frac_ocm < 0.1)}")
def median_composite(bad):
out = {}
for k, v in refl.items():
a = v.copy()
a[bad | nodata] = np.nan
out[k] = np.nanmedian(a, axis=0)
out["n"] = np.sum(~(bad | nodata), axis=0)
return out
comp = {"No mask": median_composite(np.zeros_like(scl_bad)),
"SCL": median_composite(scl_bad),
"OmniCloudMask": median_composite(ocm_bad),
"SCL + OmniCloudMask": median_composite(scl_bad | ocm_bad)}
ci = list(dates).index(CLEAR_DATE)
clear = {k: v[ci] for k, v in refl.items()}
print(f"\ncomposites compared with the clear date {CLEAR_DATE} "
f"(SCL masks {frac_scl[ci] * 100:.1f}% of it, OmniCloudMask {frac_ocm[ci] * 100:.1f}%):")
rows = []
for name, c in comp.items():
ndvi = (c["B08"] - c["B04"]) / (c["B08"] + c["B04"])
rows.append({"composite": name, "median_obs": np.median(c["n"]), "min_obs": int(c["n"].min()),
"no_data_%": np.mean(c["n"] == 0) * 100,
"brighter_blue_%": np.nanmean((c["B02"] - clear["B02"]) > 0.03) * 100,
"darker_nir_%": np.nanmean((c["B08"] - clear["B08"]) < -0.08) * 100,
"median_ndvi": np.nanmedian(ndvi)})
print(pd.DataFrame(rows).round(3).to_string(index=False))
# Before and after
pick = int(np.argmin(np.abs(frac_scl - 0.5))) # a typical half-cloudy date
fig, axes = plt.subplots(1, 3, figsize=(15, 5.3))
axes[0].imshow(rgb([refl[k][pick] for k in ["B04", "B03", "B02"]]), extent=EXTENT)
axes[0].set_title(f"Before: one scene, {dates[pick]}")
axes[1].imshow(rgb([comp["No mask"][k] for k in ["B04", "B03", "B02"]]), extent=EXTENT)
axes[1].set_title(f"Median of {ds.sizes['time']} dates, no mask")
axes[2].imshow(rgb([comp["SCL + OmniCloudMask"][k] for k in ["B04", "B03", "B02"]]), extent=EXTENT)
axes[2].set_title("After: median, SCL + OmniCloudMask masked")
for ax in axes:
ax.set_xticks([]); ax.set_yticks([])
fig.savefig(OUT / "05-composite-before-after.png")
plt.close(fig)
fig, axes = plt.subplots(1, 2, figsize=(11.5, 4.8))
vmax = np.percentile(comp["SCL"]["n"], 99)
for ax, name in zip(axes, ["SCL", "SCL + OmniCloudMask"]):
im = ax.imshow(comp[name]["n"], cmap="Spectral", vmin=0, vmax=vmax, extent=EXTENT, interpolation="nearest")
ax.set_title(f"Clear observations per pixel: {name}")
ax.set_xlabel("km east"); ax.set_ylabel("km north")
fig.colorbar(im, ax=ax, shrink=0.8, label="dates")
fig.savefig(OUT / "06-clear-observations.png")
plt.close(fig)
# Save the composite as a Cloud Optimised GeoTIFF for reuse
profile = dict(driver="COG", dtype="float32", count=4, width=W, height=H, crs=CRS,
transform=from_origin(AOI[0], AOI[3], RES, RES), nodata=np.nan, compress="deflate")
with rasterio.open(OUT / "lake_district_median_2026.tif", "w", **profile) as dst:
for i, k in enumerate(["B02", "B03", "B04", "B08"], start=1):
dst.write(comp["SCL + OmniCloudMask"][k].astype("float32"), i)
dst.set_band_description(i, k)
print(f"wrote {OUT / 'lake_district_median_2026.tif'}")