How to delineate field boundaries from imagery in Python

On this page

Problem statement

Where no agricultural parcel register exists, the boundaries have to come from the imagery — and a single date cannot provide them, because two adjacent fields growing the same crop at the same stage are the same colour.

What separates fields is time. Two fields differ because they were sown on different dates, with different varieties, and harvested in different weeks. A stack of dates turns an invisible boundary into a sharp one, and the delineation becomes a segmentation of a multi-band image whose bands are dates.

This guide builds that stack from real Sentinel-2 data, segments it, and checks the result against the artefacts that always appear.

Quick answer

Segment a temporal stack, not a single image:

import numpy as np
from skimage.segmentation import felzenszwalb

stack = np.dstack(ndre_by_date)                              # rows × cols × dates
stack = np.nan_to_num(stack, nan=float(np.nanmedian(stack)))

segments = felzenszwalb(stack, scale=200, sigma=1.0, min_size=250, channel_axis=2)
print(f"{segments.max() + 1:,} segments from {stack.shape[2]} dates")

scale controls how large the segments are and is the parameter to sweep. min_size is in pixels — at 20 m resolution, 250 pixels is 10 ha. That is too coarse even for Flevoland's large arable fields: on the run below it left 42% of segments spanning two registered parcels, against 31% at 50 pixels (2 ha).

Two fields indistinguishable on one date and separated across a season. one date, peak canopyno boundary in the dataa season of datesfield Afield Bsown a week apartThe boundary is in the timing, not in any single image spatialworkflow.io
Two fields with the same crop are one object on one date and two across a season.

What you get — from The data used on this page

6464 BRP parcels; {'Bouwland': 2659, 'Landschapselement': 2435, 'Grasland': 1321, 'Natuurterrein': 34, 'Braakland': 11, 'Overige': 4}

A clean temporal stack, a non-saturating index, segmentation

1. Build a clean temporal stack

Cloud-screened, gap-filled and on a common grid. Twenty dates across a season is plenty. The Flevoland series used below — Sentinel-2 scenes under 20% cloud from March to September 2025 — has 21 dates from 7 March to 6 September, and 19 of them are more than 60% clear over the area. The search returns 25 items for those 21 dates, because Earth Search lists four acquisitions twice; stack them unchecked and those dates count double.

2. Use an index that does not saturate

The stack's job is to separate fields, and NDVI is flat across the whole of mid-summer — on a June scene over these same fields, 50.7% of vegetation pixels were above 0.8. A red-edge index keeps its dynamic range and therefore keeps the boundaries.

3. Reduce the dates to a small number of components

Twenty correlated bands is more than any segmenter needs. A principal component transform reduces them to three or four that hold nearly all the variance, which is faster and less noisy.

4. Segment

Felzenszwalb, SLIC and watershed on a gradient all work. Felzenszwalb needs the least tuning and produces variable-sized segments, which suits fields. SLIC produces roughly equal-sized superpixels, which does not.

5. Merge segments that are the same field

Over-segmentation is easier to fix than under-segmentation. Merging adjacent segments whose temporal profiles are close recovers fields that were split by a tramline or a wet patch.

6. Filter the artefacts

Slivers, ribbons along roads and hedges, and segments that are mostly non-agricultural. Area and compactness catch most of them, but check both against a reference before choosing thresholds. In the Dutch parcel register (BRP) for this area, 55.4% of the 3,980 arable and grassland parcels are under half a hectare, yet together they hold only 2.6% of the farmed area; a 20 m delineation should not try to find them. Registered parcels of 2 ha or more have a median compactness of 0.66, and only 6% fall below 0.3.

Measure compactness on smoothed outlines. Polygons traced from pixels have staircase edges that inflate the perimeter: on the run below, 203 of 312 segments score below 0.3 as traced and 158 after a one-pixel simplification. Even smoothed, half the segments are less compact than 94% of registered parcels, because a segment that spans two fields is an irregular shape. A 0.3 threshold taken from the register would discard most of the output; low compactness here is a sign of merged fields, not of slivers.

7. Validate against something

Where any reference parcels exist — a register, a sample of hand-digitised fields, OSM — compare. The useful metrics are the fraction of reference parcels matched by one segment, the fraction split across several, and the fraction of segments that span two reference parcels. The first two move against the third as segments shrink, so report all of them. Example 4 computes them against the BRP.

From a cloud-screened stack to field polygons. six stepsscreened stack>60% clear datesnon-saturatingindexred edgePCA to 3–4bandsfaster, less noisyover-segmentsmall scalemerge + filterprofile similarityThe first two decide whether the boundaries exist in the data at all spatialworkflow.io
Six steps; the first two decide whether the boundaries exist in the data at all.

Delineating fields in Flevoland

The data used on this page

The area is 5.55–5.75°E, 52.48–52.56°N in Flevoland: large arable fields with a public parcel register to check against. The register is the 2025 BRP (Basisregistratie Gewaspercelen) from PDOK's OGC API; the imagery is Sentinel-2 L2A from Element 84's Earth Search, read directly from the cloud-optimised GeoTIFFs.

import os
import geopandas as gpd
import requests

BBOX = [5.55, 52.48, 5.75, 52.56]

if not os.path.exists("brp.gpkg"):
    url = "https://api.pdok.nl/rvo/gewaspercelen/ogc/v1/collections/brpgewas/items"
    params, feats = {"f": "json", "bbox": ",".join(map(str, BBOX)), "limit": 1000}, []
    while url:
        page = requests.get(url, params=params, timeout=120).json()
        feats += page["features"]
        url, params = next((l["href"] for l in page["links"] if l["rel"] == "next"), None), None
    gpd.GeoDataFrame.from_features(feats, crs=4326).to_file("brp.gpkg")
brp = gpd.read_file("brp.gpkg")
print(len(brp), "BRP parcels;", brp["category"].value_counts().to_dict())
6464 BRP parcels; {'Bouwland': 2659, 'Landschapselement': 2435, 'Grasland': 1321, 'Natuurterrein': 34, 'Braakland': 11, 'Overige': 4}
import json, urllib.request, os, numpy as np, rasterio
from rasterio.warp import transform_bounds
from rasterio.windows import from_bounds

os.environ.setdefault("GDAL_DISABLE_READDIR_ON_OPEN", "EMPTY_DIR")
os.environ.setdefault("AWS_NO_SIGN_REQUEST", "YES")
SCALE = 1e-4

def search(bbox, start, end, cloud=20, limit=60):
    body = {"collections": ["sentinel-2-l2a"], "bbox": bbox,
            "datetime": f"{start}T00:00:00Z/{end}T23:59:59Z", "limit": limit,
            "query": {"eo:cloud_cover": {"lt": cloud}}}
    req = urllib.request.Request("https://earth-search.aws.element84.com/v1/search",
        data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=120) as r:
        return json.loads(r.read())["features"]

def window_read(href, bbox, step=1):
    with rasterio.open(href) as src:
        b = transform_bounds("EPSG:4326", src.crs, *bbox)
        win = from_bounds(*b, transform=src.transform)
        return (src.read(1, window=win).astype("float32")[::step, ::step],
                src.crs, src.window_transform(win))

def build_stack(bbox, start, end, min_clear=0.6):
    # Earth Search holds some acquisitions twice (reprocessed); keep one per date
    best = {}
    for f in search(bbox, start, end):
        d, cc = f["properties"]["datetime"][:10], f["properties"]["eo:cloud_cover"]
        if d not in best or cc < best[d]["properties"]["eo:cloud_cover"]:
            best[d] = f
    layers, dates, dropped = [], [], []
    for d in sorted(best):
        f = best[d]
        a = f["assets"]
        nir, crs, tr = window_read(a["nir"]["href"], bbox, 2)
        re1, _, tr20 = window_read(a["rededge1"]["href"], bbox, 1)
        scl, _, _ = window_read(a["scl"]["href"], bbox, 1)
        h = min(nir.shape[0], re1.shape[0], scl.shape[0])
        w = min(nir.shape[1], re1.shape[1], scl.shape[1])
        nir, re1, scl = nir[:h, :w] * SCALE, re1[:h, :w] * SCALE, scl[:h, :w]
        clear = np.isin(scl, [4, 5, 6])
        if clear.mean() < min_clear:
            dropped.append(d)
            continue
        with np.errstate(invalid="ignore", divide="ignore"):
            ndre = np.where(clear, (nir - re1) / (nir + re1), np.nan)
        layers.append(ndre)
        dates.append(d)
    print(f"{len(best)} dates, {len(dates)} kept, dropped below {min_clear:.0%} clear: {dropped}")
    return np.dstack(layers), dates, crs, tr20
stack, dates, crs, tr = build_stack(BBOX, "2025-03-01", "2025-09-30")
print(stack.shape, dates[0], "to", dates[-1])
21 dates, 19 kept, dropped below 60% clear: ['2025-04-26', '2025-05-09']
(470, 695, 19) 2025-03-07 to 2025-09-06

Dropping dates below 60% clear is a decision worth reporting: here it removed 2 of 21 dates, both in the spring gap between sowing and emergence. The duplicate check matters as much. Without it, the stack has 22 layers, 17 March, 6 April and 14 August each appear twice, and the PCA weights those dates double.

Example 2 — reduce, segment and vectorise

import numpy as np, geopandas as gpd
from sklearn.decomposition import PCA
from skimage.segmentation import felzenszwalb
from rasterio.features import shapes
from shapely.geometry import shape

def vectorise(labels, transform, crs):
    polys = [{"seg": int(v), "geometry": shape(g)}
             for g, v in shapes(labels.astype("int32"), transform=transform)]
    return gpd.GeoDataFrame(polys, crs=crs).dissolve("seg", as_index=False)

def delineate(stack, transform, crs, n_components=4, scale=200, min_size=250):
    h, w, t = stack.shape
    flat = stack.reshape(-1, t)
    med = np.nanmedian(flat, axis=0)
    flat = np.where(np.isfinite(flat), flat, med)

    pca = PCA(n_components=min(n_components, t)).fit(flat)
    comp = pca.transform(flat).reshape(h, w, -1)
    print("explained variance:", np.round(pca.explained_variance_ratio_, 3))

    comp = (comp - comp.min(axis=(0, 1))) / np.ptp(comp, axis=(0, 1))
    seg = felzenszwalb(comp, scale=scale, sigma=1.0, min_size=min_size,
                       channel_axis=2)

    g = vectorise(seg, transform, crs)
    g["area_ha"] = g.area / 1e4
    smooth = g.simplify(transform.a)            # staircase edges inflate the perimeter
    g["compactness"] = 4 * np.pi * smooth.area / (smooth.length ** 2)
    print(f"{len(g):,} segments; median {g.area_ha.median():.1f} ha, "
          f"{(g.area_ha < 0.5).sum()} under 0.5 ha, "
          f"{(g.compactness < 0.3).sum()} with compactness below 0.3")
    return g, seg
segs, seg = delineate(stack, tr, crs)
segs_fine, seg_fine = delineate(stack, tr, crs, min_size=50)
explained variance: [0.549 0.179 0.143 0.055]
312 segments; median 22.6 ha, 0 under 0.5 ha, 158 with compactness below 0.3
explained variance: [0.549 0.179 0.143 0.055]
673 segments; median 8.6 ha, 0 under 0.5 ha, 280 with compactness below 0.3

Four components hold 93% of the variance of 19 dates. The first run's median segment is 22.6 ha where the median registered parcel of 2 ha or more is 5.8 ha, which is the first sign that min_size=250 is merging fields.

Example 3 — merge over-segmented neighbours

import numpy as np, geopandas as gpd
from scipy import ndimage

def merge_similar(seg, stack, threshold=0.05):
    """Merge adjacent segments whose mean temporal profiles are close."""
    n = seg.max() + 1
    profiles = np.stack([
        np.nanmean(stack[seg == i], axis=0) if (seg == i).any() else np.zeros(stack.shape[2])
        for i in range(n)])

    parent = np.arange(n)
    def find(a):
        while parent[a] != a:
            parent[a] = parent[parent[a]]
            a = parent[a]
        return a

    # adjacency from a one-pixel dilation of each label boundary
    right = np.c_[seg[:, :-1].ravel(), seg[:, 1:].ravel()]
    down = np.c_[seg[:-1, :].ravel(), seg[1:, :].ravel()]
    pairs = np.unique(np.vstack([right, down]), axis=0)
    pairs = pairs[pairs[:, 0] != pairs[:, 1]]

    for a, b in pairs:
        d = np.nanmean(np.abs(profiles[a] - profiles[b]))
        if d < threshold:
            ra, rb = find(a), find(b)
            if ra != rb:
                parent[ra] = rb

    merged = np.vectorize(find)(seg)
    print(f"{n:,} segments → {len(np.unique(merged)):,} after merging at {threshold}")
    return merged
merged = merge_similar(seg_fine, stack)
segs_merged = vectorise(merged, tr, crs)
673 segments → 597 after merging at 0.05

Merging on the mean absolute difference between temporal profiles is crude and effective. The threshold is the parameter to sweep, and the right value depends on the index's dynamic range — which is another reason to use one that does not saturate.

Example 4 — validate against the parcel register

import numpy as np, geopandas as gpd

def validate(segs, parcels, min_ha=2, match=0.8, span=0.2):
    """Share of parcels inside one segment, and of segments spanning two parcels."""
    p = parcels[parcels.area >= min_ha * 1e4]
    ov = gpd.overlay(p[["pid", "geometry"]], segs[["seg", "geometry"]],
                     how="intersection", keep_geom_type=True)
    ov["a"] = ov.area
    one = ov.groupby("pid")["a"].max() / p.set_index("pid").area >= match
    seg_area = segs.set_index("seg").area
    by_seg = ov.groupby("seg")["a"]
    farmed = by_seg.sum() / seg_area >= 0.5          # ignore segments over towns and water
    second = by_seg.apply(lambda a: np.sort(a.values)[-2] if len(a) > 1 else 0) / seg_area
    spans = second[farmed[farmed].index] >= span
    print(f"{len(p):,} parcels ≥ {min_ha} ha: {one.mean():.1%} in one segment, "
          f"{1 - one.mean():.1%} split; {spans.size:,} farmed segments, "
          f"{spans.mean():.1%} spanning two parcels")

parcels = brp[brp["category"].isin(["Bouwland", "Grasland"])].to_crs(crs).reset_index(drop=True)
parcels["pid"] = np.arange(len(parcels))
for name, s in [("min_size 250", segs), ("min_size 50", segs_fine), ("50, merged", segs_merged)]:
    print(name, end=": ")
    validate(s, parcels)
min_size 250: 1,361 parcels ≥ 2 ha: 91.0% in one segment, 9.0% split; 273 farmed segments, 41.8% spanning two parcels
min_size 50: 1,361 parcels ≥ 2 ha: 82.3% in one segment, 17.7% split; 551 farmed segments, 29.4% spanning two parcels
50, merged: 1,361 parcels ≥ 2 ha: 83.2% in one segment, 16.8% split; 496 farmed segments, 30.6% spanning two parcels

A parcel counts as matched when one segment covers 80% of it, and a segment spans two parcels when the second-largest parcel inside it fills a fifth of it. Both thresholds are choices; report them with the numbers.

Parcels split and segments spanning two parcels at two segmentation sizes, Flevoland 2025, against the BRP register. smaller segments trade one failure for the other250 px: parcels split9.0%250 px: segs spanning41.8%50 px: parcels split17.7%50 px: segs spanning29.4%merged: parcels split16.8%merged: segs spanning30.6%025%50%min_size in pixels; 1,361 registered parcels of 2 ha or more; 19 NDRE dates at 20 m spatialworkflow.io
Shrinking the segments cut the spanning share by 12.4 points and added 8.7 to the split share; merging barely moved either.

Why a single date cannot work

Why a single date cannot work

Field boundaries are management boundaries, and management is only visible when it differs. Two neighbouring fields of winter wheat sown a week apart are indistinguishable at peak canopy and clearly different at emergence and at senescence. A single image samples one moment and therefore finds only the boundaries that happen to be expressed then.

Why the index choice is not cosmetic here

A saturated index has no dynamic range, so adjacent fields that differ by real biomass have the same value and no boundary appears. On 12 June 2025, 462 of the 867 registered fields over 1 ha of wheat, potato, onion, sugar beet, carrot and grass in this area already had a mean NDVI above 0.8. Across those 462 fields, field-mean NDVI spanned 0.800–0.911, a range of 0.110, while NDRE spanned 0.515–0.778, a range of 0.263 — more than twice as much room for a boundary to show.

Why over-segmentation is the safer failure

The Flevoland run shows both failures. Dropping min_size from 250 to 50 pixels cut the segments spanning two parcels from 41.8% to 29.4%, and raised the parcels split across segments from 9.0% to 17.7%. Of the 104 segments still spanning two parcels at min_size=250 after merging, 46 join two parcels declared with the same crop, which is the case a temporal stack separates only if their dates differ. Merging by profile at 0.05 changed little here: it rejoined 76 segments but moved the split share by under a point.

A field split into three segments can be merged with a rule. Three fields merged into one segment cannot be separated without going back to the imagery, and any statistic computed on it is a weighted average of three different crops. Tune towards smaller segments and merge.

Why to validate on match fractions rather than on an overall accuracy

"Accuracy" for a delineation is ill-defined, because the reference itself is a choice of parcel type. The useful numbers are how many reference parcels are covered by exactly one segment, how many are split, and how many segments straddle two parcels. Those three name the failure modes directly.

The three numbers that describe a delineation’s quality. validate against a referencemetricwhat it measuresfailure it names1 segment per parcelclean matches—parcels split across segmentsover-segmentationtramlines, wet patchessegments spanning parcelsunder-segmentationsame crop, mergedan overall "accuracy" is undefined — the reference is itself a choice of parcel typeThree numbers, three distinct failures spatialworkflow.io
Each number names a failure mode directly, which an accuracy figure cannot.

Tramlines, grassland and irrigation circles

  • Tramlines split a field in the segmentation; merging fixes it.
  • Grassland has no strong seasonal signal and delineates poorly.
  • Irrigation circles are easy, hedges are hard.
  • Mixed cropping within a field produces genuine internal boundaries.
  • The stack must be on one grid. Reproject before stacking, not after.
  • Gap-fill before PCA. NaNs propagate through the decomposition.
  • min_size is in pixels. Convert to hectares before choosing it.
  • Record the dates used. A delineation is a statement about a season.

FAQ

Can I delineate fields from a single image?

No. Adjacent fields with the same crop at the same stage are the same colour. Boundaries appear across a season, not within one date.

Which index should I stack?

One that does not saturate — a red-edge index. NDVI is flat across most of mid-summer, which is a large part of the season contributing nothing.

How many dates do I need?

Fifteen to twenty across a season is ample, provided they span emergence and senescence as well as the peak. The Flevoland run used 19.

Which segmentation algorithm?

Felzenszwalb needs the least tuning and produces variable-sized segments, which suits fields. SLIC's equal-sized superpixels do not.

Should I aim for over- or under-segmentation?

Over. Merging adjacent segments with similar temporal profiles is a rule; separating merged fields is not.

How do I know if it worked?

Compare with any reference parcels you have, and report three numbers: parcels matched by one segment, parcels split across several, and segments spanning two parcels.