library(lidR) # For processing lidar data
library(dplyr) # For data manipulation
library(sf) # For manipulation of vector datasets
library(terra) # For raster manipulation
library(siplab) # For calculating competition metrics
<- readLAS('extdata/thin_plot_dec.laz') las
Lidar Competition Metrics Workflow
Quantifying Tree-level Competition Metrics and Crown Volume with Drone Lidar Data - Irwin et al. 2024
Overview
In this document we will provide a brief overview of the processing steps required to go from a raw point cloud to a individual tree competition metric layer. This workflow is based on the methods outlined in Irwin et al. 2024 and is designed to be used with the R programming language.
1 - Normalized LAS point cloud
2 - Tree detection (lidR)
3 - Tree segmentation
4 - Computation of crown volume
5 - Computation of competition indices
Tree Detection and Segmentation
In this snippet we will start with a normalized lidar point cloud collected over a coniferous managed forest stand. In this stand managers have expressed desire to perform thinning but require a spatially explicit layer describing the competitive distribution of trees to prioritize their approach. Using this layer they can target areas or even trees with relatively high competition metrics as priority for thinning.
First we will detect and segment tree crowns, there are many methods to achieve this and they should be selected based on the objectives, forest type, and avaliable compute power (more details: lidRbook/itd-its). In this study we applied a modified marker-based watershed approach. Thanks to the distinct conical shape and distribution of the trees in these conifer dominated stands, this approach worked well for detecting and segmenting dominant and co-dominant trees that are the target for commercial thinning.
We will start by loading relevant packages and an example point cloud from the DJI L1 lidar dataset; a subset of the drone laser scanning data clipped from the Irwin et al. 2024 coverage. See the manuscript for details on acquisition parameters.
- To speed up computation in this tutorial, the point cloud has been thinned from the original density (~1200 pts/m2) using a 25 cm voxel retaining 1 random point for each, resulting in a final point density of (173 points/m2).
Download the point cloud here (8.05 mb)
First we will generate a canopy height model, smooth it, detect tree tops, and finally segment tree crowns.
Canopy Height Model Generation
# Generate 0.1m Canopy Height Model (CHM) with lidR
<- lidR::rasterize_canopy(las, res = 0.1, p2r(subcircle = 0.075))
chm
# Gaussian smoothing function
<- function(sigma, n = ws) {
fgauss <- matrix(ncol = n, nrow = n)
m <- rep(1:n, n)
col <- rep(1:n, each = n)
row <- col - ceiling(n/2)
x <- row - ceiling(n/2)
y cbind(row, col)] <- 1/(2 * pi * sigma^2) * exp (-(x^2 + y^2)/(2 * sigma^2))
m[/sum(m)
m
}
# Apply Gaussian smoothing with terra::focal
<- terra::focal(chm, w = fgauss(1, n = 5), na.rm = T)
chm
# Plot smoothed CHM
plot(chm, col = viridis::viridis(50))
Tree top detection
# Locate local maxima (tree tops) across the smoothed CHM with 2m window
<- locate_trees(chm, algorithm = lmf(ws = 2, shape = "circular"))
ttops
# Plot smoothed the CHM with detected local maxima overlaid
plot(chm, col = viridis::viridis(50))
plot(st_geometry(ttops), add = T, pch = 3, col = 'red', cex = 0.5)
Tree crown segmentation
Functions
Watershed Segmentation
# Base watershed segmentation (requires EBimage)
<-function(chm, treetops, th_tree = 2, tree_id = "treeID")
mcwatershed
{= function(bbox)
f
{if (!missing(bbox)) chm <- terra::crop(chm, bbox)
# Convert the CHM to a matrix
<- terra::as.matrix(chm, wide = TRUE)
Canopy <- Canopy < th_tree | is.na(Canopy)
mask <- 0
Canopy[mask]
<- terra::cellFromXY(chm, sf::st_coordinates(treetops)[, c(1, 2)])
cells <- dplyr::pull(treetops, tree_id)
ids
= chm
seeds = 0L
seeds[] = ids
seeds[cells] = terra::as.matrix(seeds, wide = TRUE)
treetops
<- Canopy/max(Canopy)
Canopy <- imager::as.cimg(Canopy)
Canopy <- imager::as.cimg(treetops)
treetops <- imager::watershed(treetops, Canopy)
Crowns <- Crowns[,,1,1]
Crowns
<- NA_integer_
Crowns[mask] <- terra::setValues(chm, Crowns)
out
return(out)
}
<- lidR::plugin_its(f, raster_based = TRUE)
f return(f)
}
Modified Height Limited approach
This approach limits watershed crown segmentation using a canopy height pixel threshold parameter that is set as a multiple of tree height. In this sense a 10 m tall tree crown could not contain CHM pixels less than 7 m in height. This approach is very useful in dense closed canopy stands to reduce over-segmentation and constraint watershed results.
# Height limited watershed segmentation function
<- function(chunk, ttops = NULL, chm_res = 0.25, crown_height_threshold = 0.7, hmin = 2){
crown_mask
# Check that 'chunk' is not missing or NULL
if (is.null(chunk) || missing(chunk)) {
stop("Error: 'chunk' argument must be specified.")
}
# Check if input is a SpatRaster object
if("SpatRaster" == class(chunk)){
<- chunk
chm <- terra::res(chm)[1]
chm_res print('Input is SpatRaster (Presuming CHM) proceeding with crown segmentation')
else{
} # Check if input is a las file
if("LAS" %in% class(chunk)){
<- chunk
las print('Input is las file proceeding with crown segmentation')
}else{
# Check if input is a las catalog chunk
<- lidR::readLAS(chunk)
las if (lidR::is.empty(las)) {
return(NULL)
}print('Input is catalog tile proceeding with crown segmentation')
}
# Generate canopy height
<- lidR::rasterize_canopy(las, res = chm_res , lidR::p2r(na.fill = lidR::knnidw()))
chm
}
# Initial crown segmentation
<- mcwatershed(chm, ttops, th_tree = hmin, tree_id = 'treeID')()
crowns # Replace crown IDs with maximum canopy height within crown
<- terra::classify(x = crowns,
crowns_max rcl = as.data.frame(terra::zonal(chm,
crowns,fun = 'max')))
# Make CHM mask raster where height is less than threshold percent
<- chm
chm_mask < (crown_height_threshold * crowns_max)] = NA
chm_mask[chm_mask # Re-run segmentation on masked CHM
<- mcwatershed(chm_mask, ttops, th_tree = hmin, tree_id = 'treeID')()
crowns_masked
return(crowns_masked)
}
Fill watershed segmentations and keep largest crown polygon for each tree
This function is used to clean up the holes in segmentations resulting from the height limited approach, and to take the resulting largest polygon that is assumed to be the tree crown.
# Keep and fill largest crown polygon for each tree
<- function(polygons, fill_holes = TRUE){
convert_multi_to_single_polygons ::tic()
tictocif(!"MULTIPOLYGON" %in% unique(sf::st_geometry_type(polygons))){
print('ERROR: Input sf polygon df contained zero MUTLIPOLYGONS; conversion not neccessary')
stop()
}# Seperate out multipolygons
<- polygons %>% dplyr::filter(sf::st_geometry_type(polygons) == 'MULTIPOLYGON')
mp # Seperate out polygons
<- polygons %>% dplyr::filter(sf::st_geometry_type(polygons) == 'POLYGON')
sp
<- function(x, Z) {
largest_polygon # Turn multipolygon into vector of single polygons
<- sf::st_combine(x)
x <- sf::st_cast(x, "POLYGON")
x # Calculate area of each single polygon
<- sf::st_area(x)
areas # Take largest polygon
<- which.max(areas)
max_area_index # Re-add the attribute column
<- x[max_area_index] %>% sf::st_as_sf(crs = sf::st_crs(x))
largest # Re-name geometry column
::st_geometry(largest) <- 'geometry'
sf<- cbind(largest, Z) %>% dplyr::relocate(geometry, .after = tidyselect::last_col())
largest if(fill_holes){
# Fill any holes in the resulting polygon
<- nngeo::st_remove_holes(largest)
largest
}return(largest)
}
# Go through multipolygons and apply largest_polygon function
# Empty list for fixed polygons
<- list()
p_polygons # Progress bar
<- progress::progress_bar$new(total = nrow(mp))
pb print('Taking largest polygon of each MULTIPOLYGON crown')
for (i in 1:nrow(mp)) {
<- largest_polygon(sf::st_geometry(mp[i,]), Z = sf::st_drop_geometry(mp[i,]))
p_polygons[[i]] $tick()
pb
}# Bind list of corrected polygons
print(glue::glue('Binding together {length(p_polygons)} cleaned polygons'))
<- do.call(rbind, p_polygons)
p_polygons ::st_crs(p_polygons) <- sf::st_crs(polygons)
sfif(fill_holes == TRUE & nrow(sp) > 0){
# Fill holes in single polygon polygons
<- nngeo::st_remove_holes(sp)
sp
}# Re-join all polygons together
<- rbind(sp, p_polygons)
polygons print(glue::glue('Finished cleaning {nrow(polygons)} polygons'))
::toc()
tictocreturn(polygons)
}
Calculate Tree Crown Volume
Apply treeID values from segmented tree crowns to point cloud
# Merge Crown ID with las point cloud
<- lidR::merge_spatial(las, crowns, attribute = 'treeID')
tree_las # Add treeID attribute to las
= add_lasattribute(tree_las, name = "treeID", desc = "ID of a tree")
tree_las # Filter out non tree points to speed up computation
<- filter_poi(tree_las, !is.na(treeID))
tree_las # Plot resulting point cloud coloured by treeID
plot(tree_las, color = 'treeID')
Generate 3D alphashape and calculate convex/concave hull (crown) volume for each tree
Functions
Compute alpha shape of a clipped lidar point cloud
This function takes an input individual tree lidar point cloud and computes and alpha shape using the alphashape3d package. To do so first any segmentation with less than 3 points are discarded (in practice there are few or none). Second the X, Y, and Z coordinates are normalized to be locally referenced. Next several metrics including the volume of the hull are calculated.
<- function(X,Y,Z){
get_crown_attributes if (length(X) <= 3 || length(Y) <= 3 || length(Z) <= 3) {
print('Cannot compute a 3D hull from 3 or fewer points')
return(NULL)
}# alphashadep3d
= cbind(X, Y, Z)
a3d # get treetop location
<- a3d[which.max(a3d[,3]),1]
top_x <- a3d[which.max(a3d[,3]),2]
top_y # normalize X and Y
1] = a3d[,1] - mean(a3d[,1]) #center points around 0,0,0
a3d[,2] = a3d[,2] - mean(a3d[,2]) #center points around 0,0,0
a3d[,<- c(Inf, 1)
alpha <- alphashape3d::ashape3d(x = a3d, alpha = alpha, pert = TRUE)
ashape
# calculate crown metrics
<- data.frame(
df # Crown height metrics
Zmax = max(Z),
Zq999 = as.numeric(quantile(Z, 0.999)),
Zq99 = as.numeric(quantile(Z, 0.990)),
Z_mean = mean(Z),
n_points = length(Z),
# Crown size
vol_convex = alphashape3d::volume_ashape3d(ashape, indexAlpha = 1),
vol_concave = alphashape3d::volume_ashape3d(ashape, indexAlpha = 2),
# Crown complexity
CV_Z = sd(Z) / mean(Z),
CRR = (mean(Z) - min(Z)) / (max(Z) - min(Z)),
# Get tree top position
X = top_x,
Y = top_y)
return(df)
}
The following is a lidR compatible function to compute these metrics across large areas.
To apply this across lidar tiles where treeIDs have been attributed to each relevant point we create this wrapper function that applies the above function across each tree ID.
<- function(chunk){
get_alphashape_metrics
# Determine if input is chunk in a LAScatalog or a LAS object loaded in R
if ("LAS" %in% class(chunk)) {
<- chunk
tree_las print('Individual LAS object input into function')
else{
} <- lidR::readLAS(chunk)
tree_las
}# Check if input tile is empty
if (lidR::is.empty(tree_las)) return(NULL)
print(glue::glue('Beginning crown metric generation for chunk'))
# Remove duplicate points
<- lidR::filter_duplicates(tree_las)
tree_las
# Organize data by treeID; remove trees with less than 4 points
<- tree_las@data %>%
obs ::filter(!is.na(treeID)) %>%
dplyr::select(X, Y, Z, treeID) %>%
dplyr::group_by(treeID) %>%
dplyr::summarise(n = dplyr::n()) %>% dplyr::ungroup() %>% dplyr::filter(n <= 4)
dplyr
if(nrow(obs) > 0){
print(glue::glue('{nrow(obs)} treeIDs had 4 or fewer points and were discarded'))
}
else{
<- tree_las@data %>%
mets ::filter(!is.na(treeID)) %>%
dplyr::filter(!treeID %in% obs$treeID) %>%
dplyr::select(X,Y,Z,treeID) %>%
dplyr::group_by(treeID) %>%
dplyr# Apply get_crown_attributes function to each treeID
::summarise(ashape_metrics = get_crown_attributes(X, Y, Z))
dplyr
}
# Return data frame with treeID and crown metrics
<- cbind(mets$treeID, mets$ashape_metrics)
mets colnames(mets)[1] <- "treeID"
return(mets)
}
# Filter out points for one tree (example)
<- filter_poi(tree_las, treeID == 147)
tree # Calculate alphashape for tree
<- get_crown_attributes(tree@data$X,
ashape @data$Y,
tree@data$Z,
treeexport_ashape = TRUE)
# Extract individual X,Y,Z points (for plotting)
<- ashape[[3]]
a3d # Extract alphashape object (for plotting)
<- ashape[[2]]
ashape
# Plot Raw Point Cloud
plot(tree, bg = 'white', size = 6)
# Plot convex hull (alpha = Inf)
plot(ashape, indexAlpha = 1, transparency = 0.3, axes = TRUE)
::points3d(a3d, color = 'black')
rgl# Plot concave hull (alpha = 1)
plot(ashape, indexAlpha = 2, transparency = 0.4, axes = TRUE)
::points3d(a3d, color = 'black') rgl
# Decimate density heavily to speed up alphashape computation in example
<- decimate_points(tree_las, random_per_voxel(res = 0.5, n = 1))
tree_las_dec
# Calculate alphashape metrics across all trees in the LAS object
<- get_alphashape_metrics(tree_las) ashape_mets
[1] "Individual LAS object input into function"
Beginning crown metric generation for chunk
Compute Competition Index
Calculate Competition Index based on Crown Volume Values
Functions
Calculate Heygi 1974 Pairwise Index with sf point objects (tree tops) This function will be used to take the detected tree tops from lidR (an sf point obect), convert them to a spatstat compatiable point pattern (ppp) object, and finally calculate the pairwise competition index using the siplap package. Read the paper on siplap here.
<- function(ttops, comp_input = 'vol_convex', maxR = 6){
heygi_cindex
::tic()
tictoc
# Reformat tree tops dataframe
<- ttops %>%
trees_ppp ::mutate(X = X, Y = Y) %>%
dplyras.data.frame() %>%
::select(c(X, Y, comp_input))
dplyr# Standardize column names
names(trees_ppp) <- c('X','Y','comp_value')
# Convert to spatstat ppp object
<- trees_ppp %>%
trees_ppp ::ppp(
spatstat.geomx = .$X,
y = .$Y,
window = spatstat.geom::owin(range(.$X),
range(.$Y)),
marks = .$comp_value)
# Calculate heygi competition index for each tree (comp_input instead of dbh)
<- trees_ppp %>%
heygi ::pairwise(., maxR=maxR, kernel=siplab::powers_ker,
siplabkerpar=list(pi=1, pj=1, pr=1, smark=1))
# Join new cindex with original ttops
<- heygi %>%
trees_cindex as.data.frame() %>% dplyr::select(x, y, cindex)
# Convert to sf object
<- ttops %>% sf::st_as_sf(coords = c('X', 'Y'))
ttops # Join cindex to tree tops sf object
<- dplyr::bind_cols(ttops, trees_cindex)
trees_cindex
print(glue::glue('Calculated Heygi style competition for {nrow(trees_cindex)} trees assesing their {comp_input} within a {maxR}m radius'))
::toc()
tictoc
return(trees_cindex)
}
Here we will take the above function and apply it to the tree tops attributed with the convex hull alpha shape volume from their associated point clouds. We will finish by plotting the result (filtered/binned to emphasize differences in index values).
# Calculate Heygi 1974 style Competition Index on tree tops attributed with crown volume values - remove one outliers one edge
<- heygi_cindex(ashape_mets, comp_input = 'vol_concave', maxR = 6) %>% filter(cindex < 10) heygi_6m
Calculated Heygi style competition for 335 trees assesing their vol_concave within a 6m radius
1.21 sec elapsed
# Define the bins
<- seq(0, 10, by = 2.5)
breaks <- paste(head(breaks, -1), tail(breaks, -1), sep = "-")
bin_labels
# Assign each cindex value to a bin
$cindex_bin <- cut(heygi_6m$cindex, breaks = breaks, labels = bin_labels, include.lowest = TRUE)
heygi_6m
# Create a color mapping for the bins - ensuring one color per bin
<- viridis::viridis(length(bin_labels))
c
# Map each bin to its color
<- setNames(c, bin_labels)
bin_colors <- bin_colors[as.character(heygi_6m$cindex_bin)]
cindex_bin_colors # Plot the CHM
plot(chm, col = colorRampPalette(c("black", "white"))(50), legend = T)
# Plot the sf points with bin colors
plot(st_geometry(heygi_6m), add = TRUE, pch = 21, bg = cindex_bin_colors, col = NA)
# Add a legend for the bins
legend("bottomleft", legend = bin_labels, fill = bin_colors, title = "Competition Index", inset = (c(0.13,0)))