简介:一套基于Python和Shell的茶叶枯萎病检测系统源码包,面向茶叶种植者、农业科研人员及计算机视觉开发者,用于解决人工识别耗时耗力、准确率不稳的问题。系统覆盖图像采集、预处理和基于YOLO的病害识别等完整流程,并提供Dockerfile便于容器化部署。包体共51个文件,以23个Python源文件为核心,负责算法与数据处理;16个YAML配置用于调整训练与推理参数;另有Shell自动化脚本、Dockerfile、Jupyter Notebook及Markdown说明等辅助文件,压缩包约745KB。目录结构涵盖模型定义、训练测试、工具函数及部署配置,便于按模块阅读和二次开发。项目开源共享,且已有272人浏览学习,可帮助用户快速搭建茶叶病害检测环境,理解YOLO系列模型在农业场景中的落地流程,也可作为目标检测项目改造的参考模板。
1. 一套茶叶枯萎病检测系统,为什么同时要Python和Shell
茶园里拍回来的照片不会自己说话。判断一片茶叶是否开始枯萎,靠人眼翻几千张图不现实,常见做法是用Python训练一个分类模型,让新照片进来时直接给出"枯萎/健康"的判断。这是算法侧的事,也是"基于Python的检测系统"这条主线。
但一套能上线的检测系统,源码里七成工作量不在模型。照片从相机同步到服务器、按日期归档、滤掉拍糊的残图、把最优权重替换上线,这些环节用Shell处理比Python更直接,也是很多刚接触linux系统安装python的人容易低估的部分。
这套Python加Shell的组合,适合正在做农业视觉项目、或想验证"模型之外还有多少活"的工程师。下面按数据集、训练、自动化、部署的主线把源码设计讲透,结构取舍比单个模型文件更能决定系统能否在真实茶园里跑起来。
2. 用Python搭建枯萎病识别核心:数据集、迁移学习与推理
2.1 先解决图片标准化:预处理函数是所有样本的统一入口
实际采集的茶叶照片渠道很杂——手机拍的、固定摄像头截的、甚至翻拍旧照片。尺寸、亮度、叶片朝向都不一样,直接喂进网络会让训练loss持续震荡。所以第一步是把所有路径的图片压到统一尺寸并归一化。常见做法是OpenCV读图,它对EXIF旋转和色彩通道的处理比PIL更省心。
import cv2 import numpy as np def load_leaf_image(src, size=(224, 224)): # 兼容 str 路径与文件流两种输入 if isinstance(src, str): img = cv2.imread(src) else: data = np.frombuffer(src.read(), dtype=np.uint8) img = cv2.imdecode(data, cv2.IMREAD_COLOR) if img is None: raise ValueError(f"无法读取图片: {src}") img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, size, interpolation=cv2.INTER_AREA) return img.astype(np.float32) / 255.0这个函数同时兼容文件路径和文件流两种输入,前一种给训练脚本用,后一种给第四章的Flask接口用,一处修改两端生效。imdecode把字节流直接解码成图像,不需要先在磁盘上落地临时文件。INTER_AREA在缩小图片时保留叶片纹理骨架、减少锯齿,换成INTER_LINEAR则适合放大场景。0-1归一化对小规模数据集够用,ImageNet的均值方差归一化在枯萎病这种单一对象识别上收益不明显,却会引入对mean/std参数的依赖,调试时多一个变量。
数据集目录建议按train/val/test分三层,每层内部再按类别分子目录,PyTorch的ImageFolder可以直接读这种结构,不用手写Dataset。
/data/tea_images/ ├── train/ │ ├── wilted/ │ └── healthy/ ├── val/ └── test/子目录名的命名要一致,训练脚本、增强脚本、告警脚本都会引用这两个类别名,中途改名会连带改一批脚本。样本量少时test集至少要留30张健康、30张枯萎,否则最终指标波动很大,不好判断模型真实水平。
2.2 训练脚本:用迁移学习把ResNet18改造成二分类器
枯萎病公开数据集很少,自己标注几百张到一千张已是极限。从零训练一个CNN效果远不如加载ImageNet预训练权重、只替换最后一层全连接的做法。PyTorch里这个改造只要几行。
import torch.nn as nn from torchvision import models def build_classifier(num_classes=2): model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) in_f = model.fc.in_features model.fc = nn.Linear(in_f, num_classes) # 只替换最后一层,保留骨干特征 return model model = build_classifier() optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3) # 冻结骨干,只训分类头 criterion = nn.CrossEntropyLoss()关键在optimizer只接收model.fc.parameters(),骨干网络的梯度不会更新,相当于只训练一个分类头。
提示:如果误传model.parameters(),前面所有层都会参与训练,几百张样本几乎必然过拟合。想微调时再解冻最后两个stage,并把学习率降到1e-4。
训练循环里要单独维护一个best_state字典,每个epoch结束后用验证集算accuracy,只有超过历史最优时才保存权重。最后部署的必须是这份best权重,而不是最后一个epoch的权重。类别不均衡时accuracy会失真,比如枯萎样本占90%时模型全猜枯萎也有90%准确率,所以要同时记录precision和recall,枯萎类的recall偏低说明漏报变多,比总准确率下降更危险。如果枯萎样本确实远多于健康样本,可以在CrossEntropyLoss里传weight参数,给少数类更高权重。weight值先用样本数的反比估算,再根据验证集recall微调,一般不会偏离超过两倍。
表:枯萎病二分类训练参数建议
| 参数 | 建议值 | 说明 |
|---|---|---|
| image_size | 224 | 与ResNet输入匹配,显存紧张先减batch |
| batch_size | 16~32 | 单卡建议32,OOM时降到16 |
| epochs | 20~30 | 迁移学习收敛快,太多反而过拟合 |
| lr | 1e-3 | 只训分类头;解冻后降到1e-4 |
| 数据划分 | 7:2:1 | 训练/验证/测试,划分时固定随机种子 |
2.3 推理脚本:识别结果不能只看argmax
训练结束后导出的.pth文件由推理脚本加载,输出"枯萎/健康"标签。这里建议返回softmax概率而不是直接argmax。茶园的图片类别不可控,一张模糊的叶子、一只虫子、甚至镜头误触的照片都会被强行分到某一类,置信度低时应该拒识而不是乱报。
def predict(model, tensor, threshold=0.85): import torch model.eval() with torch.no_grad(): logits = model(tensor.unsqueeze(0)) prob = torch.softmax(logits, dim=1)[0] conf, idx = torch.max(prob, dim=0) if conf.item() < threshold: return -1, conf.item() # 低于阈值返回-1,交给人工复核 return idx.item(), conf.item()返回-1表示"不确定",告警模块对这类样本一律转人工复核。threshold不能拍脑袋,要统计验证集全部样本的置信度分布后决定,第五章会讲具体做法。这段逻辑在源码里只有几行,却是误报率控制的核心,也是"能demo"和"能落地"的分水岭。
3. Shell脚本包圆采集与训练流程:照片归档、一键训练与定时任务
3.1 数据侧为什么用Shell而不是再写一个Python脚本
入行时我也习惯所有逻辑都用python写,直到发现每天凌晨要从边缘盒子同步照片到训练服务器。SSH拉取、按日期建目录、删除损坏文件、统计数量生成manifest,这些操作原生Shell命令就能完成。rsync断点续传、find筛选、shuf随机抽样,写起来比Python短得多,还不需要关心虚拟环境是否存在。对一台只装了基础组件的Linux服务器,shell脚本是零依赖的任务编排方式,正好是shell脚本入门阶段能立刻上手的实战场景。
边缘设备拍照、rsync同步、归档建目录、生成训练清单、触发训练,这条流水线每一环都是独立可重跑的命令,出问题只看退出码就能定位到环节,排查成本远低于维护一个几百行的Python编排器。
3.2 按日期归档并生成训练清单
#!/bin/bash # archive_images.sh set -euo pipefail BASE_DIR=/data/tea_images DAY=$(date +%Y%m%d) SRC_DIR=/mnt/edge_capture mkdir -p "$BASE_DIR/$DAY" rsync -avz --partial "$SRC_DIR"/ "$BASE_DIR/$DAY"/ find "$BASE_DIR/$DAY" -name "*.jpg" -size -20k -delete find "$BASE_DIR/$DAY" -name "*.jpg" > "$BASE_DIR/$DAY/manifest.txt"rsync的--partial保证网线抖动中断后重传不用从头开始,-a保留时间戳方便核对拍摄时刻。find用-size -20k过滤掉压缩失败的残图,这类文件在移动网络上传时很常见,留着会污染训练集。最后的manifest.txt是训练脚本的输入清单,后续人工筛样只编辑清单不删原图,原图是排查误判的第一手证据。
训练集和验证集也可以用Shell从当天新样本里随机抽,shuf配合head/tail就够了:
shuf "$BASE_DIR/$DAY/manifest.txt" | head -n 20 > "$BASE_DIR/$DAY/val_extra.txt"shuf打乱行序后取前20行做验证样本,剩余行留在manifest里参与训练。这种做法的好处是新样本随采随用,坏处是每天的验证样本都在变,跨天指标不可比。想对比训练效果时,验证集必须固定一个目录,不参与当天的shuf。
归档时文件名里避免中文和空格,边缘盒子的固件日期格式通常像20250110_083000.jpg这样纯数字,训练脚本解析时间就简单。如果上游文件名带中文,先mv重命名再归档,否则python脚本里的open和cv2.imread在部分语言环境的容器里会报编码错误。
3.3 训练封装脚本:环境、日志、退出码三位一体
python训练脚本写得再完整,直接扔进crontab也会遇到PATH问题——cron环境里找不到python和虚拟环境,脚本静默失败后只有一行日志。训练入口必须用一层shell封装,把环境变量和日志路径固定下来。
#!/bin/bash # train.sh set -euo pipefail export PATH=/opt/python3/bin:$PATH source /opt/tea_env/bin/activate LOG_DIR=/data/logs mkdir -p "$LOG_DIR" LOG_FILE="$LOG_DIR/train_$(date +%Y%m%d_%H%M%S).log" python train.py --data /data/tea_images \ --out /data/models/tea_wilt.pth \ > "$LOG_FILE" 2>&1 echo "train exit: $?"set -euo pipefail让脚本在任一步失败时立即退出,训练失败后不会继续执行后续动作。source activate显式激活虚拟环境,不依赖用户bashrc。日志重定向到带时间戳的文件,训练现场完整保留。输出train exit给上层调度系统判断成败。
注意:cron运行时的PATH只有/bin和/usr/bin,python3路径必须写绝对路径或用export声明,否则会在cron日志里留一行"command not found",找问题特别费时。
3.4 定时任务节奏:凌晨采集、每周增量训练
生产方式下没人手动执行,cron是shell脚本最常见的挂载点。
30 2 * * * /opt/tea/bin/archive_images.sh >> /data/logs/cron.log 2>&1 0 6 * * 1 /opt/tea/bin/train.sh >> /data/logs/cron.log 2>&1 */5 * * * * /opt/tea/bin/watch_detect.sh >> /data/logs/watch.log 2>&1第一行每天凌晨2:30拉取前一天的拍摄照片,避开白天网络高峰;第二行每周一早上6点跑增量训练;第三行每5分钟探测一次推理服务,对应第四章的守护脚本。
表:cron任务规划
| 任务 | 表达式 | 作用 |
|---|---|---|
| 照片归档 | 30 2 * * * | 拉取并归档前一天照片 |
| 增量训练 | 0 6 * * 1 | 每周一早训练 |
| 服务探活 | */5 * * * * | 推理服务异常自动重启 |
增量训练周期不建议短于一周。样本积累不足时模型反复学习同一批图,验证集acc虚高但不涨,这是典型的假象。判断标准是训练集和验证集的acc差距:差距超过10个百分点,就是过拟合信号,该回采集端补样本,而不是继续加epoch。
4. 部署联调:Flask推理服务与Shell守护脚本怎么分工
4.1 把模型封装成HTTP接口,边缘端只负责传图片
边缘采集程序通常是C++或Go写的,没法直接加载PyTorch权重。最可靠的对接方式是模型服务化:Python起一个轻量HTTP服务,接收multipart图片,返回JSON结果。Flask在这里够用,瓶颈在图像解码和推理,不在框架本身。生产环境不用app.run(),用gunicorn起多worker:gunicorn -w 2 -b 0.0.0.0:8000 app:app,worker数是2还是4,用压测工具测P99延迟决定,不是拍脑袋。
from flask import Flask, request, jsonify import torch from data_utils import load_leaf_image from inference import predict app = Flask(__name__) model = torch.load("/data/models/latest.pth", map_location="cpu") @app.route("/detect", methods=["POST"]) def detect(): f = request.files["image"] tensor = load_leaf_image(f.stream) label, conf = predict(model, tensor) return jsonify({"label": int(label), "confidence": round(float(conf), 4)}) app.run(host="0.0.0.0", port=8000, threaded=True)回应2.1的伏笔:这里传的是f.stream,一个file-like对象,load_leaf_image走imdecode分支,不需要把上传文件先写盘再读盘,减少一次IO。map_location="cpu"保证没有CUDA的服务器也能起服务,有GPU再改成"cuda"。threaded=True允许并发请求,单卡推理时注意请求排队,显存不够时优先限流而不是加worker。端口8000对外网卡开放前,建议在Nginx层限制来源IP,边缘盒子的地址是固定的,白名单比任何鉴权都直接。
4.2 守护脚本:探活、自动重启、保留现场
进程挂在服务器上一定会死。OOM、端口被误占、模型文件被cp覆盖瞬间句柄失效,每周总有一两次。Shell守护脚本是兜底方案,它不处理根因,但保证服务在分钟级恢复。
#!/bin/bash # watch_detect.sh URL="http://127.0.0.1:8000/detect" CODE=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "$URL" -F "image=@/tmp/health.jpg" --max-time 10) if [ "$CODE" != "200" ]; then pkill -f "app.py" || true sleep 2 nohup /opt/tea_env/bin/python /opt/tea/app.py \ >> /data/logs/app.log 2>&1 & echo "$(date '+%F %T') restart reason code=$CODE" >> /data/logs/watch.log fi健康检查用的是真实推理请求,传一张固定的健康样本图,返回200说明服务和模型都活着,比单纯测TCP端口可靠。pkill后跟|| true,进程不存在时pkill返回非零,没有这个容错,set -e会直接让脚本静默退出。sleep 2等端口释放,端口占用是重启失败的第一大原因。nohup配合&让服务脱离SSH会话,终端断开不影响运行。
4.3 联调阶段最常踩的三个点
| 现象 | 最常见原因 | 处理办法 |
|---|---|---|
| 接口报504 | 推理超时 | 检查是否误用CPU跑大模型,或上传图分辨率过大 |
| 启动即退出 | 8000端口被占 | ss -lntp | grep 8000,kill旧进程再起 |
| 全天输出同一类 | 加载了过拟合权重 | 确认部署的是best_model.pth,不是最后一个epoch |
第三条在源码里最隐蔽。如果train.sh里没有单独保存best权重,部署脚本引用的往往是最终epoch的模型,这类模型在验证集上表现可能很差。建议训练循环内维护best_state_dict,训练结束后另存best_model.pth,部署侧永远引用这个固定文件名。另外一个容易忽略的细节是Python和Shell两侧日志时间要统一,训练日志用北京时间,守护脚本用UTC,两个时间线对不上时,排查故障要多绕一圈。统一在脚本开头export TZ=Asia/Shanghai,cron的时区设置也一起核对。
5. 让枯萎病检测在真实茶园里更可靠的三个落地技巧
5.1 用置信度分布校准阈值,而不是拍脑袋定0.85
第2.3节里的threshold=0.85只是初值。上线前把验证集全部样本过一次推理,记录每张图的最大置信度,分别画出枯萎类和健康类的分布。两个分布如果在0.7到0.9区间重叠严重,说明类间区分度不够,调阈值治标不治本,要回训练侧加样本和增强。重叠小但靠阈值切分时,取分布波谷位置的值比固定0.85更合理。阈值确定后写进配置文件,不要在代码里硬编码,后续换季样本分布变了,改配置比重发代码快。
5.2 样本不足时先做增强,别急着换大模型
几百张样本时,ResNet18加随机增强就够用。换ResNet50或ViT参数量更大,样本量喂不起,过拟合后验证集acc反而更低。torchvision里三个增强组合效果最直接:
train_transform = transforms.Compose([ transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(15), transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), ])RandomHorizontalFlip对叶片这类对称对象很有效,RandomResizedCrop模拟不同拍摄距离,scale控制在0.8到1.0之间,避免裁掉病斑区域。增强只加在训练集,验证集和测试集保持原图,否则指标虚高,部署后对不上。
5.3 模型产物的命名约定与一键回滚
推荐命名格式model_YYYYMMDD_HHMM.pth,训练脚本维护一个latest软链接指向当前最优版本。回滚时不需要重新训练,改软链接再重启守护脚本:
ln -sfn /data/models/model_20250110_1402.pth /data/models/latest.pth /opt/tea/bin/watch_detect.shln -sfn的n参数很关键,它把latest当成符号链接本身而不是链接目标目录,缺了n会在/data/models/下多套一层目录。回滚后调一次健康检查接口确认推理结果正常。系统上线后,每周翻一遍误报日志比反复调网络结构收获更大,误报样本攒起来按周回填训练集,模型就跟着茶园的真实状态走,比任何参数微调都可靠。
本文还有配套的精品资源,点击获取