TAI BUI
Back to curriculum
BuildPython

Semantic Segmentation — U-Net

Segmentation is classification at every pixel. U-Net solved it by pairing a downsampling encoder with an upsampling decoder, then wiring skip connections between them so detail survives the round trip.

TIME: ~75 minutesPREREQUISITES: Phase 4 Lesson 03 (CNNs), Phase 4 Lesson 04 (Image Classification)

Learning Objectives

  • Distinguish semantic, instance, and panoptic segmentation and pick the right task for a given problem
  • Build a U-Net from scratch in PyTorch with encoder blocks, a bottleneck, an upsampling decoder, and skip connections
  • Implement pixel-wise cross-entropy, Dice loss, and the combined loss that is the current default for medical and industrial segmentation
  • Read IoU and Dice metrics per class and diagnose whether a bad score comes from small-object recall, boundary accuracy, or class imbalance

The Problem

Classification outputs one label per image. Detection outputs a handful of boxes. Segmentation outputs one label per pixel: for an H x W input, the output is a full H x W tensor (semantic) or H x W x N_instances (instance). That's millions of predictions per image, not one.

This is why segmentation runs almost every dense-prediction product in vision: medical imaging (tumor masks), autonomous driving (road, lane, obstacle), satellite (building footprints, crop boundaries), document parsing (layout zones), robotics (graspable regions). None of those tasks can be solved with a box around the object. They need the silhouette.

The hard part here is architectural, not conceptual: the network needs the global context of an image (what kind of scene is this) and the local pixel detail (which exact pixel is road versus pavement) at the same time. A standard CNN compresses spatially to gain that context, and the compression is exactly what throws the detail away. U-Net was the design that kept both.

Warm-up0/3
1. A CNN classifier repeatedly halves spatial resolution with pooling or strided convolutions to build a global sense of the scene. What is the natural cost of doing that?Warm-up
Four boxes in a row labelled 128x128, 64x64, 32x32 and 16x16, connected by arrows marked pool, showing a feature map's spatial size shrinking by half at each step.
Illustrative: a classifier's feature map after each pooling step.
2. A segmentation task has 99% background pixels and 1% tumor pixels. You train with plain cross-entropy and reach 99% pixel accuracy. What most likely happened?Warm-up
A 10x10 grid of 100 cells, 99 in the background color and 1 in orange for the tumor class, next to a badge for the plain cross-entropy run reading accuracy 99%.
99 of 100 cells are background, 1 is tumor, matching the stem's 99%/1% split.
3. A conv layer with a 3x3 kernel, stride 1 and padding 1 is applied to a 64x64 feature map. What happens to the spatial size?Warm-up
A 64x64 input square with a dashed padding ring around it and a 3x3 kernel window highlighted in the top-left corner, an arrow to a dashed box marked question mark, output size, and a legend naming the input size, kernel size and padding.
A 3x3 kernel, stride 1, padding 1, applied to a 64x64 input; the output size is not shown.

The Concept

Semantic vs instance vs panoptic

Mermaid diagram

  • Semantic segmentation says "this pixel is road, that pixel is car." Two cars sitting next to each other collapse into one blob.
  • Instance segmentation says "this pixel is car #3, that pixel is car #5," and ignores background "stuff" (sky, road, grass) entirely.
  • Panoptic segmentation unifies both: every pixel gets a class label, every instance also gets a unique id, so stuff and things are both segmented.

This lesson covers semantic segmentation. The next one, on Mask R-CNN, covers instance.

The U-Net shape

Mermaid diagram

The encoder halves spatial resolution four times and doubles the channel count each time. The decoder mirrors that in reverse: doubling resolution and halving channels at every step. Skip connections concatenate the encoder's features into the decoder at each matching resolution, and a final 1x1 conv maps 64 -> num_classes at full resolution.

Skip connections exist because the decoder, by the time it has to output pixel-level predictions, has only seen small feature maps. Without them it can't localize edges accurately, because that spatial detail was compressed away going down the encoder. The skips hand back the high-resolution features the encoder computed on the way down, so the decoder has both the coarse context and the fine detail it needs.

Transposed vs bilinear upsample

The decoder has to expand spatial dimensions, and there are two common ways to do it. Transposed convolution (nn.ConvTranspose2d) is a learnable upsample and was the original U-Net's choice, but it can produce checkerboard artifacts when stride and kernel size don't divide evenly. Bilinear upsample followed by a 3x3 conv is smoother, uses fewer parameters, produces fewer artifacts, and is the modern default.

Both show up in real codebases. For a first U-Net, bilinear is the safer choice.

Cross-entropy on a pixel grid

For semantic segmentation with C classes, the model output is (N, C, H, W). The target is (N, H, W) with integer class IDs. Cross-entropy is identical to the classification case, just applied at every spatial position:

Loss = mean over (n, h, w) of -log( softmax(logits[n, :, h, w])[target[n, h, w]] )

F.cross_entropy in PyTorch handles this shape natively. No reshape needed.

Dice loss and why you need it

Dice loss directly optimizes the overlap between the predicted and true mask:

Dice(p, y) = 2 * sum(p * y) / (sum(p) + sum(y) + epsilon)
Dice_loss = 1 - Dice

where p is the sigmoid/softmax probability map for a class and y is the binary ground-truth mask. The loss hits zero only when the overlap is perfect, and because it's ratio-based, class imbalance doesn't affect it. That matters because cross-entropy treats every pixel equally, which falls apart when one class dominates the frame (medical imaging: 99% background, 1% tumor); a network can hit 99% accuracy by predicting background everywhere and still be completely useless.

In practice, use the combined loss:

L = L_cross_entropy + lambda * L_dice       (lambda ~ 1)

Cross-entropy gives stable gradients early in training; Dice spends the tail end of training actually matching the mask shape. This combination is the medical-imaging default, and it's hard to beat on any class-imbalanced dataset.

Evaluation metrics

  • Pixel accuracy: the percent of pixels predicted correctly. Cheap to compute, and just as broken on imbalanced data as plain accuracy is in classification.
  • IoU per class, intersection over union for each class's mask, averaged across classes to get mIoU.
  • Dice (F1 on pixels) is similar to IoU (Dice = 2 * IoU / (1 + IoU)); medical imaging tends to report Dice, driving-scene work tends to report IoU, and the two are monotonically related, so it rarely matters which one you pick.
  • Boundary F1 measures how close predicted boundaries land to the ground-truth boundaries, penalizing even small shifts. It matters most for high-precision tasks like semiconductor inspection.

Report IoU per class, not just the mean. A mIoU of 0.78 can hide one class sitting at 15% while nine others sit at 85%. Small objects take the biggest hit here: a handful of missed boundary pixels is a much larger fraction of a small object's area than a large one, so a genuinely well-trained detector can still post its lowest IoU on the smallest class.

Input resolution trade-off

U-Net's encoder halves resolution four times, so the input has to be divisible by 16. Medical images often run 512x512 or 1024x1024; autonomous-driving crops run 2048x1024. Memory cost scales with H * W * C_max, and at 1024x1024 with 1024 bottleneck channels, the forward pass alone already eats gigabytes of VRAM.

There are two standard workarounds. Tile the input: process 256x256 tiles with overlap, then stitch the predictions back together. Or replace the bottleneck with dilated convolutions that keep spatial resolution higher while widening the receptive field, which is what the DeepLab family does.

For a first model, a 256x256 input with a 64-channel-base U-Net trains comfortably on 8 GB of VRAM.

Check: The Concept0/26
1. What does semantic segmentation produce for an image?Define
A street scene with a sky band, a road band and two cars side by side, an arrow from it into a dashed box marked question mark, model output.
The input image only; what the model produces for it is not drawn.
2. Which task type separates individual cars of the same class from each other?Define
Two separate cars on a plain background, each drawn in a different color and labelled mask id 1 and mask id 2.
Two cars from one image, drawn as two separate masks numbered 1 and 2.
3. What distinguishes panoptic segmentation from semantic and instance segmentation on their own?Define
A park scene with a grass area filling the frame and two separate tree shapes standing in it, an arrow from it into a dashed box marked question mark, labels produced.
The input scene only; the labelling it would receive is not drawn.
4. What is a skip connection in a U-Net?Define
An encoder stage box at 128x128 with 256 channels, a bottleneck box at 32x32 with 1024 channels, and a decoder stage box back at 128x128 with 256 channels. A curved orange arrow labelled skip runs straight from the encoder stage to the decoder stage, bypassing the bottleneck.
A U-Net stage pair with the orange arrow the lesson calls a skip, drawn for a 512x512 input.
5. What is a transposed convolution (nn.ConvTranspose2d) used for in a decoder?Define
A box labelled feature map 8x8, an arrow into a box labelled nn.ConvTranspose2d, and an arrow into a box labelled feature map 16x16.
A transposed convolution standing between an 8x8 feature map and a 16x16 one inside a decoder.
6. How is Dice loss computed for one class from the predicted probability map p and ground-truth mask y?Define
Two 4x4 grids side by side, a heat-shaded predicted probability map p and a binary ground-truth mask y, next to a dashed box marked question mark, Dice loss for this class.
Illustrative probability and mask grids for one class; the loss for them is not worked out.
7. What does mIoU measure?Define
A solid green rectangle for the true mask overlapping a dashed orange rectangle for the predicted mask, with the area they share filled in orange and labelled shared area, next to a card naming the shared area and the combined area.
One predicted mask over one true mask, with the area they share and the area they cover together marked.
8. What does Boundary F1 measure?Define
A pixel grid with a solid green rectangle tracing the boundary of a true mask and a dashed orange rectangle tracing the predicted boundary one cell to the right and one cell down from it.
A zoomed view of one mask edge, where the predicted boundary runs one pixel off the true one.
9. Why can instance segmentation alone not give a full labelling of a driving scene that includes road and sky?Explain
A driving scene with a sky band, a road band, a car, and a pedestrian icon, with no segmentation output drawn yet.
The input driving scene before any segmentation is applied.
10. Why does a U-Net decoder need skip connections to localize edges accurately?Explain
A coarse 4x4 grid of muted blocks standing for the bottleneck feature map at 32x32 with 1024 channels, an arrow labelled decoder, and a fine 16x16 grid standing for the 512x512 output the decoder has to produce.
The tensor the decoder begins with at the bottleneck, and the output size it has to reach.
11. Why is bilinear upsampling followed by a 3x3 conv the modern default over a transposed convolution?Explain
Two boxes side by side: nn.ConvTranspose2d with stride 2, and nn.Upsample bilinear followed by a 3x3 conv, with the word 'or' between them.
The decoder's two candidate ways to double spatial resolution at each up-step.
12. For logits of shape (N, C, H, W) and integer targets of shape (N, H, W), how is cross-entropy applied in segmentation?Explain
A box for logits of shape N,C,H,W and a box for targets of shape N,H,W, above a small H by W grid with one cell marked.
The two tensor shapes, with one spatial position marked in the H by W grid.
13. Why does cross-entropy struggle on a frame that is 99% background, while Dice loss does not?Explain
A grid of mostly background cells with one foreground cell, next to two boxes naming the two candidate losses, cross-entropy and Dice loss.
The imbalanced frame from the lesson, with the two loss functions named but not yet compared.
14. Why does the combined loss pair cross-entropy with Dice instead of using Dice alone?Explain
The formula L = L_cross_entropy + lambda * L_dice with lambda about 1, above two boxes, L_cross_entropy and L_dice, both feeding into a box labelled L, total loss.
The lesson's combined-loss formula and the two terms it adds together.
15. You report mIoU = 0.78 on a 10-class task. Why should you also publish per-class IoU?Explain
A badge reading mIoU, 10 classes, 0.78, above a row of ten dashed boxes each marked with a question mark, standing for an unknown per-class breakdown.
The single number reported: a mean over 10 classes, with no per-class breakdown shown yet.
16. Dice = 2*IoU / (1 + IoU) for the same masks. What does this imply for choosing which metric to report?Explain
A box with the formula Dice = 2 times IoU over 1 plus IoU, above a two-column table listing IoU values 0.25, 0.50, 0.75 and 0.90 with the Dice column left as question marks.
The formula the stem gives, with four IoU values and their Dice left blank.
17. U-Net's encoder halves resolution four times, so inputs must be divisible by 16. What two workarounds exist for very large images?Explain
A badge reading input size 1024 by 1024 and a badge reading forward pass eats gigabytes of VRAM, with notes that this is 16 times the pixels of a 256 by 256 input and that the input must be divisible by 16.
The resolution problem for a large segmentation input, before any fix is applied.
18. You swap bilinear upsample + 3x3 conv for ConvTranspose2d with kernel_size=3, stride=2 and see checkerboard artifacts. Why?Analyze
A grid showing a faint repeating checkerboard pattern, labelled as the output of a ConvTranspose2d layer with kernel_size 3 and stride 2.
The checkerboard pattern described in the stem, on the output of this transposed-conv layer.
19. Two classes are segmented equally well in the interior, but one is a large object and the other is a small object a few dozen pixels wide. Why might the small object's IoU look worse?Analyze
A large square and a small square drawn to the same scale, each with the same 3-pixel-deep orange boundary error band around its edge and the interior otherwise correct.
Two example objects of very different sizes, each carrying the same depth of boundary error at its edge.
20. A team trains with plain cross-entropy, sees a strong mIoU, then learns in review that the rare class has a Dice near zero. What practice would have exposed this on the first run?Analyze
A badge reading run 1 reported, mIoU 0.91, an arrow into a second badge reading later in review, rare class Dice about 0.00.
Illustrative figures for the sequence the stem describes: a strong mean first, a failing class later.
21. A model's masks have the right overall shapes, but the outlines sit a few pixels off. Pixel accuracy and mIoU look fine. Which metric exposes the problem?Analyze
A filled circle for the true mask with a dashed outline a few pixels off for the predicted boundary, next to two badges both reading 'fine': pixel accuracy and mIoU.
The scenario from the stem: good interior overlap and good headline metrics, with outlines slightly off.
22. A model labels every car pixel as 'car' correctly, yet two parked cars side by side come out as one blob and you cannot count the cars. What is the cause?Analyze
Two parked cars drawn as one unbroken orange region with no boundary anywhere inside it, next to a dashed box marked question mark, cars counted.
The two parked cars from the stem, every pixel of them labelled car, forming one continuous region.
23. A U-Net finds objects in the right places, but their edges come out blurry and rounded. Which cause is most likely?Analyze
A blob in roughly the right place with a wavy, rounded outline instead of a crisp edge, labelled rounded wavy outline instead of a crisp edge.
The predicted mask from the stem: correctly located, but with blurry, rounded edges.
24. You must segment 1024x1024 medical images and the forward pass exhausts an 8 GB GPU. Which approach fits?Design
A badge reading input size 1024 by 1024 and a badge reading GPU memory 8 GB, exhausted.
The stem's resolution and memory constraint, with no workaround applied yet.
25. A new project has a rare lesion class covering about 1% of pixels. Which training and evaluation setup fits best?Design
A horizontal bar split into a wide background segment of about 99 percent and a thin orange lesion segment of about 1 percent, above a card listing two blanks, loss function and headline metric.
The pixel split the stem gives, and the two choices the question is about.
26. A driving product must count individual pedestrians and also label road and sky for every pixel. Which task fits?Design
A driving scene with a sky band, a road band and a pedestrian icon, with no segmentation applied, next to a card listing two requirements: count pedestrians, and label road and sky per pixel.
The input scene and the two requirements given in the stem, with no task chosen yet.

Build It

Step 1: Encoder block

Two 3x3 convs with batch norm and ReLU. The first conv changes channel count; the second keeps it.

python
import torch
import torch.nn as nn
import torch.nn.functional as F
 
class DoubleConv(nn.Module):
    def __init__(self, in_c, out_c):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(in_c, out_c, kernel_size=3, padding=1, bias=False),
            nn.BatchNorm2d(out_c),
            nn.ReLU(inplace=True),
            nn.Conv2d(out_c, out_c, kernel_size=3, padding=1, bias=False),
            nn.BatchNorm2d(out_c),
            nn.ReLU(inplace=True),
        )
 
    def forward(self, x):
        return self.net(x)

This block is reused throughout. bias=False because BN's beta handles the bias.

Step 2: Down and up blocks

python
class Down(nn.Module):
    def __init__(self, in_c, out_c):
        super().__init__()
        self.net = nn.Sequential(
            nn.MaxPool2d(2),
            DoubleConv(in_c, out_c),
        )
 
    def forward(self, x):
        return self.net(x)
 
 
class Up(nn.Module):
    def __init__(self, in_c, out_c):
        super().__init__()
        self.up = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False)
        self.conv = DoubleConv(in_c, out_c)
 
    def forward(self, x, skip):
        x = self.up(x)
        if x.shape[-2:] != skip.shape[-2:]:
            x = F.interpolate(x, size=skip.shape[-2:], mode="bilinear", align_corners=False)
        x = torch.cat([skip, x], dim=1)
        return self.conv(x)

The spatial-only shape check (shape[-2:]) handles inputs whose dimensions are not divisible by 16; a safe F.interpolate aligns the tensor before the concat. Comparing the full shape would also trigger on channel-count differences, which should be a loud error, not a silent interpolate.

Step 3: The U-Net

python
class UNet(nn.Module):
    def __init__(self, in_channels=3, num_classes=2, base=64):
        super().__init__()
        self.inc = DoubleConv(in_channels, base)
        self.d1 = Down(base, base * 2)
        self.d2 = Down(base * 2, base * 4)
        self.d3 = Down(base * 4, base * 8)
        self.d4 = Down(base * 8, base * 16)
        self.u1 = Up(base * 16 + base * 8, base * 8)
        self.u2 = Up(base * 8 + base * 4, base * 4)
        self.u3 = Up(base * 4 + base * 2, base * 2)
        self.u4 = Up(base * 2 + base, base)
        self.outc = nn.Conv2d(base, num_classes, kernel_size=1)
 
    def forward(self, x):
        x1 = self.inc(x)
        x2 = self.d1(x1)
        x3 = self.d2(x2)
        x4 = self.d3(x3)
        x5 = self.d4(x4)
        x = self.u1(x5, x4)
        x = self.u2(x, x3)
        x = self.u3(x, x2)
        x = self.u4(x, x1)
        return self.outc(x)
 
net = UNet(in_channels=3, num_classes=2, base=32)
x = torch.randn(1, 3, 256, 256)
print(f"output: {net(x).shape}")
print(f"params: {sum(p.numel() for p in net.parameters()):,}")

Output shape is (1, 2, 256, 256): same spatial size as the input, with num_classes channels instead of 3. About 7.7M parameters at base=32.

Step 4: Losses

python
def dice_loss(logits, targets, num_classes, eps=1e-6):
    probs = F.softmax(logits, dim=1)
    targets_one_hot = F.one_hot(targets, num_classes).permute(0, 3, 1, 2).float()
    dims = (0, 2, 3)
    intersection = (probs * targets_one_hot).sum(dim=dims)
    denom = probs.sum(dim=dims) + targets_one_hot.sum(dim=dims)
    dice = (2 * intersection + eps) / (denom + eps)
    return 1 - dice.mean()
 
 
def combined_loss(logits, targets, num_classes, lam=1.0):
    ce = F.cross_entropy(logits, targets)
    dc = dice_loss(logits, targets, num_classes)
    return ce + lam * dc, {"ce": ce.item(), "dice": dc.item()}

Dice is computed per class then averaged (macro Dice). The eps prevents division by zero on classes absent from the batch.

Step 5: IoU metric

python
@torch.no_grad()
def iou_per_class(logits, targets, num_classes):
    preds = logits.argmax(dim=1)
    ious = torch.zeros(num_classes)
    for c in range(num_classes):
        pred_c = (preds == c)
        true_c = (targets == c)
        inter = (pred_c & true_c).sum().float()
        union = (pred_c | true_c).sum().float()
        ious[c] = (inter / union) if union > 0 else torch.tensor(float("nan"))
    return ious

Returns a vector of length C; nan marks classes absent from the batch, so don't average over those when computing mIoU.

Step 6: Synthetic dataset for end-to-end verification

Generate shapes on colored backgrounds so the network has to learn shape, not pixel color.

python
import numpy as np
from torch.utils.data import Dataset, DataLoader
 
def synthetic_segmentation(num_samples=200, size=64, seed=0):
    rng = np.random.default_rng(seed)
    images = np.zeros((num_samples, size, size, 3), dtype=np.float32)
    masks = np.zeros((num_samples, size, size), dtype=np.int64)
    for i in range(num_samples):
        bg = rng.uniform(0, 1, (3,))
        images[i] = bg
        masks[i] = 0
        num_shapes = rng.integers(1, 4)
        for _ in range(num_shapes):
            cls = int(rng.integers(1, 3))
            color = rng.uniform(0, 1, (3,))
            cx, cy = rng.integers(10, size - 10, size=2)
            r = int(rng.integers(4, 12))
            yy, xx = np.meshgrid(np.arange(size), np.arange(size), indexing="ij")
            if cls == 1:
                mask = (xx - cx) ** 2 + (yy - cy) ** 2 < r ** 2
            else:
                mask = (np.abs(xx - cx) < r) & (np.abs(yy - cy) < r)
            images[i][mask] = color
            masks[i][mask] = cls
        images[i] += rng.normal(0, 0.02, images[i].shape)
        images[i] = np.clip(images[i], 0, 1)
    return images, masks
 
 
class SegDataset(Dataset):
    def __init__(self, images, masks):
        self.images = images
        self.masks = masks
 
    def __len__(self):
        return len(self.images)
 
    def __getitem__(self, i):
        img = torch.from_numpy(self.images[i]).permute(2, 0, 1).float()
        mask = torch.from_numpy(self.masks[i]).long()
        return img, mask

Three classes: background (0), circles (1), squares (2). The network must learn to distinguish shape.

Step 7: Training loop

python
def train_one_epoch(model, loader, optimizer, device, num_classes):
    model.train()
    loss_sum, total = 0.0, 0
    iou_sum = torch.zeros(num_classes)
    for x, y in loader:
        x, y = x.to(device), y.to(device)
        logits = model(x)
        loss, _ = combined_loss(logits, y, num_classes)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        loss_sum += loss.item() * x.size(0)
        total += x.size(0)
        iou_sum += iou_per_class(logits, y, num_classes).nan_to_num(0)
    return loss_sum / total, iou_sum / len(loader)

Run this for 10-30 epochs on the synthetic dataset and watch mIoU climb past 0.9 for the shape classes. Note the nan_to_num(0) treats classes absent from a batch as zero; for accurate per-class IoU, mask by presence and use torch.nanmean across batches at evaluation time rather than averaging here.

Check: Build It0/10
1. In the Up block, why does the code compare only shape[-2:] before calling F.interpolate, not the full shape?Explain
The Up block's forward method, with the line comparing x.shape[-2:] to skip.shape[-2:] highlighted, just before torch.cat.
The exact shape check from the lesson's Up block, taken from its own code.
2. With net = UNet(in_channels=3, num_classes=2, base=32) and x = torch.randn(1, 3, 256, 256), what does net(x).shape print?Apply
A box labelled input (1, 3, 256, 256), an arrow into a box labelled encoder to decoder, five stages each, and an arrow into a dashed box labelled (1, ?, ?, ?), output, with the constructor arguments in_channels=3, num_classes=2, base=32 noted below.
The network's constructor arguments and input shape; the output shape is left unresolved.
3. For the 256x256 input in that U-Net, what is the spatial size of x5, the tensor passed into u1?Apply
Five boxes in a row labelled x1 (inc) through x5 (d4), with the first marked 256x256 and each arrow between them marked MaxPool2d(2), with no sizes shown for x2 through x5.
The encoder's chain of stages for a 256x256 input; only x1's size is given.
4. With base=32, how many channels does the tensor have inside u1 right after torch.cat([skip, x], dim=1)?Apply
A box for x5 with base*16 channels and a box for skip x4 with base*8 channels, both feeding into a dashed box marked question mark channels, torch.cat([skip, x]).
The two tensors concatenated inside u1, in terms of base, before any arithmetic.
5. For one class, sum(p*y) = 3, sum(p) = 4 and sum(y) = 5, ignoring epsilon. What is the Dice loss?Apply
The Dice and Dice-loss formulas from the lesson, with a card listing the given values sum(p*y) = 3, sum(p) = 4 and sum(y) = 5 for one class.
The lesson's own Dice formula with the three numbers given in the stem; no arithmetic is carried out yet.
6. In iou_per_class, class 2 appears in neither the predictions nor the targets of a batch. What is ious[2]?Apply
The iou_per_class code for one class, with pred_c and true_c both noted as all False, computing intersection and union, with a note that class 2 appears in neither the predictions nor the targets.
The exact code path when a class is absent from both masks of a batch.
7. In train_one_epoch, a batch has no pixels of class 2 and none predicted. What does nan_to_num(0) do to that class's running IoU sum?Apply
The accumulation line iou_sum += iou_per_class(...).nan_to_num(0), with a note that this batch has no pixels of class 2 in either the predictions or the targets, so that class's value is nan before nan_to_num runs.
The line that accumulates IoU across batches, and the batch condition the question asks about.
8. In the Up block with a 250x250 input, the upsampled tensor is 30x30 while its skip is 31x31. What does forward do?Apply
A box labelled upsampled x, 30 by 30, and a box labelled skip, 31 by 31, both pointing down into a dashed box marked question mark, torch.cat([skip, x]).
The two mismatched spatial sizes the Up block must reconcile before concatenating.
9. What does combined_loss(logits, targets, num_classes) return with the default lam?Apply
The lesson's combined_loss function computing ce and dc, with its return statement replaced by an ellipsis on a highlighted line.
The combined_loss function's body, with the return statement left out.
10. With 3 classes, dice_loss finds per-class Dice of 0.9, 0.9 and 0.0. What does it return?Apply
Three bars labelled class 1, class 2 and class 3 with Dice values 0.9, 0.9 and 0.0, next to a dashed box marked question mark labelled mean.
The three per-class Dice values given in the stem, with no mean or loss computed yet.

Use It

For production, segmentation_models_pytorch ("smp") wraps every standard segmentation architecture with any torchvision or timm backbone. It's three lines:

python
import segmentation_models_pytorch as smp
 
model = smp.Unet(
    encoder_name="resnet34",
    encoder_weights="imagenet",
    in_channels=3,
    classes=3,
)

Also worth knowing for real work:

  • DeepLabV3+ swaps max-pool-based downsampling for dilated convs, so the bottleneck keeps its resolution and boundaries come out sharper on satellite and driving data.
  • SegFormer replaces the conv encoder with a hierarchical transformer and has ranked near the top of several segmentation benchmarks.
  • Mask2Former and OneFormer go further and unify semantic, instance, and panoptic segmentation into one architecture.

All three drop in as replacements inside smp or transformers, using the same data loader.

Check: Use It0/1
1. Predicted boundaries on satellite imagery are soft because max-pool downsampling shrinks the bottleneck. Which change does the lesson point to?Design
A field-shaped blob with a soft, blurred boundary on a satellite image, with a note that the cause given is max-pool downsampling shrinking the bottleneck.
The soft-boundary symptom and its stated cause, with no architectural fix shown yet.

Ship It

This lesson produces:

  • outputs/prompt-segmentation-task-picker.md: a prompt that picks between semantic, instance, and panoptic segmentation and names the architecture for a given task.
  • outputs/skill-segmentation-mask-inspector.md: a skill that reports class distribution and predicted-mask statistics, and flags the classes that are under-predicted or have blurred boundaries.
Author's Note

The most wasted debugging time I've seen on a segmentation project has nothing to do with the architecture. A team ships a U-Net trained on plain cross-entropy, watches a strong mIoU come in, and doesn't notice their rare class has a Dice score near zero until someone asks about it directly in a review. Add Dice to the loss from day one, even before you think you need it, and report IoU per class starting with your very first training run. It costs a few extra lines of code and saves you an awkward conversation three weeks later.

The other thing worth knowing before you hit it: bilinear upsampling beat transposed convolutions for a mundane reason, not a clever one. Checkerboard artifacts show up when a learnable upsample's receptive fields overlap unevenly across output positions, and once you've stared at a prediction with a faint grid baked into it, you stop reaching for ConvTranspose2d by default.

Exercises

  1. (Easy) Implement bce_dice_loss for a binary segmentation task (foreground vs background). Verify on a synthetic two-class dataset that the combined loss converges faster than BCE alone when the foreground is 5% of pixels.
  2. (Medium) Replace the nn.Upsample + conv up-block with a nn.ConvTranspose2d up-block. Train both on the synthetic dataset and compare mIoU. Observe where checkerboard artifacts appear in the transposed-conv version.
  3. (Hard) Take a real segmentation dataset (Oxford-IIIT Pets, Cityscapes mini split, or a medical subset) and train the U-Net to within 2 IoU points of the smp.Unet reference. Report per-class IoU and identify which classes benefit most from adding Dice to the loss.

Key Terms

TermWhat people sayWhat it actually means
Semantic segmentation"Label every pixel"Per-pixel classification into C classes; instances of the same class merge
Instance segmentation"Label every object"Separates distinct instances of the same class; foreground-only
Panoptic segmentation"Semantic + instance"Every pixel gets a class; every thing instance also gets a unique id
Skip connection"U-Net bridge"Concatenation of encoder features into matching-resolution decoder features; preserves high-frequency detail
Transposed conv"Deconvolution"Learnable upsampling; can produce checkerboard artifacts
Dice loss"Overlap loss"1 - 2
mIoU"Mean intersection over union"Average IoU across classes; the community-standard metric for segmentation
Boundary F1"Boundary accuracy"F1 score computed on boundary pixels only; matters for precision-critical tasks

Further Reading