Files
CSIROBoeingPhase5-Vietnam/deafrica_tools/plotting.py
T
2024-08-28 05:10:02 +00:00

1273 lines
50 KiB
Python

"""
Functions for plotting Digital Earth Africa data.
"""
# Import required packages
# Force GeoPandas to use Shapely instead of PyGEOS
# In a future release, GeoPandas will switch to using Shapely by default.
import os
os.environ['USE_PYGEOS'] = '0'
import math
import folium
import ipywidgets
import branca
import numpy as np
import geopandas as gpd
import matplotlib as mpl
import matplotlib.patheffects as PathEffects
import matplotlib.pyplot as plt
import matplotlib.animation as animation
from datetime import datetime
import matplotlib.cm as cm
from matplotlib import colors as mcolours
from pyproj import Transformer
from IPython.display import display
from matplotlib.colors import ListedColormap
from mpl_toolkits.axes_grid1.inset_locator import inset_axes
from mpl_toolkits.axes_grid1 import make_axes_locatable
from ipyleaflet import Map, Marker, Popup, GeoJSON, basemaps, Choropleth
from skimage import exposure
from branca.colormap import linear
from odc.ui import image_aspect
from matplotlib.animation import FuncAnimation
import pandas as pd
from pathlib import Path
from shapely.geometry import box
from skimage.exposure import rescale_intensity
from tqdm.auto import tqdm
import warnings
def rgb(
ds,
bands=["red", "green", "blue"],
index=None,
index_dim="time",
robust=True,
percentile_stretch=None,
col_wrap=4,
size=6,
aspect=None,
savefig_path=None,
savefig_kwargs={},
**kwargs,
):
"""
Takes an xarray dataset and plots RGB images using three imagery
bands (e.g ['red', 'green', 'blue']). The `index`
parameter allows easily selecting individual or multiple images for
RGB plotting. Images can be saved to file by specifying an output
path using `savefig_path`.
This function was designed to work as an easier-to-use wrapper
around xarray's `.plot.imshow()` functionality.
Last modified: April 2021
Parameters
----------
ds : xarray Dataset
A two-dimensional or multi-dimensional array to plot as an RGB
image. If the array has more than two dimensions (e.g. multiple
observations along a 'time' dimension), either use `index` to
select one (`index=0`) or multiple observations
(`index=[0, 1]`), or create a custom faceted plot using e.g.
`col="time"`.
bands : list of strings, optional
A list of three strings giving the band names to plot. Defaults
to '['red', 'green', 'blue']'. If the dataset does not contain
bands named `'red', 'green', 'blue'`, then `bands` must be
specified.
index : integer or list of integers, optional
`index` can be used to select one (`index=0`) or multiple
observations (`index=[0, 1]`) from the input dataset for
plotting. If multiple images are requested these will be plotted
as a faceted plot.
index_dim : string, optional
The dimension along which observations should be plotted if
multiple observations are requested using `index`. Defaults to
`time`.
robust : bool, optional
Produces an enhanced image where the colormap range is computed
with 2nd and 98th percentiles instead of the extreme values.
Defaults to True.
percentile_stretch : tuple of floats
An tuple of two floats (between 0.00 and 1.00) that can be used
to clip the colormap range to manually specified percentiles to
get more control over the brightness and contrast of the image.
The default is None; '(0.02, 0.98)' is equivelent to
`robust=True`. If this parameter is used, `robust` will have no
effect.
col_wrap : integer, optional
The number of columns allowed in faceted plots. Defaults to 4.
size : integer, optional
The height (in inches) of each plot. Defaults to 6.
aspect : integer, optional
Aspect ratio of each facet in the plot, so that aspect * size
gives width of each facet in inches. Defaults to None, which
will calculate the aspect based on the x and y dimensions of
the input data.
savefig_path : string, optional
Path to export image file for the RGB plot. Defaults to None,
which does not export an image file.
savefig_kwargs : dict, optional
A dict of keyword arguments to pass to
`matplotlib.pyplot.savefig` when exporting an image file. For
all available options, see:
https://matplotlib.org/api/_as_gen/matplotlib.pyplot.savefig.html
**kwargs : optional
Additional keyword arguments to pass to `xarray.plot.imshow()`.
For example, the function can be used to plot into an existing
matplotlib axes object by passing an `ax` keyword argument.
For more options, see:
http://xarray.pydata.org/en/stable/generated/xarray.plot.imshow.html
Returns
-------
An RGB plot of one or multiple observations, and optionally an image
file written to file.
"""
# If bands are not in the dataset
ds_vars = list(ds.data_vars)
if set(bands).issubset(ds_vars) == False:
raise ValueError(
"rgb() bands do not match band names in dataset. "
"Note the default rgb() bands are ['red', 'green', 'blue']."
)
# If ax is supplied via kwargs, ignore aspect and size
if "ax" in kwargs:
# Create empty aspect size kwarg that will be passed to imshow
aspect_size_kwarg = {}
else:
# Compute image aspect
if not aspect:
aspect = image_aspect(ds)
# Populate aspect size kwarg with aspect and size data
aspect_size_kwarg = {"aspect": aspect, "size": size}
# If no value is supplied for `index` (the default), plot using default
# values and arguments passed via `**kwargs`
if index is None:
# Select bands and convert to DataArray
da = ds[bands].to_array()
# If percentile_stretch == True, clip plotting to percentile vmin, vmax
if percentile_stretch:
vmin, vmax = da.compute().quantile(percentile_stretch).values
kwargs.update({"vmin": vmin, "vmax": vmax})
# If there are more than three dimensions and the index dimension == 1,
# squeeze this dimension out to remove it
if (len(ds.dims) > 2) and ("col" not in kwargs) and (len(da[index_dim]) == 1):
da = da.squeeze(dim=index_dim)
# If there are more than three dimensions and the index dimension
# is longer than 1, raise exception to tell user to use 'col'/`index`
elif (len(ds.dims) > 2) and ("col" not in kwargs) and (len(da[index_dim]) > 1):
raise Exception(
f"The input dataset `ds` has more than two dimensions: "
"{list(ds.dims.keys())}. Please select a single observation "
"using e.g. `index=0`, or enable faceted plotting by adding "
'the arguments e.g. `col="time", col_wrap=4` to the function '
"call"
)
da = da.compute()
img = da.plot.imshow(
robust=robust, col_wrap=col_wrap, **aspect_size_kwarg, **kwargs
)
# If values provided for `index`, extract corresponding observations and
# plot as either single image or facet plot
else:
# If a float is supplied instead of an integer index, raise exception
if isinstance(index, float):
raise Exception(
f"Please supply `index` as either an integer or a list of " "integers"
)
# If col argument is supplied as well as `index`, raise exception
if "col" in kwargs:
raise Exception(
f"Cannot supply both `index` and `col`; please remove one and "
"try again"
)
# Convert index to generic type list so that number of indices supplied
# can be computed
index = index if isinstance(index, list) else [index]
# Select bands and observations and convert to DataArray
da = ds[bands].isel(**{index_dim: index}).to_array().compute()
# If percentile_stretch == True, clip plotting to percentile vmin, vmax
if percentile_stretch:
vmin, vmax = da.compute().quantile(percentile_stretch).values
kwargs.update({"vmin": vmin, "vmax": vmax})
# If multiple index values are supplied, plot as a faceted plot
if len(index) > 1:
img = da.plot.imshow(
robust=robust,
col=index_dim,
col_wrap=col_wrap,
**aspect_size_kwarg,
**kwargs,
)
# If only one index is supplied, squeeze out index_dim and plot as a
# single panel
else:
img = da.squeeze(dim=index_dim).plot.imshow(
robust=robust, **aspect_size_kwarg, **kwargs
)
# If an export path is provided, save image to file. Individual and
# faceted plots have a different API (figure vs fig) so we get around this
# using a try statement:
if savefig_path:
print(f"Exporting image to {savefig_path}")
try:
img.fig.savefig(savefig_path, **savefig_kwargs)
except:
img.figure.savefig(savefig_path, **savefig_kwargs)
def display_map(x, y, crs="EPSG:4326", margin=-0.5, zoom_bias=0):
"""
Given a set of x and y coordinates, this function generates an
interactive map with a bounded rectangle overlayed on Google Maps
imagery.
Last modified: September 2019
Modified from function written by Otto Wagner available here:
https://github.com/ceos-seo/data_cube_utilities/tree/master/data_cube_utilities
Parameters
----------
x : (float, float)
A tuple of x coordinates in (min, max) format.
y : (float, float)
A tuple of y coordinates in (min, max) format.
crs : string, optional
A string giving the EPSG CRS code of the supplied coordinates.
The default is 'EPSG:4326'.
margin : float
A numeric value giving the number of degrees lat-long to pad
the edges of the rectangular overlay polygon. A larger value
results more space between the edge of the plot and the sides
of the polygon. Defaults to -0.5.
zoom_bias : float or int
A numeric value allowing you to increase or decrease the zoom
level by one step. Defaults to 0; set to greater than 0 to zoom
in, and less than 0 to zoom out.
Returns
-------
folium.Map : A map centered on the supplied coordinate bounds. A
rectangle is drawn on this map detailing the perimeter of the x, y
bounds. A zoom level is calculated such that the resulting
viewport is the closest it can possibly get to the centered
bounding rectangle without clipping it.
"""
# Convert each corner coordinates to lat-lon
all_x = (x[0], x[1], x[0], x[1])
all_y = (y[0], y[0], y[1], y[1])
transformer = Transformer.from_crs(crs, "EPSG:4326")
all_longitude, all_latitude = transformer.transform(all_x, all_y)
# Calculate zoom level based on coordinates
lat_zoom_level = (
_degree_to_zoom_level(min(all_latitude), max(all_latitude), margin=margin)
+ zoom_bias
)
lon_zoom_level = (
_degree_to_zoom_level(min(all_longitude), max(all_longitude), margin=margin)
+ zoom_bias
)
zoom_level = min(lat_zoom_level, lon_zoom_level)
# Identify centre point for plotting
center = [np.mean(all_latitude), np.mean(all_longitude)]
# Create map
interactive_map = folium.Map(
location=center,
zoom_start=zoom_level,
tiles="http://mt1.google.com/vt/lyrs=y&z={z}&x={x}&y={y}",
attr="Google",
)
# Create bounding box coordinates to overlay on map
line_segments = [
(all_latitude[0], all_longitude[0]),
(all_latitude[1], all_longitude[1]),
(all_latitude[3], all_longitude[3]),
(all_latitude[2], all_longitude[2]),
(all_latitude[0], all_longitude[0]),
]
# Add bounding box as an overlay
interactive_map.add_child(
folium.features.PolyLine(locations=line_segments, color="red", opacity=0.8)
)
# Add clickable lat-lon popup box
interactive_map.add_child(folium.features.LatLngPopup())
return interactive_map
def map_shapefile(
gdf,
attribute,
continuous=False,
cmap="viridis",
basemap=basemaps.Esri.WorldImagery,
default_zoom=None,
hover_col=True,
**style_kwargs,
):
"""
Plots a geopandas GeoDataFrame over an interactive ipyleaflet
basemap, with features coloured based on attribute column values.
Optionally, can be set up to print selected data from features in
the GeoDataFrame.
Last modified: February 2020
Parameters
----------
gdf : geopandas.GeoDataFrame
A GeoDataFrame containing the spatial features to be plotted
over the basemap.
attribute: string, required
An required string giving the name of any column in the
GeoDataFrame you wish to have coloured on the choropleth.
continuous: boolean, optional
Whether to plot data as a categorical or continuous variable.
Defaults to remapping the attribute which is suitable for
categorical data. For continuous data set `continuous` to True.
cmap : string, optional
A string giving the name of a `matplotlib.cm` colormap that will
be used to style the features in the GeoDataFrame. Features will
be coloured based on the selected attribute. Defaults to the
'viridis' colormap.
basemap : ipyleaflet.basemaps object, optional
An optional `ipyleaflet.basemaps` object used as the basemap for
the interactive plot. Defaults to `basemaps.Esri.WorldImagery`.
default_zoom : int, optional
An optional integer giving a default zoom level for the
interactive ipyleaflet plot. Defaults to None, which infers
the zoom level from the extent of the data.
hover_col : boolean or str, optional
If True (the default), the function will print values from the
GeoDataFrame's `attribute` column above the interactive map when
a user hovers over the features in the map. Alternatively, a
custom shapefile field can be specified by supplying a string
giving the name of the field to print. Set to False to prevent
any attributes from being printed.
**style_kwargs :
Optional keyword arguments to pass to the `style` paramemter of
the `ipyleaflet.Choropleth` function. This can be used to
control the appearance of the shapefile, for example 'stroke'
and 'weight' (controlling line width), 'fillOpacity' (polygon
transparency) and 'dashArray' (whether to plot lines/outlines
with dashes). For more information:
https://ipyleaflet.readthedocs.io/en/latest/api_reference/choropleth.html
"""
def on_hover(event, id, properties):
with dbg:
text = properties.get(hover_col, "???")
lbl.value = f"{hover_col}: {text}"
# Verify that attribute exists in shapefile
if attribute not in gdf.columns:
raise ValueError(
f"The `attribute` {attribute} does not exist "
f"in the geopandas.GeoDataFrame. "
f"Valid attributes include {gdf.columns.values}."
)
# If hover_col is True, use 'attribute' as the default hover attribute.
# Otherwise, hover_col will use the supplied attribute field name
if hover_col and (hover_col is True):
hover_col = attribute
# If a custom string if supplied to hover_col, check this exists
elif hover_col and (type(hover_col) == str):
if hover_col not in gdf.columns:
raise ValueError(
f"The `hover_col` field {hover_col} does "
f"not exist in the geopandas.GeoDataFrame. "
f"Valid attributes include "
f"{gdf.columns.values}."
)
# Convert to WGS 84 and GeoJSON format
gdf_wgs84 = gdf.to_crs(epsg=4326)
data_geojson = gdf_wgs84.__geo_interface__
# If continuous is False, remap categorical classes for visualisation
if not continuous:
# Zip classes data together to make a dictionary
classes_uni = list(gdf[attribute].unique())
classes_clean = list(range(0, len(classes_uni)))
classes_dict = dict(zip(classes_uni, classes_clean))
# Get values to colour by as a list
classes = gdf[attribute].map(classes_dict).tolist()
# If continuous is True then do not remap
else:
# Get values to colour by as a list
classes = gdf[attribute].tolist()
# Create the dictionary to colour map by
keys = gdf.index
id_class_dict = dict(zip(keys.astype(str), classes))
# Get centroid to focus map on
lon1, lat1, lon2, lat2 = gdf_wgs84.total_bounds
lon = (lon1 + lon2) / 2
lat = (lat1 + lat2) / 2
if default_zoom is None:
# Calculate default zoom from latitude of features
default_zoom = _degree_to_zoom_level(lat1, lat2, margin=-0.5)
# Plot map
m = Map(
center=(lat, lon),
zoom=default_zoom,
basemap=basemap,
layout=dict(width="800px", height="600px"),
)
# Define default plotting parameters for the choropleth map.
# The nested dict structure sets default values which can be
# overwritten/customised by `choropleth_kwargs` values
style_kwargs = dict({"fillOpacity": 0.8}, **style_kwargs)
# Get `branca.colormap` object from matplotlib string
cm_cmap = cm.get_cmap(cmap, 30)
colormap = branca.colormap.LinearColormap(
[cm_cmap(i) for i in np.linspace(0, 1, 30)]
)
# Create the choropleth
choropleth = Choropleth(
geo_data=data_geojson,
choro_data=id_class_dict,
colormap=colormap,
style=style_kwargs,
)
# If the vector data contains line features, they will not be
# be coloured by default. To resolve this, we need to manually copy
# across the 'fillColor' attribute to the 'color' attribute for each
# feature, then plot the data as a GeoJSON layer rather than the
# choropleth layer that we use for polygon data.
linefeatures = any(
x in ["LineString", "MultiLineString"] for x in gdf.geometry.type.values
)
if linefeatures:
# Copy colour from fill to line edge colour
for i in keys:
choropleth.data["features"][i]["properties"]["style"][
"color"
] = choropleth.data["features"][i]["properties"]["style"]["fillColor"]
# Add GeoJSON layer to map
feature_layer = GeoJSON(data=choropleth.data, style=style_kwargs)
m.add_layer(feature_layer)
else:
# Add Choropleth layer to map
m.add_layer(choropleth)
# If a column is specified by `hover_col`, print data from the
# hovered feature above the map
if hover_col and not linefeatures:
# Use cholopleth object if data is polygon
lbl = ipywidgets.Label()
dbg = ipywidgets.Output()
choropleth.on_hover(on_hover)
display(lbl)
else:
lbl = ipywidgets.Label()
dbg = ipywidgets.Output()
feature_layer.on_hover(on_hover)
display(lbl)
# Display the map
display(m)
def xr_animation(
ds,
bands=None,
output_path="animation.mp4",
width_pixels=500,
interval=100,
percentile_stretch=(0.02, 0.98),
image_proc_funcs=None,
show_gdf=None,
show_date="%d %b %Y",
show_text=None,
show_colorbar=True,
gdf_kwargs={},
annotation_kwargs={},
imshow_kwargs={},
colorbar_kwargs={},
limit=None,
):
"""
Takes an `xarray` timeseries and animates the data as either a
three-band (e.g. true or false colour) or single-band animation,
allowing changes in the landscape to be compared across time.
Animations can be customised to include text and date annotations
or use specific combinations of input bands. Vector data can be
overlaid and animated on top of imagery, and custom image
processing functions can be applied to each frame.
Supports .mp4 (ideal for Twitter/social media) and .gif (ideal
for all purposes, but can have large file sizes) format files.
Last modified: October 2020
Parameters
----------
ds : xarray.Dataset
An xarray dataset with multiple time steps (i.e. multiple
observations along the `time` dimension).
bands : list of strings
An list of either one or three band names to be plotted,
all of which must exist in `ds`.
output_path : str, optional
A string giving the output location and filename of the
resulting animation. File extensions of '.mp4' and '.gif' are
accepted. Defaults to 'animation.mp4'.
width_pixels : int, optional
An integer defining the output width in pixels for the
resulting animation. The height of the animation is set
automatically based on the dimensions/ratio of the input
xarray dataset. Defaults to 500 pixels wide.
interval : int, optional
An integer defining the milliseconds between each animation
frame used to control the speed of the output animation. Higher
values result in a slower animation. Defaults to 100
milliseconds between each frame.
percentile_stretch : tuple of floats, optional
An optional tuple of two floats that can be used to clip one or
three-band arrays by percentiles to produce a more vibrant,
visually attractive image that is not affected by outliers/
extreme values. The default is `(0.02, 0.98)` which is
equivalent to xarray's `robust=True`. This parameter is ignored
completely if `vmin` and `vmax` are provided as kwargs to
`imshow_kwargs`.
image_proc_funcs : list of funcs, optional
An optional list containing functions that will be applied to
each animation frame (timestep) prior to animating. This can
include image processing functions such as increasing contrast,
unsharp masking, saturation etc. The function should take AND
return a `numpy.ndarray` with shape [y, x, bands]. If your
function has parameters, you can pass in custom values using
a lambda function:
`image_proc_funcs=[lambda x: custom_func(x, param1=10)]`.
show_gdf: geopandas.GeoDataFrame, optional
Vector data (e.g. ESRI shapefiles or GeoJSON) can be optionally
plotted over the top of imagery by supplying a
`geopandas.GeoDataFrame` object. To customise colours used to
plot the vector features, create a new column in the
GeoDataFrame called 'colors' specifying the colour used to plot
each feature: e.g. `gdf['colors'] = 'red'`.
To plot vector features at specific moments in time during the
animation, create new 'start_time' and/or 'end_time' columns in
the GeoDataFrame that define the time range used to plot each
feature. Dates can be provided in any string format that can be
converted using the `pandas.to_datetime()`. e.g.
`gdf['end_time'] = ['2001', '2005-01', '2009-01-01']`
show_date : string or bool, optional
An optional string or bool that defines how (or if) to plot
date annotations for each animation frame. Defaults to
'%d %b %Y'; can be customised to any format understood by
strftime (https://strftime.org/). Set to False to remove date
annotations completely.
show_text : str or list of strings, optional
An optional string or list of strings with a length equal to
the number of timesteps in `ds`. This can be used to display a
static text annotation (using a string), or a dynamic title
(using a list) that displays different text for each timestep.
By default, no text annotation will be plotted.
show_colorbar : bool, optional
An optional boolean indicating whether to include a colourbar
for single-band animations. Defaults to True.
gdf_kwargs : dict, optional
An optional dictionary of keyword arguments to customise the
appearance of a `geopandas.GeoDataFrame` supplied to
`show_gdf`. Keyword arguments are passed to `GeoSeries.plot`
(see http://geopandas.org/reference.html#geopandas.GeoSeries.plot).
For example: `gdf_kwargs = {'linewidth': 2}`.
annotation_kwargs : dict, optional
An optional dict of keyword arguments for controlling the
appearance of text annotations. Keyword arguments are passed
to `plt.annotate` from `matplotlib`.
(see https://matplotlib.org/api/_as_gen/matplotlib.pyplot.annotate.html
for options). For example,
`annotation_kwargs={'fontsize':20, 'color':'red', 'family':'serif'}`.
imshow_kwargs : dict, optional
An optional dict of keyword arguments for controlling the
appearance of arrays passed to `matplotlib`'s `plt.imshow`
(see https://matplotlib.org/api/_as_gen/matplotlib.pyplot.imshow.html
for options). For example, a green colour scheme and custom
stretch could be specified using:
`onebandplot_kwargs={'cmap':'Greens`, 'vmin':0.2, 'vmax':0.9}`.
(some parameters like 'cmap' will only have an effect for
single-band animations, not three-band RGB animations).
colorbar_kwargs : dict, optional
An optional dict of keyword arguments used to control the
appearance of the colourbar. Keyword arguments are passed to
`matplotlib.pyplot.tick_params`
(see https://matplotlib.org/api/_as_gen/matplotlib.pyplot.tick_params.html
for options). This can be used to customise the colourbar
ticks, e.g. changing tick label colour depending on the
background of the animation:
`colorbar_kwargs={'colors': 'black'}`.
limit: int, optional
An optional integer specifying how many animation frames to
render (e.g. `limit=50` will render the first 50 frames). This
can be useful for quickly testing animations without rendering
the entire time-series.
"""
def _start_end_times(gdf, ds):
"""
Converts 'start_time' and 'end_time' columns in a
`geopandas.GeoDataFrame` to datetime objects to allow vector
features to be plotted at specific moments in time during an
animation, and sets default values based on the first
and last time in `ds` if this information is missing from the
dataset.
"""
# Make copy of gdf so we do not modify original data
gdf = gdf.copy()
# Get min and max times from input dataset
minmax_times = pd.to_datetime(ds.time.isel(time=[0, -1]).values)
# Update both `start_time` and `end_time` columns
for time_col, time_val in zip(["start_time", "end_time"], minmax_times):
# Add time_col if it does not exist
if time_col not in gdf:
gdf[time_col] = np.nan
# Convert values to datetimes and fill gaps with relevant time value
gdf[time_col] = pd.to_datetime(gdf[time_col], errors="ignore")
gdf[time_col] = gdf[time_col].fillna(time_val)
return gdf
def _add_colorbar(fig, ax, vmin, vmax, imshow_defaults, colorbar_defaults):
"""
Adds a new colorbar axis to the animation with custom minimum
and maximum values and styling.
"""
# Create new axis object for colorbar
cax = fig.add_axes([0.02, 0.02, 0.96, 0.03])
# Initialise color bar using plot min and max values
img = ax.imshow(np.array([[vmin, vmax]]), **imshow_defaults)
fig.colorbar(
img, cax=cax, orientation="horizontal", ticks=np.linspace(vmin, vmax, 2)
)
# Fine-tune appearance of colorbar
cax.xaxis.set_ticks_position("top")
cax.tick_params(axis="x", **colorbar_defaults)
cax.get_xticklabels()[0].set_horizontalalignment("left")
cax.get_xticklabels()[-1].set_horizontalalignment("right")
def _frame_annotation(times, show_date, show_text):
"""
Creates a custom annotation for the top-right of the animation
by converting a `xarray.DataArray` of times into strings, and
combining this with a custom text annotation. Handles cases
where `show_date=False/None`, `show_text=False/None`, or where
`show_text` is a list of strings.
"""
# Test if show_text is supplied as a list
is_sequence = isinstance(show_text, (list, tuple, np.ndarray))
# Raise exception if it is shorter than number of dates
if is_sequence and (len(show_text) == 1):
show_text, is_sequence = show_text[0], False
elif is_sequence and (len(show_text) < len(times)):
raise ValueError(
f"Annotations supplied via `show_text` must have "
f"either a length of 1, or a length >= the number "
f"of timesteps in `ds` (n={len(times)})"
)
times_list = (
times.dt.strftime(show_date).values if show_date else [None] * len(times)
)
text_list = show_text if is_sequence else [show_text] * len(times)
annotation_list = [
"\n".join([str(i) for i in (a, b) if i])
for a, b in zip(times_list, text_list)
]
return annotation_list
def _update_frames(
i,
ax,
extent,
annotation_text,
gdf,
gdf_defaults,
annotation_defaults,
imshow_defaults,
):
"""
Animation called by `matplotlib.animation.FuncAnimation` to
animate each frame in the animation. Plots array and any text
annotations, as well as a temporal subset of `gdf` data based
on the times specified in 'start_time' and 'end_time' columns.
"""
# Clear previous frame to optimise render speed and plot imagery
ax.clear()
ax.imshow(
array[i, ...].clip(0.0, 1.0),
extent=extent,
vmin=0.0,
vmax=1.0,
**imshow_defaults,
)
# Add annotation text
ax.annotate(annotation_text[i], **annotation_defaults)
# Add geodataframe annotation
if show_gdf is not None:
# Obtain start and end times to filter geodataframe features
time_i = ds.time.isel(time=i).values
# Subset geodataframe using start and end dates
gdf_subset = show_gdf.loc[
(show_gdf.start_time <= time_i) & (show_gdf.end_time >= time_i)
]
if len(gdf_subset.index) > 0:
# Set color to geodataframe field if supplied
if ("color" in gdf_subset) and ("color" not in gdf_kwargs):
gdf_defaults.update({"color": gdf_subset["color"].tolist()})
gdf_subset.plot(ax=ax, **gdf_defaults)
# Remove axes to show imagery only
ax.axis("off")
# Update progress bar
progress_bar.update(1)
# Test if bands have been supplied, or convert to list to allow
# iteration if a single band is provided as a string
if bands is None:
raise ValueError(
f"Please use the `bands` parameter to supply "
f"a list of one or three bands that exist as "
f"variables in `ds`, e.g. {list(ds.data_vars)}"
)
elif isinstance(bands, str):
bands = [bands]
# Test if bands exist in dataset
missing_bands = [b for b in bands if b not in ds.data_vars]
if missing_bands:
raise ValueError(
f"Band(s) {missing_bands} do not exist as "
f"variables in `ds` {list(ds.data_vars)}"
)
# Test if time dimension exists in dataset
if "time" not in ds.dims:
raise ValueError(
f"`ds` does not contain a 'time' dimension "
f"required for generating an animation"
)
# Set default parameters
outline = [PathEffects.withStroke(linewidth=2.5, foreground="black")]
annotation_defaults = {
"xy": (1, 1),
"xycoords": "axes fraction",
"xytext": (-5, -5),
"textcoords": "offset points",
"horizontalalignment": "right",
"verticalalignment": "top",
"fontsize": 20,
"color": "white",
"path_effects": outline,
}
imshow_defaults = {"cmap": "magma", "interpolation": "nearest"}
colorbar_defaults = {"colors": "white", "labelsize": 12, "length": 0}
gdf_defaults = {"linewidth": 1.5}
# Update defaults with kwargs
annotation_defaults.update(annotation_kwargs)
imshow_defaults.update(imshow_kwargs)
colorbar_defaults.update(colorbar_kwargs)
gdf_defaults.update(gdf_kwargs)
# Get info on dataset dimensions
height, width = ds.geobox.shape
scale = width_pixels / width
left, bottom, right, top = ds.geobox.extent.boundingbox
# Prepare annotations
annotation_list = _frame_annotation(ds.time, show_date, show_text)
# Prepare geodataframe
if show_gdf is not None:
show_gdf = show_gdf.to_crs(ds.geobox.crs)
show_gdf = gpd.clip(show_gdf, mask=box(left, bottom, right, top))
show_gdf = _start_end_times(show_gdf, ds)
# Convert data to 4D numpy array of shape [time, y, x, bands]
ds = ds[bands].to_array().transpose(..., "variable")[0:limit, ...]
array = ds.astype(np.float32).values
# Optionally apply image processing along axis 0 (e.g. to each timestep)
bar_format = (
"{l_bar}{bar}| {n_fmt}/{total_fmt} ({remaining_s:.1f} "
"seconds remaining at {rate_fmt}{postfix})"
)
if image_proc_funcs:
print("Applying custom image processing functions")
for i, array_i in tqdm(
enumerate(array),
total=len(ds.time),
leave=False,
bar_format=bar_format,
unit=" frames",
):
for func in image_proc_funcs:
array_i = func(array_i)
array[i, ...] = array_i
# Clip to percentiles and rescale between 0.0 and 1.0 for plotting
vmin, vmax = np.quantile(array[np.isfinite(array)], q=percentile_stretch)
# Replace with vmin and vmax if present in `imshow_defaults`
if "vmin" in imshow_defaults:
vmin = imshow_defaults.pop("vmin")
if "vmax" in imshow_defaults:
vmax = imshow_defaults.pop("vmax")
# Rescale between 0 and 1
array = rescale_intensity(array, in_range=(vmin, vmax), out_range=(0.0, 1.0))
array = np.squeeze(array) # remove final axis if only one band
# Set up figure
fig, ax = plt.subplots()
fig.set_size_inches(width * scale / 72, height * scale / 72, forward=True)
fig.subplots_adjust(left=0, bottom=0, right=1, top=1, wspace=0, hspace=0)
# Optionally add colorbar
if show_colorbar & (len(bands) == 1):
_add_colorbar(fig, ax, vmin, vmax, imshow_defaults, colorbar_defaults)
# Animate
print(f"Exporting animation to {output_path}")
anim = FuncAnimation(
fig=fig,
func=_update_frames,
fargs=(
ax, # axis to plot into
[left, right, bottom, top], # imshow extent
annotation_list, # list of text annotations
show_gdf, # geodataframe to plot over imagery
gdf_defaults, # any kwargs used to plot gdf
annotation_defaults, # kwargs for annotations
imshow_defaults,
), # kwargs for imshow
frames=len(ds.time),
interval=interval,
repeat=False,
)
# Set up progress bar
progress_bar = tqdm(total=len(ds.time), unit=" frames", bar_format=bar_format)
# Export animation to file
if Path(output_path).suffix == ".gif":
anim.save(output_path, writer="pillow")
else:
anim.save(output_path, dpi=72)
# Update progress bar to fix progress bar moving past end
if progress_bar.n != len(ds.time):
progress_bar.n = len(ds.time)
progress_bar.last_print_n = len(ds.time)
def _degree_to_zoom_level(l1, l2, margin=0.0):
"""
Helper function to set zoom level for `display_map`
"""
degree = abs(l1 - l2) * (1 + margin)
zoom_level_int = 0
if degree != 0:
zoom_level_float = math.log(360 / degree) / math.log(2)
zoom_level_int = int(zoom_level_float)
else:
zoom_level_int = 18
return zoom_level_int
def plot_wofs(wofs, legend=True, **plot_kwargs):
"""Plot a water observation bit flag image.
Parameters
----------
wofs : xr.DataArray
A DataArray containing water observation bit flags.
legend : bool
Whether to plot a legend. Default True.
plot_kwargs : dict
Keyword arguments passed on to DataArray.plot.
Returns
-------
plot
"""
cmap = mcolours.ListedColormap(
[
np.array([150, 150, 110]) / 255, # dry - 0
np.array([0, 0, 0]) / 255, # nodata, - 1
np.array([119, 104, 87]) / 255, # terrain - 16
np.array([89, 88, 86]) / 255, # cloud_shadow - 32
np.array([216, 215, 214]) / 255, # cloud - 64
np.array([242, 220, 180]) / 255, # cloudy terrain - 80
np.array([79, 129, 189]) / 255, # water - 128
np.array([51, 82, 119]) / 255, # shady water - 160
np.array([186, 211, 242]) / 255, # cloudy water - 192
]
)
bounds = [
0,
1,
16,
32,
64,
80,
128,
160,
192,
]
norm = mcolours.BoundaryNorm(np.array(bounds) - 0.1, cmap.N)
cblabels = [
"dry",
"nodata",
"terrain",
"cloud shadow",
"cloud",
"cloudy terrain",
"water",
"shady water",
"cloudy water",
]
try:
im = wofs.plot.imshow(cmap=cmap, norm=norm, add_colorbar=legend, **plot_kwargs)
except AttributeError:
im = wofs.plot(cmap=cmap, norm=norm, add_colorbar=legend, **plot_kwargs)
if legend:
try:
cb = im.colorbar
except AttributeError:
cb = im.cbar
ticks = cb.get_ticks()
cb.set_ticks(ticks + np.diff(ticks, append=193) / 2)
cb.set_ticklabels(cblabels)
return im
def plot_lulc(lulc, product=None, legend=True, **plot_kwargs):
"""Plot a LULC image.
Parameters
----------
lulc : xr.DataArray
A DataArray containing LULC bit flags.
product : str
'ESA', 'IO', 'CGLS', or 'CCI', 'ESRI'
legend : bool
Whether to plot a legend. Default True.
plot_kwargs : dict
Keyword arguments passed on to DataArray.plot.
Returns
-------
plot
"""
if "ESRI" in product:
# this is for the orignal ESRI/IO 10 class product for 2020
try:
cmap = mcolours.ListedColormap(
[
np.array([0, 0, 0]) / 255,
np.array([65, 155, 223]) / 255,
np.array([57, 125, 73]) / 255,
np.array([136, 176, 83]) / 255,
np.array([122, 135, 198]) / 255,
np.array([228, 150, 53]) / 255,
np.array([223, 195, 90]) / 255,
np.array([196, 40, 27]) / 255,
np.array([165, 155, 143]) / 255,
np.array([168, 235, 255]) / 255,
np.array([97, 97, 97]) / 255,
]
)
bounds = range(0, 12)
norm = mcolours.BoundaryNorm(np.array(bounds), cmap.N)
cblabels = [
"no data",
"water",
"trees",
"grass",
"flooded vegetation",
"crops",
"scrub/shrub",
"built area",
"bare ground",
"snow/ice",
"clouds",
]
except:
AttributeError
if "IO" in product:
# this is for the ESRI/IO 9 class multiyear product; same color as preview
try:
cmap = mcolours.ListedColormap(
[
np.array([0, 0, 0]) / 255,
np.array([65, 155, 223]) / 255,
np.array([57, 125, 73]) / 255,
np.array([122, 135, 198]) / 255,
np.array([228, 150, 53]) / 255,
np.array([196, 40, 27]) / 255,
np.array([165, 155, 143]) / 255,
np.array([168, 235, 255]) / 255,
np.array([97, 97, 97]) / 255,
np.array([227, 226, 195]) / 255,
]
)
bounds = [-0.5, 0.5, 1.5, 3, 4.5, 6, 7.5, 8.5, 9.5, 10.5, 11.5]
norm = mcolours.BoundaryNorm(np.array(bounds), cmap.N)
cblabels = [
"no data",
"water",
"trees",
"flooded vegetation",
"crops",
"built area",
"bare ground",
"snow/ice",
"clouds",
"rangeland",
]
ticks = list(np.mean((bounds[i+1], val)) for i, val in enumerate(bounds[:-1]))
except:
AttributeError
if "ESA" in product:
try:
cmap = mcolours.ListedColormap(
[
np.array([0, 0, 0]) / 255,
np.array([0, 100, 0]) / 255,
np.array([255, 187, 34]) / 255,
np.array([255, 255, 76]) / 255,
np.array([240, 150, 255]) / 255,
np.array([250, 0, 0]) / 255,
np.array([180, 180, 180]) / 255,
np.array([240, 240, 240]) / 255,
np.array([0, 100, 200]) / 255,
np.array([0, 150, 160]) / 255,
np.array([0, 207, 117]) / 255,
np.array([250, 230, 160]) / 255,
]
)
bounds = [-5, 5, 15, 25, 35, 45, 55, 65, 75, 85, 92, 98, 105]
norm = mcolours.BoundaryNorm(np.array(bounds), cmap.N)
cblabels = [
"no data",
"tree cover",
"shrubland",
"grassland",
"cropland",
"built up",
"bare/sparse vegetation",
"snow and ice",
"permanent water bodies",
"herbaceous wetland",
"mangroves",
"moss and lichen",
]
except:
AttributeError
if "CGLS" in product:
try:
labels = {0: {'color': '#282828', 'flag': 'unknown'},
20: {'color': '#FFBB22', 'flag': 'shrubs'},
30: {'color': '#FFFF4C', 'flag': 'herbaceous_vegetation'},
40: {'color': '#F096FF', 'flag': 'cultivated_and_managed_vegetation_or_agriculture'},
50: {'color': '#FA0000', 'flag': 'urban_or_built_up'},
60: {'color': '#B4B4B4', 'flag': 'bare_or_sparse_vegetation'},
70: {'color': '#F0F0F0', 'flag': 'snow_and_ice'},
80: {'color': '#0032C8', 'flag': 'permanent_water_bodies'},
90: {'color': '#0096A0', 'flag': 'herbaceous_wetland'},
100: {'color': '#FAE6A0', 'flag': 'moss_and_lichen'},
111: {'color': '#58481F', 'flag': 'closed_forest_evergreen_needle_leaf'},
112: {'color': '#009900', 'flag': 'closed_forest_evergreen_broad_leaf'},
113: {'color': '#70663E', 'flag': 'closed_forest_deciduous_needle_leaf'},
114: {'color': '#00CC00', 'flag': 'closed_forest_deciduous_broad_leaf'},
115: {'color': '#4E751F', 'flag': 'closed_forest_mixed'},
116: {'color': '#007800', 'flag': 'closed_forest_not_matching_any_of_the_other_definitions'},
121: {'color': '#666000', 'flag': 'open_forest_evergreen_needle_leaf'},
122: {'color': '#8DB400', 'flag': 'open_forest_evergreen_broad_leaf'},
123: {'color': '#8D7400', 'flag': 'open_forest_deciduous_needle_leaf'},
124: {'color': '#A0DC00', 'flag': 'open_forest_deciduous_broad_leaf'},
125: {'color': '#929900', 'flag': 'open_forest_mixed'},
126: {'color': '#648C00', 'flag': 'open_forest_not_matching_any_of_the_other_definitions'},
200: {'color': '#000080', 'flag': 'oceans_seas'}}
colors = [label['color'] for label in labels.values()]
cmap = ListedColormap([label['color'] for label in labels.values()])
norm = mcolours.BoundaryNorm(list(labels.keys())+[201], cmap.N+1, extend='max')
ticks = list(np.mean((list(list(labels.keys())+[201])[i+1], val)) for i, val in enumerate(list(labels.keys())))
cblabels=[label['flag'] for label in labels.values()]
except:
AttributeError
if 'CCI' in product:
try:
labels = {0: {'color': '#282828', 'flag': 'no data'},
10: {'color': '#EBEB34', 'flag': 'cropland, rainfed'},
11: {'color': '#D9EB34', 'flag': 'cropland, rainfed, herbaceous cover'},
12: {'color': '#EBDF34', 'flag': 'cropland, rainfed, tree or shrub cover'},
20: {'color': '#34EBE2', 'flag': 'cropland, irrigated or post-flooding'},
30: {'color': '#EBBD34', 'flag': 'mosaic cropland/natural vegetation'},
40: {'color': '#eba534', 'flag': 'mosaic natural vegetation/cropland'},
50: {'color': '#34eb46', 'flag': 'tree cover, broadleaved, evergreen, closed to open'},
60: {'color': '#21750e', 'flag': 'tree cover, broadleaved, deciduous, closed to open'},
61: {'color': '#449432', 'flag': 'tree cover, broadleaved, deciduous, closed'},
62: {'color': '#5da64c', 'flag': 'tree cover, broadleaved, deciduous, open'},
70: {'color': '#16470b', 'flag': 'tree cover, needleleaved, evergreen, closed to open'},
71: {'color': '#237012', 'flag': 'tree cover, needleleaved, evergreen, closed'},
72: {'color': '#237012', 'flag': 'tree cover, needleleaved, evergreen, open'},
80: {'color': '#31a317', 'flag': 'tree cover, needleleaved, deciduous, closed to open'},
81: {'color': '#57ed34', 'flag': 'tree cover, needleleaved, deciduous, closed'},
82: {'color': '#81f765', 'flag': 'tree cover, needleleaved, deciduous, open'},
90: {'color': '#b6ed64', 'flag': 'tree cover, mixed leaf type'},
100: {'color': '#6f8f3f', 'flag': 'mosaic tree and shrub/herbaceous cover'},
110: {'color': '#ad950c', 'flag': 'mosaic herbaceous cover/tree and shrub'},
120: {'color': '#5e5209', 'flag': 'shrubland'},
121: {'color': '#292302', 'flag': 'shrubland, evergreen'},
122: {'color': '#a89008', 'flag': 'shrubland, deciduous'},
130: {'color': '#f7bf07', 'flag': 'grassland'},
140: {'color': '#f57feb', 'flag': 'lichens and mosses'},
150: {'color': '#f57feb', 'flag': 'sparse vegetation'},
151: {'color': '#fcf7a4', 'flag': 'sparse tree'},
152: {'color': '#d4cf87', 'flag': 'sparse shrub'},
153: {'color': '#b0aa54', 'flag': 'sparse herbaceous cover'},
160: {'color': '#159638', 'flag': 'tree cover, flooded, fresh or brakish water'},
170: {'color': '#22bf81', 'flag': 'tree cover, flooded, saline water'},
180: {'color': '#44eba9', 'flag': 'shrub or herbaceous cover, flooded, fresh/saline/brakish water'},
190: {'color': '#a3273c', 'flag': 'urban areas'},
200: {'color': '#fffbcc', 'flag': 'bare areas'},
201: {'color': '#b0afa4', 'flag': 'consolidated bare areas'},
202: {'color': '#d6d4b6', 'flag': 'unconsolidated bare areas'},
210: {'color': '#1A3EF0', 'flag': 'water bodies'},
220: {'color': '#ffffff', 'flag': 'permanent snow and ice'}}
colors = [label['color'] for label in labels.values()]
cmap = ListedColormap([label['color'] for label in labels.values()])
norm = mcolours.BoundaryNorm(list(labels.keys())+[221], cmap.N+1, extend='max')
ticks = list(np.mean((list(list(labels.keys())+[221])[i+1], val)) for i, val in enumerate(list(labels.keys())))
cblabels=[label['flag'] for label in labels.values()]
except:
AttributeError
try:
im = lulc.plot.imshow(cmap=cmap, norm=norm, add_colorbar=legend, **plot_kwargs)
except AttributeError:
im = lulc.plot(cmap=cmap, norm=norm, add_colorbar=legend, **plot_kwargs)
if legend:
try:
cb = im.colorbar
except AttributeError:
cb = im.cbar
if "ESRI" in product:
cb.set_ticks(np.arange(0, 11, 1)+0.5)
cb.set_ticklabels(cblabels)
if "IO" in product:
cb.set_ticks(ticks)
cb.set_ticklabels(cblabels)
if "ESA" in product:
cb.set_ticks([0, 10, 20, 30, 40, 50, 60, 70, 80, 88.5, 95, 101.5])
cb.set_ticklabels(cblabels)
if "CGLS" in product:
cb.set_ticks(ticks)
cb.set_ticklabels(cblabels)
if "CCI" in product:
cb.set_ticks(ticks)
cb.set_ticklabels(cblabels)
return im