使用 DeepLab 训练一个人脸语义分割模型

DeepLab 是google 推出的用于语义分割的一系列论文,在语义分割领域取得 state of the art 表现。
在我的了解里,语义分割大概从 FCN[1] 开始转为用 DL 方法,Unet[2] 在医学图像分割大放异彩,然后 DeepLab 在相当时间统治了语义分割排行榜

我的项目需要把人脸抠图出来从而去除背景的干扰,适合使用语义分割的方法,由于用自己写的 unet 训练后效果很差,又自我感觉数据集没什么问题,因而决定用 DeepLab 再试试。

Deeplab 架构及要点

这里以 DeepLabV3+[3] 为准,架构如图:
deeplab_structure

要点可以总结为三部分:

  • 左上 DCNN 即 feature extractor backbone,可选用 mobilenet_v2、resnet、xception等, 论文在这里贡献主要在于针对 xception 的改进,使其结构灵活化,尤其是通过 output_stride 设置,直接改变 encoder 向下潜的层数,方便使用者权衡 GPU 和网络效能。同时大量把空洞卷积应用在 seperable convolution 的 depthwize_conv 上,提高了效能并增大感受野,如下图:
    atrous_depthwise_conv

  • Encoder 后半部分 ASPP(Atrous Spatial Pyramid Pooling),结合了 SPP 的多尺度特性和空洞卷积的低参特性:
    backbone + aspp 接在一起组成 encoder:
    atrous_spatial_pyramid_pooling_aspp[4]

  • Decoder 不是多层上采样,而是很简单暴力的只有两层快速上采样,这点让我比较意外。backbone 输出的 low-level feature 与 ASPP 的结果 concat 一起,而在 concat 前,使用 point_conv 来调整两者 channel 及比例,这个是比较影响结果的 trick,论文中有更多详细例证。

配置环境,跑通 deeplab 库的样例数据和模型。

Deeplab 由 Tensorflow 开源,属于 models/research/ 下的一个子项目,跟 object detection api 等项目一样依赖 slim, 因此需要 git clone 整个 models 项目,再根据教程进行配置,以及测试基本数据集是否能正常训练。

按照这里的文档尝试 PASCAL VOC 2012 数据集的训练,大概会下载2G数据,并随后转为 tfrecords。使用默认命令,尝试训练。
确定能跑后,再从 model zoo 下载预训练模型,继续训练,跑一下,total loss 大概 0.2。这样我们对于训练方案和目标 loss 心里就大概有了底。

DeepLab 训练参数设置

DeepLab 训练对显存要求还是很高的,这里总结下应对显存少的办法:

  • 按文档所说,从预训练模型开始训练,并设置 fine_tune_batch_norm = False,从而减少 bn 参数对 GPU 的占用。(当然,训练自己的数据集的话,这个方案可能不太可靠)
  • 使用相对小一点的模型架构,默认 model_variant 参数为 xception_65,使用 mobilenet_v2 会小很多,可供选择的模型参考 model zoo 列表或者项目文件 core.feature_extractor.py 里声明的模型。
  • 减小 crop_size 大小,原因待看论文补充,值不得小于 [321, 321] [5]
  • 减少 batch_size, 但文档建议 batch_size 要足够大(>12)才能有较好效果。
  • output_stride 用 16。output_stride 是 encoder 输入到输出的分辨率比例[6],决定 downsample 程度,值越小,特征丢失越少,但参数空间显然大了许多。

我这里实测,在自己的单张 rtx2080 显卡上(win10),使用 mobilenet_v2, crop_size 513, batch_size 可以到 8,再大似乎就不行了。
xception_65 的 batch_size 则大概只能到2。batch_size 太低是不行的,bn性能会急剧下降[7],因而我这里还是暂时选用了 mobilenet_v2 模型来训练,后面会再试下改变 crop_size。

在单张 rtx2080ti 显卡上,使用 xception_65,不断压榨参数,最终能够在 449,449 crop_size 时用 batch_size=6 时跑起来,但是loss下降很慢,感觉就是因为 bn 没法充分训练,训练一晚,miou 还不到 0.7。 使用 mobilenet_v2,能够 batch_size=16 跑起来。

使用 mobilenet_v2 架构训练时,官方备注要把 atrous_stride 设为 None,但其实不必的,mobilenet_v2 也只是作为 backbone,设置了 ASPP 可以正常生效,官方是为了速度才不建议用[8]。在我看来,不用 ASPP 未免完全抛弃了 deeplab 的精华,还是用了这个参数。

准备数据

使用 CelebAMaskHQ 数据集

CelebAMaskHQ 提供了一个非常高质量的人脸图像数据库,由 CelebA 筛选而来,共 30000 个样本,图像分辨率为 1024x1024,语义分割标注分辨率 512x512,标注19个类别如眼睛、眼镜、鼻子等等。
CelebAMaskHQ_mask_anno_example

由于我们的目的是去掉人像的背景,只要把整个人像标注为一个类而其他标注为背景就可以了,所以这些类别我们在实际使用时都会被看作一个类。原数据集标注数据是按照每个类别分别生成掩码的,我们需要预处理把他们汇总在一起,做个mask叠加的动作。
这部分mask预处理部分,可以使用原项目的预处理代码,或者参考下面我的脚本。(以下代码生成汇总mask(19分类)、人像mask(2分类)以及去掉背景的人像图)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import os
import sys
import cv2
import glob
import numpy as np
import threading
import shutil
from tqdm import tqdm_notebook,tnrange

def touch_dir(dir):
if not os.path.exists(dir):
print('making dir : ', dir)
os.makedirs(dir)

def clean_and_touch_dir(dir):
if os.path.exists(dir):
shutil.rmtree(dir)
os.makedirs(dir)

def work_on_imgs(num_from, num_to):
for k in tnrange(num_from, num_to, desc='img {:d} to {:d}'.format(num_from, num_to)):
folder_num = int(k / 2000)
num_str = '{:05d}'.format(k)

img_path = os.path.join(img_dir, str(k) + '.jpg')
image = cv2.imread(img_path)
image = cv2.resize(image, (512, 512), interpolation=cv2.INTER_CUBIC)

mask = np.zeros((512, 512), dtype='uint8')
for idx, label in enumerate(label_list):
filename = os.path.join(mask_dir, str(folder_num), num_str + '_' + label + '.png')
if (os.path.exists(filename)):
im=cv2.imread(filename)
im = im[:, :, 0]
for i in range(512):
for j in range(512):
if im[i][j] != 0:
mask[i][j] = (idx + 1)

total_mask_path = os.path.join(total_mask_dir, str(k) + '.png')
cv2.imwrite(total_mask_path, mask)

one_mask = np.uint8(mask>0)
inverse_mask = 1-one_mask

bg = np.ones((512, 512, 3), dtype='uint8') * 255

masked = cv2.bitwise_and(image, image, mask=one_mask)
bg_masked = cv2.bitwise_and(bg, bg, mask = inverse_mask)
composed = cv2.add(masked, bg_masked)

target_path = os.path.join(target_dir, 'masked_' + num_str + '.png')
cv2.imwrite(target_path, composed)


import math

label_list = ['skin', 'nose', 'eye_g', 'l_eye', 'r_eye', 'l_brow', 'r_brow', 'l_ear', 'r_ear', 'mouth', 'u_lip', 'l_lip', 'hair', 'hat', 'ear_r', 'neck_l', 'neck', 'cloth']

data_root = os.path.join('path_to_project', 'CelebAMask-HQ') # modify your dataset path here
example_num = 30000
img_dir = os.path.join(data_root, 'CelebA-HQ-img')
mask_dir = os.path.join(data_root, 'CelebAMask-HQ-mask-anno')
total_mask_dir = os.path.join(data_root, 'CelebAMask-total-mask')
target_dir = os.path.join(data_root, 'CelebAMask-target')

clean_and_touch_dir(total_mask_dir)
clean_and_touch_dir(target_dir)

parallel_num = 20
batch_num = math.ceil(example_num / parallel_num)
threadings = []

for i in range(parallel_num):
start = batch_num * i
end = min(batch_num*(i+1), example_num)
print("pending batch examples from {} to {}".format(start, end))
t = threading.Thread(target=work_on_imgs, args=(start,end, ))
threadings.append(t)
t.start()

for t in threadings:
t.join()

使用脚本转为 tfrecords

或许可以将文件按照 Pascal VOC 的组织方式整理下,然后用 deeplab 的脚本转成训练数据。不过,由于我这里已经有一套自己用得比较熟悉的 tfrecords 生成脚本,改下 file_path 到 feature 的转换函数就行。我的 tfrecords 生成工具脚本在这里:

https://gist.github.com/ec14e54f8b9ee5ce7e4b96e3f39d9302#file-parallel_tfrecords-py

针对本数据集,更换 parser 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#deeplab 使用 decode_jpeg 或 decode_png operator 解析图像数据,因此生成 feature 的时候,要注意传入 png 编码过的数据。

import io
def parse_example_deeplab_style(file_path):
filename = os.path.basename(file_path)
img_format = filename.split('.')[-1]

image = Image.open(file_path)
image = image.convert('RGB')
image = image.resize((TARGET_SIZE, TARGET_SIZE))

imgByteArr = io.BytesIO()
image.save(imgByteArr, format='PNG')
image_data = imgByteArr.getvalue()

index = os.path.splitext(filename)[0]
mask_path = os.path.join(mask_dir, index + '.png')
# seg_data = tf.gfile.FastGFile(mask_path, 'rb').read() # the masks are already in (512, 512) size

mask_img = Image.open(mask_path)
mask_img = mask_img.resize((TARGET_SIZE, TARGET_SIZE))
mask_np = np.uint8(np.array(mask_img))

portrait_mask_np = np.uint8(mask_np>0) # get portrait mask, which only cares about face or background
portrait_mask_image = Image.fromarray(portrait_mask_np) # convert back to PIL Image

maskByteArr = io.BytesIO()
portrait_mask_image.save(maskByteArr, format='PNG')
portrait_mask_data = maskByteArr.getvalue() # save to BytesIO and get encoded values without disk writingg

tf_example = tf.train.Example(features=tf.train.Features(feature={
'image/encoded': _bytes_list_feature(image_data),
'image/filename': _bytes_list_feature(filename),
'image/format': _bytes_list_feature(_IMAGE_FORMAT_MAP[img_format]),
'image/height': _int64_list_feature(TARGET_SIZE),
'image/width': _int64_list_feature(TARGET_SIZE),
'image/channels': _int64_list_feature(3),
'image/segmentation/class/encoded': (_bytes_list_feature(portrait_mask_data)),
'image/segmentation/class/format': _bytes_list_feature('png'),
}))

return tf_example

训练以及验证

首先要针对自己的数据集修改 dataset_generator.py 中的代码,增加数据集参数,主要是 num_classes 及 train/eval 样本个数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_CELEBAMASK_FACIAL_SEG_INFORMATION = DatasetDescriptor(
splits_to_sizes={
'train': 28480,
'val': 1498,
},
num_classes=2,
ignore_label=255,
)
_DATASETS_INFORMATION = {
'cityscapes': _CITYSCAPES_INFORMATION,
'pascal_voc_seg': _PASCAL_VOC_SEG_INFORMATION,
'ade20k': _ADE20K_INFORMATION,
'celebamask_facial_seg': _CELEBAMASK_FACIAL_SEG_INFORMATION,
}

Train 训练

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
python deeplab/train.py \
--logtostderr \
--training_number_of_steps=300000 \
--train_split="train" \
--model_variant="mobilenet_v2" \
--atrous_rates=6 \
--atrous_rates=12 \
--atrous_rates=18 \
--output_stride=16 \
--decoder_output_stride=4 \
--train_crop_size="513,513" \
--train_batch_size=16 \
--dataset="celebamask_facial_seg" \
--train_logdir=path_to_proj_portrait2anime/train_log_deeplab/celebamask_facial_seg_mobilenetv2_513_bs_16_08221853 \
--dataset_dir=path_to_proj_portrait2anime/CelebAMask-HQ/facial_segmentation_tfrecords_for_deeplab_0818_131910

在我测试下来,对于我这里的人脸分割任务,大概要至少1小时后才能有一定结果。
一般来说,Eval 也在训练时一直跑着比较好,每次有新 ckpt 时执行一次验证能清楚看到 miou 变化。

Eval 验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
python deeplab/eval.py \
--logtostderr \
--eval_split="val" \
--model_variant="mobilenet_v2" \
--atrous_rates=12 \
--atrous_rates=24 \
--atrous_rates=36 \
--output_stride=8 \
--decoder_output_stride=4 \
--eval_crop_size="513,513" \
--train_batch_size=8 \
--dataset="celebamask_facial_seg" \
--checkpoint_dir=path_to_proj_portrait2anime/deeplab_train_log/celebamask_facial_seg_mobilenetv2_513_8_08181604 \
--eval_logdir=path_to_proj_portrait2anime/deeplab_train_log/eval_0818_131910 \
--dataset_dir=path_to_proj_portrait2anime/CelebAMask-HQ/facial_segmentation_tfrecords_for_deeplab_0818_131910

Eval 里各参数基本与 train 时一直,但 train_crop_size 要改为 eval_crop_size。另外我在某次实验时,训练的 train_crop_size 调低到 449,验证时 eval_crop_size 还用这个值会报错,调到 513 后正常。这里有解释,验证时用全图大小就行,似乎不用顾虑显存/模型大小,只是我暂时还没完全理解。

另外稍提下,Eval 是启动进程不断跟踪 eval_logdir 下的 ckpt 并持续验证,所以不会直接打印结果,需要进入 tensorboard 中看 miou 结果。

Vis 可视化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
python deeplab/vis.py \
--logtostderr \
--vis_split="val" \
--model_variant="mobilenet_v2" \
--atrous_rates=12 \
--atrous_rates=24 \
--atrous_rates=36 \
--output_stride=8 \
--decoder_output_stride=4 \
--vis_crop_size="513,513" \
--dataset="celebamask_facial_seg" \
--checkpoint_dir=path_to_proj_portrait2anime/deeplab_train_log/celebamask_facial_seg_mobilenetv2_513_8_08181604 \
--vis_logdir=path_to_proj_portrait2anime/deeplab_train_log/vis_0818_131910 \
--dataset_dir=path_to_proj_portrait2anime/CelebAMask-HQ/facial_segmentation_tfrecords_for_deeplab_0818_131910

结果类似下图(训练时间短):
deeplab_on_celebamask_facial_seg_vis_0

Export 部署 .pb and SavedModel

deeplab 项目中也提供了 export_model 代码,用于固化成 .pb 模型。模型固化前后节点信息变化不少,固化前输入输出节点难找,因此这里为了得到 SavedModel 干脆先转 .pb 固化模型,代码里比较明确地给出了输入输出节点名,这样直接拿来用会方便很多:

1
2
_INPUT_NAME = 'ImageTensor'
_OUTPUT_NAME = 'SemanticPredictions'

不过这个 export_model.py 的参数与之前有些差距,容易踩坑,先上正确的例子:

1
2
3
4
5
6
7
8
9
10
11
12
python deeplab/export_model.py \
--model_variant="mobilenet_v2" \
--crop_size=513 \
--crop_size=513 \
--atrous_rates=6 \
--atrous_rates=12 \
--atrous_rates=18 \
--output_stride=16 \
--num_classes=2 \
--decoder_output_stride=4 \
--checkpoint_path=path_to_proj_portrait2anime/train_log_deeplab/celebamask_facial_seg_mobilenetv2_513_8_08181604/model.ckpt-15016 \
--export_path=path_to_proj_portrait2anime/train_log_deeplab/frozen/frozen_inference_graph_mobilenetv2_test233.pb

以上面命令来讲,虽然使用了 mobilenet_v2 backbone,但是训练的时候如果设置了 atrous_rates,export时注意别漏掉;之前训练时要指定 --dataset,这里改用 --num_classes 来确定模型输出结构;crop_sizeexport_model.py 里定义为 DEFINE_multi_integer,所以要像这样改用多次列出的格式。

最终效果样例

使用 mobilenet_v2、 output_stride=16、batch_size=16 训练一晚达到 step-88170,loss在约0.4,难以下降,验证 miou 只有 0.83。
在 val 数据上进行可视化,结果如下:

result_deeplab_on_facial_seg_vis_val_0823

loss 过程如下:

result_deeplab_on_facial_seg_loss_scalars_0823

实际用在项目中,部分典型结果如下:

跟训练数据集相似的:
result_portrait2anime_v0_thump_0

边缘清晰,侧脸:
result_portrait2anime_v0_gakki_0

比较难的:
result_portrait2anime_v0_boxer_0

一言难尽,miou 明显不够,实际结果也确实难以达到要求。
可能还是得用 xception,另外图像分割的边缘需要做类似羽化拟合的操作,必须增加后处理来去掉毛刺。


  1. 1.fully convolutional networks https://people.eecs.berkeley.edu/~jonlong/long_shelhamer_fcn.pdf
  2. 2.U-Net: Convolutional Networks for Biomedical Image Segmentation https://arxiv.org/abs/1505.04597
  3. 3.Encoder-Decoder with Atrous Separable Convolution for Semantic Image Segmentation https://arxiv.org/abs/1802.02611
  4. 4.图源:Review: DeepLabv3 — Atrous Convolution (Semantic Segmentation) 推荐阅读
  5. 5.https://blog.csdn.net/weixin_41713230/article/details/81937763
  6. 6.output_stride-> the ratio of input image spatial resolution to the final output resolution
  7. 7.What's wrong with BN https://zhuanlan.zhihu.com/p/35005794
  8. 8.Issue-Question over atrous rate for mobilenet