Привет, Хабр! Меня зовут Андрей Дугин, я руководитель группы видеоаналитики компании MTS AI. В статье раскрою то, как мы создаём постеры для сериалов и подбираем материалы для обложек фильмов в онлайн-кинотеатре KION. О том, как мы решили эту задачу, я постараюсь рассказать максимально подробно и с техническими деталями. Забегая вперёд, упомяну, что для выбора одной-единственной обложки приходится обрабатывать сотни тысяч кадров фильмов и сериалов. Конечно же, не вручную. Интересно, как всё это реализовано? Тогда прошу под кат.
Сначала о том, чем мы занимаемся, наших проектах и областях компетенции.
MTS AI — один из лидеров в сфере искусственного интеллекта. В нашем R&D-центре работает более 200 экспертов. Они обучают нейросети на самом мощном суперкомпьютере в телекоме и регулярно занимают призовые места на различных конкурсах в области AI, а также пишут статьи для передовых научных журналов.
Мы занимаемся:
компьютерным зрением
обработкой естественных языков
роботизацией процессов
распознаванием символов
генеративными сетями
Среди наших проектов и продуктов:
виртуальный ассистент
цифровая маркировка
видеонаблюдение
различные боты, которые помогают общаться с клиентами
инфраструктура для обеспечения защиты от спам-звонков
проекты для KION
MTS AI вместе с KION реализует несколько проектов, вкратце расскажу о пяти самых интересных.
Пропуск титров и заставок. Когда вы смотрите сериал в онлайн-кинотеатре, во время показа заставок или начальных/финальных титров на экране может появляться кнопка «Пропустить». Чтобы она правильно работала, мы разрабатываем алгоритмы, которые определяют повторяющиеся фрагменты в сериалах и фильмах:
заставки кинокомпаний
оригинальные заставки к каждой серии
опенинги
дополнительные эпизоды в титрах
напоминание сюжета прошлых серий
Разметка контента производится автоматически, без привлечения редакторов, гораздо быстрее, чем вручную.
Super Resolution. Мы повышаем разрешение и детализацию видео при помощи нейросетей для улучшения восприятия старых фильмов на современных экранах. Что касается нового контента, то для него мы обеспечиваем возможность увеличения разрешения с FullHD до 4К.
Распознавание актёров в кадре. При постановке видео на паузу лица главных героев обводятся рамкой, появляется плашка с дополнительной информацией об актёрах и ссылкой на фильмографию. Для этого мы настроили поиск фото соответствующего человека по имени и фамилии в Google Images и Яндекс Картинках. Результаты поиска фильтруются от мусора и кластеризуются. В самом большом кластере оказываются фотографии искомого актёра. По ним вычисляются векторы-дескрипторы, а затем видео обрабатывается покадрово — выполняется поиск и распознавание всех лиц.
Дальше мы производим трекинг перемещений героев в кадре вперёд и назад во времени. Даже если актёр повернулся спиной к камере, он всё равно будет «распознан» (но как будет показано далее, эта «фишка» одновременно является и недостатком). В результате мы получаем JSON-файл с разметкой актёров, которая используется в том числе в механизме генерации постеров.
Определение места для вставки рекламы. Многие видеохостинги монетизируют бесплатный контент. Но реклама не должна прерывать сюжет и диалог героев. Для этого наши алгоритмы анализируют видео и разбивают его на сцены, а также детектируют присутствие человеческой речи (здесь нам пришлось выйти за рамки компьютерного зрения и для анализа звука использовать механизм VAD, Voice Activity Detection). Так мы вычисляем временные метки, куда можно вставить рекламу: в этот момент происходит смена сцен и не звучит человеческая речь.
Генерация постеров. Мы используем механизм автоматического выбора наиболее удачного кадра для картинки-обложки по заданным критериям. Ранжирование и выборка кадров делаются с помощью нейросетей. Дополнительно мы получаем нарезку заданного количества изображений для дизайнеров, которые используют их для создания альтернативных постеров к фильмам с целью предотвращения эффекта «баннерной слепоты». Кстати, подробнее об этом явлении можно почитать здесь.
Постером мы называем картинку, используемую для иллюстрации каждой серии в сезоне сериала, размещаемую рядом с номером и названием эпизода. Исторически прижился термин «генератор постеров», хотя на самом деле это «просто» поиск и выбор одного удачного стоп-кадра из соответствующего видео.
Для каждого сериала постеров требуется немного — всего несколько десятков (по количеству серий), но их приходится выбирать из сотен тысяч кадров. Этим занимается специальный сервис, который сначала выбирает TOP-N (у нас N=150) изображений, а затем предлагает финальное. Масштаб задачи можно оценить по таблице ниже:
К постерам предъявляется много требований:
картинка должна быть чёткой, не смазанной
без титров и надписей
в кадре присутствуют люди — 1, 2 или 3 человека. Опционально — чтобы там был и один из главных актёров
изображения лиц — достаточно крупные, в анфас или вполоборота, с открытыми глазами
эмоции — нейтральные или радостные. Промежуточных кадров с полуоткрытым ртом, странным выражением лица (silly face) быть не должно
отдаётся предпочтение некоторым позам — например, это объятия и поцелуи
хорошо, если в кадре есть животные — кошки, собаки, лошади
обращаем внимание на заметные предметы — это может быть телефон, фонарь, оружие и т. п.
композиция кадра должна быть удачной
удалено экранное каше («чёрные полосы» — леттербоксинг, пилларбоксинг)
Добиться такого результата было непросто — пришлось выполнить очень масштабную работу.
Обычно создание рабочих решений у нас проходит в несколько стадий: PoC (Proof-of-Concept), MVP, PROD. На этапе PoC мы пробуем различные подходы и демонстрируем, что предложенное решение в принципе может работать, пусть даже в данный момент неидеально. В зависимости от текущих приоритетов заказчика код PoC может быть отложен до лучших времён либо взят в дальнейшую работу для улучшения метрик и запуска в продакшн. Генератор постеров был «положен на полку» на пару лет.
До прихода моей команды над кодом работал коллектив, разработавший PoC (в дальнейшем я буду условно называть его legacy-кодом). Но это была даже не альфа-версия, проект был очень «сырым». Когда к нам пришёл KION с предложением реанимировать проект и довести его до ума, мы развернули сервис в тестовой среде — и вот примеры кадров, которые иногда попадались на выходе:
Здесь есть всё то, чего быть не должно — чёрные полосы, титры, персонажи повёрнуты к зрителю спиной (побочный эффект от трекинга актёров в кадре) или лицо закрыто волосами.
Мы начали разбираться с legacy-кодом и обнаружили «традиционные» недостатки: запутанный пайплайн, полное отсутствие комментариев, нагромождение нейросетей и библиотек, вычислительно неэффективный анализ всех кадров подряд. Часть кода вообще не использовалась. В итоге — низкая производительность и непредсказуемый результат.
Поверхностно проанализировав код, мы обнаружили следующие механизмы и сущности:
выделение лиц на основе файла разметки актёров без, собственно, детектирования лиц на протяжении всей серии. Из-за трекинга периодически попадаются спины и затылки
обнаружение переходов между сценами с затемнением
Dlib — ключевые точки лица (facial landmarks) и обнаружение закрытых глаз
SINet — сегментация людей
HRNet и YOLO-Pose — определение поз (pose estimation)
YoloV4COCO — поиск дополнительных объектов в кадре, включая транспортные средства, множество животных, мебель и т. п. Среди животных зачем-то детектировались, например, жирафы
EmotionRecognizer — распознавание эмоций
DeepBlurDetection — определение смазанности кадра
Tesseract — детектирование текста без OCR
оценка композиции по взвешенной близости лица к центру кадра. Это достаточно примитивный механизм
простейший скоринг, который добавлял единицу при выполнении каждого условия. Например, есть лица — +1, есть животные — +1. Чем больше баллов, тем лучше кадр
нет обрезки леттербоксинга
Запустить полноценный сервис нужно было быстро, и мы решили не тратить время на изучение, рефакторинг и доработку имеющегося наследия. Вместо этого мы добавили ещё один этап обработки — предварительный отбор кадров-кандидатов. Legacy-код оставили практически без изменений, но на вход теперь стали подавать не целый фильм, а короткий видеоролик из небольшого числа заведомо качественных кадров.
Такой подход позволил решить следующие задачи:
улучшить качество кода
ускорить обработку видео
предотвратить ошибки legacy-кода
быстро запустить решение в прод
Да, и заодно мы создали универсальную для всех проектов KION эффективную внутреннюю библиотеку, которую назвали KION Tools. В неё вошли такие модули и классы:
composition.py — класс Composition (расчёт ГРИП, векторизация и оценка композиции кадра)
detectors.py — TextDetector (обнаружение присутствия текста в кадре) и FaceDetector (детекция и скоринг лиц)
ffmpeg.py — FFmpegCapture (аналог VideoCapture из OpenCV, обёртка над ffmpeg и ffprobe), CropboxDetector (обнаружение экранного каше и вычисление параметров обрезки), SceneChangeDetector (вычисление различия кадров для поиска монтажных склеек и статичных кадров), InterlacingDetector (обнаружение интерлейсинга в старых фильмах с целью коррекции)
labeling.py — ActorsLabeling (работа с JSON-файлом разметки актёров)
paths.py — MediaPath, VideoPath, MoviePath, SerialPath (классы для извлечения полезной информации из URL видео на S3)
posters.py — CandidatesSelection (отбор кадров-кандидатов в постеры с экспортом в MP4 или ZIP)
quality.py — HistogramQuality (оценка формы канальной гистограммы по сравнению с референсной) и CinematicQuality (интегральный скоринг фотографического качества изображения путём оценки богатства и насыщенности цветов и гистограммы яркости)
series.py — работа с сериалами и поиск повторяющихся кадров с использованием классов Frame, Episode, Season
Экранное каше — это метод согласования соотношения сторон видео с соотношением сторон экрана без обрезки исходного изображения путём добавления горизонтальных (леттербоксинг, обычно встречается в современных широкоэкранных фильмах) или вертикальных (пилларбоксинг — в старых видео) чёрных полос по краям кадра. Иными словами, нам необходимо вписать один прямоугольник в другой с сохранением исходных пропорций.
Мастер-файлы фильмов хранятся в KION уже с добавленным (вкодированным прямо в видео) экранным каше. Поэтому для постеров его необходимо обнаружить и корректно подрезать края кадра, сохранив только содержимое.
При написании модулей для библиотеки KION Tools мы стремились максимально использовать возможности ffmpeg, чтобы у нас были заведомо работающие алгоритмы и минимизировалась собственная кодовая база. Как оказалось, это не всегда оправдано.
Чтобы вычислить параметры обрезки видео, сначала мы воспользовались ffmpeg-фильтром cropdetect — вот базовая команда, которая анализирует каждый 500-й кадр и выводит результаты в лог-файл, который затем можно распарсить и выбрать самые часто встречающиеся значения:
ffmpeg -i video.mp4 -vf select='not(mod(n\,500))',cropdetect=round=0,metadata=print:file=cropdetect.log -f null -
frame:211 pts:105500000 pts_time:4220
lavfi.cropdetect.x1=0
lavfi.cropdetect.x2=1919
lavfi.cropdetect.y1=138
lavfi.cropdetect.y2=941
lavfi.cropdetect.w=1920
lavfi.cropdetect.h=800
lavfi.cropdetect.x=0
lavfi.cropdetect.y=140
frame:212 pts:106000000 pts_time:4240
lavfi.cropdetect.x1=0
lavfi.cropdetect.x2=1919
lavfi.cropdetect.y1=138
lavfi.cropdetect.y2=941
lavfi.cropdetect.w=1920
lavfi.cropdetect.h=800
lavfi.cropdetect.x=0
lavfi.cropdetect.y=140
Пример содержимого файла cropdetect.log
def _parse_cropbox_from_cropdetect_log(self, path_to_cropdetect_log: Path) -> dict:
# В ffmpeg есть фильтр cropdetect, здесь парсим его метаданные на выходе
if path_to_cropdetect_log.exists():
stats = defaultdict(Counter)
# Регулярное выражение для строк вида "lavfi.cropdetect.y=140", "lavfi.cropdetect.w=1920"
regex = re.compile(r"^lavfi\.cropdetect\.(?P<coord>[xywh]{1})=(?P<value>\d+)$", re.M)
# Подсчитываем, сколько раз встречаются значения каждой из координат bbox'а (x, y, w, h)
metadata = path_to_cropdetect_log.read_text()
for match in regex.finditer(metadata):
coord, value = match.group("coord", "value")
stats[coord][value] += 1
# И в качестве координат (x, y, w, h) берём самые часто встречающиеся
self.cropbox = {coord: stats[coord].most_common(1)[0][0] for coord in stats.keys()}
return self.cropbox
Парсинг результата работы фильтра cropdetect и определение параметров обрезки экранного каше
У такого подхода оказались следующие недостатки:
Иногда режиссёры используют творческий приём в виде меняющегося на протяжении фильма экранного каше. Кадр может то расширяться, то сужаться по вертикали, и способ с парсингом самых частых значений может дать сбой. А значит, иногда появляются кадры с частично обрезанным каше (кадр оказался суженным по вертикали относительно всего фильма в целом). Мы пока не обрабатываем такие случаи и оставляем их коррекцию редактору KION.
FFmpeg «заточен» на последовательную обработку кадров, поэтому выборка кадров с помощью фильтров select или fps приводит к фоновому декодированию вообще всех кадров подряд, что довольно медленно. Если вы знаете, как это обойти, напишите в комментариях.
Чтобы решить эти проблемы, мы воспользовались библиотекой OpenCV — главным образом потому, что она позволяет эффективно перемещаться между кадрами. В текущей реализации алгоритма мы анализируем каждый 300-й кадр и накапливаем максимальную яркость пикселей на протяжении всего видео, после чего легко вычислить параметры обрезки с помощью masked array:
Вычисление параметров обрезки видео с помощью OpenCV и numpydef _calc_cropbox_from_video_with_opencv(self,
# Обрезать полосы сверху и снизу (если они есть)
crop_top_and_bottom: bool = True,
# Обрезать полосы слева и справа (если они есть)
crop_left_and_right: bool = True) -> dict:
# Будем читать избранные кадры с помощью OpenCV, т. к. это НАМНОГО быстрее, чем с ffmpeg
video = cv2.VideoCapture(self.path_to_video.as_posix())
# В video.read() во избежание лишних аллокаций можно передать буфер frame (или None, если frame ещё не создан)
frame = None
# Пробегаемся по номерам кадров с шагом every_nth_frame
for frame_id in count(0, self.every_nth_frame):
# Если шаг 1, то быстрее просто читать кадры последовательно, а не скакать через video.set()
if self.every_nth_frame > 1:
# "Проматываем" видео к кадру с номером frame_id
video.set(cv2.CAP_PROP_POS_FRAMES, frame_id)
# Передаём frame как буфер для записи в video.read()
success, frame = video.read(frame) # Если frame is None (когда читаем первый кадр), то тоже подойдёт
if not success:
break
# Будем использовать темпоральную маску, в которой на протяжении всего видео аккумулируем максимальные
# значения яркостей пикселей среди всех проанализированных кадров
if frame_id == 0:
# Инициализируем темпоральную маску текущим кадром, она же будет выделенным буфером во избежание
# лишних аллокаций памяти (см. использование параметров out и dst в функциях OpenCV и numpy ниже)
cumulative_brightness_mask = frame.copy() # Делаем копию, чтобы не перезаписать в video.read()
else:
# Сохраняем в маску максимальные значения яркости пикселей из маски и текущего кадра
np.maximum(frame, cumulative_brightness_mask, out=cumulative_brightness_mask)
video.release()
# Агрегируем цветовые каналы в псевдо-grayscale, выбирая максимальные значения пикселей в каналах
if cumulative_brightness_mask.ndim == 3:
cumulative_brightness_mask = cumulative_brightness_mask.max(axis=-1)
# С целью облегчения дебага сохраним копию маски в отдельное поле
self._cumulative_brightness_mask = cumulative_brightness_mask.copy()
# Применим порог, чтобы чётче дифференцировать области темпоральной маски — особенно чёрные
cv2.threshold(cumulative_brightness_mask, 127, 255, cv2.THRESH_BINARY, dst=cumulative_brightness_mask)
# Готовим дефолтные слайсы для подрезки видео (по умолчанию — без подрезки)
# Пример дефолтных слайсов для FullHD-кадра: [slice(0, 1080), slice(0, 1920)]
slices = [slice(0, length) for length in cumulative_brightness_mask.shape]
# Перебираем оси и рассматриваем условия — нужно ли подрезать по данной оси
for axis, crop_along_this_axis in enumerate((crop_top_and_bottom, crop_left_and_right)):
if not crop_along_this_axis:
continue
# Ищем ненулевые (True) значения вдоль строк/столбцов
profile = cumulative_brightness_mask.any(axis=1-axis)
# Представим профиль как masked array, т. к. там есть функция поиска границ
profile = np.ma.masked_array(profile, ~profile)
# Вычисляем индексы границ содержимого кадра
edges = np.ma.flatnotmasked_edges(profile)
# Особенность flatnotmasked_edges(): returns None if all values are masked, т. е. None для полного кадра
if edges is not None:
# Особенность flatnotmasked_edges(): returns indices of first and last non-masked value in the array
start, stop = edges
# Необходимо перейти от индекса последнего элемента к индексу межэлементного интервала, поэтому +1
slices[axis] = slice(start, stop + 1)
else:
# По этой оси обрезки нет (полный кадр), поэтому оставляем дефолтный слайс без изменений
pass
# Переложим слайсы в параметры cropbox'а
self.cropbox = {
"x": slices[1].start,
"y": slices[0].start,
"w": slices[1].stop - slices[1].start,
"h": slices[0].stop - slices[0].start,
}
return self.cropbox
Мы высказали и подтвердили гипотезу, что лучшие кадры — статические. В них нет движения и смазывания, камера замирает, актёры на мгновение фиксируют мимику. Эти кадры часто оказываются ключевыми с точки зрения режиссёра и имеют хорошую композицию, а также на них обычно нет закрытых глаз или незавершённых эмоций.
Чтобы выделить такие кадры, проще всего попиксельно сравнить последовательные пары кадров на протяжении видео: вычесть из одного кадра другой, а затем взять разницу по модулю и рассчитать среднее значение. Так вычисляется метрика MAFD, Mean Absolute Frame Difference. Чем выше численное значение метрики, тем сильнее отличаются кадры.
Для решения этой задачи мы использовали фильтр scdet. Он выводит в лог несколько метрик, необходимых для поиска нужных кадров:
ffmpeg -i video.mp4 -vf scdet=0:0,metadata=print:file=scdet.log -f null –
frame:137978 pts:137978000 pts_time:5519.12
lavfi.scd.mafd=0.665
lavfi.scd.score=0.220
lavfi.scd.time=5519.12
frame:137979 pts:137979000 pts_time:5519.16
lavfi.scd.mafd=0.582
lavfi.scd.score=0.084
lavfi.scd.time=5519.16
Результаты вычисления разницы последовательных кадров
Здесь пики соответствуют монтажным склейкам (смене сцен, а точнее, шотов), где кадры отличаются максимально. Найти пики мы можем с помощью функции scipy.signal.find_peaks, она также позволяет задать минимальное расстояние между пиками с помощью параметра distance. Но нам нужны минимумы MAFD, и мы используем обратную метрику:
def get_static_frame_ids(self, metric: str = "scd.mafd") -> List[int]:
# Минимальное количество кадров на сцену, исходя из минимальной длительности сцены и FPS видео. Ограничение
# необходимо, чтобы избежать появления групп близко расположенных и очень похожих кадров из одной сцены.
min_frames_per_scene = self.min_scene_duration_seconds * self.video.avg_frame_rate
# Логично было бы разбивать видео на сцены по пикам scd.score и затем искать минимумы scd.mafd, но такой
# подход показал себя не очень хорошо. Кроме того, возникла бы проблема с фильмами некоторых режиссёров,
# которые снимают длинные сцены одним кадром.
# Среди всех кадров в каждой сцене выбираем один кадр с минимальным значением scd.mafd; это кадр, который
# минимально отличается от предыдущего, что обычно соответствует относительно статичному, когда действие
# "замерло" хотя бы на мгновение. Но нет гарантии, что кадр резкий (не размытый)! При этом нулевое scd.mafd
# соответствует пустым (чёрным) кадрам или титрам на однородном фоне, поэтому такие значения исключаем,
# заменяя на np.nan — функция find_peaks вполне справляется с такими данными.
# Поскольку find_peaks ищет максимальные значения, а нам нужны минимальные, берём обратную метрику.
metric = 1.0 / self.metadata[metric].replace(0.0, np.nan)
# Ищем пики с условием, чтобы расстояние между ними было не меньше min_frames_per_scene. Найденные ненулевые
# минимумы scd.mafd и будут номерами кадров-кандидатов.
frame_ids, _ = find_peaks(metric, distance=min_frames_per_scene)
# Возвращаем список отсортированных номеров кадров
return sorted(frame_ids)
Поиск статичных кадров
Минус такого подхода — не оценивается резкость кадров, а статичные кадры вполне могут быть и размытыми. Конечно, можно одновременно с фильтром scdet воспользоваться и фильтром blurdetect с выводом результатов в тот же лог-файл, но есть пара проблем. Во-первых, это будет работать довольно долго. Во-вторых, подбор оптимальных параметров неоднозначен и может варьироваться от видео к видео. Кроме того, необходимо обрезать леттербоксинг и в начале цепочки запускать фильтр crop, что ещё сильнее замедляет обработку.
Из-за этих недостатков мы перешли на другой метод: читаем все кадры видео с помощью нашего класса FFmpegCapture, причём сразу обрезаем и извлекаем изображения в градациях серого. Например, в случае видео в YUV — как правило, это yuv420p — просто берём уже готовый яркостный канал Y с помощью фильтра extractplanes=y, чем избегаем лишних вычислений и потерь при преобразованиях. Далее рассчитываем метрику MAFD и одновременно оцениваем композицию и резкость кадра.
После этого с помощью комбинации полученных метрик и той же функции find_peaks выбираем наиболее статичные, резкие и композиционно удачные кадры.
На этом же этапе можно отбросить номера кадров, которые отсутствуют в разметке актёров, таким образом оставив только кадры с интересующими актёрами.
После того как обнаружены статичные кадры, необходимо найти на них лица и оценить их качество. Как и при формировании файла разметки актёров, мы могли бы воспользоваться решением LUNA Platform нашей портфельной компании VisionLabs — оно даёт отличное качество. Но мы искали что-то более лёгкое и простое, не требующее регулярного обновления лицензий. Попробовали библиотеку от Google MediaPipe, но она на тот момент была очень «сырой» и не давала достаточного качества. Поэтому мы остановились на библиотеке DeepFace:
отличное качество детектирования лиц
хорошая скорость работы
определение пола и возраста
распознавание эмоций
Для задачи детектирования лиц «под капотом» поддерживаются движки Dlib, SSD, OpenCV, MTCNN, RetinaFace и тот же MediaPipe. Мы выбрали RetinaFace.
Ниже показан пример файла разметки. В начале перечислены актёры с их id, затем указываются номера кадров с указанием id актёра и координатами рамки вокруг лица (bounding box):
{'actors': {'0': 'glo_person_aleksandr_feklistov',
'1': 'glo_person_alina_sergeeva',
'2': 'glo_person_anatolij_terpickij',
'3': 'glo_person_danila_kozlovskij',
'4': 'glo_person_dmitrij_pevcov',
'5': 'glo_person_elena_dubrovskaya',
'6': 'glo_person_ivan_mackevich',
'7': 'glo_person_magdalena_gurska',
'8': 'glo_person_pavel_harlanchuk',
'9': 'glo_person_valeriya_arlanova'},
'content_id': 'glo_episode_mts_6204380',
'fps': 25.0,
'frames': {'10199': [{'1': [0.476, 0.432, 0.057, 0.118]}],
'10200': [{'1': [0.475, 0.431, 0.061, 0.123]}],
'10201': [{'1': [0.475, 0.429, 0.061, 0.123]}],
'10202': [{'1': [0.474, 0.425, 0.065, 0.132]}],
'10203': [{'1': [0.475, 0.425, 0.062, 0.13]}],
'10204': [{'1': [0.474, 0.429, 0.068, 0.123]}],
'10205': [{'1': [0.474, 0.425, 0.068, 0.132]}], ...
JSON-файл с разметкой по актёрам
Файл разметки актёров (если он предоставлен) мы используем сейчас только для того, чтобы исключить кадры, на которых заведомо нет перечисленных в файле главных актёров. При этом мы не можем полагаться на разметку bbox'ов из файла — из-за механизма трекинга отслеживаются не только лица, но и затылки. Нам нужен детектор лиц RetinaFace — он ищет все лица в кадре. Это используется в скоринге, о нём расскажу дальше.
Для оценки этого параметра мы используем оператор Лапласа — это вторая производная от яркости пикселей изображения. Один из способов его посчитать — выполнить свёртку серого изображения с соответствующим ядром 3 × 3:
Мерой резкости выступает вариация Лапласиана (variance of Laplacian). Чем больше значение, тем выше резкость изображения.
После экспериментов мы пришли к использованию альтернативной матрицы 3 × 3 для расчёта Лапласиана с учётом диагоналей. В ней посередине стоит значение -8, а вся остальная матрица заполнена единицами. Такой подход более устойчив к шумам.
Здесь, пожалуй, проще показать весь код метода с комментариями, нежели объяснять построчно. Ключевые идеи:
формируем булеву маску размером с изображение и заполняем её значениями True только внутри рамок (bbox’ов) найденных лиц
значения Лапласиана всего изображения внутри bbox’ов домножаем на скорректированное значение confidence — это эквивалентно дополнительному размытию областей с лицами
по экспериментально подобранной формуле (она может быть и другой) оцениваем площадь и резкость частей кадра с лицами относительно оставшейся части без лиц
def score(self, frame: np.ndarray, draw_bbox: bool = False) -> Tuple[float, bool]:
# Предобработка изображения
processed = self.preprocess(frame, blur=True)
# Рассчитываем Лапласиан всего изображения, чтобы далее посчитать var() как меру резкости
laplacian = self.laplacian(processed)
# Резкость всего изображения
overall_sharpness = laplacian.var()
# Маска, которая будет выделять bbox'ы всех лиц в кадре, принимая в них значение True
face_union_mask = np.zeros_like(laplacian, dtype="bool")
# Площадь полного кадра
full_frame_area = np.prod(frame.shape[:2])
# Флаг, который говорит, найдено ли в кадре хотя бы одно лицо, удовлетворяющее критериям отбора. Это чисто
# индикатор, не влияющий на скоринг ниже и предназначенный для дальнейшего использования во внешнем коде.
face_is_found = False
# Детектируем лица и обрабатываем результаты
for confidence, (x1, y1, x2, y2) in self.detect(processed):
# Бэкенд retinaface может выдавать confidence == 0.0, но это нам не подходит (ложное срабатывание)
if confidence == 0.0:
continue
# Костыль связан с тем, что retinaface даёт очень высокие значения confidence порядка 0.99. "Вытянем"
# значения confidence от очень близких к 1.0 (диапазон порядка 0.99-1.0) в сторону 0.0 (в диапазон
# 0.0-1.0). Кроме того, этот трюк позволяет увеличить флуктуацию confidence и снизить риск выборки
# большого количества идущих подряд почти одинаковых кадров.
confidence **= 64 # Magic number, подобрано экспериментально глядя на график confidence по целому фильму
# Заполняем маску кадра внутри bbox'а лица
face_union_mask[y1:y2, x1:x2] = True
# Взвешиваем значения Лапласиана внутри bbox'а в соответствии с confidence. Это эквивалентно дополнительному
# размытию (снижению резкости) лиц, в которых нейросеть не очень уверена.
laplacian[y1:y2, x1:x2] *= confidence # TODO: А нужно ли, особенно с учётом "искусственности" confidence?
# В режиме отладки можем нарисовать bbox'ы прямо на изображении
if draw_bbox:
# Можем модифицировать исходное изображение только здесь
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2)
# Также нанесём надпись со значением confidence
cv2.putText(frame, f"{confidence:.6f}", (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1, cv2.LINE_AA)
# Вычисляем относительную площадь лица по сравнению с площадью всего кадра
face_bbox_area = abs((x2 - x1) * (y2 - y1)) / full_frame_area
# Проверяем соответствие параметров найденного лица запрошенным и в зависимости от этого обновляем флаг
face_is_found |= (
confidence >= self.confidence_threshold and
self.min_face_area <= face_bbox_area <= self.max_face_area
)
# Если в кадре есть лица и они занимают не 100% площади
if np.any(face_union_mask) and not np.all(face_union_mask):
# Считаем метрики по частям кадра, занятым лицами
faces_sharpness = laplacian[face_union_mask].var()
faces_area = np.count_nonzero(face_union_mask)
# Считаем метрики для оставшихся частей кадра, где лиц нет
other_sharpness = laplacian[~face_union_mask].var()
other_area = face_union_mask.size - faces_area
# Увеличению score способствуют следующие факторы:
# 1. Резкость (чёткость) лиц, отсутствие размытия
# 2. Суммарная площадь, занимаемая всеми лицами в кадре
# 3. Фокус на лицах (лица чёткие, а фон более размытый)
score = (faces_sharpness * faces_area) / (other_sharpness * other_area)
else:
# Нам нужно, чтобы скоринг работал и для кадров без человеческих лиц (например, анализируем мультфильм),
# но при этом приоритет был у кадров с лицами. Т. е. необходимо сформировать 2 разнесённых по шкале score
# группы кадров (есть лица/нет лиц) с корректным ранжированием кадров внутри каждой группы. Для этого
# просто уменьшим скоринг кадров без лиц на несколько порядков:
score = 1e-4 # Magic number, можно ещё уменьшить при необходимости
# Домножаем на нормированную оценку общей резкости полного кадра, это даст приоритет более чётким кадрам
score *= overall_sharpness / self.ref_var_of_laplacian
return score, face_is_found
Так мы проранжировали кадры по наличию и «качеству» (резкости, размеру) лиц и можем использовать эти оценки при дальнейшей фильтрации кадров.
Очевидный недостаток такого подхода в том, что формула отдаёт предпочтение кадрам с самыми крупными лицами. Попытка уменьшать максимальную относительную площадь лиц приводит к существенному ухудшению композиции.
Сейчас мы совершенствуем формулу скоринга для того, чтобы повысить вариативность масштабов лиц и получать не только кадры вида «лицо и плечи», но и фигуры людей на расстоянии, а также кадры без людей вообще.
На этом я завершаю первую часть. В следующей расскажу об оценке композиции и качества изображения, обучении нейросети, поиске текста и о результатах работы. В комментариях отвечу на все вопросы!