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.
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 reductionrasterio>=1.3— GeoTIFF I/O and spatial metadata preservationnumpy>=1.24—np.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).
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 |
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.
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.
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.
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:
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 * reflectancewill fail with a shape mismatch. Either resample the SCL up to 10 m withrasterio.Resampling.nearestbefore 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.concatraises 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 withrasterio.vrt.WarpedVRTbefore entering the pipeline. -
Median composite is entirely NaN for some pixels. See FAQ below. In practice, adding SCL class 7 (Unclassified) to
VALID_SCL_CLASSESand 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.nanas nodata only for float GeoTIFFs. If you accidentally writeNaNinto auint16band, it silently converts to0, which is a valid (low) reflectance value. Always cast tofloat32and confirmprofile["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.
Related
- Cloud Masking for Agricultural Imagery — single-date spectral thresholding, morphological cleanup, and windowed COG export for both drone and satellite imagery
- Temporal Aggregation of Vegetation Indices — seasonal maximum, mean, and percentile reductions on cloud-free index stacks using
xarrayanddask - Calculating NDVI and NDRE with Rasterio Step by Step — apply the composite output from this guide directly to NDVI and NDRE band math
- Band Math & Raster Algebra in Python — safe division patterns, EVI2, SAVI, and multi-index batch export for production agtech pipelines