The NDVI (Normalized Difference Vegetation Index) criterion assesses drought risk based on vegetation health measured from aerial CIR (Color Infrared) imagery. NDVI is a widely-used indicator of vegetation vigor and photosynthetic activity. Higher NDVI values indicate healthier, denser vegetation, which correlates with lower drought stress, while lower NDVI values indicate stressed or sparse vegetation, suggesting higher drought risk. This criterion uses an inverse relationship: high NDVI = low risk, low NDVI = high risk.
If you have CIR TIFF files (not pre-calculated NDVI), calculate NDVI first using the code in Preparing CIR Data.
# Import single NDVI raster
python scripts/import_raster_to_postgis.py --raster ndvi_2024 --version 2024
# If you also have historical data
python scripts/import_raster_to_postgis.py --raster ndvi_2020 --version 2020
The NDVI criterion is already configured in config/config.yaml with sensible defaults. Adjust parameters as needed (see Configuration).
Year-aware configuration (operational panel): When running historical panel snapshots, set project.feature_reference_year and use yearly_sources in the NDVI params to automatically select the correct raster for each snapshot year (see yearly_sources).
conda activate bgcki_wesemann
python -m bluegreencity_ki.runners.cli --config config/config.yaml
The output will include:
ndvi_risk: Risk score (0-1) where 0 = low risk, 1 = high riskndvi_value_2024 (or ndvi_value_current / ndvi_value_2020 for the second raster):
actual NDVI value at the tree (zonal crown mean where available, else point sample)ndvi_change_2020to2024: NDVI change between the two rasters — only in
multi_temporal mode; the currently configured single_date mode produces
no change columnbluegreencity_ki/criteria/ndvi.pytests/test_ndvi.pyNDVI Calculation: NDVI is typically pre-calculated from CIR imagery as: NDVI = (NIR - Red) / (NIR + Red) where NIR is the Near-Infrared band and Red is the red band. The input raster should contain pre-computed NDVI values.
params.threshold_method):
sample_percentile, p5/p95)Sampling at Tree Locations: For each tree, the NDVI value is sampled as a zonal mean over the
crown buffer by default (use_zonal_statistics: true), falling back to a point sample where the
crown buffer is unavailable.
yearly_sources): In operational panel mode, a single config block automatically picks the correct NDVI raster for each reference year, producing the canonical ndvi_value_current column used as the ML feature. See yearly_sources.NDVI Range: -1.0 to 1.0 (typical vegetation values: 0.2 to 0.8)
Single-date mode:
NDVI ≥ 0.6 → Risk Score = 0.0 (healthy vegetation, low risk)
NDVI = 0.4 → Risk Score = 0.5 (moderate vegetation)
NDVI ≤ 0.2 → Risk Score = 1.0 (stressed vegetation, high risk)
Missing/Invalid data → Risk Score = 0.5 (neutral)
Multi-temporal mode:
Base score from current NDVI + adjustment from NDVI change
Negative change (declining health) → increases risk
Positive change (improving health) → decreases risk
Example Mappings (with thresholds 0.2/0.6):
Add to config.yaml:
inputs:
ndvi_raster:
type: "postgis"
schema: "bgcki"
table: "context_raster_ndvi_2024"
criteria:
- name: "ndvi"
weight: 0.5 # example; current calibrated weight in config/config.yaml
params:
threshold_method: "spatial_percentile"
low_percentile: 25
high_percentile: 75
mode: "single_date"
| Parameter | Type | Default | Description |
|---|---|---|---|
threshold_method |
string | "static" |
Threshold computation method: "static", "sample_percentile" (configured), or "spatial_percentile" |
ndvi_low |
float | 0.2 |
Low NDVI threshold for static method (stressed vegetation) |
ndvi_high |
float | 0.6 |
High NDVI threshold for static method (healthy vegetation) |
low_percentile |
int | 25 |
Percentile for low threshold in spatial_percentile method |
high_percentile |
int | 75 |
Percentile for high threshold in spatial_percentile method |
mode |
string | "single_date" |
Analysis mode: "single_date" or "multi_temporal" |
raster_key_2 |
string | "ndvi_2024" |
Second raster key for multi-temporal comparison |
change_weight |
float | 0.0 |
Weight for NDVI change in multi-temporal mode (0-1) |
change_threshold |
float | 0.1 |
NDVI change normalization threshold for multi-temporal mode |
yearly_sources |
list | — | Year-range list for automatic raster selection; see below |
When yearly_sources is configured, the criterion resolves the NDVI raster automatically
based on project.feature_reference_year (or run_date.year). This is the
recommended setup for operational panel mode:
params:
mode: "single_date"
yearly_sources:
- start_year: 2020
end_year: 2023
raster_key: "ndvi_2020"
source_year: 2020
output_column: "ndvi_value_2020"
- start_year: 2024
end_year: 2026
raster_key: "ndvi"
source_year: 2024
output_column: "ndvi_value_2024"
| Entry key | Type | Description |
|---|---|---|
start_year |
int | First year (inclusive) for which this entry applies |
end_year |
int | Last year (inclusive) for which this entry applies |
raster_key |
string | Key in inputs pointing to the raster to sample |
source_year |
int | Stored in ndvi_source_year for provenance |
output_column |
string | Legacy per-year column written alongside ndvi_value_current |
When yearly_sources is active, the criterion produces these output columns:
| Column | Description |
|---|---|
ndvi_value_current |
NDVI value from the matched raster – the operative ML feature |
ndvi_source_year |
Calendar year of the raster used |
ndvi_age_years |
feature_reference_year − ndvi_source_year (data freshness) |
ndvi_available |
True/False – whether a valid NDVI value was found |
ndvi_value_<year> |
Year-specific column (e.g. ndvi_value_2024) for exports |
Note: When
yearly_sourcesis absent (legacy mode), the criterion produces the static columnndvi_value_2024instead.
"spatial_percentile" (recommended) to automatically adapt to local conditions"static" when you have domain knowledge of appropriate NDVI thresholds for your region"single_date" for current vegetation health assessment"multi_temporal" when historical data is available to detect trendsConservative (single-date, spatial thresholds):
params:
threshold_method: "spatial_percentile"
low_percentile: 25
high_percentile: 75
mode: "single_date"
Aggressive (static thresholds for stressed vegetation):
params:
threshold_method: "static"
ndvi_low: 0.3 # Higher threshold = more trees flagged as stressed
ndvi_high: 0.7
mode: "single_date"
Multi-temporal (detect vegetation decline):
params:
threshold_method: "spatial_percentile"
low_percentile: 25
high_percentile: 75
mode: "multi_temporal"
raster_key_2: "ndvi_2020"
change_weight: 0.3
change_threshold: 0.1
Growing season analysis (higher thresholds):
params:
threshold_method: "static"
ndvi_low: 0.4 # Expect higher NDVI during growing season
ndvi_high: 0.7
mode: "single_date"
If you have CIR TIFF files, calculate NDVI before import:
import rasterio
import numpy as np
# Open CIR image (CIR format: NIR, Red, Green bands)
with rasterio.open("cir_2024.tif") as src:
# Read NIR (band 1) and Red (band 2)
nir = src.read(1).astype(float)
red = src.read(2).astype(float)
# Calculate NDVI
ndvi = (nir - red) / (nir + red + 1e-8) # Add epsilon to avoid division by zero
# Handle nodata
nodata_mask = (nir == src.nodata) | (red == src.nodata)
ndvi[nodata_mask] = -9999
# Write NDVI raster
profile = src.profile
profile.update(count=1, dtype=rasterio.float32, nodata=-9999)
with rasterio.open("ndvi_2024.tif", "w", **profile) as dst:
dst.write(ndvi, 1)
The criterion returns metadata for quality assurance:
{
"threshold_method": "spatial_percentile", # Method used
"mode": "single_date", # Analysis mode
"ndvi_low_threshold": 0.25, # Low NDVI threshold used
"ndvi_high_threshold": 0.68, # High NDVI threshold used
"ndvi_min": 0.12, # Minimum NDVI found
"ndvi_max": 0.85, # Maximum NDVI found
"ndvi_mean": 0.48, # Mean NDVI
"n_trees_sampled": 50000, # Total trees
"n_valid_samples": 49850, # Trees with valid NDVI
"n_missing_samples": 150, # Trees with missing NDVI
# Multi-temporal mode only:
"ndvi_change_mean": -0.05, # Mean NDVI change (negative = decline)
"ndvi_change_std": 0.08, # Std dev of change
"n_declining": 28000, # Trees with negative NDVI change
"n_improving": 21850 # Trees with positive NDVI change
}
Extras (output columns):
Legacy (no yearly_sources):
ndvi_value_2024: Actual NDVI value at tree location (single-date)ndvi_value_2020: NDVI value of the second raster (multi-temporal mode)ndvi_change_2020to2024: NDVI change between the two rasters (multi-temporal mode)With yearly_sources (operational panel / year-aware mode):
ndvi_value_current: NDVI from the matched raster — the operative ML featurendvi_source_year: Calendar year of the raster usedndvi_age_years: feature_reference_year − ndvi_source_yearndvi_available: True/Falsendvi_value_<year>: Year-specific column (e.g. ndvi_value_2024)The NDVI criterion complements:
Run tests with:
conda activate bgcki_wesemann
python -m pytest tests/test_ndvi.py -v
Tests cover:
After running the pipeline, validate results by:
import geopandas as gpd
import matplotlib.pyplot as plt
# Load results
gdf = gpd.read_file("./data/output/<run_date>_<scenario>/trees_at_risk.gpkg", layer="trees_at_risk_points")
# Validation visualization
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# Plot 1: NDVI vs. Risk Score (should show inverse relationship)
axes[0].scatter(gdf['ndvi_value_2024'], gdf['ndvi_risk'], alpha=0.3, s=1)
axes[0].set_xlabel('NDVI Value')
axes[0].set_ylabel('Risk Score')
axes[0].set_title('NDVI vs. Risk Score (expect inverse relationship)')
axes[0].grid(True, alpha=0.3)
# Plot 2: NDVI distribution
axes[1].hist(gdf['ndvi_value_2024'].dropna(), bins=50, edgecolor='black', alpha=0.7)
axes[1].set_xlabel('NDVI Value')
axes[1].set_ylabel('Number of Trees')
axes[1].set_title('NDVI Distribution')
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Summary statistics
print(f"NDVI range: {gdf['ndvi_value_2024'].min():.3f} to {gdf['ndvi_value_2024'].max():.3f}")
print(f"Mean NDVI: {gdf['ndvi_value_2024'].mean():.3f}")
print(f"Trees with low NDVI (<0.3): {(gdf['ndvi_value_2024'] < 0.3).sum()}")
print(f"Trees with high NDVI (>0.6): {(gdf['ndvi_value_2024'] > 0.6).sum()}")
# For multi-temporal analysis
if 'ndvi_change_2020to2024' in gdf.columns:
fig, axes = plt.subplots(1, 2, figsize=(12, 5))
# Plot change distribution
axes[0].hist(gdf['ndvi_change_2020to2024'].dropna(), bins=50, edgecolor='black', alpha=0.7)
axes[0].axvline(0, color='red', linestyle='--', label='No change')
axes[0].set_xlabel('NDVI Change')
axes[0].set_ylabel('Number of Trees')
axes[0].set_title('NDVI Change Distribution (2020 to 2024)')
axes[0].legend()
# Plot change vs risk score
axes[1].scatter(gdf['ndvi_change_2020to2024'], gdf['ndvi_risk'], alpha=0.3, s=1)
axes[1].set_xlabel('NDVI Change')
axes[1].set_ylabel('Risk Score')
axes[1].set_title('NDVI Change vs. Risk Score')
axes[1].axvline(0, color='red', linestyle='--', alpha=0.5)
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print(f"Mean NDVI change: {gdf['ndvi_change_2020to2024'].mean():.3f}")
print(f"Declining trees: {(gdf['ndvi_change_2020to2024'] < 0).sum()}")
print(f"Improving trees: {(gdf['ndvi_change_2020to2024'] > 0).sum()}")
Solution: Check that the NDVI raster is properly imported to PostGIS and the table name matches the configuration. Verify the raster exists with:
SELECT * FROM bgcki.context_raster_ndvi_2024 LIMIT 1;
Solution: Verify that NDVI was calculated correctly from CIR bands. NIR should be band 1, Red should be band 2 in CIR imagery. NDVI values should be in the range -1.0 to 1.0.
Solution: Remember the inverse relationship (high NDVI = low risk). Check threshold values with spatial_percentile method logging. Review the output metadata for the actual thresholds used.
Solution: Tests use mocking and don’t require rasterio. Ensure pytest is installed: pip install pytest
Solution: Verify your database connection is configured correctly and the target schema exists. Check that the NDVI raster file is a valid GeoTIFF.
Author: BlueGreenCity-KI Team
Last Updated: 2026-02-04