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.
- cloud_masking.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 cloud_masking.pycloud_masking.py
"""Sentinel-2 cloud and cloud-shadow masking in Python: SCL vs s2cloudless vs OmniCloudMask vs asimple 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 osimport sysimport timeimport warningsfrom importlib.metadata import versionfrom 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 npimport pandas as pdimport requestsimport rasteriofrom rasterio.enums import Resamplingfrom rasterio.transform import from_originfrom rasterio.windows import from_boundsfrom scipy import ndimageimport pystac_clientimport planetary_computer as pcimport odc.stacfrom odc.geo.geobox import GeoBoximport matplotlibmatplotlib.use("Agg") # render to files onlyimport matplotlib.pyplot as pltfrom matplotlib.colors import ListedColormapfrom matplotlib.patches import Patchfrom s2cloudless import S2PixelCloudDetectorfrom 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 30UVFAOI = (478000, 6030000, 498000, 6050000) # 20 x 20 km: Keswick, Derwentwater, Borrowdale, ButtermereRES = 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 datesCOMPOSITE_PERIOD = "2026-05-01/2026-09-30"CLEAR_DATE = "2026-07-12" # the clearest summer date, used to check the compositesN_POINTS = 80 # reference points per evaluation dateSEED = 42
# Every mask uses the same codes: 0 clear, 1 cloud, 2 cloud shadowCLEAR, CLOUD, SHADOW = 0, 1, 2MASK_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 scenessection("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 bandssection("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 masksection("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 bandssection("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: OmniCloudMasksection("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 baselinesection("Step 6: threshold baseline")BLUE_CLOUD = 0.18 # clear land in summer has B02 reflectance of about 0.02-0.08NIR_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 pointssection("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 maskHALF = 32for 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 datessection("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.2for 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 compositesection("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 processingfor 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] = ititems = 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 == 0scl_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]) > 0print(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 afterpick = int(np.argmin(np.abs(frac_scl - 0.5))) # a typical half-cloudy datefig, 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 reuseprofile = 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'}")