
Harmonized Landsat-Sentinel composite and PCA
hls-harmonized-pca.RmdThe Harmonized Landsat Sentinel-2 (HLS) project delivers a common
surface reflectance product from two sensors, giving a land observation
every 2-3 days at 30 m. The two collections are spectrally harmonized
but name their bands differently and carry different band
sets (Landsat has thermal bands and no red edge; Sentinel-2 has
red edge and a broad NIR). This vignette harmonizes both onto one band
schema, builds a cloud-masked median composite, and reduces the band
axis to its first three principal components. It uses the Microsoft
Planetary Computer; stac_sign_mpc() signs the assets,
caching the collection’s SAS token in memory and on disk until it
expires.
Discover and harmonize
Query both collections over an area of interest, sign them, filter
cloudy scenes, and drop duplicate acquisitions. Every step operates on
the STAC items object itself (rstac’s
doc_items): the filters compose on it directly, and
lazy_dataset() builds the source table internally, so
nothing coerces to a data frame here.
library(garry)
aoi <- c(144.24, -7.66, 144.36, -7.54) # lon/lat bbox
mpc <- "https://planetarycomputer.microsoft.com/api/stac/v1/"
discover <- function(collection) {
stac_query(bbox = aoi, stac_source = mpc, collection = collection,
start_date = "2023-01-01", end_date = "2023-12-31") |>
stac_sign_mpc() |>
stac_filter_cloud(80) |>
stac_drop_duplicates()
}Each collection’s assets are mapped onto one shared schema with
stac_rename_assets(): aerosol A, visible
B/G/R, red edge
RE1-RE3, NIR N (broad) and
N2 (narrow), water vapour WV, cirrus
C, SWIR S1/S2, thermal
T1/T2. Unmapped assets (view angles, previews)
drop out, so the map doubles as the band selector.
stac_merge() unions the two harmonized item
collections.
l30 <- discover("hls2-l30") |>
stac_rename_assets(c(
B01 = "A", B02 = "B", B03 = "G", B04 = "R", B05 = "N2", B06 = "S1",
B07 = "S2", B09 = "C", B10 = "T1", B11 = "T2", Fmask = "Fmask"))
s30 <- discover("hls2-s30") |>
stac_rename_assets(c(
B01 = "A", B02 = "B", B03 = "G", B04 = "R", B05 = "RE1", B06 = "RE2",
B07 = "RE3", B08 = "N", B8A = "N2", B09 = "WV", B10 = "C", B11 = "S1",
B12 = "S2", Fmask = "Fmask"))
src <- stac_merge(l30, s30)
cat("bands:", paste(sort(unique(unlist(
lapply(src$features, function(f) names(f$assets))))), collapse = " "), "\n")
#> bands: A B C Fmask G N N2 R RE1 RE2 RE3 S1 S2 T1 T2 WVNo empty-band padding or reordering is needed (unlike a VRT-based
approach): lazy_dataset() gives each band only the slices
that carry it, and mask() pairs a band’s slices with the
matching Fmask by name. A Landsat-only thermal band and a Sentinel-only
red-edge band each reduce over exactly their own observations.
Cloud-masked median composite
The reflectance and red-edge bands are read with
resampling = "bilinear" onto the analysis grid;
mask_asset = "Fmask" is always read nearest regardless,
since interpolating packed QA bits would corrupt the flag values.
# Thermal (T1/T2) is Landsat-only and sparse, so requiring it drops most pixels
# from the PCA; keep the reflectance/red-edge bands, which both sensors provide.
bands <- c("A", "B", "G", "R", "RE1", "RE2", "RE3", "N",
"N2", "WV", "C", "S1", "S2")
target <- grid_from_bbox(aoi, res = 30) # equal-area 30 m grid
garry_daemons()
comp <- lazy_dataset(
src, grid = target, assets = bands, mask_asset = "Fmask",
nodata = c(stats::setNames(rep(-9999, length(bands)), bands), Fmask = 255),
resampling = "bilinear"
) |>
mask(from = "Fmask", where = qa_bits(0:3), open = 2, dilate = 3) |>
reduce_over("median", over = "t")
PCA over the band axis
A per-pixel PCA collapses the spectral vector to a few components. Fit it in plain R on the materialized composite – fitting a model in R is perfectly reasonable; not everything belongs in the lazy graph.
cube <- stack_bands(comp) # LazyRaster (band, y, x)
arr <- collect(cube) # (y, x, band)
px <- matrix(arr, ncol = dim(arr)[[3]])
px <- px[stats::complete.cases(px), , drop = FALSE]
pca <- prcomp(px, center = TRUE, scale. = TRUE)
round(summary(pca)$importance[, 1:3], 3) # variance explained, PC1-3
#> PC1 PC2 PC3
#> Standard deviation 3.204 1.242 0.905
#> Proportion of Variance 0.790 0.119 0.063
#> Cumulative Proportion 0.790 0.908 0.971The projection onto each principal component is a linear
combination of the bands – exactly
reduce_over(over = "band"). band_project()
builds the reducer (centre, then weight by the loading); stacking the
first three gives a 3-band image. Because it is lazy, the same
projection runs chunked and distributed at full resolution without ever
holding the whole cube in memory.
sc <- pca$scale # prcomp(scale. = TRUE): weights /= sd
pcs <- lazy_stack(
lapply(1:3, function(i)
reduce_over(cube, band_project(pca$rotation[, i] / sc, center = pca$center),
over = "band")),
along = "band")
preview(pcs) # PC1-3 as R/G/B
The same band_project() reducer is the basis for any
linear-model predict (regression, logistic, unmixing): fit
in R, pass the coefficients as weights. Non-linear models
(random forests, boosting) don’t trace to the compute backend – for
those, collect() the composite and predict in R.