Skip to content

Paddock segmentation

SAM-based segmentation pipeline that turns a multi-temporal Sentinel-2 stack into a geopandas.GeoDataFrame of paddock polygons, with area_ha, compactness, and a 1-based paddock integer ID.

NDWI Fourier presegmentation image fed to SAM

NDWI Fourier presegmentation image fed to SAM

Filtered SAM paddocks over the Sentinel-2 window

Filtered SAM paddocks over the Sentinel-2 window

Three internal stages:

  1. Presegmentation — derives a single grayscale image from the Sentinel-2 stack using NDWI Fourier features. This emphasises stable field boundaries and suppresses transient noise (clouds, shadows, seasonal greenness). Written as a GeoTIFF at troi.preseg_path.
  2. SAM mask generation — feeds the presegmented image to segment-geospatial (samgeo). Default backbone is SAM ViT-H (sam_vit_h_4b8939.pth, ~2.4 GB) which is auto-downloaded on first use to {config.tmp_dir}/sam_weights. Outputs a mask GeoTIFF and a raw polygons GeoPackage.
  3. Vectorisation and filtering — explodes multipart geometries, reprojects to a local UTM zone for accurate area / perimeter, computes area_ha and isoperimetric compactness = 4πA/L², drops polygons outside [min_area_ha, max_area_ha] or below min_compactness, sorts by area descending, and assigns 1-based paddock IDs. Result written to troi.sam_paddocks_path.

Example: end-to-end with defaults

from datetime import date
from troi.troi import Troi
from PaddockTS.PaddockSegmentation.get_paddocks import get_paddocks

q = Troi(
    bbox=[148.36265, -33.52606, 148.38265, -33.50606],
    start=date(2024, 1, 1),
    end=date(2024, 12, 31),
    stub="seg_demo",
)

gdf = get_paddocks(q)         # downloads S2 first if needed
print(gdf[["paddock", "area_ha", "compactness"]].head())

#    paddock  area_ha  compactness
# 0        1   142.30         0.65
# 1        2    87.41         0.71
# 2        3    66.18         0.58
# ...

# Plot over a basemap
gdf.plot(facecolor="none", edgecolor="red", linewidth=1)

Example: tuning the filters

Defaults drop polygons under 5 ha, over 1500 ha, or below 0.1 isoperimetric compactness (sliver-like). For a high-resolution survey of small horticultural blocks:

gdf = get_paddocks(
    q,
    min_area_ha=0.5,      # keep small blocks
    max_area_ha=200,
    min_compactness=0.2,  # tighter shape filter
    device="cpu",         # force CPU even if CUDA available
)

For a broadacre survey where you want only large rectangular fields:

gdf = get_paddocks(
    q,
    min_area_ha=20,
    max_area_ha=5000,
    min_compactness=0.5,
)

Example: visual sanity check against NDVI

To check segmentation quality, overlay the polygons on the median NDVI of the AOI to confirm boundaries follow real field edges.

import numpy as np
import matplotlib.pyplot as plt
from pysentinel2.cube import Cube

ds = Cube(config=q.config).get_ds_troi(q, indices=('NDVI',))
ndvi_median = np.nanmedian(ds['NDVI'].values, axis=0)
extent = [ds.x.min(), ds.x.max(), ds.y.min(), ds.y.max()]

fig, ax = plt.subplots(figsize=(10, 10))
ax.imshow(ndvi_median, cmap="RdYlGn", vmin=-0.1, vmax=0.9,
          extent=extent, origin="upper")
gdf.boundary.plot(ax=ax, color="red", linewidth=1)
ax.set_title(f"{len(gdf)} paddocks")
ax.axis("off")

Bring your own paddocks

If you already have field boundaries from QGIS, a cadastral layer, or a previous run, skip SAM entirely. The downstream stages (make_paddock_time_series, plotting) accept any GeoPackage / Shapefile / GeoJSON with a paddock column. See get_outputs(..., skip_sam=True, paddocks_filepath=...) for the orchestrator-level option, or just pass the file directly:

from PaddockTS.Phenology.make_paddock_time_series import make_paddock_time_series

ts = make_paddock_time_series(
    q,
    paddocks_filepath="/path/to/my_paddocks.gpkg",
)

PaddockTS.utils.load_user_paddocks will add paddock, area_ha, and compactness columns if your file is missing them.


Reference

PaddockTS.PaddockSegmentation.get_paddocks.get_paddocks

get_paddocks(troi: Troi, ds_sentinel2=None, min_area_ha: float = 5, max_area_ha: float = 1500, min_compactness: float = 0.1, device: str | None = None) -> gpd.GeoDataFrame

End-to-end paddock segmentation: NDWI preseg → SAM masks → filtered polygons.

Caches each stage's output in the region x time cache (:class:PaddockTS.paths.Paths): preseg.tif, sam_mask.tif, sam_raw.gpkg, and the final sam_paddocks.gpkg — so reruns and other stubs over the same bbox x dates reuse the work.

Parameters:

Name Type Description Default
troi Troi

The :class:troi.Troi.

required
ds_sentinel2

Optional in-memory Sentinel-2 dataset. If None, the cloud-masked window is read from the pysentinel2 cube.

None
min_area_ha float

Minimum polygon area in hectares; smaller polygons are discarded as noise. Default 5 ha.

5
max_area_ha float

Maximum polygon area in hectares; larger polygons (typically the whole-AOI background mask) are discarded. Default 1500 ha.

1500
min_compactness float

Minimum isoperimetric compactness 4πA/L²[0, 1]. 1 is a circle, low values are sliver-like. Default 0.1 drops elongated edge artefacts.

0.1
device str | None

Torch device for SAM. None lets samgeo pick (CUDA if available, else CPU). Pass "cpu" to force CPU even with a GPU present.

None

Returns:

Type Description
GeoDataFrame

geopandas.GeoDataFrame: One row per paddock, sorted by

GeoDataFrame

area_ha descending, with columns geometry, area_ha,

GeoDataFrame

compactness, and a 1-based paddock integer ID. Also

GeoDataFrame

written to Paths(troi).sam_paddocks.