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.
- 13 min read
- 3 reading levels
- Updated
Read these first
On this page 8
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 boxThe 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
pip install torch torchvisionEverything 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.
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())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
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}")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
| Method | Year | Mechanism |
|---|---|---|
| YOLACT | 2019 | Predict $k$ prototype masks per image; each detection outputs coefficients; mask is a linear combination |
| SOLO / SOLOv2 | 2020 | Predict a mask directly per grid cell by object location and size; no boxes, no RoI crop |
| CondInst | 2020 | Per-instance dynamic convolution filters generated by the detection head, applied to a full-resolution feature map |
| BlendMask | 2020 | Attention-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_layersto 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
- He, Gkioxari, Dollár, Girshick, Mask R-CNN, ICCV 2017 — arxiv.org/abs/1703.06870
- Lin et al., Feature Pyramid Networks for Object Detection, CVPR 2017 — arxiv.org/abs/1612.03144
- Bolya et al., YOLACT: Real-time Instance Segmentation, ICCV 2019 — arxiv.org/abs/1904.02689
- Wang et al., SOLOv2: Dynamic and Fast Instance Segmentation, NeurIPS 2020 — arxiv.org/abs/2003.10152
- Kirillov, Wu, He, Girshick, PointRend: Image Segmentation as Rendering, CVPR 2020 — arxiv.org/abs/1912.08193
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.