具有虚拟背景和开源工具的视频通话

现在,由于COVID-19我们中的许多人被隔离了,视频通话比以前更加频繁。特别是,ZOOM服务突然变得非常流行。最有趣的缩放功能可能是对虚拟背景的支持。它允许用户以任何图像或视频交互地替换其背后的背景。



在Kubernetes上的开源会议上,我在工作中使用Zoom已有很长时间了,通常是使用企业笔记本电脑来实现的。现在,当我在家工作时,我倾向于使用功能更强大,更方便的个人台式计算机来解决我的一些开源任务。

不幸的是,缩放仅支持称为“ 色度键 ”或“ 绿屏的背景去除方法。要使用此方法,必须用某种纯色(理想情况下为绿色)表示背景并均匀照明。

由于我没有绿屏,因此我决定简单地实施自己的后台删除系统。当然,这比在公寓中整理物品或不断使用工作用的笔记本电脑要好得多。

事实证明,使用现成的开源组件并编写几行自己的代码,您可以获得非常不错的结果。

读取相机数据


让我们从头开始,回答以下问题:“如何从将要处理的网络摄像头中获取视频?”

由于我在家用计算机上使用Linux(当我不玩游戏时),因此我决定使用我已经知道Open CV Python绑定 除了用于从网络摄像头读取数据的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。效果很好!

现在仅以适用于TensorFlow.js的形式提供BodyPix。结果,最简单的方法是使用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请求

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

结果大致如下。


遮罩

在执行所有操作时,我遇到了一条推文。


绝对是视频通话的最佳背景,

现在我们有了一个将前景与背景分开的遮罩,用其他东西替换背景将非常简单。

我从tweet分支中获取了背景图像,并将其剪切后得到了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)

这是具有全息效果的框架的外观:


具有全息效果的

镜架此镜架本身看起来非常不错。

现在,让我们尝试将其与背景结合起来。


背景图像叠加。

完成!(我保证-这种视频看起来会更有趣)。

视频输出


现在我必须说,我们在这里错过了一些东西。事实是,我们仍然不能使用所有这些来进行视频通话。

为了解决这个问题,我们将使用pyfakewebcamv4l2loopback创建一个虚拟的网络摄像头。

此外,我们计划将此摄像头连接到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_capscard_label设置此标记仅是为了确保在应用程序中选择相机的便利。如果相应的号码不忙,并且不太可能忙,则指示号码video_nr=20会导致设备的创建/dev/video20

现在,我们将对脚本进行更改以创建虚拟摄像机:

# ,  ,   ,   ,  width  height
fake = pyfakewebcam.FakeWebcam('/dev/video20', width, height)

应该注意的是,pyfakewebcam期望具有RGB通道(红色,绿色,蓝色-红色,绿色,蓝色)的图像,而Open CV则按BGR通道(蓝色,绿色,红色)的顺序工作。

您可以在输出帧之前解决此问题,然后像这样发送帧:

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

摘要


这是演示我的工作成果的剪辑。


背景变化结果

见!我是从千禧猎鹰号召集的,使用的是开放源代码技术堆栈,可以用于相机!

我所做的,我真的很喜欢。在下一次视频会议上,我一定会利用所有这些优势。

亲爱的读者们!您是否打算将视频通话后的可见内容更改为其他内容?


All Articles