first commit

This commit is contained in:
nghiadang
2024-08-28 05:10:02 +00:00
commit 92c760f474
68 changed files with 23322 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
__locales__ = __path__[0] + '/locales'
def set_lang(lang=None):
if lang is None:
import os
os_lang = os.getenv('LANG')
# Just take the first 2 letters: 'fr' not 'fr_FR.UTF-8'
if os_lang is not None and len(os_lang) >=2:
lang = [os_lang[:2]]
else:
lang = [lang]
import gettext
try:
translation = gettext.translation(
'deafrica_tools',
localedir=__locales__,
languages=lang,
fallback=True
)
translation.install()
except FileNotFoundError:
print(f'Could not load lang={lang}')
Binary file not shown.
View File
+942
View File
@@ -0,0 +1,942 @@
# -*- coding: utf-8 -*-
"""
Satellite imagery animation widget, which can be used to interactively
produce animations for multiple DE Africa products.
"""
# 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 fiona
import sys
import datacube
import warnings
import matplotlib.pyplot as plt
from datacube.utils.geometry import CRS
from ipyleaflet import (
WMSLayer,
basemaps,
basemap_to_tiles,
Map,
DrawControl,
WidgetControl,
LayerGroup,
LayersControl,
GeoData,
)
from traitlets import Unicode
from ipywidgets import (
GridspecLayout,
Button,
Layout,
HBox,
VBox,
HTML,
Output,
)
import json
import itertools
import numpy as np
import geopandas as gpd
from io import BytesIO
import ipywidgets as widgets
import datetime
from skimage import exposure
from skimage.filters import unsharp_mask
from datacube.utils import masking
from datacube.utils.geometry import Geometry
from datacube.utils.masking import mask_invalid_data
import deafrica_tools.app.widgetconstructors as deawidgets
from deafrica_tools.dask import create_local_dask_cluster
from deafrica_tools.spatial import reverse_geocode
from deafrica_tools.datahandling import pan_sharpen_brovey
import warnings
warnings.filterwarnings("ignore")
# WMS params and satellite style bands
sat_params = {
"Landsat": {
"products": ["ls5_sr", "ls7_sr", "ls8_sr", "ls9_sr"],
"styles": {
"True colour": ("true_colour", ["red", "green", "blue"]),
"False colour": (
"false_colour",
["swir_1", "nir", "green"],
),
},
},
"Sentinel-2": {
"products": ["s2_l2a"],
"styles": {
"True colour": ("simple_rgb", ["red", "green", "blue"]),
"False colour": (
"infrared_green",
["swir_2", "nir_1", "green"],
),
},
},
}
def make_box_layout():
return Layout(
# border='solid 1px black',
margin="0px 10px 10px 0px",
padding="5px 5px 5px 5px",
width="100%",
height="100%",
)
def create_expanded_button(description, button_style):
return Button(
description=description,
button_style=button_style,
layout=Layout(width="auto", height="auto"),
)
def update_map_layers(self):
"""
Updates map to add new DE Africa layers, styles or basemap when selected
using menu options. Triggers data reload by resetting load params
and output arrays.
"""
# Clear data load params to trigger data re-load
self.timeseries_ds = None
self.load_params = None
self.query_params = None
# Clear all layers and add basemap
self.map_layers.clear_layers()
self.map_layers.add_layer(self.basemap)
def extract_data(self):
# Connect to datacube database
dc = datacube.Datacube(app="Exporting satellite images")
# Configure local dask cluster
client = create_local_dask_cluster(return_client=True, display_client=True)
# Convert to geopolygon
geopolygon = Geometry(geom=self.gdf_drawn.geometry[0], crs=self.gdf_drawn.crs)
# Create query.
start_date = np.datetime64(self.start_date)
end_date = np.datetime64(self.end_date)
self.query_params = {
"time": (str(start_date), str(end_date)),
"geopolygon": geopolygon,
}
# Find matching datasets
dss = [
dc.find_datasets(product=i, **self.query_params)
for i in sat_params[self.dealayer]["products"]
]
dss = list(itertools.chain.from_iterable(dss))
# If data is found
if len(dss) > 0:
# Get CRS
crs = str(dss[0].crs)
self.load_params = {
"measurements": sat_params[self.dealayer]["styles"][self.style][1],
"resolution": (-self.resolution, self.resolution),
"output_crs": crs,
"group_by": "solar_day",
"dask_chunks": {"time": 1, "x": 2048, "y": 2048},
"resampling": {"*": "cubic", "oa_fmask": "nearest", "fmask": "nearest"},
}
# Load data
from deafrica_tools.datahandling import load_ard
timeseries_ds = load_ard(
dc=dc,
products=sat_params[self.dealayer]["products"],
min_gooddata=1.0 - (self.max_cloud_cover / 100),
ls7_slc_off=False,
mask_pixel_quality=self.cloud_mask,
**self.load_params,
**self.query_params,
)
# Set invalid nodata pixels to NaN
timeseries_ds = mask_invalid_data(timeseries_ds)
# Else if no data is returned, return None
else:
timeseries_ds = None
# Close down the dask client
client.close()
return timeseries_ds.compute()
def plot_data(self, fname):
# Data to plot
to_plot = self.timeseries_ds
# If rolling median specified
if self.rolling_median:
with self.status_info:
print(
f"\nApplying rolling median ({self.rolling_median_window} timesteps window)"
)
to_plot = to_plot.rolling(
time=int(self.rolling_median_window), center=True, min_periods=1
).median()
# If resampling freq specified
if self.resample_freq:
with self.status_info:
print(f"\nResampling data to {self.resample_freq} frequency")
to_plot = to_plot.resample(time=self.resample_freq).median()
# Raise by power to dampen bright features and enhance dark.
# Raise vmin and vmax by same amount to ensure proper stretch
if self.power < 1.0:
with self.status_info:
print(f"\nApplying power transformation ({self.power})")
to_plot = to_plot ** self.power
# Apply unsharp masking to enhance overall dynamic range,
# and improve fine scale detail
if self.unsharp_mask:
with self.status_info:
print(
f"\nApplying unsharp masking with {self.unsharp_mask_radius} "
f"radius and {self.unsharp_mask_amount} amount"
)
from skimage.exposure import rescale_intensity
funcs_list = [
rescale_intensity,
lambda x: unsharp_mask(
x, radius=self.unsharp_mask_radius, amount=self.unsharp_mask_amount
),
]
else:
funcs_list = None
from deafrica_tools.plotting import xr_animation
xr_animation(
output_path=fname,
ds=to_plot.dropna(dim="time", how="all"),
show_text="",
bands=sat_params[self.dealayer]["styles"][self.style][1],
interval=self.interval,
width_pixels=self.width,
show_gdf=deacoastlines_overlay(to_plot) if self.deacoastlines else None,
gdf_kwargs={"linewidth": 3},
percentile_stretch=(self.vmin, self.vmax),
image_proc_funcs=funcs_list,
show_date="%Y" if self.resample_freq == "1Y" else "%b %Y",
annotation_kwargs={"fontsize": 75},
)
# Add plot preview below map and finish
plt.show()
with self.status_info:
print(f"\nImage successfully exported to:\n{fname}.")
def deacoastlines_overlay(ds):
import geopandas as gpd
import pandas as pd
import matplotlib
from shapely.geometry import box, Point
from deafrica_tools.coastal import get_coastlines
# Get bounding box of data
xmin, ymin, xmax, ymax = ds.geobox.geographic_extent.boundingbox
bounds = [xmin, ymin, xmax, ymax]
# Load data
deacl_gdf = get_coastlines(bbox=bounds)
# Clip to extent of satellite data
bbox = gpd.GeoDataFrame(geometry=[ds.geobox.extent.geom], crs=ds.geobox.crs)
deacl_gdf = gpd.overlay(deacl_gdf, bbox.to_crs(deacl_gdf.crs))
deacl_gdf = deacl_gdf.dissolve("year") # values("year", ascending=True)
# Apply colours
norm = matplotlib.colors.Normalize(vmin=0, vmax=len(deacl_gdf.index))
cmap = matplotlib.cm.get_cmap("inferno")
rgba = cmap(norm(deacl_gdf.reset_index().index))
deacl_gdf["color"] = list(rgba)
deacl_gdf["start_time"] = pd.to_datetime(deacl_gdf.index) + pd.DateOffset(months=0)
deacl_gdf = deacl_gdf.sort_index()
if len(deacl_gdf.index) > 0:
return deacl_gdf
else:
return None
class animation_app(HBox):
def __init__(self):
super().__init__()
######################
# INITIAL ATTRIBUTES #
######################
# Basemap
self.basemap_list = [
("ESRI World Imagery", basemap_to_tiles(basemaps.Esri.WorldImagery)),
("Open Street Map", basemap_to_tiles(basemaps.OpenStreetMap.Mapnik)),
]
self.basemap = self.basemap_list[0][1]
# Satellite data
end_date = datetime.datetime.today()
start_date = datetime.datetime(
year=end_date.year - 3, month=end_date.month, day=end_date.day
)
self.start_date = start_date.strftime("%Y-%m-%d")
self.end_date = end_date.strftime("%Y-%m-%d")
self.dealayer_list = [
("Landsat", "Landsat"),
("Sentinel-2", "Sentinel-2"),
]
self.dealayer = self.dealayer_list[0][1]
# Styles
self.styles_list = ["True colour", "False colour"]
self.style = self.styles_list[0]
# Analysis params
self.resolution = 30
self.vmin = 0.01
self.vmax = 0.99
self.power = 1.0
self.output_list = [("MP4", "mp4"), ("GIF", "gif")]
self.output_format = self.output_list[0][1]
self.rolling_median = False
self.rolling_median_window = 20
self.unsharp_mask = False
self.unsharp_mask_radius = 20
self.unsharp_mask_amount = 0.3
self.max_size = False
self.width = 900
self.interval = 100
self.cloud_mask = False
self.max_cloud_cover = 20
self.resample_list = [
("None", False),
("Monthly", "1M"),
("Quarterly", "Q-DEC"),
("Yearly", "1Y"),
]
self.resample_freq = self.resample_list[0][1]
self.deacoastlines = False
# Drawing params
self.target = None
self.action = None
self.gdf_drawn = None
# Data load params
self.timeseries_ds = None
self.load_params = None
self.query_params = None
##################
# HEADER FOR APP #
##################
# Create the Header widget
header_title_text = (
"<h3>Digital Earth Africa satellite imagery animations</h3>"
)
instruction_text = (
"<p>Select the desired satellite data, imagery date range "
"and image style, then zoom in and draw a rectangle to "
"select an area export as a satellite imagery time-series "
"animation.</p>"
)
self.header = deawidgets.create_html(f"{header_title_text}{instruction_text}")
self.header.layout = make_box_layout()
#####################################
# HANDLER FUNCTION FOR DRAW CONTROL #
#####################################
# Define the action to take once something is drawn on the map
def update_geojson(target, action, geo_json):
# Get data from action
self.action = action
# Clear data load params to trigger data re-load
self.timeseries_ds = None
self.load_params = None
self.query_params = None
# Convert data to geopandas
json_data = json.dumps(geo_json)
binary_data = json_data.encode()
io = BytesIO(binary_data)
io.seek(0)
gdf = gpd.read_file(io)
gdf.crs = "EPSG:4326"
# Convert to WGS 84 / NSIDC EASE-Grid 2.0 Global and compute area
gdf_drawn_nsidc = gdf.copy().to_crs("EPSG:6933")
m2_per_ha = 10000
area = gdf_drawn_nsidc.area.values[0] / m2_per_ha
polyarea_label = "Total area of satellite data to extract"
polyarea_text = f"<b>{polyarea_label}</b>: {area:.2f} ha</sup>"
# Test area size
if self.max_size:
confirmation_text = (
'<span style="color: #33cc33"> '
"<b>(Overriding maximum size limit; use with caution as may lead to memory issues)</b></span>"
)
self.header.value = (
header_title_text
+ instruction_text
+ polyarea_text
+ confirmation_text
)
self.gdf_drawn = gdf
elif area <= 50000:
confirmation_text = (
'<span style="color: #33cc33"> '
"<b>(Area to extract falls within "
"recommended 50000 ha limit)</b></span>"
)
self.header.value = (
header_title_text
+ instruction_text
+ polyarea_text
+ confirmation_text
)
self.gdf_drawn = gdf
else:
warning_text = (
'<span style="color: #ff5050"> '
"<b>(Area to extract is too large, "
"please select an area less than 50000 "
"ha)</b></span>"
)
self.header.value = (
header_title_text + instruction_text + polyarea_text + warning_text
)
self.gdf_drawn = None
###########################
# WIDGETS FOR APP OUTPUTS #
###########################
self.status_info = Output(layout=make_box_layout())
self.output_plot = Output(layout=make_box_layout())
#########################################
# MAP WIDGET, DRAWING TOOLS, WMS LAYERS #
#########################################
# Create drawing tools
desired_drawtools = ["rectangle"]
draw_control = deawidgets.create_drawcontrol(desired_drawtools)
# Begin by displaying an empty layer group, and update the group with desired WMS on interaction.
self.map_layers = LayerGroup(layers=())
self.map_layers.name = "Map Overlays"
# Create map widget
self.m = deawidgets.create_map(map_center=(5.65, 26.17), zoom_level=13)
self.m.layout = make_box_layout()
# Add tools to map widget
self.m.add_control(draw_control)
self.m.add_layer(self.map_layers)
# Update all maps to starting defaults
update_map_layers(self)
############################
# WIDGETS FOR APP CONTROLS #
############################
# Create parameter widgets
dropdown_basemap = deawidgets.create_dropdown(
self.basemap_list, self.basemap_list[0][1]
)
dropdown_dealayer = deawidgets.create_dropdown(
self.dealayer_list, self.dealayer_list[0][1]
)
dropdown_output = deawidgets.create_dropdown(
self.output_list, self.output_list[0][1]
)
date_picker_start = deawidgets.create_datepicker(
value=start_date,
)
date_picker_end = deawidgets.create_datepicker(
value=end_date,
)
dropdown_styles = deawidgets.create_dropdown(
self.styles_list, self.styles_list[0]
)
slider_percentile = widgets.FloatRangeSlider(
value=[0.01, 0.99],
min=0,
max=1,
step=0.001,
description="",
layout={"width": "85%"},
)
run_button = create_expanded_button("Generate animation", "info")
floatslider_max_cloud_cover = widgets.IntSlider(
value=20,
min=0,
max=100,
step=1,
description="",
layout={"width": "85%"},
)
checkbox_rolling_median = deawidgets.create_checkbox(
self.rolling_median,
"Apply rolling median<br>to produce smooth, <br> cloud-free animations",
layout={"width": "90%",
"height": "4em"},
)
text_rolling_median_window = widgets.IntText(
value=20,
step=1,
description="</br>Rolling window (timesteps)",
layout={
"width": "85%",
"margin": "0px",
"padding": "0px",
"display": "none",
},
)
# Expandable advanced section
text_interval = widgets.IntText(
value=100, description="", step=50, layout={"width": "95%"}
)
text_resolution = widgets.FloatText(
value=30,
description="",
layout={"width": "95%", "margin": "0px", "padding": "0px"},
)
text_width = widgets.IntText(
value=900, description="", step=50, layout={"width": "95%"}
)
dropdown_resampling = deawidgets.create_dropdown(
self.resample_list,
self.resample_freq,
description="",
layout={"width": "95%"},
)
checkbox_cloud_mask = deawidgets.create_checkbox(
self.cloud_mask, "Mask out cloudy <br> pixels", layout={"width": "95%", "height": "auto"}
)
slider_power = widgets.FloatSlider(
value=1.0,
min=0.01,
max=1.0,
step=0.01,
description="",
layout={"width": "95%"},
)
checkbox_unsharp_mask = deawidgets.create_checkbox(
self.unsharp_mask, "Enable", layout={"width": "95%"}
)
text_unsharp_mask_radius = widgets.FloatText(
value=20,
step=1,
description="Radius",
layout={
"width": "95%",
"margin": "0px",
"padding": "0px",
"display": "none",
},
)
text_unsharp_mask_amount = widgets.FloatText(
value=0.3,
step=0.1,
description="Amount",
layout={
"width": "95%",
"margin": "0px",
"padding": "0px",
"display": "none",
},
)
checkbox_deacoastlines = deawidgets.create_checkbox(
self.deacoastlines, "Add DE Africa Coastlines overlay", layout={"width": "95%"}
)
checkbox_max_size = deawidgets.create_checkbox(
self.max_size, "Enable", layout={"width": "95%"}
)
expand_box = widgets.VBox(
[
HTML("Frame interval (milliseconds):"),
text_interval,
HTML("</br>Resolution (metres):"),
text_resolution,
HTML("</br>Width of output animation in pixels:"),
text_width,
HTML("</br>Apply temporal resampling:"),
dropdown_resampling,
HTML("</br>"),
checkbox_cloud_mask,
checkbox_deacoastlines,
HTML("</br>Apply power transformation to darken bright features:"),
slider_power,
HTML("</br>Apply unsharp masking to sharpen imagery:"),
checkbox_unsharp_mask,
text_unsharp_mask_radius,
text_unsharp_mask_amount,
HTML(
"</br>Override maximum size limit: (use with caution; may cause memory issues/crashes)"
),
checkbox_max_size,
],
)
expand = widgets.Accordion(
children=[expand_box],
selected_index=None,
)
expand.set_title(0, "Advanced")
# Add specific dialogs to class so they can be modified
self.text_resolution = text_resolution
self.text_unsharp_mask_radius = text_unsharp_mask_radius
self.text_unsharp_mask_amount = text_unsharp_mask_amount
self.text_rolling_median_window = text_rolling_median_window
####################################
# UPDATE FUNCTIONS FOR EACH WIDGET #
####################################
# Run update functions whenever various widgets are changed.
date_picker_start.observe(self.update_start_date, "value")
date_picker_end.observe(self.update_end_date, "value")
dropdown_basemap.observe(self.update_basemap, "value")
dropdown_dealayer.observe(self.update_dealayer, "value")
dropdown_styles.observe(self.update_styles, "value")
slider_percentile.observe(self.update_slider_percentile, "value")
floatslider_max_cloud_cover.observe(
self.update_floatslider_max_cloud_cover, "value"
)
checkbox_rolling_median.observe(self.update_checkbox_rolling_median, "value")
text_rolling_median_window.observe(
self.update_text_rolling_median_window, "value"
)
dropdown_output.observe(self.update_output, "value")
run_button.on_click(self.run_app)
draw_control.on_draw(update_geojson)
# Advanced params
text_resolution.observe(self.update_text_resolution, "value")
slider_power.observe(self.update_slider_power, "value")
text_width.observe(self.update_width, "value")
text_interval.observe(self.update_interval, "value")
dropdown_resampling.observe(self.update_dropdown_resampling, "value")
checkbox_cloud_mask.observe(self.update_checkbox_cloud_mask, "value")
checkbox_unsharp_mask.observe(self.update_checkbox_unsharp_mask, "value")
text_unsharp_mask_radius.observe(self.update_text_unsharp_mask_radius, "value")
text_unsharp_mask_amount.observe(self.update_text_unsharp_mask_amount, "value")
checkbox_deacoastlines.observe(self.update_deacoastlines, "value")
checkbox_max_size.observe(self.update_checkbox_max_size, "value")
##################################
# COLLECTION OF ALL APP CONTROLS #
##################################
parameter_selection = VBox(
[
HTML("<b>Satellite imagery:</b>"),
dropdown_dealayer,
HTML("<b>Start date:</b>"),
date_picker_start,
HTML("<b>End date:</b>"),
date_picker_end,
HTML("<b>Style:</b>"),
dropdown_styles,
HTML("<b>Colour percentile stretch:</b>"),
slider_percentile,
HTML("<b>Maximum cloud cover (%):</b>"),
floatslider_max_cloud_cover,
checkbox_rolling_median,
text_rolling_median_window,
HTML("</br><b>Output file format:</b>"),
dropdown_output,
HTML("</br>"),
expand,
]
)
map_selection = VBox(
[
HTML("</br><b>Map overlay:</b>"),
dropdown_basemap,
]
)
parameter_selection.layout = make_box_layout()
map_selection.layout = make_box_layout()
###############################
# SPECIFICATION OF APP LAYOUT #
###############################
# 0 1 2 3 4 5 6 7 8 9
# ---------------------------------------------
# 0 | Header | Map sel. |
# |-------------------------------------------|
# 1 | Params | |
# 2 | | |
# 3 | | |
# 4 | | Map |
# 5 | | |
# |--------| |
# 6 | Run | |
# |-------------------------------------------|
# 7 | Status info | Figure/output |
# 8 | | |
# 9 | | |
# 10 | | |
# 11 ---------------------------------------------
# Create the layout #[rowspan, colspan]
grid = GridspecLayout(12, 10, height="1500px", width="auto")
# Header and controls
grid[0, :8] = self.header
grid[0, 8:] = map_selection
grid[1:6, 0:2] = parameter_selection
grid[6, 0:2] = run_button
# Status info, map and plot
grid[1:7, 2:] = self.m # map
grid[7:, 0:4] = self.status_info
grid[7:, 4:] = self.output_plot
# Display using HBox children attribute
self.children = [grid]
######################################
# DEFINITION OF ALL UPDATE FUNCTIONS #
######################################
# Update date
def update_start_date(self, change):
self.start_date = str(change.new)
# Clear data load params to trigger data re-load
self.timeseries_ds = None
self.load_params = None
self.query_params = None
# Update date
def update_end_date(self, change):
self.end_date = str(change.new)
# Clear data load params to trigger data re-load
self.timeseries_ds = None
self.load_params = None
self.query_params = None
# Update colour stretch
def update_slider_percentile(self, change):
self.vmin, self.vmax = change.new
# Update power transform
def update_slider_power(self, change):
self.power = change.new
# Update good data slider
def update_floatslider_max_cloud_cover(self, change):
self.max_cloud_cover = change.new
# Clear data load params to trigger data re-load
self.timeseries_ds = None
self.load_params = None
self.query_params = None
# Enable unsharp masking and show/hide custom params
def update_checkbox_unsharp_mask(self, change):
self.unsharp_mask = change.new
# Show unsharp masking params in menu if activated
if change.new:
self.text_unsharp_mask_radius.layout.display = "block"
self.text_unsharp_mask_amount.layout.display = "block"
else:
self.text_unsharp_mask_radius.layout.display = "none"
self.text_unsharp_mask_amount.layout.display = "none"
# Change unsharp masking radius
def update_text_unsharp_mask_radius(self, change):
self.unsharp_mask_radius = change.new
# Change unsharp masking amount
def update_text_unsharp_mask_amount(self, change):
self.unsharp_mask_amount = change.new
# Enable rolling median and show/hide custom params
def update_checkbox_rolling_median(self, change):
self.rolling_median = change.new
# Show rolling median params in menu if activated
if change.new:
self.text_rolling_median_window.layout.display = "block"
else:
self.text_rolling_median_window.layout.display = "none"
# Change rolling median window
def update_text_rolling_median_window(self, change):
self.rolling_median_window = change.new
# Override max size limit
def update_checkbox_max_size(self, change):
self.max_size = change.new
# Add DE Africa Coastlines overlay
def update_deacoastlines(self, change):
self.deacoastlines = change.new
# Apply cloud mask in load_ard
def update_checkbox_cloud_mask(self, change):
self.cloud_mask = change.new
# Clear data load params to trigger data re-load
self.timeseries_ds = None
self.load_params = None
self.query_params = None
# Override min width
def update_width(self, change):
self.width = change.new
# Override interval
def update_interval(self, change):
self.interval = change.new
# Update resolution
def update_text_resolution(self, change):
self.resolution = change.new
# Clear data load params to trigger data re-load
self.timeseries_ds = None
self.load_params = None
self.query_params = None
# Change layers shown on the map
def update_dealayer(self, change):
self.dealayer = change.new
if change.new == "Landsat":
self.text_resolution.value = 30
else:
self.text_resolution.value = 10
# Update basemap
def update_basemap(self, change):
self.basemap = change.new
update_map_layers(self)
# Set imagery style
def update_styles(self, change):
self.style = change.new
# Clear data load params to trigger data re-load
self.timeseries_ds = None
self.load_params = None
self.query_params = None
# Set output file format
def update_output(self, change):
self.output_format = change.new
# Set output file format
def update_dropdown_resampling(self, change):
self.resample_freq = change.new
def run_app(self, change):
# Clear progress bar and output areas before running
self.status_info.clear_output()
self.output_plot.clear_output()
# Verify that polygon was drawn
if self.gdf_drawn is not None:
with self.status_info:
# Load data and add to attribute
if self.timeseries_ds is None:
self.timeseries_ds = extract_data(self)
else:
print("Using previously loaded data")
if self.timeseries_ds is not None:
with self.status_info:
# Create unique file name
centre_coords = self.gdf_drawn.geometry[0].centroid.coords[0][::-1]
site = reverse_geocode(coords=centre_coords)
fname = (
f"{self.dealayer}_{site}_{self.start_date}_"
f"{self.end_date}_{self.style}_{self.resolution:.0f}m."
f"{self.output_format}".replace(" ", "")
.replace(",", "")
.lower()
)
print(
f"\nExporting animation for {site}.\nThis may take several minutes..."
)
############
# Plotting #
############
with self.output_plot:
plot_data(self, fname)
else:
with self.status_info:
print(
"No satellite data found in the selected area. "
"Please select a new rectangle over an area with "
"satellite imagery."
)
else:
with self.status_info:
print(
'Please draw a valid rectangle on the map, then press "Generate animation".'
)
+275
View File
@@ -0,0 +1,275 @@
"""
Loading and interacting with data in the change filmstrips notebook,
inside the Real_world_examples folder.
"""
# Load modules
import os
import dask
import datacube
import warnings
import numpy as np
import pandas as pd
import xarray as xr
import matplotlib.pyplot as plt
from odc.algo import geomedian_with_mads
from odc.ui import select_on_a_map
from dask.utils import parse_bytes
from datacube.utils.geometry import CRS, assign_crs
from datacube.utils.rio import configure_s3_access
from datacube.utils.dask import start_local_dask
from ipyleaflet import basemaps, basemap_to_tiles
# Load utility functions
from deafrica_tools.datahandling import load_ard, mostcommon_crs
from deafrica_tools.dask import create_local_dask_cluster
def run_filmstrip_app(
output_name,
time_range,
time_step,
tide_range=(0.0, 1.0),
resolution=(-30, 30),
max_cloud=0.5,
ls7_slc_off=False,
size_limit=10000,
):
"""
An interactive app that allows the user to select a region from a
map, then load Digital Earth Africa Landsat data and combine it
using the geometric median ("geomedian") statistic to reveal the
median or 'typical' appearance of the landscape for a series of
time periods.
The results for each time period are combined into a 'filmstrip'
plot which visualises how the landscape has changed in appearance
across time, with a 'change heatmap' panel highlighting potential
areas of greatest change.
For coastal applications, the analysis can be customised to select
only satellite images obtained during a specific tidal range
(e.g. low, average or high tide).
Last modified: April 2020
Parameters
----------
output_name : str
A name that will be used to name the output filmstrip plot file.
time_range : tuple
A tuple giving the date range to analyse
(e.g. `time_range = ('1988-01-01', '2017-12-31')`).
time_step : dict
This parameter sets the length of the time periods to compare
(e.g. `time_step = {'years': 5}` will generate one filmstrip
plot for every five years of data; `time_step = {'months': 18}`
will generate one plot for each 18 month period etc. Time
periods are counted from the first value given in `time_range`.
tide_range : tuple, optional
An optional parameter that can be used to generate filmstrip
plots based on specific ocean tide conditions. This can be
valuable for analysing change consistently along the coast.
For example, `tide_range = (0.0, 0.2)` will select only
satellite images acquired at the lowest 20% of tides;
`tide_range = (0.8, 1.0)` will select images from the highest
20% of tides. The default is `tide_range = (0.0, 1.0)` which
will select all images regardless of tide.
resolution : tuple, optional
The spatial resolution to load data. The default is
`resolution = (-30, 30)`, which will load data at 30 m pixel
resolution. Increasing this (e.g. to `resolution = (-100, 100)`)
can be useful for loading large spatial extents.
max_cloud : float, optional
This parameter can be used to exclude satellite images with
excessive cloud. The default is `0.5`, which will keep all images
with less than 50% cloud.
ls7_slc_off : bool, optional
An optional boolean indicating whether to include data from
after the Landsat 7 SLC failure (i.e. SLC-off). Defaults to
False, which removes all Landsat 7 observations > May 31 2003.
size_limit : int, optional
An optional integer (in hectares) specifying the size limit
for the data query. Queries larger than this size will receive
a warning that he data query is too large (and may
therefore result in memory errors).
Returns
-------
ds_geomedian : xarray Dataset
An xarray dataset containing geomedian composites for each
timestep in the analysis.
"""
########################
# Select and load data #
########################
# Define centre_coords as a global variable
global centre_coords
# Test if centre_coords is in the global namespace;
# use default value if it isn't
if "centre_coords" not in globals():
centre_coords = (6.587292, 1.532833)
# Plot interactive map to select area
basemap = basemap_to_tiles(basemaps.Esri.WorldImagery)
geopolygon = select_on_a_map(height="600px",
layers=(basemap,),
center=centre_coords,
zoom=14)
# Set centre coords based on most recent selection to re-focus
# subsequent data selections
centre_coords = geopolygon.centroid.points[0][::-1]
# Test size of selected area
msq_per_hectare = 10000
area = geopolygon.to_crs(crs=CRS("epsg:6933")).area / msq_per_hectare
radius = np.round(np.sqrt(size_limit), 1)
if area > size_limit:
print(f"Warning: Your selected area is {area:.00f} hectares. "
f"Please select an area of less than {size_limit} hectares."
f"\nTo select a smaller area, re-run the cell "
f"above and draw a new polygon.")
else:
print("Starting analysis...")
# Connect to datacube database
dc = datacube.Datacube(app="Change_filmstrips")
# Configure local dask cluster
client = create_local_dask_cluster(return_client=True)
# Obtain native CRS
crs = mostcommon_crs(dc=dc,
product="ls8_sr",
query={
"time": "2014",
"geopolygon": geopolygon
})
# Create query based on time range, area selected, custom params
query = {
"time": time_range,
"geopolygon": geopolygon,
"output_crs": crs,
"resolution": resolution,
"dask_chunks": {
"x": 3000,
"y": 3000
},
"align": (resolution[1] / 2.0, resolution[1] / 2.0),
}
# Load data from all three Landsats
warnings.filterwarnings("ignore")
ds = load_ard(
dc=dc,
measurements=["red", "green", "blue"],
products=["ls5_sr", "ls7_sr", "ls8_sr"],
min_gooddata=max_cloud,
ls7_slc_off=ls7_slc_off,
**query,
)
# Optionally calculate tides for each timestep in the satellite
# dataset and drop any observations out side this range
if tide_range != (0.0, 1.0):
from deafrica_tools.coastal import tidal_tag
ds = tidal_tag(ds=ds, tidepost_lat=None, tidepost_lon=None)
min_tide, max_tide = ds.tide_height.quantile(tide_range).values
ds = ds.sel(time=(ds.tide_height >= min_tide) &
(ds.tide_height <= max_tide))
ds = ds.drop("tide_height")
print(f" Keeping {len(ds.time)} observations with tides "
f"between {min_tide:.2f} and {max_tide:.2f} m")
# Create time step ranges to generate filmstrips from
bins_dt = pd.date_range(start=time_range[0],
end=time_range[1],
freq=pd.DateOffset(**time_step))
# Bin all satellite observations by timestep. If some observations
# fall outside the upper bin, label these with the highest bin
labels = bins_dt.astype("str")
time_steps = (pd.cut(ds.time.values, bins_dt,
labels=labels[:-1]).add_categories(
labels[-1]).fillna(labels[-1]))
time_steps_var = xr.DataArray(time_steps, [("time", ds.time.values)],
name="timestep")
# Resample data temporally into time steps, and compute geomedians
ds_geomedian = (ds.groupby(time_steps_var).apply(
lambda ds_subset: geomedian_with_mads(
ds_subset, compute_mads=False, compute_count=False)))
print("\nGenerating geomedian composites and plotting "
"filmstrips... (click the Dashboard link above for status)")
ds_geomedian = ds_geomedian.compute()
# Reset CRS that is lost during geomedian compositing
ds_geomedian = assign_crs(ds_geomedian, crs=ds.geobox.crs)
############
# Plotting #
############
# Convert to array and extract vmin/vmax
output_array = ds_geomedian[["red", "green", "blue"]].to_array()
percentiles = output_array.quantile(q=(0.02, 0.98)).values
# Create the plot with one subplot more than timesteps in the
# dataset. Figure width is set based on the number of subplots
# and aspect ratio
n_obs = output_array.sizes["timestep"]
ratio = output_array.sizes["x"] / output_array.sizes["y"]
fig, axes = plt.subplots(1,
n_obs + 1,
figsize=(5 * ratio * (n_obs + 1), 5))
fig.subplots_adjust(wspace=0.05, hspace=0.05)
# Add timesteps to the plot, set aspect to equal to preserve shape
for i, ax_i in enumerate(axes.flatten()[:n_obs]):
output_array.isel(timestep=i).plot.imshow(ax=ax_i,
vmin=percentiles[0],
vmax=percentiles[1])
ax_i.get_xaxis().set_visible(False)
ax_i.get_yaxis().set_visible(False)
ax_i.set_aspect("equal")
# Add change heatmap panel to final subplot. Heatmap is computed
# by first taking the log of the array (so change in dark areas
# can be identified), then computing standard deviation between
# all timesteps
(np.log(output_array).std(dim=["timestep"]).mean(
dim="variable").plot.imshow(ax=axes.flatten()[-1],
robust=True,
cmap="magma",
add_colorbar=False))
axes.flatten()[-1].get_xaxis().set_visible(False)
axes.flatten()[-1].get_yaxis().set_visible(False)
axes.flatten()[-1].set_aspect("equal")
axes.flatten()[-1].set_title("Change heatmap")
# Export to file
date_string = "_".join(time_range)
ts_v = list(time_step.values())[0]
ts_k = list(time_step.keys())[0]
fig.savefig(
f"filmstrip_{output_name}_{date_string}_{ts_v}{ts_k}.png",
dpi=150,
bbox_inches="tight",
pad_inches=0.1,
)
# close dask client
client.shutdown()
return ds_geomedian
+337
View File
@@ -0,0 +1,337 @@
# crophealth.py
'''
Functions for loading and interacting with data in the crop health notebook,
inside the Real_world_examples folder.
'''
# Load modules
# 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'
from ipyleaflet import (
Map,
GeoJSON,
DrawControl,
basemaps
)
import datetime as dt
import datacube
from osgeo import ogr
import matplotlib as mpl
import matplotlib.pyplot as plt
import rasterio
from rasterio.features import geometry_mask
import xarray as xr
from IPython.display import display
import warnings
import ipywidgets as widgets
import json
import geopandas as gpd
from io import BytesIO
# Load utility functions
from deafrica_tools.datahandling import load_ard
from deafrica_tools.spatial import xr_rasterize
from deafrica_tools.bandindices import calculate_indices
def load_crophealth_data(lat, lon, buffer, date):
"""
Loads Sentinel-2 analysis-ready data (ARD) product for the crop health
case-study area over the last two years.
Last modified: April 2020
Parameters
----------
lat: float
The central latitude to analyse
lon: float
The central longitude to analyse
buffer:
The number of square degrees to load around the central latitude and longitude.
For reasonable loading times, set this as `0.1` or lower.
date:
The most recent date to show data for.
The app will automatically load all data available for the two years prior to this date.
Returns
----------
ds: xarray.Dataset
data set containing combined, masked data
Masked values are set to 'nan'
"""
# Suppress warnings
warnings.filterwarnings('ignore')
# Initialise the data cube. 'app' argument is used to identify this app
dc = datacube.Datacube(app='Crophealth-app')
# Define area to load
latitude = (lat - buffer, lat + buffer)
longitude = (lon - buffer, lon + buffer)
# Specify the date range
# Calculated as today's date, subtract 730 days to collect two years of data
# Dates are converted to strings as required by loading function below
end_date = dt.datetime.strptime(date, "%Y-%m-%d")
start_date = end_date - dt.timedelta(days=730)
time = (start_date.strftime("%Y-%m-%d"), end_date.strftime("%Y-%m-%d"))
# Construct the data cube query
products = ["s2_l2a"]
query = {
'x': longitude,
'y': latitude,
'time': time,
'measurements': [
'red',
'green',
'blue',
'nir',
'swir_2'
],
'output_crs': 'EPSG:6933',
'resolution': (-20, 20)
}
# Load the data and mask out bad quality pixels
ds = load_ard(dc, products=products, min_gooddata=0.5, **query)
# Calculate the normalised difference vegetation index (NDVI) across
# all pixels for each image.
# This is stored as an attribute of the data
ds = calculate_indices(ds, index='NDVI', satellite_mission='s2')
# Return the data
return(ds)
def run_crophealth_app(ds, lat, lon, buffer):
"""
Plots an interactive map of the crop health case-study area and allows
the user to draw polygons. This returns a plot of the average NDVI value
in the polygon area.
Last modified: January 2020
Parameters
----------
ds: xarray.Dataset
data set containing combined, masked data
Masked values are set to 'nan'
lat: float
The central latitude corresponding to the area of loaded ds
lon: float
The central longitude corresponding to the area of loaded ds
buffer:
The number of square degrees to load around the central latitude and longitude.
For reasonable loading times, set this as `0.1` or lower.
"""
# Suppress warnings
warnings.filterwarnings('ignore')
# Update plotting functionality through rcParams
mpl.rcParams.update({'figure.autolayout': True})
# Define polygon bounds
latitude = (lat - buffer, lat + buffer)
longitude = (lon - buffer, lon + buffer)
# Define the bounding box that will be overlayed on the interactive map
# The bounds are hard-coded to match those from the loaded data
geom_obj = {
"type": "Feature",
"properties": {
"style": {
"stroke": True,
"color": 'red',
"weight": 4,
"opacity": 0.8,
"fill": True,
"fillColor": False,
"fillOpacity": 0,
"showArea": True,
"clickable": True
}
},
"geometry": {
"type": "Polygon",
"coordinates": [
[
[
longitude[0],
latitude[0]
],
[
longitude[1],
latitude[0]
],
[
longitude[1],
latitude[1]
],
[
longitude[0],
latitude[1]
],
[
longitude[0],
latitude[0]
]
]
]
}
}
# Create a map geometry from the geom_obj dictionary
# center specifies where the background map view should focus on
# zoom specifies how zoomed in the background map should be
loadeddata_geometry = ogr.CreateGeometryFromJson(str(geom_obj['geometry']))
loadeddata_center = [
loadeddata_geometry.Centroid().GetY(),
loadeddata_geometry.Centroid().GetX()
]
loadeddata_zoom = 16
# define the study area map
studyarea_map = Map(
center=loadeddata_center,
zoom=loadeddata_zoom,
basemap=basemaps.Esri.WorldImagery
)
# define the drawing controls
studyarea_drawctrl = DrawControl(
polygon={"shapeOptions": {"fillOpacity": 0}},
marker={},
circle={},
circlemarker={},
polyline={},
)
# add drawing controls and data bound geometry to the map
studyarea_map.add_control(studyarea_drawctrl)
studyarea_map.add_layer(GeoJSON(data=geom_obj))
# Index to count drawn polygons
polygon_number = 0
# Define widgets to interact with
instruction = widgets.Output(layout={'border': '1px solid black'})
with instruction:
print("Draw a polygon within the red box to view a plot of "
"average NDVI over time in that area.")
info = widgets.Output(layout={'border': '1px solid black'})
with info:
print("Plot status:")
fig_display = widgets.Output(layout=widgets.Layout(
width="50%", # proportion of horizontal space taken by plot
))
with fig_display:
plt.ioff()
fig, ax = plt.subplots(figsize=(8, 6))
ax.set_ylim([0, 1])
colour_list = plt.rcParams['axes.prop_cycle'].by_key()['color']
# Function to execute each time something is drawn on the map
def handle_draw(self, action, geo_json):
nonlocal polygon_number
# Execute behaviour based on what the user draws
if geo_json['geometry']['type'] == 'Polygon':
info.clear_output(wait=True) # wait=True reduces flicker effect
# Save geojson polygon to io temporary file to be rasterized later
jsonData = json.dumps(geo_json)
binaryData = jsonData.encode()
io = BytesIO(binaryData)
io.seek(0)
# Read the polygon as a geopandas dataframe
gdf = gpd.read_file(io)
gdf.crs = "EPSG:4326"
# Convert the drawn geometry to pixel coordinates
xr_poly = xr_rasterize(gdf, ds.NDVI.isel(time=0), crs='EPSG:6933')
# Construct a mask to only select pixels within the drawn polygon
masked_ds = ds.NDVI.where(xr_poly)
masked_ds_mean = masked_ds.mean(dim=['x', 'y'], skipna=True)
colour = colour_list[polygon_number % len(colour_list)]
# Add a layer to the map to make the most recently drawn polygon
# the same colour as the line on the plot
studyarea_map.add_layer(
GeoJSON(
data=geo_json,
style={
'color': colour,
'opacity': 1,
'weight': 4.5,
'fillOpacity': 0.0
}
)
)
# add new data to the plot
xr.plot.plot(
masked_ds_mean,
marker='*',
color=colour,
ax=ax
)
# reset titles back to custom
ax.set_title("Average NDVI from Sentinel-2")
ax.set_xlabel("Date")
ax.set_ylabel("NDVI")
# refresh display
fig_display.clear_output(wait=True) # wait=True reduces flicker effect
with fig_display:
display(fig)
with info:
print("Plot status: polygon sucessfully added to plot.")
# Iterate the polygon number before drawing another polygon
polygon_number = polygon_number + 1
else:
info.clear_output(wait=True)
with info:
print("Plot status: this drawing tool is not currently "
"supported. Please use the polygon tool.")
# call to say activate handle_draw function on draw
studyarea_drawctrl.on_draw(handle_draw)
with fig_display:
# TODO: update with user friendly something
display(widgets.HTML(""))
# Construct UI:
# +-----------------------+
# | instruction |
# +-----------+-----------+
# | map | plot |
# | | |
# +-----------+-----------+
# | info |
# +-----------------------+
ui = widgets.VBox([instruction,
widgets.HBox([studyarea_map, fig_display]),
info])
display(ui)
+505
View File
@@ -0,0 +1,505 @@
"""
Digital Earth Africa Coastline widget, which can be used to
interactively extract shoreline data using transects.
"""
# 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 fiona
import sys
import datacube
import warnings
import matplotlib.pyplot as plt
from datacube.utils.geometry import CRS
from ipyleaflet import (
WMSLayer,
basemaps,
basemap_to_tiles,
Map,
DrawControl,
WidgetControl,
LayerGroup,
LayersControl,
GeoData,
)
from traitlets import Unicode
from ipywidgets import (
GridspecLayout,
Button,
Layout,
HBox,
VBox,
HTML,
Output,
)
import json
import geopandas as gpd
from io import BytesIO
import ipywidgets as widgets
import deafrica_tools.app.widgetconstructors as deawidgets
from deafrica_tools.coastal import get_coastlines, transect_distances
from owslib.wms import WebMapService
def make_box_layout():
return Layout(
# border='solid 1px black',
margin='0px 10px 10px 0px',
padding='5px 5px 5px 5px',
width='100%',
height='100%',
)
def create_expanded_button(description, button_style):
return Button(
description=description,
button_style=button_style,
layout=Layout(width="auto", height="auto"),
)
class transect_app(HBox):
def __init__(self):
super().__init__()
######################
# INITIAL ATTRIBUTES #
######################
self.output_name = "example_output"
self.export_csv = False
self.export_plot = False
self.product_list = [
("ESRI World Imagery", "none"),
("Open Street Map", "open_street_map"),
]
self.product = self.product_list[0][1]
self.mode_list = [('Distance', 'distance'), ('Width', 'width')]
self.mode = self.mode_list[0][1]
self.target = None
self.action = None
self.gdf_drawn = None
self.gdf_uploaded = None
##################
# HEADER FOR APP #
##################
# Create the Header widget
header_title_text = "<h3>Digital Earth Africa Coastlines shoreline transect extraction</h3>"
instruction_text = "Select parameters and draw a transect on the map to extract shoreline data. <b>In distance mode</b>, draw a transect line starting from land that crosses multiple shorelines. <br><b>In width mode</b>, draw a transect line that intersects shorelines at least twice. Alternatively, <b>upload an vector file</b> to extract shoreline data for multiple existing transects."
self.header = deawidgets.create_html(
f"{header_title_text}<p>{instruction_text}</p>")
self.header.layout = make_box_layout()
#####################################
# HANDLER FUNCTION FOR DRAW CONTROL #
#####################################
# Define the action to take once something is drawn on the map
def update_geojson(target, action, geo_json):
# Remove previously uploaded data if present
self.gdf_uploaded = None
fileupload_transects._counter = 0
# Get data from action
self.action = action
# Convert data to geopandas
json_data = json.dumps(geo_json)
binary_data = json_data.encode()
io = BytesIO(binary_data)
io.seek(0)
gdf = gpd.read_file(io)
gdf.crs = "EPSG:4326"
# Convert to WGS 84 / NSIDC EASE-Grid 2.0 Global and compute area
gdf_drawn_nsidc = gdf.copy().to_crs("EPSG:6933")
m2_per_km2 = 10**6
area = gdf_drawn_nsidc.envelope.area.values[0] / m2_per_km2
polyarea_label = 'Total area of DE Africa Coastlines data to extract'
polyarea_text = f"<b>{polyarea_label}</b>: {area:.2f} km<sup>2</sup>"
# Test area size
if area <= 50000:
confirmation_text = '<span style="color: #33cc33"> <b>(Area to extract falls within recommended limit; click "Extract shoreline data" to continue)</b></span>'
self.header.value = header_title_text + polyarea_text + confirmation_text
self.gdf_drawn = gdf
else:
warning_text = '<span style="color: #ff5050"> <b>(Area to extract is too large, please select a smaller transect)</b></span>'
self.header.value = header_title_text + polyarea_text + warning_text
self.gdf_drawn = None
###########################
# WIDGETS FOR APP OUTPUTS #
###########################
self.status_info = Output(layout=make_box_layout())
self.output_plot = Output(layout=make_box_layout())
#########################################
# MAP WIDGET, DRAWING TOOLS, WMS LAYERS #
#########################################
# Create drawing tools
desired_drawtools = ['polyline']
draw_control = deawidgets.create_drawcontrol(desired_drawtools)
# Load DEACoastLines WMS
deacl_url = "https://geoserver.digitalearth.africa/geoserver/wms"
deacl_layer = "coastlines:DEAfrica_Coastlines"
deacoastlines = WMSLayer(
url=deacl_url,
layers=deacl_layer,
format='image/png',
transparent=True,
attribution='DE Africa Coastlines © 2022 Digital Earth Africa')
# Begin by displaying an empty layer group, and update the group with desired WMS on interaction.
self.map_layers = LayerGroup(layers=(deacoastlines,))
self.map_layers.name = 'Map Overlays'
# Create map widget
self.m = deawidgets.create_map(map_center=(0.5273, 25.1367),
zoom_level=3,
basemap=basemaps.Esri.WorldImagery)
self.m.layout = make_box_layout()
# Add tools to map widget
self.m.add_control(draw_control)
self.m.add_layer(self.map_layers)
# Store current basemap for future use
self.basemap = self.m.basemap
############################
# WIDGETS FOR APP CONTROLS #
############################
# Create parameter widgets
text_output_name = deawidgets.create_inputtext(self.output_name,
self.output_name)
checkbox_csv = deawidgets.create_checkbox(self.export_csv,
'Distance table (.csv)')
checkbox_plot = deawidgets.create_checkbox(self.export_plot,
'Figure (.png)')
deaoverlay_dropdown = deawidgets.create_dropdown(
self.product_list, self.product_list[0][1])
mode_dropdown = deawidgets.create_dropdown(self.mode_list,
self.mode_list[0][1])
run_button = create_expanded_button("Extract shoreline data", "info")
fileupload_transects = widgets.FileUpload(accept='', multiple=True)
####################################
# UPDATE FUNCTIONS FOR EACH WIDGET #
####################################
# Run update functions whenever various widgets are changed.
text_output_name.observe(self.update_text_output_name, "value")
checkbox_csv.observe(self.update_checkbox_csv, "value")
checkbox_plot.observe(self.update_checkbox_plot, "value")
deaoverlay_dropdown.observe(self.update_deaoverlay, "value")
mode_dropdown.observe(self.update_mode, "value")
run_button.on_click(self.run_app)
draw_control.on_draw(update_geojson)
fileupload_transects.observe(self.update_fileupload_transects, "value")
##################################
# COLLECTION OF ALL APP CONTROLS #
##################################
parameter_selection = VBox([
HTML("<b>Output name:</b>"), text_output_name,
HTML(
'<b>Transect extraction mode:</b><br><img src="https://i.imgur.com/9fdTH9C.png">'
),
mode_dropdown,
HTML("<b></br>Output files:</b>"),
checkbox_plot,
checkbox_csv,
HTML(
"</br><i><b>Advanced</b></br>Upload a GeoJSON or ESRI "
"Shapefile (<5 mb) containing one or more transect lines.</i>"),
fileupload_transects
])
map_selection = VBox([
HTML("</br><b>Map overlay:</b>"),
deaoverlay_dropdown,
])
parameter_selection.layout = make_box_layout()
map_selection.layout = make_box_layout()
###############################
# SPECIFICATION OF APP LAYOUT #
###############################
# 0 1 2 3 4 5 6 7 8 9
# ---------------------------------------------
# 0 | Header | Map sel. |
# ---------------------------------------------
# 1 | Params | |
# 2 | | |
# 3 | | |
# 4 | | Map |
# 5 | | |
# ---------- |
# 6 | Run | |
# ---------------------------------------------
# 7 | Status info |
# ---------------------------------------------
# 8 | |
# 9 | Output/figure |
# 10 | |
# 11 | ------------------------------------------|
# Create the layout #[rowspan, colspan]
grid = GridspecLayout(12, 10, height="1350px", width="auto")
# Header and controls
grid[0, :8] = self.header
grid[0, 8:] = map_selection
grid[1:6, 0:2] = parameter_selection
grid[6, 0:2] = run_button
# Status info, map and plot
grid[1:7, 2:] = self.m # map
grid[7:8, :] = self.status_info
grid[8:, :] = self.output_plot
# Display using HBox children attribute
self.children = [grid]
######################################
# DEFINITION OF ALL UPDATE FUNCTIONS #
######################################
# Set the output csv
def update_fileupload_transects(self, change):
# Clear any drawn data if present
self.gdf_drawn = None
# Save to file
for uploaded_filename in change.new.keys():
with open(uploaded_filename, "wb") as output_file:
content = change.new[uploaded_filename]['content']
output_file.write(content)
with self.status_info:
try:
print('Loading vector data...', end='\r')
valid_files = [
file for file in change.new.keys()
if file.lower().endswith(('.shp', '.geojson'))
]
valid_file = valid_files[0]
transect_gdf = (gpd.read_file(valid_file).to_crs(
"EPSG:4326").explode().reset_index(drop=True))
# Use ID column if it exists
if 'id' in transect_gdf:
transect_gdf = transect_gdf.set_index('id')
print(f"Uploaded '{valid_file}'; automatically labelling "
"transects using column 'id'.")
else:
print(
f"Uploaded '{valid_file}'; no 'id' column detected, "
f"labelling transects from 0 to {len(transect_gdf.index) - 1}."
)
# Create a geodata
geodata = GeoData(geo_dataframe=transect_gdf,
style={
'color': 'black',
'weight': 3
})
# Add to map
xmin, ymin, xmax, ymax = transect_gdf.total_bounds
self.m.fit_bounds([[ymin, xmin], [ymax, xmax]])
self.m.add_layer(geodata)
# If completed, add to attribute
self.gdf_uploaded = transect_gdf
except IndexError:
print(
"Cannot read uploaded files. Please ensure that data is "
"in either GeoJSON or ESRI Shapefile format.",
end='\r')
self.gdf_uploaded = None
except fiona.errors.DriverError:
print(
"Shapefile is invalid. Please ensure that all shapefile "
"components (e.g. .shp, .shx, .dbf, .prj) are uploaded.",
end='\r')
self.gdf_uploaded = None
# Set output name
def update_text_output_name(self, change):
self.output_name = change.new
# Output CSV
def update_checkbox_csv(self, change):
self.export_csv = change.new
# Output plot
def update_checkbox_plot(self, change):
self.export_plot = change.new
# Set mode
def update_mode(self, change):
self.mode = change.new
# Update product
def update_deaoverlay(self, change):
self.product = change.new
# Load DE Africa CoastLines WMS
deacl_url = "https://geoserver.digitalearth.africa/geoserver/wms"
deacl_layer = "coastlines:DEAfrica_Coastlines"
deacoastlines = WMSLayer(
url=deacl_url,
layers=deacl_layer,
format="image/png",
transparent=True,
attribution="DE Africa Coastlines © 2022 Digital Earth Africa")
if self.product == "none":
self.map_layers.clear_layers()
self.map_layers.add_layer(deacoastlines)
elif self.product == "open_street_map":
self.map_layers.clear_layers()
layer = basemap_to_tiles(basemaps.OpenStreetMap.Mapnik)
self.map_layers.add_layer(layer)
self.map_layers.add_layer(deacoastlines)
def run_app(self, change):
# Clear progress bar and output areas before running
self.status_info.clear_output()
self.output_plot.clear_output()
# Run DE Africa Coastlines analysis
with self.status_info:
warnings.filterwarnings("ignore")
# Load transects from either map or uploaded files
if self.gdf_uploaded is not None:
transect_gdf = self.gdf_uploaded
run_text = 'uploaded file'
elif self.gdf_drawn is not None:
transect_gdf = self.gdf_drawn
transect_gdf.index = [self.output_name]
run_text = 'selected transect'
else:
print(f'No transect drawn or uploaded. Please select a transect on the map, or upload a GeoJSON or ESRI Shapefile.',
end='\r')
transect_gdf = None
# If valid data was returned, load DEA Coastlines data
if transect_gdf is not None:
# Load Coastlines data from WFS
deacl_gdf = get_coastlines(bbox=transect_gdf)
# Test that data was correctly returned
if len(deacl_gdf.index) > 0:
# Dissolve by year to remove duplicates, then sort by date
deacl_gdf = deacl_gdf.dissolve(by='year', as_index=False)
deacl_gdf['year'] = deacl_gdf.year.astype(int)
deacl_gdf = deacl_gdf.sort_values('year')
deacl_gdf = deacl_gdf.set_index('year')
else:
print(
"No annual shoreline data was found near the "
"supplied transect. Please draw or select a new "
"transect.",
end='\r')
deacl_gdf = None
# If valid DEA Coastlines data returned, calculate distances
if deacl_gdf is not None:
print(f'Analysing transect distances using "{self.mode}" mode...',
end='\r')
dist_df = transect_distances(
transect_gdf.to_crs("EPSG:6933"),
deacl_gdf.to_crs("EPSG:6933"),
mode=self.mode)
# If valid data was produced:
if dist_df.any(axis=None):
# Successful output
print(f'DE Africa Coastlines data successfully extracted for {run_text}.')
# Export distance data
if self.export_csv:
# Create folder if required and set path
out_dir = 'deacoastlines_outputs'
os.makedirs(out_dir, exist_ok=True)
csv_filename = f"{out_dir}/{self.output_name}.csv"
# Export to file
dist_df.to_csv(csv_filename, index_label="Transect")
print(f'Distance data exported to "{csv_filename}".')
# Generate plot
with self.output_plot:
fig, ax = plt.subplots(constrained_layout=True,
figsize=(15, 5.5))
dist_df.T.plot(ax=ax, linewidth=3)
ax.legend(frameon=False, ncol=3, title='Transect')
ax.set_title(f"Digital Earth Africa Coastlines transect extraction - {self.output_name}")
ax.set_ylabel(f"Along-transect {self.mode} (m)")
ax.set_xlim(dist_df.T.index[0], dist_df.T.index[-1])
# Hide the right and top spines
ax.spines['right'].set_visible(False)
ax.spines['top'].set_visible(False)
# Only show ticks on the left and bottom spines
ax.yaxis.set_ticks_position('left')
ax.xaxis.set_ticks_position('bottom')
plt.show()
# Export plot
with self.status_info:
if self.export_plot:
# Create folder if required and set path
out_dir = 'deacoastlines_outputs'
os.makedirs(out_dir, exist_ok=True)
figure_filename = f"{out_dir}/{self.output_name}.png"
# Export to file
fig.savefig(figure_filename)
print(f'Figure exported to "{figure_filename}".')
else:
print(
"No valid shoreline data intersects with the "
"supplied transect. This can occur if:\n\n"
" - the transect does not intersect with any shorelines\n"
" - the transect intersects with shorelines more than once in 'distance' mode\n"
" - the transect intersects with shorelines only once in 'width' mode\n\n"
"Please draw or upload a new transect.",
end='\r')
File diff suppressed because it is too large Load Diff
+126
View File
@@ -0,0 +1,126 @@
"""
Geomedian widget: generates an interactive visualisation of
the geomedian summary statistic.
"""
# Load modules
import ipywidgets as widgets
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
import numpy as np
import xarray as xr
from odc.algo import xr_geomedian
def run_app():
"""
An interactive app that allows users to visualise the difference between the median and geomedian time-series summary statistics. By modifying the red-green-blue values of three timesteps for a given pixel, the user changes the output summary statistics.
This allows a visual representation of the difference through the output values, RGB colour, as well as showing values plotted as a vector on a 3-dimensional space.
Last modified: December 2021
"""
# Define the red-green-blue sliders for timestep 1
p1r = widgets.IntSlider(description='Red', max=255, value=58)
p1g = widgets.IntSlider(description='Green', max=255, value=153)
p1b = widgets.IntSlider(description='Blue', max=255, value=68)
# Define the red-green-blue sliders for timestep 2
p2r = widgets.IntSlider(description='Red', max=255, value=208)
p2g = widgets.IntSlider(description='Green', max=255, value=221)
p2b = widgets.IntSlider(description='Blue', max=255, value=203)
# Define the red-green-blue sliders for timestep 3
p3r = widgets.IntSlider(description='Red', max=255, value=202)
p3g = widgets.IntSlider(description='Green', max=255, value=82)
p3b = widgets.IntSlider(description='Blue', max=255, value=33)
# Define the median calculation for the timesteps
def f(p1r, p1g, p1b, p2r, p2g, p2b, p3r, p3g, p3b):
print('Red Median = {}'.format(np.median([p1r, p2r, p3r])))
print('Green Median = {}'.format(np.median([p1g, p2g, p3g])))
print('Blue Median = {}'.format(np.median([p1b, p2b, p3b])))
# Define the geomedian calculation for the timesteps
def g(p1r, p1g, p1b, p2r, p2g, p2b, p3r, p3g, p3b):
print('Red Geomedian = {:.2f}'.format(xr_geomedian(xr.Dataset({"red": (("x", "y", "time"), [[[np.float32(p1r), np.float32(p2r), np.float32(p3r)]]]), "green": (("x", "y", "time"), [[[np.float32(p1g), np.float32(p2g), np.float32(p3g)]]]), "blue": (("x", "y", "time"), [[[np.float32(p1b), np.float32(p2b), np.float32(p3b)]]])})).red.values.ravel()[0]))
print('Green Geomedian = {:.2f}'.format(xr_geomedian(xr.Dataset({"red": (("x", "y", "time"), [[[np.float32(p1r), np.float32(p2r), np.float32(p3r)]]]), "green": (("x", "y", "time"), [[[np.float32(p1g), np.float32(p2g), np.float32(p3g)]]]), "blue": (("x", "y", "time"), [[[np.float32(p1b), np.float32(p2b), np.float32(p3b)]]])})).green.values.ravel()[0]))
print('Blue Geomedian = {:.2f}'.format(xr_geomedian(xr.Dataset({"red": (("x", "y", "time"), [[[np.float32(p1r), np.float32(p2r), np.float32(p3r)]]]), "green": (("x", "y", "time"), [[[np.float32(p1g), np.float32(p2g), np.float32(p3g)]]]), "blue": (("x", "y", "time"), [[[np.float32(p1b), np.float32(p2b), np.float32(p3b)]]])})).blue.values.ravel()[0]))
# Define the Timestep 1 box colour
def h(p1r, p1g, p1b):
fig1, axes1 = plt.subplots(figsize=(2,2))
fig1 = plt.imshow([[(p1r, p1g, p1b)]])
axes1.set_title('Timestep 1')
axes1.axis('off')
plt.show(fig1)
# Define the Timestep 2 box colour
def hh(p2r, p2g, p2b):
fig2, axes2 = plt.subplots(figsize=(2,2))
fig2 = plt.imshow([[(p2r, p2g, p2b)]])
axes2.set_title('Timestep 2')
axes2.axis('off')
plt.show(fig2)
# Define the Timestep 3 box colour
def hhh(p3r, p3g, p3b):
fig3, axes3 = plt.subplots(figsize=(2,2))
fig3 = plt.imshow([[(p3r, p3g, p3b)]])
axes3.set_title('Timestep 3')
axes3.axis('off')
plt.show(fig3)
# Define the Median RGB colour box
def i(p1r, p1g, p1b, p2r, p2g, p2b, p3r, p3g, p3b):
fig4, axes4 = plt.subplots(figsize=(3,3))
fig4 = plt.imshow([[(int(np.median([p1r, p2r, p3r])), int(np.median([p1g, p2g, p3g])), int(np.median([p1b, p2b, p3b])))]])
axes4.set_title('Median RGB - All timesteps')
axes4.axis('off')
plt.show(fig4)
# Define the Geomedian RGB colour box
def ii(p1r, p1g, p1b, p2r, p2g, p2b, p3r, p3g, p3b):
fig5, axes5 = plt.subplots(figsize=(3,3))
fig5 = plt.imshow([[(int(xr_geomedian(xr.Dataset({"red": (("x", "y", "time"), [[[np.float32(p1r), np.float32(p2r), np.float32(p3r)]]]), "green": (("x", "y", "time"), [[[np.float32(p1g), np.float32(p2g), np.float32(p3g)]]]), "blue": (("x", "y", "time"), [[[np.float32(p1b), np.float32(p2b), np.float32(p3b)]]])})).red.values.ravel()[0]), int(xr_geomedian(xr.Dataset({"red": (("x", "y", "time"), [[[np.float32(p1r), np.float32(p2r), np.float32(p3r)]]]), "green": (("x", "y", "time"), [[[np.float32(p1g), np.float32(p2g), np.float32(p3g)]]]), "blue": (("x", "y", "time"), [[[np.float32(p1b), np.float32(p2b), np.float32(p3b)]]])})).green.values.ravel()[0]), int(xr_geomedian(xr.Dataset({"red": (("x", "y", "time"), [[[np.float32(p1r), np.float32(p2r), np.float32(p3r)]]]), "green": (("x", "y", "time"), [[[np.float32(p1g), np.float32(p2g), np.float32(p3g)]]]), "blue": (("x", "y", "time"), [[[np.float32(p1b), np.float32(p2b), np.float32(p3b)]]])})).blue.values.ravel()[0]))]])
axes5.set_title('Geomedian RGB - All timesteps')
axes5.axis('off')
plt.show(fig5)
# Define 3-D axis to display vectors on
def j(p1r, p1g, p1b, p2r, p2g, p2b, p3r, p3g, p3b):
fig6 = plt.figure()
axes6 = fig6.add_subplot(111, projection='3d')
x = [p1r, p2r, p3r, int(np.median([p1r, p2r, p3r])), int(xr_geomedian(xr.Dataset({"red": (("x", "y", "time"), [[[np.float32(p1r), np.float32(p2r), np.float32(p3r)]]]), "green": (("x", "y", "time"), [[[np.float32(p1g), np.float32(p2g), np.float32(p3g)]]]), "blue": (("x", "y", "time"), [[[np.float32(p1b), np.float32(p2b), np.float32(p3b)]]])})).red.values.ravel()[0])]
y = [p1g, p2g, p3g, int(np.median([p1g, p2g, p3g])), int(xr_geomedian(xr.Dataset({"red": (("x", "y", "time"), [[[np.float32(p1r), np.float32(p2r), np.float32(p3r)]]]), "green": (("x", "y", "time"), [[[np.float32(p1g), np.float32(p2g), np.float32(p3g)]]]), "blue": (("x", "y", "time"), [[[np.float32(p1b), np.float32(p2b), np.float32(p3b)]]])})).green.values.ravel()[0])]
z = [p1b, p2b, p3b, int(np.median([p1b, p2b, p3b])), int(xr_geomedian(xr.Dataset({"red": (("x", "y", "time"), [[[np.float32(p1r), np.float32(p2r), np.float32(p3r)]]]), "green": (("x", "y", "time"), [[[np.float32(p1g), np.float32(p2g), np.float32(p3g)]]]), "blue": (("x", "y", "time"), [[[np.float32(p1b), np.float32(p2b), np.float32(p3b)]]])})).blue.values.ravel()[0])]
labels = [' 1', ' 2', ' 3', ' median', ' geomedian']
axes6.scatter(x, y, z, c=['black','black','black','r', 'blue'], marker='o')
axes6.set_xlabel('Red')
axes6.set_ylabel('Green')
axes6.set_zlabel('Blue')
axes6.set_xlim3d(0, 255)
axes6.set_ylim3d(0, 255)
axes6.set_zlim3d(0, 255)
for ax, ay, az, label in zip(x, y, z, labels):
axes6.text(ax, ay, az, label)
plt.title('Each band represents a dimension.')
plt.show()
# Define outputs
outf = widgets.interactive_output(f, {'p1r': p1r, 'p2r': p2r,'p3r': p3r, 'p1g': p1g, 'p2g': p2g,'p3g': p3g, 'p1b': p1b, 'p2b': p2b,'p3b': p3b})
outg = widgets.interactive_output(g, {'p1r': p1r, 'p2r': p2r,'p3r': p3r, 'p1g': p1g, 'p2g': p2g,'p3g': p3g, 'p1b': p1b, 'p2b': p2b,'p3b': p3b})
outh = widgets.interactive_output(h, {'p1r': p1r, 'p1g': p1g, 'p1b': p1b})
outhh = widgets.interactive_output(hh, {'p2r': p2r, 'p2g': p2g, 'p2b': p2b})
outhhh = widgets.interactive_output(hhh, {'p3r': p3r, 'p3g': p3g, 'p3b': p3b})
outi = widgets.interactive_output(i, {'p1r': p1r, 'p2r': p2r,'p3r': p3r, 'p1g': p1g, 'p2g': p2g,'p3g': p3g, 'p1b': p1b, 'p2b': p2b,'p3b': p3b})
outii = widgets.interactive_output(ii, {'p1r': p1r, 'p2r': p2r,'p3r': p3r, 'p1g': p1g, 'p2g': p2g,'p3g': p3g, 'p1b': p1b, 'p2b': p2b,'p3b': p3b})
outj = widgets.interactive_output(j, {'p1r': p1r, 'p2r': p2r,'p3r': p3r, 'p1g': p1g, 'p2g': p2g,'p3g': p3g, 'p1b': p1b, 'p2b': p2b,'p3b': p3b})
app_output = widgets.HBox([widgets.VBox([widgets.HBox([outh, widgets.VBox([ p1r, p1g, p1b])]), widgets.HBox([outhh, widgets.VBox([p2r, p2g, p2b])]), widgets.HBox([outhhh, widgets.VBox([ p3r, p3g, p3b])])]), widgets.VBox([widgets.HBox([widgets.VBox([outf, outi]), widgets.VBox([outg, outii])]), outj])])
return app_output
+372
View File
@@ -0,0 +1,372 @@
"""
Create an interactive map for selecting satellite imagery and exporting image files.
"""
# Load modules
import datacube
import itertools
import numpy as np
import matplotlib.pyplot as plt
from odc.ui import select_on_a_map
from datacube.utils.geometry import CRS
from datacube.utils import masking
from skimage import exposure
from ipyleaflet import (WMSLayer, basemaps, basemap_to_tiles)
from traitlets import Unicode
from deafrica_tools.spatial import reverse_geocode
from deafrica_tools.dask import create_local_dask_cluster
def select_region_app(date,
satellites,
size_limit=10000):
"""
An interactive app that allows the user to select a region from a
map using imagery from Sentinel-2 and Landsat. The output of this
function is used as the input to :func:`export_image_app` to export high-
resolution satellite images.
Last modified: September 2021
Parameters
----------
date : str
The exact date used to plot imagery on the interactive map
(e.g. ``date='1988-01-01'``).
satellites : str
The satellite data to plot on the interactive map. The
following options are supported:
``'Landsat-9'``: data from the Landsat 9 satellite
``'Landsat-8'``: data from the Landsat 8 satellite
``'Landsat-7'``: data from the Landsat 7 satellite
``'Landsat-5'``: data from the Landsat 5 satellite
``'Sentinel-2'``: data from Sentinel-2A and Sentinel-2B
``'Sentinel-2 geomedian'``: data from the Sentinel-2 annual geomedian
size_limit : int, optional
An optional size limit for the area selection in sq km.
Defaults to 10000 sq km.
Returns
-------
A dictionary containing:
* 'geopolygon' (defining the area to export imagery from),
* 'date' (date used to export imagery), and
* 'satellites' (the satellites from which to extract imagery).
These are passed to the :func:`export_image_app` function to export the image.
"""
########################
# Select and load data #
########################
# Load DEA WMS
class TimeWMSLayer(WMSLayer):
time = Unicode('').tag(sync=True, o=True)
# WMS layers
wms_params = {
'Landsat-9': 'ls9_sr',
'Landsat-8': 'ls8_sr',
'Landsat-7': 'ls7_sr',
'Landsat-5': 'ls5_sr',
'Sentinel-2': 's2_l2a',
'Sentinel-2 geomedian': 'gm_s2_annual'
}
time_wms = TimeWMSLayer(url='https://ows.digitalearth.africa/',
layers=wms_params[satellites],
time=date,
format='image/png',
transparent=True,
attribution='Digital Earth Africa')
# Plot interactive map to select area
basemap = basemap_to_tiles(basemaps.OpenStreetMap.Mapnik)
geopolygon = select_on_a_map(height='1000px',
layers=(
basemap,
time_wms,
),
center=(4, 20),
zoom=4)
# Test size of selected area
area = geopolygon.to_crs(crs=CRS('epsg:6933')).area / 1000000
if area > size_limit:
print(f'Warning: Your selected area is {area:.00f} sq km. '
f'Please select an area of less than {size_limit} sq km.'
f'\nTo select a smaller area, re-run the cell '
f'above and draw a new polygon.')
else:
return {'geopolygon': geopolygon,
'date': date,
'satellites': satellites}
def export_image_app(geopolygon,
date,
satellites,
style='True colour',
resolution=None,
vmin=0,
vmax=2000,
percentile_stretch=None,
power=None,
image_proc_funcs=None,
output_format="jpg",
standardise_name=False):
"""
Exports Digital Earth Africa satellite data as an image file
based on the extent and time period selected using
:func:`select_region_app`. The function supports Sentinel-2 and Landsat
data, creating True and False colour images.
By default, files are named using:
``"<product> - <YYYY-MM-DD> - <site, state> - <description>.png"``
Set ``standardise_name=True`` for a machine-readable name:
``"<product>_<YYYY-MM-DD>_<site-state>_<description>.png"``
Last modified: September 2021
Parameters
----------
geopolygon : datacube.utils.geometry object
A datacube geopolygon providing the spatial bounds used to load
satellite data.
date : str
The exact date used to extract imagery
(e.g. `date='1988-01-01'`).
satellites : str
The satellite data to be used to extract imagery. The
following options are supported:
``'Landsat-9'``: data from the Landsat 9 satellite
``'Landsat-8'``: data from the Landsat 8 satellite
``'Landsat-7'``: data from the Landsat 7 satellite
``'Landsat-5'``: data from the Landsat 5 satellite
``'Sentinel-2'``: data from Sentinel-2A and Sentinel-2B
``'Sentinel-2 geomedian'``: data from the Sentinel-2 annual geomedian
style : str, optional
The style used to produce the image. Two options are currently
supported:
* ``'True colour'``: Creates a true colour image using the red,
green and blue satellite bands
* ``'False colour'``: Creates a false colour image using
short-wave infrared, infrared and green satellite bands.
The specific bands used vary between Landsat and Sentinel-2.
resolution : tuple, optional
The spatial resolution to load data. By default, the tool will
automatically set the best possible resolution depending on the
satellites selected (i.e 30 m for Landsat, 10 m for Sentinel-2).
Increasing this (e.g. to ``resolution=(-100, 100)``) can be useful
for loading large spatial extents.
vmin, vmax : int or float
The minimum and maximum surface reflectance values used to
clip the resulting imagery to enhance contrast.
percentile_stretch : tuple of floats, optional
An tuple of two floats (between 0.00 and 1.00) that can be used
to clip the imagery to based on 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, ``vmin`` and ``vmax`` will have no effect.
power : float, optional
Raises imagery by a power to reduce bright features and
enhance dark features. This can add extra definition over areas
with extremely bright features like snow, beaches or salt pans.
image_proc_funcs : list of funcs, optional
An optional list containing functions that will be applied to
the output image. 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, e.g.:
``[lambda x: skimage.filters.unsharp_mask(x, radius=5, amount=0.2)]``
output_format : str, optional
The output file format of the image. Valid options include ``'jpg'``
and ``'png'``. Defaults to ``'jpg'``.
standardise_name : bool, optional
Whether to export the image file with a machine-readable
file name (e.g. ``<product>_<YYYY-MM-DD>_<site-state>_<description>.png``)
"""
###########################
# Set up satellite params #
###########################
sat_params = {
'Landsat-9': {
'products': ['ls9_sr'],
'resolution': [-30, 30],
'styles': {
'True colour': ['red', 'green', 'blue'],
'False colour': ['swir_1', 'nir', 'green']
}
},
'Landsat-8': {
'products': ['ls8_sr'],
'resolution': [-30, 30],
'styles': {
'True colour': ['red', 'green', 'blue'],
'False colour': ['swir_1', 'nir', 'green']
}
},
'Landsat-7': {
'products': ['ls7_sr'],
'resolution': [-30, 30],
'styles': {
'True colour': ['red', 'green', 'blue'],
'False colour': ['swir_1', 'nir', 'green']
}
},
'Landsat-5': {
'products': ['ls5_sr'],
'resolution': [-30, 30],
'styles': {
'True colour': ['red', 'green', 'blue'],
'False colour': ['swir_1', 'nir', 'green']
}
},
'Sentinel-2': {
'products': ['s2_l2a'],
'resolution': [-10, 10],
'styles': {
'True colour': ['red', 'green', 'blue'],
'False colour': ['swir_2', 'nir_1', 'green']
}
},
'Sentinel-2 geomedian': {
'products': ['gm_s2_annual'],
'resolution': [-10, 10],
'styles': {
'True colour': ['red', 'green', 'blue'],
'False colour': ['swir_2', 'nir_1', 'green']
}
},
}
#############
# Load data #
#############
# Connect to datacube database
dc = datacube.Datacube(app='Exporting_satellite_images')
# Configure local dask cluster
client = create_local_dask_cluster(return_client=True)
# Create query after adjusting interval time to UTC by
# adding a UTC offset of -10 hours.
start_date = np.datetime64(date)
query_params = {
'time': (str(start_date)),
'geopolygon': geopolygon
}
# Find matching datasets
dss = [
dc.find_datasets(product=i, **query_params)
for i in sat_params[satellites]['products']
]
dss = list(itertools.chain.from_iterable(dss))
# Get CRS and sensor
crs = str(dss[0].crs)
if satellites == 'Sentinel-2 geomedian':
sensor = satellites
else:
sensor = dss[0].metadata_doc['properties']['eo:platform'].capitalize()
sensor = sensor[0:-1].replace('_', '-') + sensor[-1].capitalize()
# Use resolution if provided, otherwise use default
if resolution:
sat_params[satellites]['resolution'] = resolution
load_params = {
'output_crs': crs,
'resolution': sat_params[satellites]['resolution'],
'resampling': 'bilinear'
}
# Load data from datasets
ds = dc.load(datasets=dss,
measurements=sat_params[satellites]['styles'][style],
group_by='solar_day',
dask_chunks={
'time': 1,
'x': 3000,
'y': 3000
},
**load_params,
**query_params)
ds = masking.mask_invalid_data(ds)
rgb_array = ds.isel(time=0).to_array().values
############
# Plotting #
############
# Create unique file name
centre_coords = geopolygon.centroid.coords[0][::-1]
site = reverse_geocode(coords=centre_coords)
fname = (f"{sensor} - {date} - {site} - {style}, "
f"{load_params['resolution'][1]} m resolution.{output_format}")
# Remove spaces and commas if requested
if standardise_name:
fname = fname.replace(' - ', '_').replace(', ',
'-').replace(' ',
'-').lower()
print(
f'\nExporting image to {fname}.\nThis may take several minutes to complete...'
)
# Convert to numpy array
rgb_array = np.transpose(rgb_array, axes=[1, 2, 0])
# If percentile stretch is supplied, calculate vmin and vmax
# from percentiles
if percentile_stretch:
vmin, vmax = np.nanpercentile(rgb_array, percentile_stretch)
# Raise by power to dampen bright features and enhance dark.
# Raise vmin and vmax by same amount to ensure proper stretch
if power:
rgb_array = rgb_array**power
vmin, vmax = vmin**power, vmax**power
# Rescale/stretch imagery between vmin and vmax
rgb_rescaled = exposure.rescale_intensity(rgb_array.astype(float),
in_range=(vmin, vmax),
out_range=(0.0, 1.0))
# Apply image processing funcs
if image_proc_funcs:
for i, func in enumerate(image_proc_funcs):
print(f'Applying custom function {i + 1}')
rgb_rescaled = func(rgb_rescaled)
# Plot RGB
plt.imshow(rgb_rescaled)
# Export to file
plt.imsave(fname=fname, arr=rgb_rescaled, format=output_format)
# Close dask client
client.shutdown()
print('Finished exporting image.')
+388
View File
@@ -0,0 +1,388 @@
"""
Wetlands insight tool widget, which can be used to run an interactive
version of the wetlands insight tool.
"""
# 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 datacube
import warnings
import seaborn as sns
import matplotlib.pyplot as plt
from datacube.utils.geometry import CRS
from ipyleaflet import (
WMSLayer,
basemaps,
basemap_to_tiles,
Map,
DrawControl,
WidgetControl,
LayerGroup,
LayersControl,
)
from traitlets import Unicode
from ipywidgets import (
GridspecLayout,
Button,
Layout,
HBox,
VBox,
HTML,
Output,
)
import json
import geopandas as gpd
from io import BytesIO
from dask.diagnostics import ProgressBar
import deafrica_tools
from deafrica_tools.dask import create_local_dask_cluster
from deafrica_tools.wetlands import WIT_drill
import deafrica_tools.app.widgetconstructors as deawidgets
def make_box_layout():
return Layout(
#border='solid 1px black',
margin='0px 10px 10px 0px',
padding='5px 5px 5px 5px',
width='100%',
height='100%',
)
def create_expanded_button(description, button_style):
return Button(
description=description,
button_style=button_style,
layout=Layout(width="auto", height="auto"),
)
class wit_app(HBox):
def __init__(self, lang=None):
super().__init__()
deafrica_tools.set_lang(lang)
##########################################################
# INITIAL ATTRIBUTES #
self.startdate = "2020-01-01"
self.enddate = "2020-03-01"
self.mingooddata = 0.0
self.resamplingfreq = "1M"
self.out_csv = "example_WIT.csv"
self.out_plot = "example_WIT.png"
self.product_list = [
(_("None"), "none"),
(_("ESRI World Imagery"), "esri_world_imagery"),
(_("Sentinel-2 Geomedian"), "gm_s2_annual"),
(_("Water Observations from Space"), "wofs_ls_summary_annual"),
]
self.product = self.product_list[0][1]
self.product_year = "2020-01-01"
self.target = None
self.action = None
self.gdf_drawn = None
##########################################################
# HEADER FOR APP #
# Create the Header widget
header_title_text = _("Wetlands Insight Tool")
instruction_text = _("Select parameters and AOI")
self.header = deawidgets.create_html(f"<h3>{header_title_text}</h3><p>{instruction_text}</p>")
self.header.layout = make_box_layout()
##########################################################
# HANDLER FUNCTION FOR DRAW CONTROL #
# Define the action to take once something is drawn on the map
def update_geojson(target, action, geo_json):
self.action = action
json_data = json.dumps(geo_json)
binary_data = json_data.encode()
io = BytesIO(binary_data)
io.seek(0)
gdf = gpd.read_file(io)
gdf.crs = "EPSG:4326"
self.gdf_drawn = gdf
gdf_drawn_epsg6933 = gdf.copy().to_crs("EPSG:6933")
m2_per_km2 = 10 ** 6
area = gdf_drawn_epsg6933.area.values[0] / m2_per_km2
polyarea_label = _('Total polygon area')
polyarea_text = f"<p><b>{polyarea_label}</b>: {area:.2f} km<sup>2</sup></p>"
if area <= 3000:
confirmation_text = '<p style="color:#33cc33;">' + _('Area falls within recommended limit') + '</p>'
self.header.value = header_title_text + polyarea_text + confirmation_text
else:
warning_text = '<p style="color:#ff5050;">' + _('Area is too large, please update your polygon') + '</p>'
self.header.value = header_title_text + polyarea_text + warning_text
##########################################################
# WIDGETS FOR APP OUTPUTS #
self.dask_client = Output(layout=make_box_layout())
self.progress_bar = Output(layout=make_box_layout())
self.wit_plot = Output(layout=make_box_layout())
self.progress_header = deawidgets.create_html("")
##########################################################
# MAP WIDGET, DRAWING TOOLS, WMS LAYERS #
# Create drawing tools
desired_drawtools = ['rectangle', 'polygon']
draw_control = deawidgets.create_drawcontrol(desired_drawtools)
# Begin by displaying an empty layer group, and update the group with desired WMS on interaction.
self.deafrica_layers = LayerGroup(layers=())
self.deafrica_layers.name = _('Map Overlays')
# Create map widget
self.m = deawidgets.create_map()
self.m.layout = make_box_layout()
# Add tools to map widget
self.m.add_control(draw_control)
self.m.add_layer(self.deafrica_layers)
# Store current basemap for future use
self.basemap = self.m.basemap
##########################################################
# WIDGETS FOR APP CONTROLS #
# Create parameter widgets
startdate_picker = deawidgets.create_datepicker()
enddate_picker = deawidgets.create_datepicker()
min_good_data = deawidgets.create_boundedfloattext(self.mingooddata, 0.0, 1.0, 0.05)
resampling_freq = deawidgets.create_inputtext(self.resamplingfreq, self.resamplingfreq)
output_csv = deawidgets.create_inputtext(self.out_csv, self.out_csv)
output_plot = deawidgets.create_inputtext(self.out_plot, self.out_plot)
deaoverlay_dropdown = deawidgets.create_dropdown(self.product_list, self.product_list[0][1])
run_button = create_expanded_button(_("Run"), "info")
##########################################################
# COLLECTION OF ALL APP CONTROLS #
parameter_selection = VBox(
[
HTML("<b>" + _("Map Overlay:") + "</b>"),
deaoverlay_dropdown,
HTML("<b>" + _("Start Date:") + "</b>"),
startdate_picker,
HTML("<b>" + _("End Date:") + "</b>"),
enddate_picker,
HTML("<b>" + _("Minimum Good Data:") + "</b>"),
min_good_data,
HTML("<b>" + _("Resampling Frequency:") + "</b>"),
resampling_freq,
HTML("<b>" + _("Output CSV:") + "</b>"),
output_csv,
HTML("<b>" + _("Output Plot:") + "</b>"),
output_plot,
]
)
parameter_selection.layout = make_box_layout()
##########################################################
# SPECIFICATION OF APP LAYOUT #
# Create the layout #[rowspan, colspan]
grid = GridspecLayout(11, 10, height="1100px", width="auto")
# Controls and Status
grid[0, :] = self.header
grid[1:6, 0:2] = parameter_selection
grid[6, 0:2] = run_button
# Dask and Progress info
grid[1, 7:] = self.dask_client
grid[2:7, 7:] = self.progress_bar
# Map
grid[1:7, 2:7] = self.m
# Plot
grid[7:, :] = self.wit_plot
# Display using HBox children attribute
self.children = [grid]
##########################################################
# SPECIFICATION UPDATE FUNCTIONS FOR EACH WIDGET #
# Run update functions whenever various widgets are changed.
startdate_picker.observe(self.update_startdate, "value")
enddate_picker.observe(self.update_enddate, "value")
min_good_data.observe(self.update_mingooddata, "value")
resampling_freq.observe(self.update_resamplingfreq, "value")
output_csv.observe(self.update_outputcsv, "value")
output_plot.observe(self.update_outputplot, "value")
deaoverlay_dropdown.observe(self.update_deaoverlay, "value")
run_button.on_click(self.run_app)
draw_control.on_draw(update_geojson)
##############################################################
# DEFINITION OF ALL UPDATE FUNCTIONS #
# set the start date to the new edited date
def update_startdate(self, change):
self.startdate = change.new
# set the end date to the new edited date
def update_enddate(self, change):
self.enddate = change.new
# set the min good data
def update_mingooddata(self, change):
self.mingooddata = change.new
# set the resampling frequency
def update_resamplingfreq(self, change):
self.resamplingfreq = change.new
# set the output csv
def update_outputcsv(self, change):
self.out_csv = change.new
# set the output plot
def update_outputplot(self, change):
self.out_plot = change.new
# Update product
def update_deaoverlay(self, change):
self.product = change.new
if self.product == "none":
self.deafrica_layers.clear_layers()
elif self.product == "esri_world_imagery":
self.deafrica_layers.clear_layers()
layer = basemap_to_tiles(basemaps.Esri.WorldImagery)
self.deafrica_layers.add_layer(layer)
else:
self.deafrica_layers.clear_layers()
layer = deawidgets.create_dea_wms_layer(self.product, self.product_year)
self.deafrica_layers.add_layer(layer)
def run_app(self, change):
# Clear progress bar and output areas before running
self.dask_client.clear_output()
self.progress_bar.clear_output()
self.wit_plot.clear_output()
# Connect to datacube database
dc = datacube.Datacube(app="wetland_app")
# Configure local dask cluster
with self.dask_client:
client = create_local_dask_cluster(
return_client=True, display_client=True
)
# Set any defaults
TCW_threshold = -0.035
dask_chunks = dict(x=1000, y=1000, time=1)
#check resampling freq
if self.resamplingfreq == 'None':
rsf = None
else:
rsf = self.resamplingfreq
self.progress_header.value = f"<h3>"+_("Progress")+"</h3>"
# run wetlands polygon drill
with self.progress_bar:
# with ProgressBar():
warnings.filterwarnings("ignore")
try:
df = WIT_drill(
gdf=self.gdf_drawn,
time=(self.startdate, self.enddate),
min_gooddata=self.mingooddata,
resample_frequency=rsf,
TCW_threshold=TCW_threshold,
export_csv=self.out_csv,
dask_chunks=dask_chunks,
verbose=False,
verbose_progress=True,
)
print(_("WIT complete"))
except AttributeError:
print(_("No polygon selected"))
# close down the dask client
client.shutdown()
# save the csv
if self.out_csv:
df.to_csv(self.out_csv, index_label="Datetime")
# ---Plotting------------------------------
with self.wit_plot:
fontsize = 17
plt.rcParams.update({"font.size": fontsize})
# set up color palette
pal = [
sns.xkcd_rgb["cobalt blue"],
sns.xkcd_rgb["neon blue"],
sns.xkcd_rgb["grass"],
sns.xkcd_rgb["beige"],
sns.xkcd_rgb["brown"],
]
# make a stacked area plot
plt.close("all")
fig, ax = plt.subplots(constrained_layout=True, figsize=(20, 6))
ax.stackplot(
df.index,
df.wofs_area_percent,
df.wet_percent,
df.green_veg_percent,
df.dry_veg_percent,
df.bare_soil_percent,
labels=[
_("open water"),
_("wet"),
_("green veg"),
_("dry veg"),
_("bare soil"),
],
colors=pal,
alpha=0.6,
)
# set axis limits to the min and max
ax.set_ylim(0, 100)
ax.set_xlim(df.index[0], df.index[-1])
ax.tick_params(axis="x", labelsize=fontsize)
# add a legend and a tight plot box
ax.legend(loc="lower left", framealpha=0.6)
ax.set_title(_("Percentage Fractional Cover, Wetness, and Water"))
# plt.tight_layout()
plt.show()
if self.out_plot:
# save the figure
fig.savefig(f"{self.out_plot}")
+367
View File
@@ -0,0 +1,367 @@
"""
Functions for easily defining widgets in the context of DE Africa notebooks.
These are largely customised wrappers around existing widgets.
"""
import ipyleaflet as leaflet
from ipyleaflet import LayersControl
import ipywidgets as widgets
from traitlets import Unicode
def create_datepicker(description='', value=None, layout={'width': '85%'}):
'''
Create a DatePicker widget
Last modified: July 2022
Parameters
----------
description : string
descirption label to attach
layout : dictionary
any layout commands for the widget
Returns
-------
date_picker : ipywidgets.widgets.widget_date.DatePicker
'''
date_picker = widgets.DatePicker(
description=description,
layout=layout,
disabled=False,
value=value
)
return date_picker
def create_inputtext(value, placeholder, description="", layout={'width': '85%'}):
'''
Create a Text widget
Last modified: October 2021
Parameters
----------
value : string
initial value of the widget
placeholder : string
placeholder text to display to the user before intput
description : string
descirption label to attach
layout : dictionary
any layout commands for the widget
Returns
-------
input_text : ipywidgets.widgets.widget_string.Text
'''
input_text = widgets.Text(
value=value,
placeholder=placeholder,
description=description,
layout=layout,
disabled=False
)
return input_text
def create_boundedfloattext(value, min_val, max_val, step_val, description="", layout={'width': '85%'}):
'''
Create a BoundedFloatText widget
Last modified: October 2021
Parameters
----------
value : float
initial value of the widget
min_val : float
minimum allowed value for the float
max_val : float
maximum allowed value for the float
step_val : float
allowed increment for the float
description : string
descirption label to attach
layout : dictionary
any layout commands for the widget
Returns
-------
float_text : ipywidgets.widgets.widget_float.BoundedFloatText
'''
float_text = widgets.BoundedFloatText(
value=value,
min=min_val,
max=max_val,
step=step_val,
description=description,
layout=layout,
disabled=False,
)
return float_text
def create_dropdown(options, value, description="", layout={'width': '85%'}):
'''
Create a Dropdown widget
Last modified: October 2021
Parameters
----------
options : list
a list of options for the user to select from
value : string
initial value of the widget
description : string
descirption label to attach
layout : dictionary
any layout commands for the widget
Returns
-------
dropdown : ipywidgets.widgets.widget_selection.Dropdown
'''
dropdown = widgets.Dropdown(
options=options,
value=value,
description=description,
layout=layout,
disabled=False,
)
return dropdown
def create_html(value):
'''
Create a HTML widget
Last modified: October 2021
Parameters
----------
value : string
HTML text to display
Returns
-------
html : ipywidgets.widgets.widget_string.HTML
'''
html = widgets.HTML(
value=value,
)
return html
def create_map(map_center=(4, 20), zoom_level=3, basemap=leaflet.basemaps.OpenStreetMap.Mapnik, basemap_name='Open Street Map'):
'''
Create an interactive ipyleaflet map
Last modified: October 2021
Parameters
----------
map_center : tuple
A tuple containing the latitude and longitude to focus on.
Defaults to center of Africa, (4, 20)
zoom_level : integer
Zoom level for the map
Defaults to 3 to view all of Africa
basemap : ipyleaflet basemap (dict)
Basemap to use, can be any from https://ipyleaflet.readthedocs.io/en/latest/api_reference/basemaps.html
Defaults to Open Street Map (basemaps.OpenStreetMap.Mapnik)
basemap_name : string
Layer name for the basemap
Returns
-------
m : ipyleaflet.leaflet.Map
interactive ipyleaflet map
'''
basemap_tiles = leaflet.basemap_to_tiles(basemap)
basemap_tiles.name = basemap_name
m = leaflet.Map(center=map_center, zoom=zoom_level, basemap=basemap_tiles, scroll_wheel_zoom=True)
return m
def create_dea_wms_layer(product, date):
'''
Create a Digital Earth Africa WMS layer to add to a map
Last modified: October 2021
Parameters
----------
product : string
The Digital Earth Africa product to load
(e.g. 'gm_s2_annual')
date : string (yyyy-mm-dd format)
The date to load the product for
Returns
-------
time_wms : ipyleaflet WMS layer
'''
# Load DEA WMS
class TimeWMSLayer(leaflet.WMSLayer):
time = Unicode("").tag(sync=True, o=True)
time_wms = TimeWMSLayer(
url="https://ows.digitalearth.africa/",
layers=product,
time=date,
format="image/png",
transparent=True,
attribution="Digital Earth Africa",
)
return time_wms
def create_drawcontrol(
draw_controls = ['rectangle', 'polygon', 'circle', 'polyline', 'marker', 'circlemarker'],
rectangle_options={},
polygon_options={},
circle_options={},
polyline_options={},
marker_options={},
circlemarker_options={},
):
'''
Create a draw control widget to add to ipyleaflet maps
Last modified: October 2021
Parameters
----------
draw_controls : list
List of draw controls to add to the map. Defaults to adding all
Viable options are 'rectangle', 'polygon', 'circle', 'polyline', 'marker', 'circlemarker'
rectangle_options : dict
Options to customise the appearence of the relevant shape
User can supply, or leave blank to get default DE Africa appearence
polygon_options : dict
Options to customise the appearence of the relevant shape
User can supply, or leave blank to get default DE Africa appearence
circle_options : dict
Options to customise the appearence of the relevant shape
User can supply, or leave blank to get default DE Africa appearence
polyline_options : dict
Options to customise the appearence of the relevant shape
User can supply, or leave blank to get default DE Africa appearence
marker_options : dict
Options to customise the appearence of the relevant shape
User can supply, or leave blank to get default DE Africa appearence
circlemarker_options : dict
Options to customise the appearence of the relevant shape
User can supply, or leave blank to get default DE Africa appearence
Returns
-------
draw_control : ipyleaflet.leaflet.DrawControl
'''
# Set defualt DE Africa styling options for polygons
default_shapeoptions = {
"color": "#FFFFFF",
"opacity": 0.8,
"fillColor": "#336699",
"fillOpacity": 0.4,
}
default_drawerror = {
"color": "#FF6633",
"message": "Drawing error, clear all and try again"
}
# Set draw control appearence to DE Africa defaults
# Do this if user has requested a control, but has not provided a corresponding options dict
if ('rectangle' in draw_controls) and (not rectangle_options):
rectangle_options = {"shapeOptions": default_shapeoptions}
if ('polygon' in draw_controls) and (not polygon_options):
polygon_options = {
"shapeOptions": default_shapeoptions,
"drawError": default_drawerror,
"allowIntersection": False,
}
if ('circle' in draw_controls) and (not circle_options):
circle_options = {"shapeOptions": default_shapeoptions}
if ('polyline' in draw_controls) and (not polyline_options):
polyline_options = {"shapeOptions": default_shapeoptions}
if ('marker' in draw_controls) and (not marker_options):
marker_options = {'shapeOptions': {'opacity': 1.0}}
if ('circlemarker' in draw_controls) and (not circlemarker_options):
circlemarker_options = {"shapeOptions": default_shapeoptions}
# Instantiate draw control and add options
draw_control = leaflet.DrawControl()
draw_control.rectangle = rectangle_options
draw_control.polygon = polygon_options
draw_control.marker = marker_options
draw_control.circle = circle_options
draw_control.circlemarker = circlemarker_options
draw_control.polyline = polyline_options
return draw_control
def create_checkbox(value, description="", layout={'width': '85%'}):
'''
Create a Checkbox widget
Last modified: July 2022
Parameters
----------
value : string
initial value of the widget; True or False
description : string
description label to attach
layout : dictionary
any layout commands for the widget
Returns
-------
dropdown : ipywidgets.widgets.widget_selection.Dropdown
'''
checklist = widgets.Checkbox(value=value,
description=description,
layout=layout,
disabled=False,
indent=False)
return checklist
+54
View File
@@ -0,0 +1,54 @@
"""
Function for defining an area of interest using either a point and buffer or a vector file.
"""
# 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 geopandas as gpd
from shapely.geometry import box
from geojson import Feature, Point, FeatureCollection
def define_area(lat=None, lon=None, buffer=None, vector_path=None):
'''
Define an area of interest using either a point and buffer or a vector.
Parameters:
-----------
lat : float, optional
The latitude of the center point of the area of interest.
lon : float, optional
The longitude of the center point of the area of interest.
buffer : float, optional
The buffer around the center point, in degrees.
vector_path : str, optional
The path to a vector defining the area of interest.
Returns:
--------
feature_collection : dict
A GeoJSON feature collection representing the area of interest.
'''
# Define area using point and buffer
if lat is not None and lon is not None and buffer is not None:
lat_range = (lat - buffer, lat + buffer)
lon_range = (lon - buffer, lon + buffer)
box_geom = box(min(lon_range), min(lat_range), max(lon_range), max(lat_range))
aoi = gpd.GeoDataFrame(geometry=[box_geom], crs='EPSG:4326')
# Define area using vector
elif vector_path is not None:
aoi = gpd.read_file(vector_path).to_crs("EPSG:4326")
# If neither option is provided, raise an error
else:
raise ValueError("Either lat/lon/buffer or vector_path must be provided.")
# Convert the GeoDataFrame to a GeoJSON FeatureCollection
features = [Feature(geometry=row["geometry"], properties=row.drop("geometry").to_dict()) for _, row in aoi.iterrows()]
feature_collection = FeatureCollection(features)
return feature_collection
+615
View File
@@ -0,0 +1,615 @@
"""
Functions for computing remote sensing band indices on Digital Earth Africa
data.
"""
# Import required packages
import warnings
import numpy as np
# Define custom functions
def calculate_indices(
ds,
index=None,
collection=None,
satellite_mission=None,
custom_varname=None,
normalise=True,
drop=False,
deep_copy=True,
):
"""
Takes an xarray dataset containing spectral bands, calculates one of
a set of remote sensing indices, and adds the resulting array as a
new variable in the original dataset.
Last modified: July 2022
Parameters
----------
ds : xarray Dataset
A two-dimensional or multi-dimensional array with containing the
spectral bands required to calculate the index. These bands are
used as inputs to calculate the selected water index.
index : str or list of strs
A string giving the name of the index to calculate or a list of
strings giving the names of the indices to calculate:
* ``'ASI'`` (Artificial Surface Index, Yongquan Zhao & Zhe Zhu 2022)
* ``'AWEI_ns'`` (Automated Water Extraction Index, no shadows, Feyisa 2014)
* ``'AWEI_sh'`` (Automated Water Extraction Index, shadows, Feyisa 2014)
* ``'BAEI'`` (Built-Up Area Extraction Index, Bouzekri et al. 2015)
* ``'BAI'`` (Burn Area Index, Martin 1998)
* ``'BSI'`` (Bare Soil Index, Rikimaru et al. 2002)
* ``'BUI'`` (Built-Up Index, He et al. 2010)
* ``'CMR'`` (Clay Minerals Ratio, Drury 1987)
* ``'ENDISI'`` (Enhanced Normalised Difference for Impervious Surfaces Index, Chen et al. 2019)
* ``'EVI'`` (Enhanced Vegetation Index, Huete 2002)
* ``'FMR'`` (Ferrous Minerals Ratio, Segal 1982)
* ``'IOR'`` (Iron Oxide Ratio, Segal 1982)
* ``'LAI'`` (Leaf Area Index, Boegh 2002)
* ``'MBI'`` (Modified Bare Soil Index, Nguyen et al. 2021)
* ``'MNDWI'`` (Modified Normalised Difference Water Index, Xu 1996)
* ``'MSAVI'`` (Modified Soil Adjusted Vegetation Index, Qi et al. 1994)
* ``'NBI'`` (New Built-Up Index, Jieli et al. 2010)
* ``'NBR'`` (Normalised Burn Ratio, Lopez Garcia 1991)
* ``'NDBI'`` (Normalised Difference Built-Up Index, Zha 2003)
* ``'NDCI'`` (Normalised Difference Chlorophyll Index, Mishra & Mishra, 2012)
* ``'NDMI'`` (Normalised Difference Moisture Index, Gao 1996)
* ``'NDSI'`` (Normalised Difference Snow Index, Hall 1995)
* ``'NDTI'`` (Normalised Difference Turbidity Index, Lacaux et al. 2007)
* ``'NDVI'`` (Normalised Difference Vegetation Index, Rouse 1973)
* ``'NDWI'`` (Normalised Difference Water Index, McFeeters 1996)
* ``'SAVI'`` (Soil Adjusted Vegetation Index, Huete 1988)
* ``'TCB'`` (Tasseled Cap Brightness, Crist 1985)
* ``'TCG'`` (Tasseled Cap Greeness, Crist 1985)
* ``'TCW'`` (Tasseled Cap Wetness, Crist 1985)
* ``'WI'`` (Water Index, Fisher 2016)
collection : str
Deprecated in version 0.1.7. Use `satellite_mission` instead.
Valid options are:
* ``'c2'`` (for USGS Landsat Collection 2)
If 'c2', then `satellite_mission='ls'`.
* ``'s2'`` (for Sentinel-2)
If 's2', then `satellite_mission='s2'`.
satellite_mission : str
An string that tells the function which satellite mission's data is
being used to calculate the index. This is necessary because
different satellite missions use different names for bands covering
a similar spectra.
Valid options are:
* ``'ls'`` (for USGS Landsat)
* ``'s2'`` (for Copernicus Sentinel-2)
custom_varname : str, optional
By default, the original dataset will be returned with
a new index variable named after `index` (e.g. 'NDVI'). To
specify a custom name instead, you can supply e.g.
`custom_varname='custom_name'`. Defaults to None, which uses
`index` to name the variable.
normalise : bool, optional
Some coefficient-based indices (e.g. ``'WI'``, ``'BAEI'``,
``'AWEI_ns'``, ``'AWEI_sh'``, ``'TCW'``, ``'TCG'``, ``'TCB'``,
``'EVI'``, ``'LAI'``, ``'SAVI'``, ``'MSAVI'``)
produce different results if surface reflectance values are not
scaled between 0.0 and 1.0 prior to calculating the index.
Setting `normalise=True` first scales values to a 0.0-1.0 range
by dividing by 10000.0. Defaults to True.
drop : bool, optional
Provides the option to drop the original input data, thus saving
space. If `drop=True`, returns only the index and its values.
deep_copy: bool, optional
If `deep_copy=False`, calculate_indices will modify the original
array, adding bands to the input dataset and not removing them.
If the calculate_indices function is run more than once, variables
may be dropped incorrectly producing unexpected behaviour. This is
a bug and may be fixed in future releases. This is only a problem
when `drop=True`.
Returns
-------
ds : xarray Dataset
The original xarray Dataset inputted into the function, with a
new varible containing the remote sensing index as a DataArray.
If drop = True, the new variable/s as DataArrays in the
original Dataset.
"""
# Set ds equal to a copy of itself in order to prevent the function
# from editing the input dataset. This is to prevent unexpected
# behaviour though it uses twice as much memory.
if deep_copy:
ds = ds.copy(deep=True)
# Capture input band names in order to drop these if drop=True
if drop:
bands_to_drop = list(ds.data_vars)
print(f"Dropping bands {bands_to_drop}")
# Dictionary containing remote sensing index band recipes
index_dict = {
# Normalised Difference Vegation Index, Rouse 1973
"NDVI": lambda ds: (ds.nir - ds.red) / (ds.nir + ds.red),
# Enhanced Vegetation Index, Huete 2002
"EVI": lambda ds: (
2.5 * ((ds.nir - ds.red) / (ds.nir + 6 * ds.red - 7.5 * ds.blue + 1))
),
# Leaf Area Index, Boegh 2002
"LAI": lambda ds: (
3.618
* ((2.5 * (ds.nir - ds.red)) / (ds.nir + (6 * ds.red) - (7.5 * ds.blue) + 1))
- 0.118
),
# Soil Adjusted Vegetation Index, Huete 1988
"SAVI": lambda ds: ((1.5 * (ds.nir - ds.red)) / (ds.nir + ds.red + 0.5)),
# Mod. Soil Adjusted Vegetation Index, Qi et al. 1994
"MSAVI": lambda ds: (
(2 * ds.nir + 1 - ((2 * ds.nir + 1) ** 2 - 8 * (ds.nir - ds.red)) ** 0.5)
/ 2
),
# Normalised Difference Moisture Index, Gao 1996
"NDMI": lambda ds: (ds.nir - ds.swir_1) / (ds.nir + ds.swir_1),
# Normalised Burn Ratio, Lopez Garcia 1991
"NBR": lambda ds: (ds.nir - ds.swir_2) / (ds.nir + ds.swir_2),
# Burn Area Index, Martin 1998
"BAI": lambda ds: (1.0 / ((0.10 - ds.red) ** 2 + (0.06 - ds.nir) ** 2)),
# Normalised Difference Chlorophyll Index,
# (Mishra & Mishra, 2012)
"NDCI": lambda ds: (ds.red_edge_1 - ds.red) / (ds.red_edge_1 + ds.red),
# Normalised Difference Snow Index, Hall 1995
"NDSI": lambda ds: (ds.green - ds.swir_1) / (ds.green + ds.swir_1),
# Normalised Difference Water Index, McFeeters 1996
"NDWI": lambda ds: (ds.green - ds.nir) / (ds.green + ds.nir),
# Modified Normalised Difference Water Index, Xu 2006
"MNDWI": lambda ds: (ds.green - ds.swir_1) / (ds.green + ds.swir_1),
# Normalised Difference Built-Up Index, Zha 2003
"NDBI": lambda ds: (ds.swir_1 - ds.nir) / (ds.swir_1 + ds.nir),
# Built-Up Index, He et al. 2010
"BUI": lambda ds: ((ds.swir_1 - ds.nir) / (ds.swir_1 + ds.nir))
- ((ds.nir - ds.red) / (ds.nir + ds.red)),
# Built-up Area Extraction Index, Bouzekri et al. 2015
"BAEI": lambda ds: (ds.red + 0.3) / (ds.green + ds.swir_1),
# New Built-up Index, Jieli et al. 2010
"NBI": lambda ds: (ds.swir_1 + ds.red) / ds.nir,
# Bare Soil Index, Rikimaru et al. 2002
"BSI": lambda ds: ((ds.swir_1 + ds.red) - (ds.nir + ds.blue))
/ ((ds.swir_1 + ds.red) + (ds.nir + ds.blue)),
# Automated Water Extraction Index (no shadows), Feyisa 2014
"AWEI_ns": lambda ds: (
4 * (ds.green - ds.swir_1) - (0.25 * ds.nir * +2.75 * ds.swir_2)
),
# Automated Water Extraction Index (shadows), Feyisa 2014
"AWEI_sh": lambda ds: (
ds.blue + 2.5 * ds.green - 1.5 * (ds.nir + ds.swir_1) - 0.25 * ds.swir_2
),
# Water Index, Fisher 2016
"WI": lambda ds: (
1.7204
+ 171 * ds.green
+ 3 * ds.red
- 70 * ds.nir
- 45 * ds.swir_1
- 71 * ds.swir_2
),
# Tasseled Cap Wetness, Crist 1985
"TCW": lambda ds: (
0.0315 * ds.blue
+ 0.2021 * ds.green
+ 0.3102 * ds.red
+ 0.1594 * ds.nir
+ -0.6806 * ds.swir_1
+ -0.6109 * ds.swir_2
),
# Tasseled Cap Greeness, Crist 1985
"TCG": lambda ds: (
-0.1603 * ds.blue
+ -0.2819 * ds.green
+ -0.4934 * ds.red
+ 0.7940 * ds.nir
+ -0.0002 * ds.swir_1
+ -0.1446 * ds.swir_2
),
# Tasseled Cap Brightness, Crist 1985
"TCB": lambda ds: (
0.2043 * ds.blue
+ 0.4158 * ds.green
+ 0.5524 * ds.red
+ 0.5741 * ds.nir
+ 0.3124 * ds.swir_1
+ -0.2303 * ds.swir_2
),
# Clay Minerals Ratio, Drury 1987
"CMR": lambda ds: (ds.swir_1 / ds.swir_2),
# Ferrous Minerals Ratio, Segal 1982
"FMR": lambda ds: (ds.swir_1 / ds.nir),
# Iron Oxide Ratio, Segal 1982
"IOR": lambda ds: (ds.red / ds.blue),
# Normalized Difference Turbidity Index, Lacaux, J.P. et al. 2007
"NDTI": lambda ds: (ds.red - ds.green) / (ds.red + ds.green),
# Modified Bare Soil Index, Nguyen et al. 2021
"MBI": lambda ds: ((ds.swir_1 - ds.swir_2 - ds.nir) / (ds.swir_1 + ds.swir_2 + ds.nir)) + 0.5,
}
# Enhanced Normalised Difference Impervious Surfaces Index, Chen et al. 2019
def mndwi(ds):
return (ds.green - ds.swir_1) / (ds.green + ds.swir_1)
def swir_diff(ds):
return ds.swir_1/ds.swir_2
def alpha(ds):
return (2*(np.mean(ds.blue)))/(np.mean(swir_diff(ds)) + np.mean(mndwi(ds)**2))
def ENDISI(ds):
m = mndwi(ds)
s = swir_diff(ds)
a = alpha(ds)
return (ds.blue - (a)*(s + m**2))/(ds.blue + (a)*(s + m**2))
index_dict["ENDISI"] = ENDISI
## Artificial Surface Index, Yongquan Zhao & Zhe Zhu 2022
def af(ds):
AF = (ds.nir - ds.blue) / (ds.nir + ds.blue)
AF_norm = (AF - AF.min(dim=["y","x"]))/(AF.max(dim=["y","x"]) - AF.min(dim=["y","x"]))
return AF_norm
def ndvi(ds):
return (ds.nir - ds.red) / (ds.nir + ds.red)
def msavi(ds):
return ((2 * ds.nir + 1 - ((2 * ds.nir + 1) ** 2 - 8 * (ds.nir - ds.red)) ** 0.5) / 2 )
def vsf(ds):
NDVI = ndvi(ds)
MSAVI = msavi(ds)
VSF = 1 - NDVI * MSAVI
VSF_norm = (VSF - VSF.min(dim=["y","x"]))/(VSF.max(dim=["y","x"]) - VSF.min(dim=["y","x"]))
return VSF_norm
def mbi(ds):
return ((ds.swir_1 - ds.swir_2 - ds.nir) / (ds.swir_1 + ds.swir_2 + ds.nir)) + 0.5
def embi(ds):
MBI = mbi(ds)
MNDWI = mndwi(ds)
return (MBI - MNDWI - 0.5) / (MBI + MNDWI + 1.5)
def ssf(ds):
EMBI = embi(ds)
SSF = 1 - EMBI
SSF_norm = (SSF - SSF.min(dim=["y","x"]))/(SSF.max(dim=["y","x"]) - SSF.min(dim=["y","x"]))
return SSF_norm
# Overall modulation using the Modulation Factor (MF).
def mf(ds):
MF = ((ds.blue + ds.green) - (ds.nir + ds.swir_1)) / ((ds.blue + ds.green) + (ds.nir + ds.swir_1))
MF_norm = (MF - MF.min(dim=["y","x"]))/(MF.max(dim=["y","x"]) - MF.min(dim=["y","x"]))
return MF_norm
def ASI(ds):
AF = af(ds)
VSF = vsf(ds)
SSF = ssf(ds)
MF = mf(ds)
return AF * VSF * SSF * MF
index_dict["ASI"] = ASI
# If index supplied is not a list, convert to list. This allows us to
# iterate through either multiple or single indices in the loop below
indices = index if isinstance(index, list) else [index]
# calculate for each index in the list of indices supplied (indexes)
for index in indices:
# Select an index function from the dictionary
index_func = index_dict.get(str(index))
# If no index is provided or if no function is returned due to an
# invalid option being provided, raise an exception informing user to
# choose from the list of valid options
if index is None:
raise ValueError(
f"No remote sensing `index` was provided. Please "
"refer to the function \ndocumentation for a full "
"list of valid options for `index` (e.g. 'NDVI')"
)
elif (
index
in [
"WI",
"BAEI",
"AWEI_ns",
"AWEI_sh",
"EVI",
"LAI",
"SAVI",
"MSAVI",
]
and not normalise
):
warnings.warn(
f"\nA coefficient-based index ('{index}') normally "
"applied to surface reflectance values in the \n"
"0.0-1.0 range was applied to values in the 0-10000 "
"range. This can produce unexpected results; \nif "
"required, resolve this by setting `normalise=True`"
)
elif index_func is None:
raise ValueError(
f"The selected index '{index}' is not one of the "
"valid remote sensing index options. \nPlease "
"refer to the function documentation for a full "
"list of valid options for `index`"
)
# Deprecation warning if `collection` is specified instead of `satellite_mission`.
if collection is not None:
warnings.warn('`collection` was deprecated in version 0.1.7. Use `satelite_mission` instead.',
DeprecationWarning,
stacklevel=2)
# Map the collection values to the valid satellite_mission values.
if collection == "c2":
satellite_mission = "ls"
elif collection == "s2":
satellite_mission = "s2"
# Raise error if no valid collection name is provided:
else:
raise ValueError(
f"'{collection}' is not a valid option for "
"`collection`. Please specify either \n"
"'c2' or 's2'.")
# Rename bands to a consistent format if depending on what satellite mission
# is specified in `satellite_mission`. This allows the same index calculations
# to be applied to all satellite missions. If no satellite mission was provided,
# raise an exception.
if satellite_mission is None:
raise ValueError(
"No `satellite_mission` was provided. Please specify "
"either 'ls' or 's2' to ensure the \nfunction "
"calculates indices using the correct spectral "
"bands."
)
elif satellite_mission == "ls":
sr_max = 1.0
# Dictionary mapping full data names to simpler alias names
# This only applies to properly-scaled "ls" data i.e. from
# the Landsat geomedians. calculate_indices will not show
# correct output for raw (unscaled) Landsat data (i.e. default
# outputs from dc.load)
bandnames_dict = {
"SR_B1": "blue",
"SR_B2": "green",
"SR_B3": "red",
"SR_B4": "nir",
"SR_B5": "swir_1",
"SR_B7": "swir_2",
}
# Rename bands in dataset to use simple names (e.g. 'red')
bands_to_rename = {
a: b for a, b in bandnames_dict.items() if a in ds.variables
}
elif satellite_mission == "s2":
sr_max = 10000
# Dictionary mapping full data names to simpler alias names
bandnames_dict = {
"nir_1": "nir",
"B02": "blue",
"B03": "green",
"B04": "red",
"B05": "red_edge_1",
"B06": "red_edge_2",
"B07": "red_edge_3",
"B08": "nir",
"B11": "swir_1",
"B12": "swir_2",
}
# Rename bands in dataset to use simple names (e.g. 'red')
bands_to_rename = {
a: b for a, b in bandnames_dict.items() if a in ds.variables
}
# Raise error if no valid satellite_mission name is provided:
else:
raise ValueError(
f"'{satellite_mission}' is not a valid option for "
"`satellite_mission`. Please specify either \n"
"'ls' or 's2'"
)
# Apply index function
try:
# If normalised=True, divide data by 10,000 before applying func
mult = sr_max if normalise else 1.0
index_array = index_func(ds.rename(bands_to_rename) / mult)
except AttributeError:
raise ValueError(
f"Please verify that all bands required to "
f"compute {index} are present in `ds`."
)
# Add as a new variable in dataset
output_band_name = custom_varname if custom_varname else index
ds[output_band_name] = index_array
# Once all indexes are calculated, drop input bands if drop=True
if drop:
ds = ds.drop(bands_to_drop)
# Return input dataset with added water index variable
return ds
def dualpol_indices(
ds,
co_pol='vv',
cross_pol='vh',
index=None,
custom_varname=None,
drop=False,
deep_copy=True,
):
"""
Takes an xarray dataset containing dual-polarization radar backscatter,
calculates one or a set of indices, and adds the resulting array as a
new variable in the original dataset.
Last modified: July 2021
Parameters
----------
ds : xarray Dataset
A two-dimensional or multi-dimensional array containing the
two polarization bands.
co_pol: str
Measurement name for the co-polarization band.
Default is 'vv' for Sentinel-1.
cross_pol: str
Measurement name for the cross-polarization band.
Default is 'vh' for Sentinel-1.
index : str or list of strs
A string giving the name of the index to calculate or a list of
strings giving the names of the indices to calculate:
* ``'RVI'`` (Radar Vegetation Index for dual-pol, Trudel et al. 2012; Nasirzadehdizaji et al., 2019; Gururaj et al., 2019)
* ``'VDDPI'`` (Vertical dual depolarization index, Periasamy 2018)
* ``'theta'`` (pseudo scattering-type, Bhogapurapu et al. 2021)
* ``'entropy'`` (pseudo scattering entropy, Bhogapurapu et al. 2021)
* ``'purity'`` (co-pol purity, Bhogapurapu et al. 2021)
* ``'ratio'`` (cross-pol/co-pol ratio)
custom_varname : str, optional
By default, the original dataset will be returned with
a new index variable named after `index` (e.g. 'RVI'). To
specify a custom name instead, you can supply e.g.
`custom_varname='custom_name'`. Defaults to None, which uses
`index` to name the variable.
drop : bool, optional
Provides the option to drop the original input data, thus saving
space. If `drop=True`, returns only the index and its values.
deep_copy: bool, optional
If `deep_copy=False`, calculate_indices will modify the original
array, adding bands to the input dataset and not removing them.
If the calculate_indices function is run more than once, variables
may be dropped incorrectly producing unexpected behaviour. This is
a bug and may be fixed in future releases. This is only a problem
when `drop=True`.
Returns
-------
ds : xarray Dataset
The original xarray Dataset inputted into the function, with a
new varible containing the remote sensing index as a DataArray.
If drop = True, the new variable/s as DataArrays in the
original Dataset.
"""
if not co_pol in list(ds.data_vars):
raise ValueError(f"{co_pol} measurement is not in the dataset")
if not cross_pol in list(ds.data_vars):
raise ValueError(f"{cross_pol} measurement is not in the dataset")
# Set ds equal to a copy of itself in order to prevent the function
# from editing the input dataset. This is to prevent unexpected
# behaviour though it uses twice as much memory.
if deep_copy:
ds = ds.copy(deep=True)
# Capture input band names in order to drop these if drop=True
if drop:
bands_to_drop = list(ds.data_vars)
print(f"Dropping bands {bands_to_drop}")
def ratio(ds):
return ds[cross_pol] / ds[co_pol]
def purity(ds):
return (1 - ratio(ds)) / (1 + ratio(ds))
def theta(ds):
return np.arctan((1 - ratio(ds))**2 / (1 + ratio(ds)**2 - ratio(ds)))
def P1(ds):
return 1 / (1 + ratio(ds))
def P2(ds):
return 1 - P1(ds)
def entropy(ds):
return P1(ds)*np.log2(P1(ds)) + P2(ds)*np.log2(P2(ds))
# Dictionary containing remote sensing index band recipes
index_dict = {
# Radar Vegetation Index for dual-pol, Trudel et al. 2012
"RVI": lambda ds: 4*ds[cross_pol] / (ds[co_pol] + ds[cross_pol]),
# Vertical dual depolarization index, Periasamy 2018
"VDDPI": lambda ds: (ds[co_pol] + ds[cross_pol]) / ds[co_pol],
# cross-pol/co-pol ratio
"ratio": ratio,
# co-pol purity, Bhogapurapu et al. 2021
"purity": purity,
# pseudo scattering-type, Bhogapurapu et al. 2021
"theta": theta,
# pseudo scattering entropy, Bhogapurapu et al. 2021
"entropy": entropy,
}
# If index supplied is not a list, convert to list. This allows us to
# iterate through either multiple or single indices in the loop below
indices = index if isinstance(index, list) else [index]
# calculate for each index in the list of indices supplied (indexes)
for index in indices:
# Select an index function from the dictionary
index_func = index_dict.get(str(index))
# If no index is provided or if no function is returned due to an
# invalid option being provided, raise an exception informing user to
# choose from the list of valid options
if index is None:
raise ValueError(
f"No radar `index` was provided. Please "
"refer to the function \ndocumentation for a full "
"list of valid options for `index` (e.g. 'RVI')"
)
elif index_func is None:
raise ValueError(
f"The selected index '{index}' is not one of the "
"valid remote sensing index options. \nPlease "
"refer to the function documentation for a full "
"list of valid options for `index`"
)
# Apply index function
index_array = index_func(ds)
# Add as a new variable in dataset
output_band_name = custom_varname if custom_varname else index
ds[output_band_name] = index_array
# Once all indexes are calculated, drop input bands if drop=True
if drop:
ds = ds.drop(bands_to_drop)
# Return input dataset with added water index variable
return ds
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
"""
Functions for simplifying the creation of a local dask cluster.
"""
from importlib.util import find_spec
import os
import dask
from aiohttp import ClientConnectionError
from datacube.utils.dask import start_local_dask
from datacube.utils.rio import configure_s3_access
_HAVE_PROXY = bool(find_spec('jupyter_server_proxy'))
_IS_AWS = ('AWS_ACCESS_KEY_ID' in os.environ or
'AWS_DEFAULT_REGION' in os.environ)
def create_local_dask_cluster(spare_mem='3Gb', display_client=True, return_client=False):
"""
Using the datacube utils function `start_local_dask`, generate
a local dask cluster. Automatically detects if on AWS or NCI.
Parameters
----------
spare_mem : String, optional
The amount of memory, in Gb, to leave for the notebook to run.
This memory will not be used by the cluster. e.g '3Gb'
display_client : Bool, optional
An optional boolean indicating whether to display a summary of
the dask client, including a link to monitor progress of the
analysis. Set to False to hide this display.
return_client : Bool, optional
An optional boolean indicating whether to return the dask client
object.
"""
if _HAVE_PROXY:
# Configure dashboard link to go over proxy
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
dask.config.set({"distributed.dashboard.link":
prefix + "proxy/{port}/status"})
# Start up a local cluster
client = start_local_dask(mem_safety_margin=spare_mem)
if _IS_AWS:
# Configure GDAL for s3 access
configure_s3_access(aws_unsigned=True,
client=client)
# Show the dask cluster settings
if display_client:
from IPython.display import display
display(client)
# return the client as an object
if return_client:
return client
try:
from dask_gateway import Gateway
def create_dask_gateway_cluster(profile='r5_L', workers=2):
"""
Create a cluster in our internal dask cluster.
Parameters
----------
profile : str
Possible values are:
- r5_L (2 cores, 15GB memory)
- r5_XL (4 cores, 31GB memory)
- r5_2XL (8 cores, 63GB memory)
- r5_4XL (16 cores, 127GB memory)
workers : int
Number of workers in the cluster.
"""
try:
gateway = Gateway()
# Close any existing clusters
cluster_names = gateway.list_clusters()
if len(cluster_names) > 0:
print("Cluster(s) still running:", cluster_names)
for n in cluster_names:
cluster = gateway.connect(n.name)
cluster.shutdown()
options = gateway.cluster_options()
options['profile'] = profile
# limit username to alphanumeric characters
# kubernetes pods won't launch if labels contain anything other than [a-Z, -, _]
options['jupyterhub_user'] = ''.join(c if c.isalnum() else '-' for c in os.getenv('JUPYTERHUB_USER'))
cluster = gateway.new_cluster(options)
cluster.scale(workers)
return cluster
except ClientConnectionError:
raise ConnectionError("access to dask gateway cluster unauthorized")
except ImportError:
def create_dask_gateway_cluster(*args, **kwargs):
raise NotImplementedError
File diff suppressed because it is too large Load Diff
+411
View File
@@ -0,0 +1,411 @@
"""
Functions to retrieve ERA5 gridded climate data.
Updated Apr 2020 to directly access Zarr format data in PDS
Previous code for downloading and loading netcdf adpated from scripts by Andrew Cherry and Brian Killough.
"""
import os
import datetime
import numpy as np
import xarray as xr
import fsspec
from datacube.utils.geometry import assign_crs
# # only used for netcdf access
# from dateutil.parser import parse
# import boto3
# import botocore
# import warnings
ERA5_VARS = ['air_pressure_at_mean_sea_level',
'air_temperature_at_2_metres',
'air_temperature_at_2_metres_1hour_Maximum',
'air_temperature_at_2_metres_1hour_Minimum',
'dew_point_temperature_at_2_metres',
'eastward_wind_at_100_metres',
'eastward_wind_at_10_metres',
'integral_wrt_time_of_surface_direct_downwelling_shortwave_flux_in_air_1hour_Accumulation',
'lwe_thickness_of_surface_snow_amount',
'northward_wind_at_100_metres',
'northward_wind_at_10_metres',
'precipitation_amount_1hour_Accumulation',
'sea_surface_temperature',
'snow_density',
'surface_air_pressure']
def load_era5(
var, lat, lon, time,
reduce_func=None,
resample="1D",
):
"""
Download and return an ERA5 variable for a defined time window.
Parameters
----------
var : string
Name of the ERA5 climate variable to download, e.g "air_temperature_at_2_metres"
lat: tuple or list
Latitude range for query.
lon: tuple or list
Longitude range for query.
time: string or datetime object or a list or tuple of strings or datetime objects
Used to define starting and end date dates of the time window.
reduce_func: numpy function
lets you specify a function to apply to each day's worth of data.
The default is np.mean, which computes daily average. To get a sum, use np.sum.
resample: string
Temporal resampling frequency to be used for xarray's resample function.
The default is '1D', which is daily.
Since this is applied on monthly ERA5 data, maximum resampling period is '1M'.
Returns
-------
A lazy-loaded xarray dataset containing an ERA5 variable for the selected region and time window.
"""
# constrain query to available variables
assert var in ERA5_VARS, "var must be one of [{}] (got {})".format(
",".join(ERA5_VARS), var
)
# set default reduction function
if reduce_func is None:
reduce_func = np.mean
# process date range
if type(time) in [list, tuple]:
date_from = np.datetime64(min(time)).astype('datetime64[D]')
date_to = (np.datetime64(max(time))+1).astype('datetime64[D]')-np.timedelta64(1,'D')
elif type(time) in [str, np.datetime64]:
date_from = np.datetime64(time).astype('datetime64[D]')
date_to = (np.datetime64(time)+1).astype('datetime64[D]')-np.timedelta64(1,'D')
else:
raise(ValueError)
# actual lat lon ranges will be infered from nearest match to data
lat_range = None
lon_range = None
datasets = []
# Loop through month and year to access ERA5 zarr
month = date_from.astype('datetime64[M]')
while month <= date_to.astype('datetime64[M]'):
url = f"s3://era5-pds/zarr/{month.astype(object).year:04}/{month.astype(object).month:02}/data/{var}.zarr"
ds = xr.open_zarr(fsspec.get_mapper(url, anon=True,
client_kwargs={'region_name':'us-east-1'}),
consolidated=True)
# re-order along longitude to go from -180 to 180 if needed
if min(lon) < 0:
ds = ds.assign_coords({"lon": (((ds.lon + 180) % 360) - 180)})
ds = ds.reindex({"lon": np.sort(ds.lon)})
if lat_range is None:
# find the nearest lat lon boundary points
test = ds.sel(lat=list(lat), lon=list(lon), method="nearest")
# define the lat/lon grid
lat_range = slice(test.lat.max().values, test.lat.min().values)
lon_range = slice(test.lon.min().values, test.lon.max().values)
if "time0" in ds.dims:
ds = ds.rename({"time0": "time"})
if "time1" in ds.dims:
ds = ds.rename(
{"time1": "time"}
) # This should INTENTIONALLY error if both times are defined
output = ds[[var]].sel(lat=lat_range, lon=lon_range, time=slice(date_from, date_to)).resample(time=resample).reduce(reduce_func)
output.attrs = ds.attrs
for v in output.data_vars:
output[v].attrs = ds[v].attrs
datasets.append(output)
month += np.timedelta64(1,'M')
return assign_crs(xr.combine_by_coords(datasets), 'EPSG:4326')
# # older version of scripts to download and use netcdf
# ERA5_VARS_NC = [
# "air_pressure_at_mean_sea_level",
# "air_temperature_at_2_metres",
# "air_temperature_at_2_metres_1hour_Maximum",
# "air_temperature_at_2_metres_1hour_Minimum",
# "dew_point_temperature_at_2_metres",
# "eastward_wind_at_100_metres",
# "eastward_wind_at_10_metres",
# "integral_wrt_time_of_surface_direct_downwelling_shortwave_flux_in_air_1hour_Accumulation",
# "lwe_thickness_of_surface_snow_amount",
# "northward_wind_at_100_metres",
# "northward_wind_at_10_metres",
# "precipitation_amount_1hour_Accumulation",
# "sea_surface_temperature",
# "sea_surface_wave_from_direction",
# "sea_surface_wave_mean_period",
# "significant_height_of_wind_and_swell_waves",
# "snow_density",
# "surface_air_pressure",
# ]
# def get_era5_daily(
# var,
# date_from_arg,
# date_to_arg=None,
# reduce_func=None,
# cache_dir="era5",
# resample="1D",
# ):
# """
# Download and return an ERA5 variable for a defined time window.
# Parameters
# ----------
# var : string
# Name of the ERA5 climate variable to download, e.g "air_temperature_at_2_metres"
# date_from_arg: string or datetime object
# Starting date of the time window.
# date_to_arg: string or datetime object
# End date of the time window. If not supplied, set to be the same as starting date.
# reduce_func: numpy function
# lets you specify a function to apply to each day's worth of data.
# The default is np.mean, which computes daily average. To get a sum, use np.sum.
# cache_dir: sting
# Path to save downloaded ERA5 data. The path will be created if not already exists.
# The default is 'era5'.
# resample: string
# Temporal resampling frequency to be used for xarray's resample function.
# The default is '1D', which is daily.
# Since ERA5 data is provided as one file per month, maximum resampling period is '1M'.
# Returns
# -------
# A lazy-loaded xarray dataset containing an ERA5 variable for the selected time window.
# """
# # Massage input data
# assert var in ERA5_VARS_NC, "var must be one of [{}] (got {})".format(
# ",".join(ERA5_VARS_NC), var
# )
# if not os.path.exists(cache_dir):
# os.mkdir(cache_dir)
# if reduce_func is None:
# reduce_func = np.mean
# if type(date_from_arg) == str:
# date_from_arg = parse(date_from_arg)
# if type(date_to_arg) == str:
# date_to_arg = parse(date_to_arg)
# if date_to_arg is None:
# date_to_arg = date_from_arg
# # Make sure our dates are in the correct order
# from_date = min(date_from_arg, date_to_arg)
# to_date = max(date_from_arg, date_to_arg)
# # Download ERA5 files to local cache if they don't already exist
# client = None # Boto client (if needed)
# local_files = [] # Will hold list of local filenames
# Y, M = from_date.year, from_date.month # Loop vars
# loop_end = to_date.year * 12 + to_date.month # Loop sentinel
# while Y * 12 + M <= loop_end:
# local_file = os.path.join(
# cache_dir, "{Y:04}_{M:02}_{var}.nc".format(Y=Y, M=M, var=var)
# )
# data_key = "{Y:04}/{M:02}/data/{var}.nc".format(Y=Y, M=M, var=var)
# if not os.path.isfile(
# local_file
# ): # check if file already exists (TODO: move to temp?) (TODO: catch failed download)
# if client is None:
# client = boto3.client(
# "s3",
# config=botocore.client.Config(signature_version=botocore.UNSIGNED),
# )
# client.download_file("era5-pds", data_key, local_file)
# local_files.append(local_file)
# if M == 12:
# Y += 1
# M = 1
# else:
# M += 1
# # Load and merge the locally-cached ERA5 data from the list of filenames
# date_slice = slice(
# str(from_date.date()), str(to_date.date())
# ) # I do this to INCLUDE the whole end date, not just 00:00
# def prepro(ds):
# if "time0" in ds.dims:
# ds = ds.rename({"time0": "time"})
# if "time1" in ds.dims:
# ds = ds.rename(
# {"time1": "time"}
# ) # This should INTENTIONALLY error if both times are defined
# ds = ds[[var]]
# output = ds.sel(time=date_slice).resample(time=resample).reduce(reduce_func)
# output.attrs = ds.attrs
# for v in output.data_vars:
# output[v].attrs = ds[v].attrs
# return output
# return xr.open_mfdataset(
# local_files,
# combine="by_coords",
# compat="equals",
# preprocess=prepro,
# parallel=True,
# )
# def era5_area_crop(ds, lat, lon):
# """
# Crop a dataset containing EAR5 variables to a location.
# The output spatial grid will either include input grid points within lat/lon boundaries or the nearest point if none is within the search location.
# Parameters
# ----------
# ds : xarray dataset
# A dataset containing ERA5 variables of interest.
# lat: tuple or list
# Latitude range for query.
# lon: tuple or list
# Longitude range for query.
# Returns
# -------
# An xarray dataset containing ERA5 variables for the selected location.
# """
# # Handle single value lat/lon args by wrapping them in lists
# try:
# min(lat)
# except TypeError:
# lat = [lat]
# try:
# min(lon)
# except TypeError:
# lon = [lon]
# if min(lon) < 0:
# # re-order along longitude to go from -180 to 180
# ds = ds.assign_coords({"lon": (((ds.lon + 180) % 360) - 180)})
# ds = ds.reindex({"lon": np.sort(ds.lon)})
# # Issue warnings if args outside range.
# if min(lat) < ds.lat.min() or max(lat) > ds.lat.max():
# warnings.warn(
# "Lats must be in range {} .. {}. Got: {}".format(
# ds.lat.min().values, ds.lat.max().values, lat
# )
# )
# if min(lon) < ds.lon.min() or max(lon) > ds.lon.max():
# warnings.warn(
# "Lons must be in range {} .. {}. Got: {}".format(
# ds.lon.min().values, ds.lon.max().values, lon
# )
# )
# # Find existing coords between min&max
# lats = ds.lat[np.logical_and(ds.lat >= min(lat), ds.lat <= max(lat))].values
# # If there was nothing between, just plan to grab closest
# if len(lats) == 0:
# lats = np.unique(ds.lat.sel(lat=np.array(lat), method="nearest"))
# lons = ds.lon[np.logical_and(ds.lon >= min(lon), ds.lon <= max(lon))].values
# if len(lons) == 0:
# lons = np.unique(ds.lon.sel(lon=np.array(lon), method="nearest"))
# # crop and keep attrs
# output = ds.sel(lat=lats, lon=lons)
# output.attrs = ds.attrs
# for var in output.data_vars:
# output[var].attrs = ds[var].attrs
# return output
# def era5_area_nearest(ds, lat, lon):
# """
# Crop a dataset containing EAR5 variables to a location.
# The output spatial grid is snapped to the nearest input grid points.
# Parameters
# ----------
# ds : xarray dataset
# A dataset containing ERA5 variables of interest.
# lat: tuple or list
# Latitude range for query.
# lon: tuple or list
# Longitude range for query.
# Returns
# -------
# An xarray dataset containing ERA5 variables for the selected location.
# """
# if min(lon) < 0:
# # re-order along longitude to go from -180 to 180
# ds = ds.assign_coords({"lon": (((ds.lon + 180) % 360) - 180)})
# ds = ds.reindex({"lon": np.sort(ds.lon)})
# # find the nearest lat lon boundary points
# test = ds.sel(lat=lat, lon=lon, method="nearest")
# # define the lat/lon grid
# lat_range = slice(test.lat.max().values, test.lat.min().values)
# lon_range = slice(test.lon.min().values, test.lon.max().values)
# # crop and keep attrs
# output = ds.sel(lat=lat_range, lon=lon_range)
# output.attrs = ds.attrs
# for var in output.data_vars:
# output[var].attrs = ds[var].attrs
# return output
# def load_era5_netcdf(var, lat, lon, time, grid="nearest", **kwargs):
# """
# Returns a ERA5 variable for a selected location and time window.
# Parameters
# ----------
# var : string
# Name of the ERA5 climate variable to download, e.g "air_temperature_at_2_metres"
# lat: tuple or list
# Latitude range for query.
# lon: tuple or list
# Longitude range for query.
# time: tuple or list
# Time range for query.
# grid: string
# Option for output spatial gridding.
# The default is 'nearest', for which output spatial grid is snapped to the nearest ERA5 input grid points.
# Alternatively, output spatial grid will either include input grid points within lat/lon boundaries or the nearest point if none is within the search location.
# Returns
# -------
# An xarray dataset containing the variable for the selected location and time window.
# """
# ds = get_era5_daily(var, time[0], time[1], **kwargs)
# if grid == "nearest":
# return era5_area_nearest(ds, lat, lon).compute()
# else:
# return era5_area_crop(ds, lat, lon).compute()
+92
View File
@@ -0,0 +1,92 @@
"""
Functions to retrieve iSDAsoil data.
"""
import numpy as np
import pandas as pd
import xarray as xr
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import rasterio as rio
from pyproj import Transformer
import matplotlib.pyplot as plt
import os
import numpy as np
from urllib.parse import urlparse
import boto3
from pystac import stac_io, Catalog
#this function allows us to directly query the data on s3, adapted from iSDA tutorial https://github.com/iSDA-Africa/isdasoil-tutorial/blob/main/iSDAsoil-tutorial.ipynb
def my_read_method(uri):
parsed = urlparse(uri)
if parsed.scheme == 's3':
bucket = parsed.netloc
key = parsed.path[1:]
s3 = boto3.resource('s3')
obj = s3.Object(bucket, key)
return obj.get()['Body'].read().decode('utf-8')
else:
return stac_io.default_read_text_method(uri)
stac_io.read_text_method = my_read_method
catalog = Catalog.from_file("https://isdasoil.s3.amazonaws.com/catalog.json")
assets = {}
for root, catalogs, items in catalog.walk():
for item in items:
str(f"Type: {item.get_parent().title}")
# save all items to a dictionary as we go along
assets[item.id] = item
for asset in item.assets.values():
if asset.roles == ['data']:
str(f"Title: {asset.title}")
str(f"Description: {asset.description}")
str(f"URL: {asset.href}")
str("------------")
# define load_isda() function
def load_isda(var, lat, lon):
"""
Download and return iSDA variable with number of bands corresponding to number of iSDA layers.
Parameters
----------
var : string
Name of the iSDA variable to download, e.g "ph"
lat: tuple or list
Latitude range for query.
lon: tuple or list
Longitude range for query.
"""
bands = assets[var].assets["image"].extra_fields.get('eo:bands')
bands = [val['description'] for val in bands]
if len(np.unique(bands)) > 1:
ds = xr.open_dataset(assets[var].assets["image"].href, engine="rasterio").rio.clip_box(
minx=lon[0],
miny=lat[0],
maxx=lon[1],
maxy=lat[1],
crs="EPSG:4326",
)
ds_layered = ds.drop_dims('band')
for x in np.unique(ds.band):
ds_layered[bands[x-1]] = ds.sel(band=x).to_array(dim='band').squeeze()
else:
ds_layered = xr.open_dataset(assets[var].assets["image"].href, engine="rasterio").rio.clip_box(
minx=lon[0],
miny=lat[0],
maxx=lon[1],
maxy=lat[1],
crs="EPSG:4326",
).squeeze()
return ds_layered
+39
View File
@@ -0,0 +1,39 @@
import xarray as xr
import numpy as np
# function to load soil moisture data
def load_soil_moisture(lat, lon, time, product = 'surface', grid = 'nearest'):
product_baseurl = 'https://dapds00.nci.org.au/thredds/dodsC/ub8/global/GRAFS/'
assert product in ['surface', 'rootzone'], 'product parameter must be surface or root-zone'
# lat, lon grid
if grid == 'nearest':
# select lat/lon range from data; snap to nearest grid
lat_range, lon_range = None, None
else:
# define a grid that covers the entire area of interest
lat_range = np.arange(np.max(np.ceil(np.array(lat)*10.+0.5)/10.-0.05), np.min(np.floor(np.array(lat)*10.-0.5)/10.+0.05)-0.05, -0.1)
lon_range = np.arange(np.min(np.floor(np.array(lon)*10.-0.5)/10.+0.05), np.max(np.ceil(np.array(lon)*10.+0.5)/10.-0.05)+0.05, 0.1)
# split time window into years
day_range = np.array(time).astype("M8[D]")
year_range = np.array(time).astype("M8[Y]")
if product == 'surface':
product_name = 'GRAFS_TopSoilRelativeWetness_'
else: product_name = 'GRAFS_RootzoneSoilWaterIndex_'
datasets = []
for year in np.arange(year_range[0], year_range[1]+1, np.timedelta64(1, 'Y')):
start = np.max([day_range[0], year.astype("M8[D]")])
end = np.min([day_range[1], (year+1).astype("M8[D]")-1])
product_url = product_baseurl + product_name +'%s.nc'%str(year)
print(product_url)
# data is loaded lazily through OPeNDAP
ds = xr.open_dataset(product_url)
if lat_range is None:
# select lat/lon range from data if not specified; snap to nearest grid
test = ds.sel(lat=list(lat), lon=list(lon), method='nearest')
lat_range = slice(test.lat.values[0], test.lat.values[1])
lon_range = slice(test.lon.values[0], test.lon.values[1])
# slice before return
ds = ds.sel(lat=lat_range, lon=lon_range, time=slice(start, end)).compute()
datasets.append(ds)
return xr.merge(datasets)
@@ -0,0 +1,117 @@
msgid ""
msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: POEditor.com\n"
"Project-Id-Version: deafrica_tools\n"
"Language: fr\n"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:83
msgid "None"
msgstr "Aucun"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:84
msgid "ESRI World Imagery"
msgstr "Imagerie mondiale ESRI"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:85
msgid "Sentinel-2 Geomedian"
msgstr "Sentinel-2 Geomedian"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:86
msgid "Water Observations from Space"
msgstr "Observations de l'eau depuis l'espace-WOfS"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:99
msgid "Wetlands Insight Tool"
msgstr "Outil d'analyse des zones humides"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:100
msgid "Select parameters and AOI"
msgstr "Sélectionner les paramètres et la zone d'intérêt"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:124
msgid "Total polygon area"
msgstr "Superficie totale du polygone"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:128
msgid "Area falls within recommended limit"
msgstr "La zone se situe dans la limite recommandée"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:131
msgid "Area is too large, please update your polygon"
msgstr "La zone est trop grande, veuillez réduire votre polygone"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:151
msgid "Map Overlays"
msgstr "Superpositions de cartes"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:176
msgid "Run"
msgstr "Exécuter"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:183
msgid "Map Overlay:"
msgstr "Carte superposée :"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:185
msgid "Start Date:"
msgstr "Date de début :"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:187
msgid "End Date:"
msgstr "Date de fin :"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:189
msgid "Minimum Good Data:"
msgstr "Minimum de bonnes données :"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:191
msgid "Resampling Frequency:"
msgstr "Fréquence de rééchantillonnage :"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:193
msgid "Output CSV:"
msgstr "Sortie CSV :"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:195
msgid "Output Plot:"
msgstr "Tracé de sortie :"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:308
msgid "Progress"
msgstr "Progrès"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:326
msgid "WIT complete"
msgstr "WIT achevée"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:328
msgid "No polygon selected"
msgstr "Aucun polygone sélectionné"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:365
msgid "open water"
msgstr "eau libre"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:366
msgid "wet"
msgstr "humide"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:367
msgid "green veg"
msgstr "végétation verts"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:368
msgid "dry veg"
msgstr "végétation seche"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:369
msgid "bare soil"
msgstr "sol nu"
#: Tools/deafrica_tools/app/wetlandsinsighttool.py:382
msgid "Percentage Fractional Cover, Wetness, and Water"
msgstr "Pourcentage de couverture fractionnée, humidité et eau"
File diff suppressed because it is too large Load Diff
+949
View File
@@ -0,0 +1,949 @@
'''
Spatial analyses functions for Digital Earth Africa data.
'''
# 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 multiprocessing as mp
import dask
import fiona
import geopandas as gpd
import numpy as np
import odc.geo.xr # adds `.odc.x` attributes to our xarray objects.
import pandas as pd
import rasterio.features
import scipy.interpolate
import xarray as xr
from datacube.api.query import query_group_by
from datacube.model.utils import xr_apply
from datacube.utils.cog import write_cog
from datacube.utils.geometry import CRS, Geometry
from geopy.geocoders import Nominatim
from rasterstats import zonal_stats
from shapely.geometry import LineString, MultiLineString, mapping, shape
from skimage.measure import find_contours, label
def add_geobox(ds, crs=None):
"""
Ensure that an xarray DataArray has a GeoBox and .odc.* accessor
using `odc.geo`.
If `ds` is missing a Coordinate Reference System (CRS), this can be
supplied using the `crs` param.
Parameters
----------
ds : xarray.Dataset or xarray.DataArray
Input xarray object that needs to be checked for spatial
information.
crs : str, optional
Coordinate Reference System (CRS) information for the input `ds`
array. If `ds` already has a CRS, then `crs` is not required.
Default is None.
Returns
-------
xarray.Dataset or xarray.DataArray
The input xarray object with added `.odc.x` attributes to access
spatial information.
"""
# If a CRS is not found, use custom provided CRS
if ds.odc.crs is None and crs is not None:
ds = ds.odc.assign_crs(crs)
elif ds.odc.crs is None and crs is None:
raise ValueError(
"Unable to determine `ds`'s coordinate "
"reference system (CRS). Please provide a "
"CRS using the `crs` parameter "
"(e.g. `crs='EPSG:3577'`)."
)
return ds
def xr_vectorize(
da,
attribute_col=None,
crs=None,
dtype="float32",
output_path=None,
verbose=True,
**rasterio_kwargs,
):
"""
Vectorises a raster ``xarray.DataArray`` into a vector
``geopandas.GeoDataFrame``.
Parameters
----------
da : xarray.DataArray
The input ``xarray.DataArray`` data to vectorise.
attribute_col : str, optional
Name of the attribute column in the resulting
``geopandas.GeoDataFrame``. Values from ``da`` converted
to polygons will be assigned to this column. If None,
the column name will default to 'attribute'.
crs : str or CRS object, optional
If ``da``'s coordinate reference system (CRS) cannot be
determined, provide a CRS using this parameter.
(e.g. 'EPSG:3577').
dtype : str, optional
Data type of must be one of int16, int32, uint8, uint16,
or float32
output_path : string, optional
Provide an optional string file path to export the vectorised
data to file. Supports any vector file formats supported by
``geopandas.GeoDataFrame.to_file()``.
verbose : bool, optional
Print debugging messages. Default True.
**rasterio_kwargs :
A set of keyword arguments to ``rasterio.features.shapes``.
Can include `mask` and `connectivity`.
Returns
-------
gdf : geopandas.GeoDataFrame
"""
# Add GeoBox and odc.* accessor to array using `odc-geo`
da = add_geobox(da, crs)
# Run the vectorizing function
vectors = rasterio.features.shapes(
source=da.data.astype(dtype), transform=da.odc.transform, **rasterio_kwargs
)
# Convert the generator into a list
vectors = list(vectors)
# Extract the polygon coordinates and values from the list
polygons = [polygon for polygon, value in vectors]
values = [value for polygon, value in vectors]
# Convert polygon coordinates into polygon shapes
polygons = [shape(polygon) for polygon in polygons]
# Create a geopandas dataframe populated with the polygon shapes
attribute_name = attribute_col if attribute_col is not None else "attribute"
gdf = gpd.GeoDataFrame(
data={attribute_name: values}, geometry=polygons, crs=da.odc.crs
)
# If a file path is supplied, export to file
if output_path is not None:
if verbose:
print(f"Exporting vector data to {output_path}")
gdf.to_file(output_path)
return gdf
def xr_rasterize(
gdf,
da,
attribute_col=None,
crs=None,
name=None,
output_path=None,
verbose=True,
**rasterio_kwargs,
):
"""
Rasterizes a vector ``geopandas.GeoDataFrame`` into a
raster ``xarray.DataArray``.
Parameters
----------
gdf : geopandas.GeoDataFrame
A ``geopandas.GeoDataFrame`` object containing the vector
data you want to rasterise.
da : xarray.DataArray or xarray.Dataset
The shape, coordinates, dimensions, and transform of this object
are used to define the array that ``gdf`` is rasterized into.
It effectively provides a spatial template.
attribute_col : string, optional
Name of the attribute column in ``gdf`` containing values for
each vector feature that will be rasterized. If None, the
output will be a boolean array of 1's and 0's.
crs : str or CRS object, optional
If ``da``'s coordinate reference system (CRS) cannot be
determined, provide a CRS using this parameter.
(e.g. 'EPSG:3577').
name : str, optional
An optional name used for the output ``xarray.DataArray`.
output_path : string, optional
Provide an optional string file path to export the rasterized
data as a GeoTIFF file.
verbose : bool, optional
Print debugging messages. Default True.
**rasterio_kwargs :
A set of keyword arguments to ``rasterio.features.rasterize``.
Can include: 'all_touched', 'merge_alg', 'dtype'.
Returns
-------
da_rasterized : xarray.DataArray
The rasterized vector data.
"""
# Add GeoBox and odc.* accessor to array using `odc-geo`
da = add_geobox(da, crs)
# Reproject vector data to raster's CRS
gdf_reproj = gdf.to_crs(crs=da.odc.crs)
# If an attribute column is specified, rasterise using vector
# attribute values. Otherwise, rasterise into a boolean array
if attribute_col is not None:
# Use the geometry and attributes from `gdf` to create an iterable
shapes = zip(gdf_reproj.geometry, gdf_reproj[attribute_col])
else:
# Use geometry directly (will produce a boolean numpy array)
shapes = gdf_reproj.geometry
# Rasterise shapes into a numpy array
im = rasterio.features.rasterize(
shapes=shapes,
out_shape=da.odc.geobox.shape,
transform=da.odc.geobox.transform,
**rasterio_kwargs,
)
# Convert numpy array to a full xarray.DataArray
# and set array name if supplied
da_rasterized = odc.geo.xr.wrap_xr(im=im, gbox=da.odc.geobox)
da_rasterized = da_rasterized.rename(name)
# If a file path is supplied, export to file
if output_path is not None:
if verbose:
print(f"Exporting raster data to {output_path}")
write_cog(da_rasterized, output_path, overwrite=True)
return da_rasterized
def subpixel_contours(
da,
z_values=[0.0],
crs=None,
attribute_df=None,
output_path=None,
min_vertices=2,
dim="time",
time_format="%Y-%m-%d",
errors="ignore",
verbose=True,
):
"""
Uses `skimage.measure.find_contours` to extract multiple z-value
contour lines from a two-dimensional array (e.g. multiple elevations
from a single DEM), or one z-value for each array along a specified
dimension of a multi-dimensional array (e.g. to map waterlines
across time by extracting a 0 NDWI contour from each individual
timestep in an xarray timeseries).
Contours are returned as a geopandas.GeoDataFrame with one row per
z-value or one row per array along a specified dimension. The
`attribute_df` parameter can be used to pass custom attributes
to the output contour features.
Last modified: May 2023
Parameters
----------
da : xarray DataArray
A two-dimensional or multi-dimensional array from which
contours are extracted. If a two-dimensional array is provided,
the analysis will run in 'single array, multiple z-values' mode
which allows you to specify multiple `z_values` to be extracted.
If a multi-dimensional array is provided, the analysis will run
in 'single z-value, multiple arrays' mode allowing you to
extract contours for each array along the dimension specified
by the `dim` parameter.
z_values : int, float or list of ints, floats
An individual z-value or list of multiple z-values to extract
from the array. If operating in 'single z-value, multiple
arrays' mode specify only a single z-value.
crs : string or CRS object, optional
If ``da``'s coordinate reference system (CRS) cannot be
determined, provide a CRS using this parameter.
(e.g. 'EPSG:3577').
output_path : string, optional
The path and filename for the output shapefile.
attribute_df : pandas.Dataframe, optional
A pandas.Dataframe containing attributes to pass to the output
contour features. The dataframe must contain either the same
number of rows as supplied `z_values` (in 'multiple z-value,
single array' mode), or the same number of rows as the number
of arrays along the `dim` dimension ('single z-value, multiple
arrays mode').
min_vertices : int, optional
The minimum number of vertices required for a contour to be
extracted. The default (and minimum) value is 2, which is the
smallest number required to produce a contour line (i.e. a start
and end point). Higher values remove smaller contours,
potentially removing noise from the output dataset.
dim : string, optional
The name of the dimension along which to extract contours when
operating in 'single z-value, multiple arrays' mode. The default
is 'time', which extracts contours for each array along the time
dimension.
time_format : string, optional
The format used to convert `numpy.datetime64` values to strings
if applied to data with a "time" dimension. Defaults to
"%Y-%m-%d".
errors : string, optional
If 'raise', then any failed contours will raise an exception.
If 'ignore' (the default), a list of failed contours will be
printed. If no contours are returned, an exception will always
be raised.
verbose : bool, optional
Print debugging messages. Default is True.
Returns
-------
output_gdf : geopandas geodataframe
A geopandas geodataframe object with one feature per z-value
('single array, multiple z-values' mode), or one row per array
along the dimension specified by the `dim` parameter ('single
z-value, multiple arrays' mode). If `attribute_df` was
provided, these values will be included in the shapefile's
attribute table.
"""
def _contours_to_multiline(da_i, z_value, min_vertices=2):
"""
Helper function to apply marching squares contour extraction
to an array and return a data as a shapely MultiLineString.
The `min_vertices` parameter allows you to drop small contours
with less than X vertices.
"""
# Extracts contours from array, and converts each discrete
# contour into a Shapely LineString feature. If the function
# returns a KeyError, this may be due to an unresolved issue in
# scikit-image: https://github.com/scikit-image/scikit-image/issues/4830
# A temporary workaround is to peturb the z-value by a tiny
# amount (1e-12) before using it to extract the contour.
try:
line_features = [
LineString(i[:, [1, 0]])
for i in find_contours(da_i.data, z_value)
if i.shape[0] >= min_vertices
]
except KeyError:
line_features = [
LineString(i[:, [1, 0]])
for i in find_contours(da_i.data, z_value + 1e-12)
if i.shape[0] >= min_vertices
]
# Output resulting lines into a single combined MultiLineString
return MultiLineString(line_features)
def _time_format(i, time_format):
"""
Converts numpy.datetime64 into formatted strings;
otherwise returns data as-is.
"""
if isinstance(i, np.datetime64):
ts = pd.to_datetime(str(i))
i = ts.strftime(time_format)
return i
# Verify input data is a xr.DataArray
if not isinstance(da, xr.DataArray):
raise ValueError(
"The input `da` is not an xarray.DataArray. "
"If you supplied an xarray.Dataset, pass in one "
"of its data variables using the syntax "
"`da=ds.<variable name>`."
)
# Add GeoBox and odc.* accessor to array using `odc-geo`
da = add_geobox(da, crs)
# If z_values is supplied is not a list, convert to list:
z_values = (
z_values
if (isinstance(z_values, list) or isinstance(z_values, np.ndarray))
else [z_values]
)
# If dask collection, load into memory
if dask.is_dask_collection(da):
if verbose:
print("Loading data into memory using Dask")
da = da.compute()
# Test number of dimensions in supplied data array
if len(da.shape) == 2:
if verbose:
print("Operating in multiple z-value, single array mode")
dim = "z_value"
contour_arrays = {
_time_format(i, time_format): _contours_to_multiline(da, i, min_vertices)
for i in z_values
}
else:
# Test if only a single z-value is given when operating in
# single z-value, multiple arrays mode
if verbose:
print("Operating in single z-value, multiple arrays mode")
if len(z_values) > 1:
raise ValueError(
"Please provide a single z-value when operating "
"in single z-value, multiple arrays mode"
)
contour_arrays = {
_time_format(i, time_format): _contours_to_multiline(
da_i, z_values[0], min_vertices
)
for i, da_i in da.groupby(dim)
}
# If attributes are provided, add the contour keys to that dataframe
if attribute_df is not None:
try:
attribute_df.insert(0, dim, contour_arrays.keys())
# If this fails, it is due to the applied attribute table not
# matching the structure of the loaded data
except ValueError:
if len(da.shape) == 2:
raise ValueError(
f"The provided `attribute_df` contains a different "
f"number of rows ({len(attribute_df.index)}) "
f"than the number of supplied `z_values` "
f"({len(z_values)})."
)
else:
raise ValueError(
f"The provided `attribute_df` contains a different "
f"number of rows ({len(attribute_df.index)}) "
f"than the number of arrays along the '{dim}' "
f"dimension ({len(da[dim])})."
)
# Otherwise, use the contour keys as the only main attributes
else:
attribute_df = list(contour_arrays.keys())
# Convert output contours to a geopandas.GeoDataFrame
contours_gdf = gpd.GeoDataFrame(
data=attribute_df, geometry=list(contour_arrays.values()), crs=da.odc.crs
)
# Define affine and use to convert array coords to geographic coords.
# We need to add 0.5 x pixel size to the x and y to obtain the centre
# point of our pixels, rather than the top-left corner
affine = da.odc.geobox.transform
shapely_affine = [
affine.a,
affine.b,
affine.d,
affine.e,
affine.xoff + affine.a / 2.0,
affine.yoff + affine.e / 2.0,
]
contours_gdf["geometry"] = contours_gdf.affine_transform(shapely_affine)
# Rename the data column to match the dimension
contours_gdf = contours_gdf.rename({0: dim}, axis=1)
# Drop empty timesteps
empty_contours = contours_gdf.geometry.is_empty
failed = ", ".join(map(str, contours_gdf[empty_contours][dim].to_list()))
contours_gdf = contours_gdf[~empty_contours]
# Raise exception if no data is returned, or if any contours fail
# when `errors='raise'. Otherwise, print failed contours
if empty_contours.all() and errors == "raise":
raise ValueError(
"Failed to generate any valid contours; verify that "
"values passed to `z_values` are valid and present "
"in `da`"
)
elif empty_contours.all() and errors == "ignore":
if verbose:
print(
"Failed to generate any valid contours; verify that "
"values passed to `z_values` are valid and present "
"in `da`"
)
elif empty_contours.any() and errors == "raise":
raise Exception(f"Failed to generate contours: {failed}")
elif empty_contours.any() and errors == "ignore":
if verbose:
print(f"Failed to generate contours: {failed}")
# If asked to write out file, test if GeoJSON or ESRI Shapefile. If
# GeoJSON, convert to EPSG:4326 before exporting.
if output_path and output_path.endswith(".geojson"):
if verbose:
print(f"Writing contours to {output_path}")
contours_gdf.to_crs("EPSG:4326").to_file(filename=output_path)
if output_path and output_path.endswith(".shp"):
if verbose:
print(f"Writing contours to {output_path}")
contours_gdf.to_file(filename=output_path)
return contours_gdf
def interpolate_2d(ds,
x_coords,
y_coords,
z_coords,
method='linear',
factor=1,
verbose=False,
**kwargs):
"""
This function takes points with X, Y and Z coordinates, and
interpolates Z-values across the extent of an existing xarray
dataset. This can be useful for producing smooth surfaces from point
data that can be compared directly against satellite data derived
from an OpenDataCube query.
Supported interpolation methods include 'linear', 'nearest' and
'cubic (using `scipy.interpolate.griddata`), and 'rbf' (using
`scipy.interpolate.Rbf`).
Last modified: February 2020
Parameters
----------
ds : xarray DataArray or Dataset
A two-dimensional or multi-dimensional array from which x and y
dimensions will be copied and used for the area in which to
interpolate point data.
x_coords, y_coords : numpy array
Arrays containing X and Y coordinates for all points (e.g.
longitudes and latitudes).
z_coords : numpy array
An array containing Z coordinates for all points (e.g.
elevations). These are the values you wish to interpolate
between.
method : string, optional
The method used to interpolate between point values. This string
is either passed to `scipy.interpolate.griddata` (for 'linear',
'nearest' and 'cubic' methods), or used to specify Radial Basis
Function interpolation using `scipy.interpolate.Rbf` ('rbf').
Defaults to 'linear'.
factor : int, optional
An optional integer that can be used to subsample the spatial
interpolation extent to obtain faster interpolation times, then
up-sample this array back to the original dimensions of the
data as a final step. For example, setting `factor=10` will
interpolate data into a grid that has one tenth of the
resolution of `ds`. This approach will be significantly faster
than interpolating at full resolution, but will potentially
produce less accurate or reliable results.
verbose : bool, optional
Print debugging messages. Default False.
**kwargs :
Optional keyword arguments to pass to either
`scipy.interpolate.griddata` (if `method` is 'linear', 'nearest'
or 'cubic'), or `scipy.interpolate.Rbf` (is `method` is 'rbf').
Returns
-------
interp_2d_array : xarray DataArray
An xarray DataArray containing with x and y coordinates copied
from `ds_array`, and Z-values interpolated from the points data.
"""
# Extract xy and elev points
points_xy = np.vstack([x_coords, y_coords]).T
# Extract x and y coordinates to interpolate into.
# If `factor` is greater than 1, the coordinates will be subsampled
# for faster run-times. If the last x or y value in the subsampled
# grid aren't the same as the last x or y values in the original
# full resolution grid, add the final full resolution grid value to
# ensure data is interpolated up to the very edge of the array
if ds.x[::factor][-1].item() == ds.x[-1].item():
x_grid_coords = ds.x[::factor].values
else:
x_grid_coords = ds.x[::factor].values.tolist() + [ds.x[-1].item()]
if ds.y[::factor][-1].item() == ds.y[-1].item():
y_grid_coords = ds.y[::factor].values
else:
y_grid_coords = ds.y[::factor].values.tolist() + [ds.y[-1].item()]
# Create grid to interpolate into
grid_y, grid_x = np.meshgrid(x_grid_coords, y_grid_coords)
# Apply scipy.interpolate.griddata interpolation methods
if method in ('linear', 'nearest', 'cubic'):
# Interpolate x, y and z values
interp_2d = scipy.interpolate.griddata(points=points_xy,
values=z_coords,
xi=(grid_y, grid_x),
method=method,
**kwargs)
# Apply Radial Basis Function interpolation
elif method == 'rbf':
# Interpolate x, y and z values
rbf = scipy.interpolate.Rbf(x_coords, y_coords, z_coords, **kwargs)
interp_2d = rbf(grid_y, grid_x)
# Create xarray dataarray from the data and resample to ds coords
interp_2d_da = xr.DataArray(interp_2d,
coords=[y_grid_coords, x_grid_coords],
dims=['y', 'x'])
# If factor is greater than 1, resample the interpolated array to
# match the input `ds` array
if factor > 1:
interp_2d_da = interp_2d_da.interp_like(ds)
return interp_2d_da
def contours_to_arrays(gdf, col):
"""
This function converts a polyline shapefile into an array with three
columns giving the X, Y and Z coordinates of each vertex. This data
can then be used as an input to interpolation procedures (e.g. using
a function like `interpolate_2d`.
Last modified: October 2021
Parameters
----------
gdf : Geopandas GeoDataFrame
A GeoPandas GeoDataFrame of lines to convert into point
coordinates.
col : str
A string giving the name of the GeoDataFrame field to use as
Z-values.
Returns
-------
A numpy array with three columns giving the X, Y and Z coordinates
of each vertex in the input GeoDataFrame.
"""
# Explode multi-part geometries into multiple single geometries.
gdf = gdf.explode(ignore_index=True)
coords_zvals = []
for i in range(0, len(gdf)):
val = gdf.iloc[i][col]
try:
coords = np.concatenate(
[np.vstack(x.coords.xy).T for x in gdf.iloc[i].geometry.geoms]
)
except Exception:
coords = np.vstack(gdf.iloc[i].geometry.coords.xy).T
coords_zvals.append(
np.column_stack((coords, np.full(np.shape(coords)[0], fill_value=val)))
)
return np.concatenate(coords_zvals)
def largest_region(bool_array, **kwargs):
'''
Takes a boolean array and identifies the largest contiguous region of
connected True values. This is returned as a new array with cells in
the largest region marked as True, and all other cells marked as False.
Parameters
----------
bool_array : boolean array
A boolean array (numpy or xarray.DataArray) with True values for
the areas that will be inspected to find the largest group of
connected cells
**kwargs :
Optional keyword arguments to pass to `measure.label`
Returns
-------
largest_region : boolean array
A boolean array with cells in the largest region marked as True,
and all other cells marked as False.
'''
# First, break boolean array into unique, discrete regions/blobs
blobs_labels = label(bool_array, background=0, **kwargs)
# Count the size of each blob, excluding the background class (0)
ids, counts = np.unique(blobs_labels[blobs_labels > 0],
return_counts=True)
# Identify the region ID of the largest blob
largest_region_id = ids[np.argmax(counts)]
# Produce a boolean array where 1 == the largest region
largest_region = blobs_labels == largest_region_id
return largest_region
def transform_geojson_wgs_to_epsg(geojson, EPSG):
"""
Takes a geojson dictionary and converts it from WGS84 (EPSG:4326) to desired EPSG
Parameters
----------
geojson: dict
a geojson dictionary containing a 'geometry' key, in WGS84 coordinates
EPSG: int
numeric code for the EPSG coordinate referecnce system to transform into
Returns
-------
transformed_geojson: dict
a geojson dictionary containing a 'coordinates' key, in the desired CRS
"""
gg = Geometry(geojson['geometry'], CRS('epsg:4326'))
gg = gg.to_crs(CRS(f'epsg:{EPSG}'))
return gg.__geo_interface__
def zonal_stats_parallel(shp,
raster,
statistics,
out_shp,
ncpus,
**kwargs):
"""
Summarizing raster datasets based on vector geometries in parallel.
Each cpu recieves an equal chunk of the dataset.
Utilizes the perrygeo/rasterstats package.
Parameters
----------
shp : str
Path to shapefile that contains polygons over
which zonal statistics are calculated
raster: str
Path to the raster from which the statistics are calculated.
This can be a virtual raster (.vrt).
statistics: list
list of statistics to calculate. e.g.
['min', 'max', 'median', 'majority', 'sum']
out_shp: str
Path to export shapefile containing zonal statistics.
ncpus: int
number of cores to parallelize the operations over.
kwargs:
Any other keyword arguments to rasterstats.zonal_stats()
See https://github.com/perrygeo/python-rasterstats for
all options
Returns
-------
Exports a shapefile to disk containing the zonal statistics requested
"""
# yields n sized chunks from list l (used for splitting task to multiple processes)
def chunks(l, n):
for i in range(0, len(l), n):
yield l[i:i + n]
# calculates zonal stats and adds results to a dictionary
def worker(z, raster, d):
z_stats = zonal_stats(z, raster, stats=statistics, **kwargs)
for i in range(0, len(z_stats)):
d[z[i]['id']] = z_stats[i]
# write output polygon
def write_output(zones, out_shp, d):
# copy schema and crs from input and add new fields for each statistic
schema = zones.schema.copy()
crs = zones.crs
for stat in statistics:
schema['properties'][stat] = 'float'
with fiona.open(out_shp, 'w', 'ESRI Shapefile', schema, crs) as output:
for elem in zones:
for stat in statistics:
elem['properties'][stat] = d[elem['id']][stat]
output.write({'properties': elem['properties'], 'geometry': mapping(shape(elem['geometry']))})
with fiona.open(shp) as zones:
jobs = []
# create manager dictionary (polygon ids=keys, stats=entries)
# where multiple processes can write without conflicts
man = mp.Manager()
d = man.dict()
# split zone polygons into 'ncpus' chunks for parallel processing
# and call worker() for each
split = chunks(zones, len(zones)//ncpus)
for z in split:
p = mp.Process(target=worker, args=(z, raster, d))
p.start()
jobs.append(p)
# wait that all chunks are finished
[j.join() for j in jobs]
write_output(zones, out_shp, d)
def reverse_geocode(coords, site_classes=None, state_classes=None):
"""
Takes a latitude and longitude coordinate, and performs a reverse
geocode to return a plain-text description of the location in the
form:
Site, State
E.g.: `reverse_geocode(coords=(-35.282163, 149.128835))`
'Canberra, Australian Capital Territory'
Parameters
----------
coords : tuple of floats
A tuple of (latitude, longitude) coordinates used to perform
the reverse geocode.
site_classes : list of strings, optional
A list of strings used to define the site part of the plain
text location description. Because the contents of the geocoded
address can vary greatly depending on location, these strings
are tested against the address one by one until a match is made.
Defaults to:
``['city', 'town', 'village', 'suburb', 'hamlet', 'county', 'municipality']``
state_classes : list of strings, optional
A list of strings used to define the state part of the plain
text location description. These strings are tested against the
address one by one until a match is made. Defaults to:
`['state', 'territory']`.
Returns
-------
If a valid geocoded address is found, a plain text location
description will be returned:
'Site, State'
If no valid address is found, formatted coordinates will be returned
instead:
'XX.XX S, XX.XX E'
"""
# Run reverse geocode using coordinates
geocoder = Nominatim(user_agent='Digital Earth Africa')
out = geocoder.reverse(coords)
# Create plain text-coords as fall-back
lat = f'{-coords[0]:.2f} S' if coords[0] < 0 else f'{coords[0]:.2f} N'
lon = f'{-coords[1]:.2f} W' if coords[1] < 0 else f'{coords[1]:.2f} E'
try:
# Get address from geocoded data
address = out.raw['address']
# Use site and state classes if supplied; else use defaults
default_site_classes = ['city', 'town', 'village', 'suburb', 'hamlet',
'county', 'municipality']
default_state_classes = ['state', 'territory']
site_classes = site_classes if site_classes else default_site_classes
state_classes = state_classes if state_classes else default_state_classes
# Return the first site or state class that exists in address dict
site = next((address[k] for k in site_classes if k in address), None)
state = next((address[k] for k in state_classes if k in address), None)
# If site and state exist in the data, return this.
# Otherwise, return N/E/S/W coordinates.
if site and state:
# Return as site, state formatted string
return f'{site}, {state}'
else:
# If no geocoding result, return N/E/S/W coordinates
print('No valid geocoded location; returning coordinates instead')
return f'{lat}, {lon}'
except (KeyError, AttributeError):
# If no geocoding result, return N/E/S/W coordinates
print('No valid geocoded location; returning coordinates instead')
return f'{lat}, {lon}'
def sun_angles(dc, query):
"""
For a given spatiotemporal query, calculate mean sun
azimuth and elevation for each satellite observation, and
return these as a new `xarray.Dataset` with 'sun_elevation'
and 'sun_azimuth' variables.
Parameters:
-----------
dc : datacube.Datacube object
Datacube instance used to load data.
query : dict
A dictionary containing query parameters used to identify
satellite observations and load metadata.
Returns:
--------
sun_angles_ds : xarray.Dataset
An `xarray.set` containing a 'sun_elevation' and
'sun_azimuth' variables.
"""
# Identify satellite datasets and group outputs using the
# same approach used to group satellite imagery (i.e. solar day)
gb = query_group_by(**query)
datasets = dc.find_datasets(**query)
dataset_array = dc.group_datasets(datasets, gb)
# Load and take the mean of metadata from each product
sun_azimuth = xr_apply(
dataset_array,
lambda t, dd: np.mean([d.metadata.eo_sun_azimuth for d in dd]),
dtype=float,
)
sun_elevation = xr_apply(
dataset_array,
lambda t, dd: np.mean([d.metadata.eo_sun_elevation for d in dd]),
dtype=float,
)
# Combine into new xarray.Dataset
sun_angles_ds = xr.merge(
[sun_elevation.rename("sun_elevation"), sun_azimuth.rename("sun_azimuth")]
)
return sun_angles_ds
+576
View File
@@ -0,0 +1,576 @@
"""
Functions for calculating per-pixel temporal summary statistics on a
timeseries stored in a xarray.DataArray.
The key functions are:
.. autosummary::
:caption: Primary functions
:nosignatures:
:toctree: gen
xr_phenology
temporal_statistics
.. autosummary::
:nosignatures:
:toctree: gen
"""
import sys
import dask
import numpy as np
import xarray as xr
import hdstats
from packaging import version
from datacube.utils.geometry import assign_crs
def allNaN_arg(da, dim, stat):
"""
Calculate da.argmax() or da.argmin() while handling
all-NaN slices. Fills all-NaN locations with an
float and then masks the offending cells.
Parameters
----------
da : xarray.DataArray
dim : str
Dimension over which to calculate argmax, argmin e.g. 'time'
stat : str
The statistic to calculte, either 'min' for argmin()
or 'max' for .argmax()
Returns
-------
xarray.DataArray
"""
# generate a mask where entire axis along dimension is NaN
mask = da.isnull().all(dim)
if stat == "max":
y = da.fillna(float(da.min() - 1))
y = y.argmax(dim=dim, skipna=True).where(~mask)
return y
if stat == "min":
y = da.fillna(float(da.max() + 1))
y = y.argmin(dim=dim, skipna=True).where(~mask)
return y
def _vpos(da):
"""
vPOS = Value at peak of season
"""
return da.max("time")
def _pos(da):
"""
POS = DOY of peak of season
"""
return da.isel(time=da.argmax("time")).time.dt.dayofyear
def _trough(da):
"""
Trough = Minimum value
"""
return da.min("time")
def _aos(vpos, trough):
"""
AOS = Amplitude of season
"""
return vpos - trough
def _vsos(da, pos, method_sos="first"):
"""
vSOS = Value at the start of season
Params
-----
da : xarray.DataArray
method_sos : str,
If 'first' then vSOS is estimated
as the first positive slope on the
greening side of the curve. If 'median',
then vSOS is estimated as the median value
of the postive slopes on the greening side
of the curve.
"""
# select timesteps before peak of season (AKA greening)
greenup = da.where(da.time < pos.time)
# find the first order slopes
green_deriv = greenup.differentiate("time")
# find where the first order slope is postive
pos_green_deriv = green_deriv.where(green_deriv > 0)
# positive slopes on greening side
pos_greenup = greenup.where(~np.isnan(pos_green_deriv))
# find the median
median = pos_greenup.median("time")
# distance of values from median
distance = pos_greenup - median
if method_sos == "first":
# find index (argmin) where distance is most negative
idx = allNaN_arg(distance, "time", "min").astype("int16")
if method_sos == "median":
# find index (argmin) where distance is smallest absolute value
idx = allNaN_arg(np.fabs(distance), "time", "min").astype("int16")
return pos_greenup.isel(time=idx)
def _sos(vsos):
"""
SOS = DOY for start of season
"""
return vsos.time.dt.dayofyear
def _veos(da, pos, method_eos="last"):
"""
vEOS = Value at the end of season
Params
-----
method_eos : str
If 'last' then vEOS is estimated
as the last negative slope on the
senescing side of the curve. If 'median',
then vEOS is estimated as the 'median' value
of the negative slopes on the senescing
side of the curve.
"""
# select timesteps before peak of season (AKA greening)
senesce = da.where(da.time > pos.time)
# find the first order slopes
senesce_deriv = senesce.differentiate("time")
# find where the fst order slope is negative
neg_senesce_deriv = senesce_deriv.where(~np.isnan(senesce_deriv < 0))
# negative slopes on senescing side
neg_senesce = senesce.where(neg_senesce_deriv)
# find medians
median = neg_senesce.median("time")
# distance to the median
distance = neg_senesce - median
if method_eos == "last":
# index where last negative slope occurs
idx = allNaN_arg(distance, "time", "min").astype("int16")
if method_eos == "median":
# index where median occurs
idx = allNaN_arg(np.fabs(distance), "time", "min").astype("int16")
return neg_senesce.isel(time=idx)
def _eos(veos):
"""
EOS = DOY for end of seasonn
"""
return veos.time.dt.dayofyear
def _los(da, eos, sos):
"""
LOS = Length of season (in DOY)
"""
los = eos - sos
#handle negative values
los = xr.where(
los >= 0,
los,
da.time.dt.dayofyear.values[-1] + (eos.where(los < 0) - sos.where(los < 0)),
)
return los
def _rog(vpos, vsos, pos, sos):
"""
ROG = Rate of Greening (Days)
"""
return (vpos - vsos) / (pos - sos)
def _ros(veos, vpos, eos, pos):
"""
ROG = Rate of Senescing (Days)
"""
return (veos - vpos) / (eos - pos)
def xr_phenology(
da,
stats=[
"SOS",
"POS",
"EOS",
"Trough",
"vSOS",
"vPOS",
"vEOS",
"LOS",
"AOS",
"ROG",
"ROS",
],
method_sos="first",
method_eos="last",
verbose=True
):
"""
Obtain land surface phenology metrics from an
xarray.DataArray containing a timeseries of a
vegetation index like NDVI.
last modified June 2020
Parameters
----------
da : xarray.DataArray
DataArray should contain a 2D or 3D time series of a
vegetation index like NDVI, EVI
stats : list
list of phenological statistics to return. Regardless of
the metrics returned, all statistics are calculated
due to inter-dependencies between metrics.
Options include:
* `SOS` = DOY of start of season
* `POS` = DOY of peak of season
* `EOS` = DOY of end of season
* `vSOS` = Value at start of season
* `vPOS` = Value at peak of season
* `vEOS` = Value at end of season
* `Trough` = Minimum value of season
* `LOS` = Length of season (DOY)
* `AOS` = Amplitude of season (in value units)
* `ROG` = Rate of greening
* `ROS` = Rate of senescence
method_sos : str
If 'first' then vSOS is estimated as the first positive
slope on the greening side of the curve. If 'median',
then vSOS is estimated as the median value of the postive
slopes on the greening side of the curve.
method_eos : str
If 'last' then vEOS is estimated as the last negative slope
on the senescing side of the curve. If 'median', then vEOS is
estimated as the 'median' value of the negative slopes on the
senescing side of the curve.
Returns
-------
xarray.Dataset
Dataset containing variables for the selected
phenology statistics
"""
# Check inputs before running calculations
if dask.is_dask_collection(da):
if version.parse(xr.__version__) < version.parse("0.16.0"):
raise TypeError(
"Dask arrays are not currently supported by this function, "
+ "run da.compute() before passing dataArray."
)
stats_dtype = {
"SOS": np.int16,
"POS": np.int16,
"EOS": np.int16,
"Trough": np.float32,
"vSOS": np.float32,
"vPOS": np.float32,
"vEOS": np.float32,
"LOS": np.int16,
"AOS": np.float32,
"ROG": np.float32,
"ROS": np.float32,
}
da_template = da.isel(time=0).drop("time")
template = xr.Dataset(
{
var_name: da_template.astype(var_dtype)
for var_name, var_dtype in stats_dtype.items()
if var_name in stats
}
)
da_all_time = da.chunk({"time": -1})
lazy_phenology = da_all_time.map_blocks(
xr_phenology,
kwargs=dict(
stats=stats,
method_sos=method_sos,
method_eos=method_eos,
),
template=xr.Dataset(template),
)
try:
crs = da.geobox.crs
lazy_phenology = assign_crs(lazy_phenology, str(crs))
except:
pass
return lazy_phenology
if method_sos not in ("median", "first"):
raise ValueError("method_sos should be either 'median' or 'first'")
if method_eos not in ("median", "last"):
raise ValueError("method_eos should be either 'median' or 'last'")
# If stats supplied is not a list, convert to list.
stats = stats if isinstance(stats, list) else [stats]
# try to grab the crs info
try:
crs = da.geobox.crs
except:
pass
# remove any remaining all-NaN pixels
mask = da.isnull().all("time")
da = da.where(~mask, other=0)
# calculate the statistics
if verbose:
print(" Phenology...")
vpos = _vpos(da)
pos = _pos(da)
trough = _trough(da)
aos = _aos(vpos, trough)
vsos = _vsos(da, pos, method_sos=method_sos)
sos = _sos(vsos)
veos = _veos(da, pos, method_eos=method_eos)
eos = _eos(veos)
los = _los(da, eos, sos)
rog = _rog(vpos, vsos, pos, sos)
ros = _ros(veos, vpos, eos, pos)
# Dictionary containing the statistics
stats_dict = {
"SOS": sos.astype(np.int16),
"EOS": eos.astype(np.int16),
"vSOS": vsos.astype(np.float32),
"vPOS": vpos.astype(np.float32),
"Trough": trough.astype(np.float32),
"POS": pos.astype(np.int16),
"vEOS": veos.astype(np.float32),
"LOS": los.astype(np.int16),
"AOS": aos.astype(np.float32),
"ROG": rog.astype(np.float32),
"ROS": ros.astype(np.float32),
}
# intialise dataset with first statistic
ds = stats_dict[stats[0]].to_dataset(name=stats[0])
# add the other stats to the dataset
for stat in stats[1:]:
if verbose:
print(" " + stat)
stats_keep = stats_dict.get(stat)
ds[stat] = stats_dict[stat]
try:
ds = assign_crs(ds, str(crs))
except:
pass
return ds.drop("time")
def temporal_statistics(da, stats):
"""
Calculate various generic summary statistics on any timeseries.
This function uses the hdstats temporal library:
https://github.com/daleroberts/hdstats/blob/master/hdstats/ts.pyx
last modified June 2020
Parameters
----------
da : xarray.DataArray
DataArray should contain a 3D time series.
stats : list
list of temporal statistics to calculate.
Options include:
* 'discordance' =
* 'f_std' = std of discrete fourier transform coefficients, returns
three layers: f_std_n1, f_std_n2, f_std_n3
* 'f_mean' = mean of discrete fourier transform coefficients, returns
three layers: f_mean_n1, f_mean_n2, f_mean_n3
* 'f_median' = median of discrete fourier transform coefficients, returns
three layers: f_median_n1, f_median_n2, f_median_n3
* 'mean_change' = mean of discrete difference along time dimension
* 'median_change' = median of discrete difference along time dimension
* 'abs_change' = mean of absolute discrete difference along time dimension
* 'complexity' =
* 'central_diff' =
* 'num_peaks' : The number of peaks in the timeseries, defined with a local
window of size 10. NOTE: This statistic is very slow
Returns
-------
xarray.Dataset
Dataset containing variables for the selected
temporal statistics
"""
# if dask arrays then map the blocks
if dask.is_dask_collection(da):
if version.parse(xr.__version__) < version.parse("0.16.0"):
raise TypeError(
"Dask arrays are only supported by this function if using, "
+ "xarray v0.16, run da.compute() before passing dataArray."
)
# create a template that matches the final datasets dims & vars
arr = da.isel(time=0).drop("time")
# deal with the case where fourier is first in the list
if stats[0] in ("f_std", "f_median", "f_mean"):
template = xr.zeros_like(arr).to_dataset(name=stats[0] + "_n1")
template[stats[0] + "_n2"] = xr.zeros_like(arr)
template[stats[0] + "_n3"] = xr.zeros_like(arr)
for stat in stats[1:]:
if stat in ("f_std", "f_median", "f_mean"):
template[stat + "_n1"] = xr.zeros_like(arr)
template[stat + "_n2"] = xr.zeros_like(arr)
template[stat + "_n3"] = xr.zeros_like(arr)
else:
template[stat] = xr.zeros_like(arr)
else:
template = xr.zeros_like(arr).to_dataset(name=stats[0])
for stat in stats:
if stat in ("f_std", "f_median", "f_mean"):
template[stat + "_n1"] = xr.zeros_like(arr)
template[stat + "_n2"] = xr.zeros_like(arr)
template[stat + "_n3"] = xr.zeros_like(arr)
else:
template[stat] = xr.zeros_like(arr)
try:
template = template.drop("spatial_ref")
except:
pass
# ensure the time chunk is set to -1
da_all_time = da.chunk({"time": -1})
# apply function across chunks
lazy_ds = da_all_time.map_blocks(
temporal_statistics, kwargs={"stats": stats}, template=template
)
try:
crs = da.geobox.crs
lazy_ds = assign_crs(lazy_ds, str(crs))
except:
pass
return lazy_ds
# If stats supplied is not a list, convert to list.
stats = stats if isinstance(stats, list) else [stats]
# grab all the attributes of the xarray
x, y, time, attrs = da.x, da.y, da.time, da.attrs
# deal with any all-NaN pixels by filling with 0's
mask = da.isnull().all("time")
da = da.where(~mask, other=0)
# ensure dim order is correct for functions
da = da.transpose("y", "x", "time").values
stats_dict = {
"discordance": lambda da: hdstats.discordance(da, n=10),
"f_std": lambda da: hdstats.fourier_std(da, n=3, step=5),
"f_mean": lambda da: hdstats.fourier_mean(da, n=3, step=5),
"f_median": lambda da: hdstats.fourier_median(da, n=3, step=5),
"mean_change": lambda da: hdstats.mean_change(da),
"median_change": lambda da: hdstats.median_change(da),
"abs_change": lambda da: hdstats.mean_abs_change(da),
"complexity": lambda da: hdstats.complexity(da),
"central_diff": lambda da: hdstats.mean_central_diff(da),
"num_peaks": lambda da: hdstats.number_peaks(da, 10),
}
print(" Statistics:")
# if one of the fourier functions is first (or only)
# stat in the list then we need to deal with this
if stats[0] in ("f_std", "f_median", "f_mean"):
print(" " + stats[0])
stat_func = stats_dict.get(str(stats[0]))
zz = stat_func(da)
n1 = zz[:, :, 0]
n2 = zz[:, :, 1]
n3 = zz[:, :, 2]
# intialise dataset with first statistic
ds = xr.DataArray(
n1, attrs=attrs, coords={"x": x, "y": y}, dims=["y", "x"]
).to_dataset(name=stats[0] + "_n1")
# add other datasets
for i, j in zip([n2, n3], ["n2", "n3"]):
ds[stats[0] + "_" + j] = xr.DataArray(
i, attrs=attrs, coords={"x": x, "y": y}, dims=["y", "x"]
)
else:
# simpler if first function isn't fourier transform
first_func = stats_dict.get(str(stats[0]))
print(" " + stats[0])
ds = first_func(da)
# convert back to xarray dataset
ds = xr.DataArray(
ds, attrs=attrs, coords={"x": x, "y": y}, dims=["y", "x"]
).to_dataset(name=stats[0])
# loop through the other functions
for stat in stats[1:]:
print(" " + stat)
# handle the fourier transform examples
if stat in ("f_std", "f_median", "f_mean"):
stat_func = stats_dict.get(str(stat))
zz = stat_func(da)
n1 = zz[:, :, 0]
n2 = zz[:, :, 1]
n3 = zz[:, :, 2]
for i, j in zip([n1, n2, n3], ["n1", "n2", "n3"]):
ds[stat + "_" + j] = xr.DataArray(
i, attrs=attrs, coords={"x": x, "y": y}, dims=["y", "x"]
)
else:
# Select a stats function from the dictionary
# and add to the dataset
stat_func = stats_dict.get(str(stat))
ds[stat] = xr.DataArray(
stat_func(da), attrs=attrs, coords={"x": x, "y": y}, dims=["y", "x"]
)
# try to add back the geobox
try:
crs = da.geobox.crs
ds = assign_crs(ds, str(crs))
except:
pass
return ds
View File
+732
View File
@@ -0,0 +1,732 @@
"""
Functions for working with the Wetlands Insight Tool (WIT)
"""
# 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 warnings
import numpy as np
import pandas as pd
import geopandas as gpd
import seaborn as sns
import xarray as xr
import matplotlib.pyplot as plt
from skimage import exposure
import matplotlib.animation as animation
import matplotlib.patheffects as PathEffects
from mpl_toolkits.axes_grid1.inset_locator import inset_axes
from dask.distributed import progress
import datacube
from datacube.utils import masking
from datacube.utils import geometry
from deafrica_tools.bandindices import calculate_indices
from deafrica_tools.datahandling import load_ard, wofs_fuser
from deafrica_tools.spatial import xr_rasterize
from deafrica_tools.classification import HiddenPrints
def WIT_drill(
gdf,
time,
min_gooddata=0.85,
TCW_threshold=-0.035,
resample_frequency=None,
export_csv=None,
dask_chunks=None,
verbose=False,
verbose_progress=False,
):
"""
The Wetlands Insight Tool run onver an extent covered by a polygon.
This function loads FC, WOfS, and Landsat data, and calculates tasseled
cap wetness, in order to determine the dominant land cover class
within a polygon at each satellite observation.
The output is a pandas dataframe containing a timeseries of the relative
fractions of each class at each time-step. This forms the input to produce
a stacked line-plot.
Last modified: Oct 2021
Parameters
----------
gdf : geopandas.GeoDataFrame
The dataframe must only contain a single row,
containing the polygon you wish to interrograte.
time : tuple
a tuple containing the time range over which to run the WIT.
e.g. ('2015-01' , '2019-12')
min_gooddata : Float, optional
A number between 0 and 1 (e.g 0.8) indicating the minimum percentage
of good quality pixels required for a satellite observation to be loaded
and therefore included in the WIT plot. This number should, at a minimum,
be set to 0.80 to limit biases in the result if not resampling the time-series.
If resampling the data using the parameter `resample_frequency`, then
setting this number to 0 (or a low float number) is acceptable.
TCW_threshold : Int, optional
The tasseled cap wetness threshold, beyond which a pixel will be
considered 'wet'. Defaults to -0.035.
resample_frequency : str
Option for resampling time-series of input datasets. This option is useful
for either smoothing the WIT plot, or because the area of analysis is larger
than a scene width and therefore requires composites. Options include any
str accepted by `xarray.resample(time=)`. The resampling method used is .max()
export_csv : str, optional
To save the returned pandas dataframe as a .csv file, pass a
a location string (e.g. 'output/results.csv')
dask_chunks : dict, optional
To lazily load the datasets using dask, pass a dictionary containing
the dimensions over which to chunk e.g. {'time':-1, 'x':250, 'y':250}.
verbose: bool, optional
If true, print statements are putput detailing the progress of the tool.
verbose_progress: bool, optional
For use with Dask progress bar
Returns
-------
df : Pandas.Dataframe
A pandas dataframe containing the timeseries of relative fractions
of each land cover class (WOfs, FC, TCW)
"""
# add geom to dc query dict
if isinstance(gdf, datacube.utils.geometry._base.Geometry):
gdf = gpd.GeoDataFrame({'col1':['name'],'geometry':gdf.geom}, crs=gdf.crs)
geom = geometry.Geometry(geom=gdf.iloc[0].geometry, crs=gdf.crs)
query = {"geopolygon": geom, "time": time}
# Create a datacube instance
dc = datacube.Datacube(app="wetlands insight tool")
# load landsat 5,7,8 data
warnings.filterwarnings("ignore")
if verbose_progress:
print("Loading Landsat data")
ds_ls = load_ard(
dc=dc,
products=["ls8_sr", "ls7_sr", "ls5_sr"],
output_crs="epsg:6933",
min_gooddata=min_gooddata,
mask_filters=(['opening', 3], ['dilation', 3]),
measurements=["red", "green", "blue", "nir", "swir_1", "swir_2"],
dask_chunks=dask_chunks,
group_by="solar_day",
resolution=(-30, 30),
verbose=verbose,
**query,
)
# create polygon mask
mask = xr_rasterize(gdf.iloc[[0]], ds_ls)
ds_ls = ds_ls.where(mask)
# calculate tasselled cap wetness within masked AOI
if verbose:
print("calculating tasseled cap wetness index ")
with HiddenPrints(): #suppres the prints from this func
tcw = calculate_indices(
ds_ls, index=["TCW"], normalise=False, satellite_mission="ls", drop=True
)
if resample_frequency is not None:
if verbose:
print('Resampling TCW to '+ resample_frequency)
tcw = tcw.resample(time=resample_frequency).max()
tcw = tcw.TCW >= TCW_threshold
tcw = tcw.where(mask, 0)
tcw = tcw.persist()
if verbose:
print("Loading WOfS layers ")
wofls = dc.load(
product="wofs_ls",
like=ds_ls,
fuse_func=wofs_fuser,
dask_chunks=dask_chunks,
collection_category="T1",
)
# boolean of wet/dry
wofls_wet = masking.make_mask(wofls.water, wet=True)
if resample_frequency is not None:
if verbose:
print('Resampling WOfS to '+ resample_frequency)
wofls_wet = wofls_wet.resample(time=resample_frequency).max()
# mask sure wofs matches other datasets
wofls_wet = wofls_wet.where(wofls_wet.time == tcw.time)
# apply the polygon mask
wofls_wet = wofls_wet.where(mask)
# load Fractional cover
if verbose:
print("Loading fractional Cover")
# load fractional cover
fc_ds = dc.load(
product="fc_ls",
time=time,
dask_chunks=dask_chunks,
like=ds_ls,
measurements=["pv", "npv", "bs"],
collection_category="T1",
)
# use wofls mask to cloud mask FC
clear_and_dry = masking.make_mask(wofls, dry=True).water
fc_ds = fc_ds.where(clear_and_dry)
if resample_frequency is not None:
if verbose:
print('Resampling FC to '+ resample_frequency)
fc_ds = fc_ds.resample(time=resample_frequency).max()
# mask sure fc matches other datasets
fc_ds = fc_ds.where(fc_ds.time == tcw.time)
# mask with polygon
fc_ds = fc_ds.where(mask)
# mask with TC wetness
fc_ds_noTCW = fc_ds.where(tcw == False)
if verbose:
print("Generating classification")
# Cast the dataset to a dataarray
fc_ds_noTCW = fc_ds_noTCW.to_array(dim="variable", name="fc_ds_noTCW")
# turn FC array into integer only as nanargmax doesn't
# seem to handle floats the way we want it to
fc_int = fc_ds_noTCW.astype("int8")
# use nanargmax to get the index of the maximum value
BSPVNPV = fc_int.argmax(dim="variable")
#int dytype remocves NaNs so we need to create mask again
FC_mask = np.isfinite(fc_ds_noTCW).all(dim="variable")
BSPVNPV = BSPVNPV.where(FC_mask)
# Restack the Fractional cover dataset all together
# CAUTION:ARGMAX DEPENDS ON ORDER OF VARIABALES IN
# DATASET. NEED TO ADJUST BELOW DEPENDING ON ORDER OF FC VARIABLES
FC_dominant = xr.Dataset(
{
"bs": (BSPVNPV == 2).where(FC_mask),
"pv": (BSPVNPV == 0).where(FC_mask),
"npv": (BSPVNPV == 1).where(FC_mask),
}
)
# pixel counts
pixels = mask.sum(dim=["x", "y"])
if verbose_progress:
print("Computing wetness")
tcw_pixel_count = tcw.sum(dim=["x", "y"]).compute()
if verbose_progress:
print("Computing green veg, dry veg, and bare soil")
FC_count = FC_dominant.sum(dim=["x", "y"]).compute()
if verbose_progress:
print("Computing open water")
wofs_pixels = wofls_wet.sum(dim=["x", "y"]).compute()
# count percentages
wofs_area_percent = (wofs_pixels / pixels) * 100
tcw_area_percent = (tcw_pixel_count / pixels) * 100
tcw_less_wofs = tcw_area_percent - wofs_area_percent # wet not wofs
# Fractional cover pixel count method
# Get number of FC pixels, divide by total number of pixels per polygon
# Work out the number of nodata pixels in the data
BS_percent = (FC_count.bs / pixels) * 100
PV_percent = (FC_count.pv / pixels) * 100
NPV_percent = (FC_count.npv / pixels) * 100
NoData_count = ((
100 - wofs_area_percent - tcw_less_wofs - PV_percent - NPV_percent - BS_percent
) / 100) * pixels
# re-do percentages but now handling any no-data pixels within polygon
BS_percent = (FC_count.bs / (pixels - NoData_count)) * 100
PV_percent = (FC_count.pv / (pixels - NoData_count)) * 100
NPV_percent = (FC_count.npv / (pixels - NoData_count)) * 100
wofs_area_percent = (wofs_pixels / (pixels - NoData_count)) * 100
tcw_area_percent = (tcw_pixel_count / (pixels - NoData_count)) * 100
tcw_less_wofs = tcw_area_percent - wofs_area_percent
# Sometimes when we resample datastes, WOfS extent can be
# greater than the wetness extent, thus make negative values == zero
tcw_less_wofs = tcw_less_wofs.where(tcw_less_wofs>=0, 0)
# start setup of dataframe by adding only one dataset
df = pd.DataFrame(
data=wofs_area_percent.data,
index=wofs_area_percent.time.values,
columns=["wofs_area_percent"],
)
# add data into pandas dataframe for export
df["wet_percent"] = tcw_less_wofs.data
df["green_veg_percent"] = PV_percent.data
df["dry_veg_percent"] = NPV_percent.data
df["bare_soil_percent"] = BS_percent.data
# round numbers
df = df.round(2)
# save the csv of the output data used to create the stacked plot for the polygon drill
if export_csv:
if verbose:
print("exporting csv: " + export_csv)
df.to_csv(export_csv, index_label="Datetime")
return df
def animated_timeseries_WIT(
ds,
df,
output_path,
width_pixels=1000,
interval=200,
bands=["red", "green", "blue"],
percentile_stretch=(0.02, 0.98),
image_proc_func=None,
title=False,
show_date=True,
annotation_kwargs={},
onebandplot_cbar=True,
onebandplot_kwargs={},
shapefile_path=None,
shapefile_kwargs={},
pandasplot_kwargs={},
time_dim="time",
x_dim="x",
y_dim="y",
):
###############
# Setup steps #
###############
# Test if all dimensions exist in dataset
if time_dim in ds and x_dim in ds and y_dim in ds:
# Test if there is one or three bands, and that all exist in both datasets:
if ((len(bands) == 3) | (len(bands) == 1)) & all(
[(b in ds.data_vars) for b in bands]
):
# Import xarrays as lists of three band numpy arrays
imagelist, vmin, vmax = _ds_to_arrraylist(
ds,
bands=bands,
time_dim=time_dim,
x_dim=x_dim,
y_dim=y_dim,
percentile_stretch=percentile_stretch,
image_proc_func=image_proc_func,
)
# Get time, x and y dimensions of dataset and calculate width vs height of plot
timesteps = len(ds[time_dim])
width = len(ds[x_dim])
height = len(ds[y_dim])
width_ratio = float(width) / float(height)
height = 10.0 / width_ratio
# If title is supplied as a string, multiply out to a list with one string per timestep.
# Otherwise, use supplied list for plot titles.
if isinstance(title, str) or isinstance(title, bool):
title_list = [title] * timesteps
else:
title_list = title
# Set up annotation parameters that plt.imshow plotting for single band array images.
# The nested dict structure sets default values which can be overwritten/customised by the
# manually specified `onebandplot_kwargs`
onebandplot_kwargs = dict(
{
"cmap": "Greys",
"interpolation": "bilinear",
"vmin": vmin,
"vmax": vmax,
"tick_colour": "black",
"tick_fontsize": 11,
},
**onebandplot_kwargs,
)
# Use pop to remove the two special tick kwargs from the onebandplot_kwargs dict, and save individually
onebandplot_tick_colour = onebandplot_kwargs.pop("tick_colour")
onebandplot_tick_fontsize = onebandplot_kwargs.pop("tick_fontsize")
# Set up annotation parameters that control font etc. The nested dict structure sets default
# values which can be overwritten/customised by the manually specified `annotation_kwargs`
annotation_kwargs = dict(
{
"xy": (1, 1),
"xycoords": "axes fraction",
"xytext": (-5, -5),
"textcoords": "offset points",
"horizontalalignment": "right",
"verticalalignment": "top",
"fontsize": 15,
"color": "white",
"path_effects": [
PathEffects.withStroke(linewidth=3, foreground="black")
],
},
**annotation_kwargs,
)
# Define default plotting parameters for the overlaying shapefile(s). The nested dict structure sets
# default values which can be overwritten/customised by the manually specified `shapefile_kwargs`
shapefile_kwargs = dict(
{"linewidth": 2, "edgecolor": "black", "facecolor": "#00000000"},
**shapefile_kwargs,
)
# Define default plotting parameters for the right-hand line plot. The nested dict structure sets
# default values which can be overwritten/customised by the manually specified `pandasplot_kwargs`
pandasplot_kwargs = dict({}, **pandasplot_kwargs)
###################
# Initialise plot #
###################
# Set up figure
fig, (ax1, ax2) = plt.subplots(
ncols=2, gridspec_kw={"width_ratios": [1, 2]}
)
fig.subplots_adjust(left=0, bottom=0, right=1, top=1, wspace=0.2, hspace=0)
fig.set_size_inches(10.0, height * 0.5, forward=True)
ax1.axis("off")
ax2.margins(x=0.01)
ax2.xaxis.label.set_visible(False)
# Initialise axesimage objects to be updated during animation, setting extent from dims
extents = [
float(ds[x_dim].min()),
float(ds[x_dim].max()),
float(ds[y_dim].min()),
float(ds[y_dim].max()),
]
im = ax1.imshow(imagelist[0], extent=extents, **onebandplot_kwargs)
# Initialise right panel and set y axis limits
# set up color palette
pal = [
sns.xkcd_rgb["cobalt blue"],
sns.xkcd_rgb["neon blue"],
sns.xkcd_rgb["grass"],
sns.xkcd_rgb["beige"],
sns.xkcd_rgb["brown"],
]
# make a stacked area plot
ax2.stackplot(
df.index,
df.wofs_area_percent,
df.wet_percent,
df.green_veg_percent,
df.dry_veg_percent,
df.bare_soil_percent,
labels=["open water", "wet", "green veg", "dry veg", "bare soil"],
colors=pal,
alpha=0.6,
**pandasplot_kwargs,
)
ax2.legend(loc="lower left", framealpha=0.6)
df1 = pd.DataFrame(
{
"wofs_area_percent": df.wofs_area_percent,
"wet_percent": df.wofs_area_percent + df.wet_percent,
"green_veg_percent": df.wofs_area_percent
+ df.wet_percent
+ df.green_veg_percent,
"dry_veg_percent": df.wofs_area_percent
+ df.wet_percent
+ df.green_veg_percent
+ df.dry_veg_percent,
"bare_soil_percent": df.dry_veg_percent
+ df.green_veg_percent
+ df.wofs_area_percent
+ df.wet_percent
+ df.bare_soil_percent,
}
)
df1 = df1.set_index(df.index)
line_test = df1.plot(
ax=ax2, legend=False, color="black", **pandasplot_kwargs
)
# set axis limits to the min and max
ax2.set(xlim=(df.index[0], df.index[-1]), ylim=(0, 100))
# add a legend and a tight plot box
ax2.set_title("Fractional Cover, Wetness, and Water")
# Initialise annotation objects to be updated during animation
t = ax1.annotate("", **annotation_kwargs)
#########################
# Add optional overlays #
#########################
# Optionally add shapefile overlay(s) from either string path or list of string paths
if isinstance(shapefile_path, str):
shapefile = gpd.read_file(shapefile_path)
shapefile.plot(**shapefile_kwargs, ax=ax1)
elif isinstance(shapefile_path, list):
# Iterate through list of string paths
for shapefile in shapefile_path:
shapefile = gpd.read_file(shapefile)
shapefile.plot(**shapefile_kwargs, ax=ax1)
# After adding shapefile, fix extents of plot
ax1.set_xlim(extents[0], extents[1])
ax1.set_ylim(extents[2], extents[3])
# Optionally add colourbar for one band images
if (len(bands) == 1) & onebandplot_cbar:
_add_colourbar(
ax1,
im,
tick_fontsize=onebandplot_tick_fontsize,
tick_colour=onebandplot_tick_colour,
vmin=onebandplot_kwargs["vmin"],
vmax=onebandplot_kwargs["vmax"],
)
########################################
# Create function to update each frame #
########################################
# Function to update figure
def update_figure(frame_i):
####################
# Plot image panel #
####################
# If possible, extract dates from time dimension
try:
# Get human-readable date info (e.g. "16 May 1990")
ts = ds[time_dim][{time_dim: frame_i}].dt
year = ts.year.item()
month = ts.month.item()
day = ts.day.item()
date_string = "{} {} {}".format(
day, calendar.month_abbr[month], year
)
except:
date_string = ds[time_dim][{time_dim: frame_i}].values.item()
# Create annotation string based on title and date specifications:
title = title_list[frame_i]
if title and show_date:
title_date = "{}\n{}".format(date_string, title)
elif title and not show_date:
title_date = "{}".format(title)
elif show_date and not title:
title_date = "{}".format(date_string)
else:
title_date = ""
# Update left panel with annotation and image
im.set_array(imagelist[frame_i])
t.set_text(title_date)
########################
# Plot linegraph panel #
########################
# Create list of artists to return
artist_list = [im, t]
# Update right panel with temporal line subset, adding each new line into artist_list
for i, line in enumerate(line_test.lines):
# Clip line data to current time, and get x and y values
y = df1[
df1.index
<= datetime(year=year, month=month, day=day, hour=23, minute=59)
].iloc[:, i]
x = df1[
df1.index
<= datetime(year=year, month=month, day=day, hour=23, minute=59)
].index
# Plot lines after stripping NaNs (this produces continuous, unbroken lines)
line.set_data(x[y.notnull()], y[y.notnull()])
artist_list.extend([line])
# Return the artists set
return artist_list
# Nicely space subplots
fig.tight_layout()
##############################
# Generate and run animation #
##############################
# Generate animation
ani = animation.FuncAnimation(
fig=fig,
func=update_figure,
frames=timesteps,
interval=interval,
blit=True,
)
# Export as either MP4 or GIF
if output_path[-3:] == "mp4":
print(" Exporting animation to {}".format(output_path))
ani.save(output_path, dpi=width_pixels / 10.0)
elif output_path[-3:] == "wmv":
print(" Exporting animation to {}".format(output_path))
ani.save(
output_path,
dpi=width_pixels / 10.0,
writer=animation.FFMpegFileWriter(
fps=1000 / interval, bitrate=4000, codec="wmv2"
),
)
elif output_path[-3:] == "gif":
print(" Exporting animation to {}".format(output_path))
ani.save(output_path, dpi=width_pixels / 10.0, writer="imagemagick")
else:
print(" Output file type must be either .mp4, .wmv or .gif")
else:
print(
"Please select either one or three bands that all exist in the input dataset"
)
else:
print(
"At least one x, y or time dimension does not exist in the input dataset. Please use the `time_dim`,"
"`x_dim` or `y_dim` parameters to override the default dimension names used for plotting"
)
# Define function to convert xarray dataset to list of one or three band numpy arrays
def _ds_to_arrraylist(
ds, bands, time_dim, x_dim, y_dim, percentile_stretch, image_proc_func=None
):
"""
Converts an xarray dataset to a list of numpy arrays for plt.imshow plotting
"""
# Compute percents
p_low, p_high = ds[bands].to_array().quantile(percentile_stretch).values
array_list = []
for i, timestep in enumerate(ds[time_dim]):
# Select single timestep from the data array
ds_i = ds[{time_dim: i}]
# Get shape of array
x = len(ds[x_dim])
y = len(ds[y_dim])
if len(bands) == 1:
# Create new one band array
img_toshow = exposure.rescale_intensity(
ds_i[bands[0]].values, in_range=(p_low, p_high), out_range="image"
)
else:
# Create new three band array
rawimg = np.zeros((y, x, 3), dtype=np.float32)
# Add xarray bands into three dimensional numpy array
for band, colour in enumerate(bands):
rawimg[:, :, band] = ds_i[colour].values
# Stretch contrast using percentile values
img_toshow = exposure.rescale_intensity(
rawimg, in_range=(p_low, p_high), out_range=(0, 1.0)
)
# Optionally image processing
if image_proc_func:
img_toshow = image_proc_func(img_toshow).clip(0, 1)
array_list.append(img_toshow)
return array_list, p_low, p_high
def _add_colourbar(
ax, im, vmin, vmax, cmap="Greys", tick_fontsize=15, tick_colour="black"
):
"""
Add a nicely formatted colourbar to an animation panel
"""
# Add colourbar
axins2 = inset_axes(ax, width="97%", height="4%", loc=8, borderpad=1)
plt.gcf().colorbar(
im, cax=axins2, orientation="horizontal", ticks=np.linspace(vmin, vmax, 3)
)
axins2.xaxis.set_ticks_position("top")
axins2.tick_params(axis="x", colors=tick_colour, labelsize=tick_fontsize)
# Justify left and right labels to edge of plot
axins2.get_xticklabels()[0].set_horizontalalignment("left")
axins2.get_xticklabels()[-1].set_horizontalalignment("right")
labels = [item.get_text() for item in axins2.get_xticklabels()]
labels[0] = " " + labels[0]
labels[-1] = labels[-1] + " "
if __name__ == "__main__":
# print that we are running the testing
print("Testing..")
# import doctest to test our module for documentation
import doctest
doctest.testmod()
print("Testing done")