Post-Processing¶
Polygonisation, merging, area filtering, smoothing, simplification, regularisation and the LULC crop filter.
postprocess ¶
Post-processing utilities for field boundary polygons.
Provides shared functionality for polygonization, simplification, regularization, area filtering, and cross-tile merging.
filter_polygons ¶
filter_polygons(gdf: GeoDataFrame, min_area_m2: float = 2500.0, max_area_m2: float | None = None, remove_holes_below_m2: float | None = None, lulc_mask_path: str | None = None, lulc_agricultural_classes: list[int] | None = None, lulc_min_fraction: float = 0.5) -> gpd.GeoDataFrame
Filter field boundary polygons by area and an optional LULC raster.
Steps, in order:
- Area filter: keep polygons with
min_area_m2 <= area(skipped whenmin_area_m2 <= 0) andarea <= max_area_m2(when given). Areas are computed in EPSG:6933 and exclude holes. Rows with a missing geometry have no area and are removed by an active area filter. - Hole removal: interior rings whose own area is below
remove_holes_below_m2are filled. - LULC mask (only when both lulc_mask_path and
lulc_agricultural_classes are given): keep polygons for which more
than lulc_min_fraction of the valid (non-nodata) raster pixels whose
centres fall inside the polygon belong to lulc_agricultural_classes.
The pipeline's LULC crop filter is
:func:
agribound.postprocess.lulc_filter.filter_by_lulc, not this.
No area columns are added to the output; the pipeline writes the final
metrics:area/metrics:perimeter after all geometry edits.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gdf
|
GeoDataFrame
|
Input polygons (must have a CRS). |
required |
min_area_m2
|
float
|
Minimum polygon area in m² (default 2500). |
2500.0
|
max_area_m2
|
float or None
|
Maximum polygon area in m². None means no upper limit. |
None
|
remove_holes_below_m2
|
float or None
|
Fill interior rings (holes) smaller than this area in m². |
None
|
lulc_mask_path
|
str or None
|
Path to a classified LULC raster. |
None
|
lulc_agricultural_classes
|
list[int] or None
|
LULC class values considered agricultural. |
None
|
lulc_min_fraction
|
float
|
Minimum agricultural pixel fraction for the LULC mask (exclusive, default 0.5). |
0.5
|
Returns:
| Type | Description |
|---|---|
GeoDataFrame
|
Filtered polygons in the input CRS (index reset, attributes kept). |
Raises:
| Type | Description |
|---|---|
ValueError
|
If gdf has no CRS (areas in m² cannot be computed). |
Source code in agribound/postprocess/filter.py
22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 | |
merge_polygons ¶
merge_polygons(gdf: GeoDataFrame, iou_threshold: float = 0.3, containment_threshold: float = 0.8, *, return_groups: bool = False) -> gpd.GeoDataFrame | tuple[gpd.GeoDataFrame, list[list[int]]]
Merge polygons that overlap strongly.
Two polygons a and b whose interiors or boundaries intersect are
linked when IoU = area(a ∩ b) / area(a ∪ b) >= iou_threshold or when
area(a ∩ b) / min(area(a), area(b)) >= containment_threshold. Areas
are measured in the frame's CRS units (the ratios are unit-free). Links
are transitive (union-find): if a is linked to b and b to c, all
three are merged, even when a and c do not overlap. Each group of
linked polygons is replaced by the union of its (repaired) geometries.
Attribute rule for merged rows: all non-geometry columns are copied from
the group's largest input polygon (by area); values are not aggregated.
If a group's union is a MultiPolygon it is split into one row per part,
each carrying those attributes. Polygons that are not linked to any other
are returned unchanged, attributes included. Output rows are ordered by
the position of each group's first member in the input. gdf.attrs is
preserved and attrs["merge_stats"] records the counts. A frame with
fewer than two rows is returned as it is (the same object, without
merge_stats).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gdf
|
GeoDataFrame
|
Input polygons (potentially with duplicates). |
required |
iou_threshold
|
float
|
IoU at or above which two polygons are merged (default 0.3). |
0.3
|
containment_threshold
|
float
|
Fraction of the smaller polygon covered by the intersection at or above which two polygons are merged (default 0.8). |
0.8
|
return_groups
|
bool
|
Also return, for every output row, the input positions (0-based) that were merged into it. |
False
|
Returns:
| Type | Description |
|---|---|
GeoDataFrame or tuple
|
Polygons with all input columns (index reset); with return_groups,
|
Source code in agribound/postprocess/merge.py
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | |
polygonize_mask ¶
polygonize_mask(mask_path: str, min_area_m2: float = 2500.0, band: int = 1, connectivity: int = 4, field_value: int | None = None) -> gpd.GeoDataFrame
Convert a raster segmentation mask to field boundary polygons.
Every connected region (under connectivity) of equal non-zero value
becomes one polygon, with holes where other values are enclosed.
Non-finite pixels and pixels equal to the raster's (finite) nodata value
are treated as background (0). Float rasters are accepted only when all
finite values are whole numbers (e.g. labels stored as float32);
probability rasters must be thresholded first. Polygons that
rasterio.features.shapes returns invalid are repaired with
:func:agribound.postprocess.simplify.make_polygonal rather than dropped.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mask_path
|
str
|
Path to the segmentation mask GeoTIFF. Non-zero values are treated as field pixels (or only field_value if specified). |
required |
min_area_m2
|
float
|
Minimum polygon area in m² to keep (default 2500; |
2500.0
|
band
|
int
|
Band index to polygonize (1-based, default 1). |
1
|
connectivity
|
int
|
Pixel connectivity for grouping, 4 or 8 (default 4). |
4
|
field_value
|
int or None
|
If set, only pixels equal to this value are polygonized. If None, all non-zero pixels are included. |
None
|
Returns:
| Type | Description |
|---|---|
GeoDataFrame
|
Polygons with |
Raises:
| Type | Description |
|---|---|
ValueError
|
If connectivity is not 4 or 8, or a float raster holds non-integer values. |
Source code in agribound/postprocess/polygonize.py
23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 | |
regularize_polygons ¶
regularize_polygons(gdf: GeoDataFrame, method: str = 'adaptive', *, simplify_tolerance_m: float = 0.5, area_threshold: float = 0.9, parallel_threshold_m: float = 1.0, allow_45_degree: bool = True, allow_circles: bool = True, circle_threshold: float = 0.9, num_cores: int = 1) -> gpd.GeoDataFrame
Regularize field boundary polygon geometry.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gdf
|
GeoDataFrame
|
Input polygons (with a CRS). |
required |
method
|
str
|
|
'adaptive'
|
simplify_tolerance_m
|
float
|
Douglas-Peucker tolerance in metres used by both methods (geoai default 0.5). |
0.5
|
area_threshold
|
float
|
|
0.9
|
parallel_threshold_m
|
float
|
|
1.0
|
allow_45_degree
|
(bool, bool, float)
|
|
True
|
allow_circles
|
(bool, bool, float)
|
|
True
|
circle_threshold
|
(bool, bool, float)
|
|
True
|
num_cores
|
int
|
|
1
|
Returns:
| Type | Description |
|---|---|
GeoDataFrame
|
Regularized polygons in the input CRS with the input's columns (index
reset). |
Raises:
| Type | Description |
|---|---|
ValueError
|
If method is unknown or gdf has no CRS. |
ImportError
|
If geoai-py (or, for |
Source code in agribound/postprocess/regularize.py
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | |
simplify_polygons ¶
simplify_polygons(gdf: GeoDataFrame, tolerance: float = 2.0, preserve_topology: bool = True) -> gpd.GeoDataFrame
Simplify field boundary polygons with a tolerance in metres.
Unless the frame is already in a UTM zone it is projected to the UTM zone
estimated from its extent (:func:to_metric_crs), simplified with
:meth:geopandas.GeoSeries.simplify, and projected back, so the
tolerance is in ground metres for any input CRS. A frame without a CRS
is simplified in its native coordinate units (logged as a warning).
Geometries that become invalid are repaired (:func:make_polygonal);
geometries that collapse to empty are removed (the number removed is
logged). Attribute columns are preserved.
Applied after :func:smooth_polygons, as in the pipeline's default
post-processing, the 2 m default removed area from 95-98 % of the
polygons of four real engine outputs (median -0.2 % to -1.9 % of the
smoothed area); see :func:agribound.pipeline._postprocess.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gdf
|
GeoDataFrame
|
Input polygons. |
required |
tolerance
|
float
|
Douglas-Peucker tolerance in metres (default 2.0). |
2.0
|
preserve_topology
|
bool
|
Use the topology-preserving variant (default True), which avoids collapsed or self-intersecting rings. |
True
|
Returns:
| Type | Description |
|---|---|
GeoDataFrame
|
Simplified polygons in the input CRS (index reset). |
Source code in agribound/postprocess/simplify.py
Smoothing and simplification¶
simplify ¶
Polygon smoothing and simplification.
- :func:
smooth_polygonsapplies Chaikin corner cutting to round off the pixel-staircase edges left by raster-to-vector conversion. - :func:
simplify_polygonsapplies Douglas-Peucker simplification with a tolerance in metres: unless the data are already in a UTM zone, they are projected to the UTM zone estimated from their extent for the operation and projected back afterwards.
The module also holds two helpers shared by the other post-processing
modules: :func:to_metric_crs and :func:make_polygonal.
to_metric_crs ¶
Project gdf to a UTM zone so that distances are in ground metres.
Frames already in a UTM zone (pyproj.CRS.utm_zone is set, e.g.
EPSG:326xx/327xx or NAD83 UTM) are returned unchanged. Every other CRS --
geographic, Web Mercator, equal-area, State Plane in feet -- is projected
to the UTM zone that :meth:geopandas.GeoDataFrame.estimate_utm_crs
picks for the data's extent.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gdf
|
GeoDataFrame
|
Input frame with a CRS and at least one non-empty geometry (unless it is already in a UTM zone). |
required |
Returns:
| Type | Description |
|---|---|
tuple[GeoDataFrame, CRS or None]
|
|
Source code in agribound/postprocess/simplify.py
make_polygonal ¶
Return a valid polygonal version of geom.
Invalid geometries are repaired with :func:shapely.validation.make_valid
and only their polygonal parts are kept (lines and points produced by the
repair are discarded). Returns None for missing input and an empty
Polygon when nothing polygonal remains.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
geom
|
shapely geometry or None
|
Input geometry. |
required |
Returns:
| Type | Description |
|---|---|
Polygon or MultiPolygon or None
|
|
Source code in agribound/postprocess/simplify.py
smooth_polygons ¶
smooth_polygons(gdf: GeoDataFrame, iterations: int = 3, max_segment_length: float | None = None) -> gpd.GeoDataFrame
Smooth pixel-staircase artefacts with Chaikin's corner-cutting algorithm.
Chaikin's scheme is affine-invariant, so it is applied directly to the
vertex coordinates in the frame's CRS (no reprojection). Each iteration
doubles the vertex count and cuts every corner at 1/4 and 3/4 of its
adjacent segments, so the depth of a cut scales with the segment length
and convex outlines shrink. :func:rasterio.features.shapes traces an
axis-aligned rectangular pixel region with only its four corners, and
three iterations then remove about 16 % of its area (at any scale);
staircase outlines of oblique edges have a vertex at every pixel step and
lose much less (about 0.02 % for a 60 x 40 pixel rectangle rotated by
30 degrees, about 1 % for a 10 x 6 pixel one). Passing
max_segment_length (e.g. the pixel size) first inserts vertices so no
segment is longer than that, which confines the rounding to the corners
(a 20 x 10 pixel rectangle densified at one pixel loses about 0.1 %).
The corner cut grows with the segment length, so long straight edges
traced with few vertices are rounded over tens of metres: a 400 x 300 m
rectangle traced with four corners loses 16.4 % of its area and moves by
up to 60 m (Hausdorff distance). On real engine outputs, three
iterations changed the polygon area by a median of -0.35 % to -1.6 %
(10th percentile -3.3 % to -7.7 %; Hausdorff distance median 6.6-9.3 m,
90th percentile 17-25 m) for Delineate-Anything on 0.9 m NAIP and 10 m
Sentinel-2 and FTW on 10 m Sentinel-2; see
:func:agribound.pipeline._postprocess for the full measurement.
Holes are smoothed the same way. If a smoothed polygon becomes invalid it is repaired and only its largest part is kept; if nothing remains the original polygon is kept. Attribute columns are preserved.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gdf
|
GeoDataFrame
|
Input polygons (typically raw output from raster vectorization). |
required |
iterations
|
int
|
Number of Chaikin iterations (default 3). |
3
|
max_segment_length
|
float or None
|
Densify rings with :func: |
None
|
Returns:
| Type | Description |
|---|---|
GeoDataFrame
|
Smoothed polygons (empty geometries removed, index reset). |
Source code in agribound/postprocess/simplify.py
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | |
simplify_polygons ¶
simplify_polygons(gdf: GeoDataFrame, tolerance: float = 2.0, preserve_topology: bool = True) -> gpd.GeoDataFrame
Simplify field boundary polygons with a tolerance in metres.
Unless the frame is already in a UTM zone it is projected to the UTM zone
estimated from its extent (:func:to_metric_crs), simplified with
:meth:geopandas.GeoSeries.simplify, and projected back, so the
tolerance is in ground metres for any input CRS. A frame without a CRS
is simplified in its native coordinate units (logged as a warning).
Geometries that become invalid are repaired (:func:make_polygonal);
geometries that collapse to empty are removed (the number removed is
logged). Attribute columns are preserved.
Applied after :func:smooth_polygons, as in the pipeline's default
post-processing, the 2 m default removed area from 95-98 % of the
polygons of four real engine outputs (median -0.2 % to -1.9 % of the
smoothed area); see :func:agribound.pipeline._postprocess.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gdf
|
GeoDataFrame
|
Input polygons. |
required |
tolerance
|
float
|
Douglas-Peucker tolerance in metres (default 2.0). |
2.0
|
preserve_topology
|
bool
|
Use the topology-preserving variant (default True), which avoids collapsed or self-intersecting rings. |
True
|
Returns:
| Type | Description |
|---|---|
GeoDataFrame
|
Simplified polygons in the input CRS (index reset). |
Source code in agribound/postprocess/simplify.py
LULC crop filter¶
lulc_filter ¶
LULC crop filter.
Removes non-agricultural polygons using a land-use/land-cover (LULC) dataset
read from Google Earth Engine. For every polygon the filter computes a crop
value in [0, 1] and drops polygons below config.lulc_crop_threshold:
======================== ========================================== ========== =========
lulc_dataset Earth Engine asset / crop rule Years Scale
======================== ========================================== ========== =========
"nlcd" projects/sat-io/open-datasets/USGS/ 1985-2025 30 m
ANNUAL_NLCD/LANDCOVER band b1, classes
81 (pasture/hay) and 82 (cultivated crops);
fraction of pixels
"cdl" USDA/NASS/CDL band cultivated == 2; 2013-2023 30 m
fraction of pixels (CONUS)
"dynamic_world" GOOGLE/DYNAMICWORLD/V1 annual median of 2016-last 10 m
crops; mean probability (not a pixel full year
fraction)
"c3s" projects/sat-io/open-datasets/ESA/C3S-LC- 2000-2022 300 m
L4-LCCS band b1, classes 10, 11, 12,
20, 30; fraction of pixels
======================== ========================================== ========== =========
Tree crops (config.lulc_tree_crops=True): Dynamic World files plantations
and orchards under trees, so their crops probability is low and the
default rule removes them. With the option, the Dynamic World value is the
annual median of the per-image sum of the crops and trees
probabilities, and C3S also counts its tree-cover classes
(:data:C3S_TREE_CLASSES: 50, 60, 61, 62, 70, 71, 72, 80, 81, 82, 90). With
these two datasets the filter then keeps forest as well as tree crops; it
still removes water, built-up, bare, grass and shrub land. NLCD and CDL are
unchanged, so with them it still removes forest: NLCD class 82 (cultivated
crops) includes "perennial woody crops such as orchards and vineyards", and
CDL counts orchards as cultivated.
Year ranges are those of the assets on 2026-09-26. When the requested year is
outside a dataset's range, the nearest available year is used, recorded in
lulc:year and logged as a WARNING. Annual NLCD and C3S come from the
community catalogue (projects/sat-io), which has no Google service-level
agreement; if the Annual NLCD asset cannot be read, the official
USGS/NLCD_RELEASES/2021_REL/NLCD (single year 2021, band landcover) is
used instead, with a WARNING. The CDL cultivated band is present in Earth
Engine only for 2013-2023.
Dataset routing (lulc_dataset="auto", :func:select_lulc_dataset), for
the study area (or, without a study area, the local raster's footprint):
- If the area intersects the conterminous-US envelope (-125, 24, -66, 50) and at least 90 % of it has valid Annual NLCD pixels (checked on Earth Engine), use NLCD. Northern Mexico and southern Canada fall inside the envelope but have no NLCD pixels, so they are not routed to NLCD.
- Otherwise, for years with a complete Dynamic World year (2016 up to the previous calendar year), use Dynamic World; later years use the last complete year.
- Otherwise (before 2016) use C3S (nearest year in 2000-2022).
The GEE catalogue notes that Dynamic World crop probabilities can be comparatively low in the absence of obvious distinguishing features and on high-return surfaces in arid climates, so the default threshold may remove real fields in arid regions.
Output columns: lulc:crop_fraction (float; NaN when the dataset has no
valid pixel under the polygon, never 0), lulc:dataset, lulc:year
(the dataset year used) and lulc:valid (bool). Polygons with NaN are kept
(lulc_nodata_policy="keep") or dropped ("drop").
Modes (config.lulc_mode):
"server": per-polygon means withee.Image.reduceRegions(Earth Engine's pixel-area-weighted mean at the dataset scale), in batches ofconfig.lulc_batch_sizepolygons, retried on rate limits."raster": :func:prefetch_lulc_rasterdownloads a float32 crop raster (0/1 mask, or probability for Dynamic World; NaN nodata; 30 m, 10 m for Dynamic World) during the composite stage; the filter then averages the pixels whose centres fall inside each polygon locally (:func:zonal_mean_from_raster), so the delineation stage can run offline.
filter_by_lulc ¶
Drop polygons whose crop value is below config.lulc_crop_threshold.
See the module docstring for datasets, routing, modes and columns.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gdf
|
GeoDataFrame
|
Field polygons (any CRS; the output keeps it). |
required |
config
|
AgriboundConfig
|
Pipeline configuration ( |
required |
Returns:
| Type | Description |
|---|---|
GeoDataFrame
|
Kept polygons with |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the LULC values cannot be computed (Earth Engine or download failure, missing cached raster offline, ...). |
Source code in agribound/postprocess/lulc_filter.py
947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 | |
prefetch_lulc_raster ¶
Download the LULC crop raster for the study area (lulc_mode="raster").
Writes a single-band float32 GeoTIFF (band crop: 1/0 crop mask, or the
Dynamic World crop probability; NaN where the dataset has no data) covering
the study-area bounding box plus a 3-pixel margin, at 30 m (10 m for
Dynamic World), in config.export_crs, to
cache_path(config, "lulc_<dataset>", ".tif", ...). An existing file is
reused.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
AgriboundConfig
|
Pipeline configuration. |
required |
Returns:
| Type | Description |
|---|---|
str or None
|
Path to the raster, or None when |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the dataset cannot be selected or downloaded. |
Source code in agribound/postprocess/lulc_filter.py
select_lulc_dataset ¶
select_lulc_dataset(config: AgriboundConfig, aoi_gdf: GeoDataFrame | None = None) -> tuple[str, int]
Select the LULC dataset and year for config.
Applies config.lulc_dataset directly, or for "auto" the routing
rules in the module docstring to the study area (else the local raster's
footprint, else the envelope of aoi_gdf). The NLCD coverage test is an
Earth Engine request; it is skipped for areas that do not intersect the
conterminous-US envelope. The decision is cached next to the other
intermediates (lulc_selection_<key>.json), so later calls with the
same configuration (e.g. on an offline node) do not query Earth Engine.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
AgriboundConfig
|
Pipeline configuration ( |
required |
aoi_gdf
|
GeoDataFrame or None
|
Polygons used as the area when there is neither a study area nor a local raster. |
None
|
Returns:
| Type | Description |
|---|---|
tuple[str, int]
|
|