Repairing Self-Intersecting Polygons Without Losing Area
Prefer make_valid, which splits a bowtie into both lobes, over buffer(0), which typically returns one and discards the other — then measure the relative area change on every repair and reject the ones that exceed a tolerance, because both functions return a valid geometry and neither tells you what it threw away.
This guide sits under Geometry Validation Pipelines, within Spatial Payload Routing & Parsing. It covers the repair step that topic’s pipeline invokes once a geometry has been found invalid.
When to use this pattern
- Producers emit invalid geometry occasionally, which for anything hand-digitised or machine-simplified they do.
- Downstream operations need validity — spatial joins, area calculations, tile generation all misbehave on invalid input.
- Silently changing a feature’s extent is unacceptable, which for anything measured it is.
The two repairs disagree, and only one says so
buffer(0) because it is shorter to type is a decision about half the geometry, made without knowing it was a decision.Complete runnable implementation
from dataclasses import dataclass
from shapely.geometry import GeometryCollection, MultiPolygon, mapping, shape
from shapely.ops import unary_union
from shapely.validation import explain_validity, make_valid
# Relative, because one square metre is nothing on a country and most of a
# parking bay. The absolute floor stops a near-zero-area sliver dividing by
# something close to nothing.
MAX_RELATIVE_AREA_LOSS = 0.001 # 0.1%
ABSOLUTE_AREA_FLOOR = 1e-14 # square degrees
class UnrepairableGeometry(Exception):
"""The repair changed the feature more than a repair is allowed to."""
def __init__(self, reason: str, relative_loss: float) -> None:
super().__init__(f"{reason} (relative area change {relative_loss:.4%})")
self.relative_loss = relative_loss
@dataclass(frozen=True, slots=True)
class Repair:
geometry: dict
was_valid: bool
reason: str | None
relative_area_change: float
def _polygonal(geom):
"""make_valid can return a GeometryCollection with stray lines in it.
Keeping those produces a geometry whose area is right and whose type
breaks the next operation that assumes polygons.
"""
if isinstance(geom, GeometryCollection):
parts = [g for g in geom.geoms if g.geom_type in ("Polygon", "MultiPolygon")]
return unary_union(parts) if parts else geom
return geom
def repair(geometry: dict,
max_loss: float = MAX_RELATIVE_AREA_LOSS) -> Repair:
"""Repair an invalid polygon, refusing repairs that change its extent.
A repair is an assertion about what the producer meant. Making it
silently means nobody ever fixes the producer.
"""
original = shape(geometry)
if original.is_valid:
return Repair(geometry, True, None, 0.0)
reason = explain_validity(original)
# make_valid, NOT buffer(0): the latter resolves a bowtie by even-odd
# interpretation and returns one lobe.
repaired = _polygonal(make_valid(original))
before = original.area
after = repaired.area
denominator = max(before, ABSOLUTE_AREA_FLOOR)
change = abs(after - before) / denominator
if change > max_loss:
raise UnrepairableGeometry(reason, change)
if repaired.is_empty:
raise UnrepairableGeometry(f"{reason}; repair produced an empty geometry", 1.0)
return Repair(mapping(repaired), False, reason, change)
Note that original.area on an invalid polygon is itself unreliable — a self-intersecting ring’s area is computed by the shoelace formula, which subtracts the lobe wound the other way. That is precisely why the comparison is worth making: a bowtie whose lobes are equal has an original area near zero, so almost any repair exceeds the tolerance and the geometry is rejected rather than quietly reinterpreted.
explain_validity returns a message rather than a category.Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
MAX_RELATIVE_AREA_LOSS |
float |
Proportion, not an absolute area — a country and a parking bay differ | 0.001 |
ABSOLUTE_AREA_FLOOR |
float |
Stops a near-zero-area sliver dividing by nothing | 1e-14 |
| Repair function | — | make_valid; buffer(0) discards bowtie lobes |
make_valid |
_polygonal |
call | Strips stray lines from a returned GeometryCollection | — |
explain_validity |
str |
Recorded on the event, so producers can be told what they emit | — |
| Area CRS | — | Equal-area projection when the tolerance must mean square metres | — |
Gotchas and spatial edge cases
-
make_validcan change the geometry type. A repaired polygon may come back as a MultiPolygon, or as a GeometryCollection containing polygons plus dangling lines from a degenerate spike. Code downstream that assumesPolygonbreaks on the first repaired feature, and the stray lines have zero area so the tolerance check does not notice them — hence the explicit filter. -
The area of an invalid polygon is not what it looks like. The shoelace formula subtracts area enclosed with the opposite winding, so a symmetric bowtie has an area near zero. Treating that as the “before” figure is correct here — it makes the tolerance reject the shape — but it means the reported relative change is not a physical quantity and should not be graphed as one.
-
Areas in EPSG:4326 are in square degrees. A relative tolerance is unaffected by that, because both sides of the ratio use the same units, which is another reason to express it relatively. If the tolerance ever needs to be absolute, reproject first as CRS Normalization Strategies describes.
-
Ring winding order is not a validity question. RFC 7946 specifies counter-clockwise exterior rings, but a clockwise ring is still a valid polygon to Shapely —
is_validreturns true and nothing repairs it. Winding must be normalised separately, and it matters because some consumers interpret a reversed exterior ring as a hole. -
Repair before any spatial predicate, not after.
intersects,containsandintersectionon an invalid geometry return results that are wrong rather than erroneous, so a validation stage placed after routing has already let the bad answers through. -
Count repairs by producer and alert on the rate. A repair is a message about the source system, and the only way anybody acts on it is if the rate is visible per producer — see Tracking Geometry Validation Failure Rate with Prometheus.
Verification
import pytest
from shapely.geometry import Polygon, mapping, shape
BOWTIE = mapping(Polygon([(0, 0), (10, 10), (0, 10), (10, 0), (0, 0)]))
NICKED = mapping(Polygon([(0, 0), (0, 10), (10, 10), (10, 0),
(5, 1e-9), (0, 0)])) # micrometre self-touch
def test_bowtie_is_rejected_not_halved():
"""The failure buffer(0) produces silently."""
with pytest.raises(UnrepairableGeometry):
repair(BOWTIE)
def test_buffer_zero_would_have_lost_a_lobe():
"""Documented in executable form, so nobody 'simplifies' the repair."""
both = shape(mapping(shape(BOWTIE).buffer(0)))
from shapely.validation import make_valid
kept = make_valid(shape(BOWTIE))
assert kept.area > both.area * 1.5
def test_precision_artefact_is_repaired_quietly():
result = repair(NICKED)
assert result.was_valid is False
assert result.relative_area_change < 1e-6
assert shape(result.geometry).is_valid
def test_valid_geometry_passes_through_unchanged():
square = mapping(Polygon([(0, 0), (0, 1), (1, 1), (1, 0)]))
result = repair(square)
assert result.was_valid and result.geometry == square
def test_repair_never_returns_a_geometry_collection():
"""Stray lines have zero area, so the tolerance does not catch them."""
spiked = mapping(Polygon([(0, 0), (0, 10), (5, 10), (5, 20),
(5, 10), (10, 10), (10, 0)]))
result = repair(spiked, max_loss=1.0)
assert shape(result.geometry).geom_type in ("Polygon", "MultiPolygon")
The second test asserts something about code the pipeline deliberately does not use. It is there so that the next person who replaces make_valid with the shorter buffer(0) sees the consequence in a failing test rather than in an area report six months later.
Related
- Geometry Validation Pipelines — the topic this guide belongs to, and where this repair sits in the pipeline
- Tracking Geometry Validation Failure Rate with Prometheus — making the repair rate visible per producer
- Merging Overlapping Zone Edits with Shapely — another caller of
make_valid, and why it repairs after the booleans - CRS Normalization Strategies — reprojecting before measuring, when the tolerance has to mean metres