Cloud Masking and Compositing for Exposure Rasters
This guide, part of Remote Sensing Indices for Health Studies, covers the step every satellite-derived exposure depends on and nobody writes up: deciding which observations are usable and how to reduce the survivors to one number per cell.
Problem Context & Constraints
Cloud does not remove data at random. It removes it in coherent patches, correlated with terrain, coastlines and season. That has two consequences for an exposure raster. Cells that survive masking in a cloudy region describe a biased subset of the period; and cells that fail to survive cluster geographically, so the missing data have a map.
Residual contamination — cloud that the mask failed to catch — biases indices in a direction that depends on the index. For a vegetation index, cloud is bright in red and relatively less bright in near-infrared, so a contaminated cell reads low, and a maximum-value composite systematically selects the clearest observations, which is why that composite became the tradition. For a thermal product, cloud top is cold, so contamination reads low as well, and a mean composite drags the whole city cooler in cloudier areas. For an aerosol product, thin cloud reads as high aerosol.
Three constraints shape the fix. Quality bits must be decoded, not thresholded. Modern products ship a packed bitmask where individual bits mean cloud, shadow, cirrus, snow and confidence level; comparing the packed integer against a number is a bug that happens to work on some scenes. The mask must be dilated. Cloud edges and shadow are under-detected, and a one-to-three cell dilation removes most residual contamination at a modest cost in coverage. And the clear count must survive into the output, because a composite value without it cannot be distinguished from a guess.
Prerequisites
python3.11numpy1.26.4,scipy1.13.1,rasterio1.3.10- Scene-level index arrays and their quality assurance bands, on one grid
- The product’s quality-band bit definitions from its user guide — not from memory
- A declared compositing window and clear-observation threshold
Step-by-Step Solution
Decode the quality bits into a boolean clear mask, dilate it, then composite with a statistic chosen for the index and keep the count.
# Quality-bit decoding, mask dilation and compositing for an exposure raster.
# Pinned: numpy==1.26.4, scipy==1.13.1, rasterio==1.3.10
import logging
import numpy as np
from scipy import ndimage
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("exposure.mask")
# Bit positions from the product user guide. These differ between products and
# between collections of the SAME product — read the guide for your collection.
QA_BITS = {"fill": 0, "dilated_cloud": 1, "cirrus": 2, "cloud": 3,
"cloud_shadow": 4, "snow": 5, "water": 7}
REJECT = ("fill", "dilated_cloud", "cirrus", "cloud", "cloud_shadow", "snow")
def clear_mask(qa: np.ndarray, reject=REJECT, bits=QA_BITS,
dilate_cells: int = 2) -> np.ndarray:
"""Boolean clear mask from a packed quality band, dilated to catch edges."""
bad = np.zeros(qa.shape, dtype=bool)
for name in reject:
if name not in bits:
raise KeyError(f"no bit position defined for {name!r}")
# Test the individual BIT, never the packed integer against a threshold.
bad |= ((qa >> bits[name]) & 1).astype(bool)
if dilate_cells > 0:
k = 2 * dilate_cells + 1
bad = ndimage.binary_dilation(bad, structure=np.ones((k, k), dtype=bool))
clear = ~bad
log.info("clear mask: %.1f%% clear after %d-cell dilation",
100.0 * clear.mean(), dilate_cells)
return clear
def composite(scenes: list[np.ndarray], clears: list[np.ndarray],
statistic: str = "median", min_clear: int = 4) -> dict:
"""Reduce a scene stack to one layer, keeping the clear-observation count."""
if len(scenes) != len(clears):
raise ValueError("one clear mask per scene is required")
stack = np.stack([np.where(c, s.astype("float32"), np.nan)
for s, c in zip(scenes, clears)])
n_clear = np.sum(~np.isnan(stack), axis=0).astype("int16")
with np.errstate(all="ignore"):
if statistic == "median":
out = np.nanmedian(stack, axis=0)
elif statistic == "mean":
out = np.nanmean(stack, axis=0)
elif statistic == "max":
# Traditional for vegetation indices; biased upward by residual haze.
out = np.nanmax(stack, axis=0)
elif statistic == "p95":
out = np.nanpercentile(stack, 95, axis=0)
else:
raise ValueError(f"unknown statistic: {statistic}")
out = np.where(n_clear >= min_clear, out, np.nan).astype("float32")
nulled = int((n_clear < min_clear).sum())
log.info("composite statistic=%s scenes=%d min_clear=%d nulled_cells=%d (%.2f%%)",
statistic, len(scenes), min_clear, nulled,
100.0 * nulled / n_clear.size)
return {"value": out, "n_clear": n_clear, "statistic": statistic,
"n_scenes": len(scenes), "min_clear": min_clear}
def missingness_map(n_clear: np.ndarray, min_clear: int) -> dict:
"""Summarise where the composite is uninformed — the map of the gaps."""
short = n_clear < min_clear
lab, n = ndimage.label(short)
sizes = ndimage.sum(short, lab, index=range(1, n + 1)) if n else np.array([])
return {"nulled_fraction": float(short.mean()),
"n_patches": int(n),
"largest_patch_cells": int(sizes.max()) if sizes.size else 0}
Decoding individual bits rather than thresholding the packed value is the correctness point. A quality band is an integer whose bits carry independent flags, and a comparison like qa < 300 happens to exclude the right cells on some scenes and the wrong ones on others, with no error either way.
The choice of composite statistic should follow the contamination direction of the index rather than tradition. A median is robust to a minority of contaminated observations in both directions and is the right default. A maximum-value composite is appropriate only for a vegetation index where contamination is known to push values down, and it is actively harmful for a thermal product where the extreme is what the study wants but contamination pushes in the same direction.
Validation & Edge Cases
Bit definitions that changed between collections. A reprocessing can renumber quality bits. Code that hard-codes positions will silently mask the wrong things. Load the bit map from configuration, version it, and assert the expected clear fraction is plausible.
Snow and ice flagged as cloud. In northern study areas a large share of winter observations are removed by a snow flag, which is correct for a vegetation index and wrong for a thermal study of cold exposure. Choose the reject list per application.
Water flagged but wanted. Water is often a quality flag rather than a defect. Whether to reject it depends on the index; for greenness it should be masked, for a heat study it may be informative.
Persistent-cloud regions. Where no cell reaches the threshold across a whole region, the honest outcome is a hole in the exposure raster. Filling it from a neighbouring region imports a value from a different climate.
Dilation on a coarse grid. A two-cell dilation at 30 m removes 60 m; at 1 km it removes 2 km and can erase a small study area. Scale the dilation to the cell size.
INFO exposure.mask clear mask: 61.4% clear after 2-cell dilation
INFO exposure.mask composite statistic=median scenes=18 min_clear=4 nulled_cells=1842190 (4.71%)
Dilation radius should scale with the grid, and the arithmetic is worth doing before it silently removes a small study area:
Compliance Notes
Record the product and collection identifier, the quality bit map with its source document and version, the reject list and the reason for each entry, the dilation radius in cells and in metres, the scene list with acquisition dates, the composite statistic and the reason it was chosen, the minimum clear-observation threshold, and the nulled fraction with its patch structure.
Ship the clear-observation count as a band of the composite, not as a separate file that gets lost. Every downstream consumer of the exposure raster needs it to distinguish an informed cell from an uninformed one, and separating them is how a value derived from two observations ends up being used as though it were derived from eighteen.
Related Topics
- Remote Sensing Indices for Health Studies — the parent guide and the indices this compositing serves.
- Deriving Land Surface Temperature for Heat Vulnerability — where the clear-sky bias matters most.
- Combining Land-Use Regression With Satellite Aerosol Estimates — the aerosol case, where the traditional composite statistic is exactly wrong.