Wall & Door Detection Algorithms for Indoor Mapping Pipelines
Wall and door detection is the geometric heart of indoor navigation: it converts an inert floor-plan drawing into the passable edges and barriers a routing engine reasons over. This collection sits inside the wider Automated Floor Plan Parsing & Vectorization reference and consumes the unit-aligned geometry produced upstream, turning raster masks and vector primitives into a topologically sound wall-and-door graph that the rest of the pipeline can trust.
The Problem: Drawings Are Not Graphs
A facilities team hands you a high-DPI scanned PDF for one building and a clean DWG export for the next, and both describe walls and doors in ways a routing graph cannot consume directly. Walls appear as single centrelines, as double parallel lines bounding a hollow core, or as filled poché. Doors are not objects at all — they are intentional gaps in the wall run, sometimes annotated with a swing arc, sometimes just an empty span. Nothing in the source explicitly says “this 0.9 m break is a doorway and this 2.4 m break is a corridor mouth.”
The symptom of getting this wrong is concrete: corridors that should connect render as sealed rooms, a furniture gap gets promoted to a phantom doorway, double-line walls produce two parallel edges where the router expects one centreline, and the connectivity audit reports isolated subgraphs that strand an entire wing. Robust detection has to suppress non-structural clutter, vectorize wall runs into clean centrelines, classify openings against real architectural dimensions, and prove the resulting graph is fully connected — every step deterministic enough to re-run across a portfolio of hundreds of floor levels on each renovation cycle.
Prerequisites & Dependencies
Before implementing detection, pin the libraries and agree the coordinate assumptions the upstream stages already guarantee:
opencv-python(cv2) — adaptive thresholding, morphological filtering, contour tracing, and optional skeletonization viacv2.ximgproc.thinning.numpy— raster array math and the binary mask representation.shapely—LineString/Polygongeometry, collinear merging withunary_union, and snapping withshapely.ops.snap.networkx— building and auditing the routing graph (connectivity, shortest path sanity checks).- Coordinate contract: inputs arrive in the local Cartesian frame the parsing stage establishes — origin bottom-left, Y-up, units in metres. Raster inputs additionally carry a calibrated pixels-per-metre factor. Vector-native DWG and SVG should never be rasterized; route them through the SVG/DWG Parsing Workflows instead so sub-pixel precision and floor-level metadata survive. Detected geometry is later projected into a campus-wide indoor coordinate reference system so adjacent buildings share one frame.
Detection thresholds — wall thickness, minimum and maximum door width — only make sense in real-world units, so scale calibration is a hard prerequisite, not an afterthought. A pipeline that measures gaps in pixels will reclassify the same doorway differently on every export resolution.
Architecture: From Pixels to Passable Edges
Conceptually the workflow is a linear refinement. A raster normalizer suppresses everything that is not load-bearing linework and emits a clean binary mask; a scale calibrator fixes the pixels-per-metre factor against a known architectural reference; a vectorizer traces that mask into wall centrelines; an opening detector scans the wall run for gaps and classifies each by width and context; and a graph builder lifts walls-and-doors into nodes and edges, then audits connectivity. Keeping each stage to a single responsibility lets you reprocess one floor level without re-running a portfolio and isolate a failure to the stage that caused it.
The join points are deliberately narrow: a binary np.ndarray between normalization and vectorization, a list of shapely LineString centrelines between vectorization and opening detection, and a networkx graph at the end. Because both raster and vector branches converge on the same LineString wall representation, the opening detector and graph builder never need to know whether a wall originated in a scan or a CAD export. The same classified output then feeds attribute mapping from blueprints, which attaches fire ratings, swing direction, and accessibility clearances to each detected door.
Step-by-Step Implementation
1. Normalize the raster into a clean wall mask
Most legacy plans arrive as high-DPI TIFFs or scanned PDFs cluttered with furniture, text, and hatching. The first stage suppresses non-structural elements while preserving continuous wall linework. Adaptive thresholding absorbs uneven scan lighting, morphological opening removes annotation speckle, closing reconnects scan-induced wall breaks, and a contour-area filter drops the remaining noise. See the OpenCV adaptive thresholding documentation for parameter-tuning guidance.
import logging
import cv2
import numpy as np
logger = logging.getLogger("detect.raster")
def normalize_floorplan_raster(
image: np.ndarray,
block_size: int = 15,
c: int = 8,
min_contour_area: int = 50,
) -> np.ndarray:
"""Convert a raw BGR/grayscale floor plan to a cleaned binary wall mask.
Applies adaptive thresholding plus morphological filtering to strip
annotation clutter while preserving continuous wall linework.
"""
if image is None or image.size == 0: # empty / failed decode
raise ValueError("Input image is empty or could not be decoded.")
if image.ndim == 3:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
elif image.ndim == 2:
gray = image.copy()
else:
raise ValueError("Unsupported image dimensions; expected 2D or 3D array.")
# Adaptive thresholding handles uneven scanning illumination.
binary = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV, block_size, c,
)
# Open to drop isolated speckle and thin annotation lines.
kernel_open = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
cleaned = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel_open, iterations=2)
# Close to reconnect minor wall breaks from scan artefacts.
kernel_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
final_mask = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel_close, iterations=1)
# Keep only contours above the structural area threshold.
contours, _ = cv2.findContours(final_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
mask_filtered = np.zeros_like(final_mask)
kept = 0
for cnt in contours:
if cv2.contourArea(cnt) > min_contour_area:
cv2.drawContours(mask_filtered, [cnt], -1, 255, -1)
kept += 1
logger.info("Raster normalized: retained %d of %d structural components",
kept, len(contours))
return mask_filtered
2. Calibrate the pixel-to-metre scale
Detection thresholds operate in metres, so resolve a single pixels-per-metre factor against a known reference — a scale bar, a standard door leaf (0.8–0.9 m), or grid spacing — and carry it through every later stage rather than re-deriving it per geometry.
import logging
logger = logging.getLogger("detect.scale")
def calibrate_scale(
known_length_metres: float,
pixel_distance: float,
min_ppm: float = 10.0,
max_ppm: float = 500.0,
) -> float:
"""Compute a pixels-per-metre factor and sanity-check it against typical scales."""
if pixel_distance <= 0 or known_length_metres <= 0:
raise ValueError("Both pixel distance and known length must be positive.")
ppm = pixel_distance / known_length_metres
if not (min_ppm <= ppm <= max_ppm): # e.g. 1:100 / 1:50 plausibility band
logger.warning("Unusual scale %.2f px/m; verify the reference measurement", ppm)
return ppm
3. Vectorize wall runs into centrelines
A binary mask is not yet geometry. Trace contours, simplify them with approxPolyDP to collapse the redundant vertices raster tracing emits, reject sub-structural fragments by area, and merge collinear and overlapping segments with unary_union so a double-line wall does not survive as two parallel edges. Consult the Shapely geometry operations manual for the topology helpers (buffer, snap, intersection) used here and downstream.
import logging
from typing import List
import cv2
import numpy as np
from shapely.geometry import LineString, Polygon
from shapely.ops import unary_union
logger = logging.getLogger("detect.walls")
def extract_wall_segments(
mask: np.ndarray,
ppm: float,
epsilon_factor: float = 0.005,
min_wall_length_m: float = 0.5,
) -> List[LineString]:
"""Trace a binary mask into merged wall-centreline LineStrings (metres)."""
if ppm <= 0:
raise ValueError("Pixels-per-metre factor must be positive.")
contours, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
lines: List[LineString] = []
min_area_px = (min_wall_length_m * ppm) ** 2
for cnt in contours:
perimeter = cv2.arcLength(cnt, True)
approx = cv2.approxPolyDP(cnt, epsilon_factor * perimeter, True)
if len(approx) < 3:
continue
pts = approx.reshape(-1, 2)
if Polygon(pts).area < min_area_px: # drop non-structural slivers
continue
for i in range(len(pts)):
start = tuple(pts[i] / ppm) # carry geometry in metres
end = tuple(pts[(i + 1) % len(pts)] / ppm)
lines.append(LineString([start, end]))
merged = unary_union(lines) # collapse collinear / overlapping runs
segments = list(merged.geoms) if hasattr(merged, "geoms") else [merged]
logger.info("Vectorized %d wall segments from %d contours", len(segments), len(contours))
return segments
4. Detect and classify openings
Doors are gaps in the wall run. Scan the wall endpoints for breaks wider than a real door leaf but narrower than a structural opening, then classify each gap by width and contextual placement. Pairing endpoints is O(n²) in the naive form below; for large floor levels, index endpoints with a shapely.STRtree and query only nearby pairs.
import logging
import math
from typing import Dict, List
from shapely.geometry import LineString
logger = logging.getLogger("detect.doors")
def detect_door_openings(
wall_lines: List[LineString],
ppm: float,
min_door_width_m: float = 0.7,
max_door_width_m: float = 1.5,
) -> List[Dict]:
"""Identify door openings as classified gaps between wall-segment endpoints."""
if ppm <= 0:
raise ValueError("Pixels-per-metre factor must be positive.")
endpoints: List[tuple] = []
for line in wall_lines:
coords = list(line.coords)
endpoints.extend([coords[0], coords[-1]])
doors: List[Dict] = []
for i, p1 in enumerate(endpoints):
for j in range(i + 1, len(endpoints)):
p2 = endpoints[j]
gap_m = math.hypot(p1[0] - p2[0], p1[1] - p2[1]) # already metres
if min_door_width_m <= gap_m <= max_door_width_m:
midpoint = ((p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2)
doors.append({
"id": f"door_{len(doors) + 1}",
"start": p1, "end": p2, "midpoint": midpoint,
"width_m": round(gap_m, 3), "space_class": "door",
})
logger.info("Detected %d candidate openings from %d endpoints",
len(doors), len(endpoints))
return doors
Once openings are detected, attach semantic properties — fire rating, swing direction, ADA clearance — through attribute mapping from blueprints so downstream routing respects egress and accessibility constraints rather than treating every opening identically.
5. Build and audit the routing graph
Walls and doors become a routing graph: nodes at intersections, door midpoints, and dead ends; edges along passable spans, never through wall interiors. A breadth-first traversal from any node confirms full connectivity — an isolated subgraph means a missed doorway or a genuine vertical-only connector (a lift or stairwell) that belongs to level mapping and Z-axis logic rather than the planar graph.
import logging
from typing import Dict, List
import networkx as nx
from shapely.geometry import LineString
logger = logging.getLogger("detect.graph")
def build_routing_graph(
wall_lines: List[LineString],
doors: List[Dict],
snap_tol_m: float = 0.05,
) -> nx.Graph:
"""Lift walls and doors into a connectivity-audited routing graph."""
graph = nx.Graph()
for d in doors: # doors are the passable nodes
graph.add_node(d["id"], pos=d["midpoint"], space_class="door")
# Connect doors that share a wall run within snap tolerance.
ids = list(graph.nodes)
for a in range(len(ids)):
for b in range(a + 1, len(ids)):
pa = graph.nodes[ids[a]]["pos"]
pb = graph.nodes[ids[b]]["pos"]
link = LineString([pa, pb])
if any(link.distance(w) < snap_tol_m for w in wall_lines):
graph.add_edge(ids[a], ids[b], weight=link.length)
if graph.number_of_nodes() and not nx.is_connected(graph):
components = nx.number_connected_components(graph)
logger.warning("Routing graph has %d disconnected components — "
"check for missed doorways or vertical connectors", components)
else:
logger.info("Routing graph connected: %d nodes, %d edges",
graph.number_of_nodes(), graph.number_of_edges())
return graph
For CAD-native inputs, the layer-aware extraction rules in automating wall and door detection in CAD cut false positives from annotation blocks and viewport frames before any of the above runs, and should be preferred over rasterizing a DWG.
Edge Cases & Gotchas
| Symptom | Root cause | Resolution |
|---|---|---|
| Fragmented walls | Low DPI, scan artefacts, or inconsistent line weights | Increase the MORPH_CLOSE kernel; skeletonize with cv2.ximgproc.thinning() before vectorizing so double lines collapse to one centreline. |
| Phantom doors | Furniture gaps, hatching, or text misread as openings | Validate candidates against room boundaries via attribute mapping; doors align with wall runs, not interior furniture. |
| Door widths read as “1200 metres” | Detection run in pixels, scale never applied | Carry the pixels-per-metre factor from calibrate_scale through every stage; never threshold in pixel space. |
| Scale drift across floor levels | Inconsistent PDF exports or missing reference markers | Compare ppm across floor levels; enforce a global calibration against a constant (standard corridor width 1.5 m). |
| Graph loops or disconnects | Overlapping or unmerged collinear walls | Apply shapely.ops.snap(lines, tol) and raise epsilon_factor in approxPolyDP before building the graph. |
| Mirrored geometry | Y-axis inversion skipped upstream | Fix in parsing, not here — confirm rings are CCW before detection; see the parsing workflows. |
Validation Output
Confirm correctness by asserting on geometry, not by eyeballing a render. The validator below checks that every door midpoint actually sits on the wall run and that navigable corridor space exists at all — the two failure modes that silently break routing.
import logging
from typing import Dict, List
from shapely.geometry import LineString, Point
from shapely.ops import unary_union
logger = logging.getLogger("detect.validate")
def validate_indoor_topology(
walls: List[LineString],
doors: List[Dict],
align_tol_m: float = 0.25,
corridor_half_width_m: float = 0.75,
) -> Dict:
"""Assert door alignment and corridor presence; return a structured report."""
report = {"valid": True, "issues": []}
for door in doors:
on_wall = any(w.distance(Point(door["midpoint"])) < align_tol_m for w in walls)
if not on_wall:
report["issues"].append(f"{door['id']} not aligned with any wall run")
report["valid"] = False
corridor = unary_union([w.buffer(corridor_half_width_m) for w in walls])
if corridor.is_empty:
report["issues"].append("no navigable corridor space detected")
report["valid"] = False
logger.info("Topology validation %s with %d issue(s)",
"passed" if report["valid"] else "FAILED", len(report["issues"]))
return report
A correct single-corridor result asserts cleanly — every door midpoint lies within align_tol_m of a wall and the buffered corridor union is non-empty:
{
"valid": true,
"issues": [],
"doors": 3,
"graph": { "nodes": 3, "edges": 2, "connected": true }
}
A wrong result is easy to catch: a phantom door flags not aligned with any wall run, and an over-aggressive normalization pass that erased the wall mask reports no navigable corridor space detected. Both fail fast in the pipeline rather than shipping a broken graph.
Performance & Scale Notes
Per-floor cost is dominated by the opening detector’s pairwise endpoint scan, which is O(n²) in segment endpoints. On a dense floor level with thousands of wall segments this is the bottleneck, so index endpoints in a shapely.STRtree and query only candidates within max_door_width_m, dropping the practical cost toward O(n log n). Morphological filtering and contour tracing scale linearly with raster pixel count, so down-sampling oversized scans to a working resolution that still resolves a 0.7 m door (roughly 30–40 px/m) cuts memory and time without losing real openings.
For portfolio ingestion, treat each floor level as an independent task and parallelise with a ProcessPoolExecutor over CPU cores; detection holds no cross-floor state until the graph merge. Batch sizes of 8–16 floor levels per worker keep GeoJSON writes amortised without exhausting memory on large rasters. Feed the heavy normalization and vectorization stages through the async batch processing pipelines so a single slow scan never stalls the pool, and gate the merged output before release through CI gating for map updates.
Frequently Asked Questions
Should I rasterize a DWG to reuse the OpenCV detection path?
No. Rasterizing throws away the sub-pixel precision, floor-level metadata, and explicit door blocks that a vector source already provides. Run vector inputs through the SVG/DWG Parsing Workflows and the CAD-specific extraction rules instead; reserve the raster path for scans and flattened PDFs where no vector geometry survives.
How do I stop furniture gaps from being detected as doors?
Constrain by width and context. A real door leaf is 0.7–1.5 m, so the width band rejects most furniture, but two chair legs can still fall in range. The reliable filter is alignment: a door midpoint must sit on a wall run (the validator’s align_tol_m check), whereas furniture gaps float in room interiors away from any wall.
Why do my double-line walls produce two parallel routing edges?
Because contour tracing follows both faces of a hollow wall. Either skeletonize the mask with cv2.ximgproc.thinning() so the wall collapses to a single centreline before vectorizing, or merge the parallel runs with a small buffer-then-unary_union-then-medial-axis pass. The extract_wall_segments merge step handles overlaps but not two genuinely parallel lines, so resolve thickness first.
The connectivity audit reports isolated subgraphs — is detection broken?
Not necessarily. A disconnected component is either a missed doorway (a real bug — lower min_door_width_m or check the wall mask for that span) or a legitimately vertical-only connector such as a lift or stair core. The latter is expected on a single floor level and is reconnected across floors by level mapping and Z-axis logic, not by the planar graph builder.
Related
- Automating wall and door detection in CAD
- SVG/DWG Parsing Workflows
- Attribute Mapping from Blueprints
- Async Batch Processing Pipelines
This page is part of the Automated Floor Plan Parsing & Vectorization section of the Indoor Mapping & Wayfinding Automation reference.