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
      • Sample Guidelines and Policies

    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. Classify a Lidar Raster in Python

Tutorial

Classify a Lidar Raster in Python

Authors: Bridget Hass

Last Updated: Jun 30, 2026

This tutorial covers how to read in a NEON lidar Canopy Height Model (CHM) geotiff file into a Python rasterio object, shows some basic information about the raster data, and then ends with classifying the CHM into height bins.

Learning Objectives

After completing this tutorial, you will be able to:

  • User rasterio to read in a NEON lidar raster geotiff file
  • Plot a raster tile and histogram of the data values
  • Create a classified raster object using thresholds

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
  • rasterio
  • neonutilities
  • python-dotenv

Data

For this lesson, we will read in a Canopy Height Model data collected at NEON's Lower Teakettle (TEAK) site in California. This data is downloaded in the first part of the tutorial, using the Python neonutilities package.

In this tutorial, we will work with the NEON AOP L3 LiDAR ecoysystem structure (Canopy Height Model) data product. For more information about NEON data products and the CHM product DP3.30015.001, see the Ecosystem structure data product page on NEON's Data Portal.

First, let's import the required packages and set our plot display to be in-line:

import dotenv
import os
import copy
import neonutilities as nu
import numpy as np
import rasterio as rio
from rasterio.plot import show, show_hist
import matplotlib.pyplot as plt

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")

Next, let's download a single tile (1 km x 1 km CHM file) using nu.by_tile_aop().

nu.by_tile_aop(dpid='DP3.30015.001',
               site='TEAK',
               year='2024',
               easting=320000,
               northing=4092000,
               token=token,
               savepath=r'C:\NEON_Data') # change if desired
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 2.9 MB. Do you want to proceed? (y/n)  y


Downloading 2 NEON data files totaling approximately 2.9 MB

100%|███████████████████████████████████████| 2/2 [00:00<00:00,  2.22it/s]
# iterate over directory recursively to show path of downloaded CHM.tif file
for root, dirs, files in os.walk(r'C:\NEON_Data\DP3.30015.001'):
    for name in files:
        if name.endswith('.tif'):
            chm_tile = os.path.join(root, name)
            print(chm_tile) 
C:\NEON_Data\DP3.30015.001\neon-aop-products\2024\FullSite\D17\2024_TEAK_7\L3\DiscreteLidar\CanopyHeightModelGtif\NEON_D17_TEAK_DP3_320000_4092000_CHM.tif

Open a GeoTIFF with rasterio

Let's look at the TEAK Canopy Height Model (CHM) to start. We can open and read this in Python using the rasterio.open function:

# read the chm file to the variable chm_dataset
chm_dataset = rio.open(chm_tile)

Now we can look at a few properties of this dataset to start to get a feel for the rasterio object:

print('chm_dataset:\n',chm_dataset)
print('\nshape:\n',chm_dataset.shape)
print('\nno data value:\n',chm_dataset.nodata)
print('\nspatial extent:\n',chm_dataset.bounds)
print('\ncoordinate information (crs):\n',chm_dataset.crs)
chm_dataset:
 <open DatasetReader name='C:\NEON_Data\DP3.30015.001\neon-aop-products\2024\FullSite\D17\2024_TEAK_7\L3\DiscreteLidar\CanopyHeightModelGtif\NEON_D17_TEAK_DP3_320000_4092000_CHM.tif' mode='r'>

shape:
 (1000, 1000)

no data value:
 -9999.0

spatial extent:
 BoundingBox(left=320000.0, bottom=4092000.0, right=321000.0, top=4093000.0)

coordinate information (crs):
 PROJCS["WGS 84 / UTM zone 11N",GEOGCS["WGS 84",DATUM["World Geodetic System 1984",SPHEROID["WGS 84",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433,AUTHORITY["EPSG","9122"]]],PROJECTION["Transverse_Mercator"],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",-117],PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",500000],PARAMETER["false_northing",0],UNIT["metre",1,AUTHORITY["EPSG","9001"]],AXIS["Easting",EAST],AXIS["Northing",NORTH]]

Plot the Canopy Height Map and Histogram

We can use rasterio's built-in functions show and show_hist to plot and visualize the CHM tile. It is often useful to plot a histogram of the geotiff data in order to get a sense of the range and distribution of values.

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12,5))
show(chm_dataset, ax=ax1);

show_hist(chm_dataset, bins=50, histtype='stepfilled',
          lw=0.0, stacked=False, alpha=0.3, ax=ax2);
ax2.set_xlabel("Canopy Height (meters)");
ax2.get_legend().remove()

plt.show();

png

On your own, adjust the number of bins, and range of the y-axis to get a better sense of the distribution of the canopy height values. We can see that a large portion of the values are zero. These correspond to bare ground. Let's look at a histogram and plot the data without these zero values which are dominating the frequency distribution. To do this, we'll remove all values > 2 m. Due to the vertical range resolution of the lidar sensor, data collected with the older Optech Gemini sensor can only resolve the ground to within 2 m, so anything below that height would be rounded down to zero. Our newer sensors (Riegl Q780 and Optech Galaxy Prime) have a higher range resolution, so the ground can be resolved to within ~0.7 m. To see which lidar sensor collected a given site, refer to the table at the bottom of the Flight Schedules and Coverage page (https://www.neonscience.org/data-collection/flight-schedules-coverage).

chm_data = chm_dataset.read(1)
valid_data = chm_data[chm_data>2]
plt.hist(valid_data.flatten(),bins=30);

png

From the histogram we can see that the majority of the trees are < 60m. The frequency of tall trees rapidly drops off.

Threshold Based Raster Classification

Next, we will create a classified raster object. To do this, we will use the numpy.where function to create a new raster based off boolean classifications. Let's classify the canopy height into five groups:

  • Class 1: CHM = 0 m
  • Class 2: 0m < CHM <= 15m
  • Class 3: 10m < CHM <= 30m
  • Class 4: 20m < CHM <= 45m
  • Class 5: CHM > 45m

We can use np.where to find the indices where the specified criteria is met.

chm_reclass = chm_data.copy()
chm_reclass[np.where(chm_data==0)] = 1 # CHM = 0 : Class 1
chm_reclass[np.where((chm_data>0) & (chm_data<=15))] = 2 # 0m < CHM <= 10m - Class 2
chm_reclass[np.where((chm_data>15) & (chm_data<=30))] = 3 # 10m < CHM <= 20m - Class 3
chm_reclass[np.where((chm_data>30) & (chm_data<=45))] = 4 # 20m < CHM <= 30m - Class 4
chm_reclass[np.where(chm_data>45)] = 5 # CHM > 30m - Class 5

When we look at this variable, we can see that it is now populated with values between 1-5:

chm_reclass
array([[1., 1., 1., ..., 3., 3., 3.],
       [2., 2., 2., ..., 3., 3., 3.],
       [1., 2., 2., ..., 3., 3., 3.],
       ...,
       [3., 1., 4., ..., 2., 2., 2.],
       [3., 1., 1., ..., 2., 2., 2.],
       [1., 1., 1., ..., 2., 2., 1.]], shape=(1000, 1000))

Lastly we can use matplotlib to display this re-classified CHM. We will define our own colormap to plot these discrete classifications, and create a custom legend to label the classes. First, to include the spatial information in the plot, create a new variable called ext that pulls from the rasterio "bounds" field to create the extent in the expected format for plotting.

ext = [chm_dataset.bounds.left,
       chm_dataset.bounds.right,
       chm_dataset.bounds.bottom,
       chm_dataset.bounds.top]
ext
[320000.0, 321000.0, 4092000.0, 4093000.0]
import matplotlib.colors as colors
plt.figure(); 
cmap_chm = colors.ListedColormap(['lightblue','yellow','orange','green','red'])
plt.imshow(chm_reclass,extent=ext,cmap=cmap_chm)
plt.title('TEAK CHM Classification')
ax=plt.gca(); ax.ticklabel_format(useOffset=False, style='plain') #do not use scientific notation 
rotatexlabels = plt.setp(ax.get_xticklabels(),rotation=90) #rotate x tick labels 90 degrees

# Create custom legend to label the four canopy height classes:
import matplotlib.patches as mpatches
class1 = mpatches.Patch(color='lightblue', label='0 m')
class2 = mpatches.Patch(color='yellow', label='0-15 m')
class3 = mpatches.Patch(color='orange', label='15-30 m')
class4 = mpatches.Patch(color='green', label='30-45 m')
class5 = mpatches.Patch(color='red', label='>30 m')

ax.legend(handles=[class1,class2,class3,class4,class5],
          handlelength=0.7,bbox_to_anchor=(1.05, 0.4),loc='lower left',borderaxespad=0.);

png

Challenge: Try Another Classification

Create the following threshold classified outputs:

  1. An NDVI raster where values are classified into the following categories:
  • Low greenness: NDVI < 0.3
  • Medium greenness: 0.3 < NDVI < 0.6
  • High greenness: NDVI > 0.6
  1. A classified aspect raster where the data is grouped into North and South facing slopes (or all four cardinal directions):
  • North: 0-45 & 315-360 degrees
  • South: 135-225 degrees

Get Lesson Code

classify-chm.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.