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

Integrate S2 Pipeline vegetation_indices.py

Browse files

- Use same index calculation functions from main pipeline
- Support all 13 indices: NDVI, EVI, NDWI, NDRE, RECI, SMI, NDSI, PRI, PSRI, MCARI, SASI, SOMI, SFI
- Fetch all 13 bands from Sentinel-2 and calculate indices locally
- Add water-specific colormap for NDWI/SMI

Files changed (2) hide show
  1. app.py +89 -91
  2. vegetation_indices.py +246 -0
app.py CHANGED
@@ -1,11 +1,13 @@
1
  """
2
  AGROW Heatmap Service
3
  =====================
4
- Generates heatmap images from Sentinel-2 satellite data.
 
5
  """
6
 
7
  import os
8
  import io
 
9
  import base64
10
  import logging
11
  from datetime import datetime, timedelta
@@ -27,6 +29,9 @@ from sentinelhub import (
27
  MimeType, bbox_to_dimensions, SentinelHubCatalog
28
  )
29
 
 
 
 
30
  # Configure logging
31
  logging.basicConfig(level=logging.INFO)
32
  logger = logging.getLogger(__name__)
@@ -35,7 +40,7 @@ logger = logging.getLogger(__name__)
35
  app = FastAPI(
36
  title="AGROW Heatmap Service",
37
  description="Generate heatmap images from Sentinel-2 vegetation indices",
38
- version="1.0.0"
39
  )
40
 
41
  # CORS
@@ -47,6 +52,7 @@ app.add_middleware(
47
  allow_headers=["*"],
48
  )
49
 
 
50
  # Sentinel Hub configuration
51
  def get_sh_config():
52
  config = SHConfig()
@@ -62,7 +68,7 @@ class HeatmapRequest(BaseModel):
62
  center_lat: float
63
  center_lon: float
64
  field_size_hectares: float = 10.0
65
- index_type: str = "NDVI" # NDVI, EVI, NDWI, SMI, NDRE
66
 
67
 
68
  class HeatmapResponse(BaseModel):
@@ -73,6 +79,7 @@ class HeatmapResponse(BaseModel):
73
  mean_value: float
74
  image_base64: str
75
  timestamp: str
 
76
 
77
 
78
  # Custom colormap for vegetation indices
@@ -88,78 +95,54 @@ def get_vegetation_colormap():
88
  return LinearSegmentedColormap.from_list('vegetation', colors, N=256)
89
 
90
 
91
- def create_evalscript(index_type: str) -> str:
92
- """Create Sentinel Hub evalscript for the requested index."""
93
-
94
- evalscripts = {
95
- "NDVI": """
96
- //VERSION=3
97
- function setup() {
98
- return {
99
- input: ["B04", "B08", "dataMask"],
100
- output: { bands: 1, sampleType: "FLOAT32" }
101
- };
102
- }
103
- function evaluatePixel(sample) {
104
- let ndvi = (sample.B08 - sample.B04) / (sample.B08 + sample.B04);
105
- return [ndvi * sample.dataMask];
106
- }
107
- """,
108
- "EVI": """
109
- //VERSION=3
110
- function setup() {
111
- return {
112
- input: ["B02", "B04", "B08", "dataMask"],
113
- output: { bands: 1, sampleType: "FLOAT32" }
114
- };
115
- }
116
- function evaluatePixel(sample) {
117
- let evi = 2.5 * (sample.B08 - sample.B04) / (sample.B08 + 6 * sample.B04 - 7.5 * sample.B02 + 1);
118
- return [evi * sample.dataMask];
119
- }
120
- """,
121
- "NDWI": """
122
- //VERSION=3
123
- function setup() {
124
- return {
125
- input: ["B03", "B08", "dataMask"],
126
- output: { bands: 1, sampleType: "FLOAT32" }
127
- };
128
- }
129
- function evaluatePixel(sample) {
130
- let ndwi = (sample.B03 - sample.B08) / (sample.B03 + sample.B08);
131
- return [ndwi * sample.dataMask];
132
- }
133
- """,
134
- "NDRE": """
135
- //VERSION=3
136
- function setup() {
137
- return {
138
- input: ["B05", "B08", "dataMask"],
139
- output: { bands: 1, sampleType: "FLOAT32" }
140
- };
141
- }
142
- function evaluatePixel(sample) {
143
- let ndre = (sample.B08 - sample.B05) / (sample.B08 + sample.B05);
144
- return [ndre * sample.dataMask];
145
- }
146
- """,
147
- "SMI": """
148
- //VERSION=3
149
- function setup() {
150
- return {
151
- input: ["B8A", "B11", "dataMask"],
152
- output: { bands: 1, sampleType: "FLOAT32" }
153
- };
154
- }
155
- function evaluatePixel(sample) {
156
- let smi = (sample.B8A - sample.B11) / (sample.B8A + sample.B11);
157
- return [smi * sample.dataMask];
158
  }
159
- """,
160
- }
161
-
162
- return evalscripts.get(index_type, evalscripts["NDVI"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
 
165
  def generate_heatmap_image(data: np.ndarray, index_type: str) -> tuple:
@@ -181,8 +164,12 @@ def generate_heatmap_image(data: np.ndarray, index_type: str) -> tuple:
181
  # Create figure
182
  fig, ax = plt.subplots(figsize=(8, 8), dpi=100)
183
 
184
- # Apply colormap
185
- cmap = get_vegetation_colormap()
 
 
 
 
186
  im = ax.imshow(data_normalized, cmap=cmap, interpolation='bilinear')
187
 
188
  # Add colorbar
@@ -215,10 +202,12 @@ def generate_heatmap_image(data: np.ndarray, index_type: str) -> tuple:
215
  async def root():
216
  return {
217
  "service": "AGROW Heatmap Service",
218
- "version": "1.0.0",
219
  "status": "running",
 
220
  "endpoints": {
221
  "/generate-heatmap": "POST - Generate heatmap from coordinates",
 
222
  "/health": "GET - Health check"
223
  }
224
  }
@@ -226,20 +215,26 @@ async def root():
226
 
227
  @app.get("/health")
228
  async def health():
229
- return {"status": "healthy"}
230
 
231
 
232
  @app.post("/generate-heatmap", response_model=HeatmapResponse)
233
  async def generate_heatmap(request: HeatmapRequest):
234
  """Generate a heatmap image for the specified location and index type."""
235
 
 
 
 
 
 
 
 
236
  try:
237
  logger.info(f"Generating {request.index_type} heatmap for ({request.center_lat}, {request.center_lon})")
238
 
239
  config = get_sh_config()
240
 
241
  # Calculate bounding box from center and field size
242
- # Approximate conversion: 1 degree ≈ 111km at equator
243
  field_radius_km = np.sqrt(request.field_size_hectares / 100) / 2
244
  lat_offset = field_radius_km / 111
245
  lon_offset = field_radius_km / (111 * np.cos(np.radians(request.center_lat)))
@@ -256,26 +251,21 @@ async def generate_heatmap(request: HeatmapRequest):
256
 
257
  # Calculate resolution (10m per pixel)
258
  size = bbox_to_dimensions(bbox, resolution=10)
259
-
260
- # Ensure minimum size
261
  size = (max(size[0], 64), max(size[1], 64))
262
 
263
  # Get recent date
264
  end_date = datetime.now()
265
  start_date = end_date - timedelta(days=30)
266
 
267
- # Create evalscript
268
- evalscript = create_evalscript(request.index_type)
269
-
270
  # Define data collection for CDSE
271
  SENTINEL2_L2A_CDSE = DataCollection.define_from(
272
  DataCollection.SENTINEL2_L2A,
273
  service_url='https://sh.dataspace.copernicus.eu'
274
  )
275
 
276
- # Create request
277
  sh_request = SentinelHubRequest(
278
- evalscript=evalscript,
279
  input_data=[
280
  SentinelHubRequest.input_data(
281
  data_collection=SENTINEL2_L2A_CDSE,
@@ -295,12 +285,19 @@ async def generate_heatmap(request: HeatmapRequest):
295
  if data is None or data.size == 0:
296
  raise HTTPException(status_code=404, detail="No satellite data available for this location")
297
 
298
- # Squeeze if needed
299
- if len(data.shape) > 2:
300
- data = data.squeeze()
 
 
 
 
 
 
 
301
 
302
  # Generate heatmap
303
- img_base64, min_val, max_val, mean_val = generate_heatmap_image(data, request.index_type)
304
 
305
  return HeatmapResponse(
306
  success=True,
@@ -309,7 +306,8 @@ async def generate_heatmap(request: HeatmapRequest):
309
  max_value=max_val,
310
  mean_value=mean_val,
311
  image_base64=img_base64,
312
- timestamp=datetime.now().isoformat()
 
313
  )
314
 
315
  except HTTPException:
 
1
  """
2
  AGROW Heatmap Service
3
  =====================
4
+ Generates heatmap images from Sentinel-2 satellite data using the same
5
+ vegetation indices from the main Sentinel-2 pipeline.
6
  """
7
 
8
  import os
9
  import io
10
+ import re
11
  import base64
12
  import logging
13
  from datetime import datetime, timedelta
 
29
  MimeType, bbox_to_dimensions, SentinelHubCatalog
30
  )
31
 
32
+ # Import vegetation indices from S2 pipeline
33
+ from vegetation_indices import INDEX_FUNCTIONS, calculate_all_indices
34
+
35
  # Configure logging
36
  logging.basicConfig(level=logging.INFO)
37
  logger = logging.getLogger(__name__)
 
40
  app = FastAPI(
41
  title="AGROW Heatmap Service",
42
  description="Generate heatmap images from Sentinel-2 vegetation indices",
43
+ version="2.0.0"
44
  )
45
 
46
  # CORS
 
52
  allow_headers=["*"],
53
  )
54
 
55
+
56
  # Sentinel Hub configuration
57
  def get_sh_config():
58
  config = SHConfig()
 
68
  center_lat: float
69
  center_lon: float
70
  field_size_hectares: float = 10.0
71
+ index_type: str = "NDVI" # Any index from vegetation_indices.py
72
 
73
 
74
  class HeatmapResponse(BaseModel):
 
79
  mean_value: float
80
  image_base64: str
81
  timestamp: str
82
+ image_date: Optional[str] = None
83
 
84
 
85
  # Custom colormap for vegetation indices
 
95
  return LinearSegmentedColormap.from_list('vegetation', colors, N=256)
96
 
97
 
98
+ def get_water_colormap():
99
+ """Colormap for water-related indices (NDWI, SMI)."""
100
+ colors = [
101
+ (0.9, 0.6, 0.3), # Brown (dry)
102
+ (0.95, 0.9, 0.5), # Yellow
103
+ (0.5, 0.8, 0.9), # Light blue
104
+ (0.2, 0.5, 0.8), # Blue
105
+ (0.1, 0.3, 0.6), # Dark blue (wet)
106
+ ]
107
+ return LinearSegmentedColormap.from_list('water', colors, N=256)
108
+
109
+
110
+ # Evalscript to fetch all 13 bands
111
+ FULL_BANDS_EVALSCRIPT = """
112
+ //VERSION=3
113
+ function setup() {
114
+ return {
115
+ input: [{
116
+ bands: ["B01", "B02", "B03", "B04", "B05", "B06", "B07", "B08", "B8A", "B09", "B11", "B12", "dataMask"],
117
+ units: "REFLECTANCE"
118
+ }],
119
+ output: {
120
+ bands: 13,
121
+ sampleType: "FLOAT32"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  }
123
+ };
124
+ }
125
+
126
+ function evaluatePixel(sample) {
127
+ return [
128
+ sample.B01, sample.B02, sample.B03, sample.B04,
129
+ sample.B05, sample.B06, sample.B07, sample.B08,
130
+ sample.B8A, sample.B09, sample.B11, sample.B12,
131
+ sample.dataMask
132
+ ];
133
+ }
134
+ """
135
+
136
+
137
+ def parse_timestamp(ts_str: str) -> datetime:
138
+ """Parse ISO timestamp with variable fractional seconds."""
139
+ ts_str = ts_str.replace('Z', '+00:00')
140
+ match = re.match(r'(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\.(\d+)([+-]\d{2}:\d{2})', ts_str)
141
+ if match:
142
+ base, frac, tz = match.groups()
143
+ frac = frac.ljust(6, '0')[:6]
144
+ ts_str = f"{base}.{frac}{tz}"
145
+ return datetime.fromisoformat(ts_str)
146
 
147
 
148
  def generate_heatmap_image(data: np.ndarray, index_type: str) -> tuple:
 
164
  # Create figure
165
  fig, ax = plt.subplots(figsize=(8, 8), dpi=100)
166
 
167
+ # Select colormap based on index type
168
+ if index_type in ['NDWI', 'SMI']:
169
+ cmap = get_water_colormap()
170
+ else:
171
+ cmap = get_vegetation_colormap()
172
+
173
  im = ax.imshow(data_normalized, cmap=cmap, interpolation='bilinear')
174
 
175
  # Add colorbar
 
202
  async def root():
203
  return {
204
  "service": "AGROW Heatmap Service",
205
+ "version": "2.0.0",
206
  "status": "running",
207
+ "supported_indices": list(INDEX_FUNCTIONS.keys()),
208
  "endpoints": {
209
  "/generate-heatmap": "POST - Generate heatmap from coordinates",
210
+ "/generate-heatmap-image": "GET - Get heatmap as PNG directly",
211
  "/health": "GET - Health check"
212
  }
213
  }
 
215
 
216
  @app.get("/health")
217
  async def health():
218
+ return {"status": "healthy", "indices_available": list(INDEX_FUNCTIONS.keys())}
219
 
220
 
221
  @app.post("/generate-heatmap", response_model=HeatmapResponse)
222
  async def generate_heatmap(request: HeatmapRequest):
223
  """Generate a heatmap image for the specified location and index type."""
224
 
225
+ # Validate index type
226
+ if request.index_type not in INDEX_FUNCTIONS:
227
+ raise HTTPException(
228
+ status_code=400,
229
+ detail=f"Invalid index type '{request.index_type}'. Supported: {list(INDEX_FUNCTIONS.keys())}"
230
+ )
231
+
232
  try:
233
  logger.info(f"Generating {request.index_type} heatmap for ({request.center_lat}, {request.center_lon})")
234
 
235
  config = get_sh_config()
236
 
237
  # Calculate bounding box from center and field size
 
238
  field_radius_km = np.sqrt(request.field_size_hectares / 100) / 2
239
  lat_offset = field_radius_km / 111
240
  lon_offset = field_radius_km / (111 * np.cos(np.radians(request.center_lat)))
 
251
 
252
  # Calculate resolution (10m per pixel)
253
  size = bbox_to_dimensions(bbox, resolution=10)
 
 
254
  size = (max(size[0], 64), max(size[1], 64))
255
 
256
  # Get recent date
257
  end_date = datetime.now()
258
  start_date = end_date - timedelta(days=30)
259
 
 
 
 
260
  # Define data collection for CDSE
261
  SENTINEL2_L2A_CDSE = DataCollection.define_from(
262
  DataCollection.SENTINEL2_L2A,
263
  service_url='https://sh.dataspace.copernicus.eu'
264
  )
265
 
266
+ # Create request for all 13 bands
267
  sh_request = SentinelHubRequest(
268
+ evalscript=FULL_BANDS_EVALSCRIPT,
269
  input_data=[
270
  SentinelHubRequest.input_data(
271
  data_collection=SENTINEL2_L2A_CDSE,
 
285
  if data is None or data.size == 0:
286
  raise HTTPException(status_code=404, detail="No satellite data available for this location")
287
 
288
+ logger.info(f"Fetched data shape: {data.shape}")
289
+
290
+ # Calculate the requested index using the S2 pipeline function
291
+ # Need to remove dataMask (last band) for index calculation
292
+ img_data = data[:, :, :12] # Remove dataMask
293
+
294
+ index_func = INDEX_FUNCTIONS[request.index_type]
295
+ index_data = index_func(img_data)
296
+
297
+ logger.info(f"Calculated {request.index_type}: min={np.nanmin(index_data):.4f}, max={np.nanmax(index_data):.4f}")
298
 
299
  # Generate heatmap
300
+ img_base64, min_val, max_val, mean_val = generate_heatmap_image(index_data, request.index_type)
301
 
302
  return HeatmapResponse(
303
  success=True,
 
306
  max_value=max_val,
307
  mean_value=mean_val,
308
  image_base64=img_base64,
309
+ timestamp=datetime.now().isoformat(),
310
+ image_date=end_date.strftime('%Y-%m-%d')
311
  )
312
 
313
  except HTTPException:
vegetation_indices.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Vegetation Indices Calculator
3
+ ==============================
4
+
5
+ This module provides functions to calculate 10 vegetation indices from Sentinel-2 data
6
+ and perform temporal analysis.
7
+
8
+ Indices:
9
+ - NDVI: Normalized Difference Vegetation Index
10
+ - EVI: Enhanced Vegetation Index
11
+ - NDWI: Normalized Difference Water Index
12
+ - NDRE: Normalized Difference Red Edge
13
+ - RECI: Red Edge Chlorophyll Index
14
+ - SMI: Soil Moisture Index
15
+ - NDSI: Normalized Difference Snow Index
16
+ - PRI: Photochemical Reflectance Index
17
+ - PSRI: Plant Senescence Reflectance Index
18
+ - MCARI: Modified Chlorophyll Absorption Ratio Index
19
+ - SASI: Salinity Index
20
+ - SOMI: Soil Organic Matter Index
21
+ - SFI: Soil Fertility Index
22
+ """
23
+
24
+ import numpy as np
25
+ from typing import Dict, List, Tuple
26
+ import datetime
27
+ from datetime import timedelta
28
+
29
+ # ==================== INDEX CALCULATION FUNCTIONS ====================
30
+
31
+ def calculate_ndvi(img: np.ndarray) -> np.ndarray:
32
+ """NDVI = (NIR - RED) / (NIR + RED)"""
33
+ nir = img[:, :, 7] # B08
34
+ red = img[:, :, 3] # B04
35
+ return (nir - red) / (nir + red + 1e-10)
36
+
37
+ def calculate_evi(img: np.ndarray) -> np.ndarray:
38
+ """EVI = 2.5 * ((NIR - RED) / (NIR + 6*RED - 7.5*BLUE + 1))"""
39
+ nir = img[:, :, 7] # B08
40
+ red = img[:, :, 3] # B04
41
+ blue = img[:, :, 1] # B02
42
+ return 2.5 * ((nir - red) / (nir + 6*red - 7.5*blue + 1))
43
+
44
+ def calculate_ndwi(img: np.ndarray) -> np.ndarray:
45
+ """NDWI = (GREEN - NIR) / (GREEN + NIR)"""
46
+ green = img[:, :, 2] # B03
47
+ nir = img[:, :, 7] # B08
48
+ return (green - nir) / (green + nir + 1e-10)
49
+
50
+ def calculate_ndre(img: np.ndarray) -> np.ndarray:
51
+ """NDRE = (NIR - RedEdge) / (NIR + RedEdge)"""
52
+ nir = img[:, :, 7] # B08
53
+ red_edge = img[:, :, 4] # B05
54
+ return (nir - red_edge) / (nir + red_edge + 1e-10)
55
+
56
+ def calculate_reci(img: np.ndarray) -> np.ndarray:
57
+ """RECI = (NIR / RedEdge) - 1"""
58
+ nir = img[:, :, 7] # B08
59
+ red_edge = img[:, :, 4] # B05
60
+ return (nir / (red_edge + 1e-10)) - 1
61
+
62
+ def calculate_smi(img: np.ndarray) -> np.ndarray:
63
+ """SMI = (SWIR1 - SWIR2) / (SWIR1 + SWIR2)"""
64
+ swir1 = img[:, :, 10] # B11
65
+ swir2 = img[:, :, 11] # B12
66
+ return (swir1 - swir2) / (swir1 + swir2 + 1e-10)
67
+
68
+ def calculate_ndsi(img: np.ndarray) -> np.ndarray:
69
+ """NDSI = (GREEN - SWIR1) / (GREEN + SWIR1)"""
70
+ green = img[:, :, 2] # B03
71
+ swir1 = img[:, :, 10] # B11
72
+ return (green - swir1) / (green + swir1 + 1e-10)
73
+
74
+ def calculate_pri(img: np.ndarray) -> np.ndarray:
75
+ """PRI = (B02 - B03) / (B02 + B03)"""
76
+ b02 = img[:, :, 1] # B02
77
+ b03 = img[:, :, 2] # B03
78
+ return (b02 - b03) / (b02 + b03 + 1e-10)
79
+
80
+ def calculate_psri(img: np.ndarray) -> np.ndarray:
81
+ """PSRI = (RED - GREEN) / NIR"""
82
+ red = img[:, :, 3] # B04
83
+ green = img[:, :, 2] # B03
84
+ nir = img[:, :, 7] # B08
85
+ return (red - green) / (nir + 1e-10)
86
+
87
+ def calculate_mcari(img: np.ndarray) -> np.ndarray:
88
+ """MCARI = ((B05 - B04) - 0.2 * (B05 - B03)) * (B05 / B04)"""
89
+ b03 = img[:, :, 2] # B03
90
+ b04 = img[:, :, 3] # B04
91
+ b05 = img[:, :, 4] # B05
92
+ return ((b05 - b04) - 0.2 * (b05 - b03)) * (b05 / (b04 + 1e-10))
93
+
94
+ def calculate_sasi(img: np.ndarray) -> np.ndarray:
95
+ """SASI (Salinity Index) = SQRT(B11 * B04)"""
96
+ swir1 = img[:, :, 10] # B11
97
+ red = img[:, :, 3] # B04
98
+ return np.sqrt(swir1 * red)
99
+
100
+ def calculate_somi(img: np.ndarray) -> np.ndarray:
101
+ """SOMI (Soil Organic Matter Index) = (B08 + B04) / (B11 + B12)"""
102
+ nir = img[:, :, 7] # B08
103
+ red = img[:, :, 3] # B04
104
+ swir1 = img[:, :, 10] # B11
105
+ swir2 = img[:, :, 11] # B12
106
+ return (nir + red) / (swir1 + swir2 + 1e-10)
107
+
108
+ def calculate_sfi(img: np.ndarray) -> np.ndarray:
109
+ """SFI (Soil Fertility Index) = (NDVI * SOMI) / SASI
110
+ Combines vegetation health, organic matter, and salinity"""
111
+ ndvi = calculate_ndvi(img)
112
+ somi = calculate_somi(img)
113
+ sasi = calculate_sasi(img)
114
+ return (ndvi * somi) / (sasi + 1e-10)
115
+
116
+ # Index registry
117
+ INDEX_FUNCTIONS = {
118
+ 'NDVI': calculate_ndvi,
119
+ 'EVI': calculate_evi,
120
+ 'NDWI': calculate_ndwi,
121
+ 'NDRE': calculate_ndre,
122
+ 'RECI': calculate_reci,
123
+ 'SMI': calculate_smi,
124
+ 'NDSI': calculate_ndsi,
125
+ 'PRI': calculate_pri,
126
+ 'PSRI': calculate_psri,
127
+ 'MCARI': calculate_mcari,
128
+ 'SASI': calculate_sasi,
129
+ 'SOMI': calculate_somi,
130
+ 'SFI': calculate_sfi
131
+ }
132
+
133
+ # ==================== BATCH CALCULATION ====================
134
+
135
+ def calculate_all_indices(img: np.ndarray) -> Dict[str, np.ndarray]:
136
+ """
137
+ Calculate all 10 vegetation indices for a single image.
138
+
139
+ Args:
140
+ img: Image array of shape (height, width, 12) with reflectance values
141
+
142
+ Returns:
143
+ Dictionary mapping index names to 2D arrays
144
+ """
145
+ indices = {}
146
+ for name, func in INDEX_FUNCTIONS.items():
147
+ indices[name] = func(img)
148
+ return indices
149
+
150
+ def calculate_indices_temporal(images: np.ndarray) -> Dict[str, np.ndarray]:
151
+ """
152
+ Calculate all indices for multiple time steps.
153
+
154
+ Args:
155
+ images: Array of shape (time, height, width, 12)
156
+
157
+ Returns:
158
+ Dictionary mapping index names to 3D arrays (time, height, width)
159
+ """
160
+ indices_data = {}
161
+
162
+ for index_name, calc_func in INDEX_FUNCTIONS.items():
163
+ index_series = []
164
+ for img in images:
165
+ index_map = calc_func(img)
166
+ index_series.append(index_map)
167
+ indices_data[index_name] = np.array(index_series)
168
+
169
+ return indices_data
170
+
171
+ # ==================== STATISTICS ====================
172
+
173
+ def get_field_statistics(index_map: np.ndarray) -> Dict[str, float]:
174
+ """
175
+ Calculate statistics for a single index map.
176
+
177
+ Returns:
178
+ Dictionary with mean, std, min, max, median
179
+ """
180
+ return {
181
+ 'mean': float(np.nanmean(index_map)),
182
+ 'std': float(np.nanstd(index_map)),
183
+ 'min': float(np.nanmin(index_map)),
184
+ 'max': float(np.nanmax(index_map)),
185
+ 'median': float(np.nanmedian(index_map)),
186
+ 'valid_pixels': int(np.sum(~np.isnan(index_map)))
187
+ }
188
+
189
+ def get_temporal_statistics(indices_temporal: Dict[str, np.ndarray]) -> Dict[str, Dict]:
190
+ """
191
+ Calculate temporal statistics for all indices.
192
+
193
+ Args:
194
+ indices_temporal: Dictionary with index names and 3D arrays (time, height, width)
195
+
196
+ Returns:
197
+ Dictionary with temporal stats for each index
198
+ """
199
+ temporal_stats = {}
200
+
201
+ for index_name, data in indices_temporal.items():
202
+ stats = {
203
+ 'mean_over_time': np.nanmean(data, axis=0),
204
+ 'std_over_time': np.nanstd(data, axis=0),
205
+ 'max_over_time': np.nanmax(data, axis=0),
206
+ 'min_over_time': np.nanmin(data, axis=0),
207
+ 'range': np.nanmax(data, axis=0) - np.nanmin(data, axis=0),
208
+ 'temporal_trend': data[-1] - data[0] if len(data) >= 2 else np.zeros_like(data[0]),
209
+ }
210
+
211
+ # Rolling average (window size = 3)
212
+ if len(data) >= 3:
213
+ rolling_avg = np.array([np.nanmean(data[max(0, i-2):i+1], axis=0)
214
+ for i in range(len(data))])
215
+ stats['rolling_avg_3'] = rolling_avg
216
+
217
+ temporal_stats[index_name] = stats
218
+
219
+ return temporal_stats
220
+
221
+ def get_summary_report(indices_temporal: Dict[str, np.ndarray],
222
+ dates: List[str]) -> Dict:
223
+ """
224
+ Generate a comprehensive summary report.
225
+
226
+ Returns:
227
+ Dictionary with summary statistics and temporal trends
228
+ """
229
+ report = {
230
+ 'dates': dates,
231
+ 'num_images': len(dates),
232
+ 'indices': {}
233
+ }
234
+
235
+ for index_name, data in indices_temporal.items():
236
+ index_report = {
237
+ 'latest': get_field_statistics(data[-1]),
238
+ 'oldest': get_field_statistics(data[0]),
239
+ 'mean_values_over_time': [float(np.nanmean(data[i])) for i in range(len(dates))],
240
+ 'change': float(np.nanmean(data[-1]) - np.nanmean(data[0])),
241
+ 'max_in_field': float(np.nanmax(data)),
242
+ 'min_in_field': float(np.nanmin(data))
243
+ }
244
+ report['indices'][index_name] = index_report
245
+
246
+ return report