将 YOLO11 的 ONNX 转换为 MaixCAM2 可运行的 MUD 模型

8.9k 词

本文记录一次把 YOLO11nONNX 模型转换成 MaixCAM2 可用文件的完整过程。

MaixCAM2 不能直接运行电脑上训练出来的 onnx 文件,一般需要先做 INT8 量化,再转换成 MaixCAM2 支持的 .axmodel,最后用一个 .mud 文件描述模型信息。最终我们需要得到这三个文件:

1
2
3
yolo11n.mud
yolo11n_npu.axmodel
yolo11n_vnpu.axmodel

其中:

  • yolo11n_npu.axmodel:使用完整 NPU 算力,对应 NPU2
  • yolo11n_vnpu.axmodel:使用虚拟 NPU,对应 NPU1,适合需要给 AI-ISP 留算力的情况。
  • yolo11n.mud:MaixPy 读取的模型描述文件,里面记录模型类型、类别标签、预处理参数和两个 axmodel 文件名。

本文参考了 Sipeed 的 MaixPy 文档:

准备文件

我的转换目录如下:

1
D:\code\onnx_to_mud\2\teach

目录中先放好模型和校准图片:

1
2
3
4
5
6
D:\code\onnx_to_mud\2\teach
├─ yolo11n.onnx
└─ images
├─ 000000000139.jpg
├─ 000000000885.jpg
└─ ...

images 文件夹是量化校准图片。图片最好来自模型真实使用场景,数量可以几十张到几百张。本文示例中用了 30 张图片,所以配置文件里 calibration_size30

查看模型

可以用 Netron 打开 yolo11n.onnx 查看模型结构。

对 MaixCAM2 的 YOLO11 目标检测模型,官方文档推荐使用方案一,也就是这三个输出节点:

1
2
3
/model.23/Concat_output_0
/model.23/Concat_1_output_0
/model.23/Concat_2_output_0

我这个模型的原始 ONNX 信息是:

1
2
3
4
5
Input:
images [1, 3, 640, 640]

Output:
output0 [1, 84, 8400]

原始最终输出 output0 [1,84,8400] 是 YOLO 后处理前的合并输出。为了让 MaixPy 的 nn.YOLO11 更容易识别,我没有直接拿 output0 做输出,而是把模型整理成三尺度检测头输出:

1
2
3
/model.23/Concat_output_0    [1, 144, 80, 80]
/model.23/Concat_1_output_0 [1, 144, 40, 40]
/model.23/Concat_2_output_0 [1, 144, 20, 20]

这里的 144 = 64 + 80,其中 64 是 YOLO11 的 bbox 分布回归通道,80 是 COCO 类别数。如果你是自定义类别,类别数不一定是 80,这里要按自己的模型输出为准。

Docker 环境

本文使用 Docker 中的 Pulsar2 工具链转换模型。我本机镜像是:

1
docker images

可以看到:

1
pulsar2:3.3

进入转换目录后,后面所有命令都在 PowerShell 中执行:

1
cd D:\code\onnx_to_mud\2\teach

这里把当前 Windows 目录挂载到 Docker 容器的 /data

1
docker run --rm --entrypoint /bin/bash -v "${PWD}:/data" pulsar2:3.3

我这个镜像里的 pulsar2 命令实际路径是:

1
/opt/pulsar2/pulsar2

所以脚本里直接使用这个完整路径。

准备转换目录

D:\code\onnx_to_mud\2\teach 下创建这些文件夹:

1
2
3
4
5
config
datasets
out
tmp
tmp1

作用如下:

1
2
3
4
5
config    存放 pulsar2 的 json 配置
datasets 存放校准图片打包后的 train.tar
out 存放最终生成的 mud 和 axmodel
tmp pulsar2 中间输出
tmp1 裁剪和整理后的 onnx 中间文件

整理 ONNX 输出节点

新建 prepare_yolo11_maixcam2_onnx.py,用于从原始模型中取出三个尺度的 bbox 和 cls 输出,并重新拼成 MaixPy 更好解析的三尺度输出。

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
import os
import sys

import onnx
from onnx import TensorProto, helper, shape_inference


def main():
src = sys.argv[1]
dst = sys.argv[2]
tmp = os.path.splitext(dst)[0] + "_six_outputs.onnx"
inputs = ["images"]
pairs = [
(
"/model.23/cv2.0/cv2.0.2/Conv_output_0",
"/model.23/cv3.0/cv3.0.2/Conv_output_0",
"/model.23/Concat_output_0",
[1, 144, 80, 80],
),
(
"/model.23/cv2.1/cv2.1.2/Conv_output_0",
"/model.23/cv3.1/cv3.1.2/Conv_output_0",
"/model.23/Concat_1_output_0",
[1, 144, 40, 40],
),
(
"/model.23/cv2.2/cv2.2.2/Conv_output_0",
"/model.23/cv3.2/cv3.2.2/Conv_output_0",
"/model.23/Concat_2_output_0",
[1, 144, 20, 20],
),
]
outputs = []
for bbox, cls, _, _ in pairs:
outputs.extend([bbox, cls])

os.makedirs(os.path.dirname(dst), exist_ok=True)
onnx.utils.extract_model(src, tmp, inputs, outputs)
model = onnx.load(tmp)

for bbox, cls, name, _ in pairs:
node = helper.make_node("Concat", [bbox, cls], [name], name=name.replace("_output_0", ""), axis=1)
model.graph.node.append(node)

model.graph.ClearField("output")
for _, _, name, shape in pairs:
model.graph.output.append(helper.make_tensor_value_info(name, TensorProto.FLOAT, shape))

model = shape_inference.infer_shapes(model)
onnx.checker.check_model(model)
onnx.save(model, dst)
print("saved", dst)


if __name__ == "__main__":
main()

如果你的模型不是标准 COCO 80 类 YOLO11n,或者 Netron 里节点名称不同,就要按自己的模型结构改 pairs 里的节点名和输出通道数。

编写 Pulsar2 配置

config/yolo11n.vnpu.json

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
{
"model_type": "ONNX",
"npu_mode": "NPU1",
"quant": {
"input_configs": [
{
"tensor_name": "images",
"calibration_dataset": "datasets/train.tar",
"calibration_size": 30,
"calibration_mean": [0, 0, 0],
"calibration_std": [255, 255, 255]
}
],
"calibration_method": "MinMax",
"precision_analysis": true
},
"input_processors": [
{
"tensor_name": "images",
"tensor_format": "RGB",
"tensor_layout": "NCHW",
"src_format": "RGB",
"src_dtype": "U8",
"src_layout": "NHWC",
"csc_mode": "NoCSC"
}
],
"output_processors": [
{
"tensor_name": "/model.23/Concat_output_0",
"dst_perm": [0, 2, 3, 1]
},
{
"tensor_name": "/model.23/Concat_1_output_0",
"dst_perm": [0, 2, 3, 1]
},
{
"tensor_name": "/model.23/Concat_2_output_0",
"dst_perm": [0, 2, 3, 1]
}
],
"compiler": {
"check": 3,
"check_mode": "CheckOutput",
"check_cosine_simularity": 0.9
}
}

config/yolo11n.npu.json 和上面基本一样,只把 npu_mode 改成 NPU2

1
"npu_mode": "NPU2"

几个关键参数说明:

  • target_hardware:命令行里指定为 AX620E,对应 MaixCAM2。
  • npu_mode = NPU1:虚拟 NPU,只用一部分 NPU 算力,生成 model_vnpu
  • npu_mode = NPU2:完整 NPU,生成 model_npu
  • calibration_dataset:校准图片打包后的 tar 文件。
  • calibration_size:校准图片数量,不能比实际图片数量大。
  • calibration_meancalibration_std:对应 YOLO 常见预处理 x / 255
  • dst_perm [0,2,3,1]:把输出从 NCHW 转成 NHWC,方便 MaixPy 后处理解析。

一键转换脚本

新建 convert_yolo11n_maixcam2.sh

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/bin/bash
set -e

cd /data
mkdir -p datasets out tmp tmp1

python3 prepare_yolo11_maixcam2_onnx.py yolo11n.onnx tmp1/yolo11n_maixcam2_heads.onnx
onnxsim tmp1/yolo11n_maixcam2_heads.onnx tmp1/yolo11n_maixcam2.onnx

tar -cf datasets/train.tar -C images .

rm -rf tmp/vnpu tmp/npu
mkdir -p tmp/vnpu tmp/npu

/opt/pulsar2/pulsar2 build --target_hardware AX620E --input tmp1/yolo11n_maixcam2.onnx --output_dir tmp/vnpu --config config/yolo11n.vnpu.json
cp tmp/vnpu/compiled.axmodel out/yolo11n_vnpu.axmodel

/opt/pulsar2/pulsar2 build --target_hardware AX620E --input tmp1/yolo11n_maixcam2.onnx --output_dir tmp/npu --config config/yolo11n.npu.json
cp tmp/npu/compiled.axmodel out/yolo11n_npu.axmodel

执行转换:

1
2
cd D:\code\onnx_to_mud\2\teach
docker run --rm --entrypoint /bin/bash -v "${PWD}:/data" pulsar2:3.3 /data/convert_yolo11n_maixcam2.sh

转换过程会比较久,中间会看到量化校准、精度分析和 NPU 编译日志。成功时会出现类似下面的信息:

1
2
3
check npu graph [subgraph_npu_0_b1] [/model.23/Concat_2_output_0] successfully
check npu graph [subgraph_npu_0_b1] [/model.23/Concat_output_0] successfully
check npu graph [subgraph_npu_0_b1] [/model.23/Concat_1_output_0] successfully

我这次转换后,三个输出被检查为:

1
2
3
/model.23/Concat_output_0    (1, 80, 80, 144)
/model.23/Concat_1_output_0 (1, 40, 40, 144)
/model.23/Concat_2_output_0 (1, 20, 20, 144)

这说明输出已经从 NCHW 转成了 NHWC

编写 MUD 文件

out 目录新建 yolo11n.mud

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[basic]
type = axmodel
model_npu = yolo11n_npu.axmodel
model_vnpu = yolo11n_vnpu.axmodel

[extra]
model_type = yolo11
type = detector
input_type = rgb
labels = person, bicycle, car, motorcycle, airplane, bus, train, truck, boat, traffic light, fire hydrant, stop sign, parking meter, bench, bird, cat, dog, horse, sheep, cow, elephant, bear, zebra, giraffe, backpack, umbrella, handbag, tie, suitcase, frisbee, skis, snowboard, sports ball, kite, baseball bat, baseball glove, skateboard, surfboard, tennis racket, bottle, wine glass, cup, fork, knife, spoon, bowl, banana, apple, sandwich, orange, broccoli, carrot, hot dog, pizza, donut, cake, chair, couch, potted plant, bed, dining table, toilet, tv, laptop, mouse, remote, keyboard, cell phone, microwave, oven, toaster, sink, refrigerator, book, clock, vase, scissors, teddy bear, hair drier, toothbrush
input_cache = true
output_cache = true
input_cache_flush = false
output_cache_inval = true

mean = 0,0,0
scale = 0.00392156862745098, 0.00392156862745098, 0.00392156862745098

如果是自己训练的模型,一定要把 labels 改成自己的类别,而且顺序要和训练数据集的类别顺序一致。

最终文件

转换完成后,out 目录里应该有:

1
2
3
4
D:\code\onnx_to_mud\2\teach\out
├─ yolo11n.mud
├─ yolo11n_npu.axmodel
└─ yolo11n_vnpu.axmodel

我这次生成的文件大小大致如下:

1
2
3
yolo11n.mud             1.0 KB
yolo11n_npu.axmodel 2.8 MB
yolo11n_vnpu.axmodel 3.3 MB

使用时把这三个文件放到 MaixCAM2 的同一个目录即可,例如:

1
2
3
/root/models/yolo11n.mud
/root/models/yolo11n_npu.axmodel
/root/models/yolo11n_vnpu.axmodel

MaixPy 加载测试

在 MaixCAM2 上可以用 maix.nn.YOLO11 加载:

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
from maix import camera, display, image, nn, app
import time

detector = nn.YOLO11("/root/models/yolo11n_teach.mud")
cam = camera.Camera(detector.input_width(), detector.input_height(), detector.input_format())
disp = display.Display()

last_time = time.time()
fps = 0

while not app.need_exit():
now = time.time()
dt = now - last_time
last_time = now

if dt > 0:
fps = fps * 0.9 + (1 / dt) * 0.1

img = cam.read()
objs = detector.detect(img, conf_th=0.5, iou_th=0.45)

for obj in objs:
img.draw_rect(obj.x, obj.y, obj.w, obj.h, color=image.COLOR_RED)

if obj.class_id < len(detector.labels):
label = detector.labels[obj.class_id]
else:
label = str(obj.class_id)

msg = f"{label}: {obj.score:.2f}"
img.draw_string(obj.x, obj.y, msg, color=image.COLOR_RED)

img.draw_string(5, 5, f"FPS: {fps:.1f}", color=image.COLOR_GREEN)
disp.show(img)

如果加载时报 model output not valid 一类错误,优先检查:

  • .mud 中的 model_type 是否是 yolo11
  • .mud 中的 type 是否是 detector
  • labels 数量是否和模型类别数一致。
  • 输出节点形状是否是三尺度检测头,而不是直接拿了最终 output0 [1,84,8400]
  • model_npumodel_vnpu 文件名是否和实际文件名一致。

小结

整个流程可以概括为:

1
2
3
4
5
6
7
yolo11n.onnx
-> 检查输入输出节点
-> 整理成三尺度 YOLO11 检测头 ONNX
-> 用校准图片做 INT8 量化
-> pulsar2 build 生成 npu/vnpu 两个 axmodel
-> 编写 yolo11n.mud
-> MaixPy 用 nn.YOLO11 加载运行

MaixCAM2 的目标硬件是 AX620E,转换时建议同时生成 NPU1NPU2 两个版本,这样后面是否启用 AI-ISP 都能选择合适的模型。

留言