File size: 12,077 Bytes
71b9099 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 | #!/usr/bin/env python3
"""Export task heads that have no Model Zoo example and compile them with RKNN."""
import argparse
import hashlib
import os
from pathlib import Path
import sys
import tempfile
TASKS = {
"yolov5n-cls": "classify",
"yolov8n-cls": "classify",
"yolo11n-cls": "classify",
}
YOLO26_TASKS = {"cls": "classify", "seg": "segment", "pose": "pose", "obb": "obb",
"sem": "semantic", "depth": "depth"}
TASKS.update({f"{family}{size}-cls": "classify"
for family in ("yolov5", "yolov8", "yolo11") for size in "sm"})
TASKS.update({f"yolo26{size}-{suffix}": task
for size in "nsm" for suffix, task in YOLO26_TASKS.items()})
SIZES = {"classify": 224, "semantic": 512, "depth": 384}
CHIPS = ("rk3576", "rk3588")
IMAGENET_MEAN = (0.485, 0.456, 0.406)
IMAGENET_STD = (0.229, 0.224, 0.225)
def load_yolov5_classifier(checkpoint, source):
"""Bake YOLOv5's ImageNet normalization into the input graph."""
import torch
sys.path.insert(0, str(source))
try:
from models.experimental import attempt_load
classifier = attempt_load(str(checkpoint), device=torch.device("cpu"), fuse=True)
finally:
sys.path.pop(0)
class NormalizeClassifier(torch.nn.Module):
def __init__(self, model):
super().__init__()
self.model = model
self.register_buffer("mean", torch.tensor(IMAGENET_MEAN).reshape(1, 3, 1, 1))
self.register_buffer("std", torch.tensor(IMAGENET_STD).reshape(1, 3, 1, 1))
def forward(self, images):
return self.model((images - self.mean) / self.std).softmax(dim=1)
return NormalizeClassifier(classifier).eval(), classifier.names
def split_yolo26(head, task, size):
"""Expose one-to-one heads before TopK, following the Zoo YOLO26 detection layout."""
import torch
from ultralytics.utils.tal import make_anchors
def forward(features):
predictions = head.forward_head(features, **head.one2one)
anchors, strides = make_anchors(features, head.stride, 0.5)
head.anchors = anchors.transpose(0, 1)
head.strides = strides.transpose(0, 1)
raw_boxes = predictions["boxes"]
left_top, right_bottom = raw_boxes.split(2, dim=1)
anchor = head.anchors.unsqueeze(0)
if task == "obb":
angle = predictions["angle"]
offset_x, offset_y = ((right_bottom - left_top) / 2).split(1, dim=1)
cosine, sine = angle.cos(), angle.sin()
rotated = torch.cat((offset_x * cosine - offset_y * sine,
offset_x * sine + offset_y * cosine), dim=1)
boxes = torch.cat((anchor + rotated, left_top + right_bottom), dim=1)
else:
boxes = torch.cat((anchor - left_top, anchor + right_bottom), dim=1)
outputs = (predictions["scores"].sigmoid(), boxes * (head.strides / size))
if task == "segment":
return (*outputs, predictions["mask_coefficient"], head.proto(features))
if task == "obb":
return (*outputs, angle)
points = head.kpts_decode(predictions["kpts"]).reshape(1, *head.kpt_shape, -1)
# Keep visibility and x/y on comparable scales for INT8 quantization.
points = torch.cat((points[:, :, :2] / size, points[:, :, 2:]), dim=2)
return (*outputs, points.reshape(1, head.nk, -1))
return forward
def export_checkpoint(checkpoint, destination, size, yolov5_source):
os.environ.setdefault("YOLO_AUTOINSTALL", "false")
import onnx
import torch
import ultralytics
import yaml
from ultralytics import YOLO
task = TASKS[checkpoint.stem]
if checkpoint.stem.startswith("yolov5"):
model, names = load_yolov5_classifier(checkpoint, yolov5_source)
head = None
else:
model = YOLO(str(checkpoint), task=task).model.cpu().float().eval()
head = model.model[-1]
names = getattr(model, "names", None) or {0: "depth"}
split = task in {"segment", "pose", "obb"}
if split:
if head.reg_max != 1 or getattr(head, "one2one_cv2", None) is None:
raise ValueError("Expected a DFL-free, one-to-one YOLO26 head")
# Fusion removes whichever trained branch is inactive.
head.end2end = True
if head is not None:
model.fuse(verbose=False)
with checkpoint.open("rb") as file:
digest = hashlib.file_digest(file, "sha256").hexdigest()
metadata = {
"task": task,
"batch": 1,
"channels": 3,
"imgsz": [size, size],
"names": names,
"preprocess": "center_crop" if task == "classify" else "letterbox",
"coordinates": "pixels",
"end2end": False,
"export_source": "model-zoo-inspired-pytorch",
"checkpoint": checkpoint.name,
"checkpoint_sha256": digest,
"ultralytics": ultralytics.__version__,
"pytorch": str(torch.__version__),
}
if split:
head.export = True
head.forward = split_yolo26(head, task, size)
metadata.update(coordinates="normalized", end2end=True)
output_names = ["Sigmoid_scores", "boxes"]
if task == "segment":
output_names.extend(("mask_coefficients", "prototypes"))
elif task == "pose":
output_names.append("keypoints")
metadata["kpt_shape"] = list(head.kpt_shape)
else:
output_names.append("angle")
elif task == "classify":
if head is not None:
head.export = True
else:
metadata["checkpoint_source"] = (
f"https://github.com/ultralytics/yolov5/releases/download/v7.0/{checkpoint.name}"
)
metadata["normalization"] = "ImageNet mean/std baked into ONNX"
output_names = ["probabilities"]
else:
# Evaluation mode emits the native grid before terminal full-image resize.
head.export = False
metadata["dense_resize"] = "half_pixel" if task == "semantic" else "align_corners"
output_names = ["logits" if task == "semantic" else "depth"]
image = torch.zeros(1, 3, size, size)
with torch.inference_mode():
expected = model(image)
tensors = expected if isinstance(expected, tuple) else (expected,)
if len(tensors) != len(output_names) or not all(torch.is_tensor(x) for x in tensors):
raise ValueError("Unexpected output contract from checkpoint head")
for name, tensor in zip(output_names, tensors):
print(f"{name}: {tuple(tensor.shape)}")
torch.onnx.export(model, image, str(destination / "model.onnx"), opset_version=13,
input_names=["images"], output_names=output_names,
dynamo=False, do_constant_folding=True)
graph = onnx.load(destination / "model.onnx")
onnx.checker.check_model(graph)
(destination / "metadata.yaml").write_text(yaml.safe_dump(metadata, sort_keys=False))
def compile_graph(source, destination, chip, precision, dataset):
import yaml
from rknn.api import RKNN
metadata = yaml.safe_load((source / "metadata.yaml").read_text())
metadata.update(target_platform=chip, precision="int8" if precision == "i8" else "fp16")
def check(code, step):
if code != 0:
raise RuntimeError(f"RKNN {step} failed ({code})")
toolkit = RKNN(verbose=False)
try:
check(toolkit.config(mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]],
target_platform=chip), "config")
check(toolkit.load_onnx(model=str(source / "model.onnx")), "load ONNX")
with tempfile.TemporaryDirectory(prefix="nvr-calibration-") as directory:
if precision == "i8" and metadata["checkpoint"].startswith("yolov5"):
dataset = center_crop_calibration(dataset, Path(directory), metadata["imgsz"][0])
check(toolkit.build(do_quantization=precision == "i8",
dataset=str(dataset) if dataset else None), "build")
check(toolkit.export_rknn(str(destination / "model.rknn")), "export")
finally:
toolkit.release()
(destination / "metadata.yaml").write_text(yaml.safe_dump(metadata, sort_keys=False))
def center_crop_calibration(dataset, directory, size):
"""Use the same square crop and linear resize as YOLOv5 classify/predict.py."""
import cv2
paths = []
for index, line in enumerate(dataset.read_text().splitlines()):
if not line.strip():
continue
source = dataset.parent / line.strip()
image = cv2.imread(str(source), cv2.IMREAD_COLOR)
if image is None:
raise ValueError(f"Cannot read calibration image: {source}")
height, width = image.shape[:2]
side = min(height, width)
top, left = (height - side) // 2, (width - side) // 2
image = cv2.resize(image[top:top + side, left:left + side], (size, size))
output = directory / f"{index:04}.png"
if not cv2.imwrite(str(output), image):
raise RuntimeError(f"Cannot write calibration image: {output}")
paths.append(str(output))
cropped = directory / "images.txt"
cropped.write_text("\n".join(paths) + "\n")
return cropped
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
export = commands.add_parser("export", help="Export one original checkpoint to ONNX")
export.add_argument("checkpoint", type=Path)
export.add_argument("output", type=Path, help="New directory for the ONNX and metadata")
export.add_argument("--imgsz", type=int, help="Override the square input size")
export.add_argument("--yolov5-source", type=Path, help="YOLOv5 v7.0 source checkout")
compile_parser = commands.add_parser("compile", help="Build one RKNN from an exported directory")
compile_parser.add_argument("onnx_directory", type=Path)
compile_parser.add_argument("output", type=Path, help="New directory for the RKNN and metadata")
compile_parser.add_argument("--chip", required=True, choices=CHIPS)
compile_parser.add_argument("--precision", required=True, choices=("fp", "i8"))
compile_parser.add_argument("--dataset", type=Path, help="RKNN calibration image list")
args = parser.parse_args()
destination = args.output.resolve()
if destination.exists():
parser.error(f"Output already exists: {destination}")
destination.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".convert-", dir=destination.parent) as directory:
work = Path(directory) / "output"
work.mkdir()
if args.command == "export":
checkpoint = args.checkpoint.resolve()
if not checkpoint.is_file() or checkpoint.stem not in TASKS:
parser.error("Provide one of these local checkpoints: " + ", ".join(TASKS))
size = args.imgsz or SIZES.get(TASKS[checkpoint.stem], 640)
if size < 32 or size > 4096 or size % 32:
parser.error("--imgsz must be a multiple of 32 from 32 to 4096")
yolov5_source = args.yolov5_source.resolve() if args.yolov5_source else None
if checkpoint.stem.startswith("yolov5") and (
yolov5_source is None or not (yolov5_source / "models/experimental.py").is_file()
):
parser.error("YOLOv5 classification requires a --yolov5-source v7.0 checkout")
export_checkpoint(checkpoint, work, size, yolov5_source)
else:
dataset = args.dataset.resolve() if args.dataset else None
if args.precision == "i8" and (dataset is None or not dataset.is_file()):
parser.error("INT8 requires an existing --dataset image list")
compile_graph(args.onnx_directory.resolve(), work, args.chip, args.precision, dataset)
work.rename(destination)
print(destination)
if __name__ == "__main__":
main()
|