kilunga3 / app.py
JohnNvula's picture
tes
c9756e8 verified
Raw
History Blame Contribute Delete
27 kB
import os
import sys
import time
import io
import json
from pathlib import Path
from utils import print_with_line_number
# Add the parent directory to the Python path
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(parent_dir)
import ee
# Load the credentials from a JSON file
credential_path = 'kilunga app/Sentinel2App/assets'
with open(credential_path) as f:
service_account_info = json.load(f)
# Add Google Service account credential. Authenticates to the Earth Engine servers.
credentials = ee.ServiceAccountCredentials(os.environ.get("SERVICE_EMAIL"), key_data=os.environ.get("SERVICE_JSON"))
import math
from typing import Optional
from shiny import App, render, ui, reactive, Inputs, Outputs, Session, req
import ipyleaflet as L
from ipywidgets import Layout
from htmltools import css
import numpy as np
import pandas as pd
# from PIL import Image
from shinywidgets import output_widget, reactive_read, register_widget
from geopy.geocoders import Nominatim
import json
import requests
import traceback
from datetime import datetime, date
from typing import List
from timezonefinder import TimezoneFinder
# Library for ANN model loading
import tensorflow
import joblib
# Sentinel 2 Bands
# Sentinel-2 carries the Multispectral Imager (MSI). This sensor delivers 13 spectral bands ranging from 10 to 60-meter pixel size.
# Its blue (B2), green (B3), red (B4), and near-infrared (B8) channels have a 10-meter resolution.
# Next, its red edge (B5), near-infrared NIR (B6, B7, and B8A), and short-wave infrared SWIR (B11 and B12) have a ground sampling distance of 20 meters.
# Finally, its coastal aerosol (B1) and cirrus band (B10) have a 60-meter pixel size.
# Band Resolution Central Wavelength Description
# B1 60 m 443 nm Ultra Blue (Coastal and Aerosol)
# B2 10 m 490 nm Blue
# B3 10 m 560 nm Green
# B4 10 m 665 nm Red
# B5 20 m 705 nm Visible and Near Infrared (VNIR)
# B6 20 m 740 nm Visible and Near Infrared (VNIR)
# B7 20 m 783 nm Visible and Near Infrared (VNIR)
# B8 10 m 842 nm Visible and Near Infrared (VNIR)
# B8a 20 m 865 nm Visible and Near Infrared (VNIR)
# B9 60 m 940 nm Short Wave Infrared (SWIR)
# B10 60 m 1375 nm Short Wave Infrared (SWIR) - excluded
# B11 20 m 1610 nm Short Wave Infrared (SWIR)
# B12 20 m 2190 nm Short Wave Infrared (SWIR)
tf = TimezoneFinder()
# You can use different URLs to load remote sensing image data from various sources
# In this example, we use image data from Google Earth Engine
GEEurl = 'https://earthengine.googleapis.com/v1alpha/projects/earthengine-legacy/maps/{mapid}/tiles/{z}/{x}/{y}?token={token}'
GEEmap_id = 'projects/ee-joaonvula501/maps/c483c79bcb04b7f2e2b15d6be8fa4b29-3e09ed6b5f9bd283fb1caeba520f6d3e' # Replace with your Google Earth Engine Map ID
GEEtoken = '' # Replace with your Google Earth Engine Token
# Custom Loss Function with Covariance Penalty
def custom_loss(lam, cov_real_data):
def loss(y_true, y_pred):
mse_loss = tensorflow.reduce_mean(tensorflow.square(y_true - y_pred))
cov_pred = tensorflow.linalg.matmul(tensorflow.transpose(y_pred - tensorflow.reduce_mean(y_pred, axis=0)),
(y_pred - tensorflow.reduce_mean(y_pred, axis=0))) / tensorflow.cast(tensorflow.shape(y_pred)[0], tensorflow.float32)
cov_penalty = tensorflow.reduce_sum(tensorflow.square(cov_pred - cov_real_data))
return mse_loss + lam * cov_penalty
return loss
def load_model_and_preprocessors(model_path, cov_real_data_path, scaler_X_path, scaler_y_path):
# Load the covariance matrix
cov_real_data = np.load(cov_real_data_path)
# Load the trained model
model = tensorflow.keras.models.load_model(model_path, custom_objects={'loss': custom_loss(1e-6, cov_real_data)})
# Load the input data scaler
scaler_X = joblib.load(scaler_X_path)
# Load the output data scaler
scaler_Y = joblib.load(scaler_y_path)
return model, scaler_X, scaler_Y
# Load ANN model and preprocessors
model, loaded_scaler_X, loaded_scaler_Y = load_model_and_preprocessors(
"ANN_assests/model",
"ANN_assests/cov_real_data.npy",
"ANN_assests/scaler_X.pkl",
"ANN_assests/scaler_y.pkl"
)
# Create labels
labels = ['Longitude', 'Latitude', 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B8A', 'B9', 'B11', 'B12', 'N', 'Cab', 'Ccx', 'Cw', 'Cm']
X_labels = ['B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B8A', 'B9', 'B11', 'B12']
output_labels = ["N", "Cab", "Ccx", "Cw", "Cm"]
layer_names = ["structure parameter", "Chlorophylla+b content (µg/cm2)", "Carotenoids content (µg/cm2)", "Equivalent Water content (cm)", "Leaf Mass per Area (g/cm2)"]
data_to_map = {
"structure parameter": "N",
"Chlorophylla+b content (µg/cm2)": "Cab",
"Carotenoids content (µg/cm2)": "Ccx",
"Equivalent Water content (cm)": "Cw",
"Leaf Mass per Area (g/cm2)": "Cm"
}
# gradient_settings = {
# "structure parameter": {0.2: 'rgba(0, 0, 255, 1.0)', 0.6: 'rgba(0, 255, 255, 1.0)', 1.0: 'rgba(0, 255, 0, 1.0)'},
# "Chlorophylla+b content (µg/cm2)": {0.2: 'rgba(0, 128, 0, 1.0)', 0.6: 'rgba(127, 255, 0, 1.0)', 1.0: 'rgba(255, 255, 0, 1.0)'},
# "Carotenoids content (µg/cm2)": {0.2: 'rgba(255, 69, 0, 1.0)', 0.6: 'rgba(255, 0, 0, 1.0)', 1.0: 'rgba(139, 0, 0, 1.0)'},
# "Equivalent Water content (cm)": {0.2: 'rgba(0, 0, 139, 1.0)', 0.6: 'rgba(65, 105, 225, 1.0)', 1.0: 'rgba(0, 191, 255, 1.0)'},
# "Leaf Mass per Area (g/cm2)": {0.2: 'rgba(75, 0, 130, 1.0)', 0.6: 'rgba(148, 0, 211, 1.0)', 1.0: 'rgba(255, 20, 147, 1.0)'}
# }
gradient_parula = {
0.0: 'rgba(128, 0, 128, 1.0)', # purple
0.2: 'rgba(0, 0, 255, 1.0)', # blue
0.4: 'rgba(0, 255, 255, 1.0)', # cyan
0.5: 'rgba(0, 250, 154, 1.0)', # mediumspringgreen
0.6: 'rgba(50, 205, 50, 1.0)', # lime
0.7: 'rgba(173, 255, 47, 1.0)', # greenyellow
0.8: 'rgba(255, 255, 0, 1.0)', # yellow
0.9: 'rgba(255, 165, 0, 1.0)', # orange
1.0: 'rgba(255, 0, 0, 1.0)' # red
}
gradient_settings = {
"structure parameter": gradient_parula,
"Chlorophylla+b content (µg/cm2)": gradient_parula,
"Carotenoids content (µg/cm2)": gradient_parula,
"Equivalent Water content (cm)": gradient_parula,
"Leaf Mass per Area (g/cm2)": gradient_parula
}
print_with_line_number("Finish loading the ANN model!")
def runModel(input_data, scaler_X, scaler_Y, ANNmodel):
# Preprocess the Input Data
# Scale the input features using the previously saved scaler for X
input_data_scaled = scaler_X.transform(input_data)
# Use the Model for Prediction
# Predict the output values (N, Cab, Ccx, Cw, Cm) for each pixel block
output_data_scaled = ANNmodel.predict(input_data_scaled)
# Post-process the Output Data
# Inverse scale the output data using the previously saved scaler for Y
output_data = scaler_Y.inverse_transform(output_data_scaled)
# Organize the Output Results and Coordinates
# Create datasets for each output label and one for coordinates
# Each dataset contains corresponding data for all pixel blocks
datasets = {}
for i, label in enumerate(output_labels):
datasets[label] = output_data[:, i]
# Print the results for verification
for label, data in datasets.items():
print(label, data)
return datasets
gpsurl = 'https://www.googleapis.com/geolocation/v1/geolocate?key=' + os.environ.get("GMAP_TOKEN")
def getGPS():
GPSurl = gpsurl
data = {'homeMobileCountryCode': 310, 'homeMobileNetworkCode': 410, 'considerIp': 'True'}
response = requests.post(GPSurl, data=json.dumps(data))
result = json.loads(response.content)
return result
def get_location(lat, lon):
geolocator = Nominatim(timeout=120, user_agent="when-to-fly")
location = geolocator.reverse(f"{lat},{lon}")
return location.address
# Start UI
app_ui = ui.page_fluid(
ui.div(
ui.input_action_button("analyze", "Analyze", class_="btn-success"),
ui.download_button("download_polygon", "Download spectral data as tif", class_="btn-success"),
ui.download_button("download_output", "Download spectral and output data as csv", class_="btn-success"),
ui.download_button("download_output", "Download spectral and output data report", class_="btn-success"),
style=css(display="flex", justify_content="center", align_items="center", gap="2rem"),
),
ui.strong("Must analyze (to renew the image information) before downloading any file.", style="color: green;"),
ui.layout_sidebar(
ui.panel_sidebar(
ui.div(
ui.div(
ui.input_date("date", "Date:"),
ui.input_slider("zoom", "Map zoom level", value=12, min=1, max=18),
),
ui.div(
ui.input_numeric("lat", "Latitude", value=38.53667742),
ui.input_numeric("long", "Longitude", value=-121.75387309),
),
style=css(display="flex", justify_content="center", align_items="center", gap="1rem"),
),
),
ui.panel_main(
ui.div(
ui.output_text("N_range"),
ui.output_text("Cab_range"),
ui.output_text("Ccx_range"),
ui.output_text("Cw_range"),
ui.output_text("Cm_range"),
style=css(display="flex", justify_content="center", align_items="center", gap="2rem"),
),
ui.img(src="legend.png"),
),
),
output_widget("map"),
# style=css(display="flex", justify_content="center", align_items="center", gap="1rem"),
)
# end UI
# re-run when a user using the application
def server(input, output, session):
# Initialize Earth Engine
ee.Initialize(credentials)
global address_line, polygoned_image, output_df
address_line = None
polygoned_image = None
polygon_data = reactive.Value([])
output_df = pd.DataFrame()
N = reactive.Value("structure parameter")
Cab = reactive.Value("Chlorophylla+b content (µg/cm2)")
Ccx = reactive.Value("Carotenoids content (µg/cm2)")
Cw = reactive.Value("Equivalent Water content (cm)")
Cm = reactive.Value("Leaf Mass per Area (g/cm2)")
m = ui.modal(
"Please wait for progress...",
easy_close=False,
size="s",
footer=None,
fade=True
)
@output
@render.text
def N_range():
return N.get()
@output
@render.text
def Cab_range():
return Cab.get()
@output
@render.text
def Ccx_range():
return Ccx.get()
@output
@render.text
def Cw_range():
return Cw.get()
@output
@render.text
def Cm_range():
return Cm.get()
def handle_draw(self, action, geo_json):
print("运行handle_draw")
if geo_json['type'] == 'Feature':
# Check if the drawn shape is a polygon
if geo_json['geometry']['type'] == 'Polygon':
# Get the coordinates of the polygon's vertices
coordinates = geo_json['geometry']['coordinates'][0]
# Extract latitude and longitude values from each vertex
# For GeoJSON, coordinates are represented as [longitude, latitude]
# (note the reverse order compared to traditional [latitude, longitude])
polygon_data.set([(lon, lat) for lon, lat in coordinates])
# Process the polygon_data as per your requirement
# For example, print the coordinates
print("Polygon Vertex Coordinates:")
for lon, lat in polygon_data.get():
print(f"Latitude: {lat}, Longitude: {lon}")
ui.modal_show(m)
# Check API status
asset_roots = ee.data.getAssetRoots()
if asset_roots:
print("API is connected and working.")
else:
print("API is not connected or not working.")
try:
# Get the user's current geoinformation
current_gps = getGPS()
print_with_line_number(current_gps)
current_location = get_location(current_gps['location']['lat'], current_gps['location']['lng'])
print_with_line_number(current_location)
ui.update_text(id="address",
label="Data for",
value=current_location)
# Initialize and display when the session starts (1)
map = L.Map(center=(current_gps['location']['lat'], current_gps['location']['lng']), zoom=12, scroll_wheel_zoom=True)
map.layout = Layout(height='600px')
@reactive.isolate()
def update_text_inputs(lat: Optional[float], long: Optional[float]) -> None:
req(lat is not None, long is not None)
lat = round(lat, 8)
long = round(long, 8)
if lat != input.lat():
input.lat.freeze()
ui.update_text("lat", value=lat)
if long != input.long():
input.long.freeze()
ui.update_text("long", value=long)
map.add_layer(L.TileLayer(url='https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', name='Natural Map'))
# Add a distance scale
map.add_control(L.leaflet.ScaleControl(position="bottomleft"))
layer_control = L.LayersControl(position='topright')
map.add_control(layer_control)
# Add the DrawControl widget to the map
draw_control = L.DrawControl(
polygon = {
"shapeOptions": {
"fillColor": "transparent",
"fillOpacity": 0.0
}
}
)
map.add_control(draw_control)
# Attach the handle_draw function to the on_draw event
draw_control.on_draw(handle_draw)
register_widget("map", map)
ui.modal_remove()
except Exception as e:
ui.modal_remove()
error_modal = ui.modal(
str(e),
title="An Error occured. Please refresh",
easy_close=True,
size="xl",
footer=None,
fade=True
)
# print_with_line_number("Show error modal")
ui.modal_show(error_modal)
traceback.print_exc()
# When the slider changes, update the map's zoom attribute (2)
@reactive.Effect
def _():
map.zoom = input.zoom()
# When zooming directly on the map, update the slider's value (2 and 3)
@reactive.Effect
def _():
ui.update_slider("zoom", value=reactive_read(map, "zoom"))
@reactive.Effect
def location():
"""Returns tuple of (lat,long) floats--or throws silent error if no lat/long is
selected"""
# Require lat/long to be populated before we can proceed
req(input.lat() is not None, input.long() is not None)
try:
long = input.long()
# Wrap longitudes so they're within [-180, 180]
long = (long + 180) % 360 - 180
if round(map.center[0], 8) == input.lat() and round(map.center[1], 8) == long:
return
map.center = (input.lat(), long)
except ValueError as e:
error_modal = ui.modal(
str(e),
title="Invalid latitude/longitude specification. Please refresh",
easy_close=True,
size="xl",
footer=None,
fade=True
)
# print_with_line_number("Show error modal")
ui.modal_show(error_modal)
traceback.print_exc()
# Everytime the map's bounds change, update the output message (3)
# rerun when a user do some reactive changes.
@reactive.Effect
def map_bounds():
center = reactive_read(map, "center")
if len(center) == 0:
return
lon = (center[1] + 180) % 360 - 180
update_text_inputs(center[0], lon)
def update_or_create_heatmaps(output_datasets, scale):
"""
Check if a heatmap layer exists for each dataset in output_datasets.
If it exists, update the heatmap, otherwise create a new heatmap.
Parameters:
output_datasets (list of dict): The datasets for creating/updating heatmaps
"""
# Iterate over each dataset in output_datasets
existing_layers = {layer.name: layer for layer in map.layers}
print_with_line_number(existing_layers)
for layer_name in layer_names:
# Check if a heatmap layer with this name already exists
if layer_name in existing_layers:
print("deleting ", layer_name)
map.remove_layer(existing_layers[layer_name])
heatmap_data = []
data_values = output_datasets[data_to_map[layer_name]]
q03 = np.percentile(data_values, 3)
q97 = np.percentile(data_values, 97)
min_value = min(data_values)
if min_value < 0:
min_value = 0
max_value = max(data_values)
if (data_to_map[layer_name] == "N"):
N.set(layer_name + ": " + str(min_value) + " ~ " + str(max_value))
elif (data_to_map[layer_name] == "Cab"):
Cab.set(layer_name + ": " + str(min_value) + " ~ " + str(max_value))
elif (data_to_map[layer_name] == "Ccx"):
Ccx.set(layer_name + ": " + str(min_value) + " ~ " + str(max_value))
elif (data_to_map[layer_name] == "Cw"):
Cw.set(layer_name + ": " + str(min_value) + " ~ " + str(max_value))
else:
Cm.set(layer_name + ": " + str(min_value) + " ~ " + str(max_value))
for coord, n in zip(output_datasets["Coordinates"], data_values):
# normalized_value = (n - min_value) / (max_value - min_value)
if n <= q03:
normalized_value = 0
elif n >= q97:
normalized_value = 1
else:
normalized_value = (n - q03) / (q97 - q03)
heatmap_data.append([coord[1], coord[0], normalized_value])
# Generate new heatmap for this dataset
heatmap = L.Heatmap(
locations=heatmap_data,
radius=scale * 1.4,
gradient=gradient_settings[layer_name],
max=1,
blur=scale / 2,
name=layer_name
)
# Add the new heatmap layer to the map
map.add_layer(heatmap)
@reactive.Effect
@reactive.event(input.analyze, ignore_none=True, ignore_init=True)
def _():
global output_df
if not polygon_data.get():
return
ui.modal_show(m)
global polygoned_image
polygon = ee.Geometry.Polygon(polygon_data.get())
print("Polygon Data: " , polygon_data.get())
print("Polygon: " , polygon)
# Define Sentinel-2 image collection ("2021-01-01", "2021-12-31")
#Estes script é de bastante importamcia porque mostra que o codigo frequente analisa imagens novas.
current_date = input.date()
today = ee.Date(input.date().strftime('%Y-%m-%d'))
start_date = today.advance(-15, 'day')
print("Start Date: ", start_date.format('YYYY-MM-dd').getInfo(), "| End Date: ", today.format('YYYY-MM-dd').getInfo())
sentinel2 = ee.ImageCollection("COPERNICUS/S2_SR")\
.filterDate(start_date, today)\
.filterBounds(polygon)\
.sort('CLOUDY_PIXEL_PERCENTAGE', True)
# .first() # Retrieve the first image from the ImageCollection
# sentinel2 = sentinel2.sort('CLOUDY_PIXEL_PERCENTAGE', True)
polygoned_image = sentinel2.first()
# Make sure the polygoned_image is available
if not polygoned_image:
print("No image available for download.")
return
retry = 5
while(sentinel2.size().getInfo() == 0):
if(retry == 0):
ui.update_date("date", label="Date:", value=current_date)
ui.modal_remove()
error_modal = ui.modal(
"Please do not choose a date that is too far in the future. This application will search for remote sensing data within the two weeks prior to the selected date.",
title="Something wrong happened, try \"Analyze\" again ",
easy_close=True,
size="xl",
footer=None,
fade=True
)
ui.modal_show(error_modal)
print("fail to fecth image.")
return
print("wait for fetching.")
time.sleep(2)
retry -= 1
#fim do script de extrema importancia
print_with_line_number("Type of sentinel2: " + str(type(sentinel2)))
print("Counts of Fetched image: ", sentinel2.size().getInfo())
# Clip the image to the extent of the polygon
clipped_image = polygoned_image.clip(polygon)
# Get meta data about the image object
# bands = clipped_image.bandNames().getInfo()
# print_with_line_number(bands)
# Calculate suitable pixel number. GEE service allow fecthing 5000 pixels at most for one call. So we use the "polygoned area / 4999" to decide a rational pixel scale.
scale=1
polygon_area = polygon.area().getInfo()
num = math.ceil(polygon_area / scale / scale)
if (num > 4999):
per_area = math.ceil(polygon_area / 4998)
scale = math.ceil(math.pow(per_area, 1.0/2))
print("polygon_area(m2): ", polygon_area, "scale: ", scale)
# Fetch reflectance of B1-B12
spectral_values = clipped_image.select('B1', 'B2', 'B3', 'B4', 'B5', 'B6', 'B7', 'B8', 'B8A', 'B9', 'B11', 'B12').sample(
region=polygon,
scale=scale,
numPixels=4999,
geometries=True
)
print_with_line_number("Pre-process the bands data.")
# print_with_line_number(type(spectral_values))
spectral_values = spectral_values.getInfo()
# print_with_line_number(type(spectral_values))
spectral_values_json = json.dumps(spectral_values)
# print(spectral_values_json)
spectral_values_dict = json.loads(spectral_values_json)
features = spectral_values_dict['features']
print_with_line_number("Extract the center coordinates and values of B1-B12 for each pixel block")
coords = []
input_data = []
for feature in features:
coords.append(feature['geometry']['coordinates'])
props = feature['properties']
input_data.append([props[b] for b in X_labels])
# Convert to NumPy arrays
coords = np.array(coords)
# print("coords: ", coords)
input_data = np.array(input_data)
# print("input_bands: ", input_data)
output_datasets = runModel(input_data, loaded_scaler_X, loaded_scaler_Y, model)
# Combine all data
data_combined = np.column_stack((coords, input_data, output_datasets['N'], output_datasets['Cab'], output_datasets['Ccx'], output_datasets['Cw'], output_datasets['Cm']))
# Convert to DataFrame
output_df = pd.DataFrame(data_combined, columns=labels)
print_with_line_number("Add a dataset for the coordinates")
output_datasets['Coordinates'] = coords
update_or_create_heatmaps(output_datasets, scale)
register_widget("map", map)
ui.modal_remove()
@reactive.Effect
def _():
print("Current navbar page: ", input.navbar_id())
@session.download(
filename=lambda: f"image-{input.date().isoformat()}-{np.random.randint(100, 999)}.tif"
)
async def download_polygon():
# # Replace this with your ee.Image object
# image_id = "COPERNICUS/S2_SR/20230728T184921_20230728T190044_T10SFH"
# image = ee.Image(image_id)
# Make sure the polygoned_image is available
if not polygoned_image:
print("No image available for download.")
return
# Clip the image to the extent of the polygon
clipped_image = polygoned_image.clip(ee.Geometry.Polygon(polygon_data.get()))
print("clipped_image: ", clipped_image)
# Define export parameters
download_params = {
'scale': 10,
'region': polygon_data.get(), # ee.Geometry object defining the region to export
'format': 'GeoTIFF',
}
# Generate download URL for the GeoTIFF image
download_url = clipped_image.getDownloadURL(download_params)
# Send a request to download the image
response = requests.get(download_url)
# Create a BytesIO buffer
with io.BytesIO() as buf:
# Write the image content to the buffer
buf.write(response.content)
buf.seek(0) # Move the buffer's position to the beginning
# Yield the buffer's content as a downloadable file
yield buf.getvalue()
print("Image downloaded successfully!")
@session.download(
filename=lambda: f"data-{input.date().isoformat()}-{np.random.randint(100, 999)}.csv"
)
def download_output():
global output_df
# Check if data is available
if output_df.empty:
print("No data available for download.")
return
# Convert dataframe to CSV and encode to bytes
csv_data = output_df.to_csv(index=False).encode()
# Create a StringIO buffer for textual data
with io.BytesIO() as buf:
buf.write(csv_data)
# Reset the buffer's position to the beginning
buf.seek(0)
# Create and return a streaming response
yield buf.getvalue()
print("Data downloaded successfully!")
static_dir = Path(__file__).parent / "assets"
app = App(app_ui, server, static_assets=static_dir)