存储和读取海量图像的方法

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的可用磁盘空间,对于学习而言,这太奢侈了。

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

存储和读取海量图像的方法_第1张图片

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

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

 

 

 

 

 

你可能感兴趣的:(人工智能)