先交代一下背景。我接触深度学习目标检测有一段时间了,一直想把YOLO系列用在某个具体场景里,而不是永远跑公开数据集。刚好有位做果园种植的朋友跟我抱怨,采摘季最耗人力的环节不是摘果,而是分拣:同一片园子,向阳面和背阴面的果子成熟度能差出两个等级,工人靠肉眼一个个看,又慢又不稳定。于是我用YOLOv11做了一套苹果成熟度识别检测系统,从数据标注、模型训练到UI界面和登录注册界面全部跑通,Python项目源码加模型打包在一起。这篇文章把整套流程和踩过的坑都拆出来,从零讲清楚。
1. 为什么选苹果成熟度识别这件事来落地
1.1 一个非常现实的需求场景
先聊需求拆解。苹果成熟度不是非黑即白的问题,果园分拣时通常要按成熟程度把果子分成几个等级,分别入库、定价、安排发货时间。太生的果子口感差,客户不满意;熟过头的果子运输途中容易碰伤腐烂。所以成熟度分级越精细,损耗越小,收益越稳。
这套系统的目标很直接:给定一张苹果图片,模型自动判断每个苹果属于哪个成熟度等级,并画出边框、给出置信度。我把成熟度定义成四个类别:green(未熟)、half(半熟)、ripe(成熟)、overripe(过熟)。四分类方案在训练时类间区分度相对清晰,实测也比三分类更好收敛,不会出现“半熟夹在生熟之间不知道归哪边”的尴尬。
1.2 为什么绕开传统方案,直接选YOLOv11
传统图像处理做成熟度判断,最典型的方法是用颜色阈值分割,HSV空间里挑出红色区域占比。这在实验室条件下能跑通,一到自然光照就崩,阴影、逆光、树叶遮挡都会让阈值失效。
目标检测的思路完全不同:模型直接学习“什么形状、什么颜色、什么纹理的苹果对应什么成熟度”。选型时我对比过Faster R-CNN、YOLOv8和YOLOv11。Faster R-CNN精度不错但推理太慢,分拣场景根本跟不上。YOLOv8够用,但既然官方发布了新版,我直接上了YOLOv11。它主要改进了主干模块和检测头设计,在同样权重体积下,小目标检测和边界回归精度比上一代更稳。对苹果这种密集、遮挡多、目标有重叠的场景,这个进步是实打实的。
2. 苹果成熟度数据集的搭建全过程
2.1 数据采集策略:不要只顾数量,要顾分布
数据集是整个项目的根基,这一步做不好,后面模型怎么调都白搭。我一开始犯了典型错误:大量采集熟透的红苹果图,绿色未熟果只顺手拍了几十张。结果训练出来的模型对green类别几乎不响应,因为样本太少,模型根本学不到代表性的特征。
后来重新梳理采集策略,按四个类别均衡采集:
- green(未熟):青绿色果皮,果形偏小,常见于树冠内部、背阴面;
- half(半熟):开始转色但没全红,黄绿色和浅红色相间;
- ripe(成熟):果皮大面积鲜红或粉红,光泽度好;
- overripe(过熟):颜色暗沉,果皮可能有褶皱或斑点。
每个类别最终收集了400到500张图片,总共1600多张。采集时故意覆盖不同时间段和天气:晴天直射光、阴天散射光、清晨侧光、傍晚逆光,还拍了一部分带树叶遮挡的。别小看这个细节,苹果成熟度识别最大的干扰就是光照,模型如果只在一种光线下见过苹果,换到现场就完全不认了。
2.2 标注环节的实操细节
标注工具我用的是LabelImg,开源、简单,导出格式直接支持YOLO的txt格式。标注时有一个容易被忽略的点:只框苹果本体,不要为了省事把树枝、叶子一起框进去。遮挡严重的苹果,按能看到的轮廓尽可能完整框起来;完全被叶子挡住的不要标,不然会引入大量背景噪声。
标注完成后,每一张图片对应一个同名的txt文件,内容格式是:
类别编号 cx cy w h四个类别的编号我固定为0、1、2、3,对应green、half、ripe、overripe。坐标是归一化之后的中心点坐标和宽高,取值范围都在0到1之间。这个格式是YOLO训练的标准输入,后续不需要转换。
2.3 数据增强与数据集拆分
YOLO官方仓库自带增强策略,mosaic、翻转、旋转、亮度调整这些默认就开了一部分。我在自定义数据集上额外关注的是颜色扰动。苹果成熟度识别本质极度依赖颜色,如果增强力度太猛,把红色调成偏绿,反而会误导模型。所以亮度调整我给的范围控制在正负20%,色相调整幅度设得非常小,保持真实感。
数据集拆分按8:1:1分成训练集、验证集、测试集。
| 数据集 | 图片数 | 标注目标数 |
|---|---|---|
| 训练集 | 约1280张 | 约2000个 |
| 验证集 | 约160张 | 约250个 |
| 测试集 | 约160张 | 约250个 |
拆分时我特别注意:同一个枝头连续拍摄的相似照片不能同时出现在训练集和测试集,模型会“记住”而不只是“学会”,测出来的指标虚高,一到真实场景就露馅。
3. YOLOv11训练全流程与调参记录
3.1 环境准备与数据集配置文件
硬件方面,训练用了一块显存8GB的GPU,跑YOLOv11s绰绰有余。软件环境是Python 3.9、PyTorch、官方YOLO库。安装简单,直接按官方文档装就行,这里不多说。
数据准备好之后,需要写一个数据集配置文件,告诉模型去哪里找数据、分成几类、类名是什么。我的apple.yaml长这样:
path: G:/apple_project/dataset train: images/train val: images/val test: images/test nc: 4 names: 0: green 1: half 2: ripe 3: overripe3.2 训练命令与参数取舍
一切就绪后,执行:
yolo detect train data=apple.yaml model=yolo11s.pt epochs=200 imgsz=640 batch=16 patience=50 project=apple_training name=run1几个关键参数我解释一下:
- model=yolo11s.pt:使用官方预训练的small版本。预训练权重包含了大量通用视觉特征,尤其是边缘、纹理、颜色组合的底层知识,可以让模型在一个相对好的起点上继续学习苹果的特征;
- imgsz=640:输入图片统一缩放到640x640。苹果目标不算特别小,640足够,改大到1280会明显增加显存消耗,收益不大;
- patience=50:训练过程如果连续50个epoch验证集loss不下降,就提前停。这个参数帮我省了很多时间,实际训练到第96个epoch就触发了早停;
- batch=16:8GB显存下这个值稳定。想再快可以梯度累积,但没必要。
训练过程中我会盯两个点:一是训练loss和验证loss是否同步下降,如果验证loss先降后升,说明过拟合;二是各类别的mAP50指标是否均衡发展。初次训练时我发现half类别的mAP只有0.7左右,其他三类都能到0.9以上。原因就是半熟样本数不足,而且半熟在外观上和green、ripe都有重叠,模型学习起来确实吃力。
3.3 训练结果怎么判断好不好
训练结束后,官方工具会自动输出一堆结果曲线和指标文件。我最关心的指标是mAP50(IoU=0.5时的平均精度)和mAP50-95(不同IoU阈值下的综合平均精度)。
最终最好的模型权重保存在best.pt,我用它在测试集上验证:mAP50达到0.93,mAP50-95达到0.74。如果只看数字,这个模型已经算能用了。但数字背后有一个隐患我必须提醒:四分类模型容易把“半熟”和“成熟”混淆。这个在PR曲线上看得出,两个类别的曲线交叠比较多。后面实测也印证了这一点,我单独列成避坑小节讲。
4. 注册登录界面与UI检测界面的完整实现
4.1 登录注册模块:很多人忽略的工程化细节
一个能被真正拿去用的模型系统,不能只写个脚本跑完就拉倒。我的方案是做一个桌面应用,带登录注册界面,进入主界面后才能选择图片、视频或摄像头进行实时检测。这套界面用PyQt5实现。
登录注册模块核心逻辑如下。用户信息存SQLite数据库,密码用哈希处理,不存明文。注册时写入用户表和登录时间,登录时校验账号密码,并区分管理员和普通用户两种角色,管理员可以看到检测历史记录,普通用户只能检测。
import sqlite3 import hashlib def register_user(username, password, role="user"): conn = sqlite3.connect("apple_system.db") cursor = conn.cursor() hashed = hashlib.sha256(password.encode()).hexdigest() cursor.execute( "INSERT INTO users(username, password, role) VALUES(?, ?, ?)", (username, hashed, role), ) conn.commit() conn.close() def check_login(username, password): conn = sqlite3.connect("apple_system.db") cursor = conn.cursor() hashed = hashlib.sha256(password.encode()).hexdigest() cursor.execute( "SELECT role FROM users WHERE username=? AND password=?", (username, hashed), ) result = cursor.fetchone() conn.close() return result[0] if result else None4.2 主界面与检测流程对接
登录完成后进入主界面。界面布局我做成三块:左侧是图片选择区和检测参数区(置信度阈值、IoU阈值,可手动调整),中间是图片显示区,右侧是检测结果列表和统计信息。
检测逻辑的核心代码是这样组织的:加载一次best.pt模型,后续所有检测都复用同一个模型实例,避免重复初始化拖慢响应。
def load_model(self): self.model = YOLO("best.pt") def detect_image(self, image_path): results = self.model.predict( source=image_path, conf=self.conf_threshold, iou=self.iou_threshold, verbose=False, ) result = results[0] boxes = result.boxes # 提取每个检测框的坐标、类别、置信度 for box in boxes: cls = int(box.cls) conf = float(box.conf) label = self.model.names[cls] if label in ("ripe", "overripe"): self.mature_count += 1 # 绘制画框逻辑略我在这里加了两个业务逻辑细节。一是统计成熟度占比,主界面实时显示green、half、ripe、overripe的饼图;二是把置信度低于阈值的检测框单独标灰,提醒使用者“这个框不太确定,需要人工确认”,而不是直接展示高置信度结果,容易误导。
4.3 线程设计:为什么界面会卡成PPT
第一次把UI和模型对接时,检测大图时界面会卡住,鼠标拖不动,标题栏提示“无响应”。原因是模型推理在PyQt主线程里跑,主线程被阻塞,窗口事件循环处理不过来。
解法是用QThread把耗时推理扔到子线程,推理完成后通过信号把结果传回主线程刷新界面。这是所有带UI的模型系统都要过的坎,不处理没法用。
from PyQt5.QtCore import QThread, pyqtSignal class DetectThread(QThread): result_ready = pyqtSignal(object) def __init__(self, model, image_path): super().__init__() self.model = model self.image_path = image_path def run(self): results = self.model.predict(source=self.image_path, verbose=False) self.result_ready.emit(results[0])4.4 项目文件结构一览
整个项目的核心文件如下:
apple_ripeness_system/ ├── main.py # 程序入口 ├── login_window.py # 登录注册界面 ├── main_window.py # 检测主界面 ├── detect_thread.py # 检测线程 ├── model/ │ └── best.pt # 训练好的权重 ├── dataset/ │ ├── images/train # 训练图片 │ ├── images/val # 验证图片 │ ├── images/test # 测试图片 │ └── apple.yaml # 数据集配置 └── requirements.txt # 依赖列表运行方法非常直接:
python main.py5. 实测效果、优化过程与踩过的坑
5.1 训练阶段最大的坑:半熟果识别率上不去
系统搭完后,我拿朋友果园里的真实照片做了一次批量测试,发现一个严重问题:真正的半熟果,很大比例被识别成了green或者ripe,half类别的召回率只有0.6左右。排查之后,我发现两个原因叠加。
第一个原因是数据层面的半熟样本不够多元。四个类别中half的自然样本最少,采集难度也最大,因为果子转色期就那么几天,过了就全红了。补采不现实,我只好从已有的成熟果图、青果图做颜色插值合成了一部分半熟样本,再用亮度扰动扩展。
第二个原因是类别定义本身有模糊地带。有些果子从外观上连人眼都分不清半熟还是刚熟,强行让模型学一个清晰的边界,它自然容易“犹豫”。我最后的处理办法是把类别之间的关系做了软化:置信度落在half和ripe之间的框,界面里额外显示一个黄标,提示人工复核。这个设计虽然保守,但实际用起来反而更可靠。
5.2 推理阶段的坑:光照和遮挡让精度波动很大
图片检测在常规光照下表现不错,但一到两种极端情况就露馅。一是逆光,苹果主体偏暗,模型会把部分青苹果的暗部纹理误判为腐烂特征,导致overripe误报。二是遮挡,叶子挡住一半的果子,边界框被截断,置信度掉到0.5以下,直接被过滤掉。
针对逆光,我在采集时补了一批逆光样本,并给训练增加了曝光扰动增强。针对遮挡,我没有选择强行提升IoU或降低置信度,而是在UI上做了“低置信度提示”的妥协方案。任何模型面对严重遮挡都难有完美解,工程上最重要的是让使用者知道这个框不确定,而不是假装一切正常。
5.3 多批次检测的累计误差
还有一个不是模型本身的问题,而是系统设计的问题。当检测一段视频或一组批次图片时,我最初是逐帧独立推理,结果同一颗苹果在不同帧里被识别成不同成熟度,统计报表忽高忽低。
这个问题靠调模型参数解决不了,必须在上层做时序平滑。我的方案是:对最近5帧的同一目标ID取多数投票,如果连续两次判定发生变化才更新最终结果。实际效果肉眼可见地稳定下来,报表数字也不再剧烈跳动。这套逻辑对于直播摄像头检测尤其重要。
6. 如果要继续优化,这几个方向性价比最高
6.1 模型侧改进
当前的yolo11s模型在普通场景表现已经不错。如果想进一步提高精度,直接换yolo11m或yolo11l权重,训练时用小的预训练权重作为起点做迁移学习,我发现收敛速度和精度都有改善。
另外可以考虑把“半熟”和“成熟”两个类别在某些业务场景下合并成“可采摘”和“不可采摘”两分类。业务需求不同,分类粒度就不同。有的用户关心的是“今天能不能摘”,不是“具体哪个等级”,这时四分类就太重了。提供一个二分类的简化版本,实际部署中反而更受欢迎。
6.2 部署与易用性方向
桌面端UI已经能满足单机检测需求,但如果果园管理员要在手机上用,就需要把模型导出为ONNX格式,再转换成对应的端侧推理格式。官方YOLO仓库支持一键导出:
yolo export model=best.pt format=onnx imgsz=640导出后可以继续压缩体积,量化到低精度,部署到移动端或嵌入式设备。这样在果园现场就能直接拍摄判断成熟度,不用回电脑前再处理。
数据管理上也可以继续加功能:每次检测结果记录保存到本地数据库,按日期、批次、果园区域统计成熟度分布,生成采摘建议报表。这会把一个单一的目标检测工具,升级成一个简单的果园管理辅助系统。
7. 最后分享一点我个人的经验体会
如果让我重新做这个项目,第一步我不会急着标数据,而是先把类别定义和采集计划写清楚。一个合理的半熟样本采集时间窗口只有几天,错过就要等下一季。数据分布就是模型的上限,调参只是逼近这个上限而已。
另一个体会是:目标检测模型做出来只是工程的一半,把它包装成带界面、带账号体系、带统计功能的完整系统,它才能从“能跑的模型”变成“能用的产品”。YOLOv11本身不难上手,难的是围绕模型补全一套适用于真实场景的工程能力。这套流程走完之后,我对任何深度学习落地项目的理解都比之前深了一个层次。