Spaces:
Sleeping
Sleeping
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(""" | |
<style> | |
/* General body styling */ | |
body { | |
background-color: #f9f9f9; /* Light gray background */ | |
color: #333; /* Dark text color */ | |
font-family: 'Arial', sans-serif; /* Clean font */ | |
line-height: 1.6; /* Improved line spacing */ | |
} | |
/* Center title */ | |
h1 { | |
text-align: center; | |
color: #FFFFFF; /* Darker blue for headings */ | |
margin-bottom: 20px; /* Spacing below heading */ | |
} | |
/* Subheading styling */ | |
h2, h3 { | |
color: #2980b9; /* Blue color for subheadings */ | |
} | |
/* Paragraph and list styling */ | |
p, li { | |
margin-bottom: 15px; /* Spacing between paragraphs and list items */ | |
} | |
/* Link styling */ | |
a { | |
color: #2980b9; /* Blue links */ | |
text-decoration: none; /* Remove underline */ | |
} | |
a:hover { | |
text-decoration: underline; /* Underline on hover */ | |
} | |
/* Button styling */ | |
.stButton { | |
background-color: #2980b9; /* Blue button */ | |
color: white; /* White text */ | |
border: none; /* No border */ | |
border-radius: 5px; /* Rounded corners */ | |
padding: 10px 20px; /* Padding */ | |
cursor: pointer; /* Pointer cursor */ | |
} | |
.stButton:hover { | |
background-color: #1c6690; /* Darker blue on hover */ | |
} | |
/* Adjust layout for printing */ | |
@media print { | |
body { | |
background-color: white; /* White background for printing */ | |
color: black; /* Black text for printing */ | |
} | |
h1, h2, h3 { | |
color: black; /* Black headings for printing */ | |
} | |
.stButton { | |
display: none; /* Hide buttons on print */ | |
} | |
} | |
</style> | |
""", 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() |