Files
remote-sensing/deafrica_tools/app/crophealth.py
T

338 lines
10 KiB
Python
Executable File

# 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)