老齐教室

用Python读写海量图片的方法

翻译:老齐


为什么必须要了解更多用Python存储和访问图像的方法?如果你的业务只用到少量图片,比如根据图像的色彩分类,,或者用OpenCV实现人脸识别,这时完全不用担心这个问题了。即使借助Python的PIL,也能轻松处理几百张照片,把图像以.png.jpg文件的形式存储在磁盘上,简单方便又恰当。

然而,现实的任务不都如此,比如卷积神经网络(CNN)等算法可以处理包含大量图像的数据集,还可以从中学习。如果你对此感兴趣,可以申请加入本文的微信公众号“老齐教室”提供的在线《机器学习案例》,在真实的案例项目中去体验。

注:关注微信公众号:老齐教室,回复“姓名+手机号+’案例’”,申请获得《机器学习案例集》,本文的代码和数据,都已经收集到此案例集。

WechatIMG6

ImageNet是一个著名的公共图像数据库,可以用于对象分类、识别等任务的模型训练,它包含超过1400万张图像。

想一想要花多长时间才能把它们分批地、成百上千次地装入内存中进行训练。如果你用常规方法来读取这些图片,应该在开始读取之后,离开电脑去做点别的事情,回来后还不一定完成。但是,如果你希望去谷歌或英伟达工作,就不能这样玩。

在本文中,你将了解:

  • 将图像作为.png文件存储在磁盘上
  • 将图像存储到LMDB(lightning memory-mapped databases,闪电般的内存映射数据库)
  • 将图像存储到HDF5格式的文件中

我们还将探索以下内容:

  • 为什么替代存储方法值得考虑
  • 当你读、写单个图像时,这三种方法的性能有什么不同
  • 当你读、写多个图像时,这三种方法的性能有什么不同
  • 这三种方法在磁盘使用方面的比较

如果没有一种存储方法听起来耳熟,不要担心:对于这篇文章,你所需要的只是一些基本的Python语言知识以及对图像(它们实际上是由多维数组组成的)、内存的基本理解,比如10MB和10GB之间的差异。

我们开始吧!

安装程序

下面的项目中,需要一个图像数据集,以及一些Python包。本文的微信公众号“老齐教室”对下述所有代码均提供了在线实验平台,请按照前面提示申请使用《机器学习案例集》

数据集

案例中的数据集来自众所周知的CIFAR-10,它由60000个32x32像素的彩色图像组成,这些图像属于不同的对象类别,如狗、猫和飞机。相对而言,CIFAR不是一个很大的数据集,但是如果我们使用完整的TinyImages数据集,就需要大约400GB的可用磁盘空间,对于学习而言,这太奢侈了。

此数据集已经上传到本文的微信公众号“老齐教室”的《机器学习案例集》,可以按照前述方式申请获得。

以下代码将从数据集文件中读取图像数据,并加载到NumPy数组中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import numpy as np
import pickle
from pathlib import Path

# Path to the unzipped CIFAR data
data_dir = Path("data/cifar-10-batches-py/")

# Unpickle function provided by the CIFAR hosts
def unpickle(file):
with open(file, "rb") as fo:
dict = pickle.load(fo, encoding="bytes")
return dict

images, labels = [], []
for batch in data_dir.glob("data_batch_*"):
batch_data = unpickle(batch)
for i, flat_im in enumerate(batch_data[b"data"]):
im_channels = []
# Each image is flattened, with channels in order of R, G, B
for j in range(3):
im_channels.append(
flat_im[j * 1024 : (j + 1) * 1024].reshape((32, 32))
)
# Reconstruct the original image
images.append(np.dstack((im_channels)))
# Save the label
labels.append(batch_data[b"labels"][i])

print("Loaded CIFAR-10 training set:")
print(f" - np.shape(images) {np.shape(images)}")
print(f" - np.shape(labels) {np.shape(labels)}")

所有的图像用images变量引用,对应的元数据保存在labels中。接下来,你可以安装以下的三个Python包。

在磁盘上存储图像

你需要为从磁盘上保存和读取这些图像的默认方法设置环境。本文假设你的系统上安装了Python 3.x,并将使用Pillow进行图像处理:

1
$ pip install Pillow

或者,如果你愿意,可以使用Anaconda安装它:

1
$ conda install -c conda-forge pillow

注意:PIL是Pillow的原始版本,目前它已经不再维护,并且与Python 3.x不兼容。如果你先前安装了PIL,请在安装Pillow之前卸载它,因为它们彼此。

现在你可以存储和读取磁盘上的图像了。

LMDB入门

LMDB,有时被称为“闪电数据库”,意味着像闪电般那么快的内存映射数据库,由此可见,它速度快,并且使用内存映射文件。它以键值对存储,不是关系数据库。

在实现方面,LMDB是一个B+树,这基本上意味着它是存储在内存中的树状图结构,其中每个键值对都是一个节点,节点可以有许多子节点。同一级别的节点相互链接以进行快速遍历。

关键在于,B+树的关键组件被设置为与主机操作系统的文件相对应。当访问数据库中的任何键值对时,实现效率最大化。由于LMDB的高性能在很大程度上依赖于这一点,LMDB的效率已经被证明依赖于底层文件系统及其实现。

LMDB效率的另一个关键原因是:它是内存映射的。这意味着它返回指向键和值的内存地址的直接指针,而不需要像大多数其他数据库那样复制内存中的任何内容。

如果你对B+树不感兴趣,别担心。后面的操作中,我们不需要为了使用LMDB,你不需要了解它们的内部实现。我们将使用Python的LMDB C,用pip安装:

1
$ pip install lmdb

你还可以选择通过Anaconda安装:

1
$ conda install -c conda-forge python-lmdb

然后在Python交互模式中,用import lmdb检查,不报错,就OK了。

HDF5入门

HDF5代表分层数据格式,这种文件格式被称为HDF4或HDF5。我们不需要担心HDF4,因为HDF5是当前维护的版本。

有趣的是,HDF起源于(美国)国家超级计算应用中心,是一种便携式、紧凑的科学数据格式。如果你想知道它是否被广泛使用,请查看美国宇航局的地球数据项目中关于HDF5的简介。

HDF文件由两种类型的对象组成:

  • 数据集
  • 群组

数据集是多维数组,群组由数据集或其他组组成。任何大小和类型的多维数组都可以存储为数据集,但数据集中的维度和类型必须统一。每个数据集必须包含一个同构的N维数组。也就是说,因为组和数据集可能是嵌套的,所以你仍然可以获得可能需要的异构性:

1
$ pip install h5py

与其他库一样,你可以通过Anaconda安装:

1
$ conda install -c conda-forge h5py

如果你import h5py不报错,那也说明一切都将正确设置。

存储单个图像

现在,你已经对这些方法有了一个大致的了解,让我们直接进入主题:读、写文件各需要多长时间,以及将占用多少内存。通过这些示例,也可以了解每种方法的基本工作原理。

当我提到文件时,通常指的是很多文件。但是,由于有些方法可能针对不同的操作和文件数量进行了优化,因此进行区分是很重要的。

为了便于实验,我们可以比较读取不同数量的文件的性能,把图片的数量按10的倍数从1张增至10万张。由于我们的五批CIFAR-10总共有50000个图像,因此可以每个图像可以用两次,总共获得100000个图像。

为了准备实验,你需要为每个方法创建一个文件夹,其中包含所有数据库文件或图像:

1
2
3
4
5
from pathlib import Path

disk_dir = Path("data/disk/")
lmdb_dir = Path("data/lmdb/")
hdf5_dir = Path("data/hdf5/")

Path不会自动为你创建文件夹,除非你明确地要求它这样做:

1
2
3
disk_dir.mkdir(parents=True, exist_ok=True)
lmdb_dir.mkdir(parents=True, exist_ok=True)
hdf5_dir.mkdir(parents=True, exist_ok=True)

在接下来的代码中,可以使用Python标准库中timeit模块来对程序计时。

存储到磁盘

下面的实验中,输入是一个单独的图像image,当前作为NumPy数组存储在内存中。首先要将其作为.png图像保存到磁盘上,并使用唯一的图像ID image_id对其命名。这个步骤可以使用之前安装的Pillow完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from PIL import Image
import csv

def store_single_disk(image, image_id, label):
""" Stores a single image as a .png file on disk.
Parameters:
---------------
image image array, (32, 32, 3) to be stored
image_id integer unique ID for image
label image label
"""
Image.fromarray(image).save(disk_dir / f"{image_id}.png")

with open(disk_dir / f"{image_id}.csv", "wt") as csvfile:
writer = csv.writer(
csvfile, delimiter=" ", quotechar="|", quoting=csv.QUOTE_MINIMAL
)
writer.writerow([label])

这样可以保存图像。在所有实际的应用程序中,你还要关心附加到图像的元数据。在我们的示例数据集中,元数据是图像标签。将图像存储到磁盘时,有几中不同的保存元数据的方式。

一种是将标签编码为图像名称。这样做的好处是不需要任何额外的文件。

但是,它也有一个很大的缺点,即:无论何时处理标签,都会强迫你处理所有文件。将标签存储在一个单独的文件中可以允许你单独处理标签,而不必加载图像。在上面的代码中,我已经为这个实验将标签存储在一个单独的.csv文件中。

现在让我们继续使用LMDB执行完全相同的任务。

存储到LMDB

首先,LMDB是一个键值存储系统,其中每个条目都保存为一个字节数组。因此在我们的例子中,键将是每个图像的唯一标识符,值将是图像本身。键和值都应该是字符串,此通常的用法是将值序列化为字符串,然后在读取时反序列化。

你可以使用pickle进行序列化。任何Python对象都可以序列化,因此你也可以在数据库中包含图像元数据。这就避免了从磁盘加载数据集时将元数据附加回图像数据的麻烦。

你可以为图像及其元数据创建一个基本的Python类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class CIFAR_Image:
def __init__(self, image, label):
# Dimensions of image for reconstruction - not really necessary
# for this dataset, but some datasets may include images of
# varying sizes
self.channels = image.shape[2]
self.size = image.shape[:2]

self.image = image.tobytes()
self.label = label

def get_image(self):
""" Returns the image as a numpy array. """
image = np.frombuffer(self.image, dtype=np.uint8)
return image.reshape(*self.size, self.channels)

其次,因为LMDB是内存映射的,所以新的数据库需要知道它们将消耗多少内存。这在我们这里相对简单,但在其他的案例中可能是一个巨大的麻烦。LMDB以map_size表示与内存相关的参数。

最后,在transactions中用LMDB执行读写操作。你可以把它们看作类似于传统数据库,由数据库上的一组操作组成。这看起来可能已经比磁盘版本复杂得多,但是请坚持读下去!

考虑到这三点,让我们看看将单个图像保存到LMDB的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import lmdb
import pickle

def store_single_lmdb(image, image_id, label):
""" Stores a single image to a LMDB.
Parameters:
---------------
image image array, (32, 32, 3) to be stored
image_id integer unique ID for image
label image label
"""
map_size = image.nbytes * 10

# Create a new LMDB environment
env = lmdb.open(str(lmdb_dir / f"single_lmdb"), map_size=map_size)

# Start a new write transaction
with env.begin(write=True) as txn:
# All key-value pairs need to be strings
value = CIFAR_Image(image, label)
key = f"{image_id:08}"
txn.put(key.encode("ascii"), pickle.dumps(value))
env.close()

现在可以将图像保存到LMDB。最后,让我们看看最后一种方法:HDF5。

存储到HDF5

记住,HDF5文件可以包含多个数据集。在这种情况下,你可以创建两个数据集,一个用于图像,一个用于图像的元数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import h5py

def store_single_hdf5(image, image_id, label):
""" Stores a single image to an HDF5 file.
Parameters:
---------------
image image array, (32, 32, 3) to be stored
image_id integer unique ID for image
label image label
"""
# Create a new HDF5 file
file = h5py.File(hdf5_dir / f"{image_id}.h5", "w")

# Create a dataset in the file
dataset = file.create_dataset(
"image", np.shape(image), h5py.h5t.STD_U8BE, data=image
)
meta_set = file.create_dataset(
"meta", np.shape(label), h5py.h5t.STD_U8BE, data=label
)
file.close()

h5py.h5t.STD_U8BE指定将要存储在数据集中的数据类型,在本例中是无符号8位整数。

注意:数据类型的选择将强烈影响HDF5的运行时间和存储要求,因此最好选择最低要求。

现在,我们已经回顾了保存单个图像的三种方法。让我们进入下一个步骤。

存储单个图像的实验

你可以把用于保存单个图像的所有三个函数放入字典中,该字典可以在稍后的计时代码中使用:

1
2
3
_store_single_funcs = dict(
disk=store_single_disk, lmdb=store_single_lmdb, hdf5=store_single_hdf5
)

万事俱备只欠东风。让我们尝试保存CIFAR中的第一个图像及其相应的标签,并以三种不同的方式存储它:

1
2
3
4
5
6
7
8
9
10
11
12
13
from timeit import timeit

store_single_timings = dict()

for method in ("disk", "lmdb", "hdf5"):
t = timeit(
"_store_single_funcs[method](image, 0, label)",
setup="image=images[0]; label=labels[0]",
number=1,
globals=globals(),
)
store_single_timings[method] = t
print(f"Method: {method}, Time usage: {t}")

注意:在使用LMDB时,可能会看到MapFullError: mdb_txn_commit: MDB_MAP_FULL: Environment mapsize limit reached错误。LMDB不重写预先存在的值,即使它们具有相同的键。这有助于加快写入时间,但也意味着:如果针对同一个LMDB文件进行写入,则会增加映射数量。如果执行上述函数,请务必先删除任何预先存在的LMDB文件。

请记住,我们对运行时间(以毫秒为单位显示)以及内存使用情况感兴趣:

Method Save Single Image + Meta Memory
Disk 1.915 ms 8 K
LMDB 1.203 ms 32 K
HDF5 8.243 ms 8 K

这里有两个要点:

  • 所有的方法都非常快速。
  • 在磁盘使用方面,LMDB占用更多。

显然,尽管LMDB在性能上略有领先,但我们并没有说服任何人为什么不将图像存储在磁盘上。毕竟,这是一种人类可读的格式,你可以从任何文件系统浏览器打开和查看它们!好吧,是时候看看更多的图片了…

存储多个图像

前面已经演示了使用几种存储单个图像的方法,现在要继续调整代码,以保存多个图像,然后运行计时实验。

调整代码以用于多个图像

将多个图像保存为.png文件,就像多次调用store_single_method()一样简单。但对于LMDB或HDF5,情况并非如此,因为你不希望每个图像都有不同的数据库文件。相反,你希望将所有图像放入一个或多个文件中。

你需要稍微修改代码并创建三个接受多个图像的新函数:store_many_disk()store_many_lmdb()store_many_hdf5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
 def store_many_disk(images, labels):
""" Stores an array of images to disk
Parameters:
---------------
images images array, (N, 32, 32, 3) to be stored
labels labels array, (N, 1) to be stored
"""
num_images = len(images)

# Save all the images one by one
for i, image in enumerate(images):
Image.fromarray(image).save(disk_dir / f"{i}.png")

# Save all the labels to the csv file
with open(disk_dir / f"{num_images}.csv", "w") as csvfile:
writer = csv.writer(
csvfile, delimiter=" ", quotechar="|", quoting=csv.QUOTE_MINIMAL
)
for label in labels:
# This typically would be more than just one value per row
writer.writerow([label])

def store_many_lmdb(images, labels):
""" Stores an array of images to LMDB.
Parameters:
---------------
images images array, (N, 32, 32, 3) to be stored
labels labels array, (N, 1) to be stored
"""
num_images = len(images)

map_size = num_images * images[0].nbytes * 10

# Create a new LMDB DB for all the images
env = lmdb.open(str(lmdb_dir / f"{num_images}_lmdb"), map_size=map_size)

# Same as before — but let's write all the images in a single transaction
with env.begin(write=True) as txn:
for i in range(num_images):
# All key-value pairs need to be Strings
value = CIFAR_Image(images[i], labels[i])
key = f"{i:08}"
txn.put(key.encode("ascii"), pickle.dumps(value))
env.close()

def store_many_hdf5(images, labels):
""" Stores an array of images to HDF5.
Parameters:
---------------
images images array, (N, 32, 32, 3) to be stored
labels labels array, (N, 1) to be stored
"""
num_images = len(images)

# Create a new HDF5 file
file = h5py.File(hdf5_dir / f"{num_images}_many.h5", "w")

# Create a dataset in the file
dataset = file.create_dataset(
"images", np.shape(images), h5py.h5t.STD_U8BE, data=images
)
meta_set = file.create_dataset(
"meta", np.shape(labels), h5py.h5t.STD_U8BE, data=labels
)
file.close()

用上面的修改之后的代码,就可以将多个文件存储到磁盘,在这个代码中,可以遍历列表中的每个图像。对于LMDB,还需要通过循环,将每个图像及其元数据加载到一个CIFAR_Image对象中。

修改幅度最小是用HDF5方法,其实,几乎没有任何调整!除了外部限制或数据集大小外,HFD5文件对文件大小没有限制,因此所有图像都像以前一样被填充到一个数据集中,最终存储为一个文件。

接下来,你需要通过增大数据集来为实验准备数据集。

准备数据集

再次运行这些实验之前,让我们首先将数据集大小增加一倍,这样我们就可以使用多达100000个图像进行测试:

1
2
3
4
5
6
7
8
9
cutoffs = [10, 100, 1000, 10000, 100000]

# Let's double our images so that we have 100,000
images = np.concatenate((images, images), axis=0)
labels = np.concatenate((labels, labels), axis=0)

# Make sure you actually have 100,000 images and labels
print(np.shape(images))
print(np.shape(labels))

既然有了足够的图像,现在是实验的时候了。

测试存储多个图像的程序

正如前面那样,可以创建一个字典来处理带有store_many_的所有函数并运行实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
_store_many_funcs = dict(
disk=store_many_disk, lmdb=store_many_lmdb, hdf5=store_many_hdf5
)

from timeit import timeit

store_many_timings = {"disk": [], "lmdb": [], "hdf5": []}

for cutoff in cutoffs:
for method in ("disk", "lmdb", "hdf5"):
t = timeit(
"_store_many_funcs[method](images_, labels_)",
setup="images_=images[:cutoff]; labels_=labels[:cutoff]",
number=1,
globals=globals(),
)
store_many_timings[method].append(t)

# Print out the method, cutoff, and elapsed time
print(f"Method: {method}, Time usage: {t}")

运行代码,需要你在悬念中静坐片刻,等待111,110个图像以三种不同的格式分别存储到磁盘上三次,当然,你还需要消耗大约2GB的磁盘空间。

现在是见证奇迹的时刻了!所有这些储存需要多长时间?一幅图表胜过千言万语:

第一个图显示程序所得到的存储时间未经变换的比较,从图中可知,存储到“.png”文件和LMDB或HDF5之间的耗时差异较大。

第二张图对存储时间进行了对数变换,从图中可知,HDF5的在开始阶段速度比LMDB慢,但是,随着图像数量的增加,它的速度略微领先。

准确的测试结果可能会因机器而异,这就是为什么LMDB和HDF5值得考虑的原因。下面是生成上述图形的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import matplotlib.pyplot as plt

def plot_with_legend(
x_range, y_data, legend_labels, x_label, y_label, title, log=False
):
""" Displays a single plot with multiple datasets and matching legends.
Parameters:
--------------
x_range list of lists containing x data
y_data list of lists containing y values
legend_labels list of string legend labels
x_label x axis label
y_label y axis label
"""
plt.style.use("seaborn-whitegrid")
plt.figure(figsize=(10, 7))

if len(y_data) != len(legend_labels):
raise TypeError(
"Error: number of data sets does not match number of labels."
)

all_plots = []
for data, label in zip(y_data, legend_labels):
if log:
temp, = plt.loglog(x_range, data, label=label)
else:
temp, = plt.plot(x_range, data, label=label)
all_plots.append(temp)

plt.title(title)
plt.xlabel(x_label)
plt.ylabel(y_label)
plt.legend(handles=all_plots)
plt.show()

# Getting the store timings data to display
disk_x = store_many_timings["disk"]
lmdb_x = store_many_timings["lmdb"]
hdf5_x = store_many_timings["hdf5"]

plot_with_legend(
cutoffs,
[disk_x, lmdb_x, hdf5_x],
["PNG files", "LMDB", "HDF5"],
"Number of images",
"Seconds to store",
"Storage time",
log=False,
)

plot_with_legend(
cutoffs,
[disk_x, lmdb_x, hdf5_x],
["PNG files", "LMDB", "HDF5"],
"Number of images",
"Seconds to store",
"Log storage time",
log=True,
)

下面,再演示读取图片。

读取单个图像

首先,让我们考虑读入单个图像的三种方法。

从磁盘读取

在这三种方法中,由于序列化的原因,当从内存中读取图像文件时,LMDB需要的工作量最大。让我们浏览一下这些函数,它们分别为三种存储格式读取单个图像。

首先,从.png.csv文件中读取单个图像及其元数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def read_single_disk(image_id):
""" Stores a single image to disk.
Parameters:
---------------
image_id integer unique ID for image
Returns:
----------
image image array, (32, 32, 3) to be stored
label associated meta data, int label
"""
image = np.array(Image.open(disk_dir / f"{image_id}.png"))

with open(disk_dir / f"{image_id}.csv", "r") as csvfile:
reader = csv.reader(
csvfile, delimiter=" ", quotechar="|", quoting=csv.QUOTE_MINIMAL
)
label = int(next(reader)[0])

return image, label

从LMDB读取

接下来,演示从LMDB读取图像的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def read_single_lmdb(image_id):

""" Stores a single image to LMDB.
Parameters:
---------------
image_id integer unique ID for image
Returns:
----------
image image array, (32, 32, 3) to be stored
label associated meta data, int label

"""

# Open the LMDB environment

env = lmdb.open(str(lmdb_dir / f"single_lmdb"), readonly=True)


# Start a new read transaction

with env.begin() as txn:

# Encode the key the same way as we stored it

data = txn.get(f"{image_id:08}".encode("ascii"))

# Remember it's a CIFAR_Image object that is loaded

cifar_image = pickle.loads(data)

# Retrieve the relevant bits

image = cifar_image.get_image()

label = cifar_image.label

env.close()


return image, label

注意这两行:

  • env = lmdb.open(str(lmdb_dir / f"single_lmdb"), readonly=True),其中readonly=True是指在事务完成之前,不允许对LMDB文件进行写操作。在数据库术语中,它相当于获取一个读锁。
  • image = cifar_image.get_image()中的get_image()用处是返回CIFAR_Image对象,这也是反序列化的过程。

这就结束了从LMDB读取图像的过程。最后,你还需要对HDF5执行相同的操作。

从HDF5读取

从HDF5读取图像与写入过程非常相似。下面是打开和读取HDF5文件并解析相同图像和元数据的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def read_single_hdf5(image_id):
""" Stores a single image to HDF5.
Parameters:
---------------
image_id integer unique ID for image

Returns:
----------
image image array, (32, 32, 3) to be stored
label associated meta data, int label
"""
# Open the HDF5 file
file = h5py.File(hdf5_dir / f"{image_id}.h5", "r+")

image = np.array(file["/image"]).astype("uint8")
label = int(np.array(file["/meta"]).astype("uint8"))

return image, label

注意,在文件目录后用/,后面是表示图片文件名的变量,这样可以访问文件中的各种数据集。与以前一样,你可以创建一个包含所有读取函数的字典:

1
2
3
_read_single_funcs = dict(
disk=read_single_disk, lmdb=read_single_lmdb, hdf5=read_single_hdf5
)

准备好,就可以开始实验了。

读取单个图像的实验

你可能会认为:读取单个图像的的时间肯定会很短,下面是实验代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
from timeit import timeit

read_single_timings = dict()

for method in ("disk", "lmdb", "hdf5"):
t = timeit(
"_read_single_funcs[method](0)",
setup="image=images[0]; label=labels[0]",
number=1,
globals=globals(),
)
read_single_timings[method] = t
print(f"Method: {method}, Time usage: {t}")

下面是读取单个图像的实验结果:

Method Read Single Image + Meta
Disk 1.61970 ms
LMDB 4.52063 ms
HDF5 1.98036 ms

直接从磁盘读取.png.csv文件稍微快一点,但这三种方法执行起来都很快。我们接下来要做的实验更有趣。

读取多个图像

继续修改代码,实现读取多个图像的功能,当然,性能很重要。

修改代码以用于读取多个图像

修改前面的函数(注: 详见本系列第二部分),你可以使用read_many_作为前缀命名函数,然后将它们用于随后的实验。与之前一样,在读取不同数量的图像时,比较性能是很有趣的。这些图像在下面的代码中重复,以供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def read_many_disk(num_images):
""" Reads image from disk.
Parameters:
---------------
num_images number of images to read

Returns:
----------
images images array, (N, 32, 32, 3) to be stored
labels associated meta data, int label (N, 1)
"""
images, labels = [], []

# Loop over all IDs and read each image in one by one

for image_id in range(num_images):
images.append(np.array(Image.open(disk_dir / f"{image_id}.png")))

with open(disk_dir / f"{num_images}.csv", "r") as csvfile:
reader = csv.reader(
csvfile, delimiter=" ", quotechar="|", quoting=csv.QUOTE_MINIMAL
)
for row in reader:
labels.append(int(row[0]))
return images, labels

def read_many_lmdb(num_images):
""" Reads image from LMDB.
Parameters:
---------------
num_images number of images to read

Returns:
----------
images images array, (N, 32, 32, 3) to be stored
labels associated meta data, int label (N, 1)
"""
images, labels = [], []
env = lmdb.open(str(lmdb_dir / f"{num_images}_lmdb"), readonly=True)

# Start a new read transaction

with env.begin() as txn:
# Read all images in one single transaction, with one lock
# We could split this up into multiple transactions if needed
for image_id in range(num_images):
data = txn.get(f"{image_id:08}".encode("ascii"))
# Remember that it's a CIFAR_Image object
# that is stored as the value
cifar_image = pickle.loads(data)
# Retrieve the relevant bits
images.append(cifar_image.get_image())
labels.append(cifar_image.label)
env.close()
return images, labels

def read_many_hdf5(num_images):
""" Reads image from HDF5.
Parameters:
---------------
num_images number of images to read

Returns:
----------
images images array, (N, 32, 32, 3) to be stored
labels associated meta data, int label (N, 1)
"""
images, labels = [], []

# Open the HDF5 file

file = h5py.File(hdf5_dir / f"{num_images}_many.h5", "r+")

images = np.array(file["/images"]).astype("uint8")
labels = np.array(file["/meta"]).astype("uint8")

return images, labels

_read_many_funcs = dict(
disk=read_many_disk, lmdb=read_many_lmdb, hdf5=read_many_hdf5
)

把读函数和写函数一起放在字典中,就可以进行实验了。

读取多个图像的实验

现在可以运行程序来读取多个图像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from timeit import timeit

read_many_timings = {"disk": [], "lmdb": [], "hdf5": []}

for cutoff in cutoffs:
for method in ("disk", "lmdb", "hdf5"):
t = timeit(
"_read_many_funcs[method](num_images)",
setup="num_images=cutoff",
number=1,
globals=globals(),
)
read_many_timings[method].append(t)

# Print out the method, cutoff, and elapsed time
print(f"Method: {method}, No. images: {cutoff}, Time usage: {t}")

如前所述,你可以将读取的实验结果绘制成图表:

上面的图表显示了正常的、未经调整的读取时间,由图示可知,从.png文件读取图像的时间与从LMDB或HDF5读取之间存在巨大差异。

相反,下面的图形是对时间进行对数变换之后的显示,突显了它们之间的相对差异。

在实践中,写入时间通常比读取时间更不重要。想象一下,你正在开发一个关于图像的深层神经网络,而整个图像数据集只有一半可以同时放入内存。神经网络训练的每个阶段都需要整个数据集,而模型需要几百个阶段才能收敛。实际上,每个阶段都会将数据集的一半读入内存。

此时,人们会使用一些技巧,比如训练pseudo-epochs,使其稍微好一点。

现在,再次看一看上面的图,40秒和4秒之间的差距突然就成了6个小时和40分钟的区别!

如果我们在同一张图表上查看读写时间,有以下图示:

当你将图像存储为.png文件时,写入时间和读取时间有很大的区别。然而,对于LMDB和HDF5,这种差别就不那么明显了。总的来说,即使读取时间比写入时间更重要,使用LMDB或HDF5存储图像也有充分的理由。

既然你已经看到了LMDB和HDF5的性能优势,那么让我们来看看另一个重要的指标:磁盘使用率。

磁盘使用情况

速度并不是你唯一感兴趣的性能指标。我们已经在处理非常大的数据集,所以磁盘空间也是一个非常有效和相关的问题。

假设你有一个3TB的图像数据集。与我们的CIFAR示例不同,你可能已经将它们放在磁盘上的某个位置,因此通过使用另一种存储方法,你实际上是在备份,而这些备份也必须存储。这样做,将在你使用图像时,为你带来巨大的性能优势,但你需要确保有足够的磁盘空间。

各种存储方法使用多少磁盘空间?以下是每种方法用于每一数量的图像所占的磁盘空间:

HDF5和LMDB都比使用普通的.png图像存储占用更多的磁盘空间。需要注意的是,LMDB和HDF5对磁盘的使用和性能在很大程度上取决于各种因素,包括操作系统。更重要的是,存储数据的大小。

LMDB通过缓存来提高效率,你不需要了解它的内部工作原理,但请注意:对于较大的图像,LMDB使用的磁盘空间会明显增加,因为图像不适合以LMDB的分支形式存储,会有许多溢出。上面图表中的LMDB柱就要从图表中冒出了。

与你可能使用的普通图像相比,32x32x3像素图像相对较小,并且它们允许最佳的LMDB性能。

虽然我们不会在这里进行实验性的探索,但根据我自己对256x256x3或512x512x3像素图像的体验,HDF5在磁盘使用方面通常比LMDB稍微高效一些。这是进入最后一节的一个很好的过渡,在最后一节里定性讨论各方法之间的差异。

讨论

LMDB和HDF5还有其他值得了解的显著特性,简要讨论对这两种方法的一些评论也是很重要的。

并行存取

在上面的实验中,我们没有测试的一个关键比较是并行读和写。通常,对于如此大的数据集,你可能希望通过并行化来加快操作速度。

在大多数情况下,你不会对同时读取同一图像的部分内容感兴趣,但你会希望同时读取多个图像。有了这个并行定义,将.png文件存储到磁盘实际上允许并发。只要图像名称不同,没有什么可以阻止你从不同的线程一次读取多个图像,或者一次写入多个文件。

LMDB怎么样?在一个LMDB环境中可以同时有多个读取器,但是只有一个写入器,并且写入器不会阻塞读取器。你可以在LMDB技术网站上看到更多关于这方面的信息。

多个应用程序可以同时访问同一个LMDB数据库,同一进程的多个线程也可以同时访问LMDB进行读取。这使读取过程变得更快:如果将所有CIFAR划分为10个集合,那么可以为一个集合中的每个读取设置10个进程,相当于把加载时间除以10。

HDF5还提供并行I/O,允许并行读写。但是,在实现中,除非你有一个并行文件系统,否则会保持写锁,并且访问是按顺序的。

如果你正在处理这样一个系统,有两个主要选项。在本文中,关于并行IO的HDF组将更深入地讨论这两个主要选项。它可能变得相当复杂,最简单的选择是智能地将数据集拆分为多个HDF5文件,这样每个进程可以独立处理一个.h5文件。

文档

如果你用谷歌搜索lmdb,至少在英国,第三个搜索结果是IMDb(互联网电影数据库)。那不是你要找的!

实际上,LMDB的Python包甚至还没有达到0.94以上的版本,但它被广泛使用并被认为是稳定的。

至于LMDB技术本身,LMDB技术网站上有更详细的文档。除非你从他们的入门页面开始,否则这种感觉有点像在二年级学习微积分。

对于HDF5,在h5py文档站点上有非常清晰的文档,还有Christopher Lovell的一篇有用的博客文章,这篇文章非常全面地介绍了如何使用h5py包。O’Reilly book、Python和HDF5也是一个很好的入门途径。

虽然不像初学者所希望的那样有文档记录,但LMDB和HDF5都有很大的用户社区,因此更深入的Google搜索通常会产生有用的结果。

更具批判性地看待实现

在存储系统中没有乌托邦,LMDB和HDF5都有各自的陷阱。

理解LMDB的一个关键点是:在不覆盖或移动现有数据的情况下写入新数据。这是一个设计决策,它允许你非常快速地读取,就像在我们的实验中所看到的那样,并且还保证数据的完整性和可靠性,而无需保存事务日志。

不过,请记住,在写入新数据库之前,需要定义用于内存分配的map_size参数。这就是LMDB的麻烦所在。假设你已经创建了一个LMDB数据库,并且一切都很好。你耐心地等待着你的庞大数据集被打包到LMDB中。

然后,接下来,你会记住你需要添加新的数据。即使你在map_size中指定了缓冲区,也可能很容易看到lmdb.MapFullError。除非你想用更新后的map_size重新编写整个数据库,否则你必须将新数据存储在单独的LMDB文件中。即使一个事务可以跨越多个LMDB文件,但拥有多个文件仍然是一件麻烦的事。

此外,有些系统对一次可以占用多少内存有限制。以我自己的经验,在使用HPC系统时,就遇到了令人非常沮丧的事,这让我不得不使用HDF5而不是LMDB。

对于LMDB和HDF5,一次只能将请求的项读入内存。使用LMDB,键值对被逐个读入内存,而用HDF5,可以像访问Python数组那样访问dataset对象,索引为dataset[i],切片为dataset[i:j]dataset[i:j:interval]

因为系统是优化的,而且取决于你的操作系统,对于数据的访问顺序可能会影响性能。

根据我的经验,通常情况下,对于LMDB,按照键的顺序(键值对按照键的字母数字顺序保存在内存中)访问时可能会获得更好的性能;而对于HDF5,访问大范围将比使用以下方法逐个读取数据集的每个元素执行得更好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Slightly slower

for i in range(len(dataset)):
# Read the ith value in the dataset, one at a time

读取数据集中的第i个值,一次一个

do_something_with(dataset[i])

# This is better

data = dataset[:]
for d in data:
do_something_with(d)

与其他库的集成

如果你处理的是非常大的数据集,你很可能会对它们做一些重要的事情。值得考虑的是深度学习库以及它与LMDB和HDF5的集成类型。

首先,只要将图像转换为预期格式的NumPy数组,所有的库都支持从磁盘读取的.png文件。这对所有方法都适用,而且我们在上面已经看到,将图像作为数组读取相对简单。

以下是几个最受欢迎的深度学习库及其LMDB和HDF5集成:

  • Caffe有一个稳定的、支持良好的LMDB集成,并且它透明地处理读取步骤。LMDB层也可以很容易地替换为HDF5数据库。
  • Keras使用HDF5格式保存和恢复模型。这意味着TensorFlow也可以。
  • TensorFlow有一个内置的类LMDBDataset,它提供了一个接口,用于从LMDB文件中读取输入数据,并且可以批量生成迭代器和张量。TensorFlow没有针对HDF5的内置类,但是可以编写继承Dataset的类。我个人使用了一个自定义的类,它是根据我构造HDF5文件的方式为优化读取访问而设计的。
  • Theano不支持任何特定的文件格式或数据库,但如前所述,只要它作为N维数组被读入,就可以使用任何内容。

虽然这远远不够全面,但希望通过一些关键的深度学习库,让你对LMDB/HDF5的集成有所了解。

关于用Python存储图像的一些个人见解

在我自己的日常工作中,分析万亿字节的医学图像时,我同时使用了LMDB和HDF5,并且了解到,对于任何存储方法,事前筹划都是至关重要的。

通常,模型需要使用k-fold交叉验证进行训练,这涉及到将整个数据集拆分为k个集(k通常为10)和正在训练的k个模型,每个模型使用不同的k个集作为测试集。这可以确保模型不会过度拟合数据集,或者,换句话说,无法对未看到的数据进行良好的预测。

上述的数据集,要保存到单独的HDF5数据集里,才能以最大限度地提高效率。有时,单个数据集不能一次加载到内存中,因此即使数据集里的数据排序也需要预先考虑。

对于LMDB,在创建数据库之前,我同样谨慎地提前计划。在保存图像之前,有几个好问题值得问:

  • 如何保存图像,以便让大多数读取都是连续的?
  • 什么是好的键?
  • 如何计算适合的map_size,预测数据集中未来的潜在变化?
  • 单个事务可以有多大,应该如何细分事务?

不管是哪种存储方法,在处理大型图像数据集时,一个小的规划就能大有帮助。

结论

你成功了!你现在对一个大问题有了高屋建瓴的了解。

在本文中,我们向你介绍了用Python存储和访问大量图像的三种方法,也许你有机会使用其中的一些方法。本文的所有代码都在发布到本文公众号的在线《机器学习案例集》中,欢迎加入这个案例集,里面还有其他有关机器学习的项目。

你已经看到了各种存储方法如何显著地影响读写时间的证据,以及本文中考虑的三种方法的一些优缺点。虽然将图像存储为.png文件可能是最直观的,但是HDF5或LMDB等方法也有很大的性能优势。

(全文完毕)

关注微信公众号:老齐教室。读深度文章,得精湛技艺,享绚丽人生。

原文链接:https://realpython.com/storing-images-in-python/

使用支付宝打赏
使用微信打赏

若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏

关注微信公众号,读文章、听课程,提升技能