第一次把 COCO-Stuff 的标注图拖进看图软件时,我盯着屏幕上那张灰扑扑的 PNG 愣了好一会儿——放大之后全是密密麻麻的数字色块,跟原图完全对不上号。当时脑子里蹦出来的第一个念头是:这东西到底怎么喂给分割网络?后来踩了几轮坑才明白,COCO-Stuff 的用法难点根本不在模型,而在数据本身的组织方式:它的类别编号不连续、thing 和 stuff 两套体系混在一张图里、10K 和 164K 两个版本的标注质量差了一大截。这篇就按我自己的实操顺序,把 COCO-Stuff 从下载到跑通训练、再到评估指标对得上号的完整链路捋一遍,中间会重点讲清楚几个容易翻车的环节,比如像素值到类别索引的映射、忽略标签该设成几、mIoU 为什么算出来跟论文对不上。无论你是刚接触语义分割想找个类别丰富的数据集练手,还是已经跑过 Cityscapes、ADE20K 想换个场景验证模型泛化性,下面这些内容应该都能直接抄作业。
1. 从 COCO 到 COCO-Stuff:一张标注图背后的设计取舍
1.1 thing 和 stuff 这对概念,决定了整个数据集的形态
要理解 COCO-Stuff 的用法,得先把 thing 和 stuff 这对概念掰开。thing 指的是那些"可数、有明确边界、独立成个体"的目标,比如人、车、杯子、键盘;stuff 指的是那些"不可数、没有固定形状、铺满一片区域"的东西,比如天空、草地、马路、墙面。COCO 原始数据集只标注了 80 类 thing,所有的背景区域一律不管——这在检测任务里没问题,但做语义分割的时候就会很尴尬,因为模型根本不知道天空和墙壁有什么区别,全被当成"背景"一个类。
COCO-Stuff 做的事情就是在 COCO 的基础上,把 91 类 stuff 补了进去,让每一张图有了逐像素的完整标注。最终 164K 版本里有 80 个 thing 类加 91 个 stuff 类,再加一个 unlabeled 类,一共 172 个标签。这个数字记一下,后面配模型输出通道数的时候要用到。
这个设计带来的直接好处是:模型的输出空间从"80 类前景 + 1 类背景"变成了 172 类细粒度语义,天空、云、树、草地、路面这些以前被合并的类别全都拆开了。坏处也很明显——类别数一多,长尾问题就被放大,COCO-Stuff 里像"牙刷""消防栓"这种类的像素占比可能连万分之一都不到,训练时如果不用类别平衡采样,模型基本学不会。这是后话,先记着。
1.2 10K 和 164K:两个版本不是"多少"的区别
刚上手的人最容易犯的错,就是把 COCO-Stuff 10K 和 COCO-Stuff 164K 当成"小样本版"和"完整版"的关系,随便挑一个用。实际上这两个版本的差别是标注来源不同,直接影响你能拿它做什么。
| 对比项 | COCO-Stuff 10K | COCO-Stuff 164K |
|---|---|---|
| 图片数量 | 10,000 张(9000 训练 / 1000 测试) | 约 16.4 万张(含 COCO 2017 全部图片) |
| 类别数 | 171(80 thing + 91 stuff) | 172(80 thing + 91 stuff + unlabeled) |
| 标注方式 | 全部人工像素级标注 | 自动化流水线生成,部分人工校验 |
| 标注质量 | 高,边界干净 | 有噪声,细碎区域容易出错 |
| 底图来源 | COCO 2014 | COCO 2017 |
| 适用场景 | 算法对比、论文复现、小规模调试 | 大规模预训练、数据量需求高的场景 |
我自己的经验是:做方法验证和调参首选 10K,因为它标注干净,模型学到的边界更准,loss 曲线也更平滑,出问题的时候你基本能确定是模型的问题而不是标注的问题。等到方法定型、需要冲精度的时候再切到 164K,这时候数据量的收益才显现出来,但你必须接受它的标注噪声,并且在数据增强和 loss 设计上留出容错空间。
还有一个坑:164K 的自动标注在细长物体上表现特别差,比如电线杆、栏杆、树枝这类。我在可视化预测结果的时候经常看到模型把一段栏杆切成好几段,人眼一看就知道不对,但去查标注发现标注本身也是断的。所以拿 164K 训出来的模型,评估的时候别太纠结那几个点的 mIoU 差距。
2. 下载与落盘:目录结构没对齐,后面全是返工
2.1 官方下载脚本做了哪些事
COCO-Stuff 的官方仓库提供了下载脚本,分 10K 和 164K 两套。这一步看起来简单,但目录结构如果一开始没摆对,后面写 Dataset 的时候会反复返工。我见过不少人在这一步把图片和标注分开放在两个互不相干的目录,结果写 dataloader 的时候路径拼了半小时。
164K 版本下载完之后,一个比较顺手的目录结构长这样:
cocostuff/ ├── images/ │ ├── train2017/ # 原图,jpg │ └── val2017/ ├── annotations/ │ └── stuffthingmaps_trainval2017/ │ ├── train2017/ # 标注图,png │ └── val2017/ ├── labels.txt # 类别 id 与名称的对应表 └── imageLists/ ├── train.txt # 图片 id 列表 ── val.txt原图和标注图的文件主名必须一致,比如原图是000000123456.jpg,标注就得是000000123456.png。下载脚本一般会保证这一点,但如果你手动解压的时候把嵌套目录搞乱了,就会出现在标注目录里找不到对应 png 的情况。我的做法是先跑一遍一致性检查:遍历标注目录,逐个确认原图存在,反之亦然,缺一个就打印出来。这个检查脚本我建议你也写一个,五秒钟的事,能省掉后面调试 dataloader 时的大量时间。
另外注意磁盘空间。164K 的原图加上标注图,解压之后大概是几十 GB 的量级,/tmp或者系统盘小的话容易爆。我一般会把数据软链到独立的数据盘上,ln -s建个链接就行,PyTorch 的 dataloader 对符号链接是完全透明的,不用担心。
2.2 标注 PNG 的像素值就是类别号
这是整个 COCO-Stuff 用法里最反直觉的一点,也是我最想强调的地方:标注 PNG 不是彩色的,它是单通道的 8 位灰度图,每个像素的值直接就是类别 id。
什么意思呢?你把标注图用 PIL 打开,转成 numpy 数组,得到的 dtype 是uint8,数值范围在 0 到 182 之间。某个像素值是 92,那它代表的可能就是"天空"这一类(具体对应关系要查labels.txt)。它不是用颜色区分类别的,没有任何调色板信息,纯粹就是数字。
from PIL import Image import numpy as np label = np.array(Image.open('annotations/stuffthingmaps_trainval2017/train2017/000000123456.png')) print(label.dtype) # uint8 print(label.shape) # (H, W),单通道 print(np.unique(label)[:20]) # 看看这张图里出现了哪些类别这段代码里有几个细节值得说一下。第一,Image.open打开 PNG 之后不要急着.convert('RGB'),那会把单通道复制成三通道,白白浪费三倍内存,而且数值不变但容易让后面读代码的人误解。第二,np.array出来的 dtype 是uint8,如果你后面要做类别 id 的加减运算,记得先转成int64,否则 uint8 溢出会静默发生——比如 200 加 100 会得到 44,这种 bug 极难排查。第三,np.unique是排查标注问题的好工具,一张正常的 COCO-Stuff 标注图里通常会出现十几个到几十个类别值,如果某张图只有一个非零值,大概率是标注异常,可以考虑在训练时过滤掉。
还有个隐蔽的坑:PNG 是有损压缩格式吗?不是,PNG 是无损的,所以像素值可以精确恢复。但如果你手贱把标注图转成了 JPEG 再转回来,像素值就被破坏了,整个数据集就废了。这种事情我身边真的有人干过,转格式的时候没注意文件扩展名,几百张标注全废。所以处理标注图的时候,任何格式转换操作都要极其谨慎。
3. labels.txt 与类别编号:最容易翻车的环节
3.1 编号不连续这件事
打开labels.txt,你会看到类似这样的内容:
0:unlabeled 1:person 2:bicycle 3:car ... 90:toothbrush 92:... ... 182:...注意看,thing 类用的是COCO 原始的 category_id,而 COCO 的 category_id 本身是有空洞的——1 到 90 之间并不是每个数字都有对应的类,中间有几个位置是空的(COCO 早期版本删过类,id 没重排)。stuff 类则从 92 开始往后分配,一直排到 182 附近。
这就导致了一个核心问题:COCO-Stuff 的类别 id 在数值上是不连续的。你不能简单地认为类别总数是 183,也不能直接把 id 当作模型输出的 channel 索引。模型的输出通常需要是 0 到 N-1 的连续整数,其中 N 是你实际使用的类别数(比如 171 或 172),所以中间必须做一次映射。
我第一次写 Dataset 的时候就在这儿翻了车。当时的思路特别朴素:读进来 label,直接torch.from_numpy(label).long(),然后丢进CrossEntropyLoss。跑起来没报错,但训练几个 epoch 之后发现某些类完全没学会,可视化一看全是乱七八糟的。原因就是模型输出的 channel 数设成了 183,而其中一大半 channel 在标注里永远不会出现,梯度永远是零;同时有些 channel 对应的实际类别又混在一起。
3.2 三种索引体系的转换
在实际工程里,一共有三套"编号"在流转,必须分清楚它们各自是什么:
| 索引体系 | 说明 | 范围 |
|---|---|---|
| 原始标注 id | PNG 里的像素值,等于 labels.txt 的 key | 0–182,不连续 |
| 连续类别索引 | 压缩到 0..N-1,用于模型输出和 loss | 0–170 或 0–171,连续 |
| 忽略索引 | 表示"这个像素不计入 loss",需自己指定 | 通常设为 255 或 N |
映射表的构建逻辑很直接,就是把labels.txt里所有非零 id 排个序,然后依次给 0、1、2……具体做法有三种常见选择,各有取舍:
- 包含 unlabeled 类:把 id 0 也当成一个正式类别参与训练和评估。这样模型会去学"这里是没有标注的区域",在推理阶段能输出一块"未知"区域。好处是语义完整,坏处是 unlabeled 区域的语义本身很杂,会稀释其他类的学习信号。
- 排除 unlabeled,映射到 255:把 id 0 直接变成 255 当忽略标签,loss 不算它,评估也不算它。这是最主流的做法,也是我推荐新手先用的。
- 只保留 thing 类:如果你只想做实例级别的分割,可以把 91 个 stuff 类也忽略掉,退化成"COCO 原来那 80 类 + 背景"。这种用法比较少见,但确实有人这么做。
def build_label_mapping(label_file, with_unlabeled=False, ignore_index=255): raw_ids = [] with open(label_file, 'r') as f: for line in f: line = line.strip() if not line or ':' not in line: continue cid = int(line.split(':')[0]) if cid == 0 and not with_unlabeled: continue raw_ids.append(cid) raw_ids = sorted(raw_ids) raw2cont = {cid: i for i, cid in enumerate(raw_ids)} cont2raw = {i: cid for cid, i in raw2cont.items()} raw2cont[0] = ignore_index # 原来的 unlabeled 一律变忽略 return raw2cont, cont2raw这段代码里最关键的是最后一行:不管你要不要 unlabeled 类,都建议把它落到 ignore_index 上。因为 COCO-Stuff 里 unlabeled 的区域主要是那些标注者也不确定的地方,让模型去硬猜没有意义。
提示:注意
labels.txt里那一行的实际分隔符。不同来源的版本有的用冒号、有的用制表符,直接写死冒号可能在别人机器上就跑不通。稳妥的做法是先读第一行看看格式,或者用re.split(r'[:\t ]+', line)这种宽容一点的写法。
4. 自己写 Dataset:从 PNG 到训练张量
4.1 读图、同步几何变换
写 COCO-Stuff 的 Dataset,核心难点只有一个:几何变换必须同时作用在原图和标注图上。原图做随机裁剪、翻转、缩放之后,标注图必须做完全一样的空间变换,否则像素级别的对应关系就全乱了。这是分割任务和分类任务最大的区别,分类任务随便怎么增强图片都行,分割任务一不小心就会造出错误的监督信号。
我最开始图省事,用了torchvision.transforms里给分类用的那套,结果发现标注图被单独 resize 了,尺寸对不上,训练时直接报 shape mismatch。后来换成手动同步的方式才稳。下面是一个可以跑的骨架:
import os import numpy as np import torch from PIL import Image from torch.utils.data import Dataset import torchvision.transforms.functional as TF import random class CocoStuffSegDataset(Dataset): def __init__(self, root, split='train2017', crop_size=(512, 512), raw2cont=None, ignore_index=255): self.img_dir = os.path.join(root, 'images', split) self.ann_dir = os.path.join(root, 'annotations', 'stuffthingmaps_trainval2017', split) self.crop_size = crop_size self.ignore_index = ignore_index self.raw2cont = raw2cont # dict: 原始id -> 连续索引 names = sorted(os.listdir(self.ann_dir)) self.ids = [os.path.splitext(n)[0] for n in names if n.endswith('.png')] def __len__(self): return len(self.ids) def _remap(self, label): out = np.full(label.shape, self.ignore_index, dtype=np.int64) for raw_id, cont_id in self.raw2cont.items(): if cont_id == self.ignore_index: continue out[label == raw_id] = cont_id return out def __getitem__(self, idx): img_id = self.ids[idx] img = Image.open(os.path.join(self.img_dir, img_id + '.jpg')).convert('RGB') label = Image.open(os.path.join(self.ann_dir, img_id + '.png')) # 随机缩放 + 裁剪,两个图同步做 scale = random.uniform(0.75, 1.5) new_h = int(img.height * scale) new_w = int(img.width * scale) img = TF.resize(img, (new_h, new_w)) label = TF.resize(label, (new_h, new_w), interpolation=TF.InterpolationMode.NEAREST) cw, ch = self.crop_size pad_h = max(ch - new_h, 0) pad_w = max(cw - new_w, 0) if pad_h > 0 or pad_w > 0: img = TF.pad(img, (0, 0, pad_w, pad_h), fill=0) label = TF.pad(label, (0, 0, pad_w, pad_h), fill=0, padding_mode='constant') new_h, new_w = img.height, img.width top = random.randint(0, new_h - ch) left = random.randint(0, new_w - cw) img = TF.crop(img, top, left, ch, cw) label = TF.crop(label, top, left, ch, cw) if random.random() < 0.5: img = TF.hflip(img) label = TF.hflip(label) label = np.array(label, dtype=np.int64) label = self._remap(label) img = TF.to_tensor(img) img = TF.normalize(img, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) return img, torch.from_numpy(label).long()有几个地方值得单独拎出来说。首先,TF.resize对标注图必须指定NEAREST插值,默认的双线性插值会把类别 id 混在一起——比如 id 92 和 id 95 之间插值出 93、94,产生根本不存在的类别。这个坑非常典型,我见过不止一个人在论坛上问"为什么训练时出现了 labels.txt 里没有的类别",八成就是这个原因。
其次是 padding。裁剪窗口超出原图范围的时候需要补边,原图补 0 没问题,标注图补 0 就补成了 unlabeled 类,正好对应 ignore,逻辑上是自洽的。但如果你的 ignore_index 设成 255,那 padding 值就必须是 255 而不是 0,否则补出来的边缘会被当成 unlabeled 参与训练。这里我特意把 padding 值设成 0 是因为后面_remap会把 0 映射成 ignore_index,两步走避免混淆。
第三,_remap那个循环在类别数 172 的情况下其实不慢,因为 numpy 的布尔索引很快。但如果你追求极致速度,可以把映射表预计算成一个 256 长度的查找数组,然后直接用索引取值:
lut = np.full(256, ignore_index, dtype=np.int64) for raw_id, cont_id in raw2cont.items(): lut[raw_id] = cont_id # 之后只需要一行 out = lut[label]这个是 O(1) 的,比循环快一个数量级。64 万张图的时候能省不少时间。
4.2 ignore_index 与类别平衡采样
CrossEntropyLoss的ignore_index参数必须和你 Dataset 里使用的忽略值保持一致,这个看似废话但对新手来说特别容易出错。我建议在写代码的时候把这个值定义成一个全局常量,Dataset、loss、评估脚本三处都引用同一个变量,避免"改了这边忘了那边"。
类别平衡这块,COCO-Stuff 的不平衡程度是相当夸张的。我统计过一次 164K 训练集的像素分布,排前三的类(天空、墙面、地面)加起来能占到总像素的一半以上,而排在最后几十个类每个可能只有几千个像素。如果直接均匀采样,模型很快就会倾向于只预测那几个大类,mIoU 卡在 0.3 左右上不去。
常见的解法有两种。一种是按类别频率的反比给每个样本图像赋权重,让包含稀有类的图被更频繁地采样;另一种是在 loss 层面做加权,用CrossEntropyLoss(weight=...)把稀有类的权重调高。我个人的做法是前者为主、后者为辅——采样层面解决"见不到"的问题,loss 层面解决"见到了但不重视"的问题。权重不要调得太狠,一般按1 / log(freq + c)这种形式,比纯粹的反比温和一些,否则训练会变得很不稳定。
还有一个小细节:做数据增强的时候,翻转和旋转对 stuff 类没什么影响,但对 thing 类的方向性是有影响的(比如"人"倒过来就很怪)。所以如果你要把这套数据用于下游的检测或者实例分割任务,增强策略要收敛一点。
5. 接进 MMSegmentation:配置里那几个必须改的字段
5.1 CocoStuffDataset 的重映射逻辑
如果你的项目用的是 MMSegmentation 这类成熟框架,其实不用自己从零写 Dataset,框架里已经有现成的CocoStuffDataset。它内部帮你处理了原始 id 到连续索引的映射,你只需要把数据按约定的目录结构摆好,然后改配置就行。
配置里最关键的几个字段是data_root、dataset_type和metainfo里的类别列表。类别列表的顺序必须和框架内部映射的顺序完全一致,否则你算出来的 mIoU 虽然数值正常,但对应到具体类别名全错位了。我就干过这事:把类别名按字母序排了一遍写进配置,结果评估报告里"天空"的 IoU 其实是"人"的,白折腾了一晚上。
另外要留意框架版本之间的差异。MMSegmentation 的CocoStuffDataset有一个控制标注来源的开关,可以选择直接读官方提供的 PNG 标注图,也可以选择基于 COCO 原始的实例分割标注现算 stuff 区域。这两条路径出来的标签在小区域上会有肉眼可见的区别。稳妥的做法是:先跑一个极小的子集(比如 20 张图),把两种模式下的标签可视化出来对比一下,确认哪种符合你的预期,再去跑全量训练。别嫌麻烦,这一步花十分钟能避免两天后的返工。
5.2 训练参数的经验取值
基于我自己的实操,几个超参的起始值可以这样设:
| 参数 | 建议起始值 | 说明 |
|---|---|---|
| crop_size | 512×512 | 显存够的话可以上 768×768,小物体能好不少 |
| base_lr | 0.01(8 卡线性缩放) | 单卡的话按 0.01 × batch/16 折算 |
| batch_size | 16(单卡 512 裁剪) | 显存吃紧就用梯度累积凑等效 batch |
| 迭代次数 | 160k 起 | COCO-Stuff 类多,训短了收敛不充分 |
| 优化器 | SGD momentum 0.9 | AdamW 也能用,但要重调 lr |
| 权重衰减 | 5e-4 | |
| 忽略索引 | 255 | 与 Dataset 保持一致 |
学习率这块特别提一句。COCO-Stuff 有 172 个类,比 Cityscapes 的 19 类多了一个数量级,梯度噪声自然更大。我一开始沿用 Cityscapes 的 lr 配置,结果 loss 前几千步剧烈震荡,后来把 lr 降了一半、warmup 拉长到 3000 步才稳住。所以别直接套用其他数据集的配置,该调的还是要调。
6. 评估口径:mIoU 算出来对不上是怎么回事
6.1 混淆矩阵路线
很多人第一次自己算 COCO-Stuff 的 mIoU,得到的数字会比论文里低一大截,或者比某个开源实现高很多,然后开始怀疑人生。大多数情况下不是你的模型有问题,而是评估口径不一样。搞清楚这件事,比调模型更能提升你的实验效率。
最可靠的算法是混淆矩阵法。维护一个N×N的矩阵,行是真实类别,列是预测类别,每跑完一个 batch 就累加进去,最后从矩阵里一次算出所有指标:
import numpy as np class ConfusionMatrix: def __init__(self, num_classes, ignore_index=255): self.n = num_classes self.ignore = ignore_index self.mat = np.zeros((num_classes, num_classes), dtype=np.int64) def update(self, pred, gt): pred = pred.reshape(-1) gt = gt.reshape(-1) valid = (gt != self.ignore) & (gt >= 0) & (gt < self.n) pred = pred[valid] gt = gt[valid] idx = gt * self.n + pred bincount = np.bincount(idx, minlength=self.n ** 2) self.mat += bincount.reshape(self.n, self.n) def compute_iou(self): inter = np.diag(self.mat).astype(np.float64) union = (self.mat.sum(axis=0) + self.mat.sum(axis=1) - inter).astype(np.float64) with np.errstate(divide='ignore', invalid='ignore'): iou = np.where(union > 0, inter / union, np.nan) return iou def mean_iou(self): iou = self.compute_iou() return np.nanmean(iou)这段代码里有两个细节值得抠一下。第一,valid那个掩码要同时过滤掉 ignore 值和越界的预测值——模型偶尔会因为某些原因输出超出类别范围的索引,直接索引到矩阵上会报越界。第二,算 mean IoU 的时候用np.nanmean而不是np.mean,这样在某个类完全没有出现的情况下(inter 和 union 都是 0),不会污染平均值。至于"完全没出现的类到底算不算进平均",这本身就是个口径问题,见下一节。
6.2 忽略类与类别集合的差异
评估口径的差异主要体现在三个地方,每一个都能让你的 mIoU 差出好几个点:
第一,unlabeled 类怎么处理。有的实现把 unlabeled 当成一个正经类别参与 mIoU 计算,有的直接忽略。如果参与计算,因为它的像素量大且模型预测得往往还不错,会把均值往上抬。
第二,未出现的类别算不算分母。有些实现用全部 172 类的平均值,有些只统计在验证集里出现过的类。COCO-Stuff 的验证集里,稀有类可能只在一两张图里出现几十个像素,IoU 波动极大,算不算进去对结果影响很明显。
第三,预测结果要不要做多尺度或者翻转增强。这两个操作一般能稳定涨 1 到 2 个点,如果对比的开源实现用了而你没用,差距就出来了。
我的建议是:在实验记录里明确写清楚你用的是哪种口径,别只写一个数字。团队协作或者复现别人结果的时候,这个信息比数字本身还重要。我自己踩过的坑就是拿自己"忽略 unlabeled"的数字去和别人"包含 unlabeled"的数字对比,得出"我的模型差了一截"的结论,白焦虑了好几天。
7. 可视化与实战调参的一些碎碎念
7.1 颜色映射:别用随机颜色
调试阶段可视化预测结果是必须的,但颜色怎么分配有讲究。用随机颜色生成的话,每次跑出来的配色都不一样,你根本没法跟踪同一个类的表现。我一般会把颜色固定下来,用固定的伪随机种子生成,或者干脆手工指定几个关键类的颜色——天空用蓝色、草地用绿色、人用红色、车用黄色,这样一眼就能看出模型是不是把天空预测成了水。
def build_palette(num_classes, seed=42): rng = np.random.RandomState(seed) palette = rng.randint(0, 256, size=(num_classes, 3), dtype=np.uint8) return palette def colorize(mask, palette, ignore_index=255): h, w = mask.shape out = np.zeros((h, w, 3), dtype=np.uint8) valid = mask != ignore_index out[valid] = palette[mask[valid]] return out可视化的时候还有一个技巧:把原图、标注图和预测图横向拼成一张大图,同一行对应同一个样本。这种三联图在排查问题的时候效率极高——你能立刻发现是标注本身有问题(标注图就不对),还是模型没学好(标注对但预测错),还是后处理有问题(预测概率图对了但取 argmax 之后错了)。
7.2 几个我踩过的具体坑
坑一:uint8 溢出。前面提过一次,这里再强调。任何对标注数组做算术运算之前,先.astype(np.int64)。我在做类别合并的时候,把几个 stuff 类合成一个大类,用了label + offset,结果 uint8 回绕,产生了完全不存在的类别 id,训练时 loss 直接变成 nan。
坑二:DataLoader 的 num_workers 别设太大。分割任务的单样本读取比分类重得多,一张 512×512 的图加上标注,解码和变换的开销不小。num_workers 设成 CPU 核心数的一半左右比较合适,设太大反而因为进程间通信开销拖慢速度。我在一台 8 核机器上设了 16,结果比设 4 还慢,挺反直觉的。
坑三:验证集不要开随机增强。这个看着像废话,但我确实见过配置里把训练和验证的 pipeline 复制粘贴,忘了删掉随机裁剪,导致验证集每轮的结果都在抖,根本没法比较不同 checkpoint 的好坏。验证阶段应该用固定尺寸的中心裁剪或者直接整图 resize,保证每次评估的输入是一样的。
坑四:小心 164K 的重复图片。COCO 2017 的训练集和验证集之间有极少量重叠,虽然官方已经清理过大部分,但如果你同时用了 10K 和 164K 的数据做混合训练,一定要先做图片级别去重,否则验证集里的图可能出现在训练集中,指标虚高。
7.3 关于数据规模的实际感受
最后说一下我对数据量的实际感受。从 10K 换到 164K,参数量在 3000 万左右的模型,mIoU 大概能涨 4 到 6 个点,但训练时间长了十几倍。如果你的目标只是验证一个新模块有没有效果,10K 完全够用,甚至 2000 张图的子集都够——只要能稳定看到相对差异就行。真正需要全量数据的场景是:你要发布一个可用的预训练权重,或者你的方法就是冲着榜单去的。
另外,如果你自己的业务数据只有几百张标注图,拿 COCO-Stuff 做预训练再微调,效果通常比从头训练好很多,尤其是那些 COCO-Stuff 里本来就有的类(天空、建筑、路面这些)。微调的时候记得把输出层换掉,并且用较小的学习率(base_lr 的十分之一左右)跑个几十轮,冻结 backbone 前几层会更稳。这套流程我在几个小数据集上都试过,涨点是比较稳定的。