Morphology, Contours, and Shape Analysis for Real Images
In this series (30 parts)
- Computer Vision Roadmap: From Basics to Real Projects
- What is Computer Vision? From Pixels to Decisions
- A Short History of Computer Vision: 1545 to Now
- Math for CV Beginners: Vectors, Matrices, Convolutions
- Image Fundamentals: Color, Histograms, Noise, Filtering
- How Images and Video Are Stored: Colour, JPEG, Frames
- OpenCV Setup + First 10 Tasks in Python
- Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
- CV Project Workflow: Dataset, Baseline, Error Iteration
- Intensity Transforms and Frequency-Domain Filtering
- Edge Detection and Thresholding That Actually Work
- Morphology, Contours, and Shape Analysis for Real Images
- Feature Matching (SIFT/ORB) and Image Stitching
- Camera Calibration and Perspective Correction
- Epipolar Geometry, Stereo Vision, and Depth Estimation
- Optical Flow and Motion Tracking in Video
- HOG, HOF and MBH: Descriptors Before Deep Learning
- Your First Image Classifier (PyTorch + Transfer Learning)
- Data Pipelines and Augmentation for Vision Models
- CNN Architectures Explained: From LeNet to ResNet
- YOLO Detection Pipeline: Data to Inference
- Semantic and Instance Segmentation: U-Net to Mask R-CNN
- Vision Transformers (ViT) and When to Use Them
- CV Explainability: Grad-CAM, Failures, Bias Checks
- 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
- Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
- Video Understanding: Flow Networks, Interpolation, Stabilisation
- Real-Time CV Systems: Tracking, Latency, Streaming
- Deploying CV Models: ONNX, TensorRT, Edge, and APIs
- 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:
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.
Take the same block plus one isolated noise pixel:
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.
Take the block with a hole punched in the middle:
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.
graph TD
M["Raw binary mask"] --> Q{"What is wrong<br/>with it?"}
Q -->|"scattered specks"| O["Opening<br/>small ellipse kernel"]
Q -->|"holes inside objects"| C["Closing<br/>kernel bigger than the hole"]
Q -->|"both"| B["Opening, then closing"]
Q -->|"objects fused together"| W["Distance transform<br/>+ watershed"]
O --> F["findContours"]
C --> F
B --> F
W --> F
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 | 1.00 | 0.79 | 0.31 | |
| 4 | Solidity | 1.00 | 1.00 | 0.80 | |
| 5 | Extent | 0.79 | 1.00 | 0.67 | |
| 6 | Aspect ratio | 1.00 | 1.00 | 0.22 |
Five shape descriptors computed for three shapes
Check the circle column. A circle of radius 50 has:
And its bounding box is 100×100, so:
A circle fills only 78.5% of its bounding box, which is . 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:
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:
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 , 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
graph TD
A["BGR plate photo"] --> B["Grayscale + median blur"]
B --> C["Otsu threshold"]
C --> D["Opening: remove specks"]
D --> E["Closing: fill highlight holes"]
E --> F{"Objects touching?"}
F -->|"no"| G["findContours RETR_EXTERNAL"]
F -->|"yes"| H["Distance transform + watershed"]
H --> G
G --> I["Filter: area, circularity, solidity"]
I --> J["Count + write per-object CSV"]
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.
- Objects sit on a plain, consistent background
- You can produce a reliable binary mask first
- Objects have a describable shape you can filter on
- You need to explain every decision, for audit or regulatory reasons
- You have no labelled training data
- The background is cluttered or textured
- Objects overlap heavily rather than merely touching
- The object's identity depends on appearance rather than shape
- Thresholding already fails, since everything here depends on that mask
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.
- Threshold with Otsu and count contours. Compare with the true count.
- Apply opening with kernel sizes 3, 5, and 9. Record the count after each.
- Apply closing after opening. Did the count change?
- Compute area, circularity, solidity, and extent for every contour and write them to a CSV.
- Sort by circularity. Which objects sit at the bottom, and are they real coins?
- 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.
- 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 (, 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.
- Erosion needs every pixel under the kernel to be white; dilation needs only one.
- Opening removes specks and preserves object size. Closing fills holes and preserves object size.
- Do opening before closing. The other order grows the noise first.
- Never use repeated erosion to clean a mask; that shrinks everything and deletes small objects.
- Use MORPH_ELLIPSE for round objects. Long thin rectangles isolate table lines.
- Top hat on a grayscale image removes uneven illumination in one operation.
- RETR_EXTERNAL for counting; RETR_TREE when you need to count holes.
- Circularity is 1.00 for a circle and 0.785 for a square. Extent is 0.785 for a circle in its bounding box.
- Solidity catches merged objects, because a fused pair has a much larger convex hull than area.
- Touching objects need the distance transform and watershed. Sweep the seed threshold and look for a plateau.
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.