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

Clouds have soft edges; masks have hard ones A cross-section of reflectance across a cloud shows a bright core that the classifier masks, and a fringe on either side where reflectance is raised by thin cloud and scattering but still below the classifier's threshold. Those fringe pixels pass as clear and brighten any composite or index. Dilation extends the mask across the fringe. Reflectance across a cumulus cloud classifier mask fringe fringe dilation covers the orange

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

Contamination falls, loss rises As the buffer grows from zero to two hundred metres, residual contamination in the unmasked pixels falls steeply at first and then flattens around eighty to a hundred metres. The fraction of genuinely clear pixels removed rises steadily. The useful buffer is where the contamination curve flattens. Buffer distance trade-off, partly cloudy scene ≈ 80–100 m residual contamination clear pixels lost 0 m 200 m Find the knee for your region once; it depends on cloud type more than on the sensor.

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

Unmasked pixels near clouds should not be brighter The mean blue reflectance of unmasked pixels within 200 metres of a cloud is compared with that of unmasked pixels far from any cloud. Before dilation the near-cloud pixels are noticeably brighter; after dilation the two agree, showing the fringe has been removed. Blue reflectance of pixels left unmasked before after Coloured: near clouds. Grey: far from clouds. After dilation they should match.
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.