pythonopencvcomputer-visionobject-detectionedge-detection

How to detect barely visible lines on the grayscale image and calculate their length


I am trying to write computer vision code based on the OpenCV library in Python to detect horizontal lines with intensity close to background. See example of the image below.

enter image description here

I have tried 2 approaches. The first one is based on Canny edge detection and Hough transform, but it detected only a few lines (see code and image below).

import math
import numpy as np
import cv2

scaleFactor = 1

maskX1 = 57
maskX2 = 263
maskY1 = 30
maskY2 = 164

angleStart = -1
angleEnd = 1

verticalKernel = np.array([[1, 2, 1], [0, 0, 0], [-1, -2, -1]])
sharpenKernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]])

def applyKernel(image, kernel):
    return cv2.filter2D(image, -1, kernel)


# read image
image_c = cv2.imread('images/1.png')

image_c = cv2.resize(image_c, None, fx=scaleFactor, fy=scaleFactor, interpolation=cv2.INTER_CUBIC)

cv2.imshow('Original Image', image_c)

# convert to grayscale
image_g = cv2.cvtColor(image_c, cv2.COLOR_RGB2GRAY)
# image_g = cv2.bilateralFilter(image_g, 15, 15, 15)

image_g = applyKernel(image_g, sharpenKernel)
cv2.imshow('Sharpen Image', image_g)

image_g = applyKernel(image_g, verticalKernel)

cv2.imshow('Vertical Sobel Operator', image_g)

# Gaussian blur and Canny
threshold_low = 250
threshold_high = 300

image_canny = cv2.Canny(image_g, threshold_low, threshold_high)
cv2.imshow('Canny Image', image_canny)

# Visualize region of interest
mask = np.zeros_like(image_g)
vertices = np.array([[(maskX1 * scaleFactor, maskY1 * scaleFactor), (maskX2 * scaleFactor, maskY1 * scaleFactor), (maskX2 * scaleFactor, maskY2 * scaleFactor), (maskX1 * scaleFactor, maskY2 * scaleFactor)]], dtype=np.int32)
cv2.fillPoly(mask, vertices, 255)
masked_image = cv2.bitwise_and(image_canny, mask)

# masked_image = image_canny
cv2.imshow('Region of interest', masked_image)

rho = 1 * scaleFactor  # distance resolution in pixels
theta = np.pi / 180  # angular resolution in radians
threshold = 3  # minimum number of votes
min_line_len = 10 * scaleFactor  # minimum number of pixels making up a line
max_line_gap = 20 * scaleFactor  # maximum gap in pixels between connectable line segments

lines = cv2.HoughLinesP(masked_image, rho, theta, threshold, np.array([]), minLineLength=min_line_len,
                        maxLineGap=max_line_gap)

line_image = np.zeros((masked_image.shape[0], masked_image.shape[1], 3), dtype=np.uint8)

numLines = 0
totalLineLength = 0
for line in lines:
    for x1, y1, x2, y2 in line:
        if x2 == x1:
            lineAngle = 90
        else:
            lineAngle = math.degrees(math.atan((y2 - y1) / (x2 - x1)))
        if angleStart < lineAngle < angleEnd:
            cv2.line(line_image, (x1, y1), (x2, y2), [0, 0, 255], 2)
            numLines = numLines + 1
            totalLineLength = totalLineLength + math.sqrt((x2 - x1)**2 + (y2 - y1)**2)

α = 1
β = 0.3
γ = 0

# Resultant weighted image is calculated as follows: original_img * α + img * β + γ
image_with_lines = cv2.addWeighted(image_c, α, line_image, β, γ)
cv2.imshow('Image with lines', image_with_lines)

cv2.waitKey()
cv2.destroyAllWindows()

enter image description here

The second approach was based on image thresholding and contour analysis, but the results were also disappointing (see code and image below).

import math
import numpy as np
import cv2

scaleFactor = 1

maskX1 = 57
maskX2 = 263
maskY1 = 30
maskY2 = 164

angleStart = -5
angleEnd = 5

verticalKernel = np.array([[1, 2, 1], [0, 0, 0], [-1, -2, -1]])
sharpenKernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]])

basePath = 'images/'
fileExtension = '.png'

def applyKernel(image, kernel):
    return cv2.filter2D(image, -1, kernel)

def getHoughLines(image, masked_image):
    rho = 1 * scaleFactor  # distance resolution in pixels
    theta = np.pi / 180  # angular resolution in radians
    threshold = 3  # minimum number of votes
    min_line_len = 10 * scaleFactor  # minimum number of pixels making up a line
    max_line_gap = 5 * scaleFactor  # maximum gap in pixels between connectable line segments

    lines = cv2.HoughLinesP(masked_image, rho, theta, threshold, np.array([]), minLineLength=min_line_len,
                            maxLineGap=max_line_gap)

    line_image = np.zeros((masked_image.shape[0], masked_image.shape[1]), dtype=np.uint8)

    numLines = 0
    totalLineLength = 0
    for line in lines:
        for x1, y1, x2, y2 in line:
            if x2 == x1:
                lineAngle = 90
            else:
                lineAngle = math.degrees(math.atan((y2 - y1) / (x2 - x1)))
            if angleStart < lineAngle < angleEnd:
                cv2.line(line_image, (x1, y1), (x2, y2), 255, 2)
                numLines = numLines + 1
                totalLineLength = totalLineLength + math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)

    α = 1
    β = 0.3
    γ = 0

    # Resultant weighted image is calculated as follows: original_img * α + img * β + γ

    image_with_lines = cv2.addWeighted(image, α, line_image, β, γ)
    cv2.imshow('Image with lines', image_with_lines)

    return image_with_lines

# read image
image_g = cv2.imread('images/1.png', cv2.IMREAD_GRAYSCALE)

# image_g = cv2.resize(image_g, None, fx=scaleFactor, fy=scaleFactor, interpolation=cv2.INTER_CUBIC)

cv2.imshow('Original Image', image_g)

# Apply Gaussian blur to reduce noise
# image_blurred = cv2.GaussianBlur(image_g, (5, 5), 0)
image_blurred = image_g

image_blurred = applyKernel(image_blurred, sharpenKernel)
cv2.imshow('Sharpen Image', image_blurred)

image_blurred = applyKernel(image_blurred, verticalKernel)

cv2.imshow('Vertical Sobel Operator', image_blurred)

# Apply adaptive thresholding to binarize the image
# _, binary_image = cv2.threshold(image_blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
_, binary_image = cv2.threshold(image_blurred, 70, 255, cv2.THRESH_BINARY)

cv2.imshow('Binary image', binary_image)

# Visualize region of interest
mask = np.zeros_like(image_g)
vertices = np.array([[(maskX1 * scaleFactor, maskY1 * scaleFactor), (maskX2 * scaleFactor, maskY1 * scaleFactor), (maskX2 * scaleFactor, maskY2 * scaleFactor), (maskX1 * scaleFactor, maskY2 * scaleFactor)]], dtype=np.int32)
cv2.fillPoly(mask, vertices, 255)
masked_image = cv2.bitwise_and(binary_image, mask)

cv2.imshow('Masked image', masked_image)

# morphological operations
# kernel = np.ones((2,2),np.uint8)
# masked_image = cv2.morphologyEx(masked_image, cv2.MORPH_OPEN, kernel)

cv2.imshow('morphologyEx', masked_image)

# Perform edge detection
edges = cv2.Canny(masked_image, 30, 200)

cv2.imshow('Edges', edges)

contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

print(contours)

for contour in contours:
    # Calculate the length of the contour
    length = cv2.arcLength(contour, True)

    # Calculate the area of the contour
    area = cv2.contourArea(contour)

    # Filter out small contours (adjust the area threshold as needed)
    if area > 1:
        x, y, width, height = cv2.boundingRect(contour)

        if width > 5:
            # Draw the contour on the original image
            cv2.drawContours(image_g, [contour], -1, (0, 255, 0), 2)

            # Print or store the length and area
            print(f"Length: {length}, Area: {area}")

cv2.imshow('Processed Image', image_g)
cv2.waitKey(0)
cv2.destroyAllWindows()

enter image description here

Is there a way to detect these lines more accurately?


Solution

  • I came up with the following solution based on image thresholding and contours detection. Combination of 2 additional filters gave more visible lines which was easier to detect using thresholding and contours detection.

    def getStreakyStructuresForOneImage(imagePath, showProcessingImages, filterVertical = False):
    scaleFactor = 1
    
    maskX1 = 128  # 57
    maskX2 =  355 # 263
    maskY1 = 50
    maskY2 = 205
    
    angleStart = -5
    angleEnd = 5
    
    verticalKernel = np.array([[1, 2, 1], [0, 0, 0], [-1, -2, -1]])
    sharpenKernel2 = 0.64 * np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]])
    sharpenKernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]])
    
    if filterVertical:
        verticalKernel = np.transpose(verticalKernel)
        sharpenKernel2 = np.transpose(sharpenKernel2)
        sharpenKernel = np.transpose(sharpenKernel)
    
    # read image
    image_g = cv2.imread(imagePath, cv2.IMREAD_GRAYSCALE)
    
    if showProcessingImages:
        cv2.imshow('Original Image', image_g)
    
    image_blurred = applyKernel(image_g, sharpenKernel)
    
    if showProcessingImages:
        cv2.imshow('Sharpen Image', image_blurred)
    
    image_blurred = applyKernel(image_blurred, verticalKernel)
    image_blurred = applyKernel(image_blurred, sharpenKernel2)
    
    if showProcessingImages:
        cv2.imshow('Sharpening using different kernels', image_blurred)
    
    _, binary_image = cv2.threshold(image_blurred, 70, 255, cv2.THRESH_BINARY)
    
    if showProcessingImages:
        cv2.imshow('Binary image', binary_image)
    
    # Visualize region of interest
    mask = np.zeros_like(image_g)
    vertices = np.array([[(maskX1 * scaleFactor, maskY1 * scaleFactor), (maskX2 * scaleFactor, maskY1 * scaleFactor),
                          (maskX2 * scaleFactor, maskY2 * scaleFactor), (maskX1 * scaleFactor, maskY2 * scaleFactor)]],
                        dtype=np.int32)
    cv2.fillPoly(mask, vertices, 255)
    masked_image = cv2.bitwise_and(binary_image, mask)
    
    if showProcessingImages:
        cv2.imshow('Masked image', masked_image)
    
    contours, _ = cv2.findContours(masked_image, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    
    image_g = cv2.cvtColor(image_g, cv2.COLOR_GRAY2RGB)
    masked_image = cv2.cvtColor(masked_image, cv2.COLOR_GRAY2RGB)
    
    line_image = np.zeros((masked_image.shape[0], masked_image.shape[1], 3), dtype=np.uint8)
    
    numberOfMeaningfulContours = 0
    totalLineLength = 0
    
    for contour in contours:
        # Calculate the length of the contour
        length = cv2.arcLength(contour, True)
    
        # Calculate the area of the contour
        area = cv2.contourArea(contour)
    
        # Filter out small contours (adjust the area threshold as needed)
        if area > 0:
            x, y, width, height = cv2.boundingRect(contour)
            if filterVertical:
                if height > 50:
                    # Draw the contour on the original image
                    cv2.drawContours(line_image, [contour], -1, (0, 0, 255), 2)
    
                    numberOfMeaningfulContours += 1;
                    totalLineLength += width * mmInPx;
                    # Print or store the length and area
                    print(f"Length: {length}, Area: {area}")
            else:
                if width > 15:
                    # Draw the contour on the original image
                    cv2.drawContours(line_image, [contour], -1, (0, 0, 255), 2)
    
                    numberOfMeaningfulContours += 1;
                    totalLineLength += width * mmInPx;
                    # Print or store the length and area
                    print(f"Length: {length}, Area: {area}")
    
    α = 1
    β = 0.14
    γ = 0
    
    # Resultant weighted image is calculated as follows: original_img * α + img * β + γ
    image_with_lines = cv2.addWeighted(image_g, α, line_image, β, γ)
    
    if showProcessingImages:
        cv2.imshow('Processed Image', image_with_lines)
    
    averageLineLength = 0
    if numberOfMeaningfulContours > 0:
        averageLineLength = totalLineLength / numberOfMeaningfulContours
    
    return image_with_lines, numberOfMeaningfulContours, totalLineLength, averageLineLength