Segmentation in Depth

Mask R-CNN

Mask R-CNN finds a box around each object first, then draws a small mask inside that box, and one careful fix to the cropping step is what made it work.

On this page 8
  1. The short answer
  2. The analogy
  3. Why it exists
  4. How it works
  5. The fix that made it work
  6. Where you have already seen this
  7. Remember this
  8. What to learn next

One lesson, three depths. Pick the one that fits you today — you can switch any time.

Beginner — No maths. Plain English.

The short answer

Mask R-CNN finds a rough box around each object, then draws a careful outline inside that box.

The analogy

You are cutting a friend out of a printed photo for a scrapbook. Nobody starts by following the outline of their hair.

You cut a rough rectangle around them first. Then you pick up the rectangle, hold it close, and trim carefully around the shoulders and the hair.

Two steps, and the second is only possible because the first one narrowed the job. Mask R-CNN works in exactly that order.

Finding boxes is a solved problem. So it reuses a box finder and adds a small outlining step.

Why it exists

Semantic segmentation shades every pixel by category but cannot count, as covered in semantic, instance and panoptic segmentation. Two people standing shoulder to shoulder become one green blob.

The insight was that counting is what box detectors already do well. A detector returns a separate box per object, even for people who are touching.

So attach a small outlining step to a working detector. Each box gets its own outline, so touching objects stay separate for free.

How it works

   photo
     |
   [backbone]            look at the whole photo once
     |
   [region proposals]    "something interesting is probably here"
     |                    a few hundred rough boxes
     |
   [crop each box out of the feature maps]
     |
     +--> [class head]   what is it?           -> "a person, and sure of it"
     +--> [box head]     tighten the rectangle -> better corners
     +--> [mask head]    draw the outline      -> a small square mask
                                                  stretched to fit the box

The mask is drawn in a small fixed square, no matter how big the object is on screen. It gets stretched to the size of the box afterwards.

That has a consequence worth knowing early. The outline can never spill outside its box. If the box misses a leg, the mask misses it too.

The fix that made it work

The cropping step used to round every measurement to the nearest whole grid square. It sounds harmless.

Think of cutting your scrapbook rectangle with a ruler marked only in whole centimetres. Your friend's edge falls between two marks, so you cut at the nearer mark. The rectangle is fine — that is a small slip on a big cut.

Now trim the fine outline with the same blunt ruler. Every hair edge lands half a centimetre out, and the cut-out looks wrong.

Mask R-CNN replaced the rounding with careful in-between measurement. Boxes barely improved. Masks improved a lot, and that single change is the paper's main idea.

Where you have already seen this

  • Photo editors where you tap a person and the app selects them alone.
  • Shop shelf audits where a camera counts each packet as a separate item.
  • Sports analysis that tracks each player separately through a match.
  • Crop tools that let you pick one object out of a busy scene.

Remember this

  • Box first, outline second. The outline is drawn inside the box.
  • Touching objects stay separate because each one owns a box.
  • Removing the rounding from the cropping step is what made the outlines sharp.

What to learn next

  • Panoptic segmentation — merging these overlapping masks into one clean map.
  • Object detection — the box stage this whole design rests on.
  • YOLO — the single-stage tradition, and where its segmentation variants come from.

Developer — Code and libraries.

Setup

bash
pip install torch torchvision

Everything below runs on CPU and downloads nothing.

The rounding bug, made visible

Start with the part that matters. roi_pool rounds box coordinates onto the feature grid; roi_align samples at exact positions with bilinear interpolation.

The test is to move a box by half a pixel and see whether the output notices.

roialign.py
import torch, torch.nn.functional as F
from torchvision.ops import roi_align, roi_pool

# a feature map whose values encode their own column, so misalignment is visible
feat = torch.arange(64, dtype=torch.float32).reshape(1, 1, 8, 8) % 8
print("feature map (value = column index):")
print(feat[0, 0].numpy())

box   = torch.tensor([[0., 1.5, 1.5, 5.5, 5.5]])     # [batch_idx, x1, y1, x2, y2]
shift = torch.tensor([[0., 2.0, 1.5, 6.0, 5.5]])     # same box, moved half a pixel right

print("\nRoIPool, box x1=1.5 :\n", roi_pool(feat, box, output_size=2, spatial_scale=1.0)[0, 0].numpy())
print("RoIPool, box x1=2.0 :\n", roi_pool(feat, shift, output_size=2, spatial_scale=1.0)[0, 0].numpy())
print("-> identical. RoIPool rounded 1.5 and 2.0 onto the same grid cell.")

print("\nRoIAlign, box x1=1.5 :\n", roi_align(feat, box, output_size=2, spatial_scale=1.0, sampling_ratio=2)[0, 0].numpy())
print("RoIAlign, box x1=2.0 :\n", roi_align(feat, shift, output_size=2, spatial_scale=1.0, sampling_ratio=2)[0, 0].numpy())
print("-> the half-pixel shift moved the numbers. No rounding happened.")

# ---- pasting a 28x28 mask back into the image ----
m = torch.zeros(1, 1, 28, 28); m[0, 0, 6:22, 6:22] = 3.0        # logits from the mask head
x1, y1, x2, y2 = 10.4, 20.7, 46.9, 60.2
w, h = round(x2 - x1), round(y2 - y1)
resized = F.interpolate(m, size=(h, w), mode="bilinear", align_corners=False)
canvas = torch.zeros(1, 1, 80, 80)
canvas[0, 0, round(y1):round(y1) + h, round(x1):round(x1) + w] = (resized[0, 0] > 0).float()
print(f"\nmask head output {tuple(m.shape)} -> stretched to the box {(h, w)} -> pasted into {tuple(canvas.shape[-2:])}")
print("pixels on:", int(canvas.sum()), " rows used:", (canvas[0,0].sum(1) > 0).nonzero().flatten()[[0, -1]].tolist())
Output
feature map (value = column index):
[[0. 1. 2. 3. 4. 5. 6. 7.]
 [0. 1. 2. 3. 4. 5. 6. 7.]
 [0. 1. 2. 3. 4. 5. 6. 7.]
 [0. 1. 2. 3. 4. 5. 6. 7.]
 [0. 1. 2. 3. 4. 5. 6. 7.]
 [0. 1. 2. 3. 4. 5. 6. 7.]
 [0. 1. 2. 3. 4. 5. 6. 7.]
 [0. 1. 2. 3. 4. 5. 6. 7.]]

RoIPool, box x1=1.5 :
 [[4. 6.]
 [4. 6.]]
RoIPool, box x1=2.0 :
 [[4. 6.]
 [4. 6.]]
-> identical. RoIPool rounded 1.5 and 2.0 onto the same grid cell.

RoIAlign, box x1=1.5 :
 [[2.5 4.5]
 [2.5 4.5]]
RoIAlign, box x1=2.0 :
 [[3. 5.]
 [3. 5.]]
-> the half-pixel shift moved the numbers. No rounding happened.

mask head output (1, 1, 28, 28) -> stretched to the box (40, 36) -> pasted into (80, 80)
pixels on: 528  rows used: [29, 52]

Reading that carefully

Two different boxes, one identical RoIPool answer. The half-pixel difference was rounded away before any pooling happened. Every box whose corner falls anywhere inside a grid cell produces the same crop.

At output stride 16, one feature-grid cell is 16 image pixels. So RoIPool cannot distinguish boxes that differ by up to 16 pixels in the original photo. A box head can absorb that; a 28x28 mask cannot.

RoIAlign moved by exactly the amount the box moved. 2.5 → 3.0 and 4.5 → 5.0. Because the feature values encode their own column index, those numbers are directly readable as sampling positions. The half-pixel shift produced a half-unit shift.

sampling_ratio=2 means four sample points per output cell, bilinearly interpolated and averaged. Setting sampling_ratio=-1 makes the count adaptive to box size, which is the torchvision default inside Mask R-CNN.

The paste-back block shows the resolution cost. A 28x28 mask stretched to a 40x36 box means every mask pixel covers more than one image pixel. Boundaries are quantised to that grid, which is why Mask R-CNN masks look slightly blocky on large objects. PointRend and Mask R-CNN's later variants exist to fix precisely this.

The full model, without downloading weights

maskrcnn_shapes.py
import torch
from torchvision.models.detection import maskrcnn_resnet50_fpn_v2

torch.manual_seed(0)
model = maskrcnn_resnet50_fpn_v2(weights=None, weights_backbone=None, num_classes=3,
                                 min_size=128, max_size=128)
print("parameters:", f"{sum(p.numel() for p in model.parameters()):,}")

img = torch.rand(3, 128, 128)
mask = torch.zeros(1, 128, 128); mask[0, 30:80, 40:90] = 1
target = {"boxes": torch.tensor([[40., 30., 90., 80.]]),
          "labels": torch.tensor([1]),
          "masks": mask.to(torch.uint8)}

model.train()
losses = model([img], [target])
print("\nthe five losses Mask R-CNN trains on:")
for k, v in losses.items():
    print(f"   {k:20s} {v.item():.4f}")

model.eval()
with torch.no_grad():
    out = model([img])[0]
print("\nprediction dictionary from one image:")
for k, v in out.items():
    print(f"   {k:8s} {tuple(v.shape)}  dtype {v.dtype}")
Output
parameters: 45,885,793

the five losses Mask R-CNN trains on:
   loss_classifier      1.0276
   loss_box_reg         0.0673
   loss_mask            1.5885
   loss_objectness      0.6989
   loss_rpn_box_reg     0.0075

prediction dictionary from one image:
   boxes    (100, 4)  dtype torch.float32
   labels   (100,)  dtype torch.int64
   scores   (100,)  dtype torch.float32
   masks    (100, 1, 128, 128)  dtype torch.float32

These weights are randomly initialised, so the boxes and scores carry no meaning. The loss values come from that random initialisation with manual_seed(0), and they will shift with a different torch version. What is stable and worth reading is the structure.

Five losses, trained together. Two belong to the region proposal network: loss_objectness (is there anything here?) and loss_rpn_box_reg (where exactly?). Three belong to the heads: class, box refinement and mask. A single backward pass updates all of them.

masks comes back at full image size, already pasted. torchvision does the 28x28-to-box stretch and the paste for you. Values are soft probabilities, so you threshold at 0.5 yourself.

One hundred detections from an untrained model is box_detections_per_img=100, the default cap, combined with a low score threshold. On a trained model you filter by scores > 0.5 or higher before doing anything else.

Targets need boxes, labels and masks together. masks must be uint8 with shape (N, H, W), one per instance, in the same order as boxes. Mismatched order is a silent, brutal bug.

Common mistakes

Class 0 used for a real class. In torchvision detection models, 0 is reserved for background. num_classes=3 means background plus two real classes. Label your data from 1.

Passing an empty target. An image with no objects needs boxes shaped (0, 4) and masks shaped (0, H, W) — not None. Getting the dtype and shape right on empty tensors is a common training crash.

Feeding normalised tensors. These models normalise internally using their own transform. Pass images as float tensors in the range 0 to 1 and let the model do the rest.

Trusting the mask outside its box. The mask head cannot draw beyond its box, so a box that clips an arm produces a mask that clips the arm. When masks look truncated, inspect the boxes first.

Comparing mask AP against mIoU. They measure different things. See semantic, instance and panoptic segmentation for why the numbers do not move together.

Try it yourself

Change output_size=2 to output_size=4 in the RoIAlign script and re-run. The resolution of the crop rises, and the half-pixel shift becomes visible in more cells. Then set sampling_ratio=1 and watch the interpolated values change, since one sample per cell is a coarser estimate of the same region.

What to learn next

  • Panoptic segmentation — merging these overlapping masks into one clean map.
  • Object detection — the box stage this whole design rests on.
  • YOLO — the single-stage tradition, and where its segmentation variants come from.

Researcher — Mathematics and papers.

The formulation

He, Gkioxari, Dollár, Girshick (2017), Mask R-CNN, ICCV. Faster R-CNN with a third head. The multi-task loss on each sampled RoI is

$$ L = L_{\text{cls}} + L_{\text{box}} + L_{\text{mask}} $$

The mask head outputs $K$ binary masks of resolution $m \times m$, one per class. $L_{\text{mask}}$ is the average binary cross-entropy over the $m^2$ pixels, for the ground-truth class $k$ only. Masks for other classes contribute nothing.

That decoupling is a design decision with consequences. Because each class has its own mask channel and the class is decided elsewhere, the mask head never competes across classes. Contrast this with per-pixel softmax segmentation, where classes compete at every pixel. The paper reports this decoupling as a substantial gain over a softmax mask head.

RoIAlign

RoIPool quantises twice: the RoI's continuous coordinates onto the feature grid, and the RoI's subdivision into bins. RoIAlign removes both. For each of the $m \times m$ bins it samples a fixed number of points at exact locations. Each is computed by bilinear interpolation over the four nearest feature cells, then aggregated by max or average.

The reported effect is asymmetric and instructive: RoIAlign improved mask AP by roughly 10 points under stride-32 features, while box AP moved far less. Localisation error that a box regressor absorbs is fatal for a $28\times28$ mask that must be stretched by a large factor.

The paper reports the system running at about 5 fps, which was the honest cost of a two-stage design in 2017.

Structural limits

Mask resolution. $m = 28$ for the FPN variant. A 400-pixel-tall person is represented by 28 mask rows, so the boundary is quantised at roughly 14 pixels before upsampling. PointRend (Kirillov et al., 2020) treats this as an adaptive subdivision problem. It computes mask predictions at selectively sampled high-resolution points, giving markedly crisper boundaries at similar cost.

Box dependence. The mask is defined inside the box. Recall for the mask is upper-bounded by recall for the box. Heavily occluded and articulated objects therefore fail as masks before they fail as boxes.

Overlap. Masks from different RoIs are independent, so they may overlap or leave gaps. Any panoptic output built on Mask R-CNN needs an explicit merge step, described in panoptic segmentation.

NMS on boxes, not masks. Two objects with near-identical boxes but distinct masks — a person behind a chair — can suppress each other before the mask head ever runs.

The single-stage alternatives

MethodYearMechanism
YOLACT2019Predict $k$ prototype masks per image; each detection outputs coefficients; mask is a linear combination
SOLO / SOLOv22020Predict a mask directly per grid cell by object location and size; no boxes, no RoI crop
CondInst2020Per-instance dynamic convolution filters generated by the detection head, applied to a full-resolution feature map
BlendMask2020Attention-weighted blending of global and per-RoI features

CondInst and SOLOv2 are worth understanding as the conceptual bridge to mask classification. Both drop the fixed $m \times m$ RoI crop and generate instance-specific parameters applied to a full-resolution mask feature map. Mask quality stops being capped by a crop resolution. Mask2Former takes the same step and generalises it.

Practical notes for training

  • Freeze trainable_backbone_layers to 3 or fewer for small datasets; the default of 3 in torchvision is a sensible starting point.
  • Mask annotations are usually polygons and are rasterised to the RoI grid. Polygon-to-mask rasterisation at $28\times28$ discards fine structure before training even begins, so a model can be blamed for annotation quantisation.
  • maskrcnn_resnet50_fpn_v2 (Li et al., 2021, Benchmarking Detection Transfer Learning) uses an improved training recipe and a heavier mask head. It is the better default in torchvision for new work.

Papers

What to learn next

  • Panoptic segmentation — merging these overlapping masks into one clean map.
  • Object detection — the box stage this whole design rests on.
  • YOLO — the single-stage tradition, and where its segmentation variants come from.