EVERYTHING AIAI engineering, made visual
0/28 complete
LESSON 08 · COMPUTER VISION × AI · BUILD + LEARN

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.

75 MIN · 7 CHAPTERS + CHECKPREREQ · PHASE 4 · LESSONS 06–07
FIG. 08 / PROPOSE → ALIGN → PAINT · THE WHOLE PIPELINE
proposal / sample bin average suppressed waiting
LESSON 08TYPE · BUILD + LEARN~75 MINPREREQ · PHASE 4 · LESSONS 06–07 (YOLO, U-NET)ORIGINAL LESSON ↗
THE 60-SECOND VERSIONSee the problem ↓
01 / TWO STAGES

Propose first, then classify and refine.

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 image
02 / 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 anywhere
03 / 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.

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).

01 / SEMANTICgrass (stuff)dogone region · count = ?02 / INSTANCEstuff ignoreddog #1dog #2two masks · count = 203 / PANOPTICgrass (stuff)dog #1dog #2two masks + grass
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.

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

image3 × H × WbackboneC2–C5FPNP2–P6 · 256 chRPN159,882 anchorsproposals≈1,000 boxesRoIAlign7×7 & 14×14box + mask91 cls · 28×28outputsN × 1 × H × WSTAGE 1 · PROPOSESTAGE 2 · CLASSIFY, REFINE, PAINTstage 1 knows where. It does not know what, and it does not paint.stage 2 gives every proposal a class, a tighter box and a 28×28 mask per class.the mask branch is the only new part: four convolutions and a deconvolution, ~2.6M parameters.
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.
LossApplies toWhat it teachesShape of the target
L_rpn_clsevery RPN anchorobjectness: is there anything in this anchor, or not?binary, per anchor (159,882 of them)
L_rpn_boxanchors assigned to an objecthow to move the anchor onto the object4 smooth-L1 deltas per positive anchor
L_box_clsthe 512 sampled RoIswhich of the 91 classes (background included) this proposal isone class id per RoI
L_box_regpositive RoIs onlythe final tightening of the box4 deltas per RoI per class
L_maskpositive RoIs, predicted class channel onlythe silhouette: per-pixel binary cross-entropy at 28×28one 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?

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.
LevelStrideGridChannelsAnchor locationsBuilt from
P24200×200256120kfrom layer1 / C2 · small objects
P38100×10025630kfrom layer2 / C3
P41650×502567.5kfrom layer3 / C4 · the 224px level
P53225×252561.9kfrom layer4 / C5 · large objects
P66413×13256507max-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)kLevelCells across on that level
64×64 · a small object64-1.8072P216.0
224×224 · the canonical size2240.0004P414.0
512×512 · a large object5121.1935P516.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).

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.

0.964×0.241.247×0.061.442×0.561.901×0.14sample (2.2, 1.7)dx = 2.2 − 1 = 0.20dy = 1.7 − 1 = 0.700.24·0.964 + 0.06·1.247+ 0.56·1.442 + 0.14·1.901= 1.380cell (1, 1) · feature map at stride 16 · cell (1, 1) covers input pixels 16…32the four weights sum to 1: (1−dx)(1−dy), dx(1−dy), (1−dx)dy, dx·dy
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.5 for 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=True
from 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?

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.
KeyShapeMeaningThe detail people miss
boxes(N, 4)final refined boxes, x1 y1 x2 y2 in input pixelsalready NMS-ed per class at IoU 0.5, already score-filtered
labels(N,)class id per box0 is background, so real classes start at 1
scores(N,)confidence per boxsoftmax probability of the label, not objectness
masks(N, 1, H, W)per-object mask at full image resolutionfloats 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?

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 tensors
with 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) -> boolean

for 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.
WeightsBox mAPMask mAPGapParamsGFLOPs / image
maskrcnn_resnet50_fpn_v247.441.85.6 pts46.4M333.58
maskrcnn_resnet50_fpn37.934.63.3 pts44.4M134.38
fasterrcnn_resnet50_fpn_v246.743.7M280.37
fasterrcnn_mobilenet_v3_large_fpn32.819.4M4.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.

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.
ComponentParametersFrozen?Why
ResNet-50 trunk (conv + BN)23.5Mfrozenthe 25.6M ImageNet checkpoint minus its 2.0M classifier
FPN laterals + output convs3.3Mfrozenlives inside model.backbone, so freezing the backbone freezes this too
RPN head (2 convs + objectness + deltas)1.2Mtrainable3 anchors per location, a binary objectness logit and 4 deltas per anchor
Box head (4 convs + 1 FC)15.2Mtrainable7×7×256 = 12,544 features per RoI; the v2 recipe uses batch norm in the convs
Box predictor (5 classes)26ktrainablecls 5 logits + 16 box deltas, one linear layer each
Mask head (4 convs)2.4Mtrainableon the 14×14 mask RoIAlign crop, before the deconvolution
Mask predictor (5 classes)264ktrainable2× deconvolution 256→256, then a 1×1 convolution to 5 channels
Total45.9M19.0M trainable41.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 = False
    return 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 itemThis configurationWhere it comes from
Total parameters46.4Mtorchvision docs table (maskrcnn_resnet50_fpn_v2)
Trainable now≈19.0M (41.0%)RPN + box head + mask head; 26.9M trunk and FPN frozen
Weights (float32)177.0 MB46.4M × 4 bytes
Gradients72.6 MBtrainable × 4 bytes
Adam moments145.2 MBtrainable × 8 bytes (two buffers)
Fixed total394.9 MBweights + gradients + optimizer
Activation floor, 1 image927.8 MB512 RoIs: trunk 122.1 MB, FPN 51.9 MB, heads 753.8 MB
Batch of 27.25 GBfloor × 2 × 4 for stored intermediates (estimate)
Estimate vs 16 GB7.63 GB · fitsleave headroom for fragmentation
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?

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.

  1. 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.

  2. 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.

  3. 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 NMSThe 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 refereeBoth 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 segmentationLesson 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 learningThe 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 stridesEvery 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 lossFive 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.

Original lessonInstance Segmentation — Mask R-CNNAI Engineering from Scratch · the source text, quiz and main.py with roi_align_single, the torchvision comparison, the pretrained loader, build_custom_maskrcnn, freeze_backbone and the five-line training step.Founding paperMask R-CNNHe, Gkioxari, Dollár & Girshick (2017) · section 3 is the critical read: RoIAlign replaces RoIPool, the mask branch is one small FCN, and every design choice is an ablation (mask AP rises several points from RoIAlign alone).Predecessor paperFaster R-CNN: Towards Real-Time Object Detection with Region Proposal NetworksRen, He, Girshick & Sun (2015) · the two-stage design Mask R-CNN extends: anchors, the RPN's objectness + delta losses, RoI pooling, and the 4-step alternating training the modern single-pass loop replaced.Founding paperFeature Pyramid Networks for Object DetectionLin, Dollár, Girshick, He, Hariharan & Belongie (2017) · the lateral + top-down construction that gives P2–P5 the same 256 channels, and the k = floor(k0 + log2(√(wh)/224)) rule for routing an RoI to a level.Reference docstorchvision — Instance segmentation modelsPyTorch · the table this lesson reads its numbers from: maskrcnn_resnet50_fpn_v2 at box 47.4 / mask 41.8, 46.4M parameters, 333.58 GFLOPS; the v1 row at 44.4M; and the Faster R-CNN rows the mask branch is measured against.Production recipetorchvision tutorial — Fine-tuning Mask R-CNNPyTorch · the 40-line training loop this lesson's fine-tuning chapter follows, with the targets dict (boxes, labels, masks), the per-epoch loop, and the pycocotools evaluation that returns box AP and mask AP side by side.

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.