{ "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 }