TensorFlow 实战(三)(2)https://developer.aliyun.com/article/1522743
练习答案
练习 1
- 如果出现欠拟合,你应该降低 dropout 率,以保持更多节点在训练过程中保持开启状态:
model = tf.keras.models.Sequential([ tf.keras.layers.Dense(100, activation=’relu’, input_shape=(250,)), tf.keras.layers.Dropout(0.2), tf.keras.layers.Dense(10, activation=’softmax’) ]) model.compile(loss=’categorical_crossentropy’, optimizer=’adam’, ➥ metrics=[‘accuracy’]) model.fit(X, y, epochs=25)
- 提前停止是通过使用 EarlyStopping 回调引入的:
es_callback = tf.keras.callbacks.EarlyStopping(monitor='val_loss', ➥ patience=5, min_delta=0.1) model.fit(X, y, epochs=25, callbacks=[es_callback])
练习 2
tf.keras.callbacks.EarlyStopping(monitor='val_loss', min_delta=0.01, patience=5)
练习 3
def my_conv_block(input, activation): out_1 = tf.keras.layers.Conv2D(n_filters[0][2], (3,3), strides=(1,1), kernel_initializer=initializer, activation=activation, padding='same')(input) out_final = tf.keras.layers.BatchNormalization()(out_1) out = out_final + out_1 return out
练习 4
model = tf.keras.models.Sequential([ tf.keras.layers.Input(shape=(224,224,3)), tf.keras.applications.VGG16(include_top=False, pooling='max'), tf.keras.layers.Dense(100, activation=’relu’), tf.keras.layers.Dense(50, activation='softmax') ])
第八章:区分事物:图像分割
本章内容涵盖
- 了解分割数据并在 Python 中处理它
- 实现一个完整的分割数据管道
- 实现高级分割模型(DeepLab v3)
- 使用自定义构建的图像分割损失函数/度量编译模型
- 对清洁和处理后的图像数据进行图像分割模型训练
- 评估经过训练的分割模型
在上一章中,我们学习了各种先进的计算机视觉模型和技术,以提高图像分类器的性能。我们了解了 Inception net v1 的架构以及它的后继者(例如 Inception net v2、v3 和 v4)。我们的目标是提高模型在一个包含 200 个不同类别的对象的 64×64 大小的 RGB 图像的图像分类数据集上的性能。在尝试在此数据集上训练模型时,我们学到了许多重要的概念:
- Inception blocks—一种将具有不同尺寸窗口(或核)的卷积层分组在一起的方法,以鼓励学习不同尺度的特征,同时由于更小尺寸的核而使模型参数高效。
- 辅助输出—Inception net 不仅在网络末端使用分类层(即具有 softmax 激活的完全连接层),而且还在网络中间使用。这使得从最终层到第一层的梯度能够强劲地传播。
- 数据增强—使用各种图像转换技术(调整亮度/对比度、旋转、平移等)使用 tf.keras.preprocessing.image.ImageDataGenerator 增加标记数据的数量。
- Dropout—随机打开和关闭层中的节点。这迫使神经网络学习更健壮的特征,因为网络并不总是激活所有节点。
- 提前停止—使用验证数据集上的性能作为控制训练何时停止的方法。如果在一定数量的 epochs 中验证性能没有提高,则停止训练。
- 迁移学习—下载并使用在更大、类似数据集上训练的预训练模型(例如 Inception-ResNet v2)作为初始化,并对其进行微调以在手头的任务上表现良好。
在本章中,我们将学习计算机视觉中另一个重要任务:图像分割。在图像分类中,我们只关心给定图像中是否存在对象。另一方面,图像分割不仅识别同一图像中的多个对象,还识别它们在图像中的位置。这是计算机视觉的一个非常重要的主题,像自动驾驶汽车这样的应用程序依赖于图像分割模型。自动驾驶汽车需要精确定位其周围的物体,这就是图像分割发挥作用的地方。你可能已经猜到,它们在许多其他应用程序中也有它们的根基:
- 图像检索
- 识别星系 (
mng.bz/gwVx
) - 医学图像分析
如果您是从事与图像相关问题的计算机视觉/深度学习工程师/研究人员,您的道路很可能会与图像分割相交。图像分割模型将图像中的每个像素分类为预定义的一组对象类别之一。图像分割与我们之前看到的图像分类任务有关。两者都解决了一个分类任务。此外,预训练的图像分类模型被用作分割模型的骨干,因为它们可以提供不同粒度的关键图像特征,以更好更快地解决分割任务。一个关键区别是图像分类器解决了一个稀疏预测任务,其中每个图像都有一个与之关联的单个类标签,而分割模型解决了一个密集预测任务,其中图像中的每个像素都有一个与之关联的类标签。
任何图像分割算法都可以分类为以下类型之一:
- 语义分割—该算法仅对图像中存在的不同类别的对象感兴趣。例如,如果图像中有多个人,则与所有人对应的像素将被标记为相同的类。
- 实例分割—该算法对单独识别不同对象感兴趣。例如,如果图像中有多个人,属于每个人的像素将被表示为唯一的类。与语义分割相比,实例分割被认为更难。
图 8.1 描述了语义分割任务中找到的数据与实例分割任务中找到的数据之间的区别。在本章中,我们将重点关注语义分割 (mng.bz/5QAZ
)。
图 8.1 语义分割与实例分割的比较
在下一节中,我们将更仔细地研究我们正在处理的数据。
8.1 理解数据
您正在尝试一个创业想法。这个想法是为小型遥控(RC)玩具开发一种导航算法。用户可以选择导航需要多安全或者冒险。作为第一步,您计划开发一个图像分割模型。图像分割模型的输出将稍后馈送到另一个模型,该模型将根据用户的请求预测导航路径。
对于这个任务,您觉得 Pascal VOC 2012 数据集会是一个很好的选择,因为它主要包含了城市/家庭环境中的室内和室外图像。它包含图像对:一个包含一些对象的输入图像和一个带有注释的图像。在注释图像中,每个像素都有一个分配的颜色,取决于该像素属于哪个对象。在这里,您计划下载数据集并成功将数据加载到 Python 中。
在深入了解/界定您想解决的问题之后,下一个重点应该是了解和探索数据。分割数据与我们迄今为止见过的图像分类数据集不同。一个主要的区别是输入和目标都是图像。输入图像是一个标准图像,类似于您在图像分类任务中找到的图像。与图像分类不同,目标不是标签,而是图像,其中每个像素都有来自预定义颜色调色板的颜色。换句话说,我们感兴趣的每个对象都被分配了一种颜色。然后,在输入图像中对应于该对象的像素以该颜色着色。可用颜色的数量与您想要识别的不同对象(加上背景)的数量相同(图 8.2)。
图 8.2 图像分类器与图像分割模型的输入和输出
对于这个任务,我们将使用流行的 PASCAL VOC 2012 数据集,该数据集由真实场景组成。数据集为 22 个不同类别提供了标签,如表 8.1 所述。
表 8.1 PASCAL VOC 2012 数据集中的不同类别及其相应的标签
类别 | 指定标签 | 类别 | 指定标签 |
背景 | 0 | 餐桌 | 11 |
飞机 | 1 | 狗 | 12 |
自行车 | 2 | 马 | 13 |
鸟 | 3 | 摩托车 | 14 |
船 | 4 | 人 | 15 |
瓶子 | 5 | 盆栽植物 | 16 |
公共汽车 | 6 | 羊 | 17 |
汽车 | 7 | 沙发 | 18 |
猫 | 8 | 火车 | 19 |
椅子 | 9 | 电视/显示器 | 20 |
牛 | 10 | 边界/未知对象 | 255 |
白色像素代表对象边界或未知对象。图 8.3 通过显示每个单独的对象类别的样本来说明数据集。
图 8.3 PASCAL VOC 2012 数据集的样本。数据集显示了单个示例图像,以及用于 20 种不同对象类别的注释分割。
在图 8.4 中,深入挖掘一下,你可以近距离看到单个样本数据点(最好是以彩色视图查看)。它有两个对象:一把椅子和一只狗。正如所示,不同的颜色分配给不同的对象类别。虽然最好以彩色查看图像,但您仍然可以通过注意在图像中勾勒对象的白色边框来区分不同的对象。
图 8.4 图像分割中的原始输入图像及其相应的目标标注/分割图像
首先,我们将从mng.bz/6XwZ
下载数据集(请参阅下一个清单)。
清单 8.1 下载数据
import os import requests import tarfile # Retrieve the data if *not* os.path.exists(os.path.join('data','VOCtrainval_11-May-2012.tar')): ❶ url = "http:/ /host.robots.ox.ac.uk/pascal/VOC/voc2012/VOCtrainval_11- ➥ May-2012.tar" # Get the file from web r = requests.get(url) ❷ if *not* os.path.exists('data'): os.mkdir('data') # Write to a file with open(os.path.join('data','VOCtrainval_11-May-2012.tar'), 'wb') as f: f.write(r.content) ❸ else: print("The tar file already exists.") if *not* os.path.exists(os.path.join('data', 'VOCtrainval_11-May-2012')): ❹ with tarfile.open(os.path.join('data','VOCtrainval_11-May-2012.tar'), 'r') as tar: tar.extractall('data') else: print("The extracted data already exists")
❶ 检查文件是否已经下载。如果已下载,则不要重新下载。
❷ 从 URL 获取内容。
❸ 将文件保存到磁盘。
❹ 如果文件存在但尚未提取,则提取文件。
数据集的下载与我们以前的经验非常相似。数据作为 tar 文件存在。如果文件不存在,我们会下载文件并解压缩。接下来,我们将讨论如何使用图像库 Pillow 和 NumPy 将图像加载到内存中。在这里,目标图像将需要特殊处理,因为您将看到它们不是使用常规方法存储的。加载输入图像到内存中没有任何意外情况。使用 PIL(即 Pillow)库,可以通过一行代码加载它们:
from PIL import Image orig_image_path = os.path.join('data', 'VOCtrainval_11-May-2012', ➥ 'VOCdevkit', 'VOC2012', 'JPEGImages', '2007_000661.jpg') orig_image = Image.open(orig_image_path)
接下来,您可以检查图像的属性:
print("The format of the data {}".format(orig_image.format)) >>> The format of the data JPEG print("This image is of size: {}".format(orig_image.shape)) >>> This image is of size: (375, 500, 3)
是时候加载相应的注释/分割的目标图像了。如前所述,目标图像需要特殊关注。目标图像不是作为标准图像存储的,而是作为调色板化图像存储的。调色板化是一种在图像中存储具有固定颜色数量的图像时减少内存占用的技术。该方法的关键在于维护一个颜色调色板。调色板被存储为整数序列,其长度为颜色数量或通道数量(例如,对于 RGB 的情况,一个像素由三个值对应于红、绿和蓝,通道数量为三。灰度图像具有单个通道,其中每个像素由单个值组成)。然后,图像本身存储了一个索引数组(大小为高度×宽度),其中每个索引映射到调色板中的一种颜色。最后,通过将图像中的调色板索引映射到调色板颜色,可以计算出原始图像。图 8.5 提供了这个讨论的视觉展示。
图 8.5 显示了 PASCAL VOC 2012 数据集中输入图像和目标图像的数值表示。
下一个清单展示了从调色板图像中重新构造原始图像像素的代码。
代码清单 8.2 从调色板图像中重建原始图像
def rgb_image_from_palette(image): """ This function restores the RGB values form a palletted PNG image """ palette = image.get_palette() ❶ palette = np.array(pallette).reshape(-1,3) ❷ if isinstance(image, PngImageFile): h, w = image.height, image.width ❸ # Squash height and width dimensions (makes slicing easier) image = np.array(image).reshape(-1) ❹ elif isinstance(image, np.ndarray): ❺ h, w = image.shape[0], image.shape[1] image = image.reshape(-1) rgb_image = np.zeros(shape=(image.shape[0],3)) ❻ rgb_image[(image != 0),:] = pallette[image[(image != 0)], :] ❻ rgb_image = rgb_image.reshape(h, w, 3) ❼ return rgb_image
❶ 从图像中获取颜色调色板。
❷ 调色板以向量形式存储。我们将其重新整形为一个数组,其中每一行表示一个单独的 RGB 颜色。
❸ 获取图像的高度和宽度。
❹ 将以数组形式存储的调色板图像转换为向量(有助于接下来的步骤)。
❺ 如果图像是以数组而不是 Pillow 图像提供的,将图像作为向量获取。
❻ 首先,我们定义一个与图像长度相同的零向量。然后,对于图像中的所有索引,我们从调色板中获取相应的颜色,并将其分配到 rgb_image 的相同位置。
❼ 恢复原始形状。
在这里,我们首先使用 get_palette()函数获取图像的调色板。这将作为一个一维数组存在(长度为类别数×通道数)。接下来,我们需要将数组重塑为一个(类别数,通道数)大小的数组。在我们的情况下,这将转换为一个(22,3)大小的数组。由于我们将重塑的第一维定义为-1,它将从原始数据的大小和重塑操作的其他维度中自动推断出来。最后,我们定义一个全零数组,它最终将存储图像中找到的索引的实际颜色。为此,我们使用图像(包含索引)索引 rgb_image 向量,并将调色板中匹配的颜色分配给这些索引。
利用我们迄今为止看到的数据,让我们定义一个 TensorFlow 数据管道,将数据转换和转换为模型可接受的格式。
练习 1
你已经提供了一个以 RGB 格式表示的 rgb_image,其中每个像素属于 n 种独特的颜色之一,并且已经给出了一个称为调色板的调色板,它是一个[n,3]大小的数组。你将如何将 rgb_image 转换为调色板图像?
提示 你可以通过使用三个 for 循环来创建一个简单的解决方案:两个循环用于获取 rgb_image 的单个像素,然后最后一个循环用于遍历调色板中的每种颜色。
8.2 认真对待:定义一个 TensorFlow 数据管道
到目前为止,我们已经讨论了将帮助我们为 RC 玩具构建导航算法的数据。在构建模型之前,一个重要的任务是完成从磁盘到模型的可扩展数据摄取方法。提前完成这项工作将节省大量时间,当我们准备扩展或投产时。你认为最好的方法是实现一个 tf.data 管道,从磁盘中检索图像,对它们进行预处理、转换,并使其准备好供模型获取。该管道应该读取图像,将它们重塑为固定大小(对于变尺寸图像),对它们进行数据增强(在训练阶段),分批处理它们,并为所需的 epoch 数重复此过程。最后,我们将定义三个管道:一个训练数据管道,一个验证数据管道和一个测试数据管道。
在数据探索阶段结束时,我们的目标应该是建立一个从磁盘到模型的可靠数据管道。这就是我们将在这里看到的。从高层次来看,我们将建立一个 TensorFlow 数据管道,执行以下任务:
- 获取属于某个子集(例如,训练、验证或测试)的文件名。
- 从磁盘中读取指定的图像。
- 预处理图像(包括对图像进行归一化/调整大小/裁剪)。
- 对图像执行增强以增加数据量。
- 将数据分批处理成小批次。
- 使用几种内置优化技术优化数据检索。
作为第一步,我们将编写一个函数,返回一个生成器,该生成器将生成我们要获取的数据的文件名。我们还将提供指定用户想要获取的子集(例如,训练、验证或测试)的能力。通过生成器返回数据将使编写tf.data
流水线更容易(参见下面的代码清单)。
图 8.3 检索给定数据子集的文件名列表
def get_subset_filenames(orig_dir, seg_dir, subset_dir, subset): """ Get the filenames for a given subset (train/valid/test)""" if subset.startswith('train'): ser = pd.read_csv( ❶ os.path.join(subset_dir, "train.txt"), index_col=None, header=None, squeeze=True ).tolist() elif subset.startswith('val') or subset.startswith('test'): random.seed(random_seed) ❷ ser = pd.read_csv( ❸ os.path.join(subset_dir, "val.txt"), index_col=None, header=None, squeeze=True ).tolist() random.shuffle(ser) ❹ if subset.startswith('val'): ser = ser[:len(ser)//2] ❺ else: ser = ser[len(ser)//2:] ❻ else: raise NotImplementedError("Subset={} is not recognized".format(subset)) orig_filenames = [os.path.join(orig_dir,f+'.jpg') for f in ser] ❼ seg_filenames = [os.path.join(seg_dir, f+'.png') for f in ser] ❽ for o, s in zip(orig_filenames, seg_filenames): yield o, s ❾
❶ 读取包含训练实例文件名的 CSV 文件。
❷ 对验证/测试子集执行一次洗牌,以确保我们使用固定的种子得到良好的混合。
❸ 读取包含验证/测试文件名的 CSV 文件。
❹ 修复种子后对数据进行洗牌。
❺ 将第一半部分作为验证集。
❻ 将第二半部分作为测试集。
❼ 形成我们捕获的输入图像文件的绝对路径(取决于子集参数)。
❽ 将文件名对(输入和注释)作为生成器返回。
❾ 形成分段图像文件的绝对路径。
您可以看到,在读取 CSV 文件时我们传递了一些参数。这些参数描述了我们正在读取的文件。这些文件非常简单,每行只包含一个图像文件名。index_col=None
表示文件没有索引列,header=None
表示文件没有标题,squeeze=True
表示输出将被呈现为 pandas Series,而不是 pandas Dataframe。有了这些,我们可以定义一个 TensorFlow 数据集(tf.data.Dataset
),如下所示:
filename_ds = tf.data.Dataset.from_generator( subset_filename_gen_func, output_types=(tf.string, tf.string) )
TensorFlow 有几个不同的函数,用于使用不同的来源生成数据集。由于我们已经定义了函数get_subset_filenames()
来返回一个生成器,我们将使用tf.data.Dataset.from_generator()
函数。注意,我们需要提供返回数据的格式和数据类型,通过生成器使用output_types
参数。函数subset_filename_gen_func
返回两个字符串;因此,我们将输出类型定义为两个tf.string
元素的元组。
另一个重要方面是我们根据子集从不同的 txt 文件中读取的情况。在相对路径中有三个不同的文件:data\VOCtrainval_11-May-2012\VOCdevkit\VOC2012\ImageSets\Segmentation
文件夹;train.txt
、val.txt
和 trainval.txt
。在这里,train.txt
包含训练图像的文件名,而 val.txt
包含验证/测试图像的文件名。我们将使用这些文件创建不同的流水线,产生不同的数据。
tf.data
是从哪里来的?
TensorFlow 的tf.data
流水线可以从各种来源消耗数据。以下是一些常用的检索数据的方法:
tf.data.Dataset.from_generator(gen_fn)
——你已经在实际操作中见过这个函数。如果你有一个生成器(即 gen_fn
)产生数据,你希望它通过一个 tf.data
流水线进行处理。这是使用的最简单的方法。
tf.data.Dataset.from_tensor_slices(t)——如果你已经将数据加载为一个大矩阵,这是一个非常有用的函数。t 可以是一个 N 维矩阵,这个函数将在第一个维度上逐个元素地提取。例如,假设你已经将一个大小为 3 × 4 的张量 t 加载到内存中:
t = [ [1,2,3,4], [2,3,4,5], [6,7,8,9] ]
然后,你可以轻松地设置一个 tf.data 管道,如下所示。tf.data.Dataset.from_tensor_slices(t) 将返回 [1,2,3,4],然后 [2,3,4,5],最后 [6,7,8,9] 当你迭代这个数据管道时。换句话说,你每次看到一行(即从批处理维度中切片,因此称为 from_tensor_slices)。
现在是时候读取我们在上一步获取的文件路径中找到的图像了。TensorFlow 提供了支持,可以轻松加载图像,其中文件名路径为 img_filename,使用函数 tf.io.read_file 和 tf.image.decode_image。在这里,img_filename 是一个 tf.string(即 TensorFlow 中的字符串):
tf.image.decode_jpeg(tf.io.read_file(image_filename))
我们将使用这种模式来加载输入图像。然而,我们需要实现一个自定义图像加载函数来加载目标图像。如果你使用前面的方法,它将自动将图像转换为具有像素值的数组(而不是调色板索引)。但如果我们不执行该转换,我们将得到一个精确符合我们需要的格式的目标数组,因为目标图像中的调色板索引是输入图像中每个对应像素的实际类标签。我们将在 TensorFlow 数据管道中使用 PIL.Image 来加载图像作为调色板图像,并避免将其转换为 RGB:
from PIL import Image def load_image_func(image): """ Load the image given a filename """ img = np.array(Image.open(image)) return img
然而,你还不能将自定义函数作为 tf.data 管道的一部分使用。它们需要通过将其包装为 TensorFlow 操作来与数据管道的数据流图相协调。这可以通过使用 tf.numpy_function 操作轻松实现,它允许你将返回 NumPy 数组的自定义函数包装为 TensorFlow 操作。如果我们用 y 表示目标图像的文件路径,你可以使用以下代码将图像加载到 TensorFlow 中并使用自定义图像加载函数:
tf.numpy_function(load_image_func, inp=[y], Tout=[tf.uint8])
tf.numpy_function 的黑暗面
NumPy 对各种科学计算有比 TensorFlow 更广泛的覆盖,所以你可能会认为 tf.numpy_function 让事情变得非常方便。但事实并非如此,因为你可能会在 TensorFlow 代码中引入可怕的性能下降。当 TensorFlow 执行 NumPy 代码时,它可能会创建非常低效的数据流图并引入开销。因此,尽量坚持使用 TensorFlow 操作,并且只在必要时使用自定义的 NumPy 代码。在我们的情况下,由于没有其他方法可以加载调色板图像而不将调色板值映射到实际的 RGB,我们使用了一个自定义函数。
请注意,我们将输入(即,inp=[y])和其数据类型(即,Tout=[tf.uint8])都传递给此函数。它们都需要以 Python 列表的形式存在。最后,让我们把我们讨论的所有内容都整理到一个地方:
def load_image_func(image): """ Load the image given a filename """ img = np.array(Image.open(image)) return img # Load the images from the filenames returned by the above step image_ds = filename_ds.map(lambda x,y: ( tf.image.decode_jpeg(tf.io.read_file(x)), tf.numpy_function(load_image_func, [y], [tf.uint8]) ))
tf.data.Dataset.map() 函数将在本讨论中大量使用。您可以在侧边栏中找到 map() 函数的详细解释。
刷新器:tf.data.Dataset.map() 函数
此 tf.data 管道将大量使用 tf.data.Dataset.map() 函数。因此,我们提醒自己此函数实现了什么功能是非常有帮助的。
td.data.Dataset.map() 函数将给定的函数或多个函数应用于数据集中的所有记录。换句话说,它使用指定的转换来转换数据集中的数据点。例如,假设 tf.data.Dataset
dataset = tf.data.Dataset.from_tensor_slices([1, 2, 3, 4])
要获取每个元素的平方,可以使用 map 函数如下
dataset = dataset.map(lambda x: x**2)
如果在单个记录中有多个元素,则可以利用 map()的灵活性来分别转换它们:
dataset = tf.data.Dataset.from_tensor_slices([[1,3], [2,4], [3,5], [4,6]]) dataset = dataset.map(lambda x, y: (x**2, y+x)) which will return, [[1, 4], [4, 6], [9, 8], [16, 10]]
作为规范化步骤,我们将通过使用将像素值带到 [0,1] 范围的方法
image_ds = image_ds.map(lambda x, y: (tf.cast(x, 'float32')/255.0, y))
请注意,我们保留了目标图像(y)。在我们的管道中继续进行更多步骤之前,我想引起您的注意。这是一个相当常见的警告,因此值得注意。在我们刚刚完成的步骤之后,您可能会觉得,如果您愿意,您可以将数据进行批处理并将其馈送到模型中。例如
image_ds = image_ds.batch(10)
如果您对此数据集进行此操作,将会收到以下错误:
InvalidArgumentError: Cannot batch tensors with different shapes in ➥ component 0\. First element had shape [375,500,3] and element 1 had ➥ shape [333,500,3]. [Op:IteratorGetNext]
这是因为您忽略了数据集的一个关键特征和一个健全性检查。除非您使用的是经过筛选的数据集,否则您不太可能找到具有相同尺寸的图像。如果您查看数据集中的图像,您会注意到它们的尺寸不同;它们具有不同的高度和宽度。在 TensorFlow 中,除非您使用像 tf.RaggedTensor 这样的特殊数据结构,否则无法将大小不同的图像一起进行批处理。这正是 TensorFlow 在错误中抱怨的内容。
为了缓解问题,我们需要将所有图像调整为标准大小(请参见列表 8.4)。为此,我们将定义以下函数。它将
- 将图像调整为较大的尺寸(resize_to_before_crop),然后将图像裁剪为所需大小(input_size),或者
- 将图像调整为所需大小(input_size)
列表 8.4 使用随机裁剪或调整大小将图像调整为固定大小
def randomly_crop_or_resize(x,y): """ Randomly crops or resizes the images """ def rand_crop(x, y): ❶ """ Randomly crop images after enlarging them """ x = tf.image.resize(x, resize_to_before_crop, method='bilinear') ❷ y = tf.cast( ❸ tf.image.resize( tf.transpose(y,[1,2,0]), ❹ resize_to_before_crop, method='nearest' ), 'float32' ) offset_h = tf.random.uniform( [], 0, x.shape[0]-input_size[0], dtype='int32' ) ❺ offset_w = tf.random.uniform( [], 0, x.shape[1]-input_size[1], dtype='int32' ) ❻ x = tf.image.crop_to_bounding_box( image=x, offset_height=offset_h, offset_width=offset_w, target_height=input_size[0], target_width=input_size[1] ❼ ) y = tf.image.crop_to_bounding_box( image=y, offset_height=offset_h, offset_width=offset_w, target_height=input_size[0], target_width=input_size[1] ❼ ) return x, y def resize(x, y): """ Resize images to a desired size """ x = tf.image.resize(x, input_size, method='bilinear') ❽ y = tf.cast( tf.image.resize( tf.transpose(y,[1,2,0]), input_size, method='nearest' ❽ ), 'float32' ) return x, y rand = tf.random.uniform([], 0.0,1.0) ❾ if augmentation and \ ❿ (input_size[0] < resize_to_before_crop[0] or \ input_size[1] < resize_to_before_crop[1]): x, y = tf.cond( rand < 0.5, ⓫ lambda: rand_crop(x, y), lambda: resize(x, y) ) else: x, y = resize(x, y) ⓬ return x, y
❶ 定义一个函数,在调整大小后随机裁剪图像。
❷ 使用双线性插值将输入图像调整为较大的尺寸。
❸ 使用最近邻插值将目标图像调整为较大的尺寸。
❹ 要调整大小,我们首先交换 y 轴的轴,因为它的形状为 [1, height, width]。我们使用 tf.transpose() 函数将其转换回 [height, width, 1](即,单通道图像)。
❺ 定义一个随机变量,在裁剪期间偏移图像的高度。
❻ 定义一个随机变量,在裁剪期间在宽度上对图像进行偏移。
❼ 使用相同的裁剪参数裁剪输入图像和目标图像。
❽ 将输入图像和目标图像都调整为所需大小(不裁剪)。
❾ 定义一个随机变量(用于执行增强)。
❿ 如果启用增强并且调整大小后的图像大于我们请求的输入大小,则执行增强。
⓫ 在增强期间,随机执行 rand_crop 或 resize 函数。
⓬ 如果禁用增强,则只调整大小。
这里,我们定义了一个名为 randomly_crop_or_resize 的函数,其中包含两个嵌套函数 rand_crop 和 resize。rand_crop 首先将图像调整为 resize_to_before_crop 中指定的大小,并创建一个随机裁剪。务必检查是否对输入和目标应用了完全相同的裁剪。例如,应使用相同的裁剪参数对输入和目标进行裁剪。为了裁剪图像,我们使用
x = tf.image.crop_to_bounding_box( image=x, offset_height=offset_h, offset_width=offset_w, target_height=input_size[0], target_width=input_size[1] ) y = tf.image.crop_to_bounding_box( image=y, offset_height=offset_h, offset_width=offset_w, target_height=input_size[0], target_width=input_size[1] )
参数的含义不言而喻:image 接受要裁剪的图像,offset_height 和 offset_width 决定裁剪的起点,target_height 和 target_width 指定裁剪后的最终大小。resize 函数将使用 tf.image.resize 操作简单地将输入和目标调整为指定大小。
在调整大小时,我们对输入图像使用双线性插值,对目标使用最近邻插值。双线性插值通过计算结果像素的邻近像素的平均值来调整图像大小,而最近邻插值通过从邻居中选择最近的常见像素来计算输出像素。双线性插值在调整大小后会导致更平滑的结果。然而,必须对目标图像使用最近邻插值,因为双线性插值会导致分数输出,破坏基于整数的注释。图 8.6 可视化了所描述的插值技术。
图 8.6 最近邻插值和双线性插值用于上采样和下采样任务
接下来,我们将在使用这两个嵌套函数的方式上引入一个额外的步骤。如果启用了增强,我们希望裁剪或调整大小在管道中随机地发生。我们将定义一个随机变量(从介于 0 和 1 之间的均匀分布中抽取)并根据随机变量的值在给定时间内执行裁剪或调整大小。可以使用 tf.cond 函数实现这种条件,该函数接受三个参数,并根据这些参数返回输出:
- Condition——这是一个计算结果为布尔值的计算(即随机变量 rand 是否大于 0.5)。
- true_fn——如果条件为真,则执行此函数(即对 x 和 y 执行 rand_crop)
- false_fn——如果条件为假,则执行此函数(即对 x 和 y 执行调整大小)
如果禁用了增强(即通过将augmentation
变量设置为False
),则仅执行调整大小操作。详细信息澄清后,我们可以在我们的数据管道中使用randomly_crop_or_resize
函数如下:
image_ds = image_ds.map(lambda x,y: randomly_crop_or_resize(x,y))
此时,我们的管道中出现了一个全局固定大小的图像。接下来我们要处理的事情非常重要。诸如图像大小可变和用于加载图像的自定义 NumPy 函数等因素使得 TensorFlow 在几个步骤之后无法推断其最终张量的形状(尽管它是一个固定大小的张量)。如果您检查此时产生的张量的形状,您可能会将它们视为
(None, None, None)
这意味着 TensorFlow 无法推断张量的形状。为了避免任何歧义或问题,我们将设置管道中输出的形状。对于张量t
,如果形状不明确但您知道形状,您可以使用手动设置形状
t.set_shape([<shape of the tensor>])
在我们的数据管道中,我们可以设置形状为
def fix_shape(x, y, size): """ Set the shape of the input/target tensors """ x.set_shape((size[0], size[1], 3)) y.set_shape((size[0], size[1], 1)) return x, y image_ds = image_ds.map(lambda x,y: fix_shape(x,y, target_size=input_size))
我们知道跟随调整大小或裁剪的输出将会是
- 输入图像 —— 一个具有
input_size
高度和宽度的 RGB 图像 - 目标图像 —— 一个具有
input_size
高度和宽度的单通道图像
我们将使用tf.data.Dataset.map()
函数相应地设置形状。不能低估数据增强的威力,因此我们将向我们的数据管道引入几个数据增强步骤(见下一篇列表)。
列表 8.5 用于图像随机增强的函数
def randomly_flip_horizontal(x, y): """ Randomly flip images horizontally. """ rand = tf.random.uniform([], 0.0,1.0) ❶ def flip(x, y): return tf.image.flip_left_right(x), tf.image.flip_left_right(y) ❷ x, y = tf.cond(rand < 0.5, lambda: flip(x, y), lambda: (x, y)) ❸ return x, y if augmentation: image_ds = image_ds.map(lambda x, y: randomly_flip_horizontal(x,y)) ❹ image_ds = image_ds.map(lambda x, y: (tf.image.random_hue(x, 0.1), y)) ❺ image_ds = image_ds.map(lambda x, y: (tf.image.random_brightness(x, 0.1), y)) ❻ image_ds = image_ds.map(lambda x, y: (tf.image.random_contrast(x, 0.8, 1.2), y))❼
❶ 定义一个随机变量。
❷ 定义一个函数来确定性地翻转图像。
❸ 使用与之前相同的模式,我们使用tf.cond
随机执行水平翻转。
❹ 在数据集中随机翻转图像。
❺ 随机调整输入图像的色调(即颜色)(目标保持不变)。
❻ 随机调整输入图像的亮度(目标保持不变)。
❼ 随机调整输入图像的对比度(目标保持不变)。
TensorFlow 实战(三)(4)https://developer.aliyun.com/article/1522745