Serg4451D commited on
Commit
175b9c8
·
verified ·
1 Parent(s): 818c773

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +445 -0
app.py ADDED
@@ -0,0 +1,445 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import io
4
+ import json
5
+ import math
6
+ import datetime as dt
7
+ from typing import Optional, Tuple, Dict
8
+
9
+ import requests
10
+ import numpy as np
11
+ import cv2
12
+ from mgrs import MGRS
13
+ from PIL import Image
14
+ import matplotlib
15
+ matplotlib.use("Agg")
16
+ import matplotlib.pyplot as plt
17
+ import gradio as gr
18
+
19
+
20
+ # --------------------------
21
+ # Константы и хелперы
22
+ # --------------------------
23
+ S2_BASE = "https://sentinel-s2-l1c.s3.amazonaws.com/tiles"
24
+ UA = {"User-Agent": "s2-ndvi-gradio-tool/1.0 (+https://example.com)"}
25
+
26
+ # Sentinel-2 тайлы 10м — 10980 px (около 109.8 км), 100 км поле внутри с ~490 px отступом
27
+ TILE_SIZE_10M = 10980
28
+ GRID_100KM_M = 100_000
29
+ MARGIN_PX_10M = (TILE_SIZE_10M - GRID_100KM_M // 10) // 2 # ~490
30
+
31
+
32
+ # --------------------------
33
+ # Геокодирование / парсинг
34
+ # --------------------------
35
+ def parse_latlon(text: str) -> Optional[Tuple[float, float]]:
36
+ if not text:
37
+ return None
38
+ m = re.match(r"^\s*([+-]?\d+(?:\.\d+)?)\s*[,; ]\s*([+-]?\d+(?:\.\d+)?)\s*$", text)
39
+ if not m:
40
+ return None
41
+ lat = float(m.group(1))
42
+ lon = float(m.group(2))
43
+ if not (-90 <= lat <= 90 and -180 <= lon <= 180):
44
+ return None
45
+ return lat, lon
46
+
47
+
48
+ def geocode_nominatim(query: str, lang: str = "ru") -> Optional[Tuple[float, float, str]]:
49
+ url = "https://nominatim.openstreetmap.org/search"
50
+ params = {"q": query, "format": "jsonv2", "limit": 1, "accept-language": lang}
51
+ try:
52
+ r = requests.get(url, params=params, headers=UA, timeout=20)
53
+ if r.status_code != 200:
54
+ return None
55
+ data = r.json()
56
+ if not data:
57
+ return None
58
+ lat = float(data[0]["lat"])
59
+ lon = float(data[0]["lon"])
60
+ display = data[0].get("display_name", query)
61
+ return lat, lon, display
62
+ except Exception:
63
+ return None
64
+
65
+
66
+ # --------------------------
67
+ # MGRS / индексация тайла
68
+ # --------------------------
69
+ def latlon_to_mgrs(lat: float, lon: float) -> Dict[str, any]:
70
+ """
71
+ Возвращает словарь с полями:
72
+ - zone (int), band (str), square (str) — компоненты пути S3
73
+ - easting_m, northing_m — координаты внутри 100 км квадрата (метры)
74
+ - mgrs_str — полный MGRS (с 5+5 цифрами)
75
+ """
76
+ m = MGRS()
77
+ s = m.toMGRS(lat, lon, MGRSPrecision=5) # Пример: '31UDQ4825111980'
78
+ # Найдём границу цифр/букв
79
+ i = 0
80
+ while i < len(s) and s[i].isdigit():
81
+ i += 1
82
+ zone = int(s[:i])
83
+ band = s[i]
84
+ square = s[i + 1:i + 3]
85
+ digits = s[i + 3:]
86
+ e_str = digits[:5]
87
+ n_str = digits[5:10]
88
+ easting_m = int(e_str)
89
+ northing_m = int(n_str)
90
+ return {
91
+ "zone": zone,
92
+ "band": band,
93
+ "square": square,
94
+ "easting_m": easting_m,
95
+ "northing_m": northing_m,
96
+ "mgrs_str": s
97
+ }
98
+
99
+
100
+ def build_s2_tile_prefix(zone: int, band: str, square: str, d: dt.date, seq: int) -> str:
101
+ # В AWS путь вида: /tiles/{utm_zone}/{lat_band}/{grid_square}/{YYYY}/{M}/{D}/{sequence}/
102
+ return f"{S2_BASE}/{zone}/{band}/{square}/{d.year}/{d.month}/{d.day}/{seq}"
103
+
104
+
105
+ def url_band(zone: int, band: str, square: str, d: dt.date, seq: int, band_name: str) -> str:
106
+ # band_name: 'B04', 'B08', 'TCI', 'QA60' и т.д.
107
+ return f"{build_s2_tile_prefix(zone, band, square, d, seq)}/{band_name}.jp2"
108
+
109
+
110
+ def url_preview(zone: int, band: str, square: str, d: dt.date, seq: int) -> str:
111
+ return f"{build_s2_tile_prefix(zone, band, square, d, seq)}/preview.jpg"
112
+
113
+
114
+ def head_exists(url: str) -> bool:
115
+ try:
116
+ r = requests.head(url, headers=UA, timeout=15)
117
+ return r.status_code == 200
118
+ except Exception:
119
+ return False
120
+
121
+
122
+ def find_sequence_with_data(zone: int, band: str, square: str, d: dt.date, test_band="B04") -> Optional[int]:
123
+ # Пробуем seq 0..3 пока найдём объект
124
+ for seq in range(4):
125
+ if head_exists(url_band(zone, band, square, d, seq, test_band)):
126
+ return seq
127
+ return None
128
+
129
+
130
+ # --------------------------
131
+ # Загрузка и декодирование JP2
132
+ # --------------------------
133
+ def http_get(url: str) -> Optional[bytes]:
134
+ try:
135
+ r = requests.get(url, headers=UA, timeout=60)
136
+ if r.status_code != 200:
137
+ return None
138
+ return r.content
139
+ except Exception:
140
+ return None
141
+
142
+
143
+ def decode_jp2_to_np(jp2_bytes: bytes, flags=cv2.IMREAD_UNCHANGED):
144
+ # Возвращает numpy массив (H, W) или (H, W, C); 16-bit для каналов, 8/16-bit для TCI
145
+ arr = np.frombuffer(jp2_bytes, dtype=np.uint8)
146
+ img = cv2.imdecode(arr, flags)
147
+ return img
148
+
149
+
150
+ # --------------------------
151
+ # Геометрия ROI внутри тайла
152
+ # --------------------------
153
+ def pixel_from_mgrs(easting_m: int, northing_m: int) -> Tuple[int, int]:
154
+ """
155
+ Перевод ��оложения внутри 100 км квадрата (метры от юго-западного угла)
156
+ в пиксели 10 м данных тайла (с учётом ~490 px отступа).
157
+ Возвращает (col_x, row_y).
158
+ """
159
+ x_px = MARGIN_PX_10M + int(round(easting_m / 10.0))
160
+ # y: сверху 0, внутри квадрата от южной границы -> переворачиваем
161
+ y_px = MARGIN_PX_10M + int(round((GRID_100KM_M - northing_m) / 10.0))
162
+ # страхуемся в пределах допустимого
163
+ x_px = max(0, min(TILE_SIZE_10M - 1, x_px))
164
+ y_px = max(0, min(TILE_SIZE_10M - 1, y_px))
165
+ return x_px, y_px
166
+
167
+
168
+ def crop_roi(img: np.ndarray, cx: int, cy: int, rad_px: int) -> np.ndarray:
169
+ h, w = img.shape[:2]
170
+ x0 = max(0, cx - rad_px)
171
+ x1 = min(w, cx + rad_px)
172
+ y0 = max(0, cy - rad_px)
173
+ y1 = min(h, cy + rad_px)
174
+ return img[y0:y1, x0:x1].copy(), (x0, y0, x1, y1)
175
+
176
+
177
+ # --------------------------
178
+ # NDVI и отрисовка
179
+ # --------------------------
180
+ def compute_ndvi(b08_10m: np.ndarray, b04_10m: np.ndarray, qa60_10m: Optional[np.ndarray] = None) -> Tuple[np.ndarray, Optional[np.ndarray]]:
181
+ b08 = b08_10m.astype(np.float32)
182
+ b04 = b04_10m.astype(np.float32)
183
+ denom = (b08 + b04)
184
+ ndvi = np.where(denom > 0, (b08 - b04) / denom, np.nan)
185
+
186
+ cloud_mask = None
187
+ if qa60_10m is not None:
188
+ # QA60: биты 10 (opaque clouds) и 11 (cirrus)
189
+ qa = qa60_10m.astype(np.uint16)
190
+ clouds = (((qa >> 10) & 1) | ((qa >> 11) & 1)) > 0
191
+ ndvi = np.where(clouds, np.nan, ndvi)
192
+ cloud_mask = clouds
193
+ return ndvi, cloud_mask
194
+
195
+
196
+ def colorize_ndvi(ndvi: np.ndarray, cloud_mask: Optional[np.ndarray] = None,
197
+ vmin: float = -0.2, vmax: float = 0.9) -> np.ndarray:
198
+ x = ndvi.copy()
199
+ # Отдельно отметим NaN (облака/нет данных)
200
+ nan_mask = np.isnan(x)
201
+ x = np.clip(x, vmin, vmax)
202
+ norm = (x - vmin) / (vmax - vmin + 1e-9)
203
+ cmap = plt.get_cmap("RdYlGn") # красный->желтый->зеленый
204
+ rgb = (cmap(norm)[..., :3] * 255).astype(np.uint8)
205
+
206
+ # Заштрихуем NaN серым
207
+ if cloud_mask is not None:
208
+ nan_mask = nan_mask | cloud_mask
209
+ rgb[nan_mask] = np.array([180, 180, 180], dtype=np.uint8)
210
+ return rgb
211
+
212
+
213
+ def stretch_16_to_8(img: np.ndarray, p_low=2, p_high=98) -> np.ndarray:
214
+ # Клип и линейная растяжка для лучшего контраста
215
+ if img.ndim == 2:
216
+ lo, hi = np.percentile(img[img > 0], [p_low, p_high]) if np.any(img > 0) else (0, 1)
217
+ hi = max(hi, lo + 1)
218
+ out = (np.clip(img, lo, hi) - lo) / (hi - lo + 1e-6)
219
+ return (out * 255).astype(np.uint8)
220
+ else:
221
+ out = np.zeros_like(img, dtype=np.uint8)
222
+ for c in range(img.shape[2]):
223
+ chan = img[..., c]
224
+ if np.any(chan > 0):
225
+ lo, hi = np.percentile(chan[chan > 0], [p_low, p_high])
226
+ hi = max(hi, lo + 1)
227
+ out[..., c] = ((np.clip(chan, lo, hi) - lo) / (hi - lo + 1e-6) * 255).astype(np.uint8)
228
+ return out
229
+
230
+
231
+ # --------------------------
232
+ # Основная логика анализа
233
+ # --------------------------
234
+ def analyze_vegetation(
235
+ address_or_coords: str,
236
+ date_str: str,
237
+ radius_m: int,
238
+ ) -> Tuple[np.ndarray, np.ndarray, str]:
239
+ # 1) Получаем координаты
240
+ latlon = parse_latlon(address_or_coords.strip())
241
+ resolved_label = None
242
+ if latlon is None:
243
+ geo = geocode_nominatim(address_or_coords.strip())
244
+ if geo is None:
245
+ raise gr.Error("Не удалось геокодировать адрес или распознать координаты. Попробуйте формат '55.75, 37.61' или уточните адрес.")
246
+ lat, lon, resolved_label = geo
247
+ else:
248
+ lat, lon = latlon
249
+ resolved_label = f"Координаты: {lat:.6f}, {lon:.6f}"
250
+
251
+ # 2) MGRS тайл + положение
252
+ tile = latlon_to_mgrs(lat, lon)
253
+ zone = tile["zone"]
254
+ band = tile["band"]
255
+ square = tile["square"]
256
+ easting_m = tile["easting_m"]
257
+ northing_m = tile["northing_m"]
258
+ mgrs_str = tile["mgrs_str"]
259
+
260
+ cx, cy = pixel_from_mgrs(easting_m, northing_m)
261
+ rad_px = max(5, int(radius_m / 10)) # 10 м/пикс
262
+
263
+ # 3) Дата и последовательность
264
+ if date_str:
265
+ try:
266
+ d = dt.datetime.strptime(date_str.strip(), "%Y-%m-%d").date()
267
+ except Exception:
268
+ raise gr.Error("Дата должна быть в формате ГГГГ-ММ-ДД (например, 2024-07-15).")
269
+ else:
270
+ d = dt.date.today()
271
+
272
+ seq = find_sequence_with_data(zone, band, square, d, test_band="B04")
273
+ if seq is None:
274
+ # Попробуем отмотать назад до 10 дней
275
+ found = False
276
+ for back in range(1, 11):
277
+ dd = d - dt.timedelta(days=back)
278
+ seq_try = find_sequence_with_data(zone, band, square, dd, test_band="B04")
279
+ if seq_try is not None:
280
+ d = dd
281
+ seq = seq_try
282
+ found = True
283
+ break
284
+ if not found:
285
+ raise gr.Error("Не нашёл подходящих сцен Sentinel-2 для этой локации и последних 10 дней.")
286
+
287
+ # 4) Скачиваем каналы: B04 (red), B08 (nir), QA60 (облака), TCI (true color) либо B02/B03/B04
288
+ url_b04 = url_band(zone, band, square, d, seq, "B04")
289
+ url_b08 = url_band(zone, band, square, d, seq, "B08")
290
+ url_qa60 = url_band(zone, band, square, d, seq, "QA60")
291
+ url_tci = url_band(zone, band, square, d, seq, "TCI")
292
+
293
+ b04_bytes = http_get(url_b04)
294
+ b08_bytes = http_get(url_b08)
295
+ if b04_bytes is None or b08_bytes is None:
296
+ raise gr.Error("Не удалось скачать каналы B04/B08. Попробуйте другую дату.")
297
+
298
+ qa60_bytes = http_get(url_qa60) # может быть None
299
+ tci_bytes = http_get(url_tci) # может быть None
300
+
301
+ # 5) Декодирование
302
+ b04 = decode_jp2_to_np(b04_bytes, cv2.IMREAD_UNCHANGED) # (10980, 10980), uint16
303
+ b08 = decode_jp2_to_np(b08_bytes, cv2.IMREAD_UNCHANGED)
304
+
305
+ if b04 is None or b08 is None or b04.shape != b08.shape:
306
+ raise gr.Error("Ошибка чтения JP2 или несовпадение размеров каналов B04/B08.")
307
+
308
+ # QA60 обычно 60м (1830x1830) — апсемплим до 10м
309
+ qa10m = None
310
+ cloud_pct_roi = None
311
+ if qa60_bytes is not None:
312
+ qa = decode_jp2_to_np(qa60_bytes, cv2.IMREAD_UNCHANGED)
313
+ if qa is not None:
314
+ qa10m = cv2.resize(qa, (TILE_SIZE_10M, TILE_SIZE_10M), interpolation=cv2.INTER_NEAREST)
315
+
316
+ # 6) Кадрируем ROI
317
+ b04_roi, (x0, y0, x1, y1) = crop_roi(b04, cx, cy, rad_px)
318
+ b08_roi, _ = crop_roi(b08, cx, cy, rad_px)
319
+ qa_roi = None
320
+ if qa10m is not None:
321
+ qa_roi, _ = crop_roi(qa10m, cx, cy, rad_px)
322
+
323
+ # 7) NDVI + маска облаков
324
+ ndvi_roi, cloud_mask = compute_ndvi(b08_roi, b04_roi, qa_roi)
325
+
326
+ # 8) Статистика
327
+ valid = ~np.isnan(ndvi_roi)
328
+ if np.any(valid):
329
+ mean_ndvi = float(np.nanmean(ndvi_roi))
330
+ med_ndvi = float(np.nanmedian(ndvi_roi))
331
+ pct_veg_good = float(np.nanmean(ndvi_roi > 0.5) * 100.0)
332
+ pct_veg_med = float(np.nanmean((ndvi_roi > 0.3) & (ndvi_roi <= 0.5)) * 100.0)
333
+ else:
334
+ mean_ndvi = float("nan")
335
+ med_ndvi = float("nan")
336
+ pct_veg_good = 0.0
337
+ pct_veg_med = 0.0
338
+
339
+ if cloud_mask is not None:
340
+ cloud_pct_roi = float(np.mean(cloud_mask) * 100.0)
341
+ else:
342
+ cloud_pct_roi = None
343
+
344
+ # 9) Рендер NDVI
345
+ ndvi_rgb = colorize_ndvi(ndvi_roi, cloud_mask)
346
+
347
+ # 10) True color ROI
348
+ # a) Сначала пытаемся TCI
349
+ tci_roi_rgb = None
350
+ if tci_bytes is not None:
351
+ tci = decode_jp2_to_np(tci_bytes, cv2.IMREAD_UNCHANGED)
352
+ if tci is not None:
353
+ if tci.ndim == 3:
354
+ # cv2 читает BGR — переведём в RGB
355
+ if tci.shape[2] == 3:
356
+ tci = cv2.cvtColor(tci, cv2.COLOR_BGR2RGB)
357
+ tci_roi, _ = crop_roi(tci, cx, cy, rad_px)
358
+ tci_roi_rgb = stretch_16_to_8(tci_roi)
359
+ else:
360
+ # бывает моно — не используем
361
+ tci_roi_rgb = None
362
+
363
+ # b) Если TCI нет — соберём из B02/B03/B04
364
+ if tci_roi_rgb is None:
365
+ url_b02 = url_band(zone, band, square, d, seq, "B02")
366
+ url_b03 = url_band(zone, band, square, d, seq, "B03")
367
+ b02_bytes = http_get(url_b02)
368
+ b03_bytes = http_get(url_b03)
369
+ if b02_bytes is not None and b03_bytes is not None:
370
+ b02 = decode_jp2_to_np(b02_bytes, cv2.IMREAD_UNCHANGED)
371
+ b03 = decode_jp2_to_np(b03_bytes, cv2.IMREAD_UNCHANGED)
372
+ if b02 is not None and b03 is not None and b02.shape == b03.shape == b04.shape:
373
+ b02_roi, _ = crop_roi(b02, cx, cy, rad_px)
374
+ b03_roi, _ = crop_roi(b03, cx, cy, rad_px)
375
+ rgb16 = np.dstack([b04_roi, b03_roi, b02_roi])
376
+ tci_roi_rgb = stretch_16_to_8(rgb16)
377
+ # если не получилось — сделаем псевдоцвет из B08/B04
378
+ if tci_roi_rgb is None:
379
+ pseudo = np.dstack([b08_roi, b04_roi, b04_roi])
380
+ tci_roi_rgb = stretch_16_to_8(pseudo)
381
+
382
+ # 11) Текстовый вывод/диагностика
383
+ url_used_preview = url_preview(zone, band, square, d, seq)
384
+ info_lines = []
385
+ info_lines.append(f"Адрес/точка: {resolved_label}")
386
+ info_lines.append(f"MGRS тайл: {mgrs_str} (zone={zone}, band={band}, square={square})")
387
+ info_lines.append(f"Дата съёмки: {d.isoformat()} (seq={seq})")
388
+ if cloud_pct_roi is not None:
389
+ info_lines.append(f"Облака в ROI: {cloud_pct_roi:.1f}%")
390
+ if not np.isnan(mean_ndvi):
391
+ info_lines.append(f"Средний NDVI (ROI): {mean_ndvi:.3f}")
392
+ info_lines.append(f"Медианный NDVI (ROI): {med_ndvi:.3f}")
393
+ info_lines.append(f"Доля пикселей NDVI>0.5: {pct_veg_good:.1f}%")
394
+ info_lines.append(f"Доля пикселей 0.3<NDVI≤0.5: {pct_veg_med:.1f}%")
395
+ # Грубая классификация
396
+ if mean_ndvi < 0.25:
397
+ concl = "Низкая растительность/стресс или открытая почва."
398
+ elif mean_ndvi < 0.45:
399
+ concl = "Умеренная растительность."
400
+ else:
401
+ concl = "Хорошее состояние растительности."
402
+ info_lines.append(f"Вывод: {concl}")
403
+ else:
404
+ info_lines.append("Недостаточно валидных пикселей для оценки (возможно, облака).")
405
+
406
+ info_lines.append("")
407
+ info_lines.append("Служебное:")
408
+ info_lines.append(f"Пример ссылки на предпросмотр: {url_used_preview}")
409
+
410
+ summary_md = " \n".join(info_lines)
411
+
412
+ # 12) Возвращаем изображения как numpy (RGB) + текст
413
+ return tci_roi_rgb, ndvi_rgb, summary_md
414
+
415
+
416
+ # --------------------------
417
+ # Gradio UI
418
+ # --------------------------
419
+ with gr.Blocks(title="Sentinel-2 NDVI по адресу") as demo:
420
+ gr.Markdown(
421
+ """
422
+ # NDVI по адресу/координатам (Sentinel-2 L1C, AWS)
423
+ Введите адрес (или координаты в формате `lat, lon`), при желании дату и радиус.
424
+ Инструмент скачивает данные напрямую из публичного бакета AWS без ключей.
425
+ """
426
+ )
427
+ with gr.Row():
428
+ address_in = gr.Textbox(label="Адрес или координаты (lat, lon)", placeholder="Например: Москва, Тверская 1 или 55.75, 37.61", lines=1)
429
+ date_in = gr.Textbox(label="Дата (ГГГГ-ММ-ДД) — опционально", placeholder="например: 2025-07-01 (пусто = сегодня/последние дни)")
430
+ radius_in = gr.Slider(label="Радиус области интереса (м)", minimum=100, maximum=5000, step=50, value=1000)
431
+
432
+ run_btn = gr.Button("Анализировать NDVI")
433
+ with gr.Row():
434
+ tci_out = gr.Image(label="True Color (ROI)", type="numpy")
435
+ ndvi_out = gr.Image(label="NDVI (ROI)", type="numpy")
436
+ summary_out = gr.Markdown()
437
+
438
+ def on_click(addr, dstr, rad):
439
+ return analyze_vegetation(addr, dstr, int(rad))
440
+
441
+ run_btn.click(fn=on_click, inputs=[address_in, date_in, radius_in], outputs=[tci_out, ndvi_out, summary_out])
442
+
443
+ if __name__ == "__main__":
444
+ # Запуск локально: http://127.0.0.1:7860
445
+ demo.launch()