简介:7种常见长在果树上的水果图像分类数据集已按深度学习项目标准整理完毕,主要面向计算机视觉初学者和图像分类任务实践者,可直接作为分类网络输入。数据包含草莓、甜瓜、橙子、苹果等7个类别,约500张标注图片,利用配套json文件可查看类别与标签信息;训练集、测试集已划分完毕,同一类图片存放于对应目录,便于直接训练评估。压缩包共587个文件,其中584张jpg图像、1个Python可视化脚本、1张示例png和1个json标注文件,整体大小23.19MB,轻量易下载。已有218人浏览学习,适合用于图像分类入门实验或网络改进对比。运行show脚本即可预览数据集,省去手工整理标签和划分数据的重复工作,可快速聚焦模型结构或训练调参。
1. 500张标注好的果树水果图,够不够你跑通一个图像分类项目?
先把这个数据集的价值说透:一份“7种常见(长在果树上的水果)图像分类数据集”,已标注,约500张,意味着你拿到的不是一堆散图,而是每张图都已经告诉你它属于哪一类水果的现成训练料。对刚接触图像分类、或者想在一个垂直场景里快速验证迁移学习流程的人来说,这是性价比极高的起点。500张图不大,做不了大模型预训练,但配合ImageNet预训练权重做微调,完全能跑出一个可用的分类器——这也是绝大多数落地项目的真实做法。我见过不少团队拿着上万张数据照样翻车,也见过有人用500张图就把果园巡检的分类原型做出来了,差别不在数据量,在流程和参数是否踩对。
2. 数据集拆解:7类果树水果、约500张样本,标注到底长什么样
2.1 7类水果怎么选:果树上长的和货架上的不是一回事
标题里“长在果树上的水果”这个限定不是废话,它直接决定了数据集的内容边界。常见做法是覆盖苹果、梨、桃、樱桃、李子、柑橘、芒果这一类典型乔木果树的水果,偶尔也会把柿子、石榴放进来凑数。这里有个容易被忽略的细节:香蕉虽然是常见水果,但它长在草本植株上;葡萄是藤本,也不是树。如果你拿着这类数据集去训练一个“树上长的水果”分类器,又混入了香蕉和葡萄,模型学到的特征边界就会变模糊,尤其是当背景里同时出现藤架和树干时。
选7类而不是5类或10类,也是有讲究的。类别太少,模型学不出类间差异的泛化能力;类别太多,平均到每类的样本数就太稀薄。约500张、7类,每类差不多70张左右,这个密度刚好够迁移学习做微调,也是小样本图像分类实验里最常碰到的配置。70张/类不是一个随便的数字:低于50张/类时,即便用预训练模型,也很难压住过拟合;超过100张/类时,数据本身的收益就开始递减,瓶颈会转移到模型和调参上。
2.2 已标注的真实含义:文件夹式标签与清单式标签
“已标注”在图像分类数据集里有两种常见形态。第一种是文件夹式:根目录下7个子文件夹,每个文件夹名就是类别名,里面放着对应的水果图片,这种格式对新手最友好,PyTorch的ImageFolder和Keras的flow_from_directory都能直接读。第二种是清单式:一个CSV或JSON文件,每行记录文件名,类别这样的映射,图片平铺在一个大目录里。拿到数据集先确认是哪一种,这决定了你的数据加载代码怎么写。
这里要特别提醒一个误区:很多人拿到“已标注”就以为可以直接开训,但图像分类的标注只有类别标签,没有目标框。这和目标检测数据集不是一回事——检测数据集的标注是坐标框加上类别,比如YOLO格式的txt文件里每行是类别 cx cy w h。如果你后续想从分类升级到检测,比如照“用YOLOv8训练自己的数据集”那套流程来定位果树上的果实,这份分类标注是直接派不上用场的,得重新标框。所以先问清楚自己的需求:只要判断“这张图里是什么水果”,分类标注够了;要知道“水果在图的哪个位置”,得另找检测数据集。
2.3 500张够不够:迁移学习下的最低可用规模
直接给结论:500张做图像分类,够用,但前提是走迁移学习,不能从零训练。从零训练一个ResNet级别的网络,ImageNet-1K那种百万级数据是标准配置,几十万张都算少的;500张从零训练,train loss能降,val loss基本不会跟着降,模型记住的是训练集本身,换个环境立刻失效。这正是很多人第一次拿小数据集做分类时的困惑——明明loss很低,准确率却上不去,原因就是没有预训练权重兜底。
迁移学习的思路是:用一个在ImageNet-1K上预训练好的模型,把前面所有层的参数作为初始化,只重新学习最后几层(甚至只重新学一个分类头)。预训练模型已经掌握了纹理、边缘、颜色分布这些通用视觉特征,果树水果的分类任务本质上只是在这个基础上做一次细分类的适配。500张、7类、每类约70张,在这个框架下是完全可以跑出90%上下准确率的,具体取决于图片的拍摄一致性和类别之间的相似度。如果图片全是网上下载的白底商品图,难度会低很多;如果是果园实地拍摄、带光照变化和枝叶遮挡,难度会明显上升,这一点的预判比数据量本身更重要。
3. 数据准备:划分训练验证集、预处理与加载器
3.1 先划分再增强:小数据集最容易踩的数据泄漏
拿到数据集第一件事不是写模型,而是划分训练集和验证集。常见做法是8:2或7:3,比如500张里划400张训练、100张验证。这个步骤看起来简单,但有一个隐藏陷阱:必须先划分,再做数据增强,绝不能先增强再划分。如果你先把500张图增强成5000张,再从增强后的池子里划分,同一张原图的各种增强版本会同时出现在训练集和验证集里,验证集就失去了意义——模型相当于提前见过答案。
另一个数据泄漏风险来自拍摄批次。如果这份数据集的图片是按拍摄时间、光线条件分批次采集的,随机划分时同一批次的图片很可能同时落入训练和验证集。这样验证集准确率高得漂亮,但部署到新场景马上露馅。稳妥做法是按文件名的批次前缀或目录分组划分,比如前60%的组做训练、后40%做验证。小数据集样本本来就少,验证集还不能代表真实分布的话,后面的调参全部白费。
import os import random import shutil src_root = "fruit_dataset_7cls" # 原始数据集,内含7个类别子目录 out_root = "fruit_split" train_ratio = 0.8 # 收集所有样本路径,按“目录:文件名”记录,保证每个类别都参与划分 samples = [] for cls_name in os.listdir(src_root): cls_dir = os.path.join(src_root, cls_name) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): if fname.lower().endswith((".jpg", ".jpeg", ".png")): samples.append((cls_name, fname)) random.seed(42) # 固定随机种子,让划分结果可复现 random.shuffle(samples) train_count = int(len(samples) * train_ratio) for idx, (cls_name, fname) in enumerate(samples): split = "train" if idx < train_count else "val" dst_dir = os.path.join(out_root, split, cls_name) os.makedirs(dst_dir, exist_ok=True) src_path = os.path.join(src_root, cls_name, fname) dst_path = os.path.join(dst_dir, fname) shutil.copy2(src_path, dst_path) print(f"划分完成:训练集 {train_count} 张,验证集 {len(samples) - train_count} 张")这段脚本的逻辑是按8:2比例对全部样本做随机划分,然后复制到train/类别和val/类别的目录结构里。random.seed(42)是为了可复现——如果你调参后发现结果有改善,但不知道是改参起效还是划分变了,那就说不清了。copy2保留文件的元信息,比copy多一些调试时可用的信息。如果你担心拍摄批次泄漏,可以在shuffle之前先按文件名前缀分组,再做组级别划分,而不是逐张划分。
3.2 预处理参数:均值和标准差不是随便填的
图像分类的预处理有一组约定俗成的参数,在PyTorch生态里用ImageNet的均值方差做归一化是标准做法。为什么不用自己的数据集算均值?因为预训练模型是在ImageNet上训练的,它的权重假设输入数据服从ImageNet的分布。你用自己的均值把像素值强行拉到自己数据集的分布上,反而和预训练权重期望的输入对不上,特征提取层会水土不服。
from torchvision import transforms # 训练集增强:随机裁剪模拟不同取景,翻转和颜色抖动对抗光照变化 train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.3, hue=0.05), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 验证集不增强,只做尺寸统一和归一化 val_transform = 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]) ])这里几个参数需要理解它的意图。RandomResizedCrop(224, scale=(0.6, 1.0))是随机裁剪一块原图面积60%到100%的区域再缩放到224×224,这模拟了同一颗水果在不同距离、不同取景下的观感,同时也顺带实现了轻微的尺度不变性。scale下限设0.6是为了不让裁剪区域过小,否则模型会学出一堆极端局部特征。ColorJitter里brightness=0.3表示亮度在±30%范围随机扰动,果园里阳光从早到晚变化很大,这正是给模型打的“预防针”。
3.3 DataLoader配置:batch size与workers怎么设
数据准备好之后,加载器配置对训练稳定性的影响比很多人想象的大。小数据集场景下,batch_size一般取16或32。取16可以让每批的梯度估计更频繁地更新,在小数据集上收敛更稳;取32训练更快,但每类样本在batch里的覆盖率会波动。我的习惯是先从16开始,如果训练loss曲线平滑、没有明显震荡,再试32。num_workers在Windows上设0最省心,Linux上设4或8,具体看CPU核数。
from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder train_dataset = ImageFolder(root="fruit_split/train", transform=train_transform) val_dataset = ImageFolder(root="fruit_split/val", transform=val_transform) train_loader = DataLoader( train_dataset, batch_size=16, shuffle=True, num_workers=4, pin_memory=True, ) val_loader = DataLoader( val_dataset, batch_size=16, shuffle=False, num_workers=4, pin_memory=True, ) print("类别映射:", train_dataset.class_to_idx) print("训练集样本数:", len(train_dataset)) print("验证集样本数:", len(val_dataset))ImageFolder会自动扫描fruit_split/train下的子目录,按子目录名生成类别索引,这是最省事的加载方式。shuffle=True只对训练集开,验证集必须保持顺序,因为后面要做逐样本错误分析时,顺序乱了就对应不上文件名。pin_memory=True在GPU训练时能减少CPU到GPU的拷贝时间,数据集只有500张时感知不强,但养成习惯没坏处。跑通这段代码后,先确认打印出的类别映射是不是7类、样本数是不是和之前划分的一致——这一步能拦住80%的“目录路径配错”问题。
4. 训练流程:用预训练ResNet18跑通7类水果分类
4.1 为什么选ResNet18而不是更大的模型
在小样本图像分类里,模型选型的第一原则是“够用就好”。ResNet18是权衡之选:它比ResNet50浅,但已经具备残差连接带来的梯度稳定性;参数量约1170万,在500张数据上不容易把训练集的噪声细节全部背下来;推理速度快,CPU上也能跑。反观ResNet50或EfficientNet-B7这类大模型,能力更强,但在数据量不够时反而更容易过拟合,训练时间也更长。最新的图像分类模型比如ViT、Swin Transformer,在大规模数据集上确实碾压CNN,但它们的训练对数据量和超参敏感度都很高,500张数据深度不够,初学者大概率调不出来效果。
这里有个反直觉的结论:小模型在小数据上不一定比大模型差,很多时候反而更好。ResNet18在每类70张的配置下,配合预训练权重,验证准确率能做到85%到95%之间。而换成ResNet50,如果不加更强的正则化和更小的学习率,验证集反而更容易出现过拟合式的震荡。先把ResNet18跑通,如果后续数据扩充了,再平滑升级到ResNet50,这是最务实的路径。
4.2 迁移学习的关键一步:替换分类头
预训练ResNet18最后接的是一个1000类的全连接层(对应ImageNet的1000个类别),要把它替换成7类输出。这一步是整个迁移学习流程的核心:保留前面所有卷积层已经学到的通用特征,只让最后一层从头学怎么把512维特征映射到7种水果的类别概率。
import torch import torch.nn as nn from torchvision import models # 加载ImageNet预训练权重,旧版本PyTorch可写成 pretrained=True weights = models.ResNet18_Weights.IMAGENET1K_V1 model = models.resnet18(weights=weights) # 替换分类头:查看原fc层的输入维度,接一个7输出的线性层 num_features = model.fc.in_features model.fc = nn.Linear(num_features, 7) # 把模型放到GPU上(没有GPU的机器会自动用CPU) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) # 为分类头单独设一个较大的学习率,主干网络用较小的学习率 param_groups = [ {"params": [p for name, p in model.named_parameters() if "fc" not in name], "lr": 1e-4}, {"params": model.fc.parameters(), "lr": 1e-3}, ]model.fc.in_features这一步很关键——它不问ResNet18内部结构就知道分类头的输入维度是多少,以后换ResNet34、ResNet50这段代码都不用改。关于学习率分层:主干网络已经是训练好的,只需要微调,学习率给1e-4;分类头是从零开始的,需要学得更快,给1e-3。如果不分层统一用1e-3,主干网络微调步子太大,容易把预训练学到的好特征冲掉,这在调参里是血泪经验。
4.3 训练参数:学习率、epoch、损失函数的搭配
小数据集的训练配置和工业级大模型训练是两套逻辑。损失函数用CrossEntropyLoss,这个基本没有悬念。优化器我一般用Adam,因为它的自适应学习率在小数据上更稳,不熟悉SGD动量的人不容易翻车。完整训练脚本如下:
import torch import torch.nn as nn from torch.optim import Adam criterion = nn.CrossEntropyLoss() optimizer = Adam(param_groups) best_acc = 0.0 epochs = 40 for epoch in range(epochs): model.train() train_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() train_loss += loss.item() * images.size(0) # 每个epoch结束后在验证集上评估一次 model.eval() correct = 0 total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) val_acc = correct / total avg_train_loss = train_loss / len(train_dataset) print(f"Epoch {epoch+1}/{epochs} | Train Loss: {avg_train_loss:.4f} | Val Acc: {val_acc:.4f}") # 只保存验证集准确率最高的权重,防止把最差的模型留到最后 if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "fruit_best.pth") print(f" -> 保存新最佳模型,准确率 {best_acc:.4f}")40个epoch是这类小任务的合理区间。更重要的是torch.save的位置——很多初学者在训练结束后统一保存最后一轮模型,但最后一轮往往不是验证集上最好的那轮,因为后期过拟合会让验证准确率掉头向下。按验证集准确率实时保存最佳模型,相当于给训练过程买了后悔药。
4.4 训练日志里看什么:过拟合和欠拟合的信号
同样的训练脚本,如何从打印出的日志判断模型状态,这比代码本身更难用文字讲清楚。几个基本判据:
train_loss持续下降、val_acc同步上升,这是健康状态。train_loss降到很低,val_acc却停滞或下降,这是过拟合的典型信号——模型开始死记训练集,在验证集上反而丧失泛化能力。此时先不要加数据(你没数据可加),优先检查三点:增强强度是否不足、学习率是否偏大、模型是否过大。另一种情况是train_loss和val_acc都上不去,这通常是学习率过小、模型没有有效学习,或者预训练权重没加载成功——检查model.fc的输出维度是否还是1000,这个错误很隐蔽,报错不会提示但效果会奇差。
最后一轮结束后,先看best_acc是多少。如果低于80%,不要急着调参,优先回头检查划分有没有泄漏、预处理有没有配对、类别数是不是真的7类——很多时候不是模型的问题,是数据管线的问题。如果验证准确率在90%以上,说明模型已经学到了有效特征,接下来要做的是在真实场景里验证,而不是继续在验证集上打磨。
5. 避坑排查:小样本图像分类最常见的5个翻车现场
5.1 数据侧的坑:划分泄漏、类别不均、标注噪声
第一个坑:验证集准确率虚高,部署到现场立刻崩。现象是离线验证集上准确率95%,拿到手机拍一张果园实景图,预测结果完全不对。原因是数据划分时有泄漏——同一个拍摄批次里光照、背景相似的图片同时出现在训练集和验证集,模型学的是背景特征而不是水果特征。解决方法是按拍摄批次分组划分,或者在数据准备阶段主动加入背景多样化,比如把训练集中的叶片遮挡、土壤背景、阳光直射等情况的比例刻意调高。
第二个坑:某两个类别的准确率被互相拉低。现象是整体准确率90%,但查混淆矩阵发现“李子”和“樱桃”互相认错,或者“青苹果”和“梨”分不清。原因是这两类在视觉上确实接近,而且每类只有70张,模型学到判别性特征的空间不够。解决方法是先看这两类的样本,确认标注本身没有错误——我遇到过“标注时把青苹果标成梨”的情况,这属于标注噪声;如果标注没问题,则给这两个易混类单独加样本,或者对这两类做更强的颜色扰动,逼模型去学形状特征而不是颜色特征。
第三个坑:某个类别的样本数和别的类差太多。现象是训练日志里每个epoch的loss都在降,但模型几乎把所有样本都预测成样本数最多的那一类。原因很简单:7类约500张,不是严格平均分配,可能某一类有120张,另一类只有40张,模型找到了“全猜多数类”这个捷径。解决方法是给少的类别做针对性增强,比如多做一些随机旋转和小角度仿射变换,或者用WeightedRandomSampler给少数类更高的采样权重。查看train_dataset.class_to_idx和各类样本数,这一步应该在训练前就做。
5.2 训练侧的坑:过拟合、学习率失控、预训练权重没生效
第四个坑:train_loss降到0.02,val_acc只有60%。这是过拟合最极端的样子,原因通常是训练epoch跑太久、增强强度不够,或者模型对500张数据来说太大了。解决方法是:把epoch从40降到25,把RandomResizedCrop的scale下限从0.6调到0.4,增加ColorJitter的幅度,在ResNet18的最后一个卷积层后加一个Dropout(p=0.3)(需要轻微改模型结构)。这里调参有些玄学成分,但有一条原则不会错:训练集和验证集的表现差距越大,正则化强度就该越高。
第五个坑:loss完全不动,准确率像随机猜。现象是训练了好几个epoch,loss始终在1.9附近(7类随机猜测的交叉熵约等于ln(7)≈1.95),准确率始终在14%左右。原因概率最高的三个:一是没加载预训练权重,weights=None导致模型从零开始,500张数据根本训不动;二是model.fc替换后没to(device),模型部分参数留在CPU上导致前向传播报错但被某些代码吞掉了;三是数据归一化用了自己的均值方差,和预训练权重的期望输入不匹配。解决方法是逐项排查:打印model.fc确认输出是7,检查model整体在哪个设备上,确认Normalize用的确实是ImageNet的均值和标准差。这三个检查做完,90%的“loss不动”都能解决。
6. 验证与进阶:用混淆矩阵找出模型的真实短板
验证集准确率只是一个数字,它告诉你模型整体行不行,但不告诉你哪里不行。我每次训练完都会画一张混淆矩阵——横轴是真实类别,纵轴是预测类别,对角线上的数字越大越好,对角线之外的数字就是模型犯错的分布。在7类水果、500张数据的场景里,这一步几乎能直接指出你下一步该干什么。
import numpy as np import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix model.load_state_dict(torch.load("fruit_best.pth", map_location=device)) model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in val_loader: images = images.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) class_names = list(val_dataset.class_to_idx.keys()) plt.figure(figsize=(8, 6)) plt.imshow(cm, cmap="Blues") plt.colorbar() plt.xticks(range(len(class_names)), class_names, rotation=45) plt.yticks(range(len(class_names)), class_names) plt.xlabel("Predicted") plt.ylabel("True") # 在每个格子里标注数字 for i in range(len(class_names)): for j in range(len(class_names)): plt.text(j, i, cm[i, j], ha="center", va="center", color="black") plt.tight_layout() plt.savefig("confusion_matrix.png", dpi=150)看混淆矩阵时,最值得关注的是那些对角线上数字小、旁边却有非零数字的类别对。如果“李子”那一行有相当一部分预测到了“樱桃”,说明这两类在颜色和形状上确实接近。此时你有三条路:给这两类补充更多不同成熟度的样本;或者在预处理里对红色系颜色做去相关处理,让模型不要过于依赖颜色通道;第三种是把这两类合并成一个“类似小型核果”类,再单独训练一个二分类器做细分类。这个分层分类的思路,是处理易混类别时最有效的手段。
数据增强方向上也还有进阶空间:当基础翻转和颜色抖动已经不够时,可以试试RandomPerspective模拟不同拍摄角度,或者用MixUp在样本间做线性插值,这两种方法在每类只有几十张的小数据集上能明显提升鲁棒性。500张数据的天花板不在模型,而在你如何榨干每一张样本的信息量。我现在做类似项目,拿到小数据集的第一步就是先写混淆矩阵脚本,再决定要不要动模型——这个习惯帮我省下了大量无效调参的时间。数据集小不可怕,可怕的是对着一个黑匣子瞎调。希望帮到你。
本文还有配套的精品资源,点击获取