Skip to contents

Introduction

The two main function families in pliman —shapefile_*() and mosaic_*()— serve as the foundation for high-throughput phenotyping (HTP) analysis. The shapefile_build() function is central for constructing shapefiles, offering customizable layouts and export options that allow users to tailor shapefiles to diverse experimental designs and field layouts, enhancing the relevance and reproducibility of the data collected.

Following shapefile creation, the mosaic_*() family is pivotal for performing mosaic analysis —a process that includes calculating vegetation indices, segmenting plant canopies, and visualizing plant-level data. The mosaic_analyze() function, in particular, automates the extraction of detailed phenotypic information, making it easy to capture metrics on plant health, spatial distribution, and structure across fields. Through practical examples, users learn to leverage this function for automating plant-level data extraction, thus boosting the efficiency and depth of HTP workflows.

All shapefiles and orthomosaics used in this vignette are publicly available here, ensuring reproducibility and allowing users to follow along with each example. Since the data is accessed remotely, an active internet connection is required to run the examples. Alternatively, you can download the entire repository and modify the code to import the files from a local source, making it more convenient for offline use and adaptable to different working environments.

R packages

library(dplyr)
#> 
#> Attaching package: 'dplyr'
#> The following objects are masked from 'package:stats':
#> 
#>     filter, lag
#> The following objects are masked from 'package:base':
#> 
#>     intersect, setdiff, setequal, union
library(ggplot2)
library(pliman)
#> ╭ Welcome to pliman version "3.1.0"! ──────────────────────────────╮
#> │                                                                  │
#> │   Developed collaboratively by NEPEM <https://nepemufsc.com>
#> │   Group lead: Prof. Tiago Olivoto                                │
#> │   For citation, type `citation('pliman')`                        │
#> │   We welcome your feedback and suggestions!                      │
#> │                                                                  │
#> ╰────────────── Simplifying high-throughput plant phenotyping in R
#> 
#> Attaching package: 'pliman'
#> The following object is masked from 'package:dplyr':
#> 
#>     %>%

Shapefiles

Building

Shapefiles are a widely used format in geographic information systems (GIS) for representing vector data such as points, lines, and polygons. They are essential in spatial analysis and can store information about geographical features and their attributes. There are three main geometry types.

  • Points: Represent specific locations (e.g., control points, cities).
  • Lines: Represent linear features (e.g., roads, rivers).
  • Polygons: Represent areas (e.g., boundaries of regions or land parcels).

Below, we explore how theshapefile_build() function works for constructing shapefiles. By default, calling shapefile_build(mosaic, ...) allows the creation of either rectangular grids (defined by rows and columns) or custom, free-form shapes, providing flexibility for different experimental designs.

To create a rectangular grid, you define the corners of your region of interest (ROI) in this sequence: top left → top right → bottom right → bottom left → back to top left to close the polygon. If more than four points are provided, the function will interpret them as vertices of a free-form shape, constructing a customized polygon that precisely fits your specified boundaries. This flexibility makes shapefile_build() a versatile tool, allowing users to adapt their shapefile structures to match complex field layouts or specific plot arrangements.

For the purpose of this example, we will use a predefined shapefile containing control points to demonstrate the process, ensuring reproducibility and allowing you to follow along with the output.

url <- "https://github.com/TiagoOlivoto/images/raw/refs/heads/master/pliman/ortho/"
mosaic <-    mosaic_input(paste0(url, "orthosmall.tif"), info = FALSE)
cpoint <- shapefile_input(paste0(url, "controlpoints.rds"), info = FALSE)

mosaic_plot_rgb(mosaic)
shapefile_plot(cpoint, add = TRUE, lwd = 5)


# Create a basemap for further plots
bm <- mosaic_view(mosaic, r = 1, g = 2, b = 3)
#> Warning in CPL_crs_from_input(x): GDAL Message 1: +init=epsg:XXXX syntax is
#> deprecated. It might return a CRS with a non-EPSG compliant axis order. Further
#> messages of this type will be suppressed.

shp <- shapefile_build(
  mosaic = mosaic,        # the raster file
  controlpoints = cpoint, # control points (optional)
  basemap = bm,           # basemap (optional)
  nrow = 5,               # number of rows
  ncol = 3,               # number of columns
  layout = "tbrl",        # layout definition
  serpentine = FALSE      # serpentine layout?
  
)
#>  Building the mosaic Mosaic built [13ms]
#>  Cropping the mosaic Mosaic cropped [470ms]
#>  Creating the shapes Shapes created [157ms]
#>  Finishing the shapefile Shapefile built [54ms] 

# see key aspects of the created shapefiles
shapefile_measures(shp)
#> Simple feature collection with 15 features and 11 fields
#> Geometry type: POLYGON
#> Dimension:     XY
#> Bounding box:  xmin: 734315.6 ymin: 4488975 xmax: 734327 ymax: 4488979
#> Projected CRS: WGS 72BE / UTM zone 14N
#> First 10 features:
#>    unique_id block plot_id row column   xcoord  ycoord     area perimeter width
#> 1          1   B01   P0011   1      1 734317.5 4488978 2.943949  9.127652 3.786
#> 2          2   B01   P0012   2      1 734317.5 4488978 2.943949  9.127652 3.786
#> 3          3   B01   P0013   3      1 734317.5 4488977 2.943949  9.127652 3.786
#> 4          4   B01   P0014   4      1 734317.5 4488976 2.943949  9.127652 3.786
#> 5          5   B01   P0015   5      1 734317.6 4488975 2.943949  9.127652 3.786
#> 6          6   B01   P0006   1      2 734321.2 4488978 2.943949  9.127652 3.786
#> 7          7   B01   P0007   2      2 734321.3 4488978 2.943949  9.127652 3.786
#> 8          8   B01   P0008   3      2 734321.3 4488977 2.943949  9.127652 3.786
#> 9          9   B01   P0009   4      2 734321.3 4488976 2.943949  9.127652 3.786
#> 10        10   B01   P0010   5      2 734321.3 4488975 2.943949  9.127652 3.786
#>    height                       geometry
#> 1   0.778 POLYGON ((734315.6 4488979,...
#> 2   0.778 POLYGON ((734315.6 4488978,...
#> 3   0.778 POLYGON ((734315.6 4488977,...
#> 4   0.778 POLYGON ((734315.6 4488976,...
#> 5   0.778 POLYGON ((734315.7 4488976,...
#> 6   0.778 POLYGON ((734319.3 4488979,...
#> 7   0.778 POLYGON ((734319.4 4488978,...
#> 8   0.778 POLYGON ((734319.4 4488977,...
#> 9   0.778 POLYGON ((734319.4 4488976,...
#> 10  0.778 POLYGON ((734319.4 4488976,...

The function shapefile_build() returns a Simple Feature sf object , which is a data structure used to store spatial objects (points, lines, polygons) along with associated attributes, as follows:

Attribute Description
unique_id A unique identifier for each feature (e.g., plot or area).
block The block grouping of the feature, often used in experimental designs (e.g., “B01” represents Block 1).
plot_id The identifier for the specific plot within the block (e.g., “P0001” represents Plot 1).
row The row number within the plot layout (spatial positioning of the plot within a block).
column The column number within the plot layout (spatial positioning of the plot within a block).
geometry The spatial data representing the polygon boundaries of each feature POLYGON ((x y, ...)).

shapefile_build() returns a grid layout that by default goes from left to right and top to bottom (layout = "lrtb")

bm + shapefile_view(shp, attribute = "plot_id")

By combining the layout and serpentine arguments, you can generate a total of 16 distinct layouts. The layout argument controls the primary arrangement of items, while the serpentine argument introduces an optional serpentine pattern, which alters the direction of item placement in alternating rows or columns.

The layout argument specifies the orientation of the layout and is a character string. You can choose from the following options:

  • ‘tblr’: Top to Bottom, Left to Right
  • ‘tbrl’: Top to Bottom, Right to Left
  • ‘btlr’: Bottom to Top, Left to Right
  • ‘btrl’: Bottom to Top, Right to Left
  • ‘lrtb’: Left to Right, Top to Bottom
  • ‘lrbt’: Left to Right, Bottom to Top
  • ‘rltb’: Right to Left, Top to Bottom
  • ‘rlbt’: Right to Left, Bottom to Top

The serpentine argument determines whether a serpentine layout is applied. When set to TRUE, items in alternating rows or columns will be placed in reverse order, creating a “zig-zag” pattern. By default, serpentine is set to FALSE, which means the layout follows the specified direction without altering the order in alternating rows or columns. Copy and run the following code to build a Shiny app to demonstrate how these two arguments interact and affect the layout:

library(shiny)
library(bs4Dash)
library(pliman)
library(leaflet)

# Define the UI
ui <- bs4DashPage(
  sidebar = bs4DashSidebar(disable = TRUE), 
  body = bs4DashBody(
    fluidRow(
      column(
        width = 4, # Controls will be in a 3-column layout
        title = "Controls",
        selectInput(
          inputId = "layout",
          label = "Select Layout Orientation:",
          choices = c('tblr', 'tbrl', 'btlr', 'btrl', 'lrtb', 'lrbt', 'rltb', 'rlbt'),
          selected = 'tblr'
        ),
        checkboxInput(
          inputId = "serpentine",
          label = "Apply Serpentine Layout?",
          value = FALSE
        ),
        numericInput(
          inputId = "nrow",
          label = "Number of Rows:",
          value = 5,
          min = 1,
          max = 10
        ),
        numericInput(
          inputId = "ncol",
          label = "Number of Columns:",
          value = 3,
          min = 1,
          max = 10
        ),
        numericInput(
          inputId = "pwidth",
          label = "Plot width (optional):",
          value = NULL,
          min = 0,
          max = Inf
        ),
        numericInput(
          inputId = "pheight",
          label = "Plot height (optional):",
          value = NULL,
          min = 0,
          max = Inf
        )
      ),
      column(
        width = 8, # Map plot will take 9 columns
        title = "Map",
        leafletOutput("map", height = "640px")
      )
    )
  ),
  header = bs4DashNavbar(
    title = dashboardBrand(
      title = "Live demonstration",
      color = "white",
      opacity = 0.8
    ),
    status = "white",
    fixed = TRUE
  )
)


# Define the server logic
server <- function(input, output, session) {
  url <- "https://github.com/TiagoOlivoto/images/raw/refs/heads/master/pliman/ortho/"
  mosaic <-    mosaic_input(paste0(url, "orthosmall.tif"), info = FALSE)
  cpoint <- shapefile_input(paste0(url, "controlpoints.rds"), info = FALSE)
  bm <- mosaic_view(mosaic, r = 1, g = 2, b = 3)
  # Build the shapefile
  map <- reactive({
    if(!is.na(input$pwidth) && !is.na(input$pheight)){
      pwidth <- input$pwidth
      pheight <- input$pheight
    } else{
      pwidth <- NULL
      pheight <- NULL
    }
    shp <- shapefile_build(mosaic = mosaic,
                           controlpoints = cpoint,
                           basemap = bm,
                           nrow = input$nrow,
                           ncol = input$ncol,
                           layout = input$layout,
                           serpentine = input$serpentine,
                           plot_width = pwidth,
                           plot_height = pheight,
                           verbose = FALSE)
    req(shp)
    (bm + shapefile_view(shp, attribute = "plot_id"))@map
  })
  
  output$map <- renderLeaflet({
    map()
  })
}

# Run the application 
shinyApp(ui = ui, server = server)

Exporting

When working with spatial data in R, two common file formats are .shp (shapefiles) and .rds (R serialized files). A shapefile is a standard format in Geographic Information Systems (GIS) for storing spatial vector data (points, lines, polygons) and can be imported in any GIS software, like QGIS. Despite the name, a shapefile is not a single file but a collection of related files:

  • .shp: Contains the geometry (shapes) of the features (points, lines, or polygons).
  • .cpg: Contains the character encoding used to interpret the text data in the .dbf file (defaults to UTF-8).
  • .shx: An index file that speeds up data access.
  • .dbf: Stores the attributes or properties of each feature (like ID, name, etc.).
  • .prj: Defines the Coordinate Reference System (CRS), ensuring correct placement on the Earth’s surface.

The .rds file (suggested to work with pliman) is a format specific to R, used for saving single R objects (including spatial data) in a serialized form. It’s ideal for saving R objects in their native format and later loading them back into R exactly as they were saved.

The function shapefile_export() can be used to export a shapefile created with shapefile_build() or any other SpatVector or sf object.

# export to a .rds file
shapefile_export(shp, "shape_rds.rds")
# export to a .shp file
shapefile_export(shp, "shape_shp.shp")

Importing shapefiles

You can import previously saved shapefiles using the shapefile_input() function. This function supports both .rds files and .shp files, whether they were exported using shapefile_export() or created in other software.

However, since all functions in the pliman package are designed to work with shapefiles generated within pliman, it’s crucial to ensure that specific fields—such as unique_id, block, plot_id, row, and column—are present in the shapefile. If any of these required fields are missing, unexpected errors may occur during processing.

shp <- shapefile_input("shape_rds.rds")

Exploring the mosaic_analyze() function

mosaic_analyze() is the cornerstone function in pliman for high-throughput phenotyping. It enables users to efficiently process orthomosaics and extract a wealth of data from satellite or drone imagery with just a few lines of code. In most cases, all you need is an orthomosaic (or even a .jpg image from a cellphone) and the right function parameters to unlock its full potential.

Case study

In the example below, mosaic_analyze() is used to count, measure, and extract image indices at the block, plot, and individual levels in a lettuce trial. This process is based on an orthomosaic image, as described in this paper.

A big thank you to the authors for providing the full-resolution .tif file, which enabled me to advance several functionalities in pliman, including high-throughput image analysis and data extraction at multiple levels. This kind of data sharing is invaluable for driving further innovation and tool development.

The trial was conducted using a randomized complete block design with four blocks. The researchers tested the effects of Aspergillus niger application (six different levels, combining both concentration and formulation) and three levels of phosphorus (0%, 50%, 100%) on lettuce growth.

Field Experiment
Field Experiment

In the plimans shapefile, each plot within the four blocks is represented by a unique plot_id, such as “P0001,” “P0002,” etc. These correspond to the following treatments:

Plot ID Inoculant Phosphorus (%)
P0001 NI 0
P0002 NI 50
P0003 NI 100
P0004 TS 0
P0005 TS 50
P0006 TS 100
P0007 GR2 0
P0008 GR2 50
P0009 GR2 100
P0010 GR6 0
P0011 GR6 50
P0012 GR6 100
P0013 SC2 0
P0014 SC2 50
P0015 SC2 100
P0016 SC6 0
P0017 SC6 50
P0018 SC6 100

Importing the needed files

The mosaic_input() function is used to load the mosaic of a lettuce field, and the shapefile_input() function is used to load the corresponding shapefile that delineates the plots. You can also create a shapefile with shapefile_build() (as in the previous section) or simply define the nrow and ncol arguments in mosaic_analyze().

In this example, a basemap is created using a mosaic image to serve as the foundation for further visualizations. While creating a basemap is not mandatory, it can significantly speed up the process, as functions like mosaic_analyze(), shapefile_build(), and shapefile_edit() will automatically render a leaflet map if one is not provided. By pre-creating the basemap, you avoid the overhead of rendering multiple maps, making the workflow more efficient.

Additionally, a shapefile layer is overlaid on top of the basemap to display the levels of the inoculante factor.

url <- "https://github.com/TiagoOlivoto/images/raw/refs/heads/master/pliman/lettuce/"
mos <-    mosaic_input(paste0(url, "lettuce.tif"), info = FALSE)
shp <- shapefile_input(paste0(url, "lettuce.rds"), info = FALSE)

# create a basemap
bm <- mosaic_view(mos, r = 1, g = 2, b = 3) # defaults is 1e6.. so here, a bit higher resolution is used
#>  Using `downsample = 2` to match the max_pixels
#> constraint.
bm + shapefile_view(shp, attribute = "p", color_regions = ggplot_color(3))

Analyzing the mosaic

There function mosaic_analyze() is all you need now. The vegetation indexes computed for each plant are defined in the object indexes. Here, the Normalized Green Red Difference Index (NGRDI), Green Leaf Index (GLI), and Blue Green Index (BGI) are used. You can find a list with all build-in vegetation indexes in pliman here.

By setting segment_individuals = TRUE, mosaic_analyze() shifts its focus to the individual plant level. Using a threshold-based segmentation method, it isolates each plant within a plot, enabling precise counting and measurement, provided that a higher contrast between plant and soi. While the function can also handle complex backgrounds with additional arguments, that’s not the focus here. Instead, the power of this approach lies in its ability to break down each plot into individual components, providing a detailed analysis of plant morphology, size, and distribution. This transforms high-throughput phenotyping by moving from a broad plot-level perspective to an in-depth examination of each plant, unlocking a new level of precision and insight.

For context, in the original study, the researchers manually measured the diameter of the four central plants in each plot. With mosaic_analyze(), this process is not only automated but also expanded to include every plant in the plot, providing more comprehensive data in a fraction of the time.

Using segment_index = "GLI", we configure the analysis to segment soil and identify individual plants based on the GLI index. The analysis will return both summary statistics for each plot and a map showing the segmented individual plants.

indexes <- c("NGRDI", "GLI")
an <- mosaic_analyze(
  mosaic = mos,
  basemap = bm,
  r = 1,
  g = 2,
  b = 3,
  shapefile = shp,
  plot_index = indexes,
  segment_individuals = TRUE,
  segment_index = "GLI"
)
#> ── Analyzing the mosaic ──────────────────── Started on 2025-08-02 | 19:27:32 ──
#>  Cropping the mosaic to the shapefile extent...
#> Warning: ! ``segment_plot`` must have length 1 or 4 (the
#> number of drawn polygons).
#> Warning: ! ``segment_individuals`` must have length 1 or 4 (the number of drawn
#>   polygons).
#> Warning: ! ``threshold`` must have length 1 or 4 (the
#> number of drawn polygons).
#> Warning: ! ``watershed`` must have length 1 or 4 (the
#> number of drawn polygons).
#> Warning: ! ``segment_index`` must have length 1 or 4
#> (the number of drawn polygons).
#> Warning: ! ``invert`` must have length 1 or 4 (the
#> number of drawn polygons).
#> Warning: ! ``includeopt`` must have length 1 or 4 (the
#> number of drawn polygons).
#> Warning: ! ``opening`` must have length 1 or 4 (the
#> number of drawn polygons).
#> Warning: ! ``closing`` must have length 1 or 4 (the
#> number of drawn polygons).
#> Warning: ! ``filter`` must have length 1 or 4 (the
#> number of drawn polygons).
#> Warning: ! ``erode`` must have length 1 or 4 (the number
#> of drawn polygons).
#> Warning: ! ``dilate`` must have length 1 or 4 (the
#> number of drawn polygons).
#> Warning: ! ``grid`` must have length 1 or 4 (the number
#> of drawn polygons).
#> Warning: ! ``lower_noise`` must have length 1 or 4 (the
#> number of drawn polygons).
#>  Cropping the mosaic to the shapefile extent [1.3s]
#>  Computing vegetation indexes... Computing vegetation indexes [1.1s]
#> 
#> ── Analyzing block 1 ──
#> 
#>  Segmenting individuals within plots... Segmenting individuals within plots [1.7s]
#>  Extracting features from segmented individuals... Extracting features from segmented individuals [172ms]
#>  Extracting plot-level features... Extracting plot-level features [216ms]
#>  Binding the extracted features...                                    ── Analyzing block 2 ──
#>  Binding the extracted features...                                    
#>  Binding the extracted features... Binding the extracted features [81ms]
#>  Segmenting individuals within plots... Segmenting individuals within plots [1.3s]
#>  Extracting features from segmented individuals... Extracting features from segmented individuals [128ms]
#>  Extracting plot-level features... Extracting plot-level features [72ms]
#>  Binding the extracted features...                                    ── Analyzing block 3 ──
#>  Binding the extracted features...                                    
#>  Binding the extracted features... Binding the extracted features [74ms]
#>  Segmenting individuals within plots... Segmenting individuals within plots [1.4s]
#>  Extracting features from segmented individuals... Extracting features from segmented individuals [143ms]
#>  Extracting plot-level features... Extracting plot-level features [80ms]
#>  Binding the extracted features...                                    ── Analyzing block 4 ──
#>  Binding the extracted features...                                    
#>  Binding the extracted features... Binding the extracted features [80ms]
#>  Segmenting individuals within plots... Segmenting individuals within plots [1.5s]
#>  Extracting features from segmented individuals... Extracting features from segmented individuals [167ms]
#>  Extracting plot-level features... Extracting plot-level features [93ms]
#>  Binding the extracted features... Binding the extracted features [23ms]
#>  Summarizing the results...                             ── Mosaic successfully analyzed ─────────── Finished on 2025-08-02 | 19:27:42 ──
#>  Summarizing the results... Summarizing the results [846ms]

Below, you can see the results at the individual plant level. Each plant within a plot is identified, segmented, and color-coded based on its measured characteristics (e.g., mean vegetation indices). While you can use the attribute argument in mosaic_analyze() to control these visualizations, there’s no need to worry—new plots can easily be generated after the results are computed, giving you full flexibility in how the data is displayed.

For each plot, detailed summary statistics are also returned, allowing for in-depth analysis of plant performance across the entire experiment.

an$map_indiv

We can gain deeper insights by utilizing the results generated from mosaic_analyze(). Below, the data is grouped by the different levels of the inoculante factor to explore how it influences the analysis.

# see the results averaged by the combination of inoculante and p factors
library(dplyr)
dfino <- 
  an$result_plot_summ |> 
  group_by(plot_id, inoculante, p) |> 
  summarise(across(where(is.numeric), mean))
#> `summarise()` has grouped output by 'plot_id', 'inoculante'. You can override
#> using the `.groups` argument.

# inoculante levels
bm + shapefile_view(dfino, attribute = "inoculante", color_regions = ggplot_color(6))

# phospurus level
bm + shapefile_view(dfino, attribute = "p", color_regions = ggplot_color(3))

Vegetation indexes and canopy coverage

This example demonstrates how to use an orthomosaic from a soybean trial to calculate vegetation indexes, specifically the Green Leaf Index (GLI) and the Normalized Green-Red Difference Index (NGRDI). The analysis not only computes these indexes but also segments the plots based on the GLI index, allowing for the remotion of soil effects. Once soil is removed, the canopy coverage within each plot can be computed by the ration between the area covered by plants and the plot area. Here, it is visualized in an interactive map.

The following steps guide you through loading the orthomosaic and corresponding shapefile, calculating vegetation indexes, and visualizing the segmented canopy coverage.


# Define the base URL for the example dataset
url <- "https://github.com/TiagoOlivoto/images/raw/refs/heads/master/pliman/ortho/"

# Load the orthomosaic and shapefile
mosaic <- mosaic_input(paste0(url, "orthomosaic.tif"), info = FALSE)
shp <- shapefile_input(paste0(url, "orthomosaic.rds"), info = FALSE)

# Visualize the mosaic and overlay the shapefile for plot boundaries
bm <- mosaic_view(mosaic, r = 1, g = 2, b = 3) # RGB visualization of the mosaic
#>  Using `downsample = 4` to match the max_pixels
#> constraint.
bm + shapefile_view(shp)                       # Overlay the shapefile on the mosaic

# Compute the Green Leaf Index (GLI) for the mosaic to highlight vegetation
ind <- mosaic_index(mosaic, index = "GLI", r = 1, g = 2, b = 3)


# Segment the mosaic based on the GLI index to distinguish soil and plants
seg <- mosaic_segment(
  mosaic = mosaic,
  index = "GLI",  # Use GLI for segmentation
  r = 1,          # Red channel
  g = 2,          # Green channel
  b = 3           # Blue channel
)

# Plot the segmented mosaic, showing the separation of plants and soil
mosaic_plot(seg)


# Perform further analysis using mosaic_analyze
# - This calculates both GLI and NGRDI for each plot
# - Segments the plots based on the GLI index
# - Calculates canopy coverage within each plot
res <- mosaic_analyze(
  mosaic = mosaic,                 # The orthomosaic image
  shapefile = shp,                 # The shapefile with plot boundaries
  basemap = bm,                    # Basemap (not mandatory)
  plot_index = c("GLI", "NGRDI"),  # Vegetation indexes to calculate
  segment_plot = TRUE,             # Segment plots using the index
  segment_index = "GLI",           # GLI is used for segmentation
  attribute = "coverage"           # Calculate canopy coverage
)
#> ── Analyzing the mosaic ──────────────────── Started on 2025-08-02 | 19:28:11 ──
#>  Cropping the mosaic to the shapefile extent... Cropping the mosaic to the shapefile extent [2s]
#>  Computing vegetation indexes... Computing vegetation indexes [3s]
#> 
#> ── Analyzing block 1 ──
#> 
#>  Masking vegetation from ground... Vegetation masking completed [4.7s]
#>  Extracting plot-level features... Extracting plot-level features [1.7s]
#>  Binding the extracted features... Binding the extracted features [30ms]
#>  Summarizing the results...                             ── Mosaic successfully analyzed ─────────── Finished on 2025-08-02 | 19:28:26 ──
#>  Summarizing the results... Summarizing the results [2.7s]

# Display the interactive map showing the plot segmentation
# Color shows the canopy coverage
res$map_plot

# create a plot to shows the NGRDI index
# bm + shapefile_view(res$result_plot, attribute = "mean.NGRDI")

Multispectral Indexes

In this example, we use a multispectral orthomosaic to compute important vegetation indexes such as the Normalized Difference Vegetation Index (NDVI), Enhanced Vegetation Index (EVI), and Normalized Difference Red Edge Index (NDRE). You can see a complete list of multispectral indexes available in pliman for more details. These indexes are valuable for assessing plant health, canopy coverage, and other agronomic insights. You can download the orthomosaic and shapefile to reproduce this analysis by following the steps below.

The process involves loading the multispectral mosaic and shapefile, calculating the vegetation indexes, and segmenting the plots based on the NDVI index. Additionally, an interactive map will display the segmented plots and canopy coverage metrics.


# Define the base URL for the example dataset
url <- "https://github.com/TiagoOlivoto/images/raw/refs/heads/master/pliman/wheat/"

# Load the orthomosaic and shapefile
mosaic <- mosaic_input(paste0(url, "wheat.tif"), info = FALSE)
shp <- shapefile_input(paste0(url, "wheat.rds"), info = FALSE)
# Generate a basemap
bm <- mosaic_view(mosaic, r = 3, g = 2, b = 1)
#>  Using `downsample = 2` to match the max_pixels
#> constraint.

# - Computes three vegetation indexes: NDVI, EVI, and NDRE
ind <- mosaic_index(
  mosaic = mosaic,
  index = c("NDVI", "TVI", "NDRE"),
  b = 1,
  g = 2,
  r = 3,
  re = 4,
  nir = 5,
  plot = FALSE
)
#> ── Computing rasters for 3 indices ──────────────────── Started at "19:28:37" ──
#> ■■■■■■■■■■■                      1/3 | ETA:  3s■■■■■■■■■■■■■■■■■■■■■            2/3 | ETA:  1s■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■  3/3 | ETA:  0s
#> ── 3 vegetation indices computed ─────────── Ended at "2025-08-02 | 19:28:41" ──
# NDVI
mosaic_plot(ind[[1]])
shapefile_plot(shp, lwd = 3, add = TRUE)


# EVI and NDRE
mosaic_plot(c(ind[[2]], ind[[3]]))





# - Here, I declared the orthomosaic and the bult indexes
# - The basemap will be needed if `plot = TRUE` (default). If not provided, it will be rendered
# - Declare summary statistics
res <- mosaic_analyze(
  mosaic = mosaic,                          # The orthomosaic image
  indexes = ind,                            # we can also declare a raster with computed indexes
  basemap = bm,                             # Use the visualized mosaic as the base map
  shapefile = shp,                          # The shapefile with plot boundaries
  summarize_fun = c("min", "median", "max"), # Calculate min, mean, max for the indexes
  attribute = "median.NDVI"
)
#> ── Analyzing the mosaic ──────────────────── Started on 2025-08-02 | 19:28:44 ──
#>  Cropping the mosaic to the shapefile extent...
#> Warning: ! ``segment_plot`` must have length 1 or 2 (the
#> number of drawn polygons).
#> Warning: ! ``segment_individuals`` must have length 1 or 2 (the number of drawn
#>   polygons).
#> Warning: ! ``threshold`` must have length 1 or 2 (the
#> number of drawn polygons).
#> Warning: ! ``watershed`` must have length 1 or 2 (the
#> number of drawn polygons).
#> Warning: ! ``segment_index`` must have length 1 or 2
#> (the number of drawn polygons).
#> Warning: ! ``invert`` must have length 1 or 2 (the
#> number of drawn polygons).
#> Warning: ! ``includeopt`` must have length 1 or 2 (the
#> number of drawn polygons).
#> Warning: ! ``opening`` must have length 1 or 2 (the
#> number of drawn polygons).
#> Warning: ! ``closing`` must have length 1 or 2 (the
#> number of drawn polygons).
#> Warning: ! ``filter`` must have length 1 or 2 (the
#> number of drawn polygons).
#> Warning: ! ``erode`` must have length 1 or 2 (the number
#> of drawn polygons).
#> Warning: ! ``dilate`` must have length 1 or 2 (the
#> number of drawn polygons).
#> Warning: ! ``grid`` must have length 1 or 2 (the number
#> of drawn polygons).
#> Warning: ! ``lower_noise`` must have length 1 or 2 (the
#> number of drawn polygons).
#>  Cropping the mosaic to the shapefile extent [1.4s]
#> 
#> ── Analyzing block 1 ──
#> 
#>  Extracting plot-level features... Extracting plot-level features [241ms]
#>  Binding the extracted features...                                    ── Analyzing block 2 ──
#>  Binding the extracted features...                                    
#>  Binding the extracted features... Binding the extracted features [194ms]
#>  Extracting plot-level features... Extracting plot-level features [136ms]
#>  Binding the extracted features... Binding the extracted features [44ms]
#>  Summarizing the results...                             ── Mosaic successfully analyzed ─────────── Finished on 2025-08-02 | 19:28:47 ──
#>  Summarizing the results... Summarizing the results [217ms]

# Display the interactive map showing the segmented plots
res$map_plot

Canopy Height Models

A Canopy Height Model (CHM) represents the height of vegetation or structures above the ground surface, making it a crucial tool for analyzing vegetation structure and biomass. It is derived by subtracting a Digital Terrain Model (DTM), which shows the bare earth surface, from a Digital Surface Model (DSM), which captures the elevation of all surface objects,like plants. By comparing these two models, the CHM provides detailed insights into the height of vegetation, enabling accurate assessments of canopy cover and plant growth in agricultural or forested landscapes.

DSM and DTM are available

# Load DSM, DTM, mask and shapefile
url <- "https://github.com/TiagoOlivoto/images/raw/refs/heads/master/pliman/dsm/"
dsm <- mosaic_input(paste0(paste0(url, "dsm.tif")), info = FALSE)
dtm <- mosaic_input(paste0(paste0(url, "dtm.tif")), info = FALSE)
msk <- mosaic_input(paste0(paste0(url, "mask.tif")), info = FALSE)
shp <- shapefile_input(paste0(paste0(url, "shapefile.rds")), info = FALSE)

# Visualize the DSM and DTM side by side. 
# The argument nc = 1 means that the plots will be displayed in a single column.
mosaic_plot(c(dsm, dtm))


# Compute the Canopy Height Model (CHM) by subtracting the DTM from the DSM.
# The `mask` parameter specifies the regions to be used, and `mask_soil = FALSE` 
# means that areas identified by the mask are considered non-soil (i.e., representing the plants).
res <- mosaic_chm(dsm = dsm,
                  dtm = dtm,
                  mask = msk,
                  mask_soil = FALSE)  
#> ── Canopy Height-Model generation ─────────────────── "2025-08-02 | 19:28:55" ──
#>  Building the canopy height model... Building the canopy height model [2.1s]

# Extract canopy height values from the CHM using the provided shapefile.
# This will associate the height values with the polygons in the shapefile.
chmvals <- mosaic_chm_extract(res, shp)

# Visualize the DSM with a custom color palette to represent different elevation levels.
pal <- custom_palette(c("#8B4513", "#B2DF8A", "forestgreen"), n = 10)
bm <- mosaic_view(dsm, color_regions = pal)
#>  Using `downsample = 2` to match the max_pixels constraint.
#> Number of pixels is above 1e+06.Only about 1e+06 pixels will be shown.
#> You can increase the value of `maxpixels` to 1000980 to avoid this.

# Overlay the shapefile on top of the DSM visualization, using the "coverage" attribute 
# from the shapefile to define the regions of interest.
bm + shapefile_view(chmvals, attribute = "coverage")

Building DTM from DSM

In field experiments, the Digital Terrain Model (DTM) is frequently obtained before sowing and represents the bare soil. But, if we could derivate DTM from DSM? If a DTM is not provided, mosaic_chm() will derive DTM from DSM using an interpolation strategy.


# Interpolate DTM using a moving window
res2 <- mosaic_chm(
  dsm,
  mask = msk,
  window_size = c(4, 4),
  mask_soil = FALSE
)
#> ── Canopy Height-Model generation ─────────────────── "2025-08-02 | 19:29:07" ──
#>  Extracting ground points for each moving window... Extracting ground points for each moving window [1.6s]
#>  Interpolating ground points... Interpolating ground points [3.1s]
#>  Resampling and masking the interpolated raster... Resampling and masking the interpolated raster [1.5s]
#>  Building the canopy height model... Building the canopy height model [1.4s]

# Extract CHM values
chmvals2 <- mosaic_chm_extract(res2, shp) 

# Quantile 95
bm + shapefile_view(chmvals2, attribute = "q95")

# Entropy
# bm + shapefile_view(chmvals2, attribute = "entropy")

Counting and measuring distance between plants

In this example, we use an RGB orthomosaic from a potato field to analyze and segment individual plants within the plots. The analysis involves loading the mosaic and corresponding shapefile, cropping the mosaic to the area defined by the shapefile, and then segmenting individual plants using a custom vegetation index. When map_individuals = TRUE is used, important metrics such as the average distance between plants and the coefficient of variation for each cropping row are also computed.

# Download and load orthomosaic and shapefile
url <- "https://github.com/TiagoOlivoto/images/raw/refs/heads/master/pliman/potato/"
mos <-    mosaic_input(paste0(url, "potato.tif"))
shp <- shapefile_input(paste0(url, "potato.rds"))

bm <- mosaic_view(mos)

res <-
  mosaic_analyze(
    mosaic = mos,
    basemap = bm,
    shapefile = shp,
    plot_index = "GLI",
    segment_individuals = TRUE,
    map_individuals = TRUE,
    map_direction = "horizontal",    # default
    attribute = "cv"
  )

pal <- c( "#fde725", "#5ec962", "#21918c", "#3b528b", "#440154")
p1 <- shapefile_view(res$result_plot_summ, attribute = "cv", color_regions = pal)
p2 <- shapefile_view(res$result_indiv, type = "centroid", attribute = "area")

(bm + p1) | p2

The interactive map above shows the segmented potato plants within each row. Note that some plots were not rendered due to the absence of identified plants. It is important to highlight the structure of the res object:

names(res)

When map_individuals = TRUE is used, the result_individ_map object contains the distances between each plant within the plots. By default, the mapping occurs in the horizontal direction.

res[["result_individ_map"]][["distances"]][["B01_P0001"]]

The objects means and cvs hold the average distances and coefficients of variation, respectively.

library(patchwork)
pmean <-
  ggplot(res$result_plot_summ, aes(x = mean_distance)) +
  geom_histogram() +
  labs(x = "Average distance between plants",
       y = "Number of plots")

pcv <-
  ggplot(res$result_plot_summ, aes(x = cv)) +
  geom_histogram(bins = 10) +
  labs(x = "Coefficient of variation (%)",
       y = "Number of plots")

pmean + pcv

Below, we’ll explore two contrasting plots to demonstrate how this information can be valuable for assessing plot uniformity.


library(dplyr)
par(mfrow = c(2, 1))
p1 <- 
  res$result_indiv |> 
  filter(plot_id == "P0184")

# plot 
p1plot <- 
  res$result_plot_summ |> 
  filter(plot_id == "P0184")

plot1 <- mosaic_crop(mos, shapefile = p1plot, buffer = 0.2)
coords <- p1[, c("x", "y")] |> sf::st_drop_geometry() |> arrange(x)
mosaic_plot_rgb(plot1, main = "P0184: Average distance: 0.243 m; CV: 14.1%")
lines(coords, lwd = 2)
shapefile_plot(p1plot, add = TRUE, border = "blue", lwd = 3)
points(p1$x, p1$y, pch = 16,  cex = 2, col = "red")




p2 <- 
  res$result_indiv |> 
  filter(plot_id == "P0204")
p2plot <- 
  res$result_plot_summ |> 
  filter(plot_id == "P0204")

plot2 <- mosaic_crop(mos, shapefile = p2plot, buffer = 0.2)
coords2 <- p2[, c("x", "y")] |> sf::st_drop_geometry() |> arrange(x)
mosaic_plot_rgb(plot2, main = "P0204: Average distance: 0.325 m; CV: 64.0%")
lines(coords2, lwd = 2)
shapefile_plot(p2plot, add = TRUE, border = "blue", lwd = 3)
points(p2$x, p2$y, pch = 16,  cex = 2, col = "red")

Handling complex backgrounds

Challenges

Threshold-based methods often struggle with complex backgrounds typical of field experiments, where diverse soil types, colors, and textures introduce considerable variability. This variability reduces the contrast between plants and soil, complicating the computation of vegetation indexes in orthomosaics. When soil and plant pixels have similar reflectance values, thresholding fails to separate them accurately, leading to erroneous plant segmentation, which in turn affects vegetation index calculations, plant measurements, and analytical outputs.

For instance, in this section, we use an orthomosaic from a field with complex soil backgrounds, generously provided by Lucas Côrredo-UFV, to illustrate the limitations of threshold-based segmentation and how they can be overcome using simple alternatives that are not based on complex machine learning-based methods.


url <- "https://github.com/TiagoOlivoto/images/raw/refs/heads/master/pliman/citrus/"
mosaic <-    mosaic_input(paste0(url, "citrus_mos.tif"), info = FALSE)
shp <- shapefile_input(paste0(url, "citrus_shp.rds"), info = FALSE)
bm <- mosaic_view(mosaic, r = 1, g = 2, b = 3, max_pixels = 5e6)
#>  The number of pixels is very high, which might slow the rendering process.
#>  Using `downsample = 3` to match the max_pixels constraint.
bm

If you take a closer look at the orthomosaic, you’ll notice that the soil background is complex, with varying colors and textures that make it challenging to separate from the plants. This complexity poses a significant challenge for threshold-based segmentation methods, which rely on clear distinctions between plants and soil to accurately identify and measure vegetation indexes. Early in this material we’ve seen that the GLI index is a good option to segment plants from soil. However, in this case, segmentation using threshold-based methods may not be effective due to the complex background.

seg <- mosaic_segment(mosaic, index = "GLI", r = 1, g = 2, b = 3)
mosaic_plot(seg)

The segmentation results indicate that the threshold-based method struggles to distinguish plants from soil, resulting in inaccurate plant identification and measurements. This challenge highlights the need for alternative methods capable of handling complex backgrounds more effectively.

When vegetation indexes are insufficient

Threshold-based methods often fail to distinguish plants from soil, making it necessary to explore strategies beyond vegetation indexes. A more robust approach involves using machine learning, particularly deep learning models, to enhance plant segmentation and analysis in orthomosaics. These models can identify intricate patterns and features, offering superior differentiation between plants and soil compared to traditional methods. However, such models have not yet been integrated into pliman 3.0. So, how can we enhance segmentation with simpler approaches? The solution lies in leveraging a 3D perspective with a Canopy Height Model (CHM).

Do we really need AI-based models?

To improve plant segmentation, incorporating a digital surface model (DSM) can be highly effective, as it provides valuable height information for objects within the orthomosaic. The mosaic_chm_mask() function allows generating a mask from a Canopy Height Model (CHM) derived using the mosaic_chm() function. This mask enables segmentation based on plant height instead of relying solely on color, which can more precisely distinguish plants from soil, particularly in complex backgrounds. This method offers a straightforward, non-AI solution for overcoming segmentation challenges, delivering enhanced plant isolation without the need for machine learning algorithms, mainly for examples like this, where the orange trees are relatively tall, facilitating the separation between plants and soil.

dsm <- mosaic_input(paste0(url, "citrus_dsm.tif"), info = FALSE)
# create a mask, retaining only pixels between 1 and 5 meters

mask <- mosaic_chm_mask(
  dsm = dsm,
  window_size = c(3, 3),  # Window size for interpolating the DTM
  lower = 0.5,            # Lower limit for the mask (height)
  upper = 3               # Upper limit for the mask (height)
)

mosaic_plot(mask)

Great advance! Now, we have a mask that retains only pixels between 0.5 and 3 meters in height, effectively isolating the plants from the soil. This mask can be now used in mosaic_analyze() to segment the plants and calculate vegetation indexes more accurately.

# Analyze the segmented mosaic data
mos <- mosaic_analyze(
  mosaic = mosaic,                # The input mosaic
  basemap = bm,                   # The basemap for visualization.
  r = 1, g = 2, b = 3,            # Specify the red, green, and blue channels
  mask = mask,                    # Apply the mask created previously
  # dsm = dsm,                    # or directly use the DSM
  # dsm_lower = 0.5,              # or directly use the DSM
  # dsm_upper = 3,                # or directly use the DSM
  #dsm_window_size = c(3, 3)      # or directly use the DSM
  shapefile = shp,                # Shapefile to outline regions of interest
  segment_individuals = TRUE,     # Enable segmentation
  plot_index = c("NGRDI", "GLI"), # Vegetation indexes to compute for each plant
  lower_noise = 0,                # Avoid removing small plants
  filter = 10,                    # Size of the filter applied to reduce noise
  watershed = FALSE               # Disable watershed algorithm
)
#> ── Analyzing the mosaic ──────────────────── Started on 2025-08-02 | 19:30:09 ──
#>  Cropping the mosaic to the shapefile extent... Cropping the mosaic to the shapefile extent [32ms]
#>  Computing vegetation indexes... Computing vegetation indexes [11.4s]
#> 
#> ── Analyzing block 1 ──
#> 
#>  Segmenting individuals... Segmenting individuals [19.3s]
#>  Extracting plant-level features... Extracting plant-level features [2.3s]
#>  Extracting plot-level features... Extracting plot-level features [1.9s]
#>  Binding the extracted features... Binding the extracted features [17ms]
#>  Summarizing the results...                             ── Mosaic successfully analyzed ─────────── Finished on 2025-08-02 | 19:30:47 ──
#>  Summarizing the results... Summarizing the results [2s]

# Summary
mos$result_plot_summ
#> Simple feature collection with 1 feature and 18 fields
#> Geometry type: POLYGON
#> Dimension:     XY
#> Bounding box:  xmin: 723911.2 ymin: 7702727 xmax: 723974.3 ymax: 7702835
#> Projected CRS: WGS 84 / UTM zone 23S
#> # A tibble: 1 × 19
#>   block plot_id   row column     n area_sum  area coverage perimeter length
#>   <chr> <chr>   <dbl>  <dbl> <dbl>    <dbl> <dbl>    <dbl>     <dbl>  <dbl>
#> 1 B01   P0001       1      1   223    4120.  18.5     1.46      5.47   1.43
#> # ℹ 9 more variables: width <dbl>, diam_min <dbl>, diam_mean <dbl>,
#> #   diam_max <dbl>, mean.NGRDI <dbl>, mean.GLI <dbl>, individual <chr>,
#> #   plot_area [m^2], geometry <POLYGON [m]>

The identified plants are displayed on the map, color-coded by their mean NGRDI (Normalized Green-Red Difference Index) values. Thias visualization highlights plant distribution and relative health across the field, making it easier to identify variations in vegetation index values and assess plant vigor.

bm +
  shapefile_view(mos$result_indiv[-1, ],
                 attribute = "diam_mean",
                 alpha.regions = 0.6)

The results show that the CHM-based segmentation method effectively isolates plants from the soil, providing accurate plant identification and measurements. This approach offers a simple yet powerful alternative to threshold-based methods, demonstrating the value of leveraging 3D information to enhance plant segmentation in orthomosaics.

Below, we explore the individual plant data to gain deeper insights into plant health across the field.

coords <- shapefile_measures(mos$result_indiv[-1, ])
model <- shapefile_interpolate(coords, z = "mean.NGRDI")
mosaic_plot_rgb(mosaic)

Vectorizing field masks

In this section, we use another orthomosaic from citrus field provided by Lucas Côrredo-UFV. This dataset includes both RGB and multispectral indices, as well as a mask derived from the digital surface model, enhancing our ability to differentiate features in the field.

url <- "https://github.com/TiagoOlivoto/images/raw/refs/heads/master/pliman/multispec/"
rgb <- mosaic_input(paste0(url, "citrus_rgb.tif"), info = FALSE)
mes <- mosaic_input(paste0(url, "citrus_ms.tif"), info = FALSE)
mask <- mosaic_input(paste0(url, "citrus_mask.tif"), info = FALSE)

mosaic_plot_rgb(rgb)

In this section, we take a different approach from the previous example, using the mask file to create an sf object that allows vegetation index extraction at the individual level. Specifically, we will vectorize the raster mask using the mosaic_vectorize() function.

The mosaic_vectorize() function converts a raster mask (SpatRaster object) into a vectorized sf object, with customizable options for morphological operations and filtering to enhance object detection and segmentation. The function includes an option for watershed segmentation, which, if enabled, applies watershed-based segmentation to distinguish touching objects, like the needed in this case.

Morphological operations (opening, closing, filter, erode, dilate) allow further customization, helping to refine the mask by removing noise, filling small gaps, or smoothing object edges. The lower_size and upper_size arguments allow size-based filtering, while topn_lower and topn_upper select objects based on their area.

Through these settings, mosaic_vectorize() enables vectorization tailored to individual vegetation elements, optimizing extraction of vegetation indices at the plant level. Let’s vectorize the binary mask using filter = 10 to remove some noises and erode = 10 to erode 10 pixels from the edges of each object to avoid edge effects

bm <- mosaic_view(rgb, r = 1, g = 2, b = 3)
#>  Using `downsample = 3` to match the max_pixels
#> constraint.

# Vectorize the shapefile
shp <- mosaic_vectorize(mask, 
                        filter = 10,
                        erode = 15)
bm + shapefile_view(shp)

With the shapefile representing individual plants, we can now extract vegetation indices at the plant level. Instead of applying mosaic_analyze(), we’ll use mosaic_index() to compute specific multispectral indices.

vind <-
  mosaic_index(mes,
               index = c("RDVI", "NDVI"),
               b = 1,
               r = 2,
               nir = 3,
               re = 4,
               g = NA)
#> ── Computing rasters for 2 indices ──────────────────── Started at "19:31:08" ──
#> ■■■■■■■■■■■■■■■■                 1/2 | ETA:  1s■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■  2/2 | ETA:  0s
#> ── 2 vegetation indices computed ─────────── Ended at "2025-08-02 | 19:31:11" ──


results <- mosaic_extract(vind, shp)
#>   |                                                                              |                                                                      |   0%  |                                                                              |                                                                      |   1%  |                                                                              |=                                                                     |   1%  |                                                                              |=                                                                     |   2%  |                                                                              |==                                                                    |   3%  |                                                                              |===                                                                   |   4%  |                                                                              |===                                                                   |   5%  |                                                                              |====                                                                  |   5%  |                                                                              |====                                                                  |   6%  |                                                                              |=====                                                                 |   7%  |                                                                              |======                                                                |   8%  |                                                                              |======                                                                |   9%  |                                                                              |=======                                                               |  10%  |                                                                              |=======                                                               |  11%  |                                                                              |========                                                              |  11%  |                                                                              |========                                                              |  12%  |                                                                              |=========                                                             |  12%  |                                                                              |=========                                                             |  13%  |                                                                              |==========                                                            |  14%  |                                                                              |==========                                                            |  15%  |                                                                              |===========                                                           |  15%  |                                                                              |===========                                                           |  16%  |                                                                              |============                                                          |  16%  |                                                                              |============                                                          |  17%  |                                                                              |============                                                          |  18%  |                                                                              |=============                                                         |  18%  |                                                                              |=============                                                         |  19%  |                                                                              |==============                                                        |  20%  |                                                                              |===============                                                       |  21%  |                                                                              |===============                                                       |  22%  |                                                                              |================                                                      |  22%  |                                                                              |================                                                      |  23%  |                                                                              |=================                                                     |  24%  |                                                                              |==================                                                    |  25%  |                                                                              |==================                                                    |  26%  |                                                                              |===================                                                   |  27%  |                                                                              |===================                                                   |  28%  |                                                                              |====================                                                  |  28%  |                                                                              |====================                                                  |  29%  |                                                                              |=====================                                                 |  30%  |                                                                              |======================                                                |  31%  |                                                                              |======================                                                |  32%  |                                                                              |=======================                                               |  32%  |                                                                              |=======================                                               |  33%  |                                                                              |=======================                                               |  34%  |                                                                              |========================                                              |  34%  |                                                                              |========================                                              |  35%  |                                                                              |=========================                                             |  35%  |                                                                              |=========================                                             |  36%  |                                                                              |==========================                                            |  37%  |                                                                              |==========================                                            |  38%  |                                                                              |===========================                                           |  38%  |                                                                              |===========================                                           |  39%  |                                                                              |============================                                          |  39%  |                                                                              |============================                                          |  40%  |                                                                              |=============================                                         |  41%  |                                                                              |=============================                                         |  42%  |                                                                              |==============================                                        |  43%  |                                                                              |===============================                                       |  44%  |                                                                              |===============================                                       |  45%  |                                                                              |================================                                      |  45%  |                                                                              |================================                                      |  46%  |                                                                              |=================================                                     |  47%  |                                                                              |==================================                                    |  48%  |                                                                              |==================================                                    |  49%  |                                                                              |===================================                                   |  49%  |                                                                              |===================================                                   |  50%  |                                                                              |===================================                                   |  51%  |                                                                              |====================================                                  |  51%  |                                                                              |====================================                                  |  52%  |                                                                              |=====================================                                 |  53%  |                                                                              |======================================                                |  54%  |                                                                              |======================================                                |  55%  |                                                                              |=======================================                               |  55%  |                                                                              |=======================================                               |  56%  |                                                                              |========================================                              |  57%  |                                                                              |=========================================                             |  58%  |                                                                              |=========================================                             |  59%  |                                                                              |==========================================                            |  60%  |                                                                              |==========================================                            |  61%  |                                                                              |===========================================                           |  61%  |                                                                              |===========================================                           |  62%  |                                                                              |============================================                          |  62%  |                                                                              |============================================                          |  63%  |                                                                              |=============================================                         |  64%  |                                                                              |=============================================                         |  65%  |                                                                              |==============================================                        |  65%  |                                                                              |==============================================                        |  66%  |                                                                              |===============================================                       |  66%  |                                                                              |===============================================                       |  67%  |                                                                              |===============================================                       |  68%  |                                                                              |================================================                      |  68%  |                                                                              |================================================                      |  69%  |                                                                              |=================================================                     |  70%  |                                                                              |==================================================                    |  71%  |                                                                              |==================================================                    |  72%  |                                                                              |===================================================                   |  72%  |                                                                              |===================================================                   |  73%  |                                                                              |====================================================                  |  74%  |                                                                              |====================================================                  |  75%  |                                                                              |=====================================================                 |  76%  |                                                                              |======================================================                |  77%  |                                                                              |======================================================                |  78%  |                                                                              |=======================================================               |  78%  |                                                                              |=======================================================               |  79%  |                                                                              |========================================================              |  80%  |                                                                              |=========================================================             |  81%  |                                                                              |=========================================================             |  82%  |                                                                              |==========================================================            |  82%  |                                                                              |==========================================================            |  83%  |                                                                              |==========================================================            |  84%  |                                                                              |===========================================================           |  84%  |                                                                              |===========================================================           |  85%  |                                                                              |============================================================          |  85%  |                                                                              |============================================================          |  86%  |                                                                              |=============================================================         |  87%  |                                                                              |=============================================================         |  88%  |                                                                              |==============================================================        |  88%  |                                                                              |==============================================================        |  89%  |                                                                              |===============================================================       |  89%  |                                                                              |===============================================================       |  90%  |                                                                              |================================================================      |  91%  |                                                                              |================================================================      |  92%  |                                                                              |=================================================================     |  93%  |                                                                              |==================================================================    |  94%  |                                                                              |==================================================================    |  95%  |                                                                              |===================================================================   |  95%  |                                                                              |===================================================================   |  96%  |                                                                              |====================================================================  |  97%  |                                                                              |===================================================================== |  98%  |                                                                              |===================================================================== |  99%  |                                                                              |======================================================================|  99%  |                                                                              |======================================================================| 100%

# overlay the shapefile to the basemap
bm + shapefile_view(
  results,
  attribute = "median.NDVI"
)