Skip to main content
NSF NEON, Operated by Battelle

Main navigation

  • About
    • NEON Overview
      • Vision and Management
      • Spatial and Temporal Design
      • History
    • About the NEON Biorepository
      • ASU NEON Biorepository Staff
      • Contact the NEON Biorepository
    • Observatory Blog
    • Newsletters
    • Staff
    • FAQ
    • User Accounts
    • Contact Us

    About

  • Data
    • Data Portal
      • Data Availability Charts
      • API & GraphQL
      • Prototype Data
      • Externally Hosted Data
    • Data Collection Methods
      • Airborne Observation Platform (AOP)
      • Instrument System (IS)
        • Instrumented Collection Types
        • Aquatic Instrument System (AIS)
        • Terrestrial Instrument System (TIS)
      • Observational System (OS)
        • Observation Types
        • Observational Sampling Design
        • Sampling Schedules
        • Taxonomic Lists Used by Field Staff
        • Optimizing the Observational Sampling Designs
      • Protocols & Standardized Methods
    • Getting Started with NEON Data
      • neonUtilities for R and Python
      • Learning Hub
      • Code Hub
    • Using Data
      • Data Formats and Conventions
      • Released, Provisional, and Revised Data
      • Data Product Bundles
      • Usage Policies
      • Acknowledging and Citing NEON
      • Publishing Research Outputs
    • Data Notifications
    • NEON Data Management
      • Data Availability
      • Data Processing
      • Data Quality

    Data

  • Samples & Specimens
    • NEON Biorepository Sample Portal at ASU
    • About Samples
      • Sample Types
      • Sample Repositories
      • Megapit and Distributed Initial Characterization Soil Archives
    • Finding and Accessing Sample Data
      • Species Checklists
      • Sample Explorer - Relationships and Data
      • Biorepository API
    • Requesting and Using Samples
      • Requesting Samples from the NEON Biorepository
      • Request Megapit and Initial Characterization Soil
      • Sample Use Guidelines
      • Sample Use Policy
      • Acknowledging and Citing the NEON Biorepository

    Samples & Specimens

  • Field Sites
    • Field Site Map and Info
    • Spatial Data Layers & Maps

    Field Sites

  • Resources
    • Getting Started with NEON Data
    • Research Support Services
      • Field Site Coordination
      • Letters of Support
      • Permits and Permissions
      • AOP Flight Campaigns
      • Research Support FAQs
      • Research Support Projects
    • Code Hub
      • neonUtilities for R and Python
      • Code Resources Guidelines
      • Code Resources Submission
      • NEON's GitHub Organization Homepage
    • Learning Hub
      • Tutorials
      • Workshops & Courses
      • Science Videos
      • Teaching Modules
    • Science Seminars and Data Skills Webinars
    • Document Library
    • Funding Opportunities

    Resources

  • Impact
    • Research Highlights
    • Papers & Publications
    • NEON in the News

    Impact

  • Get Involved
    • Upcoming Events
    • Past Events
    • Research and Collaborations
      • Environmental Data Science Innovation and Inclusion Lab
      • Collaboration with DOE BER User Facilities and Programs
      • EFI-NEON Ecological Forecasting Challenge
      • NEON Great Lakes User Group
      • NCAR-NEON-Community Collaborations
    • Advisory Groups
      • Science, Technology & Education Advisory Committee (STEAC)
      • Innovation Advisory Committee (IAC)
      • Technical Working Groups (TWG)
    • NEON Ambassador Program
      • Exploring NEON-Derived Data Products Workshop Series
    • Partnerships
    • Community Engagement
    • Work Opportunities

    Get Involved

  • My Account
  • Search

Search

Learning Hub

  • Tutorials
  • Workshops & Courses
  • Science Videos
  • Teaching Modules

Breadcrumb

  1. Resources
  2. Learning Hub
  3. Tutorials
  4. Calculate Vegetation Biomass from LiDAR Data in Python

Tutorial

Calculate Vegetation Biomass from LiDAR Data in Python

Authors: Tristan Goulden, Bridget Hass

Last Updated: Jun 30, 2026

In this tutorial, we will calculate the biomass for a section of the SJER site. We will be using the Ecosystem structure, or Canopy Height Model (CHM), derived from discrete lidar, as well as training data derived from NEON Vegetation structure data. This tutorial will calculate Biomass for individual trees in the forest.

Learning Objectives

After completing this tutorial, you will be able to:

  • Learn how to apply a guassian smoothing kernel for high-frequency spatial filtering
  • Apply a watershed segmentation algorithm for delineating tree crowns
  • Calculate biomass predictor variables from a CHM
  • Setup training data for Biomass predictions
  • Apply a Random Forest machine learning approach to calculate biomass

Things You’ll Need To Complete This Tutorial

To complete this tutorial, you will need:

  • Python version 3.9 or higher
  • Create a NEON user account
  • Generate an API token for downloading data

Install Python Packages

  • gdal
  • scipy
  • scikit-learn
  • scikit-image
  • neonutilities
  • python-dotenv

The following packages should be part of the standard conda installation:

  • os
  • sys
  • numpy
  • matplotlib

Download Data

The Canopy Height Model data will be downloaded programmatically at the start of the script. Download the training data by clicking on the link below.

Download the Training Data: SJER_Biomass_Training.csv and save it in your working directory.

In this tutorial, we will calculate the biomass for a section of the SJER site. We will be using the Canopy Height Model discrete LiDAR data product as well as NEON field data on vegetation data. This tutorial will calculate biomass for individual trees in the forest.

Calculating biomass using this method consists of four primary steps:

  1. Delineate individual tree crowns using watershed segmentation
  2. Calculate predictor variables for all individual trees
  3. Collect training data
  4. Apply a Random Forest regression model to estimate biomass from the predictor variables

In this tutorial we use a Random Forest (RF) machine learning algorithm for relating the predictor variables to biomass (part 4). The predictor variables were selected following suggestions by Gleason et al. (2012) and biomass estimates were determined from the stem diameter measurements (diameter at breast height, or DBH) from NEON's vegetation structure data product, following relationships given in Jenkins et al. (2003).

Get Started

First, import the Python packages required to run various parts of the script:

import os, sys, dotenv
from osgeo import gdal, osr
import matplotlib.pyplot as plt
import neonutilities as nu
import numpy as np
import pandas as pd
import rasterio as rio
from rasterio.plot import show, show_hist
from scipy import ndimage as ndi

Next, we will add libraries from scikit-learn which will help with the watershed delination, determination of predictor variables and random forest algorithm

#Import biomass specific libraries
from skimage.segmentation import watershed
from skimage.feature import peak_local_max
from skimage.measure import regionprops
from sklearn.ensemble import RandomForestRegressor

We also need to specify the directory where we will find and save the data needed for this tutorial. You may need to change this line to follow a different working directory structure, or to suit your local machine. I have decided to save my data in the following directory:

data_path = "C:\data"

Define functions

Now we will define a few functions that allow us to pull metrics from the CHM data and get predictor variables.

  • crown_geometric_volume_pct: function to get the tree height and crown volume percentiles
def crown_geometric_volume_pct(tree_data,min_tree_height,pct):
    p = np.percentile(tree_data, pct)
    tree_data_pct = [v if v < p else p for v in tree_data]
    crown_geometric_volume_pct = np.sum(tree_data_pct - min_tree_height)
    return crown_geometric_volume_pct, p
  • get_predictors: function to get the predictor variables from the CHM data
def get_predictors(tree,chm_array, labels):
    indexes_of_tree = np.asarray(np.where(labels==tree.label)).T
    tree_crown_heights = chm_array[indexes_of_tree[:,0],indexes_of_tree[:,1]]
    
    full_crown = np.sum(tree_crown_heights - np.min(tree_crown_heights))
    
    crown50, p50 = crown_geometric_volume_pct(tree_crown_heights,tree.min_intensity,50)
    crown60, p60 = crown_geometric_volume_pct(tree_crown_heights,tree.min_intensity,60)
    crown70, p70 = crown_geometric_volume_pct(tree_crown_heights,tree.min_intensity,70)
        
    return [tree.label,
            float(tree.area),
            tree.major_axis_length,
            tree.max_intensity,
            tree.min_intensity, 
            p50, p60, p70,
            full_crown, 
            crown50, crown60, crown70]
  • output_raster: function to write out intermediate rasters and the final biomass raster
def output_raster(output_path, array, profile, dtype="float32", nodata=None):
    out_profile = profile.copy()
    out_profile.update(driver="GTiff", count=1, dtype=dtype, nodata=nodata)
    
    with rio.open(output_path, "w", **out_profile) as dst:
        dst.write(np.asarray(array, dtype=dtype), 1)

Download CHM Data

As of June 2026, NEON requires an API token for data downloads, to reduce bot scraping and improve user support. Tokens can be generated in NEON data portal user accounts - log in to your account or create one, and go to the API Tokens section. For best practices in storing and using tokens, follow the instructions here. Once you've set up your token as an environment variable, you can load it using the python-dotenv package as follows, optionally specifying the path to the .env file in load_dotenv().

dotenv.load_dotenv()
token = os.environ.get("NEON_TOKEN")
# download the CHM data to the C:/data directory - change this if desired
nu.by_tile_aop(dpid='DP3.30015.001',
               site='SJER',
               year=2018,
               easting=256000,
               northing=4106000,
               token=token,
               savepath=r'C:\data')
Provisional NEON data are not included. To download provisional data, use input parameter include_provisional=True.


Continuing will download 2 NEON data files totaling approximately 551.9 KB. Do you want to proceed? (y/n)  y


Downloading 2 NEON data files totaling approximately 551.9 KB

100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 2/2 [00:00<00:00,  2.64it/s]
    
chm_dir = os.path.expanduser(r"C:\data\DP3.30015.001\neon-aop-products\2018")

for root, dirs, files in os.walk(chm_dir):
    for file in files:
        if file.endswith('.tif'):
            chm_file = os.path.join(root, file)
            print(chm_file)
C:\data\DP3.30015.001\neon-aop-products\2018\FullSite\D17\2018_SJER_3\L3\DiscreteLidar\CanopyHeightModelGtif\NEON_D17_SJER_DP3_256000_4106000_CHM.tif

Plot CHM Data

With everything set up, we can now start working with our data by define the file path to our CHM file. Note that you will need to change this and subsequent filepaths according to your local machine.

When we output the results, we will want to include the same file information as the input, so we will gather the file name information.

Now we will read in the CHM data using rasterio ...

chm_dataset = rio.open(chm_file)

..., and plot it.

show(chm_dataset);

png

It looks like SJER primarily has low vegetation with scattered taller trees.

Read in the CHM as an array, as well as other relevant info we'll use later on:

with rio.open(chm_file) as src:
    # Read CHM values and keep ground/no-data as 0 for downstream segmentation steps
    chm_array = src.read(1, masked=True).filled(0).astype(np.float32)
    chm_profile = src.profile.copy()
    chm_bounds = src.bounds
    extent = (chm_bounds.left, chm_bounds.right, chm_bounds.bottom, chm_bounds.top)
    chm_name = os.path.basename(chm_file)

Create Filtered CHM

Now we will use a Gaussian smoothing kernal (convolution) across the data set to remove spurious high vegetation points. This will help ensure we are finding the treetops properly before running the watershed segmentation algorithm.

For different forest types it may be necessary to change the input parameters. Information on the function can be found in the SciPy documentation.

Of most importance are the second and fifth inputs. The second input defines the standard deviation of the Gaussian smoothing kernal. Too large a value will apply too much smoothing, too small and some spurious high points may be left behind. The fifth, the truncate value, controls after how many standard deviations the Gaussian kernal will get cut off (since it theoretically goes to infinity).

#Smooth the CHM using a gaussian filter to remove spurious points
chm_array_smooth = ndi.gaussian_filter(chm_array,2,mode='constant',cval=0,truncate=2.0)
chm_array_smooth[chm_array==0] = 0 

Now save a copy of filtered CHM. We will later use this in our code, so we'll output it into our data directory.

# Save the smoothed CHM using rasterio
output_raster(
    os.path.join(data_path, 'chm_filter.tif'),
    chm_array_smooth,
    chm_profile,
    dtype="float32",
    nodata=0
    )

Determine local maximums

Now we will run an algorithm to determine local maximums within the image. Setting indices to False returns a raster of the maximum points, as opposed to a list of coordinates. The footprint parameter is an area where only a single peak can be found. This should be approximately the size of the smallest tree. Information on more sophisticated methods to define the window can be found in Chen (2006).

# Calculate local maximum points for filtered and unfiltered CHM
footprint = np.ones((5, 5), dtype=bool)

# Unfiltered maxima
local_max_coords_unfiltered = peak_local_max(
    chm_array,
    footprint=footprint,
    exclude_border=False
    )
local_maxi_unfiltered = np.zeros_like(chm_array, dtype=bool)
if local_max_coords_unfiltered.size > 0:
    local_maxi_unfiltered[tuple(local_max_coords_unfiltered.T)] = True

# Filtered maxima (used downstream for segmentation)
local_max_coords = peak_local_max(
    chm_array_smooth,
    footprint=footprint,
    exclude_border=False
    )
local_maxi = np.zeros_like(chm_array_smooth, dtype=bool)
if local_max_coords.size > 0:
    local_maxi[tuple(local_max_coords.T)] = True

Our new object local_maxi is an array of boolean values where each pixel is identified as either being the local maximum (True) or not being the local maximum (False).

local_maxi
array([[False, False, False, ..., False, False, False],
       [False, False,  True, ..., False, False, False],
       [False, False, False, ..., False, False, False],
       ...,
       [False, False, False, ..., False, False, False],
       [False, False, False, ..., False, False, False],
       [False, False, False, ..., False, False, False]],
      shape=(1000, 1000))

This is helpful, but it can be difficult to visualize boolean values. We can convert this boolean array to an numeric format to use this function. Booleans convert easily to integers with values of False=0 and True=1 using the .astype(int) method.

local_maxi.astype(int)
array([[0, 0, 0, ..., 0, 0, 0],
       [0, 0, 1, ..., 0, 0, 0],
       [0, 0, 0, ..., 0, 0, 0],
       ...,
       [0, 0, 0, ..., 0, 0, 0],
       [0, 0, 0, ..., 0, 0, 0],
       [0, 0, 0, ..., 0, 0, 0]], shape=(1000, 1000))

Next we can plot the raster of local maximums by coercing the boolean array into an array of integers inline. The following figure shows the difference in finding local maximums for a filtered vs. non-filtered CHM.

We will save the graphics (.png) in an outputs folder sister to our working directory and data outputs (.tif) to our data directory.

# Plot the local maximums
plt.figure(2)
plt.imshow(local_maxi.astype(int), cmap='Greys', extent=extent, origin='upper', vmin=0, vmax=1)
plt.title('Local Maxima')
plt.xlabel('Maxi')
plt.colorbar(label='Maxi')

plt.savefig(data_path+chm_name[0:-4]+ '_Maximums.png',
            dpi=300,orientation='landscape',
            bbox_inches='tight',pad_inches=0.1)

output_raster(
    os.path.join(data_path, 'maximum.tif'),
    local_maxi.astype(np.uint8),
    chm_profile,
    dtype="uint8",
    nodata=0
    )

png

Looking at the overlap between the tree crowns and the local maxima from each method, you can see that filtering improves the results.

# Zoomed CHM section with unfiltered vs filtered local maxima overlay
from mpl_toolkits.axes_grid1 import make_axes_locatable

n_rows, n_cols = chm_array_smooth.shape

# Define a small window around the center of the tile
win_rows = min(250, n_rows)
win_cols = min(250, n_cols)
r0 = max(0, (n_rows // 2) - (win_rows // 2))
c0 = max(0, (n_cols // 2) - (win_cols // 2))
r1 = min(n_rows, r0 + win_rows)
c1 = min(n_cols, c0 + win_cols)

chm_zoom = chm_array_smooth[r0:r1, c0:c1]
local_zoom_filtered = local_maxi[r0:r1, c0:c1]
local_zoom_unfiltered = local_maxi_unfiltered[r0:r1, c0:c1]

# Use rasterio transform to get the true map extent for this zoom window
transform = chm_profile["transform"]
left, top = rio.transform.xy(transform, r0, c0, offset='ul')
right, bottom = rio.transform.xy(transform, r1 - 1, c1 - 1, offset='lr')
zoom_extent = (left, right, bottom, top)

# Convert local maxima row/col indices to map x/y for plotting
peak_rows_filtered, peak_cols_filtered = np.where(local_zoom_filtered)
peak_x_filtered, peak_y_filtered = rio.transform.xy(
    transform,
    peak_rows_filtered + r0,
    peak_cols_filtered + c0,
    offset='center'
    )

peak_rows_unfiltered, peak_cols_unfiltered = np.where(local_zoom_unfiltered)
peak_x_unfiltered, peak_y_unfiltered = rio.transform.xy(
    transform,
    peak_rows_unfiltered + r0,
    peak_cols_unfiltered + c0,
    offset='center'
    )

fig, ax = plt.subplots(figsize=(8, 8))
im = ax.imshow(chm_zoom, cmap='terrain', extent=zoom_extent, origin='upper')

if len(peak_x_unfiltered) > 0:
    ax.scatter(
        peak_x_unfiltered, peak_y_unfiltered, s=18, marker='x', c='yellow',
        linewidths=0.7, label='Unfiltered maxima'
        )
if len(peak_x_filtered) > 0:
    ax.scatter(
        peak_x_filtered, peak_y_filtered, s=14, c='red', edgecolors='black',
        linewidths=0.3, label='Filtered maxima'
        )

ax.set_title('Zoomed CHM: Unfiltered vs Filtered Local Maxima')
ax.set_xlabel('Easting (m)')
ax.set_ylabel('Northing (m)')
ax.legend(loc='upper right')

# Create a colorbar axis matched to the plot's y-axis height
divider = make_axes_locatable(ax)
cax = divider.append_axes('right', size='4%', pad=0.08)
fig.colorbar(im, cax=cax, label='Canopy height (m)')

plt.tight_layout()

png

#Identify all the maximum points
markers = ndi.label(local_maxi)[0]

Next, create a mask layer of all of the vegetation points so that the watershed segmentation will only occur on the trees and not extend into the surrounding ground points. Since 0 represent ground points in the CHM, setting the mask to 1 where the CHM is not zero will define the mask

#Create a CHM mask so the segmentation will only occur on the trees
chm_mask = chm_array_smooth
chm_mask[chm_array_smooth != 0] = 1

Watershed segmentation

As in a river system, a watershed is divided by a ridge that divides areas. Here our watershed are the individual tree canopies and the ridge is the delineation between each one.

See Basics of Image Processing - Watershed Segmentation for more information on this algorithm.

Next, carry out the watershed segmentation. This produces a raster of labels. First, we'll visualize the steps of the watershed segmentation process.

# Create a visualization showing the watershed segmentation process
fig, axes = plt.subplots(1, 2, figsize=(15, 5))

# Panel 1: Smoothed CHM with local maxima (markers)
im1 = axes[0].imshow(chm_array_smooth, cmap='viridis', extent=extent, origin='upper')
axes[0].scatter(chm_bounds.left + np.where(markers)[1] * (chm_bounds.right - chm_bounds.left) / chm_array_smooth.shape[1],
                chm_bounds.top - np.where(markers)[0] * (chm_bounds.top - chm_bounds.bottom) / chm_array_smooth.shape[0],
                c='red', s=30, marker='x', linewidths=2, label='Tree tops (markers)')
axes[0].set_title('Step 1: Smoothed CHM\nwith Tree Top Markers', fontsize=12, fontweight='bold')
axes[0].set_xlabel('X coordinate')
axes[0].set_ylabel('Y coordinate')
axes[0].legend(loc='upper right')
plt.colorbar(im1, ax=axes[0], label='Height (m)')

# Panel 2: CHM with vegetation mask
masked_chm = np.where(chm_mask, chm_array_smooth, np.nan)
im2 = axes[1].imshow(masked_chm, cmap='Greens', extent=extent, origin='upper')
axes[1].set_title('Step 2: Vegetation Mask\n(Algorithm only operates on vegetation)', fontsize=12, fontweight='bold')
axes[1].set_xlabel('X coordinate')
axes[1].set_ylabel('Y coordinate')
plt.colorbar(im2, ax=axes[1], label='Height (m)');

png

The visualization above shows the first two stages of watershed segmentation: 1. smoothing the CHM with detected tree top markers (red x's), and 2) the vegetation mask, showing only areas with trees included. The third and final step showing the resulting segments are shown after performing the watershed segmentation, in the next cell.

# Perform watershed segmentation        
labels = watershed(chm_array_smooth, markers, mask=chm_mask)
labels_for_plot = labels.copy()
labels_for_plot = np.array(labels_for_plot,dtype = np.float32)
labels_for_plot[labels_for_plot==0] = np.nan
max_labels = np.max(labels)
# Plot the segments
plt.figure(4)
plt.imshow(labels_for_plot, cmap='Spectral', extent=extent, origin='upper', vmin=0, vmax=max_labels)
plt.title('Crown Segmentation')
plt.colorbar(label='Tree Crown Number')

plt.savefig(data_path+chm_name[0:-4]+'_Segmentation.png',
            dpi=300,orientation='landscape',
            bbox_inches='tight',pad_inches=0.1)

output_raster(
    os.path.join(data_path, 'labels.tif'),
    labels,
    chm_profile,
    dtype="int32",
    nodata=0
    )

png

Now collect several properties of the individual trees that will be used as predictor variables.

#Get the properties of each segment
tree_properties = regionprops(labels,chm_array)

Now we will get the predictor variables to match the (soon to be loaded) training data using the get_predictors function defined above. The first column is the segment ID, and the rest are the predictor variables, namely the tree area (pixels), major_axis_length, maximum height, minimum height, height percentiles (p50, p60, p70), and crown geometric volume percentiles (full and percentiles 50, 60, and 70).

predictors_chm = np.array([get_predictors(tree, chm_array, labels) for tree in tree_properties])
X = predictors_chm[:,1:]
tree_ids = predictors_chm[:,0]
np.shape(predictors_chm)
(2749, 12)

We now read the training data directly from GitHub. The CSV contains 12 columns: biomass (the target variable), followed by 11 predictor variables: area, major_axis_length, max_height, min_height, p50, p60, p70, full_crown, crown50, crown60, and crown70. Field-validated tree diameter (DBH) values are used to compute biomass using the formulas from Jenkins et al. (2003), while the remaining predictors were derived from the CHM by manually delineating tree crowns.

# Read training data from download path
training_data_csv = r'C:\NEON-Data-Skills\tutorials\Python\AOP\Lidar\lidar-applications\lidar-biomass\calc-biomass_py_files\SJER_Biomass_Training.csv'

# Alternatively, read training data directly from GitHub (raw CSV)
# training_data_csv = ('https://raw.githubusercontent.com/NEONScience/NEON-Data-Skills/'
#                      'main/tutorials/Python/AOP/Lidar/lidar-applications/'
#                      'lidar-biomass/calc-biomass_py_files/SJER_Biomass_Training.csv')

training_df = pd.read_csv(training_data_csv)

# Build per-column rounding: 0 dp for integer columns, 1 dp for float columns
decimals = {c: (0 if training_df[c].dtype == np.int64 else 1) for c in training_df.columns}

# Preview the data
print('Shape:', training_df.shape)
print()
print('First 5 rows:')
display(training_df.head().round(decimals))
print()
print('Summary statistics:')
display(training_df.describe().round(1))

# Extract numpy arrays for the model
biomass = training_df['biomass'].values
biomass_predictors = training_df.drop(columns=['biomass']).values
Shape: (42, 12)

First 5 rows:
biomass area major_axis_length max_height min_height p50 p60 p70 full_crown crown50 crown60 crown70
0 14.2 23 5.6 9 5.4 8.2 8.4 8.5 54.1 48.7 50.5 51.5
1 7643.4 47 10.9 12 6.7 10.4 10.7 10.8 159.0 146.0 151.0 154.0
2 889.0 16 12.4 11 7.0 9.8 10.1 10.2 39.5 34.8 37.0 37.3
3 456.9 26 10.3 11 5.8 8.6 9.0 9.2 68.6 55.5 60.8 62.6
4 296.7 5 11.8 11 8.3 10.6 10.7 10.8 9.4 8.4 8.6 8.7
Summary statistics:
biomass area major_axis_length max_height min_height p50 p60 p70 full_crown crown50 crown60 crown70
count 42.0 42.0 42.0 42.0 42.0 42.0 42.0 42.0 42.0 42.0 42.0 42.0
mean 2644.0 33.5 8.4 9.2 5.1 7.5 7.8 8.0 99.9 85.6 90.1 93.2
std 4851.6 29.3 3.4 3.6 1.8 2.7 2.8 2.9 129.5 113.3 118.5 122.2
min 14.2 3.0 3.6 5.0 2.1 2.8 2.9 3.3 1.4 0.3 0.5 0.8
25% 240.1 15.2 6.1 7.0 4.0 5.7 5.8 6.0 26.2 23.5 24.0 24.7
50% 659.4 23.0 7.5 8.5 5.0 7.0 7.2 7.3 47.4 38.2 41.0 42.3
75% 2791.3 41.2 10.7 11.0 5.7 8.6 9.0 9.2 88.4 72.2 78.4 82.8
max 25968.4 118.0 18.2 21.0 11.8 15.5 16.0 16.5 627.0 553.0 574.0 591.0

Let's inspect the training data by making some pairwise plots.

# Inspect pairwise relationships among training-data columns
column_names = training_df.columns.tolist()

# Summary table
summary = training_df.describe().round(1)
print('Column summary (min, max, mean, std):')
for name in column_names:
    mn = summary.loc['min', name]
    mx = summary.loc['max', name]
    mu = summary.loc['mean', name]
    sd = summary.loc['std', name]
    print(f'{name:>16s}: min={mn:10.1f}, max={mx:10.1f}, mean={mu:10.1f}, std={sd:10.1f}')

# Plot 1: correlation heatmap (all columns)
corr = training_df.corr(numeric_only=True)
fig, ax = plt.subplots(figsize=(10, 8))
im = ax.imshow(corr, cmap='coolwarm', vmin=-1, vmax=1)
ax.set_xticks(np.arange(len(corr.columns)))
ax.set_yticks(np.arange(len(corr.columns)))
ax.set_xticklabels(corr.columns, rotation=45, ha='right')
ax.set_yticklabels(corr.columns)
ax.set_title('Training Data Correlation Matrix')
plt.colorbar(im, ax=ax, label='Pearson r')
plt.tight_layout()

# Plot 2: scatter matrix style plot (key variables)
plot_columns = [
    'area', 'major_axis_length', 'max_height', 'p70',
    'full_crown', 'crown70', 'biomass'
    ]
plot_df = training_df[plot_columns]

n = len(plot_columns)
fig, axes = plt.subplots(n, n, figsize=(2.2 * n, 2.2 * n), squeeze=False)
for i in range(n):
    for j in range(n):
        ax = axes[i, j]
        if i == j:
            ax.hist(plot_df.iloc[:, j], bins=20, color='gray', alpha=0.8)
        else:
            ax.scatter(plot_df.iloc[:, j], plot_df.iloc[:, i], s=10, alpha=0.6, color='tab:blue')

        if i == n - 1:
            ax.set_xlabel(plot_columns[j], fontsize=8, rotation=45)
        else:
            ax.set_xticklabels([])
        if j == 0:
            ax.set_ylabel(plot_columns[i], fontsize=8)
        else:
            ax.set_yticklabels([])

fig.suptitle('Pairwise Relationships Among Key Training Variables', y=1.02)
plt.tight_layout()
Column summary (min, max, mean, std):
         biomass: min=      14.2, max=   25968.4, mean=    2644.0, std=    4851.6
            area: min=       3.0, max=     118.0, mean=      33.5, std=      29.3
major_axis_length: min=       3.6, max=      18.2, mean=       8.4, std=       3.4
      max_height: min=       5.0, max=      21.0, mean=       9.2, std=       3.6
      min_height: min=       2.1, max=      11.8, mean=       5.1, std=       1.8
             p50: min=       2.8, max=      15.5, mean=       7.5, std=       2.7
             p60: min=       2.9, max=      16.0, mean=       7.8, std=       2.8
             p70: min=       3.3, max=      16.5, mean=       8.0, std=       2.9
      full_crown: min=       1.4, max=     627.0, mean=      99.9, std=     129.5
         crown50: min=       0.3, max=     553.0, mean=      85.6, std=     113.3
         crown60: min=       0.5, max=     574.0, mean=      90.1, std=     118.5
         crown70: min=       0.8, max=     591.0, mean=      93.2, std=     122.2

png

png

Random Forest classifiers

We can then define parameters of the Random Forest classifier and fit the predictor variables from the training data to the Biomass estimates.

#Define parameters for the Random Forest Regressor
max_depth = 30

#Define regressor settings
regr_rf = RandomForestRegressor(max_depth=max_depth, random_state=2)

#Fit the biomass to regressor variables
regr_rf.fit(biomass_predictors,biomass)

We will now apply the Random Forest model to the predictor variables to estimate biomass

#Apply the model to the predictors
estimated_biomass = regr_rf.predict(X)

To output a raster, pre-allocate (copy) an array from the labels raster, then cycle through the segments and assign the biomass estimate to each individual tree segment.

#Set an out raster with the same size as the labels
biomass_map =  np.array((labels),dtype=float)
#Assign the appropriate biomass to the labels
biomass_map[biomass_map==0] = np.nan
for tree_id, biomass_of_tree_id in zip(tree_ids, estimated_biomass):
    biomass_map[biomass_map == tree_id] = biomass_of_tree_id  

Calculate Biomass

Collect some of the biomass statistics and then plot the results and save an output geotiff.

#Get biomass stats for plotting
mean_biomass = np.mean(estimated_biomass)
std_biomass = np.std(estimated_biomass)
min_biomass = np.min(estimated_biomass)
sum_biomass = np.sum(estimated_biomass)

print('Sum of above ground biomass (AGB) is',round(sum_biomass,1),'kg')

# Plot the biomass
plt.figure(5)
plt.imshow(
    biomass_map,
    cmap='winter',
    extent=extent,
    origin='upper',
    vmin=min_biomass + std_biomass,
    vmax=mean_biomass + std_biomass * 3
    )
plt.title('Biomass (kg)')
plt.colorbar(label='Biomass (kg)')

# Save the biomass figure; use the same name as the original file, but replace CHM with Biomass
plt.savefig(os.path.join(data_path,chm_name.replace('CHM.tif','Biomass.png')),
            dpi=300,orientation='landscape',
            bbox_inches='tight',
            pad_inches=0.1)

# Write biomass geotiff with rasterio
output_raster(
    os.path.join(data_path, chm_name.replace('CHM.tif','Biomass.tif')),
    biomass_map,
    chm_profile,
    dtype="float32",
    nodata=np.nan
    )
Sum of above ground biomass (AGB) is 10515682.7 kg

png

Get Lesson Code

calc-biomass_py.ipynb

Questions?

If you have questions or comments on this content, please contact us.

Contact Us
NSF NEON, Operated by Battelle

Follow Us:

Join Our Newsletter

Get updates on events, opportunities, and how NEON is being used today.

Subscribe Now

Footer

  • About Us
  • Contact Us
  • Terms & Conditions
  • Careers
  • Code of Conduct

Copyright © Battelle, 2026

The National Ecological Observatory Network is a major facility fully funded by the U.S. National Science Foundation.

Any opinions, findings and conclusions or recommendations expressed in this material do not necessarily reflect the views of the U.S. National Science Foundation.