dryness / app.py
maxcasado's picture
Update app.py
1823022 verified
import os
import numpy as np
import streamlit as st
import matplotlib.pyplot as plt
from sentinelhub import (
SHConfig,
SentinelHubRequest,
DataCollection,
MimeType,
BBox,
CRS,
bbox_to_dimensions,
)
st.set_page_config(page_title="Sentinel-2 NDWI (B8A-B11 / B8A-B12)", layout="wide")
st.title("Sentinel-2 NDWI (agri) + détection de stress hydrique")
st.caption("NDWI agri: (B8A − SWIR) / (B8A + SWIR), avec SWIR = B11 (1610 nm) ou B12 (2200 nm)")
# ----------------------------
# Secrets / config
# ----------------------------
def make_sh_config():
cfg = SHConfig()
cfg.sh_client_id = st.secrets["SH_CLIENT_ID"]
cfg.sh_client_secret = st.secrets["SH_CLIENT_SECRET"]
# Sentinel Hub sur Copernicus Data Space
cfg.sh_base_url = "https://sh.dataspace.copernicus.eu"
cfg.sh_token_url = "https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token"
return cfg
config = make_sh_config()
# ----------------------------
# UI inputs
# ----------------------------
with st.sidebar:
st.header("Zone (BBox WGS84)")
st.write("Entrez les coordonnées en **lon/lat** (EPSG:4326).")
col1, col2 = st.columns(2)
with col1:
min_lon = st.number_input("min_lon", value=3.85, format="%.6f")
min_lat = st.number_input("min_lat", value=43.58, format="%.6f")
with col2:
max_lon = st.number_input("max_lon", value=3.95, format="%.6f")
max_lat = st.number_input("max_lat", value=43.64, format="%.6f")
st.divider()
st.header("Dates")
start_date = st.date_input("Début", value=None)
end_date = st.date_input("Fin", value=None)
st.divider()
st.header("Paramètres NDWI")
swir_choice = st.selectbox("Formule", ["B8A & B11 (1610 nm)", "B8A & B12 (2200 nm)"])
p = st.slider("Percentile p (stress = NDWI < pᵉ percentile)", min_value=1, max_value=49, value=15, step=1)
st.divider()
st.header("Résolution")
res_m = st.slider("Résolution (m/pixel) — approx", min_value=10, max_value=120, value=30, step=10)
st.divider()
run = st.button("🚀 Lancer", type="primary")
# Defaults for dates if user left empty
import datetime as dt
today = dt.date.today()
if start_date is None:
start_date = today - dt.timedelta(days=14)
if end_date is None:
end_date = today
if not (min_lon < max_lon and min_lat < max_lat):
st.error("BBox invalide: vérifie min < max.")
st.stop()
time_interval = (start_date.isoformat(), end_date.isoformat())
# ----------------------------
# DataCollection
# ----------------------------
# SentinelHub DataCollection standard
S2_L2A = DataCollection.SENTINEL2_L2A.define_from(
name="s2l2a_cdse",
api_id="sentinel-2-l2a",
service_url="https://sh.dataspace.copernicus.eu"
)
# ----------------------------
# EvalScripts
# ----------------------------
evalscript_bands = """
//VERSION=3
function setup() {
return {
input: [{ bands: ["B8A", "B11", "B12", "dataMask"] }],
output: { bands: 4, sampleType: "FLOAT32" }
};
}
function evaluatePixel(s) { return [s.B8A, s.B11, s.B12, s.dataMask]; }
"""
evalscript_truecolor = """
//VERSION=3
function setup() {
return {
input: [{ bands: ["B04","B03","B02","dataMask"] }],
output: { bands: 4, sampleType: "FLOAT32" }
};
}
function evaluatePixel(s) { return [s.B04, s.B03, s.B02, s.dataMask]; }
"""
# ----------------------------
# Helpers
# ----------------------------
def request_tiff(evalscript: str, bbox: BBox, size: tuple[int, int]):
req = SentinelHubRequest(
evalscript=evalscript,
input_data=[SentinelHubRequest.input_data(data_collection=S2_L2A, time_interval=time_interval)],
responses=[SentinelHubRequest.output_response("default", MimeType.TIFF)],
bbox=bbox,
size=size,
config=config,
)
return req.get_data()[0]
def normalize_rgb(rgb):
# robust-ish normalization
mx = np.nanpercentile(rgb, 99)
if not np.isfinite(mx) or mx <= 0:
mx = np.nanmax(rgb)
if not np.isfinite(mx) or mx <= 0:
mx = 1.0
out = np.clip(rgb / mx, 0, 1)
return out
# ----------------------------
# Run
# ----------------------------
if not run:
st.info("Configure la zone + dates, puis clique **Lancer**.")
st.stop()
with st.spinner("Téléchargement Sentinel-2 + calcul NDWI…"):
bbox = BBox(bbox=[min_lon, min_lat, max_lon, max_lat], crs=CRS.WGS84)
# On approx la taille via res_m (en mètres/pixel)
# SentinelHub fait le calcul via bbox_to_dimensions (distance géodésique approx en WGS84)
size = bbox_to_dimensions(bbox, resolution=res_m)
# cap to avoid huge images in Spaces
max_side = 1600
if max(size) > max_side:
scale = max_side / max(size)
size = (max(64, int(size[0] * scale)), max(64, int(size[1] * scale)))
bands = request_tiff(evalscript_bands, bbox, size)
tc = request_tiff(evalscript_truecolor, bbox, size)
b8a, b11, b12, m = bands[..., 0], bands[..., 1], bands[..., 2], bands[..., 3]
rgb = tc[..., :3]
mask_rgb = tc[..., 3]
# masks
b8a = np.where(m > 0, b8a, np.nan)
b11 = np.where(m > 0, b11, np.nan)
b12 = np.where(m > 0, b12, np.nan)
rgb = np.where(mask_rgb[..., None] > 0, rgb, np.nan)
rgb = normalize_rgb(rgb)
rgb_raw = rgb.copy() # même image true color, sans overlay
eps = 1e-6
ndwi_11 = (b8a - b11) / (b8a + b11 + eps)
ndwi_12 = (b8a - b12) / (b8a + b12 + eps)
ndwi_used = ndwi_11 if "B11" in swir_choice else ndwi_12
thr = np.nanpercentile(ndwi_used, p)
stress = (ndwi_used < thr).astype(float)
stress = np.where(np.isfinite(ndwi_used), stress, np.nan)
# ----------------------------
# Visualisations
# ----------------------------
left, right = st.columns([1.2, 1.0], gap="large")
with left:
st.subheader("Vraie couleur : raw vs stress")
c1, c2 = st.columns(2, gap="medium")
with c1:
st.caption("Raw (true color)")
fig_raw = plt.figure(figsize=(6, 6))
plt.imshow(rgb_raw)
plt.axis("off")
st.pyplot(fig_raw, clear_figure=True)
with c2:
st.caption("Overlay stress hydrique")
fig = plt.figure(figsize=(6, 6))
plt.imshow(rgb)
plt.imshow(stress, cmap="Reds", alpha=0.40, vmin=0, vmax=1)
plt.axis("off")
plt.title(f"Stress = NDWI < p{p} (seuil {thr:.3f})")
st.pyplot(fig, clear_figure=True)
with right:
st.subheader("Cartes NDWI")
fig2 = plt.figure(figsize=(9, 4))
ax1 = plt.subplot(1, 2, 1)
im1 = ax1.imshow(ndwi_11, cmap="viridis")
ax1.set_title("NDWI (B8A, B11)")
ax1.axis("off")
ax2 = plt.subplot(1, 2, 2)
im2 = ax2.imshow(ndwi_12, cmap="viridis")
ax2.set_title("NDWI (B8A, B12)")
ax2.axis("off")
st.pyplot(fig2, clear_figure=True)
st.divider()
st.write("**Détails**")
st.write(
{
"time_interval": time_interval,
"bbox_wgs84": [min_lon, min_lat, max_lon, max_lat],
"size_px": list(size),
"resolution_m": res_m,
"p": p,
"threshold": float(thr),
"formula_used": swir_choice,
}
)