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.

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.

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

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