Skip to main content
NSF NEON, Operated by Battelle

Main navigation

    • About Overview
    • NEON Overview
    • About the NEON Biorepository
    • Observatory Blog
    • Newsletters
    • Staff
    • FAQ
    • User Accounts
    • Contact Us
    • Data Overview
    • Data Portal
    • Data Availability Charts
    • API & GraphQL
    • Prototype Data
    • Data Collection Methods
    • Getting Started with NEON Data
    • Using Data
    • Data Notifications
    • NEON Data Management
    • Samples & Specimens Overview
    • NEON Biorepository Sample Portal at ASU
    • About NEON Samples
    • Sample Types
    • Finding and Accessing Sample Data
    • Species Checklists
    • Requesting and Using Samples
    • Requesting Samples from the NEON Biorepository
    • Sample Use Guidelines
    • Acknowledging and Citing the NEON Biorepository
    • Field Sites Overview
    • Field Site Map and Info
    • Spatial Data Layers & Maps
    • Resources Overview
    • Getting Started with NEON Data
    • Research Support Services
    • Code Hub
    • Learning Hub
    • Science Seminars and Data Skills Webinars
    • Document Library
    • Funding Opportunities
    • Impact Overview
    • Research Highlights
    • Papers & Publications
    • NEON in the News
    • Get Involved Overview
    • Upcoming Events
    • Past Events
    • Research and Collaborations
    • Advisory Groups
    • NEON Ambassador Program
    • Partnerships
    • Community Engagement
    • Work Opportunities

Search

Learning Hub

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

Breadcrumb

  1. Resources
  2. Learning Hub
  3. Tutorials
  4. Create a Hillshade from a Terrain Raster in Python

Tutorial

Create a Hillshade from a Terrain Raster in Python

Authors: Bridget Hass

Last Updated: Jun 30, 2026

This tutorial covers how to create a hillshade from a terrain raster in Python, and demonstrates a few options for visualizing lidar-derived Digital Elevation Models.

Learning Objectives

After completing this tutorial, you will be able to:

  • Understand how to read in and visualize Lidar elevation models (DTM, DSM) in Python
  • Plot a contour map of the DTM
  • Create a hillshade from the DTM
  • Calculate and plot Canopy Height along with hillshade and elevation

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

Download Data

For this lesson, we will read in Digital Terrain Model (DTM) 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.

Additional Resources

NEON'S Airborne Observation Platform provides Algorithm Theoretical Basis Documents (ATBDs) for all of their data products. Please refer to the ATBDs below for a more in-depth understanding of how the Lidar-derived rasters are generated.

  • Elevation (DSM and DTM) ATBD
  • Ecosystem Structure ATBD

First, let's import the required packages:

import dotenv
import os
import numpy as np
import neonutilities as nu
import rasterio as rio
from rasterio.plot import show
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")

Read in the datasets

Download Lidar Elevation Models from TEAK

To start, we will download the NEON Elevation Models (DTM and DSM) which are provided in geotiff (.tif) format. Use the download_url function below to download the data directly from the cloud storage location.

For more information on these data products, refer to the NEON Data Portal page, linked here: Elevation - LiDAR.

# download the DSM and DTM data to the C:/data directory - change this if desired
nu.by_tile_aop(dpid='DP3.30024.001',
               site='TEAK',
               year=2021,
               easting=320000,
               northing=4092000,
               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 3 NEON data files totaling approximately 6.7 MB. Do you want to proceed? (y/n)  y


Downloading 3 NEON data files totaling approximately 6.7 MB

100%|█████████████████████████████████████████████████████████████████████████████████████████████████████████████████| 3/3 [00:01<00:00,  2.97it/s]

Display the DSM and DTM files that have been downloaded:

dem_dir = os.path.expanduser(r"C:\data\DP3.30024.001")

for root, dirs, files in os.walk(dem_dir):
    for file in files:
        if file.endswith('DTM.tif'):
            dtm_file = os.path.join(root, file)
            print(os.path.join(root, file).replace(os.path.expanduser('~/Downloads/'),'..'))
        if file.endswith('DSM.tif'):
            dsm_file = os.path.join(root, file)
            print(os.path.join(root, file).replace(os.path.expanduser('~/Downloads/'),'..'))
C:\data\DP3.30024.001\neon-aop-products\2021\FullSite\D17\2021_TEAK_5\L3\DiscreteLidar\DSMGtif\NEON_D17_TEAK_DP3_320000_4092000_DSM.tif
C:\data\DP3.30024.001\neon-aop-products\2021\FullSite\D17\2021_TEAK_5\L3\DiscreteLidar\DTMGtif\NEON_D17_TEAK_DP3_320000_4092000_DTM.tif

Calculate Hillshade

Hillshade is used to visualize the hypothetical illumination value (from 0-255) of each pixel on a surface given a specified light source. To calculate hillshade, we need the zenith (altitude) and azimuth of the illumination source, as well as the slope and aspect of the terrain. The formula for hillshade is:

$$Hillshade = 255.0 * (( cos(zenith_I)*cos(slope_T))+(sin(zenith_I)*sin(slope_T)*cos(azimuth_I-aspect_T))$$

Where all angles are in radians.

For more information about how hillshades work, refer to the ESRI ArcGIS page How Hillshade Works.

# function to caluclate hillshade
def hillshade(array,azimuth,angle_altitude):
    azimuth = 360.0 - azimuth 
    
    x, y = np.gradient(array)
    slope = np.pi/2. - np.arctan(np.sqrt(x*x + y*y))
    aspect = np.arctan2(-x, y)
    azm_rad = azimuth*np.pi/180. #azimuth in radians
    alt_rad = angle_altitude*np.pi/180. #altitude in radians
 
    shaded = np.sin(alt_rad)*np.sin(slope) + np.cos(alt_rad)*np.cos(slope)*np.cos((azm_rad - np.pi/2.) - aspect)
    
    return 255*(shaded + 1)/2
dtm_dataset = rio.open(dtm_file)
dtm_data = dtm_dataset.read(1)
fig, ax = plt.subplots(1, 1, figsize=(6,6))
dtm_map = show(dtm_dataset, title='Digital Terrain Model', adjust=False, cmap='terrain', ax=ax); # adjust=False prevents auto-normalization
show(dtm_dataset, adjust=False, contour=True, ax=ax); # overlay the contours
im = dtm_map.get_images()[0]
fig.colorbar(im, label = 'Elevation (m)', ax=ax) # add a colorbar
ax.ticklabel_format(useOffset=False, style='plain') # turn off scientific notation

png

Now that we have a function to generate hillshade, we need to read in the DTM raster using rasterio and then calculate hillshade using the hillshade function. We can then plot both.

# Use hillshade function on the DTM data array
hs_data = hillshade(dtm_data,225,45)
fig, ax = plt.subplots(1, 1, figsize=(6,6))
ext = [dtm_dataset.bounds.left, dtm_dataset.bounds.right, dtm_dataset.bounds.bottom, dtm_dataset.bounds.top]
plt.imshow(hs_data, extent=ext)
plt.title('TEAK Hillshade')
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

png

#Overlay transparent hillshade on DTM:
fig, ax = plt.subplots(1, 1, figsize=(6,6))
im1 = plt.imshow(dtm_data,cmap='terrain_r',extent=ext); 
cbar = plt.colorbar(); cbar.set_label('Elevation, m',rotation=270,labelpad=20)
im2 = plt.imshow(hs_data,cmap='Greys',alpha=0.8,extent=ext); #plt.colorbar()
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
plt.grid('on'); # plt.colorbar(); 
plt.title('TEAK Hillshade + DTM');

png

Calculate CHM & Overlay on Top of Hillshade

Canopy Height can be simply calculated by subtracting the Digital Terrain Model from the Digital Surface Model. While NEON's CHM is calculated using a slightly more sophisticated "pit-free" algorithm (see the ATBD linked at the top of this tutorial), in this example, we'll calculate the CHM with the simple difference formula. First, read in the DSM data set, which we previously downloaded into the data folder.

dsm_dataset = rio.open(dsm_file)
dsm_data = dsm_dataset.read(1)
# calculate CHM by differencing the terrain model (DTM) from the surface model (DSM):
chm_data = dsm_data - dtm_data;

Plot the Canopy Height Model for reference:

fig, ax = plt.subplots(1, 1, figsize=(6,6))
im1 = plt.imshow(chm_data,cmap='Greens',extent=ext); 
ax=plt.gca(); ax.ticklabel_format(useOffset=False, style='plain') #do not use scientific notation 
fig.colorbar(im1, label = 'CHM (m)', ax=ax) # add a colorbar
ax.set_title('Canopy Height Model (DSM-DTM)');

png

Finally, we can make a plot to bring together all of these visualizations from earlier in the tutorial.

#Overlay transparent hillshade on DTM:
fig, ax = plt.subplots(1, 1, figsize=(10,10))

#Terrain
im1 = plt.imshow(dtm_data,cmap='terrain',extent=ext); 
cbar1 = plt.colorbar(im1,fraction=0.04,pad=0.08,ax=ax); 
cbar1.set_label('Elevation, m',rotation=270,labelpad=20)

#Hillshade
im2 = plt.imshow(hs_data,cmap='Greys',alpha=.5,extent=ext); 

#Canopy
im3 = plt.imshow(chm_data,cmap='Greens',alpha=0.6,extent=ext); 
cbar2 = plt.colorbar(im3,fraction=0.045,pad=0.04,ax=ax); cbar2.set_label('Canopy Height, m',rotation=270,labelpad=15)

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
plt.grid('on'); # plt.colorbar(); 
plt.title('Terrain, Hillshade, & Canopy Height');

png

Get Lesson Code

create-hillshade.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.