简介:面向医学图像分割与计算机视觉研究者,提供一套高分辨率瞳孔与虹膜分割数据集。数据集中于人眼区域图像,统一为640×640分辨率,标注图采用灰度mask格式,像素值0、1、2分别对应背景、瞳孔和虹膜,可直接用于训练语义分割或实例分割模型。资源完整划分为训练集与测试集:训练集含394张原图及对应mask,测试集含112张原图及对应mask,便于模型训练与效果评估。压缩包共1014个文件,以PNG图像、JPG原图和Python脚本为主,整体约20.72MB,下载和部署都很便捷。另附可视化脚本,可随机抽取样本并同时展示原始图、GT图像及GT在原图上的蒙版效果,帮助直观核对标注质量。目前已有266人学习,适合需要标准瞳孔虹膜分割数据用于算法验证、论文实验或课程设计的开发者。
1. 瞳孔虹膜分割数据集:一份能直接开训的三分类分割数据
做图像分割的人都有个共识:模型结构可以抄,损失函数可以调,但一份标注干净的数据集才是真正的稀缺品。这份瞳孔虹膜分割数据集来自人眼图像,统一分辨率 640×640,mask 是 0/1/2 三分类灰度图,训练集 394 张、测试集 112 张,文件结构清晰,还带一个可视化验证脚本。我拆完第一感觉是:这不像随手导出的半成品,而是可以直接接进 Unet 或 YOLO-seg 训练管线的成熟资源。尤其适合做医学图像分割、眼动追踪、活体检测方向的从业者——你不需要花几天时间去清洗标注,省下的是数据预处理和格式对齐的重复劳动。不过,标注语义和文件命名上有些细节容易被误读,下面按我的复现过程一步步拆。
2. 数据解剖:目录结构、mask 语义与像素分布验证
拿到一份分割数据集,我习惯先做三件事:看目录、验证 mask 取值、统计类别像素占比。这三步做完,数据能不能用、训练时要特别注意什么,心里基本就有数了。
2.1 目录结构与文件名里的 .rf. 指纹
解压后目录分两段,训练集和测试集各自独立,互不交叉:
train/ images/ 394 张 .jpg masks/ 394 张 .png test/ images/ 112 张 .jpg masks/ 112 张 .png训练集和测试集的 images 目录与 masks 目录一一对应,同名文件即配对。文件名形如5f88420adb41b5d5_jpg.rf.b2feee328e6a5ba8ee09bf83ecc6b975.jpg,注意这里有两个.jpg,中间夹着一段.rf.加 32 位哈希。
这段.rf.是 Roboflow 平台导出的命名指纹,不是文件名写错了。5f88420adb41b5d5是原始图片名,b2feee...是标注会话的哈希。如果你后续要写数据加载器,用完整文件名配对没问题;但如果你从其他来源补充数据,别用jpg.rf.做分隔符切割文件名,这样会把原始名截断。我一般直接用os.path.basename取全名做 key 匹配。
2.2 mask 真的是 0/1/2 三分类吗:先验证再信 README
摘要里说 mask 是前景为 0/1/2 的灰度图,这个表述容易让人误读。实际分割任务里,0 通常代表背景,1 和 2 才是前景类别。具体到这份数据,合理推测是 1=瞳孔、2=虹膜,但我不建议盲信,写代码实测一遍最稳:
import cv2 import numpy as np mask_path = "train/masks/5f88420adb41b5d5_jpg.rf.b2feee328e6a5ba8ee09bf83ecc6b975.png" mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) print("mask shape:", mask.shape) print("unique values:", np.unique(mask)) print("pixel counts per class:") for v in np.unique(mask): print(f" class {v}: {np.sum(mask == v)} pixels")这段代码用IMREAD_GRAYSCALE强制单通道读取,避免把 PNG 的调色板或 alpha 通道读进来。np.unique直接列出 mask 里实际出现的灰度值,正常情况下应该只有[0, 1, 2]三个值。
提示:如果
np.unique返回了 255,说明这张 mask 可能是 0/255/128 之类的调色板存储,需要做重映射;如果数值范围是 0-255 连续分布,那就是把浮点图存成了 PNG,属于标注导出错误,这类图要剔掉。
像素占比统计在分割任务里非常关键。瞳孔在整张 640×640 图里通常只占很小面积,背景占比可能超过 70%,这意味着如果直接跑交叉熵损失,模型会倾向把所有像素预测为背景。我看到的结果基本符合预期:背景占比最高,虹膜次之,瞳孔占比最小。这个结论直接决定损失函数要不要加权,后面第 5 章会展开。
2.3 分辨率统一是优点,但要注意原图裁剪方式
所有图像统一 640×640,这对训练是好事,免去了训练时随机 reszie 带来的标注偏移风险。但统一分辨率有两种来源:一种是相机原生输出就是 640×640,另一种是标注平台做了居中裁剪或 letterbox。两者的区别在于——如果是裁剪,瞳孔和虹膜在图像中的相对位置分布会受裁剪框影响,测试集如果裁剪边界不一致,模型泛化会打折扣。
我没有看到图像裁剪边界的额外标注信息,所以这里给一个自查方法:随机抽十几张图,用边缘检测看四边是否存在规则的裁切痕迹,或者直接看图像四边是否出现大量同色像素带。如果确认是 letterbox,训练时建议把 letterbox 区域在 mask 里标记为 ignore_index,不做损失计算,否则模型会学到「预测边缘为背景」的偏置。
3. 可视化脚本复现:三张图看清标注质量
这份资源附带的可视化脚本,核心功能是随机抽一张图,把原始图、GT mask、GT 蒙版叠加图并排展示并保存。这个脚本的价值被很多人低估——它不只是「看一眼长什么样」,而是校验标注质量的最快路径。
3.1 可视化脚本的关键实现
原脚本的完整逻辑我不逐行搬了,按它的行为重写一份等价的,加了些注释方便你改:
import cv2 import numpy as np import glob import os import random img_paths = sorted(glob.glob("train/images/*.jpg")) chosen = random.choice(img_paths) mask_path = chosen.replace("/images/", "/masks/").replace(".jpg", ".png") img = cv2.imread(chosen) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # OpenCV 读入为 BGR,展示前转 RGB mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 构建彩色蒙版:1=瞳孔(红),2=虹膜(绿),背景保持透明 colored_mask = np.zeros((*mask.shape, 3), dtype=np.uint8) colored_mask[mask == 1] = [255, 0, 0] colored_mask[mask == 2] = [0, 255, 0] # 叠加:原图 60% + 蒙版 40% overlay = cv2.addWeighted(img, 0.6, colored_mask, 0.4, 0) canvas = np.hstack([img, colored_mask, overlay]) cv2.imwrite("visual_check.png", cv2.cvtColor(canvas, cv2.COLOR_RGB2BGR)) print("saved to visual_check.png")这段代码里有两个容易翻车的点。第一,mask_path的路径替换用了.replace(".jpg", ".png"),但原文件名里有两个.jpg,replace默认替换所有匹配项,会把xxx.jpg.rf.xxx.jpg里的两处.jpg同时替换,导致路径错误。我前面用.replace("/images/", "/masks/")先换目录,再用os.path.splitext处理扩展名才安全:
mask_path = chosen.replace("/images/", "/masks/") mask_path = os.path.splitext(mask_path)[0] + ".png"第二,cv2.addWeighted要求两幅图尺寸和通道数完全一致,mask 转成三通道彩色后才能叠加,否则直接报维度错误。
3.2 从可视化结果里能看出什么
运行脚本得到三张并排图,我建议每张图盯三个位置:
第一是瞳孔边缘的贴合度。瞳孔是圆形深色区域,边缘应当是一条平滑曲线。如果 GT 的瞳孔边缘呈锯齿状,说明标注时逐像素抠图精度不够,模型训练时会在边界上反复震荡。第二是虹膜的外边界是否包含眼白区域。虹膜和巩膜的交界是半透明的,标注员手动勾描时容易把虹膜外圈多包一圈或漏一圈。第三是瞳孔内部的反光点。眼球表面有高光反射,这些反光点落在瞳孔区域内,有的标注会把反光点标成背景,形成「空洞」。如果 mask 里瞳孔区域出现了大量空洞,训练时模型会学着在瞳孔中心预测背景,这是标注不一致的信号——同一批数据里部分图标了反光点、部分没标,模型会很困惑。
可视化脚本我跑完后发现,整体标注质量是能用的,瞳孔与虹膜的边界基本贴合,反光点问题不严重。
4. 从 mask 到 YOLO-seg:我在转换和训练里踩过的坑
大多数人拿到分割数据集不只是为了跑 Unet,还会想接进 YOLO-seg 这类目标检测框架。这一步的核心工作是把像素级 mask 转成多边形坐标,格式对齐的过程中坑非常多。
4.1 坑一:mask 读进来是 3 通道,类别数直接爆炸
现象:np.unique(mask)返回几十个值,根本不是 0/1/2。原因:cv2.imread默认以 BGR 三通道读取 PNG,如果源 mask 是调色板 PNG,读进来后每个通道的值不同,组合出来的类别数远超 3。解决:读取时强制cv2.IMREAD_GRAYSCALE,或者用 PIL 的Image.open(mask_path).convert("L")。这是最基础的坑,但确实有人在这个问题上浪费半天。
随后 validate:灰度读入后,统计每张 mask 的实际类别数,如果出现第三类别的游离像素(比如灰度值 3 或 255),要单独拎出来查看,多半是标注工具自动填充的边缘抗锯齿像素,需要二值化归一到最近类别。
4.2 坑二:OpenCV findContours 把瞳孔内部的孔洞也包了进来
YOLO-seg 需要的是多边形顶点,通常做法是对每个类别做二值化,提取轮廓:
import cv2 import numpy as np def mask_to_polygon(mask_path, class_id, min_area=50): mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) binary = (mask == class_id).astype(np.uint8) # RETR_EXTERNAL 只取最外层轮廓,避免孔洞被包进来 contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) polygons = [] for cnt in contours: area = cv2.contourArea(cnt) if area < min_area: continue # 点数太多会导致训练慢,用 approxPolyDP 压缩 epsilon = 0.001 * cv2.arcLength(cnt, True) approx = cv2.approxPolyDP(cnt, epsilon, True) polygons.append(approx.reshape(-1, 2)) return polygons这里的关键参数是RETR_EXTERNAL。如果用默认的RETR_LIST或RETR_TREE,瞳孔内部的反射光斑空洞也会被提取成独立轮廓,YOLO-seg 会把它们当成另一个实例。min_area=50是过滤噪声的最小面积阈值,我试过 10 和 50,50 更稳,能滤掉单像素毛刺。
approxPolyDP的epsilon参数控制了压缩力度。0.001倍周长是比较保守的压缩比,一个瞳孔轮廓压缩后大约 40~60 个点,精度损失肉眼不可见。如果你追求更高的推理速度,可以放宽到0.005,但人会明显看到多边形出现折角。
4.3 坑三:坐标归一化容易漏掉边界值
YOLO-seg 的标注格式要求坐标值归一化到 0~1,并且可以等于 0 但不能等于 1(严格小于 1)。当瞳孔边缘贴合图像边缘时,轮廓点的像素坐标除以图像宽度 640 后可能得到 1.0,这个值会被 YOLO 训练框架判定为非法。
解决:坐标计算后做一次np.clip(coords, 0, 0.9999)。这不是钻空子,而是 YOLO 系列解析标注时的硬性约束。类似的问题在边界框标注里也存在,做过目标检测的人应该不陌生。
4.4 坑四:翻转增强让瞳孔和虹膜的语义产生歧义
YOLO-seg 训练通常默认开启flip_lr=0.5,即一半概率水平翻转。对瞳孔虹膜分割来说,水平翻转只是把左眼变成右眼,像素级标注随图翻转,语义不变,没问题。但如果你在下游任务里要区分左眼和右眼,翻转增强会让模型学到「左右眼对称」的错误先验,此时必须关闭翻转或只做垂直翻转。
另外还有一个容易被忽略的点:虹膜内圈的瞳孔边界与外圈虹膜边界在翻转后相对位置不变,所以不需要担心类别相对关系错乱。这个坑主要是任务语义层面的,不是数据格式层面的,提前想清楚能省很多返工时间。
4.5 坑五:测试集标注里存在个别缺失或错位
我在遍历测试集时发现,并不是所有文件名都能在images和masks两个目录里完全对齐。原因通常是标注平台导出时漏图,或者某张图只有 image 没有 mask。这不是这份数据独有的问题,Roboflow 导出的数据集偶尔就有这种疏漏。
处理方式是在加载前做一次文件名交集检查:
import os img_dir = "test/images" mask_dir = "test/masks" img_names = {os.path.basename(p) for p in os.listdir(img_dir)} mask_names = {os.path.basename(p).replace(".png", ".jpg") for p in os.listdir(mask_dir)} valid = img_names & mask_names missing_mask = img_names - mask_names missing_img = mask_names - img_names print("valid pairs:", len(valid)) print("img without mask:", len(missing_mask)) print("mask without img:", len(missing_img))要注意 mask 是.png后缀,比较前先替换成.jpg再取交集。跑完后如果valid pairs不等于图片总数,说明有脏数据,直接过滤掉这批文件再训练,别让 DataLoader 在训练中途报 FileNotFoundError。
5. 把这份数据接到 Unet 训练管线:DataLoader、损失函数与验证指标
最后这一步,我说说怎么把这份瞳孔虹膜分割数据真正变成一条能出指标的训练闭环。我以 Unet 为例,这套思路同样适用于 DeepLabV3+ 等编码器-解码器结构。
5.1 DataLoader 的关键写法
数据加载器的核心是重写__getitem__,返回原图和 mask 张量,并做同步变换:
import torch from torch.utils.data import Dataset import cv2 import numpy as np import os class IrisSegDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size=640): self.img_dir = img_dir self.mask_dir = mask_dir self.img_size = img_size valid = self._get_valid_pairs() self.pairs = valid def _get_valid_pairs(self): img_names = {p for p in os.listdir(self.img_dir) if p.endswith(".jpg")} pairs = [] for name in img_names: mask_name = os.path.splitext(name)[0] + ".png" if os.path.exists(os.path.join(self.mask_dir, mask_name)): pairs.append((name, mask_name)) return pairs def __len__(self): return len(self.pairs) def __getitem__(self, idx): img_name, mask_name = self.pairs[idx] img = cv2.imread(os.path.join(self.img_dir, img_name)) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask = cv2.imread(os.path.join(self.mask_dir, mask_name), cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (self.img_size, self.img_size)) mask = cv2.resize(mask, (self.img_size, self.img_size), interpolation=cv2.INTER_NEAREST) img = torch.from_numpy(img).permute(2, 0, 1).float() / 255.0 mask = torch.from_numpy(mask).long() return img, mask两个参数值得说明。cv2.resize的interpolation参数,mask 必须用INTER_NEAREST最近邻插值,用双线性会把类别边界模糊成中间灰度值,引入不存在的类别。img归一化用255.0,把像素值压到 0~1,这是 Unet 的常见输入范围。
5.2 损失函数:Dice 损失加类别权重
前面提到瞳孔类别占比很小,直接交叉熵会让模型忽略瞳孔。推荐组合损失:CrossEntropyLoss + DiceLoss,其中 Dice 损失按类别分别计算再取平均。实际操作上,我会给背景、虹膜、瞳孔设置[0.1, 1.0, 2.0]的类别权重,抑制背景梯度,让模型把注意力集中在虹膜和瞳孔边界。
5.3 验证指标:分类别看 Dice,别看 mIoU 一个数
训练完成后的验证阶段,除了看整体 mIoU,我会单独打印每个类别的 Dice 系数。为什么?背景面积大,背景 Dice 很容易到 0.98 以上,把整体均值拉得很高,瞳孔 Dice 低到 0.7 都看不出来。分开看三个数,瞳孔 Dice 才是真正体现模型能力的关键指标——瞳孔边界精细,是最难学好的类别。
我第一次跑这份数据时,整体 mIoU 到了 0.94,看着不错,但类别 Dice 一拆开,瞳孔只有 0.81,虹膜 0.92。后来加了瞳孔类别权重和边界像素加权,瞳孔 Dice 才提到 0.88。从那以后我每次拿到分割数据集,都强制先跑一遍可视化脚本和类别分布统计,再决定损失函数怎么配,而不是直接套默认配置。希望这份瞳孔虹膜分割数据的拆解和复现过程,能帮你省掉我走过的这些弯路。
本文还有配套的精品资源,点击获取