https://realpython.com/storing-images-in-python/
翻译:老齐
为什么必须要了解更多用Python存储和访问图像的方法?如果你的业务只用到少量图片,比如根据图像的色彩分类,,或者用OpenCV实现人脸识别,这时完全不用担心这个问题了。即使借助Python的PIL,也能轻松处理几百张照片,把图像以.png
或.jpg
文件的形式存储在磁盘上,简单方便又恰当。
然而,现实的任务不都如此,比如卷积神经网络(CNN)等算法可以处理包含大量图像的数据集,还可以从中学习。
ImageNet是一个著名的公共图像数据库,可以用于对象分类、识别等任务的模型训练,它包含超过1400万张图像。
想一想要花多长时间才能把它们分批地、成百上千次地装入内存中进行训练。如果你用常规方法来读取这些图片,应该在开始读取之后,离开电脑去做点别的事情,回来后还不一定完成。但是,如果你希望去谷歌或英伟达工作,就不能这样玩。
在本文中,你将了解:
- 将图像作为.png文件存储在磁盘上
- 将图像存储到LMDB(lightning memory-mapped databases,闪电般的内存映射数据库)
- 将图像存储到HDF5格式的文件中
我们还将探索以下内容:
- 为什么替代存储方法值得考虑
- 当你读、写单个图像时,这三种方法的性能有什么不同
- 当你读、写多个图像时,这三种方法的性能有什么不同
- 这三种方法在磁盘使用方面的比较
如果没有一种存储方法听起来耳熟,不要担心:对于这篇文章,你所需要的只是一些基本的Python语言知识以及对图像(它们实际上是由多维数组组成的)、内存的基本理解,比如10MB和10GB之间的差异。
我们开始吧!
安装程序
下面的项目中,需要一个图像数据集,以及一些Python包。
数据集
案例中的数据集来自众所周知的CIFAR-10,它由60000个32x32像素的彩色图像组成,这些图像属于不同的对象类别,如狗、猫和飞机。相对而言,CIFAR不是一个很大的数据集,但是如果我们使用完整的TinyImages数据集,就需要大约400GB的可用磁盘空间,对于学习而言,这太奢侈了。
以下代码将从数据集文件中读取图像数据,并加载到NumPy数组中:
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)}")
在磁盘上存储图像
你需要为从磁盘上保存和读取这些图像的默认方法设置环境。本文假设你的系统上安装了Python 3.x,并将使用Pillow
进行图像处理:
$ pip install Pillow
或者,如果你愿意,可以使用Anaconda安装它:
$ 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安装:
$ pip install lmdb
你还可以选择通过Anaconda安装:
$ conda install -c conda-forge python-lmdb
然后在Python交互模式中,用import lmdb
检查,不报错,就OK了。
HDF5入门
HDF5代表分层数据格式,这种文件格式被称为HDF4或HDF5。我们不需要担心HDF4,因为HDF5是当前维护的版本。
有趣的是,HDF起源于(美国)国家超级计算应用中心,是一种便携式、紧凑的科学数据格式。如果你想知道它是否被广泛使用,请查看美国宇航局的地球数据项目中关于HDF5的简介。
HDF文件由两种类型的对象组成:
- 数据集
- 群组
数据集是多维数组,群组由数据集或其他组组成。任何大小和类型的多维数组都可以存储为数据集,但数据集中的维度和类型必须统一。每个数据集必须包含一个同构的N维数组。也就是说,因为组和数据集可能是嵌套的,所以你仍然可以获得可能需要的异构性:
$ pip install h5py
与其他库一样,你可以通过Anaconda安装:
$ conda install -c conda-forge h5py
如果你import h5py
不报错,那也说明一切都将正确设置。
存储单个图像
现在,你已经对这些方法有了一个大致的了解,让我们直接进入主题:读、写文件各需要多长时间,以及将占用多少内存。通过这些示例,也可以了解每种方法的基本工作原理。
当我提到文件时,通常指的是很多文件。但是,由于有些方法可能针对不同的操作和文件数量进行了优化,因此进行区分是很重要的。
为了便于实验,我们可以比较读取不同数量的文件的性能,把图片的数量按10的倍数从1张增至10万张。由于我们的五批CIFAR-10总共有50000个图像,因此可以每个图像可以用两次,总共获得100000个图像。
为了准备实验,你需要为每个方法创建一个文件夹,其中包含所有数据库文件或图像:
from pathlib import Path
disk_dir = Path("data/disk/")
lmdb_dir = Path("data/lmdb/")
hdf5_dir = Path("data/hdf5/")
Path
不会自动为你创建文件夹,除非你明确地要求它这样做:
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
完成:
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类:
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的代码:
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文件可以包含多个数据集。在这种情况下,你可以创建两个数据集,一个用于图像,一个用于图像的元数据:
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的运行时间和存储要求,因此最好选择最低要求。
现在,我们已经回顾了保存单个图像的三种方法。让我们进入下一个步骤。
存储单个图像的实验
你可以把用于保存单个图像的所有三个函数放入字典中,该字典可以在稍后的计时代码中使用:
_store_single_funcs = dict(
disk=store_single_disk, lmdb=store_single_lmdb, hdf5=store_single_hdf5
)
万事俱备只欠东风。让我们尝试保存CIFAR中的第一个图像及其相应的标签,并以三种不同的方式存储它:
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在性能上略有领先,但我们并没有说服任何人为什么不将图像存储在磁盘上。毕竟,这是一种人类可读的格式,你可以从任何文件系统浏览器打开和查看它们!好吧,是时候看看更多的图片了…
(未完,待续,请关注后续文章)