简介:面向计算机视觉与图像分割任务研究者,这份资源提供面部眼镜图像分割专用数据集。整体包含约16000张带像素级标注的图片,按训练集与测试集划分:训练集约11200张图片及对应masks,测试集约4800张;images与masks一一对应,目录结构清晰,方便直接接入主流分割框架。标注类别分为背景与眼镜两类,既可用于训练语义分割模型,也可作为验证分割算法或扩充数据集的素材。资源共2000个文件,主体为PNG格式原始图与掩码,另附类别说明txt和Python可视化脚本,压缩包整体约849MB。脚本会随机挑选一张样本,把原始图、GT图以及GT在原图上的蒙版效果同屏展示并保存,便于快速核对标注正确性、直观理解数据格式。已有91人浏览学习,适合需要眼镜区域精细分割标注的入门及进阶开发者,以及人脸解析、可穿戴设备等视觉项目。
1. 面部眼镜图像分割数据集:先别急着套模型
做 AR 虚拟试戴、疲劳驾驶预警、眼镜防雾算法或者医学图像分割里的眼周分析,第一步往往不是写网络,而是拿到一份能用的图像分割数据集。面前这份「面部眼镜图像分割数据集」规模在 16000 张左右,每张都带配套的标签,已经属于「够训练一个像样分割模型」的量级。很多从业者拿到数据集后的第一反应是直接丢进 Unet 或者 MMSegmentation 里跑,结果往往是被小目标、镜片反光和正负样本失衡三件事反复折磨。这篇笔记会从数据拆解、标签校验、训练管线、超参设置到排错,把这条落地路径完整走一遍。适合手里有类似数据、正准备训分割模型的算法工程师和学生,看完能直接照做。
2. 数据拆解:眼镜分割到底在分割什么,以及 16k 样本的构成
2.1 眼镜目标与通用语义分割的三点差别
拿 Cityscapes 或者 ADE20K 的思路来套眼镜分割,是第一个翻车点。眼镜目标在整张人脸图上通常占不到 10% 的像素,大多数时候只有 2% 到 5%。通用语义分割面对的类别分布相对均衡很多,而眼镜分割是典型的极端正负样本失衡任务,背景像素轻松超过 95%。如果不做类别加权或者换损失函数,模型很快就会学成「把所有像素都预测成背景」,训练 loss 看着在降,实际输出全黑。
第二个差别是边界属性。镜框有非常锐利的强边缘,但镜片区域是半透明的,反光严重时镜片内部的纹理和肤色混在一起,标签本身就会存在歧义。通用分割里那种「物体内部相对致密」的假设在眼镜上不成立,镜片中间经常被标注成背景,或者反过来被整块标注成前景,这取决于标注规范。
第三个差别是和身份强相关。同一个人戴同一副眼镜,正脸、侧脸、仰头、低头,镜片反光区域完全不一样。数据集的 16000 张图看起来数量不小,但实际多样性要看人物数量、姿态覆盖和光照条件。我拿到类似数据集的第一件事不是统计文件个数,而是写个采样脚本,均匀抽出 50 张图连标签一起人工过一遍,半小时就能把数据质量的底摸清楚。
2.2 标签形态:二值 Mask、VOC 与软标签
绝大多数眼镜分割数据集采用最简单的标签形式:一张图配一个同名 PNG,前景像素为 255,背景为 0。这种二值 Mask 可读性好,标注工具导出方便,但要注意文件是单通道还是三通道。有些标注工具导出的是三通道 PNG,三个通道值完全一样,直接用cv2.imread默认参数读进来是H×W×3,后续和单通道标签一拼接就容易出维度错误。
也有数据集采用 VOC 格式的调色板 PNG,或者 COCO JSON 多边形标注。如果拿到的是 COCO JSON,转 Mask 时先cv2.fillPoly再检查孔洞;眼镜框中间是空的,标注软件里的多边形可能只框外轮廓,也可能框完外轮廓再挖掉内圈,转换后必须可视化核对。还有一种情况是标签本身就分「镜框」和「镜片」两类,像素值为 1 和 2。需要先确认任务是二分类还是多分类,不能默认 255 就是唯一前景。
这里特别提一下软标签。镜片边缘的像素本来就介于「眼镜」和「非眼镜」之间,硬标注 0 或 1 会让模型在边界上反复横跳。常见做法是用 OpenCV 距离变换把边界附近的标签软化:
import cv2 import numpy as np mask = cv2.imread("masks/0001.png", cv2.IMREAD_GRAYSCALE) mask_bin = (mask > 127).astype(np.uint8) # 计算前景到背景的距离,归一化成软标签 dist = cv2.distanceTransform(mask_bin, cv2.DIST_L2, 5) # 距离变换的结果是前景内部越深越大,边界处接近0 soft_gt = np.clip(dist / 10.0, 0, 1).astype(np.float32)软标签可以缓解边界歧义,但也有代价:模型输出的是连续值,验证时要用阈值 0.5 转回二值 Mask,IoU 计算也有额外一步。如果数据集本身标注质量不错,我一般只在镜片反光严重的子集上用软标签,其他部分维持硬标签。
2.3 数据集划分:按人头划分而不是按图像划分
这是新手最容易忽略的点。16000 张图如果直接random_split,同一个人可能同时出现在训练集和验证集里。人脸特征高度相似,模型很容易记住这个人而不是学会分割眼镜,验证集 mIoU 虚高,一到真实场景就掉点。正确做法是先按人物 ID 分组,再以组为单位切分。
文件名一般带有身份编码,比如031_0023.jpg这种前缀为人物编号的命名。按组划分的脚本如下:
import os import random from collections import defaultdict random.seed(42) img_dir = "images" mask_dir = "masks" out_dir = "splits" os.makedirs(out_dir, exist_ok=True) files = [f for f in os.listdir(img_dir) if f.endswith(".jpg")] group = defaultdict(list) for f in files: person_id = f.split("_")[0] group[person_id].append(f) train, val, test = [], [], [] for pid, flist in group.items(): random.shuffle(flist) n = len(flist) train += flist[: int(n * 0.8)] val += flist[int(n * 0.8) : int(n * 0.9)] test += flist[int(n * 0.9) :] for split, lst in [("train", train), ("val", val), ("test", test)]: with open(f"{out_dir}/{split}.txt", "w") as fp: for name in sorted(lst): fp.write(name.replace(".jpg", "") + "\n")这段脚本的关键在分组逻辑:person_id = f.split("_")[0]假设前缀是人员编号,如果文件名没有这个规律,需要先维护一份人员映射表再执行。8:1:1 的划分对 16000 张图是合理起点,训练集约 12800 张,验证和测试各约 1600 张。注意val += flist[int(n*0.8):int(n*0.9)]这一行为当前人物分配了 10% 的样本到验证集,不会出现代码截断 0.9 到 0.9 的空区间,但最终测试集的量取决于每个人物样本数,如果某个人只拍了 3 张,可能分不均匀,属于正常现象。
3. 把数据集跑进训练管线:目录约定、Dataset 类与框架对接
3.1 先定目录结构和配对校验
模型训练前先把这个目录结构定下来,后面所有脚本都围绕它展开:
data/glasses_seg/ ├── images/ # 原图,*.jpg ├── masks/ # 标签,*.png,与 images 同名不同后缀 └── splits/ ├── train.txt ├── val.txt └── test.txtsplits里的每一行是图像 id,不含扩展名。加载时统一用f"{sid}.jpg"和f"{sid}.png"拼接路径,避免在不同脚本里各写一套路径逻辑。
拿到数据先跑一遍配对校验,检查每张图是否都有对应标签,尺寸是否一致:
for f in images/*.jpg; do b=$(basename "$f" .jpg) if [ ! -f "masks/$b.png" ]; then echo "missing mask: $b" fi done很多数据集在打包传输时丢过文件,这一条命令能查出所有缺标签的样本。如果检查出来缺了几十张,直接过滤掉或者在训练脚本里跳过,不要留着让 DataLoader 在 epoch 中途报错。
3.2 写一个能直接跑的 PyTorch Dataset 加载器
下面这个 Dataset 类是我常用模板,兼容二值 PNG 标签,同时把数据增强写进同一套管线,保证图和 Mask 做完全一致的变换:
import cv2 import torch import numpy as np from torch.utils.data import Dataset import albumentations as A class GlassesSegDataset(Dataset): def __init__(self, split_file, img_dir, mask_dir, size=(512, 512), training=False): with open(split_file) as fp: self.ids = [line.strip() for line in fp if line.strip()] self.img_dir = img_dir self.mask_dir = mask_dir self.size = size if training: self.aug = A.Compose([ A.RandomResizedCrop(size[0], size[1], scale=(0.5, 1.0), p=0.8), A.HorizontalFlip(p=0.5), A.ColorJitter(brightness=0.2, contrast=0.2, p=0.5), A.CoarseDropout(max_holes=2, max_height=64, max_width=64, p=0.3), ]) else: self.aug = A.Compose([ A.Resize(size[0], size[1]) ]) def __len__(self): return len(self.ids) def __getitem__(self, idx): sid = self.ids[idx] img = cv2.imread(f"{self.img_dir}/{sid}.jpg") img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask = cv2.imread(f"{self.mask_dir}/{sid}.png", cv2.IMREAD_GRAYSCALE) mask = (mask > 127).astype(np.uint8) transformed = self.aug(image=img, mask=mask) img = transformed["image"].astype(np.float32) / 255.0 mask = transformed["mask"] img = torch.from_numpy(img).permute(2, 0, 1) # 语义分割标签用 long mask = torch.from_numpy(mask).long() return {"image": img, "mask": mask, "id": sid}这段代码有三个细节要注意。第一,mask > 127把标签统一归到 0/1,忽略原标签可能存在的中间灰度值;如果数据集本身含镜框和镜片两类,就不能这么做,需改成mask.astype(np.int64)保留类别编号。第二,permute(2, 0, 1)将 HWC 转成 CHW,和 PyTorch 的卷积输入对齐。第三,验证集的Resize对 mask 默认用最近邻插值,不会因为缩放产生新的中间灰度;如果自己用cv2.resize单独处理标签,记得显式指定interpolation=cv2.INTER_NEAREST。
3.3 对接 MMSegmentation 与 YOLOv8-seg
不想维护自己的训练循环,可以对接现成分割框架。MMSegmentation 是最常用的一个,支持 Mask 和 VOC 两种标签格式。最快路径是把数据整理成 VOC 格式:JPEGImages放原图,SegmentationClass放调色板 PNG 标签,然后数据配置写成:
# configs/_base_/datasets/glasses_voc.py 关键片段 dataset_type = "VOCDataset" data_root = "data/glasses_seg/voc_format/" img_norm_cfg = dict( mean=[123.675, 116.28, 103.53], std=[58.395, 57.12, 57.375], to_rgb=True) train_pipeline = [ dict(type="LoadImageFromFile"), dict(type="LoadAnnotations"), dict(type="Resize", img_scale=(512, 512), ratio_range=(0.5, 2.0)), dict(type="RandomFlip", prob=0.5), dict(type="Normalize", **img_norm_cfg), dict(type="Collect", keys=["img", "gt_semantic_seg"]), ]LoadAnnotations会自己读调色板索引,因此标签 PNG 必须是无压缩调色板格式,用cv2.imread读出来是索引值而不是灰度值。如果手里的二值 PNG 是普通灰度图,需要先用 PIL 转一次调色板模式再存,否则 MMSegmentation 读到的类别索引是错的。
如果只做检测式的实例分割,YOLOv8-seg 也是一个选项。它要的标签是每个目标的归一化多边形坐标,不是 dense mask。把二值 Mask 转 YOLO 多边形标签的常见做法是cv2.findContours提取轮廓再归一化,一个 512 分辨率下的镜框轮廓一般要保留 20 到 40 个点,少了边界锯齿明显,多了训练变慢。YOLOv8 的数据 YAML 配置如下:
# glasses_seg.yaml path: data/glasses_seg train: images/train val: images/val names: 0: glassesYOLO 格式转换脚本不复杂,但要注意轮廓开口问题:眼镜框是环状结构,findContours默认只取外轮廓,镜框内圈容易被忽略,导致 mask 覆盖不完整。遇到这种情况,我会把二值 Mask 做一次填充孔洞预处理,再提取轮廓,保证内外圈都能进入标注。
4. 让眼镜分割收敛稳:损失函数、数据增强与四个超参
4.1 损失函数:CrossEntropy 之外必须加一个 Dice 项
眼镜分割的类别失衡有多严重,直接跑一次就能体会。背景像素动辄 95% 以上,纯 CrossEntropy 会让模型倾向把所有像素预测成背景,mIoU 看起来还行,输出 Mask 里眼镜区域全是残缺的。
常用做法是 CE 加 Dice Loss,把 Dice 作为正则项拉高前景的召回:
import torch import torch.nn.functional as F def dice_loss(pred, target, smooth=1.0): # pred 是未经过 softmax 的 logits,形状 [B, C, H, W] prob = F.softmax(pred, dim=1)[:, 1] # 取眼镜类 prob = prob.contiguous().view(-1) target = target.contiguous().view(-1).float() intersection = (prob * target).sum() return 1 - (2.0 * intersection + smooth) / ( prob.sum() + target.sum() + smooth) ce = F.cross_entropy(pred, mask) dice = dice_loss(pred, mask) loss = ce + 0.5 * dice权重 0.5 是个中庸值。Dice 权重太高会让模型在边界附近产生过度激进的预测,太低又压不住背景倾向。如果用的网络是 DeepLabV3+ 这类带 ASPP 的模型,我可以接受 0.3 到 0.7 的范围,从 0.5 起步,看到验证集 mIoU 停滞再微调。另一个思路是在 CE 里加权,torch.nn.CrossEntropyLoss(weight=torch.tensor([0.2, 1.0])),效果接近但不如 Dice 平滑。
4.2 数据增强:针对眼镜的四个具体操作
通用的随机翻转和裁剪对眼镜任务不够。眼镜目标集中在人脸中上部,裁剪范围太大会把眼镜裁出画面,太小又学不到上下文。我常用这一套增强组合:
import albumentations as A aug = A.Compose([ A.OneOf([ A.RandomCrop(480, 480, p=0.7), A.RandomResizedCrop(512, 512, scale=(0.6, 1.2), p=0.3), ]), A.Affine(rotate=(-15, 15), scale=(0.9, 1.1), p=0.5), A.OneOf([ A.MotionBlur(blur_limit=5, p=0.5), A.GaussianBlur(blur_limit=3, p=0.5), ]), A.CoarseDropout(max_holes=2, max_height=64, max_width=64, p=0.3), ])四项各自有针对性。随机裁剪固定 480 尺寸,和输入 512 之间产生尺度扰动,让模型对眼镜大小不敏感。仿射变换只旋转 ±15 度,因为人脸姿态超过这个范围后眼镜形变严重,训练分布过度扩展反而干扰。运动模糊和中心区域遮挡模拟摄像头运动模糊和刘海、手部遮挡。CoarseDropout 的 64 像素空洞尺寸正好覆盖镜片面积,强迫模型从镜框上下文推断镜片位置,对反光镜片很有效。
验证阶段不要用这套组合,只用Resize,否则验证集结果不具备可比性。
4.3 四个超参的起步推荐值
| 参数 | 推荐起步值 | 说明 |
|---|---|---|
| 输入分辨率 | 512×512 | 低于 384 会漏掉镜框细边,高于 640 训练时间成倍增加 |
| Batch size | 8(单卡 16GB 显存) | 低于 4 时 BatchNorm 统计不稳定,需配合小学习率 |
| 学习率 | 1e-4(AdamW) | 1e-3 容易在 Dice 项上震荡,收敛后换 5e-5 精调 |
| Epochs | 60–100 | 小目标任务收敛偏慢,配合验证集早停 |
分辨率是最关键的一项。512×512 在 16:9 的原图上会丢失一些细节,但对镜框分割足够;如果场景里眼镜占面部比例很小,我会先检测人脸并裁剪到 512 再训练,而不是直接缩放整张图。学习率用 1e-4 起步几乎不会翻车,Dice Loss 的梯度比 CE 更尖锐,学习率大了训练 loss 会出现周期性尖峰。
5. 眼镜分割最容易翻车的 5 个坑:现象、原因、处理
5.1 验证集指标很高,真实摄像头下一团糟
现象:在测试集上 mIoU 有 85,模型部署到手机摄像头后,侧脸、低头、夜间场景集体翻车,检测区域要么偏移要么整个丢失。
原因:数据集主体是正面、室内、正常光照的样本,测试集也来自同一分布,模型学到的是「人脸正中间有个眼镜」的先验,不是真正的眼镜边界。侧脸时镜框投影变化剧烈,夜间反光让镜片区域和背景融为一体,先验失效。
解决:划分一个「困难集」单独评估,挑出侧脸、暗光、戴帽子的样本,用同样的模型跑一遍。我一般会把困难集从训练数据里抽出来冻结不放回,只做验证。如果困难集 mIoU 低于正常集 10 个点以上,就需要补充对应场景的数据,或者在增强里加大仿射旋转幅度和亮度扰动范围。
5.2 Mask 缩放后出现锯齿和渐变灰边
现象:训练集可视化正常,predict 时输出的 Mask 边缘有一圈半透明灰边,二值化后边缘参差不齐,比原标签粗糙很多。
原因:训练管线里对 mask 用了和 image 相同的缩放方式,默认双线性插值会在 0/1 边界插出中间灰度值。虽然训练时被阈值化成 0/1,但灰度值污染了模型对边界的判断。
解决:所有对 mask 的 resize 和仿射变换必须用最近邻插值。在 albumentations 里,A.Resize和A.Affine对 mask 默认就是最近邻,但自定义脚本里容易忽略。手动cv2.resize时记得写interpolation=cv2.INTER_NEAREST。
5.3 镜片反光区域被预测成背景
现象:深色墨镜和透明镜片的表现截然不同,透明镜片反光强烈时,模型把反光高光区切成了背景,镜片中心出现一个大洞。
原因:标签本身就不一致。标注员在高光区域可能标了背景,也可能标了前景,模型在高光区域学到的是随机噪声。
解决:先统计标签里镜片区域的平均灰度分布,如果反光区域的标签大量为 0,就要么重新标注一批反光样本,要么给反光区域单独设一个类别提高标签一致性。最省事的方案是训练时不区分镜框和镜片,整体作为前景,让模型自己对反光区域做容错。
5.4 图像和标签错位:字符串排序的坑
现象:训练 loss 降到 0.2 后验证集 loss 始终降不下去,抽了几张图看,发现图片和 Mask 完全对不上,人物轮廓和眼镜位置都对错了。
原因:某些脚本用sorted(os.listdir())做配对,字符串排序会把img10.jpg排在img9.jpg前面,当文件名超过 100 时,这种排序会积累大量错位。
解决:用 split.txt 作为唯一的数据索引,不要依赖目录遍历顺序。每次加载数据后先做一次配对校验:
img = cv2.imread(f"{img_dir}/{sid}.jpg") mask = cv2.imread(f"{mask_dir}/{sid}.png", cv2.IMREAD_GRAYSCALE) assert img.shape[:2] == mask.shape[:2], f"size mismatch: {sid}"尺寸一致还不能完全确认语义对齐,更稳妥的方法是在训练前随机抽 10 个样本,把 image 和 mask 叠在一起可视化一次。肉眼确认比任何自动校验都可靠。
5.5 训练到一半 loss 变成 NaN
现象:前两个 epoch 正常,第三个 epoch 开始 loss 周期性出现 NaN,重启训练后随机复现。
原因:常见原因有三个。Mask 里出现了类别索引超出模型输出通道数;输入图像里有全黑或全白异常样本;混合精度训练下梯度溢出。
解决:训练前打印 Mask 的唯一值,确认最大类别小于num_classes。全黑或异常的图直接在 Dataset 里跳过:
if mask.sum() < 16: # 前景像素合计少于16个,当成坏样本 return self.__getitem__((idx + 1) % len(self.ids))混合精度问题可以在前 5 个 epoch 关闭 AMP,等训练稳定后再打开。这条经验在分割任务里能省很多排查时间。
6. 验证不只是 mIoU:三个离线检查与从整镜到部件分割的进阶
验证集 mIoU 是必要指标,但不足以判断模型能不能用。我习惯在 mIoU 之外加三个离线检查,它们能暴露指标掩盖掉的问题。
第一个是面积合理性检查。统计预测 Mask 占整图比例,眼镜在面部脸部的一般不会超过 30%,也不会低于 0.5%。如果某个样本预测出来占比异常,说明模型在图片的奇怪位置产生了幻觉区域。
第二个是连通域检查。一张图里眼镜正常情况是 1 到 2 个连通域,如果在预测 Mask 里数出 5 个以上互相分离的区域,大概率是过分割或者背景噪声被误判:
import cv2 import numpy as np pred = (pred_mask > 0.5).astype(np.uint8) num_labels, labels = cv2.connectedComponents(pred) # 正常约 2~3,多于此值说明有碎片第三个是边缘对齐度。把预测 Mask 的边缘像素和输入图像的梯度方向做对比,边缘处梯度越大说明贴合得越好。这一步不用写复杂代码,简单做法是把边缘叠加到原图上可视化,肉眼判断。
进阶方向是把「眼镜」整体分割拆成「镜框 + 镜片」的多部件分割。整个数据集不需要重新标注,可以先训一个整体分割模型,把预测的眼镜区域裁出来,再在这个局部区域上训练一个二分类的镜框/镜片分割小模型。这样做的好处是 AR 试戴场景里可以只替换镜片而不动镜框,也可以只换镜框保留镜片光学参数,产品价值比单输出一个整体 Mask 高很多。
我现在的习惯是拿到任何新的图像分割数据集,第一轮先做三件事:看标签分布、按人划分数据集、跑一个 batch 过拟合验证管线通不通。这三步做完,后面所有训练和排查都顺很多。数据本身不会骗人,但数据管线的每一个细节都可能骗你,希望你也能少踩这些坑。
本文还有配套的精品资源,点击获取