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.

Detecting a self-intersecting OSM polygon and reporting the crossing coordinate A bowtie polygon crosses itself at a central point; Shapely is_valid returns false and explain_validity yields the reason plus that coordinate; the feature is written to a report unchanged. Detect, locate the crossing, report — do not mutate Bowtie polygon self-intersection edges cross at centre Shapely check is_valid -> False explain_validity "Self-intersection[x y]" Flag row index · defect class crossing coordinate source geometry untouched

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.

python
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

  1. Read-only contractscan_batch takes geometries and returns Flag records; 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.
  2. Skip the valid fastgeom.is_valid short-circuits before the more expensive explain_validity call, so a batch that is mostly clean spends almost no time in the classifier.
  3. Guard empties — a None or empty geometry is flagged as empty rather than crashing explain_validity; an empty polygon is technically valid but is almost never intended.
  4. Parse the reason string_REASON_RE splits explain_validity output into a label and an optional [x y] coordinate. The regex tolerates reasons with no coordinate (like Too few points) by making the bracket group optional.
  5. Distinguish ring from shell self-intersection — GEOS reports Ring Self-intersection when a single ring crosses itself and Self-intersection when two rings of a polygon cross; the classifier keeps them separate because they hint at different mapping errors.
  6. 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”.
  7. 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.
  8. 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 defect self_intersection or ring_self_intersection and a crossing coordinate near (1, 1).
  • Valid square is silent. The unit square must produce no report row; if it does, is_valid is being bypassed.
  • Row count matches log lines. The number of feature ... invalid warnings must equal the report row count minus any empty rows.
  • Coordinate parses. For any self-intersection row, at_x and at_y must be non-null floats; a null there means the explain_validity format changed and _REASON_RE needs updating.
  • Source is unchanged. Re-run scan_batch on 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_valid returns True if a geometry is valid in the OGC Simple Features sense, and shapely.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 of is_valid, is_simple, and explain_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.

Up one level: Geometry Validation & Repair.