☰
36类果蔬图像分类数据集:PyTorch加载、迁移学习与避坑实战
2026/10/9 3:20:42 网站建设 项目流程

简介:面向图像分类任务,这份数据集收录了香蕉、苹果、梨、葡萄、橙子等36类常见水果与蔬菜的标注图像,共约3400张,所有图片已按类别归入独立文件夹。数据在发布前已完成统一预处理,可直接送入分类网络训练,省去自行写清洗与转换脚本的环节;同时划分出训练集与验证集,方便对比模型在不同类别上的表现。包内附有JSON标签映射文件,可一次性核对36个类别的目录与标签对应关系;show可视化脚本则支持批量预览样本,便于快速检查标注是否与图片匹配。资源共2000个文件,以JPG图片为主体,另含1个Python脚本与1个JSON配置文件,整套7z压缩包约94.47MB,体量适中,便于本地存储与迁移使用。对于正在入门图像分类、想练习数据加载流程,或需要一份可靠基准数据验证模型改进效果的学习者与开发者,这份数据集开箱即用;目前已有216人学习下载。

1. 36 类果蔬图像分类数据集:3400 张图为什么是练手黄金尺寸

做图像分类实验最烦的往往不是模型,而是数据集:类别少没说服力,类别多又没精力核对。这份 36 种常见水果和蔬菜图像分类数据集,约 3400 张已标注图片,正好卡在“能跑通全流程、又没大到没法手工核对”的中间位置。它已经划分好训练集和验证集,每个类别单独放目录,还带 JSON 标签和可视化脚本,属于那种下载下来就能直接喂给 PyTorch 的分类数据资源。

适合谁用?一类是做课程设计或入门图像分类的初学者,另一类是想快速验证新模型的小团队。你不需要去爬图、清洗、做标注,省下的时间足够把迁移学习、数据增强、评估指标这些真正花时间的东西过一遍。要说边界也很直接:3400 张图大约平均每类 94 张,做不了从零预训练的大模型,但做 ResNet、ViT 的迁移学习实验,或者小样本图像分类的 1-shot/5-shot 自测,这个规模刚刚好。

2. 数据集结构解析:从 JSON 标签到 train/val 目录,4 步对齐

2.1 先读 JSON,再谈训练

拿到这类数据集后,第一件事不是急着写训练脚本,而是把资源里的 JSON 文件打开看结构。很多踩坑都是从“我以为标签是文件夹名”开始的。JSON 里保存的是 36 个类别的最终清单,训练时类名、类别编号、图片路径都应该以它为准。

我习惯在项目根目录放一个label.json这样的名字,实际文件名以你下载的资源为准。先跑一小段代码确认它长什么样:

import json from pathlib import Path json_path = Path('label.json') # 实际文件名请以资源为准 with open(json_path, 'r', encoding='utf-8') as f: label_map = json.load(f) print(type(label_map)) print(len(label_map)) print(list(label_map.items())[:10])

这段代码的要点有两个。第一,encoding='utf-8'必须写,否则中文类别名在 Windows 上很容易变成乱码;第二,先看type(label_map),如果它是字典,那大概率是“文件名→类别”或“类别名→编号”的映射,如果它是列表,就需要继续看每个元素的字段。后面所有的数据加载逻辑都取决于这个结构。

提示:不要看着项目简介里的 36 个中文名就直接写死一份class_names,一定要和 JSON 里的 key 对齐。资源说明里“辣椒”和“萝卜”在文本列表中出现了重复,真实类别必须以 JSON 的唯一编号为准。

2.2 目录结构:train/val 下按类放图

这份数据集的图片组织形式是“训练集、验证集,各自存放同一类数据图片”。展开看大概是这样的:

dataset/ ├── train/ │ ├── apple/ │ │ ├── Image_7.jpg │ │ └── Image_28.jpg │ ├── banana/ │ │ └── Image_1.jpg │ └── ... ├── val/ │ ├── apple/ │ │ └── Image_21.jpg │ └── ... └── label.json

这种结构对 PyTorch 特别友好,因为torchvision.datasets.ImageFolder就是按子目录名生成标签的。训练脚本里只要把路径指向train和val,类别顺序会自动按目录名的字典序排好。

我一般会在训练前跑一个统计脚本,看看每个类别到底有多少张图,确认没有空目录,也确认训练集和验证集的比例不是太离谱:

from pathlib import Path for split_name in ['train', 'val']: split_dir = Path(split_name) print(f'--- {split_name} ---') for cls_dir in sorted(split_dir.iterdir()): if not cls_dir.is_dir(): continue count = len(list(cls_dir.glob('*.jpg'))) print(f'{cls_dir.name}: {count}')

这个统计脚本的用途不是看热闹,而是帮你提前发现两类问题:类别样本数极端不均衡,以及某个类在验证集里一张图都没有。如果出现这两类情况,后续训练就要考虑加权采样或重新划分,否则模型很容易偏向样本多的类别。

2.3 文件名和标签怎么映射

资源里的图片名是Image_7.jpg、Image_28.jpg这样的全局编号,文件名本身不携带类别信息。也就是说,Image_7.jpg不等于第 7 类,这个编号只是采集顺序。真正的映射关系要回到 JSON 里查。

如果 JSON 是“文件名→类别名”结构,映射代码非常简单:

import json with open('label.json', 'r', encoding='utf-8') as f: labels = json.load(f) def get_label_by_filename(filename): return labels.get(filename) print(get_label_by_filename('Image_7.jpg')) print(get_label_by_filename('Image_28.jpg'))

如果 JSON 是“类别名→编号”结构,那就需要按目录名反查。无论哪种结构,我建议你先把 JSON 的 key 和目录里的文件名做一次差集检查。比如,JSON 里有的图片但 train/val 目录里不存在,或者目录里有图但 JSON 没记录,这些都会让训练和验证时出现“index out of range”之外的诡异错误。

3. 把数据接进 PyTorch 训练流程:ImageFolder、JSON Loader 与预处理参数

3.1 目录已经按类分好:ImageFolder 是最省事的读取方式

因为 train/val 已经按类别目录整理好,用ImageFolder就是零配置方案。它会自动把每一个子目录当作一个类别,返回(image, class_index)的样本对,同时也把classes属性按字典序生成。

第一次跑通流程时,我建议先用一个最简单的 transform,把图片缩放到 224x224 并转成 Tensor,确认数据能正常读取:

from torchvision import datasets, transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_set = datasets.ImageFolder('train', transform=train_transform) print(train_set.classes) print(len(train_set)) print(train_set[0][0].shape)

这里有两个参数值得留意。第一,Resize((224, 224))会把图直接拉伸到正方形,简单跑通可以,但正式实验时最好换成Resize(256) + CenterCrop(224),减少长宽比变形带来的失真。第二,Normalize的 mean 和 std 用的是 ImageNet 预训练模型的统计值,只要后面加载ResNet50_Weights.IMAGENET1K_V2这类预训练权重,就必须用这一组值,不能随便改成自己的数据均值。

3.2 JSON 标签也能当标签源:自定义 Dataset 兜底

有些情况下目录不是严格按类组织的,或者 JSON 里有额外的细分标签。这时候ImageFolder就不够用了。我一般会写一个轻量的自定义Dataset,把 JSON 当作唯一标签源:

import json from pathlib import Path from PIL import Image from torch.utils.data import Dataset class FruitDataset(Dataset): def __init__(self, img_dir, label_path, transform=None): self.img_dir = Path(img_dir) self.transform = transform with open(label_path, 'r', encoding='utf-8') as f: self.labels = json.load(f) self.samples = [ (str(self.img_dir / filename), label) for filename, label in self.labels.items() if (self.img_dir / filename).exists() ] def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert('RGB') if self.transform: img = self.transform(img) return img, label

这段代码的逻辑很直接:构造样本列表时,先按 JSON 里的文件名到img_dir下找文件,找到才留下;convert('RGB')是为了把可能的 RGBA 或灰度图统一成三通道,避免生成 Batch 时尺寸不一致。要注意的是,如果 JSON 同时包含 train 和 val 的所有图片,你需要在调用时分别传入train和val目录,并且只保留该目录下存在的文件,否则训练集和验证集会互相串数据。

3.3 预处理参数怎么定:训练集增强,验证集保守

3400 张图不多,训练集必须带增强,验证集则应该尽量保持原图信息,减少随机性,否则每次验证结果都不一样。我通常这样配:

train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1, 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的scale=(0.6, 1.0)表示每次随机裁剪原图的 60% 到 100% 区域,相当于做了尺度扰动,这对果蔬这种目标大小不固定的场景很有用;如果设成(0.08, 1.0),裁剪比例太狠,某些小目标会被切掉关键特征。ColorJitter的光照扰动不宜过大,水果颜色是重要分类特征,saturation=0.1是为了不让颜色偏移到完全失真。验证集用Resize(256) + CenterCrop(224),先把短边放到 256,再裁中心区域,比直接Resize((224,224))更尊重原始长宽比。

4. 先可视化再试跑:show 脚本、ResNet-50 基线与评估口径

4.1 先运行资源里的 show 脚本,人工核对一遍

资源说明里提到可以用 show 脚本可视化数据集。这一步不是可选项,而是必须项。图片标注是自动采集或人工整理的,总会有错标、模糊图、背景杂图混进来。如果不看一遍直接训练,模型很容易学到“背景是木桌子就分类成苹果”这种假的规律。

如果你不想折腾 show 脚本,也可以用matplotlib自己画一个网格图看:

import matplotlib.pyplot as plt from torchvision import datasets, transforms from torchvision.utils import make_grid vis_set = datasets.ImageFolder('train', transform=transforms.Resize((224, 224))) images = [vis_set[i][0] for i in range(16)] grid = make_grid(images, nrow=4, normalize=True) plt.figure(figsize=(8, 8)) plt.imshow(grid.permute(1, 2, 0)) plt.axis('off') plt.show()

这里没有加Normalize,因为归一化后的图直接显示会偏色;make_grid的normalize=True会把像素值重新缩放到 0 到 1,方便显示。人工核对时重点看四类问题:类别是否和图片内容一致、同一类里是否存在不同成熟度的水果蔬菜、背景是否特别杂乱、有没有黑白图或分辨率极低的图。这四类问题决定了后面的增强策略和模型选择。

4.2 用 ResNet-50 跑基线:10 轮看趋势,不追求刷点

小数据集上最稳的起点是 ImageNet 预训练的 ResNet-50。替换最后一层全连接,把输出改成 36 类,然后在train上微调 10 轮,足够看出数据本身干不干净。

import torch import torch.nn as nn from torchvision import models device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) model.fc = nn.Linear(model.fc.in_features, 36) model.to(device)

如果你用的 torchvision 版本比较旧,weights参数不可用,可以退回到pretrained=True,效果差别不大。替换model.fc时,in_features直接用原始全连接层的属性读取,不要写死 2048,虽然 ResNet-50 的fc.in_features就是 2048,但写成动态读取能避免换模型时改错。

接下来是训练循环。这个基线不是为了刷分,而是为了确认数据加载、loss 计算、验证流程都没问题:

from torch.utils.data import DataLoader train_loader = DataLoader(train_set, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_set, batch_size=64, shuffle=False, num_workers=4) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=10) for epoch in range(10): model.train() total_loss, total_correct, total_num = 0, 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() total_loss += loss.item() * images.size(0) total_correct += (outputs.argmax(1) == labels).sum().item() total_num += images.size(0) scheduler.step() print(f'epoch {epoch + 1}: loss {total_loss / total_num:.3f}, acc {total_correct / total_num:.3f}')

这里lr=1e-4是微调预训练模型的常用值,比从头训练的1e-3小一个量级,避免破坏已经学好的底层特征。AdamW对权重衰减更友好,CosineAnnealingLR在 10 个 epoch 内把学习率平滑降到接近 0,适合这种短平快的基线实验。如果你在 CPU 上跑,把batch_size降到 8 或 16,num_workers可以保持 0,否则数据加载的线程调度反而更慢。

4.3 评估口径:小样本评估别只盯 top-1

验证集准确率只是一个数字,尤其在每类只有几十张图的数据集里,top-1 很容易被两三个容易分的类拉高。比如“香蕉”和“西瓜”几乎不会错,“辣椒”和“甜椒”可能互相混。只看整体准确率,你根本不知道模型到底卡在哪类。

训练完基线后,我建议立刻出分类报告:

from sklearn.metrics import classification_report y_true, y_pred = [], [] model.eval() with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) y_true.extend(labels.cpu().numpy()) y_pred.extend(outputs.argmax(1).cpu().numpy()) print(classification_report(y_true, y_pred, target_names=val_set.classes, digits=3))

classification_report会输出每个类别的 precision、recall、f1-score,以及最后的 macro avg 和 weighted avg。在小样本场景下,我更看重 macro avg,因为它不会被样本多的类别带偏。如果你打算用这份数据做小样本图像分类(1-shot/5-shot)自测,还需要自己重新抽支持集和查询集,不能把这里的train_loader随机 shuffle 当作 few-shot 评估协议,否则结果没有参考价值。

5. 果蔬图像分类避坑指南:5 个能让你重训的坑

下面这些坑是我跑类似果蔬数据集时实打实踩过的,按“现象 → 原因 → 解决”写,方便你直接对号入座。

5.1 类目清单一出现重名,类别映射就被覆盖

现象:训练总体的类别数对不上,打印len(train_set.classes)只有 34 或 35,而不是 36。原因:项目简介里的类别文本出现“辣椒”和“萝卜”重复,如果 JSON 里也存在同名不同编号的情况,而你用中文名做label2id映射,后写入的值会把前面的覆盖掉。解决:训练标签只认 JSON 里的唯一 key 或编号;如果资源里确实存在同名不同类,手动加后缀区分,比如辣椒_a、辣椒_b,再重新生成训练标签。

5.2 每类样本数不均衡,模型偏科

现象:整体 acc 不低,但某些类别的 recall 是 0,模型把所有图都预测成另外几个大类。原因:3400 张平均到 36 类,每类约 94 张,但采集时不可能完全均匀,样本少的类别在随机抽样时被淹没。解决:用WeightedRandomSampler按类别反比加权,让样本少的类在每次 epoch 里被抽到的机会更大。

from torch.utils.data import WeightedRandomSampler class_counts = [0] * len(train_set.classes) for _, label_idx in train_set.samples: class_counts[label_idx] += 1 sample_weights = [1.0 / class_counts[label_idx] for _, label_idx in train_set.samples] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True)

这里replacement=True表示同一张图在一个 epoch 里可以被重复抽中,这正是我们想要的效果,但不要把它当成数据增强,它只是平衡类别分布的手段。

5.3 验证集被反复调参污染,准确率虚高

现象:验证集准确率已经到 92%,换一批数据或换个模型又掉到 80%。原因:你把验证集当成了调参工具,每改一次学习率或增强参数就看一次验证集,模型选择最终过拟合到了这一批验证图的噪声上。解决:这份资源只提供了 train/val,没有独立 test。更稳妥的做法是把 train 再拆出一部分做调参验证,或者直接对 train 做 5 折交叉验证,原 val 只留到最后做一次最终评估。

5.4 中文目录名在 Windows 上读取失败

现象:同一段代码在 Linux 上正常,在 Windows 上Image.open偶尔报错,或者cv2.imread返回 None。原因:中文目录名加上 Windows 默认编码,容易在路径拼接时出问题。解决:尽量用pathlib.Path管理路径,避免直接用字符串拼接;如果一定要用 OpenCV,用imdecode绕过文件扩展名编码问题。

import cv2 import numpy as np def read_image_cn(path): img = cv2.imdecode(np.fromfile(path, dtype=np.uint8), cv2.IMREAD_COLOR) return img

np.fromfile按原始字节读入文件,cv2.imdecode再从内存解码,这样中文路径不会因为imread的编码限制而失败。不过在这个数据集场景下,我建议直接用 PIL,兼容性更好。

5.5 直接拉伸到正方形,果实形状失真

现象:训练 loss 降得慢,验证集里“梨”和“苹果”混淆严重。原因:直接Resize((224, 224))把纵向或横向的长图拉扁,果实的整体形状特征被破坏。解决:验证集用Resize(256) + CenterCrop(224),训练集用RandomResizedCrop(224)。如果原图分辨率差异很大,先打印所有图片的尺寸分布,再决定是否统一用短边缩放。

6. 进阶技巧:数据增强、交叉验证和 1-shot/5-shot 小样本评估

3400 张图想用出几千张的效果,靠的不是堆增强,而是把评估方式做严谨,再用预训练模型做迁移学习。我从这个数据集上得到的最大启发是:小数据集不怕模型弱,怕的是你拿验证集反复调参,最后练成一个只能在该验证集上“自我感动”的模型。

6.1 先把 train 拆成 5 折,再谈换模型

我现在的习惯是拿到资源后先不看 val,直接对 train 做分层 5 折交叉验证。每次折用 4 份训练、1 份验证,5 次结果取平均,这样比单次 train/val 更能说明模型稳定性。

from sklearn.model_selection import StratifiedKFold import numpy as np labels = np.array([s[1] for s in train_set.samples]) skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for fold, (train_idx, val_idx) in enumerate(skf.split(np.zeros(len(train_set)), labels)): print(f'fold {fold}: train {len(train_idx)}, val {len(val_idx)}')

StratifiedKFold会保证每一折里各类别比例和总体一致,适合这种类别不均的数据集。random_state=42固定下来,保证后面换模型时对比的划分完全一样,否则你很难判断准确率变化是模型带来的还是随机划分带来的。

6.2 增强别开满,伪标签不如交叉验证靠谱

很多新手拿到小数据集就疯狂加增强,最后模型在训练集上还是过拟合,因为增强只能增加图像变化,不能凭空创造新的类别信息。我在这份数据上试下来的经验是:RandomResizedCrop、RandomHorizontalFlip、小幅ColorJitter已经够用,MixUp 可以先不开,等基线稳定后再用alpha=0.2的小值去试。

换 ViT 或其他新模型时也要注意分类头。ResNet 改model.fc,ViT 则是改model.heads.head,并显式传入num_classes=36。如果不改输出维度,最后一层还是 ImageNet 的 1000 类,训练一开始就会报尺寸不匹配。这一步和预训练权重配合使用,前面的 encoder 可以做微调,但学习率要比分类头小,我一般分类头用1e-4,encoder 用1e-5。

如果你要做 1-shot/5-shot 小样本图像分类评估,不要直接用原始 train/val 划分。正确的做法是每个类别独立抽支持集和查询集,保证查询集里的图片不出现在支持集里,然后再跑匹配或元学习算法。这个数据集每类约 94 张,做 5-shot 时支持集抽 5 张、查询集抽剩下的图,结果相对稳定。

从那以后,我每次拿到新的图像分类数据集都会先花十分钟核对 JSON、目录、每类样本数,再跑一次 ResNet-50 基线,最后才谈得上换模型、加增强还是做 few-shot。这套流程看起来慢,但能省掉后面三天的翻车排查。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询