
简介这是一套基于ShuffleNet轻量级CNN的菠萝成熟度分类实战项目面向图像分类初学者与轻量网络应用开发者解决8种不同阶段如没熟、半熟、成熟等果实的自动识别问题。压缩包为7z格式共2000个文件内含1992张jpg图片、4个Python脚本、2个txt说明、1份readme和1个json配置整体约201MB训练集4808张、测试集806张目录按类别分好解压即可直接运行数据集为真实菠萝图像覆盖不同成熟阶段的视觉差异。目前已有125人学习浏览。项目使用约一百万参数的ShuffleNet配合cos学习率衰减训练50个epoch测试集最高精度达87%run_results中存有最优权重、训练日志及loss/精度曲线预测时可直接使用predict脚本自动推理inference目录下所有图片并标注前三概率类别方便快速查看分类结果。readme还说明了训练自定义数据的方法代码会自动生成类别个数等配置便于迁移到其他分类任务无需手动修改网络结构。1. 用 ShuffleNet 给菠萝成熟度分类8个阶段分得准难点不在模型在水果催熟库、分拣线或是电商质检现场要上一套“8种不同阶段菠萝成熟度分类”的视觉系统最务实的起点不是堆算力上大模型而是用 ShuffleNet 这类轻量级 CNN 网络在相机端或本地小主机上实时跑出结果。ShuffleNet 参数少、推理快CPU 上也能达到实时帧率预算和部署环境都很友好这也是图像分类实战项目里最常见的落地口径。但这个题目真正卡人的不是网络结构而是“8个成熟阶段”怎么定义、怎么标注——适熟和完熟可能只差半天光照人眼都会判错。这篇笔记面向准备做端侧图像分类落地、以及刚入深度学习想完整跑一个 CNN 项目的人我会把选型、数据、训练和踩坑完整铺开。2. 轻量级网络的工程选型这道题为什么选 ShuffleNetV2 而不是 MobileNet2.1 三个选型维度先卡算力再看吞吐最后看精度底线做图像分类实战第一件事不是看榜单是看现场设备。常见部署端有三种摄像头内置芯片海思、瑞芯微这类、工控机 CPU、低端 GPU 盒。每种设备对应的模型体量完全不同先确认“模型单帧推理要跑到多少毫秒以内”再谈选哪个网络。比如在四核 ARM 板子上跑实时视频流单帧推理最好控制在 80ms 以内在 x86 工控机上可以放宽到 200ms。这个门槛没定选 MobileNet 还是 ShuffleNet 都是空谈。第二步看吞吐也就是同一设备上能同时跑多少路视频。摄像头多路接入时网络结构的内存访问量MAC比 FLOPs 更关键因为内存带宽往往是瓶颈。现在工业相机一路就能吃掉不少带宽多路并行时模型对缓存的友好程度直接决定能不能稳定跑满帧率。ShuffleNetV2 设计时把“内存访问成本”当作核心优化目标这一点在真实设备上比纯算力数字更可信。我习惯先拿一个最小模型在目标设备上实测一遍而不是对着理论 FLOPs 做推断。最后才是精度底线。菠萝成熟度分类的错误代价不是对等的把“未熟”判成“完熟”会导致售后投诉把“完熟”判成“变质”会造成损耗。所以选型时不能只看测试集平均准确率要看“关键相邻阶段”的类别召回率。轻量级模型在单一特征颜色、纹理差异大的任务上和大模型的差距非常小成熟度分类恰恰是这种任务没必要为了零点几个点的准确率换上几千万参数的模型边际收益很低部署成本却直线上升。2.2 ShuffleNetV2 的四条设计准则怎么做到又快又准ShuffleNetV2 有四个在工程上很好记的准则一是输入输出通道相等时内存访问成本最低二是过多的组卷积会增加内存访问需要控制组数三是网络碎片化会降低并行度结构要尽量“直”四是逐元素操作ReLU、Add虽不算 FLOPs但占用时间要精简。这四条不是论文里的空理论直接对应到我们在板子上看到的真实延迟。通道混洗Channel Shuffle是这个网络最特别的操作。分组卷积把通道切成几组每组各学各的组与组之间信息不互通混洗就是把通道顺序打乱让下一层的每组都能拿到上一层各组的信息。用打牌来记最直观一副牌分成几摞洗一下再重新发每个人手里的牌就来自不同摞了。代码实现上只需 reshape 加 transpose非常轻量。这也是 ShuffleNet 命名和精度的核心比 MobileNet 的深度可分离卷积多了一步“信息打通”。理解到这一步就明白为什么在“类别多但特征集中在颜色、纹理”的任务上ShuffleNet 的精度不容易掉它把通道间信息交换做得很充分而成熟度判别的关键特征果皮颜色、果眼密度、光泽恰好分布在多个通道组合里。很多人在实际项目里踩过另一个坑组卷积里组数设得太大通道碎片化反而拖慢了速度。ShuffleNetV2 在结构上刻意控制了组数上限这也是它在大核数 CPU 上仍然能保持稳定性能的原因。2.3 与 MobileNetV3、EfficientNet-Lite 的对比边界在哪对比项ShuffleNetV2MobileNetV3EfficientNet-Lite内存访问控制重点优化 MAC一般一般结构复杂度简单适合手写复现含 SE 模块稍复杂复合缩放规则偏复杂并行度较高碎片少中等较低分支多常用宽度因子x0.5 / x1.0 两档small / large 两档b0 / b1 两档CPU 部署效果好老式 ARM 也稳好但 SE 模块在 CPU 上有代价取决于框架对缩放规则的支持实际选型时我一般按这个顺序判断设备只有 CPU 且在意延迟优先 ShuffleNetV2设备是手机端且有厂商 NPUMobileNetV3 更顺手如果精度要求高、且允许 GPU才考虑 EfficientNet-Lite。对菠萝成熟度这种项目ShuffleNetV2 的性价比最高后面所有内容都按它来写。别急着追最新的图像分类模型榜单先把手头设备跑通比多刷一个百分点的验证集更有价值。3. 菠萝成熟度 8 分类的数据整理标注规范、目录组织与训练集划分3.1 先定8个阶段的标注标准拍什么、怎么标、哪些别拍分类必须先定义边界。8 个成熟阶段不是随便贴标签每个阶段都要有可观察、可复现的外观描述我把标准整理成下面这张表拍照前先跟质检同事过一遍类别编号阶段名外观可判特征不建议拍入该类的边界情况0深绿未熟果皮整体深绿无光泽果眼棱角硬果皮颜色不均匀但整体偏绿1浅绿半熟绿色变浅微亮果眼间隙开始饱满有明显黄色斑块的绿果2转色初期绿色为主黄色占比低于三成黄色占比刚好一半的“骑墙”样本3转色中期黄绿参半黄略大于绿花萼处带黄但果身全绿4适熟黄色为主果眼平按压微弹过软或出现褐斑的样本5完熟金黄、有香气果眼平且边缘略深稍有褐斑但果肉完好的临界果6过熟出现褐斑或褐纹局部软塌光泽下降褐斑显著、有流汁倾向的归为 77变质/损伤霉斑、发黑、流汁、明显机械伤只有碰伤但无霉变的归为过熟一个重要的经验不要在拍照现场“随手标”先让负责质检的同事把临界样本挑出来集体定完标准再整批标注。否则后面会有一堆骑墙样本模型训练时同一张照片换个人标就换类数据噪声直接体现在测试集上。每个阶段拍照时不只拍正面侧面、顶部花萼区、底部果眼区各来一张同一个果实至少拍 2 到 4 张为后面按“果”划分数据集做准备。3.2 采集与增强策略光照、角度和遮挡怎么补齐采集时每个成熟阶段至少要有 300 到 500 张图注意覆盖不同时间段、不同天气、不同产季的光线条件。拍摄距离和分辨率尽量统一建议不低于 640x640后期缩到 224 时才不容易丢纹理信息。同一个菠萝的多个视角不是为了凑数量是为了让模型学会“从多个角度判断成熟度”而不是只记住某一个拍摄姿势。在线增强是补数据不足的主要手段但参数要谨慎下面这个是我常用的训练增强配置import torchvision.transforms as T train_transform T.Compose([ T.RandomResizedCrop(224, scale(0.75, 1.0), ratio(0.9, 1.1)), T.RandomRotation(degrees15), T.ColorJitter(brightness0.25, contrast0.25, saturation0.05, hue0.02), T.RandomHorizontalFlip(p0.3), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])参数说明RandomResizedCrop 的 scale 控制截取区域占原图比例给它 0.75 到 1.0 是为了保留果皮整体颜色分布截得太小会只拍到局部成熟度信息反而丢失饱和度抖动只给 0.05因为成熟度分类高度依赖颜色抖动太大等于把正确答案改错翻转只做水平翻转不做上下翻转菠萝果柄和果眼的方向是有语义的翻开会制造假样本。验证集和测试集只做 Resize 到 224 和 Normalize不要加任何随机增强否则评估结果每天都不一样。这一点在多人协作时特别容易翻车有人图省事直接复制了训练增强的配置去跑测试最后报告出来的准确率根本没法复现。3.3 生成 train/val/test 划分脚本按“果”分组防数据泄露划分训练集最典型的错误是用随机划分导致同一颗菠萝的照片同时出现在训练集和验证集。成熟度的颜色变化在相邻阶段本身就接近这种数据泄露会让验证集虚高部署后掉点明显。正确做法是先给每个菠萝一个 unique_id再按 id 分组划分。我用 GroupShuffleSplit 来实现脚本如下from sklearn.model_selection import GroupShuffleSplit from glob import glob import os # 文件名格式菠萝ID_类别_序号.jpg例如 A001_3_02.jpg image_paths glob(raw/*.jpg) group_ids [os.path.basename(p).split(_)[0] for p in image_paths] labels [int(os.path.basename(p).split(_)[1]) for p in image_paths] gss GroupShuffleSplit(n_splits1, test_size0.3, random_state42) train_idx, temp_idx next(gss.split(image_paths, labels, groupsgroup_ids)) # 从临时集合里再切一半作为验证集一半作为测试集 val_ratio 0.5 val_count int(len(temp_idx) * val_ratio) val_idx temp_idx[:val_count] test_idx temp_idx[val_count:] print(ftrain{len(train_idx)}, val{len(val_idx)}, test{len(test_idx)})这段代码先把 30% 的整组样本拆出来留作验证加测试再把这 30% 一分为二得到 15% 验证、15% 测试。关键在 GroupShuffleSplit 的 groups 参数它保证同一个菠萝 ID 的所有图片只会出现在一个集合里不会一半在训练一半在验证。random_state 固定是为了实验可复现调参时不会因为数据切分不同而无法比对。val_idx 的数量可以用 len 打出来确认。常见坑是 GroupShuffleSplit 在 group 数量太少时给出的验证集合特别小此时宁可就地重拍几组新果也别靠复制贴图硬撑。3.4 脏样本清洗类别不平衡检查与颜色直方图排错划分完先统计每类数量。菠萝成熟度采集时“转色初期/中期”往往很好拍“变质/损伤”很难拍到足够数量这类样本少是正常的要在训练阶段做平衡采样而不是靠重复复制硬凑。如果某一类少于总样本的 5%我就认为它不足以单独训练要么补拍要么把相邻阶段合并后重新设计类别。颜色直方图是排查脏样本最快的手段。同一个成熟阶段如果有照片颜色分布明显离群多半是白平衡偏差或滤镜导致直接删掉。我还会按文件大小、清晰度用 Laplacian 方差判断模糊再过滤一轮模糊样本不标注硬标进去只会让模型更困惑。模糊样本单独放一个 unlabeled 目录等后续补拍再人工决定归属。脏样本清洗没有玄学就是拿工具把每一类的分布都过一遍眼睛模型后面的收敛速度会好很多。4. ShuffleNetV2 训练与调参跑通最小实验再谈收敛4.1 用 PyTorch 搭一个最小训练脚本先跑通、再调优。最小训练脚本不需要花哨核心是把数据读进来、模型改好、跑几个 epoch 看到 loss 下降即可。ShuffleNet 在 PyTorch 里有官方实现直接用 torchvision 加载预训练权重再替换最后的全连接层。import torch import torch.nn as nn import torchvision.models as models from torch.utils.data import DataLoader def build_model(num_classes8): # pretrained 使用 ImageNet 预训练权重成熟度分类也适用 # 新版 torchvision 推荐用 weights..., 这里保留常见旧式写法 model models.shufflenet_v2_x0_5(pretrainedTrue) in_features model.fc.in_features # 最后一层原来输出 1000 类换成自己的 8 分类头 model.fc nn.Linear(in_features, num_classes) return model model build_model(num_classes8) device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() # 微调经验值SGD 动量0.9权重衰减 4e-5初始学习率 0.01 optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay4e-5) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max80, eta_min1e-5) for epoch in range(1, 81): 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) scheduler.step() val_loss, val_f1 evaluate(model, val_loader, device) print(fepoch {epoch:2d} | train_loss {train_loss/len(train_loader.dataset):.4f} f| val_loss {val_loss:.4f} | val_macro_f1 {val_f1:.4f})逻辑说明预训练模型前三层学到的底层特征边缘、棱角、颜色过渡对菠萝同样有效直接在 ImageNet 权重上微调比随机初始化收敛更快也更能稳住早期训练。替换最后一层是因为原模型输出 1000 类而这里要 8 类。交叉熵损失直接用在全连接层输出上不需要额外做 softmaxPyTorch 的 CrossEntropyLoss 内部已经包含 log_softmax 与 NLLLoss 的计算。参数说明SGD 配动量和余弦退火是图像分类微调里最稳的组合Adam 收敛虽快但后期在类别不平衡数据上容易出现验证集指标波动。学习率 0.01 对预训练模型来说偏大但它配合余弦退火会在 80 epoch 内平滑降到 1e-5前段能快速适应菠萝数据后段能精细收敛。batch size 这个脚本按 64 设计显存不够就降到 32但要注意降低 batch 时学习率也要等比调整否则收敛不稳。4.2 四个必调参数lr、batch size、weight_decay、学习率策略参数推荐起点调节方向与理由初始学习率0.01SGD预训练微调用 0.005~0.02从随机初始化开始则用 0.1 基准值batch size64显存紧张降到 32此时 lr 大约减半避免单批梯度噪声变大weight_decay4e-5值太大会让模型欠拟合值太小则后期 val_loss 反复弹跳学习率策略CosineAnnealingLR不用 StepLR 的原因余弦退火在类别不平衡时后期更稳不容易卡在平台期权重衰减对 ShuffleNet 的影响比想象中大。这个网络本身结构紧凑如果 weight_decay 取 1e-3 这种偏大的值后期几乎每个卷积核都被拖到接近零范数特征表达被削平。常见做法是先按 4e-5 跑一个基准观察 train_loss 和 val_loss 的间距间距大说明过拟合再加到 1e-4间距很小甚至 val 比 train 好就维持或降低。另外多卡训练时 batch size 翻倍学习率最好也按比例翻倍。单卡 64 与双卡 128 不能直接用同一学习率否则收敛路径会变。这些参数没调好往往不是网络结构的问题而是优化器与批量大小没对齐。4.3 早停与模型保存按验证集宏F1选模型别只看val_loss训练过程中每轮都在 val_loader 上计算指标。成熟度分类类别不平衡val_acc 会被多数类主导而 val_loss 对错分“变质/损伤”这种少数类不敏感。所以我一般把验证集的宏 F1macro F1当作选模型指标它对每个类平等看待少数类拉胯时它会明显掉。def evaluate(model, loader, device): from sklearn.metrics import f1_score all_preds, all_labels [], [] model.eval() with torch.no_grad(): for images, labels in loader: images images.to(device) outputs model(images) preds outputs.argmax(dim1).cpu().tolist() all_preds.extend(preds) all_labels.extend(labels.tolist()) return f1_score(all_labels, all_preds, averagemacro)保存模型时不要只存 state_dict把预处理参数也一起存训练时用的 mean、std、输入尺寸、类别顺序。工程部署时常常会忘记这些细节到时候在推理脚本里重新翻训练代码找参数非常浪费时间。一次存好推理端直接读配置文件这是实战项目里很值得坚持的小习惯。早停的 patience 设置 15 到 20 epoch。在成熟度分类这种任务上模型在 30 到 60 epoch 之间还会持续提升patience 设 5 容易在模型还没收敛完就停了。patience 设 20配合余弦退火基本能让你放心地跑满 80 epoch 再选点。5. ShuffleNet 训练避坑5个典型现象与排查记录5.1 现象训练 loss 下降缓慢val acc 卡在 30% 附近loss 一直在 2.0 以上徘徊准确率始终在 1/8 上下说明模型基本没学到东西。最常见原因不是网络坏了是标签写错类别编号从 1 开始而不是 0CrossEntropyLoss 默认 ignore_index 是 -1对第 0 类不惩罚模型就自动放弃这一类。如果 train_loader 里 labels 范围是 1 到 8而模型输出 8 类输出索引 0 永远不会被训练自然永远预测不了 0 类。解决方法很简单打印确认print(ys.min(), ys.max(), len(set(ys.tolist())))如果输出是 1 8 8说明标签从 1 开始需要把全部标签转换为 0 到 7。另一个次常见原因是预处理 Normalize 的 mean/std 写成了 [0,0,0] 和 [1,1,1]图像没有归一化梯度信号发散同样会 loss 跌不下去。用 ImageNet 标准值是安全的。5.2 现象验证集宏F1挺高一到测试集掉点严重翻车率明显这是典型的数据划分泄露。统计时看起来 val 有 0.94测试集只剩 0.86且通常不是个别类掉而是所有类都掉 2 到 5 个点。原因是同一个菠萝在不同角度、不同光照下的照片被随机划分到了训练集和验证集验证时相当于模型见过同源的近亲样本。成熟度的相邻阶段本身差异极小这种同源数据泄露特别隐蔽。解决思路在 3.3 已经铺过用 GroupShuffleSplit 按菠萝的唯一 ID 分组划分。如果项目是后期才发现的修补方式是直接把训练集里同组图片全部挪到验证集重新训练一次。你会发现 val 掉了一些但测试集和 val 差距明显缩小这才是真实水平。5.3 现象增强一加强就过拟合不增强又欠拟合为了数据更稳我曾把 ColorJitter 的 saturation 调到 0.3结果训练集 loss 能降到 0.05验证集反而更差。成熟度分类几乎完全靠颜色饱和度抖动 0.3 意味着把“适熟”的黄色调到接近“完熟”的金黄等于在训练集里制造了大量标签错误的样本模型只能靠死记硬背来拟合自然过拟合。解决把 saturation 和 hue 压到很小brightness 和 contrast 可以适度保留。我常用的值是 saturation0.05、hue0.02、brightness0.25。还想加鲁棒性就加 RandomResizedCrop scale(0.75, 1.0)让模型适应不同观察距离而不是去改变颜色分布。5.4 现象8分类模型只会输出两三类几乎不预测“过熟/变质”训练完成后用测试集一数发现预测结果集中在 0、2、5 三个类6 和 7 几乎没有预测。原因通常有两类一是 6/7 类样本数量太少模型在损失中不太在乎它们二是 6/7 类样本本身集中在某个拍摄条件里模型没有看到多样化环境下的样本。解决分三步先统计类频数少的那几类做过采样或 WeightedRandomSampler再检查这几类的图片是否只有单一背景或单一光照如果是就去补拍不同环境下的样本最后评估时改看宏 F1别让极少数类的表现被淹没在整体准确率里。这个阶段不要急着加网络复杂度数据补上之后 ShuffleNet 通常会立刻见效。5.5 现象CPU 推理延迟比预期高一截帧率上不去ShuffleNet 的 FLOPs 数字很漂亮但在老式 CPU 上跑 PyTorch 模型时延迟往往比理论值高不少。原因在于通道混洗的实现涉及张量 reshape、transpose、chunk这些操作在 CPU 上会产生额外的内存搬运而且 PyTorch 的逐个算子调度会放大碎片化开销。多线程环境下一路 CPU 推理反而比单线程更慢也是常见现象。解决时我一般分三步走先把模型转成 ONNX再用框架的 CPU 加速后端跑x86 上用 OpenVINO 或 ONNX Runtime 的 CPU 加速算子ARM 板上尝试 NCNN。如果项目允许量化把权重转成 int8 精度损失通常不到 0.5%但延迟能再降一半。注意转换后要重新跑一遍测试集排除量化掉点过大的情况尤其是 6/7 这种纹理细节多的类别。6. 进阶一步用混淆矩阵找成熟度阶段边界让准确率再提 3 个点6.1 混淆矩阵要看“相邻阶段交叉”不是只看对角线模型训练结束后先别急着部署跑一遍测试集混淆矩阵。常见结果是相邻阶段的错分特别扎眼0/1 之间、1/2 之间、3/4 之间这类错分并不是模型不行而是标注时“阶段边界”在不同人手里不一致。我之前遇到过“适熟”和“完熟”错分最多的案例把错分样本调出来看发现果皮颜色确实接近差异只在果眼平展程度和光泽——这些在现场图片里很容易被光照掩盖。遇到这种相邻错分有效的处理不是继续堆训练而是把错分样本整理成一份“边界复核表”发给一线质检人员逐张确认。确认之后有两类做法如果错分样本里确实有一半标错就修正标签后重新训练如果标签没标错说明这两个阶段在图像上确实过于接近考虑把这两类合并成一个大类如“适熟-完熟”模型输出的业务价值反而更高。这一步通常能一次性把对应类别的准确率拉高 3 到 5 个点比调任何训练参数都明显。6.2 用置信度阈值做二次校验不确定就让现场人工看最后一步是给推理端加置信度阈值。部署场景里最有价值的不是让模型“硬判”而是让它知道自己“不确定”。我一般会为每个阶段单独设阈值因为不同阶段的难易程度不同。阶段阈值低于阈值时的现场动作深绿未熟0.85默认输出复核果柄断面转色初期0.75弹窗提示人工复核适熟0.80交由质检员目检过熟/变质0.90必须人工复核防止漏判低于阈值的样本不直接进入分拣动作而是归入“待人工复核”队列。这条规则看着简单实际让整个系统的错误率下降了一个量级因为机器承担了九成的简单分拣剩下的一成临界样本由人来接手。阈值可以在部署后用小批量样本滚动调不用追求一次到位。我第一次做这个项目时特别迷信训练指标把 val acc 刷到 0.95 就急着上线。后来才发现真正让现场验收满意的是“误判可控”——模型拿不准时主动说不知道比全盘硬判更让人放心。现在我的习惯是先跑混淆矩阵再定阈值最后才回头看训练参数。希望这个思路对你的菠萝成熟度项目也有帮助。本文还有配套的精品资源点击获取