1. 图像与TensorFlow

TensorFlow在设计时,就考虑了给将图像作为神经网络的输入提供支持。TensorFlow支持加载常见的图像格式(JPG、PNG),可在不同的颜色空间(RGB、RGBA)中工作,并能够完成常见的图像操作任务。

虽然TensorFlow使得图像操作变得容易,但仍然面临一些挑战。使用图像时,所面临的最大挑战便是最终需要加载的张量的尺寸。每幅图像都需要用一个与图像尺寸(heightwidthchannel)相同的张量表示。再次提醒,通道是用一个包含每个通道中颜色数量的标量的秩1张量表示。

在TensorFlow中,一个红色的RGB像素可用如下张量表示:

red = tf.constant([255, 0, 0])

每个标量都可修改,以使像素值为另一个颜色值或一些颜色值的混合。对于RGB颜色空间,像素对应的秩1张量的格式为[red,green,blue]。

一幅图像中的所有像素都存储在磁盘文件中,它们都需要被加载到内存中,以便TensorFlow对其进行操作。

2. 加载图像

TensorFlow在设计时便以能够从磁盘快速加载文件为目标。图像的加载与其他大型二进制文件的加载是相同的,只是图像的内容需要解码。加载下列3×3的JPG格式的示例图像的过程与加载任何其他类型的文件完全一致。

加载图像代码:

import tensorflow as tf
import numpy as np
sess = tf.InteractiveSession()
# match_filenames_once 将接收一个正则表达式,但在本例中不需要
image_filename = "test-input-image.jpg"
# string_input_producer会产生一个文件名队列
filename_queue = tf.train.string_input_producer(tf.train.match_filenames_once(image_filename))

# reader从文件名队列中读数据。对应的方法是reader.read
image_reader = tf.WholeFileReader()
_, image_file = image_reader.read(filename_queue)
# 图像将被解码
image = tf.image.decode_jpeg(image_file)

# tf.train.string_input_producer定义了一个epoch变量,要对它进行初始化
tf.local_variables_initializer().run()

# 使用start_queue_runners之后,才会开始填充队列
coord = tf.train.Coordinator()
threads = tf.train.start_queue_runners(coord=coord)
sess.run(image)

输出:

array([[[  0,   9,   4],
        [254, 255, 250],
        [255,  11,   8]],

       [[ 10, 195,   5],
        [  6,  94, 227],
        [ 14, 205,  11]],

       [[255,  10,   4],
        [249, 255, 244],
        [  3,   8,  11]]], dtype=uint8)

在上述代码中,输入生成器(tf.train.string_input_producer)会找到所需的文件,并将其加载到一个队列中。
加载图像要求将完整的文件加载到内存中(tf.WholeFileReader)。
一旦文件被读取(image_reader.read),
所得到的图像就将被解码(tf.image.decode_jpeg)。

这样便可以查看这幅图像sess.run(image)。由于按照名称只存在一个文件,所以队列将始终返回同一幅图像。

加载图像后,查看输出。注意,它是一个非常简单的三阶张量。RGB值对应9个一阶张量。

3. 图像格式

训练一个CNN需要大量时间,加载非常大的文件会进一步增加训练所需的时间。即便增加的时间在可接受的范围内,单幅图像的尺寸也很难存放在大多数系统的GPU显存中。

输入图像尺寸过大也会为大多数CNN模型的训练产生不利影响。CNN总是试图找到图像中的本征属性,虽然这些属性有一定的独特性,但也需要推广到其他具有类似结果的图像上。使用尺寸过大的输入会使网络中充斥大量无关信息,从而影响模型的泛化能力

图像中的重要信息是通过按照某种恰当的文件格式存储并处理得以强调的。在使用图像时,不同的格式可用于解决不同的问题。

3.1. JPEG与PNG

TensorFlow拥有两种可对图像数据解码的格式,一种是tf.image.decode_jpeg,另一种是tf.image.decode_png。在计算机视觉应用中,这些都是常见的文件格式,因为将其他格式转换为这两种格式非常容易。

PNG图像会存储任何alpha通道的信息,如果在训练模型时需要利用alpha信息(透明度),则这一点非常重要。一种应用场景是当用户手工切除图像的一些区域,如狗所戴的不相关的小丑帽。将这些区域置为黑色会使它们与该图像中的其他黑色区域看起来有相似的重要性。若将所移除的帽子对应的区域的alpha值设为0,则有助于标识该区域是被移除的区域。

使用JPEG图像时,不要进行过于频繁的操作,因为这样会留下一些伪影(artifact)。在进行任何必要的操作时,获取图像的原始数据,并将它们导出为JPEG文件。为了节省训练时间,请试着尽量在图像加载之前完成对它们的操作。

如果一些操作是必要的,PNG图像可以很好地工作。PNG格式采用的是无损压缩,因此它会保留原始文件(除非被缩放或降采样)中的全部信息。PNG格式的缺点在于文件体积相比JPEG要大一些。

3.2. TFRecord

为将二进制数据和标签(训练的类别标签)数据存储在同一个文件中,TensorFlow设计了一种内置文件格式,该格式被称为TFRecord,它要求在模型训练之前通过一个预处理步骤将图像转换为TFRecord格式。该格式的最大优点是将每幅输入图像和与之关联的标签放在同一文件中。

从技术角度讲,TFRecord文件是protobuf格式的文件。作为一种经过预处理的格式,它们是非常有用的。由于它们不对数据进行压缩,所以可被快速加载到内存中。

3.3. 保存为TFRecord格式代码举例:

将一幅图像及其标签写入一个新的TFRecord格式的文件中。


# 重复使用上面加载的图像,并给它一个假标签
image_label = b'\x01'  # 标签的格式为独热编码(one-hot encoding) (00000001)

# 将张量转换成字节,注意这将加载整个图像文件
image_loaded = sess.run(image)
image_bytes = image_loaded.tobytes()
image_height, image_width, image_channels = image_loaded.shape

# 导出 TFRecord
writer = tf.python_io.TFRecordWriter("./output/training-image.tfrecord")

# 不要在此示例文件中存储宽度,高度或图像通道,以节省空间,但不是必需的
example = tf.train.Example(features=tf.train.Features(feature={
            'label':
             tf.train.Feature(bytes_list=tf.train.BytesList(value=[image_label])),
            'image':
            tf.train.Feature(bytes_list=tf.train.BytesList(value=[image_bytes]))
        }))

# 这将保存样本到文本文件tfrecord
writer.write(example.SerializeToString())
writer.close()

在这段示例代码中,图像被加载到内存中并被转换为字节数组。
之后,这些字节被添加到tf.train.Example文件中,
而后者在被保存到磁盘之前先通过SerializeToString序列化为二进制字符串。

序列化是一种将内存对象转换为某种可安全传输到某个文件的格式。上面序列化的样本现在被保存为一种可被加载的格式,并可被反序列化为这里的样本格式。

3.4. 从TFRecord文件加载

由于图像被保存为TFRecord文件,所以可被再次加载(从TFRecord文件加载,而非从图像文件加载)。在训练阶段,加载图像及其标签是必需的。这样相比将图像及其标签分开加载会节省一些时间。

代码示例:

# 加载TFRecord
tf_record_filename_queue = tf.train.string_input_producer(
    tf.train.match_filenames_once("./output/training-image.tfrecord"))

# 请注意不同的记录读取器,这个设计用于与TFRecord文件一起使用,这些文件可能有多个示例。
tf_record_reader = tf.TFRecordReader()
_, tf_record_serialized = tf_record_reader.read(tf_record_filename_queue)

# 标签和图像以字节存储,但可以作为int64或float64类型存储在序列化的tf.Example protobuf中。
tf_record_features = tf.parse_single_example(
    tf_record_serialized,
    features={
        'label': tf.FixedLenFeature([], tf.string),
        'image': tf.FixedLenFeature([], tf.string),
    })

# 使用tf.uint8,因为所有的通道信息都在0-255之间
tf_record_image = tf.decode_raw(
    tf_record_features['image'], tf.uint8)

# 调整图像的尺寸,使其与所保存的图像类似,但这并非必需的
# 用实值表示图像的高度、宽度和通道,因为必须对输入的形状进行调整
tf_record_image = tf.reshape(
    tf_record_image,
    [image_height, image_width, image_channels])

tf_record_label = tf.cast(tf_record_features['label'], tf.string)

首先,按照与其他任何文件相同的方式加载该文件,主要差别在于之后该文件会由TFRecordReader对象读取。
tf.parse_single_example并不对图像进行解码,而是解析TFRecord,
然后图像会按原始字节(tf.decode_raw)被读取。

该文件被加载后,为使其布局符合tf.nn.conv2d的要求,即上面代码获取的值[image_height,image_width,image_channels],需要对形状进行调整(tf.reshape)。

为将batch_size维添加到input_batch中,需要对维数进行扩展(tf.expand)。

在本例中,TFRecord文件中虽然只包含一个图像文件,但这类记录文件也支持被写入多个样本。将整个训练集保存在一个TFRecord文件中是安全的,但分开存储也完全可以。

当需要检查保存到磁盘的文件是否与从TensorFlow加载的图像是同一图像时,可使用下列代码:

sess.close()
sess = tf.InteractiveSession()
tf.local_variables_initializer().run()
sess.run(tf.global_variables_initializer())
coord = tf.train.Coordinator()
threads = tf.train.start_queue_runners(coord=coord)

sess.run(tf.equal(image, tf_record_image))

sess.run(tf_record_label)

# setup-only-ignore
tf_record_filename_queue.close(cancel_pending_enqueues=True)
coord.request_stop()
coord.join(threads)

输出:

array([[[ True,  True,  True],
        [ True,  True,  True],
        [ True,  True,  True]],

       [[ True,  True,  True],
        [ True,  True,  True],
        [ True,  True,  True]],

       [[ True,  True,  True],
        [ True,  True,  True],
        [ True,  True,  True]]], dtype=bool)
b'\x01'

可以看出,原始图像的所有属性都和从TFRecord文件加载的图像一致。为确认这一点,可从TFRecord文件加载标签,并检查它与之前保存的版本是否一致。

创建一个既可存储原始图像数据,也可存储其期望的输出标签的TFRecord文件,能够降低训练中的复杂性。尽管使用TFRecord文件并非必需,但在使用图像数据时,却是强烈推荐的。如果对于某个工作流,它不能很好地工作,那么仍然建议在训练之前对图像进行预处理并将预处理结果保存下来。每次加载图像时才对其进行处理是不推荐的做法。

4. 图像操作

在大多数场景中,对图像的操作最好能在预处理阶段完成。预处理包括对图像裁剪、缩放以及灰度调整等。另一方面,在训练时对图像进行操作有一个重要的用例。

当一幅图像被加载后,可对其做翻转或扭曲处理,以使输入给网络的训练信息多样化。虽然这个步骤会进一步增加处理时间,但却有助于缓解过拟合现象。

TensorFlow并未设计成一个图像处理框架。与TensorFlow相比,有一些Python库(如PIL和OpenCV)支持更丰富的图像操作。对于TensorFlow,可将那些对训练CNN十分有用的图像处理方法总结如下。

4.1. 裁剪

裁剪会将图像中的某些区域移除,将其中的信息完全丢弃。裁剪与tf.slice类似,后者是将一个张量中的一部分从完整的张量中移除。当沿某个维度存在多余的输入时,为CNN对输入图像进行裁剪便是十分有用的。例如,为减少输入的尺寸,可对狗位于图像中心的图片进行裁剪。

代码:

sess.run(tf.image.central_crop(image, 0.1))

执行上面的代码后,可得到输出:

array([[[  6,  94, 227]]], dtype=uint8)

被裁剪前的图像数据:

array([[[  0,   9,   4],
        [254, 255, 250],
        [255,  11,   8]],

       [[ 10, 195,   5],
        [  6,  94, 227],
        [ 14, 205,  11]],

       [[255,  10,   4],
        [249, 255, 244],
        [  3,   8,  11]]], dtype=uint8)

这段示例代码利用了tf.image.central_crop将图像中10%的区域抠出,并将其返回。该方法总是会基于所使用的图像的中心返回结果。

裁剪通常在预处理阶段使用,但在训练阶段,若背景也有用时,它也可派上用场。当背景有用时,可随机化裁剪区域起始位置到图像中心的偏移量来实现裁剪。

代码:

# 这个裁剪方法仅可接收实值输入
real_image = sess.run(image)

bounding_crop = tf.image.crop_to_bounding_box(
    real_image, offset_height=0, offset_width=0, target_height=2, target_width=1)

sess.run(bounding_crop)

输出:

array([[[  0,   9,   4]],

       [[ 10, 195,   5]]], dtype=uint8)

为从位于(0,0)的图像的左上角像素开始对图像裁剪,这段示例代码使用了tf.image.crop_to_bounding_box。目前,该函数只能接收一个具有确定形状的张量。因此,输入图像需要事先在数据流图中运行。

4.2. 边界填充

为使输入图像符合期望的尺寸,可用0进行边界填充。可利用tf.pad函数完成该操作,但对于尺寸过大或过小的图像,TensorFlow还提供了另外一个非常有用的尺寸调整方法。对于尺寸过小的图像,该方法会围绕该图像的边界填充一些灰度值为0的像素。通常,该方法用于调整小图像的尺寸,因为任何其他调整尺寸的方法都会使图像的内容产生扭曲。

代码:

# 该边界填充方法仅可接收实值输入
real_image = sess.run(image)

pad = tf.image.pad_to_bounding_box(
    real_image, offset_height=0, offset_width=0, target_height=4, target_width=4)

sess.run(pad)

输出:

array([[[  0,   9,   4],
        [254, 255, 250],
        [255,  11,   8],
        [  0,   0,   0]],

       [[ 10, 195,   5],
        [  6,  94, 227],
        [ 14, 205,  11],
        [  0,   0,   0]],

       [[255,  10,   4],
        [249, 255, 244],
        [  3,   8,  11],
        [  0,   0,   0]],

       [[  0,   0,   0],
        [  0,   0,   0],
        [  0,   0,   0],
        [  0,   0,   0]]], dtype=uint8)

这段示例代码将图像的高度和宽度都增加了一个像素,所增加的新像素的灰度值均为0。对于尺寸过小的图像,这种边界填充方式是非常有用的。如果训练集中的图像存在多种不同的长宽比,便需要这样的处理方法。

对于那些长宽比不一致的图像,TensorFlow还提供了一种组合了pad和crop的尺寸调整的便捷方法。

代码:

real_image = sess.run(image)

crop_or_pad = tf.image.resize_image_with_crop_or_pad(
    real_image, target_height=2, target_width=5)

sess.run(crop_or_pad)

输出:

array([[[  0,   0,   0],
        [  0,   9,   4],
        [254, 255, 250],
        [255,  11,   8],
        [  0,   0,   0]],

       [[  0,   0,   0],
        [ 10, 195,   5],
        [  6,  94, 227],
        [ 14, 205,  11],
        [  0,   0,   0]]], dtype=uint8)

real_image的高度被减小了两个像素,而通过边界填充0像素使宽度得以增加。这个函数的操作是相对图像输入的中心进行的。

4.3. 翻转

翻转操作的含义与其字面意思一致,即每个像素的位置都沿水平或垂直方向翻转。从技术角度讲,翻转是在沿垂直方向翻转时所采用的术语。

利用TensorFlow对图像执行翻转操作是非常有用的,这样可以为同一幅训练图像赋予不同的视角。

例如,一幅左耳卷曲的澳大利亚牧羊犬图像如果经过了翻转,便有可能与其他的图像中右耳卷曲的狗匹配。

TensorFlow有一些函数可实现垂直翻转、水平翻转,用户可随意选择。随机翻转一幅图像的能力对于防止模型对图像的翻转版本产生过拟合非常有用。

代码:

top_left_pixels = tf.slice(image, [0, 0, 0], [2, 2, 3])

flip_horizon = tf.image.flip_left_right(top_left_pixels)
flip_vertical = tf.image.flip_up_down(flip_horizon)

sess.run([top_left_pixels, flip_vertical])

输出如下:

[array([[[  0,   9,   4],
         [254, 255, 250]],

        [[ 10, 195,   5],
         [  6,  94, 227]]], dtype=uint8), array([[[  6,  94, 227],
         [ 10, 195,   5]],

        [[254, 255, 250],
         [  0,   9,   4]]], dtype=uint8)]

这段示例代码对一幅图像的一个子集首先进行水平翻转,然后进行垂直翻转。该子集是用tf.slice选取的,这是因为对原始图像翻转返回的是相同的图像(仅对这个例子而言)。这个像素子集解释了当图像发生翻转时所发生的变化。tf.image.flip_left_right和tf.image.flip_up_down都可对张量进行操作,而非仅限于图像。

这些函数对图像的翻转具有确定性,要想实现对图像随机翻转,可利用另一组函数。

随机翻转代码:

top_left_pixels = tf.slice(image, [0, 0, 0], [2, 2, 3])

random_flip_horizon = tf.image.random_flip_left_right(top_left_pixels)
random_flip_vertical = tf.image.random_flip_up_down(random_flip_horizon)

sess.run(random_flip_vertical)

输出:

array([[[  3, 108, 233],
        [  0, 191,   0]],

       [[255, 255, 255],
        [  0,   0,   0]]], dtype=uint8)

这个例子与之前的例子具有相同的逻辑,唯一的区别在于本例中的输出是随机的。这个例程每次运行时,都会得到不同的输出。有一个名称为seed的参数可控制翻转发生的随机性。

4.4. 饱和与平衡

可在互联网上找到的图像通常都事先经过了编辑。例如,Stanford Dogs数据集中的许多图像都具有过高的饱和度(大量颜色)。当将编辑过的图像用于训练时,可能会误导CNN模型去寻找那些与编辑过的图像有关的模式,而非图像本身所呈现的内容。

为向在图像数据上的训练提供帮助,TensorFlow实现了一些通过修改饱和度、色调、对比度和亮度的函数。利用这些函数可对这些图像属性进行简单的操作和随机修改。对训练而言,这种随机修改是非常有用的,原因与图像的随机翻转类似。

对属性的随机修改能够使CNN精确匹配经过编辑的或不同光照条件下的图像的某种特征。

调整brightness代码:

example_red_pixel = tf.constant([254., 2., 15.])
adjust_brightness = tf.image.adjust_brightness(example_red_pixel, 0.2)

sess.run(adjust_brightness)

输出:

array([ 254.19999695,    2.20000005,   15.19999981], dtype=float32)

这个例子提升了一个以红色为主的像素的灰度值(增加了0.2)。

对比度调整代码:

adjust_contrast = tf.image.adjust_contrast(image, -.5)
sess.run(tf.slice(adjust_contrast, [1, 0, 0], [1, 3, 3]))

输出:

array([[[169,  76, 125],
        [171, 126,  13],
        [167,  71, 122]]], dtype=uint8)

这段示例代码将对比度调整了-0.5,这将生成一个识别度相当差的新图像。调节对比度时,最好选择一个较小的增量,以避免对图像造成“过曝”。这里的“过曝”的含义与神经元出现饱和类似,即达到了最大值而无法恢复。当对比度变化时,图像中的像素可能会呈现出全白和全黑的情形。

简而言之,tf.slice运算的目的是突出发生改变的像素。当运行该运算时,它是不需要的。

调整色度代码:

adjust_hue = tf.image.adjust_hue(image, 0.7)

sess.run(tf.slice(adjust_hue, [1, 0, 0], [1, 3, 3]))

输出:

array([[[195,  38,   5],
        [ 49, 227,   6],
        [205,  46,  11]]], dtype=uint8)

这段示例代码调整了图像中的色度,使其色彩更加丰富。该调整函数接收一个delta参数,用于控制需要调节的色度数量。

调整饱和的代码:

adjust_saturation = tf.image.adjust_saturation(image, 0.4)

sess.run(tf.slice(adjust_saturation, [1, 0, 0], [1, 3, 3]))

输出:

array([[[121, 195, 119],
        [138, 174, 227],
        [128, 205, 127]]], dtype=uint8)

这段代码与调节对比度的那段代码非常类似。为识别边缘,对图像进行过饱和处理是很常见的,因为增加饱和度能够突出颜色的变化。

5. 颜色

CNN通常使用具有单一颜色的图像来训练。当一幅图像只有单一颜色时,我们称它使用了灰度颜色空间,即单颜色通道。

对大多数计算机视觉相关任务而言,使用灰度值是合理的,因为要了解图像的形状无须借助所有的颜色信息。缩减颜色空间可加速训练过程。为描述图像中的灰度,仅需一个单个分量的秩1张量即可,而无须像RGB图像那样使用含3个分量的秩1张量。

虽然只使用灰度信息有一些优点,但也必须考虑那些需要利用颜色的区分性的应用。在大多数计算机视觉任务中,如何使用图像中的颜色都颇具挑战性,因为很难从数学上定义两个RGB颜色之间的相似度。为在CNN训练中使用颜色,对图像进行颜色空间变换有时是非常有用的。

代码示例:


输出:


5.1. 灰度

灰度图具有单个分量,且其取值范围与RGB图像中的颜色一样,也是[0,255]。

代码示例:

gray = tf.image.rgb_to_grayscale(image)
sess.run(tf.slice(gray, [0, 0, 0], [1, 3, 1]))

输出:

array([[[  5],
        [254],
        [ 83]]], dtype=uint8)

这个例子将RGB图像转换为灰度图。tf.slice运算提取了最上一行的像素,并查看其颜色是否发生了变化。这种灰度变换是通过将每个像素的所有颜色值取平均,并将其作为灰度值实现的。

5.2. HSV空间

色度、饱和度和灰度值构成了HSV颜色空间。与RGB空间类似,这个颜色空间也是用含3个分量的秩1张量表示的。HSV空间所度量的内容与RGB空间不同,它所度量的是图像的一些更为贴近人类感知的属性。有时HSV也被称为HSB,其中字母B表示亮度值。
代码示例:

hsv = tf.image.rgb_to_hsv(tf.image.convert_image_dtype(image, tf.float32))

sess.run(tf.slice(hsv, [0, 0, 0], [3, 3, 3]))

输出:

array([[[ 0.        ,  0.        ,  0.        ],
        [ 0.        ,  0.        ,  1.        ],
        [ 0.        ,  1.        ,  0.99607849]],

       [[ 0.33333334,  1.        ,  0.74901962],
        [ 0.59057975,  0.98712444,  0.91372555],
        [ 0.33333334,  1.        ,  0.74901962]],

       [[ 0.        ,  1.        ,  0.99607849],
        [ 0.        ,  0.        ,  1.        ],
        [ 0.        ,  0.        ,  0.        ]]], dtype=float32)

5.3. RGB空间

到目前为止,所有的示例代码中使用的都是RGB颜色空间。它对应于一个含3个分量的秩1张量,其中红、绿和蓝的取值范围均为[0,255]。大多数图像本身就位于RGB颜色空间中,但考虑到有些图像可能会来自其他颜色空间,TensorFlow也提供了一些颜色空间转换的内置函数。

代码示例:

rgb_hsv = tf.image.hsv_to_rgb(hsv)
rgb_grayscale = tf.image.grayscale_to_rgb(gray)

这段示例代码非常简单,只是从灰度空间转换到RGB空间并无太大的实际意义。RGB图像需要三种颜色,而灰度图像只需要一种颜色。当转换(灰度到RGB)发生时,RGB中每个像素的各通道都将被与灰度图中对应像素的灰度值填充。

5.4. LAB空间

TensorFlow并未为LAB颜色空间提供原生支持。它是一种有用的颜色空间,因为与RGB相比,它能够映射大量可感知的颜色。虽然TensorFlow并未为它提供原生支持,但它却是一种经常在专业场合使用的颜色空间。

Python库python-colormath为LAB和其他本书未提及的颜色空间提供了转换支持。

使用LAB颜色空间最大的好处在于与RGB或HSV空间相比,它对颜色差异的映射更贴近人类的感知。在LAB颜色空间中,两个颜色的欧氏距离在某种程度上能够反映人类所感受到的这两种颜色的差异。

5.5. 图像数据类型转换

在这些例子中,为说明如何修改图像的数据类型,tf.to_float被多次用到。对于某些例子,使用这种方式是可以的,

但TensorFlow还提供了一个内置函数,用于当图像数据类型发生变化时恰当地对像素值进行比例变换。tf.image.convert_iamge_dtype(image,dtype,saturate=False)是将图像的数据类型从tf.uint8更改为tf.float的便捷方法。

6. 参考文献:

+++十图详解TensorFlow数据读取机制(附代码) - 极客头条 - CSDN.NET

TensorFlow直接读取图片和读写TFRecords速度对比 - 知乎专栏


技术交流学习,请加QQ微信:631531977
目录