4

I would like to get the 4 corners of a page, The steps I took:

  1. Converted to grayscale
  2. Applied threshold the image
  3. Applied Canny for detecting edges
  4. After that I have used findContours
  5. Draw the approx polygon for each polygon, my assumption was the relevant polygon must have 4 vertices.

but along the way I found out my solution sometimes misses, apparently my solution is not robust enough (probably a bit a naive solution).

enter image description here

I think some of the reasons for those paper corner detection failure are:

  • The thresholds are picked manually for canny detection.
  • The same about the epsilon value for approxPolyDP

My Code

import cv2
import numpy as np
image = cv2.imread('page1.jpg') 

descalingFactor = 3
imgheight, imgwidth = image.shape[:2]
resizedImg = cv2.resize(image, (int(imgwidth / descalingFactor), int(imgheight / descalingFactor)),
                        interpolation=cv2.INTER_AREA)

cv2.imshow(winname="original", mat=resizedImg)
cv2.waitKey()

gray = cv2.cvtColor(resizedImg, cv2.COLOR_BGR2GRAY)
cv2.imshow(winname="gray", mat=gray)
cv2.waitKey()
img_blur = cv2.GaussianBlur(gray, (5, 5), 1)
cv2.imshow(winname="blur", mat=img_blur)
cv2.waitKey()

canny = cv2.Canny(gray,
                  threshold1=120,
                  threshold2=255,
                  edges=1)

cv2.imshow(winname="Canny", mat=canny)
cv2.waitKey()

contours, _ = cv2.findContours(image=canny, mode=cv2.RETR_EXTERNAL, method=cv2.CHAIN_APPROX_SIMPLE)

contours = sorted(contours, key=cv2.contourArea, reverse=True)

for idx, cnt in enumerate(contours):
    # print("Contour #", idx)
    # print("Contour #", idx, " len(cnt): ", len(cnt))

    cv2.drawContours(image=resizedImg, contours=[cnt], contourIdx=0, color=(255, 0, 0), thickness=3)
    cv2.imshow(winname="contour" + str(idx), mat=resizedImg)
    conv = cv2.convexHull(cnt)
    epsilon = 0.1 * cv2.arcLength(cnt, True)
    approx = cv2.approxPolyDP(cnt, epsilon, True)
    cv2.drawContours(resizedImg, [approx], 0, (0, 0, 255), 3)
    cv2.waitKey(0)
    if len(approx) == 4:
        print("found the paper!!")
        break

pts = np.squeeze(approx)

Another approach

I was wondering wouldn't it be a better approach to fit a polygon with 4 vertices (Quadrilateral) to the contour , and then check if the area difference between the polygon to the contour is below a specified threshold.

Can somebody please suggest a more robust solution (demonstrating it with code), thank you.

The images:

image1: https://ibb.co/K2SqLwZ

image2: https://ibb.co/mbGFsNp

image3: https://ibb.co/m6QKkzw

image4: https://ibb.co/xh7W41V

stateMachine
  • 5,227
  • 4
  • 13
  • 29
JammingThebBits
  • 732
  • 11
  • 31
  • You need better background and lighting. The light reflections make it hard to determine the page from the background. Use diffuse lighting. Also a textured background is hard to process. Use a constant colored one. – fmw42 May 22 '21 at 01:23
  • If you photograph more than one page at a time (3 and 4 have two pages), you could easily get more than 4 corners. – fmw42 May 22 '21 at 01:57
  • 1
    You can go do corner detection using cv2.goodFeaturesToTrack(). It will allow you to keep from having corner that are too close together. – fmw42 May 22 '21 at 02:24
  • See for example https://stackoverflow.com/questions/67434707/how-to-fill-the-hollow-lines-opencv/67442660#67442660 – fmw42 May 22 '21 at 02:58

1 Answers1

8

As fmw42 suggested, you need to restrict the problem more. There are way too many variables to build a "works under all circumstances" solution. A possible, very basic, solution would be to try and get the convex hull of the page.

Another, more robust approach, would be to search for the four vertices of the corners and extrapolate lines to approximate the paper edges. That way you don't need perfect, clean edges, because you would reconstruct them using the four (maybe even three) corners.

To find the vertices you can run Hough Line detector or a Corner Detector on the edges and get at least four discernible clusters of end/starting points. From that you can average the four clusters to get a pair of (x, y) points per corner and extrapolate lines using those points.

That solution would be hypothetical and pretty laborious for a Stack Overflow question, so let me try the first proposal - detection via convex hull. Here are the steps:

  1. Threshold the input image
  2. Get edges from the input
  3. Get the external contours of the edges using a minimum area filter
  4. Get the convex hull of the filtered image
  5. Get the corners of the convex hull

Let's see the code:

# imports:
import cv2
import numpy as np

# image path
path = "D://opencvImages//"
fileName = "img2.jpg"

# Reading an image in default mode:
inputImage = cv2.imread(path + fileName)

# Deep copy for results:
inputImageCopy = inputImage.copy()

# Convert BGR to grayscale:
grayInput = cv2.cvtColor(inputImageCopy, cv2.COLOR_BGR2GRAY)

# Threshold via Otsu:
_, binaryImage = cv2.threshold(grayInput, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

The first step is to get a binary image, very straightforward. This is the result if you threshold via Otsu:

It is never a good idea to try and segment an object from a textured (or high frequency) background, however, in this case the paper it is discernible in the image histogram and the binary image is reasonably good. Let's try and detect edges on this image, I'm applying Canny with the same parameters as your code:

# Get edges:
cannyImage = cv2.Canny(binaryImage, threshold1=120, threshold2=255, edges=1)

Which produces this:

Seems good enough, the target edges are mostly present. Let's detect contours. The idea is to set an area filter, because the target contour is the biggest amongst the rest. I (heuristically) set a minimum area of 100000 pixels. Once the target contour is found I get its convex hull, like this:

# Find the EXTERNAL contours on the binary image:
contours, hierarchy = cv2.findContours(cannyImage, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

# Store the corners:
cornerList = []

# Look for the outer bounding boxes (no children):
for i, c in enumerate(contours):

    # Approximate the contour to a polygon:
    contoursPoly = cv2.approxPolyDP(c, 3, True)

    # Convert the polygon to a bounding rectangle:
    boundRect = cv2.boundingRect(contoursPoly)

    # Get the bounding rect's data:
    rectX = boundRect[0]
    rectY = boundRect[1]
    rectWidth = boundRect[2]
    rectHeight = boundRect[3]

    # Estimate the bounding rect area:
    rectArea = rectWidth * rectHeight

    # Set a min area threshold
    minArea = 100000

    # Filter blobs by area:
    if rectArea > minArea:

        # Get the convex hull for the target contour:
        hull = cv2.convexHull(c)
        # (Optional) Draw the hull:
        color = (0, 0, 255)
        cv2.polylines(inputImageCopy, [hull], True, color, 2)

You'll notice I've prepared beforehand a list (cornerList) in which I'll store (hopefully) all the corners. The last two lines of the previous snippet are optional, they draw the convex hull via cv2.polylines, this would be the resulting image:

Still inside the loop, after we compute the convex hull, we will get the corners via cv2.goodFeaturesToTrack, which implements a Corner Detector. The function receives a binary image, so we need to prepare a black image with the convex hull points drawn in white:

        # Create image for good features to track:
        (height, width) = cannyImage.shape[:2]
        # Black image same size as original input:
        hullImg = np.zeros((height, width), dtype =np.uint8)

        # Draw the points:
        cv2.drawContours(hullImg, [hull], 0, 255, 2)
        cv2.imshow("hullImg", hullImg)
        cv2.waitKey(0)

This is the image:

Now, we must set the corner detector. It needs the number of corners you are looking for, a minimum "quality" parameter that discards poor points detected as "corners" and a minimum distance between the corners. Check out the documentation for more parameters. Let's set the detector, it will return an array of points where it detected a corner. After we get this array, we will store each point in our cornerList, like this:

        # Set the corner detection:
        maxCorners = 4
        qualityLevel = 0.01
        minDistance = int(max(height, width) / maxCorners)

        # Get the corners:
        corners = cv2.goodFeaturesToTrack(hullImg, maxCorners, qualityLevel, minDistance)
        corners = np.int0(corners)

        # Loop through the corner array and store/draw the corners:
        for c in corners:

            # Flat the array of corner points:
            (x, y) = c.ravel()
            # Store the corner point in the list:
            cornerList.append((x,y))

            # (Optional) Draw the corner points:
            cv2.circle(inputImageCopy, (x, y), 5, 255, 5)
            cv2.imshow("Corners", inputImageCopy)
            cv2.waitKey(0)
        

Additionally you can draw the corners as circles, it will yield this image:

This is the same algorithm tested on your third image:

stateMachine
  • 5,227
  • 4
  • 13
  • 29
  • 1
    `@stateMachine` Nice solution and excellent explanation. – fmw42 May 22 '21 at 02:58
  • @stateMachine thank you so much for the efforts and time you put in writing this answer!! :) looks impressive and now I got a better grasp of goodFeaturesToTrack usage. I have read your solution thoroughly, very nice! The lighting condition sometimes can make quite trouble with the corner detection, like here: https://ibb.co/HPgRzV3 Any suggestions, how to overcome those kind of situations? – JammingThebBits May 22 '21 at 10:10