Masking Clouds with the Sentinel-2 Scene Classification Layer in Python
An unmasked cloud is not noise — it is a bright, systematic, seasonally clustered bias, and in a forest NDVI series it looks exactly like a disturbance. Sentinel-2 Level-2A products ship a Scene Classification Layer (SCL) that labels every 20 m pixel as vegetation, cloud, shadow, snow and so on, and it is the cheapest defensible mask available. Using it well is a matter of choosing classes deliberately, dilating the result, and knowing what it misses. This guide covers that, inside Vegetation Index Calculation in Python, part of Ecological GIS Data Foundations in Python.
When to use this approach
| Mask source | Cost | Strength | Weakness |
|---|---|---|---|
Scene-level eo:cloud_cover |
Free, in the STAC metadata | Cheap pre-filter | Describes a 110 km tile, not your stands |
| SCL band | Ships with every L2A product | Per-pixel, no extra download | Misses thin cirrus and shadow edges |
Cloud probability band (MSK_CLDPRB) |
Ships with L2A | A continuous score you can threshold | Still misses shadow |
| s2cloudless | Extra dependency or asset | Better thin-cloud detection | Another model to validate |
| Temporal outlier rejection | Compute only | Catches what every mask misses | Needs a dense series to be safe |
The practical recipe for forest work is SCL plus a dilation, followed by a temporal median that absorbs whatever leaked through. Scene-level cloud cover belongs in the STAC query as a loose pre-filter only — as covered in Searching STAC Catalogs for Sentinel-2 with pystac-client.
The classes, and which to keep
SCL is a single band of small integers. The choice of which values count as usable is the whole method.
| Value | Class | Keep for forest indices? |
|---|---|---|
| 0 | No data | No |
| 1 | Saturated or defective | No |
| 2 | Dark area pixels | No — often deep terrain shadow |
| 3 | Cloud shadow | No |
| 4 | Vegetation | Yes — the target class |
| 5 | Not vegetated | Yes — bare soil, harvested stands, roads |
| 6 | Water | Usually yes, so lakes are not gaps; exclude for canopy statistics |
| 7 | Unclassified | Yes, with caution — often haze-affected edges |
| 8 | Cloud medium probability | No |
| 9 | Cloud high probability | No |
| 10 | Thin cirrus | No for index work; it depresses NDVI by a few hundredths |
| 11 | Snow or ice | No — and it is regularly confused with bright cloud |
Keeping class 5 matters more than it looks. A clearcut, a fresh burn, or a heavily thinned stand classifies as “not vegetated”, and dropping it removes exactly the pixels a disturbance analysis exists to find — producing a time series in which harvest looks like missing data.
Minimal reproducible example
The mask itself is one isin. Everything that makes it work is the dilation around it — SCL boundaries are conservative, and a cloud’s real influence extends a pixel or two beyond where the classifier draws its edge.
import numpy as np
import rasterio
from scipy.ndimage import binary_dilation
CLEAR_CLASSES = (4, 5, 6, 7) # vegetation, bare, water, unclassified
CLOUDY_CLASSES = (3, 8, 9, 10, 11) # shadow, cloud, cirrus, snow
def clear_mask(scl: np.ndarray, dilate_px: int = 3,
keep_water: bool = True) -> np.ndarray:
"""Boolean mask of usable pixels from a Sentinel-2 SCL band."""
keep = set(CLEAR_CLASSES)
if not keep_water:
keep.discard(6)
clear = np.isin(scl, list(keep))
if dilate_px:
# Grow the cloud/shadow footprint outward; SCL edges are optimistic.
bad = np.isin(scl, CLOUDY_CLASSES)
structure = np.ones((3, 3), dtype=bool)
bad = binary_dilation(bad, structure=structure, iterations=dilate_px)
clear &= ~bad
return clear
def masked_ndvi(red_path: str, nir_path: str, scl_path: str,
dilate_px: int = 3) -> tuple[np.ndarray, dict]:
"""NDVI with cloud, shadow and cirrus set to NaN."""
with rasterio.open(red_path) as r, rasterio.open(nir_path) as n, \
rasterio.open(scl_path) as s:
red = r.read(1).astype("float32")
nir = n.read(1).astype("float32")
scl = s.read(1, out_shape=red.shape,
resampling=rasterio.enums.Resampling.nearest)
profile = r.profile
clear = clear_mask(scl, dilate_px=dilate_px)
denom = nir + red
ndvi = np.where(denom != 0, (nir - red) / denom, np.nan).astype("float32")
ndvi[~clear] = np.nan
profile.update(dtype="float32", nodata=np.nan, count=1)
return ndvi, profile
Two details are load-bearing. SCL is delivered at 20 m while red and NIR are 10 m, so it must be resampled — and with nearest resampling, because averaging class codes 4 and 9 produces 6, which is water. And the guard on denom != 0 prevents a division that would otherwise emit warnings and infinities across every no-data pixel.
Parameter reference
| Parameter | Type | Default | Range | Rationale |
|---|---|---|---|---|
CLEAR_CLASSES |
tuple | (4, 5, 6, 7) | — | Keep bare soil (5) or harvest disappears from the series |
dilate_px |
int | 3 | 1–5 | At 20 m that is 60 m of buffer; raise it for cumulus, lower for thin haze |
keep_water |
bool | True |
— | Set False for canopy statistics so lakes do not enter the mean |
| SCL resampling | enum | nearest | — | Class codes must never be interpolated |
| cirrus (10) | keep/drop | drop | — | Cirrus lowers NDVI by roughly 0.02–0.05, enough to fake a decline |
| snow (11) | keep/drop | drop | — | Frequently confused with bright cloud; both are unusable anyway |
Expected output and verification
The number that decides whether a composite is trustworthy is not the mask fraction on any one date — it is the count of clear observations per pixel across the series. A median over three observations is a very different statistic from a median over thirty.
import numpy as np
def clear_observation_count(scl_stack: np.ndarray, dilate_px: int = 3) -> np.ndarray:
"""Per-pixel count of clear dates from a stacked SCL array (time, y, x)."""
counts = np.zeros(scl_stack.shape[1:], dtype="int16")
for t in range(scl_stack.shape[0]):
counts += clear_mask(scl_stack[t], dilate_px=dilate_px).astype("int16")
return counts
counts = clear_observation_count(scl_stack)
print({
"dates": int(scl_stack.shape[0]),
"min_clear": int(counts.min()),
"median_clear": int(np.median(counts)),
"pixels_under_5": int((counts < 5).sum()),
})
assert np.median(counts) >= 5, "fewer than 5 clear dates at the median pixel — widen the window"
Ship that count raster alongside every composite. It is the honest uncertainty layer: a pixel with two clear observations and a pixel with thirty carry the same NDVI value and very different confidence, and without the count nothing downstream can tell them apart.
The second check is that masking actually changed the distribution in the direction it should. Clouds are bright in red and dark in NIR relative to vegetation, so removing them should raise the NDVI mean and shrink its spread:
def mask_effect(ndvi_raw: np.ndarray, ndvi_masked: np.ndarray) -> dict:
"""Confirm the mask moved the distribution the way clouds imply."""
raw = ndvi_raw[np.isfinite(ndvi_raw)]
msk = ndvi_masked[np.isfinite(ndvi_masked)]
return {
"kept_frac": round(float(msk.size / raw.size), 3),
"mean_raw": round(float(raw.mean()), 3),
"mean_masked": round(float(msk.mean()), 3),
"sd_raw": round(float(raw.std()), 3),
"sd_masked": round(float(msk.std()), 3),
}
eff = mask_effect(ndvi_raw, ndvi_masked)
assert eff["mean_masked"] >= eff["mean_raw"], f"masking lowered mean NDVI: {eff}"
assert eff["sd_masked"] <= eff["sd_raw"], f"masking widened the spread: {eff}"
If masking lowers the mean, the mask is inverted — a mistake that is otherwise invisible because the output still looks like a plausible NDVI raster.
Common pitfalls
- Resampling SCL bilinearly. Interpolating class codes invents classes. Always nearest.
- Dropping class 5. Harvested and burned stands classify as not-vegetated, so excluding it deletes the disturbances a monitoring series exists to detect.
- No dilation. SCL cloud edges are tight, and the pixels immediately outside them are contaminated by adjacency scattering. Three pixels of dilation is cheap insurance.
- Trusting the mask over snow. Snow and bright cloud are routinely confused in both directions. In winter series, exclude both and expect a genuine seasonal data gap.
- Compositing without reporting the clear count. A median over two observations is not comparable with a median over twenty, and nothing in the output raster records which one you have.
Frequently Asked Questions
Should I use the cloud probability band instead of SCL?
They complement each other. MSK_CLDPRB gives a continuous probability you can threshold to taste, which is useful when SCL is too aggressive over bright bare soil, but it says nothing about shadow. The usual combination is SCL for shadow and snow plus a probability threshold for cloud.
How do I handle shadow that SCL misses?
Shadow sits on the opposite side of a cloud from the sun, at a distance set by cloud height and sun elevation, so it can be projected geometrically: take the cloud mask, shift it along the solar azimuth by a plausible range of heights, and add the union to the mask. It over-masks slightly, which for a dense time series is the right direction to err.
Is masking still necessary if I take a median composite?
Less necessary, not unnecessary. A median is robust to a minority of contaminated observations, so with twenty clear dates it will absorb two clouds. With five observations of which three are cloudy the median is cloud. Mask first, then let the median clean up the remainder.
Related
- Vegetation Index Calculation in Python — the wider index workflow this mask protects
- Calculating NDVI from Sentinel-2 with rasterio — the index computation this feeds
- Chunked Raster Processing with xarray and dask — applying this mask across a lazy stack
- Measuring Post-Fire NDVI Recovery Trajectories in Python — an analysis that an unmasked series would ruin
Up: Vegetation Index Calculation · Ecological GIS Data Foundations in Python