A box says where. A mask says exactly which pixels.
Instance segmentation is detection plus a mask branch: propose regions with a Faster R-CNN, sample each proposal’s features at exact float coordinates with RoIAlign, then let a tiny fully convolutional head paint a 28×28 answer per class. Two dogs are two masks, not one class blob. This lesson is the whole machine — the pyramid, the 159,882 anchors, the bilinear arithmetic that makes RoIAlign work, and the 40 lines that fine-tune it on your own data.
A Faster R-CNN is already two stages: the RPN proposes class-agnostic boxes, the heads decide what they are and move them. Mask R-CNN keeps both stages untouched and adds a third output — a mask — beside the class and the box refinement. Nothing about the detector is rewritten.
159,882 anchors at 800×800 → NMS 0.7 → ~1,000 proposals at inference (2,000 in training) · 512 sampled per image02 / RoIALIGN
Sample the exact coordinates. Never round.
A proposal's corners are floats; feature maps live on integer cells. RoIPool rounded twice — box corners, then bin boundaries — and paid for it in localisation. RoIAlign subdivides the float box and bilinearly interpolates the exact sample positions, 2×2 per bin, so the crop belongs to the box the detector actually predicted.
7×7 × 2×2 = 196 samples for the box head · 14×14 × 2×2 = 784 for the mask head · no rounding anywhere03 / THE MASK BRANCH
One 28×28 channel per class per object.
After RoIAlign gives the mask head a 14×14 crop, four 3×3 convolutions, a 2× deconvolution and a 1×1 convolution produce 28×28 per class. At inference only the predicted class's channel is kept, upsampled to the RoI's pixel size and thresholded at 0.5. Decoupling shape from classification is the whole trick.
91 × 28 × 28 = 71,344 numbers per RoI · keep 784 · ≈2.6M params · box 47.4 vs mask 41.8 mAP
MENTAL MODEL IN ONE SENTENCE
Mask R-CNN is detection that draws: reuse Faster R-CNN to find and align each proposal, hand the aligned crop to a tiny fully convolutional branch that answers with a 28×28 mask per class, and keep only the class you already decided on.
By the end you will be able to draw the whole graph with tensor shapes — backbone, FPN, RPN, RoIAlign, box head, mask head; compute a bilinear sample by hand and explain why RoIPool’s rounding costs a twelfth of a mask’s accuracy; read torchvision’s output dict and know that labels start at 1 and masks are full-resolution probabilities; swap the two predictor heads, freeze the 26.9M-parameter trunk and train on a few hundred images; and choose between the 44.4M v1 weights, the 46.4M v2 weights and a 19.4M MobileNet detector on evidence instead of vibes.
01
ONE MASK PER OBJECT
Semantic says “dog”. Instance says dog #1 and dog #2.
Lesson 07 labelled every pixel with a class. That answer cannot count: two touching dogs of the same class become one dog-shaped region. The task this lesson builds keeps one mask per object — and it gets there by borrowing the whole detection machinery from Lesson 06.
The difference is not a detail of implementation; it is a different question. Semantic segmentation answers “what kind of thing is this pixel?” and merges instances of the same class — which is exactly right for road, sky, tumour, water, and useless for count-the-apples. Instance segmentation answers “which object is this pixel part of?”, keeps a separate mask and id per object, and only covers countable things: the grass, the sky and the road have no instances, so an instance model leaves them unlabelled. Panoptic segmentation is the union: class labels for stuff, ids for things, nothing left unlabelled.
Counting is the operational test. Cell biology, microscope fields, produce sorting, traffic counts, inventory audits, construction take-offs, wildlife camera traps — every one of them is a question of the form “how many, and where exactly is each one?” A detector answers “how many” with boxes; the masks add the exact silhouette, which is what you need when two objects touch or when the thing you measure is a shape, not a rectangle (the bounding box of each brick in a wall, each cell in a smear).
The same scene under the three contracts. Semantic merges the two dogs into a single “dog” region — the count is unrecoverable. Instance separates them and ignores the grass. Panoptic does both. Mask R-CNN is the instance column; the target tensor is (H, W, N_instances) of binary masks plus a class and a score per instance, which is the detection contract with an extra mask per row.
Mask R-CNN’s founding move was to refuse to build a new architecture. Faster R-CNN already finds objects: a region proposal network guesses where things are, and heads classify and refine each guess. Instance segmentation is that, plus answering “which pixels inside this box belong to it?” — so Mask R-CNN keeps the detector intact and adds one small branch whose only job is to paint a mask inside the box. The paper’s own framing is that clean, and it is why for the next five years almost every instance segmentation paper was a Mask R-CNN variant.
The hard part is not the mask branch — it is how you hand a box to a branch. A proposal is four floats; a feature map is a grid of integers. The crop that bridges them was, before 2017, computed with rounding, and that rounding quietly destroyed a large part of the localisation accuracy a mask needs. The fix is RoIAlign, and it is worth more than a chapter — it is worth understanding at the level of one bilinear sample, which is where this lesson goes next.
02
THE TWO-STAGE MACHINE
Stage one finds. Stage two decides. The mask is a third answer, not a third network.
Mask R-CNN is a Faster R-CNN with one extra output. Everything in this chapter already existed in Lesson 06 except RoIAlign and the mask branch — which is precisely why the architecture is worth reading as a graph with shapes on every edge.
Five pieces, in order:
Backbone — ResNet-50 with its classifier removed. 23.5M conv and batch-norm parameters, feature maps at strides 4, 8, 16 and 32. It is the same kind of trunk Lesson 03 built and Lesson 05 fine-tuned, now feeding a detector instead of a single label.
FPN — the Feature Pyramid Network turns that single hierarchy into five outputs, P2 to P6, each with 256 channels, at strides 4 to 64. 3.3M parameters. Every level is a good feature map for one range of object sizes.
RPN — a 1.18M-parameter head (two 3×3 convolutions, then 1×1 objectness and delta convolutions) that, at every location of every level, scores three anchor shapes and predicts a delta box. One forward pass covers 159,882 anchors on an 800×800 image; the top 1,000 at inference (2,000 in training) go through NMS at IoU 0.7 and about 1,000 class-agnostic proposals reach the heads. “Class-agnostic” is the important word: at this point the network knows something is at a location, not what.
RoIAlign — the bridge from boxes to grids. For any proposal, on the FPN level that matches its size, it samples a fixed 7×7 (box head) or 14×14 (mask head) grid of 256-channel features at exact float coordinates, bilinearly. Chapter 04 is entirely about this step.
Heads — a box head (15.2M parameters: four 3×3 convolutions with batch norm and one 1,024-wide fully connected layer) that produces 91 class logits and 364 box deltas per proposal, and a mask head that produces 28×28 per class. Two predictions from the same aligned crop, trained with different losses.
The two stages are not two networks: they are one network whose output depends on model.training. In training mode the forward pass returns a dict of five losses; in evaluation mode it returns a list of per-image dicts with boxes, labels, scores and masks. Same weights, different outputs — which is why the training loop is only five lines.
The whole graph in one strip. Stage 1 (propose) is class-agnostic and cheap per anchor; stage 2 (classify, refine, paint) runs only on the ~1,000 surviving proposals, so the expensive 1024-dimensional heads and the mask branch never touch the full image. That asymmetry is the design: pay for pixels once in the trunk, pay for objects only where objects might be.
The Mask R-CNN architecture explorer
Click any block — or use the buttons — to read its tensor shape, operation, parameter count and job. The selected block’s edges print the tensor that crosses each one, so you can trace any value from the input pixels to the masks.
pick a block
block roialign · RoIAlign
operation bilinear sampling, 2×2 samples per bin
output 7×7×256 (box) · 14×14×256 (mask) per RoI
params no trainable parameters
what it does
Each float-coordinate proposal crops its level's feature map into a fixed grid: 196 samples for the box head, 784 for the mask head, none of them rounded.
incoming edges
from proposals 1,000 × (4 + 1 objectness)
from fpn 5 levels × 256 ch
outgoing edges
to box-head 1,000 × 256 × 7 × 7
to mask-head 1,000 × 256 × 14 × 14
FPN ladder (800×800 input)
P2 stride 4 200×200×256 from layer1 / C2 · small objects
P3 stride 8 100×100×256 from layer2 / C3
P4 stride 16 50×50×256 from layer3 / C4 · the 224px level
P5 stride 32 25×25×256 from layer4 / C5 · large objects
P6 stride 64 13×13×256 max-pool of P5 · RPN anchors only
parameter budget with 5-class predictors
ResNet-50 trunk (conv + BN) 23.5M frozen
FPN laterals + output convs 3.3M frozen
RPN head (2 convs + objectness + deltas) 1.2M
Box head (4 convs + 1 FC) 15.2M
Box predictor (5 classes) 26k
Mask head (4 convs) 2.4M
Mask predictor (5 classes) 264k
TOTAL 45.9M · frozen 26.9M · trainable 19.0M (41.5%)
(with 91-class predictors these rows sum to 46,359,409 — exactly torchvision's published count for the COCO weights; the 5-class swap above lands at 45.9M)
The two numbers worth memorising from this graph: a proposal becomes 196 bilinear samples for the box head (7×7 bins × 2×2) and 784 for the mask head (14×14 × 2×2) — and the whole mask branch is ~2.6M parameters, ~6% of the model.
One forward pass, two contractspython
import torch
from torchvision.models.detection import (
maskrcnn_resnet50_fpn_v2, MaskRCNN_ResNet50_FPN_V2_Weights,
)
model = maskrcnn_resnet50_fpn_v2(weights=MaskRCNN_ResNet50_FPN_V2_Weights.DEFAULT)
# ---- evaluation mode: the model returns predictions, not losses
model.eval()
with torch.no_grad():
predictions = model([torch.randn(3, 800, 800)])
p = predictions[0]
print({k: tuple(v.shape) for k, v in p.items()})
# {'boxes': (N, 4), 'labels': (N,), 'scores': (N,), 'masks': (N, 1, 800, 800)}# ---- training mode: the same weights return five losses
model.train()
targets = [{
"boxes": torch.tensor([[60.0, 80.0, 260.0, 300.0]]), # (M, 4) x1 y1 x2 y2"labels": torch.tensor([1]), # 1-based; 0 is background"masks": torch.zeros((1, 300, 400), dtype=torch.uint8), # (M, H, W) binary
}]
loss_dict = model([torch.rand(3, 300, 400)], targets)
print({k: round(v.item(), 3) for k, v in loss_dict.items()})
# {'loss_classifier': ..., 'loss_box_reg': ...,# 'loss_mask': ..., 'loss_objectness': ..., 'loss_rpn_box_reg': ...}
The targets dict is the whole dataset contract: boxes, labels and a binary mask per instance. The model decides what to return from model.training — there is no separate training graph to build.
The five losses, from the source’s L = L_rpn_cls + L_rpn_box + L_box_cls + L_box_reg + L_mask. torchvision sums them with equal weight out of the box: the constructors expose thresholds, not per-loss weights, so re-weighting a term means editing the loss dict yourself.
Loss
Applies to
What it teaches
Shape of the target
L_rpn_cls
every RPN anchor
objectness: is there anything in this anchor, or not?
binary, per anchor (159,882 of them)
L_rpn_box
anchors assigned to an object
how to move the anchor onto the object
4 smooth-L1 deltas per positive anchor
L_box_cls
the 512 sampled RoIs
which of the 91 classes (background included) this proposal is
one class id per RoI
L_box_reg
positive RoIs only
the final tightening of the box
4 deltas per RoI per class
L_mask
positive RoIs, predicted class channel only
the silhouette: per-pixel binary cross-entropy at 28×28
one 28×28 map per RoI
Quick check
In the Mask R-CNN graph, which component is class-agnostic — it says 'something is here' without saying what?
03
FIVE LEVELS OF PROPOSALS
One object size per level. 159,882 anchors, one forward pass.
Before any mask exists, something has to say where the objects are. The FPN gives the detector a feature map per size range; the RPN scores anchors on all of them at once and hands a thousand boxes forward. This is Lesson 06’s machinery, upgraded from one grid to five.
A single feature map forces a single scale. At stride 32 — the last ResNet stage — a 32×32 object is one cell: the classification signal survives, the spatial signal does not, and the box can only be refined to within 32 pixels. A stride-4 map has 8×8 cells for the same object but far weaker semantics. The FPN refuses to choose. It keeps every level and gives them all the same 256 channels:
The five levels at an 800×800 input. 160k anchors per image, three aspect ratios (0.5, 1, 2) per location.
Level
Stride
Grid
Channels
Anchor locations
Built from
P2
4
200×200
256
120k
from layer1 / C2 · small objects
P3
8
100×100
256
30k
from layer2 / C3
P4
16
50×50
256
7.5k
from layer3 / C4 · the 224px level
P5
32
25×25
256
1.9k
from layer4 / C5 · large objects
P6
64
13×13
256
507
max-pool of P5 · RPN anchors only
Each level is built from its ResNet stage with a 1×1 lateral convolution that squeezes the channels to 256, added to the top-down pathway from the coarser level above, then smoothed with a 3×3 convolution. The 1×1 laterals cost 0.98M parameters and the 3×3 outputs 2.36M, so the entire pyramid is 3.3M against the backbone’s 23.5M. P6 is not a stage at all: it is a max-pool of P5, giving the RPN a stride-64 level for huge objects at zero parameters.
With five maps, a proposal has to be routed to one of them. The paper’s rule is a logarithm of size:
k = floor( k0 + log2( sqrt(w·h) / 224 ) ) k0 = 4
64×64 sqrt = 64 ratio 0.286 log2 = −1.807 k = floor(2.19) = 2 → P2
224×224 sqrt = 224 ratio 1.000 log2 = 0.000 k = floor(4.00) = 4 → P4
512×512 sqrt = 512 ratio 2.286 log2 = 1.193 k = floor(5.19) = 5 → P5
In words: a 224×224 object is the reference size and lives on P4; every doubling of object size moves one level coarser, every halving one level finer. torchvision implements this same routing rule inside MultiScaleRoIAlign: despite the name, each RoI is still assigned to one level — a LevelMapper applies the equation above (with the level index clamped to the levels the heads read) — and the RoI is pooled only on that level’s feature map. There is no all-levels sampling or channel-wise max merge. Either implementation gives the head a feature map whose receptive field fits the object.
The pyramid and the routing rulepython
import math
import torch
from torchvision.ops import MultiScaleRoIAlign
# The paper's RoI -> level assignment (k0 = 4, clamped to the levels the heads read).def level_for_roi(w, h, k0=4, min_level=2, max_level=5):
k = math.floor(k0 + math.log2(math.sqrt(w * h) / 224))
return max(min_level, min(max_level, k))
print(level_for_roi(64, 64)) # 2 -> P2, stride 4: the object is 16 cells wide
print(level_for_roi(224, 224)) # 4 -> P4, stride 16: 14 cells
print(level_for_roi(512, 512)) # 5 -> P5, stride 32: 16 cells# torchvision's actual head: MultiScaleRoIAlign routes each RoI to ONE level# (the LevelMapper applies the equation above, clamped to the levels present)# and pools only that level — no all-levels sampling or channel-wise merge.
box_roi_pool = MultiScaleRoIAlign(
featmap_names=["0", "1", "2", "3"], # P2, P3, P4, P5
output_size=7, # 7x7 grid for the box head
sampling_ratio=2, # 2x2 bilinear samples per bin
)
# a proposal's coordinates are scaled by 1/stride of its assigned level inside# the op; the result is (num_rois, 256, 7, 7) -> 12,544 features per proposal# for the MLP.
The paper routes each RoI to one level; torchvision's MultiScaleRoIAlign uses the same rule through its LevelMapper, pooling only the assigned level. Both give the head a size-appropriate feature map — worth knowing when you read the paper and the code side by side.
The RPN then reduces 160k anchors to a thousand boxes in three moves: sort by objectness and keep the top 1,000 at inference (2,000 in training), run NMS at IoU 0.7 to delete duplicates, keep the survivors. Training samples 512 of those per image — 128 positive (IoU ≥ 0.5 with some ground-truth box) and 384 negative — because classifying every proposal with the 15.2M-parameter box head each step would be wasteful and badly imbalanced. That funnel is the quiet hero of the design: it is what makes stage two affordable.
The paper’s routing rule, worked. Class sizes are the square root of the box area, which is why a wide box and a tall box of the same area land on the same level.
Object
√(w·h)
log₂(√/224)
k
Level
Cells across on that level
64×64 · a small object
64
-1.807
2
P2
16.0
224×224 · the canonical size
224
0.000
4
P4
14.0
512×512 · a large object
512
1.193
5
P5
16.0
The proposal stepper: two NMS passes, two thresholds
Step through one image’s proposals from raw anchors to final masks. The RPN’s NMS runs class-agnostically and loosely (0.7); the final per-class NMS runs strictly (0.5). Move either threshold and watch which boxes live or die.
step 1 raw anchors
eight RPN anchors with their objectness, spread over the two objects and two background corners
anchors → 8
rpn NMS 0.70 → 6 proposals (2 suppressed)
class gate 0.50 → 3 candidates (3 dropped: background or low score)
final NMS 0.50 → 2 objects
RPN NMS trace
pick 1 A1 obj 0.95 (top score left)
· C1 IoU 0.000 ≤ 0.70
✕ A2 IoU 0.765 > 0.70 suppressed
· A3 IoU 0.619 ≤ 0.70
· C2 IoU 0.058 ≤ 0.70
· C3 IoU 0.000 ≤ 0.70
· B1 IoU 0.070 ≤ 0.70
· B2 IoU 0.000 ≤ 0.70
pick 2 C1 obj 0.91 (top score left)
· A3 IoU 0.116 ≤ 0.70
· C2 IoU 0.546 ≤ 0.70
✕ C3 IoU 0.724 > 0.70 suppressed
· B1 IoU 0.000 ≤ 0.70
· B2 IoU 0.017 ≤ 0.70
pick 3 A3 obj 0.71 (top score left)
· C2 IoU 0.196 ≤ 0.70
· B1 IoU 0.047 ≤ 0.70
· B2 IoU 0.000 ≤ 0.70
pick 4 C2 obj 0.62 (top score left)
· B1 IoU 0.000 ≤ 0.70
· B2 IoU 0.021 ≤ 0.70
pick 5 B1 obj 0.55 (top score left)
· B2 IoU 0.000 ≤ 0.70
pick 6 B2 obj 0.41 (top score left)
class decisions
A1 dog 0.94 kept
C1 cat 0.92 kept
A3 dog 0.42 dropped
C2 cat 0.79 kept
B1 background 0.86 dropped
B2 background 0.91 dropped
final per-class NMS trace
pick A1 dog 0.94
· C1 IoU 0.000 ≤ 0.50
· C2 IoU 0.058 ≤ 0.50
pick C1 cat 0.92
✕ C2 cat IoU 0.546 > 0.50 suppressed
output
dog 0.94 box (54, 73) → (275, 320) mask IoU 0.88 mask 28×28
cat 0.92 box (288, 111) → (442, 317) mask IoU 0.84 mask 28×28
threshold experiments (move the sliders and watch the tables above change)
RPN 0.50 A3 (IoU 0.619 with A1) and C2 (0.546 with C1) die before the heads: 2 candidates, same 2 outputs — a stricter first pass costs recall insurance, not accuracy here
RPN 0.80 A2 (0.765) and C3 (0.724) survive to the heads as a fourth and fifth candidate; the final NMS still removes them, so the outputs do not change — the loose first pass buys recall insurance at the price of head compute
final 0.55 C2 now survives next to C1 (IoU 0.546): three masks, and the cat is reported twice — the 0.50 default has only 0.046 of margin on this box pair
gate 0.42 A3 (dog 0.42) passes the gate and joins the candidates; the final NMS removes it anyway (IoU 0.619 with A1), which is why a low score gate is survivable but not free
This is Lesson 06’s NMS doing two different jobs. The RPN only needs to stop reporting the same region twice, so it can be loose (0.7) — a wrong merge there costs a proposal. The final pass decides what you report, so it is strict (0.5) and per class, because two different classes may legitimately overlap (a person on a bike).
04
RoIALIGN FROM SCRATCH
The box is a float. The feature map is not. Sample it anyway.
This is the one component of Mask R-CNN that is simpler as code than as prose — and the one the paper was actually about. Get the arithmetic of a single bilinear sample right and the rest of the architecture is bookkeeping.
A proposal is four floats: the RPN predicts a box like (34.7, 51.3, 98.2, 142.9) in input pixels. A feature map is a grid of integer cells, and the mapping from pixels to cells is a division by the level’s stride: at stride 16 the same box is (2.169, 3.206, 6.138, 8.931) in feature coordinates, with a half-pixel convention (subtract 0.5) because a feature cell represents the pixel region around its centre. Now crop that region into a 7×7 grid. The crop boundaries do not land on cell boundaries, and they never will.
The pre-2017 answer was to round. RoIPool rounded the box corners to integers, then rounded each bin boundary, then took the max feature value inside each integer cell. Rounding the playground’s default box — 4.8 cells on a side — moves its left edge 0.4 cells to the right and its right edge 0.4 cells to the left: 12.8 input pixels of shrink per side at stride 32, and the bin boundaries round again inside the box, so the error compounds in two places. On a classification feature map that is noise. On a mask, where the head is asked to output a silhouette aligned with the proposal, it is the difference between a mask that fits the object and one that is uniformly offset — and the original paper measured it: RoIAlign was worth several points of mask AP on COCO for the same network.
RoIAlign keeps every coordinate exact. Subdivide the float box into bins, place samples inside each bin, read the feature value at each exact position with bilinear interpolation — using the four neighbouring cells and the fractional distances to them — and average. torchvision’s detector uses 2×2 samples per bin (the constructor argument is sampling_ratio=2): 7×7 bins × 4 samples = 196 samples for the box head, 14×14 × 4 = 784 for the mask head, per proposal, all at exact coordinates and all differentiable.
One sample, all the arithmetic. The point (2.2, 1.7) sits inside cell (1, 1) — floor(2.2) = 1, floor(1.7) = 1 — at fractions dx = 0.20 and dy = 0.70. Bilinear interpolation is the weighted sum of the four cell centres around it, with weights that sum to 1. Nothing is rounded, and the result is exactly the value a continuous feature field would have at that point (for a linear field, interpolation is exact).
roi_align_single — the 15 lines the paper boughtpython
import torch
import torch.nn.functional as F
def roi_align_single(feature, box, output_size=7, spatial_scale=1 / 16.0):
"""feature: (C, H, W) · box: (x1, y1, x2, y2) in input pixels."""
C, H, W = feature.shape
# to feature coordinates: divide by the stride, shift by half a pixel
x1, y1, x2, y2 = [c * spatial_scale - 0.5for c in box]
bin_w = (x2 - x1) / output_size
bin_h = (y2 - y1) / output_size
# bin centres (sampling_ratio = 1); with sampling_ratio = 2 sample at# 1/4 and 3/4 through each bin instead and average the four values.
grid_y = torch.linspace(y1 + bin_h / 2, y2 - bin_h / 2, output_size)
grid_x = torch.linspace(x1 + bin_w / 2, x2 - bin_w / 2, output_size)
yy, xx = torch.meshgrid(grid_y, grid_x, indexing="ij")
# grid_sample wants coordinates in [-1, 1] per axis, align_corners=False
gx = 2 * (xx + 0.5) / W - 1
gy = 2 * (yy + 0.5) / H - 1
grid = torch.stack([gx, gy], dim=-1).unsqueeze(0)
sampled = F.grid_sample(feature.unsqueeze(0), grid, mode="bilinear",
align_corners=False)
return sampled.squeeze(0)
# check against torchvision: max|diff| below 1e-5 with aligned=Truefrom torchvision.ops import roi_align
feature = torch.randn(1, 16, 50, 50)
box = torch.tensor([[0, 10, 20, 100, 90]], dtype=torch.float32)
ours = roi_align_single(feature[0], box[0, 1:].tolist(), 7, 1 / 4)
theirs = roi_align(feature, box, (7, 7), spatial_scale=1 / 4,
sampling_ratio=1, aligned=True)[0]
print((ours - theirs).abs().max().item())
The −0.5 is the whole coordinate convention: a feature cell at integer position (2, 3) represents the image region centred there, so input pixel p maps to feature coordinate p·spatial_scale − 0.5. torchvision's aligned=True uses the same convention; feed it the same box and the two implementations agree to float32 noise.
The playground makes the difference concrete on an 8×8 feature map. The default proposal (1.6, 1.1, 6.4, 5.9) is split into a 2×2 grid of 2.4 × 2.4-cell bins; each bin holds four samples at 1/4 and 3/4 through it. The top-left bin’s samples read 1.380, 1.722, 1.800 and 2.264, so RoIAlign’s answer for that bin is their average, 1.792. RoIPool rounds the box to (2, 1, 6, 6) — moving the left edge 0.4 cells right and the right edge 0.4 cells left, which at stride 32 is 12.8 pixels of shrink on each side — and then takes the maximum over each integer cell, which for the same bin is 2.278. The two crops differ by up to 0.908 feature units in that configuration, and even when you hold the reduction fixed at “average” the rounding alone moves a bin by up to 0.416.
One more detail the playground reveals when you press integer corners: a box that already starts and ends on the grid still gets cropped wrong, because the bin boundaries round as well. With the box (2, 1, 6, 6) and a 2×2 output, the vertical split wants boundaries at 1, 3.5 and 6; RoIPool rounds 3.5 to 4, so the crop is 1 / 4 / 6 — bins of height 3 and 2 instead of 2.5 and 2.5 — and the same-reduction difference is still 0.348 feature units. Rounding twice is worse than rounding once, and there is no way to fix it from outside: the sampled crop is simply not the region the detector asked about.
RoIAlign vs RoIPool: sample, don’t round
One 8×8 feature map, one proposal at float coordinates, a 2×2 output grid. Toggle the crop algorithm and read the arithmetic for any bin: RoIAlign bilinearly samples exact points, RoIPool rounds the box and the bin boundaries and takes the max inside each integer cell.
Crop algorithm
Samples per bin
Box edges (feature coordinates)
Bin to inspect
Presets
mode RoIAlign · bilinear, exact coordinates
samples/bin 4 (2×2, torchvision's sampling_ratio=2)
stride 32 px per feature cell
proposal (1.60, 1.10, 6.40, 5.90)
rounded box (2, 1, 6, 6)
shift x1 0.40 y1 -0.10 x2 -0.40 y2 0.10 cells
= 12.8 px per side at stride 32
RoIAlign 2×2
[0,0] 1.792 [0,1] 1.457
[1,0] 1.535 [1,1] 1.254
RoIPool max
[0,0] 2.278 [0,1] 2.338
[1,0] 2.107 [1,1] 2.162
difference with RoIPool's max reduction 0.908
difference with the same (average) reduction 0.416 ← the rounding alone
selected bin 1 (row 0, col 0)
at (2.20, 1.70) cell (1, 2) dx 0.20 dy 0.70
0.964×0.24 + 1.247×0.06 + 1.442×0.56 + 1.901×0.14
= 1.380
bin average 1.792
pooled max 2.278 at cell (3, 3)
preset note rounds to (2, 1, 6, 6): +0.4 / −0.1 / −0.4 / +0.1 cells — 12.8 px of shrink per side at stride 32
RoIPool’s damage is not one rounding but several stacked: the box corners, then every bin boundary, then the max over whole cells instead of the average at exact positions. Compare integer corners too — even a box that starts on the grid moves, because the bin split still rounds.
Quick check
torchvision's mask RoIAlign is configured with output_size=14 and sampling_ratio=2. How many interpolated samples does one proposal cost there?
05
A 28×28 ANSWER
Four convolutions and a deconvolution. One channel per class, per object.
The mask branch is the only new neural network in Mask R-CNN, and it is tiny: it starts from the 14×14 aligned crop, refines it, doubles the resolution, and emits a 28×28 map for every class. Everything after that is resampling and thresholding.
The mask head is a fully convolutional network, which means it contains no fully connected layers and no global pooling — it preserves the spatial layout of whatever it is given. Four 3×3 convolutions with 256 channels operate on the 14×14 crop, a 2× transposed convolution doubles the resolution to 28×28, and a final 1×1 convolution projects 256 channels down to num_classes: 91 channels at COCO scale. One channel per class, one mask each, for the price of about 2.6M parameters (4 convolutions, a 2× deconvolution and a 1×1 convolution).
At training time, the mask loss is per-pixel binary cross-entropy applied only to the channel of the class the box head selected — the mask head never has to learn to suppress masks for classes the proposal is not. At inference time, the whole 91-channel tensor is produced and all but one channel is thrown away. That looks wasteful (91 × 28 × 28 = 71,344 numbers per RoI to keep 784) and it is the design’s best idea: because the mask head never sees the class decision, it cannot trade mask quality against classification confidence. Mask shape and class probability are separate targets with separate gradients, and a wrong class does not corrupt a right mask — which matters when you later review the masks by hand or reclassify them downstream.
What one image’s forward pass returns in evaluation mode — torchvision’s contract, which every downstream tool expects.
Key
Shape
Meaning
The detail people miss
boxes
(N, 4)
final refined boxes, x1 y1 x2 y2 in input pixels
already NMS-ed per class at IoU 0.5, already score-filtered
labels
(N,)
class id per box
0 is background, so real classes start at 1
scores
(N,)
confidence per box
softmax probability of the label, not objectness
masks
(N, 1, H, W)
per-object mask at full image resolution
floats in [0, 1] — the 28×28 grid has already been resampled to the box and pasted; threshold at 0.5 yourself
That last detail is worth leaning on, because it is where most first attempts lose an hour: masks is not a 28×28 tensor. The model resizes each 28×28 probability map to the box’s size, pastes it into an all-zero (H, W) canvas, and stacks the N canvases. So N masks of the full image size come back, N is usually under 100, and at 800×800 in float32 that is 2.56 MB per mask before compression. The 28×28 head output is an internal detail — it exists because 784 logits per class is a tractable thing to train against a 28×28 target, not because that is the resolution you ship.
Reading the output dict, and the 28×28 → RoI → threshold pathpython
import torch
from torchvision.models.detection import (
maskrcnn_resnet50_fpn_v2, MaskRCNN_ResNet50_FPN_V2_Weights,
)
model = maskrcnn_resnet50_fpn_v2(weights=MaskRCNN_ResNet50_FPN_V2_Weights.DEFAULT)
model.eval()
with torch.no_grad():
p = model([torch.rand(3, 800, 800)])[0]
print(p["boxes"].shape) # (N, 4) x1 y1 x2 y2 in input pixels
print(p["labels"].shape) # (N,) 1-based; 0 is background
print(p["scores"].shape) # (N,)
print(p["masks"].shape) # (N, 1, 800, 800) <- full resolution, floats in [0, 1]
binary = (p["masks"] > 0.5).squeeze(1) # (N, 800, 800) boolean
areas = binary.sum(dim=(1, 2)) # pixels per object
print(areas) # e.g. tensor([18422, 9031])# what happened internally, for one kept object:
roi = p["boxes"][0] # (4,) float coordinates# 1. mask head emitted 91 channels at 28x28, one 784-number map per class# 2. only channel labels[0] was kept: (28, 28)# 3. resized to the roi's size: (h, w) bilinear, probability space# 4. pasted into an 800x800 zero canvas: (800, 800)# 5. stacked over N objects: (N, 1, 800, 800)# and the source's recipe for a custom class count keeps this contract:from torchvision.models.detection.faster_rcnn import FastRCNNPredictor
from torchvision.models.detection.mask_rcnn import MaskRCNNPredictor
in_features = model.roi_heads.box_predictor.cls_score.in_features
model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes)
hidden = model.roi_heads.mask_predictor.conv5_mask.in_channels
model.roi_heads.mask_predictor = MaskRCNNPredictor(hidden, 256, num_classes)
Four lines of head-swapping change the class count and nothing else: the backbone, FPN, RPN and both head trunks keep their pretrained weights. num_classes includes background, so four object classes means num_classes=5.
The mask head playground: 28×28 is not the mask
The head answers with a coarse per-class grid; torchvision resamples it to the RoI’s pixel size, thresholds at 0.5 and pastes it into the image. Change the grid resolution, the resampling and the threshold, and watch IoU against the ground-truth outline move.
Mask grid resolution
Resampling onto the RoI
grid 28×28 = 784 logits per class
one cell covers 1.43 × 1.07 display px of the RoI
resampling bilinear
threshold p ≥ 0.50 ⟺ logit ≥ 0.000
ln(0.50 / 0.50) = 0.000
predicted mask 252 display px
ground truth 254 display px
intersection 240
union 252 + 254 − 240 = 266
mask IoU 0.902
memory, 512 RoIs, float32, one 256-channel tensor per resolution
14×14 98.0 MB
28×28 392.0 MB
56×56 1.53 GB
current 392.0 MB (1.00× the 28×28 bill)
at COCO scale
91 classes × 784 = 71,344 numbers per RoI
keep only the predicted class: 784
Two lessons hide here. First, bilinear upsampling of a smooth logit field recovers a smooth boundary at any of these resolutions — IoU barely moves, so a 56×56 head mostly buys memory. Second, the threshold and the resampling mode matter more than the grid: the same 28×28 mask reads IoU 0.902 at 0.50 and 0.717 at 0.90, and nearest-neighbour resampling drops it to 0.842. The predicted shape is not the true shape, and 0.5 is the crossing where they agree best.
Quick check
A proposal gets class 7 with score 0.9, and the mask head's 91-channel output has, for that proposal, a beautiful mask in channel 3 and a messy one in channel 7. What does Mask R-CNN report?
06
PRODUCTION MASKS
You will almost never train this from scratch. You will load it, read it, and be done by lunch.
maskrcnn_resnet50_fpn_v2 with COCO weights is the default answer for small and medium instance segmentation work. Four lines load it, one dict comes back, and the numbers below tell you what it costs and what it is worth.
The load is as ordinary as any torchvision model: the weights enum handles the download, weights.transforms() gives you the exact preprocessing the weights were trained with, and weights.meta[“categories”] gives you the label names. What is not ordinary is the forward pass: a detection model expects a list of tensors (one per image, variable sizes are fine), returns a list of dicts, and — the part that trips people — behaves differently depending on model.training. In evaluation mode you get predictions; in training mode you get losses. There is no third mode and no separate API.
Load, preprocess, infer, readpython
import torch
from torchvision.io import decode_image
from torchvision.models.detection import (
maskrcnn_resnet50_fpn_v2, MaskRCNN_ResNet50_FPN_V2_Weights,
)
weights = MaskRCNN_ResNet50_FPN_V2_Weights.DEFAULT
model = maskrcnn_resnet50_fpn_v2(weights=weights).eval()
preprocess = weights.transforms() # includes the resize/normalize
categories = weights.meta["categories"] # 91 names, 0 = '__background__'
image = decode_image("photo.jpg") # uint8 (3, H, W)
batch = [preprocess(image)] # a *list* of tensorswith torch.no_grad():
prediction = model(batch)[0] # a list of dicts comes back
keep = prediction["scores"] > 0.7# the model already NMS-ed
boxes = prediction["boxes"][keep] # (N, 4) x1 y1 x2 y2, pixels
labels = prediction["labels"][keep] # (N,) 1-based class ids
masks = prediction["masks"][keep] > 0.5# (N, 1, H, W) -> booleanfor box, label, mask in zip(boxes, labels, masks):
print(categories[label.item()], box.tolist(), int(mask.sum()), "px")
# box AP and mask AP, side by side, with the official COCO evaluator:# from pycocotools.cocoeval import COCOeval -> stats[0] = mAP@0.5:0.95# run it twice: once with bbox results, once with segmentation results.
Two habits: keep the score threshold high (0.7+) before reporting masks, because the 0.2–0.4 tail is where the classification head is guessing; and always look at box mAP and mask mAP together — they are the two halves of this model's story.
The torchvision docs’ instance-segmentation and detection rows, COCO val2017, single-scale, as reported for each set of weights. The mask column is the only thing the mask branch adds — and the point of the lesson.
Weights
Box mAP
Mask mAP
Gap
Params
GFLOPs / image
maskrcnn_resnet50_fpn_v2
47.4
41.8
5.6 pts
46.4M
333.58
maskrcnn_resnet50_fpn
37.9
34.6
3.3 pts
44.4M
134.38
fasterrcnn_resnet50_fpn_v2
46.7
—
—
43.7M
280.37
fasterrcnn_mobilenet_v3_large_fpn
32.8
—
—
19.4M
4.49
Read the table the way you would read a price list. The mask branch itself is the difference between the two ResNet-50-FPN v2 rows: 46.4M against 43.7M parameters (2.7M, about 6%) and 333.58 against 280.37 GFLOPs (+53.2, +19% compute) — and what it buys is the mask column, 41.8 mAP. That is the whole economics of instance segmentation: masks are not expensive to add to a detector; they are expensive to annotate.
The gap between box mAP and mask mAP is a diagnosis, not a defect. On the v1 weights it is 3.3 points (37.9 → 34.6); on v2 it is 5.6 (47.4 → 41.8). A mask must be tight enough to clear the same IoU thresholds as a box, pixel by pixel, on a boundary it has only 28×28 logits to describe; some slippage is unavoidable. Watch the gap and it tells you which head to work on: a shrinking box-mask gap with flat box mAP means the mask head has hit its data limit; a growing gap on a new dataset usually means the masks are noisier than the boxes (mask annotation is 5–10× more expensive, and cheap mask annotation is visible).
Choosing between the four rows is a compute question more than an accuracy question. v2 costs 2.5× the FLOPs of v1 for +9.5 box and +7.2 mask points; MobileNetV3-Large-FPN costs about a seventy-fourth of v2’s FLOPs (4.49 against 333.58) for −14.6 box points and no mask head at all, which is the right trade when you cannot fit the mask branch at all. And note the units: 333.58 GFLOPs is a forward pass per image; a training step is roughly 3× that, which is why the memory arithmetic in the next chapter matters more than the accuracy table.
07
SMALL DATASETS, BIG MODELS
46 million parameters, 500 images. Decide what is allowed to move.
The fine-tuning recipe is ten lines. The judgement is which of those lines to comment out — and the answer comes from arithmetic you can do before you burn a GPU-hour: what is frozen, what is trainable, and what the activations cost.
The recipe reuses everything: COCO’s backbone, FPN and RPN weights stay; only the two predictors are rebuilt for your class count, and only the parts that must learn are unfrozen. On a 500-image dataset, freezing model.backbone.parameters() freezes 26.9M parameters — the 23.5M ResNet-50 trunk and the 3.3M FPN, because torchvision packs the FPN inside model.backbone — and leaves 19.0M trainable (41.5%): the RPN plus the box and mask heads. The box head is the largest learner at 15.2M, which surprises people who expect the mask branch to dominate; it is four 3×3 convolutions with batch norm on 12,544 features per RoI, a 1,024-wide fully connected layer, and then the predictor’s second 1,024-wide layer with the class and delta heads.
The component budget for maskrcnn_resnet50_fpn_v2 with 5-class predictors (4 object classes + background). With the COCO 91-class predictors these same rows sum to 46,359,409 — exactly the count torchvision publishes for these weights — and the head swap brings the total to 45.9M.
Component
Parameters
Frozen?
Why
ResNet-50 trunk (conv + BN)
23.5M
frozen
the 25.6M ImageNet checkpoint minus its 2.0M classifier
FPN laterals + output convs
3.3M
frozen
lives inside model.backbone, so freezing the backbone freezes this too
RPN head (2 convs + objectness + deltas)
1.2M
trainable
3 anchors per location, a binary objectness logit and 4 deltas per anchor
Box head (4 convs + 1 FC)
15.2M
trainable
7×7×256 = 12,544 features per RoI; the v2 recipe uses batch norm in the convs
Box predictor (5 classes)
26k
trainable
cls 5 logits + 16 box deltas, one linear layer each
Mask head (4 convs)
2.4M
trainable
on the 14×14 mask RoIAlign crop, before the deconvolution
Mask predictor (5 classes)
264k
trainable
2× deconvolution 256→256, then a 1×1 convolution to 5 channels
Total
45.9M
19.0M trainable
41.5% of the model moves on 500 images
Then there is the memory bill, and this is where the mask branch stops being small. Training samples 512 RoIs per image, and every one of them passes through the mask head at 14×14 and then 28×28, 256 channels deep. The biggest single tensor in the model is the 28×28 deconvolution output: 512 × 256 × 28 × 28 × 4 bytes = 392 MB for one image. Add the mask logits (512 × 91 × 28 × 28 = 139 MB), the 14×14 crops and the deconvolution, and the mask branch accounts for 727.3 MB of the 927.8 MB activation floor — 78% of the memory for 2.6M parameters, about 6% of the model. The trunk’s six stage outputs together are 122.1 MB.
So the freeze question and the memory question are the same question. Freezing the trunk removes its gradients and Adam moments (≈310 MB of fixed memory) but not its activations; dropping the batch from 2 to 1 removes a whole copy of the 927.8 MB floor. Between those two levers, an 8 GB GPU can train this model on a few hundred images, and a 16 GB GPU can do it at batch 2 with the mask branch you actually want.
Swap the heads, freeze the trunk, take one steppython
import torch
from torchvision.models.detection import (
maskrcnn_resnet50_fpn_v2, MaskRCNN_ResNet50_FPN_V2_Weights,
)
from torchvision.models.detection.faster_rcnn import FastRCNNPredictor
from torchvision.models.detection.mask_rcnn import MaskRCNNPredictor
def build_custom_maskrcnn(num_classes):
"""num_classes includes background: 4 object classes -> 5."""
model = maskrcnn_resnet50_fpn_v2(weights=MaskRCNN_ResNet50_FPN_V2_Weights.DEFAULT)
in_features = model.roi_heads.box_predictor.cls_score.in_features
model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes)
in_features_mask = model.roi_heads.mask_predictor.conv5_mask.in_channels
model.roi_heads.mask_predictor = MaskRCNNPredictor(in_features_mask, 256, num_classes)
return model
def freeze_backbone_and_fpn(model):
# torchvision packs the FPN inside model.backbone (model.backbone.fpn),# so this freezes the ResNet stages AND the FPN in one loop.for p in model.backbone.parameters():
p.requires_grad = Falsereturn model
model = freeze_backbone_and_fpn(build_custom_maskrcnn(num_classes=5))
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(f"{trainable:,} of {total:,} trainable") # ~19.0M of ~45.9M
optimizer = torch.optim.SGD(
[p for p in model.parameters() if p.requires_grad],
lr=1e-3, momentum=0.9, weight_decay=0.0005, # 5x lower than the full-FT recipe
)
def train_step(images, targets):
model.train() # -> returns losses
loss_dict = model(images, targets) # targets: boxes, labels, masks per image
losses = sum(loss for loss in loss_dict.values())
optimizer.zero_grad()
losses.backward()
optimizer.step()
return {k: v.item() for k, v in loss_dict.items()}
# the targets contract, per image:# {"boxes": (M, 4) float x1 y1 x2 y2,# "labels": (M,) int, 1-based,# "masks": (M, H, W) uint8 0/1}
The five losses are summed with equal weight out of the box — torchvision exposes the RPN and box-head thresholds (rpn_nms_thresh, box_nms_thresh, box_score_thresh) as constructor arguments, but no per-loss weight, so re-weighting the mask term means editing the loss dict yourself. If mask quality plateaus while box mAP keeps climbing, suspect the masks' annotation quality before the learning rate.
Two failure modes are worth naming before you start. The first is the plateau: train loss keeps falling, validation mAP stops improving. That is what overfitting on 19M trainable parameters looks like, and the first move is not more epochs — it is freezing more (add the RPN) or training fewer. The second is subtler: freeze everything and you buy stability at the cost of a ceiling. COCO features are excellent for dogs, cars and people; on microscope fields or semiconductor wafers the frozen trunk may simply not contain the features your masks need, and the run converges beautifully to a mediocre number. The triage board below makes the first decision; this paragraph is the second.
The fine-tune triage board: dataset × GPU → recipe
Pick a dataset size and a GPU. The board names the recipe, the exact parameter budget, the failure mode to expect, and whether the memory estimate fits — with the arithmetic shown, not asserted.
Recipe: freeze the trunk + FPN, train the RPN and both heads.
Line item
This configuration
Where it comes from
Total parameters
46.4M
torchvision docs table (maskrcnn_resnet50_fpn_v2)
Trainable now
≈19.0M (41.0%)
RPN + box head + mask head; 26.9M trunk and FPN frozen
strategy Freeze the trunk + FPN, train the RPN and both heads
when small data (a few hundred images or fewer) on any GPU, or a GPU that cannot hold a batch
classes 4 (3 objects + background) → FastRCNNPredictor(1024, 4) and MaskRCNNPredictor(256, 256, 4)
recipe
for p in model.backbone.parameters(): p.requires_grad = False
swap box_predictor and mask_predictor for num_classes = objects + 1
train only the RPN, box head and mask head at lr 1e-3 → 1e-4
expected
≈19.2M of 46.4M parameters stay trainable (41%). The frozen trunk is an ImageNet+COCO feature extractor; the heads learn your classes from it.
failure modes to watch
· the mask head overfits an object shape it saw once, so masks go mushy
· the RPN keeps proposing the COCO shapes, which can miss unusual aspect ratios
· batch norm in the frozen trunk still updates its running stats unless you keep it in eval()
first move if it plateaus
unfreeze layer4 and the FPN with a 10× lower learning rate on those parameters
why this configuration
dataset: 500 images → small data (a few hundred images or fewer) on any GPU, or a GPU that cannot hold a batch
trainable: 19.0M of 46.4M parameters (41.0%)
fixed memory: 394.9 MB (weights + gradients + Adam)
activation floor: 927.8 MB per image × batch 2 × 4 for stored intermediates = 7.25 GB
estimate: 7.63 GB against 16 GB available
mask-branch share of the activation floor
727.3 MB of 927.8 MB = 78.4% (≈2.6M parameters, ≈6% of the model)
the fallback nobody regrets
fasterrcnn_mobilenet_v3_large_fpn: 19.4M params, 4.49 GFLOPS, box mAP 32.8 — a tenth of the compute when the mask branch will not fit
datasets in the board: 50 · 200 · 500 · 2k · 5k · 20k · 100k images
Dataset size
GPU memory
trainable parameter count by strategy
heads-only 19.0M (41.1% of the 91-class 46.4M model)
trunk-heads 46.4M (100.0% of the 91-class 46.4M model)
from-scratch 46.4M (100.0% of the 91-class 46.4M model)
predictor sizes for 4 classes
box cls_score 1024 × 4 = 4,096
bbox_pred 1024 × 16 = 16,384
mask deconv 256 × 256 × 2 × 2 = 262,400
logits 256 × 4 = 1,024
(background is class 0, so 3 object classes means num_classes=4)
Three recipes, not thirty. Under ~500 images the pretrained features are the model and you only fit the heads; a few thousand lets the whole network move at a small learning rate; tens of thousands is where from-scratch or a different architecture starts to pay. The memory arithmetic is the other half of the decision — the mask branch is 6% of the parameters and 78% of the activations.
Quick check
You have 400 annotated images, a 16 GB GPU, and a validation set that plateaus at epoch 8 while train loss keeps dropping. What is the first experiment?
08
CHECK YOURSELF
Five questions. Then the terms worth keeping.
Answer before you look. The RoIAlign question and the freeze question are the two that separate “I read about Mask R-CNN” from “I could fine-tune one this week.”
0 / 5 answered · 0 correct
01Why did Mask R-CNN replace RoIPool with RoIAlign?
02The mask head outputs a 28×28 mask per class per proposal. Why per class?
03torchvision's Mask R-CNN prediction dict has `labels` that start at 1, not 0. Why?
04You fine-tune Mask R-CNN on a 500-image dataset and val mAP plateaus while train loss keeps dropping. What is the first thing to try?
05The FPN inside Mask R-CNN has four levels the RoI heads can read (P2–P5, strides 4–32) plus P6 for the RPN. Why not just use one level?
Key terms, demystified
Click a card to swap the lazy description for what it actually means — each one carries the arithmetic from the labs.
Exercises from the lesson
Three problems with exact numbers: verify your RoIAlign against torchvision and show RoIPool’s drift, fine-tune on 50 images with a real parameter budget, and rebuild the mask head at 56×56 to see what the extra resolution actually buys. Try first; a worked answer is one click away.
Implement roi_align_single for a (C, H, W) feature map and verify it against torchvision.ops.roi_align on 100 random boxes. Report the maximum absolute difference. Then implement the textbook RoIPool and show it diverges by ~1–2 feature-map pixels on boxes whose corners are not integers.Show one worked answer
The implementation is the source's 15 lines: scale the box by spatial_scale, subtract 0.5 to convert input pixels into the feature grid's coordinate convention, split into output_size bins, and sample each bin's points with F.grid_sample(mode="bilinear", align_corners=False). With sampling_ratio=1 and aligned=True the two agree to about 1e-5 — float32 rounding, not an algorithmic difference. The RoIPool side is where the arithmetic gets visible: box (34.7, 51.3, 98.2, 142.9) at spatial_scale 1/16 becomes (2.169, 3.206, 6.138, 8.931) in feature coordinates, and rounding it gives (2, 3, 6, 9) — shifts of −0.169, −0.206, −0.138 and +0.069 cells, each worth 16 input pixels, so the crop starts up to 3.3 px away from where the detector pointed. The lab's 8×8 example is sharper still: rounding (1.6, 1.1, 6.4, 5.9) to (2, 1, 6, 6) shifts the left edge by +0.4 cells and the right edge by −0.4, so the pooled crop is 12.8 px narrower per side at stride 32, and the bin split moves from 1 / 3.5 / 6 to 1 / 4 / 6. Report both numbers — the source calls RoIPool's rounding "catastrophic at stride 32" and now you have the arithmetic behind it.
Fine-tune maskrcnn_resnet50_fpn_v2 on a 50-image custom dataset with two object classes. Freeze the backbone and FPN, train 20 epochs, and report mask AP@0.5. Give the parameter budget, the RoIs per image, and the reason each frozen module stays frozen.Show one worked answer
Budget first: swap box_predictor for FastRCNNPredictor(1024, 3) and mask_predictor for MaskRCNNPredictor(256, 256, 3) — two classes plus background — then freeze model.backbone.parameters(), which covers the ResNet-50 trunk (23.5M conv+BN parameters) and the FPN (3.3M) because torchvision packs the FPN inside the backbone. The component sum is 45.9M total, 26.9M frozen, 19.0M trainable — 41.5% — with the box head (15.2M) the largest learner. Training samples 512 RoIs per image (128 positive, 384 negative), so 50 images is 25,600 RoIs per epoch against 19.0M parameters: still far more parameters than examples, which is exactly why the frozen features matter. Train with the source's five-line step (losses = sum(model(images, targets).values()); backward; step), SGD lr 1e-3 with a 10× drop at epoch 15, and evaluate with pycocotools at IoU 0.5. A healthy run on 50 images of one easy class reaches mask AP@0.5 in the 0.7–0.9 band; if validation AP peaks at epoch 6 and then falls while train loss keeps dropping, that is the overfitting signature the freeze was supposed to prevent — drop to 5 epochs, or freeze the RPN too. Report box AP next to mask AP: with 50 images the box head usually saturates first, and the mask column is where the last 5 points hide.
Replace the mask head with one that predicts 56×56 instead of 28×28 and measure mAP@IoU=0.75 before and after. Explain the result with the boundary-precision / memory trade-off, and say what you would measure instead of mAP@0.5 to see the difference.Show one worked answer
The change is two lines — the mask RoIAlign output goes from 14 to 28 and the deconvolution from 2× to 4× (or add a second 2× block) — but the memory arithmetic is not small: the largest mask tensor per RoI goes from 256 × 28 × 28 = 200,704 values to 256 × 56 × 56 = 802,816, and at 512 training RoIs that is 392 MB → 1.53 GB in float32 for that one tensor, a 3.9× increase, with the mask logits (91 × 28 × 28 → 91 × 56 × 56) going from 139 MB to 557 MB. The playground shows why the accuracy side is usually disappointing: bilinear upsampling already recovers a smooth boundary, so the same silhouette reads IoU 0.894 at 14×14, 0.902 at 28×28 and 0.893 at 56×56 — the coarse grid's error is a boundary that can only move in steps of one cell (2.1 × 2.9 display pixels at 14×14), not a blurry blob. Where the extra resolution does pay is thin structure and strict localisation: the playground's thin leg is 1.2 display cells tall, and mask AP@0.75 — which asks for IoU 0.75 — is the metric that moves while mAP@0.5 stays flat. So measure: mAP@0.5 (expected flat), mAP@0.75 and mask AP@0.5:0.95 (expected a small gain), plus the memory and per-step time. If the gain is under a point and the step time is up 40%, 28×28 was the right default.
Terms this lesson borrows from later lessons (or outside)
You do not need to master these here. Each one gets a proper treatment in its own lesson; the one-line meaning is enough to keep reading. Orange dotted underlines in the prose point back to this list.
Anchors, objectness and NMS — The RPN is the same machine Lesson 06 built: pre-defined box shapes at every feature location, an objectness score, regression deltas, and greedy non-maximum suppression. The difference is what happens after — Mask R-CNN refines and classifies proposals instead of predicting everything in one shot. Phase 4, Lesson 06.
IoU as the referee — Both NMS passes and both metrics run on intersection-over-union. Mask R-CNN uses it three ways: 0.7 to deduplicate proposals, 0.5 to deduplicate final per-class boxes, and 0.5–0.95 as the mask-AP grading gate. Phase 4, Lesson 06.
Semantic segmentation — Lesson 07's per-pixel class prediction, where touching instances merge and amorphous stuff is labelled. Mask R-CNN is the instance side of that split — foreground things only, one binary mask per object — and it borrows the same loss vocabulary (per-pixel cross-entropy, IoU-based metrics). Phase 4, Lesson 07.
Pretrained backbone and transfer learning — The ResNet-50 trunk is an ImageNet classifier with its head removed; the FPN, RPN and both heads were trained on COCO. Fine-tuning starts by freezing 26.9M of those parameters — the same freeze-the-trunk arithmetic as Lesson 05, now with a 46M-parameter model and 512 RoIs per image. Phase 4, Lesson 05.
Feature maps and strides — Every FPN level is a feature map at a different stride, and stride is why a 64×64 object is 8×8 cells on P2 but a single cell on P5. RoIAlign's spatial_scale (1/stride) is the same coordinate conversion Lesson 03 uses to read shapes from a convolutional stack. Phase 4, Lesson 03.
Multi-task loss — Five losses are added — RPN objectness and box, head classification and box, plus per-pixel mask BCE. Detection is the canonical multi-task problem: each term trains a different head, and the balance between them is what you tune when one head underfits. torchvision sums them with equal weight out of the box. Phase 3, Lesson 05.
KEEP GOING
A picture is a start. Practice is the rest.
This lesson is a port of an open course. Everything here traces back to it — and the next step is running the code yourself.
Lesson text adapted from AI Engineering from Scratch (Phase 04, Lesson 08) and the Math Foundations Notebook reference build. The five labs — the RoIAlign vs RoIPool playground, the architecture explorer, the proposal stepper, the mask head playground, and the fine-tune triage board — are original to this page, as are the bilinear sample worked by hand on the 8×8 map (at (2.2, 1.7): weights 0.24 / 0.06 / 0.56 / 0.14 on values 0.964 / 1.247 / 1.442 / 1.901 → 1.380, bin average 1.792 against RoIPool's max 2.278), the rounding bill (+0.4 / −0.1 / −0.4 / +0.1 cells = 12.8 px per side at stride 32, and the 0.348 that survives integer corners because 3.5 rounds to 4), the sampling counts (7×7 × 2×2 = 196 and 14×14 × 2×2 = 784), the anchor arithmetic at 800×800 (120,000 + 30,000 + 7,500 + 1,875 + 507 = 159,882 anchors, NMS 0.7 in the RPN, ~1,000 proposals with 512 sampled in training as 128 positives and 384 negatives), the RoI-to-level rule worked out (64→P2, 224→P4, 512→P5), the torchvision docs-table row arithmetic (box 47.4 / mask 41.8 and 37.9 / 34.6; 46.4M vs 43.7M and +53.21 GFLOPS for the mask branch), the fine-tune budget (26.9M frozen, 19.0M trainable, 41.5%, the box head the largest learner at 15.2M), and the activation bill (927.8 MB at 512 RoIs with 78.4% in the mask branch). Every number shown is computed live by the labs or verified by hand in the prose.