Image Processing with Python — Template Matching with Scikit-Image

How to identify similar objects in your image

Tonichi Edeza
Towards Data Science

--

Shots of Leuven Town Hall (Image by Author)

Template matching is a useful technique for identifying objects of interest in a picture. Unlike similar methods of object identification such as image masking and blob detection. Template matching is helpful as it allows us to identify more complex figures. This article will discuss exactly how to do this in Python.

Let’s get started!

As always, begin by importing the required Python libraries.

import numpy as np
from skimage.io import imread, imshow
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Rectangle
from skimage import transform
from skimage.color import rgb2gray
from skimage.feature import match_template
from skimage.feature import peak_local_max

Great, now let us load the image we will be working with.

leuven = imread('leuven_picture.PNG')
plt.figure(num=None, figsize=(8, 6), dpi=80)
imshow(leuven);
Leuven Town Hall (Image by Author)

The image above is of the Leuven Town Hall I took some years ago. Its highly decorative window arches are definitely a sight to behold. For our task let us try to use template matching to identify as many of them as possible. Our first step of course is to convert the image to grayscale.

leuven_gray = rgb2gray(leuven)
plt.figure(num=None, figsize=(8, 6), dpi=80)
imshow(leuven_gray);
Grayscale Leuven Town Hall

Excellent, now let us pick out one of the windows and use it as a template. To do this we simply have to cut out that slice of the image.

template = leuven_gray[310:390,240:270]
imshow(template);
A Slice of Leuven

At this point we can feed the template into the match_template function of Skimage.

resulting_image = match_template(leuven_gray, template)
plt.figure(num=None, figsize=(8, 6), dpi=80)
imshow(resulting_image, cmap='magma');
Resulting Image from the function

The above is the result of using the match_template function. Put very simply, the brighter the section of the image, the closer of a match it is to the template. Let us see which section of the image the function thinks is the closest match to the template.

x, y = np.unravel_index(np.argmax(resulting_image), resulting_image.shape)
template_width, template_height = template.shape
rect = plt.Rectangle((y, x), template_height, template_width,
color='r', fc='none')
plt.figure(num=None, figsize=(8, 6), dpi=80)
plt.gca().add_patch(rect)
imshow(leuven_gray);
Best Match Identification

We can see that the image was able to correctly identify the perfect match for the template (to validate you can check with the slicing coordinates we used). This will definitely be useful in any task that would require you to search for an exact match of an object within an image.

Let us now see if we can get the function to identify the other windows as being more or less similar to our template.

template_width, template_height = template.shape    
plt.figure(num=None, figsize=(8, 6), dpi=80)
for x, y in peak_local_max(result, threshold_abs=0.5,
exclude_border = 20):
rect = plt.Rectangle((y, x), template_height, template_width,
color='r', fc='none')
plt.gca().add_patch(rect)
imshow(leuven_gray);
Multiple Template Matches

We see that though the function does accurately identify several other windows. It also erroneously identifies several other objects that are clearly not windows. Let us see if we can cut down on the amount of false positives.

One way we can can remedy this is by making use of use of the homography matrix. I’ve written an article previously on how to make use of the transform.warp function in Skimage, but generally it warps the image and make it seem as if the image had been taken from another angle.

points_of_interest =[[240, 130], 
[525, 255],
[550, 545],
[250, 545]]
projection = [[180, 150],
[520, 150],
[520, 550],
[180, 550]]
color = 'red'
patches = []
fig, ax = plt.subplots(1,2, figsize=(15, 10), dpi = 80)
for coordinates in (points_of_interest + projection):
patch = Circle((coordinates[0],coordinates[1]), 10,
facecolor = color)
patches.append(patch)
for p in patches[:4]:
ax[0].add_patch(p)
ax[0].imshow(leuven_gray);
for p in patches[4:]:
ax[1].add_patch(p)
ax[1].imshow(np.ones((leuven_gray.shape[0], leuven_gray.shape[1])));
Original Corners vs Target Corners
points_of_interest = np.array(points_of_interest)
projection = np.array(projection)
tform = transform.estimate_transform('projective', points_of_interest, projection)
tf_img_warp = transform.warp(leuven, tform.inverse)
plt.figure(num=None, figsize=(8, 6), dpi=80)
fig, ax = plt.subplots(1,2, figsize=(15, 10), dpi = 80)
ax[0].set_title(f'Original', fontsize = 15)
ax[0].imshow(leuven)
ax[0].set_axis_off();
ax[1].set_title(f'Transformed', fontsize = 15)
ax[1].imshow(tf_img_warp)
ax[1].set_axis_off();
Transformed Image

We can see that the image now faces forward. As we have mitigated the effect the angle has on template matching, let us see if we get better results. As before, let us first convert the image into grayscale and then apply the transform function.

points_of_interest = np.array(points_of_interest)
projection = np.array(projection)
tform = transform.estimate_transform('projective', points_of_interest, projection)
tf_img_warp = transform.warp(leuven_gray, tform.inverse)
plt.figure(num=None, figsize=(8, 6), dpi=80)
fig, ax = plt.subplots(1,2, figsize=(15, 10), dpi = 80)
ax[0].set_title(f'Original', fontsize = 15)
ax[0].imshow(leuven_gray, cmap = 'gray')
ax[0].set_axis_off();
ax[1].set_title(f'Transformed', fontsize = 15)
ax[1].imshow(tf_img_warp, cmap = 'gray')
ax[1].set_axis_off();
Transformed Grayscale
result = match_template(tf_img_warp, template)
plt.figure(num=None, figsize=(8, 6), dpi=80)
imshow(result, cmap=’magma’);
Result of Template Match (or a citadel in the underworld)

Now let us apply the exact same codes as before and see if we get better results.

template_width, template_height = template.shape    
plt.figure(num=None, figsize=(8, 6), dpi=80)
for x, y in peak_local_max(result, threshold_abs=0.5,
exclude_border = 10):
rect = plt.Rectangle((y, x), template_height, template_width,
color='r', fc='none')
plt.gca().add_patch(rect)
imshow(tf_img_warp);
Multiple Template Matches on Transformed Image

We can see that the algorithm can still identify every window on the image, however it still has those pesky false positives. To alleviate this, let us apply a filter the template matches. Below are some codes to do our data wrangling, apologies if they are slightly abtruse.

template_width, template_height = template.shape
matched_list = []
for x, y in peak_local_max(result, threshold_abs=0.50, exclude_border = 10):
rect = plt.Rectangle((y, x), template_height, template_width)
coord = Rectangle.get_bbox(rect).get_points()
matched_list.append(coord)

matched_patches = [tf_img_warp[int(match[0][1]):int(match[1][1]),
int(match[0][0]):int(match[1][0])] for match in matched_list]
difference = [abs(i.flatten() - template.flatten()) for i in matched_patches]
summed_diff = [array.sum() for array in difference]
final_patches =list(zip(matched_list,summed_diff))
statistics.mean(summed_diff)

After running the above codes, we can now create the filtered list of template matches. Again apologies if the code may not be that easy to follow.

summed_diff = np.array(summed_diff)
filtered_list_mean =list(filter(lambda x: x[1] <=
summed_diff.mean(), final_patches))
filtered_list_median =list(filter(lambda x: x[1] <=
np.percentile(summed_diff, 50),
final_patches))
filtered_list_75 =list(filter(lambda x: x[1] <=
np.percentile(summed_diff, 75),
final_patches))

The above code should filter the matches by the mean difference, the median difference, and the 75% percentile difference. Essentially it will only hold matches that have absolute differences below those thresholds. The final step is to plot these out and see if the results have improved.

fig, ax = plt.subplots(1,3, figsize=(17, 10), dpi = 80)
template_width, template_height = template.shape
for box in filtered_list_mean:
patch = Rectangle((box[0][0][0],box[0][0][1]), template_height,
template_width, edgecolor='b',
facecolor='none', linewidth = 3.0)
ax[0].add_patch(patch)
ax[0].imshow(tf_img_warp, cmap = 'gray');
ax[0].set_axis_off()
for box in filtered_list_median:
patch = Rectangle((box[0][0][0],box[0][0][1]), template_height,
template_width, edgecolor='b',
facecolor='none', linewidth = 3.0)
ax[1].add_patch(patch)
ax[1].imshow(tf_img_warp, cmap = 'gray');
ax[1].set_axis_off()
for box in filtered_list_75:
patch = Rectangle((box[0][0][0],box[0][0][1]), template_height,
template_width,
edgecolor='b', facecolor='none',
linewidth = 3.0)
ax[2].add_patch(patch)
ax[2].imshow(tf_img_warp, cmap = 'gray');
ax[2].set_axis_off()
fig.tight_layout()
Template Filtered Images

We can see that all of them do look much better than the original image. However, we notice that though Mean and Median have far less false positives they also have far less true positives. The 75 Perc filter however is able to retain almost all the true positives.

In Conclusion

Template matching can be a tricky thing if the template is a particularly complex image. We must remember that though we as humans may interpret the image as a simple window, the machine only sees a matrix. There then two ways we can tackle this issue. One is by ensuring that the template is unique enough that false positives will be rare, the other is developing a sophisticated filtering system that is able to accurately remove any false positives from the data. A topic like this deserves several articles and in the future we shall go over some best practices when it comes to template matching. For now I hope you were able to learn how to make use of template matching in your own projects and can now think ahead of how to deal with the inevitable issues.

--

--