把植物杆枝分类识别做成一个能跑通的完整Demo,这件事我前前后后折腾了两周。起因其实很简单:一个农林方向的模拟项目需要快速区分不同树种的枝干样本,既要判断画面里的主体是主杆、侧枝还是细枝,又要大致识别出它属于哪个目标类别。当时第一反应是"这不就是个图像分类任务吗",真正动手才发现,杆枝这种细长、表面纹理重复、背景干扰又大的目标,处理起来比想象中麻烦得多。
这篇文章会把整个方案的来龙去脉全部拆开讲:从任务定义、数据采集与标注,到骨干网络选型、训练参数调整,再到推理服务和交互界面的落地,每一步都结合实测结果说明白。适合正在做农林视觉识别、或者准备把分类模型快速打包成可演示系统的朋友参考。哪怕你之前没碰过深度学习,照着这个思路也能把Demo搭起来并跑通。
1. 先想清楚:植物杆枝分类到底在分什么
1.1 任务边界与类别体系怎么定
"分类识别"这四个字看着简单,但杆枝分类和常见物体分类有个本质区别:普通人脸、猫狗数据集里,目标通常居中、面积占比大、背景干净;而枝条在画面里往往是细长条、弯弯曲曲、还经常被叶片和杂物遮挡。所以第一步不是选模型,而是把任务边界划清楚。
我当时把任务拆成了两个层级。第一层级判断部位:画面里主要对象是主干部位、侧枝还是细枝,这直接关系到后面分拣环节的操作方式。第二层级才做种类识别,把枝条归到具体的类别里去。两个层级共用同一套图像特征提取部分,只在分类头各自独立,这样维护起来很省事。类别数量上,Demo阶段我只选了三个形态差异比较明显的目标类别,外加一个"其他/无效"类别兜底。
这个"其他"类别非常关键。实际采集的图片一定会混入叶子特写、树皮局部、甚至手指遮挡的画面,如果没有兜底类,模型只能强行把这类图分到某一个正经类别里,准确率直接被拉低。我在第一个版本没设这个类,验证集准确率看起来还行,一放到真实拍摄的图片上立刻露馅。
提示:类别数量宁少勿多。三个清晰的目标类别跑通的Demo,比二十个糊成一团的类别有价值得多。
类别体系确定后,顺手统计了每一类的样本量和拍摄条件分布。这一步很值得做,它能提前暴露两类问题:一类图片太少导致训练失衡,另一类是某个类别的照片全在同一个光照角度下拍的,模型学到的是光斑而不是枝条特征。我见过太多人跳过这一步,等训练完看混淆矩阵才后悔。
1.2 为什么直接套用通用分类方案会翻车
很多人拿到任务第一反应是"找个图像分类模型跑一下",我也是这么过来的,但直接套通用方案会踩三个坑。
第一个坑是形状特征失效。通用模型在 ImageNet 上练出来的前置知识里,形状边缘很关键,但枝条恰恰是形状高度重复、纹理主导的对象。同一类别的枝条,粗看几乎一模一样,人和模型能分出来全靠树皮纹理、颜色、节疤分布这些细节。这意味着骨干网络不能太浅,浅层特征不够用。
第二个坑是相似类间的差异太小。目标类别的枝条就算叶片掉光了,枝干纹理依然很接近,类间距离小到普通分类头很难拉开。解决思路是加大输入分辨率,同时配合数据增强把纹理细节强化出来。我最终把输入从常见的 224 提到 256,虽然训练时间变长,但对杆枝这类纹理敏感任务是值得的。
第三个坑是背景污染。枝条照片很容易拍进大片天空、地面和叶片,模型会不自觉地把背景当成区分信号。前面说的"其他"类别只能部分解决这个问题,更有效的办法是把照片里枝条区域尽量放大再送进模型,这一点在数据预处理阶段就要严格控制。
选型上还有一个方向性决定:我明确不做目标检测,只做整图分类。原因很简单,Demo 目标就是判断"这张照片里主要是什么",不需要定位到具体像素位置。检测方案要求为每个枝条标注框,标注量翻几倍不说,训练和调参的复杂度也明显更高,在没有明确需求的情况下不值得。
2. 数据集处理:系统上限由数据决定
2.1 采集规范:普通手机也能拍出可用样本
数据是这类项目真正的护城河。模型结构、训练技巧再花哨,数据不干净全都白搭。我对采集定了几条硬性规范。
拍摄设备不挑,手机就行,但距离要固定在 30 到 80 厘米之间。离太近画面全是树皮纹理,离太远枝条占比太小,模型学不到有效信息。背景统一用纯色板或者树冠自然虚化,避免天空、墙面这类强干扰。每根枝条至少拍三个角度:正面、侧面、斜上方,把纹理在不同视角下的变化覆盖进去。
光线条件也要刻意多样化。晴天直射光、阴天散射光、略微逆光都要拍一些,防止模型对亮度形成偏置。我第一批图全在一个晴天下午拍完,训练出来在阴天场景里直接掉点五个百分点,后来补拍了阴天样本才拉回来。
数量方面,每个目标类别 200 张原始图起步,三个正类加上"其他"类共约 800 张。拍完之后按 70%、15%、15% 划分训练、验证、测试三部分。特别提醒:划分前先按拍摄时间排个序,防止同一天拍的图片因为光照、设备状态高度相似而全部落到测试集里,那测试结果会虚高得离谱。
2.2 标注规范、筛选与预处理流程
图像分类任务的"标注"比检测轻量得多,核心是把图片按类别放进对应文件夹。但组织方式直接影响后面迭代效率,我建议目录结构用"数据集根目录/训练集或验证集/类别名/图片"的三级形式,代码里直接用文件夹名当标签,省掉一份额外的标注文件。
文件名也要有规范。我用"类别简写_拍摄日期_序号"的格式,比如 C1_0712_003.jpg。这样后期想追溯某张图片是什么时候、哪个批次拍的,一目了然。真实生产环境里还会遇到同一批枝条重复入镜的问题,文件名带日期编号能帮你快速定位去重。
预处理阶段先做一轮人工筛选。凡是虚焦、严重遮挡、大面积反光、目标占比过小的图,一律删掉。与其让模型在脏数据上浪费时间,不如在一开始把数据质量关把好。图片统一转成 RGB、JPEG 格式,再缩放到 256x256,并按 ImageNet 的均值方差做归一化。这里有个容易踩的坑:有些图片是带 EXIF 旋转信息的,训练库和推理库如果来自不同渠道,旋转方向可能不一致,我后面在排查实录里还会专门讲。
2.3 数据增强:用最小成本把样本量做上去
原始图只有 800 张,对深度学习来说偏少。数据增强是最划算的扩样本手段,我用的增强策略分三组。
几何增强组:随机水平翻转、随机旋转正负 15 度、随机裁剪后缩放回原尺寸、随机长宽比变换。这一组让模型对枝条摆放角度和画面构图不敏感。
色彩增强组:亮度扰动正负 20%、对比度扰动正负 15%、HSV 色相偏移少量。这组是为了模拟不同光照和季节变化。要注意色相偏移不能太大,毕竟枝条颜色本身是识别特征之一,偏移过头会把不同类别的颜色差异抹平。
噪声增强组:轻微高斯噪声和随机模糊。智能手机在不同光线下的噪点差异很大,加一点噪声能让模型更鲁棒。模糊则模拟手抖和近距离拍摄时的景深虚化。
实现上我推荐用 albumentations 库而不是 torchvision 自带 transform。前者处理速度更快、增强种类更多,而且接口更符合"先增强后归一化"的常规写法。增强幅度一定要克制,我见过有人把旋转开到 90 度,结果模型对完全倒置的枝条学习出错误特征,验证损失反而下不去。
3. 模型训练:骨干选择与参数调优实录
3.1 迁移学习骨干网络怎么选
数据集规模不大,从头训练一个 CNN 不现实,迁移学习是当然选择。ImageNet 上预训练的模型已经把边缘、纹理、颜色块这些底层视觉特征学得很扎实,而杆枝识别恰好依赖这些底层特征,微调起来事半功倍。
我在 Demo 里对比了四个骨干:ResNet-18、ResNet-50、EfficientNet-B0、MobileNet-V3-Large。对比时主要看四个方面:参数量、在验证集上的准确率、单张推理耗时、部署难度。
当时实测结果大致如下:
| 骨干网络 | 参数量 | 验证准确率 | CPU单张耗时 | 部署难度 |
|---|---|---|---|---|
| ResNet-18 | 约 11M | 94.2% | 约 40ms | 低 |
| ResNet-50 | 约 25M | 94.8% | 约 80ms | 低 |
| EfficientNet-B0 | 约 5M | 93.6% | 约 50ms | 中 |
| MobileNet-V3-Large | 约 5M | 92.1% | 约 25ms | 中 |
结论很明确:Demo 阶段首选 ResNet-18。准确率跟 ResNet-50 几乎持平,但推理速度快一倍,模型文件小,后面转 ONNX 也省事。EfficientNet 理论精度不错,但实际在纹理细粒度任务上没有体现出优势,反而因为结构复杂,调参时更折腾。
3.2 训练超参的确定过程
超参我全部从公开经验和自己的实验结合确定,不推荐拍脑袋。
优化器选了 SGD,momentum 设为 0.9。迁移学习场景下 SGD 配合合适的学习率衰减,收敛稳定性比 AdamW 好,我在实验中用 AdamW 后期验证集掉点的情况更常见。学习率初始值设为 1e-3,这是微调阶段比较稳妥的量级;如果从零训练得用 1e-2 甚至 1e-1,但这里用不上。学习率策略用余弦退火,总轮数 40 轮,从头到尾衰减,比固定学习率多拿约 1 个点的提升。
batch size 在显存允许范围内用 32,实际不够就降到 16。这里有个容易被忽略的点:batch size 变了,学习率最好跟着缩放。从 32 降到 16,学习率可以按比例减半,否则梯度噪声变大,loss 波动会很厉害。
损失函数用交叉熵。三个正类样本量基本均衡,加权的需求不大,但如果后续扩类把某一类的样本搞少了,要立刻切换成加权交叉熵,权重按"样本数倒数归一化"计算。评价指标不能只看 accuracy,类别一旦不均衡就要补看 F1-score。我训练完把每类的 precision、recall 都打印出来,比单看总体准确率更能发现某些类被牺牲的问题。
关键训练代码结构大致是这样:
import torch import torch.nn as nn from torchvision import models, transforms from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, 4) # 3个正类 + 1个其他类 criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=1e-3, momentum=0.9) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=40) for epoch in range(40): model.train() for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() scheduler.step() # 每轮结束在验证集上评估,保存最优权重有一点要提醒:ImageNet 预训练模型是 1000 类输出头,替换最后一层时in_features要从原模型读,别写死成 512 或 2048。每个版本换骨干都要改这里,忘了就报维度不匹配的错。
3.3 训练过程怎么看曲线、怎么调
训练不是丢进去等结果就完事了。我习惯把每轮的 train loss、val loss、val accuracy 全部记录成曲线图,盯三个信号。
第一个信号是 loss 下降速度。如果前两轮 train loss 几乎不动,大概率是学习率太低或者数据预处理出错。如果 loss 直接飞起来发散,那就是学习率太大,立刻停掉重来。
第二个信号是 train loss 和 val loss 的分叉。train loss 持续下降、val loss 掉头上涨,是典型过拟合。我的应对办法按顺序尝试:加大色彩增强、开启随机裁剪、最后一层加 dropout。一般不轻易减少轮数,因为早期停止会欠拟合。
第三个信号是混淆矩阵里的固定错误对。我这个项目里最容易混的是两个类别的幼嫩枝条,颜色和纹理都很接近。看了几十张被分错的图才发现,一是幼嫩枝条表皮光滑没形成明显纹理,二是原始训练图里这类样本太少。解决方法是专门补拍一批幼嫩枝条,而不是盲目调模型结构。
整个调参过程我保留了实验记录表,每次改配置都记下数据集变化、超参、最终指标。这习惯帮了大忙,后面调结构对比时不用靠记忆猜测哪次实验是哪套配置。
4. 从模型到完整Demo的落地细节
4.1 用 Flask 封装推理服务
模型训练完只是第一步,Demo 要能给别人体验,得包一层可靠的推理服务。我用 Flask 写了个轻量接口,接收图片请求,返回前三类别的预测和对应概率。
from flask import Flask, request, jsonify import torch import torch.nn.functional as F from PIL import Image from torchvision import transforms app = Flask(__name__) model = torch.load("stem_classifier.pt", map_location="cpu") model.eval() transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) @app.route("/predict", methods=["POST"]) def predict(): file = request.files["image"] img = Image.open(file.stream).convert("RGB") tensor = transform(img).unsqueeze(0) with torch.no_grad(): logits = model(tensor) probs = F.softmax(logits, dim=1)[0] topk = torch.topk(probs, k=3) labels = ["类别A", "类别B", "类别C", "其他"] result = [{"label": labels[i], "prob": round(float(probs[i]), 4)} for i in topk.indices.tolist()] return jsonify(result) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)这一层最要注意的是图像通道顺序。PIL 读进来是 RGB,Torch 训练也是 RGB,中间别用 OpenCV 转一圈。OpenCV 默认 BGR,一旦混用,模型看到的颜色全反,准确率直接崩掉,但训练代码不报任何错,是最隐蔽的坑之一。
4.2 加一层 Grad-CAM 可视化,说服力直接翻倍
模型给个分类结果,对搞农学的用户来说说服力不够。他们更想知道"模型凭什么这么分"。我给系统加了 Grad-CAM 热力图,把模型关注的区域叠加到原图上。
原理不复杂:取最后一个卷积层的特征图,用类别概率对特征图通道求加权平均,得到一个粗略的"注意力热区"。实现上我用 torchcam 库,两行代码就能拿到热力图,再配合 OpenCV 做颜色映射叠加。如果不想引库,手写 hook 也不难,但没必要重复造轮子。
Grad-CAM 还有个意外收获:排查模型学歪了特别有效。有一次热力图显示模型判断枝条类别时注意力全落在背景叶片上,我立刻明白数据里叶片信息泄漏了。把这类图重新裁剪、让枝条主体占比更大,重新训练后问题消失。没有可视化,这种问题可能要等部署到现场才会暴露。
4.3 性能优化与部署注意点
Demo 的部署环境不一定有 GPU,我在 CPU 上做了两件事。第一件是把模型转成 ONNX,推理速度提升明显,我的 ResNet-18 转完后单张 256 图在普通笔记本上约 25 到 40 毫秒。第二件是尝试 INT8 量化,速度更快但准确率掉了约 0.8 个点,对 Demo 来说可以接受,对实际分拣场景我会保留 FP32 或半精度。
前端界面我特意做得很轻,一个 HTML 页面加一点 JavaScript,上传图片后调 Flask 接口,显示预测类别、概率和热力图叠加结果。没必要引入重型前端框架,Demo 的核心是让对方快速理解系统能力,交互越简单越好。
还有个并发问题容易被忽略。Flask 开发服务器是单进程的,多人同时体验时请求会被排队。我当时为了演示流畅,用 waitress 替换了内置服务器,再配合进程池加载模型实例,实测同时来十几个请求也没问题。如果只是内部小范围演示,加threaded=True也够用。
注意:整个流程里我反复强调数据,因为杆枝识别这类细粒度任务的瓶颈几乎都在数据侧,模型结构反而是最不挑剔的部分。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
我把项目里实际遇到的高频问题整理成一张表,按"症状、原因、解决办法"三列对照,排查时直接查表最快。
| 症状 | 常见原因 | 解决办法 |
|---|---|---|
| 训练 loss 不降 | 学习率过大或过小;标签错乱 | 先打印几批数据核对标签;调学习率量级 |
| 验证集高、真实验证崩 | 训练测试分布不一致 | 按拍摄时间分组划分数据集 |
| 某类总是被分错 | 类别样本太少或特征重叠 | 补拍该类别样本;换加权损失 |
| 模型对背景敏感 | 数据里背景信息泄漏 | Grad-CAM 定位热区;裁剪让目标占比更大;加"其他"类 |
| CPU 推理慢 | 模型过大或未优化 | 换轻量骨干;转 ONNX;必要时量化 |
| 预测结果与训练时颜色怪异 | RGB/BGR 通道混用 | 统一用 PIL 或统一用 OpenCV,别混 |
5.2 三个最让我印象深刻的坑
第一个坑是手机自动美化。补拍样本时部分图片被手机 HDR 和美颜算法处理过,颜色饱和度明显异常,模型训练后对这批特殊色调产生依赖,正常手机拍的照片反而分不准。解决方式是采集时关闭所有增强算法,且在筛选阶段肉眼检查色偏。
第二个坑是 EXIF 旋转。很多手机照片的像素方向和显示方向不一致,靠 EXIF 字段记录旋转。我的训练库来自平铺拍摄,没有 EXIF 问题,但推理时用户上传的照片有旋转信息,直接读像素就横竖颠倒。解决方法是统一走PIL.ImageOps.exif_transpose把旋转固化到像素里。
第三个坑是下载素材混入乱图。从网络补了一批枝条素材,筛选不仔细,结果混进几张枝叶茂密的图,模型把"有大量叶片"当成了某个类别的信号。这类问题防不胜防,最有效的办法是训练完跑一遍全量训练集,把预测错误的图单独拉出来人工查看,往往能发现标注错误或误入图片。
排查的顺序也有讲究。遇到准确率异常,先看数据再看模型,90% 是数据问题。每隔一段时间把训练集、验证集各自重跑一轮预测,输出错误样本文件名的清单,这个过程我称之为"数据审计",成本很低但能避免很多无效调参。
这套植物杆枝分类识别 Demo 做下来,我的核心体会是:细粒度、纹理主导的视觉任务,数据质量比模型结构重要一个数量级。把拍摄规范、标注清理、数据审计这三件事做好,哪怕只是用 ResNet-18,效果也会超过很多花哨结构的模型。后续如果想让它真正走向野外应用,我会优先做两件事:一是把目标类别扩到十个以上并引入野外多场景数据,二是把模型进一步轻量化,争取能跑在边缘设备上。最后分享一个小技巧:每个版本训练完,保存一份带全部训练配置和数据划分的记录文件,我当时就靠它避免了很多返工,这个习惯在迭代速度快的项目里尤其值钱。