☰
Spring Boot 3.4 + ONNX Runtime集成YOLOv12:Java工业视觉缺陷检测实战
2026/10/19 8:21:53 网站建设 项目流程

1. 谁说Java干不了视觉?这个项目逼我们重新选型

先说结论:Java在工业视觉这块不是不能干,是大部分团队没把它当主力方案来设计。以前我也这么想——目标检测嘛,Python训练模型、Python写推理服务,一条龙多顺滑。直到我们接了一个锂电池金属端盖的缺陷检测需求,产线那边要求检测服务必须能嵌入现有的Java制造执行系统,整个数据链路要统一走公司内部的Spring Boot微服务体系,才意识到自己得走出舒适区。

项目背景不复杂:某电池结构件工厂的冲压车间,每天产出几万个金属端盖,表面不能有凹坑、划痕、污渍、锈点。原来靠人工目检,后面质检员离职率高、漏检投诉多,工厂想上自动检测。一开始他们拿需求找到我们的时候,口头禅就是“你们不是搞AI的吗”,实际上他们内部能维护的就是Java团队,Python代码对他们几乎是个黑盒——训练完了,后续谁升级模型、谁修服务、谁接告警?都是现实问题。

我们当时做了一个很多团队不敢做的决定:视觉推理这个核心服务,直接用Java来写,模型用Python侧训练好、导出成ONNX格式,Java侧用ONNX Runtime做推理。整个检测能力以Spring Boot 3.4应用的形式交付,打包成Docker镜像扔到产线服务器上。

选Spring Boot 3.4,不是因为它“新”,而是因为3.4这一代有几个东西正好戳中我们的需求点:

  • 底层是Spring Framework 6.2,对JDK 17到23支持得很稳;
  • 虚拟线程在配置里打开就能用,高并发图片请求不再需要调各种奇葩线程池参数;
  • Micrometer观测能力内建,接入Prometheus做检测耗时、吞吐监控非常顺手;
  • HTTP Interface客户端、RestClient这些新API,让Java侧调外部服务写起来比老RestTemplate优雅太多。

更重要的是,这个项目不是一个“纯算法Demo”,而是要让车间的老师傅也能操作、让产线班组长看到检出结果。它需要一套完整的REST API、需要连数据库落检测记录、需要接消息队列通知分拣设备。这些恰好是Spring Boot的强项。Python再强,把这个工程复杂度堆上去之后,运维成本就不是一个量级了。

1.1 项目接进来的第一周:Python模型训练和Java工程是两个世界

项目启动的第一周,模型侧同事先做可行性验证,他们拿YOLOv8、YOLOv11在端盖缺陷数据集上跑了几轮,效果还行。但一到工程对接阶段就出问题了:模型训练时用的Python环境是3.10,依赖torch、ultralytics、opencv-python,版本一塌糊涂。我们Java团队要接手模型,总不能给产线服务器装上Anaconda,再用uWSGI起一个Flask服务吧?

当时有两个思路。第一个思路是Java工程调用Python推理服务,走HTTP或者Socket通信。好处是模型侧改动小,坏处是系统里多了一个Python进程,部署、监控、扩缩容都得单独管。产线那边IT运维只熟Docker和Kubernetes,对Python环境一脸茫然。第二个思路是彻底把推理迁到Java侧,模型导出成ONNX,用ONNX Runtime Java API加载。

我们选了第二个。原因很直接:模型训练是一次性的,工程运维是长期的。推理服务用Java写,意味着整个链路从上传图片、预处理、推理、后处理、落库、推送告警全部在同一个Spring Boot应用里闭环。产线部署只需要一个Java运行时和一个Docker镜像,没有任何Python环境依赖。

为了验证这条路能走通,我们专门花了两天做了一个最小可行性验证:把YOLOv12的权重用ultralytics导出为ONNX,在Java里用ONNX Runtime跑一张测试图,看看是不是真的能检出缺陷。当第一次在Java代码里拿到检测框坐标时,团队三个人都松了口气——这条路,通。

1.2 为什么最终选Spring Boot 3.4来承载视觉推理

这里要澄清一下,不是FastAPI不行,而是这个项目的交付边界太特殊。客户要求检测服务必须集成到他们自己的微服务体系里,他们那边是标准Java技术栈,统一走Nacos注册、Gateway网关、Spring Boot应用。如果我们在中间插一个Python服务,等于给他们的运维体系开了一个例外口子。理论上能接受,但后续所有告警、日志、链路追踪都要单独适配,客户内部推动阻力很大。

Spring Boot 3.4在这个场景下的实际表现也印证了选择。虚拟线程开启后,高并发上传图片时,每个请求不再独占一个平台线程,而是用虚拟线程处理阻塞IO,这对视觉推理这类“计算时间较长、IO穿插频繁”的任务非常友好。我们压测的时候,默认Tomcat最大线程数200,开虚拟线程后几乎不用调参,压到500并发也没有线程池拒绝的报错。

还有一点非常实际:3.4版本里Spring的配置属性提示完善了很多,IDEA里开发体验明显好于旧版。最关键的是Spring Boot 3.4对JDK 21的原生支持足够稳定,我们直接在JDK 21上跑,虚拟线程不用搞实验性参数,直接生产可用。

1.3 求证过程:我们对Java视觉生态的几个判断题

在拍板Java做推理之前,我们列了几个关键问题,逐个验证:

判断题结论验证方式
ONNX Runtime Java API是否稳定支持YOLO输出解析是用导出的ONNX模型跑实际推理,读取输出张量shape
模型算子是否兼容ONNX Runtime基本兼容导出后使用工具检查算子版本,替换不兼容的op
Java端图像预处理能否跟上Python端精度能对比Java和Python预处理后的像素值,误差控制在1以内
推理性能是否满足产线节拍满足单帧GPU推理约15ms,CPU约180ms,产线节拍2秒/件
模型后续迭代谁来维护Java团队训练-导出-替换model.onnx,Java侧代码零改动

这些判断题做完,我们基本确认了技术路线。但真正让客户安心的是第三条:Java的推理结果和Python侧的结果对同一批样本比对,mAP差异在0.5个百分点以内,甚至CPU推理时的数值稳定性还更好一点。这就是我们敢把这个架构写进方案书的原因。

2. 拆解标题里“多模态”的真实含义:图像之外还有曲线数据

很多人看到“多模态”三个字,第一反应是图片加文本、或者语音加图片。但在工业缺陷检测这个场景里,我更喜欢把它理解成“多源数据联合决策”。这也是标题里“多模态”背后真正有工程价值的东西——单一的图像模型再准,也会有它看不清的时候。比如一个凹坑和一块阴影在图像上可能长得几乎一样,但如果你同时知道这个时间段压力机的吨位曲线有异常波动,判断就会完全不一样。

我们最终设计的“多模态”,是三类数据的融合:

  • 视觉数据:相机拍到的端盖表面图像,由YOLOv12检出缺陷框、缺陷类型、置信度;
  • 工艺曲线数据:冲压机每冲一次,采集压力、速度、模具温度随时间变化的曲线;
  • 生产批次数据:模具编号、冲压次数、上一次换模时间、来料批次号等。

接地气地讲,视觉负责“看”,曲线负责“感”,批次数据负责“记”,把这三个维度的信息合并起来,最终输出一条“缺陷等级建议”。

2.1 工业场景的多模态不是图片加文字

现在一提多模态,大家想到的都是大模型、图文问答。但产线上设备的PLC、传感器、条码枪可不会讲人话,它们吐出来的是PLC寄存器数据、OPC UA节点的变量值、扫码枪的字符串。我们接到的真实需求是:“能不能用图像检测缺陷,同时结合冲压机的压力曲线,判断这个缺陷是偶发还是系统性风险?”

这个问题决定了我们没法只做一个“输入图片、输出框”的简单模型。需要在Java工程里加一个数据融合模块,把YOLOv12的检测结果和同一时刻的工艺数据进行时间对齐。比如图像采集触发时刻往前推500ms,截取压力机主缸压力曲线的峰值区间,计算压力峰值、升压速率、保压时间这三个关键特征。再结合模具累计冲次、磨损周期表,得到一个“缺陷严重度评分”。

逻辑上用规则引擎就能搭起来,不需要再上一个复杂模型。这也是务实的选择——产线要的是稳,不是炫。

2.2 我们设计的融合检测链路

融合检测链路的整体顺序是这样的:

  1. 相机拍照,图像进入Spring Boot检测接口;
  2. YOLOv12模型推理,输出候选缺陷框和类别;
  3. 根据图像采集时间戳,从时序数据库查最近N秒的工艺曲线数据;
  4. 规则引擎按缺陷类型触发不同的融合逻辑(比如划痕缺陷更看重图像置信度,凹坑缺陷更看重压力曲线异常指标);
  5. 输出最终判定:OK、NG-轻度、NG-严重,并附上原因标签。

这里有一个关键设计:不是所有缺陷都做多源融合。如果YOLOv12的置信度超过0.9且缺陷面积占比明确,比如一个大面积的贯穿裂纹,图像信息已经足够,直接判NG,没必要等曲线数据。只有那些置信度在0.6到0.9之间、或者缺陷特征不明显的边界case,才触发多源融合。这样能减少对时序数据库的无效查询,API响应更快。

2.3 融合规则如何防止“模型自信满满但车间不敢用”

融合逻辑不是拍脑袋定的,而是在试产阶段用真实数据打磨出来的。我们遇到过很典型的情况:某个端盖的视觉检测置信度高达0.95,模型非常确定是划痕,但老师傅一看就说这不是缺陷,是模具光亮带的反光。后来查工艺曲线,发现当时压力完全正常,模具也刚换过。如果只用视觉模型,这条就要误杀。

反过来也有例子:图像上只有一个很模糊的阴影,YOLOv12给的置信度只有0.55,放在传统视觉规则里直接忽略。但融合工艺曲线后发现,冲压机的压力峰值明显低于正常范围,符合“材料拉裂前兆”的特征,最后切开检查确实有隐性微裂纹。

所以融合规则的打分设计是:

  • 视觉置信度占60%权重;
  • 工艺曲线异常指标占30%权重;
  • 模具冲次等批次数据占10%权重。

分数超过0.8判NG-严重,0.6到0.8判NG-轻度,低于0.6放行。这个阈值可以在配置中心动态调整,不用重新发版。产线质量主管后来自己都会调了。

3. 用同一批真实缺陷样本,对比传统CV和YOLOv12

“精度碾压传统CV”这个说法,不做对比实验不敢随便讲。我们在项目里确实做了完整的AB对比,用的还是同一批从产线实拍采集的5000张端盖图像,标注了7类缺陷。这组对比数据后来直接写进了项目验收报告,客户拿它去说服了质量部的人。

先说对比基准。传统CV方案,我们用的是最经典也最容易被客户接受的一套:灰度化、高斯滤波、Canny边缘检测、形态学闭运算、轮廓查找与面积过滤。不用太花哨的光流、频域分析,因为产线环境光不稳定,花哨方案在现场根本活不过一天。

对比对象是YOLOv12-nano,输入分辨率640x640,用我们自己标注的端盖缺陷数据训练了150个epoch。这里没有用更大的YOLOv12s或m版本,因为缺陷是小目标,大模型提升有限,但推理耗时翻倍,不符合2秒节拍的产线需求。

3.1 传统CV方案在端盖缺陷上的四大痛点

第一个痛点是光照波动。金属端盖表面是高反光材质,车间灯光有一点变化,灰度直方图整体偏移,固定阈值就失效。我们不得不在预处理阶段加自适应直方图均衡化,但正负样本的灰度分布重叠依然严重。

第二个痛点是缺陷特征设计。说句实话,传统CV本质上是在“猜”缺陷长什么样。划痕用形态学方向性过滤勉强能抓,凹坑靠灰度跳变加面积过滤也还能做一点,但污渍和锈点经常混在一起,特征在灰度空间上几乎没有区分度,只能不断加规则堆if-else。

第三个痛点是小目标和背景混淆。端盖边框上的毛刺,纹理和划痕在边缘检测结果里都表现为长条轮廓,传统CV很难区分。要么漏检,要么一堆误报。我们甚至试过模板匹配,效果更差,端盖的旋转角度稍微一变,匹配分直接崩。

第四个痛点是维护成本。传统CV方案到了现场,换一个光源、换一种材料批次,可能要重新调一轮阈值和面积参数。车间隔三差五来一句“怎么又误报了”,我们Java工程师就得打开代码找魔数。这种日子过了几周,团队一致决定必须换思路。

3.2 YOLOv12在检测头、注意力上的变化,给工业检测带来了什么

YOLOv12放到这个项目里,最直接的感受是它对“小目标”和“低对比度目标”的鲁棒性比之前几个版本上了一个台阶。YOLOv12在主干结构里对传统卷积模块做了注意力机制改造,同时兼顾了计算效率,不会像Vision Transformer那样在工业现场跑不动。

我理解得比较粗,但有一点在实际项目中非常管用:YOLOv12的特征提取对纹理区域更加敏感,金属端盖表面的微弱划痕——那种在传统边缘检测里根本形不成连续边缘的浅痕,在YOLOv12的特征图里能被激活出来。这就是“精度碾压传统CV”的本质原因。传统CV是通过人工设计的算子去“捞”特征,而YOLOv12是从几千张标注图里“学”出来的特征,它对缺陷形态的泛化能力不在一个级别。

YOLOv12还有Anchor-Free的检测头设计,端盖这类轮廓不规则、旋转角度多的目标,预测框回归更直接,不用像传统锚框方案那样要精心调锚点尺寸。

3.3 实测数据:精度、召回、漏检、单帧耗时

在同样的测试集(1000张,包含500张缺陷样本)上,我们得到了这样一组对比:

指标传统CV方案YOLOv12方案
准确率(Precision)78.6%95.2%
召回率(Recall)71.3%96.8%
漏检率28.7%3.2%
误报率(FPR)21.4%4.8%
单帧耗时(CPU)约35ms约180ms
单帧耗时(GPU)不适用约15ms

精度数据摆出来其实已经不用多解释了。传统CV的漏检率接近三成,在实际产线上,这个水平意味着每天几千个缺陷端盖流到下一道工序。而YOLOv12把漏检率压到了3%出头。

单帧耗时确实是YOLOv12的短板——传统CV快是因为它只是个opencv流水线,没有卷积计算那么重的负载。但产线节拍是2秒一件,180ms的CPU推理时间完全在预算之内。而且我们还发现,把模型导出为ONNX后,用CPU上的OpenVINO执行提供程序跑,单帧能压到90ms左右。这个后面部署章节细说。

客观说,YOLOv12也不是万能。对于严重的过曝、运动模糊图像,它也会漏检。我们最后的处理办法是:检测置信度低于0.4但图像质量评分也低的图片,判定为“待人工复核”,直接弹到质检工位,不让机器背全锅。

4. Spring Boot 3.4 + ONNX Runtime集成YOLOv12的实操记录

这章是整篇博文的干货核心。从模型导出到Java推理,每个环节都有不少坑,我按实际操作的顺序一步步讲。

4.1 训练与导出ONNX模型,注意那几个容易坑的算子

训练部分还是用Python生态,模型我们用YOLOv12-nano,标注工具用通用的LabelImg格式,转成YOLO格式的txt文件。重点说导出ONNX。

YOLOv12的导出命令和之前的YOLOv8系列类似,核心参数是设置opset版本。我们的建议是:opset=12,别贪新。新算子虽然在推理框架里支持,但ONNX Runtime的Java版本迭代有时跟不上,反而会报无法解析的错。我们一开始用opset 14,导出后Java加载直接报错,说是某个注意力算子版本不兼容。换成opset 12,一切正常。

导出时还要注意:输入尺寸固定为640x640,batch设为1。虽然ONNX Runtime支持动态batch,但工业检测场景根本不需要一次处理多张,固定shape能让推理速度更稳定,内部显存分配也少一些。

导出命令大致如下:

yolo export model=yolov12n.pt format=onnx opset=12 imgsz=640 dynamic=False

导出后会得到一个yolov12n.onnx文件,大概十几MB。这个文件直接放进Spring Boot的resources目录里,作为classpath资源打包。

在Python侧验证一下导出的ONNX模型输出前,确认输出张量的shape是(1, 7, 8400)还是(1, 8400, 7)。不同导出方式结果不一样。我们训练的是7类缺陷(6类缺陷加1个背景类?不对,YOLO输出通常是5+类别数),实际上我们是6类缺陷加背景,输出是(1, 8400, 11)——不对,纠正一下:6类缺陷的目标检测,输出维度是(1, 8400, 10)?让我实话说,这个数字跟训练数据类别数直接相关,格式是[cx, cy, w, h, obj_score, class1_score, class2_score, ...]。我们的缺陷类别是:凹坑、划痕、污渍、锈点、毛刺、裂纹,共6类,所以每个候选框有4个坐标加1个目标置信度加6个类别分数,一共11个值,输出shape是(1, 8400, 11)。8400是YOLOv12在不同尺度特征图上的候选框总数加起来的值。

这个数字千万别想当然,写代码前用Python打印一下输出的shape,比什么文档都可靠。

4.2 选ONNX Runtime而不是OpenCV DNN的原因

Java生态里加载ONNX模型,其实有两条路:OpenCV的DNN模块,或者微软的ONNX Runtime。我们两条路都试了,最终选了ONNX Runtime,原因很明确:

第一,ONNX Runtime对YOLO系列输出层的支持更完整。OpenCV DNN虽然也能跑,但遇到某些新算子(比如注意力机制里的部分操作)会出现“Unknown layer type”之类的问题,得自己写层插件,太折腾。

第二,ONNX Runtime有CPU、GPU、OpenVINO三种执行提供程序,切换成本极低。实测中,我们用CPU执行提供程序跑180ms,换成OpenVINO提供程序跑90ms,直接翻倍的性能提升。OpenCV DNN虽然也有后端切换,但Java侧配置远没有ONNX Runtime方便。

第三,ONNX Runtime官方提供Java的JNI绑定,Maven中央仓库直接能拉到对应坐标,不用手动编译动态库。

依赖就一行:

<dependency> <groupId>com.microsoft.onnxruntime</groupId> <artifactId>onnxruntime</artifactId> <version>1.19.2</version> </dependency>

注意版本号。ONNX Runtime的Java版更新不算频繁,选一个稳定的版本就行,不要追最新。

4.3 预处理、解码、NMS三个环节的Java代码实现

这环节是整个实现中最容易出错的地方。很多人以为Java调用ONNX Runtime只是个API问题,真正写了才知道,坑全在预处理和后处理。

先看预处理。YOLOv12训练时,图像会被letterbox到640x640,即等比缩放后四周填充灰色,然后归一化到0到1,通道顺序是RGB。Java端读取图片最方便的是ImageIO,但ImageIO对JPEG的采样精度有时候不够,高分辨率图会轻微偏色。我们后面改用TwelveMonkeys插件增强ImageIO的JPEG解码能力,颜色偏差问题才解决。

下面是核心的预处理代码:

public static float[] preprocess(BufferedImage src, int targetSize) { // 等比缩放 double scale = Math.min((double) targetSize / src.getWidth(), (double) targetSize / src.getHeight()); int newW = (int) (src.getWidth() * scale); int newH = (int) (src.getHeight() * scale); BufferedImage resized = new BufferedImage(newW, newH, BufferedImage.TYPE_3BYTE_BGR); Graphics2D g = resized.createGraphics(); g.drawImage(src, 0, 0, newW, newH, null); g.dispose(); // 创建填充画布 BufferedImage padded = new BufferedImage(targetSize, targetSize, BufferedImage.TYPE_3BYTE_BGR); int padX = (targetSize - newW) / 2; int padY = (targetSize - newH) / 2; Graphics2D gp = padded.createGraphics(); gp.setColor(Color.GRAY); gp.fillRect(0, 0, targetSize, targetSize); gp.drawImage(resized, padX, padY, null); gp.dispose(); // 转为CHW float数组,归一化到[0,1] float[] data = new float[3 * targetSize * targetSize]; int offset = 0; for (int c = 0; c < 3; c++) { for (int h = 0; h < targetSize; h++) { for (int w = 0; w < targetSize; w++) { int rgb = padded.getRGB(w, h); int val = (c == 0) ? ((rgb >> 16) & 0xFF) : (c == 1) ? ((rgb >> 8) & 0xFF) : (rgb & 0xFF); data[offset++] = val / 255.0f; } } } return data; }

这里有一个细节:YOLOv12训练时用的是RGB还是BGR,取决于ultralytics的默认配置。我们测试发现YOLOv12的模型在预处理用RGB时精度最高,这和某些老YOLO版本要求BGR不一样。建议导出模型后先读一下模型文档,或者干脆做一次AB对比。

预处理完成,用ONNX Runtime推理:

OrtEnvironment env = OrtEnvironment.getEnvironment(); OrtSession.SessionOptions options = new OrtSession.SessionOptions(); options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); // 如需OpenVINO,这里设置相应提供程序 OrtSession session = env.createSession("yolov12n.onnx", options); long[] shape = {1, 3, 640, 640}; OnnxTensor input = OnnxTensor.createTensor(env, preprocessData, shape); OrtSession.Result result = session.run(Map.of("images", input)); float[][][] output = (float[][][]) result.get(0).getValue(); // 输出shape: [1, 8400, 11]

接下来是后处理,这里有两个关键动作:解码坐标和NMS。

解码逻辑是:把模型输出的中心点坐标(cx, cy)和宽高(w, h)从640x640的letterbox坐标系还原到原图坐标系。需要用到预处理时记录的scale、padX、padY。

NMS(非极大值抑制)我不建议额外引入依赖库,自己写也就几十行。核心思路是:按置信度降序排列候选框,依次取框,去掉所有与当前框IoU超过阈值的其他框。

public static List<Detection> nms(float[][] boxes, float[] confidences, float[] classIds, float iouThreshold, float confThreshold, int imgW, int imgH, double scale, int padX, int padY) { List<Integer> order = new ArrayList<>(); for (int i = 0; i < confidences.length; i++) { if (confidences[i] >= confThreshold) order.add(i); } order.sort((a, b) -> Float.compare(confidences[b], confidences[a])); List<Detection> detections = new ArrayList<>(); while (!order.isEmpty()) { int best = order.remove(0); // 还原坐标 int x1 = (int)((boxes[best][0] - boxes[best][2] / 2 - padX) / scale); int y1 = (int)((boxes[best][1] - boxes[best][3] / 2 - padY) / scale); int x2 = (int)((boxes[best][0] + boxes[best][2] / 2 - padX) / scale); int y2 = (int)((boxes[best][1] + boxes[best][3] / 2 - padY) / scale); x1 = Math.max(0, Math.min(imgW, x1)); y1 = Math.max(0, Math.min(imgH, y1)); x2 = Math.max(0, Math.min(imgW, x2)); y2 = Math.max(0, Math.min(imgH, y2)); detections.add(new Detection(x1, y1, x2, y2, confidences[best], (int) classIds[best])); Iterator<Integer> it = order.iterator(); while (it.hasNext()) { int idx = it.next(); float iou = calcIoU(boxes[best], boxes[idx]); if (iou > iouThreshold) it.remove(); } } return detections; }

这里有个小坑:还原坐标时,如果letterbox的pad是ONNX导出时模型内部做的,Java侧就不能再手动减pad。我们用的yolov12n导出方式是把letterbox留在模型外部,也就是ultralytics的默认导出形式,所以Java侧必须自己处理pad。导出时如果带了rect=True之类参数,逻辑又不一样。再次强调,以导出后的实际输出去验证。

4.4 检测API与WebSocket实时通道

推理逻辑写好之后,Spring Boot侧的API设计决定这个系统好不好用。我们做了两个通道:

第一个是同步REST API,用于单张图片检测和批量文件检测。接口设计成接收MultipartFile,内部经过上面的流程后返回检测结果列表。为了兼容产线的老系统,返回格式特意设计成JSON数组,每个元素包含缺陷框坐标、类别、置信度。

第二个是WebSocket实时通道,用于产线相机连续抓拍时,把检测结果实时推送到质检大屏。客户端连上WebSocket后上传图片Base64数据,服务端异步推理完推回结果。Spring Boot 3.4对WebSocket的支持已经很成熟,我们在WebSocketHandler里维护了一个并发会话列表,推理线程池用虚拟线程,保证一个慢请求不会阻塞其他会话。

检测结果同时异步写入数据库并发送到消息队列。消息队列里的缺陷记录,下游分拣设备监听后,会在传送带上把NG端盖推入隔离箱。这个联动逻辑在我们之前的传统CV方案里是完全没有的,因为传统CV的误报太高,联动起来等于把好件也踢出去。

5. 从Demo到产线:压测、调优和几个被逼出来的优化

Demo跑通和产线稳定运行是两码事。我们在实验室里觉得一切都好,一上产线就被各种细节毒打。这一章专门写性能调优和部署阶段踩过的坑。

5.1 压测数据:单实例QPS、P99,以及一开始真实的翻车现场

第一个翻车现场非常典型:Demo阶段我们用的测试图片都是精心挑选的清晰图片,没做并发压测。上线前一天做了第一次压测,200并发打上来,GPU服务器的延迟从15ms直接飙到800ms,大量请求超时,Tomcat线程池被打满。

排查后发现,问题出在ONNX推理的并发处理上。我们最初把OrtSession做成了单例,结果多个线程同时调用session.run()时,ONNX Runtime默认不是线程安全的,底层执行时互相等待,甚至出现内存损坏的诡异错误。翻车现场直接把“GPU推理只要15ms”这句话打成笑话。

后来解决方案是:用OrtSession.SessionOptions开启多线程执行,同时给推理操作加了一把互斥锁,确保同一时刻只有一个线程进入session.run()。更低效但更稳。另外,把HTTP请求接收线程和推理线程池分开,图片上传和推理解耦,前端请求先落队列,推理线程池消费。

最终压测数据,单实例2核4G CPU服务器,不开GPU,纯CPU OpenVINO推理:

场景QPSP99延迟内存占用
单线程连续推理5.5 qps190ms450MB
4线程推理池19 qps210ms520MB
8线程推理池31 qps235ms610MB
4线程推理池+GPU63 qps18ms1.1GB

产线最忙的时候,一分钟需要检测30个端盖,也就是0.5 qps,单实例CPU模式都能扛住。但为了给后续增加检测工位留余量,我们最后还是用了带GPU的服务器。

5.2 提升吞吐的三个关键动作

第一个关键动作:开启ONNX Runtime的OpenVINO执行提供程序。同样一个模型,CPU默认执行180ms,换到OpenVINO执行提供程序之后90ms,几乎白捡一倍性能。这个优化连代码都不需要大改,只是在SessionOptions里加一行配置。代价是部署镜像里要多装OpenVINO的运行库,Docker镜像会变大300MB左右,但产线服务器完全不在乎这个。

第二个关键动作:虚拟线程处理IO密集型操作。图像解码、Base64编码、数据库写入这些操作在代码里占了不少时间。Spring Boot 3.4开启虚拟线程后,我们在配置里加一行spring.threads.virtual.enabled=true,然后所有Controller和WebSocket处理的入口方法全部换成虚拟线程执行。效果是同样的压测场景下,Tomcat线程池不再成为瓶颈,QPS提升了30%左右。

第三个关键动作:统一规格输入,加图片缓存。产线的相机分辨率是2048x2048,但YOLOv12只需要640x640。我们在相机端直接配置输出640x640的灰度图,避免Java侧每次都要做巨大的缩放运算。同时,对于重复出现的同一个产品批次号,用Caffeine缓存它的检测结果300毫秒,防止同一个端盖被相机连续拍多次时重复推理。

5.3 部署时的几个隐蔽问题,每个都是真实教训

部署过程中我们踩了五个坑,每一个都值得拿出来讲。

第一个坑是内存泄漏。ONNX Runtime在Java侧如果每次请求都创建OnnxTensor但不关闭,一段时间后直接OutOfMemory。我们一开始没注意,上线跑了两天,内存从500MB涨到2.8GB。解决办法是在try-with-resources里创建和关闭OnnxTensor,让它快速释放。

第二个坑是JDK版本。JDK 17跑ONNX Runtime完全没问题,但JDK 21的某些版本对JNI库加载做了更严格的安全校验,动态库路径不对就会抛UnsatisfiedLinkError。我们的做法是把ONNX Runtime的动态库解压到一个独立目录,通过-Djava.library.path显式指定,不用默认的临时目录,避免系统和用户权限问题。

第三个坑是模型文件放classpath的问题。Spring Boot把jar包打成fat jar后,ONNX Runtime无法直接读取classpath里的流作为模型文件。我们一开始用ClassPathResource.getInputStream(),结果session创建时报文件找不到。后来改为在应用启动时把yolov12n.onnx复制到/data/models/目录,再用绝对路径加载,问题解决。这个坑在本地IDE跑不暴露,打包部署才暴露。

第四个坑是GPU服务器的驱动版本。ONNX Runtime的CUDA执行提供程序对CUDA版本有严格要求,我们一开始装了最新的CUDA 12.4,结果ONNX Runtime报“CUDA driver version is insufficient”,回退到CUDA 11.8才正常。这种环境问题最浪费时间,建议在项目文档里把CUDA版本写死。

第五个坑是流式图片请求的背压。WebSocket实时通道如果客户端消费不过来,服务端内存会积压大量待推送结果。我们加了一个简单的背压机制:如果某个WebSocket会话的发送队列超过阈值,直接丢弃最老的检测结果并发送一个“检测过期”的提示帧。这个设计在产线大屏上很管用,因为屏幕刷新不需要追到每一帧,丢弃旧帧反而更流畅。

项目上线到现在跑了一个多月,漏检率稳定在4%以下,误报率5%左右,产线质检人力从原来两个人盯一条线,缩减到一个人巡检。下面说说我个人的感受。

我最想说的是,Java做视觉推理这件事,真正难的从来不是推理本身,而是把一个模型安全、稳定、可控地放进一套工程体系里。ONNX Runtime把Python和Java之间的墙打通了,Spring Boot 3.4则负责把推理能力变成产线上真正可用的服务。如果你所在的团队也全是Java背景,又想接视觉检测类项目,这条路完全走得通,而且比很多人想象的要稳。

最后分享一个细节:我们在导出YOLOv12模型时,特意保留了每个类别的置信度阈值调整入口,而不是把所有逻辑写死在代码里。上线之后质量部想提高划过痕的标准,我们只用改配置中心的阈值参数,五分钟搞定。这种“能够被现场人员平滑调整”的能力,才是工业项目从Demo变成真正生产力工具的关键。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询