本文运行系统为Windows,若使用Linux请注意相关指令及操作。

目录

一、环境配置

1、创建及激活虚拟环境。

2、安装相关库

二、Unet原理讲解

1、结构简析

2、结构细述

三、模型训练及测试

1、文件讲解。

2、模型训练

3、测试/使用模型

四、总结

一、环境配置

1、创建及激活虚拟环境。

首先,需要创建一个文件夹来作为本次项目的根目录,你应该也不想希望你的项目界面一团糟。

如图便是我的项目文件夹,请按照我的操作在你的文件管理器的路径显示窗口输入cmd。这步操作目的是打开终端:

 在终端输入:

python -m venv unetven

其中unetve为虚拟环境名称,可随意更改。

本文采用pycharm作为开发软件,这是本人习惯。这里附上安装包下载网址,请注意Professional是付费的。

打开pycharm-新建项目,项目地址选择前文说的项目文件夹。

解释器请选择现有解释器,因为我们在前面已经自己创建了虚拟环境,接着按如下操作:

 创建成功后便是如下界面,其中左侧文件理应只存在"unetven"文件夹,因为其它文件是我的代码及数据集:

 这是我的代码,水平不高,谨慎参考。这是我用的数据集,提取码是1234。

2、安装相关库

在如图位置:

 输入:

python -m pip install --upgrade pip

pip install numpy

pip install matplotlib

pip install scipy

pip install tensorflow-gpu #gpu版本,cpu版本:pip install tensorflow

pip install keras

pip install python-opencv

 等待安装完毕。

二、Unet原理讲解

1、结构简析

首先,我们先来看看Unet的模型结构:

在正式分析结构之前,你应该先注意右下角的五个注释,不然可能不便于你的理解。

Unet之所以是'U'net就是因为它的模型结构是U字形的,左边对应的是一系列卷积、池化/下采样,我们也称之为编码;右边则是一系列的卷积、上采样同时与左边编码的层进行拼接,我们称之为解码。

Unet最后得出的数据是一个对应着每个像素类别概率的矩阵,你需要对其进行处理才能得到mask图。

2、结构细述

(1)什么是卷积conv?卷积有什么作用?

这里阐述的是神经网络中图像处理的卷积层。卷积,抽象的讲就是将图像中的相对优势的特征提取出来,以便于模型对任务的’调参‘。

我们都知道,一张图片是由w*h个像素组成的,那么我们可以将图像理解成w*h个小方格组成的大矩形。假设现在有一个3*3大小的小印章,你需要用这个小印章在5*5的格子上尽可能的印下多的印记,如图:

那么,你会怎么印呢?显然,最佳答案是:

图1

 其中,1代表第一次印章盖到的位置,23456789同理。现在,假设3*3印章上每个位置都对应有一个系数,那么卷积得到的结果就是3*3印章上的系数乘上这个系数在图像中印下的像素的值。其中3*3我们称为kernel size即卷积窗口/卷积核的大小,印章中的系数就是模型训练过程中调整的参数,而我们重复上述操作(用印章给格子盖章)的次数我们可以认为是filters。每一次一系列印章给格子盖章后,我们都能得出由印章上的系数和格子上的值算出来的矩阵,每多一次这个矩阵的维度就加1(即多一个和这个矩阵大小一样的新矩阵,矩阵中的值取决于‘印章’中的系数)。

上述,就是(1, 1)步长strides卷积的过程,为保证结果准确,卷积往往会设置多层filters(其实就是增加参数量以确保结果能更好的描述目标)。那步长strides和填充padding如何理解呢?

步长是一个二维矩阵,它描述着你的卷积‘印章’距离你的上一个印章盖的位置的横纵距离,像一开始所说的“尽可能多的印下印章”其实翻译过来就是距离上一个印章盖的位置的横纵距离尽可能小,即(1,1),而步长(2,2)则是横纵距离都是2即2x2的大小其它大小由此类推。填充padding是用来确保你的输入图像能够被充分利用,实际上,如果你仔细观察图1,你会发现,步长为(1, 1)时中间的数字极为密集,越靠边缘越稀疏,这就是卷积的一个缺陷。为了改善这个缺陷,于是就给图像外层加了padding环,以确保边缘的格子(像素)能被印章印到足够多的次数,以此保证这些像素对结果的影响力。

此外,为了避免图1中中间密集,边缘稀疏的情况,步长可以设置成为一个合理的值(比如(2, 2)),读者可自行验证。

(2)如何理解池化层pool?

我认为池化层是一种极端的算法。池化层一般有最大池化MaxPooling、平均池化AveragePooling、全局最大池化GlobalMaxPooling和全局平均池化GlobalAveragePooling四种。

最大池化就是用一个池化大小(x, y)的印章(池化核)给图像矩阵印下距离strides的印,其中印到的所有像素取最大值作为这个印章印出来的输出值。平均池化则是印到的所有像素取平均值作为这个印章印出来的输出值。

全局池化并不存在步长strides这个概念,因为它是把印章大小设置成这个图像大小,即最大池化就会取整个图像中的最大值而平均池化就是取整张图像的平均值。这样做咋一看是没有意义的,但是对于多次卷积后的图像矩阵显然是可靠的,因为多次卷积后的图像矩阵大小也并没有大多,数据失真其实可以接受。最主要的是全局池化可以实现降维,实现代替全连接层,或许这才是它的作用。

(3)激活函数

激活函数显然是神经网络重要的一环,最流行的说法是去线性化,实现非线性话。我觉得这是对的,但是我认为激活函数不止这个作用。防止梯度爆炸,对结果数值范围进行限制以减小内存使用量等方便都有着极大考量,甚至不只是在神经网络方面,我在其他算法使用的时候也不自觉的想到激活函数的这些特性。当然,这只是我的个人见解,大家应谨慎参考。

至于,激活函数有哪些、怎么用?一般在模型结构中会有标注,比如ReLU。

(4)上采样/反卷积/转置卷积层/ConvTranspose?为什么先卷积再反卷积呢?

这是一个有趣的算法,看起来是反卷积其实和卷积也没有非常明显的区别。卷积是把一个图(矩阵)通过某些映射函数得到一个浓缩的矩阵,理想的情况是浓缩的矩阵能很好的描述原来矩阵的特征。而反卷积就是把浓缩的矩阵通过某些算法(比如线性插值)使其大小变化成我们需要的大小,这个变化其实也是卷积的过程,那为什么它卷积后图像变大了呢?前面有讲过填充padding,假如我们对每个像素(即矩阵元素)添加填充padding,那么填充后的图像还会变小么?有可能,这取决于你的卷积核大小,填充padding的大小和步长。这个填充padding用浓缩的矩阵使用插值等算法填充,值得注意的是,在卷积的过程中我们添加填充padding是为了提高像素利用率因此填充都是0以防止对模型造成干扰。

通俗的讲,反卷积就是用某种方法使格子之间填充格子,再使用印章给这些格子盖章,盖的方法和章子和卷积过程差不多。就是先填充再卷积。当然,可能也还有别的实现方法,但是通用的应该就是这个原理。

为什么先卷积再反卷积呢?卷积为的是提取特征,而反卷积是在提取的特征中放大这些特征。举个例子,卷积就如从各种混合物中提取我们需要的物质得到高质量,提取出来后再仿造这些物质以提高它的数量以便我们能够观察或者使用。

(5)Unet中左边和右边如何相加?

想要弄明白这个问题就得清楚图像的本质。图像实际是由像素组成的,一般我们用的:三通道的图像(可能是rgb也可能是rbg ,注意python-opecv中对三通道顺序的定义和PIL.Image是不一样的),即彩色图像;灰度图,把像素定义到0-255之间的一维数据,高维数据是可以通过算法精确得到低维数据的,比如彩色图像得到灰度图就很容易但是二值化图得到彩色图那就很困难;二值化图,把图像定义非0即1的一维数据。像素,其实就是一个字节(8位)的数据,在数学上我们应该理解成数字。由数字/像素组成的固定大小的”数字堆“(在线代里面被定义为矩阵)就是图像。(文笔有限,可能不太准确,但是能理解就行)。

我们Unet训练的数据集分原始图像(我喜欢叫ori_img)和目标图像(我喜欢叫seg_img)两类。原始图像就是我们彩色图像,也就是三维矩阵,目标图像是一维的每个数字代表一种类别的矩阵(一般都是人手工标注的,标注完成后图像中每块地方都有其类别而这些地方的像素数据写的是这个类别的代号数字,比如1、2。在使用别人数据集时,你应该验证这点以确保训练的时候不会报错。)。当图像开始卷积时,图像变小但是维度增加(前面卷积提到过的,图1下面)也就是矩阵大小变小但是维度提高。那么对于矩阵,只要一个矩阵大小(注意是矩阵大小,就是每一维度的矩阵宽高)和我们的矩阵大小一样,那么我们就可以把这个矩阵叠加到我们的矩阵之中,使矩阵大小不变而维度增加,使后面的模型有充足的数据。这就是Unet'的“相加”。

三、模型训练及测试

1、文件讲解。

我的net_work.py中定义了Unet和Unet++两个网络。Unet++本文不予讲解,但是和Unet差不多。

由于代码重复性很高,先前写的时候为了方便调试也没有写循环,所以这里只摘取具有代表性的代码讲解。

self.net_param = arg_params.net.parse_args()

#第零层下降

self.inputs = ky.Input(shape=(self.net_param.HEIGTH, self.net_param.WIDTH)+(3,))

self.x = ky.Conv2D(32, 3, padding='same')(self.inputs)

self.x = ky.BatchNormalization()(self.x)

self.x = ky.Activation("relu")(self.x)

self.x = ky.Conv2D(32, 3, padding='same')(self.x)

self.x = ky.BatchNormalization()(self.x)

self.con0 = ky.Activation("relu")(self.x)

self.x = ky.MaxPooling2D(2, padding="same")(self.con0)

上述代码首先先定义输入大小,其中参数都在arg_params.py中定义。请注意图像并不是越大越好,图像大意味着参数更多模型更复杂,训练时间更长且需要的内存更大。图像尺寸应是2的指数倍,因为这样更有利于模型训练和计算。

接着进行卷积操作,其中参数介绍参考2D卷积层,请注意filters参数应是2的指数倍,可参考官方的Unet结构图但是你应该考虑你的设备情况。比如我这里就使用了32层而不是官方的64层,因为w我的笔记本性能没那么好。

接着便是一个标准化层,这一层我觉得是至关重要的,因为没加这一层训练得到的模型效果要差上不少。这一层理应不需要任何参数,使用默认参数即可。

标准化之后应对数据激活,按结构再接着卷积、标准化、激活直到用最大池化进行下采样,请注意下采样之前你需要保存你的矩阵。我的做法就是使用一个新的变量名指向它,以确保我下次能找到这个矩阵。

# 第零层上升

self.conT0 = concatenate([ky.Conv2DTranspose(256, (2, 2), strides=(

2, 2), padding='same')(self.con4), self.con3], axis=-1)

self.x = ky.Conv2D(256, 3, padding='same')(self.conT0)

self.x = ky.BatchNormalization()(self.x)

self.x = ky.Activation("relu")(self.x)

self.x = ky.Conv2D(256, 3, padding='same')(self.x)

self.x = ky.BatchNormalization()(self.x)

self.conT0 = ky.Activation("relu")(self.x)

上升阶段最重要的一是反卷积/上采样,二是拼接。反卷积时,为了确保你的模型稳定及大小合适,理应采用这个矩阵卷积前的filters大小。请注意keras中的padding是填充0,官方并不支持插值等算法,但是支持自建。我这里为了方便使用官方的,效果会变差,但是实际上可用。

接着便是同样的卷积、标准化、激活...请注意这是上升阶段不存在下采样所以请不要使用池化层。

self.x = ky.Dropout(0.5)(self.x)

self.outputs = ky.Conv2D(1, 1, activation='sigmoid')(self.x)

self.model = Model(inputs=self.inputs, outputs=self.outputs)

self.Adam = ko.Adam(learning_rate=self.net_param.Lr, decay=self.net_param.Lr_decay, clipnorm=1)

self.SGD = ko.SGD(learning_rate=self.net_param.Lr, decay=self.net_param.Lr_decay, momentum=0.9, nesterov=False)

self.model.compile(self.SGD,

loss='binary_crossentropy',

#decay=self.net_param.Lr_decay,

metrics=['accuracy', MeanIoU(num_classes=2)])

Dropout是一个特殊的层,它会随机的杀死你的神经元,你可以理解成它会随机的使你之前训练的某些参数失效,从而使神经网络在训练时有更高的稳定性和更好的收敛。当然,它会使你的训练时间显著增加,但是如果网络末尾能有效缓解这个“显著”。在输出层时,你应谨慎选择你的激活函数,因为它决定着你的模型的最终输出结果。我采用的是sigmiod,这会使我的模型最终输出的是一个每个像素的某个类别的概率组成的矩阵。而卷积的filters层应由你的类别决定,我只有一类,故是1。

优化器的选择各有利弊,请自行查阅相关资料,根据自身情况选择。

model.compile是一个重要的变量,在这里面设置了优化器的选择、损失函数、评价指标。这些都是重中之重。loss/损失函数请选择根据自己类别选择,我是二分类故选择binary_crossentropy。metrics是评价指标,我选择了准确率和MeanIoU。MeaNIoU是评价图像分割预测分割与实际分割占比大小的指标。

train.py中使用了官方的数据集载入函数class OxfordPets()不过为了适应我的二分类数据集略有改动。在原代码基础上,我在写这篇文章过程中又学习了warmup策略并加入代码中,函数如下:

def Warm_up(epoch):

# Liner+cos

if epoch <= train_params.warm_ep:

if epoch==0:

lr = (epoch+1e-5)*train_params.lr_max/train_params.warm_ep

else:

lr = epoch*train_params.lr_max/train_params.warm_ep

kb.set_value(model.optimizer.lr, lr)

else:

lr = train_params.lr_max*(1+cos(pi*(epoch-train_params.warm_ep)/(train_params.Epochs-train_params.warm_ep)))/2

kb.set_value(model.optimizer.lr, lr)

return kb.get_value(model.optimizer.lr)

若需使用,需要在callbacks中添加keras.callbacks.LearningRateScheduler(Warm_up)。

其余部分就是数据集读取、打乱、载入训练以及结果绘图部分,我觉得不需要解释。若是你们看不懂,我认为我的编程水平应该有待提高。

detect.py是对模型的测试,其中大部分是冗余设计,正文从53行开始。思路是,使用opencv将图像大小改变成模型输入图像大小->输入到模型中->得到概率矩阵->创建一个和输入图像大小维度一样的0矩阵根据概率阈值是某维度满足阈值的部分设为255->将创建的矩阵(图像)和输入图像叠合得到输出图。

arg_params.py中是各种参数,其中都有讲解,若看不懂可留言可百度。若是想使用warmup策略,请在17行后加入如下代码:

train.add_argument('--lr_max', type=int, default=5e-6, help='warmup策略线性最大学习率')

train.add_argument('--warm_ep', type=int, default=20, help='warmup策略持续轮数')

2、模型训练

这是我的数据集,其中X中是原始图像,Y中是目标图像。下载链接前文有。根据arg_params描述在其中设置好参数。

开始训练:

如果你的图像大小设置太大,且 Batch_size也很大,会使你的显存不够而报错。当然这些设置也不是越大越好,这些叫超参数,需要你自己一次一次调试,或者使用类似网格搜索算法使其自动调整。如果你没有采用我的warmup策略,lr项将不会有输出。如果你在model.compile中的评价指标中没有加入MeanIoU那么将不输出- mean_io_u及val_mean_io_u项。

  

训练完成。

3、测试/使用模型

在arg_params.py设置模型地址(模型有最佳模型和最终模型,建议最佳模型),测试视频地址(我建议是摄像头因为hk是肖像分割数据集)。

我的测试结果先前在知乎有发布过:Unet测试视频

四、总结

本文仅讲解了Unet的大致原理即如何使用我的代码,事实上应该为了更全面更准确应该会涉及更多的算法和代码。但是今天大年30,比较忙,前几天又在刷机顶盒再加上小屁孩众多,吵闹,最主要是我学的不够深同时也忘了不少了。本文写出来给大家看是次要的,最主要的是留给我考研结束后复习用。不过写的有些许仓促,或许还会写,或许等考研结束了。

有问题可留言,我尽量解答。keras官方文档,可能需要科学上网

谨以此篇祝大家新年快乐,兔年吉祥。

精彩文章

评论可见,请评论后查看内容,谢谢!!!评论后请刷新页面。