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 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
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.
Related
- Attribute Mapping from Blueprints — the surrounding stage and its architecture.
- OCR for Scanned Floor Plan Labels — where the labels come from when the source is a scan.
- Geometry Cleanup & Topology Repair — the stage that must run first, so names bind to final polygons.
This page is a companion to Attribute Mapping from Blueprints, part of the Automated Floor Plan Parsing & Vectorization section.