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).
import numpy as np
import cv2
import matplotlib.pyplot as plt
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.
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()
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.
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()
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.
_, 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))
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.
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'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.
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.
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()
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.
dilated = cv2.dilate(binary, kernel, iterations=1)
plt.imshow(dilated, cmap='gray', vmin=0, vmax=255)
plt.title('Dilated')
plt.axis('off')
plt.show()
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.
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()
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?(5, 5). How does that change the result compared to (3, 3)?