在maixcam2上部署YOLO11-OBB旋转目标检测教程
因为网上资料确认少并且maixcam2算是比较新出的板子,在转化链方面相对于maixcam pro做了比较大一个革新技术故出此教程 ,笔者也算是踩了不少坑所以建议各位学者能利用agent ai进行学习一来文章出了什么纰漏可以指出二来也可以提升诸位的效率(教程从环境搭建开始,如果已经有了yolo11obb的环境可以跳过此部分)教程仅作参考因为我毕竟不是开发人员,只是侥幸成功而已
另外诸位在看我这篇文章前不如先读读官方的教程这比任何教程都要管用!
为 MaixCAM MaixPy 离线训练 YOLO26/YOLO11/YOLOv8 模型,自定义检测物体、关键点检测 - MaixPy
将 ONNX 模型转换为 MaixCAM2 MaixPy 可以使用的模型(MUD) - MaixPy
这边贴出全部环境配置




上面有些我踩的坑我后面再说
1.前言
YOLO11-OBB 是 Ultralytics 基于 YOLO11 架构推出的有向边界框(Oriented Bounding Box)检测模型,专门用于检测具有任意旋转角度的目标。与传统的水平边界框不同,OBB 通过增加一个旋转角度参数,能够更紧密地贴合倾斜、狭长或密集排列的物体,有效减少背景干扰,特别适用于遥感图像、航拍影像、文本检测、机器人抓取等场景。
作为 YOLO 家族的一员,YOLO11-OBB 继承了单阶段实时检测的优势,并融合了多项先进设计:在骨干网络中使用改进的 C3k2 模块和通道注意力机制,颈部采用优化的特征金字塔结构,检测头则采用解耦设计并专门输出五参数旋转框(中心点坐标、宽高和角度)。它提供了从 YOLO11n-obb 到 YOLO11x-obb 等多种尺寸的预训练模型,在 DOTA 等权威旋转目标检测基准上达到了精度与速度的优越平衡,既适合高精度离线分析,也能满足边缘端实时推理的需求。
凭借简洁易用的 API 和丰富的生态支持,YOLO11-OBB 大幅降低了旋转目标检测的落地门槛,成为当前兼顾性能与工程实用性的代表性方案之一。
2.检查电脑相关配置
深度学习训练依赖 NVIDIA 独立显卡进行 GPU 加速。如果没有 NVIDIA 显卡(例如只有集成显卡或 AMD 显卡),则只能使用 CPU训练,速度会慢数十倍,基本不具备实用性。
如何确认自己有没有 NVIDIA 独立显卡:
1. 按 Ctrl + Shift + Esc 打开任务管理器
2. 点击 "性能" 选项卡
3. 左侧列表中找到 "GPU",查看右侧显示的显卡型号
4. 如果型号中包含 "NVIDIA" 或 "GeForce/RTX" 字样,说明有独立显卡,可以用于训练
如果不确定看到的是不是独立显卡,把型号名称复制到搜索引擎查一下即可。

3.安装yolo11OBB训练所需要的软件(这里我就简写一点了毕竟网上很多搜得到)
安装 Anaconda3和pycharm以及安装cuda及cudnn等都可以查看这篇文章,人家已经写得很详细了我这里就不细说了,我当初也是跟着人家来的主要是看自己显卡的版本是多少(注意版本版本版本!!!这个教程用的是老一点的版本当然我没试过可以用当我没说)目标检测:YOLOv11(Ultralytics)环境配置,适合0基础纯小白,超详细-CSDN博客

关于别人教程是yolo11而我们用的是yolo11obb有什么要注意的(真心建议诸位用agent太方便了)

安装 Docker Desktop + 导入 Pulsar2 镜像(这是maixcam2转化环境需要到的)
1. 安装 Docker Desktop
1. 打开 https://www.docker.com/products/docker-desktop/(看这个一篇就够!Windows上Docker Desktop安装 + 汉化完整指南(包含解决wsl更新失败方案)_docker汉化-CSDN博客)都不细说,安装个软件没有什么难的
2. 点击 Download for Windows 下载安装包
3. 双击运行安装程序
4. 安装过程中:
- 勾选 "Use WSL 2 instead of Hyper-V"(推荐)
- 其他保持默认,一路 Next
5. 安装完成后重启电脑
6. 重启后打开 Docker Desktop,等待启动完成(任务栏 Docker 图标变绿色鲸鱼)
7. 验证安装成功,打开命令行输入:
docker --version
能显示版本号就行。
▎ 注意:Docker Desktop 需要开启虚拟化。如果启动报错,进 BIOS 开启 Intel VT-x 或 AMD-V。
2. 导入 Pulsar2 编译器镜像
Pulsar2 是把 ONNX 模型编译成 MaixCAM2 能用的 axmodel 格式的工具,以 Docker 镜像方式提供。
获取镜像文件:AXERA-TECH/Pulsar2 at main(国内好像访问不了)
或者找你的官方卖家或去 Sipeed 官方资料下载 pulsar2_v6.0.tar(或类似文件名)。
导入镜像:
打开命令行,进入 tar 文件所在目录,执行:
docker load -i pulsar2_vxx.tar.gz
等待导入完成,会显示类似 Loaded image: pulsar2:6.0 的提示。
验证:
docker images | findstr pulsar2
能看到 pulsar2 镜像就说明导入成功。
再测试能不能运行:
docker run --rm pulsar2:6.0 pulsar2 --version
▎ 注意:这里不要写 /bin/bash -c,Pulsar2 镜像的 ENTRYPOINT 已经是 bash,重复写会报错。
总结环境
基础环境配置就这些了。总结一下:
电脑端:
1. 安装 Anaconda3(自带 Python)
2. 安装 PyCharm(可选)
3. 创建 conda 环境 + 安装依赖
4. 确认 GPU 能用
5. 安装 Docker Desktop + 导入 Pulsar2 镜像
设备端:
6. 烧录 MaixCAM2 系统镜像
7. 连接设备(USB/WiFi)+ SSH 登录验证
接下来就是数据标注 → 训练 → 导出 → 编译 → 部署,从下面第 4 节开始一步步来,全流程都在这一篇里写完了。
4.准备数据并且进行数据标注
4.1数据准备阶段
这个也简单不用多说,就是用你手上的maixcam去拍照(这边注意一下分辨率还有就是maixcam2下打开屏幕显示和不打开有一定的帧率影响看个人,笔者喜欢448*448的分辨率NPU推理就1ms)
教你从零开始在MaixCAM上部署自己本地训练的Yolov5模型(2)- 数据集的准备-CSDN博客
例程也有打开摄像头的代码,不会的先去补补基础

4.2 用什么标注
OBB 是旋转框,普通的矩形标注工具画不出角度,得用支持旋转矩形的工具:
- X-AnyLabeling:推荐,支持旋转矩形,还能加载 ONNX 模型做 AI 预标注,越标越快
- roLabelImg:老牌工具,专门标旋转框,界面简陋但够用
- labelme:只能用"多边形"画四个角来凑,能用但麻烦
我用的 X-AnyLabeling。标注时按住旋转手柄把框贴着目标转,框要尽量贴紧目标边缘,OBB 的角度就是从这个框算出来的,标歪了模型学到的角度就是歪的。
一个提效的小建议:先标 100 张左右,训一版 40 epoch,把导出的 ONNX 丢回 X-AnyLabeling 当预标注模型,剩下的图它会自动画框,你只改错的。我 450 张就是这么标完的,比纯手工快一倍以上。
4.3 标签格式:OBB 和普通检测完全不一样
这里是新手最容易翻车的地方。普通 YOLO Detect 的标签是 5 个数:
class cx cy w h
而 Ultralytics 的 OBB 用的是 DOTA 格式,8 个数,四个角点坐标,不是 cx cy w h angle:
class x1 y1 x2 y2 x3 y3 x4 y4
全部归一化到 0~1。我数据集里真实的一行长这样:
0 0.637100 0.487254 0.564353 0.246630 0.850987 0.155852 0.919382 0.373470
网上有不少 OBB 博客写的是 cx cy w h angle 那种六字段格式,那是别的框架(比如 mmrotate)的格式,喂给 Ultralytics 是训不出东西的。我自己就写过一个转六字段的脚本,后来发现根本用不上,纯属白折腾。
标注工具导出时直接选 DOTA / YOLO-OBB 格式就对了,不用自己转。
导出之后把图片全部放进 learn/images/,标签 txt 全部放进 learn/labels/,两边文件名必须一一对应(比如 img_001.jpg 对 img_001.txt),只是扩展名不同。下一节的划分脚本就是从这两个目录读的。
4.4 单类别必须写 nc=2(本教程第一个大坑)
假设你和我一样只检测一种东西(我检测黑色旋转矩形,类名 rect),你会很自然地写 nc: 1。这样部署到 MaixCAM2 上一定出问题。
原因在 MaixCDK 的解码器里。nn.YOLO11 的 OBB 解码是靠张量形状来区分哪个输出是分类、哪个是角度的:
// maix_nn_yolo11.hpp Mode 2
if (shape[1] > 1) // 当成 class
else // 当成 angle
单类别时 class 输出是 [1,1,4116],angle 输出也是 [1,1,4116],形状一模一样,解码器分不出来,结果就是框和角度全乱。
解决办法很简单:加一个占位类别,让 nc=2。
# dataset/data.yaml
train: images/train
val: images/val
nc: 2
names: ['rect', 'background']
几个关键点说清楚:
- 这个 background 是纯占位的,一张图都不用标它。我训练集里 360 个框全是 class 0,class 1 一个实例都没有,照样正常工作。
- 让 class 输出变成两通道的机制是 data.yaml 里的 nc: 2,不是"必须有 background 类的图片"。这两件事千万别搞混。
- 推理时把非 0 的类过滤掉就行:if obj.class_id == 0。
顺便交代一个我踩过的错,免得你重复:我一开始为了"凑出 background 类",写脚本把训练集里的正样本图片复制一份、配个空标签文件当背景图。后来做校验才发现,那些图和原图是逐字节完全相同的,同一批像素既被标成"有矩形"又被标成"什么都没有",是纯粹的矛盾标注。我这个任务太简单(纯色背景 + 单个高对比黑矩形)所以 mAP 还是 0.995 没看出问题,换个杂乱背景的任务就是给自己埋雷。
想加负样本是好事,但必须是真正拍的、画面里确实没有目标的图,配空标签文件;不能拿正样本复制。
5.划分训练集和验证集
标完的图和标签先放一起,我的目录是这样:
yolo11obb/
├─ learn/
│ ├─ images/ # 450 张 jpg
│ └─ labels/ # 450 个 txt,一一对应
└─ dataset/ # 下面这个脚本自动生成
划分脚本 scripts/split_dataset.py,直接抄:
"""把 learn/ 划分成 dataset/images|labels/{train,val}"""
import os
import random
import shutil
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
src_images = os.path.join(BASE, "learn", "images")
src_labels = os.path.join(BASE, "learn", "labels")
dst = os.path.join(BASE, "dataset")
val_ratio = 0.2
seed = 42 # 固定种子,保证每次划分结果一样
random.seed(seed)
images = [f for f in os.listdir(src_images)
if f.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp'))]
print(f"Total images: {len(images)}")
random.shuffle(images)
split_idx = int(len(images) * (1 - val_ratio))
train_imgs, val_imgs = images[:split_idx], images[split_idx:]
print(f"Train: {len(train_imgs)}, Val: {len(val_imgs)}")
for sub in ["images/train", "images/val", "labels/train", "labels/val"]:
os.makedirs(os.path.join(dst, sub), exist_ok=True)
for split_name, img_list in [("train", train_imgs), ("val", val_imgs)]:
for img_name in img_list:
shutil.copy2(os.path.join(src_images, img_name),
os.path.join(dst, "images", split_name, img_name))
label_name = os.path.splitext(img_name)[0] + ".txt"
src_label = os.path.join(src_labels, label_name)
if os.path.exists(src_label):
shutil.copy2(src_label,
os.path.join(dst, "labels", split_name, label_name))
else:
print(f"Warning: no label for {img_name}")
print("Done!")
跑完是 train 360 张 / val 90 张。把 data.yaml 放在 dataset/ 目录下,内容就是上面 4.4 给的那份。
顺带提一句:固定 seed 很重要。不固定的话你每次重跑划分,验证集里的图都不一样,两次训练的指标就没法比,根本分不清是你改的参数起了作用还是划分变了。
6.开始训练
下面命令里的 yolo11obb 只是我第 3 节建的那个 conda 环境的名字,你自己叫什么就换成什么。另外这个环境建议单独留给 OBB 用,不要和普通 YOLO 检测的环境混在一起——OBB 的 ONNX 图结构和后面的改图脚本都是特殊的,混用容易出莫名其妙的问题。
先把 yolo11n-obb.pt 预训练权重下到项目根目录(Ultralytics 官方 release 里有,注意是带 -obb 后缀的那个,别下成普通检测的 yolo11n.pt)。
训练脚本 scripts/train.py:
"""Train YOLO11 OBB model."""
import os
if __name__ == '__main__': # 这行必须有!原因见下面
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(BASE)
print(f"Working dir: {os.getcwd()}")
from ultralytics import YOLO
import torch
print(f"PyTorch: {torch.__version__}")
print(f"CUDA: {torch.cuda.is_available()}")
if torch.cuda.is_available():
print(f"GPU: {torch.cuda.get_device_name(0)}")
model = YOLO('yolo11n-obb.pt')
results = model.train(
data='dataset/data.yaml',
epochs=100,
imgsz=448, # 和你部署时的输入分辨率保持一致
batch=16,
device=0, # 用 GPU;没显卡写 'cpu'
project='runs/obb',
name='yolo11n_obb_448',
exist_ok=True,
)
print(f"Best weights: {results.save_dir}/weights/best.pt")
跑起来:
conda activate yolo11obb
cd D:\你的路径\yolo11obb
python scripts/train.py
6.1 两个必踩的坑
坑一:Windows 下必须写 if __name__ == '__main__':
不写的话报这个:
RuntimeError: An attempt has been made to start a new process before the
current process has finished its bootstrapping phase.
因为 Windows 创建子进程用的是 spawn,会把主模块重新 import 一遍,没有这个保护就会无限递归开进程。Linux 下用 fork 不会有这问题,所以你抄网上 Linux 的脚本就容易踩。
坑二:报 MemoryError 或 DataLoader worker exited,先查内存不是显存
报错长这样:
RuntimeError: DataLoader worker (pid xxxx) exited unexpectedly
numpy.core._exceptions._ArrayMemoryError: Unable to allocate 4.69 MiB
"4.69 MiB" 这个数字很小,很容易误判成显存不够或者代码有 bug,实际是主机内存(RAM)不够。mosaic 增强每个样本要开 imgsz*2 × imgsz*2 × 3 的画布,每个 DataLoader worker 独立持有一份,默认 workers=8 时并发峰值能到好几个 GB。我 16GB 内存的机器,开浏览器之后就剩 5GB,workers 一上 4 就崩。
按这个顺序试:
- train() 里加 workers=0(单进程加载,最省内存)或 workers=2
- 训练前把数据集图片缩到长边 ≤1280。YOLO 标注是归一化坐标,缩放不需要改标签文件,但改完必须删掉 labels 目录下的 *.cache,否则缓存里还是旧尺寸
- batch 降到 8,或 imgsz 降到 320
- 最后才考虑 mosaic=0(小目标检测很依赖它,能不关就不关)
- 训练前关掉浏览器等吃内存的进程
6.2 我的实测结果
RTX 4060 Laptop,448×448,100 epoch,总共 365 秒,六分钟就完了:
| 指标 | 数值 |
| precision | 0.99852 |
| recall | 1.0 |
| mAP50 | 0.995 |
| mAP50-95 | 0.995 |
| angle_loss | 0.00272 |
指标这么高是因为我的任务确实简单(纯色背景上一个高对比度的黑矩形)。你换成复杂场景不要期待这个数,重点看 angle_loss 有没有降下来,那个才是 OBB 训好没训好的关键。
权重出在 runs/obb/yolo11n_obb_448/weights/best.pt。
7.导出 ONNX
scripts/export_onnx.py:
"""Export the trained YOLO11-OBB model to ONNX."""
import shutil
from pathlib import Path
from ultralytics import YOLO
PROJECT_DIR = Path(__file__).resolve().parent.parent
MODEL_PATH = PROJECT_DIR / "runs" / "obb" / "yolo11n_obb_448" / "weights" / "best.pt"
OUTPUT_PATH = PROJECT_DIR / "best.onnx"
def main():
model = YOLO(str(MODEL_PATH))
exported = Path(model.export(format="onnx", imgsz=448, simplify=True))
shutil.copy2(exported, OUTPUT_PATH)
print(f"ONNX exported: {OUTPUT_PATH}")
if __name__ == "__main__":
main()
7.1 先把一个误区讲清楚(很重要)
导出来的 ONNX 只有一个输出,叫 output0,形状 [1, 7, 4116]。
而 Sipeed 官方资料里列的是四个输出节点,很多博客也跟着写"你导出来应该有四个输出",还有人说"把 Ultralytics 降级到 8.3.x 就能直接导出官方那四个输出"。
这个说法是错的,我实测过。我在纯净 Docker 环境里逐个版本试过 8.3.0、8.3.78、8.3.240、8.3.253、8.4.51,同一个 best.pt,默认导出全部都是单一 output0 [1,7,4116],没有任何一个版本会直接给你四个 graph output。
官方列的那四个"输出"其实是 ONNX 里的中间节点,不是 graph output。想拿到它们必须自己动 ONNX 图,这就是下一节要干的事。别在版本上浪费时间了。
8.把 ONNX 改成 MaixCDK Mode 1 的四输出(最关键一步)
8.1 为什么要改
MaixCDK 的 nn.YOLO11 解码器有两种模式,靠输出数量自动选:
// 4 个输出且 type == OBB → 走 Mode 1(官方推荐)
// 其他 → 走 Mode 2
Mode 2 那条路我也走通过,但它的 OBB 代码有缺陷(就是 4.4 讲的 shape[1] 判断问题),要靠给张量取特殊名字去绕。正式部署建议走 Mode 1,它是官方方案,不依赖任何 trick。
8.2 四个输出长什么样
YOLO11-OBB 的检测头 model.23 里有三组分支,每组三个尺度:
- cv2 = DFL 回归框,每尺度 64 通道
- cv3 = 分类,每尺度 nc 通道(我们 nc=2,所以是 2)
- cv4 = 角度,每尺度 1 通道
要把它们重组成这样:
merged_det_0 [1, 66, 56, 56] # cv2 + cv3 拼起来,64 + 2 = 66
merged_det_1 [1, 66, 28, 28]
merged_det_2 [1, 66, 14, 14]
merged_angle [1, 1, 4116] # 三个尺度的 cv4 拉平后拼起来
那个 4116 是怎么来的?448 输入、stride [8,16,32],三个特征图分别是 56×56、28×28、14×14:
56×56 + 28×28 + 14×14 = 3136 + 784 + 196 = 4116
如果你的输入不是 448,这个数要自己重算,下面脚本里的 [56, 28, 14] 也要跟着改成 imgsz/8、imgsz/16、imgsz/32。
8.3 合并脚本
先装 onnx:pip install onnx onnxsim
scripts/merge_for_mode1.py:
"""把 YOLO11-obb 的 ONNX 改成 MaixCDK Mode 1 的四输出格式。"""
import onnx
from onnx import helper, TensorProto, numpy_helper
import numpy as np
model = onnx.load("best.onnx")
# 三组源节点(Reshape 之前的 4D 张量)
cv2 = [ # DFL box
"/model.23/cv2.0/cv2.0.2/Conv_output_0", # [1,64,56,56]
"/model.23/cv2.1/cv2.1.2/Conv_output_0", # [1,64,28,28]
"/model.23/cv2.2/cv2.2.2/Conv_output_0", # [1,64,14,14]
]
cv3 = [ # class
"/model.23/cv3.0/cv3.0.2/Conv_output_0", # [1,2,56,56]
"/model.23/cv3.1/cv3.1.2/Conv_output_0",
"/model.23/cv3.2/cv3.2.2/Conv_output_0",
]
cv4 = [ # angle
"/model.23/cv4.0/cv4.0.2/Conv_output_0", # [1,1,56,56]
"/model.23/cv4.1/cv4.1.2/Conv_output_0",
"/model.23/cv4.2/cv4.2.2/Conv_output_0",
]
# 先确认这些节点真的存在,名字对不上会在这里直接报出来
all_outputs = set()
for node in model.graph.node:
for out in node.output:
all_outputs.add(out)
for name_list, label in [(cv2, "cv2"), (cv3, "cv3"), (cv4, "cv4")]:
for name in name_list:
if name not in all_outputs:
print(f"ERROR: {label} node {name} not found!")
import sys; sys.exit(1)
print("All source nodes verified.")
new_nodes, new_initializers = [], []
# 第一步:每个尺度把 cv2 和 cv3 沿通道拼接 -> [1,66,H,W]
merged_names = []
for i in range(3):
merged_name = f"merged_det_{i}"
new_nodes.append(helper.make_node(
"Concat", inputs=[cv2[i], cv3[i]], outputs=[merged_name],
axis=1, name=f"merge_det_{i}"))
merged_names.append(merged_name)
# 第二步:每个 cv4 从 [1,1,H,W] 拉平成 [1,1,H*W]
reshaped_names = []
for i, hw in enumerate([56, 28, 14]): # 换分辨率要改这里
reshape_out, shape_name = f"angle_flat_{i}", f"angle_shape_{i}"
new_initializers.append(numpy_helper.from_array(
np.array([1, 1, hw * hw], dtype=np.int64), name=shape_name))
new_nodes.append(helper.make_node(
"Reshape", inputs=[cv4[i], shape_name], outputs=[reshape_out],
name=f"reshape_angle_{i}"))
reshaped_names.append(reshape_out)
# 第三步:三个角度拼成 [1,1,4116]
new_nodes.append(helper.make_node(
"Concat", inputs=reshaped_names, outputs=["merged_angle"],
axis=2, name="merge_angle"))
# 第四步:换掉 graph 的输出
while model.graph.output:
model.graph.output.pop()
for name, shape in [
(merged_names[0], [1, 66, 56, 56]),
(merged_names[1], [1, 66, 28, 28]),
(merged_names[2], [1, 66, 14, 14]),
("merged_angle", [1, 1, 4116]),
]:
model.graph.output.append(
helper.make_tensor_value_info(name, TensorProto.FLOAT, shape))
model.graph.node.extend(new_nodes)
model.graph.initializer.extend(new_initializers)
onnx.save(model, "best_mode1.onnx")
print("Saved: best_mode1.onnx")
for out in onnx.load("best_mode1.onnx").graph.output:
print(" ", out.name)
跑完再简化一次:
python scripts/merge_for_mode1.py
python -m onnxsim best_mode1.onnx best_mode1_sim.onnx
用 Netron 打开 best_mode1_sim.onnx 确认输出是四个、名字和形状都对上,再往下走。这一步不确认清楚,后面编译出来的模型上板一定是废的,而且报错信息还看不出根因。
如果节点名报 not found,说明你的 Ultralytics 版本对检测头的命名不一样。用 Netron 打开 best.onnx,找到最后那一坨 Conv,把三组分支的实际输出名抄下来替换即可,规律是 cv2/cv3/cv4 各三个尺度。
9.用 Pulsar2 编译成 AXModel
9.1 准备校准图
Pulsar2 要做 INT8 量化,需要一批校准图来统计数值分布。校准图必须来自你实际部署的场景,随便找 COCO 图片来校准自定义模型是没意义的。
具体做法:在项目根目录新建一个 calibration_images/ 文件夹,从 dataset/images/train 里挑 100 张能覆盖各种角度、光照和位置的图片复制进去,然后用下面脚本打包成 tar:
# scripts/gen_cali_images_tar.py,用法:python scripts/gen_cali_images_tar.py calibration_images 100
import sys, os, random, shutil
images_dir = sys.argv[1]
images_num = int(sys.argv[2])
valid = [os.path.join(images_dir, n) for n in os.listdir(images_dir)
if os.path.splitext(n)[1].lower() in [".jpg", ".jpeg", ".png"]]
print(f"{images_dir} have {len(valid)} images")
if len(valid) < images_num:
print(f"no enough images, have {len(valid)}, need {images_num}")
sys.exit(1)
idxes = random.sample(range(len(valid)), images_num)
shutil.rmtree("tmp_images", ignore_errors=True)
os.makedirs("tmp_images/images")
for i in idxes:
shutil.copyfile(valid[i], os.path.join("tmp_images", "images", os.path.basename(valid[i])))
os.chdir("tmp_images/images")
os.system("tar -cf ../images.tar *")
9.2 编译配置
新建 yolo11obb_build_config.json:
{
"model_type": "ONNX",
"npu_mode": "NPU2",
"quant": {
"input_configs": [
{
"tensor_name": "images",
"calibration_dataset": "tmp_images/images.tar",
"calibration_size": 100,
"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": "merged_det_0", "dst_perm": [0, 2, 3, 1] },
{ "tensor_name": "merged_det_1", "dst_perm": [0, 2, 3, 1] },
{ "tensor_name": "merged_det_2", "dst_perm": [0, 2, 3, 1] },
{ "tensor_name": "merged_angle" }
],
"compiler": {
"check": 3,
"check_mode": "CheckOutput",
"check_cosine_simularity": 0.9
}
}
这里有个细节别写错:三个 4D 张量要加 dst_perm 转成 NHWC,3D 的 merged_angle 不要加。加错了上板解出来的角度就是乱的。
9.3 编译
NPU2 是全算力版,NPU1(也叫 VNPU)是分给 AI-ISP 之后剩下的算力,两个都编出来,MUD 里都写上,板子会自己选:
# NPU2(全算力)
docker run --rm -v "$PWD:/work" -w /work pulsar2:6.0 pulsar2 build --target_hardware AX620E --input best_mode1_sim.onnx --output_dir out_npu2 --config yolo11obb_build_config.json
copy out_npu2\compiled.axmodel yolo11n_obb_448_npu.axmodel
# 把 config 里的 npu_mode 改成 NPU1,再编一次
docker run --rm -v "$PWD:/work" -w /work pulsar2:6.0 pulsar2 build --target_hardware AX620E --input best_mode1_sim.onnx --output_dir out_npu1 --config yolo11obb_build_config.json
copy out_npu1\compiled.axmodel yolo11n_obb_448_vnpu.axmodel
注意上面是两趟编译,中间要手动改一次配置:第一趟 npu_mode 保持 NPU2,编完把 compiled.axmodel 复制成 _npu.axmodel;然后打开 json 把 npu_mode 改成 NPU1,再跑第二趟,复制成 _vnpu.axmodel。不改配置就直接跑两遍的话,你会得到两个一模一样的 NPU2 模型,上板不报错但 VNPU 那条路是废的。
编译日志最后会打每个输出的 cosine similarity,我这边四个输出都是 1.0。
9.4 Docker 相关的三个坑
坑一:JSON 不能带 BOM。如果你用 PowerShell 的 Set-Content -Encoding UTF8 写 json,它会加 BOM 头,Pulsar2 的解析器不认,报:
No terminal defined for '\ufeff' at line 1 col 1
用编辑器存成"UTF-8 无 BOM",或者 PowerShell 里用 -Encoding utf8NoBOM。
坑二:不要在 Git Bash 里跑 docker。Git Bash 会把 -w /work 当成 Windows 路径去转换,变成 D:/Git/usr/work,然后你就会看到一堆莫名其妙的找不到文件。用 PowerShell 或者 WSL。
坑三:镜像的 ENTRYPOINT 已经是 bash。这个你前面导入镜像时已经提过了,这里再强调一次,因为很多人抄别的教程会写成:
# 错的,会报 cannot execute binary file
docker run --rm pulsar2:6.0 /bin/bash -c "echo hello"
# 对的
docker run --rm pulsar2:6.0 -c "echo hello"
# 真要覆盖 ENTRYPOINT
docker run --rm --entrypoint /bin/bash pulsar2:6.0 -c "echo hello"
9.5 一句很重要的提醒
Pulsar2 打出"编译成功、cosine similarity 接近 1.0"只能说明 ONNX 到 AXModel 的数值是一致的,完全不代表你的输出格式符合 MaixCDK 解码器的要求。
我在这上面卡过很久:编译全绿、相似度 1.0,上板就是不出框或者角度全歪。格式对不对只能靠去读 MaixCDK 源码确认(components/nn/include/maix_nn_yolo11.hpp),或者严格照着本文的四输出结构来。
10.写 MUD 文件
MaixCAM2 不是直接加载 axmodel,而是加载一个 .mud 描述文件,由它去指向真正的模型并告诉 MaixCDK 怎么解码。新建 yolo11obb.mud:
[basic]
type = axmodel
model_npu = yolo11n_obb_448_npu.axmodel
model_vnpu = yolo11n_obb_448_vnpu.axmodel
[extra]
model_type = yolo11
type = obb
input_type = rgb
labels = rect, background
input_cache = true
output_cache = true
input_cache_flush = false
output_cache_inval = true
mean = 0,0,0
scale = 1,1,1
三个最容易写错的字段:
- [basic] 的 type 必须是 axmodel,不是 yolo11。写成 yolo11 会报 model type not match, expect cvimodel, but yolo11,这个报错信息本身还挺误导人的。
- 模型用 model_npu / model_vnpu 两个键,不是 model = xxx。
- [extra] 的 type 要写 obb,写 detector 就会走普通检测的解码路径,角度直接丢了。
labels 要把占位类也写上(rect, background),数量得和 nc 对得上。
11.上板部署
11.1 传文件
四个文件一起传到 MaixCAM2 的 /root/model/ 目录(注意是 model 不是 models,我写错过一次找了半天):
/root/model/
├─ test_yolo11obb.py
├─ yolo11obb.mud
├─ yolo11n_obb_448_npu.axmodel
└─ yolo11n_obb_448_vnpu.axmodel
用 MaixVision 直接拖进去,或者 scp:
scp yolo11obb.mud yolo11n_obb_448_npu.axmodel yolo11n_obb_448_vnpu.axmodel test_yolo11obb.py root@设备IP:/root/model/
11.2 设备端代码
test_yolo11obb.py:
"""MaixCAM2 YOLO11-OBB 旋转矩形检测"""
from maix import camera, display, image, nn, app, time
detector = nn.YOLO11(model="/root/model/yolo11obb.mud", dual_buff=True)
cam = camera.Camera(detector.input_width(), detector.input_height(),
detector.input_format())
disp = display.Display()
while not app.need_exit():
img = cam.read()
t = time.ticks_ms()
objs = detector.detect(img, conf_th=0.7, iou_th=0.45)
infer_time = time.ticks_ms() - t
# 过滤掉占位类,只留 class_id == 0
rects = [o for o in objs if o.class_id == 0]
# 场景里只有一个目标,取置信度最高的那个
best = max(rects, key=lambda o: o.score) if rects else None
for obj in ([best] if best else []):
points = obj.get_obb_points()
msg = f'rect: {obj.score:.2f}, {obj.angle * 180:.1f}'
img.draw_string(points[0], points[1] - 4, msg, color=image.COLOR_RED)
detector.draw_pose(img, points,
8 if detector.input_width() > 480 else 4,
image.COLOR_RED, close=True)
print(f"conf={obj.score:.3f}, angle={obj.angle * 180:.1f}deg, "
f"pos=({obj.x},{obj.y},{obj.w},{obj.h})")
disp.show(img)
print(f"FPS: {time.fps():.1f}, NPU: {infer_time}ms")
运行:
python /root/model/test_yolo11obb.py
11.3 几个参数经验
最后总结几句这一路最值得记住的东西:
- conf_th 从 0.3 提到 0.7:单目标场景闪框会明显减少。多目标就别抄这个值。
- 只保留最高分:目标只有一个的话 max(rects, key=...) 比什么滤波都管用。
- obj.angle 的单位不是弧度,是 π 的倍数(范围大约 -0.25 到 0.75)。所以上面代码里 obj.angle * 180 就直接是度数了。别当成弧度再去 * 180 / π,算出来是错的。这个坑我卡了不短时间,源码里解码是 angle = (sigmoid(v) - 0.25) * π,返回给 Python 的是乘 π 之前的那个值。
- dual_buff=True 吞吐更高但会多一帧延迟;要低延迟拿最新帧就设 False。
12.常见报错对照表
这一节是我这一路踩下来的报错汇总,你遇到问题直接搜关键字对着看。
现象 / 报错 原因 怎么修 识别正常但所有框都歪约 45° MaixCDK 靠输出张量名字判断要不要再算一次 sigmoid,名字里没有 Sigmoid 就会二次 sigmoid,角度被压到定值附近 走本文的 Mode 1 四输出就不会有这问题(角度是独立的第四个输出)。如果你非要走 Mode 2 三输出,角度输出名必须包含 Sigmoid,比如 Sigmoid_angle 角度整体有个固定偏移 解码器里已经带了 -0.25 的偏移(angle = (sigmoid(v) - 0.25) * π),你在 ONNX 里又加了 Sub 节点,等于偏了两次 不要在 ONNX 里做角度偏移,交给解码器 model type not match, expect cvimodel, but yolo11 MUD 里 [basic] 的 type 写成了 yolo11 改成 type = axmodel get tensor idx error: 65535
RuntimeError: Invalid arguments: TensorsONNX 输出张量名和解码器期望的对不上(axmodel 会保留 ONNX 的输出名) 按本文的 merged_det_0/1/2 + merged_angle 命名,并确认 Netron 里看到的就是这四个 单类别时框和角度全乱 class 和 angle 输出形状都是 [1,1,N],解码器分不清 data.yaml 写 nc: 2 加占位类,见 4.4 Pulsar2 报 No terminal defined for '\ufeff' json 带了 BOM 头 存成 UTF-8 无 BOM docker 报 cannot execute binary file 镜像 ENTRYPOINT 已是 bash,又写了 /bin/bash 去掉 /bin/bash,直接 -c "命令" docker 找不到文件,路径变成 D:/Git/usr/work 在 Git Bash 里跑的,-w /work 被路径转换了 换 PowerShell 或 WSL RuntimeError: An attempt has been made to start a new process... Windows spawn 会重新 import 主模块 训练脚本加 if __name__ == '__main__': DataLoader worker exited / MemoryError 主机内存不够,不是显存 workers=0,见 6.1 ONNX 导出只有一个 output0 这是 Ultralytics 的正常行为,所有版本都这样 不用换版本,按第 8 节改图 编译全绿、相似度 1.0,上板就是不出框 数值一致不等于格式正确 回去核对四个输出的数量、形状、命名、dst_perm 13.性能数据和一点总结
我这套跑下来的实测数据:
- NPU 推理:约 1 ms(448×448,YOLO11n-obb,AX620E NPU2)
- 模型大小:约 2.8 MB(axmodel)
- 摄像头帧率:60fps 是硬件上限,和分辨率无关,是传感器限制。也就是说瓶颈根本不在 NPU 上,1ms 的推理时间对 60fps(16.7ms 一帧)来说完全够用,你要提帧率得从传感器和显示那边想办法
- 训练:100 epoch,RTX 4060 Laptop,365 秒
- OBB 的标签是 8 点多边形,不是 cx cy w h angle,别抄错框架的格式。
- 单类别一定要写 nc=2,这是 MaixCDK 解码器的形状判断决定的,不是玄学。
- 默认导出的 ONNX 只有一个输出,必须自己改图成四输出,降 Ultralytics 版本解决不了。
- 4D 输出加 dst_perm,3D 的角度不加。
- Pulsar2 编译成功、余弦相似度 1.0,不代表能用。格式是否符合解码器,只能去读 MaixCDK 源码或者严格照抄验证过的结构。这一条是整个流程里最值钱的经验——它决定了你是在两小时内搞定,还是卡一个礼拜。
有问题欢迎评论区交流,我踩过的坑基本都写在上面了。如果这篇对你有帮助,点个赞收藏一下 : )
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)