Object Detection in Depth

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.

On this page 9
  1. The short answer
  2. The analogy
  3. Why this step exists at all
  4. How it works
  5. The rounding problem
  6. The fix
  7. Where you have seen the result
  8. Remember this
  9. What to learn next

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

Beginner — No maths. Plain English.

The short answer

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

Developer — Code and libraries.

Setup

bash
pip install torch torchvision

Written against torch 2.5.1 and torchvision 0.20.1.

Watching the rounding happen, on numbers you can check by eye

roi.py
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)}")
Output
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

multiscale.py
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())
Output
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

ArgumentMeaningAdvice
spatial_scalefeature size divided by image sizeGet this wrong and boxes land in the wrong place. It is 1/stride.
output_sizethe fixed grid, 7 for boxes, 14 for masksLarger costs compute in the head, not in the pooling.
sampling_ratiopoints sampled per bin per axis2 is standard. -1 adapts to bin size.
alignedhalf-pixel shiftTrue 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

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_ratio hyperparameter 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

What to learn next