简介:一套面向人物抠图场景的UNet语义分割实战项目,适合深度学习初学者、图像分割开发者及毕业设计选题者。资源完整覆盖二分类分割的工程实现:网络输出单通道特征图,经sigmoid归一化至[0,1]后与标签计算交叉熵损失,整个流程清晰可复现。包体总量约987MB,含3430个文件,其中3404个PNG图片构成训练数据集与预测输出,11个Python脚本负责模型搭建、训练与推理,另有少量pyc编译缓存、Dockerfile、Shell脚本及README等文件,便于快速配置运行环境、了解项目结构与复现步骤。已有2816人学习下载,配套的LICENSE与gitignore等配置也体现了工程规范性。读者可借助这套代码深入理解UNet在二分类分割中的实践细节,并迁移至人像美化、前景提取等类似任务。
1. UNet语义分割实战:从网络结构到人物抠图的完整落地
第一次用UNet做人物抠图时,我的第一反应是“这模型都经典成这样了,直接跑不就完了”,结果卡在标签归一化上整整一个晚上。UNet语义分割的本质,是把抠图这件事当成像素级二分类——每个像素要么属于人,要么属于背景。这份工程把整条链路都打包好了:UNet网络定义、二分类训练逻辑、推理和后处理脚本,外加Dockerfile和示例图。适合两类人:一是刚入门语义分割、想把UNet代码跑通并看懂每个环节的,二是要拿UNet训练自己数据集做前景提取的。读完你会明白单通道输出和双通道softmax差在哪,也会知道训练时最容易翻车的几个细节。
2. UNet的结构与二分类设计:为什么人物抠图首选它
人物抠图看着简单,真正做起来有两个硬约束:边缘要干净,训练要稳定。UNet之所以在这个任务上一直是强基线,和它的结构设计直接相关。这一章先拆结构,再讲二分类输出方案,最后落到这份资源里数据集和标签的组织方式。搞清楚这三件事,后面训练和推理的代码才有依据。
2.1 编码器-解码器与跳跃连接:UNet高边缘保留能力的来源
UNet的骨架是一个对称的U型。编码器部分由四个下采样阶段组成:每个阶段先做两次卷积,卷积核3×3、padding保持尺寸不变,然后接BatchNorm和ReLU,最后用2×2最大池化把特征图尺寸减半、通道数翻倍。输入512×512的RGB图,经过四层下采样后,最底层的特征图是16×16,通道数从64一路增加到512。解码器则做相反的事,用转置卷积把特征图逐步恢复分辨率,每一层再把编码器对应层的特征图在通道维度上拼接起来。
跳跃连接(skip connection)是UNet的灵魂。以第一个上采样阶段为例:解码器把16×16×512的特征图上采样到32×32×256,然后和编码器第三层输出的32×32×256特征图拼接,得到32×32×512,再继续向上。拼接后的特征图同时包含两类信息:深层特征知道“这是一片人体区域”,浅层特征知道“这里的边缘纹理具体长什么样”。人物抠图最难的头发丝、手指缝隙,靠的就是这些浅层高频细节。DeepLabV3用空洞卷积扩大感受野,SegNet用池化索引,两者偏重语义区域识别;UNet则把边缘细节直接送到解码器,所以在人物分割这类边界敏感的任务上表现更稳。
从参数角度看,输入512×512、初始通道64的UNet,参数量大约在3100万左右,一张12G显存的卡可以跑batch size 8。工程里如果看到初始通道被改成32或48,那是为了在低显存设备上训练做的妥协。需要记住的一个原则是:输入尺寸必须能被32整除,因为网络里有四次下采样,每次减半,尺寸不被32整除,跑到最后一层或者跳跃连接拼接时会直接报错。
2.2 单通道sigmoid与双通道softmax:两种二分类输出方案对比
这套工程里人物抠图被当成二分类任务来做。摘要里提到的第一种方案是单通道输出:网络的输出形状是[batch_size, 1, height, width],对每个像素输出一个实数logit,然后在训练和推理时对这个logit做sigmoid归一化,得到范围落在[0,1]的概率值。标签图是单通道的0/1图,0代表背景、1代表人,两个值直接作为交叉熵的目标。
第二种方案是双通道输出:输出形状为[batch_size, 2, height, width],两个通道分别代表背景和人的logit,之后在通道维度上做softmax,得到每个像素属于两类的概率之和为1。二分类场景下这两种方案数学上基本等价,但实现细节上有个容易被忽略的差别。
| 对比项 | 单通道 + sigmoid | 双通道 + softmax |
|---|---|---|
| 输出形状 | [B, 1, H, W] | [B, 2, H, W] |
| 归一化方式 | sigmoid,逐像素独立 | softmax,两通道联合归一化 |
| 损失函数常用实现 | BCELoss / BCEWithLogitsLoss | CrossEntropyLoss |
| 标签格式 | 单通道0/1图 | 单通道0/1图或one-hot |
| 显存占用 | 更小 | 略大 |
| 适用场景 | 纯二分类,前景背景 | 多分类或多标签二分类 |
PyTorch里这两种方案对应的损失函数写法差别很大。单通道方案我最常用nn.BCEWithLogitsLoss,它把sigmoid和binary cross entropy合并在一起做了数值稳定性处理,输入的target是[B,1,H,W]的float张量,值只能是0或1。双通道方案用nn.CrossEntropyLoss,输入的target是[B,H,W]的长整型张量,不需要额外加通道维度。很多人翻车就是把两种方案的标签格式搞混了。
这份工程采用的是单通道方案,也就是摘要里描述的第一种做法。从工程角度看,单通道输出的好处是后处理更直接——模型输出一个概率图,阈值一设,mask就出来了,不需要在通道维度上做argmax。训练时让输出先过sigmoid再和0/1标签算交叉熵,这个流程在PyTorch里可以直接用BCEWithLogitsLoss一步完成。
2.3 数据准备:理解标签约定与归一化
工程自带的几张png示例图,来自公开的人物分割数据集,原图是普通RGB照片,对应标签图是单通道的灰度图。这里有一个关键约定:标签图里的像素值不是0和1,而是0和255——0表示背景,255表示前景人物。如果直接把这样的标签喂给交叉熵损失,模型永远收敛不了,因为sigmoid的输出范围是[0,1],永远够不到255这个目标值。
所以数据加载的第一步,是把标签图除以255,让它变成0/1的浮点图。数据集的原始目录结构常见组织方式如下:
data/ ├── images/ │ ├── 00315.png │ ├── 00458.png │ └── ... └── masks/ ├── 00315.png ├── 00458.png └── ...对应的Dataset读取逻辑里,图像和标签最好同步resize到同一尺寸,并且都要转成float类型。图像除以255归一化到[0,1],标签除以255变成0/1。下面是一段标准的预处理代码:
class MattingDataset(Dataset): def __init__(self, image_dir, mask_dir, size=512): self.image_paths = sorted(glob.glob(os.path.join(image_dir, "*.png"))) self.mask_paths = sorted(glob.glob(os.path.join(mask_dir, "*.png"))) self.size = size def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image = cv2.imread(self.image_paths[idx]) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) mask = cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) image = cv2.resize(image, (self.size, self.size)) mask = cv2.resize(mask, (self.size, self.size)) image = torch.from_numpy(image).permute(2, 0, 1).float() / 255.0 mask = torch.from_numpy(mask).unsqueeze(0).float() / 255.0 return image, mask这里mask / 255.0是最容易漏的一步。我在第一次跑这套流程时,就是因为没做这步,loss降到0.01以后输出全黑,排查了快三个小时才发现是标签范围的问题。cv2.resize对mask默认用线性插值,会在边缘产生介于0和255之间的中间值,除以255后会变成非0非1的小数,比如0.5。批训练时没关系,但推理做阈值判断时要注意用0.5而不是0作为分界。
3. 训练代码与关键参数:损失函数、优化器和可复现配置
网络结构定了,数据加载对了,接下来就是训练。这一章给出可以直接抄的训练代码,并解释每个参数的选取理由。训练环节最影响结果的是三件事:损失函数怎么组合、优化器和学习率怎么设、数据增强做到什么程度。逐项说清楚。
3.1 损失函数:为什么只用BCE不够,要叠加Dice
二分类分割最基础的损失是BCE,但直接用在人物抠图上会有一个问题:图片里背景区域占比通常很高,比如一张1080×720的图,人物可能只占30%的区域,剩余全是背景。BCE对每个像素的惩罚是平等的,背景像素数量多,梯度就被背景主导,模型会倾向于把整个图都预测成背景来降低loss。
解决方法是叠加Dice损失。Dice系数衡量的是预测集合和真实集合的重叠程度,对类别不平衡不敏感,因为它是按区域重叠率算的,而不是按像素逐一平均。常见的做法是BCE和Dice按1:1相加。Dice损失实现如下:
def dice_loss(pred, target, smooth=1.0): # pred: [B,1,H,W] 已经过sigmoid的概率值 # target: [B,1,H,W] 值为0/1的标签 pred = pred.contiguous().view(-1) target = target.contiguous().view(-1) intersection = (pred * target).sum() return 1 - (2.0 * intersection + smooth) / (pred.sum() + target.sum() + smooth)smooth是平滑项,作用是防止前景区域为0时除零,也起到一定的正则作用,一般取1.0就行。注意这个函数接收的是sigmoid之后的概率,不是原始logits,如果传入logits,交并集的数值范围会失去概率语义,损失值会异常。还要注意contiguous().view(-1)先把张量在内存中变成连续的再展平,避免view操作报错。
混合损失在训练循环里的写法如下:
bce_criterion = nn.BCEWithLogitsLoss() # logits是模型原始输出[8,1,512,512] bce_loss = bce_criterion(logits, masks) dice = dice_loss(torch.sigmoid(logits), masks) loss = 0.5 * bce_loss + 0.5 * dice这里有个细节:bce_criterion传的是原始logits,dice_loss传的是sigmoid(logits)。BCEWithLogitsLoss内部自己做了sigmoid和BCE的合并,有数值稳定性优化,如果先手动sigmoid再用nn.BCELoss,在logit绝对值较大的区域容易产生梯度消失或数值波动。这就是为什么我一律推荐用BCEWithLogitsLoss而不是BCELoss。
3.2 训练主循环:优化器、调度器与每个参数的含义
训练配置方面,优化器选AdamW而不是SGD。人物分割任务不是特别吃优化器的极端调参,AdamW在大部分情况下能更快收敛,weight decay也能有效抑制过拟合。学习率初始值我一般设为1e-4,这个值对512×512输入、8个batch size的UNet来说是安全的。如果batch size减半,学习率可以适当降到7e-5到8e-5之间。
调度器用余弦退火,训练总轮数80到100轮,学习率从1e-4平滑降到接近0。余弦退火的好处是前中期保持较大学习率快速收敛,后期自动缩小学习率稳定在局部最优点附近。训练主循环代码:
model = UNet(in_channels=3, out_channels=1, init_features=64).to(device) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=80, eta_min=1e-6) bce_criterion = nn.BCEWithLogitsLoss() for epoch in range(80): model.train() running_loss = 0.0 for images, masks in train_loader: images, masks = images.to(device), masks.to(device) logits = model(images) loss = bce_criterion(logits, masks) + 0.5 * dice_loss(torch.sigmoid(logits), masks) optimizer.zero_grad() loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) scheduler.step() print(f"Epoch {epoch+1}, Loss: {running_loss / len(train_loader.dataset):.4f}")T_max设为80表示余弦曲线在80轮内从初始学习率下降到eta_min,如果改了总轮数,这个值要同步改。weight_decay=1e-4用来约束模型权重不要过大,但对BN层的gamma和beta有时会产生不好的影响,如果训练曲线出现异常波动,可以改用decoupled_weight_decay=False的AdamW或者对BN层参数单独设weight_decay为0。训练过程中重点关注loss下降速度:正常情况前10轮loss会从0.7附近快速降到0.3以下,如果前5轮loss纹丝不动,优先检查标签范围和学习率。
3.3 训练自己的数据集:目录组织、超参数参考与Docker环境
要换成自己的数据集,把数据按前面Dataset代码里的目录结构放好,image_dir指向原图目录,mask_dir指向标签目录,两张图保持同名。标注工具建议用Labelme导出8位单通道png,背景为0、前景为1或255,导出后先随机抽几张图检查一下像素值分布,确认没有其他杂散值。
超参数参考表如下:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 512×512 | 显存有限可降到384或256,但边缘细节会变差 |
| batch size | 8 | 12G显存上限;OOM就改4并同比例降学习率 |
| 初始通道 | 64 | 显存不够改32,模型参数量降到约1/4 |
| 优化器 | AdamW | lr=1e-4,weight_decay=1e-4 |
| 调度器 | CosineAnnealingLR | T_max=总轮数,eta_min=1e-6 |
| 总轮数 | 80 | 数据量小可提前早停 |
| 混合损失 | BCE + Dice(0.5:0.5) | 类别极度不平衡时Dice权重可提到0.7 |
数据增强方面,我一般只做随机水平翻转、随机缩放裁剪和轻微颜色抖动。随机裁剪能模拟人物在画面不同位置的情况,增强平移鲁棒性;颜色抖动可以缓解光照变化的影响。注意不要在mask上做颜色抖动,只能用几何变换。torchvision的RandomApply配合ColorJitter就能实现,几何变换需要把image和mask同步操作,这对分割任务来说是个关键点——很多人只对图像做增强,忘了给mask做同样的变换,结果就是训练时图像和标签错位。
工程里自带了一份Dockerfile,构建后可以在容器里直接跑,避免torch和opencv版本互踩的问题。如果你的机器已经配好环境,不用Docker也完全不影响跑通,核心代码不依赖容器特性。我一般建议环境不干净的人优先用Docker,虚拟环境次之,裸装是在浪费排查时间。
4. UNet训练与推理避坑:五个高频问题的现象、原因与解决
跑UNet跑多了,会发现翻车的点高度集中。这一章记录我在这套代码上踩过的五类高频问题,每一条都按现象、原因、解决三个层次写清楚。训练前把这些过一遍,能省下不少反复试错的时间。
4.1 标签值是0和255,loss降到很低但预测全黑
现象:训练时loss一路下降,甚至降到0.01以下,但保存的mask全黑,或者输出的概率值全小于0.5。训练曲线看起来一切正常,结果却完全不能用。
原因:标签png是单通道灰度图,背景0、前景255。如果Dataset里没有做除以255的归一化,模型输出的sigmoid概率范围是[0,1],永远无法拟合255这个目标值。网络为了降低loss,会把所有权重推向让输出趋近于0的方向,最终所有像素都被预测成背景。
解决:在__getitem__里对mask执行mask = mask / 255.0,把标签范围从[0,255]压缩到[0,1]。同时用matplotlib或OpenCV打印一两张mask的像素值分布,确认最大值是255、最小值是0,没有其他杂散值。这个检查动作我后来养成了习惯:每次换数据集先打印标签唯一值,两秒的事,能避免一晚上的无用功。
4.2 输入尺寸不是32的倍数,拼接时维度不匹配
现象:训练时报错The size of tensor a (257) must match the size of tensor b (256),或者推理时输出的mask和原图尺寸对不上,出现明显错位和拉伸变形。
原因:UNet有四次下采样,每次减半,所以输入尺寸必须是2的4次方等于32的整数倍。如果输入是480×640这样不能被32整除的尺寸,下采样到最后一层时特征图尺寸不是整数,上采样回来和编码器特征图拼接时尺寸对不上,直接报错。推理时如果直接resize到任意尺寸,输出再resize回原图,边缘位置会偏移。
解决:推理时先等比缩放,再padding到32的整数倍,最后把padding区域裁掉。下面这段代码是标准做法:
def pad_to_alignment(image, alignment=32): h, w = image.shape[:2] pad_h = (alignment - h % alignment) % alignment pad_w = (alignment - w % alignment) % alignment padded = cv2.copyMakeBorder(image, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value=(0, 0, 0)) return padded, (h, w)padding值选0还是选边缘像素,对结果影响不大,因为推理结束后会裁掉。训练时直接统一resize到512×512就行,不用这么麻烦。
4.3 验证集IoU很高但看起来效果很差
现象:验证集上IoU算出来0.95以上,实际看预测mask,人物的轮廓完全不对,或者背景里大量区域被误判成前景,但IoU数值就是虚高。
原因:两个可能。一是类别不平衡太严重,如果1000张图里人物平均只占10%的像素,那么模型把所有像素都预测成背景,IoU也近似等于背景占比,算出来虚高。二是验证集划分时图像和标签的顺序没有同步打乱,导致image和mask错位,模型看到的标签根本不是对应图的,这时候任何指标都没有参考意义。
解决:评估指标以Dice和前景IoU为准,不要只盯着总体IoU。前景IoU只计算前景区域的重叠率,公式是intersection / (pred_cnt + target_cnt - intersection),用混淆矩阵单独算。数据划分时要固定随机种子,用一个索引列表同时打乱图像和mask的路径,可以打印出image_paths[i]和mask_paths[i]的文件名核对是否同名。
4.4 换了自己的数据集后loss不收敛,卡在0.6附近震荡
现象:用公开数据集训练一切正常,换成自己的数据集后loss在0.6到0.7之间震荡,怎么调学习率都降不下去,或者降得很慢。
原因:常见原因有三个。一是自己的数据集标注里有灰色边界,就是标注工具的羽化笔刷导致边缘像素既不是0也不是255,除以255后出现0.5这样的中间值,BCE对这类标签会产生矛盾梯度。二是类别极端不平衡,上述Dice损失没加进去,BCE被背景主导。三是标注噪声太大,前景区域里有大量漏标或误标。
解决:第一步,把标签图重新阈值化,所有大于127的像素置255,其余置0,消除灰色过渡带。第二步,确认损失函数里Dice部分确实加上了,单独打印一次dice loss的数值,看是否在下降。第三步,跑一个batch的过拟合测试——只拿8张图反复训练50轮,如果loss能降到接近0,说明模型和数据管道没问题,问题出在全部数据的训练配置上;如果连单batch都降不下去,优先怀疑标签和数据读取。
4.5 推理时忘了sigmoid,直接把logits和阈值比
现象:推理时输出的mask全黑,或者全白,概率图看起来只有几种离散的数值,完全没有连续的概率分布。
原因:推理代码里直接对模型输出取阈值判断,忘了logits是未经归一化的原始分数,范围可在负几十到正几十之间。拿0.5去和logits比,正样本的logits如果大于0.5的多,就全白,小于0.5的多,就全黑。这是训练时用了BCEWithLogitsLoss带来的副作用——它把sigmoid封装在损失函数里了,模型输出的还是logits,推理时没人替你过sigmoid。
解决:推理统一封装一个predict函数,把sigmoid放进函数内部,不暴露给调用方。这个函数的实现我在下一章给出。这个坑几乎是每个从分类任务转过来的同学都会踩的,原因在于分类任务的模型输出层通常是softmax或sigmoid,而分割模型为了数值稳定性会输出logits,两种约定不一致。
5. 推理与后处理:从模型输出到干净的alpha蒙版
模型训练好了,真正要做抠图还得走完推理和后处理。这一章把推理脚本、形态学清理和边缘羽化讲清楚,最终输出一个可以直接用于合成的alpha蒙版。
5.1 推理脚本:预处理、前向传播与阈值分割
推理流程和训练时有一个重要区别:训练时resize到固定尺寸就行,推理时必须保持原图比例。如果直接拉伸到512×512,人物的长宽比会变形,输出的mask边缘位置也会偏。正确的做法是等比缩放后padding到32的整数倍,推理结束后再裁掉padding区域并resize回原图尺寸。
def predict(model, image_path, size=512, threshold=0.5): model.eval() img = cv2.imread(image_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w = img.shape[:2] scale = min(size / h, size / w) resized = cv2.resize(img, (int(w * scale), int(h * scale))) padded, (rh, rw) = pad_to_alignment(resized, alignment=32) x = torch.from_numpy(padded).permute(2, 0, 1).unsqueeze(0).float() / 255.0 with torch.no_grad(): logits = model(x.to(device)) prob = torch.sigmoid(logits).cpu().numpy()[0, 0] prob = prob[:rh, :rw] prob = cv2.resize(prob, (w, h), interpolation=cv2.INTER_LINEAR) mask = (prob > threshold).astype(np.uint8) * 255 return mask, probscale的计算保证图像完全放进512×512的框内,不会超出边界。alignnment=32对应UNet的四次下采样。prob[:rh, :rw]裁掉右边和底部的padding区域,然后线性插值恢复到原图尺寸。恢复时用INTER_LINEAR而不是INTER_NEAREST,因为这是概率图,不是二值图,线性插值能保留更平滑的边缘过渡。阈值0.5是一个合理的默认值,但实际使用中可以微调:阈值调高,mask更保守,边缘收缩,不容易带出背景;阈值调低,mask更激进,边缘外扩,可能多出背景。人物抠图建议从0.5开始,根据实际效果往0.4或0.6方向调。
5.2 形态学后处理:开运算去噪与最大连通域筛选
模型输出的mask虽然大概率是对的,但总会带些零星的小噪点——背景里一个被误判成人的小区域,或者前景区域内部几处小空洞。后处理要解决的就是这两类问题。开运算是先腐蚀再膨胀的组合操作,能去掉独立的小噪点;闭运算相反,先膨胀再腐蚀,能填补前景内部的小空洞。人物抠图场景下,我一般先开运算,再按需要决定要不要闭运算。
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8) if num_labels > 1: largest_label = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA]) mask = np.where(labels == largest_label, 255, 0).astype(np.uint8)这里kernel尺寸5×5在512×512的输入下比较合适,如果输入是1080×1920的大图,可以把kernel放大到7×7或9×9。connectedComponentsWithStats统计所有连通域的面积,stats[1:, cv2.CC_STAT_AREA]跳过背景区域,从第1行开始取每个前景连通域的面积,argmax找到最大连通域的索引,然后把其他连通域全部置为背景。这一步很重要,它假设了画面里最大的连通域就是人物主体。如果画面里有多个人,这个假设不成立,需要根据业务场景决定保留几个连通域。
5.3 logit、probability与mask:三者的关系与转换
后处理做完,你在内存里会有三种不同的数据形态。搞清楚它们的区别,能避免很多调试时的困惑。
| 数据形态 | 数值范围 | 含义 | 常见操作 |
|---|---|---|---|
| logits | (-∞, +∞) | 网络最后一层的原始输出 | 只有计算损失时才直接用 |
| probability | [0, 1] | sigmoid归一化后的概率 | 可视化、计算IoU、阈值分割 |
| mask | {0, 255} | 二值化后的掩膜 | 保存为png、做形态学操作、合成抠图 |
训练阶段用的是logits,因为BCEWithLogitsLoss内部做了数值稳定性处理。验证和可视化阶段用probability,因为它有明确的概率语义。保存成图片和做形态学操作时用mask,因为二值图像才能正确处理连通域分析。最后边缘羽化时,我把mask转成alpha蒙版:
alpha = cv2.GaussianBlur(mask, (0, 0), sigmaX=3) alpha = alpha.astype(np.float32) / 255.0高斯模糊让硬边缘产生一个3像素左右的过渡带,合成时前景边缘不会出现明显的白边或锯齿。sigmaX取3对应人物头发这种高频边缘是够用的,取太大会让边缘泛白,取太小则羽化效果不明显。
6. 模型效果验证:训练曲线之外必须做的三重检查
模型训练完,不能只看loss曲线和验证集IoU就收工,我每次都会强制自己走一遍三重验证,缺一不可。
第一重是单图可视化。随机从测试集抽3到5张不同背景、不同姿态、不同光照的图,把原图、概率图和二值mask并排画出来。这里用概率图判断比直接用mask更直观——概率图中人物边缘如果呈渐变色,说明模型对边缘有一定置信度过渡;如果边缘一片暗色,说明模型根本没学到边缘细节。可视化代码随手就能写:
fig, axes = plt.subplots(1, 3, figsize=(12, 4)) axes[0].imshow(image) axes[0].set_title('original') axes[1].imshow(prob, cmap='jet', vmin=0, vmax=1) axes[1].set_title('probability') axes[2].imshow(mask, cmap='gray') axes[2].set_title('mask')第二重是前景IoU与Dice复核。用前面提过的方法单独算前景区域的IoU,不要看总体IoU。人物分割任务里,前景IoU低于0.85基本不能直接用于生产,需要检查数据标注质量或增加训练轮数。Dice和IoU的数值关系是Dice一般略高于IoU,如果出现两者差距过大,说明预测结果存在系统性偏差,比如整体偏保守或偏激进。
第三重是跨样本稳定性验证。找几张训练时没见过的高难度图——逆光剪影、蹲姿、人物和背景颜色相近的图,跑一遍推理看输出是否稳定。逆光图背景亮度过高,模型容易把人物和背景混在一起;人物穿和背景相近颜色的衣服时,模型容易丢失大块前景区域。这些测试图不需要进训练集,可以作为固定验证集长期保存。
如果三重检查都过了,这份模型基本可以进入使用阶段。如果第一重就挂了,比如边缘锯齿严重,先调后处理参数而不是重新训练——把开运算kernel调大一圈、阈值往0.55调一下,很多表面上的问题其实都是后处理没调好。我在训练完第一版UNet模型时,也是被这个细节折腾了两天:模型预测其实很准,但后处理阈值设得太低,把背景里一大片灰度区域带进了mask。从那以后,我每次训练完都强制走一遍这套三重检查,确认结果没问题才敢说“模型训好了”,这份UNet抠图工程也是按这套流程跑通的,下下来对照着跑一遍,比自己从零抄能省至少半天时间。希望帮到你。
本文还有配套的精品资源,点击获取