first commit

This commit is contained in:
nghiadang
2024-08-28 05:10:02 +00:00
commit 7ce3fe722b
57 changed files with 14844 additions and 0 deletions
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