简介:一套面向果园智能化管理场景的完整工程,围绕OpenCV、卷积神经网络(CNN)、物联网(IoT)与微信小程序,实现果实成熟度自动识别、远程监测和采摘指导。资源以Python和JavaScript源码为主,附有图片数据集、训练好的Keras模型及微信小程序前端代码,适合农业物联网开发者、深度学习初学者及移动端AI部署工程师参考。压缩包共937个文件,约39.86MB。913张png图片构成数据集样本;3个ipynb与1个py覆盖数据爬取、预处理、模型创建与训练保存流程;5个js、4个wxss、3个wxml组成小程序页面与逻辑;另含h5模型文件、json配置与jpg示例图。项目采用双卷积层、最大池化层和全连接层结构,借助L2正则化、Dropout与BN算法抑制过拟合,并通过OneNET云平台实现摄像头拍摄或本地图片输入后的结果上传。已有347人学习/下载。资源包含爬虫与数据预处理、模型创建与编译、模型训练及保存、结果上传、小程序开发五个功能模块,从数据集整理到云端上报再到移动端展示均可按模块逐步复现,可作为课程设计、毕业设计或实际农业智能化项目的改造底座。
1. 从果园到屏幕:这套果实采摘指导系统到底在解决什么
果园管理最烧钱的不是肥料,而是人工巡检。工人每天要在园区里转一圈,逐个果子看长势、估成熟度,再决定今天采哪片、留哪片。这个活儿既靠经验又靠眼力,新工人上手至少一个季度,老工人又不可能盯着每一棵树的每一个果。基于OpenCV+CNN+IOT+微信小程序这套组合做出来的智能果实采摘指导系统,就是把“经验”拆成可量化的视觉判断,让机器帮你把果子的位置、大小、成熟度先筛一遍,再把结果推送到果园管理人员的小程序上。整套链路从硬件到云端再到手机端都能落地,既有Python工程源码,也有JS工程源码,还有配套数据集和训练好的模型,适合想复现一套完整农业视觉方案的开发者。
这套系统最核心的思路并不复杂:用OpenCV做图像前处理和果实区域定位,用CNN对剪裁出来的果实区域做成熟度等级判断,用IOT设备采集田间实时图像并上传推理结果,最后用微信小程序做移动端展示和采摘指令下发。它会直接回答三个问题:果子在哪里、果子多大、现在采还是不采。
2. 整体架构与技术选型:一套系统四个模块,先定边界再定工具
2.1 系统链路怎么串,数据从相机到小程序要经过几道手
整套系统的数据流可以拆成四段:采集端、推理端、传输端、展示端。采集端通常是树间部署的USB摄像头或工业相机,接在边缘计算设备上,比如Jetson Nano、树莓派或者RK3588这类开发板。推理端跑OpenCV预处理和CNN推理,输出果实检测框、成熟度置信度。传输端通过MQTT协议把结构化结果推送出去,后端起一个服务把结果存库并转发给微信小程序。展示端就是小程序端,负责把检测框标注叠加到实时画面上,并对接采摘工单。
标题里同时出现OpenCV、CNN、IOT和微信小程序,最容易犯的错是把四个技术点平铺开,每个都做一点但没打通。我给这类项目的落地顺序建议是:先跑通最简单的本地闭环,也就是“摄像头拍一张图 → OpenCV定位 → CNN判断 → 结果存JSON”,这个闭环能在三小时内跑通,后面再逐步增加IOT传输和小程序展示。四个模块里,模型和数据集是价值密度最高的部分,IOT和小程序本质上是把模型的输出送到用户手上。
2.2 CNN模型选型:为什么推荐YOLO系而不是自己搭ResNet
标题里的CNN是一个统称,真到落地阶段,果实检测和成熟度判断往往用两个模型配合。第一个模型是目标检测器,负责在整幅果园图像里找到果实的位置和像素范围,推荐用YOLOv5或YOLOv8这类单阶段检测器,训练和部署资料多、社区成熟。第二个模型是成熟度分类器,对目标检测框裁出来的果实子图做分类,比如分为未熟、半熟、可采摘三档,这里可以用轻量CNN结构,比如MobileNetV3或EfficientNet-Lite。
我自己常用的分工方式是:目标检测用YOLOv8s做,因为果实目标小且密集,YOLO在COCO上预训练的骨干网络对小目标的迁移能力比自研CNN强很多;成熟度分类用MobileNetV3-Small,部署到边缘设备上单次推理只要几毫秒。两个模型分开还有一个好处,数据集可以分别扩充——检测模型需要的是带框标注的数据,分类模型只需要裁好的果实图和标签,不需要框。对很多拿不到大量标注数据的团队来说,分类标注成本远低于检测标注,分开训练是性价比最高的路径。
2.3 采集端硬件的现实约束:算力和供电决定你能跑什么模型
硬件选型没有银弹。Jetson Nano和树莓派4B都能跑YOLOv8s,但帧率差距明显,前者大约12到15帧,后者可能只有3到5帧。果实采摘场景并不需要实时视频流,通常5到10秒抓拍一张就足够,所以我一般会把推理频率降下来,把设备功耗和发热控制在可接受范围。摄像头方面,普通USB摄像头加红外补光在夜间场景基本够用,但如果要识别遮挡严重的果实,最好选带全局快门的工业相机,避免果实在轻微晃动时产生运动模糊。
关于数据集,标题里明确提到包含数据集。需要说明的是,公开的农作物数据集大多以苹果、番茄、草莓为主,如果你做柑橘或葡萄,建议先采集现场数据替代原模型。我之前接手过一个项目,模型在公开苹果数据集上测试精度有91%,换到果园实拍后掉到82%,原因就是光照角度和叶片遮挡分布不一致。唯一可靠的路径是先跑通流程,再花时间采集至少500张现场图片做标注微调。
3. 数据集标注与格式转换:从原始图片到能训练的YOLO格式
3.1 采集照片的命名规范和存储结构
数据是整套系统的地基。图片采集阶段要考虑到季节、天气、时段、距离、遮挡情况这些变量。我建议每个品种单独建目录,目录下按日期分子目录,确保同一个文件夹内图片的光照差异足够大。图片命名规则用日期加序号,比如20240615_apple_001.jpg,避免训练时因为文件名重复把数据集搞乱。
标注工具推荐使用LabelImg或X-AnyLabeling。标签类别建议这样设置:单类别fruit用于检测,成熟度分类在后续单独处理。为什么检测阶段只标一个类别?因为CNN成熟度分类器是独立训练的,检测阶段只需要把所有果实框出来,尽量减少标注成本。如果检测模型输出了多个类别,后续分类器还要做类别映射,增加一次出错机会。
3.2 将XML标注转换为YOLO训练格式的Python脚本
LabelImg默认导出Pascal VOC格式的XML文件,而YOLOv5和YOLOv8需要TXT格式,每行是class x_center y_center width height,坐标全部归一化到0到1之间。这个转换脚本是整个数据管线里最容易被忽略又最容易出错的环节,我直接给出一个能跑的版本:
import os import xml.etree.ElementTree as ET from pathlib import Path def convert_voc_to_yolo(xml_file, out_dir, class_names): tree = ET.parse(xml_file) root = tree.getroot() img_w = int(root.find('size/width').text) img_h = int(root.find('size/height').text) out_name = Path(xml_file).stem + '.txt' lines = [] for obj in root.iter('object'): cls_name = obj.find('name').text if cls_name not in class_names: continue cls_id = class_names.index(cls_name) box = obj.find('bndbox') x1 = float(box.find('xmin').text) y1 = float(box.find('ymin').text) x2 = float(box.find('xmax').text) y2 = float(box.find('ymax').text) # YOLO格式需要归一化中心点和宽高 x_center = (x1 + x2) / 2.0 / img_w y_center = (y1 + y2) / 2.0 / img_h w = (x2 - x1) / img_w h = (y2 - y1) / img_h # 防止边界框为0或越界 w = min(max(w, 0.0), 1.0) h = min(max(h, 0.0), 1.0) lines.append(f'{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}') if lines: with open(os.path.join(out_dir, out_name), 'w', encoding='utf-8') as f: f.write('\n'.join(lines)) if __name__ == '__main__': xml_root = 'path/to/annotations' out_root = 'path/to/labels' class_names = ['fruit'] Path(out_root).mkdir(parents=True, exist_ok=True) for xml_file in Path(xml_root).glob('*.xml'): convert_voc_to_yolo(str(xml_file), out_root, class_names)这个脚本的核心逻辑是把XML里的绝对坐标转成归一化坐标。一个最常见的坑是部分标注工具输出的width/height节点格式略有不同,有的用<width>在<size>里,有的直接用<image_width>,脚本里读取路径需要根据实际数据结构微调。判断转换是否成功,可以直接读取生成的TXT文件,检查每行数值是否都在0到1之间,以及坐标是否落在图像有效范围内。
3.3 YOLO训练过程中的数据增强参数怎么给
转换完格式,训练阶段的数据增强会直接影响模型精度。YOLOv5和YOLOv8都内置了多种增强策略,关键的几个参数要重点调:hsv_h控制色相偏移,果实颜色在不同光照下变化很大,我一般设0.015左右;hsv_s控制饱和度偏移,青苹果在低饱和和高饱和之间差异明显,设0.4到0.7之间;degrees控制旋转角度,果园相机安装角度可能不水平,旋转增强能提升鲁棒性,设5度到15度之间即可,旋转太大会产生大量无效背景。
帧尺寸参数也值得注意。imgsz决定训练时图片缩放到的尺寸,果实是小目标,我建议设为640而不是320,太小会丢特征。batch大小取决于显存,边缘设备训练一般不在本地做,而是在有NVIDIA显卡的服务器上训练完再导出模型。数据集划分保持训练集、验证集、测试集的比例在8:1:1左右,而且要确保同一棵树的照片尽量落在同一个集合里,避免数据泄漏导致验证集踩坑。
4. OpenCV视觉检测链路:从像素处理到果实区域定位
4.1 图像预处理:光照校正和颜色空间转换为什么放在CNN之前
CNN可以自己学习光照不变性,但前提是你的训练数据包含足够多光照变化。实际果园场景中,逆光和阴影的极端程度远超训练集覆盖范围,因此我坚持在CNN推理前用OpenCV做一层基础校正。经典做法是先做白平衡校正再转HSV空间,然后根据果实颜色设定HSV阈值来提取候选区域。
OpenCV读取图像的通道顺序是BGR,初学者最容易在这里翻车。用cv2.imread()读出来的数组通道顺序是BGR,如果直接用cv2.cvtColor(img, cv2.COLOR_BGR2RGB)转,再送进模型做标准化,顺序对了;但如果忘了转就送进去,模型看到的颜色通道是反的,CNN的精度会显著下降。这是一条写进我项目文档第一页的注意事项。
4.2 基于HSV色彩空间的果实候选区域提取
果实颜色通常是画面中最饱和的色块之一,通过HSV阈值分割可以把果实区域从背景里粗略抠出来。下面这段代码实现了亮度校正、HSV转换、掩膜提取和轮廓过滤:
import cv2 import numpy as np def preprocess_and_extract_fruit(image_path): img = cv2.imread(image_path) if img is None: raise FileNotFoundError(f'cannot read image: {image_path}') # 白平衡:用灰度世界假设校正色偏 result = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) avg_a = np.mean(result[:, :, 1]) avg_b = np.mean(result[:, :, 2]) result[:, :, 1] = result[:, :, 1] - (avg_a - 128) result[:, :, 2] = result[:, :, 2] - (avg_b - 128) img = cv2.cvtColor(result, cv2.COLOR_LAB2BGR) # 转HSV并提取果实色相范围 hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 红色果实示例:H范围为0-10和170-180 mask_red = cv2.inRange(hsv, (0, 60, 60), (10, 255, 255)) mask_red2 = cv2.inRange(hsv, (170, 60, 60), (180, 255, 255)) mask = cv2.bitwise_or(mask_red, mask_red2) # 形态学闭运算填补果实表面高光空洞 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2) # 轮廓提取并过滤过小面积噪声 contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) min_area = 500 boxes = [] for c in contours: area = cv2.contourArea(c) if area < min_area: continue x, y, w, h = cv2.boundingRect(c) boxes.append((x, y, w, h)) return img, boxes这段代码里有两个参数需要按实际场景调整。HSV色相范围直接决定哪些果实能被识别出来——红苹果H在0到10和170到180之间,但黄苹果的H在15到35之间。一个通用做法是先在测试图上用cv2.createTrackbar调出合适的范围,再固化到代码里,不要凭感觉给范围。形态学闭运算的kernel大小也是一个关键点,果实越大、kernel应该越大,否则表面高光区域会把单个果实切成多个碎块。
4.3 CNN推理前的裁剪、缩放与归一化细节
OpenCV定位到果实区域后,需要把每个检测框对应的区域裁剪出来,送进CNN成熟度分类器。这里有两个必须处理的细节。第一个是把裁剪区域的长宽比调整为正方形,因为CNN输入通常是固定尺寸比如224×224或96×96,直接resize会导致果实形变。常见做法是取检测框的中心点,按长宽中较大值扩展成正方形,再裁剪。第二个是归一化方式,PyTorch和TensorFlow的预训练模型各不相同,需要对每个通道减均值再除标准差,苹果数据集常用的均值是[0.485, 0.456, 0.406],标准差是[0.229, 0.224, 0.225]。
如果模型是用OpenCV的DNN模块加载ONNX文件,还要注意输入张量的布局是NCHW还是NHWC。OpenCV的cv2.dnn.blobFromImage默认输出NCHW格式,大多数ONNX导出模型用NCHW,这里容易在推理时报维度不匹配错误。我一般会在导出ONNX时固定输入尺寸和batch,比如torch.onnx.export(model, dummy_input, 'model.onnx', input_names=['images'], output_names=['output0'], dynamic_axes=None),固化尺寸能省掉大量部署期的麻烦。
5. 果实采摘指导系统落地中的5个常见坑:现象、原因与解决方案
5.1 模型在测试集上精度很高,实拍却几乎检测不到果实
这是复现这类项目时最典型的翻车现场。现象是模型在数据集验证集上mAP有0.87,拿到果园随手一拍,一张图只能框出一两个果。原因通常是训练集图片与实拍图片在以下至少一个维度上存在分布差异:光照强度、果实颜色成熟度分布、相机高度和角度、背景复杂度。解决这个问题没有捷径,唯一的可靠路径是采集现场数据并做增量训练。我经历过最狠的一次是,模型始终把遮阳网上的黑色阴影识别为果实,因为训练集里完全没有遮阳网这种纹理。
5.2 OpenCV掩膜把多个重叠果实识别成一个连通域
先看现象:两个果实靠在一起,HSV掩膜后形态学闭运算把它们连成了一个整体。原因在于闭运算的kernel过大,把果实之间的缝隙填掉了,导致后续cv2.boundingRect框出了包含两个果实的超大矩形。解决方法是先减小kernel尺寸,再看重叠度——如果两个果实有30%以上重叠,检测框会很难分离,此时应考虑直接用CNN检测模型而不是依赖HSV分割,因为YOLO这类检测器在训练阶段见过大量重叠实例,输出多个重叠框的能力比颜色分割强得多。
5.3 MQTT消息在小程序端时断时续,偶发丢消息
IOT模块最容易出的问题不在推理端而在传输链路上。现象是PC端订阅MQTT主题一切正常,小程序端偶发收不到消息。原因多数是QoS等级设置不一致,或者小程序端WebSocket连接在切换网络时没有重连机制。MQTT的QoS有0、1、2三个等级,我建议至少用QoS 1,保证消息至少送达一次。小程序端需要监听wx.onSocketClose并在回调里重新连接,否则网络抖动一次就永久掉线。另外注意MQTT broker的心跳间隔,很多broker默认60秒没消息就断开连接,小程序端要配置keepalive参数配合重连。
5.4 微信小程序真机预览正常,发布版本却空白或报错
这个坑看起来诡异,实际原因几乎都在合法域名配置上。真机调试模式默认关闭域名校验,所以你本地开发工具里一切正常;发布体验版时域名校验开启,小程序的wx.request和wx.connectSocket请求的接口域名必须在微信公众平台配置为request合法域名和socket合法域名。我在第一次交付小程序时就栽在这里,后台接口用的是IP加端口,微信根本不认。解决方法是使用带域名的HTTPS/WSS地址,并在微信公众平台后台的“开发管理-服务器域名”里加上对应白名单。
5.5 数据集图片数量很足,但训练时loss一直不下降
数据集有一千多张图,训练80个epoch后mAP只有0.3,loss曲线像心跳一样反复。原因排查顺序通常是:先检查标签和图片是否错位——XML转TXT时文件名前缀对不上,导致模型学到的是随机映射;再检查类别编号是否正确,class_names = ['fruit']只有单类,如果XML里混了其他标签,转换脚本会直接跳过,那些图片在训练时被当作背景样本,干扰极大。最后检查数据划分是否泄漏。这三个问题逐一排除后,loss曲线基本会恢复正常。
6. IOT数据联动与微信小程序端实现:从推理结果到可用的采摘指导
6.1 边缘设备推理结果如何通过MQTT推送出去
边缘设备上的Python推理脚本每5秒跑一次,检测到果实数量变化或成熟度分布变化时,就通过MQTT发布结构化消息。topic命名我建议按果园和地块分区,比如orchard/block1/fruit_status,这样小程序端可以按需订阅对应地块,避免收到全园区消息。payload用JSON格式发布,方便小程序端直接解析。下面的代码展示了MQTT发布端的关键逻辑:
import json import time import paho.mqtt.client as mqtt # 推理结果示例:人工构造一次检测输出 result = { "block": "block1", "timestamp": int(time.time()), "total_fruits": 156, "ripe_count": 89, "unripe_count": 67, "avg_bbox_size": 124, # 平均检测框面积,用于估计果实尺寸 "suggestion": "suggest_harvest" # 模型给出的采摘建议 } broker = "your-mqtt-broker-address" client = mqtt.Client(client_id="edge-001") client.username_pw_set("iot_user", "iot_password") client.connect(broker, port=1883, keepalive=60) payload = json.dumps(result, ensure_ascii=False) info = client.publish("orchard/block1/fruit_status", payload, qos=1) info.wait_for_publish() client.disconnect()MQTT发布端有几个参数和实际环境相关性很强。keepalive=60是心跳周期,如果网络不稳定,可以降到30秒避免broker误判设备离线。qos=1保证消息至少送达,虽然多了一次ACK往返,但数据量很小,延迟几乎无感。如果边缘设备断网重连,必须在on_connect回调里重新订阅主题,否则会出现设备在线但收不到消息的假象。生产环境建议把broker地址改为内网域名而不是IP,后续扩容或迁移时不需要改动边缘设备配置。
6.2 微信小程序端如何获取手机号并绑定地块权限
微信小程序登录与手机号绑定是果园管理系统的身份基础。微信官方在小程序端提供wx.login获取code,后端用code换openid,同时用button组件的open-type="getPhoneNumber"拉起手机号授权。这里要注意的是:
// 小程序端获取手机号授权按钮逻辑 Page({ data: { canGetPhone: false }, getPhoneNumber(e) { if (e.detail.errMsg === 'getPhoneNumber:ok') { // 取得动态令牌,传给后端换取真实手机号 wx.request({ url: 'https://your-api-domain/api/user/bind_phone', method: 'POST', data: { code: this.globalData.loginCode, phoneCode: e.detail.code }, success: (res) => { if (res.data.code === 0) { // 绑定成功,记录token并跳转到地块列表 wx.setStorageSync('token', res.data.token); wx.switchTab({ url: '/pages/block/block' }); } } }); } else { // 用户拒绝授权,提示通过其他方式登录 wx.showToast({ title: '需要手机号才能使用采摘指导', icon: 'none' }); } } });e.detail.code是动态令牌,有效期只有5分钟,后端拿到后需要立即调用微信接口换取手机号,不能存储后延迟处理。这个设计是为了防止手机号在传输过程中泄露,但如果你的后端没有接入微信接口,也可以降级方案:让管理员在小程序里手动录入手机号并关联微信openid,适合小规模果园试点。手机号绑定完成后,需要在后端做地块与用户的关系映射,管理员可查看全部地块,普通用户只能看自己负责的片区。
6.3 小程序端如何订阅并展示IOT推送的检测结果
小程序端实时数据展示有两种常见做法:一种是用wx.connectSocket连接MQTT over WebSocket,另一种是后端HTTP轮询。对果园场景来说,MQTT over WebSocket更合适,因为数据是事件驱动的,5到10秒更新一次,轮询会浪费大量请求。小程序原生Socket API在不同版本上有些差异,我习惯在项目中封装一层Socket管理模块,负责连接、重连、订阅topic和消息分发:
// 小程序端MQTT over WebSocket订阅示例 const mqtt = require('../../utils/mqtt.min.js'); function connectAndSubscribe(blockId) { const client = mqtt.connect('wss://your-mqtt-broker:8084/mqtt', { username: 'weapp_user', password: 'weapp_password', clientId: 'weapp_' + Date.now() }); client.on('connect', () => { // 订阅自己负责的地块主题 client.subscribe(`orchard/${blockId}/fruit_status`, { qos: 1 }); }); client.on('message', (topic, message) => { const data = JSON.parse(message.toString()); // 更新页面数据,这里触发setData渲染 this.setData({ totalFruits: data.total_fruits, ripeCount: data.ripe_count, suggestion: data.suggestion }); }); client.on('close', () => { // 断线重连,指数退避避免高频率重连 setTimeout(() => connectAndSubscribe(blockId), 5000); }); }小程序端的MQTT库有多个选择,体积和兼容性各有差异,选用时注意确认库文件大小不要超过小程序主包2MB限制,否则需要分包加载。断线重连这里用了固定5秒延迟,实际项目中我推荐指数退避策略,比如第一次5秒、第二次10秒、第三次20秒,避免大量终端同时重连打爆broker。服务端同时要注意鉴权,clientId不能写死,每次连接用随机串,否则多个用户共用一个clientId会导致broker踢掉旧连接。
6.4 一个能显著提升实战效率的调试技巧
整套系统联调时,最烦躁的环节是反复拍照、标注、训练、部署。我后来养成一个习惯:在OpenCV预处理阶段就叠加一个调试输出窗口,把HSV掩膜、检测框和CNN分类置信度同时可视化到一张图上,保存到本地。这样每次到果园现场采集数据时,当场就能判断到底是颜色分割参数不对、检测框偏移还是模型误判,不用回电脑前翻日志猜问题。这个“一次采集、当场可视化”的工作流,能省掉至少一半的往返调试时间。调试脚本用cv2.imshow加cv2.waitKey(0)逐帧查看,批量处理时则用cv2.imwrite输出到文件夹再快速翻阅。
回想我做过的那几个农业项目,每轮交付最花时间的其实不是模型训练,而是场景适配和数据修正。果园里的光线、作物品种、支架结构千差万别,没有任何一个预训练模型开箱即用。这套基于OpenCV+CNN+IOT+微信小程序的采摘指导系统,价值恰恰在于它给你搭好了一条完整的主干道,剩下你要做的就是把现场数据不断喂进去,让模型越来越贴近你那一亩三分地。希望帮到你。
本文还有配套的精品资源,点击获取