1

I need to know how can I capture the extreme edges of a line(starting and ending coordinates) after identifying the contours. Currently, I am identifying the contours for the shapes(different types of lines) in the following image and drawing them back to a new image. I have already tried to obtain the topmost, the bottom-most, the leftmost, and the right-most coordinates from the contours array but they will not accurate to a line that has curves like below. So is there any way to capture those starting and ending points from the contours array?

Source Code

import cv2
import numpy as np

# Let's load a simple image with 3 black squares
image = cv2.imread("C:/Users/Hasindu/3D Objects/edge-test-188.jpg")
cv2.waitKey(0)

# Grayscale
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

# Find Canny edges
edged = cv2.Canny(gray, 30, 200)
cv2.waitKey(0)

# Finding Contours
# Use a copy of the image e.g. edged.copy()
# since findContours alters the image
contours, hierarchy = cv2.findContours(edged,
    cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)

cv2.imshow('Canny Edges After Contouring', edged)
cv2.waitKey(0)

print("Number of Contours found = " + str(len(contours)))
print(contours)
topmost = tuple(contours[0][contours[0][:,:,1].argmin()][0]);
bottommost = tuple(contours[0][contours[0][:,:,1].argmax()][0]);

print(topmost);
print(bottommost);



# Draw all contours
# -1 signifies drawing all contours
cv2.drawContours(image, contours, -1, (0, 255, 0), 3)


cv2.imshow('Contours', image)
cv2.waitKey(0)
cv2.destroyAllWindows()

Input 1

Input

Output 1

Output

EDIT:

I have followed the solution suggested by stateMachine but it was not 100 percent accurate on my all the inputs.You can see clearly some of the endpoints on the 2nd input image are not detected by the solution.

Input 2 Input

Output 2 Output

Jeru Luke
  • 20,118
  • 13
  • 80
  • 87
Hasindu Dahanayake
  • 1,344
  • 2
  • 14
  • 37

2 Answers2

4

One possible solution involves applying the approach in this post. It involves convolving the input image with a special kernel used to identify end-points. These are the steps:

  1. Converting the image to grayscale
  2. Getting a binary image by applying Otsu's thresholding to the grayscale image
  3. Applying a little bit of morphology, to ensure we have continuous and closed curves
  4. Compute the skeleton of the image
  5. Convolve the skeleton with the end-points kernel
  6. Draw the end-points on the original image

Let's see the code:

# Imports:
import cv2
import numpy as np

# Reading an image in default mode:
inputImage = cv2.imread(path + fileName)
# Prepare a deep copy of the input for results:
inputImageCopy = inputImage.copy()

# Grayscale conversion:
grayscaleImage = cv2.cvtColor(inputImage, cv2.COLOR_BGR2GRAY)

# Threshold via Otsu:
_, binaryImage = cv2.threshold(grayscaleImage, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

The first bit is very straightforward. Just get a binary image using Otsu's Thresholding. This is the result:

The thresholding could miss some pixels inside the curves, leading to "gaps". We don't want that, because we are trying to identify the end-points, which are essentially gaps on the curves. Let's fill possible gaps using a little bit of morphology - a closing will help fill those smaller gaps:

# Set morph operation iterations:
opIterations = 2
# Get the structuring element:
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
# Perform Closing:
binaryImage = cv2.morphologyEx(binaryImage, cv2.MORPH_CLOSE, kernel, None, None, opIterations, cv2.BORDER_REFLECT101)

This is now the result:

Ok, what follows is getting the skeleton of the binary image. The skeleton is a version of the binary image where lines have been normalized to have a width of 1 pixel. This is useful because we can then convolve the image with a 3 x 3 kernel and look for specific pixel patterns - those that identify a end-point. Let's compute the skeleton using OpenCV's extended image processing module:

# Compute the skeleton:
skeleton = cv2.ximgproc.thinning(binaryImage, None, 1)

Nothing fancy, the thing is done in just one line of code. The result is this:

It is very subtle with this image, but the curves have now 1 px of width, so we can apply the convolution. The main idea of this approach is that the convolution yields a very specific value where patterns of black and white pixels are found in the input image. The value we are looking for is 110, but we need to perform some operations before the actual convolution. Refer to the original post for details. These are the operations:

# Threshold the image so that white pixels get a value of 0 and
# black pixels a value of 10:
_, binaryImage = cv2.threshold(skeleton, 128, 10, cv2.THRESH_BINARY)

# Set the end-points kernel:
h = np.array([[1, 1, 1],
              [1, 10, 1],
              [1, 1, 1]])

# Convolve the image with the kernel:
imgFiltered = cv2.filter2D(binaryImage, -1, h)

# Extract only the end-points pixels, those with
# an intensity value of 110:
endPointsMask = np.where(imgFiltered == 110, 255, 0)

# The above operation converted the image to 32-bit float,
# convert back to 8-bit uint
endPointsMask = endPointsMask.astype(np.uint8)

If we imshow the endPointsMask, we would get something like this:

In the above image, you can see the location of the identified end-points. Let's get the coordinates of these white pixels:

# Get the coordinates of the end-points:
(Y, X) = np.where(endPointsMask == 255)

Finally, let's draw circles on these locations:

# Draw the end-points:
for i in range(len(X)):
    # Get coordinates:
    x = X[i]
    y = Y[i]
    # Set circle color:
    color = (0, 0, 255)
    # Draw Circle
    cv2.circle(inputImageCopy, (x, y), 3, color, -1)

    cv2.imshow("Points", inputImageCopy)
    cv2.waitKey(0)

This is the final result:


EDIT: Identifying which blob produces each set of points

Since you need to also know which blob/contour/curve produced each set of end-points, you can re-work the code below with some other functions to achieve just that. Here, I'll mainly rely on a previous function I wrote that is used to detect the biggest blob in an image. One of the two curves will always be bigger (i.e., have a larger area) than the other. If you extract this curve, process it, and then subtract it from the original image iteratively, you could process curve by curve, and each time you could know which curve (the current biggest one) produced the current end-points. Let's modify the code to implement these ideas:

# Imports:
import cv2
import numpy as np

# image path
path = "D://opencvImages//"
fileName = "w97nr.jpg"

# Reading an image in default mode:
inputImage = cv2.imread(path + fileName)

# Deep copy for results:
inputImageCopy = inputImage.copy()

# Grayscale conversion:
grayscaleImage = cv2.cvtColor(inputImage, cv2.COLOR_BGR2GRAY)

# Threshold via Otsu:
_, binaryImage = cv2.threshold(grayscaleImage, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

# Set morph operation iterations:
opIterations = 2

# Get the structuring element:
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))

# Perform Closing:
binaryImage = cv2.morphologyEx(binaryImage, cv2.MORPH_CLOSE, kernel, None, None, opIterations, cv2.BORDER_REFLECT101)

# Compute the skeleton:
skeleton = cv2.ximgproc.thinning(binaryImage, None, 1)

Up until the skeleton computation, everything is the same. Now, we will extract the current biggest blob and process it to obtain its end-points, we will continue extracting the current biggest blob until there are no more curves to extract. So, we just modify the prior code to manage the iterative nature of this idea. Additionaly, let's store the end-points on a list. Each row of this list will denote a new curve:

# Processing flag:
processBlobs = True

# Shallow copy for processing loop:
blobsImage = skeleton

# Store points per blob here:
blobPoints = []

# Count the number of processed blobs:
blobCounter = 0

# Start processing blobs:
while processBlobs:

    # Find biggest blob on image:
    biggestBlob = findBiggestBlob(blobsImage)

    # Prepare image for next iteration, remove
    # currrently processed blob:
    blobsImage = cv2.bitwise_xor(blobsImage, biggestBlob)

    # Count number of white pixels:
    whitePixelsCount = cv2.countNonZero(blobsImage)
    # If the image is completely black (no white pixels)
    # there are no more curves to process:
    if whitePixelsCount == 0:
        processBlobs = False

    # Threshold the image so that white pixels get a value of 0 and
    # black pixels a value of 10:
    _, binaryImage = cv2.threshold(biggestBlob, 128, 10, cv2.THRESH_BINARY)

    # Set the end-points kernel:
    h = np.array([[1, 1, 1],
                  [1, 10, 1],
                  [1, 1, 1]])

    # Convolve the image with the kernel:
    imgFiltered = cv2.filter2D(binaryImage, -1, h)

    # Extract only the end-points pixels, those with
    # an intensity value of 110:
    endPointsMask = np.where(imgFiltered == 110, 255, 0)

    # The above operation converted the image to 32-bit float,
    # convert back to 8-bit uint
    endPointsMask = endPointsMask.astype(np.uint8)

    # Get the coordinates of the end-points:
    (Y, X) = np.where(endPointsMask == 255)

    # Prepare random color:
    color = (np.random.randint(low=0, high=256), np.random.randint(low=0, high=256), np.random.randint(low=0, high=256))

    # Prepare id string:
    string = "Blob: "+str(blobCounter)
    font = cv2.FONT_HERSHEY_COMPLEX
    tx = 10
    ty = 10 + 10 * blobCounter
    cv2.putText(inputImageCopy, string, (tx, ty), font, 0.3, color, 1)

    # Store these points in list:
    blobPoints.append((X,Y, blobCounter))
    blobCounter = blobCounter + 1

    # Draw the end-points:
    for i in range(len(X)):
        x = X[i]
        y = Y[i]

        cv2.circle(inputImageCopy, (x, y), 3, color, -1)

        cv2.imshow("Points", inputImageCopy)
        cv2.waitKey(0)

This loop extracts the biggest blob and processes it just like in the first part of the post - we convolve the image with the end-point kernel and locate the matching points. For the original input, this would be the result:

As you see, each set of points is drawn using one unique color (randomly generated). There's also the current blob "ID" (just an ascending count) drawn in text with the same color as each set of points, so you know which blob produced each set of end-points. The info is stored in the blobPoints list, we can print its values, like this:

# How many blobs where found:
blobCount = len(blobPoints)
print("Found: "+str(blobCount)+" blobs.")

# Let's check out each blob and their end-points:
for b in range(blobCount):
    # Fetch data:
    p1 = blobPoints[b][0]
    p2 = blobPoints[b][1]
    id = blobPoints[b][2]
    # Print data  for each blob:
    print("Blob: "+str(b)+" p1: "+str(p1)+" p2: "+str(p2)+" id: "+str(id))

Which prints:

Found: 2 blobs.
Blob: 0 p1: [39 66] p2: [ 42 104] id: 0
Blob: 1 p1: [129 119] p2: [25 49] id: 1

This is the implementation of the findBiggestBlob function, which just computes the biggest blob on the image using its area. It returns an image of the biggest blob isolated, this comes from a C++ implementation I wrote of the same idea:

def findBiggestBlob(inputImage):
    # Store a copy of the input image:
    biggestBlob = inputImage.copy()
    # Set initial values for the
    # largest contour:
    largestArea = 0
    largestContourIndex = 0

    # Find the contours on the binary image:
    contours, hierarchy = cv2.findContours(inputImage, cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE)

    # Get the largest contour in the contours list:
    for i, cc in enumerate(contours):
        # Find the area of the contour:
        area = cv2.contourArea(cc)
        # Store the index of the largest contour:
        if area > largestArea:
            largestArea = area
            largestContourIndex = i

    # Once we get the biggest blob, paint it black:
    tempMat = inputImage.copy()
    cv2.drawContours(tempMat, contours, largestContourIndex, (0, 0, 0), -1, 8, hierarchy)
    # Erase smaller blobs:
    biggestBlob = biggestBlob - tempMat

    return biggestBlob
stateMachine
  • 5,227
  • 4
  • 13
  • 29
  • Thank you very much for your great explanation.I tested your solution it works really well. But I need to capture those coordinates by knowing which line it belongs to .If I further explain my requirement I need to specifically know the (start,end) coordinates for a identified line.But the two arrays which we get after the below statement contains all the coordinates related to start and endpoints(x and y coordinates),I can't extract the start and end point coordinates related to a specific line at once.Statement:```(Y, X) = np.where(endPointsMask == 255)``` – Hasindu Dahanayake Jul 06 '21 at 08:36
  • It will be great if you can tell me how to obtain the start and end points related to a specific line at once from this solution. – Hasindu Dahanayake Jul 06 '21 at 08:37
  • I followed your code.But it was not accurate.Please see the updated post. – Hasindu Dahanayake Jul 15 '21 at 15:03
  • Change kernel size: kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) or not morphological processing for this image: kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 1)) – Alex Alex Jul 21 '21 at 03:56
  • @AlexAlex Thank you very much for your help .Changing the kernal size worked really well on detecting the missed endpoints in the previous solution but only problem is in the output for some cases it detects several endpoints as one blob .I mean output should consist only the starting and endpoint coordinates related to one line.But for some cases it detects ending and starting coordinates of different lines to gather and output it like this, 29 p1: [545 562 562] p2: [193 281 283] id: 29 .And some times empty like this.Blob: 41 p1: [] p2: [] id: 41``` . – Hasindu Dahanayake Jul 21 '21 at 23:37
  • How to obtain the specific ending and starting coordinates related to a line. – Hasindu Dahanayake Jul 21 '21 at 23:38
1

After finding the contours, we apply a special filter for each point of the contour. It applies the mask centered around each pixel, then finds the contours (or connected components or blobs) in the masked region. Ideally, for end points there'll be only one blob in the region, for other points there'll be more than one. We take the candidate end points for each contour, then cluster them into two clusters because there'll be more than two candidates because of the filter width and line thickness. If the clustering outputs two points, they are the end points of the processed contour.

An example is shown below.

mask:
1 1 1 1 1
1 0 0 0 1
1 0 0 0 1
1 0 0 0 1
1 1 1 1 1

image:
0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0
0 0 0 1 1 1 1 1 0 0 0
0 0 0 0 1 1 1 1 0 0 0
0 0 0 0 0 0 0 1 0 0 0
0 0 0 0 0 0 0 1 0 0 0
0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0

response for end point:
1 1 1 1 1     0 0 0 0 0        0 0 0 0 0
1 0 0 0 1     0 0 0 0 0        0 0 0 0 0
1 0 0 0 1  &  0 0 1 1 1    =   0 0 0 0 1 
1 0 0 0 1     0 0 0 1 1        0 0 0 0 1
1 1 1 1 1     0 0 0 0 0        0 0 0 0 0

response for corner point:
1 1 1 1 1     0 0 0 0 0        0 0 0 0 0
1 0 0 0 1     0 0 0 0 0        0 0 0 0 0
1 0 0 0 1  &  1 1 1 0 0    =   1 0 0 0 0 
1 0 0 0 1     1 1 1 0 0        1 0 0 0 0
1 1 1 1 1     0 0 1 0 0        0 0 1 0 0

This won't work well if the image is too noisy or your inputs are jpegs and the thresholding isn't good, because it can introduce some stray components in the masked region so that the point is counted as not a candidate for an end point.

If the lines in your input image (or the thresholded image) are more than 2 pixels wide, you can change the filter radius (r in the code).

If the line gap is less than two pixels, again you'll have problems with the current filter or anything larger. But in this case, you can draw each contour in a separate image and then apply the filter, but I haven't done this in the code for simplicity.

Here, we are using CHAIN_APPROX_SIMPLE to reduce the contour pixel count, and Otsu thresholding. For simplicity, the code does not handle cases where contour points fall at image boundaries.

out

import cv2 as cv
import numpy as np
    
im = cv.imread('dclSa.jpg')
gray = cv.cvtColor(im, cv.COLOR_BGR2GRAY)
# apply Otsu threshold
th, bw = cv.threshold(gray, 0, 1, cv.THRESH_BINARY_INV | cv.THRESH_OTSU)
# find contours
contours, _ = cv.findContours(bw, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)

# create filter
r = 2
mask = np.ones((2*r+1, 2*r+1), dtype=np.uint8)
mask[1:2*r, 1:2*r] = 0
#print mask

criteria = (cv.TERM_CRITERIA_EPS + cv.TERM_CRITERIA_MAX_ITER, 5, 1.0)

for contour in contours:
    all_x = []
    all_y = []
    for point in contour:
        x = point[0][0]
        y = point[0][1]
        # extract the region centered around the contour pixel
        roi = bw[y-r:y+r+1, x-r:x+r+1]
        # find the blobs in masked region
        n, _ = cv.findContours(roi & mask, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)

        # if the blob count is 1, this pixel is an end point candidate
        # if you use cv.connectedComponents to find blobs, then check 2 == n as it counts background
        if 1 == len(n):
            all_x.append(x)
            all_y.append(y)

    # if there are no candidate points, check next contour
    if not all_x:
        continue

    # we are done with a contour. cluster the end point candidates into two clusters
    points = np.vstack((all_x, all_y))
    _, _, endpoints = cv.kmeans(np.float32(points.transpose()), 2, None, criteria, 5, cv.KMEANS_RANDOM_CENTERS)
    
    # if the clustering goes well, we'll have the two end points of the contour
    if 2 == len(endpoints) and 2 == len(endpoints[0]) and 2 == len(endpoints[1]):
        im = cv.circle(im, (int(endpoints[0][0]), int(endpoints[0][1])), 3, (255, 0, 0), -1)
        im = cv.circle(im, (int(endpoints[1][0]), int(endpoints[1][1])), 3, (0, 255, 0), -1)
dhanushka
  • 10,492
  • 2
  • 37
  • 47
  • This is a great answer. You use `cv.findContours()` to determine the connected components in the binary image which is a bit of a hack in my opinion. I would recommend using `cv.connectedComponents()` instead, specifically `retval, labels = cv.connectedComponents(bw, labels=np.zeros_like(bw), connectivity=8)` – Bart van Otterdijk Jul 24 '21 at 22:04
  • @BartvanOtterdijk Thank you. Mentioned `cv.connectedComponents()` usage in a comment if one wants to use it. – dhanushka Jul 25 '21 at 04:05
  • @dhanushka I tried your solution unfortunately , it gave me an error. – Hasindu Dahanayake Jul 25 '21 at 19:35
  • 46 # if the clustering goes well, we'll have the two end points of the contour 47 if 2 == len(endpoints) and 2 == len(endpoints[0]) and 2 == len(endpoints[1]): ---> 48 im = cv.circle(im, (endpoints[0][0], endpoints[0][1]), 3, (255, 0, 0), -1) 49 im = cv.circle(im, (endpoints[1][0], endpoints[1][1]), 3, (0, 255, 0), -1) error: OpenCV(4.5.2) :-1: error: (-5:Bad argument) in function 'circle' > Overload resolution failed: > - Can't parse 'center'. Sequence item with index 0 has a wrong type > - Can't parse 'center'. Sequence item with index 0 has a wrong type – Hasindu Dahanayake Jul 25 '21 at 19:36
  • @HasinduDahanayake Cast end point coordinates to int in the drawing function. I updated the answer. – dhanushka Jul 26 '21 at 02:00