Image Segmentation: Semantic Segmentation, Instance Segmentation & U-Net
Transition from coarse rectangular bounding boxes to dense pixel-level visual understanding. Master semantic, instance, and panoptic segmentation, formulate pixel-wise loss landscapes (Cross-Entropy, Soft Dice, and Focal Loss), dissect the U-Net architecture and skip connection mechanics, and implement an end-to-end segmentation pipeline in PyTorch.
1 Introduction: What Is Image Segmentation?
In the previous lesson, we explored Object Detection, where models detect multiple instances in an image and draw rectangular bounding boxes around them. While bounding boxes tell us approximately where an object is located, they fail to describe its exact geometric shape, irregular contours, or fine boundaries.
Consider an autonomous vehicle navigating an urban intersection. Knowing that a pedestrian or vehicle exists inside a bounding box $[x_1, y_1, x_2, y_2]$ is insufficient for path planning: the box includes extraneous pixels of the background street, sidewalk, or neighboring trees. The vehicle must know with surgical precision:
- Which exact pixels belong to the drivable road surface?
- Which pixels delineate the curved boundary of the sidewalk and curbs?
- What is the precise morphological outline of pedestrians and cyclists?
The Formal Definition of Image Segmentation
Image Segmentation is a dense computer vision task in which a model partitions an input image into meaningful visual regions by assigning a discrete class label or object instance identifier to every individual pixel:
Unlike image classification which outputs a single global vector $\hat{y} \in \mathbb{R}^K$, image segmentation produces a full spatial prediction tensor with matching height $H$ and width $W$.
2 Learning Objectives
3 Semantic, Instance and Panoptic Segmentation
Depending on how pixel identities and object instances are treated, computer vision recognizes three distinct segmentation tasks:
1. Semantic Segmentation
Class-Only
Assigns a semantic category label to every pixel without distinguishing between separate individual objects. If an image contains three cars, all car pixels receive the identical class ID 1.
2. Instance Segmentation
Object-AwareDetects and segments individual countable objects ("things"). If an image contains three cars, each car is assigned an independent mask and instance ID (Car 1, Car 2, Car 3). Background pixels ("stuff") are ignored.
Target: {(Mask_k, Class_k, ID_k)}3. Panoptic Segmentation
Unified VisionThe holy grail: combines semantic and instance segmentation. It classifies amorphous background regions ("stuff" like road, sky, grass) while simultaneously distinguishing unique instance IDs for countable objects ("things").
Target: (Class_Map, Instance_ID_Map)| Feature / Property | Semantic Segmentation | Instance Segmentation | Panoptic Segmentation |
|---|---|---|---|
| Pixel-Level Prediction | ✓ Yes (Full Coverage) | ✓ Yes (Detected Objects) | ✓ Yes (100% Coverage) |
| Differentiates Individual Instances | × No (All instances share label) | ✓ Yes (Unique mask per object) | ✓ Yes (Unique ID per "thing") |
| Handles Amorphous "Stuff" (Sky, Road) | ✓ Yes | × No (Ignores background) | ✓ Yes (Continuous regions) |
| Primary Canonical Architectures | U-Net, DeepLabv3+, SegFormer | Mask R-CNN, YOLOV8-Seg | Panoptic-DeepLab, Mask2Former |
4 Understanding Segmentation Masks & Data Representations
A segmentation mask is a 2D matrix $\mathbf{M} \in \mathbb{Z}^{H \times W}$ where each entry $M_{i, j}$ stores an integer identifier. Beginners often mistake segmentation masks for colored images; colors in visualizations are merely human-readable palettes mapping integer IDs to RGB values.
Binary Segmentation Masks
Used when isolating a single target entity from the background (e.g., tumor vs healthy tissue, polyp detection in endoscopy).
Stored as 1-channel 8-bit unsigned integers (uint8).
Multiclass Categorical Masks
Used in multi-class semantic segmentation (e.g., Cityscapes dataset with Road, Sidewalk, Vehicle, Sky, Building).
Fed directly to PyTorch CrossEntropyLoss as torch.long tensor of shape $[N, H, W]$.
5 How Does a Segmentation Model Work? (Encoder-Decoder Topology)
Standard CNNs downsample spatial dimensions through strided convolutions and max pooling. For classification, spatial downsampling is beneficial because it builds scale invariance. However, in segmentation, downsampling destroys high-resolution boundary details. An encoder-decoder architecture resolves this tension:
Progressively extracts semantic visual features while contracting spatial dimensions ($H \times W \to \frac{H}{16} \times \frac{W}{16}$).
The deepest layer holding compact, maximum-receptive-field semantic context (e.g., $512 \times 16 \times 16$).
Upsamples feature maps back to original resolution ($\frac{H}{16} \to H$) via transposed convolutions or bilinear interpolation.
A $1 \times 1$ convolution projecting channel depth to $K$ classes, generating per-pixel raw logits $[N, K, H, W]$.
6 U-Net: The Architecture & Skip Connection Mechanics
Proposed by Olaf Ronneberger, Philipp Fischer, and Thomas Brox in 2015, U-Net became one of the most cited computer vision architectures in deep learning history. Named after its symmetric "U" shape, U-Net solved the fundamental dilemma of segmentation: how to combine broad semantic context with pixel-accurate spatial localization.
The Four Crucial Components of U-Net
Repeated applications of two $3 \times 3$ convolutions (unpadded in original paper, padded in modern implementations), Batch Normalization, ReLU activations, and $2 \times 2$ Max Pooling with stride 2 for downsampling. At each step, feature channels double.
At each stage, an upsampling operation ($2 \times 2$ Transposed Convolution or Bilinear Interpolation) halves the number of feature channels while doubling spatial dimensions.
Directly bridges the high-resolution feature maps from each encoder stage across to the corresponding decoder stage. The decoder concatenates these tensors along the channel dimension:
A final $1 \times 1$ convolutional layer maps the 32- or 64-channel refined decoder output directly to the number of classes $K$, producing the final unnormalized logit tensor.
7 The Mathematics of Pixel-Level Classification
Semantic segmentation reformulates image processing as $H \times W$ simultaneous, independent multi-class classification problems. For each pixel $(i, j)$, the network outputs a vector of raw logits $\mathbf{z}_{i, j} = [z_{i, j, 0}, z_{i, j, 1}, \dots, z_{i, j, K-1}] \in \mathbb{R}^K$.
The final predicted class ID $\hat{y}_{i, j}$ for pixel $(i, j)$ is computed via the argmax operator along the channel dimension:
8 Loss Functions for Segmentation: Cross-Entropy, Dice & Combined
Choosing the correct loss function determines whether a segmentation model converges or completely fails. Unlike classification, segmentation datasets suffer from massive class imbalance: the background class often accounts for 90%–99% of total pixels, while target entities (tumors, cracks, pedestrians) occupy only 1%–5%.
1. Pixel-wise Cross-Entropy
Evaluates classification accuracy at each pixel independently:
Drawback: Overwhelmed by massive background regions.
2. Soft Dice Loss
Measures global region overlap, inherently immune to background pixel count:
Ideal for small, rare foreground targets (e.g., tumors).
3. Combined Loss
The industry standard: blends smooth pixel probabilities with region overlap:
Provides stable gradients while maximizing mIoU.
import torch
import torch.nn as nn
class SoftDiceLoss(nn.Module):
def __init__(self, eps: float = 1e-6):
super().__init__()
self.eps = eps
def forward(self, logits: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
# logits: [N, 1, H, W] for binary segmentation
# targets: [N, 1, H, W] float {0.0, 1.0}
probs = torch.sigmoid(logits)
probs_flat = probs.view(probs.shape[0], -1)
targets_flat = targets.view(targets.shape[0], -1)
intersection = (probs_flat * targets_flat).sum(dim=1)
cardinality = probs_flat.sum(dim=1) + targets_flat.sum(dim=1)
dice = (2.0 * intersection + self.eps) / (cardinality + self.eps)
return 1.0 - dice.mean()
9 Evaluation Metrics: Pixel Accuracy, IoU, Mean IoU & Dice
Evaluating segmentation models requires metrics that resist class-imbalance distortions.
$$\text{Acc} = \frac{\sum \text{Correct Pixels}}{\text{Total Pixels}}$$ If 95% of the image is background, predicting all zeros achieves 95% accuracy while completely missing the target entity.
$$\text{IoU}_c = \frac{\text{TP}_c}{\text{TP}_c + \text{FP}_c + \text{FN}_c}$$ Directly penalizes both false alarms and missed regions for class $c$.
$$\text{mIoU} = \frac{1}{K} \sum_{c=0}^{K-1} \text{IoU}_c$$ The benchmark metric across all competition leaderboards (Cityscapes, ADE20K, Pascal VOC).
Mathematical Identity Between Dice Score & IoU (Jaccard)
The Dice coefficient and IoU are monotonically related functions of the exact same underlying confusion counts:
Notice that $\text{Dice} \ge \text{IoU}$ for all values in $[0, 1]$. For instance, an IoU of $0.70$ equates to a Dice score of $0.824$.
10 Preparing a Segmentation Dataset & The Interpolation Rule
A semantic segmentation dataset consists of matched pairs of images and annotation masks.
When resizing images and segmentation masks to a uniform resolution (e.g., $256 \times 256$):
Image.Resampling.BILINEAR) to preserve continuous optical texture.
Image.Resampling.NEAREST)!
Why? If class 0 is Background and class 2 is Car, bilinear interpolation on the mask creates intermediate floating values like 0.8, which round to class 1 (Road). Bilinear interpolation literally fabricates non-existent classes along object boundaries!
11 Building a U-Net with PyTorch
Let us build a modular, production-ready U-Net in PyTorch. Each stage utilizes a DoubleConv block comprising two $3 \times 3$ convolutions with Batch Normalization and LeakyReLU / ReLU activations.
import torch
import torch.nn as nn
import torch.nn.functional as F
class DoubleConv(nn.Module):
# (Conv2d -> BatchNorm -> ReLU) * 2 with padding=1 to preserve spatial dimensions.
def __init__(self, in_channels: int, out_channels: int):
super().__init__()
self.block = nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True),
nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True)
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.block(x)
class UNet(nn.Module):
def __init__(self, in_channels: int = 3, num_classes: int = 3):
super().__init__()
# Encoder (Contracting Path)
self.enc1 = DoubleConv(in_channels, 32)
self.enc2 = DoubleConv(32, 64)
self.enc3 = DoubleConv(64, 128)
self.enc4 = DoubleConv(128, 256)
self.pool = nn.MaxPool2d(kernel_size=2, stride=2)
# Bottleneck
self.bottleneck = DoubleConv(256, 512)
# Decoder (Expanding Path) with Transposed Convolutions
self.up4 = nn.ConvTranspose2d(512, 256, kernel_size=2, stride=2)
self.dec4 = DoubleConv(512, 256)
self.up3 = nn.ConvTranspose2d(256, 128, kernel_size=2, stride=2)
self.dec3 = DoubleConv(256, 128)
self.up2 = nn.ConvTranspose2d(128, 64, kernel_size=2, stride=2)
self.dec2 = DoubleConv(128, 64)
self.up1 = nn.ConvTranspose2d(64, 32, kernel_size=2, stride=2)
self.dec1 = DoubleConv(64, 32)
# 1x1 Convolution Segmentation Head
self.out = nn.Conv2d(32, num_classes, kernel_size=1)
def forward(self, x: torch.Tensor) -> torch.Tensor:
# Encoder forward pass
e1 = self.enc1(x) # [N, 32, H, W]
e2 = self.enc2(self.pool(e1)) # [N, 64, H/2, W/2]
e3 = self.enc3(self.pool(e2)) # [N, 128, H/4, W/4]
e4 = self.enc4(self.pool(e3)) # [N, 256, H/8, W/8]
# Bottleneck
b = self.bottleneck(self.pool(e4)) # [N, 512, H/16, W/16]
# Decoder stage 4 with skip connection from e4
d4 = self.up4(b)
if d4.shape[-2:] != e4.shape[-2:]:
d4 = F.interpolate(d4, size=e4.shape[-2:], mode="bilinear", align_corners=False)
d4 = self.dec4(torch.cat([d4, e4], dim=1)) # Concat channels: 256 + 256 = 512
# Decoder stage 3 with skip connection from e3
d3 = self.up3(d4)
if d3.shape[-2:] != e3.shape[-2:]:
d3 = F.interpolate(d3, size=e3.shape[-2:], mode="bilinear", align_corners=False)
d3 = self.dec3(torch.cat([d3, e3], dim=1)) # 128 + 128 = 256
# Decoder stage 2 with skip connection from e2
d2 = self.up2(d3)
if d2.shape[-2:] != e2.shape[-2:]:
d2 = F.interpolate(d2, size=e2.shape[-2:], mode="bilinear", align_corners=False)
d2 = self.dec2(torch.cat([d2, e2], dim=1)) # 64 + 64 = 128
# Decoder stage 1 with skip connection from e1
d1 = self.up1(d2)
if d1.shape[-2:] != e1.shape[-2:]:
d1 = F.interpolate(d1, size=e1.shape[-2:], mode="bilinear", align_corners=False)
d1 = self.dec1(torch.cat([d1, e1], dim=1)) # 32 + 32 = 64
# Output projection head produces raw logits: [N, num_classes, H, W]
return self.out(d1)
# Sanity check output dimensions
model = UNet(in_channels=3, num_classes=4)
x = torch.randn(2, 3, 256, 256)
logits = model(x)
print(f"Logits shape: {logits.shape}") # Expect: [2, 4, 256, 256]
12 End-to-End Dataset, Training & Validation Pipeline
Here is the complete training pipeline including custom PyTorch Dataset loading, loss computation with CrossEntropyLoss, and validation.
import numpy as np
from PIL import Image
from torch.utils.data import Dataset, DataLoader
class SegmentationDataset(Dataset):
def __init__(self, image_paths, mask_paths, size=(256, 256)):
self.image_paths = image_paths
self.mask_paths = mask_paths
self.size = size
def __len__(self):
return len(self.image_paths)
def __getitem__(self, idx):
# 1. Load image and mask
image = Image.open(self.image_paths[idx]).convert("RGB")
mask = Image.open(self.mask_paths[idx]).convert("L")
# 2. Resize with correct interpolation!
w, h = self.size[1], self.size[0]
image = image.resize((w, h), Image.Resampling.BILINEAR)
mask = mask.resize((w, h), Image.Resampling.NEAREST)
# 3. Convert to PyTorch tensors
img_np = np.asarray(image, dtype=np.float32) / 255.0 # Normalize to [0, 1]
mask_np = np.asarray(mask, dtype=np.int64) # Discrete class IDs
image_tensor = torch.from_numpy(img_np).permute(2, 0, 1) # [C, H, W]
mask_tensor = torch.from_numpy(mask_np) # [H, W]
return image_tensor, mask_tensor
# Setup Training Loop
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = UNet(in_channels=3, num_classes=3).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)
# Training Epoch
model.train()
for images, masks in train_loader:
images, masks = images.to(device), masks.to(device)
optimizer.zero_grad(set_to_none=True)
logits = model(images) # [N, 3, 256, 256]
loss = criterion(logits, masks) # masks is [N, 256, 256] with dtype torch.long
loss.backward()
optimizer.step()
13 Calculating Mean IoU via Vectorized Confusion Matrix
Calculating per-pixel IoU iteratively in Python loops causes devastating CPU-GPU synchronization bottlenecks. Production systems compute the $K \times K$ confusion matrix in a single vectorized GPU kernel using torch.bincount:
@torch.no_grad()
def evaluate_miou(model, dataloader, num_classes, device):
model.eval()
confusion = torch.zeros(num_classes, num_classes, dtype=torch.long, device=device)
for images, targets in dataloader:
images, targets = images.to(device), targets.to(device)
logits = model(images)
preds = logits.argmax(dim=1) # [N, H, W]
# Valid pixel mask excluding ignore_index
valid = (targets >= 0) & (targets < num_classes)
indices = num_classes * targets[valid].long() + preds[valid].long()
confusion += torch.bincount(indices, minlength=num_classes**2).view(num_classes, num_classes)
# Compute per-class IoU from confusion matrix diagonal
tp = confusion.diag()
fp = confusion.sum(dim=0) - tp
fn = confusion.sum(dim=1) - tp
denominator = tp + fp + fn
ious = tp.float() / denominator.clamp(min=1)
present_classes = denominator > 0
mean_iou = ious[present_classes].mean().item()
return ious.cpu().numpy(), mean_iou
14 Modern Segmentation Model Zoo
| Architecture | Primary Paradigm | Core Innovation | Ideal Use Case |
|---|---|---|---|
| U-Net | Semantic Segmentation | Encoder-decoder with dense skip connections | Biomedical imaging, small datasets, defect detection |
| FCN (Fully Conv Net) | Semantic Segmentation | Replaced dense linear layers with $1 \times 1$ convs | Historical baseline, simple dense prediction |
| DeepLabv3+ | Semantic Segmentation | Atrous Spatial Pyramid Pooling (ASPP) & depthwise separable convs | Urban street scenes, multi-scale complex landscapes |
| SegFormer | Semantic Segmentation | Hierarchical Vision Transformer encoder + lightweight MLP decoder | State-of-the-art accuracy with zero positional encodings |
| Mask R-CNN | Instance Segmentation | Faster R-CNN + FCN mask branch via RoIAlign | Object-aware instance tracking, robotics grasping |
15 Top 10 Segmentation Pitfalls & Anti-Patterns Matrix
| # | Pitfall / Anti-Pattern | Root Cause | Symptom | Production Remedy |
|---|---|---|---|---|
| 1 | Bilinear Mask Resizing | Using default bilinear interpolation when resizing label masks. | Phantom intermediate class IDs appear on borders. | Always use Image.Resampling.NEAREST for masks. |
| 2 | CrossEntropy Target Shape Mismatch | Passing $[N, 1, H, W]$ or $[N, K, H, W]$ targets to PyTorch CE loss. | Runtime crash: 1D target tensor expected. |
Squeeze channel dimension: target must be $[N, H, W]$ with dtype torch.long. |
| 3 | Sigmoid with BCEWithLogitsLoss | Applying torch.sigmoid() before calling BCEWithLogitsLoss. |
Double sigmoid saturates gradients; loss stagnates. | Pass raw unbounded logits directly into BCEWithLogitsLoss. |
| 4 | Unsynchronized Augmentation | Applying separate random transforms to image and mask independently. | Image is flipped horizontally while mask stays unflipped. | Use Albumentations or set fixed random seeds for joint spatial transforms. |
| 5 | Relying on Pixel Accuracy | Evaluating severe class imbalance using plain pixel accuracy. | Model reports 97% accuracy while mIoU is 0.05. | Always report per-class IoU, mIoU, and Dice score. |
| 6 | VRAM Out-of-Memory (OOM) | Retaining full-resolution high-channel feature maps during backward pass. | CUDA OOM on batch size 4 with $512 \times 512$ inputs. | Enable PyTorch mixed precision (torch.cuda.amp.autocast()) and gradient checkpointing. |
| 7 | Dice Division by Zero | Omitting smoothing constant $\epsilon$ when neither prediction nor GT has foreground. | Dice loss returns NaN; weights corrupt. |
Always add $\epsilon = 10^{-6}$ to both numerator and denominator. |
| 8 | Padding Mismatch in U-Net | Using unpadded $3 \times 3$ convs causing decoder feature maps to shrink. | Shape mismatch during torch.cat([d, e]). |
Use padding=1 in all conv layers or interpolate decoder maps. |
| 9 | Color Augmentation on Masks | Applying ColorJitter or Brightness to categorical integer mask. | Pixel labels shift to corrupt, arbitrary class IDs. | Apply photometric transformations strictly to input images only. |
| 10 | Ignoring Missing Classes in mIoU | Averaging IoU over all $K$ classes when some classes never appeared in batch. | mIoU falsely penalized by unobserved classes. | Compute mean only across classes present in GT or prediction. |
16 Practical Assignment: Build a Road & Vehicle Segmentation Model
Capstone Task: Train a 3-Class Urban Scene Segmenter
Train a PyTorch U-Net to segment street scenes into Background (0), Road (1), and Vehicle (2).
17 Interactive Image Segmentation Workbench
Explore U-Net skip connections, compare semantic vs instance vs panoptic segmentation, test Dice vs CE loss dynamics, and diagnose mask interpolation errors.
U-Net Architecture & Skip Connection Laboratory
Toggle skip connections ON or OFF to observe how encoder spatial features restore boundary sharpness in the reconstructed mask.
Click the "Skip Connections" button above to witness the loss of fine boundaries when encoder features are disconnected from the decoder!
18 Key Takeaways
- Dense Pixel Prediction: Image segmentation predicts a class label or instance identifier for every individual pixel in an $H \times W$ image.
- Taxonomy: Semantic segmentation classifies pixels without distinguishing instances; Instance segmentation isolates individual countable "things"; Panoptic segmentation unifies both for 100% coverage.
- U-Net Architecture: Combines an encoder (feature compression), a bottleneck (high-level context), and a decoder (resolution restoration).
- Skip Connections: Transfer high-resolution spatial feature maps directly from encoder stages to decoder stages via concatenation, preserving sub-pixel boundary accuracy.
- Loss Landscape: Pixel Cross-Entropy is vulnerable to extreme class imbalance; Soft Dice Loss evaluates global region overlap; Combined Loss ($\mathcal{L}_{\text{CE}} + \mathcal{L}_{\text{Dice}}$) provides the best convergence.
- Evaluation: Pixel accuracy is dangerously deceptive on imbalanced datasets; always report Mean IoU (mIoU) and per-class IoU.
- Interpolation Rule: Always use Bilinear interpolation for input images and Nearest-Neighbor interpolation for categorical label masks.