Resolving Room Names to Polygons

The core technique of Attribute Mapping from Blueprints, in detail: turning a text anchor into a name on the right polygon, and — more importantly — declining to when the evidence does not support it.

The Ladder

The four-outcome resolution ladder for a room label One decision applied to each label in priority order. A label whose anchor falls inside exactly one room resolves by containment with full confidence. A label whose anchor is within two and a half metres of exactly one room centroid resolves to it with confidence between 0.6 and 0.9. A label equidistant from two rooms is flagged ambiguous rather than guessed. A label with nothing in range leaves its room unnamed and is counted. Two ways to resolve, two ways to decline Which polygon does this label name? rules applied in order, first match wins inside one room Containment confidence 1.00 one room ≤ 2.5 m Nearest confidence 0.6-0.9 two rooms tie Ambiguous flag, do not guess nothing near Unresolved null name, counted

Two of the four outcomes are refusals. That is the design: a resolver that always answers produces confidently wrong room names, which nothing downstream can detect.

How labels resolve across six buildings A grouped bar chart over six buildings showing how room labels resolve. Containment accounts for between 29 and 52 percent of labels depending on the building. The nearest-centroid fallback accounts for the largest share in most buildings, between 42 and 61 percent. Between 6 and 11 percent remain unresolved or ambiguous in every building. The fallback does more work than the primary rule 0 20 40 60 1 2 3 4 5 6 building share of labels (%) resolved by containment (%) resolved by nearest centroid (%) unresolved or ambiguous (%)

Containment resolves a minority. Draughting convention puts labels wherever they fit, so a resolver built on point-in-polygon alone silently drops half the names in a typical building.

The measured split is the argument for the ladder. Containment — the rule everyone implements first — resolves between a third and a half of labels. The rest sit in corridors, in neighbouring rooms, or outside the plan area entirely, because a draughter places a label where it fits on a printed sheet rather than where a point-in-polygon test would like it.

The nearest-centroid fallback recovers most of them, bounded by a radius so that a label 15 m from anything is not attached to whatever happens to be closest. And 6-11% remain, which is the number that matters: it is countable, it can be gated on, and it is honest.

Minimal Working Example

import logging
from dataclasses import dataclass

from shapely.geometry import Point, Polygon
from shapely.strtree import STRtree

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)


@dataclass(frozen=True)
class Resolution:
    label: str
    room_id: str | None
    rule: str                # containment | nearest | ambiguous | unresolved
    confidence: float


def resolve_labels(labels: list[tuple[str, Point]], rooms: dict[str, Polygon],
                   *, radius: float = 2.5, tie_margin: float = 0.15) -> list[Resolution]:
    """Bind each label to a room, or decline to. Never guesses between equals."""
    if not rooms:
        raise ValueError("no room polygons: resolution is impossible")
    ids = list(rooms)
    tree = STRtree([rooms[i] for i in ids])
    out: list[Resolution] = []

    for text, anchor in labels:
        inside = [ids[j] for j in tree.query(anchor) if rooms[ids[j]].contains(anchor)]
        if len(inside) == 1:
            out.append(Resolution(text, inside[0], "containment", 1.0))
            continue
        if len(inside) > 1:                       # nested polygons: a zone and a room
            smallest = min(inside, key=lambda i: rooms[i].area)
            out.append(Resolution(text, smallest, "containment", 0.9))
            continue

        near = sorted(
            ((rooms[i].representative_point().distance(anchor), i) for i in ids
             if rooms[i].representative_point().distance(anchor) <= radius * 4),
            key=lambda kv: kv[0])
        if not near or near[0][0] > radius * 4:
            out.append(Resolution(text, None, "unresolved", 0.0))
            continue
        if len(near) > 1 and (near[1][0] - near[0][0]) < tie_margin * near[0][0]:
            out.append(Resolution(text, None, "ambiguous", 0.0))
            continue

        d = near[0][0]
        out.append(Resolution(text, near[0][1], "nearest", max(0.6, 1.0 - d / (radius * 8))))

    counts = {r.rule: sum(1 for x in out if x.rule == r.rule) for r in out}
    logger.info("resolution: %s", counts)
    return out

The tie_margin is what turns “pick the closest” into “pick the closest if it is clearly closest”. A 15% relative margin means two rooms whose distances differ by less than a seventh are treated as equally likely, which is the honest reading of a label placed between them.

Ambiguity Is a Result

A label equidistant from two candidate rooms Two adjacent rooms, 2.11 and 2.12, with a text anchor reading Meeting Room placed in the corridor between and above them. Dashed lines to each room's centroid are both 8.9 metres long, so no nearest-centroid rule can separate them. The correct outcome is to flag the label ambiguous rather than assign it to either room. Equal distances mean no evidence, not a coin toss 2.11 2.12 “Meeting Room” 8.9 m 8.9 m equidistant: flag ambiguous rather than pick one

Ties are common at corridor labels. A rule that breaks them arbitrarily — by iteration order, or by a hair's-breadth distance difference — is a rule that assigns half of them wrongly.

The instinct to always return something is strong and wrong. A resolver that breaks ties by iteration order assigns roughly half of its tied labels to the wrong room, and — this is the important part — the result is indistinguishable from a correct one. There is no downstream check that catches “Meeting Room” attached to 2.11 rather than 2.12.

An ambiguous flag, by contrast, is a work item. It appears in the resolution report, it can be reviewed in a batch, and the room keeps a null name until someone decides. A building with 400 labels and 12 ambiguities is twenty minutes of curation; the same building with 12 silent errors is a support ticket in eight months.

Validation Output

The resolver’s report is what the publish gates on:

{
  "building": "HQ", "level": 2, "labels": 118,
  "containment": 47, "nearest": 58, "ambiguous": 6, "unresolved": 7,
  "rooms": 61, "rooms_named": 54, "rooms_unnamed": 7,
  "mean_confidence": 0.87,
  "delta_vs_published": {"rooms_named": 0, "ambiguous": +4}
}

The delta is the useful part. Six ambiguities is normal; six where there were two last time means something moved — usually that rooms were subdivided and the labels were not, so anchors that used to sit inside one room now sit between two.

def test_named_rooms_do_not_regress(report, previous):
    assert report["rooms_named"] >= previous["rooms_named"], (
        f"named rooms fell from {previous['rooms_named']} to {report['rooms_named']}")

Common Errors & Fixes

Every label resolves and some names are wrong. The ambiguity check is missing or its margin is zero. Two rooms 8.90 m and 8.91 m away are not distinguishable by that measurement.

Zone labels overwrite room names. Nested polygons — a department zone containing several rooms — catch a label that was meant for the zone. Taking the smallest containing polygon, as above, resolves it correctly for room labels; zone labels then land in the same room and need the layer or the text pattern to disambiguate.

Names are attached to corridors. The nearest-centroid search includes corridor polygons, which are long and have centroids that can be closer to a label than the room it names. Restrict the candidate set by space_class where the source supports it — a room number names a room.

Resolution rates differ wildly between buildings. Usually a genuine difference in draughting convention, and worth knowing rather than smoothing over. A building at 90% containment was drawn by someone who placed labels inside rooms; one at 20% was not, and its radius may deserve tuning.

Integration Point

Resolution runs after geometry cleanup has produced final room polygons — resolving against pre-cleanup geometry attaches names to faces that are about to be merged or discarded — and before the POI taxonomy assigns categories, since the name is frequently what the category is inferred from.

Its output feeds two consumers with different tolerances. Search wants every name it can get and copes with nulls. The routing layer does not use names at all. That asymmetry is why unresolved labels are a quality signal rather than a blocking failure — unless the count regresses, which is the delta gate above.

Frequently Asked Questions

What radius should the nearest-centroid fallback use?

Around 2.5 metres from the room boundary, which in practice means searching a somewhat larger radius from the centroid and rejecting on the boundary distance. The reasoning is that draughters place a displaced label just outside the room it names — in the corridor immediately beside it — rather than metres away, so a tight radius captures the real cases and excludes the coincidental ones. Buildings with unusually large rooms may need more; the way to tell is to plot the distance distribution for labels that resolve by containment and see where the tail actually sits.

Should a label ever name more than one room?

Occasionally, and it should be explicit when it does. Open-plan areas are sometimes drawn as several polygons with one label, and a suite may be numbered once and partitioned into three. Handling that well means allowing a one-to-many resolution as a distinct rule with its own confidence, rather than letting the nearest-centroid rule pick one polygon arbitrarily. Where it is common in a portfolio it is worth detecting explicitly: adjacent unnamed polygons sharing a boundary with exactly one named one are usually the same space.

How do I handle labels for spaces that are not rooms?

Filter by the candidate’s space_class before matching, not after. Door tags, zone names, level headings and drawing titles all appear in the same text stream as room names, and each should only be matched against the kind of feature it can plausibly name. Where the source provides layers this is trivial; where it does not, the text pattern is usually enough — a door tag looks nothing like a room name — which is exactly the pattern set the OCR guide defines.

This page is a companion to Attribute Mapping from Blueprints, part of the Automated Floor Plan Parsing & Vectorization section.