To test out the moving window approach to downscaling fuel load maps versus the regression-aided approach in a 2D, canopy height model-based context.
Step 1. Input data
I am going to use two inputs:
Canopy fuel load
I acquired this dataset from Nuria, with the original filename of OR_Sycan_2021_cfl_kg_m2_proj.tif. It has a spatial resolution of 30m and the filename suggests it represents CFL in kg/m2.
Lidar
I’m using a single tile of lidar data from the 2021 dataset that appears to have come from USGS 3DEP (USGS_LPC_OR_SouthwestCentralSycan_2021_B21). The tile I am using is ...10TFN4948.laz.
Show Code
# load librarieslibrary(lidR)library(terra)# mute progress barsoptions(lidR.progress = F)# read in the lidar data, with some filterslas <-readLAS("S:/ursa/campbell/fb_downscaling/data/als/als_2021/a02_hgt/USGS_LPC_OR_SouthwestCentralSycan_2021_B21_10TFN4948.laz",filter ="-drop_class 7 18 -drop_withheld -keep_z -1 50")# read in the fuel datafuel <-rast("S:/ursa/campbell/fb_downscaling/data/from_nuria/CFL_downscaling/input/OR_Sycan_2021_cfl_kg_m2_proj.tif")
Show Code
# plot the fuel map at full scaleplot( fuel,plg =list(title =expression("Fuel load (kg m"^{-2}*")") ))# add the lidar tile center pointx_center <-mean(range(las$X, na.rm =TRUE))y_center <-mean(range(las$Y, na.rm =TRUE))pt_center <-vect(data.frame(x = x_center, y = y_center),geom =c("x", "y"),crs =projection(las)) |>project(fuel)points( pt_center,pch =21,cex =1.5,bg ="orange",col ="white",lwd =1.5)add_legend("topright",legend ="Lidar tile",pch =21,pt.cex =1.5,pt.bg ="orange",col ="white",pt.lwd =1.5)
Figure 1. Fuel load raster with the center point of the lidar tile.
Figure 2. Closeup view of the lidar tile’s extent with 30m fuel map pixels.
Step 2. Convert biomass density to biomass
In this step, I will convert the original map from biomass density (kg/m2) to biomass (kg).
Show Code
# convert biomass density to biomassfuel_coarse <- fuel_coarse *res(fuel_coarse)[1] *res(fuel_coarse)[2]# plot it outplot( fuel_coarse, plg =list(title ="Fuel load (kg)" ))
Figure 3. Fuel map in raw biomass totals.
Step 3. Generate canopy height model
By default, I’ll use the pitfree() algorithm built into lidR as it tends to produce nice-looking CHMs.
Show Code
# interpolate chmchm <-rasterize_canopy(las, res =1, algorithm =pitfree())# set pixels below 2m to 0 to isolate "canopy"chm <-ifel(chm <2, 0, chm)# get chm boundarychm_boundary <-as.polygons(ext(chm))# plot chmplot( chm,plg =list(title ="Canopy Height (m)" ))
Figure 4. Lidar-based canopy height model.
Step 4. Get focal proportional canopy height
Here is where we begin the downscaling process. The first step in this process is to get the proportional height of each CHM pixel relative to the total CHM height in a focal neighborhood (moving window) around it.
For the sake of simplicity, I will start with a square kernel. Note that there is no way to get a square kernel that will exactly match the ratio of spatial resolutions in this case (30:1), since kernels are required to have odd dimensions. So, my solution is to create a kernel that is one pixel larger than the original ratio.
Using this moving window, I will calculate the focal sum of canopy heights:
Show Code
# get coarse/fine resolution ratio as integer multiplierres_mult <-as.integer(res(fuel_coarse) /res(chm))[1]# get window sizew <-ifelse(res_mult %%2==0, res_mult +1, res_mult)# get chm focal sumchm_focal_sum <-focal(x = chm, w = w, fun ="sum", na.rm = T, na.policy ="omit")# plot it outplot( chm_focal_sum,plg =list(title ="Canopy Height Focal Sum (m)" ))
Figure 5. Sum of canopy height within 31x31 neighborhood around each pixel.
I will then divide individual pixel-level canopy heights to the focal sum to get focal proportional height. That is, each pixel will capture the proportion of canopy height relative to the sum thereof within its 31x31m surrounding area.
Show Code
# get chm focal propchm_focal_prop <- chm / chm_focal_sum# fix division by zero for cases where there are no pixels greater than 2mchm_focal_prop <-ifel(chm_focal_sum ==0, 0, chm_focal_prop)# plot it outplot( chm_focal_prop,plg =list(title ="Canopy Height Focal Proportion" ))
Figure 6. Proportion of canopy height within 31x31 neighborhood around each pixel.
Step 5. Get focal mean fuel load
The next step is to use that exact same moving window kernel to get focal mean fuel load.
To do this, I first have to resample the original 30m fuel map down to 1m to match the resolution of the CHM. To ensure that they are in the same extent, CRS, and pixel grid, I will use the project() function with a nearest neighbor resampling algorithm. In effect, this should look exactly the same as Figure 3, but under the hood, there are 900 1m pixels with replicated values for each original 30m pixel.
Show Code
# reproject and crop fuel rast to match chmfuel_fine_near <-project(fuel_coarse, chm, method ="near")# plot it outplot( fuel_fine_near,plg =list(title ="Fuel Load (kg)" ))
Figure 7. 1m-resampled fuel load.
Next, I will get focal mean fuel load. In effect, this will just look like a smoothed out version of the previous figure.
Show Code
# get fuel focal meanfuel_focal_mean <-focal(x = fuel_fine_near, w = w, fun ="mean", na.rm = T, na.policy ="omit")# plot it outplot( fuel_focal_mean,plg =list(title ="Fuel Load (kg)" ))
Figure 8. Focal mean fuel load.
As a double-check, the global means of these two maps (the nearest-neighbor 1m-resampled fuel map in Figure 7 and the focal mean-smoothed map in Figure 8) should be identical:
NN-resampled global mean: 637.3875732
Focal mean global mean: 637.373486
Within a margin of rounding error. Let’s move on…
Step 6. Apportion fuel to proportional canopy height
The final(-ish) step in this process is to multiply proportional canopy height by focal mean fuel load. The key assumption here is that canopy height explains fuel load. Therefore, within a neighborhood surrounding each cell, the proportion of canopy height that that pixel possesses should equate to the proportion of fuel in that same neighborhood.
Show Code
# get height-apportioned fuelfuel_fine <- fuel_focal_mean * chm_focal_prop# plot it outplot( fuel_fine,plg =list(title ="Fuel Load (kg)" ))
Figure 9. Downscaled 1m fuel load.
If you look closely, you will notice some “spikes” in fuel load. These are found with spatially isolated trees. This makes sense – for an extreme example, if there is only one tree found within a 31x31m area surrounding area, then all of that original biomass has to be assigned to that one tree. Potentially problematic?
Step 7. Re-aggregate for comparison
One of the theoretical benefits of this approach is the fact that, when you re-aggregate the fine-scale map, it should approximately match the original, coarse-scale pixel values. I’ll test that theory here:
Show Code
# reaggregate for testingfuel_reagg <-resample(fuel_fine, fuel_coarse, method ="sum") |>crop(e)# crop original fuel map to lidar extentfuel_orig <-crop(fuel_coarse, e)# stack them upfuel_stack <-c(fuel_orig, fuel_reagg)names(fuel_stack) <-c("original", "reaggregated")# get global maximum for consistent visualizationfuel_max <-global(fuel_stack, "max") |>max()# plot them outplot(fuel_stack, range =c(0, fuel_max))
Figure 10. Original versus re-aggregated fuel load maps.
Interestingly, the re-aggregated results are noisier than the original map. The patterns are very close, but the re-aggregated results tend to yield more extreme highs and lows than the original map. This is also evident in a pixel-to-pixel scatterplot comparison:
Show Code
# convert pixels to data.framedf <-as.data.frame(fuel_stack)x <- df$originaly <- df$reaggregated# plot it outplot(x = x,y = y,pch =16,col =rgb(0,0,0,0.1),xlim =range(df),ylim =range(df),xlab ="original",ylab ="reaggregated",las =1)grid()abline(0,1)mod <-lm(y ~ x)abline(mod, lwd =2, col =2)r2 <-summary(mod)$r.squared |>formatC(2, format ="f")rmse <-sqrt(mean((x - y)^2)) |>formatC(2, format ="f")bias <-mean(y - x) |>formatC(2, format ="f")r2_txt <-bquote(bold(R^2==.(r2)))rmse_txt <-bquote(bold(RMSE==.(rmse)))bias_txt <-bquote(bold(Bias==.(bias)))legend("topleft",legend =c(r2_txt, rmse_txt, bias_txt),text.col =2,bty ="n",x.intersp =0)
Figure 11. Original versus re-aggregated fuel load scatterplot.
Step 8. Testing regression-based approach
Let’s see if the added regression step could help improve invertibility, by potentially mitigating some of the extreme values found inherent to the moving window downscaling approach. To do this I’ll first explore the relationship between the 1m downscaled fuel map and the 1m CHM. I’ll fit a negative exponential model to it.
Pretty good-looking fit. Now I’ll apply this to the CHM to predict fuel load at fine-scale based on canopy height.
Show Code
# apply model to chmnames(chm) <-"x"fuel_fine_regr <-predict( chm, mod,fun =function(model, data, ...) {predict(model, newdata =as.data.frame(data), ...) })# plot it outplot( fuel_fine_regr,plg =list(title ="Fuel Load (kg)" ))
Figure 13. Downscaled fuel via regression approach.
Note how the “isolated tree” effect is entirely gone from this new output – values make much more logical sense. Now let’s aggregate this back up to the original resolution for comparative purposes.
Show Code
# reaggregate for testingfuel_reagg_regr <-resample(fuel_fine_regr, fuel_coarse, method ="sum") |>crop(e)# stack them upfuel_stack <-c(fuel_orig, fuel_reagg_regr)names(fuel_stack) <-c("original", "reaggregated")# get global maximum for consistent visualizationfuel_max <-global(fuel_stack, "max") |>max()# plot them outplot(fuel_stack, range =c(0, fuel_max))
Figure 14. Original versus re-aggregated resolution-based fuel load maps.
The re-aggregated results based on the regression model appear noisier than the pure moving window-based approach. Here is the scatterplot, for comparison:
Show Code
# convert pixels to data.framedf <-as.data.frame(fuel_stack)x <- df$originaly <- df$reaggregated# plot it outplot(x = x,y = y,pch =16,col =rgb(0,0,0,0.1),xlim =range(df),ylim =range(df),xlab ="original",ylab ="reaggregated",las =1)grid()abline(0,1)mod <-lm(y ~ x)abline(mod, lwd =2, col =2)r2 <-summary(mod)$r.squared |>formatC(2, format ="f")rmse <-sqrt(mean((x - y)^2)) |>formatC(2, format ="f")bias <-mean(y - x) |>formatC(2, format ="f")r2_txt <-bquote(bold(R^2==.(r2)))rmse_txt <-bquote(bold(RMSE==.(rmse)))bias_txt <-bquote(bold(Bias==.(bias)))legend("topleft",legend =c(r2_txt, rmse_txt, bias_txt),text.col =2,bty ="n",x.intersp =0)
Figure 15. Original versus re-aggregated regression-based fuel load scatterplot.
Conclusions
The conclusions are about what we already knew, but now hopefully with a little more visual/quantitative support to inform a discussion:
The moving window approach nests back up to the original map more cleanly than the regression-based approach.
However, the moving window approach does yield some pretty extreme values locally at fine scale. Given that these downscaled maps are intended to be used in a fire behavior modeling context, this would seem to be an important consideration.
The regression-based approach yields much more spatially consistent results at fine scales (possibly why it performed better in Nuria’s independent test), but does not aggregate back up as cleanly as the pure moving window approach.
Possible suggestion for moving forward: The regression approach is basically an “add-on” step to the moving window approach, as you can see if you dig through the code above. And, it really does not add much in the way of processing times at the individual tile level. So, might it be worth adding an argument to the eventual downscaling function in voxelFuel that allows one to run the additional regression step? I would just hate to go all the way down the road of processing massive swaths of the Western US, ultimately yielding some voxel-based fuel load estimates that produce unusable fire behavior predictions. One of the big questions was about how to define the spatial window within which the regression model should be based. If we just applied this at the individual tile level, then perhaps that is a baked-in solution to the problem. It could introduce some slight tile-based edge effects, but perhaps those effects are less harmful in a fire behavior context than individual trees with unrealistically high fuel loads?