ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从YOLO11到OpenVINO IR:CPU部署推理实战指南

从YOLO11到OpenVINO IR:CPU部署推理实战指南

从训练好的 YOLO11 权重到能在 CPU 上丝滑推理的 OpenVINO IR 模型,这中间就差一条export命令。但就这一条命令,背后牵扯到 ONNX 中间格式、算子映射、精度校验和一堆容易踩的坑。这篇文章先把最基础的链路跑通:环境准备、导出命令、看懂 IR 文件、拿 IR 模型实际推理一张图。适合那些刚接触 OpenVINO 部署、对yolo11n_openvino_model这一堆文件发怵的人。读完你就能自己导出一份能用的 IR 模型,并且知道怎么验证它没导出失败。

1. 为什么非转不可:直接加载 PyTorch 权重不如转 IR

1.1 你以为的"导出"其实解决的是部署环境问题

我在实际部署中最大的体会是:PyTorch 权重不是不能直接跑到生产环境,而是它会把整个 PyTorch 运行时环境一起搬过去。你的服务器上要装对应版本的 torch、torchvision,还要小心 Python 版本、CUDNN 版本,任何一个对不上,模型加载到一半直接崩。你本来只是想跑个目标检测,结果先搭了半小时环境。

OpenVINO IR 不一样。它是把计算图和权重彻底剥离出来的静态模型文件,运行时只需要装一个轻量的openvino包,不依赖 torch。在只有 CPU 的边缘设备、工控机或者普通办公电脑上,这个区别非常关键。很多企业内网服务器根本没有 GPU,也不允许随便装 CUDA,IR 模型几乎是唯一现实的选择。

1.2 IR 到底是什么:一个 XML 加一个 BIN,没有第三只文件

IR 全称 Intermediate Representation,OpenVINO 的静态模型格式。它由两个核心文件组成:

  • .xml:模型的计算图描述,记录了每一个算子节点、节点之间的连接关系、每个算子的属性参数。
  • .bin:所有权重的二进制数据,一个大文件,按顺序排列。

这两个文件靠文件名关联。加载时你只需要指定.xml的路径,运行时会自动在同一目录下找同名的.bin。所以文件名一定不要手动乱改,改了一个另一个就跟丢。

打一个比方:.xml是建筑的施工图纸,.bin是石材和钢筋。图纸告诉你每个构件怎么放,钢材提供实际的强度。少了图纸没法施工,少了钢材图纸就是废纸。

1.3 什么时候转,什么时候不建议转

不是所有情况都适合转 IR。纯做实验、往往要改网络结构反复训练、调试,那直接在 PyTorch 里跑就行,每次导出一次反而拖慢迭代。

但如果是这几个场景,IR 几乎是当前最合适的方案:

  • 模型要部署到 Intel CPU、集成显卡或 NPU 上
  • 目标机器不方便安装重型 PyTorch 环境
  • 你需要比较严格的推理延迟控制,OpenVINO 在 Intel 平台上有针对性的算子优化和线程调度
  • 你要在多个 CPU 机器上批量复制部署,IR 文件完全可移植

另外提一句,如果你的硬件根本不是 Intel 平台,比如是 ARM 的边缘盒子,那么更应该考虑 ONNX Runtime 或 TensorRT 之类的方案。OpenVINO 对非 Intel 平台的优化有限,转了也不一定快。

2. 动手前的环境准备:版本选不对,后面全是泪

2.1 虚拟环境与 Python 版本

老规矩,永远不要往系统 Python 里直接裸装深度学习库。不同项目依赖互相打架,装完一个新库把另一个项目搞挂是常有的事。我习惯用venv或者 conda 建一个独立环境。

Python 版本方面,我建议至少 3.9 以上,3.10 或 3.11 更稳妥。太老的解释器对 OpenVINO 新版 wheel 不友好,版本太新又可能出现个别依赖编译问题。3.10 是我这几年用过最省心的一档。

拿 venv 举例:

python3.10 -m venv yolo_openvino_env source yolo_openvino_env/bin/activate pip install --upgrade pip

如果电脑上有多个 Python 版本,直接用python3.10 -m venv指定版本,避免python指令指向不明确的系统 Python。

2.2 安装 ultralytics 和 openvino 的正确姿势

接下来是核心依赖:

pip install ultralytics pip install openvino

ultralytics这个包会自动带上 torch 和 torchvision,不用手动安装,省去版本对齐的麻烦。如果你希望导出过程尽量干净,也可以装 CPU 版的 torch,减少 GPU 相关依赖:但这一步不是必须,默认装什么就用什么,导出的结果是一样的。

openvino在较新版本里已经自带了模型转换 API,也就是openvino.convert_model,Ultralytics 后台调的就是它。早期 OpenVINO 还要单独装openvino-dev才能用mo命令行转换工具,现在大部分场景不需要了。只有在跑老教程、见到mo --input_model yolo11n.onnx这种命令却发现mo不存在的时候,再补一句:

pip install openvino-dev

注意,不要盲目装最新版,如果机器上已经装过 openvino,先用pip show openvino看一下版本。升级到过新版本有时会和已有项目冲突,而我们的目标只是跑通导出,稳定优先。

2.3 先加载一份 YOLO11 权重验证环境

环境装完先别急着导出,第一步先确认模型能正常加载:

from ultralytics import YOLO model = YOLO("yolo11n.pt") print(model.model)

能打印出一堆模块结构,说明 torch、ultralytics 和权重文件都正常。yolo11n.pt是最小的 YOLO11 权重,模型只有几 MB,下载非常快。如果这一步就报错,优先检查网络、权重文件是否损坏、torch 是否装好。这步没通,后面一切都没意义,所以一定不要跳。

3. 导出内核:一条 export 命令背后到底发生了什么

3.1 最标准的导出命令与对应的目录结果

直接上代码。用 Python API:

from ultralytics import YOLO model = YOLO("yolo11n.pt") model.export(format="openvino", imgsz=640, batch=1)

或者命令行:

yolo export model=yolo11n.pt format=openvino imgsz=640 batch=1

两条命令的结果完全一样,默认在当前目录下生成一个叫yolo11n_openvino_model的文件夹。文件夹里核心内容是:

yolo11n_openvino_model/ ├── yolo11n.xml ├── yolo11n.bin ├── yolo11n.mapping └── ...(一些辅助 json 或 yaml,不影响推理)

yolo11n.mapping这个文件可能有也可能没有,取决于当前 Ultralytics 版本。它不是推理必须的文件,是导出时记录输入输出节点名称映射用的,不用管它。

看到这个文件夹,导出这一步就算成功了。真正的推理只要.xml和.bin这两个文件,整个文件可以拷走,甚至压缩发送到另一台机器都能用。

这里有一个关键参数说明:imgsz=640决定了输入尺寸。如果你的数据集不是默认的 COCO 尺寸,比如你训练时用的是 512,这里一定要改成你的训练尺寸,否则推理精度会下降。batch=1是推理部署最常用的配置,导出的模型会固定第一批大小,内存占用更可控。

3.2 内部链路:PyTorch 先到 ONNX,再到 IR

很多人以为export(format="openvino")是一次性把 PyTorch 权重转成 IR,其实内部是两步:

第一步,PyTorch 权重导出成 ONNX。ONNX 在这里扮演一个中间交换格式的角色,它把 PyTorch 的计算图暴露出来,形成一个跨框架的标准描述。你在导出的日志里会先看到类似YOLO11n export success → saved as yolo11n.onnx的信息,然后才出现 OpenVINO 相关的转换日志。

第二步,openvino.convert_model读取这个 ONNX 文件,把 ONNX 的计算图映射成 OpenVINO 的内部 IR 数据结构,最后序列化出.xml和.bin。

理解这个链路的意义在于:如果导出时报错,你要能判断错误发生在 ONNX 环节还是 OpenVINO 环节。ONNX 环节报错,多为模型结构或算子兼容性;OpenVINO 环节报错,多为 ONNX 文件里包含的算子 OpenVINO 不支持,或版本问题。

整个导出过程全程在 CPU 上完成,不需要 GPU。所以哪怕机器没有独立显卡,也完全不影响。

3.3 解读导出的 OpenVINO 模型文件夹

拿到 IR 文件后,推荐先看一眼模型结构:

import openvino as ov core = ov.Core() model = core.read_model("yolo11n_openvino_model/yolo11n.xml") for inp in model.inputs: print("input:", inp.any_name, inp.shape, inp.get_element_type()) for out in model.outputs: print("output:", out.any_name, out.shape, out.get_element_type())

正常情况下你会看到:

input: images f32[1,3,640,640] output: output0 f32[1,84,8400]

f32[1,3,640,640]表示输入是 1 张 RGB 图片,通道数 3,分辨率 640x640。[1,84,8400]中最关键的 84,它是4个边框坐标加80个类别分数,对应 COCO 数据集的 80 类目标检测。8400 是 YOLO11 在三个不同尺度特征图上生成的检测候选框总数。

如果你的模型不是 COCO 数据集训练的,比如自己标了个 5 类的数据集,那么输出第二维会是4 + 5 = 9,也就是[1,9,8400]。看到这个数字别慌,不是导出错误,是类别数变了。

这个输出形状非常重要,后面的后处理全部围绕它展开。

3.4 命令行转换工具与 Python API 的取舍

有时候你会看到网上教程用mo命令来转:

mo --input_model yolo11n.onnx --output_dir yolo11n_openvino_model

mo是 OpenVINO 早期的命令行转换工具,现在的openvino-dev包里还能找到,但官方新推的convert_modelAPI 更灵活。Python API 的优点是可以在转换脚本里同时做预处理、参数调整和数据校验,整个过程可以纳入你的项目代码库,可复现。命令行工具的优点是快速、适合临时转换。

我个人推荐用 Python API,因为后续如果要调整 input shape、动态 batch 这些参数,同一份脚本改一下就能重新导出,不用翻历史命令。

4. 推理验证:拿 IR 模型实测一张图

4.1 用 OpenVINO Runtime 读取 XML

导出完只是第一步,能不能跑出正确的检测结果才是关键。很多情况下模型能导出,但推理结果全是噪声或者坐标完全错位,十有八九是预处理或后处理没配对。

用 OpenVINO Runtime 做推理不需要 torch,只需要openvino包。核心代码:

import cv2 import numpy as np import openvino as ov core = ov.Core() model = core.read_model("yolo11n_openvino_model/yolo11n.xml") compiled_model = core.compile_model(model, "CPU") # 也可以用 compile_model 直接传路径 # compiled_model = core.compile_model("yolo11n_openvino_model/yolo11n.xml", "CPU") infer_request = compiled_model.create_infer_request()

create_infer_request()创建一个推理请求,这个对象负责装载输入、执行推理、取得输出。如果不想显式创建 request,也可以直接compiled_model([input_array]),但对要重复调用的场景,请求复用更合理。

这步如果报模型文件找不到,检查你当前的工作目录和.xml文件路径是否一致,路径问题占了这类报错的八成。

4.2 预处理细节:letterbox 和 RGB 顺序

这里有个非常容易踩坑的点:训练时用的预处理是 letterbox 拉伸 + 灰色填充,推理时必须完全复现,否则检测精度会严重下降。

YOLO11 的输入是方形 640x640,但实际图片几乎都不是方形。letterbox 的做法是保持宽高比缩放,把最长边缩放到 640,剩余区域用灰色 114 填充。直接cv2.resize成 640x640 会让目标变形,框的坐标定位自然不准。

实现代码:

def letterbox(img, new_shape=(640, 640), color=(114, 114, 114)): shape = img.shape[:2] # H, W r = min(new_shape[0] / shape[0], new_shape[1] / shape[1]) new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r)) dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1] dw, dh = dw // 2, dh // 2 if (new_unpad[0], new_unpad[1]) != (shape[1], shape[0]): img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR) top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1)) left, right = int(round(dw - 0.1)), int(round(dw + 0.1)) img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color) return img, r, (dw, dh)

返回的r是缩放比例,(dw, dh)是填充的宽高,后处理要把检测框还原到原图坐标时必需。

除了 letterbox,通道顺序也要注意。Ultralytics 训练时用的是 RGB 顺序,而 OpenCV 默认读出来的是 BGR。如果你直接用cv2.imread读图片,再送进模型,颜色通道是反的,检测结果可能不报错,但会漏检或误检。

正确的做法有两种:一种是读图后做一次通道翻转:

img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

另一种是干脆用 RGB 读入,再把 HWC 顺序转成 CHW,最后归一化到[0,1]:

input_tensor = img.transpose(2, 0, 1)[None].astype(np.float32) / 255.0

很多网上教程漏了这步,导致推理结果乱七八糟。我做模型部署时,每次写推理脚本的第一件事就是确认通道顺序。

4.3 输出解码与坐标还原

IR 模型的输出是(1, 84, 8400),注意这个形状的含义。在 NumPy 层面,这个张量的第一个维度是 batch,第二个维度 84,第三个是候选框数量 8400。

方便处理的姿势是先转置再 reshape:

output = infer_request.get_output_tensor().data # 实际上是 (1, 84, 8400) output = output[0].transpose(1, 0) # 变成 (8400, 84)

每一行对应一个候选框。前 4 个数是cx, cy, w, h,是模型在 640x640 letterbox 坐标系下的预测值。接下来 80 个数是 80 个类别的分数。

要还原到原图坐标:

cx, cy, w, h = output[i, :4] x1 = (cx - w / 2 - pad_w) / r y1 = (cy - h / 2 - pad_h) / r x2 = (cx + w / 2 - pad_w) / r y2 = (cy + h / 2 - pad_h) / r

这里的pad_w和pad_h是 letterbox 时左边和上边的填充量,r是缩放比例。坐标还原错一个符号,框的位置就会整体偏。

类别分数取这一行的最大值和对应索引:

scores = output[i, 4:] class_id = np.argmax(scores) score = scores[class_id]

只有分数大于阈值,比如 0.25,才把这一行判定为正样本候选框。

4.4 简单 NMS 与可视化

同一个目标往往会输出多个高度重叠的候选框,这就是重复检测。NMS(非极大值抑制)的作用是在这些重叠框中保留分数最高的那个,去掉邻居。

OpenCV 提供了一个现成的 NMS 函数:

boxes = [] scores = [] labels = [] for i in range(len(output)): scores = output[i, 4:] score = scores.max() if score < conf_thres: continue # ... 计算 x1 y1 x2 y2,坐标还原到原图 boxes.append([x1, y1, x2, y2]) scores.append(float(score)) labels.append(int(scores.argmax())) if boxes: boxes = np.array(boxes) scores = np.array(scores) indices = cv2.dnn.NMSBoxes(boxes.tolist(), scores.tolist(), score_threshold=conf_thres, nms_threshold=0.45)

NMS 的nms_threshold一般取 0.4~0.6。太高会导致两个不同目标合并,太低会漏检紧密相邻的目标。

最后用 OpenCV 画框标注:

for idx in indices: x1, y1, x2, y2 = boxes[idx] label = f"{class_names[labels[idx]]} {scores[idx]:.2f}" cv2.rectangle(img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) cv2.putText(img, label, (int(x1), int(y1)-10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2) cv2.imwrite("result.jpg", img)

到这一步,一张图的检测流程就完整跑通了。如果框的位置和类别基本正确,说明 IR 模型导出和推理链路都是通的。

4.5 性能对比:OpenVINO CPU vs PyTorch CPU

完整推理跑通后,顺手做一个耗时对比是很有价值的。我拿手头一台不带独显的办公本测 YOLO11n 模型推理一张 640x640 图,PyTorch CPU 推理一次大概在 120ms 左右浮动,OpenVINO CPU 能压到 60ms 左右,快一倍是有的。不同机器、不同线程数设置差异比较大,但大方向是 OpenVINO 在 Intel CPU 上有明显优势。

跑耗时对比时注意两点:

  • 不要只跑一次,多跑几十次取均值,排除 CPU 频率波动和缓存冷启动。
  • 对比时统一输入数据,预处理和后处理的时间不计入。只比较模型本身的推理耗时。

我建议把耗时统计脚本固定下来,后续换模型、换机器、换线程数,都可以直接复用。

5. 导出路上的常见坑与排查思路

5.1 ONNX 转换环节提示算子不支持的应对

比较常见的是导出到一半报错,日志里出现Unsupported operator ...之类的字眼。这类错误通常发生在 ONNX 导出阶段或 OpenVINO 读取 ONNX 的阶段。

常见原因:

  • Ultralytics 版本较旧,对新的 OpenVINO 转换 API 适配不完整。解决方案是升级ultralytics到最新版。
  • OpenVINO 版本过旧,不识别新算子。解决方案是升级openvino。
  • 模型结构包含一些特殊自定义算子或动态控制流,这种在 YOLO11 标准模型上不常见,如果你改了模型结构,就要考虑回退到标准结构。

排查思路很简单:先看报错信息里出现的是哪个阶段的字眼。是 ONNX 导出失败,还是 OpenVINO 读取失败。前者优先查 Ultralytics 版本,后者优先查 OpenVINO 版本。新旧版本的排列组合是最常见的不兼容来源。

5.2 运行时提示模型文件找不到

用 OpenVINO Runtime 推理时,最常见的报错就是Failed to open model或类似找不到文件的提示。多数原因是路径写错了。

read_model("yolo11n_openvino_model/yolo11n.xml")这个路径是相对当前工作目录的。很多人在 Jupyter Notebook 里跑,当前目录和你以为的不一样,就会找不着文件。

建议养成绝对路径习惯,或者一上来就打印路径:

import os model_path = os.path.abspath("yolo11n_openvino_model/yolo11n.xml") print(model_path) model = core.read_model(model_path)

另外.xml和.bin必须同目录同名。拷贝时只带.xml,运行时会找不到.bin,报错会明确提示.bin路径问题。

5.3 推理结果全黑的 input 通道问题

一个典型的"导出成功但效果稀烂"的问题:图能画框,框的位置基本准,但类别全是错的,或者干脆一个目标都检不出来。

99% 是通道顺序错。用cv2.imread读入是 BGR,模型期望 RGB。处理办法前面说了,读图后cv2.cvtColor(img, cv2.COLOR_BGR2RGB)再送模型。改成 RGB 后,同样的循环检测代码不用动,效果立刻正常。

这类问题最难查,因为不报错,只有检测效果差。排查时可以在预处理前后把图像数组的像素均值打出来,如果和你训练时的统计差异很大,基本就是通道或归一化问题。

5.4 性能对比时线程数没设置

OpenVINO 默认线程数受设备核心数影响。用性能测试时,可以显式设置一下:

compiled_model = core.compile_model(model, "CPU", config={"PERFORMANCE_HINT": "LATENCY"})

LATENCY模式强调单次推理延迟最小,适合实时检测。如果需要同时跑多个视频流,THROUGHPUT模式更适合。这个设置不影响模型导出效果,只影响运行时的调度策略。

在实际项目中,我习惯在部署脚本里写成 config 字典,方便现场调整。

6. 先把这条链路跑通:一些体会与下一篇预告

这一篇的核心就是把导出链路完整走了一遍:从环境准备、导出命令到 IR 文件解读,再到用 OpenVINO Runtime 完成推理验证,最后踩了几个常见的坑。整个过程看起来不长,但每一个环节都有值得较真的细节:letterbox 的 padding 记录、通道顺序、输出张量的转置方式,这些在官方文档里往往一句话带过,实际写代码时处处是坑。

我自己的经验是:第一次跑通时不要贪多,先把标准流程原封不动复现一遍,确认检测结果正确,再去改输入尺寸、动态 batch、分块推理这些进阶玩法。很多人在第一步就卡住,往往不是因为技术多难,而是急于求成,把环境、路径、版本这些问题层层叠加,最后无从下手。

下一篇计划聊两个方向:一个是把 IR 模型封装成可复用的推理类,支持视频流和摄像头输入;另一个是校准精度,通过 POT 工具做 INT8 量化,把模型压到更小的体积和更低的延迟。如果你们对哪个更感兴趣,也可以留言告诉我。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进