
简介这是一份面向图像分割任务、专供马铃薯叶片病害研究使用的数据集由健康、早期枯萎病、枯萎病晚期三类叶片样本及其对应的mask标注组成适合计算机视觉学习者、农业病害识别相关研究人员用于语义分割模型训练、效果验证与算法对比。压缩包内共2000个文件主要为1999张jpg图片与1个Python脚本图片涵盖原始叶片图和RGB彩色掩膜标签脚本用于随机抽取样本并生成可视化对比图整体大小约58.28MB。目前已有215人浏览/学习。样张分辨率为256×256背景简洁、前景区域丰富且标注细致mask图像以0表示背景可视化脚本会随机取一张图片将原图、GT分割图以及GT蒙板叠加在原图上的效果并排保存至当前目录便于快速检查标注质量也为后续训练自己的分割模型提供了可直接复用的数据组织参考。1. 马铃薯叶片病害分割数据集解决的不只是认病而是圈病拿到一份马铃薯potato叶片病害分割数据集多数人的第一反应是这不就是一批图片加一批类别标签吗但等模型真正跑起来你会发现图像分割数据集和分类数据集的使用方式差着一条鸿沟——分割任务要求模型对每个像素做出判断标签不再是这张叶子得了早疫病而是一张与原图等大的掩码图逐像素写着背景、病斑、健康叶肉。这份数据集要解决的核心问题是把识别病害推进到定位病害为植保场景里的病斑面积估算、严重度分级和喷药决策提供像素级依据。对从业者来说这套数据的价值集中在三件事上目录结构和类别标签规不规范决定了你能不能直接接进训练管线掩码编码方式决定了你和模型之间的协议清不清楚配套的可视化代码决定了你能否在三分钟内发现坏样本。适合读这篇内容的是做农业视觉、从医学图像分割经验迁移过来的算法工程师以及用语义分割框架练手、想复现完整流程的研究生。一句话概括分类告诉你叶子病了分割告诉你病在哪、病了多大面积。下面围绕这张像素级地图的拆包、清洗、训练与排错展开。2. 拆开数据包目录结构、类别标签映射与掩码编码2.1 一类典型目录结构images、masks、labels 的同名配对规则大多数公开的叶片病害分割数据集目录结构都在往图像分割领域的两套主流约定上靠一套是原图掩码一一对应的语义分割约定另一套是目标检测风格的多标签标注。你手头这份标着数据集类别标签可视化代码的包按业界的常见组织方式长这样potato_leaf_seg/ ├── images/ # 原始叶片图像JPEG 或 PNG │ ├── 001_early.jpg │ ├── 002_late.jpg │ └── ... ├── masks/ # 掩码图与原图同名同尺寸 │ ├── 001_early.png │ ├── 002_late.png │ └── ... ├── labels/ # 类别标签文件记录类别名与像素值映射 │ ├── classes.txt │ └── label_info.json ├── split/ # 训练/验证/测试划分 │ ├── train.txt │ ├── val.txt │ └── test.txt └── visualize.py # 配套可视化脚本这套结构的核心约定是同名配对images下的001_early.jpg必然在masks下有一张同名001_early.png且两者宽高一致。这几乎是所有语义分割数据集的底线协议Pascal VOC、Cityscapes以及医学图像分割里的公开数据集都遵循同一原则。拿到数据后第一件事不是看图片而是写脚本检查配对完整性后面所有 DataLoader 都建立在这个假设上。classes.txt的内容也很典型按像素值顺序列出类别名。很多新手直接忽略这个文件等训练时才发现编号对不上。我的习惯是第一步就把类别映射打出来把它当成整个项目的黑匣子钥匙from pathlib import Path root Path(potato_leaf_seg) print(类别映射文件) print((root / labels / classes.txt).read_text()) # 检查 images 与 masks 是否同名配对 imgs sorted((root / images).glob(*.jpg)) sorted((root / images).glob(*.png)) masks sorted((root / masks).glob(*.png)) print(f原图数量: {len(imgs)}, 掩码数量: {len(masks)}) for m in masks[:5]: if not (root / images / (m.stem .jpg)).exists() and \ not (root / images / (m.stem .png)).exists(): print(f警告: {m.name} 没有对应原图)这段脚本的检查逻辑是遍历masks下所有掩码在images里找同名不同后缀的原图。只要有一个掩码找不到配对原图训练时大概率会崩在 batch 拼不上或索引越界。参数上要注意的是很多数据集的图片后缀并不统一同包里既有.jpg又有.png所以两种后缀都纳入统计。如果检查输出为空说明配对完好可以放心进入下一步。2.2 掩码里的类别编码为什么像素值就是类别 ID看完目录结构下一个关键问题是类别标签存在哪答案是存在掩码的像素值里。一张标准语义分割掩码是单通道 PNG以 8bit 灰阶存储每个像素的数值就是类别 ID。以马铃薯病害为例最常见的约定是像素值 0 是背景土壤、阴影、非叶片区域像素值 1 是早疫病Alternaria solani褐色轮纹斑像素值 2 是晚疫病Phytophthora infestans暗褐色水浸状坏死区像素值 3 是健康叶肉组织如果数据集连健康叶片也标注的话。像素值即类别 ID这个设计有个实打实的好处它天然就是交叉熵损失的 target 格式。PyTorch 的CrossEntropyLoss要求 target 是(B, H, W)的长整型张量通道维存类别索引单通道掩码读出来转成torch.long就能直接喂。这也是分割数据集和分类数据集在标签形态上最本质的区别。不过这里有个高频混淆点很多数据集为了让人眼看得舒服会额外提供一份彩色预览版掩码背景画成黑色、早疫病画成绿色、晚疫病画成红色。千万别把彩色图当训练标签用——它是三通道 RGB直接当 target 会让模型面向 256 级颜色空间去学根本学不出语义。拆包后立刻用这段代码确认掩码的真实编码import cv2 import numpy as np mask cv2.imread(potato_leaf_seg/masks/001_early.png, cv2.IMREAD_UNCHANGED) print(掩码形状:, mask.shape, 掩码数据类型:, mask.dtype) print(掩码中的像素值种类:, np.unique(mask))cv2.IMREAD_UNCHANGED是关键参数。默认的cv2.imread会把图片读成三通道 BGR即使是单通道原文件也会被复制成三个通道np.unique返回一堆三元组真实标号根本看不出来。加上这个参数后单通道 PNG 读出来就是二维数组np.unique干净地列出[0 1 2]或[0 255]。如果结果里出现了类别数以外的值就说明掩码里混进了不该有的像素后面 2.3 会专门处理。提示有效类别数的判断标准是类别数 1背景例如早疫病、晚疫病两类时合法像素值集合应为[0, 1, 2]。2.3 彩色预览掩码的还原建立颜色查找表转单通道索引图现实比理想残酷。我经手过不止一份马铃薯病害包掩码要么是单通道标准版要么就是给人看的 RGB 彩色版。彩色掩码不能直接训练但也没有成本回去重新标注所以正确做法是写一个颜色映射脚本把彩色版还原成单通道索引图。思路是先确认每种颜色对应哪个类别建立颜色到类别的查找表再逐像素替换。import cv2 import numpy as np from pathlib import Path # 颜色表BGR 值 - 类别ID按数据集的预览约定填写 color_to_class { (0, 0, 0): 0, # 背景黑 (0, 128, 0): 1, # 早疫病绿 (0, 0, 255): 2, # 晚疫病红 } def rgb_mask_to_index(mask_path: Path) - np.ndarray: mask_rgb cv2.imread(str(mask_path)) h, w mask_rgb.shape[:2] index np.zeros((h, w), dtypenp.uint8) for bgr, cls_id in color_to_class.items(): # 精确匹配该颜色生成布尔掩码 matched np.all(mask_rgb np.array(bgr), axis-1) index[matched] cls_id return index out_dir Path(masks_single_channel) out_dir.mkdir(exist_okTrue) for mask_file in Path(masks_color).glob(*.png): result rgb_mask_to_index(mask_file) cv2.imwrite(str(out_dir / mask_file.name), result) print(mask_file.name, 像素值:, np.unique(result))这里有两个容易翻车的细节。第一np.all(mask_rgb np.array(bgr), axis-1)必须在最后一个维度上做三个通道同时相等的判断去掉axis-1得到的布尔矩阵语义完全错误。第二转换后务必打印np.unique(result)不只是确认类别数而是检查有没有漏网像素——如果冒出没定义过的值比如 7 或 254说明彩色掩码边缘有反锯齿像素颜色不在表里。这些边缘像素会被默认留在背景类等于往背景标签里注入了病害像素噪声。漏网像素的处理业界常见做法是把它归入颜色距离最近的有效类别或者做形态学闭运算磨掉细碎边缘。但我不建议轻易做形态学操作——病害病斑边缘恰恰是分割评估最敏感的区域磨掉边缘会虚高 mIoU属于自欺欺人。宁可在颜色表里多补几个中间色也不要动形态学手术。3. 预处理三件套像素统计、数据增强与张量转换3.1 先统计类别像素占比再决定损失函数和采样策略农业病害数据集的共同痛点是类别极度不均衡。健康叶肉和背景往往占掉一张图的 70% 到 90%而早疫病病斑可能只有几百个像素在 256×256 的输入里占比不足 1%。如果跳过统计直接套标准交叉熵模型很快会学会全部预测成背景这种偷懒解训练 loss 曲线还走得很顺——这是图像分割里最经典的假收敛。所以我拿到数据集后的第一件事永远是跑一段像素统计把每个类别的占比拉出来看再决定损失函数。下面这个脚本可以复用到任何单通道掩码数据集import cv2 import numpy as np from pathlib import Path class_names [background, early_blight, late_blight] pixel_counts {name: 0 for name in class_names} img_area 0 for mask_path in Path(potato_leaf_seg/masks).glob(*.png): mask cv2.imread(str(mask_path), cv2.IMREAD_UNCHANGED) for i, name in enumerate(class_names): pixel_counts[name] int(np.sum(mask i)) img_area mask.shape[0] * mask.shape[1] total sum(pixel_counts.values()) print(f总像素: {total}, 图片总面积: {img_area}) for name, count in pixel_counts.items(): print(f{name:12s}: {count:10d} {count / total * 100:.2f}%)逻辑很简单逐张读掩码用mask i统计每类像素数并累加最后除以总像素得占比。注意读掩码仍要用IMREAD_UNCHANGED否则统计结果永远是三通道复制值。输出通常分两种情况背景占比超过 90%我优先选 Dice Loss 或 Focal Loss最稀有病害占比低于 0.5%我还会加病斑区域的局部裁剪增强。这个统计不只是一串数字它直接决定权重和采样策略。我常用的一个权重经验公式是反比于像素占比的平方根weight_i (total / (n_classes * count_i)) ** 0.5。它比直接反比温和不会因为某个类别只有几千像素就给出爆炸级权重导致模型过拟合到少数样本。用 PyTorch 的话直接把权重列表传进CrossEntropyLoss(weighttorch.tensor(weights))即可。下面是一个典型马铃薯病害数据集的参考分布类别像素占比示例损失权重的合理取值背景78.4%0.42早疫病3.2%2.06晚疫病12.6%1.04健康叶肉5.8%1.51这组数字不是拍脑袋而是上面公式算出来的。占比最小的早疫病拿到最大权重但不会被放大到失控。3.2 数据增强的红绿灯哪些操作会破坏病害标签数据增强在分割任务里比在分类任务里危险得多。分类任务里翻转图片标签跟着翻就行分割任务里标签是一张图原图和掩码必须经过完全相同的几何变换稍有不慎就是标签错位模型学到的全是噪声。我把常见增强分成三档。安全类水平/垂直翻转、90 度整数倍旋转、随机缩放、随机裁剪、弹性形变。这些操作对原图和掩码做同样的仿射或形变变换语义保持不变。注意随机裁剪后掩码必须一起裁且裁剪区域不能全部落在背景上。危险类亮度/对比度/色调抖动、高斯噪声、模糊。这些只作用于原图、掩码不动本身不出错但过度模糊会让模型对清晰边缘反而敏感不起来推理时遇到真实病斑边界会犹豫。禁用类JPEG 压缩、重采样插值改掩码值。掩码一旦经过插值边缘会出现 0.5、1.5 这类小数类别边界变成渐变带JPEG 压缩会让边缘像素值漂移直接破坏像素值即类别 ID的协议。代码层面我用 albumentations 而不是手写增强原因很直接它的Compose支持同时传 image 与 mask并保证两者应用完全相同的随机变换。下面是最小可用的训练期增强配置import albumentations as A train_transform A.Compose([ A.HorizontalFlip(p0.5), A.RandomRotate90(p0.5), A.RandomResizedCrop(height256, width256, scale(0.7, 1.0), p0.8), A.RandomBrightnessContrast(brightness_limit0.15, contrast_limit0.15, p0.5), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ]) # 训练循环里的调用方式image 和 mask 必须同时传 augmented train_transform(imageimage_rgb, maskmask_index) image_t augmented[image] mask_t augmented[mask].astype(np.int64)参数说明RandomResizedCrop的scale(0.7, 1.0)控制裁剪区域占原图面积比例设太小会把只带零星病斑的叶片裁成纯背景等于给背景类送免费样本RandomBrightnessContrast的亮度幅度我控制在 0.15 以内马铃薯叶片本身颜色差异大过强扰动会让早期病斑和健康叶肉的色差失真。所有几何变换通过同一个Compose执行albumentations 内部保存本次随机参数并同时应用在 image 和 mask 上这是它相比手写np.flip的核心价值。注意Normalize只作用在原图上掩码绝不能走均值方差减除。误把 mask 传进去掩码的整数标号会变成浮动值且整体偏移类别变成 0.004、2.256 这种值模型直接报废。3.3 掩码转训练张量保持索引图还是转 one-hot处理好增强后还剩一个容易被忽略的接口问题掩码是(H, W)的 uint8 索引图而分割模型期望的 target 是(B, H, W)的 long 张量输入图是(B, 3, H, W)的 float 张量。这中间需要一次类型转换而且有两条路线。第一条是保持索引图直接把掩码转成torch.long。它与 PyTorch 的CrossEntropyLoss原生兼容内存占用最小是绝大多数分割网络的默认做法。第二条是转 one-hot形状变成(B, C, H, W)的 float 张量适用于自定义加权损失、或需要对每个类别单独做后处理的场景。两种写法的标准转换如下import torch import torch.nn.functional as F # 索引图 - long tensor直接作为 CrossEntropyLoss 的 target target torch.from_numpy(mask_index).long() # (H, W) # 输入图 - (1, 3, H, W) 的 float tensor image_t torch.from_numpy(image_t).permute(2, 0, 1).unsqueeze(0).float() # 需要 one-hot 时用 F.one_hot 一次性完成 onehot F.one_hot(target, num_classes4) # (H, W, 4) onehot onehot.permute(2, 0, 1).float() # (4, H, W)转换里最容易出错的是维度顺序。F.one_hot输出最后一维是类别通道所以要先permute(2, 0, 1)才能变成(C, H, W)而torch.from_numpy(image_t)读出来是 HWC 顺序同样要先permute(2, 0, 1)再变成 CHW。很多新手在这里翻车把 HWC 的张量直接unsqueeze(0)塞进模型batch 维语义完全错掉模型要么报 shape 错误要么以诡异的方式跑完却不收敛。我的习惯是任何一次形状变换后都打印tensor.shape验证一遍再进网络这个习惯救过我很多次。4. 可视化代码把掩码叠回原图坏标签就藏不住了4.1 cv2.addWeighted 半透明叠加两个参数的视觉效果差异可视化代码不只是给人看的它还是最廉价的质量检查工具。一份分割数据集里最常出问题的不是像素值编码而是标注错位——病斑框比实际大一圈、掩码偏移了几个像素、把叶柄标成了早疫病。这些单看掩码根本发现不了只有把掩码的半透明轮廓叠回原图一眼就能看出问题。最常用的是 OpenCV 的半透明叠加核心就一个函数import cv2 import numpy as np image_bgr cv2.imread(potato_leaf_seg/images/001_early.jpg) mask cv2.imread(potato_leaf_seg/masks/001_early.png, cv2.IMREAD_UNCHANGED) # 为每个类别生成一张彩色渲染图 vis np.zeros_like(image_bgr) vis[mask 1] (0, 200, 0) # 早疫病绿色 vis[mask 2] (0, 0, 255) # 晚疫病红色 # 半透明叠加alpha 控制原图比重beta 控制掩码比重 overlay cv2.addWeighted(image_bgr, 0.6, vis, 0.4, 0) cv2.imwrite(overlay_001.jpg, overlay)cv2.addWeighted的参数要理解而不是背alpha0.6是原图权重beta0.4是掩码渲染图权重gamma0是亮度偏置输出像素等于src1 * alpha src2 * beta gamma。我习惯让原图占大头因为掩码本身是纯色块权重太高会盖住叶片的纹理细节病斑和健康组织的边界反而不清楚。如果想让边界更锐利可以先用cv2.Canny提取掩码边缘线再用cv2.addWeighted只把边缘线叠上去这样病害轮廓是线而不是色块视觉上更干净。参数上有个小坑cv2.addWeighted要求两张图尺寸和通道数完全一致。如果原图是三通道 BGR、vis是三通道就没问题但如果你偷懒用cv2.imread(mask, cv2.IMREAD_GRAYSCALE)读掩码后直接传进去通道数对不上函数会直接抛异常。解决方法是先给掩码做颜色映射生成三通道渲染图或者用np.dstack把单通道复制成三通道。4.2 批量生成对比拼图5 分钟扫完全部标签质量单张叠加适合细看但整个数据集几百张图不可能一张张打开。我的做法是写一个批量拼图脚本把原图、掩码、叠加图三列排成一张大图每张拼图放 6 到 8 个样本几分钟就能扫完整个数据集的标签质量。import cv2 import numpy as np from pathlib import Path import math def make_montage(root: Path, sample_names: list, out_path: str, cols: int 3): tiles [] for name in sample_names: img cv2.imread(str(root / images / name)) mask cv2.imread(str(root / masks / name), cv2.IMREAD_UNCHANGED) vis np.zeros_like(img) vis[mask 1] (0, 200, 0) vis[mask 2] (0, 0, 255) overlay cv2.addWeighted(img, 0.6, vis, 0.4, 0) # 三列原图、掩码渲染、叠加图用 np.hstack 拼接 mask_rgb cv2.cvtColor(vis, cv2.COLOR_BGR2RGB) tiles.append(np.hstack([img, mask_rgb, overlay])) rows math.ceil(len(tiles) / cols) # 每个 tile 是 3 张图横向拼接总高度需要按行堆叠 montage np.vstack([np.hstack(tiles[i * cols:(i 1) * cols]) for i in range(rows)]) cv2.imwrite(out_path, montage) print(f拼图已保存: {out_path}, 形状: {montage.shape}) names [p.name for p in (Path(potato_leaf_seg/images)).glob(*.jpg)][:9] make_montage(Path(potato_leaf_seg), names, montage_quality.jpg)这个脚本的核心逻辑是np.hstack把同一张样本的三种视角拼成一行再用np.vstack把多行堆成一张大图。扫拼图时我按三个标准找坏标签一是掩码渲染里出现孤立的杂色点说明标注有飞溅噪声二是叠加图里病斑色块明显超出叶片边缘说明掩码没有贴住叶片轮廓三是原图里明明有大片病斑但掩码几乎空白说明这个样本漏标了。这三类问题在拼图里都是一眼可见的。注意拼图前先确认sample_names里每张图在 masks 目录下都有同名文件否则cv2.imread返回Nonenp.hstack会报一个莫名其妙的维度错误。建议加一行判断if img is None or mask is None: continue。4.3 病斑密度热力图距离变换暴露标注边界矛盾批量拼图能发现明显的错标但有一类问题拼图看不出来标注边界不齐。比如同一份数据集里有的标注把病斑周围的黄晕也算进病害区域有的只标深色坏死核心导致同类病害的掩码面积差出一倍。这类问题可以用距离变换做一张病斑密度热力图把整个数据集的病斑覆盖频率可视化出来。思路是把所有掩码按类别累加得到一张每个像素被标成早疫病的次数的计数图再归一化成热力图。如果热力图中心是深红色、边缘突然断崖说明标注一致性还行如果热力图从中心到边缘是渐变的说明不同图片对病斑边界的理解差异很大。import cv2 import numpy as np from pathlib import Path # 累加所有掩码中早疫病类别的出现次数 accum np.zeros((256, 256), dtypenp.float32) count 0 for mask_path in Path(potato_leaf_seg/masks).glob(*.png): mask cv2.imread(str(mask_path), cv2.IMREAD_UNCHANGED) mask cv2.resize(mask, (256, 256), interpolationcv2.INTER_NEAREST) accum (mask 1).astype(np.float32) count 1 # 归一化并应用颜色映射 accum / max(count, 1) heat (accum * 255).astype(np.uint8) heat_color cv2.applyColorMap(heat, cv2.COLORMAP_JET) cv2.imwrite(early_blight_density.jpg, heat_color)这里有两个技术点。第一cv2.resize必须用cv2.INTER_NEAREST因为掩码是索引图默认的双线性插值会把[0, 1]的边界变成0.3、0.7这种中间值累加结果直接失真。第二归一化除以样本数count后热力图的像素值表示该位置被标成早疫病的概率越接近 255 说明标注越一致。如果热力图边缘呈现明显的亮度梯度而不是陡峭边界就要考虑是否需要在标注规范里统一病斑边界怎么画的口径再决定要不要对掩码做腐蚀或膨胀来对齐。5. 避坑指南马铃薯病害分割最容易翻车的 5 个现场5.1 现象掩码被 JPG 压缩病斑边缘糊成一片现象训练时 loss 能降但验证集 mIoU 始终在 0.6 左右上不去放大预测结果发现模型输出的病斑边界有一圈灰色的过渡带和掩码本身的模糊边缘完全对应。原因数据包在分发时为了减小体积把掩码 PNG 转存成了 JPG。JPG 的有损压缩会在边缘产生大量介于 0 和 1 之间的像素值掩码从严格的类别索引图变成了灰度渐变图。模型看到的是半病斑半背景的模糊监督信号自然学不出锐利边界。解决第一步先诊断用np.unique检查掩码像素值种类看是否出现非法中间值。第二步找回原始 PNG如果实在找不到就用 Otsu 阈值或按像素值大于 0.5 归为病害的方式二值化恢复干净的索引图。二值化后务必再用np.unique确认只有合法的类别编号。5.2 现象类别编号在文件、文档和代码之间漂移现象按classes.txt写代码早疫病对应像素值 1但训练出来的模型把背景区域预测成早疫病单类 IoU 直接崩到 0.1。原因数据集的类别标签文件可能和掩码实际编码不一致。常见的情况是classes.txt按背景、早疫病、晚疫病排序但掩码里晚疫病是 1、早疫病是 2或者某次重新标注后编号被推翻文档没同步更新。这是分割项目里最隐蔽的坑因为它不报错只是默默学错。解决把类别映射的验证做成训练前的强制检查步骤而不是靠肉眼。写一段脚本随机抽 20 张图把掩码像素值 1 的位置在原图上抠出来判断这些区域的颜色均值是否接近早疫病的褐色或轮纹特征。更简单的做法是直接可视化叠加图人工确认一次。关键是把这次确认的结果固化成一个label_map.json在后面所有训练和评估代码里只读这一份映射不再信任散落的classes.txt。5.3 现象增强后掩码和原图错位现象训练 loss 正常下降但验证集表现极差且预测结果里病斑位置普遍偏左或偏上几个像素。原因手写增强时原图和掩码走了不同的随机分支。比如对原图做了随机裁剪但掩码没有裁或者用了np.random.permutation分别打乱了两个数组的索引。分割任务里原图和掩码是一对孪生数据任何一处独立随机都会导致错位。解决不要手写几何增强统一用 albumentations 的Compose同时传入image和mask它内部保证两者共享同一套随机参数。如果必须手写每次变换后用np.array_equal校验掩码中某个标志点的位置是否与原图对应。强烈建议在增强后的数据上跑一次可视化拼接抽查 5 到 10 张确认病灶轮廓没有位移再进训练。5.4 现象训练集和验证集混入同一株叶片的重复拍摄现象训练集 mIoU 高达 0.95验证集却只有 0.55且差距不随迭代缩小。原因数据集划分时没有按叶片个体去重。同一株马铃薯的同一片叶子可能被拍摄多张或者对同一张原图做了多个裁剪版本被拆进了 train 和 val。分割模型对像素纹理的记忆力极强训练时见过的叶片纹理会在验证时被认出导致验证集分数虚高或严重失真的评估结果。解决划分数据前先检查文件名。如果命名规则里带拍摄序号或叶片编号如leaf_07_a.jpg、leaf_07_b.jpg按叶片编号分组后整组划分如果文件名没有任何线索跑一次感知哈希dhash对所有图片两两比对找到重复或近重复样本把同一组的图片放进同一个 split。这个坑在农业数据集里尤其常见因为采集时经常对同一叶片从多个角度连拍。5.5 现象病斑面积太小下采样直接吃掉现象训练时早疫病类别的 IoU 始终为 0模型完全预测不出任何早疫病像素但背景和晚疫病的分数正常。原因马铃薯早疫病早期病斑直径可能只有 3 到 5 个像素模型输入缩放到 256×256 时小病斑在几何变换和池化过程中直接被抹掉。特别是用了 RandomResizedCrop 且scale下限太低时小病斑被缩成亚像素级标签变成空类。解决先做病斑尺寸统计。用连通域分析找出最小病斑的像素数如果小于输入尺寸的 1%需要双管齐下一是把输入分辨率提高到 512 或 768保持细节二是对含小病斑的样本做局部放大增强用A.RandomCrop以小病斑为中心裁出含病灶的 patch 单独训练。更激进的做法是两类分开训练一个模型专做病害 vs 背景的粗分割另一个模型在粗分割结果上做细分类。这个思路在医学图像分割里很常见移植到叶片病害上同样有效。6. 进阶验证按病斑面积分段评估把掩码导出成 YOLO 多边形6.1 逐类 IoU 计算一秒钟揪出拖后腿的类别全局 mIoU 会掩盖问题。早疫病占比 3%晚疫病占比 12%即使早疫病 IoU 只有 0.2mIoU 被晚疫病一拉也能到 0.7看着像模像样。正确的做法是算逐类 IoU 并打印混淆矩阵这是所有分割项目验收的第一关def compute_iou(pred, target, num_classes3): ious [] for c in range(num_classes): inter ((pred c) (target c)).sum() union ((pred c) | (target c)).sum() ious.append(inter / union if union 0 else 1.0) return ious这段代码注意两个细节一是union为 0 的类别要返回 0 而不是除零报错代表该类别在样本里完全不存在二是逐类 IoU 结合错误矩阵看如果早疫病被大量预测成背景说明是漏检被预测成晚疫病说明两类病斑特征太接近需要考虑加颜色归一化或换损失函数。6.2 按病斑面积分组评估模型可能只认识大病斑农业场景里漏掉一个 5 像素的早期病斑和漏掉一片 5000 像素的大块坏死后果完全不同。等 mIoU 合格后再做一步分面积评估按连通域大小把病斑分成小、中、大三组分别算检出率能直接暴露模型的能力边界def detect_by_area(pred_mask, target_mask, area_bins[(0, 50), (50, 500), (500, 1e9)]): num, labels, stats, _ cv2.connectedComponentsWithStats(target_mask.astype(np.uint8)) results {} for lo, hi in area_bins: detected total 0 for i in range(1, num): area stats[i, cv2.CC_STAT_AREA] if lo area hi: total 1 if np.any(pred_mask[labels i] 0): detected 1 results[(lo, hi)] (detected, total) return resultscv2.connectedComponentsWithStats返回连通域标签和面积统计labels i取出第 i 个病斑的全部像素。判断这个病斑是否被检出用的标准是预测掩码里该区域是否存在任何预测为正类的像素。这个标准的阈值可以调更严格的做法是要求预测正类像素覆盖病斑面积的 50% 以上。按面积分组后如果小病斑检出率低于大病斑说明需要提高输入分辨率或增加小病斑局部增强而不是盲目调损失权重。6.3 把掩码导出成多边形迁移到 YOLO 分割格式的通用脚本最后一步常常被忽略分割模型的输出是掩码但植保系统的下游模块如喷药决策、面积统计不一定接受掩码。最常见的落地需求是把掩码导出成多边形喂给 YOLO 系列做实例分割训练或者转成 JSON 交给地图渲染。下面这个脚本把单通道掩码按类别转成 YOLO 格式的归一化多边形import cv2 import numpy as np def mask_to_yolo_polygons(mask, class_id, img_w, img_h): binary (mask class_id).astype(np.uint8) * 255 contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) lines [] for cnt in contours: if len(cnt) 3: continue # 多边形顶点按原图尺寸归一化YOLO 要求坐标在 0~1 pts cnt.reshape(-1, 2).astype(np.float32) pts[:, 0] / img_w pts[:, 1] / img_h line str(class_id) .join(f{x:.6f} {y:.6f} for x, y in pts) lines.append(line) return linescv2.findContours的RETR_EXTERNAL只取外轮廓避免病斑内部的小洞生成重复多边形CHAIN_APPROX_SIMPLE压缩轮廓点减少标注文件体积。YOLO 分割格式要求坐标归一化到 0 到 1且按 x、y 交替排列。这步转换的坑在于点数过多会让模型训练变慢必要时可以用cv2.approxPolyDP做多边形简化容差 2 个像素是一个比较保守的起点。我个人的习惯是每次导出一批多边形后随机挑几个样本把多边形在原图上画出来目视确认轮廓贴合度再进 YOLO 训练管线。处理数据集用于 yolov8 训练时最常出问题的反而是坐标归一化方向搞反或者类别号没对齐上一步的label_map.json。多花一次肉眼校验比后续排查奇怪训练行为省时间得多——这是我把报错当饭吃攒下的血泪经验。希望帮到你。本文还有配套的精品资源点击获取