尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

深度学习Unet图像分割实战:从数据集预处理到miou指标全解析

深度学习Unet图像分割实战:从数据集预处理到miou指标全解析 简介一份面向深度学习入门与进阶开发者的 Unet 二值图像分割实战项目基于 DUT-OMRON 数据集完整涵盖 4135 张训练图和 1033 张测试图及其对应掩膜适合学习图像分割、模型训练与调参。资源包共 2000 个文件以 png 图像、py 脚本、xml 配置和 readme 说明为主整体约 223.63MB目录含训练集、测试集、训练脚本、预测脚本与结果曲线。项目实现了自动计算灰度均值与方差用于归一化并支持随机缩放 0.51.5 倍的多尺度训练模型训练 50 个 epochmIoU 稳定在 0.72 左右学习率采用余弦衰减同时保存了训练日志、最优权重以及由 matplotlib 绘制的损失和 IoU 曲线。预测脚本可直接推理 inference 目录下的所有图片代码均已注释参考 README 即可用自己的数据傻瓜式运行。目前已有 442 人学习下载适合正在做分割课题或希望快速跑通 Unet 流程的开发者参考复用。1. 深度学习 Unet 图像分割DUT-OMRON 二值分割数据集实用在哪做深度学习 Unet 图像分割最难啃的往往不是网络结构——Unet 在中小规模二值图像分割任务上表现足够稳编码器提特征、解码器恢复分辨率、跳跃连接补细节套路固定。真正让人翻车的是数据组织、预处理和训练日志这些不起眼的环节。这个深度学习 Unet 实战分割项目把 DUT-OMRON 图像分割数据集整理成了直接能跑的格式训练集 4135 张原图加 4135 张二值 mask测试集 1033 对合计 113MB。train 脚本会自动计算灰度均值和方差用于归一化并把数据随机缩放到设定尺寸的 0.5 到 1.5 倍之间做多尺度训练50 个 epochs 训下来 miou 稳定在 0.72 左右学习率按 cos 衰减训练和测试的损失、IoU 曲线都保存在 run_results 里。适合刚跑通分割流程、或者想用自己的数据复现 Unet 训练闭环的人。2. 数据与预处理DUT-OMRON 二值分割的数据组织和归一化细节拿到项目别急着跑 train 脚本先花十分钟把数据目录结构看清楚。二值分割的数据组织比较特殊原图和 mask 分开目录放文件名一一对应任何一步命名不匹配都会让 DataLoader 直接报错或者静默读错样本。我用这个项目踩过两次命名上的坑一次是扩展名不一致一次是排序混乱导致训练和验证的对不上后面全是在排查指标异常时才发现的。2.1 数据集目录结构与训练/测试划分项目的 DUT-OMRON 部分分 train 和 test 两个大目录train 里有 images 和 masks 两个子目录分别放 4135 张原图和 4135 张对应的二值 masktest 同理是 1033 对。文件名是一一对应的比如 im050.jpg 对应 im050_mask.png 这种约定。项目附带的 im050_result.jpg、im061_result.jpg 是训练完成后跑出来的预测结果可视化sun_*.png 是训练过程中定期保存的验证集输出方便还没训练完就能直观看到模型在学什么。目录内容数量train/images原图自然场景图片4135train/masks二值 mask单通道 0/2554135test/images原图1033test/masks二值 mask1033读取原图和 mask 的标准做法是直接用 cv2.imreadmask 按灰度模式读。这里有个实在的坑mask 文件别用 JPEG 格式保存JPEG 的有损压缩会在物体边缘产生过渡灰度二值 mask 一旦出现 127 这种中间值训练时 loss 会间歇性波动看起来像模型在跳实际是标签被压缩算法污染了。项目里统一处理成 PNG这个选择是对的。import cv2 import os train_img_dir train/images train_mask_dir train/masks img_names sorted(os.listdir(train_img_dir)) for name in img_names: img cv2.imread(os.path.join(train_img_dir, name)) mask cv2.imread( os.path.join(train_mask_dir, name.replace(.jpg, .png)), cv2.IMREAD_GRAYSCALE ) # img: (H, W, 3) BGR, mask: (H, W) 取值 0 或 255这段代码先把图像文件名排序保证每次读进来的顺序一致mask 的路径把 .jpg 后缀替换成 .png文件名主体相同就能对上。mask 用 IMREAD_GRAYSCALE 读成单通道避免后面和模型输出的单通道预测图算 loss 时维度不匹配。有个小细节cv2 读图默认是 BGR 通道序后面转成 RGB 再喂给模型预处理函数里别忘了这一步否则训练时模型看到的颜色分布是反的。2.2 灰度均值方差自动计算归一化不再手算很多 Unet 教程里预处理直接写死 mean0.5、std0.5这在数据集整体偏亮或偏暗时会让训练收敛变慢甚至让模型对亮度分布产生错误依赖。DUT-OMRON 是自然场景显著目标数据不同图片的亮度差异很大林间阳光直射和夜间暗光场景出现在同一个数据集里很常见。所以这个项目的 train 脚本会在训练开始前先扫描一遍训练集所有图片把每个通道的全局均值和标准差算出来再拿这两个统计量去做标准化。import numpy as np import cv2 import os def compute_mean_std(img_dir): pixel_sum np.zeros(3, dtypenp.float64) pixel_sq_sum np.zeros(3, dtypenp.float64) count 0 for name in os.listdir(img_dir): img cv2.imread(os.path.join(img_dir, name)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) / 255.0 pixel_sum img.sum(axis(0, 1)) pixel_sq_sum (img ** 2).sum(axis(0, 1)) count img.shape[0] * img.shape[1] mean pixel_sum / count std np.sqrt(pixel_sq_sum / count - mean ** 2) return mean, std mean, std compute_mean_std(train/images) print(fmean: {mean}, std: {std})这里两个参数要留意。第一是像素值先除以 255 再算统计量这样得到的是 0 到 1 区间的均值和标准差和 PyTorch 里 Normalize(mean, std) 的默认输入范围一致直接对 0 到 255 的整数像素算Normalize 时相当于做了二次缩放数值全乱。第二是用 E[x^2] - E[x]^2 这个公式推方差比直接调用 np.var 省内存因为不用把全部像素临时存进数组对 113MB 的数据集差别不大换到 GB 级数据集这个写法能省不少内存。算完这组 mean 和 std 后保存到项目配置里推理时直接用不要再对单张图重新算。2.3 transforms.py 重实现多尺度随机缩放的边界行为多尺度训练的做法是每个 batch 随机把输入缩放到基准尺寸的 0.5 到 1.5 倍之间让网络在不同尺度上学到尺度不变的特征。实际训练时输入尺寸是固定的所以项目里的做法是随机缩放后再随机裁剪回固定尺寸。transforms.py 里重新实现了这部分预处理函数核心逻辑和常见做法一致import random import cv2 def random_scale_and_crop(img, mask, base_size(256, 256)): scale random.uniform(0.5, 1.5) h, w int(base_size[0] * scale), int(base_size[1] * scale) img cv2.resize(img, (w, h), interpolationcv2.INTER_LINEAR) mask cv2.resize(mask, (w, h), interpolationcv2.INTER_NEAREST) # 高度方向随机裁剪 if h base_size[0]: top random.randint(0, h - base_size[0]) img img[top:top base_size[0], :, :] mask mask[top:top base_size[0], :] # 宽度方向随机裁剪 if w base_size[1]: left random.randint(0, w - base_size[1]) img img[:, left:left base_size[1], :] mask mask[:, left:left base_size[1]] return img, mask这段最核心的是 img 和 mask 用了不同的插值方式。原图用 INTER_LINEAR 线性插值图像本身连续线性插值不会引入明显伪影mask 用 INTER_NEAREST 最近邻因为二值 mask 只有 0 和 255 两个值线性插值会在这两个值之间产生 127 这种过渡灰度破坏二值约束。如果你用的是三通道彩色标注图而不是单通道 mask这个问题更隐蔽——过渡值不会报错但训练时 loss 会一直在某个平台抖动看起来像欠拟合实际是标签被插值搞脏了。3. Unet 训练策略50 个 epochs 的损失曲线与 cos 衰减调参跑通一个 Unet 很容易跑出一个指标能看的 Unet 需要花点心思。这个项目的训练配置是 50 个 epochs、cos 学习率衰减、自动保存最佳权重整体属于比较标准的短周期训练方案。我拆完这套配置最大的感受是每个超参数选择都有明确意图不是随便拍的。3.1 网络结构选型与输入尺寸设置Unet 的结构大家都熟编码器 4 次下采样每层两个 3x3 卷积加一个 max pooling把分辨率从 256 压到 16通道数从 64 翻到 512解码器 4 次上采样每次都把编码器对应层的特征图沿通道方向拼过来再卷积再上采样最终恢复到原分辨率。二值分割只需要输出一个前景概率通道所以最后一层是 1x1 卷积把通道数压到 1再接 sigmoid。选 Unet 而不是其他分割模型的原因也直接DUT-OMRON 这类单目标显著检测前景和背景在边缘处对比强Unet 的跳跃连接能保留高分辨率细节比纯 encoder-decoder 结构在边缘处的分割质量好一截。项目里输入尺寸的设定逻辑是给一个基准尺寸代码里对应 base_size通常是 256 或 320训练时在此基础上随机缩放 0.5 到 1.5 倍。选 256 是性价比最高的显存友好、训练速度快想提升边缘精度可以改到 320 或 384但显存占用会明显上升。import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, padding1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), nn.Conv2d(out_channels, out_channels, 3, padding1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x)这个卷积块是 Unet 的基本骨架。两个卷积之间夹 BN 和 ReLU 是标配biasFalse 是因为 BN 层自带可学习的偏置卷积层再带 bias 就冗余了。如果把 padding 从 1 改成 0特征图尺寸每过一层卷积就减小 2下采样路径上的跳跃连接拼接会直接报尺寸不匹配这是改网络结构时最容易翻车的地方。想加载 ImageNet 预训练 encoder 的话可以直接用 torchvision 里的 resnet34 替换编码器部分但注意跳跃连接的通道数要对齐否则 concat 时会报错。3.2 学习率 cos 衰减与优化器选择分割任务里优化器常见就两条路Adam 配 1e-3 初始学习率收敛快、前期效果明显SGD 配 momentum 0.9、weight_decay 1e-4收敛慢但上限通常更高。这个项目选的是 cos 衰减也就是学习率按余弦曲线从初始值平滑降到接近 0。cos 衰减的好处是前期保持较高学习率快速收敛后期逐渐逼近最优解比 step 衰减那种每 30 个 epoch 直接砍一半的方式稳定很多尤其适合 50 个 epochs 这种短周期训练。如果项目里没有直接调 CosineAnnealingLR你也可以在训练脚本里自己实现代码很简单import math def cosine_lr(epoch, total_epochs, lr_start, lr_min1e-6): return lr_min 0.5 * (lr_start - lr_min) * (1 math.cos(math.pi * epoch / total_epochs)) # 每个 epoch 开始前调用 lr cosine_lr(epoch, 50, 1e-3) for param_group in optimizer.param_groups: param_group[lr] lr这个函数里的 total_epochs 必须等于实际训练轮数设成 100 但只训 50 个 epoch学习率只衰减到一半就停了模型会提前进入平台期后面 10 个 epoch 基本白训。cos 函数在中段衰减最快所以第 20 到 35 个 epochs 之间你会观察到 loss 曲线有几次明显下跳这是正常现象。如果你之前只跑过固定学习率第一次看到这种下跳大概率会以为训练崩了——其实没有这是 cos 衰减的正常节奏。3.3 训练日志与 best 权重保存机制项目把训练过程中的关键信息全部落盘到 run_results 目录包括每个 epoch 的训练损失、验证集损失和 miou以及 matplotlib 绘制的曲线图和 best 权重文件。保存 best 权重的逻辑非常朴素但值得抄best_miou 0.0 for epoch in range(50): train_loss train_one_epoch() val_loss, val_miou validate() if val_miou best_miou: best_miou val_miou torch.save(model.state_dict(), run_results/best_model.pth) with open(run_results/best_epoch.txt, w) as f: f.write(fepoch: {epoch}, miou: {val_miou:.4f}) with open(run_results/train_log.txt, a) as f: f.write(f{epoch} {train_loss:.4f} {val_loss:.4f} {val_miou:.4f}\n)判断标准是用验证集 miou 而不是训练损失来选模型这个细节很关键。训练损失低只代表模型拟合了训练集最后如果拿 train_loss 最小的权重去推理很可能得到一个过拟合严重的模型。日志按行追加每行固定四个数值后面用 matplotlib 画曲线或者用 pandas 读进来分析都很方便。best_epoch.txt 单独记录最优 epoch 的编号省得翻日志。我一般还会把每次训练的配置参数也写进日志头部比如 batch size、输入尺寸、学习率方便几天后回来看结果时还能对上号。4. 预测与验证用 run_results 里的曲线判断模型真实水平训练跑完只是第一步。真正要回答的问题是模型到底行不行这要拆成两件事一是预测脚本能不能稳定批量出结果二是 run_results 里的曲线和指标怎么解读。很多人训完模型只看一眼 loss 图就觉得完事了实际上那里面藏着的过拟合、标签错误、阈值不合适这些问题不看透就白训了。4.1 预测脚本批量推理 inference 目录项目的预测脚本会自动读取 inference 目录下所有图片逐张推理并输出带 _result 后缀的结果图。推理时最容易犯的错是用了一套和训练时不一样的预处理所以脚本里通常把加载权重和预处理封装在一起import torch import cv2 import glob import numpy as np model.load_state_dict(torch.load(run_results/best_model.pth)) model.eval() with torch.no_grad(): for img_path in glob.glob(inference/*.jpg): img cv2.imread(img_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) / 255.0 img cv2.resize(img, (256, 256)) img_tensor torch.from_numpy(img).permute(2, 0, 1).unsqueeze(0).float() pred torch.sigmoid(model(img_tensor)) pred (pred 0.5).byte().squeeze(0).squeeze(0).numpy() out (pred * 255).astype(np.uint8) cv2.imwrite(img_path.replace(.jpg, _result.jpg), out)代码里有三个点需要核对。第一是归一化用的均值和方差必须和训练时 compute_mean_std 算出来的一致不能推理时临时改成 mean0.5 那套否则亮度分布对不上预测结果会整体偏移。第二是尺寸训练时用了随机缩放加裁剪推理时就直接 resize 到基准尺寸 256不要引入随机性。第三是阈值 0.5二值分割的预测输出是 sigmoid 后的概率大于 0.5 判为前景小于等于 0.5 判为背景。这个阈值可以在验证集上扫一遍找到最优值替代 0.5下面会讲。4.2 miou 0.72 指标怎么解读miou 即 mean Intersection over Union二值分割里最常见的指标。对单一前景类它计算的是预测前景和真实前景的交集除以并集miou |pred ∩ gt| / |pred ∪ gt|项目里 50 个 epochs 训出 0.72 的 miou对 DUT-OMRON 这种包含大量弱光、遮挡、复杂背景自然图片的显著目标数据集来说属于中上水平。纯随机猜测的 miou 非常低所以 0.72 说明模型确实学到了显著性结构而不是靠输出全背景蒙混。但要注意一个坑如果脚本算 miou 时同时算了前景和背景两个类再取平均数值会比只算前景高出不少因为背景区域占大部分背景的 IoU 轻松到 0.95 以上。看指标前先确认这个细节否则对比其他模型时会被虚高的数字误导。4.3 训练/测试损失与 IoU 曲线怎么看run_results 里保存的曲线图是判断训练过程最直接的依据。拿 matplotlib 读取日志文件画图或者直接看项目里已经生成好的图片重点按下面顺序扫第一看两条损失曲线的 gap。训练损失和验证损失在最后 10 个 epochs 里如果 gap 持续扩大说明过拟合了这时应该提前停止或者加数据增强如果 gap 始终很小说明模型泛化正常。第二看验证 miou 曲线的斜率。0.72 不是一步到位的正常会看到它在第 10 到 40 个 epochs 之间阶梯式上升之后逐渐走平。如果 miou 在某个 epoch 后开始抖动下跌排查方向是学习率是否衰减过快或者 batch size 太小。第三看曲线是否存在明显的锯齿。锯齿大说明梯度更新不稳定可能是 cos 衰减的学习率到后期已经很小但 batch size 太小时梯度噪声大权重在小范围内反复跳动。锯齿不大但 loss 降得慢就要检查学习率初始值是否设得太低。5. 避坑指南Unet 二值分割的常见问题与排查这章列的问题全部来自跑分割项目的血泪经验每一条按现象、原因、解决的顺序写你对照着排查比自己翻源码快得多。5.1 现象loss 下降但 miou 不动现象训练损失一路从 0.7 降到 0.2但验证集 miou 一直在 0.2 附近徘徊完全没有上升趋势。原因最常见的是二值 mask 里前景像素占比太小。比如一张 256x256 的图显著目标只占中心一小块背景占 95% 以上。模型发现只要全部输出背景loss 就已经很低miou 却不涨因为没有预测出任何前景像素。解决改用 Dice Loss 或者 BCE Dice 的混合损失Dice Loss 直接优化 IoU 相关的度量对类别不平衡不敏感。也可以对前景像素做加权把背景权重从 1 降到 0.2 左右。一个快办法是训练前先统计 mask 里的前景占比低于 10% 优先考虑调整损失函数。5.2 现象mask 是灰度图归一化方式不对现象训练时不报错但 loss 曲线异常高或者验证集 miou 一直很低。排查后发现数据加载进来的 mask 是 (H, W, 3) 三通道。原因cv2.imread 默认按三通道读图即使源文件是单通道灰度 PNG读进来也会变成三个通道值相同的数组。模型输出是 (H, W)和 (H, W, 3) 的标签算 loss 时PyTorch 会自动广播结果看起来能训但 loss 数值偏大且不稳定。解决读 mask 时强制指定 cv2.IMREAD_GRAYSCALE并在预处理里断言 mask.ndim 2。训练脚本里加一行检查发现 ndim 不等于 2 就直接抛异常避免无效训练跑一晚上才发现问题。5.3 现象多尺度缩放导致 target 与输出尺寸不匹配现象训练到一半报错说 batch 内 tensor 尺寸不一致或者 loss 计算时 pred 和 target 的 H、W 对不上。原因把多尺度缩放的结果直接送进网络没有统一裁剪回固定尺寸。不同样本缩放后尺寸不同PyTorch 的 DataLoader 默认会把一个 batch 内的数据按最大尺寸 pad 成一个张量但网络输出尺寸和 target 的原始尺寸就对不上了loss 计算时维度直接崩。解决多尺度缩放后一定要接随机裁剪或者中心裁剪把输入统一成固定尺寸。项目代码里就是缩放之后做裁剪这步和你说了八百遍真的没有捷径。注意 mask 的裁剪位置必须和原图一致原图裁了哪里mask 必须裁同一个区域两个数组的切片索引要保持同步。5.4 现象显存不够 / batch size 调小后效果变差现象输入尺寸 256、batch size 16 时显存爆掉改成 batch size 4 后能跑了但验证集 miou 比原来低了不少。原因batch size 直接影响 BN 层的统计量估计。batch size 从 16 降到 4 后BN 层每个 step 看到的样本太少均值和方差的估计噪声变大训练稳定性下降最终精度跟着掉。解决优先减小输入尺寸而不是减小 batch size。256 降到 224显存占用降幅明显精度损失通常很小。如果必须用小 batch size把 BN 换成 GroupNorm或者做梯度累积——每 4 个 step 做一次 optimizer.step()等效于 batch size 翻 4 倍只增加一点训练时间。这个问题的玄学之处在于同样一个模型batch size 4 和 16 训出来的结果有时候能差 3 个百分点不是模型问题是 BN 统计量在作祟。5.5 现象推理结果全黑或全白现象predict 脚本跑完输出的结果图要么整张全黑要么整张全白看不到任何分割边界。原因最常见的三个原因按概率排第一推理时忘了过 sigmoid直接把网络输出的 logits 当作概率和 0.5 比较logits 天然可能是负数全判成背景就全黑第二预处理不一致训练时做了归一化和 resize推理时只 resize 没有归一化输入分布偏移导致输出塌缩第三mask 读进来是 0/255而网络输出是 sigmoid 之后的 0 到 1直接拿 0/255 的标签和输出算 BCE 会让 loss 非常大训练发散模型退化到输出恒定值。解决先自查预处理流程确保推理和训练完全一致。然后在预测代码里显式对网络输出执行 torch.sigmoid再和 0.5 比较。训练时如果标签是 0/255务必先除以 255 转成 0/1 再送进 loss。这里自查的顺序一般是先对比训练和推理的 transform再检查有没有 sigmoid最后看标签范围。6. 训练自己的数据集README 之外的三个实用技巧项目 README 说训练自己的数据只需要按目录结构放图就能跑确实能跑通但真上手还会碰到几个 README 没写透的点。第一个是文件名对应关系。如果你的数据是 img_001.jpg 和 img_001_mask.png 这种命名直接用项目脚本没问题如果命名对不上比如原图叫 1.jpgmask 叫 A_1.png别改代码逻辑先把 mask 批量重命名把文件名主体统一。一个二十行以内的脚本就能搞定不值得为了省这点事去改 DataLoader 的匹配规则改完还得防着后续新增数据再次踩坑。第二个是 mask 的格式统一。我见过有人用标注软件导出彩色 PNG 的 mask前景是红色、背景是黑色这种三通道彩色标签直接送进二值分割训练loss 会非常难看。正确做法是训练前把所有 mask 转成单通道 0/255import cv2 import glob import numpy as np for mask_path in glob.glob(train/masks/*.png): mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) binary_mask np.where(mask 127, 255, 0).astype(np.uint8) cv2.imwrite(mask_path, binary_mask)这里的 127 是二值化阈值如果你的 mask 里前景不是纯白而是带灰色边缘可以适当降低到 100 试试。关键是训练前一次性处理完所有训练和测试的 mask不要跑到一半发现有的 mask 没转过来回炉重跑很耽误时间。第三个是验证指标别只信 miou 一个数。跑完 50 个 epochsmiou 0.72 听起来不错但建议额外看一眼预测结果的边缘轮廓。具体做法是从推理输出里随机抽 20 张把 result 图和原图叠在一起看显著目标轮廓是否贴合真实边界犄角旮旯有没有误切细节不好的话miou 再高也只能说明区域大方向对了实际落到真实场景里边缘质量才是决定模型能不能用的关键。我现在的习惯是把抽样的叠加图直接存成一个 HTML 文件训练完打开扫一眼30 秒就能判断这批权重能不能上线。最后说个我自己的习惯每次跑分割项目拿到数据集第一件事永远是打开五张图片和对应的 mask用可视化代码按 1:1 重叠检查一遍确认标签没反、mask 是二值、文件名一一对应。这套检查流程十分钟不到但已经帮我避开过三次标签混乱的翻车事故。从那以后我每次都在预处理脚本的开头强制走一遍检查逻辑再往下跑训练。希望这篇笔记也能帮你在 DUT-OMRON 或者自己的数据上少走几步弯路。本文还有配套的精品资源点击获取
返回列表