# Import and initialize GEE
import ee
import glob
import requests
import pandas as pd
from datetime import datetime, timedelta
11 Animate image collection
When working with spatial data in the form of images spanning a period of time, animations are an effective way to showcase temporal and spatial changes across a region. Similar to a video, animations are composed of sequential frames and Google Earth Engine provides tools for creating and retrieving animations via URL. Alternatively, individual images can be downloaded for local processing, that enable the creation of animations using plotting libraries like Matplotlib. This tutorial focuses on the former approach, exploring how to leverage Google Earth Engine for creating an animation of changes in annual vegetation over the central U.S. Great Plains.
# Authenticated
#ee.Authenticate()
# Initialize
ee.Initialize()
Example 1: Animation of vegetation dynamics in the cloud
Define animation area
# Define a rectangular region over the state of Kansas
# Rectangle coordinates: xMin, yMin, xMax, yMax
= ee.Geometry.Rectangle([[-102.5,36.5], [-94,40.5]]); rect
# Select state boundary
# For countries you can use: FAO/GAUL_SIMPLIFIED_500m/2015/level1
# A site with country codes: http://www.statoids.com/wab.html
= ee.FeatureCollection("TIGER/2018/States").filter(ee.Filter.eq('NAME', 'Kansas')) region
Retrieve vegetation product
# Select a collection from the available dataset
= '2012-01-01'
start_date = '2022-01-01'
end_date = ee.ImageCollection('MODIS/006/MOD13A2').filterDate(start_date, end_date)
modis = modis.select('NDVI').map(lambda img: img.clip(region)) collection
Get day of the year for each image
In this step we define a function that is applied to each image of the collection using the map()
method. The function computes the day of the year based on the date, and adds this variable to each image as a property. Since each image of the collection remains the same, except that each image now includes the day of the year (doy
), we overwrite the collection with the updated version of itself.
# Define function
def get_doy(img):
"""
Function that finds and adds the day of the year
to each image in the collection.
"""
= ee.Date(img.get('system:time_start')).getRelative('day', 'year')
doy return img.set('doy', doy)
# Apply the function to each image of the collection using the .map() method
= collection.map(get_doy)
collection
# The `doy` is added to the properties of each image
# Use the following line to see the added property
# collection.getInfo()
# Filter the complete collection to a single year of data e.g. 2021.
# We use one year as a dummy variable to compute the day of the year.
= collection.filterDate('2021-01-01', '2022-01-01') unique_doy
# Define a filter that identifies which images from the complete collection
# match the DOY of the unique DOY variable.
# leftField == rightField
= ee.Filter.equals(leftField='doy', rightField='doy') filt
# Define a join.
= ee.Join.saveAll('doy_matches')
join
# Apply the join and convert the resulting FeatureCollection to an ImageCollection.
= ee.ImageCollection(join.apply(unique_doy, collection, filt)) join_col
Reduce all images for a given DOY pixel-wise.
# Define median reducer for images of the same DOY
def apply_median(img):
"""
Function that computes the pixel-wise median
for all images matching a given DOY
"""
= ee.ImageCollection.fromImages(img.get('doy_matches'))
doy_col return doy_col.reduce(ee.Reducer.median()).multiply(0.0001)
# Apply function for each DOY and for all images matching a given DOY
= join_col.map(apply_median) composite
Create animation
# Define function that handles the visuals (paint adds the boundary,
# which is a assigned a value (-0.1) relative to the other pixels (so a low value means red here).
def animate(img):
= ['black','FFFFFF', 'CE7E45', 'DF923D', 'F1B555', 'FCD163', '99B718', '74A901',
cmap '66A000', '529400', '3E8601', '207401', '056201', '004C00', '023B01',
'012E01', '011D01', '011301']
= img.paint(region, -0.1, 2).visualize(min=-0.1, max=0.8, palette=cmap)
frame return frame
# Map the function to each image
= composite.map(animate) animation
# Animation options
= {'region': rect, # Selected region on the map
animationOptions 'dimensions': 600, # Size of the animation
'crs': 'EPSG:3857', # Coordinate reference system
'framesPerSecond': 6 # Animation speed
}
# Render the GIF animation in the console.
print(animation.getVideoThumbURL(animationOptions))
https://earthengine.googleapis.com/v1alpha/projects/earthengine-legacy/videoThumbnails/dab1527ad8bdb39bc2937c68ffaa4f5a-444f7489b454a5e458a7b3773c598bee:getPixels
# Right click on the generated GIF image in the browser and select "save image as" to download it.
Example 2: Animation of vegetation dynamics in local disk
This option requires downloading the images to the local drive and creating the animation ourselves, but it provides with the greatest flexibilty to edit the resulting animation.
# Import additional modules
import xarray as xr
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from matplotlib import colors
# Define function to save images to the local drive
def save_geotiff(ee_image, filename, crs, scale, geom, bands=[]):
"""
Function to save images from Google Earth Engine into local hard drive.
"""
= ee_image.getDownloadUrl({'region': geom,'scale':scale,
image_url 'bands': bands,
'crs': f'EPSG:{crs}',
'format': 'GEO_TIFF'})
# Request data using URL and save data as a new GeoTiff file
= requests.get(image_url)
response with open(filename, 'wb') as f:
f.write(response.content)return print(f"Saved image {filename}")
# Select
= ee.FeatureCollection("TIGER/2018/States").filter(ee.Filter.eq('NAME', 'Kansas'))
region
# Create mask
= ee.Image.constant(1).clip(region).mask() mask
# Define the time range
= '2022-01-01'
start_date = '2022-12-31'
end_date
# Select MODIS Terra Vegetation Indices 16-Day Global 1km
= ee.ImageCollection("MODIS/061/MOD13A2").filterDate(start_date, end_date)
modis = modis.select('NDVI')
collection
# Get the list of available image dates
= lambda image: ee.Image(image).date().format('YYYY-MM-dd')
get_date
= collection.toList(collection.size()).map(get_date).getInfo()
dates print(dates)
['2022-01-01', '2022-01-17', '2022-02-02', '2022-02-18', '2022-03-06', '2022-03-22', '2022-04-07', '2022-04-23', '2022-05-09', '2022-05-25', '2022-06-10', '2022-06-26', '2022-07-12', '2022-07-28', '2022-08-13', '2022-08-29', '2022-09-14', '2022-09-30', '2022-10-16', '2022-11-01', '2022-11-17', '2022-12-03', '2022-12-19']
= '../outputs/ndvi_gif_files'
gif_folder if not glob.os.path.isdir(gif_folder):
glob.os.mkdir(gif_folder)
for date in dates:
= date
start_date = (datetime.strptime(start_date, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
end_date = ee.ImageCollection('MODIS/006/MOD13A2').filterDate(start_date, end_date).first()
ndvi_img = ndvi_img.multiply(0.0001).clip(region).mask(mask)
ndvi_img
= f"{gif_folder}/ndvi_{date}.tiff"
filename try:
=4326, scale=1000, geom=region.geometry(), bands=['NDVI'])
save_geotiff(ndvi_img, filename, crsexcept:
print(f"Trouble loading image {filename}. Skipping this image.")
Saved image ../outputs/ndvi_gif_files/ndvi_2022-01-01.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-01-17.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-02-02.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-02-18.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-03-06.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-03-22.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-04-07.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-04-23.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-05-09.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-05-25.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-06-10.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-06-26.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-07-12.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-07-28.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-08-13.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-08-29.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-09-14.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-09-30.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-10-16.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-11-01.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-11-17.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-12-03.tiff
Saved image ../outputs/ndvi_gif_files/ndvi_2022-12-19.tiff
In the method .filterDate(start_date, end_date)
the start date is inclusive, but the end date is exclusive.
# Read the list of images
= glob.glob(f"{gif_folder}/*.tiff")
images images.sort()
# Paletter of colors for the Enhanced Vegetation Index
= ['#FF69B4','#CE7E45', '#DF923D', '#F1B555', '#FCD163', '#99B718', '#74A901',
hex_palette '#66A000', '#529400', '#3E8601', '#207401', '#056201', '#004C00', '#023B01',
'#012E01', '#011D01', '#011301']
# Use the built-in ListedColormap function to do the conversion
= colors.ListedColormap(hex_palette) cmap
The first color of the palette is hot pink (‘#FF69B4’). The color was added to represent the lowest NDVI values, which are typically caused by snow on the ground during winter months.
# Create figure
= plt.subplots(figsize=(6,3))
fig, ax
# Leave a bit more room at the bottom to avoid cutting the xlabel
=0.15)
fig.subplots_adjust(bottom
# Create figure with axes and colorbar, which will remain fixed.
= xr.open_dataarray(images[0]).squeeze()
raster =ax, cmap=cmap, add_colorbar=True,
raster.plot.imshow(ax={'label':'NDVI'},vmin=0, vmax=0.8)
cbar_kwargs
def animate(index):
"""
Function that creates each frame.
"""
# Read geotiff image with xarray
= xr.open_dataarray(images[index]).squeeze()
raster
# Clear axes and draw new objects (without colorbar)
# Force vmin and vmax to keep the same range of values as the colorbar
ax.clear()=ax, cmap=cmap, add_colorbar=False, vmin=0, vmax=0.8)
raster.plot.imshow(ax-15:-5])
ax.set_title(images[index]['Longitude')
ax.set_xlabel('Latitude')
ax.set_ylabel(
plt.tight_layout()
return ax
# Avoid displaying the first figure
plt.close()
# Save animation as .gif
= animation.FuncAnimation(fig, animate, len(images),interval=250)
ani '../outputs/ndvi_animation.gif', writer='pillow') ani.save(
<Figure size 640x480 with 0 Axes>
Here is the resulting gif. Note that during the winter the image occasionally shows some areas with snow on the ground (look for reddish patches). You can display it in your notebook using the following html code:
<img src="../outputs/ndvi_animation.gif" alt="drawing" width="650"/>
Example 3: Soil moisture dynamics
# Since the product is available every 3 hours, define one month only
# to avoid running hitting the GEE memory limit
= '2023-01-01'
start_date = '2023-01-31'
end_date
# Select SMAP Level 3 layer at 9-km spatial resolution
= ee.ImageCollection('NASA/SMAP/SPL4SMGP/007')
smap
# Get the list of available image dates
= lambda image: ee.Image(image).date().format('YYYY-MM-dd HH:mm:SS')
get_date
= smap.filterDate(start_date, end_date)
collection = collection.toList(collection.size()).map(get_date).getInfo()
dates print(len(dates))
print(dates[0:10])
240
['2023-01-01 01:30:00', '2023-01-01 04:30:00', '2023-01-01 07:30:00', '2023-01-01 10:30:00', '2023-01-01 13:30:00', '2023-01-01 16:30:00', '2023-01-01 19:30:00', '2023-01-01 22:30:00', '2023-01-02 01:30:00', '2023-01-02 04:30:00']
= '../outputs/smap_gif_files'
smap_gif_folder if not glob.os.path.isdir(smap_gif_folder):
glob.os.mkdir(smap_gif_folder)
# Use pandas to create range of dates
= pd.date_range('2023-01-01', '2023-12-31', freq='7D')
dates dates
DatetimeIndex(['2023-01-01', '2023-01-08', '2023-01-15', '2023-01-22',
'2023-01-29', '2023-02-05', '2023-02-12', '2023-02-19',
'2023-02-26', '2023-03-05', '2023-03-12', '2023-03-19',
'2023-03-26', '2023-04-02', '2023-04-09', '2023-04-16',
'2023-04-23', '2023-04-30', '2023-05-07', '2023-05-14',
'2023-05-21', '2023-05-28', '2023-06-04', '2023-06-11',
'2023-06-18', '2023-06-25', '2023-07-02', '2023-07-09',
'2023-07-16', '2023-07-23', '2023-07-30', '2023-08-06',
'2023-08-13', '2023-08-20', '2023-08-27', '2023-09-03',
'2023-09-10', '2023-09-17', '2023-09-24', '2023-10-01',
'2023-10-08', '2023-10-15', '2023-10-22', '2023-10-29',
'2023-11-05', '2023-11-12', '2023-11-19', '2023-11-26',
'2023-12-03', '2023-12-10', '2023-12-17', '2023-12-24',
'2023-12-31'],
dtype='datetime64[ns]', freq='7D')
# Only use weekly moisture levels to avoid retrieving tons of images
for date in dates:
= date.strftime('%Y-%m-%d')
start_date = (date + pd.Timedelta('1D')).strftime('%Y-%m-%d')
end_date
# Request data and create average of all images for that day
= smap.filterDate(start_date, end_date) \
smap_img reduce(ee.Reducer.mean()).multiply(100).clip(region).mask(mask)
.
try:
# Creaoutput file name
= f"{smap_gif_folder}/smap_{start_date}.tiff"
filename
# Note that the band name has `mean` appended since that is the reducer we used
# I also donwscaled the map to 1 km resolution from 9 km
=4326, scale=9000,
save_geotiff(smap_img, filename, crs=region.geometry(), bands=['sm_profile_mean'])
geom
except:
print(f"Trouble loading image {filename}. Skipping this image.")
In the method .filterDate(start_date, end_date)
the start date is inclusive, but the end date is exclusive.
# Read the list of images
= glob.glob(f"{smap_gif_folder}/*.tiff")
images images.sort()
# Define colormap
= 'Spectral'
cmap
# Create figure
= plt.subplots(figsize=(6,3))
fig, ax
# Leave a bit more room at the bottom to avoid cutting the xlabel
=0.15)
fig.subplots_adjust(bottom
# Create figure with axes and colorbar, which will remain fixed.
= xr.open_dataarray(images[0]).squeeze()
raster =ax, cmap=cmap, add_colorbar=True,
raster.plot.imshow(ax={'label':'VWC (%)'}, vmin=0, vmax=40)
cbar_kwargs
def animate(index):
"""
Function that creates each frame.
"""
# Read geotiff image with xarray
= xr.open_dataarray(images[index]).squeeze()
raster
# Clear axes and draw new objects (without colorbar)
# Force vmin and vmax to keep the same range of values as the colorbar
ax.clear()=ax, cmap=cmap, add_colorbar=False, vmin=0, vmax=40)
raster.plot.imshow(ax-15:-5])
ax.set_title(images[index]['Longitude')
ax.set_xlabel('Latitude')
ax.set_ylabel(
plt.tight_layout()
return ax
# Avoid displaying the first figure
plt.close()
# Save animation as .gif
= animation.FuncAnimation(fig, animate, len(images), interval=250)
ani '../outputs/smap_animation.gif', writer='pillow') ani.save(
<Figure size 640x480 with 0 Axes>