Lesson 3: Thresholding, Erosion, and Dilation

Thresholding converts a grayscale image into a binary (black/white) image by comparing each pixel to a cutoff value. The result is often noisy, so we clean it up with two basic morphological operations: erosion (shrinks white regions, removes small specks) and dilation (grows white regions, fills small holes).

In [1]:
import numpy as np
import cv2
import matplotlib.pyplot as plt

Build a noisy test image

We synthesize a grayscale image with a bright shape on a dark background, then add random noise so some background pixels are bright and some foreground pixels are dark.

In [2]:
rng = np.random.default_rng(0)

gray = np.full((120, 120), 40, dtype=np.uint8)
cv2.circle(gray, (60, 60), 35, 220, -1)

noise = rng.normal(0, 35, gray.shape)
noisy = np.clip(gray.astype(np.int16) + noise, 0, 255).astype(np.uint8)

# sprinkle a few salt-and-pepper specks
speckle_coords = rng.integers(0, 120, size=(60, 2))
for y, x in speckle_coords:
    noisy[y, x] = 255 if noisy[y, x] < 128 else 0

plt.imshow(noisy, cmap='gray', vmin=0, vmax=255)
plt.title('Noisy grayscale image')
plt.axis('off')
plt.show()
No description has been provided for this image

Thresholding

Thresholding is nothing more than a per-pixel comparison: every pixel above the cutoff becomes white (255), every pixel at or below it becomes black (0). With NumPy, that's a single boolean comparison plus np.where to pick the output value.

In [3]:
threshold_value = 128
binary = (noisy > threshold_value) * np.uint8(255)
plt.imshow(binary, cmap='gray', vmin=0, vmax=255)
plt.title(f'Thresholded (t={threshold_value})')
plt.axis('off')
plt.show()
No description has been provided for this image

OpenCV's cv2.threshold

OpenCV bundles the same operation into cv2.threshold, which is convenient because it also supports variants beyond simple binary thresholding — inverted output, capping instead of zeroing, and automatic cutoff selection (cv2.THRESH_OTSU) — all through the same function. For plain binary thresholding, it computes exactly the same result as the NumPy version above.

In [4]:
_, binary_cv2 = cv2.threshold(noisy, threshold_value, 255, cv2.THRESH_BINARY)

print('cv2.threshold matches the NumPy version exactly:', np.array_equal(binary, binary_cv2))
cv2.threshold matches the NumPy version exactly: True

Otsu's method: choosing the threshold automatically

threshold_value = 128 above was picked by hand. Otsu's method (Otsu, 1979) picks it automatically: it treats the image's histogram as a mixture of two classes (foreground and background) and searches over every possible cutoff for the one that minimizes the within-class variance (equivalently, maximizes the variance between the two classes) — the cutoff that best separates the histogram into two tight, well-separated clusters. It assumes the histogram is roughly bimodal, which a bright shape on a dark background satisfies well.

In [5]:
thresh_otsu, binary_otsu = cv2.threshold(noisy, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print(f'Otsu-selected threshold: {thresh_otsu:.1f}   (we hand-picked {threshold_value} above)')

fig, axes = plt.subplots(1, 3, figsize=(9, 3.5))
axes[0].hist(noisy.ravel(), bins=50, color='gray')
axes[0].axvline(thresh_otsu, color='red', linestyle='--', label=f'Otsu t={thresh_otsu:.0f}')
axes[0].set_title('Histogram', fontsize=10)
axes[0].legend(fontsize=8)
axes[1].imshow(binary, cmap='gray', vmin=0, vmax=255)
axes[1].set_title(f'Manual (t={threshold_value})', fontsize=10)
axes[2].imshow(binary_otsu, cmap='gray', vmin=0, vmax=255)
axes[2].set_title(f'Otsu (t={thresh_otsu:.0f})', fontsize=10)
for ax in axes[1:]:
    ax.axis('off')
plt.tight_layout()
plt.show()
Otsu-selected threshold: 129.0   (we hand-picked 128 above)
No description has been provided for this image

Otsu's automatically computed threshold lands close to the hand-picked value here. Otsu is a good default choice whenever a threshold is needed, but keep in mind that it won't work with badly-separated or multi-modal histograms.

Erosion

There are two basic morphological operations (erosion and dilation) for cleaning up the salt-and-pepper specks and ragged edges in the above results. Erosion slides a small structuring element (kernel) over the image; a pixel stays white only if the entire kernel fits inside the white region. This shrinks white regions and removes small white specks.

In [6]:
kernel = np.ones((3, 3), np.uint8)
eroded = cv2.erode(binary, kernel, iterations=1)

plt.imshow(eroded, cmap='gray', vmin=0, vmax=255)
plt.title('Eroded')
plt.axis('off')
plt.show()
No description has been provided for this image

Dilation

Dilation does the opposite: a pixel becomes white if the kernel overlaps any of the white region. This grows white regions and fills small black holes/specks.

In [7]:
dilated = cv2.dilate(binary, kernel, iterations=1)

plt.imshow(dilated, cmap='gray', vmin=0, vmax=255)
plt.title('Dilated')
plt.axis('off')
plt.show()
No description has been provided for this image

Opening: erosion followed by dilation

Applying erosion then dilation (an opening) removes small white specks while restoring the size of the main region — a common way to denoise a thresholded image.

In [8]:
opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)

fig, axes = plt.subplots(1, 4, figsize=(12, 3))
for ax, img, title in zip(
    axes,
    [binary, eroded, dilated, opened],
    ['Thresholded', 'Eroded', 'Dilated', 'Opened\n(erode then dilate)'],
):
    ax.imshow(img, cmap='gray', vmin=0, vmax=255)
    ax.set_title(title, fontsize=10)
    ax.axis('off')
plt.tight_layout()
plt.show()
No description has been provided for this image

Exercise

  1. Try cv2.MORPH_CLOSE (dilation followed by erosion) instead of MORPH_OPEN. How does it differ, and which black-pixel noise does it fix that opening does not?
  2. Increase the kernel size to (5, 5). How does that change the result compared to (3, 3)?