Module 25: Computer vision and multimodal 45 min10 min quick readDeep learning
Object detection and YOLO
Explain how a one-stage detector turns a feature map into boxes, compute IoU and run non-maximum suppression by hand, encode and decode YOLO box targets, and read precision, recall, AP@0.5, and mAP@0.5:0.95.
Before this: Convolutional networks, Classification metrics and thresholds
Quick read: the intuition, interactive visualization, summary, and interview questions. The math, implementation, engineering, and knowledge-check sections are hidden.
Mental model. A detector is a classifier and a ruler run at every position of a feature map at once. Every grid cell guesses "is an object centred here, what is it, and where exactly are its edges?" That produces hundreds of overlapping guesses. Non-maximum suppression then keeps the best guess per object and throws away the echoes.
You will learn to
- State what every number in a YOLO-style output tensor means and compute its shape.
- Compute intersection over union (IoU) for two boxes and run non-maximum suppression (NMS) by hand.
- Encode a ground-truth box into the offsets a detector regresses, and decode them back.
- Compute average precision from a ranked list of detections, and explain why mAP@0.5:0.95 is stricter than AP@0.5.
- Diagnose a detector from its metric row and pick the next fix.
Why it matters. Classification says "there is a dog". Detection says "a dog at pixels (112, 40, 280, 210) and a cat at (400, 180, 560, 310)". That change, a variable number of labelled boxes instead of one label, is what driving stacks, shelf scanners, document layout parsers, and factory inspection lines depend on. IoU, NMS, and mAP are also a standard interview set for any vision role.
1. Intuition
Picture laying a 13 by 13 grid over a photo. Each cell is responsible for objects whose centre falls inside it. For each object it owns, a cell answers three questions:
- Is there an object centred here? One number, the objectness score.
- What is it? One score per class.
- Where are its edges? Four numbers that place a box.
A convolutional backbone already produces a grid of feature vectors, one per region of the image (convolutional networks). A one-stage detector like YOLO ("You Only Look Once", 2016) adds a convolution that turns each feature vector into those answers. One forward pass, every cell in parallel. That is why YOLO runs in real time.
Two refinements make the box numbers learnable:
- Anchors. Objects come in very different shapes: a standing person is tall, a car is wide. Instead of predicting raw widths in pixels, each cell gets a few anchor boxes (template shapes, often found by clustering the training boxes) and predicts how much to stretch each one. Several anchors per cell also let one cell hold a person and a bicycle at once.
- Bounded offsets. The centre offset passes through a sigmoid, so it stays inside the cell that owns the object.
The network fires many boxes for one object, because neighbouring cells and anchors all see it. The fix is a cleanup step after the network: sort boxes by score, keep the best, delete every other box that overlaps it too much, repeat. That is non-maximum suppression. "Overlaps too much" is measured by IoU: overlap area divided by combined area. IoU is also how we decide whether a prediction counts as correct when we score the detector.
NoteTwo-stage versus one-stage. Faster R-CNN first proposes candidate regions, then classifies each one. It was more accurate but slower. One-stage detectors (YOLO, SSD, RetinaNet) predict directly on the grid. Modern YOLO versions (v8 onward) are anchor-free: each cell predicts distances to the four box edges instead of stretching an anchor. Transformer detectors (DETR, RT-DETR) predict a fixed set of boxes and match them to objects one-to-one during training, so they need no NMS at all. The IoU, NMS, and mAP ideas carry over to all of them.
2. Visualization

Five raw predictions on two synthetic objects. NMS keeps box 0 (score 0.92) and box 3 (0.80): every other box overlaps one of these with IoU above 0.5.
Try it (predict first, then check with the script in section 4):
- Raise the NMS threshold to 0.9. Which boxes survive now? (Hint: box 2 overlaps box 0 with IoU 0.862.)
- Lower it to 0.1. Could that delete a correct detection of a different object? When would two real objects overlap that much?
- In the AP example, move the false positive with score 0.70 to score 0.30. Predict whether AP goes up or down before you rerun.
- Swap the anchor list for one with only a single square anchor (100, 100). Predict the new and for the worked box.
3. The math
Symbols
| Symbol | Meaning | Shape |
|---|---|---|
| grid size (cells per side) | scalar | |
| anchors per cell | scalar | |
| number of classes | scalar | |
| stride: input pixels per grid cell | scalar | |
| integer column and row of a grid cell | scalars | |
| raw box outputs the network regresses | 4 per anchor | |
| anchor width and height in pixels | 2 per anchor | |
| sigmoid, | ||
| a predicted box and a ground-truth box | 4 numbers each |
Output tensor
For each of cells and anchors the head outputs 4 box numbers, 1 objectness, and class scores:
A 416 by 416 input with stride 32 gives . With anchors and the 80 COCO classes the head outputs numbers per image.
Decoding
The sigmoid keeps the centre inside its cell. The exponential keeps width and height positive and makes "twice as wide" and "half as wide" symmetric around .
IoU
Loss (YOLOv1–v3 style)
Box and class losses count only for the (cell, anchor) pairs that own an object. Empty pairs contribute only an objectness loss pushing them toward 0. With pairs and a handful of objects, the empty ones vastly outnumber the rest, so YOLOv1 used and . Modern detectors replace the squared box error with an IoU-based loss (GIoU, DIoU, CIoU) and the plain cross-entropy with focal-style losses, but the three-part structure remains.
Worked example 1: IoU
Prediction , ground truth , as .
- Intersection corners: , , , .
- Intersection area: .
- Areas: , .
- Union: .
- IoU .
A 10-pixel shift on a 100-pixel box already costs a third of the IoU. That is why strict thresholds like 0.75 are hard.
Worked example 2: encode a box
Ground truth , stride , anchors , , .
- Centre . Size , .
- Grid position: , . So cell with in-cell offsets and .
- Pick the anchor whose shape overlaps best (boxes aligned at one corner). For anchor : intersection ; union ; shape IoU . The other two score and , so anchor 1 wins.
- Invert the sigmoid: , .
- Invert the exponential: , .
Decoding: , ; . The box comes back exactly.
Worked example 3: average precision at IoU 0.5
Three ground-truth objects; five detections sorted by score. A detection is a true positive (TP) if its IoU with an unmatched ground-truth box is at least 0.5; a second hit on the same object is a false positive.
| Rank | Score | TP? | Precision = TP so far / rank | Recall = TP so far / 3 |
|---|---|---|---|---|
| 1 | 0.95 | yes | 1/1 = 1.000 | 0.333 |
| 2 | 0.90 | yes | 2/2 = 1.000 | 0.667 |
| 3 | 0.70 | no (duplicate) | 2/3 = 0.667 | 0.667 |
| 4 | 0.60 | no (empty space) | 2/4 = 0.500 | 0.667 |
| 5 | 0.40 | yes | 3/5 = 0.600 | 1.000 |
AP is the area under the precision-recall curve after replacing each precision with the best precision at that recall or higher (the "envelope"): . Recall only rises at ranks 1, 2, and 5, each by :
mAP@0.5 averages this over classes. COCO's mAP@0.5:0.95 also averages over ten IoU thresholds , so loosely placed boxes that pass at 0.5 but fail at 0.75 drag it down. (COCO interpolates at 101 fixed recall points; the idea is the same.)
4. Implementation
The full script is code/25-computer-vision/detection_scratch.py (NumPy only). It builds IoU, NMS, the box codec, anchor assignment, and AP, then reproduces every worked example above.
Build it from scratch
Step 1: pairwise IoU. Everything in detection reduces to "how much do these boxes overlap?", for many pairs at once. Broadcasting [:, None] against [None, :] gives the full [Na, Nb] matrix with no loops. The clip(..., 0) handles boxes that don't touch: their "intersection width" would be negative.
def box_iou(a, b):
"""a: [Na, 4], b: [Nb, 4] boxes as (x1, y1, x2, y2). Returns [Na, Nb] pairwise IoU."""
x1 = np.maximum(a[:, None, 0], b[None, :, 0]) # intersection corners, broadcast to [Na, Nb]
y1 = np.maximum(a[:, None, 1], b[None, :, 1])
x2 = np.minimum(a[:, None, 2], b[None, :, 2])
y2 = np.minimum(a[:, None, 3], b[None, :, 3])
inter = np.clip(x2 - x1, 0, None) * np.clip(y2 - y1, 0, None) # 0 when boxes don't overlap
area_a = (a[:, 2] - a[:, 0]) * (a[:, 3] - a[:, 1])
area_b = (b[:, 2] - b[:, 0]) * (b[:, 3] - b[:, 1])
return inter / (area_a[:, None] + area_b[None, :] - inter)Predict: what does box_iou return for two boxes that touch only along an edge, like (0, 0, 10, 10) and (10, 0, 20, 10)?
Step 2: greedy NMS. Keep the top-scoring box, drop every remaining box whose IoU with it exceeds the threshold, and repeat on what is left. It never merges or averages boxes, it only deletes.
def nms(boxes, scores, iou_threshold=0.5):
order = np.argsort(-scores) # highest score first
keep = []
while len(order):
i = order[0]
keep.append(int(i))
ious = box_iou(boxes[[i]], boxes[order[1:]])[0]
order = order[1:][ious <= iou_threshold] # survivors: boxes that overlap i too little
return keepPredict: production detectors run NMS separately per class. What goes wrong in a crowded street if you run one class-agnostic NMS over people and bicycles together?
Step 3: the box codec. The network never outputs pixels. It outputs offsets relative to a cell and an anchor, and decode is the exact inverse of encode. Training targets come from encode; predictions are read with decode.
def encode(box, cell_x, cell_y, stride, anchor_wh):
"""Pixel box (x1, y1, x2, y2) -> regression targets (tx, ty, tw, th)."""
cx, cy = (box[0] + box[2]) / 2, (box[1] + box[3]) / 2
w, h = box[2] - box[0], box[3] - box[1]
off_x, off_y = cx / stride - cell_x, cy / stride - cell_y # centre offset inside the cell, in [0, 1)
return np.array([logit(off_x), logit(off_y), np.log(w / anchor_wh[0]), np.log(h / anchor_wh[1])])
def decode(t, cell_x, cell_y, stride, anchor_wh):
cx = (sigmoid(t[0]) + cell_x) * stride # sigmoid keeps the centre inside its cell
cy = (sigmoid(t[1]) + cell_y) * stride
w = anchor_wh[0] * np.exp(t[2]) # exp keeps width positive, scales the anchor
h = anchor_wh[1] * np.exp(t[3])
return np.array([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2])Predict: an object centred exactly on a cell boundary has offset 0. What is logit(0), and why do real implementations clip the offset to something like [1e-6, 1 - 1e-6] (or use YOLOv5's wider 2σ(t) - 0.5 range)?
Step 4: who owns the object? Each ground-truth box is assigned to the cell containing its centre and the anchor whose shape fits best. Only that (cell, anchor) pair gets box and class targets; all others learn "no object".
def assign(box, anchors, stride):
"""Which grid cell and which anchor are responsible for this ground-truth box?"""
cx, cy = (box[0] + box[2]) / 2, (box[1] + box[3]) / 2
w, h = box[2] - box[0], box[3] - box[1]
shape_iou = [min(w, aw) * min(h, ah) / (w * h + aw * ah - min(w, aw) * min(h, ah)) for aw, ah in anchors]
return int(cx // stride), int(cy // stride), int(np.argmax(shape_iou)), shape_iouPredict: two people stand so close that their centres fall in the same cell and both best match the same anchor. What happens to one of them during training?
Step 5: average precision. Sort detections by score, greedily match each to an unmatched ground-truth box at IoU at least 0.5, then integrate precision over recall using the precision envelope.
def average_precision(pred_boxes, pred_scores, gt_boxes, iou_thr=0.5):
order = np.argsort(-pred_scores)
matched = set()
tp = []
for i in order: # greedy: each GT box can be matched once
ious = box_iou(pred_boxes[[i]], gt_boxes)[0]
j = int(np.argmax(ious))
hit = ious[j] >= iou_thr and j not in matched
if hit:
matched.add(j)
tp.append(hit)
tp = np.array(tp, dtype=float)
precision = np.cumsum(tp) / np.arange(1, len(tp) + 1)
recall = np.cumsum(tp) / len(gt_boxes)
# area under the precision envelope (all-point interpolation, as in COCO/VOC2010+)
envelope = np.maximum.accumulate(precision[::-1])[::-1]
recall_steps = np.diff(np.concatenate([[0.0], recall]))
return float(np.sum(envelope * recall_steps)), precision, recall, tpPredict: the duplicate box at rank 3 is a false positive here. If NMS had removed it, which ranks' precision would change, and would AP rise?
Use the library
torchvision ships IoU and NMS as fused, batched operators, plus pretrained detectors. The Ultralytics package wraps training and inference for its YOLO family (note its AGPL-3.0 license before shipping it in a product).
import torch
from torchvision.ops import box_iou, batched_nms # pip install torch torchvision
from torchvision.models.detection import fasterrcnn_resnet50_fpn_v2
keep = batched_nms(boxes, scores, class_ids, iou_threshold=0.5) # per-class NMS in one call
model = fasterrcnn_resnet50_fpn_v2(weights="DEFAULT").eval()
with torch.no_grad():
out = model([image_tensor])[0] # dict: boxes [K, 4], scores [K], labels [K]
# from ultralytics import YOLO
# results = YOLO("yolov8n.pt")("street.jpg") # decode + NMS done for youWhat the library adds: per-class NMS (batched_nms offsets boxes per class so classes never suppress each other), GPU kernels, feature pyramids that predict at several strides (small objects on fine grids, large on coarse), and COCO-style evaluation through pycocotools.
Run it
uv run code/25-computer-vision/detection_scratch.pyKey lines of output (all boxes are synthetic):
worked example: IoU = 0.6807 (8100 / 11900)
kept (threshold 0.5): [0, 3]
gt box [120.0, 80.0, 240.0, 220.0] -> centre (180, 150) -> cell (5, 4); shape IoU with anchors [0.107, 0.551, 0.221] -> anchor 1 (75, 170)
targets (tx, ty, tw, th) = [0.5108, 0.7885, 0.47, -0.1942]
decoded back: [120.0, 80.0, 240.0, 220.0]
output tensor for a 416x416 input, stride 32, 3 anchors, 80 classes: [13, 13, 3, 85] = 43,095 numbers
AP@0.5 = 0.86675. Engineering
Use cases. Counting and tracking (traffic, retail shelves, wildlife cameras), safety zones on factory floors, document layout (tables, figures, signatures), and as the first stage of pipelines that crop and then classify, read text, or estimate pose.
Choosing a detector. Real-time on an edge device: a small one-stage model (YOLO-family "n" or "s" sizes, RT-DETR small variants) exported to ONNX or TensorRT. Highest accuracy offline: larger transformer detectors. Classes not in any dataset: an open-vocabulary detector (Grounding DINO, OWL-ViT style) that takes text prompts, built on the image-text alignment from CLIP.
Data. Box labelling is expensive, so label consistency matters more than volume: decide once whether a half-hidden car gets a box, and whether boxes hug visible pixels or the estimated full extent. Augment with flips, scale jitter, and mosaic (four images tiled into one, so the model sees many small objects). Never apply a geometric augmentation to the image without applying it to the boxes.
Compute and latency. Quote latency for the whole pipeline: preprocessing, the forward pass, decode, and NMS. NMS on thousands of low-confidence boxes can dominate on CPU, which is one reason NMS-free designs (DETR family, YOLOv10's one-to-one head) exist. A confidence threshold before NMS (0.25 is a common default for display, 0.001 for mAP evaluation) controls how many boxes reach it.
Reading the metric row. High precision, low recall: the model is too conservative; lower the confidence threshold or check for small objects lost by a coarse stride. High AP@0.5 but low mAP@0.5:0.95: objects are found but boxes are loose; try an IoU-based box loss or higher-resolution features. Good mAP but bad in production: compare object size, lighting, and camera angle distributions with the training set.
Watch outFailure modes. Small objects vanish on a stride-32 grid (a 20-pixel object is under one cell); crowds get suppressed by NMS when real objects overlap above the threshold; a class-agnostic NMS lets a confident "person" box delete a correct "bicycle" box; images resized without keeping the aspect ratio distort every box; coordinates mixed between , , and normalized formats silently shift predictions.
Common mistakes
- Reporting only mAP@0.5 and calling localization "good".
- Evaluating with a high confidence threshold. mAP needs the full ranked list, low scores included.
- Tuning the NMS threshold on the test set.
- Comparing mAP numbers computed by different tools or at different input resolutions.
- Forgetting that anchors tuned for one dataset (say, pedestrians) fit another (say, shipping containers) badly.
- Treating a confidence score as a calibrated probability that the box is right.
6. Knowledge check
Quiz: Object detection and YOLO
A YOLO-style head uses a 13 by 13 grid, 3 anchors per cell, and 20 classes. Its output is shaped [13, 13, 3, 25]. Why is the last dimension 25?
Practice exercise. A detector predicts box for an object whose ground truth is . Compute the IoU. Does it count as a true positive at the 0.5 threshold? At 0.75?
Solution
Intersection: from 10 to 40, from 10 to 40, area . Each box has area . Union . IoU . It fails even the 0.5 threshold, so it is a false positive and the object counts as missed. A 10-pixel shift on a 40-pixel box is far worse than the same shift on a 100-pixel box (0.68 in worked example 1). Small objects need much more precise boxes.
Implementation challenge. Add Soft-NMS to the script: instead of deleting an overlapping box, multiply its score by with , and keep every box whose score stays above 0.001. Run it on the five-box example and report the final scores.
Solution sketch
def soft_nms(boxes, scores, sigma=0.5, min_score=1e-3):
scores = scores.copy()
idx = list(range(len(boxes)))
keep = []
while idx:
i = max(idx, key=lambda k: scores[k])
keep.append(i)
idx.remove(i)
for j in idx:
scores[j] *= np.exp(-box_iou(boxes[[i]], boxes[[j]])[0, 0] ** 2 / sigma)
idx = [j for j in idx if scores[j] > min_score]
return keep, scoresAll five boxes survive, in the order 0, 3, 1, 4, 2, with final scores 0.92, 0.80, 0.337, 0.189, and 0.059. Box 2 is decayed twice: when box 0 is kept (IoU 0.862, factor , so ) and again when box 1 is kept (IoU 0.646, factor 0.434, so ). A display threshold of 0.25 then shows boxes 0, 3, and 1. Soft-NMS helps in crowds, where a real second object overlapping the first is down-weighted instead of deleted.
Summary
- A one-stage detector predicts, for every grid cell and anchor, 4 box offsets, 1 objectness score, and class scores: an tensor.
- Box offsets are relative: a sigmoid keeps the centre in its cell, an exponential scales an anchor. Encode builds training targets; decode reads predictions.
- IoU measures box overlap; NMS keeps the best box and deletes overlapping duplicates, per class.
- AP integrates precision over recall at one IoU threshold; mAP@0.5:0.95 averages over classes and strict thresholds, so it rewards tight boxes.
- Modern detectors drop anchors or NMS, but IoU, assignment, and mAP remain the core vocabulary.
Next: Semantic segmentation and U-Net
Related: Convolutional networks · Classification metrics · Vision transformers
Go deeper, code-first (AI Engineering from Scratch): Instance Segmentation — Mask R-CNN
Parts of this lesson are adapted from AI Engineering from Scratch by Rohit Ghumare (MIT license, notice): Object Detection — YOLO from Scratch.
Interview angle
Walk me through what a YOLO-style head outputs and how you turn it into final boxes.
The backbone produces a feature map, say 13 by 13 for a 416-pixel input at stride 32. A 1 by 1 convolution maps each cell's feature vector to B anchors times (5 + C) numbers: four box offsets, one objectness logit, and C class logits. So the tensor is [13, 13, B, 5 + C]; with 3 anchors and 80 classes that is 43,095 numbers. To decode, take the sigmoid of the centre offsets, add the cell index, and multiply by the stride; multiply each anchor's width and height by the exponential of its offsets. Score each box as objectness times the best class probability, drop boxes below a confidence threshold, then run NMS per class: sort by score, keep the best, remove any same-class box with IoU above about 0.5, repeat. Anchor-free versions predict distances to the four edges instead, but the decode-threshold-NMS flow is the same.
Why does mAP@0.5:0.95 sit far below AP@0.5 for some models, and what would you change?
AP@0.5 accepts any box overlapping the object by half. mAP@0.5:0.95 averages AP over ten thresholds up to 0.95, so it rewards tight boxes. A big gap, say 0.75 versus 0.32, means the model finds the right objects but places loose boxes. A 10-pixel shift on a 100-pixel box already drops IoU to 0.68, failing every threshold from 0.70 up. Fixes target localization: an IoU-based regression loss (GIoU, DIoU, CIoU) instead of squared error on offsets; higher-resolution features or a feature pyramid so small objects get fine strides; better label quality, since inconsistent box tightness in annotations caps what the model can learn; and test-time checks that resizing preserves aspect ratio. Raising the confidence threshold will not help, since it changes which boxes are kept, not where they sit.
Your detector misses people in dense crowds even though recall on isolated people is high. Diagnose it.
Start with NMS. In a crowd, two real people can overlap with IoU above the 0.5 threshold, so the weaker detection is deleted as a "duplicate". Check by running evaluation with NMS thresholds of 0.5, 0.6, and 0.7 and watching crowd recall. Remedies include a higher threshold for that class, Soft-NMS (decay scores instead of deleting), or an NMS-free detector. Second, check assignment: if two people's centres land in the same cell and best match the same anchor, only one becomes a training target, so the model learns to predict one. Finer strides and more anchors, or modern dynamic assignment, help. Third, check labels: crowd regions are often marked "ignore" or labelled inconsistently. Finally, check resolution: people far from the camera may be only a few cells tall at stride 32.
Quick calculation: a prediction is (0, 0, 40, 40) and the ground truth is (10, 10, 50, 50). Is it a hit?
Compute the intersection first: x runs from 10 to 40 and y from 10 to 40, so it is 30 by 30 = 900 square pixels. Each box is 40 by 40 = 1600. The union is 1600 + 1600 − 900 = 2300, so IoU is 900 / 2300 ≈ 0.39. That is below 0.5, so at the usual PASCAL threshold the prediction is a false positive and the object also counts as a miss, hurting both precision and recall. The broader point is scale: the same 10-pixel offset on a 100-pixel box gives IoU around 0.68. Small objects need proportionally tighter localization, which is why COCO reports AP separately for small, medium, and large objects and why small-object performance usually lags.