Три способа хранения и доступа к множеству изображений в Python

Spread the love

Почему может возникнуть необходимость в изучение различных способов хранения и доступа к изображениям в Python? Так например если вам будет нужно классифицировать несколько изображений по цветам или найти лицо человека в изображении с помощью OpenCV вам не потребуется специальные способы работы с изображениями. Даже если вы используете Python Imaging Library (PIL) для рисования нескольких сотен фотографий, вам все равно это будет не нужно. В этих случаях достаточно обычного хранение изображений на диске в виде файлов .png или .jpg.

Однако число изображений, необходимых для выполнения определенных задач, может быть значительно большим. Алгоритмы, такие как сверточные нейронные сети, также известные как Connet или CNN, могут обрабатывать огромные наборы данных изображений и даже учиться на них. Если вам интересно, вы можете прочитать больше о том, как использовать сети Connet для ранжирования селфи или для анализа настроений.

ImageNet – это общедоступная база данных изображений, созданная для обучения моделей таким задачам, как классификация объектов, обнаружение и сегментация, и состоит из более чем 14 миллионов изображений.

Подумайте, сколько времени потребуется, чтобы загрузить их все в память для тренировок, партиями, возможно, сотни или тысячи раз. Позже вы убедитесь, что это займет довольно много времени – по крайней мере, достаточно, чтобы отойти от компьютера и заняться другими делами.

В этой статье вы узнаете о:

  • Хранение изображений на диске в виде файлов .png
  • Хранение изображений в формате LMDB (lightning memory-mapped databases)
  • Хранение изображений в формате HDF5 (hierarchical data format – иерархическом формате данных)

Вы также изучите следующее:

  • Почему стоит рассмотреть альтернативные способы хранения
  • Каковы различия в производительности, когда вы читаете и пишете отдельные изображения
  • Каковы различия в производительности, когда вы читаете и пишете в множество изображений
  • Как эти три метода сравниваются с точки зрения использования дискового пространства

Давайте начнем!

Начальная настройка

Вам понадобится набор данных изображений для экспериментов, а также несколько пакетов Python.

Набор данных

Мы будем использовать набор данных изображений Канадского института перспективных исследований, более известный как CIFAR-10, который состоит из 60000 цветных изображений размером 32×32 пикселя, принадлежащих к различным классам объектов, таким как собаки, кошки и самолеты. Относительно CIFAR – не очень большой набор данных, но если бы мы использовали больший набор данных например TinyImages dataset, то в этом случае вам потребовалось бы около 400 ГБ свободного дискового пространства, что, вероятно, будет ограничивающим фактором.

Если вы хотите следовать примерам кода в этой статье, вы можете скачать CIFAR-10 здесь, выбрав нужную вам версию Python. Этот набор займет около 163 МБ дискового пространства:

Когда вы загрузите и разархивируете папку, вы обнаружите, что эти файлы не являются удобочитаемыми файлами изображений. Они будут фактически сериализованы и сохранены в пакетах с использованием cPickle.

Хотя в этой статье мы не будем рассматривать pickle или cPickle, кроме как для извлечения набора данных CIFAR, стоит отметить, что модуль pickle имеет ключевое преимущество, заключающееся в возможности сериализации любого объекта Python без какого-либо дополнительного кода. У него также есть потенциально серьезный недостаток: он создает угрозу безопасности и плохо справляется с очень большими объемами данных.

Следующий код распаковывает каждый из пяти пакетных файлов и загружает все изображения в массив 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)}")

Все изображения теперь находятся в ОЗУ в переменной images с соответствующими метаданными в labels и готовы для манипулирования. Затем вы можете установить пакеты Python, которые вы будете использовать для трех методов.

Примечание: последний блок кода использовал f-строки. Вы можете прочитать больше о них в f-Strings в Python 3: An Improved String Formatting Syntax (Guide).

Настройки для хранения изображений на диске

Вам нужно будет настроить свою среду на метод сохранения и доступа к этим изображениям с диска по умолчанию. В этой статье предполагается, что у вас установлен Python 3.x, и вы будете использовать Pillow для манипулирования изображениями:

$ pip install Pillow

Кроме того, если вы предпочитаете, вы можете установить его с помощью Anaconda:

$ conda install -c conda-forge pillow

Примечание: PIL – это оригинальная версия библиотеки изображений Python, которая больше не поддерживается и не совместима с Python 3.x. Если вы ранее установили PIL, обязательно удалите его перед установкой Pillow, поскольку они не могут существовать вместе.

Теперь вы готовы к хранению и чтению изображений с диска.

Начало работы с LMDB

LMDB, иногда называют «Lightning Database», или «Lightning Memory-Mapped Database», потому что она быстрая и использует файлы с отображением в памяти. По сути представляет собой хранилище ключ-значение.

С точки зрения реализации, LMDB – это дерево B+, что в основном означает, что это древовидная структура графа, хранящаяся в памяти, где каждый элемент значения ключа является узлом, а узлы могут иметь много дочерних элементов. Узлы одного уровня связаны друг с другом для быстрого обхода.

Важно отметить, что ключевые компоненты дерева B+ соответствуют размеру страницы операционной системы хоста, что обеспечивает максимальную эффективность при доступе к любой паре ключ-значение в базе данных. Поскольку высокая производительность LMDB в значительной степени зависит от этого конкретного момента, эффективность LMDB, как было показано, зависит от базовой файловой системы и ее реализации.

Другой ключевой причиной эффективности LMDB является то, что она отображена в памяти. Это означает, что она возвращает прямые указатели на адреса памяти как ключей, так и значений, без необходимости что-либо копировать в памяти, как это делается в большинстве других баз данных.

Те, кто хочет углубиться в некоторые внутренние детали реализации деревьев B+, могут ознакомиться с этой статьей о деревьях B+, а затем поиграть с этой визуализацией вставки узлов.

Если B+ деревья вас не интересуют, не волнуйтесь. Вам не нужно много знать об их внутренней реализации, чтобы использовать LMDB. Мы будем использовать привязку Python для библиотеки LMDB, которую можно установить через pip:

$ pip install lmdb

У вас также есть возможность установки через Anaconda:

$ conda install -c conda-forge python-lmdb

Начало работы с HDF5

HDF5 обозначает Иерархический формат данных, формат файла, называемый HDF4 или HDF5. Нам не нужно беспокоиться о HDF4, так как HDF5 является текущей поддерживаемой версией.

Интересно, что HDF берет свое начало в Национальном центре суперкомпьютерных приложений, как портативный, компактный формат научных данных. Если вам интересно, широко ли он используется, посмотрите на рекламный ролик НАСА о HDF5 из их проекта Earth Data.

Файлы HDF состоят из двух типов объектов:

  1. Datasets
  2. Groups

Наборы данных (Datasets) являются многомерными массивами, а группы (Groups) состоят из наборов данных или других групп. Многомерные массивы любого размера и типа могут быть сохранены как набор данных, но размеры и тип должны быть одинаковыми в наборе данных. Каждый набор данных должен содержать однородный N-мерный массив. Тем не менее, поскольку группы и наборы данных могут быть вложенными, вы все равно можете получить гетерогенность, которая может вам понадобиться:

$ pip install h5py

Как и в случае с другими библиотеками, вы можете поочередно установить через Anaconda:

$ conda install -c conda-forge python-lmdb

Хранение одного изображения

Теперь, когда у вас есть общий обзор методов, давайте подробно рассмотрим количественное сравнение основных задач, которые нас интересуют: сколько времени занимает чтение и запись файлов и сколько дисковой памяти будет использоваться. Это также послужит базовым введением в работу методов и примерами их использования.

Когда я ссылаюсь на «файлы», я обычно имею в виду большую группу файлов. Однако важно учитывать различие, поскольку некоторые методы могут быть оптимизированы для различных операций и количества файлов.

В целях эксперимента мы можем сравнить производительность между различными количествами файлов, от 10 изображения до 100 000 изображений. Поскольку наши пять групп CIFAR-10 содержат до 50 000 изображений, мы можем использовать каждое изображение дважды, чтобы получить 100 000 изображений.

Чтобы подготовиться к экспериментам, вам нужно будет создать папку для каждого метода, которая будет содержать все файлы базы данных или изображения, и сохранить пути к этим каталогам в переменных:

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)

Теперь мы можем приступить к проведению реальных экспериментов с примерами кода того, как выполнять основные задачи с помощью трех различных методов. Мы можем использовать модуль timeit, который входит в стандартную библиотеку Python, чтобы помочь рассчитать время экспериментов.

Хотя основная цель этой статьи не состоит в изучении API различных пакетов Python, полезно иметь представление о том, как их можно реализовать. Мы пройдемся по общим принципам вместе со всем кодом, используемым для проведения экспериментов по хранению.

Хранение на диске

Входными данными для этого эксперимента является одно изображение image, которое в данный момент находится в памяти в виде массива NumPy. Сначала мы сохраним его на диск как изображение в формате .png и назвать его, используя уникальный идентификатор изображения 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])

Это функция просто сохраняет изображение. Во всех реалистичных приложениях вам также нужно позаботиться о метаданных, прикрепленных к изображению, которое в нашем примере набора данных является меткой изображения 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.

Наконец, операции чтения и записи с 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()

Примечание. Рекомендуется рассчитать точное число байтов, которое займет каждая пара ключ-значение.

С набором данных изображений различного размера это будет приближенно, но вы можете использовать sys.getsizeof() для получения разумного приближения. Помните, что sys.getsizeof(CIFAR_Image) будет возвращать только размер определения класса, равный 1056, а не размер экземпляра объекта.

Функция также не сможет полностью рассчитать вложенные элементы, списки или объекты, содержащие ссылки на другие объекты.

Кроме того, вы можете использовать pympler, чтобы сэкономить некоторые расчеты, определив точный размер объекта.

Наконец, давайте посмотрим на последний метод, 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-разрядные целые числа без знака. Вы можете увидеть полный список предопределенных типов данных HDF здесь.

Примечание. Выбор типа данных сильно повлияет на требования к времени выполнения и хранилищу HDF5, поэтому лучше выбрать минимальные требования.

Теперь, когда мы рассмотрели три метода сохранения одного изображения, давайте перейдем к следующему шагу.

Эксперименты по сохранению одного изображения

Теперь вы можете поместить все три функции для сохранения одного изображения в словарь, который можно вызвать позже во время экспериментов по синхронизации:

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

Наконец, все готово для проведения запланированного эксперимента. Давайте попробуем сохранить первое изображение из CIFAR и соответствующий ему label и сохранить его тремя различными способами:

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 error. Важно отметить, что LMDB не перезаписывает существующие значения, даже если они имеют одинаковый ключ.

Это способствует быстрому времени записи, но также означает, что если вы храните изображение более одного раза в одном и том же файле LMDB, то вы будете использовать размер карты. Если вы запускаете функцию хранилища, сначала обязательно удалите все существующие файлы LMDB.

Помните, что нас интересует время выполнения, отображаемое здесь в секундах, а также использование памяти:

MethodSave Single Image + MetaMemory
Disk1.915 ms8 K
LMDB1.203 ms32 K
HDF58.243 ms8 K

Здесь есть два момента:

  1. Все методы просты.
  2. С точки зрения использования диска, LMDB использует больше всего.

Очевидно, что, несмотря на незначительное снижение производительности LMDB, мы никого не убедили, почему бы не просто хранить образы на диске. В конце концов, это удобочитаемый формат, и вы можете открывать и просматривать их из любого браузера файловой системы! Ну что ж, пришло время посмотреть на еще больше количество изображений …

Хранение множества изображений

Вы видели код для использования различных методов хранения для сохранения одного изображения, поэтому теперь нам нужно создать код, чтобы сохранить множество изображений, а затем запустить эксперимент.

Корректировка кода для работы с множеством изображений

Сохранение нескольких изображений в виде файлов .png так же просто, как многократный вызов store_single_method(). Но это не так для LMDB или HDF5, поскольку вам не нужен отдельный файл базы данных для каждого изображения. Скорее всего, вы хотите поместить все изображения в один или несколько файлов.

Вам нужно будет немного изменить код и создать три новые функции, которые принимают несколько изображений: store_many_disk(), store_many_lmdb() и store_many_hdf5():

 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 не имеют ограничений по размеру файла, кроме внешних ограничений или размера набора данных, поэтому все изображения были помещены в один набор данных, как и раньше.

Далее вам нужно будет подготовить набор данных для экспериментов, увеличив его размер.

Подготовка набора данных

Прежде чем снова проводить эксперименты, давайте сначала удвоим размер нашего набора данных, чтобы мы могли протестировать до 100 000 изображений:

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_ и запустить тесты:

_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 изображений сохранятся по три раза на ваш диск в трех разных форматах. Вам также придется попрощаться примерно с 2 ГБ дискового пространства.

Теперь момент истины! Сколько времени заняло все это хранение? Одна картинка стоит тысячи слов:

На первом графике показано нормальное нескорректированное время хранения, подчеркивающее существенную разницу между хранением в файлах .png и LMDB или HDF5.

Второй график показывает логарифм временных интервалов, подчеркивая, что HDF5 запускается медленнее, чем LMDB, но с большим количеством изображений выходит немного впереди.

Хотя точные результаты могут отличаться в зависимости от вашей машины, именно поэтому стоит подумать о LMDB и HDF5. Вот код, который сгенерировал приведенный выше график:

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:

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 def read_single_lmdb(image_id):
 2     """ Stores a single image to LMDB.
 3         Parameters:
 4         ---------------
 5         image_id    integer unique ID for image
 6 
 7         Returns:
 8         ----------
 9         image       image array, (32, 32, 3) to be stored
10         label       associated meta data, int label
11     """
12     # Open the LMDB environment
13     env = lmdb.open(str(lmdb_dir / f"single_lmdb"), readonly=True)
14 
15     # Start a new read transaction
16     with env.begin() as txn:
17         # Encode the key the same way as we stored it
18         data = txn.get(f"{image_id:08}".encode("ascii"))
19         # Remember it's a CIFAR_Image object that is loaded
20         cifar_image = pickle.loads(data)
21         # Retrieve the relevant bits
22         image = cifar_image.get_image()
23         label = cifar_image.label
24     env.close()
25 
26     return image, label

Вот пара моментов, которые не касаются фрагмента кода выше:

  • Строка 13: флаг readonly = True указывает, что запись в файл LMDB не будет разрешен до завершения транзакции. В языке базы данных это эквивалентно блокировке чтения.
  • Строка 20: чтобы получить объект CIFAR_Image, вам нужно выполнить в обратном порядке те шаги, которые мы предприняли, когда мы записывали его.

Это завершает чтение изображения обратно из LMDB. Наконец, вы захотите сделать то же самое с HDF5.

Чтение из HDF5

Чтение из HDF5 выглядит очень похоже на процесс записи. Вот код для открытия и чтения файла HDF5 и анализа того же изображения и мета данных:

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

Обратите внимание, что вы обращаетесь к различным наборам данных в файле, индексируя объект file, используя имя набора данных, которому предшествует косая черта /. Как и раньше, вы можете создать словарь, содержащий все функции чтения:

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

Подготовив этот словарь, вы готовы к проведению эксперимента.

Эксперимент по чтению одного изображения

Вы можете ожидать, что эксперимент для чтения одного изображения будет иметь несколько предсказуемые результаты, но вот код эксперимента:

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}")

Вот результаты эксперимента по чтению одного изображения:

MethodRead Single Image + Meta
Disk1.61970 ms
LMDB4.52063 ms
HDF51.98036 ms

Чтение файлов .png и .csv непосредственно с диска происходит немного быстрее, но все три метода работают достаточно быстро. Эксперименты, которые мы сделаем дальше, намного интереснее.

Чтение множества изображений

Теперь вы можете настроить код для чтения сразу нескольких изображений. Скорее всего, это действие, которое вы будете выполнять чаще всего, поэтому производительность среды выполнения очень важна.

Корректировка кода для множества изображений

Расширяя приведенные выше функции, вы можете создавать функции с read_many_, которые можно использовать для следующих экспериментов. Как и прежде, интересно сравнить производительность при чтении разных количеств изображений, которые повторяются в приведенном ниже коде:

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
)

С функциями чтения, хранящимися в словаре, как и с функциями записи, вы готовы к эксперименту.

Эксперимент по чтению множества изображений

Теперь вы можете запустить эксперимент для чтения множества изображений:

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.

Напротив, график внизу показывает логарифм времени, выделяя относительные различия с меньшим количеством изображений. А именно, мы можем видеть, как HDF5 начинает отставать, но с большим количеством изображений становится стабильно быстрее, чем LMDB, с небольшим отрывом.

Используя ту же функцию построения графиков, что и для времени записи, мы имеем следующее:

disk_x_r = read_many_timings["disk"]
lmdb_x_r = read_many_timings["lmdb"]
hdf5_x_r = read_many_timings["hdf5"]

plot_with_legend(
    cutoffs,
    [disk_x_r, lmdb_x_r, hdf5_x_r],
    ["PNG files", "LMDB", "HDF5"],
    "Number of images",
    "Seconds to read",
    "Read time",
    log=False,
)

plot_with_legend(
    cutoffs,
    [disk_x_r, lmdb_x_r, hdf5_x_r],
    ["PNG files", "LMDB", "HDF5"],
    "Number of images",
    "Seconds to read",
    "Log read time",
    log=True,
)

На практике время записи часто менее критично, чем время чтения. Представьте, что вы тренируете глубокую нейронную сеть на изображениях, и только половина всего вашего набора данных изображений помещается в ОЗУ одновременно. Каждая эпоха обучения сети требует всего набора данных, а модели требуется несколько сотен эпох, чтобы сходиться. По сути, вы будете читать половину набора данных в память каждую эпоху.

Есть несколько уловок, которые делают люди, такие как обучение псевдо-эпох, чтобы сделать это немного лучше, но вы поняли идею.

Теперь, посмотрите еще раз на прочитанный график выше. Разница между 40-секундным и 4-секундным временем чтения внезапно становится разницей между ожиданием тренировки модели на шесть часов или на сорок минут!

Если мы посмотрим время чтения и записи на одном графике, у нас будет следующее:

Вы можете построить все данные чтения и записи на одном графике, используя одну и ту же функцию построения графика:

plot_with_legend(
    cutoffs,
    [disk_x_r, lmdb_x_r, hdf5_x_r, disk_x, lmdb_x, hdf5_x],
    [
        "Read PNG",
        "Read LMDB",
        "Read HDF5",
        "Write PNG",
        "Write LMDB",
        "Write HDF5",
    ],
    "Number of images",
    "Seconds",
    "Log Store and Read Times",
    log=False,
)

Когда вы храните изображения в виде файлов .png, существует большая разница между временем записи и чтения. Однако, с LMDB и HDF5, разница гораздо менее заметна. В целом, даже если время чтения более критично, чем время записи, существует веский аргумент в пользу хранения изображений с использованием LMDB или HDF5.

Теперь, когда вы увидели преимущества LMDB и HDF5 в производительности, давайте рассмотрим еще один важный показатель: использование диска.

Использование дискового пространства

Скорость – не единственная метрика производительности, которая вас может заинтересовать. Мы уже имеем дело с очень большими наборами данных, поэтому дисковое пространство также очень актуально.

Предположим, у вас есть набор данных изображения 3 ТБ. Предположительно, они уже есть где-то на диске, в отличие от нашего примера CIFAR, поэтому, используя альтернативный метод хранения, вы, по сути, делаете их копию, которая также должна храниться. Это даст вам огромный выигрыш в производительности при использовании образов, но вам нужно убедиться, что у вас достаточно места на диске.

Сколько дискового пространства используют различные методы хранения? Вот дисковое пространство, используемое для каждого метода для каждого количества изображений:

Я использовал команду Linux du -h -c folder_name / * для вычисления использования дискового пространства в моей системе. Существует некоторое приближение, присущее этому методу из-за округления, но вот общее сравнение:

# Memory used in KB
disk_mem = [24, 204, 2004, 20032, 200296]
lmdb_mem = [60, 420, 4000, 39000, 393000]
hdf5_mem = [36, 304, 2900, 29000, 293000]

X = [disk_mem, lmdb_mem, hdf5_mem]

ind = np.arange(3)
width = 0.35

plt.subplots(figsize=(8, 10))
plots = [plt.bar(ind, [row[0] for row in X], width)]
for i in range(1, len(cutoffs)):
    plots.append(
        plt.bar(
            ind, [row[i] for row in X], width, bottom=[row[i - 1] for row in X]
        )
    )

plt.ylabel("Memory in KB")
plt.title("Disk memory used by method")
plt.xticks(ind, ("PNG", "LMDB", "HDF5"))
plt.yticks(np.arange(0, 400000, 100000))

plt.legend(
    [plot[0] for plot in plots], ("10", "100", "1,000", "10,000", "100,000")
)
plt.show()

И HDF5, и LMDB занимают больше места на диске, чем при хранении с использованием обычных изображений .png. Важно отметить, что использование и производительность дисков LMDB и HDF5 сильно зависят от различных факторов, включая операционную систему и, что более важно, размер хранимых данных.

LMDB получает свою эффективность от кэширования и использования размеров страниц ОС. Вам не нужно понимать его внутреннюю работу, но имейте в виду, что с большими изображениями у вас будет значительно больше использования диска с LMDB, потому что изображения не поместятся на листовых страницах LMDB, обычном месте хранения в дереве, и вместо этого у вас будет много переполненных страниц.

Наши изображения размером 32x32x3 пикселя относительно малы по сравнению со средними изображениями, которые вы можете использовать, и они обеспечивают оптимальную производительность LMDB.

Хотя мы не будем исследовать это здесь экспериментально, по моему опыту с изображениями 256x256x3 или 512x512x3 пикселей HDF5 обычно немного более эффективен с точки зрения использования диска, чем LMDB.

Обсуждение различий между методами

Есть и другие отличительные особенности LMDB и HDF5, о которых стоит знать, и также важно кратко обсудить некоторые критические замечания обоих методов.

Параллельный доступ

Ключевое сравнение, которое мы не тестировали в приведенных выше экспериментах, – это одновременное чтение и запись. Часто с такими большими наборами данных вам может потребоваться ускорить работу за счет распараллеливания.

В большинстве случаев вы не будете заинтересованы в чтении частей одного и того же изображения одновременно, но вам захочется прочитать несколько изображений одновременно. При таком определении параллелизма сохранение на диск в виде файлов .png фактически обеспечивает полный параллелизм. Ничто не мешает вам читать несколько изображений одновременно из разных потоков или записывать несколько файлов одновременно, если имена изображений различны.

Как насчет LMDB? В среде LMDB может быть несколько читателей одновременно, но только один писатель, и писатели не блокируют читателей. Вы можете узнать больше об этом на веб-сайте технологии LMDB.

Несколько приложений могут одновременно обращаться к одной и той же базе данных LMDB, а несколько потоков одного и того же процесса могут одновременно обращаться к LMDB для чтения. Это обеспечивает еще более быстрое время чтения: если вы разделите весь CIFAR на десять наборов, то вы можете настроить десять процессов для каждого чтения в одном наборе, и это уменьшит время загрузки в десять раз.

HDF5 также предлагает параллельный ввод / вывод, позволяющий одновременное чтение и запись. Однако в реализации блокировка записи удерживается, и доступ является последовательным, если у вас нет параллельной файловой системы.

Если вы работаете с такой системой, есть два основных варианта, которые более подробно обсуждаются в этой статье группой HDF по параллельному вводу-выводу. Это может быть довольно сложно, и самый простой вариант – это разбить ваш набор данных на несколько файлов HDF5, чтобы каждый процесс мог работать с одним файлом .h5 независимо от других.

Документация

Существует один основной источник документации для привязки Python к LMDB, который размещен в Read the Docs LMDB. Хотя пакет даже не достиг версии> 0.94, он довольно широко используется и считается стабильным.

Что касается самой технологии LMDB, на веб-сайте технологии LMDB есть более подробная документация, которая может немного походить на изучение исчисления во втором классе, если только вы не начнете со страницы «Начало работы».

Что касается HDF5, то на сайте h5py есть документация, а также полезная запись в блоге Кристофера Ловелла, которая представляет собой отличный обзор того, как использовать пакет h5py. Книга O’Reilly, 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 доступ к объекту dataset возможен как массив Python, с индексным набором dataset[i], диапазоном, набором dataset[i:j] и другим вариантом dataset[i:j:interval].

Из-за того, как системы оптимизированы, и в зависимости от вашей операционной системы, порядок доступа к элементам может влиять на производительность.

По моему опыту, в целом верно, что для LMDB вы можете получить лучшую производительность при последовательном доступе к элементам по ключу (пары ключ-значение хранятся в памяти, упорядоченные буквенно-цифрово по ключу), и что для HDF5 доступ к большим диапазонам будет работать лучше, чем чтение каждого элемента набора данных один за другим, используя следующее:

# Slightly slower
for i in range(len(dataset)):
    # Read the ith value in the dataset, one at a time
    do_something_with(dataset[i])

# This is better
data = dataset[:]
for d in data:
    do_something_with(d)

Если вы рассматриваете выбор формата хранения файлов для написания своего программного обеспечения, было бы упущением не упомянуть Moving away from HDF5 Сирила Россанта с подводные камни HDF5, а также ответ Конрада Хинсена о On HDF5 and the future of data management, которое рассказывает, как можно избежать некоторых ловушек в некоторых случаях использования с большим количеством небольших наборов данных. Обратите внимание, что размер относительно небольшого набора данных по-прежнему составляет несколько ГБ.

Интеграция с другими библиотеками

Если вы имеете дело с действительно большими наборами данных, весьма вероятно, что вы будете делать с ними что-то важное. Стоит подумать о библиотеках глубокого обучения и о том, какая у них интеграция с LMDB и HDF5.

Прежде всего, все библиотеки поддерживают чтение изображений с диска в виде файлов .png, если вы преобразуете их в массивы NumPy ожидаемого формата. Это справедливо для всех методов, и мы уже видели выше, что считывать изображения в виде массивов относительно просто.

Вот несколько самых популярных библиотек глубокого обучения и их интеграция с LMDB и HDF5:

  • Caffe имеет стабильную, хорошо поддерживаемую интеграцию LMDB и прозрачно обрабатывает этап чтения. Слой LMDB также можно легко заменить базой данных HDF5.
  • Keras использует формат HDF5 для сохранения и восстановления моделей. Это означает, что TensorFlow может также.
  • TensorFlow имеет встроенный класс LMDBDataset, который предоставляет интерфейс для чтения входных данных из файла LMDB и может создавать итераторы и тензоры в пакетном режиме. TensorFlow не имеет встроенного класса для HDF5, но можно написать такой, который наследуется от класса набора данных. Лично я вообще использую пользовательский класс, который разработан для оптимального доступа для чтения в зависимости от того, как я структурирую свои файлы HDF5.
  • Theano изначально не поддерживает какой-либо конкретный формат файла или базу данных, но, как было сказано ранее, может использовать что угодно, пока он считывается как N-мерный массив.

Хотя это далеко не исчерпывающее описание, мы надеемся, что это даст вам представление об интеграции LMDB / HDF5 с некоторыми ключевыми библиотеками глубокого обучения.

Несколько личных рассуждений о хранении изображений в Python

В своей ежедневной работе по анализу терабайтов медицинских изображений я использую как LMDB, так и HDF5, и узнал, что при любом способе хранения предусмотрительность крайне важна.

Часто модели необходимо обучать с использованием k-кратной перекрестной проверки, которая включает в себя разбиение всего набора данных на k наборов (k обычно равняется 10) и обучение k моделей, каждая с различным k-набором, используемым в качестве тестового набора. Это гарантирует, что модель не переопределяет набор данных или, другими словами, не может делать хорошие прогнозы по невидимым данным.

Стандартный способ создания k-набора состоит в размещении равного представления каждого типа данных, представленных в наборе данных, в каждом k-множестве. Таким образом, сохранение каждого набора k в отдельный набор данных HDF5 максимизирует эффективность. Иногда один набор k не может быть загружен в память одновременно, поэтому даже порядок данных в наборе данных требует некоторой предусмотрительности.

С LMDB я также тщательно планирую заранее, прежде чем создавать базы данных. Перед сохранением изображений стоит задать несколько хороших вопросов:

  • Как я могу сохранить изображения так, чтобы большинство чтений было последовательным?
  • Какие использовать ключи?
  • Как я могу рассчитать хороший map_size, предвидя потенциальные будущие изменения в наборе данных?
  • Насколько крупной может быть одна транзакция и как следует подразделять транзакции?

Независимо от способа хранения, когда вы работаете с большими наборами данных изображений, небольшое планирование имеет большое значение.

Заключение

В этой статье вы познакомились с тремя способами хранения и доступа к большому количеству изображений в Python и, возможно, имели возможность поиграть с некоторыми из них. Весь код этой статьи находится в notebook Jupyter здесь или ввиде скрипта Python здесь.

Мы рассмотрели доказательства того, как различные методы хранения могут существенно повлиять на время чтения и записи, а также некоторые плюсы и минусы трех методов, рассмотренных в этой статье. Хотя хранение изображений в виде файлов .png может быть наиболее интуитивно понятным, при рассмотрении таких методов, как HDF5 или LMDB, есть большие преимущества в производительности.

Не стесняйтесь обсуждать в разделе комментариев отличные методы хранения, не описанные в этой статье, такие как LevelDB, Feather, TileDB, Badger, BoltDB или что-либо еще. Не существует идеального метода хранения, и наилучший метод зависит от вашего конкретного набора данных и вариантов использования.

Оригинальная статья: Rebecca Stone Three Ways of Storing and Accessing Lots of Images in Python

Была ли вам полезна эта статья?
[1 / 5]

Spread the love
Подписаться
Уведомление о
guest
2 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Катя
4 лет назад

Спасибо за полезную статью, мне особенно интересовал Хранение изображений в формате HDF5

Виктор
Виктор
4 лет назад
Reply to  Катя

Катя, “мне интересовал” или “меня интересовал”? “Интересовал хранение” или “интересовало хранение”? И Катя ли ты вообще?