Measuring Spatial IoU for LLM-Generated Geometries

Build an intersection-over-union scorer that repairs invalid model output, measures area in an equal-area projection, and returns a defined value on every degenerate case.

Intersection-over-union is the workhorse metric for asking whether a model put a shape in the right place, but model-generated geometry breaks the naive implementation: it self-intersects, arrives in the wrong projection, and occasionally has no overlap at all. This guide builds a robust IoU function that repairs invalid input, measures area in honest units, and returns a defined value on every degenerate case — the scoring primitive the rest of evaluation and benchmarking for spatial LLMs is built on. It belongs at the scoring stage of the evaluation harness, after generation and before any threshold is applied.

When to Use This Approach

Use spatial IoU whenever the ground truth is a footprint and you care both how well the prediction covers it and how much it over-claims beyond it: extracted parcels, delineated hazard zones, service areas, building outlines. The metric is

IoU=ABAB\mathrm{IoU} = \frac{\lvert A \cap B \rvert}{\lvert A \cup B \rvert}

for predicted footprint AA and truth footprint BB, ranging from 0.0 (disjoint) to 1.0 (identical). Its virtue is that the denominator grows when the model draws too much, so a prediction that swallows the whole city cannot score well by containing the right building. Reach for a different metric when the truth is a point or a line, where both areas collapse to zero and a distance measure carries the information instead.

How the IoU ratio is assembled from two footprintsThe union is every panel of the bar — truth-only area, the shared intersection, and predicted-only over-claim. The numerator is only the middle panel, so over-claiming on either side lowers the score.Union is every panel; the numerator is only the middle oneTruth onlyIntersectionOver-claim160 units200 units160 unitsIoU = 200 / 520 = 0.38A prediction that covers the truth but spills past it is penalised twice over
Why the denominator matters. Recall alone would score this prediction 200/360 = 0.56. IoU charges for the 160 units of over-claim as well, which is exactly the behaviour you want from a model that likes to round footprints outward.
Truth geometry Metric Reason
Polygon / multipolygon Spatial IoU Overlap and over-claim both matter
Point Distance error Areas are zero; IoU is undefined
Linestring Buffered IoU or Hausdorff Compare corridors, not raw overlap
Very small slivers Area-weighted IoU Guards against precision noise dominating
Ranked candidate set Mean IoU at rank k One score per candidate hides ordering quality

IoU is also the wrong tool when the task is classification dressed up as geometry. If the model is choosing among a fixed set of pre-existing polygons — census tracts, administrative units, delivery zones — then the answer is a label, and a confusion matrix over those labels tells you more than an area ratio ever will. Reserve IoU for the case where the model actually drew the boundary.

Implementation

The function projects both geometries to a caller-supplied equal-area projection, repairs invalidity with make_valid, and treats an empty union as a clean 0.0 rather than a division error. It never raises on data quality; a geometry it cannot use scores zero and is logged. That distinction — programmer error raises, data error scores — is what lets the harness run a thousand cases unattended without a single bad prediction aborting the sweep.

import logging
from typing import Optional

from shapely.geometry.base import BaseGeometry
from shapely.validation import make_valid
from shapely.ops import transform
from shapely.errors import GEOSException
from pyproj import Transformer, CRS

logger = logging.getLogger("spatial_iou")


class IoUInputError(ValueError):
    """Raised only for programmer error, never for bad geometry data."""


def _to_equal_area(geom: BaseGeometry, src_epsg: int, area_epsg: int) -> Optional[BaseGeometry]:
    """Reproject into an equal-area projection so that .area is in real square metres."""
    try:
        if src_epsg == area_epsg:
            return geom
        transformer = Transformer.from_crs(
            CRS.from_epsg(src_epsg), CRS.from_epsg(area_epsg), always_xy=True
        )
        return transform(transformer.transform, geom)
    except Exception as exc:                        # malformed CRS, pyproj failure
        logger.warning("reprojection %s->%s failed: %s", src_epsg, area_epsg, exc)
        return None


def _validate(geom: BaseGeometry) -> Optional[BaseGeometry]:
    """Repair an invalid geometry; return None if it is unusable."""
    if geom is None or geom.is_empty:
        return None
    if geom.is_valid:
        return geom
    try:
        fixed = make_valid(geom)              # deterministic repair
        return fixed if not fixed.is_empty else None
    except GEOSException as exc:
        logger.warning("make_valid guard failed: %s", exc)
        return None


def spatial_iou(
    predicted: BaseGeometry,
    truth: BaseGeometry,
    src_epsg: int = 4326,
    area_epsg: int = 6933,          # World Equal-Area Cylindrical (metres)
) -> float:
    """Return IoU in [0, 1]. Never crashes; returns 0.0 on any degenerate case."""
    if not isinstance(src_epsg, int) or not isinstance(area_epsg, int):
        raise IoUInputError("EPSG codes must be integers")

    pred = _validate(predicted)
    tru = _validate(truth)
    if pred is None or tru is None:
        return 0.0

    pred = _to_equal_area(pred, src_epsg, area_epsg)
    tru = _to_equal_area(tru, src_epsg, area_epsg)
    if pred is None or tru is None:
        return 0.0

    try:
        intersection_area = pred.intersection(tru).area
        union_area = pred.union(tru).area
    except GEOSException as exc:
        logger.warning("overlay failed, scoring as no-overlap: %s", exc)
        return 0.0

    # Fallback: an empty union means both footprints vanished under repair;
    # define IoU as 0.0 rather than dividing by zero.
    if union_area <= 0.0:
        return 0.0

    iou = intersection_area / union_area
    return max(0.0, min(1.0, iou))     # clamp against floating-point overshoot

Four things are load-bearing in that order of operations. Repair runs first, because an invalid ring makes every subsequent area meaningless rather than merely wrong. Reprojection runs second, because repairing after a projection change can move vertices that the repair had just reconciled. The overlay is wrapped, because GEOS raises on inputs that survived make_valid but still confound the noding step. And the clamp runs last, because the ratio of two floating-point areas is not guaranteed to land inside the closed unit interval even when the geometry is perfect.

The four scoring stages and the shared failure laneRepair, reproject, overlay and clamp run in sequence. Any stage that fails drops into a single deterministic lane that logs the cause and returns a score of zero, so no bad prediction can abort an evaluation sweep.1 · Repair2 · Reproject3 · Overlay4 · Clampmake_validequal-area unitsareas of both setsscore into [0, 1]Deterministic failure laneunrepairable ring · projection error · empty union — log the cause, return 0.0, never raise
One exit for every failure. Each stage drops into the same lane rather than raising its own exception type, so the harness records a comparable zero for a torn polygon, a bad EPSG code and a collapsed union alike.

Validation & Testing

from shapely.geometry import box, Polygon


def test_identical_footprints_score_one():
    a = box(0, 0, 10, 10)
    assert abs(spatial_iou(a, a, src_epsg=6933, area_epsg=6933) - 1.0) < 1e-9


def test_disjoint_footprints_score_zero():
    a, b = box(0, 0, 1, 1), box(50, 50, 51, 51)
    assert spatial_iou(a, b, src_epsg=6933, area_epsg=6933) == 0.0


def test_self_intersecting_prediction_is_repaired_not_crashed():
    bowtie = Polygon([(0, 0), (2, 2), (2, 0), (0, 2), (0, 0)])  # invalid
    truth = box(0, 0, 2, 2)
    iou = spatial_iou(bowtie, truth, src_epsg=6933, area_epsg=6933)
    assert 0.0 <= iou <= 1.0            # defined, in range, no exception


def test_over_claim_is_penalised_more_than_partial_cover():
    truth = box(0, 0, 10, 10)
    spilled = box(0, 0, 20, 20)         # covers everything, claims four times the area
    clipped = box(0, 0, 10, 5)          # covers half, claims nothing extra
    assert spatial_iou(spilled, truth, 6933, 6933) < spatial_iou(clipped, truth, 6933, 6933)

Run these in CI with pinned GEOS and PROJ versions so the equal-area reprojection and repair produce identical scores across environments. A version bump that changes make_valid’s output on a bow-tie will move scores by a few percent, and without pinning that drift is indistinguishable from a regression in the model itself. Record both library versions alongside every score you publish, the same way you would record the model version.

The fourth test is the one worth defending in review. It encodes the property the metric exists for, not an arithmetic identity, and it fails loudly if somebody “optimises” the implementation into a recall calculation.

Gotchas & Edge Cases

Area measured in degrees. Calling .area on EPSG:4326 geometry returns square degrees, which vary with latitude and make IoU incomparable across regions. The _to_equal_area step exists precisely to avoid this; never skip it because the inputs “look small”. A test suite drawn entirely from one city will not catch the omission, because within one city the distortion is a constant factor that cancels in the ratio.

Empty union from over-aggressive repair. A degenerate zero-width sliver can survive parsing but collapse to empty under make_valid, producing a zero union. The union_area <= 0.0 guard returns 0.0 instead of raising ZeroDivisionError.

Floating-point overshoot above 1.0. Overlay arithmetic can yield an intersection marginally larger than the union at the last decimal. The final min(1.0, ...) clamp keeps the score in range so downstream thresholds behave.

MultiPolygon truth with holes. IoU handles multipart and holed geometry correctly only if both sides are valid; an unrepaired hole becomes a phantom overlap. The _validate gate must run before any area is taken.

Averaging scores across wildly different footprint sizes. A mean IoU over a set containing both a bus shelter and a national park is dominated by whichever cases happen to be easy at that scale. Report the distribution — median plus the tenth percentile — or weight by truth area deliberately, and say which you did.

Release bands for an IoU scoreFive score bands running from reject through manual review, borderline and pass to near-exact, each mapped to the action an evaluation harness should take when a case lands in it.Bands are per task — publish them next to the model version0.00–0.300.30–0.500.50–0.700.70–0.900.90–1.00rejectreviewborderlinepassnear-exactA single mean hides the reject tail — gate on a percentile, not an averageSuggested gate: tenth percentile above the review band on every release
Turning a score into a decision. The bands are a policy, not a property of the metric: a parcel-extraction task and a wildfire-perimeter task will draw them in different places, and both should state where.

Frequently Asked Questions

Which equal-area projection should I pass as area_epsg?

EPSG:6933 is a reasonable global default because it is equal-area everywhere and needs no per-case selection. For work confined to one country, a national equal-area projection gives less shape distortion at the same area fidelity. What matters is that the same code is used for every case in a comparison set — mixing projections across a benchmark makes the scores incomparable even though each one is individually correct.

Should a prediction that fails to parse score zero, or be excluded?

Score it zero and count it. Excluding unparseable output flatters the model exactly where it is weakest, and two systems with identical mean IoU over their parseable cases can have wildly different failure rates. If you need the two numbers separated, publish the parse rate alongside the mean rather than quietly filtering.

Why clamp rather than assert the score is in range?

The overshoot is a floating-point artefact of taking the ratio of two independently computed areas, not a signal about the geometry. Asserting would abort an otherwise valid sweep over a discrepancy in the fifteenth decimal place. Clamping records the score the geometry deserves; if you want visibility, log when the raw ratio exceeds 1.0 by more than a small epsilon.

How does IoU interact with coordinate hallucination?

Badly, and that is the point of pairing them. A hallucinated coordinate usually lands the footprint in the wrong hemisphere, where IoU is exactly zero and tells you nothing about how wrong the answer was. Run a plausibility screen first — see detecting hallucinated coordinates in model output — and report the two failure classes separately.