Handling Multipolygon Members with No Role Jump to heading

A multipolygon relation whose members have no role is not broken data — the specification says a consumer must work out containment geometrically anyway. Code that trusts the roles works on most relations and fails on exactly the ones that needed care.

Prerequisites Jump to heading

Conceptual minimum Jump to heading

The multipolygon specification is explicit that member roles are advisory. A conforming consumer assembles the member ways into closed rings and then determines, geometrically, which rings are outer and which are inner. Roles, where present, are a hint and may be wrong.

That turns assembly into three steps rather than one.

Ring the ways. Members are arbitrary fragments; consecutive ones share endpoints and must be stitched until each ring closes. A fragment that cannot be joined is a data error worth reporting rather than silently dropping.

Nest the rings. Every ring is tested for containment within every other. A ring contained by an even number of others is an outer ring; one contained by an odd number is a hole. That parity rule handles the awkward and legitimate case of an island inside a lake inside an island.

Assemble. Each outer ring becomes a polygon, with the odd-depth rings directly inside it as its holes.

Three steps from role-less members to a valid multipolygon Four steps. The stitch step joins member way fragments end to end until each forms a closed ring, reporting any fragment that cannot be joined. The nest step tests every ring for containment inside every other, producing a depth for each. The parity step assigns rings at even depth as outer boundaries and rings at odd depth as holes, which handles an island inside a lake inside an island correctly. The assemble step builds one polygon per outer ring with the odd-depth rings immediately inside it as its interior rings. Stitch, nest, parity, assemble stitch join fragments report what will not close nest containment tests a depth per ring parity even outer, odd hole handles islands in lakes assemble polygon per outer holes are its children Roles are consulted only afterwards, as a check on the geometric result rather than as the basis for it.
Deriving containment geometrically is what the specification requires, not a workaround for missing roles.

Runnable solution Jump to heading

python
from __future__ import annotations

import logging
from collections import defaultdict

from shapely.geometry import LinearRing, LineString, MultiPolygon, Polygon
from shapely.ops import linemerge

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.relations.multipolygon")


class UnclosedRing(ValueError):
    """Member ways could not be stitched into closed rings."""


def stitch_rings(ways: list[LineString]) -> list[LinearRing]:
    """Join member fragments end to end until each ring closes."""
    merged = linemerge(ways)
    parts = list(merged.geoms) if merged.geom_type == "MultiLineString" \
        else [merged]

    rings: list[LinearRing] = []
    open_parts: list[LineString] = []
    for part in parts:
        coords = list(part.coords)
        if len(coords) >= 4 and coords[0] == coords[-1]:
            rings.append(LinearRing(coords))
        else:
            open_parts.append(part)

    if open_parts:
        # An unclosed fragment is a data error worth naming, not dropping.
        raise UnclosedRing(
            f"{len(open_parts)} fragment(s) did not close; "
            f"first gap between {open_parts[0].coords[0]} and "
            f"{open_parts[0].coords[-1]}")
    return rings


def nesting_depth(rings: list[LinearRing]) -> list[int]:
    """How many other rings each ring sits inside."""
    polys = [Polygon(r) for r in rings]
    depths = []
    for i, inner in enumerate(polys):
        # A representative point is guaranteed inside; a centroid is not.
        probe = inner.representative_point()
        depth = sum(1 for j, outer in enumerate(polys)
                    if j != i and outer.contains(probe))
        depths.append(depth)
    return depths


def assemble(rings: list[LinearRing]) -> MultiPolygon:
    """Even depth is an outer ring; odd depth is a hole in its parent."""
    depths = nesting_depth(rings)
    polys = [Polygon(r) for r in rings]

    holes_of: dict[int, list[LinearRing]] = defaultdict(list)
    for i, depth in enumerate(depths):
        if depth % 2 == 0:
            continue
        # The parent is the smallest even-depth ring containing this one.
        probe = polys[i].representative_point()
        candidates = [(polys[j].area, j) for j, d in enumerate(depths)
                      if d == depth - 1 and polys[j].contains(probe)]
        if not candidates:
            logger.warning("ring %d at depth %d has no parent; treating as outer",
                           i, depth)
            continue
        holes_of[min(candidates)[1]].append(rings[i])

    built = [Polygon(rings[i], holes_of.get(i, []))
             for i, d in enumerate(depths) if d % 2 == 0]
    result = MultiPolygon([p for p in built if p.is_valid and not p.is_empty])
    logger.info("assembled %d ring(s) into %d polygon(s) with %d hole(s)",
                len(rings), len(result.geoms), sum(len(v) for v in holes_of.values()))
    return result


def check_roles(rings: list[LinearRing], roles: list[str]) -> None:
    """Compare the geometric answer against the declared roles, for reporting."""
    depths = nesting_depth(rings)
    for i, (depth, role) in enumerate(zip(depths, roles)):
        derived = "outer" if depth % 2 == 0 else "inner"
        if role and role != derived:
            logger.warning("member %d declares role %r; geometry says %s",
                           i, role, derived)


if __name__ == "__main__":
    outer = LineString([(0, 0), (10, 0), (10, 10), (0, 10), (0, 0)])
    hole = LineString([(3, 3), (6, 3), (6, 6), (3, 6), (3, 3)])
    rings = stitch_rings([outer, hole])
    logger.info("%s", assemble(rings))

Step-by-step walkthrough Jump to heading

  1. Merge before closing. Members arrive as fragments in arbitrary order and direction; a line-merge joins them without requiring the caller to sort anything.
  2. Raise on an unclosed fragment. A ring that will not close means the relation is incomplete or a member is missing from the extract, and naming the gap coordinates is what makes it fixable.
  3. Probe with a representative point. A polygon’s centroid can lie outside it for a crescent shape; a representative point is guaranteed inside, which matters because the containment test is the whole algorithm.
  4. Count containment, do not assume two levels. An island in a lake on an island is depth 2, and a rule that only distinguishes inside from outside gets it exactly backwards.
  5. Attach each hole to its smallest containing parent. With nested rings, several outer rings can contain a given hole; the smallest is the immediate parent.
  6. Warn rather than fail on an orphan. A hole with no parent indicates inconsistent geometry, and treating it as an outer ring produces something usable while flagging the problem.
  7. Check roles afterwards, for reporting only. Comparing the declared roles against the geometric result finds mistagged relations without ever letting a wrong role affect the output.
What nesting depth means for each ring, with a worked nesting A grid of four rings from one relation against their nesting depth, the role the geometry implies, and what they represent in a worked example of an island in a lake on an island. The outermost ring is at depth zero and is an outer boundary, representing the main island. The lake ring is at depth one and is a hole, cut out of the island. The island within the lake is at depth two and is an outer boundary again. A pond on that inner island is at depth three and is a hole once more. Depth parity, worked through an island in a lake Depth Implies In the example Outermost ring 0 outer the main island Lake ring 1 hole water on the island Inner island 2 outer land in the lake Pond ring 3 hole water on that land A two-level rule handles the first two rows and turns the third into a hole, erasing the island inside the lake entirely.
Parity generalises to any nesting depth, which is why it is the rule rather than a special case.
Three ways a role-trusting assembler produces wrong geometry Three panels. A missing role makes the assembler treat the ring as an outer boundary by default, producing two overlapping polygons where one polygon with a hole was intended. A wrong role makes the assembler cut a hole where land should be or fill an area that should be water, which renders convincingly and is wrong. A nested case defeats the two-level model entirely, because an island inside a lake needs a ring that is both inside another ring and an outer boundary in its own right. Three failures, all from trusting the roles Missing role Defaults to outer Two overlapping polygons Area roughly doubles Renders as a solid block Wrong role Hole where land should be Or land where water is Renders convincingly Only area checks catch it Nested rings Island inside a lake Inside another, and outer Two-level model cannot Island vanishes entirely Only the second failure is detectable by looking at the result; the other two produce geometry that appears entirely reasonable.
Each of these is avoided by the same change: derive containment from the geometry and treat roles as a report.

Verification Jump to heading

  • A simple polygon with one hole assembles correctly. Area should equal the outer area minus the hole.
  • Nested cases survive. Build an island-in-a-lake-on-an-island and confirm four rings produce two polygons.
  • Unclosed input raises. Remove a member fragment and confirm the error names the gap.
  • Role disagreements are reported. Feed correct geometry with deliberately wrong roles and confirm warnings appear without the output changing.
  • The result is valid. Every assembled polygon should pass a validity check, per Detecting Self-Intersecting OSM Polygons with Shapely.

Common errors and fixes Jump to heading

Symptom Root cause One-line fix
Islands inside lakes disappear Two-level inside/outside rule Use nesting parity, not a binary test
Holes attached to the wrong polygon First containing ring chosen Attach to the smallest containing parent
Containment test wrong on crescents Centroid used as the probe Use a representative point, guaranteed inside
Assembly silently drops members Unclosed fragments ignored Raise and name the gap coordinates
Output follows a wrong role Roles trusted over geometry Derive containment geometrically; check roles after
Members in the wrong order break it Manual stitching assumed ordering Use a line merge, which is order-independent
Relation incomplete in the extract Members outside the cut boundary Cut with a strategy that keeps relation members

Specification reference Jump to heading

In the multipolygon relation, member roles outer and inner are used by many consumers but the geometry is authoritative: rings are formed from the member ways and their containment relationships determine which rings bound areas and which bound holes. Members with an empty role are valid and must be handled by geometric assembly. See the multipolygon relation documentation for the assembly algorithm and the treatment of roles.

Frequently Asked Questions Jump to heading

Is a multipolygon with empty roles invalid?

No. The specification treats roles as advisory and requires consumers to determine containment from the geometry, so a relation whose members carry no role is entirely conforming. Code that depends on roles is the thing out of specification, and it fails not only on role-less relations but on the more troublesome case of relations whose roles are present and wrong.

Why use nesting parity rather than a simple inside test?

Because nesting genuinely goes deeper than two levels. An island in a lake on an island is four rings at depths zero to three, and a binary inside-or-outside rule turns the inner island into a hole, erasing it. Parity generalises to any depth with no extra cases, which makes it both more correct and simpler than the special-cased alternative.

What should happen when a ring will not close?

Raise, naming the coordinates of the gap. An unclosed ring means either that the relation is genuinely broken or, far more often, that a member way is missing because the extract was cut without keeping relation members. Silently dropping the fragment produces a polygon that looks plausible and is wrong; naming the gap lets somebody check which of the two causes applies.

Should I ever use the declared roles at all?

As a check on your result, never as its basis. Comparing the geometric answer against the declared roles is a cheap and useful quality signal: a relation where the two disagree is one a mapper should look at. But the output must follow the geometry, because that is what the specification says and because a wrong role is at least as common as a missing one.

Up one level: Node, Way & Relation Data Model.