Aniket2006 commited on
Commit
25b1296
·
1 Parent(s): 1027689

Add AGROW Heatmap Service - Generate vegetation index heatmaps from Sentinel-2 data

Browse files

Features:
- POST /generate-heatmap - Returns base64 image + stats
- GET /generate-heatmap-image - Returns PNG directly
- Supports NDVI, EVI, NDWI, NDRE, SMI indices
- Custom vegetation colormap (red-yellow-green)

Files changed (4) hide show
  1. Dockerfile +22 -0
  2. README.md +69 -5
  3. app.py +348 -0
  4. requirements.txt +8 -0
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && apt-get install -y \
7
+ gcc \
8
+ libgdal-dev \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Copy requirements and install
12
+ COPY requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ # Copy application
16
+ COPY app.py .
17
+
18
+ # Expose port
19
+ EXPOSE 7860
20
+
21
+ # Run the application
22
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,74 @@
1
  ---
2
- title: Heatmap
3
- emoji: 🐠
4
- colorFrom: pink
5
- colorTo: red
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: AGROW Heatmap Service
3
+ emoji: 🗺️
4
+ colorFrom: green
5
+ colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
+ license: mit
9
  ---
10
 
11
+ # AGROW Heatmap Service
12
+
13
+ This service generates heatmap images from Sentinel-2 satellite data for agricultural field visualization.
14
+
15
+ ## Features
16
+
17
+ - Generate vegetation index heatmaps (NDVI, EVI, NDWI, NDRE, SMI)
18
+ - Custom colormap optimized for agricultural analysis
19
+ - Returns images as base64 or direct PNG
20
+
21
+ ## API Endpoints
22
+
23
+ ### POST `/generate-heatmap`
24
+
25
+ Generate a heatmap for the specified location and index type.
26
+
27
+ **Request:**
28
+ ```json
29
+ {
30
+ "center_lat": 26.1885,
31
+ "center_lon": 91.6894,
32
+ "field_size_hectares": 10.0,
33
+ "index_type": "NDVI"
34
+ }
35
+ ```
36
+
37
+ **Response:**
38
+ ```json
39
+ {
40
+ "success": true,
41
+ "index_type": "NDVI",
42
+ "min_value": 0.2,
43
+ "max_value": 0.85,
44
+ "mean_value": 0.65,
45
+ "image_base64": "...",
46
+ "timestamp": "2025-12-05T10:30:00"
47
+ }
48
+ ```
49
+
50
+ ### GET `/generate-heatmap-image`
51
+
52
+ Get heatmap as PNG image directly.
53
+
54
+ **Query Parameters:**
55
+ - `center_lat`: Field center latitude
56
+ - `center_lon`: Field center longitude
57
+ - `field_size_hectares`: Field size (default: 10.0)
58
+ - `index_type`: Index type (default: "NDVI")
59
+
60
+ ## Supported Indices
61
+
62
+ | Index | Description |
63
+ |-------|-------------|
64
+ | NDVI | Normalized Difference Vegetation Index - General crop health |
65
+ | EVI | Enhanced Vegetation Index - Dense vegetation monitoring |
66
+ | NDWI | Normalized Difference Water Index - Water/moisture content |
67
+ | NDRE | Normalized Difference Red Edge Index - Chlorophyll content |
68
+ | SMI | Soil Moisture Index - Soil water content |
69
+
70
+ ## Environment Variables
71
+
72
+ Required secrets in HF Space:
73
+ - `SH_CLIENT_ID`: Sentinel Hub client ID
74
+ - `SH_CLIENT_SECRET`: Sentinel Hub client secret
app.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AGROW Heatmap Service
3
+ =====================
4
+ Generates heatmap images from Sentinel-2 satellite data.
5
+ """
6
+
7
+ import os
8
+ import io
9
+ import base64
10
+ import logging
11
+ from datetime import datetime, timedelta
12
+ from typing import Optional
13
+
14
+ import numpy as np
15
+ import matplotlib
16
+ matplotlib.use('Agg') # Non-interactive backend
17
+ import matplotlib.pyplot as plt
18
+ from matplotlib.colors import LinearSegmentedColormap
19
+ from PIL import Image
20
+ from fastapi import FastAPI, HTTPException
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from fastapi.responses import Response
23
+ from pydantic import BaseModel
24
+
25
+ from sentinelhub import (
26
+ SHConfig, BBox, CRS, DataCollection, SentinelHubRequest,
27
+ MimeType, bbox_to_dimensions, SentinelHubCatalog
28
+ )
29
+
30
+ # Configure logging
31
+ logging.basicConfig(level=logging.INFO)
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Initialize FastAPI
35
+ app = FastAPI(
36
+ title="AGROW Heatmap Service",
37
+ description="Generate heatmap images from Sentinel-2 vegetation indices",
38
+ version="1.0.0"
39
+ )
40
+
41
+ # CORS
42
+ app.add_middleware(
43
+ CORSMiddleware,
44
+ allow_origins=["*"],
45
+ allow_credentials=True,
46
+ allow_methods=["*"],
47
+ allow_headers=["*"],
48
+ )
49
+
50
+ # Sentinel Hub configuration
51
+ def get_sh_config():
52
+ config = SHConfig()
53
+ config.sh_client_id = os.environ.get('SH_CLIENT_ID', '')
54
+ config.sh_client_secret = os.environ.get('SH_CLIENT_SECRET', '')
55
+ config.sh_base_url = 'https://sh.dataspace.copernicus.eu'
56
+ config.sh_token_url = 'https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token'
57
+ return config
58
+
59
+
60
+ # Request models
61
+ class HeatmapRequest(BaseModel):
62
+ center_lat: float
63
+ center_lon: float
64
+ field_size_hectares: float = 10.0
65
+ index_type: str = "NDVI" # NDVI, EVI, NDWI, SMI, NDRE
66
+
67
+
68
+ class HeatmapResponse(BaseModel):
69
+ success: bool
70
+ index_type: str
71
+ min_value: float
72
+ max_value: float
73
+ mean_value: float
74
+ image_base64: str
75
+ timestamp: str
76
+
77
+
78
+ # Custom colormap for vegetation indices
79
+ def get_vegetation_colormap():
80
+ """Create a colormap from red (low) to yellow (mid) to green (high)."""
81
+ colors = [
82
+ (0.8, 0.2, 0.2), # Red (stress/low)
83
+ (0.9, 0.6, 0.2), # Orange
84
+ (0.95, 0.9, 0.3), # Yellow (moderate)
85
+ (0.6, 0.8, 0.3), # Light green
86
+ (0.2, 0.6, 0.2), # Dark green (healthy/high)
87
+ ]
88
+ return LinearSegmentedColormap.from_list('vegetation', colors, N=256)
89
+
90
+
91
+ def create_evalscript(index_type: str) -> str:
92
+ """Create Sentinel Hub evalscript for the requested index."""
93
+
94
+ evalscripts = {
95
+ "NDVI": """
96
+ //VERSION=3
97
+ function setup() {
98
+ return {
99
+ input: ["B04", "B08", "dataMask"],
100
+ output: { bands: 1, sampleType: "FLOAT32" }
101
+ };
102
+ }
103
+ function evaluatePixel(sample) {
104
+ let ndvi = (sample.B08 - sample.B04) / (sample.B08 + sample.B04);
105
+ return [ndvi * sample.dataMask];
106
+ }
107
+ """,
108
+ "EVI": """
109
+ //VERSION=3
110
+ function setup() {
111
+ return {
112
+ input: ["B02", "B04", "B08", "dataMask"],
113
+ output: { bands: 1, sampleType: "FLOAT32" }
114
+ };
115
+ }
116
+ function evaluatePixel(sample) {
117
+ let evi = 2.5 * (sample.B08 - sample.B04) / (sample.B08 + 6 * sample.B04 - 7.5 * sample.B02 + 1);
118
+ return [evi * sample.dataMask];
119
+ }
120
+ """,
121
+ "NDWI": """
122
+ //VERSION=3
123
+ function setup() {
124
+ return {
125
+ input: ["B03", "B08", "dataMask"],
126
+ output: { bands: 1, sampleType: "FLOAT32" }
127
+ };
128
+ }
129
+ function evaluatePixel(sample) {
130
+ let ndwi = (sample.B03 - sample.B08) / (sample.B03 + sample.B08);
131
+ return [ndwi * sample.dataMask];
132
+ }
133
+ """,
134
+ "NDRE": """
135
+ //VERSION=3
136
+ function setup() {
137
+ return {
138
+ input: ["B05", "B08", "dataMask"],
139
+ output: { bands: 1, sampleType: "FLOAT32" }
140
+ };
141
+ }
142
+ function evaluatePixel(sample) {
143
+ let ndre = (sample.B08 - sample.B05) / (sample.B08 + sample.B05);
144
+ return [ndre * sample.dataMask];
145
+ }
146
+ """,
147
+ "SMI": """
148
+ //VERSION=3
149
+ function setup() {
150
+ return {
151
+ input: ["B8A", "B11", "dataMask"],
152
+ output: { bands: 1, sampleType: "FLOAT32" }
153
+ };
154
+ }
155
+ function evaluatePixel(sample) {
156
+ let smi = (sample.B8A - sample.B11) / (sample.B8A + sample.B11);
157
+ return [smi * sample.dataMask];
158
+ }
159
+ """,
160
+ }
161
+
162
+ return evalscripts.get(index_type, evalscripts["NDVI"])
163
+
164
+
165
+ def generate_heatmap_image(data: np.ndarray, index_type: str) -> tuple:
166
+ """Generate a heatmap image from index data."""
167
+
168
+ # Handle NaN values
169
+ valid_mask = ~np.isnan(data)
170
+ if not np.any(valid_mask):
171
+ raise ValueError("No valid data pixels found")
172
+
173
+ min_val = float(np.nanmin(data))
174
+ max_val = float(np.nanmax(data))
175
+ mean_val = float(np.nanmean(data))
176
+
177
+ # Normalize data to 0-1 range for colormap
178
+ data_normalized = np.clip((data - min_val) / (max_val - min_val + 1e-8), 0, 1)
179
+ data_normalized = np.nan_to_num(data_normalized, nan=0.5)
180
+
181
+ # Create figure
182
+ fig, ax = plt.subplots(figsize=(8, 8), dpi=100)
183
+
184
+ # Apply colormap
185
+ cmap = get_vegetation_colormap()
186
+ im = ax.imshow(data_normalized, cmap=cmap, interpolation='bilinear')
187
+
188
+ # Add colorbar
189
+ cbar = plt.colorbar(im, ax=ax, shrink=0.8, pad=0.02)
190
+ cbar.set_label(f'{index_type} Value', fontsize=10)
191
+
192
+ # Format colorbar ticks to show actual values
193
+ cbar_ticks = np.linspace(0, 1, 5)
194
+ cbar_labels = [f'{min_val + t * (max_val - min_val):.2f}' for t in cbar_ticks]
195
+ cbar.set_ticks(cbar_ticks)
196
+ cbar.set_ticklabels(cbar_labels)
197
+
198
+ # Style
199
+ ax.set_title(f'{index_type} Heatmap', fontsize=14, fontweight='bold')
200
+ ax.axis('off')
201
+
202
+ # Save to buffer
203
+ buf = io.BytesIO()
204
+ plt.savefig(buf, format='png', bbox_inches='tight', facecolor='white', edgecolor='none')
205
+ plt.close(fig)
206
+ buf.seek(0)
207
+
208
+ # Convert to base64
209
+ img_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
210
+
211
+ return img_base64, min_val, max_val, mean_val
212
+
213
+
214
+ @app.get("/")
215
+ async def root():
216
+ return {
217
+ "service": "AGROW Heatmap Service",
218
+ "version": "1.0.0",
219
+ "status": "running",
220
+ "endpoints": {
221
+ "/generate-heatmap": "POST - Generate heatmap from coordinates",
222
+ "/health": "GET - Health check"
223
+ }
224
+ }
225
+
226
+
227
+ @app.get("/health")
228
+ async def health():
229
+ return {"status": "healthy"}
230
+
231
+
232
+ @app.post("/generate-heatmap", response_model=HeatmapResponse)
233
+ async def generate_heatmap(request: HeatmapRequest):
234
+ """Generate a heatmap image for the specified location and index type."""
235
+
236
+ try:
237
+ logger.info(f"Generating {request.index_type} heatmap for ({request.center_lat}, {request.center_lon})")
238
+
239
+ config = get_sh_config()
240
+
241
+ # Calculate bounding box from center and field size
242
+ # Approximate conversion: 1 degree ≈ 111km at equator
243
+ field_radius_km = np.sqrt(request.field_size_hectares / 100) / 2
244
+ lat_offset = field_radius_km / 111
245
+ lon_offset = field_radius_km / (111 * np.cos(np.radians(request.center_lat)))
246
+
247
+ bbox = BBox(
248
+ (
249
+ request.center_lon - lon_offset,
250
+ request.center_lat - lat_offset,
251
+ request.center_lon + lon_offset,
252
+ request.center_lat + lat_offset
253
+ ),
254
+ crs=CRS.WGS84
255
+ )
256
+
257
+ # Calculate resolution (10m per pixel)
258
+ size = bbox_to_dimensions(bbox, resolution=10)
259
+
260
+ # Ensure minimum size
261
+ size = (max(size[0], 64), max(size[1], 64))
262
+
263
+ # Get recent date
264
+ end_date = datetime.now()
265
+ start_date = end_date - timedelta(days=30)
266
+
267
+ # Create evalscript
268
+ evalscript = create_evalscript(request.index_type)
269
+
270
+ # Define data collection for CDSE
271
+ SENTINEL2_L2A_CDSE = DataCollection.define_from(
272
+ DataCollection.SENTINEL2_L2A,
273
+ service_url='https://sh.dataspace.copernicus.eu'
274
+ )
275
+
276
+ # Create request
277
+ sh_request = SentinelHubRequest(
278
+ evalscript=evalscript,
279
+ input_data=[
280
+ SentinelHubRequest.input_data(
281
+ data_collection=SENTINEL2_L2A_CDSE,
282
+ time_interval=(start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d')),
283
+ mosaicking_order='leastCC'
284
+ )
285
+ ],
286
+ responses=[SentinelHubRequest.output_response('default', MimeType.TIFF)],
287
+ bbox=bbox,
288
+ size=size,
289
+ config=config
290
+ )
291
+
292
+ # Fetch data
293
+ data = sh_request.get_data()[0]
294
+
295
+ if data is None or data.size == 0:
296
+ raise HTTPException(status_code=404, detail="No satellite data available for this location")
297
+
298
+ # Squeeze if needed
299
+ if len(data.shape) > 2:
300
+ data = data.squeeze()
301
+
302
+ # Generate heatmap
303
+ img_base64, min_val, max_val, mean_val = generate_heatmap_image(data, request.index_type)
304
+
305
+ return HeatmapResponse(
306
+ success=True,
307
+ index_type=request.index_type,
308
+ min_value=min_val,
309
+ max_value=max_val,
310
+ mean_value=mean_val,
311
+ image_base64=img_base64,
312
+ timestamp=datetime.now().isoformat()
313
+ )
314
+
315
+ except HTTPException:
316
+ raise
317
+ except Exception as e:
318
+ logger.error(f"Error generating heatmap: {str(e)}")
319
+ raise HTTPException(status_code=500, detail=str(e))
320
+
321
+
322
+ @app.get("/generate-heatmap-image")
323
+ async def generate_heatmap_image_direct(
324
+ center_lat: float,
325
+ center_lon: float,
326
+ field_size_hectares: float = 10.0,
327
+ index_type: str = "NDVI"
328
+ ):
329
+ """Generate and return heatmap as PNG image directly."""
330
+
331
+ request = HeatmapRequest(
332
+ center_lat=center_lat,
333
+ center_lon=center_lon,
334
+ field_size_hectares=field_size_hectares,
335
+ index_type=index_type
336
+ )
337
+
338
+ response = await generate_heatmap(request)
339
+
340
+ # Decode base64 to bytes
341
+ img_bytes = base64.b64decode(response.image_base64)
342
+
343
+ return Response(content=img_bytes, media_type="image/png")
344
+
345
+
346
+ if __name__ == "__main__":
347
+ import uvicorn
348
+ uvicorn.run(app, host="0.0.0.0", port=7860)
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn[standard]==0.24.0
3
+ pydantic==2.5.2
4
+ numpy>=1.24.0
5
+ matplotlib>=3.7.0
6
+ Pillow>=9.0.0
7
+ sentinelhub>=3.9.0
8
+ python-dotenv>=1.0.0