Java + YOLOv8 边缘端实时行为分析:从模型适配到部署调优
这个标题下的东西我在最近一个边缘端项目里完整趟了一遍。网上搜“Java YOLO”能出来的资料要么是Python调包教程要么是拿OpenCV跑个CPU demo就算完事真正到生产环境要面对的模型适配、推理引擎选型、行为规则落地、边缘盒子部署这些硬骨头很少有文章系统讲清楚。我这次做的项目是某企业的实时行为分析系统需求是检测区域内的人员离岗、越界闯入两类行为部署在几台边缘盒子上整套链路用Java写模型用YOLOv8。项目做完复盘我把整个流程从模型准备到Java推理、再到行为逻辑和边缘调优按实操顺序整理成文希望能让那些同样被“Java怎么跑YOLO”卡住的人少走弯路。先交代一下背景和方案选型再说核心细节和坑点最后给一份可以直接照抄的排查清单。1. 项目整体设计与技术选型思路1.1 需求场景与核心目标项目需求来自某园区管理方需要在一套已有的Java后端系统上接入实时视频流完成两类行为识别第一类是人员离岗检测即某个工位、值班区域在设定时间内无人出现第二类是区域闯入检测即人员进入围栏、禁区等不允许进入的区域。所有检测结果要实时推送给业务系统并支持告警记录回溯。这个需求的关键约束有三点一是现有团队以Java为主不能引入Python微服务来增加运维成本二是检测设备是边缘盒子不是GPU服务器算力有限三是实时性要求高从视频帧采集到行为判定结果输出端到端延迟不能超过500毫秒。这三条约束直接决定了技术选型的方向模型必须轻量推理必须走Java原生链路行为逻辑必须在Java侧完成。我在方案设计阶段其实也纠结过两条路一条是Java进程通过HTTP/gRPC调用独立的Python推理服务另一条是完全Java化。第一条路开发速度快YOLO生态全是Python的调模型、改预处理都方便但引入了一个常驻Python服务部署时要多维护一套环境边缘盒子上资源本来就紧张多一个进程就多一分不稳定。第二条路前期要解决Java推理框架的兼容性问题但部署时一个JAR包搞定依赖干净长期维护省心。考虑到这类企业项目的生命周期普遍很长我最终选了完全Java化这条路。1.2 技术栈组成与适配逻辑这套系统的技术栈可以拆成四层每层选型都对应实际约束。模型层用的是YOLOv8n也就是YOLOv8的nano版本参数量最小COCO上mAP大概37.3精度足够应付人员检测这种单一类别场景但推理速度快在边缘CPU上也能跑起来。为什么不用YOLOv8s或者更大的版本因为边缘盒子的CPU算力就那么大模型大一倍帧率掉一半实时性就没法保证了。后面实测也验证了nano版本经ONNX优化后在RK3568级别的盒子上单帧推理能做到90毫秒左右完全够用。推理层对比过三个框架OpenCV的DNN模块、ONNX Runtime的Java API、Deep Java LibraryDJL。OpenCV DNN胜在集成简单但ONNX算子支持不全遇到一些新版本模型导出的算子会直接报错而且CPU推理优化一般DJL是亚马逊出的深度学习Java框架封装做得漂亮但中间多了一层抽象排查问题时要多绕一圈ONNX Runtime的Java API最底层也最直接模型加载、会话配置、输入输出绑定都透明可控。最终选了ONNX Runtime因为它是微软维护的推理引擎算子兼容性最好CPU推理做了指令集优化而且Java API是JNI直调C核心性能损耗可以忽略。版本用的1.16.1这个版本在Java 11下很稳定后面会有个坑和这个版本相关先按下不表。行为分析层完全自己写用Java实现计时器、区域判定、状态机。这部分是项目里最容易写烂的地方很多团队把行为分析塞到Python里用现成库做但咱们Java化路线里这些逻辑都是纯计算不依赖第三方库写起来反而顺手。部署层选了RK3568芯片的边缘盒子4核A55 CPU2GB内存跑Linux系统。选这个芯片一是成本低二是它带NPU虽然咱们这次没用NPU加速但后续如果要强化实时性可以把ONNX模型转成RKNN格式部署到NPU上这条升级路径是通的。内存2GB其实很紧张因为JVM堆加ONNX Runtime原生内存加OpenCV帧缓冲很容易爆这个后面单独讲。2. 模型适配与预处理全流程2.1 YOLOv8模型导出ONNX的版本坑YOLOv8训练用的是Ultralytics框架训练好的模型是PyTorch格式的.pt文件Java侧肯定不能直接加载第一步要导出成ONNX格式。导出命令本身不复杂一条yolo export modelbest.pt formatonnx就能搞定但里面的参数有讲究。第一个坑是opset版本。ONNX Runtime Java API默认支持的opset版本有范围导出时如果用了过高的opset加载会报“Unsupported model opset version”错误。Ultralytics默认导出opset是12这个版本太老有些算子优化做不了我实测用opset 12导出能跑但用opset 17导出后在ONNX Runtime里加载报错查了半天发现是1.16.1版本对opset 17的部分算子支持不完整最后锁定了opset 15稳定性最好推理速度也比opset 12快一些。第二个坑是动态输入尺寸。YOLOv8导出时默认支持动态输入也就是模型可以接受任意宽高的输入这在灵活性上很好但动态尺寸会带来一个性能问题ONNX Runtime在处理动态shape时无法做某些内存布局优化每次推理时Shape推断会额外耗时。咱们的场景输入源是固定分辨率的视频流完全不需要动态尺寸所以导出时必须把imgsz参数固定成训练尺寸。我用的是640x640这是YOLOv8训练的默认尺寸固定后模型文件更小推理更稳定。导出命令长这样yolo export modelbest.pt formatonnx opset15 imgsz640 simplifyTrue dynamicFalse这里simplifyTrue会调用ONNX Simplifier做图优化把一些冗余节点合并删掉模型体积能缩小不少推理速度也有轻微提升建议打开。dynamicFalse就是固定尺寸确定好输入是640x640后面所有预处理都要按这个尺寸来。2.2 输入图像预处理细节视频帧从摄像头拿到时是1920x1080的BGR帧要送进模型推理必须先做三步预处理resize到640x640、归一化到0到1区间、调整维度顺序为CHW。但直接暴力拉伸图片到640x640是绝对不行的那会破坏目标的宽高比检测精度会明显下降。正确做法是letterbox也就是保持宽高比的情况下把图缩放到640x640剩余部分用灰色填充。letterbox的代码逻辑不复杂但几个细节必须抠死。先计算缩放比例取宽的缩放比和高的缩放比中较小的那个然后按这个比例缩放图片再计算缩放后的图在目标画布上的偏移量最后把图片贴到画布上。偏移量要记录到全局变量里因为后面坐标还原时必须用同样的偏移量把检测框映射回原图坐标。public static float[] letterbox(Mat src, Mat dst, int targetWidth, int targetHeight) { int srcW src.cols(), srcH src.rows(); float scale Math.min((float) targetWidth / srcW, (float) targetHeight / srcH); int newW Math.round(srcW * scale), newH Math.round(srcH * scale); float dx (targetWidth - newW) / 2.0f, dy (targetHeight - newH) / 2.0f; Mat resized new Mat(); Imgproc.resize(src, resized, new Size(newW, newH), 0, 0, Imgproc.INTER_LINEAR); Mat canvas Mat.zeros(targetHeight, targetWidth, CvType.CV_8UC3); Rect roi new Rect(Math.round(dx), Math.round(dy), newW, newH); resized.copyTo(canvas.submat(roi)); canvas.copyTo(dst); resized.release(); canvas.release(); return new float[]{scale, dx, dy}; }替代方案里有一种是等比缩放后直接放在左上角不居中这样偏移量计算简单点但边界目标容易出问题尤其是靠近图像边缘的目标所以居中letterbox更稳妥。归一化就是每个像素值除以255。这里有个性能优化点用Java写两层for循环逐个像素归一化640x640就是40多万像素CPU耗时能到10毫秒以上太慢了。正确做法是利用OpenCV的convertTo函数一步完成数据类型转换和缩放。乘上1/255而不是除255是因为浮点乘法比除法快。转换后的CV_32FC3矩阵就是归一化后的图像。维度顺序也必须调整。ONNX模型的输入格式是NCHW也就是Batch、Channel、Height、Width而OpenCV的Mat默认是HWC布局。可以用OpenCV的blobFromImage函数一步完成减均值、缩放、通道转换、维度重排但要注意它默认会把图像从BGR转成RGB因为YOLO系列训练时用的是RGB输入。如果模型训练时本身就用BGR就不用转但YOLOv8官方权重是RGB训练的这个转换必须保留。2.3 模型输入输出的张量解析ONNX模型加载进ONNX Runtime之后要确认输入输出的张量名称和维度。YOLOv8的输出张量只有一个shape是1x84x8400。这个8400是模型在不同尺度特征图上预测的候选框总数84是4个框坐标值加80个类别置信度。咱们项目里只检测person一类所以解析时只要取第5个元素作为person的置信度其他79个类别可以忽略这样能省不少计算。有些资料会按旧版YOLO的输出格式解析比如三个输出头分别解析YOLOv8已经合并成单输出头了别搞混。拿到ONNX Runtime会话后可以用session.getInputInfo()和session.getOutputInfo()动态获取张量信息但更高效的做法是直接用已知的shape分配好Tensor分配缓冲避免每次推理都走一次反射查询。输入Tensor的形状是1x3x640x640float数组长度就是1228800输出Tensor的形状是1x84x8400float数组长度是705600。这两个大数组提前new好复用不要每帧都new不然GC压力会很大。3. Java推理引擎搭建与检测框后处理3.1 ONNX Runtime Java API的基本用法ONNX Runtime Java侧的核心类就几个OrtEnvironment、OrtSession、OnnxTensor。OrtEnvironment是环境单例一个进程创建一个就行创建开销比较大别每次推理都建。OrtSession负责加载模型和创建推理会话可以设置会话选项比如开启CPU优化线程数。OrtEnvironment env OrtEnvironment.getEnvironment(); OrtSession.SessionOptions options new OrtSession.SessionOptions(); // 设置线程数边缘盒子4核就设4不是越多越好 options.setIntraOpNumThreads(4); // 开启CPU优化的内存分配器避免频繁申请内存 options.addSessionConfigEntry(session.intra_op.allow_spinning, 1); OrtSession session env.createSession(modelPath, options);这里的线程数设置有个需要留意的点ONNX Runtime的intra-op线程是计算图内部算子并行用的理论上和CPU核数一致最好但实际操作中发现当视频流路数多的时候每路一个推理线程都设4个内部线程线程总数会爆炸CPU上下文切换开销巨大。后来我改成每路推理线程只设2个intra-op线程同时开两路视频流的推理并发总线程数和核数匹配吞吐反而上去了。这个参数需要根据实际部署的摄像头路数来调没有固定的标准答案。推理时的输入绑定也要注意。可以先从视频帧拉取一张Mat转成float数组再包装成OnnxTensor。这个转换过程涉及一次字节拷贝Java侧用ByteBuffer order(ByteOrder.nativeOrder())的方式能提升效率因为OnnxTensor的构造支持直接包装FloatBuffer这个内存是堆外内存拷贝路径更短。FloatBuffer inputBuffer FloatBuffer.allocate(1 * 3 * 640 * 640); // 填充预处理后的像素值注意这里的顺序是CHW try (OnnxTensor inputTensor OnnxTensor.createTensor(env, inputBuffer, new long[]{1, 3, 640, 640})) { OrtSession.Result result session.run(Collections.singletonMap(images, inputTensor)); float[][] output (float[][]) result.get(0).getValue(); // output[0]就是1x84x8400中的84x8400部分 }ONNX Runtime的Java对象基本都实现了AutoCloseable用完要close尤其是OnnxTensor和OrtSession.Result不close的话堆外内存会一直涨最后触发OOM。很多Java开发者没这个意识因为Java的GC管不了堆外内存。3.2 置信度过滤与坐标还原模型输出的原始结果是8400个候选框每个候选框有84个值其中第5个值索引4是person类的置信度。首先要做一个粗过滤把置信度低于0.5的候选框全部丢掉这一步能筛掉90%以上的无效框。过滤后剩下的框坐标是相对于640x640输入图的也就是letterbox之后坐标系里的值。要把这些坐标映射回原始1920x1080图像必须使用之前letterbox记录下的scale、dx、dy三个参数反向计算。float x1 (box[0] - dx) / scale; float y1 (box[1] - dy) / scale; float x2 (box[2] - dx) / scale; float y2 (box[3] - dy) / scale; // 裁剪到图像边界内避免坐标越界 x1 Math.max(0, Math.min(x1, frameWidth - 1)); y1 Math.max(0, Math.min(y1, frameHeight - 1));坐标还原这一步经常被忽略尤其是从网上抄代码的时候。如果直接拿模型输出坐标画框检测框位置全偏在原图上完全对不上。关键就在于letterbox的padding和scale必须同步保存、同步使用。3.3 NMS非极大值抑制手写实现粗过滤之后的候选框仍然会有一堆重叠框同一个目标可能被预测出好几个框需要做NMS把冗余框去掉。Python侧直接用torchvision.ops.nms一行搞定Java侧就得自己写。NMS的原理不复杂按置信度从高到低排序依次取最高分的框删除所有和它IoU大于阈值的框重复直到处理完所有框。public static Listfloat[] nms(Listfloat[] boxes, float iouThreshold) { // boxes中每个元素是{x1,y1,x2,y2,score} boxes.sort((a, b) - Float.compare(b[4], a[4])); Listfloat[] result new ArrayList(); boolean[] suppressed new boolean[boxes.size()]; for (int i 0; i boxes.size(); i) { if (suppressed[i]) continue; result.add(boxes.get(i)); for (int j i 1; j boxes.size(); j) { if (suppressed[j]) continue; float iou computeIoU(boxes.get(i), boxes.get(j)); if (iou iouThreshold) suppressed[j] true; } } return result; }IoU的计算就是两个框的交集面积除以并集面积注意边界情况两个框不相交时交集面积为0IoU为0不要出现负值。NMS实现里有个性能优化点值得说就是提前按置信度排序后再做双层循环剪枝。如果完全不排序直接两两比较时间复杂度是O(n²)且剪枝效果差。排序后每轮优先处理高置信度框很多低置信度框在第一轮就被抑制掉了后面循环次数能减少一半以上。实现NMS的时候还有个邻居坑Java的ArrayList.contains、remove这些操作在循环里性能不行用boolean数组标记抑制状态来避免频繁删除元素是实测最快的方式。这个版本比用removeIf能快3倍左右在高帧率场景下差距很明显。NMS的IoU阈值也是经常翻车的地方。YOLO官方默认是0.7边缘端检测人这种宽大目标0.5到0.6表现更稳因为人的检测框容易互相重叠阈值太高会导致一个行人被输出两三个框行为分析的区域判定就会误判。我最终设为0.5在密集人群场景下效果比较干净。3.4 推理线程与视频帧采集的并发模型实时视频流的处理不能每帧都在主线程里阻塞做推理否则视频采集会跟不上帧队列越来越长延迟越来越大。我设计的并发模型是这样的每路视频对应一个采集线程和一个推理线程采集线程负责从摄像头拉流、做预处理、放入一个有界队列推理线程从队列取帧、跑模型、做后处理、触发行为分析。队列容量设为2满了直接丢帧保证处理的是最新的帧而不是积压的旧帧。丢帧策略是有意为之。实时检测场景下如果推理速度跟不上采集速度等很久去处理一帧旧画面行为分析的实时性就没了用户是拿来看监控告警的不是看录像回放的。丢掉中间帧只处理最新帧牺牲一点连续性换来告警延迟的稳定这笔账怎么算都划算。队列用ArrayBlockingQueue这是一个有界阻塞队列线程安全且实现简单不要自己去写锁管理。Java的ConcurrentLinkedQueue虽然并发性能好但无界高帧率下内存会失控。4. 行为分析规则设计与实现4.1 区域闯入检测的几何计算区域闯入检测的前提是定义感兴趣区域也就是禁入区域。我用了多边形区域因为实际监控场景中禁入区域很少是矩形。多边形点的坐标从业务侧配置格式是经纬度或者像素坐标业务系统转成像素坐标下发到检测端。检测端要判断的是检测框的底边中心点是否落在多边形内。底边中心点而不是框中心这个细节很关键。人的检测框通常是从头到脚框的中心点在人体躯干位置而站立的人的“位置”应该以脚底为准。如果以框中心点为基准判断人站在禁区线外但身体探进禁区时就会被误判为闯入会产生大量无效告警。用底边中心点只有脚真正进入区域才算闯入这个语义和业务预期是一致的。判断点是否在多边形内的算法叫射线法也叫奇偶规则法。思路是从该点向右水平发一条射线统计射线与多边形边的交点数量如果是奇数个则点在多边形内偶数则在多边形外。Java实现大约二三十行就能搞定网上有现成模板注意处理射线恰好经过顶点的情况这时要做容差处理。public static boolean pointInPolygon(double px, double py, ListPoint polygon) { boolean inside false; int n polygon.size(); for (int i 0, j n - 1; i n; j i) { double xi polygon.get(i).x, yi polygon.get(i).y; double xj polygon.get(j).x, yj polygon.get(j).y; boolean intersect ((yi py) ! (yj py)) (px (xj - xi) * (py - yi) / (yj - yi) xi); if (intersect) inside !inside; } return inside; }4.2 离岗检测的计时器设计与内存管理离岗检测实现的是这样一套规则在指定区域内系统持续一段时间比如5分钟没有检测到人就触发离岗告警。这个逻辑拆解后实际上是两个子状态区域内有人和区域内无人。我用一个HashMapString, PresenceState管理每个检测区域的状态key是区域IDvalue是一个内部类包含当前状态、无人开始时间戳、告警状态标志。每帧推理结束后遍历所有配置区域检查区域内是否有检测框有就把时间戳清空并把状态置为有人没有就检查当前时间与无人开始时间戳的差值是否超过阈值。这里有个容易被忽略的问题如果区域本来就一直没人无人开始时间戳应该是什么时候如果从系统启动就开始算那刚启动5分钟就会触发一次离岗告警这在业务上不合理。正确做法是第一次检测到有人后系统才开始“计时”人离开后记录离开时间戳超过阈值才告警。也就是要有一个“系统性预热”阶段等第一次有人的状态出现后离岗检测才算生效。实现时用一个布尔变量记录该区域是否曾经出现过人即可。内部类里保存状态对象的引用区域ID做key。如果配置的区域数量不多这个Map的规模很小不会产生内存问题。但一个隐蔽的内存泄漏点藏在计时器的实现方式上如果用了ScheduledExecutorService每帧都创建一个定时任务去检查任务队列会堆积内存就爆炸了。正确做法是做轮询式检查用主循环同步检查不用Timer也不用Scheduled线程池。4.3 状态机与告警去重行为分析最忌讳每帧都触发告警。假设一个人站在禁入区域边缘不动如果每帧都判定为闯入并推送告警业务系统一分钟能收到几百条重复消息运维人员会直接疯掉。解决办法是给每个区域维护一个告警去重状态机。状态分四种NORMAL正常、ALARM_TRIGGERED告警触发中、ALARM_SILENT静默中、ALARM_RECOVERED恢复。从NORMAL到ALARM_TRIGGERED需要满足行为条件一旦触发记录告警时间并推送一次然后进入静默期静默期内即使行为持续满足也不再推送静默时间到了如果行为仍存在再次允许推送。当行为不再满足时状态回到NORMAL清除告警状态。静默期设多少合适实际项目里一般设30秒到60秒。太短了告警频率还是高太长真实事件时业务侧感知延迟大。最终这个项目里各区统一设30秒。这个状态机逻辑不复杂但它的价值很高是行为分析系统能不能被业务方接受的关键。很多初版系统都因为告警刷屏被业务方打回重做不是因为识别不准而是因为推送策略没做好。4.4 行为置信度与模糊场景处理真实监控画面里行为判定不是非黑即白的有很多模糊场景要处理。人的检测框闪烁是什么情况人明明站着没动检测框却一帧有、一帧无。如果直接按帧级判定“无人”这个状态会频繁闪断离岗计时会被反复重置导致离岗告警永远触发不了。解决思路是引入帧级确认机制不要求连续两帧都检测到人才认定有人而是统计最近30帧的检测命中率命中率超过阈值才更新状态。这个滑动窗口计数器可以用一个简单的环形数组实现或者用整数计数器和指数滑动平均。我实测下来的经验值是最近10帧中至少6帧有检测框才算有人低于这个阈值才算无人。这样单人快速走动、镜头轻微抖动导致的检测闪烁都能被过滤掉误差很小。同时这个确认机制引入的延迟很低10帧按30fps算也就是0.33秒对离岗检测这个场景来说可以忽略。5. 边缘部署实战与性能调优5.1 边缘盒子上的JVM参数与内存规划部署机器是2GB内存的RK3568盒子跑Java进程的同时还有系统服务和视频流拉流程序JVM堆内存必须严格控制。堆设多大我实测的分配方案是JVM堆最大512MBONNX Runtime原生内存约200MBOpenCV帧缓冲约100MB系统预留约500MB剩下约700MB给系统和其它进程。这个预算必须要提前算好不然运行几天后随机OOM崩溃会把人折磨死。JVM参数我最终配置如下java -Xms256m -Xmx512m -XX:UseG1GC -XX:MaxGCPauseMillis100 \ -XX:ExitOnOutOfMemoryError -XX:OnOutOfMemoryErrorkill -9 %p \ -jar detection-server.jar这里做了几个取舍。G1GC的停顿控制比较好能避免Full GC导致的推理延迟毛刺-XX:ExitOnOutOfMemoryError加上OnOutOfMemoryError的操作看起来有点粗暴但边缘盒子环境没有人为守护进程宕了反而可以被进程守护脚本自动拉起比卡死在那里强得多。这种策略适合这种无人值守的设备服务器上可能有更温和的降级策略但边缘盒子讲究的是快速恢复。5.2 多路视频流的资源分配模型项目实际接了四路摄像头。四路同时跑的话如果每路都开一个独立线程池做推理CPU资源会被争抢成一片混乱。我最后定下来的方案是全局共享一个固定大小4线程的推理线程池每路视频流提交推理任务到这个线程池任务内部带视频流ID推理完成后回调对应视频流的后处理逻辑。线程和资源分离的好处是能全局控制CPU负载。推理线程池的4个线程在四路视频流之间动态分配哪路画面复杂候选框多、NMS耗时长就多占一点时间片哪路画面简单就少占一点总的CPU使用率能稳定在85%左右不会出现某路视频把CPU吃满、其他路全部卡死的极端情况。四路视频在业务上有优先级差异比如主干道比停车场重要实现方式给线程池提交任务时加了一个优先级参数用ThreadPoolExecutor配合PriorityBlockingQueue高优先级视频的推理任务先执行。这个方案在实测里效果不错低优先级视频在最坏情况下退到大约12帧/秒高优先级视频始终稳定在18帧以上。5.3 推理耗时优化实例让我记录一组实测数据对边缘部署调优有参考价值。优化项优化前耗时毫秒优化后耗时毫秒说明模型导出固定尺寸11694去掉动态shape的动态推断开销ONNX Runtime线程数设为294101单路反而略慢多路场景总吞吐提升输入Tensor用FloatBuffer复用10193减少堆外内存重复创建NMS改成排序布尔标记9390候选框多时收益更明显预处理用convertTo替代逐像素循环11593这一步收益最大特别强调预处理那一步的收益。最初我用Java双层循环做归一化一张640x640图耗时接近22毫秒换成OpenCV的convertTo之后直接降到2毫秒左右整体帧率提升立竿见影。这个优化是最容易做、收益也最高的强烈建议优先做。5.4 服务化与告警推送检测服务对外提供两个接口一个HTTP接口用于配置区域、查询状态、拉取告警记录另一个是WebSocket接口用于实时推送告警消息到业务前端。边缘盒子上没跑数据库所有状态数据存在内存里重启后从业务侧重新拉取配置。告警记录写本地文件按天滚动同时通过HTTP回调推送给中心业务系统。告警消息的格式要设计好别只推一个文本消息。我的消息结构里带区域ID、行为类型、置信度、发生时间、触发帧的检测框坐标和截图路径前端可以直接在画面上画出告警位置和告警框这给业务方体验会好很多。截图保存需要注意存储空间。边缘盒子上存储有限截图不能无限存我按每个告警保存一张JPEG截图约200KB计算一天几十条告警一个月也就几百MB这块开销不算大但要在代码里加一个定期清理任务删除过期截图防止长时间运行后磁盘塞满。6. 常见问题与排查技巧速查6.1 高频问题汇总问题现象可能原因排查方法与解决模型加载报错提示opset版本不支持ONNX模型opset版本高于ONNX Runtime支持范围重新导出固定opset 15也可以升级ONNX Runtime版本推理结果全为零或置信度极低输入图像通道顺序错误BGR/RGB没转检查预处理时是否用了blobFromImage确认模型训练时的通道顺序检测框位置明显偏移letterbox的scale和padding没有在坐标还原时使用检查坐标还原公式确认用的是letterbox返回的scale、dx、dy运行几小时后内存持续增长OnnxTensor或Result没有close堆外内存泄漏检查所有推理路径确保try-with-resources释放视频延迟越来越大采集线程和推理线程之间用了无界队列换成有界队列容量设为2满了丢帧告警重复刷屏没有告警去重状态机实现状态机加静默期CPU占用100%但推理吞吐低线程池及ONNX线程配置不合理全局线程池限制总线程数intra-op线程数与CPU核数匹配摄像头偶发断流进程存活但无告警VideoCapture内部重连失败增加健康检查线程定期测试帧间隔超时自动重建VideoCapture6.2 三个容易被忽视的细节第一个是Mat.release()。OpenCV的Mat对象是C内存Java的GC管不到。如果不手动release哪怕是几百KB的Mat一秒钟30帧地造内存会涨得飞快。代码里所有new出来的Mat用完后都要release。配合try-with-resources不好封装因为Mat没有实现AutoCloseable但OpenCV Java版里其实有个try (Mat m new Mat())的用法通过让Mat实现AutoCloseable接口的方式可以用但要注意不同版本支持情况不同。保险起见严格在每个分支里release或者统一走一个工具类封装。第二个是ONNX Runtime的Session不是线程安全的。多路视频流共享一个OrtSession做并发推理会触发底层native断言错误甚至崩溃。要并发推理需要创建多个Session实例个数和核数匹配。也可以考虑用OrtSession.Result的clone来共享模型权重但这个API在不同版本行为有差异稳妥做法还是每路一个Session模型文件小加载快多几个实例内存开销不大。第三个是RTSP拉流稳定性。边缘盒子用OpenCV的VideoCapture拉RTSP流长时间运行很容易莫名其妙断流表现为读取超时、返回空帧。后来我用了一个偏方用cap.set(Videoio.CAP_PROP_BUFFERSIZE, 1)减少内部缓冲同时单独开一个线程监控帧间隔如果连续5秒没有新帧就销毁重建VideoCapture。这个方法上线后再也没有出现长时间黑屏无告警的情况。6.3 边缘盒子的散热与稳定性这个不属于软件但实际部署时它比软件更影响人。RK3568盒子是铝合金外壳被动散热满负荷运行CPU温度能到85度高温下芯片会降频推理延迟会从90毫秒飙到130毫秒实时性就崩了。后来加装了一个小的主动散热风扇温度压到65度性能稳定多了。有条件的话部署位置要选通风的地方不要塞进密闭弱电箱。边缘设备的物理环境属于部署方案的一环忽略它会埋雷。写在最后这套系统上线运行了几个月四路视频全天候跑人员检测的准确率在正常光线环境下能到95%以上离岗告警和闯入告警的准确率业务方也认可。给我最大的感受是Java做YOLO推理这条路完全走得通关键在模型适配和资源规划两个环节模型适配决定了检测准不准资源规划决定了系统能不能长期稳定跑。如果之后再做类似的系统我会优先考虑两条升级路径一是把ONNX模型转成RKNN格式跑NPU推理延迟能从90毫秒降到30毫秒级别可以支撑更多路数二是引入ByteBuffer零拷贝方案把摄像头帧从OpenCV直接映射到ONNX Runtime的输入Tensor去掉一次内存拷贝这条路对帧率达到30fps以上的场景很有意义。但这两条都是锦上添花当前这套架构在不升级硬件的前提下已经能满足绝大多数实时行为分析需求了。