猫狗图像分割数据集构建与训练测试集划分实战
简介深度学习图像分割入门与训练可用的猫狗图像分割数据集面向需要现成样本划分的算法学习者与研究者。数据集中图像与掩码一一对应包含训练集5912张、测试集1478张共7390对样本覆盖多个猫狗品种前景区域丰富且标注细致可直接训练分割网络免去自行标注与拆分的预处理工作目录结构清晰训练集和测试集下均设图像目录与掩码目录便于加载与批处理。包内另附可视化Python脚本可随机抽取一张图将原始图片、GT图像及GT蒙板叠加效果展示并保存便于快速检查标注质量或用于论文展示。资源以zip压缩包形式提供共2000个文件以jpeg图片为主另有1个py脚本压缩包大小约230.81MB。目前已有236人浏览学习适合用于课程作业、算法对比实验或分割模型快速验证。1. 猫狗图像分割数据集训练集和测试集的划分是整个项目的第一道分水岭很多刚开始接触深度学习图像分割的人把注意力全放在模型结构上U-Net还是DeepLabV3学习率调到多少backbone换成谁。但真实流程踩下来会发现卡住进度的往往不是模型而是数据集本身。猫狗图像分割数据集不是一堆猫狗照片加一个txt标签它要求原图、逐像素掩码、训练集和测试集三者严格对齐。划分训练集和测试集时如果没处理好同场景图片的泄漏后面调参数全是白调。这篇笔记围绕“自建一套已划分训练集和测试集的猫狗图像分割数据集”完整走一遍标注格式怎么选、图片怎么清洗、划分脚本怎么写、以及常见坑位在哪适合准备拿深度学习做图像分割入门、或想造私有数据集的开发者照着走。2. 猫狗图像分割数据集的结构与标注格式动手前先确认四件事2.1 一张猫图配一张掩码图像分割数据集的最小单元图像分割和图像分类、目标检测的本质区别在于输出粒度。分类给整张图一个标签检测给目标一个框分割要给每个像素一个类别。对应到数据上一套分割数据集的最小单元是“一对文件”——原图和掩码。原图就是普通RGB照片掩码是尺寸完全相同的单通道PNG图每个像素存一个类别ID。猫狗分割里常用0表示背景、1表示猫、2表示狗如果只做“把猫狗从背景里抠出来”的二分类掩码只用0和1就够。第一次自建数据集的开发者几乎都会在掩码格式上踩坑把掩码存成JPEG。JPEG是有损压缩猫毛边缘会被压出一圈连续的过渡灰色像素值既不是0也不是1。训练时模型把这层灰色当独立类别去学最终分割边缘会糊成一片。掩码必须用PNG这类无损格式保证像素值精确无误。这是整个数据集环节里最不该省的成本。提示加载掩码后第一件事是打印np.unique(mask)确认实际取值。标注工具导出的掩码经常把前景存成255而不是1如果不做归一化模型的输出类别数会比你预期的多一个。2.2 三种标注格式怎么选PNG掩码、Labelme JSON和COCO自建猫狗分割数据集标注阶段的中间产物和训练阶段的数据文件往往是两种格式。标注时用Labelme这类工具产出的是JSON格式的多边形顶点训练前要把JSON转成PNG掩码如果后续要接MMSegmentation或其他开源工具链则可能要转COCO格式。三种格式的取舍我这里给一张对比表| 格式 | 内容 | 优点 | 缺点 | 适用阶段 | | PNG掩码 | 每个像素存类别值 | 训练加载无解析开销 | 不可逆改一笔要返工 | 训练文件 | | Labelme JSON | 多边形顶点坐标 | 可再编辑标注友好 | 训练前必须转掩码 | 标注中间产物 | | COCO JSON | 多边形或RLE编码 | 框架生态完善工具链多 | 结构重手写容易出错 | 对接现有工具链 |一个人标几百张图的话我的建议是Labelme标注、PNG掩码训练暂时不碰COCO。COCO转换脚本网上很多哪天有了明确对接需求再转也不迟。反过来如果团队用的是能直接输出COCO的标注平台那就顺着平台走写个COCO转PNG的脚本喂给训练千万不要做二次标注。2.3 划分训练集和测试集同一只猫不能同时出现在两边划分训练集和测试集时很多人都是按文件名做一次随机打乱八二开就开跑。这在分类数据集上勉强能忍在图像分割数据集上是埋雷。典型翻车场景你从一段视频里抽帧同一只狗在画面里回头三次三帧高度相似。随机打乱后两帧进了训练集一帧进了测试集——训练时模型等于提前见过测试题的答案测试集指标虚高部署到真实新场景立刻现原形。正确做法是先把图片按“来源”分组再对组做划分。同一次拍摄、同一只猫狗的所有图要么全进训练集要么全进测试集。实现上我习惯在文件名里加组前缀比如group01_cat_001.jpg划分脚本只对组ID做shuffle组内不拆散。比例上也没有放之四海皆准的值。分类任务常说的8:2指训练集和测试集但图像分割我建议再单独划一个验证集因为分割模型的超参数多得靠验证集调参、做早停。常用比例是7:2:1或6:2:2。数据总量低于两三百张时验证集别切太小否则loss曲线抖得看不出收敛方向。2.4 拿到数据集先做统计三步看清这套数据值不值得往下做划分完第一件事不是开训是看一眼类别的分布。我一般会跑一段最短的统计脚本import os import numpy as np from PIL import Image mask_dir dataset/masks/train sample_count {} for f in os.listdir(mask_dir): if not f.endswith(.png): continue mask np.array(Image.open(os.path.join(mask_dir, f))) for v in np.unique(mask): sample_count[int(v)] sample_count.get(int(v), 0) 1 print(sample_count) # {0: 58, 1: 42, 2: 36}这段代码统计的是“每个类别出现在多少张图里”不是像素总数。想看像素级占比用np.bincount(mask.ravel())另外算。前一个数反映样本层面的类别均衡性后一个数暴露分割任务里的老问题背景像素动辄占九成以上这个要在训练时用损失函数权重或采样来平衡。如果某一类只出现在寥寥几张图里直接训练等于让模型用玄学猜。优先补数据补不了就把这类并进背景再训练。硬着头皮训出来的模型测试集表现往往也是靠运气撑着的换个场景就原形毕露。3. 从零构建猫狗分割数据集采集、清洗、标注一套打通3.1 图片采集与初筛哪些图放进训练集就是给自己埋雷做私有数据集的素材来源常见有三种自己拍摄、网络公开图、拿现有公开分类数据集二次加工。自己拍摄数据最干净但量少网络爬图要盯紧授权商用项目尽量别碰开源猫狗分类数据集拿来练手最快但那些数据集大多只有分类标签没有掩码一样要过一遍标注关。初筛要做的基础检查是三件事。第一是分辨率分割模型输入常见256到512最小边低于256的图直接放弃否则resize后猫脸细节全没了。第二是遮挡情况目标被大面积遮挡、两只猫互相叠在一起的图不要标注时边界都判不齐训练时模型学的是不确定性。第三是水印和滤镜过重水印会让模型学到“水印等于猫”的杂念滤镜改变颜色分布给后续数据增强添乱。批量过一遍最小边过滤一段Python就够import os from PIL import Image src_dir raw/ keep_dir clean/ bad [] for root, _, files in os.walk(src_dir): for name in files: if not name.lower().endswith((.jpg, .jpeg, .png)): continue path os.path.join(root, name) img Image.open(path) w, h img.size if min(w, h) 256: bad.append((path, f{w}x{h})) else: os.makedirs(keep_dir, exist_okTrue) img.save(os.path.join(keep_dir, name)) print(removed:, len(bad))这段代码只做了尺寸过滤。想再滤掉模糊图可以给每张图算一下Laplacian方差数值低说明图像柔和模糊直接剔除。模糊图的边缘信息是脏的分割模型学到的边界会飘。我第一次做数据时偷懒跳过这步结果训练loss怎么都降不下去回头洗数据花了一整天这是真金白银换来的教训。3.2 标注猫狗轮廓Labelme里那些决定成败的细节清洗完进入标注。常用流程是pip install labelme写一个labels.txt里面依次放背景、猫、狗三行然后启动labelme images/ --labels labels.txt开始标。标注规范直接影响模型质量值得注意的有三点。第一多实例必须设group_id。一张图里有两只猫如果都给cat标签但不设group_idLabelme会把它们当成一个对象生成的掩码就会把两只猫黏成一大块模型永远学不会分开两只紧挨着的猫。设了group_id同一label下的不同实例才会被区分开。第二贴合轮廓但别逐根描毛。猫毛边缘本来就没有锐利边界多边形覆盖主要轮廓就够了。要求越细标注员和标注员之间的标准越难统一模型学到的不是边界而是噪声。第三目标贴图像边缘时多边形停在图像边界上就好不要把顶点延伸到图外。延伸到图外会在掩码里留下一条细边训练时既不是背景也不是前景属于标准的脏数据。标注过程输出的是JSON中间产物不要手工去改目录结构让Labelme自己管理文件。标完一批后进入转换环节。3.3 把JSON批量转成PNG掩码拿来即用的转换脚本标注完所有图把JSON转成掩码。这是我一直在用的一个精简版转换脚本import json import os import numpy as np import cv2 label_map {background: 0, cat: 1, dog: 2} def json_to_mask(image_path, json_path, mask_path): img cv2.imread(image_path) h, w img.shape[:2] mask np.zeros((h, w), dtypenp.uint8) with open(json_path, r, encodingutf-8) as f: data json.load(f) for shape in data[shapes]: label shape[label].strip() if label not in label_map: print(funknown label: {label}, force skip) continue pts np.array(shape[points], dtypenp.int32) cv2.fillPoly(mask, [pts], label_map[label]) cv2.imwrite(mask_path, mask) return mask逻辑很清楚读原图拿到宽高建一张全零掩码遍历JSON里每个shape用cv2.fillPoly把多边形内的像素填上对应的类别ID最后无损写PNG。值得展开的细节有这几个cv2.imwrite写PNG默认无损比PIL的save更可预期不用额外指定参数。掩码数组用uint8足够类别ID不超过255有强迫症可以用uint16但没必要。label.strip()这行是防呆设计。标注时手滑在标签前后打了空格label_map就匹配不上脚本会默默跳过最后掩码大概率缺一块。mask_path要与原图严格同名只换扩展名。我见过不少项目在转换时随意取名到划分阶段文件名对不齐返工成本很高。转换完别急着开训。随便抽几张图用np.unique检查掩码值只剩0/1/2再用cv2.addWeighted把掩码叠加到原图上肉眼抽查五到十张。这十分钟换来的是后面少排查好几个小时。4. 划分训练集和测试集一个可复现脚本把数据整理成能直接训练的目录4.1 为什么非要用脚本划分而不是手动拖文件做过一遍手动整理的人都不会想再来第二次。几百张图命名风格不统一掩码和原图还分在两个目录手动拖文件夹一天时间就没了而且你今天拖完明天就忘了当时的划分比例和随机种子。脚本划分带来的两个好处是复现和可审计。复现指固定随机种子后任何人跑同一份脚本得到完全相同的划分训练结果可比较。可审计指划分比例、按组还是按文件切、去了训练还是测试都有据可查出问题能逐层回溯。更关键的是脚本能把“按组划分”这个规则固定下来。手动拖文件几乎不可能保证同组图片不拆散脚本用一段循环就能严格做到。4.2 按组划分的训练测试集脚本同主体图像不泄漏下面这段脚本是按组划分的标准写法。拿到手之后要改的是路径、比例和组前缀规则。import os import random import shutil from collections import defaultdict random.seed(42) SRC_IMAGES clean_images SRC_MASKS converted_masks DST dataset train_ratio 0.7 val_ratio 0.2 # 1) 按文件名的组前缀分组例如 group01_cat_001.jpg - group01 groups defaultdict(list) for name in os.listdir(SRC_IMAGES): if not name.lower().endswith((.jpg, .jpeg, .png)): continue group_id name.split(_)[0] groups[group_id].append(name) # 2) 对组ID打乱再按比例切成三份 group_ids list(groups.keys()) random.shuffle(group_ids) n_groups len(group_ids) n_train int(n_groups * train_ratio) n_val int(n_groups * val_ratio) split_map {} for i, g in enumerate(group_ids): if i n_train: split_map[g] train elif i n_train n_val: split_map[g] val else: split_map[g] test # 3) 按组复制文件到目标目录不是移动原数据保留做后悔药 for group_id, files in groups.items(): split split_map[group_id] for name in files: img_src os.path.join(SRC_IMAGES, name) mask_src os.path.join(SRC_MASKS, name.replace(.jpg, .png)) img_dst os.path.join(DST, images, split, name) mask_dst os.path.join(DST, masks, split, name.replace(.jpg, .png)) os.makedirs(os.path.dirname(img_dst), exist_okTrue) os.makedirs(os.path.dirname(mask_dst), exist_okTrue) shutil.copyfile(img_src, img_dst) shutil.copyfile(mask_src, mask_dst) print(done, groups:, n_groups, train:, n_train, val:, n_val, test:, n_groups - n_train - n_val)这段代码做了三件事注释里已经标清把文件名按组前缀聚成组对组ID做shuffle并按比例切成训练、验证、测试三段最后逐文件复制到目标目录。复制不是移动这样原始标注文件保留完整任何时候都能重跑划分。三个值得注意的参数random.seed(42)不写这个脚本每次跑出的划分都不一样后面排查问题就成了玄学。train_ratio和val_ratio脚本里test是剩下的所以先定这两个值。比例按第二章说的先按自己的数据量定。文件名后缀写死.jpg和.png如果你的图有.png也有.jpg这里就要写成根据实际后缀去凑掩码文件名或者干脆在清洗阶段就统一后缀。我习惯把所有原图统一成.jpg、掩码统一成.png省掉一堆麻烦。4.3 划分完成后做三件校验配对、尺寸、平衡复制完不直接开训先跑校验。第一个校验是配对每张原图都要有同名掩码from PIL import Image import numpy as np import os DST dataset for split in [train, val, test]: img_dir os.path.join(DST, images, split) mask_dir os.path.join(DST, masks, split) img_names set(os.listdir(img_dir)) mask_names set(os.listdir(mask_dir)) orphan_imgs img_names - mask_names orphan_masks mask_names - img_names assert len(orphan_imgs) 0, f{split} orphan images: {orphan_imgs} assert len(orphan_masks) 0, f{split} orphan masks: {orphan_masks} for name in img_names: img np.array(Image.open(os.path.join(img_dir, name))) mask np.array(Image.open(os.path.join(mask_dir, name.replace(.jpg, .png)))) assert img.shape[:2] mask.shape[:2], f{name} size mismatch这段校验会拦下三类常见问题原图和掩码数量对不上、命名规则不一致、以及尺寸不一致。尺寸不一致最常见的原因是某张原图或掩码在传输或转换过程中被改过如果不校验模型数据加载器会直接崩崩的位置还特别不好找。三件事里的最后一件是平衡性。把训练集的掩码统计跑一遍用第二章的统计脚本输出每个类别覆盖的样本数和像素占比。像素占比严重失衡的比如背景占95%要在训练时给前景类别加权重这个我们在下一章展开讲。5. 常见问题排查猫狗分割数据集从造到用最容易翻车的五个点做数据集的过程里有些问题是反复出现的。下面五个是我在不同项目里都真实撞过、且周围同事也踩过同一个坑的典型都按现象、原因、解决三个维度写。5.1 现象训练loss下降很快测试集指标却忽高忽低原因训练集和测试集之间发生了数据泄漏。最常见的来源就是从同一段视频里抽帧相近画面被随机分到了两侧其次是同一个人拍摄的同一只猫在光线、角度差异极小本质上等于同一张图的轻微扰动。解决回过头按“来源”重建分组信息把所有相似镜头捆成一组按组重新划分训练集和测试集。如果原始文件名里没有组信息而且已经丢了来源记录那这组数据只能废掉重标一部分别无他法。所以划分之前先把拍摄时间、场景、主体三个信息记进文件名永远不要等出问题后再回溯。5.2 现象训练时模型输出类别数总是比预期多一个原因掩码像素值不干净。很多标注工具导出掩码时把背景存成0、目标存成255直接用0/1/2去对齐标签就多出一个255类。有些场景还会出现掩码边缘的过渡值比如128。解决在数据加载器里做归一化把非0的像素值统一映射成1或2。加载掩码时打印一次np.unique(mask)如果出现预期外的值找出是哪张图回数据源修掉。不要试图在损失函数里处理这个治标不治本。5.3 现象训练集和验证集指标都不错测试集预测图边缘糊成一片原因分割任务的背景像素占比通常极高如果你用像素准确率当主指标模型只要把整张图全预测成背景准确率都能到90%以上指标好看但分割结果等于零。解决换成IoU和Dice作为评估指标并把预测掩码叠加原图肉眼检查。在损失函数里对前景类别加权重常用做法是给每个类别权重设为样本像素占比的倒数或者用FocalLoss缓解前景背景不平衡。5.4 现象划分脚本跑完训练时图片读不齐数量缺了三分之一原因文件名大小写不一致、图片后缀有的是.JPG有的是.jpg、掩码后缀有的是.png有的是.PNG划分脚本里写死的后缀匹配不上文件被静默跳过。解决清洗阶段就把文件名和后缀全部统一。我习惯统一转成小写、后缀统一成.jpg和.png文件名用“{group}{category}{编号}”的规则重排。划分脚本里不要写死后缀用Pathlib的suffix拿实际后缀去拼掩码路径省得以后换数据还得改脚本。5.5 现象同样的模型换到公开数据集上效果变好用自己的数据就崩原因自建数据集往往存在系统性偏差例如所有猫狗图都来自同一处场景、同一个角度、同一个相机模型学到了场景特征而不是猫狗本身泛化自然差。解决在采集环节刻意引入多场景、多机位、多光线如果补数据困难退一步在训练时做更强的数据增强随机裁剪、翻转、色彩抖动先拉满至少让模型见过多样的几何和颜色扰动别让场景特征成为模型真正的分类依据。6. 验收你的猫狗分割数据集用20张图小样本快速跑通U-Net数据造好了划分也完成了别急着全量训练。一口气砸几千张图进去训十几个小时最后发现数据有问题成本太高。我的习惯是先抽一个小样本做冒烟测试训练集里抽20张图、5张验证、5张测试用最小的U-Net跑20到30轮目标不是刷指标而是验证整套数据管线能不能走通。小样本跑通要盯三个信号。第一训练loss能不能在20轮内明显下降如果20张小图都降不下去多半是掩码和原图没对齐或是标签值有问题。第二验证loss有没有跟着降如果不降先怀疑划分泄漏再怀疑数据量太小。第三把测试集的预测掩码叠加到原图上存下来肉眼看边缘和区域这一步比任何指标都来得直接。算指标也至少用IoU和Dice不要只看像素准确率。一张512x512的图猫占不到5%像素时全预测背景也有95%准确率但分割任务等于完全失败。跑通冒烟测试后可以再走一步进阶把这套数据的掩码转换脚本和划分脚本的随机种子、比例参数一起写进项目的README或者直接保存成一份数据集产出的说明文件。这样训练的时候不管谁接手第一条命令就能复现出完全一样的划分后面排查指标波动、对比实验都省心。不少团队会忽略这件事等换了一台机器、重新划分一次数据所有对比实验就失去了意义。如果你打算把这套数据进一步拿去接YOLOv8-seg这类分割框架思路也是一样的先把掩码转成对应格式的标注文件再按组做同样的划分冒烟测试过了再全量。换框架换的是标注格式数据划分的准则不变。最后说一句我的习惯每次造完数据集我都会在跑完第一轮冒烟测试后写一段三行的记录包含总图数、掩码格式、划分比例和发现的问题。这个习惯不花几分钟但下次再面对图像分割任务时能少走很多弯路。希望这套流程对你也有帮助。本文还有配套的精品资源点击获取