Делаем эффекты в видеосвязи, используя Canvas API и MediaPipe
Привет! На связи Влад из команды видеоплатформы Skyeng. Мы отвечаем за аудио и видео коммуникацию в образовательных продуктах, применяем WebRTC и реализуем фичи вокруг Video Conferencing. О реализации одной из них хочу рассказать: мы сделали видеоэффекты для веба.
Изначально, мы шли от проблемы: не все преподаватели и ученики могут найти подходящее место для занятий. Например, в квартире ремонт, кругом стройматериалы или на фоне бегают дети. Такая картина отвлекает от образовательного процесса.
Когда мы поговорили с пользователями, они подтвердили — нужна возможность заменить фон во время урока на альтернативный или размыть на звонке то, что происходит позади. Да и видеоэффекты уже есть на многих видеоплатформах, надо не отставать от трендов.
Все сошлось. Решили делать.
Первые шаги реализации
В данной задаче есть 2 основные части:
Поиск и интеграция решения для сегментации. Как правило, это ML-модель с биндингами под ту или иную платформу.
Рендеринг растрового изображения — кадра, это подразумевает работу с 2D графикой. Из этих кадров (количество зависит от FPS видео) будет состоять video track с примененным эффектом.
Итак, мы посмотрели, какие инструменты, полностью реализующие сегментацию и отрисовку, есть в open source. Так познакомились с BodyPix. BodyPix как библиотека содержит в себе решение для сегментации, а также для рендеринга блюр-эффекта — включаешь и работает. Именно поэтому вначале выбор пал на него.
По ссылке хорошая статья про инструмент, советую почитать. Демо сегментации от BodyPix:
Но у решения с использованием BodyPix возникли проблемы. Мы раскатили его на 5% преподавателей и замерили время использования эффектов. По продолжительности заметили: пользователи включают эффект, быстро выключают и далее фичей не пользуются.
Оказалось, при включении эффектов пользователи начинали испытывать задержки в видео. Норма для видео — 30 кадров в секунду. Максимум, который выдавал BodyPix — 18 кадров в секунду на мощном ноутбуке. А у преподавателей ноутбуки в основном по мощности средние. В общем, пользоваться было невозможно. Это же подтвердил аналитик, сводивший фидбек от преподавателей.
Замерили производительность и заметили, что BodyPix выполняет медленно и сегментацию, и отрисовку. Сделали выводы, что нужно искать альтернативы, чтобы оба этих шага выполнялись эффективно с точки зрения производительности.
MediaPipe
Попробовали другое решение для сегментации, существующее на рынке — MediaPipe Selfie Segmentation. Его разработал Google и научил быстро работать в браузере. На вебе MediaPipe исполняется в WebAssembly. Ниже на скрине результаты замеров — сколько занимает сегментация одного кадра на MacBook Air M1:
Сравнение MediaPipe Selfie Segmentation, Bodypix v2, Bodypix v1
Также есть бенчмарки от Google, там сравнивают производительность модели Selfie Segmentation, запуская её на различных рантаймах MediaPipe и Tensorflow:
Источник — статья в блоге TensorFlow https://blog.tensorflow.org/2022/01/body-segmentation.html.
MediaPipe — это только осегментации. Модель Selfie Segmentation возвращает нам две картинки: одна оригинал — кадр, что поступил на вход в модель, другая — маска с вырезанным бэкграундом:
Дальше надо взять результаты и что-то с ними сделать.
Вот так выглядит наш флоу:
Поддержка WebGL2
Для работы MediaPipe необходима поддержка WebGL2. Для полной проверки совместимости с WebGL2 мы взяли за основу реализацию с сайта khronos.
function isWebgl2Supported(): boolean {
const canvas = document.createElement('canvas');
const webgl2Context = canvas.getContext('webgl2');
let supported = true;
if (!webgl2Context) {
supported = false;
return supported;
}
const params = [
{ pname: 'MAX_3D_TEXTURE_SIZE', min: 256 },
{ pname: 'MAX_DRAW_BUFFERS', min: 4 },
{ pname: 'MAX_COLOR_ATTACHMENTS', min: 4 },
{ pname: 'MAX_VERTEX_UNIFORM_BLOCKS', min: 12 },
{ pname: 'MAX_VERTEX_TEXTURE_IMAGE_UNITS', min: 16 },
{ pname: 'MAX_FRAGMENT_INPUT_COMPONENTS', min: 60 },
{ pname: 'MAX_UNIFORM_BUFFER_BINDINGS', min: 24 },
{ pname: 'MAX_COMBINED_UNIFORM_BLOCKS', min: 24 },
];
for (let i = 0; i < params.length; i += 1) {
const param = params[i];
const value = webgl2Context.getParameter(webgl2Context[param.pname]);
if (typeof value !== 'number' || Number.isNaN(value) || value < param.min) {
supported = false;
break;
}
}
return supported;
}
Прочие нюансы работы с MediaPipe
MediaPipe нелегкий. Модель весит ~5МБ. Изначально мы инициализировали его в момент, когда пользователь включал эффект. Но у пользователей с медленным интернетом возникал лаг включения — эффект применялся, но с ощутимой задержкой.
Стали инициализировать MediaPipe заблаговременно при запуске видеосвязи.
Совет: файлы, относящиеся к MediaPipe и Selfie Segmentation, лучше кэшировать, чтобы при перезагрузке страницы браузер не скачивал эти файлы заново, а брал из кэша.
Кстати, нельзя выполнять запрос на сегментацию в MediaPipe, не дожидаясь результатов предыдущего вызова на сегментацию. В противном случае, вся вкладка зависнет сразу при применении эффектов или чуть позже. Может быть даже получится какое-то время что-то отрисовывать, но потом wasm взорвется.
В консоли вы увидите следующее:
RuntimeError: abort (Module.arguments has been replaced with plain arguments_ (the initial value can be provided on Module, but after startup the value is only looked for on a local variable of that name)) at Error.
RuntimeError: memory access out of bounds.
Рендеринг
Переходим ко второй части, к рендерингу. Наша задача — взять результаты сегментации, составить и отрисовать кадр с примененным эффектом — блюром или фоном.
Перфоманс метрика
Важно, чтобы тот код, который делает сегментацию и отрисовку, вычислялся в соответствии с frame rate. То есть за интервал 1000 ms / FPS. Если частота обновления кадров 30 FPS, то на сегментацию и отрисовку одного кадра с примененным эффектом должно уходить не более 33 ms. Для обеспечения 60 FPS у вас есть всего 16 ms. Если решение вылезает за эту границу, делаем выводы, что pipeline написан не оптимально.
Canvas API vs WebGL
Для работы с растровой графикой в вебе мы можем использовать Canvas API и WebGL.
Сразу скажу, что Canvas API уступает по производительности WebGL. Если вам нужно максимально оптимизированное решение и вы уже разобрались в программировании шейдеров, вам лучше пойти WebGl-путём. В ином же случае, Canvas API достаточно оптимален, если научиться правильно его готовить. Проведенные нами эксперименты показали, что мы можем добиться оптимальной производительности и адекватного time to market, используя Canvas API.
В документации есть рекомендации по улучшению производительности — must read перед тем, как рисовать что-то в Canvas с высокой частотой обновления. Также советую еще две статьи для более глубокого погружения в оптимизацию производительности:
Первая реализация рендеринга была не оптимальной. Не укладывалась в метрику. После прочтения упомянутых выше статей про оптимизацию, переписали реализацию с учетом советов. Рендеринг ускорился в 10 раз!
Первая оптимизация — это пререндеринг объектов изображения. То, что можно вычислить до использования эффектов и то, что между кадрами не меняется, лучше пререндерить.
Примечание
Для лучшей синхронизации обновлений в видеотреке с примененным эффектом с FPS оригинального видео можно использовать requestFrame () и управлять захватом вручную.
Цитата из документации:
Applications that need to carefully control the timing of rendering and frame capture can use requestFrame () to directly specify when it’s time to capture a frame.
Как только мы нарисовали кадр на Canvas, мы вызываем requestFrame (). Таким образом, мы вручную контролируем обновления в видеотреке.
Теперь порисуем немножечко.
Получаем результат сегментации, вычисляем body, закрашиваем фон.Оставляем фон, закрашиваем body.
В Canvas API есть крутая фича, она называется globalCompositeOperation. Это мощный инструмент для регулирования способа наложения (композиции) двух произвольных Canvas’ов (source и destination) друг на друга. Ключевые слова: compositing (или blend) modes.
Cо слоями можно делать все, что захочется: отобразить один слой над другим, отображать только то, что пересекается, и так далее. Например, вот так:
Или вот так:
Нюанс: свечение в нативной реализации блюра
Мы используем нативный filter blur.
В нем есть неприятный момент. При применении его к изображению возникает свечение по краям. Скорее всего, причина кроется в следующем. Выглядит проблема вот так:
Засвеченные края влияют на восприятие картинки. Давайте посмотрим, как можно этого избежать:
Берем оригинальное изображение.
Создаем копию этого изображения и накладываем на нее блюр.
Разница между изображениями по краям — это и есть то самое свечение.
Мы объединяем оригинальное и заблюренное изображения так, чтобы прозрачные пиксели (свечение) заблюренной картинки заменились пикселями с оригинального изображения.
Playground с решением доступен по ссылке.
Да, это не идеальное и не самое оптимальное решение, но мы всё еще укладываемся в метрику. Вот, например, список альтернативных решений, которыми можете пойти вы:
Написать функцию блюра на JS (очень медленно).
Написать блюр на WebGL (отличный вариант, но шейдеры…).
Нюанс: зеркальное отражение
Когда мы смотрим в зеркало, мы видим себя зеркально отраженными. И ожидаем того же, когда смотрим на себя в UI, и это нужно учитывать при применении эффекта.
Самый простой и доступный способ — использовать в CSS метод scale () и повернуть всю картинку, но тогда переворачиваются и становятся нечитаемыми надписи на фоне — так не пойдёт. Вот пример как выглядит эта проблема:
В решении этой проблемы мы используем scale, только не CSS scale, а другой — от Canvas.
Отражение нужно сделать только для локального видеотрека пользователя, ведь для собеседника привычнее обратное — он должен видеть нас в оригинале. Для этого необходимы следующие действия:
Для локального видео с примененным эффектом фона:
Вычисляем background, body человека
Отражаем body горизонтально
Соединяем с той картинкой, которую хотим наложить
Для видео, которое отправляем партнеру:
Вычисляем background, body человека
Соединяем с той картинкой, которую хотим наложить
Для локального видео с примененным эффектом блюра:
Вычисляем body человека
Блюрим оригинальное изображение
Соединяем body человека с заблюренным фоном
Отражаем горизонтально
Для видео, которое отправляем партнеру:
Вычисляем body человека
Блюрим оригинальное изображение
Соединяем с заблюренным фоном
Планы по улучшению
Мы почти пришли к завершению нашей статьи. Думаю, стоит рассказать вам о наших дальнейших планах, ведь всегда найдётся что-то, что можно улучшить.
Нам хочется, чтобы возможность добавить эффект в видео была у как можно большего числа пользователей. Соответственно, нам нужен больший охват устройств, на которых эффекты работают оптимально, а для этого потребуется ресёрч эффективых оптимизации.
Как вы уже наверняка догадались, video processing — очень ресурсоёмкая операция и переход рендеринга на OpenGL (WebGL) потенциально может существенно улучшить производительность эффектов.
Также, есть ряд артефактов, которые обывателю, возможно, будут незаметны, но их исправление может улучшить восприятие картинки, сделать её более натуральной. Вот наш to-do list для них:
Как мы с вами помним, Selfie Segmentation даёт маску для последующего вычисления body и background. Так вот, эта маска, из-за оптимизаций со стороны Mediapipe, имеет низкое разрешение, из-за этого выглядит резковато. Эффективная мера — применить smoothing, например bilateral filter.
Применение метода light wrapping для компоновки эффекта замены фона.
На этом всё, друзья! Спасибо за внимание!