Fixing Invalid Polygons with Shapely
This page covers one technique from Geometry Cleanup & Topology Repair: turning an invalid room polygon into a valid one without silently changing what the room is. The distinction matters because shapely’s repair function always succeeds, and roughly two-thirds of the time what it returns is a diagnosis rather than a fix.
What OGC Validity Actually Requires
A polygon is valid under the OGC Simple Features rules when its rings are closed and simple (no self-intersection), its interior rings lie inside the exterior ring and do not overlap one another, and its interior is connected. Nothing in that definition mentions size, shape or plausibility — a 0.2 m² triangle in a stairwell is perfectly valid, and so is a single polygon covering the whole floor because a partition had a gap in it.
That is the first thing to internalise: validity is necessary and nowhere near sufficient. It
is a precondition for spatial operations working at all, which is why it matters — an invalid
polygon makes intersects, contains and buffer return wrong answers or raise
TopologyException unpredictably, often several stages downstream from where the geometry was
created.
The GEOS messages in the first column are worth learning to read, because they name the cause
precisely. Self-intersection means two edges cross. Ring Self-intersection means one ring
crosses itself — the bow-tie case. Hole lies outside shell means an interior ring escaped its
exterior, which in floor plans almost always means a courtyard polygon was assigned to the wrong
room. Too few points in geometry component means a ring has fewer than four coordinates and was
never a polygon at all.
Minimal Working Example
The complete repair is three lines. What makes it correct is the two checks around it.
import logging
from shapely.geometry import Polygon
from shapely.validation import explain_validity, make_valid
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def repair_room(poly: Polygon, *, area_tol: float = 0.01) -> Polygon | None:
"""Return a repaired room polygon, or None when the input is not repairable.
A `None` return is information, not an error: it means the source geometry was
wrong in a way no repair can resolve, and the caller must report it.
"""
if poly.is_valid:
return poly
reason = explain_validity(poly)
before = poly.area
try:
fixed = make_valid(poly)
except Exception as exc: # GEOSException on degenerate rings
logger.error("make_valid failed (%s): %s", reason, exc)
return None
if fixed.is_empty or fixed.geom_type != "Polygon":
# A MultiPolygon means the ring genuinely self-intersected: the repair split
# it into lobes. Choosing one would silently discard the rest of the room.
logger.warning("unrepairable (%s): %s -> %s", reason, poly.geom_type, fixed.geom_type)
return None
drift = abs(fixed.area - before) / max(before, 1e-9)
if drift > area_tol:
logger.warning("repaired but area moved %.1f%% (%s)", drift * 100, reason)
return fixed
bowtie = Polygon([(0, 0), (9, 0), (0, 7), (9, 7)])
print(repair_room(bowtie)) # -> None, with a warning naming the ring self-intersection
The bow-tie is the case that catches people. make_valid returns a MultiPolygon of two
triangles whose combined area equals the original’s — nothing was lost — but the geometry is now
two rooms where the drawing showed one, and neither triangle is a room. The correct response is to
report the source entity so the drawing can be fixed, not to pick a lobe.
Repair Decision Reference
| Returned type | Area check | Action | Reported as |
|---|---|---|---|
Polygon |
within 1% | accept | — |
Polygon |
moved > 1% | accept, log | repaired_with_drift |
MultiPolygon |
any | reject the face | self_intersecting |
GeometryCollection |
any | reject the face | mixed_repair |
| empty | any | reject the face | degenerate |
| exception raised | — | reject the level | repair_failed |
The last row is deliberately harsher than the others. make_valid raising is rare and indicates
something structurally broken — usually a ring with NaN coordinates, which comes from a unit
conversion that divided by zero. One such geometry in a level means the level’s coordinates cannot
be trusted, so failing the whole level is proportionate.
The area tolerance of 1% is generous on purpose. Genuine repairs — deduplicating a repeated ring, merging nested holes — change area by nothing or by rounding. A repair that moves area by more than a percent has changed the shape, and while the result may still be usable, it is worth a line in the report so that a portfolio-wide drift shows up as a trend rather than as a surprise.
Common Errors & Fixes
AttributeError: 'MultiPolygon' object has no attribute 'exterior'. The classic symptom of
using make_valid’s output without checking its type. Some later stage — usually routing-graph
construction, which walks room exteriors to find doors — receives a MultiPolygon where it
expected a Polygon. The fix is not to add a type check at the crash site but to stop the
MultiPolygon being produced upstream, by rejecting the face at repair time as shown above.
Total floor area silently shrinks between builds. Repairs that return a valid Polygon with
different area accumulate. One room losing 3% is invisible; forty rooms losing 3% each is a floor
that no longer matches its lease documents. Log the drift per repair and gate on the level’s total
area against the previously published value — the delta gate described in the
cleanup topic
catches exactly this.
buffer(0) used as the repair. It was the standard trick before make_valid existed, and it
still appears in a lot of code. It works by round-tripping the geometry through a zero-width buffer,
which resolves self-intersections by dropping the smaller lobe rather than returning both. That
makes it strictly worse than make_valid for this purpose: it produces a plausible single polygon
and gives you no signal that anything was discarded. If you find buffer(0) in a cleanup path,
replacing it with make_valid plus a type check will usually surface defects that were being
swallowed for years.
# Do not do this: the smaller lobe of a bow-tie simply vanishes.
fixed = poly.buffer(0)
# Do this: the split is visible, and the caller decides.
fixed = make_valid(poly)
if fixed.geom_type != "Polygon":
report(entity_handle, "self_intersecting")
Integration Point
Repair sits inside the classification pass of the cleanup pipeline, between polygonisation and the
plausibility filters. Its input is the raw faces shapely.ops.polygonize produced from the noded
segment graph; its output is the subset that both validates and survives the area and width floors
described in Geometry Cleanup & Topology Repair.
Downstream, everything assumes validity. Wall & door detection
measures perpendicular distances between faces and needs distance to be meaningful;
routing-graph construction
walks room exteriors to place door nodes and will raise on a MultiPolygon; and the
schema validation
at publish time rejects a FeatureCollection containing an invalid geometry outright. Repairing
here, once, with a report, is what keeps all three of those stages simple.
Frequently Asked Questions
Is `make_valid` always better than `buffer(0)`?
For diagnosing floor-plan geometry, yes, and the reason is not accuracy but honesty. Both produce a valid result from an invalid input; buffer(0) resolves a self-intersection by keeping the larger lobe and discarding the rest, while make_valid returns everything and lets the type of the result tell you what happened. Since the whole point of the repair stage is to distinguish geometry that was nearly right from geometry that was wrong, a function that silently discards evidence is the wrong tool. The one place buffer(0) still has a use is deliberately dissolving a known-messy geometry where you have already decided the discard is acceptable.
Should invalid geometry fail the build?
The individual face should be rejected and counted; the build should fail on the aggregate. A handful of unrepairable faces per level is normal in a large portfolio and failing the build on each one means nothing ever publishes. What should fail the build is a level whose count of rejected faces jumps relative to its last published version, because that indicates something changed upstream — a new export setting, a different CAD version, a parser regression. Absolute thresholds age badly; deltas against the previous good build do not.
Why does the same polygon validate in QGIS but not in shapely?
Because they may be applying different validity models. GEOS — which shapely wraps — implements the strict OGC model, in which a polygon whose interior is pinched to a single point is invalid. Some tools apply the looser ESRI model, which permits it. Neither is wrong, but the strict model is the one that matters here, because it is what every downstream spatial predicate in the pipeline assumes. If a geometry validates in a desktop GIS and not in the pipeline, trust the pipeline: the operations that will run on it later are GEOS operations.
Related
- Geometry Cleanup & Topology Repair — the pipeline this repair step sits inside, and the tolerances around it.
- Detecting and Merging Duplicate Wall Segments — the other defect class that produces valid-but-wrong geometry.
- Extracting Room Boundaries from SVG Floor Plans — where the invalid rings this page repairs usually originate.
This page is a companion to Geometry Cleanup & Topology Repair, part of the Automated Floor Plan Parsing & Vectorization section.