bluegreencity-ki

NDVI Criterion

Overview

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.

Quick Start

Step 1: Prepare NDVI Data

If you have CIR TIFF files (not pre-calculated NDVI), calculate NDVI first using the code in Preparing CIR Data.

Step 2: Import NDVI Rasters to PostGIS

# 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

Step 3: Configure the Pipeline

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).

Step 4: Run the Pipeline

conda activate bgcki_wesemann
python -m bluegreencity_ki.runners.cli --config config/config.yaml

Step 5: Analyze Results

The output will include:

Implementation

Location

How It Works

  1. NDVI 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.

  2. Threshold Determination: The criterion supports three methods for defining risk thresholds (params.threshold_method):
    • Static thresholds (code default): Fixed NDVI values (e.g., 0.2 for low/stressed, 0.6 for high/healthy)
    • Sample percentile: Thresholds computed from the distribution of the sampled tree values — the currently configured method (sample_percentile, p5/p95)
    • Spatial percentile: Thresholds computed from the full raster distribution
  3. 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.

  4. Risk Score Calculation: NDVI values are linearly mapped to risk scores with an inverse relationship:
    • NDVI ≥ high_threshold → Risk Score = 0.0 (healthy vegetation, low risk)
    • NDVI ≤ low_threshold → Risk Score = 1.0 (stressed vegetation, high risk)
    • NDVI between thresholds → Linear interpolation, inverted
  5. Multi-temporal Mode (Optional): When two NDVI rasters from different dates are available (e.g., 2020 and 2024), the criterion can compute NDVI change and adjust risk scores:
    • Declining NDVI (negative change) → increased risk
    • Improving NDVI (positive change) → decreased risk
  6. Year-aware raster selection (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.

Scoring Logic

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):

Configuration

Basic Configuration

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"

Parameters

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

Yearly Sources — Year-aware Raster Selection

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_sources is absent (legacy mode), the criterion produces the static column ndvi_value_2024 instead.

Parameter Selection Guidelines

Example Configurations

Conservative (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"

Data Requirements

Input Format

Data Quality Considerations

  1. Imagery Timing: Collect during growing season (May-September) for maximum vegetation signal
  2. Cloud Coverage: Ensure cloud-free imagery for accurate NDVI calculation
  3. Radiometric Calibration: CIR imagery should be properly calibrated for accurate NDVI
  4. Sensor Consistency: Use same sensor/method for multi-temporal comparisons
  5. Spatial Resolution: Higher resolution (≤1m) preferred for individual tree assessment

Example Data Sources

Preparing CIR Data

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)

Output Metadata

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):

With yearly_sources (operational panel / year-aware mode):

Integration with Other Criteria

The NDVI criterion complements:

Validation & Testing

Unit Tests

Run tests with:

conda activate bgcki_wesemann
python -m pytest tests/test_ndvi.py -v

Tests cover:

Visual Validation

After running the pipeline, validate results by:

  1. NDVI Distribution: Check that NDVI values are within expected range
  2. Spatial Patterns: Verify that parks/green spaces have high NDVI, sealed areas have low NDVI
  3. Score Correlation: Confirm inverse relationship between NDVI and risk scores
  4. Multi-temporal Changes: For time series, verify that declining areas show increased risk

Example Validation Code

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()}")

Multi-temporal Validation

# 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()}")

Troubleshooting

Issue: All NDVI values are missing

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;

Issue: NDVI values seem incorrect

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.

Issue: Risk scores don’t match expectations

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.

Issue: Tests fail with rasterio import errors

Solution: Tests use mocking and don’t require rasterio. Ensure pytest is installed: pip install pytest

Issue: PostGIS import fails

Solution: Verify your database connection is configured correctly and the target schema exists. Check that the NDVI raster file is a valid GeoTIFF.

Known Limitations

  1. Seasonal Variation: NDVI is highly seasonal; imagery should be from comparable times of year for multi-temporal analysis
  2. Shadow Effects: Shadows from buildings or other trees can reduce NDVI values, potentially overestimating risk
  3. Non-tree Vegetation: NDVI captures all vegetation, not just the target tree; nearby grass/shrubs may influence values
  4. Sensor Differences: Different sensors or processing methods can produce different NDVI values; ensure consistency
  5. Resolution Constraints: Low-resolution imagery may not accurately capture individual tree crowns in dense urban areas
  6. Dormant Season: NDVI of deciduous trees is very low in winter; use growing season imagery only

References

Changelog

2026-02-04: Documentation Consolidation

2026-01-09: Initial Implementation


Author: BlueGreenCity-KI Team
Last Updated: 2026-02-04