PCB视觉检测这几年在电子制造里基本成了标配,但大多数公开案例都是Python一条龙:训练脚本、推理服务、Web框架全堆在一起。真到了产线落地,要跟已有的Java后端体系对接、要做数据落库、要搞实时告警,Python方案往往就显得很“孤岛”。所以我这次直接选了Java + YOLOv11 + Spring Boot 3.4这套组合,把检测链路完整落到一套可维护的服务架构里。
这个项目的核心目标是:给一条PCB生产线的AOI检测环节做一个替代人工复判的自动识别系统。产线相机拍下板卡图像后,系统自动识别短路、断路、缺件、偏移、锡珠等缺陷类型,把结果写进MySQL做追溯,同时通过Redis支撑实时统计和阈值告警,最终把异常工单推给产线看板。整套流程从模型训练到服务上线,全部由我一个人从零搭建,没有复用别人半成品。
本文面向的是:有一定Java基础、想了解深度学习模型如何用工程化方式嵌入业务系统的开发者,以及正在评估PCB缺陷检测落地路径的制造行业从业者。我会把模型侧和服务侧的每一个关键决策、每一步实操都摊开讲,包括那些文档里不会写的坑。
1. 项目整体架构与选型思路
1.1 缺陷检测的需求边界与技术路线
PCB缺陷检测和通用目标检测最大的区别在于:缺陷种类多、面积小、对比度低,而且误报成本极高。少检一个短路可能会让整批板卡报废,多报一个假缺陷又会拖垮产线效率。所以技术选型上首先要回答三个问题:模型能不能识别小目标?推理延迟能不能跟上产线节拍?结果能不能被业务系统快速消费?
我的方案拆成四层:图像采集层(产线相机或本地图片上传)、推理服务层(YOLOv11模型加载与检测)、业务处理层(Spring Boot处理检测结果、落库、规则判断)、告警展示层(Redis做实时计数与状态缓存,配合SSE推送到看板)。这套分层的好处是每一层都能独立压测和替换,比如前期没有真实相机时,可以直接用图片上传接口模拟。
1.2 为什么是YOLOv11而不是老牌YOLOv5或RCNN系
说实话,两年前我自己做第一版检测时用的是YOLOv5s,精度在常见缺陷上不错,但对密集小目标(比如0.5mm线宽上的断路)表现一般。YOLOv11在C3k2模块和C2PSA注意力机制的加持下,小目标特征提取明显更强,而且anchor-free设计省掉了大量锚框调参工作——这对工程落地非常重要,因为产线上的缺陷尺寸分布非常不均匀,锚框参数很难一次调好。
更关键的是,YOLOv11在推理速度上几乎没有牺牲精度的代价。用同一批PCB缺陷测试集对比,v11s的mAP50比v5s高出3到4个点,而单张640×640推理耗时在GPU上只多了不到2毫秒。在产线节拍是“每20秒出一张板”的场景下,这个余量非常充足。
1.3 Java技术栈各司其职的逻辑
模型推理选型上有三条路:Java直接调ONNX Runtime、调用独立的Python推理微服务、用DJL封装PyTorch模型。我最终选了ONNX Runtime Java API,原因很务实:少了Python进程间通信的延迟和运维负担,一个JVM进程里既能跑推理又能跑业务逻辑,部署就是打一个Jar包的事。
Spring Boot 3.4在这里不只是Web框架,它是整个检测结果的编排中心。MySQL负责缺陷记录、工单、设备的维度存储;Redis负责两块脏活:一是检测结果的实时计数(比如当前班次某类缺陷出现频次),二是告警去重和冷却窗口的控制。MySQL扛持久化,Redis扛时效性,各管一摊,互不干扰。
2. YOLOv11模型训练与工程化落地
2.1 数据集准备:PCB缺陷样本从哪里来
PCB缺陷数据集最大的痛点就是“不够脏”。公开的PCB数据集大多是实验室环境下拍摄的,光照均匀、背景干净,但产线真实图像有反光、有油污、有传送带震动拖影。我当时的做法是把公开数据作为预训练基础,再从产线收集了约5000张真实图像做补充,两类缺陷样本做到每类不低于800张。
标注环节我用的工具是labelImg,格式导出为YOLO的txt格式,每个缺陷类别对应一个id。这里有个重要经验:对于重叠缺陷(比如短路区域同时有锡珠),标注时只标主要缺陷,而不是画多个重叠框。否则训练出来的模型在预测阶段会输出大量低置信度的重复框,NMS反而会把正确结果滤掉。缺陷按实际产线命名习惯分成六类:short_circuit、open_circuit、missing_part、shift、solder_bridge、scratch,编号0到5。
2.2 训练参数设置与效果评估
训练配置上我用了YOLOv11s作为主力模型,输入尺寸640×640,batch_size设为16,epochs设200轮。优化器用默认的SGD配合warmup策略,前3轮warmup让学习率从0缓慢爬升到0.01,避免初始梯度震荡。数据增强打开了mosaic、flipud、hsv_h等,但对flipud要谨慎:PCB板卡有明确的丝印方向,垂直翻转会让字符朝向失真,模型可能学到错误特征。我实测下来flipud打开后误检率大约上升了1.2%,果断关掉。
最终模型在验证集上的指标:mAP50为0.972,mAP50-95为0.891,单张推理平均耗时9.8ms(NVIDIA T4)。这个精度对产线复判场景已经够用,但注意mAP高并不代表产线没有问题——后面我会单独讲真实场景下阈值怎么调。
2.3 模型导出为ONNX并与Java衔接
训练完成后,模型导出是关键一环。我直接用ultralytics包导出ONNX格式:
yolo export model=best.pt format=onnx opset=12 simplify=True导出后要检查ONNX的输入输出结构,特别是输出维度。YOLOv11的ONNX输出通常是一个1×84×8400的Tensor,其中84表示4个框坐标 + 80个类别得分,8400是三个尺度特征图上的预测框总数。但因为我只训了6类,所以输出是1×10×8400。
这里有个绕不开的坑:ONNX Runtime直接输出的是原始预测张量,需要自己写后处理代码做解码、过滤和NMS。后处理的逻辑是:从8400个候选框中筛掉置信度低于阈值的,再做类别级别NMS,最后把归一化坐标换算回原图尺寸。这段逻辑我会在Spring Boot服务里用Java重写一遍,下一节具体说。
3. Spring Boot 3.4 后端服务实现
3.1 工程初始化与依赖引入
项目用Maven管理,Spring Boot版本3.4.x,Java版本21。ONNX Runtime的Java依赖坐标直接用官方提供的artifact,版本要和导出ONNX时的opset兼容,建议选一个较新的稳定版本,避免旧版不识别新算子。
<dependency> <groupId>com.microsoft.onnxruntime</groupId> <artifactId>onnxruntime</artifactId> <version>1.19.2</version> </dependency>另外还用了Spring Data JPA操作MySQL、Spring Data Redis操作Redis、Spring Web的SSE做实时推送。Spring Boot 3.4里SSE的接口写起来非常简洁,用SseEmitter就可以实现服务端向浏览器/看板单向推送。这个组合没有引入额外重型中间件,部署上就是一个Jar包加一个本地Redis,产线环境友好很多。
3.2 模型加载与推理核心代码
模型加载做成单例Bean,Spring容器启动时就加载ONNX文件,避免每次请求都重新读文件。OrtSession不是线程安全的,所以推理时要用线程池做并发控制,我用Executors.newFixedThreadPool(4),配合一个阻塞队列处理请求。
推理流程的代码骨架如下:
@Service public class InferenceService { private final OrtSession session; private final OnnxTensor inputTensor; public DetectionResult detect(BufferedImage image) { // 1. 图像缩放至640x640并做letterbox填充 // 2. 转换为CHW float数组,归一化到0~1 // 3. 创建OnnxTensor,shape为 [1,3,640,640] // 4. session.run() 得到float[][][]原始输出 // 5. 后处理:置信度过滤 + NMS } }这里要重点提醒letterbox的实现。很多人在Java里直接image.getScaledInstance(640, 640)缩放,这会改变图像宽高比,导致检测框坐标映射错位。正确做法是等比缩放后,在短边用灰色(RGB 114)填充,推理完成后做坐标逆映射时去掉填充区域。这段代码网上很多版本写得不对,我排查过一个误检问题,最后定位就是填充值用成了黑色,导致图像边缘的暗色缺陷全被误检。
3.3 坐标映射与业务实体落库
检测结果映射回原图坐标是另一个雷区。ONNX输出的坐标是letterbox填充后的归一化坐标,必须做两步逆变换:先乘640得到填充图上的像素坐标,再减去填充偏移除以缩放比例。我封装了一个DetectionBox类,里面直接存储原图坐标、置信度、类别,方便前端画框和数据库查询。
数据库建模方面,我设计了四张核心表:检测记录表(defect_record)、图片表(defect_image)、设备表(device_info)、告警工单表(alert_order)。检测记录表是核心,字段包括:图片ID、设备ID、缺陷类别、置信度、x坐标、y坐标、宽度、高度、检测耗时、创建时间。索引上对create_time和defect_type建了联合索引,因为最常见的查询是“某个时间段内某类缺陷出现了多少次”,没有这个索引,数据量到百万级后会明显变慢。
3.4 检测接口的完整调用链路
对外暴露的检测接口是一个POST接口,接收MultipartFile图片,内部流程如下:
接收图片 -> 保存原图到本地磁盘 -> 缩略图生成 -> 推理检测 -> 结果落库 -> Redis计数更新 -> 规则引擎判断是否告警 -> SSE推送结果这个流程中,保存原图和缩略图是必须的,因为产线需要事后追溯“当时到底板卡长什么样”,光靠数据库里的坐标框没法复现现场。缩略图我控制在200KB以内,作为列表页的快速预览,原图按日期分目录存储。图片存储路径与数据库记录通过一个唯一UUID关联,避免文件名冲突。
4. Redis实时告警链路与性能优化
4.1 Redis在链路中承担的两类职责
第一类职责是实时计数。产线看板需要展示“本班次各缺陷类型的出现次数”,如果每次都去MySQL里COUNT(*),在每分钟几十条检测记录的频率下虽然也能扛住,但看板是每秒刷新的,长期跑下来会对数据库造成不必要的压力。所以我用Redis的INCR命令维护每个缺陷类型的计数Key,例如defect:count:shift_20250101:open_circuit,每小时或每班次结束再异步把计数批量写回MySQL持久化。
第二类职责是告警去重和冷却。同一块板卡如果在短时间内连续检出同类缺陷,系统不应该反复发告警刷屏。做法是:每次命中告警规则时,先尝试写入一个带过期时间的Redis Key,比如alert:cooldown:device_03:short_circuit,过期时间设为120秒。只有写入成功(即Key之前不存在)才真正触发告警推送,否则直接忽略。这个机制用的是SETNX的原子性,天然天然免疫并发问题。
4.2 告警规则的动态配置与实时推送
告警规则我没有写死在代码里,而是设计成数据库表+缓存的方式。规则的字段包括:设备ID、缺陷类型、阈值数量、统计窗口(比如10分钟)、冷却时间、是否启用。当检测记录落库后,规则引擎会做两件事:一是统计当前窗口内的缺陷次数,二是对比阈值判断是否触发。
规则判断的统计次数也放在Redis里,用ZSet按时间戳存储每次检测事件:
// 记录一次缺陷事件 stringRedisTemplate.opsForZSet().add( "defect:event:" + deviceId + ":" + defectType, String.valueOf(System.currentTimeMillis()), System.currentTimeMillis() ); // 查询窗口内次数 Long count = stringRedisTemplate.opsForZSet().count( "defect:event:" + deviceId + ":" + defectType, startTime, endTime );为什么用ZSet而不是简单计数?因为滑动窗口要支持“删除窗口外的旧事件”,ZSet按时间戳排序后可以用removeRangeByScore把过期的成员清理掉,统计永远只针对有效窗口。这个设计在产线“某台设备连续3块板卡都出现同一缺陷”的场景下特别好用,能精准抓到设备状态异常,而不是单个板卡的偶然问题。
SSE推送部分,我用SseEmitter维护了一个连接池,检测结果一旦落库就向该设备关联的所有前端会话推送一条JSON消息,格式如下:
{ "type": "DEFECT_ALERT", "deviceId": "AOI-03", "defectType": "short_circuit", "confidence": 0.95, "imageUrl": "/images/20250101/a1b2c3d4.jpg", "timestamp": "2025-01-01 10:23:45" }前端看板收到消息后立即弹窗并播放声光提示,整个链路从图片上传到看板弹出实测在1秒内完成,其中模型推理占9.8ms,通信开销几乎可以忽略。
4.3 性能瓶颈排查与参数调优
整套服务上线初期遇到过两个性能瓶颈。第一个瓶颈是ONNX Runtime的线程配置。默认情况下OrtSession会占用CPU的所有物理核,在产线服务器上跟其他业务进程争抢资源。解决方式是创建Session时指定OrtSession.SessionOptions的setIntraOpNumThreads(2),把推理线程数限制住,实测在双路服务器上整体吞吐反而更稳定。
第二个瓶颈是Redis连接池。Spring Boot默认的Lettuce连接池在短连接高并发下会出现连接等待,我把连接池最大连接数调到了50,并且把检测记录的Redis写入改为批量Pipeline模式——每10条记录或每500毫秒批量刷一次。这里有个取舍:批量写入会增加一点内存开销,但对Redis的压力大幅下降,产线这种写入频率下非常划算。
5. 实际落地中的问题排查与经验沉淀
5.1 高频问题速查表
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 检测框整体偏左上/右下 | letterbox填充偏移计算错误 | 统一用填充后坐标减去填充偏移再除以缩放比 |
| 同一缺陷输出多个重叠框 | NMS的IoU阈值设置过松 | 把NMS的IoU阈值从0.5降到0.45 |
| 反光板面误检率飙升 | 训练数据缺少反光样本 | 采集时增加不同光照角度,并在训练集做亮度增强 |
| Redis告警频繁误触发 | 冷却Key过期时间设置过短 | 按产线节奏把冷却时间调至120秒以上 |
| SSE连接频繁断开 | 代理层空闲超时 | 前端加心跳重连,服务端设置更长的超时时间 |
| 连续推理后内存缓慢上涨 | OrtSession输入Tensor未关闭 | 每次推理后调用OnnxTensor.close()释放native内存 |
内存泄漏那个问题最隐蔽。ONNX Runtime的Java绑定时,OnnxTensor持有的是native内存,GC管不到,如果不显式close,时间长了JVM的堆内存没涨多少,但容器内存持续攀升,最后触发OOM。排查这类问题要用jstat配合top看进程RES内存,不能只看堆内存。
5.2 真实产线环境比实验室残酷得多
模型精度在实验室测得很理想,一上产线就露馅。最大问题是光线:PCB板面的反光会让局部区域过曝,缺陷特征被高光吃掉。我当时的对策是给产线相机加了偏振滤光片,同时把模型推理前的预处理改成可配置参数,在检测接口里支持传入曝光增益参数,不同产线可以单独调。这个改动很小,但对误检率的改善非常明显。
另外一个容易被忽略的问题是图像的色彩空间。相机输出的是RGB,但有些工业相机直接输出Bayer RAW格式,如果服务端不做正确的颜色转换,模型看到的图像和训练时的分布完全不同。我在接口层加了一个图像格式校验,非RGB格式一律先转换再送推理,避免脏数据进入模型。
5.3 个人实操中的几个关键心得
第一,不要把YOLO当黑盒。模型输出的原始张量、NMS逻辑、坐标解码这些后处理代码,我强烈建议自己写一遍。原因有两个:一是第三方库的后处理实现差异会导致结果不一致;二是出现问题排查时,不懂解码逻辑的人会完全无从下手。我在这套系统里把后处理全部用Java重写了,虽然只写了不到200行,但对整个系统的掌控感完全不同。
第二,监控先行。我上线第一周就加了三个监控:推理耗时P95、检测结果积压队列长度、Redis连接池使用率。没有这些指标,产线一有波动你根本不知道是模型变慢、队列积压还是Redis扛不住。我用简单的定时任务把指标写入日志,再用Prometheus+Grafana做了可视化看板,虽然前期多花了一天时间,但后面救了我无数次。
第三,数据库和缓存的一致性要想清楚。这个系统的数据流是“检测结果先写Redis做实时展示,再由定时任务批量写MySQL做持久化”。如果服务中途宕机,Redis里可能会有少量未落库的记录。我的处理是加了一个本地文件补偿日志,定时任务启动时优先补录上次未完成的批次。产线场景可以接受最多几秒的数据丢失,但绝对不能接受重复计数。
这套系统从零开始到产线试运行,前后花了大概三周,其中模型训练和调参占了大头,服务端开发和对接其实很快。现在每天稳定处理上千张板卡图像,告警准确率比人工复判提高了不少,最直接的收益是复判人员的劳动强度降下来了,缺陷批次拦截做到了秒级响应。我个人最大的体会是:深度模型落地到业务系统,难点从来不在训练出一个高精度模型,而是如何用工程手段把模型“嵌”进一个稳定、可维护、可观测的生产链路里。如果你也在做类似的项目,建议先别急着堆功能,把模型后处理、坐标映射、告警去重这三块打扎实,整个系统的地基就稳了大半。