Videochamadas com fundo virtual e ferramentas de código aberto

Agora que muitos de nós estão em quarentena devido ao COVID-19 , as videochamadas se tornaram uma ocorrência muito mais frequente do que antes. Em particular, o serviço ZOOM de repente se tornou muito popular. Provavelmente, o recurso de zoom mais interessante é o suporte ao fundo virtual . Ele permite que os usuários substituam interativamente o plano de fundo atrás deles por qualquer imagem ou vídeo.



Eu uso o Zoom no trabalho há muito tempo, em reuniões de código aberto no Kubernetes, geralmente fazendo isso em um laptop corporativo. Agora, quando estou trabalhando em casa, estou inclinado a usar um computador pessoal mais poderoso e conveniente para resolver algumas das minhas tarefas de código aberto.

Infelizmente, o Zoom suporta apenas um método de remoção de fundo conhecido como " chroma key " ou " tela verde ". Para usar esse método, é necessário que o plano de fundo seja representado por alguma cor sólida, idealmente verde e seja uniformemente iluminado.

Como não tenho uma tela verde, decidi simplesmente implementar meu próprio sistema de remoção de plano de fundo. E isso, é claro, é muito melhor do que colocar as coisas em ordem no apartamento ou o uso constante de um laptop de trabalho.

Como se viu, usando componentes de código aberto prontos e escrevendo apenas algumas linhas do seu próprio código, você pode obter resultados muito decentes.

Lendo dados da câmera


Vamos começar do início e responder à seguinte pergunta: "Como obter vídeo de uma webcam que iremos processar?"

Como uso o Linux no meu computador doméstico (quando não estou jogando), decidi usar as ligações Open CV Python com as quais já estou familiarizado. Além das ligações V4L2 para ler dados de uma webcam, elas incluem funções úteis básicas de processamento de vídeo.

Ler um quadro de uma webcam em python-opencv é muito simples:

import cv2
cap = cv2.VideoCapture('/dev/video0')
success, frame = cap.read()

Para melhorar os resultados ao trabalhar com minha câmera, apliquei as seguintes configurações antes de capturar o vídeo:

#    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)

Há uma sensação de que a maioria dos programas de videoconferência limita o vídeo a 720p a 30 FPS ou menos. Mas nós, de qualquer forma, podemos não ler todos os quadros. Essas configurações definem o limite superior.

Coloque o mecanismo de captura de quadros em um loop. Agora temos acesso ao fluxo de vídeo da câmera!

while True:
    success, frame = cap.read()

Você pode salvar o quadro para fins de teste da seguinte maneira:

cv2.imwrite("test.jpg", frame)

Depois disso, podemos garantir que a câmera esteja funcionando. Ótimo!


Espero que você não esteja contra minha barba

Detecção de fundo


Agora que temos acesso ao fluxo de vídeo, pensaremos em como detectar o plano de fundo, possibilitando substituí-lo e localizá-lo. Mas isso já é uma tarefa bastante difícil.

Embora haja a sensação de que os criadores do Zoom nunca falam exatamente sobre como o programa remove o fundo, a maneira como o sistema se comporta me faz pensar no que poderia ter sido feito sem redes neurais. É difícil de explicar, mas os resultados são exatamente assim. Além disso, encontrei um artigo sobre como o Microsoft Teams implementa o desfoque de fundo usando uma rede neural convolucional .

Em princípio, criar sua própria rede neural não é tão difícil. Existem muitos artigos e artigos científicos sobre segmentação de imagens.. Existem muitas bibliotecas e ferramentas de código aberto. Mas precisamos de um conjunto de dados muito especializado para obter bons resultados.

Em particular, precisamos de muitas imagens remanescentes das obtidas em uma webcam, com uma imagem perfeita de uma pessoa em primeiro plano. Cada pixel dessa imagem deve ser marcado como diferente do plano de fundo.

Construir esse conjunto de dados em preparação para o treinamento de uma rede neural pode não exigir muito esforço. Isso se deve ao fato de a equipe de pesquisadores do Google já ter feito o máximo possível e colocar no código aberto uma rede neural pré-treinada para segmentar pessoas. Essa rede é chamada BodyPix . Funciona muito bem!

Agora, o BodyPix está disponível apenas em um formato adequado para TensorFlow.js. Como resultado, é mais fácil aplicar usando a biblioteca body-pix-node .

Para acelerar a saída de rede (previsão) no navegador, é preferível usar o back-end WebGL , mas no ambiente Node.js você pode usar o back-end Tensorflow GPU (observe que isso exigirá uma placa de vídeo da NVIDIA , que eu tenho).

Para simplificar a configuração do projeto, usaremos um pequeno ambiente em contêiner que fornece a GPU TensorFlow e o Node.js. Usando tudo isso com nvidia-docker- muito mais fácil do que coletar as dependências necessárias no seu computador. Para fazer isso, você só precisa do Docker e dos drivers gráficos mais recentes no seu computador.

Aqui está o conteúdo do arquivo bodypix/package.json:

{
    "name": "bodypix",
    "version": "0.0.1",
    "dependencies": {
        "@tensorflow-models/body-pix": "^2.0.5",
        "@tensorflow/tfjs-node-gpu": "^1.7.1"
    }
}

Aqui está o arquivo 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

Agora vamos falar sobre como obter resultados. Mas eu te aviso imediatamente: não sou especialista em Node.js. Este é apenas o resultado das minhas experiências noturnas, portanto, seja indulgente comigo :-).

O script simples a seguir está ocupado processando uma imagem de máscara binária enviada ao servidor usando uma solicitação HTTP POST. Uma máscara é uma matriz bidimensional de pixels. Pixels representados por zeros são o plano de fundo.

Aqui está o código do arquivo 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);
})();

Para converter um quadro em uma máscara, nós, em um script Python, podemos usar os pacotes numpy e request :

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

O resultado é aproximadamente o seguinte.


Máscara

Enquanto fazia tudo isso, me deparei com o próximo tweet.


Esse é definitivamente o melhor plano de fundo para videochamadas.

Agora que temos uma máscara para separar o primeiro plano do segundo plano, a substituição do segundo plano por outra será muito simples.

Peguei a imagem de fundo do ramo do tweet e cortei para obter uma imagem 16x9.


Imagem de fundo

Depois disso, fiz o seguinte:

#    (     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

Foi o que consegui depois disso.


O resultado da substituição do plano de fundo:

obviamente, essa máscara não é precisa o suficiente, a razão disso são as compensações de desempenho que fizemos ao configurar o BodyPix. Em geral, enquanto tudo parece mais ou menos tolerante.

Mas, quando olhei para esse pano de fundo, uma ideia me ocorreu.

Experiências interessantes


Agora que descobrimos como mascarar, perguntaremos como melhorar o resultado.

O primeiro passo óbvio é suavizar as bordas da máscara. Por exemplo, isso pode ser feito assim:

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

Isso melhorará um pouco a situação, mas não há muito progresso. E uma simples substituição é bastante chata. Mas, como nós mesmos resolvemos tudo isso, isso significa que podemos fazer qualquer coisa com a imagem, e não apenas remover o fundo.

Dado que estamos usando um plano de fundo virtual de Star Wars, decidi criar um efeito de holograma para tornar a imagem mais interessante. Além disso, isso permite suavizar o desfoque da máscara.

Primeiro, atualize o código de pós-processamento:

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

As bordas estão agora borradas. Isso é bom, mas ainda precisamos criar um efeito de holograma.

Os hologramas de Hollywood geralmente têm as seguintes propriedades:

  • Uma imagem pálida ou monocromática - como se estivesse sendo desenhada por um laser brilhante.
  • Um efeito que lembra linhas de varredura ou algo como uma grade - como se a imagem fosse exibida em vários raios.
  • “Efeito fantasma” - como se a projeção fosse executada em camadas ou como se a distância correta em que deveria ser exibida não fosse mantida durante a criação da projeção.

Todos esses efeitos podem ser implementados passo a passo.

Primeiro, para colorir a imagem em um tom de azul, podemos usar o método applyColorMap:

#     -  
holo = cv2.applyColorMap(frame, cv2.COLORMAP_WINTER)

Em seguida - adicione uma linha de varredura com um efeito que lembra deixar em meio-tom:

#    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)

Em seguida, implementamos o "efeito fantasma" adicionando cópias ponderadas deslocadas do efeito atual à imagem:

# 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)

E, finalmente, queremos manter algumas das cores originais, para combinar o efeito holográfico com o quadro original, fazendo algo semelhante à adição do "efeito fantasma":

holo_done = cv2.addWeighted(img, 0.5, holo2, 0.6, 0)

Aqui está a aparência de um quadro com efeito de holograma:


Um quadro com efeito de holograma

Esse quadro em si parece muito bom.

Agora vamos tentar combiná-lo com o plano de fundo.


Sobreposta imagem no fundo.

Feito! (Eu prometo - esse tipo de vídeo ficará mais interessante).

Saida de video


E agora devo dizer que perdemos algo aqui. O fato é que ainda não podemos usar tudo isso para fazer chamadas de vídeo.

Para corrigir isso, usaremos pyfakewebcam e v4l2loopback para criar uma webcam fantasma.

Além disso, planejamos conectar esta câmera ao Docker.

Primeiro, crie um arquivo de fakecam/requirements.txtdescrição de dependência:

numpy==1.18.2
opencv-python==4.2.0.32
requests==2.23.0
pyfakewebcam==0.1.0

Agora crie um arquivo fakecam/Dockerfilepara o aplicativo que implementa os recursos de uma câmera fictícia:

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

Agora, na linha de comandos, instale o v4l2loopback:

sudo apt install v4l2loopback-dkms

Configure uma câmera fictícia:

sudo modprobe -r v4l2loopback
sudo modprobe v4l2loopback devices=1 video_nr=20 card_label="v4l2loopback" exclusive_caps=1

Para garantir a funcionalidade de alguns aplicativos (Chrome, Zoom), precisamos de uma configuração exclusive_caps. A marca card_labelé definida apenas para garantir a conveniência de escolher uma câmera em aplicativos. A indicação do número video_nr=20leva à criação do dispositivo /dev/video20se o número correspondente não estiver ocupado e é improvável que esteja ocupado.

Agora, faremos alterações no script para criar uma câmera fictícia:

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

Note-se que o pyfakewebcam espera imagens com canais RGB (vermelho, verde, azul - vermelho, verde, azul), e o Open CV funciona com a ordem dos canais BGR (azul, verde, vermelho).

Você pode corrigir isso antes de gerar o quadro e enviar o quadro da seguinte maneira:

frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
fake.schedule_frame(frame)

Aqui está o código completo do script 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)

Agora colete as imagens:

docker build -t bodypix ./bodypix
docker build -t fakecam ./fakecam

Execute-os:

#  
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

Resta apenas considerar que isso deve ser iniciado antes que a câmera seja aberta ao trabalhar com qualquer aplicativo. E no Zoom ou em outro lugar, você precisa selecionar uma câmera v4l2loopback/ /dev/video20.

Sumário


Aqui está um clipe que demonstra os resultados do meu trabalho.


Resultado da mudança de fundo

Veja! Estou ligando do Millennium Falcon usando a pilha de tecnologia de código aberto para trabalhar com a câmera!

O que eu fiz, gostei muito. Definitivamente vou aproveitar tudo isso na próxima videoconferência.

Queridos leitores! Você planeja alterar o que é visível durante as videochamadas atrás de você para outra coisa?


All Articles