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.

Every SCL class, and what to do with it Classes four, five and six are the vegetation, bare soil and water pixels most analyses keep. Classes three, eight, nine, ten and eleven are shadow, cloud and ice and are always dropped. Classes one, two and seven are saturated, dark and low-probability-cloud pixels whose treatment depends on whether you are prioritising coverage or cleanliness. SCL classes grouped by what a pipeline should do with them keep 4 — vegetation 5 — not vegetated 6 — water the analysis-ready surface for most index work always drop 3 — cloud shadow 8 — cloud, medium probability 9 — cloud, high probability 10 — thin cirrus 11 — snow and ice unless snow is your subject judgement call 0 — no data 1 — saturated / defective 2 — dark area pixels 7 — cloud, low probability class 7 over dark water is usually not cloud at all SCL is 20 m: resample it to 10 m with nearest before applying it to B04 or B08. Any other method invents class codes that mean nothing.
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

Why the mask needs a buffer SCL labels the cloud core but not the semi-transparent fringe around it, so an unbuffered mask leaves a ring of brightened pixels that biases every index. Dilating the mask by two or three pixels removes the fringe at the cost of a small amount of genuinely clear data. mask as SCL labels it after binary_dilation(iterations=3) masked core the pale ring is thin cloud SCL never flagged masked core + buffer the fringe is gone; a little clear data goes with it Two or three 10 m pixels is usually enough — check the effect on your retained-pixel count before going wider.

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.


Frequently Asked Questions

Q: Which SCL classes should I mask for clouds and shadows? Mask class 3 (cloud shadows), 8 (cloud medium probability), 9 (cloud high probability), 10 (thin cirrus), and usually 11 (snow/ice) depending on the study. Class 1 (saturated/defective) and 0 (no data) should also be dropped as invalid.

Q: Why dilate the SCL cloud mask? The SCL classifier under-flags thin cloud edges and semi-transparent halos, so reflectance just outside a flagged cloud is still contaminated. Dilating the mask by two or three pixels removes that fringe before it corrupts an index or a composite.

Q: How do I match the 20 m SCL band to 10 m reflectance? Resample the SCL mask to 10 m with nearest-neighbour interpolation. SCL holds categorical class codes, so bilinear or cubic would invent fractional class values that mean nothing; nearest preserves the labels.