
简介这是基于PyTorch的柑橘成熟度识别完整项目面向计算机视觉初学者或农业图像分类场景内置可直接运行的深度学习代码与已标注图像数据帮助用户从零搭建一个完整的图像分类应用。压缩包共120个文件其中113张jpg与1张jpeg原始图像覆盖健康、成熟、病害等多种状态3个Python脚本分别实现数据集标签生成、卷积神经网络训练和PyQt可视化界面3个txt文本用于记录训练集与验证集的文件路径及对应标签整包约10.8MB目录结构清晰便于快速定位。项目在数据预处理阶段采用短边补灰边转为正方形、随机旋转等增强策略有效扩充样本多样性降低过拟合风险运行01脚本可自动读取各类别图片路径并生成标准标签文件02脚本据此完成模型训练并保存本地权重03脚本则启动图形界面实现图片识别结果的可视化展示。目前已有237人学习下载适合希望快速跑通分类任务全流程、积累PyTorch实操经验的中初级学习者。1. 柑橘成熟度识别为什么这个任务是 CNN 的强项而不是阈值分割做果品分选产线的人多半都试过这条路用工业相机拍柑橘按 RGB 颜色阈值判断青果还是黄果。晴天跟阴天拍出来的颜色都不一样农户换了个品种阈值就得重调更不用说阳光直射产生的高光、叶片遮挡投下的阴影、以及果实表面本身的花斑——纯颜色规则在这种场景里几乎必翻车。而基于卷积神经网络的柑橘成熟度识别是把成熟度当作一个视觉分类/检测问题来解网络自己从数据里学颜色、纹理、形状的综合特征光照变化和品种差异被数据覆盖掉鲁棒性比手写规则高一个量级。这个项目最值钱的部分其实不是网络结构而是「含数据集」这三个字。柑橘成熟度不是 ImageNet1k 那种公开大任务能用来训练的标注数据很少绝大多数人卡在第一步找不到数据或者找到了只有几十张没法训。标题里明确带了数据集意味着拿到的是一份可用的训练资产。适合两类人一类是做农业信息化、果品分级设备落地的工程师想快速验证 CNN 在这个场景的上限另一类是入门深度学习、想拿真实业务数据走一遍完整流程的开发者。下面按我自己的落地习惯从数据准备、模型选型、训练调参到部署验证整条链路拆开讲。2. 先把数据集吃透目录结构、标注格式与训练/验证划分2.1 拿到数据集的第一件事别急着训练先做三样检查我习惯把下载的数据集解压后先跑一遍文件树和统计信息而不是直接丢进训练脚本。很多压缩包里的数据组织方式跟常见框架默认格式不一样直接训练大概率报路径错误或者类别跟标签对不上。先看整体结构# 在数据集根目录执行输出层级结构和每类文件数量 tree -L 3 --dirsfirst find . -type f -name *.jpg | wc -l find . -type f -name *.xml | wc -l # 如果是VOC格式 find . -type f -name *.json | wc -l # 如果是COCO格式 find . -type f -name *.txt | wc -l # 如果是YOLO格式这里有几个关键点在输出里确认。第一图片和标注文件的个数是否对得上对不上说明有漏标或脏文件。第二标注是不是跟图片一一对应同名很多数据集图片是001.jpg、标注是001.xml但也有图片名带下划线、标注名不带的埋到后面就是训练时标签加载不到。第三类别名称是什么——是unripe / semiripe / ripe这种英文还是青果 / 半熟 / 成熟这种中文还是干脆用0 / 1 / 2数字。这一步定下来后面做格式转换才有的放矢。第三点特别提醒做检测的读者有些数据集表面上写了「柑橘成熟度」标注却是整图单标签的 CSV或者是每个果实的框级标注这两种标注对应的技术路线完全不同。整图单标签只能做图像分类框级标注才能做目标检测。我看到过不少人在这一步栽跟头拿着分类数据想训 YOLO那肯定训不了。2.2 VOC 转 YOLO 格式转换脚本与三个边界坑我默认你拿到的数据集是 VOC 格式xml 标注因为国内多数农业类数据集的标注都是用 LabelImg 做的导出就是 VOC。而 YOLOv8 训练自定义数据集用的是 txt 格式每行一个目标class_id cx cy w h坐标是相对图片宽高的归一化值。转换脚本如下import xml.etree.ElementTree as ET import os, glob def voc_to_yolo(xml_path, out_dir, class_dict): tree ET.parse(xml_path) root tree.getroot() img_w int(root.find(size/width).text) img_h int(root.find(size/height).text) txt_lines [] for obj in root.iter(object): cls_name obj.find(name).text if cls_name not in class_dict: continue # 忽略未定义类 cls_id class_dict[cls_name] bbox obj.find(bndbox) x1 float(bbox.find(xmin).text) y1 float(bbox.find(ymin).text) x2 float(bbox.find(xmax).text) y2 float(bbox.find(ymax).text) # 归一化并做边界裁剪防止坐标越界 x1 max(0, min(x1, img_w - 1)) y1 max(0, min(y1, img_h - 1)) x2 max(0, min(x2, img_w - 1)) y2 max(0, min(y2, img_h - 1)) if x2 x1 or y2 y1: continue # 无效框丢弃 cx (x1 x2) / 2.0 / img_w cy (y1 y2) / 2.0 / img_h w (x2 - x1) / img_w h (y2 - y1) / img_h # 归一化后坐标理论在[0,1]但浮点计算可能超一点裁剪到[0,1] cx max(0, min(cx, 1.0)) cy max(0, min(cy, 1.0)) w max(0, min(w, 1.0)) h max(0, min(h, 1.0)) txt_lines.append(f{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) # 输出同名txt到指定目录 out_name os.path.basename(xml_path).replace(.xml, .txt) with open(os.path.join(out_dir, out_name), w) as f: f.write(\n.join(txt_lines)) class_dict {unripe: 0, semiripe: 1, ripe: 2} xml_list glob.glob(labels/*.xml) os.makedirs(labels_yolo, exist_okTrue) for x in xml_list: voc_to_yolo(x, labels_yolo, class_dict)这个脚本做了几件容易漏的事。第一是坐标边界裁剪标注软件偶尔会标出超出图片边界的框不处理的话归一化后坐标可能跑到[0,1]之外YOLO 训练时会报警甚至跳过。第二是无效框过滤x2 x1这种情况虽然少见但存在多半是手误直接丢弃比带着跑安全。第三是忽略class_dict里没有的类别如果数据里混了leaf、branch这种标注要么删掉、要么单独给类不能静默跳过否则后续统计分析会发现样本数莫名其妙变少。转完后抽样检查五到十个 txt 文件手工算一下坐标对不对。常见做法是写一个反向脚本把 txt 里画回图片上存成可视化的 jpg肉眼确认框的位置跟果实贴合。这一步别省转换脚本的 bug 在可视化下最多十分钟就暴露放到训练里能浪费一整天。2.3 训练/验证/测试划分随机划分是给自己埋雷数据集划分是另一个容易被低估的环节。很多人图省事直接random.shuffle后按 8:2 切如果数据里同一个果实的多角度照片都来自同一棵树这种做法会让模型「作弊」——验证集里出现训练集同株果实的其他角度照片mAP 虚高到离谱一上真实果园就现原形。我一般按拍摄批次或植株 ID 分组划分确保同一个体的所有样本只在训练集或验证集里出现。下面这段脚本是按文件名前缀分组的划分逻辑import os, random, shutil from collections import defaultdict img_files [f for f in os.listdir(images) if f.endswith(.jpg)] # 按文件名下划线前的部分作为分组ID假设拍摄时按株编号命名 groups defaultdict(list) for f in img_files: group_id f.split(_)[0] groups[group_id].append(f) group_ids list(groups.keys()) random.seed(42) random.shuffle(group_ids) split int(len(group_ids) * 0.8) train_groups set(group_ids[:split]) val_groups set(group_ids[split:]) train_files [] val_files [] for gid, files in groups.items(): if gid in train_groups: train_files.extend(files) else: val_files.extend(files) # 写入train.txt / val.txt供YOLO训练使用 os.makedirs(data_split, exist_okTrue) with open(data_split/train.txt, w) as f: f.write(\n.join([os.path.abspath(os.path.join(images, x)) for x in train_files])) with open(data_split/val.txt, w) as f: f.write(\n.join([os.path.abspath(os.path.join(images, x)) for x in val_files]))分组划分的核心是用split(_)[0]把同一株柑橘的样本锁在同一个分组里。我在实际项目里还见过另一种划分泄漏同一个视频序列截取的帧前缀相同但时间连续按帧随机切会让时序上的相邻帧同时出现在两边模型等于提前看到了答案。分组维度可以是植株、可以是拍摄批次、也可以是视频序号原则只有一个——信息不能跨集合泄露。3. CNN 两套选型轻量分类网络与 YOLO 检测网络怎么定3.1 看标注形式决定技术路线别把分类数据集硬拿去训检测拿到数据后第一件事是分辨标注粒度这直接决定后面走哪条路线。整图单标签的数据集每张图只有「成熟 / 半熟 / 未熟」一个标签这种只能走图像分类路线ResNet、EfficientNet、MobileNet 这类 CNN 分类网络输入单果或单图输出成熟度类别概率。而框级标注每张图上有若干个框每个框标了类别的数据集才能走目标检测路线YOLO 系列、Faster R-CNN 这类网络输出每个果实的位置框和成熟度类别。这两条路线没有绝对优劣取决于你的落地场景。如果做的是产线分选果实是一个个过相机背景干净、单果居中分类网络就够了速度快、部署简单、标注成本也低。如果做的是果园测产、采摘机器人相机拍到的是一整棵树果实密集且相互遮挡这时候检测网络是唯一选择——你得先知道果实在哪再谈成熟度。标题里没写明标注形式但「含数据集」通常指的是包含果实级别框标注的 VIA 或 VOC 格式这是做检测用得最多的。我把两条路线的对比列成表格方便照着选型维度分类网络ResNet/EfficientNet检测网络YOLOv8标注形式整图单标签标注成本低框级标注标注成本高输出内容全图成熟度概率每个果实的位置 成熟度适用场景产线单果分选、品控抽检果园测产、采摘机器人、大田拍照推理速度极快可跑 CPU较快GPU 或 NPU 部署模型复杂度低容易收敛高需要更多数据和调参3.2 分类路线的模型选择与训练入口如果数据集是整图单标签我一般直接用 torchvision 里预训练好的 ResNet18 或 EfficientNet-B0先用 ImageNet1k 预训练权重初始化再在自己的数据上微调。原因是柑橘成熟度的底层特征边缘、纹理、颜色区块跟 ImageNet 的自然图像特征高度通用从头训的话收敛慢且数据量大概率不够。训练入口的 pytorch 代码核心部分如下import torch, torchvision from torchvision import transforms, models transform_train transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.1, contrast0.1, saturation0.1), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.fc torch.nn.Linear(model.fc.in_features, 3) # 三类成熟度 criterion torch.nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.parameters(), lr0.001, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30)这里注意两点。第一model.fc替换成三分类输出层因为预训练模型最后是 1000 类不换的话前向传播就报维度错误。第二学习率设为0.001而不是从头训练常用的0.01因为预训练权重已经很接近最优点了学习率太大会把学好的底层特征破坏掉。Resize((224, 224))是 torchvision 分类模型的通用输入尺寸如果想用 256 或 512 也行但要注意数据量少时大分辨率反而容易过拟合。3.3 检测路线的 YOLOv8 训练命令与数据集组织如果数据集是框级标注直接用 YOLOv8 是当前最省事的选择不需要自己搭网络结构。先把标注文件按上一章的方法转成 YOLO txt 格式然后把数据集按官方要求的目录组织好写一个data.yaml# data.yaml类名顺序必须与转化脚本的class_dict一致 path: ./citus_dataset train: images/train val: images/val nc: 3 names: [unripe, semiripe, ripe]names的顺序必须跟第 2 章转换脚本里class_dict定义的顺序完全一致否则训练时类别标签对应关系就乱了——比如转换脚本里unripe: 0但这里 names 写成[ripe, unripe, semiripe]那么原来标 0 的未熟果会被当成成熟果训练模型直接学歪。接着用官方命令训练yolo detect train \ modelyolov8s.pt \ datadata.yaml \ epochs100 \ imgsz640 \ batch16 \ lr00.01 \ patience15 \ device0modelyolov8s.pt里的 s 是 small 版本柑橘果实不算小目标但数量多s 版本在速度和精度之间平衡得最好用nnano精度掉太多用mmedium对数据集量要求更高。imgsz640是训练时的输入分辨率如果原始图片是 4K 的果园大图后面推理还要做切图训练分辨率不必跟原图一致。batch16取决于显卡显存8GB 显存的卡跑yolov8s用 16 没问题显存不够就调到 8batch 太小训练不稳定。lr00.01是初始学习率用预训练权重时这个值用官方默认即可。训练完成后会输出runs/detect/train/目录里面有weights/best.pt和weights/last.ptbest 是按验证集指标保存的最优权重last 是最后一轮的权重正式用的时候取 best。从实践来看第一次训练不要急着调参先把一条基线跑通看损失曲线和各类别 mAP再决定下一步是调增强、加轮次还是换模型尺寸。4. 训练配置与调参让模型在半标注数据上收敛的关键参数4.1 预训练权重与冻结训练数据量少时的后悔药农业类数据集通常只有几百到两三千张图这个量级从头训练一个 CNN 是玩不转的几乎必然陷入过拟合训练集准确率 99%、验证集 60% 都是常事。预训练权重就是数据不足时的后悔药它把网络前几层已经训成了通用边缘检测器和纹理提取器你需要做的只是在它基础上学习柑橘成熟度特有的高级语义。常见做法是分两步走第一步冻结骨干网络只训分类头第二步解冻全部层用小学习率微调# 第一步冻结骨干只训分类头 for name, param in model.named_parameters(): if fc not in name and classifier not in name: param.requires_grad False optimizer torch.optim.SGD(filter(lambda p: p.requires_grad, model.parameters()), lr0.001) # 训练5~10轮分类头先收敛 # 第二步解冻全部层学习率降10倍做微调 for param in model.parameters(): param.requires_grad True optimizer torch.optim.SGD(model.parameters(), lr0.0001, momentum0.9)冻结训练的成本很低但收益明确它让先训的分类头在骨干特征还没被扰动时稳定下来之后再解冻所有层一起调的时候损失函数已经有了一个比较好的起点。这套「先冻结后解冻」的节奏在做柑橘这类小数据集时非常稳定几乎不会翻车。要是直接全程微调学习率稍大一点就可能把预训练权重破坏掉损失曲线震荡不降。4.2 数据增强的参数边界别把柑橘增强成橙子数据增强是小数据集训练的必需品但增强幅度要卡在真实场景的物理范围内。柑橘成熟度识别的关键特征就是果皮颜色从青到橙的渐变HSV 颜色增强里hue参数尤其敏感——默认的hue0.015对 YOLOv8 是大范围增强但对柑橘来说色调漂移超过 0.03 就能把青果变成橙果模型学到的是「橙色成熟」这个假规律真实场景下青果都被误判成成熟果。我用的增强配置有两条原则一是hue幅度控制在 0.01 到 0.02 之间只模拟不同品种间细微的底色差异不改变成熟度主色调二是saturation和brightness可以适当放宽到 0.3 左右因为实际果园里光照变化对饱和度和亮度的影响远大于对色调的影响。YOLOv8 的增强参数在hyp.scratch-low.yaml里定义训练时用hyp参数指定自定义配置# hyp_citus.yaml 关键增强参数 hsv_h: 0.01 # 色调增强幅度比默认的0.015更保守 hsv_s: 0.3 # 饱和度增强 hsv_v: 0.3 # 亮度增强 fliplr: 0.5 # 水平翻转概率 mosaic: 0.5 # mosaic增强概率一个对照组实验就能验证增强幅度是否合适用同一份数据、同一个种子分别用默认增强和上面的保守增强训练 50 轮对比验证集 mAP。我做过一次这种实验默认增强的 mAP0.5 是 0.82保守增强能到 0.87差别就在 hue 漂移把训练分布带偏了。4.3 早停、学习率与批次尺寸的联动关系训练时的三个超参数是互相牵制的不能单独调。patience早停参数控制模型在验证集指标连续多少轮不提升就停止训练一般设 15 到 20数据集越小这个值可以越大一点因为小数据集每轮波动大太早停会错过后续的回升。学习率策略上YOLOv8 默认用余弦退火lr0设 0.01 时整个训练过程学习率会平滑降到接近 0如果发现损失曲线在高位震荡不降先把lr0降到 0.005 试试比调别的参数都见效。batch size 跟学习率是联动的batch 翻倍梯度估计更准学习率可以适当加大batch 减半梯度噪声更大学习率最好也减半。这条经验在分类和检测任务里都适用。我用 8GB 显存的卡训练时batch16配lr00.01跑 YOLOv8s 没问题换到 4GB 的卡batch 降到 8lr0就改成 0.005。忘记了这条最常见的翻车就是显存不足改小 batch 后忘记调学习率损失直接炸到 NaN。5. 柑橘成熟度识别踩坑记录5 个让我翻车的细节5.1 标注文件与图片文件名不对齐训练集样本数悄悄减半现象训练日志显示 800 张图只加载了 400 张且没有任何报错mAP 也低得离谱。原因数据集的图片文件名是DJI_0001.jpg标注文件是0001.txt转格式时按os.path.basename(xml_path)输出导致生成的 txt 文件名跟图片对不上。YOLO 训练按图片名去查找对应 txt找不到就跳过该图片且静默处理。解决写一个校验脚本遍历每张图片检查同名 txt 是否存在不存在就打印文件名。更重要的是在 VOC 转 YOLO 时以图片文件名为准生成 txt 文件名而不是以 xml 文件名为准。做数据准备时多花十分钟跑校验能省掉后续排查的一整天。5.2 类别不平衡未熟果占七成模型学成「永远猜未熟」现象训练完成后混淆矩阵里未熟果的召回率 0.95成熟果召回率只有 0.4但整体 mAP 看着还不低。原因果园里未熟果远多于成熟果是物理事实数据集天然倾斜。模型发现全预测未熟就有 70% 准确率懒得学成熟果的特征了。解决一是按类别做采样训练时每轮从成熟果样本里过采样让每个 batch 里三类比例接近均衡二是把损失函数里的类别权重调成反比于类别频率YOLOv8 里可以在data.yaml中设置weight参数分类网络里直接给CrossEntropyLoss(weighttorch.tensor([0.3, 1.0, 1.5]))。我一般两个一起上效果比单用任一更稳。5.3 青果与叶片颜色接近小目标漏检集中在小果上现象验证集里漏检的框 90% 是青果而且集中在果实占整图面积小于 2% 的那些图上。原因青果颜色跟绿叶太接近网络学到的高层特征里「绿色 圆形」还不够独特加上小目标本身在下采样过程中特征被稀释。这是自然场景的通病不是模型结构的问题。解决第一训练分辨率从 640 提到 960显存允许的话小目标的特征保留要好很多第二推理时做大图切块把 4K 图切成 640×640 的块检测完再把框映射回原图坐标这个过程常用 SAHI 框架实现。这两招组合青果漏检率我实际做过能降 40% 以上。5.4 数据增强把成熟度特征破坏了模型学会的是奇怪颜色现象训练损失降得很快但验证集 mAP 只有 0.6 出头而且错误集中在把半熟果判成未熟果。原因YOLOv8 默认的hsv_h: 0.015对一般物体检测没问题但柑橘成熟度的判别特征就是皮色色调一偏移训练样本里成熟果的「橙色」被漂成了别的色模型反而学到了不稳定的色相模式。解决把hsv_h降到 0.01 或直接关掉同时把hsv_s和hsv_v保留在 0.3 水平模拟光照变化。这个坑最隐蔽的地方在于训练曲线一切正常只有做可解释性分析时才发现网络激活区域集中在果皮的边缘而不是整体颜色。5.5 验证集和训练集同源mAP 虚高导致上线翻车现象本地验证 mAP 0.89拉到现场果园实拍数据测试mAP 直接掉到 0.55。原因数据划分时按文件随机切分同一棵树上采集的几十张照片被切到了两边。模型已经在训练时「见」过这颗树的纹理、光照和背景验证指标是作弊指标真实场景没有这种同源关系。解决回到第 2 章的按组划分方案以植株 ID 或拍摄批次为分组单位确保整棵树的样本只在训练集或验证集中出现。这一条也是我做柑橘识别最大的血泪教训当时被 demo 指标欺骗差一点带着虚高模型上产线。6. 不止看 mAP置信度阈值、分类错误矩阵与部署验证训练完模型只是第一步离「能上产线」还有一段路。分类模型的落地验证要看两类错误各自的代价把未熟果判成成熟果漏掉次果比把成熟果判成未熟果浪费好果代价更高因为前者会砸口碑后者只是损失一点利润。这个代价不对称决定了置信度阈值不能简单取 0.5。我用验证集画出各类别的置信度分布曲线取误差代价最小的交叉点作为阈值——产线场景我一般把成熟类的阈值提到 0.7未熟类的阈值压到 0.4。检测模型的有效做法是看混淆矩阵YOLOv8 训练结束会在runs/detect/train/confusion_matrix.png输出归一化混淆矩阵重点看ripe被错分成unripe的比例。如果这个值偏高说明模型对果皮色的过渡段不敏感可以针对性增大semiripe类别的样本权重。另一个容易被忽略的指标是各类别的置信度直方图如果大量正确检测的框置信度集中在 0.3-0.5 区间说明训练不充分或者增强过度先别急着部署。部署侧的验证我习惯用 ONNX 导出后跑到边缘设备上实测帧率而不是只看 GPU 上的 FPS。YOLOv8 官方提供导出命令yolo export modelbest.pt formatonnx imgsz640导出后在边缘设备上跑一个简单的推理脚本统计单帧处理时间。我踩过的一个坑是训练时用的imgsz640是 640×640但推理时输入的实际是 4K 原图letterbox处理后实际推理分辨率超过 640 导致速度大打折扣。解决方案是先做图像缩放再做切块推理每块固定 640×640检测结果映射回原图坐标。我用 Jetson Orin Nano 跑 YOLOv8s 切块推理实测单棵果树约 4K 图处理时间在 1.2 秒左右这个速度对产线按个分选够用对实时采摘机器人还偏慢得换 TensorRT 或剪枝到 nano 模型。验证完这些再谈上线是我现在做农业视觉项目的固定流程。希望帮到你。本文还有配套的精品资源点击获取