
这台无人机升空的时候底下的麦田绿得发亮可我在屏幕前盯着的却是那些混在麦垄里的杂草。无人机农田图像分割这个需求这几年从“看着很酷”变成了“真得做”村里的植保队想知道的不是“这块地有没有草”而是“草到底在哪些位置、每片面积多大”这样才能让打药车精确对准杂草区域而不是整片地无差别扫一遍。我基于一套自建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约适用场景30m1-1.5cm苗期小杂草识别单株级标注50m2-3cm主要推荐分割精度与效率平衡80m3-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, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels3, num_classes3, base64): 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, stride2) self.d4 DoubleConv(base * 16, base * 8) self.up3 nn.ConvTranspose2d(base * 8, base * 4, 2, stride2) self.d3 DoubleConv(base * 8, base * 4) self.up2 nn.ConvTranspose2d(base * 4, base * 2, 2, stride2) self.d2 DoubleConv(base * 4, base * 2) self.up1 nn.ConvTranspose2d(base * 2, base, 2, stride2) 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], dim1) x self.d4(x) x self.up3(x) x torch.cat([x, c3], dim1) x self.d3(x) x self.up2(x) x torch.cat([x, c2], dim1) x self.d2(x) x self.up1(x) x torch.cat([x, c1], dim1) 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, smooth1e-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.540轮后稳定在0.85左右其中杂草类IoU在0.62到0.7之间这个数字在上大图推理后还会降一点所以训练阶段指标不要看着好就停要留出推理损耗余量。4.3 训练参数和调参经验输入分辨率统一512×512batch 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方案从数据到推理完整跑一遍把数据规范和验收口径都定下来后面的升级会越走越轻松。这一期对应的数据集和工程代码我按标题编号归档好了大家拿到后可以先看数据集标注规则再对照代码跑通评估流程少走很多我走弯过的路。