When YOLOv5 appeared in mid-2020, it was slightly scandalous: a “YOLOv5” with no paper behind it, built directly in PyTorch while YOLOv4 was still a Darknet project. Four years of production use later, the Ultralytics/yolov5 repository has become one of the most-forked detection codebases on GitHub, and its longevity owes much to a few legible design decisions. The repo is not a library you import so much as an application you run: train.py, val.py, detect.py, and export.py at the top level, with everything else tucked into models/ and utils/.

The most consequential of those decisions is that the network architecture is data, not code. A models/yolov5s.yaml file lists every layer of the backbone and head, and a single function — parse_model — turns that list into real PyTorch modules. That one move is what made the s/m/l/x family trivial: the same layer graph, scaled by depth and width multiples. It also explains why the repo aged well; adding a new block type meant teaching parse_model one more name, not rewriting the builder.

This post is a source tour of the current master branch. We follow the path a detection actually takes: yaml to modules in parse_model, forward through C3 and SPPF blocks into the anchor-decoding Detect head, out through non_max_suppression, and across the export surface that DetectMultiBackend loads back. Two diagrams generated from the real repository tree anchor the tour — a compact overview and a detailed call graph.

YOLOv5 overview architecture: from hubconf and detect.py entry points through DetectionModel assembly, C3/SPPF blocks and the Detect head, to non-max suppression and DetectMultiBackend

Overview graph of YOLOv5: entry points feed a yaml-driven model assembly stage, whose backbone blocks and anchor head hand raw predictions to the NMS and multi-backend runtime layer.

Reading the overview from left to right:

  • parse_model, L375 — the yaml interpreter: walks the layer list, resolves repeats through depth scaling, and instantiates every module.
  • DetectionModel, L215 — wraps parse_model, probes strides with a dummy forward, and initializes head biases.
  • Detect, L71 — the anchor head: one 1×1 conv per scale, sigmoid split, and grid/anchor decoding of box coordinates.
  • SPPF, L318 — the fast spatial-pyramid-pooling block that concatenates three sequential 5×5 max-pools.
  • letterbox, L85 — aspect-preserving resize with gray padding to the model stride.
  • non_max_suppression, L658 — batched, class-aware NMS with the offset trick that keeps per-class suppression independent.
  • DetectMultiBackend, L456 — one inference interface over PyTorch, ONNX Runtime, TensorRT, TensorFlow, OpenVINO and friends.
  • attempt_load, L60 — the checkpoint loader that un-pickles .pt files, optionally fuses layers, and can return an Ensemble.

From Install to Your First Model

The repo is meant to be cloned and run, and the README’s quick start is genuinely two commands long. After cloning, install the pinned dependency set — the requirements file is versioned carefully because several optional accelerators (onnxruntime, TensorRT, CoreML tools) move fast:

git clone https://github.com/ultralytics/yolov5
cd yolov5
pip install -r requirements.txt

Then run inference on the bundled sample images. The first invocation downloads yolov5s.pt (6.2M parameters) automatically and writes annotated results to runs/detect/exp:

python detect.py --source data/images --weights yolov5s.pt

The same model is reachable without cloning, through the hubconf.py entry point. _create, L16 downloads the weights, loads them via attempt_load, and by default wraps the network in AutoShape so you can feed it file paths, PIL images, or NumPy arrays directly:

import torch
model = torch.hub.load("ultralytics/yolov5", "yolov5s")
results = model("https://ultralytics.com/images/zidane.jpg")
results.print()

Both paths converge on the same objects: a DetectionModel and, in the hub case, an AutoShape shell that hides the letterbox/NMS/rescale choreography from the caller.

parse_model: Architecture as Data

Open models/yolov5s.yaml and you find three sections: hyperparameters (nc, depth_multiple, width_multiple, anchor lists), a backbone list, and a head list. Each row is [from, number, module, args] — where the feature comes from, how many times it repeats, which class to build, and with what arguments. parse_model reads these rows in order, maintaining a ch list of output channels and a save list of layer indices that later layers consume via the from field.

Two global scalings make one yaml describe four model sizes. depth_multiple divides the number column, so yolov5n builds fewer Bottleneck repeats than yolov5x; width_multiple scales every channel count, shrinking or widening each conv. Repetitive blocks are handled by a dictionary of class pairs — C3 maps to (C3, [256])-style signatures — so a row like - [-1, 3, C3, [256, True]] becomes one module with n inner repeats rounded by make_divisible.

The building blocks themselves are refreshingly plain. Conv is conv-BN-SiLU with padding from autopad, which infers “same” padding from the kernel size and honors dilation. C3 is the CSP variant: two conv stems, n Bottleneck blocks in one branch, a straight conv path in the other, concat, and a 1×1 to mix. SPPF replaced the original four-parallel-kernel SPP with three chained 5×5 max-pools whose concatenated outputs cover the same receptive fields — the MAXPOOL legalization for TensorRT came as a bonus of that design.

DetectionModel orchestrates all of this: it calls parse_model, then runs a 256-pixel dummy tensor through the network to measure the stride of each Detect layer, asserts the strides divide 32 cleanly, and re-seeds head biases toward a known prior. Forward passes live in BaseModel, whose _forward_once loops over self.model, stashing outputs of saved layers so from: -1 and from: [4] style wiring works without any graph machinery. DetectionModel.fuse() folds Conv+BN pairs via fuse_conv_and_bn in utils/torch_utils.py — a real latency win at inference.

The Detect Head: Anchors, Grids, and Decoding

YOLOv5 is anchor-based — deliberately, even as anchor-free competitors multiplied — and the entire mechanism lives in about sixty lines. The head’s __init__ registers an (nl, na, 2) anchor buffer, computes na (anchors per layer, 3 by default) and no = nc + 5 outputs per anchor, then creates one nn.Conv2d(x, no * na, 1) per detection scale.

In Detect.forward, each per-scale feature map is reshaped from (bs, 255, ny, nx) to (bs, na, ny, nx, 85). During inference the tensor is split into box and class halves, and the decode happens in-place:

xy, wh, conf = x[i].sigmoid().split((2, 2, self.nc + 1), 4)
xy = (xy * 2 + self.grid[i]) * self.stride[i]
wh = (wh * 2) ** 2 * self.anchor_grid[i]

Those two lines, L109–111, are the whole anchor story: sigmoid outputs in (0,1) are stretched to (−0.5, 1.5) cells by the * 2 + grid − 0.5 affine, then multiplied by stride for centers; width and height are squared so the network predicts a multiplicative factor around the preset anchor box. The grids themselves come from _make_grid, which rebuilds meshgrid buffers whenever the input’s spatial shape changes — that check is what makes the model “dynamic” for non-square inputs. In training mode the head returns the raw per-scale tensors instead, because ComputeLoss in utils/loss.py needs the un-decoded predictions to assign targets against anchor priors.

The detect.py Flow: From Frames to Boxes

detect.run is the reference consumer of everything above, and it is worth reading top to bottom once. After argument parsing, it calls select_device and check_img_size, which rounds your requested inference size up to the nearest multiple of the model stride. Weights load through DetectMultiBackend; for .pt files that lands in attempt_load, which applies torch.load with a strict allowlist and optionally fuses Conv+BN immediately.

The source loop uses LoadImages, a generator yielding BGR frames, original-resolution copies, and video metadata across images, videos, globs, and streams. Each frame passes through letterbox, then the batched forward produces (bs, anchors, 85) predictions, which are passed to non_max_suppression — visible in context at detect.py L228.

That NMS implementation rewards a close read. It batches across the batch dimension with a confidence threshold, converts xywh to xyxy once, then — the clever part — adds a per-class offset of classes * max_wh to box coordinates so that torchvision.ops.nms never suppresses across classes, before subtracting the offset back. Box limits are clamped, max_det caps survivors per image, and an optional merge mode averages boxes weighted by IoU. The survivors are still in letterbox coordinates, so scale_boxes maps them back to original pixels using the ratio and padding recorded earlier. What remains is presentation: the Annotator draws boxes, save_crop can cut each detection out with padding — done via numpy slicing on the original frame — and increment_path allocates the next runs/detect/exp directory.

DetectMultiBackend and the Export Surface

The bridge between training and deployment is DetectMultiBackend. Its constructor dispatches purely on file suffix: .pt goes through attempt_load, .onnx builds an onnxruntime.InferenceSession with CUDA/CPU provider selection, .engine deserializes a TensorRT runtime (with a helpful error if the engine was built by a different TRT version), and .tflite, .pb, .xml (OpenVINO), CoreML, Paddle, and Triton each get their branch. Its forward normalizes everything to a single return shape, and its _model_type helper is the definitive list of what the repo can consume.

The production side is export.py. export.run loads the checkpoint, builds an example input, and calls one export function per requested format, then re-validates each artifact with DetectMultiBackend and runs a single-image sanity inference. export_onnx exports with dynamic axes when --dynamic is set and optionally runs onnx-simplifier; export_engine builds a serialized TensorRT engine with workspace and precision flags. Our onnx and tensorrt tours cover what those formats contain at the runtime level; openvino covers the .xml/.bin branch. On the training side, augmentations feed mosaicked batches, while preprocessing stays letterbox-based — a contrast worth remembering when reading torchvision detection reference code.

Detailed YOLOv5 call graph: parse_model yaml assembly, Detect anchor decoding, NMS internals, DetectMultiBackend format dispatch, export functions, and loss/metric utilities

Detailed graph of the same tour: every node is pinned to a real file in the repository, from the yaml assembly and Detect head down to export targets and evaluation metrics.

Honest Limits

Be clear-eyed about what this codebase is in 2026. Ultralytics’ own attention has moved to the unified ultralytics package (YOLOv8 and later, anchor-free heads, integrated CLI); the yolov5 repository is in maintenance mode — still maintained, but the successor is where new research lands, and a few helpers in utils/torch_utils.py (like time_sync and model_info) are now re-imported from the installed ultralytics package.

The anchor-based design is a strength for deployment stability but a tax for new domains: the anchors in each yaml are hand-tuned for COCO-like object scales, and while check_anchors in utils/autoanchor.py can re-run k-means on your dataset, off-domain deployments (aerial imagery, elongated objects) still need anchor and threshold babysitting. The NMS defaults — conf_thres=0.25, iou_thres=0.45 — are reasonable starting points, not constants; dense or tiny-object scenes reliably demand per-domain tuning of both thresholds and max_det. Finally, the license is AGPL-3.0, which means embedding this code (or a derivative network’s training stack) in a distributed product creates source-disclosure obligations — a real consideration for commercial integration that predates but survives into the 8.x package.

None of that diminishes the engineering value of reading it. YOLOv5 remains one of the best case studies of a complete, deployable detection system whose complexity budget was spent on legibility — yaml as architecture, one head, one NMS, one loader that accepts every runtime the ecosystem produced.

Watch PyShine on YouTube

Contents