
简介面向计算机视觉图像分割任务的面部眼镜分割数据集包含约一万六千张人脸图像及对应掩码划分为训练集与测试集标注背景与眼镜两个类别适用于人脸解析、眼镜佩戴检测及语义分割模型训练与评估。压缩包共两千个文件以PNG格式保存原图与真值分割掩码另附类别说明文本与可视化脚本整体约八百四十九兆字节。目前已有九十一人浏览学习。数据涵盖多种人脸姿态、表情与户外场景可视化脚本可随机抽取一张样本将原始图片、真值图像及真值叠加蒙板效果同屏展示便于直观核对标注质量也可用于快速训练常见分割网络并评估复杂面部场景下的泛化能力适合中高级深度学习研究者开展分割实验与实践。1. 图像分割数据集不是越多越好约16000张面部眼镜数据先想清楚怎么用很多人拿到一份图像分割数据集第一反应就是赶紧跑模型结果往往白跑一遍。约16000张的面部眼镜图像分割数据集属于那种看起来不大、但足够把一个具体任务做实的规模它聚焦眼镜这个目标在脸部图像里的逐像素分割能直接落到眼镜试戴、美妆 AR、智能眼镜检测和电商眼镜抠图这几类场景。适合两类人用一是刚学图像分割、想拿真实标注数据练手的新手二是已经在做人脸相关产品、需要把眼镜区域从背景里干净摘出来的工程师。16k 张配好标签量上既不会大到处理不动也不会小到学不到东西。但拿到手先别急着训练标签格式和标注质量才是决定你后面十天过得好不好的关键。2. 盘点手里的货面部眼镜数据集的目录结构、标签格式与标注质量检查2.1 先排除第一个翻车点文件命名与数量对齐这类人脸相关数据集最常见的组织方式是 images 和 labels或 masks两个平级目录同名文件一一对应。也就是说images/sample_001.jpg对应的标签应该是labels/sample_001.png。听起来很简单但我在多个公开数据集上见过三种翻车情况图片比标签多、命名后缀不一致、同一张图配了多份不同版本的掩码。拿到数据先做两件事数数量、对命名。数数量直接看两个目录下的文件个数是否一致注意隐藏文件和无损格式后缀差异。find images -type f | wc -l find labels -type f | wc -l如果数量对不上基本可以断定这批数据需要先清洗。数量一致后还要检查命名匹配不能只看后缀因为有的数据集图片叫sample_001.jpg标签却叫sample_001_mask.png。import os img_files sorted(os.listdir(images)) lab_files sorted(os.listdir(labels)) # 提取去后缀后的文件名做交集 img_keys {os.path.splitext(f)[0] for f in img_files} lab_keys {os.path.splitext(f)[0].replace(_mask, ) for f in lab_files} missing img_keys - lab_keys extra lab_keys - img_keys print(缺少标签的图片:, len(missing), list(missing)[:5]) print(缺少图片的标签:, len(extra), list(extra)[:5])这段脚本里两个集合取差集能一眼看出哪些图片没有配标签。说明一下替换_mask前缀是因为很多数据集喜欢在标签文件名里加后缀直接比较会误报全部不匹配。如果差集很大先去看 README 或目录说明不要自己在代码里瞎猜命名规则。这个检查 10 分钟能做完能省下后面好几个小时的 debug 时间。2.2 识别标签格式PNG 掩码、彩色掩码与 COCO JSON 怎么区分「有标签」和「标签能用」是两码事。面部眼镜这类细粒度分割数据集的标签常见有三种存储形式它们的读取方式和后续处理链路完全不同。标签格式典型后缀存储内容直接喂分割模型单通道 PNG 掩码.png灰度图像素值 0 或 255可以但需归一化彩色/索引 PNG.png每个类别一种颜色如镜框红色、镜片绿色不能直接用需颜色映射COCO JSON.json多边形坐标 类别名不能直接用需转掩码识别方法很简单读一张标签出来看通道数和唯一值就行。import cv2 import numpy as np mask cv2.imread(labels/sample_001.png, cv2.IMREAD_UNCHANGED) print(shape:, mask.shape, dtype:, mask.dtype) print(unique values:, np.unique(mask))如果打印结果是shape: (512, 512)、dtype: uint8、unique values: [0, 255]恭喜这是最干净的单通道二值掩码后面所有工作都能省一半。如果是shape: (512, 512, 3)或(512, 512, 4)说明是彩色或带 alpha 通道的掩码必须做颜色映射转成单通道标签。如果是unique values: [0, 1]而 dtype 是 float也要小心后面转 long 时容易出类型问题。提示如果标签后缀是 .jpg基本可以判定这套标注是压缩过的边缘会有一圈混色像素不适合直接当分割标签用。分割标注必须是无损格式。COCO JSON 的情况稍微复杂一点。annotations里每个对象有个segmentation字段里面是多边形顶点坐标需要你先把多边形填充成掩码。常见做法是用pycocotools的maskUtils或者直接用 OpenCV 的fillPoly。我一般会优先写一个通用转换脚本把 JSON 一次性转成 PNG 掩码存盘而不是每个 epoch 都现场转省得训练时反复解析。2.3 标注质量不是玄学用叠加可视化抽检 50 张标注质量判断这件事很多人靠肉眼翻文件夹效率低还会漏。正确做法是把掩码叠加到原图上生成一张网格预览图一次看 2050 张。重点看三个地方镜框边缘是否贴合真实轮廓、镜片透明区域是否被标进去、镜腿和头发的边界是否干净。import numpy as np from PIL import Image import matplotlib.pyplot as plt img np.asarray(Image.open(images/sample_001.jpg).convert(RGB)).copy() mask np.asarray(Image.open(labels/sample_001.png).convert(L)) mask_bin (mask 127).astype(np.uint8) # 把前景染成半透明红色 overlay img.copy() overlay[mask_bin 1] (overlay[mask_bin 1] * 0.4 np.array([255, 0, 0]) * 0.6).astype(np.uint8) fig, ax plt.subplots(1, 2, figsize(10, 5)) ax[0].imshow(img) ax[0].set_title(original) ax[1].imshow(overlay) ax[1].set_title(overlay) plt.savefig(check_sample_001.png, dpi150, bbox_inchestight)说下这段的几个关键点掩码读出来一定要转成单通道灰度再二值化有些彩色掩码直接用mask 1会得到全 False叠加时前景像素取原图和红色的加权平均这样能看到透明镜片和肤色边界到底标成什么样。抽检时我会保存成一张横向对比图方便和同事或标注方沟通哪些样本要返工。提示如果抽检发现某类样本比如墨镜、半框镜、运动镜标注风格明显不一致先记录样本索引后面切分时尽量把它们均匀分到训练集和验证集否则模型会对这类样本严重偏心。3. 把标签转成 U-Net 能直接吃的样子彩色掩码归一化、按人切分与数据增强3.1 定任务镜框和镜片要不要分开预测拿到 16000 张眼镜分割数据后第一步不是急着写模型而是把任务定义清楚。眼镜这个目标可以当成一个整体二分类前景是整副眼镜背景是其他也可以拆成镜框、镜片、背景三个类别。这个选择直接影响模型输出通道数、损失函数和最终产品能力。什么时候拆开典型场景是眼镜试戴 AR用户需要单独给镜片换颜色或调透明度镜框要保留原样这时候必须分开预测。而电商眼镜抠图、遮挡判断这类任务只需要整体轮廓二分类就够了。多数公开的眼镜分割数据集提供的是「整副眼镜一个前景」的标签但也会有一部分对镜框和镜片用了不同颜色标注。我建议拿到数据先看一眼 2.3 的叠加图如果掩码内部颜色一致就按二分类做如果镜框和镜片明显是两类颜色就按三分类做别浪费标注信息。3.2 彩色掩码转单通道标签映射脚本与未知颜色处理如果你的标签是彩色 PNG比如背景黑色、镜框红色、镜片绿色那你需要一个颜色映射脚本。原理是先定义颜色到类别 id 的映射表再按像素精确匹配。import numpy as np from PIL import Image # 颜色到类别 ID 的映射按你数据集的实际情况改 PALETTE { (0, 0, 0): 0, # 背景 (255, 0, 0): 1, # 镜框 (0, 255, 0): 2, # 镜片 } mask np.asarray(Image.open(labels/sample_001.png).convert(RGB)) h, w, _ mask.shape label np.zeros((h, w), dtypenp.uint8) for rgb, cls_id in PALETTE.items(): label[np.all(mask np.array(rgb), axis-1)] cls_id # 统计没被任何颜色命中的像素 unknown_mask (label 0) ~np.all(mask np.array((0, 0, 0)), axis-1) print(unknown ratio:, unknown_mask.mean()) Image.fromarray(label, modeL).save(labels_uint8/sample_001.png)这段代码里最关键的是np.all(..., axis-1)它要求三个通道同时等于目标 RGB 值才算命中。很多人图省事直接mask (255,0,0)结果得到的是一个三通道布尔矩阵用于索引时行为完全不对。另外一个必须处理的问题是未知颜色如果数据集里出现你没见过的颜色label里这些像素会落到 0而 0 恰好是背景 id等于静默地把这些像素标成背景训练时模型会在这些区域学到错误信息。所以我打印出unknown ratio如果比例超过千分之一就去把调色板补全。3.3 切分训练集别随机按人物 ID 分组防止数据泄漏16k 张照片通常来自几百个人的多张照片。如果直接随机切分同一个人的不同照片会同时出现在训练集和验证集模型学到的是「记住这张脸」而不是「理解眼镜的结构」验证指标会虚高。这是人脸类数据集最隐蔽的数据泄漏。正确做法是按人物 ID 分组。多数数据集文件名会有规律比如p001_0001.jpg、p001_0002.jpg人物 ID 就是第一个下划线前的部分。import os import random from collections import defaultdict files sorted(os.listdir(images)) persons defaultdict(list) for f in files: pid f.split(_)[0] # 根据实际命名规则取人物 ID persons[pid].append(f) ids list(persons.keys()) random.seed(42) random.shuffle(ids) n len(ids) train_ids set(ids[: int(n * 0.8)]) val_ids set(ids[int(n * 0.8) : int(n * 0.9)]) test_ids set(ids[int(n * 0.9) :]) train_files [f for pid in train_ids for f in persons[pid]] val_files [f for pid in val_ids for f in persons[pid]] test_files [f for pid in test_ids for f in persons[pid]] print(len(train_files), len(val_files), len(test_files))切分比例我习惯用 8:1:1。分割任务验证集不需要太大重点是训练集尽量多覆盖不同脸型、不同佩戴角度。random.seed(42)一定要固定否则每次跑出来的切分都不同实验对比就失去意义。如果文件名里没有人物 ID那就只能退而求其次按文件名前缀粗分如果连前缀都没有至少按文件夹聚类然后人工抽查有没有同一人的照片散落在不同集合里。3.4 数据增强眼镜是刚体重点加光照扰动而非常规形变眼镜不会变形所以 ElasticTransform、格点变形这类强形变增强不适合用在这个任务上。真正有效的是光照扰动、轻微旋转、水平翻转和随机裁剪。眼镜轮廓对亮度敏感因为镜片是透明的不同光照下镜片区域的外观差异很大如果模型没见过足够多的亮度变化实际部署时遇到逆光照片就会翻车。我一般用 albumentations 来做增强它能保证图像和掩码做完全相同的空间变换这是手写增强最容易出错的地方。import albumentations as A from albumentations.pytorch import ToTensorV2 transform A.Compose([ A.RandomResizedCrop(512, 512, scale(0.8, 1.0), ratio(1.0, 1.0), p1.0), A.HorizontalFlip(p0.5), A.RandomBrightnessContrast(brightness_limit0.15, contrast_limit0.15, p0.6), A.HueSaturationValue(hue_shift_limit5, sat_shift_limit10, val_shift_limit10, p0.3), ]) # 使用方式 transformed transform(imagenp_img, masknp_mask) aug_img transformed[image] aug_mask transformed[mask]几个参数值得说清楚。RandomResizedCrop的ratio(1.0, 1.0)是我特意固定的避免把眼镜拉宽拉窄如果想让模型对透视更鲁棒可以放宽到(0.9, 1.1)但不要用(0.8, 1.2)这种大跨度。scale(0.8, 1.0)表示裁剪区域占原图比例保证眼镜主体能被裁进去。HueSaturationValue的hue_shift_limit5设得比较小因为肤色对色相很敏感hue 变动太大会让模型把肤色和镜框混淆。所有这些空间变换裁剪、翻转albumentations 会自动同步到掩码且掩码会使用 nearest 插值不会产生 0.5 这种假标签——这是它比 torchvision 自带 transform 省心的地方。提示如果你自己写 transform掩码的 resize 插值必须用cv2.INTER_NEAREST用线性插值会给掩码边缘带来大量介于 0 和 1 之间的像素模型会学到「模棱两可的边界」这属于数据增强里最隐蔽的坑。4. 用 16000 张眼镜数据训一个能用的分割模型U-Net 的 DataLoader、Loss 与训练参数4.1 选型为什么 16k 量级下优先用 U-Net 而不是别的分割算法如果你只是想把眼镜框出来、不关心像素级轮廓那用 YOLOv8 训练自己的数据集走检测路线会更快。但标题里明确是「图像分割数据集」任务是逐像素分割就得用分割模型。在 16k 这个量级上我首选 U-Net 加预训练 encoder。原因有三个一是 U-Net 的跳跃连接能在小数据集上保住空间细节镜腿这种细长结构不容易丢二是它训练稳定不需要特别精细的调参就能收敛到可用的水平三是显存占用比 DeepLabV3 这类带空洞卷积的模型小512 输入配 ResNet34 encoder 在 12GB 显存上跑得动。常见做法是使用带预训练 encoder 的 U-Net比如 segmentation_models_pytorch 库里的Unet(encoder_nameresnet34, encoder_weightsimagenet)。ResNet34 在 ImageNet 上学过的底层特征对镜框边缘、镜片反光这类通用纹理有很强的迁移效果比从头训练快得多也稳得多。如果你对推理速度有要求可以把 encoder 换成mobilenet_v2精度会掉两三个点但推理时间能砍一半。4.2 DataLoader图像尺寸统一与归一化参数的四个选择图像分割训练里 DataLoader 的写法直接决定训练能不能跑得动。眼镜分割数据的原图尺寸往往不统一有的是手机自拍竖图有的是摄像头横图必须做尺寸统一。import torch import numpy as np from torch.utils.data import Dataset from PIL import Image class GlassesDataset(Dataset): def __init__(self, file_list, transformNone): self.file_list file_list self.transform transform def __len__(self): return len(self.file_list) def __getitem__(self, idx): img_path self.file_list[idx] lab_path img_path.replace(images, labels) img np.asarray(Image.open(img_path).convert(RGB)) mask np.asarray(Image.open(lab_path).convert(L)) mask (mask 127).astype(np.uint8) # 归一到 0/1 if self.transform: out self.transform(imageimg, maskmask) img, mask out[image], out[mask] # 归一化由 albumentations 里的 Normalize 完成这里只转 tensor 和类型 return torch.as_tensor(img, dtypetorch.float32), torch.as_tensor(mask, dtypetorch.long)这里有两个容易踩的细节。第一mask 127这步必须做因为有些 PNG 的白色区域是 255直接当 1 传给模型数值范围不一致会导致 loss 波动。第二mask 转成torch.long而不是 float这样后面接交叉熵或 Dice Loss 时不会因为类型问题报错。输入尺寸建议从 512 开始显存紧张就降到 320。归一化参数用 ImageNet 的 mean/std因为 encoder 是用 ImageNet 预训练的输入分布不一致会让迁移效果打折扣。4.3 Loss眼镜占比可能不到 5%Dice Loss 与 BCE 混合怎么配眼镜在一张脸图里占的比例通常很小尤其是远距离或侧脸照片前景像素可能只有几千个背景却有几十万个。这种情况下纯 BCE Loss 会让模型很快收敛到「全预测背景」因为这样 loss 已经很低了。Dice Loss 关注的是前景区域的交并比对类别不均衡天然免疫但它对错误像素的梯度不够平滑。我的习惯是把 Dice Loss 和 BCE 按 0.5:0.5 混合。import torch import torch.nn as nn import torch.nn.functional as F class DiceBCE(nn.Module): def __init__(self, dice_weight0.5, smooth1.0): super().__init__() self.dice_weight dice_weight self.smooth smooth def forward(self, logits, targets): probs torch.sigmoid(logits) # (B,1,H,W) bce F.binary_cross_entropy(probs, targets.float()) p probs.view(probs.size(0), -1) t targets.view(targets.size(0), -1) intersection (p * t).sum(dim1) dice 1 - (2.0 * intersection self.smooth) / ( p.sum(dim1) t.sum(dim1) self.smooth ) return self.dice_weight * dice.mean() (1 - self.dice_weight) * bcedice_weight0.5是个比较均衡的起点如果发现前景占比特别小可以调到 0.7 让 Dice 主导。smooth1.0的作用是防止某个 batch 里恰好没有眼镜像素时分母为 0虽然概率低但碰上一次训练就 NaN 了。注意这里的 Dice 是1 - Dice表示距离模型预测越准loss 越接近 0。模型输出通道数为 1用 sigmoid 激活而不是 softmax因为这是二分类任务。4.4 最小训练骨架优化器、学习率与训练轮数的设置数据规模 16k、输入 512、encoder 预训练这个组合下 Adam 配 1e-4 学习率基本不会出错。训练轮数 60 轮左右能看到 Dice 曲线平稳再多容易过拟合到标注噪声上。下面是去掉验证和日志后的最小骨架。model model.cuda() optimizer torch.optim.Adam(model.parameters(), lr1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max60) criterion DiceBCE(dice_weight0.5) for epoch in range(60): model.train() for imgs, masks in train_loader: imgs, masks imgs.cuda(), masks.cuda() logits model(imgs) loss criterion(logits, masks) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()几个参数说清楚。lr1e-4对预训练 encoder 是安全的如果从头训练可以提到 1e-3但这里没必要。CosineAnnealing 让后期学习率平滑下降比 StepLR 更适合分割任务的稳定收敛。batch size 在 12GB 显存上建议 8再大打满显存就别想跑验证了。训练初期如果发现 loss 完全不降先回 5.1 检查数据读取不要急着调学习率。参数推荐值理由输入尺寸512x512保留镜腿细节12GB 显存可跑batch size8平衡显存与 BN 统计稳定性学习率1e-4适配预训练 encoder训练轮数6016k 量级约 30 轮开始稳定60 轮收敛5. 眼镜分割训练中的 5 条踩坑记录从全黑预测到数据泄漏怎么排查5.1 训练 loss 不降且预测全黑先检查 mask 是不是读错了现象训练了好几个 epochloss 纹丝不动验证集预测结果全黑或全白一张眼镜轮廓都看不到。原因标签读取出了问题。最常见的是用cv2.imread默认三通道方式读单通道 PNG然后直接把三通道数组当索引用更隐蔽的是标签里前景不是 255 而是 1你在二值化时用了mask 127导致所有前景像素全被当成背景。解决按 2.2 的方式打印np.unique(mask)确认取值读取时统一用Image.open(...).convert(L)二值化阈值先看一眼像素分布再写死。这些年我见过的「模型学不会」案例里至少一半是数据读取问题不是模型问题。5.2 镜腿和头发糊在一起标签边界噪声要怎么清洗现象训练出的模型在镜腿边缘向外扩一圈把头发丝也划进前景视觉效果上眼镜像长了毛。原因原始标签里镜腿和头发交界处本身就是渐变的标注员在放大镜下也画不出像素级精确的边界而 U-Net 的跳跃连接会把这种边界噪声原样学进特征。解决对掩码做形态学开运算去掉细碎凸起再做一次 1 像素腐蚀把边缘往里收一点。注意开运算的 kernel 不要超过 3x3大了会把细镜腿直接抹掉。这个清洗操作应该在训练前对全量标签做一次而不是在训练中动态做。5.3 统一 resize 后小眼镜消失保持比例 padding 而不是拉伸现象训练时指标正常但部署后遇到横向自拍照模型经常漏检或分错。原因训练时把不同长宽比的图直接 resize 到 512x512横向图被压扁眼镜形态严重变形有些图里眼镜本来就只占 30x20 像素resize 时被下采样抹掉了。解决统一改成 letterbox padding长边缩放到 512短边补 0保持原始宽高比如果眼镜占比实在太小先在整图做人脸检测裁剪人脸区域后再喂分割模型这是「检测 分割」两步走的典型方案。5.4 验证指标虚高换新照片就掉同一个人出现在了两个集合现象离线验证集 Dice 高达 0.95但拿一批新照片测试直接掉到 0.8 以下。原因数据泄漏。随机切分时同一个人的多张照片分别进了训练集和验证集模型记住的是这个人的脸型特征而不是眼镜这个目标本身。解决严格按人物 ID 分组切分具体脚本见 3.3。如果数据里没有人物 ID观察文件名里是否有地址、时间戳等可聚类信息实在没有就对提取的人脸特征做聚类按聚类结果切分这是迫不得已但有效的补救办法。5.5 镜片反光区域被预测成空洞标注规范不一致怎么统一现象预测结果里镜片上有细碎的黑色斑点集中在高光反射区域。原因反光区域在不同样本里的标注不一致有的标注员把反光当作镜片的一部分标成前景有的认为反光部位看不清楚就标成背景模型学到了这种随机性。解决先统一标注规范——反光属于镜片必须标前景然后对训练标签做闭运算把镜片内部的小洞填掉。如果清洗后问题依旧可以在 loss 里降低高光区域的权重用 Sobel 算子找出高梯度区域让这些像素对 loss 的贡献减半模型就不会耗费大量容量去拟合这些噪声。6. 用四个指标判断这套数据集值不值得继续投入6.1 四个指标怎么算、多少算合格模型训完不要只看 loss 曲线要用语义分割的通用指标做一次完整评估。我固定看四个PA、IoU、Dice、Boundary F1。def iou(pred, gt): inter (pred gt).sum() union (pred | gt).sum() return inter / (union 1e-6) def dice(pred, gt): return 2 * (pred gt).sum() / (pred.sum() gt.sum() 1e-6)指标计算口径16k 规模下的合理基准PA预测正确像素 / 总像素0.98 以上但参考意义有限背景占比大IoU前景交集 / 前景并集0.80 可用0.85 以上算干净Dice2 倍交集 / 预测与标签像素和0.85 是分水岭Boundary F1距边缘 N 像素内的精确率和召回率AR 试戴类应用重点看这个需另写计算脚本PA 在背景占大头的分割任务里几乎没区分度主要用来发现数据读取错误。IoU 和 Dice 是核心指标单类别前景 16k 数据训到 IoU 0.85 左右是正常水平低于 0.75 先怀疑标签问题而不是模型容量。如果你做的场景是 AR 试戴镜片边缘稍微偏两三个像素用户都能看出来这时候 Boundary F1 比 Dice 更能反映体验。6.2 要不要继续投入先加清洗再加数据最后才是换模型如果四个指标都在合格线上这套数据集可以直接投产接下来要做的是收集更多真实场景的难例而不是盲目加数据。如果指标偏低按顺序排查先按第 5 章的方式清洗标签再检查切分是否泄漏然后才考虑换更大的模型或加数据。我自己的习惯是每轮实验先跑 5 个 epoch 看验证曲线而不是直接跑满早期在这类小数据集上盲目堆数据和堆模型都试过最快的提升往往来自把标签清洗干净。清洗一轮标签花一天换模型花三天前者通常更值。希望帮到你。本文还有配套的精品资源点击获取