Spaces:
Sleeping
Sleeping
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
- app.py +89 -91
- 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="
|
| 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" #
|
| 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
|
| 92 |
-
"""
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 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=
|
| 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 |
-
|
| 299 |
-
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
# Generate heatmap
|
| 303 |
-
img_base64, min_val, max_val, mean_val = generate_heatmap_image(
|
| 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
|