[Перевод] Видеозвонки с виртуальным фоном и опенсорсные инструменты
Сейчас, когда многие из нас находятся на карантине из-за COVID-19, видеозвонки стали куда более частым явлением, чем раньше. В частности, сервис ZOOMнеожиданно стал очень популярным. Вероятно, самой интересной возможностью Zoom является поддержка виртуального фона (Virtual Background). Она позволяет пользователям, в интерактивном режиме, заменять фон, находящийся позади них, на любое изображение или видео.
Я уже давно применяю Zoom на работе, на опенсорсных встречах, посвящённых Kubernetes, делая это обычно с корпоративного ноутбука. Теперь я, в режиме работы из дома, склонен к использованию более мощного и удобного персонального настольного компьютера для решения некоторых из моих опенсорсных задач.
К несчастью, Zoom поддерживает лишь способ удаления фона, известный как «хромакей» или «зелёный экран». Для использования этого метода нужно, чтобы фон был бы представлен неким сплошным цветом, в идеале — зелёным, и был бы равномерно освещён.
Так как зелёного экрана у меня нет, я решил просто реализовать собственную систему удаления фона. А это, конечно, куда лучше, чем наведение порядка в квартире, или постоянное использование рабочего ноутбука.
Как оказалось, применив готовые опенсорсные компоненты и написав буквально несколько строк собственного кода, можно получить весьма достойные результаты.
Чтение данных с камеры
Начнём с начала и ответим на следующий вопрос: «Как получить видео с веб-камеры, которое будем обрабатывать?».
Так как я на домашнем компьютере использую Linux (когда не играю в игры), я решил использовать Python-привязки Open CV, с которыми я уже знаком. Они, помимо V4L2-биндингов для чтения данных с веб-камеры, включают в себя и полезные базовые функции по обработке видео.
Чтение кадра с веб-камеры в python-opencv устроено очень просто:
import cv2
cap = cv2.VideoCapture('/dev/video0')
success, frame = cap.read()
Я, чтобы улучшить результаты при работе с моей камерой, перед захватом с неё видео применил следующие настройки:
# настройка камеры на 720p @ 60 FPS
height, width = 720, 1280
cap.set(cv2.CAP_PROP_FRAME_WIDTH ,width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT,height)
cap.set(cv2.CAP_PROP_FPS, 60)
Возникает такое ощущение, что большинство программ для видеоконференций ограничивают видео параметрами 720p @ 30 FPS, или ниже. Но мы, в любом случае, можем и не читать каждый кадр. Такие настройки задают верхний лимит.
Поместим механизм захвата кадра в цикл. Теперь у нас есть доступ к видеопотоку с камеры!
while True:
success, frame = cap.read()
Сохранить кадр в тестовых целях можно так:
cv2.imwrite("test.jpg", frame)
После этого мы можем убедиться в том, что камера работает. Замечательно!
Надеюсь, вы не против моей бороды
Обнаружение фона
Теперь, когда у нас есть доступ к видеопотоку, подумаем о том, как обнаружить фон, сделав так, чтобы, найдя его, его можно было бы заменить. А вот это — уже достаточно сложная задача.
Хотя возникает такое ощущение, что создатели Zoom нигде не рассказывают о том, как именно программа убирает фон, то, как ведёт себя система, заставляет меня задуматься о том, что тут не обошлось без нейронных сетей. Это сложно объяснить, но результаты выглядят именно так. Кроме того, я нашёл статью о том, как в Microsoft Teams реализовано размытие фона с помощью свёрточной нейронной сети.
В принципе, создать собственную нейронную сеть не так уж и сложно. Существует множество статей и научных трудов на тему сегментации изображений. Есть масса опенсорсных библиотек и инструментов. Но нам нужен, для получения хороших результатов, весьма специализированный датасет.
В частности, нам нужно множество изображений, напоминающих те, что получены с веб-камеры, с идеальной картинкой человека на переднем плане. Каждый пиксель такой картинки должен быть промаркирован как отличающийся от фона.
Построение такого датасета при подготовке к обучению нейронной сети, возможно, не потребует больших усилий. Это — благодаря тому, что команда исследователей из Google уже сделала всё самое тяжёлое и выложила в опенсорс предварительно обученную нейронную сеть для сегментации людей. Эта сеть называется BodyPix. Работает она очень хорошо!
Сеть BodyPix сейчас доступна лишь в форме, подходящей для TensorFlow.js. В результате, легче всего её применить, воспользовавшись библиотекой body-pix-node.
Для ускорения получения вывода сети (прогноза) в браузере предпочтительно пользоваться WebGL-бэкендом, но в среде Node.js можно воспользоваться бэкендом Tensorflow GPU (обратите внимание на то, что для этого понадобится видеокарта от NVIDIA, которая у меня есть).
Для того чтобы упростить настройку проекта, воспользуемся маленьким контейнеризованным окружением, обеспечивающим работу TensorFlow GPU и Node.js. Использование всего этого с помощью nvidia-docker — гораздо легче, чем самостоятельный сбор необходимых зависимостей на своём компьютере. Для этого на компьютере понадобится лишь наличие Docker и актуальных графических драйверов.
Вот содержимое файла bodypix/package.json
:
{
"name": "bodypix",
"version": "0.0.1",
"dependencies": {
"@tensorflow-models/body-pix": "^2.0.5",
"@tensorflow/tfjs-node-gpu": "^1.7.1"
}
}
Вот — файл bodypix/Dockerfile
:
# Базовый образ, соответствующий требованиям TensorFlow GPU
FROM nvcr.io/nvidia/cuda:10.0-cudnn7-runtime-ubuntu18.04
# Установим node
RUN apt update && apt install -y curl make build-essential \
&& curl -sL https://deb.nodesource.com/setup_12.x | bash - \
&& apt-get -y install nodejs \
&& mkdir /.npm \
&& chmod 777 /.npm
# Проверим, имеется ли достаточно видеопамяти
# К сожалению tfjs-node-gpu не даёт сведений о конфигурации GPU :(
ENV TF_FORCE_GPU_ALLOW_GROWTH=true
# Устанавливаем node-зависимости
WORKDIR /src
COPY package.json /src/
RUN npm install
# Задаём приложение в качестве точки входа
COPY app.js /src/
ENTRYPOINT node /src/app.js
Теперь поговорим о получении результатов. Но сразу предупреждаю: я не отношусь к Node.js-экспертам! Это — лишь результат моих вечерних экспериментов, поэтому будьте ко мне снисходительны :-).
Следующий простой скрипт занят обработкой изображения с двоичной маской, отправленного на сервер с использованием HTTP POST-запроса. Маска — это двумерный массив пикселей. Пиксели, представленные нулями — это фон.
Вот — код файла app.js
:
const tf = require('@tensorflow/tfjs-node-gpu');
const bodyPix = require('@tensorflow-models/body-pix');
const http = require('http');
(async () => {
const net = await bodyPix.load({
architecture: 'MobileNetV1',
outputStride: 16,
multiplier: 0.75,
quantBytes: 2,
});
const server = http.createServer();
server.on('request', async (req, res) => {
var chunks = [];
req.on('data', (chunk) => {
chunks.push(chunk);
});
req.on('end', async () => {
const image = tf.node.decodeImage(Buffer.concat(chunks));
segmentation = await net.segmentPerson(image, {
flipHorizontal: false,
internalResolution: 'medium',
segmentationThreshold: 0.7,
});
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
res.write(Buffer.from(segmentation.data));
res.end();
tf.dispose(image);
});
});
server.listen(9000);
})();
Для преобразования кадра в маску мы, в Python-скрипте, можем воспользоваться пакетами numpy и requests:
def get_mask(frame, bodypix_url='http://localhost:9000'):
_, data = cv2.imencode(".jpg", frame)
r = requests.post(
url=bodypix_url,
data=data.tobytes(),
headers={'Content-Type': 'application/octet-stream'})
# преобразуем необработанные байты в numpy-массив
# необработанные данные выглядят как uint8[width * height] со значениями 0 или 1
mask = np.frombuffer(r.content, dtype=np.uint8)
mask = mask.reshape((frame.shape[0], frame.shape[1]))
return mask
В результате получается примерно следующее.
Маска
Пока я всем этим занимался, я наткнулся на следующий твит.
Это, определённо, самый лучший фон для видеозвонков
Теперь, когда у нас имеется маска для отделения переднего плана от фона, заменить фон на что-то другое будет очень просто.
Я взял из ветки того твита фоновое изображение и обрезал его так, чтобы получилась бы картинка формата 16×9.
Фоновое изображение
После этого я сделал следующее:
# читаем виртуальный фон (соотношение сторон изображения должно быть 16:9)
replacement_bg_raw = cv2.imread("background.jpg")
# меняем размер изображения так, чтобы оно вписалось бы в кадр (width & height из предыдущего кода)
width, height = 720, 1280
replacement_bg = cv2.resize(replacement_bg_raw, (width, height))
# комбинируем фон и передний план, используя инвертированную маску
inv_mask = 1-mask
for c in range(frame.shape[2]):
frame[:,:,c] = frame[:,:,c]*mask + replacement_bg[:,:,c]*inv_mask
Вот что у меня после этого получилось.
Результат замены фона
Такая маска, очевидно, недостаточно точна, причиной этого являются те компромиссы в сфере производительности, на которые мы пошли, настраивая BodyPix. В общем, пока всё выглядит более или менее терпимо.
Но, когда я смотрел на этот фон, меня посетила одна идея.
Интересные эксперименты
Теперь, когда мы разобрались с маскированием, зададимся вопросом о том, как улучшить результат.
Первый очевидный шаг заключается в смягчении краёв маски. Например, это можно сделать так:
def post_process_mask(mask):
mask = cv2.dilate(mask, np.ones((10,10), np.uint8) , iterations=1)
mask = cv2.erode(mask, np.ones((10,10), np.uint8) , iterations=1)
return mask
Это позволит немного улучшить ситуацию, но особого продвижения вперёд тут нет. Да и простая замена — дело довольно-таки скучное. Но, так как мы дошли до всего этого сами, это значит, что мы можем сделать с картинкой всё что угодно, а не просто убрать фон.
Учитывая то, что мы используем виртуальный фон из «Звёздных войн», я решил создать эффект голограммы для того чтобы сделать картинку интереснее. Это, кроме того, позволяет сгладить размытие маски.
Для начала обновим код пост-процессинга:
def post_process_mask(mask):
mask = cv2.dilate(mask, np.ones((10,10), np.uint8) , iterations=1)
mask = cv2.blur(mask.astype(float), (30,30))
return mask
Края теперь оказываются размытыми. Это хорошо, но нам ещё нужно создать эффект голограммы.
Голливудские голограммы обычно отличаются следующими свойствами:
- Бледная цветная или монохромная картинка — будто нарисованная ярким лазером.
- Эффект, напоминающий линии развёртки или нечто вроде сетки — будто изображение выводится несколькими лучами.
- «Эффект привидения» — будто бы проекция выполняется послойно или так, как если бы при создании проекции не выдерживалось бы правильное расстояние, на котором она должна выводиться.
Все эти эффекты можно реализовать пошагово.
Сначала, чтобы окрасить изображение в оттенок синего, можем воспользоваться методом applyColorMap
:
# делаем мэппинг кадра в сине-зелёное цветовое пространство
holo = cv2.applyColorMap(frame, cv2.COLORMAP_WINTER)
Далее — добавляем линии развёртки с эффектом, напоминающим уход в полутона:
# для каждой строки bandLength уменьшить яркость на 10-30%,
# не трогать строки bandGap.
bandLength, bandGap = 2, 3
for y in range(holo.shape[0]):
if y % (bandLength+bandGap) < bandLength:
holo[y,:,:] = holo[y,:,:] * np.random.uniform(0.1, 0.3)
Далее, реализуем «эффект привидения», добавляя к изображению сдвинутые взвешенные копии текущего эффекта:
# shift_img из: https://stackoverflow.com/a/53140617
def shift_img(img, dx, dy):
img = np.roll(img, dy, axis=0)
img = np.roll(img, dx, axis=1)
if dy>0:
img[:dy, :] = 0
elif dy<0:
img[dy:, :] = 0
if dx>0:
img[:, :dx] = 0
elif dx<0:
img[:, dx:] = 0
return img
# первый будет примерно таким: holo * 0.2 + shifted_holo * 0.8 + 0
holo2 = cv2.addWeighted(holo, 0.2, shift_img(holo1.copy(), 5, 5), 0.8, 0)
holo2 = cv2.addWeighted(holo2, 0.4, shift_img(holo1.copy(), -5, -5), 0.6, 0)
И наконец, мы хотим сохранить какие-то из исходных цветов, поэтому скомбинируем голографический эффект с оригинальным кадром, поступив примерно так, как при добавлении «эффекта привидения»:
holo_done = cv2.addWeighted(img, 0.5, holo2, 0.6, 0)
Вот как выглядит кадр с эффектом голограммы:
Кадр с эффектом голограммы
Сам по себе этот кадр выглядит неплохо.
Попробуем теперь совместить его с фоном.
Изображение, наложенное на фон
Готово! (обещаю — видео такого рода будет выглядеть интереснее).
Вывод видео
А теперь надо сказать о том, что мы тут кое-что упустили. Дело в том, что всем этим мы пока не можем воспользоваться для совершения видеозвонков.
Для того чтобы это исправить, воспользуемся pyfakewebcam и v4l2loopback для создания фиктивной веб-камеры.
Мы, кроме того, планируем прицепить эту камеру к Docker.
Сначала создадим файл fakecam/requirements.txt
с описанием зависимостей:
numpy==1.18.2
opencv-python==4.2.0.32
requests==2.23.0
pyfakewebcam==0.1.0
Теперь создадим файл fakecam/Dockerfile
для приложения, реализующего возможности фиктивной камеры:
FROM python:3-buster
# обеспечиваем актуальность pip
RUN pip install --upgrade pip
# устанавливаем зависимости opencv
RUN apt-get update && \
apt-get install -y \
`# opencv requirements` \
libsm6 libxext6 libxrender-dev \
`# opencv video opening requirements` \
libv4l-dev
# устанавливаем зависимости из requirements.txt
WORKDIR /src
COPY requirements.txt /src/
RUN pip install --no-cache-dir -r /src/requirements.txt
# копируем виртуальный фон
COPY background.jpg /data/
# запускаем скрипт фиктивной камеры (с небуферизованным выводом для упрощения отладки)
COPY fake.py /src/
ENTRYPOINT python -u fake.py
Теперь, из командной строки, установим v4l2loopback:
sudo apt install v4l2loopback-dkms
Настроим фиктивную камеру:
sudo modprobe -r v4l2loopback
sudo modprobe v4l2loopback devices=1 video_nr=20 card_label="v4l2loopback" exclusive_caps=1
Нам, для обеспечения работоспособности некоторых приложений (Chrome, Zoom), нужна настройка exclusive_caps
. Метка card_label
задаётся лишь для обеспечения удобства выбора камеры в приложениях. Указание номера video_nr=20
приводит к созданию устройства /dev/video20
в том случае, если соответствующий номер не занят, а он вряд ли будет занят.
Теперь внесём в скрипт изменения, позволяющие создать фиктивную камеру:
# тут, опять же, используются те же, что и раньше, значения width и height
fake = pyfakewebcam.FakeWebcam('/dev/video20', width, height)
Нужно отметить, что pyfakewebcam ожидает изображения с каналами RGB (Red, Green, Blue — красный, зелёный, синий), а Open CV работает с порядком каналов BGR (Blue, Green, Red).
Исправить это можно до вывода кадра, а затем отправить кадр так:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
fake.schedule_frame(frame)
Вот — полный код скрипта fakecam/fake.py
:
import os
import cv2
import numpy as np
import requests
import pyfakewebcam
def get_mask(frame, bodypix_url='http://localhost:9000'):
_, data = cv2.imencode(".jpg", frame)
r = requests.post(
url=bodypix_url,
data=data.tobytes(),
headers={'Content-Type': 'application/octet-stream'})
mask = np.frombuffer(r.content, dtype=np.uint8)
mask = mask.reshape((frame.shape[0], frame.shape[1]))
return mask
def post_process_mask(mask):
mask = cv2.dilate(mask, np.ones((10,10), np.uint8) , iterations=1)
mask = cv2.blur(mask.astype(float), (30,30))
return mask
def shift_image(img, dx, dy):
img = np.roll(img, dy, axis=0)
img = np.roll(img, dx, axis=1)
if dy>0:
img[:dy, :] = 0
elif dy<0:
img[dy:, :] = 0
if dx>0:
img[:, :dx] = 0
elif dx<0:
img[:, dx:] = 0
return img
def hologram_effect(img):
# окрашиваем в синий оттенок
holo = cv2.applyColorMap(img, cv2.COLORMAP_WINTER)
# добавляем эффект полутонов
bandLength, bandGap = 2, 3
for y in range(holo.shape[0]):
if y % (bandLength+bandGap) < bandLength:
holo[y,:,:] = holo[y,:,:] * np.random.uniform(0.1, 0.3)
# эффект привидения
holo_blur = cv2.addWeighted(holo, 0.2, shift_image(holo.copy(), 5, 5), 0.8, 0)
holo_blur = cv2.addWeighted(holo_blur, 0.4, shift_image(holo.copy(), -5, -5), 0.6, 0)
# комбинируем с перенасыщенным исходным цветом
out = cv2.addWeighted(img, 0.5, holo_blur, 0.6, 0)
return out
def get_frame(cap, background_scaled):
_, frame = cap.read()
# загружаем маску с поддержкой повторов (приложению нужно разогреться, а мы ленивы)
# всё будет согласовано по прошествии некоторого времени
mask = None
while mask is None:
try:
mask = get_mask(frame)
except requests.RequestException:
print("mask request failed, retrying")
# пост-процессинг маски и кадра
mask = post_process_mask(mask)
frame = hologram_effect(frame)
# комбинируем фон и передний план
inv_mask = 1-mask
for c in range(frame.shape[2]):
frame[:,:,c] = frame[:,:,c]*mask + background_scaled[:,:,c]*inv_mask
return frame
# настраиваем доступ к реальной камере
cap = cv2.VideoCapture('/dev/video0')
height, width = 720, 1280
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
cap.set(cv2.CAP_PROP_FPS, 60)
# настраиваем фиктивную камеру
fake = pyfakewebcam.FakeWebcam('/dev/video20', width, height)
# загружаем новый виртуальный фон
background = cv2.imread("/data/background.jpg")
background_scaled = cv2.resize(background, (width, height))
# вечный цикл перебора кадров
while True:
frame = get_frame(cap, background_scaled)
# фиктивная камера ожидает RGB-изображение
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
fake.schedule_frame(frame)
Теперь соберём образы:
docker build -t bodypix ./bodypix
docker build -t fakecam ./fakecam
Запустим их:
# создадим сеть
docker network create --driver bridge fakecam
# запустим приложение bodypix
docker run -d \
--name=bodypix \
--network=fakecam \
--gpus=all --shm-size=1g --ulimit memlock=-1 --ulimit stack=67108864 \
bodypix
# запустим камеру, учтя то, что нам нужно пройтись по видеоустройствам, и то,
# что пользователь и группа должны иметь разрешение на доступ к ним
# возможно, придётся воспользоваться командой вида `sudo groupadd $USER video`
docker run -d \
--name=fakecam \
--network=fakecam \
-p 8080:8080 \
-u "$$(id -u):$$(getent group video | cut -d: -f3)" \
$$(find /dev -name 'video*' -printf "--device %p ") \
fakecam
Осталось учесть лишь то, что это надо запустить до открытия камеры при работе с любыми приложениями. А в Zoom или где-то ещё нужно выбрать камеру v4l2loopback
/ /dev/video20
.
Итоги
Вот клип, который демонстрирует результаты моей работы.
Your browser does not support HTML5 video.
Результат замены фона
Видите! Я звоню с Сокола Тысячелетия, пользуясь опенсорсным стеком технологий для работы с камерой!
То, что у меня получилось, мне очень понравилось. И я, определённо, всем этим воспользуюсь на следующей видеоконференции.
Уважаемые читатели! Планируете ли вы поменять то, что видно при видеозвонках у вас за спиной, на что-то другое?