Java+YOLOv5食品包装缺陷检测实战:从模型导出到Spring Boot API对接前几个月在做一个食品产线的质检项目,需求很朴素:检测包装袋的封口是否平整、有无漏气皱褶、印刷字符是否错位、包装表面有没有污渍。算法那边用YOLOv5训练了一版模型,效果不错,但到了部署环节突然发现一个尴尬问题——产线管理系统、MES对接层、设备控制服务全是Java写的,算法同学丢过来一个.pt权重文件就撤了。剩下的事情,就是把YOLOv5模型真正跑进Java服务里,再包一层Spring Boot API给产线调用。这中间的水比想象中深。模型导出踩了坑、Java端图像预处理和Python端不一致导致检测结果对不上、ONNX Runtime集成后内存暴涨、API并发一上来就超时……前前后后折腾了两周,把整套链路跑通之后回看,其实核心问题就那几个:模型怎么导、Java端怎么接、前后处理怎么写、服务怎么扛住产线的并发。这篇文章就把这套完整方案拆开讲,从模型导出到Spring Boot API对接,每一步给出可直接照抄的做法和参数,顺手把我踩过的坑一并交代清楚。这篇文章适合两类人看:一是Java后端工程师,被分配了“把模型接进服务”的活,PyTorch不熟但想把事情干成;二是算法工程师,训练完模型之后想自己把部署链路打通,不用每次都在Python里起一个服务给Java那边调用。两边视角我都尽量覆盖。1. 方案设计:先想清楚部署链路再动手1.1 缺陷检测场景的真实约束食品包装的缺陷检测和通用目标检测不太一样。产线上是固定工位的工业相机,拍照距离基本固定,光照环境相对可控,被检物体(包装袋)的形态比较规整。这意味着两件事:第一,模型不需要做旋转目标检测,也不需要多尺度强鲁棒性,常规YOLOv5已经够用;第二,推理时延是硬指标,产线节拍如果是一分钟60包,那么单包检测时间必须控制在1秒以内才能跟上流水线速度,否则就得降速或者增加并行工位。另外,缺陷类别通常是小目标。比如封口处的皱褶,在1080P图像里可能只占几十个像素;包装袋角落的污渍面积也不大。所以训练时输入尺寸不能压得太低,我用的是640x640输入,特征层采样的信息足够保留小目标细节。这个选择会在Java端的预处理环节直接产生影响,后面细说。还有一个很容易被忽视的点:缺陷检测的“误报代价”很高。食品行业如果放过了一个漏气袋,到了流通环节就会变成客诉;但反过来如果误报率太高,产线工人要频繁处理假阳性,慢慢就不信任这套系统了。所以后处理时的置信度阈值不能只定一个,我最后在API层做了分缺陷类别的阈值配置,而不是在模型里写死,方便现场调参。1.2 技术选型:为什么用ONNX Runtime而不是直接用PyTorch先说一下我当时筛选部署方案的思路。算法同学给的模型是PyTorch训练的.pt文件,Java端有几条路可以走:在Python侧单独部署一个PyTorch推理服务,Java通过HTTP调用。缺点很明显:多维护一个进程,跨语言网络调用有传输开销,而且Python服务的稳定性不如JVM体系好保障。产线环境里不能接受一个三天两头被内存杀掉的Python进程当核心依赖。用Java端的PyTorch官方库。PyTorch Java API覆盖率一直不太行,当时试了一下,部分算子支持不完整,而且.pt文件里包含Python对象序列化信息,Java端加载对版本极度敏感,踩坑成本太高。导出ONNX模型,用ONNX Runtime Java API加载推理。这是当时综合评估下来最稳的方案,也是我现在给类似项目做技术选型时的默认答案。ONNX是模型交换的标准格式,ONNX Runtime在Java侧的API虽然不算丰富,但推理这核心功能非常稳定,CPU和GPU都能跑,和Spring Boot集成没有任何额外负担。另外,ONNX Runtime还有一个好处是对CPU推理做了深度优化。产线现场很多工控机是没有独立显卡的,纯CPU推理能跑到什么性能直接决定方案能不能落地。YOLOv5s模型导出为ONNX后,在普通i7工控机上640x640输入大约可以做到80~120ms一帧,去掉前后处理的时间,整体足以满足秒级节拍。方案架构一句话总结:YOLOv5 训练产出.pt→ 导出带NMS后处理的ONNX模型 → Spring Boot 服务通过 ONNX Runtime 加载模型 → 接收产线相机上传的图片 → 预处理 → 推理 → 后处理 → 返回缺陷坐标、类别和置信度 → 上位机/控制系统根据结果做剔除动作。2. 模型导出与转换:从.pt到ONNX的关键操作2.1 导出前的检查和准备先用Python环境把YOLOv5跑通,确认PyTorch版本。这里有个非常关键的前提:训练环境和导出环境的PyTorch版本要尽量接近,否则导出时有些算子映射会不一致。我踩过一次坑:训练在PyTorch 1.12,导出机器上是2.0,导出的ONNX在Java端加载报“Unsupported operator”错,后来重新在同等版本环境导出就好了。导出前还要确认模型是否已经转为半精度或做了剪枝。常规做法是训练完保留best.pt,如果追求CPU推理速度可以尝试导出FP16模型。但是FP16在CPU上的优化收益有限,反而可能在边缘设备上出现精度下降,我的建议是先用FP32导出跑通链路,再考虑FP16。导出的核心命令如下,建议直接写在requirements.txt环境固定版本:python export.py --weights best.pt --include onnx --opset 12 --simplify三个参数背后是有讲究的:--include onnx:只导出ONNX,不要顺手导出TorchScript,后者Java端用不上。--opset 12:ONNX算子集版本。ONNX Runtime对opset 12的支持非常成熟,再高的版本(比如15、17)虽然新特性多,但在某些老版本ONNX Runtime上会遇到算子缺失的问题。低版本opset兼容性最好,除非模型里用到了新算子,否则就用默认的 12 或根据YOLOv5默认值来。--simplify:用onnx-simplifier做模型精简,去掉冗余算子(比如Shape、Gather等无关计算),这一步能让模型体积减少5%~10%,推理速度也能提升一点。导出后务必看一眼模型大小。YOLOv5s大概14MB左右,如果导出后是40MB,多半是导出了整个训练模型,包含优化器状态,需要重新用export.py脚本导出。模型大小直接决定Java端加载时间和内存占用,不能忽略。2.2 导出带NMS和不带NMS的差异YOLOv5的export.py默认导出的ONNX是不带NMS后处理的,模型的输出是原始预测张量,形状为[1, 25200, 85](以COCO 80类为例,25200 = 3个尺度 x (640/8)^2 + (640/16)^2 + (640/32)^2,85 = 5 + 80,即cx, cy, w, h, objectness, class_scores)。这意味着后处理(坐标解码、置信度过滤、NMS去重)必须在Java端手动完成。也有人在导出时就通过--end-to-end参数把NMS集成到ONNX模型里,这样Java端就不用写NMS代码,直接拿最终检测框。我强烈建议不要在导出时集成NMS,原因有三:第一,集成NMS的ONNX模型在不同ONNX Runtime版本上的算子兼容性差异很大,经常出现 “NonMaxSuppression” 算子不支持的情况。第二,内置NMS会把置信度阈值和IoU阈值固化在模型里,现场想调阈值就要重新导出模型,非常不方便。第三,Java端手写NMS并不困难,百来行代码而已,还能顺便把一些业务逻辑(比如按缺陷类型配置阈值)合进去,灵活性高得多。所以最终目标是:导出不带NMS的原始ONNX,Java端自己处理后处理。导出后不要急着写Java,先用Python做一次“对齐验证”,这是整个部署链路中最容易省掉但最不能省的一步:import onnxruntime as ort import numpy as np import cv2 sess = ort.InferenceSession("best.onnx", providers=["CPUExecutionProvider"]) img = cv2.imread("test.jpg") img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (640, 640)) img = img.astype(np.float32) / 255.0 img = img.transpose(2, 0, 1) img = np.expand_dims(img, 0) outputs = sess.run(None, {sess.get_inputs()[0].name: img}) print(outputs[0].shape)这一步的意义在于:先确认模型在Python侧输出是正常的,再对比Java端输出。如果Python侧结果都是乱的,说明模型本身有问题,Java端写得再好也没用。对齐验证时,固定一张测试图,把Python侧的结果(坐标、类别、置信度)记下来,后面Java端部署完用同一张图跑,结果应该完全一致。3. Java环境搭建与图片预处理:最容易出问题的环节3.1 Maven依赖和模型目录规划Java工程引入ONNX Runtime非常简单,Maven里加一行依赖:<dependency> <groupId>com.microsoft.onnxruntime</groupId> <artifactId>onnxruntime</artifactId> <version>1.16.3</version> </dependency>版本我建议用当前最新的稳定版,不要用太老的版本,因为ONNX Runtime每个版本对算子支持和CPU性能优化都在持续改进。实际项目里我用的1.16.3,稳定跑了很久。模型文件放哪里也值得提前约定。不要直接把.onnx文件塞进Spring Boot的resources目录里打包进Jar,因为产线环境经常需要热更新模型(重新训练后替换权重),每次更新都要重新打Jar再发布太痛苦了。正确做法是放在外部目录,通过配置文件指定路径:detect: model-path: /data/models/best.onnx input-size: 640 conf-threshold: 0.25 iou-threshold: 0.45启动时通过@Value注入路径,服务初始化时加载模型。这样后续更新模型只需覆盖文件,然后调用一个/admin/reload接口触发重新加载即可,不需要重启服务。3.2 图像预处理的每个步骤都必须和Python端完全一致前面给Python对齐用的预处理是:BGR转RGB→resize到640x640→归一化到0~1→NCHW排列。Java端要做到一模一样,但实际写起来有两个隐藏的坑。第一个坑是resize方式。YOLOv5默认的letterbox是等比缩放加灰度填充(pad),不是直接把图拉伸到640x640。如果直接用BufferedImage.getScaledInstance(640, 640)把图硬拽变形,检测精度会明显下降,因为目标的长宽比被破坏了。实际上YOLOv5 export后的模型期望的输入是“已letterbox”的图像,原因很简单,模型训练时也是这么做的。所以在Java端需要自己实现letterbox逻辑:public static int[] letterbox(BufferedImage src, int targetSize) { int srcW = src.getWidth(); int srcH = src.getHeight(); double scale = Math.min((double) targetSize / srcW, (double) targetSize / srcH); int newW = (int) Math.round(srcW * scale); int newH = (int) Math.round(srcH * scale); int padX = (targetSize - newW) / 2; int padY = (targetSize - newH) / 2; 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 canvas = new BufferedImage(targetSize, targetSize, BufferedImage.TYPE_3BYTE_BGR); Graphics2D g2 = canvas.createGraphics(); g2.setColor(Color.GRAY); // YOLOv5默认用灰值114填充 g2.fillRect(0, 0, targetSize, targetSize); g2.drawImage(resized, padX, padY, null); g2.dispose(); return new int[]{padX, padY, scale}; }注意YOLOv5源码letterbox的填充色是(114, 114, 114),不是纯黑也不是纯白。这个细节必须对齐,否则图像分区填充区域的像素值会影响输入分布,降低检测精度。第二个坑是内存中的像素排列。Java的BufferedImage像素存储是CHW之外的交错格式,说白了就是每个像素的B、G、R三个字节连续排列。而ONNX Runtime需要的输入是NCHW,也就是先所有通道的Row0,再所有通道的Row1。转换时要手动把像素按通道拆开重排:float[] input = new float[3 * targetSize * targetSize]; for (int y = 0; y < targetSize; y++) { for (int x = 0; x < targetSize; x++) { int rgb = canvas.getRGB(x, y); int b = rgb & 0xFF; int g = (rgb >> 8) & 0xFF; int r = (rgb >> 16) & 0xFF; int idx = y * targetSize + x; input[idx] = r / 255.0f; input[idx + targetSize * targetSize] = g / 255.0f; input[idx + 2 * targetSize * targetSize] = b / 255.0f; } }getRGB每像素调用在性能上是灾难性的,一张640x640的图要执行40万次,还要做位运算,实测下来预处理耗时能到300~500ms,完全不可接受。优化方案是直接拿像素数组批量读取:int[] pixels = canvas.getRGB(0, 0, targetSize, targetSize, null, 0, targetSize); for (int i = 0; i < pixels.length; i++) { int rgb = pixels[i]; int b = rgb & 0xFF; int g = (rgb >> 8) & 0xFF; int r = (rgb >> 16) & 0xFF; float val = (r & 0xFF) / 255.0f; input[i] = val; input[i + targetSize * targetSize] = g / 255.0f; input[i + 2 * targetSize * targetSize] = b / 255.0f; }这样一次getRGB拷贝整个像素数组,40万次位运算在毫秒级完成。实测预处理+转换总耗时可以压到30ms以内。关于BGR和RGB的顺序,如果模型训练时走的是标准YOLOv5默认方式,图片是先转RGB再进入网络的,那Java端也要转。但要注意:如果训练时喂的本来就是OpenCV读出来的BGR图,那导出后模型输入的通道顺序其实是BGR。这必须回到训练脚本里去确认。我当时就是没确认清楚,Java端转了RGB之后检测精度比Python侧低了一截,检查了一天发现训练脚本里压根没有cv2.cvtColor这一步,模型期望的是BGR输入。所以提前记住:完全以训练时的数据管线为准,不是所有YOLOv5模型都要转RGB。3.3 模型加载和Session配置ONNX Runtime在Java端的使用方式如下:import ai.onnxruntime.*; OrtEnvironment env = OrtEnvironment.getEnvironment(); OrtSession.SessionOptions options = new OrtSession.SessionOptions(); options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); options.setIntraOpNumThreads(4); OrtSession session = env.createSession(modelPath, options);这个session是线程安全的,多个线程可以同时调用session.run()做推理,不需要加锁也不需要为每个线程单独创建session。这是ONNX Runtime设计好的并发模型,不要每来一张图就重新createSession,session创建过程中要解析模型结构、申请内存,非常耗时。内部线程数setIntraOpNumThreads需要根据CPU核心数调整。不是线程越多越好,ONNX Runtime内部算子并行度太高会导致线程频繁切换,反而变慢。在8核工控机上实测,4~6个内部线程综合效果最好;在只分配了2核的容器里就把线程数设成2。如果服务本身还承担了其他业务,建议先用4核跑,观察CPU占用再慢慢调。GPU模式下还需要设置CUDA provider,代码会复杂一点。如果产线现场有显卡,配置方式是在创建session时添加CUDA provider:options.addCUDA(0);同时把依赖里增加onnxruntime-gpu替代原来的onnxruntime。但GPU不是必须的,我们项目最后反而是CPU方案更省心:现场工控机显卡驱动成了新的故障点,CPU推理跑下来也能满足节拍要求。部署环境的稳定性优先于推理速度。4. 核心推理与后处理:坐标解码和NMS4.1 推理调用ONNX Runtime的输入是一个OnnxTensor,可以直接把上面算好的float[]包进去:long[] shape = new long[]{1, 3, 640, 640}; OnnxTensor inputTensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(inputData), shape); Map<String, OnnxTensor> inputs = new HashMap<>(); inputs.put(session.getInputNames().iterator().next(), inputTensor); try (OnnxTensor output = (OnnxTensor) session.run(inputs).get(0)) { float[][][] result = (float[][][]) output.getValue(); // result[0] 形状是 [1, 25200, 85] // 每一行是 cx, cy, w, h, objectness, class_scores... }注意session.getInputNames()返回的是Set,虽然只有一个输入,但要用迭代器拿到,不要硬编码字符串,避免模型重导出后输入节点名变化导致报错。4.2 输出张量解析:从中心坐标到边界框ONNX输出的是YOLOv5原始格式:每个预测框用(cx, cy, w, h)表示中心点坐标和宽高,后面是objectness(该框内是否有目标的置信度)和各类别得分。要先把它转换成(x1, y1, x2, y2)的左上右下坐标:float cx = row[0], cy = row[1], w = row[2], h = row[3]; float objScore = row[4]; float x1 = cx - w / 2; float y1 = cy - h / 2; float x2 = cx + w / 2; float y2 = cy + h / 2; // 得分 = objectness * classScore float maxClassScore = 0; int maxClassId = -1; for (int c = 5; c < row.length; c++) { float clsScore = row[c]; float score = objScore * clsScore; if (score > maxClassScore) { maxClassScore = score; maxClassId = c - 5; } }这里用的是“目标置信度 x 类别置信度”的乘积作为最终得分,这是YOLOv5的输出逻辑。只取classScore不问objectness是不对的,会在背景区域产生大量虚警。另外,因为输入做了letterbox,检测出来的坐标是相对于640x640输入图的。要还原到原始图像坐标,需要把前面letterbox记录的padX, padY, scale反向换算:float origX1 = (x1 - padX) / scale; float origY1 = (y1 - padY) / scale; float origX2 = (x2 - padX) / scale; float origY2 = (y2 - padY) / scale; // 需要做边界裁剪,防止坐标越界: // origX1 = Math.max(0, origX1); ...没有做反向映射就直接把框画到原图上,位置会整体偏移。训练和推理之间的每一个对齐细节,最后都体现在这一个坐标偏移上。4.3 NMS的Java实现之后需要对过滤后的框做NMS去重。同一缺陷可能会被多个尺度的anchor同时检出,NMS保留置信度最高、抑制IoU过高的重叠框。NMS的Java实现不复杂,但有几个细节要注意。先用置信度阈值过滤掉低分框:float confThreshold = 0.25f; // 从配置读取 List<Detection> detections = new ArrayList<>(); for (float[] row : result[0]) { float score = ...; // 上面算出的最终得分 if (score >= confThreshold) { detections.add(new Detection(x1, y1, x2, y2, score, classId)); } }然后按得分从高到低排序,逐个取框,把和当前框IoU超过阈值的框都删掉:detections.sort((a, b) -> Float.compare(b.score, a.score)); List<Detection> picked = new ArrayList<>(); while (!detections.isEmpty()) { Detection best = detections.remove(0); picked.add(best); Iterator<Detection> iter = detections.iterator(); while (iter.hasNext()) { Detection d = iter.next(); if (calcIoU(best, d) > iouThreshold) { iter.remove(); } } }IoU计算是矩形面积比的经典公式,注意并集面积要减去交集面积,避免重叠部分被重复计算。IoU阈值一般取0.45~0.5,过低会导致同一个目标被拆成多个框,过高则无法有效去重,现场可以调。不过NMS有个常见问题:当两个不同类别的缺陷目标高度重叠时,会被NMS合并掉一个。比如包装袋上一张污渍标签恰好叠在印刷字符错位上,如果两个目标框IoU超过阈值,得分低的那个会被抑制。解决方法是按类别分组做NMS:同一类别之间做NMS,不同类别不抑制。这样不会漏掉叠加缺陷。5. Spring Boot API封装与工程化5.1 接口设计和返回结构API的思路是:接收前端/上位机传来的图片,返回检测到的缺陷列表。接口路径建议设计成两个,一个给产线实时检测用,另一个给算法调试用:POST /api/detect:传图片二进制流,返回缺陷检测结果GET /api/model/info:返回当前模型信息、各类别名称、阈值配置,方便前端做展示返回结构要考虑到调用方的消费能力。产线控制系统只要知道“这包有没有问题,有问题就触发剔除”,所以JSON不要设计太复杂:{ "code": 0, "message": "success", "data": { "defectCount": 2, "defects": [ { "type": "wrinkle", "typeName": "封口皱褶", "confidence": 0.93, "x1": 112.5, "y1": 340.2, "x2": 178.3, "y2": 402.9 } ], "elapsedMs": 96 } }elapsedMs这个字段一定要返回,现场调试时根据它判断系统的性能余量。我那时就是靠这个字段发现某台工控机CPU占用过高、推理时长翻倍的问题。5.2 异步调用与线程池配置产线调用方(比如PLC或上位机)的请求特征是:高频率、单张图片数据量不大、对响应时间敏感。如果Spring Boot默认的Tomcat线程池来处理,每个请求占用一个线程做推理,那么顶层配置的线程数就是系统的最大并发处理能力。推理属于CPU密集操作。8核机器上,如果线程数设为8,每个线程都在抢CPU时间片,上下文切换开销很大。我自己的经验是:CPU密集型推理服务的线程数 = CPU核心数或核心数-1,预留一个核心给GC和网络IO处理。可以这样配置:@Bean("detectExecutor") public ThreadPoolTaskExecutor detectExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(4); executor.setQueueCapacity(200); executor.setThreadNamePrefix("detect-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return executor; }CallerRunsPolicy被拒绝的任务由调用线程执行,而不是直接抛异常。在产线并发高峰时,这相当于一种保险机制,不会因为线程池满了就丢弃检测请求,代价是调用方线程会被占用在推理上,响应变慢。对于产线系统,丢一个检测请求意味着漏检一个产品,远比变慢严重。接口内部的处理逻辑应该是:请求进来 → 图片解码 → 预处理 → 提交给线程池推理 → 同步等待结果。用CompletableFuture做异步编排:@PostMapping("/detect") public DetectResponse detect(@RequestParam("file") MultipartFile file) { // 提交推理任务到线程池 CompletableFuture<DetectResult> future = CompletableFuture.supplyAsync( () -> detectService.detectImage(file), detectExecutor); DetectResult result = future.get(5, TimeUnit.SECONDS); // 超时保护 return DetectResponse.success(result); }超时保护非常重要。万一模型推理卡死(ONNX Runtime偶发的线程饥饿问题),不能让产线请求无限等下去。5秒超时是在产线允许范围内(单包节拍1秒,但如果整体卡住给5秒重试窗口是合理的)的兜底方案。5.3 图片解码和跨域问题MultipartFile是个松耦合接口,但实际操作时要先把文件字节转成BufferedImage。这一步常见问题是,相机上传的图片格式可能不止JPEG,有些工业相机直接输出BMP甚至裸RGB数据。我建议服务端统一用ImageIO.read(new ByteArrayInputStream(bytes)),它对JPEG、PNG、BMP都支持。但如果遇到ImageIO不支持的格式,就要考虑加上OpenCV的Java绑定。跨域配置是另一个现场常遇到的问题。前端监控大屏页面和检测服务往往是分开部署的,直接调用API会触发CORS拦截。在配置类里加一个WebMvcConfigurer统一定义放行规则:@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST") .maxAge(3600); } }这里有个细节:allowedOrigins("*")在Spring Boot新版里不允许和allowCredentials(true)同时使用,会启动报错。产线检测接口不需要cookie,所以直接不要开credentials,或者用allowedOriginPatterns("*")前者版本兼容写法。6. 生产环境踩坑记录:问题排查速查表6.1 高频问题整理按下表整理是我调试期内遇到的高频问题,这些问题网上零散也有,但集中成表方便在产线现场快速对照:问题现象可能原因排查思路与解决Java推理结果和Python不一致预处理链路不一致(resize方式、通道顺序、归一化)固定同一张测试图,分别打点比对预处理结果坐标偏移但能框出目标letterbox的padX/padY/scale未反向映射检查后处理反向坐标计算的逻辑session.run()抛OOM并发请求过多,tensor内存未释放使用try-with-resources管理OnnxTensor,检查每个请求是否关闭了tensor检测结果时有时无图片格式不统一,解码失败后降级处理增加格式校验,兼容工业相机裸输出推理耗时突然变长线程池被阻塞,请求排队等待监控线程池活跃数和队列长度,排查是否有长任务耗尽线程加载模型时提示unsupported operator训练/导出环境opset版本过高或过低固定opset=12重新导出;更新ONNX Runtime版本6.2 隐蔽故障实录:ONNX Runtime的Tensor释放问题ONNX Runtime的Java API有个隐蔽的坑:**OrtSession.run()返回的结果必须在用完后关闭,否则DirectByteBuffer的内存不会被释放**。Java堆内存外还有一块直接内存,JVM的Xmx参数管不到它,如果服务跑几天内存持续上涨,最终会OutOfMemory。正确做法是使用try-with-resources:try (OrtSession.Result result = session.run(inputs)) { OnnxTensor output = (OnnxTensor) result.get(0); // 处理结果... }同时在每个请求结束时也要把输入tensor关闭:try (OnnxTensor inputTensor = OnnxTensor.createTensor(...)) { // 推理逻辑 }这个坑在本地压测短时间看不出来,跑到第几百个小时就暴露了。我们当时线上服务跑了3天内存翻倍,排查了很久最后发现是session.run返回值没有关闭导致的。6.3 模型热更新机制产线系统不能频繁重启。模型重新训练后,在服务运行状态下把权重文件替换掉,再触发一个reload接口即可。实现方式是维护一个volatile OrtSession引用,reload时创建新session再替换旧引用:private volatile OrtSession session; public synchronized void reloadModel(String path) throws OrtException { OrtSession newSession = env.createSession(path, options); OrtSession oldSession = this.session; this.session = newSession; if (oldSession != null) { oldSession.close(); } }用synchronized保证reload不会和推理请求并发创建/关闭session。旧session等新session加载完成后再关闭,避免出现“正在推理时模型被卸载”的竞态问题。这个机制在现场帮了大忙,算法那边迭代了一版新模型,直接在中午换班间隙调接口热切换,完全没影响产线。最后的实践经验小结这套方案从模型导出到Spring Boot API对接,核心难点其实不在模型本身,而在各种链路对齐的细节上:预处理顺序对不对、letterbox有没有做、坐标有没有反向换算、tensor有没有释放。把这些对齐了,整套系统跑起来非常稳。我在这个项目里最后沉淀的几条经验是:第一,部署之前一定要做Python和Java的对齐验证,这能省掉后面90%的排查时间;第二,所有阈值参数全部配置化,不要在代码里写死,现场调优时你会感谢这个决定;第三,ONNX Runtime的session是线程安全的,但tensor不是,该释放的一定要释放。如果你正在做类似的项目,希望这篇文章能让你少走几段弯路。
Java+YOLOv5食品包装缺陷检测实战:从模型导出到Spring Boot API对接前几个月在做一个食品产线的质检项目,需求很朴素:检测包装袋的封口是否平整、有无漏气皱褶、印刷字符是否错位、包装表面有没有污渍。算法那边用YOLOv5训练了一版模型,效果不错,但到了部署环节突然发现一个尴尬问题——产线管理系统、MES对接层、设备控制服务全是Java写的,算法同学丢过来一个.pt权重文件就撤了。剩下的事情,就是把YOLOv5模型真正跑进Java服务里,再包一层Spring Boot API给产线调用。这中间的水比想象中深。模型导出踩了坑、Java端图像预处理和Python端不一致导致检测结果对不上、ONNX Runtime集成后内存暴涨、API并发一上来就超时……前前后后折腾了两周,把整套链路跑通之后回看,其实核心问题就那几个:模型怎么导、Java端怎么接、前后处理怎么写、服务怎么扛住产线的并发。这篇文章就把这套完整方案拆开讲,从模型导出到Spring Boot API对接,每一步给出可直接照抄的做法和参数,顺手把我踩过的坑一并交代清楚。这篇文章适合两类人看:一是Java后端工程师,被分配了“把模型接进服务”的活,PyTorch不熟但想把事情干成;二是算法工程师,训练完模型之后想自己把部署链路打通,不用每次都在Python里起一个服务给Java那边调用。两边视角我都尽量覆盖。1. 方案设计:先想清楚部署链路再动手1.1 缺陷检测场景的真实约束食品包装的缺陷检测和通用目标检测不太一样。产线上是固定工位的工业相机,拍照距离基本固定,光照环境相对可控,被检物体(包装袋)的形态比较规整。这意味着两件事:第一,模型不需要做旋转目标检测,也不需要多尺度强鲁棒性,常规YOLOv5已经够用;第二,推理时延是硬指标,产线节拍如果是一分钟60包,那么单包检测时间必须控制在1秒以内才能跟上流水线速度,否则就得降速或者增加并行工位。另外,缺陷类别通常是小目标。比如封口处的皱褶,在1080P图像里可能只占几十个像素;包装袋角落的污渍面积也不大。所以训练时输入尺寸不能压得太低,我用的是640x640输入,特征层采样的信息足够保留小目标细节。这个选择会在Java端的预处理环节直接产生影响,后面细说。还有一个很容易被忽视的点:缺陷检测的“误报代价”很高。食品行业如果放过了一个漏气袋,到了流通环节就会变成客诉;但反过来如果误报率太高,产线工人要频繁处理假阳性,慢慢就不信任这套系统了。所以后处理时的置信度阈值不能只定一个,我最后在API层做了分缺陷类别的阈值配置,而不是在模型里写死,方便现场调参。1.2 技术选型:为什么用ONNX Runtime而不是直接用PyTorch先说一下我当时筛选部署方案的思路。算法同学给的模型是PyTorch训练的.pt文件,Java端有几条路可以走:在Python侧单独部署一个PyTorch推理服务,Java通过HTTP调用。缺点很明显:多维护一个进程,跨语言网络调用有传输开销,而且Python服务的稳定性不如JVM体系好保障。产线环境里不能接受一个三天两头被内存杀掉的Python进程当核心依赖。用Java端的PyTorch官方库。PyTorch Java API覆盖率一直不太行,当时试了一下,部分算子支持不完整,而且.pt文件里包含Python对象序列化信息,Java端加载对版本极度敏感,踩坑成本太高。导出ONNX模型,用ONNX Runtime Java API加载推理。这是当时综合评估下来最稳的方案,也是我现在给类似项目做技术选型时的默认答案。ONNX是模型交换的标准格式,ONNX Runtime在Java侧的API虽然不算丰富,但推理这核心功能非常稳定,CPU和GPU都能跑,和Spring Boot集成没有任何额外负担。另外,ONNX Runtime还有一个好处是对CPU推理做了深度优化。产线现场很多工控机是没有独立显卡的,纯CPU推理能跑到什么性能直接决定方案能不能落地。YOLOv5s模型导出为ONNX后,在普通i7工控机上640x640输入大约可以做到80~120ms一帧,去掉前后处理的时间,整体足以满足秒级节拍。方案架构一句话总结:YOLOv5 训练产出.pt→ 导出带NMS后处理的ONNX模型 → Spring Boot 服务通过 ONNX Runtime 加载模型 → 接收产线相机上传的图片 → 预处理 → 推理 → 后处理 → 返回缺陷坐标、类别和置信度 → 上位机/控制系统根据结果做剔除动作。2. 模型导出与转换:从.pt到ONNX的关键操作2.1 导出前的检查和准备先用Python环境把YOLOv5跑通,确认PyTorch版本。这里有个非常关键的前提:训练环境和导出环境的PyTorch版本要尽量接近,否则导出时有些算子映射会不一致。我踩过一次坑:训练在PyTorch 1.12,导出机器上是2.0,导出的ONNX在Java端加载报“Unsupported operator”错,后来重新在同等版本环境导出就好了。导出前还要确认模型是否已经转为半精度或做了剪枝。常规做法是训练完保留best.pt,如果追求CPU推理速度可以尝试导出FP16模型。但是FP16在CPU上的优化收益有限,反而可能在边缘设备上出现精度下降,我的建议是先用FP32导出跑通链路,再考虑FP16。导出的核心命令如下,建议直接写在requirements.txt环境固定版本:python export.py --weights best.pt --include onnx --opset 12 --simplify三个参数背后是有讲究的:--include onnx:只导出ONNX,不要顺手导出TorchScript,后者Java端用不上。--opset 12:ONNX算子集版本。ONNX Runtime对opset 12的支持非常成熟,再高的版本(比如15、17)虽然新特性多,但在某些老版本ONNX Runtime上会遇到算子缺失的问题。低版本opset兼容性最好,除非模型里用到了新算子,否则就用默认的 12 或根据YOLOv5默认值来。--simplify:用onnx-simplifier做模型精简,去掉冗余算子(比如Shape、Gather等无关计算),这一步能让模型体积减少5%~10%,推理速度也能提升一点。导出后务必看一眼模型大小。YOLOv5s大概14MB左右,如果导出后是40MB,多半是导出了整个训练模型,包含优化器状态,需要重新用export.py脚本导出。模型大小直接决定Java端加载时间和内存占用,不能忽略。2.2 导出带NMS和不带NMS的差异YOLOv5的export.py默认导出的ONNX是不带NMS后处理的,模型的输出是原始预测张量,形状为[1, 25200, 85](以COCO 80类为例,25200 = 3个尺度 x (640/8)^2 + (640/16)^2 + (640/32)^2,85 = 5 + 80,即cx, cy, w, h, objectness, class_scores)。这意味着后处理(坐标解码、置信度过滤、NMS去重)必须在Java端手动完成。也有人在导出时就通过--end-to-end参数把NMS集成到ONNX模型里,这样Java端就不用写NMS代码,直接拿最终检测框。我强烈建议不要在导出时集成NMS,原因有三:第一,集成NMS的ONNX模型在不同ONNX Runtime版本上的算子兼容性差异很大,经常出现 “NonMaxSuppression” 算子不支持的情况。第二,内置NMS会把置信度阈值和IoU阈值固化在模型里,现场想调阈值就要重新导出模型,非常不方便。第三,Java端手写NMS并不困难,百来行代码而已,还能顺便把一些业务逻辑(比如按缺陷类型配置阈值)合进去,灵活性高得多。所以最终目标是:导出不带NMS的原始ONNX,Java端自己处理后处理。导出后不要急着写Java,先用Python做一次“对齐验证”,这是整个部署链路中最容易省掉但最不能省的一步:import onnxruntime as ort import numpy as np import cv2 sess = ort.InferenceSession("best.onnx", providers=["CPUExecutionProvider"]) img = cv2.imread("test.jpg") img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (640, 640)) img = img.astype(np.float32) / 255.0 img = img.transpose(2, 0, 1) img = np.expand_dims(img, 0) outputs = sess.run(None, {sess.get_inputs()[0].name: img}) print(outputs[0].shape)这一步的意义在于:先确认模型在Python侧输出是正常的,再对比Java端输出。如果Python侧结果都是乱的,说明模型本身有问题,Java端写得再好也没用。对齐验证时,固定一张测试图,把Python侧的结果(坐标、类别、置信度)记下来,后面Java端部署完用同一张图跑,结果应该完全一致。3. Java环境搭建与图片预处理:最容易出问题的环节3.1 Maven依赖和模型目录规划Java工程引入ONNX Runtime非常简单,Maven里加一行依赖:<dependency> <groupId>com.microsoft.onnxruntime</groupId> <artifactId>onnxruntime</artifactId> <version>1.16.3</version> </dependency>版本我建议用当前最新的稳定版,不要用太老的版本,因为ONNX Runtime每个版本对算子支持和CPU性能优化都在持续改进。实际项目里我用的1.16.3,稳定跑了很久。模型文件放哪里也值得提前约定。不要直接把.onnx文件塞进Spring Boot的resources目录里打包进Jar,因为产线环境经常需要热更新模型(重新训练后替换权重),每次更新都要重新打Jar再发布太痛苦了。正确做法是放在外部目录,通过配置文件指定路径:detect: model-path: /data/models/best.onnx input-size: 640 conf-threshold: 0.25 iou-threshold: 0.45启动时通过@Value注入路径,服务初始化时加载模型。这样后续更新模型只需覆盖文件,然后调用一个/admin/reload接口触发重新加载即可,不需要重启服务。3.2 图像预处理的每个步骤都必须和Python端完全一致前面给Python对齐用的预处理是:BGR转RGB→resize到640x640→归一化到0~1→NCHW排列。Java端要做到一模一样,但实际写起来有两个隐藏的坑。第一个坑是resize方式。YOLOv5默认的letterbox是等比缩放加灰度填充(pad),不是直接把图拉伸到640x640。如果直接用BufferedImage.getScaledInstance(640, 640)把图硬拽变形,检测精度会明显下降,因为目标的长宽比被破坏了。实际上YOLOv5 export后的模型期望的输入是“已letterbox”的图像,原因很简单,模型训练时也是这么做的。所以在Java端需要自己实现letterbox逻辑:public static int[] letterbox(BufferedImage src, int targetSize) { int srcW = src.getWidth(); int srcH = src.getHeight(); double scale = Math.min((double) targetSize / srcW, (double) targetSize / srcH); int newW = (int) Math.round(srcW * scale); int newH = (int) Math.round(srcH * scale); int padX = (targetSize - newW) / 2; int padY = (targetSize - newH) / 2; 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 canvas = new BufferedImage(targetSize, targetSize, BufferedImage.TYPE_3BYTE_BGR); Graphics2D g2 = canvas.createGraphics(); g2.setColor(Color.GRAY); // YOLOv5默认用灰值114填充 g2.fillRect(0, 0, targetSize, targetSize); g2.drawImage(resized, padX, padY, null); g2.dispose(); return new int[]{padX, padY, scale}; }注意YOLOv5源码letterbox的填充色是(114, 114, 114),不是纯黑也不是纯白。这个细节必须对齐,否则图像分区填充区域的像素值会影响输入分布,降低检测精度。第二个坑是内存中的像素排列。Java的BufferedImage像素存储是CHW之外的交错格式,说白了就是每个像素的B、G、R三个字节连续排列。而ONNX Runtime需要的输入是NCHW,也就是先所有通道的Row0,再所有通道的Row1。转换时要手动把像素按通道拆开重排:float[] input = new float[3 * targetSize * targetSize]; for (int y = 0; y < targetSize; y++) { for (int x = 0; x < targetSize; x++) { int rgb = canvas.getRGB(x, y); int b = rgb & 0xFF; int g = (rgb >> 8) & 0xFF; int r = (rgb >> 16) & 0xFF; int idx = y * targetSize + x; input[idx] = r / 255.0f; input[idx + targetSize * targetSize] = g / 255.0f; input[idx + 2 * targetSize * targetSize] = b / 255.0f; } }getRGB每像素调用在性能上是灾难性的,一张640x640的图要执行40万次,还要做位运算,实测下来预处理耗时能到300~500ms,完全不可接受。优化方案是直接拿像素数组批量读取:int[] pixels = canvas.getRGB(0, 0, targetSize, targetSize, null, 0, targetSize); for (int i = 0; i < pixels.length; i++) { int rgb = pixels[i]; int b = rgb & 0xFF; int g = (rgb >> 8) & 0xFF; int r = (rgb >> 16) & 0xFF; float val = (r & 0xFF) / 255.0f; input[i] = val; input[i + targetSize * targetSize] = g / 255.0f; input[i + 2 * targetSize * targetSize] = b / 255.0f; }这样一次getRGB拷贝整个像素数组,40万次位运算在毫秒级完成。实测预处理+转换总耗时可以压到30ms以内。关于BGR和RGB的顺序,如果模型训练时走的是标准YOLOv5默认方式,图片是先转RGB再进入网络的,那Java端也要转。但要注意:如果训练时喂的本来就是OpenCV读出来的BGR图,那导出后模型输入的通道顺序其实是BGR。这必须回到训练脚本里去确认。我当时就是没确认清楚,Java端转了RGB之后检测精度比Python侧低了一截,检查了一天发现训练脚本里压根没有cv2.cvtColor这一步,模型期望的是BGR输入。所以提前记住:完全以训练时的数据管线为准,不是所有YOLOv5模型都要转RGB。3.3 模型加载和Session配置ONNX Runtime在Java端的使用方式如下:import ai.onnxruntime.*; OrtEnvironment env = OrtEnvironment.getEnvironment(); OrtSession.SessionOptions options = new OrtSession.SessionOptions(); options.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); options.setIntraOpNumThreads(4); OrtSession session = env.createSession(modelPath, options);这个session是线程安全的,多个线程可以同时调用session.run()做推理,不需要加锁也不需要为每个线程单独创建session。这是ONNX Runtime设计好的并发模型,不要每来一张图就重新createSession,session创建过程中要解析模型结构、申请内存,非常耗时。内部线程数setIntraOpNumThreads需要根据CPU核心数调整。不是线程越多越好,ONNX Runtime内部算子并行度太高会导致线程频繁切换,反而变慢。在8核工控机上实测,4~6个内部线程综合效果最好;在只分配了2核的容器里就把线程数设成2。如果服务本身还承担了其他业务,建议先用4核跑,观察CPU占用再慢慢调。GPU模式下还需要设置CUDA provider,代码会复杂一点。如果产线现场有显卡,配置方式是在创建session时添加CUDA provider:options.addCUDA(0);同时把依赖里增加onnxruntime-gpu替代原来的onnxruntime。但GPU不是必须的,我们项目最后反而是CPU方案更省心:现场工控机显卡驱动成了新的故障点,CPU推理跑下来也能满足节拍要求。部署环境的稳定性优先于推理速度。4. 核心推理与后处理:坐标解码和NMS4.1 推理调用ONNX Runtime的输入是一个OnnxTensor,可以直接把上面算好的float[]包进去:long[] shape = new long[]{1, 3, 640, 640}; OnnxTensor inputTensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(inputData), shape); Map<String, OnnxTensor> inputs = new HashMap<>(); inputs.put(session.getInputNames().iterator().next(), inputTensor); try (OnnxTensor output = (OnnxTensor) session.run(inputs).get(0)) { float[][][] result = (float[][][]) output.getValue(); // result[0] 形状是 [1, 25200, 85] // 每一行是 cx, cy, w, h, objectness, class_scores... }注意session.getInputNames()返回的是Set,虽然只有一个输入,但要用迭代器拿到,不要硬编码字符串,避免模型重导出后输入节点名变化导致报错。4.2 输出张量解析:从中心坐标到边界框ONNX输出的是YOLOv5原始格式:每个预测框用(cx, cy, w, h)表示中心点坐标和宽高,后面是objectness(该框内是否有目标的置信度)和各类别得分。要先把它转换成(x1, y1, x2, y2)的左上右下坐标:float cx = row[0], cy = row[1], w = row[2], h = row[3]; float objScore = row[4]; float x1 = cx - w / 2; float y1 = cy - h / 2; float x2 = cx + w / 2; float y2 = cy + h / 2; // 得分 = objectness * classScore float maxClassScore = 0; int maxClassId = -1; for (int c = 5; c < row.length; c++) { float clsScore = row[c]; float score = objScore * clsScore; if (score > maxClassScore) { maxClassScore = score; maxClassId = c - 5; } }这里用的是“目标置信度 x 类别置信度”的乘积作为最终得分,这是YOLOv5的输出逻辑。只取classScore不问objectness是不对的,会在背景区域产生大量虚警。另外,因为输入做了letterbox,检测出来的坐标是相对于640x640输入图的。要还原到原始图像坐标,需要把前面letterbox记录的padX, padY, scale反向换算:float origX1 = (x1 - padX) / scale; float origY1 = (y1 - padY) / scale; float origX2 = (x2 - padX) / scale; float origY2 = (y2 - padY) / scale; // 需要做边界裁剪,防止坐标越界: // origX1 = Math.max(0, origX1); ...没有做反向映射就直接把框画到原图上,位置会整体偏移。训练和推理之间的每一个对齐细节,最后都体现在这一个坐标偏移上。4.3 NMS的Java实现之后需要对过滤后的框做NMS去重。同一缺陷可能会被多个尺度的anchor同时检出,NMS保留置信度最高、抑制IoU过高的重叠框。NMS的Java实现不复杂,但有几个细节要注意。先用置信度阈值过滤掉低分框:float confThreshold = 0.25f; // 从配置读取 List<Detection> detections = new ArrayList<>(); for (float[] row : result[0]) { float score = ...; // 上面算出的最终得分 if (score >= confThreshold) { detections.add(new Detection(x1, y1, x2, y2, score, classId)); } }然后按得分从高到低排序,逐个取框,把和当前框IoU超过阈值的框都删掉:detections.sort((a, b) -> Float.compare(b.score, a.score)); List<Detection> picked = new ArrayList<>(); while (!detections.isEmpty()) { Detection best = detections.remove(0); picked.add(best); Iterator<Detection> iter = detections.iterator(); while (iter.hasNext()) { Detection d = iter.next(); if (calcIoU(best, d) > iouThreshold) { iter.remove(); } } }IoU计算是矩形面积比的经典公式,注意并集面积要减去交集面积,避免重叠部分被重复计算。IoU阈值一般取0.45~0.5,过低会导致同一个目标被拆成多个框,过高则无法有效去重,现场可以调。不过NMS有个常见问题:当两个不同类别的缺陷目标高度重叠时,会被NMS合并掉一个。比如包装袋上一张污渍标签恰好叠在印刷字符错位上,如果两个目标框IoU超过阈值,得分低的那个会被抑制。解决方法是按类别分组做NMS:同一类别之间做NMS,不同类别不抑制。这样不会漏掉叠加缺陷。5. Spring Boot API封装与工程化5.1 接口设计和返回结构API的思路是:接收前端/上位机传来的图片,返回检测到的缺陷列表。接口路径建议设计成两个,一个给产线实时检测用,另一个给算法调试用:POST /api/detect:传图片二进制流,返回缺陷检测结果GET /api/model/info:返回当前模型信息、各类别名称、阈值配置,方便前端做展示返回结构要考虑到调用方的消费能力。产线控制系统只要知道“这包有没有问题,有问题就触发剔除”,所以JSON不要设计太复杂:{ "code": 0, "message": "success", "data": { "defectCount": 2, "defects": [ { "type": "wrinkle", "typeName": "封口皱褶", "confidence": 0.93, "x1": 112.5, "y1": 340.2, "x2": 178.3, "y2": 402.9 } ], "elapsedMs": 96 } }elapsedMs这个字段一定要返回,现场调试时根据它判断系统的性能余量。我那时就是靠这个字段发现某台工控机CPU占用过高、推理时长翻倍的问题。5.2 异步调用与线程池配置产线调用方(比如PLC或上位机)的请求特征是:高频率、单张图片数据量不大、对响应时间敏感。如果Spring Boot默认的Tomcat线程池来处理,每个请求占用一个线程做推理,那么顶层配置的线程数就是系统的最大并发处理能力。推理属于CPU密集操作。8核机器上,如果线程数设为8,每个线程都在抢CPU时间片,上下文切换开销很大。我自己的经验是:CPU密集型推理服务的线程数 = CPU核心数或核心数-1,预留一个核心给GC和网络IO处理。可以这样配置:@Bean("detectExecutor") public ThreadPoolTaskExecutor detectExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(4); executor.setQueueCapacity(200); executor.setThreadNamePrefix("detect-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return executor; }CallerRunsPolicy被拒绝的任务由调用线程执行,而不是直接抛异常。在产线并发高峰时,这相当于一种保险机制,不会因为线程池满了就丢弃检测请求,代价是调用方线程会被占用在推理上,响应变慢。对于产线系统,丢一个检测请求意味着漏检一个产品,远比变慢严重。接口内部的处理逻辑应该是:请求进来 → 图片解码 → 预处理 → 提交给线程池推理 → 同步等待结果。用CompletableFuture做异步编排:@PostMapping("/detect") public DetectResponse detect(@RequestParam("file") MultipartFile file) { // 提交推理任务到线程池 CompletableFuture<DetectResult> future = CompletableFuture.supplyAsync( () -> detectService.detectImage(file), detectExecutor); DetectResult result = future.get(5, TimeUnit.SECONDS); // 超时保护 return DetectResponse.success(result); }超时保护非常重要。万一模型推理卡死(ONNX Runtime偶发的线程饥饿问题),不能让产线请求无限等下去。5秒超时是在产线允许范围内(单包节拍1秒,但如果整体卡住给5秒重试窗口是合理的)的兜底方案。5.3 图片解码和跨域问题MultipartFile是个松耦合接口,但实际操作时要先把文件字节转成BufferedImage。这一步常见问题是,相机上传的图片格式可能不止JPEG,有些工业相机直接输出BMP甚至裸RGB数据。我建议服务端统一用ImageIO.read(new ByteArrayInputStream(bytes)),它对JPEG、PNG、BMP都支持。但如果遇到ImageIO不支持的格式,就要考虑加上OpenCV的Java绑定。跨域配置是另一个现场常遇到的问题。前端监控大屏页面和检测服务往往是分开部署的,直接调用API会触发CORS拦截。在配置类里加一个WebMvcConfigurer统一定义放行规则:@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST") .maxAge(3600); } }这里有个细节:allowedOrigins("*")在Spring Boot新版里不允许和allowCredentials(true)同时使用,会启动报错。产线检测接口不需要cookie,所以直接不要开credentials,或者用allowedOriginPatterns("*")前者版本兼容写法。6. 生产环境踩坑记录:问题排查速查表6.1 高频问题整理按下表整理是我调试期内遇到的高频问题,这些问题网上零散也有,但集中成表方便在产线现场快速对照:问题现象可能原因排查思路与解决Java推理结果和Python不一致预处理链路不一致(resize方式、通道顺序、归一化)固定同一张测试图,分别打点比对预处理结果坐标偏移但能框出目标letterbox的padX/padY/scale未反向映射检查后处理反向坐标计算的逻辑session.run()抛OOM并发请求过多,tensor内存未释放使用try-with-resources管理OnnxTensor,检查每个请求是否关闭了tensor检测结果时有时无图片格式不统一,解码失败后降级处理增加格式校验,兼容工业相机裸输出推理耗时突然变长线程池被阻塞,请求排队等待监控线程池活跃数和队列长度,排查是否有长任务耗尽线程加载模型时提示unsupported operator训练/导出环境opset版本过高或过低固定opset=12重新导出;更新ONNX Runtime版本6.2 隐蔽故障实录:ONNX Runtime的Tensor释放问题ONNX Runtime的Java API有个隐蔽的坑:**OrtSession.run()返回的结果必须在用完后关闭,否则DirectByteBuffer的内存不会被释放**。Java堆内存外还有一块直接内存,JVM的Xmx参数管不到它,如果服务跑几天内存持续上涨,最终会OutOfMemory。正确做法是使用try-with-resources:try (OrtSession.Result result = session.run(inputs)) { OnnxTensor output = (OnnxTensor) result.get(0); // 处理结果... }同时在每个请求结束时也要把输入tensor关闭:try (OnnxTensor inputTensor = OnnxTensor.createTensor(...)) { // 推理逻辑 }这个坑在本地压测短时间看不出来,跑到第几百个小时就暴露了。我们当时线上服务跑了3天内存翻倍,排查了很久最后发现是session.run返回值没有关闭导致的。6.3 模型热更新机制产线系统不能频繁重启。模型重新训练后,在服务运行状态下把权重文件替换掉,再触发一个reload接口即可。实现方式是维护一个volatile OrtSession引用,reload时创建新session再替换旧引用:private volatile OrtSession session; public synchronized void reloadModel(String path) throws OrtException { OrtSession newSession = env.createSession(path, options); OrtSession oldSession = this.session; this.session = newSession; if (oldSession != null) { oldSession.close(); } }用synchronized保证reload不会和推理请求并发创建/关闭session。旧session等新session加载完成后再关闭,避免出现“正在推理时模型被卸载”的竞态问题。这个机制在现场帮了大忙,算法那边迭代了一版新模型,直接在中午换班间隙调接口热切换,完全没影响产线。最后的实践经验小结这套方案从模型导出到Spring Boot API对接,核心难点其实不在模型本身,而在各种链路对齐的细节上:预处理顺序对不对、letterbox有没有做、坐标有没有反向换算、tensor有没有释放。把这些对齐了,整套系统跑起来非常稳。我在这个项目里最后沉淀的几条经验是:第一,部署之前一定要做Python和Java的对齐验证,这能省掉后面90%的排查时间;第二,所有阈值参数全部配置化,不要在代码里写死,现场调优时你会感谢这个决定;第三,ONNX Runtime的session是线程安全的,但tensor不是,该释放的一定要释放。如果你正在做类似的项目,希望这篇文章能让你少走几段弯路。