Home › Machine learning › Vision models
algorithm medium

Object detection

Problem Cameras, scanners and lidar produce frames in which software must find every instance of known object types, such as vehicles, products, lesions or defects. Each needs a position, a type and a confidence, for counting or action.

A network turns an image into scored, classified boxes in one pass; anchor-based, anchor-free and set-prediction heads differ in how they assign labels and remove duplicates.

backbone O(pixels), proportional to input area; decode O(cells·classes); greedy NMS O(n·kept)
detectionbounding-boxfpnanchor-freedetrmapfocal-loss
Overview

A backbone turns the image into feature maps at several scales; a head predicts, at every cell, "an object of class c is here, its box reaches l, t, r, b away". Training assigns positive cells to objects; inference keeps confident cells and removes duplicates with NMS. DETR-style models replace both with queries matched one-to-one to objects.

Key facts

Use when

  • Driving and robot perception, retail shelf counting, medical lesion or cell finding, satellite and aerial surveys, factory defect inspection, wildlife camera traps, document layout regions.

Avoid when

  • One object per image or a yes/no answer: classify instead.
  • Pixel-accurate shapes: segmentation.

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:

  1. Letterbox-resize to the input size; normalise.
  2. Backbone, neck, head: per level, cls[cell][K] and a box per cell.
  3. Drop scores below the threshold before building boxes.
  4. Decode: anchor-based cx = ax + dx·aw, w = aw·exp(dw); anchor-free x1 = px − l … y2 = py + b.
  5. 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, &gts[*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(), &gts0, 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, &gts0, 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

Faster R-CNNaccuracy, masks, small training sets. YOLO/FCOS — real-time edge.
CenterNet3D boxes, pose, tracking offsets. DETR family — no NMS; more data or epochs.
Open vocabularynew classes without retraining, lower accuracy.
Oriented boxesadd an angle for aerial images, documents (layout, OCR). BEV detectors — lidar, multi-camera 3D (BEV).

In production

  • Driving and robot stacks feed detections into tracking and sensor fusion.
  • Retail shelf audit, inspection lines, camera-trap triage, aerial surveys.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
torchvision.models.detectionPythonpytorch/visionpip install torchvision————Faster R-CNN, RetinaNet, FCOS, SSD; ops.nms, batched_nms
Detectron2Pythonfacebookresearch/detectron2python -m pip install 'git+https://github.com/facebookresearch/detectron2.git'————Faster/Mask R-CNN; Apache-2.0; not on PyPI
MMDetectionPythonopen-mmlab/mmdetectionpip install mmdet————model zoo incl. DETR, Grounding DINO; Apache-2.0
UltralyticsPythonultralytics/ultralyticspip install ultralytics————YOLO models; AGPL-3.0, separate Enterprise License offered
pycocotoolsPython/Ccocodataset/cocoapipip 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

Builds on

Used by

gui grounding · plannedMapping an intended UI action to screen coordinates or elements from screenshots, with scaling and verification
layout analysis · plannedDetecting page regions (text blocks, tables, figures) and reading order in documents
Multi-object trackingSensors such as cameras, lidar, radar or microscopes find objects scan by scan but don't know which object is which over time; counting, trajectories and collision warnings need a stable identity per object.
table structure recognition · plannedRecovering rows, columns and cell spans of tables from document images

Systems (18):

Alternatives and related

vision transformer · plannedViT: patch embeddings, position encodings, encoder blocks; vision-language connectors
Image model trainingHow do you set up the training of an image classifier or a diffusion model so it works well? This guide helps pick optimizer, augmentation, resolution schedules and other settings that are known to work.Evaluation metricsAfter training a model, how do you measure whether it is actually good, and whether one model is really better than another? This helps pick and compute metrics that don't mislead on imbalanced data or rankings.Assignment problem (Hungarian / Jonker–Volgenant)A system with n agents and n tasks, each pairing having a known cost, must give every agent exactly one task so that the total cost is lowest, and must do so again every frame, batch or scheduling round.
bipartite matching · plannedMaximum/weighted bipartite matching (Hopcroft-Karp, Hungarian) for assignment problems
Weight quantizationRunning a large language model for one user at a time is slow and needs a lot of GPU memory, because every weight must be read for each generated token. Engineers want faster, smaller deployments.
image segmentation · plannedPixel/instance segmentation networks and promptable segmentation (Mask R-CNN, SAM-style)
bev perception · plannedLifting multi-camera features into a bird's-eye-view grid (lift-splat, BEV transformers) for detection and mapping
Optical character recognition (text recognition)Teams holding images of text, such as scans, photos and screenshots, need the characters as a string with positions and a per-line trust score, so that downstream code can search, extract, translate or send doubtful lines to a person.