Threshold Mapping for Crop Health
Threshold mapping converts a continuous vegetation index raster into a discrete, zone-classified raster that prescription systems, scouting teams, and variable-rate application controllers can act on directly. The output of this pipeline is a single-band uint8 GeoTIFF where each pixel carries an integer class label — stress, moderate, healthy, or high-vigour — together with a companion vector layer of management zone polygons. This guide is part of the Drone Imagery Processing & Vegetation Index Workflows pipeline, sitting immediately downstream of band math & raster algebra and upstream of variable-rate prescription export.
The diagram below shows where threshold mapping sits in the full processing chain and the two threshold-strategy branches available in production.
Prerequisites
Python packages
rasterio>=1.3.9numpy>=1.24scikit-image>=0.21(forthreshold_otsu,threshold_multiotsu)geopandas>=0.14(for polygon export)scipy>=1.11(for morphological operations)
pip install rasterio numpy scikit-image geopandas scipy
Input data
- Single-band vegetation index GeoTIFF in
float32, value range[-1, 1](NDVI/NDRE) or[0, ~1.5](SAVI/EVI) - CRS must be a projected coordinate system — UTM (e.g., EPSG:32632, EPSG:32614) or a national grid — so that area-based polygon outputs are in metres. Never run classification on WGS84 geographic coordinates; degree-based pixels produce inconsistent zone areas across latitudes. Reproject to your target UTM zone using the approach in understanding CRS in precision agriculture
- Nodata must be set in the raster profile (not left as
None) to ensure windowed reads handle boundary tiles correctly - Upstream cloud masking for agricultural imagery must be complete; cloudy or shadow-contaminated pixels will produce false stress zones that trigger unnecessary scouting
Sensor notes
MicaSense RedEdge-MX exports five aligned bands (Blue, Green, Red, Red-edge, NIR) as separate GeoTIFFs; merge them before computing NDRE. DJI P4 Multispectral delivers a five-band stack with NIR at position 4 (0-indexed). Parrot Sequoia provides bands as individual TIFFs with calibration panels baked into the flight log. For band-order specifics across these sensors see ingesting multispectral drone imagery.
1. Concept and Algorithm
Threshold mapping is a supervised or data-driven raster reclassification: every valid pixel in the index raster is assigned an integer class based on where its value falls relative to a set of breakpoints. The agronomic meaning of those classes depends on the index and crop stage.
Fixed agronomic thresholds use breakpoints derived from calibration trials or extension publications. For NDVI mid-season maize or soybean a common set is:
| Class | Label | NDVI range |
|---|---|---|
| 1 | Stress / bare | ≤ 0.35 |
| 2 | Moderate | 0.35 – 0.55 |
| 3 | Healthy | 0.55 – 0.75 |
| 4 | High vigour | > 0.75 |
These cutoffs must be recalibrated at key growth stages (V4, R1, R5 in maize; tillering, heading in wheat) because canopy closure shifts the baseline distribution.
Adaptive statistical thresholds derive breakpoints from the field’s own index histogram. Otsu’s method maximises inter-class variance, producing a single binary split. Multi-Otsu extends this to N−1 splits for N classes. Jenks natural breaks minimises within-class variance while maximising between-class variance and is widely used in choropleth cartography. These methods are preferable when no crop-specific calibration data exists or when the field contains multiple variety strips with different biomass levels.
In practice, production pipelines use a hybrid approach: fix the outer boundaries at agronomic limits (e.g., anything below 0.15 is bare soil regardless of the histogram), then split the remaining range adaptively. This prevents an outlier-heavy distribution from placing breakpoints in agronomically meaningless positions.
2. Step-by-Step Implementation
Step 1 — Ingest and validate
Open the vegetation index raster, verify CRS, data type, and nodata value. Reject inputs that do not meet minimum data quality criteria before any classification logic runs.
import rasterio
import numpy as np
from pathlib import Path
def open_and_validate_vi(input_path: str) -> rasterio.DatasetReader:
"""Open a vegetation index raster and enforce minimum quality criteria."""
p = Path(input_path)
if not p.exists():
raise FileNotFoundError(f"Raster not found: {input_path}")
src = rasterio.open(input_path)
# Must be a projected CRS — geographic CRS produces inaccurate zone areas
if src.crs is None:
raise ValueError("Raster has no CRS — reproject before classifying.")
if src.crs.is_geographic:
raise ValueError(
f"CRS {src.crs.to_epsg()} is geographic. "
"Reproject to a UTM zone (e.g., EPSG:32632) before classifying."
)
# Must be single-band float
assert src.count == 1, f"Expected single-band raster, got {src.count} bands."
assert src.dtypes[0] in ("float32", "float64"), (
f"Expected float32/64 index raster, got {src.dtypes[0]}. "
"Cast to float32 after radiometric conversion."
)
# Nodata must be declared so windowed reads handle border tiles correctly
if src.nodata is None:
src.close()
raise ValueError("Raster has no nodata value set. Set nodata in rasterio profile.")
print(f"Validated: {p.name} | CRS: {src.crs.to_epsg()} | "
f"Size: {src.width}×{src.height} | Nodata: {src.nodata}")
return src
Step 2 — Choose and compute thresholds
from skimage.filters import threshold_multiotsu
def compute_thresholds(
src: rasterio.DatasetReader,
strategy: str = "fixed",
fixed_breaks: list = None,
n_classes: int = 4,
sample_fraction: float = 0.05
) -> list[float]:
"""
Derive threshold breakpoints for an n-class classification.
For adaptive strategies, a random spatial sample avoids loading the full raster.
sample_fraction controls what fraction of pixels to sample (default 5%).
"""
if strategy == "fixed":
breaks = fixed_breaks if fixed_breaks else [0.35, 0.55, 0.75]
assert len(breaks) == n_classes - 1, (
f"Need {n_classes-1} breakpoints for {n_classes} classes, "
f"got {len(breaks)}."
)
return breaks
if strategy in ("otsu", "jenks"):
# Stratified random sample to avoid loading full raster into RAM
total_pixels = src.width * src.height
sample_size = max(10_000, int(total_pixels * sample_fraction))
nodata = src.nodata
rows = np.random.randint(0, src.height, sample_size)
cols = np.random.randint(0, src.width, sample_size)
# Read individual pixel windows (vectorised via rasterio sample)
xy = list(zip(
src.transform.c + cols * src.transform.a,
src.transform.f + rows * src.transform.e
))
values = np.array([v[0] for v in src.sample(xy)], dtype=np.float32)
valid = values[~np.isnan(values) & (values != nodata) & np.isfinite(values)]
if len(valid) < n_classes * 50:
raise ValueError(
f"Insufficient valid pixels for adaptive thresholding "
f"(need ≥{n_classes*50}, got {len(valid)})."
)
if strategy == "otsu":
thresholds = threshold_multiotsu(valid, classes=n_classes)
return sorted(thresholds.tolist())
if strategy == "jenks":
# Jenks via Fisher-Jenks algorithm from mapclassify (optional dep)
try:
import mapclassify
jnk = mapclassify.JenksCaspall(valid, k=n_classes)
return sorted(jnk.bins[:-1].tolist())
except ImportError:
raise ImportError(
"mapclassify is required for Jenks thresholds: "
"pip install mapclassify"
)
raise ValueError(f"Unknown strategy '{strategy}'. Choose 'fixed', 'otsu', or 'jenks'.")
Step 3 — Classify with windowed I/O
The core loop reads 1024×1024 pixel windows, classifies each, and writes back. This keeps peak RAM below 500 MB regardless of orthomosaic size.
from rasterio.windows import Window
def classify_windowed(
src: rasterio.DatasetReader,
output_path: str,
thresholds: list[float],
window_size: int = 1024
) -> None:
"""
Classify a vegetation index raster into discrete health zones using
windowed I/O. Writes a uint8 GeoTIFF where 0 = nodata, 1–N = class.
"""
nodata = src.nodata
n_classes = len(thresholds) + 1
breaks = sorted(thresholds)
profile = src.profile.copy()
profile.update(dtype="uint8", count=1, nodata=0, compress="lzw", predictor=2)
classified_arr = np.zeros((src.height, src.width), dtype=np.uint8)
for row_off in range(0, src.height, window_size):
for col_off in range(0, src.width, window_size):
h = min(window_size, src.height - row_off)
w = min(window_size, src.width - col_off)
win = Window(col_off, row_off, w, h)
data = src.read(1, window=win).astype(np.float32)
valid = (data != nodata) & np.isfinite(data)
if not np.any(valid):
continue
classes = np.zeros_like(data, dtype=np.uint8)
# Assign classes 1..N based on sorted breakpoints
prev_upper = -np.inf
for cls_id, upper in enumerate(breaks, start=1):
mask = valid & (data > prev_upper) & (data <= upper)
classes[mask] = cls_id
prev_upper = upper
# Last class captures everything above the highest breakpoint
classes[valid & (data > breaks[-1])] = n_classes
classified_arr[row_off:row_off+h, col_off:col_off+w] = classes
with rasterio.open(output_path, "w", **profile) as dst:
dst.write(classified_arr, 1)
print(f"Classified raster written to {output_path}")
# Verify class distribution
unique, counts = np.unique(classified_arr[classified_arr > 0], return_counts=True)
for u, c in zip(unique, counts):
print(f" Class {u}: {c:,} px ({100*c/classified_arr.size:.1f}%)")
Step 4 — Morphological post-processing
Isolated misclassified pixels — caused by sensor noise, mixed boundary pixels, or calibration targets — create speckle that confuses VRA controllers. A sieve filter removes patches smaller than a minimum area threshold, and a majority filter smooths jagged zone boundaries.
from scipy.ndimage import label, generic_filter
def sieve_and_smooth(
classified_path: str,
min_patch_px: int = 25,
smooth_radius: int = 1
) -> np.ndarray:
"""
Remove small isolated patches (sieve) and apply majority smoothing.
min_patch_px: minimum connected patch size in pixels (e.g. 25 px ≈ 2.5 m²
at 10 cm GSD — roughly the size of one soybean row).
smooth_radius: neighbourhood radius for majority filter (pixels).
"""
with rasterio.open(classified_path) as src:
arr = src.read(1).copy()
profile = src.profile.copy()
nodata_mask = arr == 0
# Sieve: label connected components per class, remove undersized patches
sieved = arr.copy()
for cls in np.unique(arr[arr > 0]):
binary = (arr == cls)
labelled, n_feat = label(binary)
for feat_id in range(1, n_feat + 1):
patch = labelled == feat_id
if patch.sum() < min_patch_px:
sieved[patch] = 0 # mark for reassignment
# Fill sieved-out pixels with nearest valid neighbour (majority in 3×3)
def majority(values):
vals = values[values > 0]
if len(vals) == 0:
return 0
counts = np.bincount(vals.astype(int))
return int(np.argmax(counts))
kernel_size = 2 * smooth_radius + 1
smoothed = generic_filter(
sieved.astype(np.float32),
majority,
size=kernel_size,
mode="nearest"
).astype(np.uint8)
smoothed[nodata_mask] = 0 # restore nodata pixels
with rasterio.open(classified_path, "r+") as dst:
dst.write(smoothed, 1)
return smoothed
Step 5 — Vectorise zone polygons
Convert the classified raster to GeoJSON polygons for import into farm management software (FMS), tractor terminals, or GIS platforms.
import geopandas as gpd
from rasterio.features import shapes
from shapely.geometry import shape
def vectorise_zones(
classified_path: str,
output_gpkg: str,
class_labels: dict = None
) -> gpd.GeoDataFrame:
"""
Convert a classified zone raster to a GeoPackage of clean zone polygons.
Requires that the input CRS is projected (metres) for accurate area fields.
"""
labels = class_labels or {1: "Stress", 2: "Moderate", 3: "Healthy", 4: "High Vigour"}
with rasterio.open(classified_path) as src:
assert not src.crs.is_geographic, (
"Raster CRS must be projected for area calculations. "
f"Got: {src.crs}"
)
arr = src.read(1)
transform = src.transform
crs = src.crs
geoms, class_vals, areas = [], [], []
for geom_dict, val in shapes(arr, mask=(arr > 0), transform=transform):
geom = shape(geom_dict)
geoms.append(geom)
class_vals.append(int(val))
areas.append(geom.area) # m² when CRS is projected
gdf = gpd.GeoDataFrame(
{
"class_id": class_vals,
"label": [labels.get(v, f"Class {v}") for v in class_vals],
"area_ha": [a / 10_000 for a in areas],
},
geometry=geoms,
crs=crs,
)
# Remove slivers (< 1 m²)
gdf = gdf[gdf["area_ha"] > 0.0001].reset_index(drop=True)
gdf.to_file(output_gpkg, layer="crop_health_zones", driver="GPKG")
print(f"Vectorised zones saved: {output_gpkg}")
print(gdf.groupby("label")["area_ha"].sum().round(2))
return gdf
Step 6 — Full pipeline entry point
def run_threshold_pipeline(
vi_raster: str,
classified_out: str,
vector_out: str,
strategy: str = "fixed",
fixed_breaks: list = None,
n_classes: int = 4,
min_patch_px: int = 25,
) -> gpd.GeoDataFrame:
"""
End-to-end crop health threshold mapping pipeline.
Returns a GeoDataFrame of management zone polygons.
"""
src = open_and_validate_vi(vi_raster)
try:
breaks = compute_thresholds(src, strategy=strategy,
fixed_breaks=fixed_breaks,
n_classes=n_classes)
print(f"Thresholds ({strategy}): {breaks}")
classify_windowed(src, classified_out, breaks)
finally:
src.close()
sieve_and_smooth(classified_out, min_patch_px=min_patch_px)
return vectorise_zones(classified_out, vector_out)
# Example call — NDVI maize mid-season with fixed agronomic thresholds
if __name__ == "__main__":
zones = run_threshold_pipeline(
vi_raster="ndvi_field_utm32.tif",
classified_out="crop_health_classified.tif",
vector_out="management_zones.gpkg",
strategy="fixed",
fixed_breaks=[0.35, 0.55, 0.75],
)
3. Key Parameters and Tuning
| Parameter | Type | Default | Agronomic Effect |
|---|---|---|---|
fixed_breaks |
list[float] |
[0.35, 0.55, 0.75] |
Zone boundary positions; recalibrate per crop species, phenological stage, and regional biomass norms |
strategy |
str |
"fixed" |
Determines threshold origin; "otsu" adapts to field distribution, useful for heterogeneous fields without calibration data |
n_classes |
int |
4 |
Number of health zones; 3–5 is agronomically meaningful; >5 classes rarely align with distinct management responses |
window_size |
int |
1024 |
Processing tile size in pixels; reduce to 512 on memory-constrained instances; increase to 2048 for high-RAM servers |
min_patch_px |
int |
25 |
Minimum contiguous zone area in pixels; at 10 cm GSD, 25 px ≈ 2.5 m² (one soybean row segment); increase for coarser sensors |
smooth_radius |
int |
1 |
Neighbourhood radius for majority filter; 1 = 3×3 kernel; increase to 2 for smoother polygons at the cost of edge precision |
sample_fraction |
float |
0.05 |
Fraction of pixels sampled for adaptive threshold estimation; reduce to 0.01 for very large rasters (>5 GB) |
4. Handling Edge Cases and Failure Modes
Integer overflow and precision loss. Input rasters stored as int16 or uint16 (common in sensor-native exports) will silently produce wrong values if read without casting. Always call .astype(np.float32) immediately after src.read(). Use np.clip(data, -1, 1) for NDVI or np.clip(data, 0, 1.5) for EVI before classifying.
Threshold drift across seasons. Canopy closure, senescence, and variety differences shift the biomass distribution. Fixed thresholds calibrated at V6 will over-classify stress at R6 when canopy naturally senesces. Either recalibrate thresholds at each growth stage, or switch to adaptive methods that normalise against the current flight’s histogram. Log the threshold values used for every flight in the output raster’s metadata tags for traceability.
Mixed pixels at field edges. Boundary pixels blend canopy with soil, road, or adjacent crop. Before classifying, clip the vegetation index raster to a field boundary polygon buffered inward by 1–2 m using rasterio.mask.mask(). This eliminates the most egregious mixed pixels without the complexity of full edge-effect compensation — for deeper treatment see handling edge effects in raster index generation.
UTM zone boundary crossings. Fields that straddle a UTM boundary (e.g., spanning 6°W longitude from EPSG:32629 to EPSG:32630) will have mismatched affine transforms if individual tiles were processed separately. Always reproject all inputs to a single target UTM zone before mosaicking and classifying. Confirm with assert src.crs.to_epsg() == target_epsg at ingestion.
Zero denominator in upstream band math. If the vegetation index raster was computed without a safe-division guard, pixels over water or bare soil may carry inf or -inf. These pass nodata checks silently. Add data = np.where(np.isfinite(data), data, np.nan) as the first operation in the classification window loop to catch them.
Cloud shadow masking upstream failures. Cloud shadows reduce NIR reflectance and produce low index values that mimic severe crop stress. If the upstream cloud masking step was too conservative or used a fixed NIR threshold that misidentified dense canopy as shadow, the classification will show false stress zones beneath where clouds passed. Cross-reference the cloud mask with the classified output using rasterio.features.shapes() to check for geometric overlap between shadow pixels and high-confidence stress zones.
5. Verification and Output Validation
Histogram inspection. Before writing the output, assert that each class contains a plausible share of valid pixels. A field where 100% of pixels fall in class 1 (stress) almost always indicates a classification error rather than a genuine agronomic disaster.
# After classify_windowed() — sanity check class distribution
with rasterio.open(classified_out) as src:
arr = src.read(1)
valid = arr[arr > 0]
if len(valid) == 0:
raise ValueError("No valid classified pixels — check nodata handling.")
for cls in range(1, n_classes + 1):
pct = 100 * (arr == cls).sum() / len(valid)
print(f"Class {cls}: {pct:.1f}%")
# Warn if any single class captures >90% of the field
assert pct < 90, f"Class {cls} dominates ({pct:.1f}%) — verify thresholds."
Ground-truth scouting validation. Walk transects across each zone and record visual crop condition. Healthy and high-vigour zones should exhibit closed canopy and uniform leaf colouring. Stress zones should correlate with visible symptoms: chlorosis, wilting, pest damage, or waterlogged soil. Log GPS coordinates of validation points and compute a confusion matrix against classified pixel values at those locations.
Yield monitor overlay. After harvest, overlay the classified zones with combine yield monitor data. In a well-calibrated classification, high-vigour zones (class 4) should consistently yield 15–30% above stress zones (class 1) in mature cereal or oilseed crops. Weak correlation indicates threshold recalibration is needed before the next season.
Temporal consistency check. Re-run the same fixed thresholds on two flights from the same phenological window in consecutive years. Zone patterns should be spatially stable for features driven by permanent soil properties (drainage, organic matter gradients) and temporally variable for features driven by agronomic management (seeding rate variability, compaction). Sudden, field-wide class shifts between flights taken days apart with no weather event almost always indicate sensor calibration drift.
6. Integration with the Broader Pipeline
Threshold mapping sits at the output end of the vegetation index computation stage and the input end of variable-rate prescription generation. The classified zone raster and accompanying GeoPackage produced here feed directly into:
- Variable-rate prescription export — the zone polygons drive application rate lookups per zone in ISOXML or Shapefile format for VRA controllers
- Temporal change detection — running the same classification across multiple dates, as detailed in temporal aggregation of vegetation indices, enables season-long stress trajectory tracking
Upstream inputs this cluster depends on:
- Calibrated, cloud-masked NDVI/NDRE/SAVI raster from band math & raster algebra in Python
- Cloud and shadow removal via cloud masking for agricultural imagery
- Field boundary polygon in the same projected CRS, validated via field boundary extraction with GeoPandas
Store the classified GeoTIFF in Cloud Optimized GeoTIFF format (compress="lzw", tiled=True, blockxsize=512, blockysize=512) and publish overviews so web mapping clients can render the zone map without reading the full file.
This guide is part of the Drone Imagery Processing & Vegetation Index Workflows — see there for the full pipeline context, from acquisition and orthomosaic stitching through to prescription export.
Frequently Asked Questions
Why does my classified raster show false stress zones at field edges?
Edge pixels mix canopy and soil or background reflectance, deflating index values below crop-stress thresholds. Apply a 1–2 m inward buffer to the field boundary polygon before classifying, and use reflect-mode padding for any upstream spatial smoothing. See handling edge effects in raster index generation for the full implementation.
When should I use Otsu thresholds instead of fixed agronomic cutoffs?
Use Otsu or Jenks natural-breaks thresholds when you lack crop-specific calibration data, when the field contains multiple variety strips, or in early season before canopy closure when biomass variance is high. Fixed thresholds are preferable for multi-seasonal comparisons because they maintain consistent zone definitions across dates.
My NDVI values are unexpectedly high everywhere — why?
The most common causes are: (1) input data has not been converted from raw digital numbers to surface reflectance — classification must run on reflectance, not DNs; (2) the nodata value was not masked and coincides with a value in a valid index range; (3) the wrong band was read for NIR — verify band order against your sensor’s datasheet (MicaSense RedEdge-MX uses Band 5 for NIR, DJI P4 Multispectral uses Band 4).
Related
- Band Math & Raster Algebra in Python — compute NDVI, NDRE, and SAVI from multispectral bands before classification
- Cloud Masking for Agricultural Imagery — remove atmospheric contamination upstream of threshold mapping
- Temporal Aggregation of Vegetation Indices — stack multi-date classifications for season-long crop monitoring
- Handling Edge Effects in Raster Index Generation — prevent false stress zones at field boundaries
- Field Boundary Extraction with GeoPandas — generate and validate field boundary polygons for zone clipping