☰
基于卷积神经网络的鲜茶叶分选:从数据集构建到产线部署的工程实践
2026/9/30 13:48:54 网站建设 项目流程

简介:这份PDF文献面向从事茶叶加工、智能装备研发及计算机视觉应用的研究人员与工程技术人员,针对机采鲜茶叶中风选、筛选难以精确细分等级的问题,提出结合计算机视觉与深度学习的智能分选方案。资源包内仅含1个PDF文件,大小约2.31MB,即《基于卷积神经网络的鲜茶叶智能分选系统研究》全文,便于直接查阅与引用。文中搭建了7层结构的卷积神经网络识别模型,通过共享权值与逐渐下降的学习速率提升训练性能,并借助图像分割与尺度变换完成输入归一化,避免繁琐的人工特征提取。实验表明,系统可对单芽、一芽一叶、一芽二叶、一芽三叶、单片叶及叶梗进行有效类别分选,识别正确率不低于90%。目前已有172人学习,适合作为智能系统开发、人工智能与图像识别方向的参考文献与专业指导材料。

1. 鲜茶叶分选这道题,卷积神经网络到底能解到什么程度

鲜茶叶分选是个典型的「看着简单、做起来全是坑」的工业视觉问题。一斤鲜叶里混着单芽、一芽一叶、一芽二叶、老叶、茶梗、虫伤叶,传统做法靠振动筛加风选,按尺寸和重量粗分,遇到颜色相近但品级不同的芽叶就抓瞎。人工拣剔效率低、标准漂移大,旺季招不到人是常态。卷积神经网络(CNN)进这个场景,核心价值不是「AI 概念」,而是把「芽叶形态 + 色泽 + 纹理」这三类人眼判据变成可复现的特征提取,让分选标准从老师傅的手感变成可标定、可回滚的模型参数。这篇笔记面向两类人:一类是茶机厂或茶企里想上视觉分选但不知道从哪下手的工程师,另一类是做过通用图像分类、想把这套东西迁到农产品分选上的算法同学。我会按「数据怎么采、模型怎么选、产线怎么落、坑在哪」的顺序讲,参数和命令都给到能直接抄的程度。

2. 从鲜叶图像到可训练数据集:采集、标注与增强的完整链路

2.1 为什么鲜茶叶的数据集比通用数据集难做

鲜茶叶分选的数据集难点不在量,在「一致性」。通用图像分类数据集(比如 ImageNet)的拍摄条件相对可控,而鲜叶从茶园到分选机之间,光照、含水率、堆放厚度、传送带速度全在变。同一批鲜叶,早上采的和下午采的,颜色能差出一个色阶;摊放两小时和摊放半小时,叶片挺度和反光完全不同。如果采集时不把这些变量记录下来,后面模型在产线上翻车你连原因都找不到。

我一般的做法是:采集阶段就按「变量矩阵」来组织,而不是随手拍。具体来说,至少控制四个维度——光照(顺光/逆光/补光)、鲜叶状态(刚采/摊放1h/摊放2h)、品级(单芽/一芽一叶/一芽二叶/老叶/茶梗/杂质)、背景(传送带空载/薄层/厚层)。每个组合拍 50~100 张,这样出来的数据集才有「抗干扰」的底子。常见做法是用工业相机加环形补光,固定在传送带正上方 30~40cm,视场覆盖 20cm×20cm 左右,分辨率至少 1280×960,保证单芽的芽尖在图像里占 40 像素以上。

标注环节,鲜茶叶分选有个特殊点:类别边界模糊。「一芽一叶」和「一芽二叶」在图像上可能只差一片刚冒头的小叶,标注员之间的一致性(Inter-Annotator Agreement)往往只有 0.7 左右。我的经验是,标注规范里必须写死「以芽为基准,数展开叶数,未展开的不计」,并且每类给 20 张「标准参考图」贴在标注工具里,标注员拿不准就对照。标注格式用 YOLO 的 txt 或 COCO 的 json 都行,但建议同时存一份「原始采集元数据」(光照、摊放时间、批次),后面做误差分析时能按维度切片。

2.2 用 Python 做鲜叶数据集的清洗与增强

数据拿到手,第一步不是直接喂网络,而是清洗。鲜叶图像里常见的废片包括:运动模糊(传送带速度没匹配好)、过曝(补光太强)、叶片重叠超过 70%(标注不可靠)、背景杂物(非茶叶的梗、虫体)。下面这段脚本做三件事:按清晰度过滤、按亮度过滤、按标注框面积过滤。

import cv2 import numpy as np import os import json def laplacian_variance(img_gray): """拉普拉斯方差,值越低越模糊,鲜叶建议阈值 > 80""" return cv2.Laplacian(img_gray, cv2.CV_64F).var() def mean_brightness(img_gray): """平均亮度,过曝或过暗都筛掉,鲜叶建议 60~200""" return np.mean(img_gray) def filter_dataset(img_dir, label_dir, out_txt, blur_th=80, bright_lo=60, bright_hi=200): kept, dropped = [], [] for fname in os.listdir(img_dir): if not fname.lower().endswith(('.jpg', '.png')): continue img_path = os.path.join(img_dir, fname) img = cv2.imread(img_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur = laplacian_variance(gray) bright = mean_brightness(gray) # 标注文件同名 .txt label_path = os.path.join(label_dir, os.path.splitext(fname)[0] + '.txt') if not os.path.exists(label_path): dropped.append((fname, 'no_label')) continue with open(label_path) as f: lines = [l.strip() for l in f if l.strip()] if len(lines) == 0: dropped.append((fname, 'empty_label')) continue if blur < blur_th: dropped.append((fname, f'blur_{blur:.1f}')) continue if bright < bright_lo or bright > bright_hi: dropped.append((fname, f'bright_{bright:.1f}')) continue kept.append(fname) with open(out_txt, 'w') as f: f.write('\n'.join(kept)) print(f'kept={len(kept)}, dropped={len(dropped)}') for d in dropped[:10]: print('drop:', d) return kept filter_dataset('./images', './labels', './clean_list.txt')

这段代码的逻辑很直白:拉普拉斯方差衡量图像高频分量,模糊图像的高频被抹掉,方差会明显偏低;鲜叶因为纹理细,正常清晰图的方差通常在 100 以上,低于 80 基本是糊了。亮度阈值 60~200 是经验值,过暗说明补光不足或叶片堆太厚,过曝说明补光直射导致高光溢出,这两类都会让颜色特征失真。参数怎么改:如果你的相机是黑白相机,亮度范围要重新标;如果传送带速度很快,模糊阈值可以降到 60,但要在增强阶段补运动模糊模拟。

清洗完做增强。鲜叶分选的增强不能乱用通用策略——水平翻转可以(芽叶左右对称),垂直翻转不行(芽尖朝上的方向是品级判据之一);颜色抖动要克制,因为色泽本身就是分类依据,抖动太猛会把「一芽一叶」的嫩绿抖成「老叶」的暗绿。我一般用 Albumentations 配一套「温和增强」:

import albumentations as A import cv2 train_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.5), A.HueSaturationValue(hue_shift_limit=5, sat_shift_limit=10, val_shift_limit=10, p=0.4), A.GaussNoise(var_limit=(5.0, 20.0), p=0.3), A.MotionBlur(blur_limit=3, p=0.2), # 模拟传送带轻微运动模糊 A.Resize(448, 448), ], bbox_params=A.BboxParams(format='yolo', label_fields=['class_labels'])) # 使用示例 img = cv2.imread('sample.jpg') img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) aug = train_transform(image=img, bboxes=[[0.5, 0.5, 0.2, 0.3]], class_labels=[1])

这里的关键参数:brightness_limit=0.1和hue_shift_limit=5是刻意压小的,目的只是让模型对采集时的轻微波动鲁棒,而不是改变类别语义。MotionBlur(blur_limit=3)模拟传送带速度波动带来的轻微模糊,让模型不至于在产线提速时崩掉。Resize(448, 448)是输入尺寸,后面选模型时会对应调整。如果你的鲜叶目标特别小(单芽在图中占比低),建议用 640 甚至 800,别为了省显存牺牲小目标。

提示:增强后的图像一定要抽样人工看 50 张,确认没有把「一芽二叶」增强成「一芽一叶」的歧义样本。我见过有人用 RandAugment 默认强度,结果颜色抖动把嫩芽抖成了老叶色,模型学出来的决策边界完全是错的。

3. 选 CNN 还是上 Transformer:鲜叶分选的模型选型与训练参数

3.1 鲜茶叶分选该选哪种 CNN 结构

标题里写的是「基于卷积神经网络」,但 CNN 是个大类,从 ResNet、EfficientNet 到 MobileNet、ConvNeXt 都算。鲜叶分选选型的核心约束有三个:目标尺寸小(单芽可能只占 40×40 像素)、类别间差异细(一芽一叶 vs 一芽二叶)、产线要实时(至少 15~30 FPS)。这三个约束决定了你不能直接拿 ImageNet 上刷分的最大模型往上堆。

我的选型逻辑是:先看目标尺寸,如果单芽在输入图里小于 64×64,优先选保留高分辨率特征图的骨干,比如 EfficientNet-B0 的 stride 配置比 ResNet-50 更友好,或者直接用 YOLOv8n/YOLOv8s 这类检测框架做「检测 + 分类」一体。如果类别差异主要在纹理和色泽,ConvNeXt-Tiny 在细粒度任务上通常比同量级 ResNet 好一截,因为它的 7×7 大核卷积更擅长抓纹理。如果产线算力只有 Jetson Nano 级别,MobileNetV3-Small 加一个自定义分类头是稳妥选择,精度掉 2~3 个点但帧率能翻倍。

下面给一个基于 timm 的 EfficientNet-B0 微调模板,这是我在鲜叶分选里用得最多的 baseline:

import torch import torch.nn as nn import timm from torch.utils.data import DataLoader from torchvision import transforms, datasets NUM_CLASSES = 6 # 单芽/一芽一叶/一芽二叶/老叶/茶梗/杂质 BATCH_SIZE = 32 EPOCHS = 40 LR = 3e-4 DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu' # 鲜叶专用增强:训练用温和增强,验证只做 Resize + Normalize train_tf = transforms.Compose([ transforms.Resize((448, 448)), transforms.RandomHorizontalFlip(0.5), transforms.ColorJitter(brightness=0.1, contrast=0.1, saturation=0.1, hue=0.02), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_tf = transforms.Compose([ transforms.Resize((448, 448)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_ds = datasets.ImageFolder('./data/train', transform=train_tf) val_ds = datasets.ImageFolder('./data/val', transform=val_tf) train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=4) # 加载预训练骨干,替换分类头 model = timm.create_model('efficientnet_b0', pretrained=True, num_classes=NUM_CLASSES) model = model.to(DEVICE) # 分层学习率:骨干小学习率,分类头大学习率 backbone_params = [p for n, p in model.named_parameters() if 'classifier' not in n] head_params = [p for n, p in model.named_parameters() if 'classifier' in n] optimizer = torch.optim.AdamW([ {'params': backbone_params, 'lr': LR * 0.1}, {'params': head_params, 'lr': LR}, ], weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) for epoch in range(EPOCHS): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(DEVICE), labels.to(DEVICE) optimizer.zero_grad() loss = criterion(model(imgs), labels) loss.backward() optimizer.step() scheduler.step() # 验证略,按需加 accuracy / confusion matrix print(f'epoch {epoch} done')

这段代码有几个鲜叶场景特有的设计。第一,ColorJitter的 hue 只给 0.02,比通用设置的 0.1 小一个量级,原因前面说过——色泽是分类依据,不能大改。第二,分层学习率把骨干压到 3e-5,分类头用 3e-4,因为鲜叶数据集通常只有几千到几万张,骨干全量微调容易过拟合,小学习率让它「轻微适配」就够了。第三,label_smoothing=0.1是必须的,鲜叶类别边界模糊,硬标签会让模型对「一芽一叶/一芽二叶」的边界过度自信,平滑后泛化更好。参数怎么调:如果显存不够,把 batch 降到 16,学习率同比降到 1.5e-4;如果类别极不平衡(茶梗样本远少于单芽),把CrossEntropyLoss换成带 weight 的版本,weight 按类别频率的倒数设。

3.2 训练过程中必须盯住的三个指标

鲜叶分选模型训练,光看 accuracy 会被骗。我一般同时盯三个指标:每类的 recall、混淆矩阵里相邻类别的误分率、以及「高置信度错误」的数量。为什么?因为产线上最怕的不是「不确定」,而是「自信地分错」——一个一芽二叶被以 0.95 置信度分成一芽一叶,下游拼配就全乱了。

具体做法:每个 epoch 在验证集上算混淆矩阵,重点看「一芽一叶→一芽二叶」和「一芽二叶→老叶」这两对相邻类。如果这两对的误分率超过 8%,说明模型没学到「叶片展开程度」这个关键特征,要么是分辨率不够,要么是增强把叶片边缘糊掉了。这时候的排查顺序是:先把输入分辨率提到 640 重训一轮,如果没改善,检查标注里这两类的边界是否一致,最后才考虑换更大的骨干。

「高置信度错误」的统计方式是:取验证集里预测置信度 > 0.9 但预测错误的样本,单独存成一个文件夹人工看。我做过统计,鲜叶分选里这类样本 70% 以上是「标注本身就模棱两可」的,剩下 30% 是「光照极端」或「叶片严重重叠」。前者要回去修标注,后者要在增强里补对应场景。这个习惯帮我省了大量盲目调参的时间。

注意:不要用测试集调参。鲜叶分选的测试集应该按「批次」划分,而不是随机划分。同一批鲜叶的图像高度相似,随机划分会让训练集和测试集「泄漏」,测出来的精度虚高 5~10 个点,上线就露馅。

4. 从实验室到产线:鲜叶分选系统的部署与推理优化

4.1 推理速度与精度的平衡怎么定

实验室里模型跑 95% 精度,到了产线可能只有 80%,原因通常不是模型本身,而是「推理链路」没对齐。鲜叶分选系统的推理链路是:相机采图 → 预处理(去噪、白平衡)→ 模型推理 → 后处理(NMS、置信度过滤)→ 执行机构(气阀/挡板)。这条链路里,预处理和后处理的时间经常被忽略,但它们可能占掉一半的延迟。

我的做法是先测「端到端延迟」,再拆解优化。用下面这段代码测单张推理时间,注意要包含预处理:

import time import torch import numpy as np import cv2 def benchmark(model, img_path, device='cuda', warmup=10, runs=100): model.eval() img = cv2.imread(img_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 预处理 img_resized = cv2.resize(img, (448, 448)) img_norm = (img_resized / 255.0 - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) tensor = torch.from_numpy(img_norm).permute(2, 0, 1).float().unsqueeze(0).to(device) # warmup with torch.no_grad(): for _ in range(warmup): _ = model(tensor) if device == 'cuda': torch.cuda.synchronize() # 计时 t0 = time.time() with torch.no_grad(): for _ in range(runs): _ = model(tensor) if device == 'cuda': torch.cuda.synchronize() t1 = time.time() print(f'单张推理: {(t1-t0)/runs*1000:.2f} ms, FPS: {runs/(t1-t0):.1f}') benchmark(model, 'test.jpg')

测出来如果单张超过 30ms(对应 33 FPS),就要优化。优化顺序我一般是这样:先转 TensorRT 或 ONNX Runtime,EfficientNet-B0 在 Jetson Xavier NX 上从 PyTorch 的 25ms 能压到 8ms 左右;如果还不够,把输入从 448 降到 320,精度通常掉 1~2 个点,但速度能再快一倍;最后才考虑换 MobileNet 这类轻量骨干。注意,转 TensorRT 时要把预处理也放进引擎里(用--fp16和自定义插件),否则预处理在 CPU 上反而成瓶颈。

4.2 产线集成的四个接口约定

模型部署不是把 .pt 文件丢给产线就完事,接口约定不清楚,联调能拖两周。我一般会和产线工程师先定死四件事:

第一,图像传输格式。相机出的是 Bayer 还是 RGB?分辨率多少?传输用 GigE 还是 USB3?这些决定了预处理代码怎么写。常见坑是相机出 Bayer 图,算法端忘了做去马赛克,颜色全偏。

第二,触发信号。是相机硬触发还是软件轮询?硬触发延迟低但需要接线,软触发灵活但抖动大。鲜叶分选建议硬触发,因为传送带速度稳定时,固定间隔采图比轮询可靠。

第三,结果输出格式。分类结果是一个类别 ID 还是「类别 + 置信度 + 位置」?如果下游要按位置吹气,就必须输出检测框;如果只是整图分类,输出类别 ID 就够。我一般输出 JSON:{"class_id": 2, "confidence": 0.93, "bbox": [x, y, w, h]},方便下游解析。

第四,异常处理。模型置信度低于阈值怎么办?图像采集失败怎么办?我的约定是:置信度低于 0.6 的样本走「默认通道」(比如全部归入待人工复检),采集失败重试 3 次后报警停机。这些逻辑要写进部署代码,不能靠产线工人临场判断。

5. 鲜叶分选落地时最容易翻车的五个坑

5.1 坑一:训练集和产线光照不一致,模型「见光死」

现象:实验室验证集 94%,上线第一天精度掉到 75%,且错误集中在下午时段。

原因:训练集是上午顺光拍的,产线补光是顶部环形光,下午环境光变化后,叶片反光模式完全不同。模型学到的是「上午光照下的颜色分布」,不是「鲜叶本身的颜色」。

解决:采集阶段就覆盖多时段、多光照,训练时加RandomBrightnessContrast和GaussNoise,部署前在产线现场拍 200 张做「现场验证集」,如果掉点超过 5%,用现场数据做 5~10 个 epoch 的微调(小学习率 1e-5)。

5.2 坑二:类别不平衡导致「茶梗」被吞

现象:茶梗识别 recall 只有 0.5,大量茶梗被分进「老叶」。

原因:茶梗样本天然少,可能只占总量的 3%,交叉熵损失被多数类主导,模型倾向于把茶梗预测成老叶。

解决:损失函数加类别权重(weight = 1 / class_freq),或者用 Focal Loss。同时做「过采样」——把茶梗样本复制到和其他类同量级,但要注意复制时用不同增强,否则过拟合。我一般两者结合:权重调到 3~5 倍,过采样到 10% 占比。

5.3 坑三:输入分辨率压缩太狠,小目标特征丢失

现象:单芽和「一芽一叶」的误分率高达 15%,混淆矩阵显示两类互相串。

原因:为了提速把输入从 448 压到 224,单芽在特征图上只剩 20×20 像素,芽尖和第一片叶的边界特征被池化掉了。

解决:要么保持 448 输入,要么用「裁剪 + 分类」两阶段——先检测芽叶位置,再对每个目标裁剪出来单独分类。后者速度可能更快,因为分类网络输入可以小,但检测网络要额外训练。我的经验是,如果单芽在 448 输入下小于 48×48,直接上两阶段。

5.4 坑四:数据增强把类别语义改掉了

现象:训练 loss 正常下降,但验证集精度卡在 80% 上不去,混淆矩阵里「一芽二叶」大量被分成「老叶」。

原因:用了ColorJitter(hue=0.1)或RandAugment默认强度,把嫩叶的黄绿色抖成了老叶的暗绿色,模型学到的决策边界和真实语义相反。

解决:鲜叶分选的增强必须「保守」,hue 抖动不超过 0.03,saturation 不超过 0.15,禁用Equalize、Posterize这类会改变颜色分布的算子。增强后抽样人工检查,确认没有语义歧义。

5.5 坑五:模型更新没有回滚机制,一次翻车全批报废

现象:某次用新数据微调后上线,精度不升反降,但已经分选了 200 斤鲜叶,只能全部返工。

原因:没有版本管理和 A/B 验证,新模型直接替换旧模型,出问题才发现。

解决:模型文件按「日期 + 训练集版本 + 精度」命名,部署时保留上一个版本,新模型先跑「影子模式」(只记录预测不执行分选)24 小时,对比新旧模型的预测差异,差异超过 5% 的样本人工复核。确认无误再切换。这个习惯看起来麻烦,但能避免「一次翻车全批报废」的血泪教训。

6. 把鲜叶分选模型压到 8ms 以内:TensorRT 量化与现场验证的一个具体技巧

模型训练完只是半成品,真正决定产线能不能用的是推理延迟。我拿 EfficientNet-B0(448 输入,6 分类)在 Jetson Xavier NX 上做过一轮完整优化,路径是 PyTorch → ONNX → TensorRT FP16 → INT8 校准,延迟从 25ms 压到 7.8ms,精度只掉 0.6 个点。这里把关键步骤和参数写清楚。

第一步,导出 ONNX。注意opset用 12 以上,动态轴只留 batch:

import torch model.eval() dummy = torch.randn(1, 3, 448, 448).cuda() torch.onnx.export( model, dummy, 'fresh_tea.onnx', opset_version=13, input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch'}, 'output': {0: 'batch'}} )

第二步,用trtexec转 TensorRT FP16,先看延迟:

trtexec --onnx=fresh_tea.onnx --fp16 --saveEngine=fresh_tea_fp16.engine \ --shapes=input:1x3x448x448 --workspace=2048

第三步,INT8 量化。鲜叶分选做 INT8 有个坑:校准集必须用产线现场图,不能用训练集。因为训练集和现场的光照分布有差异,用训练集校准出来的 scale 在现场会偏。校准集准备 500 张现场图,覆盖不同时段和品级:

trtexec --onnx=fresh_tea.onnx --int8 --fp16 --saveEngine=fresh_tea_int8.engine \ --shapes=input:1x3x448x448 --workspace=2048 \ --calib=fresh_tea_calib.cache

第四步,现场验证。这一步最容易被跳过,但最关键。把 FP16 和 INT8 两个引擎同时部署,跑同一批现场图,对比预测差异。如果 INT8 和 FP16 的预测不一致率超过 2%,说明量化损失太大,要么增加校准集,要么回退 FP16。我一般会做一个「差异样本池」,把两个引擎预测不同的样本存下来人工看,如果差异集中在「一芽一叶/一芽二叶」边界,说明量化把细粒度特征压没了,这时候可以考虑只量化骨干、保留分类头 FP16(混合精度)。

最后给一个我自己的习惯:每次模型上线前,我会在产线旁蹲半天,拿一个笔记本记录「模型分错的样本长什么样」。这个习惯看起来笨,但比看任何指标都管用——因为产线上的错误分布和验证集永远不一样,只有亲眼看过,才知道下一个版本该补什么数据。希望帮到你。

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

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

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

立即咨询