Skip to content

Cloud mask

There are two separate questions: which scenes to use, and which pixels inside a scene to trust.

Every item has eo:cloud_cover, the percentage of the whole scene flagged as cloud by the producer.

  • In search, filter with the STAC query extension: "query": {"eo:cloud_cover": {"lte": 20}}. CQL2 filter is not available in lean v1.
  • On tiles and TileJSON, use maxCloudCoverage=20. Tiles take the least cloudy scenes first.
  • In POST /v1/process, use input.data[].dataFilter.maxCloudCoverage.

Scene cloud cover says nothing about your area: a scene at 5% cloud can be cloudy exactly where you look. For that you need a pixel mask.

Lean v1 has no unified mask yet: tiles are not pixel-masked, and processing.cloudMask in a process request is rejected with 400 invalid-body. Request the mission’s own classification layer as a band and mask with it yourself. Both are passed through exactly as stored.

Sentinel-2: SCL (scene classification, 20 m, one class value per pixel)

SCL value Meaning Treat as
0 No data nodata
1 Saturated or defective bad
2 Dark area or terrain shadow shadow
3 Cloud shadow cloud shadow
4 Vegetation clear
5 Not vegetated clear
6 Water water
7 Unclassified your choice
8 Cloud, medium probability cloud
9 Cloud, high probability cloud
10 Thin cirrus cirrus
11 Snow or ice snow

Class 7 is ambiguous: SentiWiki calls it unclassified, while Sentinel Hub and Earth Engine treat it as cloud. Decide what suits your analysis.

Landsat 8-9: QA_PIXEL (a bit field, 30 m)

Bit Meaning when set
0 Fill (no data)
1 Dilated cloud
2 Cirrus
3 Cloud
4 Cloud shadow
5 Snow
6 Clear
7 Water
8-9 Cloud confidence
14-15 Cirrus confidence

Download red, nir08 and SCL together, with process or the download API, then keep only clear pixels. Process names the band scl in the file:

import numpy as np
import rasterio
with rasterio.open("scene.tif") as src:
names = list(src.descriptions) # band names are stored in the file
data = {n: src.read(i + 1) for i, n in enumerate(names)}
scl = data[[n for n in names if n.lower() == "scl"][0]]
clear = np.isin(scl, [4, 5, 6]) # vegetation, not vegetated, water

Sentinel-2 has no buffer around clouds of its own; consider growing the cloud and shadow areas by a few pixels before trusting the edges.

Schema v1 defines one unified mask with 11 classes for every optical collection, whatever the source calls its flags:

Bit and class Class id Meaning
0 nodata No valid observation
1 saturated_defective Saturated or defective detector
2 cloud Opaque cloud; on HLS includes cirrus
3 cloud_shadow Shadow cast by cloud
4 cirrus Thin cirrus, detectable only on Sentinel-2 and Landsat 8-9
5 cloud_adjacent Buffer around cloud and shadow
6 snow_ice Snow or ice
7 water Water
8 dark_terrain_shadow Dark area or terrain shadow
9 unclassified The source could not classify the pixel
10 clear Valid, with no other flag

It will be exposed three ways: mask (a uint16 bit field), mask_class (one class per pixel) and dataMask (1 for valid and not excluded, 0 elsewhere, as in Sentinel Hub). None of them will count towards units.

Requests will choose classes to drop with cloudMask.exclude, and three settings resolve the known disagreements:

  • scl7: whether SCL class 7 is unclassified (the default), cloud or clear.
  • dilate_m: 0 to 600 m of buffer around Sentinel-2 cloud and shadow; 150 m matches HLS.
  • cloudConfidence: the Landsat QA_PIXEL confidence that counts as cloud, medium by default.

Each item will carry sdn:mask_source (scl, qa_pixel, …) and sdn:mask_capabilities, the classes that source can detect. A missing class means “not detectable”, never “absent”.