Source code for lib.vegetation
"""Vegetation index computation."""
from __future__ import annotations
import numpy as np
[docs]
def compute_ndvi(red: np.ndarray, nir: np.ndarray) -> np.ndarray:
"""Compute Normalized Difference Vegetation Index from radiance.
NDVI = (NIR - Red) / (NIR + Red)
Works with radiance because the ratio cancels solar irradiance
to first order (same sun angle, same atmospheric path).
Args:
red: Red band radiance [W/m²/sr/μm], any shape.
nir: NIR band radiance [W/m²/sr/μm], same shape as red.
Returns:
NDVI array, range [-1, 1]. NaN where inputs are invalid
or denominator is zero.
"""
with np.errstate(invalid='ignore', divide='ignore'):
ndvi = (nir - red) / (nir + red)
ndvi = np.where(np.isfinite(ndvi), ndvi, np.nan)
return ndvi
[docs]
def has_sunlight(red: np.ndarray, nir: np.ndarray, threshold: float = 5.0) -> bool:
"""Check if VNIR bands have meaningful solar signal.
At night or under heavy cloud, reflected-solar bands (Red, NIR) read
near zero. Checking actual radiance is more robust than Solar Zenith
Angle because SZA only tells you if the sun is geometrically above
the horizon — it can't detect cloud blocking the signal.
Empirical MASTER NIR radiance ranges:
Daytime: median ~40, min ~7 W/m²/sr/μm
Nighttime: median ~0.2, p95 ~0.5 W/m²/sr/μm (sensor noise)
Args:
red: Red band radiance [W/m²/sr/μm], any shape.
nir: NIR band radiance [W/m²/sr/μm], same shape.
threshold: minimum median NIR radiance to consider "sunlit"
[W/m²/sr/μm]. Default 5.0 sits between nighttime
noise (~0.5) and daytime minimum (~7).
Returns:
True if the scene has usable solar illumination.
"""
valid_nir = nir[np.isfinite(nir) & (nir > 0)]
if len(valid_nir) == 0:
return False
return float(np.median(valid_nir)) > threshold
[docs]
def detect_vegetation_loss(baseline: np.ndarray, current: np.ndarray,
threshold: float = 0.15) -> np.ndarray:
"""Detect significant vegetation loss by comparing NDVI to baseline.
A drop of >= threshold from the first-observed NDVI indicates that
vegetation has been consumed (e.g. by fire). Uses absolute drop,
not percentage, because a 0.15 drop is physically meaningful
regardless of the baseline magnitude.
Args:
baseline: NDVI baseline array (first valid daytime observation).
current: Current NDVI array (same shape as baseline).
threshold: Minimum NDVI drop to flag as vegetation loss.
Returns:
Boolean mask, True where baseline - current >= threshold and
both values are finite.
"""
with np.errstate(invalid='ignore'):
drop = baseline - current
return np.isfinite(drop) & (drop >= threshold)