Lidar Competition Metrics Workflow

Author

Liam Irwin

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)

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

las <- readLAS('extdata/thin_plot_dec.laz')

Example data included in tutorial: top row is unthinned DLS dataset (1250 points/m2, coloured by Z values and RGB), bottom row is same dataset thinned to one point per 0.25 m voxel (173 points/m2)

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
chm <- lidR::rasterize_canopy(las, res = 0.1, p2r(subcircle = 0.075))

# Gaussian smoothing function
fgauss <- function(sigma, n = ws) {
    m <- matrix(ncol = n, nrow = n)
    col <- rep(1:n, n)
    row <- rep(1:n, each = n)
    x <- col - ceiling(n/2)
    y <- row - ceiling(n/2)
    m[cbind(row, col)] <- 1/(2 * pi * sigma^2) * exp     (-(x^2 + y^2)/(2 * sigma^2))
    m/sum(m)
  }

# Apply Gaussian smoothing with terra::focal
chm <- terra::focal(chm, w = fgauss(1, n = 5), na.rm = T)

# Plot smoothed CHM
plot(chm, col = viridis::viridis(50))

Tree top detection

# Locate local maxima (tree tops) across the smoothed CHM with 2m window
ttops <- locate_trees(chm, algorithm = lmf(ws = 2, shape = "circular"))

# 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)
mcwatershed <-function(chm, treetops, th_tree = 2, tree_id = "treeID")
{
  f = function(bbox)
  {
    if (!missing(bbox)) chm <- terra::crop(chm, bbox)

    # Convert the CHM to a matrix
    Canopy <- terra::as.matrix(chm, wide = TRUE)
    mask   <- Canopy < th_tree | is.na(Canopy)
    Canopy[mask] <- 0

    cells <- terra::cellFromXY(chm, sf::st_coordinates(treetops)[, c(1, 2)])
    ids <- dplyr::pull(treetops, tree_id)

    seeds = chm
    seeds[] = 0L
    seeds[cells] = ids
    treetops = terra::as.matrix(seeds, wide = TRUE)

    Canopy <- Canopy/max(Canopy)
    Canopy <- imager::as.cimg(Canopy)
    treetops  <- imager::as.cimg(treetops)
    Crowns <- imager::watershed(treetops, Canopy)
    Crowns <- Crowns[,,1,1]

    Crowns[mask] <- NA_integer_
    out <- terra::setValues(chm, Crowns)

    return(out)
  }

  f <- lidR::plugin_its(f, raster_based = TRUE)
  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 
crown_mask <- function(chunk, ttops = NULL, chm_res = 0.25, crown_height_threshold = 0.7, hmin = 2){

  # 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)){
    chm <- chunk
    chm_res <- terra::res(chm)[1]
    print('Input is SpatRaster (Presuming CHM) proceeding with crown segmentation')
  } else{
    # Check if input is a las file
    if("LAS" %in% class(chunk)){
      las <- chunk
      print('Input is las file proceeding with crown segmentation')
    }
    else{
      # Check if input is a las catalog chunk
      las <- lidR::readLAS(chunk)
      if (lidR::is.empty(las)) {
        return(NULL)
      }
      print('Input is catalog tile proceeding with crown segmentation')
    }

    # Generate canopy height
    chm <- lidR::rasterize_canopy(las, res = chm_res , lidR::p2r(na.fill = lidR::knnidw()))
  }

  # Initial crown segmentation
  crowns <- mcwatershed(chm, ttops, th_tree = hmin, tree_id = 'treeID')()
  # Replace crown IDs with maximum canopy height within crown
  crowns_max <- terra::classify(x = crowns,
                                rcl = as.data.frame(terra::zonal(chm,
                                                                 crowns,
                                                                 fun = 'max')))
  # Make CHM mask raster where height is less than threshold percent
  chm_mask <- chm
  chm_mask[chm_mask < (crown_height_threshold * crowns_max)] = NA
  # Re-run segmentation on masked CHM
  crowns_masked <- mcwatershed(chm_mask, ttops, th_tree = hmin, tree_id = 'treeID')()

  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
convert_multi_to_single_polygons <- function(polygons, fill_holes = TRUE){
tictoc::tic()
if(!"MULTIPOLYGON" %in% unique(sf::st_geometry_type(polygons))){
  print('ERROR: Input sf polygon df contained zero MUTLIPOLYGONS; conversion not neccessary')
  stop()
}
# Seperate out multipolygons
mp <- polygons %>% dplyr::filter(sf::st_geometry_type(polygons) == 'MULTIPOLYGON')
# Seperate out polygons
sp <- polygons %>% dplyr::filter(sf::st_geometry_type(polygons) == 'POLYGON')

largest_polygon <- function(x, Z) {
  # Turn multipolygon into vector of single polygons
  x <- sf::st_combine(x)
  x <- sf::st_cast(x, "POLYGON")
  # Calculate area of each single polygon
  areas <- sf::st_area(x)
  # Take largest polygon
  max_area_index <- which.max(areas)
  # Re-add the attribute column
  largest <- x[max_area_index] %>% sf::st_as_sf(crs = sf::st_crs(x))
  # Re-name geometry column
  sf::st_geometry(largest) <- 'geometry'
  largest <- cbind(largest, Z) %>% dplyr::relocate(geometry, .after = tidyselect::last_col())
  if(fill_holes){
  # Fill any holes in the resulting polygon
  largest <- nngeo::st_remove_holes(largest)
  }
  return(largest)
}

# Go through multipolygons and apply largest_polygon function
# Empty list for fixed polygons
p_polygons <- list()
# Progress bar
pb <- progress::progress_bar$new(total = nrow(mp))
print('Taking largest polygon of each MULTIPOLYGON crown')
for (i in 1:nrow(mp)) {
  p_polygons[[i]] <- largest_polygon(sf::st_geometry(mp[i,]), Z = sf::st_drop_geometry(mp[i,]))
  pb$tick()
}
# Bind list of corrected polygons
print(glue::glue('Binding together {length(p_polygons)} cleaned polygons'))
p_polygons <- do.call(rbind, p_polygons)
sf::st_crs(p_polygons) <- sf::st_crs(polygons)
if(fill_holes == TRUE & nrow(sp) > 0){
# Fill holes in single polygon polygons
sp <- nngeo::st_remove_holes(sp)
}
# Re-join all polygons together
polygons <- rbind(sp, p_polygons)
print(glue::glue('Finished cleaning {nrow(polygons)} polygons'))
tictoc::toc()
return(polygons)
}

Calculate Tree Crown Volume

Apply treeID values from segmented tree crowns to point cloud

# Merge Crown ID with las point cloud
tree_las <- lidR::merge_spatial(las, crowns, attribute = 'treeID')
# Add treeID attribute to las
tree_las = add_lasattribute(tree_las, name = "treeID", desc = "ID of a tree")
# Filter out non tree points to speed up computation
tree_las <- filter_poi(tree_las, !is.na(treeID))
# Plot resulting point cloud coloured by treeID
plot(tree_las, color = 'treeID')

DLS point cloud plot coloured by treeID’s generated in detection/segmentation process

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.

get_crown_attributes <- function(X,Y,Z){
  if (length(X) <= 3 || length(Y) <= 3 || length(Z) <= 3) {
    print('Cannot compute a 3D hull from 3 or fewer points')
    return(NULL)
  }
  # alphashadep3d
  a3d = cbind(X, Y, Z)
  # get treetop location
  top_x <- a3d[which.max(a3d[,3]),1]
  top_y <- a3d[which.max(a3d[,3]),2]
  # normalize X and Y
  a3d[,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
  alpha <- c(Inf, 1)
  ashape <- alphashape3d::ashape3d(x = a3d, alpha = alpha, pert = TRUE)

  # calculate crown metrics

  df <- data.frame(
    # 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.

get_alphashape_metrics <- function(chunk){

# Determine if input is chunk in a LAScatalog or a LAS object loaded in R
if ("LAS" %in% class(chunk)) {
    tree_las <- chunk
    print('Individual LAS object input into function')
} else{
tree_las <- lidR::readLAS(chunk)
}
# 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
tree_las <- lidR::filter_duplicates(tree_las)

# Organize data by treeID; remove trees with less than 4 points
obs <- tree_las@data %>%
  dplyr::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)

if(nrow(obs) > 0){
print(glue::glue('{nrow(obs)} treeIDs had 4 or fewer points and were discarded'))
}

else{
  mets <- tree_las@data %>%
    dplyr::filter(!is.na(treeID)) %>%
    dplyr::filter(!treeID %in% obs$treeID) %>%
    dplyr::select(X,Y,Z,treeID) %>%
    dplyr::group_by(treeID) %>%
    # Apply get_crown_attributes function to each treeID
    dplyr::summarise(ashape_metrics = get_crown_attributes(X, Y, Z))
}

# Return data frame with treeID and crown metrics
mets <- cbind(mets$treeID, mets$ashape_metrics)
colnames(mets)[1] <- "treeID"

return(mets)

}
# Filter out points for one tree (example)
tree <- filter_poi(tree_las, treeID == 147)
# Calculate alphashape for tree
ashape <- get_crown_attributes(tree@data$X,
                     tree@data$Y,
                     tree@data$Z,
                     export_ashape = TRUE)
# Extract individual X,Y,Z points (for plotting)
a3d <- ashape[[3]]
# Extract alphashape object (for plotting)
ashape <- ashape[[2]]

# Plot Raw Point Cloud
plot(tree, bg = 'white', size = 6)
# Plot convex hull (alpha = Inf)
plot(ashape, indexAlpha = 1, transparency = 0.3, axes = TRUE)
rgl::points3d(a3d, color = 'black')
# Plot concave hull (alpha = 1)
plot(ashape, indexAlpha = 2, transparency = 0.4, axes = TRUE)
rgl::points3d(a3d, color = 'black')

Example computation of three-dimensional alpha shapes from segmented a tree point cloud. Clipped point cloud is displayed on the left, convex (alpha = Infinite) and concave (alpha = 1) alpha shapes fit to this point cloud are displayed in the centre and right
# Decimate density heavily to speed up alphashape computation in example
tree_las_dec <- decimate_points(tree_las, random_per_voxel(res = 0.5, n = 1))

# Calculate alphashape metrics across all trees in the LAS object
ashape_mets <- get_alphashape_metrics(tree_las)
[1] "Individual LAS object input into function"
Beginning crown metric generation for chunk

Compute Competition Index

Calculate Competition Index based on Crown Volume Values

Calculation of Heygi style competition index (maxR = radius of sphere of influence)

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.

heygi_cindex <- function(ttops, comp_input = 'vol_convex', maxR = 6){

  tictoc::tic()

  # Reformat tree tops dataframe
  trees_ppp <- ttops %>%
    dplyr::mutate(X = X, Y = Y) %>%
    as.data.frame() %>%
    dplyr::select(c(X, Y, comp_input))
  # Standardize column names
  names(trees_ppp) <- c('X','Y','comp_value')
  # Convert to spatstat ppp object
  trees_ppp <- trees_ppp %>%
    spatstat.geom::ppp(
      x = .$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)
  heygi <- trees_ppp %>%
    siplab::pairwise(., maxR=maxR, kernel=siplab::powers_ker,
                     kerpar=list(pi=1, pj=1, pr=1, smark=1))


  # Join new cindex with original ttops
  trees_cindex <- heygi %>%
    as.data.frame() %>% dplyr::select(x, y, cindex)
  # Convert to sf object
  ttops <- ttops %>% sf::st_as_sf(coords = c('X', 'Y'))
  # Join cindex to tree tops sf object
  trees_cindex <- dplyr::bind_cols(ttops, trees_cindex)

  print(glue::glue('Calculated Heygi style competition for {nrow(trees_cindex)} trees assesing their {comp_input} within a {maxR}m radius'))

  tictoc::toc()

  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_6m <- heygi_cindex(ashape_mets, comp_input = 'vol_concave', maxR = 6) %>% filter(cindex < 10)
Calculated Heygi style competition for 335 trees assesing their vol_concave within a 6m radius
1.21 sec elapsed
# Define the bins
breaks <- seq(0, 10, by = 2.5)
bin_labels <- paste(head(breaks, -1), tail(breaks, -1), sep = "-")

# Assign each cindex value to a bin
heygi_6m$cindex_bin <- cut(heygi_6m$cindex, breaks = breaks, labels = bin_labels, include.lowest = TRUE)

# Create a color mapping for the bins - ensuring one color per bin
c <- viridis::viridis(length(bin_labels))

# Map each bin to its color
bin_colors <- setNames(c, bin_labels)
cindex_bin_colors <- bin_colors[as.character(heygi_6m$cindex_bin)]
# 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)))