简介面向以 Java 为主技术栈、需在业务系统中接入 YOLO 视频目标检测能力的开发人员这份资源提供了一套 Java 与 Python 混合调用 ONNX 模型的完整参考实现可解决跨语言推理集成中的衔接与调试难题。方案覆盖 YOLOv5、YOLOv7、YOLOv8 三款主流模型经 JNI 桥接 Java 应用与 Python 脚本并给出从 RTSP/RTMP 视频流解析、帧格式转换、resize 归一化填充等预处理到置信度过滤、绘制识别框的完整后处理链路。包体共 32 个文件、约 144.73MB含 12 个 Java 源码、Maven 配置主工程、4 个 ONNX 权重及 2 个 dll 本地调用库另配 7 张结构图、演示视频与 README 便于快速理解工程组织。目前已有 506 人学习适合中高级开发者评估跨语言方案、查看前后处理细节或直接改造集成进自身项目尤其适用于实时视频流的深度学习检测场景。1. 为什么 Java 项目调 Python 跑 YOLO视频检测落地的现实选择视频目标检测要落到 Java 业务系统里我一向不推荐在 Java 里从零训练或自己实现检测网络。原因很现实YOLO 系列的训练、调参、数据增强都在 Python 生态里而 Java 这边能拿到的成熟推理方案要么性能残废要么维护成本高。更常见的尴尬是——算法同学交付的是 .pt 权重Java 工程师拿到手里往往还得再要一份导出好的模型。这套方案的价值在于用 ONNX 作为中间表示算法侧用 Python 导出Java 侧负责视频解码、帧调度和业务落库模型推理交给 Python ONNX Runtime。这不是架构倒退而是把计算密集点和业务密集点拆开两边各干各擅长的活。适合谁手上有 YOLOv5/v7/v8 权重、业务系统是 Java、且不想重写训练管线的人这个方案就是当前投入产出比最高的过渡路线。2. 模型导出 ONNX从 PyTorch 权重到跨语言部署的第一步2.1 为什么先导出 ONNX而不是在 Java 里直接调 PyTorch很多人一开始觉得反正 Java 端要通过 Python 调模型那不如直接把 PyTorch 环境装到生产机器上用 Python 加载 .pt 权重推理省掉导出这一步。这个想法在开发环境跑得通一上生产就出问题PyTorch 依赖体积大、CUDA 版本和 Python 版本要精确匹配、每次部署都要拉一堆编译缓存。ONNX 则是一个静态计算图ONNX Runtime 在 CPU 上占用小GPU 上也只是多一个 CUDA 依赖而且 Java 侧有官方绑定的 ONNX Runtime 库。这意味着即使你不用 Python 子进程也可以用 Java 直接推理。我一般建议团队把导出 ONNX 作为算法交付的一部分。因为 ONNX 把模型固化成推理图训练时的动态控制流被展开或简化能暴露很多只在导出时才出现的算子兼容问题。早踩坑的成本远低于生产环境再踩。另一个隐性收益是模型体积ONNX 经常比 .pt 小 20% 到 40%这对内网传输和镜像构建都友好。2.2 YOLOv5 / YOLOv7 / YOLOv8 的导出命令与最小验证三个版本现在都有官方导出脚本但命令参数不一样。导出时一个常见误区是直接用训练完的模型跑导出其实应该先确认训练时是否开启了端到端或 NMS 集成这直接决定后续在 Java 端解码的复杂度。下面给出三个版本的最小导出命令。# YOLOv5静态图导出opset 用 12 python export.py --weights yolov5s.pt --include onnx --opset 12 --simplify# YOLOv7带网格解码输出保留动态输入 python export.py --weights yolov7.pt --include onnx --simplify --grid --dynamic# YOLOv8官方 CLI 导出 yolo export modelyolov8s.pt formatonnx opset12 simplifyTrue dynamicTrue参数说明--simplify用 ONNX Simplifier 做常量折叠和冗余节点清理导出文件更小推理更快opset 12对这三个版本都兼容低于 11 会触发某些算子缺失的报错--grid是 YOLOv7 导出时保证输出张量带解码网格的参数不加这个参数输出会变成无 anchor 的原始预测后处理写起来非常痛苦dynamicTrue保留动态 batch 和动态宽高视频推理时如果分辨率固定建议关掉它换静态推理速度能提升不少。导出后必须验证一次不要直接丢给 Java 端。最小验证脚本很简单构造一个随机输入跑一遍看输出 shapeimport onnxruntime as ort import numpy as np # 构造 1x3x640x640 的随机输入模拟一帧图 dummy np.random.rand(1, 3, 640, 640).astype(np.float32) session ort.InferenceSession(yolov8s.onnx, providers[CPUExecutionProvider]) outputs session.run(None, {session.get_inputs()[0].name: dummy}) print(输出张量数量, len(outputs)) for out in outputs: print(shape:, out.shape)这个脚本只验证一件事模型能跑通、输出形状和预期一致。YOLOv5 和 YOLOv8 的导出输出通常是[1, 84, 8400]YOLOv7 的常规导出是[1, 25200, 85]。看到这两种布局的差异先不要慌后面的解码都按模型对应格式来写绝不混用。2.3 导出输出张量结构决定了后面所有解码逻辑输出布局的差异不是小事。YOLOv5/v8 的[1, 84, 8400]中84 是 4 个坐标加 80 个类别分数8400 是三个尺度特征图的候选框总数YOLOv7 的[1, 25200, 85]中25200 是候选框总数85 是 5 个坐标扩展加 80 个类别。如果你把 v7 的张量当成 v8 来解析第一行坐标就错位检测框全部乱飘。我的建议后处理解码坐标还原加 NMS放在 Python 端做Java 只接收最终检测框。理由有二一是 YOLO 系列候选框数量大把原始张量传给 Java 再解码网络传输和 Java 侧循环都要多耗时间二是 Python 生态里有现成的 NMS 实现自己写容易在边界条件上翻车。这个决定在避坑章节还会展开。提示输出张量布局要写进模型交付文档里Java 端不要硬编码模型版本否则换版本时解码逻辑很容易忘了同步。3. Java 侧调用落地ONNX Runtime 直推与 Python 子进程两条路线3.1 路线对比直推 vs 子进程什么场景选哪个Java 调 ONNX 模型有两条主流路线。第一条是 ONNX Runtime 的 Java APIJava 进程内直接加载 .onnx 文件并推理和 Python 端无进程通信延迟最低适合模型固定、后处理简单的场景。第二条是 Python 子进程方案Java 通过 ProcessBuilder 拉起一个常驻 Python 推理服务用 TCP 或 HTTP 通信。这条路线让算法同学可以独立迭代新模型不经过 Java 编译就能上线。我一般按两个条件判断。如果模型为静态输入、分辨率固定、单机 GPU 推理且团队里没有常驻 Python 服务的运维经验选 ONNX Runtime Java API 更稳。如果模型要频繁热更新、算法同学希望快速交付新权重、或者你需要用 Python 侧复杂的后处理逻辑选子进程方案。子进程方案的额外收益是Java 崩溃或重启不打断推理服务Python 侧升级只需单独重启子进程。坏处是多一层网络通信单帧延迟会多个 1 到 3 毫秒对视频检测来说完全可接受。3.2 用 ProcessBuilder 拉起 Python 推理服务最小可运行代码进程拉起这种方式我踩过的坑主要在两处一是子进程输出重定向不读取 stdout 和 stderr 会导致缓冲区满、进程卡死二是启动命令里 python 解释器路径写死换一台机器就起不来。下面给一个最小可运行的 Java 启动代码。import java.io.BufferedReader; import java.io.InputStreamReader; import java.util.concurrent.TimeUnit; public class InferenceProcess { private Process process; private BufferedReader reader; public void start() throws Exception { // infer_service.py 是独立 Python 脚本启动时监听本机 9000 端口 ProcessBuilder pb new ProcessBuilder( python, infer_service.py, --port, 9000, --model, yolov8s.onnx ); pb.redirectErrorStream(true); // 合并 stderr 到 stdout避免读两个流造成死锁 process pb.start(); // 立刻开启一个线程持续消费 stdout防止输出管道写满阻塞 reader new BufferedReader(new InputStreamReader(process.getInputStream())); Thread logThread new Thread(() - { String line; try { while ((line reader.readLine()) ! null) { System.out.println([python] line); } } catch (Exception e) { // 进程退出或流关闭时的正常路径 } }); logThread.setDaemon(true); logThread.start(); // 等待 2 秒确认 Python 进程还活着再做后续请求 process.waitFor(2, TimeUnit.SECONDS); if (!process.isAlive()) { throw new IllegalStateException(Python 推理进程启动失败); } } }这段逻辑的要点redirectErrorStream(true)是保命选项两个流分别读一旦遇到大量日志就会互相阻塞waitFor(2, TimeUnit.SECONDS)只做了存活检查更可靠的健康检查应该再向服务发一个 ping 请求。生产环境我建议把 Python 进程托管给系统守护进程而不是 Java 拉起但开发期和联调期这个写法足够用。Python 端最小服务用标准库写避免一上来就上 Flask减少依赖纠纷import argparse import json import socket def handle_connection(conn, inferer): # 一次请求处理一帧读取 4 字节长度 JSON 体 length int.from_bytes(conn.recv(4), byteorderbig) payload b while len(payload) length: chunk conn.recv(length - len(payload)) if not chunk: break payload chunk req json.loads(payload.decode(utf-8)) frame req[frame] # base64 编码的图像 result inferer.infer(frame) # 返回检测框列表 resp json.dumps(result).encode(utf-8) conn.sendall(len(resp).to_bytes(4, byteorderbig) resp) conn.close() def main(): parser argparse.ArgumentParser() parser.add_argument(--port, typeint, default9000) parser.add_argument(--model, typestr, requiredTrue) args parser.parse_args() inferer YOLOInfer(args.model) # 封装 onnxruntime 的推理类 server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.bind((127.0.0.1, args.port)) server.listen(8) while True: conn, _ server.accept() handle_connection(conn, inferer) if __name__ __main__: main()这里的设计取舍用 TCP 而不是 HTTP省掉 JSON 之外的 HTTP 头开销视频场景帧率高时吞吐差异明显。base64 编码虽然让文本膨胀但换来 Python 端不需要额外处理图像解码开发方便。如果帧率要求更高可以改成 raw bytes 加宽高信息传输体积能压到 base64 的四分之一左右。3.3 通信协议选型JSON 行协议还是 Protobuf 二进制当检测结果数据量大、单路视频每秒出 30 帧结果时JSON 解析开销会变得可感知。我的经验是字段少于 10 个、结果结构固定的场景先用 JSON协议演进期不要过早二进制化。等到真的压测发现解析瓶颈再切换到 Protobuf。因为 JSON 换 Protobuf 涉及 Java 端、Python 端、日志工具三方改动成本远不是替换一个序列化库能覆盖的。通信协议一旦黑盒化后续排查问题的难度会翻倍。Java 端接收结果的代码我通常定义这样一个结构public class DetectionResult { private float x1, y1, x2, y2; // 左上角和右下角原图坐标 private float confidence; private int classId; public static DetectionResult fromJson(com.google.gson.JsonObject obj) { DetectionResult r new DetectionResult(); r.x1 obj.get(x1).getAsFloat(); r.y1 obj.get(y1).getAsFloat(); r.x2 obj.get(x2).getAsFloat(); r.y2 obj.get(y2).getAsFloat(); r.confidence obj.get(confidence).getAsFloat(); r.classId obj.get(classId).getAsInt(); return r; } }这个结构同时服务于网络解析和视频标注。注意点坐标统一用 float 传到 Java不要用 doubleYOLO 输出的坐标精度 float 已经足够double 会让每帧数据量多出一倍。类别 id 保持 int显示名称留给 Java 端查表Python 端不传字符串减少传输体积也避免编码问题。字段命名建议全小写加数字后缀Java 端解析时可以少写很多映射代码。4. 视频帧处理与结果回传把 Java/Python 数据管道做扎实4.1 视频解码放在哪一端Java 端解码是更稳的默认选项视频源可能是 mp4 文件、RTSP 流、或摄像头推流。解码这种事放在 Java 端做更稳原因是我踩过 Python 端解码 RTSP 的坑OpenCV 的 VideoCapture 在个别网络流上会频繁丢帧、重连逻辑混乱而 Java 端已有的存储、转码管线可以共享解码能力。用 Java 自带 ImageIO 或 javacv 抽取帧抽出来的是 BufferedImage再转成 byte[] 发给 Python。Java 侧抽帧要关心的事之一是转 base64 其实挺浪费 CPU但为了少写一个依赖我建议在开发期先用 base64性能压测时再换成裸字节。一个折中方案是 Java 端直接把帧压缩成 JPEG体积小很多Python 端用 cv2.imdecode 解码。下面代码演示了如何把帧转成 JPEG 字节流import javax.imageio.ImageIO; import java.awt.image.BufferedImage; import java.io.ByteArrayOutputStream; public class FrameSender { public byte[] encodeFrame(BufferedImage frame) throws Exception { // 压缩成 JPEG 能明显降低传输体积质量 0.85 足够检测使用 ByteArrayOutputStream baos new ByteArrayOutputStream(); ImageIO.write(frame, jpg, baos); return baos.toByteArray(); } }参数说明JPEG 质量 0.85 是一个经验值低于 0.8 会影响小目标检测召回率高于 0.9 收益趋缓、体积涨得快。如果检测目标是行人这类中等目标0.75 也能跑。注意检测结果回传坐标是原图坐标所以压缩带来的像素变化不影响框的准确性除非压缩太狠导致模型本身没检出。4.2 帧采样与缓冲队列跳帧策略和积压控制实时视频流的处理瓶颈通常在推理耗时而不是网络传输。如果模型推理一帧耗时 50ms理论帧率只有 20fps此时把输入流的 30fps 全送推理队列必然堆积。两个策略配合使用第一固定跳帧比如每 3 帧取 1 帧送检第二动态降级当检测队列积压超过阈值时自动把跳帧从 3 调到 5。Java 端很容易实现一个带监控的队列import java.util.concurrent.LinkedBlockingQueue; public class FrameQueue { private final LinkedBlockingQueueBufferedImage queue new LinkedBlockingQueue(16); private int frameCount 0; private int jumpStep 3; public void submit(BufferedImage frame) throws InterruptedException { frameCount; if (frameCount % jumpStep ! 0) { return; // 跳帧 } // offer 不阻塞满了就丢弃最老的一帧保证实时性优先 if (queue.remainingCapacity() 0) { queue.offer(frame); } else { queue.poll(); // 丢弃最旧的帧 queue.offer(frame); } } public void adjustJumpStep(int queueSize) { if (queueSize 10) { jumpStep 5; // 积压严重降频 } else { jumpStep 3; } } }这段设计的核心是宁可丢帧不可积压。对实时视频监控场景延迟比帧率更重要迟到的检测结果没有价值。跳帧后检测目标在画面里快速移动时可能漏检这就是实时检测系统的固有取舍你要接受这个上限。对离线视频分析任务不要用这个队列应该全部帧送检并保留时间戳。注意离线批量分析时使用跳帧队列会直接漏掉目标设计时要分清楚在线和离线两套管线。4.3 检测结果回传的数据结构坐标、类别与置信度怎么组织假设 Python 端输出如下 JSON 列表Java 端要能稳定解析并关联到视频帧。一个容易翻车的细节是不返回检测到的帧号或时间戳Java 端无法对齐结果和画面。所以回传结构里必须带 frameId 或 timestampMs。下面给出推荐结构示例[ { frameId: 1024, timestampMs: 34120, x1: 112.5, y1: 88.3, x2: 231.4, y2: 312.8, confidence: 0.92, classId: 0 } ]Java 端解析时约定frameId 必须匹配发送时携带的 ID不匹配直接丢弃并告警这可能是跳帧逻辑和异步发送之间出现了乱序。我要强调的是结果回传的坐标属性要写清楚是原图坐标、letterbox 前坐标还是模型输入坐标。这个字段定义能救你后续所有排查。实践里我用一个约定Java 发给 Python 时附上原图宽高和 letterbox 参数Python 回传时直接还原成原图坐标Java 不用再做任何换算。换算逻辑放 Python是因为坐标还原跟模型预处理耦合太紧放在一侧才能保证不两头改。5. 从 YOLOv5 迁到 YOLOv7 / YOLOv8 的避坑手册预处理、坐标与 NMS 三处翻车高发区5.1 letterbox 预处理不匹配检测框整体偏移的元凶现象Java 端和 Python 端分别处理视频帧后Java 直接调用 Python 服务检测框整体向右下方偏移且偏移量和图像尺寸相关。原因YOLO 系列标准预处理是 letterbox先按比例缩放再在边缘填充灰色像素。调用方如果不做 letterbox而是直接 resize 成 640x640图像长宽比就变了目标位置失真检测框就会系统性偏移。YOLOv8 推理默认仍然按长边等比缩放并居中填充导出 ONNX 后模型输入仍期望以 640x640 的等比缩放图为准。解决Python 推理服务统一实现 letterbox并把填充边距回传。不要试图在 Java 端复刻预处理因为源码版本更新后填充颜色值、缩放策略都可能变。def letterbox(img, new_shape640, color(114, 114, 114)): h, w img.shape[:2] r min(new_shape / h, new_shape / w) new_unpad (int(round(w * r)), int(round(h * r))) dw (new_shape - new_unpad[0]) / 2 dh (new_shape - new_unpad[1]) / 2 # 缩放 填充返回 img, (dw, dh), r ...这段代码是标准写法关键点是 r 和 (dw, dh) 必须和导出时的训练配置保持一致。遇到检测框偏移时第一排查项就是这个函数和训练仓库里的版本差异。我在项目里就让算法同学把训练代码里的 letterbox 原样复制到推理服务禁止任何人优化这个函数。5.2 坐标还原的边界填充边距漏减导致框错位现象满屏检测一切正常但目标靠近图像边缘时框的位置偏出目标本身越靠近边缘偏差越大。原因模型输出坐标是相对于 letterbox 后输入图的要还原到原图必须减去左边距和上边距再除以缩放比例。常见错误是只除以比例没还原 padding或把 dw 和 dh 顺序搞反。解决用上一步 letterbox 返回的 (dw, dh, r) 做还原并且用浮点数计算不要提前整数化。x1 (pred_x1 - dw) / r y1 (pred_y1 - dh) / r x2 (pred_x2 - dw) / r y2 (pred_y2 - dh) / r这个换算一句话的事但我在代码评审里见过三次以上搞反方向。建议写成一个专门函数并附带单元测试用一张固定分辨率图像验证人为在 (100,100) 处放一个已知目标确认还原坐标误差小于 1 像素。5.3 NMS 到底在哪端做Java 写 NMS 的血泪经验现象Java 端直接把模型原始输出画框结果画面中同一个目标框了三四个矩形。原因ONNX 导出时如果不带 NMS模型输出包含大量重叠候选框。很多初版实现为了快把后处理里最简单的阈值过滤做了没做 NMS导致重复框。解决NMS 必须做但放在 Python 端做。我在一个项目里试过用 Java 复刻 NMS跟 Python 侧精度能对上的概率很低主要是置信度阈值和 IoU 阈值两个参数以及坐标排序方式只要差一点输出框数量就不一致排查成本极高。Python 原生实现用两重循环就够检测框数量不超过 300 时性能可接受。def nms(boxes, scores, iou_threshold0.45): indices [] order scores.argsort()[::-1] while order.size 0: i order[0] indices.append(i) if order.size 1: break rest order[1:] iou compute_iou(boxes[i], boxes[rest]) keep np.where(iou iou_threshold)[0] order rest[keep] return indices参数说明iou_threshold 0.45 是目标检测的常见默认值视野中目标密集时如人群计数可以降到 0.3目标稀疏时升到 0.5 反而能减少漏检。注意这个参数要和训练时的设置对齐差别太大会出现明显不一致。5.4 YOLOv7 导出前必须检查的两个参数现象YOLOv7 导出的 ONNX 在 Java 端一直推理失败报错提示不支持的算子或者输出 shape 和文档对不上。原因YOLOv7 的导出脚本有两个关键分支是否加入 NMS 模块、是否使用--grid输出网格预测。导出时若漏了--grid输出张量和 YOLOv5/v8 的候选框解码路径完全不同加了--simplify但没加--gridONNX Simplifier 可能把部分网格算子折叠掉导致后续解码数值错乱。解决统一导出命令为python export.py --weights yolov7.pt --include onnx --simplify --grid --dynamic。然后对着输出 shape 验证常规模型输出必须是[1, 25200, 85]如果你看到[1, 3, 42, 640, 640]这类五维张量基本就是--grid没生效。不要只信脚本日志打印一次 shape 再上线。5.5 显存泄漏与线程安全服务跑几天后卡死现象推理服务刚启动时延迟正常跑几小时后检测速度越来越慢最后 GPU 显存打满或 Java 进程崩溃。原因两处常见根源。第一Python 服务的 session 被每次请求重建ONNX Runtime 在 GPU 端创建的 CUDA context 没有释放第二Java 端向 Python 发送帧时没有做连接池复用服务每处理一帧就新建 socket 连接操作系统的句柄耗尽。解决Python 端把 InferenceSession 做成单例全局只创建一次session None def get_session(model_path, use_gpu): global session if session is None: providers [CUDAExecutionProvider, CPUExecutionProvider] if use_gpu else [CPUExecutionProvider] session ort.InferenceSession(model_path, providersproviders) return sessionJava 端用连接池不要每帧新建 Socket。很多框架自带的连接池配置里池大小以 2 到 4 个为宜。压测时如果发现 Java 进程文件描述符持续上升优先怀疑连接未关闭。显存泄漏排查最笨的办法是监控 GPU 显存占用曲线如果只涨不回落基本就是 session 重建或没有复用的 CUDA context。6. 生产落地技巧性能压测、GPU 分配与模型热更新6.1 先压测再调参数延迟画像的三个关键指标这个方案的性能瓶颈到底在哪我建议先用三个指标做压测画像。第一个是推理延迟也就是 Python 服务处理单帧的耗时取模型输入到输出回传的平均值第二个是端到端延迟从 Java 送出帧到收到结果的完整时间包含网络往返和序列化第三个是队列积压率在固定输入帧率下检测队列长度的变化趋势。压测方法很简单准备一段固定十分钟、带人工标注的视频分别在 CPU 和 GPU 下跑同一个模型记录三个版本 YOLO 的延迟分布。我见过很多团队卡在调参靠感觉其实数据一拉出来是解码耗时占比高还是推理耗时占比高一目了然。如果解码占比高考虑 Java 端抽帧用硬件解码如果推理占比高考虑换更小的模型变体或降低输入分辨率。6.2 模型热更新不重启 Java 进程的两条路视频检测系统上线后算法同学会频繁更新模型。如果每次都要重启 Java 进程业务中断不小。两条路可走。第一条是把模型文件路径做成配置中心项Python 服务监听文件变化发现新版本后重新创建 ONNX Runtime session。这种做法最简单切换时有短暂的推理中断但能接受。第二条是做双模型平滑切换Java 端维护一旧一新两个 Python 服务端口旧流量停在旧端口新流量切到新端口稳定后再下线旧端口。适用于 7x24 在线场景代价是 GPU 显存短暂双倍占用。热更新最容易翻车的点新模型和旧模型的输入分辨率不一致而 Java 端的抽帧缩放是固定参数。所以每次热更新必须校验模型的输入 shape和当前的下发配置对比不一致要拦截并告警不能静默放行。模型热更不是替换文件四个字而是一个有版本管理、灰度验证的流程。6.3 一个评测习惯回归脚本保平安最后分享一个我固定保留的习惯每个版本发布前跑一遍同样的回归视频集把检测框数量和位置差值和上一版本对比。目标不是要求完全一致而是要看出差异是否在可解释范围内。ONNX 导出后CPU 推理和 GPU 推理的浮点结果有微小差异如果回归脚本把两个平台结果直接做差数值可能吓你一跳实际框画的完全正常。这个习惯帮我挡过至少两次模型文件传错、参数写错的低级事故。至于踩坑的体会——我在最早一批项目里总想着把全部逻辑用 Java 重写一遍后来发现与其对抗生态不如顺着生态走。ONNX 把模型重量交给 PythonJava 把精力放在业务管线这是这个方案最正确的打开方式。希望帮到你。本文还有配套的精品资源点击获取