
垃圾分类识别这件事听起来不算新鲜但真正动手把一套系统从数据准备做到端侧可部署过程中需要踩的坑远比想象中多。我这次做的是一套基于TensorFlow的垃圾分类系统核心思路是双线并行一边从零训练CNN模型作为基线用来理解卷积网络的工作方式和参数调优另一边使用MobileNet做迁移学习用更少的数据拿到更高精度同时把模型量化为TFLite格式保证它可以跑在手机、嵌入式开发板这类资源受限的设备上。整个项目做完用摄像头扫一下就能实时输出垃圾类别是一个逻辑完整、可以继续扩展的工程实践。这篇文章适合刚入门深度学习、想做一个真实项目练手的同学也适合正在做课程设计、毕业设计或产品原型的开发者。我会把每一个关键选择的“为什么”讲清楚不只是贴代码还会分享我在实验里遇到的高频坑和解决办法。你能从里面直接拿走一套可复现的方案。1. 先把垃圾分类系统拆清楚需求、方案与技术栈1.1 需求拆解你要做的不是一个“模型”而是一套系统很多人在拿到“垃圾分类”这个题目后第一反应就是找数据集、跑分类网络但真正落地时要回答的问题远不止这些。第一个问题是“输入是什么”是单张图片还是摄像头视频流手机拍照识别和实时视频识别对模型大小和推理速度的要求完全不同。第二个问题是“输出是什么”是输出一个类别标签还是需要在界面上同时显示置信度和对应垃圾的投放规则第三个问题是“运行环境”是在电脑的GPU上演示还是要部署到无GPU的移动端我给自己定的目标很实际输入是普通摄像头实时画面或照片输出是垃圾类别和置信度最终模型可以转换成TFLite格式在CPU设备上流畅运行。从这个目标倒推模型就不能选那种追求极致精度但体积好几GB的超大网络训练框架则定在TensorFlow 2.x因为它从训练到部署的链路最完整对新手也友好。1.2 为什么同时用自训练CNN和MobileNet迁移学习两条路线如果只追求结果直接用现成的预训练模型做迁移学习就可以了不需要自己从零构建CNN。但我还是坚持把两条路线都做出来原因有两点。第一自训练CNN是理解深度学习底层原理的最佳路径。通过自己搭建卷积层、池化层、全连接层你能直观感受到卷积核怎么提特征、池化怎么降维、Dropout怎么抑制过拟合。这些感知在你后续调整任何模型时都非常有用。第二在实际数据量有限的情况下从零训练CNN很难追上迁移学习的效果因为垃圾分类数据集的规模和复杂度都远不如大规模自然图像数据集模型很难只靠少量样本学到足够泛化的特征。所以我的策略是把自训练CNN当作基线模型用来建立“最差效果”认知把MobileNet迁移学习作为正式方案负责最终落地精度和部署可行性。1.3 技术栈与总体流程设计整个系统的技术选型如下表选型逻辑是“优先考虑完整性和可复现性”。模块选型选择理由深度学习框架TensorFlow 2.x训练接口成熟TFLite转换方便部署链路完整基础模型自训练CNN MobileNetV2兼顾原理学习与落地性能MobileNet在移动端友好图像处理OpenCV、TensorFlow数据管道摄像头读取、图像预处理和增强都能覆盖训练平台CPU可跑GPU更好数据量不大时CPU也能完成但GPU能显著加快迭代部署格式TFLite模型小、推理快不依赖完整TensorFlow环境整体流程分成四段数据准备与预处理、自训练CNN做基线、MobileNet迁移学习调优、TFLite导出与端侧验证。每一段都不是孤立的数据准备的质量直接影响后面所有环节所以我建议不要在数据阶段节省时间。2. 数据准备与预处理实战2.1 数据来源与类别设计先定类别再谈效果垃圾分类系统的第一步不是写模型而是确定类别体系。常见的公共垃圾分类数据集多以“可回收垃圾、厨余垃圾、有害垃圾、其他垃圾”四大类为顶层再细化到数十个子类。我建议第一次做项目的人不要一上来就挑战上百个细分类别先做4到10类等整个训练部署流程跑通之后再逐步扩充。我是从“纸板、玻璃瓶、金属罐、塑料瓶、塑料袋、果皮、废电池、旧衣服”这8类开始的每类收集600到1000张图片。如果你使用公开数据集一定要先做一次人工抽检因为这类数据集中最容易出现的问题是图片分辨率参差不齐、存在重复图片、少数图片标注明显错误比如把塑料瓶标成玻璃瓶。如果模型学习到了错标特征后面的精确率再高也是虚的。数据不足的部分可以自己拍摄补充注意覆盖不同角度、不同光线和不同背景这样模型才不至于只在某一类固定背景上表现好。2.2 预处理细节尺寸、归一化与通道顺序预处理看起来简单却决定了模型能否正确接收图片。首先必须把所有图片统一成相同尺寸否则CNN全连接层的输入维度会不一致训练直接报错。MobileNetV2的默认输入尺寸是224x224自训练CNN则根据计算资源可以选择96x96或128x128。其次把像素值从0到255归一化。使用ImageDataGenerator时设置rescale1./255即可使用MobileNetV2时要注意它有自己的预处理函数不能简单除以255就算完。还要注意通道顺序。TensorFlow默认是“高度、宽度、通道”的NHWC格式一般读图后不做特殊处理即可。如果你习惯用OpenCV读图要记得OpenCV读取的是BGR顺序需要转换为RGB否则模型看到的颜色通道是反的训练效果会明显变差。2.3 数据增强让模型在少量数据里“见多识广”数据增强不是单纯为了增加图片数量而是让模型对拍摄时的真实变化不敏感。我使用的增强方式包括随机旋转、水平翻转、宽度和高度平移、缩放、剪切变换、亮度扰动。用TensorFlow的ImageDataGenerator实现只需要几行配置。from tensorflow.keras.preprocessing.image import ImageDataGenerator train_datagen ImageDataGenerator( rescale1./255, rotation_range20, width_shift_range0.2, height_shift_range0.2, shear_range0.2, zoom_range0.2, horizontal_flipTrue, brightness_range[0.8, 1.2], fill_modenearest )这些增强参数也不是越大越好。比如旋转角度超过30度很多垃圾对象的类别特征可能被破坏反而加大学习难度。我建议先用轻中度增强观察验证集准确率是否提升再逐步调整。务必记住验证集和测试集不能使用与训练集相同的数据增强最多只做缩放和归一化否则验证结果会虚高不能反映模型真实泛化能力。2.4 数据集划分与目录组织数据划分要严格做到分层抽样避免某一类图片全部集中在训练集而验证集只剩下其他类别。对多分类任务最简单有效的做法是使用train_test_split并指定stratifyy。import os import shutil from sklearn.model_selection import train_test_split all_images [] for category in categories: imgs [os.path.join(category, f) for f in os.listdir(category)] all_images.extend(imgs) labels [category] * len(imgs) train_imgs, val_imgs, train_labels, val_labels train_test_split( all_images, labels, test_size0.2, stratifylabels, random_state42 )目录结构建议采用规范的分类文件夹方式data/ ├── train/ │ ├── cardboard/ │ ├── metal/ │ └── ... ├── val/ └── test/直接把图片按类别放进对应的文件夹后续用ImageDataGenerator的flow_from_directory加载非常方便也方便人工检查数据。我习惯把训练、验证、测试按7:2:1或8:1:1划分测试集留到最后才用避免在调试过程中反复看测试集结果产生隐性的“测试集过拟合”。3. 从零训练CNN网络结构、参数计算与训练回调3.1 CNN为什么能处理图片从卷积核到特征图CNN的核心假设是图像具有局部相关性。一个像素往往与周围的像素关系更密切而远处像素关系较弱。卷积层通过一组可学习的卷积核在图片上滑动每个卷积核负责检测某一种局部特征比如边缘、颜色块、纹理。多个卷积核叠加后浅层网络学到的是线条和角点深层网络学到的是“瓶口”“把手”这类语义部件最后用全连接层把这些特征组合成类别判断。池化层的作用是在保留主要特征的同时缩小特征图尺寸既减少计算量也增强平移不变性。整个过程很像我们人眼先看局部细节再综合判断整体类别。理解了这一点你就能明白为什么那些把卷积核堆得很深却不做归一化的模型经常出现训练震荡。3.2 网络结构设计不只是“越深越好”我设计的自训练CNN结构如下适合8个分类类别输入尺寸为224x224x3卷积层132个3x3卷积核BatchNormalizationReLU2x2最大池化卷积层264个3x3卷积核BatchNormalizationReLU2x2最大池化卷积层3128个3x3卷积核BatchNormalizationReLU全局平均池化全连接层128个神经元ReLUDropout 0.5输出层8个神经元Softmax为什么第三层用全局平均池化而不是继续展平因为如果直接Flatten前面特征图的尺寸还会很大参数量会急剧增加。比如128个特征图每个尺寸为28x28展平后就是100352个输入节点全连接层参数量巨大非常容易过拟合。全局平均池化把每个特征图变成一个值参数量大幅下降效果在大多数分类任务上不输Flatten。实际参数量估算第一层卷积是3x3x3x32再加32个偏置一共896个参数第二层3x3x32x64再加64个偏置约18496个参数第三层3x3x64x128再加128约73856个参数。整个模型参数量不到几十万相比于几百万甚至上亿参数的大模型已经非常轻量。3.3 TensorFlow代码实现import tensorflow as tf from tensorflow.keras import layers, models def build_cnn(input_shape(224, 224, 3), num_classes8): model models.Sequential([ layers.Conv2D(32, (3, 3), activationrelu, input_shapeinput_shape), layers.BatchNormalization(), layers.MaxPooling2D(pool_size(2, 2)), layers.Conv2D(64, (3, 3), activationrelu), layers.BatchNormalization(), layers.MaxPooling2D(pool_size(2, 2)), layers.Conv2D(128, (3, 3), activationrelu), layers.BatchNormalization(), layers.GlobalAveragePooling2D(), layers.Dense(128, activationrelu), layers.Dropout(0.5), layers.Dense(num_classes, activationsoftmax) ]) return model这里有两个容易被忽略的细节。第一是BatchNormalization放在卷积和激活之间很多新手喜欢先激活再归一化效果往往会差一点。第二是卷积层后面接了池化不要急着堆很多卷积层因为小数据集上的深度神经网络很容易过拟合浅一点反而好训练。3.4 训练配置与回调机制训练配置决定了模型收敛速度和最终效果不要随便选参数。model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), losscategorical_crossentropy, metrics[accuracy] ) callbacks [ tf.keras.callbacks.EarlyStopping(monitorval_loss, patience10, restore_best_weightsTrue), tf.keras.callbacks.ReduceLROnPlateau(monitorval_loss, factor0.5, patience5), tf.keras.callbacks.ModelCheckpoint(cnn_best.h5, monitorval_accuracy, save_best_onlyTrue) ] history model.fit( train_generator, validation_dataval_generator, epochs50, callbackscallbacks )categorical_crossentropy要求标签是one-hot编码flow_from_directory默认会用categorical模式输出one-hot标签所以这里正好匹配。EarlyStopping和ReduceLROnPlateau是好搭档当验证集loss不再下降时学习率会自动降一半给模型一个更精细的收敛机会如果连续10个epoch仍然没有改善就提前停止避免浪费时间。3.5 评估模型准确率不够要看混淆矩阵训练结束后不要只看总准确率一定要看分类报告。8个类别的垃圾中可能“塑料瓶”和“玻璃瓶”经常混淆而其他类别都很好这时候总准确率可能显示92%看起来不错但特定两类完全不可用。正确做法是在测试集上输出混淆矩阵和逐类精确率、召回率。from sklearn.metrics import classification_report, confusion_matrix import numpy as np y_pred model.predict(test_generator) y_pred_classes np.argmax(y_pred, axis1) y_true test_generator.classes print(classification_report(y_true, y_pred_classes, target_namescategories)) print(confusion_matrix(y_true, y_pred_classes))一个真实项目中如果“塑料袋”因为太透明、反光严重而被大量误判为“塑料瓶”说明模型还没有把“形状可变形”这个特征学会。这时候优先补充更多透明的、揉成一团的塑料袋图片比盲目加深网络更有效。这也是小项目里“数据比模型更值钱”的典型例子。4. MobileNet迁移学习实战少样本也能拿到高精度4.1 MobileNet的核心优势轻量而高效MobileNet不是某一种固定结构而是一系列为移动端设计的轻量网络。它的核心创新是深度可分离卷积把普通卷积拆分成“逐通道卷积”和“逐点卷积”。简单理解普通卷积一次性同时处理空间信息和通道信息而深度可分离卷积先对每个通道做空间卷积再用1x1卷积去融合通道信息。这样计算量可以下降一个数量级而精度损失相对可控。对于垃圾分类这个场景模型是要部署到普通设备上的MobileNet的轻量特性直接决定了“可用”。我选择MobileNetV2而不是MobileNetV1是因为V2引入了线性瓶颈和倒残差结构在相同体积下精度更高很多公开部署案例都验证过它的稳定性。4.2 迁移学习为什么要借大规模预训练模型的“眼力”垃圾分类图片虽然有自己的特点但底层视觉特征比如边缘、纹理、形状、颜色分布与大规模自然图像数据集中学到的特征非常相似。预训练模型已经在海量图片上学会了识别这些通用特征我们不需要从零开始教它“什么是边缘”。迁移学习有两种常见策略。第一种是特征提取把预训练模型全部冻结只修改最后几层适合每类只有几百张图片的小数据集。第二种是微调先冻结预训练模型训练分类头再解冻部分高层卷积层用很低的学习率继续训练适合数据量稍多且希望进一步提升精度的场景。我用一张表说明区别策略训练时间所需数据量精度上限适合场景特征提取短每类200~500张中等偏高快速搭建基线、CPU训练微调较长每类500张以上更高追求最终部署精度4.3 特征提取模式实现先冻结全部卷积基座下面这段代码创建了MobileNetV2迁移学习模型关键点在于base_model.trainable False。from tensorflow.keras.applications import MobileNetV2 from tensorflow.keras.applications.mobilenet_v2 import preprocess_input from tensorflow.keras import layers, models base_model MobileNetV2( weightsimagenet, include_topFalse, input_shape(224, 224, 3) ) base_model.trainable False inputs tf.keras.Input(shape(224, 224, 3)) x preprocess_input(inputs) x base_model(x, trainingFalse) x layers.GlobalAveragePooling2D()(x) x layers.Dropout(0.2)(x) outputs layers.Dense(num_classes, activationsoftmax)(x) model models.Model(inputs, outputs) model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), losscategorical_crossentropy, metrics[accuracy] )注意这里的preprocess_input是MobileNetV2专用的预处理方式不是简单除以255。把输入先做预处理再进入模型才能和预训练时看到的数据分布保持一致。还有一个容易被忽略的细节因为base_model被冻结在推理模式下trainingFalseBatchNormalization层会使用移动平均而不是当前批次统计量这样能避免因为冻结BN层导致的训练不稳定。4.4 微调模式实现小学习率解冻高层特征提取模式训练几个epoch后如果验证准确率进入了平台期就可以尝试微调。微调的思路不是一次性把整个网络都解冻那样学习率稍大一点就会让已经学好的预训练特征被破坏。我的做法是先解冻整个MobileNetV2然后手动把比较浅的层继续冻结只让最后几十层跟着训练。base_model.trainable True # 具体冻结层数需要根据模型结构调整一般保留前100层左右不动 for layer in base_model.layers[:100]: layer.trainable False model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-5), losscategorical_crossentropy, metrics[accuracy] )这个阶段的学习率一定要比前面低两个数量级以上。我用过1e-5也用过5e-6整体都很稳定。如果学习率突然拉高到1e-4很快就看到loss冲高然后模型再也回不到原来的状态这就是很多同学遇到的“微调直接废掉”的原因。4.5 小样本训练的几个关键技巧在每类只有几百张图片时有几个技巧非常管用。第一是先用特征提取模式得到一个不错的初始化点再进入微调不要在一开始就解冻全部层。第二是在分类头之前加Dropout层虽然预训练模型已经很强但在小数据集上依然很容易过拟合。第三是合理使用早停让训练在验证集开始下降时立刻停止。第四是如果某类图片数量特别少可以为这类图片做更多增强或者用类别权重惩罚模型对少数类的“忽视”。我实测下来MobileNet迁移学习在每类500张左右图片的情况下验证准确率比自训练CNN高出8到12个百分点而且模型体积只有约十几MB转换TFLite之后还能再压缩一半以上。这也是为什么最终方案我毫不犹豫选择迁移学习的原因。5. 让模型真正可用TFLite导出与端侧推理5.1 部署目标模型要“变小变快”而不是只追求准确率很多同学训练完模型保存一个.h5文件就认为项目完成了。但对“可用”来说这远远不够。h5或SavedModel格式需要完整的TensorFlow环境才能加载CPU设备上跑起来还特别慢。手机、嵌入式板卡根本没有办法直接运行大模型。所以我的部署目标是转换成本低的TFLite格式。TFLite是TensorFlow为移动端和嵌入式设备设计的推理格式它执行时不需要完整训练框架模型体积更小推理速度快。转换时还可以做量化把模型中的权重从32位浮点数变成16位浮点数甚至8位整数进一步压缩体积。5.2 从SavedModel到TFLite的转换操作先训练并保存模型推荐保存为SavedModel格式因为它对部署链路兼容性最好。model.save(garbage_mobilenet_savedmodel)然后使用TFLite转换器。最简单的转换如下import tensorflow as tf converter tf.lite.TFLiteConverter.from_saved_model(garbage_mobilenet_savedmodel) converter.optimizations [tf.lite.Optimize.DEFAULT] tflite_model converter.convert() with open(garbage_mobilenet.tflite, wb) as f: f.write(tflite_model)这里tf.lite.Optimize.DEFAULT会尽可能压缩模型。由于训练时模型权重分布比较合理量化后精度损失通常很小。如果后续实测发现精度掉得比较多可以改用仅对权重做量化而不对激活值做量化或者干脆不量化只保留FP32模型根据自己的硬件算力做取舍。5.3 使用TFLite解释器在Python端推理TFLite模型不能直接喂给原来的模型预测函数需要用TFLite解释器加载。我写了一个可复用的推理函数import numpy as np import tensorflow as tf interpreter tf.lite.Interpreter(model_pathgarbage_mobilenet.tflite) interpreter.allocate_tensors() input_details interpreter.get_input_details() output_details interpreter.get_output_details() def predict_tflite(image): image cv2.resize(image, (224, 224)) image image.astype(np.float32) image preprocess_input(image) # 和训练时保持一致 image np.expand_dims(image, axis0) interpreter.set_tensor(input_details[0][index], image) interpreter.invoke() output interpreter.get_tensor(output_details[0][index]) return output部署时最容易出的问题就是预处理不一致。训练时你用了MobileNetV2的preprocess_input推理时却用了简单除以255模型输出的置信度会很奇怪。如果你自己在模型里加了归一化层那么推理时就不需要再做一遍。在写部署代码之前先花十分钟核对接下来的每一步比训练时调参还要重要。5.4 精度与速度的取舍量化不是越高越好我实际对比过三份模型原始FP32、FP16半精度、INT8量化。模型模型体积推理速度准确率变化FP32约14MB基准基准FP16约7MB更快基本不变INT8约3.5MB最快可能下降1%~3%INT8量化在小模型上通常不会有太大损失但如果你训练时的数据分布与部署现场差异较大掉点就会更明显。一个稳妥的做法是先在开发板上跑一遍量化模型如果准确率下降超过预期就退回FP16。端侧推理还可以设置线程数比如在双核处理器上尝试interpreter.set_num_threads(2)有时候速度提升比换模型更直接。6. 常见问题与高频踩坑实录6.1 训练集较小模型反复过拟合典型现象是训练准确率很快升到98%验证准确率却停在70%。这通常意味着模型把训练数据死记硬背下来了没有真正学会泛化。解决顺序分三步先加强数据增强增加旋转、翻转、色彩抖动的强度再增强模型正则化增大Dropout比例或减少全连接层神经元最后如果还是不行就换迁移学习模型而不是继续堆CNN层数。对小数据来说换网络带来的收益往往大于调参。6.2 类别不均衡导致模型“永远猜多数类”如果“塑料瓶”图片有3000张“旧衣服”只有200张模型很可能为了整体准确率把旧衣服全部忽略。在训练损失上体现为少数类几乎不被预测。对策有两个一是收集更多少数类图片这是最根本的二是在class_weight里给少数类更大权重。TensorFlow训练时可以直接传入class_weight参数不同框架语法略有差异核心思想是让少数类样本被分错时产生更大的损失。6.3 验证集准确率高测试集却崩了这是最隐蔽的坑。常见原因是数据泄漏同一个物体的多张不同角度照片被同时分到了训练集和验证集导致验证集“见过”训练数据。另外数据增强如果被错误地应用到了验证集也会让验证分数虚高。我建议划分数据集时按“物体实例”而不是按“单张图片”去划分比如同一组连续拍摄的塑料瓶图片全部划到同一个集合不要打散。这样做更接近真实场景。6.4 迁移学习微调时loss突然冲高这是我在迁移学习里踩过最狠的坑。原因通常是解冻后学习率设置过大导致预训练权重被快速破坏也可能是模型里BatchNormalization层在trainingTrue时重新统计了少量样本的均值方差而样本量太小统计不稳定。解决方法是把学习率降到1e-5以下同时确保微调阶段的batch size不要太小尽量保持在32以上。如果loss还是冲高重新从特征提取阶段的模型恢复再降低解冻层数。6.5 端侧部署后推理速度达不到实时如果摄像头画面看上去有明显的卡顿先检查模型输入分辨率是不是太高。比如你训练时用224x224部署时却把原图先缩放到640x640再送进模型速度自然慢。输入超过模型需要的大小并不会提升精度。其次检查量化是否生效先用interpreter打印模型输入数据类型如果还是float32就确认转换时是否真的加上了optimizations。两条都排查完基本可以解决问题。7. 写在最后一些真实体会7.1 复现这个项目的建议顺序我自己跑这个项目时最受益的是把“自训练CNN”和“MobileNet迁移学习”放在同一套数据上对比。这样你能直观感受到数据量、模型容量和泛化能力之间的关系而不是单纯迷信某个高大上的网络。建议复现时也按这个顺序来先准备数据再跑一遍CNN基线然后用MobileNet迁移学习做正式方案最后再做TFLite量化部署。不要急着跳过基线直接做迁移学习少了那一步很多训练现象你很难解释。7.2 这个项目后续还能往哪里扩展完成第一版后可以做的扩展方向很多。比如给系统加入目标检测前置模块先用检测模型定位画面中的垃圾区域再裁剪送入分类模型这样就能处理画面里有多个垃圾对象的情况。也可以把单帧图片分类改成视频流分类加入时序平滑策略避免连续帧分类结果抖动太厉害。再进一步还可以在部署脚本里加入简单的置信度阈值逻辑置信度低时提示用户重新拍摄或反馈人工标注形成一个小闭环。垃圾分类系统并不是什么“高不可攀”的项目它真正考验的是你把数据、模型、部署串联起来的工程能力。希望这篇记录能帮你少走几步弯路。