36种果蔬图像分类实战:数据集处理到模型评估全流程

发布时间:2026/9/14 2:06:17
36种果蔬图像分类实战:数据集处理到模型评估全流程 简介一份包含36种常见水果与蔬菜的已标注图像分类数据集约3400张数据经过预处理可直接作为分类网络输入适合正在学习图像分类、分割或目标检测的初学者及研究者用于模型训练与效果验证。资源共2000个文件以jpg图片为主体另附1个Python脚本用于数据可视化、1个json文件记录类别名称整体压缩包94.47MB结构清晰便于快速上手。数据集已划分训练集与验证集同一类图片集中存放可直接配合常见深度学习框架进行训练运行show脚本即可直观查看样本内容方便检查数据质量。目前已有212人学习下载可作为果蔬分类项目的基础数据支撑也适合作为计算机视觉课程作业的现成实验数据。1. 拿到 36 种水果和蔬菜图像分类数据集先把 3400 张的真实分量算清楚我第一次拿到这份「36 种常见水果和蔬菜图像图像分类数据集」时先做的不是打开训练脚本而是把 3400 张除以 36 类平均每类不到 95 张。这个数字直接决定了后面所有技术选型——从零预训练大概率过拟合简单 CNN 又托不住类间差异标注哪怕错掉 3% 也会让准确率掉一个点。它足够撑起一条完整的图像分类流水线也足够把数据增强、迁移学习、模型验证这些环节的坑全都暴露出来。这篇东西就按我实际会走的路径写先统计类目和分辨率对齐标注格式再用 CNN 和 Transformer 各跑一个基线最后用混淆矩阵和 Grad-CAM 反推数据质量。适合想拿小数据集验证图像分类算法、做课程设计或者给工程预研摸底的人。2. 果蔬图像分类数据集的构成类别均衡、标注格式与抽检2.1 统计每类样本数与分辨率先跑通这份脚本拿到任何数据集第一件事都不是训练而是摸底。36 类、约 3400 张这只说明总量真正影响后面方案的是「每类分布」和「图像分辨率下限」。果蔬图像分类数据集大多是整图级类别标注label 要么是目录名要么是 CSV 里的 class 字段这跟 POI 数据集、桥墩病害数据集那种带位置信息的场景型标注不一样后者要校验坐标和框前者只需要校验「目录-标签」的映射关系。摸底脚本很简单我一般直接这样写from pathlib import Path from PIL import Image import csv root Path(fruits_veg_36) # 数据集根目录按类别分文件夹 rows [] for cls_dir in sorted(root.iterdir()): if not cls_dir.is_dir(): continue n 0 widths [] for img_file in cls_dir.glob(*.jpg): try: im Image.open(img_file) widths.append(im.size[0]) n 1 except Exception: print(损坏文件:, img_file) # 坏图直接记录不中断统计 if n: rows.append({class: cls_dir.name, count: n, min_width: min(widths), max_width: max(widths)}) with open(class_stats.csv, w, newline) as f: w csv.DictWriter(f, fieldnames[class, count, min_width, max_width]) w.writeheader() w.writerows(rows)脚本做了三件事按目录统计每类样本数、检查图片能否正常打开、记录最小和最大宽度。glob 只匹配.jpg如果你的数据里有 PNG 或 BMP需要把模式改成*.*再按扩展名过滤。min_width很关键果蔬图像里常见手机拍摄的原图分辨率极高但边长不统一后面做 Resize 时如果出现小图被拉伸变形分类结果会莫名其妙地变差。跑完这份脚本你会得到一份class_stats.csv先看有没有某一类样本量是其他类的两倍以上如果有第 4 章的类别权重就必须做。2.2 标注格式对齐COCO、VOC 与 YOLO txt 怎么选已标注数据集的标注载体决定你能直接接到哪个训练框架。果蔬图像分类如果只做整图分类标注其实就是「文件名 → 类别」的映射表但如果你想顺带训练检测模型或者用已有工具做增量标注就必须理解三种常见格式的差异标注格式载体常见用途备注COCO JSONannotations/*.json检测、分割、关键点结构固定类别存在categories里VOC XMLAnnotations/*.xml检测、分类老工具兼容性好结构直观YOLO txtimages/labels/YOLO 系训练每行class x_center y_center w h全部归一化从 labelme 这类标注工具导出的通常是多边形 JSON要转成 YOLO txt 才能喂给常见训练框架。这里给一段直接可用的转换逻辑核心是坐标归一化import json from pathlib import Path def labelme_to_yolo(json_file, out_file, class_map): with open(json_file, encodingutf-8) as f: data json.load(f) h, w data[imageHeight], data[imageWidth] lines [] for shape in data[shapes]: # shapes 存放所有标注对象 cls shape[label] pts shape[points] x_min min(p[0] for p in pts) # 多边形顶点转包围盒 y_min min(p[1] for p in pts) x_max max(p[0] for p in pts) y_max max(p[1] for p in pts) cx (x_min x_max) / 2 / w # 归一化中心坐标 cy (y_min y_max) / 2 / h bw (x_max - x_min) / w bh (y_max - y_min) / h lines.append(f{class_map[cls]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) Path(out_file).write_text(\n.join(lines), encodingutf-8)class_map是类别名到数字编号的字典必须与训练配置里的names顺序严格一致否则会出现「模型预测的是苹果标注写的是橙子」的错位。KITTI 标注转 YOLO、COCO 转 YOLO 本质都是同一个过程区别只在解析源格式的字段名。转换后找一个样本可视化核对一下框的位置这一步花不了几分钟能避免后续训练时 loss 长时间不降。2.3 用 cvat 抽检标注顺带修掉错标和漏标3400 张图全部人工核对不现实但每类平均不到 95 张抽样 10% 到 15% 完全可行。我一般用 cvat 直接打开数据集做抽检官方推荐的启动方式是 Dockerdocker compose -f docker-compose.yml up -d起来之后在浏览器里创建任务导入图像再按 36 个类别逐个筛选查看。抽检重点看两类问题一类是错标比如番茄标成辣椒这种跨物种错配另一类是模糊或遮挡严重的图这类图即使标注正确也会在训练时变成噪声。果蔬图像分类数据集里的类别往往有近似形态例如土豆和红薯、苹果和番茄错标样本对准确率的拖累远大于训练样本不足。抽检时发现错误直接在 cvat 里改标签再导出比事后在代码里做 label cleanup 省力得多。3. 用 CNN 与 Transformer 把果蔬图像分类数据集跑成基线结果3.1 3400 张不适合从零训练迁移学习是正解36 类、约 3400 张的规模离从零训练一个深度图像分类模型还差两个数量级。ImageNet 预训练权重里已经包含了大量边缘、纹理、颜色渐变的底层特征果蔬图像的表面质地和一般物体识别共用这些特征。常见做法是加载预训练模型、替换最后的全连接层输出为 36。花卉图像分类这类任务能靠 CNN 迁移学习在小数据集上取得不错效果靠的也是同一套逻辑。先冻结主干训练新分类头再解冻微调这是小数据集上最稳的路径。3.2 用 ResNet18 训练果蔬分类的最小流程3.2.1 数据加载与训练循环写法import torch import torchvision from torchvision import transforms, models from torch.utils.data import Dataset, DataLoader from PIL import Image from pathlib import Path class FruitVegDataset(Dataset): def __init__(self, root: Path, transformNone): self.samples [] for label, cls in enumerate(sorted(p.name for p in root.iterdir() if p.is_dir())): for p in (root / cls).glob(*.jpg): self.samples.append((p, label)) self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, i): p, label self.samples[i] img Image.open(p).convert(RGB) if self.transform: img self.transform(img) return img, label train_tf transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224), # 与 ImageNet 输入口径一致 transforms.RandomHorizontalFlip(), transforms.ColorJitter(0.2, 0.2, 0.2), 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, 36) # 替换分类头为 36 类 for p in model.parameters(): p.requires_grad False # 冻结主干 for p in model.fc.parameters(): p.requires_grad True # 只训练分类头 opt torch.optim.AdamW(model.fc.parameters(), lr1e-3, weight_decay1e-4) loss_fn torch.nn.CrossEntropyLoss()数据加载部分用自定义 Dataset 直接读目录结构省去单独维护 CSV 的麻烦。RandomResizedCrop(224)的作用是随机裁剪并缩放等价于轻微的尺度扰动ColorJitter的幅度设到 0.2目的是模拟不同光照和成熟度下的表面颜色变化但不宜再大否则会把青番茄学成绿叶菜。Normalize用的是 ImageNet 统计量因为预训练模型是在这套均值方差下训练的输入分布必须对齐。3.2.2 调整学习率与 batch_size 的参考值参数建议范围说明batch_size16 - 323400 张规模下 32 足够稳定再大意义不大lr 冻结主干1e-3只训练新分类头时相对安全lr 解冻微调1e-5 - 3e-5解冻后必须下调否则破坏预训练特征weight_decay1e-4常规 L2 正则抑制过拟合训练循环本身不复杂按标准写法即可每个 epoch 走一遍 DataLoader先model.train()做前向和反向验证阶段model.eval()配合torch.no_grad()计算 top-1 准确率保存验证集最优的权重。先冻结主干跑 10 个 epoch如果验证准确率能到 80% 以上再解冻最后两个残差块用低学习率微调 20 到 30 个 epoch。3.3 换 ViT-B/16 看 Transformer 图像分类的差异CNN 跑通之后值得用 Transformer 图像分类模型做一次对照实验。用 timm 库切换模型非常直接import timm model timm.create_model(vit_base_patch16_224, pretrainedTrue, num_classes36)ViT 没有卷积的局部归纳偏置对数据量和增强强度的要求都更高。在 3400 张的小数据集上常见趋势是 ViT 收敛更慢、前期验证准确率低于 ResNet18但训练充分后对类间纹理差异的区分能力更强尤其是苹果和番茄这种颜色相近的类别。这时需要把增强策略加强比如加上 RandAugment并把 epoch 从 30 拉到 50 以上。最新的一些图像分类模型如 ConvNeXt、EfficientFormer 也走timm.create_model接口做横评时只需改模型名训练循环可以完全不动。3.4 对比两张准确率曲线决定最终模型把 ResNet18 和 ViT-B/16 的验证准确率画在同一条坐标轴上决策依据不是最高点而是曲线的「平台期位置」和「尾部波动」。如果 ViT 比 ResNet18 高 1 到 2 个点但需要多花三倍训练时间工程上选 ResNet18 更划算如果 ViT 在长尾类别上显著更稳那就值得用它做最终模型。我一般会在训练结束时导出val_predictions.csv包含文件名、真实类别、预测类别和置信度后面做混淆矩阵直接复用。4. 果蔬图像分类模型的关键训练参数与标注易错点4.1 36 类样本量不均衡时先上 class weight 再考虑换 loss2.1 节统计出的每类样本数如果波动大最直接的修正不是换 loss而是给 CrossEntropyLoss 加 class weight。让样本少的类别在 loss 中占更大权重模型就不会被多数类彻底带走。代码做法是counts train_set.counts_per_class() # 返回每类样本数的 Tensor weights 1.0 / torch.tensor(counts, dtypetorch.float) weights weights / weights.sum() * len(counts) # 归一化后均值约为 1 loss_fn torch.nn.CrossEntropyLoss(weightweights)weights归一化到均值 1是为了不让整体 loss 的量级发生剧烈变化这样原先调好的学习率还能继续用。假如某类只有 30 张另一类有 150 张权重比约 5 倍少数类的梯度贡献被补回。要是加了 class weight 后准确率仍不理想再换 Focal Lossgamma取 1 到 2alpha用类别频率的反比作为初值。4.2 数据增强的幅度与组合影响比模型更大小数据集的增强策略直接决定模型最终落到哪个局部最优。果蔬图像分类有个特殊点颜色是重要判别特征但颜色又最容易受光照干扰所以色彩类增强必须克制。下面这套组合是我在类似数据集上用得比较顺的配置增强方法参数幅度作用与风险RandomResizedCropscale 0.6 - 1.0模拟不同取景距离过小会让模型只看到局部纹理HorizontalFlipp0.5无副作用果蔬基本对称RandomBrightnessContrast0.1模拟光照变化过大会让成熟度难分辨HueSaturationValue10 / 15 / 10颜色微扰动过大会出现茄子变紫薯的假样本import albumentations as A from albumentations.pytorch import ToTensorV2 train_aug A.Compose([ A.Resize(256, 256), A.RandomCrop(224, 224), A.HorizontalFlip(p0.5), A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1, p0.5), A.HueSaturationValue(hue_shift_limit10, sat_shift_limit15, val_shift_limit10, p0.5), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ])验证集不要做随机增强只做 Resize、CenterCrop 和 Normalize。小数据集上增强强度不是越大越好我一般先跑一版轻增强确认验证准确率不再上升后再逐步加 MixUp 或 CutMix上来就重增强会导致训练 loss 降不下去误判为模型容量不足。4.3 early stopping 与余弦退火把小数据集训练拉长而不跑飞3400 张数据训练几十个 epoch 后一定会进入过拟合区间这时候 early stopping 比手动盯验证曲线可靠得多best_acc, bad_epochs, patience 0, 0, 10 for epoch in range(60): val_acc train_one_epoch(model, loader, opt, loss_fn) if val_acc best_acc: best_acc, bad_epochs val_acc, 0 torch.save(model.state_dict(), best.pt) else: bad_epochs 1 if bad_epochs patience: # 连续 10 个 epoch 无提升就停 break配合余弦退火可以让学习率在训练后期自然衰减到很低的水平scheduler torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max40, eta_min1e-6)T_max设置成你预期的总 epoch 数eta_min是学习率下限。这样训练后期以极低学习率做精细搜索容易在过拟合边界附近拿到更好的一组权重。需要注意的是 early stopping 的patience在增强较强时要适当加大到 15 到 20因为更强的增强会让验证准确率出现更大波动。4.4 顺带一提yolov8 训练自己的数据集时配置入口长什么样做果蔬分类的人常常同时想跑检测。YOLOv5、YOLOv8 训练自己的数据集时核心入口是一份 data.yaml和前面 2.2 的类别编号必须对齐train: datasets/fruits_veg_36/images/train val: datasets/fruits_veg_36/images/val nc: 36 names: [apple, banana, carrot, tomato, ...] # 顺序与 class_map 保持一致之后用yolov8 train datadata.yaml modelyolov8n.pt就能跑起来。最容易出问题的地方是names顺序和标注 txt 里的数字编号对不上转换工具生成标注时用了一套顺序训练配置里写了另一套模型会一直学一个错位的映射且表现得很「自然」——loss 正常下降验证准确率却永远在一个低水平徘徊。如果发现这种症状第一反应不是调模型而是回去核对 2.2 节的class_map。5. 用混淆矩阵和 Grad-CAM 复核果蔬图像分类数据集的标注质量5.1 混淆矩阵找出易混类别对训练结束后我会把val_predictions.csv交给 sklearn 生成混淆矩阵并做行归一化from sklearn.metrics import confusion_matrix import numpy as np cm confusion_matrix(y_true, y_pred) cm_norm cm.astype(float) / (cm.sum(axis1, keepdimsTrue) 1e-9) low_recall np.argsort(cm_norm.diagonal())[:5]行归一化之后对角线数值就是每个类别的召回率。取出最低的几个类别去原始数据目录里翻对应图片通常会发现两类问题一是该类本身样本量少且形态复杂二是标注里混入了相似邻类的图。这两种问题的处理方式完全不同前者加数据增强或补充样本后者直接清洗标注别用模型硬扛。5.2 Grad-CAM 验证模型到底在看果皮还是背景准确率高不等于模型学到了正确特征。用 Grad-CAM 看最后卷积层的激活区域能直观判断模型是否把注意力放在果实本身上def grad_cam(model, img_tensor, target_layer, pred_idx): acts, grads [], [] def fwd_hook(m, inp, out): acts.append(out.detach()) def bwd_hook(m, grad_in, grad_out): grads.append(grad_out[0].detach()) target_layer.register_forward_hook(fwd_hook) target_layer.register_full_backward_hook(bwd_hook) out model(img_tensor.unsqueeze(0)) model.zero_grad() out[0, pred_idx].backward() weights grads[0].mean(dim(2, 3), keepdimTrue) # GAP 得到通道权重 cam (weights * acts[0]).sum(dim1, keepdimTrue) cam torch.relu(cam) return cam热力图高亮区域如果集中在果实中心说明模型学到了皮色、纹理这类合理特征如果集中在果柄、叶片甚至背景桌面说明训练集里存在背景泄漏模型实际在学「桌子上的红色块」而不是「苹果」。果蔬图像分类数据集经常在拍摄时引入强烈背景 bias因为每类图片可能是同一批拍摄环境下收集的。这类问题改模型没用回到数据层面做背景裁剪或补充多样背景才是正路。5.3 把验证产物固化成训练脚本的一部分最后一个小技巧把混淆矩阵和 Grad-CAM 两块输出放进训练脚本的验证段之后每次实验自动保存到runs/exp_id/目录而不是等训练结束后手动补跑。这样每个实验都留下「准确率 混淆矩阵 热力图」三件套对比不同增强策略或模型时不用重新训练就能判断上一版模型是输在标注噪声还是输在特征学习上。对 3400 张这种规模的数据集这套验收流程跑一遍只要几分钟但它暴露的问题往往比多看十轮 loss 曲线更有价值。本文还有配套的精品资源点击获取