Русский
Русский
English
Статистика
Реклама

Computer vision

Как мы построили Computer Vision из подручных материалов, чтобы сделать гифки

17.06.2021 14:15:17 | Автор: admin

Меня зовут Денис Власов, я Data Scientist в Учи.ру. С помощью моделей машинного обучения из записей онлайн-уроков мы сделали гифки последовательность из нескольких кадров с наиболее яркими эмоциями учеников. Эти гифки получили их родители в e-mail-рассылке. Вместе с Data Scientist @DariaV Дашей Васюковой расскажем, как без экспертизы в Computer Vision, а только с помощью открытых библиотек и готовых моделей сделать MVP, в основе которого лежат low-res видео. В конце бонус виджет для быстрой разметки кадров.

Откуда у нас вообще возникла мысль распознавать эмоции? Дело в том, что мы в Учи.ру развиваем онлайн-школу Учи.Дома сервис персональных видео-уроков для школьников. Но поскольку такой урок это чисто человеческое взаимодействие, возникла идея прикрутить к нему немного аналитики. Такие данные могут помочь повысить конверсию, отследить эффективность уроков, замерить вовлеченность учеников и многое другое.

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

Маркеры начала и конца урока

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

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

Разбили видео на кадры

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

Научились детектировать детские улыбки (и не только)

На каждом кадре необходимо обнаружить лицо. Если оно там есть, проверить, родитель это или ученик, а также оценить эмоции на лице. И тут возникло несколько нюансов, которые пришлось учитывать.

Проблема 1. Распознавать лица на картинках низкого качества сложнее

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

Стандартный детектор DNN Face Detector из библиотеки OpenCV, который мы сначала взяли за основу, на наших данных давал неточные результаты. Оказалось, что алгоритм недостаточно хорошо справляется с реальными кадрами из видеочатов: иногда пропускает лица, которые явно есть в кадре, из двух лиц находил только одно или определял лица там, где их нет.

Стандартный детектор DNN Face Detector мог определить как лицо узор на занавеске, игрушечного медведя или даже композицию из картин на стене и стулаСтандартный детектор DNN Face Detector мог определить как лицо узор на занавеске, игрушечного медведя или даже композицию из картин на стене и стула

Поэтому мы решили попробовать обучить свой детектор. Для этого взяли реализацию RetinaNet-модели на PyTorch. В качестве данных для обучения подали результаты работы стандартного детектора и убедились, что новая модель учится находить лица. Затем подготовили обучающую и валидационную выборку, просматривая и при необходимости исправляя результаты работы детектора на новых кадрах: исправлять разметку работающей модели получается быстрее, чем отмечать лица на кадре с нуля.

Размечали итеративно: после добавления новой порции размеченных кадров мы заново обучали модель. А после проверки ее работы сохраняли разметку для новых кадров, наращивая обучающую выборку. Всего мы разметили 2624 кадра из 388 видеозаписей, на которых в сумме было 3325 лиц.

Таким образом удалось обучить более чувствительный в наших условиях детектор. В валидационной выборке из 140 кадров старый детектор нашел 150 лиц, а пропустил 38. Новый же пропустил только 5, а 183 обнаружил верно.

Проблема 2. В кадре присутствует не только ребенок

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

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

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

  • возраст людей на кадре с низким разрешением становится неочевидным;

  • дети присутствуют в кадре практически весь урок, а взрослые минуты.

В процессе разметки мы заметили, что очень часто родитель присутствует на уроке плечом или локтем. Так мы назвали тип кадров, когда камера направлена на ученика, но видно, что рядом сидит родитель. Обычно на таких уроках видно плечо сидящего рядом взрослого или только локоть.

На всех трех кадрах родитель присутствует, но по отдельному кадру найти его бывает непростоНа всех трех кадрах родитель присутствует, но по отдельному кадру найти его бывает непросто

Вторая модель должна была находить именно такие родительские плечи. Очевидно, что в этой задаче детектор лиц не применим, поэтому надо обучаться на кадрах целиком. Конечно, таких датасетов мы не нашли в публичном доступе и разметили около 250 000 кадров, на которых есть часть родителя, и кадры без них. Разметки на порядок больше, чем в других задачах, потому что размечать гораздо легче: можно смотреть не отдельные кадры, а отрезки видео и в несколько кликов отмечать, например, что вот эти 15 минут (900 кадров!) родитель присутствовал.

На дашборде урока с аналитикой доступны графики присутствия родителями по мнению обеих моделей. Они помогают понять, когда родитель просто интересуется процессом урока, а когда скорее общается с преподавателем.

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

Проблема 3. Дети улыбаются по-разному

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

За основу классификатора настроения мы взяли предобученную модель ResNet34 из библиотеки fast.ai. Эту же библиотеку использовали для дообучения модели в два этапа: сначала на публичных датасетах facial_expressions и SMILEsmileD с веселыми и нейтральными лицами, потом на нашем размеченном вручную датасете с кадрами с камер учеников. Публичные датасеты решили включить, чтобы расширить размер выборки и помочь модели более качественными изображениями, чем кадры видео с планшетов и веб-камер наших учеников.

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

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

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

  3. Аугментация. Позволяет в разы увеличить эффективный размер выборки и учесть особенности данных.

  4. Нормализация цветов с помощью CLAHE normalizer из библиотеки OpenCV. По ощущениям, такая нормализация лучше других вытягивает контраст на пересвеченных или темных изображениях.

Дообучаем модель для распознавания улыбок

1. Аугментации

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

  • Отражали изображение по горизонтали.

  • Поворачивали на случайную величину.

  • Применяли три разных искажения для изменения контраста и яркости.

  • Брали не всю картинку, а квадрат, составляющий не менее 60% от площади исходного изображения.

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

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

Пример аугментаций на одном изображении. Для наглядности аугментации сделаны до масштабирования к разрешению 64х64Пример аугментаций на одном изображении. Для наглядности аугментации сделаны до масштабирования к разрешению 64х64Код для аугментаций
# ! pip freeze | grep fastai# fastai==1.0.44import fastaiimport matplotlib.pyplot as pltfrom matplotlib import cmfrom matplotlib import colorsimport seaborn as sns%matplotlib inlinefrom pylab import rcParamsplt.style.use('seaborn-talk')rcParams['figure.figsize'] = 12, 6path = 'facial_expressions/images/'def _side_cutoff(    x,    cutoff_prob=0.25,    cutoff_intensity=(0.1, 0.25)):    if np.random.uniform() > cutoff_prob:        return x    # height and width    h, w = x.shape[1:]    h_cutoff = np.random.randint(        int(cutoff_intensity[0]*h), int(cutoff_intensity[1]*h)    )    w_cutoff = np.random.randint(        int(cutoff_intensity[0]*w), int(cutoff_intensity[1]*w)    )        cutoff_side = np.random.choice(        range(4),        p=[.34, .34, .16, .16]    ) # top, bottom, left, right.    if cutoff_side == 0:        x[:, :h_cutoff, :] = 0    elif cutoff_side == 1:        x[:, h-h_cutoff:, :] = 0    elif cutoff_side == 2:        x[:, :, :w_cutoff] = 0    elif cutoff_side == 3:        x[:, :, w-w_cutoff:] = 0    return x# side cutoff goes frist.side_cutoff = fastai.vision.TfmPixel(_side_cutoff, order=99)augmentations = fastai.vision.get_transforms(    do_flip=True,    flip_vert=False,    max_rotate=25.0,    max_zoom=1.25,    max_lighting=0.5,    max_warp=0.0,    p_affine=0.5,    p_lighting=0.5,        xtra_tfms = [side_cutoff()])def get_example():    return fastai.vision.open_image(        path+'George_W_Bush_0016.jpg',    )def plots_f(rows, cols, width, height, **kwargs):    [        get_example()        .apply_tfms(            augmentations[0], **kwargs        ).show(ax=ax)        for i,ax in enumerate(            plt.subplots(                rows,                cols,                figsize=(width,height)            )[1].flatten())    ]plots_f(3, 5, 15, 9, size=size)

2. Нормализация цвета

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

Пример нормализации цвета на изображениях из публичного датасетаПример нормализации цвета на изображениях из публичного датасетаКод для нормализации цвета
# pip freeze | grep opencv# > opencv-python==4.5.2.52import cv2import matplotlib.pyplot as pltfrom matplotlib import cmfrom matplotlib import colorsimport seaborn as sns%matplotlib inlinefrom pylab import rcParamsplt.style.use('seaborn-talk')rcParams['figure.figsize'] = 12, 6path = 'facial_expressions/images/'imgs = [    'Guillermo_Coria_0021.jpg',    'Roger_Federer_0012.jpg',]imgs = list(    map(        lambda x: path+x, imgs    ))clahe = cv2.createCLAHE(    clipLimit=2.0,    tileGridSize=(4, 4))rows_cnt = len(imgs)cols_cnt = 4imsize = 3fig, ax = plt.subplots(    rows_cnt, cols_cnt,    figsize=(cols_cnt*imsize, rows_cnt*imsize))for row_num, f in enumerate(imgs):    img = cv2.imread(f)    col_num = 0        img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)    ax[row_num, col_num].imshow(img, cmap='gray')    ax[row_num, col_num].set_title('bw', fontsize=14)    col_num += 1    img_normed = cv2.normalize(        img,        None,        alpha=0,        beta=1,        norm_type=cv2.NORM_MINMAX,        dtype=cv2.CV_32F    )    ax[row_num, col_num].imshow(img_normed, cmap='gray')    ax[row_num, col_num].set_title('bw normalize', fontsize=14)    col_num += 1        img_hist_normed = cv2.equalizeHist(img)    ax[row_num, col_num].imshow(img_hist_normed, cmap='gray')    ax[row_num, col_num].set_title('bw equalizeHist', fontsize=14)    col_num += 1        img_clahe = clahe.apply(img)    ax[row_num, col_num].imshow(img_clahe, cmap='gray')    ax[row_num, col_num].set_title('bw clahe_norm', fontsize=14)    col_num += 1        for col in ax[row_num]:        col.set_xticks([])        col.set_yticks([])plt.show()

В итоге мы получили модель, способную отличить улыбку от нейтрального выражения лица в с качеством 0.93 по метрике ROC AUC. Иными словами, если взять из выборки по случайному кадру с улыбкой и без, то с вероятностью 93% модель присвоит большую вероятность улыбки кадру с улыбающимся лицом. Этот показатель мы использовали для сравнения разных вариантов дообучения и пайплайнов. Но интуитивно кажется, что это достаточно высокий уровень точности: даже человек не всегда может определить эмоцию на лице другого человека. К тому же в реальности существует гораздо больше выражений лиц кроме однозначной радости и однозначной печали.

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

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

3. Увеличение объема выборки

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

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

Такое упражнение называется построением learning curve, и оно в очередной раз подтверждает тезис: объем данных самое важное в модели машинного обучения.

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

4. Картинки Google для обогащения выборки

Мы пробовали спарсить первые 1000 результатов картинок по запросам в духе happy, unhappy, smiling, neutral и т. д. Не ожидали получить данные высокого качества, поэтому планировали потом просмотреть их глазами и удалить совсем неподходящие. В итоге мы быстро поняли, что никакая фильтрация эти картинки не спасет, поэтому отказались от этой идеи совсем.

Примеры изображений по запросам happy и unhappyПримеры изображений по запросам happy и unhappy

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

  • есть ли в кадре лицо;

  • с какой вероятностью этот человек улыбается;

  • ребенок это или взрослый;

  • есть ли в кадре взрослый, даже если мы не нашли лица.

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

Собрали гифку

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

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

Примеры итоговых GIF с улыбками нашей коллеги и ее детейПримеры итоговых GIF с улыбками нашей коллеги и ее детей

Что мы в итоге получили?

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

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

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

Статистика дисконнектов. В этом уроке был единственный дисконнект на стороне ученикаСтатистика дисконнектов. В этом уроке был единственный дисконнект на стороне ученика

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

Виджеты

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

  1. Виджет для разметки кадров с улыбками.

  2. Виджет для разметки кадров с детьми и взрослыми.

  3. Виджет для разметки кадров с плечом или локтем родителя.

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

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

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

Видео работы виджета

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

Код виджета
import pandas as pdimport numpy as npimport datetimeimport randomimport osimport ipywidgets as widgetsfrom IPython.display import displayfrom pathlib import Pathclass BulkLabeler():    def __init__(self, frames_path, annotations_path,                 labels = ['0', '1'],                 predict_fn = None,                 frame_width=120,                 num_frames = 27,                 face_width = 120,                 num_faces = 27,                 myname = '?',                 ):        self.predict_fn = predict_fn        self.labels = labels        self.frames_path = frames_path        self.frame_width = frame_width        self.num_frames = num_frames        self.face_width = face_width        self.num_faces = num_faces        self.myname = myname        self.faces_batch = []                # get annotations        self.annotations_path = annotations_path        processed_videos = []        if annotations_path.exists():            annotations = pd.read_csv(annotations_path)            processed_videos = annotations.file.str.split('/').str[-3].unique()        else:            with open(self.annotations_path, 'w') as f:                f.write('file,label,by,created_at\n')                # get list of videos        self.video_ids = [x for x in os.listdir(frames_path)                           if x not in processed_videos]        random.shuffle(self.video_ids)        self.video_ind = -1                self._make_video_widgets_row()        self._make_frames_row()        self._make_range_slider()        self._make_buttons_row()        self._make_faces_row()        self._make_video_stats_row()                display(widgets.VBox([self.w_video_row,                              self.w_frames_row,                              self.w_slider_row,                              self.w_buttons_row,                              self.w_faces_row,                              self.w_faces_label,                              self.w_video_stats]))        self._on_next_video_click(0)            ### Video name and next video button        def _make_video_widgets_row(self):        # widgets for current video name and "Next video" button        self.w_current_video = widgets.Text(            value='',            description='Current video:',            disabled=False,            layout = widgets.Layout(width='500px')            )                self.w_next_video_button = widgets.Button(            description='Next video',            button_style='info', # 'success', 'info', 'warning', 'danger' or ''            tooltip='Go to the next video',            icon='right-arrow'        )                self.w_video_row = widgets.HBox([self.w_current_video, self.w_next_video_button])                self.w_current_video.observe(self._on_video_change, names='value')        self.w_next_video_button.on_click(self._on_next_video_click)                        def _on_next_video_click(self, _):        while True:            self.video_ind += 1            current_video = self.video_ids[self.video_ind]            if next(os.scandir(self.frames_path/current_video/'student_faces'), None) is not None:                break        self.w_current_video.value = current_video                    def _on_video_change(self, change):        self.video_id = change['new']        self.frame_nums_all = sorted(int(f.replace('.jpg',''))                                      for f in os.listdir(self.frames_path/self.video_id/'student_src'))        start, stop = min(self.frame_nums_all), max(self.frame_nums_all)        self.w_range_slider.min = start        self.w_range_slider.max = stop        step = self.frame_nums_all[1] - self.frame_nums_all[0] if len(self.frame_nums_all)>1 else 1        self.w_range_start.step = step        self.w_range_stop.step = step        # change to slider value will cause frames to be redrawn        self.w_range_slider.value = [start, stop]               # reset faces        self.faces_df = None        self._reset_faces_row()        self.w_video_stats.value = f'Video {self.video_id}  no annotations yet.'                def _close_video_widgets_row(self):        self.w_current_video.close()        self.w_next_video_button.close()        self.w_video_row.close()        ### Video frames box        def _make_frames_row(self):        frame_boxes = []        self.w_back_buttons = {}        self.w_forward_buttons = {}        for i in range(self.num_frames):            back_button = widgets.Button(description='<',layout=widgets.Layout(width='20px',height='20px'))            self.w_back_buttons[back_button] = i            back_button.on_click(self._on_frames_back_click)            label = widgets.Label(str(i+1), layout = widgets.Layout(width=f'{self.frame_width-50}px'))            forward_button = widgets.Button(description='>',layout=widgets.Layout(width='20px',height='20px'))            self.w_forward_buttons[forward_button] = i            forward_button.on_click(self._on_frames_forward_click)            image = widgets.Image(width=f'{self.frame_width}px')            frame_boxes.append(widgets.VBox([widgets.HBox([back_button, label, forward_button]),                                              image]))                    self.w_frames_row = widgets.GridBox(frame_boxes,                                             layout = widgets.Layout(width='100%',                                                                     display='flex',                                                                     flex_flow='row wrap'))            def _on_frames_back_click(self, button):        frame_ind = self.w_back_buttons[button]        frame = int(self.w_frames_row.children[frame_ind].children[0].children[1].value)        start, stop = self.w_range_slider.value        self.w_range_slider.value = [frame, stop]            def _on_frames_forward_click(self, button):        frame_ind = self.w_forward_buttons[button]        frame = int(self.w_frames_row.children[frame_ind].children[0].children[1].value)        start, stop = self.w_range_slider.value        self.w_range_slider.value = [start, frame]            def _close_frames_row(self):        for box in self.w_frames_row.children:            label_row, image = box.children            back, label, forward = label_row.children            image.close()            back.close()            label.close()            forward.close()            box.close()        self.w_frames_row.close()                    ### Frames range slider                        def _make_range_slider(self):        self.w_range_start = widgets.BoundedIntText(                                        value=0,                                        min=0,                                        max=30000,                                        step=1,                                        description='Frames from:',                                        disabled=False,                                        layout = widgets.Layout(width='240px')                                    )        self.w_range_stop = widgets.BoundedIntText(                                        value=30000,                                        min=0,                                        max=30000,                                        step=1,                                        description='to:',                                        disabled=False,                                        layout = widgets.Layout(width='240px')                                    )        self.w_range_slider = widgets.IntRangeSlider(            value=[0, 30000],            min=0,            max=30000,            step=1,            description='',            disabled=False,            continuous_update=False,            orientation='horizontal',            readout=True,            readout_format='d',            layout=widgets.Layout(width='500px')        )        self.w_range_flip = widgets.Button(description='Flip range',            button_style='', # 'success', 'info', 'warning', 'danger' or ''            tooltip='Invert frames selection',            layout = widgets.Layout(width=f'{self.frame_width}px'),            icon='retweet'                                          )                self.w_range_slider.observe(self._on_slider_change, names='value')        self.w_range_start.observe(self._on_range_start_change, names='value')        self.w_range_stop.observe(self._on_range_stop_change, names='value')        self.w_range_flip.on_click(self._on_range_flip)        self.w_slider_row = widgets.HBox([self.w_range_start,                                          self.w_range_slider,                                          self.w_range_stop,                                          self.w_range_flip])        def _close_range_slider(self):        self.w_range_start.close()        self.w_range_stop.close()        self.w_range_slider.close()        self.w_range_flip.close()        self.w_slider_row.close()            def _on_range_flip(self, _):        start, stop = self.w_range_slider.value        left, right = self.w_range_slider.min, self.w_range_slider.max        if start==left and right==stop:            pass        elif start - left > right - stop:            self.w_range_slider.value=[left, start]        else:            self.w_range_slider.value=[stop, right]                                                   def _on_range_start_change(self, change):        new_start = change['new']        start, stop = self.w_range_slider.value        self.w_range_slider.value = [new_start, stop]                    def _on_range_stop_change(self, change):        new_stop = change['new']        start, stop = self.w_range_slider.value        self.w_range_slider.value = [start, new_stop]                    def _on_slider_change(self, change):        start, stop = change['new']        # update text controls        self.w_range_start.max = stop        self.w_range_start.value = start        self.w_range_stop.min = start        self.w_range_stop.max = self.w_range_slider.max        self.w_range_stop.value = stop        # show frames that fit current selection        frame_nums = [i for i in self.frame_nums_all if i>=start and i<=stop]        N = len(frame_nums)        n = self.num_frames        inds = [int(((N-1)/(n-1))*i) for i in range(n)]        # load new images into image widgets        for ind, box in zip(inds, self.w_frames_row.children):            frame_num = frame_nums[ind]            filename = self.frames_path/self.video_id/'student_src'/f'{frame_num}.jpg'            with open(filename, "rb") as image:                f = image.read()            label, image = box.children            label.children[1].value = str(frame_num)            image.value = f            ### Buttons row        def _make_buttons_row(self):        labels = list(self.labels)        if self.predict_fn is not None:            labels.append('model')        self.w_default_label = widgets.ToggleButtons(options=labels,                                                      value=self.labels[0],                                                      description='Default label:')                self.w_next_batch_button = widgets.Button(description='New batch',            button_style='info', # 'success', 'info', 'warning', 'danger' or ''            tooltip='Show next batch of faces from current frame range',            icon='arrow-right'        )        self.w_save_button = widgets.Button(description='Save labels',            button_style='success', # 'success', 'info', 'warning', 'danger' or ''            tooltip='Save current labels',            icon='check'        )        self.w_buttons_row = widgets.HBox([self.w_default_label, self.w_next_batch_button, self.w_save_button])        self.w_next_batch_button.on_click(self._on_next_batch_click)        self.w_save_button.on_click(self._on_save_labels_click)                def _close_buttons_row(self):        self.w_default_label.close()        self.w_next_batch_button.close()        self.w_save_button.close()        self.w_buttons_row.close()                def _on_next_batch_click(self, _):        if self.faces_df is None:             self._create_faces_df()        # select a sample from faces_df        start, stop = self.w_range_slider.value        subdf = self.faces_df.loc[lambda df: df.frame_num.ge(start)&                                             df.frame_num.le(stop)&                                             df.label.eq('')]        num_faces = min(len(subdf), self.num_faces)                if num_faces == 0:            self.faces_batch = []            self.w_faces_label.value = 'No more unlabeled images in this frames range'            self.w_faces_label.layout.visibility = 'visible'            for box in self.w_faces_row.children:                box.layout.visibility = 'hidden'        else:            self.w_faces_label.layout.visibility = 'hidden'            self.faces_batch = subdf.sample(num_faces).index            # if we have a model then we use it to sort images            if self.predict_fn is not None:                probs, labels = self._predict()                # sort faces according to probability                ind = sorted(range(len(probs)), key=probs.__getitem__)                self.faces_batch = [self.faces_batch[i] for i in ind]                labels = [labels[i] for i in ind]            # create labels for each face            if self.w_default_label.value != 'model':                labels = [self.w_default_label.value]*len(self.faces_batch)            # update faces UI            for facefile, label, box in zip(self.faces_batch, labels, self.w_faces_row.children):                image, buttons = box.children                with open(self.frames_path/facefile, "rb") as im:                    image.value = im.read()                buttons.value = label                box.layout.visibility = 'visible'            if len(self.faces_batch) < len(self.w_faces_row.children):                for box in self.w_faces_row.children[len(self.faces_batch):]:                    box.layout.visibility = 'hidden'        def _predict(self):        probs = []        labels = []        for facefile in self.faces_batch:            prob, label = self.predict_fn(self.frames_path/facefile)            probs.append(prob)            labels.append(label)        self.faces_df.loc[self.faces_batch, 'prob'] = probs        return probs, labels                    def _on_save_labels_click(self, _):        self.w_save_button.description='Saving...'                        with open(self.annotations_path, 'a') as f:            for file, box in zip(self.faces_batch, self.w_faces_row.children):                label = box.children[1].value                self.faces_df.at[file,'label'] = label                print(file, label, self.myname, str(datetime.datetime.now()),sep=',', file=f)                # update current video statistics        stats = self.faces_df.loc[self.faces_df.label.ne(''),'label'].value_counts().sort_index()        stats_str = ', '.join(f'{label}: {count}' for label, count in stats.items())        self.w_video_stats.value = f'Video {self.video_id}  {stats_str}.'                self.w_save_button.description = 'Save labels'        # ask for next batch        self._on_next_batch_click(0)            ### Faces row        def _make_faces_row(self):        face_boxes = []        for i in range(self.num_faces):            image = widgets.Image(width=f'{self.face_width}px')            n = len(self.labels)            toggle_buttons_width = int(((self.face_width-5*(n-1))/n))            toggle_buttons = widgets.ToggleButtons(options=self.labels,                                                    value=self.w_default_label.value,                                                    style=widgets.ToggleButtonsStyle(button_width=f'{toggle_buttons_width}px'))            face_boxes.append(widgets.VBox([image, toggle_buttons]))                    self.w_faces_row = widgets.GridBox(face_boxes,                                            layout = widgets.Layout(width='100%',                                                                    display='flex',                                                                    flex_flow='row wrap'))        self.w_faces_label = widgets.Label()        self._reset_faces_row()            def _close_faces_row(self):        for box in self.w_faces_row.children:            image, buttons = box.children            for w in [image, buttons, box]:                w.close()        self.w_faces_row.close()        self.w_faces_label.close()            def _reset_faces_row(self):        for box in self.w_faces_row.children:            box.layout.visibility = 'hidden'        self.w_faces_label.layout.visibility = 'visible'        self.w_faces_label.value = 'Press "New batch" button to see a new batch of faces'        self.faces_batch = []            ### Video statistics row        def _make_video_stats_row(self):        self.w_video_stats = widgets.Label('No video currently selected')        def _close_video_stats_row(self):        self.w_video_stats.close()                def _create_faces_df(self):        folder = Path(self.video_id,'student_faces')        df = pd.DataFrame({'file':[folder/f for f in os.listdir(self.frames_path/folder)]})        df['frame_num'] = df.file.apply(lambda x: int(x.stem.split('_')[0]))        df['label'] = '' #TODO maybe existing annotations?        df['prob'] = np.nan        df = df.sort_values(by='frame_num').set_index('file')        self.faces_df = df                    def close(self):        self._close_video_widgets_row()        self._close_frames_row()        self._close_range_slider()        self._close_buttons_row()        self._close_faces_row()        self._close_video_stats_row()
Подробнее..

Пора избавляться от мышки или Hand Pose Estimation на базе LiDAR за 30 минут

12.01.2021 14:15:46 | Автор: admin
image

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

Предлагаю начать с посмотра коротенького видео, на котором видно, как можно за пару вечеров накидать простейшее управления курсором мышки на основе Object Detection, Hand Pose Estimation и камеры Intel Realsense L515. Конечно, оно далеко от идеала, но кажется, что осталось совсем немного подтянуть технологии и появятся принципиально новые способы управлять устройствами.



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

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

Основная идея это двигать мышь, передвигая не всю руку, а только указательный палец, что позволит не отрывая рук от клавиатуры, бегать по меню, нажимать кнопки и в совокупности с горячими клавишами превратиться в настоящего клавиатурного ninja! А что будет если добавить жесты пролистывания или скоролла? Думаю будет бомба! Но до этого момента нам еще придётся подождать пару-тройку лет)

Начнём собирать наш протитип манипулятора будущего



Что понадобится:
1. Камера с LiDAR Intel Realsense L515.
2. Умение программировать на python
3. Совсем чуть-чуть вспомнить школьную математику
4. Крепление для камеры на монитор ака штатив

Крепим камеру на шатив с алиэкспресс, он оказался очень удобный, лёгкий и дешевый =)
image
image

Разбираемся, как и на чём делать прототип



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

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

Во-первых, там все уже есть из коробки установка и запуск потребует минут 30, с учётом всех пререквизитов.

Во-вторых, благодаря мощной команде разработчиков, они не только бирут State Of Art в Hand Pose Estimation, но и дают лёгкое в понимание API.

В-третьих, сеть готова работать на CPU, так что порог входа минимален.

Наверное, вы спросите почему я не зашёл вот сюда и не воспользовался репозиториями победителей этого соревнования. На самом деле я довольно подробно изучил их решение, они вполне prod-ready, никаких стаков миллионов сеток и т.д. Но самая большая проблема, как мне кажется это то, что они работают с изображением глубины. Так как это академики, они не гнушались все данные конвертировать через матлаб, кроме того, разрешение, в котором были отсняты глубины, мне показались маленьким. Это могло сильно сказаться на результате. Поэтому, кажется, что проще всего получить ключевые точки на RGB картинке и по XY координатам взять значение по оси Z в Depth Frame. Сейчас не стоит задача сильно что-то оптимизировать, так что сделаем так, как это быстрее с точки зрения разработки.

Вспоминаем школьную математику



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

На картинке схематично изображена плоскость монитора и линия, ее пересекающая. Посмотреть на математику можно тут

По двум точкам получаем параметрическое представление прямой в пространстве.

image

Не буду сильно заострять внимание на школьной программе математики.

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


Пожалуй, это самая сложная часть этой работы. Как оказалось, софт для камеры под Ubuntu очень сырой, librealsense просто завален все возможными багами, глюками и танцами с бубном.
До сих пор мне не удалось победить странное поведение камеры, иногда она не подгружает параметры при запуске.
Камера работает только один раз после рестарта компьютера!!! Но есть решение: перед каждым запуском делать програмно hard reset камеры, резет usb, и, может быть, всё будет хорошо. Кстати для Windows 10 там все нормально. Странно разработчики себе представляют роботов на базе винды =)

Чтобы под Ubuntu 20 у вас завелся realsense, сделайте так:
$ sudo apt-get install libusb-1.0-0-devThen rerun cmake and make install. Here is a complete recipe that worked for me:$ sudo apt-get install libusb-1.0-0-dev$ git clone https://github.com/IntelRealSense/librealsense.git$ cd librealsense/$ mkdir build && cd build$ cmake ../ -DFORCE_RSUSB_BACKEND=true -DBUILD_PYTHON_BINDINGS=true -DCMAKE_BUILD_TYPE=release -DBUILD_EXAMPLES=true -DBUILD_GRAPHICAL_EXAMPLES=true$ sudo make uninstall && make clean && make && sudo make install


Собрав из сорцов, оно будет более или менее стабильно.
Месяц общения с техподдержкой выявил, что надо ставить Ubuntu 16 или страдать. Я выбрал сами понимаете что.

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



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


Это, как вы поняли, не тряска моих рук, на праздниках я выпил всего одну кружку New England DIPA =) Все дело в постоянных флуктациях ключевых точек и Z-координаты на основе значений, получаемых от лидара.
Посмотим вблизи:


В нашей SOTA от mediapipe флуктаций конечно меньше, но они тоже есть. Как выяснилось, они борются с этим путём прокидывания из прошлого кадра heatmap в текущий кадр и обучают сеть это даёт больше стабильности, но не 100%.

Еще, как мне кажется, играет роль специфика разметки. Врядли можно сделать на таком колличестве кадров одинаковую разметку, не говоря уже о том, что разрешение кадра везде разное и не очень большое. Также мы не видим мерцание света, которое, вероятнеё всего, не постоянно из-за разного периода работы и величины экспозиции камеры. И еще сеть возращает бутерброд из heatmap, равный количеству ключевых точек на экране, размер этого тензора BxNx96x96, где N это кол-во ключевых точек, и, конечно же, после threshold и resize к оригинальному размеру кадра, мы получаем то что получаем =(

Прмер визуализации heatmap:
image

Обзор кода


Весь код находится в этом репозитории и он очень короткий. Давайте разберём основной файл, а остальное вы посмотрите сами.
import cv2import mediapipe as mpimport numpy as npimport pyautoguiimport pyrealsense2.pyrealsense2 as rsfrom google.protobuf.json_format import MessageToDictfrom mediapipe.python.solutions.drawing_utils import _normalized_to_pixel_coordinatesfrom pynput import keyboardfrom utils.common import get_filtered_values, draw_cam_out, get_right_indexfrom utils.hard_reset import hardware_resetfrom utils.set_options import set_short_rangepyautogui.FAILSAFE = Falsemp_drawing = mp.solutions.drawing_utilsmp_hands = mp.solutions.hands# инициализируем mediapipe для Hand Pose Estimationhands = mp_hands.Hands(max_num_hands=2, min_detection_confidence=0.9) def on_press(key):    if key == keyboard.Key.ctrl:        pyautogui.leftClick()    if key == keyboard.Key.alt:        pyautogui.rightClick()def get_color_depth(pipeline, align, colorizer):    frames = pipeline.wait_for_frames(timeout_ms=15000) # ождидаем фрейм от камеры    aligned_frames = align.process(frames)     depth_frame = aligned_frames.get_depth_frame()    color_frame = aligned_frames.get_color_frame()    if not depth_frame or not color_frame:        return None, None, None    depth_image = np.asanyarray(depth_frame.get_data())    depth_color_image = np.asanyarray(colorizer.colorize(depth_frame).get_data())    color_image = np.asanyarray(color_frame.get_data())    depth_color_image = cv2.cvtColor(cv2.flip(cv2.flip(depth_color_image, 1), 0), cv2.COLOR_BGR2RGB)    color_image = cv2.cvtColor(cv2.flip(cv2.flip(color_image, 1), 0), cv2.COLOR_BGR2RGB)    depth_image = np.flipud(np.fliplr(depth_image))    depth_color_image = cv2.resize(depth_color_image, (1280 * 2, 720 * 2))    color_image = cv2.resize(color_image, (1280 * 2, 720 * 2))    depth_image = cv2.resize(depth_image, (1280 * 2, 720 * 2))    return color_image, depth_color_image, depth_imagedef get_right_hand_coords(color_image, depth_color_image):    color_image.flags.writeable = False    results = hands.process(color_image)    color_image.flags.writeable = True    color_image = cv2.cvtColor(color_image, cv2.COLOR_RGB2BGR)    handedness_dict = []    idx_to_coordinates = {}    xy0, xy1 = None, None    if results.multi_hand_landmarks:        for idx, hand_handedness in enumerate(results.multi_handedness):            handedness_dict.append(MessageToDict(hand_handedness))        right_hand_index = get_right_index(handedness_dict)        if right_hand_index != -1:            for i, landmark_list in enumerate(results.multi_hand_landmarks):                if i == right_hand_index:                    image_rows, image_cols, _ = color_image.shape                    for idx, landmark in enumerate(landmark_list.landmark):                        landmark_px = _normalized_to_pixel_coordinates(landmark.x, landmark.y,                                                                       image_cols, image_rows)                        if landmark_px:                            idx_to_coordinates[idx] = landmark_px            for i, landmark_px in enumerate(idx_to_coordinates.values()):                if i == 5:                    xy0 = landmark_px                if i == 7:                    xy1 = landmark_px                    break    return color_image, depth_color_image, xy0, xy1, idx_to_coordinatesdef start():    pipeline = rs.pipeline() # инициализируем librealsense    config = rs.config()     print("Start load conf")    config.enable_stream(rs.stream.depth, 1024, 768, rs.format.z16, 30)    config.enable_stream(rs.stream.color, 1280, 720, rs.format.bgr8, 30)    profile = pipeline.start(config)     depth_sensor = profile.get_device().first_depth_sensor()    set_short_range(depth_sensor) # загружаем параметры для работы на маленьком расстоянии    colorizer = rs.colorizer()    print("Conf loaded")    align_to = rs.stream.color    align = rs.align(align_to) # совокупляем карту глубины и цветную картинку    try:        while True:            color_image, depth_color_image, depth_image = get_color_depth(pipeline, align, colorizer)            if color_image is None and color_image is None and color_image is None:                continue            color_image, depth_color_image, xy0, xy1, idx_to_coordinates = get_right_hand_coords(color_image,                                                                                                 depth_color_image)            if xy0 is not None or xy1 is not None:                z_val_f, z_val_s, m_xy, c_xy, xy0_f, xy1_f, x, y, z = get_filtered_values(depth_image, xy0, xy1)                pyautogui.moveTo(int(x), int(3500 - z))  # 3500 хард код специфичый для моего монитора                if draw_cam_out(color_image, depth_color_image, xy0_f, xy1_f, c_xy, m_xy):                    break    finally:        hands.close()        pipeline.stop()hardware_reset() # делаем ребут камеры и ожидаем её появленияlistener = keyboard.Listener(on_press=on_press) # устанавливаем слушатель нажатия кнопок клавиатурыlistener.start()start() # запуск программы


Я не стал использовать классы или потоки, потому что для такого простого случая достаточно всё выполнять в основном потоке в бесконечном цикле while.

В самом начале происходит инициализация mediapipe, камеры, загрузка настроек камеры для работы short range и вспомогательных переменных. Следом идёт магия под названием alight depth to color эта функция ставит в соответсвие каждой точки из RGB картинки, точку на Depth Frame, то есть даёт нам возможность получать по координатам XY, значение Z.

Понято что необходимо произвести калибровку на вашем мониторе. Я специально не стал вытаскивать отдельно эти параметры, что бы читатель решивший запустить код сделал это сам, за одно резберётся в коде =)

Далее мы берём из всего предсказания только точки под номером 5 и 7 правой руки.
image

Осталось дело за малым полученные координаты фильтруем с помощью скользящего среднего. Можно было конечно применить более серьзные алгоритмы фильтрации, но взглянув на их визуализацию и подёргав разные рычажки, стало понятно, что для демо вполне хватит и скользящего среднего с глубиной 5 фреймов, хочу заметить что для XY вполне хватало и 2-3-х, но вот с Z дела обстоят хуже.
deque_l = 5x0_d = collections.deque(deque_l * [0.], deque_l)y0_d = collections.deque(deque_l * [0.], deque_l)x1_d = collections.deque(deque_l * [0.], deque_l)y1_d = collections.deque(deque_l * [0.], deque_l)z_val_f_d = collections.deque(deque_l * [0.], deque_l)z_val_s_d = collections.deque(deque_l * [0.], deque_l)m_xy_d = collections.deque(deque_l * [0.], deque_l)c_xy_d = collections.deque(deque_l * [0.], deque_l)x_d = collections.deque(deque_l * [0.], deque_l)y_d = collections.deque(deque_l * [0.], deque_l)z_d = collections.deque(deque_l * [0.], deque_l)def get_filtered_values(depth_image, xy0, xy1):    global x0_d, y0_d, x1_d, y1_d, m_xy_d, c_xy_d, z_val_f_d, z_val_s_d, x_d, y_d, z_d    x0_d.append(float(xy0[1]))    x0_f = round(mean(x0_d))    y0_d.append(float(xy0[0]))    y0_f = round(mean(y0_d))    x1_d.append(float(xy1[1]))    x1_f = round(mean(x1_d))    y1_d.append(float(xy1[0]))    y1_f = round(mean(y1_d))    z_val_f = get_area_mean_z_val(depth_image, x0_f, y0_f)    z_val_f_d.append(float(z_val_f))    z_val_f = mean(z_val_f_d)    z_val_s = get_area_mean_z_val(depth_image, x1_f, y1_f)    z_val_s_d.append(float(z_val_s))    z_val_s = mean(z_val_s_d)    points = [(y0_f, x0_f), (y1_f, x1_f)]    x_coords, y_coords = zip(*points)    A = np.vstack([x_coords, np.ones(len(x_coords))]).T    m, c = lstsq(A, y_coords)[0]    m_xy_d.append(float(m))    m_xy = mean(m_xy_d)    c_xy_d.append(float(c))    c_xy = mean(c_xy_d)    a0, a1, a2, a3 = equation_plane()    x, y, z = line_plane_intersection(y0_f, x0_f, z_val_s, y1_f, x1_f, z_val_f, a0, a1, a2, a3)    x_d.append(float(x))    x = round(mean(x_d))    y_d.append(float(y))    y = round(mean(y_d))    z_d.append(float(z))    z = round(mean(z_d))    return z_val_f, z_val_s, m_xy, c_xy, (y0_f, x0_f), (y1_f, x1_f), x, y, z


Создаем deque c длинной 5 фреймов и усредняем все подряд =) Дополнительно расчитываем y = mx+c, Ax+By+Cz+d=0, уравнение для прямой луч на RGB картинке и уравнение плоскости монитора, оно у нас получается y=0.

Итоги



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

Благодарности



Спасибо сообществу ods.ai, без него невозможно развиваться!
Подробнее..

DimDrone20 как я сделал летающую платформу для computer vision исследований

21.03.2021 16:08:22 | Автор: admin

С 2017 года я делаю разный computer vision: начиная от понятных детекций и классификаций, заканчивая чтением по губам.

И вот после череды случайностей, я оказываюсь единственным Computer Vision инженером в стартапе, который делает беспилотные вертолеты. По это причине я решаю ворваться со своей computer vision ноги и сделать какой-нибудь pet project связанный с дронами.

План такой: соберу просто квадрик с камерой, автопилотом и какой-нибудь edge железкой (нейронки и прочий CV гонять), получится плюс-минус универсальная летающая платформа. Например, как эта, но более гибкая и дещевая. И интересный применений масса: от детекции человека и следования за ним, до управления квадрокоптером с помощью Reinforcement Learning-a.

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

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

А во второй части (она уже готовится!) расскажу, про то, как заставить эту платформу удерживать позицию и летать простые миссии без использования GPS, с помощью visual SLAM.


Итак, рама

Раму квадрокоптера решил выбрать 450ого размера (расстояние между диагональными моторами равно 450мм).

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

Берите только карбоновые рамы. На мой взгляд главный их минус: проводят ток. И какая-нибудь платка лежащая оголенными контактами на раме может наделать делов. А в остальном, получите наилучший tradeoff по отношению легкость\прочность.

Итак, моторы

По-хорошему, чтобы решать какие моторы вам нужны желательно понимать финальную взлетную массу дрона. Но делать я так, конечно же, не стал \_()_/

На сайте где вы покупаете раму (в моем случае AliExpress) на странице товара скорее всего будут рекомендованные производителем компоненты. В моем случае это были моторы на 700KV ( число сильно кореллирующее со скоростью вращения винтов; чем больше ваш дрон, тем KV должно быть меньше). Здесь подробнее по KV.

Итак, регуляторы оборотов

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

Выбираем их очень просто: смотрим, сколько тока моторы потребляют в пиках, добавляем 10А и на такое количество тока берем регуляторы. Я добавлял 10А, как страховку от нерадивого производителя регуляторов, если берете у надежного продавца, думаю, можно и не добавлять и соответсвенно не переплачивать.

Еще обращайте внимание по какому протоколу общается ESC. Ищите сразу DShotXXX (чем больше число XXX, тем лучше)

Также неплохо было бы убедиться, что software автопилот, который вы будете использовать поддерживал протокол ESC (если это ArduPilot или PX4, скорее всего с этим проблем не будет)

Итак, лопасти

У лопастей есть два численных параметра на которые надо смотреть при покупке (обычно они пишутся в названиях как одно четырехзначное число XXYY): длина и шаг (обе в дюймах). С длиной все понятно. Что же такое шаг? Это расстояние которое проходит винт за один оборот по прямой оси. Вот изображение хорошо это иллюстрирующее.

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

На самом деле это я сейчас такой умный, а в начале проекта я просто взял винты рекомендованные производителем рамы. В моем случае это винты 1145.

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

И да (сейчас будет аксиома): 1) только карбоновые винты (самые крепкие из доступных обычным хобийщикам) и 2) не ограничивайте себя одной парой.

Итак, батареи

Тут две главные цифры это вольтаж (или количество "банок") и емкость.

Алгоритм такой: смотрим какой вольтаж пишет производитель выбранных вами моторов и берем такой же. По емкости можно разгуляться, вопрос лишь в том, сколько вам позволяет ваш бюджет. Я выбрал 4S Li-Po батарею и решил, что по емкости она должна быть около 5000 mAh. Так и выбирал. Пока у меня две такие батареи, которые позволяют висеть (самый энергозатратный режим для квадрокоптера) в воздухе порядка 20 минут.

Итак, приёмник и передатчик

В любом случае дроном нужно будет управлять в ручном режиме для этого нам нужен передатчик (аппаратура\пульт) и приемник. Здесь конкретных правил выбора я назвать не могу и не хочу т.к. новичок и так перегружен информацией. Кому хочется прямо в дебри различий между разными аппаратурами, тот найдет способ. А для себя (и всем советую) я взял приемник и передатчик от FrSky (Taranis X10 lite и FrSky R-XSR). Они далеко не дешевые, но почти самое оптимальное, что можно взять начинающему по соотношению цена\качество.

"Почти" потому что в последнее время слышал много хороших отзывов про RadioMaster. Подозреваю, что они тоже составляют неплохую конкуренцию FrSky на этом рынке, но нужно проверять.

Итак, автопилот

Автопилотом называют как software stack для управления дроном (state estimation, драйвера для различной подключаемой периферии, ОС для микроконтроллера), так и hardware (железяка где собственно software stack работает).

Hardware автопилот это микроконтроллер, в который уже встроены самые вжные датчики для управления (зачастую это IMU, барометр, магнетометр).

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

Для этого используется тьма-тьмущая сложных и не очень алгоритмов. Т.к. моей целью не было самому писать все эти алгоритмы и мне бы хотелось использовать что-то готовое. Насколько мне известно в open-source есть только два автопилота, которые позволяют использовать визуальную одометрию (что для меня было важно): ArduPilot и PX4.

Я методом тыка выбрал ardupilot (все еще не уверен, что сделал правильный выбор) и по документации с их сайта выбрал один из самых новых на начало 2020-ого года hardware автопилотов - pixhawk 4 mini.

Итак, companion computer

Это еще одна числодробилка для нашего дрона, но уже гораздо мощнее. Обычно на companion computer возлагают такие задачи, как кодирование и стриминг видео с курсовой камеры, какой-нибудь сложный path planning, external navigation, обнаружение препятствий + task specific задачи.

И тут на самом деле есть из чего выбирать: Intel Movidius, Google Coral, Jetson серия. Raspberry серию изначально не раcсматривал т.к. уже в самых ближайших планах были нейронки, а пускать их на "малине" нужно только при крайней необходимости.

Остановился в итоге на Jetson-e Nano (самый дешевый из тогда доступных) поскольку кроме удобства запуска нейронок (TensorRT), это еще и железка с обычной GPU т.е. можно переписывать какие-то медленные куски кода на CUDA (все ведь умеют переписывать медленные куски кода на CUDA, так, чтобы это не стало еще медленней?), что очень актуально для SLAM алгоритмов.

Итак, камера

У меня под рукой была камера Intel RealSense D435, от которой я использовал только модуль, который выдает RGB картинку. В общем случае я бы посоветовал взять Raspberry Pi камеру c MIPI интерфейсом т.к. подключить и начать пользоваться максимально просто и в интернете много различных 3D моделей кейсов для такой камеры, наверняка найдете что-то подходящее, чтобы поставить на раму.

Стоит, правдв, учесть, что они все с rolling shutter т.е. при плохой балансировке лопастей и наличии вибраций на дроне, картинка с камеры запросто может превратиться в мыло.

Итак, собираем!

напомню вам картинку из заставки поста

Почитав несколько статей и туториалов в интернете, вооружившись паяльников (да, надо будет научиться) мы собираем это все воедино и вуаля!

Я не буду подробно останавливаться на процессе сборки, но вот пару моментов, про которые я советую помнить:

  1. На сайте выбранного автопилота будет гайд а ля Getting Started. Следуйте ему. Там будет и про сборку железа, и про настройку самого АП.

  2. Если это ваш первый опыт сборки, не устанавливайте companion computer и камеру сразу. Сначала убедитесь, что дрон хорошо и предсказуемо летает.

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

  4. Если проверяете вращение моторов в помещении, ВСЕГДА делайте это без лопастей.

  5. Используйте только шестигранные винты для всего, что вы прикручиваете на квадрокоптер (бОльшая площадь соприкосновения отвертки и винта чем у крестообразной или плоской можно сильнее затянуть винт).

  6. Если не используете Loctite (я не использую), проверяйте время от времени несколько случайных винтов на дроне (особенно те, что держат моторы). Из-за сильных вибраций на дроне тяга винтов может ослабиться со временем.

  7. Если у вас всех четырех моторах одинаковая резьба (гайка закручивается в одну сторону), ОБЯЗАТЕЛЬНО проверяйте, насколько сильно затянуты эти гайки перед каждым полетом.

  8. Если есть такая возможность (на борту есть GPS), первые полетные тесты делайте в режиме удержания позиции.

  9. Самый простой способ понимать количество заряда на батарее вот такой дешевый вольтметр. Суть вот в чем: чем меньше заряда в батарее осталось, тем меньше на ней напряжение. На одной заряженной LiPo ячейке напряжение 4.2v. Такой вольтметр начнет очень громко пищать, как только напряжение хотя бы одной из "банок" упадет ниже, чем вы на нем выставили (я обычно выставляю около 3.5-3.6v)

Итак, что в итоге?

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

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

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

Подробнее..

Scaled YOLO v4 самая лучшая нейронная сеть для обнаружения объектов на датасете MS COCO

07.12.2020 20:14:08 | Автор: admin

Scaled YOLO v4 является самой точной нейронной сетью (55.8% AP) на датасете Microsoft COCO среди всех опубликованных нейронных сетей на данный момент. А также является лучшей с точки зрения соотношения скорости к точности во всем диапазоне точности и скорости от 15 FPS до 1774 FPS. На данный момент это Top1 нейронная сеть для обнаружения объектов.

Scaled YOLO v4 обгоняет по точности нейронные сети:

  • Google EfficientDet D7x / DetectoRS or SpineNet-190 (self-trained on extra-data)
  • Amazon Cascade-RCNN ResNest200
  • Microsoft RepPoints v2
  • Facebook RetinaNet SpineNet-190




Мы показываем, что подходы YOLO и Cross-Stage-Partial (CSP) Network являются лучшими с точки зрения, как абсолютной точности, так и соотношения точности к скорости.

График Точности (вертикальная ось) и Задержки (горизонтальная ось) на GPU Tesla V100 (Volta) при batch=1 без использования TensorRT:



Даже при меньшем разрешении сети Scaled-YOLOv4-P6 (1280x1280) 30 FPS чуть точнее и в 3.7х раза быстрее, чем EfficientDetD7 (1536x1536) 8.2 FPS. Т.е. YOLOv4 эффективнее использует разрешение сети.

Scaled YOLO v4 лежит на кривой оптимальности по Парето какую бы другую нейронную сеть вы не взяли, всегда есть такая сеть YOLOv4, которая или точнее при той же скорости, или быстрее при той же точности, т.е. YOLOv4 является лучшей с точки зрения отношения скорости и точности.

Scaled YOLOv4 точнее и быстрее, чем нейронные сети:
  • Google EfficientDet D0-D7x
  • Google SpineNet S49s S143
  • Baidu Paddle-Paddle PP YOLO
  • И многие другие


Scaled YOLO v4 это серия нейронных сетей, созданная из улучшенной и отмасштабированной сети YOLOv4. Наша нейронная сеть была обучена с нуля без использования предобученных весов (Imagenet или любых других).

Рейтинг точности опубликованных нейронных сетей: paperswithcode.com/sota/object-detection-on-coco


Скорость нейронной сети YOLOv4-tiny достигает 1774 FPS на игровой видеокарте GPU RTX 2080Ti при использовании TensorRT+tkDNN (batch=4, FP16): github.com/ceccocats/tkDNN

YOLOv4-tiny может исполняться в real-time со скоростью 39 FPS / 25ms Latency на JetsonNano (416x416, fp16, batch=1) tkDNN/TensorRT:


Scaled YOLOv4 намного эффективнее использует ресурсы параллельных вычислителей, таких как GPU и NPU. Например, GPU V100 (Volta) имеет производительность: 14 TFLops 112 TFLops-TC images.nvidia.com/content/technologies/volta/pdf/tesla-volta-v100-datasheet-letter-fnl-web.pdf

Если мы будем тестировать обе модели на GPU V100 с batch=1, с параметрами --hparams=mixed_precision=true и без --tensorrt=FP32, то:

  • YOLOv4-CSP (640x640) 47.5% AP 70 FPS 120 BFlops (60 FMA)
    Исходя из BFlops, должно быть 933 FPS = (112 000 / 120), но в действительности мы получаем 70 FPS, т.е. используется 7.5% GPU = (70 / 933)
  • EfficientDetD3 (896x896) 47.5% AP 36 FPS 50 BFlops (25 FMA)
    Исходя из BFlops, должно быть 2240 FPS = (112 000 / 50), но в действительности мы получаем 36 FPS, т.е. используется 1.6% GPU = (36 / 2240)


Т.е. эффективность вычислительных операций на устройствах с массивными параллельными вычислениями типа GPU, используемых в YOLOv4-CSP (7.5 / 1.6) = в 4.7x раза лучше, чем эффективность операций, используемых в EfficientDetD3.

Обычно нейронные сети запускаются на CPU только в исследовательских задачах для более легкой отладки, а характеристика BFlops на данный момент имеет только академический интерес. В реальных задачах важны реальные скорость и точность, а не характеристики на бумаге. Реальная скорость YOLOv4-P6 в 3.7х раза выше, чем EfficientDetD7 на GPU V100. Поэтому почти всегда используются устройства с массовым параллелизмом GPU / NPU / TPU/ DSP с гораздо более оптимальными: скоростью, ценой и тепловыделением:

  • Embedded GPU (Jetson Nano/Nx)
  • Mobile-GPU/NPU/DSP (Bionic-NPU/Snapdragon-DSP/Mediatek-APU/Kirin-NPU/Exynos-GPU/...)
  • TPU-Edge (Google Coral/Intel Myriad/Mobileye EyeQ5/Tesla-motors TPU 144 TOPS-8bit)
  • Cloud GPU (nVidia A100/V100/TitanV)
  • Cloud NPU (Google-TPU, Huawei Ascend, Intel Habana, Qualcomm AI 100, ...)


Также при использовании нейронных сетей On Web обычно используется GPU через библиотеки WebGL, WebAssembly, WebGPU, for this case the size of the model can matter: github.com/tensorflow/tfjs#about-this-repo

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

  • Текущий лучший размер Литографии процессоров (Semiconductor device fabrication) равен 5 нанометров.
  • Размер кристаллической решетки кремния равен 0.5 нанометров.
  • Атомный радиус кремния равен 0.1 нанометра.

Решение это вычислители с массивным параллелизмом: на одном кристалле или на нескольких кристаллах, соединенных интерпозером. Поэтому крайне важно создавать нейронные сети, которые эффективно используют вычислители с массивным параллелизмом, такие как GPU и NPU.

Улучшения в Scaled YOLOv4 по сравнению с YOLOv4:
  • В Scaled YOLOv4 применяли оптимальные способы масштабирования сети для получения YOLOv4-CSP -> P5 -> P6 -> P7 сетей
  • Улучшенная архитектура сети: оптимизирован Backbone, а также в Neck (PAN) используются Cross-stage-partial (CSP) connections и Mish-активация
  • Во время обучения используется Exponential Moving Average (EMA) это частный случай SWA: pytorch.org/blog/pytorch-1.6-now-includes-stochastic-weight-averaging
  • Для каждого разрешения сети обучается отдельная нейронная сеть (в YOLOv4 обучали только одну нейронную сеть для всех разрешений)
  • Улучшены нормализаторы в [yolo] слоях
  • Изменены активации для Width и Height, что позволяет быстрее обучать сеть
  • Используется параметр [net] letter_box=1 (сохраняет соотношение сторон входного изображения) для сетей с большим разрешением (для всех кроме yolov4-tiny.cfg)


Архитектура нейронной сети Scaled-YOLOv4 (примеры трех сетей: P5, P6, P7):


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


Простейший пример CSP-соединения (слева обычная сеть, справа CSP-сеть):


Пример CSP-соединения в YOLOv4-CSP / P5 / P6 / P7
(слева обычная сеть, справа CSP-сеть использу):


В YOLOv4-tiny используются 2 CSP-соединения (используя Partial Concatenation)


YOLOv4 применяется в различных областях и задачах:

И во многих других задачах.

Имеются реализации на различных фреймворках:


Как скомпилировать и запустить Обнаружение объектов в облаке бесплатно:
  1. colab: colab.research.google.com/drive/12QusaaRj_lUwCGDvQNfICpa7kA7_a2dE
  2. video: www.youtube.com/watch?v=mKAEGSxwOAY


Как скомпилировать и запустить Обучение в облаке бесплатно:
  1. colab: colab.research.google.com/drive/1_GdoqCJWXsChrOiY8sZMr_zbr_fH-0Fg?usp=sharing
  2. video: youtu.be/mmj3nxGT2YQ


Также подход YOLOv4 может использоваться в других задачах, например, при обнаружении 3D объектов:


Подробнее..

Детектирование пользовательских объектов

14.04.2021 08:08:47 | Автор: admin

Код вы можете скачать на странице GitHub (ссылка)

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

Я использую python3.7 и названия всех модулей с версиями хранятся в файле requirements.txt.

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

Для обучения нужно пройти следующие шаги:

  • Соберите по крайней мере 500 изображений, содержащих ваш объект абсолютный минимум будет около 100, в идеале больше 1000 или больше, но чем больше изображений у вас есть, тем более утомительным будет Шаг 2.

  • Разделите эти данные на обучающие/тестовые образцы. Обучающие данные должны составлять около 80%, а тестовые-около 20%.

  • Генерируйте записи TF для этих изображений.

  • Настройте файл. config для выбранной модели (вы можете обучить свою собственную с нуля, но мы будем использовать трансферное обучение).

  • Тренируйте вашу модель.

  • Экспорт графа вывода из новой обученной модели.

  • Обнаружение пользовательских объектов.

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

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

Изображения:

А вот тут начинается самое интересное. Когда все фотографии собраны, пришло время пометить нужные объекты на каждой картинке. LabelImg-это отличный инструмент для маркировки изображений, и на его странице GitHub есть очень четкие инструкции о том, как его установить и использовать.

LabelImg ссылка на GitHub (ссылка)

LabelImg ссылка для скачивания (ссылка)

Загрузите и установите LabelImg, при запуске этого приложения вы должны получить окно GUI. Отсюда выберите пункт Открыть каталог dir и выберите каталог, в который вы сохранили все свои изображения. Теперь вы можете начать аннотировать изображения с помощью кнопки create rectbox. Нарисуйте свою коробку, добавьте имя и нажмите кнопку ОК. Сохраните, нажмите на следующее изображение и повторите! Вы можете нажать клавишу w, чтобы нарисовать поле, и сделать ctrl+s, чтобы сохранить его быстрее. Для меня это заняло в среднем 1 час на 100 изображений, это зависит от количества объектов, которые у вас есть на изображении. Имейте в виду, это займет некоторое время!

LabelImg сохраняет xml-файл, содержащий данные метки для каждого изображения. Эти xml-файлы будут использоваться для создания TFRecords, которые являются одним из входных данных для тренера TensorFlow. После того, как вы пометили и сохранили каждое изображение, для каждого изображения в каталогах \test и \train будет создан один xml-файл.

Как только вы пометите свои изображения, мы разделим их на обучающие и тестовые группы. Чтобы сделать это, просто скопируйте около 20% ваших фотографий и их аннотаций XML-файлов в новый каталог под названием test, а затем скопируйте оставшиеся в новый каталог под названием train.

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

Во-первых, данные image .xml будут использоваться для создания csv-файлов, содержащих все данные для тренировочных и тестовых изображений. Из главной папки, если вы используете ту же структуру файлов выполните в командной строке следующую команду: python xml_to_csv.py.

Это создает файл train_labels.csv и test_labels.csv в папке CSGO_images. Чтобы избежать использования cmd, я создал короткий скрипт .bat под названием xml_to_csv.bat.

Затем откройте generate_tfrecord.py файл в текстовом редакторе. Замените карту меток своей собственной картой меток, где каждому объекту присваивается идентификационный номер. Это же присвоение номера будет использоваться при настройке файла labelmap.pbtxt.

Например, если вы обучаете свой собственный классификатор, вы замените следующий код в generate_tfrecord.py:

# TO-DO замените это на label mapdef class_text_to_int(row_label):    if row_label == 'table':        return 1    else:        return None

Затем сгенерируйте файлы TFRecord, запустив мой созданный файл generate_tfrecord.bat.

Эти строки генерируют файлы train.record и test.record в папке training. Они будут использоваться для обучения нового классификатора обнаружения объектов.

Последнее, что нужно сделать перед обучением это создать карту меток и отредактировать файл конфигурации обучения. Карта меток сообщает тренеру, что представляет собой каждый объект, определяя сопоставление имен классов с идентификационными номерами классов. С помощью текстового редактора создал новый файл и сохранил его как labelmap.pbtxt в папке CSGO_training. В текстовом редакторе создал карту меток. Идентификационные номера карт меток должны быть такими же, как определено в generate_tfrecord.py.

item { id: 1 name: 'table'}

Настройка обучения:

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

Перешел в каталог TensorFlow research\ object_detection\ samples\ configs и скопировал файл faster_rcnn_ inception_v2_ coco.config в каталог CSGO_training. Затем открыл файл с помощью текстового редактора. В этот файл .config необходимо внести несколько изменений, в основном изменив количество классов, примеров и добавив пути к файлам для обучающих данных. Строка 10. изменил num_classes на количество различных объектов, которые должен обнаружить классификатор. Для моего случая было так:

num_classes : 1строка 107. изменил fine_tune_checkpoint на:fine_tune_checkpoint : "faster_rcnn_inception_v2_coco_2018_01_28 / model.ckpt"Строки 122 и 124. в разделе train_input_reader изменил input_path и label_map_path на:input_path: "CSGO_images / train. record"label_map_path: "CSGO_training / labelmap.pbtxt"Линия 128. Изменил num_examples на количество изображений, имеющихся в каталоге CSGO_images\test. У меня есть 113 изображений, поэтому я меняю их на:num_examples: 113(Загружать все не стал)Строки 136 и 138. в разделе eval_input_reader измените input_path и label_map_path на:input_path: "CSGO_images / test. record"label_map_path: "CSGO_training / labelmap.pbtxt"

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

Запустите тренировку:

Осталось запустить обучение, запустив файл train.bat.

На рисунке выше каждый шаг обучения сообщает о потере. Он будет начинаться высоко и становиться все ниже и ниже по мере тренировки. Для моего обучения он начинался примерно с 1.5 . Я рекомендую позволить вашей модели тренироваться до тех пор, пока потеря последовательно не упадет ниже 0,05, что может занять довольно большое количество шагов или около нескольких часов (в зависимости от того, насколько мощен ваш процессор или графический процессор). При использовании другой модели, цифры потерь могут быть разными. Кроме того, это зависит от объектов, которые вы хотите обнаружить.

Теперь нужно экспортировать график вывода и обнаруживать наши собственные пользовательские объекты.

Экспорт Графика Вывода:

Теперь, когда обучение завершено, последний шаг это создание замороженного графика вывода (наша модель обнаружения). В папке graph лежит файл export_inference_graph.py, затем из командной строки выполните следующую команду, где XXXX в model.ckpt-XXXX должен быть заменен на самый высокий номер файла .ckpt в папке обучения:

python export_inference_graph.py --input_type image_tensor --pipeline_config_path CSGO_training/faster_rcnn_inception_v2_coco.config --trained_checkpoint_prefix CSGO_training/model.ckpt-XXXX --output_directory CSGO_inference_graph

Используйте наш обученный пользовательский классификатор обнаружения объектов:

Приведенная выше строка создает файл frozen_inference_graph.pb в папке /coco_v3/ CSGO_inference_ graph. Файл .pb содержит классификатор обнаружения объектов. Переименуйте его в frozen_inference_graph.pb . В папке coco_v3 возьмите файл predict.py Изменил строку 39 на мой замороженный файл графика вывода.

PATH_TO_FROZEN_GRAPH = 'graph/frozen_inference_graph.pb'

Изменена строка 41 в мой файл labelmap.

PATH_TO_LABELS = 'graph/labelmap.pbtxt'

И, наконец, перед запуском скриптов Python вам нужно изменить переменную NUM_CLASSES в скрипте, чтобы она равнялась количеству классов, которые мы хотим обнаружить. Я использую только 1 класс, поэтому я изменил его на 1:
NUM_CLASSES = 1

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

После запуска вы увидите окно и распознанную табличку.

На этом все, спасибо за внимание.

Подробнее..

Обнаружение объектов с помощью YOLOv3 на Tensorflow 2.0

08.05.2021 14:13:54 | Автор: admin
Кадр из аниме "Жрица и медведь"Кадр из аниме "Жрица и медведь"

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

В данной статье мы узнаем о системе YOLO Object Detection и как реализовать подобную систему в Tensorflow 2.0

О YOLO:

Наша унифицированная архитектура чрезвычайно быстра. Базовая модель YOLO обрабатывает изображения в режиме реального времени со скоростью 45 кадров в секунду. Уменьшенная версия сети, Fast YOLO, обрабатывает аж 155 кадра в секунду

You Only Look Once: Unified, Real-Time Object Detection, 2015

Что такое YOLO?

YOLO это новейшая (на момент написания оригинальной статьи) система (сеть) обнаружения объектов. Она была разработана Джозефом Редмоном (Joseph Redmon). Наибольшим преимуществом YOLO над другими архитектурами является скорость. Модели семейства YOLO исключительно быстры и намного превосходят R-CNN (Region-Based Convolutional Neural Network) и другие модели. Это позволяет добиться обнаружения объектов в режиме реального времени.

На момент первой публикации (в 2016 году) по сравнению с другими системами, такими как R-CNN и DPM (Deformable Part Model), YOLO добилась передового значения mAP (mean Average Precision). С другой стороны, YOLO испытывает трудности с точной локализацией объектов. Однако в новой версии были внесены улучшения в скорости и точности системы.

Альтернативы (на момент публикации статьи): Другие архитектуры в основном использовали метод скользящего окна по всему изображению, и классификатор использовался для определенной области изображения (DPM). Также, R-CNN использовал метод предложения регионов (region proposal method). Описываемый метод сначала создает потенциальные bounding boxы. Затем, на области, ограниченные bounding boxами, запускается классификатор и следующее удаление повторяющихся распознаваний, и уточнение границ рамок.

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

Теория

Так как YOLO необходимо только один взгляд на изображение, то метод скользящего окна не подходит в данной ситуации. Вместо этого, изображение будет поделено на сетку с ячейками размером S x S. Каждая ячейка может содержать несколько разных объектов для распознавания.

Во-первых, каждая ячейка отвечает за прогнозирование количества bounding boxов. Также, каждая ячейка прогнозирует доверительное значение (confidence value) для каждой области, ограниченной bounding boxом. Иными словами, это значение определяет вероятность нахождения того или иного объекта в данной области. То есть в случае, если какая-то ячейка сетки не имеет определенного объекта, важно, чтобы доверительное значение для этой области было низким.

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

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

Давайте подробней опишем вывод модели.

В YOLO используются anchor boxes (якорные рамки / фиксированные рамки) для прогнозирования bounding boxов. Идея anchor boxов сводится к предварительному определению двух различных форм. И таким образом, мы можем объединить два предсказания с двумя anchor boxами (в целом, мы могли бы использовать даже большее количество anchor boxов). Эти якоря были рассчитаны с помощью датасета COCO (Common Objects in Context) и кластеризации k-средних (K-means clustering).

У нас есть сетка, где каждая ячейка предсказывает:

  • Для каждого bounding box'а:

    • 4 координаты (tx , ty , tw , th)

    • 1 objectness error (ошибка объектности), которая является показателем уверенности в присутствии того или иного объекта

  • Некоторое количество вероятностей классов

Если же присутствует некоторое смещение от верхнего левого угла на cx , cy то прогнозы будут соответствовать:

b_{x} = \sigma(t_{x}) + c_{x}\\ b_{y} = \sigma(t_{y}) + c_{y}\\ b_{w} = p_{w}e^{t_{w}}\\ b_{h} = p_{h}e^{t_{h}}

где pw (ширина) и ph (высота) соответствуют ширине и высоте bounding box'а. Вместо того, чтобы предугадывать смещение как в прошлой версии YOLOv2, авторы прогнозируют координаты местоположения относительно местоположения ячейки.

Этот вывод является выводом нашей нейронной сети. В общей сложности здесьS x S x [B * (4+1+C)] выводов, где B это количество bounding box'ов, которое может предсказать ячейка на карте объектов, C это количество классов, 4 для bounding box'ов, 1 для objectness prediction (прогнозирование объектности). За один проход мы можем пройти от входного изображения к выходному тензору, который соответствует обнаруженным объектам на картинке. Также стоит отметить, что YOLOv3 прогнозирует bounding box'ы в трех разных масштабах.

Теперь, если мы возьмем вероятность и умножим их на доверительные значения, мы получим все bounding box'ы, взвешенные по вероятности содержания этого объекта.

Простое нахождение порогового значения избавит нас от прогнозов с низким доверительным значением. Для следующего шага важно определить метрику IoU (Intersection over Union / Пересечение над объединением). Эта метрика равняется соотношению площади пересекающихся областей к площади областей объединенных.

После этого все равно могут остаться дубликаты, и чтобы от них избавиться нужно использовать подавление не-максимумов (non-maximum suppression). Подавление не-максимумов заключается в следующем: алгоритм берёт bounding box с наибольшей вероятностью принадлежности к объекту, затем, среди остальных граничащих bounding box'ов с данной области, возьмёт один с наивысшим IoU и подавляет его.

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

Yolov3Yolov3

Мы также рекомендуем прочитать следующие статьи о YOLO:

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

Первым шагом в реализации YOLO это подготовка ноутбука и импортирование необходимых библиотек. Целиком ноутбук с кодом вы можете на Github или Kaggle:

Следуя этой статье, мы сделаем полную сверточную сеть (fully convolutional network / FCN) без обучения. Для того, чтобы применить эту сеть для определения объектов, нам необходимо скачать готовые веса от предварительно обученной модели. Эти веса были получены от обучения YOLOv3 на датасете COCO (Common Objects in Context). Файл с весами можно скачать по ссылке официального сайта.

# Создаем папку для checkpoint'ов с весами.# !mkdir checkpoints# Скачиваем файл с весами для YOLOv3 с официального сайта.# !wget https://pjreddie.com/media/files/yolov3.weights# Импортируем необходимые библиотеки.import cv2import numpy as np import tensorflow as tf from absl import loggingfrom itertools import repeatfrom PIL import Imagefrom tensorflow.keras import Modelfrom tensorflow.keras.layers import Add, Concatenate, Lambdafrom tensorflow.keras.layers import Conv2D, Input, LeakyReLUfrom tensorflow.keras.layers import MaxPool2D, UpSampling2D, ZeroPadding2Dfrom tensorflow.keras.regularizers import l2from tensorflow.keras.losses import binary_crossentropyfrom tensorflow.keras.losses import sparse_categorical_crossentropyyolo_iou_threshold = 0.6 # Intersection Over Union (iou) threshold.yolo_score_threshold = 0.6 # Score threshold.weightyolov3 = 'yolov3.weights' # Путь до файла с весами.size = 416 # Размер изображения. checkpoints = 'checkpoints/yolov3.tf' # Путь до файла с checkpoint'ом.num_classes = 80 # Количество классов в модели.# Список слоев в YOLOv3 Fully Convolutional Network (FCN).YOLO_V3_LAYERS = [    'yolo_darknet',    'yolo_conv_0',    'yolo_output_0',    'yolo_conv_1',    'yolo_output_1',    'yolo_conv_2',    'yolo_output_2']

По причине того, что порядок слоев в Darknet (open source NN framework) и tf.keras разные, то загрузить веса с помощью чистого функционального API будет проблематично. В этом случае, наилучшим решением будет создание подмоделей в keras. TF Checkpoints рекомендованы для сохранения вложенных подмоделей и они официально поддерживаются Tensorflow.

# Функция для загрузки весов обученной модели.def load_darknet_weights(model, weights_file):    wf = open(weights_file, 'rb')    major, minor, revision, seen, _ = np.fromfile(wf, dtype=np.int32, count=5)    layers = YOLO_V3_LAYERS    for layer_name in layers:        sub_model = model.get_layer(layer_name)        for i, layer in enumerate(sub_model.layers):            if not layer.name.startswith('conv2d'):                continue            batch_norm = None            if i + 1 < len(sub_model.layers) and \                sub_model.layers[i + 1].name.startswith('batch_norm'):                    batch_norm = sub_model.layers[i + 1]            logging.info("{}/{} {}".format(                sub_model.name, layer.name, 'bn' if batch_norm else 'bias'))                        filters = layer.filters            size = layer.kernel_size[0]            in_dim = layer.input_shape[-1]            if batch_norm is None:                conv_bias = np.fromfile(wf, dtype=np.float32, count=filters)            else:                bn_weights = np.fromfile(wf, dtype=np.float32, count=4*filters)                bn_weights = bn_weights.reshape((4, filters))[[1, 0, 2, 3]]            conv_shape = (filters, in_dim, size, size)            conv_weights = np.fromfile(wf, dtype=np.float32, count=np.product(conv_shape))            conv_weights = conv_weights.reshape(conv_shape).transpose([2, 3, 1, 0])            if batch_norm is None:                layer.set_weights([conv_weights, conv_bias])            else:                layer.set_weights([conv_weights])                batch_norm.set_weights(bn_weights)    assert len(wf.read()) == 0, 'failed to read weights'    wf.close()

На этом же этапе, мы должны определить функцию для расчета IoU. Мы используем batch normalization (пакетная нормализация) для нормализации результатов, чтобы ускорить обучение. Так как tf.keras.layers.BatchNormalization работает не очень хорошо для трансферного обучения (transfer learning), то мы используем другой подход.

# Функция для расчета IoU.def interval_overlap(interval_1, interval_2):    x1, x2 = interval_1    x3, x4 = interval_2    if x3 < x1:        return 0 if x4 < x1 else (min(x2,x4) - x1)    else:        return 0 if x2 < x3 else (min(x2,x4) - x3)def intersectionOverUnion(box1, box2):    intersect_w = interval_overlap([box1.xmin, box1.xmax], [box2.xmin, box2.xmax])    intersect_h = interval_overlap([box1.ymin, box1.ymax], [box2.ymin, box2.ymax])    intersect_area = intersect_w * intersect_h    w1, h1 = box1.xmax-box1.xmin, box1.ymax-box1.ymin    w2, h2 = box2.xmax-box2.xmin, box2.ymax-box2.ymin    union_area = w1*h1 + w2*h2 - intersect_area    return float(intersect_area) / union_area class BatchNormalization(tf.keras.layers.BatchNormalization):    def call(self, x, training=False):        if training is None: training = tf.constant(False)        training = tf.logical_and(training, self.trainable)        return super().call(x, training)# Определяем 3 anchor box'а для каждой ячейки.   yolo_anchors = np.array([(10, 13), (16, 30), (33, 23), (30, 61), (62, 45),                        (59, 119), (116, 90), (156, 198), (373, 326)], np.float32) / 416yolo_anchor_masks = np.array([[6, 7, 8], [3, 4, 5], [0, 1, 2]])

В каждом масштабе мы определяем 3 anchor box'а для каждой ячейки. В нашем случае если маска будет:

  • 0, 1, 2 означает, что будут использованы первые три якорные рамки

  • 3, 4 ,5 означает, что будут использованы четвертая, пятая и шестая

  • 6, 7, 8 означает, что будут использованы седьмая, восьмая, девятая

# Функция для отрисовки bounding box'ов.def draw_outputs(img, outputs, class_names, white_list=None):    boxes, score, classes, nums = outputs    boxes, score, classes, nums = boxes[0], score[0], classes[0], nums[0]    wh = np.flip(img.shape[0:2])    for i in range(nums):        if class_names[int(classes[i])] not in white_list:            continue        x1y1 = tuple((np.array(boxes[i][0:2]) * wh).astype(np.int32))        x2y2 = tuple((np.array(boxes[i][2:4]) * wh).astype(np.int32))        img = cv2.rectangle(img, x1y1, x2y2, (255, 0, 0), 2)        img = cv2.putText(img, '{} {:.4f}'.format(            class_names[int(classes[i])], score[i]),            x1y1, cv2.FONT_HERSHEY_COMPLEX_SMALL, 1, (0, 0, 255), 2)    return img

Теперь пришло время для реализации YOLOv3. Идея заключается в том, чтобы использовать только сверточные слои. Так как их здесь 53, то самым простым способом является создание функции, в которую мы будем передавать важные параметры, меняющиеся от слоя к слою.

Остаточные блоки (Residual Blocks) в диаграмме архитектуры YOLOv3 применяются для изучения признаков. Остаточный блок содержит в себе несколько сверточных слоев и дополнительные связи для обхода этих слоев.

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

def DarknetConv(x, filters, size, strides=1, batch_norm=True):    if strides == 1:        padding = 'same'    else:        x = ZeroPadding2D(((1, 0), (1, 0)))(x)        padding = 'valid'    x = Conv2D(filters=filters, kernel_size=size,              strides=strides, padding=padding,              use_bias=not batch_norm, kernel_regularizer=l2(0.0005))(x)    if batch_norm:        x = BatchNormalization()(x)        x = LeakyReLU(alpha=0.1)(x)    return xdef DarknetResidual(x, filters):    previous = x    x = DarknetConv(x, filters // 2, 1)    x = DarknetConv(x, filters, 3)    x = Add()([previous , x])    return xdef DarknetBlock(x, filters, blocks):    x = DarknetConv(x, filters, 3, strides=2)    for _ in repeat(None, blocks):        x = DarknetResidual(x, filters)           return xdef Darknet(name=None):    x = inputs = Input([None, None, 3])    x = DarknetConv(x, 32, 3)    x = DarknetBlock(x, 64, 1)    x = DarknetBlock(x, 128, 2)    x = x_36 = DarknetBlock(x, 256, 8)    x = x_61 = DarknetBlock(x, 512, 8)    x = DarknetBlock(x, 1024, 4)    return tf.keras.Model(inputs, (x_36, x_61, x), name=name)  def YoloConv(filters, name=None):    def yolo_conv(x_in):        if isinstance(x_in, tuple):            inputs = Input(x_in[0].shape[1:]), Input(x_in[1].shape[1:])            x, x_skip = inputs            x = DarknetConv(x, filters, 1)            x = UpSampling2D(2)(x)            x = Concatenate()([x, x_skip])        else:            x = inputs = Input(x_in.shape[1:])        x = DarknetConv(x, filters, 1)        x = DarknetConv(x, filters * 2, 3)        x = DarknetConv(x, filters, 1)        x = DarknetConv(x, filters * 2, 3)        x = DarknetConv(x, filters, 1)        return Model(inputs, x, name=name)(x_in)    return yolo_conv  def YoloOutput(filters, anchors, classes, name=None):    def yolo_output(x_in):        x = inputs = Input(x_in.shape[1:])        x = DarknetConv(x, filters * 2, 3)        x = DarknetConv(x, anchors * (classes + 5), 1, batch_norm=False)        x = Lambda(lambda x: tf.reshape(x, (-1, tf.shape(x)[1], tf.shape(x)[2],                                        anchors, classes + 5)))(x)        return tf.keras.Model(inputs, x, name=name)(x_in)    return yolo_outputdef yolo_boxes(pred, anchors, classes):    grid_size = tf.shape(pred)[1]    box_xy, box_wh, score, class_probs = tf.split(pred, (2, 2, 1, classes), axis=-1)    box_xy = tf.sigmoid(box_xy)    score = tf.sigmoid(score)    class_probs = tf.sigmoid(class_probs)    pred_box = tf.concat((box_xy, box_wh), axis=-1)    grid = tf.meshgrid(tf.range(grid_size), tf.range(grid_size))    grid = tf.expand_dims(tf.stack(grid, axis=-1), axis=2)    box_xy = (box_xy + tf.cast(grid, tf.float32)) /  tf.cast(grid_size, tf.float32)    box_wh = tf.exp(box_wh) * anchors    box_x1y1 = box_xy - box_wh / 2    box_x2y2 = box_xy + box_wh / 2    bbox = tf.concat([box_x1y1, box_x2y2], axis=-1)        return bbox, score, class_probs, pred_box

Теперь определим функцию подавления не-максимумов.

def nonMaximumSuppression(outputs, anchors, masks, classes):    boxes, conf, out_type = [], [], []    for output in outputs:        boxes.append(tf.reshape(output[0], (tf.shape(output[0])[0], -1, tf.shape(output[0])[-1])))        conf.append(tf.reshape(output[1], (tf.shape(output[1])[0], -1, tf.shape(output[1])[-1])))        out_type.append(tf.reshape(output[2], (tf.shape(output[2])[0], -1, tf.shape(output[2])[-1])))    bbox = tf.concat(boxes, axis=1)    confidence = tf.concat(conf, axis=1)    class_probs = tf.concat(out_type, axis=1)    scores = confidence * class_probs      boxes, scores, classes, valid_detections = tf.image.combined_non_max_suppression(        boxes=tf.reshape(bbox, (tf.shape(bbox)[0], -1, 1, 4)),        scores=tf.reshape(            scores, (tf.shape(scores)[0], -1, tf.shape(scores)[-1])),        max_output_size_per_class=100,        max_total_size=100,        iou_threshold=yolo_iou_threshold,        score_threshold=yolo_score_threshold)      return boxes, scores, classes, valid_detections

Основная функция:

def YoloV3(size=None, channels=3, anchors=yolo_anchors,            masks=yolo_anchor_masks, classes=80, training=False):    x = inputs = Input([size, size, channels])    x_36, x_61, x = Darknet(name='yolo_darknet')(x)    x = YoloConv(512, name='yolo_conv_0')(x)    output_0 = YoloOutput(512, len(masks[0]), classes, name='yolo_output_0')(x)    x = YoloConv(256, name='yolo_conv_1')((x, x_61))    output_1 = YoloOutput(256, len(masks[1]), classes, name='yolo_output_1')(x)    x = YoloConv(128, name='yolo_conv_2')((x, x_36))    output_2 = YoloOutput(128, len(masks[2]), classes, name='yolo_output_2')(x)    if training:        return Model(inputs, (output_0, output_1, output_2), name='yolov3')    boxes_0 = Lambda(lambda x: yolo_boxes(x, anchors[masks[0]], classes),                  name='yolo_boxes_0')(output_0)    boxes_1 = Lambda(lambda x: yolo_boxes(x, anchors[masks[1]], classes),                  name='yolo_boxes_1')(output_1)    boxes_2 = Lambda(lambda x: yolo_boxes(x, anchors[masks[2]], classes),                  name='yolo_boxes_2')(output_2)    outputs = Lambda(lambda x: nonMaximumSuppression(x, anchors, masks, classes),                  name='nonMaximumSuppression')((boxes_0[:3], boxes_1[:3], boxes_2[:3]))    return Model(inputs, outputs, name='yolov3')

Функция потерь:

def YoloLoss(anchors, classes=80, ignore_thresh=0.5):    def yolo_loss(y_true, y_pred):        pred_box, pred_obj, pred_class, pred_xywh = yolo_boxes(            y_pred, anchors, classes)        pred_xy = pred_xywh[..., 0:2]        pred_wh = pred_xywh[..., 2:4]        true_box, true_obj, true_class_idx = tf.split(            y_true, (4, 1, 1), axis=-1)        true_xy = (true_box[..., 0:2] + true_box[..., 2:4]) / 2        true_wh = true_box[..., 2:4] - true_box[..., 0:2]        box_loss_scale = 2 - true_wh[..., 0] * true_wh[..., 1]        grid_size = tf.shape(y_true)[1]        grid = tf.meshgrid(tf.range(grid_size), tf.range(grid_size))        grid = tf.expand_dims(tf.stack(grid, axis=-1), axis=2)        true_xy = true_xy * tf.cast(grid_size, tf.float32) - \            tf.cast(grid, tf.float32)        true_wh = tf.math.log(true_wh / anchors)        true_wh = tf.where(tf.math.is_inf(true_wh),                      tf.zeros_like(true_wh), true_wh)        obj_mask = tf.squeeze(true_obj, -1)        true_box_flat = tf.boolean_mask(true_box, tf.cast(obj_mask, tf.bool))        best_iou = tf.reduce_max(intersectionOverUnion(            pred_box, true_box_flat), axis=-1)        ignore_mask = tf.cast(best_iou < ignore_thresh, tf.float32)        xy_loss = obj_mask * box_loss_scale * \            tf.reduce_sum(tf.square(true_xy - pred_xy), axis=-1)        wh_loss = obj_mask * box_loss_scale * \            tf.reduce_sum(tf.square(true_wh - pred_wh), axis=-1)        obj_loss = binary_crossentropy(true_obj, pred_obj)        obj_loss = obj_mask * obj_loss + \            (1 - obj_mask) * ignore_mask * obj_loss        class_loss = obj_mask * sparse_categorical_crossentropy(            true_class_idx, pred_class)        xy_loss = tf.reduce_sum(xy_loss, axis=(1, 2, 3))        wh_loss = tf.reduce_sum(wh_loss, axis=(1, 2, 3))        obj_loss = tf.reduce_sum(obj_loss, axis=(1, 2, 3))        class_loss = tf.reduce_sum(class_loss, axis=(1, 2, 3))        return xy_loss + wh_loss + obj_loss + class_loss    return yolo_loss

Функция "преобразовать цели" возвращает кортеж из форм:

(    [N, 13, 13, 3, 6],    [N, 26, 26, 3, 6],    [N, 52, 52, 3, 6])

Где N число меток в пакете, а число 6 означает [x, y, w, h, obj, class] bounding box'а.

@tf.functiondef transform_targets_for_output(y_true, grid_size, anchor_idxs, classes):    N = tf.shape(y_true)[0]    y_true_out = tf.zeros(      (N, grid_size, grid_size, tf.shape(anchor_idxs)[0], 6))    anchor_idxs = tf.cast(anchor_idxs, tf.int32)    indexes = tf.TensorArray(tf.int32, 1, dynamic_size=True)    updates = tf.TensorArray(tf.float32, 1, dynamic_size=True)    idx = 0    for i in tf.range(N):        for j in tf.range(tf.shape(y_true)[1]):            if tf.equal(y_true[i][j][2], 0):                continue            anchor_eq = tf.equal(                anchor_idxs, tf.cast(y_true[i][j][5], tf.int32))            if tf.reduce_any(anchor_eq):                box = y_true[i][j][0:4]                box_xy = (y_true[i][j][0:2] + y_true[i][j][2:4]) / 2                anchor_idx = tf.cast(tf.where(anchor_eq), tf.int32)                grid_xy = tf.cast(box_xy // (1/grid_size), tf.int32)                indexes = indexes.write(                    idx, [i, grid_xy[1], grid_xy[0], anchor_idx[0][0]])                updates = updates.write(                    idx, [box[0], box[1], box[2], box[3], 1, y_true[i][j][4]])                idx += 1    return tf.tensor_scatter_nd_update(        y_true_out, indexes.stack(), updates.stack())def transform_targets(y_train, anchors, anchor_masks, classes):    outputs = []    grid_size = 13    anchors = tf.cast(anchors, tf.float32)    anchor_area = anchors[..., 0] * anchors[..., 1]    box_wh = y_train[..., 2:4] - y_train[..., 0:2]    box_wh = tf.tile(tf.expand_dims(box_wh, -2),                    (1, 1, tf.shape(anchors)[0], 1))    box_area = box_wh[..., 0] * box_wh[..., 1]    intersection = tf.minimum(box_wh[..., 0], anchors[..., 0]) * \    tf.minimum(box_wh[..., 1], anchors[..., 1])    iou = intersection / (box_area + anchor_area - intersection)    anchor_idx = tf.cast(tf.argmax(iou, axis=-1), tf.float32)    anchor_idx = tf.expand_dims(anchor_idx, axis=-1)    y_train = tf.concat([y_train, anchor_idx], axis=-1)    for anchor_idxs in anchor_masks:        outputs.append(transform_targets_for_output(            y_train, grid_size, anchor_idxs, classes))        grid_size *= 2    return tuple(outputs) # [x, y, w, h, obj, class]def preprocess_image(x_train, size):    return (tf.image.resize(x_train, (size, size))) / 255

Теперь мы можем создать нашу модель, загрузить веса и названия классов. В COCO датасете их 80.

yolo = YoloV3(classes=num_classes)load_darknet_weights(yolo, weightyolov3)yolo.save_weights(checkpoints)class_names =  ["person", "bicycle", "car", "motorbike", "aeroplane", "bus", "train", "truck",    "boat", "traffic light", "fire hydrant", "stop sign", "parking meter", "bench",    "bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe",    "backpack", "umbrella", "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard",    "sports ball", "kite", "baseball bat", "baseball glove", "skateboard", "surfboard",    "tennis racket", "bottle", "wine glass", "cup", "fork", "knife", "spoon", "bowl",    "banana","apple", "sandwich", "orange", "broccoli", "carrot", "hot dog", "pizza", "donut",    "cake","chair", "sofa", "pottedplant", "bed", "diningtable", "toilet", "tvmonitor", "laptop",     "mouse","remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink",    "refrigerator","book", "clock", "vase", "scissors", "teddy bear", "hair drier", "toothbrush"]def detect_objects(img_path, white_list=None):    image = img_path     # Путь к изображению.    img = tf.image.decode_image(open(image, 'rb').read(), channels=3)    img = tf.expand_dims(img, 0)    img = preprocess_image(img, size)    boxes, scores, classes, nums = yolo(img)    img = cv2.imread(image)    img = draw_outputs(img, (boxes, scores, classes, nums), class_names, white_list)    cv2.imwrite('detected_{:}'.format(img_path), img)    detected = Image.open('detected_{:}'.format(img_path))    detected.show()    detect_objects('test.jpg', ['bear'])

Итог

В этой статье мы поговорили об отличительных особенностях YOLOv3 и её преимуществах перед другими моделями. Мы рассмотрели способ реализации с использованием TensorFlow 2.0 (TF должен быть не менее версией 2.0).

Ссылки

Подробнее..

3D teeth instance segmentation. В темноте, но не один

23.05.2021 14:09:40 | Автор: admin

3D сегментация зубов от поиска данных до конечного результата. Почти.

Дисклеймер

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

Об авторе

Добрый - всем, зовут Андрей(27). Постараюсь коротко. Почему программирование? По образованию - бакалавр электромеханик, профессию знаю. Отработал 2 года на должности инженера-энергетика в буровой компании вполне успешно, вместо повышения написал заявление - сгорел, да не по мне оказалось это всё. Нравится создавать, находить решения сложных задач, с ПК в обнимку с сознательных лет. Выбор очевиден. Вначале (полгода назад), всерьёз думал записаться на курсы от Я или подобные. Начитался отзывов, поговорил с участниками и понял что с получением информацией проблем нет. Так нашел сайт, там получил базу по Python и с ним уже начал свой путь (сейчас там постепенно изучаю всё, что связано с ML). Сразу заинтересовало машинное обучение, CV в частности. Придумал себе задачу и вот здесь (по мне, так отличный способ учиться).

1. Введение

В результате нескольких неудачных попыток, пришел к решению использовать 2 легковесные модели для получения желаемого результата. 1-ая сегментирует все зубы как [1, 0] категорию, а вторая делит их на категории[0, 8]. Но начнем по порядку.

2. Поиск и подготовка данных

Потратив не один вечер на поиск данных для работы, пришел в выводу что в свободном доступе челюсть в хорошем качестве и формате (*.stl, *.nrrd и т.д.) не получится. Лучшее, что мне попалось - это тестовый образец головы пациента после хирургической операции на челюсти в программе 3D Slicer.

Очевидно, мне не нужна голова целиком, поэтому обрезал исходник в той же программе до размера 163*112*120рх (в данном посте {x*y*z = ш-г-в} и 1рх - 0,5мм), оставив только зубы и сопутствующие челюстно-лицевые части.

Уже больше похоже на то что нужно, дальше - интереснее. Теперь нужно создать маски всех необходимых нам объектов. Для тех, кто уже работал с этим - "autothreshold" не то чтобы совсем не работает, просто лишнего много, думаю, исправление заняло бы столько же времени, сколько и разметка вручную(через маски).

- Пиксели(срезы слева)? - Вспоминаем размер изображения- Пиксели(срезы слева)? - Вспоминаем размер изображения

Размечал часов 12~14. И да, тот факт что я не сразу разметил каждый зуб как категорию стоил мне еще порядка 4 часов. В итоге у нас есть данные, с которыми у же можно работать.

Конечный вариант маски. Smooth 0.5. (сглаживание в обучении не использовалось)Конечный вариант маски. Smooth 0.5. (сглаживание в обучении не использовалось)

Должен добавить, даже на мой (без опыта) взгляд, этих данных очень мало для обучения и последующей полноценной работы нейронной сети. На данном этапе, единственное что пришло в голову, повернуть имеющиеся данные N-раз и соединить, random-crop использовать не стал.

Код подготовки данных
import nrrdimport torchimport torchvision.transforms as tfclass DataBuilder:    def __init__(self,                 data_path,                 list_of_categories,                 num_of_chunks: int = 0,                 augmentation_coeff: int = 0,                 num_of_classes: int = 0,                 normalise: bool = False,                 fit: bool = True,                 data_format: int = 0,                 save_data: bool = False                 ):        self.data_path = data_path        self.number_of_chunks = num_of_chunks        self.augmentation_coeff = augmentation_coeff        self.list_of_cats = list_of_categories        self.num_of_cls = num_of_classes        self.normalise = normalise        self.fit = fit        self.data_format = data_format        self.save_data = save_data    def forward(self):        data = self.get_data()        data = self.fit_data(data) if self.fit else data        data = self.pre_normalize(data) if self.normalise else data        data = self.data_augmentation(data, self.augmentation_coeff) if self.augmentation_coeff != 0 else data        data = self.new_chunks(data, self.number_of_chunks) if self.number_of_chunks != 0 else data        data = self.category_splitter(data, self.num_of_cls, self.list_of_cats) if self.num_of_cls != 0 else data        torch.save(data, self.data_path[-14:]+'.pt') if self.save_data else None        return torch.unsqueeze(data, 1)    def get_data(self):        if self.data_format == 0:            return torch.from_numpy(nrrd.read(self.data_path)[0])        elif self.data_format == 1:            return torch.load(self.data_path).cpu()        elif self.data_format == 2:            return torch.unsqueeze(self.data_path, 0).cpu()        else:            print('Available types are: "nrrd", "tensor" or "self.tensor(w/o load)"')    @staticmethod    def fit_data(some_data):        data = torch.movedim(some_data, (1, 0), (0, -1))        data_add_x = torch.nn.ZeroPad2d((5, 0, 0, 0))        data = data_add_x(data)        data = torch.movedim(data, -1, 0)        data_add_z = torch.nn.ZeroPad2d((0, 0, 8, 0))        return data_add_z(data)    @staticmethod    def pre_normalize(some_data):        min_d, max_d = torch.min(some_data), torch.max(some_data)        return (some_data - min_d) / (max_d - min_d)    @staticmethod    def data_augmentation(some_data, aug_n):        torch.manual_seed(17)        tr_data = []        for e in range(aug_n):            transform = tf.RandomRotation(degrees=(20*e, 20*e))            for image in some_data:                image = torch.unsqueeze(image, 0)                image = transform(image)                tr_data.append(image)        return tr_data    def new_chunks(self, some_data, n_ch):        data = torch.stack(some_data, 0) if self.augmentation_coeff != 0 else some_data        data = torch.squeeze(data, 1)        chunks = torch.chunk(data, n_ch, 0)        return torch.stack(chunks)    @staticmethod    def category_splitter(some_data, alpha, list_of_categories):        data, _ = torch.squeeze(some_data, 1).to(torch.int64), alpha        for i in list_of_categories:            data = torch.where(data < i, _, data)            _ += 1        return data - alpha

Имейте ввиду что это финальная версия кода подготовки данных для 3D U-net. Форвард:

  • Загружаем дату (в зависимости от типа).

  • Добавляем 0 по краям чтобы подогнать размер до 168*120*120 (вместо исходных 163*112*120). *пригодится дальше.

  • Нормализуем входящие данные в 0...1 (исходные ~-2000...16000).

  • Поворачиваем N-раз и соединяем.

  • Полученные данные режем на равные части чтобы забить память видеокарты по максимуму (в моем случае это 1, 1, 72, 120, 120).

  • Эта часть распределяет по категориям 28 имеющихся зубов и фон для облегчения обучения моделей (см. Введение):

    • одну категорию для 1-ой;

    • на 9 категорий (8+фон) для 2-ой.

Dataloader стандартный
import torch.utils.data as tudclass ToothDataset(tud.Dataset):    def __init__(self, images, masks):        self.images = images        self.masks = masks    def __len__(self): return len(self.images)    def __getitem__(self, index):        if self.masks is not None:            return self.images[index, :, :, :, :],\                    self.masks[index, :, :, :, :]        else:            return self.images[index, :, :, :, :]def get_loaders(images, masks,                batch_size: int = 1,                num_workers: int = 1,                pin_memory: bool = True):    train_ds = ToothDataset(images=images,                            masks=masks)    data_loader = tud.DataLoader(train_ds,                                 batch_size=batch_size,                                 shuffle=False,                                 num_workers=num_workers,                                 pin_memory=pin_memory)    return data_loader

На выходе имеем следующее:

Semantic

Instance

Predictions

Data

(27*, 1, 56*, 120,120)[0...1]

(27*, 1, 56*, 120,120) [0, 1]

(1, 1, 168, 120, 120)[0...1]

Masks

(27*, 1, 56*, 120,120)[0, 1]

(27*, 1, 56*, 120,120)[0, 8]

-

*эти размеры менялись, в зависимости от эксперимента, подробности - дальше.

3. Выбор и настройка моделей обучения

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

2D U-Net2D U-Net

Подробно рассказывать не буду, информации в достатке в сети. Метод оптимизации - Adam, функция расчета потерь Dice-loss(implement), спусков/подъемов 4, фильтры [64, 128, 256, 512] (знаю, много, об этом - позже). Обучал в среднем 60-80 epochs на эксперимент. Transfer learning не использовал.

model.summary()
model = UNet(dim=2, in_channels=1, out_channels=1, n_blocks=4, start_filters=64).to(device)print(summary(model, (1, 168, 120)))"""----------------------------------------------------------------        Layer (type)               Output Shape         Param #================================================================            Conv2d-1         [-1, 64, 168, 120]             640              ReLU-2         [-1, 64, 168, 120]               0       BatchNorm2d-3         [-1, 64, 168, 120]             128            Conv2d-4         [-1, 64, 168, 120]          36,928              ReLU-5         [-1, 64, 168, 120]               0       BatchNorm2d-6         [-1, 64, 168, 120]             128         MaxPool2d-7           [-1, 64, 84, 60]               0         DownBlock-8  [[-1, 64, 84, 60], [-1, 64, 168, 120]]  0            Conv2d-9          [-1, 128, 84, 60]          73,856             ReLU-10          [-1, 128, 84, 60]               0      BatchNorm2d-11          [-1, 128, 84, 60]             256           Conv2d-12          [-1, 128, 84, 60]         147,584             ReLU-13          [-1, 128, 84, 60]               0      BatchNorm2d-14          [-1, 128, 84, 60]             256        MaxPool2d-15          [-1, 128, 42, 30]               0        DownBlock-16  [[-1, 128, 42, 30], [-1, 128, 84, 60]]  0           Conv2d-17          [-1, 256, 42, 30]         295,168             ReLU-18          [-1, 256, 42, 30]               0      BatchNorm2d-19          [-1, 256, 42, 30]             512           Conv2d-20          [-1, 256, 42, 30]         590,080             ReLU-21          [-1, 256, 42, 30]               0      BatchNorm2d-22          [-1, 256, 42, 30]             512        MaxPool2d-23          [-1, 256, 21, 15]               0        DownBlock-24  [[-1, 256, 21, 15], [-1, 256, 42, 30]]  0           Conv2d-25          [-1, 512, 21, 15]       1,180,160             ReLU-26          [-1, 512, 21, 15]               0      BatchNorm2d-27          [-1, 512, 21, 15]           1,024           Conv2d-28          [-1, 512, 21, 15]       2,359,808             ReLU-29          [-1, 512, 21, 15]               0      BatchNorm2d-30          [-1, 512, 21, 15]           1,024        DownBlock-31  [[-1, 512, 21, 15], [-1, 512, 21, 15]]  0  ConvTranspose2d-32          [-1, 256, 42, 30]         524,544             ReLU-33          [-1, 256, 42, 30]               0      BatchNorm2d-34          [-1, 256, 42, 30]             512      Concatenate-35          [-1, 512, 42, 30]               0           Conv2d-36          [-1, 256, 42, 30]       1,179,904             ReLU-37          [-1, 256, 42, 30]               0      BatchNorm2d-38          [-1, 256, 42, 30]             512           Conv2d-39          [-1, 256, 42, 30]         590,080             ReLU-40          [-1, 256, 42, 30]               0      BatchNorm2d-41          [-1, 256, 42, 30]             512          UpBlock-42          [-1, 256, 42, 30]               0  ConvTranspose2d-43          [-1, 128, 84, 60]         131,200             ReLU-44          [-1, 128, 84, 60]               0      BatchNorm2d-45          [-1, 128, 84, 60]             256      Concatenate-46          [-1, 256, 84, 60]               0           Conv2d-47          [-1, 128, 84, 60]         295,040             ReLU-48          [-1, 128, 84, 60]               0      BatchNorm2d-49          [-1, 128, 84, 60]             256           Conv2d-50          [-1, 128, 84, 60]         147,584             ReLU-51          [-1, 128, 84, 60]               0      BatchNorm2d-52          [-1, 128, 84, 60]             256          UpBlock-53          [-1, 128, 84, 60]               0  ConvTranspose2d-54         [-1, 64, 168, 120]          32,832             ReLU-55         [-1, 64, 168, 120]               0      BatchNorm2d-56         [-1, 64, 168, 120]             128      Concatenate-57        [-1, 128, 168, 120]               0           Conv2d-58         [-1, 64, 168, 120]          73,792             ReLU-59         [-1, 64, 168, 120]               0      BatchNorm2d-60         [-1, 64, 168, 120]             128           Conv2d-61         [-1, 64, 168, 120]          36,928             ReLU-62         [-1, 64, 168, 120]               0      BatchNorm2d-63         [-1, 64, 168, 120]             128          UpBlock-64         [-1, 64, 168, 120]               0           Conv2d-65          [-1, 1, 168, 120]              65================================================================Total params: 7,702,721Trainable params: 7,702,721Non-trainable params: 0----------------------------------------------------------------Input size (MB): 0.08Forward/backward pass size (MB): 7434.08Params size (MB): 29.38Estimated Total Size (MB): 7463.54"""
Эксп.12D U-Net, подача изображений покадрово, плоскость [x, z]Эксп.12D U-Net, подача изображений покадрово, плоскость [x, z]

Определенно, это - зубы. Только кроме зубов есть много всего, нам ненужного. Подробнее о трансформации numpy - *.stl в Главе 6. Посмотрим ещё раз на фактический размер и качество изображений, которые попадают на вход нейросети:

Слева на право:1. Не видно[x, y]. 2. Немного лучше[x, z]. 3.Ещё лучше[y, z]Слева на право:1. Не видно[x, y]. 2. Немного лучше[x, z]. 3.Ещё лучше[y, z]

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

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

Эксп.2Каскад 2-ух 2D U-Net, подача изображений покадрово, плоскость [y, z]Эксп.2Каскад 2-ух 2D U-Net, подача изображений покадрово, плоскость [y, z]

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

Эксп.3Каскад 2-ух 2D U-Net, подача изображений покадрово плоскость [y, z]с увеличением времени обучения на 50%Эксп.3Каскад 2-ух 2D U-Net, подача изображений покадрово плоскость [y, z]с увеличением времени обучения на 50%

Ввиду последних событий было принято решение о переходе на 3D архитектуру нейронной сети. Переподготовил входные данные, а именно разделил на части размером (24*, 120, 120). Почему так? - изначально большая модель обучения (~22млн. параметров). Моя видеокарта(1063gtx) не могла физически вместить больше.

24*

Это размер глубины. Был подобран так чтобы:

  • количество данных(1512, 120, 120) делится нацело на это число - получается 63;

  • в свою очередь получившийся batch size (24, 120, 120) - максимум, вмещающийся в память видеокарты с текущими параметрами сети;

  • само это число (24) делилось на количество спусков/подъемов так же нацело (имеется в виду соответствие выражению 24/2/2/2=3 и 3*2*2*2=24, где количество делений/умножений на 2 соответствует количеству спусков/подъемов минус 1);

  • то же самое не только для глубины данных, но и длинны и ширины. Подробнее в .summary()

model.summary()
model = UNet(dim=3, in_channels=1, out_channels=1, n_blocks=4, start_filters=64).to(device)print(summary(model, (1, 24, 120, 120)))"""  ----------------------------------------------------------------        Layer (type)               Output Shape         Param #================================================================            Conv3d-1     [-1, 64, 24, 120, 120]             1,792              ReLU-2     [-1, 64, 24, 120, 120]                 0       BatchNorm3d-3     [-1, 64, 24, 120, 120]               128            Conv3d-4     [-1, 64, 24, 120, 120]           110,656              ReLU-5     [-1, 64, 24, 120, 120]                 0       BatchNorm3d-6     [-1, 64, 24, 120, 120]               128         MaxPool3d-7        [-1, 64, 12, 60, 60]                0         DownBlock-8  [[-1, 64, 12, 60, 60], [-1, 64, 24, 120, 120]]               0            Conv3d-9       [-1, 128, 12, 60, 60]          221,312             ReLU-10       [-1, 128, 12, 60, 60]                0      BatchNorm3d-11       [-1, 128, 12, 60, 60]              256           Conv3d-12       [-1, 128, 12, 60, 60]          442,496             ReLU-13       [-1, 128, 12, 60, 60]                0      BatchNorm3d-14       [-1, 128, 12, 60, 60]              256        MaxPool3d-15       [-1, 128, 6, 30, 30]                 0        DownBlock-16  [[-1, 128, 6, 30, 30], [-1, 128, 12, 60, 60]]               0           Conv3d-17       [-1, 256, 6, 30, 30]           884,992             ReLU-18       [-1, 256, 6, 30, 30]                 0      BatchNorm3d-19       [-1, 256, 6, 30, 30]               512           Conv3d-20       [-1, 256, 6, 30, 30]         1,769,728             ReLU-21       [-1, 256, 6, 30, 30]                 0      BatchNorm3d-22       [-1, 256, 6, 30, 30]               512        MaxPool3d-23       [-1, 256, 3, 15, 15]                 0        DownBlock-24  [[-1, 256, 3, 15, 15], [-1, 256, 6, 30, 30]]               0           Conv3d-25       [-1, 512, 3, 15, 15]         3,539,456             ReLU-26       [-1, 512, 3, 15, 15]                 0      BatchNorm3d-27       [-1, 512, 3, 15, 15]             1,024           Conv3d-28       [-1, 512, 3, 15, 15]         7,078,400             ReLU-29       [-1, 512, 3, 15, 15]                 0      BatchNorm3d-30       [-1, 512, 3, 15, 15]             1,024        DownBlock-31  [[-1, 512, 3, 15, 15], [-1, 512, 3, 15, 15]]               0  ConvTranspose3d-32       [-1, 256, 6, 30, 30]         1,048,832             ReLU-33       [-1, 256, 6, 30, 30]                 0      BatchNorm3d-34       [-1, 256, 6, 30, 30]               512      Concatenate-35       [-1, 512, 6, 30, 30]                 0           Conv3d-36       [-1, 256, 6, 30, 30]         3,539,200             ReLU-37       [-1, 256, 6, 30, 30]                 0      BatchNorm3d-38       [-1, 256, 6, 30, 30]               512           Conv3d-39       [-1, 256, 6, 30, 30]         1,769,728             ReLU-40       [-1, 256, 6, 30, 30]                 0      BatchNorm3d-41       [-1, 256, 6, 30, 30]               512          UpBlock-42       [-1, 256, 6, 30, 30]                 0  ConvTranspose3d-43       [-1, 128, 12, 60, 60]          262,272             ReLU-44       [-1, 128, 12, 60, 60]                0      BatchNorm3d-45       [-1, 128, 12, 60, 60]              256      Concatenate-46       [-1, 256, 12, 60, 60]                0           Conv3d-47       [-1, 128, 12, 60, 60]          884,864             ReLU-48       [-1, 128, 12, 60, 60]                0      BatchNorm3d-49       [-1, 128, 12, 60, 60]              256           Conv3d-50       [-1, 128, 12, 60, 60]          442,496             ReLU-51       [-1, 128, 12, 60, 60]                0      BatchNorm3d-52       [-1, 128, 12, 60, 60]              256          UpBlock-53       [-1, 128, 12, 60, 60]                0  ConvTranspose3d-54       [-1, 64, 24, 120, 120]          65,600             ReLU-55       [-1, 64, 24, 120, 120]               0      BatchNorm3d-56       [-1, 64, 24, 120, 120]             128      Concatenate-57      [-1, 128, 24, 120, 120]               0           Conv3d-58       [-1, 64, 24, 120, 120]         221,248             ReLU-59       [-1, 64, 24, 120, 120]               0      BatchNorm3d-60       [-1, 64, 24, 120, 120]             128           Conv3d-61       [-1, 64, 24, 120, 120]         110,656             ReLU-62       [-1, 64, 24, 120, 120]               0      BatchNorm3d-63       [-1, 64, 24, 120, 120]             128          UpBlock-64       [-1, 64, 24, 120, 120]               0           Conv3d-65        [-1, 1, 24, 120, 120]              65================================================================Total params: 22,400,321Trainable params: 22,400,321Non-trainable params: 0----------------------------------------------------------------Input size (MB): 0.61Forward/backward pass size (MB): 15974.12Params size (MB): 85.45Estimated Total Size (MB): 16060.18----------------------------------------------------------------"""
Эксп.43D U-Net, подача объемом, плоскость [y, z],время*0,38Эксп.43D U-Net, подача объемом, плоскость [y, z],время*0,38

С учетом сокращенного на ~60% времени обучения(25 epochs) результат меня устроил, продолжаем.

Эксп.53D U-Net, подача объемом, плоскость [y, z], 65 epochs ~ 1,5 часа Эксп.53D U-Net, подача объемом, плоскость [y, z], 65 epochs ~ 1,5 часа

Особых потерь в искомых зонах не заметил. Решил продолжать, однако результат дальнейшего обучения мы уже где то видели(эксп.3) - значительное уменьшение искомых зон и появление артефактов:

Эксп.63D U-Net, подача объемом, плоскость [x, z], 105 epochs ~ 2,1 часа Эксп.63D U-Net, подача объемом, плоскость [x, z], 105 epochs ~ 2,1 часа

"Научный" перебор параметров в течении недели принес результат. Уменьшил количество параметров сети до ~400к (от первоначальных ~22м) путем уменьшения фильтра [18, 32, 64, 128] и спуска/подъема до 3. Изменил метод оптимизации на RSMProp. Уменьшение количества параметров нейросети позволило увеличить объем входных данных в три раза (1, 1, 72*, 120, 120). Посмотрим результат?

model.summary()
model = UNet(dim=3, in_channels=1, out_channels=1, n_blocks=3, start_filters=18).to(device)print(summary(model, (1, 1, 72, 120, 120)))"""----------------------------------------------------------------        Layer (type)               Output Shape         Param #================================================================            Conv3d-1     [-1, 18, 72, 120, 120]             504              ReLU-2     [-1, 18, 72, 120, 120]               0       BatchNorm3d-3     [-1, 18, 72, 120, 120]              36            Conv3d-4     [-1, 18, 72, 120, 120]           8,766              ReLU-5     [-1, 18, 72, 120, 120]               0       BatchNorm3d-6     [-1, 18, 72, 120, 120]              36         MaxPool3d-7       [-1, 18, 36, 60, 60]               0         DownBlock-8  [[-1, 18, 36, 60, 60], [-1, 18, 24, 120, 120]]               0            Conv3d-9       [-1, 36, 36, 60, 60]          17,532             ReLU-10       [-1, 36, 36, 60, 60]               0      BatchNorm3d-11       [-1, 36, 36, 60, 60]              72           Conv3d-12       [-1, 36, 36, 60, 60]          35,028             ReLU-13       [-1, 36, 36, 60, 60]               0      BatchNorm3d-14       [-1, 36, 36, 60, 60]              72        MaxPool3d-15        [-1, 36, 18, 30, 30]              0        DownBlock-16  [[-1, 36, 18, 30, 30], [-1, 36, 36, 60, 60]]               0           Conv3d-17        [-1, 72, 18, 30, 30]         70,056             ReLU-18        [-1, 72, 18, 30, 30]              0      BatchNorm3d-19        [-1, 72, 18, 30, 30]            144           Conv3d-20        [-1, 72, 18, 30, 30]        140,040             ReLU-21        [-1, 72, 18, 30, 30]              0      BatchNorm3d-22        [-1, 72, 18, 30, 30]            144        DownBlock-23  [[-1, 72, 18, 30, 30], [-1, 72, 18, 30, 30]]               0  ConvTranspose3d-24       [-1, 36, 36, 60, 60]          20,772             ReLU-25       [-1, 36, 36, 60, 60]               0      BatchNorm3d-26       [-1, 36, 36, 60, 60]              72      Concatenate-27       [-1, 72, 36, 60, 60]               0           Conv3d-28       [-1, 36, 36, 60, 60]          70,020             ReLU-29       [-1, 36, 36, 60, 60]               0      BatchNorm3d-30       [-1, 36, 36, 60, 60]              72           Conv3d-31       [-1, 36, 36, 60, 60]          35,028             ReLU-32       [-1, 36, 36, 60, 60]               0      BatchNorm3d-33       [-1, 36, 36, 60, 60]              72          UpBlock-34       [-1, 36, 36, 60, 60]               0  ConvTranspose3d-35     [-1, 18, 72, 120, 120]           5,202             ReLU-36     [-1, 18, 72, 120, 120]               0      BatchNorm3d-37     [-1, 18, 72, 120, 120]              36      Concatenate-38     [-1, 36, 72, 120, 120]               0           Conv3d-39     [-1, 18, 72, 120, 120]          17,514             ReLU-40     [-1, 18, 72, 120, 120]               0      BatchNorm3d-41     [-1, 18, 72, 120, 120]              36           Conv3d-42     [-1, 18, 72, 120, 120]           8,766             ReLU-43     [-1, 18, 72, 120, 120]               0      BatchNorm3d-44     [-1, 18, 72, 120, 120]              36          UpBlock-45     [-1, 18, 72, 120, 120]               0           Conv3d-46      [-1, 1, 72, 120, 120]              19================================================================Total params: 430,075Trainable params: 430,075Non-trainable params: 0----------------------------------------------------------------Input size (MB): 1.32Forward/backward pass size (MB): 5744.38Params size (MB): 1.64Estimated Total Size (MB): 5747.34----------------------------------------------------------------"""
72*

Некоторые из вас подумают, исходные данные (168, 120, 120), а часть (72, 120, 120). Назревает вопрос, как делить. Всё просто, во 2 главе мы увеличивали размер наших данных и затем делили их на части, соответствующие объему памяти видеокарты. Я увеличил данные в 9 раз (1512, 120, 120) т.е. повернул на 9 различных углов относительно одной оси, а затем разделил на 21(batch size) часть по (72, 120, 120). Так же 72 соответствует всем условиям, описанным в 24*(выше).

Эксп.73D U-Net, подача объемом, плоскость [x, z],Маска (слева) и готовая сегментация (справа),оптимизированные параметры сети,время обучения(65 epochs) ~ 14мин.Эксп.73D U-Net, подача объемом, плоскость [x, z],Маска (слева) и готовая сегментация (справа),оптимизированные параметры сети,время обучения(65 epochs) ~ 14мин.

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

О размере подаваемых данных

Первоначальная идея при переходе на 3D архитектуру была в том чтобы делить данные не слайсами (как в данном посте) (1512, 120, 120) --> 21*(1, 72, 120, 120), а кубиками ~х*(30, 30, 30) или около того (результат этой попытки не был сохранен оп понятным причинам). Опытным путем понял 2 вещи: чем большими порциями ты подаешь 3-х мерные объекты, тем лучше результат(для моего конкретного случая); и нужно больше изучать теорию того, с чем работаешь.

О времени обучения и размере модели

Параметры сети подобраны так, что обучение 1 epochs на моей "старушке" занимает ~13сек, а размер конечной модели не превышает 2мб (прошлая>80мб). Время рабочего цикла примерно равно 1 epochs. Однако стоит понимать, это обучение и работа на данных достаточно маленького размера.

Для разделения на категории пришлось немного повозиться с функцией расчета ошибки и визуализацией данных. Первоначально поставил себе задачу разделить на 8 категорий + фон. О loss function и визуализации поговорим подробнее.

Код training loop
import torchfrom tqdm import tqdmfrom _loss_f import LossFunctionclass TrainFunction:    def __init__(self,                 data_loader,                 device_for_training,                 model_name,                 model_name_pretrained,                 model,                 optimizer,                 scale,                 learning_rate: int = 1e-2,                 num_epochs: int = 1,                 transfer_learning: bool = False,                 binary_loss_f: bool = True                 ):        self.data_loader = data_loader        self.device = device_for_training        self.model_name_pretrained = model_name_pretrained        self.semantic_binary = binary_loss_f        self.num_epochs = num_epochs        self.model_name = model_name        self.transfer = transfer_learning        self.optimizer = optimizer        self.learning_rate = learning_rate        self.model = model        self.scale = scale    def forward(self):        print('Running on the:', torch.cuda.get_device_name(self.device))        self.model.load_state_dict(torch.load(self.model_name_pretrained)) if self.transfer else None        optimizer = self.optimizer(self.model.parameters(), lr=self.learning_rate)        for epoch in range(self.num_epochs):            self.train_loop(self.data_loader, self.model, optimizer, self.scale, epoch)            torch.save(self.model.state_dict(), 'models/' + self.model_name+str(epoch+1)                       + '_epoch.pth') if (epoch + 1) % 10 == 0 else None    def train_loop(self, loader, model, optimizer, scales, i):        loop, epoch_loss = tqdm(loader), 0        loop.set_description('Epoch %i' % (self.num_epochs - i))        for batch_idx, (data, targets) in enumerate(loop):            data, targets = data.to(device=self.device, dtype=torch.float), \                            targets.to(device=self.device, dtype=torch.long)            optimizer.zero_grad()            *тут секрет*            with torch.cuda.amp.autocast():                predictions = model(data)                loss = LossFunction(predictions, targets,                                    device_for_training=self.device,                                    semantic_binary=self.semantic_binary                                    ).forward()            scales.scale(loss).backward()            scales.step(optimizer)            scales.update()            epoch_loss += (1 - loss.item())*100            loop.set_postfix(loss=loss.item())        print('Epoch-acc', round(epoch_loss / (batch_idx+1), 2))

4. Функция расчета ошибки

Мне в целом понравилось как проявляет себя Dice-loss в сегментации, только 'проблема' в том что он работает с форматом данных [0, 1]. Однако, если предварительно разделить данные на категории (а так же привести к формату [0, 1]), и пропускать пары (имеется ввиду "предсказание" и "маска" только одной категории) в стандартную Dice-loss функцию, то это может сработать.

Код categorical_dice_loss
import torchclass LossFunction:    def __init__(self,                 prediction,                 target,                 device_for_training,                 semantic_binary: bool = True,                 ):        self.prediction = prediction        self.device = device_for_training        self.target = target        self.semantic_binary = semantic_binary    def forward(self):        if self.semantic_binary:            return self.dice_loss(self.prediction, self.target)        return self.categorical_dice_loss(self.prediction, self.target)    @staticmethod    def dice_loss(predictions, targets, alpha=1e-5):        intersection = 2. * (predictions * targets).sum()        denomination = (torch.square(predictions) + torch.square(targets)).sum()        dice_loss = 1 - torch.mean((intersection + alpha) / (denomination + alpha))        return dice_loss    def categorical_dice_loss(self, prediction, target):        pr, tr = self.prepare_for_multiclass_loss_f(prediction, target)        target_categories, losses = torch.unique(tr).tolist(), 0        for num_category in target_categories:            categorical_target = torch.where(tr == num_category, 1, 0)            categorical_prediction = pr[num_category][:][:][:]            losses += self.dice_loss(categorical_prediction, categorical_target).to(self.device)        return losses / len(target_categories)    @staticmethod    def prepare_for_multiclass_loss_f(prediction, target):        prediction_prepared = torch.squeeze(prediction, 0)        target_prepared = torch.squeeze(target, 0)        target_prepared = torch.squeeze(target_prepared, 0)        return prediction_prepared, target_prepared

Тут просто, но всё равно объясню "categorical_dice_loss":

  • подготовка данных (убираем ненужные в данном расчете измерения);

  • получения списка категорий, которые содержит каждый batch масок;

  • для каждой категории берем "прогноз" и "маску" соответствующих категорий, приводим значения к формату [0, 1] и пропускаем через стандартную Dice-loss;

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

Так же, думаю, помог бы перевод данных к one-hot формату, но только не в момент формирования основного дата сета (раздует в размере), а непосредственно перед расчетом ошибки, но я не проверял. Кто в курсе, напишите, пожалуйста, буду рад. Результат работы данной функции будет в Главе(5).

5. Визуализация данных

Так и хочется добавить "..как отдельный вид искусства". Начну с того что прочитать *.nrrd оказалось самым простым.

Код
import nrrd# читает в numpyread = nrrd.read(data_path) data, meta_data = read[0], read[1]print(data.shape, np.max(data), np.min(data), meta_data, sep="\n")(163, 112, 120)14982-2254  OrderedDict([('type', 'short'), ('dimension', 3), ('space', 'left-posterior-superior'), ('sizes', array([163, 112, 120])), ('space directions', array([[-0.5,  0. ,  0. ],       [ 0. , -0.5,  0. ],       [ 0. ,  0. ,  0.5]])), ('kinds', ['domain', 'domain', 'domain']), ('endian', 'little'), ('encoding', 'gzip'), ('space origin', array([131.57200623,  80.7661972 ,  32.29940033]))])

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

Неправильный путь

Иными словами, чтобы сделать куб нам необходимо 8 вершин и 12 треугольных поверхностей. В этом и состояла первая идея (до применения специальных библиотек) - заменить все пиксели (числа в 3-х мерной матрице) на такие кубики. Код я не сохранил, но смысл прост, рисуем куб на месте "пикселя" со сдвигом -1 по трем направлениям, потом следующий и т.д.

Выглядит это так же бредово, как и звучитВыглядит это так же бредово, как и звучит

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

from skimage.measure import marching_cubesimport nrrdimport numpy as npfrom stl import meshpath = 'some_path.nrrd'data = nrrd.read(path)[0]def three_d_creator(some_data):    vertices, faces, volume, _ = marching_cubes(some_data)    cube = mesh.Mesh(np.full(faces.shape[0], volume.shape[0], dtype=mesh.Mesh.dtype))    for i, f in enumerate(faces):        for j in range(3):            cube.vectors[i][j] = vertices[f[j]]    cube.save('name.stl')    return cubestl = three_d_creator(datas)

Пользовался этим способом, но иногда файлы "ломались" в процессе сохранения и не открывались. А на те, которые открывались, ругался встроенный в Win 10 3D Builder и постоянно пытался там что-то исправить. Так же еще придется "прикрутить" к коду модуль для просмотра 3D объектов без их сохранения. Решение "из коробки" дальше.

На момент написания статью пользуюсь v3do. Коротко, быстро, удобно и можно сразу осмотреть модель.

Код перевода npy в stl и вывода объекта на дисплей
from vedo import Volume, show, writeprediction = 'some_data_path.npy'def show_save(data, save=False):    data_multiclass = Volume(data, c='Set2', alpha=(0.1, 1), alphaUnit=0.87, mode=1)    data_multiclass.addScalarBar3D(nlabels=9)    show([(data_multiclass, "Multiclass teeth segmentation prediction")], bg='black', N=1, axes=1).close()    write(data_multiclass.isosurface(), 'some_name_.stl') if save else None    show_save(prediction, save=True)

Названия функций говорят сами за себя.

Пришло время увидеть конечный результат всего вышесказанного. Томить не буду:

model.summary()
model = UNet(dim=3, in_channels=1, out_channels=9, n_blocks=3, start_filters=9).to(device)print(summary(model, (1, 168*, 120, 120)))    """----------------------------------------------------------------        Layer (type)               Output Shape         Param #================================================================            Conv3d-1      [-1, 9, 168, 120, 120]            252              ReLU-2      [-1, 9, 168, 120, 120]              0       BatchNorm3d-3      [-1, 9, 168, 120, 120]             18            Conv3d-4      [-1, 9, 168, 120, 120]          2,196              ReLU-5      [-1, 9, 168, 120, 120]              0       BatchNorm3d-6      [-1, 9, 168, 120, 120]             18         MaxPool3d-7        [-1, 9, 84, 60, 60]               0         DownBlock-8  [[-1, 9, 84, 60, 60], [-1, 9, 168, 120, 120]]               0            Conv3d-9       [-1, 18, 84, 60, 60]           4,392             ReLU-10       [-1, 18, 84, 60, 60]               0      BatchNorm3d-11       [-1, 18, 84, 60, 60]              36           Conv3d-12       [-1, 18, 84, 60, 60]           8,766             ReLU-13       [-1, 18, 84, 60, 60]               0      BatchNorm3d-14       [-1, 18, 84, 60, 60]              36        MaxPool3d-15       [-1, 18, 42, 30, 30]               0        DownBlock-16  [[-1, 18, 18, 42, 30], [-1, 18, 84, 60, 60]]               0           Conv3d-17       [-1, 36, 42, 30, 30]          17,532             ReLU-18       [-1, 36, 42, 30, 30]               0      BatchNorm3d-19       [-1, 36, 42, 30, 30]              72           Conv3d-20       [-1, 36, 42, 30, 30]          35,028             ReLU-21       [-1, 36, 42, 30, 30]               0      BatchNorm3d-22       [-1, 36, 42, 30, 30]              72        DownBlock-23  [[-1, 36, 42, 30, 30], [-1, 36, 42, 30, 30]]               0  ConvTranspose3d-24       [-1, 18, 84, 60, 60]           5,202             ReLU-25       [-1, 18, 84, 60, 60]               0      BatchNorm3d-26       [-1, 18, 84, 60, 60]              36      Concatenate-27       [-1, 36, 84, 60, 60]               0           Conv3d-28       [-1, 18, 84, 60, 60]          17,514             ReLU-29       [-1, 18, 84, 60, 60]               0      BatchNorm3d-30       [-1, 18, 84, 60, 60]              36           Conv3d-31       [-1, 18, 84, 60, 60]           8,766             ReLU-32       [-1, 18, 84, 60, 60]               0      BatchNorm3d-33       [-1, 18, 84, 60, 60]              36          UpBlock-34       [-1, 18, 84, 60, 60]               0  ConvTranspose3d-35      [-1, 9, 168, 120, 120]          1,305             ReLU-36      [-1, 9, 168, 120, 120]              0      BatchNorm3d-37      [-1, 9, 168, 120, 120]             18      Concatenate-38     [-1, 18, 168, 120, 120]              0           Conv3d-39      [-1, 9, 168, 120, 120]          4,383             ReLU-40      [-1, 9, 168, 120, 120]              0      BatchNorm3d-41      [-1, 9, 168, 120, 120]             18           Conv3d-42      [-1, 9, 168, 120, 120]          2,196             ReLU-43      [-1, 9, 168, 120, 120]              0      BatchNorm3d-44      [-1, 9, 168, 120, 120]             18          UpBlock-45      [-1, 9, 168, 120, 120]              0           Conv3d-46      [-1, 9, 168, 120, 120]             90================================================================Total params: 108,036Trainable params: 108,036Non-trainable params: 0----------------------------------------------------------------Input size (MB): 3.96Forward/backward pass size (MB): 12170.30Params size (MB): 0.41Estimated Total Size (MB): 12174.66----------------------------------------------------------------    """

*Ввиду ещё большего уменьшения параметров сети(фильтр[9, 18, 36, 72]), удалось уместить объект в память видеокарты целиком - 9*(168, 120, 120)

6. After words

Думал, что закончил, а оказалось - только начал. Тут еще есть над чем поработать. Мне, в целом, 2 этап не нравится, хоть он и работает. Зачем заново переопределять каждый пиксель, когда мне нужен целый регион? А если, образно, есть 28 разделенных регионов, зачем мне пытаться определить их все, не проще ли определить один зуб и завязать это всё на "условный" ориентированный/неориентированный граф? Или вместо U-net использовать GCNN и вместо Pytorch - Pytorch3D? Пятна, думаю, можно убрать с помощью выравнивания данных внутри bounding box(ведь один зуб может принадлежать только 1 категории). Но, возможно, это вопросы для следующей публикации.

Прототип (набросок)
Тот самый "условный граф"
Пример неориентированного графа на 28 категорий с "разделителями"Пример неориентированного графа на 28 категорий с "разделителями"

Отдельное спасибо моей жене - Алёне, за особую поддержку во время этого "погружения в темноту".

Благодарю всех за внимание. Конструктивная критика и предложения, как исправлений, так и новых проектов - приветствуются.

Подробнее..

Azure Custom Vision без Azure, или где у них маска. Как мы распознавали маску на лице (и других частях тела)

26.01.2021 14:17:08 | Автор: admin

Среди набора примеров для Azure на GitHub был найден один очень интересный: распознавание образов на Raspberry Pi, в офлайне. Авторами предлагается подготовить модель машинного обучения в одном из облачных сервисов Azure, затем перенести ее на компьютер, у которого большую часть времени нет подключения к Интернет, после чего распознавание образов будет работать автономно. Разработчики подготовили проект для двух платформ: ARM32 (собственно Raspberry Pi) и AMD64 (но без поддержки веб-камеры).


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


Все сложно...


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


В Azure Computer Vision, например, даже не нужно обучать модель перед ее использованием: есть набор облачных API, уже готовый к применению. Для более сложных задач есть Azure Custom Vision, где мы сначала обучаем модель, потом ей пользуемся. Причем знание специфической математики практически не требуется, все делается прямо в браузере мышкой. Знание пары терминов из машинного обучения все-таки понадобится, чтобы понять, насколько качественно работает модель.


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


  • Поскольку вся логика находится в облаке, придется передавать туда-обратно много данных, даже если их передача не нужна;
  • Время реакции на распознавание будет зависеть от качества соединения и доступности облачных сервисов;
  • При пропадании соединения все решение перестает работать.

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


  • Для всего придется разрабатывать свой код и настраивать все вручную;
  • Непонятно, как впоследствии масштабировать решение. Что, если таких "турникетов" нужно 100 штук?

Что делать?


Итак, есть вариант "только облако", есть "совсем без облака". Оба варианта крайности. Было бы удобно взять лучшее от облачных сервисов, но "спустить их на землю". Например, однократно обучить модель на Azure Custom Vision, не углубляясь в вопросы математики, а затем использовать эту модель автономно, без подключения к Интернет.


Такое решение уже существует: Azure IoT Edge позволяет использовать предварительно экспортированную модель машинного обучения без постоянного подключения к Интернет. При этом мы получаем все преимущества и со стороны облака, и со стороны полностью наземного решения:


  • Будем передавать в облако только необходимую телеметрию;
  • Для обучения модели практически неограниченные объемы хранилища и вычислительной мощности;
  • Быстрый цикл управления, т.к. все решения принимаются локально, а не в облаке;

Azure IoT Edge и с чем его едят


Подробно мы описывали IoT Edge в этой статье. Вкратце, IoT Edge включает среду выполнения демон Linux, "внутри" которого выполняются модули, которые, в свою очередь, являются Docker-совместимыми контейнерами. Поддержка устройств IoT Edge включена в Azure IoT Hub. Среда выполнения IoT Edge единожды устанавливается на устройство, а затем набор модулей конфигурируется через облако, с портала Azure, после чего компьютер с IoT Edge может работать автономно, без подключения.


"Родной" платформой для IoT Edge по архитектурным причинам является Linux, хотя с 2019 года IoT Edge доступен и для Windows 10 Enterprise LTSC ОС для устройств специального назначения.


Модули IoT Edge могут содержать произвольный код, или в модули можно "обернуть" следующие службы Azure:



В нашем случае интерес представляет как раз Custom Vision. В этом сервисе мы подготовим модель распознавания маски и "обернем" ее в модуль.


В IoT Edge заложен механизм обмена сообщениями с между модулями и между модулями и облаком. Сами сообщения передаются в подобии текстового формата, поэтому такой механизм плохо подходит для обмена сырыми двоичными данными (картинками), поэтому в исходном примере от Microsoft для обмена картинками с камеры между модулями используется HTTP, т.е. один из обменивающихся данными модулей является web-сервером, а другой клиентом.


Пример распознавания изображений от Microsoft


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



На схеме обработка начинается с видеопотока с обычной веб-камеры, подключенной через USB к устройству, на котором предварительно установлена среда выполнения IoT Edge.


Откуда на этом устройстве возьмется среда IoT Edge? Ее необходимо установить вручную, и после того, как она установит подключение к IoT Hub, ей можно (и нужно) управлять уже с IoT Hub. Под "управлением" я также подразумеваю установку модулей.


Модули устанавливаются по команде с IoT Hub. Причем на IoT Edge "спускается" не сам модуль, а как бы ссылка на его скачивание. Механизм устроен так, что предварительно модули нужно выложить в Container Registry (это специальный репозиторий для хранения модулей).


Модули после установки взаимодействуют, как показано на следующей схеме, взятой из того же примера:



  • Camera: модуль захвата видео с камеры. Видео "нарезается" на картинки (не кадры! Из десятка кадров в обработку может уйти только один) и по HTTP передается в следующий модуль;
  • AI: модуль, содержащий обученную модель машинного обучения. Модель работает с отдельными изображениями, а не с видеопотоком, именно поэтому видео "нарезается" на картинки. Модель машинного обучения предварительно должна быть подготовлена в сервисе Custom Vision;
  • Display: модуль, отображающий результаты распознавания. Конкретно в рассматриваемом примере предлагается отличить банан от яблока, соответственно, на экране будет либо картинка яблока, либо банана (либо ничего).

Помимо всего прочего, телеметрия отсылается непосредственно в IoT Hub.


Если мы хотим использовать какую-то свою модель с данным примером, в описании к нему нам предлагается просто заменить модель на свою. Мы в Кварта Технологии проверили этот сценарий, но из-за ограниченности ресурсов Raspberry Pi работало такое решение ну очень медленно, так что на практике пользоваться им было очень неудобно.


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


Есть еще одна проблема. У компьютера на базе AMD64 нет "экранчика" SenseHat, как в Raspberry Pi, поэтому так элегантно, как на SenseHat, результат распознавания уже не отобразить, и придется придумывать что-то свое.


Обучение модели (Custom Vision)


Заходим на Custom Vision (понадобится подписка Azure) и нажимаем New Project.


  • Name вводим произвольное имя;
  • Description произвольное описание;
  • Resource нажимаем Create new и создаем новый ресурс типа Cognitive Services;
  • Classification Types выбираем Multilabel, так как мы будем определять не только наличие/отсутствие маски, но и где именно она надета на лице или на теле (гуглим "бикини из масок")
  • Domains General (compact).

После создания проекта загружаем предварительно подготовленные картинки следующих типов:


  • Люди в маске на лице, размечаем тегом MaskOnFace;
  • Люди в маске не на лице ("бикини из масок"), размечаем тегом MaskNotOnFace на волне маскобикини-хайпа используем и такой случай, чтобы наша модель машинного обучения не пропускала хитрых фитоняш в маскобикини через турникет (наверное, стоящий на входе в фитнес-клуб, где они качаются);
  • Люди без масок, изображения масок без людей, не размечаем тегами (Negative).

Картинки можно найти в интернете или пофоткать всех знакомых.


Нажимаем кнопку Train, далее Advanced training и ждем, пока модель обучается. После чего на вкладке Performace можно посмотреть параметры качества функционирования модели.


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


Значение Probability Threshold это вероятность назначенного тега, в зависимости от которой рассчитываются параметры качества функционирования модели. Значение Probability Threshold = 90% означает, что правильными предсказаниями будут считаться теги, вероятность которых оказалась выше 90%. Думаю, эта фраза требует пояснения. Вообще, в машинном обучении ответы обычно не дискретные (да/нет), то есть модель не может "ответить" на вопрос "на лице ли у человека маска" просто "да" или "нет". Грубо говоря, будет что-то типа "маска на лице с вероятностью 89%". Проблема в том, что для расчета параметров качества модели нужны как раз те самые дискретные "да" или "нет". И вот если параметр этот самый Probability Threshold установлен 90%, то ответ "маска на лице с вероятностью 89%" превратится в дискретное "нет", а ответ "маска на лице с вероятностью 91%" превратится в дискретное "да".


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


  • Параметр Recall (полнота) означает способность алгоритма обнаруживать заданный класс вообще;
  • Параметр Precision (точность) способность отличать этот класс от других классов.

Чем больше каждое из значений тем лучше.


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


Модель следует опубликовать (Publish), а затем экспортировать (Export Dockerfile Linux). Полученный файл далее будет использоваться в модуле IoT Edge.


Подготовка наземной платформы


Из-за архитектурных особенностей примера нам понадобится платформа (а именно процессор) с поддержкой AVX инструкций (узнать, поддерживает ли платформа AVX инструкции, можно, выполнив команду grep avx /proc/cpuinfo) это потребуется для корректной работы библиотеки tensorflow. Если вы хотите использовать платформу без поддержки AVX, потребуется пересобрать библиотеку, что выходит за рамки данной статьи. Упростим себе жизнь и возьмем платформу с соответствующей поддержкой. Мы использовали Intel NUC на базе Core i5.


В качестве ОС будем использовать Ubuntu 20.04 LTS. Сложность заключается в том, что инструкций для установки IoT Edge для данной версии нет (слишком свежая по мнению Microsoft?), поэтому о процессе настройки расскажем достаточно подробно.


Вначале установите саму ОС с графическим окружением, затем откройте командную строку для установки Edge. По умолчанию все команды выполняются в домашнем каталоге (~).


Установим необходимые утилиты:


sudo apt-get updatesudo apt-get install wget nano

Устанавливаем конфигурацию репозитория:


wget https://packages.microsoft.com/config/ubuntu/20.04/prod.listmv prod.list microsoft-prod.list

Копируем полученный файл в директорию sources.list.d, чтобы ОС "видела" репозитории Microsoft:


sudo cp ./microsoft-prod.list /etc/apt/sources.list.d/

Загружаем и устанавливаем публичный ключ Microsoft GPG:


wget https://packages.microsoft.com/keys/microsoft.asccat microsoft.asc | gpg --dearmor > microsoft.gpgsudo cp ./microsoft.gpg /etc/apt/trusted.gpg.d/

Устанавливаем ПО для контейнеризации (обратим внимание на первую команду ее обязательно нужно выполнить, так как мы добавили новые репозитории и apt еще "не в курсе"):


sudo apt-get updatesudo apt-get install moby-engine

Устанавливаем демон IoT Edge:


sudo apt-get install iotedge

И с удивлением обнаруживаем, что такого пакета в добавленных выше репозиториях нет (это касается только нашей Ubuntu 20.04 на момент публикации статьи), поэтому вместо следования документации пойдем своим путем. Можем добавить репозитории от более ранней версии Ubuntu (18.04 что, строго говоря, не очень хорошая идея) или установить пакеты вручную (правда, опять же, от более ранней версии). Пойдем вторым путем и установим нужные пакеты с GitHub. Ищем Latest release и устанавливаем его при помощи dpkg. Нам также понадобится libssl определенной версии:


wget http://archive.ubuntu.com/ubuntu/pool/main/o/openssl1.0/libssl1.0.0_1.0.2n-1ubuntu5_amd64.debsudo dpkg -i libssl1.0.0_1.0.2n-1ubuntu5_amd64.debwget https://github.com/Azure/azure-iotedge/releases/download/1.0.10.4/libiothsm-std_1.0.10.4-1_ubuntu16.04_amd64.debsudo dpkg -i libiothsm-std_1.0.10.4-1_ubuntu16.04_amd64.debwget https://github.com/Azure/azure-iotedge/releases/download/1.0.10.4/iotedge_1.0.10.4-1_ubuntu16.04_amd64.debsudo dpkg -i iotedge_1.0.10.4-1_ubuntu16.04_amd64.deb

Среда выполнения IoT Edge установлена, но она пока не подключена к Azure. Чтобы это сделать, наберем команду:


sudo nano /etc/iotedge/config.yaml

И в открывшемся файле увидим, что для подключения к Azure необходимо указать значение device_connection_string в разделе provisioning. Для того, чтобы получить это значение, зарегистрируем наш экземпляр IoT Edge в Azure.


Настройки в Azure


Все экземпляры IoT Edge подключаются к IoT Hub, который мы сейчас и создадим на портале Azure. Если у вас нет подписки создайте пробную.


Сама процедура создания IoT Hub и регистрации IoT Edge подробно описана в документации. Особого смысла перепечатывать ее здесь не вижу, поэтому предлагаю вам повторить то, что в ней описано:



В результате ваше устройство IoT Edge будет подключено к созданному IoT Hub, а также будет подготовлен репозиторий контейнеров.


Разработка


Внутри IoT Edge нашего решения будут исполняться сразу три модуля:


  • Модуль захвата изображения с камеры. Работает напрямую с веб-камерой, подключенной по USB. Отдает картинку на следующий модуль по HTTP;
  • Модуль машинного обучения, распознающий изображения и классифицирующий их (в маске/без маски/маска не на лице). Получает картинку по HTTP, отдает результат распознавания в следующий модуль путем обмена сообщениями;
  • Модуль отображения (веб-сервер). Отдает по запросу веб-страницу, на которой можно увидеть видео с камеры и результат распознавания.

Схема коммуникаций между модулями показана ниже:



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


Важно! Последующие действия нужно выполнять на компьютере с Windows 10, а не на устройстве с IoT Edge.

  • Загрузите и установите Visual Studio Code. Разработку также можно вести и в Visual Studio;
  • Установите расширение Azure IoT Edge Extension. После установки на открывшейся странице нажмите Select IoT Hub, пройдите аутентификацию и выберите созданный ранее IoT Hub;
  • Установите расширение Azure IoT Tools;
  • Установите Python 3.8.3. При установке отметьте опцию Add Python to PATH подробнее здесь в секции Task 4;
  • Перейдите в Панель управления, затем Программы и компоненты, и убедитесь, что включена вся группа Hyper-V и Контейнеры. Если нет отметьте их, нажмите OK и при необходимости перезагрузите компьютер;
  • Установите Docker for Windows подробнее там же в секции Task 5;
  • Установите клиент Git для Windows последней доступной версии.

Важно! В случае проблем с виртуализацией Docker выполните команду в командной строке администратора: bcdedit /set {current} hypervisorlaunchtype Auto и перезагрузите компьютер.

Важно! Включение Hyper-V сделает неработоспособной виртуализацию VirtualBox. Для того, чтобы быстро восстановить работоспособность VirtualBox, можно выполнить команду в командной строке администратора: bcdedit /set {current} hypervisorlaunchtype off, но при этом "сломается" Hyper-V и Docker.

Получите исходный код примера, выполнив команду:


git clone https://github.com/Azure-Samples/Custom-vision-service-iot-edge-raspberry-pi.git

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


git checkout 6b3540f9b31121321f9e75d8df0ed86397c9324f

В Visual Studio Code откройте папку с примером (File Open Folder) и ознакомьтесь с его структурой. Вы увидите три модуля, о которых мы говорили выше. Если при открытии среда предложит установить дополнительные расширения, сделайте это.


Модификация примера


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


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


Итак, поехали.


.env


Необходимо установить параметры реестра (репозитория) контейнеров, взятые с портала Azure:


  • CONTAINER_REGISTRY_ADDRESS="имя_реестра_контейнеров.azurecr.io"
  • CONTAINER_REGISTRY_USERNAME="имя_пользователя"
  • CONTAINER_REGISTRY_PASSWORD="пароль"

deployment.template.json


  • modules camera-capture env RESIZE_WIDTH установить 640, RESIZE_HEIGHT установить 480
  • modules camera-capture settings image установить ${MODULES.CameraCapture.amd64}
  • modules sensehat-display env THRESHOLD value установить 0.9
  • modules sensehat-display settings image установить ${MODULES.SenseHatDisplay.amd64}, createOptions удалить содержимое HostConfig и установить "PortBindings": { "8000/tcp": [ { "HostPort": "8000" } ] }
  • modules image settings image установить ${MODULES.ImageClassifierService.amd64}

deployment.template_AMD64


Данный файл следует удалить с файловой системы.


СameraCapture\amd64.Dockerfile


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


CameraCapture.py


Поскольку мы перенесли решение на более мощную платформу (по сравнению с Raspberry Pi), уменьшим временной интервал, за который накапливаются кадры для анализа. Для этого ищем строку time.sleep(1.0) и заменяем 1.0 на 0.1.


ImageClassifierService\amd64.Dockerfile


Здесь потребуется подобрать версии библиотек для AMD64. Модифицированный файл можно найти в приложенном архиве.


labels.txt


Данный файл содержит метки, которые получает каждое изображение. Эти метки должны соответствовать меткам из сервиса Custom Vision, поэтому содержимое файла должно быть следующим:


MaskNotOnFaceMaskOnFace

model.pb


Двоичный файл модели из Custom Vision следует заменить существующий на подготовленный нами ранее.


SenseHatDisplay\amd64.Dockerfile


Такого файла в примере нет, нам нужно его создать.


SenseHatDisplay модуль отображения результата распознавания. Но на нашей платформе AMD64 никакого шилда SenseHat нет, поэтому из данного модуля весь код, взаимодействующий, собственно, с SenseHat, уберем, и будем реализовывать простейший веб-сервер на Python, который сможет прямо в браузере показывать результат распознавания.


Файл можно найти в приложенном архиве.


SenseHatDisplay\module.json


В раздел platform нужно внести изменения, а именно добавить amd64:


"amd64": "./amd64.Dockerfile",

SenseHatDisplay\app (директория)


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


  • custom.js
  • index.htm
  • jquery.js
  • style.css
  • DisplayManager.py
  • MessageParser.py

Веб-сервер достаточно "хитрый". Он отдает клиенту страницу, в которой выполняются сразу две важных вещи:


  • Отображается видеопоток от модуля захвата изображения с камеры: его можно увидеть прямо с компьютера с IoT Edge, зайдя браузером на адрес http://127.0.0.1:5012;
  • Отображается результат распознавания от модуля классификатора по адресу http://127.0.0.1:8000/status.

Подчеркну, что фактически получаются два веб-сервера один с видеопотоком, второй с результатом распознавания. Причем страницу, с которой идет обращение к этим серверам, отдает второй сервер. Сама эта страница доступна по адресу http://127.0.0.1:8000.


Сборка


Для сборки потребуется подключение к Интернет.


  • В VS Code выберите View Command Pallette Azure IoT Edge: Set Default Target Platform for IoT Edge Solution и в появившемся списке выберите amd64;
  • Там же выберите команду Azure IoT Edge: Build and Push IoT Edge Solution. Все должно собраться с первого раза и загрузиться в реестр контейнеров, но этого еще недостаточно для попадания на устройство IoT Edge;
  • Разверните решение на IoT Edge, кликнув правой кнопкой на файл config/deployment.json и выбрав Create Deployment for Single device, затем выберите ваше устройство, указав его имя;
  • На развертывание потребуется некоторое время (не забудьте подключить к платформе веб-камеру!). Можете кликнуть на ваше устройство правой кнопкой мыши в расширении IoT Edge Extension и выбрать Start Monitoring D2C Message, тем самым наблюдать телеметрию.

Момент истины


На нашем IoT Edge устройстве логинимся в UI и прямо браузером заходим на веб-страницу по адресу http://localhost:8000, где наблюдаем веб-страницу, как на картинке. Надеваем маску и смотрим в камеру, система определяет, что маска на нас.



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


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


Если что-то пошло не так...


Если модули собрались и отлажены, а устройство в сети, вмешательство на стороне IoT Edge не требуется. Но иногда на этапе запуска возникают проблемы (так я, например, узнал, что без AVX инструкций и/или пересборки библиотеки tensorflow модуль классификатора не работает). IoT Edge предлагает разные способы диагностики. Подробно о них можно прочитать здесь. Самое главное, что может пригодиться:


  • Получить список модулей и их статус: iotedge list. В примере на рисунке видно выполняющиеся модули и их аптайм (более недели);
  • Журнал модуля: iotedge logs имя_модуля. Позволяет понять, что конкретно происходит с модулем.

Обновление модулей


Для того, чтобы обновить модуль на устройстве IoT Edge, нужно либо инкрементировать его версию при публикации, либо удалить существующий и его файлы из реестра модулей (на портале Azure), иначе IoT Edge не увидит изменения.


Что дальше?


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


При всем перечисленном мы, конечно, не рассмотрели всех возможностей IoT Edge, например, мы никак не обработали телеметрию на облаке, а могли бы вести, например, журнал "проходов через турникет" и считать какую-нибудь статистику и даже сохранять ее локально при помощи SQL Edge, а затем синхронизировать с облаком.


Мы также совсем не рассмотрели возможности управления устройством IoT Edge с портала Azure, так как это было незначимо для темы данной статьи.


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


Мы убедились, что при помощи сервисов Azure можем комбинировать все плюсы как облачной, так и наземной разработки, и при этом получать решение, которое без проблем будет работать автономно.


Если у вас еще остались вопросы по облачным технологиям Microsoft и Интернету вещей обращайтесь к нам в Кварта Технологии.


Файлы к статье можно скачать по ссылке.


Автор статьи Сергей Антонович, ведущий инженер Кварта Технологии. Связаться с ним можно по адресу sergant (at) quarta.ru.

Подробнее..

Управляем звуком ПК от активности пользователя с помощью Python

17.06.2021 14:15:17 | Автор: admin

Настройка программного обеспечения

Без промедления начнём. Нам нужно установить следующее ПО:

  • Windows 10

  • Anaconda 3 (Python 3.8)

  • Visual Studio 2019 (Community) - объясню позже, зачем она понадобится.

Открываем Anaconda Prompt (Anaconda3) и устанавливаем следующие пакеты:

pip install opencv-pythonpip install dlibpip install face_recognition

И уже на этом моменте начнутся проблемы с dlib.

Решаем проблему с dlib

Я перепробовал все решения, что нашёл в интернете и они оказались неактуальными - раз, два, три, официальное руководство и видео есть. Поэтому будем собирать пакет вручную.

Итак, первая же ошибка говорит о том, что у нас не установлен cmake.

ERROR: CMake must be installed to build dlib
ERROR: CMake must be installed to build dlibERROR: CMake must be installed to build dlib

Не закрывая консоль, вводим следующую команду:

pip install cmake
Проблем при установке быть не должно

Пробуем установить пакет той же командой (pip install dlib), но на этот раз получаем новую ошибку:

Отсутствуют элементы Visual Studio

Ошибка явно указывает, что у меня, скорее всего, стоит студия с элементами только для C# - и она оказывается права. Открываем Visual Studio Installer, выбираем "Изменить", в вкладке "Рабочие нагрузки" в разделе "Классические и мобильные приложения" выбираем пункт "Разработка классических приложений на С++":

Пошагово
"Изменить""Изменить"Разработка классических приложений на С++Разработка классических приложений на С++Ждем окончания установкиЖдем окончания установки

Почему важно оставить все галочки, которые предлагает Visual Studio. У меня с интернетом плоховато, поэтому я решил не скачивать пакет SDK для Windows, на что получил следующую ошибку:

Не нашли компилятор

Я начал искать решение этой ошибки, пробовать менять тип компилятора (cmake -G " Visual Studio 16 2019"), но только стоило установить SDK, как все проблемы ушли.

Я пробовал данный метод на двух ПК и отмечу ещё пару подводных камней. Самое главное - Visual Studio должна быть 2019 года. У меня под рукой был офлайн установщик только 2017 - я мигом его поставил, делаю команду на установку пакета и получаю ошибку, что нужна свежая Microsoft Visual C++ версии 14.0. Вторая проблема была связана с тем, что даже установленная студия не могла скомпилировать проект. Помогла дополнительная установка Visual C++ 2015 Build Tools и Microsoft Build Tools 2015.

Открываем вновь Anaconda Prompt, используем ту же самую команду и ждём, когда соберется проект (около 5 минут):

Сборка
Всё прошло успешноВсё прошло успешно

Управляем громкостью

Вариантов оказалось несколько (ссылка), но чем проще - тем лучше. На русском язычном StackOverflow предложили использовать простую библиотеку от Paradoxis - ей и воспользуемся. Чтобы установить её, нам нужно скачать архив, пройти по пути C:\ProgramData\Anaconda3\Lib и перенести файлы keyboard.py, sound.py из архива. Проблем с использованием не возникало, поэтому идём дальше

Собираем события мыши

Самым популярным модулем для автоматизации управления мышью/клавиатурой оказался pynput. Устанавливаем так же через (pip install dlib). У модуля в целом неплохое описание - https://pynput.readthedocs.io/en/latest/mouse.html . Но у меня возникли сложности при получении событий. Я написал простую функцию:

from pynput import mousedef func_mouse():        with mouse.Events() as events:            for event in events:                if event == mouse.Events.Scroll or mouse.Events.Click:                    #print('Переместил мышку/нажал кнопку/скролл колесиком: {}\n'.format(event))                    print('Делаю половину громкости: ', time.ctime())                    Sound.volume_set(volum_half)                    break

Самое интересное, что если раскомментировать самую первую строчку и посмотреть на событие, которое привело выходу из цикла, то там можно увидеть Move. Если вы заметили, в условии if про него не слово. Без разницы, делал я только скролл колесиком или только нажатие любой клавиши мыши - все равно просто движение мыши приводит к выходу из цикла. В целом, мне нужно все действия (Scroll, Click, Move), но такое поведение я объяснить не могу. Возможно я где-то ошибаюсь, поэтому можете поправить.

А что в итоге?

Adam Geitgey, автор библиотеки face recognition, в своём репозитории имеет очень хороший набор примеров, которые многие используют при написании статей: https://github.com/ageitgey/face_recognition/tree/master/examples

Воспользуемся одним из них и получим следующий код, который можно скачать по ссылке: Activity.ipynb, Activity.py

Код
# Подключаем нужные библиотекиimport cv2import face_recognition # Получаем данные с устройства (веб камера у меня всего одна, поэтому в аргументах 0)video_capture = cv2.VideoCapture(0) # Инициализируем переменныеface_locations = []from sound import SoundSound.volume_up() # увеличим громкость на 2 единицыcurrent = Sound.current_volume() # текущая громкость, если кому-то нужноvolum_half=50  # 50% громкостьvolum_full=100 # 100% громкостьSound.volume_max() # выставляем сразу по максимуму# Работа со временем# Подключаем модуль для работы со временемimport time# Подключаем потокиfrom threading import Threadimport threading# Функция для работы с активностью мышиfrom pynput import mousedef func_mouse():        with mouse.Events() as events:            for event in events:                if event == mouse.Events.Scroll or mouse.Events.Click:                    #print('Переместил мышку/нажал кнопку/скролл колесиком: {}\n'.format(event))                    print('Делаю половину громкости: ', time.ctime())                    Sound.volume_set(volum_half)                    break# Делаем отдельную функцию с напоминаниемdef not_find():    #print("Cкрипт на 15 секунд начинается ", time.ctime())    print('Делаю 100% громкости: ', time.ctime())    #Sound.volume_set(volum_full)    Sound.volume_max()        # Секунды на выполнение    #local_time = 15    # Ждём нужное количество секунд, цикл в это время ничего не делает    #time.sleep(local_time)        # Вызываю функцию поиска действий по мышке    func_mouse()    #print("Cкрипт на 15 сек прошел")# А тут уже основная часть кодаwhile True:    ret, frame = video_capture.read()        '''    # Resize frame of video to 1/2 size for faster face recognition processing    small_frame = cv2.resize(frame, (0, 0), fx=0.50, fy=0.50)    rgb_frame = small_frame[:, :, ::-1]    '''    rgb_frame = frame[:, :, ::-1]        face_locations = face_recognition.face_locations(rgb_frame)        number_of_face = len(face_locations)        '''    #print("Я нашел {} лицо(лица) в данном окне".format(number_of_face))    #print("Я нашел {} лицо(лица) в данном окне".format(len(face_locations)))    '''        if number_of_face < 1:        print("Я не нашел лицо/лица в данном окне, начинаю работу:", time.ctime())        '''        th = Thread(target=not_find, args=()) # Создаём новый поток        th.start() # И запускаем его        # Пока работает поток, выведем на экран через 10 секунд, что основной цикл в работе        '''        #time.sleep(5)        print("Поток мыши заработал в основном цикле: ", time.ctime())                #thread = threading.Timer(60, not_find)        #thread.start()                not_find()        '''        thread = threading.Timer(60, func_mouse)        thread.start()        print("Поток мыши заработал.\n")        # Пока работает поток, выведем на экран через 10 секунд, что основной цикл в работе        '''        #time.sleep(10)        print("Пока поток работает, основной цикл поиска лица в работе.\n")    else:        #все хорошо, за ПК кто-то есть        print("Я нашел лицо/лица в данном окне в", time.ctime())        Sound.volume_set(volum_half)            for top, right, bottom, left in face_locations:        cv2.rectangle(frame, (left, top), (right, bottom), (0, 0, 255), 2)        cv2.imshow('Video', frame)        if cv2.waitKey(1) & 0xFF == ord('q'):        breakvideo_capture.release()cv2.destroyAllWindows()

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

Тестирование в бою

Ожидание и реальность

Если вы посмотрели видео, то поняли, что результат ещё далёк от реальной эксплуатации.

Признаю честно - до этого момента никогда не сталкивался с многопоточностью на Python, поэтому "с наскоку" тему взять не удалось и результат по видео понятен. Есть неплохая статья на Хабре, описывающая различные методы многопоточности, применяемые в языке. Пока у меня решения нету по этой теме нету - будет повод разобраться лучше и дописать код/статью с учетом этого.

Так же возникает закономерный вопрос - а если вместо живого человека поставить перед монитором картинку? Да, она распознает, что, скорее всего, не совсем верно. Мне попался очень хороший материал по поводу определения живого лица в реальном времени - https://www.machinelearningmastery.ru/real-time-face-liveness-detection-with-python-keras-and-opencv-c35dc70dafd3/ , но это уже немного другой уровень и думаю новичкам это будет посложнее. Но эксперименты с нейронными сетями я чуть позже повторю, чтобы тоже проверить верность и повторяемость данного руководства.

Немаловажным фактором на качество распознавания оказывает получаемое изображение с веб-камеры. Предложение использовать 1/4 изображения (сжатие его) приводит только к ухудшению - моё лицо алгоритм распознать так и не смог. Для повышения качества предлагают использовать MTCNN face detector (пример использования), либо что-нибудь посложнее из абзаца выше.

Другая интересная особенность - таймеры в Питоне. Я, опять же, признаю, что ни разу до этого не было нужды в них, но все статьях сводится к тому, чтобы ставить поток в sleep(кол-во секунд). А если мне нужно сделать так, чтобы основной поток был в работе, а по истечению n-ое количества секунд не было активности, то выполнялась моя функция? Использовать демонов (daemon)? Так это не совсем то, что нужно. Писать отдельную программу, которая взаимодействует с другой? Возможно, но единство программы пропадает.

Заключение

На своём примере могу точно сказать - не все руководства одинаково полезны и простая задача может перерасти в сложную. Я понимаю, что большинство материалов является переводом/простым копированием документации. Но если ты пишешь руководство, то проверь хотя бы на тестовой системе, что написанные тобой действия точно повторимы. Пробуйте, экспериментируйте - это хороший повод изучить и узнать новое.

P.S. Предлагаю вам, читатели, обсудить в комментариях статью - ваши идеи, замечания, уточнения.

Подробнее..

Первое место на AI Journey 2020 Digital Петр

27.12.2020 16:14:22 | Автор: admin
Приветъ ХабрПриветъ Хабр

Всем добрейшего дня! Совсем недавно закончилось ежегодное международное соревнование AI Contest, организатором которого является Сбер вместе с российскими и зарубежными партнёрами в рамках конференции Artificial Intelligence Journey. Задачи этого года: Digital Петр: распознавание рукописей Петра I, NoFloodWithAI: паводки на реке Амур и AI 4 Humanities: ruGPT-3. В этот раз в соревновании участвовало около 1000 человек из 43 государств.

Наша команда приняла участие в решении задачи "Digital Петр: распознавание рукописей Петра I" и заняла первое место. Я бы хотел рассказать о том, что мы наворотили в процессе решения соревнования, кто тут батя, какие трюки и фишки использовали. Информации много, будет много спецэфичных слов, для тех кто не в теме. Это не туториал, очень подробно я описывать не буду, но с удовольствием отвечу на вопросы в комментариях.

Можете посмотреть на команду мечты

План

Описание задачи

Формат данных, доступные ресурсы и ограничения

Если без воды, то: Необходимо перевести строку, написанную от руки Петром I, в печатный формат (см. пример ниже). Организаторы совместно с историками подготовили данные, разбив документы построчно, где каждая строка - картинка и ей соответствует текстовый файл с расшифровкой.

Примеры. Текст от руки и печатный аналог

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

Этапы решения

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

1. Предобработка данных

Выбросили картинки, на которых очевидно не верна разметка (нашли с помощью OOF), удалили редкие символы. Так как Пётр писал не только горизонтально (как на картинках выше), но и на полях (как на картинке ниже), то в данных присутствовали картинки с вертикальными надписями, которые нужно было перевернуть либо на +90, либо на -90 градусов. Для поворота картинок мы обучили сеть (Resnet34 с изменённой головой) которая предсказывала есть ли необходимость поворачивать картинку и в какую сторону. Это необходимо для того, чтобы поворачивать картинки на скрытых данных.

Пример вертикальной картинки

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

2. Описание нейронной сети

Мы рассматривали два варианта архитектур нейронных сетей, одну под CTCLoss и другую на классическом Attention. Отдельно про CTCLoss можно посмотреть тут, а про Attention почитать тут. Начали с CTCLoss, но на нём и остались, так как на подход с Attention не хватило времени. Сразу покажу картинку.

Где Bs - размер батча, (w, h, c) - параметры изображения (ширина, высота, каналы). Штрихи указывают на производные параметры от исходных. Hidden size - размер скрытого слоя в LSTM слое. Dict Size - количество буковок, которые будет знать наша нейронка. Dense - слой полносвязной сети в Keras, аналог Linear в PyTorch.

3. Аугментации

Что такое аугментации, как их применять можно посмотреть тут и тут. Мы использовали стандартные аугментации: ToGray, CLAHE, Rotate, CutOut.

Однако CutOut мы в середине соревнования заменили на другую аугментацию. Мы написали её сами, назвали HandWrittenBlots, суть в том, что это имитация человеческой почеркушки с различным размером, наклоном и прозрачностью. Сделано это для того, чтобы улучшить (кто бы мог подумать) распознавание перечёркнутых букв. CutOut накидывал, HandWrittenBlots накинул еще больше. Аугментацию можно найти в репозиторииAugmixations. Пример использования тут.

P.S. Форма вырезанных прямоугольников в CutOut тут такая потому, что параметры были подобраны эмпирически и вертикальные тонкие прямоугольники докидывали больше всего.

4. CharMasks

Это крутая штука, которая возможна, когда используешь CTC Loss. Дело в том, после предсказания моделью последовательности символов, есть возможность разбить входную картинку по этим символам, пропорционально размеру выходной последовательности (руками разбивать картинки конечно тоже можно, но это совсем прохладная история). Для этого нужно использовать координаты стыков различных букв (Именно так делали ребята для Action Labeling тут).

Таким образом мы получаем координаты начала и конца каждого символа. А затем, имея координаты всех символов во всех строках датасета, мы можем сами генерировать любую фразу почерком Петра. Вопрос остаётся в том, что нам пока негде брать древнерусские фразочки. Поэтому мы идём в гугл и нагугливаем книжки с текстами XVII-XVIII веков (Да, их тоже надо почистить и обработать). И уже теперь, с чистой совестью, достаём любую фразу из нашего корпуса текстов и составляем по ней картинку.

всемъ спасибо за соревъ piterвсемъ спасибо за соревъ piter

Да, она будет не идеальна, и если генерировать изображения, используя однобуквенные символы, то скор это не улучшит. Но тут в дело вступают токенизаторы. Обучаем мы их на предложениях из имеющегося датасета, в котором у нас есть соответствующие картинки. Таким образом для каждого токена мы теперь можем получить картинку. Далее с помощью токенизаторов (Multi Word Expression) мы можем составлять фразы из токенов (состоящих из нескольких символов и пробелов) различной длины и генерировать соответствующую картинку.

всемъ спасибо за соревъ piterвсемъ спасибо за соревъ piter

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

всемъ спасибо за соревъ piterвсемъ спасибо за соревъ piter

5. Spell correction using XLMRoberta

Сразу скажу, что в этом пункте много текста.

Естественно, нашасупермегапаверфьюженстелскрутаямодель не предсказывает идеальные предложения и всё же делает некоторые ошибки(особенно пробелы, ненавижу пробелы). И совершенно случайно в наши ряды затисались эксперты NLP. Ну они и обучили буквенную языковую модель XLMRoberta на корпусе XVII-XVIII в.в., а затем реализовали модель исправления опечаток в стиле Петра I. Делали следующее:

1. из сырого выхода OCR модели (перед тем как схлопнуть повторяющиеся символы и паддинги) склеивали повторяющиеся символы (включая паддинг) и пересчитывали их вероятности (среднее + softmax), брали 3 наиболее вероятные символа (буквы/цифры/blank в т.ч.) для каждой позиции в тексте;

2. каждую локальную позицию проверяли и исправляли так: давали 3-4 варианта модели, а она выбирала наиболее правильный - т.к. символы были буквы/цифры/blank, то таким образом мы боролись как с расстановкой пробелов, так и с другими видами опечаток с учетом контекста. Также с помощью данного подхода легко реализовать zero-shot learning, где предсказываются символы, которых не было в исходном датасете. Так мы накинули варианты похожих с точки зрения OCR латинских и кириллических букв ('р': 'p', 'о': 'o', 'е': 'e', 'с': 'c', 'а': 'a', 'х': 'x', 'и': 'u', 'к': k);

3. сортировали все локальные позиции по уверенности OCR модели и исправляли по одной step by step (!), что позволило улучшить и главное не испортить следующие предикты на более уверенных позициях;

4. обучали модель так: маскировали буквы (рандомно от 0 до 12), 50% масок превращали в padding (борьба с наличием лишних символов), 10% оставшихся букв заменяли на рандомный символ в тч и. паддинг (для стабилизации предикта). пытались предсказать маскированные буквы на фичах XLMRoberta из outputhiddenstates - почти как NER, но классификация на все заданные символы;

5. на GPU данная модель учится довольно долго, поэтому мы юзали TPU на Colab

P.S.
После завершения соревнования мы узнали (один из участников опубликовал своё решение), что в этой задаче можно было применить BeamSearch. Реализация которого есть тут.

6. Ensemble + Spell Correction Thresholds

Думаю, что многие в соревновании столкнулись с тем, что модели, обученные с помощью CTCLoss, нельзя так просто заблендить. А ансамбль это крутая штука и хотелось бы его использовать. Поэтому немногопокурив бамбукподумав, мы пришли к своеобразному ансамблю.Представим что у нас есть N моделей и у всех мы сделали предикт и нам остаётся только "схлопнуть" повторяющиеся буквы для того чтобы получилось чистое предложение. Мы проделываем данную операцию, но не только с символами но и с их вероятностями, усредняя их. Таким образом получаем среднюю вероятность каждого символа. И теперь, итерируясь по всем моделям, бёрем только те предикты, средняя вероятность которых наиболее высокая.Надеюсь, что +- понятно описал.

Что не сработало

Other Backbones. Мы ставили эксперименты с кучей других бекбонов и доп блоками (EfficientNet, [SE, ECA]ResNet[xt], Mobilenet и др), но на удивление лучше всего заходит классический Resnet34.

Augmentations.Перепробовали практически весь набор аугментаций из всеми нами любимогоAlbumentations (Brightness, Gamma, Blur и др), остались только те, что я указал выше.

TTA (Test-Time Augmentations).Интересно то, что на нашей holdout выборке ТТА давал прирост, а на public test - нет. Мы решили верить паблик тесту, так как там выборка заметно больше нашей на holdout.

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

Команда

Все четверо из команды работаем в компании ОЦРВ в лаборатории искусственного интеллекта и нейронных сетей в городе Сириус (Сочи). Спасибо ребятам, что продержались до конца и показали отличный результат! :)

Информация каждом члене команды

Карачёв Денис (github, linkedin, kaggle)

Шоненков Алексей (github, linkedin, kaggle)

Смолин Илья (github, linkedin, kaggle)

Новопольцев Максим (linkedin, kaggle)

Заключение

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

P.S.Наше самое быстрое решение (одна модель, public):
| CER: 2.531 | WER: 13.5 | ACC: 62.107 | TIME: 32s |
Код submission и веса опубликованы здесь.

P.P.S. Бонус

Особо пытливым предлагаю разгадать ребус, что же здесь написано? :)

Подробнее..

Играем с CLIP. Создаем универсальный zero-shot классификатор на Android

13.03.2021 14:14:35 | Автор: admin

TLDR: приложение можно скачать и потестить тут

Ссылка на Google Play

Эта статья является дополненной и сильно расширенной версией моей статьи в TowardsDataScience о создании приложения, использующем новейшую мультимодальную нейросеть от OpenAI

В чем проблема классификаторов?

Многие заметили, что в последние годы все чаще для обработки изображений используется нейросетевой подход. Одной из простейших (по формулировке) задач является задача классификации изображений. В ней необходимо определить, к какому из заданных классов относится изображение. Стандартный подход с использованием сверточных нейросетей предполагает использование большого количества последовательных преобразований - сверток, с добавлением простых нелинейных функций, в результате которых изображение превращается в многомерный набор признаков. Далее эти признаки анализируются полносвязной нейросетью. Для обучения подобной нейросети обычно требуется большое количество обучающих примеров - размеченных изображений и сбор данных для конкретной задачи может являться наиболее трудоемким этапом для решения задачи классификации. Чтобы сократить количество необходимых размеченных данных, обычно используется подход переноса обучения (transfer learning). Для этого в качестве сверточной части используют сеть, предварительно обученную для решения задачи классификации на большом датасете (обычно ImageNet). Использование предобученной части позволяет выделять значимые признаки на любом изображении. Далее используется небольшая полносвязная сеть для решение задачи классификации. Использование такого подхода позволяет снизить размер обучающей выборки до нескольких тысяч или даже сотен примеров каждого класса. Тем не менее у вышеописанного подхода есть два существенных недостатка:

  • Если к сети, обученной определять 1000 классов необходимо добавить еще один класс, нужно существенно менять архитектуру сети и заново переобучать ее с использованием полного датасета, а не только изображений из добавленных классов (есть трюки, чтоб ослабить эту проблему но они не сильно меняют общее положение дел).

  • Для некоторых задач сбор данных может быть очень затруднительным

Поэтому чашей грааля для решения задачи классификации является реализации концепции Zero shot learning - создание классификатора, способного решить произвольную задачу классификации без обучающих примеров. Звучит немного фантастично и малопонятно, как это может быть реализовано. Многие считают, что для создания такого рода классификатора необходимо привлечь "понимание" естественного языка. К счастью, последние пару лет был достигнут большой прогресс в использовании нейросетей типа Transfirmer для обработки естественного языка. Кроме того, сейчас растет популярность и использования трансформеров для обработки изображений.

Почему CLIP?

В январе этого года был сделан прорыв в области обработки изображений - OpenAI представила новый генератор изображений Dall-E, который может генерировать изображение на основе текстового описания. Несмотря на название OpenAI, код Dall-E не является открытым. Тем не менее, меня очень заинтересовала вспомогательная нейронная сеть для обучения Dall-E и отбора лучших примеров. Это сеть CLIP. CLIP, в отличие от Dall-E, проект с открытым исходным кодом, опубликованный под лицензией MIT, поэтому его можно легко использовать в своих целях. Эта нейронная сеть выглядит не столь впечатляющей для демонстраций публике, но меня она очень удивила. В целом, это двухмодульный проект. Первый модуль - эффективная нейронная сеть Image Transformer. Этот модуль использует State-of-Art механизм внимания для кодирования изображения в 512-мерное пространство. Другая часть - нейросеть-трансформер для обработки текста, который преобразует текст в вектор в то же 512-мерное пространство. Сеть обучалась на большом массиве изображений (каком именно я не нашел, но, похоже, что это что-то типа "весь интернет", "вся википедия" или "весь инстаграм", как недавно сделали в Facebook AI). Процедура обучения не раскрывается, но предполагаю, что использовался loss типа Cosface или Arcface и различные параметры обучения для каждого из модулей. При обучении картинка с подходящей подписью должны быть близки, а с неподходящей - максимально далеки в пространстве embedding-ов.

CLIP хорошо работает для решения задачи zero-shot learning. Для этого необходимо создать набор предложений с использованием шаблона. Предложения могут быть типа "This is a photo of OBJECT", где OBJECT - название одного из множества классов. Набор предложений можно превратить при помощи текстового модуля transformer в набор векторов. Далее смотрится на какой из векторов больше всего похоже закодированное при помощи второго модуля изображение. Если нормировать близость векторов при помощи Softmax, то можно интерпретировать результат как вероятность того, что изображение принадлежит к какому-то классу.

Оказалось, что для многих задач Zero-shot learning работает даже лучше, чем натренированные на специально отобранных датасентах state-of-art сверточные нейросети.

Архитектура приложения

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

Телефон обладает доступом к изображениям (с камеры или из хранилища). После предварительной обработки (изменения разрешения и перенормировки каналов) изображение при помощи нейросети-трансформера превращается в 512-мерный вектор. Данный вектор сравнивается с каждым из векторов одного из предварительно сгенерированных наборов. Выдается описание 5 наиболее близких векторов.

Кроме того, существует возможность отправить запрос на сервер для генерации собственного классификатора. Отправляется строка - шаблон и набор классов. С сервера возвращается и сохраняется простой torchScript модуль, содержащий набор векторов и необходимые действия с ними. У пользователя появляется собственный классификатор! Работа с CLIP была на python, Android приложение - на JAVA. Серверная часть - Python/FLASK. Архитектура показана на рисунке.

Работаем с CLIP (Python)

Для разработки я использовал дистрибутив Anaconda python. Установим необходимые библиотеки

conda create-name pytorchconda activate pytorchconda install-yes -c pytorch pytorch=1.7.1 torchvision cudatoolkit=11.0conda create-name pytorchpip install ftfy regex tqdmpip install git+https://github.com/openai/CLIP.gitconda install -c conda-forge notebook

После установки необходимых библиотек импортируем их в проект:

И создаем модель CLIP. Работу с ним будем вести на GPU:

Нейросеть для обработки изображений - это модуль clipmodel.visual. Попробуем скомпилировать его и сохранить его отдельно в виде модуля TorchScript. Для этого используется JIT компилятор библиотеки torch (torch.jit). Для JIT компиляции необходимо запустить модуль visual на каком-либо изображении:

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

Создаем приложение для Android (Java)

Так как у меня нет опыта разработки на быстро набирающем популярность языке Kotlin, я использовал Java. В отличии от python, код на Java гораздо более громоздкий и менее выразительный. Поэтому, чтобы не перегружать пост оставлю только 2 наиболее важные части кода. Остальные части - описание работы кнопок/интерфейс - достаточно стандартные для любого приложения.

Первая важная часть - загрузка *.pt модели из папки assets и получение ответа в виде строки в TextView:

В качестве бонуса я добавил модуль, натренированный классическим образом (на большом датасете) для определения вероятности наличия пневмонии (python код не приведен) по флюорографии - сейчас такую задачку очень любят решать.

Вторая важная часть - отправление запроса на сервер и сохранения ответа (модели) в ExternalFilesDir. Список классов и название модели сохраняются в той же директории:

Серверная часть (Python/flask)

Я арендовал VPS на одном из сервисов. О системе - я запустил сервер apache 2.0 с WSGI / Flask под Centos 7 (для меня это была самая сложная часть проекта, поскольку я никогда раньше не работал с развертыванием сервера, на это ушло несколько дней постоянного поиска в Google/StackOverflow). Серверная часть Python очень похожа на функцию cerate_xml. Единственная разница заключается в обработке запросов и отправке сгенерированного файла модели. К сожалению, сервер работает не так быстро, как хотелось бы. Возможно, если приложение будет популярным, мне стоит перенести наиболее тяжелые вычисления (кодирование текста через текстовый трансформер) в AWS Lambda:

Буду смотреть по нагрузке.

Крутая часть! Тестируем!

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

Общий классификатор NOUN использует 4000 наиболее часто используемых английских существительных и предложений, сгенерированных шаблоном This is an image of NOUN.

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

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

Здесь есть что-то определенно французское) Мне стало особенно любопытно, можно ли описывать любые изображения, используя названия коктейлей:

Список похожих коктейлей меня порадовал. Зеленый гоблин? Кажется у сети все неплохо со знанием вселенной Marvel) Также очевидно, что это изображение чего-то красного и большого, летающего или даже похожего на насекомое. Чтобы протестировать серверную часть, я создал на телефоне классификатор, определяющий профессию по изображению. Я нашел список из 30 профессий в одном из учебников английского языка и добавил их. Модель была успешно сгенерирована на сервере и загружена. К сожалению, на это ушла пара минут (

Проверим, как работает созданная на сервере модель:

Видим, что приложение неплохо определяет профессии.

А кто по профессии Человек-Паук?

Что касается других классификаторов, то они работают хорошо:

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

Думаю ассоциативному мышлению неизбежно сопутствует предвзятость, поэтому от нее невозможно полностью избавиться в сложных системах типа нейросетей:

Посмотрим, понимает ли нейросеть русский язык. Если использовать стандартный классификатор, результаты не очень. Если уточнить, что это животное - уже лучше:

При этом с пониманием английского проблем нет, а с китайским - есть:

А можно ли превратить приложение в переводчик? Ограничимся названием животных. Шаблон: This is NAME in russian. NAME - список из 100 часто встречающихся животных:

/

Загрузка на Google Play market

Основной проблемой стало ограничение на размер загружаемого *.aab файла. Из-за большого размера трансформерной нейросети приложение пришлось разбить на 2 части с использованием механизма Asset Delivery. Оказалось, что оно некорректно доставляет Assets при внутреннем тестировании - отправил запрос в техподдержку, но ответа не получил. Я подключил firebase для сбора аналитики, нарисовал простую страничку с описанием и отправил приложение в Play Market, где оно проверялось в течение 1 недели.

Монетизация

Если приложение будет пользоваться популярностью, я собираюсь добавить пару баннеров из AdMob, чтобы оплатить сервер и заработать немного денег)

Проблемы

В описываемом приложении есть несколько проблем. Во-первых, я обнаружил медленный (5 с) холодный запуск на нескольких устройствах из-за загрузки преобразователя изображений в оперативную память при запуске приложения. Вторая проблема - медленный ответ сервера на запросы новых классификаторов. Эту проблему можно решить, перенеся вычисления в облако (я думаю о сервисе AWS-lambda), но сейчас мне сложно оценить стоимость AWS. Мне, вероятно, следует ограничить ежедневные запросы к серверу для каждого пользователя или взимать плату с пользователей за расширение лимита, чтобы покрыть расходы AWS и обеспечить лучший UX. Третья проблема возникла сегодня - нестабильный доступ к серверу. Похоже связано с "замедлением Твиттера".

Что можно добавить

Еще подумываю добавить режим one-shot (одна фотография используется для создания классификатора). Это улучшение можно легко реализовать в приложении.

Если говорить непосредственно о перспективах CLIP - я много играл с комбинацией CLIP и BERT для генерации описания изображения на естественном языке и уже получил некоторые многообещающие результаты. Но BERT определенно нельзя запускать на телефонах, и даже на моей rtx3080 (успел купить за 80!) есть некоторые проблемы для быстрого прототипирования таких систем. Также пробовал реализовать CLIP Style transfer с описанием обработки фото естественным языком - результат пока не очень, на это следовало бы потратить больше времени. Кроме того подозреваю, что использование CLIP для покадровой обработки видео и анализа потока векторов при помощи трансформеров или LSTM (почему-то кажется, что LSTMы будут лучше работать) может привести к прорыву в нейросетевом описании видео - сейчас результаты в этой области достаточно скромные.

Спасибо!

Не стесняйтесь обращаться ко мне в случае новых идей, предложений или вопросов!

Полезные Ссылки:

О Dall-e:

О CLIP

О мультимодальных нейронах CLIP

Github CLIP

Подробнее..

Нейродайджест главное из области машинного обучения за май 2021

03.06.2021 14:23:54 | Автор: admin

Управляемые складки одежды и морщины, фотореалистичные симуляции вождения, естественное освещение объектов при смене фона, китайский аналог DALL-E и многое другое: встречайте подборку самых интересных исследований и нейросетевых моделей, которые появились в прошедшем месяце.

DECA

Доступность: страница проекта / статья / репозиторий

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

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

Garment Collision Handling

Доступность: страница проекта / статья

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

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

DriveGAN

Доступность: страница проекта / статья

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

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

Enhancing Photorealism Enhancement

Доступность: страница проекта / статья / репозиторий

Пока приходится ждать симулятор езды от NVIDIA, можно развлечь себя с помощью разработок от исследователей из Intel. Они представили подход к повышению реалистичности синтетических изображений. Сверточная нейросеть использует промежуточные представления, созданные обычными пайплайнами рендеринга, что позволяет добиться фотореалистичной картинки в GTA V.

CogView

Доступность: онлайн-демо / статья / репозиторий

Новая нейросеть для перевода текста в изображение. В основе модели трансформер на 4 миллиарда параметров и токенизатор VQ-VAE. Создатели утверждают, что их модель работает лучше DALL-E от OpenAI, и в статье также делятся подходом к файнтюнингу модели для решения других задач вроде обучения стилю, улучшению разрешения, а также стабилизации предварительного обучения.

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

Expire-Span

Доступность: публикация в блоге / статья / репозиторий

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

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

Wav2Vec-U

Доступность: публикация в блоге / статья / репозиторий

Есть много моделей распознавания речи, которые превосходно справляются с распространенными языками. Но множество диалектов все еще не поддерживаются этими технологиями. Это связано с тем, что высококачественные системы необходимо обучать с использованием большого количества размещенных аудиозаписей. Исследователи FAIR представили версию модели wav2vec-U, которая обучается без учителя и вообще не требуют размеченных данных.

Rethinking Style Transfer

Доступность: страница проекта / статья / репозиторий

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

Relit

Доступность: страница проекта / статья

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

Total Relighting

Доступность: страница проекта / статья

Исследователи из GoogleAI пошли дальше и представили систему, которая способна заменить фон фотографии и скорректировать освещение человека на ней, сохраняя четкими границы объектов. На вход подаются две фотографии портретный снимок и картинка с новым окружением. Исследователи отмечают, что пока модель плохо справляется с альбедо, из-за чего некоторые типы одежды и глаза могут выглядеть неестественно.

Omnimatte

Доступность: страница проекта / статья

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

DeepFaceEditing

Доступность: страница проекта / репозиторий

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

StyleMapGAN

Доступность: репозиторий

Новый нейросетевой фотошоп, на этот раз от исследователей из корейской компании Naver. Метод позволяет редактировать отдельные области изображений. Как и у решений, которые мы рассматривали в апреле, здесь та же задача управление скрытыми векторами генеративно-состязательной сети. В их подходе промежуточное скрытое пространство имеет пространственные измерения, и пространственно изменяющаяся модуляция заменяет адаптивную раздельную нормализацию. Таким образом кодировщик более точно создает вектора чем методы, основанные на оптимизации с сохранением свойств GAN.

GPEN

Доступность: онлайн-демо / статья / репозиторий

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

CodeNet

Доступность: репозиторий

Исследователи из IBM представили крупнейший открытый датасет для проведения бенчмарков с участием программного кода. Набор данных содержит 500 миллионов строк кода на 55 языках программирования, включая C ++, Java, Python, Go, COBOL, Pascal и FORTRAN. CodeNet фокусируется на обнаружении сходств и отличий кода, чтобы продвигать разработку систем, которые смогут автоматически переводить код с одного языка программирования на другой.

DatasetGAN

Доступность: страница проекта / статья

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

Golos

Доступность: репозиторий

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

В мае стали доступны:

На этом все, спасибо за внимание и до встречи в следующем месяце!

Подробнее..

Перевод Преобразуем графику Fortnite в PUBG новым более быстрым подходом

09.04.2021 20:23:06 | Автор: admin

Пару лет назад я показал, как мы можем использовать ИИ для переноса стилей, такой как CycleGAN, для преобразования графики одной игры, чтобы она выглядела похожей на другую, на примере Fortnite в PUBG. Этот проект до сих пор остается одним из самых просматриваемых, и спустя два года он все еще привлекает новых зрителей. Это означает, что это направление исследований в области ИИ вызывает большой интерес, но, к сожалению, мы пока не видим больших подвижек по превращению таких прототипов в реальность. Хотя и были представлены версии этого ИИ, работающиее на более высоком разрешении, они нуждались в использованию нескольких графических процессоров для обучения, что является непрактичным для использования в реальном мире.

К счастью, по прошествии большого количества времени у нас наконец-то есть статья, демонстрирующая значительный прогресс в попытке уменьшить вычислительную мощность, необходимую для обучения этого ИИ. Статья от Калифорнийского университета в Беркли и Adobe называется Contrastive Learning (контрастное обучение) для непарного преобразования изображений (CUT).

Графика Fortnite преобразована в PUBG с помощью CUT.Графика Fortnite преобразована в PUBG с помощью CUT.

Используя тот же набор данных и то же самое аппаратное обеспечение с тем же графическим процессором, которое я использовал в прошлый раз, эта новая модель позволила мне перейти с разрешения 256p до 400p для синтезированных изображений. Более того, мне понадобилось чуть меньше чем за 2 часа на обучение модели, по сравнению с 8+ часами в прошлый раз.

CycleGAN и Patchwise Contrastive Framework.

Наблюдается существенная разница в количестве требуемых вычислительных мощностей по сравнению с CycleGAN. Итак, чем отличается этот подход от CycleGAN? Теперь он использует фреймворк Patchwise Contrastive Learning, который требует значительно меньше графической памяти и вычислений по сравнению с CycleGAN.

Сети CycleGANСети CycleGAN

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

А здесь мы используем Contrastive Loss (контрастную потерю). Во-первых, вместо того, чтобы работать со всеми изображениями сразу, этот метод фокусируется на извлечении фрагментов из входных и выходных изображений. Задача нашей модели здесь - определить, какой из нескольких входных ключей является положительным совпадением с нашим проверочным фрагментом, полученным из синтезированного изображения. Это называется Contrastive Learning, и это позволяет модели обучаться лучшему представлению признаков с помощью самоконтроля.

Сравнение с CycleGAN

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

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

Полезные ссылки

  1. Полный текст статьи (PDF)

  2. Страница проекта

  3. Код (GitHub)

Спасибо за внимание. Если вам понравилась эта статья, вы можете следить за другими моими работами на Medium, GitHub или подписаться на мой YouTube-канал.


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

- УЗНАТЬ О КУРСЕ ПОДРОБНЕЕ

-

ЗАПИСАТЬСЯ НА ДЕМО-УРОК

Подробнее..

Как мы сделали акселератор инференса нейронных сетей для ЦОД с 64 чипами Intel Movidius

11.05.2021 10:09:42 | Автор: admin

Некоторое время назад мы искали оптимальное аппаратное и программное обеспечение для исполнения нейронных сетей в ЦОД и "на краю" (edge computing). В рамках нашего исследования мы протестировали множество устройств, от процессоров до встроенной графики iGPU и GPGPU различных производителей. С результатами исследования можно ознакомиться по ссылке.

В рамках этого исследования нас заинтересовал VPU Intel Movidius (MyriadX). При вычислениях "на краю" и использовании фреймворка Intel OpenVINO он позволял нам увеличивать число потоков или каналов путем дооснащения существующих устройств без какой-либо модификации аппаратной и программной базы. По умолчанию мы использовали встроенную графику, например, Intel HD или Iris Plus 655, но если FPS и число потоков необходимо было увеличивать, то промышленные ПК можно было дооснастить VPU. Это давало возможность сохранить единообразие множества устройств при изменяемом числе потоков. В качестве примера можно привести транспортную отрасль и подсчет пассажиров на борту автобусов. Автобусы бывают с 2, 3 и 4 дверьми. И если для двух дверей достаточно встроенной графики, то для четырех необходимо увеличение FPS, что достигалось расширением готового решения при помощи VPU формата M.2.

Вот так выглядело наше устройство для исполнения нейронных сетей "на краю" с Intel Movidius:

ComBox Outdoor Box SquaredComBox Outdoor Box Squared

Сегодня для инференса "на краю" интерес представляют решения от компании AAEON, в частности VPC-3350S, VPC-3350AI:

AAEON VPC-3350SAAEON VPC-3350S

Они отличаются расширенным температурным диапазоном эксплуатации -20+70 градусов, наличием возможности расширения двумя VPU Movidius, широкой линейкой поддерживаемых процессоров от Intel Atom x5 E3940 до Pentium N4200 или Atom x7 E3950, а также наличием 4 PoE Ethernet портов для подключения камер или иного оборудования.

С использованием Movidius в IoT все было более или менее понятно, но нас заинтересовала хотя бы теоретическая возможность масштабирования и применения этих компактных энергоэффективных чипов в ЦОД в виде ускорителей инференса PCIe формата.

Суммарный объем рынка публичных и частных облаков в России по данным IDC с 2019 года растет минимум на 25% в год, что на 2019 год составляло $1,72 млрд., а на 2020 год увеличилось до $2,2 млрд. Доля публичных облаков в общем объеме рынка в 2019 году 84,6%. Несмотря на то, что облачный рынок претерпел ряд структурных изменений в 2020 году, рост продолжается с частичным, но постоянным увеличением объемов облачных вычислений в системах искусственного интеллекта прикладного уровня, например, видеоаналитике для обработки ранее сформированных видеоархивов.

После предварительной оценки рынка мы провели поиск имеющихся решений в формате PCIe. Все найденные на тот момент устройства содержали 4 или 8 Movidius на одну плату. Например, решения от AAEON:

AAEON AI CORE XP4/ XP8AAEON AI CORE XP4/ XP8

Общее назначение имеющихся устройств - инференса "на краю". И вот здесь родилась идея реализации собственного ускорителя инференса нейронных сетей для ЦОД с чипами Movidius высокой плотности.

Сейчас в этой сфере используются два основных устройства: GPGPU nVidia Tesla T4 и ускорители инференса Huawei Atlas 300. Альтернатив по производительности от компании Intel для дооснащения существующих систем или внедрения новых серверных решений нет. Возможное решение, сопоставимое по производительности и стоимости - это ускоритель на основе VPU Movidius (MyriadX) высокой плотности в форм-факторе PCIe с плотностью не менее 64 Movidius на каждой несущей плате.

Требования:

  • плотность чипов Movidius не менее 64 штук на каждую плату

  • наличие возможности изменения числа VPU на плате

  • минимально возможное энергопотребление

  • форм-фактор PCIe x4/x8

  • работа конечного устройства под управлением фреймворка Intel OpenVINO без каких-либо значимых доработок

  • исполнение под использование в серверных платформах

Концепт не заставил себя долго ждать:

ComBox x64 Movidius Blade BoardComBox x64 Movidius Blade BoardComBox x64 Movidius Blade BoardComBox x64 Movidius Blade Board

Результатом проектирования платы получилось устройство PCIe с размещенными на несущей плате кастомными разъемами для подключения дочерних плат с нанесенными на них VPU. Таким образом конечную плату можно использовать с числом VPU до 64 штук, кратно 8. На каждый разъем отведена 1 линия PCIe, а в рамках каждой дочерней платы устройства подключены через USB хаб.

Охлаждение - пассивные радиаторы вдоль каждой дочерней платы, которые продуваются мощными вентиляторами серверных платформ.

Первые образцы прототипа:

ComBox x64 Movidius Blade BoardComBox x64 Movidius Blade Board

Дочерние платы (по 8 Movidius на каждой):

x8 Movidius blades for ComBox x64 Movidius boardx8 Movidius blades for ComBox x64 Movidius board

Для тестирования и отладки мы использовали платформу Supermicro SYS-1029TRT и рекомендуем ее по следующим причинам:

  • хорошее соотношения цена/качество

  • форм-фактора 1U (занимает 1 место в стойке)

  • наличие 4 портов PCIe x8/x16

Про 1U отдельно отмечу, что при высоком энергопотреблении платформы и установленных ускорителей это не важно из-за наличия лимитов энергопотребления на шкаф, в целом. Но так как мы говорим о чипах, изначально предназначенных для использования в IoT, вся плата отличается низким энергопотреблением, что позволяет повышать плотность количества серверов в стойках.

Supermicro SYS-1029TRT с установленной платой ComBox x64 Movidius Blade BoardSupermicro SYS-1029TRT с установленной платой ComBox x64 Movidius Blade Board

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

Вид готового изделия:

ComBox x64 Movidius Blade BoardComBox x64 Movidius Blade Board

И первые первые промышленные образцы платы:

Каких итогов мы добились:

  1. Максимальная плотность VPU Movidius на одной плате в мире.

  2. Показатель в инференсе сверточных нейронных сетей (на примере Mobilenet v.2 SSD) - 2800 FPS.

  3. Энергопотребление платы не более 120 Вт при полной загрузке.

  4. Возможность использовать произвольное число дочерних плат и устанавливать по 8, 16, 24 и т.д. VPU в рамках одной несущей платы.

  5. Возможность запуска инференса под управлением фреймворка Intel OpenVINO с использованием MDL и HDDL плагинов.

Следующие планируемые шаги:

  1. Выпуск несущих плат с интегрируемым аппаратным ключом Senselock для защиты моделей нейронных сетей в процессе их исполнения.

  2. Предоставление облачных мощностей для инференса в аренду на базе ComBox x64 Movidius Blade board.

Подробнее..

Доббль практичный подход с OpenCV и NumPy

13.01.2021 18:13:38 | Автор: admin

О чём мы вспоминаем в первую очередь, когда слышим про распознавание образов? Сложные нейронные сети, мощные видеокарты, объёмные наборы данных. Всего этого не будет в моей истории - я расскажу, как с помощью OpenCV и NumPy можно за 1 вечер решить задачу классификации 57 символов из игры Доббль, используя менее 500 их изображений без дополнительной аугментации. Разный масштаб, произвольный угол поворота - всё это не имеет значения, когда для описания символа достаточно четырёх чисел.

Эта история произошла весной 2020 года, во время вынужденной самоизоляции. Я смотрел ролики на youtube и наткнулся на интересную игру - Доббль, или по-другому SpotIt. В местных магазинах я вряд ли смог бы её найти, а в условиях самоизоляции вариант с заказом тоже выглядел довольно призрачно. В результате нашёл в сети файл с изображениями карточек, распечатал на плотной фотобумаге и вырезал - получился довольно аккуратный набор. Сыну игра понравилась, стали играть.

На второй день внезапно выяснилось, что в наборе присутствуют две абсолютно одинаковые карточки. Дубль было решено отложить в сторону. В игре появились ничьи и какое-то чувство неправильности, не дающее забыть, что мы играем неполным набором. Что же делать? Я скачал еще один архив с изображениями карточек, но искать вручную какой же из них не хватает или тем более печатать и вырезать новый набор не хотелось.

И тут на Хабре обнаружилась статья "Как я научила свой компьютер играть в Доббль с помощью OpenCV и Deep Learning". Казалось бы, вот оно - решение, но Проанализировав код, я понял, что у подобного решения есть два фатальных недостатка - нарезка и разметка такого количества картинок займет слишком много времени, а тренировка модели на машине с неподдерживаемой видеокартой продлится еще дольше. Стал думать.

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

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

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

Один из неудачных вариантов автоматической разметкиОдин из неудачных вариантов автоматической разметки

Потом я обучил нейросеть - многослойный перцептрон (MLP). Сеть сделал на основе учебного примера из книги "Python Machine Learning" Себастьяна Рашки, для её реализации достаточно пакета NumPy.

В качестве входных данных использовал список файлов с символами - содержащейся в нём информации достаточно для обучения сети. Название папки с символами начинается с двузначного числа - номер символа, используем его как метку. Имя файла содержит 4 числа, соответствующие параметрам символа. Значение всех параметров оказалось в пределах 45..255, поэтому для полного использования диапазона 0..1 вычитаем из них 45 и делим на 210. Так как данных мало, в качестве тестового набора используем часть тренировочного. В списке 440 файлов, время обучения составило около 1 минуты.

И всё равно в результате проверки идеи выявились коллизии, не дающие правильно опознать символ. Пришлось добавить еще один параметр - отношение площади символа к площади описывающего его прямоугольника (я назвал его "плотность"). В результате этот параметр оказался очень полезным, от одного из цветовых каналов удалось избавиться. Единичные символы теперь распознавались устойчиво по четырём признакам.

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

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

Проверка работы нейросетиПроверка работы нейросети

Ради интереса проверил производительность - полный проход по колоде из 55 карточек (1485 комбинаций) занял 170 секунд, ошибок 0. Была идея сделать распознавание карточек по изображению с веб-камеры, но оказалось, что они сильно бликуют. Кроме того, у сети выявился недостаток - она сильно чувствительна к понижению разрешения картинки: мелкие детали (например, лучи снежинки) сливаются и это приводит к изменению параметров. Но сделать на ней игру с поиском совпадений по двум карточкам можно. Или использовать уже размеченные изображения символов для создания полноценного датасета и тренировки свёрточной нейронной сети.

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

Подробнее..

Вкусовщина и AI как мы в Prisma Labs делали объективно субъективный автоматический улучшатель фотографий

09.03.2021 10:13:46 | Автор: admin

Привет, Хабр! Меня зовут Андрей, я занимаюсь R&D в Prisma Labs. В своё время наша команда провела весьма интересное исследование на тему автоматического улучшения фотографии, результатом которого стала фича AutoAdjustment в приложении Lensa, позволяющая в один клик сделать цветокоррекцию фото. В этом посте я хочу поделиться полученным в ходе проекта опытом. Расскажу, в чём заключается сложность этой задачи, где вас могут поджидать нежеланные грабли. Также покажу, на что способен разработанный нашей командой искусственный интеллект. Прочитав этот пост, вы вместе с нами пройдёте тернистый путь от красивой идеи до одной из киллер-фичей популярного приложения. Ну что, погнали?

Зачем вообще всё это нужно?

Почти каждый хоть раз делился собственными фотографиями в социальных сетях. Выкладывая свои фото, мы, конечно же, хотим, чтобы они набрали как можно больше лайков. Для этого часто приходится прибегать к различным техникам и инструментам для коррекции изображений. Так, например, перед тем как выложить фотографию в Instagram, мы можем наложить какой-то фильтр и исправить некоторые настройки фотографии: экспозицию, контраст, температуру, резкость и т.д. Как итог, мы тратим много собственного времени, при том что основная проблема кроется даже не в этом. Корректируя фотографию, мы, к сожалению, не всегда можем объективно оценить, насколько нам получилось её улучшить (особенно если мы с вами не профессиональные фотографы). Так, после наложения какого-либо фильтра вам может показаться, что фотография стала выглядеть лучше, а друзья могут не согласиться, сказав, что она потеряла естественность, и лучше бы вы вообще выложили оригинал. Получается, время было потрачено напрасно! Конечно же, всегда есть возможность обратиться к профессиональному фотографу или ретушеру, и тогда, скорее всего, обработка будет на высоте, но это тоже непросто и не бесплатно. Из этих мыслей и предположений родилась идея: как бы нам помочь пользователям сэкономить их время предложить решение, которое в один клик сделает их фотографии "лучше" (при помощи искусственного интеллекта, разумеется).

Теперь чуть больше по существу, что же мы хотели получить?

В нашем приложении Lensa присутствуют стандартные инструменты для редактирования фотографий: настройка экспозиции, контраста, теней, температуры, светлых участков и т.д. Нашей задачей было построить решение, которое позволит всего в один клик автоматически подобрать значения для каждой из этих настроек так, чтобы после их применения фотография стала нравиться аудитории сильнее, чем оригинал (по крайней мере, большинству). Мы не претендуем на новизну идеи автоматического улучшения фотографии, методов, которые делают что-то похожее, к моменту начала проекта уже существовало множество. Главной особенностью нашего подхода стала идея предсказывать значения настроек для инструментов, доступных через UI в нашем приложении.

Такой подход был выбран по нескольким причинам:

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

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

На рисунке ниже представлена схема нашей модели.

Схема модели автоматического улучшения фотографииСхема модели автоматического улучшения фотографии

Одна из основных "фишек" нашего редактора состоит в том, что после "автоулучшения" фотография должна остаться естественной. По этой причине некоторые из инструментов вроде зернистости или выцветания (grain/fade) мы исключили из нашего подхода, так как они делают фотографию менее реалистичной (на рисунке выше предсказанные значения нулевые).

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

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

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

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

  • Ко всему прочему, мы не хотим, чтобы модель предсказывала специфичный стиль, например, настройки для перевода в чёрно-белые тона. Многие профессиональные фотографы любят делать нестандартную авторскую обработку. В связи с этим поставить чёткое тз для разметки не самая тривиальная задача.

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

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

Что означают эти названия у нас в Lensa?

Ответ основан на понимании того, что ожидает пользователь, когда выставляет значение теней на 50/100, а значение контрастности на -20/100. Как оказалось, опытный юзер предполагает, что тёмные области (тени) станут светлее, и при этом вся фотография станет более серой (из-за уменьшения контрастности). Ровно так, опираясь на то, чего ждут от нас пользователи, и к чему они привыкли, мы выстраивали эти инструменты.

Подбросим в топку чуть больше математики. Когда инженеры пытаются делать какие-то манипуляции с картинкой, в первую очередь вспоминают про различные цветовые пространства. Самое распространённое RGB, но в нём гораздо сложнее реализовать наши инструменты, чем в тех, где за цвет и яркость отвечают раздельные компоненты. Все операции с изображением мы выполняем в пространстве LAB (L канал отвечает за интенсивность).

Рассмотрим пару примеров наших инструментов, которые направлены в основном на L канал изображения: контраст и тени. На рисунке ниже представлены кривые инструментов (adjustments) контраста и теней для L канала, по OX исходное значение, по OY значение после применения инструмента.

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

Каждая кривая (красная, серая, зелёная) соответствует некоторому значению этого инструмента.

adjusted=A(x, \alpha)

здесьx исходное значение L канала для некоторого пикселя,adjusted во что перейдетx после применения инструмента,\alpha значение "ползунка" инструмента (сила применения),A кривая (функция) инструмента (adjustment). В случае изображения кривые применяются попиксельно.

Теперь про кривые: зеленая кривая соответствует максимальному значению инструмента тому, как будет преобразован L канал для максимального значения ползунка (+100). Красная кривая, напротив, соответствует минимальному (-100). Пунктирная линия отражает преобразование при нулевом значении\alpha(тождественное).

Инжиниринг кривых не самая тривиальная задача!

Разберёмся с формой кривых на примере контраста. Когда вы увеличиваете контраст у картинки (зелёная кривая), то хотите, чтобы пиксели, которые были светлыми, становились еще более светлыми, а пиксели, которые были тёмными, получались еще более тёмными, при этом серые особо не менялись. Ровно это и позволяет нам сделать кривая контраста: значения ближе к 50 (серый) меняются слабо, зато области, которые находятся ближе к квантилям 1/4 и 3/4, изменяются сильнее всего. В случае с красной кривой всё наоборот: белые и чёрные участки тянутся к серому, поэтому зелёная и красная кривые проходят по разные стороны от пунктирной. С тенями всё еще проще, когда вы поднимаете тени, то хотите, чтобы высветились тёмные участки, а светлые участки почти не менялись. К каналам a и b также применяются некоторые преобразования, но для простоты опустим эти детали.

А что с промежуточными значениями?

Можно линейно проинтерполировать результат максимума или минимума с оригиналом (в зависимости от знака альфы). Так, например, если мы хотим узнать, чему будет равен результат при значение ползунка в 50/100, то сначала считаем, чему будет равен результат при максимальном значении, а затем смешиваем с оригиналом с весами 0.5 (формула ниже).

\dfrac{1}{2}x+\dfrac{1}{2}A(x, \alpha_{max})

Обратимость кривых и предлагаемый подход

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

Обратимость кривой инструментов (adjusts)Обратимость кривой инструментов (adjusts)

Если не вдаваться в детали, то обратимость функции (кривой)Aозначает, что для неё можно построить такую функциюA^{-1}, что для каждого значенияxи каждого допустимого значения\alphaверно следующее утверждение:

A(A^{-1}(x, \alpha), \alpha)=x

Допустим, мы построили такие функции, что все они являются обратимыми, что же дальше? Для упрощения задачи давайте представим, что нам нужно предсказать значение для одного инструмента, например, контраста. То есть наша модель должна принимать на вход исходную фотографию юзераxс "плохим" контрастом и предсказывать такое значение\alpha, чтобы применённая с силой\alphaфункция контраста давала на выходе "идеально контрастную" фотографиюI.В каком случае вообще представляется возможным предсказать такое значение\alphaчтобы получитьI? Это выполнимо, если существует некоторое\alpha, для которого справедливо следующее утверждение:

x=A^{-1}(I, \alpha)

Это предположение является одним из самых важных моментов в нашем подходе.

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

Допустим, у нас есть сет таких "идеальных" фотографийI. Тогда, "испортив" их обратной функцией для данного инструмента (в нашем случае контраста), мы получим обучающую выборку с триплетами\big(I,\alpha,x=A^{-1}(x,\alpha)\big)и будем учить нашу модель предсказывать\alphaпо входуx.

Но у нас же несколько инструментов!

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

\begin{cases}x_{0}=I\\x_{i}=A^{-1}_{N-i+1}(x_{i-1}, \alpha_{N-i+1}),\ \ \ i=1...N\end{cases}

Если у насNинструментов, тоx_{N} результат применения композиции их обратных функций.

Ниже представлена иллюстрация парадигмы с триплетами для случая одного инструмента.

Схема обучения модели с триплетамиСхема обучения модели с триплетами

На рисунке выше CI=InvA(I, alpha) искажение "идеальной" фотографии путём наложения некоторой обратной функции со значением alpha. M(CI) предсказание обучаемой модели.

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

  • Идеологически каждый инструмент (эджаст) должен исправлять только искажения от собственной обратной функции (композиция эджастов от композиции обратных). То есть инструмент контраста направлен на то, чтобы исправлять недочеты именно контраста на фотографии, но никак не температуры или другого инструмента. Всё ещё не удаётся избавиться того, что некоторые эджасты скоррелированы меняя, например, контраст, вы так или иначе влияете на тени (об этом дальше).

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

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

Важнейшая составляющая. Откуда мы брали данные для обучения?

Есть же размеченные данные, что с ними не так?

В начале работы над проектом мы пытались использовать некоторые open-source данные от фотографов вроде MIT-Adobe FiveK Dataset (его используют в большинстве статей по автоулучшению фото). Достаточно быстро мы поняли, что все найденные open-source датасеты нам не подходят по нескольким причинам:

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

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

Мы пробовали размечать данные командой, но столкнулись с проблемой. Каждый выбирал настройки, исходя из собственных вкусов, поэтому в данных не было видно явного тренда, и получившаяся разметка была очень разнородной и даже противоречивой. В связи с этим мы выбрали одного асессора, и в итоге весь датасет был размечен одним человеком с экспертизой в области фото (будем называть его "судьёй"). После этого нашей задачей было уже не обучить объективный "улучшатель" фотографий, а научить модель размечать фотографии так же, как наш судья.

По итогу в датасет попало порядка 1000 фотографий (оригиналы, идеалы и конфиги). Немного, но этого оказалось достаточно.

Одна за всех или все за ...

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

  • Эффекты от наложения некоторых инструментов очень скоррелированы. Существует множество конфигов, применяя которые, мы получаем почти одинаковый выход. Обучаться модели с таким условием тяжелее нужно учить все инструменты одновременно. Более того, нужно выучить не только правильный конфиг, а ещё понять, как применение всего этого конфига (всех инструментов одновременно) влияет на фотографию.

  • Модель почти всегда видит сложные сэмплы если мы сразу портим фотографию композицией всех обратных инструментов, то в большинстве случаев модели на вход будет приходить изображение, на котором плохо абсолютно всё: плохая экспозиция, плохой контраст, плохие тени и т.д. Сэмплировать искажения так же, как "in the wild", мы не можем, так как не знаем, как выглядит их априорное распределение в реальном мире.

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

У такого подхода тоже есть свои проблемы:

  • Во время обучения определённой модели, например, контраста, сеть не видит фотографии с испорченными тенями, что не является правдой для in the wild. Мы пробовали добавлять аугментациями искажения других инструментов (кроме того, который учим), но опять столкнулись с проблемой корреляции эффектов и усложнили каждой модели задачу.

  • Данный подход сложнее в интеграции на девайс и гораздо более трудозатратен в плане обучения: вам нужно последовательно обучить N сеток вместо всего одной.

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

Функция потерь. Картинки укажут вам более правильный путь, чем конфиги

Сначала мы пробовали, используя регрессионный лосс на паре (predicted_alpha, gt_alpha), учить модель предсказывать верное значение инструмента, но наступили на очередные грабли. У такого подхода есть как минимум два минуса.

Во-первых, как упоминалось выше, может существовать несколько различных конфигов для инструментов, которые приводят к одному и тому же результату. Но, если мы учим отдельно по одной модели на каждый инструмент при фиксированных остальных инструментах (которые не участвуют в обучении), найдётся ровно один правильный ответ для восстановления искажений инструмента, под который мы обучаем модель. Так что для подхода "одна модель под один инструмент" это не стало серьёзным препятствием.

Вторая проблема оказалась более серьёзной, и здесь нас уже не спас подход с обучением своей сетки под каждый инструмент. Допустим, мы хотим исправить насыщенность картинки. Мы знаем, что оригинальное изображение было испорчено со значением -90, а мы предсказали 50. Правда ли, что вне зависимости от исходного изображения мы всегда должны одинаково штрафовать нашу модель? Оказывается, что это не так.

За наглядным примером проблемы и ходить далеко не нужно!
Искажение насыщенности на -90 для двух разных оригиналовИскажение насыщенности на -90 для двух разных оригиналов

В приведённом примере абсолютная разница между предсказанным и истинным значениями одинакова для обоих случаев, при этом мы явно должны штрафовать модель для Case 2 сильнее (мы не восстановим оригинал с предсказанием 50). В то же время, в случае Case 1 для любого предсказания модели мы получаем одинаковый результат в точности оригинал (изменение насыщенности не меняет чёрно-белую фотографию). Получается, что мы должны штрафовать не за разницу в конфигах, а за то, насколько результат применения предсказанного конфига отличается от желанного изображения.

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

Получается, что функция потерь должна штрафовать разницу между изображениями, а не между конфигами. Например, мы можем использовать L2-норму между изображениями:

Loss=MSE(I, A(M(CI))

Важно отметить, что предложенная функция потерь MSE между изображениями, дифференцируема по предсказанному значению, так как каждая наша функция каждого инструмента дифференцируема по нему.

Мы обучили модель. Как понять, что она делает что-то адекватное?

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

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

Cходимость первый признак адекватности модели!

Допустим, мы обучили модель для улучшения контрастности. Подаём некоторую фотографию (лучше ту, на которой есть явные проблемы с контрастом) в нашу модель. Модель предсказывает некоторое число P1. Затем применяем к этой фотографии инструмент контраста со значением P1 и получаем новую исправленную фотографию. Подаём её в модель,получаем число P2 и т.д. Так вот, мы считали, что модель является "адекватной", если эта последовательность (P1, P2, P3, ) достаточно быстро сходится. В идеале значения (P2, P3, ) должны быть около нуля.

Иллюстрация сходимости моделиИллюстрация сходимости модели

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

Хорошая модель монотонная модель

На рисунке выше по оси X отложено значение, с которым мы портили фотографию, а по оси Y предсказание модели. Видно, что модель не идеально предсказывает значения, с которыми портится фотография, но отношение порядка сохраняет. Связано это с тем, что модели достаточно просто понять, что на картинке нужно поднять или опустить контрастность и масштаб этого значения (относительно своего априорного идеала), а вот точное абсолютное значение угадать сложно (ещё один пунктик в сторону того, что обучать с функцией потерь именно на конфиги очень сложно). Также во время обучения мы старались подбирать такие распределения для параметров искажения (на основе размеченных данных), чтобы фотографии всё ещё выглядели более-менее естественными, поэтому модель не видела совсем испорченные фотографии (хвосты кривой).

Пристегнитесь, сдедующая остановка используемые метрики!

Долгое время в качестве метрик мы использовали только визуальный тест. Заключался он в следующем: мы показывали нашему судье две фотографии и просили выбрать, какая выглядит лучше. При этом мы также просили дать оценку от 1 до 5 (насколько выбранный вариант выглядит лучше конкурента). Это позволяло нам как сравнивать обученные модели, так и понимать, насколько мы далеки от разметки судьи. Важнейшей проблемой такой метрики является то, что этот процесс совершенно не автоматизирован и требует слишком много времени и усилий каждый раз нам нужно было строить несколько кандидатов (обучать несколько моделей) и для каждого нового кандидата просить нашего судью проходить такой визуальный тест. По этой причине нам пришлось думать над альтернативными метриками хотя бы для уменьшения количества проводимых визуальных тестов (для отсеивания заведомо неудачных моделей).

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

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

Схема подсчёта первой используемой метрикиСхема подсчёта первой используемой метрики

Спустя некоторое время мы поняли, что у такой метрики есть один важный недостаток. Допустим, наша модель на схеме выше (при расчёте метрики) предсказала\alphaдля контраста. У наших инструментов есть четкий порядок применения, и проблема состоит в том, что, на самом деле, контраст идёт вторым в очереди (то есть до теней), а не последним. Даже с учётом того, что мы сохраняем порядок применения всех инструментов после предсказания, мы "подсовываем" модели не совсем истинный вход. В реальном мире (в самом редакторе), даже если обученные модели для экспозиции и теней будут всегда предсказывать правильные значения, модель для контраста, скорее всего, не предскажет то же самое значение\alpha, так как на вход поступит изображение с ещё не исправленными тенями (исправление теней влияет на контраст фотографии).

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

Предположим, мы хотим настраивать экспозицию, контраст и светлые участки. Мы знаем, что сначала идёт экспозиция, затем контраст и только после настройка для светлых участков. Пусть у нас уже есть какое-то приближение для нашего решения модели, которые предсказывают значения для всех указанных инструментов (в самом начале это могут быть просто константные нулевые предсказания). Допустим, мы выучили новую модель для светлых участков. Чтобы проверить, сможет ли только она улучшить качество всего имеющегося пайплайна, мы заменим в лучшем наборе ровно одну модель по светлым участкам и проверим, стал ли предсказанный результат ближе к разметке судьи. Если да, то мы обновляем в лучшем множестве старую модель на новую и переходим к контрасту уже с новой зафиксированной моделью по светлым участкам (затем только к экспозиции).Схема одного предлагаемого шага обновления лучшего набора моделей представлена ниже.

Схема подсчёта второй используемой метрикиСхема подсчёта второй используемой метрики

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

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

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

Везде грабли-грабли-грабли, а что же мы получили в итоге?

Если долго мучиться, всё обязательно получится! Нам удалось построить модели, которые устроили нас по визуальному качеству, удовлетворяли нашим эвристикам и показывали позитивные результаты на приведённых метриках. Сейчас эти модели встроены в наш инструмент автоулучшения фотографии (Autoadjustments) в приложении Lensa. Ниже парочка примеров селфи с результатами работы пайплайна. Ждём вас в гости в приложении, если подумали, что это "черри-пики" :)

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

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

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

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

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

Подробнее..

Разработка Computer Vision в онкологии почему всегда нужно еще больше сил, времени и денег

25.05.2021 16:13:10 | Автор: admin
image

Привет!

Я Жека Никитин, Head of AI в компании Celsus. Больше трех лет мы занимаемся разработкой системы для выявления патологий на медицинских снимках.

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

Ориентировался я в первую очередь на ML-разработчиков и DS-менеджеров, но пост может быть интересен и всем любопытствующим, кто хочет разобраться со спецификой CV в медицине.

Собираем данные для обучения


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

image

Вы собрали команду талантливых программистов, ML-инженеров и аналитиков, купили дорогое оборудование, арендовали офис и продумали маркетинговую стратегию. Кажется, все готово для того, чтобы начать менять мир к лучшему! Увы, все не так просто, ведь вы забыли о самом главном о данных. Без них нельзя натренировать нейронную сеть или другую модель машинного обучения.

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

Рассмотрим ситуацию на все том же примере детекции рака молочной железы. Более или менее качественные публичные датасеты можно пересчитать по пальцам одной руки: DDSM (порядка 2600 кейсов), InBreast (115), MIAS (161). Есть еще OPTIMAM и BCDR с достаточно сложной и запутанной процедурой получения доступа.

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

Итак, вы разослали запросы в медицинские учреждения, подняли все свои связи и контакты и получили в руки разношерстную коллекцию различных снимков. Не радуйтесь раньше времени, вы в самом начале пути! Ведь несмотря на наличие единого стандарта хранения медицинских изображений DICOM (Digital Imaging and Communications in Medicine), в реальной жизни все не так радужно. К примеру, информация о стороне (Left/Right) и проекции (CC/MLO) снимка молочной железы могут в разных источниках данных храниться в абсолютно разных полях. Решение тут единственное собирать данные из максимального числа источников и пытаться учесть в логике работы сервиса все возможные варианты.

Что разметишь, то и пожнешь


image

Мы наконец-то подобрались к самому интересному процессу разметки данных. Что же делает его таким особенным и незабываемым в сфере медицины? Во-первых, сам процесс разметки намного сложнее и дольше, чем в большинстве индустрий. Рентгеновские снимки невозможно загрузить в Яндекс.Толоку и за копейки получить размеченный датасет. Здесь требуется кропотливая работа медицинских специалистов, причем каждый снимок желательно отдавать для разметки нескольким врачам а это дорого и долго.

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

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

image

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

image

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

Конечно, если подходить к процессу с умом, то затраты можно и нужно сокращать например, с помощью активного обучения. В этом случае ML-система сама подсказывает врачам, какие снимки нужно доразметить для того, чтобы максимально улучшить качество распознавания патологий. Существуют разные способы оценки уверенности модели в своих предсказаниях Learning Loss, Discriminative Active Learning, MC Dropout, энтропия предсказанных вероятностей, confidence branch и многие другие. Какой из них лучше использовать, покажут только эксперименты на ваших моделях и датасетах.

Наконец, можно вовсе отказаться от разметки врачей и полагаться только на конечные, подтвержденные исходы например, смерть или выздоровление пациента. Возможно, это лучший подход (хотя и здесь есть куча нюансов), вот только начать работать он может в лучшем случае лет через десять-пятнадцать, когда повсеместно будут внедрены полноценные PACS (picture archiving and communication systems) и медицинские информационные системы (МИС) и когда будет накоплено достаточное количество данных. Но даже в этом случае чистоту и качество этих данных вам никто не гарантирует.

Хорошей модели хороший препроцессинг


image

Ура! Модель обучена, показывает отличные результаты и готова к запуску в пилотном режиме. Заключены договоры о сотрудничестве с несколькими медицинскими организациями, система установлена и настроена, врачам проведена демонстрация и показаны возможности системы.

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

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

Наглядный пример: наша команда протестировала публичную модель от исследователей из Нью-Йоркского университета, обученную на миллионе снимков. Авторы статьи утверждают, что модель продемонстрировала высокое качество детектирования онкологии на маммограммах, а конкретно они говорят о показателе точности ROC-AUC в районе 0.88-0.89. На наших данных эта же модель демонстрирует значительно худшие результаты от 0.65 до 0.70 в зависимости от датасета.

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

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

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


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

image
image

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

image

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

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

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

Компьютерное зрение в промышленной дефектоскопии Часть 1 Как мы заставляли нейронку пялиться на ржавчину

11.02.2021 14:07:02 | Автор: admin


Сегодня мы хотим поделиться опытом решения задачи детекции дефектов на снимках промышленных объектов методами современного компьютерного зрения.


Наш рассказ будет состоять из нескольких частей:


  • Постановка задачи и Данные, в которой мы будем смотреть на ржавые отопительные котлы и лопнувшие трубы, наслаждаться разметкой и аугментацией данных, а также будем вращать и шатать трубы чтобы сделать данные разнообразнее;
  • Выбор архитектуры, в которой мы сядем на два стула попытаемся выбрать между скоростью и точность;
  • Фреймворки для обучения, в которой мы будем погружаться в Darknet и заглянем в MMLab и покажем как сделать итоговое решение воспроизводимым и удобным для тестов.

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


Заметка от партнера IT-центра МАИ и организатора магистерской программы VR/AR & AI компании PHYGITALISM.


Описание задачи



Рис 1. Схематичное изображение рассматриваемого проекта.


Машинное обучение (machine learning / ML) в общем и компьютерное зрение (computer vision / CV) в частности находят сегодня все больше применений в решение задач из промышленной области (пример). Начиная от задач нахождения бракованных деталей на конвейере и заканчивая управлением беспилотным транспортом везде используются глубокие архитектуры, позволяющие детектировать многочисленные объекты разных категорий, предсказывать пространственное расположение объектов друг относительно друга и многое другое.


Сегодня мы рассмотрим кейс (проект Defects detector CV) по созданию прототипа программного обеспечения (ПО), которое использует нейронные сети, для того, чтобы на фото или видеопотоке детектировать объекты заданных категорий. В качестве предметной области, в данном проекте, выступила дефектоскопия промышленных труб.



Рис 2.Схематичное изображение парового котла, аналогичного тому, что рассматривался в проекте.


Кратко опишем предметную постановку задачи:


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


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


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


Мы постараемся осветить все основные этапы при разработке проекта, связанного с машинным


  • обучением;
  • сбор данных;
  • разметка данных;
  • дополнение данных (аугментация);
  • обучение модели;
  • оценка качества работы модели;
  • подготовка к внедрению.

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


Набор данных



Рис. 3 Пример объекта обучающей выборки фото, сделанное при осмотре внутренности остановленного котла.


Данные в нашей задачи представлены классическим для CV образом в виде цветных RGB снимков. На каждом снимки может как присутствовать сразу несколько дефектов, так и не присутствовать вовсе.


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


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

Виды задач распознавания образов на изображениях



Рис.4 Пример разметки для задачи детекции объектов на изображениях из датасета MS COCO. Иллюстрация из репозитория detectron2.


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


  • выделения области интереса;
  • классификация объекта в области интереса.

В зависимости от формы области интереса, задачи детекции делят на детектирование в виде ограничивающих прямоугольников (bounding boxes), в виде многоугольников, в виде битовых масок, в виде точек интереса и пр.


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


Помимо формы предсказания, также необходимо было определить формат датасета (способ организации данных и аннотаций). Формат датасета обычно определяется группами исследователей и разработчиков, которые создают значимые датасеты или архитектуры. В нашем случае, датасеты рассматривались в форматах MS COCO и YOLO.


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


Про устройство формата MS COCO и формы предсказаний можно узнать здесь.


Разметка данных



Рис. 5 Демонстрация работы CVAT для разметки дефекта трубы в виде полигональной маски.


Для того, чтобы разметить сырые данные существует несколько основных способов. Во-первых, можно воспользоваться услугами сервисов облачной распределенной разметки данных, вроде Amazon Mechanical Turk или Яндекс.Толока, во-вторых, если данные приходят не из реального мира, а генерируются искусственно, то разметку можно генерировать вместе с данными, ну и самым доступным способом является использование специализированного ПО для разметки данных.


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


Некоторые альтернативные инструменты разметки:



Усиление обобщающей способности


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



Рис. 5.1 Изображение из сообщества Memes on Machine Learning for Young Ladies. Там же можно увидеть пример того как не нужно делать аугментации.


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


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


Метрики качества


Для того, чтобы измерять качество работы алгоритма после процесса обучения на тестовой выборке в задачах детекции объектов на изображениях традиционно используют метрику mean average precision (mAP), рассчитанную для каждого класса по отдельности и усредненную для всех классов. Значение этой метрики рассчитываются при разных уровнях характеристики Intersection over Union (IoU). Здесь мы не будем подробно останавливаться на разъяснении устройства этих функций, всем заинтересованным предлагаем пройти по ссылкам ниже на статьи и заметки, которые помогут освоится в данном вопросе, но все же поясним некоторые основные моменты оценки качества работы алгоритмов в нашей задаче.


Для оценки результатов были выбраны следующие метрики:


  • mAP (mean average precision) среднее значение точности по всем классам (поскольку у термина могут быть разные трактовки, рекомендуем ознакомится с различными вариантами здесь).
  • AP (средняя точность) средняя точность по каждому отдельному классу.
  • Precision Recall кривая.
  • Число случаев когда дефект был обнаружен и он на самом деле был (TP).
  • Число случаев когда дефект был обнаружен, но его не было на самом деле (FP) т. е. ложное срабатывание.

Расчет метрик производился следующим образом. Так как целевым объектом для оценки был ограничивающий прямоугольник (bounding box), то интерес для оценки представляет три свойства:


  • Насколько модель уверена в предсказании. Чем выше уверенность, тем более надёжным будет результат. В идеале все правильные предсказания должны быть с высокой степенью уверенности.
  • Правильно ли предсказан класс объекта.
  • Насколько предсказанный ограничивающий прямоугольник совпадает с правильным.

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


Рассмотрим пример вычисления метрик на основе примера из рис. ниже. Для этого определяется величина IoU, которая равна отношению площади пересечения прямоугольников (серый прямоугольник) к площади их объединения. Она принимает значения в отрезке [0;1]. Можно выбрать определённый порог и считать, что при превышении этого порога прямоугольники совпадают.


Определение TP:
Если IoU больше определенного порога и метки классов совпадают, то предсказание считается правильным.


Определение FP:
Если IoU меньше определенного порога и метки классов совпадают, то предсказание считается ложным.


На основании этих показателей рассчитывается точность и полнота. Если кратко, то точность показывает насколько хорошо модель предсказывает дефекты определенного класса из тех которые были обнаружены вообще. Чем больше значение, тем меньше ошибок совершается. Значение в отрезке от [0;1].



Рис. 6 Пример рассчитанной Precision-Recall кривой для одного из классов дефектов для архитектуры DetectoRS.


Полнота показывает способность модели найти все дефекты определенного класса. Чем выше значение тем больше будет обнаруживаться дефектов, но не все могут быть точно предсказаны Эти величины взаимосвязаны. Как правило, чем больше точность, тем меньше полнота и наоборот. Т. к. предсказания зависят от некоторого порога принятия решения, то строят графики точности-полноты при разных значениях порога принятия решения для его оптимального определения и оценки возможностей модели.



Рис. 7 Сравнение технических метрик для выбранных архитектур для процесса обучения.


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


Заметку с примерами и объяснениями всей терминологии про метрики качества в CV можно посмотреть здесь.


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



*Рис. 8 Сравнение технических метрик для выбранных архитектур для процесса использования.* тестирование проводилось на видеокарте RTX 2080 Ti, тестирование проводилось на CPU AMD Ryzen 7 2700X Eight-Core Processor.*


Деление данных на обучающую и тестовую выборку


Для тестирования алгоритмов и оценки качества их работы часто нужно поделить данные на несколько частей. Одна часть используется при обучении, а другая для проверки обобщающей способности и подсчета метрик. Обычно это не представляет проблемы, но в нашем случае ситуация сложнее, т. к. каждое изображение (объект) может содержать несколько классов и в разном количестве. Изображение атомарно и мы не можем его как-то разделить логически, чтобы число примеров каждого класса поделилось в нужном соотношении. Тут стоит заметить, что фактически можно разделить изображение на множество отдельных частей, в которых есть только один класс, но это приведёт к уничтожению изначальной структуры изображений.


Рассмотрим пример, изображенный на рис. 9 случай, когда каждый объект принадлежит одному классу:

Рис. 9 Деление данных на тестовую и обучающую выборку.


Особой проблемы здесь нет и разделение на две выборке можно сделать достаточно просто. В нашем же случае картина с распределением объектов и классов будет такая:

Рис. 10 Каждый объект может содержать иметь несколько классов.


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

Рис. 11 Результат разделения на тестовую и обучающую выборку.


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


Выбор архитектуры


На основе результатов анализа известных архитектур собранных на paperwithcode (на момент 3 квартала 2020 года) для детектирования дефектов были выбраны две архитектуры:




Рис. 12 Сравнение архитектур на бенчмарке MS COCO object detection с сайта papesrwithcode (3 квартал 2020 года).



Рис.13 Сравнение архитектур на бенчмарке MS COCO, полученное авторами архитектуры YOLOv4.


YOLOv4 попала в этот список из-за своей скорости работы. Мы хотели посмотреть на результаты разных моделей и выбрать итоговый вариант в зависимости от требований. Потенциально могла быть потребность в обработке видео и для этого случая планировалось использовать YOLOv4. Более точная, но медленная работа ожидалась от DetectoRS.


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


DetectoRS


Данная архитектура базируется на использовании специального типа сверток (Switchable Atrouse Convolution / SAC) и в верхнем уровне устроена в виде рекурсивной пирамиды (Recursive Feature Pyramid / RFP), объединяющий локальные и глобальные признаки.



Рис. 14 Основные нововведения, используемые авторами архитектуры DetectoRS: (a) рекурсивная пирамида признаковых описаний, используемая для объединения глобальных признаков на изображении; (b) переключательные свертки типа atrouse для работы с локальными признаками.


Реализация данной модели присутствует внутри фреймворка MMDetection.
Об устройстве архитектуры можно прочитать здесь.


YOLOv4


Эта модель делает свои предсказания на основе анализа изображения, разбивая его на квадратную сетку как на рис. ниже:

Рис. 15 Принцип работы из оригинальной статьи про YOLO.


Видео с объяснением работы можно найти здесь.


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


Фреймворки для обучения моделей в задачах CV


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


Для разработки под Python, двумя наиболее популярными фреймворками являются MMdetection (open source) и Detectron2 (Facebook research). Для разработки под C, существует фреймворк Darknet (open source). Подробнее про то, как использовать данные фреймворки можно прочитать в заметках один, два, три.


MMDetection


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


Устройство фреймворка


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


  • подготовить веса при тренированной модели в формате .pth или прописать самостоятельно инициализацию новых весов;
  • написать конфигурационный файл в виде Python скрипта со всеми настройками для сети и описание хода обучения, валидации и пр.;
  • выбрать способ логирования процесса обучения (доступна запись в текстовый файл и логирование с помощью tensorboard);
  • организовать данные в файловой системе согласно выбранному типу разметки (доступны MS COCO, Pascal VOC и поддерживается возможность внедрение пользовательских форматов);
  • написать основный скрипт, собирающий воедино все перечисленные выше составные части.

После тестов локально на компьютере, на котором была установлена и развернута среда для работы с MMDetection, аналогично YOLOv4, сборка была перенесена внутрь NVidia Docker контейнера.


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


Наш конфигурационный файл для обучения на данных с трубами для 9 классов дефектов:
# Обучения на 9 классах без масокmodel = dict(    type='CascadeRCNN',    pretrained='torchvision://resnet50',    backbone=dict(        type='DetectoRS_ResNet',        depth=50,        num_stages=4,        out_indices=(0, 1, 2, 3),        frozen_stages=1,        norm_cfg=dict(type='BN', requires_grad=True),        norm_eval=True,        style='pytorch',        conv_cfg=dict(type='ConvAWS'),        sac=dict(type='SAC', use_deform=True),        stage_with_sac=(False, True, True, True),        output_img=True),    neck=dict(        type='RFP',        in_channels=[256, 512, 1024, 2048],        out_channels=256,        num_outs=5,        rfp_steps=2,        aspp_out_channels=64,        aspp_dilations=(1, 3, 6, 1),        rfp_backbone=dict(            rfp_inplanes=256,            type='DetectoRS_ResNet',            depth=50,            num_stages=4,            out_indices=(0, 1, 2, 3),            frozen_stages=1,            norm_cfg=dict(type='BN', requires_grad=True),            norm_eval=True,            conv_cfg=dict(type='ConvAWS'),            sac=dict(type='SAC', use_deform=True),            stage_with_sac=(False, True, True, True),            pretrained='torchvision://resnet50',            style='pytorch')),    rpn_head=dict(        type='RPNHead',        in_channels=256,        feat_channels=256,        anchor_generator=dict(            type='AnchorGenerator',            scales=[8],            ratios=[0.5, 1.0, 2.0],            strides=[4, 8, 16, 32, 64]),        bbox_coder=dict(            type='DeltaXYWHBBoxCoder',            target_means=[0.0, 0.0, 0.0, 0.0],            target_stds=[1.0, 1.0, 1.0, 1.0]),        loss_cls=dict(            type='CrossEntropyLoss', use_sigmoid=True, loss_weight=1.0),        loss_bbox=dict(            type='SmoothL1Loss', beta=0.1111111111111111, loss_weight=1.0)),    roi_head=dict(        type='CascadeRoIHead',        num_stages=3,        stage_loss_weights=[1, 0.5, 0.25],        bbox_roi_extractor=dict(            type='SingleRoIExtractor',            roi_layer=dict(type='RoIAlign', out_size=7, sample_num=0),            out_channels=256,            featmap_strides=[4, 8, 16, 32]),        bbox_head=[            dict(                type='Shared2FCBBoxHead',                in_channels=256,                fc_out_channels=1024,                roi_feat_size=7,                num_classes=9,                bbox_coder=dict(                    type='DeltaXYWHBBoxCoder',                    target_means=[0.0, 0.0, 0.0, 0.0],                    target_stds=[0.1, 0.1, 0.2, 0.2]),                reg_class_agnostic=True,                loss_cls=dict(                    type='CrossEntropyLoss',                    use_sigmoid=False,                    loss_weight=1.0),                loss_bbox=dict(type='SmoothL1Loss', beta=1.0,                               loss_weight=1.0)),            dict(                type='Shared2FCBBoxHead',                in_channels=256,                fc_out_channels=1024,                roi_feat_size=7,                num_classes=9,                bbox_coder=dict(                    type='DeltaXYWHBBoxCoder',                    target_means=[0.0, 0.0, 0.0, 0.0],                    target_stds=[0.05, 0.05, 0.1, 0.1]),                reg_class_agnostic=True,                loss_cls=dict(                    type='CrossEntropyLoss',                    use_sigmoid=False,                    loss_weight=1.0),                loss_bbox=dict(type='SmoothL1Loss', beta=1.0,                               loss_weight=1.0)),            dict(                type='Shared2FCBBoxHead',                in_channels=256,                fc_out_channels=1024,                roi_feat_size=7,                num_classes=9,                bbox_coder=dict(                    type='DeltaXYWHBBoxCoder',                    target_means=[0.0, 0.0, 0.0, 0.0],                    target_stds=[0.033, 0.033, 0.067, 0.067]),                reg_class_agnostic=True,                loss_cls=dict(                    type='CrossEntropyLoss',                    use_sigmoid=False,                    loss_weight=1.0),                loss_bbox=dict(type='SmoothL1Loss', beta=1.0, loss_weight=1.0))        ],        train_cfg=[            dict(                assigner=dict(                    type='MaxIoUAssigner',                    pos_iou_thr=0.5,                    neg_iou_thr=0.5,                    min_pos_iou=0.5,                    match_low_quality=False,                    ignore_iof_thr=-1),                sampler=dict(                    type='RandomSampler',                    num=512,                    pos_fraction=0.25,                    neg_pos_ub=-1,                    add_gt_as_proposals=True),                pos_weight=-1,                debug=False),            dict(                assigner=dict(                    type='MaxIoUAssigner',                    pos_iou_thr=0.6,                    neg_iou_thr=0.6,                    min_pos_iou=0.6,                    match_low_quality=False,                    ignore_iof_thr=-1),                sampler=dict(                    type='RandomSampler',                    num=512,                    pos_fraction=0.25,                    neg_pos_ub=-1,                    add_gt_as_proposals=True),                pos_weight=-1,                debug=False),            dict(                assigner=dict(                    type='MaxIoUAssigner',                    pos_iou_thr=0.7,                    neg_iou_thr=0.7,                    min_pos_iou=0.7,                    match_low_quality=False,                    ignore_iof_thr=-1),                sampler=dict(                    type='RandomSampler',                    num=512,                    pos_fraction=0.25,                    neg_pos_ub=-1,                    add_gt_as_proposals=True),                pos_weight=-1,                debug=False)        ],        test_cfg=dict(            score_thr=0.05, nms=dict(type='nms', iou_thr=0.5),            max_per_img=100)))train_cfg = dict(    rpn=dict(        assigner=dict(            type='MaxIoUAssigner',            pos_iou_thr=0.7,            neg_iou_thr=0.3,            min_pos_iou=0.3,            match_low_quality=True,            ignore_iof_thr=-1),        sampler=dict(            type='RandomSampler',            num=256,            pos_fraction=0.5,            neg_pos_ub=-1,            add_gt_as_proposals=False),        allowed_border=0,        pos_weight=-1,        debug=False),    rpn_proposal=dict(        nms_across_levels=False,        nms_pre=2000,        nms_post=2000,        max_num=2000,        nms_thr=0.7,        min_bbox_size=0),    rcnn=[        dict(            assigner=dict(                type='MaxIoUAssigner',                pos_iou_thr=0.5,                neg_iou_thr=0.5,                min_pos_iou=0.5,                match_low_quality=False,                ignore_iof_thr=-1),            sampler=dict(                type='RandomSampler',                num=512,                pos_fraction=0.25,                neg_pos_ub=-1,                add_gt_as_proposals=True),            pos_weight=-1,            debug=False),        dict(            assigner=dict(                type='MaxIoUAssigner',                pos_iou_thr=0.6,                neg_iou_thr=0.6,                min_pos_iou=0.6,                match_low_quality=False,                ignore_iof_thr=-1),            sampler=dict(                type='RandomSampler',                num=512,                pos_fraction=0.25,                neg_pos_ub=-1,                add_gt_as_proposals=True),            pos_weight=-1,            debug=False),        dict(            assigner=dict(                type='MaxIoUAssigner',                pos_iou_thr=0.7,                neg_iou_thr=0.7,                min_pos_iou=0.7,                match_low_quality=False,                ignore_iof_thr=-1),            sampler=dict(                type='RandomSampler',                num=512,                pos_fraction=0.25,                neg_pos_ub=-1,                add_gt_as_proposals=True),            pos_weight=-1,            debug=False)    ])test_cfg = dict(    rpn=dict(        nms_across_levels=False,        nms_pre=1000,        nms_post=1000,        max_num=1000,        nms_thr=0.7,        min_bbox_size=0),    rcnn=dict(        score_thr=0.05, nms=dict(type='nms', iou_thr=0.5), max_per_img=100))dataset_type = 'CocoDataset'data_root = 'data/coco/'classes = ('ПРМУ_поперечная трещина на изгибе', 'ПРМУ_выход трубы из ряда',           'ПРМУ_Крип', 'ПРМУ_свищи', 'ПРМУ_разрыв трубы',           'ПРМУ_поперечная трещина в околошовной зоне',           'ПРМУ_трещина в основном металле', 'ПРМУ_продольные трещины',           'ПРМУ_Цвета побежалости')img_norm_cfg = dict(    mean=[123.675, 116.28, 103.53], std=[58.395, 57.12, 57.375], to_rgb=True)train_pipeline = [    dict(type='LoadImageFromFile'),    dict(type='LoadAnnotations', with_bbox=True),    dict(type='Resize', img_scale=(1280, 720), keep_ratio=True),    dict(type='RandomFlip', flip_ratio=0.5),    dict(        type='Normalize',        mean=[123.675, 116.28, 103.53],        std=[58.395, 57.12, 57.375],        to_rgb=True),    dict(type='Pad', size_divisor=32),    dict(type='DefaultFormatBundle'),    dict(type='Collect', keys=['img', 'gt_bboxes', 'gt_labels'])]test_pipeline = [    dict(type='LoadImageFromFile'),    dict(        type='MultiScaleFlipAug',        img_scale=(1280, 720),        flip=False,        transforms=[            dict(type='Resize', keep_ratio=True),            dict(type='RandomFlip'),            dict(                type='Normalize',                mean=[123.675, 116.28, 103.53],                std=[58.395, 57.12, 57.375],                to_rgb=True),            dict(type='Pad', size_divisor=32),            dict(type='ImageToTensor', keys=['img']),            dict(type='Collect', keys=['img'])        ])]data = dict(    samples_per_gpu=2,    workers_per_gpu=1,    train=dict(        type='CocoDataset',        classes=('ПРМУ_поперечная трещина на изгибе',                 'ПРМУ_выход трубы из ряда', 'ПРМУ_Крип', 'ПРМУ_свищи',                 'ПРМУ_разрыв трубы',                 'ПРМУ_поперечная трещина в околошовной зоне',                 'ПРМУ_трещина в основном металле', 'ПРМУ_продольные трещины',                 'ПРМУ_Цвета побежалости'),        ann_file='data/coco/annotations/instances_train.json',        img_prefix='data/coco/train/',        pipeline=[            dict(type='LoadImageFromFile'),            dict(type='LoadAnnotations', with_bbox=True),            dict(type='Resize', img_scale=(1280, 720), keep_ratio=True),            dict(type='RandomFlip', flip_ratio=0.5),            dict(                type='Normalize',                mean=[123.675, 116.28, 103.53],                std=[58.395, 57.12, 57.375],                to_rgb=True),            dict(type='Pad', size_divisor=32),            dict(type='DefaultFormatBundle'),            dict(type='Collect', keys=['img', 'gt_bboxes', 'gt_labels'])        ]),    val=dict(        type='CocoDataset',        classes=('ПРМУ_поперечная трещина на изгибе',                 'ПРМУ_выход трубы из ряда', 'ПРМУ_Крип', 'ПРМУ_свищи',                 'ПРМУ_разрыв трубы',                 'ПРМУ_поперечная трещина в околошовной зоне',                 'ПРМУ_трещина в основном металле', 'ПРМУ_продольные трещины',                 'ПРМУ_Цвета побежалости'),        ann_file='data/coco/annotations/instances_val.json',        img_prefix='data/coco/val/',        pipeline=[            dict(type='LoadImageFromFile'),            dict(                type='MultiScaleFlipAug',                img_scale=(1280, 720),                flip=False,                transforms=[                    dict(type='Resize', keep_ratio=True),                    dict(type='RandomFlip'),                    dict(                        type='Normalize',                        mean=[123.675, 116.28, 103.53],                        std=[58.395, 57.12, 57.375],                        to_rgb=True),                    dict(type='Pad', size_divisor=32),                    dict(type='ImageToTensor', keys=['img']),                    dict(type='Collect', keys=['img'])                ])        ]),    test=dict(        type='CocoDataset',        classes=('ПРМУ_поперечная трещина на изгибе',                 'ПРМУ_выход трубы из ряда', 'ПРМУ_Крип', 'ПРМУ_свищи',                 'ПРМУ_разрыв трубы',                 'ПРМУ_поперечная трещина в околошовной зоне',                 'ПРМУ_трещина в основном металле', 'ПРМУ_продольные трещины',                 'ПРМУ_Цвета побежалости'),        ann_file='data/coco/annotations/instances_val.json',        img_prefix='data/coco/val/',        pipeline=[            dict(type='LoadImageFromFile'),            dict(                type='MultiScaleFlipAug',                img_scale=(1280, 720),                flip=False,                transforms=[                    dict(type='Resize', keep_ratio=True),                    dict(type='RandomFlip'),                    dict(                        type='Normalize',                        mean=[123.675, 116.28, 103.53],                        std=[58.395, 57.12, 57.375],                        to_rgb=True),                    dict(type='Pad', size_divisor=32),                    dict(type='ImageToTensor', keys=['img']),                    dict(type='Collect', keys=['img'])                ])        ]))evaluation = dict(interval=1, metric='bbox')optimizer = dict(type='SGD', lr=0.0001, momentum=0.9, weight_decay=0.0001)optimizer_config = dict(grad_clip=None, type='OptimizerHook')lr_config = dict(    policy='step',    warmup=None,    warmup_iters=500,    warmup_ratio=0.001,    step=[8, 11],    type='StepLrUpdaterHook')total_epochs = 12checkpoint_config = dict(interval=-1, type='CheckpointHook')log_config = dict(    interval=10,    hooks=[dict(type='TextLoggerHook'),           dict(type='TensorboardLoggerHook')])dist_params = dict(backend='nccl')log_level = 'INFO'load_from = './checkpoints/detectors_cascade_rcnn_r50_1x_coco-32a10ba0.pth'resume_from = Noneworkflow = [('train', 1)]work_dir = './logs'seed = 0gpu_ids = range(0, 1)

Обучение модели


Обучение модели занимало порядка 2 часов на RTX 2080 Ti. Прогресс можно было отслеживать с помощью запущенного tensorboard. Ниже приведены графики эволюции метрик и функции ошибки в процессе обучения на реальных данных.



Рис. 16 Зависимость mAP от числа итераций обучения для архитектуры DetectoRS для датасета дефектов труб по 9 классам на валидационном датасете.



Рис. 17 Зависимость функции потерь (multiclass cross entropy) от числа итераций обучения для архитектуры DetectoRS для датасета дефектов труб по 9 классам.


Подготовка модели для использования


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


Стоит отметить, что, несмотря на то, что данная архитектура обучается в несколько раз быстрее чем YOLOv4, она занимает в 2 раза больше памяти (500 MB для DetectoRS против 250 MB для YOLOv4 для хранения весов модели) и работает на порядок медленнее (1 с. для DetectoRS против 10 мс. для YOLOv4).


Малое время обучения DetectoRS отчасти объясняется тем, что веса базовых слоев сети (backbone и neck) были взяты из претренированной на ImageNet датасете аналогичной архитектуры и в процессе обучения не изменялись. Такой прием называется transfer learning. Про него вы можете подробнее прочитать в этой заметке.


Darknet


Оригинальная реализация YOLOV4 написана на C c использованием CUDA C. Было принято решение использовать оригинальную реализацию модели, хотя обычно доминируют эксперименты на Python. Это накладывало свои ограничения и риски, связанные с необходимость разбираться в коде на C, в случае каких-то проблем или переделки частей под свои нужды. Подробной документации с примерами не было, поэтому пришлось в некоторых местах смотреть исходный код на C.
Для успешного запуска нужно было решить несколько проблем:


  1. Собрать проект.
  2. Понять что необходимо для обучения модели.
  3. Обучить модель.
  4. Подготовить код для использования.

Сборка проекта


Первая сборка проекта происходила на Windows. Для сборки использовался CMake, поэтому особых проблем с этим не возникло. Единственная проблема была с компиляцией динамической библиотеки для обёртки на Python. Пришлось редактировать файл с проектом для Visual Studio, чтобы включить поддержку CUDA. Динамическая библиотека была нужна т. к. это позволяло использовать код на Python для запуска модели.


После было принято решение перенести сборку внутрь Docker контейнера. Для того чтобы оставить возможность использовать видеокарту был установлен NVIDIA Container Toolkit. Это позволяет достаточно просто организовать перенос проекта на другую машину при необходимости, а также упрощает дальнейшее использование. Благодаря наличию различных образов nvidia/cuda на Docker Hub, можно достаточно просто менять конфигурации. Например, переключение между различными версиями CUDA или cuDNN.


Необходимые файлы для обучения


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


Обучение модели


Обучение модели производилось в контейнере NVIDIA Docker. Занимало порядка 12 часов на RTX 2080 Ti. Прогресс можно было отслеживать периодически копируя график функции потерь из контейнера на хост, где запущен контейнер. Красным показано значение mAP на тестовой выборке.



Рис. 18 График обучения YOLOv4.


Подготовка модели для использования


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


Результаты


После всех экспериментов были выявлены следующие зависимости:


  • Обучение на смеси данных (реальные + искусственные) приводит к небольшому ухудшению обобщающей способности (это связано с тем, что в данной итерации генератора синтетических данных присутствуют недостаточно разнообразные данные и однотипное освещение);
  • После недолгого подбора гиперпараметров для архитектуры DetectoRS удалось добиться показателя $mAP (IoU=0.5) = 0.85$, а для архитектуры YOLOv4 $mAP(IoU=0.5) = 0.74$ ;
  • Некоторые типы дефектов, такие как разного рода трещины или выход труб из ряда, детектируются лучше чем иные типы дефектов, такие как вздутие труб. Это можно объяснить не только дисбалансом и малым количеством примеров, но и тем, что для определения некоторых типов дефектов, нужно больше пространственной информации (аналогично этому, в реальной детекции дефектов, некоторые дефекты определяются на глаз, а некоторые требуют специальных измерительных приборов). Потенциально, использование помимо RGB каналов с камеры также еще и канала глубины (RGB-D) могло бы помочь с детектированием этих сложных пространственных дефектов: в этом случае мы смогли бы прибегнуть к методам и алгоритмама 3D ML.


Рис. 19 Сравнение работы архитектур, обученных на разных датасетах: Real только реальные изображение, Mixed обучении на смеси реальных и синтетических изображений, Mask обучения на реальных изображениях, с многоугольной разметкой областей.



Рис. 20 Пример некорректной детекции модели DetectoRS, обученной на синтетических данных отсутствие посторонних предметов в синтетическом датасете приводит к определению куска деревянной балки как трещины.



Рис. 21 Пример детекции дефектов в виде многоугольных масок модели DetectoRS, обученной на датасете реальных изображений с соответствующей разметкой.



Рис. 22 Пример детекции дефектов в виде прямоугольников для одного и того же изображения с помощью разных архитектур.


Финальное решение с интерфейсом


В итоге, был получен работающий прототип ПО с интерфейсом, который можно было бы просто использовать как со стороны пользователя так и для разработчиков. Так как проекты на основе ML часто требуют вычислительных мощностей видеокарт NVIDIA для своей работы, то было принято решение сделать клиент-серверное приложение. При необходимости всё можно развернуть на одном компьютере. Если же есть в наличии свободный сервер с видеокартой, то основную логику можно перенести туда, оставив возможность любым пользователям использовать сервис даже на слабом по вычислительным возможностям компьютере.


Для архитектуры ПО была выбрана следующая схема:
Финальное решение с интерфейсом
В итоге, был получен работающий прототип ПО с интерфейсом, который можно было бы просто использовать как со стороны пользователя так и для разработчиков. Так как проекты на основе ML часто требуют вычислительных мощностей видеокарт NVIDIA для своей работы, то было принято решение сделать клиент-серверное приложение. При необходимости всё можно развернуть на одном компьютере. Если же есть в наличии свободный сервер с видеокартой, то основную логику можно перенести туда, оставив возможность любым пользователям использовать сервис даже на слабом по вычислительным возможностям компьютере.


Для архитектуры ПО была выбрана следующая схема:

Рис. 23 Схема архитектуры прототипа ПО для детектирования дефектов на изображениях.


Все модели для детектирования дефектов работают внутри NVIDIA Docker. Остальные части, кроме Web-интерфейса внутри обычных контейнеров Docker. Логика работы следующая:


  1. Пользователь загружает изображение и выбирает нужную модель вместе с порогом принятия решения.
  2. Изображение отправляется на сервер. Оно сохраняется на диске и сообщение с заданием на обработку попадает в RabbitMQ, в соответствующую очередь.
  3. Модель берёт сообщение с заданием из очереди, когда готова выполнить предсказание. Выполняет предсказание и сохраняет необходимые файлы на диск. Отправляет сообщение в RabbitMQ о готовности результат.
  4. Когда результат готов он отображается в web-интерфейсе.

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


RabbitMQ также позволяет изолировать систему друг от друга и начать разработку независимо. При разработке сервера не надо дожидаться реализации ML моделей и наоборот. Каждая система общается только с RabbitMQ. В случае падения какого-то сервиса сообщение не потеряется, если соответствующим образом настроить RabbitMQ.



Рис. 24 Демонстрация работы созданного прототипа ПО для детекции дефектов на трубах.


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


Основные источники


Статьи описывающие SOTA глубокие архитектуры в задаче детекции:

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

Фундаментальные монографии на тему современного компьютерного зрения:
  • Szeliski, R., 2010. Computer vision: algorithms and applications. Springer Science & Business Media.
  • Nixon, M. and Aguado, A., 2019. Feature extraction and image processing for computer vision. Academic press.
  • Jiang, X. ed., 2019. Deep Learning in Object Detection and Recognition. Springer.
  • Pardo, A. and Kittler, J. eds., 2015. Progress in Pattern Recognition, Image Analysis, Computer Vision, and Applications: 20th Iberoamerican Congress, CIARP 2015, Montevideo, Uruguay, November 9-12, 2015, Proceedings (Vol. 9423). Springer.
Подробнее..

Как я Лигу Легенд парсил

19.04.2021 00:19:54 | Автор: admin

Привет, Хабр!

Осмелюсь сегодня рассказать, как мне довелось извлекать данные прямо из видеозаписей турнирных игр по игре League of Legends с помощью глубоких нейронных сетей: зачем это нужно, какие архитектуры и приёмы использовались, и с какими сложностями я столкнулся.

Шаг 0: Разбираемся, что к чему

Лига Легенд (League of Legends, LoL) - популярная MOBA игра, с ежемесячной аудиторией более чем в 100 млн игроков всему миру. LoL была разработана компанией Riot Games и выпущена в далёком 2009-м году.

Именно благодаря политике компании Riot Games мне и довелось познакомиться с данной задачей извлечения статистик прямо из турнирных видео. Дело в том, что Риоты с очень большой неохотой отдают турнирные данные, из-за чего проведение анализа сильно осложняется. К примеру китайский чемпионат вообще имеет закрытое апи и получить ну хоть какие-то игровые статистики становится невозможным. Почти...

Скриншот стрима матча континентальной лиги. LCL Летний Сплит 2020.Скриншот стрима матча континентальной лиги. LCL Летний Сплит 2020.

Для начала разберёмся с игровым HUD-ом (Heads-Up Display - визуальный интерфейс игры). На картинке выше цветами выделены основные его части:

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

  2. Боковые панели (фиолетовый цвет) - две панели, содержащие в себе информацию о чемпионах - игровых персонажах обеих команд. Панели показывают количество жизненной силы и маны чемпиона, а также набор его способностей с индикацией использования.

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

  4. Нижняя панель (красный цвет) - наиболее нагруженная панель, показывающая более детальные статистики по каждому чемпиону, такие как: K/D/A (Kills/Deaths/Assists), кол-во убитых миньонов, а также набор предметов в инвентаре чемпиона.

Шаг 1: Парсинг верхней панели

  1. Первично была произведена аннотация данных. С использованием утилиты CVAT были выделены обрамляющими прямоугольниками необходимые поля, а также в ручном режиме выписаны их числовые значения. Хоть и задача распознавания чисел на картинке и не кажется сложной для современных нейронных сетей, в ручном режиме необходимость всё же была, т.к. эти самые современные нейронные сети (Google OCR, Yandex OCR) показали откровенно плохие результаты на некоторых типах полей при тестировании, не говоря уже об открытых движках для распознавания (Tesseract OCR, EasyOCR).

  2. Далее нам необходимо научиться выделять (детектировать) нужные нам поля. Для решения данной задачи было принято использовать segmentation-based подход. Я взял сеть Unet c предобученным efficientnet энкодером и обучил решать задачу instance сегментации для трех классов: башни (жёлтый цвет), золото (зелёный цвет) и кол-во убийств (фиолетовый цвет). Имплементация модели была взята из репозитория segmentation_models.pytorch. Хороший код для обучения таких моделей на Pytorch Lightning вы можете найти в репозитории Владимира Игловикова по сегментированию одежды.

  3. Теперь мы умеем получать маски с классами, а после применения watershed алгоритма даже целые отдельные области нахождения наших полей. Пора научиться их распознавать. Вдохновением для решения данной задачи послужили статьи по распознаванию номеров домов SVHN , а конкретно multihead архитектуры. Идея такова, что если мы имеем последовательности чисел фиксированной небольшой длины (а мы имеем), то мы можем не возиться с RNN или же детектировать отдельные символы. Мы можем взять энкодер и поставить за ним несколько отдельных голов по количеству цифр в числе, с 11-ю (11-ый для случаев, когда цифры нет) выходами в каждой. Каждая голова будет отвечать за предсказание отдельной цифры в числе, но учится все они будут вместе. Схожий подход можно найти имплементированным на Pytorch здесь.

  4. Но не будем забывать, что мы имеем дело с видео. Т.е. нам нужно научить модель работать с последовательностями кадров. Для этого был выбран простейший подход: а давайте просто заменим все 2D свёртки на трёхмерные. Как итог, практически без изменения архитектуры сети, мы добиваемся нужного результата, ведь размерности выходов у этих слоёв одинаковы. Добавление 3D свёрток сильно уменьшает количество выбросов распознавания, ведь сеть учится, что после числа N может идти либо само это чисто, либо же N+1.

  5. Последним шагом остаётся постпроцессинг выходных данных модели: маппинг статистик ко значению таймера, сглаживание выбросов, интерполяция игровых пауз и моментов повторов, в случаях, когда интерфейсы закрыты или не видны совсем.

Multihead OCR архитектураMultihead OCR архитектураНо зачем таймер-то парсить?

Если вы подумали, что в этом нет необходимости и зная одно лишь начало игры таймер восстанавливается однозначно, то я поспешу вас расстроить. Таймер на видео (внимание) не является линейным. Скажу даже больше, если вам интересно - можете посмотреть данный пятисекундный ролик и объяснить, почему семнадцатая секунда длится по времени как две :/

Шаг 2: Парсинг боковых панелей

  1. Размечаем маски необходимых нам полей, куда без этого: заклинания чемпиона (синий цвет), иконка чемпиона (красный цвет), здоровье и мана чемпиона (салатовый и фиолетовый цвета соотвественно). Обучаем Unet из прошлого шага.

  2. Начнём с самого простого: определение кол-ва здоровья и маны чемпиона. Для начала стоит предобработать вырезанные прямоугольные области . Переведём их в цветовую модель HSV (данный переход нужен для более простых манипуляций с цветами) и оставим лишь цвета из нужного диапазона: зелёного для здоровья и синего для маны. Теперь всё просто: итерируемся по матричному представлению одного из каналов изображения (или всех вместе) и ищем наиболее резкий переход между цветом и его подложкой. Разделив x-координату на размер всей области, из относительного перехода получаем значение кол-ва здоровья/маны чемпиона. Данный элементарный метод, как ни странно, показывает отличные результаты, являясь в достаточной мере робастным и точным.

  3. Далее нам нужно распознать классы заклинаний чемпиона и их статус(готовы заклинания к использованию или нет). О статусе заклинания можно узнать по наличию постепенно уходящего затемнения по часовой стрелке , чуть позже мы научимся определять прогресс этого отката. А пока вспомним, что такое multihead архитектура нейронной сети и применим ёё и здесь. Вместо предобученного энкодера возьмём простой набор из нескольких свёрточных и пуллинг слоёв в качестве кодировщика и пару голов с полносвязными слоями, одна из которых будет отвечать за предсказание класса заклинания, а другая за его состояние (активен/нет активен).

  4. К сожалению, свёрточные нейронные сети хуже приспособлены к тому, чтобы решать регрессионные задачи, а задача определения прогресса, когда заклинание будет готово является таковой. В процессе рисёрча я наткнулся на статью, в которой исследователи (да, кто-то публикует статьи про LoL) решили её за меня. Основная идея заключалась в том, чтобы взять неглубокую свёрточную нейросеть и решать ей задачу классификации, разделив набор вариантов отката заклинания на 20-ть интервалов воспринимая их как 20-ть отдельных классов и считая финальное число как взвешенную среднюю активаций выходных нейронов. Я слегка изменил данный подход, сделав не 20-ть, а 100 интервалов, вычисляя финальное число как argmax по выходам сети (в целом, так делать теоретически правильнее).

  5. На сладкое остаётся задача классификации чемпиона по его иконке. Её также можно решать обучив простую нейросеть классификации. На первое время так и сделаем, для того, чтобы набрать данных для обучения другой сети, более архитектурно подходящей для наших баранов. Подход с простой сетью-классификатором ограничен проблемой, которая называется OOD (Out-of-Domain), ведь разработчики достаточно часто добавляют новых чемпионов в игру, и чтобы не переобучать сеть каждый раз я решил обучить другую, основанную на подходе metric learning. Данный подход позволяет обучать сеть находить схожие изображения. Для этого я взял простую сеть-классификатор и вместо оптимизации кросс-энтропии и случайного сэмплирования оптимизировал hinge-loss с hard-negative triplet сэмплированием, чтобы сеть научалась выучивать эмбеддинги чемпионов.

Мяу?

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

Но зачем hinge-loss-то обучать?

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

Шаг 3: Парсинг мини-карты

  1. Ещё одна задача, которую кто-то уже решал до меня. Человек под ником Farza собрал датасет из ста тысяч изображений (огромное спасибо ему за это) и обучил на нём модель детекции Yolo, получив неплохие метрики детекции, но не особо хорошие классификации чемпионов. Давайте применим немного другой подход, используя его датасет.

  2. Yolo - anchor-based архитектура детекции, которая не очень хорошо справляется с коллизиями объектов. Мне же нравится идея применить segmentation-based подход. Для начала сгенерируем маски. Используя имеющуюся разметку будем генерировать два вида масок: маска чемпиона (синий цвет), граница изображения чемпиона (желтый цвет). Это довольно известный приём для instance сегментации, идея которого состоит в предсказании всего двух видов масок, а потом в вычитании границ.

  3. Вновь обучаем Unet.

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

  5. Вжух, всё готово. Остаётся только лишь эвристически выровнять координаты чемпионов на мини-карте.

Шаг N: Итоги

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

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

Спасибо за внимание!

Подробнее..

Поиск изображений с помощью AffNet

29.01.2021 02:04:02 | Автор: admin
Перед нами стояла задача сравнения изображений (image matching) для поиска изображения максимально подобного данному изображению из коллекции. В этой статье я расскажу как мы использовали для этой задачи подход на основе нейронных сетей под названием AffNet. Кому интересно, прошу под кат.

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

Первоначально мы попробовали стандартный матчинг изображений с использованием дескрипторов признаков SIFT и матчера FLANN из библиотеки OpenCV, а также Bag-of-Words. Оба подхода давали слабые результаты. Bag-of-Words к тому же требует огромный датасет изображений и много времени для обучения.

Обзор подхода AffNet


На использование подхода нас вдохновил вебинар Points & Descriptors, который прошлым летом проводил CVisionLab. Для всех заинтересованных здесь доступны слайды с этого вебинара. На этом вебинаре представили интересный подход: AffNet + HardNet. Результаты матчинга, представленные на слайдах нас впечатлили и мы решили попробовать его в нашей задаче. По словам авторов AffNet это инновационный метод для обучения регионов ковариантных к афинной трансформации с функцией постоянной отрицательной потери (hard negative-constant loss), который обходит state-of-the-art подходы типа Bag-of-Words на задачах матчинга изображений и wide baseline stereo.

image

HardNet это новый компактный обучаемый дескриптор признаков, показавший лучшую эффективность state-of-art в сравнении с классическими и обучаемыми дескрипторами признаков и который возможно быстро вычислять на GPU (ссылка на статью). Он доступен публично на github. Здесь есть хороший пример матчинга изоображений с большой афинной трансформацией с использованием AffNet.

image

Авторы AffNet объясняют подход более детально в своей статье.

Тестирование подхода AffNet


Клонируем репозиторий с github:

git clone https://github.com/ducha-aiki/affnet.git


Затем переходим в папку affnet.
Запустим Jupyter:

jupyter notebook


и откроем ноутбук SIFT-AffNet-HardNet-kornia-matching.ipynb в папке examples. Сначала установим все завивисимости. Создадим ячейку в верху ноутбука и запустим ее

!pip install kornia pydegensac extract_patches


Если у вас установлен OpenCV версии 4 вы можете получить ошибку из-за функции SIFT_create:

The function/feature is not implemented) This algorithm is patented and is excluded in this configuration;


. Дело в том, что начиная с версии 3.4.2.16 SIRF и SURF больше недоступны в основном репозитории opencv, они были вынесены в отдельный пакет opencv-contrib. Установим OpenCV и opencv-contrib:

pip install opencv-python==3.4.2.16pip install opencv-contrib-python==3.4.2.16

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

30.0 inliers found

image

Довольно неплохой результат для таких изображений.
Для пайплайна DoG-AffNet-OriNet-HardNet

18.0 inliers found


image

И наконец для пайплайна DoG-OriNet-HardNet

25.0 inliers found


image

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

Теперь осталось попробовать AffNet на своих изображениях.
Чтобы интегрировать AffNet в свою программу вам нужно только установить все необходимые библиотеки, скачать предобученные веса модели и скопировать несколько функций из ноутбука SIFT-AffNet-HardNet-kornia-matching.ipynb.

Можно легко сконвертировать Jupyter ноутбук в скрипт python с помощью утилиты jupyter nbconvert. Установим ее через pip:

pip install nbconvert


и запустим конвертацию:

jupyter nbconvert SIFT-AffNet-HardNet-kornia-matching.ipynb --to python


На этом все. Удачи в использовании AffNet для матчинга изоображений и до новых встреч.
Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru