第一次接触语义分割时,我被它的神奇能力震撼到了——它能让计算机像人类一样"看懂"图片中的每个像素。简单来说,语义分割就是给图像中的每个像素点打标签的过程。比如在一张街景图中,它能准确区分哪里是道路、哪里是行人、哪里是车辆。
Unet作为语义分割领域的经典网络,其结构设计非常巧妙。我把它想象成一个"U型管道":左边是不断下采样的编码器(Encoder),像漏斗一样提取特征;右边是上采样的解码器(Decoder),像喷泉一样还原细节。中间还有"跳跃连接"(Skip Connection)作为桥梁,把浅层的细节特征传递给深层,这个设计解决了传统网络丢失空间信息的问题。
在实际项目中,我发现Unet有三大优势特别适合初学者:
工欲善其事,必先利其器。建议使用Anaconda创建独立的Python环境,避免包冲突。这是我常用的环境配置命令:
bash复制conda create -n unet python=3.8
conda activate unet
pip install torch==1.12.1+cu113 torchvision==0.13.1+cu113 -f https://download.pytorch.org/whl/torch_stable.html
pip install opencv-python pillow matplotlib labelme
遇到过最头疼的问题就是CUDA版本不匹配。有一次在Ubuntu系统上,明明安装了CUDA 11.3,却总是报错"undefined symbol"。后来发现是PyTorch版本需要精确匹配,建议通过官方命令安装指定版本。
数据标注工具我强烈推荐Labelme,虽然界面看起来有点复古,但用起来非常顺手。标注时有个小技巧:先用大轮廓框选目标,再用小范围调整细节,能节省30%以上的标注时间。标注完成后,用这个命令转换数据格式:
bash复制labelme_json_to_dataset your_annotation.json -o output_dir
原版Unet使用简单的卷积堆叠作为主干,但实际项目中我更喜欢用预训练的VGG16。就像装修房子,直接用精装房比毛坯房省力。VGG16的前13层卷积已经学会了提取通用特征的能力,我们只需要"接"上Unet的解码部分。
这里有个关键细节:VGG16默认输出1000类的分类结果,我们需要去掉最后的全连接层。具体操作如下:
python复制import torchvision.models as models
vgg16 = models.vgg16(pretrained=True)
features = list(vgg16.features.children())
实测发现,使用预训练主干网络后,模型收敛速度提升2-3倍。特别是在医学影像领域,因为ImageNet预训练已经让网络学会了边缘检测等基础特征提取能力。
Unet的精髓在于它的特征金字塔设计。我把它比作乐高积木:底层是大块的积木(低层特征),上层是小颗粒的积木(高层特征),跳跃连接就是把这些不同尺寸的积木巧妙拼接。
在代码实现时,要特别注意特征图的尺寸匹配。我踩过的坑是忘记在上采样后做通道数调整,导致特征融合失败。正确的做法应该是:
python复制class unetUp(nn.Module):
def __init__(self, in_size, out_size):
super(unetUp, self).__init__()
self.conv = conv_block(in_size, out_size)
self.up = nn.Upsample(scale_factor=2, mode='bilinear')
def forward(self, inputs1, inputs2):
outputs = torch.cat([inputs1, self.up(inputs2)], 1)
return self.conv(outputs)
建议仿照PASCAL VOC的目录结构组织数据:
code复制dataset/
├── JPEGImages/ # 原始图片
├── SegmentationClass/ # 标注图片
├── ImageSets/
│ └── Segmentation/
│ ├── train.txt
│ └── val.txt
标注图片需要是单通道的PNG格式,像素值对应类别ID。比如0表示背景,1表示类别A,2表示类别B。有个易错点是忘记检查标注图片的像素值范围,曾经遇到过标注工具生成的是0-255的灰度图,导致训练时类别识别错误。
医学影像数据少?试试这些增强组合:
python复制transform = A.Compose([
A.RandomRotate90(p=0.5),
A.HorizontalFlip(p=0.5),
A.VerticalFlip(p=0.5),
A.RandomBrightnessContrast(p=0.2),
A.GaussNoise(var_limit=(10.0, 50.0), p=0.3),
A.ElasticTransform(alpha=1, sigma=50, alpha_affine=50, p=0.3)
])
特别注意:增强后的图像和标注mask必须同步变换!我写了个检查函数,训练前务必运行:
python复制def check_pair(img, mask):
plt.subplot(121); plt.imshow(img)
plt.subplot(122); plt.imshow(mask)
plt.show()
样本不平衡是语义分割的常见问题。在肺部CT分割项目中,病灶区域可能只占图像的5%。这时用普通的交叉熵损失会导致模型偏向背景预测。我的解决方案是:
python复制# Focal Loss实现
class FocalLoss(nn.Module):
def __init__(self, gamma=2, alpha=0.25):
super(FocalLoss, self).__init__()
self.gamma = gamma
self.alpha = alpha
def forward(self, inputs, targets):
BCE_loss = F.binary_cross_entropy_with_logits(inputs, targets, reduction='none')
pt = torch.exp(-BCE_loss)
loss = self.alpha * (1-pt)**self.gamma * BCE_loss
return loss.mean()
推荐使用分阶段训练策略:
监控mIoU指标比只看loss更有意义。我通常会在验证集mIoU连续3个epoch不提升时,提前终止训练。
训练好的模型需要经过后处理才能得到理想结果。我的标准流程是:
部署到生产环境时,建议将模型转为TorchScript格式:
python复制model = UNet(num_classes=2)
model.load_state_dict(torch.load('best_model.pth'))
script_model = torch.jit.script(model)
script_model.save('unet_script.pt')
对于实时性要求高的场景,可以尝试这些优化:
遇到模型不收敛时,按这个检查清单排查:
内存不足是另一个常见问题。我的解决方案是:
python复制scaler = torch.cuda.amp.GradScaler()
with torch.cuda.amp.autocast():
outputs = model(inputs)
loss = criterion(outputs, labels)
scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()
掌握基础Unet后,可以尝试这些改进方向:
最近在kaggle比赛看到一个有趣的技巧:将Unet的最后一层卷积替换为空间金字塔池化(ASPP),在Cityscapes数据集上提升了2% mIoU。代码实现大致如下:
python复制class ASPP(nn.Module):
def __init__(self, in_channels, out_channels):
super(ASPP, self).__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels, 1)
self.conv2 = nn.Conv2d(in_channels, out_channels, 3,
padding=6, dilation=6)
# 其他分支省略...
def forward(self, x):
return torch.cat([self.conv1(x), self.conv2(x)], dim=1)
语义分割领域发展迅速,但Unet依然是最好