简介:这是一套面向Java开发者与AI应用工程师的跨语言目标检测实战源码,解决Java项目难以直接调用YOLO系列模型完成视频识别的问题。资源以Java调用Python端YOLO ONNX模型为主线,兼容YOLOv5、YOLOv7、YOLOv8三种主流网络,并集成RTSP、RTMP实时流处理能力,可直接从视频流中抽帧送入模型完成目标检测与跟踪。压缩包共52个文件,约173MB,包含12个Java源码文件、4个onnx模型文件、23张png与3张jpg效果截图、3个gif演示动图,以及pom.xml、README.md、dll依赖和license等配套内容,覆盖工程配置、模型加载、预处理与后处理全流程。代码中对图像缩放、裁剪、灰度化等预处理,以及输出解析、筛选、可视化等后处理均有体现,便于读者理解Java与Python协同推理的完整链路。目前已有1502人学习下载,适合需要将YOLO检测能力嵌入Java服务或视频监控系统的开发者参考复用。
1. Java 调用 Python YOLO ONNX 模型做视频识别:为什么这条链路值得搭
很多做后端或桌面端的团队,视频识别需求来得都很突然:产品说要在现有 Java 系统里加一路摄像头分析,最好还能识别出人、车、安全帽。你手里现成的资产往往是一份 Python 训练出来的 YOLO 权重,可能是 yolov5、yolov7 或 yolov8,导出成 ONNX 之后,问题就变成了——Java 怎么把它跑起来,还要跑在视频流上。
直接上 Java 深度学习框架不是不行,但模型转换、算子兼容、前后处理对齐,每一步都是玄学。更稳的做法是让 Python 负责推理,Java 负责业务调度和视频解码,两边用进程调用或本地服务通信。这条链路的好处是:模型侧完全复用 Python 生态,yolov5/yolov7/yolov8 的 ONNX 都能吃;Java 侧不用碰张量计算,只做编排。适合已有 Java 主系统、又不想重写推理层的团队,也适合想快速验证视频识别可行性的个人开发者。
2. 三种 Java 调 Python 的落地方式:进程、HTTP、gRPC 怎么选
2.1 先想清楚推理进程的生命周期
Java 调 Python 最容易被忽略的是进程模型。每次识别都Runtime.exec起一个 Python,模型加载就要几百毫秒到几秒,视频按帧跑根本扛不住。所以核心问题是:Python 推理进程是常驻还是短命。
常见做法有三类。第一类是短进程,Java 把图片路径或 base64 传给 Python 脚本,脚本跑完输出 JSON 就退出,适合低频、离线批处理。第二类是常驻 HTTP 服务,Python 用 FastAPI 或 Flask 起一个本地端口,Java 用 HttpClient 发请求,模型只加载一次,适合视频这种高频场景。第三类是 gRPC 或本地 socket,延迟更低,但要多写协议定义。
我一般会先上 HTTP 常驻服务,理由是调试直观、跨语言无痛、出问题能单独用 curl 验证。等吞吐真的顶不住,再考虑换 gRPC 或共享内存传帧。
2.2 用 FastAPI 起一个常驻推理服务
先看 Python 侧。下面这段代码同时兼容 yolov5、yolov7、yolov8 导出的 ONNX,关键在于统一输入输出格式,而不是依赖某个版本的官方推理脚本。
# infer_server.py import io import numpy as np import onnxruntime as ort from fastapi import FastAPI, UploadFile from PIL import Image app = FastAPI() # 只加载一次,常驻内存 session = ort.InferenceSession( "yolov8n.onnx", providers=["CPUExecutionProvider"] # 有 GPU 可换 CUDAExecutionProvider ) input_name = session.get_inputs()[0].name input_shape = session.get_inputs()[0].shape # [1,3,640,640] def letterbox(img, new_shape=640): # 保持比例缩放并填充,避免形变影响精度 w, h = img.size scale = min(new_shape / w, new_shape / h) nw, nh = int(w * scale), int(h * scale) img_resized = img.resize((nw, nh)) canvas = Image.new("RGB", (new_shape, new_shape), (114, 114, 114)) canvas.paste(img_resized, ((new_shape - nw) // 2, (new_shape - nh) // 2)) return canvas, scale, (new_shape - nw) // 2, (new_shape - nh) // 2 def preprocess(img): img, scale, dx, dy = letterbox(img) arr = np.asarray(img, dtype=np.float32) / 255.0 arr = arr.transpose(2, 0, 1)[None, ...] # HWC -> NCHW return np.ascontiguousarray(arr), scale, dx, dy @app.post("/detect") async def detect(file: UploadFile): raw = await file.read() img = Image.open(io.BytesIO(raw)).convert("RGB") tensor, scale, dx, dy = preprocess(img) outputs = session.run(None, {input_name: tensor}) # 后处理省略,返回原始输出维度便于调试 return {"shape": [list(o.shape) for o in outputs]}这段代码里几个点必须说清楚。ort.InferenceSession放在模块顶层,保证服务启动时只加载一次模型,这是常驻服务的意义所在。providers决定用 CPU 还是 GPU,装了 onnxruntime-gpu 才能写 CUDAExecutionProvider,否则会直接报错。letterbox是 YOLO 系列通用的预处理,yolov5、yolov7、yolov8 都吃这一套,区别只在后处理的解码方式。输入张量必须是 NCHW 且连续内存,np.ascontiguousarray这行别省,否则 onnxruntime 可能报维度或内存错误。
2.3 Java 侧用 HttpClient 发帧并解析结果
Java 这边不引入任何深度学习依赖,只用 JDK 自带的 HttpClient 和 Jackson。
// YoloClient.java import java.net.URI; import java.net.http.*; import java.nio.file.*; import java.time.Duration; public class YoloClient { private static final HttpClient CLIENT = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); public static String detect(Path imagePath) throws Exception { // 手工拼 multipart,避免引入额外依赖 String boundary = "----YoloBoundary" + System.currentTimeMillis(); byte[] fileBytes = Files.readAllBytes(imagePath); var body = new java.io.ByteArrayOutputStream(); body.write(("--" + boundary + "\r\n").getBytes()); body.write(("Content-Disposition: form-data; name=\"file\"; filename=\"frame.jpg\"\r\n").getBytes()); body.write("Content-Type: image/jpeg\r\n\r\n".getBytes()); body.write(fileBytes); body.write(("\r\n--" + boundary + "--\r\n").getBytes()); HttpRequest req = HttpRequest.newBuilder() .uri(URI.create("http://127.0.0.1:8000/detect")) .timeout(Duration.ofSeconds(10)) .header("Content-Type", "multipart/form-data; boundary=" + boundary) .POST(HttpRequest.BodyPublishers.ofByteArray(body.toByteArray())) .build(); HttpResponse<String> resp = CLIENT.send(req, HttpResponse.BodyHandlers.ofString()); return resp.body(); } }HttpClient是 JDK 11 之后自带的,不用引第三方库。multipart 手工拼是为了减少依赖,如果项目里已经有 OkHttp 或 Apache HttpClient,直接用它们的 multipart 更省事。超时时间要设,视频帧偶尔卡一下不能让 Java 线程无限等。返回的 JSON 里包含检测框和类别,Java 侧再映射成业务对象。
2.4 视频流怎么拆帧、怎么控节奏
视频识别不是单张图片,Java 侧要负责拆帧。常见做法是用 JavaCV 或 ffmpeg 命令行抽帧,再逐帧发给 Python。
// 用 ffmpeg 按固定帧率抽帧,避免逐帧处理拖垮服务 // ffmpeg -i input.mp4 -vf fps=5 frames/%06d.jpg抽帧率是关键参数。原始视频 25fps,全抽就是每秒 25 次推理,CPU 上大概率跑不动。我一般先降到 5fps,看业务能不能接受,再往上调。抽出来的帧按序号命名,Java 顺序读取、发送、收集结果,最后按时间戳回填到视频时间轴。如果要做实时流,就用 ffmpeg 推流到本地管道,Java 读管道里的帧,省去落盘。
3. YOLOv5/v7/v8 的 ONNX 输出差异与后处理对齐
3.1 三个版本的输出张量到底差在哪
这是整条链路最容易翻车的地方。yolov5 和 yolov7 的 ONNX 输出通常是[1, 25200, 85]这种形状,85 = 4 个框坐标 + 1 个置信度 + 80 类。yolov8 变了,输出是[1, 84, 8400],84 = 4 个框 + 80 类,而且没有独立的置信度维度,类别分数直接乘在框上。如果你拿 yolov5 的后处理去解 yolov8 的输出,结果要么全空要么全是乱框。
| 版本 | 典型输出形状 | 是否含 objectness | 解码方式 |
|---|---|---|---|
| yolov5 | [1, 25200, 85] | 有 | 先筛 objectness 再取类别 |
| yolov7 | [1, 25200, 85] | 有 | 同 yolov5,部分导出带多输出 |
| yolov8 | [1, 84, 8400] | 无 | 直接取类别最大分,转置后 NMS |
导出时的参数也会影响形状。yolov5 导出加--include onnx,默认就是上面这种。yolov8 用yolo export model=yolov8n.pt format=onnx,默认输出[1,84,8400]。如果加了nms=True,输出会变成已经做过 NMS 的结果,形状又不一样,Java 侧解析逻辑要跟着改。
3.2 写一个版本无关的后处理函数
与其为每个版本写一套,不如在 Python 侧统一成一种输出格式再返回给 Java。
def postprocess(outputs, conf_thres=0.25, iou_thres=0.45): pred = outputs[0] # 统一转成 [N, 4+nc] 且带 objectness 的中间格式 if pred.ndim == 3 and pred.shape[1] < pred.shape[2]: # yolov8: [1,84,8400] -> [8400,84] pred = pred[0].transpose(0, 1) boxes, scores = pred[:, :4], pred[:, 4:] conf = scores.max(axis=1) cls = scores.argmax(axis=1) else: # yolov5/v7: [1,25200,85] pred = pred[0] boxes, obj, scores = pred[:, :4], pred[:, 4], pred[:, 5:] conf = obj * scores.max(axis=1) cls = scores.argmax(axis=1) mask = conf > conf_thres boxes, conf, cls = boxes[mask], conf[mask], cls[mask] # 这里接 NMS,xywh -> xyxy 后做 IoU 抑制 return boxes, conf, cls判断分支靠的是维度关系:yolov8 的通道数小于候选框数,转置后每行是一个候选。yolov5/v7 反过来。conf_thres控制召回,视频场景我一般设 0.25 到 0.4,太低会满屏误检,太高会漏掉远处小目标。iou_thres控制重叠框合并,0.45 是通用值,密集场景可以降到 0.3。
3.3 坐标还原:别让框画偏了
预处理做了 letterbox,后处理必须把坐标还原回原图。这一步错了,框的位置会整体偏移,而且偏移量随图片宽高比变化,很难一眼看出。
def scale_boxes(boxes, scale, dx, dy, orig_w, orig_h): # boxes 是 xywh 格式,先转 xyxy x1 = (boxes[:, 0] - boxes[:, 2] / 2 - dx) / scale y1 = (boxes[:, 1] - boxes[:, 3] / 2 - dy) / scale x2 = (boxes[:, 0] + boxes[:, 2] / 2 - dx) / scale y2 = (boxes[:, 1] + boxes[:, 3] / 2 - dy) / scale return np.stack([x1, y1, x2, y2], axis=1).clip(0, [orig_w, orig_h, orig_w, orig_h])scale、dx、dy就是预处理时算出来的缩放比和填充偏移,必须一路传到后处理。clip 是防止坐标越界,画框时超出画面会报错。这套还原逻辑对三个版本都适用,因为 letterbox 是共通的。
4. 视频识别链路的避坑与排查:五个真实踩过的坑
4.1 坑一:模型加载慢,每帧都重新加载
现象是单张图片识别要两三秒,视频完全跑不动。原因是 Java 每次调用都起新 Python 进程,模型反复加载。解决就是改成常驻服务,模型在服务启动时加载一次。如果必须用短进程,至少把模型路径缓存到本地 SSD,别放网络盘。
4.2 坑二:ONNX 输出形状和预期不符
现象是后处理报维度错误,或者框全是 NaN。原因是导出参数不同,比如 yolov8 加了nms=True,或者 yolov5 导出时改了--img-size。解决是先用一段最小脚本打印session.get_outputs()[0].shape,确认形状再写后处理,别凭记忆。
4.3 坑三:Java 传图 base64 导致内存暴涨
现象是跑一段时间后 Java 堆内存持续上涨。原因是把每帧都转成 base64 字符串,字符串对象在堆里堆积。解决是改用 multipart 二进制传输,或者用本地文件路径传递,Java 只传路径,Python 自己读文件。视频场景帧率高,二进制传输是必须的。
4.4 坑四:CPU 推理线程数没设,多路视频互相抢核
现象是单路视频还行,加到三路就全部变慢。原因是 onnxruntime 默认用满所有 CPU 核,多进程互相抢占。解决是在SessionOptions里限制intra_op_num_threads,每路服务给 2 到 4 个线程,按核数除以路数来分。
opts = ort.SessionOptions() opts.intra_op_num_threads = 4 session = ort.InferenceSession("yolov8n.onnx", opts, providers=["CPUExecutionProvider"])4.5 坑五:抽帧率设太高,识别结果时间轴对不上
现象是识别结果和视频画面对不上,或者结果数量远少于预期。原因是抽帧率和回填时间戳没对齐。解决是抽帧时记录每帧的原始时间戳,识别结果按时间戳回填,而不是按帧序号。ffmpeg 抽帧可以用-frame_pts 1保留时间信息,或者 Java 侧自己按 fps 算。
5. 把单路 Demo 推到多路可用:几个我常用的调优习惯
单路跑通只是起点,真正上线要考虑多路并发和稳定性。我一般会先做一个压测脚本,用同一段视频循环发送,观察 Python 服务的 CPU、内存和延迟曲线。如果延迟随并发数线性上升,说明推理是瓶颈,要么加 GPU,要么降抽帧率,要么上模型量化。
模型量化是最直接的提速手段。ONNX 支持动态量化,把权重从 FP32 降到 INT8,CPU 上通常能快 1.5 到 2 倍,精度掉一点点。
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic("yolov8n.onnx", "yolov8n_int8.onnx", weight_type=QuantType.QUInt8)量化后的模型直接用同样的InferenceSession加载,Java 侧完全无感。但要注意,量化对 yolov8 的检测头部分有时会掉点明显,建议量化后在验证集上跑一遍 mAP,掉超过 3 个点就换回 FP32。
另一个习惯是给 Python 服务加健康检查接口。Java 侧在发送帧之前先探活,服务挂了就重启或切备用实例,别让视频流卡死。健康检查就返回模型名和当前队列长度,简单但能救命。
@app.get("/health") def health(): return {"model": "yolov8n", "status": "ok"}最后说个我自己的教训。早期做这类链路时,我总想把所有逻辑塞进 Java,觉得跨语言调用不优雅。结果每次模型换版本,Java 侧后处理都要重写一遍,改到怀疑人生。后来把后处理全部收进 Python,Java 只做传输和业务映射,换模型只动 Python 一个文件,省下的时间够喝好几杯咖啡。跨语言链路的分工原则就一条:谁离模型近,谁负责模型相关的一切。希望帮到你。
本文还有配套的精品资源,点击获取