1. 项目概述与为什么选核桃
这几年计算机视觉相关的毕业设计选题,数量最多的两类:一类是人脸、车牌这类通用任务,另一类就是农产品外观质检。核桃好坏识别属于后者,但我接触下来发现,它比想象中更适合做深度学习入门项目,卡在一个“不难但足够典型”的位置上。
先说这个项目要解决的问题。核桃在采收、晾晒、仓储过程中,很容易出现霉变、空壳、虫眼、开裂这类问题。传统做法是人工分拣,靠老师傅眼睛看、手掂量,效率低不说,标准还不统一。工厂里每小时要过好几吨核桃,人工盯久了疲劳,漏检率会明显上升。用卷积网络自动识别核桃外观好坏,本质上就是做一个二分类图像分类器:输入一张核桃照片,输出“好”或者“坏”。
从技术难度看,核桃识别比人脸识别、物体检测简单得多,它没有复杂背景、没有多目标重叠、没有姿态剧烈变化。在固定光源、固定角度下拍摄,一张图里放一到三颗核桃,分类精度做到95%以上并不难。这也是我推荐把它作为毕业设计题目的原因:数据集自建可控、模型训练周期短、可视化效果好,答辩时能讲清楚每一个环节。
这个项目适合三类人:第一类是计算机、人工智能专业的本科生,需要完成毕业设计或课程设计;第二类是打算转行做算法工程师、想用真实小项目练手的人;第三类是农业信息化、食品工程方向的学生,想做农产品质检相关的交叉课题。对这三类人来说,这个题目的价值在于:它麻雀虽小,五脏俱全,从数据采集、模型训练到评估部署,把计算机视觉的完整流程走了一遍。
下面我会把这个题目从零拆开,包括数据集怎么做、模型怎么选、参数怎么调、坑怎么踩,尽量写到可以直接照着复现的程度。代码基于 Python 和 TensorFlow/Keras,但思路同样适用于 PyTorch。
2. 总体方案设计与技术选型思路
2.1 场景约束决定了技术路线
核桃好坏识别本质上是一个受控场景下的图像二分类问题。在动手写代码之前,先要明确场景约束,因为不同约束条件会导向完全不同的技术方案。
如果是工厂流水线场景,图片背景相对固定,光源稳定,核桃的摆放角度可能有限制。这种情况不需要用目标检测去寻找核桃位置,直接用分类网络就够了。如果场景是手机随手拍照,背景复杂、光线变化大、核桃可能被遮挡,那就要先做检测再分类,工程复杂度一下子高了很多。作为毕业设计,我建议优先做受控场景,把核心精力放在分类模型本身,而不是陷入检测和跟踪的泥潭。
基于这个判断,整个系统分为四层:数据层(采集与标注)、预处理层(图像增强与数据集划分)、模型层(卷积网络训练)、评估层(准确率/召回率/可视化)。每一层都有独立的评判标准,最终串起来就是一个可演示的完整系统。
2.2 为什么选择卷积神经网络而不是传统方法
很多同学第一次看到这个题目会问:用 OpenCV 提取颜色、纹理特征,再扔给 SVM 分类行不行?答案是行,但效果天花板很低。
核桃的好坏差异,有些是全局性的,比如整体发霉导致颜色发暗;有些是局部性的,比如某个角落有一个虫眼,或者壳上有一道裂纹。传统方法靠人工设计特征提取器,很难同时捕捉这两种尺度。如果只用颜色直方图,局部虫眼完全看不出来;如果只做边缘检测,霉变区域的纹理变化又不够显著。
卷积神经网络通过多层卷积核自动学习特征,浅层学边缘纹理,深层学语义部件,天然适合这种“既看整体又看局部”的任务。而且核桃表面纹理复杂,深浅不一,靠人工定义“什么样的纹理算霉变”非常困难,让网络自己学是最省力的方式。我自己做过对比,同样的数据集,HOG 特征加 SVM 的准确率大概在 85% 左右,而一个简单的 CNN 轻松超过 95%,差距非常明显。
2.3 模型选型:自建轻量网络还是迁移学习
方案确定用 CNN 之后,下一个问题是:自己搭一个网络,还是用预训练模型做迁移学习?
我给这个项目的建议是:两条路都走一遍。第一条路,自建一个 3 到 5 层的轻量卷积网络,参数量控制在 50 万以内。这条路的好处是训练速度快,对显存要求低,且每一层的特征图都可以可视化,答辩时能直观展示网络学到了什么。第二条路,用迁移学习,加载在 ImageNet 上预训练过的 ResNet50 或 MobileNetV3,冻结前面大部分层,只训练最后的全连接层。这条路的好处是准确率更高,小数据集也不容易过拟合。
实际操作中,我见过不少人直接上 ResNet50,但数据集只有两三千张,训练集误差和验证集误差差距很大,过拟合严重。后来换成轻量网络加数据增强,效果反而更好。所以我的建议是:先跑一个简单的自建网络作为 baseline,再尝试迁移学习做对比,这样论文里可以多一个对比实验,评分也更好看。
2.4 评估指标不能只看准确率
核桃好坏识别里,两类样本的数量往往不均衡。好核桃占多数,坏核桃占少数,比如好核桃 3500 个,坏核桃 1000 个。如果模型把所有图都判成好核桃,准确率仍然有 77.8%,看起来好像还行,但坏核桃一个都没找出来,生产线上的霉变核桃全漏掉了。
这种场景下,真正的核心指标不是准确率,而是坏核桃的召回率,也就是“所有坏核桃里被模型抓出来多少”。漏掉一个好核桃,损失一点点;漏掉一个坏核桃,整批货可能被退货。所以评估时必须同时看准确率、精确率、召回率和 F1-Score,还要画出混淆矩阵,直观展示每一类的错分情况。
我在项目里会额外关注“坏核桃召回率”这一个指标,训练时如果发现这个指标低于 90%,就说明模型太保守,需要调整分类阈值或者增加坏样本的权重。这是很多教程不会强调的,但实际项目里恰恰是最关键的一步。
3. 数据集构建:采集、标注与预处理
3.1 图像采集的设备与拍摄规范
数据集是整个项目的基石,模型再强,数据不行都白搭。核桃识别的最佳数据来源有三个:自己买核桃拍摄、网上爬取公开图片、使用某农产品检测机构提供的脱敏数据。对大多数学生来说,第一种最靠谱,完全可控且没有版权问题。
拍摄时建议用手机或普通相机均可,但必须固定拍摄环境。我用的方案是:一个 500 瓦的 LED 摄影棚灯、一张黑色哑光背景纸、一个手机三脚架,核桃平放在背景纸上,镜头垂直向下拍摄。固定光源的目的是消除阴影干扰,避免核桃本身纹理和阴影混杂在一起,让网络更专注于核桃表面的缺陷特征。
分辨率方面,手机拍摄的原始照片一般是 3000x3000 以上,这个尺寸直接扔给网络训练会非常慢,而且没有必要。统一缩放为 224x224 或 256x256 都可以,这也是大多数预训练模型的输入尺寸。如果自建网络,128x128 也能跑,但会丢失细小的裂纹纹理,不建议低于这个值。我最终选了 224x224,兼顾细节和训练速度。
3.2 好坏核桃的定义与标注标准
标注之前必须先定义什么算好、什么算坏,不然不同人标出来的结果不一致,模型学到的东西就会很混乱。
在我的项目里,“好核桃”定义为:外壳完整、无明显裂纹、无虫眼、无霉斑、颜色均匀。“坏核桃”包括四类:霉变核桃(表面有白色或黑色霉菌斑)、开裂核桃(外壳有明显裂缝)、虫眼核桃(表面有圆形小孔洞)、空壳核桃(重量轻但外观可能正常,这类主要靠声音和手感判断,视觉上不一定能看出来)。这个项目只做外观识别,所以空壳核桃不作为主要目标,如果混进数据集反而会影响模型训练。
坏核桃的标注里有一个细节容易踩坑:霉变的初期阶段在 RGB 图像上并不明显,只有淡淡的灰白色区域,普通手机拍出来对比度很低。解决方法是拍摄时适当提高光源强度,或者在预处理时做对比度增强,把霉变区域和正常纹理的差异拉开。我身边一位同学最开始没注意这一点,模型对霉变的识别准确率一直在 60% 左右徘徊,后来重新调整拍摄和预处理才提上来。
3.3 数据集规模与划分策略
深度学习虽然对数据量有要求,但二分类任务没有想象中那么“吃数据”。我建议最低标准是:总样本量 2000 张以上,好核桃与坏核桃的比例控制在 1.5:1 到 3:1 之间,不要太极端。如果你想追求更好的效果,5000 张以上会非常宽裕。
数据划分比例我采用训练集 70%、验证集 15%、测试集 15%。这里有一个常见误区:很多人只切训练集和测试集,训练时反复用测试集调参,最后测试集准确率高得离谱,但换一批真实数据就崩了。正确做法是“三折”:训练集用来更新权重,验证集用来调整超参数、监控过拟合,测试集只在最终模型确定后用于一次性的最终评估。这样评估结果才可信。
拍摄时还有一个技巧:不要只在同一个背景下拍完所有照片。可以稍微变化背景颜色、旋转核桃角度、调整光照强度,人为增加数据的多样性。深度学习最怕的是“特定环境下的记忆”,而不是“核桃特征的泛化”。如果一个模型只在黑色背景上效果好,换个背景就垮掉,说明数据多样性不足。
3.4 图像增强策略:适度即可,不要过度
数据增强是深度学习练好模型的必备手段,但对于核桃这个场景,增强策略需要谨慎选择。
我实际使用的增强组合是:随机水平翻转、随机垂直翻转、随机旋转 90 度、随机亮度调整(范围 0.8 到 1.2)、随机对比度调整(范围 0.9 到 1.1)。这些增强方式不会改变核桃的语义特征,只是增加视角多样性,可以有效降低过拟合。
但有两个增强操作需要避免。第一是随机裁剪,核桃可能被裁掉一半,模型被迫学会“看到半个核桃”的特征,这对整颗核桃识别没有帮助。第二是过度色彩扭曲,比如随机改变色相饱和度,会导致核桃本身的颜色特征失真,霉变区域的颜色差异被抹平。我踩过这个坑:加了强色彩增强之后,训练集准确率很高,测试集反而掉了好几个点,排查半天才发现是增强策略的问题。
3.5 数据集构建的完整流程总结
为了方便复现,我把整个数据集构建流程整理为五步:
- 拍摄原始照片,每个核桃至少从两个角度各拍一张,总计收集 3000 张以上。
- 人工筛选剔除模糊、过曝、被遮挡的无效图片。
- 按照“好核桃”和“坏核桃”两类分别放入不同文件夹,文件命名规则统一为
good_001.jpg和bad_001.jpg的格式。 - 使用脚本统一缩放为 224x224,并转换为 JPEG 格式。
- 按照 70%、15%、15% 的比例随机划分三份,保存为独立的文件夹。
第四步有一个隐藏的好处:统一尺寸后,训练时数据加载的速度会明显加快,不用每次都在 CPU 上做实时缩放。对于没有 GPU 的同学来说,这一步能节省大量时间。
4. 卷积网络模型搭建与关键参数解析
4.1 轻量自建网络结构详解
我推荐的自建网络结构如下,参数量约 46 万,在普通笔记本 CPU 上训练一轮大约需要 3 到 5 分钟,在入门级 GPU 上不到 1 分钟。
前面四个卷积块的结构类似:每一块包含一个卷积层、一个批归一化层、一个 ReLU 激活和最大池化。卷积核数量从 32 开始倍增:32、64、128、256,卷积核大小统一为 3x3,padding 设置为 same,保证特征图尺寸不缩小,池化层再减半。这个设计的好处是特征图尺寸逐步减半、通道数逐步增加,符合卷积网络的基本设计原则:在空间分辨率降低的同时,用更多通道保存信息。
最后接一个全局平均池化,把最后一个卷积块得到的 8x8x256 特征图压缩成 256 维向量,再通过一个 Dropout 层丢弃一部分神经元,最后接一个输出维度为 1 的全连接层,激活函数用 sigmoid,输出值代表“是坏核桃”的概率。全局平均池化取代 Flatten 加全连接,是减少参数量的关键设计。
import tensorflow as tf from tensorflow.keras import layers, models def build_simple_cnn(input_shape=(224, 224, 3)): model = models.Sequential([ layers.Input(shape=input_shape), layers.Conv2D(32, 3, padding='same', activation='relu'), layers.BatchNormalization(), layers.MaxPooling2D(2), layers.Conv2D(64, 3, padding='same', activation='relu'), layers.BatchNormalization(), layers.MaxPooling2D(2), layers.Conv2D(128, 3, padding='same', activation='relu'), layers.BatchNormalization(), layers.MaxPooling2D(2), layers.Conv2D(256, 3, padding='same', activation='relu'), layers.BatchNormalization(), layers.MaxPooling2D(2), layers.GlobalAveragePooling2D(), layers.Dropout(0.5), layers.Dense(1, activation='sigmoid') ]) return modelbatch size 我建议取 32,这个值在绝大多数设备上都能跑,梯度更新也比较稳定。如果显存不够,降到 16;如果数据集很大且 GPU 很强,可以试 64,但要注意 batch size 过大会影响泛化性能。
4.2 迁移学习路线:ResNet50 的微调技巧
自建网络作为 baseline 跑通之后,迁移学习路线建议用 ResNet50 作为第二套对比方案。具体做法是加载 ImageNet 预训练权重,去掉最顶层的全连接分类层,保留卷积基,新接一个全局平均池化和一个输出维度为 1 的 sigmoid 层。
迁移学习里最重要的超参数是冻结策略。我的建议分两个阶段训练:第一阶段,冻结整个卷积基,只训练新加的分类层,用较大学习率如 0.001,训练 10 个 epoch;第二阶段,解冻最后 20 到 30 层卷积层,用较小学习率如 0.0001,继续训练 10 个 epoch。这种“先训头、再微调”的方式,可以避免在随机初始化的分类层还没收敛时就去调整预训练特征,导致破坏原有的良好特征表示。
from tensorflow.keras.applications import ResNet50 base_model = ResNet50(weights='imagenet', include_top=False, input_shape=(224, 224, 3)) base_model.trainable = False model = models.Sequential([ base_model, layers.GlobalAveragePooling2D(), layers.Dropout(0.5), layers.Dense(1, activation='sigmoid') ])这里有一个需要特别提醒的点:预训练模型要求的输入预处理和自建网络不一样。ResNet50 需要把像素值归一化到特定范围,并且要按通道进行标准化,如果直接复用自建网络的预处理代码,精度会明显下降。Keras 自带的preprocess_input函数专门干这个,直接调用即可,不要自己写归一化。
4.3 损失函数、优化器与学习率策略
二分类任务最常用的损失函数是二元交叉熵(binary crossentropy),Keras 里对应BinaryCrossentropy。这个损失函数在模型输出严重错误时梯度大,接近正确时梯度小,训练过程比较平稳,是二分类的标准选择。
优化器我推荐 Adam,默认学习率 0.001。Adam 自适应调节每个参数的学习率,在大多数任务上都能快速收敛,对新手非常友好。我也试过 SGD 加动量,收敛速度慢,但最终精度可能略高一点,不过调参成本高,不太适合作为毕业设计的时间预算。
学习率是最需要手调的超参数。我的经验是:在 Adam 默认 0.001 的基础上,每训练 10 个 epoch 后验证集准确率如果不再提升,就让学习率降为原来的五分之一。也可以用 Keras 的ReduceLROnPlateau回调自动完成这件事。我见过不少同学从 0.1 开始训练,损失直接爆炸,NAN 不断,就是因为学习率太大。0.001 这个起点要保守得多。
4.4 类不平衡的处理方法
如果数据集中坏核桃占比明显少,有两种常见修正手段。第一种是给少数类更高的损失权重,在 Keras 的class_weight参数里设置,比如好核桃权重设为 1.0,坏核桃权重设为 2.5,这样模型在训练时会更重视坏核桃的误判。第二种是过采样,在数据加载时对坏核桃样本多循环几次,让每一轮训练看到同等数量的两类样本。
我实际测试下来,class_weight的效果更稳定,因为过采样本质上是在重复使用少数类样本,容易让模型对特定几个坏核桃“背答案”,泛化性稍差。权重设置也不是越高越好,权重过大会导致模型把大量好核桃误判为坏核桃。在项目里我调了几组对比,坏核桃权重设为总样本中好与坏的数量比值减半,最终召回率和精确率比较均衡。
5. 训练过程完整实录与调优心得
5.1 训练环境配置
代码能跑起来的前提是环境装好。TensorFlow 2.x 搭配 Python 3.8 到 3.10 都可以,我建议用 Anaconda 创建独立虚拟环境,避免和系统其他项目的依赖冲突。
conda create -n walnut python=3.9 conda activate walnut pip install tensorflow==2.13.0 pip install numpy matplotlib scikit-learn opencv-python硬件条件不用太担心。纯 CPU 训练轻量自建网络,3000 张图片一轮大约需要 3 分钟,30 个 epoch 大概一个半小时,完全可以接受。ResNet50 迁移学习在 CPU 上会慢一些,如果有 NVIDIA 显卡就很快,没有的话可以把图片尺寸降到 160x160,减少计算量。
5.2 训练过程监控技巧
训练过程中不能只盯着 loss 曲线看,我的做法是同时监控训练集 loss、验证集 loss、训练集准确率和验证集准确率四条曲线。验证集 loss 是最重要的过拟合信号,当训练集 loss 持续下降但验证集 loss 开始回升时,就是典型的过拟合,应该早停或者降低模型容量。
我在项目里加入了三个 Keras 回调函数:ModelCheckpoint保存验证集准确率最高的权重;EarlyStopping在验证集 loss 连续 5 个 epoch 不下降时自动停止训练;ReduceLROnPlateau自动降低学习率。三者配合,基本不需要人工干预。
callbacks = [ tf.keras.callbacks.ModelCheckpoint('best_model.h5', save_best_only=True, monitor='val_accuracy', mode='max'), tf.keras.callbacks.EarlyStopping(patience=5, restore_best_weights=True, monitor='val_loss', mode='min'), tf.keras.callbacks.ReduceLROnPlateau(factor=0.2, patience=3, monitor='val_loss', mode='min') ] model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy']) model.fit(train_dataset, validation_data=val_dataset, epochs=30, callbacks=callbacks)这里有一个很实用的技巧:restore_best_weights=True会在早停时自动恢复到验证集表现最好的那一步权重,而不是停在最后一个 epoch 的权重上。很多同学不知道这一点,训练被早停后以为模型已经是最优的,实际可能相差好几个点。
5.3 训练曲线解读与典型调参案例
我记录了一组典型训练过程,自建网络在初始默认参数下训练了 20 个 epoch,结果如下:
训练集准确率从第 1 个 epoch 的 68% 稳步上升到第 15 个 epoch 的 99% 以上,验证集准确率却只从 70% 上升到 92%,之后基本不再提升。同时验证集 loss 在第 12 个 epoch 触底,随后缓慢回升。这是明显的过拟合信号,说明模型容量对于这个数据集来说偏大。
针对这个情况,我的处理是三步:第一,把最后一个卷积层的 Dropout 从 0.3 提高到 0.5;第二,数据增强力度适当增加,亮度调整范围从 0.9-1.1 扩大到 0.8-1.2;第三,开启 EarlyStopping。调整后验证集准确率提升到 95.3%,验证集 loss 也不再反弹,过拟合问题基本解决。
另一个调参案例发生在迁移学习阶段。我一开始全模型以 0.0001 的学习率从头微调,训练 10 个 epoch 后验证集准确率只有 89%。后来改为先冻结训练分类层、再解冻微调的两阶段策略,同样的数据量下验证集准确率提升到 97.1%。这说明迁移学习的微调顺序和节奏,比单纯调学习率重要得多。
5.4 测试集评估与结果可视化
训练完模型,最终评估要在测试集上进行。测试集是模型从未见过的 450 张图片,这时候得出的指标才有说服力。我在项目里用model.evaluate得到整体损失和准确率,再用predict得到每一张图片的预测概率,最后算精确率、召回率和 F1。
除了数字指标,还要做可视化。用 matplotlib 把预测错误的图片单独列出来,标注真实标签和预测概率,会非常有用。比如我发现很多误判案例都是光线偏暗导致的阴影被误判为霉斑,于是重新调整了训练数据中的亮度增强范围,这比盲目调网络结构有效得多。
6. 常见问题与排查技巧实录
6.1 问题速查表
项目过程中会遇到很多实际问题,我把最常见的整理了如下:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 训练 loss 为 NAN | 学习率过大 | 降到 0.0001 或更低重试 |
| 验证集准确率很低但训练集很高 | 过拟合 | 增加 Dropout、数据增强、早停 |
| 好核桃全对但坏核桃全漏 | 类不平衡 | 设置 class_weight 或调整阈值 |
| 迁移学习精度比自建网络还差 | 预处理不一致或微调策略错误 | 改用 preprocess_input,冻结后分阶段训练 |
| 预测时单张图片结果不稳定 | 模型过度依赖背景特征 | 增加背景多样性,统一拍摄条件 |
| 训练速度极慢 | 图片没有预缩放 | 统一 224x224 后再进网络 |
这里面最容易被忽视的是第一项。学习率过大导致 loss 变成 NAN,很多新手会以为是自己代码写错了,实际上只需调小学习率或者引入梯度裁剪就能解决。
6.2 单个坏核桃识别失败案例分析
我在测试集上发现一个典型的失败案例:一颗轻度霉变的核桃,霉斑面积不到整个外壳的 5%,网络给出的坏核桃概率只有 0.32,被误判为好核桃。这类样本的特点是:霉斑颜色和正常核桃壳的浅棕色非常接近,肉眼也需要凑近看才能分辨,在 224x224 的图片上可能只占十几个像素。
针对这类样本,我尝试了两个方向的改进。第一个方向是图像预处理:把图片缩放前先做一次对比度增强,让霉斑和正常区域的差值变大。第二个方向是模型结构:在输入后加一层RandomBrightness作为数据增强,模拟不同光源条件。两个方向结合后,该案例的预测概率从 0.32 上升到 0.61,虽然还是不高,但整体坏核桃召回率提升了约 2 个百分点。
这说明一个问题:轻度缺陷识别是整个任务的难点,不能指望单靠模型结构解决,数据处理层面的配合同样重要。
6.3 部署环节的简化方案
毕业设计做到模型训练和测试集评估,基本已经满足要求。但如果想做得更完整,可以做一个简单的推理脚本:输入一张图片路径,输出预测类别和概率。这个脚本可以直接给老师现场演示,效果比只看训练曲线好很多。
def predict_single_image(image_path, model_path='best_model.h5'): model = tf.keras.models.load_model(model_path) img = tf.keras.utils.load_img(image_path, target_size=(224, 224)) img = tf.keras.utils.img_to_array(img) / 255.0 img = tf.expand_dims(img, axis=0) prob = model.predict(img, verbose=0)[0][0] label = '坏核桃' if prob >= 0.5 else '好核桃' return label, prob这个脚本里有几个可以扩展的方向:把阈值 0.5 改成可配置参数,方便调整判别严格程度;加入 Grad-CAM 可视化,把模型的关注热区显示出来,对答辩演示很有说服力;用 ONNX 导出模型,后续可以在嵌入式设备上部署。这些可选功能根据自己的时间精力加,不必全部做完。
7. 项目收尾的一些个人体会
跑了几个版本的模型,我最深的感受是,核桃好坏识别这个题目看起来简单,但要把准确率从 90% 推到 96% 以上,每一个环节都需要打磨。数据采集不规范,后面怎么调模型都补不回来;标注标准不统一,模型学到的特征就是混乱的;评估指标只看准确率,坏核桃召回率低的问题根本发现不了。
如果让我给后来者一个最直接的建议,那就是:不要一上来就追求复杂的网络结构。先把数据做规范,用最简单的 CNN 跑通一个模型,看训练曲线、看错误案例,再决定下一步怎么优化。很多问题在简单模型上反而更容易暴露,复杂模型只会让问题更难排查。
最后再分享一个小技巧:整个项目过程中,每调整一次数据或模型,就把实验结果记录在一个表格里,包括模型结构、输入尺寸、增强策略、超参数、准确率、召回率这几项。答辩时老师最常问的问题就是“你做了哪些尝试、效果如何”,有一份完整的实验记录,回答起来逻辑非常清晰,也是论文实验部分最扎实的素材。