Search…

Semantic and Instance Segmentation: U-Net to Mask R-CNN

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 bounding box around a road crack tells you almost nothing useful. Cracks are thin and irregular, so the box is 90% intact tarmac. To budget for resurfacing you need the actual area, which means a decision per pixel.

Prerequisites: YOLO object detection and CNN architectures.

The problem: measuring road damage

A council photographs its roads from a vehicle-mounted camera and needs to know how many square metres of each road are cracked, to allocate resurfacing budget.

# Approach Output Can it give area? target Can it count potholes?
1 Classification 'this road is damaged' no no
2 Detection boxes around damaged regions roughly, and badly for thin cracks yes
3 Semantic segmentation every pixel: crack or not yes, exactly no
4 Instance segmentation a separate mask per pothole yes yes

Only pixel-level output answers the area question.

semantic segmentation Assigning a class label to every pixel. All pixels of the same class share one label, with no distinction between separate objects. instance segmentation Assigning both a class and an object identity to every pixel, so two adjacent potholes get separate masks.
Semantic: both potholes are class 1 (6×8)
0
0
0
0
0
0
0
0
0
0
1
1
0
0
0
0
0
0
1
1
0
2
2
0
0
0
1
1
0
2
2
0
0
0
0
0
0
2
2
0
0
0
0
0
0
0
0
0
Instance: pothole #1 and pothole #2 (6×8)
0
0
0
0
0
0
0
0
0
0
1
1
0
0
0
0
0
0
1
1
0
2
2
0
0
0
1
1
0
2
2
0
0
0
0
0
0
2
2
0
0
0
0
0
0
0
0
0

For area, semantic is enough and simpler. For counting individual potholes, or when two merge visually, you need instance.

U-Net

The problem with a plain classification backbone is that it throws away spatial resolution. ResNet’s output is 7×7 for a 224×224 input, and you cannot recover pixel-accurate edges from that.

U-Net’s answer: go down as usual, then come back up, and at each step of the way up, concatenate the matching feature map from the way down.

Trace the shapes:

# Stage Spatial size Channels target Note
1 input 256 × 256 3
2 enc1 256 × 256 64 saved for skip
3 enc2 128 × 128 128 saved for skip
4 enc3 64 × 64 256 saved for skip
5 bottleneck 32 × 32 512 widest context, coarsest detail
6 up3 + concat enc3 64 × 64 256 + 256 = 512 detail rejoins
7 up2 + concat enc2 128 × 128 128 + 128 = 256
8 up1 + concat enc1 256 × 256 64 + 64 = 128
9 output 256 × 256 1 one logit per pixel

U-Net shape trace. Note the channel counts doubling at each concatenation — that is the skip arriving.

Why the skips matter. The bottleneck at 32×32 knows what is in the image but has lost exactly where to within 8 pixels. enc1 at 256×256 knows precisely where every edge is but has no idea what it belongs to. Concatenating gives the decoder both.

class DoubleConv(nn.Module):
    def __init__(self, cin, cout):
        super().__init__()
        self.block = nn.Sequential(
            nn.Conv2d(cin, cout, 3, padding=1, bias=False),
            nn.BatchNorm2d(cout), nn.ReLU(inplace=True),
            nn.Conv2d(cout, cout, 3, padding=1, bias=False),
            nn.BatchNorm2d(cout), nn.ReLU(inplace=True))
    def forward(self, x): return self.block(x)

class UNet(nn.Module):
    def __init__(self, n_classes=1):
        super().__init__()
        self.e1, self.e2, self.e3 = DoubleConv(3,64), DoubleConv(64,128), DoubleConv(128,256)
        self.bott = DoubleConv(256, 512)
        self.u3 = nn.ConvTranspose2d(512, 256, 2, 2)
        self.d3 = DoubleConv(512, 256)          # 256 from up + 256 from skip
        self.u2 = nn.ConvTranspose2d(256, 128, 2, 2)
        self.d2 = DoubleConv(256, 128)
        self.u1 = nn.ConvTranspose2d(128, 64, 2, 2)
        self.d1 = DoubleConv(128, 64)
        self.out = nn.Conv2d(64, n_classes, 1)
        self.pool = nn.MaxPool2d(2)

    def forward(self, x):
        s1 = self.e1(x);           x = self.pool(s1)
        s2 = self.e2(x);           x = self.pool(s2)
        s3 = self.e3(x);           x = self.pool(s3)
        x  = self.bott(x)
        x  = self.d3(torch.cat([self.u3(x), s3], 1))
        x  = self.d2(torch.cat([self.u2(x), s2], 1))
        x  = self.d1(torch.cat([self.u1(x), s1], 1))
        return self.out(x)                      # logits, no sigmoid

The output has no sigmoid because BCEWithLogitsLoss applies it internally in a numerically stable way. Apply sigmoid only at inference.

The imbalance trap

Cracks cover about 1.8% of the pixels in a road photo. In a 256×256 image that is 1,180 crack pixels against 64,356 background pixels.

# Prediction Pixel accuracy target Crack IoU Useful?
1 Everything is background 98.20% 0.000 completely useless
2 A poor model 98.61% 0.284 barely
3 A decent model 99.14% 0.622 yes
4 A good model 99.41% 0.741 yes

Pixel accuracy is nearly identical across all four. IoU separates them completely.

Never report pixel accuracy on an imbalanced segmentation task. The all-background model scores 98.2%, which sounds excellent and is worth nothing. IoU on the positive class scores it 0, which is correct.

Dice and IoU, computed

Both compare a predicted mask with the ground truth. Take a prediction with TP = 620, FP = 80, FN = 180:

IoU=TPTP+FP+FN=620620+80+180=620880=0.7045\text{IoU} = \frac{TP}{TP + FP + FN} = \frac{620}{620 + 80 + 180} = \frac{620}{880} = 0.7045

Dice=2TP2TP+FP+FN=12401240+260=12401500=0.8267\text{Dice} = \frac{2 \cdot TP}{2 \cdot TP + FP + FN} = \frac{1240}{1240 + 260} = \frac{1240}{1500} = 0.8267

They are not independent. One converts to the other exactly:

Dice=2IoU1+IoU=2(0.7045)1.7045=1.4091.7045=0.8267  \text{Dice} = \frac{2 \cdot \text{IoU}}{1 + \text{IoU}} = \frac{2(0.7045)}{1.7045} = \frac{1.409}{1.7045} = 0.8267 \;\checkmark

# IoU Dice target Difference
1 0.30 0.462 +0.162
2 0.50 0.667 +0.167
3 0.70 0.824 +0.124
4 0.80 0.889 +0.089
5 0.90 0.947 +0.047
6 1.00 1.000 0.000

Dice is always higher than IoU, and the gap is widest in the middle. They rank models identically.

Since they rank models identically, the choice is about reporting convention: medical imaging papers use Dice, general vision benchmarks use IoU. State which one you are quoting, because a Dice of 0.82 and an IoU of 0.82 are very different models.

The loss function

Plain BCE on a 1.8%-positive task lets the model reach a low loss by predicting background everywhere. Two fixes, usually combined.

Dice loss optimises the overlap metric directly:

LDice=12pigi+ϵpi+gi+ϵ\mathcal{L}_{\text{Dice}} = 1 - \frac{2 \sum p_i g_i + \epsilon}{\sum p_i + \sum g_i + \epsilon}

Notice what is absent: true negatives never appear. A million correctly predicted background pixels contribute nothing, so the loss is driven entirely by the foreground.

class DiceLoss(nn.Module):
    def __init__(self, eps=1.0):
        super().__init__(); self.eps = eps
    def forward(self, logits, target):
        p = torch.sigmoid(logits).flatten(1)
        g = target.flatten(1)
        inter = (p * g).sum(1)
        return 1 - ((2*inter + self.eps) / (p.sum(1) + g.sum(1) + self.eps)).mean()

bce  = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([12.0]))
dice = DiceLoss()
loss = 0.5 * bce(logits, y) + 0.5 * dice(logits, y)

The pos_weight of 12 comes from the imbalance. With 1.8% positive pixels, the exact balancing weight is 98.2/1.8=54.698.2/1.8 = 54.6, but that is usually too aggressive in practice and produces very thick, over-eager masks. Something in the 5–15 range works better.

# Loss Crack IoU target Behaviour
1 BCE, unweighted 0.284 Predicts background almost everywhere
2 BCE, pos_weight=12 0.658 Reasonable, slightly thick masks
3 BCE, pos_weight=55 0.591 Over-predicts, many false positives
4 Dice only 0.694 Good overlap, noisy boundaries
5 0.5 BCE(12) + 0.5 Dice 0.741 Best — stable and sharp

Loss comparison on the crack dataset. The combination beats either alone.

The combination works because the two losses fail differently. BCE gives stable per-pixel gradients but ignores overlap; Dice optimises overlap but has unstable gradients when the prediction is nearly empty. Together they cover for each other.

Instance segmentation with Mask R-CNN

U-Net cannot count. Two potholes that touch become one connected region in the mask. Mask R-CNN handles that by detecting objects first, then segmenting inside each detection.

The mask head outputs a fixed 28×28 mask per detected object, which is then resized to fit the predicted box. That is coarse, and it is why Mask R-CNN masks look slightly blobby compared with U-Net’s.

RoIAlign, and the bug it fixed

The original Faster R-CNN used RoIPool, which rounded box coordinates to whole feature-map cells. At stride 16, a box edge at pixel 71 maps to feature coordinate 71/16=4.437571/16 = 4.4375, and RoIPool rounded that to 4.

4.43754=0.4375 feature cells=0.4375×16=7 pixels of error4.4375 - 4 = 0.4375 \text{ feature cells} = 0.4375 \times 16 = 7 \text{ pixels of error}

RoIAlign Cropping a region from a feature map using bilinear interpolation at exact fractional coordinates, instead of rounding to whole cells.

Seven pixels does not matter for a 300-pixel box’s classification. It matters enormously for a 40-pixel pothole’s mask, where it is nearly 20% of the object. Replacing rounding with bilinear interpolation improved mask AP by about 10 points on small objects, from one change.

PropertyU-NetMask R-CNNSAM (promptable)
TaskSemanticInstanceClass-agnostic instance
Separates touching objectsnoyesyes
Mask resolutionfull input resolution28×28, then resizedhigh
Boundary sharpnessexcellentmoderateexcellent
Training data needed~200 images~1000 imagesnone — zero-shot
Inference speedfastmoderateslow
Best forArea, medical, thin structuresCounting distinct objectsInteractive labelling
Three segmentation approaches

SAM is worth knowing about for a practical reason unrelated to deployment: it makes annotation dramatically faster. Click a pothole, get a mask, correct it slightly, move on — often five times faster than drawing polygons by hand.

From mask to square metres

The point of all this. Count the positive pixels and multiply by the real area each pixel covers.

The vehicle camera is calibrated so that at the road surface, 1 pixel spans 0.02 m. So each pixel is:

0.02×0.02=0.0004 m20.02 \times 0.02 = 0.0004 \text{ m}^2

A 30-metre road segment’s mask contains 4,820 crack pixels:

4,820×0.0004=1.928 m24{,}820 \times 0.0004 = 1.928 \text{ m}^2

# Road segment Crack pixels Area (m²) Segment length Damage per 100 m target
1 Elm St 1 4820 1.93 30 m 6.4 m²
2 Elm St 2 11240 4.50 30 m 15.0 m²
3 Oak Rd 1 1105 0.44 30 m 1.5 m²
4 Oak Rd 2 890 0.36 30 m 1.2 m²
5 Mill Ln 1 18930 7.57 30 m 25.2 m²

Segmentation output converted to a resurfacing priority list. Mill Ln is 17x worse than Oak Rd.

That last column is the deliverable. It is not a model metric — it is a ranked work order, and it is the only reason the segmentation model exists.

The 0.02 m/px figure comes from camera calibration, and it is only valid on the road plane. Cracks further up the frame have fewer pixels per metre, so the mask must be rectified to a top-down view before counting, or near cracks will be systematically overweighted.

Practice task

Use a public crack, road, or medical segmentation dataset.

  1. Compute the positive-pixel fraction. Then compute pixel accuracy for an all-background prediction.
  2. Train a U-Net with plain BCE. Record IoU and pixel accuracy. Note the gap in how informative they are.
  3. Retrain with pos_weight of 5, 12, 30, and 55. Plot IoU against pos_weight.
  4. Retrain with Dice loss alone, then with the 50/50 combination.
  5. Verify Dice = 2·IoU/(1+IoU) on your own numbers.
  6. Sweep the output threshold over 0.3 to 0.7 and plot IoU. Where is the peak?
  7. Remove the skip connections and retrain. Compare boundary sharpness visually.
  8. Convert your best mask to real area using a known scale.

Step 7 makes the architecture concrete. Without skips, the output is recognisable but blurry, and boundaries wander by several pixels — you can see exactly what the skip connections were carrying.

Summary

Segmentation predicts a class per pixel. Semantic labels every crack pixel the same; instance separates individual potholes.

U-Net goes down to a 32×32 bottleneck that knows what, then back up, concatenating each encoder map so the decoder also knows where. Channel counts double at each concatenation — 256 + 256, then 128 + 128, then 64 + 64.

On a 1.8%-positive task, an all-background prediction scores 98.2% pixel accuracy and IoU of 0. Report IoU or Dice on the foreground, never pixel accuracy. With TP = 620, FP = 80, FN = 180, IoU is 0.7045 and Dice is 0.8267, and 2×0.7045/1.70452 \times 0.7045 / 1.7045 confirms the relationship exactly.

Dice loss ignores true negatives, which is precisely why it handles imbalance. Combined 50/50 with a weighted BCE it reached 0.741 IoU against 0.694 and 0.658 for either alone. Use about a fifth of the exact inverse frequency for pos_weight.

Mask R-CNN adds instance identity, and RoIAlign’s bilinear sampling replaced RoIPool’s rounding — 7 pixels of error at stride 16, which was crippling for small masks.

What comes next

Every architecture so far has been convolutional, built on the assumption that nearby pixels matter most. Vision transformers drop that assumption entirely: split the image into patches and let every patch attend to every other from the first layer. That is a different set of trade-offs, and importantly a different data requirement.

Test your understanding
Your crack segmentation model reports 98.4% pixel accuracy and 0.09 IoU. What is it doing?
Test your understanding
You need to count individual potholes, and several of them touch each other. You have a U-Net producing an excellent semantic mask. What do you need to change?

Frequently asked questions

Should I use Dice or IoU?
For reporting, either — they rank models identically, since one is an exact function of the other. Just state which you used, because a Dice of 0.82 corresponds to an IoU of 0.70 and quoting the wrong one makes your results look better than they are. For the loss function, Dice is the useful one, because it optimises overlap directly and ignores the true negatives that dominate imbalanced tasks.
How much data does U-Net need?
Less than most architectures. The original paper trained on around 30 annotated microscopy images with heavy augmentation, and 200 to 500 well-annotated images is comfortable for a two-class problem. Segmentation labels carry far more information per image than classification labels, since every pixel is supervised. Annotation quality matters more than count: inconsistent boundaries between annotators cap your achievable IoU regardless of dataset size.
Why are my mask boundaries blurry?
Usually one of three things. Missing or misconnected skip connections mean fine detail never reaches the decoder — check that channel counts double at each concatenation. Inconsistent annotation means the ground truth boundaries themselves vary, so the model learns an average. Or the output threshold is off; sweep it from 0.3 to 0.7 and pick the peak, since 0.5 is rarely optimal on imbalanced data.
Can I use a pretrained backbone in U-Net?
Yes, and you generally should. Replace the encoder with a pretrained ResNet or EfficientNet and take skip connections from the outputs of each stage. Libraries like segmentation_models_pytorch do this in one line. It typically improves IoU by five to ten points on small datasets, for the same reason transfer learning helps classification: the encoder already knows edges and textures.
When should I choose detection over segmentation?
Whenever a box is precise enough, which is more often than people expect. Polygon annotation costs roughly ten times what box annotation does, so a segmentation project needs about ten times the labelling budget for the same object count. Prove that boxes are insufficient before paying that — thin or irregular objects, area measurement, and heavy overlap are the genuine cases where they are.
Start typing to search across all content
navigate Enter open Esc close