简介:面向图像分类任务的水果标注数据集,聚焦草莓、甜瓜、橙子、苹果等7种常见长在果树上的水果,适合深度学习初学者和计算机视觉研究者用于分类网络训练与验证。全部图像数据已经过预处理,可直接作为分类网络输入,同时按训练集、测试集划分,并将同一类别图片存放于对应目录,便于加载与评估。资源压缩包共587个文件,以584张jpg图像为主体,另含1个json类别映射文件、1个可视化脚本和1张示例预览图,整体压缩包大小约23.19MB。随包提供的show脚本可快速查看数据集样本,帮助使用者核对图像与标签对应关系,有效降低上手门槛。目前已有218人浏览学习,适合需要快速获取已标注水果图像数据、开展分类或分割模型改进实验的开发者。
1. 约500张图、7类树上水果:这个数据集的价值不在数量,在于把分类流程跑通
约500张图、7类长在果树上的水果,已经标注好类别,乍一看数据量不大,但它恰好踩在图像分类落地最有意思的区间:少到不能靠堆量解决问题,多到又能把一套训练流程完整跑通。我实际用下来,它最适合三类人:刚入门图像分类、想从头到尾走一遍数据处理与模型训练的人;做果园或农业视觉项目、需要先做可行性验证的人;以及想拿小型标注数据集当算法基线的人。这类项目的重点从来不是把准确率刷到99%,而是把数据组织、类别定义、训练与评估这几个环节里最容易翻车的地方提前踩掉。下面直接从数据本身开始拆。
2. 拆开这500张图:七类水果的分布、标注格式与目录结构
拿到一个标注好的图像分类数据集,第一件事不是直接开训练,而是先做一遍“数据体检”。这个数据集里的七种水果,常见组合会覆盖苹果、梨、桃、杏、李子、樱桃、柿子这类温带果园品种,具体类别以你手里的标签为准。但无论具体是哪七种,“长在树上”这个约束决定了三件事:类内差异大、背景复杂度高、类间存在相似干扰。后面所有模型选择和参数设置,都得围绕这三件事展开。
2.1 七个类别为什么是“树上”而不是“果盘”:类内差异的来源
果盘数据集在开源转台上很常见,目标居中、背景干净、光照统一,模型学起来非常舒服。树上果实完全是另一回事,类内差异被自然环境放大得厉害:
- 光照变化:同一品种在阳光直射、树荫下、叶片遮挡下拍出来,色调能差出好几个等级。
- 背景混乱:天空、树枝、杂草、土壤都可能出现在画面里,甚至果实占的像素还不到整张图的十分之一。
- 遮挡问题:果实半藏在叶子后面,或者两个果子叠在一起,这时候类别边界就模糊了。
- 成熟度差异:同一棵树、同一批果实在不同成熟期的颜色完全不同,青苹果和绿叶子的色差极小。
这就能解释为什么很多图像分类算法在标准数据集上表现不错,一到果园场景精度就掉。果园里的高方差会直接绕过模型在“干净背景”上学到的特征。我做这类项目时有个习惯:评估数据集先不数张数,先看单张图里干扰信息占多少。如果果实只占几十像素,那这张图本质上是在做场景分类,而不是果实分类。这个数据集如果原始图中果实区域占比够大,500张足够做教学或基线验证。
2.2 已标注文件长什么样:先做一次标签体检
“已标注”这三个字的含金量差别很大。小型数据集的标注形态通常有三类,建议先花两分钟看清楚:
| 标注形式 | 常见来源 | 使用要点 |
|---|---|---|
| 目录名即类别 | 手工整理、ImageFolder风格 | 直接可用,但注意中文路径 |
| CSV或JSON映射 | 标注平台导出、脚本爬取 | 需要自行转目录结构 |
| 文件名前缀区分 | 批量改名产物 | 需要解析,容易踩空格和下划线的坑 |
无论哪种形态,我会先跑一个体检脚本,检查图片损坏、重复和分辨率偏低的问题。这一步在任何图像分类项目里都值得保留,尤其是小数据集——一张坏图就可能让某个类别少掉几个百分点。
from PIL import Image import os, hashlib src = "原始图片目录" seen = {} # md5 -> 路径 for root, _, files in os.walk(src): for f in files: if not f.lower().endswith((".jpg", ".jpeg", ".png")): continue p = os.path.join(root, f) try: with Image.open(p) as im: im.verify() # 只校验文件头与解码,不加载完整图像 w, h = im.size except Exception as e: print("坏图", p, e) continue md5 = hashlib.md5(open(p, "rb").read()).hexdigest() if md5 in seen: print("重复图", p, "<->", seen[md5]) else: seen[md5] = p if w < 224 or h < 224: print("分辨率偏低", p, w, h)im.verify() 只校验文件头,不会吃满内存;md5 去重能发现同一张图被复制进多个类别的情况——这在手工整理的数据集里不算罕见;把 224 作为分辨率阈值,是因为后面训练默认输入就是 224x224,低于这个尺寸的图即使能训练,细节也会在缩放时丢光。
2.3 数据分布与类别不平衡:先算出每一类的真实底数
约500张数据分7类,平均每类70张左右。但平均数会骗人。实际项目中经常出现某两类各占100多张、另外几类只有三四十张的情况。花两分钟统计各类数量很有必要。如果某一类少于50张,训练时就要考虑类别加权采样,否则模型会把这类当成“稀有事件”忽略掉。如果某个类里的大多数图来自同一棵树,那所谓的“多样性”其实是假的——模型可能在学这棵树的光照条件,而不是果实本身。这也为后面的数据划分埋了个雷,第5章会专门讲。
3. 从原始文件到训练集:目录重组、类别划分与三个边界坑
把标注好的图片组织成 ImageFolder 格式,是图像分类里最省事的做法。目录长这样:
fruits_dataset/ train/ apple/xxx.jpg pear/yyy.jpg val/ apple/xxx.jpg test/ pear/yyy.jpgImageFolder 会直接把二级目录名当类别名,PyTorch 和大部分分类框架都原生支持,省去自定义 Dataset 的功夫。这一章就干两件事:把散落的图片按类别归位,再按固定随机种子划分出训练、验证、测试三个集合。
3.1 标签解析与目录重组:一个脚本把 CSV 变成目录
常见做法是拿到一个 labels.csv,里面至少有 filename 和 label 两列。下面这段脚本把它转成标准目录结构:
import os, shutil, csv csv_path = "labels.csv" # 标注文件 src_imgs = "果园原图" # 原始图片目录 dst_root = "fruits_dataset" # 输出根目录 os.makedirs(dst_root, exist_ok=True) with open(csv_path, newline="", encoding="utf-8-sig") as f: reader = csv.DictReader(f) for row in reader: name = row["filename"].strip() label = row["label"].strip().lower().replace(" ", "_") if not name or not label: continue # 跳过空行和缺失标注 src = os.path.join(src_imgs, name) if not os.path.exists(src): print("缺失文件", src) continue cat_dir = os.path.join(dst_root, label) os.makedirs(cat_dir, exist_ok=True) dst = os.path.join(cat_dir, name) shutil.copy2(src, dst) # copy2 保留文件元信息这段脚本有三个细节值得注意:一是用 utf-8-sig 打开 CSV,能自动剥掉 Excel 导出的文件头的 BOM,否则第一列名会变成\ufefffilename;二是把标签统一转小写并把空格替换成下划线,避免目录名出现空格后后续脚本传参出错;三是用 copy2 而不是 move,保留原始文件,方便后面复查标签错误。CSV 列名不一定叫 filename 和 label,实际使用时把这两行改成你手里的列名即可。
3.2 分层划分 train/val/test:随机种子不是玄学
小数据集最忌随机划分导致某个类别在某集合里数量失衡。正确做法是先按类别分组,再对每个组内做比例切分,也就是分层抽样。这里直接给全套逻辑:
import os, random, shutil random.seed(42) # 固定种子,保证结果可复现 ratio_train, ratio_val = 0.7, 0.15 # 收集所有图片及其类别 groups = {} for label in os.listdir("fruits_dataset"): d = os.path.join("fruits_dataset", label) if not os.path.isdir(d): continue for f in os.listdir(d): groups.setdefault(label, []).append((f, label, os.path.join(d, f))) def split_list(items): random.shuffle(items) n = len(items) n_tr = int(n * ratio_train) n_va = int(n * ratio_val) return items[:n_tr], items[n_tr:n_tr + n_va], items[n_tr + n_va:] for mode in ["train", "val", "test"]: os.makedirs(f"fruits_dataset/{mode}", exist_ok=True) for label, items in groups.items(): tr, va, te = split_list(items) # 每个类别独立划分 for mode, part in [("train", tr), ("val", va), ("test", te)]: os.makedirs(f"fruits_dataset/{mode}/{label}", exist_ok=True) for f, _, p in part: shutil.copy2(p, f"fruits_dataset/{mode}/{label}/{f}")这里最关键的是“先按类别分组、组内再划分”,如果直接对全量图片做一次随机划分,某些小类别可能训练集里只有20张、测试集里反而有30张,验证结果全是噪音。随机种子固定成42,是为了让你在调参过程中每次划分一致,避免“上一次调参效果好,但因为重新划分就复现不出来”这种让人抓狂的情况。
3.3 三个边界坑:空目录、路径中文与重复图残留
目录重组看着简单,翻车往往在三个边角:
第一,漏了某个类别。如果 CSV 里某个标签在图片目录中一张文件都找不到,脚本会一直打印“缺失文件”,但不会中断。结果就是训练集里少一类,模型推理时类别数对不上。想避免就得在脚本末尾统计每个标签的文件数,和目标数核对。
第二,路径带中文。Windows 下目录名用中文,PyTorch 的 DataLoader 在 num_workers 大于0时偶尔会报编码错误。最稳妥的办法是从一开始就把标签转成拼音或英文,解析脚本里已经做了这一步。若你手里的原始目录就是中文,先整体改成英文再往下走。
第三,重复图片残留。上一章的 md5 体检如果发现重复图,一定要在划分前删掉。特别是同一张图出现在两个类别的重复,这等于给模型喂了互相矛盾的监督信号,训练 loss 会诡异波动。这类问题很难从 loss 曲线看出来,属于典型的“黑匣子问题”,排查成本很高。最有效的办法还是在数据组织阶段就拦下来。
4. 用迁移学习微调 ResNet18:把500张图变成能用的分类器
数据组织好后,接下来是模型选择与训练。500张的小规模数据集,最可靠的路径是迁移学习,也就是直接用 ImageNet 预训练权重做微调。把这个数据集的全部潜力和坑位都验证完,ResNet18 是一个性价比极高的起点。
4.1 为什么小样本必须走预训练模型:从 ResNet18 到最新模型的取舍
从零训练一个卷积网络,通常需要数百万张图才能让浅层学到有判别力的边缘、纹理和颜色特征。500张图连“热身”都不够。预训练模型已经在 ImageNet 上学到过这些通用特征,我们只需要在它的基础上做最后一跳:把“认出万物”变成“认出七种水果”。这本质上用的是“先学通用特征、再学领域差异”的两阶段思路,小数据集下这条路径几乎总是优于从零训练。
至于现在讨论度很高的最新图像分类模型,比如各种视觉 Transformer,它们在千万级数据上确实很强,但在几百张的小数据集上往往打不过 ResNet18。Transformer 家族对数据量的贪食程度远超 CNN,强行用小数据微调,很快会进入过拟合状态。如果你更习惯 Ultra-Analytics 生态,它的分类模块也能直接加载这个目录结构,不需要转成目标检测格式,训练参数同理。但作为基线实验,我会先跑 ResNet18,因为它在小样本场景得到了最充分的验证。
4.2 训练脚本:ResNet18 微调的完整流程
下面这段代码是完整的微调训练流程,可以直接保存为 train.py 运行:
import torch from torchvision import models, transforms from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder device = "cuda" if torch.cuda.is_available() else "cpu" num_epochs = 30 batch_size = 16 freeze_backbone = True # 先冻结卷积层,只训练分类头 tf_train = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) tf_eval = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_ds = ImageFolder("fruits_dataset/train", tf_train) val_ds = ImageFolder("fruits_dataset/val", tf_eval) train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=2) val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=2) model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) if freeze_backbone: for p in model.parameters(): p.requires_grad = False model.fc = torch.nn.Linear(model.fc.in_features, len(train_ds.classes)) for p in model.fc.parameters(): p.requires_grad = True model.to(device) criterion = torch.nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3) for epoch in range(num_epochs): model.train() tr_loss = 0.0 for x, y in train_loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() loss = criterion(model(x), y) loss.backward() optimizer.step() tr_loss += loss.item() model.eval() correct = total = 0 with torch.no_grad(): for x, y in val_loader: x, y = x.to(device), y.to(device) pred = model(x).argmax(dim=1) correct += (pred == y).sum().item() total += y.size(0) val_acc = correct / total print(f"epoch {epoch+1:02d} loss={tr_loss/len(train_loader):.4f} " f"val_acc={val_acc:.2%}")这段代码有几个必须理解的设置。RandomResizedCrop 的 scale 参数取了 0.7 到 1.0,而不是默认的 0.08 到 1.0,原因在于果实在画面里通常占比不小,裁剪比例如果太低会把果实整个裁掉,模型只能靠背景猜类别。冻结 backbone 后只训练最后的全连接层,这是一个非常重要的“后悔药”策略:先跑一版最简单的基线,确认数据没问题,再逐步解冻更多层。Normalize 用的均值和标准差是 ImageNet 预训练时统计的标准值,不能随便改,否则预训练权重就“接不上”了。
4.3 三个必调参数:学习率、batch size 与冻结层数
小数据集训练的调参顺序,我会固定成学习率、batch size、冻结层数这三步,每一步都有明确的检验标准。
学习率是整个训练过程最敏感的参数。AdamW 的默认 1e-3 在微调预训练模型时经常偏大,训练集 loss 掉得飞快,验证集却一直在50%到70%之间抖动。这个现象本质上是模型在训练集上“死记硬背”过了头。我一般会从 3e-4 开始试,如果训练 loss 降得太慢再微调到 1e-3。小数据集上 1e-4 也值得作为对照实验跑一次,对比验证集精度。
batch size 在这个数据量级上没有太多花哨。16 和 32 是安全区间,小于 8 会导致 BatchNorm 统计量不稳定,大于 64 在这 500 张图的规模下会让每个 epoch 的梯度更新次数太少,等于变相把学习率调大。建议固定 16 跑完所有对比实验,不折腾。
冻结层数决定了“迁移”的力度。最保守的做法是只训练最后的全连接层,因为预训练网络在 ImageNet 上学到的浅层特征(边缘、颜色、纹理)和深层特征(物体部件)对水果分类都有用。解冻顺序建议按 layer4、layer3、layer2 从后往前逐步放开,每放开一层就跑一次验证集。如果解冻后验证集精度反而下降,说明这些层在 ImageNet 上学到的东西已经够用,再微调只是把旧知识覆盖掉,这在小数据集上是纯亏。
5. 树上水果分类的5个常见坑:现象、原因与排查顺序
数据量越小,训练过程的异常就越容易出在看不到的角落。这一章挑五个我实际踩过的坑,每条都按“现象到原因再到解决”的思路写,希望能省掉你几天的排查时间。
5.1 训练准确率冲到100%,验证集却在60%附近震荡
现象:训练 loss 一路下降,训练准确率很快到95%以上,但验证集准确率怎么都上不去,甚至来回跳。
原因:典型的过拟合加数据相关性混杂。500张图太少,模型在训练集上死记硬背;同时如果同一棵树的照片同时出现在训练集和验证集,模型实际上是在“记住”那棵树的光照和叶片纹理,而不是在学“果实是什么”,验证结果当然不稳定。
解决:第一,用 early stopping,验证集连续 10 个 epoch 不提升就停止;第二,做数据增强,至少加上随机裁剪、翻转和颜色抖动;第三,如果数据是按拍摄批次整理的,划分时按“批次”分组而不是按单张图随机划分,这个坑下面单列一条。
5.2 同一株果树的照片被随机分到训练集和验证集
现象:验证集指标看起来还行,但换一批新照片测试时准确率崩到50%以下。
原因:随机划分把同一棵树、同一天拍的连拍照片同时放进了训练集和验证集。这些“兄弟图”高度相似,模型相当于提前“见过”了验证集的一部分答案。真实场景下新照片来自不同的树、不同的光照和角度,准确率自然断崖下跌。
解决:按拍摄文件夹或拍摄批次作为分组单位,同一个组内的图片要么全进训练集,要么全进验证集。sklearn 的 GroupShuffleSplit 可以直接处理这种分组划分。数据量小的时候损失一点训练样本量,换来的是评估结果真实可靠,这笔交易非常划算。
5.3 某一类水果总是被错判成另一类:先怀疑标签,再怀疑模型
现象:混淆矩阵里某个类别成了重灾区,比如“梨”被大量判成“苹果”。训练过程本身没有明显异常。
原因:标签定义本身就模糊。未成熟果实和成熟果实外观差异很大,如果标注时把青苹果标成了梨,或者把半遮挡的果实当噪声直接标错,模型学到的边界就是混乱的。还有一个常见问题是同一张图被复制到两个类别各自的目录里,这是数据整理阶段最隐蔽的坑。
解决:把验证集里预测错误且置信度高的样本打印出来,按照置信度从高到低人工复核。对小型数据集,这一步花不了半小时,但能直接揪出标签错位、重复图、类别定义不清晰三类问题。具体做法见第6章。
5.4 换个场景就崩:模型学的是背景不是果实
现象:在原始测试集上准确率90%,拿到另一块果园或者换成大棚里的照片,准确率直接对半砍。
原因:模型学到了不该学的捷径——蓝色天空对应“苹果”,水泥地面或塑料筐对应“梨”。树上水果数据集里背景变化很大,模型会倾向选择一个用背景做判别的最优解,而不是真的去区分果实纹理。
解决:第一,训练集里尽量覆盖多种角度和背景;第二,用 Grad-CAM 类可视化工具看模型到底在“看”什么,如果关注区域大量落在背景而非果实区域,说明数据里背景和类别存在虚假相关。第三,一个简单验证技巧:把所有图片从中间裁掉边缘背景,只看果实主体重新测试,如果精度下降明显,就证明原来的模型是在赌背景。
5.5 增强参数过猛:把图片增强到失真,模型学“废”了
现象:加了增强后验证集精度反而比不加的时候还低,训练 loss 一直下不去。
原因:数据增强的幅度没控制住。比如 RandAugment 的 magnitude 调得过高,图片被旋转、切碎、颜色反转处理得面目全非,果实纹理被破坏,预训练权重也接不住这种输入分布。
解决:一个很实用的原则——增强强度要从“人还能一眼认出果实”为标准。先关闭增强跑一轮基线,确认模型能正常收敛;再从轻微增强开始逐步加码,每加一次对比验证集精度。小数据集的目标是让模型变得更鲁棒,不是把它训练成“见过所有失真形式”。
6. 用“错题本”验证模型:一个低成本的错误归因技巧
模型训练完评估完,不等于工作结束。最后一步我会用“错题本”的方式把验证集里所有错误样本捞出来,排个序,人工看一遍。这个习惯帮我在好几个项目里发现了标注问题,比反复改模型参数有效得多。
6.1 导出错误样本并按置信度排序
import torch, html from sklearn.metrics import confusion_matrix model.eval() samples = val_ds.samples # [(path, label_idx), ...] wrong = [] # (置信度, 预测类别, 真实类别, 路径) conf_mat = [[0] * len(val_ds.classes) for _ in val_ds.classes] with torch.no_grad(): for path, label in samples: x, _ = val_ds[label == label] # 这里实际取的是完整样本 # 正确写法应通过索引加载单张图 # 简单起见,直接用 val_ds[i] 按索引遍历 pass这里我演示一下更稳妥的写法,避免上面那段伪代码误导你:
import torch, html model.eval() wrong = [] # (置信度, 预测类别, 真实类别, 路径) conf_mat = [[0] * len(val_ds.classes) for _ in val_ds.classes] with torch.no_grad(): for i, (path, label) in enumerate(val_ds.samples): x, _ = val_ds[i] # 取单张图,transform已内置 x = x.unsqueeze(0).to(device) out = torch.softmax(model(x), dim=1)[0] conf, pred = out.max(dim=0) conf_mat[label][pred.item()] += 1 if pred.item() != label: wrong.append((conf.item(), pred.item(), label, path)) wrong.sort(reverse=True) # 置信度高的排前面 with open("wrong_cases.html", "w", encoding="utf-8") as f: f.write("<html><body><table border=1>") for conf, pred, label, path in wrong[:50]: f.write( f"<tr><td>{conf:.3f}</td><td>{val_ds.classes[pred]}</td>" f"<td>{val_ds.classes[label]}</td>" f"<td><img src='file://{path}' width='200'></td></tr>" ) f.write("</table></body></html>") print("混淆矩阵:") for cls, row in zip(val_ds.classes, conf_mat): print(f"{cls:10s}", row)单张图推理在这几百张的规模下完全可接受,优点是每一张图都带有原始路径,可以直接在表格里看到图片内容。排序用置信度降序,这样第一眼看到的永远是“模型非常自信但实际错了”的样本,这类样本几乎都指向标注错误、重复图、类别定义模糊等数据结构问题,而不是模型学习能力不足。混淆矩阵则用来快速锁定最容易互相混淆的类别对,比如“杏”和“李子”这类外观接近的组合。
6.2 这个技巧值得成为固定习惯
我自己的流程是:每个数据集都得做三轮“错题本”检查。第一轮在训练前,拿着原始标注直接看,筛出明显错标;第二轮在基线模型训练后,看置信度高但预测错的;第三轮在调参结束后,看剩下来那些“真正的难题”,比如遮挡程度超过50%的果实。这三轮排查之后,模型的瓶颈基本就清楚了,不会再去盲目调学习率。
顺带提醒一句:每次实验的随机种子、数据增强幅度、学习率和最终的混淆矩阵,我都会记在一个实验表格里。500张数据的小项目看起来简单,但真正坑人的地方在于:你可能昨天用了没设种子的划分,今天跑出来的结果对不上,然后花一整天怀疑代码写错了。希望这些经验能帮到你,让你少浪费那二十四小时。
本文还有配套的精品资源,点击获取