简介:面向植物图像分割任务,这份数据集包含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 python=3.8 -y conda activate labelme # 安装并启动 pip install labelme==5.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字段如果为null,Labelme会通过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", encoding="utf-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_size=512, stride=512): 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:y+patch_size, x:x+patch_size] mask_patch = mask[y:y+patch_size, x:x+patch_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:y+patch_size, x:x+patch_size], mask_pad[y:y+patch_size, x:x+patch_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_size=512): self.img_paths = img_paths self.mask_paths = mask_paths self.image_size = image_size self.transform = A.Compose([ A.RandomResizedCrop(height=image_size, width=image_size, scale=(0.5, 1.0)), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.1), A.RandomBrightnessContrast(p=0.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(image=img, mask=mask) 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_channels=3, out_channels=2): 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_size=2, stride=2) self.dec1 = self._block(256, 128) self.up2 = nn.ConvTranspose2d(128, 64, kernel_size=2, stride=2) self.dec2 = self._block(128, 64) self.up3 = nn.ConvTranspose2d(64, 32, kernel_size=2, stride=2) self.dec3 = self._block(64, 32) self.out = nn.Conv2d(32, out_channels, kernel_size=1) def _block(self, in_ch, out_ch): return 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): 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], dim=1)) d2 = self.dec2(torch.cat([self.up2(d1), e2], dim=1)) d3 = self.dec3(torch.cat([self.up3(d2), e1], dim=1)) return self.out(d3)这个模型参数量大约700万,单张512x512图像跑一次前向在4GB显存上足够。torch.cat后面的e1、e2、e3是编码器对应层输出,跳跃连接切的是通道维,正是这种结构让精细边缘信息得以保留。叶片分割最怕的就是边缘被“磨平”,有了跳跃连接,边界锯齿会好很多。输出层的out_channels=2表示二分类(背景和叶片),如果想细分病斑,改成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, smooth=1.0): pred = torch.softmax(pred, dim=1)[:, 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, alpha=0.3): ce = F.cross_entropy(pred, target) dice = dice_loss(pred, target) return alpha * ce + (1 - alpha) * dice代码里alpha=0.3表示交叉熵占30%,Dice Loss占70%。Dice Loss对小目标区域很敏感,因为它是按交集/并集算的,即便叶片占比小,只要预测错了就扣分。但Dice Loss有个麻烦:如果模型一开始全预测背景,梯度会非常小,甚至出现训练停滞。因此在早停策略上,建议先用纯CE训练5个epoch预热,再切到组合损失函数。
训练循环部分采用了标准的Adam优化器,学习率设1e-4,并配合余弦退火衰减:
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=60) 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(f"Epoch {epoch+1}, 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_classes=2): 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_classes=2时,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叠在原图上,生成一张“分割可视化预览”存下来。整个过程自动化跑下来,哪些边界被磨平、哪些背景被误判,一目了然。数月的图像分割项目做下来,我发现数据集的构建才是最大的时间黑洞,而不是模型调参。希望这些记录对正在准备植物图像叶片分割数据集的你有所帮助。
本文还有配套的精品资源,点击获取