15. From Satellite Reflectance to Vegetation Traits: Real EO Application
t15-real-eo-application.RmdEvery tutorial so far worked on simulated spectra. This closing page applies the exact same hybrid-inversion framework (Tutorials 10-13) to a real Sentinel-2 image, retrieved live via STAC (SpatioTemporal Asset Catalog) – and, unlike Tutorial 03’s single-pixel SPART geometry sweep or Tutorial 14’s single-spectrum test set, produces a genuine 2D spatial map, not a point value.
Sentinel-2 image (STAC)
|
v
Cloud/shadow mask (SCL, inside get.sentinel2_cube())
|
v
Vegetation mask (NDVI threshold)
|
v
Realistic, sufficiently large LUT (real geometry, variable soil)
|
v
Domain-applicability check (real reflectance vs. LUT coverage)
|
v
Hybrid inversion (trained on the LUT)
|
v
Masked LAI map, NA outside vegetation -- not a validated product
This restructures the previous version of this page on purpose: the mask, the LUT’s realism, and the domain check now all happen before inversion, not as a discussion tacked on after a map was already shown.
1. Retrieve a real Sentinel-2 image via STAC
ToolsRTM::get.satellite_collection() searches and signs
a STAC collection (Microsoft Planetary Computer by default);
get.sentinel2_cube() builds an actual gridded raster cube
from it over a chosen area/date range (gdalcubes under the
hood). Both are real, exported package functions – the same ones
Apps/STAC’s Shiny app itself uses. Wrapped in
tryCatch() since live network/STAC access is inherently
less reliable in a documentation build than a local simulation:
retrieval <- tryCatch({
library(sf)
# Wageningen, NL -- open farmland, a plausible study area for this package
pt <- st_point(c(5.6667, 51.9667)) |> st_sfc(crs = 4326)
scenario <- st_as_sf(data.frame(id = 1), geometry = st_sfc(pt[[1]], crs = 4326))
sc <- get.satellite_collection(scenario = scenario, collection = "sentinel-2-l2a",
cloud_server = "microsoft", n.limit = 20,
date_range = c("2024-06-01", "2024-08-31"),
cloud_threshold = 20, buffer_size = 1500)
bbox <- get_bounding_box(scenario, 1500)
shape <- st_as_sf(data.frame(id = 1), geometry = st_sfc(st_polygon(list(rbind(
c(bbox["xmin"], bbox["ymin"]), c(bbox["xmin"], bbox["ymax"]),
c(bbox["xmax"], bbox["ymax"]), c(bbox["xmax"], bbox["ymin"]),
c(bbox["xmin"], bbox["ymin"])))), crs = 4326))
cube <- get.sentinel2_cube(sc[[1]], shape = shape, date_range = c("2024-06-01", "2024-08-31"),
aggregation_method = "mean", get.dataset = FALSE)
# Real sun geometry for this acquisition, from the STAC item's own metadata
# (df.data -- the second element get.satellite_collection() returns) --
# used below to build a more realistic training LUT instead of a default
# fixed angle.
df_meta <- sc[[2]]
list(ok = TRUE, cube = cube, df_meta = df_meta)
}, error = function(e) list(ok = FALSE, msg = conditionMessage(e)))
if (!retrieval$ok) {
cat("STAC retrieval skipped -- no network/STAC access from this build environment (",
retrieval$msg, "). Everything below needs `retrieval$cube` and is skipped too.\n")
}
if (retrieval$ok) {
cube <- retrieval$cube
cat("Real Sentinel-2 cube retrieved:", paste(dim(cube), collapse = " x "),
"(rows x cols x bands), bands:", paste(names(cube), collapse = ", "), "\n")
}
#> Real Sentinel-2 cube retrieved: 161 x 161 x 11 (rows x cols x bands), bands: B02, B03, B04, B05, B06, B07, B08, B8A, B11, B12, SCLget.sentinel2_cube() already masks clouds/shadows/snow
via the SCL band and mean-aggregates every cloud-free date in range onto
one 20m grid – one real, composited scene, not a single tile from a
single overpass.
2. A vegetation mask, before any inversion
Inverting a trait model on every pixel – bare soil, roads, water – produces a number for all of them, and that number is meaningless outside vegetation. Build and apply an NDVI-based mask first:
if (retrieval$ok) {
refl_cube <- cube[[c("B02","B03","B04","B05","B06","B07","B08","B8A","B11","B12")]] / 10000
ndvi <- (refl_cube[["B08"]] - refl_cube[["B04"]]) / (refl_cube[["B08"]] + refl_cube[["B04"]])
names(ndvi) <- "NDVI"
# NDVI > 0.3 is a common, but application-dependent, vegetation threshold
# (dense crops/forest often clear 0.5+; this is deliberately permissive
# enough to include sparser/early-season vegetation too). A real
# operational workflow would tune this per land-cover context, and could
# combine it with the cube's own SCL classes for a stricter mask.
veg_mask <- ndvi > 0.3
op <- par(mfrow = c(1, 2))
plot(ndvi, main = "NDVI (cloud-masked mean composite)")
plot(veg_mask, main = "Vegetation mask (NDVI > 0.3)", col = c("grey85", "#009E73"))
par(op)
cat("Vegetation pixels:", round(100 * sum(values(veg_mask), na.rm = TRUE) / sum(!is.na(values(veg_mask))), 1), "% of the scene\n")
}
#> Vegetation pixels: 64 % of the scene
3. A realistic, sufficiently large training LUT
n_samples = 150 (this page’s own previous size) is a
demo size, not a scientific one – and size alone isn’t the whole story
either. A larger and better-constrained LUT can improve
inversion by providing denser coverage of the relevant parameter space;
it does not automatically improve inversion just by being
larger (100,000 badly- parameterized simulations can be worse
than 5,000 well-designed ones). This LUT improves on three fronts at
once, not just row count:
- Real acquisition geometry: sun zenith from this scene’s own STAC metadata, not this package’s generic fixed default.
-
Variable soil, not one flat
rsoil = 0.15for every row – sampled brightness across a realistic range (Tutorial 03’s BSM sensitivity section covers the physics). - A larger sample count – 2000 rows here (up from 150), the largest practical for this page’s build time; Section 4 shows explicitly why that specific number is defensible, not arbitrary.
if (retrieval$ok) {
n_samples <- 2000
LUT <- as.data.frame(getLUT(inputs = ToolsRTM::inputsPROSAIL, nLUT = n_samples, setseed = 1))
wl <- 400:2500
# Real sun zenith for this acquisition (falls back to the package default
# if the STAC item didn't carry a usable zenith field).
real_tts <- suppressWarnings(mean(as.numeric(retrieval$df_meta$zenith), na.rm = TRUE))
if (is.finite(real_tts)) LUT$tts <- real_tts
# Variable soil brightness (BSM-style range, not one flat value) --
# see Tutorial 03 Section 3.3 for the physical parameters.
set.seed(2)
soil_brightness <- runif(n_samples, 0.05, 0.30)
sim_refl <- t(sapply(seq_len(n_samples), function(i) {
rsoil_i <- rep(soil_brightness[i], length(wl))
foursail(inputLUT = LUT[i, ], rsoil = rsoil_i, LeafModel = "PROSPECT-PRO")$rsot
}))
refl_X <- as.data.frame(sim_refl); colnames(refl_X) <- paste0("X", wl)
refl_X <- cbind(id = seq_len(n_samples), refl_X)
se2a_full <- suppressMessages(get.spectra.convolved(rfl = refl_X, sensor = "Sentinel2a", plot.spectra = FALSE))
names(se2a_full) <- c("id","B1","B2","B3","B4","B5","B6","B7","B8","B8A","B9","B10","B11","B12")
keep <- c("B2","B3","B4","B5","B6","B7","B8","B8A","B11","B12") # matches the cube's 10 bands
real_names <- c("B02","B03","B04","B05","B06","B07","B08","B8A","B11","B12")
se2a <- se2a_full[, keep]; names(se2a) <- real_names
train_df <- cbind(LUT, se2a)
cat("Real acquisition sun zenith used for training LUT:", round(real_tts, 1), "deg\n")
}4. Does LUT size actually matter here? A convergence check, not an assertion
Rather than assert that 2000 is enough, test it: train on several LUT sizes, evaluate every one against the same independent synthetic test set (not the real image – that would conflate two different questions), and look at where the curve actually flattens:
if (retrieval$ok) {
test_lut <- as.data.frame(getLUT(inputs = ToolsRTM::inputsPROSAIL, nLUT = 300, setseed = 999))
test_lut$tts <- LUT$tts[1]
set.seed(3)
test_soil <- runif(300, 0.05, 0.30)
test_refl <- t(sapply(seq_len(300), function(i) {
foursail(inputLUT = test_lut[i, ], rsoil = rep(test_soil[i], length(wl)), LeafModel = "PROSPECT-PRO")$rsot
}))
test_refl_X <- as.data.frame(test_refl); colnames(test_refl_X) <- paste0("X", wl)
test_refl_X <- cbind(id = seq_len(300), test_refl_X)
test_se2a_full <- suppressMessages(get.spectra.convolved(rfl = test_refl_X, sensor = "Sentinel2a", plot.spectra = FALSE))
names(test_se2a_full) <- names(se2a_full)
test_se2a <- test_se2a_full[, keep]; names(test_se2a) <- real_names
sizes <- c(150, 500, 1000, 2000)
size_results <- do.call(rbind, lapply(sizes, function(n) {
sub_df <- cbind(LUT[seq_len(n), ], se2a[seq_len(n), ])
fit_n <- get.inversion(data = sub_df, depVar = "LAI", inputs = real_names,
algorithm = "RF", n.samples = nrow(sub_df), seed = 42)
pred_n <- as.numeric(predict(fit_n$model, newdata = cbind(test_lut, test_se2a)[, c("LAI", real_names)]))
obs_n <- test_lut$LAI
data.frame(n = n, R2 = 1 - sum((obs_n - pred_n)^2) / sum((obs_n - mean(obs_n))^2),
RMSE = sqrt(mean((obs_n - pred_n)^2)))
}))
}



if (retrieval$ok) {
knitr::kable(size_results, digits = 3, row.names = FALSE)
plot(size_results$n, size_results$RMSE, type = "o", pch = 19, col = "#D55E00",
xlab = "LUT size (training rows)", ylab = "RMSE (LAI)", main = "LUT-size convergence, independent test set")
}
Accuracy improves with LUT size but with diminishing returns – most
of the gain shows up well before 2000 rows. This is exactly the nuance
worth being explicit about: the improvement here comes from
denser coverage of the same, already-reasonable parameter space
(real geometry, variable soil, inputsPROSAIL’s trait
ranges) as size grows, not from size alone – the same 2000 rows drawn
from a badly-chosen parameter space would not show this curve.
5. Domain-applicability check: does the real scene actually look like the LUT?
A trained model will return a prediction for any input, including reflectance combinations nothing in the training LUT resembles – a real, silent failure mode worth surfacing rather than hoping it doesn’t happen. Compare the real scene’s per-band reflectance range against the simulated LUT’s:
if (retrieval$ok) {
pix_df <- as.data.frame(refl_cube, xy = TRUE, na.rm = FALSE)
ok_rows <- complete.cases(pix_df[, real_names])
domain_tbl <- do.call(rbind, lapply(real_names, function(b) {
lut_range <- range(se2a[[b]])
real_vals <- pix_df[[b]][ok_rows]
data.frame(band = b, lut_min = lut_range[1], lut_max = lut_range[2],
pct_real_outside = round(100 * mean(real_vals < lut_range[1] | real_vals > lut_range[2]), 1))
}))
knitr::kable(domain_tbl, digits = 4, row.names = FALSE)
# Flag any pixel with at least one band outside the LUT's simulated range --
# these predictions are extrapolations, not interpolations.
out_of_domain <- rep(FALSE, nrow(pix_df))
for (b in real_names) {
rng <- range(se2a[[b]])
out_of_domain <- out_of_domain | (pix_df[[b]] < rng[1] | pix_df[[b]] > rng[2])
}
out_of_domain[!ok_rows] <- NA
cat("Vegetation pixels with at least one band outside the LUT's simulated range:",
round(100 * mean(out_of_domain[ok_rows & values(veg_mask)[ok_rows] %in% TRUE], na.rm = TRUE), 1), "%\n")
}
#> Vegetation pixels with at least one band outside the LUT's simulated range: 100 %6. A masked, hybrid-inverted LAI map
Fit on the full 2000-row LUT from Section 3, predict on every pixel, then discard predictions outside the vegetation mask (Section 2) – the whole reason for building the mask before this step:
if (retrieval$ok) {
fit <- get.inversion(data = train_df, depVar = "LAI", inputs = real_names,
algorithm = "RF", n.samples = nrow(train_df), seed = 42)
pred_lai <- rep(NA_real_, nrow(pix_df))
pred_lai[ok_rows] <- as.numeric(predict(fit$model, newdata = pix_df[ok_rows, real_names]))
veg_vals <- values(veg_mask)[, 1]
pred_lai[!(veg_vals %in% TRUE)] <- NA # mask out non-vegetation, including NA-NDVI pixels
lai_rast <- refl_cube[["B04"]] # reuse its grid/extent/crs
values(lai_rast) <- pred_lai
names(lai_rast) <- "LAI_pred"
plot(lai_rast, main = "Vegetation-masked, hybrid-inverted LAI (real Sentinel-2 pixels)")
cat("Predicted LAI range, vegetation pixels only:",
paste(round(range(pred_lai, na.rm = TRUE), 2), collapse = " to "), "\n")
}
#> [1] "processing hybrid approach using Random Forest ..."
#> -0.03662417 0.01
#> 0.06540701 0.01
#> -0.0129615 0.01

#> Predicted LAI range, vegetation pixels only: 2.49 to 3.66
This is a hybrid RTM-derived LAI estimate, not a validated product. Its accuracy has not been checked against field measurements or an established satellite LAI product (e.g. Copernicus Global Land, MODIS LAI) – doing that is the actual next step before treating any of these numbers as ground truth, not something this page’s simulated-LUT training can substitute for.
7. What’s still a real gap, restated after doing the work above
Sections 2-5 close several gaps the previous version of this page only described after the fact. What’s left genuinely open:
- View/relative-azimuth geometry (
tto/psi) still uses this package’s fixed defaults, not the real per-scene values –tts(sun zenith) is now real (Section 3), but Sentinel-2’s near-nadir viewing geometry makes the view-angle simplification much smaller in practice than the sun-angle one would have been. - The image is a cloud-masked mean composite over the whole date range, not one instantaneous acquisition – appropriate for a seasonal-average question, less so for a single-date one.
- No field or reference-product validation (Section 6’s caution).
None of this invalidates the framework – it’s exactly why Tutorials 05 (LUT design), 10 (sensitivity – which traits actually matter for which bands), and 03 (soil realism) matter before trusting a real-world inversion result, and why operational retrieval systems calibrate LUT ranges against the actual study area and validate against independent data rather than using generic defaults unmodified and unchecked.
Take-home
Every stage from Tutorial 01 through this page is a real, runnable piece of the same chain – from one hand-written trait row to a real satellite-derived trait map, now with the mask, LUT realism, and domain check happening before inversion rather than as an afterthought. Two pages close out the series from here:
- Tutorial 16 – MARMIT + SPART: whether the soil-moisture realism this page’s LUT training now partly includes (Section 3’s variable soil brightness) actually survives to TOA, answered directly rather than assumed.
- Tutorial 17 – the same real-EO approach as this page, extended to a real multi-date time series over a forest site, comparing NDVI against hybrid-inverted Cab/LAI/EWT through a growing season.
See the Reference manuals and deep dives section of
this site’s Articles menu for the comprehensive, all-12-algorithm
reference manual (ToolsRTM, Getting-LUTs,
InversionOpt vignettes) this tutorial series complements
rather than replaces.