
简介面向图像分类任务的水果标注数据集聚焦草莓、甜瓜、橙子、苹果等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_okTrue) with open(csv_path, newline, encodingutf-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_okTrue) 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(ffruits_dataset/{mode}, exist_okTrue) for label, items in groups.items(): tr, va, te split_list(items) # 每个类别独立划分 for mode, part in [(train, tr), (val, va), (test, te)]: os.makedirs(ffruits_dataset/{mode}/{label}, exist_okTrue) for f, _, p in part: shutil.copy2(p, ffruits_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_sizebatch_size, shuffleTrue, num_workers2) val_loader DataLoader(val_ds, batch_sizebatch_size, shuffleFalse, num_workers2) model models.resnet18(weightsmodels.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(), lr1e-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(dim1) correct (pred y).sum().item() total y.size(0) val_acc correct / total print(fepoch {epoch1:02d} loss{tr_loss/len(train_loader):.4f} fval_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), dim1)[0] conf, pred out.max(dim0) conf_mat[label][pred.item()] 1 if pred.item() ! label: wrong.append((conf.item(), pred.item(), label, path)) wrong.sort(reverseTrue) # 置信度高的排前面 with open(wrong_cases.html, w, encodingutf-8) as f: f.write(htmlbodytable border1) for conf, pred, label, path in wrong[:50]: f.write( ftrtd{conf:.3f}/tdtd{val_ds.classes[pred]}/td ftd{val_ds.classes[label]}/td ftdimg srcfile://{path} width200/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张数据的小项目看起来简单但真正坑人的地方在于你可能昨天用了没设种子的划分今天跑出来的结果对不上然后花一整天怀疑代码写错了。希望这些经验能帮到你让你少浪费那二十四小时。本文还有配套的精品资源点击获取