Validating a Cloud Mask against Reference Labels
Compare the mask with labelled pixels and report both kinds of error, broken down by surface:
import numpy as np
omission = np.mean(~mask[ref == "cloud"]) # cloud the mask missed
commission = np.mean(mask[ref == "clear"]) # clear ground wrongly masked
print(f"cloud omission {omission:.1%}, commission {commission:.1%}")
A mask’s reputation is not a measurement of how it behaves on your scenes. This page belongs to cloud and shadow masking strategies in Satellite Processing Workflows & Index Pipelines.
Two Errors, Unequal Costs
The exception proves the rule: if the analysis is about snow, deserts or salt flats, commission over those surfaces removes exactly the data you need, and the balance shifts. That is why the breakdown by surface type matters as much as the overall figures.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
numpy |
>=1.23 |
Error rates |
pandas |
>=2.0 |
Per-surface breakdowns |
geopandas |
>=0.14 |
Reference points with labels |
rasterio |
>=1.3.0 |
Sampling the mask at reference locations |
pip install "numpy>=1.23" "pandas>=2.0" "geopandas>=0.14" "rasterio>=1.3.0"
Complete Working Example
import geopandas as gpd
import numpy as np
import pandas as pd
import rasterio
def sample_mask(mask_path: str, refs: gpd.GeoDataFrame) -> np.ndarray:
with rasterio.open(mask_path) as src:
pts = refs.to_crs(src.crs)
vals = np.array([v[0] for v in src.sample(zip(pts.geometry.x, pts.geometry.y))])
return vals.astype(bool)
def score(refs: gpd.GeoDataFrame, masked: np.ndarray, *, edge_px: np.ndarray | None = None) -> pd.DataFrame:
df = refs.assign(masked=masked)
if edge_px is not None:
df = df.assign(near_edge=edge_px)
rows = []
for surface, g in df.groupby("surface"):
cloud, clear = g[g.label == "cloud"], g[g.label == "clear"]
rows.append({
"surface": surface,
"n_cloud": len(cloud), "n_clear": len(clear),
"omission": float((~cloud.masked).mean()) if len(cloud) else np.nan,
"commission": float(clear.masked.mean()) if len(clear) else np.nan,
})
out = pd.DataFrame(rows).set_index("surface")
total_cloud, total_clear = df[df.label == "cloud"], df[df.label == "clear"]
out.loc["ALL"] = [len(total_cloud), len(total_clear),
float((~total_cloud.masked).mean()), float(total_clear.masked.mean())]
return out
The per-surface table is the output worth studying. A mask with 5% overall omission may have 1% over forest and 25% over bright urban areas, and whether that is acceptable depends entirely on where your analysis lives. Reference points are best collected with the same spatial and scene-level diversity as any validation sample, following the design principles in validating raster predictions and accuracy assessment.
Edges Deserve Their Own Score
Stratifying reference samples by distance to the nearest cloud boundary, and reporting omission separately for edges and cores, shows exactly what a dilation buffer should fix. The buffer sizing described in dilating cloud masks to catch thin cirrus can then be tuned against the edge omission rate directly rather than by eye.
Building Reference Labels Efficiently
Labelling pixels is slow, and the reference set is only useful if it covers the situations that break masks. A sampling design stratified by predicted class, by distance to cloud boundaries and by surface type concentrates effort where masks disagree with reality. Interpreting each point on a true-colour and a false-colour composite — plus the cirrus band where available — makes thin cloud visible that neither view shows alone.
Keep the reference set versioned and reuse it: once built, it scores every future mask variant, threshold and buffer in seconds, which turns mask tuning from argument into measurement.
Choosing Thresholds from the Scores
Most masks expose at least one tunable number — a cloud probability threshold for s2cloudless, a buffer distance, a cirrus reflectance cut-off. With a reference set in hand, each setting becomes a point on an omission-versus-commission curve. Sweep the parameter, score every value, and pick the setting where omission reaches the level your application tolerates, then read off what commission that costs. Plotting the curve for each surface type separately often shows that a single global threshold is a poor compromise and that a surface-dependent rule — a stricter threshold over bright ground, a looser one over vegetation — gives better results for both errors.
Guard against tuning to the reference set itself. If the same points are used to choose the threshold and to report its performance, the reported numbers are optimistic. Split the references by scene, tune on one half, and report the score on the other, exactly as with any model validation.
Reporting the Result
A useful cloud-mask report is short: the reference set’s size and composition, overall omission and commission with confidence intervals, the per-surface table, the edge-versus-core breakdown, and the chosen parameters. Keep it next to the pipeline configuration, in version control, so anyone reading a composite months later can find out exactly how clean its inputs were. When the mask algorithm, the processing baseline of the input product, or the region changes, re-run the scoring rather than assuming the old numbers still hold — cloud-mask behaviour is notoriously regional, and a mask validated in temperate Europe can behave very differently over the Sahel or the Andes.
Verification
counts = refs.groupby(["surface", "label"]).size().unstack(fill_value=0)
print(counts)
assert (counts.min(axis=1) >= 100).all(), "some surface/label cells too small to score"
assert refs["scene_id"].nunique() >= 10, "references come from too few scenes"
Common Errors
Scores look excellent but composites are contaminated
The references under-sample cloud edges and cirrus. Stratify by distance to cloud boundary.
Commission is very high in one region
That region has bright surfaces the mask mistakes for cloud. Check the per-surface table and add a surface-specific rule.
Sampling the mask returns wrong values
The reference points were not reprojected to the mask CRS. Reproject before sampling.
Results change between reviewers
Label definitions differ — especially for thin haze. Write a short labelling guide and double-label a subset.
Frequently Asked Questions
Q: Which cloud-mask error is worse, omission or commission? For most land analysis, omission — missed cloud contaminates composites, indices and models. Commission only removes some clear observations. Tune masks to low omission and accept a modest commission rate.
Q: Why do masks fail over bright surfaces? Snow, salt pans, bright sand and some urban roofs are as bright as cloud in visible bands. Masks commonly over-flag them, so validation must include those surfaces explicitly or it will overstate performance.
Q: How many reference pixels are needed? A few thousand pixels drawn from many scenes and surface types, rather than many pixels from few scenes. Diversity of conditions matters far more than raw count, because cloud mask errors cluster by situation.
Q: Should shadows be scored separately from cloud? Yes. Shadow masks fail differently — mainly through misplacement relative to their clouds and confusion with dark water or terrain shadow — and folding them into the cloud score hides both problems.
Related
- Cloud and Shadow Masking Strategies — the parent topic.
- Dilating Cloud Masks to Catch Thin Cirrus — the tuning this validation informs.
- Computing Confusion Matrices from Raster Pairs — the general form of the same comparison.
- Estimating Per-Scene Cloud Cover in Python — scene-level figures built on the mask.