File size: 17,962 Bytes
46719a3
 
 
 
 
 
 
 
 
 
 
a1ed477
c66588a
e38ccc6
 
61d57e7
1777b8d
44aadb9
46719a3
 
 
 
487950f
c0206c3
 
 
 
2276dbd
 
c0206c3
46719a3
775b8c3
2276dbd
46719a3
a1ed477
46719a3
 
 
53144e5
46719a3
 
 
882e722
46719a3
 
 
 
 
 
 
 
 
 
a1ed477
46719a3
 
882e722
46719a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487950f
46719a3
487950f
 
 
 
 
 
 
 
 
 
 
 
 
46719a3
44aadb9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e4278b
7324e32
 
7cc54d0
7324e32
8e4278b
 
 
 
 
 
 
 
 
44aadb9
 
 
8e4278b
46719a3
487950f
46719a3
44aadb9
487950f
46719a3
44aadb9
487950f
44aadb9
487950f
 
 
 
7324e32
487950f
46719a3
487950f
 
 
 
 
 
 
 
 
 
 
 
ec19192
487950f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46719a3
 
30cf8c5
46719a3
ec19192
487950f
 
44aadb9
 
 
 
 
 
 
 
487950f
 
 
 
 
 
 
 
a1ed477
ec19192
 
 
 
46719a3
ec19192
 
a1ed477
ec19192
46719a3
ec19192
 
 
46719a3
ec19192
46719a3
 
 
 
 
 
 
 
 
 
 
 
 
493782c
46719a3
 
 
 
 
 
 
b786efe
46719a3
b786efe
 
7d21b47
9a0e806
dbffabb
 
46719a3
dbffabb
 
46719a3
7324e32
8e4278b
1777b8d
7324e32
0faf3a8
44aadb9
0faf3a8
 
44aadb9
7324e32
 
1777b8d
487950f
 
44aadb9
7324e32
1777b8d
 
 
 
8e4278b
1777b8d
44aadb9
8e4278b
1777b8d
44aadb9
 
 
 
 
03d4ea9
1777b8d
 
44aadb9
487950f
 
 
 
1777b8d
44aadb9
487950f
44aadb9
 
1777b8d
487950f
0faf3a8
1777b8d
44aadb9
0faf3a8
1777b8d
 
44aadb9
1777b8d
0faf3a8
44aadb9
 
 
 
01a7e0d
7638c84
44aadb9
 
 
487950f
44aadb9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487950f
44aadb9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30cf8c5
44aadb9
 
 
 
46719a3
44aadb9
46719a3
 
 
abbf4aa
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
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()