杂草识别这事,放在两年前还是件挺麻烦的事——要么靠人工下地巡查,要么用传统的颜色分割算法在实验室里跑几组数据,一旦换光照、换土壤背景,误报率立刻飙升。这次的项目,我把它做成了一条完整的链路:基于YOLOv12搭建杂草检测模型,用自己整理的YOLO格式数据集完成训练,再包上一层带登录注册功能的Web UI界面,全套用Python实现,交付源码加训练好的模型,拿到就能直接跑。
说“拿到就能跑”其实有点谦虚,中间还是踩了不少坑。从数据集标注、模型训练参数,到Flask登录模块、图片上传接口,再到最后联调时候的显存和并发问题,每一步都有值得记录的细节。这篇文章我把整条实现路径拆开来讲,既讲清楚每个模块为什么这么设计,也把实际运行中容易翻车的地方列出来,给正在做同类农业视觉项目或毕业设计的朋友一个可以直接参考的完整范本。
1. 这个项目到底在解决农林场景里的什么问题
1.1 从“人眼巡田”到“模型识草”:需求拆解
农业场景里的杂草检测,和通用物体检测有个明显的不同:目标小、背景杂、类别相近。同一块田里,狗尾草和牛筋草在幼苗期长得几乎一样,人工都容易认错;更麻烦的是杂草分布不均匀,有的区域密密麻麻,有的区域稀稀拉拉,检测系统不但要回答“有没有草”,还要回答“草在哪里、大概是什么类型”。
这个项目最初的需求来自一个很朴素的场景:在智慧农业试验基地里,想给自动喷洒装置提供一套视觉定位信号。喷洒装置不需要知道田里总共有多少草,它需要的是每个杂草目标在图像坐标系下的位置和类别,然后换算成喷洒坐标。所以模型输出的不只是“有草/无草”这种二分类结果,而是带边界框的目标检测结果。
需求拆下来其实就三条:第一,能识别常见杂草类别;第二,能给出稳定的边界框坐标;第三,单张图片推理速度要够快,至少是实时或近实时水平,不能等个三五秒才出结果。这三条直接把方案指向了深度学习目标检测路线,传统图像处理那套基本不用考虑。
1.2 技术选型:为什么是YOLOv12而不是传统算法或其他版本
接触过农业视觉项目的同学应该都有体会,传统方法在实验室里表现得不错,一到田间就现原形。光照一变、露水一干、土壤颜色一换,颜色阈值和形态学特征就得重新调一遍,根本谈不上泛化。所以这类项目现在基本都直接用深度学习模型。
YOLO系列在目标检测里一直是工程落地的首选,社区生态也完善。这个项目选择的是YOLOv12——YOLO系列里比较新的版本,相比更早的YOLOv5/v8,v12在推理速度、小目标召回和训练稳定几个方向都有明显改进。对于杂草这种小目标密集的场景,小目标召回率很关键,v12在这方面的提升正好打在需求点上。
至于为什么不选其他版本,一方面是新版本在相同体量下精度更好,另一方面是Ultralytics接口向下兼容,换模型的成本很低。如果哪天需要换更轻量的模型跑边缘设备,代码改动量很小。配套的Web框架选了Flask,理由很简单:它是Python生态里最轻量、最容易上手的Web框架,一个单文件就能把登录注册、图片上传、推理展示全部串起来,不需要像Django那样引入复杂目录结构,对个人项目和学术demo都更友好。
2. 训练之前,数据集的准备工作决定了80%的效果
2.1 采集与标注:什么样的数据才算“能训练”的数据
很多人上手就急着敲训练命令,结果跑出来效果稀烂,第一反应是模型不行,实际上八成是数据不行。这个项目的经验是:训练之前的数据准备比调参重要得多,直接决定模型上限。
先说采集。杂草数据和猫狗数据不一样,它极度依赖拍摄环境。同一个品种在幼苗期、分蘖期、开花期外观完全不同,清晨带露水的叶子和中午暴晒的叶子颜色也差很多。当时准备数据集时,在一个试验田里分三天采集,分别是晴天上午、阴天下午、雨后不久,尽量把光照变化覆盖进去。如果只挑晴天中午拍,模型虽然训练时loss很好看,一换环境就废。
标注环节用的是LabelImg,工具本身不复杂,但有几条规则必须遵守:框要紧贴目标边缘,宁可稍微内收也不要往外扩太多,因为往外扩会把背景土壤带进来,干扰特征学习;重叠遮挡特别严重的样本直接放弃,不要硬标;每个类别的样本量尽量均衡,如果某类草特别少,就要额外补充采集,不能让模型产生类别偏见。
2.2 YOLO数据集目录结构与训练/验证集划分
数据标注完,还要整理成YOLO训练要求的格式。每张图片对应一个同名txt文件,txt里的每一行是:
class_id 中心点x坐标 中心点y坐标 目标宽度 目标高度这里的x、y、w、h都是相对图片宽高的归一化值,范围0到1,不是像素坐标。class_id从0开始。目录结构按下面这样组织:
weed_dataset/ ├── images/ │ ├── train/ │ └── val/ └── labels/ ├── train/ └── val/训练集和验证集的图片绝对不要有重叠,很多人图省事直接复制一遍,最后验证指标虚高,真实场景一测就露馅。我当时写了个简单脚本做随机划分,比例大概8:2,然后额外做了一层校验,专门检查两个目录下文件名是否有交集,这个检查花不了几分钟,但能避免一个非常隐蔽的坑。
还有一个容易忽略的校验:确认每张图片都有对应的标注文件,以及标注文件里的class_id没有超出实际类别总数。标注工具偶尔会漏生成文件,训练时它会直接跳过或报错,排查起来很头疼。数据最终整理完,我习惯会把这几个检查项写进一个脚本里,每次换数据集都跑一遍,省心很多。
数据增强方面,YOLO训练自带Mosaic、翻转、色彩抖动这些手段,用默认配置就行。不过有一点要提醒,Mosaic对杂草这种小目标密集场景有帮助,但增强比例如果过高,会把原本就小的草切得更碎,反而增加学习难度。
3. YOLOv12模型训练的完整过程与关键参数解读
3.1 环境搭建与训练命令
训练环境的版本组合建议直接照搬一套稳定组合。这次用的是Python 3.10,PyTorch 2.1系列,Ultralytics包直接pip安装最新版即可,它会自动拉取YOLOv12相关依赖。CUDA环境用11.8或12.1都行,只要PyTorch版本能对得上。
训练命令比想象中简单,Ultralytics封装得已经很完善了。不需要自己写训练循环,一行命令就能跑起来:
pip install ultralytics yolo detect train model=yolov12s.pt data=weed.yaml epochs=150 imgsz=640 batch=16 device=0其中weed.yaml是数据集配置文件,内容大致如下:
path: /data/weed_dataset train: images/train val: images/val nc: 3 names: ["broadleaf", "grass", "sedge"]几个关键参数值得展开说一下。model=yolov12s.pt用的是s这个体量的预训练权重,如果显存紧张可以换成yolov12n.pt,对应nano版本,速度快但精度略低。batch=16在16G显存左右的显卡上比较稳妥,显存小就把batch降到8甚至4,不要硬撑,OOM会直接中断训练。imgsz=640是输入分辨率,杂草目标小,分辨率低于640会导致小目标特征丢失太严重。
训练过程里还有个容易被忽略的参数是patience,它控制early stopping的等待轮数,默认是50。意思是如果连续50个epoch验证集指标没有提升,训练就自动停止。跑这类项目我习惯把patience设成25到30,省时间,也能防止后期过拟合。
3.2 训练过程怎么判断模型训练得好不好
训练启动后,不要傻等,要看几个核心指标。训练日志里每轮都会打印loss、精度、召回率、mAP50等数值。前期loss下降很快是正常的,大概前20个epoch就能看到明显趋势;中后期如果mAP50还在缓慢上升就保持观望,如果训练loss继续降但验证集loss开始反弹,那就是过拟合信号了。
判断一个杂草检测模型能不能用,我一般看两个数:mAP50达到0.5以上算能用,0.7以上算好用。mAP50-95是更严格的标准,对小目标尤其苛刻,前者达标的情况下后者如果也能稳定在0.45以上,这个模型在田间场景就基本值得信任了。
训练结束会生成best.pt和last.pt两个权重文件,best.pt是根据验证集指标自动选出的最优模型,实际部署和推理都用它,不要手滑拿成last.pt。我当时第一版就是没注意,用最后一轮权重测试,感觉效果不对劲,折腾半天才发现问题。
3.3 模型导出与压缩:从best.pt到可直接部署的模型
训练好后,.pt格式的模型可以直接用Ultralytics接口推理,但对Web服务来说,导出成ONNX或TensorRT引擎格式会更合适。导出命令很直接:
yolo export model=best.pt format=onnx opset=12 dynamic=TrueONNX格式有两个好处:一是推理速度往往比PyTorch原生快一些,尤其在高并发场景;二是部署时不依赖完整的PyTorch环境,方便后续换设备。如果显卡支持,还可以再进一步导出成TensorRT引擎(format=engine),速度提升更明显,但导出时绑定显卡型号,换机器要重新导出。
导出之前建议先用model.predict跑一遍测试图片,确认输出正常,再考虑格式转换。因为我遇到过用best.pt推理正常,导出ONNX后输出张量解析出错的情况,排查下来是动态尺寸设置和预处理逻辑不一致导致的,先验证再导出能少踩一个坑。
模型体量选择上,我的建议是显卡允许就用s或m,追求实时性才用n。杂草检测场景里小目标多,nano模型速度快是快,但小目标召回率下降明显,省下来的那点延迟不值得。
4. UI界面和登录注册模块是怎么用Flask实现出来的
4.1 项目目录结构与整体业务流程
整个Web系统的目录结构,我按“入口文件、模板、静态资源、模型权重”四块来组织:
project/ ├── app.py ├── model.py ├── weights/ │ └── best.pt ├── templates/ │ ├── login.html │ ├── register.html │ └── index.html └── static/ ├── uploads/ └── css/核心业务流程分两条路径:未登录用户访问首页会先被重定向到登录页,注册、登录成功后跳转主页;主页提供图片上传功能,图片传到后端,调用模型推理,把检测结果画框后返回前端展示。
这个流程看上去简单,真正实现的时候有不少细节。比如推理函数不要直接写在路由函数里,单独抽一个model.py模块来封装,一方面代码清晰,另一方面方便在其他脚本里单独测试模型效果,不用启动整个Web服务。
4.2 登录注册模块的实现要点
登录注册看似一个老生常谈的模块,但实现时几个点必须注意。第一是密码不能明文存储,第二是会话要保持。Flask生态里用Werkzeug自带的密码哈希最省事:
from werkzeug.security import generate_password_hash, check_password_hash # 注册时 password_hash = generate_password_hash(password) # 登录时 check_password_hash(user.password_hash, password)用户表用SQLite就够,不需要上MySQL。表结构简单明了:
CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now', 'localtime')) )注册时除了校验两次密码一致,还要检查用户名是否已存在,因为数据库给username加了UNIQUE约束,重复插入会抛异常,与其让异常冒出来,不如在业务逻辑里提前拦截,给用户一个友好提示。
登录成功后用session保存用户ID,然后给需要权限的页面加一个装饰器:
from functools import wraps def login_required(f): @wraps(f) def wrapper(*args, **kwargs): if not session.get("user_id"): return redirect(url_for("login")) return f(*args, **kwargs) return wrapper这个装饰器加在主页路由上,比在每个页面里手写判断干净得多。还要记得设置secret_key,不然后端签名session时会报错。
4.3 上传检测与结果展示模块
图片上传和检测推理是系统的核心功能。路由接收前端上传的文件,读取文件内容后,调用模型推理接口。核心代码可以这样写:
from PIL import Image from ultralytics import YOLO import base64, io model = YOLO("weights/best.pt") def predict_base64(image_bytes): img = Image.open(io.BytesIO(image_bytes)).convert("RGB") results = model.predict(source=img, conf=0.35, iou=0.45, verbose=False) annotated = results[0].plot() # 转成base64字符串返回,方便前端直接展示 buf = io.BytesIO() annotated.save(buf, format="JPEG") return base64.b64encode(buf.getvalue()).decode()几个特别注意的地方。model要放在函数外面做全局加载,如果每次请求都重新加载一次模型,几秒钟就过去了,用户体验极差,GPU显存也会被反复申请释放。conf=0.35是置信度阈值,杂草检测场景宁可稍微低一点,避免漏检,但也不能太低,否则背景噪声全被框进来。当时调参测试时,0.35算是一个比较均衡的取值,如果实际应用场景对假阳性容忍度低,可以调高到0.45甚至0.5。
前端页面接收base64字符串,在HTML里用img标签的src属性直接展示,不用保存文件到磁盘,省去了临时文件清理的麻烦。不过如果检测的图片特别多,内存压力会大,那时再改成存文件、返回文件路径也不迟。
5. 项目从“能跑”到“好用”:我实际遇到过的几个问题
5.1 训练阶段的显存不足与标注错位问题
训练阶段碰到的第一个坑是显存不足。我一开始把batch设成32,想着数据量不大,应该没问题,结果跑了几十个step就OOM。后来把batch降到16,同时开启了梯度累积才稳定跑完。当时还有一个隐性诱因:图像分辨率虽然是640,但如果数据集里混着特别大的原图,预处理时的显存开销会临时放大,所以最好在整理数据时统一把图片缩放到接近训练尺寸。
第二个坑是标注类别编号错位。我数据集的三个类别,在weed.yaml里写的顺序是broadleaf、grass、sedge,但有一批标注文件在早期是用另一套类别顺序标出来的,训练时loss一直下不去,mAP卡在0.3左右。花了大半天排查,最后发现是部分txt文件里的class_id和names列表对不上。建议训练机上放一个类别ID与名称的对照表,标注阶段就要固定下来,中途不要改顺序。
5.2 Web端部署的首帧延迟、并发问题与中文显示
Web端的坑集中在部署阶段。第一个是首帧推理非常慢。因为模型加载是在服务启动后第一次请求时惰性执行,第一次请求要等好几秒。解决办法也很简单,服务启动函数里主动调用一次model.predict做预热,把模型加载和CUDA上下文初始化的时间提前消耗掉,真实用户访问时体感就正常了。
第二个是并发请求可能导致显存峰值溢出。YOLO推理本身是线程安全的,但并发请求同时进到GPU推理时,显存占用会叠加。为了稳定,我在推理接口外面加了一个线程锁,把推理过程串行化,请求量大时排队处理,而不是同时挤爆显存:
import threading infer_lock = threading.Lock() @app.route("/predict", methods=["POST"]) def predict(): if not infer_lock.acquire(blocking=True): ... try: ... finally: infer_lock.release()第三个问题不大但很烦,就是画完框的图片上,中文标签显示成乱码。Ultralytics的plot()方法默认字体不支持中文,如果给每个类别起的是中文名,跑出来图片上全是方框。方案是把类别名全部改成英文,或者用PIL手动指定中文字体文件再重新绘制。项目里图省事直接用了英文类别名,前端展示时再做一个中文映射。
最后还有一个小优化,提升了实际使用体验:上传的图片如果是手机拍的,动辄三四千像素的大图,直接送进模型推理,预处理耗时明显增加,而且大图中的小目标会被等比缩放,识别率未必更好。我加了一步预处理,超过1600px的图片先等比缩到1600以内再送模型,推理速度提升明显,检测精度没有明显下降。这个操作对生产环境的系统部署很有参考价值,尤其是当用户的输入不可控时,后端一定不能想当然地认为输入尺寸固定。
复现这个项目,我给你的建议顺序
如果你现在正准备复现这个项目,我给一个实用的操作顺序,按这个顺序来能少走不少弯路。第一步是花最多时间整理数据集,宁可只有几百张高质量标注图,也不要几千张标得很随意的图;第二步是用默认参数先跑一版模型,拿到一个baseline,确认模型能正常收敛,再开始调优和做Web界面;第三步是训练和Web并行推进时,先把推理函数独立封装好,用脚本直接测,不要边写Web边调模型,两件事搅在一起会非常痛苦。最后,所有训练日志、类别映射表、数据集校验脚本都要留存好,这类农业视觉项目随时可能要改类别、加数据,没有这些基础文件就得重新来过。
这个项目后续还可以往两个方向扩展。一个是把检测结果直接对接模拟喷洒装置,做成值机联动;另一个是把前端做成前后端分离,方便部署到平板或者手机浏览器上。但不管怎么扩展,核心思路都是一样的:数据、模型、界面三层解耦,每一层都能独立替换升级。