Automating Cloud Removal in Sentinel-2 Time Series

TL;DR: Parse the Sentinel-2 Scene Classification Layer (SCL) band to flag cloud, cloud-shadow, and cirrus pixels as NaN, stack all acquisitions along a time dimension with xarray, then collapse the stack with .median(dim="time", skipna=True) to produce a single cloud-free surface-reflectance composite. The whole operation is ~80 lines of production Python with no manual date selection.

Context

Sentinel-2 revisits most agricultural areas every 5 days, but in practice only 30–60 % of acquisitions over a full growing season are cloud-free at the pixel level. If you feed raw, unmasked imagery into vegetation index pipelines, cloud pixels register as artificially high NIR reflectance, producing NDVI spikes that look identical to healthy dense canopy. Downstream yield models and stress-detection algorithms trained on these spikes accumulate silent errors throughout the season.

Manually discarding contaminated scenes is unsustainable at scale. An automated pipeline that reconstructs a clean surface-reflectance stack from the valid pixels across many dates eliminates manual curation and gives analysts a deterministic, reproducible product. The output of this workflow feeds directly into band math & raster algebra in Python for NDVI, NDRE, or EVI2 calculation without any intermediate visual inspection step.

Sentinel-2 cloud removal pipeline Five-stage pipeline: L2A files on disk, SCL mask generation, band loading, xarray time-stack, and median composite output L2A files (bands + SCL) SCL mask valid=1 / NaN Masked band arrays xarray time-stack Median composite Step 1 Step 2 Step 3 Step 4 Step 5 Sentinel-2 Automated Cloud-Removal Pipeline SCL band parsing → NaN masking → xarray stacking → median composite

Prerequisites

This guide assumes your environment already meets the baseline requirements described in Cloud Masking for Agricultural Imagery. The additional packages specific to this time-series workflow are:

  • xarray>=2023.1 — time-dimension stacking and NaN-aware reduction
  • rasterio>=1.3 — GeoTIFF I/O and spatial metadata preservation
  • numpy>=1.24np.isin() for SCL class filtering

Input data must be Sentinel-2 Level-2A products (not Level-1C). The SCL band is computed by the ESA sen2cor processor and ships as a separate *_SCL_20m.tif file inside each granule. All band GeoTIFFs for a given date must share the same CRS (EPSG:326xx for UTM), extent, and resolution — if yours come from different tile granules, reproject them first using the approach in understanding CRS in precision agriculture.

Step-by-Step Implementation

The complete pipeline below processes an arbitrary number of Sentinel-2 acquisitions from a directory tree, builds NaN masks from the SCL, stacks all dates into an xarray.Dataset, and produces a single cloud-free composite GeoTIFF.

Step 1 — Organise your L2A files

Collect SCL and reflectance band GeoTIFFs into two parallel directories. The function below enumerates dates automatically by parsing the standard Sentinel-2 filename convention (T32TQM_20230615T105021_SCL.tif).

PYTHON
import os
import glob
import numpy as np
import rasterio
import xarray as xr
from pathlib import Path
from typing import List, Optional, Dict


def _extract_date(filename: str) -> str:
    """Return the 8-digit acquisition date (YYYYMMDD) from a Sentinel-2 filename."""
    stem = Path(filename).stem           # e.g. T32TQM_20230615T105021_SCL
    parts = stem.split("_")
    for part in parts:
        if len(part) >= 8 and part[:8].isdigit():
            return part[:8]
    raise ValueError(f"Cannot parse date from filename: {filename}")


def collect_file_pairs(
    bands_dir: str,
    scl_dir: str,
    target_bands: List[str]
) -> Dict[str, Dict]:
    """
    Return a dict keyed by YYYYMMDD, each value a dict with 'scl' path and
    per-band paths.  Dates missing any band or SCL file are silently skipped.
    """
    scl_files = sorted(glob.glob(os.path.join(scl_dir, "*_SCL*.tif")))
    pairs: Dict[str, Dict] = {}

    for scl_path in scl_files:
        date = _extract_date(scl_path)
        band_paths = {}
        for band in target_bands:
            matches = glob.glob(
                os.path.join(bands_dir, f"*{date}*_{band}*.tif")
            )
            if not matches:
                break                     # skip incomplete dates
            band_paths[band] = matches[0]
        else:
            pairs[date] = {"scl": scl_path, "bands": band_paths}

    if not pairs:
        raise FileNotFoundError(
            "No complete date entries found.  Check directory paths and filename patterns."
        )
    return pairs

Step 2 — Build the SCL validity mask

The SCL integer values that represent valid land surfaces for agricultural analysis are classes 4 (Vegetation), 5 (Bare Soils), and 6 (Water). Everything else — cloud shadows (3), medium-probability cloud (8), high-probability cloud (9), and thin cirrus (10) — becomes NaN.

SCL Value Classification Action
4 Vegetation Keep
5 Bare Soils Keep
6 Water Keep
7 Unclassified Optional fallback
3 Cloud Shadow Exclude
8 Cloud Medium Probability Exclude
9 Cloud High Probability Exclude
10 Thin Cirrus Exclude
11 Snow / Ice Exclude
PYTHON
VALID_SCL_CLASSES = [4, 5, 6]   # adjust to include class 7 in persistently cloudy areas


def build_scl_mask(scl_path: str, valid_classes: List[int] = VALID_SCL_CLASSES) -> np.ndarray:
    """
    Read the SCL band and return a float32 array:
      1.0  → valid pixel
      NaN  → cloud / shadow / invalid pixel
    """
    with rasterio.open(scl_path) as src:
        scl = src.read(1)                  # SCL is always single-band

    mask = np.where(np.isin(scl, valid_classes), 1.0, np.nan).astype(np.float32)

    # Sanity check: a mask that is entirely NaN means the scene is fully clouded
    valid_fraction = np.nansum(mask) / mask.size
    if valid_fraction < 0.01:
        import warnings
        warnings.warn(
            f"SCL mask for {Path(scl_path).name} is >99 % invalid "
            f"(valid fraction: {valid_fraction:.3f}). "
            "Consider excluding this date."
        )
    return mask

Step 3 — Load and mask reflectance bands

Multiply each band array by the validity mask so invalid pixels become NaN. Sentinel-2 L2A DN values must be scaled by 0.0001 to convert to surface reflectance in the 0–1 range.

PYTHON
SCALE_FACTOR = 0.0001   # ESA sen2cor Level-2A scaling


def load_masked_bands(
    band_paths: Dict[str, str],
    mask: np.ndarray
) -> Dict[str, np.ndarray]:
    """
    Load each band, scale to surface reflectance, and apply the validity mask.
    Returns a dict of float32 arrays with NaN on invalid pixels.
    """
    arrays: Dict[str, np.ndarray] = {}
    for band_name, path in band_paths.items():
        with rasterio.open(path) as src:
            raw = src.read(1).astype(np.float32)
            # Preserve spatial profile for export (taken from the last band read — reuse later)
            profile = src.profile
        reflectance = np.clip(raw * SCALE_FACTOR, 0.0, 1.0)
        arrays[band_name] = reflectance * mask   # NaN where mask is NaN
    return arrays, profile

Step 4 — Stack along time and compute the median composite

xr.concat() aligns all dates along a new time dimension. .median(dim="time", skipna=True) then collapses the stack, automatically ignoring NaN (clouded) observations and using only valid pixel values.

PYTHON
def build_composite(
    file_pairs: Dict[str, Dict],
    target_bands: List[str],
    valid_classes: List[int] = VALID_SCL_CLASSES
) -> tuple[xr.Dataset, dict]:
    """
    Iterate over all dates, mask each date's bands, stack into an xarray Dataset,
    and return a median composite along the time axis.
    """
    date_datasets = []
    dates = sorted(file_pairs.keys())       # chronological order
    profile_ref = None

    for date in dates:
        scl_path = file_pairs[date]["scl"]
        band_paths = file_pairs[date]["bands"]

        mask = build_scl_mask(scl_path, valid_classes)
        arrays, profile = load_masked_bands(band_paths, mask)

        if profile_ref is None:
            profile_ref = profile

        ds = xr.Dataset(
            {band: xr.DataArray(arr, dims=["y", "x"])
             for band, arr in arrays.items()}
        )
        date_datasets.append(ds)

    if not date_datasets:
        raise ValueError("All dates were skipped (fully clouded or missing files).")

    # Stack along time dimension
    stack = xr.concat(date_datasets, dim="time")
    stack = stack.assign_coords(time=dates)

    # Observation-count layer: how many valid (non-NaN) pixels contributed per position
    obs_count = (~stack.isnull()).sum(dim="time")

    # Median composite — NaN-aware
    composite = stack.median(dim="time", skipna=True)

    # Flag pixels with fewer than 2 valid observations as unreliable
    min_obs = 2
    for band in target_bands:
        composite[band] = xr.where(obs_count[band] >= min_obs, composite[band], np.nan)

    return composite, profile_ref

Step 5 — Export the composite and validate

Write the composite to a Cloud Optimized GeoTIFF. Assert that the output value range sits within valid surface-reflectance bounds and that the nodata convention is consistent before feeding the result into index calculation.

PYTHON
def export_composite(
    composite: xr.Dataset,
    profile: dict,
    out_path: str,
    target_bands: List[str]
) -> None:
    """Write the cloud-free composite to a multi-band GeoTIFF."""
    profile = profile.copy()
    profile.update(
        count=len(target_bands),
        dtype="float32",
        nodata=np.nan,
        compress="deflate",
        predictor=3,        # floating-point predictor for better compression
        tiled=True,
        blockxsize=512,
        blockysize=512,
    )

    with rasterio.open(out_path, "w", **profile) as dst:
        for i, band in enumerate(target_bands, start=1):
            arr = composite[band].values
            # Validate before writing
            assert arr.dtype == np.float32, f"Band {band} dtype mismatch"
            valid_vals = arr[~np.isnan(arr)]
            assert valid_vals.min() >= 0.0 and valid_vals.max() <= 1.0, (
                f"Band {band} contains out-of-range reflectance values: "
                f"min={valid_vals.min():.4f}, max={valid_vals.max():.4f}"
            )
            dst.write(arr, i)

    print(f"Composite written to {out_path}")
    print(f"  Bands: {target_bands}")
    print(f"  Valid pixel coverage: "
          f"{np.isfinite(composite[target_bands[0]].values).mean() * 100:.1f} %")


# --- Entry point ---
if __name__ == "__main__":
    TARGET_BANDS = ["B04", "B08", "B11"]   # Red, NIR, SWIR — adjust for your index needs

    pairs = collect_file_pairs(
        bands_dir="./S2_L2A/bands",
        scl_dir="./S2_L2A/SCL",
        target_bands=TARGET_BANDS,
    )
    composite, profile = build_composite(pairs, TARGET_BANDS)
    export_composite(composite, profile, "./output/cloud_free_composite.tif", TARGET_BANDS)

Inline verification snippet:

PYTHON
import rasterio
import numpy as np

with rasterio.open("./output/cloud_free_composite.tif") as src:
    arr = src.read(1)                  # first band (B04)
    assert src.crs is not None, "Output CRS missing"
    valid = arr[~np.isnan(arr)]
    print(f"B04 median reflectance: {np.median(valid):.4f}")   # expect ~0.05–0.15 for vegetation
    assert valid.min() >= 0.0 and valid.max() <= 1.0
    print("Validation passed.")

Gotchas & Edge Cases

  • SCL at 20 m, bands at 10 m. Sentinel-2 B02, B03, B04, and B08 are delivered at 10 m resolution; the SCL is 20 m. If you mix resolutions without resampling, mask * reflectance will fail with a shape mismatch. Either resample the SCL up to 10 m with rasterio.Resampling.nearest before applying the mask, or downsample your 10 m bands to 20 m. Never use bilinear or cubic resampling on the SCL — the integer class values will be corrupted.

  • xr.concat raises a shape error. This almost always means two dates have different spatial extents or were resampled to slightly different grid origins. Snap all inputs to a common reference grid with rasterio.vrt.WarpedVRT before entering the pipeline.

  • Median composite is entirely NaN for some pixels. See FAQ below. In practice, adding SCL class 7 (Unclassified) to VALID_SCL_CLASSES and extending the time window to a full season (April–October for Northern Hemisphere winter wheat) are the two fastest fixes before resorting to interpolation.

  • Float NaN vs integer nodata. Rasterio treats np.nan as nodata only for float GeoTIFFs. If you accidentally write NaN into a uint16 band, it silently converts to 0, which is a valid (low) reflectance value. Always cast to float32 and confirm profile["dtype"] == "float32" before export.


This guide is part of Cloud Masking for Agricultural Imagery — see there for the full single-date masking pipeline, spectral thresholding, and morphological cleanup patterns.


Frequently Asked Questions

Why use the SCL band instead of spectral thresholding for Sentinel-2 cloud masking?

Sentinel-2 Level-2A products include a pre-computed Scene Classification Layer generated by the ESA sen2cor processor. It classifies every pixel into 12 categories using multi-spectral thresholds, shadow geometry, and cirrus tests that are far more comprehensive than a simple blue-band brightness cut. Using the SCL is reproducible, sensor-consistent, and eliminates the threshold-tuning overhead required by custom spectral methods.

What causes persistent NaN gaps in the median composite even after many acquisitions?

Persistent NaN gaps indicate that every acquisition for a pixel was masked by cloud, cloud shadow, or cirrus. This is common in regions with monsoon or maritime climates. Solutions include: including SCL class 7 (Unclassified) as a conditional fallback, switching to a 25th-percentile composite instead of median, or interpolating from temporally adjacent clean pixels using linear or spline methods.

Does the processing order of acquisitions affect the median composite result?

No. The median is an order-independent statistic, so shuffling the time axis produces an identical result. However, the order matters if you switch to a most-recent-valid-pixel strategy or a rank-based compositing method. Sort by acquisition date from the start to keep your pipeline deterministic and reproducible.