"I went in for a small procedure: they eroded my speckle, then dilated me right back to size. The surgeon says a second visit would change nothing. Apparently I am now idempotent."
A Recently Opened Binary Mask
Erosion and dilation are rarely used alone; their power is in composition, where one operator's destruction is the other's restoration: opening (erode, then dilate) deletes what is too small to matter and returns everything else to size, closing (dilate, then erode) fills what is too small to be a real hole, and their differences carve out boundaries and rescue thresholds from uneven lighting. This section turns the atoms of Section 6.2 into the four or five compound operators that do most of the real cleanup work in industrial vision.
The previous section closed on a deliberate loose end: erosion and dilation are not inverses, and information destroyed by one cannot be recovered by the other. This section weaponizes that asymmetry. Erode a mask and every speck smaller than the structuring element is gone forever; dilate the result and the surviving objects regrow to their original size, minus the casualties. Run the pair in the opposite order and pinholes vanish instead of specks. These two compositions, with their differences and grayscale variants, form the standard toolkit applied to virtually every thresholded image before anyone dares count or measure anything, including the masks produced by the segmentation networks of Chapter 24.
1. Opening: Delete the Small, Restore the Rest Beginner
The opening of $A$ by structuring element $B$ is erosion followed by dilation with the same SE:
$$ A \circ B \;=\; (A \ominus B) \oplus B. $$
There is an equivalent characterization that explains the behavior far better than the formula: the opening is the union of all placements of $B$ that fit entirely inside $A$. Imagine painting the inside of every object using $B$ as the brush, never letting the brush cross the boundary. Whatever the brush can reach survives; whatever it cannot (specks smaller than the brush, hairline protrusions, thin bridges) is erased. Surviving objects keep their size and roughly their shape, with convex corners gently rounded to the brush's curvature. Opening also never adds a single pixel: $A \circ B \subseteq A$, a property called anti-extensivity.
2. Closing: Fill the Small, Preserve the Rest Beginner
The closing runs the composition in the opposite order:
$$ A \bullet B \;=\; (A \oplus B) \ominus B. $$
By the duality law of Section 6.2, closing the foreground is exactly opening the background: $A \bullet B = (A^c \circ \hat{B})^c$. Every fact about opening therefore has a closing mirror. Closing fills pinholes and narrow gulfs smaller than the SE, bridges gaps thinner than the SE, and never removes a pixel ($A \subseteq A \bullet B$, extensivity). The brush picture works here too, painted from outside: closing keeps every point the brush cannot reach when rolled around the exterior, so small interior voids that the brush cannot enter get sealed.
Both compositions share a third property that no amount of repeated blurring from Chapter 3 can claim: idempotence. Opening an opened image changes nothing, $(A \circ B) \circ B = A \circ B$, and likewise for closing. The operator converges in one application; "more cleanup" requires a bigger SE, not another pass. Figure 6.3.1 shows the canonical cleanup pipeline that puts both to work.
The pipeline of Figure 6.3.1 is six lines of code. The demonstration below builds a controllable mess (one true object, salt specks, glare holes) so the cleanup can be verified by counting rather than by squinting:
import cv2
import numpy as np
rng = np.random.default_rng(7)
mask = np.zeros((400, 400), np.uint8)
cv2.circle(mask, (200, 200), 120, 255, -1) # the real object
for x, y in rng.integers(0, 400, (60, 2)): # 60 salt specks
cv2.circle(mask, (int(x), int(y)), int(rng.integers(1, 3)), 255, -1)
for x, y in rng.integers(120, 280, (25, 2)): # 25 glare pinholes
cv2.circle(mask, (int(x), int(y)), int(rng.integers(1, 4)), 0, -1)
se = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9))
opened = cv2.morphologyEx(mask, cv2.MORPH_OPEN, se)
cleaned = cv2.morphologyEx(opened, cv2.MORPH_CLOSE, se)
for name, m in [("raw", mask), ("opened", opened), ("cleaned", cleaned)]:
n, _ = cv2.connectedComponents(m)
holes, _ = cv2.connectedComponents(255 - m, connectivity=4)
print(f"{name:8s} objects = {n - 1:3d} holes+bg = {holes - 1}")
# Output:
# raw objects = 59 holes+bg = 24 (some specks overlap; some holes merge)
# opened objects = 1 holes+bg = 24
# cleaned objects = 1 holes+bg = 1 (only the true background remains)
cv2.morphologyEx with MORPH_OPEN then MORPH_CLOSE takes the mask from 59 apparent objects with two dozen false holes down to exactly one object and one background region, with every step verifiable by the component counts of Section 6.1 (note the 4-connected background count, per the Jordan pairing).Opening with a disk of radius $r$ encodes the claim "no real object feature is thinner than $2r$ pixels"; closing with the same disk claims "no real hole is smaller than that." Choose $r$ from physical knowledge (smallest defect that matters, optics resolution, pixel pitch from Chapter 1), not by eyeballing one image. When the claim is false, morphology fails silently and confidently: an opening sized for dust will happily erase the hairline crack you were hired to find.
3. Granulometry: Sieving an Image by Size Intermediate
Opening with ever-larger SEs is a measurement instrument in its own right. Since opening removes everything smaller than the SE, the surviving foreground area as a function of SE radius is a decreasing curve, and its drops mark the sizes at which the image holds its content. This is granulometry, the original 1964 application: sieving crushed ore through ever-finer meshes, in software. The curve's negative derivative (the pattern spectrum) is effectively a histogram of object sizes computed without ever segmenting a single object, a trick worth remembering whenever objects touch or overlap too much to count, and a spiritual cousin of the multi-scale analysis in Chapter 4.
import cv2
import numpy as np
beans = (cv2.imread("coffee_beans.png", cv2.IMREAD_GRAYSCALE) > 128)
beans = beans.astype(np.uint8) * 255
areas = []
radii = range(1, 30)
for r in radii:
se = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * r + 1, 2 * r + 1))
surviving = cv2.morphologyEx(beans, cv2.MORPH_OPEN, se)
areas.append(int(np.count_nonzero(surviving)))
spectrum = -np.diff(areas) # area destroyed at each radius
peak = radii[int(np.argmax(spectrum)) + 1]
print(f"dominant grain radius ~ {peak} px")
# Representative output:
# dominant grain radius ~ 14 px
4. Morphological Gradients: Boundaries From Differences Intermediate
Differences between the atoms and the original image isolate exactly the pixels each operator moves, and those pixels live on boundaries. Three standard combinations exist. The morphological gradient, $(A \oplus B) - (A \ominus B)$, yields a boundary ribbon roughly two pixels thick straddling the contour. The internal gradient $A - (A \ominus B)$ keeps the one-pixel rim just inside the object, and the external gradient $(A \oplus B) - A$ the rim just outside. On binary masks these are the standard way to extract object outlines before the more structured contour tracing of Section 6.6; on grayscale images the morphological gradient behaves like an edge-strength map, an unsigned cousin of the derivative filters from Chapter 3 and a precursor to the proper edge detectors of Chapter 9. One line each in OpenCV: cv2.morphologyEx(img, cv2.MORPH_GRADIENT, se), with the internal and external variants as ordinary subtractions.
5. Top-Hat & Black-Hat: Rescuing Thresholds From Bad Lighting Intermediate
The most valuable composition for grayscale work subtracts an opening from its input. The white top-hat, $f - (f \circ B)$, works because a grayscale opening with a large SE erases every bright structure narrower than the SE, leaving a clean estimate of the smooth background; subtracting that background leaves precisely the small bright structures, now sitting on a flat field. The black-hat, $(f \bullet B) - f$, does the same for small dark structures on bright backgrounds, ink on unevenly lit paper being the canonical case. This is the morphological answer to the uneven-illumination problem that motivated adaptive thresholding in Chapter 2: rather than adapting the threshold to the lighting, flatten the lighting and keep the simple global threshold.
import cv2
import numpy as np
# Page photographed under a desk lamp: strong left-to-right falloff.
page = cv2.imread("lamp_lit_page.png", cv2.IMREAD_GRAYSCALE)
_, global_otsu = cv2.threshold(page, 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
# SE must dwarf the text strokes but stay smaller than the lighting scale.
se = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (31, 31))
blackhat = cv2.morphologyEx(page, cv2.MORPH_BLACKHAT, se) # dark ink -> bright
_, rescued = cv2.threshold(blackhat, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print("ink fraction, naive Otsu :", f"{(global_otsu > 0).mean():.1%}")
print("ink fraction, black-hat :", f"{(rescued > 0).mean():.1%}")
# Representative output:
# ink fraction, naive Otsu : 37.2% <- entire dark half of page marked as ink
# ink fraction, black-hat : 6.1% <- text only, both halves of the page
Opening removes small objects but also rounds the corners of survivors, because the SE's shape is stamped onto everything it touches. When you only want "delete blobs under 50 px and fill holes under 20 px" with zero shape distortion, scikit-image does it in two lines: skimage.morphology.remove_small_objects(mask, 50) and remove_small_holes(mask, 20). Internally these label components (Section 6.4 machinery), measure areas, and discard by threshold, which replaces the dozen lines of label-measure-filter logic you would otherwise write and leaves surviving shapes pixel-identical. The trade-off cuts both ways: area criteria cannot break thin bridges or trim hairs, which only a true opening's shape test can do. Production pipelines often run both.
6. Hit-or-Miss: Morphology as Exact Pattern Matching Advanced
One last composition rounds out the algebra. The hit-or-miss transform erodes the foreground with one SE and the background with a second, disjoint SE, keeping only pixels where both fit: a template match demanding specified foreground pixels and specified background pixels simultaneously. OpenCV encodes both SEs in a single kernel of $1$ (must be foreground), $-1$ (must be background), and $0$ (indifferent), via cv2.morphologyEx(mask, cv2.MORPH_HITMISS, kernel). A kernel of all $-1$s around a central $1$ finds isolated pixels; an L-shaped pattern finds right-angle corners. Hit-or-miss is the primitive behind the thinning algorithms that produce the skeletons of Section 6.5, where a rotating family of such templates peels away boundary pixels that can be removed without breaking connectivity.
Who: A vision integrator commissioning a blister-pack inspection station at a pharmaceutical contract manufacturer.
Situation: Each pack holds 10 tablets under a clear plastic film; a camera with a ring light checks for missing or broken tablets at 90 packs per minute. Thresholding tablet silhouettes worked well in the lab.
Problem: On the line, the plastic film's curvature threw specular glare that punched 2 to 5 px holes through tablet silhouettes, while airborne tablet dust speckled the background with 1 to 3 px false foreground. Both confused the area check: holed tablets read as broken, and dust occasionally aggregated into phantom tablet fragments. Regulatory constraints made a switch to an opaque-model deep network unattractive: every reject must be explainable in an audit.
Decision: Insert a 7 px elliptical opening (kills dust up to twice the observed maximum) followed by an 11 px closing (seals glare holes with margin) between threshold and measurement, with both radii recorded in the audit file alongside the physical justification: the smallest genuine defect the customer must catch, a chipped tablet edge, removes at least 30 px of silhouette width and survives both operators untouched.
Result: False rejects fell from 4.1 percent to 0.06 percent over a 2-million-pack validation run, with zero missed genuine defects in the seeded-defect challenge set. The auditor accepted the two-line mathematical argument (anti-extensivity plus the size threshold) as the explanation of why no real defect can be masked.
Lesson: Opening and closing radii are not tuning knobs; they are documented claims about defect physics. When a regulator asks "prove this filter cannot hide a defect," idempotence and monotonicity from this chapter are the proof, which is an argument no learned post-processor can yet make.
The open-then-close recipe now has learned competitors and learned collaborators. SegRefiner (Wang et al., NeurIPS 2023, arXiv:2312.12425) recasts mask cleanup as a discrete diffusion process that iteratively refines coarse masks, fixing exactly the speckle-and-pinhole errors this section targets, but learned from data. The mask pipelines around SAM 2 (Ravi et al., 2024, arXiv:2408.00714) take the hybrid route: foundation-model masks post-processed with classical hole filling and small-region removal, because the morphological step is free, deterministic, and adds no failure modes. And through differentiable morphology libraries such as Kornia's kornia.morphology, opening and closing now appear inside loss functions, where penalizing the difference between a prediction and its opening teaches a network of Chapter 24's kind to stop emitting speckle in the first place.
The names are older than the notation. "Opening" opens narrow channels and isthmuses between regions (erosion cuts them; dilation cannot rebuild them), while "closing" closes narrow channels between background regions. Serra's school in Fontainebleau taught the pair with coastline metaphors, and the vocabulary stuck: morphologists still speak of gulfs, isthmuses, capes, and lakes, making the field's papers read faintly like sailing charts.
Using the characterization "opening = union of all SE placements that fit inside $A$," argue in two or three sentences why opening twice with the same SE must equal opening once. Then explain why the same argument fails for plain erosion (which keeps shrinking with every pass), and what this difference means operationally for someone tempted to run a cleanup loop "until the mask stops changing."
Photograph or synthesize an image of mixed coins (or generate disks of two known radii, 12 px and 20 px, with heavy overlap), threshold it, and compute the pattern spectrum as in this section's granulometry code. Verify that the spectrum shows two peaks at the correct radii even when the coins touch and overlap so much that connected-component counting (Section 6.4) would fail. Report how the smaller peak degrades as you increase overlap, and explain why.
Opening-then-closing and closing-then-opening are both idempotent smoothers, but they are not equal. Construct a mask where the two orders give visibly different results (hint: place a speck close enough to an object that closing first welds them together). Characterize in general terms which artifacts each order favors, and state a rule for choosing the order based on whether false foreground or false holes are the more dangerous error in a given inspection task.