Morphology and noise removal

Erosion, dilation, opening, closing, gradients and top-hat, choosing a kernel, and removing salt-and-pepper noise from binary masks.

The four basic operations

import cv2
import numpy as np

mask = cv2.imread("mask.png", cv2.IMREAD_GRAYSCALE)
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
ellipse = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
cross = cv2.getStructuringElement(cv2.MORPH_CROSS, (5, 5))

eroded = cv2.erode(mask, kernel, iterations=1)          # shrinks white regions
dilated = cv2.dilate(mask, kernel, iterations=1)        # grows white regions

# opening = erode then dilate: removes small white specks
opened = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)

# closing = dilate then erode: fills small black holes
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)

# gradient = dilation minus erosion: an outline of the region
gradient = cv2.morphologyEx(mask, cv2.MORPH_GRADIENT, ellipse)

# top-hat = original minus opening: bright details smaller than the kernel
tophat = cv2.morphologyEx(mask, cv2.MORPH_TOPHAT, ellipse)

# black-hat = closing minus original: dark details smaller than the kernel
blackhat = cv2.morphologyEx(mask, cv2.MORPH_BLACKHAT, ellipse)

print([m.mean() for m in (mask, opened, closed)])
OperationEffect on white regionsRemovesKeeps
ErodeShrinksThin protrusionsSolid cores
DilateGrowsSmall gapsEverything
OpenShrinks then growsSmall white specksLarge regions with their size
CloseGrows then shrinksSmall black holesRegion extent
GradientOutlineInteriorsBoundaries
Top-hatExtracts bright detailLarge structuresSmall bright features
  • Opening removes objects smaller than the kernel; closing fills gaps smaller than the kernel. The kernel size is the threshold, so choose it in pixels relative to the feature you want to keep.
  • Apply opening before closing on a noisy mask: removing specks first means closing does not merge two specks into one blob.
  • Morphology assumes white is foreground. If your region of interest is dark, invert the mask first with cv2.bitwise_not.
  • Iterations compound: two iterations with a 3x3 kernel is roughly a 5x5 kernel for a rectangular element, but not for an ellipse. Prefer one operation with a larger kernel when the shape matters.

Noise removal

gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

# salt and pepper: isolated black and white pixels, common after a bad scan
# median is the right filter: it ignores outliers rather than averaging them in
median = cv2.medianBlur(gray, 5)

# Gaussian noise: a small blur, at the cost of edge softness
gaussian = cv2.GaussianBlur(gray, (5, 5), sigmaX=1.2)

# edge-preserving denoising: slower, but it does not blur boundaries
denoised = cv2.fastNlMeansDenoising(gray, None, h=10,
                                    templateWindowSize=7, searchWindowSize=21)

# bilateral: smooths flat areas while keeping edges, good before edge detection
bilateral = cv2.bilateralFilter(gray, d=9, sigmaColor=75, sigmaSpace=75)

# on a binary mask, combined morphological cleaning is usually enough
binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
cleaned = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=1)
cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel, iterations=2)

print("foreground before", round(float(binary.mean()) / 255, 4),
      "after", round(float(cleaned.mean()) / 255, 4))
  • Median blur is the correct filter for salt-and-pepper noise. Gaussian blur spreads the outlier into its neighbours and leaves a grey smear where a black pixel was.
  • The median kernel must be odd and greater than 3 for visible effect. A 5x5 kernel is a reasonable start; a 7x7 starts removing small genuine features.
  • Bilateral filtering is edge-preserving but slow, and its two sigma parameters interact confusingly. It is usually worth it before Canny on a noisy image.
  • Denoising is not free: every filter removes some signal. If your detection accuracy falls after filtering, you probably smoothed away the feature you were detecting.

Designing kernels

# custom kernels: any binary array works
horizontal = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 1))   # join horizontal text
vertical = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 25))     # join vertical rules

binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]

# a common OCR preprocessing recipe: remove horizontal and vertical rules
lines_h = cv2.morphologyEx(binary, cv2.MORPH_OPEN, horizontal, iterations=1)
lines_v = cv2.morphologyEx(binary, cv2.MORPH_OPEN, vertical, iterations=1)
rules = cv2.bitwise_or(lines_h, lines_v)
text_only = cv2.bitwise_and(binary, cv2.bitwise_not(rules))
text_only = cv2.morphologyEx(text_only, cv2.MORPH_CLOSE,
                             cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)))

# a diagonal two-element kernel for small diagonal joins
diagonal = np.array([[1, 0, 0],
                     [0, 1, 0],
                     [0, 0, 1]], dtype="uint8")
joined = cv2.dilate(binary, diagonal, iterations=1)

print("rule pixels removed:", int((rules > 0).sum()))
  • A long thin kernel joins features along one axis only. That is the cleanest way to separate table rules from text without a machine-learning model.
  • cv2.getStructuringElement is preferred over a hand-built array for the standard shapes because it centres the element correctly for even-sized kernels.
  • Test kernel sizes on a handful of representative images and record them. A kernel that works at one scan resolution will not work at another; normalise the input resolution first.
  • Morphological operations are fast relative to filters, so applying several in a chain costs little compared with a denoising pass.
💡
Morphology operates on shape, not on intensity. It cannot recover a feature that the threshold already destroyed. Fix the binarisation before tuning kernels, or you will be refining noise.

FAQ

Opening or closing first?
Opening first on a noisy mask: remove the specks, then fill the holes. Closing first can bridge two separate specks into a false blob that opening cannot undo.
Why did morphology make my results worse?
The kernel is larger than the features you care about. Halve the kernel size and check the intermediate mask after each operation, saving it to disk so you can see where the detail was lost.

Filters and edge detection Thresholding and image segmentation

Last refreshed 2026-09-18.