Extracting Room Boundaries from SVG Floor Plans

This page covers the specific technique of converting raw SVG floor-plan geometry into closed, metric-aligned room polygons, and sits within the SVG/DWG Parsing Workflows collection of the floor-plan parsing reference.

What “room boundary extraction” actually means

Room boundary extraction is the deterministic process of walking an SVG DOM, resolving every nested coordinate transform to absolute space, and reconstructing each room as a single closed polygon ring expressed in real-world metres. It is not the same as reading the <path> d attributes verbatim. A boundary that is usable downstream must satisfy three properties at once: coordinates are absolute (all transform matrices applied), the frame is metric and right-side-up (Y inverted, pixels scaled), and the ring is topologically closed (no sub-pixel gap between the last and first vertex).

SVG exported from CAD/BIM viewers violates all three by default. Geometry is buried inside nested <g> groups that each carry a transform="matrix(a b c d e f)", the origin is top-left with Y pointing down, and walls that look joined on screen are often separated by anti-aliasing-width gaps. The job of this technique is to absorb those quirks before the geometry reaches any Wall & Door Detection Algorithms or routing-graph stage, both of which assume clean, closed, unit-aligned input.

Why one SVG path becomes a room and its neighbour does not Two four-segment outlines drawn side by side inside a dashed page boundary. The left outline, in cyan, closes exactly, so its segments form a ring that polygonises into a room. The right outline, in orange, has a 0.4 pixel gap at its bottom-left corner, marked with a red cross; the segments never form a ring and the room is silently lost. Below, two arrows show the axis conflict: SVG's y axis grows downward while the metric world frame is Y-up, so every coordinate must be flipped during conversion. A ring that nearly closes is not a room closed path -> Polygon 0.4 px gap -> not a ring SVG y grows downward; the metric frame is Y-up SVG +y world +y (after flip)

Two failure modes, one drawing. Sub-pixel gaps are the common cause of “missing rooms” and are invisible at any sane zoom — which is why the extractor snaps endpoints within a tolerance before polygonising, and why the Y-flip is applied once, at the boundary, rather than sprinkled through the code.

Minimal working example

The snippet below takes a single transformed path and returns a closed, repaired Shapely polygon in metres. It is deliberately self-contained: a regex d-parser for M/L/Z, a homogeneous transform, a Y-flip against the viewBox height, and a buffer(0) repair. Curved segments (C, Q, A) must be pre-sampled to line segments before they reach this function.

import logging
import re
import numpy as np
from shapely.geometry import Polygon
from shapely.errors import GEOSException

logger = logging.getLogger("svg_boundary")
_TOKENS = re.compile(r"[MLZmlz]|[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?")

def extract_room_polygon(d_attr: str, matrix: np.ndarray,
                         viewbox_height: float, px_per_meter: float) -> Polygon:
    """Parse one SVG path into a closed, metric, valid Shapely Polygon."""
    nums = [float(t) for t in _TOKENS.findall(d_attr) if t.upper() not in "MLZ"]
    if len(nums) < 6 or len(nums) % 2:
        raise ValueError(f"Malformed path: {len(nums)} coordinate values")
    pts = np.array(nums).reshape(-1, 2)
    homog = np.hstack([pts, np.ones((pts.shape[0], 1))])
    world = (matrix @ homog.T).T[:, :2]          # apply nested transforms
    world[:, 1] = viewbox_height - world[:, 1]    # SVG Y-down -> Y-up
    world /= px_per_meter                          # pixels -> metres
    try:
        poly = Polygon(world)
        if not poly.is_valid:
            poly = poly.buffer(0)                  # repair self-intersections
    except GEOSException as exc:
        logger.warning("Geometry repair failed: %s", exc)
        raise
    logger.info("Extracted room: area=%.2f m^2", poly.area)
    return poly

Parameter reference

Argument Type Default Notes
d_attr str The SVG path d string. Only M/L/Z survive parsing; pre-sample C/S/Q/T/A curves first.
matrix np.ndarray (3×3) Cumulative homogeneous transform from flatten_transforms. Must be parent @ child, never child @ parent.
viewbox_height float The height from the root viewBox (min-x min-y width height), in SVG user units — used for the Y-flip.
px_per_meter float Calibration factor from a known dimension or the drawing’s architectural scale. Wrong value yields metre-scale errors.
tolerance float 0.05 Douglas-Peucker simplify tolerance (m) when reducing vertex density; keep below wall thickness.
snap_tolerance float 0.10 Max gap (m) closed during the snapping pass; oversizing it merges distinct rooms.
How each SVG element in a floor-plan export should be treated A reference grid of six SVG element types. A path with a Z command is usually a room or wall face and polygonises directly. A path without Z is usually a wall centreline and needs endpoint snapping before it can form a ring. A rect is a room in simple exports and converts to a four-point ring. A polyline is usually a corridor centreline and is kept as a routing edge rather than a polygon. A g element carrying a transform is a placed block whose current transformation matrix must be flattened before its coordinates mean anything. A text element is a room name and is resolved to a room rather than polygonised. Element type decides whether it is a polygon, an edge, or a label SVG element What it usually is Handling <path> with Z a room or a wall face ● polygonise directly <path> without Z a wall centreline snap endpoints, then ring <rect> a room in a simple export ● convert to 4-point ring <polyline> a corridor centreline keep as a routing edge <g transform=...> a placed block △ flatten the CTM before use <text> a room name or number resolve to a room, do not polygonise The transform row is the one that silently corrupts coordinates if skipped.

Nested transforms are the silent corrupter. A room inside two nested groups reads out at plausible-looking but wrong coordinates, so it lands in the dataset and fails much later, at routing, rather than at parse time.

The matrix argument comes from a recursive flatten that post-multiplies each child group’s matrix by its parent’s, because SVG composes transforms outermost-first:

import logging
from typing import Iterator, Tuple
import numpy as np
from lxml import etree

logger = logging.getLogger("svg_boundary")
_GEOM = {"path", "polygon", "rect", "line", "polyline"}

def flatten_transforms(el: etree._Element,
                       parent: np.ndarray) -> Iterator[Tuple[etree._Element, np.ndarray]]:
    """Yield each leaf geometry element with its absolute 3x3 transform."""
    local = parse_svg_matrix(el.get("transform", ""))   # identity if absent
    try:
        cumulative = parent @ local                      # parent-first composition
    except ValueError as exc:
        logger.error("Bad matrix shape on %s: %s", el.tag, exc)
        raise
    if etree.QName(el.tag).localname in _GEOM:
        yield el, cumulative
    else:
        for child in el:
            yield from flatten_transforms(child, cumulative)

For the exact composition rules and how nested viewBox/preserveAspectRatio alter the final frame, the W3C SVG 1.1 coordinate-systems spec is authoritative.

Common errors and fixes

1. Rooms render mirrored or rotated by their centre. Symptom: walls land hundreds of pixels off and rotated shapes pivot around the wrong point. Root cause is reversed matrix order. SVG applies the outermost group first, so the parent matrix must be on the left:

cumulative = parent_matrix @ child_matrix   # correct
# cumulative = child_matrix @ parent_matrix # inverts rotation centres

2. shapely.errors.GEOSException: TopologyException: Input geom 0 is invalid: Self-intersection. Hand-drafted exports produce rings that cross themselves. The buffer(0) idiom re-nodes the ring and rebuilds a valid orientation; filter the result because it can return a MultiPolygon:

repaired = poly.buffer(0)
poly = max(repaired.geoms, key=lambda g: g.area) if repaired.geom_type == "MultiPolygon" else repaired

3. Adjacent rooms come out disconnected. Symptom: a routing graph fragments into isolated subgraphs even though the plan looks continuous. Root cause is sub-pixel gaps between wall endpoints. Snap to a shared union, then union to merge collinear walls, and drop slivers by area:

from shapely.ops import snap, unary_union

def close_room_gaps(polys: list, snap_tol: float = 0.10):
    """Snap near-coincident vertices and merge boundaries into closed rooms."""
    union = unary_union(polys)
    snapped = [snap(p, union, snap_tol) for p in polys]
    merged = unary_union(snapped)
    rooms = [g for g in getattr(merged, "geoms", [merged])
             if g.geom_type == "Polygon" and g.area > 1.0]   # drop drafting noise
    logger.info("Closed %d rooms after snapping", len(rooms))
    return rooms

Enforce counter-clockwise exterior rings (OGC Simple Features) before serialising, so winding stays consistent with the rest of the pipeline.

How this feeds the pipeline

The polygons produced here are the geometric substrate everything downstream binds to. Before serialisation they should be projected into a consistent indoor coordinate reference system so multi-building campuses share one frame, then emitted inside the same GeoJSON FeatureCollection envelope defined by the JSON schema for indoor map APIs. Door and corridor detection runs against these closed rooms to add passable edges, and the sibling DWG path — parsing DWG files with Python ezdxf — converges on the identical polygon contract so SVG-sourced and CAD-sourced floors are indistinguishable to the routing graph.

Room boundary extraction: six typed stages A left-to-right pipeline. Stage 1 Nested SVG DOM (a tree of g elements each carrying a transform matrix in the viewBox frame) passes a matrix stack to Stage 2 Flatten Transforms (parent-first composition resolving each leaf to absolute pixel coordinates), which passes absolute points to Stage 3 Y-flip and Metric Scale (invert Y against viewBox height, divide by pixels-per-metre), which passes metric vertices to Stage 4 Linearize and Repair (sample curves to segments and run buffer(0)), which passes a valid polygon to Stage 5 Snap Micro-gaps (snap near-coincident vertices to a shared union and drop slivers), which passes a closed ring to Stage 6 Closed Room Polygon emitted as a GeoJSON Feature. SVG room-boundary extraction — six typed stages matrix stack absolute px metric verts valid polygon closed ring 1 2 3 4 5 6 Nested SVGDOM FlattenTransforms Y-flip &Metric Scale Linearize& Repair SnapMicro-gaps Closed RoomPolygon <g> matrix treeviewBox frame parent @ childabsolute px Y-up · ÷ px/mmetres sample curvesbuffer(0) snap to uniondrop slivers GeoJSONFeature ring to wall · door · routing graph

Frequently Asked Questions

Why do some rooms come out as slivers or self-intersecting polygons?

Almost always because the path was traced in a drawing tool that emitted near-duplicate vertices, or because two wall segments cross slightly rather than meeting. Both produce rings that are technically closed and geometrically invalid. Run shapely’s make_valid() (or buffer(0) on older versions) after polygonising and check geom.is_valid before accepting the room, then drop any resulting polygon whose area is below a floor of about 0.5 m² — real rooms are never that small, and slivers always are. It is worth counting how many rooms each building loses to this check: a handful is normal draughting noise, while dozens means the export settings are wrong and the fix belongs upstream in the drawing tool, not in the parser.

Should the Y-axis flip happen during parsing or afterwards?

During parsing, exactly once, at the boundary where SVG coordinates become world coordinates. SVG’s Y axis grows downward and the metric frame is Y-up, so the flip is a property of the format, not of any particular consumer. Applying it once at the edge means every downstream stage — polygonisation, routing-graph construction, tiling — works in one consistent frame and no one has to remember which way is up. The alternative, flipping at each consumer, guarantees that one of them eventually forgets, and the symptom is a building that is mirrored top to bottom while every individual room looks correct, which is remarkably hard to spot in a data listing and obvious only once someone renders it.

How do I match a room's name to its polygon?

Resolve <text> anchors against the polygons after both have been extracted, rather than trying to associate them during parsing. Point-in-polygon containment resolves the straightforward cases; a nearest-centroid fallback with a radius limit resolves labels that draughting convention placed in the corridor. Crucially, record which rule resolved each name and leave genuinely ambiguous cases unresolved with a null name, so the count of unresolved rooms becomes a number you can gate a publish on. That full resolution ladder is the subject of attribute mapping from blueprints.

This page belongs to the SVG/DWG Parsing Workflows collection, part of the Automated Floor Plan Parsing & Vectorization reference.