
简介面向Java开发者的纯Java视觉智能识别项目基于ONNX Runtime调用YOLO系列模型支持yolov5/v7/v8/v9/v10/v11及PaddlePaddle覆盖检测、分割、旋转框识别等任务并封装了图像预处理与结果后处理可直接集成RTSP/RTMP视频流应用于车牌识别、人脸识别、跌倒检测、打架识别等安防与智能分析场景。项目包含9个ONNX模型、24个Java源码文件和可视化客户端代码从模型加载、推理到结果解析、视频流对接均有完整实现适合需要快速落地目标检测或二次开发的Java工程师。压缩包共105个文件全部资源按代码、模型、素材分类存放含44张PNG图片、12张JPG样例、5个GIF与5个MP4演示视频以及XML配置、DLL运行库等整体约442MB目录结构便于按需查阅。已有216人浏览学习源码与演示素材配合可帮助理解从视频帧预处理到检测结果后处理的完整链路。1. 纯 Java 跑 YOLO 推理AI 视频识别不一定要绕道 Python一个典型的 Java 开发视觉智能识别项目最容易被卡住的不是模型效果而是交付链路算法组把 yolov5 训好、导出成 onnx后端团队却不允许再起一个 Python 服务运维只认 JVM 进程。反直觉的结论是纯 Java 调用 YOLO ONNX 模型完全可行yolov5、yolov7、yolov8 到 yolov11甚至 Paddle 系列转出来的 onnx都能用 ONNX Runtime 的 Java API 加载推理。你只需要把三件事做对模型的输出结构读懂、Java 侧自己写 letterbox 预处理、后处理按模型版本分流。这篇文章就从这三件事展开让 CV 能力嵌进老 Java 服务时不翻车。2. ONNX Runtime 与 YOLO 模型适配选对运行时就成功了一半2.1 为什么是 ONNX Runtime纯 Java 推理的“解释器”角色Java 生态里做视觉推理常见路子有三条套 Python 子进程、用 TensorFlow Java、直接用 ONNX Runtime。套 Python 子进程最省事但要带一个 Python 环境和模型服务视频流一多IPC 和内存都是隐形成本TensorFlow Java 的问题是训练侧导出的是 SavedModel 或 Keras 格式转来转去容易踩算子兼容性的坑。ONNX Runtime 的定位更像是模型解释器算法组从 PyTorch 导出的 YOLO 模型、从 Paddle 转出来的 onnx拿过来 Java 进程直接加载不需要在 Java 和训练框架之间做图格式翻译。具体到代码层你要接触的类非常少OrtEnvironment 负责运行时环境OrtSession 负责加载模型和推理OnnxTensor 负责构造输入OrtSession.Result 接输出。这个设计跟 Python 版 onnxruntime 是对应的所以算法同事给的调试脚本里写了什么参数你能原样翻译到 Java。选型时我一般只看两件事一是模型能不能导出成 onnx二是导出后的算子能不能被当前 onnxruntime 版本解析。YOLO 系列和 Paddle 目标检测模型基本都满足这也是这个方案能落地的根本原因。2.2 YOLOv5 到 YOLOv11、Paddleonnx 输出结构决定后处理怎么写同一份“YOLO 模型”导出成 onnx 后输出张量的形状、顺序和含义是完全不同的。这是 Java 侧最容易翻车的地方因为训练代码里那些封装好的工具函数在 Java 里不存在一切都得按原始张量来算。下面这个表是我每接一个新模型都先核对的内容模型系列常见输出 shape输出含义后处理要点YOLOv5 / YOLOv7(1, 25200, 85) 或 3 个输出节点85 4 坐标 1 目标置信度 80 类用 obj 置信度乘以类别概率得到最终分数YOLOv8 / YOLOv11(1, 84, 8400)84 4 坐标 80 类通道在前没有 obj 置信度直接取类别概率最大值YOLOv9 / YOLOv10视导出配置常见 (1, 84, 8400) 或带多个输出头类似 YOLOv8需要看导出时是否带了 NMS 节点PaddleDetection 系列依赖 paddle2onnx 的导出参数可能带 NMS输出可能是 N 个框的 (N, 6)输入输出名要以 inference.json 为准这里特别要提“coco80 怎么读在 yolo 里”。输出的类别维是 80对应 COCO 数据集的 80 个类别索引 0 是 person索引 1 是 bicycle索引 2 是 car。Java 侧要定义一个静态字符串数组把索引映射成可读标签否则识别出来的是“第几类”而不是“人还是车”。另外注意 YOLOv5 的 85 维里坐标是中心点 cx、cy、w、h而不是左上角加宽高很多从 YOLOv8 代码习惯转过来的人容易把顺序搞错。YOLOv8 之后训练时的 yolo 损失函数已经脱胎于 anchor-free 那一套推理输出也不再依赖预定义 anchor 网格所以 8400 这个数字就是三个尺度特征图上的候选框数量总和。而 YOLOv5 的 25200 是三个尺度乘以每尺度 anchor 数算出来的。Java 侧解码时不要猜直接读 onnx 模型的输出 shape 再分流。常见做法是先打印输出信息然后按情况写两份解码函数一份给 (1, 25200, 85)一份给 (1, 84, 8400)。2.3 动态尺寸与固定尺寸Java 侧预处理要跟着导出配置走算法侧导出 onnx 时经常偷懒把输入维度设成动态的比如 (1, 3, -1, -1)这样 Python 那边怎么传都行。Java 侧一旦拿到这种模型问第一个问题就应该是能不能让算法重导一版固定 640×640 的。动态尺寸在 ONNX Runtime Java API 里能跑但每帧输入的 shape 变化会迫使部分算子重新做图优化而且 nchw 转 blob 的缓冲也要跟着重算性能损耗远大于收益。如果只能拿到动态模型我一般会把输入固定到一个本地常量先读取 session 输入信息得到 TensorInfo再从 getShape() 里看到 -1 的位置然后强制在 createTensor 时传入 (1, 3, 640, 640)。代码里不能依赖 Python 那位同事的 resize 逻辑Java 侧要自己实现 letterbox保证长宽比不变、多余部分用 114 填充。这个细节直接决定识别框位置准不准后面第 3 章会给出完整代码。2.4 推理参数句柄、线程、CPU/GPU 的取舍ONNX Runtime 的 Java API 里SessionOptions 是调优入口。纯 Java 项目默认用 CPU 包我一般这样设置setOptimizationLevel(ALL_OPT)把算子融合和图优化全开setIntraOpNumThreads(CPU 物理核数)控制单个算子内部的并行度setExecutionMode(ORT_SEQUENTIAL)避免多个节点并行调度带来的不确定性。如果是带独显的机器需要换成 onnxruntime-gpu 依赖并在 Java 里通过 addCUDA() 启用 CUDA ExecutionProvider。启用后可以用 session.getProfiling()不我一般看日志有没有 “CUDA Execution Provider” 字样确保推理真的落到 GPU。很多时候依赖换对了但没启用 provider模型还是跑在 CPU 上测出来的时延当然不对。提速效果要看显卡。这些参数在下线前一定要用真实视频流压一遍因为 Java 侧排队、GC、解码都会叠加进端到端延迟单个模型参数再好也救不了整体设计问题。3. 从 Maven 依赖到第一帧识别跑通最小 Java 推理链路3.1 依赖与工程骨架onnxruntime 的 Java 包怎么引新建一个标准 Maven 工程核心依赖只需要一个dependency groupIdcom.microsoft.onnxruntime/groupId artifactIdonnxruntime/artifactId version1.x/version /dependency注意版本号要跟你本机操作系统配对Linux、Windows、macOS 的 native 库都在里面Maven 会自动解压。如果你要 GPU 版本artifactId 换成 onnxruntime-gpu同时本机要有对应 CUDA 和 cuDNN 版本。这个依赖的 jar 包体积不小上线前记得让运维确认部署目录空间。我习惯把模型文件放在 resources 外面用绝对路径加载避免每次发版都打进 jar 里导致包膨胀。加载模型并查看输入输出信息的骨架代码OrtEnvironment env OrtEnvironment.getEnvironment(); OrtSession.OrtSessionOptions opts new OrtSession.OrtSessionOptions(); opts.setOptimizationLevel(OptimizationLevel.ALL_OPT); try (OrtSession session env.createSession(modelPath, opts)) { for (String name : session.getInputNames()) { System.out.println(input name name); } for (String name : session.getOutputNames()) { System.out.println(output name name); } }这段代码的价值在于它是你后面所有后处理代码的起点。输入名不一定叫 images输出名更不一定是 output0一切以这里打印的为准。我曾接过一个从 Paddle 转过来的模型输入名是 x输出名是 save_infer_model/scale_0.tmp_1如果按 YOLO 惯例去取第一步就 null 了。3.2 预处理letterbox 缩放与归一化必须在 Java 侧自己写Java 侧没有 torchvision.transforms所以从图像字节到 float 数组的过程全得手写。下面这段代码是我常用的工具类核心逻辑输入是 BGR 字节数组输出是可直接喂给模型的 blobpublic static float[] letterboxAndNormalize(byte[] bgr, int srcW, int srcH, int dstW, int dstH, float[] ratio, float[] pad) { float r Math.min((float) dstW / srcW, (float) dstH / srcH); int newW Math.round(srcW * r); int newH Math.round(srcH * r); float padW (dstW - newW) / 2f; float padH (dstH - newH) / 2f; ratio[0] r; pad[0] padW; pad[1] padH; float[] blob new float[3 * dstW * dstH]; // 默认填充 114对应 YOLO 训练时的 padding 颜色 Arrays.fill(blob, 114f / 255f); // 把缩放后的有效区域拷贝进 blob 的 B、G、R 三个通道 for (int c 0; c 3; c) { for (int i 0; i newH; i) { int srcRow (int) (i / r); for (int j 0; j newW; j) { int srcCol (int) (j / r); int srcIdx (srcRow * srcW srcCol) * 3 c; float dstVal (bgr[srcIdx] 0xFF) / 255f; blob[c * dstW * dstH (i (int) padH) * dstW (j (int) padW)] dstVal; } } } return blob; }代码逻辑说明先算缩放比 r取宽高两个比例里的最小值保证图片不变形地放进 640×640。padW / padH 算出左右和上下的填充宽度。blob 采用 NCHW 布局也就是先填整块 B 通道再填 G、R。填充值 114 是 YOLO 训练时官方的 padding 颜色对应归一化后约 0.45不要自作聪明改成黑色 0否则模型看到的分布和训练不一致。参数说明srcW / srcH 是原始帧宽高dstW / dstH 是模型输入尺寸通常 640。ratio 和 pad 数组由调用方传入后面解码时要用它们把 640×640 坐标系还原回原图坐标。这里我用了 (int) 来取填充起点简化处理。如果 padW / padH 是奇数会损失半像素对检测影响很小。如果视频流里有大量小目标建议把模型输入改到 1280但耗时增加明显需要权衡。3.3 ONNX 推理与后处理把 (1, 84, 8400) 转成框、分数、类别预处理完毕后构造 OnnxTensor 并执行推理try (OnnxTensor input OnnxTensor.createTensor(env, FloatBuffer.wrap(blob), new long[]{1, 3, 640, 640})) { MapString, OnnxTensor inputs Collections.singletonMap(inputName, input); try (OrtSession.Result result session.run(inputs)) { Tensor output (Tensor) result.get(0); long[] shape output.getInfo().getShape(); float[] preds output.getFloatData(); System.out.println(shape Arrays.toString(shape)); } }注意FloatBuffer 必须先把 blob 写进去然后 wrap 时指向从 0 开始的位置createTensor 的第一个参数是环境第二个参数是数据第三个参数是输入形状。形状必须和模型输入匹配多一个少一个都会直接抛异常。拿到 preds 后按输出 shape 分流。以 YOLOv8 常见的 (1, 84, 8400) 为例数据排布是 channel-first遍历要按“每列是一个候选框”的顺序读public static Listfloat[] decodeYolo11ChannelFirst(float[] preds, long[] shape, float confThres) { int channels (int) shape[1]; int numBoxes (int) shape[2]; Listfloat[] boxes new ArrayList(); for (int i 0; i numBoxes; i) { float cx preds[i * channels 0]; float cy preds[i * channels 1]; float w preds[i * channels 2]; float h preds[i * channels 3]; float maxScore 0; int classId -1; for (int c 4; c channels; c) { float score preds[i * channels c]; if (score maxScore) { maxScore score; classId c - 4; } } if (maxScore confThres) { boxes.add(new float[]{cx - w / 2f, cy - h / 2f, cx w / 2f, cy h / 2f, maxScore, classId}); } } return boxes; }这段代码的逻辑是关键YOLOv8 系列没有目标置信度所以直接取各类别概率最大值作为最终分数类别索引 c-4 和 COCO80 的原生索引对齐。坐标是中心点加宽高要转成左上角右下角方便后面画框或计算 IoU。confThres 默认给 0.25实测视频流里目标小或者遮挡严重时可以降到 0.1误检会多一些用 NMS 兜底。解出来的 boxes 还要做非极大值抑制Java 侧没有现成的我通常写一个按分数降序、逐框计算 IoU 的简易 NMS。排序用 Box 的 score 做 comparator高于 iouThres(0.45) 的框直接跳过即可。至此一个 YOLOv11 模型的最小推理链路已经完整读图、letterbox、推理、解码、NMS、得到检测框。3.4 参数说明与最小验证跑通标志不是“不报错”而是框位置准确很多人第一次跑通看到控制台打印了数组就以为成功了。跑通标志应该是拿一张已知目标的图片检测出的框落在目标周围且类别正确。我习惯先用标准 COCO 样例图做单图测试把解析出的框坐标打印出来再用真值比对。假如框偏在左上角且尺寸和原图不成比例十有八九是 letterbox 的 pad / ratio 没有回代到原图坐标。解码时拿到的是 640 坐标系的框要映射回原图必须做origX (x - pad[0]) / ratio[0]、origY (y - pad[1]) / ratio[1]。这一步在第 5 章避坑处还会展开。4. 视频流识别落地抽帧、并发与“能跑多少路”的测算4.1 从视频文件、RTSP 取帧的 Java 方案视频识别的前提是拿到帧。Java 自带的 ImageIO 不能直接读视频常见做法是引入 JavaCV用 FFmpegFrameGrabber 取帧。依赖如下dependency groupIdorg.bytedeco/groupId artifactIdjavacv-platform/artifactId version1.x/version /dependency注意 JavaCV 的 javacv-platform 会带一整套 native 库体积非常大。只做取帧的话可以只引 javacv 和 ffmpeg 两个 artifact减少发布包体积。取帧代码骨架FFmpegFrameGrabber grabber new FFmpegFrameGrabber(rtspUrl); grabber.setOption(rtsp_transport, tcp); grabber.setOption(max_delay, 1000000); grabber.start(); Frame frame; while ((frame grabber.grabImage()) ! null) { // frame 转成 BGR 字节数组喂给第 3 章的预处理 } grabber.close();常见的坑有三个rtsp 流一定要设 tcp 传输方式udp 在弱网下丢帧很严重grabImage() 返回的 Frame 对象重复使用转字节时别缓存引用视频结束后要手动 close否则句柄泄漏长时间运行必挂。4.2 抽帧策略识别不一定要每秒都做视频是 25 帧每秒但识别任务没有必要每帧都推理。我先说最常见的三档全帧识别、按时间抽帧、按场景变化抽帧。全帧识别只适合叠加了跟踪算法帧间目标关联的场景否则 CPU / GPU 都被浪费掉。按时间抽帧最简单if (frameIndex % 5 0) doDetect()相当于每 2 秒识别 5 次适合车辆统计这类周期性任务。按场景变化抽帧适合摄像头固定点位比如工厂流水线计算当前帧和上一抽样帧的平均像素差超过阈值才触发识别。这样可以大幅降低推理次数。如果你的目标是“视频识别项目”建议第一版先用固定抽帧把流程跑通再视业务需求换成动态抽帧。4.3 线程池与模型复用多路视频共享一个 SessionOrtSession 的 run 方法是线程安全的我一般一个模型只创建一个 session多路视频流完全共享它。每一路视频取帧线程把预处理后的 blob 提交给一个固定线程池线程池大小不是越大越好最合理的大小是核心数减一。示例ExecutorService detectPool Executors.newFixedThreadPool(coreCount - 1); detectPool.submit(() - { float[] blob preprocess(frame); Listfloat[] boxes detect(blob); render(boxes, frame); });参数说明线程池数量超过核心数时CPU 上下文切换会吃掉推理的加速收益有 GPU 时与 GPU 通信的线程把 intraOp 线程设置成 1避免多线程抢占 GPU 上下文。后端消费检测结果的队列要有上限用 ArrayBlockingQueue 加拒绝策略压力过大时丢帧而不是阻塞取帧线程这是多路视频不堆积的最重要设计。4.4 吞吐测算从单帧耗时估算能接多少路视频“T4 1080p25帧每秒用 TensorRT YOLO 640 分辨率检测可以支持多少路”这类问题我也常被问到。这里要区分TensorRT 是 GPU 上的加速推理库它导出的 engine 不能直接给 ONNX Runtime Java API 用但我们可以用同样思路估算纯 Java 方案的容量。先测得单帧端到端耗时 T包含解码耗时至推理至后处理假设一路视频 25fps每 5 帧抽一帧识别则单路每秒推理次数是 5 次。如果一次识别耗时 10ms单路占用 50ms/s理论上 20 路以内都在安全区间。实际项目里我会用下面这个更保守的公式路数估算 (1000 / 单帧端到端耗时) / (帧率 / 抽帧间隔) × 0.8例如 1080p 视频解码 3ms、640×640 推理 7ms、后处理 2ms端到端 12ms每秒约 83 帧识别能力25fps 每 5 帧抽一帧单路 5 次每秒理论上限 16.6 路乘安全系数后约 13 路。这个数字比很多人预想的少因为 JavaCV 解码和内存拷贝会吃掉相当大一部分预算纯模型推理反而占比不高。视频路数大时优先调高抽帧间隔、降低解码分辨率、把 JavaCV 的像素格式转成灰度或缩小后再送模型。5. 纯 Java 视觉识别避坑六条血泪经验现象、原因与解决5.1 识别框整体偏左上角越靠边偏移越严重现象目标在原图中间时框还算准移到边缘后框的偏移明显增大甚至框完全跑到物体外面。原因letterbox 缩放后的坐标没有回代到原图坐标系。检测框是在 640×640 的填充坐标系里解出来的你把它们直接画到原图上忽略了缩放比 r 和填充偏移 pad。解决画框前统一做一次坐标映射origX (x - pad[0]) / ratio[0]origY (y - pad[1]) / ratio[1]。ratio 和 pad 必须是预处理时保存的那一对不能另算。建议把 ratio、pad 封装进一个 PreProcessResult 对象和后处理代码放在同一层不要跨类传裸数组。5.2 YOLOv5 模型用 YOLOv8 后处理解码输出全是垃圾框现象模型能跑打印出的 shape 是 (1, 25200, 85)但解码后上千个框NMS 之后剩下一个满屏大框。原因两份模型的后处理语义不同。YOLOv5 的 85 维里有 obj 置信度分数计算是obj * classScore而 YOLOv8 没有 obj直接取 classScore。用 YOLOv8 的解码去读 YOLOv5 输出等于把 obj 当成类之一把真正的类别维度全读偏了。解决写一个 ModelType enum加载模型后先看输出 shape对 (1, 25200, 85) 走 v5 分支对 (1, 84, 8400) 走 v8 分支。不要让后面接手的同事猜。5.3 onnxruntime-gpu 依赖装好了识别速度却和 CPU 一样现象换成 GPU 依赖后推理时延没有下降GPU 利用率也是 0%。原因依赖换成了 onnxruntime-gpu但 Java 代码里没有启用 CUDA ExecutionProvider。ONNX Runtime 默认只用 CPU provider除非你显式调用。解决创建 session 前在 SessionOptions 上调用opts.addCUDA()。再保险一点跑一次推理后打印 provider 信息确认日志中有 CUDA。如果还不行检查 CUDA / cuDNN 版本和 onnxruntime-gpu 包的编译版本是否一致版本黑匣子问题是 GPU 推理返工率最高的原因。5.4 Paddle 导出物是 inference.json 结构直接当 onnx 加载失败现象算法组说“Paddle 训练好的模型已经转好了”发过来一个目录里面有 inference.json 和一堆__model__文件Java 侧 createSession 直接报文件格式错误。原因inference.json 是 Paddle 推理模型的配置文件不是 onnx。Paddle 模型需要先用 paddle2onnx 工具转换成真正的 .onnx 文件转换时还要指定输入 shape 和输出算子。解决让算法侧执行一步转换产出标准 onnx如果他们已经转出 onnx那个目录里应该有 model.pdmodel 和 model.pdiparams用 paddle2onnx 转换时注意输出节点名要和 Java 侧取的名称一致。拿到 onnx 后第一件事仍然是打印输入输出名Paddle 转出来的节点名和 YOLO PyTorch 导出风格完全不同。5.5 输入监督量化 int8 的 onnx 模型在 Java 侧精度骤降现象同一个 onnx 文件在 Python 端用 onnxruntime 跑精度还行Java 端跑出来漏检严重小目标全丢。原因Java 端可能没有走 int8 量化所需的 provider 或数据格式。ONNX Runtime 的 int8 量化模型对算子支持有要求有些动态量化层在 CPU 上会退化成浮点计算但权重已经被量化精度自然下降。解决先确认模型是否真的量化成功查看输出张量是否全部是 float32还是混有 int8再把量化校准集扩大一个量级。贪图体积用 int8 量化在 Java 视频识别这类多尺度小目标场景里经常得不偿失。我一般只在 CPU 推理且模型实在跑不动时才考虑量化GPU 场景保持 fp16 就够。5.6 多路视频长时间运行内存持续上涨现象单路测试一切正常接 4 路 RTSP 跑半天Java 堆内存和 native 内存一起涨最后 OOM 或卡死。原因两类泄漏叠加。JavaCV 的 grabber 或 Frame 对象没有释放OnnxTensor 每次创建后没有 closenative 内存被占满。OnnxTensor 实现了 AutoCloseable它占用的不只是堆外 Direct Memory。解决取帧线程里每次得到的 Frame 不长期持有OnnxTensor 放进 try-with-resources 或 finally 中 close线程池的队列要有界。另一个常常被忽视的是FFmpegFrameGrabber 在重连网络流时旧对象必须 close。启动参数加上-XX:MaxDirectMemorySize1g只能推迟 OOM真正解法是把资源生命周期管好。6. 从“能识别”到“敢上线”验证帧率、调节点与结果缓存做到这一步模型能跑、视频能接、常见坑也排过一遍剩下的就是上线前的性能把关。我先说自己的验证习惯不是拿手机秒表掐一下而是在 Java 里做预热和分位统计。先让模型跑 20 帧预热把 JIT 和 ONNX Runtime 的图优化吃到嘴里再连续测 200 帧记录单帧耗时计算 p50 和 p95。p50 决定平均吞吐p95 才是用户体验95 分位超过 100ms 的识别链路在视频场景里会明显感觉卡顿。一个简单的基准测试循环long start System.nanoTime(); Listfloat[] boxes detect(blob); long costMs (System.nanoTime() - start) / 1_000_000;这里有个容易被忽略的优化OnnxTensor 创建本身有 native 开销每次新建、用完即关是正确的但如果你在同一个线程里循环推理可以复用同一个 FloatBuffer 并把 blob 写回同一片堆内存减少 GC 压力和 DirectMemory 分配。技巧是blob 数组常驻不要每次 newFloatBuffer.wrap(blob) 后每次 flip / clear。线程池内的线程各自持有自己的 blob不共享避免同步开销。另一个实用调节点是识别结果缓存。固定点位的摄像头画面上长时间没有变化就不必每抽一帧都推理。常见做法是维护一个“帧指纹”把缩放后的小图做均值哈希average hash或直接算平均绝对像素差低于阈值则沿用上一次检测结果。这个技巧在厂房、园区门口这类场景特别有效可以把整条链路的有效推理次数降到原来的 1/10。最后分享一个我自己的教训。曾经为了统一代码试图让一套后处理同时兼容 YOLOv5 和 YOLOv8结果模型一换漏检率一夜之间从 5% 涨到 40%。后来才明白模型的输出结构是训练时就定死的Java 侧能做的只有“识别它、适配它”不能为了省代码把差异抹掉。所以现在我的习惯是每接一个新模型先回答四个问题——输入名是什么、输出 shape 是什么、坐标是中心加宽高还是左上角加宽高、有没有独立的 obj 置信度。这四个问题的答案写进接口文档比任何“通用解码器”都可靠。希望这些方案和踩过的坑能帮你在纯 Java 的视觉识别路上少花几个通宵。本文还有配套的精品资源点击获取