RoI pooling and RoI align
Both crop a fixed-size patch out of a feature map for each candidate box; RoI pooling rounds the box to whole cells and RoI align does not, and that rounding is worth real accuracy.
- 13 min read
- 3 reading levels
- Updated
Read these first
On this page 9
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
The short answer
These are two ways of cutting a fixed-size patch out of a feature map for every candidate box.
The analogy
Think about cutting a photograph to fit a passport frame. You lay the frame on the print and cut around it.
One person insists on cutting along the ruled lines of the graph paper underneath. They do it even when the frame sits between lines. The cut is fast and the face ends up slightly off-centre.
Another person cuts exactly where the frame edge falls, halfway between lines, and blends the colour at the edge. Slower, and the face lands where it should.
RoI pooling is the first person. RoI align is the second.
Why this step exists at all
A detector finds candidate rectangles of every size. A hat box might be 20 across and a bus box 400 across.
The layer that classifies them needs a fixed-size input, the same for every box. Something has to turn "any rectangle" into "always seven by seven".
That is the job here. Take the region, split it into a fixed number of cells, and produce one number per cell. Big region or small region, the answer is always the same shape.
How it works
feature map candidate box fixed output
. . . . . . . . +-------+ +---+---+
. . . . . . . . | | -> | | |
. . . . . . . . | | +---+---+
. . . . . . . . +-------+ | | |
. . . . . . . . +---+---+Split the box into a grid. Read one value out of each grid square. Done.
The disagreement is about box edges that do not line up with the feature map's own grid. That is almost always.
The rounding problem
Feature maps are much smaller than the picture. A common shrink factor is sixteen, so sixteen picture pixels become one feature cell.
A box edge at picture pixel 100 lands on feature cell 6.25. RoI pooling rounds that to 6. That looks harmless. In picture terms it moved the edge by four pixels.
Move both edges by a few pixels each and a small object's patch is meaningfully wrong. For drawing a rough box it hardly matters. For drawing an outline that hugs the object, it matters a great deal.
The fix
RoI align refuses to round. It reads values at the exact positions, blending between neighbouring cells when a position falls between them.
This one change is why the technique was introduced. It came from Mask R-CNN, the model that added pixel outlines to detection. There, a few pixels of drift is visible to the eye.
Where you have seen the result
- A photo editor cutting a person out of a background with a clean edge.
- A video call blurring the background around your shoulders.
- A medical tool outlining an organ rather than boxing it.
- A shopping app showing only the sofa, without the room.
Remember this
- Both turn any-sized regions into one fixed-size patch, so a classifier can read them.
- RoI pooling rounds region edges onto the feature grid; RoI align does not.
- The rounding is invisible for coarse boxes and damaging for pixel outlines.
What to learn next
- R-CNN, Fast R-CNN and Faster R-CNN — the detectors this operation was built for.
- Feature pyramid networks — which level each region gets cropped from.
- Image segmentation — where the half-pixel accuracy actually shows.
Developer — Code and libraries.
Setup
pip install torch torchvisionWritten against torch 2.5.1 and torchvision 0.20.1.
Watching the rounding happen, on numbers you can check by eye
import torch
from torchvision.ops import roi_pool, roi_align
# A tiny 8x8 "feature map" whose values are 0..63, so you can follow the arithmetic.
feat = torch.arange(64, dtype=torch.float32).reshape(1, 1, 8, 8)
print("the feature map:")
print(feat[0, 0].int())
# One region of interest, in IMAGE coordinates, on a feature map with stride 4.
# 4.0 to 22.0 in the image is 1.0 to 5.5 on the map. That .5 is the whole story.
roi = torch.tensor([[0., 4., 4., 22., 22.]]) # batch_index, x1, y1, x2, y2
pooled = roi_pool(feat, roi, output_size=2, spatial_scale=0.25)
aligned = roi_align(feat, roi, output_size=2, spatial_scale=0.25,
sampling_ratio=2, aligned=False)
print("\nroi_pool (rounds the box to whole cells, then takes the MAX per bin):")
print(pooled[0, 0])
print("roi_align (samples at exact positions, then takes the AVERAGE per bin):")
print(aligned[0, 0])
# Move the box by one image pixel at a time and watch pooling refuse to notice.
print("\nnudging the box right, one image pixel at a time:")
for x1 in [4.0, 5.0, 6.0, 7.0, 8.0]:
r = torch.tensor([[0., x1, 4., x1 + 18., 22.]])
p = roi_pool(feat, r, 2, 0.25)[0, 0, 0, 0].item()
a = roi_align(feat, r, 2, 0.25, sampling_ratio=2, aligned=False)[0, 0, 0, 0].item()
print(f" x1={x1:>4} roi_pool top-left={p:7.3f} roi_align top-left={a:7.3f}")
# aligned=True shifts sampling by half a pixel; it is the correct one for new code.
a_false = roi_align(feat, roi, 2, 0.25, sampling_ratio=2, aligned=False)
a_true = roi_align(feat, roi, 2, 0.25, sampling_ratio=2, aligned=True)
print("\naligned=False:", a_false.flatten().tolist())
print("aligned=True :", a_true.flatten().tolist())
# Both produce a fixed-size output no matter how big the region is. That is the point.
big = torch.tensor([[0., 0., 0., 32., 32.]])
small = torch.tensor([[0., 8., 8., 12., 12.]])
for name, r in [("huge region", big), ("tiny region", small)]:
out = roi_align(feat, r, output_size=7, spatial_scale=0.25, sampling_ratio=2)
print(f"{name}: input box {[int(v) for v in r[0, 1:].tolist()]} -> output {tuple(out.shape)}")the feature map:
tensor([[ 0, 1, 2, 3, 4, 5, 6, 7],
[ 8, 9, 10, 11, 12, 13, 14, 15],
[16, 17, 18, 19, 20, 21, 22, 23],
[24, 25, 26, 27, 28, 29, 30, 31],
[32, 33, 34, 35, 36, 37, 38, 39],
[40, 41, 42, 43, 44, 45, 46, 47],
[48, 49, 50, 51, 52, 53, 54, 55],
[56, 57, 58, 59, 60, 61, 62, 63]], dtype=torch.int32)
roi_pool (rounds the box to whole cells, then takes the MAX per bin):
tensor([[27., 30.],
[51., 54.]])
roi_align (samples at exact positions, then takes the AVERAGE per bin):
tensor([[19.1250, 21.3750],
[37.1250, 39.3750]])
nudging the box right, one image pixel at a time:
x1= 4.0 roi_pool top-left= 27.000 roi_align top-left= 19.125
x1= 5.0 roi_pool top-left= 27.000 roi_align top-left= 19.375
x1= 6.0 roi_pool top-left= 28.000 roi_align top-left= 19.625
x1= 7.0 roi_pool top-left= 28.000 roi_align top-left= 19.875
x1= 8.0 roi_pool top-left= 28.000 roi_align top-left= 20.125
aligned=False: [19.125, 21.375, 37.125, 39.375]
aligned=True : [14.625, 16.875, 32.625, 34.875]
huge region: input box [0, 0, 32, 32] -> output (1, 1, 7, 7)
tiny region: input box [8, 8, 12, 12] -> output (1, 1, 7, 7)Reading that output
The two operators disagree by a lot, but not only because of rounding. roi_pool takes the maximum inside each bin. roi_align averages bilinearly-sampled points. Comparing 27 against 19.125 mixes two differences. The comparison that isolates the rounding is the sweep below it.
The sweep is the whole lesson. Move the box right by one image pixel at a time and roi_pool returns 27, 27, 28, 28, 28. A staircase. roi_align returns 19.125, 19.375, 19.625, 19.875, 20.125. A smooth ramp, moving 0.25 per image pixel, exactly the stride ratio.
roi_pool is blind to sub-cell movement. Two boxes four pixels apart in the image give the identical patch. The gradient with respect to box position is zero over that range. The regression head learns nothing about fine placement from those examples.
aligned=True changes every value. It shifts the sampling grid by half a pixel, because a pixel is an area rather than a point. This is the aligned fix that Detectron2 introduced and torchvision adopted; the default is False for backward compatibility. Set it to True in new code, and keep it consistent between training and inference. Mixing them costs a small, baffling amount of accuracy.
A 32-pixel region and a 4-pixel region both produced a 7x7 output. That is the contract. Whatever comes in, the head sees the same shape.
How it plugs into a real detector
from collections import OrderedDict
import torch
from torchvision.ops import MultiScaleRoIAlign
# The pooler used inside torchvision's Faster R-CNN. It picks the pyramid level per box.
pooler = MultiScaleRoIAlign(featmap_names=["0", "1", "2", "3"], output_size=7, sampling_ratio=2)
features = OrderedDict([
("0", torch.rand(1, 256, 160, 160)), # stride 4
("1", torch.rand(1, 256, 80, 80)), # stride 8
("2", torch.rand(1, 256, 40, 40)), # stride 16
("3", torch.rand(1, 256, 20, 20)), # stride 32
])
boxes = [torch.tensor([[10., 10., 30., 30.], # tiny -> a fine level
[10., 10., 300., 400.], # large -> a coarse level
[50., 50., 150., 170.]])] # medium
with torch.no_grad():
out = pooler(features, boxes, [(640, 640)])
print("pooled output:", tuple(out.shape))
print("one row per box, each 256 channels of 7x7")
print("flattened size fed to the box head:", out[0].numel())pooled output: (3, 256, 7, 7) one row per box, each 256 channels of 7x7 flattened size fed to the box head: 12544
MultiScaleRoIAlign handles the level assignment for you, using the FPN rule from feature pyramid networks. The boxes are in image coordinates; the pooler converts to feature coordinates per level from the image size you pass.
Arguments that matter
| Argument | Meaning | Advice |
|---|---|---|
spatial_scale | feature size divided by image size | Get this wrong and boxes land in the wrong place. It is 1/stride. |
output_size | the fixed grid, 7 for boxes, 14 for masks | Larger costs compute in the head, not in the pooling. |
sampling_ratio | points sampled per bin per axis | 2 is standard. -1 adapts to bin size. |
aligned | half-pixel shift | True for new code. Be consistent. |
Common mistakes
Passing spatial_scale=1.0 with a strided feature map. Boxes are then interpreted as feature coordinates, so everything gets pooled from the top-left corner. The model trains to a poor score and nothing errors.
Forgetting the batch index column. roi_align wants boxes as (batch_index, x1, y1, x2, y2), or a list of per-image tensors. Passing a plain (N, 4) tensor raises a shape error that does not mention the batch index.
Using roi_pool for mask heads. Mask R-CNN's gain over Faster R-CNN with masks comes largely from replacing pooling with align. Do not undo it.
Mixing aligned=True in training with aligned=False at inference. A silent half-pixel shift, worth a small amount of AP, and impossible to find by reading logs.
Pooling from the wrong pyramid level. If you write your own pooler, print the chosen level per box for a few examples. A level-assignment bug looks exactly like a weak model.
Try it yourself
Set sampling_ratio=1 and re-run the sweep. The ramp becomes coarser. Now set it to 4. Compare the three ramps and decide what sampling density buys you. Then see how much slower the call gets on a batch of 1,000 boxes.
What to learn next
- R-CNN, Fast R-CNN and Faster R-CNN — the detectors this operation was built for.
- Feature pyramid networks — which level each region gets cropped from.
- Image segmentation — where the half-pixel accuracy actually shows.
Researcher — Mathematics and papers.
RoI pooling
Given a region $(x_1, y_1, x_2, y_2)$ in image coordinates and a feature map at stride $s$, RoI pooling quantises twice:
$$ \tilde{x} = \left\lfloor \frac{x}{s} \right\rfloor, \qquad \text{bin}_{ij} = \left[ \tilde{x}_1 + \left\lfloor \frac{i \cdot \tilde{w}}{K} \right\rfloor,\; \tilde{x}_1 + \left\lceil \frac{(i+1) \cdot \tilde{w}}{K} \right\rceil \right) $$
Where $K$ is the output grid size and $\tilde w = \tilde x_2 - \tilde x_1$. Each bin is reduced by max. The first quantisation snaps the RoI to the feature grid; the second snaps the bin boundaries.
The first rounding misaligns each edge by up to $s/2$ pixels. The second adds up to $s$ per bin boundary. At $s = 16$ that is a substantial fraction of a small object.
Crucially, $\partial \text{output} / \partial x_1 = 0$ almost everywhere, because the floor function has zero derivative. RoI pooling gives no gradient with respect to box coordinates.
RoI align
He et al. (2017) remove both quantisations. Bin boundaries stay in continuous coordinates. $N$ sample points sit regularly inside each bin, and each is read by bilinear interpolation:
$$ f(x, y) = \sum_{i,j} f(x_i, y_j) \max(0, 1 - |x - x_i|) \max(0, 1 - |y - y_j|) $$
Bin values are the average over sample points. Bilinear interpolation is piecewise differentiable in $x$ and $y$, so gradients flow to box coordinates as well as to features. That property is what makes fully differentiable two-stage detectors and iterative box refinement possible.
The reported effect in the Mask R-CNN paper is large for masks and modest for boxes. It grows as the stride grows. The gap is widest on the coarse pyramid levels, which is where the quantisation error in pixels is largest.
The aligned half-pixel correction
Continuous coordinate $x$ maps to feature coordinate $x/s$. If pixel centres are taken to lie at half-integers, the correct mapping is $x/s - 0.5$. Omitting the shift biases every sample by half a feature cell.
Detectron2 exposed this as aligned=True; torchvision follows. The reported effect is a small but consistent AP gain, larger for masks. It costs nothing at runtime.
Alternatives
- RoI warp (Dai et al., 2016) uses a differentiable affine warp. It is close in spirit to align, formulated through a spatial transformer.
- PrRoI pooling (Jiang et al., 2018) integrates the bilinear surface over each bin in closed form. Averaging discrete samples is no longer needed. That removes the
sampling_ratiohyperparameter and gives continuous gradients. - Deformable RoI pooling (Dai et al., 2017) learns per-bin offsets, letting bins move off the rectangle to follow the object.
- Deformable attention (Zhu et al., 2021) generalises this: a query predicts a small set of sampling offsets and attention weights over multi-scale features. This is the mechanism that replaced RoI pooling entirely in the DETR line; see DETR and set prediction.
Cost
Per RoI: $O(C \cdot K^2 \cdot N)$ where $C$ is channels, $K$ the output grid and $N$ the samples per bin. With $C=256$, $K=7$, $N=4$, that is around 50,000 reads per RoI. At 512 RoIs per image in training, RoI align is a real fraction of the forward pass. It is memory-bandwidth-bound rather than compute-bound.
This cost is the structural reason one-stage detectors are faster: they never crop. The dense head is applied convolutionally to the whole map once.
Papers
- Girshick, Fast R-CNN, ICCV 2015 — arxiv.org/abs/1504.08083
- He et al., Mask R-CNN, ICCV 2017 — arxiv.org/abs/1703.06870
- Jiang et al., Acquisition of Localization Confidence for Accurate Object Detection (PrRoI pooling), ECCV 2018 — arxiv.org/abs/1807.11590
- Dai et al., Deformable Convolutional Networks, ICCV 2017 — arxiv.org/abs/1703.06211
What to learn next
- R-CNN, Fast R-CNN and Faster R-CNN — the detectors this operation was built for.
- Feature pyramid networks — which level each region gets cropped from.
- Image segmentation — where the half-pixel accuracy actually shows.