植物叶片分割数据集从标注到U-Net训练全流程指南
简介面向植物图像分割任务这份数据集包含110张真实植物叶片图像及一一对应的mask标注适合用于图像分割模型的训练、验证与算法效果对比。图像前景区域丰富标注边界精细、质量可靠能满足入门到进阶的视觉学习者在分割任务中的调参与评测需求。资源共222个文件主要包含158个png格式的标注掩膜、62个jpg格式的原始图像以及一个Python可视化脚本可随机抽取图像并生成原始图、GT图及蒙板叠加图便于直接检查标注质量。压缩包整体约545MB目前已有688人学习下载。png与jpg按样本对应存放目录结构清晰脚本开箱即用可作为植物叶片分割实验的基础数据源也可用于课程设计或相关算法复现。1. 图像分割数据集植物图像叶片分割的落地价值在哪做植物表型分析或病虫害识别的人大概率会遇到同一个痛点网上公开的叶片数据集要么是分类标签只告诉你是“病叶”还是“健康叶”要么是检测框把整片叶子框住就算了。可一旦你要算病斑占比、测叶面积、数气孔或者做表型统计矩形框完全没有用——你需要得是像素级的答案每一个像素到底属于叶片还是背景属于健康组织还是病斑。这正是叶片分割数据集要解决的。一个高质量叶片分割数据集核心就是“原图 逐像素掩码”的组合。它不仅仅能训练深度学习模型去自动分割叶片更是一套可复用的资产从传统图像处理算法的验证基准到U-Net、DeepLabV3这类语义分割模型的微调训练都能用到。对于打算自己标注数据的研究生或算法工程师最关心的是图片怎么采、掩码怎么标、标注格式怎么整理、模型训练时怎么避坑。这篇文章我把过去做农学图像项目时的完整路径拆出来从数据集的构建到模型训练的边界条件一步步讲清楚。2. 叶片分割数据集从零构建采集、筛选与标注策略2.1 采集图像的四个实用原则背景控制、光照与角度构建叶片分割数据集第一道工序是采集图片。很多人在这一步踩进同一个坑兴致勃勃去田里拍了几百张照片回来一看光照过曝、叶片重叠、背景杂乱标注员看到这种图直接崩溃标出来的掩码质量惨不忍睹。数据集的“上限”其实在采集阶段就被定死了再牛的标注工具和分割模型也救不回来。我一般的做法是先控制好三个变量背景、光照、叶片形态。背景方面单一背景纯白纸、纯色背景布占三成自然背景土壤、杂草、其他叶片占七成。这个比例很重要全用纯背景训练出来的模型在真实场景一测就掉链子。光照方面避免正午直射阳光带来的硬阴影也避免闪光灯造成的镜面反射室内补光用柔光箱室外选阴天或清晨傍晚散射光。角度方面至少保证叶片水平俯拍用于计算面积和病斑占比和45度斜拍用于形态分析两类视角拍摄距离固定让叶片在画面中的占比大约占30%到80%。另外记得检查EXIF信息里被自动压缩成小图的情况尽量保持原始分辨率建议不低于3000万像素的相机设置为最大尺寸输出手机拍摄的话关闭“智能优化”和HDR。2.2 数据清洗模糊、反光、重叠和病虫害叶片怎么取舍采集完成后需要按严格标准筛选。模糊图直接删除——分割任务需要锐利的边缘信息对焦不准确会让标注员把边缘画得歪歪扭扭。叶片反光区域如果面积超过整个叶片的5%也建议剔除或重新拍摄因为镜面反射区域在掩码标注时很难确定边界而且在训练中会让模型学到错误的纹理特征。叶片重叠是最难处理的情况。两张叶子互相遮挡时标注掩码只能给可见区域。我的建议是重叠样本保留但不超过总量的20%并在标注规范里明确“只标可见部分不还原遮挡区域”。否则标注员会自行脑补被遮挡的边缘导致掩码和原图错位。含水量高、卷曲严重的衰老叶片以及病斑弥漫导致叶肉组织大面积坏死的叶片同样需要谨慎保留。这类样本对模型泛化能力有作用但需要单独建一个子文件夹存放别和正常样本混在一起。2.3 语义分割与实例分割叶片数据集该选哪种标注粒度构建数据集前先确认你要做的是语义分割还是实例分割这是两条不同路径。语义分割只需要区分“叶片”和“背景”两个类别或者“叶”、“病斑”、“背景”多类所有叶片在掩码图中是同一种颜色实例分割则需要给每一片叶子编号掩码图中每片叶子是独立的颜色标号这对于多张叶片重叠的照片尤其重要后续统计叶片数量时才排得上用场。以常见植保场景为例单张图像中只有一片叶子做语义分割就够了如果要做田间群体长势分析多片叶子交错重叠就得走实例分割路线。对应的标注工具也不一样语义分割用Labelme标注“多边形”就足够实例分割则要在Labelme里辨别每一片叶子并分别给label导出时生成不同的json文件。另外如果你后续打算做检测框标注时可以直接用矩形框圈起来但注意矩形框会和分割掩码混在一起导出需要预处理时区分用途。3. 标注实操与格式转换从Labelme多边形到可直接训练的掩码矩阵3.1 用Labelme标注叶片多边形绘制的关键操作与存储格式标注环节的黄金标准是让结果可复现。推荐使用Labelme它保存的是JSON格式的多边形坐标不直接生成掩码图这样随时可以修改标注点而不损坏原始图像。安装和启动都非常简单# 创建虚拟环境 conda create -n labelme python3.8 -y conda activate labelme # 安装并启动 pip install labelme5.0.1 labelme --flags leaf # 单类别标注直接预置flags启动后右侧的“Edit Polygons”按钮用来勾绘叶片边缘。经验是边缘点不要太多能刻画形状变化就行一般每片叶子15到40个点点过密后续转掩码会产生锯齿边缘点过疏形状和原图重叠误差会超过2个像素。标注时如果遇到叶片边缘藏在阴影里看不清先按住“Ctrl鼠标滚轮”放大图像再逐段勾点。标注完成后每张图生成一个与图片同名的JSON文件打开来看内部结构大致是{ version: 5.0.1, flags: {}, shapes: [ { label: leaf, points: [[412, 337], [417, 346], [423, 355], [428, 361]], group_id: null, shape_type: polygon } ], imagePath: leaf_001.jpg, imageData: null }points里存的就是多边形顶点的x、y坐标它们是图像像素坐标系下的整数。imageData字段如果为nullLabelme会通过imagePath去加载图片但有些后续脚本会要求这个字段非空可先用小脚本统一补上。3.2 把JSON转成PNG掩码坐标点渲染与类别映射得到JSON后下一步是把多边形坐标渲染成二进制掩码图。这一环最容易出问题的是坐标系的横纵顺序以及单通道掩码与RGB彩色图的混用。我习惯直接用labelme.utils.shapes_to_label来转换import json import numpy as np import cv2 import labelme.utils as lutils json_path leaf_001.json img_path leaf_001.jpg out_path mask/leaf_001.png with open(json_path, r, encodingutf-8) as f: data json.load(f) img cv2.imread(img_path) shape (img.shape[0], img.shape[1]) # 注意行数是高列数是宽 label_name_to_value {_background_: 0, leaf: 1} mask, _ lutils.shapes_to_label(img.shape, data[shapes], label_name_to_value) # 转成8位单通道图背景0叶片255 mask_bin np.where(mask[:, :, 0] 0, 255, 0).astype(np.uint8) cv2.imwrite(out_path, mask_bin)这段代码里label_name_to_value的映射决定了哪些类别被合并到同一通道。shapes_to_label的第一个参数需要传入原始图像的长宽行、列顺序如果传成(width, height, channel)掩码图会发生转置错位训练时损失直接不下降。转换出的掩码是8位单通道背景值为0前景叶片值为255。把这个值直接喂给深度学习框架前需要在数据加载时做一个归一化将255映射回1。3.3 掩码统一尺寸与切块避免大图直接把显存打爆相机直出图通常是4000x3000甚至更大直接送入分割网络224x224的输入分辨率还好撑死了用原图缩放可一旦batch size上到8以上或者使用1024x1024输入显卡立刻爆显存。常见做法是“切块训练”。先把大图和对应掩码切成若干个512x512的小块再做训练。import cv2 import numpy as np def slice_image_and_mask(img_path, mask_path, patch_size512, stride512): img cv2.imread(img_path) mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) h, w img.shape[:2] patches [] for y in range(0, h - patch_size 1, stride): for x in range(0, w - patch_size 1, stride): img_patch img[y:ypatch_size, x:xpatch_size] mask_patch mask[y:ypatch_size, x:xpatch_size] patches.append((img_patch, mask_patch)) # 对最后一批不满patch_size的边角做镜像填充后再切 if h % patch_size ! 0 or w % patch_size ! 0: pad_h (h // patch_size 1) * patch_size - h pad_w (w // patch_size 1) * patch_size - w img_pad cv2.copyMakeBorder(img, 0, pad_h, 0, pad_w, cv2.BORDER_REFLECT) mask_pad cv2.copyMakeBorder(mask, 0, pad_h, 0, pad_w, cv2.BORDER_REFLECT) for y in range(0, img_pad.shape[0] - patch_size 1, stride): for x in range(0, img_pad.shape[1] - patch_size 1, stride): patches.append((img_pad[y:ypatch_size, x:xpatch_size], mask_pad[y:ypatch_size, x:xpatch_size])) return patches这里用了BORDER_REFLECT做镜像填充而不是BORDER_CONSTANT填零。原因是叶片边缘如果刚好落在图像边界填零会让训练时模型学到“图像四周总是暗的”这种错误先验而镜像填充保持纹理连续性。切块时让stride小于patch_size可以产生重叠块对数据集数量做无损扩充但要注意重叠比例不要超过50%否则同样内容多次重复相当于变相过拟合。4. 用U-Net在PyTorch中训练叶片分割模型最小可复现代码4.1 数据加载器把图像和掩码配对并做在线增强训练分割模型前数据加载器要解决两件事一是原始图像和掩码同时被加载并做相同的几何变换二是保证掩码在做归一化或缩放时仍然保持单通道语义。PyTorch的Dataset类可以这样写import torch from torch.utils.data import Dataset import cv2 import albumentations as A class LeafDataset(Dataset): def __init__(self, img_paths, mask_paths, image_size512): self.img_paths img_paths self.mask_paths mask_paths self.image_size image_size self.transform A.Compose([ A.RandomResizedCrop(heightimage_size, widthimage_size, scale(0.5, 1.0)), A.HorizontalFlip(p0.5), A.VerticalFlip(p0.1), A.RandomBrightnessContrast(p0.3), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)) ]) def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img cv2.imread(self.img_paths[idx]) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) # 注意掩码值范围是0和255归一化到0和1 mask (mask 0).astype(np.uint8) transformed self.transform(imageimg, maskmask) img_t transformed[image].transpose(2, 0, 1) mask_t transformed[mask] img_tensor torch.from_numpy(img_t).float() mask_tensor torch.from_numpy(mask_t).long() return img_tensor, mask_tensor这里有个关键细节RandomResizedCrop在裁剪时会对图像和掩码同时操作它不能单独只作用于图像。Albumentations的好处是自动同步这种几何变换。另一个容易被忽略的点是mask转成long()类型后类别索引从0开始如果你的掩码保存的是0和255一定要先做一个二值化压缩否则CrossEntropyLoss会把255当成第255类训练必然失败。4.2 轻量U-Net网络定义编码解码结构与跳跃连接针对叶片这种结构相对简单、边缘比较平滑的目标完全不需要上Transformer、注意力大模型一个三层深度的U-Net足够应付大多数情况。网络结构用PyTorch定义如下import torch.nn as nn import torch.nn.functional as F class SimpleUNet(nn.Module): def __init__(self, in_channels3, out_channels2): super(SimpleUNet, self).__init__() self.enc1 self._block(in_channels, 32) self.enc2 self._block(32, 64) self.enc3 self._block(64, 128) self.pool nn.MaxPool2d(2) self.bottleneck self._block(128, 256) self.up1 nn.ConvTranspose2d(256, 128, kernel_size2, stride2) self.dec1 self._block(256, 128) self.up2 nn.ConvTranspose2d(128, 64, kernel_size2, stride2) self.dec2 self._block(128, 64) self.up3 nn.ConvTranspose2d(64, 32, kernel_size2, stride2) self.dec3 self._block(64, 32) self.out nn.Conv2d(32, out_channels, kernel_size1) def _block(self, in_ch, out_ch): return 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): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) b self.bottleneck(self.pool(e3)) d1 self.dec1(torch.cat([self.up1(b), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d1), e2], dim1)) d3 self.dec3(torch.cat([self.up3(d2), e1], dim1)) return self.out(d3)这个模型参数量大约700万单张512x512图像跑一次前向在4GB显存上足够。torch.cat后面的e1、e2、e3是编码器对应层输出跳跃连接切的是通道维正是这种结构让精细边缘信息得以保留。叶片分割最怕的就是边缘被“磨平”有了跳跃连接边界锯齿会好很多。输出层的out_channels2表示二分类背景和叶片如果想细分病斑改成3类即可。4.3 训练循环与损失函数选择CrossEntropy还是加Dice Loss在分割任务里损失函数对结果的影响巨大。叶片分割往往存在类不平衡问题如果叶片占图像面积的比例只有10%那么90%的像素是背景直接拿CrossEntropyLoss训练模型会学到“全部预测为背景”的最优解。常见做法是使用Dice Loss配合CrossEntropyLoss组合或者直接用Focal Loss。import torch.nn.functional as F import torch def dice_loss(pred, target, smooth1.0): pred torch.softmax(pred, dim1)[:, 1, :, :] target target.float() intersection (pred * target).sum() dice (2.0 * intersection smooth) / (pred.sum() target.sum() smooth) return 1 - dice def combined_loss(pred, target, alpha0.3): ce F.cross_entropy(pred, target) dice dice_loss(pred, target) return alpha * ce (1 - alpha) * dice代码里alpha0.3表示交叉熵占30%Dice Loss占70%。Dice Loss对小目标区域很敏感因为它是按交集/并集算的即便叶片占比小只要预测错了就扣分。但Dice Loss有个麻烦如果模型一开始全预测背景梯度会非常小甚至出现训练停滞。因此在早停策略上建议先用纯CE训练5个epoch预热再切到组合损失函数。训练循环部分采用了标准的Adam优化器学习率设1e-4并配合余弦退火衰减optimizer torch.optim.Adam(model.parameters(), lr1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max60) for epoch in range(60): model.train() running_loss 0.0 for imgs, masks in train_loader: imgs, masks imgs.cuda(), masks.cuda() optimizer.zero_grad() outputs model(imgs) loss combined_loss(outputs, masks) loss.backward() optimizer.step() running_loss loss.item() scheduler.step() print(fEpoch {epoch1}, Loss: {running_loss/len(train_loader):.4f})一个值得留意的参数是lr。叶片分割数据集往往只有几百张图此时学习率太大容易在训练初期震荡太小则进展很慢。1e-4配合CosineAnnealing是一个在分割任务上相对稳的组合。完整训练一遍后记住把最好的一次checkpoint单独保存下来。4.4 评估指标mIoU和Dice的代码实现与解读训练过程不只看loss分割任务的标准评估指标是mIoU均交并比和Dice系数。计算方式不复杂def compute_miou_and_dice(pred_mask, true_mask, num_classes2): pred_mask pred_mask.flatten() true_mask true_mask.flatten() iou_list [] dice_list [] for cls in range(num_classes): pred_cls (pred_mask cls) true_cls (true_mask cls) intersection (pred_cls true_cls).sum().float() union (pred_cls | true_cls).sum().float() if union 0: iou intersection / union iou_list.append(iou.item()) else: iou_list.append(1.0 if intersection 0 else 0.0) dice (2 * intersection) / (pred_cls.sum() true_cls.sum()) dice_list.append(dice.item()) return np.mean(iou_list), np.mean(dice_list)注意num_classes2时mIoU会把背景类也算进去。如果叶片类别占比太小背景的IoU高、叶片IoU低最终mIoU值看起来很漂亮但实际预测效果不佳因此建议单独打印叶片的IoU作为核心指标。一般叶片分割任务中叶片IoU在0.85以上算是可用0.9以上算是很好。5. 叶片分割数据集训练避坑五个最常见的本地翻车现场5.1 标注错位括号内坐标与图像尺寸不一致导致掩码偏移现象训练时视觉上掩码和原图似乎在水平或垂直方向错了好几个像素模型分割出来的边缘与实际叶片轮廓不对齐。原因是在JSON转换掩码时Image的shape读取错误。用cv2.imread读入的图shape顺序是(height, width, channels)而很多人写代码时习惯写成(width, height)一行之差掩码整体被镜像转置边缘错位严重还有一种是标注结束后图片文件被人为裁剪或resize过JSON里的点和图片位置不再是原始对应关系。解决在转换脚本开头打印img.shape确认尺寸准确标注后不要对原图做任何二次修改。如果发现部分JSON错位用脚本批量检查“多边形点是否超出图像尺寸”这一条件超出即视为异常样本淘汰。5.2 混合数据集使用不同标注格式导致训练中断现象数据集来自多个来源有些是Labelme的JSON、有些是VOC的XML、还有些是COCO的JSON训练时数据加载器报key不存在的错或者mask图像是一片黑。原因是统一处理流程时没有做格式归一化不同格式的坐标系统、类别索引和存储方式不一致。解决方法是先做一次全量转换把统一输出成二值PNG掩码和RGB原图。转换时写一个格式检测分支先解析JSON/XML/YAML等标注文件头部再分别进入各自的解析函数。对无法解析的样本宁可丢弃也不要手工修格式。通常花半小时写个转换脚本后面能省出半天时间。5.3 训练集和验证集划分时图像与掩码没有成对分割现象训练loss在下降但验证集的IoU在某个epoch后突然掉到0.2以下。原因是划分数据集时用了随机数直接把文件列表切成了两半没有按“同一个根文件名”将图片和掩码绑定。比如图片列表的后半段是leaf_051到leaf_100掩码列表的后半段却从leaf_021到leaf_070开始两者错位导致验证集完全错乱。解决方法是划分前先按文件名排序再按8:2比例做分层采样。最稳妥的做法是按照文件名的哈希值来划分保证同一张图永远不会同时出现在训练和验证集中。另外建议在各子集目录里保存一个train.txt、val.txt里面按行保存前缀文件名这样随时可以复查。5.4 数据增强过度旋转扭曲让叶片掩码形变失去真实性现象加了大量随机旋转和弹性变形后练出来的模型看起来很健壮但在真实田间智能手机拍的图片上预测效果差得离谱。原因是增强的弹性变形如ElasticTransform或者大角度旋转超过90度作用于叶片时破坏了叶片的真实形态特征。叶片有生物学上的朝向约束比如大部分叶子的尖端朝向光源有上下面纹理的差别180度翻转在多数场景下还算合理但随机旋转到任意角度会引入“不可能的姿态”。解决方法是限定旋转角度在±30度之内不使用弹性变形做增强或者只在轴向翻转上做增强。增强验证的办法是每次增强后抽样25张图肉眼观察掩码是否仍然精确贴合叶片边缘。5.5 背景复杂或与叶片纹理相近导致的误分割现象在自然背景测试时模型把枯枝、土壤块甚至阴影区域预测成叶片。原因是训练集中自然背景样本太少模型学到的是“颜色像叶子的就是叶片”这种粗糙特征。解决方法是检查训练集中纯色背景与自然背景的比例自然背景至少要40%如果确实不足则做针对性的背景替换增强例如随机在目标区域插入不透明背景农田、草地、水泥地等。另一个办法是把图像转换到HSV颜色空间检查饱和度与亮度通道的关系辅助判断叶片与背景的区分度。如果差异太小那就从标注环节重新采集样本。6. 进阶方向半自动标注加速与多类病斑分割的扩展用法做叶片分割数据集做到后期最耗时的往往是人工标注。如果手头已有几百张标注好的图可以先训练一个初版模型用它去预测未标注的图片生成“预掩码”然后人工修正预掩码的边界。这个半自动的流程一般能把标注时间压缩到原来人工标注的三分之一。具体做法是模型推理输出概率图后用cv2.findContours提取轮廓再结合标注软件做边缘微调。这类方法对叶片这种轮廓相对清晰的目标特别有效但要注意遇到重叠和遮挡时优先人工重点修正。另一个扩展方向是把背景类和叶片类扩展到“健康叶区域、病斑区域、坏死区域”等多类目标。此时需要修改数据集的标注规范在Labelme里给同一个叶片打多个标签但默认导出会合并为单个掩码需要额外维护一个标签映射表或者使用更专门的多类分割标注工具。模型输出通道也从2改成n损失函数不变但compute_miou需要改成对每个类分别计算IoU。多类分割的好处是后续可以直接计算病斑面积占叶片面积的百分比减轻植物病理学统计上的人工工作量。作为一线的建议是先专注做出叶片二分类的高精度模型再逐步增加类别这样每一步训练结果都可控。最后分享一个个人习惯把训练时的每个epoch样本的mask叠在原图上生成一张“分割可视化预览”存下来。整个过程自动化跑下来哪些边界被磨平、哪些背景被误判一目了然。数月的图像分割项目做下来我发现数据集的构建才是最大的时间黑洞而不是模型调参。希望这些记录对正在准备植物图像叶片分割数据集的你有所帮助。本文还有配套的精品资源点击获取