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.
Runnable solution Jump to heading
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
- Merge before closing. Members arrive as fragments in arbitrary order and direction; a line-merge joins them without requiring the caller to sort anything.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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
outerandinnerare 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.
Related Jump to heading
- Node, Way & Relation Data Model — the parent topic and the relation model this assembles.
- Understanding OSM Multipolygon Relations for GIS — the wider multipolygon picture.
- Traversing Nested OSM Relations Safely — relations containing relations, which this deliberately does not.
- Repairing Unclosed Ways and Broken Multipolygons — what to do when the stitch genuinely fails.
- Choosing Complete Ways vs Smart in osmium extract — the cut strategy that keeps relation members present.
Up one level: Node, Way & Relation Data Model.