Morphological Operations
Morphology manipulates the shapes in a binary image using a small structuring element - a kernel that slides over the image and grows, shrinks, or reshapes the white regions. The two primitives are erosion (shrink) and dilation (grow), and every named operation - opening, closing, gradient, top hat - is a sequencing of them. Morphology is the standard cleanup pass between thresholding and contour finding: it removes specks, bridges cracks, and separates objects that touch.
Erosion and Dilation
import cv2
import numpy as np
binary = cv2.imread("binary.png", cv2.IMREAD_GRAYSCALE)
# the structuring element - the shape that does the work
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
cross = cv2.getStructuringElement(cv2.MORPH_CROSS, (5, 5))
ellipse = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
# erosion: white shrinks; a pixel stays white only if the kernel fits
eroded = cv2.erode(binary, kernel, iterations=1)
# dilation: white grows; a pixel turns white if any kernel pixel hits white
dilated = cv2.dilate(binary, kernel, iterations=1)
cv2.imwrite("eroded.png", eroded)
cv2.imwrite("dilated.png", dilated)
Erosion keeps a pixel white only where the entire kernel lands on white - it peels away thin structures and small blobs. Dilation does the opposite, thickening white regions. The kernel's shape matters: a rectangle is anisotropic, a cross spares diagonal connections, an ellipse is the all-round default. iterations repeats the operation - two iterations of a 5x5 rect is not the same as one of a 9x9, and stacking small kernels is the cheap way to get large effects.
Opening, Closing, and Friends
import cv2
binary = cv2.imread("binary.png", cv2.IMREAD_GRAYSCALE)
kernel = np.ones((5, 5), np.uint8)
# opening: erode THEN dilate - removes small white specks, keeps big shapes
opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)
# closing: dilate THEN erode - fills small holes and cracks inside shapes
closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
# morphological gradient: dilate - erode, the object outline
grad = cv2.morphologyEx(binary, cv2.MORPH_GRADIENT, kernel)
# white top hat: src - opening, isolates small bright details
tophat = cv2.morphologyEx(binary, cv2.MORPH_TOPHAT, kernel)
# black hat: closing - src, isolates small dark details in bright areas
blackhat = cv2.morphologyEx(binary, cv2.MORPH_BLACKHAT, kernel)
cv2.imwrite("opened.png", opened)
cv2.imwrite("closed.png", closed)
cv2.imwrite("gradient.png", grad)
morphologyEx takes the operation as a flag. Opening (erode then dilate) is the speck remover - noise smaller than the kernel vanishes, real objects keep their size. Closing (dilate then erode) is the hole and crack filler - the essential fix for thresholded text with broken strokes, so contours see whole letters. The gradient gives an outline band; top hat and black hat extract size-bounded details, the classic tools for finding small bright grains or dark marks in a textured background.
The Standard Cleanup Recipe
import cv2
import numpy as np
gray = cv2.cvtColor(cv2.imread("scan.jpg"), cv2.COLOR_BGR2GRAY)
_, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
# 1. kill isolated specks
clean = cv2.morphologyEx(binary, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))
# 2. bridge broken strokes inside characters
clean = cv2.morphologyEx(clean, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
# 3. the result is contour-ready
contours, _ = cv2.findContours(clean, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
print("objects:", len(contours))
Open-then-close with small kernels is the default mask-cleanup sequence, and it appears in almost every classical pipeline. Size the kernels to the objects: too large and small objects erode away, too small and the noise survives. When objects touch, a closing can merge them and an opening can split them - the same operation is a fix in one context and a bug in another, which is why the kernel size is the parameter to look at when morphology misbehaves.
Distance Transform: The Bonus Tool
import cv2
binary = cv2.imread("binary.png", cv2.IMREAD_GRAYSCALE)
# for each white pixel: distance to the nearest background pixel
dist = cv2.distanceTransform(binary, cv2.DIST_L2, 5)
# the peaks of the distance map are object centers - the seed for
# the watershed separation of touching objects
_, peaks = cv2.threshold(dist, 0.4 * dist.max(), 255, cv2.THRESH_BINARY)
cv2.imwrite("distance.png", dist)
distanceTransform measures how far each foreground pixel is from the background. Its interior peaks mark object cores, and thresholding those peaks is the standard way to seed the watershed algorithm that separates touching objects - the classic "count the overlapping circles" problem. It is also a direct size proxy: the distance map's maximum is the inradius of the shape.
Summary
Think in grow/shrink: dilate grows white, erode shrinks it, and the named operations are sequences - opening (erode-dilate) removes specks, closing (dilate-erode) fills holes and cracks. Apply them with cv2.morphologyEx and a structuring element from getStructuringElement, sizing the kernel to the objects. The practical recipe is threshold -> open (kill specks) -> close (heal strokes) -> findContours, and when objects touch, the distanceTransform peaks are the seeds for watershed separation. Morphology is shape arithmetic on masks: small, fast, and the invisible cleanup layer under nearly every contour pipeline.