Skip to contents

Tutorials 05-12 built the pipeline one stage at a time: LUT, parallel simulation, sensor convolution, indices, sensitivity, and three flavours of inversion. This page runs the whole chain as one coherent pipeline – the same shape as this package’s own course scripts (Scripts/R/ForPROSAIL/, ForFoursail2/, ForINFORM/, ForSPART/, ForMARMIT/) – and, more importantly, shows how the same pipeline looks across different canopy models with only a couple of lines changed.

getLUT()
   |
   v
simulate_RTM()  (fourSAIL / foursail2 / INFORM)
   |
   v
get.spectra.convolved()  (Sentinel-2A / 2B / PRISMA)
   |
   v
getIndices() / getIndicesSE2()
   |
   v
get.inversion()
   |
   v
Vegetation traits

1. One function, three canopy models

simulate_RTM() dispatches to foursail()/foursail2()/inform() based on canopy.modelcanopy.model is a single variable, not a separate script per model:

run_pipeline <- function(canopy.model, leaf.model = "PROSPECT-D", n.samples = 500, seed = 1) {
  LUT <- as.data.frame(getLUT(inputs = ToolsRTM::inputsPROSAIL, nLUT = n.samples, setseed = seed))
  # inputsPROSAIL's own LMA row is held fixed at 0 by design (getLUT() samples
  # Prot/CBC instead, PROSPECT-PRO's dry-matter inputs) -- the default
  # leaf.model="PROSPECT-D" needs LMA directly, so sample it here from a
  # typical real-leaf range; ignored (harmless) for other leaf models.
  LUT$LMA <- stats::runif(n.samples, 0.005, 0.02)
  LUT$Cs <- 0; LUT$fqe <- 0.01; LUT$Cx <- 0
  LUT$cell.d <- 40; LUT$inter.c <- 0.045; LUT$baseline.abs <- 0.0006
  LUT$leaf.thick <- 1.6; LUT$albino.abs <- 0; LUT$lign.cell <- 2; LUT$Nitrogen <- 1
  LUT$fraction_brown <- 0.1; LUT$diss <- 0.5; LUT$Cv <- 1; LUT$Zeta <- 0
  LUT$LAIu <- 0.5; LUT$sd <- 650; LUT$cd <- 4.5; LUT$h <- 20; LUT$skyl <- 0.1

  rsoil <- rep(0.15, 2101)
  # 500 simulations per canopy model, x3 models -- parallelized the same way
  # as Tutorial 06, rather than a plain sapply(), to keep this vignette's
  # build time reasonable at this sample size.
  no_cores <- max(1, parallel::detectCores() - 2)
  cl <- makeCluster(no_cores)
  registerDoParallel(cl)
  refl_list <- foreach(i = seq_len(n.samples), .packages = "ToolsRTM") %dopar% {
    suppressMessages(simulate_RTM(inputLUT = LUT[i, ], rsoil = rsoil,
                                   leaf.model = leaf.model, canopy.model = canopy.model))$rsot
  }
  stopCluster(cl)
  refl <- do.call(rbind, refl_list)
  colnames(refl) <- paste0("R.", 400:2500)

  refl_X <- as.data.frame(refl); colnames(refl_X) <- paste0("X", 400:2500); refl_X <- cbind(id = seq_len(n.samples), refl_X)
  se2a <- suppressMessages(get.spectra.convolved(rfl = refl_X, sensor = "Sentinel2a", plot.spectra = FALSE))
  band_names <- paste0("B", seq_along(as.numeric(names(se2a)[-1])))
  names(se2a) <- c("id", band_names)

  train_idx <- sample(seq_len(n.samples), size = round(0.7 * n.samples))
  train_df <- cbind(LUT[train_idx, ], se2a[train_idx, band_names])
  test_df  <- cbind(LUT[-train_idx, ], se2a[-train_idx, band_names])

  fit <- get.inversion(data = train_df, depVar = "LAI", inputs = band_names,
                        algorithm = "RF", n.samples = nrow(train_df), seed = 42)
  pred <- as.numeric(predict(fit$model, newdata = test_df[, c("LAI", band_names)]))
  r2 <- 1 - sum((test_df$LAI - pred)^2) / sum((test_df$LAI - mean(test_df$LAI))^2)

  list(LUT = LUT, refl = refl, obs = test_df$LAI, pred = pred, r2 = r2)
}

2. Before inverting: is INFORM’s own forward model even LAI-sensitive here?

Before running any inversion, check the physics first – same rigor as Tutorial 10’s spectral sensitivity analysis. Vary only LAI, hold every crown-geometry parameter (sd/cd/h/LAIu – stem density, crown diameter, tree height, understory LAI) at this package’s documented typical stand values, and look at reflectance at four representative bands:

base_row <- as.data.frame(getLUT(inputs = ToolsRTM::inputsPROSAIL, nLUT = 1, setseed = 1))
# inputsPROSAIL's own LMA row is held fixed at 0 by design -- both models
# below use LeafModel="PROSPECT-D", which needs LMA directly, so sample it
# from a typical real-leaf range instead.
base_row$LMA <- 0.009
base_row$Cs <- 0; base_row$fqe <- 0.01; base_row$Cx <- 0
base_row$cell.d <- 40; base_row$inter.c <- 0.045; base_row$baseline.abs <- 0.0006
base_row$leaf.thick <- 1.6; base_row$albino.abs <- 0; base_row$lign.cell <- 2; base_row$Nitrogen <- 1
base_row$fraction_brown <- 0.1; base_row$diss <- 0.5; base_row$Cv <- 1; base_row$Zeta <- 0
base_row$LAIu <- 0.5; base_row$sd <- 650; base_row$cd <- 4.5; base_row$h <- 20; base_row$skyl <- 0.1
rsoil <- rep(0.15, 2101)

lai_seq <- c(0.5, 1, 2, 3, 4, 5, 6, 7)
bands <- c(red = 665, rededge = 705, nir = 800, swir = 1650)
sens_inform <- sapply(lai_seq, function(lai) {
  r <- base_row; r$LAI <- lai
  suppressMessages(inform(inputLUT = r, rsoil = rsoil, LeafModel = "PROSPECT-D"))[bands - 400 + 1]
})
sens_foursail <- sapply(lai_seq, function(lai) {
  r <- base_row; r$LAI <- lai
  foursail(inputLUT = r, rsoil = rsoil, LeafModel = "PROSPECT-D")$rsot[bands - 400 + 1]
})
rownames(sens_inform) <- rownames(sens_foursail) <- names(bands)

op <- par(mfrow = c(1, 2))
matplot(lai_seq, t(sens_inform), type = "o", pch = 19, lty = 1, col = 1:4,
        xlab = "LAI", ylab = "Reflectance", main = "INFORM: reflectance vs. LAI\n(crown geometry fixed)",
        ylim = range(sens_inform, sens_foursail))
legend("right", rownames(sens_inform), col = 1:4, pch = 19, lty = 1, cex = 0.7)
matplot(lai_seq, t(sens_foursail), type = "o", pch = 19, lty = 1, col = 1:4,
        xlab = "LAI", ylab = "Reflectance", main = "fourSAIL: reflectance vs. LAI",
        ylim = range(sens_inform, sens_foursail))

par(op)
cat("INFORM range (max-min) across LAI 0.5-7:\n")
#> INFORM range (max-min) across LAI 0.5-7:
print(round(apply(sens_inform, 1, function(x) max(x) - min(x)), 4))
#>     red rededge     nir    swir 
#>  0.0009  0.0007  0.0052  0.0018
cat("fourSAIL range (max-min) across LAI 0.5-7:\n")
#> fourSAIL range (max-min) across LAI 0.5-7:
print(round(apply(sens_foursail, 1, function(x) max(x) - min(x)), 4))
#>     red rededge     nir    swir 
#>  0.0830  0.0400  0.1999  0.1048

That’s the real, verified starting point: under this fixed-geometry parameterization, INFORM’s own forward-simulated reflectance is far less sensitive to LAI than fourSAIL’s isred and rededge are nearly flat across the whole LAI range (INFORM’s total swing is roughly 1/20th to 1/40th of fourSAIL’s at those bands), and even nir/swir move noticeably less. This isn’t an inversion-algorithm artifact; it’s visible directly in the forward simulations, before any ML model is involved. Forest canopy reflectance in INFORM is a function of both LAI and crown geometry together (via its gap-fraction/hotspot terms, get.SCOPE-style stand-structure formulation); with geometry pinned at one fixed value, LAI alone has comparatively little room left to move the signal.

3. Does letting crown geometry vary help, or hurt?

The natural next question: if fixed geometry caps how much LAI alone can influence the signal, does allowing sd/cd/h/LAIu to vary across the LUT (closer to how a real forest actually varies) improve LAI retrieval – or does it just add more ways for different parameter combinations to produce the same reflectance (equifinality)? Three progressively more complex LUTs, 300 rows each, same RF inversion setup as Section 2 above:

n.samples <- 300
run_case <- function(vary_geom = FALSE, vary_extra = FALSE, seed = 1) {
  LUT <- as.data.frame(getLUT(inputs = ToolsRTM::inputsPROSAIL, nLUT = n.samples, setseed = seed))
  # inputsPROSAIL's own LMA row is held fixed at 0 by design (getLUT() samples
  # Prot/CBC instead, PROSPECT-PRO's dry-matter inputs) -- the default
  # leaf.model="PROSPECT-D" needs LMA directly, so sample it here from a
  # typical real-leaf range; ignored (harmless) for other leaf models.
  LUT$LMA <- stats::runif(n.samples, 0.005, 0.02)
  LUT$Cs <- 0; LUT$fqe <- 0.01; LUT$Cx <- 0
  LUT$cell.d <- 40; LUT$inter.c <- 0.045; LUT$baseline.abs <- 0.0006
  LUT$leaf.thick <- 1.6; LUT$albino.abs <- 0; LUT$lign.cell <- 2; LUT$Nitrogen <- 1
  LUT$fraction_brown <- 0.1; LUT$diss <- 0.5; LUT$Cv <- 1; LUT$Zeta <- 0
  LUT$LAIu <- 0.5; LUT$sd <- 650; LUT$cd <- 4.5; LUT$h <- 20; LUT$skyl <- 0.1

  set.seed(seed + 100)
  if (vary_geom) {
    LUT$sd <- runif(n.samples, 200, 1000); LUT$cd <- runif(n.samples, 2, 7)
    LUT$h  <- runif(n.samples, 8, 25); LUT$LAIu <- runif(n.samples, 0, 0.8)
  }
  if (vary_extra) {
    LUT$Car <- runif(n.samples, 4, 15); LUT$Anth <- runif(n.samples, 0, 2); LUT$Cbrown <- runif(n.samples, 0, 0.3)
  }

  refl <- t(sapply(seq_len(n.samples), function(i) {
    suppressMessages(inform(inputLUT = LUT[i, ], rsoil = rsoil, LeafModel = "PROSPECT-D"))
  }))
  refl_X <- as.data.frame(refl); colnames(refl_X) <- paste0("X", 400:2500); refl_X <- cbind(id = seq_len(n.samples), refl_X)
  se2a <- suppressMessages(get.spectra.convolved(rfl = refl_X, sensor = "Sentinel2a", plot.spectra = FALSE))
  band_names <- paste0("B", seq_along(as.numeric(names(se2a)[-1])))
  names(se2a) <- c("id", band_names)

  set.seed(seed)
  train_idx <- sample(seq_len(n.samples), size = round(0.7 * n.samples))
  train_df <- cbind(LUT[train_idx, ], se2a[train_idx, band_names])
  test_df  <- cbind(LUT[-train_idx, ], se2a[-train_idx, band_names])
  fit <- get.inversion(data = train_df, depVar = "LAI", inputs = band_names,
                        algorithm = "RF", n.samples = nrow(train_df), seed = 42)
  pred <- as.numeric(predict(fit$model, newdata = test_df[, c("LAI", band_names)]))
  1 - sum((test_df$LAI - pred)^2) / sum((test_df$LAI - mean(test_df$LAI))^2)
}
r2_a <- run_case(vary_geom = FALSE, vary_extra = FALSE)  # A: LAI varies, structure fixed

r2_b <- run_case(vary_geom = TRUE,  vary_extra = FALSE)  # B: LAI + crown geometry vary

r2_c <- run_case(vary_geom = TRUE,  vary_extra = TRUE)   # C: B + understory/leaf params vary

data.frame(
  case = c("A: LAI varies, geometry fixed", "B: LAI + crown geometry vary", "C: B + understory/leaf vary"),
  LAI_R2 = round(c(r2_a, r2_b, r2_c), 3)
) |> knitr::kable()
case LAI_R2
A: LAI varies, geometry fixed -0.052
B: LAI + crown geometry vary -0.153
C: B + understory/leaf vary -0.131

The answer is unambiguous: letting crown geometry vary makes LAI retrieval worse, not better (R² drops from A to B), and adding further understory/leaf variability on top (C) doesn’t recover it either. This is equifinality in action: once sd/cd/h/LAIu are free to vary too, many different (LAI, geometry) combinations produce similar reflectance, and the RF model has no way to disentangle which one actually happened. Fixing crown geometry isn’t the cause of INFORM’s weaker LAI retrieval – if anything, it’s the setting where retrieval is least bad, by isolating the one real effect (Section 2) that INFORM’s own physics does show. The full explanation has two real parts: INFORM’s forward reflectance is objectively less LAI-sensitive than fourSAIL/foursail2’s under this parameterization (Section 2), and whatever weak signal LAI does have gets further diluted the moment realistic structural variability is allowed (this section) – more structural representation, more parameter interaction, not “INFORM is worse.”

4. Why is LAI retrieval weaker with INFORM in this experiment?

set.seed(1)
res_foursail  <- run_pipeline("fourSAIL")

set.seed(1)
res_foursail2 <- run_pipeline("foursail2")

set.seed(1)
res_inform    <- run_pipeline("INFORM")

results <- data.frame(
  canopy_model = c("fourSAIL", "foursail2", "INFORM"),
  LAI_R2 = c(res_foursail$r2, res_foursail2$r2, res_inform$r2)
)
knitr::kable(results, digits = 3)
canopy_model LAI_R2
fourSAIL 0.809
foursail2 0.821
INFORM -0.082
op <- par(mfrow = c(1, 2))
plot(res_foursail2$obs, res_foursail2$pred, pch = 19, col = "#009E73",
     xlab = "Observed LAI", ylab = "Predicted LAI", main = paste("foursail2, R2 =", round(res_foursail2$r2, 2)))
abline(0, 1, lty = 2, col = "grey40")
plot(res_inform$obs, res_inform$pred, pch = 19, col = "#D55E00",
     xlab = "Observed LAI", ylab = "Predicted LAI", main = paste("INFORM, R2 =", round(res_inform$r2, 2)))
abline(0, 1, lty = 2, col = "grey40")

par(op)

Under the LUT configuration used in this example, LAI retrieval with INFORM is substantially weaker than with fourSAIL and foursail2. This result is specific to the parameterization and inversion experiment used here and should not be interpreted as a general limitation of INFORM. Sections 2-3’s sensitivity experiments show how LAI interacts with crown geometry, understory/background, and other structural parameters, illustrating the importance of parameter identifiability and equifinality when moving from homogeneous-canopy models (fourSAIL/foursail2) to forest RTMs with explicit crown structure (INFORM). A real forest-inversion study would need either a much larger LUT specifically designed to break these parameter correlations, prior constraints on crown geometry from independent data (e.g. LiDAR-derived stand structure), or accepting wider uncertainty on LAI specifically when using a structurally richer model like INFORM.

3. SPART: structurally different, same downstream pipeline

SPART() (Tutorial 03) already outputs at sensor bands directly – there is no separate “simulate native, then convolve” step, so its own version of this pipeline runs SPART() once per LUT row instead of simulate_RTM() + get.spectra.convolved(), then feeds the result into the exact same get.inversion() call:

# ForSPART/1-simulate_LUT.R's version of this pipeline, conceptually:
sim_i <- SPART(inputLUT = LUT[i, ], CanopyModel = "fourSAIL", LeafModel = "PROSPECT-PRO",
                sensor.i = ToolsRTM::Sentinel2A.MSI, rsoil = NULL, get.plots = FALSE)
toc_i <- sim_i$output$rfl.toc.BRDF  # already at Sentinel-2A bands -- no convolution step needed

4. MARMIT: the odd one out, on purpose

ForMARMIT in this package’s course scripts has no leaf or canopy model at all – no Cab/LAI/EWT to invert. Its target trait is SMC (gravimetric soil moisture, via get.marmit.rsoil()’s own physics), and it inverts almost perfectly (R² typically 0.9+) since soil reflectance’s response to moisture is close to deterministic physics, unlike the noisier proxy relationship trait retrieval from canopy reflectance usually involves. Not run here (see Tutorial 03’s soil-contribution section and the marmit-soil-in-canopy article for the underlying MARMIT physics).

What’s next

  • Tutorial 15 – the same inversion framework applied to real Sentinel-2 imagery retrieved via STAC, including a genuine spatial map.