Search code examples
pythonopencvimage-processingobject-detectionopencv3.0

How to count different grains in an image using cv2?


I have an image that has cereal items below: grains

The image has:

  • 3 walnuts
  • 3 raisins
  • 3 pumpkin seeds
  • 27 similar looking cereal

I wish to count them separately using opencv, I do not want to recognize them. So far, I have tailored the AdaptiveThreshold method to count all the seeds, but not sure how to do it separately. This is my scripts:

import cv2
import numpy as np
import matplotlib.pyplot as plt

img = cv2.imread('/Users/vaibhavsaxena/Desktop/Screen Shot 2021-04-27 at 12.22.46.png', 0)
#img = cv2.fastNlMeansDenoisingColored(img,None,10,10,7,21)
windowSize = 31
windowConstant = 40
mask = cv2.adaptiveThreshold(img, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY_INV, windowSize, windowConstant)
plt.imshow(mask)

stats = cv2.connectedComponentsWithStats(mask, 8)[2]
label_area = stats[1:, cv2.CC_STAT_AREA]

min_area, max_area = 345, max(list(label_area))  # min/max for a single circle
singular_mask = (min_area < label_area) & (label_area <= max_area)
circle_area = np.mean(label_area[singular_mask])

n_circles = int(np.sum(np.round(label_area / circle_area)))

print('Total circles:', n_circles)

36

But this one seems extremely hard coded. For example, if I zoom in or zoom out the image, it yields a different count.

Can anyone please help?


Solution

  • Your lighting is not good, as HansHirse suggested, try normalizing the conditions in which you take your photos. There's, however, a method that can somewhat normalize the lighting and get it as uniform as possible. The method is called gain division. The idea is that you try to build a model of the background and then weight each input pixel by that model. The output gain should be relatively constant during most of the image. Let's give it a try:

    # imports:
    import cv2
    import numpy as np
    
    # Reading an image in default mode:
    inputImage = cv2.imread(path + fileName)
    # Deep copy for results:
    inputImageCopy = inputImage.copy()
    
    # Get local maximum:
    kernelSize = 30
    maxKernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kernelSize, kernelSize))
    localMax = cv2.morphologyEx(inputImage, cv2.MORPH_CLOSE, maxKernel, None, None, 1, cv2.BORDER_REFLECT101)
    
    # Perform gain division
    gainDivision = np.where(localMax == 0, 0, (inputImage/localMax))
    
    # Clip the values to [0,255]
    gainDivision = np.clip((255 * gainDivision), 0, 255)
    
    # Convert the mat type from float to uint8:
    gainDivision = gainDivision.astype("uint8")
    

    Gotta be careful with those data types and their conversions. This is the result:

    As you can see, most of the background is now uniform, that's pretty cool, because now we can apply a simple thresholding method. Let's try Otsu's Thresholding to get a nice binary mask of the elements:

    # Convert RGB to grayscale:
    grayscaleImage = cv2.cvtColor(gainDivision, cv2.COLOR_BGR2GRAY)
    
    # Get binary image via Otsu:
    _, binaryImage = cv2.threshold(grayscaleImage, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
    

    Which yields this binary mask:

    The mask can be improved by applying morphology, let's try to join those blobs applying a gentle closing operation:

    # Set kernel (structuring element) size:
    kernelSize = 3
    # Set morph operation iterations:
    opIterations = 2
    
    # Get the structuring element:
    morphKernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kernelSize, kernelSize))
    
    # Perform closing:
    binaryImage = cv2.morphologyEx( binaryImage, cv2.MORPH_CLOSE, morphKernel, None, None, opIterations, cv2.BORDER_REFLECT101 )
    

    This is the result:

    Alright, now, just for completeness, let's try to compute the bounding rectangles of all the elements. We can also filter blobs of small area and store each bounding rectangle in a list:

    # Find the blobs on the binary image:
    contours, hierarchy = cv2.findContours(binaryImage, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    # Store the bounding rectangles here:
    rectanglesList = []
    
    # Look for the outer bounding boxes (no children):
    for _, c in enumerate(contours):
    
        # Get blob area:
        currentArea = cv2.contourArea(c)
        # Set a min area threshold:
        minArea = 100
    
        if currentArea > minArea:
    
            # Approximate the contour to a polygon:
            contoursPoly = cv2.approxPolyDP(c, 3, True)
            # Get the polygon's bounding rectangle:
            boundRect = cv2.boundingRect(contoursPoly)
    
            # Store rectangles in list:
            rectanglesList.append(boundRect)
    
            # Get the dimensions of the bounding rect:
            rectX = boundRect[0]
            rectY = boundRect[1]
            rectWidth = boundRect[2]
            rectHeight = boundRect[3]
    
            # Set bounding rect:
            color = (0, 0, 255)
            cv2.rectangle( inputImageCopy, (int(rectX), int(rectY)),
                       (int(rectX + rectWidth), int(rectY + rectHeight)), color, 2 )
    
            cv2.imshow("Rectangles", inputImageCopy)
            cv2.waitKey(0)
    

    The final image is this:

    This is the total of detected elements:

    print("Elements found: "+str(len(rectanglesList)))
    Elements found: 37
    

    As you can see, there's a false positive. A bit of the shadow of a grain gets detected as an actual grain. Maybe adjusting the minimum area will get rid of the problem. Or maybe, if you are classifying each grain anyway, you could filter this kind of noise.