import os import ee import geemap import json import geopandas as gpd import streamlit as st import pandas as pd import geojson from shapely.geometry import Polygon, MultiPolygon, shape, Point from io import BytesIO import fiona from shapely import wkb from shapely.ops import transform import geemap.foliumap as geemapfolium from streamlit_folium import st_folium from datetime import datetime import numpy as np import branca.colormap as cm # Enable fiona driver fiona.drvsupport.supported_drivers['LIBKML'] = 'rw' # Intialize EE library # Access secret earthengine_credentials = os.environ.get("EE_Authentication") # Initialize Earth Engine with the secret credentials os.makedirs(os.path.expanduser("~/.config/earthengine/"), exist_ok=True) with open(os.path.expanduser("~/.config/earthengine/credentials"), "w") as f: f.write(earthengine_credentials) ee.Initialize(project='in793-aq-nb-24330048') # Functions def convert_to_2d_geometry(geom): if geom is None: return None elif geom.has_z: return transform(lambda x, y, z: (x, y), geom) else: return geom def validate_KML_file(gdf): if gdf.empty: return { 'corner_points': None, 'area': None, 'perimeter': None, 'is_single_polygon': False} polygon_info = {} # Check if it's a single polygon or multipolygon if isinstance(gdf.iloc[0].geometry, Polygon) and len(gdf)==1: polygon_info['is_single_polygon'] = True polygon = convert_to_2d_geometry(gdf.iloc[0].geometry) # Calculate corner points in GCS projection polygon_info['corner_points'] = [ (polygon.bounds[0], polygon.bounds[1]), (polygon.bounds[2], polygon.bounds[1]), (polygon.bounds[2], polygon.bounds[3]), (polygon.bounds[0], polygon.bounds[3]) ] # Calculate Centroids in GCS projection polygon_info['centroid'] = polygon.centroid.coords[0] # Calculate area and perimeter in EPSG:7761 projection # It is a local projection defined for Gujarat as per NNRMS polygon = gdf.to_crs(epsg=7761).geometry.iloc[0] polygon_info['area'] = polygon.area polygon_info['perimeter'] = polygon.length else: polygon_info['is_single_polygon'] = False polygon_info['corner_points'] = None polygon_info['area'] = None polygon_info['perimeter'] = None polygon_info['centroid'] = None ValueError("Input must be a single Polygon.") return polygon_info # Function to compute zonal NDVI and add it as a property to the image def reduce_zonal_ndvi(image, ee_object): # Compute NDVI using Sentinel-2 bands (B8 - NIR, B4 - Red) ndvi = image.normalizedDifference(['B8', 'B4']).rename('NDVI') # Reduce the region to get the mean NDVI value for the given geometry reduced = ndvi.reduceRegion( reducer=ee.Reducer.mean(), geometry=ee_object.geometry(), scale=10, maxPixels=1e12 ) # Set the reduced NDVI mean as a property on the image return ndvi.set('NDVI_mean', reduced.get('NDVI')) # Function to compute cloud probability and add it as a property to the image def reduce_zonal_cloud_probability(image, ee_object): # Compute cloud probability using the SCL band (Scene Classification Layer) in Sentinel-2 cloud_probability = image.select('MSK_CLDPRB').rename('cloud_probability') # Reduce the region to get the mean cloud probability value for the given geometry reduced = cloud_probability.reduceRegion( reducer=ee.Reducer.mean(), geometry=ee_object.geometry(), scale=10, maxPixels=1e12 ) # Set the reduced cloud probability mean as a property on the image return image.set('cloud_probability_mean', reduced.get('cloud_probability')) # Calculate NDVI def calculate_NDVI(image): ndvi = image.normalizedDifference(['B8', 'B4']).rename('NDVI') return ndvi # Get Zonal NDVI for Year on Year Profile def get_zonal_ndviYoY(collection, ee_object): ndvi_collection = collection.map(calculate_NDVI) max_ndvi = ndvi_collection.max() reduced_max_ndvi = max_ndvi.reduceRegion( reducer=ee.Reducer.mean(), geometry=ee_object.geometry(), scale=10, maxPixels=1e12) return reduced_max_ndvi.get('NDVI').getInfo(), max_ndvi # Get Zonal NDVI def get_zonal_ndvi(collection, geom_ee_object, return_ndvi=True): reduced_collection = collection.map(lambda image: reduce_zonal_ndvi(image, ee_object=geom_ee_object)) cloud_prob_collection = collection.map(lambda image: reduce_zonal_cloud_probability(image, ee_object=geom_ee_object)) stats_list = reduced_collection.aggregate_array('NDVI_mean').getInfo() filenames = reduced_collection.aggregate_array('system:index').getInfo() cloud_probabilities = cloud_prob_collection.aggregate_array('cloud_probability_mean').getInfo() dates = [f.split("_")[0].split('T')[0] for f in filenames] df = pd.DataFrame({'NDVI': stats_list, 'Dates': dates, 'Imagery': filenames, 'Id': filenames, 'CLDPRB': cloud_probabilities}) if return_ndvi==True: return df, reduced_collection else: return df # Apply custom CSS for a visually appealing layout st.markdown(""" """, unsafe_allow_html=True) st.title("Zonal Average NDVI Trend Analyser") input_container = st.container() # Function to create dropdowns for date input def date_selector(label): day_options = list(range(1, 32)) if label=='start': day = st.selectbox(f"Select {label} day", day_options, key=f"{label}_day", index=0) else: day = st.selectbox(f"Select {label} day", day_options, key=f"{label}_day", index=14) month_options = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] month = st.selectbox(f"Select {label} month", month_options, key=f"{label}_month",index=11) month = datetime.strptime(month, "%B").month try: # Try to create a date datetime(year=2024, month=month, day=day) # Using a leap year for completeness return (day, month) except ValueError: st.write("Invalid date and month !") st.stop() with input_container.form(key='input_form'): # Create date selectors for start date and end date (start_day, start_month), start_year = date_selector("start"), datetime.now().year (end_day, end_month), end_year = date_selector("end"), datetime.now().year start_date = datetime(day=start_day, month=start_month, year=start_year) end_date = datetime(day=end_day, month=end_month, year=end_year) max_cloud_cover = st.number_input("Max Cloud Cover", value=20) # Get the geojson file from the user uploaded_file = st.file_uploader("Upload KML/GeoJSON file", type=["geojson", "kml"]) submit_button = st.form_submit_button(label='Submit') if uploaded_file is not None and submit_button: try: if uploaded_file.name.endswith("kml"): gdf = gpd.read_file(BytesIO(uploaded_file.read()), driver='LIBKML') elif uploaded_file.name.endswith("geojson"): gdf = gpd.read_file(uploaded_file) except Exception as e: st.write('ValueError: "Input must be a valid KML file."') st.stop() # Validate KML File polygon_info = validate_KML_file(gdf) if polygon_info["is_single_polygon"]==True: st.write("Uploaded KML file has single polygon geometry.") st.write("It has bounds as {0:.6f}, {1:.6f}, {2:.6f}, and {3:.6f}.".format( polygon_info['corner_points'][0][0], polygon_info['corner_points'][0][1], polygon_info['corner_points'][2][0], polygon_info['corner_points'][2][1] )) st.write("It has centroid at ({0:.6f}, {1:.6f}).".format(polygon_info['centroid'][0], polygon_info['centroid'][1])) st.write("It has area of {:.2f} ha.".format(polygon_info['area']/10000)) st.write("It has perimeter of {:.2f} meters.".format(polygon_info['perimeter'])) #Change geometry of polygon 3D to 2D for ee gdf.loc[0, "geometry"] = convert_to_2d_geometry(gdf.iloc[0].geometry) #Read KML file geom_ee_object = ee.FeatureCollection(json.loads(gdf.to_json())) # Add buffer of 100m to ee_object buffered_ee_object = geom_ee_object.map(lambda feature: feature.buffer(100)) ####### YoY Profile ######## start_year = 2019 end_year = datetime.now().year # Create an empty resultant dataframe columns = ['Dates', 'Imagery', 'AvgNDVI_Inside', 'CLDPRB', 'Avg_NDVI_Buffer', 'CLDPRB_Buffer', 'Ratio', 'Id' ] combined_df = pd.DataFrame(columns=columns) # Create empty lists of parameters max_ndvi_geoms = [] max_ndvi_buffered_geoms = [] years=[] ndvi_collections = [] df_geoms = [] max_ndvis = [] for year in range(start_year, end_year+1): try: # Construct start and end dates for every year start_ddmm = str(year)+pd.to_datetime(start_date).strftime("-%m-%d") end_ddmm = str(year)+pd.to_datetime(end_date).strftime("-%m-%d") # Filter data based on the date, bounds, cloud coverage and select NIR and Red Band collection = ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED").filter(ee.Filter.lt('CLOUDY_PIXEL_PERCENTAGE', max_cloud_cover)).filter(ee.Filter.date(start_ddmm, end_ddmm)) # Get Zonal Max composite NDVI based on collection and geometries (Original KML and Buffered KML) max_ndvi_geom, max_ndvi = get_zonal_ndviYoY(collection.filterBounds(geom_ee_object), geom_ee_object) # max_NDVI is image common to both max_ndvi_geoms.append(max_ndvi_geom) max_ndvi_geom, max_ndvi = get_zonal_ndviYoY(collection.filterBounds(buffered_ee_object), buffered_ee_object) max_ndvi_buffered_geoms.append(max_ndvi_geom) max_ndvis.append(max_ndvi) years.append(str(year)) # Get Zonal NDVI df_geom, ndvi_collection = get_zonal_ndvi(collection.filterBounds(geom_ee_object), geom_ee_object) # ndvi collection is common to both df_buffered_geom, ndvi_collection = get_zonal_ndvi(collection.filterBounds(buffered_ee_object), buffered_ee_object) ndvi_collections.append(ndvi_collection) df_geoms.append(df_geom) # Merge both Zonalstats on ID and create resultant dataframe resultant_df = pd.merge(df_geom, df_buffered_geom, on='Id', how='inner') resultant_df = resultant_df.rename(columns={'NDVI_x': 'AvgNDVI_Inside', 'NDVI_y': 'Avg_NDVI_Buffer', 'Imagery_x': 'Imagery', 'Dates_x': 'Dates', 'CLDPRB_x': 'CLDPRB', 'CLDPRB_y': 'CLDPRB_Buffer'}) resultant_df['Ratio'] = resultant_df['AvgNDVI_Inside'] / resultant_df['Avg_NDVI_Buffer'] resultant_df.drop(columns=['Imagery_y', 'Dates_y'], inplace=True) # Re-order the columns of the resultant dataframe resultant_df = resultant_df[columns] # Append to empty dataframe combined_df = pd.concat([combined_df, resultant_df], ignore_index=True) except Exception as e: continue if len(combined_df)>1: # Write the final table st.write("NDVI details based on Sentinel-2 Surface Reflectance Bands") st.write(combined_df[columns[:-1]]) # Plot the multiyear timeseries st.write("Multiyear Time Series Plot (for given duration)") st.line_chart(combined_df[['AvgNDVI_Inside', 'Avg_NDVI_Buffer', 'Dates']].set_index('Dates')) # Create a DataFrame for YoY profile yoy_df = pd.DataFrame({'Year': years, 'NDVI_Inside': max_ndvi_geoms, 'NDVI_Buffer': max_ndvi_buffered_geoms}) yoy_df['Ratio'] = yoy_df['NDVI_Inside'] / yoy_df['NDVI_Buffer'] slope, intercept = np.polyfit(list(range(1, len(years)+1)), yoy_df['NDVI_Inside'], 1) # plot the time series st.write("Year on Year Profile using Maximum NDVI Composite (computed for given duration)") st.line_chart(yoy_df[['NDVI_Inside', 'NDVI_Buffer', 'Ratio', 'Year']].set_index('Year')) st.write("Slope (trend) and Intercept are {}, {} respectively. ".format(np.round(slope, 4), np.round(intercept, 4))) #Get Latest NDVI Collection with completeness ndvi_collection = None for i in range(len(ndvi_collections)): #Check size of NDVI collection ndvi_collection = ndvi_collections[len(ndvi_collections)-i-1] df_geom = df_geoms[len(ndvi_collections)-i-1] if ndvi_collection.size().getInfo()>0: break #Map Visualization st.write("Map Visualization") # Function to create the map def create_map(): m = geemapfolium.Map(center=(polygon_info['centroid'][1],polygon_info['centroid'][0]), zoom=14) # Create a Folium map vis_params = {'min': -1, 'max': 1, 'palette': ['blue', 'white', 'green']} # Example visualization for Sentinel-2 # Create a colormap and name it as NDVI colormap = cm.LinearColormap( colors=vis_params['palette'], vmin=vis_params['min'], vmax=vis_params['max'] ) colormap.caption = 'NDVI' n_layers = 4 #controls the number of images to be displayed for i in range(min(n_layers, ndvi_collection.size().getInfo())): ndvi_image = ee.Image(ndvi_collection.toList(ndvi_collection.size()).get(i)) date = df_geom.iloc[i]["Dates"] # Add the image to the map as a layer layer_name = f"Sentinel-2 NDVI - {date}" m.add_layer(ndvi_image, vis_params, layer_name, z_index=i+10, opacity=0.5) for i in range(len(max_ndvis)): layer_name = f"Sentinel-2 MaxNDVI-{years[i]}" m.add_layer(max_ndvis[i], vis_params, layer_name, z_index=i+20, opacity=0.5) # Add the colormap to the map m.add_child(colormap) geom_vis_params = {'color': '000000', 'pointSize': 3,'pointShape': 'circle','width': 2,'lineType': 'solid','fillColor': '00000000'} buffer_vis_params = {'color': 'FF0000', 'pointSize': 3,'pointShape': 'circle','width': 2,'lineType': 'solid','fillColor': '00000000'} m.add_layer(geom_ee_object.style(**geom_vis_params), {}, 'KML Original', z_index=1, opacity=1) m.add_layer(buffered_ee_object.style(**buffer_vis_params), {}, 'KML Buffered', z_index=2, opacity=1) m.add_layer_control() return m # Create Folium Map object and store it in streamlit session if "map" not in st.session_state or submit_button: st.session_state["map"] = create_map() # Display the map and allow interactions without triggering reruns with st.container(): st_folium(st.session_state["map"], width=725, returned_objects=[]) st.stop() else: # Failed to find any Sentinel-2 Image in given period st.write("No Sentinel-2 Imagery found for the given period.") st.stop() else: # Failed to have single polygon geometry st.write('ValueError: "Input must have single polygon geometry"') st.write(gdf) st.stop() # Cut the "infinite" html page to the content st.stop()