Deskewing Scanned Floor Plans with OpenCV
Deskew is the orientation-correction step that makes every later stage of PDF & raster floor plan extraction trustworthy, and getting it wrong silently corrupts the geometry you trace afterwards.
What Skew Means for a Scanned Plan
Skew is the small rigid rotation a drawing acquires when it is scanned or photographed off-axis — the page was fed a degree crooked, or the phone was not square to the sheet. It is a global affine rotation of the whole raster, not a distortion of any single feature, so the drawing still looks “correct” to a human eye that mentally rotates it.
The problem is that almost everything downstream assumes walls are axis-aligned. A 1.2° skew looks like nothing, but across a 1600-pixel-wide sheet it displaces the far end of a horizontal wall by roughly 33 pixels vertically. Manhattan wall snapping then rounds that sloped run to the wrong grid line; room segmentation splits a single corridor into two; and the probabilistic Hough transform, which bins segments by angle, smears one wall across several orientation bins and under-counts it. Worse, residual skew shortens the projected length of every horizontal and vertical run by a factor of cos(theta), so scale calibration done on a skewed image is systematically wrong even when the reference measurement is perfect. Deskewing first is what lets the rest of the raster branch treat walls as clean horizontal and vertical segments.
Minimal Working Example
Estimate the dominant skew angle from long near-horizontal Hough segments, then rotate about the image centre with an expanded canvas so no corner is clipped. The whole operation is two OpenCV calls wrapped in guards.
import logging
import cv2
import numpy as np
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
def estimate_skew(mask: np.ndarray, max_angle: float = 15.0) -> float:
"""Return the median skew angle (degrees) from long near-horizontal wall runs."""
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 detected; assuming zero skew")
return 0.0
angles = [np.degrees(np.arctan2(y2 - y1, x2 - x1)) for x1, y1, x2, y2 in lines[:, 0]]
near_horizontal = [a for a in angles if abs(a) <= max_angle]
if not near_horizontal:
logger.warning("no near-horizontal runs within +/-%.1f deg", max_angle)
return 0.0
return float(np.median(near_horizontal))
def deskew(mask: np.ndarray) -> np.ndarray:
"""Rotate a binary floor-plan mask level, expanding bounds so nothing is clipped."""
angle = estimate_skew(mask)
h, w = mask.shape
rot = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 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 # re-centre so corners survive
rot[1, 2] += (new_h - h) / 2.0
try:
out = cv2.warpAffine(mask, rot, (new_w, new_h), flags=cv2.INTER_NEAREST,
borderMode=cv2.BORDER_CONSTANT, borderValue=0)
except cv2.error as exc:
logger.exception("warpAffine failed for %dx%d mask: %s", w, h, exc)
raise
logger.info("deskewed by %.3f deg -> %dx%d", angle, new_w, new_h)
return out
Two design choices carry the correctness. The rotation matrix is built around (w/2, h/2), not (0, 0), and the translation terms are adjusted so the enlarged output holds the rotated corners. INTER_NEAREST is deliberate for a binary mask: it keeps pixels strictly 0 or 255, where a bilinear interpolation would introduce grey edge pixels that a later threshold has to clean up.
How Deskew Sits Between Estimate and Rotate
The estimator and the rotation are two separable concerns — you can swap the angle source (Hough versus minAreaRect) without touching the warp, and the warp is identical regardless of how the angle was found.
Deskew Parameter Reference
| Parameter | Typical value | Notes |
|---|---|---|
max_angle (search range) |
15.0 deg |
Clamps the estimate; scans rarely exceed a few degrees, so a wide range only admits outliers. Values beyond this signal a bad estimate, not real skew. |
Hough threshold |
120 |
Minimum votes for a line; raise it on noisy scans so only strong wall runs contribute to the angle. |
Hough minLineLength |
width // 4 |
Restricts the estimator to long structural runs and rejects short text baselines and dimension ticks. |
Hough maxLineGap |
20 px |
Bridges small gaps so a dashed or door-broken wall still votes as one run. |
| Angle aggregation | median |
Median resists the outliers that a mean would let skew the result; a handful of diagonal leaders cannot drag it. |
warpAffine interpolation |
INTER_NEAREST |
Keeps a binary mask strictly 0/255. Use INTER_LINEAR only for grayscale deskew before thresholding. |
borderMode |
BORDER_CONSTANT, value 0 |
Fills the rotated corners with background so they never register as foreground. |
Common Errors & Fixes
Content sliced off after rotation. The rotation was anchored at the origin (0, 0) and written back into the original (w, h) canvas, so half the drawing rotated outside the frame:
# Wrong: origin anchor, original bounds -> corners clipped.
rot = cv2.getRotationMatrix2D((0, 0), angle, 1.0)
out = cv2.warpAffine(mask, rot, (w, h))
# Right: centre anchor, expanded bounds, re-centred translation.
rot = cv2.getRotationMatrix2D((w / 2.0, h / 2.0), angle, 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
rot[1, 2] += (new_h - h) / 2.0
out = cv2.warpAffine(mask, rot, (new_w, new_h))
The sheet ends up rotated 90°. cv2.minAreaRect reports an angle in a limited range and OpenCV changed its convention across versions, so a nearly-level page can be “corrected” by a quarter turn. Normalize the raw angle into [-45, 45] before using it:
(_, _), (rw, rh), raw = cv2.minAreaRect(np.column_stack(np.where(mask.T > 0)))
angle = raw if rw < rh else raw + 90.0 # OpenCV width/height convention
angle = (angle + 45.0) % 90.0 - 45.0 # fold into [-45, 45]
A title-block or text-heavy sheet deskews to the wrong angle. Dense text baselines and a boxed title block outvote the walls in the angle histogram, so the estimator locks onto text orientation instead of structure. Constrain the estimator to long runs and take the median rather than the mode, and if the support-line count is tiny relative to page size, treat the estimate as unreliable and leave the mask unrotated for manual review rather than rotating on weak evidence.
Integration Point
Deskew is the second stage of the raster branch: it runs on the binary mask produced by rendering and binarization, and it must complete before scale calibration and tracing, because a residual tilt shortens every projected wall by cos(theta) and biases the metres-per-pixel factor the tracer depends on. Its output — a level, axis-aligned mask — is what the calibration-and-trace step in PDF & raster floor plan extraction consumes, and the clean horizontal and vertical runs it produces are what let the wall and door detection algorithms separate structural walls from openings without fighting a sloped baseline.
Frequently Asked Questions
Hough lines or minAreaRect — which estimator should I use?
Prefer Hough lines for architectural sheets. Walls give long, strong straight runs that a minLineLength filter isolates cleanly, and the median of their angles is robust. cv2.minAreaRect fits the tightest bounding box around all foreground pixels, which works well for a page that is mostly one solid block of content but is easily thrown off by a title block or a legend that pushes the bounding box off-axis. On a plan with sparse walls and a lot of white space, Hough is both more accurate and easier to reason about.
Why rotate about the image centre instead of a corner?
Rotating about a corner keeps that corner fixed and swings the rest of the page around it, so most of the drawing rotates outside the original frame and gets clipped. Anchoring at the centre keeps the drawing centred, and expanding the output canvas to the rotated bounding box guarantees all four corners land inside it. The extra background margin is harmless — it is filled with the mask’s zero value and gets ignored by tracing.
Does deskewing change the drawing's scale?
No, and that is the point of using a rotation with unit scale (1.0) and re-centring translation only. A pure rotation preserves every distance, so a reference span measured after deskew is the true span. What deskew fixes is the apparent shortening a tilted sheet causes: on a skewed image a horizontal wall reads shorter than it is by cos(theta), which is exactly the error that would otherwise poison scale calibration.
Related
- PDF & Raster Floor Plan Extraction
- Wall & Door Detection Algorithms
- Automated Floor Plan Parsing & Vectorization
This reference is a companion page within PDF & Raster Floor Plan Extraction, part of the wider Automated Floor Plan Parsing & Vectorization section.