DeepLab 是google 推出的用于语义分割的一系列论文,在语义分割领域取得 state of the art 表现。 在我的了解里,语义分割大概从 FCN[1] 开始转为用 DL 方法,Unet[2] 在医学图像分割大放异彩,然后 DeepLab 在相当时间统治了语义分割排行榜 。
我的项目需要把人脸抠图出来从而去除背景的干扰,适合使用语义分割的方法,由于用自己写的 unet 训练后效果很差,又自我感觉数据集没什么问题,因而决定用 DeepLab 再试试。
Deeplab 架构及要点 这里以 DeepLabV3+[3] 为准,架构如图:
要点可以总结为三部分:
左上 DCNN 即 feature extractor backbone,可选用 mobilenet_v2、resnet、xception等, 论文在这里贡献主要在于针对 xception 的改进,使其结构灵活化,尤其是通过 output_stride 设置,直接改变 encoder 向下潜的层数,方便使用者权衡 GPU 和网络效能。同时大量把空洞卷积应用在 seperable convolution 的 depthwize_conv 上,提高了效能并增大感受野,如下图:
Encoder 后半部分 ASPP(Atrous Spatial Pyramid Pooling),结合了 SPP 的多尺度特性和空洞卷积的低参特性: backbone + aspp 接在一起组成 encoder:[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个类别如眼睛、眼镜、鼻子等等。
由于我们的目的是去掉人像的背景,只要把整个人像标注为一个类而其他标注为背景就可以了,所以这些类别我们在实际使用时都会被看作一个类。原数据集标注数据是按照每个类别分别生成掩码的,我们需要预处理把他们汇总在一起,做个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 osimport sysimport cv2import globimport numpy as npimport threadingimport shutilfrom tqdm import tqdm_notebook,tnrangedef 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 mathlabel_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' ) 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
结果类似下图(训练时间短):
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_size 在 export_model.py 里定义为 DEFINE_multi_integer,所以要像这样改用多次列出的格式。
最终效果样例 使用 mobilenet_v2、 output_stride=16、batch_size=16 训练一晚达到 step-88170,loss在约0.4,难以下降,验证 miou 只有 0.83。 在 val 数据上进行可视化,结果如下:
loss 过程如下:
实际用在项目中,部分典型结果如下:
跟训练数据集相似的:
边缘清晰,侧脸:
比较难的:
一言难尽,miou 明显不够,实际结果也确实难以达到要求。 可能还是得用 xception,另外图像分割的边缘需要做类似羽化拟合的操作,必须增加后处理来去掉毛刺。