PDF & Raster Floor Plan Extraction
Most facilities never hand you a clean vector drawing. They hand you a scanned emergency-egress plan taped inside a fire panel, a photographed A1 sheet, or a PDF that turns out to be a raster image wrapped in a page container. This is the raster-source branch of Automated Floor Plan Parsing & Vectorization: where the vector-native path reads coordinate arrays straight from SVG/DWG parsing workflows, the raster path has to reconstruct geometry from pixels that carry no coordinate system, no scale, and no guarantee the page is even square to the sensor. Everything downstream — wall and door detection, the routing graph, the published map — inherits the errors this stage fails to correct, so the extraction pipeline is where raster indoor mapping is won or lost.
The Problem: Pixels Without Provenance
A vector DWG entity already knows it is a line from (x1, y1) to (x2, y2) in millimetres. A scanned plan knows none of that. Four properties of raster sources make them hostile to naive tracing.
First, skew. A sheet fed through a scanner or shot with a phone lands a fraction of a degree to several degrees off axis. A 1.5° rotation is invisible to a human but shifts a wall 26 pixels across a 1000-pixel span, and any axis-aligned assumption downstream (Manhattan wall snapping, grid-based room segmentation) silently corrupts.
Second, noise. JPEG compression rings every hard edge, scanner dust speckles the background, and half-tone fill patterns in room areas read as thousands of spurious foreground pixels. A Hough transform run on raw scan noise returns hundreds of phantom two-pixel “walls”.
Third, no georeference and no scale. There is no metre-per-pixel factor anywhere in the file. Without recovering one from a known dimension or a printed scale bar, every length you trace is meaningless, and door widths land in the hundreds when the routing graph expects fractions of a metre.
Fourth, line-weight ambiguity. Structural walls, dashed property lines, dimension leaders, and hatch fills all reduce to the same black pixels once binarized. Separating them is the job of the wall and door detection algorithms that consume this stage’s output — but only if this stage hands them clean, deskewed, metrically calibrated polylines.
The extraction pipeline exists to convert that pile of unprovenanced pixels into a MultiLineString in a real metric frame, tagged well enough that the semantic and graph stages can trust it.
Prerequisites & Dependencies
Before the first line is traced, the following must be in place:
- A raster or PDF renderer.
pypdfium2(orpdf2imageover Poppler) rasterizes a chosen PDF page at a controlled DPI. Rendering DPI is a parameter you tune, not a property of the file — it is the single biggest lever on accuracy. - OpenCV (
cv2) and NumPy. All binarization, deskew, morphology, and Hough tracing run onnumpyarrays through the OpenCV bindings. shapely. Traced segments becomeLineString/MultiLineStringgeometry so the output matches the envelope the rest of the pipeline exchanges.- A known reference dimension. At least one real-world length in the drawing must be recoverable: a dimensioned corridor width, a standard door leaf, a gridline spacing, or a printed scale bar. Without one, calibration cannot solve for metres-per-pixel and the run should abort rather than emit pixel-space geometry.
- A target coordinate frame. Calibrated output is expressed in the building’s local metric frame, so it can later be projected into the shared indoor coordinate reference system alongside vector-sourced levels.
How It Works: From Scanned Page to Metric Geometry
The raster branch is a fixed six-stage sequence, and the order is not negotiable: denoising before deskew biases the angle estimator, and tracing before calibration bakes pixel units into the geometry. Each stage narrows the gap between “image of a plan” and “geometry of a building”.
Stages 1 and 3 clean the pixels, stage 2 fixes orientation, stage 4 earns metric units, and stage 5 recovers vectors. The three code steps below implement the load-bearing work: render and binarize, estimate and correct skew, then calibrate and trace.
Step-by-Step Implementation
The three steps are ordered and each hands a typed artifact to the next. Skew correction is deep enough on its own to warrant a dedicated companion reference — deskewing scanned floor plans with OpenCV — so step 2 here shows the pipeline-level integration and defers the estimator internals to that page.
Step 1: Render the PDF page and binarize to a foreground mask
Rasterize the selected page at a controlled DPI, drop to grayscale, and threshold to a clean binary mask where foreground (walls, text, fills) is white on a black background — the polarity OpenCV’s Hough and contour routines expect.
import logging
import cv2
import numpy as np
import pypdfium2 as pdfium
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def render_and_binarize(pdf_path: str, page_index: int = 0, dpi: int = 300) -> np.ndarray:
"""Render one PDF page to a binary foreground mask (uint8, 0/255).
DPI trades accuracy for memory: 200 dpi is a floor for thin walls,
400+ dpi resolves door swings but quadruples pixel count.
"""
scale = dpi / 72.0 # PDF user space is 72 units per inch
try:
pdf = pdfium.PdfDocument(pdf_path)
bitmap = pdf[page_index].render(scale=scale, grayscale=True)
except (pdfium.PdfiumError, IndexError) as exc:
logger.exception("could not render %s page %d: %s", pdf_path, page_index, exc)
raise
gray = np.asarray(bitmap.to_numpy(), dtype=np.uint8)
if gray.ndim == 3: # some backends return HxWx1
gray = gray[:, :, 0]
# Otsu picks the threshold; INV so foreground ink becomes white for morphology/Hough.
thresh, mask = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)
logger.info("rendered %s @ %d dpi -> %s mask, otsu=%.0f", pdf_path, dpi, mask.shape, thresh)
return mask
Otsu is the right default here because architectural scans are strongly bimodal — dark ink on a light ground — so an automatically chosen global threshold beats a hand-tuned constant that fails the moment a different scanner or exposure arrives. The THRESH_BINARY_INV flag matters as much as the threshold value: OpenCV’s morphology and Hough routines treat non-zero pixels as foreground, so inverting at binarization time means walls are white and the page is black, and every later stage reads the polarity it expects without a second pass.
Step 2: Estimate skew and rotate about the centre
Measure the dominant near-horizontal edge angle, then rotate the whole mask about its centre with an expanded output canvas so no content is clipped. Rotating about the origin instead of the centre is the classic bug that slices the drawing in half; the companion reference dissects both that and the estimator itself.
import logging
import cv2
import numpy as np
logger = logging.getLogger(__name__)
def deskew_mask(mask: np.ndarray, max_angle: float = 15.0) -> np.ndarray:
"""Estimate skew from dominant Hough lines and rotate the mask level.
Returns the mask rotated about its centre with bounds expanded so the
corners are never clipped. Angles beyond +/-max_angle are treated as a
failed estimate and the mask is returned unrotated.
"""
lines = cv2.HoughLinesP(mask, 1, np.pi / 180, threshold=120,
minLineLength=mask.shape[1] // 4, maxLineGap=20)
if lines is None:
logger.warning("no lines for skew estimate; leaving mask unrotated")
return mask
angles = []
for x1, y1, x2, y2 in lines[:, 0]:
deg = np.degrees(np.arctan2(y2 - y1, x2 - x1))
if abs(deg) <= max_angle: # keep near-horizontal runs only
angles.append(deg)
if not angles:
logger.warning("no near-horizontal lines within +/-%.1f deg; skipping", max_angle)
return mask
skew = float(np.median(angles)) # median resists dimension-leader outliers
h, w = mask.shape
center = (w / 2.0, h / 2.0)
rot = cv2.getRotationMatrix2D(center, skew, 1.0)
cos, sin = abs(rot[0, 0]), abs(rot[0, 1])
new_w, new_h = int(h * sin + w * cos), int(h * cos + w * sin)
rot[0, 2] += (new_w - w) / 2.0 # shift so nothing is clipped
rot[1, 2] += (new_h - h) / 2.0
deskewed = cv2.warpAffine(mask, rot, (new_w, new_h), flags=cv2.INTER_NEAREST,
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
logger.info("deskewed by %.3f deg (%d support lines)", skew, len(angles))
return deskewed
Two guards keep this robust on real scans. Restricting the estimate to near-horizontal runs within max_angle stops diagonal dimension leaders and stair nosings from dragging the angle, and taking the median rather than the mean means a handful of stray segments cannot move the result. When the estimator finds no usable support — a mask that is mostly text, or a nearly blank sheet — it returns the mask untouched rather than rotating on weak evidence, which is the conservative choice: a wrong rotation is far more damaging than a missed one-degree correction.
Step 3: Calibrate scale and trace walls into metric shapely geometry
Recover metres-per-pixel from one known reference span, denoise the mask with a morphological opening, then run the probabilistic Hough transform and scale every endpoint into the local metric frame as it becomes shapely geometry.
import logging
import cv2
import numpy as np
from shapely.affinity import scale as shp_scale
from shapely.geometry import LineString, MultiLineString
logger = logging.getLogger(__name__)
def trace_walls_metric(
mask: np.ndarray,
ref_pixel_length: float,
ref_metres: float,
min_wall_m: float = 0.3,
) -> MultiLineString:
"""Denoise, Hough-trace, and scale wall segments into a metric MultiLineString.
ref_pixel_length / ref_metres define metres-per-pixel from one known dimension
(a scale bar, a dimensioned corridor, or a standard door leaf).
"""
if ref_pixel_length <= 0 or ref_metres <= 0:
raise ValueError("reference length and metres must both be positive")
m_per_px = ref_metres / ref_pixel_length
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
clean = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1) # kill speckle
try:
segments = cv2.HoughLinesP(clean, 1, np.pi / 180, threshold=60,
minLineLength=25, maxLineGap=8)
except cv2.error as exc:
logger.exception("Hough trace failed on mask %s: %s", clean.shape, exc)
raise
if segments is None:
logger.warning("no wall segments traced; returning empty geometry")
return MultiLineString()
walls: list[LineString] = []
min_px = min_wall_m / m_per_px
for x1, y1, x2, y2 in segments[:, 0]:
line = LineString([(float(x1), float(y1)), (float(x2), float(y2))])
if line.length >= min_px:
walls.append(line)
metric = MultiLineString(walls)
metric = shp_scale(metric, xfact=m_per_px, yfact=m_per_px, origin=(0, 0))
logger.info("traced %d walls, m_per_px=%.5f", len(walls), m_per_px)
return metric
The order inside this step is deliberate. The morphological opening runs before the Hough pass so that speckle and hatch fill never become vote-carrying pixels, which both cleans the output and speeds the transform. Scaling is applied last, once, to the assembled MultiLineString — never per segment — so a single metres-per-pixel factor governs the whole level and there is no chance of two segments ending up in different units. The min_wall_m filter is expressed in metres and converted back to pixels through the same factor, so the smallest wall you keep is defined in real-world terms rather than as an arbitrary pixel count that would change meaning with DPI.
Edge Cases & Gotchas
| Symptom | Root cause | Diagnostic | Remediation |
|---|---|---|---|
| Every traced length is 100x too large | Tracing ran in pixel space; calibration skipped or reference length wrong | Compare a known door width against the traced width | Recover m_per_px from a verified reference before scaling; abort if none exists |
| Hundreds of two-pixel phantom walls | JPEG ringing and scanner speckle survived into the Hough pass | Count segments shorter than min_wall_m |
Raise the morphological open iterations and the Hough threshold; keep DPI high enough that real walls clear the noise floor |
| Dashed property lines traced as walls | Dashes read as short collinear segments; maxLineGap bridged them |
Overlay traced lines on the source at the boundary | Widen the gap only for structural runs; defer dash classification to wall and door detection |
| Skew estimate wildly off on a title-heavy sheet | Dense text baselines dominate the angle histogram | Log the count of support lines vs. page area | Restrict the estimator to long near-horizontal runs (minLineLength = width/4) and take the median |
| Walls broken into dotted fragments | Rendering DPI too low; thin walls fell below one pixel | Inspect wall stroke width in pixels at the chosen DPI | Re-render at 300-400 dpi; thin architectural strokes need >=2 px to survive binarization |
Validation Output
The stage is only trustworthy if a traced dimension matches the real building within tolerance. Assert it against the known reference before the geometry is allowed downstream:
from shapely.geometry import MultiLineString
def assert_scale_recovered(walls: MultiLineString, known_span_m: float, tol: float = 0.03) -> None:
"""Fail loudly if the longest traced wall diverges from a known span beyond tol (fraction)."""
longest = max((seg.length for seg in walls.geoms), default=0.0)
rel_err = abs(longest - known_span_m) / known_span_m
assert rel_err <= tol, f"scale off by {rel_err:.1%} (traced {longest:.2f} m vs {known_span_m:.2f} m)"
# A correctly calibrated A1 corridor scan: longest run ~24 m, known 24.0 m.
walls = trace_walls_metric(mask, ref_pixel_length=1180.0, ref_metres=24.0)
assert_scale_recovered(walls, known_span_m=24.0, tol=0.03)
assert len(walls.geoms) > 0
A relative error above a few percent almost always means the reference span was mismeasured or the page was never deskewed — the two failures compound, because residual skew shortens every projected length.
Performance & Scale Notes
- DPI is the master dial. Accuracy climbs with DPI until walls are comfortably multi-pixel, then flattens while memory keeps growing quadratically. 300 dpi is a sensible default for A1/A0 architectural sheets; drop to 200 dpi only for coarse egress plans.
- Binarization dominates small jobs, Hough dominates large ones. Otsu is linear in pixels;
HoughLinesPcost grows with foreground density, so aggressive denoising before tracing is a speed optimization as much as a quality one. - Batch by page, not by document. Multi-page PDFs (one floor per page) parallelize cleanly — render each page in its own worker so a 40-floor portfolio scan fans out across cores. This is exactly the workload the async batch processing pipelines are built to schedule.
- Cache the deskew angle, not the rotated raster. Storing the estimated angle and reference calibration per sheet lets a re-run skip re-estimation, which is the expensive Hough pass.
- Fail fast on missing calibration. A page with no recoverable reference should be routed to review immediately rather than emitting pixel-space geometry that silently poisons the routing graph.
Frequently Asked Questions
How do I recover scale when the plan has no printed scale bar?
Use any dimension you can trust. A dimensioned corridor width, a gridline spacing called out in the title block, or a standard door leaf (0.9 m is a safe default in most jurisdictions) all give you a pixel length paired with a real length, which is all m_per_px needs. If nothing at all is recoverable, do not guess — flag the sheet as non-routable and queue it for a human to annotate one reference span, because an invented scale corrupts every weight in the routing graph.
Should I deskew before or after denoising?
Deskew first, on the binarized mask, then denoise. The skew estimator relies on long straight wall runs, and an aggressive morphological opening can fragment thin walls into dashes that weaken the angle histogram. Correcting orientation on the fuller mask gives the estimator more support lines; the denoise pass then cleans the already-level image before tracing.
Why is the probabilistic Hough transform preferred over standard Hough here?
HoughLinesP returns finite segments with real endpoints rather than infinite rho-theta lines, which is exactly what wall geometry needs — a wall has a start and an end, and door openings are gaps between segments. It also lets you gate directly on minLineLength and maxLineGap, so short noise fragments and over-bridged dashes are controllable parameters rather than post-processing afterthoughts.
Can this branch and the vector branch share a downstream pipeline?
Yes, and that is the design goal. Both branches converge on the same metric MultiLineString before semantic work begins, so wall and door detection, graph construction, and validation never need to know whether a level came from a scan or a DWG. The only raster-specific stages are the ones on this page; once geometry is calibrated and traced, it is indistinguishable from vector-sourced geometry.
Related
- Deskewing Scanned Floor Plans with OpenCV
- SVG/DWG Parsing Workflows
- Wall & Door Detection Algorithms
- Indoor Coordinate Reference Systems
This reference is part of the Automated Floor Plan Parsing & Vectorization section — it is the raster-source counterpart to the vector-native parsing workflows in the same collection.