Skip to content

Commit c90b518

Browse files
authored
feat(yolov5): add the YOLOv5 object-detection family (1275)
## Implementation `checkpoint.py` stands the missing classes in while the archive is read. Unpickling a module only restores attributes, so nothing in a stand-in runs; the tensors, class names, strides and anchor boxes come out unchanged. Placeholders deliberately keep failing dunder lookups, because `inspect` reads `__file__` off every module handed to it and `torch.load` goes down that path. The head is anchor based. Each level predicts three boxes per cell as an offset and a scale relative to a stored anchor, and carries an objectness that multiplies the class scores. The archive stores anchors already divided by their level's stride, so they are scaled back to pixels in the head. Suppression stays in the family runtime, as it does for YOLOv8 and YOLO11. Three values cannot be read from the weights and are stated explicitly. Each one still builds and still detects when set wrong, which is why each has a test: | value | correct | what happens if wrong | | --- | --- | --- | | norm epsilon | `1e-3`, not the PyTorch default | same object found, plus a false detection at 0.26 the reference does not report | | stem padding | `2`, not half its 6x6 kernel | one extra row and column; every box offset | | neck residual | dropped; the backbone keeps it | shapes match either way, boxes plausible but wrong | One graph note. The first draft logged 18 TensorRT errors per build (`checkSanity`, skipped tactics). Bisected to a single shape: multiplying a slice by another slice that broadcasts on the axis they were sliced on. Flattening both sides before the multiply is the same arithmetic and builds clean; the output is unchanged. `yolov5n` goes in `excluded_profiles` rather than `entries`, because the release
1 parent 919ead1 commit c90b518

19 files changed

Lines changed: 2075 additions & 0 deletions

apps/benchmark/performance/release.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ excluded_profiles:
4343
reason: >-
4444
The pinned Diffusers reference for MiniMax-H3 has not yet been integrated
4545
into the release performance runner.
46+
- model: yolov5n
47+
reason: >-
48+
Functional and reference-parity qualification is present, but the release
49+
performance baseline runs Ultralytics, which cannot load a legacy YOLOv5
50+
archive: those archives pickle classes from the standalone yolov5
51+
repository, which is not a dependency here.
4652
- model: dpn92-mx-in1k
4753
reason: >-
4854
Functional and timm reference-parity qualification is present for every

families/yolov5/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""YOLOv5 object-detection family."""

families/yolov5/checkpoint.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Exact NumPy access to a YOLOv5 `.pt` checkpoint.
5+
6+
YOLOv5 predates the `ultralytics` package: its archives pickle classes from the
7+
standalone yolov5 repository's `models` package, which is not a dependency
8+
here. Rather than take that repository on, the classes are stood in for while
9+
the archive is read. Nothing in a stand-in runs - unpickling a module only
10+
restores attributes - so the tensors, the class names and the anchor boxes come
11+
out unchanged, and the topology is taken from the stage table in `model.py`
12+
instead of from the restored objects.
13+
14+
The stored tensors are half precision; they are widened to float32 here so the
15+
folding arithmetic downstream keeps its accuracy.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import sys
21+
import types
22+
from dataclasses import dataclass
23+
from pathlib import Path
24+
from typing import Any
25+
26+
import numpy as np
27+
28+
29+
# The modules a YOLOv5 archive names. Anything it reaches for inside these is
30+
# answered with a placeholder class.
31+
_PICKLED_MODULES = (
32+
"models",
33+
"models.yolo",
34+
"models.common",
35+
"models.experimental",
36+
"utils",
37+
"utils.general",
38+
)
39+
40+
41+
class _Placeholder:
42+
"""Stands in for a class the archive pickles that is not available here."""
43+
44+
def __setstate__(self, state: Any) -> None:
45+
self.__dict__.update(state if isinstance(state, dict) else {})
46+
47+
48+
def _placeholder_for(attribute: str) -> type:
49+
# Dunder lookups have to keep failing: `inspect` walks `__file__` on every
50+
# module it is handed, and answering that with a class breaks it.
51+
if attribute.startswith("__") and attribute.endswith("__"):
52+
raise AttributeError(attribute)
53+
return type(attribute, (_Placeholder,), {})
54+
55+
56+
def _install_placeholders() -> None:
57+
for name in _PICKLED_MODULES:
58+
if name in sys.modules:
59+
continue
60+
module = types.ModuleType(name)
61+
module.__getattr__ = _placeholder_for # type: ignore[attr-defined]
62+
sys.modules[name] = module
63+
64+
65+
def _collect(node: Any, prefix: str, into: dict[str, Any]) -> None:
66+
"""Walk a restored module tree the way `state_dict` would."""
67+
data = node.__dict__
68+
for store in ("_parameters", "_buffers"):
69+
for name, tensor in data.get(store, {}).items():
70+
if tensor is not None:
71+
into[f"{prefix}{name}"] = tensor
72+
for name, child in data.get("_modules", {}).items():
73+
if child is not None:
74+
_collect(child, f"{prefix}{name}.", into)
75+
76+
77+
@dataclass(frozen=True)
78+
class Checkpoint:
79+
tensors: dict[str, np.ndarray]
80+
class_names: tuple[str, ...]
81+
image_size: int
82+
strides: tuple[int, ...]
83+
84+
# A YOLOv5 release ships every width in one repository, and a build request
85+
# has no field naming which archive to use, so the family builds the width
86+
# it is named for. Reading the other widths needs a way for a manifest to
87+
# select a file, which does not exist today.
88+
ARCHIVE = "yolov5n.pt"
89+
90+
@classmethod
91+
def open(cls, model_dir: Path) -> "Checkpoint":
92+
path = model_dir / cls.ARCHIVE
93+
if not path.is_file():
94+
raise FileNotFoundError(f"YOLOv5 model directory has no {cls.ARCHIVE}: {model_dir}")
95+
return cls.from_archive(path)
96+
97+
@classmethod
98+
def from_archive(cls, path: Path) -> "Checkpoint":
99+
import torch
100+
101+
_install_placeholders()
102+
# The archive pickles its own model class, so it cannot be read with
103+
# weights_only. The manifest pins the revision it comes from.
104+
blob = torch.load(str(path), map_location="cpu", weights_only=False)
105+
model = blob.get("model") if isinstance(blob, dict) else None
106+
if model is None:
107+
raise ValueError(f"YOLOv5 archive has no model entry: {path}")
108+
109+
restored: dict[str, Any] = {}
110+
_collect(model, "", restored)
111+
tensors = {
112+
key: value.detach().to(torch.float32).numpy()
113+
for key, value in restored.items()
114+
if not key.endswith("num_batches_tracked")
115+
}
116+
if not tensors:
117+
raise ValueError(f"YOLOv5 archive has no weights: {path}")
118+
119+
names = model.__dict__.get("names")
120+
if isinstance(names, dict) and names:
121+
ordered = tuple(str(names[index]) for index in sorted(names))
122+
elif isinstance(names, (list, tuple)) and names:
123+
ordered = tuple(str(name) for name in names)
124+
else:
125+
raise ValueError(f"YOLOv5 archive has no class names: {path}")
126+
127+
stride = restored.get("stride")
128+
if stride is None:
129+
stride = model.__dict__.get("stride")
130+
if stride is None:
131+
raise ValueError(f"YOLOv5 archive records no detection strides: {path}")
132+
strides = tuple(int(value) for value in stride.detach().reshape(-1).tolist())
133+
if not strides or any(value <= 0 for value in strides):
134+
raise ValueError(f"YOLOv5 archive has a non-positive stride: {path}")
135+
136+
# A YOLOv5 archive does not record the size it was trained at; every
137+
# published release uses 640.
138+
return cls(tensors, ordered, 640, strides)
139+
140+
@property
141+
def names(self) -> frozenset[str]:
142+
return frozenset(self.tensors)
143+
144+
def exists(self, name: str) -> bool:
145+
return name in self.tensors
146+
147+
def tensor(self, name: str) -> np.ndarray:
148+
value = self.tensors.get(name)
149+
if value is None:
150+
raise KeyError(f"YOLOv5 checkpoint tensor not found: {name}")
151+
return value
152+
153+
def scalar(self, name: str) -> Any:
154+
return self.tensor(name)

families/yolov5/graph.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Small TensorRT graph vocabulary owned by YOLOv5."""
5+
6+
from __future__ import annotations
7+
8+
import numpy as np
9+
import tensorrt as trt
10+
11+
12+
def convolution(
13+
network,
14+
tensor,
15+
weight: np.ndarray,
16+
bias: np.ndarray,
17+
*,
18+
stride: int = 1,
19+
padding: int = 0,
20+
groups: int = 1,
21+
dtype: np.dtype,
22+
):
23+
layer = network.add_convolution_nd(
24+
tensor,
25+
num_output_maps=int(weight.shape[0]),
26+
kernel_shape=(int(weight.shape[2]), int(weight.shape[3])),
27+
kernel=trt.Weights(np.ascontiguousarray(weight, dtype=dtype)),
28+
bias=trt.Weights(np.ascontiguousarray(bias, dtype=dtype)),
29+
)
30+
if layer is None:
31+
raise RuntimeError("TensorRT rejected a YOLOv5 convolution")
32+
layer.stride_nd = (stride, stride)
33+
layer.padding_nd = (padding, padding)
34+
layer.num_groups = groups
35+
return layer.get_output(0)
36+
37+
38+
def silu(network, tensor):
39+
"""x * sigmoid(x), the activation every YOLOv5 convolution uses."""
40+
gate = network.add_activation(tensor, trt.ActivationType.SIGMOID)
41+
if gate is None:
42+
raise RuntimeError("TensorRT rejected a YOLOv5 SiLU sigmoid")
43+
product = network.add_elementwise(tensor, gate.get_output(0), trt.ElementWiseOperation.PROD)
44+
if product is None:
45+
raise RuntimeError("TensorRT rejected a YOLOv5 SiLU product")
46+
return product.get_output(0)
47+
48+
49+
def add(network, left, right):
50+
layer = network.add_elementwise(left, right, trt.ElementWiseOperation.SUM)
51+
if layer is None:
52+
raise RuntimeError("TensorRT rejected a YOLOv5 add")
53+
return layer.get_output(0)
54+
55+
56+
def concatenate(network, tensors, *, axis: int = 1):
57+
layer = network.add_concatenation(tensors)
58+
if layer is None:
59+
raise RuntimeError("TensorRT rejected a YOLOv5 concatenation")
60+
layer.axis = axis
61+
return layer.get_output(0)
62+
63+
64+
def slice_axis(network, tensor, *, axis: int, start: int, count: int):
65+
shape = [int(value) for value in tensor.shape]
66+
starts, sizes = [0] * len(shape), list(shape)
67+
starts[axis], sizes[axis] = start, count
68+
layer = network.add_slice(tensor, trt.Dims(starts), trt.Dims(sizes), trt.Dims([1] * len(shape)))
69+
if layer is None:
70+
raise RuntimeError("TensorRT rejected a YOLOv5 slice")
71+
return layer.get_output(0)
72+
73+
74+
def max_pool(network, tensor, *, kernel: int, stride: int, padding: int):
75+
layer = network.add_pooling_nd(tensor, trt.PoolingType.MAX, (kernel, kernel))
76+
if layer is None:
77+
raise RuntimeError("TensorRT rejected a YOLOv5 max pool")
78+
layer.stride_nd = (stride, stride)
79+
layer.padding_nd = (padding, padding)
80+
return layer.get_output(0)
81+
82+
83+
def nearest_upsample(network, tensor, factor: int):
84+
layer = network.add_resize(tensor)
85+
if layer is None:
86+
raise RuntimeError("TensorRT rejected a YOLOv5 upsample")
87+
layer.resize_mode = trt.InterpolationMode.NEAREST
88+
layer.scales = [1.0, 1.0, float(factor), float(factor)]
89+
return layer.get_output(0)
90+
91+
92+
def reshape(network, tensor, shape: tuple[int, ...]):
93+
layer = network.add_shuffle(tensor)
94+
if layer is None:
95+
raise RuntimeError("TensorRT rejected a YOLOv5 reshape")
96+
layer.reshape_dims = trt.Dims(shape)
97+
return layer.get_output(0)
98+
99+
100+
def permute(network, tensor, permutation: tuple[int, ...]):
101+
layer = network.add_shuffle(tensor)
102+
if layer is None:
103+
raise RuntimeError("TensorRT rejected a YOLOv5 permutation")
104+
layer.second_transpose = trt.Permutation(permutation)
105+
return layer.get_output(0)
106+
107+
108+
def matmul(network, left, right, *, transpose_left=False, transpose_right=False):
109+
layer = network.add_matrix_multiply(
110+
left,
111+
trt.MatrixOperation.TRANSPOSE if transpose_left else trt.MatrixOperation.NONE,
112+
right,
113+
trt.MatrixOperation.TRANSPOSE if transpose_right else trt.MatrixOperation.NONE,
114+
)
115+
if layer is None:
116+
raise RuntimeError("TensorRT rejected a YOLOv5 matmul")
117+
return layer.get_output(0)
118+
119+
120+
def softmax(network, tensor, axis: int):
121+
layer = network.add_softmax(tensor)
122+
if layer is None:
123+
raise RuntimeError("TensorRT rejected a YOLOv5 softmax")
124+
layer.axes = 1 << axis
125+
return layer.get_output(0)
126+
127+
128+
def sigmoid(network, tensor):
129+
layer = network.add_activation(tensor, trt.ActivationType.SIGMOID)
130+
if layer is None:
131+
raise RuntimeError("TensorRT rejected a YOLOv5 sigmoid")
132+
return layer.get_output(0)
133+
134+
135+
def scale(network, tensor, factor: float, *, dtype: np.dtype):
136+
shape = (1,) * len(tuple(tensor.shape))
137+
layer = network.add_constant(shape, trt.Weights(np.array([factor], dtype=dtype).reshape(shape)))
138+
if layer is None:
139+
raise RuntimeError("TensorRT rejected a YOLOv5 scale constant")
140+
values = layer.get_output(0)
141+
if values.dtype != tensor.dtype:
142+
cast = network.add_cast(values, tensor.dtype)
143+
if cast is None:
144+
raise RuntimeError("TensorRT rejected a YOLOv5 scale cast")
145+
values = cast.get_output(0)
146+
product = network.add_elementwise(tensor, values, trt.ElementWiseOperation.PROD)
147+
if product is None:
148+
raise RuntimeError("TensorRT rejected a YOLOv5 scale product")
149+
return product.get_output(0)
150+
151+
152+
def constant(network, values: np.ndarray, *, dtype: np.dtype, like=None):
153+
layer = network.add_constant(
154+
values.shape, trt.Weights(np.ascontiguousarray(values, dtype=dtype))
155+
)
156+
if layer is None:
157+
raise RuntimeError("TensorRT rejected a YOLOv5 constant")
158+
output = layer.get_output(0)
159+
if like is None or output.dtype == like.dtype:
160+
return output
161+
cast = network.add_cast(output, like.dtype)
162+
if cast is None:
163+
raise RuntimeError("TensorRT rejected a YOLOv5 constant cast")
164+
return cast.get_output(0)
165+
166+
167+
def subtract(network, left, right):
168+
layer = network.add_elementwise(left, right, trt.ElementWiseOperation.SUB)
169+
if layer is None:
170+
raise RuntimeError("TensorRT rejected a YOLOv5 subtraction")
171+
return layer.get_output(0)
172+
173+
174+
def reduce_max(network, tensor, axis: int, *, keep_dims: bool):
175+
layer = network.add_reduce(tensor, trt.ReduceOperation.MAX, 1 << axis, keep_dims)
176+
if layer is None:
177+
raise RuntimeError("TensorRT rejected a YOLOv5 max reduction")
178+
return layer.get_output(0)
179+
180+
181+
def top_k(network, tensor, *, k: int, axis: int):
182+
"""Largest `k` values along one axis, with their indices."""
183+
layer = network.add_topk(tensor, trt.TopKOperation.MAX, k, 1 << axis)
184+
if layer is None:
185+
raise RuntimeError("TensorRT rejected a YOLOv5 top-k")
186+
return layer.get_output(0), layer.get_output(1)
187+
188+
189+
def gather(network, tensor, indices, *, axis: int):
190+
layer = network.add_gather(tensor, indices, axis)
191+
if layer is None:
192+
raise RuntimeError("TensorRT rejected a YOLOv5 gather")
193+
return layer.get_output(0)
194+
195+
196+
def multiply(network, left, right):
197+
"""Element-wise product; TensorRT broadcasts size-one axes."""
198+
layer = network.add_elementwise(left, right, trt.ElementWiseOperation.PROD)
199+
if layer is None:
200+
raise RuntimeError("TensorRT rejected a YOLOv5 product")
201+
return layer.get_output(0)
202+
203+
204+
def floor_divide(network, left, right):
205+
layer = network.add_elementwise(left, right, trt.ElementWiseOperation.FLOOR_DIV)
206+
if layer is None:
207+
raise RuntimeError("TensorRT rejected a YOLOv5 floor division")
208+
return layer.get_output(0)

0 commit comments

Comments
 (0)