What You're Building

Over the last nine parts of this path you have picked up the pieces one at a time: how a camera turns light into pixels, how colour spaces describe those pixels, how to read and display images with OpenCV, how blurring cleans up noise, how edges and contours trace out shapes, how thresholds and masks separate foreground from background, and how to work with a live video stream. This project puts all of those pieces to work on one practical task: turning a photo of a piece of paper on a desk into a clean, flat, properly cropped scan, the kind you would get from a flatbed scanner or a phone scanning app.

The task breaks down into ideas you already know, used in a new order: find edges, find contours, pick the contour that looks like a four-cornered page, warp that four-cornered shape so it fills a straight rectangle, and finally clean up the lighting with a threshold so the result reads like ink on paper rather than a photograph of paper. Nothing here is a new OpenCV concept on its own. It is the previous parts assembled into a pipeline, and that is also the main lesson of this project: most real computer vision tools are not one clever trick, they are several ordinary steps chained together, each one doing a small, well-understood job.

By the end you will have a script that takes a photograph of a document and produces a flattened, cleaned-up version of it, and you will understand every line well enough to adapt it, for example to scan receipts, whiteboards or book pages. We will build it incrementally, checking the output at each stage, rather than writing the whole thing in one go and hoping it works.

What You Need

  • Python 3.8 or newer
  • opencv-python 4.x (this article was written and tested against the 4.x series; the function names used here have been stable throughout it)
  • numpy
  • A webcam or phone camera is optional. The exercise works end to end on a generated test image, and once it works you can point it at your own photos

Install the two libraries with pip, ideally inside a virtual environment so this project does not interfere with anything else on your machine.

💻Bash
pip install opencv-python numpy

Confirm the install worked by importing cv2 and printing its version. The exact patch version matters less than making sure it imports without errors.

🐍Python
import cv2
print(cv2.__version__)

Step 0: Getting a Test Image

The scanner needs a photo of a page that was taken from an angle, not straight above, so that in the photo the page looks like a quadrilateral (a four-sided shape with no right angles) rather than a perfect rectangle. That tilt is exactly the thing the perspective transform later in this project will undo.

If you want to use your own photo: print or find any sheet of paper with some dark text or lines on it, place it on a surface that contrasts with the paper, such as a wooden desk or a dark mat, rather than another white surface. Take a photo with your phone or webcam from a slight angle, a few steps back so the whole page is inside the frame, and copy that photo into your project folder, for example as document_photo.png.

If you do not have a camera handy, or you simply want a known-good starting point before trying a real photo, you can generate a synthetic test photo entirely in code. It draws a white page with some printed-looking lines on a darker desk, then warps the whole scene the way a camera at an angle would, so the page comes out looking like a tilted quadrilateral, which is exactly the situation the scanner has to handle. This is useful because it gives you a reproducible test case that does not depend on your lighting or your camera.

🐍Python
import cv2
import numpy as np

width, height = 800, 600

# A plain, slightly textured "desk" background
canvas = np.full((height, width, 3), (60, 65, 70), dtype=np.uint8)
noise = np.random.randint(0, 20, (height, width, 3)).astype(np.uint8)
canvas = cv2.subtract(canvas, noise)  # subtract with saturation, so values never wrap below 0

# A white "page" with some printed-looking content
page = np.full((500, 350, 3), 255, dtype=np.uint8)
cv2.putText(page, "INVOICE", (80, 60), cv2.FONT_HERSHEY_SIMPLEX, 1.1, (0, 0, 0), 2)
for y in range(120, 460, 40):
    cv2.line(page, (30, y), (320, y), (40, 40, 40), 2)

# Place the page roughly in the middle of the desk
x0, y0 = (width - 350) // 2, (height - 500) // 2
canvas[y0:y0 + 500, x0:x0 + 350] = page

# Simulate photographing the desk from an angle: warp the whole scene so
# the page's straight edges become the tilted quadrilateral a real camera
# would actually capture.
src = np.float32([[0, 0], [width, 0], [width, height], [0, height]])
dst = np.float32([[40, 90], [width - 30, 15], [width - 70, height - 25], [70, height - 80]])
matrix = cv2.getPerspectiveTransform(src, dst)
photo = cv2.warpPerspective(canvas, matrix, (width, height), borderValue=(60, 65, 70))

cv2.imwrite("document_photo.png", photo)
print("Saved document_photo.png")

Walking through it: the first block builds a dark, mildly noisy background to stand in for a desk. cv2.subtract is used rather than plain NumPy subtraction because it saturates at zero instead of wrapping around to a very large number the way unsigned 8-bit integers would if you subtracted past zero with ordinary arithmetic, which would turn a dark pixel into a bright speck. The second block builds a plain white rectangle and draws a fake title and a few lines on it with cv2.putText and cv2.line, just enough content so the page is not a featureless blank. The third block pastes that page into the canvas using plain NumPy array slicing, the same indexing you used back when you first read images as arrays. The last block is the interesting part: cv2.getPerspectiveTransform computes a transformation matrix that maps the four corners of the canvas to four new, closer-together points, and cv2.warpPerspective applies that matrix to the whole image. The result looks like a photo taken from an angle, with the page appearing as a tilted quadrilateral rather than a straight rectangle.

Run this script and you should see document_photo.png appear in your folder. Open it with cv2.imshow (as in part 4) or your usual image viewer. You should see a tilted white rectangle with a fake title and some lines, sitting on a mottled dark background, with its corners nowhere near the corners of the image. That is the input the rest of this project will work on.

The Scanning Pipeline, Step by Step

From here on, the pipeline has six stages: load the photo and shrink a working copy, convert to greyscale and find edges, find the contour that looks like a four-cornered page, put its four corners in a known order, warp that quadrilateral into a straight rectangle, and finally threshold the result so it reads like a scan rather than a photograph. We will build and check each stage separately before assembling the complete script.

Step 1: Load the Photo and Resize It for Processing

🐍Python
import cv2

image = cv2.imread("document_photo.png")
if image is None:
    raise FileNotFoundError(
        "document_photo.png not found; run the generator script first, "
        "or point this at your own photo."
    )

original = image.copy()

# Work on a smaller copy for speed and more reliable edge detection.
# Remember the scale factor so we can map coordinates back to full
# resolution later.
target_height = 500.0
scale = target_height / image.shape[0]
resized = cv2.resize(image, (int(image.shape[1] * scale), int(target_height)))

print("Original size:", image.shape[:2])
print("Resized size:", resized.shape[:2])
print("Scale factor:", scale)

Two things are worth noticing here. First, cv2.imread returns None rather than raising an error if the file is missing or unreadable, which is why it is checked explicitly rather than letting a confusing error appear several lines later. Second, we keep two copies of the image: original, at full resolution, and resized, shrunk down so its height is 500 pixels. Edge detection and contour finding are more reliable and much faster on a smaller image, where small texture and noise matter less relative to the size of the page. But we do not want to warp the small, slightly blurry resized copy for the final output, we want to warp the full-resolution original, so the scanned result is sharp. The scale factor is what lets us convert coordinates found on the small image back to the right coordinates on the big one, and we will use it again in step 5.

Step 2: Greyscale, Blur and Edges

🐍Python
import cv2

image = cv2.imread("document_photo.png")
original = image.copy()

target_height = 500.0
scale = target_height / image.shape[0]
resized = cv2.resize(image, (int(image.shape[1] * scale), int(target_height)))

gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blurred, 75, 200)

cv2.imwrite("step_edges.png", edges)
print("Saved step_edges.png. Open it and check the page outline forms a clean, closed loop.")

This is the same sequence of operations from parts 3, 5 and 6, used together for a reason. Converting to greyscale with cv2.cvtColor collapses the three colour channels into one, which is all Canny needs, since it is looking for changes in brightness rather than changes in colour. The Gaussian blur smooths away small-scale noise, such as the wood-grain-like texture on our fake desk, so that Canny does not mistake it for a genuine edge. Canny itself finds the strong, sustained intensity changes in the image, which is exactly what the boundary between the pale page and the darker desk looks like. The two numbers passed to Canny, 75 and 200, are the low and high thresholds that control how strong a change in brightness has to be before it counts as part of an edge; they are reasonable starting values for this kind of high-contrast scene, not universal constants, and you may need to raise or lower them for a real photo with softer lighting. Open step_edges.png and look for a continuous white outline around the page. If the outline has gaps, the next step is less likely to succeed, and the troubleshooting section later in this article covers what to try.

Step 3: Find the Page's Outline Among the Contours

📃Plain Text
import cv2

image = cv2.imread("document_photo.png")
original = image.copy()

target_height = 500.0
scale = target_height / image.shape[0]
resized = cv2.resize(image, (int(image.shape[1] * scale), int(target_height)))

gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blurred, 75, 200)

contours, _ = cv2.findContours(edges.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5]

page_contour = None
for c in contours:
    perimeter = cv2.arcLength(c, True)
    approx = cv2.approxPolyDP(c, 0.02 * perimeter, True)
    if len(approx) == 4:
        page_contour = approx
        break

if page_contour is None:
    raise RuntimeError("No four-cornered outline found. See the troubleshooting section.")

outline_preview = resized.copy()
cv2.drawContours(outline_preview, [page_contour], -1, (0, 255, 0), 3)
cv2.imwrite("step_outline.png", outline_preview)
print("Found corners on the resized image:")
print(page_contour.reshape(4, 2))

cv2.findContours, as covered in part 6, traces the boundaries of connected white regions in a binary image. In OpenCV 4.x it returns two values, contours and hierarchy; older OpenCV 3.x returned three, so if you are following along with an older version this line will need adjusting. We pass edges.copy() rather than edges itself, which is a habit worth keeping even though current versions of OpenCV are generally better behaved about this than older ones were; it costs almost nothing and protects you from surprises if you want to reuse the original edge map afterwards. cv2.RETR_LIST retrieves every contour without worrying about nesting relationships, and cv2.CHAIN_APPROX_SIMPLE compresses straight contour segments down to their end points instead of storing every pixel along them, both as introduced in part 6.

We then sort the contours by area, largest first, and only look at the top five. This is a deliberate shortcut: a page photographed so that it fills a good part of the frame should be one of the largest shapes in the scene, so there is no point checking every tiny contour that Canny also happened to find, such as the fake title text or the lines we drew on the page. For each of those five candidates, cv2.arcLength measures its perimeter, and cv2.approxPolyDP simplifies the contour into straight line segments, keeping only the corners that matter, within a tolerance set as 2 percent of the perimeter. A true rectangle, even a photographed and slightly tilted one, usually simplifies down to exactly four points under this tolerance, so the first candidate that reduces to four points is taken to be the page, and the loop stops there.

If none of the top five contours simplifies to four points, page_contour stays None and the script raises an error rather than silently producing a wrong answer. That is a deliberate choice: a document scanner that confidently warps the wrong shape is worse than one that tells you it could not find a page.

Step 4: Put the Four Corners in a Known Order

approxPolyDP hands back four corner points in whatever order it happened to encounter them while walking the contour, which could start at any one of the four corners and go either clockwise or counter-clockwise. To build a correct perspective transform in the next step, we need to know, consistently, which point is the top-left corner, which is top-right, which is bottom-right and which is bottom-left. Get this wrong and the warped result can come out flipped, mirrored, or rotated ninety degrees, even though the shape it found was perfectly correct.

🐍Python
import numpy as np

def order_points(pts):
    pts = pts.reshape(4, 2).astype("float32")
    rect = np.zeros((4, 2), dtype="float32")

    s = pts.sum(axis=1)
    rect[0] = pts[np.argmin(s)]   # top-left: smallest x + y
    rect[2] = pts[np.argmax(s)]   # bottom-right: largest x + y

    diff = np.diff(pts, axis=1)
    rect[1] = pts[np.argmin(diff)]  # top-right: smallest y - x
    rect[3] = pts[np.argmax(diff)]  # bottom-left: largest y - x

    return rect

# A worked example: four corners found in an arbitrary order
example = np.array([[50, 200], [300, 180], [320, 420], [40, 430]])
print("Input order:\n", example)
print("Ordered top-left, top-right, bottom-right, bottom-left:\n", order_points(example))

The trick relies on two facts about image coordinates, where x grows to the right and y grows downward. The top-left corner has both a small x and a small y, so its sum x plus y is the smallest of the four points; the bottom-right corner has both a large x and a large y, so its sum is the largest. That separates two of the four corners immediately. The remaining two, top-right and bottom-left, both have one large coordinate and one small one, so their sums are similar, but their difference, y minus x, tells them apart: top-right has a large x and small y, making y minus x strongly negative, while bottom-left has a small x and large y, making y minus x strongly positive.

Working through the numbers in the example makes this concrete. The four points are (50, 200), (300, 180), (320, 420) and (40, 430). Their sums are 250, 480, 740 and 470. The smallest sum, 250, belongs to (50, 200), so that becomes the top-left corner, and the largest, 740, belongs to (320, 420), the bottom-right corner. For the remaining two, the differences y minus x are 150 for (50, 200), negative 120 for (300, 180), 100 for (320, 420) and 390 for (40, 430); restricting attention to the two points not already placed, (300, 180) and (40, 430), the smaller difference belongs to (300, 180), which becomes the top-right corner, and the larger belongs to (40, 430), which becomes the bottom-left corner. Running the code above confirms exactly that ordering, and it will do the same for any four corners you feed it, which is why this small function is doing real, useful work even though it contains no OpenCV calls at all.

Step 5: Warp to a Straight Rectangle

A perspective transform, sometimes called a homography, answers a specific question: given four points in a photo and where you would like those same four points to end up, what single transformation maps every other point in the image consistently along with them? It is more flexible than a simple resize, crop or rotation because it can account for the fact that, in a tilted photo, one edge of the page is physically closer to the camera than the other, so it appears larger. The transform stretches the far, smaller-looking corners outward and compresses the near, larger-looking ones, so that the result looks as if the page had been photographed flat-on, directly from above.

📃Plain Text
import cv2
import numpy as np

def order_points(pts):
    pts = pts.reshape(4, 2).astype("float32")
    rect = np.zeros((4, 2), dtype="float32")
    s = pts.sum(axis=1)
    rect[0] = pts[np.argmin(s)]
    rect[2] = pts[np.argmax(s)]
    diff = np.diff(pts, axis=1)
    rect[1] = pts[np.argmin(diff)]
    rect[3] = pts[np.argmax(diff)]
    return rect

def four_point_transform(image, pts):
    rect = order_points(pts)
    (tl, tr, br, bl) = rect

    width_a = np.linalg.norm(br - bl)
    width_b = np.linalg.norm(tr - tl)
    max_width = int(max(width_a, width_b))

    height_a = np.linalg.norm(tr - br)
    height_b = np.linalg.norm(tl - bl)
    max_height = int(max(height_a, height_b))

    destination = np.array([
        [0, 0],
        [max_width - 1, 0],
        [max_width - 1, max_height - 1],
        [0, max_height - 1]], dtype="float32")

    matrix = cv2.getPerspectiveTransform(rect, destination)
    warped = cv2.warpPerspective(image, matrix, (max_width, max_height))
    return warped

# Reuse steps 1-3 to find the page, then warp the FULL-resolution original
image = cv2.imread("document_photo.png")
original = image.copy()

target_height = 500.0
scale = target_height / image.shape[0]
resized = cv2.resize(image, (int(image.shape[1] * scale), int(target_height)))

gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blurred, 75, 200)

contours, _ = cv2.findContours(edges.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5]

page_contour = None
for c in contours:
    perimeter = cv2.arcLength(c, True)
    approx = cv2.approxPolyDP(c, 0.02 * perimeter, True)
    if len(approx) == 4:
        page_contour = approx
        break

if page_contour is None:
    raise RuntimeError("No four-cornered outline found. See the troubleshooting section.")

# The corners were found on the small resized image; scale them back up
# to match the full-resolution original before warping it.
corners_resized = page_contour.reshape(4, 2).astype("float32")
corners_original = corners_resized / scale

warped = four_point_transform(original, corners_original)
cv2.imwrite("step_warped.png", warped)
print("Saved step_warped.png. The page should now look rectangular and fill the frame.")

cv2.getPerspectiveTransform takes exactly four source points and four destination points and returns the three-by-three matrix that maps one set onto the other; cv2.warpPerspective then applies that matrix to every pixel in the image, not just the four corners. The output width and height are chosen from the longer of the two measured edges on each side, the bottom edge versus the top edge for width, and the right edge versus the left edge for height, so the output rectangle is not accidentally squashed to the shorter of the two measurements. Notice the division by scale just before the warp: page_contour was found on the resized, 500-pixel-tall copy, so its coordinates need to be scaled back up before they are used against the full-resolution original, otherwise the transform would be computed correctly but applied to the wrong region of the big image, cropping only a small corner of the page.

Step 6: Make It Look Scanned

🐍Python
import cv2

warped = cv2.imread("step_warped.png")
warped_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)

scanned = cv2.adaptiveThreshold(
    warped_gray, 255,
    cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
    cv2.THRESH_BINARY,
    21,  # block size: must be odd; sets how large the local neighbourhood is
    10   # C: subtracted from the local mean; raise it if the result looks too dark
)

cv2.imwrite("scanned_document.png", scanned)
print("Saved scanned_document.png")

As covered in part 7, thresholding turns a greyscale image into pure black and white by comparing each pixel to a cutoff value. A single global cutoff often struggles across a whole page, because real-world lighting is rarely perfectly even: one side of the page might be slightly brighter than the other, or a shadow might fall across part of it from the camera or your own hand. Adaptive thresholding handles this by computing a separate cutoff for each pixel based on the brightness of the small neighbourhood immediately around it, rather than using one number for the entire image, which is why it copes with uneven lighting far better than a plain global threshold does. The block size, 21 here, controls how large that local neighbourhood is and must be an odd number greater than one, or OpenCV will raise an error; the constant C, 10 here, is subtracted from the locally computed average before comparing, and raising it will push more borderline pixels to black, which is useful if the result looks washed out or too grey. Both are starting points worth tuning by eye, in the same way you tuned threshold values in part 7, by saving the result and looking at it rather than guessing blindly.

The Complete Script

With each stage checked individually, here is the whole pipeline assembled into one file. It wraps the steps into small functions, prints progress, and saves every intermediate image alongside the final result so you can inspect where things went right or wrong. Save it as scan.py in the same folder as document_photo.png.

🐍Python
import cv2
import numpy as np


def order_points(pts):
    pts = pts.reshape(4, 2).astype("float32")
    rect = np.zeros((4, 2), dtype="float32")
    s = pts.sum(axis=1)
    rect[0] = pts[np.argmin(s)]
    rect[2] = pts[np.argmax(s)]
    diff = np.diff(pts, axis=1)
    rect[1] = pts[np.argmin(diff)]
    rect[3] = pts[np.argmax(diff)]
    return rect


def four_point_transform(image, pts):
    rect = order_points(pts)
    (tl, tr, br, bl) = rect

    width_a = np.linalg.norm(br - bl)
    width_b = np.linalg.norm(tr - tl)
    max_width = int(max(width_a, width_b))

    height_a = np.linalg.norm(tr - br)
    height_b = np.linalg.norm(tl - bl)
    max_height = int(max(height_a, height_b))

    destination = np.array([
        [0, 0],
        [max_width - 1, 0],
        [max_width - 1, max_height - 1],
        [0, max_height - 1]], dtype="float32")

    matrix = cv2.getPerspectiveTransform(rect, destination)
    warped = cv2.warpPerspective(image, matrix, (max_width, max_height))
    return warped


def find_page_contour(edges):
    contours, _ = cv2.findContours(edges.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
    contours = sorted(contours, key=cv2.contourArea, reverse=True)[:5]
    for c in contours:
        perimeter = cv2.arcLength(c, True)
        approx = cv2.approxPolyDP(c, 0.02 * perimeter, True)
        if len(approx) == 4:
            return approx
    return None


def scan(input_path, output_path="scanned_document.png"):
    image = cv2.imread(input_path)
    if image is None:
        raise FileNotFoundError(f"Could not read {input_path}")
    original = image.copy()

    target_height = 500.0
    scale = target_height / image.shape[0]
    resized = cv2.resize(image, (int(image.shape[1] * scale), int(target_height)))

    gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY)
    blurred = cv2.GaussianBlur(gray, (5, 5), 0)
    edges = cv2.Canny(blurred, 75, 200)
    cv2.imwrite("step_edges.png", edges)

    page_contour = find_page_contour(edges)
    if page_contour is None:
        raise RuntimeError(
            "No four-cornered page outline found. Try a photo with more contrast "
            "between the page and the background, or adjust the Canny thresholds."
        )

    outline_preview = resized.copy()
    cv2.drawContours(outline_preview, [page_contour], -1, (0, 255, 0), 3)
    cv2.imwrite("step_outline.png", outline_preview)

    corners_original = page_contour.reshape(4, 2).astype("float32") / scale
    warped = four_point_transform(original, corners_original)
    cv2.imwrite("step_warped.png", warped)

    warped_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
    scanned = cv2.adaptiveThreshold(
        warped_gray, 255,
        cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
        cv2.THRESH_BINARY,
        21, 10
    )
    cv2.imwrite(output_path, scanned)
    print(
        f"Done. Saved {output_path}, plus step_edges.png, step_outline.png "
        "and step_warped.png for inspection."
    )


if __name__ == "__main__":
    scan("document_photo.png")

Run it with python scan.py. It should print a short confirmation and produce four files: step_edges.png, the Canny edge map on the resized image; step_outline.png, the resized image with the detected page outline drawn on top in green, useful for confirming the right shape was picked; step_warped.png, the full-resolution page after the perspective transform, which should already look rectangular and fill the frame; and finally scanned_document.png, the thresholded version, which should look like a clean black-and-white scan of the fake invoice, with the page edges now running straight to the edges of the image rather than at an angle.

Running It on Your Own Photos

Once the pipeline works on the synthetic image, try it on a real photo by changing the filename passed to scan at the bottom of the script. A few practical habits make a real photo much easier for this pipeline to handle correctly. Photograph the page on a surface that contrasts with it in brightness, since Canny is looking for a strong change in intensity at the page's edge, and a white page on a pale tablecloth gives it almost nothing to find. Keep the lighting as even as you can across the whole page, avoiding a single strong lamp from one side, which creates a bright strip and a dark strip that can confuse both the edge detector and the later adaptive threshold. Fit the whole page inside the frame with a visible margin of background around all four sides, rather than letting any edge of the page touch or run off the edge of the photo. Keep the camera reasonably steady, since a blurred photo weakens the edges Canny relies on. Finally, keep the tilt moderate: a slight angle is exactly what this pipeline is designed to correct, but a page photographed almost edge-on, so that one side of it is a sliver a few pixels wide, is a difficult case for any document scanner, including the apps on your phone.

  • Use a background that clearly contrasts with the page in brightness, not just in colour
  • Light the page evenly, avoiding strong light from one side only
  • Leave a visible margin of background on all four sides of the page in the photo
  • Hold the camera steady to avoid blur
  • Keep the tilt moderate rather than near edge-on

When It Fails: Troubleshooting

  • No four-cornered outline is found at all: inspect step_edges.png first. If the outline around the page has gaps, try lowering the first Canny threshold slightly, or add a short cv2.dilate step on the edge map before looking for contours, which thickens the lines and can close small gaps. Also check the background contrasts enough with the page.
  • The whole image, rather than the page, is detected as the four-cornered contour: this happens when the page fills almost the entire frame, so its own outline and the image border become hard to tell apart. Leave more background margin around the page when photographing it, or explicitly skip any candidate contour whose area is suspiciously close to the full image area.
  • The warped result looks rotated or mirrored compared to the original: this points to the corner ordering rather than the transform itself. Print corners_original after ordering and check visually that the first point really is near the top-left of the page in the photo, the second near the top-right, and so on.
  • The scanned output is too dark, too light, or patchy: adjust the adaptiveThreshold block size and C constant, trying a larger block size for a bigger page, or a smaller C to keep more detail. If the lighting in your photo is genuinely even, a plain global threshold or Otsu's method from part 7 may actually look cleaner than the adaptive version.
  • approxPolyDP never returns exactly four points for a contour that looks like a clean rectangle in step_outline.png: try adjusting the epsilon fraction, for example from 0.02 up to 0.03 of the perimeter to simplify more aggressively, or down to 0.01 if it is collapsing too much of the actual shape.

Common Mistakes

  • Passing cv2.resize a (rows, columns) tuple out of habit from NumPy indexing: it actually expects (width, height), the opposite order from an array's .shape
  • Warping the small resized image instead of scaling the found corners back up and warping the full-resolution original, which produces a blurry, low-detail scan
  • Forgetting that an odd blockSize is required by cv2.adaptiveThreshold; an even number raises an error rather than silently rounding
  • Using an epsilon for approxPolyDP that is far too small, so a slightly noisy contour never simplifies down to exactly four points, or far too large, so genuine corners get smoothed away
  • Skipping the corner-ordering step and feeding approxPolyDP's raw corner order straight into getPerspectiveTransform, which can produce a flipped or rotated result that still looks plausible at first glance
  • Assuming the biggest contour in the image is always the page without sorting by area and checking several candidates, which fails as soon as something else in the scene, such as a shadow outline, happens to be larger
  • Not checking intermediate outputs at each stage and only looking at the final scanned image, which makes it hard to tell whether the problem is in edge detection, contour selection, ordering, or the warp itself

Going Further

  • Connect this pipeline to a live webcam feed using cv2.VideoCapture, as covered in part 8, so the script continuously looks for a page and only saves a scan once a stable four-cornered outline has been detected for a few frames in a row
  • Skip the greyscale and threshold step and save the warped colour image directly, useful for scanning photographs or colour documents where you want to keep the colour rather than convert to black and white
  • Try cv2.convexHull on a candidate contour before calling approxPolyDP, which can help with pages that have slightly rounded or torn corners by smoothing the shape before simplifying it
  • Compare the adaptive threshold result against a plain global threshold and against Otsu's method from part 7 on the same warped image, and get a feel for which kind of lighting favours each one
  • Add a simple command-line argument so you can run python scan.py myphoto.jpg instead of editing the filename inside the script each time

Summary and What's Next

You built a complete document scanner out of tools you already had: greyscale conversion and blurring to prepare the image, Canny edges and contours to find the page, a small geometric routine to order its corners consistently, a perspective transform to flatten the page into a proper rectangle, and an adaptive threshold to make the result look like a scan rather than a photograph. None of these steps depended on anything learned from data; every decision in the pipeline, from the Canny thresholds to the approxPolyDP tolerance, is a number you can see, reason about and adjust by hand, which is exactly the kind of classical, hand-made pipeline that part 9 contrasted with learned features. It is also, in simplified form, close to what many real scanning apps still do on-device, because it is fast and does not need a model or a network connection.

In part 11 you will use contours again, this time to detect and count round objects like coins scattered on a table, building further on what you have learned here about measuring and filtering shapes. Part 12 returns to live video to build a motion detector that flags movement between frames, bringing the path's first three small projects to a close.