pythonopencvcontour

Area of a closed contour on a plot using python openCV


I am attempting to find the area inside an arbitrarily-shaped closed curve plotted in python (example image below). So far, I have tried to use both the alphashape and polygon methods to acheive this, but both have failed. I am now attempting to use OpenCV and the floodfill method to count the number of pixels inside the curve and then I will later convert that to an area given the area that a single pixel encloses on the plot. Example image: testplot.jpg enter image description here

In order to do this, I am doing the following, which I adapted from another post about OpenCV.

import cv2
import numpy as np

# Input image
img = cv2.imread('testplot.jpg', cv2.IMREAD_GRAYSCALE)

# Dilate to better detect contours
temp = cv2.dilate(temp, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)))

# Find largest contour
cnts, _ = cv2.findContours(255-temp, cv2.RETR_TREE , cv2.CHAIN_APPROX_NONE) #255-img and cv2.RETR_TREE is to account for how cv2 expects the background to be black, not white, so I convert the background to black.
largestCnt = [] #I expect this to yield the blue contour
for cnt in cnts:
    if (len(cnt) > len(largestCnt)):
        largestCnt = cnt

# Determine center of area of largest contour
M = cv2.moments(largestCnt)
x = int(M["m10"] / M["m00"])
y = int(M["m01"] / M["m00"])

# Initial mask for flood filling, should cover entire figure
width, height = temp.shape
mask = img2 = np.ones((width + 2, height + 2), np.uint8) * 255
mask[1:width, 1:height] = 0

# Generate intermediate image, draw largest contour onto it, flood fill this contour
temp = np.zeros(temp.shape, np.uint8)
temp = cv2.drawContours(temp, largestCnt, -1, 255, cv2.FILLED)
_, temp, mask, _ = cv2.floodFill(temp, mask, (x, y), 255)
temp = cv2.morphologyEx(temp, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)))

area = cv2.countNonZero(temp) #Number of pixels encircled by blue line

I expect from this to get to a place where I have the same image as above, but with the center of the contour filled in white and the background and original blue contour in black. I end up with this:

result.jpg enter image description here

While this at first glance appears to have accurately turned the area inside the contour white, the white area is actually larger than the area inside the contour and so the result I get is overestimating the number of pixels inside it. Any input on this would be greatly appreciated. I am fairly new to OpenCV so I may have misunderstood something.

EDIT: Thanks to a comment below, I made some edits and this is now my code, with edits noted:

import cv2
import numpy as np

# EDITED INPUT IMAGE: Input image
img = cv2.imread('testplot2.jpg', cv2.IMREAD_GRAYSCALE)

# EDIT: threshold
_, temp = cv2.threshold(img, 250, 255, cv2.THRESH_BINARY_INV)

# EDIT, REMOVED: Dilate to better detect contours

# Find largest contour
cnts, _ = cv2.findContours(temp, cv2.RETR_EXTERNAL , cv2.CHAIN_APPROX_NONE)
largestCnt = [] #I expect this to yield the blue contour
for cnt in cnts:
    if (len(cnt) > len(largestCnt)):
        largestCnt = cnt

# Determine center of area of largest contour
M = cv2.moments(largestCnt)
x = int(M["m10"] / M["m00"])
y = int(M["m01"] / M["m00"])


# Initial mask for flood filling, should cover entire figure
width, height = temp.shape
mask = img2 = np.ones((width + 2, height + 2), np.uint8) * 255
mask[1:width, 1:height] = 0

# Generate intermediate image, draw largest contour, flood filled
temp = np.zeros(temp.shape, np.uint8)
temp = cv2.drawContours(temp, largestCnt, -1, 255, cv2.FILLED)
_, temp, mask, _ = cv2.floodFill(temp, mask, (x, y), 255)
temp = cv2.morphologyEx(temp, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)))

area = cv2.countNonZero(temp) #Number of pixels encircled by blue line

I input a different image with the axes and the frame that python adds by default removed for ease. I get what I expect at the second step, so this image. However, in the enter image description here both the original contour and the area it encircles appear to have been made white, whereas I want the original contour to be black and only the area it encircles to be white. How might I acheive this?


Solution

  • The problem is your opening operation at the end. This morphological operation includes a dilation at the end that expands the white contour, increasing its area. Let’s try a different approach where no morphology is involved. These are the steps:

    1. Convert your image to grayscale
    2. Apply Otsu’s thresholding to get a binary image, let’s work with black and white pixels only.
    3. Apply a first flood-fill operation at image location (0,0) to get rid of the outer white space.
    4. Filter small blobs using an area filter
    5. Find the “Curve Canvas” (The white space that encloses the curve) and locate and store its starting point at (targetX, targetY)
    6. Apply a second flood-fill al location (targetX, targetY)
    7. Get the area of the isolated blob with cv2.countNonZero

    Let’s take a look at the code:

    import cv2
    import numpy as np
    
    # Set image path
    path = "C:/opencvImages/"
    fileName = "cLIjM.jpg"
    
    # Read Input image
    inputImage = cv2.imread(path+fileName)
    inputCopy = inputImage.copy()
    
    # Convert BGR to grayscale:
    grayscaleImage = cv2.cvtColor(inputImage, cv2.COLOR_BGR2GRAY)
    
    # Threshold via Otsu + bias adjustment:
    threshValue, binaryImage = cv2.threshold(grayscaleImage, 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU)
    

    This is the binary image you get:

    Now, let’s flood-fill at the corner located at (0,0) with a black color to get rid of the first white space. This step is very straightforward:

    # Flood-fill background, seed at (0,0) and use black color:
    cv2.floodFill(binaryImage, None, (0, 0), 0)
    

    This is the result, note how the first big white area is gone:

    Let’s get rid of the small blobs applying an area filter. Everything below an area of 100 is gonna be deleted:

    # Perform an area filter on the binary blobs:
    componentsNumber, labeledImage, componentStats, componentCentroids = \
    cv2.connectedComponentsWithStats(binaryImage, connectivity=4)
    
    # Set the minimum pixels for the area filter:
    minArea = 100
    
    # Get the indices/labels of the remaining components based on the area stat
    # (skip the background component at index 0)
    remainingComponentLabels = [i for i in range(1, componentsNumber) if componentStats[i][4] >= minArea]
    
    # Filter the labeled pixels based on the remaining labels,
    # assign pixel intensity to 255 (uint8) for the remaining pixels
    filteredImage = np.where(np.isin(labeledImage, remainingComponentLabels) == True, 255, 0).astype('uint8')
    

    This is the result of the filter:

    Now, what remains is the second white area, I need to locate its starting point because I want to apply a second flood-fill operation at this location. I’ll traverse the image to find the first white pixel. Like this:

    # Get Image dimensions:
    height, width = filteredImage.shape
    
    # Store the flood-fill point here:
    targetX = -1
    targetY = -1
    
    for i in range(0, width):
        for j in range(0, height):
            # Get current binary pixel:
            currentPixel = filteredImage[j, i]
            # Check if it is the first white pixel:
            if targetX == -1 and targetY == -1 and currentPixel == 255:
                targetX = i
                targetY = j
    
    print("Flooding in X = "+str(targetX)+" Y: "+str(targetY))
    

    There’s probably a more elegant, Python-oriented way of doing this, but I’m still learning the language. Feel free to improve the script (and share it here). The loop, however, gets me the location of the first white pixel, so I can now apply a second flood-fill at this exact location:

    # Flood-fill background, seed at (targetX, targetY) and use black color:
    cv2.floodFill(filteredImage, None, (targetX, targetY), 0)
    

    You end up with this:

    As you see, just count the number of non-zero pixels:

    # Get the area of the target curve:
    area = cv2.countNonZero(filteredImage)
    
    print("Curve Area is: "+str(area))
    

    The result is:

    Curve Area is: 1510