Сегментация изображений Подробное руководство

Сегментация изображений Подробное руководство для начинающих

Как можно научить компьютер различать разные типы объектов на изображении? Пошаговое руководство.

Изображение кота перед белым забором. Из DALL·E 3.

Оглавление

  1. Введение, мотивация
  2. Извлечение данных
  3. Визуализация изображений
  4. Построение простой модели U-Net
  5. Метрики и функция потерь
  6. Построение полной модели U-Net
  7. Резюме
  8. Список литературы

Ссылки

Введение, мотивация

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

Изображение кота, сегментированное на пиксели 'кот' и 'фон'. Измененное изображение из DALL·E 3.

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

Вначале давайте поместим нашу задачу в широкий контекст машинного обучения. Определение машинного обучения самоочевидно: мы учим машины учиться решать проблемы, которые мы хотели бы автоматизировать. Есть много проблем, которые люди хотели бы автоматизировать; в этой статье мы фокусируемся на подмножестве проблем в компьютерном зрении. Компьютерное зрение стремится научить компьютер видеть. Шестилетнему ребенку легко дать изображение кошки перед белым забором и попросить его разделить изображение на пиксели «кошки» и пиксели «фона» (после того, как вы объясните ребенку, что такое «разделение», конечно). И все же веками компьютеры страдают, пытаясь решить эту проблему.

Почему компьютеры борются с тем, что шестилетний ребенок может делать? Мы можем посочувствовать компьютеру, подумав о том, как человек учится читать по Брайлю. Представьте, что вам вручают эссе, написанное на Брайле, и предположим, что у вас нет знаний о том, как его читать. Как бы вы продолжили? Что вам нужно, чтобы расшифровать Брайль на английский язык?

A small passage written in braille. From Unsplash.

Вам нужен метод преобразования этого ввода в выход, который вы сможете прочитать. В математике мы называем это отображение. Мы говорим, что мы хотим научиться функции f(x), которая отображает наш ввод x, который непонятен, в выход y, который понятен.

С многомесячной практикой и хорошим учителем каждый может научиться необходимому отображению от Брайля к английскому. По аналогии компьютер, обрабатывающий изображение, похож на человека, сталкивающегося с Брайлем в первый раз; это выглядит как набор абсурда. Компьютеру необходимо научиться необходимому отображению f(x), чтобы преобразовать набор чисел, соответствующих пикселям, во что-то, что он может использовать для сегментации изображения. И, к сожалению, компьютерная модель не обладает тысячелетней эволюцией, биологией и опытом видения мира; она по сути «рождается», когда вы запускаете программу. Это то, что мы надеемся научить нашей модели в компьютерном зрении.

Зачем нам нужно выполнять сегментацию изображений в первую очередь? Одним из более очевидных случаев использования является Zoom. Многие предпочитают использовать виртуальные фоны при видеоконференциях, чтобы избежать того, чтобы их коллеги видели, как их собака делает колеса в гостиной. Сегментация изображений крайне важна для этой задачи. Еще одним мощным применением является медицинское изображение. При проведении компьютерной томографии органов пациента может быть полезным, чтобы алгоритм автоматически выделил органы на изображениях, чтобы медицинские специалисты могли определить такие вещи, как травмы, наличие опухолей и т.д. Вот отличный пример соревнования Kaggle, сосредоточенного на этой задаче.

Есть несколько видов сегментации изображений, от простых до сложных. В этой статье мы будем иметь дело с самым простым видом сегментации изображений: бинарной сегментацией. Это означает, что в нем будет только два разных класса объектов, например «кошка» и «фон». Ни больше, ни меньше.

Обратите внимание, что представленный здесь код немного изменен и отредактирован для большей ясности. Чтобы запустить рабочий код, пожалуйста, ознакомьтесь с ссылками на код в начале статьи. Мы будет использовать набор данных Carvana Image Masking Challenge от Kaggle. Вам необходимо зарегистрироваться для участия в этом соревновании, чтобы получить доступ к набору данных, и подключить свой API-ключ Kaggle в блокноте Colab, чтобы он работал (если вы не хотите использовать блокнот Kaggle). Пожалуйста, обратитесь к этому посту в обсуждении, чтобы узнать, как это сделать.

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

Извлечение данных

Соответствующие данные для этой статьи будут находиться в следующих папках:

  • train_hq.zip: Папка, содержащая изображения высокого качества для обучения модели автомобилей
  • test_hq.zip: Папка, содержащая изображения высокого качества для тестирования модели автомобилей
  • train_masks.zip: Папка, содержащая маски для обучающего набора

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

Пример изображения вместе со своей соответствующей истинной маской, нарисованной вручную человеком. Из набора данных Carvana Image Masking Challenge.

Ваш первый шаг – распаковать папки из источника /kaggle/input:

def getZippedFilePaths():    zip_file_names = []    for dirname, _, filenames in os.walk('/kaggle/input'):        for filename in filenames:            if filename.split('.')[-1] == 'zip':                zip_file_names.append((os.path.join(dirname, filename)))    return zip_file_nameszip_file_names = getZippedFilePaths()items_to_remove = ['/kaggle/input/carvana-image-masking-challenge/train.zip',                    '/kaggle/input/carvana-image-masking-challenge/test.zip']     zip_file_names = [item for item in zip_file_names if item not in items_to_remove]for zip_file_path in zip_file_names:    with zipfile.ZipFile(zip_file_path, 'r') as zip_ref:        zip_ref.extractall()

Этот код получает пути к файлам .zip в вашем вводе и извлекает их в каталог /kaggle/output. Обратите внимание, что я специально не извлекаю фотографии низкого качества, так как репозиторий Kaggle может хранить только 20 ГБ данных, и этот шаг необходим, чтобы не превысить этот лимит.

Визуализация изображений

Первый шаг в большинстве задач компьютерного зрения – проверить ваш набор данных. С чем мы имеем дело? Сначала нам нужно собрать наши изображения в организованные наборы данных для просмотра. (В этом руководстве используется TensorFlow; конвертация в PyTorch не должна вызывать трудностей.)

# Добавление всех путей к файлам в отсортированный спискutr_dir = '/kaggle/working/train_hq/'train_masks_dir = '/kaggle/working/train_masks/'test_hq_dir = '/kaggle/working/test_hq/'X_train_id = sorted([os.path.join(train_hq_dir, filename) for filename in os.listdir(train_hq_dir)], key=lambda x: x.split('/')[-1].split('.')[0])y_train = sorted([os.path.join(train_masks_dir, filename) for filename in os.listdir(train_masks_dir)], key=lambda x: x.split('/')[-1].split('.')[0])X_test_id = sorted([os.path.join(test_hq_dir, filename) for filename in os.listdir(test_hq_dir)], key=lambda x: x.split('/')[-1].split('.')[0])X_train_id = X_train_id[:1000]y_train = y_train[:1000]X_train, X_val, y_train, y_val = train_test_split(X_train_id, y_train, test_size=0.2, random_state=42)# Создание объектов набора данных из списка путей к файламX_train = tf.data.Dataset.from_tensor_slices(X_train)y_train = tf.data.Dataset.from_tensor_slices(y_train)X_val = tf.data.Dataset.from_tensor_slices(X_val)y_val = tf.data.Dataset.from_tensor_slices(y_val)X_test = tf.data.Dataset.from_tensor_slices(X_test_id)img_height = 96img_width = 128num_channels = 3img_size = (img_height, img_width)# Применение предварительной обработкиX_train = X_train.map(preprocess_image)y_train = y_train.map(preprocess_target)X_val = X_val.map(preprocess_image)y_val = y_val.map(preprocess_target)X_test = X_test.map(preprocess_image)# Добавление меток к объектам датафреймов (one-hot-encoding)train_dataset = tf.data.Dataset.zip((X_train, y_train))val_dataset = tf.data.Dataset.zip((X_val, y_val))# Применение размера пакета к набору данныхBATCH_SIZE = 32batched_train_dataset = train_dataset.batch(BATCH_SIZE)batched_val_dataset = val_dataset.batch(BATCH_SIZE)batched_test_dataset = X_test.batch(BATCH_SIZE)# Добавление автонастройки для предварительной загрузкиAUTOTUNE = tf.data.experimental.AUTOTUNEbatched_train_dataset = batched_train_dataset.prefetch(buffer_size=AUTOTUNE)batched_val_dataset = batched_val_dataset.prefetch(buffer_size=AUTOTUNE)batched_test_dataset = batched_test_dataset.prefetch(buffer_size=AUTOTUNE)

Давайте разберемся:

  • Сначала мы создаем отсортированный список всех путей к файлам всех изображений в обучающем наборе, тестовом наборе и масках истинности истины. Обратите внимание, что это еще не изображения; на данном этапе мы только смотрим на пути к файлам изображений.
  • Затем мы берем только первые 1000 путей к файлам изображений / масок в наборе данных Carvana. Это делается для уменьшения вычислительной нагрузки и ускорения обучения. Если у вас есть доступ к нескольким мощным GPU (как вам повезло!), вы можете использовать все изображения для еще более высокой производительности. Мы также создаем разделение тренировки / валидации 80/20. Чем больше данных (изображений) вы включаете, тем больше этот раздел должен быть нацелен на тренировочный набор. Не редко видеть разделы 98/1/1 для разделений обучения / валидации / теста при работе с очень большими наборами данных. Чем больше данных в обучающем наборе, тем лучше в целом будет ваша модель.
  • Затем мы создаем объекты TensorFlow (TF) Dataset с использованием метода tf.data.Dataset.from_tensor_slices(). Использование объекта Dataset – это общий метод работы с обучающими, валидационными и тестовыми наборами данных, в отличие от хранения их в виде массивов Numpy. На мой взгляд, предварительная обработка данных гораздо быстрее и проще при использовании объектов Dataset. Здесь вы можете ознакомиться с документацией.
  • Затем мы указываем высоту, ширину и количество каналов для наших входных изображений. Фактические изображения высокого качества намного больше, чем 96 пикселей на 128 пикселей; это уменьшение наших изображений с целью уменьшения вычислительной нагрузки (более крупные изображения требуют больше времени для обучения). Если у вас есть необходимая вычислительная мощность (GPU), я не рекомендую уменьшать размер.
  • Затем мы используем функцию .map() наших объектов Dataset для предварительной обработки изображений. Это преобразует пути к файлам в изображения и выполняет соответствующую предварительную обработку. Больше об этом чуть позже.
  • После того, как мы предварительно обработали наши исходные обучающие изображения и маски истины, нам нужен способ сопоставить изображения с их масками. Для этого мы используем функцию .zip() объектов Dataset. Она берет два списка данных, объединяет первый элемент каждого списка и помещает их в кортеж. Она делает то же самое для второго элемента, третьего и так далее. В результате получается один список из кортежей вида (изображение, маска).
  • Затем мы используем функцию .batch() для создания пакетов из 32 изображений из наших тысячи изображений. Группировка – это важная часть процесса машинного обучения, поскольку позволяет обрабатывать несколько изображений одновременно, а не по одному. Это ускоряет обучение.
  • Наконец, мы используем функцию .prefetch(). Это еще один шаг, который помогает ускорить обучение. Загрузка и предварительная обработка данных может стать узким местом в процессе обучения. Это может привести к задержкам GPU или процессора, чего никто не хочет. Во время прямого и обратного распространения модели функция .prefetch() может подготовить следующую партию данных. Переменная AUTOTUNE в TensorFlow динамически вычисляет, сколько партий нужно предзагрузить на основе ресурсов вашей системы и, как правило, рекомендуется это.

Давайте ближе рассмотрим шаг предварительной обработки:

def preprocess_image(file_path):    # Загрузка и декодирование изображения    img = tf.io.read_file(file_path)    # Вы можете настроить каналы в зависимости от ваших изображений (3 для RGB)    img = tf.image.decode_jpeg(img, channels=3) # Возвращается в формате uint8    # Нормализация значений пикселей в [0, 1]    img = tf.image.convert_image_dtype(img, tf.float32)    # Изменение размера изображения до требуемых размеров    img = tf.image.resize(img, [96, 128], method='nearest')    return imgdef preprocess_target(file_path):    # Загрузка и декодирование изображения    mask = tf.io.read_file(file_path)    # Нормализация от 0 до 1 (только два класса)    mask = tf.image.decode_image(mask, expand_animations=False, dtype=tf.float32)    # Получение только одного значения для 3-го канала    mask = tf.math.reduce_max(mask, axis=-1, keepdims=True)    # Изменение размера изображения до требуемых размеров    mask = tf.image.resize(mask, [96, 128], method='nearest')    return mask

Эти функции выполняют следующие действия:

  • Во-первых, мы преобразуем пути к файлам в тензор типа данных “строка” с использованием функции tf.io.read_file(). Тензор – это специальная структура данных в TensorFlow, аналогичная многомерным массивам в других математических библиотеках, но с особыми свойствами и методами, полезными для глубокого обучения. Цитируя документацию TensorFlow: tf.io.read_file() “не выполняет никакого разбора, она просто возвращает содержимое в их исходном виде”. Это по существу означает, что она возвращает двоичный файл (1 и 0) в формате строки, содержащий информацию об изображении.
  • Во-вторых, нам нужно декодировать двоичные данные. Для этого нам нужно использовать соответствующий метод в TensorFlow. Поскольку исходные данные изображения находятся в формате .jpeg, мы используем метод tf.image.decode_jpeg(). Поскольку маски находятся в формате GIF, мы можем использовать tf.io.decode_gif() или использовать более общий tf.image.decode_image(), который может обрабатывать любой тип файла. Какой вариант выбрать – не так важно. Мы устанавливаем expand_animations=False, потому что они на самом деле не анимации, они просто изображения.
  • Затем мы используем convert_image_dtype() для преобразования наших изображений в формат float32. Это делается только для изображений, а не для маски, поскольку маска уже была декодирована в формат float32. В обработке изображений используются два распространенных типа данных: float32 и uint8. Float32 обозначает число с плавающей запятой (десятичное), занимающее 32 бита в памяти компьютера. Они знаковые (то есть число может быть отрицательным) и могут принимать значения от 0 до 2³² = 4294967296, хотя согласно общеприн
    Цвет горелого оранжевого можно представить в форматах float32 или uint8. Изображение от автора.

    После организации наших наборов данных мы можем просмотреть наши изображения:

    # Просмотр изображений и связанных метокfor images, masks in batched_val_dataset.take(1):    car_number = 0    for image_slot in range(16):        ax = plt.subplot(4, 4, image_slot + 1)        if image_slot % 2 == 0:            plt.imshow((images[car_number]))             class_name = 'Изображение'        else:            plt.imshow(masks[car_number], cmap = 'gray')            plt.colorbar()            class_name = 'Маска'            car_number += 1                    plt.title(class_name)        plt.axis("off")
    Изображения наших автомобилей с соответствующими масками.

    Здесь мы используем метод .take(), чтобы просмотреть первую пакет данных batched_val_dataset. Поскольку мы выполняем бинарную сегментацию, мы хотим, чтобы наша маска содержала только два значения: 0 и 1. Построение цветных полос на маске подтверждает, что мы правильно настроили всё. Обратите внимание, что мы добавили аргумент cmap = ‘gray’ к imshow() маски, чтобы plt знал, что мы хотим представлять эти изображения в оттенках серого.

    Построение простой модели U-Net

    В письме от 5 февраля 1675 года своему сопернику Роберту Хуку, Исаак Ньютон написал:

    «Если я видел дальше, то только потому, что стоял на плечах гигантов».

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

    Визуализация U-Net, описанная в [1]. Изображение от автора.

    Одна из более известных архитектур называется U-Net, так именуемая, потому что части сети для уменьшения и увеличения изображений можно визуализировать в виде буквы “U” (см. ниже). В статье с названием U-Net: Convolutional Networks for Biomedical Image Segmentation Роннебергер, Фишер и Брокс [1] описывают, как создать полносвязную сверточную сеть, которая эффективно работает для сегментации изображений. Полносвязная означает, что нет плотно связанных слоев, все слои являются сверточными.

    Есть несколько вещей, на которые стоит обратить внимание:

    • Сеть состоит из серии повторяющихся блоков из двух сверточных слоев с аргументом padding = ‘same’ и stride = 1, чтобы выходы сверток не уменьшались внутри блока.
    • Каждый блок следует за слоем максимальной пулинга, который уменьшает ширину и высоту карты признаков в два раза.
    • Следующий блок удваивает количество фильтров. И так далее. Этот шаблон уменьшения пространства признаков при увеличении числа фильтров должен быть вам знаком, если вы изучали сверточные нейронные сети. Это завершает то, что авторы называют “сжимающим путем”.
    • “Бутылочное” звено находится внизу “U”. Этот слой захватывает высокоабстрактные признаки (линии, кривые, окна, двери и т. д.), но с заметно уменьшенным пространственным разрешением.
    • Затем начинается то, что они называют “расширяющим путем”. Вкратце, это переворачивает сжатие, при котором каждый блок снова состоит из двух сверточных слоев. Каждый блок следует за слоем upsampling, который в TensorFlow называется слоем Conv2DTranspose. Он берет меньшую карту признаков и увеличивает высоту и ширину вдвое.
    • Затем следующий блок уменьшает количество фильтров вдвое. Повторите этот процесс до тех пор, пока размеры высоты и ширины не совпадут с изображениями, с которыми вы начали. Наконец, заканчиваем слоем 1×1 conv, чтобы уменьшить количество каналов до 1. Мы хотим закончить с одним каналом, потому что это бинарная сегментация, поэтому мы хотим иметь один фильтр, в котором значения пикселей соответствуют нашим двум классам. Мы используем сигмоидную активацию, чтобы сжать значения пикселей между 0 и 1.
    • В архитектуре U-Net также есть пропускающие соединения, позволяющие сети сохранять информацию о мелкой пространственной информации даже после уменьшения и увеличения изображений. Обычно в этом процессе теряется много информации. Передача информации из сжатого блока в соответствующий расширенный блок позволяет сохранить эту пространственную информацию. В архитектуре присутствует симметрия.

    Мы начнем с простой версии сети U-Net. Это FCN без связей остатков и слоев максимальной пулинга.

    data_augmentation = tf.keras.Sequential([        tfl.RandomFlip(mode="horizontal", seed=42),        tfl.RandomRotation(factor=0.01, seed=42),        tfl.RandomContrast(factor=0.2, seed=42)])def get_model(img_size):    inputs = Input(shape=img_size + (3,))    x = data_augmentation(inputs)        # Уменьшающийся путь    x = tfl.Conv2D(64, 3, strides=2, activation="relu", padding="same", kernel_initializer='he_normal')(x)     x = tfl.Conv2D(64, 3, activation="relu", padding="same", kernel_initializer='he_normal')(x)     x = tfl.Conv2D(128, 3, strides=2, activation="relu", padding="same", kernel_initializer='he_normal')(x)     x = tfl.Conv2D(128, 3, activation="relu", padding="same", kernel_initializer='he_normal')(x)     x = tfl.Conv2D(256, 3, strides=2, padding="same", activation="relu", kernel_initializer='he_normal')(x)     x = tfl.Conv2D(256, 3, activation="relu", padding="same", kernel_initializer='he_normal')(x)        # Расширяющийся путь    x = tfl.Conv2DTranspose(256, 3, activation="relu", padding="same", kernel_initializer='he_normal')(x)    x = tfl.Conv2DTranspose(256, 3, activation="relu", padding="same", kernel_initializer='he_normal', strides=2)(x)    x = tfl.Conv2DTranspose(128, 3, activation="relu", padding="same", kernel_initializer='he_normal')(x)    x = tfl.Conv2DTranspose(128, 3, activation="relu", padding="same", kernel_initializer='he_normal', strides=2)(x)    x = tfl.Conv2DTranspose(64, 3, activation="relu", padding="same", kernel_initializer='he_normal')(x)    x = tfl.Conv2DTranspose(64, 3, activation="relu", padding="same", kernel_initializer='he_normal', strides=2)(x)    outputs = tfl.Conv2D(1, 3, activation="sigmoid", padding="same")(x)    model = keras.Model(inputs, outputs)         return modelcustom_model = get_model(img_size=img_size)

    Здесь у нас есть такая же основная структура, как и у U-Net, с уменьшающимся путем и расширяющимся путем. Интересно отметить, что вместо использования слоя максимальной пулинга для уменьшения размера пространства признаков вдвое, мы используем сверточный слой с параметром strides=2. Согласно Chollet [2], это позволяет уменьшить размер пространства признаков вдвое, сохраняя больше пространственной информации по сравнению с слоями максимальной пулинга. Он отмечает, что в случае, когда информация о местоположении важна (как в сегментации изображений), хорошей идеей является избегать разрушительных слоев максимальной пулинга и вместо этого использовать сверточные слои с параметром strides. Также обратите внимание, что мы используем некоторую аугментацию данных, чтобы помочь нашей модели обобщаться на невидимые примеры.

    Некоторые важные детали: установка kernel_initializer на ‘he_normal’ для активаций ReLU вносит удивительно большую разницу в стабильности обучения. Сначала я недооценил силу инициализации ядер. Вместо случайной инициализации весов he_normalization инициализирует веса таким образом, чтобы их среднее значение было равным 0, а стандартное отклонение равнялось квадратному корню из (2 / количества входных единиц в слой). В случае сверточных нейронных сетей количество входных единиц относится к количеству каналов в картинках признаков предыдущего слоя. Это позволяет быстрее сходиться, сглаживает проблему исчезающих градиентов и улучшает обучение. Дополнительные детали можно найти в источнике [3].

    Метрики и функция потерь

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

    Давайте сначала рассмотрим математику, стоящую за коэффициентом Дайса:

    Коэффициент Дайса в общем виде.

    Коэффициент Дайса (dice coefficient) определяется как пересечение между двумя множествами (X и Y), деленное на сумму каждого множества, умноженное на 2. Коэффициент Дайса находится в диапазоне от 0 (если множества не пересекаются) до 1 (если множества полностью пересекаются). Теперь мы понимаем, почему это отличная метрика для сегментации изображений.

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

    Выше приведено общее определение коэффициента Дайса; когда вы применяете его к векторным величинам (в отличие от множеств), мы используем более конкретное определение:

    Коэффициент Дайса в векторной форме.

    Здесь мы выполняем итерацию по каждому элементу (пикселю) в каждой маске. x представляет собой i-й пиксель в предсказанной маске, а y представляет соответствующий пиксель в маске истинного значения (ground truth mask). В верхней части мы выполняем покомпонентное перемножение, а в нижней части мы суммируем все элементы в каждой маске независимо. N представляет собой общее количество пикселей (которое должно быть одинаковым для предсказанных и целевых масок). Помните, что в наших масках все числа будут либо 0, либо 1, поэтому пиксель со значением 1 в маске истинного значения и соответствующий пиксель в предсказанной маске со значением 0 не будет вносить вклад в оценку Дайса, как и ожидалось (1 х 0 = 0).

    Потеря Дайса определяется просто как 1 – Коэффициент Дайса. Поскольку коэффициент Дайса находится в диапазоне от 0 до 1, потеря Дайса также будет находиться в диапазоне от 0 до 1. Фактически, сумма коэффициента Дайса и потери Дайса должна быть равна 1. Они связаны обратно.

    Давайте посмотрим, как это реализуется в коде:

    from tensorflow.keras import backend as Kdef dice_coef(y_true, y_pred, smooth=10e-6):    y_true_f = K.flatten(y_true)    y_pred_f = K.flatten(y_pred)    intersection = K.sum(y_true_f * y_pred_f)    dice = (2. * intersection + smooth) / (K.sum(y_true_f) + K.sum(y_pred_f) + smooth)    return dicedef dice_loss(y_true, y_pred):    return 1 - dice_coef(y_true, y_pred)

    Здесь мы “сплющиваем” две 4D-маски (пакет, высота, ширина, каналы=1) в 1D-вектора и вычисляем коэффициенты Дайса для всех изображений в пакете. Обратите внимание, что мы добавляем значение сглаживания к числителю и знаменателю, чтобы избежать проблемы 0/0, если две маски не пересекаются.

    Наконец, мы начинаем обучение. Мы применяем раннюю остановку, чтобы предотвратить переобучение и сохраняем лучшую модель.

    custom_model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=0.0001,                                                        epsilon=1e-06),                                                         loss=[dice_loss],                                                         metrics=[dice_coef])callbacks_list = [    keras.callbacks.EarlyStopping(        monitor="val_loss",        patience=2,    ),    keras.callbacks.ModelCheckpoint(        filepath="best-custom-model",        monitor="val_loss",        save_best_only=True,    )]history = custom_model.fit(batched_train_dataset, epochs=20,                    callbacks=callbacks_list,                    validation_data=batched_val_dataset)

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

    def display(display_list):    plt.figure(figsize=(15, 15))    title = ['Исходное изображение', 'Маска истинного значения', 'Предсказанная маска']    for i in range(len(display_list)):        plt.subplot(1, len(display_list), i+1)        plt.title(title[i])        plt.imshow(tf.keras.preprocessing.image.array_to_img(display_list[i]))        plt.axis('off')    plt.show()    def create_mask(pred_mask):    mask = pred_mask[..., -1] >= 0.5    pred_mask[..., -1] = tf.where(mask, 1, 0)    # Возвращаем только первую маску из пакета    return pred_mask[0]def show_predictions(model, dataset=None, num=1):    """    Отображает первое изображение из каждого пакета    """    if dataset:        for image, mask in dataset.take(num):            pred_mask = model.predict(image)            display([image[0], mask[0], create_mask(pred_mask)])    else:        display([sample_image, sample_mask,             create_mask(model.predict(sample_image[tf.newaxis, ...]))])custom_model = keras.models.load_model("/kaggle/working/best-custom-model", custom_objects={'dice_coef': dice_coef, 'dice_loss': dice_loss})show_predictions(model = custom_model, dataset = batched_train_dataset, num = 6)

    После 10 эпох мы получили лучший коэффициент dice на валидации равный 0,8788. Неплохо, но не отлично. На GPU P100 это заняло примерно 20 минут. Вот образец маски для нашего обзора:

    Сравнение входного изображения, верной маски и предсказанной маски. Автор.

    Выделение нескольких интересных моментов:

    • Обратите внимание, что функция create_mask преобразует значения пикселей в 0 или 1. Значение пикселя < 0,5 будет усечено до 0, и этот пиксель будет отнесен к категории “фон”. Значение ≥ 0,5 будет увеличено до 1, и этот пиксель будет отнесен к категории “машина”.
    • Почему маски получились желтой и фиолетовой, а не черной и белой? Мы использовали функцию tf.keras.preprocessing.image.array_to_img() для преобразования вывода маски из тензора в изображение PIL. Затем мы передали изображение в plt.imshow(). Из документации мы видим, что стандартная цветовая карта для одноканальных изображений – “viridis” (трехканальные RGB-изображения выводятся как есть). Цветовая карта viridis преобразует низкие значения в глубокую фиолетовую, а высокие значения в желтую. Эта цветовая карта, по-видимому, помогает людям с дальтонизмом получить точное представление о цвете на изображении. Мы могли бы исправить это, добавив cmap=”grayscale” в качестве аргумента, но это испортило бы входное изображение. Подробнее смотрите здесь по этой ссылке.
    Цветовая карта viridis, от низких значений (фиолетовый) до высоких (желтый). Автор.

    Построение полной архитектуры U-Net

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

    def conv_block(inputs=None, n_filters=64, dropout_prob=0, max_pooling=True):    conv = Conv2D(n_filters,                    3,                     activation='relu',                  padding='same',                  kernel_initializer='he_normal')(inputs)    conv = Conv2D(n_filters,                    3,                     activation='relu',                  padding='same',                  kernel_initializer='he_normal')(conv)    if dropout_prob > 0:        conv = Dropout(dropout_prob)(conv)    if max_pooling:        next_layer = MaxPooling2D(pool_size=(2, 2))(conv)    else:        next_layer = conv    skip_connection = conv    return next_layer, skip_connectiondef upsampling_block(expansive_input, contractive_input, n_filters=64):    up = Conv2DTranspose(        n_filters,            3,            strides=(2, 2),        padding='same',        kernel_initializer='he_normal')(expansive_input)    # Слияние предыдущего выхода и contractive_input    merge = concatenate([up, contractive_input], axis=3)    conv = Conv2D(n_filters,                     3,                       activation='relu',                  padding='same',                  kernel_initializer='he_normal')(merge)    conv = Conv2D(n_filters,                     3,                       activation='relu',                  padding='same',                  kernel_initializer='he_normal')(conv)    return convdef unet_model(input_size=(96, 128, 3), n_filters=64, n_classes=1):    inputs = Input(input_size)        inputs = data_augmentation(inputs)    # Сжимающий путь (кодирование)    cblock1 = conv_block(inputs, n_filters)    cblock2 = conv_block(cblock1[0], n_filters*2)    cblock3 = conv_block(cblock2[0], n_filters*4)    cblock4 = conv_block(cblock3[0], n_filters*8, dropout_prob=0.3)    # Узкое место    cblock5 = conv_block(cblock4[0], n_filters*16, dropout_prob=0.3, max_pooling=False)        # Расширяющий путь (декодирование)    ublock6 = upsampling_block(cblock5[0], cblock4[1],  n_filters*8)    ublock7 = upsampling_block(ublock6, cblock3[1],  n_filters*4)    ublock8 = upsampling_block(ublock7, cblock2[1],  n_filters*2)    ublock9 = upsampling_block(ublock8, cblock1[1],  n_filters)    conv9 = Conv2D(n_filters,                   3,                   activation='relu',                   padding='same',                   kernel_initializer='he_normal')(ublock9)    conv10 = Conv2D(n_classes, 1, padding='same', activation="sigmoid")(conv9)    model = tf.keras.Model(inputs=inputs, outputs=conv10)    return model

    Затем мы компилируем U-Net. Я использую 64 фильтра для первого блока свертки. Это гиперпараметр, который вы хотели бы настроить для достижения оптимальных результатов.

    unet = unet_model(input_size=(img_height, img_width, num_channels), n_filters=64, n_classes=1)unet.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=0.0001, epsilon=1e-06),             loss=[dice_loss],              metrics=[dice_coef])callbacks_list = [    keras.callbacks.EarlyStopping(        monitor="val_loss",        patience=2,    ),    keras.callbacks.ModelCheckpoint(        filepath="best-u_net-model",        monitor="val_loss",        save_best_only=True,    )]history = unet.fit(batched_train_dataset, epochs=20,                    callbacks=callbacks_list,                    validation_data=batched_val_dataset)

    После 16 эпох я получаю валидационный коэффициент Сёренсена-Дайса 0.9416, гораздо лучше, чем с обычной U-Net. Это не должно вызывать слишком больших удивлений; при взгляде на количество параметров мы имеем порядок увеличения от обычной U-Net до полной U-Net. На графическом процессоре P100 эта операция заняла около 32 минут. Затем мы заглядываем в предсказания:

    unet = keras.models.load_model("/kaggle/working/best-u_net-model", custom_objects={'dice_coef': dice_coef, 'dice_loss': dice_loss})show_predictions(model = unet, dataset = batched_train_dataset, num = 6)
    Предсказанная маска для полной U-Net. Гораздо лучше! Соавтором

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

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

    • Количество блоков уменьшения и увеличения размеров
    • Количество фильтров
    • Разрешение изображения
    • Размер набора данных для обучения
    • Функция потерь (возможно, комбинирование функции потерь по дайсу с функцией бинарной перекрестной энтропии)
    • Настройка параметров оптимизатора. Стабильность обучения, кажется, является проблемой для обоих моделей. Из документации по оптимизатору Adam: “Значение по умолчанию 1e-7 для эпсилона может не быть хорошим значением по умолчанию в общем случае”. Увеличение эпсилона на порядок или более может помочь повысить стабильность обучения.

    Мы уже видим путь к превосходному результату в задаче Carvana. Жаль, что он уже закончился!

    Резюме

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

    • Цель сегментации изображения – найти соответствие от входных значений пикселей в изображении к числам на выходе, которые ваша модель может использовать для присвоения классов каждому пикселю.
    • Одним из первых шагов является организация ваших изображений в объекты TensorFlow Dataset и просмотр изображений и соответствующих масок.
    • Нет необходимости изобретать велосипед, когда речь идет о архитектуре модели: мы знаем из опыта, что U-Net хорошо работает.
    • Коэффициент Сёренсена-Дайса – общая метрика, которая используется для отслеживания успеха предсказаний вашей модели. Из него можно также получить функцию потерь.

    Будущие работы могут быть направлены на преобразование слоев максимальной пулинга в канонической архитектуре U-Net в сверточные слои со стридами.

    Удачи в решении ваших проблем с сегментацией изображений!

    Ссылки

    [1] О. Роннебергер, П. Фишер и Т. Брокс, U-Net: Сверточные сети для биомедицинской сегментации изображений (2015), Международная конференция MICCAI 2015

    [2] Ф. Шоллет, Глубокое обучение с использованием Python (2021), Издательство Manning

    [3] К. Хе, X. Чжан, С. Рен, Ж. Сун, Глубокое исследование выпрямителей: превышение уровня производительности человека в классификации ImageNet (2015), Международная конференция по компьютерному зрению (ICCV)