Semantic and Instance Segmentation: U-Net to Mask R-CNN
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 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.
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.
graph TD I["Input 256×256×3"] --> E1["enc1: 256×256×64"] E1 --> P1["pool → 128×128"] P1 --> E2["enc2: 128×128×128"] E2 --> P2["pool → 64×64"] P2 --> E3["enc3: 64×64×256"] E3 --> P3["pool → 32×32"] P3 --> B["bottleneck: 32×32×512"] B --> U3["up → 64×64×256"] U3 --> C3["concat with enc3<br/>64×64×512"] C3 --> D3["dec3: 64×64×256"] D3 --> U2["up → 128×128×128"] U2 --> C2["concat with enc2<br/>128×128×256"] C2 --> D2["dec2: 128×128×128"] D2 --> U1["up → 256×256×64"] U1 --> C1["concat with enc1<br/>256×256×128"] C1 --> D1["dec1: 256×256×64"] D1 --> O["1×1 conv → 256×256×1"] E3 -.->|"skip"| C3 E2 -.->|"skip"| C2 E1 -.->|"skip"| C1
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:
They are not independent. One converts to the other exactly:
| # | 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:
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 , 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.
graph TD A["Image"] --> B["Backbone + FPN"] B --> C["Region Proposal Network<br/>~1000 candidate boxes"] C --> D["RoIAlign<br/>crop + resample to 14×14"] D --> E["Box head<br/>refine coordinates"] D --> F["Class head<br/>which class"] D --> G["Mask head<br/>28×28 mask per class"] E --> H["Per-object: box + class + mask"] F --> H G --> H
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 , and RoIPool rounded that to 4.
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.
| Property | U-Net | Mask R-CNN | SAM (promptable) |
|---|---|---|---|
| Task | Semantic | Instance | Class-agnostic instance |
| Separates touching objects | no | yes | yes |
| Mask resolution | full input resolution | 28×28, then resized | high |
| Boundary sharpness | excellent | moderate | excellent |
| Training data needed | ~200 images | ~1000 images | none — zero-shot |
| Inference speed | fast | moderate | slow |
| Best for | Area, medical, thin structures | Counting distinct objects | Interactive labelling |
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:
A 30-metre road segment’s mask contains 4,820 crack pixels:
| # | 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.
- You need area, length, or exact shape
- Objects are thin or irregular, so a box is mostly background
- Objects overlap and boxes would be ambiguous
- Downstream steps need a precise outline, such as compositing or measurement
- A box is precise enough — labelling costs 10x less
- You only need counts of well-separated objects
- Annotation budget is tight and you have not proven boxes are insufficient
- Latency is critical and detection already meets the requirement
Practice task
Use a public crack, road, or medical segmentation dataset.
- Compute the positive-pixel fraction. Then compute pixel accuracy for an all-background prediction.
- Train a U-Net with plain BCE. Record IoU and pixel accuracy. Note the gap in how informative they are.
- Retrain with
pos_weightof 5, 12, 30, and 55. Plot IoU against pos_weight. - Retrain with Dice loss alone, then with the 50/50 combination.
- Verify Dice = 2·IoU/(1+IoU) on your own numbers.
- Sweep the output threshold over 0.3 to 0.7 and plot IoU. Where is the peak?
- Remove the skip connections and retrain. Compare boundary sharpness visually.
- 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 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.
- Semantic segmentation gives class per pixel; instance segmentation also gives object identity.
- U-Net's skips carry precise location past the bottleneck, which is where boundary sharpness comes from.
- Channel counts double at each skip concatenation — that is the diagnostic that skips are wired correctly.
- Never report pixel accuracy on imbalanced segmentation: all-background scores 98.2% and is useless.
- IoU = TP/(TP+FP+FN); Dice = 2TP/(2TP+FP+FN); Dice = 2·IoU/(1+IoU) exactly.
- Dice is always higher than IoU, so always state which one you are quoting.
- Dice loss excludes true negatives, which is what makes it robust to imbalance.
- Set pos_weight to about a fifth of the inverse class frequency, not the exact value.
- RoIAlign replaced rounding with bilinear sampling — 7 px of error at stride 16, fatal for small masks.
- Mask pixels become real area only after rectifying to a top-down view.
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.