Section 2 Training Data
In this section we’re going to load and explore the aerial point cloud data and RGB imagery acquired from a UAS platform which will be used for testing the slash pile detection and quantification workflow. We also have vector data including ground-truth slash pile point locations with field-measured values of pile height and diameter and image-annotated slash pile perimeters (i.e. polygons). This training data will be used in the development and statistical testing of the methodologies.
Load the standard libraries we use to do work
# bread-and-butter
library(tidyverse) # the tidyverse
library(viridis) # viridis colors
library(harrypotter) # hp colors
library(RColorBrewer) # brewer colors
library(scales) # work with number and plot scales
library(latex2exp)
# visualization
library(mapview) # interactive html maps
library(kableExtra) # tables
library(patchwork) # combine plots
library(corrplot) # correlation plots
library(ggnewscale) # new scale
library(rayshader) # Visualize Data in 2D and 3D
# spatial analysis
library(terra) # raster
library(sf) # simple features
library(lidR) # lidar data
library(rgl) # 3d plots
library(cloud2trees) # the cloud2trees
library(glcm) # textures of rasters
# modelling
library(brms)
library(tidybayes)2.1 Slash Pile Vector Data
Slash pile field measurements were taken by measuring the height and diameter (longest side of pile) using a laser hypsometer
For volume estimation, we’ll model the ground truth slash piles as a paraboloid, specifically a parabolic dome, assuming a perfectly circular base and sides curved smoothly to a peak. Assuming a paraboloid shape is common for quantifying slash pile volume (Hardy 1996; Long & Boston 2014) and may better represent the diverse shapes of real-world slash piles than assuming a conical or half-sphere form. A paraboloid can represent a variety of shapes including those that are taller and more conical, or flatter and more spread out, because it allows the measured height and width to influence the volume calculation independently. This makes the paraboloid potentially more robust for estimating volumes of piles with varying aspect ratios.
the volume formula for a paraboloid is:
\[ V = \frac{1}{8}\pi \cdot width^2 \cdot height \]
# polygons annotated using RGB and field-collected points
slash_piles_polys <- sf::st_read(
"../data/PFDP_Data/PFDP_SlashPiles/manitou_pile_polys.shp"
# "f:\\PFDP_Data\\PFDP_SlashPiles\\SlashPiles_Polygons.shp"
, quiet = T
) %>%
dplyr::rename_with(tolower) %>%
sf::st_make_valid() %>%
dplyr::filter(sf::st_is_valid(.)) %>%
# fix multipolygons
dplyr::ungroup() %>%
dplyr::mutate(treeID = dplyr::row_number()) %>%
cloud2trees::simplify_multipolygon_crowns() %>%
dplyr::select(-c(treeID, shape_leng, shape_area))
# slash_piles_polys %>% dplyr::glimpse()
# points recorded in field
slash_piles_points <- sf::st_read(
"../data/PFDP_Data/PFDP_SlashPiles/SlashPiles.shp"
# "f:\\PFDP_Data\\PFDP_SlashPiles\\SlashPiles.shp"
, quiet = T
) %>%
dplyr::rename_with(tolower) %>%
sf::st_zm() %>%
sf::st_transform(sf::st_crs(slash_piles_polys)) %>%
dplyr::filter( !(objectid %in% c(43)) ) %>% # duplicate field points
dplyr::mutate(row_number = dplyr::row_number()) %>%
dplyr::select(-c(objectid)) %>%
dplyr::rename(
height_ft = height
, diameter_ft = diameter
)
# stand boundary
stand_boundary <- sf::st_read("../data/PFDP_Data/Tree_Data/GIS/PFDP_Boundary.shp", quiet = T) %>%
sf::st_transform(sf::st_crs(slash_piles_polys)) %>%
sf::st_union()what is the area of the treatment unit boundaries we are looking over?
stand_boundary %>%
sf::st_area() %>%
as.numeric() %>%
`/`(10000) %>%
scales::comma(suffix = " ha", accuracy = 0.01)## [1] "17.48 ha"
that’s great
let’s check this on the map
# mapview::mapview(slash_piles_points, zcol = "comment", layer.name = "slash piles")
mapview::mapview(
stand_boundary
, color = "black"
, lwd = 1
, alpha.regions = 0
, label = FALSE
, legend = FALSE
, popup = FALSE
, layer.name = "stand boundary"
) +
# mapview::mapview(slash_piles_polys, zcol = "is_in_stand", layer.name = "'in' slash piles") +
mapview::mapview(slash_piles_polys, col.regions = "navy", col = NA, layer.name = "pile polys", legend = FALSE) +
mapview::mapview(slash_piles_points, cex = 2, col.regions = "gold", color = "gold", layer.name = "pile points")because each point does not necessarily fall within the polygon boundary (e.g. due to misalignment between the imagery and point locations or slight inaccuracies in either the point or pile boundaries) we need to perform a matching process to tie the points to the polygons so that we get the height and diameter measured during the point collection attached to the polygons. to do this, we’ll use a two-stage process that first attaches the points data frame to polygons where points fall within, using a spatial intersection. It then finds and assigns the remaining, unjoined points to their nearest polygon. The final output includes all polygons from the original data, ensuring that every polygon is represented even if no points were matched.
# function to perform a two-step spatial join
# first matching points that fall inside polygons and
# then assigning the remaining points to the nearest polygon
# all original polygons are returned in the final output
match_points_to_polygons <- function(
points_sf
, polygons_sf
, point_id
, polygon_id
) {
# check if point_id column exists in points_sf
if (!point_id %in% names(points_sf)) {
stop(paste0("column '", point_id, "' not found in points_sf."))
}
# check if polygon_id column exists in polygons_sf
if (!polygon_id %in% names(polygons_sf)) {
stop(paste0("column '", polygon_id, "' not found in polygons_sf."))
}
# 1. ensure the crs are the same.
if (sf::st_crs(points_sf) != sf::st_crs(polygons_sf)) {
points_sf <- sf::st_transform(points_sf, sf::st_crs(polygons_sf))
}
# 2. Perform a standard spatial join for points within polygons.
# Use an inner join (`left = FALSE`) to get only points that fall inside.
points_within <- sf::st_join(
x = points_sf
, y = polygons_sf
, join = sf::st_intersects
, left = FALSE
)
# 3. Identify points that were not matched in the first step.
matched_points_ids <- points_within[[point_id]]
unmatched_points <- points_sf[!points_sf[[point_id]] %in% matched_points_ids, ]
if (nrow(unmatched_points) > 0) {
# 4. For the remaining points, find the index of the nearest polygon.
nearest_polygon_index <- sf::st_nearest_feature(unmatched_points, polygons_sf)
# 5. Extract the nearest polygons and join their attributes to the unmatched points.
nearest_polygons <- polygons_sf[nearest_polygon_index, ]
points_nearest <- data.frame(unmatched_points, sf::st_drop_geometry(nearest_polygons))
# Preserve the geometry from the original unmatched points for the nearest matches.
points_nearest <- sf::st_set_geometry(points_nearest, sf::st_geometry(unmatched_points))
# 6. Combine the results from the "points_within" and "points_nearest" joins.
combined_points <- dplyr::bind_rows(points_within, points_nearest)
} else {
# If all points were matched in step 2.
combined_points <- points_within
}
# 7. Perform a left join to ensure all original polygons are included in the final output.
# Polygons without any matched points will have `NA` values for the point attributes.
final_result <- polygons_sf %>%
dplyr::left_join(
sf::st_drop_geometry(combined_points)
, by = polygon_id
)
return(final_result)
}we’ll also define a function to get the diameter of the polygon which we will use to extract diameter from our predicted segments to compare with the field-measured diameter values. we can also compare the field-measured diameter to the image-annotated diameter as a sanity check.
let’s define a function to get polygon diameter that accurately reflects the measurement for potentially irregular shapes. we’ll calculate the diameter by finding the maximum distance across the footprint of the entire polygon
###___________________________________________###
# calculate diameter of single polygon
###___________________________________________###
# function to calculate the diamater of an sf polygon that is potentially irregularly shaped
# using the distance between the farthest points
st_calculate_diameter_polygon <- function(polygon) {
# get the convex hull
ch <- sf::st_convex_hull(polygon)
# cast to multipoint then point to get individual vertices
ch_points <- sf::st_cast(ch, 'MULTIPOINT') %>% sf::st_cast('POINT')
# calculate the distances between all pairs of points
distances <- sf::st_distance(ch_points)
# find the maximum distance, which is the diameter
diameter <- as.numeric(max(distances,na.rm=T))
return(diameter)
}
# apply st to sf data
st_calculate_diameter <- function(sf_data) {
if(!inherits(sf_data,"sf")){stop("st_calculate_diameter() requires polygon sf data")}
if(
!all( sf::st_is(sf_data, c("POLYGON","MULTIPOLYGON")) )
){
stop("st_calculate_diameter() requires polygon sf data")
}
# get the geometry column name
geom_col_name <- attr(sf_data, "sf_column")
# calculate diameter
# !!rlang::sym() unquotes the geometry column
return_dta <- sf_data %>%
dplyr::ungroup() %>%
dplyr::rowwise() %>%
dplyr::mutate(diameter_m = st_calculate_diameter_polygon( !!rlang::sym(geom_col_name) )) %>%
dplyr::ungroup()
return(return_dta)
}let’s apply our match_points_to_polygons() and st_calculate_diameter() functions
# nrow(slash_piles_polys)
# let's do it
slash_piles_polys <-
match_points_to_polygons(
points_sf = slash_piles_points
, polygons_sf = slash_piles_polys
, point_id = "row_number"
, polygon_id = "pile_id"
) %>%
dplyr::ungroup() %>%
sf::st_make_valid() %>%
dplyr::filter(sf::st_is_valid(.)) %>%
st_calculate_diameter() %>%
dplyr::rename(image_gt_diameter_m = diameter_m) %>%
# calculate area and volume
dplyr::mutate(
# height
height_m = height_ft*0.3048
, field_diameter_m = diameter_ft*0.3048 # *0.3048 or /3.281 to convert to m
, field_radius_m = (field_diameter_m/2)
, image_gt_area_m2 = sf::st_area(.) %>% as.numeric()
, field_gt_area_m2 = pi*field_radius_m^2
# volume ASSUMING PERFECT GEOMETRIC SHAPE :/
, image_gt_volume_m3 = (1/8) * pi * ( (sqrt(image_gt_area_m2/pi)*2)^2 ) * height_m # (1/8) * pi * (shape_length^2) * max_height_m
# (sqrt(image_gt_area_m2/pi)*2) = diameter assuming of circle covering same area
, field_gt_volume_m3 = (1/8) * pi * (field_diameter_m^2) * height_m # (1/8) * pi * (shape_length^2) * max_height_m
)
# check if the pile is in the stand boundary
slash_piles_polys <- slash_piles_polys %>%
dplyr::mutate(
is_in_stand = pile_id %in% (slash_piles_polys %>%
sf::st_intersection(stand_boundary) %>%
sf::st_drop_geometry() %>%
dplyr::pull(pile_id))
)what?
## Rows: 187
## Columns: 19
## $ pile_id <dbl> 3, 4, 5, 6, 8, 11, 13, 15, 16, 17, 19, 20, 21, 22,…
## $ comment <chr> NA, NA, NA, NA, "Mechanical Pile", NA, NA, NA, NA,…
## $ comment2 <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA…
## $ height_ft <dbl> NA, NA, NA, NA, 14.0, NA, NA, NA, NA, NA, NA, NA, …
## $ diameter_ft <dbl> NA, NA, NA, NA, 22.0, NA, NA, NA, NA, NA, NA, NA, …
## $ xcoord <dbl> NA, NA, NA, NA, 1019078, NA, NA, NA, NA, NA, NA, N…
## $ ycoord <dbl> NA, NA, NA, NA, 4334862, NA, NA, NA, NA, NA, NA, N…
## $ refcorner <chr> NA, NA, NA, NA, "G4", NA, NA, NA, NA, NA, NA, NA, …
## $ row_number <int> NA, NA, NA, NA, 93, NA, NA, NA, NA, NA, NA, NA, NA…
## $ geometry <POLYGON [m]> POLYGON ((499341.7 4317759,..., POLYGON ((…
## $ image_gt_diameter_m <dbl> 6.469934, 6.867876, 6.387723, 6.589466, 7.595808, …
## $ height_m <dbl> NA, NA, NA, NA, 4.2672, NA, NA, NA, NA, NA, NA, NA…
## $ field_diameter_m <dbl> NA, NA, NA, NA, 6.7056, NA, NA, NA, NA, NA, NA, NA…
## $ field_radius_m <dbl> NA, NA, NA, NA, 3.3528, NA, NA, NA, NA, NA, NA, NA…
## $ image_gt_area_m2 <dbl> 25.166539, 32.028082, 22.928349, 26.261937, 28.909…
## $ field_gt_area_m2 <dbl> NA, NA, NA, NA, 35.315484, NA, NA, NA, NA, NA, NA,…
## $ image_gt_volume_m3 <dbl> NA, NA, NA, NA, 61.682109, NA, NA, NA, NA, NA, NA,…
## $ field_gt_volume_m3 <dbl> NA, NA, NA, NA, 75.34912, NA, NA, NA, NA, NA, NA, …
## $ is_in_stand <lgl> FALSE, FALSE, FALSE, FALSE, TRUE, FALSE, FALSE, FA…
summary statistics for the form measurements
kbl_form_sum_stats <- function(
pile_df
, caption = "Ground Truth Piles: summary statistics for form measurements"
) {
pile_df %>%
sf::st_drop_geometry() %>%
dplyr::select(
tidyselect::contains("height_m")
| tidyselect::contains("diameter_m")
| tidyselect::contains("area_m2")
| tidyselect::contains("volume_m3")
) %>%
dplyr::summarise(
dplyr::across(
dplyr::everything()
, .fns = list(
mean = ~mean(.x,na.rm=T)
, sd = ~sd(.x,na.rm=T)
, q10 = ~quantile(.x,na.rm=T,probs=0.1)
, q50 = ~quantile(.x,na.rm=T,probs=0.5)
, q90 = ~quantile(.x,na.rm=T,probs=0.9)
, min = ~min(.x,na.rm=T)
, max = ~max(.x,na.rm=T)
)
)
, n = dplyr::n()
) %>%
dplyr::ungroup() %>%
tidyr::pivot_longer(cols = -c(n)) %>%
dplyr::mutate(
agg = stringr::word(name,-1,sep = "_")
, metric = stringr::str_remove_all(name, paste0("_",agg)) %>%
stringr::str_extract("(paraboloid_volume|volume|area|height|diameter)") %>%
dplyr::coalesce("detection") %>%
stringr::str_c(
dplyr::case_when(
stringr::str_detect(name,"(field|image)") ~ paste0(" (", stringr::str_extract(name,"(field|image)"), ")")
, T ~ ""
)
) %>%
stringr::str_replace("area", "area m<sup>2</sup>") %>%
stringr::str_replace("volume", "volume m<sup>3</sup>") %>%
stringr::str_replace("diameter", "diameter m") %>%
stringr::str_replace("height", "height m") %>%
stringr::str_to_sentence()
) %>%
# dplyr::count(metric)
dplyr::select(-name) %>%
dplyr::mutate(
value = dplyr::case_when(
# metric == "gt_height_m" ~ scales::comma(value,accuracy=0.1)
T ~ scales::comma(value,accuracy=0.1)
)
) %>%
tidyr::pivot_wider(names_from = agg, values_from = value) %>%
dplyr::mutate(
range = paste0(min, "—", max)
) %>%
dplyr::arrange(desc(n)) %>%
dplyr::select(-c(min,max)) %>%
kableExtra::kbl(
caption = caption
, col.names = c(
"# piles", "Metric"
, "Mean"
, "Std Dev"
, "q 10%", "Median", "q 90%"
, "Range"
)
, escape = F
# , digits = 2
) %>%
kableExtra::kable_styling(font_size = 13) %>%
kableExtra::collapse_rows(columns = 1, valign = "top")
}kbl_form_sum_stats(
slash_piles_polys %>% dplyr::filter(is_in_stand) %>% dplyr::select(!tidyselect::contains("volume_m3"))
, caption = "Ground Truth Piles: summary statistics for form measurements<br>ponderosa pine training site"
)| # piles | Metric | Mean | Std Dev | q 10% | Median | q 90% | Range |
|---|---|---|---|---|---|---|---|
| 121 | Height m | 2.2 | 0.8 | 1.7 | 2.0 | 2.3 | 1.5—6.4 |
| Diameter m (image) | 3.8 | 1.3 | 3.0 | 3.5 | 4.5 | 2.6—10.2 | |
| Diameter m (field) | 3.4 | 1.2 | 2.8 | 3.1 | 4.0 | 2.4—9.0 | |
| Area m2 (image) | 9.8 | 9.4 | 5.6 | 7.1 | 11.9 | 3.9—59.3 | |
| Area m2 (field) | 10.5 | 10.2 | 6.2 | 7.6 | 12.3 | 4.7—63.1 |
let’s check the field-collected and image-annotated measurements of diameter which will serve as a good sanity check for our image-annotation process (assuming diameter was accurately measured in the field…might be a perilous assumption)
slash_piles_polys %>%
dplyr::mutate(diff_diameter_m = image_gt_diameter_m - field_diameter_m) %>%
ggplot2::ggplot(mapping = ggplot2::aes(x = image_gt_diameter_m, y = field_diameter_m)) +
ggplot2::geom_abline(lwd = 1.5) +
ggplot2::geom_point(ggplot2::aes(color = diff_diameter_m)) +
ggplot2::geom_smooth(method = "lm", se=F, color = "tomato", linetype = "dashed") +
ggplot2::scale_color_viridis_c(option = "mako", direction = -1, alpha = 0.8) +
ggplot2::scale_x_continuous(limits = c(0, max( max(slash_piles_polys$field_diameter_m,na.rm=T), max(slash_piles_polys$image_gt_diameter_m,na.rm=T) ) )) +
ggplot2::scale_y_continuous(limits = c(0, max( max(slash_piles_polys$field_diameter_m,na.rm=T), max(slash_piles_polys$image_gt_diameter_m,na.rm=T) ) )) +
ggplot2::labs(
x = "image-annotated diameter (m)", y = "field-collected diameter (m)"
, color = "image-field\ndiameter diff."
, subtitle = "diameter (m) comparison"
) +
ggplot2::theme_light()
the plot makes these values look very similar with the image-annotated diameter generally larger than the field-collected value. let’s check these using lm()
##
## Call:
## lm(formula = field_diameter_m ~ image_gt_diameter_m, data = slash_piles_polys)
##
## Residuals:
## Min 1Q Median 3Q Max
## -1.18985 -0.16525 0.01416 0.16807 1.76883
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 0.13522 0.09891 1.367 0.174
## image_gt_diameter_m 0.86403 0.02436 35.471 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.3593 on 119 degrees of freedom
## (66 observations deleted due to missingness)
## Multiple R-squared: 0.9136, Adjusted R-squared: 0.9129
## F-statistic: 1258 on 1 and 119 DF, p-value: < 2.2e-16
Our slope of 0.86 is close to 1 and, along with our high R-squared value of 91%, indicate our image- and field-measured diameters are well-calibrated
let’s check the field-collected and image-annotated measurements of volume and area. for both volume measurements, a paraboloid geometry is assumed for calculation with the image-annotated volume relying on the field-collected heights
p1_temp <- slash_piles_polys %>%
ggplot2::ggplot(mapping = ggplot2::aes(x = image_gt_area_m2, y =field_gt_area_m2)) +
ggplot2::geom_abline(lwd = 1.5) +
ggplot2::geom_point(ggplot2::aes(color = height_m)) +
ggplot2::geom_smooth(method = "lm", se=F, color = "tomato", linetype = "dashed") +
ggplot2::scale_color_viridis_c(option = "mako", direction = -1, alpha = 0.8) +
ggplot2::scale_x_continuous(limits = c(0, max( max(slash_piles_polys$field_gt_area_m2,na.rm=T), max(slash_piles_polys$image_gt_area_m2,na.rm=T) ) )) +
ggplot2::scale_y_continuous(limits = c(0, max( max(slash_piles_polys$field_gt_area_m2,na.rm=T), max(slash_piles_polys$image_gt_area_m2,na.rm=T) ) )) +
ggplot2::labs(
x = "image-annotated area (m2)", y = "field-collected area (m2)"
, color = "height (m)"
, subtitle = "area (m2) comparison"
) +
ggplot2::theme_light()
p2_temp <-
slash_piles_polys %>%
ggplot2::ggplot(mapping = ggplot2::aes(x = image_gt_volume_m3, y =field_gt_volume_m3)) +
ggplot2::geom_abline(lwd = 1.5) +
ggplot2::geom_point(ggplot2::aes(color = height_m)) +
ggplot2::geom_smooth(method = "lm", se=F, color = "tomato", linetype = "dashed") +
ggplot2::scale_color_viridis_c(option = "mako", direction = -1, alpha = 0.8) +
ggplot2::scale_x_continuous(limits = c(0, max( max(slash_piles_polys$field_gt_volume_m3,na.rm=T), max(slash_piles_polys$image_gt_volume_m3,na.rm=T) ) )) +
ggplot2::scale_y_continuous(limits = c(0, max( max(slash_piles_polys$field_gt_volume_m3,na.rm=T), max(slash_piles_polys$image_gt_volume_m3,na.rm=T) ) )) +
ggplot2::labs(
x = "image-annotated volume (m3)", y = "field-collected volume (m3)"
, color = "height (m)"
, subtitle = "volume (m3) comparison"
) +
ggplot2::theme_light()
patchwork::wrap_plots(list(p1_temp,p2_temp), guides = "collect") &
ggplot2::theme(legend.position = "bottom")
even assuming a perfectly circular base for the area of the field-collected data (i.e. based on measured diameter), our image-annotated values are in-line with the field-collected data as we saw with the diameter comparison
quick summary of these measurements
slash_piles_polys %>%
sf::st_drop_geometry() %>%
dplyr::select(height_m, tidyselect::ends_with("area_m2"), tidyselect::ends_with("volume_m3")) %>%
summary()## height_m image_gt_area_m2 field_gt_area_m2 image_gt_volume_m3
## Min. :1.524 Min. : 3.805 Min. : 4.670 Min. : 3.279
## 1st Qu.:1.829 1st Qu.: 6.200 1st Qu.: 6.585 1st Qu.: 5.963
## Median :1.981 Median : 7.107 Median : 7.591 Median : 7.088
## Mean :2.179 Mean :10.808 Mean :10.486 Mean : 13.831
## 3rd Qu.:2.134 3rd Qu.: 8.597 3rd Qu.: 8.829 3rd Qu.: 8.428
## Max. :6.401 Max. :60.998 Max. :63.069 Max. :172.950
## NA's :66 NA's :66 NA's :66
## field_gt_volume_m3
## Min. : 4.270
## 1st Qu.: 6.615
## Median : 7.527
## Mean : 14.990
## 3rd Qu.: 8.746
## Max. :183.080
## NA's :66
2.2 RGB orthomosaic
Orthomosaic tif files from the UAS flight imagery that were created in Agisoft Metashape are loaded and stitched together via terra::mosaic.
# read list of orthos
ortho_list_temp <- list.files(
"f:\\PFDP_Data\\p4pro_images\\P4Pro_06_17_2021_half_half_optimal\\3_dsm_ortho\\2_mosaic"
, pattern = "*\\.(tif|tiff)$", full.names = T)[] %>%
purrr::map(function(x){terra::rast(x)})
ortho_list_temp[[1]] %>% terra::res()
# terra::aggregate(20) %>%
# terra::plotRGB(r = 1, g = 2, b = 3, stretch = "hist", colNA = "transparent")
####### ensure the resolution of the rasters matches
# terra::res(ortho_list_temp[[1]])
## function
change_res_fn <- function(r, my_res=1, m = "bilinear"){
r2 <- r
terra::res(r2) <- my_res
r2 <- terra::resample(r, r2, method = m)
return(r2)
}
## apply the function
ortho_list_temp <- 1:length(ortho_list_temp) %>%
purrr::map(function(x){change_res_fn(ortho_list_temp[[x]], my_res=0.10)})
# terra::res(ortho_list_temp[[1]])
# ortho_list_temp[[1]] %>%
# terra::aggregate(2) %>%
# terra::plotRGB(r = 1, g = 2, b = 3, stretch = "hist", colNA = "transparent")
######## mosaic the raster list
ortho_rast <- terra::mosaic(
terra::sprc(ortho_list_temp)
, fun = "min" # min only thing that works
)
names(ortho_rast) <- c("red","green","blue","alpha")
# ortho_rast %>%
# terra::aggregate(4) %>%
# terra::plotRGB(r = 1, g = 2, b = 3, stretch = "lin", colNA = "transparent")make a function to plot the RGB imagery as a background for ggplot2 plots
######################################################################################
# function to plot ortho + stand
######################################################################################
ortho_plt_fn = function(my_ortho_rast = ortho_rast, stand = las_ctg_dta %>% sf::st_union() %>% sf::st_as_sf(), buffer = 20){
# convert to stars
ortho_st <- my_ortho_rast %>%
terra::subset(subset = c(1,2,3)) %>%
terra::crop(
stand %>% sf::st_buffer(buffer) %>% terra::vect()
) %>%
# terra::aggregate(fact = 2, fun = "mean", na.rm = T) %>%
stars::st_as_stars()
# convert to rgb
ortho_rgb <- stars::st_rgb(
ortho_st[,,,1:3]
, dimension = 3
, use_alpha = FALSE
# , stretch = "histogram"
, probs = c(0.005, 0.995)
, stretch = "percent"
)
# ggplot
plt_rgb <- ggplot2::ggplot() +
stars::geom_stars(data = ortho_rgb[]) +
ggplot2::scale_fill_identity(na.value = "transparent") + # !!! don't take this out or RGB plot will kill your computer
ggplot2::scale_x_continuous(expand = c(0, 0)) +
ggplot2::scale_y_continuous(expand = c(0, 0)) +
ggplot2::labs(
x = ""
, y = ""
) +
ggplot2::theme_void()
# return(plt_rgb)
# combine all plot elements
plt_combine = plt_rgb +
# geom_sf(
# data = stand
# , alpha = 0
# , lwd = 1.5
# , color = "gray22"
# ) +
ggplot2::theme(
legend.position = "top" # c(0.5,1)
, legend.direction = "horizontal"
, legend.margin = ggplot2::margin(0,0,0,0)
, legend.text = ggplot2::element_text(size = 8)
, legend.title = ggplot2::element_text(size = 8)
, legend.key = ggplot2::element_rect(fill = "white")
# , plot.title = ggtext::element_markdown(size = 10, hjust = 0.5)
, plot.title = ggplot2::element_text(size = 8, hjust = 0.5, face = "bold")
, plot.subtitle = ggplot2::element_text(size = 6, hjust = 0.5, face = "italic")
)
return(plt_combine)
}plot an example slash pile RGB image
stand_temp <- slash_piles_polys %>%
dplyr::filter(tolower(comment)=="mechanical pile") %>%
dplyr::arrange(desc(field_diameter_m)) %>%
dplyr::slice(1) %>%
sf::st_point_on_surface() %>%
sf::st_buffer(10, endCapStyle = "SQUARE") %>%
sf::st_transform(terra::crs(ortho_rast))
# check it with the ortho
ortho_plt_fn(stand = stand_temp)
2.2.1 Example ratio-based index
While hyperspectral image data enables more detailed analysis by capturing a broader spectral range than RGB imagery, we can still perform robust analysis using spectral data in the visible range
let’s define a general function for a ratio based (e.g. vegetation) index
spectral_index_fn <- function(rast, layer1, layer2) {
bk <- rast[[layer1]]
bi <- rast[[layer2]]
vi <- (bk - bi) / (bk + bi)
return(vi)
}The Green-Red Vegetation Index (GRVI) uses the reflectance of green and red bands to assess vegetation health and identify ground cover types. The formula is GRVI = (green - red) / (green + red). Higher GRVI values indicate healthy vegetation, while negative values suggest soils, and values near zero may indicate water or snow.
grvi_rast <- spectral_index_fn(rast = ortho_rast, layer1 = 2, layer2 = 1)
names(grvi_rast) <- c("grvi")
terra::plot(grvi_rast, col = harrypotter::hp(n=100, option = "Slytherin"))
let’s check the GRVI for a ground truth pile
# check it with the ortho
grvi_rast %>%
terra::crop(stand_temp %>% sf::st_buffer(20)) %>%
terra::as.data.frame(xy=T) %>%
dplyr::rename(f=3) %>%
ggplot2::ggplot() +
ggplot2::geom_tile(ggplot2::aes(x=x,y=y,fill = f), color = NA) +
scale_fill_gradient2(low = "black", high = "forestgreen") +
scale_x_continuous(expand = c(0, 0)) +
scale_y_continuous(expand = c(0, 0)) +
ggplot2::theme_void() +
theme(
legend.position = "none" # c(0.5,1)
, legend.direction = "horizontal"
, legend.margin = margin(0,0,0,0)
, legend.text = element_text(size = 8)
, legend.title = element_text(size = 8)
, legend.key = element_rect(fill = "white")
# , plot.title = ggtext::element_markdown(size = 10, hjust = 0.5)
, plot.title = element_text(size = 10, hjust = 0.5, face = "bold")
, plot.subtitle = element_text(size = 8, hjust = 0.5, face = "italic")
)
2.3 Study area imagery
let’s look at the RGB imagery and pile locations
# get the base plot
plt_rgb_ortho <- ortho_plt_fn(
stand =
slash_piles_points %>%
sf::st_bbox() %>%
sf::st_as_sfc() %>%
sf::st_buffer(50) %>%
sf::st_transform(terra::crs(ortho_rast))
)
# add pile locations
plt_rgb_ortho +
ggplot2::geom_sf(
data = slash_piles_points %>% sf::st_transform(terra::crs(ortho_rast))
, ggplot2::aes() # size = diameter
, shape = 1
, color = "firebrick"
) +
ggplot2::theme(legend.position = "none")
notice these are point measurements of plot locations and the points are not precisely in the center of the pile. notice also there are piles in the imagery that were not measured (e.g. upper-left corner)
we created image-annotated pile polygons using the RBG and field-collected pile location data to compare against our predicted slash pile segments using an intersection over union (IoU) approach
let’s make a panel of plots for each pile
p_fn_temp <- function(
rn
, df = slash_piles_polys %>% dplyr::filter(!is.na(comment))
, crs = terra::crs(ortho_rast)
) {
# scale the buffer based on the largest
d <- df %>%
dplyr::arrange(tolower(comment), desc(field_diameter_m)) %>%
dplyr::slice(rn) %>%
sf::st_transform(crs)
# plt
ortho_plt_fn(stand=d) +
ggplot2::geom_sf(data = d, fill = NA, color = "firebrick") +
ggplot2::labs(
subtitle = paste0(
tolower(d$comment)
, "\ndiam. = "
, scales::comma(d$field_diameter_m, accuracy = 0.1)
# , ", ht. = "
# , scales::comma(d$height, accuracy = 0.1)
)
)
}
# add pile locations
plt_list_rgb <- 1:nrow(slash_piles_polys %>% dplyr::filter(!is.na(comment))) %>%
purrr::map(p_fn_temp)plot tiles
patchwork::wrap_plots(
sample(
plt_list_rgb, size = as.integer(nrow(slash_piles_points)/3))
, ncol = 5
)
a challenge in using the spectral data to identify slash piles will be to develop a spectral-based method that can account for the different lighting conditions in the imagery (e.g. piles in shadows or under tree crowns). this different lighting may have also influenced the point cloud generation
2.4 Point Cloud Data
Let’s check out the point cloud data we got using UAS-SfM methods
# directory with the downloaded .las|.laz files
f_temp <-
"f:\\PFDP_Data\\p4pro_images\\P4Pro_06_17_2021_half_half_optimal\\2_densification\\point_cloud"
# system.file(package = "lidR", "extdata", "Megaplot.laz")
# is there data?
list.files(f_temp, pattern = ".*\\.(laz|las)$") %>% length()## [1] 1
## [1] "P4Pro_06_17_2021_half_half_optimal_group1_densified_point_cloud.las"
what information does lidR read from the catalog?
las_ctg <- lidR::readLAScatalog(f_temp)
# set the processing options
lidR::opt_progress(las_ctg) <- F
lidR::opt_filter(las_ctg) <- "-drop_duplicates"
lidR::opt_select(las_ctg) <- "xyziRGB"
# huh?
las_ctg## class : LAScatalog (v1.2 format 3)
## extent : 499264.2, 499958.8, 4317541, 4318147 (xmin, xmax, ymin, ymax)
## coord. ref. : WGS 84 / UTM zone 13N
## area : 420912.1 m²
## points : 158.01 million points
## type : terrestrial
## density : 375.4 points/m²
## num. files : 1
that’s a lot of points…can an ordinary laptop handle it? we’ll find out.
We’ll plot our point cloud data tiles real quick to orient ourselves
2.5 Check out one pile
las_temp <- lidR::clip_roi(
las_ctg
# biggest mechanical
, slash_piles_polys %>%
dplyr::filter(tolower(comment)=="mechanical pile") %>%
dplyr::arrange(desc(field_diameter_m)) %>%
dplyr::slice(1) %>%
sf::st_point_on_surface() %>%
sf::st_buffer(10, endCapStyle = "SQUARE") %>%
sf::st_transform(lidR::st_crs(las_ctg))
)what did we get?
## Rows: 181,073
## Columns: 7
## $ X <dbl> 499807.3, 499807.3, 499807.3, 499807.3, 499807.3, 499807.3, …
## $ Y <dbl> 4317993, 4317992, 4317983, 4317983, 4317993, 4317989, 431799…
## $ Z <dbl> 2711.213, 2711.567, 2713.496, 2713.221, 2711.405, 2712.323, …
## $ Intensity <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, …
## $ R <int> 40448, 33024, 37632, 40192, 17152, 42496, 41472, 39168, 5068…
## $ G <int> 37632, 31232, 35584, 36864, 16384, 38912, 37632, 37376, 5273…
## $ B <int> 33280, 26112, 29184, 31744, 10752, 34304, 32256, 33792, 5145…
plot a sample
las_temp %>%
lidR::plot(
color = "Z", bg = "white", legend = F
, pal = harrypotter::hp(n=50, house = "gryffindor")
)
make a gif
library(magick)
if(!file.exists(file.path("../data/", "pile_z.gif"))){
rgl::close3d()
lidR::plot(
las_temp, color = "Z", bg = "white", legend = F
, pal = harrypotter::hp(n=50, house = "gryffindor")
)
rgl::movie3d( rgl::spin3d(), duration = 10, fps = 10 , movie = "pile_z", dir = "../data/")
rgl::close3d()
}