
文章目录
-
- [1. 课前导读](#1. 课前导读)
-
- [1.1 本节课学习目标](#1.1 本节课学习目标)
- [1.2 知识重难点](#1.2 知识重难点)
- [1.3 学习前置条件](#1.3 学习前置条件)
- [1.4 学完可掌握能力](#1.4 学完可掌握能力)
- [1.5 行业应用场景](#1.5 行业应用场景)
- [2. 核心理论精讲](#2. 核心理论精讲)
-
- [2.1 图像分割任务类型](#2.1 图像分割任务类型)
- [2.2 常用数据集与标注格式](#2.2 常用数据集与标注格式)
- [2.3 标注规范与数据预处理](#2.3 标注规范与数据预处理)
- [2.4 U-Net架构](#2.4 U-Net架构)
- [2.5 分割评估指标](#2.5 分割评估指标)
- [2.6 损失函数](#2.6 损失函数)
- [3. 环境搭建与工具配置](#3. 环境搭建与工具配置)
- [4. 代码实战教学](#4. 代码实战教学)
-
- [4.1 使用LabelMe标注并转换掩码](#4.1 使用LabelMe标注并转换掩码)
- [4.2 加载自定义分割数据集](#4.2 加载自定义分割数据集)
- [4.3 搭建U-Net模型](#4.3 搭建U-Net模型)
- [4.4 损失函数与训练](#4.4 损失函数与训练)
- [4.5 评估mIoU](#4.5 评估mIoU)
- [4.6 推理与可视化](#4.6 推理与可视化)
- [5. 案例实操演练](#5. 案例实操演练)
-
- [5.1 数据准备(简化版)](#5.1 数据准备(简化版))
- [5.2 训练U-Net](#5.2 训练U-Net)
- [5.3 后处理与可视化](#5.3 后处理与可视化)
- [6. 常见坑点与排错总结](#6. 常见坑点与排错总结)
-
- [6.1 标注与数据预处理坑点](#6.1 标注与数据预处理坑点)
- [6.2 U-Net训练坑点](#6.2 U-Net训练坑点)
- [6.3 评估坑点](#6.3 评估坑点)
- [7. 知识点总结 + 课后作业](#7. 知识点总结 + 课后作业)
-
- [7.1 核心知识点梳理](#7.1 核心知识点梳理)
- [7.2 基础作业](#7.2 基础作业)
- [7.3 进阶实操作业](#7.3 进阶实操作业)
- [7.4 思考拓展题](#7.4 思考拓展题)
- [🔗《TensorFlow2.x: 深度学习入门到高阶实战教程》系列课程导航](#🔗《TensorFlow2.x: 深度学习入门到高阶实战教程》系列课程导航)
1. 课前导读
1.1 本节课学习目标
- 理解图像分割的定义和分类(语义分割 vs 实例分割 vs 全景分割)。
- 掌握图像分割的常用数据集格式(COCO JSON、VOC、Cityscapes)和标注规范。
- 学会使用LabelMe或COCO Annotator工具进行图像标注,并导出分割掩码。
- 掌握分割模型的评估指标:像素准确率(PA)、平均交并比(mIoU)、Dice系数。
- 理解U-Net架构(编码器、解码器、跳跃连接)的原理。
- 能够使用TensorFlow搭建U-Net模型,并在自定义数据集上进行训练和推理。
1.2 知识重难点
| 类别 | 内容 |
|---|---|
| 重点 | 语义分割与实例分割的区别;U-Net的对称结构与跳跃连接;mIoU的计算方法;标注数据到掩码的转换 |
| 难点 | 实例分割的掩码处理(每个实例独立);数据不平衡(小物体、背景主导)的处理;U-Net中不同层特征的融合 |
| 易混淆点 | 语义分割的类别索引与颜色映射;标注多边形转换为二值掩码时的坐标缩放;训练时loss函数的选择(交叉熵 vs Dice loss) |
1.3 学习前置条件
- 已掌握CNN基本知识和分类模型训练(第21-25课)。
- 了解目标检测的锚框概念(第41课)。
- 能够使用TensorFlow加载图像和自定义数据集。
1.4 学完可掌握能力
- 独立完成图像分割数据的标注、格式转换和预处理。
- 构建并训练U-Net模型实现语义分割。
- 评估分割模型性能,使用mIoU和Dice指标。
- 为医学图像、遥感图像等像素级任务提供解决方案。
1.5 行业应用场景
- 医学影像:肿瘤分割、器官勾画。
- 自动驾驶:道路、车辆、行人分割。
- 遥感图像:土地利用分类、建筑物提取。
- 工业质检:缺陷区域分割。
2. 核心理论精讲
2.1 图像分割任务类型
语义分割(Semantic Segmentation):为图像中的每个像素分配一个类别标签(如道路、行人、背景),不同实例不区分。输出是单通道的类别图。
实例分割(Instance Segmentation):在语义分割的基础上,区分同一类别的不同个体(如行人1、行人2)。输出通常是一组掩码和对应的类别。
全景分割(Panoptic Segmentation):结合语义分割(背景区域)和实例分割(可数物体)。
本课主要聚焦语义分割,因为它是基础且应用广泛;实例分割的标注更复杂,但理解语义分割后可拓展。
2.2 常用数据集与标注格式
- PASCAL VOC:20类+背景,标注为PNG索引图(每个像素值为类别索引)。
- COCO:80类,实例分割标注为多边形(polygon)格式,保存在JSON文件中。
- Cityscapes:30类,标注为精细的像素级掩码,用于自动驾驶场景。
标注工具:
- LabelMe:开源工具,标注多边形,输出JSON文件,可转换为掩码。
- COCO Annotator:Web工具,支持多边形、矩形。
- VGG Image Annotator (VIA):简单易用。
2.3 标注规范与数据预处理
多边形标注:每个物体由一系列点围成,需转换为二值掩码(与图像同尺寸的0/1矩阵)。转换时需考虑坐标缩放(如果原始图像被resize)。
类别不平衡:背景像素通常远多于前景,需使用加权损失或采样。
数据增强 :对于分割任务,图像和掩码必须进行相同的几何变换(旋转、翻转、缩放)。可使用tf.image或albumentations库实现。
2.4 U-Net架构
U-Net是医学图像分割中广泛使用的经典模型,特点:
- 编码器(下采样路径):卷积层+池化,提取高层次特征,逐步降低空间分辨率。
- 解码器(上采样路径):转置卷积或上采样层,恢复空间分辨率。
- 跳跃连接:将编码器对应层的特征与解码器上采样后的特征拼接,保留细节信息,利于精确分割。
网络结构(输入HxW):
- 编码器:每个阶段两个3x3卷积(ReLU)+ 2x2最大池化,通道数倍增。
- 解码器:每个阶段上采样(2x2转置卷积),与编码器对应层裁剪后拼接,再两个3x3卷积。
- 最后一层1x1卷积,输出类别数(使用softmax或sigmoid)。
参数数量适中,训练稳定,是小数据集上的首选。
2.5 分割评估指标
像素准确率(Pixel Accuracy, PA):正确分类的像素数/总像素数。易受类别不平衡影响。
平均交并比(Mean Intersection over Union, mIoU):对每个类别计算IoU(预测区域与真实区域的交集/并集),然后取平均。mIoU对不平衡更鲁棒。
\\text{IoU}*c = \\frac{TP_c}{TP_c + FP_c + FN_c}, \\quad \\text{mIoU} = \\frac{1}{C}\\sum* {c=1}\^{C} \\text{IoU}_c
Dice系数(F1 score for segmentation) :
\\text{Dice} = \\frac{2\|A \\cap B\|}{\|A\| + \|B\|}
常用于医学图像分割,与IoU近似。
2.6 损失函数
- 交叉熵损失:逐像素分类损失,适合类别平衡的数据。
- Dice损失 :
1 - Dice,对前景-背景不平衡问题更鲁棒。 - 组合损失:交叉熵 + Dice损失,综合二者优点。
3. 环境搭建与工具配置
沿用第41课环境,安装额外工具:
bash
conda activate tf213
pip install opencv-python labelme pillow matplotlib
创建项目目录:
segmentation/
├── data/
│ ├── raw/ # 原始图像和JSON标注
│ ├── masks/ # 生成的掩码PNG
│ └── train.txt # 训练集列表
├── tools/
│ └── labelme2mask.py
├── train.py
└── predict.py
导入模块:
python
import tensorflow as tf
import numpy as np
import matplotlib.pyplot as plt
import os
import cv2
from tensorflow.keras import layers, models, callbacks
4. 代码实战教学
4.1 使用LabelMe标注并转换掩码
标注步骤:
- 安装LabelMe:
pip install labelme - 启动:
labelme,打开图像,用多边形标注物体,保存JSON文件。 - 转换脚本:
python
# labelme2mask.py
import json
import numpy as np
import cv2
import os
def json2mask(json_path, output_mask_path, class_map):
"""
class_map: dict {'class_name': class_id}
"""
with open(json_path, 'r') as f:
data = json.load(f)
img_h, img_w = data['imageHeight'], data['imageWidth']
mask = np.zeros((img_h, img_w), dtype=np.uint8)
for shape in data['shapes']:
label = shape['label']
if label not in class_map:
continue
class_id = class_map[label]
points = np.array(shape['points'], dtype=np.int32)
cv2.fillPoly(mask, [points], class_id)
cv2.imwrite(output_mask_path, mask)
return mask
# 示例
class_map = {'cat': 1, 'dog': 2} # 背景为0
json_path = 'data/raw/001.json'
output_path = 'data/masks/001.png'
json2mask(json_path, output_path, class_map)
4.2 加载自定义分割数据集
python
def load_data(image_dir, mask_dir, image_size=(256,256)):
image_paths = sorted([os.path.join(image_dir, f) for f in os.listdir(image_dir) if f.endswith('.jpg')])
mask_paths = sorted([os.path.join(mask_dir, f) for f in os.listdir(mask_dir) if f.endswith('.png')])
images, masks = [], []
for img_path, msk_path in zip(image_paths, mask_paths):
img = cv2.imread(img_path)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img = cv2.resize(img, image_size)
mask = cv2.imread(msk_path, cv2.IMREAD_GRAYSCALE)
mask = cv2.resize(mask, image_size, interpolation=cv2.INTER_NEAREST) # 保持标签离散
images.append(img.astype(np.float32) / 255.0)
masks.append(mask)
return np.array(images), np.array(masks)
# 生成模拟数据集(如果不使用真实标注)
def generate_synthetic_dataset(num_samples=1000, size=128):
images = []
masks = []
for _ in range(num_samples):
img = np.ones((size, size, 3), dtype=np.float32)
mask = np.zeros((size, size), dtype=np.uint8)
# 随机绘制圆形、矩形、三角形
shape_type = np.random.choice([1,2,3]) # 1矩形,2圆形,3三角形
# 坐标和大小
cx = np.random.randint(32, size-32)
cy = np.random.randint(32, size-32)
r = np.random.randint(20, 50)
if shape_type == 1: # 矩形
x1 = cx - r
y1 = cy - r
x2 = cx + r
y2 = cy + r
cv2.rectangle(img, (x1,y1), (x2,y2), (0,0,0), -1)
cv2.rectangle(mask, (x1,y1), (x2,y2), shape_type, -1)
elif shape_type == 2: # 圆形
cv2.circle(img, (cx,cy), r, (0,0,0), -1)
cv2.circle(mask, (cx,cy), r, shape_type, -1)
else: # 三角形
pts = np.array([[cx, cy-r], [cx-r, cy+r], [cx+r, cy+r]], np.int32)
cv2.fillPoly(img, [pts], (0,0,0))
cv2.fillPoly(mask, [pts], shape_type)
images.append(img)
masks.append(mask)
return np.array(images), np.array(masks)
4.3 搭建U-Net模型
python
def unet(input_size=(256,256,3), num_classes=4): # 包括背景0, 1,2,3
inputs = layers.Input(input_size)
# 编码器
conv1 = layers.Conv2D(64, 3, activation='relu', padding='same')(inputs)
conv1 = layers.Conv2D(64, 3, activation='relu', padding='same')(conv1)
pool1 = layers.MaxPooling2D(pool_size=(2,2))(conv1)
conv2 = layers.Conv2D(128, 3, activation='relu', padding='same')(pool1)
conv2 = layers.Conv2D(128, 3, activation='relu', padding='same')(conv2)
pool2 = layers.MaxPooling2D((2,2))(conv2)
conv3 = layers.Conv2D(256, 3, activation='relu', padding='same')(pool2)
conv3 = layers.Conv2D(256, 3, activation='relu', padding='same')(conv3)
pool3 = layers.MaxPooling2D((2,2))(conv3)
conv4 = layers.Conv2D(512, 3, activation='relu', padding='same')(pool3)
conv4 = layers.Conv2D(512, 3, activation='relu', padding='same')(conv4)
drop4 = layers.Dropout(0.5)(conv4)
pool4 = layers.MaxPooling2D((2,2))(drop4)
# 底部
conv5 = layers.Conv2D(1024, 3, activation='relu', padding='same')(pool4)
conv5 = layers.Conv2D(1024, 3, activation='relu', padding='same')(conv5)
drop5 = layers.Dropout(0.5)(conv5)
# 解码器
up6 = layers.Conv2DTranspose(512, 2, strides=(2,2), padding='same')(drop5)
concat6 = layers.concatenate([up6, conv4], axis=-1)
conv6 = layers.Conv2D(512, 3, activation='relu', padding='same')(concat6)
conv6 = layers.Conv2D(512, 3, activation='relu', padding='same')(conv6)
up7 = layers.Conv2DTranspose(256, 2, strides=(2,2), padding='same')(conv6)
concat7 = layers.concatenate([up7, conv3], axis=-1)
conv7 = layers.Conv2D(256, 3, activation='relu', padding='same')(concat7)
conv7 = layers.Conv2D(256, 3, activation='relu', padding='same')(conv7)
up8 = layers.Conv2DTranspose(128, 2, strides=(2,2), padding='same')(conv7)
concat8 = layers.concatenate([up8, conv2], axis=-1)
conv8 = layers.Conv2D(128, 3, activation='relu', padding='same')(concat8)
conv8 = layers.Conv2D(128, 3, activation='relu', padding='same')(conv8)
up9 = layers.Conv2DTranspose(64, 2, strides=(2,2), padding='same')(conv8)
concat9 = layers.concatenate([up9, conv1], axis=-1)
conv9 = layers.Conv2D(64, 3, activation='relu', padding='same')(concat9)
conv9 = layers.Conv2D(64, 3, activation='relu', padding='same')(conv9)
outputs = layers.Conv2D(num_classes, 1, activation='softmax')(conv9)
model = tf.keras.Model(inputs=inputs, outputs=outputs)
return model
model = unet((128,128,3), num_classes=4)
model.summary()
4.4 损失函数与训练
python
def dice_coef(y_true, y_pred, smooth=1e-7):
y_true_f = tf.reshape(y_true, [-1, num_classes])
y_pred_f = tf.reshape(y_pred, [-1, num_classes])
intersection = tf.reduce_sum(y_true_f * y_pred_f, axis=0)
union = tf.reduce_sum(y_true_f, axis=0) + tf.reduce_sum(y_pred_f, axis=0)
dice = (2. * intersection + smooth) / (union + smooth)
return tf.reduce_mean(dice)
def dice_loss(y_true, y_pred):
return 1 - dice_coef(y_true, y_pred)
# 组合损失
def combined_loss(y_true, y_pred):
ce = tf.keras.losses.categorical_crossentropy(y_true, y_pred)
dice = dice_loss(y_true, y_pred)
return ce + dice
# 准备数据
X, y = generate_synthetic_dataset(2000, 128)
# 标签转为one-hot
y_onehot = tf.keras.utils.to_categorical(y, num_classes=4)
# 划分训练验证集
split = int(0.8 * len(X))
X_train, X_val = X[:split], X[split:]
y_train, y_val = y_onehot[:split], y_onehot[split:]
model.compile(optimizer='adam', loss=combined_loss, metrics=['accuracy', dice_coef])
model.fit(X_train, y_train, epochs=20, batch_size=16, validation_data=(X_val, y_val), callbacks=[callbacks.EarlyStopping(patience=5)])
4.5 评估mIoU
python
def compute_iou_per_class(y_true, y_pred, num_classes):
ious = []
y_pred_class = np.argmax(y_pred, axis=-1)
y_true_class = np.argmax(y_true, axis=-1)
for c in range(num_classes):
intersection = np.sum((y_pred_class == c) & (y_true_class == c))
union = np.sum((y_pred_class == c) | (y_true_class == c))
if union == 0:
iou = 1.0 if intersection==0 else 0.0
else:
iou = intersection / union
ious.append(iou)
return np.mean(ious)
# 预测验证集
y_pred = model.predict(X_val)
miou = compute_iou_per_class(y_val, y_pred, num_classes=4)
print(f"mIoU on validation: {miou:.4f}")
4.6 推理与可视化
python
def predict_and_show(model, image, num_classes=4):
# image: (H,W,3) normalized
input_tensor = np.expand_dims(image, axis=0)
pred = model.predict(input_tensor)[0]
pred_class = np.argmax(pred, axis=-1)
plt.figure(figsize=(12,4))
plt.subplot(1,3,1)
plt.imshow(image)
plt.title('Input')
plt.subplot(1,3,2)
plt.imshow(pred_class, cmap='jet')
plt.title('Prediction')
plt.subplot(1,3,3)
# 显示颜色映射(自定义)
plt.imshow(pred_class, cmap='viridis')
plt.title('Segmentation')
plt.show()
sample = X_val[0]
predict_and_show(model, sample)
5. 案例实操演练
案例:医学图像细胞分割(U-Net)
使用公开数据集(如ISBI细胞分割),下载数据,预处理,训练U-Net,评估Dice系数。
5.1 数据准备(简化版)
假设数据目录:images/ 和 masks/,图像和掩码名称对应。
python
# 加载真实数据
X_cell, y_cell = load_data('data/cell/images', 'data/cell/masks', (256,256))
y_cell_cat = tf.keras.utils.to_categorical(y_cell, num_classes=2) # 背景+细胞
# 标准化图像
X_cell = X_cell / 255.0
# 划分
split = 0.8
# ...
5.2 训练U-Net
python
model = unet((256,256,3), num_classes=2)
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=[dice_coef])
history = model.fit(X_cell_train, y_cell_train, epochs=30, validation_data=(X_cell_val, y_cell_val))
5.3 后处理与可视化
可对预测概率图阈值0.5生成二值掩码。
6. 常见坑点与排错总结
6.1 标注与数据预处理坑点
-
坑1:多边形转换掩码时,坐标超出图像边界或缩放后导致掩码偏移。
- 解决 :在转换前裁剪坐标到0, width-1范围,使用
cv2.fillPoly时确保坐标整数。
- 解决 :在转换前裁剪坐标到0, width-1范围,使用
-
坑2:图像与掩码大小不一致,训练时无法对齐。
- 解决:统一resize,掩码使用最近邻插值保持类别标签。
-
坑3:类别数量包含背景但标签从0开始,one-hot编码时需注意深度。
6.2 U-Net训练坑点
-
坑4:跳跃连接时编码器特征与解码器特征尺寸不完全匹配(由于padding)。
- 解决 :使用
crop_and_concat或设置padding='same'并确保尺寸一致。
- 解决 :使用
-
坑5:正负样本极度不平衡(如背景占99%),模型倾向于预测背景。
- 解决:使用Dice损失或加权交叉熵。
-
坑6:显存不足,U-Net全尺寸输入时消耗大。
- 解决:减小batch size,降低输入分辨率,或使用更小的编码器。
6.3 评估坑点
- 坑7 :mIoU计算时忽略无像素的类别(如某些类不出现在验证集中),会导致分母为零。
- 解决:对于没有GT的类别,IoU定义为1(如果预测也为0)或0。
7. 知识点总结 + 课后作业
7.1 核心知识点梳理
- 语义分割:像素级分类,输出类别图。
- 标注工具:LabelMe、COCO Annotator,多边形转掩码。
- U-Net:编码器-解码器+跳跃连接,适合医学分割。
- 评估指标:mIoU、Dice系数,对不平衡鲁棒。
- 损失函数:交叉熵、Dice损失、组合损失。
7.2 基础作业
- 使用LabelMe标注3张图像(例如猫狗),编写脚本将其转换为掩码,并可视化。
- 修改U-Net的深度(减少编码器层数),对比分割精度和参数量。
- 计算合成数据集的mIoU(每个类别的IoU单独打印)。
7.3 进阶实操作业
任务:使用U-Net进行道路分割
- 下载Cityscapes mini数据集或使用Carla模拟器生成道路场景。
- 实现数据加载(图像+掩码)。
- 训练U-Net(二分类:道路/非道路)。
- 测试并可视化预测结果,计算IoU。
7.4 思考拓展题
-
如果背景类别占据了绝大多数像素,使用交叉熵损失会导致模型偏向背景。请推导Dice损失的梯度为什么能够缓解这一问题。
-
U-Net中的跳跃连接与ResNet的残差连接有何异同?在分割任务中跳跃连接的作用是什么?
-
对于实例分割,通常需要先检测物体,再为每个检测框分割掩码(如Mask R-CNN)。请简述Mask R-CNN的基本思想。
下一课预告:NLP实战一:文本情感分类完整工业级项目------我们将使用LSTM、GRU和预训练词向量构建情感分类器,涵盖数据清洗、平衡采样、模型调优和部署。
🔗《TensorFlow2.x: 深度学习入门到高阶实战教程》系列课程导航
第一部分:基础入门(1-10 课)
第二部分:神经网络核心(11-25 课)
第三部分:进阶网络与框架高阶(26-40 课)
第四部分:企业实战与项目落地(41-50 课)
🌟 感谢您耐心阅读到这里!💡 如果本文对您有所启发欢迎:
👍 点赞📌 收藏 📤 分享给更多需要的伙伴。
🗣️ 期待在评论区看到您的想法, 共同进步。
🔔 关注我,持续获取更多干货内容~
🤗 我们下篇文章见~