简介面向需要实践图像分类与迁移学习的开发者这份基于GoogLeNet的香蕉成熟度识别工程涵盖完整数据集与纯手写源码除了GoogLeNet网络结构外其余代码均可直接读懂与训练。数据集按6类成熟度划分共5000张图片训练集4000、验证集1000覆盖成熟、未熟、腐烂等状态工程采用Adam优化器与交叉熵损失run_results目录已保存最优权重、训练日志及loss/精度曲线predict.py可对inference下的全部图片推理并标注Top3概率类别训练自定义数据时readme文档可自动生成配置。压缩包共2000个文件以1993张jpg图像为主内含4个py源码、json配置、readme与说明txt整体约200.1MB。已有151人学习下载适合图像分类入门及香蕉品质检测相关课题参考。1. 为什么用 GoogLeNet 做香蕉成熟度识别一个能落地的 6 分类项目香蕉从采摘到上架成熟度直接决定催熟批次和损耗率人工分拣靠经验且标准不统一。这个项目用 GoogLeNet 对香蕉图像做 6 类成熟度分类训练集 4000 张、验证集 1000 张交付物里除了图片数据还有完整可运行的 PyTorch 代码。代码风格很干净除了网络主体之外数据加载、训练循环、验证指标、预测绘图都是手写实现适合想从头跟一遍图像分类全流程的人。它不像很多仓库那样封装得只剩一个main而是把每一段逻辑都摊开新手能看懂老手也能快速改成自己的数据集。后面我按数据、网络、训练、预测、迁移的顺序把关键点拆开讲。2. 数据集结构与 6 类标签从 5000 张香蕉图开始2.1 类别划分与训练/验证数量项目说明里写着“数据集分为 6 类别成熟、没熟、腐烂”这里的 6 类并不是三个标签。我一般会把成熟度拆成 6 档绿色未熟、黄绿半熟、全熟、出现黑斑、腐烂初期、高度腐烂。这个项目实际采用的标签以交付包里的readme或类别字典为准但代码按 6 类设计fc层输出维度也是 6这一点是固定的。训练集 4000 张验证集 1000 张图片来自 Roboflow 导出的香蕉图像。文件名里的.rf.是平台导出时留下的标记和图片内容无关代码直接按文件名读取即可。目录结构决定了类别归属所以不要随便改动子目录名否则class_to_idx的顺序会变。数据部分图片数量说明train40006 类按子目录存放参与梯度更新val1000与训练集同分布不参与梯度更新inference若干预测脚本默认读取这个目录下所有图片2.2 目录结构与数据读取项目里常用目录结构如下每个类别一个文件夹dataset/ ├── train/ │ ├── class_0/ │ ├── class_1/ │ ├── class_2/ │ ├── class_3/ │ ├── class_4/ │ └── class_5/ └── val/ ├── class_0/ ├── class_1/ ├── class_2/ ├── class_3/ ├── class_4/ └── class_5/读取时最直接的做法是torchvision.datasets.ImageFolder它扫描根目录下的子目录按字典序自动生成class_to_idxfrom torchvision import datasets train_dataset datasets.ImageFolder( rootdataset/train, transformtrain_transform, ) val_dataset datasets.ImageFolder( rootdataset/val, transformval_transform, ) print(train_dataset.classes) # [class_0, class_1, ...] print(train_dataset.class_to_idx) # {class_0: 0, ...} print(len(train_dataset.imgs)) # 4000这里class_to_idx是从子目录名到整数标签的映射。ImageFolder只认目录不认文件名所以图片怎么命名都不重要。如果你的数据不是目录结构而是 CSV 标注就需要自己写Dataset这个项目用目录结构所以不用改。2.3 图片预处理与数据增强GoogLeNet 输入尺寸是 224×224训练和验证阶段的预处理不完全一样。项目里典型的写法如下from torchvision import transforms train_transform transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.1, contrast0.1, saturation0.1), 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]), ])Resize(256)不是直接为了给网络用而是先放大一点再做随机裁剪让训练时能观察到目标的局部特征。RandomResizedCrop会随机裁剪一个正方形区域再缩放到 224模拟不同距离拍摄的香蕉。验证阶段不需要随机性所以用CenterCrop。Normalize使用 ImageNet 的均值和标准差因为 GoogLeNet 预训练权重是在 ImageNet 上学的输入分布保持一致能加快收敛。参数训练集验证集Resize256256裁剪RandomResizedCrop(224, scale0.8-1.0)CenterCrop(224)翻转50% 水平随机无颜色抖动亮度/对比度/饱和度 0.1无NormalizeImageNet mean/std同左3. GoogLeNet 结构为什么 Inception 模块适合这种小数据集3.1 多尺度卷积与参数量的权衡香蕉成熟度识别不是 ImageNet 那样的一千类细粒度分类但也不是只靠颜色阈值就能搞定。青绿色到黄色的过渡、黑斑面积、腐烂区域的纹理都需要模型同时看到小范围的边缘和大范围的色块。GoogLeNet 的核心设计是 Inception 模块在同一层里并行使用 1×1、3×3、5×5 卷积和 3×3 最大池化把不同感受野的特征拼接起来。这样做的好处是参数少。VGG16 的参数量在 138M 左右GoogLeNet 只有 7M 上下训练速度和解码速度都快很多。对于 4000 张训练图的规模用 VGG 很容易过拟合GoogLeNet 反而能靠结构上的多尺度组合提升泛化能力。很多做农业图像分类的落地场景都倾向选 Inception 结构的网络原因就在这里。3.2 Inception 模块的手写实现项目里网络主体可以直接用torchvision.models.googlenet但如果想理解结构自己写一个 Inception 模块并不复杂import torch import torch.nn as nn class Inception(nn.Module): def __init__(self, in_channels, ch1x1, ch3x3_reduce, ch3x3, ch5x5_reduce, ch5x5, pool_proj): super().__init__() self.branch1 nn.Sequential( nn.Conv2d(in_channels, ch1x1, kernel_size1), nn.ReLU(inplaceTrue), ) self.branch2 nn.Sequential( nn.Conv2d(in_channels, ch3x3_reduce, kernel_size1), nn.ReLU(inplaceTrue), nn.Conv2d(ch3x3_reduce, ch3x3, kernel_size3, padding1), nn.ReLU(inplaceTrue), ) self.branch3 nn.Sequential( nn.Conv2d(in_channels, ch5x5_reduce, kernel_size1), nn.ReLU(inplaceTrue), nn.Conv2d(ch5x5_reduce, ch5x5, kernel_size5, padding2), nn.ReLU(inplaceTrue), ) self.branch4 nn.Sequential( nn.MaxPool2d(kernel_size3, stride1, padding1), nn.Conv2d(in_channels, pool_proj, kernel_size1), nn.ReLU(inplaceTrue), ) def forward(self, x): return torch.cat([self.branch1(x), self.branch2(x), self.branch3(x), self.branch4(x)], dim1)每个分支的输出在通道维度上拼接空间尺寸保持一致。branch1相当于是纯 1×1 卷积只做跨通道组合branch2和branch3通过小卷积核捕捉局部纹理和大范围色块branch4先做 3×3 池化再用 1×1 卷积融合给后续层提供池化后的信息。分支操作作用branch11×1 conv跨通道压缩降低计算量branch21×1 3×3 conv捕捉边缘、纹理等局部特征branch31×1 5×5 conv捕捉更大范围的色块过渡branch43×3 pool 1×1 conv引入池化特征保持信息密度3.3 输出层替换与辅助分类器如果把 GoogLeNet 当作黑盒使用训练时最省心的方式是关闭辅助分类器import torchvision.models as models import torch.nn as nn model models.googlenet(pretrainedTrue, aux_logitsFalse) num_classes 6 model.fc nn.Linear(model.fc.in_features, num_classes, biasTrue)aux_logitsFalse时model(x)直接返回一个(B, 6)的张量训练循环和普通分类网络完全一致。如果保留aux_logitsTrue模型会额外输出两个中间层分类结果训练时需要对三个输出分别计算损失再加权相加。对于这个小数据集关闭辅助分类器也能收敛代码更简洁这也符合“除了网络主体外其余纯手写”的定位。GoogLeNet 的fc层输入维度是 1024所以替换时用model.fc.in_features取值不要写死。4. 训练闭环Adam、交叉熵与 run_results 里的权重保存4.1 超参数设置项目默认选 Adam 优化器和交叉熵损失。Adam 对学习率不那么敏感适合代码边读边改的场景交叉熵直接对应多分类的负对数似然和nn.functional.softmax配合好不需要自己做 one-hot 编码。超参数默认值batch size32epochs50optimizerAdamlearning rate1e-3weight decay5e-4损失函数CrossEntropyLoss学习率调整每 20 个 epoch 乘 0.1weight_decay建议保留香蕉图像背景相对简单模型容易过度记忆拍摄环境而不是成熟度特征。4.2 训练循环主体训练部分的代码几乎都是手写的核心结构如下import torch import torch.nn as nn from torch.utils.data import DataLoader train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4) model.train() optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay5e-4) criterion nn.CrossEntropyLoss() for epoch in range(50): running_loss 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) train_loss running_loss / len(train_dataset) val_acc evaluate(model, val_loader) print(fepoch {epoch:02d} | loss {train_loss:.4f} | val_acc {val_acc:.2f}%)images的形状是(B, 3, 224, 224)labels是一维整数张量。loss.item()是当前 batch 的平均损失乘上images.size(0)是为了按样本数累加最后除以总样本数得到整个 epoch 的平均 loss。验证阶段不要开梯度否则显存和内存都会浪费。4.3 验证与保存最优权重验证函数和输出准确率的逻辑也很直白def evaluate(model, loader): model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in loader: outputs model(images) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) model.train() return 100.0 * correct / total注意model.eval()之后要回到model.train()否则 BN 层和 Dropout 在下一个 epoch 会处于错误状态。保存最优权重时可以比较val_acc只有超过历史最好值才覆盖best_acc 0.0 if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), run_results/best_model.pth)run_results目录下除了权重还会保存log.csv和loss_acc.png这类文件。训练日志每行记录epoch,train_loss,val_acc方便后面画曲线。4.4 从日志画曲线如果日志是 CSV读取时可以直接用pandas也可以内置csv模块逐行解析import pandas as pd import matplotlib.pyplot as plt df pd.read_csv(run_results/log.csv) plt.figure(figsize(8, 5)) plt.plot(df[epoch], df[train_loss], labeltrain_loss) plt.plot(df[epoch], df[val_acc], labelval_acc) plt.legend() plt.savefig(run_results/loss_acc.png)loss_acc.png不是训练过程中实时刷新的而是脚本跑完后根据日志生成的。如果看不到图先确认log.csv里有没有写入train_loss和val_acc两列列名大小写也能影响绘图结果。5. predict 脚本批量推理并绘制 Top-3 概率5.1 自动扫描 inference 目录预测阶段不需要手动传图片路径predict.py会扫描inference目录下的所有图片这是这个项目很顺手的设计。换新图片时只要丢进文件夹再跑一次脚本就行。import os import torch import torch.nn as nn import torchvision.models as models from PIL import Image from torchvision import transforms device torch.device(cuda if torch.cuda.is_available() else cpu) model models.googlenet(pretrainedFalse, aux_logitsFalse) model.fc nn.Linear(1024, 6) model.load_state_dict(torch.load(run_results/best_model.pth, map_locationdevice)) model.to(device) model.eval() 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]), ]) os.makedirs(output, exist_okTrue) for fname in os.listdir(inference): img Image.open(os.path.join(inference, fname)).convert(RGB) input_tensor transform(img).unsqueeze(0).to(device) with torch.no_grad(): logits model(input_tensor) probs torch.softmax(logits, dim1)[0] top3 torch.topk(probs, 3) # 把 top3 绘制到图片上这里直接Resize((224, 224))是为了让示例代码短一些。正式使用时建议复用训练阶段的val_transform也就是先 Resize 到 256 再 CenterCrop 到 224否则长宽比严重失真的图片会让输出概率偏掉。5.2 把 Top-3 类别和概率画在左上角项目要求“取前三个概率最大的类别绘制在左上角”用 PIL 的ImageDraw可以逐行绘制from PIL import ImageDraw class_names [class_0, class_1, class_2, class_3, class_4, class_5] draw ImageDraw.Draw(img) y0 10 for i in range(3): idx top3.indices[i].item() score top3.values[i].item() text f{class_names[idx]}: {score:.2f} draw.text((10, y0 i * 24), text, fill(0, 255, 0)) img.save(foutput/{fname})top3.values是 softmax 之后的概率已经做了归一化。绘制时第一行是最大概率类别第二行是次大概率第三行是第三概率。对香蕉成熟度这种边界模糊的任务只看 top-1 容易误判top-3 能帮助人工复核当前样本到底是“成熟”还是“出现黑斑”。配置默认值说明top_k3绘制前 k 个类别input_dirinference自动扫描目录output_diroutput预测结果保存目录class_namesclasses.txt按索引映射类别名有一点要注意PIL 在 Linux 默认字体下不支持中文绘制中文会变成方框。预测脚本里最好用英文或数字标签。如果必须显示中文需要手动加载中文字体文件from PIL import ImageFont font ImageFont.truetype(/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc, 16) draw.text((10, y0 i * 24), text, fontfont, fill(0, 255, 0))字体路径根据系统环境调整Windows 下一般用C:/Windows/Fonts/simhei.ttf。5.3 预测结果的保存与自动整理保存图片时直接把原文件名写在output下同名文件会被覆盖。如果你希望保留更多信息可以同时把 top-3 结果写入一个 CSV比如import csv with open(output/result.csv, a, newline) as f: writer csv.writer(f) writer.writerow([fname, idx_1, score_1, idx_2, score_2, idx_3, score_3])这样后续分析错误样本时不需要重新打开每张图片直接看 CSV 里的概率分布就能判断模型是在哪一类上犹豫。6. 换数据集时的几个细节与常见坑6.1 类别数自动变化项目 readme 里写着“训练自己的数据参考 readme 文件不需要更改代码会自动生成类别个数”。这是因为ImageFolder会扫描子目录数量。你自己替换数据集时建好train/class_0到train/class_N目录即可。但要小心目录排序ImageFolder默认按字典序排序如果你的类别有业务顺序不要依赖 0/1/2 的目录名训练前先把class_names打印出来确认一遍。6.2 输入尺寸不统一如果自己的图片长宽比很大不要直接用Resize((224, 224))会导致物体被压扁网络学到错误几何特征。建议先用Resize(256)保持比例再用CenterCrop(224)截取中心区域。如果截取后丢失了关键区域可以先加灰色 padding 再把整体缩放到 224。6.3 loss 变成 NaN出现 NaN 先检查学习率Adam 一般 1e-3 不会爆但如果用了更大的初始学习率前几个 step 就可能溢出。再检查归一化图片转成 Tensor 后值域是 0-1如果自定义 Dataset 忘了归一化而直接输出 0-255梯度也容易发散。最后检查标签CrossEntropyLoss要求标签从 0 开始连续整数如果某一类缺失fc 输出和 label 对不上。6.4 导出 ONNX如果需要把模型部署到服务或边缘设备PyTorch 模型可以直接导出为 ONNXdummy torch.randn(1, 3, 224, 224) torch.onnx.export(model, dummy, googlenet_banana.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}})dynamic_axes允许运行时修改 batch 大小方便服务端同时处理多张图片。导出的模型用 ONNX Runtime 加载后可以脱离 PyTorch 环境运行推理阶段的部署依赖会小很多。本文还有配套的精品资源点击获取