檢測(cè)架構(gòu)與避坑指南)
簡(jiǎn)介Java 與 Python 混合調(diào)用 YOLO ONNX 模型完成視頻目標(biāo)檢測(cè)的完整工程面向需要將深度學(xué)習(xí)能力接入 Java 服務(wù)端、處理 RTSP/RTMP 實(shí)時(shí)視頻流的開發(fā)者覆蓋 YOLOv5、YOLOv7、YOLOv8 等主流模型解決跨語言推理、數(shù)據(jù)格式轉(zhuǎn)換和檢測(cè)結(jié)果可視化等實(shí)際問題。壓縮包共 68 個(gè)文件、約 272MB包括 17 個(gè) Java 源碼、5 個(gè) ONNX 模型、1 個(gè) Python 推理腳本以及演示視頻、效果截圖、GIF 動(dòng)圖和說明文檔目錄結(jié)構(gòu)清晰便于直接閱讀與二次開發(fā)。已有 265 人學(xué)習(xí)。工程完整展示了從視頻流獲取、幀預(yù)處理、模型調(diào)用到后處理的全鏈路Java 端負(fù)責(zé)解析 RTSP/RTMP 視頻流對(duì)視頻幀進(jìn)行縮放、歸一化、填補(bǔ)操作并轉(zhuǎn)換為 Numpy 數(shù)組再通過 JNI 等機(jī)制調(diào)用 Python 腳本加載 ONNX 模型執(zhí)行目標(biāo)檢測(cè)返回結(jié)果后Java 端再做置信度過濾、識(shí)別框繪制等后處理。配合演示視頻和預(yù)覽圖可快速驗(yàn)證同一套方案在不同 YOLO 版本下的運(yùn)行效果適合正在做目標(biāo)檢測(cè)落地的中級(jí)以上 Java 開發(fā)者參考能顯著減少跨語言集成的踩坑時(shí)間。1. Java 調(diào) Python YOLO ONNX 模型做視頻檢測(cè)先看清這套架構(gòu)的邊界Java 后臺(tái)要接視頻目標(biāo)檢測(cè)算法同事丟來三個(gè) .pt 權(quán)重yolov5s、yolov7、yolov8n說“你拿去集成吧”。直接拿 Java 寫一堆 letterbox 和 NMS 的代價(jià)比想象中大得多。把 PyTorch 模型導(dǎo)出成 ONNX再讓 Python 側(cè)用 ONNX Runtime 常駐推理Java 只負(fù)責(zé)取幀、發(fā)送和畫框這套「Java 調(diào)用 Python YOLO ONNX 模型進(jìn)行視頻目標(biāo)檢測(cè)與識(shí)別」的管線是我做下來最不容易翻車的組合。它適合手里已有現(xiàn)成 YOLO 權(quán)重、主系統(tǒng)是 Java、又不想把模型后處理邏輯重寫一遍的團(tuán)隊(duì)。識(shí)別這一步最終落在類別 ID 到名稱的映射上Java 端拿回 JSON 畫框標(biāo)字即可。2. 把 PyTorch 權(quán)重導(dǎo)出成 ONNXYOLOv5/v7/v8 的導(dǎo)出命令與輸出校驗(yàn)2.1 三個(gè)版本的導(dǎo)出命令差異先說環(huán)境前提Python 3.8 以上裝好 onnxruntime 和 torch。沒裝 Python 的先去官網(wǎng)裝一個(gè)安裝時(shí)記得把 Add to PATH 勾上否則后面 Java 的 ProcessBuilder 會(huì)報(bào)找不到 python 命令。預(yù)訓(xùn)練權(quán)重在官方 release 頁可以直接下載yolov8 用命令行會(huì)自動(dòng)拉取。YOLOv5 的導(dǎo)出在倉庫根目錄執(zhí)行# yolov5 倉庫根目錄 python export.py --weights yolov5s.pt --include onnx --opset 12注意 v5 的 export.py 有個(gè)--grid參數(shù)。不加它導(dǎo)出的是三個(gè)尺度原始特征圖后處理要自己寫 grid 偏移和 anchor 先驗(yàn)加上它直接輸出解碼后的 1x25200x85。我在 ORT 里用后一種省事。YOLOv7 的導(dǎo)出python export.py --weights yolov7.pt --grid --simplify --img-size 640 640v7 的--end2end參數(shù)是給 TensorRT 做內(nèi)置 NMS 用的ONNX Runtime 里用不上我一般不碰。--simplify會(huì)走一遍 onnx-simplifier把多余的 Shape、Gather 節(jié)點(diǎn)清掉。YOLOv8 用 ultralytics 的 CLIyolo export modelyolov8n.pt formatonnx opset12 imgsz640v8 導(dǎo)出默認(rèn)輸出 1x84x840084 是 4 個(gè)坐標(biāo)加 80 個(gè)類別分?jǐn)?shù)8400 是三個(gè)尺度中心點(diǎn)總數(shù)沒有 objectness 分支。部分新版本支持transposedTrue輸出變成 1x8400x84兩者只有維度順序差異后面校驗(yàn)?zāi)_本能一眼看出來。2.2 用 ONNX Runtime 校驗(yàn)輸出輸入輸出名與 shape導(dǎo)出后先別急著寫后處理用一段腳本把輸入輸出名、shape 打出來。onnx 只是模型文件格式onnxruntime 是真正干活的推理引擎這兩者常被混著說但命令和報(bào)錯(cuò)信息完全不一樣。import onnxruntime as ort import numpy as np import cv2 sess ort.InferenceSession(yolov8n.onnx, providers[CPUExecutionProvider]) inp sess.get_inputs()[0] out sess.get_outputs()[0] print(input:, inp.name, inp.shape, inp.type) print(output:, out.name, out.shape, out.type) # 單幀冒煙測(cè)試 frame cv2.imread(frame.jpg) frame cv2.resize(frame, (640, 640)) x frame[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0 pred sess.run([out.name], {inp.name: x})[0] print(pred shape:, pred.shape)這段腳本做了三件事確認(rèn)輸入名和 shape確認(rèn)輸出名和 shape驗(yàn)證預(yù)處理鏈路。frame[:, :, ::-1]是把 OpenCV 讀進(jìn)來的 BGR 轉(zhuǎn)成 RGBtranspose(2, 0, 1)[None]是把 HWC 轉(zhuǎn)成 NCHW除以 255 是歸一化。跑出來如果是(1, 84, 8400)就是 v8(1, 25200, 85)就是帶 decode 的 v5/v7。第一次跑先不碰后處理這一步就能暴露八成格式問題。2.3 導(dǎo)出參數(shù)三個(gè)必調(diào)項(xiàng)opset、simplify、動(dòng)態(tài)軸opset 選 12 到 14 比較穩(wěn)妥ORT 1.11 以上都能跑。opset 開太高舊版本 ORT 會(huì)直接報(bào) UnsupportedOperator這時(shí)候最容易走玄學(xué)排障路線。v5 導(dǎo)出自帶 simplifyv8 導(dǎo)出完建議自己再跑一次 onnxsim去掉多余節(jié)點(diǎn)后輸出名會(huì)更干凈Java/Python 兩側(cè)對(duì)模型文件也更有底。動(dòng)態(tài)軸我一般不開。固定 640x640 輸入模型加載后每次推理的內(nèi)存布局一致后處理里 grid 也是寫死的比動(dòng)態(tài) shape 省心。動(dòng)態(tài)輸入每次 shape 變化時(shí) ORT 第一次推理有額外開銷Java 端也難預(yù)估單幀耗時(shí)。如果確實(shí)要跑多種分辨率我寧愿多導(dǎo)出幾個(gè)固定尺寸的 onnx 文件按場(chǎng)景切換不搞一個(gè)萬能動(dòng)態(tài)模型。3. Java 和 Python 的三種聯(lián)調(diào)方式選型對(duì)比與最小可跑通的常駐服務(wù)3.1 三種聯(lián)調(diào)方式的取舍進(jìn)程啟動(dòng)、常駐服務(wù)、Java 直調(diào)Java 調(diào) Python 里的 ONNX 模型常見做法有三種方案優(yōu)點(diǎn)缺點(diǎn)適用場(chǎng)景每幀啟動(dòng) ProcessBuilder 跑 python 腳本實(shí)現(xiàn)最簡(jiǎn)單代碼只有幾行Python 解釋器啟動(dòng)加模型加載要 1 到 3 秒幀率掉到個(gè)位數(shù)離線批量測(cè)試Python 常駐進(jìn)程 本機(jī) Socket 通信一次加載多次推理后處理改完重啟進(jìn)程就生效要處理幀序列化和進(jìn)程生命周期生產(chǎn)環(huán)境項(xiàng)目選型推薦Java 直調(diào) ONNX Runtime Java API吞吐最高沒有跨進(jìn)程拷貝letterbox、NMS、三種版本解碼全要在 Java 里重寫純 Java 團(tuán)隊(duì)且模型不再頻繁換ONNX Runtime 確實(shí)有 Java 綁定但 YOLO 后處理細(xì)節(jié)不少。v5 要算 objectness 乘類別分?jǐn)?shù)v8 直接取類別最大值NMS 還要自己實(shí)現(xiàn)或者調(diào) OpenCV 的接口。模型一換Java 代碼就得跟著發(fā)版這個(gè)維護(hù)成本會(huì)持續(xù)疊加。我選第二種Java 管視頻流和業(yè)務(wù)Python 管模型推理兩邊職責(zé)清楚模型更新只換 onnx 文件、重啟 Python 進(jìn)程Java 端一行不動(dòng)。3.2 Python 端 ONNX Runtime 常駐推理服務(wù)Python 端起一個(gè) Socket 服務(wù)監(jiān)聽 127.0.0.1。協(xié)議很簡(jiǎn)單先收 4 字節(jié)大端長(zhǎng)度再收完整 JPEG 幀返回結(jié)果用同樣方式。綁定本機(jī)回環(huán)地址就夠了不暴露給外部。import socket import struct import threading import json import numpy as np import cv2 import onnxruntime as ort class YoloDetector: def __init__(self, onnx_path): self.session ort.InferenceSession(onnx_path, providers[CPUExecutionProvider]) self.input_name self.session.get_inputs()[0].name self.input_size 640 self.lock threading.Lock() def preprocess(self, jpg_bytes): img cv2.imdecode(np.frombuffer(jpg_bytes, np.uint8), cv2.IMREAD_COLOR) h, w img.shape[:2] scale min(self.input_size / w, self.input_size / h) nw, nh int(w * scale), int(h * scale) resized cv2.resize(img, (nw, nh)) canvas np.full((self.input_size, self.input_size, 3), 114, np.uint8) dw, dh (self.input_size - nw) // 2, (self.input_size - nh) // 2 canvas[dh:dh nh, dw:dw nw] resized blob canvas[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0 return blob, scale, dw, dh def decode(self, pred, conf_thres, iou_thres): pred np.squeeze(pred) if pred.shape[0] pred.shape[1]: pred pred.T # v5/v7: (N, 85)其中第 5 列是 objectness # v8: (N, 84)沒有 objectness直接取類別最大值 if pred.shape[1] 85: obj pred[:, 4:5] cls_score pred[:, 5:] scores (obj * cls_score).max(axis1) cls cls_score.argmax(axis1) else: cls_score pred[:, 4:] scores cls_score.max(axis1) cls cls_score.argmax(axis1) keep scores conf_thres boxes pred[keep, :4] scores scores[keep] cls cls[keep] if len(scores) 0: return [] x1 boxes[:, 0] - boxes[:, 2] / 2 y1 boxes[:, 1] - boxes[:, 3] / 2 x2 boxes[:, 0] boxes[:, 2] / 2 y2 boxes[:, 1] boxes[:, 3] / 2 boxes np.stack([x1, y1, x2, y2], axis1) idx cv2.dnn.NMSBoxes(boxes.tolist(), scores.tolist(), conf_thres, iou_thres) if len(idx) 0: return [] idx np.array(idx).flatten() return boxes[idx], scores[idx], cls[idx] def infer(self, jpg_bytes, conf0.25, iou0.45): blob, scale, dw, dh self.preprocess(jpg_bytes) with self.lock: pred self.session.run(None, {self.input_name: blob})[0] boxes, scores, cls self.decode(pred, conf, iou) result [] for (x1, y1, x2, y2), score, c in zip(boxes, scores, cls): result.append({ x1: round(float((x1 - dw) / scale), 2), y1: round(float((y1 - dh) / scale), 2), x2: round(float((x2 - dw) / scale), 2), y2: round(float((y2 - dh) / scale), 2), score: round(float(score), 4), cls: int(c) }) return resultdecode 里 85 和 84 的區(qū)分是這套代碼同時(shí)吃三種模型的關(guān)鍵。v5/v7 的 model shape 是 25200x8585 里第 5 列是 objectness所以分?jǐn)?shù)要拿 objectness 乘類別概率v8 是 8400x84直接對(duì)類別維度取最大值就行。服務(wù)端主循環(huán)def handle(conn): while True: header conn.recv(4) if len(header) 4: break length struct.unpack(I, header)[0] buf b while len(buf) length: chunk conn.recv(length - len(buf)) if not chunk: break buf chunk dets detector.infer(buf) payload json.dumps(dets).encode() conn.sendall(struct.pack(I, len(payload)) payload) srv socket.socket(socket.AF_INET, socket.SOCK_STREAM) srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((127.0.0.1, 9010)) srv.listen(5) while True: conn, _ srv.accept() threading.Thread(targethandle, args(conn,), daemonTrue).start()長(zhǎng)度前綴必須用大端I和 Java 端 DataOutputStream.writeInt 默認(rèn)行為一致。收幀時(shí)不能只 recv 一次就假設(shè)拿完整包TCP 是流協(xié)議分包和粘包都會(huì)遇到循環(huán)收滿再解析是底線。session.run 外面加了鎖多線程連接同時(shí)進(jìn)來時(shí)不會(huì)出現(xiàn)偶發(fā)崩潰。3.3 Java 端啟動(dòng) Python 進(jìn)程與 Socket 客戶端封裝Java 端用 ProcessBuilder 把 Python 服務(wù)拉起來之后所有檢測(cè)請(qǐng)求都走同一個(gè) Socket 連接。public class YoloClient implements Closeable { private final DataInputStream in; private final DataOutputStream out; public YoloClient(String host, int port) throws IOException { Socket socket new Socket(host, port); in new DataInputStream(new BufferedInputStream(socket.getInputStream())); out new DataOutputStream(new BufferedOutputStream(socket.getOutputStream())); } public ListDetectedBox detect(byte[] jpeg) throws IOException { out.writeInt(jpeg.length); // 大端與 Python struct.pack(I) 對(duì)齊 out.write(jpeg); out.flush(); int len in.readInt(); byte[] buf new byte[len]; in.readFully(buf); // readFully 保證拿完整包 return parseJson(new String(buf, StandardCharsets.UTF_8)); } public static void startPythonService() throws IOException { ProcessBuilder pb new ProcessBuilder( python, /opt/yolo/server.py, --model, /opt/yolo/yolov8n.onnx, --port, 9010); pb.redirectErrorStream(true); pb.redirectOutput(new File(/var/log/yolo-service.log)); pb.start(); // 輪詢端口等服務(wù)真正起來 for (int i 0; i 20; i) { try (Socket s new Socket(127.0.0.1, 9010)) { return; } catch (IOException e) { Thread.sleep(500); } } throw new IOException(python service not ready); } }detect 方法里那個(gè)out.flush()很容易漏。BufferedOutputStream 會(huì)把 writeInt 和 write 都緩存住不 flush 數(shù)據(jù)就躺在緩沖區(qū)Python 端一直等不到請(qǐng)求。readFully 則是處理半包的后悔藥普通 read 一次讀不滿 JSON 就得自己拼readFully 內(nèi)部幫你解決。生產(chǎn)部署時(shí)我更推薦把 Python 服務(wù)單獨(dú)拉起交給 systemd 托管Java 只當(dāng)客戶端進(jìn)程生命周期不綁在一起。ProcessBuilder 拉起床的唯一好處是演示環(huán)境下夠簡(jiǎn)單。4. Java 端視頻取幀與結(jié)果回填JavaCV 管線、坐標(biāo)還原與畫框4.1 JavaCV 取流與 JPEG 編碼JavaCV 的 FFmpegFrameGrabber 能同時(shí)處理本地 MP4 和 RTSP 攝像頭流。取幀后轉(zhuǎn)成 Mat再編碼成 JPEG 發(fā)給 Python這是目前最順的鏈路。FFmpegFrameGrabber grabber new FFmpegFrameGrabber(rtsp://admin:password192.168.1.10:554/stream1); grabber.setOption(rtsp_transport, tcp); // UDP 在弱網(wǎng)下瘋狂丟包 grabber.start(); OpenCVFrameConverter.ToMat toMat new OpenCVFrameConverter.ToMat(); MatOfByte buf new MatOfByte(); long intervalMs 100; // 每秒最多 10 幀 while (true) { long t0 System.currentTimeMillis(); Frame frame grabber.grabImage(); if (frame null) break; Mat bgr toMat.convert(frame); Imgcodecs.imencode(.jpg, bgr, buf); // JPEG 質(zhì)量由 ENCODE_PARAM 控制 byte[] jpeg buf.toArray(); ListDetectedBox dets client.detect(jpeg); drawBoxes(bgr, dets); // 按節(jié)拍取幀不依賴 grabber 內(nèi)部幀率 long elapsed System.currentTimeMillis() - t0; if (elapsed intervalMs) Thread.sleep(intervalMs - elapsed); // 業(yè)務(wù)處理完記得 release }RTSP 傳輸用 TCP 而不是默認(rèn) UDP視頻流在弱網(wǎng)下不會(huì)變成滿屏馬賽克檢測(cè)框也不會(huì)跟著亂飄。不要依賴 setFrameRate 來節(jié)流那個(gè)參數(shù)在流輸入場(chǎng)景下并不控制取幀節(jié)奏手動(dòng)按 100 毫秒間隔 sleep 更可靠。JPEG 質(zhì)量是個(gè)容易忽略的坑。JDK 自帶 ImageIO 不開放質(zhì)量參數(shù)默認(rèn)壓縮率偏高小目標(biāo)會(huì)被壓糊。用 OpenCV 的 imencode 可以控制MatOfInt params new MatOfInt( Imgcodecs.IMWRITE_JPEG_QUALITY, 85, Imgcodecs.IMWRITE_JPEG_OPTIMIZE, 1); Imgcodecs.imencode(.jpg, bgr, buf, params);質(zhì)量 85 是我常用的值再低檢測(cè)精度開始下降再高編碼開銷變大對(duì)檢測(cè)結(jié)果沒有額外收益。4.2 坐標(biāo)還原與檢測(cè)類別回填Python 端返回的 x1、y1、x2、y2 已經(jīng)是原始視頻幀坐標(biāo)因?yàn)?server.py 里用(x - pad) / scale做過 letterbox 逆變換。Java 端拿到后直接畫框不需要再乘任何縮放系數(shù)。這里的分工必須寫清楚還原坐標(biāo)是 Python 端的事Java 端只負(fù)責(zé)展示和存儲(chǔ)。private void drawBoxes(Mat bgr, ListDetectedBox dets) { for (DetectedBox b : dets) { Rect rect new Rect((int) b.x1, (int) b.y1, (int) (b.x2 - b.x1), (int) (b.y2 - b.y1)); Imgproc.rectangle(bgr, rect, new Scalar(0, 255, 200), 2); String label COCO_CLASSES[b.cls] b.score; Imgproc.putText(bgr, label, new Point(b.x1, Math.max(b.y1 - 6, 20)), Imgproc.FONT_HERSHEY_SIMPLEX, 0.6, new Scalar(0, 255, 200), 1); } }COCO_CLASSES 是 80 個(gè)類別名的靜態(tài)數(shù)組網(wǎng)上到處都能找到完整清單。如果是自定義數(shù)據(jù)集自己訓(xùn)練時(shí)會(huì)在 data.yaml 里保留類別名寫個(gè)啟動(dòng)時(shí)讀取的邏輯替換掉硬編碼數(shù)組即可。putText 的 y 坐標(biāo)要防止框貼頂時(shí)文字畫到畫面外所以取了max(y1 - 6, 20)。4.3 取幀參數(shù)參考表參數(shù)推薦值說明rtsp_transporttcp避免信道丟包導(dǎo)致檢測(cè)目標(biāo)缺損抽幀間隔100ms與單幀推理耗時(shí)匹配留出余量JPEG 質(zhì)量85低于 70 小目標(biāo)容易漏檢輸入尺寸640x640固定尺寸ORT 推理消耗可控服務(wù)地址127.0.0.1:9010本機(jī)回環(huán)不占外網(wǎng)端口取幀管線里丟一兩幀沒關(guān)系。檢測(cè)任務(wù)本來就按節(jié)拍抽幀不用每幀都送推理。如果推理耗時(shí) 80 毫秒抽幀間隔設(shè) 100 毫秒最合理設(shè)成 33 毫秒只會(huì)讓請(qǐng)求在 Python 端排隊(duì)內(nèi)存占用慢慢漲上去。5. 聯(lián)調(diào)避坑記錄五個(gè)把方案拖垮的常見問題5.1 letterbox pad 沒還原框整體朝右下飄現(xiàn)象檢測(cè)框位置大體對(duì)但越往右下角偏得越狠小目標(biāo)偏得尤其明顯。原因輸入模型之前做了 letterbox把原圖縮放并填充到 640x640。模型輸出的坐標(biāo)是在 640x640 坐標(biāo)系里的如果不減 pad、不除以縮放比直接當(dāng)成原圖坐標(biāo)畫框必然偏移。padding 在右邊和下邊所以框都往右下飄。解決preprocess 階段把 scale、dw、dh 三個(gè)值帶出來解碼后逐框反算。代碼里就是(x - dw) / scale這一步反算完再 clamp 到原圖寬高范圍內(nèi)。5.2 每幀都起 Python 進(jìn)程2 FPS 的瓶頸現(xiàn)象Java 端每次都 new 一個(gè) ProcessBuilder 跑python detect.py單幀耗時(shí) 2 秒以上CPU 直接打滿。原因Python 解釋器啟動(dòng)要幾百毫秒onnxruntime 加載模型又要幾百毫秒到一兩秒這還沒算推理本身。每幀重來一次時(shí)間全部花在啟動(dòng)上。解決改成常駐進(jìn)程。模型加載只做一次Socket 復(fù)用Java 端只是發(fā)送 JPEG 和收 JSON。實(shí)測(cè)從 2 FPS 直接跳到 10 FPS 以上瓶頸才回到真正的推理耗時(shí)上。5.3 Java 與 Python 的半包和字節(jié)序聯(lián)調(diào)變黑匣子現(xiàn)象Python 單獨(dú)跑一張圖有結(jié)果Java 發(fā)過去之后要么解析報(bào)錯(cuò)要么返回空列表而且不是每次必現(xiàn)。原因兩個(gè)隱藏問題疊在一起。一是字節(jié)序Java DataOutputStream.writeInt 默認(rèn)大端Python 端如果用了struct.unpack(I)小端解析長(zhǎng)度值就完全錯(cuò)亂二是半包TCP 流可能一次只到一半數(shù)據(jù)直接用 recv 一次拿不全。解決統(tǒng)一用大端IJava 端 readInt 天然對(duì)齊。Python 端用 while 循環(huán)收滿 length 字節(jié)再解析Java 端用in.readFully()。這兩個(gè)坑都踩平之后聯(lián)調(diào)就不會(huì)再出玄學(xué)問題。5.4 換 YOLOv8 后硬編碼維度直接炸現(xiàn)象v5 跑得好好的換成 yolov8.onnx 之后返回全空或者 Java 端解析 JSON 時(shí)數(shù)組越界。原因v5 輸出是 1x25200x85v8 輸出是 1x84x8400。如果解碼代碼里寫死 25200 或者寫死 84換模型必炸。更隱蔽的是自定義數(shù)據(jù)集v8 訓(xùn)練 10 個(gè)類別時(shí)輸出是 1x14x8400寫死 84 一樣出錯(cuò)。解決解碼函數(shù)里動(dòng)態(tài)推導(dǎo)。pred.shape[1] 85走 v5/v7 分支否則走 v8 分支類別數(shù)從pred.shape[1] - 5或pred.shape[1] - 4推導(dǎo)不寫死。換模型時(shí)只改 onnx 路徑代碼不用動(dòng)。5.5 內(nèi)存只漲不降Mat 沒 release 的連鎖反應(yīng)現(xiàn)象服務(wù)跑一晚上Java 進(jìn)程內(nèi)存從 500MB 漲到 3GB最后 OOM 被殺。原因OpenCV 的 Java 綁定不像 Java 對(duì)象那樣自動(dòng)回收Mat 底層是 native 內(nèi)存。每幀取出來 new 一個(gè) Mat用完不 releasenative 內(nèi)存就持續(xù)累積。Python 端如果請(qǐng)求積壓隊(duì)列里的 numpy 數(shù)組也會(huì)跟著堆積。解決Mat 用完放 finally 里bgr.release()JavaCV 的 grabber 在 finally 里調(diào)用 stop 和 close。Python 端 Socket 接收緩沖區(qū)設(shè)置上限檢測(cè)請(qǐng)求跟不上就主動(dòng)丟幀不堆隊(duì)列。定位內(nèi)存問題時(shí)用jcmd GC.class_histogram看 native 內(nèi)存別只看堆。6. 閾值、線程數(shù)與 int8 量化讓這套檢測(cè)管線持續(xù)產(chǎn)出的驗(yàn)證清單6.1 參數(shù)表conf、iou、線程與抽幀參數(shù)建議值說明conf_thres0.25誤報(bào)多時(shí)調(diào)到 0.4框數(shù)量明顯減少iou_thres0.45密集遮擋場(chǎng)景調(diào) 0.3抑制重疊框intra_op_num_threadsCPU 核數(shù)一半留一半線程給取幀和業(yè)務(wù)抽幀間隔單幀耗時(shí)的 1.2 倍防止請(qǐng)求在 Python 端排隊(duì)JPEG 質(zhì)量85質(zhì)量再高對(duì)檢測(cè)結(jié)果無提升線程數(shù)不要盲目拉滿。onnxruntime 的 intra_op_num_threads 設(shè)成 CPU 核數(shù)時(shí)每幀推理快了但 Java 端取幀線程會(huì)被擠到等 CPU整體吞吐反而下降。留一半核給業(yè)務(wù)線程是多次壓測(cè)后的折中。6.2 int8 量化與聯(lián)調(diào)一致性驗(yàn)證跑通之后如果 CPU 資源緊張可以給模型做 int8 量化from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic(yolov8n.onnx, yolov8n_int8.onnx, weight_typeQuantType.QUInt8)量化后的模型體積縮小到四分之一左右CPU 推理能快 20% 到 40%。代價(jià)是精度下降小目標(biāo)漏檢率會(huì)上升。所以原模型文件先備份量化模型單獨(dú)存上線前用同一段視頻對(duì)比兩版輸出。這也是我給自己留的后悔藥Java 端不用改任何代碼Python 服務(wù)換個(gè) onnx 路徑重啟就行。驗(yàn)證聯(lián)調(diào)一致性最直接的辦法是讓 Python 單獨(dú)跑一遍視頻Java 聯(lián)調(diào)再跑一遍比對(duì)同幀的框def iou(a, b): x1 max(a[x1], b[x1]); y1 max(a[y1], b[y1]) x2 min(a[x2], b[x2]); y2 min(a[y2], b[y2]) inter max(0, x2 - x1) * max(0, y2 - y1) area_a (a[x2] - a[x1]) * (a[y2] - a[y1]) area_b (b[x2] - b[x1]) * (b[y2] - b[y1]) return inter / (area_a area_b - inter 1e-6) def match_rate(dets_py, dets_java): matched sum(any(iou(a, b) 0.5 for b in dets_java) for a in dets_py) return matched / max(len(dets_py), 1)匹配率 95% 以上就算通過百分之幾的差異來自浮點(diǎn)精度和 JPEG 重編碼不影響業(yè)務(wù)判斷。這套方案我前后接過三個(gè)項(xiàng)目從 v5 遷到 v8 都是只換 onnx 文件Java 側(cè)代碼始終沒動(dòng)過。每個(gè)項(xiàng)目最后我都會(huì)留一段測(cè)試視頻和這個(gè)比對(duì)腳本下次聯(lián)調(diào)直接跑省掉大量反復(fù)確認(rèn)的黑匣子時(shí)間。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取