这台无人机升空的时候,底下的麦田绿得发亮,可我在屏幕前盯着的却是那些混在麦垄里的杂草。无人机农田图像分割这个需求,这几年从“看着很酷”变成了“真得做”:村里的植保队想知道的不是“这块地有没有草”,而是“草到底在哪些位置、每片面积多大”,这样才能让打药车精确对准杂草区域,而不是整片地无差别扫一遍。我基于一套自建UNET网络做完整流程后,整理一下这个项目从数据到推理的全部细节——把作物与杂草分类做成像素级分割结果,实现杂草识别与杂草分割检测,同时把数据集和代码的获取逻辑也一并说明白。
1. 为什么“分清楚作物和杂草”比“识别出杂草”更重要
1.1 一个分割任务背后的真实业务诉求
先看一个场景:一片200亩的小麦田,无人机带多光谱或可见光相机拍一圈,得到一张巨大正射影像。传统做法是找几个取样点人工数草,然后估计全田杂草密度。但杂草从来不是均匀分布,田头、沟边、灌水口附近的杂草密度能高出中间好几倍。如果只给植保队一个“整体有草”的结论,他们没法决定喷多少药——宁可保守一点整片喷,浪费农药不说,还可能伤作物。
所以业务端真正要的是热力图——一张和地图对齐的“哪些像素是杂草、哪些像素是作物”的逐点分类结果。这种逐像素分类就是图像分割。无人机农田图像分割落地后,变量喷洒系统拿到这张图,可以只在杂草密度超过阈值的网格里开启对应喷头,这才是这套项目最大的价值所在。
1.2 为什么二分类分割在这个场景里够用
有些朋友一上来就问:能不能做几十个类别,把不同杂草种类也分开?我的建议是第一步别这么做。田间数据标注成本极高,杂草种类又容易因为生长期变化长得很相似,强行分细类会导致标注不一致、模型学习混乱。项目代码里默认的设定就是三个语义类别:作物、杂草、背景(土壤/残茬/其他)。背景一定要有,否则模型会把没长作物的土地也硬归进某分类,后面统计杂草覆盖率时会虚假偏高。
“自建UNET”的方向也与此匹配:小类别数、中等分辨率输入、边缘几何特征重要,这种任务恰好是UNET擅长的。至于网上能搜到的很多现成预训练模型,大多是针对城市街景或医学影像训练的,在农田影像上直接迁移效果很差,因为作物行、阴影、土壤纹理这些底层特征和城市斑块差异太大。所以“自建”不是情怀,是务实。
2. UNET能啃下农田图像的底层结构
2.1 先压缩再还原:编码器在做什么
讲UNET之前先想想一个问题:为什么不用普通CNN把图分类完事?因为分类网络最后输出的是“这张图有没有杂草”,但我们需要的是“杂草在哪个像素”。这条路必须让网络在输出端恢复出和输入一样宽高的像素级判断。
UNET的结构是标准的“编码-解码”对称结构。编码器部分通过连续卷积和最大池化逐步把512×512的图压缩到32×32甚至16×16,通道数翻倍、空间分辨率降低。这一步的本质是扩大感受野——每个高层特征对应大范围的上下文。农田图像里,单看一个像素很难判断它是不是杂草,但当网络看到周围的作物行走向、土壤纹理、阴影边界后,判断就准确很多。所以编码器做得越深,越能理解“这块区域是田里的一块杂草斑块”这种全局信息。
2.2 逐级跳连:像素级定位的关键
解码器负责把压缩后的特征逐步采样回原尺寸,但它面临一个致命问题:上采样过程会丢失精细边界。单靠高层语义信息还原出的分割图,经常出现“杂草边缘糊成一团”“细长杂草茎断裂”的情况。UNET的skip connection(跳连)就是专门解决这个问题的——每一层编码器的输出会直接叠加到对应层解码器输入上,相当于把高分辨率的低层特征和低分辨率的高层语义融合。
打个比方:高层特征告诉你“这里面有一片草”,低层特征告诉你“草的边缘像素具体在哪几个点”。跳连把两种信息拼在一起,网络既能看清轮廓,又不会迷失大对象。农田分割里,作物行的边界、单株杂草的叶片轮廓都靠这部分保证精度,实验里把跳连去掉后mIoU会肉眼可见地下降百分之七八,毫无悬念。
2.3 为什么不直接上Transformer
确实,现在有Swin-Unet、SegFormer这些更强模型,但在我这个项目里最终仍然选择自建轻量UNET,理由有三个:
- 训练数据量:几千张切片配FP16训练,UNET几十轮就能收敛,Transformer动辄要更大数据量才能体现出优势。
- 推理部署:农田无人机一天拍上万张影像,工程师不会只在家里跑漂亮指标,要拿到嵌入设备上跑。UNET参数量小,INT8量化后依旧稳。
- 噪声鲁棒性:田间图像的纹理其实有一定重复性,UNET归纳偏置够用,复杂注意力机制在这类任务上提升有限。
如果你偏要试试更复杂的结构,可以用UNET++替换骨架,也能跑通。但对于第一次做这个项目、且目标是“快速拿到第一版可用结果”的人来说,标准自建UNET是最稳的起跑线。
3. 自建数据集:最硬的一块骨头
3.1 采集参数与影像预处理
代码可以一天写完,但数据要跑田里。我建议第一步确定飞行高度和相机焦距的关系——地面采样间隔GSD严格由高度、焦距、像元尺寸决定。工程上用简式估算,一条近似:GSD = 单像元尺寸 × 飞行高度 ÷ 焦距。我自己的项目设置扫描高度50m左右,得到GSD大约2cm/像素,也就是说每个像素对应2厘米见方的地面,单株杂草能看清叶片轮廓。
别贪高分辨率。GSD低于1cm意味着同样面积需要四倍切片数量,标注成本爆炸,而且无人机晃动带来的重影会更明显。以下是一组常用的经验值:
| 飞行高度 | GSD约 | 适用场景 |
|---|---|---|
| 30m | 1-1.5cm | 苗期小杂草识别,单株级标注 |
| 50m | 2-3cm | 主要推荐,分割精度与效率平衡 |
| 80m | 3-5cm | 大范围巡田,只找斑块级杂草 |
航线规划一定保证旁向重叠率80%以上,航向重叠率75%以上。数据回来后会拼成正射影像图,重叠率不足会导致拼接位置错位,一片叶子的边缘被拆成两半,标注时你会崩溃。拼接工具就选常用开源拼图流程,RTK定位信息好的话基本能直接对齐。
3.2 类别怎么标、用什么标
标注意见分歧是项目里最常见的坑。两个人标同一块区域,一个把黄化小麦标成杂草,另一个标成作物,模型就会学到混乱边界。我在项目文档里特别强调标注规则:
- 杂草 = 非目标作物的单子叶、双子叶植物,不管它长什么样;
- 作物 = 本次目标作物,苗期黄化、倒伏、病斑都算作物;
- 背景 = 土壤、水源、残茬、石子、无人机阴影,以及一切看不清的东西。
标注工具我用开源LabelMe,导出的JSON转成多边形掩膜再栅格化,整个链路很成熟。工作量做个体感参考:512×512切片标一张,单类纯净的田块最快五分钟,杂草密集斑块可能拖到二十分钟。一个基础数据集至少要标350到500张切片,建议两个人并行标注后交叉抽检。
3.3 切片和增强:把小图变成训练集
正射影像是巨图,宽高常有几万像素,直接喂UNET会把显存撑爆,网络也学不到细节。标准做法是滑窗切出512×512或640×640的小块,步长取512(不重叠),并把切割结果导出成RGB图和掩膜图一一对应。
我要特别提醒:切割时不采样,直接生成所有切片,然后按比例筛选。很多切片整块都是背景,训练会让模型沉迷输出“全背景”,所以要做类别的粗筛,保证训练集里每类占比都别太低。数据增强方面,我只用了随机水平翻转、垂直翻转、90度旋转、亮度抖动、轻微缩放。别用强颜色扰动,因为多光谱和可见光拼接影像的颜色一致性很重要,强扰动反而会让模型学到错误颜色关联。
| 增强策略 | 是否使用 | 原因 |
|---|---|---|
| 翻转/旋转 | 使用 | 农田方向无固定语义 |
| 亮度抖动 | 使用 | 覆盖日照变化 |
| 随机裁剪缩放 | 使用 | 模拟不同GSD |
| 重度色彩扰动 | 不用 | 破坏作物/杂草颜色先验 |
4. 自建UNET网络的核心实现与训练
4.1 网络主体代码拆解
自建UNET不依赖现成预训练库,核心代码可以很精简。基础模块是两次卷积加批量归一化的双卷积结构,编码器和解码器对称堆叠。下面是我项目里用的一版核心骨架:
import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels=3, num_classes=3, base=64): super().__init__() # 编码器 self.e1 = DoubleConv(in_channels, base) self.pool1 = nn.MaxPool2d(2) self.e2 = DoubleConv(base, base * 2) self.pool2 = nn.MaxPool2d(2) self.e3 = DoubleConv(base * 2, base * 4) self.pool3 = nn.MaxPool2d(2) self.e4 = DoubleConv(base * 4, base * 8) self.pool4 = nn.MaxPool2d(2) # 瓶颈 self.bottleneck = DoubleConv(base * 8, base * 16) # 解码器 self.up4 = nn.ConvTranspose2d(base * 16, base * 8, 2, stride=2) self.d4 = DoubleConv(base * 16, base * 8) self.up3 = nn.ConvTranspose2d(base * 8, base * 4, 2, stride=2) self.d3 = DoubleConv(base * 8, base * 4) self.up2 = nn.ConvTranspose2d(base * 4, base * 2, 2, stride=2) self.d2 = DoubleConv(base * 4, base * 2) self.up1 = nn.ConvTranspose2d(base * 2, base, 2, stride=2) self.d1 = DoubleConv(base * 2, base) # 输出 self.out = nn.Conv2d(base, num_classes, 1) def forward(self, x): # 编码 c1 = self.e1(x) x = self.pool1(c1) c2 = self.e2(x) x = self.pool2(c2) c3 = self.e3(x) x = self.pool3(c3) c4 = self.e4(x) x = self.pool4(c4) # 瓶颈 x = self.bottleneck(x) # 解码 x = self.up4(x) x = torch.cat([x, c4], dim=1) x = self.d4(x) x = self.up3(x) x = torch.cat([x, c3], dim=1) x = self.d3(x) x = self.up2(x) x = torch.cat([x, c2], dim=1) x = self.d2(x) x = self.up1(x) x = torch.cat([x, c1], dim=1) x = self.d1(x) return self.out(x)这套代码里,base控制基础通道数,我建议保持64。通道数翻倍太猛模型会重,推理速度下滑,对效果提升很有限。输入只接受3通道RGB,如果你有多光谱数据(比如多出来的近红外通道),可以把in_channels改成4或5,但要同步调整编码器第一层卷积结构,别直接硬改。
4.2 损失函数与评价指标怎么选
很多第一次跑分割项目的人直接用CrossEntropyLoss,但对农田场景来说,杂草和背景数量经常严重不平衡。我用的损失组合是Sigmoid多类二值化加Dice损失,配合少量交叉熵:
class DiceLoss(nn.Module): def __init__(self, smooth=1e-6): super().__init__() self.smooth = smooth def forward(self, logits, targets): probs = torch.sigmoid(logits) num = 2 * (probs * targets).sum() + self.smooth den = probs.sum() + targets.sum() + self.smooth return 1 - num / den多分类时需要把目标转成one-hot格式,对每个类别算一次Dice。总损失我写成0.6 * CrossEntropy + 0.4 * DiceLoss。Dice损失直接优化交并比,对小目标杂草更友好;交叉熵保持梯度稳定,两者搭配下来收敛速度比纯Dice更快。
评价指标别只盯着mIoU。实战里还要看按类别的IoU和Recall。业务上更关心的是“杂草”这一类的Recall——漏掉一片草地会导致处方图出现空白,宁可在边界多圈一点,也不能漏。我的训练记录里mIoU从初期不到0.5,40轮后稳定在0.85左右,其中杂草类IoU在0.62到0.7之间,这个数字在上大图推理后还会降一点,所以训练阶段指标不要看着好就停,要留出推理损耗余量。
4.3 训练参数和调参经验
输入分辨率统一512×512,batch size看显存,我常用8到12之间。优化器用Adam,初始学习率1e-4,配合CosineAnnealing或者ReduceLROnPlateau都行。总体上30到50轮就能看到结果平台期。显存不够时优先调batch size,而不是砍图片尺寸——输入降到256会让杂草细长结构直接消失。
我踩过的坑是BN层的batch size太小导致训练震荡。batch size掉到4以下时,把BatchNorm换成GroupNorm会稳定很多。另外保存模型别只存最后一代,要按验证集IoU存best模型,因为最后一代经常过拟合在训练集的高频细节上,验证集反而退了。训练日志里的train loss看着很低但验证IoU涨不动,基本就是过拟合信号,先降低学习率,再不行才增加标注数据量。
5. 从单图到整片田:推理阶段真正的技术活
5.1 滑动窗口推理的缝合问题
模型在512到640的小图上训练,而正射影像可能是24000×32000像素的巨图,这一步是项目是否能落地的试金石。直接把整图缩放后丢进模型会丢失小目标杂草,不可取。我用的是滑动窗口推理:以1280×1280窗口、1024步长裁切,模型按patch逐块预测,再把结果拼回完整掩膜。
有人会问:训练用512,推理为何用1280?因为推理时显存余量更大,窗口大一些可以减少上下文割裂。但窗口也不能无限大,窗口超过模型感受野时边缘伪影显著剧增。这里有一个必要操作:相邻窗口必须有重叠区域,重叠区域取模型置信度最高或加权平均的预测结果,否则窗口边界会出现一条条明显接缝。我通常设重叠128像素,然后再做中值滤波抹平接缝。
5.2 分割结果的后处理和输出形式
模型输出的原始概率图不能直接当成果交付。第一步对每个像素取argmax得到三类标签图,然后做形态学后处理:小面积孤立区域认定为噪声直接删除,大面积连通的杂草区域保留。这一步很重要,因为无人机影像上经常有石块、农机轮胎痕迹被误判为杂草,形态学开运算能有效消除小噪点。
输出形式建议两种都要有:一是PNG格式的分类彩色图,方便在GIS软件里叠加查看;二是GeoJSON矢量边界,直接能给植保机后台按地块下指令。从栅格转矢量时,我用的简化容差设0.5米,太细碎的多边形会被简化掉,太粗会把整块田的边界弄丢。生成出来的杂草斑块面积统计要按地理坐标真实面积计算,而不是按像素数乘个粗略比例,否则地块边缘的畸变会让面积误差放大。
5.3 部署到机载或地面推理设备
训练完的PyTorch模型直接上机效率不够。我一般先把模型转ONNX,再做FP16或INT8量化,量化后模型体积缩小四倍,推理速度提升明显。转换时注意把argmax和softmax留在后处理端,不要混进计算图,否则量化时数值抖动会放大。
机载实时识别和地面后处理是两条不同路线:实时喷洒需要模型跑在带GPU的嵌入式设备上,对单帧延迟敏感,512输入下大概要求延迟低于100ms;地面批量出图则直接使用服务器或高性能工作站,一分钟出几百张图都不怕。实际项目里这两种都要准备,代码里保留了一个统一的推理接口,模型文件切换即可,这比临时改代码省事得多。
6. 实测中的隐藏坑与业务启示
6.1 最常翻车的是阴影和露水
模型在测试集上表现很好,一上真实影像立刻翻车的案例我见过不少。最大原因是无人机拍摄时间不统一:中午拍的图阴影范围小,清晨拍的图作物阴影长,阴影区域的颜色分布和杂草很接近,模型很容易把阴影里的小杂草漏掉或者把阴影边界的背景误判成杂草。解决思路有二:训练数据里专门保留一部分有长阴影的采样;推理时如果可以,统一选择上午十点到下午两点之间拍摄。
另一个翻车点是露水。清晨麦田带着露水时,叶片高光很重,整个图像颜色偏亮偏灰,模型判断作物的纹理特征会混乱。整理项目数据时我把带露水的图像单独放了一组,通过亮度归一化预处理后才进入训练,这种细节直接决定模型在真实作业时稳不稳。
6.2 指标高不等于业务可用
我见过一张分割结果图,mIoU做到0.83,在图上看着也不错,但把杂草斑块按50米网格聚合后,模型漏掉了几块面积在2平方米以上“不大不小”的斑块。这些斑块对喷洒处方影响最大:太小了可以不处理,太大了肉眼可见不会漏,恰恰是模型在滑动窗口边缘容易漏掉的中间尺寸目标。所以项目文档明确要求增加一个聚合层面的检查:按网格统计杂草覆盖率,和人工抽样结果对比,误差要控制在正负15%以内才算验收。
这类“图纸上完美、作业时掉链子”的差异,基本都出在数据分布覆盖不足上,最好的补法不是改模型,而是去不同生长阶段的地块补拍数据。苗期和拔节期的杂草外观差异巨大,一套数据集如果不跨生育期,根本无法全年使用。
6.3 下一步可以怎么扩展
这套自建UNET框架跑通后,扩展空间很大。如果资金和时间允许,可以把RGB输入换成RGB+近红外的多光谱输入,很多杂草对近红外波段的反射特征和作物差异更明显。也可以把UNET升级为带注意力模块的变体,或者在解码器后加一条边缘监督分支,让作物行边缘更干净。
我手上已经在跑的方向是在分割结果上叠加时序分析:同一块田连续多次飞行,看杂草覆盖率随时间变化率,这比单次静态图判断更有说服力,能直接指导施药决策。老实说,无人机农田图像分割的难度不在算法论文里,而在从拼图、标注、训练、部署到业务验收的每个环节,任何一个环节偷懒,最后都会在田间原形毕露。
如果你也准备搭一套类似项目,我的建议是别一上来追求复杂网络,先拿这个自建UNET方案从数据到推理完整跑一遍,把数据规范和验收口径都定下来,后面的升级会越走越轻松。这一期对应的数据集和工程代码我按标题编号归档好了,大家拿到后可以先看数据集标注规则,再对照代码跑通评估流程,少走很多我走弯过的路。