prateeksharmacoder commited on
Commit
d5ae627
·
1 Parent(s): ffe942e

Initial TerraVision ZeroGPU deployment

Browse files
Files changed (4) hide show
  1. .gitignore +11 -0
  2. README.md +64 -7
  3. app.py +1188 -0
  4. requirements.txt +26 -0
.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Downloaded model weights (fetched at runtime via mlstac.download)
2
+ model/
3
+
4
+ # TerraVision temporary GeoTIFF output directories
5
+ terravision_*/
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.py[cod]
10
+ *.egg-info/
11
+ .venv/
README.md CHANGED
@@ -1,13 +1,70 @@
1
  ---
2
- title: Terravision
3
- emoji: 👁
4
- colorFrom: pink
5
- colorTo: gray
6
  sdk: gradio
7
- sdk_version: 6.27.0
8
- python_version: '3.12'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: TerraVision
3
+ emoji: 🌍
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: gradio
7
+ sdk_version: "5"
 
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
11
  ---
12
 
13
+ # 🌍 TerraVision — Sentinel-2 Super Resolution & Analysis
14
+
15
+ **Sharper Earth. Brighter Decisions.**
16
+
17
+ TerraVision performs 4× satellite super-resolution on Sentinel-2 L2A imagery
18
+ using the ESA **LDSR-S2** latent diffusion model and **SEN2SR**, upscaling from
19
+ 10 m to 2.5 m spatial resolution across all 10 bands.
20
+
21
+ ---
22
+
23
+ ## How to Use
24
+
25
+ 1. **Click on the map** to select any location on Earth, or type coordinates manually.
26
+ 2. Set a **date range** (a few days is enough; the pipeline picks the first available image).
27
+ 3. Click **🚀 Generate Super-Resolution**.
28
+ 4. Explore the analysis layers and download the georeferenced GeoTIFF ZIP.
29
+
30
+ ---
31
+
32
+ ## Output Layers
33
+
34
+ | Layer | Description |
35
+ |---|---|
36
+ | **RGB** | True-colour super-resolved composite |
37
+ | **False Color / NIR** | NIR–Red–Green (vegetation highlighting) |
38
+ | **SWIR Composite** | SWIR2–SWIR1–Red (geology & burn scars) |
39
+ | **NDVI** | Normalised Difference Vegetation Index |
40
+ | **NDWI** | Normalised Difference Water Index |
41
+ | **NDBI** | Normalised Difference Built-up Index |
42
+ | **NBR** | Normalised Burn Ratio |
43
+ | **LDSR-S2 Uncertainty** | Pixel-wise model uncertainty from the diffusion ensemble |
44
+
45
+ All layers are exported as **georeferenced GeoTIFF** files (EPSG as acquired)
46
+ and bundled into a single ZIP download.
47
+
48
+ ---
49
+
50
+ ## Models
51
+
52
+ - **LDSR-S2** — ESA / tacofoundation latent diffusion SR for RGB+NIR bands
53
+ Weights: [`tacofoundation/RS-SR-LTDF`](https://huggingface.co/tacofoundation/RS-SR-LTDF)
54
+ - **SEN2SR** — Swin Transformer SR for 20 m Sentinel-2 bands guided by LDSR-S2 output
55
+
56
+ ---
57
+
58
+ ## Technical Details
59
+
60
+ - Input: 10-band Sentinel-2 L2A at 128 × 128 pixels (10 m native resolution)
61
+ - Output: 10-band SR product at 512 × 512 pixels (2.5 m effective resolution)
62
+ - Uncertainty: 5-sample diffusion ensemble (`n_variations=5`, `sampling_steps=50`)
63
+ - Data source: [cubo](https://github.com/ESDS-Leipzig/cubo) → Microsoft Planetary Computer STAC
64
+
65
+ ---
66
+
67
+ ## Acknowledgements
68
+
69
+ Built on top of [OpenSR](https://github.com/ESA-PhiLab/OpenSR) and
70
+ [SEN2SR](https://github.com/ESA-PhiLab/SEN2SR) by ESA Phi-Lab / ESDS Leipzig.
app.py ADDED
@@ -0,0 +1,1188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ TerraVision — Sentinel-2 Super Resolution & Analysis
4
+ Hugging Face ZeroGPU Gradio Space
5
+ """
6
+
7
+ # ============================================================
8
+ # IMPORT spaces FIRST — must precede torch / any CUDA import
9
+ # ============================================================
10
+ import spaces
11
+
12
+ # ============================================================
13
+ # STANDARD IMPORTS
14
+ # ============================================================
15
+ import os
16
+ import tempfile
17
+ import zipfile
18
+
19
+ import mlstac
20
+ import torch
21
+ import cubo
22
+ import numpy as np
23
+ import rasterio
24
+ import sen2sr
25
+ import requests
26
+ from datetime import datetime
27
+ import gradio as gr
28
+ from PIL import Image
29
+
30
+ # ============================================================
31
+ # ZEROGPU DURATION CONFIGURATION
32
+ # Increase this value if the Space account / hardware allows.
33
+ # Free-tier ZeroGPU can reject requests with very large durations.
34
+ # ============================================================
35
+ GPU_DURATION = 120 # seconds
36
+
37
+ # ============================================================
38
+ # DEVICE
39
+ # ZeroGPU handles CUDA emulation at module level.
40
+ # ============================================================
41
+ device = torch.device("cuda")
42
+
43
+ # ============================================================
44
+ # MODEL DOWNLOAD & LOADING
45
+ # Must be at module scope, outside @spaces.GPU.
46
+ # ZeroGPU handles weight migration automatically.
47
+ # ============================================================
48
+ if not os.path.isfile("model/LDSRS2-SEN2SR/mlm.json"):
49
+ mlstac.download(
50
+ file="https://huggingface.co/tacofoundation/RS-SR-LTDF/resolve/main/main/mlm.json",
51
+ output_dir="model/LDSRS2-SEN2SR/"
52
+ )
53
+
54
+ model = mlstac.load("model/LDSRS2-SEN2SR/").compiled_model(device=device)
55
+ model = model.to(device)
56
+
57
+ # ============================================================
58
+ # HELPER: SAVE TENSOR AS GEOTIFF
59
+ # ============================================================
60
+
61
+ def save_tensor_as_geotiff(tensor, attrs, out_path, super_resolved=False, sr_factor=4):
62
+ """
63
+ Save a PyTorch tensor as a georeferenced GeoTIFF using metadata in attrs.
64
+
65
+ Parameters:
66
+ tensor (torch.Tensor or np.ndarray): shape (bands, H, W), values in 0-1.
67
+ attrs (dict): Metadata from LR image (.attrs).
68
+ out_path (str): Output file path (.tif).
69
+ super_resolved (bool): If True, assumes image is SR upscaled by sr_factor.
70
+ sr_factor (int): SR upscale factor.
71
+ """
72
+ if hasattr(tensor, "cpu"):
73
+ tensor = tensor.cpu().numpy()
74
+
75
+ # Guard against NaN / Inf before uint16 conversion
76
+ tensor = np.nan_to_num(tensor, nan=0.0, posinf=1.0, neginf=0.0)
77
+
78
+ # Scale and clip
79
+ arr = (tensor * 10000).clip(0, 10000).astype(np.uint16)
80
+
81
+ # Original georef info
82
+ pixel_size = attrs["resolution"]
83
+ edge_size = attrs["edge_size"]
84
+ central_x = attrs["central_x"]
85
+ central_y = attrs["central_y"]
86
+ epsg = attrs["epsg"]
87
+
88
+ # Bounding box remains the same
89
+ total_extent = edge_size * pixel_size
90
+ half_extent = total_extent / 2
91
+ ul_x = central_x - half_extent
92
+ ul_y = central_y + half_extent
93
+
94
+ # If SR, update pixel size only (dimensions are already upsampled)
95
+ if super_resolved:
96
+ pixel_size = pixel_size / sr_factor
97
+
98
+ # Define geotransform
99
+ transform = rasterio.transform.from_origin(ul_x, ul_y, pixel_size, pixel_size)
100
+
101
+ # Save
102
+ with rasterio.open(
103
+ out_path,
104
+ "w",
105
+ driver="GTiff",
106
+ height=arr.shape[1],
107
+ width=arr.shape[2],
108
+ count=arr.shape[0],
109
+ dtype=arr.dtype,
110
+ crs=f"EPSG:{epsg}",
111
+ transform=transform,
112
+ ) as dst:
113
+ dst.write(arr)
114
+
115
+
116
+ # ============================================================
117
+ # MAP CONFIGURATION
118
+ # ============================================================
119
+
120
+ DEFAULT_LAT = 39.39785676571274
121
+ DEFAULT_LON = -0.3798517619438821
122
+
123
+ LEAFLET_HEAD = """
124
+ <link
125
+ rel="stylesheet"
126
+ href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
127
+ />
128
+
129
+ <script
130
+ src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js">
131
+ </script>
132
+ """
133
+
134
+
135
+ # ============================================================
136
+ # MAP JAVASCRIPT
137
+ # ============================================================
138
+
139
+ MAP_JS = """
140
+ // ============================================================
141
+ // TERRAVISION LEAFLET MAP
142
+ // ============================================================
143
+
144
+ const mapDiv = document.getElementById("terrativision-map");
145
+
146
+ if (!mapDiv) {
147
+
148
+ console.error("❌ TerraVision map container not found.");
149
+
150
+ } else if (mapDiv._terraVisionMapInitialized) {
151
+
152
+ console.log("ℹ️ TerraVision map already initialized.");
153
+
154
+ } else {
155
+
156
+ mapDiv._terraVisionMapInitialized = true;
157
+
158
+ const defaultLat = __DEFAULT_LAT__;
159
+ const defaultLon = __DEFAULT_LON__;
160
+
161
+ // --------------------------------------------------------
162
+ // CREATE MAP
163
+ // --------------------------------------------------------
164
+
165
+ const map = L.map(mapDiv).setView(
166
+ [defaultLat, defaultLon],
167
+ 10
168
+ );
169
+
170
+ // --------------------------------------------------------
171
+ // STREET MAP
172
+ // --------------------------------------------------------
173
+
174
+ const osmLayer = L.tileLayer(
175
+ "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
176
+ {
177
+ maxZoom: 19,
178
+ attribution: "&copy; OpenStreetMap contributors"
179
+ }
180
+ ).addTo(map);
181
+
182
+ // --------------------------------------------------------
183
+ // SATELLITE MAP
184
+ // --------------------------------------------------------
185
+
186
+ const satelliteLayer = L.tileLayer(
187
+ "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
188
+ {
189
+ maxZoom: 19,
190
+ attribution: "Tiles &copy; Esri"
191
+ }
192
+ );
193
+
194
+ // --------------------------------------------------------
195
+ // LAYER CONTROL
196
+ // --------------------------------------------------------
197
+
198
+ L.control.layers(
199
+ {
200
+ "Street Map": osmLayer,
201
+ "Satellite": satelliteLayer
202
+ }
203
+ ).addTo(map);
204
+
205
+ // --------------------------------------------------------
206
+ // MARKER
207
+ // --------------------------------------------------------
208
+
209
+ let marker = L.marker(
210
+ [defaultLat, defaultLon]
211
+ ).addTo(map);
212
+
213
+ // --------------------------------------------------------
214
+ // LOCATION DISPLAY
215
+ // --------------------------------------------------------
216
+
217
+ const locationDisplay =
218
+ document.querySelector("#map-location-display");
219
+
220
+ function updateLocationDisplay(lat, lon) {
221
+
222
+ if (locationDisplay) {
223
+
224
+ locationDisplay.innerHTML =
225
+ "📍 <b>Selected:</b> " +
226
+ lat.toFixed(8) +
227
+ ", " +
228
+ lon.toFixed(8);
229
+ }
230
+ }
231
+
232
+ updateLocationDisplay(
233
+ defaultLat,
234
+ defaultLon
235
+ );
236
+
237
+ marker.bindPopup(
238
+ "<b>Selected Location</b><br>" +
239
+ defaultLat.toFixed(8) +
240
+ ", " +
241
+ defaultLon.toFixed(8)
242
+ );
243
+
244
+ // --------------------------------------------------------
245
+ // MAP CLICK
246
+ // --------------------------------------------------------
247
+
248
+ map.on("click", function(e) {
249
+
250
+ const lat = e.latlng.lat;
251
+ const lon = e.latlng.lng;
252
+
253
+ console.log(
254
+ "📍 TerraVision selected:",
255
+ lat,
256
+ lon
257
+ );
258
+
259
+ // Move marker
260
+ marker.setLatLng([lat, lon]);
261
+
262
+ // Update popup
263
+ marker.bindPopup(
264
+ "<b>Selected Location</b><br>" +
265
+ lat.toFixed(8) +
266
+ ", " +
267
+ lon.toFixed(8)
268
+ ).openPopup();
269
+
270
+ // Update visible selected text
271
+ updateLocationDisplay(lat, lon);
272
+
273
+ // --------------------------------------------------------
274
+ // UPDATE GRADIO LATITUDE / LONGITUDE
275
+ // --------------------------------------------------------
276
+
277
+ function updateGradioNumber(elemId, value) {
278
+
279
+ const container = document.getElementById(elemId);
280
+
281
+ if (!container) {
282
+ console.error(
283
+ "❌ Gradio component not found:",
284
+ elemId
285
+ );
286
+ return;
287
+ }
288
+
289
+ const input = container.querySelector("input");
290
+
291
+ if (!input) {
292
+ console.error(
293
+ "❌ Input element not found:",
294
+ elemId
295
+ );
296
+ return;
297
+ }
298
+
299
+ // Use the native HTML value setter.
300
+ // This is important because Gradio uses a controlled input.
301
+ const nativeSetter =
302
+ Object.getOwnPropertyDescriptor(
303
+ HTMLInputElement.prototype,
304
+ "value"
305
+ ).set;
306
+
307
+ nativeSetter.call(
308
+ input,
309
+ String(value)
310
+ );
311
+
312
+ // Tell Gradio that the user changed the input
313
+ input.dispatchEvent(
314
+ new Event("input", {
315
+ bubbles: true
316
+ })
317
+ );
318
+
319
+ input.dispatchEvent(
320
+ new Event("change", {
321
+ bubbles: true
322
+ })
323
+ );
324
+
325
+ console.log(
326
+ "✅ Updated Gradio:",
327
+ elemId,
328
+ value
329
+ );
330
+ }
331
+
332
+ updateGradioNumber(
333
+ "latitude_input",
334
+ lat
335
+ );
336
+
337
+ updateGradioNumber(
338
+ "longitude_input",
339
+ lon
340
+ );
341
+
342
+ });
343
+
344
+ // --------------------------------------------------------
345
+ // FIX MAP SIZE
346
+ // --------------------------------------------------------
347
+
348
+ setTimeout(function() {
349
+
350
+ map.invalidateSize();
351
+
352
+ }, 500);
353
+
354
+ // Store map reference
355
+ element._terraVisionMap = map;
356
+
357
+ console.log(
358
+ "✅ TerraVision Leaflet map initialized successfully."
359
+ );
360
+ }
361
+ """
362
+
363
+ # Insert Python values without using an f-string
364
+ MAP_JS = MAP_JS.replace(
365
+ "__DEFAULT_LAT__",
366
+ str(DEFAULT_LAT)
367
+ ).replace(
368
+ "__DEFAULT_LON__",
369
+ str(DEFAULT_LON)
370
+ )
371
+
372
+
373
+ # ============================================================
374
+ # CUSTOM CSS
375
+ # ============================================================
376
+
377
+ custom_css = """
378
+
379
+ #comparison_slider {
380
+ width: 100% !important;
381
+ max-width: 560px !important;
382
+ margin-left: auto !important;
383
+ margin-right: auto !important;
384
+ }
385
+
386
+ #comparison_slider img {
387
+ object-fit: contain !important;
388
+ }
389
+
390
+ .analysis-image {
391
+ width: 100% !important;
392
+ }
393
+
394
+
395
+ /* ----------------------------------------------------------
396
+ MAP
397
+ ---------------------------------------------------------- */
398
+
399
+ #terrativision-map {
400
+ width: 100%;
401
+ height: 400px;
402
+ border-radius: 14px;
403
+ overflow: hidden;
404
+ border: 1px solid rgba(128,128,128,0.35);
405
+ }
406
+
407
+ #map-location-display {
408
+ margin-top: 8px;
409
+ padding: 8px 12px;
410
+ border-radius: 8px;
411
+ background: rgba(128,128,128,0.10);
412
+ font-size: 13px;
413
+ text-align: center;
414
+ }
415
+ """
416
+
417
+ # ============================================================
418
+ # IMAGE SANITIZATION HELPERS
419
+ # ============================================================
420
+
421
+ def clean_rgb(image):
422
+ image = np.asarray(image, dtype=np.float32)
423
+
424
+ # Remove NaN / Inf
425
+ image = np.nan_to_num(
426
+ image,
427
+ nan=0.0,
428
+ posinf=1.0,
429
+ neginf=0.0
430
+ )
431
+
432
+ # Keep normalized RGB range
433
+ image = np.clip(image, 0.0, 1.0)
434
+
435
+ # Convert to uint8 for Gradio
436
+ return (image * 255.0).round().astype(np.uint8)
437
+
438
+
439
+ def clean_index(image):
440
+ image = np.asarray(image, dtype=np.float32)
441
+
442
+ # Remove NaN / Inf
443
+ image = np.nan_to_num(
444
+ image,
445
+ nan=0.0,
446
+ posinf=1.0,
447
+ neginf=-1.0
448
+ )
449
+
450
+ # NDVI / NDWI / NDBI / NBR range
451
+ image = np.clip(image, -1.0, 1.0)
452
+
453
+ # Convert [-1, 1] -> [0, 255]
454
+ image = (image + 1.0) / 2.0
455
+
456
+ return (image * 255.0).round().astype(np.uint8)
457
+
458
+
459
+ def clean_uncertainty(image):
460
+ image = np.asarray(image, dtype=np.float32)
461
+
462
+ # Remove invalid values
463
+ image = np.nan_to_num(
464
+ image,
465
+ nan=0.0,
466
+ posinf=0.0,
467
+ neginf=0.0
468
+ )
469
+
470
+ # Find useful range of actual uncertainty values
471
+ valid = image[np.isfinite(image)]
472
+
473
+ if valid.size == 0:
474
+ return np.zeros(image.shape, dtype=np.uint8)
475
+
476
+ # Contrast stretching using percentiles
477
+ low = np.percentile(valid, 2)
478
+ high = np.percentile(valid, 98)
479
+
480
+ # Prevent divide-by-zero
481
+ if high <= low:
482
+ high = low + 1e-6
483
+
484
+ # Stretch actual uncertainty range to 0-1
485
+ image = (image - low) / (high - low)
486
+
487
+ image = np.clip(image, 0.0, 1.0)
488
+
489
+ # Convert to display image
490
+ return (image * 255.0).round().astype(np.uint8)
491
+
492
+
493
+ # ============================================================
494
+ # MAIN PROCESSING PIPELINE
495
+ # Called from inside @spaces.GPU so all GPU ops run there.
496
+ # ============================================================
497
+
498
+ def generate_satellite_products(
499
+ latitude,
500
+ longitude,
501
+ start_date,
502
+ end_date
503
+ ):
504
+ """
505
+ Complete TerraVision processing pipeline:
506
+
507
+ Location + dates
508
+ |
509
+ Sentinel-2 L2A
510
+ |
511
+ ESA LDSR-S2 + SEN2SR
512
+ |
513
+ 10-band 2.5m SR
514
+ |
515
+ Analysis layers
516
+ |
517
+ Preview images + GeoTIFFs
518
+ """
519
+
520
+ print("========================================")
521
+ print("Starting satellite processing")
522
+ print("========================================")
523
+
524
+ # --------------------------------------------------
525
+ # 1. Get Sentinel-2 L2A data
526
+ # --------------------------------------------------
527
+
528
+ print("\n[1/6] Fetching Sentinel-2 L2A...")
529
+
530
+ edge_size = 128
531
+
532
+ da_new = cubo.create(
533
+ lat=float(latitude),
534
+ lon=float(longitude),
535
+ collection="sentinel-2-l2a",
536
+ bands=[
537
+ "B02", "B03", "B04", "B05", "B06",
538
+ "B07", "B08", "B8A", "B11", "B12"
539
+ ],
540
+ start_date=str(start_date),
541
+ end_date=str(end_date),
542
+ edge_size=edge_size,
543
+ resolution=10
544
+ )
545
+
546
+ if da_new.sizes.get("time", 0) == 0:
547
+ raise ValueError(
548
+ "No Sentinel-2 image was found for the selected "
549
+ "location and date range."
550
+ )
551
+
552
+ print("Available images:", da_new.sizes.get("time", 0))
553
+
554
+ # For the first prototype, use the first available image.
555
+ image_index = 0
556
+
557
+ # --------------------------------------------------
558
+ # 2. Prepare 10-band input
559
+ # --------------------------------------------------
560
+
561
+ print("\n[2/6] Preparing 10-band input...")
562
+
563
+ original_numpy = (
564
+ da_new[image_index]
565
+ .compute()
566
+ .to_numpy()
567
+ ).astype("float32")
568
+
569
+ low_res = torch.from_numpy(original_numpy).float()
570
+
571
+ # Sentinel-2 reflectance scaling
572
+ low_res = low_res / 10_000
573
+
574
+ # Original Sentinel-2 RGB (10 m)
575
+ original_rgb = np.stack([
576
+ original_numpy[2], # B04 - Red
577
+ original_numpy[1], # B03 - Green
578
+ original_numpy[0], # B02 - Blue
579
+ ], axis=-1)
580
+
581
+ low_res = low_res.to(device)
582
+
583
+ print("Input:", tuple(low_res.shape))
584
+
585
+ # --------------------------------------------------
586
+ # 3. ESA LDSR-S2 + SEN2SR [GPU]
587
+ # --------------------------------------------------
588
+
589
+ print("\n[3/6] Running ESA super-resolution...")
590
+
591
+ with torch.inference_mode():
592
+
593
+ sr_tensor = sen2sr.predict_large(
594
+ model=model,
595
+ X=low_res,
596
+ overlap=16
597
+ )
598
+
599
+ print("SR output:", tuple(sr_tensor.shape))
600
+
601
+ # --------------------------------------------------
602
+ # 4. Extract bands
603
+ # --------------------------------------------------
604
+
605
+ sr = sr_tensor.detach().cpu().numpy()
606
+
607
+ B02 = sr[0]
608
+ B03 = sr[1]
609
+ B04 = sr[2]
610
+ B05 = sr[3]
611
+ B06 = sr[4]
612
+ B07 = sr[5]
613
+ B08 = sr[6]
614
+ B8A = sr[7]
615
+ B11 = sr[8]
616
+ B12 = sr[9]
617
+
618
+ # --------------------------------------------------
619
+ # 5. Generate analysis layers
620
+ # --------------------------------------------------
621
+
622
+ print("\n[4/6] Generating analysis layers...")
623
+
624
+ eps = 1e-8
625
+
626
+ rgb = np.stack(
627
+ [B04, B03, B02],
628
+ axis=-1
629
+ )
630
+
631
+ false_color = np.stack(
632
+ [B08, B04, B03],
633
+ axis=-1
634
+ )
635
+
636
+ swir = np.stack(
637
+ [B12, B11, B04],
638
+ axis=-1
639
+ )
640
+
641
+ ndvi = (B08 - B04) / (B08 + B04 + eps)
642
+
643
+ ndwi = (B03 - B08) / (B03 + B08 + eps)
644
+
645
+ ndbi = (B11 - B08) / (B11 + B08 + eps)
646
+
647
+ nbr = (B08 - B12) / (B08 + B12 + eps)
648
+
649
+ # --------------------------------------------------
650
+ # 6. Uncertainty [GPU]
651
+ # --------------------------------------------------
652
+
653
+ print("\n[5/6] Calculating LDSR-S2 uncertainty...")
654
+
655
+ deep_model = model.sr_model.sr_model
656
+
657
+ lr_4band = torch.stack([
658
+ low_res[0], # B02
659
+ low_res[1], # B03
660
+ low_res[2], # B04
661
+ low_res[6], # B08
662
+ ], dim=0).unsqueeze(0)
663
+
664
+ with torch.inference_mode():
665
+
666
+ uncertainty_tensor = deep_model.uncertainty_map(
667
+ lr_4band,
668
+ n_variations=5,
669
+ sampling_steps=50
670
+ )
671
+
672
+ uncertainty_np = (
673
+ uncertainty_tensor
674
+ .squeeze()
675
+ .detach()
676
+ .cpu()
677
+ .numpy()
678
+ )
679
+
680
+ print("Uncertainty:", uncertainty_np.shape)
681
+
682
+ # --------------------------------------------------
683
+ # 7. Save GeoTIFFs
684
+ # --------------------------------------------------
685
+
686
+ print("\n[6/6] Creating GeoTIFF outputs...")
687
+
688
+ output_dir = tempfile.mkdtemp(
689
+ prefix="terravision_"
690
+ )
691
+
692
+ def save_layer(array, filename):
693
+ path = os.path.join(
694
+ output_dir,
695
+ filename
696
+ )
697
+
698
+ # RGB/composite
699
+ if array.ndim == 3:
700
+
701
+ tensor = torch.from_numpy(
702
+ np.transpose(array, (2, 0, 1))
703
+ ).float()
704
+
705
+ # Single-band
706
+ else:
707
+
708
+ tensor = torch.from_numpy(
709
+ array
710
+ ).float().unsqueeze(0)
711
+
712
+ save_tensor_as_geotiff(
713
+ tensor,
714
+ da_new[image_index].attrs,
715
+ out_path=path,
716
+ super_resolved=True
717
+ )
718
+
719
+ return path
720
+
721
+ paths = {}
722
+
723
+ paths["SR RGB"] = save_layer(
724
+ rgb,
725
+ "sr_rgb.tif"
726
+ )
727
+
728
+ paths["False Color"] = save_layer(
729
+ false_color,
730
+ "false_color.tif"
731
+ )
732
+
733
+ paths["SWIR"] = save_layer(
734
+ swir,
735
+ "swir.tif"
736
+ )
737
+
738
+ paths["NDVI"] = save_layer(
739
+ ndvi,
740
+ "ndvi.tif"
741
+ )
742
+
743
+ paths["NDWI"] = save_layer(
744
+ ndwi,
745
+ "ndwi.tif"
746
+ )
747
+
748
+ paths["NDBI"] = save_layer(
749
+ ndbi,
750
+ "ndbi.tif"
751
+ )
752
+
753
+ paths["NBR"] = save_layer(
754
+ nbr,
755
+ "nbr.tif"
756
+ )
757
+
758
+ paths["Uncertainty"] = save_layer(
759
+ uncertainty_np,
760
+ "uncertainty.tif"
761
+ )
762
+
763
+ # --------------------------------------------------
764
+ # ZIP all GeoTIFFs
765
+ # --------------------------------------------------
766
+
767
+ zip_path = os.path.join(
768
+ output_dir,
769
+ "terravision_layers.zip"
770
+ )
771
+
772
+ with zipfile.ZipFile(
773
+ zip_path,
774
+ "w",
775
+ zipfile.ZIP_DEFLATED
776
+ ) as z:
777
+
778
+ for name, path in paths.items():
779
+
780
+ z.write(
781
+ path,
782
+ arcname=os.path.basename(path)
783
+ )
784
+
785
+ print("\n========================================")
786
+ print("PROCESSING COMPLETE")
787
+ print("========================================")
788
+
789
+ return {
790
+ "rgb": rgb,
791
+ "original_rgb": original_rgb,
792
+ "false_color": false_color,
793
+ "swir": swir,
794
+ "ndvi": ndvi,
795
+ "ndwi": ndwi,
796
+ "ndbi": ndbi,
797
+ "nbr": nbr,
798
+ "uncertainty": uncertainty_np,
799
+ "files": paths,
800
+ "zip": zip_path
801
+ }
802
+
803
+
804
+ # ============================================================
805
+ # GRADIO HANDLER — GPU-DECORATED
806
+ # All GPU computation (sen2sr.predict_large, uncertainty_map)
807
+ # runs inside generate_satellite_products which is called here.
808
+ # ============================================================
809
+
810
+ @spaces.GPU(duration=GPU_DURATION)
811
+ def run_app(latitude, longitude, start_date, end_date):
812
+
813
+ results = generate_satellite_products(
814
+ latitude,
815
+ longitude,
816
+ start_date,
817
+ end_date
818
+ )
819
+
820
+ # -------------------------------------------------
821
+ # ORIGINAL RGB
822
+ # Sentinel-2 reflectance: 0-10000 -> 0-1
823
+ # -------------------------------------------------
824
+ original_rgb = np.asarray(
825
+ results["original_rgb"],
826
+ dtype=np.float32
827
+ ) / 10000.0
828
+
829
+ original_rgb = clean_rgb(original_rgb)
830
+
831
+ # -------------------------------------------------
832
+ # SUPER-RESOLVED RGB
833
+ # -------------------------------------------------
834
+ sr_rgb = clean_rgb(results["rgb"])
835
+
836
+ # -------------------------------------------------
837
+ # IMAGE SLIDER
838
+ # -------------------------------------------------
839
+ comparison_images = (
840
+ original_rgb,
841
+ sr_rgb
842
+ )
843
+
844
+ # -------------------------------------------------
845
+ # ANALYSIS LAYERS
846
+ # -------------------------------------------------
847
+ false_color_img = clean_rgb(results["false_color"])
848
+
849
+ swir_img = clean_rgb(results["swir"])
850
+
851
+ ndvi_img = clean_index(results["ndvi"])
852
+
853
+ ndwi_img = clean_index(results["ndwi"])
854
+
855
+ ndbi_img = clean_index(results["ndbi"])
856
+
857
+ nbr_img = clean_index(results["nbr"])
858
+
859
+ uncertainty_img = clean_uncertainty(
860
+ results["uncertainty"]
861
+ )
862
+
863
+ # -------------------------------------------------
864
+ # ZIP FILE
865
+ # -------------------------------------------------
866
+ zip_file_path = results["zip"]
867
+
868
+ # -------------------------------------------------
869
+ # RETURN EXACTLY 9 OUTPUTS
870
+ # -------------------------------------------------
871
+ return (
872
+ comparison_images,
873
+ false_color_img,
874
+ swir_img,
875
+ ndvi_img,
876
+ ndwi_img,
877
+ ndbi_img,
878
+ nbr_img,
879
+ uncertainty_img,
880
+ zip_file_path
881
+ )
882
+
883
+
884
+ # ============================================================
885
+ # TERRAVISION APP
886
+ # ============================================================
887
+
888
+ with gr.Blocks(
889
+ title="TerraVision — Sentinel-2 Super Resolution"
890
+ # css parameter moved to demo.launch()
891
+ ) as demo:
892
+
893
+ # ========================================================
894
+ # HEADER
895
+ # ========================================================
896
+
897
+ gr.Markdown(
898
+ """
899
+ # 🌍 TerraVision
900
+ ### Sentinel-2 Super Resolution & Analysis
901
+
902
+ **Sharper Earth. Brighter Decisions.**
903
+ """
904
+ )
905
+
906
+
907
+ # ========================================================
908
+ # MAIN LAYOUT
909
+ # ========================================================
910
+
911
+ with gr.Row():
912
+
913
+ # ====================================================
914
+ # LEFT SIDEBAR
915
+ # ====================================================
916
+
917
+ with gr.Column(scale=1):
918
+
919
+ gr.Markdown("### 📍 Select Location")
920
+
921
+
922
+ # ------------------------------------------------
923
+ # INTERACTIVE MAP
924
+ # ------------------------------------------------
925
+
926
+ map_html = gr.HTML(
927
+
928
+ # Value now contains the div that Leaflet will target
929
+ value='<div id="terrativision-map"></div>',
930
+
931
+ head=LEAFLET_HEAD,
932
+
933
+ js_on_load=MAP_JS,
934
+
935
+ # elem_id for the wrapping gr.HTML component itself
936
+ elem_id="map-container-wrapper"
937
+ )
938
+
939
+
940
+
941
+ # Separate component for the location display
942
+ map_location_output = gr.HTML(
943
+ value=f"""
944
+ <div id="map-location-display">
945
+ 📍 <b>Selected:</b>
946
+ {DEFAULT_LAT:.8f}, {DEFAULT_LON:.8f}
947
+ </div>
948
+ """
949
+ )
950
+
951
+
952
+ gr.Markdown(
953
+ "💡 **Click anywhere on the map to select a location.**"
954
+ )
955
+
956
+
957
+ # ------------------------------------------------
958
+ # COORDINATES
959
+ # ------------------------------------------------
960
+
961
+ gr.Markdown("### 📌 Coordinates")
962
+
963
+ latitude = gr.Number(
964
+ label="Latitude",
965
+ value=DEFAULT_LAT,
966
+ precision=8,
967
+ elem_id="latitude_input"
968
+ )
969
+
970
+ longitude = gr.Number(
971
+ label="Longitude",
972
+ value=DEFAULT_LON,
973
+ precision=8,
974
+ elem_id="longitude_input"
975
+ )
976
+ # ============================================================
977
+ # MAP -> GRADIO COORDINATE UPDATE
978
+ # ============================================================
979
+
980
+ map_html.click(
981
+ fn=None,
982
+ inputs=[],
983
+ outputs=[latitude, longitude],
984
+ js="""
985
+ () => {
986
+ const coords =
987
+ window.terraVisionSelectedCoordinates;
988
+
989
+ if (!coords) {
990
+ return [null, null];
991
+ }
992
+
993
+ return [
994
+ coords.latitude,
995
+ coords.longitude
996
+ ];
997
+ }
998
+ """
999
+ )
1000
+
1001
+
1002
+
1003
+ # ------------------------------------------------
1004
+ # DATE RANGE
1005
+ # ------------------------------------------------
1006
+
1007
+ gr.Markdown("### 📅 Date Range")
1008
+
1009
+
1010
+ start_date = gr.Textbox(
1011
+ label="Start Date",
1012
+ value="2024-10-29"
1013
+ )
1014
+
1015
+
1016
+ end_date = gr.Textbox(
1017
+ label="End Date",
1018
+ value="2024-11-01"
1019
+ )
1020
+
1021
+
1022
+
1023
+ # ------------------------------------------------
1024
+ # GENERATE BUTTON
1025
+ # ------------------------------------------------
1026
+
1027
+ generate_button = gr.Button(
1028
+ "🚀 Generate Super-Resolution",
1029
+ variant="primary",
1030
+ size="lg"
1031
+ )
1032
+
1033
+
1034
+ # ------------------------------------------------
1035
+ # MODEL INFORMATION
1036
+ # ------------------------------------------------
1037
+
1038
+ gr.Markdown(
1039
+ """
1040
+ **Model:** ESA LDSR-S2 + SEN2SR
1041
+
1042
+ **Input:** Sentinel-2 L2A
1043
+
1044
+ **Output:** 2.5 m
1045
+
1046
+ **Bands:** 10
1047
+ """
1048
+ )
1049
+
1050
+
1051
+ # ====================================================
1052
+ # RIGHT CONTENT
1053
+ # ====================================================
1054
+
1055
+ with gr.Column(scale=3):
1056
+
1057
+ gr.Markdown(
1058
+ "## Results & Analysis Layers"
1059
+ )
1060
+
1061
+
1062
+ # ------------------------------------------------
1063
+ # MAIN BEFORE / AFTER
1064
+ # ------------------------------------------------
1065
+
1066
+ comparison_output = gr.ImageSlider(
1067
+
1068
+ label="10 m -> 2.5 m Super-Resolution",
1069
+
1070
+ type="numpy",
1071
+
1072
+ image_mode="RGB",
1073
+
1074
+ height=500,
1075
+
1076
+ buttons=[
1077
+ "download",
1078
+ "fullscreen"
1079
+ ]
1080
+ )
1081
+
1082
+
1083
+ # ------------------------------------------------
1084
+ # FALSE COLOR + SWIR
1085
+ # ------------------------------------------------
1086
+
1087
+ with gr.Row():
1088
+
1089
+ false_color_output = gr.Image(
1090
+ label="False Color / NIR",
1091
+ type="numpy",
1092
+ height=320
1093
+ )
1094
+
1095
+ swir_output = gr.Image(
1096
+ label="SWIR Composite",
1097
+ type="numpy",
1098
+ height=320
1099
+ )
1100
+
1101
+
1102
+ # ------------------------------------------------
1103
+ # NDVI / NDWI / NDBI
1104
+ # ------------------------------------------------
1105
+
1106
+ with gr.Row():
1107
+
1108
+ ndvi_output = gr.Image(
1109
+ label="NDVI",
1110
+ type="numpy",
1111
+ height=300
1112
+ )
1113
+
1114
+ ndwi_output = gr.Image(
1115
+ label="NDWI",
1116
+ type="numpy",
1117
+ height=300
1118
+ )
1119
+
1120
+ ndbi_output = gr.Image(
1121
+ label="NDBI",
1122
+ type="numpy",
1123
+ height=300
1124
+ )
1125
+
1126
+
1127
+ # ------------------------------------------------
1128
+ # NBR + UNCERTAINTY
1129
+ # ------------------------------------------------
1130
+
1131
+ with gr.Row():
1132
+
1133
+ nbr_output = gr.Image(
1134
+ label="NBR",
1135
+ type="numpy",
1136
+ height=320
1137
+ )
1138
+
1139
+ uncertainty_output = gr.Image(
1140
+ label="LDSR-S2 Uncertainty",
1141
+ type="numpy",
1142
+ height=320
1143
+ )
1144
+
1145
+
1146
+ # ------------------------------------------------
1147
+ # DOWNLOAD
1148
+ # ------------------------------------------------
1149
+
1150
+ zip_output = gr.File(
1151
+ label="Download All GeoTIFF Layers"
1152
+ )
1153
+
1154
+
1155
+
1156
+
1157
+ # ========================================================
1158
+ # GENERATE BUTTON
1159
+ # ========================================================
1160
+
1161
+ generate_button.click(
1162
+
1163
+ fn=run_app,
1164
+
1165
+ inputs=[
1166
+ latitude,
1167
+ longitude,
1168
+ start_date,
1169
+ end_date
1170
+ ],
1171
+
1172
+ outputs=[
1173
+ comparison_output,
1174
+ false_color_output,
1175
+ swir_output,
1176
+ ndvi_output,
1177
+ ndwi_output,
1178
+ ndbi_output,
1179
+ nbr_output,
1180
+ uncertainty_output,
1181
+ zip_output
1182
+ ]
1183
+ )
1184
+
1185
+
1186
+ print("✅ TerraVision UI + Interactive Map created successfully!")
1187
+
1188
+ demo.launch(css=custom_css)
requirements.txt ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # TerraVision — Hugging Face ZeroGPU Space dependencies
3
+ #
4
+ # Notes:
5
+ # - torch / torchvision are pre-installed on the ZeroGPU base
6
+ # image with CUDA support. Listing them here ensures pip
7
+ # resolves them correctly without re-installing CUDA builds.
8
+ # - Do NOT pin exact torch versions; let the HF base image
9
+ # supply the matching CUDA-enabled wheel.
10
+ # - spaces is the Hugging Face ZeroGPU client library.
11
+ # ============================================================
12
+
13
+ spaces
14
+ gradio
15
+ torch
16
+ torchvision
17
+ numpy
18
+ Pillow
19
+ rasterio
20
+ rioxarray
21
+ sen2sr
22
+ opensr-model
23
+ mlstac
24
+ cubo
25
+ omegaconf
26
+ requests