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基本兼容导出后使用工具检查算子版本替换不兼容的opJava端图像预处理能否跟上Python端精度能对比Java和Python预处理后的像素值误差控制在1以内推理性能是否满足产线节拍满足单帧GPU推理约15msCPU约180ms产线节拍2秒/件模型后续迭代谁来维护Java团队训练-导出-替换model.onnxJava侧代码零改动这些判断题做完我们基本确认了技术路线。但真正让客户安心的是第三条Java的推理结果和Python侧的结果对同一批样本比对mAP差异在0.5个百分点以内甚至CPU推理时的数值稳定性还更好一点。这就是我们敢把这个架构写进方案书的原因。2. 拆解标题里“多模态”的真实含义图像之外还有曲线数据很多人看到“多模态”三个字第一反应是图片加文本、或者语音加图片。但在工业缺陷检测这个场景里我更喜欢把它理解成“多源数据联合决策”。这也是标题里“多模态”背后真正有工程价值的东西——单一的图像模型再准也会有它看不清的时候。比如一个凹坑和一块阴影在图像上可能长得几乎一样但如果你同时知道这个时间段压力机的吨位曲线有异常波动判断就会完全不一样。我们最终设计的“多模态”是三类数据的融合视觉数据相机拍到的端盖表面图像由YOLOv12检出缺陷框、缺陷类型、置信度工艺曲线数据冲压机每冲一次采集压力、速度、模具温度随时间变化的曲线生产批次数据模具编号、冲压次数、上一次换模时间、来料批次号等。接地气地讲视觉负责“看”曲线负责“感”批次数据负责“记”把这三个维度的信息合并起来最终输出一条“缺陷等级建议”。2.1 工业场景的多模态不是图片加文字现在一提多模态大家想到的都是大模型、图文问答。但产线上设备的PLC、传感器、条码枪可不会讲人话它们吐出来的是PLC寄存器数据、OPC UA节点的变量值、扫码枪的字符串。我们接到的真实需求是“能不能用图像检测缺陷同时结合冲压机的压力曲线判断这个缺陷是偶发还是系统性风险”这个问题决定了我们没法只做一个“输入图片、输出框”的简单模型。需要在Java工程里加一个数据融合模块把YOLOv12的检测结果和同一时刻的工艺数据进行时间对齐。比如图像采集触发时刻往前推500ms截取压力机主缸压力曲线的峰值区间计算压力峰值、升压速率、保压时间这三个关键特征。再结合模具累计冲次、磨损周期表得到一个“缺陷严重度评分”。逻辑上用规则引擎就能搭起来不需要再上一个复杂模型。这也是务实的选择——产线要的是稳不是炫。2.2 我们设计的融合检测链路融合检测链路的整体顺序是这样的相机拍照图像进入Spring Boot检测接口YOLOv12模型推理输出候选缺陷框和类别根据图像采集时间戳从时序数据库查最近N秒的工艺曲线数据规则引擎按缺陷类型触发不同的融合逻辑比如划痕缺陷更看重图像置信度凹坑缺陷更看重压力曲线异常指标输出最终判定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方案准确率Precision78.6%95.2%召回率Recall71.3%96.8%漏检率28.7%3.2%误报率FPR21.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版本。我们的建议是opset12别贪新。新算子虽然在推理框架里支持但ONNX Runtime的Java版本迭代有时跟不上反而会报无法解析的错。我们一开始用opset 14导出后Java加载直接报错说是某个注意力算子版本不兼容。换成opset 12一切正常。导出时还要注意输入尺寸固定为640x640batch设为1。虽然ONNX Runtime支持动态batch但工业检测场景根本不需要一次处理多张固定shape能让推理速度更稳定内部显存分配也少一些。导出命令大致如下yolo export modelyolov12n.pt formatonnx opset12 imgsz640 dynamicFalse导出后会得到一个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 groupIdcom.microsoft.onnxruntime/groupId artifactIdonnxruntime/artifactId version1.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 ListDetection nms(float[][] boxes, float[] confidences, float[] classIds, float iouThreshold, float confThreshold, int imgW, int imgH, double scale, int padX, int padY) { ListInteger 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])); ListDetection 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])); IteratorInteger 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。导出时如果带了rectTrue之类参数逻辑又不一样。再次强调以导出后的实际输出去验证。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 qps190ms450MB4线程推理池19 qps210ms520MB8线程推理池31 qps235ms610MB4线程推理池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.enabledtrue然后所有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变成真正生产力工具的关键。