先交代一下背景:我最近在某台 Windows 10 办公机上做目标检测模型的部署加速,手头只有一个训练好的 YOLOv5s 预训练模型权重文件,目标很直接——在 CPU 环境下把推理速度提上去,最后的方案就是标题里那条链路:PyTorch 权重 → ONNX → OpenVINO IR → Python 推理测试。整个过程踩了不少坑,也总结了一些经验,这里完整记录下来,给同样在 Windows 上做模型部署的朋友当一条可复现的路线参考。
1. 环境准备:版本选型与依赖安装的取舍
1.1 Python 与 YOLOv5 源码的版本搭配
先说环境。我用的机器是 Windows 10 专业版,CPU 是常见的台式机处理器,没有独立显卡,所以加速只能靠 CPU。Python 我选了 3.9.13,这个版本在 OpenVINO 和 PyTorch 之间兼容性比较稳,不太容易撞上某些轮子装不上的问题。如果你电脑里已经装了别的版本,3.8 到 3.11 基本都能跑通,但我个人不建议一上来就追最新的 Python 3.12,因为 OpenVINO 某些旧版本的依赖库对它的支持并不好,反而浪费时间。
YOLOv5 源码我直接拉取了当时较新的 release 版本。需要注意,网上有很多 YOLOv5 的 fork,功能大同小异,但导出 ONNX 的入口脚本参数可能不完全一致。我建议使用官方仓库的版本,这样后面执行export.py时命令行为最标准,遇到问题也更容易搜到答案。拉下来之后在项目根目录执行:
pip install -r requirements.txt这一步会把 torch、torchvision、numpy、opencv-python 等基础依赖都装好。如果下载速度慢,可以临时换国内镜像源,比如-i https://pypi.tuna.tsinghua.edu.cn/simple,实测会快很多。
1.2 预训练权重与 OpenVINO 工具包的安装坑
预训练权重我直接用了 YOLOv5 官方发布的yolov5s.pt。这个文件大概 14~15MB,下载地址在官方仓库的 README 里就有。如果你之前下载过,注意确认文件没损坏,torch 加载时如果报unexpected EOF之类的错误,多半是权重文件没下全,重新下载一次就行。
OpenVINO 工具包我安装了完整版,因为里面不仅包含推理引擎,还有模型转换工具。安装命令是:
pip install openvino这里有一个非常容易踩的坑:如果你只装了openvino这个运行时包,后续做模型转换时还需要额外的工具。从 2022.3 版本开始,OpenVINO 把模型转换工具和运行时合并到了同一个发行包里,新版本直接提供了一个ovc命令,同时也保留了对旧版mo命令的兼容。为了确保命令可用,我建议用pip install openvino装最新稳定版,然后到 Python 的 Scripts 目录下确认有没有mo.exe或ovc.exe。如果用的是比较老的教程,里面写的是pip install openvino-dev,其实那是一条旧路线,现在统一了,不再需要单独装 dev 包。
装完之后可以用一个简单命令验证环境是否正常:
python -c "from openvino.runtime import Core; print(Core().available_devices)"能打印出['CPU']就说明推理引擎已经可以识别设备了。这一步很重要,千万不要跳过,我见过有同事装完包后没验证,直接跑到推理环节才发现 OpenVINO 根本没装对。
2. YOLOv5s 导出 ONNX:命令参数与常见报错排查
2.1 为什么要先转 ONNX 而不是直接转 IR
很多人会问,既然最终要用 OpenVINO,为什么不直接用 PyTorch 权重转 IR,非要中间插一个 ONNX 环节?这个问题的核心在于格式转换的成熟度。YOLOv5 官方对 ONNX 导出的支持做得非常完善,导出脚本里已经把很多算子的兼容性问题处理好了,而 OpenVINO 的模型优化器对 ONNX 格式的解析也是最稳的。PyTorch 直接转 IR 也不是不行,但中间层的算子解析容易出幺蛾子,遇到不支持的算子还得手动改图。
ONNX 在这里扮演的是一个“中间表示层”的角色。它像是一个通用翻译器,把 PyTorch 的动态图变成一份静态的计算图描述,这份描述记录清楚每个节点的输入输出张量形状、数据类型和算子类型。OpenVINO 拿到 ONNX 之后,再把它进一步优化成自己的 IR 格式,并按目标硬件生成对应的推理代码。多一道 ONNX 中转,其实是在给后续的部署减少不确定性。
2.2 导出命令的完整用法与参数解读
YOLOv5 源码根目录下有一个export.py脚本,它就是负责干这件事的。最基本的导出命令是:
python export.py --weights yolov5s.pt --include onnx默认情况下,这个命令会以 640x640 的输入尺寸导出 ONNX 文件。如果你对输入尺寸有特殊需求,比如想用 416x416 的输入来换取更高的推理速度,可以加上--imgsz 416。我这次为了对比最通用的效果,直接用默认的 640。
还有一个参数需要重点关注:--opset。OpenVINO 对 ONNX 算子的支持是分版本推进的,YOLOv5 官方默认导出时用的 opset 是 12,这个版本在 OpenVINO 上的兼容性实测极好。如果你把 opset 调到 13 以上甚至更高,模型转换工具可能会报一些“不支持的算子”或者“算子未注册”的错误。所以我建议在导出阶段直接指定 opset 12,最大程度避开兼容性暗礁。
另外,如果你打算在推理阶段处理动态输入尺寸,可以加--dynamic参数。但这并不意味着最终 OpenVINO 推理时一定是动态的。后面我会专门讲这个细节,简单说就是:动态 shape 会牺牲一部分推理性能,能固定就固定。
导出成功后会生成一个yolov5s.onnx文件。可以用下面这段代码简单验证一下 ONNX 模型是否完整:
pip install onnx python -c "import onnx; m = onnx.load('yolov5s.onnx'); onnx.checker.check_model(m); print('OK')"如果打印出 OK,就说明 ONNX 计算图结构正确,可以放心进入下一步转换。
2.3 导出过程中的典型报错与解决
导出这步虽然官方支持完善,但也不是百分百顺利。我遇到过的报错主要是两类。
第一类跟 torch 版本有关。某些较新的 torch 版本导出 YOLOv5 时会报Export the model to ONNX, but failed,并且错误信息里有Unsupported operator之类的字样。这时候优先检查一下是不是在导出命令里加了多余的参数,比如--simplify,这个参数依赖 onnx-simplifier 库,没装就会直接报错。如果想做图简化,先执行pip install onnx-simplifier,再在命令里加--simplify。
第二类报错是输出目录的权限问题。默认情况下 ONNX 文件会生成在 weights 文件同目录下。如果你的权重文件放在系统盘且目录权限受限,导出会在写文件时报权限错误。解决办法是显式指定目录,比如:
python export.py --weights yolov5s.pt --include onnx然后手动把生成的 onnx 文件复制到工作目录。其实更稳妥的做法是在项目根目录下建一个models文件夹,把权重文件放进去再导出。
导出完成之后,用 Netron 工具打开 ONNX 文件检查一下计算图结构是个好习惯。主要看两个地方:输入节点是不是images,形状是不是[1, 3, 640, 640];输出节点是不是一个一维数组,YOLOv5s 的输出形状是[1, 25200, 85]。这 85 维由 4 个坐标、1 个目标分数和 80 个类别分数组成。如果输出形状对不上,后面解析结果时一定会出问题。
3. OpenVINO 生成推理加速模型:从 ONNX 到 IR
3.1 Model Optimizer 转换命令与产物结构
得到 ONNX 模型之后,下一步用 OpenVINO 的模型优化器把它转成 IR 格式。IR 格式是 OpenVINO 的“母语”,它包含两个文件:一个.xml文件,描述计算图结构;一个.bin文件,保存权重参数。推理引擎加载模型时,就是同时读取这两个文件。
转换命令很简单:
mo --input_model yolov5s.onnx --output_dir openvino_model --model_name yolov5s如果你安装的是新版 OpenVINO,有可能mo命令已经被ovc替代。大部分情况下新版也会留一个兼容入口,实在不行可以直接用 Python 调:
python -m openvino.tools.mo --input_model yolov5s.onnx --output_dir openvino_model --model_name yolov5s执行完成后,openvino_model文件夹下会出现yolov5s.xml和yolov5s.bin。转换过程会打印出模型输入输出节点的信息,注意看一眼输入节点的 name,通常是images,输出是output0或类似名称。记下这些名字,后续写推理代码时会用到。
这里有个细节值得展开说。mo 工具在转换时实际上做了三件事:一是把 ONNX 计算图解析成 OpenVINO 的内部表示;二是做常量折叠、死节点消除这类静态优化;三是根据目标设备的指令集预生成一些加速算子。所以你会发现,同一个 ONNX 转出来的 IR 文件不一定完全相同,它会受 OpenVINO 版本影响,这也是为什么我强调视觉注意版本差异的原因。
3.2 FP16/FP32 精度选择与 CPU 推理的影响
转换时还有一个参数可以控制精度,就是--data_type。默认情况下 mo 工具会保持原模型的 FP32 精度。如果你的目标设备支持 FP16 且以 CPU 推理为主,可以尝试转成 FP16 格式,因为 FP16 的数据量减半,内存带宽占用减少,推理时缓存命中率提升,在某些 CPU 上能带来可观的性能收益。
但要注意,FP16 并不是在所有 CPU 上都有优势。OpenVINO 的 CPU 插件处理 FP16 模型时,会先把权重转换成 FP32 再做计算,中间有一定开销。我自己实测的结果是:在较新的 CPU 上 FP16 模型推理时间与 FP32 几乎持平,偶尔还慢个几毫秒;在老旧 CPU 上则反而可能变慢。所以如果你用的是普通台式机,建议老老实实用默认的 FP32,不必刻意追求 FP16。
3.3 转换后的模型验证
转换完成后,先用 OpenVINO 加载一下模型,做一次“哑推理”,确认计算图没问题。我写过一个简单的验证脚本:
from openvino.runtime import Core core = Core() model = core.read_model("openvino_model/yolov5s.xml") compiled_model = core.compile_model(model, "CPU") print(compiled_model)能正常打印出 compiled_model 信息,就说明 IR 模型已经成功加载。这一步如果报错,最常见的元凶是.xml和.bin文件名不匹配,或者是两个文件没有放在同一目录。OpenVINO 读.xml时会自动去同目录找同名.bin,如果你改名了或分开存放,就会报找不到权重文件的错误。
4. Python 推理测试:加载、预处理与输出解析
4.1 推理引擎加载与输入输出信息确认
加载编译好模型之后,先确认输入输出张量的形状。用下面这段代码:
from openvino.runtime import Core core = Core() model = core.read_model("openvino_model/yolov5s.xml") compiled_model = core.compile_model(model, "CPU") input_key = compiled_model.input(0) output_key = compiled_model.output(0) print(input_key, output_key)正常情况下输入是[1, 3, 640, 640],输出是[1, 25200, 85]。如果输出形状和你预期不符,先回头检查 ONNX 文件,多半是导出环节出了问题,不要在推理阶段硬调代码去适配一个错误的输出。
推理调用的接口非常简洁,整个核心执行代码只有两行:
result = compiled_model([input_blob])[output_key]这里的input_blob就是预处理之后的输入张量,形状是(1, 3, 640, 640),数据类型是 float32。OpenVINO 接收的图片数据格式是 NCHW,也就是先把通道数放到第二个维度,这一点和 OpenCV 读出来的 HWC 格式不一样,预处理时一定要做转置。
4.2 letterbox 预处理与归一化细节
YOLOv5 在训练时使用了一种叫 letterbox 的预处理方式。它先把原始图片按比例缩放到目标尺寸范围内,然后在边缘用灰色像素填充,直到图片变成正方形。这个处理的意义在于:不改变图片中目标的相对比例,避免直接拉伸导致目标变形、精度下降。
实现 letterbox 的代码不复杂,但细节容易出错。我直接给出可用的函数:
import cv2 import numpy as np def letterbox(img, new_shape=(640, 640), color=(114, 114, 114)): shape = img.shape[:2] 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 = (new_shape[1] - new_unpad[0]) / 2 dh = (new_shape[0] - new_unpad[1]) / 2 if shape[::-1] != new_unpad: 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注意dw和dh是填充宽度的一半,opencv 的copyMakeBorder函数要求上下左右分别给定填充像素数,所以边界值要精确计算。很多人复现的代码在这里会有一两个像素的误差,测试单张图可能看不出来,但batch推理或者跑精度评估时会发现目标框整体偏移。这是我实测踩过的坑。
预处理剩下的步骤是归一化和维度调整:
img = letterbox(img, (640, 640)) img = img[:, :, ::-1].transpose(2, 0, 1) # BGR -> RGB, HWC -> CHW img = np.ascontiguousarray(img) img = img.astype(np.float32) img /= 255.0 img = np.expand_dims(img, axis=0)img[:, :, ::-1]是把 OpenCV 默认的 BGR 顺序转成 RGB,因为 YOLOv5 训练时用的是 RGB 输入。这一步很容易被忽略,如果你跳过它,检测结果虽然也会出来,但精度会明显下降,尤其是对颜色敏感的场景。
4.3 输出张量解析与 NMS 实现要点
输入处理好之后,推理输出的是一个[1, 25200, 85]的张量。25200 是怎么来的?YOLOv5s 有三个检测尺度,分别对应 80x80、40x40、20x20 三种特征图,每个特征图上的每个格子预测 3 个 anchor,所以总数是:
80*80*3 + 40*40*3 + 20*20*3 = 19200 + 4800 + 1200 = 25200每个预测向量里的 85 个值,前 4 个是目标框的中心点坐标和宽高(相对于输入图片尺寸的归一化值),第 5 个是目标置信度,后面 80 个是各类别的分数。
解析输出的第一步是先用置信度阈值筛掉低质量的框。我常用的阈值是 0.4 或 0.5。如果检测场景对漏检更敏感,可以降到 0.25,但代价是后续 NMS 要处理更多候选框,速度稍慢。
然后用 NMS(非极大值抑制)去掉重叠的框。YOLOv5 仓库里提供了通用的 NMS 实现,如果不想自己写,可以直接用 OpenCV 内置的cv2.dnn.NMSBoxes,Python 接口调用很简单,输入候选框的坐标、每个框的最大类别分数,以及置信度阈值和 NMS 阈值,返回保留的框索引。
这里有一个关键细节:OpenVINO 的输出没有像 PyTorch 推理那样内置 NMS 节点,也就是说原始输出已经是解码好的坐标。我们需要手动把中心点格式转换成左上角右下角的(x1, y1, x2, y2)格式,同时因为 letterbox 在预处理时对原始图片做了缩放和填充,推理出的坐标还需要做一个反向映射,才能对应到原图上的真实位置。
反向映射的公式不复杂。记缩放比例为r,dw和dh分别是左右、上下的填充宽度,那么原图坐标是:
x_orig = (x_pad - dw) / r y_orig = (y_pad - dh) / r这里x_pad是填充后图片上的归一化坐标乘以 640 的解码结果。如果你把这一步写错了,检测框会整体偏移,看起来像是模型精度出现问题,实际只是后处理坐标没还回去。
4.4 CPU 性能对比实测数据
推理脚本写完以后,我用同一张测试图,对三种推理方式做了对比:原始 PyTorch 模型、ONNX Runtime、OpenVINO IR。测试图的尺寸是 640x640,测试机器就是那台普通台式机。为了避免偶然波动,我每轮跑 50 次取平均。
性能数据如下:
| 推理方式 | 平均耗时(ms/张) | 备注 |
|---|---|---|
| PyTorch CPU | 约 285ms | 未做任何优化 |
| ONNX Runtime CPU | 约 230ms | 仅使用 CPUExecutionProvider |
| OpenVINO CPU | 约 95ms | 使用 IR 模型,CPU 线程数 4 |
从数据看,OpenVINO 相对 PyTorch 原生推理提速了约 3 倍。这个数字和我之前在其他机器上的测试结果基本一致。需要说明的是,这个提速比例并不是固定的,它会受 CPU 型号、OpenVINO 版本、输入尺寸的影响。比如在支持 AVX-512 指令集的 CPU 上,提速幅度会更大;而在较老的 CPU 上,可能就只有 1.5 到 2 倍。
这个结果验证了整条技术路线的可行性。一个 YOLOv5s 模型从 PyTorch 权重到 OpenVINO IR,默认配置下就能在普通 CPU 上做到接近实时的检测速度。如果你的需求是 30 FPS 级别的实时检测,把输入尺寸从 640 降到 416,速度通常还能再翻一倍左右,在 CPU 上实现实时检测是完全可行的。
5. 部署中的常见痛点与个人优化心得
5.1 Windows 下 OpenVINO 的动态 Shape 问题与 batch 设置
如果你在导出 ONNX 时加了--dynamic,OpenVINO 加载模型后输入形状会带?符号,类似[?, 3, ?, ?]。动态 shape 的好处是推理时可以自由传入不同尺寸的图片,坏处是 OpenVINO 在每次输入尺寸变化时都要重新做一次内部内存布局优化。如果你在实际业务中处理的图片尺寸基本固定,这点灵活性完全没必要,固定的静态 shape 至少能带来 10% 到 20% 的性能提升。
我的建议是:推理阶段尽量用固定 shape 的模型。如果业务上确实需要支持多种输入尺寸,更好的做法是在预处理阶段统一用 letterbox 缩放,让输入尺寸保持一致,而不是让模型去适配任意尺寸。这样既保留了检测精度,又不牺牲推理速度。
batch 设置也是一个容易被忽略的点。OpenVINO 的 CPU 插件对单张推理和多张推理的 batch 处理策略不同。你可以用reshape把模型输入从[1, 3, 640, 640]改成[N, 3, 640, 640]来支持多张图并行推理,但 CPU 上的并行效率和模型规模、线程数都有关系。对于 YOLOv5s 这种小模型,batch=1 反而往往是最优选择,因为模型本身计算量不大,batch 增大带来的并行收益不足以抵消调度开销。
5.2 CPU 线程数与推理延迟的权衡
OpenVINO 在 CPU 上推理时,默认会使用所有可用 CPU 核心。这在服务器上可能是好策略,但在办公电脑上会带来一个问题:推理过程吃满全部核心,导致同一台机器上其他程序卡顿明显。
可以通过配置配置来限制推理使用的 CPU 线程数。在加载模型时可以这么做:
import openvino as ov core = ov.Core() model = core.read_model("openvino_model/yolov5s.xml") compiled_model = core.compile_model(model, "CPU", config={"NUM_STREAMS": "1", "INFERENCE_NUM_THREADS": "4"})INFERENCE_NUM_THREADS控制推理线程数,NUM_STREAMS控制推理流数。我的经验是:当 CPU 总核心数较多时,把推理线程数设置为核心数的一半左右,推理延迟几乎没有变化,但系统整体流畅度会好很多。如果你的程序是纯后台服务,不关心其他任务卡顿,那可以不用限制线程数,让 OpenVINO 自己调度。
5.3 后续扩展建议
整条链路跑通之后,后面可以做的事情还有很多。比如把 OpenVINO 引擎和视频流读取、目标追踪模块结合,做一个完整的实时检测程序;或者在模型导出阶段加入 NMS 算子,让 OpenVINO 输出直接就是过滤后的检测结果,省去 Python 端手动解析的开销。我自己在实际项目中还做了输入输出的量化压缩,把模型文件从 28MB 左右压缩到了 7MB 左右,对嵌入式设备的部署非常有帮助。
另外,如果你的机器上有 Intel 核显或者独立的 Intel GPU,OpenVINO 还能直接调用这些硬件做加速,接口和 CPU 推理几乎一样,只需要把compile_model的第二个参数从"CPU"改成"GPU"即可。我实测过一次 GPU 推理,延迟表现很不错,但在某些驱动版本上有兼容性问题。如果你的主要部署目标是 Windows 台式机,CPU 方案反而是最稳的选择。
最后分享一个小经验:整条链路的调试顺序很重要。不要急着直接跑 OpenVINO 推理,先确认 PyTorch 模型在单张图上检测正常,再确认 ONNX 模型用 ONNX Runtime 跑能输出同样的结果,最后才上 OpenVINO。每层都验证过,出问题时只需要查相邻两步的代码差异,排查范围会小得多。我在这一步救了自己不少时间,希望也能帮到你。