{
"cells": [
{
"cell_type": "markdown",
"id": "33abcca3",
"metadata": {},
"source": [
"# Lesson 3: Thresholding, Erosion, and Dilation\n",
"\n",
"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)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9d078678",
"metadata": {},
"outputs": [],
"source": [
"import numpy as np\n",
"import cv2\n",
"import matplotlib.pyplot as plt"
]
},
{
"cell_type": "markdown",
"id": "e0e4762c",
"metadata": {},
"source": [
"## Build a noisy test image\n",
"\n",
"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."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "5bb8b156",
"metadata": {},
"outputs": [],
"source": [
"rng = np.random.default_rng(0)\n",
"\n",
"gray = np.full((120, 120), 40, dtype=np.uint8)\n",
"cv2.circle(gray, (60, 60), 35, 220, -1)\n",
"\n",
"noise = rng.normal(0, 35, gray.shape)\n",
"noisy = np.clip(gray.astype(np.int16) + noise, 0, 255).astype(np.uint8)\n",
"\n",
"# sprinkle a few salt-and-pepper specks\n",
"speckle_coords = rng.integers(0, 120, size=(60, 2))\n",
"for y, x in speckle_coords:\n",
" noisy[y, x] = 255 if noisy[y, x] < 128 else 0\n",
"\n",
"plt.imshow(noisy, cmap='gray', vmin=0, vmax=255)\n",
"plt.title('Noisy grayscale image')\n",
"plt.axis('off')\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "b4304e41",
"metadata": {},
"source": [
"## Thresholding\n",
"\n",
"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."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "c7259f59",
"metadata": {},
"outputs": [],
"source": [
"threshold_value = 128\n",
"binary = (noisy > threshold_value) * np.uint8(255)\n",
"plt.imshow(binary, cmap='gray', vmin=0, vmax=255)\n",
"plt.title(f'Thresholded (t={threshold_value})')\n",
"plt.axis('off')\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "b6d84f64",
"metadata": {},
"source": [
"### OpenCV's `cv2.threshold`\n",
"\n",
"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."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "499efd5b",
"metadata": {},
"outputs": [],
"source": [
"_, binary_cv2 = cv2.threshold(noisy, threshold_value, 255, cv2.THRESH_BINARY)\n",
"\n",
"print('cv2.threshold matches the NumPy version exactly:', np.array_equal(binary, binary_cv2))"
]
},
{
"cell_type": "markdown",
"id": "f2615cc2",
"metadata": {},
"source": [
"## Otsu's method: choosing the threshold automatically\n",
"\n",
"`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."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "35be738b",
"metadata": {},
"outputs": [],
"source": [
"thresh_otsu, binary_otsu = cv2.threshold(noisy, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)\n",
"print(f'Otsu-selected threshold: {thresh_otsu:.1f} (we hand-picked {threshold_value} above)')\n",
"\n",
"fig, axes = plt.subplots(1, 3, figsize=(9, 3.5))\n",
"axes[0].hist(noisy.ravel(), bins=50, color='gray')\n",
"axes[0].axvline(thresh_otsu, color='red', linestyle='--', label=f'Otsu t={thresh_otsu:.0f}')\n",
"axes[0].set_title('Histogram', fontsize=10)\n",
"axes[0].legend(fontsize=8)\n",
"axes[1].imshow(binary, cmap='gray', vmin=0, vmax=255)\n",
"axes[1].set_title(f'Manual (t={threshold_value})', fontsize=10)\n",
"axes[2].imshow(binary_otsu, cmap='gray', vmin=0, vmax=255)\n",
"axes[2].set_title(f'Otsu (t={thresh_otsu:.0f})', fontsize=10)\n",
"for ax in axes[1:]:\n",
" ax.axis('off')\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "51c589da",
"metadata": {},
"source": [
"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."
]
},
{
"cell_type": "markdown",
"id": "dd199cce",
"metadata": {},
"source": [
"## Erosion\n",
"\n",
"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."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "60ec2be6",
"metadata": {},
"outputs": [],
"source": [
"kernel = np.ones((3, 3), np.uint8)\n",
"eroded = cv2.erode(binary, kernel, iterations=1)\n",
"\n",
"plt.imshow(eroded, cmap='gray', vmin=0, vmax=255)\n",
"plt.title('Eroded')\n",
"plt.axis('off')\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "9accd5cc",
"metadata": {},
"source": [
"## Dilation\n",
"\n",
"**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."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "bed22002",
"metadata": {},
"outputs": [],
"source": [
"dilated = cv2.dilate(binary, kernel, iterations=1)\n",
"\n",
"plt.imshow(dilated, cmap='gray', vmin=0, vmax=255)\n",
"plt.title('Dilated')\n",
"plt.axis('off')\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "dc64b275",
"metadata": {},
"source": [
"## Opening: erosion followed by dilation\n",
"\n",
"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."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "7a663bf3",
"metadata": {},
"outputs": [],
"source": [
"opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)\n",
"\n",
"fig, axes = plt.subplots(1, 4, figsize=(12, 3))\n",
"for ax, img, title in zip(\n",
" axes,\n",
" [binary, eroded, dilated, opened],\n",
" ['Thresholded', 'Eroded', 'Dilated', 'Opened\\n(erode then dilate)'],\n",
"):\n",
" ax.imshow(img, cmap='gray', vmin=0, vmax=255)\n",
" ax.set_title(title, fontsize=10)\n",
" ax.axis('off')\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"id": "78cafb82",
"metadata": {},
"source": [
"### Exercise\n",
"\n",
"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?\n",
"2. Increase the kernel size to `(5, 5)`. How does that change the result compared to `(3, 3)`?"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.x"
}
},
"nbformat": 4,
"nbformat_minor": 5
}