Введение и предыстория

Цель этой статьи — объяснить логический поток, лежащий в основе API-интерфейса datablock от fast.ai, и то, как написать функции, позволяющие использовать этот API с видеоданными.

Постановка задачи

Недавно во время работы над проектом по определению того, была ли страница книги перевернута с помощью видео, первое, что нужно было решить, — как загрузить видеоданные в нейронную сеть. Сетевая архитектура будет основана на этой статье с целью классификации видео между двумя классами или действиями. Фреймворк, который я выбрал для использования, — это fast.ai, библиотека, основанная на PyTorch. Встроенный высокоуровневый API изначально поддерживает только загрузку данных изображений.

Несмотря на то, что решения «включай и работай» нет, мы можем использовать существующий API среднего уровня для загрузки и форматирования наших данных в соответствии с тем, что нам нужно. Проще говоря, видеоданные можно представить как две части: визуальный поток данных, который может быть выражен в виде набора кадров, и аудиопоток данных, который не имеет отношения к этому проекту. Таким образом, по сути, цель этой задачи состояла в том, чтобы найти способ передать набор изображений или кадров в нейронную сеть.

Краткий обзор API DataBlock

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

dblock = DataBlock(
    blocks      = (ImageSequenceBlock, CategoryBlock),
    get_items   = get_items, 
    get_x       = get_x, 
    get_y       = get_y, 
    splitter    = splitter,
    batch_tfms  = aug_transforms()
)

Итак, давайте начнем с определения различных параметров для DataBlock.

  • блоки — Блоки представляют окончательный формат отправляемых данных. В этом случае первый блок представляет отправляемое видео или последовательность изображений, а второй блок представляет собой сопроводительную метку к данным.
  • get_items — эта функция вызывается изначально при доступе к DataBlock. Эта функция определяет, откуда брать данные, будь то локальный источник или облако.
  • get_x — если возврат ваших get_items может использоваться в качестве источника данных, то эта функция необязательна. В противном случае вы можете использовать эту функцию для дополнительной обработки возврата из get_items, чтобы получить ваши данные X или переменные.
  • get_y — эта функция используется для создания метки для данных. В этом примере данные из этой функции в конечном итоге используются в блоках CategoryBlock.
  • splitter — эта функция определяет, как ваши данные распределяются между обучающими и тестовыми наборами.
  • batch_tfms — это набор функций, которые будут использоваться для дополнения ваших данных. Это особенно важно при работе с изображениями или визуальными данными для обучения моделей.

Базовый обзор без трансформаций и сплиттера выглядит так:

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

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

Детали реализации

Блоки данных

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

Сначала мы можем определить класс ImageSequence, который в конечном итоге возвращает кортеж файлов изображений.

class ImageSequence(tuple):
     def create(image_files):
          return tuple(PILImage.create(f) for f in image_files)

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

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

def ImageSequenceBlock():
     return TransformBlock(type_tfms = ImageSequence.create)

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

Для метки мы можем использовать встроенный блок категорий.

Собираем файлы и этикетки вместе

Теперь мы должны определить класс, который будет загружаться. В нашей реализации мы будем использовать get_items для сбора имен файлов, которые будут переданы классу ImageSequence, который мы определили выше.

class SequenceGetItems():
     def __init__(self, dataset_path):
          self.dataset_path = dataset_path
     def __call__(self, source):
        # get file names of all files
        fns = get_image_files(self.dataset_path)
        # intialize vid_index dictionary and video frames list
        vid_index = {}
        vids = []
        # loop through file names
        for fn in fns:
            # get label and video id
            split = str(fn).split("\\")
            label = split[-3]
            vid_id = split[-2]
            
            # if video is not flip, add extension to id to differentiate for videos with same title
            if label == 'notflip':
                vid_id = vid_id + 'a'
            
            # add video to index dict if not exists yet
            if vid_id not in vid_index.keys():
                vid_index[vid_id] = len(vids)
                vids.append([fn])
            else:
                # if exists, add current frame to existing list of frames
                index = vid_index[vid_id]
                existing_frames = vids[index]
                existing_frames.append(fn)
                vids[index] = existing_frames
        # sort all vides by frame name
        for i in range(len(vids)):
            vids[i] = sorted(vids[i], key=lambda v: int(v.stem))
        
        return vids

Приведенный выше класс инициализирует путь для набора данных при инициализации класса и возвращает список списков. Каждый подсписок представляет собой список объектов пути для каждого кадра в одном видео. Затем эти данные передаются в get_x и get_y.

get_x и get_y можно определить как лямбда-функции:

get_x = lambda v: random_crop_video(v, min_length = 16)
get_y = lambda v: str(v[0]).split('\\')[-3]

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

Для get_x мы берем случайную обрезку видеокадров, чтобы они соответствовали минимальной длине. Помните, что для обучения модели потребуется, чтобы все наши данные были в одном и том же формате. В этом случае мы используем 16.

def random_crop_video(video, length):
    """
    get a random crop of video frames sequentially
    if frames are under max, multiply some frames at random
    """
    frame_difference = min_length - len(vid_frames)
    if frame_difference > 0:
        for i in range(0, frame_difference):
            random_index = round(len(vid_frames) * np.random.rand())
            random_frame = vid_frames[random_index]
            pre = vid_frames[0:random_index]
            pre.append(random_frame)
            post = vid_frames[random_index:]
            vid_frames = pre + post
    elif frame_difference < 0:
        random_start = round(np.random.rand() * frame_difference *     -1)
        vid_frames = vid_frames[random_start:(random_start +  min_length)]
    return vid_frames

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

Разделение данных

Затем мы используем разделитель, чтобы определить, используются ли данные в тестовом наборе или в обучающем наборе. Чтобы разделить наши данные, мы определили этот сплиттер:

splitter = FuncSplitter(lambda o: Path(o[0].parent.parent.parent.name =='test')

Поскольку у нас был набор, к которому принадлежали данные на пути, мы смогли его использовать. Существуют также другие встроенные разделители, такие как randomsplitter() или grandparentsplitter().

Собираем все вместе

splitter = FuncSplitter(lambda o: Path(o[0]).parent.parent.parent.name == 'test')
get_x = lambda v: random_crop_video(v, min_length = 16)
get_y = lambda v: str(v[0]).split('\\')[-3]
dblock = DataBlock(
    blocks      = (ImageSequenceBlock, CategoryBlock),
    get_items   =  SequenceGetItems(PATH_TO_DATASET), 
    get_x       =  get_x, 
    get_y       =  get_y, 
    splitter    =  splitter
)

С помощью приведенного выше кода мы сгенерировали наш объект DataBlock. Мы можем использовать это для обработки наших данных. Здесь же мы будем прикреплять любые преобразования к данным. Поскольку мы по существу используем 3D-данные, встроенные преобразования изначально не поддерживают это. Следите за моим следующим постом, в котором я расскажу, как это сделать!

Создание пакета

Поздравляем, если вы до сих пор следили за этим, вы успешно определили свой пользовательский объект DataBlock. Что теперь? Что ж, теперь вы можете использовать атрибут dataloaders из DataBlock для загрузки ваших данных. Вот так:

dls = dblock.dataloaders(os.path.join('data', 'turning'), 
                         bs=4, 
                        create_batch=create_batch)

Вы заметите, что здесь есть функция create_batch. Эта функция будет действовать как способ окончательной обработки наших данных перед их отправкой в ​​модель. В настоящее время DataBlock возвращает по существу список изображений и метку. Модель ожидает тензор PyTorch.

def create_batch(data):
    xs, ys = [], []
    for d in data:
        xs.append(d[0])
        ys.append(d[1])
    xs = torch.cat([TensorImage(torch.cat([im[None] for im in x],   dim=0))[None] for x in xs], dim=0)
    ys = torch.cat([y[None] for y in ys], dim=0)
    return TensorImage(xs), TensorCategory(ys)

Вот что делает приведенный выше код:
1. Добавление пакета изображений и метки во временные списки, где xs содержит первое.
2. Со списком xs мы можем манипулировать им, сначала объединяя тензоры изображений в тензор с новым измерением, а затем объединяем их вместе в виде тензора изображений.
3. Со списком ys мы просто преобразуем его в тензор
4. Мы возвращаем xs и ys в обернутом виде. во встроенных обертках fast.ai для TensorImage и TensorCategory.

Для справки, если у вас есть пакет из 8 фотографий в последовательности размером 256x256, форма тензора для одного пакета будет [1, 8, 3, 256, 256].

Отображение пакета

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

def show_sequence_batch(dls, max_n=4):
    """
    shows max_n batches of data from the dataloader
    """
    xb, yb = dls.one_batch()
    fig, axes = plt.subplots(ncols=16, nrows=max_n, figsize=(35,6), dpi=120)
    for i in range(max_n):
        xs, ys = xb[i], yb[i]
        for j, x in enumerate(xs):
            axes[i,j].imshow(x.permute(1,2,0).cpu().numpy())
            axes[i,j].set_title(ys.item())
            axes[i,j].axis('off')
show_sequence_batch(dls, 4)

В show_sequence_batch происходит то, что мы берем один пакет из нашего недавно созданного загрузчика данных и настраиваем некоторые подграфики, используя matplotlib для размещения наших изображений. Затем мы итерируем столько строк, сколько хотим отобразить, и итерируем пакет изображений. Для каждого изображения мы должны сначала изменить существующую форму тензора изображения, чтобы она соответствовала параметрам функции .imshow, убедиться, что тензор расположен в памяти процессора, и изменить его на массив numpy.

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

Вывод

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

Узнать что-то новое? Есть предложения? Подключаемся по LinkedIn