ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Spring Boot集成ONNX Runtime:垃圾分类小程序模型部署全链路实践

Spring Boot集成ONNX Runtime:垃圾分类小程序模型部署全链路实践 简介这是一份基于深度学习的垃圾分类微信小程序课程设计源码面向计算机相关专业学生、毕设作者及小程序开发者帮助快速搭建从图像识别到移动端展示的完整方案。压缩包共46个文件、大小约1.33MB涵盖8个Java文件构成的Spring Boot后端、7个Python文件负责模型训练与部署、4个pth权重文件以及微信小程序前端所需的wxml/wxss/js文件同时包含json、properties、xml等配置文件和docx讨论文档、txt环境配置说明结构清晰便于按模块学习。项目源码均经本地编译可运行评审分达95分以上并附有过程讨论记录、环境配置、线上小程序码和汇报key能够帮助读者快速复现垃圾分类功能理解深度学习模型如何与微信小程序实际结合。目前已有101人学习适合作为课程设计或项目实战的高分参考。1. 垃圾分类小程序选型为什么模型部署链路要这样搭在垃圾分类投放点居民掏出手机拍一张就能得到「纸箱属于可回收物」的即时反馈这个场景天然适合微信小程序而不是独立 App——免安装、扫码即用相机和上传链路都是现成的。这套课程设计更值得拆的其实是部署侧Python 训练出来的深度学习模型不能直接在 Spring Boot 工程里跑于是源码把训练、模型导出、JVM 推理、小程序上传串成一条完整链路。对要做深度学习的课设、毕业设计或者第一次接触模型部署的开发者来说它的价值在于把「模型训练好」和「模型被用户用上」之间的空白补齐了。后面的内容会沿着分类模型训练、ONNX 导出、Spring Boot 推理接口再到小程序交互的顺序展开。2. Spring Boot 模型推理服务ONNX Runtime 与接口实现2.1 模型部署的三种路线对比训练好模型以后第一个问题就是「怎么被 Spring Boot 调用」。三种常见做法里子进程调用 Python 最直接Java 进程里起python classify.py简单但每次请求都背着 Python 解释器并发一高就扛不住部署环境还要装 CUDA、PyTorch 全家桶。独立 Python 推理服务用 FastAPI 单独起一个模型服务再让 Spring Boot 转发工程上解耦但这门课设只有一台 Linux 环境时维护两套服务没必要。用 ONNX Runtime 直接加载是最省事的一条路模型导出成 ONNX 后在 Spring Boot 进程内用 onnxruntime Java API 做推理省掉进程间通信性能也稳定。部署方式额外进程单请求开销部署复杂度适用场景子进程调用 Python是高解释器模型加载中快速原型验证独立 Python 推理服务是中网络JSON高模型与业务解耦、频繁更新模型ONNX Runtime 直载否低低中小型单体应用课设和内部工具这个项目目录里同时出现了Springboot应用和sort 模型训练和部署所以「Java 进程内接 ONNX」是让两段代码落在一台机器上的最短路径。onnxruntime 官方支持 Java模型导出一次后服务器上不需要再装 Python 深度学习框架。2.2 项目骨架与模型放置把训练好的garbage_sort.onnx放到后端项目的src/main/resources/models/下Spring Boot 打包时会自动打进 jar。模型文件几十 MB随应用一起发布没有压力。application.yml里做三处配置garbage: model-path: classpath:models/garbage_sort.onnx input-size: 224 # 必须与训练时的输入尺寸一致 classes: glass,paper,plastic,kitchen_waste,hazardousinput-size必须与训练时图片尺寸一致classes的书写顺序必须与训练时class_to_idx的映射保持一致否则返回的 label 对不上。这是我在这类项目里最容易踩的坑训练脚本里类别是「玻璃、纸张、塑料、厨余、有害」的自然顺序而ImageFolder.class_to_idx按文件夹名的字典序生成两个顺序不一致时接口返回plastic但实际图片是玻璃瓶。2.3 推理服务类图片预处理与张量组装Spring 的Service里持有一个GarbageClassifier在构造函数里加载 ONNX 模型。OrtEnvironment是全局单例整个进程只初始化一次不能每次请求重新创建。核心代码Service public class GarbageClassifier { private final OrtEnvironment env; private final OrtSession session; private final int inputSize; private final String[] classes; public GarbageClassifier(GarbageConfig config) throws Exception { // OrtEnvironment 是全局单例整个进程只初始化一次 this.env OrtEnvironment.getEnvironment(); this.session env.createSession(config.getModelPath(), new OrtSession.SessionOptions()); this.inputSize config.getInputSize(); this.classes config.getClasses().split(,); } public PredictResult predict(byte[] imageBytes) throws Exception { BufferedImage img ImageIO.read(new ByteArrayInputStream(imageBytes)); if (img null) { throw new IllegalArgumentException(图片解码失败检查上传格式); } BufferedImage normalized centerCropAndResize(img, inputSize); // 按 NCHW 布局填充: [1, 3, 224, 224]通道顺序是 RGB float[] tensor new float[1 * 3 * inputSize * inputSize]; int idx 0; for (int y 0; y inputSize; y) { for (int x 0; x inputSize; x) { int rgb normalized.getRGB(x, y); tensor[idx] (((rgb 16) 0xFF) / 255f - 0.485f) / 0.229f; tensor[idx] (((rgb 8) 0xFF) / 255f - 0.456f) / 0.224f; tensor[idx] ((rgb 0xFF) / 255f - 0.406f) / 0.225f; } } // 输入名必须是导出 ONNX 时指定的 input_names try (OnnxTensor input OnnxTensor.createTensor(env, tensor, new long[]{1, 3, inputSize, inputSize}); OrtSession.Result result session.run(Map.of(input, input))) { float[][] logits (float[][]) result.get(0).getValue(); return softmaxAndLabel(logits[0]); } } }Maven 引入com.microsoft.onnxruntime:onnxruntime即可版本用仓库当前稳定版。代码里有几个参数需要重点说明。getRGB返回的 int 是 ARGB按 R、G、B 三个通道顺序填入数组张量布局是 NCHW通道顺序写成 BGR 时分类结果会明显变差。mean 和 std 用的0.485, 0.456, 0.406与0.229, 0.224, 0.225是 ImageNet 预训练权重对应的一组归一化常数训练脚本用同一组导出后部署端也要一致。session.run里的输入名input对应 ONNX 导出时的input_names下一章导出时会再次对上。centerCropAndResize的常规实现是先取图片短边做中央裁剪再缩放到 224避免把垃圾周围的场景直接拉伸变形。softmaxAndLabel对 logits 先减去最大值再做指数防止溢出然后取 argmax 和对应概率。这段推理代码里还有一处容易被忽略的性能点OrtSession是线程安全的多个请求可以并发调用run所以不要在每个请求里 new session。模型加载通常在几百毫秒到一两秒放在构造函数里完成能避免第一个请求把超时时间耗光。2.4 Controller接收图片并统一返回结构接口侧用MultipartFile接收小程序传来的file字段PostMapping(/api/garbage/classify) public Result classify(RequestParam(file) MultipartFile file) throws IOException { if (file.isEmpty()) { return Result.error(400, 文件为空); } if (file.getSize() 5 * 1024 * 1024) { return Result.error(413, 图片不能超过 5MB); } long start System.currentTimeMillis(); PredictResult p classifier.predict(file.getBytes()); MapString, Object data new HashMap(); data.put(category, p.getCategory()); data.put(confidence, p.getConfidence()); data.put(elapsed_ms, System.currentTimeMillis() - start); return Result.ok(data); }name file要和下一章小程序wx.uploadFile的name保持一致。限制 5MB 防止用户选原图上传后 JVM 内存被大 Bitmap 占满Controller 不需要关心模型具体用了什么框架后续换模型只动GarbageClassifier内部实现。3. 垃圾分类模型训练MobileNetV2 迁移学习与 ONNX 导出3.1 轻量 CNN 选型的取舍垃圾分类任务和 ImageNet 1000 类分类不一样垃圾图片类别间差异有时很小比如透明玻璃瓶和浅色塑料瓶。选 MobileNetV2 有三个原因一是模型体积约 14MB适合作为资源文件打包进 Spring Boot二是在 CPU 上推理一张 224×224 图大约 50 到 100 毫秒接口体验可以接受三是它有 ImageNet 预训练权重小数据集上的迁移学习效果好于从零训练的深网络。ResNet50 精度略高但体积是 MobileNetV2 的三倍多推理耗时也更长EfficientNet-B0 精度不错但在 PyTorch 里加载权重和导出 ONNX 时版本兼容性问题多一些。课程设计阶段稳定跑通比刷高一个点更重要所以选 MobileNetV2。3.2 数据组织与数据增强不管自己拍照还是用公开数据集目录都按类别分成train和valdata/ train/ glass/ paper/ plastic/ kitchen_waste/ hazardous/ val/ glass/ paper/ ...torchvision.datasets.ImageFolder读这种目录最省事。训练预处理不能只做Resize还要加随机裁剪和颜色扰动因为小程序端拍出来的图有透视、有阴影、有反光# 训练阶段使用强增强模拟真实拍摄的透视和光照变化 train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])RandomResizedCrop的scale在 0.8 到 1.0 之间保证裁剪后物体仍占主体RandomRotation用到 15 度超过这个角度会出现较大黑边。验证集只做Resize(256)后CenterCrop(224)与训练分布保持一致但不再做增强否则验证准确率会失真。3.3 分阶段微调的训练脚本迁移学习不要一上来就全量训练。常见做法是先冻结特征提取层只训练新替换的分类头等损失降下来后再解冻整个网络用小学习率微调。model models.mobilenet_v2(weightsmodels.MobileNet_V2_Weights.IMAGENET1K_V1) num_classes 5 model.classifier[1] nn.Linear(model.last_channel, num_classes) for param in model.features.parameters(): param.requires_grad False optimizer torch.optim.Adam(model.classifier.parameters(), lr1e-3) criterion nn.CrossEntropyLoss() for epoch in range(10): model.train() for images, labels in train_loader: optimizer.zero_grad() loss criterion(model(images), labels) loss.backward() optimizer.step() # 解冻 backbone降低学习率继续微调 for param in model.parameters(): param.requires_grad True optimizer torch.optim.Adam(model.parameters(), lr1e-4)两个阶段的学习率相差 10 倍因为 backbone 已经学到通用视觉特征学习率太大容易把预训练权重冲坏。CrossEntropyLoss内部已包含 softmax 计算模型输出层不需要额外加 softmax保存权重时只存state_dict而不是整个模型便于后面切换到其他推理格式。常见的可调参数关系如下参数配置说明输入尺寸224×224与 ONNX 导出、Java 端预处理一致batch size32显存不足时降到 16学习率同步调小冻结阶段 epoch10分类头收敛即可不需要太长微调阶段 epoch20观察 val_acc 停滞就早停Adam 学习率1e-3 / 1e-4新头和 backbone 用不同学习率类别顺序按目录名排序class_to_idx与部署端一致训练环境配置里有一点需要注意PyTorch 版本与torchvision必须配套否则models.mobilenet_v2加载预训练权重会报错。用pip install torch torchvision同步安装一般不会出问题手工分开装才容易版本错位。3.4 导出 ONNX 并验证输出一致性训练完的 PyTorch 权重Java 侧没法直接加载标准做法是导出成 ONNX。导出前务必model.eval()让 BN 层使用推理统计量# 导出前必须切到 eval 模式 model.load_state_dict(torch.load(garbage_sort_best.pth, map_locationcpu)) model.eval() dummy torch.randn(1, 3, 224, 224) torch.onnx.export(model, dummy, garbage_sort.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}}, opset_version11)input_names和第 2 章session.run(Map.of(input, input))里的键名对应改任意一边都会报错。导出后先本地验证 ONNX 输出与 PyTorch 输出一致用 onnxruntime 加载模型对同一张图跑一次推理打印两个输出的差值小于 1e-5 才说明部署链路没有引入额外误差。真实场景里我还会对几张不同尺寸的实拍 JPG、PNG 做测试确认 Java 端ImageIO.read能正确解码避免训练集和部署端读图库不同导致的颜色通道差异。4. 微信小程序端识别实现wx.uploadFile、图片压缩与结果渲染4.1 页面注册与导航栏适配小程序项目在app.json里注册页面路径然后在pages/classify/classify页面上完成整个识别流程。顶部导航栏用默认样式就能满足要求只设置标题和颜色{ navigationBarTitleText: 垃圾分类识别, navigationBarBackgroundColor: #07C160, navigationBarTextStyle: white }如果做自定义导航栏需要处理胶囊按钮的位置和顶部导航栏高度不同机型的statusBarHeight不同。wx.getWindowInfo基础库 2.20.1 以上可以拿到状态栏高度再用wx.getMenuButtonBoundingClientRect取胶囊位置二者相加才是标题居中的偏移量。课设场景用默认导航栏更稳。4.2 选择图片并压缩用户拍照或从相册选图用wx.chooseMedia比旧版wx.chooseImage覆盖更多场景返回的tempFiles[0].tempFilePath可以直接交给image组件展示。chooseAndUpload() { wx.chooseMedia({ count: 1, mediaType: [image], sizeType: [compressed], sourceType: [album, camera], success: (res) { // 返回临时文件路径可直接用于上传和 image 展示 const filePath res.tempFiles[0].tempFilePath; this.setData({ imagePath: filePath }); this.uploadImage(filePath); } }); }sizeType: [compressed]让微信先输出一张压缩图原图 3MB 的照片通常能压到几百 KB。但compressed并不保证一定压缩某些 iOS 机型传回来仍是原图所以后端 5MB 限制不能省。4.3 wx.uploadFile 上传识别核心上传逻辑uploadImage(filePath) { this.setData({ uploading: true }); wx.uploadFile({ url: https://your-domain.com/api/garbage/classify, filePath: filePath, name: file, // 必须与后端 RequestParam(file) 一致 timeout: 15000, success: (res) { const data JSON.parse(res.data); if (data.code 0) { this.setData({ result: data.data, uploading: false }); } else { wx.showToast({ title: data.msg || 识别失败, icon: none }); this.setData({ uploading: false }); } }, fail: () { wx.showToast({ title: 网络异常请重试, icon: none }); this.setData({ uploading: false }); } }); }wx.uploadFile的关键参数参数值说明url后端接口全路径上线必须是 HTTPS 并在小程序后台配置合法域名filePath本地临时文件路径由wx.chooseMedia返回namefile与后端RequestParam一致timeout15000ONNX 推理通常在 200ms 内15s 足够formData无需要额外参数时在这里传res.data在大多数情况下是字符串JSON.parse后判断业务码而不是 HTTP 状态码。success和fail分支各写了一次uploading: false实际开发可以挪到complete回调统一处理避免上传失败时按钮一直处于禁用状态。4.4 结果渲染与低置信度兜底WXML 通过数据绑定展示result!-- 结果卡片渲染 -- view classresult-card wx:if{{result}} text classcategory{{result.category}}/text text classconfidence置信度 {{result.confidence}}/text text classelapsed识别耗时 {{result.elapsed_ms}} ms/text /viewresult.category在模型里是英文标签界面上准备一张映射表把glass显示成「玻璃」kitchen_waste显示成「厨余垃圾」。置信度低于 0.6 时我会在前端提示用户换角度重拍。垃圾分类的错分代价是投错桶与其给一个不可靠结果不如引导重拍这个逻辑放在前端处理就行。前后端字段对不齐是联调时最常见的问题。后端返回{code:0,data:{category:glass,confidence:0.97}}WXML 里写result.category一旦后端把字段改成label页面只显示空字符串而不会报错。我一般会先固定接口字段清单再同时对照小程序的data结构和后端PredictResult的字段名避免这种静默问题。5. 联调验证与线上部署的三处硬配置5.1 本地暴露与开发者工具设置本地 Spring Boot 监听 8080 时开发者工具可以请求http://localhost:8080但真机预览时 localhost 指向手机本身。常见做法是用内网穿透工具把本地端口暴露成临时公网地址再把https://xxxx.ngrok.io填到wx.uploadFile的 url。开发调试阶段在工具右上角勾选「不校验合法域名」可以绕过 HTTPS 域名校验上线前必须去掉。5.2 用抓包确认整条请求链路把 Charles 这类抓包工具接到微信开发者工具上过滤api/garbage/classify重点看三处请求是否带上了file字段、响应 JSON 有没有data.category、单次请求耗时。小程序端最常见的失败是errMsg: request:fail如果抓包根本看不到请求先检查域名校验和代理设置再确认后端进程是否活着。elapsed_ms长期大于 500ms 时回到模型侧看是否开了多线程推理以及图片有没有被压缩。5.3 Nginx 上传体积与超时配置小程序压缩后的照片一般几百 KB但总有用户走原图链路。Nginx 默认client_max_body_size是 1MB不调会直接返回 413。业务反代配置# 默认 1m上传原图会 413 location /api/garbage/ { client_max_body_size 10m; proxy_read_timeout 30s; proxy_send_timeout 30s; }Spring Boot 侧的spring.servlet.multipart.max-file-size也要同步放开两层限制缺一个都会表现为上传失败。接口稳定后用 curl 模拟小程序做最后验收# 用 curl 模拟小程序上传 curl -X POST http://localhost:8080/api/garbage/classify \ -F file/tmp/glass_bottle.jpg正常响应会是一个包含category、confidence、elapsed_ms的 JSON字段与小程序 WXML 中的绑定一一对应两边数据能对上这条训练到识别的完整链路就真正通了。本文还有配套的精品资源点击获取
返回列表