Pre-processing¶
Clean time series before analysing them: put Landsat and Sentinel-2 in one series, flag clouds and shadows that the QA band missed, smooth noise, remove one-off spikes, and unmix pixels into the fractions of their materials.
Landsat and Sentinel-2 in one series¶
A dense series that mixes Landsat and Sentinel-2 has a step wherever the sensor changes, which CCDC or BFAST read as a break. There are two ways to remove it:
- HLS (Harmonized Landsat Sentinel-2, NASA): both sensors processed alike from level 1 (the same atmospheric correction and cloud mask, view and sun angles normalized, a common 30 m grid) and Sentinel-2 adjusted to Landsat 8's bands.
build_time_seriesreads it from Planetary Computer, both products in one call:collection=["hls2-l30", "hls2-s30"]with the bands named by role. This is the way to go wherever HLS covers the dates (from 2013 for Landsat 8, 2015 for Sentinel-2). bandpass_adjuston cubes you already have (Landsat Collection 2 and Sentinel-2 L2A): only HLS's bandpass step, so only part of the step goes away.
Measured over the Libya-4 desert site (2022–2023, Planetary Computer, pairs of Landsat 8/9 and Sentinel-2 observations at most a day apart, median of the area):
| Pairs | Sentinel-2 / Landsat, by band (blue ... SWIR2) | |
|---|---|---|
| L2A and Collection 2, as they come | 21 | 1.14, 1.07, 1.09, 1.05, 1.09, 1.13 |
L2A after bandpass_adjust |
21 | blue and red about a third closer (differences 0.030 → 0.021 and 0.042 → 0.031), the rest about the same |
| HLS (S30 and L30) | 27 | 1.01, 0.99, 0.99, 1.00, 1.01, 0.99 |
The rest of the L2A step comes from the steps bandpass_adjust does not do: Sen2Cor and LaSRC correct the atmosphere differently, and the view geometry differs.
grid = dict(source="planetary_computer", bbox=bbox, start_date="2019-01-01", end_date="2024-12-31",
apply_cloud_mask=True, resolution=30, epsg=32722)
hls = zeit.build_time_series(collection=["hls2-l30", "hls2-s30"],
bands=["blue", "green", "red", "nir", "swir1", "swir2"], **grid)
result = zeit.ccdc(hls.sel(band=["blue", "green", "red", "nir", "swir1", "swir2"]))
bandpass_adjust¶
zeit.bandpass_adjust(
data, sensor=None, etm=None, bands=None, scale="auto", nodata="auto",
chunks=None,
)
Takes Sentinel-2 reflectance to the bands of Landsat 8's OLI with the bandpass adjustment of HLS (Claverie et al. 2018): one line per band and Sentinel-2 unit, OLI = slope × MSI + intercept, from the HLS v2.0 User Guide (Table 5; S2A and S2B are also the v1.4 values, S2C has its own). OLI is the reference, as in HLS: Landsat 8 and 9 stay as they are (HLS applies no adjustment between them). Landsat 7 (ETM+) and 4-5 (TM) are left alone unless etm is given; then the lines of Roy et al. (2016, Table 2, surface reflectance) take them to OLI. Those were fitted on pre-collection data, and Landsat Collection 2 surface reflectance is generally used across sensors without them (see Earth Engine's FAQ on cross-sensor harmonization). It is one step of HLS, not HLS (see above); HLS products are refused, since their Sentinel-2 dates are already adjusted.
| Parameter | Type | Default | Description |
|---|---|---|---|
data |
DataArray, path or list |
required | Surface reflectance (time, band, y, x) or (band, y, x), in memory or dask. Or a list of cubes on the same grid (one per collection): each is adjusted and they are joined into one series sorted by time, with the bands they share matched by role and named after it (blue ... swir2), whatever each catalog calls them, and the coordinates they share. |
sensor |
str or list |
None |
Each date's sensor: by default the cube's platform coordinate (which build_time_series keeps from STAC) or attribute; or one name for every date ("sentinel-2a", "S2B", "landsat-8", "LANDSAT_7"...), or one per date. With a list of cubes, one of these per cube. A Sentinel-2 date of unknown unit is adjusted as S2A, with a warning. |
etm |
"rma", "ols" or None |
None |
Landsat 7 and 4-5: left as they are, or taken to OLI with the reduced major axis or least squares lines of Roy et al. (2016). TM is treated as ETM+, as in Earth Engine's harmonization tutorial (no TM lines are published). |
bands |
dict |
None |
The cube's band of each role (coastal, blue, green, red, nir, swir1, swir2), when its names are not the usual ones (red, B04, SR_B4...). With a list of cubes, one dict for all or one per cube. |
scale |
"auto" or float |
"auto" |
What the reflectance is multiplied by in the data: 10000 for integers or values above 2, else 1 (a lazy cube is checked on a small window). The intercepts are in reflectance. |
nodata |
"auto", float or None |
"auto" |
Missing values besides NaN (the raster's NoData; 0 for integers without one). They stay missing. |
chunks |
"auto", dict |
None |
Inputs read from disk: None reads into memory; otherwise the result is lazy. |
Returns the cube with the same dimensions, coordinates, type and georeferencing (a list: the joined series), lazy if the input is. Bands without a line (the red edge, QA bands) are copied. attrs["bandpass_adjusted"] says what was done, and an adjusted cube is refused a second time.
Sentinel-2's NIR. HLS adjusts the narrow NIR, B8A (nir08 on Earth Search, B8A on Planetary Computer), the band that matches OLI's band 5. B08, the broad NIR (nir on Earth Search, B08), has no line: it is left as it is, with a warning. Load B8A, or pass bands={"nir": ...} when a band is B8A under another name.
s2 = zeit.build_time_series(collection="sentinel-2-l2a", bands=["B02", "B03", "B04", "B8A", "B11", "B12"], **grid)
landsat = zeit.build_time_series(collection="landsat-c2-l2",
bands=["blue", "green", "red", "nir08", "swir16", "swir22"], **grid)
mixed = zeit.bandpass_adjust([s2, landsat]) # one series: blue, green, red, nir, swir1, swir2
Cloud masking¶
tmask¶
Flags clouds and cloud shadows the QA band missed, from each pixel's time series (Tmask, Zhu & Woodcock 2014): a robust harmonic model (MATLAB's robustfit with Tukey's bisquare, as CCDC's own Tmask, in C++, every pixel in parallel) is fitted to the green and SWIR bands of every pixel, and observations more than 0.04 (reflectance) above it in green (clouds) or below it in SWIR (shadows) are flagged. One function for a (time, band, y, x) cube in memory or dask, or anything load_raster reads; the dates come from its time coordinate. It replaces apply_tmask_stack and run_tmask_pixel (the per-pixel engine, still in zeit._tmask).
| Parameter | Type | Default | Description |
|---|---|---|---|
data |
DataArray or path |
required | A (time, band, y, x) cube with green and SWIR bands. |
green |
str or int |
"green" |
The green band, by name or position. |
swir |
str or int |
"swir1" |
The SWIR band (SWIR1, ~1.6 µm), by name or position. |
scale |
float |
10000.0 |
Reflectance scale of the data (1.0 for reflectance in 0-1); the thresholds are in reflectance. |
nodata |
"auto", float or None |
"auto" |
Value marking a missing observation: the raster's NoData (0 for integer data without one), a number, or None. |
chunks |
"auto", dict |
None |
Inputs read from disk: None reads into memory; otherwise the result is lazy, computed block by block. |
Returns clear (time, y, x), bool: True for a clear observation, False for a cloud, a shadow or no observation (NoData, NaN, or not above 0 in either band). Pixels with 5 or fewer valid dates are not screened. Georeferenced as the input.
cube = zeit.load_raster("landsat/", pattern=r"_(?P<date>\d{8})_(?P<band>\w+)\.tif$") # (time, band, y, x)
clear = zeit.tmask(cube, green="green", swir="swir1")
cube = cube.where(clear) # clouds and shadows become NaN
qa = xr.where(clear, 0, 4) # or as CCDC's Fmask codes (0 clear, 4 cloud)
Spectral mixture analysis¶
unmix¶
zeit.unmix(
data, endmembers="souza2005", bands=None, sum_to_one=True,
nonneg=True, scale="auto", ndfi=True, cloud_threshold=None,
nodata="auto", chunks=None, n_jobs=-1,
)
The fraction of each endmember (pure material) in every pixel and date: a 30 m Landsat pixel of forest after selective logging is part canopy (green vegetation), part dead wood and litter (non-photosynthetic vegetation), part soil and part shade, and the fractions show the damage that the reflectance hides. The unmixing is fully constrained least squares (non-negative fractions that add up to one; Heinz & Chang 2001), in C++, every pixel and date in parallel. Also exported as zeit.unmix.
| Parameter | Type | Default | Description |
|---|---|---|---|
data |
DataArray or path |
required | Reflectance (time, band, y, x) or (band, y, x), in memory or dask. |
endmembers |
str, DataFrame or dict |
"souza2005" |
"souza2005": green vegetation (gv), shade, non-photosynthetic vegetation (npv), soil and cloud for Landsat's blue, green, red, NIR, SWIR1 and SWIR2 (Souza et al. 2005, with the cloud endmember of CODED, as CODED uses them). Or a table: rows endmembers, columns bands of the cube, reflectance 0–1 (or × 10000). |
bands |
list of str |
None |
The cube's band for each column of the endmembers, when the names differ (by default found by name: nir, B08, SR_B5...). |
sum_to_one, nonneg |
bool |
True, True |
Fractions adding up to one; fractions non-negative. |
scale |
"auto" or float |
"auto" |
What the reflectance is multiplied by: 10000 for integers or values above 2, else 1. |
ndfi |
bool |
True |
Also the NDFI (below), when the endmembers include gv, npv, soil and shade. |
cloud_threshold |
float |
None |
Mask observations whose cloud fraction is above this (CODED: 0.05). |
nodata, chunks, n_jobs |
"auto", None, -1 |
As elsewhere. |
Returns an xarray.Dataset with one variable per endmember (its fraction), rmse (of the rebuilt spectrum, in reflectance) and ndfi, on the cube's dimensions but band, lazy if the cube is.
The NDFI (Normalized Difference Fraction Index; Souza et al. 2005) is (GVs − (NPV + soil)) / (GVs + NPV + soil), with GVs = GV / (1 − shade): near 1 in closed forest, lower where the canopy was damaged, below 0 on bare soil and pasture. It is also an index of compute_indices (compute_indices(cube, ["NDFI"])) and of the Earth Engine downloads.
fractions = zeit.unmix(landsat, cloud_threshold=0.05)
fractions.ndfi.zeit.plot() # page through the years
Smoothing¶
smooth¶
zeit.smooth(
data, method="whittaker", lmbda=10.0, weights=None, window=5,
polyorder=2, nodata="auto", chunks=None, n_jobs=-1,
)
Smooths time series along time, keeping their dates, georeferencing and type. One function for every input: a cube (time, y, x) or (time, band, y, x) in memory or dask, anything load_raster reads, or one pixel's pandas.Series indexed by dates. It replaces apply_whittaker_filter and apply_savgol_filter.
- Whittaker (default; Eilers 2003): a penalised least-squares curve that follows the data more or less closely (
lmbda). The dates may be unevenly spaced: the penalty uses the time between them, measured in median steps of the series, solmbdameans the same on any spacing (on an even series it is the classic smoother). Missing observations (NaN, NoData) weigh 0 and are filled by the curve;weightsdown-weights hazy ones. - Savitzky-Golay (
method="savgol"): a local polynomial overwindowdates, as scipy'ssavgol_filter, for evenly spaced series (regularise first withregularize_time_series). Missing observations are first interpolated linearly in time.
| Parameter | Type | Default | Description |
|---|---|---|---|
data |
DataArray, path or pd.Series |
required | The series to smooth (see above). |
method |
str |
"whittaker" |
"whittaker" or "savgol". |
lmbda |
float |
10.0 |
Whittaker: smoothness, larger is smoother (10 light for a 16-day NDVI series, 1000 strong). |
weights |
array or DataArray |
None |
Whittaker: a weight per observation, same shape as data (1 clear, 0.2 hazy, 0 cloudy; see qc_sentinel2_scl). |
window |
int |
5 |
Savitzky-Golay: window length in dates (odd). |
polyorder |
int |
2 |
Savitzky-Golay: order of the local polynomial. |
nodata |
"auto", float or None |
"auto" |
Value marking a missing observation: the raster's NoData (0 for integer data without one), a number, or None. |
chunks |
"auto", dict |
None |
Inputs read from disk: None reads into memory; otherwise the result is lazy. |
n_jobs |
int |
-1 |
Whittaker: threads. -1 uses all cores but one. |
Returns an xarray.DataArray with the same dims and coordinates (a pandas.Series for a Series). Floats keep their type; integer data with a NoData value (NDVI × 10000 in Int16) is rounded back to its type, NoData where a pixel has no observation at all; integer data without one comes back as float32. attrs["smoothing"] names the method, and zeit.plot(cube, fit=smoothed) draws the curve over a clicked pixel's series.
ndvi = zeit.load_raster("S2_ndvi_2022.tif") # (time, y, x), clouds as NaN
smooth = zeit.smooth(ndvi, lmbda=100) # gaps filled by the curve
weighted = zeit.smooth(ndvi, weights=zeit.qc_sentinel2_scl(scl))
sg = zeit.smooth(ndvi_16d, method="savgol", window=7)
zeit.plot(ndvi, fit=smooth) # click a pixel: raw and smoothed
desawtooth¶
LandTrendr's spike removal for a single series: one-year spikes (up or down) are dampened toward their neighbours. landtrendr already applies it internally through its spike_threshold parameter, so call this only to inspect or pre-process series yourself.
| Parameter | Type | Default | Description |
|---|---|---|---|
values |
1-D array | required | One pixel's series. |
stopat |
float |
0.9 |
Each point gets a spike score (1 = a perfect one-year spike). Spikes are corrected strongest first while the strongest score exceeds stopat, so lower values correct more points. As in the original IDL code, the first correction is always applied. |