Aniket2006 commited on
Commit
4a05f0f
·
1 Parent(s): 060f6e5

v2.3.0: Pixel-wise + 100-200 patch analysis

Browse files

- Pixel-wise vegetation index calculation (NOT CNN)
- Target 100-200 patches (10x10 to 15x15 grid)
- Enhanced logging with visual health distribution bars
- Step-by-step progress logging
- Returns 50 patches in response with health categories
- No hardcoded min image size

Files changed (1) hide show
  1. app.py +257 -301
app.py CHANGED
@@ -1,29 +1,27 @@
1
  """
2
  AGROW Heatmap Service
3
  =====================
4
- Generates heatmap images from Sentinel-2 satellite data using the same
5
- vegetation indices from the main Sentinel-2 pipeline.
6
 
7
- Version: 2.1.0 - Enhanced logging for debugging
8
  """
9
 
10
  import os
11
  import io
12
- import re
13
  import base64
14
  import logging
15
  import traceback
16
  from datetime import datetime, timedelta
17
- from typing import Optional
18
 
19
  import numpy as np
20
  from scipy.ndimage import gaussian_filter
21
 
22
  import matplotlib
23
- matplotlib.use('Agg') # Non-interactive backend
24
  import matplotlib.pyplot as plt
25
  from matplotlib.colors import LinearSegmentedColormap
26
- from PIL import Image
27
  from fastapi import FastAPI, HTTPException
28
  from fastapi.middleware.cors import CORSMiddleware
29
  from fastapi.responses import Response
@@ -34,34 +32,47 @@ from sentinelhub import (
34
  MimeType, bbox_to_dimensions
35
  )
36
 
37
- # Import vegetation indices from S2 pipeline
38
- from vegetation_indices import INDEX_FUNCTIONS, calculate_all_indices
39
 
40
- # Configure detailed logging
 
 
41
  logging.basicConfig(
42
  level=logging.INFO,
43
- format='%(asctime)s - %(levelname)s - %(name)s - %(message)s',
44
- datefmt='%Y-%m-%d %H:%M:%S'
45
  )
46
- logger = logging.getLogger(__name__)
47
-
48
- # Startup logging
49
- logger.info("=" * 60)
50
- logger.info("AGROW Heatmap Service - Starting Up")
51
- logger.info("=" * 60)
52
- logger.info(f"Available indices: {list(INDEX_FUNCTIONS.keys())}")
53
- logger.info(f"SH_CLIENT_ID configured: {'Yes' if os.environ.get('SH_CLIENT_ID') else 'No'}")
54
- logger.info(f"SH_CLIENT_SECRET configured: {'Yes' if os.environ.get('SH_CLIENT_SECRET') else 'No'}")
55
- logger.info("=" * 60)
56
-
57
- # Initialize FastAPI
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  app = FastAPI(
59
  title="AGROW Heatmap Service",
60
- description="Generate heatmap images from Sentinel-2 vegetation indices",
61
- version="2.1.0"
62
  )
63
 
64
- # CORS
65
  app.add_middleware(
66
  CORSMiddleware,
67
  allow_origins=["*"],
@@ -70,29 +81,28 @@ app.add_middleware(
70
  allow_headers=["*"],
71
  )
72
 
73
-
74
- # Sentinel Hub configuration
 
75
  def get_sh_config():
76
- logger.info(" [Config] Loading Sentinel Hub configuration...")
77
  config = SHConfig()
78
  config.sh_client_id = os.environ.get('SH_CLIENT_ID', '')
79
  config.sh_client_secret = os.environ.get('SH_CLIENT_SECRET', '')
80
  config.sh_base_url = 'https://sh.dataspace.copernicus.eu'
81
  config.sh_token_url = 'https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token'
82
-
83
- logger.info(f" [Config] Client ID: {config.sh_client_id[:10]}..." if config.sh_client_id else " [Config] Client ID: NOT SET")
84
- logger.info(f" [Config] Base URL: {config.sh_base_url}")
85
  return config
86
 
87
-
88
- # Request models
 
89
  class HeatmapRequest(BaseModel):
90
  center_lat: float
91
  center_lon: float
92
- field_size_hectares: float = 10.0
93
  index_type: str = "NDVI"
94
  gaussian_sigma: float = 1.5
95
  show_field_boundary: bool = True
 
96
 
97
 
98
  class HeatmapResponse(BaseModel):
@@ -101,35 +111,47 @@ class HeatmapResponse(BaseModel):
101
  min_value: float
102
  max_value: float
103
  mean_value: float
 
104
  image_base64: str
105
  timestamp: str
106
  image_date: Optional[str] = None
107
-
108
-
109
- # Custom colormap for vegetation indices
 
 
 
 
 
 
110
  def get_vegetation_colormap():
111
- colors = [
112
- (0.8, 0.2, 0.2), # Red (stress/low)
113
- (0.9, 0.6, 0.2), # Orange
114
- (0.95, 0.9, 0.3), # Yellow (moderate)
115
- (0.6, 0.8, 0.3), # Light green
116
- (0.2, 0.6, 0.2), # Dark green (healthy/high)
117
- ]
118
  return LinearSegmentedColormap.from_list('vegetation', colors, N=256)
119
 
120
-
121
  def get_water_colormap():
122
- colors = [
123
- (0.9, 0.6, 0.3), # Brown (dry)
124
- (0.95, 0.9, 0.5), # Yellow
125
- (0.5, 0.8, 0.9), # Light blue
126
- (0.2, 0.5, 0.8), # Blue
127
- (0.1, 0.3, 0.6), # Dark blue (wet)
128
- ]
129
  return LinearSegmentedColormap.from_list('water', colors, N=256)
130
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
- # Evalscript to fetch all 13 bands
 
 
133
  FULL_BANDS_EVALSCRIPT = """
134
  //VERSION=3
135
  function setup() {
@@ -138,362 +160,296 @@ function setup() {
138
  bands: ["B01", "B02", "B03", "B04", "B05", "B06", "B07", "B08", "B8A", "B09", "B11", "B12", "dataMask"],
139
  units: "REFLECTANCE"
140
  }],
141
- output: {
142
- bands: 13,
143
- sampleType: "FLOAT32"
144
- }
145
  };
146
  }
147
-
148
  function evaluatePixel(sample) {
149
- return [
150
- sample.B01, sample.B02, sample.B03, sample.B04,
151
- sample.B05, sample.B06, sample.B07, sample.B08,
152
- sample.B8A, sample.B09, sample.B11, sample.B12,
153
- sample.dataMask
154
- ];
155
  }
156
  """
157
 
158
-
159
- def analyze_patches(data: np.ndarray, index_type: str) -> dict:
160
- """Analyze data in 4x4 patches for detailed logging."""
 
 
 
 
 
161
  h, w = data.shape
162
- patch_h, patch_w = h // 4, w // 4
163
 
164
- patches = []
165
- for i in range(4):
166
- for j in range(4):
167
- patch = data[i*patch_h:(i+1)*patch_h, j*patch_w:(j+1)*patch_w]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  valid_pixels = np.sum(~np.isnan(patch))
 
169
  if valid_pixels > 0:
170
- patches.append({
171
- 'row': i, 'col': j,
172
- 'min': float(np.nanmin(patch)),
173
- 'max': float(np.nanmax(patch)),
174
- 'mean': float(np.nanmean(patch)),
175
- 'std': float(np.nanstd(patch)),
176
- 'valid_pixels': int(valid_pixels)
 
 
 
 
 
 
 
 
177
  })
 
 
178
 
179
- return {
180
- 'total_patches': len(patches),
181
- 'patches': patches
 
 
 
182
  }
183
-
184
-
185
- def generate_heatmap_image(
186
- data: np.ndarray,
187
- index_type: str,
188
- gaussian_sigma: float = 1.5,
189
- show_field_boundary: bool = True
190
- ) -> tuple:
191
- """Generate a heatmap image from index data with optional smoothing and field boundary."""
192
 
193
- logger.info(" [Heatmap] Starting image generation...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
- # Handle NaN values
196
  valid_mask = ~np.isnan(data)
197
- valid_count = np.sum(valid_mask)
198
- total_pixels = data.size
199
- logger.info(f" [Heatmap] Valid pixels: {valid_count}/{total_pixels} ({100*valid_count/total_pixels:.1f}%)")
200
 
201
  if not np.any(valid_mask):
202
- raise ValueError("No valid data pixels found")
203
 
204
  min_val = float(np.nanmin(data))
205
  max_val = float(np.nanmax(data))
206
  mean_val = float(np.nanmean(data))
207
  std_val = float(np.nanstd(data))
208
 
209
- logger.info(f" [Heatmap] {index_type} Statistics:")
210
- logger.info(f" - Min: {min_val:.4f}")
211
- logger.info(f" - Max: {max_val:.4f}")
212
- logger.info(f" - Mean: {mean_val:.4f}")
213
- logger.info(f" - Std: {std_val:.4f}")
214
 
215
- # Normalize data to 0-1 range for colormap
216
- data_normalized = np.clip((data - min_val) / (max_val - min_val + 1e-8), 0, 1)
217
- data_normalized = np.nan_to_num(data_normalized, nan=0.5)
218
 
219
- # Apply Gaussian smoothing if sigma > 0
220
  if gaussian_sigma > 0:
221
- logger.info(f" [Heatmap] Applying Gaussian smoothing (sigma={gaussian_sigma})...")
222
- data_normalized = gaussian_filter(data_normalized, sigma=gaussian_sigma)
223
- else:
224
- logger.info(" [Heatmap] Skipping Gaussian smoothing (sigma=0)")
225
 
226
  # Create figure
227
- logger.info(" [Heatmap] Creating matplotlib figure...")
228
  fig, ax = plt.subplots(figsize=(8, 8), dpi=100)
 
 
229
 
230
- # Select colormap based on index type
231
- if index_type in ['NDWI', 'SMI']:
232
- cmap = get_water_colormap()
233
- logger.info(f" [Heatmap] Using water colormap for {index_type}")
234
- else:
235
- cmap = get_vegetation_colormap()
236
- logger.info(f" [Heatmap] Using vegetation colormap for {index_type}")
237
-
238
- im = ax.imshow(data_normalized, cmap=cmap, interpolation='bilinear')
239
-
240
- # Draw field boundary if enabled
241
- if show_field_boundary:
242
- logger.info(" [Heatmap] Drawing field boundary overlay...")
243
- h, w = data_normalized.shape
244
- padding = 0.05
245
- rect = plt.Rectangle(
246
- (w * padding, h * padding),
247
- w * (1 - 2 * padding),
248
- h * (1 - 2 * padding),
249
- fill=False,
250
- edgecolor='white',
251
- linewidth=2,
252
- linestyle='--',
253
- alpha=0.6
254
- )
255
  ax.add_patch(rect)
256
-
257
- corners = [
258
- (w * padding, h * padding),
259
- (w * (1 - padding), h * padding),
260
- (w * padding, h * (1 - padding)),
261
- (w * (1 - padding), h * (1 - padding))
262
- ]
263
- for cx, cy in corners:
264
- ax.plot(cx, cy, 'o', color='white', markersize=6, alpha=0.7)
265
- else:
266
- logger.info(" [Heatmap] Skipping field boundary overlay")
267
 
268
- # Add colorbar
269
  cbar = plt.colorbar(im, ax=ax, shrink=0.8, pad=0.02)
270
- cbar.set_label(f'{index_type} Value', fontsize=10)
271
-
272
- cbar_ticks = np.linspace(0, 1, 5)
273
- cbar_labels = [f'{min_val + t * (max_val - min_val):.2f}' for t in cbar_ticks]
274
- cbar.set_ticks(cbar_ticks)
275
- cbar.set_ticklabels(cbar_labels)
276
 
277
  ax.set_title(f'{index_type} Heatmap', fontsize=14, fontweight='bold')
278
  ax.axis('off')
279
 
280
- # Save to buffer
281
- logger.info(" [Heatmap] Saving to PNG buffer...")
282
  buf = io.BytesIO()
283
- plt.savefig(buf, format='png', bbox_inches='tight', facecolor='white', edgecolor='none')
284
  plt.close(fig)
285
  buf.seek(0)
286
 
287
- img_base64 = base64.b64encode(buf.getvalue()).decode('utf-8')
288
- logger.info(f" [Heatmap] Generated image: {len(img_base64)} bytes (base64)")
289
 
290
- return img_base64, min_val, max_val, mean_val
291
-
292
 
 
 
 
293
  @app.get("/")
294
  async def root():
295
- logger.info("Root endpoint accessed")
296
  return {
297
  "service": "AGROW Heatmap Service",
298
- "version": "2.1.0",
299
- "status": "running",
300
- "supported_indices": list(INDEX_FUNCTIONS.keys()),
301
- "endpoints": {
302
- "/generate-heatmap": "POST - Generate heatmap from coordinates",
303
- "/generate-heatmap-image": "GET - Get heatmap as PNG directly",
304
- "/health": "GET - Health check"
305
- }
306
  }
307
 
308
-
309
  @app.get("/health")
310
  async def health():
311
- logger.info("Health check requested")
312
- return {"status": "healthy", "indices_available": list(INDEX_FUNCTIONS.keys())}
313
 
314
 
315
  @app.post("/generate-heatmap", response_model=HeatmapResponse)
316
  async def generate_heatmap(request: HeatmapRequest):
317
- """Generate a heatmap image for the specified location and index type."""
318
 
319
- request_id = datetime.now().strftime("%H%M%S%f")[:10]
320
 
321
- logger.info("=" * 60)
322
- logger.info(f"[{request_id}] NEW HEATMAP REQUEST")
323
- logger.info("=" * 60)
324
- logger.info(f"[{request_id}] Parameters:")
325
- logger.info(f" - Center: ({request.center_lat}, {request.center_lon})")
326
- logger.info(f" - Field Size: {request.field_size_hectares} hectares")
327
- logger.info(f" - Index Type: {request.index_type}")
328
- logger.info(f" - Gaussian Sigma: {request.gaussian_sigma}")
329
- logger.info(f" - Show Boundary: {request.show_field_boundary}")
330
 
331
- # Validate index type
332
  if request.index_type not in INDEX_FUNCTIONS:
333
- logger.error(f"[{request_id}] Invalid index type: {request.index_type}")
334
- raise HTTPException(
335
- status_code=400,
336
- detail=f"Invalid index type '{request.index_type}'. Supported: {list(INDEX_FUNCTIONS.keys())}"
337
- )
338
 
339
  try:
340
- # Step 1: Configure Sentinel Hub
341
- logger.info(f"[{request_id}] Step 1/5: Configuring Sentinel Hub...")
342
  config = get_sh_config()
 
343
 
344
- # Step 2: Calculate bounding box
345
- logger.info(f"[{request_id}] Step 2/5: Calculating bounding box...")
346
- field_radius_km = np.sqrt(request.field_size_hectares / 100) / 2
347
- lat_offset = field_radius_km / 111
348
- lon_offset = field_radius_km / (111 * np.cos(np.radians(request.center_lat)))
349
 
350
- bbox_coords = (
351
- request.center_lon - lon_offset,
352
- request.center_lat - lat_offset,
353
- request.center_lon + lon_offset,
354
- request.center_lat + lat_offset
355
- )
356
- bbox = BBox(bbox_coords, crs=CRS.WGS84)
357
-
358
- logger.info(f" [BBox] SW: ({bbox_coords[1]:.6f}, {bbox_coords[0]:.6f})")
359
- logger.info(f" [BBox] NE: ({bbox_coords[3]:.6f}, {bbox_coords[2]:.6f})")
360
 
361
- # Calculate resolution
362
  size = bbox_to_dimensions(bbox, resolution=10)
363
- size = (max(size[0], 64), max(size[1], 64))
364
- logger.info(f" [BBox] Image size: {size[0]}x{size[1]} pixels at 10m resolution")
365
 
366
- # Step 3: Prepare Sentinel Hub request
367
- logger.info(f"[{request_id}] Step 3/5: Preparing Sentinel Hub request...")
368
  end_date = datetime.now()
369
  start_date = end_date - timedelta(days=30)
370
- logger.info(f" [Time] Date range: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
371
 
372
- SENTINEL2_L2A_CDSE = DataCollection.define(
373
- "SENTINEL2_L2A_CDSE",
374
- api_id="sentinel-2-l2a",
375
  service_url="https://sh.dataspace.copernicus.eu",
376
- collection_type="Sentinel-2",
377
- is_timeless=False
378
  )
379
 
380
  sh_request = SentinelHubRequest(
381
  evalscript=FULL_BANDS_EVALSCRIPT,
382
- input_data=[
383
- SentinelHubRequest.input_data(
384
- data_collection=SENTINEL2_L2A_CDSE,
385
- time_interval=(start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d')),
386
- mosaicking_order='leastCC'
387
- )
388
- ],
389
  responses=[SentinelHubRequest.output_response('default', MimeType.TIFF)],
390
- bbox=bbox,
391
- size=size,
392
- config=config
393
  )
394
 
395
- # Step 4: Fetch satellite data
396
- logger.info(f"[{request_id}] Step 4/5: Fetching satellite data from Sentinel Hub...")
397
  data = sh_request.get_data()[0]
398
 
399
  if data is None or data.size == 0:
400
- logger.error(f"[{request_id}] No satellite data returned!")
401
- raise HTTPException(status_code=404, detail="No satellite data available for this location")
402
-
403
- logger.info(f" [Data] Data shape: {data.shape}")
404
- logger.info(f" [Data] Data type: {data.dtype}")
405
- logger.info(f" [Data] Data range: [{np.nanmin(data):.4f}, {np.nanmax(data):.4f}]")
406
 
407
- # Band statistics
408
- band_names = ['B01', 'B02', 'B03', 'B04', 'B05', 'B06', 'B07', 'B08', 'B8A', 'B09', 'B11', 'B12', 'dataMask']
409
- logger.info(" [Bands] Band statistics:")
410
- for i, name in enumerate(band_names[:12]):
411
- band = data[:, :, i]
412
- logger.info(f" {name}: min={np.nanmin(band):.4f}, max={np.nanmax(band):.4f}, mean={np.nanmean(band):.4f}")
413
-
414
- # Step 5: Calculate vegetation index
415
- logger.info(f"[{request_id}] Step 5/5: Calculating {request.index_type} index...")
416
- img_data = data[:, :, :12] # Remove dataMask
417
 
 
 
 
418
  index_func = INDEX_FUNCTIONS[request.index_type]
419
  index_data = index_func(img_data)
420
 
421
- logger.info(f" [Index] {request.index_type} calculated:")
422
- logger.info(f" - Shape: {index_data.shape}")
423
- logger.info(f" - Min: {np.nanmin(index_data):.4f}")
424
- logger.info(f" - Max: {np.nanmax(index_data):.4f}")
425
- logger.info(f" - Mean: {np.nanmean(index_data):.4f}")
426
 
427
- # Patch analysis
428
- logger.info(f" [Patches] Analyzing 4x4 patch grid...")
429
- patch_info = analyze_patches(index_data, request.index_type)
430
- for p in patch_info['patches'][:4]: # Show first 4 patches
431
- logger.info(f" Patch[{p['row']},{p['col']}]: mean={p['mean']:.4f}, std={p['std']:.4f}")
432
- if len(patch_info['patches']) > 4:
433
- logger.info(f" ... and {len(patch_info['patches']) - 4} more patches")
434
 
435
- # Generate heatmap
436
- logger.info(f"[{request_id}] Generating heatmap image...")
437
- img_base64, min_val, max_val, mean_val = generate_heatmap_image(
438
- index_data,
439
- request.index_type,
440
- gaussian_sigma=request.gaussian_sigma,
441
- show_field_boundary=request.show_field_boundary
442
  )
443
 
444
- logger.info(f"[{request_id}] SUCCESS - Heatmap generated!")
445
- logger.info("=" * 60)
446
 
447
  return HeatmapResponse(
448
  success=True,
449
  index_type=request.index_type,
450
- min_value=min_val,
451
- max_value=max_val,
452
- mean_value=mean_val,
453
- image_base64=img_base64,
 
454
  timestamp=datetime.now().isoformat(),
455
- image_date=end_date.strftime('%Y-%m-%d')
 
 
 
 
 
456
  )
457
 
458
  except HTTPException:
459
  raise
460
  except Exception as e:
461
- logger.error(f"[{request_id}] FAILED - Error generating heatmap:")
462
- logger.error(f" Error: {str(e)}")
463
- logger.error(f" Traceback:\n{traceback.format_exc()}")
464
- raise HTTPException(status_code=500, detail=str(e))
465
 
466
 
467
  @app.get("/generate-heatmap-image")
468
- async def generate_heatmap_image_direct(
469
- center_lat: float,
470
- center_lon: float,
471
- field_size_hectares: float = 10.0,
472
- index_type: str = "NDVI",
473
- gaussian_sigma: float = 1.5,
474
- show_field_boundary: bool = True
475
  ):
476
- """Generate and return heatmap as PNG image directly."""
477
-
478
- logger.info(f"Direct image request: {index_type} at ({center_lat}, {center_lon})")
479
-
480
  request = HeatmapRequest(
481
- center_lat=center_lat,
482
- center_lon=center_lon,
483
- field_size_hectares=field_size_hectares,
484
- index_type=index_type,
485
- gaussian_sigma=gaussian_sigma,
486
- show_field_boundary=show_field_boundary
487
  )
488
-
489
  response = await generate_heatmap(request)
490
-
491
- img_bytes = base64.b64decode(response.image_base64)
492
-
493
- return Response(content=img_bytes, media_type="image/png")
494
 
495
 
496
  if __name__ == "__main__":
497
  import uvicorn
498
- logger.info("Starting Uvicorn server on port 7860...")
499
  uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
  """
2
  AGROW Heatmap Service
3
  =====================
4
+ Generates heatmap images from Sentinel-2 satellite data using pixel-wise
5
+ vegetation indices with patch-based statistics.
6
 
7
+ Version: 2.3.0 - Pixel-wise + 100-200 patch analysis
8
  """
9
 
10
  import os
11
  import io
 
12
  import base64
13
  import logging
14
  import traceback
15
  from datetime import datetime, timedelta
16
+ from typing import Optional, List
17
 
18
  import numpy as np
19
  from scipy.ndimage import gaussian_filter
20
 
21
  import matplotlib
22
+ matplotlib.use('Agg')
23
  import matplotlib.pyplot as plt
24
  from matplotlib.colors import LinearSegmentedColormap
 
25
  from fastapi import FastAPI, HTTPException
26
  from fastapi.middleware.cors import CORSMiddleware
27
  from fastapi.responses import Response
 
32
  MimeType, bbox_to_dimensions
33
  )
34
 
35
+ from vegetation_indices import INDEX_FUNCTIONS
 
36
 
37
+ # ============================================================================
38
+ # LOGGING CONFIGURATION
39
+ # ============================================================================
40
  logging.basicConfig(
41
  level=logging.INFO,
42
+ format='[%(asctime)s] %(levelname)s: %(message)s',
43
+ datefmt='%H:%M:%S'
44
  )
45
+ logger = logging.getLogger("HeatmapService")
46
+
47
+ def log_section(title: str):
48
+ logger.info("=" * 50)
49
+ logger.info(f" {title}")
50
+ logger.info("=" * 50)
51
+
52
+ def log_step(step_num: int, total: int, msg: str):
53
+ logger.info(f"[Step {step_num}/{total}] {msg}")
54
+
55
+ def log_detail(key: str, value):
56
+ logger.info(f" • {key}: {value}")
57
+
58
+ # ============================================================================
59
+ # STARTUP
60
+ # ============================================================================
61
+ log_section("AGROW HEATMAP SERVICE v2.3.0")
62
+ log_detail("Mode", "Pixel-wise indices + 100-200 patch analysis")
63
+ log_detail("Indices available", list(INDEX_FUNCTIONS.keys()))
64
+ log_detail("SH_CLIENT_ID", "✓ Set" if os.environ.get('SH_CLIENT_ID') else "✗ NOT SET")
65
+ log_detail("SH_CLIENT_SECRET", "✓ Set" if os.environ.get('SH_CLIENT_SECRET') else "✗ NOT SET")
66
+
67
+ # ============================================================================
68
+ # FASTAPI SETUP
69
+ # ============================================================================
70
  app = FastAPI(
71
  title="AGROW Heatmap Service",
72
+ description="Pixel-wise vegetation indices with 100-200 patch analysis",
73
+ version="2.3.0"
74
  )
75
 
 
76
  app.add_middleware(
77
  CORSMiddleware,
78
  allow_origins=["*"],
 
81
  allow_headers=["*"],
82
  )
83
 
84
+ # ============================================================================
85
+ # SENTINEL HUB CONFIG
86
+ # ============================================================================
87
  def get_sh_config():
 
88
  config = SHConfig()
89
  config.sh_client_id = os.environ.get('SH_CLIENT_ID', '')
90
  config.sh_client_secret = os.environ.get('SH_CLIENT_SECRET', '')
91
  config.sh_base_url = 'https://sh.dataspace.copernicus.eu'
92
  config.sh_token_url = 'https://identity.dataspace.copernicus.eu/auth/realms/CDSE/protocol/openid-connect/token'
 
 
 
93
  return config
94
 
95
+ # ============================================================================
96
+ # REQUEST/RESPONSE MODELS
97
+ # ============================================================================
98
  class HeatmapRequest(BaseModel):
99
  center_lat: float
100
  center_lon: float
101
+ field_size_hectares: float
102
  index_type: str = "NDVI"
103
  gaussian_sigma: float = 1.5
104
  show_field_boundary: bool = True
105
+ target_patches: int = 150 # Target 100-200 patches
106
 
107
 
108
  class HeatmapResponse(BaseModel):
 
111
  min_value: float
112
  max_value: float
113
  mean_value: float
114
+ std_value: float
115
  image_base64: str
116
  timestamp: str
117
  image_date: Optional[str] = None
118
+ image_size: Optional[str] = None
119
+ num_patches: int
120
+ patch_grid: Optional[str] = None
121
+ patches: Optional[List[dict]] = None
122
+ health_summary: Optional[dict] = None
123
+
124
+ # ============================================================================
125
+ # COLORMAPS
126
+ # ============================================================================
127
  def get_vegetation_colormap():
128
+ colors = [(0.8, 0.2, 0.2), (0.9, 0.6, 0.2), (0.95, 0.9, 0.3), (0.6, 0.8, 0.3), (0.2, 0.6, 0.2)]
 
 
 
 
 
 
129
  return LinearSegmentedColormap.from_list('vegetation', colors, N=256)
130
 
 
131
  def get_water_colormap():
132
+ colors = [(0.9, 0.6, 0.3), (0.95, 0.9, 0.5), (0.5, 0.8, 0.9), (0.2, 0.5, 0.8), (0.1, 0.3, 0.6)]
 
 
 
 
 
 
133
  return LinearSegmentedColormap.from_list('water', colors, N=256)
134
 
135
+ # ============================================================================
136
+ # HEALTH CATEGORIZATION
137
+ # ============================================================================
138
+ def get_health_category(value: float, index_type: str) -> str:
139
+ if index_type in ['NDVI', 'EVI', 'NDRE']:
140
+ if value >= 0.6: return 'Healthy'
141
+ elif value >= 0.3: return 'Moderate'
142
+ else: return 'Stressed'
143
+ elif index_type in ['NDWI', 'SMI']:
144
+ if value >= 0.2: return 'Adequate'
145
+ elif value >= 0.0: return 'Moderate'
146
+ else: return 'Dry'
147
+ else:
148
+ if value >= 0.5: return 'Healthy'
149
+ elif value >= 0.25: return 'Moderate'
150
+ else: return 'Stressed'
151
 
152
+ # ============================================================================
153
+ # EVALSCRIPT
154
+ # ============================================================================
155
  FULL_BANDS_EVALSCRIPT = """
156
  //VERSION=3
157
  function setup() {
 
160
  bands: ["B01", "B02", "B03", "B04", "B05", "B06", "B07", "B08", "B8A", "B09", "B11", "B12", "dataMask"],
161
  units: "REFLECTANCE"
162
  }],
163
+ output: { bands: 13, sampleType: "FLOAT32" }
 
 
 
164
  };
165
  }
 
166
  function evaluatePixel(sample) {
167
+ return [sample.B01, sample.B02, sample.B03, sample.B04, sample.B05, sample.B06,
168
+ sample.B07, sample.B08, sample.B8A, sample.B09, sample.B11, sample.B12, sample.dataMask];
 
 
 
 
169
  }
170
  """
171
 
172
+ # ============================================================================
173
+ # PATCH ANALYSIS (100-200 patches)
174
+ # ============================================================================
175
+ def analyze_patches(data: np.ndarray, index_type: str, target_patches: int = 150) -> tuple:
176
+ """
177
+ Divide field into ~100-200 patches for statistical analysis.
178
+ Uses pixel-wise index values, then aggregates by patch.
179
+ """
180
  h, w = data.shape
 
181
 
182
+ # Calculate grid size to get ~target_patches
183
+ # patches = rows * cols, so we want sqrt(target_patches) for each dimension
184
+ grid_size = int(np.sqrt(target_patches))
185
+ grid_size = max(10, min(15, grid_size)) # Keep between 10x10 and 15x15
186
+
187
+ patch_h = max(1, h // grid_size)
188
+ patch_w = max(1, w // grid_size)
189
+
190
+ actual_rows = h // patch_h if patch_h > 0 else 1
191
+ actual_cols = w // patch_w if patch_w > 0 else 1
192
+
193
+ logger.info(f" • Grid: {actual_rows} rows × {actual_cols} cols = {actual_rows * actual_cols} patches")
194
+ logger.info(f" • Patch size: {patch_h}×{patch_w} pixels")
195
+
196
+ patches_list = []
197
+ health_counts = {}
198
+
199
+ for row in range(actual_rows):
200
+ for col in range(actual_cols):
201
+ y_start = row * patch_h
202
+ y_end = min((row + 1) * patch_h, h)
203
+ x_start = col * patch_w
204
+ x_end = min((col + 1) * patch_w, w)
205
+
206
+ patch = data[y_start:y_end, x_start:x_end]
207
  valid_pixels = np.sum(~np.isnan(patch))
208
+
209
  if valid_pixels > 0:
210
+ mean_val = float(np.nanmean(patch))
211
+ health = get_health_category(mean_val, index_type)
212
+
213
+ patches_list.append({
214
+ 'id': f"P{row}_{col}",
215
+ 'row': row,
216
+ 'col': col,
217
+ 'center_x': (x_start + x_end) // 2,
218
+ 'center_y': (y_start + y_end) // 2,
219
+ 'mean': round(mean_val, 4),
220
+ 'std': round(float(np.nanstd(patch)), 4),
221
+ 'min': round(float(np.nanmin(patch)), 4),
222
+ 'max': round(float(np.nanmax(patch)), 4),
223
+ 'health': health,
224
+ 'pixels': int(valid_pixels)
225
  })
226
+
227
+ health_counts[health] = health_counts.get(health, 0) + 1
228
 
229
+ total = len(patches_list)
230
+ health_summary = {
231
+ 'total_patches': total,
232
+ 'grid': f"{actual_rows}x{actual_cols}",
233
+ 'counts': health_counts,
234
+ 'percentages': {k: round(100 * v / total, 1) for k, v in health_counts.items()} if total > 0 else {}
235
  }
 
 
 
 
 
 
 
 
 
236
 
237
+ # Log health distribution
238
+ logger.info(" • Health Distribution:")
239
+ for cat, count in health_counts.items():
240
+ pct = 100 * count / total if total > 0 else 0
241
+ bar = "█" * int(pct // 5) + "░" * (20 - int(pct // 5))
242
+ logger.info(f" {cat:12s}: {bar} {pct:.1f}% ({count} patches)")
243
+
244
+ return patches_list, health_summary
245
+
246
+ # ============================================================================
247
+ # HEATMAP GENERATION
248
+ # ============================================================================
249
+ def generate_heatmap_image(data: np.ndarray, index_type: str, gaussian_sigma: float,
250
+ show_boundary: bool, patches_list: list = None) -> tuple:
251
+ """Generate heatmap from pixel-wise index data."""
252
 
 
253
  valid_mask = ~np.isnan(data)
254
+ valid_pct = 100 * np.sum(valid_mask) / data.size
255
+ logger.info(f" • Valid pixels: {np.sum(valid_mask):,} / {data.size:,} ({valid_pct:.1f}%)")
 
256
 
257
  if not np.any(valid_mask):
258
+ raise ValueError("No valid data pixels")
259
 
260
  min_val = float(np.nanmin(data))
261
  max_val = float(np.nanmax(data))
262
  mean_val = float(np.nanmean(data))
263
  std_val = float(np.nanstd(data))
264
 
265
+ logger.info(f" {index_type} Stats: min={min_val:.4f}, max={max_val:.4f}, mean={mean_val:.4f}, std={std_val:.4f}")
 
 
 
 
266
 
267
+ # Normalize
268
+ data_norm = np.clip((data - min_val) / (max_val - min_val + 1e-8), 0, 1)
269
+ data_norm = np.nan_to_num(data_norm, nan=0.5)
270
 
271
+ # Gaussian smoothing
272
  if gaussian_sigma > 0:
273
+ logger.info(f" Applying Gaussian smoothing (σ={gaussian_sigma})")
274
+ data_norm = gaussian_filter(data_norm, sigma=gaussian_sigma)
 
 
275
 
276
  # Create figure
 
277
  fig, ax = plt.subplots(figsize=(8, 8), dpi=100)
278
+ cmap = get_water_colormap() if index_type in ['NDWI', 'SMI'] else get_vegetation_colormap()
279
+ im = ax.imshow(data_norm, cmap=cmap, interpolation='bilinear')
280
 
281
+ # Field boundary
282
+ if show_boundary:
283
+ h, w = data_norm.shape
284
+ rect = plt.Rectangle((w*0.02, h*0.02), w*0.96, h*0.96, fill=False,
285
+ edgecolor='white', linewidth=2, linestyle='--', alpha=0.7)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  ax.add_patch(rect)
 
 
 
 
 
 
 
 
 
 
 
287
 
288
+ # Colorbar
289
  cbar = plt.colorbar(im, ax=ax, shrink=0.8, pad=0.02)
290
+ cbar.set_label(f'{index_type}', fontsize=10)
291
+ ticks = np.linspace(0, 1, 5)
292
+ cbar.set_ticks(ticks)
293
+ cbar.set_ticklabels([f'{min_val + t*(max_val-min_val):.2f}' for t in ticks])
 
 
294
 
295
  ax.set_title(f'{index_type} Heatmap', fontsize=14, fontweight='bold')
296
  ax.axis('off')
297
 
 
 
298
  buf = io.BytesIO()
299
+ plt.savefig(buf, format='png', bbox_inches='tight', facecolor='white')
300
  plt.close(fig)
301
  buf.seek(0)
302
 
303
+ img_b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
304
+ logger.info(f" Image generated: {len(img_b64):,} bytes (base64)")
305
 
306
+ return img_b64, min_val, max_val, mean_val, std_val
 
307
 
308
+ # ============================================================================
309
+ # API ENDPOINTS
310
+ # ============================================================================
311
  @app.get("/")
312
  async def root():
 
313
  return {
314
  "service": "AGROW Heatmap Service",
315
+ "version": "2.3.0",
316
+ "mode": "Pixel-wise + 100-200 patch analysis",
317
+ "indices": list(INDEX_FUNCTIONS.keys())
 
 
 
 
 
318
  }
319
 
 
320
  @app.get("/health")
321
  async def health():
322
+ return {"status": "healthy"}
 
323
 
324
 
325
  @app.post("/generate-heatmap", response_model=HeatmapResponse)
326
  async def generate_heatmap(request: HeatmapRequest):
327
+ """Generate heatmap with pixel-wise index calculation and patch analysis."""
328
 
329
+ req_id = datetime.now().strftime("%H%M%S")
330
 
331
+ log_section(f"REQUEST [{req_id}]")
332
+ log_detail("Location", f"({request.center_lat:.6f}, {request.center_lon:.6f})")
333
+ log_detail("Field Size", f"{request.field_size_hectares} hectares")
334
+ log_detail("Index", request.index_type)
335
+ log_detail("Target Patches", request.target_patches)
 
 
 
 
336
 
 
337
  if request.index_type not in INDEX_FUNCTIONS:
338
+ raise HTTPException(400, f"Invalid index: {request.index_type}")
 
 
 
 
339
 
340
  try:
341
+ # STEP 1: Config
342
+ log_step(1, 5, "Loading Sentinel Hub config")
343
  config = get_sh_config()
344
+ log_detail("Client ID", f"{config.sh_client_id[:8]}..." if config.sh_client_id else "NOT SET")
345
 
346
+ # STEP 2: Bounding Box
347
+ log_step(2, 5, "Calculating bounding box")
348
+ radius_km = np.sqrt(request.field_size_hectares / 100) / 2
349
+ lat_off = radius_km / 111
350
+ lon_off = radius_km / (111 * np.cos(np.radians(request.center_lat)))
351
 
352
+ bbox = BBox((
353
+ request.center_lon - lon_off, request.center_lat - lat_off,
354
+ request.center_lon + lon_off, request.center_lat + lat_off
355
+ ), crs=CRS.WGS84)
 
 
 
 
 
 
356
 
 
357
  size = bbox_to_dimensions(bbox, resolution=10)
358
+ log_detail("Image Size", f"{size[0]}×{size[1]} pixels @ 10m")
359
+ log_detail("Coverage", f"{size[0]*10}m × {size[1]*10}m")
360
 
361
+ # STEP 3: Fetch Data
362
+ log_step(3, 5, "Fetching Sentinel-2 data")
363
  end_date = datetime.now()
364
  start_date = end_date - timedelta(days=30)
365
+ log_detail("Date Range", f"{start_date.strftime('%Y-%m-%d')} {end_date.strftime('%Y-%m-%d')}")
366
 
367
+ SENTINEL2 = DataCollection.define(
368
+ "S2_CDSE", api_id="sentinel-2-l2a",
 
369
  service_url="https://sh.dataspace.copernicus.eu",
370
+ collection_type="Sentinel-2", is_timeless=False
 
371
  )
372
 
373
  sh_request = SentinelHubRequest(
374
  evalscript=FULL_BANDS_EVALSCRIPT,
375
+ input_data=[SentinelHubRequest.input_data(
376
+ data_collection=SENTINEL2,
377
+ time_interval=(start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d')),
378
+ mosaicking_order='leastCC'
379
+ )],
 
 
380
  responses=[SentinelHubRequest.output_response('default', MimeType.TIFF)],
381
+ bbox=bbox, size=size, config=config
 
 
382
  )
383
 
 
 
384
  data = sh_request.get_data()[0]
385
 
386
  if data is None or data.size == 0:
387
+ raise HTTPException(404, "No satellite data available")
 
 
 
 
 
388
 
389
+ log_detail("Data Shape", f"{data.shape}")
390
+ log_detail("Data Type", f"{data.dtype}")
 
 
 
 
 
 
 
 
391
 
392
+ # STEP 4: Calculate Index (PIXEL-WISE)
393
+ log_step(4, 5, f"Calculating {request.index_type} (pixel-wise)")
394
+ img_data = data[:, :, :12]
395
  index_func = INDEX_FUNCTIONS[request.index_type]
396
  index_data = index_func(img_data)
397
 
398
+ log_detail("Index Shape", f"{index_data.shape}")
399
+ log_detail("Index Range", f"[{np.nanmin(index_data):.4f}, {np.nanmax(index_data):.4f}]")
400
+ log_detail("Index Mean", f"{np.nanmean(index_data):.4f}")
 
 
401
 
402
+ # STEP 5: Patch Analysis & Heatmap
403
+ log_step(5, 5, "Analyzing patches & generating heatmap")
404
+ patches_list, health_summary = analyze_patches(index_data, request.index_type, request.target_patches)
 
 
 
 
405
 
406
+ img_b64, min_v, max_v, mean_v, std_v = generate_heatmap_image(
407
+ index_data, request.index_type, request.gaussian_sigma,
408
+ request.show_field_boundary, patches_list
 
 
 
 
409
  )
410
 
411
+ log_section(f"SUCCESS [{req_id}]")
412
+ logger.info(f" Generated {request.index_type} heatmap with {len(patches_list)} patches")
413
 
414
  return HeatmapResponse(
415
  success=True,
416
  index_type=request.index_type,
417
+ min_value=min_v,
418
+ max_value=max_v,
419
+ mean_value=mean_v,
420
+ std_value=std_v,
421
+ image_base64=img_b64,
422
  timestamp=datetime.now().isoformat(),
423
+ image_date=end_date.strftime('%Y-%m-%d'),
424
+ image_size=f"{size[0]}x{size[1]}",
425
+ num_patches=len(patches_list),
426
+ patch_grid=health_summary['grid'],
427
+ patches=patches_list[:50], # Return 50 patches
428
+ health_summary=health_summary
429
  )
430
 
431
  except HTTPException:
432
  raise
433
  except Exception as e:
434
+ logger.error(f"[{req_id}] ERROR: {str(e)}")
435
+ logger.error(traceback.format_exc())
436
+ raise HTTPException(500, str(e))
 
437
 
438
 
439
  @app.get("/generate-heatmap-image")
440
+ async def get_heatmap_image(
441
+ center_lat: float, center_lon: float, field_size_hectares: float,
442
+ index_type: str = "NDVI", gaussian_sigma: float = 1.5
 
 
 
 
443
  ):
 
 
 
 
444
  request = HeatmapRequest(
445
+ center_lat=center_lat, center_lon=center_lon,
446
+ field_size_hectares=field_size_hectares, index_type=index_type,
447
+ gaussian_sigma=gaussian_sigma
 
 
 
448
  )
 
449
  response = await generate_heatmap(request)
450
+ return Response(content=base64.b64decode(response.image_base64), media_type="image/png")
 
 
 
451
 
452
 
453
  if __name__ == "__main__":
454
  import uvicorn
 
455
  uvicorn.run(app, host="0.0.0.0", port=7860)