Detecting Self-Intersecting OSM Polygons with Shapely Jump to heading
Scan a batch of reconstructed OSM area geometries and flag every self-intersecting one — the bowtie building, the figure-eight lake — recording where each ring crosses itself, without altering a single coordinate.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A self-intersection is a polygon whose boundary crosses itself, so the ring no longer separates a coherent inside from an outside. Two shapes dominate in OSM. The bowtie is a four-node quad whose two diagonal node pairs were traced in the wrong order, pinching the ring at a central crossing into two opposed triangles. The figure-eight is a longer outline that loops back through one of its own earlier edges. Both arise from ordinary mapping mistakes — a vertex dragged past its neighbour, an import that concatenated two rings — and both are invisible in the tags, so they surface only when a geometry engine tries to compute area, run a contains test, or union the feature into a coverage.
Shapely, following the OGC Simple Features model, exposes this through is_valid: a self-intersecting polygon returns False. The companion function shapely.validation.explain_validity returns a human-readable string naming the defect and, for a self-intersection, the coordinate where the boundary crosses — for example Self-intersection[13.402 52.518]. That coordinate is the payload worth capturing, because it points a reviewer straight at the offending vertex in an editor. This page is deliberately a detection step: it classifies and reports but never mutates, because deciding how to repair a bowtie — covered in the parent Geometry Validation & Repair guide — is a separate decision that should follow, not accompany, discovery.
Runnable solution Jump to heading
The module below iterates a batch, tests validity, classifies invalid geometries, extracts the crossing coordinate from the explain_validity string, and writes a CSV triage report. It is read-only by design — nothing here calls buffer(0) or make_valid; the closest it comes to repair is a commented note on the side effects of doing so.
from __future__ import annotations
import csv
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from shapely.geometry.base import BaseGeometry
from shapely.validation import explain_validity
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.geometry.detect_self_intersection")
# explain_validity returns e.g. "Self-intersection[13.402 52.518]".
_REASON_RE = re.compile(r"^([A-Za-z ]+?)\s*(?:\[([-\d.]+)\s+([-\d.]+)\])?$")
@dataclass(frozen=True)
class Flag:
index: int
feature_id: object
defect: str
at_x: float | None
at_y: float | None
reason: str
def classify(geom: BaseGeometry) -> tuple[str, float | None, float | None, str]:
"""Return (defect_class, x, y, raw_reason) for an invalid geometry."""
reason = explain_validity(geom)
match = _REASON_RE.match(reason.strip())
label = match.group(1).strip().lower() if match else reason.lower()
x = float(match.group(2)) if match and match.group(2) else None
y = float(match.group(3)) if match and match.group(3) else None
if "ring self-intersection" in label:
defect = "ring_self_intersection"
elif "self-intersection" in label:
defect = "self_intersection"
elif "too few points" in label:
defect = "degenerate"
else:
defect = "other"
return defect, x, y, reason
def scan_batch(
geoms: list[BaseGeometry],
feature_ids: list[object] | None = None,
) -> list[Flag]:
"""Flag every self-intersecting (or otherwise invalid) geometry in a batch.
The source geometries are never modified. A self-intersection surfaces as
``is_valid`` being False with an ``explain_validity`` reason beginning
"Self-intersection" or "Ring Self-intersection".
"""
ids = feature_ids if feature_ids is not None else list(range(len(geoms)))
flags: list[Flag] = []
for i, (geom, fid) in enumerate(zip(geoms, ids)):
if geom is None or geom.is_empty:
flags.append(Flag(i, fid, "empty", None, None, "empty or null geometry"))
continue
if geom.is_valid:
continue # valid geometries need no report row
defect, x, y, reason = classify(geom)
flags.append(Flag(i, fid, defect, x, y, reason))
logger.warning("feature %s invalid: %s", fid, reason)
return flags
def write_report(flags: list[Flag], dst: Path) -> None:
"""Write the flagged geometries to a CSV triage report."""
with dst.open("w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
writer.writerow(["index", "feature_id", "defect", "at_x", "at_y", "reason"])
for f in flags:
writer.writerow([f.index, f.feature_id, f.defect, f.at_x, f.at_y, f.reason])
logger.info("wrote %d flagged features to %s", len(flags), dst)
# Note on repair side effects (intentionally NOT applied here):
# geom.buffer(0) will "fix" a bowtie by splitting it into two triangles, but
# it also silently changes area and can drop a lobe on complex rings. Detect
# first, review the report, then repair deliberately in a separate pass.
if __name__ == "__main__":
from shapely.geometry import Polygon
batch = [
Polygon([(0, 0), (2, 2), (2, 0), (0, 2), (0, 0)]), # bowtie
Polygon([(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)]), # valid unit square
]
result = scan_batch(batch, feature_ids=["way/101", "way/102"])
write_report(result, Path("self_intersection_report.csv"))
Step-by-step walkthrough Jump to heading
- Read-only contract —
scan_batchtakes geometries and returnsFlagrecords; it never assigns back to the input list. Detection and repair are separated so a review can happen between them, which is the whole point of a triage pass. - Skip the valid fast —
geom.is_validshort-circuits before the more expensiveexplain_validitycall, so a batch that is mostly clean spends almost no time in the classifier. - Guard empties — a
Noneor empty geometry is flagged asemptyrather than crashingexplain_validity; an empty polygon is technically valid but is almost never intended. - Parse the reason string —
_REASON_REsplitsexplain_validityoutput into a label and an optional[x y]coordinate. The regex tolerates reasons with no coordinate (likeToo few points) by making the bracket group optional. - Distinguish ring from shell self-intersection — GEOS reports
Ring Self-intersectionwhen a single ring crosses itself andSelf-intersectionwhen two rings of a polygon cross; the classifier keeps them separate because they hint at different mapping errors. - Capture the crossing coordinate — the
[x y]pair is the reviewer’s shortcut to the offending vertex; storing it in the report turns “this polygon is broken” into “this polygon is broken here”. - Emit a stable CSV — the report is deterministic and keyed on the feature id, so two runs over the same batch produce byte-identical output and the file diffs cleanly in review.
- Leave repair to a later pass — the trailing comment documents exactly why
buffer(0)is not called here: it mutates area and can drop geometry, so it belongs in a deliberate repair step, not a scan.
Verification Jump to heading
Confirm the detector behaves before trusting its report:
- Known bowtie flags. The example
Polygon([(0,0),(2,2),(2,0),(0,2),(0,0)])must appear in the report with defectself_intersectionorring_self_intersectionand a crossing coordinate near(1, 1). - Valid square is silent. The unit square must produce no report row; if it does,
is_validis being bypassed. - Row count matches log lines. The number of
feature ... invalidwarnings must equal the report row count minus anyemptyrows. - Coordinate parses. For any self-intersection row,
at_xandat_ymust be non-null floats; a null there means theexplain_validityformat changed and_REASON_REneeds updating. - Source is unchanged. Re-run
scan_batchon the same list twice — identical output proves nothing was mutated between passes.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
AttributeError: 'NoneType' object has no attribute 'is_valid' |
A None slipped into the batch |
The geom is None guard handles it; ensure reconstruction yields None, not a bare exception |
Every row has null at_x |
GEOS version formats the reason without coordinates | Update _REASON_RE, or fall back to shapely.validation.make_valid diffing to locate the crossing |
| Valid polygons appear in the report | is_valid check removed or inverted |
Restore the if geom.is_valid: continue fast path |
TopologyException during scan |
Calling a predicate like area on the invalid geom |
Only call is_valid and explain_validity on unvalidated input; both are safe on invalid geometry |
| Bowtie not flagged | Constructed as a LineString, not a Polygon |
Self-crossing is valid for lines; build a Polygon (or check is_simple for lines) |
| Report differs between runs | Iterating an unordered set of geometries | Feed an ordered list and pass explicit feature_ids |
Specification reference Jump to heading
Shapely’s
object.is_validreturnsTrueif a geometry is valid in the OGC Simple Features sense, andshapely.validation.explain_validity(geometry)returns a string explaining the validity or invalidity, including the location of the problem for a self-intersection. See the Shapely documentation on predicates and validation for the exact semantics ofis_valid,is_simple, andexplain_validity, and the OGC Simple Features specification for the underlying ring-validity rules a self-intersection breaks.
Frequently Asked Questions Jump to heading
Why not just call buffer(0) and skip detection?
Because buffer(0) mutates silently. On a simple bowtie it produces the two triangles you expect, but on a complex ring it can drop a lobe, merge touching parts, or return an empty geometry, all without telling you. Detecting first gives you a report to review, so the later repair is a deliberate decision per feature rather than a blanket transform that may quietly corrupt data.
What is the difference between "Self-intersection" and "Ring Self-intersection"?
GEOS reports “Ring Self-intersection” when a single ring crosses itself, such as a figure-eight outline, and “Self-intersection” when two separate rings of a polygon cross, such as an inner ring poking through its outer. Both make the polygon invalid, but they hint at different mapping errors, so the classifier keeps them as distinct defect classes in the report.
Can a self-intersecting shape ever be valid?
As a Polygon, no — self-intersection violates the OGC ring rules and is_valid returns False. As a LineString the same coordinates are simply non-simple, and is_simple returns False while the geometry is still a legal line. So whether self-crossing is an error depends entirely on whether the feature is meant to bound an area or trace a path.
How do I find which vertex causes the crossing?
The explain_validity string embeds the coordinate of the self-intersection in brackets, for example Self-intersection[13.402 52.518]. Parse that pair out and store it in the report; it points a reviewer straight at the location in a JOSM or iD editor. If your GEOS build omits the coordinate, upgrade Shapely or locate the crossing by testing consecutive edge pairs for intersection.
Related Jump to heading
- Geometry Validation & Repair — the parent guide covering the full defect taxonomy and the repair decisions that follow detection.
- Repairing Unclosed Ways and Broken Multipolygons — the sibling walkthrough for closing rings and fixing relation assembly.
- Understanding OSM Multipolygon Relations for GIS — how the polygons you are scanning were assembled from members.
- Error Handling in Large OSM Extracts — where flagged features go when detection routes them to quarantine.
- OSM Data Quality & Validation — the broader quality section this detection step belongs to.
Up one level: Geometry Validation & Repair.