Masking Clouds with the Sentinel-2 SCL Band

The Sentinel-2 Level-2A product ships a Scene Classification (SCL) band that already labels each pixel as vegetation, water, cloud, shadow, cirrus, and so on. To mask clouds and shadows, read the SCL band, select the contaminated class codes, and set matching reflectance pixels to NaN:

import numpy as np
import rasterio

CONTAMINATED = {3, 8, 9, 10, 11}  # shadow, cloud med/high, cirrus, snow

with rasterio.open("SCL_20m.tif") as scl_src:
    scl = scl_src.read(1)

mask = np.isin(scl, list(CONTAMINATED))  # True where contaminated

This is the fast, label-driven path inside Cloud and Shadow Masking Strategies. It is distinct from Implementing FMask and s2cloudless in Python: those are machine-learning cloud detectors you run yourself, whereas SCL is a categorical bitmask the ESA processor already computed. When the L2A product exists, SCL is the zero-cost first line of defence.


Why This Arises in Remote Sensing Workflows

Any multi-temporal Sentinel-2 analysis — vegetation trends, water extent, change detection — collapses the moment cloudy pixels enter the arithmetic. A cloud reads as a bright, spectrally flat surface, so an NDVI computed over one silently reports bare soil, and a median composite pulls toward whichever dates happened to be clear. Masking has to happen before any index or aggregation.

The SCL band exists precisely so you do not need to run a detector on every scene. During Level-2A processing, the ESA Sen2Cor processor classifies every 20 m pixel into one of twelve classes — vegetation, bare soil, water, cloud at two probability levels, cirrus, shadow, snow, and a few housekeeping categories — and stores the result alongside the reflectance bands. Reading it and selecting a handful of codes gives you a usable mask in milliseconds, with no model weights to download and no per-pixel inference to run.

The two refinements that separate a naive SCL mask from a production one are dilation and resolution alignment. Dilation grows the mask outward to catch the semi-transparent fringe the classifier under-flags: a cloud does not have a crisp edge, and the reflectance in the halo just outside a flagged cloud is still contaminated even though SCL labelled it clear. Resolution alignment matters because SCL is delivered at 20 m while the visible and near-infrared bands you are masking are at 10 m, so the mask has to be upsampled — and it must be upsampled with nearest-neighbour, because the class codes are categorical labels that averaging would turn into nonsense.

This page sits in Cloud and Shadow Masking Strategies under Satellite Processing Workflows & Index Pipelines. A clean SCL mask is the input that feeds Spectral Index Calculation Pipelines: mask first, then compute indices only over valid ground.


SCL Classes and the Masking Decision

The diagram shows the SCL class codes and which ones a typical cloud-and-shadow mask removes.

Sentinel-2 SCL class codes: kept versus masked The twelve SCL class codes are listed. Classes 0, 1, 3, 8, 9, 10 and 11 are masked as invalid, shadow, cloud, cirrus or snow, while vegetation, bare soil, water and unclassified are kept. Masked (set to NaN) Kept 0 — No data 1 — Saturated / defective 3 — Cloud shadows 8 — Cloud medium probability 9 — Cloud high probability 10 — Thin cirrus 11 — Snow / ice 2 — Dark area / topo shadow 4 — Vegetation 5 — Bare soil 6 — Water 7 — Unclassified Then dilate 2–3 px to catch cloud-edge halos

Class 2 (dark/topographic shadow) is often kept because masking it removes valid terrain-shaded ground; class 11 (snow) is masked only when snow is not your subject. The set is a study decision, not a fixed rule. It is worth being deliberate about the borderline classes: SCL’s cloud-shadow class (3) is geometrically projected from detected clouds and can miss shadows whose parent cloud was itself missed, while the medium-probability cloud class (8) sometimes over-flags bright bare soil or rooftops. For a conservative composite you accept a little over-masking and keep class 8 in the contaminated set; for maximum data retention over an arid site you might treat class 8 more carefully. The registry-style approach of defining the contaminated set as a named constant, rather than scattering literal codes through the code, makes that policy explicit and easy to audit.


Environment & Setup

Package Minimum version Why required
rasterio 1.3.0 Read the SCL and reflectance assets
numpy 1.24 np.isin class selection and NaN masking
scipy 1.10 binary_dilation to grow the mask
pip install "rasterio>=1.3.0" "numpy>=1.24" "scipy>=1.10"

Complete Working Example

The function reads SCL, builds and dilates the contamination mask, resamples it from 20 m to the 10 m reflectance grid with nearest-neighbour, and applies it to a stack of reflectance bands.

import numpy as np
import rasterio
from rasterio.enums import Resampling
from scipy.ndimage import binary_dilation

# SCL codes considered contaminated for a cloud + shadow mask
CONTAMINATED = (0, 1, 3, 8, 9, 10, 11)

def scl_cloud_mask(
    scl_path: str,
    reflectance_paths: list[str],
    dilate_px: int = 2,
) -> np.ndarray:
    """
    Return a reflectance stack (bands, rows, cols) as float32 with
    cloud/shadow pixels set to NaN, using the SCL band as the source of truth.
    """
    # 1. Read SCL and build the contamination mask (20 m grid)
    with rasterio.open(scl_path) as scl_src:
        scl = scl_src.read(1)
    mask_20 = np.isin(scl, CONTAMINATED)

    # 2. Dilate to remove the semi-transparent cloud fringe SCL under-flags
    if dilate_px > 0:
        mask_20 = binary_dilation(mask_20, iterations=dilate_px)

    # 3. Read reflectance and resample the mask to its (10 m) shape with nearest
    bands = []
    ref_shape = None
    for path in reflectance_paths:
        with rasterio.open(path) as src:
            ref_shape = (src.height, src.width)
            bands.append(src.read(1).astype("float32"))
    stack = np.stack(bands)

    # nearest-neighbour upsample preserves categorical mask labels
    with rasterio.open(scl_path) as scl_src:
        mask_10 = scl_src.read(
            1,
            out_shape=ref_shape,
            resampling=Resampling.nearest,
        )
    mask_10 = np.isin(mask_10, CONTAMINATED)
    if dilate_px > 0:
        mask_10 = binary_dilation(mask_10, iterations=dilate_px)

    # 4. Apply — contaminated pixels become NaN across every band
    stack[:, mask_10] = np.nan
    return stack


if __name__ == "__main__":
    masked = scl_cloud_mask(
        scl_path="SCL_20m.tif",
        reflectance_paths=["B04_10m.tif", "B08_10m.tif"],
        dilate_px=2,
    )
    valid_fraction = np.isfinite(masked[0]).mean()
    print(f"Valid pixels after masking: {valid_fraction:.1%}")

Note that the mask is resampled to 10 m with Resampling.nearest. SCL holds categorical codes, so a bilinear or cubic resample would produce fractional class values — a meaningless 8.4 between cloud and vegetation — whereas nearest keeps every label intact. The dilation is applied on both the 20 m and the upsampled grid so the fringe is grown consistently regardless of which resolution you inspect. The returned valid_fraction is a cheap quality signal you can log per scene: a value near 1.0 means an almost cloud-free acquisition, while anything below roughly 0.4 usually means the scene is too contaminated to contribute much to a composite.

One subtlety worth internalising is that masking to NaN, rather than dropping pixels or filling them, is what lets the mask compose with everything downstream. A NaN pixel is ignored by an NaN-aware index calculation, skipped by a NaN-aware temporal reducer, and excluded from statistics — all without any per-step bookkeeping. The mask does its job once, at the reflectance stage, and the choice propagates for free.


Variant Patterns

1. Masking an xarray NDVI DataArray

If you compute indices with xarray, apply the mask with .where. NaN is the natural sentinel and propagates cleanly into later temporal aggregation.

import xarray as xr

scl = xr.open_dataarray("SCL_10m.tif")
contaminated = scl.isin([0, 1, 3, 8, 9, 10, 11])
ndvi_clean = ndvi.where(~contaminated)   # contaminated pixels -> NaN

The masked index is the direct input to Calculating NDVI directly from xarray DataArrays.

2. Recording a per-scene valid fraction

Before adding a scene to a composite, reject frames that are mostly cloud. The valid fraction from the mask is a cheap gate.

import numpy as np

valid_fraction = 1.0 - np.isin(scl, CONTAMINATED).mean()
if valid_fraction < 0.4:
    print("Scene >60% contaminated — skip for compositing")

3. Keeping snow when snow is the subject

For cryosphere work, drop 11 from the contaminated set so snow/ice pixels survive while clouds and shadows are still removed.

SNOW_SAFE = (0, 1, 3, 8, 9, 10)   # 11 (snow) retained
mask = np.isin(scl, SNOW_SAFE)

Common Errors

The mask has the wrong shape for the reflectance bands

SCL is delivered at 20 m and the visible/NIR bands at 10 m. Resample the SCL mask to the reflectance shape with Resampling.nearest before applying it, or you will hit a broadcast error or silently misalign the mask.

Thin cloud edges still contaminate the index

The raw SCL classes miss semi-transparent cloud halos. Dilate the boolean mask with scipy.ndimage.binary_dilation by two or three pixels so the fringe around each flagged cloud is removed as well.

Fractional class codes appear after resampling

The SCL band was resampled with bilinear or cubic interpolation, which averages categorical labels into meaningless fractions. Always use nearest-neighbour for class rasters like SCL and QA bands.