Fractional Cover Modeling with Planet Tanager and Multivariate Random Forests

Author

Mickey Campbell & Phil Dennison

Published

August 27, 2026

Introduction

The purpose of this document is to demonstrate the application of multivariate random forests (MRFs) for predicting fractional cover using Planet Tanager hyperspectral imagery. Changes in fractional cover correspond to vegetation phenology, as well as drought, wildfire, disease, and other ecosystem disturbances. Fractional cover is also useful for mapping crop tillage practices. Our previous work has found that MRFs have robust capabilities for accurately mapping fractional cover, and we’re proposing to use Tanager scenes in combination with field-measured fractional cover to validate MRF fractional cover mapping.

MRFs are random forests that are trained to predict multiple response variables simultaneously (Ishwaran et al. 2008). Despite their limited use to date in the field of remote sensing, MRFs come with all of the same benefits of their much more popular univariate counterparts (e.g., handling of large, correlated predictor sets, ability to leverage non-linear predictor-response relationships, etc.) (Breiman 2001). In the context of fractional cover mapping, they have the added benefit of an innate sum-to-one constraint for predictions. In building trees that minimize loss across multiple response variables, MRFs leverage the complementary nature of predictor-response relationships (i.e., if one fraction increases, necessarily the other(s) must decrease). In other words, the covariance structure of the response variables helps the forest learn meaningful patterns among the predictor variables, enhancing predictive ability.

Synthetic Mixtures

We will train and test our MRF model using 10,000 synthetic spectral mixtures that are feature random mixtures of green vegetation (GV), non-photosynthetic vegetation (NPV), and soil endmembers, derived from field and lab spectra (Dennison et al. 2023). Each mixture has a known fraction of GV, NPV, and soil, along with spectral reflectance at 159 bands (10nm band spacing) between 440 and 2400nm (440 - 1320nm; 1490 - 1770nm; 2000 - 2400nm). The spectra were darkened to approximate a real-world sample of reflectance, and noise was added to simulate satellite sensor signal-to-noise ratios.

Here is a snapshot of these data:

Show code
library(data.table)
library(knitr)
library(Ternary)
library(randomForestSRC)
library(terra)
library(rhdf5)

# suppress terra progress bars
terraOptions(progress = 0)

# define directory structure
main_dir <- "S:/ursa/campbell/Other/planet_tanager"

# read in spectral mixtures data
df <- file.path(main_dir, "synthetic_mixtures.csv") |>
  fread() |> 
  as.data.frame()

# print sample of the data
kable(head(df), digits = 3)
Table 1: First few rows of the synthetic mixture reflectance data used to train and test a MRF fractional cover prediction model.
gv npv soil nm_440 nm_450 nm_460 nm_470 nm_480 nm_490 nm_500 nm_510 nm_520 nm_530 nm_540 nm_550 nm_560 nm_570 nm_580 nm_590 nm_600 nm_610 nm_620 nm_630 nm_640 nm_650 nm_660 nm_670 nm_680 nm_690 nm_700 nm_710 nm_720 nm_730 nm_740 nm_750 nm_760 nm_770 nm_780 nm_790 nm_800 nm_810 nm_820 nm_830 nm_840 nm_850 nm_860 nm_870 nm_880 nm_890 nm_900 nm_910 nm_920 nm_930 nm_940 nm_950 nm_960 nm_970 nm_980 nm_990 nm_1000 nm_1010 nm_1020 nm_1030 nm_1040 nm_1050 nm_1060 nm_1070 nm_1080 nm_1090 nm_1100 nm_1110 nm_1120 nm_1130 nm_1140 nm_1150 nm_1160 nm_1170 nm_1180 nm_1190 nm_1200 nm_1210 nm_1220 nm_1230 nm_1240 nm_1250 nm_1260 nm_1270 nm_1280 nm_1290 nm_1300 nm_1310 nm_1320 nm_1490 nm_1500 nm_1510 nm_1520 nm_1530 nm_1540 nm_1550 nm_1560 nm_1570 nm_1580 nm_1590 nm_1600 nm_1610 nm_1620 nm_1630 nm_1640 nm_1650 nm_1660 nm_1670 nm_1680 nm_1690 nm_1700 nm_1710 nm_1720 nm_1730 nm_1740 nm_1750 nm_1760 nm_1770 nm_2000 nm_2010 nm_2020 nm_2030 nm_2040 nm_2050 nm_2060 nm_2070 nm_2080 nm_2090 nm_2100 nm_2110 nm_2120 nm_2130 nm_2140 nm_2150 nm_2160 nm_2170 nm_2180 nm_2190 nm_2200 nm_2210 nm_2220 nm_2230 nm_2240 nm_2250 nm_2260 nm_2270 nm_2280 nm_2290 nm_2300 nm_2310 nm_2320 nm_2330 nm_2340 nm_2350 nm_2360 nm_2370 nm_2380 nm_2390 nm_2400
0.366 0.533 0.102 0.035 0.037 0.038 0.040 0.041 0.043 0.045 0.048 0.053 0.058 0.062 0.065 0.067 0.067 0.067 0.068 0.070 0.071 0.073 0.075 0.077 0.078 0.081 0.082 0.085 0.089 0.102 0.122 0.150 0.184 0.212 0.235 0.248 0.254 0.259 0.263 0.265 0.268 0.270 0.272 0.272 0.274 0.276 0.277 0.280 0.280 0.281 0.281 0.281 0.281 0.277 0.271 0.265 0.264 0.266 0.268 0.272 0.277 0.281 0.284 0.291 0.295 0.296 0.301 0.300 0.300 0.304 0.301 0.298 0.292 0.282 0.273 0.263 0.262 0.264 0.265 0.264 0.269 0.270 0.276 0.277 0.279 0.281 0.284 0.283 0.280 0.281 0.279 0.271 0.150 0.154 0.160 0.165 0.170 0.174 0.179 0.184 0.188 0.191 0.196 0.199 0.204 0.208 0.208 0.212 0.215 0.214 0.215 0.215 0.213 0.213 0.211 0.209 0.207 0.205 0.201 0.199 0.198 0.114 0.117 0.119 0.120 0.119 0.119 0.118 0.118 0.117 0.119 0.120 0.121 0.123 0.124 0.125 0.127 0.129 0.130 0.132 0.133 0.134 0.135 0.135 0.134 0.130 0.127 0.123 0.120 0.119 0.119 0.115 0.112 0.111 0.111 0.108 0.106 0.107 0.105 0.103 0.102 0.101
0.065 0.327 0.608 0.077 0.079 0.080 0.082 0.083 0.086 0.090 0.093 0.098 0.103 0.108 0.114 0.120 0.126 0.132 0.138 0.143 0.147 0.151 0.154 0.159 0.161 0.165 0.169 0.173 0.177 0.180 0.186 0.191 0.198 0.205 0.212 0.217 0.221 0.225 0.227 0.229 0.233 0.234 0.235 0.238 0.240 0.241 0.244 0.246 0.246 0.249 0.250 0.250 0.252 0.254 0.255 0.257 0.257 0.259 0.261 0.263 0.267 0.267 0.269 0.272 0.274 0.275 0.278 0.279 0.280 0.282 0.284 0.286 0.287 0.286 0.286 0.286 0.288 0.289 0.290 0.293 0.294 0.295 0.300 0.299 0.299 0.304 0.303 0.305 0.306 0.307 0.307 0.308 0.288 0.292 0.294 0.296 0.299 0.301 0.303 0.304 0.303 0.304 0.307 0.308 0.310 0.313 0.316 0.316 0.313 0.317 0.315 0.318 0.318 0.315 0.318 0.315 0.315 0.311 0.313 0.315 0.314 0.281 0.281 0.283 0.282 0.278 0.274 0.275 0.273 0.273 0.272 0.270 0.271 0.274 0.273 0.273 0.274 0.270 0.268 0.264 0.260 0.257 0.257 0.262 0.266 0.264 0.261 0.259 0.257 0.257 0.253 0.252 0.250 0.246 0.244 0.241 0.240 0.237 0.236 0.236 0.235 0.233
0.311 0.110 0.579 0.058 0.058 0.059 0.060 0.061 0.063 0.067 0.071 0.077 0.084 0.090 0.096 0.102 0.106 0.110 0.114 0.119 0.122 0.125 0.128 0.131 0.135 0.138 0.141 0.144 0.149 0.156 0.167 0.181 0.200 0.220 0.237 0.245 0.250 0.254 0.257 0.258 0.261 0.263 0.265 0.266 0.267 0.269 0.271 0.272 0.272 0.273 0.273 0.275 0.275 0.273 0.271 0.271 0.273 0.274 0.275 0.277 0.281 0.286 0.287 0.289 0.293 0.294 0.297 0.297 0.300 0.300 0.299 0.300 0.300 0.293 0.290 0.288 0.288 0.289 0.287 0.289 0.289 0.292 0.294 0.297 0.298 0.298 0.299 0.301 0.299 0.299 0.298 0.294 0.238 0.239 0.242 0.244 0.246 0.251 0.255 0.253 0.256 0.259 0.260 0.262 0.265 0.268 0.269 0.271 0.272 0.273 0.273 0.272 0.271 0.272 0.270 0.270 0.269 0.267 0.266 0.266 0.264 0.221 0.225 0.227 0.227 0.228 0.230 0.230 0.230 0.231 0.231 0.231 0.232 0.234 0.234 0.232 0.233 0.232 0.231 0.228 0.228 0.225 0.226 0.229 0.232 0.232 0.232 0.226 0.228 0.226 0.227 0.225 0.222 0.218 0.217 0.215 0.214 0.214 0.210 0.208 0.208 0.205
0.104 0.790 0.106 0.055 0.057 0.059 0.061 0.063 0.066 0.069 0.072 0.074 0.078 0.081 0.083 0.086 0.088 0.091 0.093 0.095 0.098 0.099 0.103 0.105 0.109 0.111 0.114 0.117 0.121 0.126 0.132 0.139 0.147 0.157 0.167 0.176 0.180 0.185 0.188 0.192 0.196 0.200 0.203 0.205 0.207 0.211 0.214 0.217 0.221 0.222 0.225 0.228 0.230 0.230 0.232 0.232 0.235 0.237 0.241 0.242 0.244 0.246 0.248 0.252 0.255 0.258 0.262 0.262 0.265 0.265 0.268 0.267 0.270 0.268 0.269 0.268 0.270 0.273 0.274 0.274 0.276 0.280 0.280 0.284 0.288 0.290 0.291 0.295 0.298 0.298 0.300 0.299 0.254 0.259 0.262 0.264 0.268 0.273 0.275 0.278 0.280 0.283 0.287 0.289 0.294 0.295 0.300 0.301 0.303 0.301 0.299 0.302 0.300 0.299 0.296 0.296 0.295 0.297 0.298 0.297 0.296 0.260 0.260 0.256 0.250 0.244 0.240 0.234 0.229 0.228 0.225 0.224 0.225 0.226 0.228 0.230 0.232 0.234 0.237 0.236 0.240 0.241 0.243 0.243 0.241 0.234 0.226 0.221 0.213 0.210 0.210 0.208 0.201 0.201 0.199 0.197 0.199 0.202 0.202 0.200 0.202 0.203
0.157 0.318 0.525 0.094 0.096 0.099 0.102 0.105 0.109 0.114 0.121 0.129 0.138 0.146 0.154 0.160 0.166 0.171 0.175 0.180 0.184 0.187 0.192 0.198 0.201 0.205 0.209 0.214 0.220 0.228 0.240 0.253 0.267 0.282 0.293 0.298 0.305 0.311 0.312 0.318 0.321 0.323 0.324 0.328 0.328 0.331 0.332 0.336 0.336 0.338 0.341 0.344 0.346 0.347 0.348 0.348 0.350 0.351 0.353 0.355 0.358 0.359 0.366 0.367 0.372 0.371 0.375 0.379 0.381 0.381 0.380 0.384 0.380 0.380 0.378 0.376 0.381 0.385 0.380 0.383 0.390 0.392 0.398 0.399 0.400 0.401 0.403 0.404 0.405 0.407 0.408 0.404 0.362 0.366 0.371 0.372 0.375 0.379 0.380 0.383 0.388 0.388 0.389 0.391 0.395 0.393 0.394 0.398 0.396 0.395 0.397 0.396 0.398 0.396 0.393 0.392 0.389 0.391 0.390 0.393 0.390 0.336 0.341 0.344 0.346 0.342 0.345 0.345 0.345 0.345 0.345 0.345 0.346 0.348 0.348 0.349 0.348 0.346 0.347 0.345 0.340 0.335 0.335 0.335 0.334 0.334 0.331 0.326 0.323 0.319 0.316 0.313 0.308 0.305 0.304 0.299 0.294 0.298 0.300 0.300 0.298 0.296
0.317 0.335 0.348 0.063 0.065 0.065 0.066 0.068 0.070 0.073 0.078 0.084 0.091 0.097 0.102 0.107 0.111 0.113 0.116 0.119 0.122 0.124 0.127 0.129 0.133 0.135 0.137 0.141 0.147 0.158 0.176 0.199 0.226 0.248 0.264 0.274 0.282 0.285 0.291 0.294 0.296 0.300 0.303 0.305 0.308 0.310 0.315 0.315 0.317 0.321 0.322 0.324 0.325 0.327 0.324 0.323 0.323 0.323 0.326 0.328 0.334 0.338 0.345 0.345 0.350 0.353 0.356 0.357 0.357 0.357 0.352 0.350 0.348 0.342 0.333 0.332 0.331 0.330 0.330 0.330 0.336 0.337 0.342 0.344 0.349 0.350 0.350 0.355 0.354 0.353 0.345 0.345 0.224 0.230 0.234 0.239 0.244 0.248 0.252 0.256 0.257 0.259 0.263 0.268 0.273 0.274 0.278 0.280 0.282 0.281 0.281 0.281 0.280 0.278 0.276 0.271 0.270 0.272 0.271 0.267 0.266 0.199 0.199 0.204 0.205 0.202 0.203 0.201 0.201 0.200 0.199 0.201 0.202 0.204 0.206 0.207 0.207 0.207 0.209 0.208 0.208 0.205 0.207 0.210 0.212 0.209 0.204 0.201 0.198 0.195 0.194 0.191 0.188 0.185 0.185 0.183 0.182 0.181 0.181 0.179 0.179 0.177

Below is a ternary plot featuring a sample of 100 mixtures. Three randomly selected mixtures are highlighted, as their spectra will be plotted out next.

Show code
# create empty ternary plot
par(mar = rep(0,4))
TernaryPlot(
  alab = "GV",
  blab = "NPV",
  clab = "Soil",
  lab.col = c("green", "red", "blue")
)

# get sample for plotting
set.seed(57)
df_tern <- df[sample(nrow(df), 100),]

# get rgb colors
cols <- rgb(
  red = df_tern$npv,
  green = df_tern$gv,
  blue = df_tern$soil
)

# grab a few random mixtures
rand_mixs <- sample(nrow(df_tern), 3)

# get fraction columns
frcols <- c("gv", "npv", "soil")

# add sample of points
TernaryPoints(
  df_tern[,frcols],
  pch = 16,
  col = adjustcolor(cols, alpha.f = 0.75),
  cex = 1
)

# add random mixture points
TernaryPoints(
  df_tern[rand_mixs, frcols], 
  pch = 21, 
  bg = cols[rand_mixs], 
  cex = 3
)
TernaryText(
  df_tern[rand_mixs, frcols], 
  labels = c(1,2,3),
  col = "white",
  font = 2
)
Figure 1: Ternary plot with a sample of synthetic mixtures from our training data. Points are colored according to their fractional cover in RGB space, where NPV fraction is displayed through the Red channel, GV Green, and Soil blue. The full reflectance spectra of points 1, 2, and 3 are shown in the next figure.

Below is a plot containing the reflectance spectra of the three points shown in the previous ternary plot.

Show code
# get reflectance columns
ref_cols <- grep("^nm", colnames(df), value = T)

# get wavelengths
wvls <- ref_cols |>
  sub("nm\\_", "", x = _) |>
  as.numeric()
wvls_all <- seq(min(wvls), max(wvls), 10)

# create reflectance matrix
ref_mat <- matrix(
  NA,
  nrow = nrow(df_tern), 
  ncol = length(wvls_all)
)
colnames(ref_mat) <- wvls_all
for (i in seq_along(wvls_all)){
  wvl <- wvls_all[i]
  if (paste0("nm_", wvl) %in% colnames(df_tern)){
    ref_mat[,i] <- df_tern[,paste0("nm_", wvl)]
  }
}

# create empty plot
par(mar = c(5,5,1,10), las = 1)
plot(
  x = range(wvls_all),
  y = range(ref_mat[rand_mixs,], na.rm = T),
  type = "n",
  xlab = "Wavelength (nm)",
  ylab = "Reflectance"
)

# add random mixtures
lines(
  x = wvls_all,
  y = ref_mat[rand_mixs[1],],
  lwd = 3,
  col = cols[rand_mixs[1]]
)
lines(
  x = wvls_all,
  y = ref_mat[rand_mixs[2],],
  lwd = 3,
  col = cols[rand_mixs[2]]
)
lines(
  x = wvls_all,
  y = ref_mat[rand_mixs[3],],
  lwd = 3,
  col = cols[rand_mixs[3]]
)

# get fractions of sample mixtures
fracs_1 <- paste0(
  "Mixture 1\nGV: ", 
  formatC(df_tern$gv[rand_mixs[1]], digits = 2, format = "f"), "\nNPV: ",
  formatC(df_tern$npv[rand_mixs[1]], digits = 2, format = "f"), "\nSoil: ",
  formatC(df_tern$soil[rand_mixs[1]], digits = 2, format = "f")
)
fracs_2 <- paste0(
  "Mixture 2\n(GV: ", 
  formatC(df_tern$gv[rand_mixs[2]], digits = 2, format = "f"), "\nNPV: ",
  formatC(df_tern$npv[rand_mixs[2]], digits = 2, format = "f"), "\nSoil: ",
  formatC(df_tern$soil[rand_mixs[2]], digits = 2, format = "f")
)
fracs_3 <- paste0(
  "Mixture 3\n(GV: ", 
  formatC(df_tern$gv[rand_mixs[3]], digits = 2, format = "f"), "\nNPV: ",
  formatC(df_tern$npv[rand_mixs[3]], digits = 2, format = "f"), "\nSoil: ",
  formatC(df_tern$soil[rand_mixs[3]], digits = 2, format = "f")
)

# add legend
par(xpd = NA)
legend(
  x = par("usr")[2] + 0.05 * diff(par("usr")[1:2]),
  y = mean(par("usr")[3:4]),
  legend = c(fracs_1, fracs_2, fracs_3),
  lwd = 5,
  col = cols[rand_mixs],
  bty = "n",
  xjust = 0,
  yjust = 0.5,
  y.intersp = 2
)
par(xpd = F)
Figure 2: Spectral reflectance of the three example synthetic mixtures shown in Figure 1. Lines are colored according to their fractional cover in RGB space, where NPV fraction is displayed through the Red channel, GV Green, and Soil blue.

Train Random Forest Model

Next, we will train a MRF model using the randomForestSRC package (Ishwaran et al. 2008). To do this, we will first randomly split our 10,000 mixtures into training (80%) and test (20%) datasets. A model will be trained with the training data and used to predict fractional cover with the test data. The scatterplots below show error for the test data. GV cover is distinct and is modeled with the highest accuracy, while NPV and soil are more difficult to separate. Past work has demonstrated that imaging spectrometer data, like Tanager, improves accuracy of NPV-soil separation due to being able to resolve lignocellulose absorption.

Show code
# split into training and test
train_frac <- 0.8
samp <- sample(nrow(df), nrow(df) * train_frac)
df_train <- df[samp,]
df_test <- df[-samp,]

# train multivariate random forest
mrf <- rfsrc(
  Multivar(gv, npv, soil) ~ .,
  data = df_train
)

# apply to test data
pred <- predict(mrf, df_test)

# compile test predictions and observations
df_pred_obs <- data.frame(
  gv_obs = df_test$gv,
  npv_obs = df_test$npv,
  soil_obs = df_test$soil,
  gv_pred = pred$regrOutput$gv$predicted,
  npv_pred = pred$regrOutput$npv$predicted,
  soil_pred = pred$regrOutput$soil$predicted
)

# create plotting function
plot_fun <- function(frac){
  plot(
    x = c(0,1), 
    y = c(0,1), 
    type = "n", 
    xaxt = "n", 
    yaxt = "n", 
    xlab = NA,
    ylab = NA
  )
  x <- df_pred_obs[,paste0(frac, "_obs")]
  y <- df_pred_obs[,paste0(frac, "_pred")]
  if (frac == "gv"){
    col_pt <- adjustcolor("green", 0.75)
    col_ln <- "darkgreen"
  } else if (frac == "npv"){
    col_pt <- adjustcolor("red", 0.75)
    col_ln <- "darkred"
  } else {
    col_pt <- adjustcolor("blue", 0.75)
    col_ln <- "darkblue"
  }
  grid()
  box()
  abline(0,1)
  points(x, y, pch = 16, col = col_pt)
  mod <- lm(y ~ x)
  abline(mod, lwd = 3, col = col_ln)
  r2 <- 1 - sum((x-y)^2)/sum((x-mean(x))^2)
  r2 <- formatC(r2, digits = 2, format = "f")
  r2 <- bquote(R^2==.(r2))
  rmse <- sqrt(mean((x-y)^2))
  rmse <- formatC(rmse, digits = 2, format = "f")
  rmse <- bquote(RMSE==.(rmse))
  legend(
    "topleft",
    legend = c(r2, rmse),
    text.col = col_ln,
    text.font = 2,
    bty = "n",
    x.intersp = 0
  )
}

# plot predictions and observations
par(mfrow = c(1,3), mar = rep(0,4), oma = c(5,5,2,1), las = 1)
plot_fun("gv")
mtext("GV", side = 3, font = 2, col = "darkgreen", line = 0.5)
axis(1)
axis(2)
plot_fun("npv")
mtext("NPV", side = 3, font = 2, col = "darkred", line = 0.5)
axis(1)
plot_fun("soil")
mtext("Soil", side = 3, font = 2, col = "darkblue", line = 0.5)
axis(1)
mtext("Observed Fraction", 1, 3, outer = T, font = 2)
mtext("Predicted Fraction", 2, 3, outer = T, font = 2, las = 0)
Figure 3: MRF-predicted versus observed fractional cover for GV, NPV, and Soil.

Explore Tanager Scene

With a MRF model now trained, we can now move towards image-based prediction. We acquired a Planet Tanager scene that was captured in Central Utah in 2025 during the Monroe Canyon Fire. We downloaded the orthorectified surface reflectance product (ortho_sr_hdf5).

Below, we will load the scene’s imagery and metadata, and map it out using a natural color composite. The smoke plume of this fire – one of Utah’s largest in recent history – can be easily seen in the image.

Show code
# get hdf file
h5_file <- file.path(main_dir, "20250724_190927_83_4001_ortho_sr_hdf5.h5")

# open as SpatRasterDataset
h5_sds <- sds(h5_file)

# find surface reflectance bands
sr_id <- grep(
  "surface_reflectance$",
  names(h5_sds),
  ignore.case = TRUE
)

# create SpatRaster of surface reflectance
sr <- h5_sds[sr_id]

# read hdf attributes
sr_h5_path <- "/HDFEOS/GRIDS/HYP/Data Fields/surface_reflectance"
attrs <- h5readAttributes(h5_file, sr_h5_path)

# get useful attributes
wavelength_nm <- as.numeric(attrs$wavelengths)
fwhm_nm <- as.numeric(attrs$fwhm)
good_band <- as.logical(attrs$good_wavelengths)

# combine
band_metadata <- data.frame(
  layer = seq_len(nlyr(sr)),
  band = sprintf("B%03d", 1:nlyr(sr)),
  wavelength_nm = wavelength_nm,
  fwhm_nm = fwhm_nm,
  good_wavelength = good_band
)

# add band names to imagery
names(sr) <- band_metadata$band

# get rgb image
r_band <- which.min((abs(650 - band_metadata$wavelength_nm)))
g_band <- which.min((abs(550 - band_metadata$wavelength_nm)))
b_band <- which.min((abs(450 - band_metadata$wavelength_nm)))
sr_rgb <- sr[[c(r_band, g_band, b_band)]]
sr_rgb <- ifel(sr_rgb < 0, 0, sr_rgb)
sr_rgb <- ifel(sr_rgb > 0.25, 0.25, sr_rgb)

# plot it
plotRGB(sr_rgb, scale = 0.25)
Figure 4: Natural color composite of Planet Tanager scene captured in Central Utah during the Monroe Canyon Fire.

Interpolation

Given that the wavelengths of Tanager data differ from our 10nm-resolution synthetic mixture data, we will first interpolate each image pixel’s spectrum to match the band centers of the synthetic mixture data.

Show code
# create spline interpolated image
sr_interp <- app(
  sr,
  fun = function(img) {
    valid <- (
      is.finite(img) &
      img != -9999 &
      band_metadata$good_wavelength &
      is.finite(band_metadata$wavelength_nm)
    )
    if (sum(valid) < 2) {
      return(rep(NA_real_, length(wvls)))
    }
    approx(
      x = band_metadata$wavelength_nm[valid],
      y = img[valid],
      xout = wvls,
      rule = 1,
      ties = "ordered"
    )$y
  }
)

# set names to match synthetic mixtures
names(sr_interp) <- paste0("nm_", wvls)

Predict Fractional Cover

We will now use the MRF model to predict fractional cover with the interpolated Tanager scene. Below you will see the resulting three-band predicted map, where predicted NPV fraction is being displayed through the red channel, GV through green, and Soil through blue. Some very interesting patterns emerge, including:

  • A very useful capture of the Monroe Canyon Fire burn scar, predicted as high soil fraction
  • The ecological gradient from soil/NPV-dominated rangeland at lower elevations to the GV-dominated forests at higher elevations
  • Older fire scars showing up as NPV-dominated late-summer senesced grass/shrub cover
  • Riparian and agricultural areas featuring very high GV fraction
  • Issues with clouds being predicted as high soil fraction (see next section) and cloud shadows being predicted as high GV fraction
Show code
# define prediction function
pred_fun <- function(model, data, ...) {
  pred <- stats::predict(
    model,
    newdata = data,
    ...
  )
  data.frame(
    npv  = pred$regrOutput$npv$predicted,
    gv   = pred$regrOutput$gv$predicted,
    soil = pred$regrOutput$soil$predicted
  )
}

# predict
pred_img <- terra::predict(
  sr_interp,
  mrf,
  fun = pred_fun,
  na.rm = TRUE
)

# plot predictions
plotRGB(pred_img, scale = 1)
Figure 5: MRF-predicted fractional cover displayed as a three-channel (RGB) image, where NPV fraction is displayed through the red channel, GV through green, and Soil through blue.

Masking

We will use the ortho_sr_hdf5 beta_cloud_mask layer to mask out the clouds (and some larger sections of smoke plume).

Show code
# find beta cloud mask
cloud_id <- grep(
  "beta_cloud_mask$",
  names(h5_sds),
  ignore.case = TRUE
)

# extract as a SpatRaster
cloud_mask <- h5_sds[cloud_id]
names(cloud_mask) <- "beta_cloud_mask"

# set cloudy areas to NA
pred_img <- ifel(cloud_mask == 1, NA, pred_img)

# plot predictions
plotRGB(pred_img, scale = 1)
Figure 6: MRF-predicted fractional cover displayed as a three-channel (RGB) image, as in Figure 5, but with clouds masked.

Conclusions

MRF provides a potential machine learning method for global mapping of fractional cover. We have shown that this method can be applied to Planet Tanager imagery in a manner that should be highly transferable across ecosystems while innately enforcing a sum-to-one constraint. Tanager data and field measurement of fractional cover can be used to validate MRF-estimated fractions, demonstrate scalability, and prove applicability across key ecosystems.

References

Breiman, Leo. 2001. “Random Forests.” Machine Learning 45: 5–32. https://doi.org/10.1023/A:1010933404324.
Dennison, Philip E, Brian T Lamb, Michael J Campbell, et al. 2023. “Modeling Global Indices for Estimating Non-Photosynthetic Vegetation Cover.” Remote Sensing of Environment 295: 113715. https://doi.org/10.1016/j.rse.2023.113715.
Ishwaran, Hemant, Udaya B. Kogalur, Eugene H. Blackstone, and Michael S. Lauer. 2008. “Random Survival Forests.” The Annals of Applied Statistics 2 (3): 841–60. https://doi.org/10.1214/08-AOAS169.