Computing Boundary IoU for Footprint Quality
Boundary IoU compares two polygons only within a band around their outlines: buffer each boundary by a fixed distance in metres, then divide the area of the intersection of the two rings by the area of their union. Because the interior is excluded, the score reports how well the outline was traced rather than how large the object is — a 900 m² warehouse traced three metres wide of its walls scores 0.41 while a 40 m² shed traced within half a metre scores 0.79, exactly inverting the ranking that plain IoU produces. This guide gives you the function, the rule for sizing the band from ground sample distance, and the two ways the metric can lie to you.
Why Plain IoU Cannot Grade Delineation
Intersection over union is an area ratio, and for a compact object the area is dominated by the interior, which both annotators got right by construction. Trace a 30 m × 30 m building three metres wide on every wall and the union grows from 900 m² to 1 296 m² while the intersection stays 900 m² — an IoU of 0.69 for a genuinely bad trace, and better still for larger buildings. The same absolute error on a 6 m × 6 m shed drops IoU to 0.25. One error, two very different scores, and the difference is object size rather than annotator skill.
That matters in two places. Ranking features for adjudication by plain IoU sends reviewers to small objects, where the metric is harsh, and lets large sloppy footprints through. And comparing quality between projects is meaningless when one works on warehouses and the other on garden sheds, because the score encodes the size distribution as much as the labelling.
Boundary IoU removes the interior from the comparison. What remains is the neighbourhood of the outline, which is the only part of the polygon where a decision was actually made.
Step-by-Step Implementation
Step 1 — Install and Fix the Working CRS
pip install geopandas==0.14.4 shapely==2.0.6 pyproj==3.6.1 numpy==1.26.4
The band width is a distance, so both geometries must be in a projected CRS whose unit is the metre before anything is buffered. Buffering a polygon that is still in EPSG:4326 by 1.0 buffers it by one degree — roughly 111 km — and the function will happily return 1.0 for every pair, because both rings then cover the entire neighbourhood. Choosing that projection is covered in coordinate reference systems in annotation pipelines; assert it rather than assume it.
import geopandas as gpd
def to_metric(gdf: gpd.GeoDataFrame, work_crs: str) -> gpd.GeoDataFrame:
"""Reproject and refuse to continue if the target is not metre-based."""
from pyproj import CRS
crs = CRS.from_user_input(work_crs)
unit = crs.axis_info[0].unit_name
if unit not in ("metre", "meter"):
raise ValueError(f"{work_crs} has axis unit {unit!r}; boundary IoU needs metres")
if gdf.crs is None:
raise ValueError("input has no declared CRS")
out = gdf.to_crs(crs)
out["geometry"] = out.geometry.make_valid()
return out
Step 2 — Size the Band From the Sensor
The band should be the width inside which two competent annotators are indistinguishable. Empirically that is about three pixels, so the rule is three times the ground sample distance.
def band_for_gsd(gsd_m: float, pixels: float = 3.0) -> float:
"""Boundary band width in metres for a given ground sample distance."""
if gsd_m <= 0:
raise ValueError("gsd_m must be positive")
return gsd_m * pixels
| Imagery | GSD | Band (3 px) | Notes |
|---|---|---|---|
| Consumer drone, low altitude | 0.02 – 0.05 m | 0.06 – 0.15 m | Below typical coordinate precision — check the export first |
| Survey drone | 0.10 m | 0.30 m | The usual case for footprint work |
| High-resolution satellite | 0.30 m | 0.90 m | Eaves and shadow start to dominate here |
| Mid-resolution satellite | 0.50 m | 1.50 m | Small structures may be below the band entirely |
| Sentinel-2 optical | 10 m | 30 m | Footprint-scale delineation is not meaningful |
The last row is not a joke: if the band is wider than the object, every pair of annotations scores near 1.0 and the metric has stopped measuring. Check band_m < sqrt(area) / 2 and skip features that fail it rather than reporting a flattering number.
Step 3 — Compute the Score
from shapely.geometry.base import BaseGeometry
def boundary_iou(reference: BaseGeometry, candidate: BaseGeometry, band_m: float) -> float:
"""Intersection over union restricted to a band around each polygon's boundary.
Both geometries must be in a projected CRS with metre units. Returns 0.0 when
either input is empty, and 1.0 only for boundaries that coincide everywhere.
"""
if reference.is_empty or candidate.is_empty:
return 0.0
if band_m <= 0:
raise ValueError("band_m must be positive")
ring_r = reference.boundary.buffer(band_m)
ring_c = candidate.boundary.buffer(band_m)
inter = ring_r.intersection(ring_c).area
if inter == 0.0:
return 0.0
union = ring_r.area + ring_c.area - inter
return float(inter / union)
boundary on a polygon with holes returns the exterior ring plus every interior ring, which is what you want: a courtyard traced badly is a delineation failure like any other.
Step 4 — Run It Over a Batch and Keep Both Numbers
import pandas as pd
def score_batch(reference: gpd.GeoDataFrame, candidate: gpd.GeoDataFrame,
pairs: pd.DataFrame, gsd_m: float) -> pd.DataFrame:
"""Score matched pairs, reporting boundary IoU next to plain IoU."""
band = band_for_gsd(gsd_m)
rows = []
for r in pairs.itertuples():
gr = reference.loc[int(r.ia)].geometry
gc = candidate.iloc[int(r.ib)].geometry
inter = gr.intersection(gc).area
plain = inter / (gr.area + gc.area - inter) if inter else 0.0
too_small = band >= (gr.area ** 0.5) / 2
rows.append({
"ref_index": int(r.ia),
"area_m2": round(gr.area, 1),
"iou": round(plain, 3),
"boundary_iou": None if too_small else round(boundary_iou(gr, gc, band), 3),
"band_m": band,
"skipped_too_small": too_small,
})
return pd.DataFrame(rows)
Reporting both is what makes the metric actionable. The features worth a reviewer’s time are the ones where iou is high and boundary_iou is low: large objects whose area is covering for a bad outline.
Parameters and Thresholds Reference
| Parameter | Value | Effect |
|---|---|---|
band_m |
3 × GSD | Wider bands forgive more; a band above sqrt(area)/2 makes the score meaningless |
| Floor, crisp classes | 0.70 – 0.80 | Buildings, solar arrays, hard-edged infrastructure |
| Floor, fuzzy classes | 0.45 – 0.60 | Wetland margins, canopy edges, burn scars |
skipped_too_small |
band ≥ sqrt(area)/2 |
Report the skip; a silently skipped feature reads as a pass |
| Buffer resolution | shapely default (16 segments/quarter) | Lower values bias the ring area on curved boundaries |
Common Errors and Fixes
Every score comes back as 1.0
Root cause: the geometries are still in degrees, so a band_m of 1.0 buffered them by roughly 111 km and both rings cover everything.
Fix: run to_metric() first and assert the axis unit, as in Step 1.
TopologyException inside buffer
Root cause: a self-intersecting ring reached the buffer operation.
Fix: call make_valid() on both inputs before scoring — the repair belongs before the metric, not inside it.
Scores are systematically lower after switching imagery providers
Root cause: the band was hard-coded rather than derived from GSD, and the new imagery is coarser.
Fix: derive band_m with band_for_gsd() per batch and record it beside the score, so historical numbers stay interpretable.
A polygon with a courtyard scores badly despite a good outer wall
Root cause: boundary includes interior rings, and the courtyard was traced loosely.
Fix: this is correct behaviour. If your project genuinely does not care about holes, compare exterior explicitly and say so in the report.
Frequently Asked Questions
# Is boundary IoU the same as the metric used in panoptic segmentation papers?
It is the same idea applied to vectors. The published version computes the mask minus an eroded copy of itself, which is the raster equivalent of buffering the boundary inward. Doing it on vectors with an outward buffer is more natural for annotation data, which is stored as polygons, and it avoids rasterising twice at a resolution that would have to be chosen anyway.
# How expensive is it on a large batch?
Two buffer operations and one intersection per feature. On a batch of 50 000 building footprints it is a couple of minutes single-threaded, which is fine for a nightly job and too slow to run per keystroke in an annotation tool. If it needs to be interactive, simplify both geometries to the band width first — the result changes by less than the band.
# Can I use it to compare a model’s predictions against ground truth?
Yes, and it is more informative than plain IoU for footprint extraction, because it is the outline quality that determines whether a predicted footprint is usable for area calculation. Vectorise the predicted mask first, as described in automating batch pre-labeling with SAM and QGIS, and use the same band you use for human annotators so the two are comparable.
# What if the two polygons are matched wrongly in the first place?
Boundary IoU is a quality score, not an identity test. It assumes the pair is already matched; feeding it a building and the road beside it returns a low score that reads as bad tracing. Do the matching first with a low plain-IoU floor, as the parent topic sets out, and only then score.
Related
- Annotation Quality Metrics & Inter-Annotator Agreement — the matching step that has to run before this score means anything, and the class-agreement half of the picture
- Calculating IoU Thresholds for Geospatial Object Detection — why plain IoU has to be computed in metres too, and how thresholds shift with mission type
- Best Practices for Polygon vs Bounding Box Annotation — the geometry choice that decides whether boundary quality is measurable at all
This metric is one part of the broader Annotation Quality Metrics & Inter-Annotator Agreement topic within Geospatial Annotation Fundamentals & Architecture.