Было ли у вас такое, что в интернет-магазине понравилась какая-нибудь вещь, но не хочется покупать ее, не примерив? Конечно, в некоторых магазинах есть возможность примерить одежду после заказа перед оплатой. Однако по статистике каждый год доля онлайн-заказов в интернет-магазинах одежды и обуви растет, но также растет и доля возвратов, она составляет 50–70% — это огромные затраты на логистику, которые можно будет значительно сократить, используя онлайн-примерочную. Представьте, вы загружаете свою фотографию, выбираете одежду и она переносится на ваше изображение. Уже существуют виртуальные примерочные обуви, они работают достаточно успешно. Некоторое время назад нас заинтересовала эта тема, как обстоят дела с одеждой? Такие работы тоже существуют, но успешных гораздо меньше, во многих из них кроме статьи найти ничего не удается, о рабочем примере остается только мечтать. Мы решили исправить это и поддержать одну из сетей в библиотеке OpenCV. Что из этого вышло можете увидеть в virtual_try_on.py sample.



Результат не идеален, но в данной области считается достаточно хорошим.


Хотите узнать как работает виртуальная примерочная и с какими сложностями мы столкнулись при интеграции модели в OpenCV — добро пожаловать под кат!


Мы выбрали нейронную сеть 2019 года CP-VTON с достаточно хорошим визуальным результатом. По сравнению с аналогами CP-VTON интересна тем, что она достаточно точно может передать форму одежды в сгенерированном изображении, а также при передаче не теряются особенности одежды (рисунок, логотип и текстура). Некоторые аналогичные сети используют 3D модели человека, для получения которых требуется 3D-сканер. Этот факт существенно сокращает области применимости модели. Также большим плюсом является наличие кода на github. Но авторы CP-VTON в репозитории приводят только скрипты для тренировки и тестирования, нет никакой демки, чтобы попробовать сеть на своих фотографиях.


Мы натренировали сеть самостоятельно и выложили ее в открытый доступ.


Принцип работы


Модель CP-VTON состоит из двух подмодулей: GMM (Geometric Matching Module) — модуль для деформации одежды в соответствие с позой человека и TOM (Try-On Module) — модуль для переноса деформированной одежды на изображение человека.


Для тренировки GMM требуется тройка $(I, c, c_t )$, а для TOM — $(I, \hat{c}, I_t)$, $I$ — изображение человека, $c$ — изображение одежды, $\hat{c}$ — выход GMM, $I_t$ — ground truth (человек в одежде $c$), $c_t$ — ground truth (деформированная одежда, получается наложением маски одежды на $I_t$). Но такие тройки достаточно тяжело собрать, обычно есть только $I_t$ и $c$. Решение этой проблемы предлагалось в одной из предыдущих работ VITON, авторы статьи вместо $I$ на вход сети подавали тензор с описанием человека $p$. При создании $p$ преследовалась цель максимального сохранения информации о человеке, включая лицо, волосы, форму тела и позу, но минимизируя влияние старой одежды (цвет, форма, текстура). Этот подход позволяет не показывать сети эталонное изображение человека в желаемой одежде. Таким образом, GMM принимает на вход $(p, c)$ и генерирует деформированную одежду $\hat{c}$, которая во время тренировки сравнивается с $c_t$. TOM принимает на вход $(p, \hat{c})$, а выход сети $I_o$ сравнивается с $I_t$.


Рассмотрим поподробнее формирование тензора $p$. Описание человека состоит из маски тела человека, ключевых точек позы и изображения головы. Сначала по фотографии выполняется поиск ключевых точек с помощью сети OpenPose. Для получения бинарной маски тела человека используется семантическая сегментация одежды и частей тела. Эта задача решается с помощью модели LIP_JPPNet. В процессе ее поддержки в OpenCV добавился (самостоятельный) sample human_parsing.py.


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


preprocessing


Рассмотрим работу модулей.


GMM используется для изменения формы желаемой одежды в соответствие с позой человека. В процессе работы сеть извлекает ключевые признаки из изображения одежды и тензора с описанием человека независимо, нормирует признаки, используя $L_2-норму$. Вычисляется корреляция признаков и выполняется прогнозирование вектора параметров пространственного преобразования. По нему в результате постобработки генерируется сетка деформации исходного изображения одежды. С помощью билинейной интерполяции одежды по узлам сетки получается форма одежды, соответствующая позе человека. Для сравнения полученной деформации одежды $\hat{c}$ с $c_t$ используется $L_1-loss$.


gmm


Модуль TOM переносит деформированную одежду на изображение с человеком. Сеть базируется на архитектуре Unet. Данная архитектура была выбрана, потому что при непосредственном переносе деформированной одежды на изображение человека результат выглядит неестественно на стыках и местах соприкосновения человека и одежды. Unet позволяет избавиться от этого, делая переходы более плавными. Однако невозможно идеально выровнять одежду по фигуре, поэтому даже незначительное смещение одежды относительно позы может сделать выход Unet размытым. Сеть представляет собой последовательность сверток и пулингов, которые уменьшают пространственное разрешение картинки. А потом разрешение увеличивается последовательностью Upsample и сверток. Кодировщик (encoder) в сети является предварительно обученным VGG-19. Чтобы выходное изображение не было слишком размытым используется следующий подход. Unet предсказывает образ человека $I_r$ и композиционную маску $M$. Затем маска комбинируется с деформированной одеждой $c$ и накладывается на $I_r$, скомбинированный с обратной маской $(1 - M)$ и получается результирующее изображение $I_o$.


$I_o=M\odot c + (1 - M)\odot I_r$


$\odot$ — знак поэлементного матричного умножения.


tom


При обучении сети главная цель Try-On модуля — минимизация несоответствия $I_o$ с $I_t$. При попиксельном сравнении результата работы сети и изображения из разметки малое значение функции потерь будет соответствовать размытым изображениям. А похожим с точки зрения человека изображениям, но с небольшими сдвигами, будет соответствовать большое значение функции потерь. Чтобы избежать этих недостатков используют комбинацию $L_1- loss$ с perceptual loss. Картинка из разметки и предсказание сети пропускаются через несколько слоев сети VGG и сравниваются полученные нейросетевые признаки, которые будут инвариантны к небольшому изменению положения в пространстве. Также VGG чувствительна к резким изменениям яркости пикселей — это позволяет устранить размытость в изображении, сделав большим значение функции потерь на таких картинках.


Реализация в OpenCV


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


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


python3 virtual_try_on.py -i person_img.jpg -c cloth.jpg

При портировании сети в OpenCV нужно было убрать всю предобработку через PIL. Тут возникли чудеса с сегментацией. В датасете LIP представлены полутоновые изображения, где интенсивность пикселя соответствует классу объекта. А при скачивании датасета для CP-VTON эти же изображения уже цветные. Читается трехканальное изображение, но потом выполняется работа с одноканальными изображениями:


shape = (segm > 0).astype(np.float32)
head  = (segm == 1).astype(np.float32) +         (segm == 2).astype(np.float32) +         (segm == 4).astype(np.float32) +         (segm == 13).astype(np.float32)
cloth = (segm == 5).astype(np.float32) +         (segm == 6).astype(np.float32) +         (segm == 7).astype(np.float32)

Оказывается, что PIL может хранить цветное изображение и кодировку, с помощью которой оно было получено. Перевод лейблов в цвета лежал в human_colormap.mat. Как их оттуда прочитать? Matlab ставить не хотелось. К счастью, scipy имеет загрузчик таких файлов. Потратив немного (много) времени на эти махинации удалось извлечь из сегментации маску тела и головы.


Дальше – больше. Для размытия маски ее сначала уменьшают в 16 раз, затем возвращают исходные размеры.


mask = mask.resize((width // 16, height // 16), Image.BILINEAR)
mask = mask.resize((width, height), Image.BILINEAR)

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



Слева маска, полученная через PIL resize, справа — cv.resize.


Маски получились вообще не похожи, а результат работы сети? В общем смотрите сами.


mask_pil mask_ocv


Слева результат, полученный при использовании маски с PIL resize, справа — cv.resize.


Выглядит грустновато, не хотелось бы так уродовать людям руки. Что же это за билинейная интерполяция такая? Изрядно погуглив, так и не удалось ничего найти. Оставался последний, но верный способ – смотреть исходники на С. Там удалось увидеть, что bilinear resize вовсе не похож на bilinear, а скорее area. Размер ядра зависит от scale factor, в нашем случае ядро имело размер 33 = 16 * 2 + 1, в то время как в OpenCV ядро фиксированного размера – 3. Одновременно с этим мы заметили, что значения пикселей отличаются только при уменьшении размеров, а при восстановлении результаты получаются достаточно близки. Но изменение типа интерполяции все равно не помогло добиться нужного результата. Смотрим дальше. Коэффициенты интерполяции считаются очень странным образом, получаются не симметричны. Это нам уже никак не удавалось смоделировать. Поэтому пришлось написать небольшой класс с таким типом интерполяции:


Реализация билинейной интерполяции из PIL
class BilinearFilter(object):
    """
    PIL bilinear resize implementation
    image = image.resize((image_width // 16, image_height // 16), Image.BILINEAR)
    """
    def _precompute_coeffs(self, inSize, outSize):
        filterscale = max(1.0, inSize / outSize)
        ksize = int(np.ceil(filterscale)) * 2 + 1

        kk = np.zeros(shape=(outSize * ksize, ), dtype=np.float32)
        bounds = np.empty(shape=(outSize * 2, ), dtype=np.int32)

        centers = (np.arange(outSize) + 0.5) * filterscale + 0.5
        bounds[::2] = np.where(centers - filterscale < 0, 0, centers - filterscale)
        bounds[1::2] = np.where(centers + filterscale > inSize, inSize, centers + filterscale) - bounds[::2]
        xmins = bounds[::2] - centers + 1

        points = np.array([np.arange(row) + xmins[i] for i, row in enumerate(bounds[1::2])]) / filterscale
        for xx in range(0, outSize):
            point = points[xx]
            bilinear = np.where(point < 1.0, 1.0 - abs(point), 0.0)
            ww = np.sum(bilinear)
            kk[xx * ksize : xx * ksize + bilinear.size] = np.where(ww == 0.0, bilinear, bilinear / ww)
        return bounds, kk, ksize

    def _resample_horizontal(self, out, img, ksize, bounds, kk):
        for yy in range(0, out.shape[0]):
            for xx in range(0, out.shape[1]):
                xmin = bounds[xx * 2 + 0]
                xmax = bounds[xx * 2 + 1]
                k = kk[xx * ksize : xx * ksize + xmax]
                out[yy, xx] = np.round(np.sum(img[yy, xmin : xmin + xmax] * k))

    def _resample_vertical(self, out, img, ksize, bounds, kk):
        for yy in range(0, out.shape[0]):
            ymin = bounds[yy * 2 + 0]
            ymax = bounds[yy * 2 + 1]
            k = kk[yy * ksize: yy * ksize + ymax]
            out[yy] = np.round(np.sum(img[ymin : ymin + ymax, 0:out.shape[1]] * k[:, np.newaxis], axis=0))

    def imaging_resample(self, img, xsize, ysize):
        height, width, *args = img.shape
        bounds_horiz, kk_horiz, ksize_horiz = self._precompute_coeffs(width, xsize)
        bounds_vert, kk_vert, ksize_vert    = self._precompute_coeffs(height, ysize)

        out_hor = np.empty((img.shape[0], xsize), dtype=np.uint8)
        self._resample_horizontal(out_hor, img, ksize_horiz, bounds_horiz, kk_horiz)
        out = np.empty((ysize, xsize), dtype=np.uint8)
        self._resample_vertical(out, out_hor, ksize_vert, bounds_vert, kk_vert)
        return out

Результаты


В общем получился достаточно интересный пайплайн из 4 сетей, полученных из разных фреймворков, но дружно работающих в OpenCV. При тестировании сети на реальных фотографиях мы заметили несколько особенностей сети. В тренировочном датасете были только изображения девушек, поэтому сеть не всегда хорошо работает с мужчинами. Руки у них становятся более женственные, а переход от головы к телу имеет очень яркую границу. Также для получения качественного результата нужно загружать фото с соотношением сторон пропорциональных 256 на 192, иначе сеть прибавит пару килограммов. Мы добавили кадрирование исходного изображения в sample до получения нужного соотношения между шириной и высотой. Лучше чтобы кроме человека ничего не было, а одежда на изображении должна находиться на белом фоне без каких-либо узоров и надписей, иначе эти части тоже будут восприниматься как одежда.


P.S. В мае 2020 года OpenCV отмечает свое 20-летие. Мы планируем в этом году писать больше статей и придумывать другие активности. Следите за новостями!