Carvana ImageMasking Challenge
一、实验要求
自动识别图像中汽车的边界。
要求:开发一种自动删除照相馆背景的算法。
二、实验思路
一、解析问题
什么是图像分割问题呢? 简单的来讲就是给一张图像,检测是用框出框出物体,而图像分割分出一个物体的准确轮廓。也这样考虑,给出一张图像 I,这个问题就是求一个函数,从I映射到Mask。所以这里kaggle的比赛名称也叫做 ImageMasking Challenge 。
二、解决问题
看了一下kaggle网站上对这个问题的解决方式,发现大部分都是使用unet解决,使用CNN的效果都很差,其余网络的提及也很少。UNET相比于CNN更适用于图像分割,因为它的编码-解码结构和跳跃连接机制使得网络能够同时学习到全局信息和局部细节,并且能够利用不同层次的特征进行语义信息传递,提高分割准确性。
U-NET原论文地址U-net是一个用于医学图像分割的全卷积神经网络。目前很多神经网络的输出结果都是最终的分类类别标签,但对医学影像的处理,医务人员除了想要知道图像的类别以外,更想知道的是图像中各种组织的位置分布,而U-net就可以实现图片像素的定位,该网络对图像中的每一个像素点进行分类,最后输出的是根据像素点的类别而分割好的图像。
三、U-net结构

编码器部分负责对输入图像进行多次下采样,以提取图像的抽象特征。常用的下采样方法是使用卷积层和池化层,逐渐减小特征图的尺寸同时增加通道数。这样可以帮助网络学习到更高级别的语义信息,并减小特征图的空间维度。
- 该U-net网络一共有四层,分别对图片进行了4次下采样和4次上采样。左侧为编码器进行下采样的过程,输入的是一张572×572×1的图片,然后经过64个3×3的卷积核进行卷积,再通过ReLU函数后得到64个570×570×1的特征通道。然后把这570×570×64的结果再经过64个3×3的卷积核进行卷积,同样通过ReLU函数后得到64个568×568×1的特征提取结果,这就是第一层的处理结果。
- 之后重复四次进行下采样,每下采样一次就会把图片的大小减小一般,卷积核层数增加一倍,这样处理的图片大小越来越小,而特征也会提取到不同的特征通道中。
解码器部分是编码器的镜像,负责将编码器提取的特征还原到原始尺寸,并从不同层次融合特征,生成最终的分割结果。这里采用了上采样和卷积操作,逐渐恢复特征图的尺寸,同时进行特征融合和信息传递。
- 右边部分从下往上则是4次上采样过程。从最右下角开始,把28×28×1024的特征矩阵经过512个2×2的卷积核进行反卷积,把矩阵扩大为56×56×512,由于反卷积只能扩大图片而不能还原图片,为了减少数据丢失,采取把左边降采样时的图片裁剪成相同大小后直接拼过来的方法增加特征层,再进行卷积来提取特征。
- 最终不断重复,每次上采样都会让图像大小扩大一倍而卷积核层数减少,最终获得两层特征输出,其实相当于做了二分类的过程,输出的分别是一层背景和一层轮廓目标。
UNET使用跳跃连接来连接编码器和解码器的对应层。跳跃连接可以帮助网络进行多尺度信息的融合,提供了更丰富的上下文信息,并避免了信息丢失。通过跳跃连接,UNET能够将低层次的细节特征与高层次的语义特征相结合,提高图像分割的准确性。 在训练过程中,UNET通常采用交叉熵损失函数来度量分割结果与真实标签的差异,并通过反向传播算法更新网络的权重。此外,UNET还可采用数据增强技术来增加训练样本的多样性,防止过拟合。
三、实验过程
A.u-net实现
Keras是一个高级神经网络API,它可以用于构建、训练和部署深度学习模型。Keras是建立在底层深度学习库(如TensorFlow、Theano等)之上的,它提供了简化和高级抽象的接口,使得模型的构建和训练过程更加方便和快捷。
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
| def get_unet_128(input_shape=(128, 128, 3), num_classes=1): inputs = Input(shape=input_shape) down1 = Conv2D(64, (3, 3), padding='same')(inputs) down1 = BatchNormalization()(down1) down1 = Activation('relu')(down1) down1 = Conv2D(64, (3, 3), padding='same')(down1) down1 = BatchNormalization()(down1) down1 = Activation('relu')(down1) down1_pool = MaxPooling2D((2, 2), strides=(2, 2))(down1) center = Conv2D(1024, (3, 3), padding='same')(down4_pool) center = BatchNormalization()(center) center = Activation('relu')(center) center = Conv2D(1024, (3, 3), padding='same')(center) center = BatchNormalization()(center) center = Activation('relu')(center) up4 = UpSampling2D((2, 2))(center) up4 = concatenate([down4, up4], axis=3) up4 = Conv2D(512, (3, 3), padding='same')(up4) up4 = BatchNormalization()(up4) up4 = Activation('relu')(up4) up4 = Conv2D(512, (3, 3), padding='same')(up4) up4 = BatchNormalization()(up4) up4 = Activation('relu')(up4) up4 = Conv2D(512, (3, 3), padding='same')(up4) up4 = BatchNormalization()(up4) up4 = Activation('relu')(up4) classify = Conv2D(num_classes, (1, 1), activation='sigmoid')(up1) model = Model(inputs=inputs, outputs=classify) model.compile(optimizer=RMSprop(lr=0.0001), loss=bce_dice_loss, metrics=[dice_coeff]) return model
|
这里的代码包括了下采样路径和上采样路径,通过跳跃连接来保留下采样过程中的细节信息,并最终输出分割结果。网络的输入是一个大小为128x128x3的图像,输出是一个大小为128x128x1的二值分割图。
B.loss
在Unet的实现中将损失函数作为参数传入进行模型训练,这里介绍损失函数的具体实现。
1
| model.compile(optimizer=RMSprop(lr=0.0001), loss=bce_dice_loss, metrics=[dice_coeff])
|
Dice系数是一种常用的分割任务评估指标,也可以作为损失函数使用。它衡量了预测结果与真值之间的重叠程度。函数中,y_true表示真实标签,y_pred表示预测标签。平滑因子smooth用于避免分母为0,将真实标签和预测标签展平后计算它们的交集(相乘),然后计算Dice系数.
1 2 3 4 5 6 7
| def dice_coeff(y_true, y_pred): smooth = 1. y_true_f = K.flatten(y_true) y_pred_f = K.flatten(y_pred) intersection = K.sum(y_true_f * y_pred_f) score = (2. * intersection + smooth) / (K.sum(y_true_f) + K.sum(y_pred_f) + smooth) return score
|
dice_loss函数: 1 - dice_coeff ,即真值和预测之间的差异越小,loss越小。
1 2 3
| def dice_loss(y_true, y_pred): loss = 1 - dice_coeff(y_true, y_pred) return loss
|
bce_dice_loss函数:将二元交叉熵损失和Dice Loss结合的一种损失函数。它考虑了两个方面,即像素级别的分类误差和分割结果的相似性。损失函数是二元交叉熵损失与Dice Loss之和。
1 2 3
| def bce_dice_loss(y_true, y_pred): loss = binary_crossentropy(y_true, y_pred) + dice_loss(y_true, y_pred) return loss
|
c.图像增强
这一节介绍对数据集的图像增强处理方法,增强模型的抗干扰性和泛化能力。
1. RGB—->HSV
首先,代码通过cv2.cvtColor将RGB图像转换为HSV颜色空间,HSV表示色调(Hue)、饱和度(Saturation)和明度(Value)。然后,代码根据输入的参数hue_shift_limit、sat_shift_limit和val_shift_limit随机生成色调、饱和度和明度的偏移量。这些参数确定了色调、饱和度和明度的变化范围。 接着,代码分别对色调、饱和度和明度进行偏移操作,使用cv2.add函数将生成的偏移量加到对应的通道上。
1 2 3 4 5 6 7 8 9 10
| image = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) h, s, v = cv2.split(image) hue_shift = np.random.uniform(hue_shift_limit[0], hue_shift_limit[1]) h = cv2.add(h, hue_shift) sat_shift = np.random.uniform(sat_shift_limit[0], sat_shift_limit[1]) s = cv2.add(s, sat_shift) val_shift = np.random.uniform(val_shift_limit[0], val_shift_limit[1]) v = cv2.add(v, val_shift) image = cv2.merge((h, s, v)) image = cv2.cvtColor(image, cv2.COLOR_HSV2BGR)
|
2. 随机平移,缩放,旋转
这段代码通过随机的平移、缩放和旋转来改变输入图像的外观,以增强训练数据的多样性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| height, width, channel = image.shape angle = np.random.uniform(rotate_limit[0], rotate_limit[1]) scale = np.random.uniform(1 + scale_limit[0], 1 + scale_limit[1]) aspect = np.random.uniform(1 + aspect_limit[0], 1 + aspect_limit[1]) sx = scale * aspect / (aspect ** 0.5) sy = scale / (aspect ** 0.5) dx = round(np.random.uniform(shift_limit[0], shift_limit[1]) * width) dy = round(np.random.uniform(shift_limit[0], shift_limit[1]) * height)
cc = np.math.cos(angle / 180 * np.math.pi) * sx ss = np.math.sin(angle / 180 * np.math.pi) * sy rotate_matrix = np.array([[cc, -ss], [ss, cc]])
box0 = np.array([[0, 0], [width, 0], [width, height], [0, height], ]) box1 = box0 - np.array([width / 2, height / 2]) box1 = np.dot(box1, rotate_matrix.T) + np.array([width / 2 + dx, height / 2 + dy])
box0 = box0.astype(np.float32) box1 = box1.astype(np.float32) mat = cv2.getPerspectiveTransform(box0, box1)
|
3. 随机水平翻转
这段代码实现了随机水平翻转图像的操作。它接受输入图像和对应的掩膜(mask),然后根据给定的概率u决定是否进行水平翻转操作。
1 2 3 4 5 6
| def randomHorizontalFlip(image, mask, u=0.5): if np.random.random() < u: image = cv2.flip(image, 1) mask = cv2.flip(mask, 1)
return image, mask
|
d.生成器
使用一个无限循环来生成训练数据和验证数据的批次。对于每个批次,它从ids_train_split列表中选择一部分训练样本的ID,并依次处理每个ID。对于每个ID,它读取对应的图像文件和掩码文件,并进行一系列的图像增强操作,包括颜色变换、平移缩放旋转和水平翻转。然后,它将增强后的图像和掩码添加到批次中。最后,它将批次中的图像和掩码转换为np.float32数据类型,并对图像和掩码进行归一化处理,将像素值缩放到[0, 1]的范围内。最后,使用yield语句将批次返回。
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
| def train_generator(): while True: for start in range(0, len(ids_train_split), batch_size): x_batch = [] y_batch = [] end = min(start + batch_size, len(ids_train_split)) ids_train_batch = ids_train_split[start:end] for id in ids_train_batch.values: img = cv2.imread('input/train/{}.jpg'.format(id)) img = cv2.resize(img, (input_size, input_size)) mask = cv2.imread('input/train_masks/{}_mask.png'.format(id), cv2.IMREAD_GRAYSCALE) mask = cv2.resize(mask, (input_size, input_size)) img = randomHueSaturationValue(img, hue_shift_limit=(-50, 50), sat_shift_limit=(-5, 5), val_shift_limit=(-15, 15)) img, mask = randomShiftScaleRotate(img, mask, shift_limit=(-0.0625, 0.0625), scale_limit=(-0.1, 0.1), rotate_limit=(-0, 0)) img, mask = randomHorizontalFlip(img, mask) mask = np.expand_dims(mask, axis=2) x_batch.append(img) y_batch.append(mask) x_batch = np.array(x_batch, np.float32) / 255 y_batch = np.array(y_batch, np.float32) / 255 yield x_batch, y_batch
|
e.训练
1 2 3 4 5 6 7
| model.fit_generator(generator=train_generator(), steps_per_epoch=np.ceil(float(len(ids_train_split)) / float(batch_size)), epochs=epochs, verbose=2, callbacks=callbacks, validation_data=valid_generator(), validation_steps=np.ceil(float(len(ids_valid_split)) / float(batch_size)))
|
将这些作为参数传入训练器中,开始训练函数,训练出的模型保存在models文件及中。
f. 预测
之后加载训练好的模型权重,把经过规范化的图片传入模型后获得预测结果,再调整预测的掩码,作用于原本的图片最终得到删除背景的照相馆汽车图片。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| model.load_weights(filepath='weights/best_weights.hdf5') for start in tqdm(range(0, len(ids_test), batch_size)): x_batch = [] end = min(start + batch_size, len(ids_test)) ids_test_batch = ids_test[start:end] for id in ids_test_batch.values: img = cv2.imread('input/test/{}.jpg'.format(id)) img = cv2.resize(img, (input_size, input_size)) x_batch.append(img) x_batch = np.array(x_batch, np.float32) / 255 preds = model.predict_on_batch(x_batch) preds = np.squeeze(preds, axis=3) for i, pred in enumerate(preds): prob = cv2.resize(pred, (orig_width, orig_height)) mask = prob > threshold img = cv2.imread('input/test/{}.jpg'.format(ids_test[start + i])) img = cv2.resize(img, (orig_width, orig_height)) img[~mask] = [0, 0, 0] cv2.imwrite('output/{}.jpg'.format(ids_test[start + i]), img) rle = run_length_encode(mask) rles.append(rle)
|
四、训练过程
直接在本地运行代码,没有使用kaggle的服务器,所以条件限制就只训练了五轮。加载模型之后,开始每一轮的训练,由于kaggle的训练集过于大,所以每一轮都要花费较多时间和资源。

1 2 3 4 5 6 7 8 9 10 11
| Epoch 1/5 255/255 - 2536s - loss: 0.1780 - dice_coeff: 0.8944 - val_loss: 1.7487 - val_dice_coeff: 0.0485 - lr: 1.0000e-04 - 2536s/epoch - 10s/step Epoch 2/5 255/255 - 2478s - loss: 0.0763 - dice_coeff: 0.9548 - val_loss: 0.0859 - val_dice_coeff: 0.9512 - lr: 1.0000e-04 - 2478s/epoch - 10s/step Epoch 3/5 255/255 - 2485s - loss: 0.0517 - dice_coeff: 0.9707 - val_loss: 0.0404 - val_dice_coeff: 0.9770 - lr: 1.0000e-04 - 2485s/epoch - 10s/step Epoch 4/5 255/255 - 2497s - loss: 0.0409 - dice_coeff: 0.9778 - val_loss: 0.0815 - val_dice_coeff: 0.9556 - lr: 1.0000e-04 - 2497s/epoch - 10s/step Epoch 5/5 255/255 - 2479s - loss: 0.0353 - dice_coeff: 0.9813 - val_loss: 0.0298 - val_dice_coeff: 0.9844 - lr: 1.0000e-04 - 2479s/epoch - 10s/step
|
训练过程输出损失值变化和预测准确度

可以看到随着训练伦次的不断增多,训练集合和验证集的损失值都在降低,而由于训练一轮之后再根据验证集损失值调整参数,所以验证集初始化的损失值会比训练集低很多。

而随着训练伦次不断增多,模型预测的准确度也在上升,DICE coefficient是模型准确度的含义,越接近1说明预测的准确度也越高。也可以看到训练前期模型快速收敛,后期模型参数变化,损失值下降梯度都有减缓。
五、输出图片
原本kaggle给的预测图片很多,而课程实验中老师做了删减,只保留了40张需要删除背景的图片,于是在上述过程中,真正模型预测的时候代码运行很快。
并且kaggle提交的是掩码的csv文件,csv文件中是掩码经过浮点编码之后保存的数值,不具有可读性,虽然最终的评价指标还是参考损失值,准确度等等,但是为了让算法实现可用性,就修改了代码,将生成的csv中的模型预测掩码作用于原本的输入图像中。

原始图像

掩码遮蔽图像
可以看到仅仅训练5轮的Unet模型也能很好的将汽车图片中的照相馆背景删除。Unet模型对于背景和目标物的特征提取能力真的很强。
六、实验收获
之前的讨论课文献阅读中经常提到Unet这个网络结构,但是一直对这个的了解并不深入,完成kaggle的实验之后对Unet的原理和实现的过程了解更加深入了,并且对于keras这个深度学习框架的使用了解也更加深入。并且上课老师也一直提到图像增强的方式,完成kaggle实验的时候参考了很多网上的代码,发现了很多图像增强的方法,包括平移,旋转,缩放,翻转以及它们的具体实现,写代码的能力也up了。