本文将介绍如何使用tf2.0的keras框架实现典型的U-net网络,并以CT影像的肿瘤分割为案例进行讲解。本文将详细地介绍UNet肿瘤分割的实现过程,这将有助于读者快速掌握UNet这一网络,并熟悉深度学习的基本使用方法。
预备知识点:
- 掌握Python中函数、模块和类的使用方法;
- 掌握基本的图像处理方法(数字图像处理);
- 掌握基本的深度学习知识(深度学习的基本概念);
- 能配置tf2.0的运行环境;
数据集和源代码可以从GitHub中下载:https://github.com/LittleMount/UNet-for-tumor-segmentation/tree/master
或者从百度网盘分享链接中下载:https://pan.baidu.com/s/1bSxMIeepQhyCxnYhdwds7g 提取码:66gq
目录
- 一、问题的描述
- 二、基本流程
- 三、数据预处理
- 四、构建网络
- 五、编译网络
- 六、训练网络
- 七、测试评价
一、问题的描述
在医学诊断中,通过CT影像来确定肿瘤的位置、大小和判断是否发生转移具有重要的意义。本案例的目标就是从已有的CT影像中分割出肿瘤的区域,肿瘤的区域是由专业医生描绘出来的,可以当做label(PS: 数据集来源于第七届“泰迪杯”数据挖掘挑战赛[1])。数据情况如下图所示,
如图,左图是CT图像的原始图像,右图是医生所绘制的肿瘤区域。
分割要求:
- 尽可能准确地分割出肿瘤区域;
- 无肿瘤的CT影像,网络分割结果应为全黑;
- 评价指标为准确率、Dice系数;
源文件及文件夹说明:
train: 放置了108位患者的CT影像文件夹;
label: 放置了与train对应的肿瘤掩膜;
evaluation: 放置了网络测试的结果;
weights: 保存网络权重;
u_net.py: 本文的执行文件;
本文达到的效果:
二、基本流程
利用深度学习解决问题具有较为固定的流程,具体可以概括为:
——1.数据预处理 -> 2.构建网络 -> 3.编译网络 -> 4.训练网络 -> 5.测试和评价。
- 数据预处理,包括数据集准备、数据归一化、数据裁剪等等操作;
- 构建网络,根据具体问题的需要构建合适的网络,本文构建的网络是UNet;
- 编译网络,选择训练网络的优化器、学习率、损失函数和评价函数等等;
- 训练网络,设置batch_size,epoch,是否乱序训练,验证集分割比例等等;
- 测试网络,编写测试函数,查看网络分割效果并计算相关评价指标等等。
下面将具体介绍每一步的实现方法。深度学习是一门工具,只有亲自实践并且多加练习才能熟练掌握它,所以建议读者(入门级)能自己复现出每一行命令。
三、数据预处理
UNet肿瘤分割问题可以拆解为多个模块,同样地,在每个模块下面也可以再细分具体的工作。数据预处理模块的工作包括:图片读取 -> 图片有效区域裁剪 -> 图像归一化 -> 分离训练集和测试集 -> 保存数据。
监督学习的深度学习需要大量数据,所以数据预处理的工作非常重要,后期熟练使用深度学习后,数据集的制备也会成为主要工作。
1. 数据集介绍
train文件夹中包含108位患者的CT影像文件夹,每位患者大概有二三十张CT影像,原本影像是DCM格式的,为了读者操作方便,笔者已经将它读取后保存为".png"格式;label文件夹保存了相对应mask图片。
2. 图片读取
由于train中每个文件夹的CT影像数量不一样,所以需要使用Python中glob函数自动读取文件夹和文件名;之后再使用Image.open读取图片;
for file in glob('./train/*'): # 获取文件夹名称
for filename in glob(file + '/*'): # 获取文件夹中的文件
img = np.array(Image.open(filename), dtype='float32') / 255
PS:注意glob函数在使用之前要进行导入(可参考程序开头的模块导入),这部分代码以及后面的代码都只是函数中的片段,作解释用,不能直接运行。
3. 图片裁剪
直肠肿瘤只发生在直肠,所以实际需要处理的区域并不需要整张图片,适当的裁剪可以提高网络训练速度;
实现方法:
x_train.append(img[256:, 128:384])
每读取一张图片,就将其扩展到x_train列表之后,当遍历完所有图片,列表x_train就保存了所有图片数据;
4. 分离训练集和测试
因为肿瘤分布是连续的,所以为了避免局部特征过于集中,这里对数据集进行乱序,之后再按9:1分配训练集和测试集。
np.random.seed(116) # 设置相同的随机种子,确保数据匹配
np.random.shuffle(x_train) # 对第一维度进行乱序
np.random.seed(116)
np.random.shuffle(x_label)
# 图片有三千张左右,按9:1进行分配
return x_train[:2700, :, :], x_label[:2700, :, :], x_train[2700:, :, :], x_label[2700:, :, :]
PS:数据预处理完之后可以专门保存到一个.npy数据中,这样不用每次都执行一遍预处理的操作。
四、构建网络
1. UNet网络介绍
UNet网络是一种可以实现端到端映射的网络,非常适用于实现图像分割、恢复、增强和超分辨等目标。其具体网络结构如下图所示,
UNet网络有几个主要的特点:
① 网络可以从中间分为左右两部分,左边是收缩路径,利用降采样和卷积模块提取不同尺度的特征;右边是扩展路径,利用上采样和卷积模块恢复尺度并融合先前特征,逐渐恢复图像;
② 卷积模块由两层连续的卷积层组成,它可以实现更大尺寸和更高深度的特征提取;降采样实现图像尺度的缩小;上采样(或反卷积层)实现图像尺度的变大;
③ 图中灰色的箭头线表示跳连,作用是在同一尺度下,将收缩路径上的特征合并到扩展路径上的特征,这样可以在扩展路径上提供更好的约束,网络也更容易输出我们期望的结果。
2. tf2.0的keras框架编写UNet网络
为了创建UNet网络,笔者专门编写了一个函数,下面的代码将按照顺序拆分讲解。根据创建UNet网络的需要,定义函数:
def build_unet(self, n_filters=16, dropout=0, batchnorm=True, padding='same'):
函数的输入中有self,是因为笔者将函数放在了class(类)中,可以实现函数的灵活调用。
n_filters是卷积核数量,dropout是Dropout层的参数,batchnorm控制是否进行标准化,padding控制卷积前后图片尺寸是否变化。这些参数在后面的程序中会使用到。
(1) 首先定义重复使用的卷积模块(函数)
这个函数放在了build_unet函数内部,是一个需要重复使用的模块(函数)。卷积模块的组成:
——卷积层 -> 标准化层 -> 激活函数 -> 卷积层 -> 标准化层 -> 激活函数 -> 返回结果。
定义的函数输入参数说明:
- input_tensor: 输入的图片或者上一层输出的结果;
- n_filters: 设置卷积核的数量;
- kernel_size:设置卷积核的大小;
- batchnorm:如果是True,表示使用标准化层(类似高斯标准化,不过有尺度和位移两个可训练变量),如果是False,则不使用标准化;
- padding:如果是’same’,表示卷积前后图片尺度相同,如果是’valid’,卷积后图片会变小一圈。
具体实现:
# 定义一个多次使用的卷积块
def conv2d_block(input_tensor, n_filters=16, kernel_size=3, batchnorm=True, padding='same'):
# the first layer
x = Conv2D(n_filters, kernel_size, padding=padding)(
input_tensor)
if batchnorm:
x = BatchNormalization()(x)
x = Activation('relu')(x)
# the second layer
x = Conv2D(n_filters, kernel_size, padding=padding)(x)
if batchnorm:
x = BatchNormalization()(x)
X = Activation('relu')(x)
return X
其中Conv2D是TensorFlow的keras.layers中的函数,程序开头已经导入,所以这里不需要前缀即可调用。这个函数就构建了一个卷积层,input_tensor是输入,x是卷积层的输出。
同样地,BatchNormalization可以对输入进行标准化;Activation(‘relu’)(x),表示对输入执行relu激活函数。
重复两次“卷积层 -> 标准化层 -> 激活函数”,即创建了两个串联的卷积层,这是一种常用的特征提取器,最后输出X。
(2) 收敛路径
从UNet结构图可以知道,收敛路径主要的过程为
输入图片 -> ①(卷积模块 -> 下采样 -> dropout层) -> ②(卷积模块 ->下采样 -> dropout层) -> ③(卷积模块 ->下采样 -> dropout层) -> ④(卷积模块 ->下采样 -> dropout层)
所以根据逻辑顺序可编写程序:
# 构建一个输入
img = Input(shape=self.shape) # self.shape是图片维度大小
# contracting path
c1 = conv2d_block(img, n_filters=n_filters * 1, kernel_size=3, batchnorm=batchnorm, padding=padding)
p1 = MaxPooling2D((2, 2))(c1)
p1 = Dropout(dropout * 0.5)(p1)
c2 = conv2d_block(p1, n_filters=n_filters * 2, kernel_size=3, batchnorm=batchnorm, padding=padding)
p2 = MaxPooling2D((2, 2))(c2)
p2 = Dropout(dropout)(p2)
c3 = conv2d_block(p2, n_filters=n_filters * 4, kernel_size=3, batchnorm=batchnorm, padding=padding)
p3 = MaxPooling2D((2, 2))(c3)
p3 = Dropout(dropout)(p3)
c4 = conv2d_block(p3, n_filters=n_filters * 8, kernel_size=3, batchnorm=batchnorm, padding=padding)
p4 = MaxPooling2D((2, 2))(c4)
p4 = Dropout(dropout)(p4)
这样就完成了收缩路径程序的编写。继续往下,到了中间一个特征层:
c5 = conv2d_block(p4, n_filters=n_filters * 16, kernel_size=3, batchnorm=batchnorm, padding=padding)
(3) 扩展路径
从UNet结构图可以知道,扩展路径主要的过程为
输入上一层的输出 -> ⑥(上采样 -> 特征合并 -> dropout层 -> 卷积模块) -> ⑦(上采样 -> 特征合并 -> dropout层 -> 卷积模块) -> ⑧(上采样 -> 特征合并 -> dropout层 -> 卷积模块) -> ⑨(上采样 -> 特征合并 -> dropout层 -> 卷积模块)。
具体实现:
# extending path
u6 = Conv2DTranspose(n_filters * 8, (3, 3), strides=(2, 2), padding='same')(c5)
u6 = concatenate([u6, c4])
u6 = Dropout(dropout)(u6)
c6 = conv2d_block(u6, n_filters=n_filters * 8, kernel_size=3, batchnorm=batchnorm, padding=padding)
u7 = Conv2DTranspose(n_filters * 4, (3, 3), strides=(2, 2), padding='same')(c6)
u7 = concatenate([u7, c3])
u7 = Dropout(dropout)(u7)
c7 = conv2d_block(u7, n_filters=n_filters * 4, kernel_size=3, batchnorm=batchnorm, padding=padding)
u8 = Conv2DTranspose(n_filters * 2, (3, 3), strides=(2, 2), padding='same')(c7)
u8 = concatenate([u8, c2])
u8 = Dropout(dropout)(u8)
c8 = conv2d_block(u8, n_filters=n_filters * 2, kernel_size=3, batchnorm=batchnorm, padding=padding)
u9 = Conv2DTranspose(n_filters * 1, (3, 3), strides=(2, 2), padding='same')(c8)
u9 = concatenate([u9, c1])
u9 = Dropout(dropout)(u9)
c9 = conv2d_block(u9, n_filters=n_filters * 1, kernel_size=3, batchnorm=batchnorm, padding=padding)
(4) 输出层
根据输出图片的数据格式,可以定义我们的输出层:
output = Conv2D(1, (1, 1), activation='sigmoid')(c9)
return Model(img, output)
其中Conv2D中的第一个1表示卷积核数量,即输出图像的通道数,如果是彩色图像,把数据更改为3即可;(1,1)表示卷积核的大小,只有一个像素大小,表示对图像的每个像素进行判别;
激活函数是’sigmoid’,输出区间为[0,1]。最后返回创建的UNet网络:Model(img, output)。PS:以上程序串联起来即可得到完整的程序。
至此,我们就定义了创建UNet网络的函数,接下来只要调用函数即可创建网络。
self.unet = self.build_unet() # 创建网络变量
self.unet.summary()
执行self.unet.summary()后若打印出网络结构,则说明网络创建成功:
五、编译网络
编译网络主要设置网络基本配置,比如优化器、学习率、损失函数、评价函数等等;
1. 经常使用的优化器是Adam优化器:
# 优化器
optimizer = Adam(0.0002, 0.5)
2. 损失函数常使用’mse’,评价函数使用’accuracy’:
self.unet.compile(loss='mse',
optimizer=optimizer,
metrics=['accuracy'])
3. 本文使用自定义的Dice函数作为评价函数
根据Dice计算公式,定义如下评价函数,该评价函数将在网络训练过程中使用,所以需要使用tf的相关函数进行定义。
def metric_fun(self, y_true, y_pred):
fz = tf.reduce_sum(2 * y_true * tf.cast(tf.greater(y_pred, 0.1), tf.float32)) + 1e-8
fm = tf.reduce_sum(y_true + tf.cast(tf.greater(y_pred, 0.1), tf.float32)) + 1e-8
return fz / fm
如此一来就可以对网络进行如下编译为:
self.unet.compile(loss='mse',
optimizer=optimizer,
metrics=[self.metric_fun])
六、训练网络
以上工作准备完成之后就到了训练网络的环节,训练网络有多种实现方法——fit方法、train_on_batch方法以及自定义训练网络的方法等,本文将介绍最简单的fit方法。
1. 导入数据集
这个函数就第三节介绍的数据预处理函数,
# 获得数据
x_train, x_label, y_train, y_label = self.load_data()
x_train, x_label是训练集;y_train, y_label是测试集;
2. 设置训练模式
# 设置训练的checkpoint
callbacks = [EarlyStopping(patience=100, verbose=2),
ReduceLROnPlateau(factor=0.1, patience=20, min_lr=0.0001, verbose=2),
ModelCheckpoint('./weights/best_model.h5', verbose=2, save_best_only=True)
其中EarlyStopping表示提前结束,如果验证集loss连续100次未下降,训练终止;verbose表示显示方法;
ReduceLROnPlateau表示降低学习率,如果验证集loss连续20次未下降,学习率变为原来的1/10;
ModelCheckpoint表示检查点(可实现断点续训),只保存最优的模型。
3. 开始训练
# 进行训练
results = self.unet.fit(x_train, x_label, batch_size=32, epochs=200, verbose=2,
callbacks=callbacks, validation_split=0.1, shuffle=True)
- validation_split=0.1,表示将训练集的数据分割0.1出来作为验证集(注意这不是测试集);
- batch_size=32,表示每一批训练的数据大小为32张图片;
- epochs=200,表示总共训练200次;
- verbose=2,表示只在每个epoch训练完之后打印训练信息,也可以取0和1,表示不同的信息打印方式;
- shuffle=True,表示每次训练将训练集的训练部分顺序打乱,验证集的不受影响;
- callbacks=callbacks,表示按照先前定义的模型进行训练;
训练过程:
笔者是在RTX2060S显卡上训练网络的,训练一个epoch大概耗时18s,200个epoch大概耗时一个小时多一点,不同显卡运算速度有所区别,读者注意一下即可。
4. 训练结果
第三步的results保存了每个epoch的训练集loss、metric和验证集val_loss、val_metric,将它们绘制出来得到以下曲线:
从图中结果可以看出,验证集(不参与训练)的loss总是比训练集的要大,即肿瘤分割效果总比训练集的差,这说明了我们评价网络要用未训练的数据进行测试,网络本身是通过不断地拟合训练集来达到训练效果的,所以用训练集的数据测试意义不大。验证集的Dice系数达到了0.8,这是一个比较高的值,说明网络训练效果不错。
虽然我们设置的训练步数是200步,但中间val_loss连续100次未下降,所以提前终止训练。
七、测试评价
对于不同的问题,使用的评价指标会不一样,读者可以仿照本文自定义评价指标的方法构建自己的评价函数。针对本文的肿瘤分割问题,评价指标主要有两个,一是分割区域精确度,也就是Dice系数;二是肿瘤分割的准确率,即accuracy。
1. Dice系数
Dice系数的计算已经在前面自定义评价函数时介绍过,所以这里直接给出测试集的Dice系数。
在300多张测试集中,分割的肿瘤平均Dice系数达到了0.832;这个结果表示网络分割的肿瘤区域与医生的掩膜极为相似。
2. 准确率
如果网络分割结果存在等于1的像素,则认为网络输出了一张有肿瘤的图片,如果网络分割结果全为0,则认为网络输出了一张没有肿瘤的图片。统计网络判断正确的图片个数,再除以总的图片数得到准确率。
实现方法:
mask = self.unet.predict(x_train[index:index + batch_size]) > 0.1
mask_true = x_label[index, :, :, 0]
if (np.sum(mask) > 0) == (np.sum(mask_true) > 0):
n += 1
如果网络输出结果mask和真实值mask_true情况相同,即都有肿瘤或都无肿瘤,则计数+1;最终可求得测试集准确率为:
计算得测试集的准确率到达了99.72%,说明网络非常准确地区分出了有肿瘤图片和无肿瘤图片。
3. 网络分割效果展示
为了查看肿瘤的分割效果,笔者编写了test1函数用于随机显示肿瘤分割图片,具体效果如下:
第一列是网络的输入,第二列是网络的输出,第三列是医生提供的掩膜。这一节的测试评价都是对为训练的测试集进行的,也再次说明了网络训练得非常成功。
完整的UNet肿瘤分割讲解到这里就结束了,感谢读者们能够耐心的看到此处,也祝愿读者们能有所收获,祝好!
参考资料:
[1] http://www.tipdm.org/bdrace/tzjingsai/20181226/1544.html
[2] O. Ronneberger, P. Fischer and T. Brox, “U-net: Convolutional networks for biomedical image segmentation”, Proc. Med. Image Comput. Comput.-Assisted Intervention, pp. 234-241, 2015.