简介:一套面向深度学习图像分割任务的猫狗分割数据集,包含完整的训练集与测试集划分,适合需要开展二类分割模型训练、验证与评估的研究者或学习者使用。训练集内含images图片目录与masks模板目录,共5912张原始图片及对应mask;测试集同样按images与masks组织,共1478张图片及对应mask,全部为jpeg格式。数据前景区域丰富,标注质量较高,无需额外处理可直接用于分割网络的输入与损失计算。压缩包共2000个文件,除1999张jpeg图像外,还附带1个Python可视化脚本,可随机抽取一张样本,将原始图、GT图像、GT在原图上的蒙板效果展示并保存为对比图,便于快速检查数据标注质量。压缩包大小约230.81MB,解压后约247MB,目前已有236人学习下载,适合入门图像分割、制作数据基准或进行算法复现时快速取用。
1. 猫狗图像分割数据集:先于模型的一个硬门槛
很多人以为猫狗图像分割的第一道坎是模型,实际跑一遍就会发现,数据集才是真正烧时间的地方。原图好找,但“哪只猫在哪个像素”的mask需要人工标注或半自动转换;训练集和测试集怎么切更讲究,猫狗数据往往来自视频抽帧,同一个体反复出现,按文件名随机切会把同一只猫同时放进训练集和测试集,mIoU虚高到不敢信。这篇文章把数据侧完整走一遍:标注格式怎么定、训练集与测试集如何按个体划分、像素占比怎么算、损失函数怎么写,最后用一套UNet最小骨架在自建数据集上跑通并打分。适合准备做图像分割、正在整理标注数据或想验证数据集质量的从业者。
2. 图像分割数据集构建:从原图到逐像素mask的转换路径
图像分割数据集与图像分类数据集最本质的区别是标签的载体。分类只需要一个类别id存进csv就能训练;分割的标签是“每一像素属于哪个类别”,通常以PNG mask、RLE编码或JSON多边形三种形式存在。构建猫狗图像分割数据集,第一步不是急着找图,而是确定标签的存储形式和标注粒度,这两点决定了后面所有代码的走向。
2.1 先定分割粒度:语义分割还是实例分割
猫狗图像分割数据集的标注粒度决定后面所有代码。语义分割给每只猫都标成“猫”这一个类别,背景记为0、猫记为1、狗记为2;实例分割则要把同一张图里的两只猫分别标成对象1和对象2。医学图像分割里常用的是多器官语义分割,和猫狗场景的组织方式一样;如果后续要做目标计数或个体跟踪,才需要升级到实例分割的标注协议。
三选一之前,先明确场景:
- 语义分割:只关心“猫在哪里”,输出一张如原图大小的类别索引图
- 实例分割:需要区分个体,输出带实例ID的掩码或Mask R-CNN格式的annotations
- 全景分割:语义加实例同时输出,工程复杂度高,通常不是猫狗数据集的默认选项
我一般根据最终指标来定:只评估mIoU就用语义分割,标注成本和训练成本都低。等方案跑通,再决定是否升级。若前期不确定,按语义分割标注,之后要做实例分割时,还要把同类别多实例拆开,那时需要重新复核标注,成本会翻倍——所以正式标注前,一定先把预测目标写清楚。
2.2 标注格式怎么选:PNG mask是训练最顺的载体
标注工具可导出的格式主要有三种,取舍逻辑很明确:
| 格式 | 工具链 | 存储开销 | 读取方式 | 适合阶段 |
|---|---|---|---|---|
| JSON多边形 | LabelMe / CVAT | 小,只存坐标 | 需转mask | 标注与复核 |
| RLE | COCO格式 | 中等 | pycocotools解码 | 评测与分发 |
| PNG 8bit mask | 转换得到 | 中等,可压缩 | 直接读灰度图 | 训练与可视化 |
经验上,训练阶段不需要RLE的压缩优势,PNG mask最直观:cv2.imread按灰度图读进来,像素值就是类别索引,直接进损失函数。JSON多边形建议只作为标注原始文件保存,训练前统一转成PNG mask,避免每个epoch实时渲染多边形,省掉无谓计算。RLE格式在coco系列工具里常见,但猫狗这类二分类三分类数据用RLE反而多一层解码开销,没有必要。
2.3 LabelMe标注结果批量转PNG mask的代码
LabelMe导出的JSON里,“shapes”是顶点坐标列表,每个shape带一个label字段。约定label为“cat”或“dog”,转换脚本如下:
import json import numpy as np import cv2 from pathlib import Path def convert_labelme_to_mask(json_path: Path, height: int, width: int) -> np.ndarray: with json_path.open('r', encoding='utf-8') as f: data = json.load(f, strict=False) # 背景固定为 0,猫=1,狗=2 mask = np.zeros((height, width), dtype=np.uint8) for shape in data['shapes']: pts = np.array(shape['points'], dtype=np.int32).reshape(-1, 2) label_name = shape['label'].lower() class_id = 1 if label_name == 'cat' else 2 if label_name == 'dog' else 0 cv2.fillPoly(mask, [pts], class_id) return mask这段代码的核心是cv2.fillPoly:它把多边形顶点覆盖到的像素全部填成对应类别ID。多边形顶点坐标在LabelMe里是(x,y)形式,与OpenCV的填图接口一致,不需要交换行列。需要特别提醒的是,部分标注工具在换行或平移时会产生零散点,可能让多边形不闭合,cv2.fillPoly对未闭合多边形会自动连线闭合,但如果顶点顺序混乱,会出现填充区域交叉。转换后最好抽查几张mask与原图的叠加效果,确认轮廓贴合度。
使用同一函数批量转换train、test目录下的JSON时,需要先读取原图拿到实际height和width,再生成mask。有些标注工具把顶点限制在图像边界内,fillPoly不会越界;有的则允许越界,转完会出现负坐标或超大坐标,这类文件要在转换日志里标出来,单独复核。
2.4 目录结构:train、test与val的物理隔离
标注转完后,文件组织建议做到“训练集与测试集物理隔离”,即用三个子目录或三份清单文件明确区分,而不是靠后缀名命名硬分。常见结构如下:
catdog_dataset/ ├── images/ │ ├── cat_001.jpg │ ├── cat_002.jpg │ └── dog_001.jpg ├── masks/ │ ├── cat_001.png │ ├── cat_002.png │ └── dog_001.png ├── train.txt ├── val.txt └── test.txttrain.txt、val.txt、test.txt里每行写一个不带扩展名的文件名,例如“cat_001”。这种清单方式的好处是划分逻辑改动只影响txt,不需要移动大文件;多机分布式训练时,每台机器读同一份清单还能保证数据集版本一致。代码里读取清单时也有讲究:txt末尾如果有空行或回车,需要strip掉再拼接路径,否则会多出一个换行符拼进文件名,报FileNotFoundError时很难一眼发现。
训练集和测试集的划分比例通常取80/20,再从训练集内部按同样的个体隔离逻辑切出验证集,而不是在80%里随手随机取10%——后者在猫狗这类个体重复多的数据上会让验证集失去参考价值。验证集不参与梯度更新,只用于选模型权重,它的分布也要尽量贴近训练集,划分逻辑必须和训练测试划分一致。
3. 训练集与测试集划分:先按“个体”再按“样本”
从标注工具导出的是原始样本文件,但“划分”绝不只是shutil.move。猫狗图像分割数据集有个隐蔽问题:同一只猫出现在连续帧或相似背景里,如果按文件名随机划分,模型看到的是同一只猫的训练版本和测试版本,mIoU会明显偏高,上线后直接现原形。先按“个体”再按“样本”的划分策略,才是测试集分数可信的前提。
3.1 为什么推荐GroupShuffleSplit而非random_split
随机划分的前提是样本独立同分布,图像分割数据集很难满足这一点。图片之间往往共享个体、场景或拍摄批次。比如一段猫的视频抽帧出30张图,按文件名随机切,可能有20张进训练、10张进测试,而它们本质上是同一只猫的角度变化,测试集的有效性就大打折扣。
正确的做法是构造一个group字段,把同一只猫、同一次拍摄会话的图片归为一组,然后按group划分。sklearn的GroupShuffleSplit正好做这件事:
import pandas as pd from sklearn.model_selection import GroupShuffleSplit file_ids = ["cat_001", "cat_002", "dog_001", "dog_002"] # group 一般取文件名中的个体标识,例如"cat_001"就保留整个前缀 groups = ["cat_001", "cat_001", "dog_001", "dog_002"] df = pd.DataFrame({"file": file_ids, "group": groups}) # 第一次划分:训练+验证 / 测试 gss_test = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_val_idx, test_idx = next(gss_test.split(df, groups=df["group"])) df_test = df.iloc[test_idx] df_train_val = df.iloc[train_val_idx] # 第二次划分:从训练+验证里再切验证集,test_size=0.25 相当于总体的20% gss_val = GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=42) train_idx, val_idx = next(gss_val.split(df_train_val, groups=df_train_val["group"])) df_train = df_train_val.iloc[train_idx] df_val = df_train_val.iloc[val_idx]参数含义如下:
- n_splits置1表示只做一次切分,避免多次随机带来的不确定性
- test_size是输出比例,第一次0.2表示测试集占20%
- random_state固定成42,保证复现同一份划分结果
如果数据集没有给个体ID,退而求其次按“拍摄批次”作为group。批次信息可以从文件名前缀、目录名或时间戳恢复。完全没有分组信息的图像分割数据集,划分结果的可靠性只能打折扣,这需要在元信息里写清楚,避免后续分析指标时误以为是模型问题。
3.2 三个划分后的必做检查
划分脚本跑完不等于数据集合格。我通常会做三类检查,每一项都对应一个具体的失败模式:
| 检查项 | 做法 | 失败时的典型表现 |
|---|---|---|
| 个体泄漏检查 | 检查同一个group是否同时出现在train和test的清单里 | 测试集mIoU虚高,换一批数据就崩 |
| 像素占比检查 | 分别统计train/test中mask非零像素比例 | 某个类在训练集里出现极少,测试时该类Dice很低 |
| mask对齐检查 | 把原图和mask叠加可视化抽20~30张 | 猫的轮廓明显偏位,说明标注文件名错位 |
个体泄漏检查可以直接用pandas的groupby验证group维度的互斥性,比如统计df_test的group与df_train的group求交集,结果为空才继续。这步看似多余,但很多项目半路追加新数据时,补丁式的划分脚本会破坏group边界,重新生成清单后不去检查,问题会悄悄混进训练集和测试集。
像素占比检查这步很容易被忽略。猫狗分割数据里背景通常占80%以上,如果训练集和测试集的类别占比差异超过5个百分点,就要检查是否是划分批次造成了分布偏移。比如训练集来自室内,测试集来自室外,即便group划分正确,场景光照和背景类型的差异也会让mIoU剧烈波动。
这里还要提一个视频抽帧数据的典型问题:相似帧会让训练集中一半样本约等于同一张图,epoch表现很好看,测试集稍微换个场景就露馅。划分之前先做一次训练集图像去重,感知哈希加汉明距离就能去掉肉眼可见的重复帧,这一步对后续训练效果的提升比调损失函数更明显。
3.3 划分结果落盘为清单文件
划分完成之后,把结果写到三个txt文件。确保test.txt完全独立,训练过程中不读、不引用,这是“测试集”在工程意义上的边界。清单文件同时记录一个meta.json,把随机种子、group字段来源、划分比例都写进去,方便日后追溯。
df_train["file"].to_csv("train.txt", index=False, header=False) df_val["file"].to_csv("val.txt", index=False, header=False) df_test["file"].to_csv("test.txt", index=False, header=False)meta.json的内容类似:
{ "split_method": "GroupShuffleSplit", "group_by": "individual_id", "test_size": 0.2, "val_size": 0.2, "random_state": 42 }这段元信息在复现对比实验、被别人质疑指标时非常有用。很多数据集只挂一份train/val/test文件列表,不说明划分依据,导致实验结果无法横向比较。把划分规则和随机种子写进meta.json,相当于给数据集质量上了保险。后续如果重新标注了一批数据并追加进旧数据集,meta.json也要同步更新,否则旧实验无法追溯当时看到的是哪一版数据。
4. 训练集像素不平衡与损失函数:猫狗分割的损失为什么不能只算交叉熵
数据集划分好之后,进入训练前的关键一步:统计mask的类别分布。猫狗图像分割与医学图像分割有个共同特点——背景区域占绝对多数。如果直接用BCE或Softmax交叉熵,网络只要把所有像素都预测成背景,准确率也轻松超过80%,但猫狗的轮廓一个都出不来。这个现象叫像素级类别不平衡,需要在损失函数和数据增强两个层面同时处理。
4.1 先用代码算出类别像素占比
转换后的PNG mask是uint8灰度图,像素值就是类别ID。统计占比最简单的方式是np.bincount:
import numpy as np import cv2 from pathlib import Path mask_dir = Path("catdog_dataset/masks") counts = np.zeros(3, dtype=np.int64) for mask_path in mask_dir.glob("*.png"): mask = cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE) counts += np.bincount(mask.ravel(), minlength=3) total = counts.sum().item() for cls_id, name in enumerate(["background", "cat", "dog"]): print(f"{name}: {counts[cls_id] / total:.4f}")np.bincount返回每个像素值出现的次数,minlength=3保证背景、猫、狗三类都有对应的槽位。统计结果通常显示背景占85%到95%,猫狗只有个位数百分比。这个数字直接影响损失函数选型:背景占95%时,网络的默认策略是“全输出背景”,因为这种预测能拿到最低的交叉熵损失。注意这里的mask路径如果混入灰度图或JPEG压缩图,bincount结果会出现第3类、第4类的像素值,统计前要先确认mask里没有杂音。
4.2 Dice Loss与Focal Loss的选型与组合
处理像素不平衡,最稳定的组合是Dice Loss加带logits的BCE。Dice Loss直接优化“预测区域与真实区域的交叠比例”,对前景类的梯度信号更强,不会因为像素数量少而被背景淹没。写法如下:
import torch import torch.nn.functional as F def dice_loss(logits: torch.Tensor, targets: torch.Tensor, eps: float = 1e-6) -> torch.Tensor: # logits: (B, 1, H, W) 或 (B, C, H, W),targets: (B, C, H, W) one-hot probs = torch.sigmoid(logits) intersection = (probs * targets).sum(dim=(2, 3)) union = probs.sum(dim=(2, 3)) + targets.sum(dim=(2, 3)) dice = (2.0 * intersection + eps) / (union + eps) return 1.0 - dice.mean()分母里的union把预测区域和真实区域的像素数加起来,加eps防止空目标区域导致除零。在一次性统计好三类占比后,可以把背景类权重调低,但经验上Dice Loss本身对不平衡已经有不错的鲁棒性,不调权重也能收敛。
和BCE组合时常用权重各取0.5:
loss = 0.5 * F.binary_cross_entropy_with_logits(logits, target_one_hot.float()) + 0.5 * dice_loss(logits, target_one_hot)BCE部分提供逐像素的收敛信号,Dice部分抑制背景主导。两个损失的比例可以按验证集mIoU微调,常见区间是0.3:0.7到0.7:0.3。如果测试集上“猫毛狗毛边缘断裂”比较明显,可以把Dice换成带Focal的变体。Focal Loss给易分类像素更低的权重,让网络把注意力集中在难样本上,一般gamma取2、alpha取0.25。
| 损失 | 适用场景 | 关键参数 |
|---|---|---|
| Dice Loss | 前景占比极低、强调区域交叠 | eps=1e-6 |
| BCE+Dice | 最通用,收敛快且稳定 | 权重0.5:0.5 |
| Focal Loss | 边缘破碎、难样本多 | gamma=2, alpha=0.25 |
4.3 mask必须与图像同步增强:albumentations的用法
图像分割的数据增强比分类多一条铁律:对图像做的每一处几何变换,mask必须用完全相同的方式变换,不能各自独立随机。用torchvision的RandomHorizontalFlip分别对image和mask调用,得到的结果方向可能不一致,这是因为两次调用各自生成随机数。常见做法是引入albumentations,一个变换同时处理image和mask:
import albumentations as A train_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.3), A.RandomSizedCrop(min_max_height=(256, 480), height=512, width=512, p=0.5), A.GaussNoise(var_limit=(10.0, 50.0), p=0.2), ], additional_targets={'mask': 'mask'})上面的配置里,HorizontalFlip增强样本多样性;RandomBrightnessContrast模拟光照变化,对户外猫狗图很实用;RandomSizedCrop相当于随机裁剪加缩放,帮助网络适应猫狗尺度变化。albumentations对mask默认使用最近邻插值,不会因为缩放产生类别边界上的中间像素值,这是它比通用图像增强库更适合分割任务的原因。
增强参数在测试集上不启用,验证集和测试集只做归一化和尺寸对齐,目的是让指标反映真实分布。如果发现训练集loss很低、验证集mIoU波动大,优先检查是否把增强带入了验证流程。增强强度的设置可以看训练曲线:几十个epoch后训练loss还在缓慢下降但验证不再提升,就说明增压强或模型容量不够,通常是前者,先下调RandomSizedCrop的概率,再看验证集曲线是否恢复平稳。
5. UNet训练自己的猫狗分割数据集:最小可跑的PyTorch骨架
数据划分和损失函数确认后,用UNet这类2D分割模型训练猫狗分割是常见做法。这里不展开模型创新,重点把Dataset、训练循环、超参三个环节写清楚,保证换到别的分割数据集也能改得动。
5.1 Dataset:原图与mask要成对读取
模型训练需要从train.txt读文件名,然后在images和masks目录里分别找同名文件。Dataset实现的关键是mask的读取方式必须与前文约定一致:
import cv2 import torch from torch.utils.data import Dataset from pathlib import Path class CatDogSegDataset(Dataset): def __init__(self, image_dir, mask_dir, file_list, transform=None): self.image_paths = [Path(image_dir) / (name + ".jpg") for name in file_list] self.mask_paths = [Path(mask_dir) / (name + ".png") for name in file_list] self.transform = transform def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image = cv2.imread(str(self.image_paths[idx])) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) mask = cv2.imread(str(self.mask_paths[idx]), cv2.IMREAD_GRAYSCALE) if self.transform is not None: transformed = self.transform(image=image, mask=mask) image, mask = transformed["image"], transformed["mask"] # HWC -> CHW,归一化到 [0,1],mask 保持类别索引 image = torch.from_numpy(image.transpose(2, 0, 1)).float() / 255.0 mask = torch.from_numpy(mask).long() return image, mask注意cv2.imread读彩色图默认是BGR顺序,转成RGB再做归一化,避免训练出的模型在颜色语义上有偏差。mask用IMREAD_GRAYSCALE直接得到单通道类别索引,不要用RGB转灰度,因为RGB的猫狗mask一旦被量化成灰度,类别边界会混入中间值。transform在train阶段传入albumentations的Compose对象,val和test阶段传None,只做标准化。
5.2 训练循环:验证集也要走同一套指标
训练循环里最重要的不是反向传播写法,而是验证集的处理策略。验证集不参与梯度更新,每个epoch结束要算一次mIoU或Dice,用来决定是否覆盖保存最佳权重。下面是一个可运行的最小骨架:
import torch import torch.nn as nn from torch.utils.data import DataLoader model = UNet(in_channels=3, num_classes=3).to(device) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr=1e-3, total_steps=total_steps, pct_start=0.1 ) for epoch in range(30): model.train() for images, masks in train_loader: images, masks = images.to(device), masks.to(device) logits = model(images) loss = combined_loss(logits, masks) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() # 验证阶段 model.eval() with torch.no_grad(): val_loss, val_dice = evaluate(model, val_loader, device) if val_dice > best_dice: best_dice = val_dice torch.save(model.state_dict(), "best_catdog_unet.pth")这里用AdamW配合OneCycleLR,初始学习率1e-4,峰值1e-3。OneCycleLR先升后降的学习率曲线在分割任务上比固定学习率更省调参时间。total_steps需要提前算出来,就是train_loader的长度乘以epoch数。evaluate里只做前向和指标计算,不做增强、不做dropout。model.eval()忘记调用,batchnorm统计的是训练batch的均值和方差,val结果会偏高,这个细节在验证loss异常低于训练loss时最先排查。
5.3 输入尺寸、batch size与epoch的适配关系
猫狗数据集图片来源杂,常见做法是统一resize到512x512或256x256。512分辨率的细节保留更完整,显卡显存有限就降级到256,配合8到16的batch size。下面是两档常见配置:
| 输入尺寸 | batch size | 8G显存 | 训练速度 | 适用情况 |
|---|---|---|---|---|
| 256x256 | 16 | 可以 | 较快 | 快速验证 |
| 512x512 | 4 | 可以 | 较慢 | 精调阶段 |
batch size不宜设到2以下,否则batchnorm统计不稳定,UNet训练容易震荡。epoch设置30是起步值,猫狗分割不是特别困难的数据集,通常30到50个epoch就足够。判断标准是验证集mIoU是否连续10个epoch不再上升,如果是,就停止训练加载最佳权重,而不是死等完整epoch数。测试集全程不参与早停判断,否则测试集信息会间接泄漏到模型选择里。
训练过程中有一个常见坑是mask在DataLoader里被自动stack成长整型,而损失函数里的target_one_hot需要float类型。建议在损失函数内部做转换,不要在collate_fn里反复转,减少出错面。模型的输出通道数与类别数保持一致,背景、猫、狗对应0、1、2,顺序一旦反了,测试结果会非常难看但loss却不高,这类错误在指标可视化阶段才容易被发现。
6. 测试集评分与三个容易翻车的细节
训练结束只完成一半。测试集上的评分要回答“这个模型在没见过的猫狗图像上到底行不行”,比训练集上的表现更关键。
6.1 用mIoU与Dice给测试集打分
评估代码建议自己写,不依赖训练框架的metrics模块。mIoU是按类计算交并比后取平均,Dice是区域重叠度的另一种表达:
def compute_metrics(pred_mask, true_mask, num_classes=3): ious, dices = [], [] pred_mask = pred_mask.ravel() true_mask = true_mask.ravel() for cls in range(num_classes): p = (pred_mask == cls) t = (true_mask == cls) inter = (p & t).sum() union = (p | t).sum() # 该类别在真实mask中不存在时跳过 if union == 0: continue ious.append(inter / union) dices.append(2 * inter / (p.sum() + t.sum() + 1e-6)) return np.mean(ious), np.mean(dices)注意这里对测试集中没有出现的类别直接跳过,不会把0贡献计入平均,这比直接除以类别总数更合理。测试集评分后要打印每个类别的单独IoU,而不只报告平均值。猫狗数据常见的情况是猫的IoU高、狗的IoU低,平均值掩盖了单类差异,导致模型看起来正常,但细分场景不达标。
6.2 三个翻车细节与对应修复
第一个细节是mask读取通道。用PIL的Image.open后不转RGB,直接拿到的是P模式的调色板索引,像素值与标注时的类ID才一致。一旦用convert("RGB")再转灰度,调色板索引会被打散,误读成另一个类别。
第二个细节是预测结果的类别顺序。模型输出的logits按通道排列,argmax得到0、1、2,要与训练时的背景、猫、狗顺序一致。如果训练时把猫作为第0类,评估时按第1类找,整张图会错位。
第三个细节是mask边界噪声。小模型预测的边缘常有碎屑或空洞,先做一次形态学开运算再算指标:
from scipy import ndimage def clean_mask(mask, kernel_size=3): structure = ndimage.generate_binary_structure(2, 1) return ndimage.binary_opening(mask, structure, iterations=1).astype(mask.dtype)6.3 把预测结果叠加到原图上验证
最后给一个必须养成的习惯:每个epoch挑几张测试图,把预测mask以半透明方式叠加到原图保存,视觉检查往往能比数字指标更快发现标签错位和类别混淆。保存成debug_epoch_xx.png,在排错时直接翻这些图,而不是只看控制台里的mIoU数字。用这个习惯跑两轮实验,你就能快速判断问题到底出在训练集划分、mask标注还是模型容量上。
本文还有配套的精品资源,点击获取