ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

YOLOv5 CPU推理全链路跑通:脚本、API与ONNX实战

YOLOv5 CPU推理全链路跑通:脚本、API与ONNX实战

1. 为什么"先跑起来"比"跑得快"更值得投入

1.1 CPU推理不是退而求其次的选择

很多人一提在 CPU 上跑 YOLOv5,第一反应是"没显卡才这么干"。这个判断只对了一半。我接触过的实际场景里,纯 CPU 跑检测的需求相当稳定:工控机、边缘盒子、老旧的机架服务器、CI 流水线上的自动化验证、教学演示环境,这些地方要么没有独立显卡,要么装不了对应的驱动栈,要么干脆不允许装。这时候 CPU 推理不是降级方案,而是唯一能落地的方案。

它的好处也很实在。没有驱动版本要匹配,没有运行库要额外安装,一套 pip 依赖装完就能跑,换个机器复制过去照样能跑。做算法验证的时候,这种"确定性"比峰值性能重要得多——你要的是每次跑出来的结果一致,而不是今天快 30 毫秒明天崩一次。

所以这一篇的定位很明确:不谈加速框架,不聊量化剪枝,就是把 YOLOv5 的检测链路在 CPU 上完整跑通,从加载权重到输出带框的图片,中间每一步都拆开讲清楚。这是整个系列第三篇的核心任务,前两篇把环境、依赖、权重这些前置事项处理完了,现在该让模型真正吐出结果了。

1.2 卡在第一步的人比想象中多

我见过太多人在"跑起来"这一步反复打转。报错信息看不懂,就去搜各种加速方案,结果越搜越乱,最后连基线都没建立起来。这是个典型的顺序错误。

正确的节奏应该是:先让流程闭环,拿到一张带检测框的输出图,记录下单张推理耗时;然后才有资格谈优化。没有基线数据,你后面做的任何调优都是盲目的——换个输入尺寸到底快了多少?换后端到底有没有收益?全靠猜。

这也解释了为什么我把"跑起来"单独拎出来写一篇。跑通这件事本身不难,难的是跑通的同时你要知道每一步在干什么,出问题时能定位到具体环节。否则一旦报错,只能靠搜关键词碰运气。

1.3 本篇的前置条件与验收标准

先把前置条件说清楚,省得你中途卡住。Python 环境要有,建议 3.8 到 3.10 之间;PyTorch 装 CPU 版本,安装命令里带上 CPU 专用的索引地址,别装成带 CUDA 的大包,白白多占几百兆;YOLOv5 的仓库代码拉到本地;权重文件提前下好放到weights/目录下,别指望运行时自动下载,网络一抖就前功尽弃。

验收标准就一条:执行一条推理命令,能在输出目录里看到一张画好检测框的图片,终端打印出每个目标的类别、置信度和坐标。做到了,这一篇的目的就达到了。至于速度,先别管,后面单独一节算账。

2. 三条跑通路径的选型与对比

2.1 官方脚本路线:验证环境的最短路径

仓库里自带的detect.py是最省事的入口。它把权重加载、预处理、推理、后处理、画框、保存这一整条链路都封装好了,你只需要传参数。对于第一次跑通的人来说,这条路能帮你最快排除"环境到底装没装对"这个疑问。

关键在于参数要传对。最容易被忽略的是设备参数,不加这个参数,脚本会去探测有没有可用的加速设备,探测不到会回退,但中间可能打印一堆警告,干扰判断。所以干脆显式指定,让它老老实实走 CPU。输入尺寸、置信度阈值、IoU 阈值这几个也建议显式写出来,别依赖默认值——默认值不是不能用,而是你写了才知道自己在控制什么。

python detect.py \ --weights weights/yolov5s.pt \ --source data/images \ --img-size 640 \ --conf-thres 0.25 \ --iou-thres 0.45 \ --device cpu \ --project runs/detect \ --name cpu_baseline

跑完以后去runs/detect/cpu_baseline/下面找结果,原图会被复制一份并画上框。终端里会逐张打印检测到的目标数量,最后给一个总耗时和平均每张的耗时。这个平均耗时就是你后面所有优化的参照系,拿笔记下来。

提示:第一次运行会稍微慢一些,因为权重加载和算子初始化都算在里面。测速的时候建议跑两次,取第二次的数据,更有参考价值。

2.2 Python API 路线:嵌入自有业务代码的正道

脚本跑通以后,绝大多数人的下一步是把它塞进自己的程序里。这时候再去调脚本就不合适了,得用 Python 接口直接加载模型。常见做法是torch.hub.load,本地模式下指定仓库路径和权重路径,就能拿到一个可以直接调用的模型对象。

import torch model = torch.hub.load( './yolov5', 'custom', path='weights/yolov5s.pt', source='local' ) model.conf = 0.25 model.iou = 0.45 model.to('cpu') results = model('data/images/bus.jpg', size=640) results.print() results.save(save_dir='runs/api_demo')

这条路的好处是返回的对象结构清晰,.xyxy、.conf、.cls这些属性直接拿来做后续业务逻辑。比如你要统计画面里有几个人,直接按类别筛一下计数;要把坐标传给下游模块,xyxy数组拿来就用。

有一点要留意:torch.hub.load在加载本地仓库时会执行仓库里的配置文件,所以路径一定要指向你确认过的那份代码,别随手写个相对路径导致加载了别的目录。另外模型对象第一次推理会带上初始化开销,做批量处理时先把这一步预热掉。

2.3 导出 ONNX 再推理:跨运行时的验证通道

如果你的目标环境里不方便装 PyTorch,或者你打算后续换推理后端,那导出 ONNX 是绕不开的一步。仓库里的export.py支持直接导出,指定格式和输入尺寸即可。

python export.py \ --weights weights/yolov5s.pt \ --include onnx \ --img-size 640 \ --opset 12 \ --device cpu

导出成功后在权重同目录下会多出一个.onnx文件。这个文件可以用 ONNX Runtime 加载推理,CPU 上的表现通常比原生 PyTorch 要好一些,常见的量级是一到三倍的提升,具体看你的 CPU 型号和算子覆盖情况。别把它当成必然结论,你自己的机器上测出来的才算数。

导出这一步有几个坑值得提前说。输入尺寸必须是 32 的整数倍,因为网络里有多次下采样,尺寸对不上会报形状不匹配;opset 版本别选太高,有些运行时的版本跟不上;导出时的动态轴设置如果不开,出来的模型就只能吃固定尺寸的输入,后面想改尺寸得重新导。

2.4 三条路径怎么选

路径适用阶段上手成本灵活性建议
官方脚本环境验证、批量处理成图最低低第一篇必跑
Python API嵌入业务、二次开发中等高落地首选
ONNX 导出换运行时、部署到精简环境中等中有性能诉求时再做

我的建议是按顺序来:先用脚本确认环境和权重都没问题,再用 API 写自己的处理逻辑,等这套跑顺了、有明确的性能诉求了,再去折腾 ONNX。跳过前两步直接上 ONNX,出问题时你分不清是模型问题、导出问题还是运行时问题。

3. 一次完整 CPU 推理的逐段拆解

3.1 权重加载:优先用本地文件

加载这一步看着简单,其实决定了后面一堆事情。模型规模的选择直接影响耗时和精度,YOLOv5 提供了从 n 到 x 五个量级,参数量大致从两百万级到八千多万级不等。在 CPU 上,我个人建议从 s 这个量级起步,n 虽然快但精度损失在复杂场景下比较明显,m 以上在普通 CPU 上单张就要接近一秒甚至更多,做实时处理不现实。

权重文件优先放本地。框架默认会尝试从网络拉取,一旦网络不稳就是长时间的等待,报错信息还不一定说得清。把文件下好放到位,路径写死,这是最稳的做法。

加载完成后建议打印一下模型结构确认规模,顺便看一眼输入输出形状。有些人拿到权重直接用,结果发现是导出的中间格式,跑起来形状对不上,白折腾半天。

3.2 预处理:letterbox 到底在解决什么问题

这一步是很多人忽略的关键环节。YOLOv5 要求输入是固定尺寸的正方形,但现实中的图片长宽比五花八门。直接拉伸会把物体压扁变形,检测精度掉得厉害。letterbox 的做法是等比缩放,然后把空出来的部分用固定灰度值填满,相当于给图片加了一圈边框。

举个例子,一张 1920×1080 的图要送进 640×640 的网络。等比缩放系数取 640/1920 和 640/1080 里较小的那个,也就是 0.333,缩放后是 640×360。然后在上下各补 140 像素的灰边,凑成 640×640。这样物体比例没变,只是画面里多了留白。

理解这个机制很重要,因为后处理要把坐标还原回去。网络输出的坐标是在 640×640 这个带边框的画布上的,你得先减去填充量,再除以缩放系数,才能映射回原图。这一步算错了,框就会整体偏移,而且偏移量随图片尺寸变化,看着像"模型不准",实际是坐标还原的数学错了。

填充值也有讲究,训练时用的是 114 这个灰度值,推理时保持一致,别自己改成 0 或者 255,会引入分布偏移。

3.3 前向推理与 NMS:两个必须理解的环节

前向推理本身没什么好说的,把预处理好的张量送进网络,拿回三个尺度的特征图输出。真正需要理解的是 NMS,也就是非极大值抑制。

模型输出的原始预测里,同一个物体会有一堆重叠的框,因为相邻的网格点都觉得自己检测到了。NMS 的作用就是把这些冗余框压掉,只留最好的那个。流程是按类别分开处理,先按置信度排序,取最高的框,然后把它和剩下所有框算 IoU,也就是交并比,超过阈值的认为是在描述同一个物体,删掉;再取下一个最高的,重复这个过程。

这里的关键参数是 IoU 阈值。设得高,比如 0.7,只有重叠度非常高的才被压掉,结果是密集场景下框会重复;设得低,比如 0.3,稍微挨着就被压掉,人群或者堆叠物体就会漏检。0.45 是个常用的平衡点,但如果你的场景里物体特别密集,可以适当往上调。

置信度阈值是另一道闸门,在 NMS 之前就把低分框过滤掉。它决定了"多确定才算数"。0.25 是常用的宽松设置,宁可多报也不错漏;如果你的业务对误报敏感,可以提到 0.4 甚至更高。

这两道闸门配合使用,先粗筛再精筛。我在实际项目里试过一个思路:先用高置信度阈值拿到确定的目标,再用低阈值拿一遍候选,两次结果做合并去重。这个方法在漏检和误报之间找了个平衡,代价是耗时翻倍,要不要用看你自己的取舍。

3.4 坐标还原与结果解析

拿到 NMS 之后的框,还差最后一步。框的坐标是归一化的,数值在 0 到 1 之间,是相对于网络输入的尺寸。要还原成原图像素坐标,顺序是:先乘回网络输入尺寸,减去填充量,除以缩放系数,最后把超出原图边界的值裁掉。

裁边这一步别省。填充导致的坐标有可能落在负值或者超出原图宽高,不裁的话下游画框会报越界,或者画出画面外。用简单的截断就行,别搞复杂的插值。

结果的类别索引也要注意,模型输出的是索引数字,需要对照类别名称表转成可读的标签。这个表通常跟权重配套,别自己乱改顺序,改了就对不上。如果你的应用只关心某几类,可以在解析阶段就直接过滤,省掉后面无谓的处理。

4. 参数调优与耗时账本

4.1 输入尺寸的取舍与耗时估算

输入尺寸是性能影响最大的单一参数,而且影响是非线性的。计算量大致跟边长的平方成正比,也就是说从 640 降到 416,理论计算量接近减半,从 640 提到 1280,计算量翻四倍。

# 粗略估算相对计算量 base = 640 for size in [320, 416, 512, 640, 800, 1024]: ratio = (size / base) ** 2 print(f"{size}: {ratio:.2f}x")

这个估算只是量级参考,实际耗时会受到内存访问模式、缓存命中率的影响,不完全按平方走。但方向是对的。

尺寸怎么选?得看你的目标在画面里占多大。如果检测的是近处的人或者大件物品,320 到 416 往往就够了;如果是远处的小目标,比如监控画面里的远处车辆,那就得用 640 甚至更高,否则目标在特征图上就剩几个像素,网络根本认不出来。

我的做法是拿一批标注好的样本,用不同尺寸各跑一遍,看精度和耗时的曲线拐点在哪。多数情况下你会发现在某个尺寸之后,精度提升就变得很平缓,而耗时还在往上涨,那个拐点就是你的最佳取值。

4.2 线程数与 CPU 亲和性

PyTorch 在 CPU 上默认会用满所有核心,但"用满"不等于"更快"。线程数超过物理核心数以后,上下文切换的开销会吃掉一部分收益,有时反而变慢。可以用torch.set_num_threads()手动控制。

import torch import os import time for n in [1, 2, 4, os.cpu_count()]: torch.set_num_threads(n) # 这里放同一张图连续推理若干次,取平均 print(f"threads={n}, avg={avg_ms:.1f}ms")

实测下来有个规律:小模型、小尺寸的情况下,线程数收益到四核左右就基本饱和了;大模型大尺寸时,多线程收益更明显,但也别超过物理核心数。另外如果你的程序本身是多进程架构,每个进程都开满线程会互相抢资源,这时候反而要限制单进程的线程数。

还有一个容易忽略的点是推理时的梯度状态。推理阶段必须关掉梯度计算,否则框架会保留中间结果用于反向传播,内存占用和耗时都会明显上升。脚本里通常已经处理好了,但你写自己的代码时得自己加。

with torch.no_grad(): pred = model(img)

这一行几乎是零成本的高收益操作,别漏。

4.3 阈值组合的调优思路

置信度和 IoU 这两个阈值不是独立调的,得一起看。常见的做法是固定一个扫另一个,画一条精度或者召回率的曲线出来,找拐点。

实际操作中我习惯这样:先把 IoU 定在 0.45,扫置信度从 0.1 到 0.6,步长 0.05,看误报和漏检的变化;确定置信度以后,再扫 IoU 从 0.3 到 0.7。两轮下来基本能找到适合你场景的组合。

有个细节值得注意:如果你的应用后面还要做跟踪或者计数,阈值可以设得宽松一些,让上游多给点候选,后面靠逻辑去筛选;如果是直接给人看的告警,那就设严一点,宁可少报也别天天误报,误报几次用户就不信这个系统了。

4.4 换后端的收益评估

原生 PyTorch 跑通以后,如果速度确实不够,可以考虑换推理后端。ONNX Runtime 是比较通用的选择,安装简单,对 CPU 的优化做得不错。如果目标机器是常见的桌面或者服务器处理器,还有一些针对特定指令集优化的后端可以试,收益会更高一些,但部署上多一层依赖。

这里的原则是:先测再换,换了再测。别听别人说哪个快就直接上,你的 CPU 型号、内存带宽、输入尺寸、模型量级都会影响结果。我见过同一个模型在两台配置接近的机器上,不同后端的排名完全相反的情况,所以自己的数据最可靠。

测的时候注意控制变量:同一批图片、同样的输入尺寸、同样的阈值、同样的线程数,只换后端。跑之前先把第一次的预热推理排除掉,不然测出来的数字不可比。

5. 踩坑记录与排查速查

5.1 环境类报错怎么定位

最常见的是导入失败和版本冲突。装了一堆东西以后,某些依赖互相不兼容,报错信息往往指向一个看起来无关的模块。这时候别急着按报错去单独装某模块,先看一眼已安装包的版本清单,确认核心框架的版本和仓库要求是否匹配。

另一个高频问题是权重文件路径。相对路径、绝对路径、当前工作目录,三者的关系很容易搞混。我的习惯是一律用绝对路径,或者用os.path拼出来,别依赖执行时的工作目录。

还有一类是权限和编码问题,多见于路径里带中文或者空格。这类问题报错信息通常很含糊,容易误判成模型问题。排查时先把路径换成纯英文短路径试一次,能排除掉一大批干扰。

5.2 结果异常怎么分类排查

结果不对,先分两类:一类是压根没检测到东西,一类是框位置明显偏了。

没检测到,先看置信度是不是设太高了,临时降到 0.05 试试;还不行就看输入图片本身,是不是通道顺序搞错了,比如 RGB 当 BGR 送进去,颜色反了会严重影响浅色系的物体;再不行就看归一化这一步,均值方差有没有对上。

框位置偏移,八成是坐标还原的数学出问题。检查填充量有没有正确减掉,缩放系数有没有用错方向。验证方法很简单:拿一张正方形图片测,如果正方形图准、长方形图偏,那基本确定是 letterbox 相关的还原逻辑错了,因为正方形图不需要填充,绕过了那段逻辑。

还有一种是框的数量对但类别全错,这通常是类别名称表对不上,或者模型的类别顺序跟你以为的不一样。把原始索引打印出来看看就知道了。

5.3 速度慢的系统性排查

慢是相对的,先确认你的基线。一张 640×640 的图在普通四核笔记本 CPU 上跑小模型,几百毫秒是正常范围;如果到了一两秒,那就有问题。

排查顺序我一般这样走:先确认梯度是不是关掉了;再看线程数设置,是不是只用了单线程;然后看输入尺寸是不是被某个默认值顶到了很大;接着看预处理是否有重复的拷贝操作,比如在 Python 层做了一次缩放,框架内部又做了一次;最后才考虑换后端。

一个常被忽略的耗时点是图片解码。如果你处理的是大尺寸的原图,每次都要完整解码再缩放,这部分开销可能比推理本身还大。做法是先用低开销的库做一次粗缩放,把尺寸降下来再送进模型,或者干脆预先处理好数据集。

5.4 常见问题速查表

现象可能原因处理办法
导入模块报错依赖版本冲突查看版本清单,重建干净环境
权重加载失败路径不对或文件不完整改绝对路径,校验文件完整性
检测结果为空置信度阈值过高 / 通道顺序错临时降低阈值验证,检查颜色通道
框整体偏移letterbox 还原计算错误用正方形图对照测试
类别标签错乱类别表与模型不匹配打印原始索引,核对映射表
单张耗时异常长未关闭梯度 / 线程配置不当加推理模式,限制线程数
首次运行很慢初始化开销跑两次取第二次数据
换尺寸后报错不是 32 的整数倍改成 32 的倍数

5.5 几个只有实际跑过才知道的细节

说几个文档里通常不会写的。第一,连续推理多张图片时,第一张总是明显慢于后面,这个差距在小图上能差出两三倍,测速一定要跳过第一张。

第二,同一个模型在不同输入尺寸之间切换会触发重新编译或者内存重分配,如果不做预热直接测,那一张的数据会严重失真。做法是先用目标尺寸空跑一次,再开始计时。

第三,CPU 推理的内存占用比想象中高,尤其大尺寸输入的时候,中间特征图占的空间不小。批量处理时不要一次把整批图都读进内存,用生成器逐张喂进去更稳,否则批次一大就容易吃满。

第四,输出目录的命名很容易重复导致覆盖。脚本默认会在同名目录后面加序号,但如果你自己指定了固定路径,要注意它会覆盖掉上次的结果。做对比实验时把实验参数写进目录名里,比如带上尺寸和阈值,事后翻记录能省很多事。

我个人在这个环节上的体会是,把每一步的输入输出都落盘存一份,包括预处理后的图、原始预测、NMS 之后的结果。看起来占空间,但每次出问题的时候,这几张中间结果能让你在几分钟内定位到是哪一环出的岔子,比对着日志猜快太多了。这套习惯我坚持用到现在,几乎每次排查都能帮上忙。

RELATED READING

延伸阅读

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