Как мы с помощью ИИ выбираем обложки для сериалов в KION: кейс MTS AI

Привет, Хабр! Меня зовут Андрей Дугин, я руководитель группы видеоаналитики компании MTS AI. В статье раскрою то, как мы создаём постеры для сериалов и подбираем материалы для обложек фильмов в онлайн-кинотеатре KION. О том, как мы решили эту задачу, я постараюсь рассказать максимально подробно и с техническими деталями. Забегая вперёд, упомяну, что для выбора одной-единственной обложки приходится обрабатывать сотни тысяч кадров фильмов и сериалов. Конечно же, не вручную. Интересно, как всё это реализовано? Тогда прошу под кат.

77ccd1219d20c2865517c4cff8ef29c2.jpg

Коротко об MTS AI

Сначала о том, чем мы занимаемся, наших проектах и областях компетенции.

MTS AI — один из лидеров в сфере искусственного интеллекта. В нашем R&D-центре работает более 200 экспертов. Они обучают нейросети на самом мощном суперкомпьютере в телекоме и регулярно занимают призовые места на различных конкурсах в области AI, а также пишут статьи для передовых научных журналов.

Мы занимаемся:

  • компьютерным зрением

  • обработкой естественных языков

  • роботизацией процессов

  • распознаванием символов

  • генеративными сетями

a35bdc26758f9d64ade324975bdb3a25.png

Среди наших проектов и продуктов:

  • виртуальный ассистент

  • цифровая маркировка

  • видеонаблюдение

  • различные боты, которые помогают общаться с клиентами

  • инфраструктура для обеспечения защиты от спам-звонков

  • проекты для KION

Что мы делаем для онлайн-кинотеатра

MTS AI вместе с KION реализует несколько проектов, вкратце расскажу о пяти самых интересных.

Пропуск титров и заставок. Когда вы смотрите сериал в онлайн-кинотеатре, во время показа заставок или начальных/финальных титров на экране может появляться кнопка «Пропустить». Чтобы она правильно работала, мы разрабатываем алгоритмы, которые определяют повторяющиеся фрагменты в сериалах и фильмах:

  • заставки кинокомпаний

  • оригинальные заставки к каждой серии

  • опенинги

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

  • напоминание сюжета прошлых серий

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

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

Распознавание актёров в кадре. При постановке видео на паузу лица главных героев обводятся рамкой, появляется плашка с дополнительной информацией об актёрах и ссылкой на фильмографию. Для этого мы настроили поиск фото соответствующего человека по имени и фамилии в Google Images и Яндекс Картинках. Результаты поиска фильтруются от мусора и кластеризуются. В самом большом кластере оказываются фотографии искомого актёра. По ним вычисляются векторы-дескрипторы, а затем видео обрабатывается покадрово — выполняется поиск и распознавание всех лиц.

Дальше мы производим трекинг перемещений героев в кадре вперёд и назад во времени. Даже если актёр повернулся спиной к камере, он всё равно будет «распознан» (но как будет показано далее, эта «фишка» одновременно является и недостатком). В результате мы получаем JSON-файл с разметкой актёров, которая используется в том числе в механизме генерации постеров.

Определение места для вставки рекламы. Многие видеохостинги монетизируют бесплатный контент. Но реклама не должна прерывать сюжет и диалог героев. Для этого наши алгоритмы анализируют видео и разбивают его на сцены, а также детектируют присутствие человеческой речи (здесь нам пришлось выйти за рамки компьютерного зрения и для анализа звука использовать механизм VAD, Voice Activity Detection). Так мы вычисляем временные метки, куда можно вставить рекламу: в этот момент происходит смена сцен и не звучит человеческая речь.

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

А теперь — о главном: генерации постеров и выборе обложек

Примеры постеров на экране ноутбука (слева) и на смартфоне (справа)

Примеры постеров на экране ноутбука (слева) и на смартфоне (справа)

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

Для каждого сериала постеров требуется немного — всего несколько десятков (по количеству серий), но их приходится выбирать из сотен тысяч кадров. Этим занимается специальный сервис, который сначала выбирает TOP-N (у нас N=150) изображений, а затем предлагает финальное. Масштаб задачи можно оценить по таблице ниже:

Количество кадров в видео в зависимости от длительности и FPS

Количество кадров в видео в зависимости от длительности и FPS

К постерам предъявляется много требований:

  • картинка должна быть чёткой, не смазанной

  • без титров и надписей

  • в кадре присутствуют люди — 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[xywh]{1})=(?P\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 и определение параметров обрезки экранного каше

У такого подхода оказались следующие недостатки:

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

  2. FFmpeg «заточен» на последовательную обработку кадров, поэтому выборка кадров с помощью фильтров select или fps приводит к фоновому декодированию вообще всех кадров подряд, что довольно медленно. Если вы знаете, как это обойти, напишите в комментариях.

Чтобы решить эти проблемы, мы воспользовались библиотекой OpenCV — главным образом потому, что она позволяет эффективно перемещаться между кадрами. В текущей реализации алгоритма мы анализируем каждый 300-й кадр и накапливаем максимальную яркость пикселей на протяжении всего видео, после чего легко вычислить параметры обрезки с помощью masked array:

Вычисление параметров обрезки видео с помощью OpenCV и numpy

def _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

Результаты вычисления разницы последовательных кадров

Пример графика MAFD для 5 000 последовательных кадров

Пример графика MAFD для 5 000 последовательных кадров

Здесь пики соответствуют монтажным склейкам (смене сцен, а точнее, шотов), где кадры отличаются максимально. Найти пики мы можем с помощью функции 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.

Детектирование лица с помощью DeepFace

Детектирование лица с помощью DeepFace

Ниже показан пример файла разметки. В начале перечислены актёры с их 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

Так мы проранжировали кадры по наличию и «качеству» (резкости, размеру) лиц и можем использовать эти оценки при дальнейшей фильтрации кадров.

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

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

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

© Habrahabr.ru