☰
瞳孔虹膜分割数据集实战:U-Net训练全流程与避坑指南
2026/9/28 5:19:08 网站建设 项目流程

简介:一份面向图像分割学习与研究人员的瞳孔虹膜分割数据集,数据来自人眼图像,训练集与测试集各自独立的原图目录与掩膜目录,共包含三百九十四张训练图片及对应掩膜、一百一十二张测试图片及对应掩膜,图像统一为六百四十乘六百四十分辨率。掩膜为灰度图,像素值零、一、二分别代表背景、瞳孔和虹膜,适合直接作为三分类语义分割训练数据。压缩包共一千零一十四个文件,以JPG原图、PNG掩膜为主体,另含一个Python可视化脚本,可随机抽取样本并生成原图、真值图、叠加蒙板三组对比图,便于快速检查标注质量与模型输出。整体包体仅二十点七二兆字节,轻量易用,已有二百六十六人学习使用,可支撑目标分割、医学影像预处理、模型入门实验等场景,是一份可直接解压投入训练的高质量小规模数据集。

1. 瞳孔虹膜分割数据集:标记不是贴框,是让模型认准每一圈边缘

做疲劳驾驶检测、视线估计或者眼科参数量测的人,迟早会卡在同一个问题上:单纯靠人脸关键点拿不到瞳孔和虹膜的真实边界,必须回到像素级分割。这份标题里说的图像分割数据集,提供的就是已经划分好训练集和测试集的瞳孔、虹膜像素标注,拿到手可以直接喂给 U-Net 这类医学图像分割模型。比起目标检测里常见的矩形框,这个任务要求模型把瞳孔内边界、虹膜外边界都切得干净,属于医学图像分割里结构最规整、但反光干扰最典型的一类。我见过不少人把分割模型当分类模型调,loss 降得快但边界一塌糊涂,本文就把这类数据集从检查到训练、再到避坑的完整流程捋一遍。

2. 先检查再训练:用脚本核验目录、配对与 mask 标注语义

2.1 数据集的目录长什么样:先跑一遍目录树和基本信息

瞳孔虹膜分割数据集的压缩包解压后,最常见的目录组织是 images 放原始眼部图像、masks 放标注结果,train 和 test 两个平级目录分别存放对应的子文件夹。拿到手的第一步不是急着写模型,而是先摸清命名规则和文件格式,这决定了后面的 Dataset 类怎么写。

import os from PIL import Image import numpy as np data_root = "iris_seg_dataset" for split in ["train", "test"]: img_dir = os.path.join(data_root, split, "images") mask_dir = os.path.join(data_root, split, "masks") img_names = sorted(os.listdir(img_dir)) mask_names = sorted(os.listdir(mask_dir)) print(split, "图像数量:", len(img_names), "标注数量:", len(mask_names)) # 打开前 3 张图和对应 mask,确认尺寸与类别取值 for name in img_names[:3]: img = Image.open(os.path.join(img_dir, name)) mask = Image.open(os.path.join(mask_dir, name.replace(".jpg", ".png"))) arr = np.array(mask) print(name, img.size, mask.size, "mask 类别值:", np.unique(arr))

这段脚本解决三个基本信息:图像和 mask 数量是否对得上、两者尺寸是否一致、mask 里到底有哪几个像素值。瞳孔虹膜分割的 mask 常见有两种保存方式,一种是单通道灰度图,背景为 0、前景为 255;另一种是单通道标签图,背景为 0、瞳孔为 1、虹膜为 2。看到np.unique的输出就能立刻判断属于哪种,这一步千万别跳过,因为它直接决定后面损失函数的输出通道数。

如果发现图像尺寸不统一,比如有的是 640x480、有的是 800x600,不要慌,这是瞳孔数据集的常态,后面预处理统一处理即可。真正需要警惕的是 mask 尺寸和图像尺寸不一致,那说明标注和原图存在缩放关系,读取时就得单独做映射。另外注意图像格式大多是 jpg,mask 推荐是 png,因为 png 是无损压缩,标注边缘不会被压缩算法污染,如果发现 mask 也存成 jpg,后续处理时要格外小心边缘的杂色像素。

2.2 瞳孔和虹膜是两类还是一类:标注语义决定损失函数和评估口径

很多人在跑通第一个训练循环后才意识到一个问题:mask 里的类别数和模型输出通道数对不上。这里的核心分歧在于,瞳孔和虹膜到底算一个前景还是两个独立类别。

如果观察np.unique得到的是 0 和 255,说明是二分类标注,前景把瞳孔和虹膜合并成了一个整体。这时候模型输出 1 个通道,sigmoid 之后和背景做二分类,评估时只算一个 Dice 或 IoU。如果得到的是 0、1、2 三个值,说明是语义分割标注,瞳孔一类、虹膜一类,模型需要输出 2 个通道(背景隐含),softmax 后按类别分别计算指标。

# 统计每个类别的像素占比,判断类别是否均衡 for split in ["train", "test"]: mask_dir = os.path.join(data_root, split, "masks") total = np.zeros(3, dtype=np.int64) for name in sorted(os.listdir(mask_dir))[:50]: arr = np.array(Image.open(os.path.join(mask_dir, name))) for c in range(3): total[c] += (arr == c).sum() print(split, "类别像素占比:", total / total.sum())

这段统计脚本能快速看出瞳孔和虹膜像素占比是否悬殊。实际数据里虹膜通常比瞳孔面积大不少,如果训练时用统一的 Dice Loss,模型会倾向于把更多像素判成虹膜来拉高得分。解决思路有两个:一个是对小类别提高权重,另一个是干脆把瞳孔和虹膜合并成前景做二分类,先拿到整体形状,再在边界细化时分开处理。具体怎么选,取决于你要落地的是瞳孔分割还是虹膜分割,两者的业务诉求不一样。

另一个容易踩的坑是 mask 里的像素值并不是规范的 0、1、2,而是 0、128、255 之类的灰度索引。如果按 255 做归一化再送进网络,类别之间会出现竞争关系,模型不知道该往哪个值收敛。我一般会在加载阶段就把任意灰度值映射回 0、1、2,保证标签的语义是离散类别而不是灰度强度。

2.3 用脚本核验图片与 mask 的配对:同尺寸、同数量、通道一致性

训练集和测试集的划分本身就值得验证。有些数据集在划分时把同一个受试者的多张眼部图像同时放进了训练集和测试集,导致测试分数虚高,这个属于数据泄漏,暂时不做深入讨论,但配对的物理核验是基础。

from PIL import Image import numpy as np import os def verify_pair(data_root, split): img_dir = os.path.join(data_root, split, "images") mask_dir = os.path.join(data_root, split, "masks") img_names = sorted(os.listdir(img_dir)) mask_names = sorted(os.listdir(mask_dir)) assert len(img_names) == len(mask_names), f"{split}: 图片与 mask 数量不一致" for im, ma in zip(img_names, mask_names): base_im = im.rsplit(".", 1)[0] base_ma = ma.rsplit(".", 1)[0] assert base_im == base_ma, f"文件名不配对: {im} vs {ma}" img = np.array(Image.open(os.path.join(img_dir, im))) mask = np.array(Image.open(os.path.join(mask_dir, ma))) assert img.shape[:2] == mask.shape[:2], f"尺寸不一致: {im}" assert len(mask.shape) == 2, f"mask 不是单通道: {ma}" print(f"{split}: 配对检查通过,共 {len(img_names)} 对") verify_pair(data_root, "train") verify_pair(data_root, "test")

这个脚本的断言逻辑很直白:文件名前缀一致、图像和 mask 尺寸一致、mask 是单通道。如果 mask 是三通道的 RGB 图,说明标注被保存成了彩色图,len(mask.shape) == 2这一条会直接报错,这反而是好事,让你在训练前就发现格式问题。如果 mask 是三通道但三个通道的像素值完全相同,也可以只取第一个通道继续用,但我不推荐这么做,因为彩色 PNG 转灰度时可能出现标注边缘的锯齿假色,最好是找到原始标注文件或者重新导出。

配对检查通过后,顺手把每个类的连通域数量也统计一下。瞳孔和虹膜在单张图里理应各是一个连通域,如果发现动辄三五个连通域,说明标注里有多余的噪点或者反光区域被错误标记成了前景,这种脏数据会在训练时产生大量的假阳性样本。

3. 用 U-Net 在训练集上跑通最小方案:数据加载、增强与损失函数

3.1 预处理:等比缩放留边还是直接 resize,以及眼睛 ROI 怎么选

瞳孔虹膜图像来自不同采集设备,分辨率差异很大,训练前需要统一尺寸。直接resize到 256x256 是最省事的做法,但如果原图是 640x480 这种横向图,直接 resize 会让瞳孔变成椭圆,破坏瞳孔近似圆形的先验信息,模型学到的形状知识会被带偏。常见做法是等比缩放后补边到正方形。

import cv2 import numpy as np def resize_with_pad(image, mask, target_size=256): h, w = image.shape[:2] scale = target_size / max(h, w) new_h, new_w = int(h * scale), int(w * scale) image = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_LINEAR) mask = cv2.resize(mask, (new_w, new_h), interpolation=cv2.INTER_NEAREST) pad_h = target_size - new_h pad_w = target_size - new_w top, bottom = pad_h // 2, pad_h - pad_h // 2 left, right = pad_w // 2, pad_w - pad_w // 2 image = cv2.copyMakeBorder(image, top, bottom, left, right, cv2.BORDER_CONSTANT, value=0) mask = cv2.copyMakeBorder(mask, top, bottom, left, right, cv2.BORDER_CONSTANT, value=0) return image, mask

注意一个关键细节:图像 resize 用线性插值没问题,mask resize 必须用INTER_NEAREST最近邻插值,否则类别边界会出现中间灰度值。补边时图像的 padding 区域填 0(黑色),对应位置的 mask 填 0(背景),这符合瞳孔图像的实际分布,因为眼睛周围本来就是背景。

另一种更激进的做法是先做人眼 ROI 裁剪,再用裁剪后的区域做分割。很多瞳孔数据集在采集时就对齐到了眼睛区域,但如果你拿到的图像包含眉毛、额头甚至半张脸,直接全图训练会让模型把大量参数浪费在无关背景上。我一般会先跑一个人眼检测器,把 ROI 扩一圈后裁剪下来再做 padding,训练速度更快,精度通常也更高。不过代价是推理阶段也必须走同一套检测流程,工程复杂度上升,小规模项目划不来。

3.2 Dataset 类实现与同步数据增强

PyTorch 的 Dataset 类负责把预处理后的图像和 mask 打包成张量。增强方面,用 albumentations 库可以保证图像和 mask 同步变换,省去手动同步的麻烦。这里给出一份能直接跑的完整实现。

import glob import cv2 import numpy as np import albumentations as A import torch from torch.utils.data import Dataset class PupilIrisDataset(Dataset): def __init__(self, img_dir, mask_dir, size=256, augment=False): self.img_paths = sorted(glob.glob(img_dir + "/*")) self.mask_paths = sorted(glob.glob(mask_dir + "/*")) self.size = size self.augment = augment self.transform = A.Compose([ A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.3), A.GaussNoise(var_limit=(10.0, 30.0), p=0.2), A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=15, border_mode=cv2.BORDER_CONSTANT, p=0.5), ]) def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img = cv2.imread(self.img_paths[idx], cv2.IMREAD_COLOR) mask = cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img, mask = resize_with_pad(img, mask, self.size) if self.augment: aug = self.transform(image=img, mask=mask) img, mask = aug["image"], aug["mask"] img = img.astype(np.float32) / 255.0 mask = mask.astype(np.int64) mask = np.clip(mask, 0, 2) img = torch.from_numpy(img).permute(2, 0, 1).float() mask = torch.from_numpy(mask).unsqueeze(0).long() return img, mask

几个参数值得说明。RandomBrightnessContrast设到 0.2 而不是更大,是因为瞳孔图像的反光点已经够讨厌了,过度调亮度会让反光区域和背景完全混在一起。var_limit=(10.0, 30.0)的噪声强度是经验值,太强会把虹膜纹理细节洗掉,太弱则起不到正则作用。ShiftScaleRotate里的border_mode=cv2.BORDER_CONSTANT保证旋转时填充的是黑色背景而不是像素外插,这一点对瞳孔这种本身就有大片背景的数据集非常关键,外插会在边缘产生不可预期的伪影。

mask 在返回前被转成了int64并限制在 0 到 2 之间,这是为了兼容两类标注。如果你确认数据集是二分类,把np.clip改成(mask > 0).astype(np.int64)即可,输出通道同步改为 1。

3.3 Dice Loss 与 BCE 的组合,以及为什么不能只用 CrossEntropy

瞳孔虹膜分割里前景占了图像的一大部分,但瞳孔本身面积很小,如果直接用 CrossEntropy,背景类损失会盖过瞳孔的梯度,模型学到最后把整张图全判成背景也能得到低损失。U-Net 训练自己的数据集时最常见的损失是 Dice Loss 和 BCE 的组合。

import torch import torch.nn as nn import torch.nn.functional as F class DiceBCELoss(nn.Module): def __init__(self, dice_weight=0.5, bce_weight=0.5): super().__init__() self.dice_weight = dice_weight self.bce_weight = bce_weight self.bce = nn.BCEWithLogitsLoss() def forward(self, pred, target): # pred: [B, 1, H, W], target: [B, 1, H, W] 取值 0/1,float bce = self.bce(pred, target) pred_sig = torch.sigmoid(pred) pred_flat = pred_sig.contiguous().view(pred.size(0), -1) target_flat = target.contiguous().view(pred.size(0), -1) intersection = (pred_flat * target_flat).sum(dim=1) dice = 1 - (2.0 * intersection + 1.0) / (pred_flat.sum(dim=1) + target_flat.sum(dim=1) + 1.0) dice = dice.mean() return self.dice_weight * dice + self.bce_weight * bce

DiceBCELoss加了BCEWithLogitsLoss而不是手动 sigmoid 后再算 BCE,是为了利用 PyTorch 自带的数值稳定计算,避免 logits 极大或极小时出现 NaN。Dice 项里的+1.0是平滑系数,防止在两个 mask 都完全为空时出现除零。实践里还有一个更隐蔽的问题:如果目标 mask 全部是 0(空图),Dice 会接近 0 而 BCE 会正常惩罚,所以这个组合本身就能应对空图,不需要额外过滤。

如果你的数据集是瞳孔和虹膜分两类,就不能用这个二分类 Loss,而是把输出通道改成 2,用 CrossEntropy 配合 per-class Dice 权重。两种做法的精度差异并不大,但二分类在工程上更简单,推理时阈值处理也更直接,个人建议第一版先跑二分类。

4. 训练阶段的超参与调参方向:学习率、Batch Size、检查点与测试集评估

4.1 一套能稳定收敛的默认超参

第一次跑瞳孔虹膜分割,不需要太花哨的训练策略,一套稳妥的默认参数能帮你快速判断数据集质量和模型结构是否合适。我常用的组合如下表。

超参取值说明
输入尺寸256x256兼顾精度和显存,瞳距较小的大图可调到 384
编码器ResNet34 预训练权重比从零训练收敛快得多,泛化更好
损失函数Dice + BCE,权重各 0.5应对前景背景不平衡
优化器AdamW权重衰减设 1e-4,避免过拟合
初始学习率1e-4预训练 backbone 用 1e-4,随机初始化头可以用 1e-3
学习率调度CosineAnnealingLR余弦退火,T_max 设为总 epoch 数
Batch Size16256 输入下单卡 12G 显存可以放下,太小则 BN 不稳定
Epochs80配合早停,一般在 40-60 轮达到收敛
早停 Patience15以验证集 Dice 为准,连续 15 轮不升就停

这里最值得解释的是学习率的选择。如果你的 U-Net 编码器加载了 ImageNet 预训练权重,整体学习率用 1e-4 而不是 1e-3,原因在于预训练特征在大规模数据上已经足够好,不需要大步伐更新,大学习率反而会破坏已学到的底层边缘纹理特征。如果你的数据是小规模瞳孔数据集,无预训练从头跑,那学习率从 1e-3 开始反而更容易跳出局部最优。

Batch Size 16 配合 AdamW 在大部分场景下都成立。如果显存有限降到 8,梯度噪声变大,Dice 曲线会明显抖动,这时候可以把学习率同步降到 5e-5,经验上能缓解抖动。

4.2 训练循环、早停与检查点保存

训练循环的骨架不复杂,但保存检查点的策略值得说清楚。瞳孔分割的验证集 Dice 在 0.95 以上后,每轮提升只有千分之几,如果不按验证指标保存,最后一轮模型很可能不是最优的。

import torch import torch.nn as nn from torch.utils.data import DataLoader def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 for imgs, masks in loader: imgs = imgs.to(device) masks = masks.to(device).float() preds = model(imgs) loss = criterion(preds, masks) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader) def evaluate(model, loader, device): model.eval() dice_total = 0.0 n = 0 with torch.no_grad(): for imgs, masks in loader: imgs = imgs.to(device) masks = masks.to(device).float() preds = torch.sigmoid(model(imgs)) preds = (preds > 0.5).float() intersection = (preds * masks).sum(dim=(1, 2, 3)) union = (preds + masks).gt(0).sum(dim=(1, 2, 3)) dice = (2.0 * intersection) / (preds.sum(dim=(1, 2, 3)) + masks.sum(dim=(1, 2, 3)) + 1e-6) dice_total += dice.mean().item() n += 1 return dice_total / n best_dice = 0.0 for epoch in range(80): train_loss = train_one_epoch(model, train_loader, optimizer, criterion, device) val_dice = evaluate(model, val_loader, device) scheduler.step() if val_dice > best_dice: best_dice = val_dice torch.save(model.state_dict(), f"best_model_dice_{val_dice:.4f}.pth") print(f"epoch {epoch}: val_dice {val_dice:.4f}, 已保存检查点")

代码里的gt(0)在计算 union 时用了比较技巧,preds + masks的每个像素如果大于 0 说明至少有一个为前景,这样可以避免简单的union = preds + masks把 2 也算进去导致分母虚高。Dice 计算里加了 1e-6 的小平滑,防止分母为零,但注意这和损失函数里的平滑系数不是一回事,评估指标里的平滑只是数值保护,不应该影响指标本身。

早停逻辑我通常放在 epoch 循环外面,记录连续未提升的轮数,达到 patience 就 break。这里有一个小坑:验证集本身是从训练集切出来的还是单独的?标题说数据集包含训练集和测试集,但一般还会在训练集里再切出一小块做验证。如果直接用测试集作为早停依据,等于拿最终考卷反复做题,测试分数会有虚高风险。

4.3 测试集评估:预测阈值、Dice 与 IoU 的计算口径

测试集的评估口径比训练时的验证要严格,因为你只有一次机会,必须在推理阈值上做足功夫。默认用 0.5 阈值展开预测没什么问题,但瞳孔反光点会让概率图出现双峰分布,0.5 阈值有时会把反光区域的低置信像素全丢掉。

def evaluate_test(model, test_loader, device, threshold=0.5): model.eval() dice_list, iou_list = [], [] with torch.no_grad(): for imgs, masks in test_loader: imgs = imgs.to(device) masks = masks.to(device) preds = torch.sigmoid(model(imgs)) preds = (preds > threshold).float() inter = (preds * masks).sum(dim=(1, 2, 3)) union = (preds + masks).gt(0).sum(dim=(1, 2, 3)) dice = (2.0 * inter) / (preds.sum(dim=(1, 2, 3)) + masks.sum(dim=(1, 2, 3)) + 1e-6) iou = inter / (union + 1e-6) dice_list.append(dice.cpu().numpy()) iou_list.append(iou.cpu().numpy()) dice_all = np.concatenate(dice_list) iou_all = np.concatenate(iou_list) print(f"Dice: {dice_all.mean():.4f} ± {dice_all.std():.4f}") print(f"IoU: {iou_all.mean():.4f} ± {iou_all.std():.4f}")

把dice和iou分别按样本维度拼接后求均值和标准差,比只汇报平均值更有参考价值。如果标准差超过 0.05,说明模型对部分样本表现很差,你要去翻这些低分样本的图片,很可能就是睫毛遮挡严重或者强反光的图。

测试集分数的上限基本由数据本身的标注一致性决定。瞳孔边界在普通图像上其实很模糊,标注人员对边缘位置的判断差异就能造成 0.02 的 Dice 波动,所以不要盲目追求 0.99,要先确认标注质量再谈模型优化。

5. 瞳孔虹膜分割的五个高频坑:从标注语义到推理部署

5.1 瞳孔内部的高光反光点被打成背景

现象:训练时 Dice 很高,但推理结果里瞳孔中心出现黑色小洞,面积跟原图里的反光点高度重合。原因:角膜反光区域在灰度图上和背景一样亮,模型没有足够的信息判断这里是瞳孔而不是背景。解决:训练时将反光点当作一个正常类别处理,不单独挖掉,同时增强时保留GaussNoise增加对干扰的鲁棒性。推理后可以用形态学闭运算把小于一定面积的黑洞填补掉,但更彻底的做法是增加带反光样本的权重,让模型学会“边界内部全是瞳孔”。

5.2 睫毛与眼皮遮挡让虹膜边缘出现锯齿和漏检

现象:测试集里侧面人脸或者闭眼瞬间的图像,虹膜边缘分割出现明显锯齿,甚至直接把遮挡区域判成背景。原因:遮挡区域在标注时本来就带有主观性,不同标注者对“看不见的虹膜该不该标”有分歧,模型在模糊边界上无所适从。解决:按训练集边缘像素的标注置信度分布做权重图,对边缘附近的 loss 降权;或者干脆先不做虹膜外边界细化,只保瞳孔分割,把遮挡问题留给上层业务判断。

5.3 mask 读进来是三通道,loss 维度不匹配

现象:train_one_epoch报维度错误,或者 loss 一直是常数不下降。原因:数据集里部分 mask 是 RGB 假彩色 PNG,三通道完全一样,代码里按IMREAD_GRAYSCALE读出来依然有形状问题。解决:在 Dataset 里统一加一步cv2.cvtColor(mask, cv2.COLOR_RGB2GRAY)之后再做二值化,并且在配对核验阶段就检查len(mask.shape) == 2,从源头拦截。

5.4 训练集和测试集设备或光照分布不一致,线上 Dice 接近崩溃

现象:训练集 Dice 0.96,测试集只有 0.88,差距一直缩不小。原因:采集批次不同,测试集里换了一种光源或摄像头,瞳孔图像的整体对比度、色温都有偏移。解决:训练时除了RandomBrightnessContrast,再叠加HueSaturationValue或者灰度归一化,把图像统一到标准范围;如果测试集分布已知,可以在预处理阶段做一个基于均值和标准差的归一化,让两域靠近再进模型。

5.5 分两类标注时按二分类默认阈值推理,瞳孔与虹膜边界被吞掉

现象:标好 0、1、2 三类的数据集,模型输出两层 softmax,但你只按背景和前景取 argmax,瞳孔和虹膜之间那条边界完全没体现。原因:推理代码写成了二分类的阈值逻辑,根本没有对不同类别做通道分离。解决:确认标注语义后,输出通道设置成 2,推理时torch.argmax(preds, dim=1)同时拿到瞳孔和虹膜掩膜,后续再按需求合并或者单独取用。

6. 测试集之外的验证技巧:可视化叠加、概率图直方图与形态学后处理

测试集指标达标并不代表模型可以直接部署,还得用肉眼过一遍预测结果。最常见做法是原图、GT、预测三图并排,再叠加一层半透明掩膜,快速定位系统性的过分割和欠分割位置。

import matplotlib.pyplot as plt import numpy as np def visualize_prediction(img, mask, pred, idx): img = img.permute(1, 2, 0).cpu().numpy() mask = mask.squeeze().cpu().numpy() pred = pred.squeeze().cpu().numpy() overlay = np.zeros_like(img) overlay[..., 1] = pred # 绿色通道显示预测前景 plt.figure(figsize=(12, 4)) plt.subplot(1, 3, 1); plt.imshow(img); plt.title("Input") plt.subplot(1, 3, 2); plt.imshow(mask, cmap="gray"); plt.title("GT") plt.subplot(1, 3, 3); plt.imshow(img); plt.imshow(overlay, alpha=0.4); plt.title("Prediction") plt.savefig(f"sample_{idx}.png", dpi=150, bbox_inches="tight")

叠加图里绿色高亮区域如果明显超出 GT 的边界一圈,说明模型对边缘过于激进,多半是训练时边缘权重偏高;如果瞳孔内部出现空洞,回到反光问题。除了看图,概率图直方图也是排查利器——导出预测 sigmoid 值的分布,如果直方图呈 U 型且中间低洼,说明模型置信度充足,阈值 0.5 合适;如果大量像素堆在 0.4 到 0.6 之间,说明模型对边界区域犹豫,这时候可以尝试把阈值调到 0.45 或者 0.55 看测试集指标变化,这是最廉价的后处理手段。

形态学闭运算适合作为最后一层保险。预测完成后做一次 3x3 的闭运算,能闭掉瞳孔内部的反光空洞,再用 3x3 开运算去掉虹膜边缘的孤立噪点。后处理参数不要贪大,结构元素超过 5x5 会明显改变边界位置,反而拉低 Dice。另一条好用的经验是保留 0.5 阈值的原始概率图,出问题时先用阈值扫描定位是模型问题还是后处理问题,不要一上来就盲调形态学参数。

有一回我在测试集上跑了 0.96 的 Dice,认为模型已经可靠,结果抽了十张侧面图让项目经理看了一眼,他发现虹膜外圈在强光下整个偏了半圈。指标不骗人,但它只能反映标注口径内的表现,超出分布的数据还是要靠可视化兜底。瞳孔虹膜分割这个方向入门不难,把数据检查、损失函数、阈值和后处理这套基本功打扎实,做出来的模型在业务上才扛得住真场景,希望这篇能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询