因本文篇幅较长,读者可参考目录,看之所需
前言
在计算机视觉领域,尤其是深度学习模型处理图像任务时,数据增强显得尤为重要,因为图像具有平移、旋转、缩放等不变性,人的视觉可以很好的处理这些变换,但对于神经网络来说,需要广泛的学习到这些变换
当下以数据驱动的AI,在实际场景中,我们很难有大量带标签的训练样本,当我们使用深度学习神经网络训练一个模型时,数以百万计的模型参数需要训练,数据量太小的话,很容易产生过拟合,训练出的模型也很难具备良好的泛化能力
什么是数据增强
数据增强,是从现有的训练样本中生成更多的训练数据,其方法是对图像的局部特征或者全局特征进行一定的随机选取变换来增加样本,当然,增强后的图像与原图像是强相关的,只是多了不同维度和深度的修剪处理。其目标是,模型在训练过程中相较于原始数据,可以学习到同一类别更多的场景变化
数据增强的作用
比如一张训练图片,其目标在特定的姿势角度或者光照等条件下拍摄,但是就如同世界上没有两片相同的叶子一般,我们所要预测的数据是未知的,所以需要尽可能的学习这些场景或变化,所谓见多识广,从而来提高模型的泛化能力(指模型在新的数据集上的适应/预测能力)以及鲁棒性(指异常样本对于模型的预测影响)
图像尺寸预处理
先简单介绍三种常用的图像尺寸大小统一预处理的方法,因为大家都知道,在卷积神经网络中,因为全连接层的关系,输入图像的大小必须是固定的
首先引入第三方依赖包:
import os # 系统操作模块,提供了丰富的目录及文件处理方法
import cv2 # Opencv,一个强大的图像处理和计算机视觉库
import shutil # 高级的对文件、文件夹、压缩包等处理模块
import random # 用于生成各种随机数据函数
import imutils # 轻量级简化版图像处理函数
import numpy as np # 科学计算、数学函数库,多维数组、矩阵运算
import imgaug as ia # 数据增强库,涵盖了大量对图像增强处理方法
import tensorflow as tf # 开源的,目前比较流行的机器学习框架
import imgaug.augmenters as iaa # imgaug的augmenters处理类
import matplotlib.pyplot as plt # 非常强大的数据可视化绘图工具
# 基于python开发的数字图片处理包
from skimage import transform
# 第三方图像处理库:图片核心处理、图片增强处理
from PIL import Image, ImageEnhance
# keras.utils中将类向量(整数)转换为one-hot编码
from keras.utils import to_categorical
# keras中内置的ImageDataGenerator图片生成器,实现图像批量增强
from keras.preprocessing.image import ImageDataGenerator
定义图像显示函数及常量,并展示演示的原始图片:
def img_show(img, rgb=False):
# 由于OpenCV读取图像的通道是BGR,而Matplotlib的通道是RGB,需要转换
if not rgb and len(img.shape) == 3:
(b, g, r) = cv2.split(img)
img = cv2.merge([r, g, b])
plt.imshow(img)
plt.axis("off") # 不显示坐标轴
plt.show()
IMG_HEIGHT = 224 # 图像高度
IMG_WIDTH = 224 # 图像宽度
IMG_FORMAT = "jpg"
# 猫十二训练集文件路径
BASE_DIR = r"/data/dataset/cat_12"
# 用来演示的某张猫图片
IMG_DIR = BASE_DIR + r"/imgehc/cat_org.jpg"
# 使用OpenCV读取演示图片
img_org = cv2.imread(IMG_DIR)
print("原始图像形状: ", img_org.shape)
img_show(img_org)
1、缩放
目前用的比较多的,最简单的处理就是用OpenCV的resize()函数直接进行缩放,函数原型如下,想了解更多,可以查看官方说明文档之resize(),这里重点说一下最后一个参数(插值方法,共有5种),当压缩一幅图像时,选择INTER_AREA(局部像素重采样)算法实现效果较好(可以避免波纹),当放大一幅图像时,通常选择INTER_CUBIC(双立方插值),但是速度较慢,或INTER_LINEAR(双线性插值法(默认))速度更快,效果也相对还不错
resize(
InputArray src, # 输入图像
OutputArray dst, # 输出图像;它的大小为dsize(非零时)或通过src.size(),fx和fy计算得出的大小;dst的类型与src相同
Size dsize, # 输出图像尺寸;如果等于零,则计算为dsize = Size(round(fxsrc.cols), round(fysrc.rows))
double fx = 0, # 沿水平轴的比例因子;当它等于0时,计算为(double)dsize.width/src.cols
double fy = 0, # 沿垂直轴的比例因子;当它等于0时,计算为(double)dsize.height/src.rows
int interpolation = INTER_LINEAR # 插值方法,具体请参见 InterpolationFlags
)
# 对演示图片进行缩放到(224, 224)大小,采用基于局部像素重采样的插值法
img_resize = cv2.resize(img_org, (IMG_WIDTH, IMG_HEIGHT), cv2.INTER_AREA)
print("resize后图像形状: ", img_resize.shape)
img_show(img_resize)
2、裁剪
裁剪也是一种常用的图片预处理方式,但会导致图片缺失不完整,本文会介绍三种实现方式,这里先介绍两种比较常用也相对比较简单好理解的(OpenCV、Pillow),如下,对原图进行中心区域(224, 224)裁剪
2.1、使用OpenCV进行裁剪
image[开始Y(H) : 结束Y(H), 开始X(W) : 结束X(W)] 以图片左上角为原点,裁剪返回图像副本
注意:开始值必须大于结束值;若不填或填写结束的Y和X超出原图的高、宽,则默认使用原图的高和宽
# 原始图像的高和宽
ORG_H, ORG_W = img_org.shape[0], img_org.shape[1]
# 目标裁剪后图像的高和宽(224, 224)
CROP_H, CROP_W = IMG_HEIGHT, IMG_WIDTH
## 1、使用OpenCV进行裁剪
crop_opencv = img_org[round((ORG_H - CROP_H) / 2):CROP_H + round((ORG_H - CROP_H) / 2),
round((ORG_W - CROP_W) / 2):CROP_W + round((ORG_W - CROP_W) / 2)]
print("OpenCV裁剪中心图像形状: ", crop_opencv.shape)
img_show(crop_opencv)
2.2、使用PIL.Image.crop进行裁剪
函数原型如下,想了解更多请参阅官方说明文档之PIL.Image.crop
Image.crop( # 返回此图像指定的矩形区域,box为一个四元组,代表裁剪矩形像素坐标
box = None # box = (左起点, 上起点, 右结点, 下结点)。注意:【*结点】必须大于【*起点】
)
注意,和OpenCV处理上不一样的是,*起点值可以为负,并且*结点值可以超出原图的高和宽,结果是超出部分以黑色背景进行填充。下面的章节 5.3任意移位 也会利用此特性进行图像的简单增强
## 2、使用PIL.Image.crop进行裁剪
# 先把numpy数组转换为Image对象
pil_img = Image.fromarray(img_org.astype('uint8')).convert('RGB')
crop_pillow = pil_img.crop((round((ORG_W - CROP_W) / 2), round((ORG_H - CROP_H) / 2),
CROP_W + round((ORG_W - CROP_W) / 2), CROP_H + round((ORG_H - CROP_H) / 2)))
# 注意,需要通过np.array(Image)转换为numpy数组
print("Pillow裁剪中心图像形状: ", np.array(crop_pillow).shape)
img_show(np.array(crop_pillow))
## 3、测试使用Pillow裁剪,超出原图高和宽部分以黑色背景填充
crop_pillow_test = pil_img.crop((200, 100, ORG_W + 30, ORG_H + 30))
print("Pillow裁剪超出原图形状: ", np.array(crop_pillow_test).shape)
img_show(np.array(crop_pillow_test))
OpenCV / Pillow 裁剪中心图像 (224, 224, 3) | Pillow裁剪超出原图(263, 330, 3) |
---|---|
3.画布填充
前面介绍的两种处理方式,缩放会导致拉伸变形,裁剪会导致图片缺失不完整,画布填充可以保证图像的完整性,下面用大家比较熟悉的OpenCV的copyMakeBorder函数来实现(更多替代函数实现方式,见下面章节)。如下为函数原型,详细可参阅官方说明文档之copyMakeBorder()
在图像周围形成边框,将源图像复制到目标图像的中间。复制的源图像左侧,右侧,上方和下方的区域将被外推像素填充
cv2.copyMakeBorder(
InputArray src, # 源图像
OutputArray dst, # 与src类型相同的目标图像
int top, # 顶部像素
int bottom, # 底部像素
int left, # 左边像素
int right, # 右边像素
int borderType, # 边框类型,具体参见BorderTypes。这里重点介绍四种(其余读者可自行尝试):
1、cv2.BORDER_CONSTANT:自定义常量填充
2、cv2.BORDER_REPLICATE:边界复制填充
3、cv2.BORDER_REFLECT:边界反射填充
3、cv2.BORDER_WRAP:边框包装填充
const Scalar & value = Scalar() # 且当边框类型为第1种时有效,表示填充常量
)
# big_pad=True:当目标图像高和宽均大于原图时,进行边缘填充
# big_pad=False:按照目标尺寸的最小缩放比例,先缩放,再进行边缘填充
# borderType=cv2.BORDER_CONSTANT:表示常量填充,borderValue为填充常量0~255(黑~白)
# borderType=cv2.BORDER_REPLICATE:边界复制填充
# borderType=cv2.BORDER_REFLECT:边界反射填充
# borderType=cv2.BORDER_WRAP:边框包装填充
def image_padding(image, target_shape, big_pad=True,
borderType=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0)):
# 目标尺寸大小
ph, pw = target_shape
# 原始图片尺寸
h, w, _ = image.shape
if big_pad and ph > h and pw > w: # 以原图为中心进行边缘填充
top = bottom = (ph - h) // 2 # 获取上、下填充尺寸
top += (ph - h) % 2 # 为保证目标大小,无法整除则上+1
left = right = (pw - w) // 2
left += (pw - w) % 2 # 为保证目标大小,同理左上+1
image_padded = cv2.copyMakeBorder(image, top, bottom, left, right,
borderType=borderType, value=borderValue)
else: # 最小比例缩放填充(大尺寸:高/宽比例变化较大的将被填充,小尺寸反之)
# 计算缩放后图片尺寸
scale = min(pw/w, ph/h) # 获取高/宽变化最小比例
nw, nh = int(scale * w), int(scale * h)
# 对原图按照目标尺寸的最小比例进行缩放
img_resized = cv2.resize(image, (nw, nh))
top = bottom = (ph - nh) // 2 # 获取上、下填充尺寸
top += (ph - nh) % 2 # 为保证目标大小,无法整除则上+1
left = right = (pw - nw) // 2
left += (pw - nw) % 2 # 为保证目标大小,同理左上+1
image_padded = cv2.copyMakeBorder(img_resized, top, bottom, left, right,
borderType=borderType, value=borderValue)
return image_padded
测试目标大尺寸的边缘填充、缩放填充及目标小尺寸的缩放填充
img_padded_big_t = image_padding(img_org, (400, 600), True)
print("目标大尺寸边缘填充形状: ", img_padded_big_t.shape)
img_show(img_padded_big_t)
img_padded_big_f = image_padding(img_org, (350, 600), False)
print("目标大尺寸缩放填充形状: ", img_padded_big_f.shape)
img_show(img_padded_big_f)
img_padded_small = image_padding(img_org, (224, 224))
print("目标小尺寸缩放填充形状: ", img_padded_small.shape)
img_show(img_padded_small)
目标大尺寸边缘填充 (400, 600, 3) | 目标大尺寸缩放填充 (350, 600, 3) | 目标小尺寸缩放填充 |
---|---|---|
测试不同的边框类型,以实现不同的填充效果,这里拿目标大尺寸的边缘填充演示
img_padded_big_rpc = image_padding(img_org, (400, 600), True, cv2.BORDER_REPLICATE)
print("目标大尺寸边缘填充-边界复制填充形状: ", img_padded_big_rpc.shape)
img_show(img_padded_big_rpc)
img_padded_big_rft = image_padding(img_org, (400, 600), True, cv2.BORDER_REFLECT)
print("目标大尺寸边缘填充-边界反射填充形状: ", img_padded_big_rft.shape)
img_show(img_padded_big_rft)
img_padded_big_wrap = image_padding(img_org, (400, 600), True, cv2.BORDER_WRAP)
print("目标大尺寸边缘填充-边框包装填充形状: ", img_padded_big_wrap.shape)
img_show(img_padded_big_wrap)
边界复制填充 (400, 600, 3) | 边界反射填充 (400, 600, 3) | 边框包装填充 (400, 600, 3) |
---|---|---|
数据增强总结
下面进入本文主题,数据增强,将先后介绍7种常用的图像增强处理方式,然后再介绍2种已经封装集成好的通用强大增强器
1、任意缩放
前面已经介绍了OpenCV的resize()函数,这里再引入一个,skimage的transform模块,按比例缩放rescale()函数,如下为函数原型,想了解更多请参阅官方说明文档之skimage.transform.rescale()。为了让大家看出来两者的差异(rescale启用抗锯齿),这里缩放到0.2倍
skimage.transform.rescale(
image, # ndarray 输入图像
scale, # {float,floats tuple} 比例因子,可以将单独的定义为(rows,cols [,…] [,dim])
… … # 省略部分参数
anti_aliasing=True # 启用抗锯齿,以免产生锯齿失真,指在缩小之前应用高斯滤波器来平滑图像
… … # 省略部分参数
)
scale_h, scale_w = 0.2, 0.2 # 定义高和宽的缩放比例
img_resize = cv2.resize(img_org, (round(img_org.shape[1] * scale_w),
round(img_org.shape[0] * scale_h)), cv2.INTER_AREA)
print("resize: ", img_resize.shape)
img_show(img_resize)
# 注意:因为原图是3通道图像,所以此处channels比例设置为1
img_scale = transform.rescale(img_org, scale=(scale_h, scale_w, 1), anti_aliasing=True)
print("rescale: ", img_scale.shape)
img_show(img_scale)
resize:(67, 100, 3) | rescale启用抗锯齿,应用高斯滤波器来平滑图像:(67, 100, 3) |
---|---|
普通的resize操作,碎片化比较严重,带点混叠伪影 | 而rescale启用抗锯齿后,再按比例缩放,比较平滑均匀 |
rescale参数只能按照一定比例进行缩放,实际中我们会有大量的样本,并且尺寸大小不一,需要缩放到指定大小
target_h, target_w = 166, 250 # 定义目标要缩放的高和宽
scale_h = target_h / img_org.shape[0] # 计算高的缩放比例
scale_w = target_w / img_org.shape[1] # 计算宽的缩放比例
img_scale_v2 = transform.rescale(img_org, scale=(scale_h, scale_w, 1), anti_aliasing=True)
print("rescale-目标高宽形状:", img_scale_v2.shape)
img_show(img_scale_v2)
2、角度翻转
这里总结了三种实现方式,最终效果都是一样的,相关函数/类不过多介绍,相信大家一看便懂。下面也会贴出对应函数/类在其官方文档的快速定位链接,想了解更多的可以参阅
2.1、cv2.flip()函数
如下为cv2.flip()函数原型,想了解更多请参阅官方说明文档之flip()
cv2.flip(
src, # 原始图像
flipCode # 0表示围绕x轴翻转,正值(例如1)表示围绕y轴翻转,负值(例如,-1)表示绕两个轴翻转
# 简单点说:flipCode=0:上下(垂直)翻转、flipCode>0:左右(水平)翻转、flipCode<0:180°(水平垂直)翻转
)
def flip_image_opencv(image_org):
# 图像数组
flip_imgs = []
# 上下(垂直)翻转
image_up_down_flip = cv2.flip(img_org, 0)
# 左右(水平)翻转
image_left_right_flip = cv2.flip(img_org, 1)
# 旋转180°(逆时针)
image_rot_180 = cv2.flip(img_org, -1)
flip_imgs.append(image_up_down_flip)
flip_imgs.append(image_left_right_flip)
flip_imgs.append(image_rot_180)
return flip_imgs
# 使用OpenCV.flip()对图像进行翻转操作
flip_imgs_opencv = flip_image_opencv(img_org)
tips = ["上下(垂直)翻转:", "左右(水平)翻转:", "旋转180°(逆时针):"]
for i in range(len(flip_imgs_opencv)):
print(tips[i])
img_show(flip_imgs_opencv[i])
2.2、PIL.Image.transpose()函数
如下为PIL.Image.transpose()函数原型,想了解更多请参阅官方说明文档之PIL.Image.transpose
转置图像(以90度为单位翻转或旋转)
image.transpose(
Image.* # 共6种选择,如下↓
)
Image.FLIP_TOP_BOTTOM:上下(垂直)翻转
Image.FLIP_LEFT_RIGHT:左右(水平)翻转
Image.TRANSPOSE:对角线旋转
Image.ROTATE_90:旋转90°(逆时针)
Image.ROTATE_180:旋转180°(逆时针)
Image.ROTATE_270:旋转270°(逆时针)
def flip_image_pillow(image_org):
image = Image.fromarray(img_org)
# 图像数组
flip_imgs = []
# 上下(垂直)翻转
image_up_down_flip = image.transpose(Image.FLIP_TOP_BOTTOM)
# 左右(水平)翻转
image_left_right_flip = image.transpose(Image.FLIP_LEFT_RIGHT)
# 对角线旋转
image_transpose = image.transpose(Image.TRANSPOSE)
# 旋转90°(逆时针)
image_rot_90 = image.transpose(Image.ROTATE_90)
# 旋转180°(逆时针)
image_rot_180 = image.transpose(Image.ROTATE_180)
# 旋转270°(逆时针)
image_rot_270 = image.transpose(Image.ROTATE_270)
flip_imgs.append(np.array(image_up_down_flip))
flip_imgs.append(np.array(image_left_right_flip))
flip_imgs.append(np.array(image_transpose))
flip_imgs.append(np.array(image_rot_90))
flip_imgs.append(np.array(image_rot_180))
flip_imgs.append(np.array(image_rot_270))
return flip_imgs
# 使用PIL.Image.transpose()对图像进行翻转操作
flip_imgs_pillow = flip_image_pillow(img_org)
tips = ["上下(垂直)翻转:", "左右(水平)翻转:", "对角线旋转:", "旋转90°(逆时针):", "旋转180°(逆时针):", "旋转270°(逆时针):"]
for i in range(len(flip_imgs_pillow)):
print(tips[i])
img_show(flip_imgs_pillow[i])
2.3、TensorFlow.image类
如下为tf.image类相关图像翻转函数说明,想了解更多请参阅官方说明文档之tf.image
tf.image.flip_up_down(image):垂直翻转图像(上下颠倒)
tf.image.flip_left_right(image):水平翻转图像(从左到右)
tf.image.transpose_image(image):通过交换高度和宽度尺寸来转置图像
tf.image.rot90(image, k=1):默认k=1,标量整数,表示图像逆时针旋转90度的次数
# 交互式环境下默认会话,便于使用.eval()
sess = tf.InteractiveSession()
def flip_image_tf(image_org):
# 图像数组
flip_imgs = []
# 上下(垂直)翻转
image_up_down_flip = tf.image.flip_up_down(image_org)
# 左右(水平)翻转
image_left_right_flip = tf.image.flip_left_right(image_org)
# 对角线旋转
image_transpose = tf.image.transpose_image(image_org)
# 旋转90°(逆时针)
image_rot_90 = tf.image.rot90(image_org, 1)
# 旋转180°(逆时针)
image_rot_180 = tf.image.rot90(image_org, 2)
# 旋转270°(逆时针)
image_rot_270 = tf.image.rot90(image_org, 3)
# tf.*返回的结果为Tensor,需要通过.eval()转换为ndarray
# 注意:若是通过cv2.flip处理的,则不需要调用.eval()
flip_imgs.append(image_up_down_flip.eval())
flip_imgs.append(image_left_right_flip.eval())
flip_imgs.append(image_transpose.eval())
flip_imgs.append(image_rot_90.eval())
flip_imgs.append(image_rot_180.eval())
flip_imgs.append(image_rot_270.eval())
return flip_imgs
# 使用tf.image类对图像进行翻转操作
flip_imgs_tf = flip_image_tf(img_org)
tips = ["上下(垂直)翻转:", "左右(水平)翻转:", "对角线旋转:", "旋转90°(逆时针):", "旋转180°(逆时针):", "旋转270°(逆时针):"]
for i in range(len(flip_imgs_tf)):
print(tips[i])
img_show(flip_imgs_tf[i])
上下(垂直)翻转 | 左右(水平)翻转 | 对角线旋转 |
---|---|---|
旋转270°(逆时针) | 旋转180°(逆时针) | 旋转90°(逆时针) |
3、任意旋转
角度翻转是对整张图片的外形操作,下面说一下,对图片内容的旋转操作,可大致分为两种,一是旋转之后超出部分缺失,二是旋转之后超出部分以色彩进行背景填充,前者图像尺寸保持不变,后者必定大于原图,如下通过两种方式结合实现
3.1、PIL.Image.rotate()
绕其中心逆时针旋转给定度数,返回此图像的旋转副本,函数原型如下,想了解更多请参阅官方说明文档之PIL.Image.rotate
Image.rotate(
angle, # 逆时针度数
resample=0, # 可选的重采样过滤器,可以是Image.NEAREST(最近邻)、Image.BILINEAR(线性插值)、Image.BICUBIC(三次样条插值)
expand=0 # 如果为true,则展开足够大以容纳整个旋转图像,如果为false或省略,则输出原图大小
)
# 对图像进行任意角度(0~360度)旋转
# angle:自定义逆时针旋转角度,默认None随机生成
# expand:默认False,旋转超出部分裁剪缺失;为True,则扩大包含
def random_rotation(image, angle=None, expand=False):
image = Image.fromarray(image)
# 如果旋转角度未传入,则随机生成
angle = (angle if angle != None else np.random.randint(1, 360))
return np.array(image.rotate(angle, Image.BILINEAR, expand))
3.2、cv2.warpAffine()、getRotationMatrix2D()
利用OpenCV实现仿射变换一般会涉及到warpAffine和getRotationMatrix2D两个函数,其中warpAffine可以实现一些简单的重映射,而getRotationMatrix2D可以获得2x3的旋转矩阵。函数原型如下,想了解更多请参阅官方说明文档之cv2.warpAffine()、cv2.getRotationMatrix2D()
cv2.getRotationMatrix2D( # 计算2D旋转的仿射矩阵
Point2f center, # 源图像中旋转的中心
double angle, # 旋转角度。正值表示逆时针旋转(假设坐标原点为左上角)
double scale # 各向同性的比例因子
)
cv2.warpAffine( # 对图像应用仿射变换
InputArray src, # 输入图像
… …,
InputArray M, # 2 × 3 转换矩阵
Size dsize, # 输出图像的大小
const Scalar & borderValue = Scalar() # 在边界不变的情况下使用的值,默认情况下为0
)
## 对图像进行任意角度(0~360度)旋转(超出部分不缺失)
# angle:自定义逆时针旋转角度,默认None随机生成
# borderValue:超出部分背景填充色彩,默认黑色(0~255|黑~白)
def rotate_bound_bg(image, angle=None, borderValue=(0, 0, 0)):
# 如果旋转角度未传入,则随机生成
angle = (angle if angle != None else np.random.randint(1, 360))
# 获取原始图像尺寸,及中心点
(h, w) = image.shape[:2]
(cX, cY) = (w // 2, h // 2)
# 获取旋转矩阵(angle正值表示逆时针),然后获取正弦和余弦
M = cv2.getRotationMatrix2D((cX, cY), angle, 1.0)
cos = np.abs(M[0, 0])
sin = np.abs(M[0, 1])
# 计算图像的新边界维数,并向上取整
nW = int((h * sin) + (w * cos)) + 1
nH = int((h * cos) + (w * sin)) + 1
# 调整旋转矩阵以考虑平移
M[0, 2] += (nW / 2) - cX
M[1, 2] += (nH / 2) - cY
# borderValue 缺失背景填充色彩,默认是黑色(0, 0 , 0)
return cv2.warpAffine(image, M, (nW, nH), borderValue = borderValue)
我们来看一下这两种不同的处理方式的实际效果
## 使用默认随机旋转角度,并且超出部分裁剪缺失
img_rotate_crop = random_rotation(img_org, angle=None, expand=False)
print("旋转后超出部分裁剪缺失形状: ", img_rotate_crop.shape)
img_show(img_rotate_crop)
## 指定旋转角度30°,并且超出部分以黑色背景填充
img_rotate_expand = random_rotation(img_org, angle=30, expand=True)
print("旋转后超出部分以黑色背景填充形状: ", img_rotate_expand.shape)
img_show(img_rotate_expand)
## 指定旋转角度30°,并且超出部分以白色背景填充
img_rotate_bound_bg = rotate_bound_bg(img_org, angle=30, borderValue=(255, 255, 255))
print("旋转后超出部分以白色背景填充形状: ", img_rotate_bound_bg.shape)
img_show(img_rotate_bound_bg)
超出部分裁剪缺失(333, 500, 3) | 超出部分以黑色背景填充(539, 600, 3) | 超出部分以白色背景填充(539, 600, 3) |
---|---|---|
4、任意裁剪
对于裁剪,前面已经介绍了两种实现方式(OpenCV、Pillow),这里介绍一下第三种,基于TensorFlow实现。当然,下面的裁剪示列,也会包含前两种的实现方式,以供参考
4.1、随机裁剪之tf.random_crop函数
随机地将张量裁剪为给定size的大小,需要的条件:value.shape >= size,例如,可以使用 size = [crop_height, crop_width, 3] 裁剪 RGB 图像。函数原型如下,想了解更多请参阅官方说明文档之tf.random_crop(注意,此函数在TF2.0移步到了tf.image.random_crop)
tf.random_crop( # TF2.0使用方式:tf.image.random_crop
value, # 向裁剪输入张量
size, # 一维张量,大小等级为 value
seed=None, # 整数.用于创建一个随机的种子
name=None # 此操作的名称(可选)
)
# 获取原始图像的高和宽
ORG_H, ORG_W = img_org.shape[0], img_org.shape[1]
# 目标裁剪后图像的高和宽
CROP_H, CROP_W = 300, 300
# 生成随机种子
seed = np.random.randint(1234)
# 进行随机裁剪
img_random_crop = tf.random_crop(img_org, size = [CROP_H, CROP_W, 3], seed = seed)
print("随机裁剪后图像形状: ", img_random_crop.shape)
img_show(img_random_crop.eval()) # 输出见下↓ 4.2.5 章节
4.2、指定位置裁剪的三种实现方式
首先介绍一下tf.image.crop_to_bounding_box函数,函数原型如下,将图像裁剪到指定的边界框:以点(offset_height, offset_width)作为左上角 -> 裁剪右下角矩形(target_height, target_width)。想了解更多请参阅官方说明文档之tf.image.crop_to_bounding_box
tf.image.crop_to_bounding_box(
image, # 形状为[batch, height, width, channels]的4-D张量,或形状为[height, width, channels]的3-D张量.
offset_height, # 输入中结果左上角的垂直坐标.
offset_width, # 输入中结果左上角的水平坐标.
target_height, # 结果的高度.
target_width # 结果的宽度.
)
4.2.1、裁剪左上角
# ====== TensorFlow实现 ======
img_crop_lt = tf.image.crop_to_bounding_box(img_org, 0, 0, CROP_H, CROP_W)
print("裁剪左上角图像形状-TF实现: ", img_crop_lt.shape)
img_show(img_crop_lt.eval())
# ======== OpenCV实现 ========
img_crop_lt_opencv = img_org[0:CROP_H, 0:CROP_W]
print("裁剪左上角图像形状-OpenCV实现: ", img_crop_lt_opencv.shape)
img_show(img_crop_lt_opencv)
# ======== Pillow实现 ========
pil_img = Image.fromarray(img_org.astype('uint8')).convert('RGB')
img_crop_lt_pillow = pil_img.crop(box = (0, 0, CROP_W, CROP_H))
print("裁剪左上角图像形状-Pillow实现: ", np.array(img_crop_lt_pillow).shape)
img_show(np.array(img_crop_lt_pillow))
4.2.2、裁剪右上角
# ====== TensorFlow实现 ======
img_crop_rt = tf.image.crop_to_bounding_box(img_org, 0, ORG_W - CROP_W, CROP_H, CROP_W)
print("裁剪右上角图像形状-TF实现: ", img_crop_rt.shape)
img_show(img_crop_rt.eval())
# ======== OpenCV实现 ========
img_crop_rt_opencv = img_org[0:CROP_H, ORG_W - CROP_W:ORG_W]
print("裁剪右上角图像形状-OpenCV实现: ", img_crop_rt_opencv.shape)
img_show(img_crop_rt_opencv)
# ======== Pillow实现 ========
pil_img = Image.fromarray(img_org.astype('uint8')).convert('RGB')
img_crop_rt_pillow = pil_img.crop(box = (ORG_W - CROP_W, 0, ORG_W, CROP_H))
print("裁剪右上角图像形状-Pillow实现: ", np.array(img_crop_rt_pillow).shape)
img_show(np.array(img_crop_rt_pillow))
4.2.3、裁剪左下角
# ====== TensorFlow实现 ======
img_crop_lb = tf.image.crop_to_bounding_box(img_org, ORG_H - CROP_H, 0, CROP_H, CROP_W)
print("裁剪左下角图像形状-TF实现: ", img_crop_lb.shape)
img_show(img_crop_lb.eval())
# ======== OpenCV实现 ========
img_crop_lb_opencv = img_org[ORG_H - CROP_H:ORG_H, 0:CROP_W]
print("裁剪左下角图像形状-OpenCV实现: ", img_crop_lb_opencv.shape)
img_show(img_crop_lb_opencv)
# ======== Pillow实现 ========
pil_img = Image.fromarray(img_org.astype('uint8')).convert('RGB')
img_crop_lb_pillow = pil_img.crop(box = (0, ORG_H - CROP_H, CROP_W, ORG_H))
print("裁剪左下角图像形状-Pillow实现: ", np.array(img_crop_lb_pillow).shape)
img_show(np.array(img_crop_lb_pillow))
4.2.4、裁剪右下角
# ====== TensorFlow实现 ======
img_crop_rb = tf.image.crop_to_bounding_box(img_org, ORG_H - CROP_H, ORG_W - CROP_W, CROP_H, CROP_W)
print("裁剪右下角图像形状-TF实现: ", img_crop_rb.shape)
img_show(img_crop_rb.eval())
# ======== OpenCV实现 ========
img_crop_rb_opencv = img_org[ORG_H - CROP_H:ORG_H, ORG_W - CROP_W:ORG_W]
print("裁剪右下角图像形状-OpenCV实现: ", img_crop_rb_opencv.shape)
img_show(img_crop_rb_opencv)
# ======== Pillow实现 ========
pil_img = Image.fromarray(img_org.astype('uint8')).convert('RGB')
img_crop_rb_pillow = pil_img.crop(box = (ORG_W - CROP_W, ORG_H - CROP_H, ORG_W, ORG_H))
print("裁剪右下角图像形状-Pillow实现: ", np.array(img_crop_rb_pillow).shape)
img_show(np.array(img_crop_rb_pillow))
4.2.5、裁剪中心块
# ====== TensorFlow实现 ======
img_crop_ct = tf.image.crop_to_bounding_box(img_org, round((ORG_H - CROP_H) / 2),
round((ORG_W - CROP_W) / 2), CROP_H, CROP_W)
print("裁剪中心块图像形状-TF实现: ", img_crop_ct.shape)
img_show(img_crop_ct.eval())
# ======== OpenCV实现 ========
img_crop_ct_opencv = img_org[round((ORG_H - CROP_H) / 2):CROP_H + round((ORG_H - CROP_H) / 2),
round((ORG_W - CROP_W) / 2):CROP_W + round((ORG_W - CROP_W) / 2)]
print("裁剪中心块图像形状-OpenCV实现: ", img_crop_ct_opencv.shape)
img_show(img_crop_ct_opencv)
# ======== Pillow实现 ========
pil_img = Image.fromarray(img_org.astype('uint8')).convert('RGB')
img_crop_ct_pillow = pil_img.crop(box = (round((ORG_W - CROP_W) / 2), round((ORG_H - CROP_H) / 2),
CROP_W + round((ORG_W - CROP_W) / 2), CROP_H + round((ORG_H - CROP_H) / 2)))
print("裁剪中心块图像形状-Pillow实现: ", np.array(img_crop_ct_pillow).shape)
img_show(np.array(img_crop_ct_pillow))
裁剪左上角 (300, 300, 3) | 随机裁剪 (300, 300, 3) | 裁剪右上角 (300, 300, 3) |
---|---|---|
裁剪左下角 (300, 300, 3) | 裁剪中心块 (300, 300, 3) | 裁剪右下角 (300, 300, 3) |
小结:虽然这几种方法都能实现同样效果的裁剪,但是性能上差异确比较大,实际应用需谨慎。经过实际测试,性能由高到低排序为:OpenCV > Pillow > TensorFlow,测试一万次平均耗时如下:
- OpenCV:0.003099 毫秒
- Pillow:0.034809 毫秒
- TensorFlow:8.407354 毫秒
- TensorFlow之随机裁剪:15.847445 毫秒
可以发现,OpenCV的性能简直不要太好,基于TensorFlow的随机裁剪,耗时最为严重,差不多是OpenCV的5千多倍,实际中我们需要进行大批量的计算,这个差异将会扩大无数倍。所以我们首选使用OpenCV就好了,下面就用OpenCV替代TensorFlow实现随机裁剪(下面也会用到):
x = np.random.randint(0, ORG_W - CROP_W + 1) # 随机生成横向起始点
y = np.random.randint(0, ORG_H - CROP_H + 1) # 随机生成纵向起始点
img_random_crop2 = img_org[y:(y + CROP_H), x:(x + CROP_W)]
print("基于OpenCV随机裁剪: ", img_random_crop2.shape)
img_show(img_random_crop2) # 这里省略输出展示...
5、任意移位
最开始提到,人的视觉具有平移不变性,现实中,目标图像,肯定是分布在图片的不同位置,所以需要让我们的模型学习到这些变化情况,已适应提高模型的泛化能力。同样的,这里总结三种实现方式(TensorFlow、OpenCV、Pillow)
5.1、tf.image.pad_to_bounding_box函数
用零填充到指定的height和width,在顶部增加了零的offset_height行,在左侧添加了零的offset_width列,然后用0填充底部和右侧的图像,直到它具有target_height,target_width等维度。如果此操作offset_*为零且原图像大小和目标大小一致,则不会执行任何操作。函数原型如下,想了解更多请参阅官方说明文档之tf.image.pad_to_bounding_box
tf.image.pad_to_bounding_box(
image, # 形状为[batch, height, width, channels]的4-D张量,或形状为[height, width, channels]的3-D张量.
offset_height, # 在顶部添加零的行数.
offset_width, # 在左侧添加的零的列数.
target_height, # 输出图像的高度.
target_width # 输出图像的宽度.
)
## ====== 左上角方向,移位100像素值 ======
# 获取原始图像高和宽
ORG_H, ORG_W = img_org.shape[0], img_org.shape[1]
# 设置左右上下填充大小
pad_left, pad_right, pad_top, pad_bottom = 0, 100, 0, 100
# 用0(黑色)进行边缘填充
img_pad_box = tf.image.pad_to_bounding_box(img_org, pad_top, pad_left, ORG_H + pad_top + pad_bottom,
ORG_W + pad_left + pad_right)
print("指定边缘填充后图像形状: ", img_pad_box.shape)
img_show(img_pad_box.eval())
# 再将图像从填充方向裁剪回原图像大小,最终得到移位
img_pad_crop = tf.image.crop_to_bounding_box(img_pad_box, pad_bottom, pad_right, ORG_H, ORG_W)
print("裁剪后得到移位原图大小形状: ", img_pad_crop.shape)
img_show(img_pad_crop.eval())
指定边缘填充后图像 (433, 600, 3) | 裁剪后得到移位原图大小 (333, 500, 3) |
---|---|
5.2、cv2.copyMakeBorder函数
在前面章节 图像处理-画布填充 有过详细介绍,此函数可以在图像周围形成边框,并支持多种填充方式,比如自定义指定像素值,既然可以填充,那我们就可以利用它来实现移位
## ====== 右上角方向,移位100像素值 ======
# 设置左右上下填充大小
pad_left, pad_right, pad_top, pad_bottom = 100, 0, 0, 100
# 用0(黑色)进行边缘填充
image_pad_cmb = cv2.copyMakeBorder(img_org, pad_top, pad_bottom, pad_left, pad_right,
cv2.BORDER_CONSTANT, value = (0, 0, 0))
print("指定边缘填充后图像形状: ", image_pad_cmb.shape)
img_show(image_pad_cmb)
# 再将图像从填充方向裁剪回原图像大小,最终得到移位
image_pad_cmb_crop = image_pad_cmb[pad_bottom:ORG_H + pad_bottom, pad_right:ORG_W + pad_right]
print("裁剪后得到移位原图大小形状: ", image_pad_cmb_crop.shape)
img_show(image_pad_cmb_crop)
指定边缘填充后图像 (433, 600, 3) | 裁剪后得到移位原图大小 (333, 500, 3) |
---|---|
5.3、PIL.Image.crop函数
在前面章节 图像处理-裁剪 已经有过详细介绍,同上原理,虽然此函数是一个裁剪函数,但我们可以利用此函数的特性,其超出原图大小部分将以0(黑色)进行填充,来实现移位
## ====== 正下方向,移位100像素值 ======
# 设置左右上下填充大小
pad_left, pad_right, pad_top, pad_bottom = 0, 0, 100, 0
# 先把numpy数组转换为Image对象
pil_img = Image.fromarray(img_org.astype('uint8')).convert('RGB')
# Image.crop(box=(左起点, 上起点, 右结点, 下结点))
image_pad_pillow = pil_img.crop(box = (0, -pad_top, ORG_W, ORG_H))
# 注意,需要np.array(Image)转换为numpy数组
image_pad_pillow = np.array(image_pad_pillow)
print("指定边缘填充后图像形状: ", image_pad_pillow.shape)
img_show(image_pad_pillow)
# 再将图像从填充方向裁剪回原图像大小,最终得到移位
image_pad_pillow_crop = image_pad_pillow[0:ORG_H, 0:ORG_W]
print("裁剪后得到移位原图大小形状: ", image_pad_pillow_crop.shape)
img_show(image_pad_pillow_crop)
指定边缘填充后图像 (433, 600, 3) | 裁剪后得到移位原图大小 (333, 500, 3) |
---|---|
6、高斯噪声
所谓高斯噪声是指它的概率密度函数服从高斯分布(即正态分布)的一类噪声,在图像上常表现为引起较强视觉效果的平均分布的孤立像素点或像素块。在神经网络训练过程中,过拟合(Overfitting)经常发生在对无用高频特征的过渡学习上。添加适量的噪音,可以有效的扭曲失真高频特征,从而减弱其对模型的影响,提升模型的学习和泛化能力。
6.1、基于TensorFlow实现
从正态分布中输出随机值,想了解更多请参阅=>官方说明文档
tf.random_normal(
shape, # 输出张量的形状,必选
mean=0.0, # 正态分布的均值,默认为0
stddev=1.0, # 正态分布的标准差,默认为1.0
dtype=tf.float32, # 输出的类型,默认为tf.float32
seed=None, # 随机数种子,一个整数,当设置之后,每次生成的随机数都一样
name=None # 操作的名称
)
计算张量的和,返回 x + y
tf.add(x, y, name=None) 想了解更多请参阅官方说明文档 tf.add
tf.math.add(x, y, name=None) 想了解更多请参阅官方说明文档之 tf.math.add
# mean: 正态分布的均值,[-255(接近黑色) ~ 0(原图亮度) ~ 255(接近白色)]
# sigma: 正态分布的标准差,可为负数,其绝对值越大,添加噪声越严重
def gaussian_noise_tf(image, mean=0, sigma=10):
# 从正态分布中输出随机值
noise = tf.random_normal(shape=image.shape, mean=mean, stddev=sigma, dtype=tf.float32)
noise_img = tf.add(image.astype(np.float32), noise).eval()
return noise_img
6.2、基于Numpy实现
从正态(高斯)分布中抽取随机样本,想了解更多请参阅=>官方说明文档
numpy.random.normal(
loc=0.0, # 正态分布的均值,默认为0
scale=1.0, # 正态分布的标准差,默认为1.0
size=None # 输出形状,int或int元组,可选
)
计算张量的和,返回 x + y
numpy.add(*args, **kwargs) 想了解更多请参阅官方说明文档之 numpy.add
# mean: 正态分布的均值,[-255(接近黑色) ~ 0(原图亮度) ~ 255(接近白色)]
# sigma: 正态分布的标准差,必须大于0,其值越大,添加噪声越严重
def gaussian_noise_np(image, mean=0, sigma=10):
# 从正态(高斯)分布中抽取随机样本
noise = np.random.normal(loc=mean, scale=sigma, size=image.shape)
noise_img = np.add(img_org.astype(np.float32), noise)
return noise_img
6.3、噪声增强效果
6.3.1、相同mean不同sigma
下面我们来看一下不同的 sigma 带来的噪声增强效果(此处mean固定为0)
img_gaussian_tf_s10 = gaussian_noise_tf(img_org, mean = 0, sigma = 10)
img_gaussian_tf_s30 = gaussian_noise_tf(img_org, mean = 0, sigma = 30)
img_gaussian_tf_s50 = gaussian_noise_tf(img_org, mean = 0, sigma = 50)
print("高斯噪声-TF-S10: ", img_gaussian_tf_s10.shape)
img_show(img_gaussian_tf_s10 / 255)
print("高斯噪声-TF-S30: ", img_gaussian_tf_s30.shape)
img_show(img_gaussian_tf_s30 / 255)
print("高斯噪声-TF-S50: ", img_gaussian_tf_s50.shape)
img_show(img_gaussian_tf_s50 / 255)
高斯噪声-TF-S10(sigma=10) | 高斯噪声-TF-S30(sigma=30) | 高斯噪声-TF-S50(sigma=50) |
---|---|---|
6.3.2、相同sigma不同mean
下面我们来看一下不同的 mean 带来的亮度增强效果(此处sigma固定为30)
## ======== 正态分布的均值,逐渐增大(亮度增强) ========
img_gaussian_np_m10 = gaussian_noise_np(img_org, mean = 10, sigma = 30)
img_gaussian_np_m30 = gaussian_noise_np(img_org, mean = 30, sigma = 30)
img_gaussian_np_m50 = gaussian_noise_np(img_org, mean = 50, sigma = 30)
print("高斯噪声-NP-M10: ", img_gaussian_np_m10.shape)
img_show(img_gaussian_np_m10 / 255)
print("高斯噪声-NP-M30: ", img_gaussian_np_m30.shape)
img_show(img_gaussian_np_m30 / 255)
print("高斯噪声-NP-M50: ", img_gaussian_np_m50.shape)
img_show(img_gaussian_np_m50 / 255)
## ======== 正态分布的均值,逐渐减小(暗度增强) ========
img_gaussian_np_mF10 = gaussian_noise_np(img_org, mean = -10, sigma = 30)
img_gaussian_np_mF30 = gaussian_noise_np(img_org, mean = -30, sigma = 30)
img_gaussian_np_mF50 = gaussian_noise_np(img_org, mean = -50, sigma = 30)
print("高斯噪声-NP-MF10: ", img_gaussian_np_mF10.shape)
img_show(img_gaussian_np_mF10 / 255)
print("高斯噪声-NP-MF30: ", img_gaussian_np_mF30.shape)
img_show(img_gaussian_np_mF30 / 255)
print("高斯噪声-NP-MF50: ", img_gaussian_np_mF50.shape)
img_show(img_gaussian_np_mF50 / 255)
高斯噪声-NP-M10(mean=10) | 高斯噪声-NP-M30(mean=30) | 高斯噪声-NP-M50(mean=50) |
---|---|---|
高斯噪声-NP-MF10(mean=-10) | 高斯噪声-NP-MF30(mean=-30) | 高斯噪声-NP-MF50(mean=-50) |
注意:当设置正态分布的均值(mean)和标准差(sigma)均等于0时,不会对图像做任何改变
7、色彩增强
在上一小结中,当设置sigma=0时(无噪声),可以通过改变mean来实现简单的亮度增强和变暗效果,但在实际彩色图像中,没有这么简单,下面我们通过PIL.ImageEnhance模块实现对图像的亮度、色彩平衡、对比度、清晰度等进行增强
7.1、亮度调整
使用实现类为PIL.ImageEnhance.Brightness,定义如下:
ImageEnhance.Brightness(image).enhance(brightness)
此类可用于控制图像的亮度。增强因子0.0给出黑色图像。1.0的系数给出原始图像。
## 调整图像亮度,如果指定亮度值未传入,则随机生成
# [0.0(黑色) ~ 1.0(原图) ~ 255.0+(接近白色)]
def random_brightness(image, brightness=None):
image = Image.fromarray(image.astype('uint8'))
brightness = (brightness if brightness != None
else np.random.uniform(0.5, 1.5))
return np.array(ImageEnhance.Brightness(image).enhance(brightness))
# 改变亮度值,输出不同效果
brightness = [0, 0.2, 0.6, 0.8, 1, 1.2, 1.6, 1.8]
for b in brightness:
img_random_brightness_x = random_brightness(img_org, b)
print("调整图像亮度值=[" + str(b) + "]: ", img_random_brightness_x.shape)
img_show(img_random_brightness_x)
调整图像亮度值=[0] | 调整图像亮度值=[0.2] | 调整图像亮度值=[0.6] | 调整图像亮度值=[0.8] |
---|---|---|---|
调整图像亮度值=[1] | 调整图像亮度值=[1.2] | 调整图像亮度值=[1.6] | 调整图像亮度值=[1.8] |
7.2、色彩平衡
使用实现类为PIL.ImageEnhance.Color,定义如下:
ImageEnhance.Color(image).enhance(color)
此类可用于调整图像的色彩平衡,其方式类似于彩色电视机上的控件。增强因子0.0产生黑白图像。1.0的系数给出原始图像。
## 调整图像色彩平衡,如果指定色彩值未传入,则随机生成
# 其方式类似于我们看过的黑白电视、彩色电视机上的控件
# [负无穷(反彩) ~ 0.0(黑白) ~ 1.0(原图) ~ 正无穷(强彩)]
def random_color(image, color=None):
image = Image.fromarray(image.astype('uint8'))
color = (color if color != None
else np.random.uniform(0.5, 1.5))
return np.array(ImageEnhance.Color(image).enhance(color))
# 改变色彩平衡值,输出不同效果
colors = [0, 1, 2, 3, -1, -2, -3, -4]
for c in colors:
img_random_color_x = random_color(img_org, c)
print("调整图像色彩平衡值=[" + str(c) + "]: ", img_random_color_x.shape)
img_show(img_random_color_x)
调整图像色彩平衡值=[0] | 调整图像色彩平衡值=[1] | 调整图像色彩平衡值=[2] | 调整图像色彩平衡值=[3] |
---|---|---|---|
调整图像色彩平衡值=[-1] | 调整图像色彩平衡值=[-2] | 调整图像色彩平衡值=[-3] | 调整图像色彩平衡值=[-4] |
7.3、对比度
使用实现类为PIL.ImageEnhance.Contrast,定义如下:
ImageEnhance.Contrast(image).enhance(contrast)
此类可用于控制图像的对比度,类似于电视机上的对比度控制。增强因子0.0给出纯灰色图像。1.0的系数给出原始图像。
## 调整图像对比度,如果指定对比值未传入,则随机生成
# 其方式类似于类似于电视机上的对比度控制(灰度反差)
# [负无穷(反比) ~ 0.0(纯灰色) ~ 1.0(原图) ~ 正无穷(强比)]
def random_contrast(image, contrast=None):
image = Image.fromarray(image.astype('uint8'))
contrast = (contrast if contrast != None
else np.random.uniform(0.5, 1.5))
return np.array(ImageEnhance.Contrast(image).enhance(contrast))
# 改变对比度值,输出不同效果
contrasts = [0, 1, 2, 3, -1, -2, -3, -4]
for c in contrasts:
img_random_contrast_x = random_contrast(img_org, c)
print("调整图像对比度值=[" + str(c) + "]: ", img_random_contrast_x.shape)
img_show(img_random_contrast_x)
调整图像对比度值=[0] | 调整图像对比度值=[1] | 调整图像对比度值=[2] | 调整图像对比度值=[3] |
---|---|---|---|
调整图像对比度值=[-1] | 调整图像对比度值=[-2] | 调整图像对比度值=[-3] | 调整图像对比度值=[-4] |
7.4、清晰度
使用实现类为PIL.ImageEnhance.Sharpness,定义如下:
ImageEnhance.Sharpness(image).enhance(sharpness)
此类可用于调整图像的清晰度。增强因子0.0给出模糊的图像,增强因子1.0给出原始图像,增强因子2.0给出清晰的图像。
## 调整图像清晰度,如果指定清晰值未传入,则随机生成
# [负无穷(强模糊) ~ 1.0(原图) ~ 正无穷+(强清晰)]
def random_sharpness(image, sharpness=None):
image = Image.fromarray(image.astype('uint8'))
sharpness = (sharpness if sharpness != None
else np.random.uniform(0.5, 1.5))
return np.array(ImageEnhance.Sharpness(image).enhance(sharpness))
# 改变对清晰度值,输出不同效果
sharpness = [1, 2, 3, 4, -1, -2, -3, -4]
for s in sharpness:
img_random_sharpness_x = random_sharpness(img_org, s)
print("调整图像清晰度值=[" + str(s) + "]: ", img_random_sharpness_x.shape)
img_show(img_random_sharpness_x)
调整图像清晰度值=[1] | 调整图像清晰度值=[2] | 调整图像清晰度值=[3] | 调整图像清晰度值=[4] |
---|---|---|---|
调整图像清晰度值=[-1] | 调整图像清晰度值=[-2] | 调整图像清晰度值=[-3] | 调整图像清晰度值=[-4] |
8、基于Keras的ImageDataGenerator进行图像增强
前面一共介绍了常用的七种增强处理方式,相信大家已经有所了解。在实际中,我们往往需要批量的并且同时使用多种增强方式进行数据增强,keras为我们提供了一站式服务,在keras.preprocessing.image模块下
8.1、ImageDataGenerator类
类原型如下,想了解更多请参阅官方说明文档之ImageDataGenerator
tf.keras.preprocessing.image.ImageDataGenerator(
featurewise_center=False, # 布尔值。将输入数据的均值设置为 0,逐特征进行
samplewise_center=False, # 布尔值。将每个样本的均值设置为 0
featurewise_std_normalization=False, # 布尔值。将输入除以数据标准差,逐特征进行
samplewise_std_normalization=False, # 布尔值。将每个输入除以其标准差
zca_whitening=False, # 布尔值。是否应用 ZCA 白化
zca_epsilon=1e-06, # ZCA 白化的 epsilon 值,默认为 1e-6
rotation_range=0, # 旋转角度值(0~180),表示图像随机旋转的角度范围
width_shift_range=0.0, # 浮点数、一维数组或整数。图像在水平方向上平移的范围
height_shift_range=0.0, # 浮点数、一维数组或整数。图像在垂直方向上平移的范围
brightness_range=None, # 元组或两个浮点数的列表。表示随机选择亮度偏移值的范围
shear_range=0.0, # 浮点数。随机错切变换的角度(逆时针)
zoom_range=0.0, # 浮点数 或 [lower, upper]。随机缩放范围
channel_shift_range=0.0, # 浮点数。随机通道转换的范围
fill_mode=“nearest”, # 用于填充新创建像素的模式[constant、nearest、reflect or wrap]
cval=0.0, # 浮点数或整数。用于边界之外的点的值,当 fill_mode = “constant” 时
horizontal_flip=False, # 布尔值。随机水平翻转
vertical_flip=False, # 布尔值。随机垂直翻转
rescale=None, # 重缩放因子。默认为 None。如果是 None 或 0,不进行缩放,否则将数据乘以所提供的值
preprocessing_function=None, # 自定义预处理函数。作用于其他改变之前运行
data_format=None, # 图像数据格式,“ channels_first”或“ channels_last”
validation_split=0.0, # 浮点数。保留划分出验证的图像的比例(取值0~1)
dtype=None # 生成批量数组使用的数据类型
)
我们来看一个Demo示列,这里引用《Python深度学习》一书作者使用的参数配置
test_gen = ImageDataGenerator(
rotation_range = 40, # 旋转角度值,表示图像随机旋转[0~40]度
width_shift_range = 0.2, # 图像在水平方向上[左/右]平移的范围(相对于总宽度的比例)
height_shift_range = 0.2, # 图像在垂直方向上[上/下]平移的范围(相对于总高度的比例)
shear_range = 0.2, # 随机错切变换的角度(逆时针)
zoom_range = 0.2, # 图像随机缩放的范围
horizontal_flip = True, # 图像随机水平翻转
fill_mode = "nearest" # 用最近邻创建新像素模式
)
上面只是构造初始化了一个ImageDataGenerator类的对象,下面我们来看两个应用增强函数
8.2、ImageDataGenerator.flow()
函数原型如下,想了解更多请参阅官方说明文档之ImageDataGenerator.flow()
获取数据和标签数组,生成一批增强数据
ImageDataGenerator.flow(
x, # 样本数据,秩为4的Numpy矩阵或元组。对于灰度数据channels=1,RGB数据channels=3
y=None, # 样本数据对应的标签
batch_size=32, # 每次增强批次大小(默认为 32)
shuffle=True, # 是否打乱样本增强 (默认为 True)
sample_weight=None, # 样本权重
seed=None, # 随机种子(默认为 None)
save_to_dir=None, # 指定保存生成增强图片的目录(用于可视化,默认为 None)
save_prefix="", # 保存图片的文件名前缀(仅当 save_to_dir 设置时可用)
save_format=“png”, # “png”, “jpeg” 之一(仅当 save_to_dir 设置时可用),默认:“png”
subset=None, # 数据子集 (“training” 或 “validation”),如果在ImageDataGenerator中设置了validation_split
)
返回:一个生成元组 (x, y) 的 Iterator,其中 x 是图像数据的 Numpy 数组(在单张图像输入时),或 Numpy 数组列表(在多个输入时),y 是对应的标签的 Numpy 数组
为了演示,我们先读取样本集,并按照比例统一缩放到固定大小333x500
样本存放路径:…/imgehc/flow_gen/ => 目录下文件:1.jpg 2.jpg 3.jpg
x_batch = [] # 演示样本集合
y_batch = [] # 演示样本标签
flow_dir = os.path.join(BASE_DIR, "imgehc/flow_gen/")
for fname in os.listdir(flow_dir):
img = cv2.imread(os.path.join(flow_dir, fname))
scale_h = 333 / img.shape[0] # 计算高的缩放比例(目标高度333)
scale_w = 500 / img.shape[1] # 计算宽的缩放比例(目标宽度500)
img_scale = transform.rescale(img, scale=(scale_h, scale_w, 1))
x_batch.append(img_scale)
y_batch.append(fname.split(".")[0])
x_batch = np.array(x_batch)
y_batch = np.array(y_batch)
print("演示样本集合:", x_batch.shape) # 输出:(3, 333, 500, 3)
print("演示样本标签:", y_batch) # 输出:[1 2 3]
演示样本-Y[1] | 演示样本-Y[2] | 演示样本-Y[3] |
---|---|---|
接着上面初始化的增强对象(test_gen),我们调用flow()函数获得一个增强器
# 调用flow函数,传入样本及标签
generator_flow = test_gen.flow(
x = x_batch, # 样本数据
y = y_batch, # 样本标签
batch_size = 3, # 最终效果为min(batch_size=3, x_count=3)得3
save_to_dir = os.path.join(BASE_DIR, "imgehc/flow_save/"),
save_format = IMG_FORMAT # 文章开头处定义的常量值"jpg"
)
注意:
- 此处的样本x_batch,必须是一批尺寸大小统一的图片
- 此处的save_to_dir,必须是已存在的文件夹,否则报错
上面生成的增强器(generator_flow)是一个具有元组 (x, y) 的 Iterator,我们可以遍历它
epoch = 0 # 生成器执行生成轮数
for gx_batch, gy_batch in generator_flow:
for c in range(len(gx_batch)):
print("生成器-EPOCH[%d]-IDX[%d]-Y[%d]" % (epoch + 1, c + 1, gy_batch[c]))
img_show(gx_batch[c]) # 像素值:0~1必须是浮点数,0~255必须为整数,此处无需处理
cv2.imwrite(BASE_DIR + r"/imgehc/flow_save/EPOCH[" + str(epoch + 1) + "]-IDX[" + str(c + 1)
+ "]-Y[" + str(gy_batch[c]) + "]" + ".jpg", gx_batch[c] * 255)
epoch += 1
if epoch == 3: # 控制生成批次数量,否则将无限循环
break
注意:
- 通过flow增强函数,生成的图片为BGR格式,使用Matplotlib展示图片时需要转换为RGB格式
- 通过flow增强函数,生成的图片像素值为0~1,如果是通过OpenCV保存图片,则需要*255,并且它会自动取整
- 值得再次强调的是,Matplotlib展示图片时,像素值必须为0~1的浮点数 或者 0~255的整数,通常采用 /255 或 .astype(int)
- 重点注意,通过flow增强函数,如果设置了save_to_dir,增强器自己保存的图片格式为BGR,而在我们的电脑上格式统一为RGB,你所看到的将错位,这个时候问题来了,如果我们使用OpenCV或者PIL再次读取时,都将和我们的预期恰好相反…而如果我们通过遍历迭代器拿到生成图片后,自己通过OpenCV保存(需要乘以255),则没有这个问题
另外,生成器会无限循环生成增强图片,所以我们需要手动跳出。以下为执行一次按顺序输出的3轮9张增强图片:
EPOCH[1]-IDX[1]-Y[2] | EPOCH[1]-IDX[2]-Y[1] | EPOCH[1]-IDX[3]-Y[3] |
---|---|---|
EPOCH[2]-IDX[1]-Y[1] | EPOCH[2]-IDX[2]-Y[3] | EPOCH[2]-IDX[3]-Y[2] |
EPOCH[3]-IDX[1]-Y[1] | EPOCH[3]-IDX[2]-Y[2] | EPOCH[3]-IDX[3]-Y[3] |
可以发现,每一轮增强输出顺序是随机打乱的(默认 shuffle=True)。因为flow函数设置的batch_size=3,恰好等于样本数量,所以每一轮增强3张图片,且没有重复。当batch_size取值不同,则情况有所不同,总结如下:
-
当batch_size小于样本数量时,如果batch_size不能被样本数量整除,例如batch_size=2,3/2余1,则每一轮增强的输出有2张或1张的情况。如果batch_size=1,可以整除,则每轮正常按照batch_size大小增强输出
-
当batch_size等于样本数量时,和第1点同理,可以被样本数量整除,每一轮正常按照batch_size大小增强输出,且同一轮不会有重复
-
当batch_size大于样本数量时,前面代码注释已经有提到,实际每一轮增强输出为样本数量,效果等同于batch_size=3,计算公式可为:batch_size = min(batch_size, 样本数量)
8.3、ImageDataGenerator.flow_from_directory()
函数原型如下,想了解更多请参阅官方说明文档之ImageDataGenerator.flow_from_directory()
获取数据和标签数组,生成一批增强数据
ImageDataGenerator.flow_from_directory(
directory, # 目标目录的路径。每个类应该包含一个子目录
target_size=(256, 256), # 整数元组 (height, width),默认:(256, 256)。目标图像调整尺寸
color_mode=“rgb”, # “grayscale”, “rbg” 之一。默认:“rgb”。图像是否被转换成 1 或 3 个颜色通道
classes=None, # 可选的类的子目录列表(例如 [‘dogs’, ‘cats’])。默认:None。如果未提供,
# 类的列表将自动从 directory 下的 子目录名称/结构 中推断出来,其中每个子目录都将被作为不同的类
class_mode=“categorical”, # “categorical”, “binary”, “sparse”, “input” 或 None 之一。默认:“categorical”。决定返回的标签数组的类型
# (1)、 “categorical” 将是 2D one-hot 编码标签,
# (2)、 “binary” 将是 1D 二进制标签,“sparse” 将是 1D 整数标签
batch_size=32, # 每次增强一批数据的大小(默认 32)
shuffle=True, # 是否打乱数据(默认 True)
seed=None, # 可选随机种子,用于打乱和转换
save_to_dir=None, # 指定保存生成增强图片的目录(用于可视化,默认为 None)
save_prefix="", # 保存图片的文件名前缀(仅当 save_to_dir 设置时可用)
save_format=“png”, # “png”, “jpeg” 之一(仅当 save_to_dir 设置时可用)。默认:“png”
follow_links=False, # 是否跟踪类子目录中的符号链接(默认为 False)
subset=None, # 数据子集 (“training” 或 “validation”),如果在ImageDataGenerator中设置了validation_split
interpolation=“nearest”, # 在目标大小与加载图像的大小不同时,用于重新采样图像的插值方法(基于PIL,参考前面3.1章节)
)
返回:一个生成 (x, y) 元组的 DirectoryIterator,其中 x 是一个包含一批尺寸为 (batch_size, *target_size, channels)的图像的 Numpy 数组,y 是对应标签的 Numpy 数组
在这之前,我们先准备一下目标样本及目录结构。这里为了演示,准备了3类不同品种的猫,每一类各3张,目录结构如下:
…/imgehc/flow_from_dir_gen/ # 目标样本目录,既上面的directory
:cat_1 # 第1类猫子目录,包含3张猫图片
:1.jpg 2.jpg 3.jpg
:cat_2 # 第2类猫子目录,包含3张猫图片
:1.jpg 2.jpg 3.jpg
:cat_3 # 第3类猫子目录,包含3张猫图片
:1.jpg 2.jpg 3.jpg
cat_1_1.jpg | cat_1_2.jpg | cat_1_3.jpg |
---|---|---|
cat_2_1.jpg | cat_2_2.jpg | cat_2_3.jpg |
cat_3_1.jpg | cat_3_2.jpg | cat_3_3.jpg |
我们还是继续使用上面初始化的增强对象(test_gen),来调用flow_from_directory()函数获得一个增强器
generator_flow_for_dir = test_gen.flow_from_directory(
directory = os.path.join(BASE_DIR, "imgehc/flow_from_dir_gen/"),
batch_size = 3, # 每一轮增强输出3张图片
target_size = (333, 500), # 目标图像将被resize的尺寸
class_mode = 'categorical', # 采用2D的one-hot 编码标签
save_to_dir = os.path.join(BASE_DIR, "imgehc/flow_from_dir_gen_save/"),
save_format = IMG_FORMAT
)
# 此处执行打印日志:Found 9 images belonging to 3 classes.
注意:
-
和flow函数不同的是,样本图片尺寸大小可以不一,可以通过target_size缩放到固定尺寸
-
和flow函数一样的是,此处的save_to_dir,必须是已存在的文件夹,否则报错
上面生成的增强器(generator_flow_for_dir)是一个具有元组 (x, y) 的 DirectoryIterator,我们可以遍历它
epoch = 0 # 生成器执行生成轮数
for gx_batch, gy_batch in generator_flow_for_dir:
for c in range(len(gx_batch)):
# 注意,多分类问题一般采用one-hot编码标签,所以这里的标签范围为[0~2]
print("生成器-EPOCH[%d]-IDX[%d]-Y[%d]" % (epoch + 1, c + 1, np.argmax(gy_batch[c])))
img_show(gx_batch[c] / 255, True) # 0~1必须是小数,0~255必须为整数,也可以img.astype(int)
# 注意,此处生成图像格式为RGB,使用OpenCV保存图像时需要转换为BGR
cv2.imwrite(BASE_DIR + r"/imgehc/flow_from_dir_gen_save/EPOCH[" + str(epoch + 1)
+ "]-IDX[" + str(c + 1) + "]-Y[" + str(np.argmax(gy_batch[c])) + "]"
+ ".jpg", gx_batch[c][:,:,::-1])
epoch += 1
if epoch == 3: # 控制生成图片数量,否则将无限循环
break
注意:
-
和前面flow函数不同的是,flow_from_directory增强函数,生成的图片格式为RGB
-
因其生成的图片格式为RGB,所以在使用OpenCV保存图片时,需要转换为BGR格式(IMG[:,:,::-1])
-
和前面flow函数也不同的是,其生成像素值为0~255的浮点数,使用Matplotlib展示时,需要处理成 0~1 或者 0~255的整数
因把猫分了3类,所以属于多分类问题,一般采用one-hot编码标签,标签取值范围为[0~2],那它如何跟目录映射的呢?其实和classes参数(可选的类的子目录列表)有关,但是上面我们并没有定义它,因为它会从子目录名称/结构中推断出来,官方文档解释是:类名将按字典序映射到标签的索引。换一句话说,就是子目录名称排序后的索引位置,比如我们前面定义的目录结构:
…/imgehc/flow_from_dir_gen/
:cat_1 # 索引位置:0 对应one-hot编码为:[1. 0. 0.]
:1.jpg 2.jpg 3.jpg
:cat_2 # 索引位置:1 对应one-hot编码为:[0. 1. 0.]
:1.jpg 2.jpg 3.jpg
:cat_3 # 索引位置:2 对应one-hot编码为:[0. 0. 1.]
:1.jpg 2.jpg 3.jpg
值得提醒注意的是:如果分类子目录较多,在命名上需要额外注意,否则获得标签值将与预期不符,看下面示列:
cat_sort = [] # 构造11个分类的子目录名称
for i in range(11):
cat_sort.append("cat_" + str(i + 1))
print("预期目录排序: ", cat_sort)
# 预期目录排序: ['cat_1', 'cat_2', 'cat_3', 'cat_4', 'cat_5', 'cat_6', 'cat_7', 'cat_8', 'cat_9', 'cat_10', 'cat_11']
cat_sort.sort()
print("实际目录排序: ", cat_sort)
# 实际目录排序: ['cat_1', 'cat_10', 'cat_11', 'cat_2', 'cat_3', 'cat_4', 'cat_5', 'cat_6', 'cat_7', 'cat_8', 'cat_9']
和flow生成器一样的是,都会无限循环生成增强图片,所以我们需要手动跳出。以下为执行一次按顺序输出的3轮9张增强图片:
EPOCH[1]-IDX[1]-Y[2] | EPOCH[1]-IDX[2]-Y[0] | EPOCH[1]-IDX[3]-Y[1] |
---|---|---|
EPOCH[1]-IDX[1]-Y[0] | EPOCH[1]-IDX[2]-Y[2] | EPOCH[1]-IDX[3]-Y[1] |
EPOCH[1]-IDX[1]-Y[0] | EPOCH[1]-IDX[2]-Y[1] | EPOCH[1]-IDX[3]-Y[2] |
可以发现,和flow函数一样,每一轮增强输出顺序是随机打乱的(默认 shuffle=True)。并且是从所有类别的所有样本中进行打乱,此时当batch_size取值不同时,则输出情况同flow函数(是否可被所有类别总样本量整除),当shuffle=False是,则挨个从每个类别获取对应数量图片,当一类别数量小于batch_size不够时,则顺延从下一类别按照顺序进行获取
另外,可以发现,flow函数增强的样本集(x)是需要提前加载到内存中的,而flow_from_directory,顾名思义其通过样本目录按批次大小实时读取并增强,好处也显而易见,因为在实际中,对于小批量样本我们可以一次性读取到内存中并进行训练,而如果是大批量的训练样本,几十万、上百万甚至上千万,我们无法一次性加载至内存中,则需要分批次读取进行训练,当然在这个过程中可以很好的嵌入数据增强,比如keras中常用的fit_generator函数(官方说明文档之fit_generator):
fit_generator(generator, steps_per_epoch=None, epochs=1, verbose=1, callbacks=None, validation_data=None, validation_steps=None, class_weight=None, max_queue_size=10, workers=1, use_multiprocessing=False, shuffle=True, initial_epoch=0)
第一个参数为python生成器,可以是上面介绍的,也可以自定义,生成器逐批生成数据,按批次训练模型。好处是,除了解决内存问题,生成器与模型并行运行,还可以提高效率。 例如,可以在 CPU 上对图像进行实时数据增强,并同时在 GPU 上训练模型
有很多人可能会有疑问,在训练的过程中到底增强了多少张图片呢,其实并没有固定数量这么一说,因为生成器是无限循环的,这取决于训练时指定的训练轮数(epochs参数)和批次步长(steps_per_epoch)。epochs 很好理解,表示训练模型的迭代总轮数,而 steps_per_epoch 则表示每一轮从生成器中抽取多少个批量,比如,前面定义每个批量包含3个样本(batch_size ),所以读取完所有9个样本则需要3个批量,伪代码如下:
history = model.fit_generator(
generator = generator_flow_for_dir,
steps_per_epoch = 3,
epochs = 30,
...
)
9、采用imgaug进行图像增强
imgaug是一个常用于机器学习项目中图像增强的python库。内置封装了很多增强技术及增强器,可以轻松地组合它们并以随机顺序执行,简单而强大。因篇幅有限,这里只介绍官方两个简单的示列供大家参考,想了解更多请参阅官方说明文档或Github文档
iaa 是文章开头出引入的 imgaug.augmenters 类的别名
iaa.Sequential( # augmenters 下的扩充序列函数
children=None, # 应用于图像上的增强器(Augmenter或者Augmenter集合)
random_order=False, # 是否按随机顺序应用增强器(下面用示列演示其区别)
seed=None, # 随机种子,可以是整数、imgaug.random.RNG、numpy.random.*
name=None, # 操作名称
random_state=‘deprecated’, # 参数seed的旧名称,0.4.0起过时,不建议使用
deterministic=‘deprecated’, # 确定性模式,0.4.0起已弃用。替代方法to_deterministic()
)
iaa.SomeOf(n=None, children=None, …) # 顾名思义,选择其中n个增强器应用于图像
iaa.OneOf(children=None, …) # 顾名思义,只选择其中一个增强器应用于图像
iaa.Sometimes( # 对批次中,一部分比例的图像采用指定增强器,剩余部分采用另一个增强器
p=0.5, # 定义多大比例的图像采用指定增强器,默认50%
then_list=None, # p比例图像指定采用的增强器集合
else_list=None # 1-p比例图像采用的增强器集合
)
9.1、标准示列,离线扩充
官方提供了一个标准的示列,按批次进行训练,并通过裁剪、水平翻转和高斯模糊来增强每批图像。首先我们需要定义一个增强序列
# 注意输入images,必须尺寸相同
# 必须是3维数组(height, width, channels)集合
# 或者直接是4维数组(N, height, width, channels)
seq_standard = iaa.Sequential([
iaa.Crop(px=(0, 16)), # 从每边裁剪图像0~16px(随机选择)
iaa.Fliplr(0.5), # 随机50%的概率水平翻转图像
iaa.GaussianBlur(sigma=(0, 3.0)) # 用0到3.0的sigma模糊图像
], random_order = False) # 按预定义顺序应用增强器
沿用前面 8.3章节 的3类9张猫图像样本来构造模拟一个load_batch函数,实现按批次读取图像
# 沿用前面8.3章节的3类9张猫图像样本
# .../flow_from_dir_gen/cat_1|cat_2|cat_3
def load_batch(batch_idx):
x_train, y_train = [], []
for i in range(3):
# 读取每一类目录中对应索引的图像,并缩放到固定尺寸大小
cat_dir = os.path.join(BASE_DIR, "imgehc/flow_from_dir_gen/cat_" + str(i + 1))
cat_name = os.path.join(cat_dir, str(batch_idx + 1) + ".jpg")
imgorg = cv2.imread(cat_name)
imgorg = cv2.resize(imgorg, (500, 333), cv2.INTER_AREA)
x_train.append(imgorg)
# 根据类子目录索引,生成one-hot编码
y_train.append(to_categorical(i, 3))
return np.array(x_train), np.array(y_train)
接下来,构造模拟一个train_on_batch函数,实际应该是按批次训练模型,这里只做输出展示
def train_on_batch(batch_idx, x_train, y_train):
for c in range(len(x_train)):
print("BATCH[{}]-IDX[{}]-Y[{}]".format(batch_idx + 1, c + 1, np.argmax(y_train[c])))
img_show(x_train[c])
最后,把整个流程串起来,先按批次读取图像并做预处理(尺寸统一)、进行数据增强处理后、按批次训练模型…
for batch_idx in range(3):
x_train, y_train = load_batch(batch_idx)
x_train = seq_standard(images = x_train) # 增强后图像顺序不变
# 很多时候,我们的样本都是有序的,导致同一批次连续为某一类,需要随机打乱数据集顺序,保证每一类的正常分布,防止过拟合
# 本例中,因演示固定只读取每一类的某一张图像,故不存在此问题,当然实际中远非如此,具体场景具体分析,供参考...
# np.random.seed(500) # 固定的随机种子,打乱训练集样本
# np.random.shuffle(x_train)
# np.random.seed(500) # 同上一致的随机种子,打乱标签,保证和训练样本顺序对应
# np.random.shuffle(y_train)
train_on_batch(batch_idx, x_train, y_train)
BATCH[1]-IDX[1]-Y[0] | BATCH[1]-IDX[2]-Y[1] | BATCH[1]-IDX[3]-Y[2] |
---|---|---|
BATCH[2]-IDX[1]-Y[0] | BATCH[2]-IDX[2]-Y[1] | BATCH[1]-IDX[3]-Y[2] |
BATCH[3]-IDX[1]-Y[0] | BATCH[3]-IDX[2]-Y[1] | BATCH[3]-IDX[3]-Y[2] |
9.2、简单而通用的扩增序列
官方还提供了一个简单而通用的扩增序列,除了对图像进行裁剪、水平翻转和高斯模糊之外,还增加了高斯噪声、色彩亮度增强、以及仿射变换应用缩放、平移、旋转、裁剪等。下面我们直接来看一下增强序列
ia.seed(1)
seq_general = iaa.Sequential([
iaa.Fliplr(0.5), # 水平翻转
iaa.Crop(percent=(0, 0.1)), # 随机裁剪
# 随机0~0.5之间的高斯模糊,应用批次的50%
iaa.Sometimes(
0.5, # 一个批次中,50%数量的图像
iaa.GaussianBlur(sigma=(0, 0.5))
),
# 增强或减弱每幅图像的对比度
iaa.LinearContrast((0.75, 1.5)),
# 添加高斯噪声,对50%图像像素采样一次噪声,并50%对每个通道进行采样
iaa.AdditiveGaussianNoise(loc=0, scale=(0.0, 0.05*255), per_channel=0.5),
# 20%对每个通道进行采样,增强改变图像色彩亮度
iaa.Multiply((0.8, 1.2), per_channel=0.2),
# 对每个图像应用仿射变换:缩放、平移、旋转和剪切
iaa.Affine(
scale={"x": (0.8, 1.2), "y": (0.8, 1.2)},
translate_percent={"x": (-0.2, 0.2), "y": (-0.2, 0.2)},
rotate=(-25, 25),
shear=(-8, 8)
)
], random_order = True) # 以随机顺序应用增强器(请注意,不同增强批次随机不同,但同一批次所有图像顺序一致)
我们可以直接使用前面构造模拟的整个流程,把标准增强序列(seq_standard)替换为通用的增强序列(seq_general )即可
for batch_idx in range(3):
x_train, y_train = load_batch(batch_idx)
x_train = seq_general(images = x_train) # 增强后图像顺序不变
train_on_batch(batch_idx, x_train, y_train)
BATCH[1]-IDX[1]-Y[0] | BATCH[1]-IDX[2]-Y[1] | BATCH[1]-IDX[3]-Y[2] |
---|---|---|
BATCH[2]-IDX[1]-Y[0] | BATCH[2]-IDX[2]-Y[1] | BATCH[2]-IDX[3]-Y[2] |
BATCH[3]-IDX[1]-Y[0] | BATCH[3]-IDX[2]-Y[1] | BATCH[3]-IDX[3]-Y[2] |
9.3、random_order的作用效果
前面 9.1章节 中采用了默认按顺序增强, 9.2章节 则采用了以随机顺序进行增强,我们用如下示列看下两者差异
# 沿x轴和y轴平移+高斯噪声的增强序列,并指定是否以随机顺序增强
def imgaug_seq(images, random_order=False):
seq_order = iaa.Sequential([
iaa.Affine(translate_px={"x":[-60, 60], "y":[-60, 60]}),
iaa.AdditiveGaussianNoise(scale=0.1*255)
], random_order = random_order)
return seq_order(images = images)
# (333, 500, 3) to (1, 333, 500, 3)
images_list = img_org.reshape((1,) + img_org.shape[0:3])
# 使用预定义顺序增强
imgaug_order = imgaug_seq(images=images_list, random_order=False)
# 使用随机顺序增强
imgaug_random_order = imgaug_seq(images=images_list, random_order=True)
print("采用顺序增强后图像: ", imgaug_order[0].shape)
img_show(imgaug_order[0])
print("采用随机顺序增强后图像: ", imgaug_random_order[0].shape)
img_show(imgaug_random_order[0])
以上连续执行3次后,得到如下输出,仔细看可以发现不同的增强器,先后作用于图像,其效果有所差异(先移位后噪声,黑色背景也将添加噪声,如果先噪声后移位,则黑色背景无噪声)
预定义顺序增强后图像 (333, 500, 3) | 使用随机顺序增强后图像 (333, 500, 3) |
---|---|
实战数据增强
作为初学者,最近正在拿 百度AI平台-新手练习赛-猫十二分类项目 练练手。前后分别尝试了自定义模型和迁移学习,迁移学习尝试了当下比较流行的经典神经网络模型,比如:AlexNet、VGGNet、RestNet 、InceptionV3 等(更多参见=>深度学习翻译论文汇总)。本文不详细展开介绍,将在下一篇文章中分享,这里以VGG16预训练模型为例,看一下使用原始数据训练和数据增强后训练的差异
1、猫分类项目介绍
顾名思义,此项目主题为图像分类—十二种猫分类。数据集包含12种类的猫的彩色图像,分为训练集和测试集,官方提供了2160张训练集样本及所属分类标签,测试集只提供了240张彩色图像,并未提供所属分类标签,需要参赛者进行标注,最后官方会以准确率评分并排名
因为,测试集没有所属分类标签,所以小编是从2160张训练集样本中,从每一类中随机抽取了20张图像,共240张验证集,用于训练时验证模型效果,剩下的作为训练集(需要注意,训练集样本中藏有5张灰度图像,不知道是不是官方搞错了…),每一类160张图像,共1920张训练集
大家应该都知道,使用预训练模型通常有两种方法:特征提取(feature extraction)和微调模型(fine-tuning)。然后在这两种方法的实现上,又可以分为多种实现方式,比如拿特征提取来说:
- 你可以先基于预训练模型,把它当做特征提取器(卷积基),运行你的数据集,得到输出,然后再独立的输入到自定义的全连接分类器中进行训练。这种方式的好处是,特征提取完之后,训练速度将特别快,因为所有训练样本只运行了一次卷积基
- 你也可以直接扩展已有模型,把预训练模型卷积基(conv_base)添加到自定义模型的最上层,然后再追加你的全连接分类器层,这样相当于端到端的进行训练,训练多少轮就相当于在数据集上运行了多少次卷积基,训练速度将特别慢。另外切记,在编译和训练模型之前,一定要冻结卷积基
本文重点围绕【数据增强】,至于在不同模型上的训练尝试及训练过程不详细展开介绍,将在下一篇文章中分享。下面我们使用VGG16作为预训练模型,采用特征提取的方法进行训练,对比一下数据增强前后的效果
2、VGG16之特征提取
我们可以直接从Keras中获取VGG16预训练模型权重并实例化,具体参数详细说明请参阅Keras官方说明文档之VGG16/19
conv_base = VGG16(weights = "imagenet", include_top = False, input_shape = (224, 224, 3))
conv_base.summary()
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
input_1 (InputLayer) (None, 224, 224, 3) 0
_________________________________________________________________
...省略输出... 注意最后输出的特征大小为 (7, 7, 512)
_________________________________________________________________
block5_pool (MaxPooling2D) (None, 7, 7, 512) 0
=================================================================
Total params: 14,714,688
Trainable params: 14,714,688
Non-trainable params: 0
_________________________________________________________________
2.1、使用原始数据训练
我们先使用原始的训练集样本进行训练。原始数据的预处理,统一缩放到224 x 224大小、标准化处理/255、标签做one-hot编码、随机打乱训练集,关键代码如下:
def load_image_data(img_dir):
x_img, y_img = [], []
for fname in os.listdir(img_dir):
# 1、读取图像,原图读取(此训练集为RGB)
img = cv2.imread(img_dir + fname)
# 2、缩放到固定大小 224 x 224
img = cv2.resize(img, (224, 224), cv2.INTER_AREA)
# 3、数据标准化处理,除以255,并保留6位小数
img = np.around(img.astype(np.float32) / 255, decimals = 6)
# 4、组装图片和分类标签
x_img.append(img)
# 文件名提前做了处理'_'前既第一位为类别标签
y_img.append(int(fname.split("_")[0]))
# 随机打乱训练数据集顺序,防止过拟合
np.random.seed(1)
np.random.shuffle(x_img)
np.random.seed(1)
np.random.shuffle(y_img)
# 转换为numpy数组,标签做one-hot编码处理
return np.array(x_img, np.float32), np_utils.to_categorical(y_img)
x_train, y_train = load_image_data(TRAIN_DIR)
x_test, y_test = load_image_data(TEST_DIR)
print(x_train.shape) # 输出:(1920, 224, 224, 3)
print(y_train.shape) # 输出:(1920, 12)
接下来,我们有上面介绍的两种实现方式,这里采用第一种,优先从卷积基(conv_base)中提取特征再进行训练,训练速度比较快,而且非常适合调参后多次进行训练尝试,关键代码如下:
# 从预训练模型卷积基中提取特征
def extract_features(img_array):
batch_size = 128
features = np.zeros(shape = (img_array.shape[0], 7, 7, 512))
for step in range(0, len(img_array), batch_size):
features[step: step + batch_size] = conv_base.predict(img_array[step: step + batch_size])
return features
x_train_features = extract_features(x_train) # 提取训练集特征
x_test_features = extract_features(x_test) # 提取测试集特征
print(x_train_features.shape) # 输出:(1920, 7, 7, 512)
print(x_test_features.shape) # 输出:(240, 7, 7, 512)
提取完特征之后,定义本项目的网络模型结构,这里添加一个256的全联接层、并Dropout50%的神经元,最后以softmax激活函数做多分类;优化器采用RMSprop,损失函数采用分类交叉熵。(模型结构下同)
model = Sequential()
# 将特征摊平后输入全连接网络
model.add(Flatten(input_shape = (7, 7, 512)))
# 全联接层
model.add(Dense(256, activation='relu'))
# Dropout 50% 的输入神经元
model.add(Dropout(0.5))
# 使用 softmax 激活函数做多分类
model.add(Dense(CAT_CLASSES, activation='softmax'))
# RMSprop优化器
OPT = RMSprop(lr=2e-5)
# 多分类问题,采用分类交叉熵损失函数
LOSS = "categorical_crossentropy"
# 编译模型,使用预定义的优化器和损失函数,衡量指标采用准确率
model.compile(optimizer=OPT, loss=LOSS, metrics=["accuracy"])
下面开始训练模型,原始的训练集样本比较小,batch_size不宜太大,此处设置为32
history = model.fit(x_train_features, y_train,
batch_size = 32,
epochs = 30, verbose = 1,
validation_data = (x_test_features, y_test))
Train on 1920 samples, validate on 240 samples
Epoch 1/30
1920/1920 [==============================] - 3s 2ms/step - loss: 2.5014 - acc: 0.1333 - val_loss: 2.2247 - val_acc: 0.2958
...省略输出...
Epoch 10/30
1920/1920 [==============================] - 3s 1ms/step - loss: 0.9406 - acc: 0.7464 - val_loss: 1.2070 - val_acc: 0.6208
...省略输出...
Epoch 20/30
1920/1920 [==============================] - 3s 1ms/step - loss: 0.4036 - acc: 0.9193 - val_loss: 0.9477 - val_acc: 0.6833
...省略输出...
Epoch 30/30
1920/1920 [==============================] - 3s 1ms/step - loss: 0.1932 - acc: 0.9776 - val_loss: 0.8471 - val_acc: 0.7000
这里只训练了30轮,因为特征已经提前提取好,所以训练时的速度很快,3秒一个epoch。可以发现,训练准确率随着训练轮数线性增加,直到接近100%,而验证准确率则停留在70%。因为训练样本相对较少,所以很容易产生过拟合
2.2、固定裁剪增强训练
这里借鉴了AlexNet和VGGNet采用的数据增强,大家可以看一下论文的详细介绍。AlexNet是先把图像固定尺寸到256 × 256,再通过随机裁剪的方式提取224 × 224的图像,然后再水平翻转,相当于(256 - 224)^2 x 2翻了2048倍。
随后的VGGNet是在AlexNet的基础上做了变种,稍微复杂了一点,采用了多尺寸随机裁剪,随机固定尺寸到(256~512),再进行随机裁剪提取224 × 224的图像
其中,AlexNet在进行预测时,对一张图片裁剪了四个角和中心区块的224 × 224的图像,并做水平翻转,共10张图片取平均值作为最终结果。这里,我们尝试以此方式来扩充增强训练集(10倍),裁剪实现方法前面章节已经介绍,考虑性能问题,首选使用OpenCV实现(参考前面章节),汇总代码如下
# 对图像进行四角+中心5次裁剪并水平翻转(扩充10倍)
# CROP_H、CROP_W:目标裁剪的高和宽,默认为原图-32
def crop_flip_image(img_org, CROP_H=None, CROP_W=None):
# 增强后图像集合
crop_flip_imgs = []
# 原始图像的高和宽
ORG_H, ORG_W = img_org.shape[0], img_org.shape[1]
# 目标裁剪后图像的高和宽(默认为原始图像高宽-32)
CROP_H = ORG_H - 32 if CROP_H is None else CROP_H
CROP_W = ORG_W - 32 if CROP_W is None else CROP_W
# 裁剪左上角
img_crop_lt = img_org[0:CROP_H, 0:CROP_W]
# 左上角水平翻转
img_crop_lt_flip = cv2.flip(img_crop_lt, 1)
# 裁剪右上角
img_crop_rt = img_org[0:CROP_H, ORG_W - CROP_W:ORG_W]
# 右上角水平翻转
img_crop_rt_flip = cv2.flip(img_crop_rt, 1)
# 裁剪左下角
img_crop_lb = img_org[ORG_H - CROP_H:ORG_H, 0:CROP_W]
# 左下角水平翻转
img_crop_lb_flip = cv2.flip(img_crop_lb, 1)
# 裁剪右下角
img_crop_rb = img_org[ORG_H - CROP_H:ORG_H, ORG_W - CROP_W:ORG_W]
# 右下角水平翻转
img_crop_rb_flip = cv2.flip(img_crop_rb, 1)
# 裁剪中心
img_crop_ct = img_org[round((ORG_H - CROP_H) / 2):CROP_H + round((ORG_H - CROP_H) / 2),
round((ORG_W - CROP_W) / 2):CROP_W + round((ORG_W - CROP_W) / 2)]
# 中心水平翻转
img_crop_ct_flip = cv2.flip(img_crop_ct, 1)
crop_flip_imgs.append(img_crop_lt)
crop_flip_imgs.append(img_crop_lt_flip)
crop_flip_imgs.append(img_crop_rt)
crop_flip_imgs.append(img_crop_rt_flip)
crop_flip_imgs.append(img_crop_lb)
crop_flip_imgs.append(img_crop_lb_flip)
crop_flip_imgs.append(img_crop_rb)
crop_flip_imgs.append(img_crop_rb_flip)
crop_flip_imgs.append(img_crop_ct)
crop_flip_imgs.append(img_crop_ct_flip)
return crop_flip_imgs
# 对训练集进行离线数据增强到磁盘
def data_augmentation(train_dir):
for fname in os.listdir(train_dir):
# 读取图像、并resize到固定尺寸(256, 256)
img_org = cv2.imread(train_dir + fname)
img_resize = cv2.resize(img_org, (256, 256), cv2.INTER_AREA)
# 省略...
# 数据扩充:5次裁剪+水平翻转
crop_flip_imgs = crop_flip_image(img_resize, CROP_H=224, CROP_W=224)
for i in range(len(crop_flip_imgs)):
# 省略...标签处理+写入磁盘
注意,只能增强训练集。接下来,我们继续采用第一种方式,预先提取特征再训练(训练速度比较快)。增强后数据量级变大了,所以此处batch_size可以适当大一些,加速收敛(此训练集实测,原始数据设32较优,扩充10倍后设128较优)
history = model.fit(x_train_features, y_train,
batch_size = 128,
epochs = 30, verbose = 1,
validation_data = (x_test_features, y_test))
Train on 19200 samples, validate on 240 samples
Epoch 1/30
19200/19200 [==============================] - 10s 517us/step - loss: 2.0255 - acc: 0.3168 - val_loss: 1.6151 - val_acc: 0.5333
...省略输出...
Epoch 10/30
19200/19200 [==============================] - 10s 506us/step - loss: 0.3839 - acc: 0.9015 - val_loss: 0.7573 - val_acc: 0.7708
...省略输出...
Epoch 20/30
19200/19200 [==============================] - 10s 530us/step - loss: 0.1304 - acc: 0.9796 - val_loss: 0.7029 - val_acc: 0.7583
...省略输出...
Epoch 30/30
19200/19200 [==============================] - 10s 534us/step - loss: 0.0570 - acc: 0.9938 - val_loss: 0.7074 - val_acc: 0.7708
这里同样训练了30轮,从图中可以看出,使用数据增强后,模型的准确率达到了 77%,相比原始数据提高了 7%,但模型效果还远未达到我们的预期
感兴趣的读者可以自行尝试微调模型,小编微调 block5_* 三层(靠近全连接层),训练的结果为:原始数据的准确率为 78%,固定裁剪增强后的准确率达到了 86%
2.3、随机裁剪增强训练
前面采用的数据增强借鉴自AlexNet在预测时的裁剪方式,实际AlexNet和VGGNet在训练时,采用的都是随机裁剪。前面,无论是原始数据还是固定裁剪增强10倍后,小编都是加载到内存中进行训练,随机裁剪不适合离线增强,需要使用python生成器,并配合keras中的fit_generator函数进行训练,部分关键代码如下,供参考~
首先预定义训练集样本和测试集样本处理函数
# 训练集样本处理:缩放+随机裁剪+50%概率水平翻转,并归一化处理
def train_handle(img_org):
img_resize = cv2.resize(img_org, (256, 256), cv2.INTER_AREA)
# 进行随机裁剪 224 x 224
x = np.random.randint(0, 256 - 224 + 1)
y = np.random.randint(0, 256 - 224 + 1)
img_crop = img_resize[y:(y + 224), x:(x + 224)]
if x % 2 == 0: # 50%的概率水平翻转
img_crop = cv2.flip(img_crop, 1)
return np.around(img_crop.astype(np.float32) / 255, decimals = 6)
# 测试集样本处理:直接resize到224x224大小,并归一化处理
def test_handle(img_org):
img_resize = cv2.resize(img_org, (224, 224), cv2.INTER_AREA)
return np.around(img_resize.astype(np.float32) / 255, decimals = 6)
下面定义训练集的生成器函数,入参为:原始训练集目录、生成批次大小(默认32)
# 训练集生成器:train_dir:文件目录,batch_size:批次大小,默认32
def train_generator(train_dir, batch_size=32):
train_list = os.listdir(train_dir)
np.random.seed(1) # 打乱训练集
np.random.shuffle(train_list)
x_train, y_train = [], []
while True:
for fname in train_list:
# 读取原始图像(此训练集为彩色)
img_org = cv2.imread(os.path.join(train_dir, fname))
# 样本处理,随机裁剪 + 50%概率水平翻转
img_crop = train_handle(img_org)
x_train.append(img_crop)
y_train.append(int(fname.split("_")[0])) # 文件命名规则:{y}_{index}.jpg
if (len(x_train) == min(batch_size, len(train_list))):
yield np.array(x_train), np_utils.to_categorical(np.array(y_train), CAT_CLASSES)
x_train, y_train = [], []
下面定义测试集的生成器函数,入参同上:原始测试集目录、生成批次大小(默认32)
# 测试集生成器:test_dir:文件目录,batch_size:批次大小,默认32
def test_generator(test_dir, batch_size=32):
test_list = os.listdir(test_dir)
np.random.seed(1) # 打乱测试集
np.random.shuffle(test_list)
x_test, y_test = [], []
while True:
for fname in test_list:
# 读取原始图像(此训练集为彩色)
img_org = cv2.imread(os.path.join(test_dir, fname))
# 样本处理,resize到224x224大小
img_resized = test_handle(img_org)
x_test.append(img_resized)
y_test.append(int(fname.split("_")[0])) # 文件命名规则:{y}_{index}.jpg
if (len(x_test) == min(batch_size, len(test_list))):
yield np.array(x_test), np_utils.to_categorical(np.array(y_test), CAT_CLASSES)
x_test, y_test = [], []
有了生成器之后,我们就可以定义网络模型结构,训练我们的模型。网络模型结构同上,训练代码如下
# 得到训练集生成器,批次大小为128
train_gen = train_generator(TRAIN_DIR, 128) # 训练集有 1920 张图像
# 得到测试集生成器,批次大小为40
test_gen = test_generator(TEST_DIR, 40) # 测试集有 240 张图像
history = model.fit_generator(train_gen,
steps_per_epoch = 150, # 150 * 128 = 19200(训练集的10倍)
epochs = 30, verbose = 1, # 30 * (150 * 128) = 训练30轮
validation_data = test_gen,
validation_steps = 6)
以上参数设置原则为,模拟和上一小节的数据增强保持一致。fit_generator函数,前面有过介绍,epochs 表示训练轮数,这里训练30轮,steps_per_epoch 表示每一轮从生成器中抽取多少个批量,这里定义为150,也就是150 * 128 = 19200,表示每一轮训练19200张图像,等于上一小节固定裁剪扩充10倍的数量级
对比离线增强固定裁剪,每一轮训练的图像相同。随机裁剪如果够随机的话,那么每一轮训练,同一张图像都被裁剪的各不相同。下面我们看一下训练结果
Epoch 1/30
150/150 [==============================] - 1548s 10s/step - loss: 1.927 - acc: 0.3552 - val_loss: 1.5308 - val_acc: 0.6083
...省略输出...
Epoch 10/30
150/150 [==============================] - 1536s 10s/step - loss: 0.2491 - acc: 0.9472 - val_loss: 0.7444 - val_acc: 0.7708
...省略输出...
Epoch 20/30
150/150 [==============================] - 1551s 10s/step - loss: 0.0673 - acc: 0.9923 - val_loss: 0.6915 - val_acc: 0.7875
...省略输出...
Epoch 30/30
150/150 [==============================] - 1547s 10s/step - loss: 0.0254 - acc: 0.9985 - val_loss: 0.7237 - val_acc: 0.7917
从图中可以看出,使用随机裁剪进行增强训练后,模型的准确率达到了79%,相比固定裁剪(准确率为77%),提高了 2%。前面提到,固定裁剪+微调模型后的准确率达到了 86%,相信使用随机裁剪+微调模型的效果会更好,感兴趣的读者可以自行尝试
2.4、随机裁剪变种增强
细心的读者可能注意到,前面无论是原始数据预处理、固定裁剪预处理还是随机裁剪预处理,小编都是先resize到固定尺寸,然后再进行裁剪。实际AlexNet在训练时,并不是单纯的直接resize,而是以目标大小短边的缩放比例进行缩放,然后再以目标大小裁剪中心区块,最后在这个基础上再进行随机裁剪。比如拿本文最开始的演示图像333 x 500,我们的目标大小为256 x 256,进入模型大小为224 x 224,以短边的缩放比例进行缩放后大小为256 x 384,然后以目标大小裁剪中心区块得到256 x 256,代码如下
# 目标大小256,计算短边缩放比例
scale = 256 / min(img_org.shape[0], img_org.shape[1])
# 计算以短边缩放比例,缩放后的高和宽
r_height = int(round(img_org.shape[0] * scale))
r_width = int(round(img_org.shape[1] * scale))
# 进行缩放操作
img_scale = cv2.resize(img_org, (r_width, r_height), cv2.INTER_AREA)
print("缩放后图像形状: ", img_scale.shape)
img_show(img_scale)
# 缩放后,裁剪中心区域256 x 256
img_crop = img_scale[round((r_height - 256) / 2):256 + round((r_height - 256) / 2),
round((r_width - 256) / 2):256 + round((r_width - 256) / 2)]
print("缩放后裁剪中心: ", img_crop.shape)
img_show(img_crop)
短边缩放比例,缩放后图像 (256, 384, 3) | 缩放后,裁剪中心区域 (256, 256, 3) |
---|---|
直观来看,直接resize和按比例缩放的区别,前者会导致拉伸变形,但内容完整,后者缩放后无变形且内容完整,但是裁剪中心后,边缘部分将缺失
下面我们尝试使用按比例缩放后再随机裁剪对训练集增强,并对比上一小节的效果。不同的是,小编觉得,按比例缩放后没必要裁剪中心,直接在缩放后进行随机裁剪,岂不是能学习到更多内容?不过这只是小编的一个想法,并没有理论支撑(这里也将不裁剪中心)
前面说的是训练集样本的处理方式,针对测试集样本,本文本项目处理比较简单,先以短边按比例缩放到256大小,然后裁剪中心224x224。至于原论文中预测时采用的(四角+中心 裁剪)+ 水平翻转,共10张取平均结果,不在本文介绍,将在下一篇文章中分享。具体代码如下~
同上,预定义训练集样本和测试集样本处理函数如下
# 训练集样本处理:短边缩放+随机裁剪+50%概率水平翻转,并归一化处理
def train_handle(img_org):
# 计算短边缩放比例、缩放后的高和宽,再resize操作
scale = 256 / min(img_org.shape[0], img_org.shape[1])
r_height = int(round(img_org.shape[0] * scale))
r_width = int(round(img_org.shape[1] * scale))
resized = cv2.resize(img_org, (r_width, r_height), cv2.INTER_AREA)
# 进行随机裁剪
x = np.random.randint(0, resized.shape[1] - 224 + 1)
y = np.random.randint(0, resized.shape[0] - 224 + 1)
img_crop = resized[y:(y + 224), x:(x + 224)]
if x % 2 == 0: # 50%的概率水平翻转
img_crop = cv2.flip(img_crop, 1)
return np.around(img_crop.astype(np.float32) / 255, decimals = 6)
# 测试集样本处理:短边缩放+中心裁剪,并归一化处理
def test_handle(img_org):
# 计算短边缩放比例、缩放后的高和宽,再resize操作
scale = 256 / min(img_org.shape[0], img_org.shape[1])
r_height = int(round(img_org.shape[0] * scale))
r_width = int(round(img_org.shape[1] * scale))
resized = cv2.resize(img_org, (r_width, r_height), cv2.INTER_AREA)
# 缩放后,对中心进行裁剪
img_crop = resized[round((r_height - 224) / 2):224 + round((r_height - 224) / 2),
round((r_width - 224) / 2):224 + round((r_width - 224) / 2)]
return np.around(img_crop.astype(np.float32) / 255, decimals = 6)
训练集和测试集的生成器函数同上、网络模型结构同上、训练代码参数设置同上…依然训练30轮,结果如下:
Epoch 1/30
150/150 [==============================] - 1594s 11s/step - loss: 2.0099 - acc: 0.3203 - val_loss: 1.4512 - val_acc: 0.6042
...省略输出...
Epoch 10/30
150/150 [==============================] - 1505s 10s/step - loss: 0.4975 - acc: 0.8568 - val_loss: 0.634 - val_acc: 0.7917
...省略输出...
Epoch 20/30
150/150 [==============================] - 1523s 10s/step - loss: 0.2559 - acc: 0.9363 - val_loss: 0.5644 - val_acc: 0.8125
...省略输出...
Epoch 30/30
150/150 [==============================] - 1503s 10s/step - loss: 0.1521 - acc: 0.9664 - val_loss: 0.5604 - val_acc: 0.8000
可以发现,按比例缩放后的随机裁剪,准确率略有提高,最后几轮平稳在80%,相比直接resize后随机裁剪,差不多提高了 1% 左右
2.5、随机裁剪色彩噪声
前面几个章节的增强处理,主要介绍了几种不同的随机裁剪方式,其次就是图像的水平翻转,下面我们在 上一小节 的基础上,添加 色彩扭曲 和 高斯噪声 处理
预定义色彩扭曲处理函数(只应用于训练集)
def distort_color(img_org):
img = Image.fromarray(img_org)
## 指定随机范围(默认0.5~1.5),调整图像亮度
# [0.0(黑色) ~ 1.0(原图) ~ 255.0+(接近白色)]
def random_brightness(img, lower=0.5, upper=1.5):
e = np.random.uniform(lower, upper)
return ImageEnhance.Brightness(img).enhance(e)
## 指定随机范围(默认0.5~1.5),调整图像对比度
# [负无穷(反比) ~ 0.0(纯灰色) ~ 1.0(原图) ~ 正无穷(强比)]
def random_contrast(img, lower=0.5, upper=1.5):
e = np.random.uniform(lower, upper)
return ImageEnhance.Contrast(img).enhance(e)
## 指定随机范围(默认0.5~1.5),调整图像清晰度
# [负无穷(强模糊) ~ 1.0(原图) ~ 正无穷+(强清晰)]
def random_sharpness(img, lower=0.5, upper=1.5):
e = np.random.uniform(lower, upper)
return ImageEnhance.Sharpness(img).enhance(e)
## 指定随机范围(默认0.5~1.5),调整图像色彩平衡
# [负无穷(反彩) ~ 0.0(黑白) ~ 1.0(原图) ~ 正无穷(强彩)]
def random_color(img, lower=0.5, upper=1.5):
e = np.random.uniform(lower, upper)
return ImageEnhance.Color(img).enhance(e)
random_ops = [random_brightness, random_contrast, random_sharpness, random_color]
np.random.shuffle(random_ops)
for color in random_ops:
img = color(img)
return np.array(img)
预定义高斯噪声处理函数(只应用于训练集)
# mean: 正态分布的均值,默认0(原图亮度)
# sigma: 正态分布的标准差,默认程度随机范围5~15
def gaussian_noise(image, mean=0, sigma=None):
if sigma is None:
sigma = np.random.uniform(5, 15)
# 从正态(高斯)分布中抽取随机样本
noise = np.random.normal(loc=mean, scale=sigma, size=image.shape)
noise_img = np.add(image.astype(np.float32), noise)
return noise_img.astype("int")
预定义训练集样本和测试集样本处理函数如下,其中测试集样本处理函数同上,此处省略
## 训练集样本处理:短边缩放+随机裁剪+50%概率水平翻转
# 50%的概率色彩扭曲,50%的概率高斯噪声,并归一化处理
def train_handle(img_org):
# 计算短边缩放比例、缩放后的高和宽,再resize操作
scale = 256 / min(img_org.shape[0], img_org.shape[1])
r_height = int(round(img_org.shape[0] * scale))
r_width = int(round(img_org.shape[1] * scale))
resized = cv2.resize(img_org, (r_width, r_height), cv2.INTER_AREA)
# 进行随机裁剪
x = np.random.randint(0, resized.shape[1] - 224 + 1)
y = np.random.randint(0, resized.shape[0] - 224 + 1)
img_crop = resized[y:(y + 224), x:(x + 224)]
if x % 2 == 0: # 50%的概率水平翻转
img_crop = cv2.flip(img_crop, 1)
if y % 2 == 0: # 50%的概率色彩扭曲
img_crop = distort_color(img_crop)
if (x + y) % 2 == 0: # 50%的概率高斯噪声
img_crop = gaussian_noise(img_crop)
return np.around(img_crop.astype(np.float32) / 255, decimals = 6)
训练集和测试集的生成器函数同上、网络模型结构同上、训练代码参数设置同上…依然训练30轮,结果如下:
Epoch 1/30
150/150 [==============================] - 1594s 11s/step - loss: 2.0929 - acc: 0.2877 - val_loss: 1.5179 - val_acc: 0.5792
...省略输出...
Epoch 10/30
150/150 [==============================] - 1505s 10s/step - loss: 0.7378 - acc: 0.7708 - val_loss: 0.6795 - val_acc: 0.7833
...省略输出...
Epoch 20/30
150/150 [==============================] - 1523s 10s/step - loss: 0.4805 - acc: 0.8570 - val_loss: 0.5899 - val_acc: 0.7958
...省略输出...
Epoch 30/30
150/150 [==============================] - 1503s 10s/step - loss: 0.3594 - acc: 0.8960 - val_loss: 0.5702 - val_acc: 0.8042
从训练30轮来看,发现加上色彩扭曲和噪声增强后,提升效果并不是很明显,但一定程度上抑制了训练集的过度拟合(表现的更难拟合,虽然已经过拟合)
2.6、随机裁剪+标准化
番外篇,最后我们在 2.4章节-随机裁剪变种增强 的基础上,使用ImageNet的均值和标准差来进行标准化处理,至于作用网上有很多介绍,这里不再赘述,ImageNet-RGB模式下的均值和标准差如下
img_mean_rgb = [0.485, 0.456, 0.406] # 均值
img_std_rgb = [0.229, 0.224, 0.225] # 标准差
# 转换为channel_last通道模式
img_mean = np.array(img_mean_rgb).reshape((1, 1, 3))
img_std = np.array(img_std_rgb).reshape((1, 1, 3))
添加标准化后的训练集样本预处理函数如下
# 训练集样本处理:短边缩放+随机裁剪+50%概率水平翻转,并标准化处理
def train_handle(img_org):
# 计算短边缩放比例、缩放后的高和宽,再resize操作
scale = 256 / min(img_org.shape[0], img_org.shape[1])
r_height = int(round(img_org.shape[0] * scale))
r_width = int(round(img_org.shape[1] * scale))
resized = cv2.resize(img_org, (r_width, r_height), cv2.INTER_AREA)
# 进行随机裁剪
x = np.random.randint(0, resized.shape[1] - 224 + 1)
y = np.random.randint(0, resized.shape[0] - 224 + 1)
img_crop = resized[y:(y + 224), x:(x + 224)]
if x % 2 == 0: # 50%的概率水平翻转
img_crop = cv2.flip(img_crop, 1)
# 转换为RGB格式,并归一化处理
img_crop = img_crop[:,:,::-1].astype(np.float32) / 255
img_crop -= img_mean # 减去RGB三通道分别对应均值
img_crop /= img_std # 除以RGB三通道分别对应标准差
return img_crop
添加标准化后的测试集样本预处理函数如下
# 测试集样本处理:短边缩放+中心裁剪,标准化处理
def test_handle(img_org):
# 计算短边缩放比例、缩放后的高和宽,再resize操作
scale = 256 / min(img_org.shape[0], img_org.shape[1])
r_height = int(round(img_org.shape[0] * scale))
r_width = int(round(img_org.shape[1] * scale))
resized = cv2.resize(img_org, (r_width, r_height), cv2.INTER_AREA)
# 缩放后,对中心进行裁剪
img_crop = resized[round((r_height - 224) / 2):224 + round((r_height - 224) / 2),
round((r_width - 224) / 2):224 + round((r_width - 224) / 2)]
# 转换为RGB格式,并归一化处理
img_crop = img_crop[:,:,::-1].astype(np.float32) / 255
img_crop -= img_mean # 减去RGB三通道分别对应均值
img_crop /= img_std # 除以RGB三通道分别对应标准差
return img_crop
训练集和测试集的生成器函数同上、网络模型结构同上、训练代码参数设置同上…依然训练30轮,结果如下:
Epoch 1/30
150/150 [==============================] - 1594s 11s/step - loss: 1.7318 - acc: 0.4112 - val_loss: 0.9705 - val_acc: 0.7333
...省略输出...
Epoch 10/30
150/150 [==============================] - 1505s 10s/step - loss: 0.1721 - acc: 0.9537 - val_loss: 0.3864 - val_acc: 0.8625
...省略输出...
Epoch 20/30
150/150 [==============================] - 1523s 10s/step - loss: 0.0567 - acc: 0.9891 - val_loss: 0.4058 - val_acc: 0.8750
...省略输出...
Epoch 30/30
150/150 [==============================] - 1503s 10s/step - loss: 0.0253 - acc: 0.9958 - val_loss: 0.4132 - val_acc: 0.8750
可以发现,效果有很大的提升,验证准确率达到了87.5%,相比未使用标准化(减去均值&除以标准差)的 2.4章节-随机裁剪变种增强,提高了 7.5%左右
3、数据增强实战总结
综上来看,数据增强虽然可以有效的抑制过拟合,但也存在一定的瓶颈,从经验值来看,提升5%~10%左右已经是非常不错的效果,大家也要结合自己的训练集注重调整模型结构及超参数等,不要过渡依赖数据增强,但也要知道,何时该用,何时不该用,以及怎么用…
完结~写在最后
本文主要以【数据增强】为主,所以实战部分并没有展开介绍,会在下一篇文章中分享
本文初衷,皆在希望能给和小编一样的深度学习初学者们有所帮助,大家互相学习,共同进步
本文中,如有描述错误或者不恰当的地方,还望读者指出,小编会及时更正,也欢迎随时讨论交流
最后,共勉比较喜欢的一句话:“你知道的越多,你不知道的也就越多,实践是最好的学习方法。”