AI-深度学习

从零搭建视觉模型 20:图像分割与 U-Net:给每个像素一个判断

展开合集目录
  1. 从零搭建视觉模型 01:图片进入模型后发生了什么
  2. 从零搭建视觉模型 02:Python 起步,写给 C/C++ 开发者
  3. 从零搭建视觉模型 03:从像素到张量的基础概念
  4. 从零搭建视觉模型 04:图片如何变成张量
  5. 从零搭建视觉模型 05:MLP 入门,从手算到图片输入
  6. 从零搭建视觉模型 06:亲手搭建一个小型 CNN
  7. 从零搭建视觉模型 07:模型怎样预测类别:分数、概率与决策
  8. 从零搭建视觉模型 08:让模型开始学习,损失、梯度与训练循环
  9. 从零搭建视觉模型 09:数据集、标签与数据划分
  10. 从零搭建视觉模型 10:损失函数与优化器:怎样把错误变成更新
  11. 从零搭建视觉模型 11:批次、训练轮次与学习率
  12. 从零搭建视觉模型 12:训练第一个真实图片分类器
  13. 从零搭建视觉模型 13:过拟合、数据增强与正则化
  14. 从零搭建视觉模型 14:验证、测试与错误分析
  15. 从零搭建视觉模型 15:保存模型、恢复训练与独立推理
  16. 从零搭建视觉模型 · 综合实践:实现 MLP,从损失计算、训练到 ONNX 推理
  17. 从零搭建视觉模型 16:ResNet 与残差连接
  18. 从零搭建视觉模型 17:预训练、迁移学习与微调
  19. 从零搭建视觉模型 18:轻量模型与计算成本
  20. 从零搭建视觉模型 19:目标检测与 YOLO:从类别到目标框
  21. 从零搭建视觉模型 20:图像分割与 U-Net:给每个像素一个判断
  22. 从零搭建视觉模型 21:注意力与视觉 Transformer
  23. 从零搭建视觉模型 22:从训练模型到 ONNX
  24. 从零搭建视觉模型 23:量化与精度对比
  25. 从零搭建视觉模型 24:从摄像头输入到部署验收
  26. 从零搭建视觉模型 25:发展历史、主流分类与当前格局
从零搭建视觉模型 20:图像分割与 U-Net:给每个像素一个判断的封面

学习初稿 · 第 20 课。 本篇已开放网页阅读,用于先建立完整认识。文中手算值和示意记录不代表真实训练成绩;真实数据训练、预训练权重和板端测试以本篇具体说明为准,本篇已附代码练习,实际验证范围见文末与包内记录。

当矩形框不能满足需求

如果要测量区域面积、提取目标轮廓或区分道路边界,仅有框不够。语义分割为每个像素分配类别;实例分割还区分同类别的不同对象。本课只做前景与背景的二分类语义分割。

一张 RGB 图输入 [N,3,H,W],本课的单通道二分类输出是 [N,1,H,W] logits。标签同形状,前景为 1、背景为 0。多类互斥分割则常输出 [N,C,H,W] 并用整数掩码 [N,H,W],损失约定不同。

为什么需要编码与解码

编码路径逐步整合空间上下文并减小分辨率;解码路径恢复空间尺寸。下采样有助于理解较大范围的信息,却可能损失边界细节,所以 U-Net 将较早的高分辨率特征与后面的解码特征结合。U-Net 原论文

这里的跳跃连接常通过通道拼接实现,与 ResNet 常见的逐元素相加不同。拼接要求空间大小匹配,并会增加通道数;后续卷积的输入通道必须据此设置。

一个独立的教学网络

下面只依赖 PyTorch,生成一块矩形作为标签。网络仅有一次下采样,属于 U-Net 思路的缩小教学实现,不是完整原论文结构。它执行一次训练步,验证形状与梯度链路,不应期待一次更新就学会分割。

import torch
from torch import nn
import torch.nn.functional as F

torch.manual_seed(7)
torch.set_num_threads(2)

class TinyUNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.enc = nn.Sequential(nn.Conv2d(3, 8, 3, padding=1), nn.ReLU())
        self.low = nn.Sequential(nn.Conv2d(8, 16, 3, padding=1), nn.ReLU())
        self.dec = nn.Sequential(nn.Conv2d(24, 8, 3, padding=1), nn.ReLU(),
                                 nn.Conv2d(8, 1, 1))

    def forward(self, x):
        skip = self.enc(x)
        low = self.low(F.max_pool2d(skip, 2))
        up = F.interpolate(low, size=skip.shape[-2:], mode='bilinear',
                           align_corners=False)
        return self.dec(torch.cat([skip, up], dim=1))

mask = torch.zeros(2, 1, 32, 32)
mask[:, :, 8:24, 10:22] = 1
images = mask.repeat(1, 3, 1, 1) * 0.7 + torch.rand(2, 3, 32, 32) * 0.2
model = TinyUNet()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
optimizer.zero_grad()
logits = model(images)
assert logits.shape == mask.shape
loss = F.binary_cross_entropy_with_logits(logits, mask)
loss.backward()
optimizer.step()
print(tuple(logits.shape), bool(torch.isfinite(loss)))

本例把前景亮度直接编码进输入,任务刻意简单。它只帮助理解数据与输出关系。若训练多个位置、尺寸和噪声的矩形,也仍然是合成任务,不能把高 IoU 解释成真实物体分割能力。

掩码处理和图片处理不能完全一样

几何增强需要同步作用于图片与掩码。类别索引掩码缩放通常使用最近邻,避免插值得到 0.3、1.7 这样没有类别意义的编号。图片则可使用适合的连续插值方式。

本例的二分类损失接原始 logits,不应先 Sigmoid 再传给带 logits 的接口。显示结果时才用 Sigmoid 转换,并按验证集确定的阈值生成前景布尔掩码。

标签若以 0 和 255 存储,读取后需要明确转换成 0 和 1,不能直接把 255 当作二元概率目标。也要检查掩码是否和原图尺寸、方向一致。

像素准确率可能被背景掩盖

若前景只占 5%,全预测背景也有 95% 像素准确率。可以查看前景 IoU:预测前景与真实前景交集除以并集。Dice 则是两倍交集除以两者面积之和,它与 IoU 相关但数值不同。

真实前景与预测都为空时,并集为零,需要提前定义统计策略,例如单独报告空图并从前景平均中排除。不同策略不能不加说明地混合比较。

检查边界时还应查看可视化叠图,确认模型是否只是学了背景或面积。损失很低不代表每个小目标边缘都正确。

练习

  1. 拼接 [2,8,32,32] 与 [2,16,32,32] 后形状是什么?
  2. 交集 30、预测面积 50、真实面积 40,IoU 是多少?
  3. 为什么本课矩形实验成功不能证明能分割汽车?
参考答案

沿通道拼接后为 [2,24,32,32]。并集为 60,所以 IoU 为 0.5。合成矩形的颜色、背景和形状分布远比真实图像简单,标签还直接对应亮度,缺少真实场景的独立验证。

下一课介绍另一种组织视觉信息的方法:把图片划成块,再让不同块通过注意力交换信息。

配套代码练习

实验:小型 U-Net 的一次训练步

查看完整代码:main.py
"""小型 U-Net 的一次训练步。运行方式与练习见 README.md。"""
import torch
from torch import nn
import torch.nn.functional as F

torch.manual_seed(7)
torch.set_num_threads(2)

class TinyUNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.enc = nn.Sequential(nn.Conv2d(3, 8, 3, padding=1), nn.ReLU())
        self.low = nn.Sequential(nn.Conv2d(8, 16, 3, padding=1), nn.ReLU())
        self.dec = nn.Sequential(nn.Conv2d(24, 8, 3, padding=1), nn.ReLU(),
                                 nn.Conv2d(8, 1, 1))

    def forward(self, x):
        skip = self.enc(x)
        low = self.low(F.max_pool2d(skip, 2))
        up = F.interpolate(low, size=skip.shape[-2:], mode='bilinear',
                           align_corners=False)
        return self.dec(torch.cat([skip, up], dim=1))

mask = torch.zeros(2, 1, 32, 32)
mask[:, :, 8:24, 10:22] = 1
images = mask.repeat(1, 3, 1, 1) * 0.7 + torch.rand(2, 3, 32, 32) * 0.2
model = TinyUNet()
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
optimizer.zero_grad()
logits = model(images)
assert logits.shape == mask.shape
loss = F.binary_cross_entropy_with_logits(logits, mask)
loss.backward()
optimizer.step()
print(tuple(logits.shape), bool(torch.isfinite(loss)))

with torch.no_grad():
    prediction = model(images).sigmoid() >= 0.5
    truth = mask.bool()
    intersection = (prediction & truth).sum().item()
    union = (prediction | truth).sum().item()
    print('一步训练后合成掩码 IoU:', intersection/union if union else 1.0)
下载代码

在项目根目录执行;下载包解压后,在解压目录执行 python3 main.py。

cd 'content/posts/AI-深度学习/从零搭建视觉模型/20-图像分割与UNet/code' && python3 main.py

本篇已附独立实验包,在代码卡片中查看源码、下载或复制运行命令。解压后执行 python3 main.py;依赖安装见包内 README.md。

观察目标: 输出 [2,1,32,32],损失有限且完成反向更新;合成矩形不代表真实分割精度。

动手修改: 把输入改为奇数尺寸 31×29,保持掩码与输入对应,验证解码器仍能恢复原尺寸。

所有输入均在本篇代码中构造;运行范围和验证记录见包内 verification.md。


完整阅读目录 · 上一篇 · 下一篇