Search…

Morphology, Contours, and Shape Analysis for Real Images

In this series (30 parts)
  1. Computer Vision Roadmap: From Basics to Real Projects
  2. What is Computer Vision? From Pixels to Decisions
  3. A Short History of Computer Vision: 1545 to Now
  4. Math for CV Beginners: Vectors, Matrices, Convolutions
  5. Image Fundamentals: Color, Histograms, Noise, Filtering
  6. How Images and Video Are Stored: Colour, JPEG, Frames
  7. OpenCV Setup + First 10 Tasks in Python
  8. Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
  9. CV Project Workflow: Dataset, Baseline, Error Iteration
  10. Intensity Transforms and Frequency-Domain Filtering
  11. Edge Detection and Thresholding That Actually Work
  12. Morphology, Contours, and Shape Analysis for Real Images
  13. Feature Matching (SIFT/ORB) and Image Stitching
  14. Camera Calibration and Perspective Correction
  15. Epipolar Geometry, Stereo Vision, and Depth Estimation
  16. Optical Flow and Motion Tracking in Video
  17. HOG, HOF and MBH: Descriptors Before Deep Learning
  18. Your First Image Classifier (PyTorch + Transfer Learning)
  19. Data Pipelines and Augmentation for Vision Models
  20. CNN Architectures Explained: From LeNet to ResNet
  21. YOLO Detection Pipeline: Data to Inference
  22. Semantic and Instance Segmentation: U-Net to Mask R-CNN
  23. Vision Transformers (ViT) and When to Use Them
  24. CV Explainability: Grad-CAM, Failures, Bias Checks
  25. 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
  26. Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
  27. Video Understanding: Flow Networks, Interpolation, Stabilisation
  28. Real-Time CV Systems: Tracking, Latency, Streaming
  29. Deploying CV Models: ONNX, TensorRT, Edge, and APIs
  30. Responsible CV: Privacy, Fairness, Security, Governance

A fresh binary mask is never clean. It has specks, holes, ragged edges, and objects fused together. Morphology fixes all four, and contour analysis turns the result into numbers you can act on. This post works both out on grids small enough to check by hand.

Prerequisites: Edge detection and thresholding, because morphology starts from a binary mask.

The problem: counting colonies on a plate

A microbiology lab counts bacterial colonies on agar plates. A technician counts by eye, which takes ten minutes per plate and produces different numbers from different technicians.

A photo of a plate, thresholded, gives a mask with four problems:

# Problem in the mask Cause What fixes it target
1 Scattered single white pixels Sensor noise and dust Opening
2 Small dark holes inside colonies Specular highlight in the centre Closing
3 Ragged, jagged colony edges Threshold sitting near the boundary value Opening then closing
4 Two colonies fused into one blob They physically touch Distance transform + watershed

Four mask problems, four different fixes. Using the wrong one makes things worse.

Each needs a different tool. Reaching for a bigger blur, which is the usual instinct, fixes none of them.

Erosion and dilation, computed by hand

structuring element The small shape, usually 3x3, that is slid over the mask. Its size and shape decide how much and in which direction the mask grows or shrinks. is the kernel of morphology. Every operation is defined by what happens under it.

Erosion: the output pixel is 1 only if every pixel under the structuring element is 1.

Take a 5×5 white block inside a 7×7 mask:

Input mask (7×7)
0
0
0
0
0
0
0
0
1
1
1
1
1
0
0
1
1
1
1
1
0
0
1
1
1
1
1
0
0
1
1
1
1
1
0
0
1
1
1
1
1
0
0
0
0
0
0
0
0
After erosion (3×3) (7×7)
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1
1
1
0
0
0
0
1
1
1
0
0
0
0
1
1
1
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0

Check a few positions:

  • At row 1, column 1: the 3×3 window covers rows 0–2 and columns 0–2. Row 0 is all zeros, so the window contains a 0. Output is 0.
  • At row 2, column 2: the window covers rows 1–3, columns 1–3, which are all 1. Output is 1.
  • At row 5, column 3: the window covers rows 4–6, and row 6 is all zeros. Output is 0.

The block shrank from 5×5 to 3×3 — one pixel removed from every side. That is exactly what a 3×3 structuring element does.

Dilation: the output pixel is 1 if any pixel under the structuring element is 1. It grows the region by one pixel on every side, the exact reverse.

# Operation Rule under the kernel Effect on white regions Effect on a 5×5 block with 3×3 kernel
1 Erosion all must be 1 shrinks by 1 pixel each side becomes 3×3
2 Dilation any may be 1 grows by 1 pixel each side becomes 7×7

The two primitives. Everything else is a combination of these.

import cv2
import numpy as np

kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
eroded  = cv2.erode(mask, kernel, iterations=1)
dilated = cv2.dilate(mask, kernel, iterations=1)

Opening: remove specks without shrinking objects

Erosion alone removes specks but also shrinks everything you wanted to keep. Opening fixes that by dilating back afterwards.

opening=dilate(erode(A))\text{opening} = \text{dilate}(\text{erode}(A))

Take the same block plus one isolated noise pixel:

Input: block + speck (7×9)
0
0
0
0
0
0
0
0
0
0
1
1
1
1
1
0
0
0
0
1
1
1
1
1
0
0
0
0
1
1
1
1
1
0
1
0
0
1
1
1
1
1
0
0
0
0
1
1
1
1
1
0
0
0
0
0
0
0
0
0
0
0
0
Step 1: erode (7×9)
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1
1
1
0
0
0
0
0
0
1
1
1
0
0
0
0
0
0
1
1
1
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
Step 2: dilate = opening (7×9)
0
0
0
0
0
0
0
0
0
0
1
1
1
1
1
0
0
0
0
1
1
1
1
1
0
0
0
0
1
1
1
1
1
0
0
0
0
1
1
1
1
1
0
0
0
0
1
1
1
1
1
0
0
0
0
0
0
0
0
0
0
0
0

Follow the speck at row 3, column 7. During erosion, its 3×3 window contains zeros, so it becomes 0. Once it is gone, dilation has nothing to grow back from. The speck is permanently removed.

The block, meanwhile, shrank to 3×3 and then grew back to exactly 5×5. Its size is unchanged.

That is the whole point of opening: anything thinner than the structuring element disappears; everything else keeps its original size.

Closing: fill holes without growing objects

The mirror image. Dilate first to close the gap, then erode back to the original size.

closing=erode(dilate(A))\text{closing} = \text{erode}(\text{dilate}(A))

Take the block with a hole punched in the middle:

Input: block with a hole (7×7)
0
0
0
0
0
0
0
0
1
1
1
1
1
0
0
1
1
1
1
1
0
0
1
1
0
1
1
0
0
1
1
1
1
1
0
0
1
1
1
1
1
0
0
0
0
0
0
0
0
Step 1: dilate (7×7)
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
1
Step 2: erode = closing (7×7)
0
0
0
0
0
0
0
0
1
1
1
1
1
0
0
1
1
1
1
1
0
0
1
1
1
1
1
0
0
1
1
1
1
1
0
0
1
1
1
1
1
0
0
0
0
0
0
0
0

The hole at row 3, column 3 has 1s all around it, so dilation sets it to 1 and it is gone. The block also grew outward to fill the whole 7×7. Erosion then removes that outer layer, returning the block to exactly its original 5×5 footprint — but the hole stays filled, because there is nothing left to re-open it.

# Operation Formula Removes Keeps unchanged
1 Opening dilate(erode(A)) specks, thin bridges, spikes size of large objects
2 Closing erode(dilate(A)) holes, small gaps, notches size of large objects
3 Gradient dilate(A) − erode(A) the interior a 2-pixel outline
4 Top hat A − opening(A) the large objects small bright details
5 Black hat closing(A) − A the large objects small dark details

The five derived operations and what each is for

k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))

opened   = cv2.morphologyEx(mask, cv2.MORPH_OPEN,  k)
closed   = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k)
gradient = cv2.morphologyEx(mask, cv2.MORPH_GRADIENT, k)
tophat   = cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, k)   # works on grayscale too

Top hat deserves a mention. Applied to a grayscale image with a large kernel, it subtracts the local background and leaves only small bright details. It is a one-line fix for uneven illumination and often works better than adaptive thresholding for text on a shaded page.

Choosing the structuring element

# Shape OpenCV constant Use for
1 Rectangle cv2.MORPH_RECT Text, tables, anything axis-aligned
2 Ellipse cv2.MORPH_ELLIPSE Round or organic objects — the usual default
3 Cross cv2.MORPH_CROSS Thin structures where you want minimal change
4 Long horizontal (25×1) MORPH_RECT (25,1) Isolating horizontal table lines
5 Long vertical (1×25) MORPH_RECT (1,25) Isolating vertical table lines

Structuring element shapes. A rectangle on round objects leaves visible corners.

The last two are a trick worth knowing. Opening with a 25×1 rectangle keeps only structures at least 25 pixels wide and 1 pixel tall, which is exactly a horizontal line. Do the same vertically and you have extracted a table’s grid, cleanly separated from its text.

The order matters when you do both. Opening first, then closing. Closing first would grow the noise specks and fuse them into blobs too large for the following opening to remove.

Contours: from a mask to a list of objects

contours, hierarchy = cv2.findContours(mask, cv2.RETR_EXTERNAL,
                                       cv2.CHAIN_APPROX_SIMPLE)
print(f"{len(contours)} objects found")

A contour An ordered list of the boundary points of one connected white region in a binary mask. is one object. Two arguments control what you get back:

# Retrieval mode Returns Use when
1 RETR_EXTERNAL Outer boundaries only Counting objects — the usual choice
2 RETR_LIST All contours, no nesting info You want holes too but not the structure
3 RETR_CCOMP Two levels: outer and holes Simple object-with-holes cases
4 RETR_TREE Full nesting hierarchy Counting holes per object, e.g. washers

Retrieval modes. RETR_EXTERNAL ignores every hole, which is usually what counting needs.

CHAIN_APPROX_SIMPLE stores only the endpoints of straight runs instead of every boundary pixel. For a 100×100 square it keeps 4 points instead of 400, with no loss for any measurement.

Shape measurements, worked out

Five numbers describe almost any shape well enough to classify it.

# Measurement Formula Circle target Square Long thin part
1 Area contourArea 7854 10000 1200
2 Perimeter arcLength 314 400 220
3 Circularity 4πA/P24\pi A / P^2 1.00 0.79 0.31
4 Solidity A/AhullA / A_{hull} 1.00 1.00 0.80
5 Extent A/AbboxA / A_{bbox} 0.79 1.00 0.67
6 Aspect ratio w/hw / h 1.00 1.00 0.22

Five shape descriptors computed for three shapes

Check the circle column. A circle of radius 50 has:

A=πr2=π(50)2=7854,P=2πr=2π(50)=314A = \pi r^2 = \pi(50)^2 = 7854, \qquad P = 2\pi r = 2\pi(50) = 314 circularity=4πAP2=4π(7854)3142=98,69698,596=1.00\text{circularity} = \frac{4\pi A}{P^2} = \frac{4\pi (7854)}{314^2} = \frac{98{,}696}{98{,}596} = 1.00

And its bounding box is 100×100, so:

extent=785410,000=0.785\text{extent} = \frac{7854}{10{,}000} = 0.785

A circle fills only 78.5% of its bounding box, which is π/4\pi/4. That number is a reliable signature.

Now the long thin part, with area 1,200, perimeter 220, convex hull area 1,500, and a 20×90 bounding box:

circularity=4π(1200)2202=15,08048,400=0.312\text{circularity} = \frac{4\pi(1200)}{220^2} = \frac{15{,}080}{48{,}400} = 0.312 solidity=12001500=0.80,extent=120020×90=12001800=0.667\text{solidity} = \frac{1200}{1500} = 0.80, \qquad \text{extent} = \frac{1200}{20 \times 90} = \frac{1200}{1800} = 0.667

solidity Contour area divided by the area of its convex hull. It equals 1.0 for a shape with no dents, and drops as the shape becomes more concave. is the one that catches merged objects. Two round colonies fused into a peanut shape have a deep notch on each side, so their hull is much larger than their area and solidity drops to around 0.85 while a single colony sits near 0.98.

import numpy as np

def describe(c):
    area = cv2.contourArea(c)
    perim = cv2.arcLength(c, True)
    x, y, w, h = cv2.boundingRect(c)
    hull_area = cv2.contourArea(cv2.convexHull(c))
    return {
        "area": area,
        "circularity": 4 * np.pi * area / perim**2 if perim else 0,
        "solidity": area / hull_area if hull_area else 0,
        "extent": area / (w * h) if w * h else 0,
        "aspect": w / h if h else 0,
        "centroid": (x + w / 2, y + h / 2),
    }

Two cheap numbers separate all three groups with wide margins. No training data, no model, and you can explain every decision.

Touching objects: distance transform and watershed

Two colonies that physically touch form one connected region. No amount of opening separates them without destroying both — the bridge between them is as thick as the objects themselves.

The fix works on a different principle. The distance transform For every white pixel, the distance to the nearest black pixel. Object centres get large values and edges get small ones. converts the mask into a landscape with a peak at each object’s centre.

Here is a slice straight through two touching blobs:

Distance transform along a slice (1×13)
1
2
3
4
3
2
1
2
3
4
3
2
1
threshold
Keep values ≥ 3 → two seeds (1×13)
0
0
3
4
3
0
0
0
3
4
3
0
0

The blobs are joined, so the mask is one continuous run of white. But the distance values are not flat: they peak at 4 in each blob’s centre and dip to 1 at the narrow join.

Threshold at 60% of the maximum, so 0.6×4=2.40.6 \times 4 = 2.4, keeping only values of 3 and above. The single run breaks into two separate seeds. Watershed then grows each seed outward until they meet, and the boundary lands at the pinch point.

import cv2
import numpy as np

# 1. clean mask
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
clean = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k, iterations=2)

# 2. definitely background: dilate generously
sure_bg = cv2.dilate(clean, k, iterations=3)

# 3. definitely foreground: peaks of the distance transform
dist = cv2.distanceTransform(clean, cv2.DIST_L2, 5)
_, sure_fg = cv2.threshold(dist, 0.6 * dist.max(), 255, 0)
sure_fg = np.uint8(sure_fg)

# 4. the uncertain band between them
unknown = cv2.subtract(sure_bg, sure_fg)

# 5. label each seed, reserve 0 for the unknown band
n, markers = cv2.connectedComponents(sure_fg)
markers = markers + 1
markers[unknown == 255] = 0

# 6. flood outward from the seeds
markers = cv2.watershed(bgr, markers)
print(f"{n} objects separated")

The 0.6 is the parameter that matters. Too low and touching objects stay merged because their seeds join. Too high and large objects lose their seed entirely and vanish. Sweep it from 0.3 to 0.8 on a few plates and pick from the resulting counts.

# Distance threshold Effect target Symptom when wrong
1 0.3 × max Large, generous seeds Touching objects stay merged
2 0.5 × max Balanced starting point
3 0.6 × max Conservative seeds
4 0.8 × max Very small seeds Small objects disappear entirely

Tuning the distance transform threshold for watershed

The full pipeline

def count_colonies(bgr, min_area=80, min_circ=0.65, min_solidity=0.90):
    gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
    gray = cv2.medianBlur(gray, 5)
    _, mask = cv2.threshold(gray, 0, 255,
                            cv2.THRESH_BINARY + cv2.THRESH_OTSU)

    k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
    mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN,  k)
    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k)

    cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL,
                               cv2.CHAIN_APPROX_SIMPLE)

    kept, rejected = [], []
    for c in cnts:
        d = describe(c)
        if (d["area"] >= min_area and d["circularity"] >= min_circ
                and d["solidity"] >= min_solidity):
            kept.append(d)
        else:
            rejected.append(d)
    return kept, rejected

Returning the rejected objects as well as the kept ones is the detail that makes this debuggable. When the count is wrong, you look at what was thrown away and immediately see whether your area floor was too high or your circularity filter was eating real colonies.

Common contour problems

# Symptom Likely cause Fix
1 Object count far too high Noise specks surviving as contours Opening, then an area floor
2 Object count far too low Objects merged into one contour Distance transform + watershed
3 Contours nested inside each other Using RETR_LIST or RETR_TREE Switch to RETR_EXTERNAL
4 Areas smaller than expected Erosion applied without dilating back Use opening, not repeated erosion
5 Objects touching the image border are clipped Object is cut off by the frame Reject contours whose bbox touches the edge
6 Perimeter looks far too long Ragged mask boundary Close the mask, or use approxPolyDP first

Contour problems by symptom

That last one is worth expanding. A jagged boundary inflates perimeter badly, and since circularity divides by perimeter squared, a mask that is only slightly ragged can push a genuine circle’s circularity from 1.00 down to 0.6. If circularity is systematically low across all your objects, the mask boundary is rough and needs closing before you measure anything.

Practice task

Photograph 20 coins on a plain sheet of paper. Arrange some so they touch.

  1. Threshold with Otsu and count contours. Compare with the true count.
  2. Apply opening with kernel sizes 3, 5, and 9. Record the count after each.
  3. Apply closing after opening. Did the count change?
  4. Compute area, circularity, solidity, and extent for every contour and write them to a CSV.
  5. Sort by circularity. Which objects sit at the bottom, and are they real coins?
  6. Apply the watershed pipeline. Sweep the distance threshold across 0.3, 0.4, 0.5, 0.6, and 0.7 and record the count at each.
  7. Plot count against distance threshold and find the plateau.

Step 7 is the technique to take away. A parameter that gives the same answer across a range of values is a parameter you can trust. If the count changes at every step with no plateau, the pipeline is fragile and no single value will hold in production.

Summary

Morphology is built from two operations. Erosion keeps a pixel only when the whole structuring element sits on white; dilation keeps it when any part does. You worked both out on a 5×5 block and watched it shrink to 3×3 and grow back.

Opening is erosion then dilation: it removes anything thinner than the kernel while returning survivors to their original size, which is how the isolated speck disappeared while the block was untouched. Closing is the reverse and fills holes without growing the object, which is how the hole vanished while the block stayed 5×5. Do opening before closing.

Contours turn the mask into objects. Area alone is a weak filter; circularity (4πA/P24\pi A/P^2, worked out as 1.00 for a circle and 0.31 for a thin part), solidity, and extent separate object types with wide margins for almost no cost. Objects that physically touch need the distance transform, whose peaks become watershed seeds, not a larger morphology kernel.

What comes next

So far every method has worked inside a single image. Feature matching with SIFT and ORB moves to relating two images: finding the same physical point in both, and using those correspondences to stitch a panorama. That is the entry point to the geometry half of computer vision.

Test your understanding
Your mask has both scattered noise specks and small holes inside the objects. You apply closing first, then opening. The noise is still there as larger blobs. Why?
Test your understanding
You are counting round washers. Every measured circularity comes out between 0.55 and 0.65 instead of near 1.0, though the mask looks correct. What is happening?

Frequently asked questions

What is the difference between opening and closing?
Opening is erosion followed by dilation, and it removes anything thinner than the structuring element — specks, thin bridges, spikes — while leaving surviving objects at their original size. Closing is dilation followed by erosion, and it fills holes and small gaps, also without changing the overall size. Use opening for noise, closing for holes, and opening first if you need both.
How do I choose the structuring element size?
Make it slightly larger than the artifacts you want to remove and clearly smaller than the objects you want to keep. If your noise specks are 1 to 2 pixels and your objects are 40 pixels across, a 5x5 element gives plenty of margin. When the noise and the objects are similar in size, morphology cannot separate them and you need a shape or intensity criterion instead.
Why do my touching objects count as one?
findContours works on connectivity, and two touching objects form one connected white region, so it correctly returns one contour. Morphology cannot fix this because the bridge between them is as thick as the objects. The distance transform gives each object its own peak, and thresholding those peaks produces separate seeds that watershed can grow back into distinct regions.
Should I use contours or a neural network for counting?
Contours when the background is plain, the objects are well separated, and you have no labelled data — which describes most controlled industrial and laboratory settings. A network when the background is cluttered, objects overlap heavily, or identity depends on appearance rather than shape. The contour version takes an afternoon and gives you the baseline that tells you whether the network is worth its cost.
What does solidity tell me that circularity does not?
Circularity measures how round the outline is overall, while solidity measures how much the shape is dented inward relative to its convex hull. Two circles fused into a peanut still have a fairly smooth outline, so circularity drops only moderately, but the deep notch on each side makes the convex hull much larger than the area and solidity falls sharply. Solidity is therefore the better detector for merged objects.
Start typing to search across all content
navigate Enter open Esc close