ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

YOLO推理迁移Java:ONNX Runtime CPU部署半年省10万实战

YOLO推理迁移Java:ONNX Runtime CPU部署半年省10万实战 如果你抱着“Java要干翻Python”的心态来看这篇文章可能会失望。因为我到今天仍然认为YOLO的训练和算法迭代Python生态就是最顺手的没有之一。真正想说的是另外一件事当YOLO要从实验室原型变成工厂里一条24小时不停机的检测产线时模型怎么推理、服务怎么部署、算力怎么压这层“运行时”的选择和用什么框架训练完全是两回事。我把训练留在Python把推理从Python迁到了Java半年下来硬件投入直接砍掉了10万出头但这个过程远没有标题看起来那么爽快。为什么会动这个念头这还得从去年我们接手的一条3C零部件外观检测线说起。现场一共20多个工位每个工位一台工控机配一块显卡跑的是我在PyTorch里训练好的YOLO模型。算法准确率没什么问题但产线运维、设备成本、驱动崩溃这些问题一串一串往外冒。后来我们做了一个有点“叛逆”的决定推理端全部换成Java用ONNX Runtime在CPU上跑。今天这篇文章就把这半年踩过的坑、算过的账、后悔和没后悔的地方一次性都说清楚。1. 一开始为什么非用Python不可做工业视觉这条线我对Python的感情其实很深。从最早的OpenCV脚本到后来用PyTorch训练YOLO再到各种标注工具的脚本处理几乎整个算法原型阶段都泡在Python里。刚接到这个项目的时候团队里大家的第一反应都是检测模型是YOLO那部署当然也用Python最多再用FastAPI包一层HTTP服务推理就走PyTorch的torch.cuda.sync多简单。确实简单但问题也就出在这份“简单”上。1.1 工业现场和实验室环境的区别实验室里跑推理你有RTX 4090有CUDA环境驱动随便装崩了重启就行。但是工厂车间里不是这样。那会儿我们的标准配置是每个工位一台i7工控机加一块RTX 3060装Ubuntu系统跑Python脚本每次开机要先等conda环境激活然后祈祷显卡驱动还在。生产现场由于供电波动、灰尘、振动设备重启是家常便饭而Python环境下只要驱动和CUDA版本对不上整个检测站就是停工状态。更折腾的是多相机场景。一条产线往往有四五个摄像头从不同角度拍同一个工件Python做多路视频流推理时GIL这个坎绕不过去。我们用过multiprocessing按进程隔离每个进程各自加载一份模型内存占用哗啦啦地往上走。一台16GB内存的工控机两个进程跑起来就接近极限了。1.2 GIL和多进程方案的后遗症说到GIL用一个生活化的比喻Python的多线程有点像超市里只有一个收银员排队的人再多同一时间也只能有一个人结账。做推理这种CPU密集任务GIL会让多线程的加速效果归零所以大家只能开多进程。但每个进程都会把模型在内存里复制一份YOLOv8s的PyTorch模型光权重就要100多MB加上CUDA context和Python解释器开销内存占用很快就爆了。我们最初用Python多进程方案支撑20多个工位每台工控机的内存都是16GB跑着跑着就会触发OOM。后来给每台机器加内存条成本又上去了。如果让所有摄像头画面都塞进同一个进程去推理GIL又卡死在CPU上检测节拍完不成产线一停就是钱。其实当时的直觉就已经告诉我问题不出在模型而出在运行推理的这一层。1.3 驱动与依赖的“环境地狱”工业现场最怕的就是环境不一致。Python项目依赖pip包pip包又依赖系统的CUDA、cuDNN、OpenBLAS这些底层库。同一份代码在开发机上是好的拷到工控机上就报libcudnn.so.8: cannot open shared object file。为了解决这种问题我们甚至专门写了一套自动化脚本去同步环境但每次厂里换一台新机器依然是玄学。用Docker确实能缓解但工控机性能本来就不强再套一层容器又增加资源开销。而且现场的工程师不是搞AI的遇到Python异常根本不知道从哪下手。那个阶段我最大的体会是检测算法再准部署不动也是白搭。2. 技术选型背后的思考为什么是Java而不是Go或C有了前面的痛点换方案这件事基本定了。但换什么这是当时讨论最久的问题。有人提C毕竟OpenCV原生就是C性能上限最高有人提Go说比Java轻量最后我们却选了Java。这里面的取舍我想重点展开聊聊。2.1 为什么不是CC的推理性能和原生图像库优势确实无法否认很多商业视觉软件都用C写的。但现实问题是我们团队不是专业C团队算法工程师主要写Python服务端工程师主要写Java。如果硬切C光是内存管理、指针错误、编译环境这些问题就能把项目节奏拖垮。工业项目里最重要的是稳定交付而不是炫技。还有一个不容忽视的点C的AI推理生态相比Java并不算“友好”到碾压的程度。ONNX Runtime本身支持C但团队里能把这个库用得地道的人不多。用Java调ONNX Runtime和用C调底层推理引擎是同一个性能差距远没有想象中那么大。2.2 为什么不是GoGo的并发模型确实漂亮轻量级goroutine做多路视频流很合适。但在AI推理这个领域Go的生态几乎是空白。虽然也能通过cgo调用一些C库但交叉编译、内存拷贝、调试复杂度直线上升。查了一圈ONNX Runtime官方对Go的绑定还不够成熟最后只能放弃。反观Java在工业后端领域耕耘了二十多年生态成熟度非常高。ONNX Runtime官方提供Java APIOpenCV也有Java绑定的JavaCV再加上Java本身就适合做常驻服务有非常成熟的线程池、内存管理、监控体系和系统守护方案。对我们这种“正需要一个稳定后端运行时”的场景来说Java是最顺手的答案。2.3 训练和推理为什么可以分开很多人一听到“用Java做YOLO”就下意识觉得是把YOLO在Java里重新实现一遍。完全不是这样。YOLO的训练阶段我们继续用PythonPyTorch该调超参调超参该看损失函数曲线看曲线该做数据增强做增强这些活没有任何一个语言能替代Python的便利。推理阶段要的只是一个“能稳定加载模型、批量处理图像、快速返回检测框”的运行时这时候把ONNX模型交给Java去加载是完全可行的。这就像做菜Python负责研发菜谱Java负责连锁后厨标准化出餐。菜谱还是同一份但后厨的排烟系统、人员调度、出餐节奏是由Java来管的。2.4 ONNX Runtime和DJL怎么选Java这边跑模型主流其实就两条路直接用ONNX Runtime的Java API或者用亚马逊开源的Deep Java LibraryDJL。DJL的好处是封装程度高提供了很多现成的Translator组件理解起来像PyTorch的torch.hub加载模型和推理的代码很短。但封装深的代价是可定制性偏弱如果要做精细的前处理和NMS处理反而要绕很多路。我们最后选的是ONNX Runtime Java API图的就是可控。前处理用JavaCV调OpenCV推理用ONNX Runtime后处理NMS自己写整个链路每一环都能看得见摸得着。后来查资料时也确认了很多工业场景的Java推理方案都走的这条路。3. 实战拆解在Java中跑通YOLO推理全流程方案定了之后真正的硬仗才开始。我原以为把Python的推理逻辑照着搬过来就行结果发现从模型导出到后处理每一步都有不少坑。这一节我按实际操作顺序把能直接抄作业的内容整理出来。3.1 模型导出环节从PyTorch权重到ONNX模型导出这一步是整个切换的关键前提。在Python训练环境里用Ultralytics YOLO框架导出ONNX格式很简单一行命令的事yolo export modelyolov8s.pt formatonnx opset12 dynamicFalse imgsz640 halfFalse simplifyTrue看起来简单但要注意几个关键参数。首先是dynamicFalse这个我要特别说明。工业检测里输入图像的尺寸通常是固定不变的比如我们采集端已经统一resize到640x640那模型就固定输入尺寸没必要开动态维度。动态维度会引入额外的shape计算开销也会让ONNX Runtime在某些CPU上的优化效果打折扣。其次是halfFalse因为我们要在CPU上推理FP16对CPU并不友好保持FP32精度更稳妥。模型优化工具simplify值得加它会把模型计算图做一轮精简去掉不少冗余节点。实测下来同一个YOLOv8s模型simplify之后在ONNX Runtime上的推理延迟能降低5%左右聊胜于无。这里还涉及一个常用选项要不要把NMS放进模型图里。我们不建议在导出时集成NMS。原因有两点第一端到端NMS虽然省掉后处理代码但在Java里做NMS其实并不复杂还能保留更多的控制空间第二工业检测业务后处理经常要叠加过滤逻辑比如只保留面积在一定范围内的检测框、或对多个相机的结果做关联这些放进模型图里反而难维护。3.2 Java工程依赖与基础环境接下来是Java工程。我们用Maven管理核心依赖其实只有两个dependency groupIdcom.microsoft.onnxruntime/groupId artifactIdonnxruntime/artifactId version1.16.3/version /dependency dependency groupIdorg.bytedeco/groupId artifactIdjavacv-platform/artifactId version1.5.9/version /dependencyONNX Runtime的Java包自带JNI库会根据操作系统自动加载对应的so/dll这一点比Python环境省心太多了。JavaCV里面带着OpenCV、FFmpeg等一堆原生库用来读摄像头流、做图像解码和预处理足够用了。一个容易被坑的点是Java版本。ONNX Runtime新版要求Java 8以上但我们后来为了用ZGC垃圾回收器统一升到了Java 17因为ZGC在Java 17里已经稳定对超大堆内存场景的延迟控制很有帮助。工业生产追求可预测的响应时间不愿意看到GC突然停一下把推理卡住。3.3 模型加载和推理的完整示例下面这段代码是我们在产线工控机上真实运行的推理核心逻辑做了删减但流程是完整的。先看模型加载和推理这块import ai.onnxruntime.*; public class YoloInferer { private OrtSession session; private final OrtEnvironment env OrtEnvironment.getEnvironment(); public YoloInferer(String modelPath) throws OrtException { OrtSession.SessionOptions options new OrtSession.SessionOptions(); options.setIntraOpNumThreads(4); options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); this.session env.createSession(modelPath, options); } public float[] infer(float[][][] normalizedImage, long[] inputShape) throws OrtException { // normalizedImage 是 HWC 格式需要转成 NCHW float[] flat new float[1 * 3 * 640 * 640]; int idx 0; for (int c 0; c 3; c) { for (int h 0; h 640; h) { for (int w 0; w 640; w) { flat[idx] normalizedImage[h][w][c]; } } } OnnxTensor inputTensor OnnxTensor.createTensor(env, FloatBuffer.wrap(flat), inputShape); OrtSession.Result result session.run(java.util.Map.of(images, inputTensor)); OnnxTensor outputTensor (OnnxTensor) result.get(0).getValue(); return (float[]) outputTensor.getValue(); } }这段代码有几处细节必须强调。第一个是setIntraOpNumThreads(4)这个参数控制ONNX Runtime内部算子并行度。我们测试发现在i5-12500这种6核12线程的CPU上线程数设为4到6时推理延迟最优设到8反而因为线程切换开销变大而变慢。第二个是输入Tensor的shape顺序必须严格按[1, 3, 640, 640]排也就是NCHW。Python里处理时很多框架隐藏了这些细节Java这边全得自己保证。session.run就是整个推理的热点路径底层的计算库是同一个所以我们对性能心里是有底的YOLOv8s在i5-12500上的单张推理大概在60到80毫秒之间YOLOv8n能压到25到40毫秒。这个速度对多数产线节拍完全够用因为很多检测位的相机触发间隔在200毫秒以上。3.4 前处理细节letterbox和归一化是翻车重灾区前处理直接决定推理精度这真不是套话。YOLO模型的训练输入通常经过letterbox处理也就是把原始图像等比缩放后填充到640x640而不是直接暴力拉伸。如果这一步做不对模型看到的图像比例就不对检测精度会明显下降。核心逻辑其实很简单Mat resized new Mat(); Size targetSize new Size(640, 640); double scale Math.min(targetSize.width / src.cols(), targetSize.height / src.rows()); int newW (int) Math.round(src.cols() * scale); int newH (int) Math.round(src.rows() * scale); Imgproc.resize(src, resized, new Size(newW, newH)); int padTop (640 - newH) / 2; int padLeft (640 - newW) / 2; Core.copyMakeBorder(resized, dst, padTop, 640 - newH - padTop, padLeft, 640 - newW - padLeft, Core.BORDER_CONSTANT, new Scalar(114, 114, 114));需要注意几点填充值必须用114这是YOLO训练时默认的填充灰度值用0会导致测试分布不一致。还有一个细节是输入的通道顺序模型训练用的图像是RGB顺序而工业相机很多输出的是BGR这个如果不转换检测效果会非常诡异。我们用OpenCV读图后在转Tensor之前必须做一次Imgproc.cvtColor转成RGB。另外相机采集的图像经常会带噪声工业现场环境光也复杂。我们最终在相机端先做一次中值滤波去噪再做letterbox有效降低了误检率这属于业务侧的优化模型侧不用改。3.5 后处理NMSYOLOv8的输出结构YOLOv8的输出和以前YOLOv5不太一样。以COCO 80类为例输出的Tensor形状是[1, 84, 8400]其中84来自4个边界框坐标加上80个类别置信度8400是三个不同尺度特征图上的候选目标数量总和。解析逻辑我的做法大致如下int channels (int) shape[1]; // 84 int anchors (int) shape[2]; // 8400 float confThreshold 0.25f; for (int i 0; i anchors; i) { float maxClassScore 0; int maxClassId -1; for (int j 4; j channels; j) { float score output[j * anchors i]; if (score maxClassScore) { maxClassScore score; maxClassId j - 4; } } if (maxClassScore confThreshold) continue; float cx output[i]; float cy output[1 * anchors i]; float w output[2 * anchors i]; float h output[3 * anchors i]; // 转为左上角坐标 detections.add(new Detection( cx - w / 2, cy - h / 2, w, h, maxClassId, maxClassScore )); } // 按类别分组做 NMS这里最容易被坑的就是数据排布。ONNX输出是一个一维浮点数组按[channel][anchor]的顺序排列和PyTorch里Tensor的语义完全一样但代码上要自己算索引。我最初就是因为这里用错了索引检测框全部错位排查了好久。NMS我们没引入额外依赖手写了一个按类别分组的循环实现单张图上候选框最多几百个手写NMS的耗时连1毫秒都不到。工业检测对精度要求高我们用的NMS IoU阈值一般是0.5到0.6比公开数据集默认的0.45更严格一点因为现场误检的成本远高于漏检。4. 硬件成本账半年10万是怎么算出来的到了大家最感兴趣的环节这10万到底怎么省的。我先把最核心的硬件对比表放出来后面再算运维和电费。这个数字不是拍脑袋编的是我们采购部门最后核对过的采购价。项目PythonGPU方案JavaCPU方案处理器i7-9700i5-12500显卡RTX 3060 12G无独显电源650W铜牌200W核显平台内存16GB DDR416GB DDR4整机单价约1.1万-1.3万约4500-5500元单台差价约6500元020个工位全部换下来光硬件采购就省了13万左右。但事情没那么简单旧机器退下来的显卡还在我们后来把RTX 3060在二手市场处理掉回了一部分血又把其中几块卡挪到算法训练集群上继续用所以“省下来”的钱实际一部分是变成了固定资产的转移不全是纯省。但半年10万这个数字是真实完成了的。4.1 功耗和电费细算独显平台的功耗是真的夸张。RTX 3060满载大约170W加上i7处理器整机功耗轻松到250W到300W。而换到i5-12500核显平台整机在跑单路YOLO推理时功耗只有80W到100W。这个差距在20台设备、24小时不停机的产线上非常可观。我们按每台设备每天运行22小时、工业电价0.8元/度来算单台GPU方案年电费0.3kW * 22h * 365 * 0.8 ≈ 1927元单台CPU方案年电费0.09kW * 22h * 365 * 0.8 ≈ 578元单台每年省电费约1350元20台半年就是1.35万元左右半年电费就省了小一万五。更关键的是现在很多厂区对用电有配额考核能砍掉一大块功耗产线整体的电力规划压力也小了很多。4.2 真正的大头其实是运维成本很多人只盯着显卡价格忽略了运维成本。PythonGPU方案的运维成本远比想象中高。显卡驱动掉了要恢复、CUDA版本不匹配要重装、conda环境坏了要重建这都属于无预警故障。产线停一小时单一客户的损失就是几千块起。最惨的一次是某周一下午现场反馈三台工控机同时起不来。所有人赶到现场排查了两小时发现是三台机器在昨晚断电重启后NVIDIA驱动没有自动加载PyTorch直接报CUDA不可用。这种问题在JavaCPU方案里几乎不可能发生。因为Java程序依赖的原生库就是ONNX Runtime自带的so文件没有额外驱动这个概念。我后来算了一笔勤杂账方案切换前每个月至少有2到3次现场环境故障需要跨城远程处理每次至少耗掉技术负责人半天时间。切换之后类似的问题接近归零。如果按技术人员的工时成本折算半年省下的人力成本两万都不止。4.3 一个人维护20台机器的底气可能有读者会问Java方案真能把那20台工控机全统一管理起来吗这也是我觉得Java方案最顺手的地方。每台机器就一个java -jar包系统环境只要装一个JDK通过systemd配置成开机自启服务。更新模型时只需替换ONNX文件并重启服务不用重建conda环境不用管pip依赖冲突现场连网络工程师都能照着文档操作。后来我们还在Java服务里接了一个简单的Prometheus监控端点把每台设备的推理延迟、CPU占用、检测框数量、异常次数全部暴露出来。这种可观测性在Python多进程时代简直是奢求。5. 私货时间这些坑只有真换过才知道方案听起来很顺但摸着石头过河的半年里踩的坑一点都不少。这一节聊的都是网上文档里不会写的实战教训如果你真要复现建议先看完。5.1 模型更新不是替换文件那么简单最开始我们天真的以为模型迭代就是训练一个新的ONNX文件然后替换工控机上的文件就行。实际做起来才发现ONNX模型和代码之间是有隐性约定的。比如导出时opset版本变了ONNX Runtime对某些算子实现会有差异模型输入channel顺序或者缩放方式一旦在训练脚本里改过Java前处理这边必须同步改。后来我们定了一个笨但有效的规矩任何算法更新必须由算法工程师在Python端重新导出ONNX同时提交一份说明文档包含输入尺寸、是否归一化、填充值是多少、NMS阈值建议。Java服务端只认这份说明不自己去猜模型行为。宁可流程慢一点也不能让现场出了精度问题却不知道是哪端出的错。踩过一次最大的坑是算法组为了简化训练把图像归一化方式从/255.0改成了(x/255.0 - 0.5) / 0.5也就是从0到1归一化改成了标准化但Java前处理没跟着改结果批量推理时检测框全部偏到图像边缘。那一次排查花了整整两天最后才发现模型和代码之间的“隐藏约定”断了。5.2 CPU推理不是简单换个运行时就行一开始我们用CPU跑YOLOv8s发现延迟在150毫秒以上远没有达到预期。后来逐步排查发现是我们踩了三个性能坑。第一个坑是线程数配置。ONNX Runtime默认线程数会按CPU核心数来但在虚拟化环境或开了超线程的机器上默认值往往不是最优的。需要实际压测不同线程数下的延迟找到一个平衡点。第二个坑是CPU的AVX2指令集。ONNX Runtime会对支持AVX2的CPU自动启用SIMD优化但如果JVM的启动参数把某些CPU特性屏蔽了或者跑在老旧的赛扬处理器上性能会成倍下降。我们后来把工控机全部换成i5十二代以上就是看中了AVX2支持。第三个坑是JVM内存和GC。YOLO推理过程中会产生大量临时浮点数组如果每次推理都new一个很大的float[]GC压力会非常大。我们用了一个简单的对象池复用浮点缓冲区把GC停顿时间压到几乎可以忽略。JVM参数里加-XX:UseZGC之后线上再没有出现过因GC导致的推理尖刺延迟。5.3 多相机并发Java线程池如何设计我之前提到过GIL问题在Java里不存在但并发不是没有代价的。每个相机源如果都独立持有一个ONNX Runtime Session那么多个Session会互相争抢CPU资源。尤其是当每个Session都配置了setIntraOpNumThreads(4)时4路相机同时推理就是16个线程在抢8个物理核心延迟反而飙升。我们的最终方案是共享同一个Session但用一个固定大小线程池来控制并发。线程池大小设为物理核心数减2让每个推理任务排队执行看起来并行度降低了但整体吞吐反而更稳定。后来我们还做了相机帧丢弃策略如果当前线程池已经排满则直接丢弃最新帧并记一次丢帧计数而不是让排队延迟不断堆积。这样保证系统永远输出最新状态的检测结果不会越来越滞后。这个话题真要展开还有很多细节比如相机SDK回调线程和推理线程之间的队列设计我们用的是有界队列加拒绝策略避免内存被视频帧占满。5.4 Java开发效率的真相最后必须诚实地聊一下开发效率。Java在部署和稳定性上赢了但在开发调试上确实不如Python顺手。Python看到一张图直接matplotlib就画出来了Java这边想可视化一张检测结果图得用JavaCV写窗口代码量翻倍。所以我们在系统里留了一个后门所有检测结果裁剪出的缺陷图都会同步写一份到本地磁盘算法团队需要用的时候直接拉走在Python环境里做可视化分析和bad case整理。这样算法同事不需要接触Java代码也能正常工作。另外Java的构建和部署流程比脚本语言重。Maven仓库依赖下载、打包、版本号管理这些虽然不算难但对习惯了python main.py的人来说初期确实需要一个适应过程。6. 回到标题后悔了吗说结论我没有后悔但也不觉得这个方案适合所有人。如果今天再让我做一次决定我还是会选Java作为推理部署层但我希望一开始就把下面这几个问题想得更清楚。最不后悔的部分是稳定性和成本。20多台工控机的显卡摘掉之后产线故障率肉眼可见地下降硬件采购预算省了一大截现场运维同事的反馈也是“再也不用半夜起来配驱动了”。这种收益不用统计跑三个月自然就感觉到。但有一点我确实后悔。如果早知道推理层迟早要从Python迁走我应该在项目第一天就用Java做服务框架只把算法和模型导出的工作留给Python。这样算法同事和Java同事从一开始各管各的就不会出现后来交接模型时反复对细节、互相扯皮的过程。另外有几个场景我真的不建议换Java。如果你的检测节拍要求超过30帧每秒比如高速表面缺陷检测CPU推理就非常吃力了这类场景还是老老实实上GPU如果模型动辄就是YOLOv8l甚至更大的分割模型CPU推理延迟很难压下来如果团队里完全没有Java工程师前端后端都是Python那硬切Java只会增加维护负担。技术的对错永远要放在团队和场景里看。最后说一句实在话。做工业项目这些年我最大的体会是不是要选一个“最好”的技术栈而是选一个“在现场撑得住”的技术栈。Python负责聪明Java负责皮实两者各司其职反而比任何单方面的坚持都走得更远。
RELATED READING

延伸阅读

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