An introduction to contours

Contours can be seen as a curve joining all the points along the boundary of a certain shape. As they define the boundary of the shape, an analysis of these points can reveal key information for shape analysis and object detection and recognition. OpenCV provides many functions to properly detect and process contours. However, before diving into these functions, we are going to see the structure of a sample contour. For example, the following function simulates detecting a contour in a hypothetical image:

def get_one_contour():
"""Returns a 'fixed' contour"""

cnts = [np.array(
[[[600, 320]], [[563, 460]], [[460, 562]], [[320, 600]], [[180, 563]], [[78, 460]], [[40, 320]], [[77, 180]], [[179, 78]], [[319, 40]], [[459, 77]], [[562, 179]]], dtype=np.int32)]
return cnts

As you can see, a contour is an array composed of many points of the np.int32 type (integers in the range [-2147483648, 2147483647]). Now, we can call this function to get this array of contours. In this case, this array has only one detected contour:

contours = get_one_contour()
print("'detected' contours: '{}' ".format(len(contours)))
print("contour shape: '{}'".format(contours[0].shape))

At this point, we can apply all the functions that OpenCV provides to play with contours. Note that it is interesting to define the get_one_contour() function because it provides you with a simple way to have a contour ready to use in order to debug and test further functionality in connection with contours. In many situations, detected contours in a real image have hundreds of points, making it really difficult to debug your code. Therefore, keep this function handy.

In order to complete this introduction to contours, OpenCV provides cv2.drawContours(), which draws a contour outline in the image. Therefore, we can call this function to see what this contour is like. Additionally, we have also coded the draw_contour_points() function, which draws the points of the contour in the image. Also, we have used the np.squeeze() function in order to get rid of one-dimensional arrays like using [1,2,3] instead of [[[1,2,3]]]. For example, if we print the contour defined in the previous function, we will get the following:

[[[600 320]]
[[563 460]]
[[460 562]]
[[320 600]]
[[180 563]]
[[ 78 460]]
[[ 40 320]]
[[ 77 180]]
[[179 78]]
[[319 40]]
[[459 77]]
[[562 179]]]

After performing the following line of code:

squeeze = np.squeeze(cnt)

If we print squeeze, we will get the following output:

[[600 320]
[563 460]
[460 562]
[320 600]
[180 563]
[ 78 460]
[ 40 320]
[ 77 180]
[179 78]
[319 40]
[459 77]
[562 179]]

At this point, we can iterate over all the points of this array.

Hence, the code for the draw_contour_points() function is as follows:

def draw_contour_points(img, cnts, color):
"""Draw all points from a list of contours"""

for cnt in cnts:
squeeze = np.squeeze(cnt)

for p in squeeze:
p = array_to_tuple(p)
cv2.circle(img, p, 10, color, -1)

return img

Another consideration is that in the previous function, we have used the array_to_tuple() function, which converts an array into a tuple:

def array_to_tuple(arr):
"""Converts array to tuple"""

return tuple(arr.reshape(1, -1)[0])

This way, the first point of the contour, [600 320], is transformed into (600, 320), which is ready to use inside cv2.circle() as its center. The full code for this previous introduction to contours can be seen in contours_introduction.py. The output of this script can be seen in the next screenshot:

With the purpose of completing this introduction to contours, we also have coded a script, contours_introduction_2.py. Here, we have coded functions, build_sample_image() and build_sample_image_2(). These functions draw basic shapes in the image and their objective is to provide some predictable (or predefined) shapes.

These two functions have the same purpose as the defined get_one_contour() function  in the previous script, that is, they help us understand key concepts related to contours. The code for the build_sample_image() function is as follows:

def build_sample_image():
"""Builds a sample image with basic shapes"""

# Create a 500x500 gray image (70 intensity) with a rectangle and a circle inside:
img = np.ones((500, 500, 3), dtype="uint8") * 70
cv2.rectangle(img, (100, 100), (300, 300), (255, 0, 255), -1)
cv2.circle(img, (400, 400), 100, (255, 255, 0), -1)

return img

As you can see, this function draws two filled shapes (one rectangle and one circle). Therefore, this function creates an image with two (external) contours. The code for the build_sample_image_2() function is as follows:

def build_sample_image_2():
"""Builds a sample image with basic shapes"""

# Create a 500x500 gray image (70 intensity) with a rectangle and a circle inside (with internal contours):
img = np.ones((500, 500, 3), dtype="uint8") * 70
cv2.rectangle(img, (100, 100), (300, 300), (255, 0, 255), -1)
cv2.rectangle(img, (150, 150), (250, 250), (70, 70, 70), -1)
cv2.circle(img, (400, 400), 100, (255, 255, 0), -1)
cv2.circle(img, (400, 400), 50, (70, 70, 70), -1)

This function draws two filled rectangles (one inside another) and two filled circles (one inside another). This function creates an image with two external and two internal contours.

In contours_introduction_2.py, after the image has been loaded, we convert it to grayscale and thresholded in order to get a binary image. This binary image will be later used to find contours using the cv2.findContours() function. As seen previously, the created images only have circles and squares. Therefore, calling cv2.findContours() will find all these created contours. The signature for the cv2.findContours() method is as follows:

cv2.findContours(image, mode, method[, contours[, hierarchy[, offset]]]) -> image, contours, hierarchy

OpenCV provides cv2.findContours(), which can be used to detect contours in binary images (for example, the resulting image after a thresholding operation). This function implements the algorithm defined in the paper Topological Structural Analysis of Digitized Binary Images by Border Following. It should be noted that before OpenCV 3.2, the source image would have been modified and since OpenCV 3.2, the source image is no longer modified after calling this function. The source image is treated as a binary image, where non-zero pixels are treated as ones. This function returns the detected contours containing, for each one, all the retrieved points defining the boundary.

The retrieved contours can be outputted in different modes—cv2.RETR_EXTERNAL (outputs only external the contours), cv2.RETR_LIST (outputs all the contours without any hierarchical relationship), and cv2.RETR_TREE (outputs all the contours by establishing a hierarchical relationship). The output vector hierarchy contains information about this hierarchical relationship, providing an entry for each detected contour. For each ith contour contours[i], hierarchy[i][j] with j in the range [0,3] contains the following:

A negative value in hierarchy[i][j] means that there is no next (j=0), no previous (j=1), no child (j=2), or no parent (j=3) contour. Finally, the method parameter sets the approximation method used when retrieving the points concerning each detected contour. This parameter is further explained in the next section.

If we execute the contours_introduction_2.py script, we can see the following screen:

In this screenshot, external (cv2.RETR_EXTERNAL) and both external and internal (cv2.RETR_LIST) are calculated by calling cv2.findContours().