- PVSM.RU - https://www.pvsm.ru -
Сейчас, когда многие из нас находятся на карантине [1] из-за COVID-19 [2], видеозвонки стали куда более частым явлением, чем раньше. В частности, сервис ZOOM [3] неожиданно [4] стал очень популярным. Вероятно, самой интересной возможностью Zoom является поддержка виртуального фона (Virtual Background [5]). Она позволяет пользователям, в интерактивном режиме, заменять фон, находящийся позади них, на любое изображение или видео.
Я уже давно применяю Zoom на работе, на опенсорсных встречах, посвящённых Kubernetes, делая это обычно с корпоративного ноутбука. Теперь я, в режиме работы из дома, склонен к использованию более мощного и удобного персонального настольного компьютера для решения некоторых из моих опенсорсных задач.
К несчастью, Zoom поддерживает лишь способ удаления фона, известный как «хромакей [6]» или «зелёный экран [7]». Для использования этого метода нужно, чтобы фон был бы представлен неким сплошным цветом, в идеале — зелёным, и был бы равномерно освещён.
Так как зелёного экрана у меня нет, я решил просто реализовать собственную систему удаления фона. А это, конечно, куда лучше, чем наведение порядка в квартире, или постоянное использование рабочего ноутбука.
Как оказалось, применив готовые опенсорсные компоненты и написав буквально несколько строк собственного кода, можно получить весьма достойные результаты.
Начнём с начала и ответим на следующий вопрос: «Как получить видео с веб-камеры, которое будем обрабатывать?».
Так как я на домашнем компьютере использую Linux (когда не играю в игры), я решил использовать Python-привязки [8] Open CV [9], с которыми я уже знаком. Они, помимо V4L2 [10]-биндингов для чтения данных с веб-камеры, включают в себя и полезные базовые функции по обработке видео.
Чтение кадра с веб-камеры в 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 [11] реализовано размытие фона [12] с помощью свёрточной нейронной сети [13].
В принципе, создать собственную нейронную сеть не так уж и сложно. Существует множество статей и научных трудов на тему сегментации изображений [14]. Есть масса опенсорсных библиотек и инструментов. Но нам нужен, для получения хороших результатов, весьма специализированный датасет.
В частности, нам нужно множество изображений, напоминающих те, что получены с веб-камеры, с идеальной картинкой человека на переднем плане. Каждый пиксель такой картинки должен быть промаркирован как отличающийся от фона.
Построение такого датасета при подготовке к обучению нейронной сети, возможно, не потребует больших усилий. Это — благодаря тому, что команда исследователей из Google уже сделала всё самое тяжёлое и выложила в опенсорс предварительно обученную нейронную сеть для сегментации людей. Эта сеть называется BodyPix [15]. Работает она очень хорошо!
Сеть BodyPix сейчас доступна лишь в форме, подходящей для TensorFlow.js [16]. В результате, легче всего её применить, воспользовавшись библиотекой body-pix-node [17].
Для ускорения получения вывода сети [18] (прогноза) в браузере предпочтительно пользоваться WebGL [19]-бэкендом, но в среде Node.js [20] можно воспользоваться бэкендом Tensorflow GPU [21] (обратите внимание на то, что для этого понадобится видеокарта от NVIDIA [22], которая у меня есть).
Для того чтобы упростить настройку проекта, воспользуемся маленьким контейнеризованным окружением, обеспечивающим работу TensorFlow GPU и Node.js. Использование всего этого с помощью nvidia-docker [23] — гораздо легче, чем самостоятельный сбор необходимых зависимостей на своём компьютере. Для этого на компьютере понадобится лишь наличие 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 [24] и requests [25]:
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
В результате получается примерно следующее.
Маска
Пока я всем этим занимался, я наткнулся на следующий [26] твит.
Это, определённо, самый лучший фон для видеозвонков
Теперь, когда у нас имеется маска для отделения переднего плана от фона, заменить фон на что-то другое будет очень просто.
Я взял из ветки того твита фоновое изображение и обрезал его так, чтобы получилась бы картинка формата 16x9.
Фоновое изображение
После этого я сделал следующее:
# читаем виртуальный фон (соотношение сторон изображения должно быть 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 [27] и v4l2loopback [28] для создания фиктивной веб-камеры.
Мы, кроме того, планируем прицепить эту камеру к 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
.
Вот клип, который демонстрирует результаты моей работы.
Результат замены фона
Видите! Я звоню с Сокола Тысячелетия, пользуясь опенсорсным стеком технологий для работы с камерой!
То, что у меня получилось, мне очень понравилось. И я, определённо, всем этим воспользуюсь на следующей видеоконференции.
Уважаемые читатели! Планируете ли вы поменять то, что видно при видеозвонках у вас за спиной, на что-то другое?
Автор: ru_vds
Источник [29]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/razrabotka/352406
Ссылки в тексте:
[1] карантине: https://www.sfchronicle.com/local-politics/article/Bay-Area-must-shelter-in-place-Only-15135014.php
[2] COVID-19: https://www.cdc.gov/coronavirus/2019-ncov/index.html
[3] ZOOM: https://zoom.us/
[4] неожиданно: https://en.wikipedia.org/wiki/Zoom_Video_Communications#Criticism
[5] Virtual Background: https://support.zoom.us/hc/en-us/articles/210707503-Virtual-Background
[6] хромакей: https://ru.wikipedia.org/wiki/%D0%A5%D1%80%D0%BE%D0%BC%D0%B0%D0%BA%D0%B5%D0%B9
[7] зелёный экран: https://en.wikipedia.org/wiki/Green_screen_(disambiguation)
[8] Python-привязки: https://pypi.org/project/opencv-python/
[9] Open CV: https://opencv.org/
[10] V4L2: https://en.wikipedia.org/wiki/Video4Linux
[11] Microsoft Teams: https://en.wikipedia.org/wiki/Microsoft_Teams
[12] размытие фона: https://en.pingwest.com/a/1579
[13] свёрточной нейронной сети: https://en.wikipedia.org/wiki/Convolutional_neural_network
[14] сегментации изображений: https://en.wikipedia.org/wiki/Image_segmentation
[15] BodyPix: https://blog.tensorflow.org/2019/11/updated-bodypix-2.html
[16] TensorFlow.js: https://www.tensorflow.org/js
[17] body-pix-node: https://www.npmjs.com/package/body-pix-node
[18] вывода сети: https://blogs.nvidia.com/blog/2016/08/22/difference-deep-learning-training-inference-ai/
[19] WebGL: https://en.wikipedia.org/wiki/WebGL
[20] Node.js: https://nodejs.org/en/
[21] Tensorflow GPU: https://www.tensorflow.org/install/gpu
[22] NVIDIA: https://www.nvidia.com/en-us/
[23] nvidia-docker: https://github.com/NVIDIA/nvidia-docker
[24] numpy: https://numpy.org/
[25] requests: https://requests.readthedocs.io/en/master/
[26] следующий: https://t.co/Urz62Kg32k
[27] pyfakewebcam: https://github.com/jremmons/pyfakewebcam
[28] v4l2loopback: https://github.com/umlaeute/v4l2loopback
[29] Источник: https://habr.com/ru/post/498458/?utm_source=habrahabr&utm_medium=rss&utm_campaign=498458
Нажмите здесь для печати.