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

(未完,待续,请关注后续文章)