Feature Pyramid Networks: the maths, the architecture, and a ResNet-50 → FPN → YOLO detector

A detector has to find a 12-pixel drone and a 400-pixel truck in the same frame. A plain CNN gives you one feature map at stride 32, where the drone is a third of one cell. Feature Pyramid Networks (Lin, Dollár, Girshick, He, Hariharan and Belongie, CVPR 2017) fixed this with a small, almost entirely linear piece of network that turns any backbone’s internal hierarchy into a pyramid with strong features at every scale. Nearly every detector since, from Mask R-CNN and RetinaNet to the YOLO family, has some descendant of it as its neck.

This post goes through the paper’s construction and its maths, then builds a concrete detector: a ResNet-50 backbone, an FPN neck and a YOLO-style head, with every tensor shape written out and working torchvision code.

1. The scale problem and four ways to attack it

A convolutional backbone trades resolution for meaning. Early stages are high resolution but only know about edges and texture. Late stages know about objects but have been downsampled 32×. The paper frames the options with four sketches, redrawn here.

(a) Featurized image pyramidaccurate, but runs the CNN once per scale(b) Single feature mapfast, but small objects get lost(c) Pyramidal feature hierarchyfree multi-scale, weak low-level semantics(d) Feature Pyramid Networkfast and strong semantics at every scalepredictpredictpredictimage at 3 scalespredictimagepredictpredictpredictpredictimagepredictbottom-uptop-down
Figure 1. Four ways to get multi-scale features. Darker blue = semantically stronger features. Redrawn after Fig. 1 of the FPN paper.
  • (a) Image pyramid. Resize the image to several scales and run the whole network on each. Every level gets strong features, but inference cost multiplies by the number of scales, and training this way is usually impractical.
  • (b) Single feature map. Predict only from the last stage (classic Faster R-CNN, YOLOv1). Fast, but small objects are smeared into a single coarse cell.
  • (c) Pyramidal feature hierarchy. Predict from several stages of the backbone (SSD). The multi-scale maps are free, but the high-resolution ones come from shallow layers with weak semantics, so they do not help much with small objects. SSD actually starts from a fairly deep layer for this reason.
  • (d) FPN. Keep (c)’s free hierarchy, then add a top-down pathway that pushes the semantics of the deep layers back down into the high-resolution ones, and predict from every level of the result.

2. The construction, as equations

2.1 Bottom-up pathway

This is just the backbone’s forward pass. ResNet has stages whose outputs halve in resolution. FPN takes the last residual block of stages conv2 to conv5 and calls them \(C_2, C_3, C_4, C_5\). With \(\ell\) the level index, level \(\ell\) has stride \(2^\ell\) relative to the input:

\[ C_\ell \in \mathbb{R}^{c_\ell \times \frac{H}{2^\ell} \times \frac{W}{2^\ell}}, \qquad (c_2, c_3, c_4, c_5) = (256, 512, 1024, 2048)\ \text{for ResNet-50.} \]

The stem output (conv1, stride 2) is left out because its memory footprint is large and its features are too low-level to be worth it.

2.2 Lateral connections

Each \(C_\ell\) has a different channel count, so it is first projected to a common width \(d\) (256 in the paper) with a \(1\times1\) convolution. A \(1\times1\) convolution is a matrix applied independently at every pixel:

\[ (W_\ell * C_\ell)[:, i, j] = W_\ell\, C_\ell[:, i, j] + b_\ell, \qquad W_\ell \in \mathbb{R}^{d \times c_\ell}. \]

It does not mix spatial positions. Its only job is to put every level in the same \(d\)-dimensional space so the levels can be added together.

2.3 Top-down pathway

Start from the coarsest level and walk down. Writing \(U\) for 2× nearest-neighbour upsampling and \(K_\ell\) for a \(3\times3\) convolution:

\[ M_5 = W_5 * C_5, \qquad M_\ell = U(M_{\ell+1}) + W_\ell * C_\ell \quad (\ell = 4, 3, 2), \qquad P_\ell = K_\ell * M_\ell. \]
One top-down merge step (shown for level 3, 640×640 input, d = 256)M₄ (coarser merged map)256 × 40 × 40C₃ (ResNet layer2 out)512 × 80 × 80Upsample 2× (nearest)256 × 80 × 80Lateral 1×1 conv512 → 256: 256 × 80 × 80+element-wise addM₃3×3 conv, 256anti-aliasingP₃ → head256 × 80 × 80M₃ is what gets upsampled into the next level (P₂), not P₃M₃ = Up₂(M₄) + W₃ ∗ C₃P₃ = K₃ ∗ M₃(no ReLU or normalisation in the original neck)
Figure 2. One top-down merge step. The lateral 1×1 conv aligns channels, nearest upsampling aligns resolution, and a 3×3 conv cleans up the sum.

Two details are easy to get wrong when reimplementing. First, the map that is upsampled into the next level is \(M_{\ell+1}\), the sum before the \(3\times3\) conv, not the output \(P_{\ell+1}\). torchvision’s implementation does exactly this (its last_inner variable). Second, there are no non-linearities anywhere in these extra layers. The authors report that adding them made little difference, and the neck is a purely linear map of the backbone features.

2.4 Nearest-neighbour upsampling as an operator

Nearest-neighbour 2× upsampling copies each coarse value into a \(2\times2\) block:

\[ U(M)[i, j] = M\big[\lfloor i/2 \rfloor, \lfloor j/2 \rfloor\big], \qquad U(M) = M \otimes \mathbf{1}_{2\times 2}. \]
Nearest-neighbour 2× upsampling, then add the lateral projection (one channel shown)M₄ (2×2)Up₂(M₄): each value copied to a 2×2 blockM₃W₃ ∗ C₃ (lateral)Up₂1234112211223344334401011010002001001223213233643444+=3×3 conv K₃smooths the 2×2block edgesUp₂(M)[i, j] = M[⌊i/2⌋, ⌊j/2⌋] — no learned weights, so its gradient simply sums each 2×2 block back onto one coarse cell.The 3×3 conv after the add is what the paper calls reducing the aliasing effect of upsampling.
Figure 4. Nearest-neighbour upsampling and the lateral add on a single channel.

It has no parameters. Its adjoint \(U^\top\) sums each \(2\times2\) block of a fine gradient back onto one coarse cell, which is how gradients travel up the top-down path during training. The blocky copy is also why the \(3\times3\) conv \(K_\ell\) exists: the paper uses it to reduce the aliasing that upsampling introduces before the map is used for prediction.

2.5 Unrolling the recursion

Because every operation in the neck is linear, the recursion can be unrolled into a closed form:

\[ P_\ell = K_\ell * \sum_{m=\ell}^{5} U^{\,m-\ell}\big(W_m * C_m\big). \]

Each output level is a smoothed sum of its own lateral projection and the projections of every deeper level, upsampled to its resolution. \(P_2\) contains information from all four backbone stages; \(P_5\) only from \(C_5\). That is the precise sense in which FPN gives high-resolution levels strong semantics.

The gradient view is just as useful. A loss on a fine level reaches every deeper stage:

\[ \frac{\partial \mathcal{L}}{\partial (W_m * C_m)} = \sum_{\ell \le m} \big(U^{\top}\big)^{m-\ell} K_\ell^{\top} \, \frac{\partial \mathcal{L}}{\partial P_\ell}. \]

So \(C_5\) is trained by the small-object losses on \(P_2\) as well as by the large-object losses on \(P_5\). In a plain pyramidal hierarchy (option c) it would only see the latter.

2.6 Why every level has the same width

Fixing \(d=256\) at every level means one prediction head can be shared across the whole pyramid. The paper does this for both the RPN and the Fast R-CNN head, and found that separate per-level heads performed about the same. A shared head means the same weights must recognise a car at stride 8 and stride 32, which is the feature-pyramid version of the scale invariance an image pyramid gives you.

2.7 Assigning objects to levels

An object of size \(s\) pixels covers \(s / 2^\ell\) cells at level \(\ell\). To keep that footprint roughly constant, the level should grow with \(\log_2 s\). The paper uses this rule in two places.

RPN anchors. One anchor area per level: \(32^2, 64^2, 128^2, 256^2, 512^2\) on \(P_2\) to \(P_6\), each with aspect ratios 1:2, 1:1 and 2:1, for 15 anchor types. The anchor side is always 8 × the stride. \(P_6\) is a stride-2 subsampling of \(P_5\) and exists only to host the \(512^2\) anchors.

RoI pooling. A region of width \(w\) and height \(h\) (in input pixels) is pooled from level

\[ k = \Big\lfloor k_0 + \log_2\!\big(\sqrt{wh} / 224\big) \Big\rfloor, \qquad k_0 = 4, \]

clamped to \([2, 5]\). The constants come from ImageNet: a 224-pixel region is the canonical pre-training size and maps to \(C_4\), which the single-scale ResNet Faster R-CNN baseline also used. Halve the region and it drops one level.

P2 (stride 4)P3 (stride 8)P4 (stride 16)P5 (stride 32)Which level pools an RoI: k = ⌊k₀ + log₂(√(wh) / 224)⌋, k₀ = 4, clamped to [2, 5]16326411222444810242345RoI size √(wh) in pixels (log scale)pyramid level kbefore floor and clamp
Figure 5. The paper’s RoI-to-level rule. Each halving of the RoI size moves it one level down the pyramid.

Some numbers: \(\sqrt{wh} = 224 \rightarrow P_4\), \(112 \rightarrow P_3\), \(448 \rightarrow P_5\), and a 50-pixel box gives \(\lfloor 4 – 2.16 \rfloor = 1\), clamped to \(P_2\).

3. What the paper measured

All ablations below are ResNet-50 on COCO minival. RPN proposals are scored by average recall at 1000 proposals (AR1k) and on small objects (ARs).

RPN variant AR1k ARs
Single scale, C4 (baseline) 48.3 32.0
Single scale, C5 44.9 25.3
FPN (P2–P6) 56.3 44.9
Bottom-up pyramid only (no top-down) 49.5 30.5
Top-down, no lateral connections 46.1 26.5
Only the finest level, P2 51.3 35.1

Each ablation removes one idea and loses most of the gain. Without the top-down path, the fine levels lack semantics. Without the laterals, the top-down maps are semantically strong but their locations have been through many rounds of down- and upsampling and are imprecise. Using only \(P_2\) (which still benefits from top-down information) recovers part of the gain, but predicting at every level is better and far cheaper in anchors.

In the full Faster R-CNN, FPN raised COCO minival AP from 31.6 to 33.9 against a strong single-scale baseline with the same backbone, and AP@0.5 from 53.1 to 56.9. With ResNet-101 the single model reached 36.2 AP on test-dev (18.2 on small objects), ahead of every single-model entry the paper compared against, including the COCO 2016 challenge winners, without extra bells and whistles. It was also faster: about 0.15 s per image on an M40 versus 0.32 s for the single-scale baseline, partly because the RoI head became much lighter.

4. Every tensor shape, ResNet-50 + FPN at 640 × 640

ResNet-50 + FPN: every tensor shape for a 640 × 640 input (batch dims omitted)Bottom-up (backbone)Lateral 1×1 (Wₗ)Top-down + output 3×3 (Kₗ)Pyramid outputsC₅ · layer42048 × 20 × 20stride 321×1 conv 2048→256256 × 20 × 20=M₅3×3 conv 256pad 1, stride 1P₅256 × 20 × 202× nearestC₄ · layer31024 × 40 × 40stride 161×1 conv 1024→256256 × 40 × 40+M₄3×3 conv 256pad 1, stride 1P₄256 × 40 × 402× nearestC₃ · layer2512 × 80 × 80stride 81×1 conv 512→256256 × 80 × 80+M₃3×3 conv 256pad 1, stride 1P₃256 × 80 × 802× nearestC₂ · layer1256 × 160 × 160stride 41×1 conv 256→256256 × 160 × 160+M₂3×3 conv 256pad 1, stride 1P₂256 × 160 × 160Stem: conv7×7 s2 + maxpool s264 × 160 × 160Input image3 × 640 × 640P₆ “pool”: maxpool k1 s2256 × 10 × 10Parameters (d = 256): laterals 0.98 M, 3×3 convs 2.36 M → 3.34 M total for P₂–P₅ResNet-50 trunk (no fc): ≈ 23.5 M. The neck is cheap in weights……but not in compute: P₂ alone costs 16.8 GMAC, vs 6.4 GMAC for P₃–P₅ together.Upsampling uses F.interpolate(size=C_l.shape[-2:]) so odd sizes align: at 610 px, C₄ is 39×39 but 2×C₅ would be 40×40.
Figure 3. ResNet-50 + FPN with every tensor shape for a 640×640 input.
Level Backbone output Lateral 1×1 FPN output Stride
2 C2 = layer1: 256×160×160 256→256 P2: 256×160×160 4
3 C3 = layer2: 512×80×80 512→256 P3: 256×80×80 8
4 C4 = layer3: 1024×40×40 1024→256 P4: 256×40×40 16
5 C5 = layer4: 2048×20×20 2048→256 P5: 256×20×20 32
6 — — P6 = maxpool(P5): 256×10×10 64

The neck is light in parameters (3.34 M for P2–P5, against roughly 23.5 M for the ResNet-50 trunk) but not in compute, and the compute is concentrated at the finest level:

FPN neck compute per level at 640×640 (d = 256): P₂ costs 2.6× all of P₃–P₅ together05101516.8 GMACP₂160×160 cells4.6 GMACP₃80×80 cells1.4 GMACP₄40×40 cells0.5 GMACP₅20×20 cellsGMAC (multiply–accumulates)lateral 1×1 (Cₗ → 256)output 3×3 (256 → 256)
Figure 8. Neck compute per level. The 3×3 output convs dominate and scale with the number of cells.

The \(3\times3\) output convs dominate, and they scale with the number of cells. \(P_2\) has 16× the cells of \(P_4\), so it costs 16.8 GMAC on its own, 2.6× the 6.4 GMAC of \(P_3\) to \(P_5\) together. On an edge device the feature maps themselves are the bigger problem: \(P_2\) in FP16 is 13 MB per image before the head touches it, and memory bandwidth, not TOPS, is usually the limit. This is the main reason real-time detectors stop at stride 8.

One implementation detail: torchvision upsamples with F.interpolate(..., size=C_l.shape[-2:], mode="nearest") rather than a fixed factor of 2. With an input that is not a multiple of 32, say 610 pixels, \(C_4\) is 39×39 while \(2\times\) \(C_5\) would be 40×40, and the add would fail. Upsampling to the lateral’s exact size avoids that.

5. FPN as a neck

Modern detectors are described as three parts:

  • Backbone: turns pixels into a feature hierarchy \(C_3, C_4, C_5\). ResNet, CSPDarknet, ConvNeXt, a ViT with an adapter, DINOv2.
  • Neck: mixes the hierarchy into a pyramid \(P_3, P_4, P_5\) with equal width. FPN is the original; PANet adds a second, bottom-up path after FPN’s top-down one; BiFPN (EfficientDet) repeats that with learned weighted sums. YOLOv3 used an FPN-style top-down neck with concatenation instead of addition, and YOLOv4 onward use FPN + PAN.
  • Head: predicts boxes and classes on every pyramid level. Two-stage (RPN + RoI head), or dense single-stage (RetinaNet, FCOS, YOLO).

Because FPN’s contract is simple, a dict of maps in and a dict of 256-channel maps out, the three parts can be swapped independently. That is what the rest of this post does.

6. ResNet-50 → FPN → YOLO head

Here is a complete single-stage detector. It uses only \(P_3\) to \(P_5\), like the YOLO family, and an anchor-free decoupled head in the style of YOLOX and YOLOv8 (simplified: plain box regression rather than YOLOv8’s distribution focal loss).

ResNet-50 backbone → FPN neck → YOLO-style head (640 × 640 input, 80 COCO classes)BACKBONENECKHEAD (one per level)Image3×640×640stem + layer1256×160×160layer2 → C₃512×80×80P₃ stride 8256×80×80Decoupledhead85×80×806400 cellslayer3 → C₄1024×40×40P₄ stride 16256×40×40Decoupledhead85×40×401600 cellslayer4 → C₅2048×20×20P₅ stride 32256×20×20Decoupledhead85×20×20400 cellstop-downflatten + concatB × 8400 × 85(4 box + 1 obj + 80 cls)decode + batched NMSboxes, scores, labelsWhy P₃–P₅ and not P₂? At 640 px, P₂ would add 25,600 cells and ≈16.8 GMAC in the neck alone (Fig. 8). YOLO-family detectors stop at stride 8.8400 = 80² + 40² + 20² prediction points; at 1280 px it becomes 33,600 (4×), which is why input size dominates post-processing cost on edge devices.
Figure 6. The full detector: ResNet-50 backbone, FPN neck on P3–P5, one decoupled YOLO-style head per level.

The output count is worth committing to memory: at 640 × 640, the head produces one prediction per cell of each level, \(80^2 + 40^2 + 20^2 = 8400\) in total, each with \(4 + 1 + 80 = 85\) numbers for COCO. At 1280 × 1280 that becomes 33,600, which is why input size dominates NMS and post-processing time on edge hardware.

6.1 The head and the box decoding

Inside one YOLO-style head, and how a cell becomes a boxPₗ256×H×Wcls branch2 × [3×3 conv · BN · SiLU]reg branch2 × [3×3 conv · BN · SiLU]1×1 → C80×H×W1×1 → 4l,t,r,b · 4×H×W1×1 → 1objectness 1×H×Wconcat85×H×WClassification and localisation want different features — the decoupled split(YOLOX onward) trains faster and scores higher than one shared 1×1 output.lrtbgrid of Pₗ, one cell = s pixelscentre of cell (i, j): cx = (j + ½)·s, cy = (i + ½)·sdistances: (l, t, r, b) = s · exp(raw₀₋₃)box: (cx − l, cy − t, cx + r, cy + b)score: σ(obj) · σ(clsₖ)
Figure 7. Left: the decoupled head. Right: decoding one cell into a box from its predicted distances l, t, r, b.

For a cell \((i, j)\) at level stride \(s\), the head predicts four raw distances \(\hat{t}\), an objectness logit \(\hat{o}\) and class logits \(\hat{z}_k\). The cell centre and the box are

\[ c_x = (j + \tfrac12)\,s,\quad c_y = (i + \tfrac12)\,s,\qquad (l, t, r, b) = s \cdot e^{\hat{t}}, \]
\[ \text{box} = (c_x – l,\; c_y – t,\; c_x + r,\; c_y + b), \qquad \text{score}_k = \sigma(\hat{o})\,\sigma(\hat{z}_k). \]

Multiplying by the stride is the dense-head version of the FPN level rule: the same raw output means a box twice as big one level up, so the head can share its sense of “object-sized” across levels. The classification and objectness biases are initialised to \(b = -\log\frac{1-\pi}{\pi} \approx -4.6\) with \(\pi = 0.01\), so every cell starts by predicting “background with 1% confidence” and the loss is not swamped by thousands of easy negatives in the first iterations (the trick from RetinaNet).

7. Code with the torchvision FPN API

torchvision gives you the neck as torchvision.ops.FeaturePyramidNetwork, plus helpers that wire it to a ResNet. Two ways to build the backbone + neck, then the full detector.

7.1 Explicit: create_feature_extractor + FeaturePyramidNetwork

import torch
from torchvision.models import resnet50, ResNet50_Weights
from torchvision.models.feature_extraction import create_feature_extractor
from torchvision.ops import FeaturePyramidNetwork

# 1) Backbone: pull C3, C4, C5 out of a stock ResNet-50
backbone = resnet50(weights=ResNet50_Weights.IMAGENET1K_V2)
body = create_feature_extractor(
    backbone,
    return_nodes={"layer2": "p3", "layer3": "p4", "layer4": "p5"},  # keys we want downstream
)

# 2) Neck: in_channels must match the backbone, ordered fine -> coarse
fpn = FeaturePyramidNetwork(in_channels_list=[512, 1024, 2048], out_channels=256)

x = torch.randn(2, 3, 640, 640)
feats = body(x)                 # dict: p3, p4, p5 (still C3, C4, C5 here)
for k, v in feats.items():
    print("C", k, tuple(v.shape))
# C p3 (2, 512, 80, 80)
# C p4 (2, 1024, 40, 40)
# C p5 (2, 2048, 20, 20)

pyramid = fpn(feats)            # OrderedDict with the same keys
for k, v in pyramid.items():
    print("P", k, tuple(v.shape))
# P p3 (2, 256, 80, 80)
# P p4 (2, 256, 40, 40)
# P p5 (2, 256, 20, 20)

FeaturePyramidNetwork(in_channels_list, out_channels, extra_blocks=None, norm_layer=None) builds one \(1\times1\) inner block and one \(3\times3\) layer block per input level, and its forward takes a dict ordered from the finest map to the coarsest and returns an OrderedDict with the same keys. Pass norm_layer=nn.BatchNorm2d if you want normalised neck convs (the paper’s neck has none), or extra_blocks=LastLevelMaxPool() / LastLevelP6P7(...) for the P6 or RetinaNet-style P6–P7 levels.

7.2 One call: resnet_fpn_backbone

import torch
from torchvision.models import ResNet50_Weights
from torchvision.models.detection.backbone_utils import resnet_fpn_backbone

bb = resnet_fpn_backbone(
    backbone_name="resnet50",
    weights=ResNet50_Weights.IMAGENET1K_V2,
    trainable_layers=5,          # 0..5 stages unfrozen; default 3
    returned_layers=[2, 3, 4],   # layer2..layer4 -> C3..C5 (default [1, 2, 3, 4])
)
print(bb.out_channels)           # 256

out = bb(torch.randn(2, 3, 640, 640))
for k, v in out.items():
    print(k, tuple(v.shape))
# 0    (2, 256, 80, 80)   P3
# 1    (2, 256, 40, 40)   P4
# 2    (2, 256, 20, 20)   P5
# pool (2, 256, 10, 10)   P6, added by the default LastLevelMaxPool

Three defaults to know about. The backbone uses FrozenBatchNorm2d (fine for fine-tuning with small batches; pass norm_layer=nn.BatchNorm2d to train BN). Only the last 3 stages are trainable unless you change trainable_layers. And extra_blocks=None here means “use the default”, which is LastLevelMaxPool, so the output always has a "pool" P6 entry and the keys are the strings "0", "1", "2", not the layer names. The returned BackboneWithFPN has an out_channels attribute, which is what torchvision’s own FasterRCNN, RetinaNet and FCOS constructors read.

7.3 The full detector: backbone, FPN neck, YOLO head, decoding

import math
import torch
import torch.nn as nn
from torchvision.models import resnet50, ResNet50_Weights
from torchvision.models.feature_extraction import create_feature_extractor
from torchvision.ops import FeaturePyramidNetwork, Conv2dNormActivation, batched_nms


def conv_bn_silu(c_in, c_out):
    return Conv2dNormActivation(c_in, c_out, kernel_size=3, activation_layer=nn.SiLU)


class YOLOHead(nn.Module):
    """Anchor-free, decoupled YOLO-style head (YOLOX / YOLOv8 family, simplified).

    One head per pyramid level. Output per level: [B, 4 + 1 + num_classes, H, W]
    channels = (l, t, r, b) distances, objectness, class logits.
    """

    def __init__(self, num_classes=80, in_ch=256, width=256, num_levels=3, prior=0.01):
        super().__init__()
        self.nc = num_classes
        self.cls_convs = nn.ModuleList()
        self.reg_convs = nn.ModuleList()
        self.cls_pred = nn.ModuleList()
        self.box_pred = nn.ModuleList()
        self.obj_pred = nn.ModuleList()
        for _ in range(num_levels):
            self.cls_convs.append(nn.Sequential(conv_bn_silu(in_ch, width), conv_bn_silu(width, width)))
            self.reg_convs.append(nn.Sequential(conv_bn_silu(in_ch, width), conv_bn_silu(width, width)))
            self.cls_pred.append(nn.Conv2d(width, num_classes, 1))
            self.box_pred.append(nn.Conv2d(width, 4, 1))
            self.obj_pred.append(nn.Conv2d(width, 1, 1))
        # start every cell at p = 0.01 so early training is not swamped by easy negatives
        b = -math.log((1 - prior) / prior)
        for m in list(self.cls_pred) + list(self.obj_pred):
            nn.init.constant_(m.bias, b)

    def forward(self, feats):
        outs = []
        for i, f in enumerate(feats):
            c = self.cls_convs[i](f)
            r = self.reg_convs[i](f)
            outs.append(torch.cat([self.box_pred[i](r), self.obj_pred[i](r), self.cls_pred[i](c)], dim=1))
        return outs


class ResNet50FPNYOLO(nn.Module):
    strides = (8, 16, 32)

    def __init__(self, num_classes=80):
        super().__init__()
        self.backbone = create_feature_extractor(
            resnet50(weights=ResNet50_Weights.IMAGENET1K_V2),
            return_nodes={"layer2": "c3", "layer3": "c4", "layer4": "c5"},
        )
        self.neck = FeaturePyramidNetwork([512, 1024, 2048], 256)
        self.head = YOLOHead(num_classes, num_levels=3)

    def forward(self, x):
        c = self.backbone(x)            # c3, c4, c5
        p = self.neck(c)                # p3, p4, p5 (same keys, 256 channels each)
        return self.head(list(p.values()))


@torch.no_grad()
def decode(outs, strides=(8, 16, 32), conf_thr=0.25, iou_thr=0.65, max_det=300):
    boxes, scores = [], []
    for out, s in zip(outs, strides):
        B, C, H, W = out.shape
        out = out.permute(0, 2, 3, 1).reshape(B, H * W, C)              # [B, HW, 5+nc]
        ys, xs = torch.meshgrid(torch.arange(H, device=out.device),
                                torch.arange(W, device=out.device), indexing="ij")
        ctr = torch.stack([(xs + 0.5) * s, (ys + 0.5) * s], -1).reshape(1, H * W, 2).to(out.dtype)
        ltrb = out[..., :4].clamp(-10, 10).exp() * s                    # distances in pixels
        boxes.append(torch.cat([ctr - ltrb[..., :2], ctr + ltrb[..., 2:]], -1))
        scores.append(out[..., 4:5].sigmoid() * out[..., 5:].sigmoid())
    boxes = torch.cat(boxes, 1)                                          # [B, 8400, 4]
    scores = torch.cat(scores, 1)                                        # [B, 8400, nc]

    results = []
    for b in range(boxes.shape[0]):
        conf, cls = scores[b].max(-1)
        keep = conf > conf_thr
        bx, conf, cls = boxes[b][keep], conf[keep], cls[keep]
        k = batched_nms(bx, conf, cls, iou_thr)[:max_det]
        results.append({"boxes": bx[k], "scores": conf[k], "labels": cls[k]})
    return results


if __name__ == "__main__":
    model = ResNet50FPNYOLO(num_classes=80).eval()
    x = torch.randn(2, 3, 640, 640)
    outs = model(x)
    for o in outs:
        print(tuple(o.shape))
    # (2, 85, 80, 80)
    # (2, 85, 40, 40)
    # (2, 85, 20, 20)
    dets = decode(outs)
    print(len(dets), dets[0]["boxes"].shape)   # 2, (N, 4)
    print(sum(p.numel() for p in model.parameters()) / 1e6, "M params")

The shapes follow the diagrams exactly: [2, 512, 80, 80], [2, 1024, 40, 40] and [2, 2048, 20, 20] out of the backbone, three [2, 256, H, W] maps out of the neck, three [2, 85, H, W] maps out of the head, and [2, 8400, 4] boxes with [2, 8400, 80] class scores before NMS. By the parameter arithmetic, the model is about 33 M parameters: 23.5 M backbone, 2.7 M neck (P3–P5) and 7.1 M in the three heads.

To train it you need two more pieces, both standard: a label assignment rule that decides which cells are responsible for each ground-truth box (centre sampling as in FCOS, SimOTA as in YOLOX, or task-aligned assignment as in YOLOv8), and a loss: BCE on objectness and classes, plus an IoU-family loss on the decoded boxes (torchvision.ops.generalized_box_iou_loss or complete_box_iou_loss). Remember the backbone expects ImageNet normalisation (Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])).

8. Practical notes for deployment

  • Keep input sides a multiple of 32. The size-based upsampling makes other sizes work, but they waste compute on padding-like edge cells and complicate engine building.
  • The neck exports cleanly. Nearest upsampling becomes an ONNX Resize op with mode="nearest", and the adds and convs fuse well in TensorRT. Export at a fixed resolution to get a static engine.
  • Choose levels by your object sizes. For small, distant targets you may want \(P_2\), but budget for it: it is the most expensive level in the network, in compute and in bandwidth. A common compromise is a light \(P_2\) head with fewer channels.
  • Count your outputs. The number of pre-NMS predictions, not the backbone, often sets the latency floor at large input sizes.

References

  • T.-Y. Lin, P. Dollár, R. Girshick, K. He, B. Hariharan, S. Belongie. Feature Pyramid Networks for Object Detection. CVPR 2017.
  • S. Liu et al. Path Aggregation Network for Instance Segmentation (PANet). CVPR 2018.
  • M. Tan, R. Pang, Q. Le. EfficientDet: Scalable and Efficient Object Detection (BiFPN). CVPR 2020.
  • Z. Ge et al. YOLOX: Exceeding YOLO Series in 2021. arXiv 2021.
  • T.-Y. Lin et al. Focal Loss for Dense Object Detection (RetinaNet). ICCV 2017.
  • torchvision: torchvision.ops.FeaturePyramidNetwork and torchvision.models.detection.backbone_utils.