Fruit Inspection

Computer vision has so many applications that is impossible not to be charmed by it. I decided to explore a tiny part of the image analysis world using a fruit as subject.

In the notebook below (which .ipynb file is in my github repository) I use the image segmentation and the edge detector technique to locate any defects on the surface of some apples.

The procedure explained below can be easily applied to other subjects.


  1. Load Images
  2. Fruit Segmentation
    2.1 Otsu’s Method
    2.2 Flood-fill Approach
    2.3 Segmented Images
  3. Defects Detection
    3.1 Canny Edge Detector
    3.2 Erosion
  4. Conclusion

1. Load Images

We have three pairs of images acquired through a NIR (Near Infra-Red) and a color camera with a little parallax effect.
We load and display the images below.

import cv2 
import numpy as np
from matplotlib import pyplot as plt
%matplotlib inline


#NIR images
img1 = cv2.imread("images/C0_000001.png", cv2.IMREAD_GRAYSCALE)
img2 = cv2.imread("images/C0_000002.png", cv2.IMREAD_GRAYSCALE)
img3 = cv2.imread("images/C0_000003.png", cv2.IMREAD_GRAYSCALE)


#Colored images
img1_c = cv2.cvtColor(cv2.imread("images/C1_000001.png", cv2.IMREAD_COLOR),cv2.COLOR_BGR2RGB) 
img2_c = cv2.cvtColor(cv2.imread("images/C1_000002.png", cv2.IMREAD_COLOR),cv2.COLOR_BGR2RGB) 
img3_c = cv2.cvtColor(cv2.imread("images/C1_000003.png", cv2.IMREAD_COLOR),cv2.COLOR_BGR2RGB) 

#Plot images
plt.figure(figsize=(10,10))
plt.subplot(2,3,1)
plt.imshow(img1, cmap='gray', vmin=0, vmax=255)
plt.title("NIR img1", fontsize = 15)
plt.subplot(2,3,2)
plt.imshow(img2, cmap='gray', vmin=0, vmax=255)
plt.title("NIR img2", fontsize = 15)
plt.subplot(2,3,3)
plt.imshow(img3, cmap='gray', vmin=0, vmax=255)
plt.title("NIR img3", fontsize = 15)
plt.subplot(2,3,4)
plt.imshow(img1_c)
plt.title("Colored img1", fontsize = 15)
plt.subplot(2,3,5)
plt.imshow(img2_c)
plt.title("Colored img2", fontsize = 15)
plt.subplot(2,3,6)
plt.imshow(img3_c)
plt.title("Colored img3", fontsize = 15)
plt.show()

2. Fruit Segmentation

We want to inspect the fruit to locate any defects, thus we need to remove the background and focus the analysis on the fruit (hereinafter called foreground).

One of the most used tool to isolate the foreground is the so called Binary Mask: a binary image having the same size as the target image and defining a region of interest (ROI).
In particular:

  • mask pixel values of 1 indicate the image pixel belongs to the ROI;
  • mask pixel values of 0 indicate the image pixel is part of the background.

We create our binary masks by thresholding the gray-level histogram of each NIR image displayed below.

#Gray-level histograms
plt.figure(figsize = (20, 5), num = 'Gray-level histograms')
plt.subplot(1,3,1)
plt.hist(img1.flatten(), bins = 50, color = 'black');
plt.ticklabel_format()
plt.grid()
plt.title("img1 g-lvl histogram", fontsize = 15)
plt.subplot(1,3,2)
plt.hist(img2.flatten(), bins = 50, color = 'black');
plt.grid()
plt.title("img2 g-lvl histogram", fontsize = 15)
plt.subplot(1,3,3)
plt.hist(img3.flatten(), bins = 50, color = 'black');
plt.grid()
plt.title("img3 g-lvl histogram", fontsize = 15)
plt.show()

2.1 Otsu’s Method

The three histograms clearly show the background peaks, but the gray-level values are spread over the whole range; thus it’s hard to manually find a correct threshold for each image.

How could we select a meaningful threshold?

One of the most well known approach is the Otsu’s method, which consists in maximizing the between-group variance of the background and the foreground.
Therefore, this method provides the thresholding value which makes both the background and the foreground the most homogeneous as possible.

Once we have found the best threshold according to Otsu’s method, we create our binary mask by setting at 0 each pixel classified as background and at 1 each foreground pixel.

The resulting masks exhibit some holes which coincide to the darkest regions of the fruit that have been wrongly classified as background.
It’s not surprising that the thresholding operation misclassified some pixels: as we highlighted, the intensity distribution is quite stretched and therefore the presence of false negatives and false positives is very likely.

# Thresholding with Otsu's method
thresh = 25
maxValue = 1 

th1, dst1 = cv2.threshold(img1, thresh, maxValue, cv2.THRESH_OTSU)
th2, dst2 = cv2.threshold(img2, thresh, maxValue, cv2.THRESH_OTSU)
th3, dst3 = cv2.threshold(img3, thresh, maxValue, cv2.THRESH_OTSU);
print("The resulting thresholding values are:\n\n {:0.0f} for img1,\n {:0.0f} for img2,\n {:0.0f} for img3."\
      .format(th1, th2, th3))
The resulting thresholding values are:

 109 for img1,
 98 for img2,
 104 for img3.
#Plot Binary Masks
plt.figure(figsize=(10,10))
plt.subplot(1,3,1)
plt.imshow(dst1 * 255, cmap='gray', vmin=0, vmax=255)
plt.title("Binary Mask - img1", fontsize = 15)
plt.subplot(1,3,2)
plt.imshow(dst2 * 255, cmap='gray', vmin=0, vmax=255)
plt.title("Binary Mask - img2", fontsize = 15)
plt.subplot(1,3,3)
plt.imshow(dst3 * 255, cmap='gray', vmin=0, vmax=255)
plt.title("Binary Mask - img3", fontsize = 15)
plt.show()

2.2 Flood-fill approach

To correct the false negatives introduced by the thresholding, we use the flood-fill algorithm to fill the connected components in the target image with a specified value.

In the lines below, we use the floodFill function of OpenCV to fill the holes inside our binary masks:

  • we set at 1 the filling value and we prepare a (h+2) by (w+2) empty mask, where h and w are the dimensions of the original image;
  • we fill the connected components in the original image and we invert the resulting binary image;
  • we combine the original image with the new one to get a Binary Mask without holes.
#Flood fill apporach

#Copy the masks
dst1_floodfill = dst1.copy()
dst2_floodfill = dst2.copy()
dst3_floodfill = dst3.copy()

# Masks used to flood filling
h, w = dst1.shape[:2]
m1 = np.zeros((h+2, w+2), np.uint8)
h, w = dst2.shape[:2]
m2 = np.zeros((h+2, w+2), np.uint8)
h, w = dst3.shape[:2]
m3 = np.zeros((h+2, w+2), np.uint8)

# Floodfill starting from point (0,0) with new value 1
seed_point = (0,0)
newValue = 1 

cv2.floodFill(dst1_floodfill, m1, seed_point, newValue);
cv2.floodFill(dst2_floodfill, m2, seed_point, newValue);
cv2.floodFill(dst3_floodfill, m3, seed_point, newValue);

#Invert the resulting images
holes1 = np.invert(dst1_floodfill > 0)
holes2 = np.invert(dst2_floodfill > 0)
holes3 = np.invert(dst3_floodfill > 0)

#Combine the two images to get the final binary masks
mask1 = dst1 | holes1
mask2 = dst2 | holes2
mask3 = dst3 | holes3
#Plot filled masks
plt.figure(figsize=(10,10))
plt.subplot(1,3,1)
plt.title("Binary Mask - img1", fontsize = 15)
plt.imshow(mask1 * 255, cmap='gray', vmin=0, vmax=255)
plt.subplot(1,3,2)
plt.title("Binary Mask - img2", fontsize = 15)
plt.imshow(mask2 * 255, cmap='gray', vmin=0, vmax=255)
plt.subplot(1,3,3)
plt.title("Binary Mask - img3", fontsize = 15)
plt.imshow(mask3 * 255, cmap='gray', vmin=0, vmax=255)
plt.show()

2.3 Segmented Images

We apply the binary masks on both the NIR and the colored images: the resulting images present a completely homogeneous background and the unmodified fruit.

#Create the masks for the colored images
mask1_RGB = cv2.cvtColor(mask1, cv2.COLOR_GRAY2BGR) 
mask2_RGB = cv2.cvtColor(mask2, cv2.COLOR_GRAY2BGR)
mask3_RGB = cv2.cvtColor(mask3, cv2.COLOR_GRAY2BGR) 

#Apply the masks
m_img1_c = img1_c * mask1_RGB
m_img2_c = img2_c * mask2_RGB
m_img3_c = img3_c * mask3_RGB

Img1 = img1 * mask1
Img2 = img2 * mask2
Img3 = img3 * mask3
# Plot segmented images
plt.figure(figsize=(10,10))
plt.subplot(2,3,1)
plt.imshow(Img1, cmap='gray', vmin=0, vmax=255)
plt.title("NIR img1", fontsize = 15)
plt.subplot(2,3,2)
plt.imshow(Img2, cmap='gray', vmin=0, vmax=255)
plt.title("NIR img2", fontsize = 15)
plt.subplot(2,3,3)
plt.imshow(Img3, cmap='gray', vmin=0, vmax=255)
plt.title("NIR img3", fontsize = 15)
plt.subplot(2,3,4)
plt.imshow(m_img1_c)
plt.title("Colored img1", fontsize = 15)
plt.subplot(2,3,5)
plt.imshow(m_img2_c)
plt.title("Colored img2", fontsize = 15)
plt.subplot(2,3,6)
plt.imshow(m_img3_c)
plt.title("Colored img3", fontsize = 15)
plt.show()

3. Defects Detection

3.1 Canny Edge Detector

The imperfections show sharp edges and so we can use an edge detector to “draw” their contours.

There exist several edge detectors and all of them are based on finding the discontinuities in brightness. The one we are going to use is named Canny Edge Detector and it is implemented in OpenCV in the Canny function.

Canny function has two parameters (high-threshold and low-threshold) which determine the hysteresis process of thresholding and which have to be carefully tuned.
There are no universal guidelines to select the best parameter values, so we decided to apply the Otsu’s method on the masked NIR image to set the high-threshold parameter and we set the low-threshold equal to half high-threshold.

#Otsu's method
h_th1, _ = cv2.threshold(Img1, thresh, maxValue, cv2.THRESH_OTSU)
h_th2, _ = cv2.threshold(Img2, thresh, maxValue, cv2.THRESH_OTSU)
h_th3, _ = cv2.threshold(Img3, thresh, maxValue, cv2.THRESH_OTSU)

print("The high-threshold values are:\n\n {:0.0f} for Img1,\n {:0.0f} for Img2,\n {:0.0f} for Img3."\
      .format(h_th1, h_th2, h_th3))


#Canny's detector
Canny_1 = cv2.Canny(Img1, h_th1 / 2, h_th1)
Canny_2 = cv2.Canny(Img2, h_th2 / 2, h_th2)
Canny_3 = cv2.Canny(Img3, h_th3 / 2, h_th3)
>>> The high-threshold values are:
>>>
>>> 99 for Img1,
>>> 89 for Img2,
>>> 90 for Img3.

3.2 Erosion

The resulting images show the contours of both the fruit and the imperfections, and we can also notice some spurious edges.
We would like to obtain a binary image with only the fruit defects as foreground, therefore we have to remove the unnecessary parts.
A very useful tool is the erode function of OpenCV, which performs the operation of erosion) on the target image.

# Plot edges found by Canny's detector
plt.figure(figsize=(10,10))
plt.subplot(1,3,1)
plt.imshow(Canny_1, cmap = 'gray')
plt.title("Edges - Img1", fontsize = 15)
plt.subplot(1,3,2)
plt.imshow(Canny_2, cmap = 'gray')
plt.title("Edges - Img2", fontsize = 15)
plt.subplot(1,3,3)
plt.imshow(Canny_3, cmap = 'gray')
plt.title("Edges - Img3", fontsize = 15)
plt.show()

The erode function takes 3 arguments:

  • the original image;
  • the kernel (the matrix with which image is convolved);
  • the number of iterations which will determine the degree of erosion.

For our purpose, we take as kernel a 5 by 5 empty matrix and we set at 5 the number of iterations.

We proceed by eroding the original binary mask (the one generated using Otsu’s thresholding and filled with the flood-fill approach); then we use the eroded binary mask to extract only the imperfections contours from the image provided by the edge detector.

In the lines below we build the ad hoc function defects and we generate the three “defects” images.

#Erosion

def defects(canny_img, mask, kernel_size = (5,5), n_iter = 1):

    #Erosion
    kernel = np.ones(kernel_size, np.uint8) 
    eroded_mask = cv2.erode(mask, kernel, iterations = n_iter)

    #Apply mask
    canny_mask = (np.invert(canny_img)*mask).flatten()
    empty = np.zeros(canny_mask.shape)
    i = 0

    for n in eroded_mask.flatten():
        i += 1
        if n :
            if canny_mask[i] == 0:
                empty[i] = 255

    return empty.reshape(canny_img.shape)
defects1 = defects(Canny_1, mask1, n_iter=5)
defects2 = defects(Canny_2, mask2, n_iter=5)
defects3 = defects(Canny_3, mask3, n_iter=5)
# Plot the defects
plt.figure(figsize=(10,10))
plt.subplot(1,3,1)
plt.imshow(defects1, cmap = 'gray')
plt.title("Defects Mask - img1", fontsize = 15)
plt.subplot(1,3,2)
plt.imshow(defects2, cmap = 'gray')
plt.title("Defects Mask- img2", fontsize = 15)
plt.subplot(1,3,3)
plt.imshow(defects3, cmap = 'gray')
plt.title("Defects Mask- img3", fontsize = 15)
plt.show()

4. Conclusion

We use the “defects” images to draw the detected defects both on the NIR and the colored images.

As expected, in the resulting colored images the detected defects do not perfectly match the real imperfections contours: this is because we apply the edge detector on the NIR images and, as mentioned at the beginning, the pairs of images are affected by the parallax effect.

We also notice that the apple sepals have been classified as defects in img2: the sepals exhibit a sharp change in intensity wich has been detected by Canny Edge Detector.

def draw_defects_Gray(img, defects):

    img_RGB = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
    h,w,bpp = img_RGB.shape

    for py in range(0,h):
        for px in range(0,w):
            if(defects[py][px]):            
                img_RGB[py][px][0] = 255
                img_RGB[py][px][1] = 0
                img_RGB[py][px][2] = 255

    return img_RGB

def draw_defects_RGB(img, defects):

    h,w,bpp = img.shape
    img_copy = img.copy()

    for py in range(0,h):
        for px in range(0,w):
            if(defects[py][px]):            
                img_copy[py][px][0] = 255
                img_copy[py][px][1] = 0
                img_copy[py][px][2] = 255

    return img_copy
defects_img1 = draw_defects_Gray(img1, defects1)
defects_img2 = draw_defects_Gray(img2, defects2)
defects_img3 = draw_defects_Gray(img3, defects3)

defects_img1_RGB = draw_defects_RGB(img1_c, defects1)
defects_img2_RGB = draw_defects_RGB(img2_c, defects2)
defects_img3_RGB = draw_defects_RGB(img3_c, defects3)
# Plot colored images with defects
plt.figure(figsize=(10,10))
plt.subplot(2,3,1)
plt.imshow(defects_img1)
plt.title("Defects - img1", fontsize = 15)
plt.subplot(2,3,2)
plt.imshow(defects_img2)
plt.title("Defects - img2", fontsize = 15)
plt.subplot(2,3,3)
plt.imshow(defects_img3)
plt.title("Defects - img3", fontsize = 15)
plt.subplot(2,3,4)
plt.imshow(defects_img1_RGB)
plt.title("Defects - img1", fontsize = 15)
plt.subplot(2,3,5)
plt.imshow(defects_img2_RGB)
plt.title("Defects - img2", fontsize = 15)
plt.subplot(2,3,6)
plt.imshow(defects_img3_RGB)
plt.title("Defects - img3", fontsize = 15)
plt.show()

This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.