Post

Image processing: Segmentation (Part one)

Image processing: Segmentation (Part one)

Project && Guide

Table of Contents

Overview

This post provides a comprehensive walkthrough of image segmentation using Python libraries including OpenCV, NumPy, SciPy, and Matplotlib. The workflow covers:

  • Loading and preprocessing images
  • Computing lightness and applying median and Gaussian filtering to reduce noise
  • Extracting edges using gradient-based methods (Perwitt filters), Marr-Hildreth (Laplacian of Gaussian), and Canny edge detection
  • Applying Otsu’s thresholding for automatic binarization
  • Comparing the effectiveness of different edge detection techniques

Each step is explained with code and visualizations, highlighting the impact of various filters and parameters on segmentation results. The notebook is designed to help you understand and implement practical image segmentation pipelines for a variety of applications.

Key Steps and Code Explanations

Library Imports and Utility Functions

The following code imports essential libraries for image processing and visualization, and defines utility functions for displaying images and converting between float and integer formats. These utilities are used throughout the segmentation workflow.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import cv2  # OpenCV for image operations
import numpy as np  # Numerical operations
import scipy  # Scientific computing
import matplotlib.pyplot as plt  # Plotting
plt.rcParams['image.cmap'] = 'gray'  # Set default colormap to grayscale
import matplotlib
import scipy.signal as signal  # Signal processing functions
matplotlib.rcParams['figure.figsize'] = (25.0, 10.0)  # Set default figure size
from scipy.cluster.vq import kmeans  # K-means clustering

def imshow(img, title=None, ax=None, is_bgr=False, cmap='gray'):
    """
    Display an image with optional title and color map.
    img: image to display (hxw or hxwx3)
    title: Figure's title
    ax: Axis to display on (optional)
    is_bgr: Convert BGR to RGB if True
    cmap: Color map
    """
    show=False
    plt.axis('off')
    if ax is None:
        show=True
        if title is not None:
            plt.title(title)
        ax = plt
    else:
        if title is not None:
            ax.set_title(title)
            ax.set_axis_off()
    if img.ndim==2:
        ax.imshow(img, cmap='gray')
    else:
        if is_bgr:
            img = img[:,:,::-1].copy()
        ax.imshow(img)
    if show:
        plt.show()

def f2int(img):
    return (img*255).astype(np.uint8)

def int2f(img):
    return img.astype(np.float32)/255.

1. Load and Display Input Image

This section loads the input image from disk, converts it to a floating-point format for processing, and displays it using the utility function defined above.

1
2
img = int2f(cv2.imread('chaton.png'))  # Read image and convert to float
imshow(img, title='Input image', is_bgr=True)  # Show image with title

2. Compute and Display Lightness

Here, the lightness channel of the image is computed by averaging the maximum and minimum values across the RGB channels. This reduces the color image to a single-channel grayscale image, simplifying further processing.

1
2
img_light = 0.5 * (img.max(2) + img.min(2))  # Calculate lightness from max and min RGB values
imshow(img_light, title='Lightness of input image')  # Show lightness image

3. Median Filtering

This code applies a median filter to the lightness image to reduce noise and smooth out small variations, which helps improve the quality of subsequent segmentation steps.

1
2
3
kernel_size = 7  # Size of the median filter
img_light = signal.medfilt2d(img_light, kernel_size)  # Apply median filter
imshow(img_light, title='Median filtered of lightness image')  # Show filtered image

4. Gradient Computation (Perwitt Filters)

This section computes the image gradients using Perwitt filters in both the x and y directions. The gradients highlight edges and transitions, which are important features for segmenting different regions in the image.

1
2
3
4
5
6
7
8
9
10
fig1, (ax1, ax2) = plt.subplots(1,2)
g_x = [[1., 1., 1.], [0, 0, 0], [-1., -1., -1.]]  # Perwitt filter for x direction
x_grad = signal.convolve2d(img_light, g_x, mode='same')  # Compute x gradient
imshow(abs(x_grad), title='Directional Gradient in x axis', ax=ax1)
g_y = [[1., 0, -1], [1, 0, -1.], [1, 0, -1.]]  # Perwitt filter for y direction
y_grad = signal.convolve2d(img_light, g_y, mode='same')  # Compute y gradient
imshow(abs(y_grad), title='Directional Gradient in y axis', ax=ax2)
plt.show()
img_grad = np.sqrt(x_grad**2 + y_grad**2)  # Compute gradient magnitude
imshow(img_grad, title='Gradient of lightness image')

5. Gaussian Filtering and Gradient

This part applies a Gaussian filter to the lightness image for further smoothing, then recomputes the gradients. Smoothing with a Gaussian kernel helps reduce noise and makes edge detection more robust.

1
2
3
4
5
6
7
8
9
kernel_gauss = create_gauss_kernel(size=5, sigma=1)  # Create Gaussian kernel
img_gauss = signal.convolve2d(img_light, kernel_gauss, mode='same')  # Apply Gaussian filter
g_x = [[1., 1., 1.], [0, 0, 0], [-1., -1., -1.]]
g_y = [[1., 0, -1], [1., 0, -1.], [1., 0, -1.]]
x_grad = signal.convolve2d(img_gauss, g_x, mode='same')
y_grad = signal.convolve2d(img_gauss, g_y, mode='same')
img_gauss_grad = np.sqrt(x_grad**2 + y_grad**2)
imshow(img_gauss, title='Gaussian filtered of lightness image')
imshow(img_gauss_grad, title='Gradient of Gaussian filtered applied on the lightness image')

6. Otsu Thresholding Function

The following function implements Otsu’s method, which automatically determines an optimal threshold value for segmenting an image into foreground and background. This is a crucial step for many segmentation tasks.

Applying Otsu Thresholding and Binarization

After defining the Otsu thresholding function, the next step is to apply it to gradient images for binarization. The gradients and Gaussian-filtered gradients are converted to integer format and thresholded using Otsu’s method. For comparison, OpenCV’s built-in Otsu thresholding is also used. The results are visualized to show the effect of thresholding on edge detection and segmentation.

The code below demonstrates:

  • Computing Otsu thresholds for gradient and Gaussian-filtered gradient images
  • Binarizing these images using the computed thresholds
  • Comparing the custom Otsu threshold with OpenCV’s built-in function
  • Visualizing the binarized results
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def otsu_thresholding(img):
    assert img.dtype == np.uint8, "The image must have a uint8 type"
    thresholds = np.unique(img)
    hist, bins = np.histogram(img, np.arange(256))
    bins = bins[:-1]
    hist = hist / img.size
    nu = 0
    for k in thresholds:
        hist_c1 = hist[:k+1]
        hist_c2 = hist[k+1:]
        P1_k = hist_c1.sum() / hist.sum()
        P2_k = hist_c2.sum() / hist.sum()
        if P1_k == 0:
            continue
        if P2_k == 0:
            break
        m1_k = np.sum(bins[:k+1] * hist_c1) / P1_k.astype(np.float32)
        m2_k = np.sum(bins[k+1:] * hist_c2) / P2_k.astype(np.float32)
        var_interclasse = P1_k * P2_k * (m2_k - m1_k) ** 2
        if var_interclasse > nu:
            threshold = k
            nu = var_interclasse
    thresholded = threshold
    return thresholded

# Apply Otsu thresholding to gradient images
thr_grad = otsu_thresholding(f2int(img_grad))  # Image taken from Q4
thr_gauss_grad = otsu_thresholding(f2int(img_gauss_grad))  # Image taken from Q5

# Binarize the images
img_grad_b = f2int(img_grad) > thr_grad
img_gauss_grad_b = f2int(img_gauss_grad) > thr_gauss_grad

# Compare with OpenCV's built-in Otsu thresholding
ret2, th2 = cv2.threshold(f2int(img_gauss_grad), 0, 255, cv2.THRESH_OTSU)
print('For test:\nThreshold using our function is %d.\nThreshold using cv2 function is %d.' % (thr_gauss_grad, ret2))

# Visualize the results
imshow(img_grad_b, title='Binarized image with the Otsu threshold = {} on gradient image.'.format(thr_grad))
imshow(img_gauss_grad_b, title='Binarized image with the Otsu threshold = {} on the gaussian + gradient image.'.format(thr_gauss_grad))

7. Marr-Hildreth (Laplacian of Gaussian) Edge Detection

This section explores edge detection using the Marr-Hildreth approach, also known as the Laplacian of Gaussian (LoG) method. The process involves:

  • Smoothing the image with a Gaussian filter to reduce noise.
  • Computing the Laplacian (second derivative) of the smoothed image to highlight regions of rapid intensity change.
  • Detecting edges by finding zero-crossings in the Laplacian image, with a threshold to filter out weak edges.

By varying the Gaussian standard deviation ($\sigma$) and the zero-crossing threshold, you can control the sensitivity and thickness of detected edges. Larger $\sigma$ values produce smoother images and less noisy edges, while higher thresholds reduce the number of detected edges.

This method is effective for detecting closed contours and is less sensitive to noise than simple gradient-based methods, but the choice of parameters significantly affects the results.

Implementation and Parameter Exploration

The following code demonstrates the Marr-Hildreth edge detection process, including parameter exploration for $\sigma$ and the zero-crossing threshold:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# Function to detect zero-crossings in the Laplacian image
def zeros_crossing(img, threshold):
    out = np.zeros_like(img)
    for i in range(1, img.shape[0]-1):
        for j in range(1, img.shape[1]-1):
            ec = 0
            if img[i-1, j]*img[i+1, j] < 0: # y direction
                ec = max(ec, np.abs(img[i+1, j]-img[i-1, j]))
            if img[i, j-1]*img[i, j+1] < 0: # x direction
                ec = max(ec, np.abs(img[i, j+1]-img[i, j-1]))
            if img[i-1, j-1]*img[i+1, j+1] < 0: # diagonal
                ec = max(ec, np.abs(img[i+1, j+1]-img[i-1, j-1]))
            if img[i+1, j-1]*img[i-1, j+1] < 0: # other diagonal
                ec = max(ec, np.abs(img[i+1, j-1]-img[i-1, j+1]))
            out[i, j] = ec
    threshold = threshold * np.max(out)
    return out > threshold

# Example: single parameter set
sigma_val = 5
kernel_gauss = create_gauss_kernel(size=6*sigma_val-1, sigma=sigma_val) # Gaussian Mask
img_gauss = signal.convolve2d(img_light, kernel_gauss, mode='same')
imshow(img_gauss, 'Gaussian filtered image with $\sigma$ = %d' % sigma_val)

mask_laplacian = np.asarray([[-1.,-1.,-1.], [-1.,8.,-1.], [-1.,-1.,-1.]]) # Laplacian mask
img_gauss_laplac = signal.convolve2d(img_gauss, mask_laplacian, mode='same')
imshow(img_gauss_laplac, 'Laplacian of Gaussian filtered image')

zero_thresh = 0.005
img_zero = zeros_crossing(img_gauss_laplac, zero_thresh)
imshow(img_zero, 'Marr-Hildreth with the threshold = %.4f.' % zero_thresh)

# Parameter exploration: different sigma and threshold values
fig1, ((ax0, ax1, ax2), (ax3,ax4,ax5),(ax6,ax7,ax8)) = plt.subplots(3,3,figsize = (20,12))
AX = [[ax0,ax1,ax2],[ax3,ax4,ax5],[ax6,ax7,ax8]]
SIGMA = [2, 5, 8]
THRESH = [0.002, 0.005, 0.01]
for i, sigma in enumerate(SIGMA):
    for j, thresh in enumerate(THRESH):
        kernel_gauss = create_gauss_kernel(size=6*sigma-1, sigma=sigma)
        img_gauss = signal.convolve2d(img_light, kernel_gauss, mode='same')
        img_gauss_laplac = signal.convolve2d(img_gauss, mask_laplacian, mode='same')
        img_zero_ = zeros_crossing(img_gauss_laplac, thresh)
        title = '$\sigma$ = %d , Zero Thresholding = %.3f'%(sigma, thresh)
        imshow(img_zero_, title = title, ax = AX[i][j])

Observations

By increasing the $\sigma$ value, the Gaussian filtered image becomes smoother and the detected edges are less noisy, but some fine details may be lost. Increasing the zero-crossing threshold reduces the number of detected edges, making the result sparser. Adjusting both parameters is important for accurate edge detection using the Marr-Hildreth method.

8. Canny Edge Detection

This section demonstrates edge detection using the Canny method, a widely used algorithm that detects a wide range of edges in images. The Canny method uses two thresholds (lower and upper) to identify strong and weak edges, and includes non-maximum suppression and edge tracking by hysteresis.

The following code applies the Canny edge detector to the lightness image, varying the lower threshold while keeping the upper threshold fixed at 100. The results show how the choice of thresholds affects the number and continuity of detected edges.

1
2
3
4
5
6
7
8
9
10
11
12
13
max_hysteresis = 100
min_hysteresis = 95
img_canny = cv2.Canny(f2int(img_light), min_hysteresis, max_hysteresis).astype(bool)

fig1, ((ax1, ax2),(ax3, ax4)) = plt.subplots(2,2, figsize=(20,12))
min_hysteresis = 10
imshow(cv2.Canny(f2int(img_light), min_hysteresis, max_hysteresis).astype(bool), title='Canny with the lower threshold = %d' % min_hysteresis, ax=ax1)
min_hysteresis = 40
imshow(cv2.Canny(f2int(img_light), min_hysteresis, max_hysteresis).astype(bool), title='Canny with the lower threshold = %d' % min_hysteresis, ax=ax2)
min_hysteresis = 80
imshow(cv2.Canny(f2int(img_light), min_hysteresis, max_hysteresis).astype(bool), title='Canny with the lower threshold = %d' % min_hysteresis, ax=ax3)
min_hysteresis = 95
imshow(cv2.Canny(f2int(img_light), min_hysteresis, max_hysteresis).astype(bool), title='Canny with the lower threshold = %d' % min_hysteresis, ax=ax4)

Observation: Increasing the lower threshold reduces the number of detected edges, resulting in fewer, more connected edge pixels. The Canny method provides thin and well-connected edges, and is less sensitive to noise compared to simple gradient-based methods.

9. Comparison of Edge Detection Methods

This section compares the results of four edge detection approaches: Gradient + Otsu, Gaussian + Gradient + Otsu, Marr-Hildreth (LoG), and Canny. The following code displays the results side by side for visual comparison.

1
2
3
4
5
fig1, ((ax1, ax2),(ax3, ax4)) = plt.subplots(2,2, figsize=(20,12))
imshow(img_grad_b, title='Gradient + Otsu', ax=ax1)
imshow(img_gauss_grad_b, title='Gaussian + Gradient + Otsu', ax=ax2)
imshow(img_zero, title='Marr-Hildreth approach (Gaussian + Laplace + Zero-crossing)', ax=ax3)
imshow(img_canny, title='Canny approach', ax=ax4)

Observation:

  • Adding a Gaussian filter before gradient-based edge detection reduces noise and produces cleaner edges.
  • The Canny method yields the thinnest and most consistent edges.
  • The Marr-Hildreth approach is sensitive to the zero-crossing threshold, while the Canny method is less sensitive to the lower threshold.
  • Overall, the Canny and Gaussian + Gradient + Otsu methods provide the most visually convincing results for edge detection in this context.

Project repository

GitHub Code: Image Processing Project

This post is licensed under CC BY 4.0 by the author.