Crop Stage Detection

August 5, 2026 · View on GitHub

A crop-agnostic model for estimating the crop stage from an NDVI time series — no crop-specific calibration needed.

Crop Stage Detection Demo

How it works

Most row crops and vegetables follow the same characteristic NDVI trajectory over a growing season: a rise from bare soil through rapid greenup to peak vegetative vigor, followed by senescence and harvest. The duration, steepness, and peak height vary by crop, region, and season — but the shape is consistent enough that a single curve-relative model can locate "where on the curve" a given observation falls, without knowing which crop it is.

Crop Stages Legend

The curve is divided into five generic stages:

StageNameDescription
ABare soil / planting / emergenceNDVI below lower threshold, no prior peak
BGreenup / rapid growthNDVI in mid-range and rising
CPeak maturityNDVI at or above upper threshold
DSenescenceNDVI in mid-range and falling, peak confirmed
EPost-harvest / residue / bare soilNDVI below lower threshold, peak confirmed

Inputs

The model expects an NDVI time series in tabular format. Two entry points — pick the one that fits your workflow:

  • Bring Your Own Data — run_crop_stage_from_dataframe(df, ...) takes NDVI time series in tabular format and returns the crop stage for the last observation in the input series. The required columns are date and NDVI. If id_col is provided, the model runs by unique id_col groups (groupby); if id_col is not provided, it treats the input as a single time series.
  • Google Earth Engine (GEE) — run_crop_stage_from_gee(polygon, ...) fetches Sentinel-2 and Landsat NDVI from GEE and returns the crop stage for the last clouds free observation. polygon is the only required argument and accepts any supported format (see below). By default, the lookback window covers the previous 150 days up to the current date. Both the lookback window and the end date are configurable through the lookback_days and end_date parameters. You can also pass the same crop-stage tuning parameters shown in the Key parameters section through this wrapper. The GEE function works on one polygon at a time; for multiple fields, use a loop or ThreadPoolExecutor (see examples/02_gee.ipynb, Section 5).

Both functions handle preprocessing internally: observations are resampled to one value per day, gap-filled with PCHIP interpolation, and smoothed with a Whittaker filter. Input observations are first converted into a daily NDVI series. If the input observed date range is shorter than 30 days, the processed series will contain fewer than 30 points and the model will return an "Insufficient Data" result. If your data is already daily or pre-smoothed, the effect on your NDVI values is negligible. The stage is then assigned from three signals on the smoothed series:

SignalDescription
NDVI levelWhere the current value falls relative to adaptive lower and upper thresholds
TrendWhether NDVI is rising or falling (estimated via a Kalman filter)
Peak historyWhether a seasonal peak has already been confirmed

Stage assignment:

StageNDVI levelTrendPeak confirmed
A — Bare soil / planting / emergenceBelow lower thresholdAnyNo
B — Greenup / rapid growthBetween thresholdsRisingNo
C — Peak maturityAt or above upper thresholdAny—
D — SenescenceBetween thresholdsFallingYes
E — Post-harvest / residue / bare soilBelow lower thresholdAnyYes

Outputs

The output returns a dict with:

KeyTypeDescription
StagestrStage label: A, B, C, D, E, or "Insufficient Data"
Stage_descriptionstrHuman-readable stage name
ValuefloatNDVI at the last observation
Velocityfloat or NoneKalman-estimated rate of change (NDVI/day); None for stage C and "Insufficient Data"
Last_dateTimestamp or NoneDate of the last observation
Peak_dateTimestamp or NoneDate of the detected seasonal peak (None if no peak confirmed)
Days_since_peakintDays elapsed since peak (or None)
Upper_thresholdfloat or NoneAdaptive upper threshold used; None for "Insufficient Data"
Lower_thresholdfloatFixed lower threshold used

Supported polygon input formats (GEE workflow)

The polygon can be provided in any of these formats. Sample files for each format are in sample_data and demonstrated in examples/02_gee.ipynb.

FormatUTM supportedWhere CRS is set
File path — .shp, .gpkgYesEmbedded in file (.prj / file metadata)
File path — .zip (zipped shapefile)Yes.prj sidecar inside the zip
File path — .geojson, .kml, .kmzNoWGS84 by spec
gpd.GeoDataFrame / gpd.GeoSeriesYes.crs attribute on the object
shapely.geometry.PolygonNoNo CRS — WGS84 inferred from bounds
GeoJSON dictNoWGS84 by spec (RFC 7946)
list of [lon, lat] pairsNoWGS84 assumed

If no CRS is found but the coordinates look like lon/lat (−180..180, −90..90), WGS84 is inferred automatically with a log warning. If no CRS is found and the coordinates don't look like WGS84, the polygon is rejected.

Installation

Requires Python 3.9+.

git clone https://github.com/nasaharvest/crop-stage-detection.git
cd crop-stage-detection

Option A — Bring Your Own Data (no GEE)

If you already have an NDVI time series and just want to run the stage model:

python -m pip install -r requirements.txt

Option B — Full GEE workflow

If you want to fetch NDVI from GEE as well, install both files — requirements-gee.txt adds the GEE-specific packages on top of the core ones:

python -m pip install -r requirements.txt
python -m pip install -r requirements-gee.txt
earthengine authenticate

The GEE workflow requires Earth Engine authentication before you can run the example or API calls. See the Earth Engine authentication guide.

To verify the installation:

python test_basic.py

Examples

Worked examples are available in the notebooks:

Key parameters

All thresholds and filter settings are configurable and can be passed to the DataFrame and GEE workflows, where they are forwarded to estimate_stage_adaptive internally.

ParameterDefaultDescription
upper_percentile90Percentile of the series used as upper threshold
min_peak_ndvi0.50Hard floor for the upper threshold
lower_threshold0.35Fixed lower threshold
min_peak_width5Minimum consecutive days above upper threshold to confirm a peak
min_observations30Minimum series length for a reliable estimate
date_col"date"Column name for dates in the input DataFrame (run_crop_stage_from_dataframe)
ndvi_col"NDVI"Column name for NDVI values in the input DataFrame (run_crop_stage_from_dataframe)
stage_descriptionsbuilt-inCustom {label: description} dict to rename or translate stage labels

License

Apache 2.0 — see LICENSE.

Citation

If you use this model in your research, please cite:

@software{pelta2026cropstage,
  author       = {Pelta, Ran},
  title        = {Crop Stage Detection},
  year         = {2026},
  publisher    = {GitHub},
  organization = {NASA Harvest / Agmatix},
  url          = {https://github.com/nasaharvest/crop-stage-detection}
}

Authors

Developed by Ran Pelta (Agmatix / GrowersTech, NASA Harvest)