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, } )