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 flow: estimate the angle, then rotate about the centre A skewed binary mask enters an angle-estimation stage that offers two interchangeable methods: a Hough-lines median of near-horizontal runs, or a cv2.minAreaRect on the foreground point cloud. Either method yields one skew angle in degrees. That angle drives a centre-anchored warpAffine with expanded output bounds, which produces a level, axis-aligned mask ready for scale calibration and tracing. Estimate the angle, then rotate about the centre skewed mask binary, off-axis angle estimator Hough median near-horizontal runs minAreaRect foreground cloud warpAffine centre + expand level mask axis-aligned angle

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.

This reference is a companion page within PDF & Raster Floor Plan Extraction, part of the wider Automated Floor Plan Parsing & Vectorization section.