简介:在AI应用落地中,模型部署与后端服务整合常是开发者绕不开的关卡。以目标检测为例,YOLOv5等模型虽以Python训练为主,但借助ONNX Runtime,模型可无缝嵌入Java生态,既规避跨语言服务调用的运维负担,也贴合企业级后端的主流技术栈。本文从计算机视觉的基本流程切入,阐述图像预处理、推理引擎调用、结果后处理等核心原理,并说明该方案如何降低AI工程化门槛、提升系统可维护性。在农业信息化场景中,柑橘病虫害识别需求典型且落地价值高,通过Java后端接收叶片图像,自动输出病害类型、置信度及防治建议,形成闭环应用。文章围绕Spring Boot框架,给出完整的系统设计、关键代码与踩坑记录,为Java开发者探索AI应用提供一条务实路径,也为相关毕业设计或项目原型提供可直接参考的技术范本。 直接进入正题。今天想跟你聊的是一个我最近完整落地过的项目——基于Java技术的柑橘病虫害智能检测系统设计源码。这个项目把计算机视觉、深度学习推理、Spring Boot后端服务、以及前端展示完整串了起来,核心目标是解决柑橘种植场景下病虫害识别依赖人工、经验门槛高、效率低的问题。你给它拍一张叶片照片,系统返回这是什么病、什么虫、置信度多少,顺带给出防治建议,整个过程在Java技术栈内闭环,没有依赖Python服务。
这套东西适合谁?一种是Java出身、想往AI应用方向延伸的开发者,你不需要从头啃Python,直接用Java生态把模型推理跑起来;另一种是农业信息化相关的从业者或学生,需要一套可以直接改、能演示、能部署的检测系统做毕设或项目原型。文章里我会把设计思路、技术选型、核心代码、部署细节和踩坑记录全部拆开讲,代码部分可以直接抄,思路部分可以拿去面试讲。
这里先提醒一句:文章里涉及的所有代码和方案,都是我在实际项目中验证过的,但具体到你的数据集、模型版本、服务器配置,参数一定会有差异,我的建议是边读边对照自己的场景调整,不要无脑复制。
1. 系统整体设计与技术选型
1.1 为什么是Java而不是Python
这是这个项目被问得最多的一个问题。目前AI检测的主流生态确实在Python侧,PyTorch、TensorFlow、YOLOv5/YOLOv8这些训练框架全是Python接口。但实际做对外服务时,Java技术栈在电商、政务、企业信息化这些领域仍然占据大量存量市场,很多公司的后端基座就是Spring Boot,团队里Java工程师占比也远高于Python工程师。
所以这个项目一开始的定位就很明确:训练环节用Python跑通,部署和服务环节全部落到Java上。模型训练完成后导出成ONNX格式,Java端通过ONNX Runtime调用,这样Java服务直接加载模型文件做推理,完全不需要在服务器上额外部署Python环境,也避免跨语言调用的网络开销和运维复杂度。从工程角度讲,这种方案比“Java后端 + Python推理微服务”更轻、更稳、更容易维护。
我实际对比过两种方案的差异。方案A是Java服务通过HTTP调用Python推理服务,优点是模型管理灵活,缺点是多一个进程就多一个故障点,并发上来后网络开销明显,而且团队里如果没有Python运维经验,光环境依赖就能折腾两天。方案B就是本项目采用的ONNX Runtime直接嵌入Java进程,延迟低、部署简单,一次JAR包到处跑,很契合Java工程师的惯性思维。
1.2 核心架构与技术栈
整个系统的模块划分如下:
- 前端:Vue.js + Element UI,提供图片上传、检测结果展示、历史记录管理、病虫害知识库浏览。
- 后端:Spring Boot 2.7,提供RESTful API,负责文件接收、调用推理引擎、持久化检测结果。
- 推理引擎:ONNX Runtime Java API,加载YOLOv5导出的ONNX模型,执行目标检测推理。
- 图像处理:OpenCV Java(opencv-4.8.0),负责图片缩放、格式转换、色彩空间变换等预处理和后处理。
- 数据存储:MySQL + MyBatis-Plus,存储检测记录、病虫害词典、用户信息。
- 模型训练(离线):Python + YOLOv5,用于训练和导出ONNX,不属于在线服务链路。
技术选型的核心考量是减少系统复杂度。图像预处理如果用Java原生BufferedImage去做,YOLO输入要求的640x640缩放、RGB转BGR、归一化这些操作写起来很别扭,而且性能不好。OpenCV Java版虽然API丑了点,但底层是C++实现,处理大批量图像时的性能有保障。ORM选MyBatis-Plus是因为这个项目的数据表结构比较固定,用它的Wrapper构造查询条件非常快,省去大量XML配置。
2. 柑橘病虫害检测的核心技术链路
2.1 数据准备与模型训练要点
这个项目检测的目标包含常见柑橘病虫害:黄龙病、溃疡病、疮痂病、炭疽病、红蜘蛛、潜叶蛾、蚜虫等,模型采用YOLOv5s规模。训练数据来自两个部分:公开数据集(比如PlantVillage中柑橘相关类别)和实地采集。这里要特别强调一点,训练数据质量直接决定检测效果,我第一版模型用纯公开数据集训练,在实地拍摄的图片上表现很差,后来混入自己拍摄的叶片、果实、枝干图片做数据增强,mAP才从0.62提升到0.81。
标注工具用的LabelImg,标注格式为YOLO的txt格式,每行是“类别id 中心点x 中心点y 宽度 高度”,坐标值都是相对原图尺寸归一化到0到1之间。标注时候有几个细节值得注意:
- 叶片上的病斑如果是密集小点,建议框整个发病区域而不是每个点单独框,否则标签数量爆炸且模型很难收敛。
- 黄龙病的症状是叶片斑驳黄化,和缺素黄化很像,标注时只标确诊样本,模糊样本直接丢弃,避免引入噪声。
- 红蜘蛛危害的叶片背面有细小蛛网,拍摄时要翻面拍,否则模型学到的特征不对。
训练配置方面,我的做法是输入尺寸640x640,batch size 16,epoch 150,优化器SGD,初始学习率0.01,使用余弦退火调度。训练完成后导出ONNX时,YOLOv5提供了export.py脚本,命令大致是:
python export.py --weights best.pt --include onnx --opset 12这里有个关键点,导出的ONNX模型默认输出格式是三维数组(batch, 25200, 85)。其中25200是三个尺度特征图(80x80、40x40、20x40)的候选框总数,85表示4个边界框坐标(x_center, y_center, width, height)、1个目标置信度、80个类别得分。Java端拿到这个输出后必须做NMS(非极大值抑制)才能得到最终检测框,后面我会详细说。
2.2 Java端图像预处理与推理集成
很多初学者以为模型推理就是把图片丢给模型这么简单,实际操作中预处理和推理同样重要。ONNX模型要求输入是归一化后的浮点张量,形状是[1, 3, 640, 640],通道顺序是RGB,每个像素值除以255映射到0到1区间。但OpenCV默认读图是BGR通道,千万别忘了这一步转换。
这里给出一个我封装好的预处理核心方法,已经跑过大量图片验证:
public static float[] preprocess(Mat src) { Mat resized = new Mat(); Size size = new Size(640, 640); // 等比缩放 + 填充,避免拉伸变形导致检测精度下降 float ratio = Math.min(size.width / src.width(), size.height / src.height()); int newW = Math.round(src.width() * ratio); int newH = Math.round(src.height() * ratio); Mat resizedExact = new Mat(); Imgproc.resize(src, resizedExact, new Size(newW, newH)); // 创建640x640画布,填充灰色(114是YOLO训练时的默认填充值) Mat canvas = Mat.zeros(640, 640, CvType.CV_8UC3); canvas.setTo(new Scalar(114, 114, 114)); Mat roi = canvas.submat(0, newH, 0, newW); resizedExact.copyTo(roi); // BGR转RGB并归一化到[0,1] Imgproc.cvtColor(canvas, canvas, Imgproc.COLOR_BGR2RGB); float[] data = new float[3 * 640 * 640]; for (int c = 0; c < 3; c++) { for (int i = 0; i < 640; i++) { for (int j = 0; j < 640; j++) { double[] pixel = canvas.get(i, j); data[c * 640 * 640 + i * 640 + j] = (float) (pixel[c] / 255.0); } } } return data; }这里有个细节很多文章不会提:缩放时要用等比缩放,周围用灰色填充,而不是直接把图片强行拉伸到640x640。强拉会导致物体变形,模型训练时没见过这种变形输入,检测框会偏移,置信度也会掉。建议用灰色114填充,这是YOLO训练时采用的默认填充值,保持一致能减少分布偏移。
推理引擎的调用相对简单,ONNX Runtime对Java的支持已经很成熟。Maven依赖如下:
<dependency> <groupId>com.microsoft.onnxruntime</groupId> <artifactId>onnxruntime</artifactId> <version>1.16.3</version> </dependency>加载模型并执行推理:
OrtEnvironment env = OrtEnvironment.getEnvironment(); OrtSession.SessionOptions opts = new OrtSession.SessionOptions(); opts.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); OrtSession session = env.createSession("yolov5s.onnx", opts); OnnxTensor inputTensor = OnnxTensor.createTensor(env, data, new long[]{1, 3, 640, 640}); Map<String, OnnxTensor> inputs = Collections.singletonMap("images", inputTensor); OrtSession.Result results = session.run(inputs);说完推理再说后处理。ONNX输出的张量形状是[1, 25200, 85],需要拆解出每个候选框的信息,过滤掉置信度低的框,然后做NMS。NMS的原理是:先按置信度从高到低排序,取出最高置信度的框,删除所有与它IoU大于阈值的框,重复直到处理完所有候选框。Java实现NMS代码大概长这样:
public static List<Detection> postprocess(float[] output, float confThreshold, float iouThreshold) { int numBoxes = 25200; int numClasses = 80; List<Detection> candidates = new ArrayList<>(); for (int i = 0; i < numBoxes; i++) { float objConf = output[i * 85 + 4]; if (objConf < confThreshold) continue; float maxClassScore = 0; int maxClassId = -1; for (int j = 0; j < numClasses; j++) { float score = output[i * 85 + 5 + j]; if (score > maxClassScore) { maxClassScore = score; maxClassId = j; } } float totalConf = objConf * maxClassScore; if (totalConf < confThreshold) continue; float cx = output[i * 85]; float cy = output[i * 85 + 1]; float w = output[i * 85 + 2]; float h = output[i * 85 + 3]; candidates.add(new Detection(cx - w / 2, cy - h / 2, w, h, totalConf, maxClassId)); } return nms(candidates, iouThreshold); }2.3 检测结果的后处理与展示
模型检测出来的坐标是相对于640x640输入图的归一化坐标,展示到用户上传的原始图片上时,需要做反算。核心逻辑是根据预处理时计算出的缩放比例ratio和填充偏移量,把检测框映射回原图坐标:
float ratio = Math.min(640.0f / srcWidth, 640.0f / srcHeight); int padW = (int) ((640 - srcWidth * ratio) / 2); int padH = (int) ((640 - srcHeight * ratio) / 2); int x1 = (int) ((det.x1 - padW) / ratio); int y1 = (int) ((det.y1 - padH) / ratio); int x2 = (int) ((det.x2 - padW) / ratio); int y2 = (int) ((det.y2 - padH) / ratio);映射回来后,用OpenCV在原图上画矩形框和标签,Java实现为:
Imgproc.rectangle(src, new Point(x1, y1), new Point(x2, y2), new Scalar(0, 0, 255), 2); Imgproc.putText(src, label, new Point(x1, y1 - 5), Imgproc.FONT_HERSHEY_SIMPLEX, 1.0, new Scalar(0, 0, 255), 2);画完的图片转成Base64字符串返回给前端显示,同时把检测结果JSON存库,前端就能展示出“原图 + 标注框 + 病虫害名称 + 置信度 + 防治建议”的完整卡片。
3. 核心代码实现与系统集成
3.1 Spring Boot后端接口设计
后端接口围绕检测流程来设计,核心是上传接口。接收MultipartFile文件,保存到本地临时目录,调用DetectionService处理,返回检测结果。Controller层非常薄,把业务逻辑全放在Service层,方便单元测试。
@RestController @RequestMapping("/api/detect") public class DetectController { @Autowired private DetectService detectService; @PostMapping("/upload") public ResultBean<DetectResultVO> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return ResultBean.error("文件不能为空"); } if (file.getSize() > 10 * 1024 * 1024) { return ResultBean.error("图片大小不能超过10MB"); } DetectResultVO result = detectService.detect(file); return ResultBean.success(result); } }Service层则包含完整链路:转Mat、预处理、推理、后处理、保存记录、返回结果。
@Service public class DetectServiceImpl implements DetectService { @Override public DetectResultVO detect(MultipartFile file) { try { byte[] bytes = file.getBytes(); Mat src = Imgcodecs.imdecode(new MatOfByte(bytes), Imgcodecs.IMREAD_COLOR); if (src.empty()) { throw new BizException("无法解析图片,请上传jpg/png格式"); } // 预处理 int originalW = src.width(); int originalH = src.height(); float[] inputData = YoloPreprocessor.preprocess(src); // 推理 float[] output = OnnxInferenceEngine.infer(inputData); // 后处理 List<Detection> detections = YoloPostprocessor.postprocess(output, 0.25f, 0.45f); // 映射回原图坐标并绘制 List<DetectionVO> detVOList = new ArrayList<>(); for (Detection det : detections) { DetectionVO vo = mapToOriginal(det, originalW, originalH); detVOList.add(vo); } // 保存记录到数据库 DetectRecord record = new DetectRecord(); record.setImageBase64(Base64.getEncoder().encodeToString(bytes)); record.setDetectResult(JSON.toJSONString(detVOList)); record.setCreateTime(new Date()); detectRecordMapper.insert(record); return buildResult(record, detVOList); } catch (Exception e) { log.error("检测失败", e); throw new BizException("检测失败: " + e.getMessage()); } } }这块要注意一个问题:Tomcat默认上传大小限制是1MB,不配置的话大图片会直接报错。需要在application.yml里放开限制:
spring: servlet: multipart: max-file-size: 20MB max-request-size: 20MB3.2 推理引擎封装与线程模型
ONNX Runtime的Session不是线程安全的,但多个线程可以共用同一个OrtSession实例并发执行run方法,官方文档明确说明run是线程安全的。实际压测下来,OrtSession实例复用比每次推理都重建Session性能高一个数量级。所以我把Session初始化放在@PostConstruct里做成单例。
@Component public class OnnxInferenceEngine { private static final Logger log = LoggerFactory.getLogger(OnnxInferenceEngine.class); private OrtEnvironment env; private OrtSession session; @PostConstruct public void init() throws IOException { log.info("开始加载ONNX模型..."); env = OrtEnvironment.getEnvironment(); OrtSession.SessionOptions opts = new OrtSession.SessionOptions(); opts.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT); opts.setIntraOpNumThreads(4); // 模型文件放在resources/models目录下 InputStream modelStream = getClass().getResourceAsStream("/models/yolov5s.onnx"); byte[] modelBytes = IOUtils.toByteArray(modelStream); session = env.createSession(modelBytes, opts); log.info("ONNX模型加载成功"); } public float[] infer(float[] inputData) throws OrtException { OnnxTensor inputTensor = OnnxTensor.createTensor(env, inputData, new long[]{1, 3, 640, 640}); Map<String, OnnxTensor> inputs = Collections.singletonMap("images", inputTensor); try (OrtSession.Result results = session.run(inputs)) { OnnxTensor outputTensor = (OnnxTensor) results.get(0); return outputTensor.getFloatTensorData(); } } }并发模型上,我这里用的是Tomcat默认的200线程池,没有额外引入线程池,因为检测操作本身是CPU密集型的,再套一层线程池反而增加上下文切换开销。你如果遇到高并发场景,更有效的手段是加服务器横向扩展,或者引入Redis缓存相同图片的检测结果,而不是在单机线程数上死磕。
3.3 前端展示与交互
前端用Vue 2 + Element UI实现,页面结构非常直接:顶部是标题栏,中间是上传组件,右侧是检测结果卡片。上传后拿到的返回结构是这个:
{ "code": 200, "data": { "imageBase64": "/9j/4AAQSkZJRgABAQAAAQ...", "detections": [ { "className": "溃疡病", "confidence": 0.87, "x1": 120, "y1": 80, "x2": 240, "y2": 180, "advice": "发病初期喷施77%可杀得3000悬浮剂..." } ] } }前端拿到Base64直接赋给img标签的src属性,再拿detections数组在Canvas上画框。这里有个性能细节:base64图片字符串可能很大,几MB的图片转成Base64后直接存数据库会让MySQL表很快膨胀,我的做法是把原图存到服务器本地目录或对象存储,数据库只存图片路径和检测结果JSON,Base64只作为接口返回给前端即时展示,不落库。
4. 常见问题与排查实录
4.1 模型加载阶段就OOM
第一次在服务器上跑的时候,模型刚加载就报java.lang.OutOfMemoryError: Insufficient memory。排查后发现是JVM默认堆太小,ONNX Runtime加载模型时需要额外分配内存放模型权重和中间计算图,YOLOv5s模型虽然只有14MB左右,但优化后运行时会占用数百MB内存。解决办法分两步:
- JVM堆设置到2GB以上:java -Xms512m -Xmx2g -jar app.jar
- ONNX Runtime的SessionOptions里不要开全部优化(ALL_OPT),改成ENABLE_ALL也行但加载更慢,或者用DEFAULT级别,内存占用能少30%左右。
另外一个小技巧:如果你的服务器内存确实很紧张,可以换更小的模型,比如YOLOv5n,精度略降但推理速度提升明显,内存占用也会低很多。
4.2 同一张图片,Java推理结果和Python训练结果对不上
这是最坑的问题之一。Java端跑出来的检测框和Python端跑出来的结果有偏差,置信度低很多,甚至完全检测不到。我排查了整整一天,最后发现是图像预处理不一致导致的。
训练时用的预处理函数是YOLOv5官方datasets.py里的letterbox函数,细节包括:等比缩放、颜色填充、BGR转RGB、归一化。其中颜色填充的默认值是(114, 114, 114),我Java端第一版用的是0填充,就这一点差异,导致迁移学习后的模型在Java端置信度掉了将近0.2。
所以再次强调,Java端的预处理必须和训练时的预处理保持完全一致,顺序都不能乱:先resize,再pad,再转RGB,最后归一化。任何一步偏差都会体现在最终效果上,而且这种问题很难通过肉眼观察定位。
4.3 推理速度慢到不能接受
JVM推理一张640x640图片的时间,在不同环境差异非常大。我的开发机(MacBook Pro M1)上大约是40ms,但在公司一台2核4G的老服务器上跑了将近500ms。排查后发现是ONNX Runtime默认的线程数没有适配机器核心数,SessionOptions默认会使用所有CPU核心,在2核机器上反而因为线程竞争导致性能下降。
解决办法是把setIntraOpNumThreads显式设置为CPU核心数减1:
opts.setIntraOpNumThreads(Runtime.getRuntime().availableProcessors() - 1);实测在4核机器上,从默认的8线程改成3线程后,推理时间从240ms降到110ms。这个反直觉的优化点值得记下来。
4.4 数据库连接池耗尽
系统上线后跑了几天,突然出现大量“Connection is not available, request timed out”的报错。排查后发现是MyBatis-Plus默认的HikariCP连接池最大连接数是10,而我在Service层把图片上传后的耗时操作(推理、存库)都放在一个事务里,导致一个请求占用连接的时间过长,并发一高连接池就被打满。
解决思路有两个:
- 把推理过程放在事务外面,让数据库连接只服务于最短时间的插入操作。
- 调大连接池配置:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000两个方案我都用了,目前压测200并发稳定运行,没有再出现连接池问题。
4.5 类别ID与病虫害名称没对上
YOLO模型输出的类别ID是从0开始的整数,训练时类别顺序是:0黄龙病、1溃疡病、2疮痂病、3炭疽病、4红蜘蛛、5潜叶蛾、6蚜虫。Java端配置类别映射表时一定不能错位,否则会出现把蚜虫识别成溃疡病的低级错误。我这里直接写在枚举类里:
public enum CitrusDiseaseEnum { HUANGLONGBING(0, "黄龙病", "目前无有效治疗药剂,发现病株建议及时挖除并销毁,防止木虱传播"), KUIYANGBING(1, "溃疡病", "喷施77%可杀得3000悬浮剂或20%噻菌铜悬浮剂,每7-10天一次,连续2-3次"), CHUANGJIABING(2, "疮痂病", "春梢萌发期喷施70%甲基托布津可湿性粉剂800倍液"), TANJUBING(3, "炭疽病", "发病初期喷施25%咪鲜胺乳油1000倍液,清除病叶病枝"), HONGZHIZHU(4, "红蜘蛛", "喷施5%尼索朗乳油2000倍液,注意叶片背面喷施到位"), QIANYEE(5, "潜叶蛾", "喷施20%灭幼脲悬浮剂3000倍液,控制夏梢和秋梢"), YACHONG(6, "蚜虫", "喷施10%吡虫啉可湿性粉剂2500倍液,保护瓢虫等天敌"); // ... }这个映射表在前后端都要用,建议后端统一输出中文名称,前端只负责展示,避免两边各维护一份映射后期对不上。
4.6 部署时OpenCV依赖缺失
Java端用到的OpenCV不是纯Java实现,需要加载native库。用Maven引入opencv的Java库后,运行时还需要把对应的.so(Linux)或.dll(Windows)或.dylib(macOS)动态库放到java.library.path下。手动管理这套库很容易踩坑,我推荐用以下方式:
<dependency> <groupId>org.openpnp</groupId> <artifactId>opencv</artifactId> <version>4.7.0-0</version> </dependency>这个依赖会自动把对应平台的native库解压出来,省去手动的烦恼。如果服务器是Linux ARM架构(比如树莓派),可能需要手动编译OpenCV,这个场景比较少见,就不展开讲了。
4.7 上传图片尺寸过大导致前端卡死
用户手机拍的照片动辄4MB、5000x3000像素,前端直接显示Base64会非常卡,Canvas绘制也要算半天。我的方案是后端返回检测结果前,先生成一张压缩后的标注图(宽度限制在1280px以内),再转Base64返回:
Mat shown = new Mat(); float showRatio = 1280.0f / src.width(); if (showRatio < 1.0f) { Imgproc.resize(src, shown, new Size(1280, (int) (src.height() * showRatio))); } else { shown = src; }这样返回给前端的图片基本在200KB以内,加载速度体感提升非常明显。
5. 这套系统的扩展思路
检测系统跑通之后,我发现它其实可以延伸出很多实用的方向。核心的检测引擎已经稳定,业务层可以随便加:
- 接入物联网设备:配合田间摄像头定时采集叶片图片,自动上传系统检测,实现全天候病虫害监测预警,新增数据达到阈值时通过短信或企业微信推送告警。
- 增加防治知识图谱:检测出病虫害后,结合天气数据(温湿度、降雨量)推荐最佳施药窗口期,这是植保站和农业公司非常关注的功能。
- 溯源与统计报表:按种植基地、时间段统计病虫害发生趋势,生成热力图和趋势图,为农药使用和采购决策提供依据。
这些扩展在架构上都不需要动推理引擎,只需要在业务层加表和接口,这也是当初把系统拆分成“检测核心”和“业务应用”两层的原因。我个人觉得这套思路比单纯做一个调通即完事的Demo有价值得多。
回到工程本身,最后再分享一点与需求侧打交道的感受:真正到果园里去和种植户聊,你会发现他们最关心的不是算法mAP多高,而是“测出来准不准、操作方便不方便、能不能告诉我该打什么药”。所以这个项目我在后端做了一件事,就是把检测结果直接映射到对应的农药建议和防治措施,用户不用去百科查这是什么病、怎么治,系统直接告诉他答案。这个“最后一公里”的体验设计,在项目验收和实际使用中获得的正面反馈比模型准确率提升0.05还要有用。
实际开发中踩过的坑比我上面写的还要多两倍,但前面这几条是最典型、最影响上线成败的。如果你正在做类似的项目,建议先跑通最小闭环(上传图片、推理、返回结果),再逐步加装饰功能。别一开始就想着分布式、微服务,一个几百亩果园的监控量,单体Spring Boot应用完全够用。这也是我一直坚持的做事方式:能用简单方案解决的,绝不为了技术炫技引入复杂度。
本文还有配套的精品资源,点击获取