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.

Scene classification classes across a partly clouded forest scene A cross-section of a satellite scene. A cumulus cloud is labelled cloud high probability, with a medium probability fringe at its edge and a displaced cloud shadow on the ground to one side. A thin cirrus veil spans part of the scene above. Beneath them the ground is labelled vegetation, not vegetated where a clearcut sits, and water at a lake. The retained classes are marked with a keep symbol and the rejected ones with a cross. thin cirrus (10) — reject cloud high (9) reject cloud medium (8) fringe sun angle vegetation (4) clearcut — (5) vegetation (4) shadow (3) water (6) vegetation (4) keep keep keep reject keep* keep * keep water so lakes are not holes; drop it when the statistic is about canopy.

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.

An NDVI time series before and after cloud masking NDVI is plotted against date across a growing season for a single forest pixel. The unmasked series follows a smooth seasonal rise and fall but is interrupted by four sharp downward spikes on cloudy dates, one of which drops below zero. The masked series removes those four dates entirely, leaving a clean curve, and the removed points are marked with hollow circles. 0.0 0.3 0.6 0.9 Apr May Jun Jul Aug Sep NDVI masked (9 clear dates) raw (13 dates) Every removed point is a downward spike — which is exactly what an unmasked series reports as forest decline. The clear-observation count is the uncertainty layer A map panel shows the count of clear observations per pixel across a study area. Most of the lowland has between eighteen and twenty-four clear dates in the season. A mountain block in the north east has only two to four, because orographic cloud sits over it. Beside the map, two example composites are drawn: the lowland median is built from twenty-two observations and the mountain median from three, and both are rendered identically on the finished map. 2–4 18–24 clear dates clear-observation count lowland median from 22 observations mountain median from 3 observations one composite rendered identically Ship the count raster with the composite, or the map claims a confidence it does not have.

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.

Up: Vegetation Index Calculation · Ecological GIS Data Foundations in Python