Friday, October 30, 2026

Python Document Scanner: Detect Corners and Correct Perspective

A tilted page can become a rectangular scan without training a neural network. The useful idea is to find a plausible page boundary, keep its four corners in a consistent order, and map them onto a rectangle.

This walkthrough builds a small Python document scanner with OpenCV. It runs locally on a CPU and saves both a color result and an optional black-and-white view. There is no model checkpoint and no OCR. The example uses an explicitly synthetic document scene from my course, so a clean result here is a demonstration of the pipeline rather than evidence that every photograph will work.

Watch the working example

Watch the complete Python and OpenCV scanner walkthrough, or see the quick before-and-after demonstration.

Actual OpenCV perspective correction: synthetic tilted page input beside the rectangular color scan
Same input pixels, transformed using four detected corners. The result is 740 × 523 pixels in this execution.

What does the scanner actually do?

  1. Convert the image to grayscale and blur small variations.
  2. Use Canny edges and morphological closing to prepare boundaries.
  3. Select the largest qualifying convex four-corner contour.
  4. Order those corners and calculate a perspective transform.
  5. Save the flattened color image and an optional thresholded view.

OpenCV's contour documentation explains how contours represent boundaries in a binary image. A boundary is a shape, not an understanding of the object. That distinction is the main limitation to keep in mind.

Prepare Python, OpenCV and one local image

Save the full code below as scanner.py. Place document_scene.png beside it to use the demonstrated course input, available in lesson 53. Alternatively, supply the path to an image you have permission to use. The example does not download or silently replace your image.

On Windows with Python 3.13 installed, create a separate environment and install the two demonstrated package versions:

py -3.13 -m venv .venv
.venv\Scripts\python.exe -m pip install numpy==2.3.5 opencv-python==4.13.0.92
.venv\Scripts\python.exe scanner.py

To process your own file instead of the default input:

.venv\Scripts\python.exe scanner.py "C:\images\my_page.jpg"

The measured execution used Python 3.13.9, NumPy 2.3.5 and OpenCV 4.13.0.92 on Windows. These commands describe that setup; they are not a fresh-install test of every operating system. Package installation may require internet access. Once the packages and image are present, this scanner needs no network connection, model download or GPU.

Complete runnable scanner

# Copyright (c) 2026 ARTSCI - Author of the AI/ML/CV course materials.
"""Straighten a page candidate with local OpenCV operations; no trained model."""
from pathlib import Path
import sys
import cv2
import numpy as np


def edge_map(image, low=50, high=150):
    if not 0 <= low < high:
        raise ValueError("Require 0 <= low < high.")
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
    blurred = cv2.GaussianBlur(gray, (5, 5), 0)
    edges = cv2.Canny(blurred, low, high)
    return cv2.morphologyEx(edges, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))


def find_page(edges):
    contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
    for contour in sorted(contours, key=cv2.contourArea, reverse=True):
        perimeter = cv2.arcLength(contour, True)
        candidate = cv2.approxPolyDP(contour, 0.02 * perimeter, True)
        large = cv2.contourArea(candidate) >= 0.10 * edges.size
        if len(candidate) == 4 and large and cv2.isContourConvex(candidate):
            return candidate.reshape(4, 2).astype(np.float32)
    return None


def order_corners(points):
    points = np.asarray(points, dtype=np.float32).reshape(4, 2)
    if not np.isfinite(points).all() or len(np.unique(points, axis=0)) != 4:
        raise ValueError("Four distinct finite corners are required.")
    center = points.mean(axis=0)
    angles = np.arctan2(points[:, 1] - center[1], points[:, 0] - center[0])
    ordered = points[np.argsort(angles)]
    if not cv2.isContourConvex(ordered) or abs(cv2.contourArea(ordered)) < 1:
        raise ValueError("Corners must form a non-degenerate convex quadrilateral.")
    start = np.argmin(ordered.sum(axis=1))
    return np.roll(ordered, -start, axis=0)


def flatten_page(image, corners):
    tl, tr, br, bl = corners
    width = max(2, round(max(np.linalg.norm(tr - tl), np.linalg.norm(br - bl))))
    height = max(2, round(max(np.linalg.norm(bl - tl), np.linalg.norm(br - tr))))
    target = np.float32([[0, 0], [width-1, 0], [width-1, height-1], [0, height-1]])
    matrix = cv2.getPerspectiveTransform(np.float32(corners), target)
    return cv2.warpPerspective(image, matrix, (width, height))


def document_mode(photo):
    gray = cv2.cvtColor(photo, cv2.COLOR_BGR2GRAY)
    blurred = cv2.GaussianBlur(gray, (3, 3), 0)
    normalized = cv2.normalize(blurred, None, 0, 255, cv2.NORM_MINMAX)
    return cv2.adaptiveThreshold(normalized, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                                 cv2.THRESH_BINARY, 31, 15)


def main():
    root = Path(__file__).resolve().parent
    source = Path(sys.argv[1]) if len(sys.argv) > 1 else root / "document_scene.png"
    if not source.is_file():
        raise FileNotFoundError(f"Required local image: {source}")
    image = cv2.imdecode(np.fromfile(source, dtype=np.uint8), cv2.IMREAD_COLOR)
    if image is None:
        raise ValueError(f"Cannot decode image: {source}")
    edges = edge_map(image)
    candidate = find_page(edges)
    if candidate is None:
        raise ValueError("No qualifying page candidate. Check the image and thresholds.")
    corners = order_corners(candidate)
    photo = flatten_page(image, corners)
    binary = document_mode(photo)
    output = root / "output"
    output.mkdir(exist_ok=True)
    for name, pixels in [("edges", edges), ("photo", photo), ("document", binary)]:
        if not cv2.imwrite(str(output / f"{name}.png"), pixels):
            raise OSError(f"Could not save {name}.png")
    print("Corners [TL, TR, BR, BL]:", corners.astype(int).tolist())
    print(f"Scan: {photo.shape[1]} x {photo.shape[0]} pixels")
    print("Saved edges.png, photo.png and document.png in output/")


if __name__ == "__main__":
    main()

Sample code © 2026 ARTSCI — Author of the AI/ML/CV course materials. OpenCV and NumPy retain their own licenses. OpenCV 4.5 and later use Apache License 2.0; NumPy's license is documented separately. This example uses no Ultralytics software or model weights.

Why grayscale, blur and Canny come first

edge_map converts the input to grayscale and applies a 5 × 5 Gaussian blur. Canny uses thresholds 50 and 150 in this run. Closing with a 5 × 5 kernel can connect small gaps, making a boundary easier to follow. These values are starting parameters, not settings that are guaranteed to work for all lighting and backgrounds.

The edge image includes text and background details as well as the page boundary. find_page sorts contours by area, approximates each polygon with a tolerance of 2% of its perimeter, then accepts the first convex candidate with four corners and at least 10% of the image area. Always inspect that selected boundary before interpreting the output as a document scan.

Actual selected page boundary and four ordered corner labels TL, TR, BR and BL on the synthetic input
Detected corners for this input: TL=(199,156), TR=(818,127), BR=(884,643), BL=(145,676). The yellow outline is the selected candidate.

Corner order controls the perspective mapping

The source and destination corners must correspond. Here the order is top left, top right, bottom right, bottom left. The code sorts points around their center and rotates that sequence so the point with the smallest x+y comes first. It also rejects duplicate, nonfinite and degenerate corner sets.

This ordering assumes a roughly upright page. It does not identify which way the text should face. Strongly rotated or ambiguous pages need additional orientation handling. Mixing corner correspondence can twist or flip the output even when all four points lie near a page.

cv2.getPerspectiveTransform calculates the mapping from the four input points to the four rectangle points. cv2.warpPerspective resamples the image using that mapping. See the official perspective transformation tutorial for the source-to-destination point relationship.

Read the actual output

Corners [TL, TR, BR, BL]: [[199, 156], [818, 127], [884, 643], [145, 676]]
Scan: 740 x 523 pixels
Saved edges.png, photo.png and document.png in output/

The printed 740 × 523 dimensions are a result of this image and the edge-length rule in flatten_page. They are not a known physical paper size. If the real page dimensions matter, supply that prior information and choose the target aspect ratio accordingly.

The actual 740 by 523 pixel color output of the perspective transform
Flattened color output. Perspective correction does not recover blurred characters or remove a curved page's three-dimensional shape.

Black and white is an optional view, not OCR

document_mode converts the flattened image to grayscale, blurs it, normalizes its intensity range and applies adaptive Gaussian thresholding with a 31-pixel neighborhood and C=15. A local threshold can help with brightness variation, but it can also discard fine detail or amplify noise. Compare this output with the color scan before deciding which one to keep.

Actual black-and-white adaptive threshold result from the same flattened synthetic page
A document-style image. No text has been recognized or exported as characters.

When a convincing scan can still be wrong

A large picture frame, desk mat or other convex four-corner object can satisfy exactly the same rule as a page. In a separate controlled test, the algorithm accepted a plain large rectangle that was not a document. A blank image produced no candidate. Those outcomes are useful checks, but neither establishes general document-detection accuracy.

For your own input, inspect the selected outline, check all four corners and compare the output with the original. Test shadows, low-contrast borders, clutter and rotation. If the script reports no qualifying page, investigate the input and parameters; do not silently crop an arbitrary region and call it a successful scan.

This project demonstrates classical image geometry. If you want to compare it with learned detection, see my first working YOLO object-detection example. My English course, Computer Vision: OpenCV, Machine Learning and YOLO, develops these operations through notebooks and practical applications, including the complete Document Scanner project.

No comments:

Post a Comment