做农产品检测的人应该都有过这种体会——人眼挑花生种子,挑一篮没问题,挑十篮就开始犯晕,挑到下午袋子堆成山的时候,全凭手感了。我最近完成的这个基于深度学习YOLOv11的花生种子霉变检测系统,就是想把这种枯燥、易疲劳、标准还不统一的人工分选环节替换成自动化视觉判断。系统包含YOLOv11模型训练、原生YOLO格式数据集构建、带登录注册的用户界面、完整的Python工程源码和训练好的模型权重,整套东西跑通之后,即使完全不懂深度学习的人也能打开界面直接使用。
这里说的“花生种子”跟市面上卖的花生米不完全是一回事。种子果仁既要保证食用安全,还要考虑发芽率和幼苗成活率,霉变粒哪怕只占一小部分,混在种子里也容易影响整批种子的商品等级。所以这个项目的核心价值不是做个炫酷Demo,而是要真正落到分选场景里的实用工具。我用这个项目完整走了一遍“数据采集—标注—训练—界面集成—实测调优”的闭环,下面把关键环节和踩过的坑都摊开讲,方便想复现的人直接参考。
1. 先想清楚“为什么做”:花生种子霉变检测的真实痛点
1.1 霉变问题到底有多严重
花生一旦霉变,最容易产生的是黄曲霉毒素B1,这是一种毒性很强的物质,在国际上被公认与肝癌发病率相关。作为食品原料,黄曲霉毒素有严格的限量要求;作为种子,霉变粒的出芽率会大幅下降,而且霉菌孢子会在储存过程中传染给相邻的健康粒,导致整批种子的商品等级被拉低。
换句话说,不论走向餐桌还是走向田地,霉变粒都必须被剔除干净,而且最好在进料早期就拦住,不要等到入库了才发现。
1.2 人工分选的三个致命短板
我在接触食品和种子加工场景时,听到最多的是三个词:效率、一致性、成本。
效率方面,一个熟练的质检员靠着分选台,每小时大概能挑出几百克到一两公斤的花生,大批量进料的时候根本供不上产线。一致性更现实,不同的人对“霉变”的判断标准不一样,同一个人上午和下午的状态也不一样,漏检率波动非常大。成本则是长期问题,质检岗位本身枯燥,人员流动性大,培训和磨合都很耗精力。
所以,用视觉算法来做初筛不是“炫技”,而是实实在在能缓解这些问题的方案。
1.3 为什么选目标检测而不是图像分类
最开始也纠结过:如果只是判断“这一堆花生里有没有霉变”,用二分类网络更简单。但放到真实分选场景里,我们要的不只是“有没有”,还得知道霉变粒在画面中的位置、数量、占比。有了位置信息,后续可以联动机械臂剔除,或者生成批次质检统计报表。
目标检测输出的正是“类别+位置框”的组合。YOLO系列在检测速度和精度的平衡上一直做得不错,YOLOv11作为较新版本,改进了特征提取和注意力机制,对霉变斑块这类“局部小特征”的捕捉能力比前几代更友好。所以我最终把方案定在YOLOv11上,整体走了一条可落地的工程路线。
2. 数据是模型的粮食:花生霉变数据集的采集、标注与划分
2.1 采集场景怎么设计
很多做深度学习项目的人会在数据上吃亏,我这次学乖了:第一阶段就把采集条件想清楚。目标是模拟真实分选环境,而不是拍一堆“实验室美图”。
- 拍摄距离:相机离托盘或传送带上的花生大约10到30厘米,要让单粒花生在画面里占足够像素。
- 背景:选深色哑光背景,对比度强、不反光。黑色绒布或哑光塑料板都可以。
- 光照:把不同时段、不同灯源的照片都混进来。太阳光、白炽灯、LED台灯下的色温差异很大,都拍一些,模型才不容易过拟合到某一种灯光。
- 摆放方式:既要有单粒散放的,也要有多粒紧挨甚至轻微重叠的,模拟真实进料时的不规则堆叠。
采集数量上,总共拍了900多张有效图片,正常花生和霉变花生两类都有覆盖,后期标注出的目标框超过6000个。
2.2 标注工具与YOLO格式
标注工具选的是LabelImg,因为它轻量、导出YOLO格式不需要额外转换。也可以选X-AnyLabeling或Roboflow,功能上各有侧重,但最核心的产出是一样的:每张图片对应一个同名txt文件。
YOLO标注格式很好理解,一行就是一个目标:
class_id x_center y_center width height其中坐标都换算成0到1的归一化值。举个例子:
# 一张640x480的图上,坐标都已归一化 0 0.4531 0.3562 0.1234 0.1562 1 0.7266 0.7125 0.1109 0.1438第一行表示一个正常花生,第二行表示一个霉变花生。这里类别0定义为正常花生normal,类别1定义为霉变花生moldy。
标注时有两个容易忽略的细节。一是框要尽量贴合果仁边缘,不要留太多空白,YOLO回归框时“贴边”能减少学习难度。二是类别定义要统一,轻微霉变就算霉变,不要一会儿按霉变标一会儿按正常标,否则模型会被你自己的标注搞糊涂。
2.3 数据组织方式
Ultralytics训练时要求数据集按固定目录组织。我习惯这样存放:
dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yamlimages和labels文件夹一一对应,文件名保持完全一致。用一个简单的脚本就能把标注好的数据自动划分到训练验证集,我习惯按80/20的比例切分:
import os import random import shutil src_img = "raw/images" src_label = "raw/labels" train_img = "dataset/images/train" train_label = "dataset/labels/train" val_img = "dataset/images/val" val_label = "dataset/labels/val" all_files = [f for f in os.listdir(src_img) if f.endswith(".jpg")] random.seed(42) random.shuffle(all_files) split = int(len(all_files) * 0.8) for f in all_files[:split]: shutil.copy(os.path.join(src_img, f), train_img) shutil.copy(os.path.join(src_label, f.replace(".jpg", ".txt")), train_label) for f in all_files[split:]: shutil.copy(os.path.join(src_img, f), val_img) shutil.copy(os.path.join(src_label, f.replace(".jpg", ".txt")), val_label)这里有个关键点:划分最好按“拍摄批次”来,而不是按单张随机划分。如果同一段视频抽帧出来的图一半在训练一半在验证,指标会虚高,这个坑我后面还会展开说。
2.4 数据增强要不要做
Ultralytics的训练流程里,默认会开启Mosaic、随机平移、缩放、HSV色彩扰动等增强策略。对小目标检测来说,Mosaic增强尤其有用,它把四张图拼在一起,相当于变相增加了每批次的样本多样性。
对于花生霉变这个场景,我额外调高了hsv_h和hsv_s的扰动范围。原因很直接:不同光照下花生的色温差异挺大,让模型在训练时就见过各种颜色偏移,测试时遇到新光源才不会露怯。
3. YOLOv11训练全流程:从模型选型到权重输出的关键决策
3.1 YOLOv11到底改进在哪
YOLOv11可以看作是YOLOv8的延续升级,核心变化集中在backbone和neck部分。比如引入了C3k2模块,在保持轻量的同时增强了特征提取能力;又比如加入了C2PSA结构,让模型能更关注图像中重点区域的特征,类似给网络加了“注意力”。
对花生霉变检测来说,霉变区域往往只是果仁上的一小块暗色斑纹,如果网络对局部细节不敏感,很容易漏检。这也正是我最终选定YOLOv11的原因:它对细小差异的感知确实比之前的版本更稳。
3.2 模型规格怎么选
YOLOv11官方提供n、s、m、l、x五档规格。我做了简单对比:
| 模型 | 参数量 | 特点 | 适用场景 |
|---|---|---|---|
| YOLO11n | 约2.6M | 速度最快,精度相对有限 | 边缘设备、摄像头实时 |
| YOLO11s | 约9.4M | 速度和精度平衡,本系统首选 | 普通办公电脑、分选设备 |
| YOLO11m | 约20.1M | 精度较高,显存需求更大 | 有条件上GPU的生产线 |
| YOLO11l/x | 更大 | 精度高但训练和推理都更重 | 服务器端离线批量检测 |
实际跑下来,YOLO11s在640分辨率下的效果已经足够区分正常和霉变花生,推理速度在普通显卡上也能达到几十毫秒一帧,所以我建议默认从s规格开始。如果训练集规模小,先拿n规格跑通流程也完全可以。
3.3 环境搭建
整个项目用Python实现,核心依赖Ultralytics和PyQt5。建议用conda建干净环境:
conda create -n peanut python=3.10 conda activate peanut pip install ultralytics pip install pyqt5 opencv-python有NVIDIA显卡的话,再装对应版本的PyTorch GPU版,训练速度会差很多倍。
3.4 数据集配置文件
在dataset目录下放一个data.yaml:
path: dataset/ train: images/train val: images/val nc: 2 names: ["normal", "moldy"]path写的是相对路径还是绝对路径都行,但为了保证打包后能用,我建议用相对路径,后续移动工程目录也省事。
3.5 训练命令与参数选择
训练代码用Ultralytics的API就能跑:
from ultralytics import YOLO model = YOLO("yolo11s.pt") model.train( data="data.yaml", epochs=120, imgsz=640, batch=16, patience=20, optimizer="SGD", lr0=0.01, device=0, )解释几个关键参数:
- epochs设为120,配合patience=20做早停。如果验证集在连续20个epoch内没有提升,训练会自动停止,避免无意义的过拟合。
- 输入分辨率imgsz设为640,是精度和速度的折中。提到960的话小目标检测更好,但推理速度会明显下降。
- batch设为16,如果你的显卡显存只有6G左右,降到8或4。
- 优化器我选了SGD。虽然Adam收敛更快,但SGD最终精度通常更稳,尤其在数据集不够大的时候。
训练过程中会在runs/detect/目录下生成权重文件,最有用的就是best.pt和last.pt。best.pt是验证集表现最好的权重,推理部署都用它。
3.6 怎么判断模型好坏
训练日志里最值得盯的指标有四个:precision、recall、mAP@0.5、mAP@0.5:0.95。
- precision:预测为霉变的框里,真正是霉变的比例。
- recall:真实霉变框里,被成功找出来的比例。
- mAP@0.5:IoU阈值0.5下的平均精度,最常用。
- mAP@0.5:0.95:更严格,要求框的位置也更准。
对于花生分选,recall比precision更重要。漏掉一粒霉变花生可能整批种子都受影响,多框一两粒正常花生顶多让人工多看一眼。所以调参和阈值时要重点守住recall。
我这轮训练的最终结果是mAP@0.5到了0.92,recall稳定在0.89左右,基本满足分选初筛需求。
4. 把模型变成产品:UI界面与登录注册系统的搭建思路
4.1 界面框架选型
桌面端UI可选PyQt5或Tkinter。Tkinter上手快但控件和样式都比较简陋,做检测软件的交互体验稍差。PyQt5虽然学习曲线稍陡,但控件丰富、支持QThread多线程、打包生态成熟,最终选了它。
| 框架 | 上手难度 | 控件丰富度 | 线程支持 | 打包生态 |
|---|---|---|---|---|
| Tkinter | 低 | 一般 | 一般 | 一般 |
| PyQt5 | 中 | 丰富 | 健全 | 成熟 |
4.2 登录注册模块怎么实现
登录注册本质上就是一套用户数据管理。数据存储我选了SQLite,比MySQL轻量,部署时不需要单独装数据库服务。
建表语句:
CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, salt TEXT NOT NULL, password_hash TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );密码不能明文存储,我用盐加哈希:
import hashlib import secrets def hash_password(password: str) -> str: salt = secrets.token_hex(16) hashed = hashlib.sha256((salt + password).encode()).hexdigest() return salt + ":" + hashed def verify_password(password: str, stored: str) -> bool: salt, hashed = stored.split(":") return hashlib.sha256((salt + password).encode()).hexdigest() == hashed每个用户的盐是随机生成的,即使数据库泄露,彩虹表也很难直接破解。注册时判断用户名是否已存在,登录时校验哈希值,逻辑清晰也安全。
4.3 主检测界面怎么布局
主界面我设计成左右结构:
- 左侧是一个大面积图片显示区,用来展示原始图和检测结果图。
- 右侧是参数区,显示置信度阈值滑块、当前检测结果统计(总粒数、正常粒、霉变粒、霉变率)。
- 底部是功能按钮:选择图片、开启摄像头、保存检测结果、退出登录。
检测按钮的槽函数核心逻辑:
def on_detect_clicked(self): path = self.open_file_dialog() if not path: return self.status_label.setText("检测中...") self.thread = DetectThread(self.model, path) self.thread.result_ready.connect(self.show_result) self.thread.start()在这里必须用QThread把模型推理放到子线程。模型推理本质是耗时计算,如果直接写在Qt主线程里,界面会直接卡死,按钮点了没反应,体验非常差。
线程类的简化版本:
import cv2 from PyQt5.QtCore import QThread, pyqtSignal from PyQt5.QtGui import QImage class DetectThread(QThread): result_ready = pyqtSignal(object, object) def __init__(self, model, image_path): super().__init__() self.model = model self.image_path = image_path def run(self): results = self.model.predict(self.image_path, conf=0.45) annotated = results[0].plot() rgb = cv2.cvtColor(annotated, cv2.COLOR_BGR2RGB) height, width, _ = rgb.shape qimg = QImage(rgb.data, width, height, rgb.strides[0], QImage.Format_RGB888) self.result_ready.emit(qimg, results)推理完成后用信号把结果图传回主线程更新界面,整个交互就不会卡顿。
5. 端到端跑通后的实测复盘:六个文档里不会写的坑
5.1 数据划分不当导致“虚高指标”
我第一次训练时按图片随机划分训练集和验证集,验证集mAP@0.5高达0.94,当时还挺高兴。结果把模型拿到全新拍摄的一批照片上测试,mAP直接掉到0.81。
原因很典型:数据里有同一段视频连续抽帧出来的图片,内容高度相似,随机划分会把这些相似图片分到两侧,验证集相当于“开卷考试”。正确的做法是按拍摄批次划分,把同一时间、同一场景拍出来的图都放进同一侧。我重新划分后又训了一轮,验证集指标和真实场景表现就基本一致了。这点很值得留意,不然你的模型上线前自我感觉良好,上了却翻车。
5.2 类别不平衡的真实影响
我的数据集里正常花生框数大约是霉变花生的两倍。训练时模型会更偏向学到“正常花生”的特征,导致霉变类recall偏低。这在大批量分选时是不能接受的,漏判一粒霉变种子的代价远高于多标一粒正常。
解决思路有两个方向。一是数据层,尽量多采集霉变样本,或者对霉变类的图片做更多离线增强。二是训练配置,Ultralytics提供了cls loss的权重调整能力,给霉变类更高的分类权重,让模型更重视这个少数类。实际使用中两个方向都做,效果最明显。
5.3 光照偏色导致的漏检
训练数据大多在室内LED灯下拍摄,模型在室内测试效果很好。后来拿到日光充足的窗边一试,霉变斑块的颜色表现跟室内差不少,漏检率明显上升。
这就是典型的颜色过拟合。解决办法是训练时把hsv_h和hsv_s的增强幅度加大,让模型见到更多色彩变化。我最后把hsv_h调到了0.02,hsv_s调到了0.6,重新训练之后对自然光下的图片明显更稳。
5.4 conf阈值到底调多少
置信度阈值conf直接影响precision和recall的取舍。调低了,recall上升,但会把一些正常花生误判成霉变;调高了,precision上升,但霉变粒容易漏掉。
对花生分选这个场景,我的建议是comfort区间在0.4到0.5之间。宁可多框出几粒让质检员复核,也不要放过真正的霉变粒。在代码里我用conf=0.45作为默认值,并且界面上保留滑块让用户按需调整。
5.5 界面卡顿与打包资源问题
除了解析卡顿,还有几个实际操作层面的细节:
- 模型初始化很慢,一定要在程序启动时加载一次,不要每次检测都new一个YOLO对象。
- PyTorch模型默认用GPU推理,如果没有GPU要显式指定cpu,否则启动时就报错。
- PyInstaller打包时,best.pt要作为数据文件打进去,并且代码里不要用绝对路径,用os.path.join处理成相对路径,换个电脑才不会找不到模型。
5.6 用ONNX加速部署
如果生产环境的机器没装PyTorch,或者想进一步提升推理速度,可以把模型导出成ONNX格式:
from ultralytics import YOLO model = YOLO("best.pt") model.export(format="onnx", imgsz=640)导出后用onnxruntime加载推理,代码结构变化不大,但推理速度和内存占用都会比直接跑PyTorch模型更优。我在实测中把单帧推理时间从约40毫秒压到了约15毫秒,这对摄像头实时检测很有帮助。
最后再说点个人体会。这个项目走完一遍,最花时间的不是模型训练,而是数据整理和踩坑调试。凡是准备自己复现的人,我建议把三分之二的精力放在数据质量和划分逻辑上。数据备到位了,YOLOv11的配置反而只是几个参数的事。这套流程换到其他农产品检测,比如玉米霉变、咖啡豆霉变,思路完全一样,核心资产始终是那套干净的数据整理方法和工程落地习惯。