Spaces:
Sleeping
Sleeping
Commit ·
4a86366
1
Parent(s): 4a05f0f
v3.0.0: Multi-mode Heatmap Service
Browse filesPIXEL-WISE MODE (fast):
- soil_moisture (SMI)
- soil_organic_matter (SOMI)
- soil_fertility (SFI)
- soil_salinity (SASI)
- greenness (NDVI)
- nitrogen_level (NDRE)
- photosynthetic_capacity (PRI)
LLM MODE (CNN+Clustering+LLM):
- pest_risk
- disease_risk
- nutrient_stress
- stress_zones
Features:
- Auto-detect mode from metric type
- CNN stress detection with patch_size=4, stride=2
- K-Means clustering for stress patterns
- Gemini LLM analysis with full context
- Added GNDVI index for nutrient stress
- Dockerfile +4 -1
- app.py +297 -173
- llm_analysis.py +422 -0
- requirements.txt +3 -0
- stress_detection_model.py +475 -0
- stress_detection_preprocessing.py +203 -0
- vegetation_indices.py +9 -1
Dockerfile
CHANGED
|
@@ -12,9 +12,12 @@ RUN apt-get update && apt-get install -y \
|
|
| 12 |
COPY requirements.txt .
|
| 13 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
|
| 15 |
-
# Copy application files
|
| 16 |
COPY app.py .
|
| 17 |
COPY vegetation_indices.py .
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
# Expose port
|
| 20 |
EXPOSE 7860
|
|
|
|
| 12 |
COPY requirements.txt .
|
| 13 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
|
| 15 |
+
# Copy all application files
|
| 16 |
COPY app.py .
|
| 17 |
COPY vegetation_indices.py .
|
| 18 |
+
COPY stress_detection_model.py .
|
| 19 |
+
COPY stress_detection_preprocessing.py .
|
| 20 |
+
COPY llm_analysis.py .
|
| 21 |
|
| 22 |
# Expose port
|
| 23 |
EXPOSE 7860
|
app.py
CHANGED
|
@@ -1,10 +1,13 @@
|
|
| 1 |
"""
|
| 2 |
AGROW Heatmap Service
|
| 3 |
=====================
|
| 4 |
-
|
| 5 |
-
vegetation indices with patch-based statistics.
|
| 6 |
|
| 7 |
-
Version:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
import os
|
|
@@ -13,7 +16,7 @@ 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
|
|
@@ -32,10 +35,14 @@ from sentinelhub import (
|
|
| 32 |
MimeType, bbox_to_dimensions
|
| 33 |
)
|
| 34 |
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
# ============================================================================
|
| 38 |
-
# LOGGING
|
| 39 |
# ============================================================================
|
| 40 |
logging.basicConfig(
|
| 41 |
level=logging.INFO,
|
|
@@ -58,19 +65,44 @@ def log_detail(key: str, value):
|
|
| 58 |
# ============================================================================
|
| 59 |
# STARTUP
|
| 60 |
# ============================================================================
|
| 61 |
-
log_section("AGROW HEATMAP SERVICE
|
| 62 |
-
log_detail("Mode", "Pixel-wise
|
| 63 |
-
log_detail("
|
| 64 |
-
log_detail("
|
| 65 |
-
log_detail("
|
|
|
|
| 66 |
|
| 67 |
# ============================================================================
|
| 68 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
# ============================================================================
|
| 70 |
app = FastAPI(
|
| 71 |
title="AGROW Heatmap Service",
|
| 72 |
-
description="
|
| 73 |
-
version="
|
| 74 |
)
|
| 75 |
|
| 76 |
app.add_middleware(
|
|
@@ -99,27 +131,32 @@ class HeatmapRequest(BaseModel):
|
|
| 99 |
center_lat: float
|
| 100 |
center_lon: float
|
| 101 |
field_size_hectares: float
|
| 102 |
-
|
| 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):
|
| 109 |
success: bool
|
| 110 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 120 |
-
|
| 121 |
-
patches: Optional[List[dict]] = None
|
| 122 |
health_summary: Optional[dict] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
# ============================================================================
|
| 125 |
# COLORMAPS
|
|
@@ -132,22 +169,9 @@ 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 |
-
|
| 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
|
|
@@ -170,129 +194,98 @@ function evaluatePixel(sample) {
|
|
| 170 |
"""
|
| 171 |
|
| 172 |
# ============================================================================
|
| 173 |
-
#
|
| 174 |
# ============================================================================
|
| 175 |
-
def
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
h, w = data.shape
|
| 181 |
-
|
| 182 |
-
|
| 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 |
-
|
| 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 |
-
'
|
| 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 |
-
|
| 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,
|
| 251 |
-
"""Generate heatmap from
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
@@ -300,10 +293,61 @@ def generate_heatmap_image(data: np.ndarray, index_type: str, gaussian_sigma: fl
|
|
| 300 |
plt.close(fig)
|
| 301 |
buf.seek(0)
|
| 302 |
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
# ============================================================================
|
| 309 |
# API ENDPOINTS
|
|
@@ -312,39 +356,42 @@ def generate_heatmap_image(data: np.ndarray, index_type: str, gaussian_sigma: fl
|
|
| 312 |
async def root():
|
| 313 |
return {
|
| 314 |
"service": "AGROW Heatmap Service",
|
| 315 |
-
"version": "
|
| 316 |
-
"
|
| 317 |
-
"
|
| 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
|
| 328 |
|
| 329 |
req_id = datetime.now().strftime("%H%M%S")
|
| 330 |
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
|
|
|
|
|
|
| 336 |
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
| 339 |
|
| 340 |
try:
|
| 341 |
-
#
|
| 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 |
-
#
|
| 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)))
|
|
@@ -355,14 +402,12 @@ async def generate_heatmap(request: HeatmapRequest):
|
|
| 355 |
), crs=CRS.WGS84)
|
| 356 |
|
| 357 |
size = bbox_to_dimensions(bbox, resolution=10)
|
| 358 |
-
log_detail("Image Size", f"{size[0]}×{size[1]} pixels
|
| 359 |
-
log_detail("Coverage", f"{size[0]*10}m × {size[1]*10}m")
|
| 360 |
|
| 361 |
-
#
|
| 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",
|
|
@@ -382,51 +427,130 @@ async def generate_heatmap(request: HeatmapRequest):
|
|
| 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 |
-
#
|
| 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 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
patches
|
| 428 |
-
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
|
| 431 |
except HTTPException:
|
| 432 |
raise
|
|
@@ -439,11 +563,11 @@ async def generate_heatmap(request: HeatmapRequest):
|
|
| 439 |
@app.get("/generate-heatmap-image")
|
| 440 |
async def get_heatmap_image(
|
| 441 |
center_lat: float, center_lon: float, field_size_hectares: float,
|
| 442 |
-
|
| 443 |
):
|
| 444 |
request = HeatmapRequest(
|
| 445 |
center_lat=center_lat, center_lon=center_lon,
|
| 446 |
-
field_size_hectares=field_size_hectares,
|
| 447 |
gaussian_sigma=gaussian_sigma
|
| 448 |
)
|
| 449 |
response = await generate_heatmap(request)
|
|
|
|
| 1 |
"""
|
| 2 |
AGROW Heatmap Service
|
| 3 |
=====================
|
| 4 |
+
Multi-mode heatmap generation with pixel-wise indices AND CNN+Clustering+LLM analysis.
|
|
|
|
| 5 |
|
| 6 |
+
Version: 3.0.0 - Integrated Stress Detection
|
| 7 |
+
|
| 8 |
+
Modes (auto-detected from metric):
|
| 9 |
+
- Pixel-wise: SMI, SOMI, SFI, SASI, NDVI, NDRE, PRI, GNDVI
|
| 10 |
+
- CNN+LLM: pest_risk, disease_risk, nutrient_stress, stress_zones
|
| 11 |
"""
|
| 12 |
|
| 13 |
import os
|
|
|
|
| 16 |
import logging
|
| 17 |
import traceback
|
| 18 |
from datetime import datetime, timedelta
|
| 19 |
+
from typing import Optional, List, Dict, Any
|
| 20 |
|
| 21 |
import numpy as np
|
| 22 |
from scipy.ndimage import gaussian_filter
|
|
|
|
| 35 |
MimeType, bbox_to_dimensions
|
| 36 |
)
|
| 37 |
|
| 38 |
+
# Import modules
|
| 39 |
+
from vegetation_indices import INDEX_FUNCTIONS, calculate_all_indices
|
| 40 |
+
from stress_detection_model import StressDetectionModel, get_stress_category, prepare_llm_context
|
| 41 |
+
from stress_detection_preprocessing import preprocess_for_model
|
| 42 |
+
from llm_analysis import configure_gemini, prepare_indices_context, format_stress_context
|
| 43 |
|
| 44 |
# ============================================================================
|
| 45 |
+
# LOGGING
|
| 46 |
# ============================================================================
|
| 47 |
logging.basicConfig(
|
| 48 |
level=logging.INFO,
|
|
|
|
| 65 |
# ============================================================================
|
| 66 |
# STARTUP
|
| 67 |
# ============================================================================
|
| 68 |
+
log_section("AGROW HEATMAP SERVICE v3.0.0")
|
| 69 |
+
log_detail("Mode", "Pixel-wise + CNN+Clustering+LLM")
|
| 70 |
+
log_detail("Pixel-wise indices", "SMI, SOMI, SFI, SASI, NDVI, NDRE, PRI, GNDVI")
|
| 71 |
+
log_detail("LLM metrics", "pest_risk, disease_risk, nutrient_stress, stress_zones")
|
| 72 |
+
log_detail("SH_CLIENT_ID", "✓" if os.environ.get('SH_CLIENT_ID') else "✗")
|
| 73 |
+
log_detail("GEMINI_API_KEY", "✓" if os.environ.get('GEMINI_API_KEY') else "✗")
|
| 74 |
|
| 75 |
# ============================================================================
|
| 76 |
+
# METRIC CONFIGURATION
|
| 77 |
+
# ============================================================================
|
| 78 |
+
# Metrics that use simple pixel-wise index calculation
|
| 79 |
+
PIXELWISE_METRICS = {
|
| 80 |
+
'soil_moisture': 'SMI',
|
| 81 |
+
'soil_organic_matter': 'SOMI',
|
| 82 |
+
'soil_fertility': 'SFI',
|
| 83 |
+
'soil_salinity': 'SASI',
|
| 84 |
+
'greenness': 'NDVI',
|
| 85 |
+
'nitrogen_level': 'NDRE',
|
| 86 |
+
'photosynthetic_capacity': 'PRI',
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
# Metrics that require CNN+Clustering+LLM
|
| 90 |
+
LLM_METRICS = {
|
| 91 |
+
'pest_risk': {'primary_index': 'NDVI', 'use_stress': True},
|
| 92 |
+
'disease_risk': {'primary_index': 'PSRI', 'use_stress': True},
|
| 93 |
+
'nutrient_stress': {'primary_index': 'GNDVI', 'use_stress': True},
|
| 94 |
+
'stress_zones': {'primary_index': 'NDVI', 'use_stress': True},
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
ALL_METRICS = list(PIXELWISE_METRICS.keys()) + list(LLM_METRICS.keys())
|
| 98 |
+
|
| 99 |
+
# ============================================================================
|
| 100 |
+
# FASTAPI
|
| 101 |
# ============================================================================
|
| 102 |
app = FastAPI(
|
| 103 |
title="AGROW Heatmap Service",
|
| 104 |
+
description="Multi-mode heatmap with pixel-wise and CNN+LLM analysis",
|
| 105 |
+
version="3.0.0"
|
| 106 |
)
|
| 107 |
|
| 108 |
app.add_middleware(
|
|
|
|
| 131 |
center_lat: float
|
| 132 |
center_lon: float
|
| 133 |
field_size_hectares: float
|
| 134 |
+
metric: str # e.g., "soil_moisture", "pest_risk"
|
| 135 |
gaussian_sigma: float = 1.5
|
| 136 |
show_field_boundary: bool = True
|
|
|
|
| 137 |
|
| 138 |
|
| 139 |
class HeatmapResponse(BaseModel):
|
| 140 |
success: bool
|
| 141 |
+
metric: str
|
| 142 |
+
mode: str # "pixelwise" or "llm"
|
| 143 |
+
index_used: str
|
| 144 |
min_value: float
|
| 145 |
max_value: float
|
| 146 |
mean_value: float
|
|
|
|
| 147 |
image_base64: str
|
| 148 |
timestamp: str
|
| 149 |
image_date: Optional[str] = None
|
| 150 |
image_size: Optional[str] = None
|
| 151 |
+
# Patch analysis (for pixel-wise)
|
| 152 |
+
num_patches: Optional[int] = None
|
|
|
|
| 153 |
health_summary: Optional[dict] = None
|
| 154 |
+
# LLM analysis (for risk metrics)
|
| 155 |
+
level: Optional[str] = None
|
| 156 |
+
analysis: Optional[str] = None
|
| 157 |
+
stress_score: Optional[float] = None
|
| 158 |
+
cluster_distribution: Optional[dict] = None
|
| 159 |
+
recommendations: Optional[List[str]] = None
|
| 160 |
|
| 161 |
# ============================================================================
|
| 162 |
# COLORMAPS
|
|
|
|
| 169 |
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)]
|
| 170 |
return LinearSegmentedColormap.from_list('water', colors, N=256)
|
| 171 |
|
| 172 |
+
def get_stress_colormap():
|
| 173 |
+
colors = [(0.2, 0.7, 0.2), (0.8, 0.8, 0.2), (0.9, 0.5, 0.1), (0.8, 0.2, 0.2)]
|
| 174 |
+
return LinearSegmentedColormap.from_list('stress', colors, N=256)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
# ============================================================================
|
| 177 |
# EVALSCRIPT
|
|
|
|
| 194 |
"""
|
| 195 |
|
| 196 |
# ============================================================================
|
| 197 |
+
# PIXEL-WISE ANALYSIS
|
| 198 |
# ============================================================================
|
| 199 |
+
def get_health_category(value: float, index_type: str) -> str:
|
| 200 |
+
if index_type in ['NDVI', 'EVI', 'NDRE', 'GNDVI']:
|
| 201 |
+
if value >= 0.6: return 'Healthy'
|
| 202 |
+
elif value >= 0.3: return 'Moderate'
|
| 203 |
+
else: return 'Stressed'
|
| 204 |
+
elif index_type in ['NDWI', 'SMI']:
|
| 205 |
+
if value >= 0.2: return 'Adequate'
|
| 206 |
+
elif value >= 0.0: return 'Moderate'
|
| 207 |
+
else: return 'Dry'
|
| 208 |
+
else:
|
| 209 |
+
if value >= 0.5: return 'Healthy'
|
| 210 |
+
elif value >= 0.25: return 'Moderate'
|
| 211 |
+
else: return 'Stressed'
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def analyze_patches_pixelwise(data: np.ndarray, index_type: str, target_patches: int = 150) -> tuple:
|
| 215 |
+
"""Divide field into ~100-200 patches for statistical analysis."""
|
| 216 |
h, w = data.shape
|
| 217 |
+
grid_size = max(10, min(15, int(np.sqrt(target_patches))))
|
| 218 |
+
patch_h, patch_w = max(1, h // grid_size), max(1, w // grid_size)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
actual_rows = h // patch_h if patch_h > 0 else 1
|
| 220 |
actual_cols = w // patch_w if patch_w > 0 else 1
|
| 221 |
|
|
|
|
|
|
|
|
|
|
| 222 |
patches_list = []
|
| 223 |
health_counts = {}
|
| 224 |
|
| 225 |
for row in range(actual_rows):
|
| 226 |
for col in range(actual_cols):
|
| 227 |
+
y_start, y_end = row * patch_h, min((row + 1) * patch_h, h)
|
| 228 |
+
x_start, x_end = col * patch_w, min((col + 1) * patch_w, w)
|
|
|
|
|
|
|
|
|
|
| 229 |
patch = data[y_start:y_end, x_start:x_end]
|
| 230 |
valid_pixels = np.sum(~np.isnan(patch))
|
| 231 |
|
| 232 |
if valid_pixels > 0:
|
| 233 |
mean_val = float(np.nanmean(patch))
|
| 234 |
health = get_health_category(mean_val, index_type)
|
|
|
|
| 235 |
patches_list.append({
|
| 236 |
+
'id': f"P{row}_{col}", 'mean': round(mean_val, 4),
|
| 237 |
+
'health': health, 'pixels': int(valid_pixels)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
})
|
|
|
|
| 239 |
health_counts[health] = health_counts.get(health, 0) + 1
|
| 240 |
|
| 241 |
total = len(patches_list)
|
| 242 |
+
return patches_list, {
|
| 243 |
'total_patches': total,
|
| 244 |
'grid': f"{actual_rows}x{actual_cols}",
|
| 245 |
'counts': health_counts,
|
| 246 |
'percentages': {k: round(100 * v / total, 1) for k, v in health_counts.items()} if total > 0 else {}
|
| 247 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
# ============================================================================
|
| 250 |
# HEATMAP GENERATION
|
| 251 |
# ============================================================================
|
| 252 |
+
def generate_heatmap_image(data: np.ndarray, index_type: str, gaussian_sigma: float = 1.5,
|
| 253 |
+
show_boundary: bool = True, is_stress: bool = False) -> tuple:
|
| 254 |
+
"""Generate heatmap from index data."""
|
|
|
|
| 255 |
valid_mask = ~np.isnan(data)
|
|
|
|
|
|
|
|
|
|
| 256 |
if not np.any(valid_mask):
|
| 257 |
raise ValueError("No valid data pixels")
|
| 258 |
|
| 259 |
+
min_val, max_val = float(np.nanmin(data)), float(np.nanmax(data))
|
|
|
|
| 260 |
mean_val = float(np.nanmean(data))
|
|
|
|
|
|
|
|
|
|
| 261 |
|
|
|
|
| 262 |
data_norm = np.clip((data - min_val) / (max_val - min_val + 1e-8), 0, 1)
|
| 263 |
data_norm = np.nan_to_num(data_norm, nan=0.5)
|
| 264 |
|
|
|
|
| 265 |
if gaussian_sigma > 0:
|
|
|
|
| 266 |
data_norm = gaussian_filter(data_norm, sigma=gaussian_sigma)
|
| 267 |
|
|
|
|
| 268 |
fig, ax = plt.subplots(figsize=(8, 8), dpi=100)
|
| 269 |
+
|
| 270 |
+
if is_stress:
|
| 271 |
+
cmap = get_stress_colormap()
|
| 272 |
+
elif index_type in ['NDWI', 'SMI']:
|
| 273 |
+
cmap = get_water_colormap()
|
| 274 |
+
else:
|
| 275 |
+
cmap = get_vegetation_colormap()
|
| 276 |
+
|
| 277 |
im = ax.imshow(data_norm, cmap=cmap, interpolation='bilinear')
|
| 278 |
|
|
|
|
| 279 |
if show_boundary:
|
| 280 |
h, w = data_norm.shape
|
| 281 |
+
rect = plt.Rectangle((w*0.02, h*0.02), w*0.96, h*0.96, fill=False,
|
| 282 |
edgecolor='white', linewidth=2, linestyle='--', alpha=0.7)
|
| 283 |
ax.add_patch(rect)
|
| 284 |
|
|
|
|
| 285 |
cbar = plt.colorbar(im, ax=ax, shrink=0.8, pad=0.02)
|
| 286 |
+
cbar.set_label(f'{index_type}' if not is_stress else 'Stress Score', fontsize=10)
|
|
|
|
|
|
|
|
|
|
| 287 |
|
| 288 |
+
ax.set_title(f'{index_type} Heatmap' if not is_stress else 'Stress Heatmap', fontsize=14, fontweight='bold')
|
| 289 |
ax.axis('off')
|
| 290 |
|
| 291 |
buf = io.BytesIO()
|
|
|
|
| 293 |
plt.close(fig)
|
| 294 |
buf.seek(0)
|
| 295 |
|
| 296 |
+
return base64.b64encode(buf.getvalue()).decode('utf-8'), min_val, max_val, mean_val
|
| 297 |
+
|
| 298 |
+
# ============================================================================
|
| 299 |
+
# LLM ANALYSIS (for risk metrics)
|
| 300 |
+
# ============================================================================
|
| 301 |
+
def run_llm_analysis(metric: str, stress_context: dict, indices_data: dict) -> dict:
|
| 302 |
+
"""Call Gemini LLM with full context from stress detection."""
|
| 303 |
+
try:
|
| 304 |
+
import google.generativeai as genai
|
| 305 |
+
|
| 306 |
+
api_key = os.environ.get("GEMINI_API_KEY")
|
| 307 |
+
if not api_key:
|
| 308 |
+
return {"level": "Unknown", "analysis": "GEMINI_API_KEY not set", "recommendations": []}
|
| 309 |
+
|
| 310 |
+
genai.configure(api_key=api_key, transport='rest')
|
| 311 |
+
model = genai.GenerativeModel('gemini-flash-latest')
|
| 312 |
+
|
| 313 |
+
# Format stress context
|
| 314 |
+
stress_text = format_stress_context(stress_context)
|
| 315 |
+
|
| 316 |
+
# Create targeted prompt based on metric
|
| 317 |
+
prompt = f"""
|
| 318 |
+
CROP STRESS ANALYSIS REQUEST
|
| 319 |
+
|
| 320 |
+
{stress_text}
|
| 321 |
+
|
| 322 |
+
METRIC TO ANALYZE: {metric.upper().replace('_', ' ')}
|
| 323 |
+
|
| 324 |
+
Based on the stress detection results above, provide analysis for {metric}.
|
| 325 |
+
|
| 326 |
+
Respond with ONLY a valid JSON object (no markdown):
|
| 327 |
+
{{
|
| 328 |
+
"level": "Low" or "Moderate" or "High",
|
| 329 |
+
"analysis": "4-5 words describing the current state",
|
| 330 |
+
"temporal_trend": "Improving" or "Stable" or "Worsening",
|
| 331 |
+
"recommendations": ["action 1", "action 2"]
|
| 332 |
+
}}
|
| 333 |
+
"""
|
| 334 |
+
|
| 335 |
+
response = model.generate_content(prompt)
|
| 336 |
+
response_text = response.text.strip()
|
| 337 |
+
|
| 338 |
+
# Clean markdown if present
|
| 339 |
+
if response_text.startswith("```"):
|
| 340 |
+
lines = response_text.split("\n")
|
| 341 |
+
response_text = "\n".join(lines[1:-1])
|
| 342 |
+
if response_text.startswith("json"):
|
| 343 |
+
response_text = response_text[4:].strip()
|
| 344 |
+
|
| 345 |
+
import json
|
| 346 |
+
return json.loads(response_text)
|
| 347 |
+
|
| 348 |
+
except Exception as e:
|
| 349 |
+
logger.error(f"LLM analysis failed: {e}")
|
| 350 |
+
return {"level": "Moderate", "analysis": "Analysis unavailable", "recommendations": ["Manual inspection recommended"]}
|
| 351 |
|
| 352 |
# ============================================================================
|
| 353 |
# API ENDPOINTS
|
|
|
|
| 356 |
async def root():
|
| 357 |
return {
|
| 358 |
"service": "AGROW Heatmap Service",
|
| 359 |
+
"version": "3.0.0",
|
| 360 |
+
"modes": {"pixelwise": list(PIXELWISE_METRICS.keys()), "llm": list(LLM_METRICS.keys())},
|
| 361 |
+
"all_metrics": ALL_METRICS
|
| 362 |
}
|
| 363 |
|
| 364 |
@app.get("/health")
|
| 365 |
async def health():
|
| 366 |
+
return {"status": "healthy", "metrics": ALL_METRICS}
|
| 367 |
|
| 368 |
|
| 369 |
@app.post("/generate-heatmap", response_model=HeatmapResponse)
|
| 370 |
async def generate_heatmap(request: HeatmapRequest):
|
| 371 |
+
"""Generate heatmap - auto-detects mode based on metric."""
|
| 372 |
|
| 373 |
req_id = datetime.now().strftime("%H%M%S")
|
| 374 |
|
| 375 |
+
# Validate metric
|
| 376 |
+
if request.metric not in ALL_METRICS:
|
| 377 |
+
raise HTTPException(400, f"Invalid metric: {request.metric}. Valid: {ALL_METRICS}")
|
| 378 |
+
|
| 379 |
+
# Determine mode
|
| 380 |
+
is_llm_mode = request.metric in LLM_METRICS
|
| 381 |
+
mode = "llm" if is_llm_mode else "pixelwise"
|
| 382 |
|
| 383 |
+
log_section(f"REQUEST [{req_id}] - {mode.upper()} MODE")
|
| 384 |
+
log_detail("Metric", request.metric)
|
| 385 |
+
log_detail("Location", f"({request.center_lat:.6f}, {request.center_lon:.6f})")
|
| 386 |
+
log_detail("Field Size", f"{request.field_size_hectares} ha")
|
| 387 |
|
| 388 |
try:
|
| 389 |
+
# Step 1: Config
|
| 390 |
+
log_step(1, 6 if is_llm_mode else 5, "Loading Sentinel Hub config")
|
| 391 |
config = get_sh_config()
|
|
|
|
| 392 |
|
| 393 |
+
# Step 2: Bounding Box
|
| 394 |
+
log_step(2, 6 if is_llm_mode else 5, "Calculating bounding box")
|
| 395 |
radius_km = np.sqrt(request.field_size_hectares / 100) / 2
|
| 396 |
lat_off = radius_km / 111
|
| 397 |
lon_off = radius_km / (111 * np.cos(np.radians(request.center_lat)))
|
|
|
|
| 402 |
), crs=CRS.WGS84)
|
| 403 |
|
| 404 |
size = bbox_to_dimensions(bbox, resolution=10)
|
| 405 |
+
log_detail("Image Size", f"{size[0]}×{size[1]} pixels")
|
|
|
|
| 406 |
|
| 407 |
+
# Step 3: Fetch Data
|
| 408 |
+
log_step(3, 6 if is_llm_mode else 5, "Fetching Sentinel-2 data")
|
| 409 |
end_date = datetime.now()
|
| 410 |
start_date = end_date - timedelta(days=30)
|
|
|
|
| 411 |
|
| 412 |
SENTINEL2 = DataCollection.define(
|
| 413 |
"S2_CDSE", api_id="sentinel-2-l2a",
|
|
|
|
| 427 |
)
|
| 428 |
|
| 429 |
data = sh_request.get_data()[0]
|
|
|
|
| 430 |
if data is None or data.size == 0:
|
| 431 |
raise HTTPException(404, "No satellite data available")
|
| 432 |
|
| 433 |
log_detail("Data Shape", f"{data.shape}")
|
|
|
|
| 434 |
|
| 435 |
+
# Get image data (remove dataMask)
|
|
|
|
| 436 |
img_data = data[:, :, :12]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
|
| 438 |
+
# ================================================================
|
| 439 |
+
# PIXEL-WISE MODE
|
| 440 |
+
# ================================================================
|
| 441 |
+
if not is_llm_mode:
|
| 442 |
+
index_type = PIXELWISE_METRICS[request.metric]
|
| 443 |
+
|
| 444 |
+
log_step(4, 5, f"Calculating {index_type} (pixel-wise)")
|
| 445 |
+
index_func = INDEX_FUNCTIONS[index_type]
|
| 446 |
+
index_data = index_func(img_data)
|
| 447 |
+
|
| 448 |
+
log_step(5, 5, "Generating heatmap & patch analysis")
|
| 449 |
+
patches_list, health_summary = analyze_patches_pixelwise(index_data, index_type)
|
| 450 |
+
img_b64, min_v, max_v, mean_v = generate_heatmap_image(
|
| 451 |
+
index_data, index_type, request.gaussian_sigma, request.show_field_boundary
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
log_section(f"SUCCESS [{req_id}]")
|
| 455 |
+
|
| 456 |
+
return HeatmapResponse(
|
| 457 |
+
success=True,
|
| 458 |
+
metric=request.metric,
|
| 459 |
+
mode="pixelwise",
|
| 460 |
+
index_used=index_type,
|
| 461 |
+
min_value=min_v,
|
| 462 |
+
max_value=max_v,
|
| 463 |
+
mean_value=mean_v,
|
| 464 |
+
image_base64=img_b64,
|
| 465 |
+
timestamp=datetime.now().isoformat(),
|
| 466 |
+
image_date=end_date.strftime('%Y-%m-%d'),
|
| 467 |
+
image_size=f"{size[0]}x{size[1]}",
|
| 468 |
+
num_patches=len(patches_list),
|
| 469 |
+
health_summary=health_summary
|
| 470 |
+
)
|
| 471 |
|
| 472 |
+
# ================================================================
|
| 473 |
+
# LLM MODE (CNN + Clustering + LLM)
|
| 474 |
+
# ================================================================
|
| 475 |
+
else:
|
| 476 |
+
metric_config = LLM_METRICS[request.metric]
|
| 477 |
+
primary_index = metric_config['primary_index']
|
| 478 |
+
|
| 479 |
+
log_step(4, 6, f"Running CNN stress detection (patch=4, stride=2)")
|
| 480 |
+
|
| 481 |
+
# Reshape data for stress detection: (1, h, w, bands) -> (time, h, w, bands)
|
| 482 |
+
all_images = img_data[np.newaxis, :, :, :] # Add time dimension
|
| 483 |
+
|
| 484 |
+
# Preprocess for stress model
|
| 485 |
+
patches, patch_coords, metadata = preprocess_for_model(
|
| 486 |
+
all_images, patch_size=4, stride=2
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
log_detail("Patches extracted", f"{len(patch_coords)}")
|
| 490 |
+
log_detail("Patch shape", f"{patches.shape}")
|
| 491 |
+
|
| 492 |
+
# Build and run stress model
|
| 493 |
+
stress_model = StressDetectionModel(
|
| 494 |
+
patch_size=metadata['patch_size'],
|
| 495 |
+
num_bands=metadata['num_bands'],
|
| 496 |
+
num_timestamps=1,
|
| 497 |
+
spatial_embedding_dim=64,
|
| 498 |
+
temporal_embedding_dim=64
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
stress_results = stress_model.predict(patches, n_clusters=3, contamination=0.1)
|
| 502 |
+
|
| 503 |
+
# Prepare LLM context
|
| 504 |
+
stress_context = prepare_llm_context(stress_results, patch_coords, patches, metadata)
|
| 505 |
+
|
| 506 |
+
log_detail("Overall stress score", f"{stress_results['stress_scores'].mean():.3f}")
|
| 507 |
+
log_detail("Clusters", f"{stress_results['n_clusters']}")
|
| 508 |
+
|
| 509 |
+
log_step(5, 6, f"Running LLM analysis for {request.metric}")
|
| 510 |
+
|
| 511 |
+
# Calculate primary index for visualization
|
| 512 |
+
index_func = INDEX_FUNCTIONS[primary_index]
|
| 513 |
+
index_data = index_func(img_data)
|
| 514 |
+
|
| 515 |
+
# Run LLM analysis
|
| 516 |
+
llm_result = run_llm_analysis(request.metric, stress_context, {'primary': index_data})
|
| 517 |
+
|
| 518 |
+
log_step(6, 6, "Generating heatmap")
|
| 519 |
+
|
| 520 |
+
# Generate stress-based heatmap
|
| 521 |
+
# Create stress map from patch scores
|
| 522 |
+
h, w = img_data.shape[:2]
|
| 523 |
+
stress_map = np.zeros((h, w))
|
| 524 |
+
for i, (py, px) in enumerate(patch_coords):
|
| 525 |
+
stress_map[py:py+4, px:px+4] = stress_results['stress_scores'][i]
|
| 526 |
+
|
| 527 |
+
img_b64, min_v, max_v, mean_v = generate_heatmap_image(
|
| 528 |
+
stress_map, "Stress", request.gaussian_sigma, request.show_field_boundary, is_stress=True
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
# Get cluster distribution
|
| 532 |
+
cluster_dist = stress_context['field_statistics']['stress_distribution']
|
| 533 |
+
|
| 534 |
+
log_section(f"SUCCESS [{req_id}]")
|
| 535 |
+
|
| 536 |
+
return HeatmapResponse(
|
| 537 |
+
success=True,
|
| 538 |
+
metric=request.metric,
|
| 539 |
+
mode="llm",
|
| 540 |
+
index_used=primary_index,
|
| 541 |
+
min_value=min_v,
|
| 542 |
+
max_value=max_v,
|
| 543 |
+
mean_value=mean_v,
|
| 544 |
+
image_base64=img_b64,
|
| 545 |
+
timestamp=datetime.now().isoformat(),
|
| 546 |
+
image_date=end_date.strftime('%Y-%m-%d'),
|
| 547 |
+
image_size=f"{size[0]}x{size[1]}",
|
| 548 |
+
level=llm_result.get('level', 'Unknown'),
|
| 549 |
+
analysis=llm_result.get('analysis', ''),
|
| 550 |
+
stress_score=float(stress_results['stress_scores'].mean()),
|
| 551 |
+
cluster_distribution=cluster_dist,
|
| 552 |
+
recommendations=llm_result.get('recommendations', [])
|
| 553 |
+
)
|
| 554 |
|
| 555 |
except HTTPException:
|
| 556 |
raise
|
|
|
|
| 563 |
@app.get("/generate-heatmap-image")
|
| 564 |
async def get_heatmap_image(
|
| 565 |
center_lat: float, center_lon: float, field_size_hectares: float,
|
| 566 |
+
metric: str = "soil_moisture", gaussian_sigma: float = 1.5
|
| 567 |
):
|
| 568 |
request = HeatmapRequest(
|
| 569 |
center_lat=center_lat, center_lon=center_lon,
|
| 570 |
+
field_size_hectares=field_size_hectares, metric=metric,
|
| 571 |
gaussian_sigma=gaussian_sigma
|
| 572 |
)
|
| 573 |
response = await generate_heatmap(request)
|
llm_analysis.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LLM Integration for Vegetation Indices Analysis
|
| 3 |
+
================================================
|
| 4 |
+
|
| 5 |
+
This module integrates with Google Gemini to analyze vegetation indices
|
| 6 |
+
and provide comprehensive soil and crop insights.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
import numpy as np
|
| 12 |
+
import google.generativeai as genai
|
| 13 |
+
from typing import Dict, Any
|
| 14 |
+
|
| 15 |
+
def configure_gemini():
|
| 16 |
+
"""Configure Gemini API with key from environment."""
|
| 17 |
+
api_key = os.environ.get("GEMINI_API_KEY")
|
| 18 |
+
if not api_key:
|
| 19 |
+
raise ValueError("GEMINI_API_KEY not found in environment variables")
|
| 20 |
+
genai.configure(api_key=api_key, transport='rest')
|
| 21 |
+
return genai.GenerativeModel('gemini-flash-latest')
|
| 22 |
+
|
| 23 |
+
def prepare_indices_context(summary_report: Dict, crop_type: str, farmer_context: Dict,
|
| 24 |
+
temporal_stats: Dict = None) -> str:
|
| 25 |
+
"""
|
| 26 |
+
Prepare a comprehensive context string for the LLM including temporal statistics.
|
| 27 |
+
|
| 28 |
+
Args:
|
| 29 |
+
summary_report: Dictionary with all indices data
|
| 30 |
+
crop_type: Type of crop being analyzed
|
| 31 |
+
farmer_context: Farmer profile information
|
| 32 |
+
temporal_stats: Dictionary with temporal statistics (optional)
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
Formatted context string
|
| 36 |
+
"""
|
| 37 |
+
context = f"""
|
| 38 |
+
CROP MONITORING ANALYSIS REQUEST
|
| 39 |
+
|
| 40 |
+
CROP INFORMATION:
|
| 41 |
+
- Crop Type: {crop_type}
|
| 42 |
+
- Analysis Period: {summary_report['dates'][0]} to {summary_report['dates'][-1]}
|
| 43 |
+
- Number of Images Analyzed: {summary_report['num_images']}
|
| 44 |
+
|
| 45 |
+
FARMER CONTEXT:
|
| 46 |
+
- Role: {farmer_context.get('role', 'Unknown')}
|
| 47 |
+
- Experience: {farmer_context.get('years_farming', 'Unknown')} years
|
| 48 |
+
- Irrigation Method: {farmer_context.get('irrigation_method', 'Unknown')}
|
| 49 |
+
- Farming Goal: {farmer_context.get('farming_goal', 'Unknown')}
|
| 50 |
+
|
| 51 |
+
VEGETATION INDICES DATA (ALL 13 INDICES):
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
for index_name, stats in summary_report['indices'].items():
|
| 55 |
+
context += f"\n{index_name}:"
|
| 56 |
+
context += f"\n - Latest Mean Value: {stats['latest']['mean']:.4f}"
|
| 57 |
+
context += f"\n - Maximum in Field: {stats['max_in_field']:.4f}"
|
| 58 |
+
context += f"\n - Minimum in Field: {stats['min_in_field']:.4f}"
|
| 59 |
+
context += f"\n - Temporal Change (Latest - Oldest): {stats['change']:+.4f}"
|
| 60 |
+
context += f"\n - Temporal Trend (All Values): {stats['mean_values_over_time']}"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# Add temporal statistics if provided
|
| 64 |
+
if temporal_stats:
|
| 65 |
+
context += "\n\nTEMPORAL STATISTICS (FEATURE ENGINEERING):\n"
|
| 66 |
+
for index_name, t_stats in temporal_stats.items():
|
| 67 |
+
context += f"\n{index_name} Temporal Features:"
|
| 68 |
+
|
| 69 |
+
# Mean and std over time
|
| 70 |
+
mean_spatial = float(np.nanmean(t_stats['mean_over_time']))
|
| 71 |
+
std_spatial = float(np.nanmean(t_stats['std_over_time']))
|
| 72 |
+
context += f"\n - Spatial Mean (averaged over time): {mean_spatial:.4f}"
|
| 73 |
+
context += f"\n - Spatial Std (averaged over time): {std_spatial:.4f}"
|
| 74 |
+
|
| 75 |
+
# Max and min over time
|
| 76 |
+
max_val = float(np.nanmax(t_stats['max_over_time']))
|
| 77 |
+
min_val = float(np.nanmin(t_stats['min_over_time']))
|
| 78 |
+
range_val = float(np.nanmean(t_stats['range']))
|
| 79 |
+
context += f"\n - Maximum Value Over Time: {max_val:.4f}"
|
| 80 |
+
context += f"\n - Minimum Value Over Time: {min_val:.4f}"
|
| 81 |
+
context += f"\n - Average Range (Max-Min): {range_val:.4f}"
|
| 82 |
+
|
| 83 |
+
# Temporal trend
|
| 84 |
+
trend_mean = float(np.nanmean(t_stats['temporal_trend']))
|
| 85 |
+
context += f"\n - Average Temporal Trend: {trend_mean:+.4f}"
|
| 86 |
+
|
| 87 |
+
# Rolling average if available
|
| 88 |
+
if 'rolling_avg_3' in t_stats:
|
| 89 |
+
latest_rolling = float(np.nanmean(t_stats['rolling_avg_3'][-1]))
|
| 90 |
+
context += f"\n - Latest Rolling Average (3-period): {latest_rolling:.4f}"
|
| 91 |
+
|
| 92 |
+
return context
|
| 93 |
+
|
| 94 |
+
def format_stress_context(stress_context: Dict) -> str:
|
| 95 |
+
"""
|
| 96 |
+
Format stress detection results for LLM prompt.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
stress_context: Dictionary with stress detection results
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
Formatted string with stress patterns, clusters, and anomalies
|
| 103 |
+
"""
|
| 104 |
+
if not stress_context:
|
| 105 |
+
return ""
|
| 106 |
+
|
| 107 |
+
c = "\nDEEP LEARNING STRESS DETECTION RESULTS:\n"
|
| 108 |
+
c += "=======================================\n"
|
| 109 |
+
|
| 110 |
+
# Field Statistics
|
| 111 |
+
fs = stress_context.get('field_statistics', {})
|
| 112 |
+
c += f"Overall Field Stress Score: {fs.get('overall_stress', {}).get('mean', 0):.3f} (0=Healthy, 1=Severe Stress)\n"
|
| 113 |
+
c += f"Stress Category Distribution: {fs.get('stress_distribution', {})}\n"
|
| 114 |
+
|
| 115 |
+
# Cluster Statistics (Patterns)
|
| 116 |
+
c += "\nIDENTIFIED CLUSTERING PATTERNS (SPATIAL-TEMPORAL BEHAVIOR):\n"
|
| 117 |
+
for cluster in stress_context.get('cluster_statistics', []):
|
| 118 |
+
c += f" * Cluster {cluster['cluster_id']} ({cluster['percentage']:.1f}% of field):\n"
|
| 119 |
+
c += f" - Average Stress Score: {cluster['stress_score']['mean']:.3f}\n"
|
| 120 |
+
c += f" - Stress Variability (Std): {cluster['stress_score']['std']:.3f}\n"
|
| 121 |
+
# Add key band stats if available to explain *why* it's a cluster
|
| 122 |
+
if 'band_statistics' in cluster:
|
| 123 |
+
c += " - Key Spectral Characteristics:\n"
|
| 124 |
+
# Just show a few key bands to keep it concise
|
| 125 |
+
for band in ['B04', 'B08', 'B11']: # Red, NIR, SWIR
|
| 126 |
+
if band in cluster['band_statistics']:
|
| 127 |
+
val = cluster['band_statistics'][band]['mean']
|
| 128 |
+
c += f" {band}: {val:.4f}\n"
|
| 129 |
+
|
| 130 |
+
# Add temporal trends if available
|
| 131 |
+
if 'temporal_trends' in cluster:
|
| 132 |
+
c += " - Temporal Trends (Change over analysis period):\n"
|
| 133 |
+
for band in ['B04', 'B08', 'B11']: # Red, NIR, SWIR
|
| 134 |
+
if band in cluster['temporal_trends']:
|
| 135 |
+
trend = cluster['temporal_trends'][band]
|
| 136 |
+
c += f" {band}: {trend['trend_direction']} ({trend['change']:+.4f})\n"
|
| 137 |
+
|
| 138 |
+
# Anomaly Information
|
| 139 |
+
anom = stress_context.get('anomaly_information', {})
|
| 140 |
+
c += f"\nANOMALY DETECTION (UNUSUAL PATTERNS):\n"
|
| 141 |
+
c += f"- Total Anomalies Detected: {anom.get('total_anomalies', 0)} patches ({anom.get('anomaly_percentage', 0):.1f}% of field)\n"
|
| 142 |
+
if anom.get('anomaly_patches'):
|
| 143 |
+
c += "- Sample Anomalies:\n"
|
| 144 |
+
for p in anom['anomaly_patches'][:3]:
|
| 145 |
+
c += f" * Patch at {p['coordinates']}: Stress={p['stress_score']:.3f}, Category={p['stress_category']}\n"
|
| 146 |
+
|
| 147 |
+
return c
|
| 148 |
+
|
| 149 |
+
def analyze_with_llm(summary_report: Dict, crop_type: str, farmer_context: Dict,
|
| 150 |
+
center_lat: float, center_lon: float, field_size_hectares: float,
|
| 151 |
+
temporal_stats: Dict = None, stress_context: Dict = None) -> Dict[str, Any]:
|
| 152 |
+
"""
|
| 153 |
+
Analyze vegetation indices using Gemini LLM and extract soil insights.
|
| 154 |
+
|
| 155 |
+
Args:
|
| 156 |
+
summary_report: Dictionary with all indices data
|
| 157 |
+
crop_type: Type of crop
|
| 158 |
+
farmer_context: Farmer profile information
|
| 159 |
+
center_lat: Latitude
|
| 160 |
+
center_lon: Longitude
|
| 161 |
+
field_size_hectares: Field size
|
| 162 |
+
temporal_stats: Dictionary with temporal statistics
|
| 163 |
+
stress_context: Dictionary with stress detection results (clustering, anomalies)
|
| 164 |
+
|
| 165 |
+
Returns:
|
| 166 |
+
Dictionary with structured LLM analysis results
|
| 167 |
+
"""
|
| 168 |
+
model = configure_gemini()
|
| 169 |
+
|
| 170 |
+
# Prepare context with temporal statistics
|
| 171 |
+
indices_context = prepare_indices_context(summary_report, crop_type, farmer_context, temporal_stats)
|
| 172 |
+
|
| 173 |
+
# Prepare stress context
|
| 174 |
+
stress_text = format_stress_context(stress_context)
|
| 175 |
+
|
| 176 |
+
# Create prompt for LLM
|
| 177 |
+
# Using concatenation to avoid potential f-string parsing issues with long multi-line strings
|
| 178 |
+
prompt = f"{indices_context}\n\n{stress_text}\n\n"
|
| 179 |
+
prompt += "FIELD METADATA:\n"
|
| 180 |
+
# Add location details
|
| 181 |
+
prompt += f"- Location: Latitude {center_lat:.4f}, Longitude {center_lon:.4f}\n"
|
| 182 |
+
prompt += f"- Field Size: {field_size_hectares:.2f} hectares\n\n"
|
| 183 |
+
|
| 184 |
+
prompt += """Based on the vegetation indices data AND the deep learning stress detection results above,
|
| 185 |
+
provide a comprehensive analysis.
|
| 186 |
+
|
| 187 |
+
Use the cluster patterns to identify distinct zones in the field.
|
| 188 |
+
Use the anomaly detection results to pinpoint specific problem areas.
|
| 189 |
+
Analyze the temporal trends in each cluster to determine if stress is worsening or recovering.
|
| 190 |
+
Combine the spectral indices (NDVI, NDWI, etc.) with the stress scores to explain the *cause* of stress.
|
| 191 |
+
|
| 192 |
+
You MUST respond with a valid JSON object (no markdown, no code blocks) with EXACTLY this structure:
|
| 193 |
+
|
| 194 |
+
{
|
| 195 |
+
"soil_moisture": {
|
| 196 |
+
"level": "Low" or "Moderate" or "High",
|
| 197 |
+
"maximum_value": <float>,
|
| 198 |
+
"minimum_value": <float>,
|
| 199 |
+
"analysis": "Analyse mainly SMI patterns,spatial and temporal and variation,then check all other information along with the context given to give four words,not necessarily full sentences,but capture the sense which are very impactful,very simple to understand about the current soil moisture content of the overall field"
|
| 200 |
+
},
|
| 201 |
+
"soil_salinity": {
|
| 202 |
+
"level": "Low" or "Moderate" or "High",
|
| 203 |
+
"analysis": "Analyse mainly NDSI patterns,spatial and temporal and variation,then check all other information along with the context given to give four words,not necessarily full sentences,but capture the sense which are very impactful,very simple to understand about the current soil salinity of the overall field"
|
| 204 |
+
},
|
| 205 |
+
"organic_matter": {
|
| 206 |
+
"level": "Low" or "Moderate" or "High",
|
| 207 |
+
"analysis": "Analyse mainly SOMI patterns,spatial and temporal and variation,then check all other information along with the context given to give four words,not necessarily full sentences,but capture the sense which are very impactful,very simple to understand about the current soil organic matter content of the overall field"
|
| 208 |
+
},
|
| 209 |
+
"soil_fertility": {
|
| 210 |
+
"level": "Low" or "Moderate" or "High",
|
| 211 |
+
"analysis": "Analyse mainly SFI patterns,spatial and temporal and variation,then check all other information along with the context given to give four words,not necessarily full sentences,but capture the sense which are very impactful,very simple to understand about the current soil fertility of the overall field"
|
| 212 |
+
},
|
| 213 |
+
"Pest Rsk": {
|
| 214 |
+
"level": "Low" or "Moderate" or "High",
|
| 215 |
+
"analysis": "Analyse field patterns,temporal and spatial health variations very meticulously,catch the pattern and use the indices as additional confirmation to give accurate pest risk diseases and give 4 words not necessarily connected sentences,but capture the sense which are very impactful,very simple to understand about current pest risk or its spreading pattern"
|
| 216 |
+
},
|
| 217 |
+
"Nutrient Stress": {
|
| 218 |
+
"level": "Low" or "Moderate" or "High",
|
| 219 |
+
"analysis":"Analyse field patterns,temporal and spatial health variations very meticulously,catch the pattern and use NDRE,NDVI,MCARI,OSAVI as primary indices whose trends both spatial and temporal should be closely analysed and four words not neccesarily connected sentences,but capture the sense which are very impactful,very simple to understand about current nutrient stress"
|
| 220 |
+
},
|
| 221 |
+
"Disease Risk": {
|
| 222 |
+
"level": "Low" or "Moderate" or "High",
|
| 223 |
+
"analysis": "Analyse field patterns,temporal and spatial health variations very meticulously,catch the pattern be closely analysed and four words not neccesarily connected sentences,but capture the sense which are very impactful,very simple to understand about current disease rsik of the entire field,preferably a possible attacking agent/pest name"
|
| 224 |
+
},
|
| 225 |
+
"Stress Zone": {
|
| 226 |
+
"level": "Low" or "Moderate" or "Alert",
|
| 227 |
+
"analysis": "Analyse field patterns,temporal and spatial health variations very meticulously,catch the pattern be closely analysed and four words not neccesarily connected sentences,but capture the sense which are very impactful,very simple to understand about current stress zones in the entire field,the location,intensity,duration of stress or stress spread pattern in the field "
|
| 228 |
+
},
|
| 229 |
+
"overall_health": {
|
| 230 |
+
"status": "poor" or "fair" or "good" or "excellent",
|
| 231 |
+
"key_concerns": ["concern1", "concern2"],
|
| 232 |
+
"recommendations": ["recommendation1", "recommendation2"]
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
IMPORTANT GUIDELINES:
|
| 237 |
+
- For soil_moisture.maximum_value and minimum_value, use the SMI index values from the data
|
| 238 |
+
- For soil_salinity.trend, provide EXACTLY four words describing the trend based on SASI values
|
| 239 |
+
- For organic_matter.status, provide EXACTLY four words based on SOMI index values
|
| 240 |
+
- For soil_fertility.status, provide EXACTLY four words (not a sentence) about soil health based on SFI values
|
| 241 |
+
- For vegetation_stress.status, provide EXACTLY four words based on NDVI, EVI, NDRE temporal patterns
|
| 242 |
+
- For photosynthetic_stress.status, provide EXACTLY four words based on PRI, PSRI, RECI values
|
| 243 |
+
- For hotspot_detection.description, provide LESS THAN 6 words about stress direction and intensity
|
| 244 |
+
- For moisture_zones.description, provide NOT MORE THAN 6 words about moisture variation and trend
|
| 245 |
+
- Use spatial statistics (max, min, range) to identify hotspots and zones
|
| 246 |
+
- Consider temporal trends to detect spreading patterns
|
| 247 |
+
- Base your analysis on the actual index values provided
|
| 248 |
+
- Provide actionable insights relevant to the farmer's context
|
| 249 |
+
|
| 250 |
+
Return ONLY the JSON object, no additional text.
|
| 251 |
+
"""
|
| 252 |
+
|
| 253 |
+
# Get LLM response
|
| 254 |
+
response = model.generate_content(prompt)
|
| 255 |
+
response_text = response.text.strip()
|
| 256 |
+
|
| 257 |
+
# Remove markdown code blocks if present
|
| 258 |
+
if response_text.startswith("```"):
|
| 259 |
+
lines = response_text.split("\n")
|
| 260 |
+
response_text = "\n".join(lines[1:-1])
|
| 261 |
+
if response_text.startswith("json"):
|
| 262 |
+
response_text = response_text[4:].strip()
|
| 263 |
+
|
| 264 |
+
# Parse JSON response
|
| 265 |
+
try:
|
| 266 |
+
analysis = json.loads(response_text)
|
| 267 |
+
return analysis
|
| 268 |
+
except json.JSONDecodeError as e:
|
| 269 |
+
print(f"Error parsing LLM response: {e}")
|
| 270 |
+
print(f"Response text: {response_text}")
|
| 271 |
+
# Return fallback structure
|
| 272 |
+
return {
|
| 273 |
+
"soil_moisture": {
|
| 274 |
+
"level": "Moderate",
|
| 275 |
+
"maximum_value": summary_report['indices']['SMI']['max_in_field'],
|
| 276 |
+
"minimum_value": summary_report['indices']['SMI']['min_in_field'],
|
| 277 |
+
"analysis": "Unable to parse LLM response"
|
| 278 |
+
},
|
| 279 |
+
"soil_salinity": {
|
| 280 |
+
"level": "Moderate",
|
| 281 |
+
"analysis": "Unable to parse LLM response"
|
| 282 |
+
},
|
| 283 |
+
"organic_matter": {
|
| 284 |
+
"level": "Moderate",
|
| 285 |
+
"analysis": "Unable to parse LLM response"
|
| 286 |
+
},
|
| 287 |
+
"soil_fertility": {
|
| 288 |
+
"level": "Moderate",
|
| 289 |
+
"analysis": "Unable to parse LLM response"
|
| 290 |
+
},
|
| 291 |
+
"pest_risk": {
|
| 292 |
+
"level": "Moderate",
|
| 293 |
+
"analysis": "Unable to parse LLM response"
|
| 294 |
+
},
|
| 295 |
+
"disease_risk": {
|
| 296 |
+
"level": "Moderate",
|
| 297 |
+
"analysis": "Unable to parse LLM response"
|
| 298 |
+
},
|
| 299 |
+
"nutrient_stress": {
|
| 300 |
+
"level": "Moderate",
|
| 301 |
+
"analysis": "Unable to parse LLM response"
|
| 302 |
+
},
|
| 303 |
+
"stress_zone": {
|
| 304 |
+
"level": "Moderate",
|
| 305 |
+
"analysis": "Unable to parse LLM response"
|
| 306 |
+
},
|
| 307 |
+
"overall_health": {
|
| 308 |
+
"status": "fair",
|
| 309 |
+
"key_concerns": ["Analysis unavailable"],
|
| 310 |
+
"recommendations": ["Please review indices manually"]
|
| 311 |
+
},
|
| 312 |
+
"overall_biorisk": 0.5,
|
| 313 |
+
"overall_soil_health": 0.5
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
def format_llm_output(analysis: Dict) -> str:
|
| 317 |
+
"""
|
| 318 |
+
Format LLM analysis into a readable report.
|
| 319 |
+
|
| 320 |
+
Args:
|
| 321 |
+
analysis: Dictionary with LLM analysis results
|
| 322 |
+
|
| 323 |
+
Returns:
|
| 324 |
+
Formatted string report
|
| 325 |
+
"""
|
| 326 |
+
report = """
|
| 327 |
+
+================================================================+
|
| 328 |
+
| LLM ANALYSIS - SOIL & CROP INSIGHTS |
|
| 329 |
+
+================================================================+
|
| 330 |
+
|
| 331 |
+
SOIL MOISTURE ANALYSIS:
|
| 332 |
+
----------------------------------------------------------------
|
| 333 |
+
"""
|
| 334 |
+
|
| 335 |
+
sm = analysis['soil_moisture']
|
| 336 |
+
report += f" Level: {sm['level'].upper()}\n"
|
| 337 |
+
report += f" Maximum Value: {sm['maximum_value']:.4f}\n"
|
| 338 |
+
report += f" Minimum Value: {sm['minimum_value']:.4f}\n"
|
| 339 |
+
report += f" Analysis: {sm['analysis']}\n"
|
| 340 |
+
|
| 341 |
+
report += """
|
| 342 |
+
SOIL SALINITY ANALYSIS:
|
| 343 |
+
----------------------------------------------------------------
|
| 344 |
+
"""
|
| 345 |
+
|
| 346 |
+
ss = analysis['soil_salinity']
|
| 347 |
+
report += f" Level: {ss['level'].upper()}\n"
|
| 348 |
+
report += f" Analysis: {ss['analysis']}\n"
|
| 349 |
+
|
| 350 |
+
report += """
|
| 351 |
+
ORGANIC MATTER ANALYSIS:
|
| 352 |
+
----------------------------------------------------------------
|
| 353 |
+
"""
|
| 354 |
+
|
| 355 |
+
om = analysis['organic_matter']
|
| 356 |
+
report += f" Level: {om['level'].upper()}\n"
|
| 357 |
+
report += f" Analysis: {om['analysis']}\n"
|
| 358 |
+
|
| 359 |
+
report += """
|
| 360 |
+
SOIL FERTILITY ANALYSIS:
|
| 361 |
+
----------------------------------------------------------------
|
| 362 |
+
"""
|
| 363 |
+
|
| 364 |
+
sf = analysis['soil_fertility']
|
| 365 |
+
report += f" Level: {sf['level'].upper()}\n"
|
| 366 |
+
report += f" Analysis: {sf['analysis']}\n"
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
report += """
|
| 371 |
+
PEST RISK ANALYSIS:
|
| 372 |
+
----------------------------------------------------------------
|
| 373 |
+
"""
|
| 374 |
+
|
| 375 |
+
pr = analysis.get('pest_risk', {'level': 'unknown', 'analysis': 'No data'})
|
| 376 |
+
report += f" Level: {pr['level'].upper()}\n"
|
| 377 |
+
report += f" Analysis: {pr['analysis']}\n"
|
| 378 |
+
|
| 379 |
+
report += """
|
| 380 |
+
DISEASE RISK ANALYSIS:
|
| 381 |
+
----------------------------------------------------------------
|
| 382 |
+
"""
|
| 383 |
+
|
| 384 |
+
dr = analysis.get('disease_risk', {'level': 'unknown', 'analysis': 'No data'})
|
| 385 |
+
report += f" Level: {dr['level'].upper()}\n"
|
| 386 |
+
report += f" Analysis: {dr['analysis']}\n"
|
| 387 |
+
|
| 388 |
+
report += """
|
| 389 |
+
NUTRIENT STRESS ANALYSIS:
|
| 390 |
+
----------------------------------------------------------------
|
| 391 |
+
"""
|
| 392 |
+
|
| 393 |
+
ns = analysis.get('nutrient_stress', {'level': 'unknown', 'analysis': 'No data'})
|
| 394 |
+
report += f" Level: {ns['level'].upper()}\n"
|
| 395 |
+
report += f" Analysis: {ns['analysis']}\n"
|
| 396 |
+
|
| 397 |
+
report += """
|
| 398 |
+
STRESS ZONE ANALYSIS:
|
| 399 |
+
----------------------------------------------------------------
|
| 400 |
+
"""
|
| 401 |
+
|
| 402 |
+
sz = analysis.get('stress_zone', {'level': 'unknown', 'analysis': 'No data'})
|
| 403 |
+
report += f" Level: {sz['level'].upper()}\n"
|
| 404 |
+
report += f" Analysis: {sz['analysis']}\n"
|
| 405 |
+
|
| 406 |
+
report += """
|
| 407 |
+
OVERALL CROP HEALTH:
|
| 408 |
+
----------------------------------------------------------------
|
| 409 |
+
"""
|
| 410 |
+
|
| 411 |
+
oh = analysis['overall_health']
|
| 412 |
+
report += f" Status: {oh['status'].upper()}\n"
|
| 413 |
+
report += f"\n Key Concerns:\n"
|
| 414 |
+
for concern in oh['key_concerns']:
|
| 415 |
+
report += f" • {concern}\n"
|
| 416 |
+
report += f"\n Recommendations:\n"
|
| 417 |
+
for rec in oh['recommendations']:
|
| 418 |
+
report += f" • {rec}\n"
|
| 419 |
+
|
| 420 |
+
report += "\n" + "=" * 64 + "\n"
|
| 421 |
+
|
| 422 |
+
return report
|
requirements.txt
CHANGED
|
@@ -7,3 +7,6 @@ matplotlib>=3.7.0
|
|
| 7 |
Pillow>=9.0.0
|
| 8 |
sentinelhub>=3.9.0
|
| 9 |
python-dotenv>=1.0.0
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
Pillow>=9.0.0
|
| 8 |
sentinelhub>=3.9.0
|
| 9 |
python-dotenv>=1.0.0
|
| 10 |
+
tensorflow>=2.12.0
|
| 11 |
+
scikit-learn>=1.3.0
|
| 12 |
+
google-generativeai>=0.3.0
|
stress_detection_model.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stress Detection Model
|
| 3 |
+
=======================
|
| 4 |
+
|
| 5 |
+
Deep learning model for crop stress detection using spatial-temporal encoding.
|
| 6 |
+
Architecture: Spatial CNN → Temporal LSTM → Clustering → Anomaly Detection
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import tensorflow as tf
|
| 11 |
+
from tensorflow import keras
|
| 12 |
+
from tensorflow.keras import layers
|
| 13 |
+
from sklearn.cluster import KMeans
|
| 14 |
+
from sklearn.ensemble import IsolationForest
|
| 15 |
+
from sklearn.preprocessing import StandardScaler
|
| 16 |
+
from sklearn.metrics import silhouette_score
|
| 17 |
+
from typing import Tuple, Dict, List
|
| 18 |
+
import warnings
|
| 19 |
+
warnings.filterwarnings('ignore')
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class SpatialEncoder(keras.Model):
|
| 23 |
+
"""
|
| 24 |
+
CNN-based spatial feature extractor.
|
| 25 |
+
Processes each timestamp independently to extract spatial features.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(self, embedding_dim=128):
|
| 29 |
+
super(SpatialEncoder, self).__init__()
|
| 30 |
+
|
| 31 |
+
# Convolutional layers
|
| 32 |
+
self.conv1 = layers.Conv2D(32, (3, 3), activation='relu', padding='same')
|
| 33 |
+
self.bn1 = layers.BatchNormalization()
|
| 34 |
+
self.pool1 = layers.MaxPooling2D((2, 2))
|
| 35 |
+
self.dropout1 = layers.Dropout(0.25)
|
| 36 |
+
|
| 37 |
+
self.conv2 = layers.Conv2D(64, (3, 3), activation='relu', padding='same')
|
| 38 |
+
self.bn2 = layers.BatchNormalization()
|
| 39 |
+
self.pool2 = layers.MaxPooling2D((2, 2))
|
| 40 |
+
self.dropout2 = layers.Dropout(0.25)
|
| 41 |
+
|
| 42 |
+
self.conv3 = layers.Conv2D(128, (3, 3), activation='relu', padding='same')
|
| 43 |
+
self.bn3 = layers.BatchNormalization()
|
| 44 |
+
|
| 45 |
+
# Global pooling and dense layers
|
| 46 |
+
self.global_pool = layers.GlobalAveragePooling2D()
|
| 47 |
+
self.dense1 = layers.Dense(256, activation='relu')
|
| 48 |
+
self.dropout3 = layers.Dropout(0.3)
|
| 49 |
+
self.dense2 = layers.Dense(embedding_dim, activation='relu')
|
| 50 |
+
|
| 51 |
+
def call(self, x, training=False):
|
| 52 |
+
# x shape: (batch, height, width, channels)
|
| 53 |
+
x = self.conv1(x)
|
| 54 |
+
x = self.bn1(x, training=training)
|
| 55 |
+
x = self.pool1(x)
|
| 56 |
+
x = self.dropout1(x, training=training)
|
| 57 |
+
|
| 58 |
+
x = self.conv2(x)
|
| 59 |
+
x = self.bn2(x, training=training)
|
| 60 |
+
x = self.pool2(x)
|
| 61 |
+
x = self.dropout2(x, training=training)
|
| 62 |
+
|
| 63 |
+
x = self.conv3(x)
|
| 64 |
+
x = self.bn3(x, training=training)
|
| 65 |
+
|
| 66 |
+
x = self.global_pool(x)
|
| 67 |
+
x = self.dense1(x)
|
| 68 |
+
x = self.dropout3(x, training=training)
|
| 69 |
+
x = self.dense2(x)
|
| 70 |
+
|
| 71 |
+
return x # (batch, embedding_dim)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class TemporalEncoder(keras.Model):
|
| 75 |
+
"""
|
| 76 |
+
LSTM-based temporal feature extractor.
|
| 77 |
+
Processes sequence of spatial embeddings to capture temporal patterns.
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
def __init__(self, embedding_dim=128, lstm_units=64):
|
| 81 |
+
super(TemporalEncoder, self).__init__()
|
| 82 |
+
|
| 83 |
+
self.lstm = layers.Bidirectional(
|
| 84 |
+
layers.LSTM(lstm_units, return_sequences=False, dropout=0.2)
|
| 85 |
+
)
|
| 86 |
+
self.dense = layers.Dense(embedding_dim, activation='relu')
|
| 87 |
+
|
| 88 |
+
def call(self, x, training=False):
|
| 89 |
+
# x shape: (batch, time, spatial_embedding_dim)
|
| 90 |
+
x = self.lstm(x, training=training)
|
| 91 |
+
x = self.dense(x)
|
| 92 |
+
return x # (batch, embedding_dim)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class StressDetectionModel:
|
| 96 |
+
"""
|
| 97 |
+
Complete stress detection pipeline with spatial-temporal encoding,
|
| 98 |
+
clustering, and anomaly detection.
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
def __init__(self, patch_size=16, num_bands=8, num_timestamps=10,
|
| 102 |
+
spatial_embedding_dim=128, temporal_embedding_dim=128):
|
| 103 |
+
self.patch_size = patch_size
|
| 104 |
+
self.num_bands = num_bands
|
| 105 |
+
self.num_timestamps = num_timestamps
|
| 106 |
+
self.spatial_embedding_dim = spatial_embedding_dim
|
| 107 |
+
self.temporal_embedding_dim = temporal_embedding_dim
|
| 108 |
+
|
| 109 |
+
# Build encoders
|
| 110 |
+
self.spatial_encoder = SpatialEncoder(embedding_dim=spatial_embedding_dim)
|
| 111 |
+
self.temporal_encoder = TemporalEncoder(
|
| 112 |
+
embedding_dim=temporal_embedding_dim,
|
| 113 |
+
lstm_units=64
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# Build spatial encoder input
|
| 117 |
+
self.spatial_encoder.build((None, patch_size, patch_size, num_bands))
|
| 118 |
+
|
| 119 |
+
# Clustering and anomaly detection (fitted during inference)
|
| 120 |
+
self.kmeans = None
|
| 121 |
+
self.anomaly_detector = None
|
| 122 |
+
self.scaler = StandardScaler()
|
| 123 |
+
|
| 124 |
+
def encode_spatial_features(self, patches: np.ndarray) -> np.ndarray:
|
| 125 |
+
"""
|
| 126 |
+
Extract spatial features from all patches and timestamps.
|
| 127 |
+
|
| 128 |
+
Args:
|
| 129 |
+
patches: Array of shape (num_patches, time, height, width, bands)
|
| 130 |
+
|
| 131 |
+
Returns:
|
| 132 |
+
spatial_embeddings: Array of shape (num_patches, time, spatial_embedding_dim)
|
| 133 |
+
"""
|
| 134 |
+
num_patches, time, height, width, bands = patches.shape
|
| 135 |
+
|
| 136 |
+
# Reshape to process all patches and timestamps together
|
| 137 |
+
# (num_patches * time, height, width, bands)
|
| 138 |
+
reshaped = patches.reshape(-1, height, width, bands)
|
| 139 |
+
|
| 140 |
+
# Extract spatial features
|
| 141 |
+
spatial_features = self.spatial_encoder(reshaped, training=False).numpy()
|
| 142 |
+
|
| 143 |
+
# Reshape back to (num_patches, time, embedding_dim)
|
| 144 |
+
spatial_embeddings = spatial_features.reshape(
|
| 145 |
+
num_patches, time, self.spatial_embedding_dim
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
return spatial_embeddings
|
| 149 |
+
|
| 150 |
+
def encode_temporal_features(self, spatial_embeddings: np.ndarray) -> np.ndarray:
|
| 151 |
+
"""
|
| 152 |
+
Extract temporal features from spatial embeddings.
|
| 153 |
+
|
| 154 |
+
Args:
|
| 155 |
+
spatial_embeddings: Array of shape (num_patches, time, spatial_embedding_dim)
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
temporal_embeddings: Array of shape (num_patches, temporal_embedding_dim)
|
| 159 |
+
"""
|
| 160 |
+
temporal_embeddings = self.temporal_encoder(
|
| 161 |
+
spatial_embeddings, training=False
|
| 162 |
+
).numpy()
|
| 163 |
+
|
| 164 |
+
return temporal_embeddings
|
| 165 |
+
|
| 166 |
+
def cluster_stress_patterns(self, embeddings: np.ndarray, n_clusters=4) -> Tuple[np.ndarray, np.ndarray]:
|
| 167 |
+
"""
|
| 168 |
+
Cluster embeddings into stress categories and compute stress scores.
|
| 169 |
+
|
| 170 |
+
Args:
|
| 171 |
+
embeddings: Array of shape (num_patches, embedding_dim)
|
| 172 |
+
n_clusters: Number of clusters (4: high, moderate, low, noise)
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
cluster_labels: Cluster assignment for each patch
|
| 176 |
+
stress_scores: Normalized stress scores in [0, 1]
|
| 177 |
+
"""
|
| 178 |
+
# Standardize embeddings
|
| 179 |
+
embeddings_scaled = self.scaler.fit_transform(embeddings)
|
| 180 |
+
|
| 181 |
+
# K-Means clustering
|
| 182 |
+
self.kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10)
|
| 183 |
+
cluster_labels = self.kmeans.fit_predict(embeddings_scaled)
|
| 184 |
+
|
| 185 |
+
# Compute stress scores based on distance to cluster centers
|
| 186 |
+
distances = self.kmeans.transform(embeddings_scaled)
|
| 187 |
+
|
| 188 |
+
# For each patch, compute stress score as weighted distance to all clusters
|
| 189 |
+
# Normalize to [0, 1] range
|
| 190 |
+
stress_scores = np.min(distances, axis=1) # Distance to nearest cluster
|
| 191 |
+
stress_scores = 1 - (stress_scores - stress_scores.min()) / (stress_scores.max() - stress_scores.min() + 1e-10)
|
| 192 |
+
|
| 193 |
+
# Alternative: Use cluster centers to assign stress levels
|
| 194 |
+
# Identify which cluster represents highest stress (largest distance from origin)
|
| 195 |
+
cluster_stress_levels = np.linalg.norm(self.kmeans.cluster_centers_, axis=1)
|
| 196 |
+
cluster_stress_levels = (cluster_stress_levels - cluster_stress_levels.min()) / \
|
| 197 |
+
(cluster_stress_levels.max() - cluster_stress_levels.min() + 1e-10)
|
| 198 |
+
|
| 199 |
+
# Assign stress score based on cluster membership
|
| 200 |
+
stress_scores = cluster_stress_levels[cluster_labels]
|
| 201 |
+
|
| 202 |
+
return cluster_labels, stress_scores
|
| 203 |
+
|
| 204 |
+
def detect_anomalies(self, embeddings: np.ndarray, contamination=0.1) -> Tuple[np.ndarray, np.ndarray]:
|
| 205 |
+
"""
|
| 206 |
+
Detect anomalous stress patterns using Isolation Forest.
|
| 207 |
+
|
| 208 |
+
Args:
|
| 209 |
+
embeddings: Array of shape (num_patches, embedding_dim)
|
| 210 |
+
contamination: Expected proportion of anomalies
|
| 211 |
+
|
| 212 |
+
Returns:
|
| 213 |
+
anomaly_labels: 1 for normal, -1 for anomaly
|
| 214 |
+
anomaly_scores: Anomaly scores (lower = more anomalous)
|
| 215 |
+
"""
|
| 216 |
+
self.anomaly_detector = IsolationForest(
|
| 217 |
+
contamination=contamination,
|
| 218 |
+
random_state=42
|
| 219 |
+
)
|
| 220 |
+
anomaly_labels = self.anomaly_detector.fit_predict(embeddings)
|
| 221 |
+
anomaly_scores = self.anomaly_detector.score_samples(embeddings)
|
| 222 |
+
|
| 223 |
+
return anomaly_labels, anomaly_scores
|
| 224 |
+
|
| 225 |
+
def predict(self, patches: np.ndarray, n_clusters=4, contamination=0.1) -> Dict:
|
| 226 |
+
"""
|
| 227 |
+
Complete stress detection pipeline.
|
| 228 |
+
|
| 229 |
+
Args:
|
| 230 |
+
patches: Array of shape (num_patches, time, height, width, bands)
|
| 231 |
+
n_clusters: Number of stress clusters
|
| 232 |
+
contamination: Expected proportion of anomalies
|
| 233 |
+
|
| 234 |
+
Returns:
|
| 235 |
+
results: Dictionary with all predictions and embeddings
|
| 236 |
+
"""
|
| 237 |
+
# Step 1: Spatial encoding
|
| 238 |
+
spatial_embeddings = self.encode_spatial_features(patches)
|
| 239 |
+
|
| 240 |
+
# Step 2: Temporal encoding
|
| 241 |
+
temporal_embeddings = self.encode_temporal_features(spatial_embeddings)
|
| 242 |
+
|
| 243 |
+
# Step 3: Clustering
|
| 244 |
+
cluster_labels, stress_scores = self.cluster_stress_patterns(
|
| 245 |
+
temporal_embeddings, n_clusters=n_clusters
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
# Step 4: Anomaly detection
|
| 249 |
+
anomaly_labels, anomaly_scores = self.detect_anomalies(temporal_embeddings, contamination=contamination)
|
| 250 |
+
|
| 251 |
+
return {
|
| 252 |
+
'spatial_embeddings': spatial_embeddings,
|
| 253 |
+
'temporal_embeddings': temporal_embeddings,
|
| 254 |
+
'cluster_labels': cluster_labels,
|
| 255 |
+
'stress_scores': stress_scores,
|
| 256 |
+
'anomaly_labels': anomaly_labels,
|
| 257 |
+
'anomaly_scores': anomaly_scores,
|
| 258 |
+
'cluster_centers': self.kmeans.cluster_centers_,
|
| 259 |
+
'n_clusters': n_clusters
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def get_stress_category(stress_score: float) -> str:
|
| 264 |
+
"""Convert stress score to category label."""
|
| 265 |
+
if stress_score < 0.25:
|
| 266 |
+
return "Low Stress"
|
| 267 |
+
elif stress_score < 0.5:
|
| 268 |
+
return "Moderate Stress"
|
| 269 |
+
elif stress_score < 0.75:
|
| 270 |
+
return "High Stress"
|
| 271 |
+
else:
|
| 272 |
+
return "Severe Stress"
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def find_optimal_clusters(embeddings: np.ndarray,
|
| 276 |
+
min_clusters: int = 2,
|
| 277 |
+
max_clusters: int = 10) -> Tuple[int, Dict]:
|
| 278 |
+
"""
|
| 279 |
+
Find optimal number of clusters using elbow method and silhouette score.
|
| 280 |
+
|
| 281 |
+
Args:
|
| 282 |
+
embeddings: Array of shape (num_samples, embedding_dim)
|
| 283 |
+
min_clusters: Minimum number of clusters to test
|
| 284 |
+
max_clusters: Maximum number of clusters to test
|
| 285 |
+
|
| 286 |
+
Returns:
|
| 287 |
+
optimal_k: Optimal number of clusters
|
| 288 |
+
metrics: Dictionary with inertia and silhouette scores
|
| 289 |
+
"""
|
| 290 |
+
print("\nFinding optimal number of clusters...")
|
| 291 |
+
|
| 292 |
+
scaler = StandardScaler()
|
| 293 |
+
embeddings_scaled = scaler.fit_transform(embeddings)
|
| 294 |
+
|
| 295 |
+
inertias = []
|
| 296 |
+
silhouette_scores = []
|
| 297 |
+
k_range = range(min_clusters, max_clusters + 1)
|
| 298 |
+
|
| 299 |
+
for k in k_range:
|
| 300 |
+
kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
|
| 301 |
+
labels = kmeans.fit_predict(embeddings_scaled)
|
| 302 |
+
|
| 303 |
+
inertias.append(kmeans.inertia_)
|
| 304 |
+
|
| 305 |
+
# Calculate silhouette score (higher is better)
|
| 306 |
+
if k > 1:
|
| 307 |
+
sil_score = silhouette_score(embeddings_scaled, labels)
|
| 308 |
+
silhouette_scores.append(sil_score)
|
| 309 |
+
else:
|
| 310 |
+
silhouette_scores.append(0)
|
| 311 |
+
|
| 312 |
+
print(f" k={k}: Inertia={kmeans.inertia_:.2f}, Silhouette={silhouette_scores[-1]:.3f}")
|
| 313 |
+
|
| 314 |
+
# Find elbow using rate of change
|
| 315 |
+
inertia_diffs = np.diff(inertias)
|
| 316 |
+
inertia_diffs_2 = np.diff(inertia_diffs)
|
| 317 |
+
|
| 318 |
+
# Optimal k is where second derivative is maximum (elbow point)
|
| 319 |
+
elbow_k = min_clusters + np.argmax(np.abs(inertia_diffs_2)) + 1
|
| 320 |
+
|
| 321 |
+
# Also consider silhouette score
|
| 322 |
+
best_silhouette_k = min_clusters + np.argmax(silhouette_scores)
|
| 323 |
+
|
| 324 |
+
# Use silhouette score as primary metric, elbow as secondary
|
| 325 |
+
optimal_k = best_silhouette_k
|
| 326 |
+
|
| 327 |
+
print(f"\n[OK] Optimal clusters: {optimal_k} (Elbow: {elbow_k}, Best Silhouette: {best_silhouette_k})")
|
| 328 |
+
|
| 329 |
+
metrics = {
|
| 330 |
+
'k_range': list(k_range),
|
| 331 |
+
'inertias': inertias,
|
| 332 |
+
'silhouette_scores': silhouette_scores,
|
| 333 |
+
'optimal_k': optimal_k,
|
| 334 |
+
'elbow_k': elbow_k,
|
| 335 |
+
'best_silhouette_k': best_silhouette_k
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
return optimal_k, metrics
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def prepare_llm_context(results: Dict,
|
| 342 |
+
patch_coords: List,
|
| 343 |
+
patches: np.ndarray,
|
| 344 |
+
metadata: Dict) -> Dict:
|
| 345 |
+
"""
|
| 346 |
+
Prepare comprehensive context for LLM including cluster statistics and anomaly information.
|
| 347 |
+
|
| 348 |
+
Args:
|
| 349 |
+
results: Dictionary from model.predict()
|
| 350 |
+
patch_coords: List of (h, w) coordinates for each patch
|
| 351 |
+
patches: Original patches array
|
| 352 |
+
metadata: Preprocessing metadata
|
| 353 |
+
|
| 354 |
+
Returns:
|
| 355 |
+
context: Dictionary with cluster-wise and anomaly statistics
|
| 356 |
+
"""
|
| 357 |
+
cluster_labels = results['cluster_labels']
|
| 358 |
+
stress_scores = results['stress_scores']
|
| 359 |
+
anomaly_labels = results['anomaly_labels']
|
| 360 |
+
temporal_embeddings = results['temporal_embeddings']
|
| 361 |
+
|
| 362 |
+
# Get anomaly scores (distance from decision boundary)
|
| 363 |
+
anomaly_scores = results.get('anomaly_scores',
|
| 364 |
+
results['anomaly_labels'].astype(float))
|
| 365 |
+
|
| 366 |
+
# Cluster-wise statistics
|
| 367 |
+
cluster_stats = []
|
| 368 |
+
for cluster_id in range(results['n_clusters']):
|
| 369 |
+
cluster_mask = cluster_labels == cluster_id
|
| 370 |
+
cluster_patches = patches[cluster_mask]
|
| 371 |
+
cluster_stress = stress_scores[cluster_mask]
|
| 372 |
+
cluster_embeddings = temporal_embeddings[cluster_mask]
|
| 373 |
+
|
| 374 |
+
# Calculate statistics for this cluster
|
| 375 |
+
stats = {
|
| 376 |
+
'cluster_id': int(cluster_id),
|
| 377 |
+
'num_patches': int(np.sum(cluster_mask)),
|
| 378 |
+
'percentage': float(100 * np.sum(cluster_mask) / len(cluster_labels)),
|
| 379 |
+
'stress_score': {
|
| 380 |
+
'mean': float(cluster_stress.mean()),
|
| 381 |
+
'std': float(cluster_stress.std()),
|
| 382 |
+
'min': float(cluster_stress.min()),
|
| 383 |
+
'max': float(cluster_stress.max())
|
| 384 |
+
},
|
| 385 |
+
'embedding_stats': {
|
| 386 |
+
'mean_norm': float(np.linalg.norm(cluster_embeddings.mean(axis=0))),
|
| 387 |
+
'std_norm': float(np.linalg.norm(cluster_embeddings.std(axis=0)))
|
| 388 |
+
},
|
| 389 |
+
'band_statistics': {}
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
# Calculate per-band statistics for this cluster
|
| 393 |
+
for band_idx, band_name in enumerate(metadata['selected_bands']):
|
| 394 |
+
band_data = cluster_patches[:, :, :, :, band_idx] # (patches, time, h, w)
|
| 395 |
+
stats['band_statistics'][band_name] = {
|
| 396 |
+
'mean': float(np.nanmean(band_data)),
|
| 397 |
+
'std': float(np.nanstd(band_data)),
|
| 398 |
+
'min': float(np.nanmin(band_data)),
|
| 399 |
+
'max': float(np.nanmax(band_data))
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
cluster_stats.append(stats)
|
| 403 |
+
|
| 404 |
+
# Calculate temporal trends for this cluster
|
| 405 |
+
# Shape: (num_patches, time, h, w, bands) -> (time, bands)
|
| 406 |
+
if cluster_patches.shape[0] > 0:
|
| 407 |
+
cluster_time_series = np.nanmean(cluster_patches, axis=(0, 2, 3))
|
| 408 |
+
|
| 409 |
+
stats['temporal_trends'] = {}
|
| 410 |
+
for band_idx, band_name in enumerate(metadata['selected_bands']):
|
| 411 |
+
series = cluster_time_series[:, band_idx]
|
| 412 |
+
if len(series) > 1:
|
| 413 |
+
change = float(series[-1] - series[0])
|
| 414 |
+
trend_direction = "stable"
|
| 415 |
+
if change > 0.05: trend_direction = "increasing"
|
| 416 |
+
elif change < -0.05: trend_direction = "decreasing"
|
| 417 |
+
|
| 418 |
+
stats['temporal_trends'][band_name] = {
|
| 419 |
+
'change': change,
|
| 420 |
+
'trend_direction': trend_direction,
|
| 421 |
+
'latest_value': float(series[-1]),
|
| 422 |
+
'earliest_value': float(series[0])
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
# Anomaly information
|
| 426 |
+
anomaly_mask = anomaly_labels == -1
|
| 427 |
+
anomaly_indices = np.where(anomaly_mask)[0]
|
| 428 |
+
|
| 429 |
+
anomaly_info = {
|
| 430 |
+
'total_anomalies': int(np.sum(anomaly_mask)),
|
| 431 |
+
'anomaly_percentage': float(100 * np.sum(anomaly_mask) / len(anomaly_labels)),
|
| 432 |
+
'anomaly_patches': []
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
# Detailed info for each anomaly patch
|
| 436 |
+
for idx in anomaly_indices[:20]: # Limit to first 20 anomalies
|
| 437 |
+
patch_info = {
|
| 438 |
+
'patch_id': int(idx),
|
| 439 |
+
'coordinates': patch_coords[idx],
|
| 440 |
+
'stress_score': float(stress_scores[idx]),
|
| 441 |
+
'stress_category': get_stress_category(stress_scores[idx]),
|
| 442 |
+
'cluster_id': int(cluster_labels[idx]),
|
| 443 |
+
'anomaly_score': float(anomaly_scores[idx]) if hasattr(anomaly_scores, '__getitem__') else -1.0,
|
| 444 |
+
'embedding_norm': float(np.linalg.norm(temporal_embeddings[idx]))
|
| 445 |
+
}
|
| 446 |
+
anomaly_info['anomaly_patches'].append(patch_info)
|
| 447 |
+
|
| 448 |
+
# Overall field statistics
|
| 449 |
+
field_stats = {
|
| 450 |
+
'total_patches': len(cluster_labels),
|
| 451 |
+
'patch_size': metadata['patch_size'],
|
| 452 |
+
'num_bands': metadata['num_bands'],
|
| 453 |
+
'selected_bands': metadata['selected_bands'],
|
| 454 |
+
'overall_stress': {
|
| 455 |
+
'mean': float(stress_scores.mean()),
|
| 456 |
+
'std': float(stress_scores.std()),
|
| 457 |
+
'min': float(stress_scores.min()),
|
| 458 |
+
'max': float(stress_scores.max())
|
| 459 |
+
},
|
| 460 |
+
'stress_distribution': {
|
| 461 |
+
'low': int(np.sum(stress_scores < 0.25)),
|
| 462 |
+
'moderate': int(np.sum((stress_scores >= 0.25) & (stress_scores < 0.5))),
|
| 463 |
+
'high': int(np.sum((stress_scores >= 0.5) & (stress_scores < 0.75))),
|
| 464 |
+
'severe': int(np.sum(stress_scores >= 0.75))
|
| 465 |
+
}
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
context = {
|
| 469 |
+
'field_statistics': field_stats,
|
| 470 |
+
'cluster_statistics': cluster_stats,
|
| 471 |
+
'anomaly_information': anomaly_info
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
return context
|
| 475 |
+
|
stress_detection_preprocessing.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Stress Detection Preprocessing Module
|
| 3 |
+
======================================
|
| 4 |
+
|
| 5 |
+
Prepares Sentinel-2 multi-spectral data for stress detection model.
|
| 6 |
+
Includes band harmonization and normalization.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
from typing import Tuple, List
|
| 11 |
+
|
| 12 |
+
# Band indices in the 12-band Sentinel-2 data
|
| 13 |
+
BAND_INDICES = {
|
| 14 |
+
'B02': 1, # Blue
|
| 15 |
+
'B03': 2, # Green
|
| 16 |
+
'B04': 3, # Red
|
| 17 |
+
'B05': 4, # Red Edge 1
|
| 18 |
+
'B08': 7, # NIR
|
| 19 |
+
'B8A': 8, # NIR Narrow
|
| 20 |
+
'B11': 10, # SWIR1
|
| 21 |
+
'B12': 11 # SWIR2
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
# Selected bands for stress detection (8 major bands)
|
| 25 |
+
SELECTED_BANDS = ['B02', 'B03', 'B04', 'B05', 'B08', 'B8A', 'B11', 'B12']
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def extract_major_bands(all_images: np.ndarray) -> np.ndarray:
|
| 29 |
+
"""
|
| 30 |
+
Extract 8 major bands from 12-band Sentinel-2 data.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
all_images: Array of shape (time, height, width, 12)
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
Array of shape (time, height, width, 8) with selected bands
|
| 37 |
+
"""
|
| 38 |
+
band_idx = [BAND_INDICES[band] for band in SELECTED_BANDS]
|
| 39 |
+
return all_images[:, :, :, band_idx]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def harmonize_bands(images: np.ndarray) -> np.ndarray:
|
| 43 |
+
"""
|
| 44 |
+
Harmonize band data to same scale [0, 1].
|
| 45 |
+
|
| 46 |
+
Sentinel-2 reflectance values are already in [0, 1] range after DN/10000 conversion.
|
| 47 |
+
This function ensures all bands are properly normalized and handles any outliers.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
images: Array of shape (time, height, width, bands)
|
| 51 |
+
|
| 52 |
+
Returns:
|
| 53 |
+
Harmonized array with values clipped to [0, 1]
|
| 54 |
+
"""
|
| 55 |
+
# Clip to [0, 1] range to handle any outliers
|
| 56 |
+
harmonized = np.clip(images, 0, 1)
|
| 57 |
+
|
| 58 |
+
# Additional per-band normalization to ensure uniform scale
|
| 59 |
+
# Use percentile-based normalization to handle outliers
|
| 60 |
+
time, height, width, bands = harmonized.shape
|
| 61 |
+
|
| 62 |
+
for b in range(bands):
|
| 63 |
+
band_data = harmonized[:, :, :, b]
|
| 64 |
+
|
| 65 |
+
# Calculate 2nd and 98th percentiles to handle outliers
|
| 66 |
+
p2 = np.nanpercentile(band_data, 2)
|
| 67 |
+
p98 = np.nanpercentile(band_data, 98)
|
| 68 |
+
|
| 69 |
+
# Normalize to [0, 1] using percentiles
|
| 70 |
+
if p98 > p2:
|
| 71 |
+
harmonized[:, :, :, b] = np.clip((band_data - p2) / (p98 - p2), 0, 1)
|
| 72 |
+
|
| 73 |
+
return harmonized
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def handle_nan_values(images: np.ndarray, method='mean') -> np.ndarray:
|
| 77 |
+
"""
|
| 78 |
+
Handle NaN values in the data.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
images: Array of shape (time, height, width, bands)
|
| 82 |
+
method: 'mean', 'zero', or 'interpolate'
|
| 83 |
+
|
| 84 |
+
Returns:
|
| 85 |
+
Array with NaN values handled
|
| 86 |
+
"""
|
| 87 |
+
if method == 'zero':
|
| 88 |
+
return np.nan_to_num(images, nan=0.0)
|
| 89 |
+
elif method == 'mean':
|
| 90 |
+
# Replace NaN with temporal mean for each pixel
|
| 91 |
+
return np.where(np.isnan(images),
|
| 92 |
+
np.nanmean(images, axis=0, keepdims=True),
|
| 93 |
+
images)
|
| 94 |
+
elif method == 'interpolate':
|
| 95 |
+
# Simple linear interpolation along time axis
|
| 96 |
+
result = images.copy()
|
| 97 |
+
time, height, width, bands = images.shape
|
| 98 |
+
|
| 99 |
+
for h in range(height):
|
| 100 |
+
for w in range(width):
|
| 101 |
+
for b in range(bands):
|
| 102 |
+
pixel_series = result[:, h, w, b]
|
| 103 |
+
if np.any(np.isnan(pixel_series)):
|
| 104 |
+
# Interpolate NaN values
|
| 105 |
+
mask = ~np.isnan(pixel_series)
|
| 106 |
+
if np.any(mask):
|
| 107 |
+
indices = np.arange(time)
|
| 108 |
+
result[:, h, w, b] = np.interp(
|
| 109 |
+
indices, indices[mask], pixel_series[mask]
|
| 110 |
+
)
|
| 111 |
+
else:
|
| 112 |
+
result[:, h, w, b] = 0.0
|
| 113 |
+
return result
|
| 114 |
+
else:
|
| 115 |
+
return images
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def create_patches(images: np.ndarray, patch_size: int = 4, stride: int = 2) -> Tuple[np.ndarray, List]:
|
| 119 |
+
"""
|
| 120 |
+
Create overlapping patches from images for spatial analysis.
|
| 121 |
+
|
| 122 |
+
Args:
|
| 123 |
+
images: Array of shape (time, height, width, bands)
|
| 124 |
+
patch_size: Size of each patch
|
| 125 |
+
stride: Stride for patch extraction
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
patches: Array of shape (num_patches, time, patch_size, patch_size, bands)
|
| 129 |
+
patch_coords: List of (h_start, w_start) coordinates for each patch
|
| 130 |
+
"""
|
| 131 |
+
time, height, width, bands = images.shape
|
| 132 |
+
patches = []
|
| 133 |
+
patch_coords = []
|
| 134 |
+
|
| 135 |
+
for h in range(0, height - patch_size + 1, stride):
|
| 136 |
+
for w in range(0, width - patch_size + 1, stride):
|
| 137 |
+
patch = images[:, h:h+patch_size, w:w+patch_size, :]
|
| 138 |
+
|
| 139 |
+
# Only include patches with sufficient valid data
|
| 140 |
+
valid_ratio = np.sum(~np.isnan(patch)) / patch.size
|
| 141 |
+
if valid_ratio > 0.5: # At least 50% valid data
|
| 142 |
+
patches.append(patch)
|
| 143 |
+
patch_coords.append((h, w))
|
| 144 |
+
|
| 145 |
+
if len(patches) == 0:
|
| 146 |
+
# If no valid patches, create at least one from center
|
| 147 |
+
h_center = (height - patch_size) // 2
|
| 148 |
+
w_center = (width - patch_size) // 2
|
| 149 |
+
patch = images[:, h_center:h_center+patch_size, w_center:w_center+patch_size, :]
|
| 150 |
+
patches.append(patch)
|
| 151 |
+
patch_coords.append((h_center, w_center))
|
| 152 |
+
|
| 153 |
+
return np.array(patches), patch_coords
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def preprocess_for_model(all_images: np.ndarray,
|
| 157 |
+
patch_size: int = 4,
|
| 158 |
+
stride: int = 2) -> Tuple[np.ndarray, List, dict]:
|
| 159 |
+
"""
|
| 160 |
+
Complete preprocessing pipeline for stress detection model.
|
| 161 |
+
|
| 162 |
+
Args:
|
| 163 |
+
all_images: Raw images of shape (time, height, width, 12)
|
| 164 |
+
patch_size: Size of patches for spatial analysis
|
| 165 |
+
stride: Stride for patch extraction
|
| 166 |
+
|
| 167 |
+
Returns:
|
| 168 |
+
patches: Preprocessed patches ready for model
|
| 169 |
+
patch_coords: Coordinates of each patch
|
| 170 |
+
metadata: Dictionary with preprocessing information
|
| 171 |
+
"""
|
| 172 |
+
print("Preprocessing data for stress detection model...")
|
| 173 |
+
|
| 174 |
+
# Step 1: Extract major bands
|
| 175 |
+
print(" [1/4] Extracting 8 major bands...")
|
| 176 |
+
major_bands = extract_major_bands(all_images)
|
| 177 |
+
|
| 178 |
+
# Step 2: Harmonize bands to same scale
|
| 179 |
+
print(" [2/4] Harmonizing bands to [0, 1] scale...")
|
| 180 |
+
harmonized = harmonize_bands(major_bands)
|
| 181 |
+
|
| 182 |
+
# Step 3: Handle NaN values
|
| 183 |
+
print(" [3/4] Handling NaN values...")
|
| 184 |
+
clean_data = handle_nan_values(harmonized, method='mean')
|
| 185 |
+
|
| 186 |
+
# Step 4: Create patches
|
| 187 |
+
print(" [4/4] Creating spatial patches...")
|
| 188 |
+
patches, patch_coords = create_patches(clean_data, patch_size, stride)
|
| 189 |
+
|
| 190 |
+
metadata = {
|
| 191 |
+
'original_shape': all_images.shape,
|
| 192 |
+
'selected_bands': SELECTED_BANDS,
|
| 193 |
+
'num_bands': len(SELECTED_BANDS),
|
| 194 |
+
'patch_size': patch_size,
|
| 195 |
+
'stride': stride,
|
| 196 |
+
'num_patches': len(patches),
|
| 197 |
+
'harmonized': True
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
print(f"[OK] Preprocessing complete. Created {len(patches)} patches.")
|
| 201 |
+
print(f" Patch shape: {patches.shape}")
|
| 202 |
+
|
| 203 |
+
return patches, patch_coords, metadata
|
vegetation_indices.py
CHANGED
|
@@ -113,6 +113,13 @@ def calculate_sfi(img: np.ndarray) -> np.ndarray:
|
|
| 113 |
sasi = calculate_sasi(img)
|
| 114 |
return (ndvi * somi) / (sasi + 1e-10)
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
# Index registry
|
| 117 |
INDEX_FUNCTIONS = {
|
| 118 |
'NDVI': calculate_ndvi,
|
|
@@ -127,7 +134,8 @@ INDEX_FUNCTIONS = {
|
|
| 127 |
'MCARI': calculate_mcari,
|
| 128 |
'SASI': calculate_sasi,
|
| 129 |
'SOMI': calculate_somi,
|
| 130 |
-
'SFI': calculate_sfi
|
|
|
|
| 131 |
}
|
| 132 |
|
| 133 |
# ==================== BATCH CALCULATION ====================
|
|
|
|
| 113 |
sasi = calculate_sasi(img)
|
| 114 |
return (ndvi * somi) / (sasi + 1e-10)
|
| 115 |
|
| 116 |
+
def calculate_gndvi(img: np.ndarray) -> np.ndarray:
|
| 117 |
+
"""GNDVI (Green NDVI) = (NIR - GREEN) / (NIR + GREEN)
|
| 118 |
+
Highly sensitive to chlorophyll content and nitrogen status"""
|
| 119 |
+
nir = img[:, :, 7] # B08
|
| 120 |
+
green = img[:, :, 2] # B03
|
| 121 |
+
return (nir - green) / (nir + green + 1e-10)
|
| 122 |
+
|
| 123 |
# Index registry
|
| 124 |
INDEX_FUNCTIONS = {
|
| 125 |
'NDVI': calculate_ndvi,
|
|
|
|
| 134 |
'MCARI': calculate_mcari,
|
| 135 |
'SASI': calculate_sasi,
|
| 136 |
'SOMI': calculate_somi,
|
| 137 |
+
'SFI': calculate_sfi,
|
| 138 |
+
'GNDVI': calculate_gndvi
|
| 139 |
}
|
| 140 |
|
| 141 |
# ==================== BATCH CALCULATION ====================
|