Download source/convert-extended.py from HanzoHuang/YOLOv5-RKNN2: direct link, hf CLI and curl.
- Browser
- Download file 12.1 kB
-
https://huggingface.co/HanzoHuang/YOLOv5-RKNN2/resolve/main/source/convert-extended.py
- Command line
-
hf download hf://HanzoHuang/YOLOv5-RKNN2/source/convert-extended.py
-
curl -L -o convert-extended.py https://huggingface.co/HanzoHuang/YOLOv5-RKNN2/resolve/main/source/convert-extended.py
12.1 kB
| #!/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() | |