Deep dive
How it works
Parts:
- backbone — CNN or ViT giving features at strides 8, 16, 32
- neck — feature pyramid (FPN): top-down path with lateral links, so every scale carries semantics
- head — per cell: K class sigmoids and a box (anchor offsets or l, t, r, b distances)
- label assigner — training only: which cells are positives for which object
- loss — classification (focal) + box regression (L1 + GIoU)
- post-processing — score threshold, class-wise NMS, top-k
Inference:
- Letterbox-resize to the input size; normalise.
- Backbone, neck, head: per level,
cls[cell][K]and a box per cell. - Drop scores below the threshold before building boxes.
- Decode: anchor-based
cx = ax + dx·aw,w = aw·exp(dw); anchor-freex1 = px − l … y2 = py + b. - Class-wise NMS (IoU about 0.5-0.65), cap at 100 per image, map back to the original frame.
image ─▶ backbone ─▶ neck (P3 P4 P5) ─▶ head ─▶ cls + box per cell ─▶ threshold ─▶ NMS ─▶ detections
training: ground truth ─▶ label assignment ─▶ loss on head output
Families.
- Two-stage anchor-based. Faster R-CNN: a Region Proposal Network scores anchors for objectness (300 proposals per image), then a second head classifies and refines each.
- One-stage anchor-based. SSD predicts per default box on several maps; RetinaNet adds an FPN and focal loss
−α(1−p)^γ·log p(γ = 2, from memory), which down-weights the many easy background cells. YOLO regresses directly from a grid. - Anchor-free. FCOS regresses l, t, r, b per cell plus a centre-ness score; CenterNet predicts a centre heat map plus size and picks peaks with a max-pool instead of NMS.
- Set prediction. DETR: N learned queries become N (box, class) pairs; training matches them to objects one-to-one by Hungarian algorithm (assignment, bipartite matching), so duplicates are never rewarded and no NMS is needed. Deformable DETR needs about 10× fewer epochs.
- Open vocabulary. OWL-ViT, Grounding DINO: class names are text input.
Label assignment. Anchor-based: positive above an IoU threshold (e.g. 0.5-0.7), negative below a lower one, ignored between. Anchor-free: positive if the cell centre lies inside the box; inside several, FCOS picks the smallest. Hungarian: one prediction per object.
Box regression. Log-scale offsets are size-invariant. IoU losses optimise the metric itself; GIoU fixes IoU's zero gradient for disjoint boxes.
Evaluation. COCO AP: per class, rank by score, mark true positives at an IoU threshold (each ground truth matches once), integrate precision over recall (101 recall points and the area ranges are in pycocotools cocoeval.py), average over classes and IoU 0.50, 0.55 … 0.95. Small, medium, large: area < 32², 32²-96², > 96² pixels.
Code
Anchor-free decoding, class-wise NMS and AP@0.5 (all-point), std only. Compiled and run (rustc 1.99.1).
#[derive(Clone, Copy)]
struct B { x1: f32, y1: f32, x2: f32, y2: f32 }
#[derive(Clone, Copy)]
struct Det { b: B, score: f32, cls: usize }
fn area(b: &B) -> f32 { (b.x2 - b.x1) * (b.y2 - b.y1) }
fn iou(a: &B, b: &B) -> f32 {
let w = (a.x2.min(b.x2) - a.x1.max(b.x1)).max(0.0);
let h = (a.y2.min(b.y2) - a.y1.max(b.y1)).max(0.0);
w * h / (area(a) + area(b) - w * h + 1e-9)
}
/// Cell-major head output: cls[cell*nc + c] = sigmoid score; ltrb[cell*4..] = pixel
/// distances from the cell centre to the left, top, right, bottom sides.
struct Head { w: usize, h: usize, nc: usize, stride: f32, cls: Vec<f32>, ltrb: Vec<f32> }
fn centre(hd: &Head, cell: usize) -> (f32, f32) {
(((cell % hd.w) as f32 + 0.5) * hd.stride, ((cell / hd.w) as f32 + 0.5) * hd.stride)
}
fn decode(hd: &Head, thr: f32) -> Vec<Det> {
let mut out = Vec::new();
for cell in 0..hd.w * hd.h {
let (cx, cy) = centre(hd, cell);
let d = &hd.ltrb[cell * 4..cell * 4 + 4];
for (c, &s) in hd.cls[cell * hd.nc..(cell + 1) * hd.nc].iter().enumerate() {
if s > thr { // threshold before building a box: most cells are background
out.push(Det { b: B { x1: cx - d[0], y1: cy - d[1], x2: cx + d[2], y2: cy + d[3] }, score: s, cls: c });
}
}
}
out
}
/// Class-wise greedy NMS: a box only suppresses boxes of its own class.
fn nms(mut v: Vec<Det>, thr: f32) -> Vec<Det> {
v.sort_by(|a, b| b.score.total_cmp(&a.score));
let mut kept: Vec<Det> = Vec::new();
for d in v {
if kept.iter().all(|k| k.cls != d.cls || iou(&k.b, &d.b) <= thr) { kept.push(d); }
}
kept
}
/// AP of one class at one IoU threshold, all-point interpolation.
/// preds: (image, box, score); gts[i] = ground-truth boxes of image i.
fn ap(mut preds: Vec<(usize, B, f32)>, gts: &[Vec<B>], thr: f32) -> f32 {
let n_gt = gts.iter().map(|g| g.len()).sum::<usize>() as f32;
preds.sort_by(|a, b| b.2.total_cmp(&a.2));
let mut used: Vec<Vec<bool>> = gts.iter().map(|g| vec![false; g.len()]).collect();
let (mut tp, mut n) = (0.0, 0.0);
let mut pr = Vec::new(); // (recall, precision) after each prediction
for (i, b, _) in &preds {
let best = (0..gts[*i].len()).filter(|&j| !used[*i][j])
.map(|j| (j, iou(b, >s[*i][j]))).max_by(|a, b| a.1.total_cmp(&b.1));
match best { // a gt matches once; duplicates count as false positives
Some((j, v)) if v >= thr => { used[*i][j] = true; tp += 1.0 }
_ => {}
}
n += 1.0;
pr.push((tp / n_gt, tp / n));
}
for i in (0..pr.len().saturating_sub(1)).rev() { pr[i].1 = pr[i].1.max(pr[i + 1].1); } // envelope
let mut r0 = 0.0;
pr.iter().map(|&(r, p)| { let a = (r - r0) * p; r0 = r; a }).sum()
}
/// Synthetic head: a cell inside several planted boxes belongs to the smallest (FCOS rule).
fn plant(objs: &[(usize, B)], nc: usize) -> Head {
let (w, h) = (16, 16);
let mut hd = Head { w, h, nc, stride: 8.0, cls: vec![0.01; w * h * nc], ltrb: vec![4.0; w * h * 4] };
for cell in 0..w * h {
let (cx, cy) = centre(&hd, cell);
let own = objs.iter().filter(|(_, b)| cx > b.x1 && cx < b.x2 && cy > b.y1 && cy < b.y2)
.min_by(|a, b| area(&a.1).total_cmp(&area(&b.1)));
if let Some((c, b)) = own {
let (l, t, r, bt) = (cx - b.x1, cy - b.y1, b.x2 - cx, b.y2 - cy);
hd.cls[cell * nc + c] = 0.3 + 0.6 * (l.min(r) / l.max(r)) * (t.min(bt) / t.max(bt)); // centre-ness
hd.ltrb[cell * 4..cell * 4 + 4].copy_from_slice(&[l, t, r, bt]);
}
}
hd
}
fn main() {
let bx = |x1, y1, x2, y2| B { x1, y1, x2, y2 };
// image 0: class-0 box + overlapping class-1 box; image 1: one class-0 box
let imgs = [vec![(0, bx(10., 10., 60., 50.)), (1, bx(30., 20., 90., 70.))], vec![(0, bx(40., 40., 100., 110.))]];
let (mut preds, mut gts0) = (Vec::new(), Vec::new());
for (i, objs) in imgs.iter().enumerate() {
let dets = nms(decode(&plant(objs, 3), 0.3), 0.5);
assert_eq!(dets.len(), objs.len()); // one box per object
for (c, g) in objs { // the centre cell regresses its box exactly
assert!(iou(&dets.iter().find(|d| d.cls == *c).unwrap().b, g) > 0.95);
}
gts0.push(objs.iter().filter(|o| o.0 == 0).map(|o| o.1).collect::<Vec<_>>());
preds.extend(dets.iter().filter(|d| d.cls == 0).map(|d| (i, d.b, d.score)));
}
assert!((ap(preds.clone(), >s0, 0.5) - 1.0).abs() < 1e-6);
preds.push((0, bx(70., 80., 120., 120.), 0.999)); // confident false positive ranks first
assert!((ap(preds, >s0, 0.5) - 2.0 / 3.0).abs() < 1e-5); // precision 0, 1/2, 2/3 -> envelope 2/3
// timing: 80x80 grid x 80 classes; p = share of scores above the threshold
let (w, h, nc) = (80, 80, 80);
let mut s = 0x9E3779B97F4A7C15u64;
let mut rnd = move || { s ^= s << 13; s ^= s >> 7; s ^= s << 17; (s >> 40) as f32 / (1u64 << 24) as f32 };
for p in [0.0005, 0.02] {
let cls = (0..w * h * nc).map(|_| if rnd() < p { 0.3 + 0.7 * rnd() } else { 0.1 * rnd() }).collect();
let ltrb = (0..w * h * 4).map(|_| 4.0 + 40.0 * rnd()).collect();
let hd = Head { w, h, nc, stride: 8.0, cls, ltrb };
let t = std::time::Instant::now();
let n = (0..100).map(|_| nms(decode(&hd, 0.3), 0.5).len()).last().unwrap();
println!("p={p}: {n} kept, decode+NMS {:?}/image", t.elapsed() / 100);
}
}
Complexity and constants
- Backbone FLOPs scale with input area: 640² to 1280² is 4×. Strides 8, 16, 32 on 640² give 8400 cells, 672 000 scores at 80 classes (arithmetic).
- Measured (Apple M4 Pro, release, one thread, random scores, 80×80 grid, 80 classes): 235 candidates → decode+NMS about 0.2 ms; 10 233 candidates (2 %) → 23-39 ms across runs (little overlaps, near NMS's worst case). Top-1000 pre-filter on the same data: about 0.8 ms.
Efficiency
Cost: the backbone and neck forward pass dominates (GPU/NPU in practice); decode and NMS are CPU work of order 0.2 ms at a sensible threshold (measured above). Bound: backbone compute, then host copy and NMS. Papers report Faster R-CNN (VGG-16) 5 fps, YOLO 45 fps, CenterNet 37.4 % AP at 52 fps (own GPUs).
Levers:
- Lower input resolution — cost falls with pixels (arithmetic) — large objects.
- Top-k before NMS — 23-39 ms to 0.8 ms (measured above) — low thresholds.
- Tile large images with overlap, NMS across seams — keeps small-object resolution — images far above input size.
- INT8 quantization of backbone and neck — gain depends on hardware, not measured here; check AP — edge devices.
- NMS-free head (DETR) — removes the CPU post-process — tight latency budgets.
Naive → standard → state of the art:
- Naive: sliding-window classifier · thousands of network calls per image.
- Standard: FPN, dense head, focal loss, threshold, class-wise NMS · one forward pass.
- State of the art: query-based transformer detectors · one forward pass, no NMS, often heavier.
Implementation notes
- Sigmoid per class, not softmax with background: objects of different classes can overlap at one cell.
- Evaluate with a very low threshold (about 0.001) and 100 detections per image; deploy with the operating threshold.
- Box distances must be non-negative (ReLU or
exp), or boxes invert.
Pitfalls
- Small objects: a stride-32 cell cannot localise 16 px; add a stride-8 level, raise resolution or tile.
- Domain shift (night, thermal, other scanner): fine-tune with augmentation.
- A COCO-tuned NMS threshold merges neighbours in dense scenes; scores are not probabilities (calibration).
Variants and alternatives
In production
- Driving and robot stacks feed detections into tracking and sensor fusion.
- Retail shelf audit, inspection lines, camera-trap triage, aerial surveys.
Implementations
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| torchvision.models.detection | Python | pytorch/vision | pip install torchvision | — | — | — | — | Faster R-CNN, RetinaNet, FCOS, SSD; ops.nms, batched_nms |
| Detectron2 | Python | facebookresearch/detectron2 | python -m pip install 'git+https://github.com/facebookresearch/detectron2.git' | — | — | — | — | Faster/Mask R-CNN; Apache-2.0; not on PyPI |
| MMDetection | Python | open-mmlab/mmdetection | pip install mmdet | — | — | — | — | model zoo incl. DETR, Grounding DINO; Apache-2.0 |
| Ultralytics | Python | ultralytics/ultralytics | pip install ultralytics | — | — | — | — | YOLO models; AGPL-3.0, separate Enterprise License offered |
| pycocotools | Python/C | cocodataset/cocoapi | pip install pycocotools | — | — | — | — | reference COCO AP/AR evaluation |
Sources
- Ren, He, Girshick, Sun, Faster R-CNN, NeurIPS 2015. paper
- Lin et al., Focal Loss for Dense Object Detection (RetinaNet), ICCV 2017. paper
- Lin et al., Feature Pyramid Networks for Object Detection, CVPR 2017. paper
- Liu et al., SSD: Single Shot MultiBox Detector, ECCV 2016. paper
- Redmon et al., You Only Look Once, CVPR 2016. paper
- Tian, Shen, Chen, He, FCOS, ICCV 2019. paper
- Zhou, Wang, Krähenbühl, Objects as Points (CenterNet), 2019. paper
- Carion et al., End-to-End Object Detection with Transformers, ECCV 2020. paper · code
- Zhu et al., Deformable DETR, ICLR 2021. paper
- Rezatofighi et al., Generalized Intersection over Union, CVPR 2019. paper
- Lin et al., Microsoft COCO: Common Objects in Context, ECCV 2014. paper
- Minderer et al., Simple Open-Vocabulary Object Detection with Vision Transformers (OWL-ViT), ECCV 2022. paper
- Liu et al., Grounding DINO, 2023. paper · code