UjjwalKGupta's picture
Update app.py
30cf8c5 verified
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()