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.

What boundary IoU actually compares Starting from two overlapping footprints, each polygon's boundary is buffered by the band width to give a ring. The interiors, where both annotators trivially agree, are discarded. Only the two rings enter the intersection over union, so the score reflects how closely the outlines were placed. two footprints the interiors agree almost completely buffer each boundary band width = 3 × GSD, in metres divide the rings ring ∩ ring ring ∪ ring a score that does not move when the object gets bigger the discarded interior is exactly the part of the polygon nobody had to make a decision about

Step-by-Step Implementation

Step 1 — Install and Fix the Working CRS

bash
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.

python
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.

Below this size the band swallows the object Three objects measured with a 0.9 metre band on 30 centimetre imagery. A 900 square metre warehouse is thirty metres across, so the band is a thin edge and the score discriminates well. A 40 square metre shed is six metres across and the band is already a third of it. A 4 square metre kiosk is two metres across, so the two buffered rings overlap almost completely whatever the annotator did, and every pair scores near one. 900 m² · 30 m across band / width = 0.03 the score discriminates 40 m² · 6 m across band / width = 0.15 still usable, less sensitive 4 m² · 2 m across band / width = 0.45 every pair scores near 1.0 skip when band_m ≥ sqrt(area) / 2 — and report the skip
python
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

python
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

python
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.

The gap between the two scores is the review queue Six features plotted with plain IoU on the horizontal axis and boundary IoU on the vertical. Features near the diagonal are consistent under both metrics. The lower right region — high plain IoU, low boundary IoU — holds large objects whose area hides a bad outline, and those are the ones that should be reviewed. 0.5 0.75 1.0 plain IoU 0.0 0.5 1.0 boundary IoU review these area is covering for the outline shed, 38 m² house, 140 m² house, 210 m² school, 1 100 m² warehouse, 900 m² depot, 1 400 m² the two metrics agree along here

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.

This metric is one part of the broader Annotation Quality Metrics & Inter-Annotator Agreement topic within Geospatial Annotation Fundamentals & Architecture.