maxcasado commited on
Commit
7ebe7f9
·
verified ·
1 Parent(s): 2cfa424

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +230 -0
app.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import numpy as np
3
+ import streamlit as st
4
+ import matplotlib.pyplot as plt
5
+
6
+ from sentinelhub import (
7
+ SHConfig,
8
+ SentinelHubRequest,
9
+ DataCollection,
10
+ MimeType,
11
+ BBox,
12
+ CRS,
13
+ bbox_to_dimensions,
14
+ )
15
+
16
+ st.set_page_config(page_title="Sentinel-2 NDWI (B8A-B11 / B8A-B12)", layout="wide")
17
+
18
+ st.title("Sentinel-2 NDWI (agri) + détection de stress hydrique")
19
+ st.caption("NDWI agri: (B8A − SWIR) / (B8A + SWIR), avec SWIR = B11 (1610 nm) ou B12 (2200 nm)")
20
+
21
+ # ----------------------------
22
+ # Secrets / config
23
+ # ----------------------------
24
+ def make_sh_config() -> SHConfig:
25
+ cfg = SHConfig()
26
+ # Deux façons: secrets Streamlit (HF Spaces) ou variables d'env
27
+ # Mets au moins SH_CLIENT_ID et SH_CLIENT_SECRET.
28
+ cfg.sh_client_id = st.secrets.get("SH_CLIENT_ID", os.getenv("SH_CLIENT_ID", ""))
29
+ cfg.sh_client_secret = st.secrets.get("SH_CLIENT_SECRET", os.getenv("SH_CLIENT_SECRET", ""))
30
+
31
+ # Si tu utilises un endpoint spécifique, tu peux aussi le mettre en secret/env
32
+ # cfg.sh_base_url = st.secrets.get("SH_BASE_URL", os.getenv("SH_BASE_URL", cfg.sh_base_url))
33
+
34
+ if not cfg.sh_client_id or not cfg.sh_client_secret:
35
+ st.error(
36
+ "Identifiants Sentinel Hub manquants. Ajoute SH_CLIENT_ID et SH_CLIENT_SECRET dans Settings → Secrets (HF Space)."
37
+ )
38
+ st.stop()
39
+
40
+ return cfg
41
+
42
+ config = make_sh_config()
43
+
44
+ # ----------------------------
45
+ # UI inputs
46
+ # ----------------------------
47
+ with st.sidebar:
48
+ st.header("Zone (BBox WGS84)")
49
+ st.write("Entrez les coordonnées en **lon/lat** (EPSG:4326).")
50
+ col1, col2 = st.columns(2)
51
+ with col1:
52
+ min_lon = st.number_input("min_lon", value=3.85, format="%.6f")
53
+ min_lat = st.number_input("min_lat", value=43.58, format="%.6f")
54
+ with col2:
55
+ max_lon = st.number_input("max_lon", value=3.95, format="%.6f")
56
+ max_lat = st.number_input("max_lat", value=43.64, format="%.6f")
57
+
58
+ st.divider()
59
+ st.header("Dates")
60
+ start_date = st.date_input("Début", value=None)
61
+ end_date = st.date_input("Fin", value=None)
62
+
63
+ st.divider()
64
+ st.header("Paramètres NDWI")
65
+ swir_choice = st.selectbox("Formule", ["B8A & B11 (1610 nm)", "B8A & B12 (2200 nm)"])
66
+ p = st.slider("Percentile p (stress = NDWI < pᵉ percentile)", min_value=1, max_value=49, value=15, step=1)
67
+
68
+ st.divider()
69
+ st.header("Résolution")
70
+ res_m = st.slider("Résolution (m/pixel) — approx", min_value=10, max_value=120, value=30, step=10)
71
+
72
+ st.divider()
73
+ run = st.button("🚀 Lancer", type="primary")
74
+
75
+ # Defaults for dates if user left empty
76
+ import datetime as dt
77
+ today = dt.date.today()
78
+ if start_date is None:
79
+ start_date = today - dt.timedelta(days=14)
80
+ if end_date is None:
81
+ end_date = today
82
+
83
+ if not (min_lon < max_lon and min_lat < max_lat):
84
+ st.error("BBox invalide: vérifie min < max.")
85
+ st.stop()
86
+
87
+ time_interval = (start_date.isoformat(), end_date.isoformat())
88
+
89
+ # ----------------------------
90
+ # DataCollection
91
+ # ----------------------------
92
+ # SentinelHub DataCollection standard
93
+ S2_L2A = DataCollection.SENTINEL2_L2A
94
+
95
+ # ----------------------------
96
+ # EvalScripts
97
+ # ----------------------------
98
+ evalscript_bands = """
99
+ //VERSION=3
100
+ function setup() {
101
+ return {
102
+ input: [{ bands: ["B8A", "B11", "B12", "dataMask"] }],
103
+ output: { bands: 4, sampleType: "FLOAT32" }
104
+ };
105
+ }
106
+ function evaluatePixel(s) { return [s.B8A, s.B11, s.B12, s.dataMask]; }
107
+ """
108
+
109
+ evalscript_truecolor = """
110
+ //VERSION=3
111
+ function setup() {
112
+ return {
113
+ input: [{ bands: ["B04","B03","B02","dataMask"] }],
114
+ output: { bands: 4, sampleType: "FLOAT32" }
115
+ };
116
+ }
117
+ function evaluatePixel(s) { return [s.B04, s.B03, s.B02, s.dataMask]; }
118
+ """
119
+
120
+ # ----------------------------
121
+ # Helpers
122
+ # ----------------------------
123
+ def request_tiff(evalscript: str, bbox: BBox, size: tuple[int, int]):
124
+ req = SentinelHubRequest(
125
+ evalscript=evalscript,
126
+ input_data=[SentinelHubRequest.input_data(data_collection=S2_L2A, time_interval=time_interval)],
127
+ responses=[SentinelHubRequest.output_response("default", MimeType.TIFF)],
128
+ bbox=bbox,
129
+ size=size,
130
+ config=config,
131
+ )
132
+ return req.get_data()[0]
133
+
134
+ def normalize_rgb(rgb):
135
+ # robust-ish normalization
136
+ mx = np.nanpercentile(rgb, 99)
137
+ if not np.isfinite(mx) or mx <= 0:
138
+ mx = np.nanmax(rgb)
139
+ if not np.isfinite(mx) or mx <= 0:
140
+ mx = 1.0
141
+ out = np.clip(rgb / mx, 0, 1)
142
+ return out
143
+
144
+ # ----------------------------
145
+ # Run
146
+ # ----------------------------
147
+ if not run:
148
+ st.info("Configure la zone + dates, puis clique **Lancer**.")
149
+ st.stop()
150
+
151
+ with st.spinner("Téléchargement Sentinel-2 + calcul NDWI…"):
152
+ bbox = BBox(bbox=[min_lon, min_lat, max_lon, max_lat], crs=CRS.WGS84)
153
+
154
+ # On approx la taille via res_m (en mètres/pixel)
155
+ # SentinelHub fait le calcul via bbox_to_dimensions (distance géodésique approx en WGS84)
156
+ size = bbox_to_dimensions(bbox, resolution=res_m)
157
+
158
+ # cap to avoid huge images in Spaces
159
+ max_side = 1600
160
+ if max(size) > max_side:
161
+ scale = max_side / max(size)
162
+ size = (max(64, int(size[0] * scale)), max(64, int(size[1] * scale)))
163
+
164
+ bands = request_tiff(evalscript_bands, bbox, size)
165
+ tc = request_tiff(evalscript_truecolor, bbox, size)
166
+
167
+ b8a, b11, b12, m = bands[..., 0], bands[..., 1], bands[..., 2], bands[..., 3]
168
+ rgb = tc[..., :3]
169
+ mask_rgb = tc[..., 3]
170
+
171
+ # masks
172
+ b8a = np.where(m > 0, b8a, np.nan)
173
+ b11 = np.where(m > 0, b11, np.nan)
174
+ b12 = np.where(m > 0, b12, np.nan)
175
+
176
+ rgb = np.where(mask_rgb[..., None] > 0, rgb, np.nan)
177
+ rgb = normalize_rgb(rgb)
178
+
179
+ eps = 1e-6
180
+ ndwi_11 = (b8a - b11) / (b8a + b11 + eps)
181
+ ndwi_12 = (b8a - b12) / (b8a + b12 + eps)
182
+
183
+ ndwi_used = ndwi_11 if "B11" in swir_choice else ndwi_12
184
+
185
+ thr = np.nanpercentile(ndwi_used, p)
186
+ stress = (ndwi_used < thr).astype(float)
187
+ stress = np.where(np.isfinite(ndwi_used), stress, np.nan)
188
+
189
+ # ----------------------------
190
+ # Visualisations
191
+ # ----------------------------
192
+ left, right = st.columns([1.2, 1.0], gap="large")
193
+
194
+ with left:
195
+ st.subheader("Overlay stress hydrique sur vraie couleur")
196
+ fig = plt.figure(figsize=(8, 8))
197
+ plt.imshow(rgb)
198
+ plt.imshow(stress, cmap="Reds", alpha=0.40, vmin=0, vmax=1)
199
+ plt.axis("off")
200
+ plt.title(f"Stress = NDWI < p{p} (seuil {thr:.3f}) — {swir_choice}")
201
+ st.pyplot(fig, clear_figure=True)
202
+
203
+ with right:
204
+ st.subheader("Cartes NDWI")
205
+ fig2 = plt.figure(figsize=(9, 4))
206
+ ax1 = plt.subplot(1, 2, 1)
207
+ im1 = ax1.imshow(ndwi_11, cmap="viridis")
208
+ ax1.set_title("NDWI (B8A, B11)")
209
+ ax1.axis("off")
210
+
211
+ ax2 = plt.subplot(1, 2, 2)
212
+ im2 = ax2.imshow(ndwi_12, cmap="viridis")
213
+ ax2.set_title("NDWI (B8A, B12)")
214
+ ax2.axis("off")
215
+
216
+ st.pyplot(fig2, clear_figure=True)
217
+
218
+ st.divider()
219
+ st.write("**Détails**")
220
+ st.write(
221
+ {
222
+ "time_interval": time_interval,
223
+ "bbox_wgs84": [min_lon, min_lat, max_lon, max_lat],
224
+ "size_px": list(size),
225
+ "resolution_m": res_m,
226
+ "p": p,
227
+ "threshold": float(thr),
228
+ "formula_used": swir_choice,
229
+ }
230
+ )