diff --git a/tethysapp/earth_engine/controllers.py b/tethysapp/earth_engine/controllers.py index 6d29352..4bbef10 100644 --- a/tethysapp/earth_engine/controllers.py +++ b/tethysapp/earth_engine/controllers.py @@ -4,6 +4,7 @@ import logging import datetime as dt import geojson +import ee import shapefile from django.http import JsonResponse, HttpResponseNotAllowed, HttpResponseRedirect from django.shortcuts import render @@ -12,7 +13,8 @@ from tethys_sdk.permissions import login_required from tethys_sdk.workspaces import user_workspace from .helpers import generate_figure, find_shapefile, write_boundary_shapefile, prep_boundary_dir -from .gee.methods import get_image_collection_asset, get_time_series_from_image_collection +from .gee.methods import get_image_collection_asset, get_time_series_from_image_collection, upload_shapefile_to_gee, \ + get_boundary_fc_props_for_user from .gee.products import EE_PRODUCTS log = logging.getLogger(f'tethys.apps.{__name__}') @@ -162,6 +164,9 @@ def viewer(request, user_workspace): attributes={'id': 'load_plot'} ) + # Get bounding box from user boundary if it exists + boundary_props = get_boundary_fc_props_for_user(request.user) + map_view = MapView( height='100%', width='100%', @@ -169,7 +174,7 @@ def viewer(request, user_workspace): 'ZoomSlider', 'Rotate', 'FullScreen', {'ZoomToExtent': { 'projection': 'EPSG:4326', - 'extent': [29.25, -4.75, 46.25, 5.2] + 'extent': boundary_props.get('bbox', [-180, -90, 180, 90]) # Default to World }} ], basemap=[ @@ -181,8 +186,8 @@ def viewer(request, user_workspace): ], view=MVView( projection='EPSG:4326', - center=[37.880859, 0.219726], - zoom=7, + center=boundary_props.get('centroid', [0, 0]), # Default to World + zoom=boundary_props.get('zoom', 3), # Default to World maxZoom=18, minZoom=2 ), @@ -254,6 +259,7 @@ def get_image_collection(request): reducer = request.POST.get('reducer', None) url = get_image_collection_asset( + request=request, platform=platform, sensor=sensor, product=product, @@ -397,5 +403,13 @@ def handle_shapefile_upload(request, user_workspace): # Write the shapefile to the workspace directory write_boundary_shapefile(shp_file, workspace_dir) + # Upload shapefile as Asset in GEE + upload_shapefile_to_gee(request.user, shp_file) + except TypeError: return 'Incomplete or corrupted shapefile provided.' + + except ee.EEException: + msg = 'An unexpected error occurred while uploading the shapefile to Google Earth Engine.' + log.exception(msg) + return msg diff --git a/tethysapp/earth_engine/gee/methods.py b/tethysapp/earth_engine/gee/methods.py index 99d4fa0..a9a8ac6 100644 --- a/tethysapp/earth_engine/gee/methods.py +++ b/tethysapp/earth_engine/gee/methods.py @@ -1,3 +1,5 @@ +import os +import math import logging import ee from ee.ee_exception import EEException @@ -44,7 +46,7 @@ def image_to_map_id(image_name, vis_params={}): log.exception('An error occurred while attempting to retrieve the map id.') -def get_image_collection_asset(platform, sensor, product, date_from=None, date_to=None, reducer='median'): +def get_image_collection_asset(request, platform, sensor, product, date_from=None, date_to=None, reducer='median'): """ Get tile url for image collection asset. """ @@ -77,6 +79,12 @@ def get_image_collection_asset(platform, sensor, product, date_from=None, date_t if reducer: ee_collection = getattr(ee_collection, reducer)() + # Attempt to clip the image by the boundary provided by the user + clip_features = get_boundary_fc_for_user(request.user) + + if clip_features: + ee_collection = ee_collection.clipToCollection(clip_features) + tile_url = image_to_map_id(ee_collection, vis_params) return tile_url @@ -145,3 +153,175 @@ def get_index(image): log.debug(f'Time Series: {time_series}') return time_series + + +def upload_shapefile_to_gee(user, shp_file): + """ + Upload a shapefile to Google Earth Engine as an asset. + + Args: + user (django.contrib.auth.User): the request user. + shp_file (shapefile.Reader): A shapefile reader object. + """ + features = [] + fields = shp_file.fields[1:] + field_names = [field[0] for field in fields] + + # Convert Shapefile to ee.Features + for record in shp_file.shapeRecords(): + # First convert to geojson + attributes = dict(zip(field_names, record.record)) + geojson_geom = record.shape.__geo_interface__ + geojson_feature = { + 'type': 'Feature', + 'geometry': geojson_geom, + 'properties': attributes + } + + # Create ee.Feature from geojson (this is the Upload, b/c ee.Feature is a server object) + features.append(ee.Feature(geojson_feature)) + + feature_collection = ee.FeatureCollection(features) + + # Get unique folder for each user to story boundary asset + user_boundary_asset_path = get_user_boundary_path(user) + + # Overwrite an existing asset with this name by deleting it first + try: + ee.batch.data.deleteAsset(user_boundary_asset_path) + except EEException as e: + # Nothing to delete, so pass + if 'Asset not found' not in str(e): + log.exception('Encountered an unhandled EEException.') + raise e + + # Export ee.Feature to ee.Asset + task = ee.batch.Export.table.toAsset( + collection=feature_collection, + description='uploadToTableAsset', + assetId=user_boundary_asset_path + ) + + task.start() + + +def get_asset_dir_for_user(user): + """ + Get a unique asset directory for given user. + + Args: + user (django.contrib.auth.User): the request user. + + Returns: + str: asset directory path for given user. + """ + asset_roots = ee.batch.data.getAssetRoots() + + if len(asset_roots) < 1: + # Initialize the asset root directory if one doesn't exist already + ee.batch.data.createAssetHome('users/earth_engine_app') + + asset_root_dir = asset_roots[0]['id'] + earth_engine_root_dir = os.path.join(asset_root_dir, 'earth_engine_app') + user_root_dir = os.path.join(earth_engine_root_dir, user.username) + + # Create earth engine directory, will raise exception if it already exists + try: + ee.batch.data.createAsset({ + 'type': 'Folder', + 'name': earth_engine_root_dir + }) + except EEException as e: + if 'Cannot overwrite asset' not in str(e): + raise e + + # Create user directory, will raise exception if it already exists + try: + ee.batch.data.createAsset({ + 'type': 'Folder', + 'name': user_root_dir + }) + except EEException as e: + if 'Cannot overwrite asset' not in str(e): + raise e + + return user_root_dir + + +def get_user_boundary_path(user): + """ + Get a unique path for the user boundary asset. + + Args: + user (django.contrib.auth.User): the request user. + + Returns: + str: the unique path for the user boundary asset. + """ + user_asset_dir = get_asset_dir_for_user(user) + user_boundary_asset_path = os.path.join(user_asset_dir, 'boundary') + return user_boundary_asset_path + + +def get_boundary_fc_for_user(user): + """ + Get the boundary FeatureClass for the given user if it exists. + + Args: + user (django.contrib.auth.User): the request user. + + Returns: + ee.FeatureCollection: boundary feature collection or None + """ + try: + boundary_path = get_user_boundary_path(user) + # If no boundary exists for the user, an exception occur when calling this and clipping will skipped + ee.batch.data.getAsset(boundary_path) + # Add the clip option + fc = ee.FeatureCollection(boundary_path) + return fc + except EEException: + pass + + return None + + +def get_boundary_fc_props_for_user(user): + """ + Get various properties of the boundary FeatureCollection. + Args: + user (django.contrib.auth.User): Get the properties of the boundary uploaded by this user. + + Returns: + dict: Dictionary containing the centroid and bounding box of the boundary and the approximate OpenLayers zoom level to frame the boundary around the centroid. Empty dictionary if no boundary FeatureCollection is found for the given user. + """ + fc = get_boundary_fc_for_user(user) + + if not fc: + return dict() + + # Compute bounding box + bounding_rect = fc.geometry().bounds().getInfo() + bounding_coords = bounding_rect.get('coordinates')[0] + + # Derive bounding box from two corners of the bounding rectangle + bbox = [bounding_coords[0][0], bounding_coords[0][1], bounding_coords[2][0], bounding_coords[2][1]] + + # Get centroid + centroid = fc.geometry().centroid().getInfo() + + # Compute length diagonal of bbox for zoom calculation + diag = math.sqrt((bbox[0] - bbox[2])**2 + (bbox[1] - bbox[3])**2) + + # Found the diagonal length and zoom level for US and Kenya boundaries + # Used equation of a line to develop the relationship between zoom and diagonal of bounding box + zoom = round((-0.0701 * diag) + 8.34, 0) + + # The returned ee.FeatureClass properties + fc_props = { + 'zoom': zoom, + 'bbox': bbox, + 'centroid': centroid.get('coordinates') + } + + return fc_props