ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于YOLOv11的花生霉变检测系统:从数据标注到桌面部署实战

基于YOLOv11的花生霉变检测系统:从数据标注到桌面部署实战

1. 项目概述与技术选型解析

1.1 这个项目到底是做什么的

花生的霉变问题在仓储和加工环节里一直是个老大难。发霉的花生不仅口感变差,更致命的是会产生黄曲霉毒素,这东西耐高温、难去除,误食后对肝脏损伤很大。过去做霉变检测主要靠人工肉眼挑拣,流水线上的工人一天盯十个小时,眼睛疲劳后漏检率会明显上升,而且每个质检员的标准不完全一样,经常出现这批货三个检验员给出三种结论的情况。

我这次做的这套“基于YOLOv11的花生种子霉变检测系统”,本质上就是用深度学习模型代替人眼,完成花生颗粒的自动识别与分级。它的核心流程是:通过摄像头或图片输入,由YOLOv11模型对画面中的花生逐颗判断是“正常”还是“霉变”,检测结果实时叠加在画面上,同时统计总数、霉变数、合格率这些关键指标。在此基础上,我还给它套了一个完整的桌面端UI外壳——带登录注册功能,有检测界面、历史记录界面、模型管理界面,整套东西不依赖命令行,双击就能跑起来。

这个项目适合三类人。第一类是刚学完YOLOv8/YOLOv11基础教程的学生,想找一个“模型+界面+业务逻辑”全套完整的项目来练手,搞清楚从数据集到可交付软件的完整链路。第二类是农产品质检相关方向的研究者,需要用深度学习方法做小目标分类识别,花生这种颗粒状、尺寸较小、分布密集的检测对象拿来当实验素材非常合适。第三类是有实际生产需求的从业者,虽然真正上线还需要做硬件集成和压力测试,但拿来走查流程、做方案预演绰绰有余。

1.2 为什么选YOLOv11而不是其他模型

选检测模型这事,我纠结过很长时间。一开始想用传统图像处理方案,根据花生皮的颜色分布和纹理特征写规则判断。测试时发现在固定光照、固定背景下效果尚可,一旦光线稍有变化、或者花生表面沾了土,误判率就飙升到没法用的程度。规则写了几十条,最后还是推倒重来。

换到深度学习方案后,我对比了好几类模型。分类网络(比如ResNet、MobileNet)能判断一张图里总体是否霉变,但回答不了“画面里哪几颗坏了”这个问题。生产场景里工人需要的是精准定位,坏的要挑出去,不是整批倒掉。而两阶段检测器(比如Faster R-CNN)精度不错,但推理速度慢,摄像头预览时明显有延迟感,不适合做实时检测。

YOLO系列是一阶段检测器的代表,它把“物体定位+分类”压缩成一个回归问题,一张图前向推理一次就能同时输出所有目标的位置和类别。YOLOv11是YOLOv5/YOLOv8之后的新版本,在C2f模块基础上做了结构优化,引入了更高效的注意力机制,在保持高精度的同时把参数量继续压缩。对于花生检测这种类别不多(就两类)、目标较小、背景相对简单的任务,YOLOv11的推理速度完全够用,在CPU上勉强能跑,有GPU更是流畅顺滑。

另外我特意选了YOLOv11还有一个实际原因:它配合Ultralytics框架,训练和导出模型的代码非常简洁,几行就能完成数据训练和权重导出,这对要写整套系统的我来说省下大量时间。模型文件可以直接导出成ONNX格式用来做CPU推理加速,也可以导出成TensorRT格式部署到边缘设备,扩展性很强。

1.3 整体功能地图:登录、检测、历史三块核心

整套系统我拆成三个功能模块来设计,避免把代码写成一坨。

第一个是用户模块,承担登录和注册。这个模块看起来简单,但它是整套系统能不能给别人用的关键门槛。没有登录,任何人打开软件就能操作,换谁来做检测、做了多少次、结果怎么追溯,全都没有记录。我给它设计了两套账号体系:一是管理员账号,能看所有用户的历史记录,适合质检主管用;二是普通用户账号,只能查看自己的检测历史,适合一线操作工用。密码采用哈希加盐后入库,不能明文存储。

第二个是检测模块,这是核心中的核心。包含图片文件检测、摄像头实时检测、拖拽图片检测三种入口。图片检测是把单张或多张图片交给模型推理,输出结果保存到本地;摄像头检测是打开本机摄像头,对实时画面临近帧推理,画框标出霉变花生;拖拽检测算是个便捷入口,直接把图片拖到窗口里就能出结果,操作门槛降到最低。检测结果要同时展示标注图、统计数字(花生总数、霉变数、正常数、霉变率)和单颗花生的置信度分数。

第三个是历史记录模块。每次检测的图片、结果图、统计指标、检测时间都存入本地数据库。界面上提供按日期查询、按结果筛选的功能,还能一键导出CSV报表,方便做批次质检记录归档。

这三个模块合起来,才是一个能“给别人用”的完整系统,而不是只能跑通模型的玩具脚本。

2. 数据集构建与预处理细节

2.1 数据从哪来:拍摄规范比数量更重要

做检测系统最怕数据质量不合格,数量凑够了,但画面光照乱、角度杂、目标比例失真,训练出来的模型在真实场景里一测就露馅。

我构建花生霉变数据集时,先定了拍摄条件规范。光照方面用5500K色温的LED平板灯做主光源,亮度调到让花生表皮纹理清晰可见、无明显反光的程度。背景统一用哑光黑色PVC板,目的是让花生与背景有足够高的对比度,降低模型学习背景纹理的负担。拍摄设备用普通工业USB摄像头,分辨率设定在1280×720,镜头距拍摄面固定为30厘米。这个距离下,单颗花生在画面里约占40×60像素,不会太大也不会小到看不清纹理。

数据集一共采集约4200张原始图片,其中正常花生2600张,霉变花生1600张。采集时尽量覆盖不同批次的花生——不同产地、不同含水量、不同霉变程度,轻度霉变(表面局部出现灰绿色斑点)和重度霉变(大面积黑色霉斑、表面干瘪)都要有。如果只收集极端样本,模型学到的特征是“很严重的霉变”,对早期轻度霉变视而不见,实际应用中就会漏检。

注意:霉变样本不要只拍一种状态。轻、中、重度霉变建议大致按3:5:2的比例采集,模型才能学到霉变程度的连续变化特征。

2.2 标注要点:小目标标注的边界处理

标注这一步我用LabelImg完成,输出格式选YOLO格式。YOLO格式的标注文件是txt,每行记录一个目标的类别id和归一化坐标中心点、宽高。花生检测我一共就两类,类别0是normal(正常),类别1是moldy(霉变),类别编号从0开始,不能写成1和2。

标注时最容易出错的地方是边界框的松紧程度。有些人喜欢把框打得特别紧,紧贴目标边缘,有些人习惯留一点边。这两种习惯训练出的模型性能差异不大,但必须保证一个人标完整个数据集或者多人标注前先统一规则。我自己的经验是:框从目标最外缘往外再放2~3个像素,避免把背景纹理圈进框里导致特征混淆。

还有一个特别容易踩的坑:花生颗与颗之间经常挨得很近,甚至相互重叠。标注重叠花生时,我选择给露出的部分也单独标框,而不是把重叠区域整个合并成一个框。虽然YOLO对紧密相邻目标的召回率天然有些吃力,但保留正确标注至少能让训练过程学到“密集场景的区分特征”。

标注完成后,我用脚本做了一次完整性校验:逐个解析txt文件,检查坐标值是否在0~1范围内、是否有重复框、是否有框的面积异常小。这类脏数据混进训练集,轻则损失一点点精度,重则让损失曲线震荡不收敛。

2.3 数据增强策略:假样本也有大作用

花生数据集的原始图片数量不算多,4200张用来训练一个小型检测模型属于勉强够用。为了把泛化能力拉上去,我做了离线数据增强和在线数据增强两层策略。

离线增强主要是对原始图片做扩充。我用Albumentations库做了一套pipeline:水平翻转(概率0.5)、随机亮度对比度调整(亮度限制±30)、随机旋转(角度限制±15度)、随机裁剪缩放(裁剪后不低于原图面积的60%)。每张图平均扩充3倍,数据集变到约12000张。这个扩充不是盲目做的,亮度扰动非常关键——不同车间的光照不可能完全一致,模型在亮度扰动下见过足够多样本,才能对光照变化保持鲁棒。

在线增强则交给Ultralytics的train配置,设置hsv_h=0.015、hsv_s=0.7、hsv_v=0.4、degrees=10、translate=0.1、scale=0.5。注意在线增强的参数不宜过高,尤其是translate和scale,太大会让花生目标变形得不像花生。

增强操作必须在标注框上同步进行。比如旋转15度后,标注框的坐标也随之旋转映射到新图里。Albumentations和Ultralytics内部都已处理好了这种同步,但如果你自己写增强函数,这一步千万别漏了,否则标注和图像内容对不上,等于给模型灌毒数据。

3. 模型训练与性能评估实录

3.1 训练环境与超参配置

我的训练环境是消费级配置:RTX 4060显卡、8GB显存、64GB内存,Windows系统上跑Ultralytics框架。YOLOv11按体积分为n / s / m / l / x五档。花生检测只有两个类别、目标尺寸偏小,属于中等偏简单任务,所以选择yolo11s这个档位就能平衡精度和速度。如果电脑显存在6GB以下,建议选yolo11n,训练速度更快,精度大概会下降2~3个点。

训练前要做的最重要一步是划分数据集。我用脚本按8:1:1划分训练集、验证集、测试集。划分配置写入一个data.yaml文件,内容大概是:

path: D:/peanut_dataset train: images/train val: images/val test: images/test nc: 2 names: ['normal', 'moldy']

注意path路径必须是绝对路径,如果换了电脑跑代码,这个坑会让人排查半天。另外,训练集里两个类别的图片数量要保持平衡,如果霉变样本明显偏少,建议在离线增强阶段对霉变类别额外扩增到和正常类别大致持平的比例,防止模型偏向多数类。

训练时的超参配置我提交如下:

yolo detect train model=yolo11s.pt data=peanut.yaml epochs=100 imgsz=640 batch=16 patience=20 project=runs name=peanut_yolo11s

epochs设100轮,用patience=20做早停,如果连续20轮验证集mAP没提升,自动停止训练并保留最优权重,不用白等100轮结束。输入分辨率设640,这是YOLO系列默认值,对花生这种中小目标合适。batch设为16,因为8GB显存跑yolo11s这个规格,batch设为16已经比较极限。如果爆显存,报CUDA out of memory,就把batch降到8,训练时间最多翻倍但效果差不多。

3.2 训练过程观察:损失曲线怎么才算好

模型训练过程中要盯两个指标,第一是训练损失(box_loss、cls_loss、dfl_loss)的收敛趋势,第二是验证集mAP50和mAP50-95的变化。

我自己这轮训练跑了大概85轮(早停触发在80多轮后)。损失曲线整体平滑下降,前20轮下降最快,40轮后进入缓慢下降区间,说明模型在快速拟合数据后进入精调阶段。如果在训练中后期发现验证集损失开始反弹而训练损失还在降,这是过拟合信号,说明该提前停了,早停机制会自动处理这一点。

最终验证集结果:mAP50约96.8%,mAP50-95约84.5%。对花生这种两类小目标检测任务,这个精度属于可用级别。测试集上的单张图片推理耗时,在RTX 4060上约为28毫秒;导出ONNX后在同机CPU上推理约480毫秒;单帧摄像头画面流畅度不错。

提示:mAP50-95是对不同IoU阈值(0.5到0.95)取平均,它更能反映“框打得准不准”。如果mAP50很高但mAP50-95偏低,说明框的位置或尺寸偏得不精准,这种情况要把imgsz调大到800再试试。

3.3 模型导出与推理验证

训练结束后,Workers目录下的weights文件夹里有两个权重文件:best.pt和last.pt。best.pt是验证集上表现最好的权重,实际部署一律用它。我还会顺手导出成ONNX格式,方便CPU部署和跨平台使用:

yolo export model=best.pt format=onnx opset=12 imgsz=640

导出后我用几张从未参与训练的图片做推理验证,选取的目标包含以下场景:颗粒紧密挨着的、轻微光照偏暗的、花生大小不均的。模型在紧挨场景下偶尔会出现两个目标被并成一个框的情况——这种情况在密集小目标检测里很难完全避免,出现频率约5%。但好消息是,真实质检过程中花生进入检测位时通常有分料机构,不会始终处于密堆状态。

另外,我还用OpenCV的DNN模块加载ONNX模型测试了CPU推理流程。虽然速度比GPU慢不少,但胜在部署简单,不用装PyTorch的CUDA环境,适合作为无GPU条件下的后备方案。

4. 系统界面开发与功能实现

4.1 登录注册模块:安全与体验的平衡

登录注册我基于PySide6(Qt的Python绑定)来实现。选PySide6而不是Tkinter,是因为它的控件美观度、布局灵活性、和OpenCV图像的集成方式都明显更顺手。做一个扫码仪风格的简洁界面,Tkinter那套控件画风很难看,交出去的项目也拿不出手。

注册界面有几个字段:用户名、密码、确认密码。密码规格要求至少6位且包含字母和数字。注册时用哈希算法做加密。这里必须多说一句:不要用MD5这类快速哈希,要用带盐的PBKDF2或bcrypt。我Python项目里直接用hashlib内置的pbkdf2_hmac实现:

import hashlib, os, sqlite3 def hash_password(password: str, salt: bytes = None) -> tuple: if salt is None: salt = os.urandom(16) hashed = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, 100000) return salt.hex(), hashed.hex()

数据库中保存salt和hash两列,校验时把用户输入的密码用同样的盐重新哈希,比对结果即可。这样做即使数据库文件被别人拷走,也无法反推明文密码,对内部工具的隐私保护够了。

登录逻辑要处理几个特殊场景:用户名不存在、密码错误、连续失败锁定。连续失败5次后锁账户15分钟,这个策略可以防止有人写脚本对登录接口做暴力尝试。

界面布局上,登录窗口居中显示一个Logo区域(放产品名或一张花生图片)、用户名输入框、密码输入框、登录按钮、注册按钮,以及一个“记住我”复选框。记住我的实现方式是把用户名和session token存入本地配置文件,下次打开自动填入,但密码绝不持久化。

4.2 主检测界面:三入口设计与实时预览体验

主窗口的布局我设计成左侧控制面板、中间实时预览区、右侧结果展示区三段式。

左侧面板从上到下依次是:摄像头选择下拉框、开启/关闭摄像头按钮、图片选择按钮、拖拽提示区域、检测阈值滑条。中间预览区用一个QLabel充当画布,不断刷新来自摄像头或图片的帧。右侧面板显示统计信息:检测总数、正常数、霉变数、霉变率,下面放一个表格列出每次检测的目标坐标和置信度。

图片检测入口走文件对话框,支持jpg / png / bmp格式,多选批量检测。检测时每一张图的推理结果都叠加画框:正常花生用绿色框,霉变花生用红色框,框的左上角标注类别和置信度,比如“moldy 0.92”。画框的代码基于OpenCV:

def draw_detections(frame, results): for box in results.boxes: x1, y1, x2, y2 = map(int, box.xyxy[0].tolist()) conf = float(box.conf[0]) cls = int(box.cls[0]) label = f'{names[cls]} {conf:.2f}' color = (0, 255, 0) if cls == 0 else (0, 0, 255) cv2.rectangle(frame, (x1, y1), (x2, y2), color, 2) cv2.putText(frame, label, (x1, y1 - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) return frame

摄像头实时检测要注意推理频率。模型在GPU上单帧推理约30毫秒,但摄像头的读取、图像预处理、画框绘制都要占用时间,如果每一帧都跑推理,界面会明显掉帧卡顿。我的做法是开一个后台线程持续从摄像头读帧,但检测操作控制在每秒4~5次,中间帧直接显示不做推理。这样既保证画面流畅,又保证检测结果不会滞后太多。

4.3 历史记录与数据持久化设计

检测结果的历史记录存储我用SQLite实现。数据库文件放在项目根目录下的data文件夹中,建表语句如下:

CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, image_path TEXT, result_path TEXT, total_count INTEGER, moldy_count INTEGER, normal_count INTEGER, moldy_rate REAL, created_at TEXT DEFAULT (datetime('now', 'localtime')) );

每次检测完成后,把原图复制到项目目录下的history_images文件夹(防止原始图被移动后记录失效),检测结果标注图也存一份,然后把统计信息写入数据库。历史记录界面做成一个表格,双击某一行可以在右侧预览该次检测的标注大图和当时的统计详情。

导出CSV功能用标准库csv模块实现,导出格式包含日期、用户、图片路径、总数、霉变数、霉变率。生产场景里这个功能很实用,质检主管可以直接把CSV导入Excel做周报月报,不用自己去手动敲数据。

注意事项:数据库写入操作要在后台线程完成,不要在UI主线程里执行IO操作,否则检测过程中界面会出现短暂卡顿。我用的是Python标准库sqlite3,注意在写入频繁时加上with self.lock上下文,避免多线程并发写导致锁竞争异常。

5. 常见问题与排查技巧实录

5.1 高频问题速查表

这套系统从开发到改稳定,我踩了不少坑。下面把最典型的几个问题整理成速查表,方便排查。

问题现象可能原因解决方案
训练时CUDA out of memorybatch尺寸过大或图片分辨率过高batch降到8,imgsz降到640
推理结果全是正常类,霉变检出率低训练集霉变样本不足或标注不准确补充霉变样本,重新检查标注框
摄像头界面卡顿严重每帧都做检测,线程调度不当检测逻辑改为间隔触发,主循环只做显示
模型运行报KeyError类名data.yaml的names顺序与标注不一致统一点检查:normal是0,moldy是1
登录后进入主界面闪退缺少Qt平台插件或模型权重路径错误检查权重文件是否存在,验证PySide6环境
注册用户后无法登录密码哈希算法在注册和登录时不一致封装统一工具函数,两端调用同一接口

5.2 模型精度够了但实际检测却误报多

这是一个容易让人头疼的情况:验证集mAP很高,但一上真实业务数据就误报不断。

根源通常不是模型本身,而是实际场景与训练集分布不一致。比如训练时拍摄背景是黑色PVC板,实际使用环境是传送带上的棕黄色背景,颜色特征虽然不会让模型丢掉花生,但会给纹理判断引入大量噪声。我的解决思路是:在系统里加入一键“背景适配微调”功能——在实际部署场地拍摄200~300张现场照片,用已训练的权重作为预训练,用小学习率(0.0001)再训练50轮完成迁移。这一步能有效把模型从实验室背景适配到生产现场。

如果不想重训,也可以在预处理加入自动白平衡和高斯滤波,减少光照和噪声带来的分布偏移。

5.3 打包发布时遇到的三个大坑

开发完成后要给别的电脑部署,PyInstaller打包是绕不过去的一关。我打包过程中遇到三个比较有代表性的坑。

第一个是模型权重文件被PyInstaller漏掉。Ultralytics的权重从代码里通过相对路径加载,PyInstaller默认不会把非py文件打包进可执行文件。解决方法是把best.pt作为数据文件显式加入spec文件的datas列表。更稳妥的做法是打包后把权重文件放在exe同级的models目录下,代码里用os.path.dirname(sys.executable)定位exe所在目录再拼接模型路径。

第二个是OpenCV的DLL依赖不全,在无Python环境的电脑上运行报找不到mkv2.dll这类错误。PyInstaller的hook机制一般能自动收集OpenCV的DLL,但偶尔会有遗漏。我的做法是用--collect-all opencv参数强制收集所有相关文件。

第三个是摄像头在打包后无法被识别。原因通常是PyInstaller没有包含PySide6的插件目录(如platforms、imageformats)。打包命令要加上--collect-all PySide6,否则界面能显示但摄像头一开就空白。

打包命令参考:

pyinstaller --noconfirm --windowed --name PeanutDetector \ --collect-all PySide6 --collect-all cv2 \ --add-data "models/best.pt;models" \ main.py

执行完在dist目录下就能找到可执行文件,整个文件夹拷给目标电脑即可运行。建议把整个dist目录压缩传输,不要只传exe。

5.4 推理速度优化与轻量化部署思路

如果要在更低配的机器上部署(比如无独立显卡的办公电脑),有几个优化手段实测有效。

第一,输入尺寸从640降到480。对花生这种中等偏小的目标,尺寸降一点影响不大,但推理速度提升接近40%。第二,ONNX Runtime配合CPU线程设置,在加载模型时指定intra_op_num_threads为CPU核心数的一半,多核机器上的延迟能再降一些。第三,模型量化。把ONNX模型转为INT8量化版后,尺寸缩小到1/4,推理速度提升约2倍,代价是mAP下降约1.5~2个百分点。对于只有两类目标的任务,这个精度损失完全可以接受。

如果产品要走向嵌入式(比如用树莓派或边缘盒子),还可以把模型导出成TensorRT格式,但这需要有NVIDIA显卡进行离线转换,推理端不需要显卡,用带有TensorRT推理引擎的边缘设备即可。

6. 送给接盘者的几点实用经验

整个项目从决定开坑到交付出一个能演示、能跑通的版本,前前后后大概花了三周时间。如果重新做一遍,我会把时间分配得更极端一点:第一周只做数据采集和标注,第二周训练和调优模型,第三周开发界面和打包。

最后分享几个我踩过坑之后觉得值得记住的经验。

第一,数据集永远值得花时间。模型性能的上限在数据标注完成那一刻就已经决定了,后续调参只是在逼近这个上限。如果你的霉变样本本身质量差、覆盖场景窄,就算换个更大的模型也救不回来。

第二,UI界面开发不要和模型训练并行。我一开始边调参边写界面,结果两头都拖慢,脑子切来切去也不清爽。先让模型达到可用精度,界面写起来才会安心,否则界面做得再漂亮,模型识别不准,演示时一样翻车。

第三,保存一切结果图。检测结果图是质检追溯的关键凭据,务必保留,这在真实生产环境和课程答辩中都是加分项。线上归档机制我建议至少保留最近90天的结果图,本地清洗策略用定时任务自动清理超期文件。

第四,如果有人要跑这个项目,第一时间提醒他先看data.yaml里的路径。我因为这个路径问题帮两个人远程排查过,每次都是路径没改导致训练报错,浪费了半小时。

这套系统的核心价值在于把训练好的模型真正变成了一个可操作、可管理、可追溯的质检工具。下一步的扩展方向也很明确:把霉变检测拓展到其他农产品上,比如玉米、黄豆、咖啡豆,换数据重新训练即可;再进一步可以接上分拣机构,让检测结果直接驱动电磁阀吹掉霉变颗粒,实现无人化分选。如果你已经成功跑通这套代码,往这些方向扩展就不算太难了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进