无人机拍回来的农田影像堆满了硬盘,真正让人熬夜的不是飞行,而是怎么把每一棵杂草从作物中间抠出来。最近我完整复盘了一个基于深度学习UNET算法的农田图像分割项目,用自建的UNET网络实现作物与杂草的像素级分类,顺带把杂草分割检测一并解决了。这个项目的数据集和代码都是自己整理的,整条链路从影像采集、数据标注、网络搭建到训练调参、大图推理拼接走了一遍,整个过程花了大概三周,踩了不少坑,也沉淀出一些能直接用起来的方法。如果你正准备入门语义分割,想找一个比街景、医疗影像更有落地感的练手方向;或者你在做农业AI、植保无人机相关产品,需要一个能跑通的基线方案——这篇复盘应该都能帮上忙。
1. 项目整体设计:目标、路线和模型选型
1.1 先想清楚要什么:分类、检测还是分割
在动手写代码之前,我花了一整晚想一个问题:这个项目到底要输出什么?很多朋友听到“杂草识别”,第一反应就是上检测模型,在图像上画框标出每株杂草的位置。但农田场景对检测框非常不友好——杂草不是独立物体,经常是一丛一丛贴着作物长,框会同时框进作物叶片和裸露土壤,后续根本无法指导机械除草或变量喷洒。如果你只需要知道“这块田有没有草”,那图像分类就够了,但分类对植保作业没有操作价值,它说不清楚草在哪、密度多大、面积占比多少。
真正有用的是像素级图像分割。每个像素都被赋予一个类别——背景、作物或杂草。这样杂草的位置、面积、密度就能精确定量,下游无论做精准喷雾还是机械除草,都有据可依。回头看我当初的需求,本质上不是“识别出杂草”,而是“告诉机器除草剂该喷哪里”,所以任务形态从一开始就应该选分割,而不是检测或分类。这个决定直接影响后面所有数据标注方式和网络结构选型,提前想清楚能省掉至少一周的返工时间。
1.2 UNET为什么是我最终的选择
语义分割的模型池子不小,FCN是开山祖师,DeepLab系列在街景分割上表现强势,SegNet也常用于自动驾驶场景。但在无人机农田影像这个特定场景里,UNET是性价比最高的选择,原因有三条。
第一,小数据集友好。农业图像标注非常贵,一个熟练标注员一天能标几十张512×512的图已经很不错。UNET的编码器-解码器结构加上跳跃连接,天然适合从有限样本里学习。它的空域先验比纯Transformer类模型强得多,不需要海量预训练数据就能达到可用精度。
第二,跳跃连接对细小目标和边界非常关键。杂草叶片细长,边缘复杂,经过五次下采样之后在深层特征图里基本糊成一团。UNET把编码器每一层的空间信息直接接到解码器对应层,浅层的高分辨率特征能帮上大忙,最后分割出来的草丛轮廓明显更干净。这一点在做对比实验时看得特别清楚——去掉跳跃连接后,杂草IoU直接掉了将近10个点。
第三,实现简单,适合自建。UNET没有诡异的模块,就是一个编码器加解码器加跳跃连接的骨架,自己手写一遍forward不到60行代码。调试也方便,想改通道数、换空洞卷积、加注意力模块都很顺手。对比SegNet,它虽然也有编码器-解码器结构,但下采样的位置信息传递不如UNET直接,对细长物体不占优势;FCN的上采样方式又太粗糙,训练出来的mask边缘锯齿感很强,不适合直接做精细化除草。
1.3 自建网络和套网库的权衡
网上有现成的分割工具库,两行命令就能加载预训练UNET,但这次我坚持把网络自己搭了一遍。原因不是“手写更高级”,而是三个很实际的问题。
一是可控性。项目代码要给同事复现,如果依赖某个第三方封装库,版本一更新接口就变,到时候光是debug环境就要耗掉半天。自己写UNET,所有依赖就是PyTorch和TorchVision,部署到哪都不会因为库版本问题出岔子。
二是可改性。农田场景里杂草尺度变化很大,有的刚出苗只有几个像素宽,有的长到十几公分高还带分枝。我需要在网络底层换更大的感受野,自建网络直接把底层几个普通卷积替换成空洞卷积堆叠,前后对比很直观。如果用现成网库,改这些结构会非常费劲,甚至得绕开高层API去改底层的forward逻辑。
三是理解深度。这句话听起来有点虚,但做工程的人都知道:用别人封装好的模型,出了bug只能看文档猜。自己写的网络,梯度爆了、激活死掉、拼接尺寸对不上,都能直接定位到代码行。这个项目里我正是靠这个能力快速找到了几个影响精度的关键问题,后面会详细展开。当然,如果你只是想做快速验证、完全不在意底层实现,用网库没有问题;但如果你想把这个项目作为模块持续迭代,自建是更值得走的路。
2. 数据没你想的那么简单:无人机农田影像的采集、标注与扩增
2.1 飞行参数和影像质量决定模型上限
这个项目的影像来自某植保团队用多旋翼无人机拍的试验田,拍摄高度大概在5到8米,地面分辨率约1到2厘米/像素。这个分辨率下,作物叶片的脉络纹理、杂草的形状细节都能清楚看到。但分辨率高也带来一个实际问题——原图尺寸非常大,单张动辄数千万甚至上亿像素,直接扔进网络训练是不可能的,必须要做切块预处理。
采集环节有几个关键参数直接决定模型能用多久。第一,拍摄高度必须和最终使用场景一致。训练用的是5米高度,实际作业时飞到30米,图像里看到的纹理完全不同,模型泛化会断崖式下跌。第二,尽量选择光照均匀的时间段拍摄。正午强光底下阴影会把杂草压得很暗,标注员都分不清边界,模型更学不会。我一般推荐上午9点到11点、下午3点到5点两个时段。第三,同一个地块要覆盖不同的作物生长阶段。草刚出苗和长到十几公分高,外观差异非常大,如果只标注了一个生长时期,模型换一个时期基本就是瞎的。数据采集阶段犯的懒,后面训练阶段都会加倍还回来。
2.2 统一标注规范:三重边界怎么定
图像分割数据集最核心的其实不是数量,而是标注规范和标注一致性。我这次用的是开源的LabelMe工具做多边形标注,把每一个像素划分为三类:背景(土壤、地膜、杂物)、作物(目标农作物)、杂草(所有非作物的草本植物)。选LabelMe的理由是JSON格式方便后处理,界面简单,几个标注员上手都很快。团队里也有用其他标注工具的,看个人习惯,关键是要能导出统一的格式。
标注规范层面我踩过一个大坑:杂草类目标的定义太模糊。有些杂草的幼苗和作物小苗在视觉上非常接近,标注员之间经常产生分歧,甚至需要拿着成株植物图鉴去对比。最后我定了一套硬性规则,每条都写进标注文档里:
- 根系明显长在非种植行上的,一律标为杂草;
- 作物种植行内混生的细长禾本科草,同样标为杂草;
- 枯叶、残株如果遮挡严重,归入背景;
- 边界上存在争议的像素,优先归入作物——误伤作物的代价比漏掉一点草边更大。
这套规则后来直接决定了模型的行为:宁可漏掉边界上一小圈草,也绝对不把作物当杂草切掉。在最终的验收测试里,这条规则帮我们躲过了好几个会被客户直接否掉的错误案例。没有统一标注规范的话,训练数据里出现互相矛盾的标签,模型会学得非常挣扎。
2.3 滑窗切块与数据增强:涨点最明显的一步
原图太大,训练前必须要切块。我用滑窗裁剪的方式,固定窗口512×512,步长256,也就是50%重叠的滑动采样策略。为什么用重叠而不是不重叠?因为农田目标在图像上分布不均,如果步长正好等于窗口大小,会漏掉很多跨边界的结构;重叠采样相当于做了一次位置级别的增强。切完块之后还要做过滤——杂草和作物像素占比低于总像素1%的图块直接丢掉,避免大量纯背景样本拖慢训练节奏。
数据增强是提升泛化能力性价比最高的一步,我按重要性排一个顺序:
- 随机旋转90度倍数和水平/垂直翻转,这是最基础的几何增广。注意不能做任意角度的旋转,因为农田垄行结构有方向性,随便旋转反而会引入错误先验;
- 颜色抖动,亮度、对比度、饱和度各自±20%,用来模拟不同光照条件;
- HSV色相小幅偏移,幅度不能太大,否则会把绿叶变得不像植物;
- 局部裁剪加缩放,模拟无人机飞行高度微调时的尺度变化。
整套流程下来,原始4330张512×512的切块在在线增强后相当于每轮训练都能看到2万多张新图。效果立竿见影——验证集mIoU从0.69直接升到0.85附近。这个提升幅度超过我在模型结构上做的任何改动,也再次印证了一个老结论:对中小规模数据集,先把数据管好,再谈调模型。
3. 自建UNET实现与训练调参全记录
3.1 网络结构设计:编码器、解码器与跳跃连接
自建UNET以原始论文结构为主干,我做了一些面向无人机影像的调整,下面把结构逐层说清楚,方便直接照着搭。
编码器采用4个stage,每个stage先做两个3×3卷积加ReLU,再接2×2最大池化下采样。通道数从浅到深是64→128→256→512。池化会让特征图尺寸减半,所以最后得到的瓶颈层特征只有32×32分辨率。在瓶颈位置,我加了一层空洞卷积,空洞率设为2和4,用来扩大感受野又不额外丢失分辨率——这对杂草这种小目标很关键,因为杂草叶片在32×32特征图上可能只有一两个像素宽。
解码器侧,每个stage先做2倍上采样。这里我用双线性插值加3×3卷积做refine,而不是直接转置卷积,因为转置卷积容易引入棋盘状噪声。上采样之后,把对应编码器的特征拿过来沿着通道维度拼接,拼接后连续两个3×3卷积融合特征。最后用1×1卷积把通道数降到3,接Softmax得到背景、作物、杂草三类概率。
整个网络参数量约2800万,输入输出都是512×512×3到512×512×3,单张12G显存显卡跑batch=16完全没问题。如果显存紧张,可以把编码器第3、4层的通道数从256/512砍半,精度损失很小但显存能省下约30%。后续要部署到机载嵌入式平台的话,我建议直接用更小的32-64-128-256四层版本。
跳跃连接有两点需要注意:拼接前的编码器特征最好过一层BatchNorm对齐分布,否则两边的数据量纲不一致会影响训练稳定性;另外如果显存不够,除了砍通道,也可以把跳跃连接改成1×1卷积先降维再拼接,能省不少显存。
3.2 损失函数与评价指标:不是所有loss都适合杂草
训练用了组合损失,CrossEntropyLoss加DiceLoss,按比例6:4混合。为什么这么配?分类交叉熵逐像素独立计算,梯度信息丰富,收敛比较稳定;Dice损失直接优化类别区域的交并比,对类别不平衡天然不敏感。农田数据里杂草面积可能只占整张图的5%到15%,单独用交叉熵的话,模型把杂草全预测成背景,loss也并不会涨多少,加上Dice之后每一块杂草区域的误差都会被明显放大,模型不得不去追那些小块的真实区域。
类别权重也不能省。我在交叉熵里给杂草类别手动加了2倍权重,背景设0.6,作物设1.0,进一步压制模型偷懒的倾向。我也试过纯Focal Loss,效果并没有比CE+Dice好,调参还麻烦,不建议新手一上来就用Focal。
评价指标重点看mIoU,也就是所有类别IoU的平均值。IoU的计算公式是TP除以TP加FP加FN,简单理解就是预测掩膜和真实掩膜的并集里,交集占多大比例。Pixel Accuracy这个指标在农田场景里没有意义——背景通常占七成以上,全猜成背景也能有70%的准确率,什么都说明不了。除了mIoU,我还会单独盯杂草类别的IoU和Recall,这才是整个项目的核心。杂草漏检意味着这株草不会被喷洒到,后面会长起来和作物抢养分;而误检多喷一点除草剂,损失相对可控。所以落地时我对杂草Recall的要求非常严格,哪怕杂草IoU不是三类里最高的,Recall也必须拉到90%以上。
3.3 超参数配置与训练过程实录
直接放一套可以复现的训练配置,这是我项目里最终跑通的方案:
- 优化器:AdamW,learning rate = 1e-4,weight decay = 1e-4
- 学习率调度:CosineAnnealingLR,T_max = 50
- Batch size:16,单张12G显存GPU
- Epoch:60,前5轮线性warmup,从1e-6升到1e-4
- 训练/验证划分:8:2,按地块随机分,验证集的地块不参与训练,用来测泛化能力
- 混合精度:开启AMP训练,显存占用下降约40%,速度提升明显,但要注意grad scaler是否正常工作
训练过程我记录了关键节点,大致是这样变化的。第1轮结束,训练loss约0.91,验证mIoU只有0.22,基本等于乱猜。第10轮训练loss降到0.66,验证mIoU升到0.61,模型已经能把大块的背景和作物区分开,但杂草区域还是和背景混在一起。第25轮训练loss约0.51,验证mIoU到0.78,杂草IoU开始冒头到0.51,模型已经勉强勾出杂草区域的轮廓了。第45轮验证mIoU到达0.86,杂草IoU到0.72,验证loss基本平稳。第55轮以后指标不再明显变化,我在第58轮做了早停。
最终在测试集上的结果:
| 指标 | 数值 |
|---|---|
| mIoU | 0.862 |
| 作物IoU | 0.913 |
| 杂草IoU | 0.754 |
| 像素准确率 | 0.927 |
| 杂草Recall | 0.906 |
这个结果对一套完全自建网络加自制数据来说,已经能拿去和客户谈需求了。调参过程中的心得有三条:初始学习率试过3e-4,模型涨到0.85之后开始震荡,降到1e-4才稳住;warmup真的有用,数据量不大的时候能避免早期训练把特征搞炸;验证集loss如果开始反弹,不要急着加数据,先把学习率减半观察两个epoch,很多时候只是学习率过大导致的震荡。
4. 从训练到落地:推理拼接、常见问题与进阶技巧
4.1 大图推理拼接:窗口步长与重叠区域怎么选
模型训练好之后,另一个现实问题来了:实际无人机拍的原图是6000×4000这种级别,没法直接像512×512那样喂给网络。我的做法是推理阶段也走滑窗,窗口仍然用512×512,但步长设置成384,也就是相邻窗口有128像素的重叠。重叠区域的预测概率取两次预测的平均值,这样能削平窗口边缘产生的接缝噪声,整个mask看起来更自然。
推理阶段要注意两个容易忽略的细节。第一,必须设置model.eval()并关闭梯度,用torch.no_grad()包裹。BatchNorm和Dropout在train和eval两种模式下行为完全不同,我遇到过不止一次因为忘记切换eval导致指标忽高忽低,排查半天才发现是模式没切。第二,如果批量处理几十张大图,建议用多进程把图像切成瓦片、分片推理再拼接,比一张张串行跑能节省非常多时间。用一张12G显存的显卡,单张6000×4000原图在滑窗推理下大约需要30到50秒,批量场景下完全够用。
如果你还要进一步提升速度,下一步可以做ONNX导出,再用TensorRT做半精度推理,配合小通道版本UNET,速度能提升到接近实时。大图推理拼接这块优化的空间很大,值得提前规划。
4.2 实战高频问题排查速查表
整个项目推进过程中踩过不少坑,也帮身边朋友排查过类似问题,我整理成了下面的速查表,遇到症状直接对着找原因:
| 现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 训练loss不下降 | 学习率过大或标签错位 | 可视化一张训练输入和对应mask | 降低lr到1e-5,检查DataLoader的索引是否错位 |
| 验证mIoU远低于训练集 | 过拟合或验证集分布差异 | 对比两边图像的亮度、地块差异 | 加强颜色抖动、添加Dropout、确保验证集独立 |
| 杂草几乎全漏检 | 类别权重太低或样本太少 | 单独统计杂草类别的Recall | 把交叉熵的杂草权重从1提高到2到3,对杂草区域做CutMix增强 |
| 预测mask边缘颗粒感重 | 上采样方式太粗糙 | 直接观察网络输出 | 把转置卷积替换为双线性插值,或加CRF后处理 |
| 训练时显存OOM | 输入太大或batch太大 | 查看GPU显存占用曲线 | 开启AMP混合精度、减小batch、编码器通道减半 |
| 换一个地块效果骤降 | 拍摄条件差异大 | 检查两张图的色调直方图 | 做风格增强,或多个地块数据按比例混合训练 |
| 推理速度太慢 | 模型参数量大、计算图冗余 | 单独测单次推理耗时 | 导出ONNX、半精度推理、用小版本模型 |
| 分割结果忽好忽坏 | 推理窗口重叠策略不对或没切eval | 看预测置信度图 | 重叠区域取平均置信度、确认eval模式 |
这表里最常被忽视的是最后一行:分割结果忽好忽坏,尤其在批量推理时非常迷惑人。后来定位到的原因大部分都是窗口拼接策略不一致,或者模型还在train模式,开关一开一关指标就差了好几个点。
4.3 提升杂草分割精度的三个进阶技巧
先讲伪标签迭代。农业数据的标注成本极高,但我手头有大量完全没标注的航拍图。做法很简单:用已经训练好的模型去预测这些无标注图,取预测置信度高的区域——比如Softmax概率超过0.95的像素——直接当作伪标签加入训练集。这一步能再把杂草类别的表现往上推一推,mIoU能到0.88左右。但一定要设置置信度阈值,否则模型会把自身错误当成标准答案反馈回来,越练越偏。
然后是测试时增强TTA。推理时把图像水平翻转、垂直翻转各预测一次,再把三次结果取平均。简单、有效,单图耗时增加两倍,但mIoU通常能上涨1到2个百分点。我一般只在客户演示和提交报告时开TTA,批量部署时关掉,灵活切换就好。
最后分享一个常用的两阶段训练策略:先用背景和植物二分类把问题简化,让网络学习“绿色像素与土地像素的边界”这个通用能力;然后冻结编码器,只微调解码器,让它细分作物和杂草。这个思路尤其适合杂草小目标,因为网络先学会了分割植物的能力,再学叶片纹理差异,比直接端到端训练更稳。最后配合形态学后处理——先做开运算去掉零散噪点,再做闭运算填充内部空洞,视觉效果会干净很多。
我个人实际操作中的最大体感是:这个项目的成败,模型结构只占很小一部分。数据规范性、标注口径、增广策略和训练细节叠加起来的作用,远比换一个更大更花哨的网络明显。如果你手上也正好有无人机影像,想用UNET做农田分割,最该花时间的地方是先把自己那批图像的标注口径整理干净。数据整理扎实了,网络这边按上面的配置基本能一次跑通。后续如果还想深挖,可以继续沿着伪标签迭代和轻量化部署两条线走,每一步的收益都是看得见摸得着的。