|
|
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)") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_sh_config(): |
|
|
cfg = SHConfig() |
|
|
cfg.sh_client_id = st.secrets["SH_CLIENT_ID"] |
|
|
cfg.sh_client_secret = st.secrets["SH_CLIENT_SECRET"] |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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") |
|
|
|
|
|
|
|
|
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()) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
S2_L2A = DataCollection.SENTINEL2_L2A.define_from( |
|
|
name="s2l2a_cdse", |
|
|
api_id="sentinel-2-l2a", |
|
|
service_url="https://sh.dataspace.copernicus.eu" |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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]; } |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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): |
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
size = bbox_to_dimensions(bbox, resolution=res_m) |
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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, |
|
|
} |
|
|
) |
|
|
|