
上个月在给一条果蔬分拣线做视觉质检方案时我遇到一个很现实的问题项目需要按品质等级对六种水果进行图像分类但翻了半天公开数据集能下载到的水果分类数据大多按“品种”区分比如苹果有富士、嘎啦、蛇果或者按“状态”区分比如好果和烂果。真正面向“同一种水果的A级/B级/C级”这种细粒度区分的六种水果分级-图像分类数据集几乎找不到。没办法只能自己动手构建一套顺便把从分级标准制定、采集清洗、标注质检、数据增强、模型训练到真机部署的完整流程都趟了一遍。这篇文章就记录这一整套做法和踩过的坑给同样在做农业视觉项目、需要自建图像分类数据集的朋友做个参考。1. 为什么我决定自己做一套六种水果的分级数据集1.1 公开数据集解决不了“等级”问题Fruits-360这样的公开数据集确实很经典里面收录了大量水果品种的实拍图类别标签也很干净。但它解决的是“这是什么水果”的问题而不是“这个水果属于哪个等级”的问题。实际分拣线上我们更关心的是这个苹果能不能按一级果出售这个橙子是否有足够的甜度和色泽进入商超渠道这批草莓里有没有畸形果需要挑出去。简单说客户要的不是物种识别而是质量分级。另一个现状是工业缺陷数据集虽然有不少比如钢材表面缺陷、木材瑕疵、纺织物破损但农产品的分级逻辑和工业缺陷完全不同。一颗苹果有0.5厘米的擦伤可能只是B级但如果是轮纹病留下的病斑哪怕很小也要直接降为C级。这种“缺陷类型面积阈值位置因素”的组合在通用公开数据集中很难找到。更别提六种水果各自的分级维度还不一样香蕉看长度和黑斑猕猴桃看软硬和绒毛葡萄看脱粒和白霜。硬套公开数据集模型上线后正确率会非常难看。1.2 数据集的规格设计与命名既然决定自己做第一步就是把数据集的形态定义清楚。我选了苹果、香蕉、橙子、草莓、猕猴桃、葡萄这六种常见水果每种水果设置A级、B级、C级三个品质等级总共18个分类。为什么是三个等级因为再细的级别比如特级、一级、二级、三级在图像上差异非常模糊人工标注都很难稳定区分模型学起来更会一团浆糊。类别命名采用英文小写加等级后缀的方式apple_A, apple_B, apple_C banana_A, banana_B, banana_C orange_A, orange_B, orange_C strawberry_A, strawberry_B, strawberry_C kiwi_A, kiwi_B, kiwi_C grape_A, grape_B, grape_C目录结构也设计成图像分类任务最常见的按类文件夹摆放dataset/ ├── train/ │ ├── apple_A/ │ │ ├── apple_041_0032.jpg │ │ └── ... │ ├── apple_B/ │ ├── apple_C/ │ ├── banana_A/ │ ... │ └── grape_C/ ├── val/ └── test/每类目标600张18类就是10800张额外增加300张“背景/杂质”图像放到单独类别用于后面让模型学会拒识最终数据集约11100张。划分比例按训练70%、验证15%、测试15%但划分必须基于“水果个体”分组切分而不是简单随机切分这一点后面会详细讲。命名里的apple_041表示第41个苹果个体0032表示该个体的第32帧这样的命名规则对后续防数据泄漏起到了关键作用。2. 分级标准是实现可复现标注的第一步2.1 把质检经验翻译成可量化的规则一开始我以为分级标准很简单不就是好果、次果、坏果吗真到制定规则的时候才发现质检师傅嘴里的“这个果面要干净”“这个太小了”根本没法直接落成标注指令。所谓“干净”是完全没有斑点还是允许少量果锈所谓“太小”直径小于多少毫米才算小后来我们拉着质检师傅一起把每个等级的关键指标拆出来做成量化表。以苹果为例等级直径着色面积瑕疵面积形状A级≥80mm红色占比≥70%无可见瑕疵端正无畸形B级≥70mm红色占比≥40%擦伤/斑点面积≤5%允许轻微不对称C级其余红色占比不足或分布不均瑕疵面积5%或存在腐烂、破损畸形明显不可接受其他五种水果也按类似思路拆解。香蕉按长度、弯度和果皮黑斑面积橙子按颜色均匀度、粗皮比例和病斑草莓按形状是否圆锥、红色覆盖比例和表面是否受损猕猴桃按软硬程度、绒毛完整度和表面凹陷葡萄按果粒密度、脱粒比例和白霜覆盖程度。重点并不是规则写得多漂亮而是要将规则细化到“看一眼就能判断”的程度。在我做的版本里瑕疵面积的判定直接用透明网格纸叠加到屏幕上用面积占比来估算。规则里必须有具体的数值阈值比如“擦伤面积≤5%”而不是“轻微擦伤”。有了这样的文字规则标注员才可能保持一致。2.2 边界案例是统一标注手感的唯一办法文字规则再细依然会出现同一张图两个人给出不同等级的情况。一个典型的例子苹果表面有一块6%的黄褐色晒斑有人觉得“不明显嘛算B级吧”另一个人认为“超过5%就算C级”。这不是标注员不认真而是规则对边界情况的解释还不够。我的解决办法是建立一份“边界案例图卡”。每个等级准备8张标准图再额外准备4张边界图。边界图专门收录那些卡在阈值附近的样本并在图卡上标注这个样本“为什么被判为这个等级”。比如那张晒斑6%的苹果明确标注为C级理由是“瑕疵面积超过5%的硬性标准”。标注员正式开工前需要先拿30张图试标准确率达到100%才允许进入正式标注流程。还有一个容易忽略的点腐烂果。拍摄过程中难免碰到已经软烂的水果这种如果直接扔进C级会让C级里的特征非常发散——既有畸形果、又有病斑果、还有腐烂果。我在标注规范里单独标记了腐烂/严重破损样本仍然放在C级但要求图像占比不超过该类别样本量的15%避免模型把“腐烂”当成C级的主要特征。3. 采集与清洗别让脏数据毁了模型3.1 采集环境和相机参数数据采集阶段最容易犯的错误是“把拍摄环境打理得太完美”。而实际上真实产线的光照、背景、运动模糊都是不可控的。我在实验室搭了一套采集台也用手机补拍了一些自然场景的图像。工业相机采用海康MV-CA050-20GC500万像素配8mm焦距镜头工作距离控制在40到50厘米。两个LED条形光源从左右45度方向打光减少水果表面的大面积阴影。白平衡先通过灰卡校准一次。传送带速度设为0.2米每秒用光电传感器触发相机拍照保证每张图都是清晰的单果画面。只靠工业相机拍出来的数据有一个严重问题背景永远是传送带的黑色皮带光源永远是固定位置。模型如果长期只在这种数据上训练很容易把“黑色背景”当作辨别特征的背景。所以我又用手机在日光、暖白灯、荧光灯等不同光源下手持拍摄了约2000张图片背景包括塑料筐、泡沫托盘、桌面、甚至手掌。拍摄角度也做了变化正面、侧面、背面、俯视水果在画面中占比约60%到90%不要求完全居中。3.2 数据清洗与去重清洗环节我差不多删掉了快三分之一的数据。先是程序过滤计算图像的拉普拉斯方差小于阈值100的判定为模糊图直接删除。这个阈值不是拍脑袋定的而是先手动挑了几十张清楚和模糊的图计算出来一个分界值。接着人工快速过一遍缩略图删除过曝、欠曝、高光反射强烈以及水果被严重遮挡的图。最容易被忽略的是连续帧去重。因为传送带触发拍照同一个水果从进入视野到离开可能拍了6到8张几乎一样的图。如果这些相似帧同时进入训练集和测试集模型等于在“背答案”。我用感知哈希算法给每一帧计算dHash然后两两比较汉明距离距离小于5的视为重复帧只保留其中一帧。这一步把约15000张原始图像压缩到了11500张左右。清洗后的文件命名也很有讲究。我采用“水果种类个体编号帧号”的方式比如apple_041_0032.jpg这样后期做数据划分时可以通过个体编号知道哪些图像来自同一个水果从而避免数据泄漏。很多人在清洗后会忽略这一步等到训练时才发现无法做分组切分只能重新整理非常浪费工时。4. 标注一致性比标注本身更值得花时间4.1 标注工具和流程设计图像分类的标注工具其实很简单我在Label Studio里配了一个分类标注模板每个样本选择对应的18个类别之一。标注结果导出为CSV包含image_id和label两列。如果你不想用工具也可以直接把图像复制到对应类别的文件夹但那样不利于后续的争议样本统计。流程上我采用了“初标-复标-仲裁”三段式。初级标注员先按类别初标全部数据然后由一位质检主管随机抽取30%的已标数据进行复核发现不一致就退回重标被退回的不一致样本进入仲裁池最后由更懂水果的质检师傅逐张拍板。这套流程看起来繁琐但非常必要特别是对于B级和C级边界模糊的样本避免了个体主观性影响整体标注质量。另一个容易踩的坑是“标注漂移”。一个人从第1张标到第500张对标准的掌握会慢慢变化——前期觉得严重的瑕疵后期可能觉得不严重。所以我在每标完250张后会抽50张与之前标注过的图进行对比检查是否存在系统性松紧偏移。如果发现标准变了就整个重新校准一次。4.2 用Kappa系数把主观分歧量化出来标注质量不能光靠“感觉大家意见差不多”我用Cohens Kappa系数来量化不同标注员之间的一致性。Kappa的计算公式是kappa (Po - Pe) / (1 - Pe)其中Po是两人标注结果的实际一致率Pe是假设完全随机标注时可能达到的一致率。kappa值大于0.8可以认为标注一致性良好。第一次试标时我随机抽取了300张图让两位资深标注员独立标注算出的kappa只有0.73。翻看分歧样本几乎所有问题都集中在“擦伤面积是否超过5%”和“轻微畸形是否算畸形”这两类边界判断上。于是我把透明网格辅助工具引入标注界面让标注员可以通过叠加网格估算瑕疵面积同时把新增的边界案例图发给每个人。第二次测量kappa提升到了0.86达到了可接受的门槛。4.3 类别平衡与样本控制经过清洗和标注后各类别数量并不均匀。苹果因为采购的是分级较好的货源C级特别少草莓恰好相反A级好果难找B/C级特别多。我的目标是把每个类别控制在600张左右上下浮动不超过50张。对于数量不足的类别我采取定向补拍而不是用合成样本硬凑。比如苹果C级缺了120张我就专门去批发市场挑了一批病斑果、畸形果和部分有轻微腐烂的果子拍。对于超过700张的类别随机删除多余的尽量保证所有类别数量在同一量级。不要用SMOTE之类的过采样方法生成图像分类模型尤其吃真实分布合成的伪图像会让训练集分布偏离真实场景。5. 数据增强和切分时最容易踩的坑5.1 用消融实验决定用哪些增强图像分类数据增强我用了很多年但每次面对新数据集我还是会做一轮消融实验而不是直接把所有增强都堆上去。这次我用了Albumentations库对比了不同增强组合对验证集准确率的影响。最终保留下来的增强配置是随机裁剪缩放RandomResizedCropscale范围0.7到1.0ratio范围0.8到1.2水平翻转概率0.5亮度对比度调整brightness_limit和contrast_limit都设为0.2HSV抖动hue_limit10, sat_limit20, val_limit20随机旋转±20度这个组合在验证集上表现最稳。我测试过额外增加CoarseDropout随机擦除验证集准确率反而掉了0.6个百分点。后来分析发现水果图像中的瑕疵区域往往面积不大随机擦除很容易把关键缺陷区域直接抹掉导致模型学到错误的特征。类似地高斯模糊强度过大会让训练集和真实场景的锐利细节不匹配影响泛化。5.2 GroupSplit按个体分组切分避免数据泄漏这一小节是整个数据集制作中最值得重视的问题。最初我用train_test_split(X, test_size0.15, random_state42)做随机切分训练出来的模型测试准确率高达98%当时觉得模型已经成了。结果拿到真实场景一测准确率直接跌到80%以下完全没法用。问题出在哪因为同一个苹果的连续帧里有大量高度相似的图像随机切分时同一个苹果的A帧可能在训练集B帧可能在测试集。模型在训练时已经见过这个苹果的“长相”到了测试阶段它做的是图像检索而不是等级分类。这是典型的数据泄漏会让验证指标完全失真。解决方法是按“水果个体”分组切分。我们命名时保留了个体编号比如apple_041的所有帧只能出现在同一个集合里。使用sklearn的GroupShuffleSplit实现from sklearn.model_selection import GroupShuffleSplit gss GroupShuffleSplit(n_splits1, test_size0.30, random_state42) train_idx, val_test_idx next(gss.split(X, y, groupsgroups))先把数据按7:3分成训练集和验证测试集再把验证测试集按同样的逻辑分成验证集和测试集。改进后测试准确率从虚高的98%降到了更真实的91%。表面上分数降了但真机实测准确率反而回升到了88%左右这才是可靠的结果。如果你做的是视频流采集的数据集强烈建议从一开始就保留个体ID否则之后想修都难。6. 基于该数据集的分类模型训练实测6.1 ResNet50迁移学习基线我先用ResNet50做基线模型。加载ImageNet预训练权重后把最后一层全连接替换成18类输出。输入图像尺寸统一为224×224。训练超参数如下优化器SGDmomentum0.9weight_decay1e-4初始学习率0.01学习率策略前5轮warmup之后余弦退火到1e-5轮数40批大小64损失函数CrossEntropyLosslabel_smoothing0.1为什么不用Adam而用SGD迁移学习场景下SGD配合余弦退火在中小数据集上通常更稳不容易在训练初期把预训练特征破坏掉。label_smoothing是为了避免模型对18类的预测过于自信尤其是相邻等级特征本身接近过度自信会让混淆更严重。训练代码很简单但有几个细节要记住import torch import torch.nn as nn model torch.hub.load(pytorch/vision:v0.14.0, resnet50, weightsResNet50_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, 18)在单张RTX 3090上训练约45分钟测试集准确率95.8%。6.2 多模型对比ResNet、EfficientNet、YOLOv8-cls基线模型跑通后我又对比了几种常见图像分类模型统一使用同样的训练配置。这里不是要“吹”某个框架而是给你一个参考路径。模型参数量测试准确率推理耗时Jetson Nano FP16ResNet5025.6M95.8%8.2msEfficientNet-B312.3M96.4%9.1msMobileNetV3-Large5.5M94.2%2.3msYOLOv8s-cls11.1M96.7%6.0ms最终我选了YOLOv8s-cls作为线上模型。原因有三准确率最高推理速度在Jetson Nano上够用同一个框架后续如果要扩展到水果目标检测可以复用部署流程和推理代码。MobileNetV3虽然更快但准确率掉了2个多点对于分拣线来说误判成本远高于算力成本。6.3 混淆矩阵暴露的等级边界问题画混淆矩阵的时候能看到一个很明显的规律绝大多数错判发生在同类水果的相邻等级之间也就是A级被当成B级、B级被当成C级跨品种的混扰极少。这说明模型确实学到了“品种”和“等级”两类特征但等级边界的可分性还是不够。最典型的例子是草莓。A级要求“外形近似圆锥形红色覆盖90%以上”B级允许轻微畸形。但在图像上轻微畸形和正常形状之间的差异非常细微人工都容易看走眼模型自然也会犯错。另一个难分的是葡萄的B/C级因为C级的脱粒和软果特征往往集中在果梗处单张俯视图不一定能拍到。针对这个问题我做了两件事一是对C级样本增加重采样权重让模型在训练时多看C级把C级的F1从87.2%提升到了89.6%二是在训练时将C级内部图像随机裁剪放大让模型多关注局部瑕疵。整体准确率提升有限但关键等级的召回更稳了。7. 部署到实际场景后的泛化教训7.1 相机变化导致精度下跌微调救场模型在测试集上准确率96%移植到客户工厂的监控相机后准确率直接跌到82%。原因并不意外不同厂家、不同型号的相机在白平衡、锐度、色彩饱和度上差异很大。实验室的工业相机成像偏冷客户现场的监控相机偏暖加上现场有自然光从窗户照进来整个画面的色调完全变了。解决办法分两步。第一步收集现场约2000张不带标注的真实图像用现有模型推理把置信度超过0.9的样本当作伪标签加入训练集然后进行两轮微调。第二步将原本纯实验室数据中的背景、光照比例降低补充更多现场风格的图像。微调后准确率回升到93%。更早更省事的做法是采集阶段就主动引入多相机、多光源、多背景。我现在做数据集都会在采集计划里强制加入10%以上的异源图像比如手机拍摄、日光灯下拍摄、隔着透明薄膜拍摄等。这个比例不是拍脑袋定的是根据部署现场可能出现的环境变化度估算出来的。7.2 给分类器加一个“拒识”选项分类模型的softmax输出天然有一个缺陷无论输入什么图像它都会强制把概率分配给已定义的类别。如果递给模型一张手套的照片它可能会以非常高的置信度把它识别成“草莓A级”。在分拣产线上这种错误是不可接受的因为杂物混入水果包装会直接引发客诉。所以我给数据集增加了一个background类专门收录空背景、人手、手套、碎叶、包装袋等杂物图像共300张。同时在推理阶段加了一个置信度阈值判断取softmax最大概率p如果p小于0.75就输出unknown不进分拣流程。阈值怎么定在验证集上做阈值扫描画出误触发率和准确率的曲线选择“每1000个正常样品误触发次数小于1次”时的最大值。落地后杂质误判率从原来的每千次几十次降到了不足1次。7.3 ONNX/TensorRT部署与量化校准模型落地用的是Jetson Nano所以要先把PyTorch模型转成ONNX再转成TensorRT engine。ONNX导出时要注意必须固定输入尺寸否则动态尺寸会带来额外的延迟torch.onnx.export( model, dummy_input, model.onnx, opset_version11, input_names[input], output_names[output], dynamic_axesNone )TensorRT量化时我的经验是校准集不能用训练集而要用单独的1000张真实部署环境采集的图片。训练集和模型分布过于接近校准出来的量化阈值偏向训练分布实际部署时精度损失会更大。INT8量化后准确率相比FP32下降了约1.2个百分点但推理速度从FP16的6毫秒提升到了约4毫秒。对于单帧处理的产线4毫秒完全够用甚至还能冗余一些算力给后续的检测模型。这次项目下来我的核心体会是数据集的质量直接决定分类模型的上限模型结构和调参只是把数据集的能量释放出来。六种水果分级听起来简单真正难的是把“看起来差不多”的果按照同一把尺子分到不同等级并且保证切分时不让同一个果出现在训练集和测试集里。如果你也在做类似的分级数据集建议先把分级规则写在一页纸以内再花时间把边界案例图拍好让标注员试标一轮确认Kappa值合格后再放开标注。最后分享一个小技巧发布数据集的时候附一张在测试集上的混淆矩阵和几张典型的边界错判示例不仅能省去大量答疑时间也能让使用者更清楚你的类别定义边界在哪里。