Меня зовут Денис Власов, я Data Scientist в Uchi.ru. Используя модели машинного обучения, мы сделали гифки из записей онлайн-уроков — последовательность из нескольких кадров с наиболее яркими эмоциями учеников.
Эти гифки были получены их родителями в рассылке по электронной почте.
Вместе с Data Scientist @DariaV Даша Васюкова расскажет, как сделать MVP на основе видео низкого разрешения без каких-либо знаний в Computer Vision, а только с помощью открытых библиотек и готовых моделей.
В конце бонусом является виджет для быстрого разметки кадров.
Откуда у нас вообще появилась идея распознавать эмоции? Дело в том, что в Учи.
ру мы развиваем онлайн-школу Учи.
Дома — сервис персональных видеоуроков для школьников.
Но поскольку такое занятие представляет собой чисто человеческое взаимодействие, возникла идея «приложить» к нему немного аналитики.
Эти данные могут помочь увеличить конверсию, отслеживать эффективность уроков, измерять вовлеченность учащихся и многое другое.
Если у вас, как и у нас, нет задачи RealTime выявления эмоций, вы можете пойти простым путем: проанализировать записи уроков.
Маркеры начала и конца
Обычно продолжительность записи камеры ученика не равна фактической продолжительности урока.Студенты часто подключаются поздно, и во время занятий могут быть отключения и повторные подключения.
Поэтому для начала мы определили, что именно мы будем считать уроком.
Для этого мы сопоставили записи с камер учеников и преподавателей.
Видео учителя помогли определить период урока: он начинается, когда обе камеры включены одновременно, и заканчивается, когда хотя бы одна камера полностью выключена.
Разделил видео на кадры
Для упрощения анализа полученные фрагменты видеороликов студентов были разрезаны на картинки.
Нам хватило одного кадра в секунду: если ребенок проявил какую-то эмоцию, она будет присутствовать на лице несколько секунд. Более высокая степень детализации усложнила бы разметку, но не оказала бы существенного влияния на результат.
Мы научились распознавать улыбки детей (и не только)
Необходимо обнаружить лицо на каждом кадре.Если оно есть, проверьте, родитель это или ученик, а также оцените эмоции на лице.
И здесь было несколько нюансов, которые нужно было учесть.
Проблема 1. На изображениях низкого качества труднее распознавать лица
Пользовательские видео часто бывают низкого качества даже без учета сжатия видео.Например, студент может заниматься в темной комнате, в кадре может быть включена настольная лампа или люстра, позади студента может быть яркое окно, а лицо может быть не полностью в кадре.
Стандартный DNN Face Detector из библиотеки OpenCV, который мы сначала взяли за основу, на наших данных давал неточные результаты.
Оказалось, что алгоритм недостаточно хорошо справляется с реальными кадрами из видеочатов: иногда он пропускает лица, которые четко присутствуют в кадре, находит только одно из двух лиц или идентифицирует лица там, где их нет.
Стандартный детектор лиц DNN мог обнаружить рисунок лица на занавеске, плюшевого мишку или даже композицию картин на стене и стуле.
Поэтому мы решили попробовать обучить наш детектор.
Для этого мы взяли реализацию модели RetinaNet на PyTorch. Мы использовали результаты стандартного детектора в качестве обучающих данных и убедились, что новая модель научилась находить лица.
Затем мы подготовили обучающую и проверочную выборку, просматривая и при необходимости корректируя результаты детектора на новых кадрах: корректировать разметку работающей модели быстрее, чем размечать лица на кадре с нуля.
Мы размечали итеративно: после добавления новой порции размеченных кадров мы повторно обучали модель.
А проверив ее работу, мы сохранили разметку для новых кадров, увеличив обучающую выборку.
Всего мы разметили 2624 кадра из 388 видеороликов, в которых в общей сложности было 3325 лиц.
Таким образом, мы смогли подготовить детектор, который был более чувствителен в наших условиях.
В проверочном наборе из 140 кадров старый детектор нашел 150 лиц, но пропустил 38. Новый пропустил только 5 и правильно обнаружил 183.
Проблема 2. В кадре не только ребенок
Поскольку видеоуроки часто посещают не только дети, но и родители, важно научить модель отличать одну от другой.В нашем случае это дает уверенность, что в гифке родитель увидит своего ребенка, а не себя.
Также данные о присутствии родителя на уроке могут помочь в анализе показателей продукта.
Мы обучили две отдельные модели.
На момент эксперимента не было необходимых общедоступных наборов данных, поэтому мы сами маркировали обучающие данные.
Первая модель должна определить, кому принадлежит лицо в кадре: родителю или ученику.
Кажется, с маркировкой проблем возникнуть не должно было, поскольку отличить взрослого человека от ребенка легко.
Это справедливо, если перед нами целое видео.
Но когда мы имеем дело с отдельными кадрами, оказывается, что:
- возраст людей в кадре с низким разрешением становится неясным;
- дети присутствуют в кадре почти весь урок, а взрослые – несколько минут.
Так мы назвали тип кадров, когда камера направлена на ученика, но видно, что рядом сидит родитель.
Обычно на таких занятиях видно плечо сидящего рядом взрослого или только локоть.
Родитель присутствует во всех трёх кадрах, но найти его в отдельном кадре может быть сложно.
Вторая модель должна была найти именно такие родительские руки.
Очевидно, что детектор лиц в данной задаче неприменим, поэтому тренироваться придется на целых кадрах.
Конечно, мы не нашли подобных датасетов в открытом доступе и отметили около 250 000 кадров, на которых есть «часть» родителя, и кадров без них.
Отметок на порядок больше, чем в других задачах, потому что разметка гораздо проще: можно просмотреть не отдельные кадры, а фрагменты видео и в несколько кликов отметить, например, что родитель присутствовал эти 15 минут( 900 кадров!) На кабинете урока с аналитикой доступны графики присутствия родителей по обеим моделям.
Они помогают понять, когда родитель просто заинтересован в процессе урока, а когда он, скорее, общается с учителем.
На верхнем графике — вероятность присутствия родителя хотя бы с «плечом», а на нижнем — вероятность того, что родитель смотрит в камеру, например, общаясь с учителем.
Проблема 3: Дети улыбаются по-разному.
На практике оказалось, что понять, улыбается ребенок или нет, не так-то просто.
И если с улыбающимися парнями проблем нет, то обнаружить сдержанные улыбки оказывается нетривиальной задачей даже для человека.
За основу классификатора настроения мы взяли предварительно обученную модель ResNet34 из библиотеки fast.ai. Ээта же библиотека использовалась для дальнейшего обучения модели в два этапа: сначала на общедоступных наборах данных выражения лица И УЛЫБКАУлыбкаD с веселыми и нейтральными лицами, а затем на нашем наборе данных, размеченном от руки, с кадрами с камер студентов.
Мы решили включить общедоступные наборы данных, чтобы расширить размер выборки и помочь модели получить изображения более высокого качества, чем видеокадры с планшетов и веб-камер наших студентов.
Размечено с помощью пользовательского виджета.
Все изображения были подвергнуты одной и той же процедуре предварительной обработки:
- Масштабирование кадра до 64 на 64 пикселя.
В общедоступных наборах данных изображения уже квадратные, поэтому масштабирование не искажает пропорции.
В нашем собственном наборе данных мы сначала расширили обнаруженную область с грани до квадрата, а затем масштабировали ее.
- Сведение к черно-белой палитре.
Визуально черно-белые изображения показались нам «чище», кроме того, один из публичных датасетов уже был в черно-белом формате.
Что ж, интуитивно кажется, что для определения улыбки цвета вообще не нужны, что и было подтверждено в экспериментах.
- Увеличение.
Позволяет увеличить эффективный размер выборки в несколько раз и учесть особенности данных.
- Нормализация цветов с помощью нормализатора CLAHE из библиотеки OpenCV. Такое ощущение, что эта нормализация лучше других справляется с контрастностью переэкспонированных или темных изображений.
Дополнительное обучение модели распознаванию улыбки
1. Дополнения
При дополнительном обучении мы использовали достаточно строгие аугментации:- Изображение отражалось горизонтально.
- Повернуто на случайное значение.
- Для изменения контрастности и яркости были применены три различных искажения.
- Они брали не всю картинку, а квадрат, составлявший не менее 60% площади исходного изображения.
- Отрезаем его с одной из четырех сторон, вставив на место отрезанной части черный прямоугольник.
Остальные дополнительно позволяют нам приблизить общедоступные наборы данных к нашей задаче.
Особенно пригодилась последняя самописная аугментация.
Она имитирует студента, камера которого смотрит немного в сторону, в результате чего его лицо оказывается обрезанным в кадре.
Когда лицо распознается и дополняется квадратом, обрезанная часть превращается в черную область.
Без аугментации таких изображений было недостаточно, чтобы модель научилась понимать, что это такое, но достаточно, чтобы в среднем испортить качество.
Более того, эти ошибки были очевидны для человека.
Пример аугментации в одном изображении.
Для наглядности аугментации были сделаны до масштабирования до разрешения 64x64Код для аугментаций
# ! pip freeze | grep fastai # fastai==1.0.44 import fastai import matplotlib.pyplot as plt from matplotlib import cm from matplotlib import colors import seaborn as sns %matplotlib inline from pylab import rcParams plt.style.use('seaborn-talk') rcParams['figure.figsize'] = 12, 6 path = '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.52
import cv2
import matplotlib.pyplot as plt
from matplotlib import cm
from matplotlib import colors
import seaborn as sns
%matplotlib inline
from pylab import rcParams
plt.style.use('seaborn-talk')
rcParams['figure.figsize'] = 12, 6
path = '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 = 4
imsize = 3
fig, 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 кадров.
Скорее всего, даже при таком объёме данных качество ещё не начало выходить на плато, но мы это не перепроверяли.
Это упражнение называется построение кривой обучения, и оно еще раз подтверждает тезис: объем данных — самое важное в модели машинного обучения.
Качество отложенной выборки возрастает по мере увеличения выборки для дополнительного обучения.
4. Google Images для обогащения образцов
Мы попытались разобрать первые 1000 результатов изображений по запросам в духе счастливые, недовольные, улыбающиеся, нейтральные и т.д. Мы не рассчитывали получить качественные данные, поэтому планировали потом просмотреть их своими глазами и удалить те, которые были совершенно непригодны.В результате мы быстро поняли, что никакая фильтрация не спасет эти картинки, поэтому полностью отказались от этой идеи.
Примеры изображений по запросам счастливый и несчастный В результате мы получили четыре модели, которые смогли показать с высокой точностью:
- есть ли лицо в кадре;
- Насколько вероятно, что этот человек улыбнется?
- это ребенок или взрослый;
- есть ли в кадре взрослый человек, даже если лица мы не нашли.
Мы собрали гифку
С помощью моделей для каждого кадра видео мы получили вероятности присутствия родителя или ребенка и вероятности улыбки на найденных лицах.Из этих кадров мы выбрали 9 кадров с улыбками ребенка, которые без участия человека были склеены в GIF. Разработчики также настроили автоматическую вставку GIF-файлов в почтовые сообщения.
Для этого в шаблон письма включен дополнительный скрипт, проверяющий, есть ли в базе GIF-изображение конкретного урока.
Примеры финальных гифок с улыбками нашей коллеги и ее детей
Что у нас в итоге получилось?
Исследование и эксперимент показали, что можно быстро и без глубоких знаний в области компьютерного зрения научиться различать пользователей и их эмоции по видео (даже если оно плохого качества) на основе открытых библиотек и моделей.Мы можем расширить эту практику позже, если возникнет новая идея или обнаружится дополнительная потребность.
Но уже сейчас можно сказать, что этот опыт был интересным и полезным, и наши аналитики уже могут использовать дополнительные данные об эмоциях на уроках для построения собственных дашбордов и графиков.
Например, вы можете увидеть количество отключений, произошедших во время урока.
Подобную информацию можно использовать, чтобы порекомендовать более стабильную связь учителю или ученику.
Отключить статистику.
На этом уроке было только отключение со стороны ученика Другой пример — отслеживание настроения ученика на протяжении всего урока.
Это позволяет проанализировать ход урока и понять, нужно ли что-то изменить в его структуре.
Виджеты
Мы сами разметили все данные и сделали это довольно быстро (около 100 кадров в минуту).В этом нам помогли самописные виджеты:
- Виджет для обозначения кадров смайлами.
- Виджет для разметки кадров детьми и взрослыми.
- Виджет для маркировки кадров «плечом» или «локтем» родителя.
Скорее всего, вы не сможете заменить в нем путь к файлам и использовать его для своей задачи, поскольку он слишком специфичен.
Но если вам нужно написать свой велосипед для маркировки, вы можете узнать кое-что полезное.
Этот виджет показывает временную шкалу всего урока с кадрами на равных расстояниях.
Вы можете использовать эти кадры для навигации, поиска необходимых интервалов видео и отправки лиц из этих кадров в разметку.
Выбрав период видео, где присутствует только ученик или только родитель, вы можете отметить десятки лиц в один клик.
Когда в кадре присутствуют и взрослый, и ребенок, в разметке помогает обученная модель.
Он сортирует отображаемые лица по «зрелости» и присваивает предварительные метки.
Остаётся только исправить ошибки модели в неочевидных случаях.
Видео виджета в действии
Таким образом, вы можете быстро собирать размеченные данные и одновременно отслеживать, какие грани вызывают проблемы в модели.
Например, модель изначально считала наличие очков явным признаком взрослости.
Чтобы исправить это заблуждение, мне пришлось отдельно искать кадры детей в очках.
Код виджета import pandas as pd
import numpy as np
import datetime
import random
import os
import ipywidgets as widgets
from IPython.display import display
from pathlib import Path
class 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,
Теги: #Машинное обучение #Работа с видео #открытый исходный код #наука о данных #распознавание лиц #компьютерное зрение #маркировка данных #маркировка изображений
-
Сертификация Cisco Ccna Полезна
19 Oct, 24 -
Oink Возрождается Благодаря Pirate Bay
19 Oct, 24 -
Летающие Тарелки На Службе В Полиции
19 Oct, 24