Dilating Cloud Masks to Catch Thin Cirrus
Combine the classification mask with a cirrus threshold, then grow it by a few pixels:
import numpy as np
from scipy import ndimage
cloud = np.isin(scl, [8, 9, 10]) # SCL: medium, high cloud, cirrus
cloud |= cirrus_b10 > 0.012 # thin cirrus the classifier missed
cloud = ndimage.binary_dilation(cloud, iterations=8) # ~80 m at 10 m pixels
Most cloud contamination in composites comes not from missed clouds but from their edges and from cirrus thin enough to look like ground. This page belongs to cloud and shadow masking strategies in Satellite Processing Workflows & Index Pipelines.
Where the Undilated Mask Leaks
The fringe pixels are only slightly brightened, which is exactly what makes them dangerous. A maximum-NDVI composite rejects them, but a median or mean composite absorbs their bias, and an index computed on them reads as slightly degraded vegetation — easy to mistake for a real signal.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
numpy |
>=1.23 |
Mask arithmetic |
scipy |
>=1.11 |
binary_dilation with a disk structuring element |
rasterio |
>=1.3.0 |
Reading SCL and the cirrus band |
pip install "numpy>=1.23" "scipy>=1.11" "rasterio>=1.3.0"
Complete Working Example
import numpy as np
from scipy import ndimage
SCL_CLOUD = [8, 9, 10] # cloud medium, cloud high, thin cirrus
SCL_SHADOW = [3]
def disk(radius_px: int) -> np.ndarray:
y, x = np.ogrid[-radius_px:radius_px + 1, -radius_px:radius_px + 1]
return (x * x + y * y) <= radius_px * radius_px
def robust_mask(scl: np.ndarray, cirrus: np.ndarray | None = None, *,
res_m: float = 10.0, cloud_buffer_m: float = 80.0,
shadow_buffer_m: float = 120.0, cirrus_thresh: float = 0.012):
"""Boolean 'bad' mask: dilated cloud, cirrus and shadow."""
cloud = np.isin(scl, SCL_CLOUD)
if cirrus is not None:
cloud |= np.nan_to_num(cirrus, nan=0.0) > cirrus_thresh
shadow = np.isin(scl, SCL_SHADOW)
cloud = ndimage.binary_dilation(cloud, structure=disk(round(cloud_buffer_m / res_m)))
shadow = ndimage.binary_dilation(shadow, structure=disk(round(shadow_buffer_m / res_m)))
return cloud | shadow
def mask_cost(raw_bad: np.ndarray, dilated_bad: np.ndarray, valid: np.ndarray) -> float:
"""Fraction of otherwise-clear valid pixels removed by the dilation."""
clear_before = valid & ~raw_bad
return float((clear_before & dilated_bad).sum() / max(clear_before.sum(), 1))
A disk structuring element grows the mask equally in all directions, where the default 3×3 cross used by repeated iterations grows it into a diamond and under-buffers the diagonals. Specifying the buffer in metres and converting to pixels keeps the behaviour the same when the pipeline switches between 10 m and 20 m data. The base mask comes from masking clouds with the Sentinel-2 SCL band.
The Cost of a Bigger Buffer
The right buffer depends on cloud regime. Scattered cumulus, with their bright cores and broad hazy fringes, need more buffer than stratiform cloud with sharp edges. In humid tropical regions where every scene is partly cloudy, an over-generous buffer can remove half the usable pixels; there, measuring the cost with mask_cost and tuning against validated labels, as in validating a cloud mask against reference labels, pays for itself.
Cirrus Specifically
Thin cirrus is the one cloud type that a buffer cannot fix, because it is not attached to a visible cloud: it can cover an entire scene as a faint veil. Sentinel-2’s band 10 and Landsat’s cirrus band sit in a strong water-vapour absorption feature, so the surface contributes almost nothing and any signal comes from high ice cloud. A threshold of roughly 0.01 in top-of-atmosphere reflectance flags most optically significant cirrus. Band 10 is only in the L1C product, so a pipeline working on L2A must either read it from L1C or rely on the classification’s own cirrus class, which is less sensitive — one of the differences described in comparing L1C and L2A Sentinel-2 products.
Dilating at Scale without Edge Artefacts
When scenes are processed in tiles or Dask chunks, dilation must see across tile boundaries, or a cloud sitting just outside one tile will not buffer into its neighbour and a hard straight seam appears in the mask. With Dask, map_overlap with a depth equal to the buffer radius in pixels solves this: each chunk borrows a margin from its neighbours, dilates, and trims the margin back off. With windowed rasterio reads, read each window padded by the same radius and crop after dilation.
Dilation is also cheap to get wrong in dtype terms. binary_dilation returns a boolean array; writing that directly as a GeoTIFF band produces an unhelpful file, so cast to uint8 and set an explicit nodata value that is distinct from the mask values. Storing the dilated mask alongside the raw one, rather than overwriting it, keeps the choice of buffer reversible, which matters when the buffer is later retuned against validated labels.
Verification
import numpy as np
from scipy import ndimage
near = ndimage.binary_dilation(np.isin(scl, SCL_CLOUD), iterations=20) & ~np.isin(scl, SCL_CLOUD)
for label, bad in (("raw", np.isin(scl, SCL_CLOUD)), ("dilated", robust_mask(scl, cirrus))):
keep = ~bad
print(label, "near", float(np.nanmean(blue[near & keep])), "far", float(np.nanmean(blue[~near & keep])))
If unmasked pixels near clouds remain brighter than those far away after dilation, the buffer is too small; if they are now darker, the buffer is removing shadowed ground and is too large on the shadow side.
Common Errors
Composites still show bright halos around former clouds
The buffer is too small or absent. Dilate by 80–100 m and re-check.
Half the scene disappears after dilation
The buffer is too large for a scattered-cloud regime. Measure the cost and reduce it.
Diagonal edges are under-masked
Iterated 3×3 dilation grows a diamond. Use a disk structuring element.
Cirrus threshold masks mountains
High, dry terrain can reflect weakly in the cirrus band. Raise the threshold or exclude high elevations from the test.
Frequently Asked Questions
Q: How much should a cloud mask be dilated? Enough to cover the hazy fringe that surrounds visible cloud — typically 50 to 150 metres, which is five to fifteen pixels at 10 metres. Larger buffers remove clear pixels for little gain; smaller ones leave bright edges that bias composites.
Q: Why does thin cirrus escape the mask? Because it is nearly transparent in visible bands, so the surface shows through and the classifier sees ground. The cirrus band sits in a water absorption feature where the surface is invisible and only high cloud reflects, which is why a threshold on it catches what the classification misses.
Q: Should shadows be dilated too? Yes, usually by a similar distance, since shadow edges are as soft as cloud edges. Shadows are also often misplaced relative to their clouds, so a slightly larger buffer on shadows is common.
Q: Does dilation affect the observation count in time series? Yes, it reduces it, and that is the correct trade: fewer, cleaner observations give better seasonal statistics than more, contaminated ones. Keep the count as a feature so downstream models know which pixels rest on fewer dates.
Related
- Cloud and Shadow Masking Strategies — the parent topic.
- Implementing FMask and s2cloudless in Python — stronger base masks to dilate.
- Validating a Cloud Mask against Reference Labels — tuning the buffer with evidence.
- Inspecting Time Series at a Clicked Pixel — spotting the dips that undilated masks leave.