这个标题下的东西,我在最近一个边缘端项目里完整趟了一遍。网上搜“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 Library(DJL)。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 CPU,2GB内存,跑Linux系统。选这个芯片一是成本低,二是它带NPU,虽然咱们这次没用NPU加速,但后续如果要强化实时性,可以把ONNX模型转成RKNN格式部署到NPU上,这条升级路径是通的。内存2GB其实很紧张,因为JVM堆加ONNX Runtime原生内存加OpenCV帧缓冲,很容易爆,这个后面单独讲。
2. 模型适配与预处理全流程
2.1 YOLOv8模型导出ONNX的版本坑
YOLOv8训练用的是Ultralytics框架,训练好的模型是PyTorch格式的.pt文件,Java侧肯定不能直接加载,第一步要导出成ONNX格式。导出命令本身不复杂,一条yolo export model=best.pt format=onnx就能搞定,但里面的参数有讲究。
第一个坑是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 model=best.pt format=onnx opset=15 imgsz=640 simplify=True dynamic=False这里simplify=True会调用ONNX Simplifier做图优化,把一些冗余节点合并删掉,模型体积能缩小不少,推理速度也有轻微提升,建议打开。dynamic=False就是固定尺寸,确定好输入是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的形状是1x3x640x640,float数组长度就是1228800,输出Tensor的形状是1x84x8400,float数组长度是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 List<float[]> nms(List<float[]> boxes, float iouThreshold) { // boxes中每个元素是{x1,y1,x2,y2,score} boxes.sort((a, b) -> Float.compare(b[4], a[4])); List<float[]> 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的计算就是两个框的交集面积除以并集面积,注意边界情况,两个框不相交时交集面积为0,IoU为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, List<Point> 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分钟)没有检测到人,就触发离岗告警。这个逻辑拆解后实际上是两个子状态:区域内有人和区域内无人。
我用一个HashMap<String, PresenceState>管理每个检测区域的状态,key是区域ID,value是一个内部类,包含当前状态、无人开始时间戳、告警状态标志。每帧推理结束后,遍历所有配置区域,检查区域内是否有检测框,有就把时间戳清空并把状态置为有人,没有就检查当前时间与无人开始时间戳的差值是否超过阈值。
这里有个容易被忽略的问题:如果区域本来就一直没人,无人开始时间戳应该是什么时候?如果从系统启动就开始算,那刚启动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堆最大512MB,ONNX Runtime原生内存约200MB,OpenCV帧缓冲约100MB,系统预留约500MB,剩下约700MB给系统和其它进程。这个预算必须要提前算好,不然运行几天后随机OOM崩溃会把人折磨死。
JVM参数我最终配置如下:
java -Xms256m -Xmx512m -XX:+UseG1GC -XX:MaxGCPauseMillis=100 \ -XX:+ExitOnOutOfMemoryError -XX:OnOutOfMemoryError='kill -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 推理耗时优化实例
让我记录一组实测数据,对边缘部署调优有参考价值。
| 优化项 | 优化前耗时(毫秒) | 优化后耗时(毫秒) | 说明 |
|---|---|---|---|
| 模型导出固定尺寸 | 116 | 94 | 去掉动态shape的动态推断开销 |
| ONNX Runtime线程数设为2 | 94 | 101 | 单路反而略慢,多路场景总吞吐提升 |
| 输入Tensor用FloatBuffer复用 | 101 | 93 | 减少堆外内存重复创建 |
| NMS改成排序+布尔标记 | 93 | 90 | 候选框多时收益更明显 |
| 预处理用convertTo替代逐像素循环 | 115 | 93 | 这一步收益最大 |
特别强调预处理那一步的收益。最初我用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内部重连失败 | 增加健康检查线程,定期测试帧间隔,超时自动重建VideoCapture |
6.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以上的场景很有意义。但这两条都是锦上添花,当前这套架构在不升级硬件的前提下已经能满足绝大多数实时行为分析需求了。