Agriculture

Crop Monitoring: NDVI Trend & Chart

2025-03-01 · 3 min · Beginner

ClearSKY provides near real‑time, daily–weekly cloud‑free mosaics compatible with Sentinel‑2. Your Nimbus composites are distributed as:

  • TOA (top‑of‑atmosphere) internally, and
  • BOA (bottom‑of‑atmosphere) for download in most sample packs

This recipe uses BOA GeoTIFFs (analysis‑ready). Sentinel‑2 BOA typically contains 12 bands — all bands except B10 (cirrus). That means:

  • B04 = Red, B08 = NIR (used for NDVI)
  • B03 = Green (used for NDWI)
  • B11 / B12 = SWIR (used for MNDWI / NBR etc.)

🧠 Tip: reflectance scaling can vary by source (e.g., 0–1 float vs 0–10000 int). The code below will auto‑scale to 0–1 when it detects values > 1.


What you'll need

  • macOS/Linux shell (or Windows PowerShell/WSL)
  • Python 3.9+ with: rasterio, numpy, pandas, matplotlib, shapely (optional), fiona (optional)
  • Access to ClearSKY sample datasets (non‑commercial)

1) Download a time series (daily–weekly BOA)

Replace the URL with a signed link from /samples after email unlock.

# Example: parcel time‑series pack (contains date‑stamped BOA mosaics)
curl -L -o parcel_timeseries.zip "REPLACE_WITH_SIGNED_URL"
unzip -q parcel_timeseries.zip -d timeseries

# Files should look like:
# timeseries/
#   20240410.tif
#   20240420.tif
#   20240430.tif
#   ...

2) (Optional) Provide a field polygon

If you want per‑field statistics, save a GeoJSON file named aoi.geojson with your polygon:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "properties": { "name": "My Field" },
      "geometry": {
        "type": "Polygon",
        "coordinates": [[[ -50.11, -9.47 ], [ -50.10, -9.47 ], [ -50.10, -9.48 ], [ -50.11, -9.48 ], [ -50.11, -9.47 ]]]
      }
    }
  ]
}

CRS: EPSG:4326 (lon/lat) is fine — the script will reproject the AOI on the fly.


3) Run the NDVI time‑series extractor

Download the script below and run it. It computes NDVI, EVI and NDWI per date (masked to your AOI if provided), then writes a CSV and a PNG chart.

python calc_ndvi_timeseries.py --dir timeseries --aoi aoi.geojson
# or without AOI
python calc_ndvi_timeseries.py --dir timeseries

Outputs:

  • indices_timeseries.csv — date, ndvi_mean, evi_mean, ndwi_mean
  • ndvi_trend.png — simple NDVI trend chart

4) Band reference (BOA composites)

These composites have 12 bands (B10 omitted). Use 1‑based band indices in Rasterio:

IndexSentinel‑2 bandCommon nameNotes
1B01Coastal aerosol60 m
2B02Blue10 m
3B03Green10 m
4B04Red10 m
5B05Red‑edge 120 m
6B06Red‑edge 220 m
7B07Red‑edge 320 m
8B08NIR10 m
9B8ANIR narrow20 m
10B09Water vapor60 m
11B11SWIR 120 m
12B12SWIR 220 m

B10 (cirrus) is not present in BOA delivery.


5) When you go live

  • Swap sample URLs for ordered AOIs.
  • Tune index thresholds per crop/region; aggregate by 7–10 day windows for smoother trends.
  • Use ClearSKY webhooks (or a cron) to re‑run when a new daily–weekly mosaic lands.

Full script (Python)

Save as calc_ndvi_timeseries.py:

#!/usr/bin/env python3
import argparse, glob, json, os, re
import numpy as np
import pandas as pd
import rasterio as rio
from rasterio.mask import mask as rio_mask
import matplotlib.pyplot as plt

def autoscale_reflectance(arr):
    # If the data looks like 0..10000 integers, scale to 0..1
    a = arr.astype("float32")
    finite = np.isfinite(a)
    if finite.any() and np.nanmax(a[finite]) > 2.0:
        a = a / 10000.0
    return a

def read_band(ds, idx):
    b = ds.read(idx).astype("float32")
    return autoscale_reflectance(b)

def compute_indices(bands):
    # bands dict must contain b2, b3, b4, b8 at minimum
    red = bands["b4"]; nir = bands["b8"]; blue = bands["b2"]; green = bands["b3"]

    # NDVI
    ndvi = (nir - red) / (nir + red + 1e-6)

    # EVI (L=1, C1=6, C2=7.5, G=2.5) using BOA
    evi = 2.5 * (nir - red) / (nir + 6.0*red - 7.5*blue + 1.0 + 1e-6)

    # NDWI (McFeeters; water index): (G - NIR)/(G + NIR)
    ndwi = (green - nir) / (green + nir + 1e-6)

    return ndvi, evi, ndwi

def read_aoi_geojson(path):
    if not path: return None
    with open(path, "r", encoding="utf-8") as f:
        gj = json.load(f)
    # Accept FeatureCollection or Feature
    if gj.get("type") == "FeatureCollection":
        geoms = [f["geometry"] for f in gj.get("features", []) if f.get("geometry")]
    elif gj.get("type") == "Feature":
        geoms = [gj["geometry"]]
    else:
        geoms = [gj]
    return geoms

def list_tifs(dirpath):
    tif_paths = sorted(glob.glob(os.path.join(dirpath, "*.tif")) + glob.glob(os.path.join(dirpath, "*.tiff")))
    # Expect filenames like YYYY-MM-DD_*.tif
    def date_from_name(p):
        m = re.search(r"(20\d{2}-\d{2}-\d{2})", os.path.basename(p))
        return m.group(1) if m else None
    items = [(date_from_name(p), p) for p in tif_paths]
    items = [(d, p) for (d, p) in items if d is not None]
    items.sort(key=lambda x: x[0])
    return items

def masked_mean(arr, aoi_mask=None):
    a = arr
    if aoi_mask is not None:
        a = np.where(aoi_mask, a, np.nan)
    return float(np.nanmean(a))

def main():
    ap = argparse.ArgumentParser(description="Compute NDVI/EVI/NDWI time series from BOA composites")
    ap.add_argument("--dir", required=True, help="Folder with *_BOA.tif files (one per date)")
    ap.add_argument("--aoi", help="Optional AOI GeoJSON (lon/lat)")
    ap.add_argument("--out_csv", default="indices_timeseries.csv")
    ap.add_argument("--out_png", default="ndvi_trend.png")
    args = ap.parse_args()

    items = list_tifs(args.dir)
    if not items:
        raise SystemExit("No GeoTIFFs found in --dir")

    geoms = read_aoi_geojson(args.aoi)

    rows = []
    for date, path in items:
        with rio.open(path) as ds:
            # If AOI present, reproject AOI and build mask using band 4 shape
            aoi_mask = None
            if geoms:
                try:
                    data_mask, _ = rio_mask(ds, geoms, crop=False, filled=False)
                    aoi_mask = data_mask[0].astype(bool)
                except Exception:
                    aoi_mask = None  # proceed without

            # BOA band indices (1-based): B2=2, B3=3, B4=4, B8=8
            bands = {
                "b2": read_band(ds, 2),
                "b3": read_band(ds, 3),
                "b4": read_band(ds, 4),
                "b8": read_band(ds, 8),
            }
            ndvi, evi, ndwi = compute_indices(bands)

            rows.append({
                "date": date,
                "ndvi_mean": masked_mean(ndvi, aoi_mask),
                "evi_mean": masked_mean(evi, aoi_mask),
                "ndwi_mean": masked_mean(ndwi, aoi_mask),
            })

    df = pd.DataFrame(rows).sort_values("date")
    df.to_csv(args.out_csv, index=False)

    # Plot NDVI trend
    plt.figure(figsize=(9, 4.5))
    plt.plot(pd.to_datetime(df["date"]), df["ndvi_mean"], marker="o")
    plt.title("NDVI Trend")
    plt.xlabel("Date")
    plt.ylabel("NDVI (mean)")
    plt.grid(True, linestyle="--", alpha=0.4)
    plt.tight_layout()
    plt.savefig(args.out_png, dpi=120)
    print(f"Wrote {args.out_csv} and {args.out_png}")

if __name__ == "__main__":
    main()

Notes

  • Cadence: These mosaics are generated daily–weekly, so your chart will update often.
  • Near real‑time: New BOA composites appear shortly after overpass; schedule the script accordingly.
  • Bands: BOA has 12 bands (B10 missing). We used B4/B8 for NDVI, B2/B4/B8 for EVI, B3/B8 for NDWI.

Happy building!