SustainabilityLabIITGN/NDVI_PERG_API
0
1import os2from datetime import datetime3import ee4import json5import numpy as np6import geemap.foliumap as gee_folium7import leafmap.foliumap as leaf_folium8import gradio as gr9import pandas as pd10import geopandas as gpd11import plotly.express as px12import branca.colormap as cm13from shapely.ops import transform14import pyproj15from io import BytesIO16import requests17import kml2geojson18import folium19import xml.etree.ElementTree as ET20from fastapi import FastAPI, HTTPException, Response21from urllib.parse import unquote22from pydantic import BaseModel, HttpUrl23 24 25app = FastAPI()26 27# --- Helper Functions ---28 29def one_time_setup():30 """Initializes the Earth Engine API."""31 try:32 # Attempt to initialize with default credentials33 ee.Initialize()34 except Exception:35 try:36 # Fallback to service account credentials if default init fails37 credentials_path = os.path.expanduser("~/.config/earthengine/credentials.json")38 ee_credentials = os.environ.get("EE")39 if ee_credentials:40 os.makedirs(os.path.dirname(credentials_path), exist_ok=True)41 with open(credentials_path, "w") as f:42 f.write(ee_credentials)43 credentials = ee.ServiceAccountCredentials('ujjwal@ee-ujjwaliitd.iam.gserviceaccount.com', credentials_path)44 ee.Initialize(credentials, project='ee-ujjwaliitd')45 except Exception as inner_e:46 # If the fallback also fails, print the error47 print(f"Earth Engine initialization failed: {inner_e}")48 49 50def _process_spatial_data(data_bytes: BytesIO) -> gpd.GeoDataFrame:51 """Core function to process bytes of a KML or GeoJSON file."""52 # Read the first few bytes to determine file type without consuming the stream53 start_of_file = data_bytes.read(100)54 data_bytes.seek(0) # Reset stream position55 56 # Check if the file is KML (XML-based)57 if start_of_file.strip().lower().startswith(b'<?xml'):58 try:59 geojson_data = kml2geojson.convert(data_bytes)60 if not geojson_data or not geojson_data[0].get("features"):61 raise ValueError("KML file is empty or has no features.")62 features = geojson_data[0]["features"]63 input_gdf = gpd.GeoDataFrame.from_features(features, crs="EPSG:4326")64 except Exception as e:65 raise ValueError(f"Failed to process KML data: {e}")66 # Otherwise, assume it's a format geopandas can read from databytes67 else:68 try:69 geojson_str = data_bytes.read().decode('utf-8')70 input_gdf = gpd.read_file(geojson_str)71 except Exception as e:72 raise ValueError(f"Failed to read GeoJSON or other vector data: {e}")73 return input_gdf74 75def get_gdf_from_file(file_obj):76 """Reads a KML or GeoJSON file from a Gradio file object and returns a GeoDataFrame."""77 if file_obj is None:78 return None79 with open(file_obj.name, 'rb') as f:80 data_bytes = BytesIO(f.read())81 return _process_spatial_data(data_bytes)82 83def get_gdf_from_url(url: str) -> gpd.GeoDataFrame:84 """Downloads and reads a KML/GeoJSON from a URL."""85 if not url or not url.strip():86 return None87 88 # Handle Google Drive URLs89 if "drive.google.com" in url:90 if "/file/d/" in url:91 file_id = url.split('/d/')[1].split('/')[0]92 elif "open?id=" in url:93 file_id = url.split('open?id=')[1].split('&')[0]94 else:95 raise ValueError("Unsupported Google Drive URL format. Please provide a direct link or a shareable link with 'open?id=' or '/file/d/'.")96 download_url = f"https://drive.google.com/uc?export=download&id={file_id}"97 else:98 download_url = url99 100 try:101 response = requests.get(download_url, timeout=30)102 response.raise_for_status()103 data_bytes = BytesIO(response.content)104 return _process_spatial_data(data_bytes)105 except requests.exceptions.RequestException as e:106 raise ValueError(f"Failed to download file from URL: {e}")107 108 109def find_best_epsg(geometry) -> int:110 """Finds the most suitable EPSG code for a given geometry based on its centroid."""111 if geometry.geom_type == "Polygon":112 centroid = geometry.centroid113 else:114 raise ValueError("Geometry is not a Polygon.")115 116 common_epsg_codes = [117 7761, # Gujarat118 7774, # Rajasthan119 7766, # MadhyaPradesh120 7767, # Maharastra121 7755, # India122 # Add other relevant state/country EPSG codes here123 ]124 125 for epsg in common_epsg_codes:126 try:127 crs = pyproj.CRS.from_epsg(epsg)128 area_of_use = crs.area_of_use.bounds129 if (area_of_use[0] <= centroid.x <= area_of_use[2]) and \130 (area_of_use[1] <= centroid.y <= area_of_use[3]):131 return epsg132 except pyproj.exceptions.CRSError:133 continue134 return 4326 # Default to WGS84 if no suitable projection is found135 136def shape_3d_to_2d(shape):137 """Converts a 3D geometry to 2D."""138 if shape.has_z:139 return transform(lambda x, y, z: (x, y), shape)140 return shape141 142def preprocess_gdf(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:143 """Preprocesses a GeoDataFrame by converting geometries to 2D and fixing invalid ones."""144 gdf["geometry"] = gdf["geometry"].apply(shape_3d_to_2d)145 gdf["geometry"] = gdf.buffer(0)146 return gdf147 148def to_best_crs(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:149 """Converts a GeoDataFrame to the most suitable CRS."""150 if not gdf.empty and gdf["geometry"].iloc[0] is not None:151 best_epsg_code = find_best_epsg(gdf.to_crs(epsg=4326)["geometry"].iloc[0])152 return gdf.to_crs(epsg=best_epsg_code)153 return gdf154 155def is_valid_polygon(geometry_gdf):156 """Checks if the geometry in a GeoDataFrame is a valid, non-empty Polygon."""157 if geometry_gdf.empty:158 return False159 geometry = geometry_gdf.geometry.item()160 return (geometry.type == 'Polygon') and (not geometry.is_empty)161 162def add_geometry_to_map(m, geometry_gdf, buffer_geometry_gdf, opacity=0.3):163 """Adds geometry and its buffer to a folium map."""164 if buffer_geometry_gdf is not None and not buffer_geometry_gdf.empty:165 folium.GeoJson(166 buffer_geometry_gdf.to_crs(epsg=4326),167 name="Geometry Buffer",168 style_function=lambda x: {"color": "red", "fillOpacity": opacity, "fillColor": "red"}169 ).add_to(m)170 if geometry_gdf is not None and not geometry_gdf.empty:171 folium.GeoJson(172 geometry_gdf.to_crs(epsg=4326),173 name="Geometry",174 style_function=lambda x: {"color": "blue", "fillOpacity": opacity, "fillColor": "blue"}175 ).add_to(m)176 return m177 178def get_wayback_data():179 """Fetches and parses Wayback imagery data from ArcGIS."""180 try:181 url = "https://wayback.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/WMTS/1.0.0/WMTSCapabilities.xml"182 response = requests.get(url)183 response.raise_for_status() # Ensure request was successful184 185 # Parse XML186 root = ET.fromstring(response.content)187 188 ns = {189 "wmts": "https://www.opengis.net/wmts/1.0",190 "ows": "https://www.opengis.net/ows/1.1",191 "xlink": "https://www.w3.org/1999/xlink",192 }193 194 # Use a robust XPath to find all 'Layer' elements anywhere in the document.195 # This is less brittle than specifying the full path.196 layers = root.findall(".//wmts:Contents/wmts:Layer", ns)197 198 layer_data = []199 for layer in layers:200 title = layer.find("ows:Title", ns)201 identifier = layer.find("ows:Identifier", ns)202 resource = layer.find("wmts:ResourceURL", ns) # Tile URL template203 204 title_text = title.text if title is not None else "N/A"205 identifier_text = identifier.text if identifier is not None else "N/A"206 url_template = resource.get("template") if resource is not None else "N/A"207 208 layer_data.append({"Title": title_text, "ResourceURL_Template": url_template})209 210 wayback_df = pd.DataFrame(layer_data)211 wayback_df["date"] = pd.to_datetime(wayback_df["Title"].str.extract(r"(\d{4}-\d{2}-\d{2})").squeeze(), errors="coerce")212 wayback_df.set_index("date", inplace=True)213 return wayback_df.sort_index(ascending=False)214 215 except Exception as e:216 print(f"Could not fetch or parse Wayback data: {e}")217 return pd.DataFrame()218 219 220def get_dem_slope_maps(ee_geometry, map_bounds, wayback_url, wayback_title, zoom=12,):221 """Creates DEM and Slope maps from SRTM data, using wayback tiles as a basemap if available."""222 223 print(wayback_url, wayback_title)224 225 # --- DEM Map ---226 dem_map = gee_folium.Map(zoom_start=zoom)227 if wayback_url:228 dem_map.add_tile_layer(url=wayback_url, name=wayback_title, attribution="Esri")229 230 dem_map_html = "<div>No DEM data available for this area.</div>"231 try:232 dem_layer = ee.Image("USGS/SRTMGL1_003").resample("bilinear").reproject(crs="EPSG:4326", scale=30).clip(ee_geometry)233 stats = dem_layer.reduceRegion(reducer=ee.Reducer.minMax(), geometry=ee_geometry, scale=30, maxPixels=1e9).getInfo()234 print(stats)235 236 if stats and stats.get('elevation_min') is not None:237 min_val, max_val = stats['elevation_min'], stats['elevation_max']238 vis_params = {"min": min_val, "max": max_val, "palette": ['#0000FF', '#00FF00', '#FFFF00', '#FF0000']}239 dem_map.addLayer(dem_layer, vis_params, "Elevation")240 dem_map.add_colorbar(vis_params=vis_params, label="Elevation (m)")241 242 dem_map.addLayerControl()243 dem_map.fit_bounds(map_bounds, padding=(10, 10))244 dem_map_html = dem_map._repr_html_()245 246 except Exception as e:247 print(f"Error creating DEM map: {e}")248 dem_map_html = f"<div>Error creating DEM map: {e}</div>"249 250 # ---Slope Map --- #251 slope_map = gee_folium.Map(zoom_start=zoom)252 if wayback_url:253 slope_map.add_tile_layer(url=wayback_url, name=wayback_title, attribution="Esri")254 255 slope_map_html = "<div>No Slope data available for this area.</div>"256 try:257 dem_for_slope = ee.Image("USGS/SRTMGL1_003")258 # Calculate slope. The result is an image with slope values in degrees.259 slope_layer = ee.Terrain.slope(dem_for_slope).clip(ee_geometry)260 261 stats = slope_layer.reduceRegion(reducer=ee.Reducer.minMax(), geometry=ee_geometry, scale=30, maxPixels=1e9).getInfo()262 print(stats)263 264 if stats and stats.get('slope_min') is not None:265 min_val, max_val = stats['slope_min'], stats['slope_max']266 vis_params = {"min": min_val, "max": max_val, "palette": ['#0000FF', '#00FF00', '#FFFF00', '#FF0000']}267 slope_map.addLayer(slope_layer, vis_params, "Slope")268 slope_map.add_colorbar(vis_params=vis_params, label="Slope (degrees)")269 270 slope_map.addLayerControl()271 slope_map.fit_bounds(map_bounds, padding=(10, 10))272 slope_map_html = slope_map._repr_html_()273 274 except Exception as e:275 print(f"Error creating Slope map: {e}")276 slope_map_html = f"<div>Error creating Slope map: {e}</div>"277 278 return dem_map_html, slope_map_html279 280def add_indices(image, nir_band, red_band, blue_band, green_band, swir_band, swir2_band, evi_vars):281 """Calculates and adds multiple vegetation indices to an Earth Engine image."""282 nir = image.select(nir_band).divide(10000)283 red = image.select(red_band).divide(10000)284 blue = image.select(blue_band).divide(10000)285 green = image.select(green_band).divide(10000)286 swir = image.select(swir_band).divide(10000)287 swir2 = image.select(swir2_band).divide(10000)288 289 # Previously existing indices290 ndvi = image.normalizedDifference([nir_band, red_band]).rename('NDVI')291 evi = image.expression(292 'G * ((NIR - RED) / (NIR + C1 * RED - C2 * BLUE + L))', {293 'NIR': nir, 'RED': red, 'BLUE': blue,294 'G': evi_vars['G'], 'C1': evi_vars['C1'], 'C2': evi_vars['C2'], 'L': evi_vars['L']295 }).rename('EVI')296 evi2 = image.expression(297 'G * (NIR - RED) / (NIR + L + C * RED)', {298 'NIR': nir, 'RED': red,299 'G': evi_vars['G'], 'L': evi_vars['L'], 'C': evi_vars['C']300 }).rename('EVI2')301 try:302 table = ee.FeatureCollection('projects/in793-aq-nb-24330048/assets/cleanedVDI').select(303 ["B2", "B4", "B8", "cVDI"], ["Blue", "Red", "NIR", 'cVDI'])304 classifier = ee.Classifier.smileRandomForest(50).train(305 features=table, classProperty='cVDI', inputProperties=['Blue', 'Red', 'NIR'])306 rf = image.classify(classifier).multiply(ee.Number(0.2)).add(ee.Number(0.1)).rename('RandomForest')307 except Exception as e:308 print(f"Random Forest calculation failed: {e}")309 rf = ee.Image.constant(0).rename('RandomForest')310 ci = image.expression(311 '(-3.98 * (BLUE/NIR) + 12.54 * (GREEN/NIR) - 5.49 * (RED/NIR) - 0.19) / ' +312 '(-21.87 * (BLUE/NIR) + 12.4 * (GREEN/NIR) + 19.98 * (RED/NIR) + 1) * 2.29', {313 'NIR': nir, 'RED': red, 'BLUE': blue, 'GREEN': green314 }).clamp(0, 1).rename('CI')315 gujvdi = image.expression(316 '0.5 * (NIR - RED) / (NIR + 6 * RED - 8.25 * BLUE - 0.01)', {317 'NIR': nir, 'RED': red, 'BLUE': blue318 }).rename('GujVDI')319 mndwi = image.normalizedDifference([green_band, swir_band]).rename('MNDWI')320 321 # Newly added indices322 savi = image.expression('(1 + L) * (NIR - RED) / (NIR + RED + L)', {323 'NIR': nir, 'RED': red, 'L': 0.5324 }).rename('SAVI')325 mvi = image.expression('(NIR - (GREEN + SWIR)) / (NIR + (GREEN + SWIR))', {326 'NIR': nir, 'GREEN': green, 'SWIR': swir327 }).rename('MVI')328 nbr = image.normalizedDifference([nir_band, swir2_band]).rename('NBR')329 gci = image.expression('(NIR - GREEN) / GREEN', {330 'NIR': nir, 'GREEN': green331 }).rename('GCI')332 333 return image.addBands([ndvi, evi, evi2, rf, ci, gujvdi, mndwi, savi, mvi, nbr, gci])334 335 336# --- Gradio App Logic ---337 338# Initialize GEE and fetch wayback data once at the start339one_time_setup()340WAYBACK_DF = get_wayback_data()341 342def process_and_display(file_obj, url_str, buffer_m, progress=gr.Progress()):343 """Main function to process the uploaded file or URL and generate initial outputs."""344 if file_obj is None and not (url_str and url_str.strip()):345 return None, "Please upload a file or provide a URL.", None, None, None, None, None346 347 348 progress(0, desc="Reading and processing geometry...")349 try:350 input_gdf = get_gdf_from_file(file_obj) if file_obj is not None else get_gdf_from_url(url_str)351 input_gdf = preprocess_gdf(input_gdf)352 geometry_gdf = next((input_gdf.iloc[[i]] for i in range(len(input_gdf)) if is_valid_polygon(input_gdf.iloc[[i]])), None)353 if geometry_gdf is None:354 return None, "No valid polygon found in the provided file.", None, None, None, None, None355 geometry_gdf = to_best_crs(geometry_gdf)356 outer_geometry_gdf = geometry_gdf.copy()357 outer_geometry_gdf["geometry"] = outer_geometry_gdf["geometry"].buffer(buffer_m)358 buffer_geometry_gdf = gpd.GeoDataFrame(359 geometry=[outer_geometry_gdf.unary_union.difference(geometry_gdf.unary_union)],360 crs=geometry_gdf.crs361 )362 except Exception as e:363 return None, f"Error processing file: {e}", None, None, None, None, None364 365 progress(0.5, desc="Generating maps and stats...")366 m = folium.Map()367 wayback_url = None368 wayback_title = "Esri Satellite"369 if not WAYBACK_DF.empty:370 latest_item = WAYBACK_DF.iloc[0]371 wayback_title = f"Esri Wayback ({latest_item.name.strftime('%Y-%m-%d')})"372 wayback_url = (373 latest_item["ResourceURL_Template"]374 .replace("{TileMatrixSet}", "GoogleMapsCompatible")375 .replace("{TileMatrix}", "{z}")376 .replace("{TileRow}", "{y}")377 .replace("{TileCol}", "{x}")378 )379 folium.TileLayer(tiles=wayback_url, attr="Esri", name=wayback_title).add_to(m)380 381 m = add_geometry_to_map(m, geometry_gdf, buffer_geometry_gdf, opacity=0.3)382 383 bounds = geometry_gdf.to_crs(epsg=4326).total_bounds384 map_bounds = [[bounds[1], bounds[0]], [bounds[3], bounds[2]]]385 m.fit_bounds(map_bounds, padding=(10, 10))386 folium.LayerControl().add_to(m)387 388 ee_geometry = ee.Geometry(json.loads(geometry_gdf.to_crs(4326).to_json())['features'][0]['geometry'])389 dem_html, slope_html = get_dem_slope_maps(ee_geometry, map_bounds, wayback_url=wayback_url, wayback_title=wayback_title)390 391 stats_df = pd.DataFrame({392 "Area (ha)": [f"{geometry_gdf.area.item() / 10000:.2f}"],393 "Perimeter (m)": [f"{geometry_gdf.length.item():.2f}"],394 "Centroid (Lat, Lon)": [f"({geometry_gdf.to_crs(4326).centroid.y.iloc[0]:.6f}, {geometry_gdf.to_crs(4326).centroid.x.iloc[0]:.6f})"]395 })396 geometry_json = geometry_gdf.to_json()397 buffer_geometry_json = buffer_geometry_gdf.to_json()398 progress(1, desc="Done!")399 return m._repr_html_(), None, stats_df, dem_html, slope_html, geometry_json, buffer_geometry_json400 401@app.get("/api/geometry")402def calculate_geometry_metrics(file_url: str):403 """404 Accepts a URL to a KML/GeoJSON file, calculates the area and405 perimeter of the first valid polygon, and returns the results406 in a CSV format compatible with Google Sheets' IMPORTDATA.407 """408 try:409 decoded_url = unquote(file_url)410 input_gdf = get_gdf_from_url(decoded_url)411 412 if input_gdf is None or input_gdf.empty:413 raise ValueError("Could not read geometry from the provided URL.")414 415 input_gdf = preprocess_gdf(input_gdf)416 geometry_gdf = next((input_gdf.iloc[[i]] for i in range(len(input_gdf)) if is_valid_polygon(input_gdf.iloc[[i]])), None)417 418 if geometry_gdf is None:419 raise ValueError("No valid polygon found in the provided file.")420 421 projected_gdf = to_best_crs(geometry_gdf)422 area_hectares = projected_gdf.area.item() / 10000423 perimeter_meters = projected_gdf.length.item()424 425 centroid_gdf = projected_gdf.to_crs(epsg=4326)426 centroid_point = centroid_gdf.centroid.item()427 428 data_row = (429 f"area_hectares, {round(area_hectares, 4)},"430 f"perimeter_meters, {round(perimeter_meters, 4)},"431 f"latitude, {round(centroid_point.y, 4)},"432 f"longitude, {round(centroid_point.x, 4)}"433 )434 csv_output = f"{data_row}"435 return Response(content=csv_output, media_type="text/csv")436 437 except ValueError as e:438 # Handle specific errors with a 400 Bad Request439 raise HTTPException(status_code=400, detail=str(e))440 except Exception as e:441 # Handle any other unexpected errors with a 500442 print(f"An unexpected error occurred in /api/geometry: {e}")443 raise HTTPException(status_code=500, detail="An unexpected server error occurred.")444 445 446def calculate_indices(447 geometry_json, buffer_geometry_json, veg_indices, evi_vars, date_range,448 min_year, max_year, progress=gr.Progress()449):450 """Calculates vegetation indices based on user inputs."""451 one_time_setup()452 453 if not all([geometry_json, buffer_geometry_json, veg_indices]):454 return "Please process a file and select at least one index first.", None, None, None455 456 try:457 geometry_gdf = gpd.read_file(geometry_json)458 buffer_geometry_gdf = gpd.read_file(buffer_geometry_json)459 ee_geometry = ee.Geometry(json.loads(geometry_gdf.to_crs(4326).to_json())['features'][0]['geometry'])460 buffer_ee_geometry = ee.Geometry(json.loads(buffer_geometry_gdf.to_crs(4326).to_json())['features'][0]['geometry'])461 462 start_day, start_month = date_range[0].day, date_range[0].month463 end_day, end_month = date_range[1].day, date_range[1].month464 dates = [465 (f"{year}-{start_month:02d}-{start_day:02d}", f"{year}-{end_month:02d}-{end_day:02d}")466 for year in range(min_year, max_year + 1)467 ]468 469 collection = (470 ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED")471 .select(472 ["B2", "B3", "B4", "B8", "B11", "B12", "MSK_CLDPRB"],473 ["Blue", "Green", "Red", "NIR", "SWIR", "SWIR2", "MSK_CLDPRB"]474 )475 .map(lambda img: add_indices(img, 'NIR', 'Red', 'Blue', 'Green', 'SWIR', 'SWIR2', evi_vars))476 )477 478 result_rows = []479 for i, (start_date, end_date) in enumerate(dates):480 progress((i + 1) / len(dates), desc=f"Processing {start_date} to {end_date}")481 filtered_collection = collection.filterDate(start_date, end_date).filterBounds(ee_geometry)482 if filtered_collection.size().getInfo() == 0:483 continue484 485 year_val = int(start_date.split('-')[0])486 row = {'Year': year_val, 'Date Range': f"{start_date}_to_{end_date}"}487 488 for veg_index in veg_indices:489 mosaic = filtered_collection.qualityMosaic(veg_index)490 mean_val = mosaic.reduceRegion(reducer=ee.Reducer.mean(), geometry=ee_geometry, scale=10, maxPixels=1e9).get(veg_index).getInfo()491 buffer_mean_val = mosaic.reduceRegion(reducer=ee.Reducer.mean(), geometry=buffer_ee_geometry, scale=10, maxPixels=1e9).get(veg_index).getInfo()492 493 row[veg_index] = mean_val494 row[f"{veg_index}_buffer"] = buffer_mean_val495 row[f"{veg_index}_ratio"] = (mean_val / buffer_mean_val) if buffer_mean_val and buffer_mean_val != 0 else np.nan496 result_rows.append(row)497 498 if not result_rows:499 return "No satellite imagery found for the selected dates.", None, None, None500 501 result_df = pd.DataFrame(result_rows)502 result_df = result_df.round(3)503 504 plots = []505 for veg_index in veg_indices:506 plot_cols = [veg_index, f"{veg_index}_buffer", f"{veg_index}_ratio"]507 existing_plot_cols = [col for col in plot_cols if col in result_df.columns]508 509 plot_df = result_df[['Year'] + existing_plot_cols].dropna()510 511 if not plot_df.empty:512 fig = px.line(plot_df, x='Year', y=existing_plot_cols, markers=True, title=f"{veg_index} Time Series")513 fig.update_layout(xaxis_title="Year", yaxis_title="Index Value")514 fig.update_xaxes(dtick=1)515 plots.append(fig)516 517 return None, result_df, plots, "Calculation complete."518 519 except Exception as e:520 import traceback521 traceback.print_exc()522 return f"An error occurred during calculation: {e}", None, None, None523 524def get_histogram(index_name, image, geometry, bins):525 """Calculates the histogram for an image within a given geometry using GEE."""526 try:527 # Request histogram data from Earth Engine528 hist_info = image.reduceRegion(529 reducer=ee.Reducer.fixedHistogram(min=bins[0], max=bins[-1], steps=len(bins)-1),530 geometry=geometry,531 scale=10, # Scale in meters appropriate for Sentinel-2532 maxPixels=1e9533 ).get(index_name).getInfo()534 535 # Extract histogram counts536 if hist_info:537 histogram = [item[1] for item in hist_info]538 return np.array(histogram), bins539 else:540 # Return empty histogram if no data541 return np.array([0] * (len(bins) - 1)), bins542 except Exception as e:543 print(f"Could not compute histogram for {index_name}: {e}")544 return np.array([0] * (len(bins) - 1)), bins545 546def generate_comparison_maps(geometry_json, selected_index, selected_years, evi_vars, date_start_str, date_end_str, progress=gr.Progress()):547 """Generates side-by-side maps for a selected index and two selected years with a custom HTML legend."""548 if not geometry_json or not selected_index or not selected_years:549 return "Please process a file and select an index and years first.", "", ""550 if len(selected_years) != 2:551 return "Please select exactly two years to compare.", "", ""552 553 one_time_setup()554 geometry_gdf = gpd.read_file(geometry_json).to_crs(4326)555 ee_geometry = ee.Geometry(json.loads(geometry_gdf.to_json())['features'][0]['geometry'])556 bounds = geometry_gdf.total_bounds557 map_bounds = [[bounds[1], bounds[0]], [bounds[3], bounds[2]]]558 559 start_month, start_day = map(int, date_start_str.split('-'))560 end_month, end_day = map(int, date_end_str.split('-'))561 562 maps_html = []563 for i, year in enumerate(selected_years):564 progress((i + 1) / 2, desc=f"Generating map for {year}")565 start_date = f"{year}-{start_month:02d}-{start_day:02d}"566 end_date = f"{year}-{end_month:02d}-{end_day:02d}"567 568 wayback_url = None569 wayback_title = "Default Satellite"570 if not WAYBACK_DF.empty:571 try:572 target_date = datetime(int(year), start_month, 15)573 nearest_idx = WAYBACK_DF.index.get_indexer([target_date], method='nearest')[0]574 wayback_item = WAYBACK_DF.iloc[nearest_idx]575 wayback_title = f"Esri Wayback ({wayback_item.name.strftime('%Y-%m-%d')})"576 wayback_url = (577 wayback_item["ResourceURL_Template"]578 .replace("{TileMatrixSet}", "GoogleMapsCompatible")579 .replace("{TileMatrix}", "{z}")580 .replace("{TileRow}", "{y}")581 .replace("{TileCol}", "{x}")582 )583 except Exception as e:584 print(f"Could not find a suitable Wayback basemap for {year}: {e}")585 wayback_url = None586 wayback_title = "Default Satellite"587 588 collection = (589 ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED")590 .filterDate(start_date, end_date)591 .filterBounds(ee_geometry)592 .select(593 ["B2", "B3", "B4", "B8", "B11", "B12", "MSK_CLDPRB"],594 ["Blue", "Green", "Red", "NIR", "SWIR", "SWIR2", "MSK_CLDPRB"]595 )596 .map(lambda img: add_indices(img, 'NIR', 'Red', 'Blue', 'Green', 'SWIR', 'SWIR2', evi_vars))597 )598 599 if collection.size().getInfo() == 0:600 maps_html.append(f"<div style='text-align:center; padding-top: 50px;'>No data found for {year}.</div>")601 continue602 603 mosaic = collection.qualityMosaic(selected_index)604 m = gee_folium.Map(zoom_start=14)605 if wayback_url:606 m.add_tile_layer(wayback_url, name=wayback_title, attribution="Esri")607 else:608 m.add_basemap("SATELLITE")609 610 if selected_index in ["NDVI", "RandomForest", "GujVDI", "CI", "EVI", "EVI2"]:611 bins = [0, 0.2, 0.4, 0.6, 0.8, 1]612 histogram, _ = get_histogram(selected_index, mosaic.select(selected_index), ee_geometry, bins)613 614 total_pix = np.sum(histogram)615 formatted_histogram = ["0.00"] * len(histogram)616 if total_pix > 0:617 formatted_histogram = [f"{h*100/total_pix:.2f}" for h in histogram]618 619 # Define visualization parameters for the classified layer620 ind_vis_params = {621 "min": 0, "max": 1,622 "palette": ["#FF0000", "#FFFF00", "#FFA500", "#00FE00", "#00A400"],623 }624 m.addLayer(mosaic.select(selected_index).clip(ee_geometry), ind_vis_params, f"{selected_index} Classified Layer ({year})")625 626 legend_items = {627 f"0-0.2: Open/Sparse Vegetation ({formatted_histogram[0]}%)": "#FF0000",628 f"0.2-0.4: Low Vegetation ({formatted_histogram[1]}%)": "#FFFF00",629 f"0.4-0.6: Moderate Vegetation ({formatted_histogram[2]}%)": "#FFA500",630 f"0.6-0.8: Dense Vegetation ({formatted_histogram[3]}%)": "#00FE00",631 f"0.8-1: Very Dense Vegetation ({formatted_histogram[4]}%)": "#00A400",632 }633 634 legend_html = f'''635 <div style="636 position: fixed; 637 bottom: 20px; 638 right: 10px; 639 z-index:9999; 640 font-size:14px;641 background-color: rgba(255, 255, 255, 0.85);642 border:2px solid grey; 643 padding: 10px;644 border-radius: 6px;645 ">646 <b>{selected_index} Classification ({year})</b>647 <ul style="list-style-type: none; padding-left: 0; margin: 5px 0 0 0;">648 '''649 for text, color in legend_items.items():650 legend_html += f'<li><i style="background:{color}; width:20px; height:15px; display:inline-block; margin-right:5px; vertical-align:middle; border: 1px solid black;"></i> {text}</li>'651 legend_html += "</ul></div>"652 653 # Add the raw HTML to the map654 m.get_root().html.add_child(folium.Element(legend_html))655 656 else: # Fallback for indices without a color bar657 vis_params = {"min": 0.0, "max": 1.0, "palette": ['FFFFFF', 'CE7E45', 'DF923D', 'F1B555', 'FCD163', '99B718', '74A901', '66A000', '529400', '3E8601', '207401', '056201', '004C00', '023B01', '012E01', '011D01', '011301']}658 clipped_image = mosaic.select(selected_index).clip(ee_geometry)659 m.addLayer(clipped_image, vis_params, f"{selected_index} {year}")660 m.add_colorbar(vis_params=vis_params, label=f"{selected_index} Value")661 662 folium.GeoJson(geometry_gdf, name="Geometry", style_function=lambda x: {"color": "yellow", "fillOpacity": 0, "weight": 2.5}).add_to(m)663 m.fit_bounds(map_bounds, padding=(10, 10))664 m.addLayerControl()665 maps_html.append(m._repr_html_())666 667 while len(maps_html) < 2:668 maps_html.append("")669 670 return f"Comparison generated for {selected_years[0]} and {selected_years[1]}.", maps_html[0], maps_html[1]671 672# --- Gradio Interface ---673theme = gr.themes.Soft(primary_hue="teal", secondary_hue="orange").set(674 background_fill_primary="white"675)676 677with gr.Blocks(theme=theme, title="Kamlan: KML Analyzer") as demo:678 # Hidden state to store data between steps679 geometry_data = gr.State()680 buffer_geometry_data = gr.State()681 timeseries_df_state = gr.State()682 683 gr.HTML("""684 <div style="display: flex; justify-content: space-between; align-items: center;">685 <img src="https://huggingface.co/spaces/SustainabilityLabIITGN/NDVI_PERG/resolve/main/Final_IITGN-Logo-symmetric-Color.png" style="width: 10%; margin-right: auto;">686 <h1 style="text-align: center;">Kamlan: KML Analyzer</h1>687 <img src="https://huggingface.co/spaces/SustainabilityLabIITGN/NDVI_PERG/resolve/main/IFS.jpg" style="width: 10%; margin-left: auto;">688 </div>689 """)690 691 with gr.Row():692 with gr.Column(scale=1):693 gr.Markdown("## 1. Provide Input Geometry")694 gr.Markdown("Use either file upload OR a URL.")695 file_input = gr.File(label="Upload KML/GeoJSON File", file_types=[".kml", ".geojson"])696 url_input = gr.Textbox(label="Or Provide File URL", placeholder="e.g., https://.../my_file.kml")697 buffer_input = gr.Number(label="Buffer (meters)", value=50)698 process_button = gr.Button("Process Input", variant="primary")699 700 with gr.Accordion("Advanced Settings", open=False):701 gr.Markdown("### Select Vegetation Indices")702 all_veg_indices = ["GujVDI", "NDVI", "EVI", "EVI2", "RandomForest", "CI", "MNDWI", "SAVI", "MVI", "NBR", "GCI"]703 veg_indices_checkboxes = gr.CheckboxGroup(all_veg_indices, label="Indices", value=["NDVI"])704 705 gr.Markdown("### EVI/EVI2 Parameters")706 with gr.Row():707 evi_g = gr.Number(label="G", value=2.5)708 evi_c1 = gr.Number(label="C1", value=6.0)709 evi_c2 = gr.Number(label="C2", value=7.5)710 with gr.Row():711 evi_l = gr.Number(label="L", value=1.0)712 evi_c = gr.Number(label="C", value=2.4)713 714 gr.Markdown("### Date Range")715 today = datetime.now()716 date_start_input = gr.Textbox(label="Start Date (MM-DD)", value="11-15")717 date_end_input = gr.Textbox(label="End Date (MM-DD)", value="12-15")718 719 with gr.Row():720 min_year_input = gr.Number(label="Start Year", value=2019, precision=0)721 max_year_input = gr.Number(label="End Year", value=today.year, precision=0)722 723 calculate_button = gr.Button("Calculate Vegetation Indices", variant="primary")724 725 726 with gr.Column(scale=2):727 gr.Markdown("## 2. Results")728 info_box = gr.Textbox(label="Status", interactive=False, placeholder="Status messages will appear here...")729 map_output = gr.HTML(label="Map View")730 stats_output = gr.DataFrame(label="Geometry Metrics")731 732 gr.Markdown("### Digital Elevation Model (DEM) and Slope")733 with gr.Row():734 dem_map_output = gr.HTML(label="DEM Map")735 slope_map_output = gr.HTML(label="Slope Map")736 737 with gr.Tabs():738 with gr.TabItem("Time Series Plot"):739 plot_output = gr.Plot(label="Time Series Plot")740 with gr.TabItem("Time Series Data"):741 timeseries_table = gr.DataFrame(label="Time Series Data")742 743 gr.Markdown("---") # Visual separator744 gr.Markdown("## 3. Year-over-Year Index Comparison")745 with gr.Row():746 comparison_index_select = gr.Radio(all_veg_indices, label="Select Index for Comparison", value="NDVI")747 comparison_years_select = gr.CheckboxGroup(label="Select Two Years to Compare", choices=[])748 749 compare_button = gr.Button("Generate Comparison Maps", variant="secondary")750 751 with gr.Row():752 map_year_1_output = gr.HTML(label="Comparison Map 1")753 map_year_2_output = gr.HTML(label="Comparison Map 2")754 755 756 # --- Event Handlers ---757 def process_on_load(request: gr.Request):758 """Checks for a 'file_url' query parameter when the app loads."""759 return request.query_params.get("file_url", "")760 761 demo.load(process_on_load, None, url_input)762 763 process_button.click(764 fn=process_and_display,765 inputs=[file_input, url_input, buffer_input],766 outputs=[map_output, info_box, stats_output, dem_map_output, slope_map_output, geometry_data, buffer_geometry_data]767 )768 769 def calculate_wrapper(geometry_json, buffer_json, veg_indices,770 g, c1, c2, l, c, start_date_str, end_date_str,771 min_year, max_year, progress=gr.Progress()):772 """Wrapper to parse inputs and handle outputs for the main calculation function."""773 try:774 evi_vars = {'G': g, 'C1': c1, 'C2': c2, 'L': l, 'C': c}775 start_month, start_day = map(int, start_date_str.split('-'))776 end_month, end_day = map(int, end_date_str.split('-'))777 date_range = (datetime(2000, start_month, start_day), datetime(2000, end_month, end_day))778 779 error_msg, df, plots, success_msg = calculate_indices(780 geometry_json, buffer_json, veg_indices,781 evi_vars, date_range, int(min_year), int(max_year), progress782 )783 784 status_message = error_msg or success_msg785 first_plot = plots[0] if plots else None786 df_display = df.round(3) if df is not None else None787 788 available_years = []789 if df is not None and 'Year' in df.columns:790 available_years = sorted(df['Year'].unique().tolist())791 792 return status_message, df_display, df, first_plot, gr.update(choices=available_years, value=[])793 794 except Exception as e:795 return f"An error occurred in the wrapper: {e}", None, None, None, gr.update(choices=[], value=[])796 797 calculate_button.click(798 fn=calculate_wrapper,799 inputs=[800 geometry_data, buffer_geometry_data, veg_indices_checkboxes,801 evi_g, evi_c1, evi_c2, evi_l, evi_c,802 date_start_input, date_end_input,803 min_year_input, max_year_input804 ],805 outputs=[info_box, timeseries_table, timeseries_df_state, plot_output, comparison_years_select]806 )807 808 def comparison_wrapper(geometry_json, selected_index, selected_years, g, c1, c2, l, c, start_date_str, end_date_str, progress=gr.Progress()):809 """Wrapper for the comparison map generation."""810 try:811 evi_vars = {'G': g, 'C1': c1, 'C2': c2, 'L': l, 'C': c}812 status, map1, map2 = generate_comparison_maps(813 geometry_json, selected_index, selected_years, evi_vars,814 start_date_str, end_date_str, progress815 )816 return status, map1, map2817 except Exception as e:818 return f"Error during comparison: {e}", "", ""819 820 compare_button.click(821 fn=comparison_wrapper,822 inputs=[823 geometry_data, comparison_index_select, comparison_years_select,824 evi_g, evi_c1, evi_c2, evi_l, evi_c,825 date_start_input, date_end_input826 ],827 outputs=[info_box, map_year_1_output, map_year_2_output]828 )829 830 gr.HTML("""831 <div style="text-align: center; margin-top: 20px;">832 <p>Developed by <a href="https://sustainability-lab.github.io/">Sustainability Lab</a>, <a href="https://www.iitgn.ac.in/">IIT Gandhinagar</a></p>833 <p>Supported by <a href="https://forests.gujarat.gov.in/">Gujarat Forest Department</a></p>834 </div>835 """)836 837def compute_index_histogram(file_url: str, index_name: str, start_date: str, end_date: str):838 """Computes histogram percentage for a vegetation index within a polygon and date range."""839 one_time_setup()840 decoded_url = unquote(file_url)841 gdf = get_gdf_from_url(decoded_url)842 gdf = preprocess_gdf(gdf)843 844 geometry_gdf = next((gdf.iloc[[i]] for i in range(len(gdf)) if is_valid_polygon(gdf.iloc[[i]])), None)845 if geometry_gdf is None:846 raise HTTPException(status_code=400, detail="No valid polygon found.")847 848 ee_geometry = ee.Geometry(json.loads(geometry_gdf.to_crs(4326).to_json())['features'][0]['geometry'])849 850 evi_vars = {'G': 2.5, 'C1': 6.0, 'C2': 7.5, 'L': 1.0, 'C': 2.4}851 collection = (852 ee.ImageCollection("COPERNICUS/S2_SR_HARMONIZED")853 .filterDate(start_date, end_date)854 .select(855 ["B2", "B3", "B4", "B8", "B11", "B12", "MSK_CLDPRB"],856 ["Blue", "Green", "Red", "NIR", "SWIR", "SWIR2", "MSK_CLDPRB"]857 )858 .map(lambda img: add_indices(img, 'NIR', 'Red', 'Blue', 'Green', 'SWIR', 'SWIR2', evi_vars))859 .filterBounds(ee_geometry)860 )861 862 if collection.size().getInfo() == 0:863 raise HTTPException(status_code=404, detail="No imagery found for the polygon in given date range.")864 865 # Use median composite to avoid cloud spikes866 # mosaic = collection.median()867 mosaic = collection.qualityMosaic(index_name)868 mean_val = mosaic.reduceRegion(869 reducer=ee.Reducer.mean(),870 geometry=ee_geometry,871 scale=10,872 maxPixels=1e9873 ).get(index_name).getInfo()874 875 if mean_val is None:876 raise HTTPException(status_code=500, detail=f"Failed to compute mean for {index_name}.")877 878 bins = [0, 0.2, 0.4, 0.6, 0.8, 1.0]879 histogram, _ = get_histogram(index_name, mosaic.select(index_name), ee_geometry, bins)880 881 total = histogram.sum()882 883 # result = {f'mean': mean_val}884 # for i in range(len(bins) - 1):885 # label = f"{bins[i]}-{bins[i+1]}"886 # pct = (histogram[i] / total * 100) if total > 0 else 0887 # result[label] = round(pct, 2)888 889 890 output_parts = [f'mean, {round(mean_val, 4)}']891 892 for i in range(len(bins) - 1):893 label = f"{bins[i]}-{bins[i+1]}"894 pct = (histogram[i] / total * 100) if total > 0 else 0895 # Append each key-value pair string to the list896 output_parts.append(f'{label}, {round(pct, 2)}')897 898 # Join all parts into a single comma-separated string899 final_string = ", ".join(output_parts)900 901 # Return the string as a plain text response902 return Response(content=final_string, media_type="text/csv")903 904# Register endpoints for multiple indices with date range905for idx in ["NDVI", "EVI", "EVI2", "RandomForest", "CI", "GujVDI", "MNDWI", "SAVI", "MVI", "NBR", "GCI"]:906 endpoint_path = f"/api/{idx}"907 908 async def index_hist_endpoint(file_url: str, start_date: str, end_date: str, index_name=idx):909 return compute_index_histogram(file_url, index_name, start_date, end_date)910 911 app.get(endpoint_path)(index_hist_endpoint)912 913# Mount the Gradio app onto the FastAPI app914app = gr.mount_gradio_app(app, demo, path="/")915 916 917if __name__ == "__main__":918 import uvicorn919 uvicorn.run(app, host="0.0.0.0", port=7860)920 