Spaces:
Sleeping
Sleeping
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
app.py
CHANGED
|
@@ -1,29 +1,27 @@
|
|
| 1 |
"""
|
| 2 |
AGROW Heatmap Service
|
| 3 |
=====================
|
| 4 |
-
Generates heatmap images from Sentinel-2 satellite data using
|
| 5 |
-
vegetation indices
|
| 6 |
|
| 7 |
-
Version: 2.
|
| 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')
|
| 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 |
-
|
| 38 |
-
from vegetation_indices import INDEX_FUNCTIONS, calculate_all_indices
|
| 39 |
|
| 40 |
-
#
|
|
|
|
|
|
|
| 41 |
logging.basicConfig(
|
| 42 |
level=logging.INFO,
|
| 43 |
-
format='%(asctime)s
|
| 44 |
-
datefmt='%
|
| 45 |
)
|
| 46 |
-
logger = logging.getLogger(
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
logger.info("=" *
|
| 50 |
-
logger.info("
|
| 51 |
-
logger.info("=" *
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
logger.info(f"
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
app = FastAPI(
|
| 59 |
title="AGROW Heatmap Service",
|
| 60 |
-
description="
|
| 61 |
-
version="2.
|
| 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 |
-
#
|
|
|
|
| 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 |
-
#
|
|
|
|
| 89 |
class HeatmapRequest(BaseModel):
|
| 90 |
center_lat: float
|
| 91 |
center_lon: float
|
| 92 |
-
field_size_hectares: float
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
h, w = data.shape
|
| 162 |
-
patch_h, patch_w = h // 4, w // 4
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
valid_pixels = np.sum(~np.isnan(patch))
|
|
|
|
| 169 |
if valid_pixels > 0:
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
'
|
| 175 |
-
'
|
| 176 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
})
|
|
|
|
|
|
|
| 178 |
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
'
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
-
# Handle NaN values
|
| 196 |
valid_mask = ~np.isnan(data)
|
| 197 |
-
|
| 198 |
-
|
| 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
|
| 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"
|
| 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
|
| 216 |
-
|
| 217 |
-
|
| 218 |
|
| 219 |
-
#
|
| 220 |
if gaussian_sigma > 0:
|
| 221 |
-
logger.info(f"
|
| 222 |
-
|
| 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 |
-
#
|
| 231 |
-
if
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 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 |
-
#
|
| 269 |
cbar = plt.colorbar(im, ax=ax, shrink=0.8, pad=0.02)
|
| 270 |
-
cbar.set_label(f'{index_type}
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 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'
|
| 284 |
plt.close(fig)
|
| 285 |
buf.seek(0)
|
| 286 |
|
| 287 |
-
|
| 288 |
-
logger.info(f"
|
| 289 |
|
| 290 |
-
return
|
| 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.
|
| 299 |
-
"
|
| 300 |
-
"
|
| 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 |
-
|
| 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
|
| 318 |
|
| 319 |
-
|
| 320 |
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 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 |
-
|
| 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 |
-
#
|
| 341 |
-
|
| 342 |
config = get_sh_config()
|
|
|
|
| 343 |
|
| 344 |
-
#
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
|
| 350 |
-
|
| 351 |
-
request.center_lon -
|
| 352 |
-
request.
|
| 353 |
-
|
| 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 |
-
|
| 364 |
-
|
| 365 |
|
| 366 |
-
#
|
| 367 |
-
|
| 368 |
end_date = datetime.now()
|
| 369 |
start_date = end_date - timedelta(days=30)
|
| 370 |
-
|
| 371 |
|
| 372 |
-
|
| 373 |
-
"
|
| 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 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 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 |
-
|
| 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 |
-
|
| 408 |
-
|
| 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 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
logger.info(f" - Max: {np.nanmax(index_data):.4f}")
|
| 425 |
-
logger.info(f" - Mean: {np.nanmean(index_data):.4f}")
|
| 426 |
|
| 427 |
-
# Patch
|
| 428 |
-
|
| 429 |
-
|
| 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 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
index_data,
|
| 439 |
-
request.index_type,
|
| 440 |
-
gaussian_sigma=request.gaussian_sigma,
|
| 441 |
-
show_field_boundary=request.show_field_boundary
|
| 442 |
)
|
| 443 |
|
| 444 |
-
|
| 445 |
-
logger.info("
|
| 446 |
|
| 447 |
return HeatmapResponse(
|
| 448 |
success=True,
|
| 449 |
index_type=request.index_type,
|
| 450 |
-
min_value=
|
| 451 |
-
max_value=
|
| 452 |
-
mean_value=
|
| 453 |
-
|
|
|
|
| 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"[{
|
| 462 |
-
logger.error(
|
| 463 |
-
|
| 464 |
-
raise HTTPException(status_code=500, detail=str(e))
|
| 465 |
|
| 466 |
|
| 467 |
@app.get("/generate-heatmap-image")
|
| 468 |
-
async def
|
| 469 |
-
center_lat: float,
|
| 470 |
-
|
| 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 |
-
|
| 483 |
-
|
| 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)
|